From ed8d3f3e8d48e487bc17733d3e0a07bbb53c19e3 Mon Sep 17 00:00:00 2001 From: tmimmanuel <14046872+tmimmanuel@users.noreply.github.com> Date: Fri, 24 Apr 2026 00:30:28 +0200 Subject: [PATCH 001/128] refactor(api): fix pyright errors in jieba, milvus, couchbase, oracle, and router (#34938) Co-authored-by: Asuka Minato --- .../rag/datasource/keyword/jieba/jieba.py | 3 +- .../jieba/jieba_keyword_table_handler.py | 2 +- .../multi_dataset_function_call_router.py | 2 +- .../dify_vdb_couchbase/couchbase_vector.py | 4 +- .../src/dify_vdb_milvus/milvus_vector.py | 7 ++-- .../src/dify_vdb_oracle/oraclevector.py | 40 +++++++++++-------- 6 files changed, 34 insertions(+), 24 deletions(-) diff --git a/api/core/rag/datasource/keyword/jieba/jieba.py b/api/core/rag/datasource/keyword/jieba/jieba.py index 242da520c1..392af351b6 100644 --- a/api/core/rag/datasource/keyword/jieba/jieba.py +++ b/api/core/rag/datasource/keyword/jieba/jieba.py @@ -156,7 +156,8 @@ class Jieba(BaseKeyword): if dataset_keyword_table: keyword_table_dict = dataset_keyword_table.keyword_table_dict if keyword_table_dict: - return dict(keyword_table_dict["__data__"]["table"]) + data: Any = keyword_table_dict["__data__"] + return dict(data["table"]) else: keyword_data_source_type = dify_config.KEYWORD_DATA_SOURCE_TYPE dataset_keyword_table = DatasetKeywordTable( diff --git a/api/core/rag/datasource/keyword/jieba/jieba_keyword_table_handler.py b/api/core/rag/datasource/keyword/jieba/jieba_keyword_table_handler.py index 1ca6303af6..2af8238cc4 100644 --- a/api/core/rag/datasource/keyword/jieba/jieba_keyword_table_handler.py +++ b/api/core/rag/datasource/keyword/jieba/jieba_keyword_table_handler.py @@ -109,7 +109,7 @@ class JiebaKeywordTableHandler: """Extract keywords with JIEBA tfidf.""" keywords = self._tfidf.extract_tags( sentence=text, - topK=max_keywords_per_chunk, + topK=max_keywords_per_chunk or 10, ) # jieba.analyse.extract_tags returns an untyped list when withFlag is False by default. keywords = cast(list[str], keywords) diff --git a/api/core/rag/retrieval/router/multi_dataset_function_call_router.py b/api/core/rag/retrieval/router/multi_dataset_function_call_router.py index 426d1b67dc..dd17545c86 100644 --- a/api/core/rag/retrieval/router/multi_dataset_function_call_router.py +++ b/api/core/rag/retrieval/router/multi_dataset_function_call_router.py @@ -31,7 +31,7 @@ class FunctionCallMultiDatasetRouter: result: LLMResult = model_instance.invoke_llm( # pyright: ignore[reportCallIssue, reportArgumentType] prompt_messages=prompt_messages, tools=dataset_tools, - stream=False, + stream=False, # pyright: ignore[reportArgumentType] model_parameters={"temperature": 0.2, "top_p": 0.3, "max_tokens": 1500}, ) usage = result.usage or LLMUsage.empty_usage() diff --git a/api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/couchbase_vector.py b/api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/couchbase_vector.py index 815ac30c0b..bab176e285 100644 --- a/api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/couchbase_vector.py +++ b/api/providers/vdb/vdb-couchbase/src/dify_vdb_couchbase/couchbase_vector.py @@ -59,7 +59,7 @@ class CouchbaseVector(BaseVector): auth = PasswordAuthenticator(config.user, config.password) options = ClusterOptions(auth) - self._cluster = Cluster(config.connection_string, options) + self._cluster = Cluster(config.connection_string, options) # pyright: ignore[reportArgumentType] self._bucket = self._cluster.bucket(config.bucket_name) self._scope = self._bucket.scope(config.scope_name) self._bucket_name = config.bucket_name @@ -306,7 +306,7 @@ class CouchbaseVector(BaseVector): def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]: top_k = kwargs.get("top_k", 4) try: - CBrequest = search.SearchRequest.create(search.QueryStringQuery("text:" + query)) + CBrequest = search.SearchRequest.create(search.QueryStringQuery("text:" + query)) # pyright: ignore[reportCallIssue] search_iter = self._scope.search( self._collection_name + "_search", CBrequest, SearchOptions(limit=top_k, fields=["*"]) ) diff --git a/api/providers/vdb/vdb-milvus/src/dify_vdb_milvus/milvus_vector.py b/api/providers/vdb/vdb-milvus/src/dify_vdb_milvus/milvus_vector.py index 46f3224a95..823b877707 100644 --- a/api/providers/vdb/vdb-milvus/src/dify_vdb_milvus/milvus_vector.py +++ b/api/providers/vdb/vdb-milvus/src/dify_vdb_milvus/milvus_vector.py @@ -1,6 +1,6 @@ import json import logging -from typing import Any, TypedDict +from typing import Any, TypedDict, cast from packaging import version from pydantic import BaseModel, model_validator @@ -92,7 +92,7 @@ class MilvusVector(BaseVector): def _load_collection_fields(self, fields: list[str] | None = None): if fields is None: # Load collection fields from remote server - collection_info = self._client.describe_collection(self._collection_name) + collection_info = cast(dict[str, Any], self._client.describe_collection(self._collection_name)) fields = [field["name"] for field in collection_info["fields"]] # Since primary field is auto-id, no need to track it self._fields = [f for f in fields if f != Field.PRIMARY_KEY] @@ -106,7 +106,8 @@ class MilvusVector(BaseVector): return False try: - milvus_version = self._client.get_server_version() + milvus_version_raw = self._client.get_server_version() + milvus_version = milvus_version_raw if isinstance(milvus_version_raw, str) else str(milvus_version_raw) # Check if it's Zilliz Cloud - it supports full-text search with Milvus 2.5 compatibility if "Zilliz Cloud" in milvus_version: return True diff --git a/api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py b/api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py index 70377c82c8..5d9ab38529 100644 --- a/api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py +++ b/api/providers/vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py @@ -3,7 +3,7 @@ import json import logging import re import uuid -from typing import Any +from typing import Any, TypedDict import jieba.posseg as pseg # type: ignore import numpy @@ -25,6 +25,18 @@ logger = logging.getLogger(__name__) oracledb.defaults.fetch_lobs = False +class _OraclePoolParams(TypedDict, total=False): + user: str + password: str + dsn: str + min: int + max: int + increment: int + config_dir: str | None + wallet_location: str | None + wallet_password: str | None + + class OracleVectorConfig(BaseModel): user: str password: str @@ -127,22 +139,18 @@ class OracleVector(BaseVector): return connection def _create_connection_pool(self, config: OracleVectorConfig): - pool_params = { - "user": config.user, - "password": config.password, - "dsn": config.dsn, - "min": 1, - "max": 5, - "increment": 1, - } + pool_params = _OraclePoolParams( + user=config.user, + password=config.password, + dsn=config.dsn, + min=1, + max=5, + increment=1, + ) if config.is_autonomous: - pool_params.update( - { - "config_dir": config.config_dir, - "wallet_location": config.wallet_location, - "wallet_password": config.wallet_password, - } - ) + pool_params["config_dir"] = config.config_dir + pool_params["wallet_location"] = config.wallet_location + pool_params["wallet_password"] = config.wallet_password return oracledb.create_pool(**pool_params) def create(self, texts: list[Document], embeddings: list[list[float]], **kwargs): From 38fc2a6574d8779d7df804caea80d88d41d1f337 Mon Sep 17 00:00:00 2001 From: Joel Date: Fri, 24 Apr 2026 10:32:06 +0800 Subject: [PATCH 002/128] feat: support key up and down to select variable item (#35527) --- .../__tests__/index.spec.tsx | 42 ++++ .../plugins/component-picker-block/index.tsx | 10 +- .../__tests__/var-reference-vars.spec.tsx | 95 ++++++++ .../variable/var-reference-vars.tsx | 214 ++++++++++++++---- 4 files changed, 318 insertions(+), 43 deletions(-) diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx index 3c734700a7..a09e25f6e9 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/__tests__/index.spec.tsx @@ -599,6 +599,48 @@ describe('ComponentPicker (component-picker-block/index.tsx)', () => { }) }) + it('defaults to the first workflow variable and removes the full slash query when selecting by keyboard', async () => { + const captures: Captures = { editor: null, eventEmitter: null } + + const workflowVariableBlock = makeWorkflowVariableBlock({}, [ + makeWorkflowVarNode('node-1', 'Node 1', [ + makeWorkflowNodeVar('first_value', VarType.string), + makeWorkflowNodeVar('second_value', VarType.string), + ]), + ]) + + render(( + + )) + + const editor = await waitForEditor(captures) + const dispatchSpy = vi.spyOn(editor, 'dispatchCommand') + + await setEditorText(editor, '/e', true) + await flushNextTick() + + const firstItem = screen.getByText('first_value').closest('[data-selected]') + const secondItem = screen.getByText('second_value').closest('[data-selected]') + + expect(firstItem).toHaveAttribute('data-selected', 'true') + expect(secondItem).toHaveAttribute('data-selected', 'false') + + fireEvent.keyDown(document, { key: 'ArrowDown' }) + + expect(firstItem).toHaveAttribute('data-selected', 'false') + expect(secondItem).toHaveAttribute('data-selected', 'true') + + fireEvent.keyDown(document, { key: 'Enter' }) + + expect(dispatchSpy).toHaveBeenCalledWith(INSERT_WORKFLOW_VARIABLE_BLOCK_COMMAND, ['node-1', 'second_value']) + await waitFor(() => expect(readEditorText(editor)).not.toContain('/e')) + }) + it('skips removing the trigger when selection is null (needRemove is null) and still dispatches', async () => { const captures: Captures = { editor: null, eventEmitter: null } diff --git a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx index 5e983ed09a..503af4077d 100644 --- a/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx +++ b/web/app/components/base/prompt-editor/plugins/component-picker-block/index.tsx @@ -7,6 +7,7 @@ import type { ExternalToolBlockType, HistoryBlockType, LastRunBlockType, + MenuTextMatch, QueryBlockType, RequestURLBlockType, VariableBlockType, @@ -89,14 +90,14 @@ const ComponentPicker = ({ ], }) const [editor] = useLexicalComposerContext() - const triggerMatchRef = useRef(null) + const triggerMatchRef = useRef(null) const baseCheckForTriggerMatch = useBasicTypeaheadTriggerMatch(triggerString, { minLength: 0, maxLength: 75, }) const checkForTriggerMatch = useCallback((text: string, editor: LexicalEditor) => { const match = baseCheckForTriggerMatch(text, editor) - triggerMatchRef.current = match?.matchingString ?? null + triggerMatchRef.current = match return match }, [baseCheckForTriggerMatch]) @@ -183,7 +184,8 @@ const ComponentPicker = ({ const handleSelectWorkflowVariable = useCallback((variables: string[]) => { editor.update(() => { - const needRemove = $splitNodeContainingQuery(checkForTriggerMatch(triggerString, editor)!) + const currentTriggerMatch = triggerMatchRef.current ?? checkForTriggerMatch(triggerString, editor) + const needRemove = currentTriggerMatch ? $splitNodeContainingQuery(currentTriggerMatch) : null if (needRemove) needRemove.remove() }) @@ -214,7 +216,7 @@ const ComponentPicker = ({ anchorElementRef, { options, selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }, ) => { - const effectiveQueryString = triggerMatchRef.current ?? queryString + const effectiveQueryString = triggerMatchRef.current?.matchingString ?? queryString if (blurHidden) return null diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx index 372fcb3508..b8d1013db9 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-vars.spec.tsx @@ -52,6 +52,42 @@ describe('VarReferenceVars', () => { expect(onClose).toHaveBeenCalledTimes(1) }) + it('should select the first visible variable by default and support arrow navigation in slash mode', () => { + const onChange = vi.fn() + + render( + , + ) + + const firstItem = screen.getByText('first_value').closest('[data-selected]') + const secondItem = screen.getByText('second_value').closest('[data-selected]') + + expect(firstItem).toHaveAttribute('data-selected', 'true') + expect(secondItem).toHaveAttribute('data-selected', 'false') + + fireEvent.keyDown(document, { key: 'ArrowDown' }) + + expect(firstItem).toHaveAttribute('data-selected', 'false') + expect(secondItem).toHaveAttribute('data-selected', 'true') + + fireEvent.keyDown(document, { key: 'Enter' }) + + expect(onChange).toHaveBeenCalledWith(['node-a', 'second_value'], expect.objectContaining({ + variable: 'second_value', + })) + }) + it('should call onChange when a variable item is chosen', () => { const onChange = vi.fn() @@ -172,6 +208,43 @@ describe('VarReferenceVars', () => { expect(onChange).toHaveBeenNthCalledWith(4, ['node-special', 'asset'], expect.objectContaining({ variable: 'asset' })) }) + it('should resolve selectors for special variables and file support from keyboard selection', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.keyDown(document, { key: 'Enter' }) + fireEvent.keyDown(document, { key: 'ArrowDown' }) + fireEvent.keyDown(document, { key: 'Enter' }) + fireEvent.keyDown(document, { key: 'ArrowDown' }) + fireEvent.keyDown(document, { key: 'Enter' }) + fireEvent.keyDown(document, { key: 'ArrowDown' }) + fireEvent.keyDown(document, { key: 'Enter' }) + + expect(onChange).toHaveBeenNthCalledWith(1, ['env', 'API_KEY'], expect.objectContaining({ variable: 'env.API_KEY' })) + expect(onChange).toHaveBeenNthCalledWith(2, ['conversation', 'user_name'], expect.objectContaining({ variable: 'conversation.user_name' })) + expect(onChange).toHaveBeenNthCalledWith(3, ['node-special', 'current'], expect.objectContaining({ variable: 'current' })) + expect(onChange).toHaveBeenNthCalledWith(4, ['node-special', 'asset'], expect.objectContaining({ variable: 'asset' })) + }) + it('should render object vars and select them by node path', () => { const onChange = vi.fn() @@ -251,4 +324,26 @@ describe('VarReferenceVars', () => { fireEvent.click(screen.getByText('asset')) expect(onChange).not.toHaveBeenCalled() }) + + it('should ignore file vars when file support is disabled during keyboard selection', () => { + const onChange = vi.fn() + + render( + , + ) + + fireEvent.keyDown(document, { key: 'Enter' }) + + expect(onChange).not.toHaveBeenCalled() + }) }) diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx index 28ad104ed7..38fef9016d 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-vars.tsx @@ -12,11 +12,8 @@ import { import { useHover } from 'ahooks' import { noop } from 'es-toolkit/function' import * as React from 'react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows' -import { CodeAssistant, MagicEdit } from '@/app/components/base/icons/src/vender/line/general' -import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development' import Input from '@/app/components/base/input' import PickerStructurePanel from '@/app/components/workflow/nodes/_base/components/variable/object-child-tree-panel/picker' import { VariableIconWithColor } from '@/app/components/workflow/nodes/_base/components/variable/variable-label' @@ -31,6 +28,42 @@ import { getVariableDisplayName, } from './var-reference-vars.helpers' +const VAR_SEARCH_INPUT_CLASS_NAME = 'var-search-input' + +const resolveValueSelector = ({ + itemData, + isFlat, + isSupportFileVar, + nodeId, + objPath, +}: { + itemData: Var + isFlat?: boolean + isSupportFileVar?: boolean + nodeId: string + objPath: string[] +}) => { + const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties + const isFile = itemData.type === VarType.file && !isStructureOutput + const isSys = itemData.variable.startsWith('sys.') + const isEnv = itemData.variable.startsWith('env.') + const isChatVar = itemData.variable.startsWith('conversation.') + const isRagVariable = itemData.isRagVariable + + return getValueSelector({ + itemData, + isFlat, + isSupportFileVar, + isFile, + isSys, + isEnv, + isChatVar, + isRagVariable, + nodeId, + objPath, + }) +} + type ItemProps = { nodeId: string title: string @@ -47,6 +80,8 @@ type ItemProps = { zIndex?: number className?: string preferSchemaType?: boolean + isSelected?: boolean + onActivate?: () => void } const Item: FC = ({ @@ -64,11 +99,11 @@ const Item: FC = ({ zIndex, className, preferSchemaType, + isSelected, + onActivate, }) => { const isStructureOutput = itemData.type === VarType.object && (itemData.children as StructuredOutput)?.schema?.properties - const isFile = itemData.type === VarType.file && !isStructureOutput const isObj = ([VarType.object, VarType.file].includes(itemData.type) && itemData.children && (itemData.children as Var[]).length > 0) - const isSys = itemData.variable.startsWith('sys.') const isEnv = itemData.variable.startsWith('env.') const isChatVar = itemData.variable.startsWith('conversation.') const isRagVariable = itemData.isRagVariable @@ -76,15 +111,21 @@ const Item: FC = ({ if (!isFlat) return null const variable = itemData.variable - let Icon switch (variable) { case 'current': - Icon = isInCodeGeneratorInstructionEditor ? CodeAssistant : MagicEdit - return + return ( + + ) case 'error_message': - return + return default: - return + return } }, [isFlat, isInCodeGeneratorInstructionEditor, itemData.variable]) @@ -147,15 +188,10 @@ const Item: FC = ({ const handleChosen = (e: React.MouseEvent) => { e.stopPropagation() e.nativeEvent.stopImmediatePropagation() - const valueSelector = getValueSelector({ + const valueSelector = resolveValueSelector({ itemData, isFlat, isSupportFileVar, - isFile, - isSys, - isEnv, - isChatVar, - isRagVariable, nodeId, objPath, }) @@ -173,11 +209,13 @@ const Item: FC = ({ ref={itemRef} className={cn( (isObj || isStructureOutput) ? 'pr-1' : 'pr-[18px]', - isHovering && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'), + (isHovering || isSelected) && ((isObj || isStructureOutput) ? 'bg-components-panel-on-panel-item-bg-hover' : 'bg-state-base-hover'), 'relative flex h-6 w-full cursor-pointer items-center rounded-md pl-3', className, )} + data-selected={isSelected ? 'true' : 'false'} onClick={handleChosen} + onMouseEnter={onActivate} onMouseDown={(e) => { e.preventDefault() e.stopPropagation() @@ -210,7 +248,7 @@ const Item: FC = ({
{(preferSchemaType && itemData.schemaType) ? itemData.schemaType : itemData.type}
{ (isObj || isStructureOutput) && ( - + ) } @@ -221,7 +259,7 @@ const Item: FC = ({ open={open} onOpenChange={noop} > - + = ({ }) => { const { t } = useTranslation() const [internalSearchValue, setInternalSearchValue] = useState('') + const listRef = useRef(null) const searchValue = searchText ?? internalSearchValue - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Escape') { - e.preventDefault() - onClose?.() - } - } - const filteredVars = useMemo(() => filterReferenceVars(vars, searchValue), [vars, searchValue]) + const selectableItems = useMemo(() => { + return filteredVars.flatMap(node => node.vars.map(item => ({ + nodeId: node.nodeId, + isFlat: node.isFlat, + itemData: item, + }))) + }, [filteredVars]) + const indexedFilteredVars = useMemo(() => { + let optionIndex = 0 + + return filteredVars.map(node => ({ + ...node, + vars: node.vars.map(variable => ({ + variable, + optionIndex: optionIndex++, + })), + })) + }, [filteredVars]) + const [selectedIndex, setSelectedIndex] = useState(-1) + const effectiveSelectedIndex = selectableItems.length ? Math.min(Math.max(selectedIndex, 0), selectableItems.length - 1) : -1 + + useEffect(() => { + const listElement = listRef.current + const selectedElement = listElement?.querySelector('[data-selected="true"]') as HTMLElement | null + if (!listElement || !selectedElement) + return + + const selectedTop = selectedElement.offsetTop + const selectedBottom = selectedTop + selectedElement.offsetHeight + const visibleTop = listElement.scrollTop + const visibleBottom = visibleTop + listElement.clientHeight + + if (selectedTop < visibleTop) + listElement.scrollTop = selectedTop + else if (selectedBottom > visibleBottom) + listElement.scrollTop = selectedBottom - listElement.clientHeight + }, [effectiveSelectedIndex]) + + const selectItem = useCallback((index: number) => { + const selectedItem = selectableItems[index] + if (!selectedItem) + return + + const { itemData, nodeId, isFlat } = selectedItem + const valueSelector = resolveValueSelector({ + itemData, + isFlat, + isSupportFileVar, + nodeId, + objPath: [], + }) + + if (valueSelector) + onChange(valueSelector, itemData) + }, [isSupportFileVar, onChange, selectableItems]) + + const handleKeyboardEvent = useCallback((event: Pick) => { + if (event.key === 'Escape') { + event.preventDefault() + onClose?.() + return + } + + if (!selectableItems.length) + return + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + event.stopPropagation() + setSelectedIndex( + event.key === 'ArrowDown' + ? Math.min(effectiveSelectedIndex + 1, selectableItems.length - 1) + : Math.max(effectiveSelectedIndex - 1, 0), + ) + return + } + + if (event.key === 'Enter') { + event.preventDefault() + event.stopPropagation() + selectItem(effectiveSelectedIndex) + } + }, [effectiveSelectedIndex, onClose, selectableItems.length, selectItem]) + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + handleKeyboardEvent(e) + }, [handleKeyboardEvent]) + + useEffect(() => { + if (!hideSearch) + return + + const handleDocumentKeyDown = (event: KeyboardEvent) => { + if (event.altKey || event.ctrlKey || event.metaKey) + return + if (!['ArrowDown', 'ArrowUp', 'Enter', 'Escape'].includes(event.key)) + return + + handleKeyboardEvent(event) + } + + document.addEventListener('keydown', handleDocumentKeyDown, true) + return () => document.removeEventListener('keydown', handleDocumentKeyDown, true) + }, [handleKeyboardEvent, hideSearch]) return ( <> { !hideSearch && ( <> -
e.stopPropagation()}> +
e.stopPropagation()}> = ({ {filteredVars.length > 0 ? ( -
- +
{ - filteredVars.map((item, i) => ( -
+ indexedFilteredVars.map((item, i) => ( +
{!item.isFlat && (
= ({ {item.title}
)} - {item.vars.map((v, j) => ( + {item.vars.map(({ variable, optionIndex }) => ( setSelectedIndex(optionIndex)} /> ))} - {item.isFlat && !filteredVars[i + 1]?.isFlat && !!filteredVars.find(item => !item.isFlat) && ( + {item.isFlat && !indexedFilteredVars[i + 1]?.isFlat && !!indexedFilteredVars.find(item => !item.isFlat) && (
{t('debug.lastOutput', { ns: 'workflow' })}
From 48e13f65dceeb4dffd2b9392fe0a7614c96fc54e Mon Sep 17 00:00:00 2001 From: Yunlu Wen Date: Fri, 24 Apr 2026 11:59:33 +0800 Subject: [PATCH 003/128] fix: sync 35528 (#35539) --- api/core/app/llm/model_access.py | 24 +++++++++- api/core/model_manager.py | 46 +++++++++++++++++-- api/services/enterprise/enterprise_service.py | 2 + .../unit_tests/core/test_model_manager.py | 25 +++++++++- 4 files changed, 89 insertions(+), 8 deletions(-) diff --git a/api/core/app/llm/model_access.py b/api/core/app/llm/model_access.py index c49c4eb0ac..5631caa1a5 100644 --- a/api/core/app/llm/model_access.py +++ b/api/core/app/llm/model_access.py @@ -1,5 +1,6 @@ from __future__ import annotations +from copy import deepcopy from typing import Any from core.app.entities.app_invoke_entities import DifyRunContext, ModelConfigWithCredentialsEntity @@ -14,8 +15,21 @@ from graphon.nodes.llm.protocols import CredentialsProvider class DifyCredentialsProvider: + """Resolves and returns LLM credentials for a given provider and model. + + Fetched credentials are stored in :attr:`credentials_cache` and reused for + subsequent ``fetch`` calls for the same ``(provider_name, model_name)``. + Because of that cache, a single instance can return stale credentials after + the tenant or provider configuration changes (e.g. API key rotation). + + Do **not** keep one instance for the lifetime of a process or across + unrelated invocations. Create a new provider per request, workflow run, or + other bounded scope where up-to-date credentials matter. + """ + tenant_id: str provider_manager: ProviderManager + credentials_cache: dict[tuple[str, str], dict[str, Any]] def __init__( self, @@ -30,8 +44,12 @@ class DifyCredentialsProvider: user_id=run_context.user_id, ) self.provider_manager = provider_manager + self.credentials_cache = {} def fetch(self, provider_name: str, model_name: str) -> dict[str, Any]: + if (provider_name, model_name) in self.credentials_cache: + return deepcopy(self.credentials_cache[(provider_name, model_name)]) + provider_configurations = self.provider_manager.get_configurations(self.tenant_id) provider_configuration = provider_configurations.get(provider_name) if not provider_configuration: @@ -46,6 +64,7 @@ class DifyCredentialsProvider: if credentials is None: raise ProviderTokenNotInitError(f"Model {model_name} credentials is not initialized.") + self.credentials_cache[(provider_name, model_name)] = deepcopy(credentials) return credentials @@ -65,7 +84,8 @@ class DifyModelFactory: provider_manager=create_plugin_provider_manager( tenant_id=run_context.tenant_id, user_id=run_context.user_id, - ) + ), + enable_credentials_cache=True, ) self.model_manager = model_manager @@ -84,7 +104,7 @@ def build_dify_model_access(run_context: DifyRunContext) -> tuple[CredentialsPro tenant_id=run_context.tenant_id, user_id=run_context.user_id, ) - model_manager = ModelManager(provider_manager=provider_manager) + model_manager = ModelManager(provider_manager=provider_manager, enable_credentials_cache=True) return ( DifyCredentialsProvider(run_context=run_context, provider_manager=provider_manager), diff --git a/api/core/model_manager.py b/api/core/model_manager.py index 86d0e3baaa..457c888e33 100644 --- a/api/core/model_manager.py +++ b/api/core/model_manager.py @@ -1,5 +1,6 @@ import logging from collections.abc import Callable, Generator, Iterable, Mapping, Sequence +from copy import deepcopy from typing import IO, Any, Literal, Optional, ParamSpec, TypeVar, Union, cast, overload from configs import dify_config @@ -36,11 +37,13 @@ class ModelInstance: Model instance class. """ - def __init__(self, provider_model_bundle: ProviderModelBundle, model: str): + def __init__(self, provider_model_bundle: ProviderModelBundle, model: str, credentials: dict | None = None) -> None: self.provider_model_bundle = provider_model_bundle self.model_name = model self.provider = provider_model_bundle.configuration.provider.provider - self.credentials = self._fetch_credentials_from_bundle(provider_model_bundle, model) + if credentials is None: + credentials = self._fetch_credentials_from_bundle(provider_model_bundle, model) + self.credentials = credentials # Runtime LLM invocation fields. self.parameters: Mapping[str, Any] = {} self.stop: Sequence[str] = () @@ -434,8 +437,30 @@ class ModelInstance: class ModelManager: - def __init__(self, provider_manager: ProviderManager): + """Resolves :class:`ModelInstance` objects for a tenant and provider. + + When ``enable_credentials_cache`` is ``True``, resolved credentials for each + ``(tenant_id, provider, model_type, model)`` are stored in + ``_credentials_cache`` and reused. That can return **stale** credentials after + API keys or provider settings change, so a manager constructed with + ``enable_credentials_cache=True`` should not be kept for the lifetime of a + process or shared across unrelated work. Prefer a new manager per request, + workflow run, or similar bounded scope. + + The default is ``enable_credentials_cache=False``; in that mode the internal + credential cache is not populated, and each ``get_model_instance`` call + loads credentials from the current provider configuration. + """ + + def __init__( + self, + provider_manager: ProviderManager, + *, + enable_credentials_cache: bool = False, + ) -> None: self._provider_manager = provider_manager + self._credentials_cache: dict[tuple[str, str, str, str], Any] = {} + self._enable_credentials_cache = enable_credentials_cache @classmethod def for_tenant(cls, tenant_id: str, user_id: str | None = None) -> "ModelManager": @@ -463,8 +488,19 @@ class ModelManager: tenant_id=tenant_id, provider=provider, model_type=model_type ) - model_instance = ModelInstance(provider_model_bundle, model) - return model_instance + cred_cache_key = (tenant_id, provider, model_type.value, model) + + if cred_cache_key in self._credentials_cache: + return ModelInstance( + provider_model_bundle, + model, + deepcopy(self._credentials_cache[cred_cache_key]), + ) + + ret = ModelInstance(provider_model_bundle, model) + if self._enable_credentials_cache: + self._credentials_cache[cred_cache_key] = deepcopy(ret.credentials) + return ret def get_default_provider_model_name(self, tenant_id: str, model_type: ModelType) -> tuple[str | None, str | None]: """ diff --git a/api/services/enterprise/enterprise_service.py b/api/services/enterprise/enterprise_service.py index 5040fcc7e3..bd7758f1c0 100644 --- a/api/services/enterprise/enterprise_service.py +++ b/api/services/enterprise/enterprise_service.py @@ -5,6 +5,7 @@ import uuid from datetime import datetime from typing import TYPE_CHECKING +from cachetools.func import ttl_cache from pydantic import BaseModel, ConfigDict, Field, model_validator from configs import dify_config @@ -99,6 +100,7 @@ def try_join_default_workspace(account_id: str) -> None: class EnterpriseService: @classmethod + @ttl_cache(ttl=5) def get_info(cls): return EnterpriseRequest.send_request("GET", "/info") diff --git a/api/tests/unit_tests/core/test_model_manager.py b/api/tests/unit_tests/core/test_model_manager.py index afea9144c0..5a7e7e30a5 100644 --- a/api/tests/unit_tests/core/test_model_manager.py +++ b/api/tests/unit_tests/core/test_model_manager.py @@ -5,7 +5,7 @@ import redis from pytest_mock import MockerFixture from core.entities.provider_entities import ModelLoadBalancingConfiguration -from core.model_manager import LBModelManager +from core.model_manager import LBModelManager, ModelManager from extensions.ext_redis import redis_client from graphon.model_runtime.entities.model_entities import ModelType @@ -40,6 +40,29 @@ def lb_model_manager(): return lb_model_manager +def test_model_manager_with_cache_enabled_reuses_stored_credentials(): + """With ``enable_credentials_cache=True``, later calls for the same key return cached creds.""" + provider_manager = MagicMock() + bundle = MagicMock() + bundle.configuration.provider.provider = "openai" + bundle.configuration.tenant_id = "tenant-1" + bundle.configuration.model_settings = [] + bundle.model_type_instance.model_type = ModelType.LLM + get_creds = MagicMock(return_value={"api_key": "first"}) + bundle.configuration.get_current_credentials = get_creds + provider_manager.get_provider_model_bundle.return_value = bundle + + manager = ModelManager(provider_manager, enable_credentials_cache=True) + first = manager.get_model_instance("tenant-1", "openai", ModelType.LLM, "gpt-4") + assert first.credentials == {"api_key": "first"} + get_creds.assert_called_once() + + get_creds.return_value = {"api_key": "second"} + second = manager.get_model_instance("tenant-1", "openai", ModelType.LLM, "gpt-4") + assert second.credentials == {"api_key": "first"} + get_creds.assert_called_once() + + def test_lb_model_manager_fetch_next(mocker: MockerFixture, lb_model_manager: LBModelManager): # initialize redis client redis_client.initialize(redis.Redis()) From ec450eb7f938272cc9efbcba89b5e1f78944362a Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 24 Apr 2026 14:36:48 +0800 Subject: [PATCH 004/128] chore(dify-ui): update tooltip and infotip migration (#35543) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 59 ------------- web/app/components/base/infotip/index.tsx | 2 +- .../form-input-item.branches.spec.tsx | 6 +- .../error-handle/error-handle-on-panel.tsx | 6 +- .../components/form-input-type-switch.tsx | 85 ++++++++++++------ .../nodes/_base/components/help-link.tsx | 33 ++++--- .../components/__tests__/model-bar.spec.tsx | 10 +-- .../components/__tests__/tool-icon.spec.tsx | 6 +- .../nodes/agent/components/model-bar.tsx | 88 +++++++++++-------- .../nodes/agent/components/tool-icon.tsx | 81 +++++++++-------- .../top-k-and-score-threshold.tsx | 24 +++-- .../json-schema-config-modal/code-editor.tsx | 61 ++++++++----- .../visual-editor/edit-card/actions.tsx | 76 ++++++++++------ .../components/__tests__/copy-id.spec.tsx | 12 +-- .../nodes/tool/components/copy-id.tsx | 45 +++++----- .../note-node/note-editor/toolbar/command.tsx | 54 ++++++------ web/docs/overlay-migration.md | 6 -- 17 files changed, 345 insertions(+), 309 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 405ce77400..943d38878e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -4270,11 +4270,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/error-handle/error-handle-on-panel.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/error-handle/error-handle-type-selector.tsx": { "no-restricted-imports": { "count": 1 @@ -4293,16 +4288,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/_base/components/form-input-type-switch.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/_base/components/help-link.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/_base/components/input-support-select-var.tsx": { "no-restricted-imports": { "count": 1 @@ -4502,22 +4487,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/agent/components/model-bar.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-empty-object-type": { - "count": 1 - } - }, - "web/app/components/workflow/nodes/agent/components/tool-icon.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "react/unsupported-syntax": { - "count": 1 - } - }, "web/app/components/workflow/nodes/agent/default.ts": { "ts/no-explicit-any": { "count": 3 @@ -4859,11 +4828,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/type.ts": { "ts/no-explicit-any": { "count": 2 @@ -4966,14 +4930,6 @@ "count": 1 } }, - "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 4 - } - }, "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/index.tsx": { "no-restricted-imports": { "count": 1 @@ -5009,11 +4965,6 @@ "count": 2 } }, - "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/actions.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/auto-width-input.tsx": { "react/set-state-in-effect": { "count": 1 @@ -5235,11 +5186,6 @@ "count": 5 } }, - "web/app/components/workflow/nodes/tool/components/copy-id.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/nodes/tool/components/input-var-list.tsx": { "ts/no-explicit-any": { "count": 7 @@ -5405,11 +5351,6 @@ "count": 1 } }, - "web/app/components/workflow/note-node/note-editor/toolbar/command.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/workflow/note-node/note-editor/utils.ts": { "regexp/no-useless-quantifier": { "count": 1 diff --git a/web/app/components/base/infotip/index.tsx b/web/app/components/base/infotip/index.tsx index b97b499af3..ce818fe030 100644 --- a/web/app/components/base/infotip/index.tsx +++ b/web/app/components/base/infotip/index.tsx @@ -73,7 +73,7 @@ export function Infotip({ /> {children} diff --git a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx index 7786dbec17..2e95473bb2 100644 --- a/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx +++ b/web/app/components/workflow/nodes/_base/components/__tests__/form-input-item.branches.spec.tsx @@ -225,7 +225,7 @@ describe('FormInputItem branches', () => { }) expect(screen.getByText('alpha')).toBeInTheDocument() - fireEvent.click(screen.getByRole('button')) + fireEvent.click(screen.getByText('alpha').closest('button') as HTMLButtonElement) fireEvent.click(screen.getByText('beta')) expect(onChange).toHaveBeenCalledWith({ @@ -320,9 +320,9 @@ describe('FormInputItem branches', () => { }) await waitFor(() => { - expect(screen.getByRole('button')).not.toBeDisabled() + expect(screen.getByText('Select options').closest('button')).not.toBeDisabled() }) - fireEvent.click(screen.getByRole('button')) + fireEvent.click(screen.getByText('Select options').closest('button') as HTMLButtonElement) fireEvent.click(screen.getByText('trigger-option')) expect(onChange).toHaveBeenCalledWith({ diff --git a/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-on-panel.tsx b/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-on-panel.tsx index 10a167c504..a00e8e1adc 100644 --- a/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-on-panel.tsx +++ b/web/app/components/workflow/nodes/_base/components/error-handle/error-handle-on-panel.tsx @@ -5,7 +5,7 @@ import type { } from '@/app/components/workflow/types' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import Collapse from '../collapse' import DefaultValue from './default-value' import ErrorHandleTypeSelector from './error-handle-type-selector' @@ -57,7 +57,9 @@ const ErrorHandle = ({
{t('nodes.common.errorHandle.title', { ns: 'workflow' })}
- + + {t('nodes.common.errorHandle.tip', { ns: 'workflow' })} + {collapseIcon}
= ({ onChange, }) => { const { t } = useTranslation() + const variableLabel = t('nodes.common.typeSwitch.variable', { ns: 'workflow' }) + const inputLabel = t('nodes.common.typeSwitch.input', { ns: 'workflow' }) + return (
- -
onChange(VarType.variable)} - > - -
-
- -
onChange(VarType.constant)} - > - -
-
+ {value === VarType.variable + ? ( + + ) + : ( + + onChange(VarType.variable)} + > + + + )} + /> + {variableLabel} + + )} + {value === VarType.constant + ? ( + + ) + : ( + + onChange(VarType.constant)} + > + + + )} + /> + {inputLabel} + + )}
) } diff --git a/web/app/components/workflow/nodes/_base/components/help-link.tsx b/web/app/components/workflow/nodes/_base/components/help-link.tsx index 30f95a12be..298f50738f 100644 --- a/web/app/components/workflow/nodes/_base/components/help-link.tsx +++ b/web/app/components/workflow/nodes/_base/components/help-link.tsx @@ -1,8 +1,7 @@ import type { BlockEnum } from '@/app/components/workflow/types' -import { RiBookOpenLine } from '@remixicon/react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { memo } from 'react' import { useTranslation } from 'react-i18next' -import TooltipPlus from '@/app/components/base/tooltip' import { useNodeHelpLink } from '../hooks/use-node-help-link' type HelpLinkProps = { @@ -17,19 +16,25 @@ const HelpLink = ({ if (!link) return null - return ( - - - - - + const label = t('userProfile.helpCenter', { ns: 'common' }) + return ( + + + + + )} + /> + {label} + ) } diff --git a/web/app/components/workflow/nodes/agent/components/__tests__/model-bar.spec.tsx b/web/app/components/workflow/nodes/agent/components/__tests__/model-bar.spec.tsx index d85f54ed19..2127b48dca 100644 --- a/web/app/components/workflow/nodes/agent/components/__tests__/model-bar.spec.tsx +++ b/web/app/components/workflow/nodes/agent/components/__tests__/model-bar.spec.tsx @@ -1,5 +1,5 @@ import type { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' -import { fireEvent, render, screen } from '@testing-library/react' +import { render, screen } from '@testing-library/react' import { ModelBar } from '../model-bar' type ModelProviderItem = { @@ -52,11 +52,9 @@ describe('agent/model-bar', () => { const emptySelector = screen.getByText((_, element) => element?.textContent === 'no-model:0') - fireEvent.mouseEnter(emptySelector) - expect(emptySelector).toBeInTheDocument() expect(screen.getByText('indicator:red')).toBeInTheDocument() - expect(screen.getByText('workflow.nodes.agent.modelNotSelected')).toBeInTheDocument() + expect(screen.getByLabelText('workflow.nodes.agent.modelNotSelected')).toBeInTheDocument() }) it('should render the selected model without warning when it is installed', () => { @@ -69,10 +67,8 @@ describe('agent/model-bar', () => { it('should show a warning tooltip when the selected model is not installed', () => { render() - fireEvent.mouseEnter(screen.getByText('openai/gpt-4.1:1')) - expect(screen.getByText('openai/gpt-4.1:1')).toBeInTheDocument() expect(screen.getByText('indicator:red')).toBeInTheDocument() - expect(screen.getByText('workflow.nodes.agent.modelNotInstallTooltip')).toBeInTheDocument() + expect(screen.getByLabelText('workflow.nodes.agent.modelNotInstallTooltip')).toBeInTheDocument() }) }) diff --git a/web/app/components/workflow/nodes/agent/components/__tests__/tool-icon.spec.tsx b/web/app/components/workflow/nodes/agent/components/__tests__/tool-icon.spec.tsx index 30a12bb528..af61b43367 100644 --- a/web/app/components/workflow/nodes/agent/components/__tests__/tool-icon.spec.tsx +++ b/web/app/components/workflow/nodes/agent/components/__tests__/tool-icon.spec.tsx @@ -87,19 +87,17 @@ describe('agent/tool-icon', () => { const { rerender } = render() - fireEvent.mouseEnter(screen.getByText('app-icon:#fff:B')) expect(screen.getByText('indicator:yellow')).toBeInTheDocument() - expect(screen.getByText('workflow.nodes.agent.toolNotAuthorizedTooltip:{"tool":"tool-b"}')).toBeInTheDocument() + expect(screen.getByLabelText('workflow.nodes.agent.toolNotAuthorizedTooltip:{"tool":"tool-b"}')).toBeInTheDocument() mockWorkflowTools = [] mockMarketplaceIcon = 'https://example.com/market-tool.png' rerender() const marketplaceIcon = screen.getByRole('img', { name: 'tool icon' }) - fireEvent.mouseEnter(marketplaceIcon) expect(marketplaceIcon).toHaveAttribute('src', 'https://example.com/market-tool.png') expect(screen.getByText('indicator:red')).toBeInTheDocument() - expect(screen.getByText('workflow.nodes.agent.toolNotInstallTooltip:{"tool":"tool-c"}')).toBeInTheDocument() + expect(screen.getByLabelText('workflow.nodes.agent.toolNotInstallTooltip:{"tool":"tool-c"}')).toBeInTheDocument() }) it('should fall back to the group icon while tool data is still loading', () => { diff --git a/web/app/components/workflow/nodes/agent/components/model-bar.tsx b/web/app/components/workflow/nodes/agent/components/model-bar.tsx index 8e2f19d726..0ec0b943ef 100644 --- a/web/app/components/workflow/nodes/agent/components/model-bar.tsx +++ b/web/app/components/workflow/nodes/agent/components/model-bar.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks' import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector' @@ -10,7 +10,10 @@ import Indicator from '@/app/components/header/indicator' type ModelBarProps = { provider: string model: string -} | {} +} | { + provider?: never + model?: never +} const useAllModel = () => { const { data: textGeneration } = useModelList(ModelTypeEnum.textGeneration) @@ -35,23 +38,27 @@ const useAllModel = () => { export const ModelBar: FC = (props) => { const { t } = useTranslation() const modelList = useAllModel() - if (!('provider' in props)) { + if (props.provider === undefined) { + const tooltip = t('nodes.agent.modelNotSelected', { ns: 'workflow' }) + return ( - -
- - -
+ + + + +
+ )} + /> + {tooltip} ) } @@ -59,23 +66,34 @@ export const ModelBar: FC = (props) => { provider => provider.provider === props.provider && provider.models.some(model => model.model === props.model), ) const showWarn = modelList && !modelInstalled - return modelList && ( - -
- - {showWarn && } -
+ if (!modelList) + return null + + const modelNotInstalledTooltip = t('nodes.agent.modelNotInstallTooltip', { ns: 'workflow' }) + const modelSelector = ( +
+ + {showWarn && } +
+ ) + + if (modelInstalled) + return modelSelector + + return ( + + + {modelNotInstalledTooltip} ) } diff --git a/web/app/components/workflow/nodes/agent/components/tool-icon.tsx b/web/app/components/workflow/nodes/agent/components/tool-icon.tsx index b545c2f370..3986dcf6a4 100644 --- a/web/app/components/workflow/nodes/agent/components/tool-icon.tsx +++ b/web/app/components/workflow/nodes/agent/components/tool-icon.tsx @@ -1,9 +1,10 @@ +import type { ReactNode } from 'react' import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { memo, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import AppIcon from '@/app/components/base/app-icon' import { Group } from '@/app/components/base/icons/src/vender/other' -import Tooltip from '@/app/components/base/tooltip' import Indicator from '@/app/components/header/indicator' import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools' import { getIconFromMarketPlace } from '@/utils/get-icon' @@ -62,44 +63,50 @@ export const ToolIcon = memo(({ providerName }: ToolIconProps) => { throw new Error('Unknown status') }, [name, notSuccess, status, t]) const [iconFetchError, setIconFetchError] = useState(false) - return ( - + + if (!iconFetchError && icon) { + if (typeof icon === 'string') { + iconContent = ( + tool icon setIconFetchError(true)} + /> + ) + } + else if (typeof icon === 'object') { + iconContent = ( + + ) + } + } + + const iconNode = ( +
-
-
- {(() => { - if (iconFetchError || !icon) - return - if (typeof icon === 'string') { - return ( - tool icon setIconFetchError(true)} - /> - ) - } - if (typeof icon === 'object') { - return ( - - ) - } - return - })()} -
- {indicator && } +
+ {iconContent}
+ {indicator && } +
+ ) + + if (!notSuccess || !tooltip) + return iconNode + + return ( + + + {tooltip} ) }) diff --git a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx index 814b3cea6d..b81032242f 100644 --- a/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx +++ b/web/app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx @@ -9,7 +9,7 @@ import { import { Switch } from '@langgenius/dify-ui/switch' import { memo, useCallback } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' +import { Infotip } from '@/app/components/base/infotip' import { env } from '@/env' export type TopKAndScoreThresholdProps = { @@ -59,10 +59,13 @@ const TopKAndScoreThreshold = ({
{t('datasetConfig.top_k', { ns: 'appDebug' })} - + + {t('datasetConfig.top_kTip', { ns: 'appDebug' })} +
{t('datasetConfig.score_threshold', { ns: 'appDebug' })}
- + + {t('datasetConfig.score_thresholdTip', { ns: 'appDebug' })} +
+type EditorOnMount = NonNullable['onMount']> +type MonacoEditor = Parameters[0] +type Monaco = Parameters[1] + const CodeEditor: FC = ({ value, onUpdate, @@ -36,8 +39,8 @@ const CodeEditor: FC = ({ }) => { const { t } = useTranslation() const { theme } = useTheme() - const monacoRef = useRef(null) - const editorRef = useRef(null) + const monacoRef = useRef(null) + const editorRef = useRef(null) const [isMounted, setIsMounted] = React.useState(false) const containerRef = useRef(null) @@ -50,7 +53,7 @@ const CodeEditor: FC = ({ } }, [theme]) - const handleEditorDidMount = useCallback((editor: any, monaco: any) => { + const handleEditorDidMount = useCallback((editor, monaco) => { editorRef.current = editor monacoRef.current = monaco @@ -83,7 +86,7 @@ const CodeEditor: FC = ({ }) monaco.editor.setTheme('light-theme') setIsMounted(true) - }, []) + }, [onBlur, onFocus]) const formatJsonContent = useCallback(() => { if (editorRef.current) @@ -122,24 +125,36 @@ const CodeEditor: FC = ({
{showFormatButton && ( - - + + + + + )} + /> + {t('operation.format', { ns: 'common' })} )} - - + + copy(value)} + > + + + )} + /> + {t('operation.copy', { ns: 'common' })}
diff --git a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/actions.tsx b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/actions.tsx index 0afedab3d2..2f3b70aefc 100644 --- a/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/actions.tsx +++ b/web/app/components/workflow/nodes/llm/components/json-schema-config-modal/visual-editor/edit-card/actions.tsx @@ -1,8 +1,7 @@ import type { FC } from 'react' -import { RiAddCircleLine, RiDeleteBinLine, RiEditLine } from '@remixicon/react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import * as React from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' type ActionsProps = { disableAddBtn: boolean @@ -18,36 +17,59 @@ const Actions: FC = ({ onDelete, }) => { const { t } = useTranslation() + const addChildFieldLabel = t('nodes.llm.jsonSchema.addChildField', { ns: 'workflow' }) + const editLabel = t('operation.edit', { ns: 'common' }) + const removeLabel = t('operation.remove', { ns: 'common' }) return (
- - + + + + + )} + /> + {addChildFieldLabel} - - + + + + + )} + /> + {editLabel} - - + + + + + )} + /> + {removeLabel}
) diff --git a/web/app/components/workflow/nodes/tool/components/__tests__/copy-id.spec.tsx b/web/app/components/workflow/nodes/tool/components/__tests__/copy-id.spec.tsx index ee6791ca03..2fb7e66e24 100644 --- a/web/app/components/workflow/nodes/tool/components/__tests__/copy-id.spec.tsx +++ b/web/app/components/workflow/nodes/tool/components/__tests__/copy-id.spec.tsx @@ -20,27 +20,21 @@ describe('tool/copy-id', () => { it('should copy content and reset copied state when mouse leaves', () => { const { container } = render() - const trigger = screen.getByText('tool-123').parentElement as HTMLElement + const trigger = screen.getByRole('button', { name: 'appOverview.overview.appInfo.embedded.copy' }) const wrapper = container.querySelector('.inline-flex') as HTMLElement - act(() => { - fireEvent.mouseEnter(trigger) - }) - expect(screen.getByText('appOverview.overview.appInfo.embedded.copy')).toBeInTheDocument() - act(() => { fireEvent.click(trigger) vi.advanceTimersByTime(100) }) expect(copy).toHaveBeenCalledWith('tool-123') - expect(screen.getByText('appOverview.overview.appInfo.embedded.copied')).toBeInTheDocument() + expect(trigger).toHaveAccessibleName('appOverview.overview.appInfo.embedded.copied') act(() => { fireEvent.mouseLeave(wrapper) vi.advanceTimersByTime(100) - fireEvent.mouseEnter(trigger) }) - expect(screen.getByText('appOverview.overview.appInfo.embedded.copy')).toBeInTheDocument() + expect(trigger).toHaveAccessibleName('appOverview.overview.appInfo.embedded.copy') }) it('should stop click propagation from the outer wrapper', () => { diff --git a/web/app/components/workflow/nodes/tool/components/copy-id.tsx b/web/app/components/workflow/nodes/tool/components/copy-id.tsx index eaf3d1bec5..18a510caaf 100644 --- a/web/app/components/workflow/nodes/tool/components/copy-id.tsx +++ b/web/app/components/workflow/nodes/tool/components/copy-id.tsx @@ -1,11 +1,10 @@ 'use client' -import { RiFileCopyLine } from '@remixicon/react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import copy from 'copy-to-clipboard' import { debounce } from 'es-toolkit/compat' import * as React from 'react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' type Props = { content: string @@ -25,27 +24,33 @@ const CopyFeedbackNew = ({ content }: Props) => { const onMouseLeave = debounce(() => { setIsCopied(false) }, 100) + const tooltip = (isCopied + ? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' }) + : t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || '' return (
e.stopPropagation()} onMouseLeave={onMouseLeave}> - -
-
- {content} -
- -
+ + + + {content} + + + + )} + /> + + {tooltip} +
) diff --git a/web/app/components/workflow/note-node/note-editor/toolbar/command.tsx b/web/app/components/workflow/note-node/note-editor/toolbar/command.tsx index ab4bf8c7bb..9f9c02de33 100644 --- a/web/app/components/workflow/note-node/note-editor/toolbar/command.tsx +++ b/web/app/components/workflow/note-node/note-editor/toolbar/command.tsx @@ -1,17 +1,10 @@ import { cn } from '@langgenius/dify-ui/cn' -import { - RiBold, - RiItalic, - RiLink, - RiListUnordered, - RiStrikethrough, -} from '@remixicon/react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { memo, useMemo, } from 'react' import { useTranslation } from 'react-i18next' -import Tooltip from '@/app/components/base/tooltip' import { useStore } from '../store' import { useCommand } from './hooks' @@ -32,15 +25,15 @@ const Command = ({ const icon = useMemo(() => { switch (type) { case 'bold': - return + return case 'italic': - return + return case 'strikethrough': - return + return case 'link': - return + return case 'bullet': - return + return } }, [type, selectedIsBold, selectedIsItalic, selectedIsStrikeThrough, selectedIsLink, selectedIsBullet]) @@ -60,22 +53,27 @@ const Command = ({ }, [type, t]) return ( - -
+ handleCommand(type)} + > + {icon} + )} - onClick={() => handleCommand(type)} - > - {icon} -
+ /> + {tip}
) } diff --git a/web/docs/overlay-migration.md b/web/docs/overlay-migration.md index 73c0f02d9d..cb020f9ab6 100644 --- a/web/docs/overlay-migration.md +++ b/web/docs/overlay-migration.md @@ -44,12 +44,6 @@ This document tracks the Dify-web migration away from legacy overlay APIs. ## Allowlist maintenance -- After each migration batch, run: - -```sh -pnpm -C web lint:fix --prune-suppressions -``` - - If a migrated file was in the allowlist, remove it from `web/eslint.constants.mjs` in the same PR. - Never increase allowlist scope to bypass new code. From be4c828214b4df661a474260382136fffb0958b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Fri, 24 Apr 2026 14:37:10 +0800 Subject: [PATCH 005/128] feat: add service api of HITL (#32826) Co-authored-by: Blackoutta Co-authored-by: QuantumGhost Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: QuantumGhost Co-authored-by: Yunlu Wen --- api/controllers/common/human_input.py | 6 + api/controllers/console/human_input_form.py | 18 +- api/controllers/service_api/__init__.py | 4 + .../service_api/app/human_input_form.py | 137 ++++ .../service_api/app/workflow_events.py | 142 ++++ api/controllers/web/human_input_form.py | 7 +- .../app/apps/advanced_chat/app_generator.py | 12 +- .../generate_response_converter.py | 39 +- .../advanced_chat/generate_task_pipeline.py | 72 +- .../agent_chat/generate_response_converter.py | 14 +- .../base_app_generate_response_converter.py | 24 +- .../apps/chat/generate_response_converter.py | 14 +- .../common/workflow_response_converter.py | 22 +- .../completion/generate_response_converter.py | 16 +- api/core/app/apps/message_generator.py | 5 +- .../pipeline/generate_response_converter.py | 8 +- .../app/apps/pipeline/pipeline_generator.py | 12 +- api/core/app/apps/streaming_utils.py | 2 +- api/core/app/apps/workflow/app_generator.py | 12 +- .../workflow/generate_response_converter.py | 17 +- .../apps/workflow/generate_task_pipeline.py | 59 +- api/core/app/entities/task_entities.py | 98 ++- api/core/workflow/human_input_forms.py | 50 +- api/core/workflow/human_input_policy.py | 73 ++ .../sqlalchemy_api_workflow_run_repository.py | 19 +- api/services/app_generate_service.py | 6 + .../workflow_event_snapshot_service.py | 161 +++- .../app_generate/workflow_execute_task.py | 28 +- ..._sqlalchemy_api_workflow_run_repository.py | 140 +++- .../console/test_human_input_form.py | 29 + .../service_api/app/test_hitl_service_api.py | 707 +++++++++++++++++ .../service_api/app/test_human_input_form.py | 184 +++++ .../service_api/app/test_workflow_events.py | 166 ++++ .../test_generate_response_converter.py | 37 +- .../test_generate_task_pipeline_core.py | 55 ++ ...st_base_app_generate_response_converter.py | 102 +++ .../core/app/apps/test_message_generator.py | 19 +- .../core/app/apps/test_streaming_utils.py | 22 + .../test_generate_task_pipeline_core.py | 48 +- .../core/workflow/test_human_input_forms.py | 50 +- .../core/workflow/test_human_input_policy.py | 50 ++ ..._sqlalchemy_api_workflow_run_repository.py | 64 ++ .../services/test_app_generate_service.py | 3 +- .../test_workflow_event_snapshot_service.py | 708 ++++++++++++++++-- .../tasks/test_workflow_execute_task.py | 229 +++++- .../template/template_advanced_chat.en.mdx | 507 +++++++++++++ .../template/template_advanced_chat.ja.mdx | 507 +++++++++++++ .../template/template_advanced_chat.zh.mdx | 507 +++++++++++++ .../develop/template/template_workflow.en.mdx | 511 +++++++++++++ .../develop/template/template_workflow.ja.mdx | 511 +++++++++++++ .../develop/template/template_workflow.zh.mdx | 511 +++++++++++++ 51 files changed, 6530 insertions(+), 214 deletions(-) create mode 100644 api/controllers/common/human_input.py create mode 100644 api/controllers/service_api/app/human_input_form.py create mode 100644 api/controllers/service_api/app/workflow_events.py create mode 100644 api/core/workflow/human_input_policy.py create mode 100644 api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py create mode 100644 api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py create mode 100644 api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py create mode 100644 api/tests/unit_tests/core/app/apps/test_base_app_generate_response_converter.py create mode 100644 api/tests/unit_tests/core/workflow/test_human_input_policy.py create mode 100644 api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py diff --git a/api/controllers/common/human_input.py b/api/controllers/common/human_input.py new file mode 100644 index 0000000000..5d6f4efb95 --- /dev/null +++ b/api/controllers/common/human_input.py @@ -0,0 +1,6 @@ +from pydantic import BaseModel, JsonValue + + +class HumanInputFormSubmitPayload(BaseModel): + inputs: dict[str, JsonValue] + action: str diff --git a/api/controllers/console/human_input_form.py b/api/controllers/console/human_input_form.py index 845af37365..79b3e6cc9f 100644 --- a/api/controllers/console/human_input_form.py +++ b/api/controllers/console/human_input_form.py @@ -8,10 +8,10 @@ from collections.abc import Generator from flask import Response, jsonify, request from flask_restx import Resource -from pydantic import BaseModel from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker +from controllers.common.human_input import HumanInputFormSubmitPayload from controllers.console import console_ns from controllers.console.wraps import account_initialization_required, setup_required from controllers.web.error import InvalidArgumentError, NotFoundError @@ -20,11 +20,11 @@ from core.app.apps.base_app_generator import BaseAppGenerator from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter from core.app.apps.message_generator import MessageGenerator from core.app.apps.workflow.app_generator import WorkflowAppGenerator +from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface from extensions.ext_database import db from libs.login import current_account_with_tenant, login_required from models import App from models.enums import CreatorUserRole -from models.human_input import RecipientType from models.model import AppMode from models.workflow import WorkflowRun from repositories.factory import DifyAPIRepositoryFactory @@ -34,11 +34,6 @@ from services.workflow_event_snapshot_service import build_workflow_event_stream logger = logging.getLogger(__name__) -class HumanInputFormSubmitPayload(BaseModel): - inputs: dict - action: str - - def _jsonify_form_definition(form: Form) -> Response: payload = form.get_definition().model_dump() payload["expiration_time"] = int(form.expiration_time.timestamp()) @@ -56,6 +51,11 @@ class ConsoleHumanInputFormApi(Resource): if form.tenant_id != current_tenant_id: raise NotFoundError("App not found") + @staticmethod + def _ensure_console_recipient_type(form: Form) -> None: + if not is_recipient_type_allowed_for_surface(form.recipient_type, HumanInputSurface.CONSOLE): + raise NotFoundError("form not found") + @setup_required @login_required @account_initialization_required @@ -99,10 +99,8 @@ class ConsoleHumanInputFormApi(Resource): raise NotFoundError(f"form not found, token={form_token}") self._ensure_console_access(form) - + self._ensure_console_recipient_type(form) recipient_type = form.recipient_type - if recipient_type not in {RecipientType.CONSOLE, RecipientType.BACKSTAGE}: - raise NotFoundError(f"form not found, token={form_token}") # The type checker is not smart enought to validate the following invariant. # So we need to assert it manually. assert recipient_type is not None, "recipient_type cannot be None here." diff --git a/api/controllers/service_api/__init__.py b/api/controllers/service_api/__init__.py index 4f7f7d9a98..182631e8f5 100644 --- a/api/controllers/service_api/__init__.py +++ b/api/controllers/service_api/__init__.py @@ -23,9 +23,11 @@ from .app import ( conversation, file, file_preview, + human_input_form, message, site, workflow, + workflow_events, ) from .dataset import ( dataset, @@ -50,6 +52,7 @@ __all__ = [ "file", "file_preview", "hit_testing", + "human_input_form", "index", "message", "metadata", @@ -58,6 +61,7 @@ __all__ = [ "segment", "site", "workflow", + "workflow_events", ] api.add_namespace(service_api_ns) diff --git a/api/controllers/service_api/app/human_input_form.py b/api/controllers/service_api/app/human_input_form.py new file mode 100644 index 0000000000..8e5003dbbf --- /dev/null +++ b/api/controllers/service_api/app/human_input_form.py @@ -0,0 +1,137 @@ +""" +Service API human input form endpoints. + +This module exposes app-token authenticated APIs for fetching and submitting +paused human input forms in workflow/chatflow runs. +""" + +import json +import logging +from datetime import datetime + +from flask import Response +from flask_restx import Resource +from werkzeug.exceptions import BadRequest, NotFound + +from controllers.common.human_input import HumanInputFormSubmitPayload +from controllers.common.schema import register_schema_models +from controllers.service_api import service_api_ns +from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token +from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface +from extensions.ext_database import db +from models.model import App, EndUser +from services.human_input_service import Form, FormNotFoundError, HumanInputService + +logger = logging.getLogger(__name__) + + +register_schema_models(service_api_ns, HumanInputFormSubmitPayload) + + +def _stringify_default_values(values: dict[str, object]) -> dict[str, str]: + result: dict[str, str] = {} + for key, value in values.items(): + if value is None: + result[key] = "" + elif isinstance(value, (dict, list)): + result[key] = json.dumps(value, ensure_ascii=False) + else: + result[key] = str(value) + return result + + +def _to_timestamp(value: datetime) -> int: + return int(value.timestamp()) + + +def _jsonify_form_definition(form: Form) -> Response: + definition_payload = form.get_definition().model_dump() + payload = { + "form_content": definition_payload["rendered_content"], + "inputs": definition_payload["inputs"], + "resolved_default_values": _stringify_default_values(definition_payload["default_values"]), + "user_actions": definition_payload["user_actions"], + "expiration_time": _to_timestamp(form.expiration_time), + } + return Response(json.dumps(payload, ensure_ascii=False), mimetype="application/json") + + +def _ensure_form_belongs_to_app(form: Form, app_model: App) -> None: + if form.app_id != app_model.id or form.tenant_id != app_model.tenant_id: + raise NotFound("Form not found") + + +def _ensure_form_is_allowed_for_service_api(form: Form) -> None: + # Keep app-token callers scoped to the public web-form surface; internal HITL + # routes must continue to flow through console-only authentication. + if not is_recipient_type_allowed_for_surface(form.recipient_type, HumanInputSurface.SERVICE_API): + raise NotFound("Form not found") + + +@service_api_ns.route("/form/human_input/") +class WorkflowHumanInputFormApi(Resource): + @service_api_ns.doc("get_human_input_form") + @service_api_ns.doc(description="Get a paused human input form by token") + @service_api_ns.doc(params={"form_token": "Human input form token"}) + @service_api_ns.doc( + responses={ + 200: "Form retrieved successfully", + 401: "Unauthorized - invalid API token", + 404: "Form not found", + 412: "Form already submitted or expired", + } + ) + @validate_app_token + def get(self, app_model: App, form_token: str): + service = HumanInputService(db.engine) + form = service.get_form_by_token(form_token) + if form is None: + raise NotFound("Form not found") + + _ensure_form_belongs_to_app(form, app_model) + _ensure_form_is_allowed_for_service_api(form) + service.ensure_form_active(form) + return _jsonify_form_definition(form) + + @service_api_ns.expect(service_api_ns.models[HumanInputFormSubmitPayload.__name__]) + @service_api_ns.doc("submit_human_input_form") + @service_api_ns.doc(description="Submit a paused human input form by token") + @service_api_ns.doc(params={"form_token": "Human input form token"}) + @service_api_ns.doc( + responses={ + 200: "Form submitted successfully", + 400: "Bad request - invalid submission data", + 401: "Unauthorized - invalid API token", + 404: "Form not found", + 412: "Form already submitted or expired", + } + ) + @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True)) + def post(self, app_model: App, end_user: EndUser, form_token: str): + payload = HumanInputFormSubmitPayload.model_validate(service_api_ns.payload or {}) + + service = HumanInputService(db.engine) + form = service.get_form_by_token(form_token) + if form is None: + raise NotFound("Form not found") + + _ensure_form_belongs_to_app(form, app_model) + _ensure_form_is_allowed_for_service_api(form) + + recipient_type = form.recipient_type + if recipient_type is None: + logger.warning("Recipient type is None for form, form_id=%s", form.id) + raise BadRequest("Form recipient type is invalid") + + try: + service.submit_form_by_token( + recipient_type=recipient_type, + form_token=form_token, + selected_action_id=payload.action, + form_data=payload.inputs, + submission_end_user_id=end_user.id, + ) + except FormNotFoundError: + raise NotFound("Form not found") + + return {}, 200 diff --git a/api/controllers/service_api/app/workflow_events.py b/api/controllers/service_api/app/workflow_events.py new file mode 100644 index 0000000000..b281b271c0 --- /dev/null +++ b/api/controllers/service_api/app/workflow_events.py @@ -0,0 +1,142 @@ +""" +Service API workflow resume event stream endpoints. +""" + +import json +from collections.abc import Generator + +from flask import Response, request +from flask_restx import Resource +from sqlalchemy.orm import sessionmaker +from werkzeug.exceptions import NotFound + +from controllers.service_api import service_api_ns +from controllers.service_api.app.error import NotWorkflowAppError +from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token +from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator +from core.app.apps.base_app_generator import BaseAppGenerator +from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter +from core.app.apps.message_generator import MessageGenerator +from core.app.apps.workflow.app_generator import WorkflowAppGenerator +from core.app.entities.task_entities import StreamEvent +from core.workflow.human_input_policy import HumanInputSurface +from extensions.ext_database import db +from models.enums import CreatorUserRole +from models.model import App, AppMode, EndUser +from repositories.factory import DifyAPIRepositoryFactory +from services.workflow_event_snapshot_service import build_workflow_event_stream + + +@service_api_ns.route("/workflow//events") +class WorkflowEventsApi(Resource): + """Service API for getting workflow execution events after resume.""" + + @service_api_ns.doc("get_workflow_events") + @service_api_ns.doc(description="Get workflow execution events stream after resume") + @service_api_ns.doc( + params={ + "task_id": "Workflow run ID", + "user": "End user identifier (query param)", + "include_state_snapshot": ( + "Whether to replay from persisted state snapshot, " + 'specify `"true"` to include a status snapshot of executed nodes' + ), + "continue_on_pause": ( + "Whether to keep the stream open across workflow_paused events," + 'specify `"true"` to keep the stream open for `workflow_paused` events.' + ), + } + ) + @service_api_ns.doc( + responses={ + 200: "SSE event stream", + 401: "Unauthorized - invalid API token", + 404: "Workflow run not found", + } + ) + @validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY, required=True)) + def get(self, app_model: App, end_user: EndUser, task_id: str): + app_mode = AppMode.value_of(app_model.mode) + if app_mode not in {AppMode.WORKFLOW, AppMode.ADVANCED_CHAT}: + raise NotWorkflowAppError() + + session_maker = sessionmaker(db.engine) + repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) + workflow_run = repo.get_workflow_run_by_id_and_tenant_id( + tenant_id=app_model.tenant_id, + run_id=task_id, + ) + + if workflow_run is None: + raise NotFound("Workflow run not found") + + if workflow_run.app_id != app_model.id: + raise NotFound("Workflow run not found") + + if workflow_run.created_by_role != CreatorUserRole.END_USER: + raise NotFound("Workflow run not found") + + if workflow_run.created_by != end_user.id: + raise NotFound("Workflow run not found") + + workflow_run_entity = workflow_run + + if workflow_run_entity.finished_at is not None: + response = WorkflowResponseConverter.workflow_run_result_to_finish_response( + task_id=workflow_run_entity.id, + workflow_run=workflow_run_entity, + creator_user=end_user, + ) + + payload = response.model_dump(mode="json") + payload["event"] = response.event.value + + def _generate_finished_events() -> Generator[str, None, None]: + yield f"data: {json.dumps(payload)}\n\n" + + event_generator = _generate_finished_events + else: + msg_generator = MessageGenerator() + generator: BaseAppGenerator + if app_mode == AppMode.ADVANCED_CHAT: + generator = AdvancedChatAppGenerator() + elif app_mode == AppMode.WORKFLOW: + generator = WorkflowAppGenerator() + else: + raise NotWorkflowAppError() + + include_state_snapshot = request.args.get("include_state_snapshot", "false").lower() == "true" + continue_on_pause = request.args.get("continue_on_pause", "false").lower() == "true" + terminal_events: list[StreamEvent] | None = [] if continue_on_pause else None + + def _generate_stream_events(): + if include_state_snapshot: + return generator.convert_to_event_stream( + build_workflow_event_stream( + app_mode=app_mode, + workflow_run=workflow_run_entity, + tenant_id=app_model.tenant_id, + app_id=app_model.id, + session_maker=session_maker, + human_input_surface=HumanInputSurface.SERVICE_API, + close_on_pause=not continue_on_pause, + ) + ) + return generator.convert_to_event_stream( + msg_generator.retrieve_events( + app_mode, + workflow_run_entity.id, + terminal_events=terminal_events, + ), + ) + + event_generator = _generate_stream_events + + return Response( + event_generator(), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + ) diff --git a/api/controllers/web/human_input_form.py b/api/controllers/web/human_input_form.py index 44876f8303..1ddf2e0717 100644 --- a/api/controllers/web/human_input_form.py +++ b/api/controllers/web/human_input_form.py @@ -9,11 +9,11 @@ from typing import Any, NotRequired, TypedDict from flask import Response, request from flask_restx import Resource -from pydantic import BaseModel from sqlalchemy import select from werkzeug.exceptions import Forbidden from configs import dify_config +from controllers.common.human_input import HumanInputFormSubmitPayload from controllers.web import web_ns from controllers.web.error import NotFoundError, WebFormRateLimitExceededError from controllers.web.site import serialize_app_site_payload @@ -26,11 +26,6 @@ from services.human_input_service import Form, FormNotFoundError, HumanInputServ logger = logging.getLogger(__name__) -class HumanInputFormSubmitPayload(BaseModel): - inputs: dict - action: str - - _FORM_SUBMIT_RATE_LIMITER = RateLimiter( prefix="web_form_submit_rate_limit", max_attempts=dify_config.WEB_FORM_SUBMIT_RATE_LIMIT_MAX_ATTEMPTS, diff --git a/api/core/app/apps/advanced_chat/app_generator.py b/api/core/app/apps/advanced_chat/app_generator.py index 9e64b471cb..b79d5514b4 100644 --- a/api/core/app/apps/advanced_chat/app_generator.py +++ b/api/core/app/apps/advanced_chat/app_generator.py @@ -34,7 +34,11 @@ from core.app.apps.exc import GenerateTaskStoppedError from core.app.apps.message_based_app_generator import MessageBasedAppGenerator from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom -from core.app.entities.task_entities import ChatbotAppBlockingResponse, ChatbotAppStreamResponse +from core.app.entities.task_entities import ( + AdvancedChatPausedBlockingResponse, + ChatbotAppBlockingResponse, + ChatbotAppStreamResponse, +) from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, PauseStatePersistenceLayer from core.helper.trace_id_helper import extract_external_trace_id_from_args from core.ops.ops_trace_manager import TraceQueueManager @@ -655,7 +659,11 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator): user: Account | EndUser, draft_var_saver_factory: DraftVariableSaverFactory, stream: bool = False, - ) -> ChatbotAppBlockingResponse | Generator[ChatbotAppStreamResponse, None, None]: + ) -> ( + ChatbotAppBlockingResponse + | AdvancedChatPausedBlockingResponse + | Generator[ChatbotAppStreamResponse, None, None] + ): """ Handle response. :param application_generate_entity: application generate entity diff --git a/api/core/app/apps/advanced_chat/generate_response_converter.py b/api/core/app/apps/advanced_chat/generate_response_converter.py index fe2702ed69..7cb0c9a8d3 100644 --- a/api/core/app/apps/advanced_chat/generate_response_converter.py +++ b/api/core/app/apps/advanced_chat/generate_response_converter.py @@ -3,7 +3,7 @@ from typing import Any, cast from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter from core.app.entities.task_entities import ( - AppBlockingResponse, + AdvancedChatPausedBlockingResponse, AppStreamResponse, ChatbotAppBlockingResponse, ChatbotAppStreamResponse, @@ -12,22 +12,40 @@ from core.app.entities.task_entities import ( NodeFinishStreamResponse, NodeStartStreamResponse, PingStreamResponse, + StreamEvent, ) -class AdvancedChatAppGenerateResponseConverter(AppGenerateResponseConverter): - _blocking_response_type = ChatbotAppBlockingResponse - +class AdvancedChatAppGenerateResponseConverter( + AppGenerateResponseConverter[ChatbotAppBlockingResponse | AdvancedChatPausedBlockingResponse] +): @classmethod - def convert_blocking_full_response(cls, blocking_response: AppBlockingResponse) -> dict[str, Any]: + def convert_blocking_full_response( + cls, blocking_response: ChatbotAppBlockingResponse | AdvancedChatPausedBlockingResponse + ) -> dict[str, Any]: """ Convert blocking full response. :param blocking_response: blocking response :return: """ - blocking_response = cast(ChatbotAppBlockingResponse, blocking_response) + if isinstance(blocking_response, AdvancedChatPausedBlockingResponse): + paused_data = blocking_response.data.model_dump(mode="json") + return { + "event": StreamEvent.WORKFLOW_PAUSED.value, + "task_id": blocking_response.task_id, + "id": blocking_response.data.id, + "message_id": blocking_response.data.message_id, + "conversation_id": blocking_response.data.conversation_id, + "mode": blocking_response.data.mode, + "answer": blocking_response.data.answer, + "metadata": blocking_response.data.metadata, + "created_at": blocking_response.data.created_at, + "workflow_run_id": blocking_response.data.workflow_run_id, + "data": paused_data, + } + response = { - "event": "message", + "event": StreamEvent.MESSAGE.value, "task_id": blocking_response.task_id, "id": blocking_response.data.id, "message_id": blocking_response.data.message_id, @@ -41,7 +59,9 @@ class AdvancedChatAppGenerateResponseConverter(AppGenerateResponseConverter): return response @classmethod - def convert_blocking_simple_response(cls, blocking_response: AppBlockingResponse) -> dict[str, Any]: + def convert_blocking_simple_response( + cls, blocking_response: ChatbotAppBlockingResponse | AdvancedChatPausedBlockingResponse + ) -> dict[str, Any]: """ Convert blocking simple response. :param blocking_response: blocking response @@ -50,7 +70,8 @@ class AdvancedChatAppGenerateResponseConverter(AppGenerateResponseConverter): response = cls.convert_blocking_full_response(blocking_response) metadata = response.get("metadata", {}) - response["metadata"] = cls._get_simple_metadata(metadata) + if isinstance(metadata, dict): + response["metadata"] = cls._get_simple_metadata(metadata) return response 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 78b582bdf5..82dbf5381d 100644 --- a/api/core/app/apps/advanced_chat/generate_task_pipeline.py +++ b/api/core/app/apps/advanced_chat/generate_task_pipeline.py @@ -53,14 +53,18 @@ from core.app.entities.queue_entities import ( WorkflowQueueMessage, ) from core.app.entities.task_entities import ( + AdvancedChatPausedBlockingResponse, ChatbotAppBlockingResponse, ChatbotAppStreamResponse, ErrorStreamResponse, + HumanInputRequiredPauseReasonPayload, + HumanInputRequiredResponse, MessageAudioEndStreamResponse, MessageAudioStreamResponse, MessageEndStreamResponse, PingStreamResponse, StreamResponse, + WorkflowPauseStreamResponse, WorkflowTaskState, ) from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline @@ -210,7 +214,13 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): if message.status == MessageStatus.PAUSED and message.answer: self._task_state.answer = message.answer - def process(self) -> Union[ChatbotAppBlockingResponse, Generator[ChatbotAppStreamResponse, None, None]]: + def process( + self, + ) -> Union[ + ChatbotAppBlockingResponse, + AdvancedChatPausedBlockingResponse, + Generator[ChatbotAppStreamResponse, None, None], + ]: """ Process generate task pipeline. :return: @@ -226,14 +236,39 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): else: return self._to_blocking_response(generator) - def _to_blocking_response(self, generator: Generator[StreamResponse, None, None]) -> ChatbotAppBlockingResponse: + def _to_blocking_response( + self, generator: Generator[StreamResponse, None, None] + ) -> Union[ChatbotAppBlockingResponse, AdvancedChatPausedBlockingResponse]: """ Process blocking response. :return: """ + human_input_responses: list[HumanInputRequiredResponse] = [] for stream_response in generator: if isinstance(stream_response, ErrorStreamResponse): raise stream_response.err + elif isinstance(stream_response, HumanInputRequiredResponse): + human_input_responses.append(stream_response) + elif isinstance(stream_response, WorkflowPauseStreamResponse): + return AdvancedChatPausedBlockingResponse( + task_id=stream_response.task_id, + data=AdvancedChatPausedBlockingResponse.Data( + id=self._message_id, + mode=self._conversation_mode, + conversation_id=self._conversation_id, + message_id=self._message_id, + workflow_run_id=stream_response.data.workflow_run_id, + answer=self._task_state.answer, + metadata=self._message_end_to_stream_response().metadata, + created_at=self._message_created_at, + paused_nodes=stream_response.data.paused_nodes, + reasons=stream_response.data.reasons, + status=stream_response.data.status, + elapsed_time=stream_response.data.elapsed_time, + total_tokens=stream_response.data.total_tokens, + total_steps=stream_response.data.total_steps, + ), + ) elif isinstance(stream_response, MessageEndStreamResponse): extras = {} if stream_response.metadata: @@ -254,8 +289,41 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport): else: continue + if human_input_responses: + return self._build_paused_blocking_response_from_human_input(human_input_responses) + raise ValueError("queue listening stopped unexpectedly.") + def _build_paused_blocking_response_from_human_input( + self, human_input_responses: list[HumanInputRequiredResponse] + ) -> AdvancedChatPausedBlockingResponse: + runtime_state = self._resolve_graph_runtime_state() + paused_nodes = list(dict.fromkeys(response.data.node_id for response in human_input_responses)) + reasons = [ + HumanInputRequiredPauseReasonPayload.from_response_data(response.data).model_dump(mode="json") + for response in human_input_responses + ] + + return AdvancedChatPausedBlockingResponse( + task_id=self._application_generate_entity.task_id, + data=AdvancedChatPausedBlockingResponse.Data( + id=self._message_id, + mode=self._conversation_mode, + conversation_id=self._conversation_id, + message_id=self._message_id, + workflow_run_id=human_input_responses[-1].workflow_run_id, + answer=self._task_state.answer, + metadata=self._message_end_to_stream_response().metadata, + created_at=self._message_created_at, + paused_nodes=paused_nodes, + reasons=reasons, + status=WorkflowExecutionStatus.PAUSED, + elapsed_time=time.perf_counter() - self._base_task_pipeline.start_at, + total_tokens=runtime_state.total_tokens, + total_steps=runtime_state.node_run_steps, + ), + ) + def _to_stream_response( self, generator: Generator[StreamResponse, None, None] ) -> Generator[ChatbotAppStreamResponse, Any, None]: diff --git a/api/core/app/apps/agent_chat/generate_response_converter.py b/api/core/app/apps/agent_chat/generate_response_converter.py index 731c6ee12e..03bc0a9108 100644 --- a/api/core/app/apps/agent_chat/generate_response_converter.py +++ b/api/core/app/apps/agent_chat/generate_response_converter.py @@ -1,6 +1,8 @@ from collections.abc import Generator from typing import Any, cast +from pydantic import JsonValue + from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter from core.app.entities.task_entities import ( AppStreamResponse, @@ -12,11 +14,9 @@ from core.app.entities.task_entities import ( ) -class AgentChatAppGenerateResponseConverter(AppGenerateResponseConverter): - _blocking_response_type = ChatbotAppBlockingResponse - +class AgentChatAppGenerateResponseConverter(AppGenerateResponseConverter[ChatbotAppBlockingResponse]): @classmethod - def convert_blocking_full_response(cls, blocking_response: ChatbotAppBlockingResponse): # type: ignore[override] + def convert_blocking_full_response(cls, blocking_response: ChatbotAppBlockingResponse): """ Convert blocking full response. :param blocking_response: blocking response @@ -37,7 +37,7 @@ class AgentChatAppGenerateResponseConverter(AppGenerateResponseConverter): return response @classmethod - def convert_blocking_simple_response(cls, blocking_response: ChatbotAppBlockingResponse): # type: ignore[override] + def convert_blocking_simple_response(cls, blocking_response: ChatbotAppBlockingResponse): """ Convert blocking simple response. :param blocking_response: blocking response @@ -70,7 +70,7 @@ class AgentChatAppGenerateResponseConverter(AppGenerateResponseConverter): yield "ping" continue - response_chunk = { + response_chunk: dict[str, JsonValue] = { "event": sub_stream_response.event.value, "conversation_id": chunk.conversation_id, "message_id": chunk.message_id, @@ -101,7 +101,7 @@ class AgentChatAppGenerateResponseConverter(AppGenerateResponseConverter): yield "ping" continue - response_chunk = { + response_chunk: dict[str, JsonValue] = { "event": sub_stream_response.event.value, "conversation_id": chunk.conversation_id, "message_id": chunk.message_id, diff --git a/api/core/app/apps/base_app_generate_response_converter.py b/api/core/app/apps/base_app_generate_response_converter.py index d5edfaeb25..abcbb2f943 100644 --- a/api/core/app/apps/base_app_generate_response_converter.py +++ b/api/core/app/apps/base_app_generate_response_converter.py @@ -1,7 +1,9 @@ import logging from abc import ABC, abstractmethod from collections.abc import Generator, Mapping -from typing import Any, Union +from typing import Any, Union, cast + +from pydantic import JsonValue from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.task_entities import AppBlockingResponse, AppStreamResponse @@ -11,8 +13,10 @@ from graphon.model_runtime.errors.invoke import InvokeError logger = logging.getLogger(__name__) -class AppGenerateResponseConverter(ABC): - _blocking_response_type: type[AppBlockingResponse] +class AppGenerateResponseConverter[TBlockingResponse: AppBlockingResponse](ABC): + @classmethod + def _cast_blocking_response(cls, response: AppBlockingResponse) -> TBlockingResponse: + return cast(TBlockingResponse, response) @classmethod def convert( @@ -20,7 +24,7 @@ class AppGenerateResponseConverter(ABC): ) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], Any, None]: if invoke_from in {InvokeFrom.DEBUGGER, InvokeFrom.SERVICE_API}: if isinstance(response, AppBlockingResponse): - return cls.convert_blocking_full_response(response) + return cls.convert_blocking_full_response(cls._cast_blocking_response(response)) else: def _generate_full_response() -> Generator[dict[str, Any] | str, Any, None]: @@ -29,7 +33,7 @@ class AppGenerateResponseConverter(ABC): return _generate_full_response() else: if isinstance(response, AppBlockingResponse): - return cls.convert_blocking_simple_response(response) + return cls.convert_blocking_simple_response(cls._cast_blocking_response(response)) else: def _generate_simple_response() -> Generator[dict[str, Any] | str, Any, None]: @@ -39,12 +43,12 @@ class AppGenerateResponseConverter(ABC): @classmethod @abstractmethod - def convert_blocking_full_response(cls, blocking_response: AppBlockingResponse) -> dict[str, Any]: + def convert_blocking_full_response(cls, blocking_response: TBlockingResponse) -> dict[str, Any]: raise NotImplementedError @classmethod @abstractmethod - def convert_blocking_simple_response(cls, blocking_response: AppBlockingResponse) -> dict[str, Any]: + def convert_blocking_simple_response(cls, blocking_response: TBlockingResponse) -> dict[str, Any]: raise NotImplementedError @classmethod @@ -106,13 +110,13 @@ class AppGenerateResponseConverter(ABC): return metadata @classmethod - def _error_to_stream_response(cls, e: Exception) -> dict[str, Any]: + def _error_to_stream_response(cls, e: Exception) -> dict[str, JsonValue]: """ Error to stream response. :param e: exception :return: """ - error_responses: dict[type[Exception], dict[str, Any]] = { + error_responses: dict[type[Exception], dict[str, JsonValue]] = { ValueError: {"code": "invalid_param", "status": 400}, ProviderTokenNotInitError: {"code": "provider_not_initialize", "status": 400}, QuotaExceededError: { @@ -126,7 +130,7 @@ class AppGenerateResponseConverter(ABC): } # Determine the response based on the type of exception - data: dict[str, Any] | None = None + data: dict[str, JsonValue] | None = None for k, v in error_responses.items(): if isinstance(e, k): data = v diff --git a/api/core/app/apps/chat/generate_response_converter.py b/api/core/app/apps/chat/generate_response_converter.py index 3d0375151d..26efcbfafd 100644 --- a/api/core/app/apps/chat/generate_response_converter.py +++ b/api/core/app/apps/chat/generate_response_converter.py @@ -1,6 +1,8 @@ from collections.abc import Generator from typing import Any, cast +from pydantic import JsonValue + from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter from core.app.entities.task_entities import ( AppStreamResponse, @@ -12,11 +14,9 @@ from core.app.entities.task_entities import ( ) -class ChatAppGenerateResponseConverter(AppGenerateResponseConverter): - _blocking_response_type = ChatbotAppBlockingResponse - +class ChatAppGenerateResponseConverter(AppGenerateResponseConverter[ChatbotAppBlockingResponse]): @classmethod - def convert_blocking_full_response(cls, blocking_response: ChatbotAppBlockingResponse): # type: ignore[override] + def convert_blocking_full_response(cls, blocking_response: ChatbotAppBlockingResponse): """ Convert blocking full response. :param blocking_response: blocking response @@ -37,7 +37,7 @@ class ChatAppGenerateResponseConverter(AppGenerateResponseConverter): return response @classmethod - def convert_blocking_simple_response(cls, blocking_response: ChatbotAppBlockingResponse): # type: ignore[override] + def convert_blocking_simple_response(cls, blocking_response: ChatbotAppBlockingResponse): """ Convert blocking simple response. :param blocking_response: blocking response @@ -70,7 +70,7 @@ class ChatAppGenerateResponseConverter(AppGenerateResponseConverter): yield "ping" continue - response_chunk = { + response_chunk: dict[str, JsonValue] = { "event": sub_stream_response.event.value, "conversation_id": chunk.conversation_id, "message_id": chunk.message_id, @@ -101,7 +101,7 @@ class ChatAppGenerateResponseConverter(AppGenerateResponseConverter): yield "ping" continue - response_chunk = { + response_chunk: dict[str, JsonValue] = { "event": sub_stream_response.event.value, "conversation_id": chunk.conversation_id, "message_id": chunk.message_id, diff --git a/api/core/app/apps/common/workflow_response_converter.py b/api/core/app/apps/common/workflow_response_converter.py index bd685d5189..7bab3f7bff 100644 --- a/api/core/app/apps/common/workflow_response_converter.py +++ b/api/core/app/apps/common/workflow_response_converter.py @@ -52,6 +52,7 @@ from core.tools.tool_manager import ToolManager from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE from core.trigger.trigger_manager import TriggerManager from core.workflow.human_input_forms import load_form_tokens_by_form_id +from core.workflow.human_input_policy import HumanInputSurface, enrich_human_input_pause_reasons from core.workflow.system_variables import SystemVariableKey, system_variables_to_mapping from core.workflow.workflow_entry import WorkflowEntry from extensions.ext_database import db @@ -336,7 +337,26 @@ class WorkflowResponseConverter: except (TypeError, json.JSONDecodeError): definition_payload = {} display_in_ui_by_form_id[str(form_id)] = bool(definition_payload.get("display_in_ui")) - form_token_by_form_id = load_form_tokens_by_form_id(human_input_form_ids, session=session) + form_token_by_form_id = load_form_tokens_by_form_id( + human_input_form_ids, + session=session, + surface=( + HumanInputSurface.SERVICE_API + if self._application_generate_entity.invoke_from == InvokeFrom.SERVICE_API + else None + ), + ) + + # Reconnect paths must preserve the same pause-reason contract as live streams; + # otherwise clients see schema drift after resume. + pause_reasons = enrich_human_input_pause_reasons( + pause_reasons, + form_tokens_by_form_id=form_token_by_form_id, + expiration_times_by_form_id={ + form_id: int(expiration_time.timestamp()) + for form_id, expiration_time in expiration_times_by_form_id.items() + }, + ) responses: list[StreamResponse] = [] diff --git a/api/core/app/apps/completion/generate_response_converter.py b/api/core/app/apps/completion/generate_response_converter.py index 71886b39ba..ad978f58e0 100644 --- a/api/core/app/apps/completion/generate_response_converter.py +++ b/api/core/app/apps/completion/generate_response_converter.py @@ -1,6 +1,8 @@ from collections.abc import Generator from typing import Any, cast +from pydantic import JsonValue + from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter from core.app.entities.task_entities import ( AppStreamResponse, @@ -12,17 +14,15 @@ from core.app.entities.task_entities import ( ) -class CompletionAppGenerateResponseConverter(AppGenerateResponseConverter): - _blocking_response_type = CompletionAppBlockingResponse - +class CompletionAppGenerateResponseConverter(AppGenerateResponseConverter[CompletionAppBlockingResponse]): @classmethod - def convert_blocking_full_response(cls, blocking_response: CompletionAppBlockingResponse): # type: ignore[override] + def convert_blocking_full_response(cls, blocking_response: CompletionAppBlockingResponse): """ Convert blocking full response. :param blocking_response: blocking response :return: """ - response = { + response: dict[str, Any] = { "event": "message", "task_id": blocking_response.task_id, "id": blocking_response.data.id, @@ -36,7 +36,7 @@ class CompletionAppGenerateResponseConverter(AppGenerateResponseConverter): return response @classmethod - def convert_blocking_simple_response(cls, blocking_response: CompletionAppBlockingResponse): # type: ignore[override] + def convert_blocking_simple_response(cls, blocking_response: CompletionAppBlockingResponse): """ Convert blocking simple response. :param blocking_response: blocking response @@ -69,7 +69,7 @@ class CompletionAppGenerateResponseConverter(AppGenerateResponseConverter): yield "ping" continue - response_chunk = { + response_chunk: dict[str, JsonValue] = { "event": sub_stream_response.event.value, "message_id": chunk.message_id, "created_at": chunk.created_at, @@ -99,7 +99,7 @@ class CompletionAppGenerateResponseConverter(AppGenerateResponseConverter): yield "ping" continue - response_chunk = { + response_chunk: dict[str, JsonValue] = { "event": sub_stream_response.event.value, "message_id": chunk.message_id, "created_at": chunk.created_at, diff --git a/api/core/app/apps/message_generator.py b/api/core/app/apps/message_generator.py index 68631bb230..c04f20c796 100644 --- a/api/core/app/apps/message_generator.py +++ b/api/core/app/apps/message_generator.py @@ -1,6 +1,7 @@ -from collections.abc import Callable, Generator, Mapping +from collections.abc import Callable, Generator, Iterable, Mapping from core.app.apps.streaming_utils import stream_topic_events +from core.app.entities.task_entities import StreamEvent from extensions.ext_redis import get_pubsub_broadcast_channel from libs.broadcast_channel.channel import Topic from models.model import AppMode @@ -26,6 +27,7 @@ class MessageGenerator: idle_timeout=300, ping_interval: float = 10.0, on_subscribe: Callable[[], None] | None = None, + terminal_events: Iterable[str | StreamEvent] | None = None, ) -> Generator[Mapping | str, None, None]: topic = cls.get_response_topic(app_mode, workflow_run_id) return stream_topic_events( @@ -33,4 +35,5 @@ class MessageGenerator: idle_timeout=idle_timeout, ping_interval=ping_interval, on_subscribe=on_subscribe, + terminal_events=terminal_events, ) diff --git a/api/core/app/apps/pipeline/generate_response_converter.py b/api/core/app/apps/pipeline/generate_response_converter.py index 02b3160b7c..3913657ae8 100644 --- a/api/core/app/apps/pipeline/generate_response_converter.py +++ b/api/core/app/apps/pipeline/generate_response_converter.py @@ -13,11 +13,9 @@ from core.app.entities.task_entities import ( ) -class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter): - _blocking_response_type = WorkflowAppBlockingResponse - +class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter[WorkflowAppBlockingResponse]): @classmethod - def convert_blocking_full_response(cls, blocking_response: WorkflowAppBlockingResponse) -> dict[str, Any]: # type: ignore[override] + def convert_blocking_full_response(cls, blocking_response: WorkflowAppBlockingResponse) -> dict[str, object]: """ Convert blocking full response. :param blocking_response: blocking response @@ -26,7 +24,7 @@ class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter): return dict(blocking_response.model_dump()) @classmethod - def convert_blocking_simple_response(cls, blocking_response: WorkflowAppBlockingResponse) -> dict[str, Any]: # type: ignore[override] + def convert_blocking_simple_response(cls, blocking_response: WorkflowAppBlockingResponse) -> dict[str, object]: """ Convert blocking simple response. :param blocking_response: blocking response diff --git a/api/core/app/apps/pipeline/pipeline_generator.py b/api/core/app/apps/pipeline/pipeline_generator.py index 4b2f17189b..4a76d0809e 100644 --- a/api/core/app/apps/pipeline/pipeline_generator.py +++ b/api/core/app/apps/pipeline/pipeline_generator.py @@ -27,7 +27,11 @@ from core.app.apps.workflow.generate_response_converter import WorkflowAppGenera from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline from core.app.entities.app_invoke_entities import InvokeFrom, RagPipelineGenerateEntity from core.app.entities.rag_pipeline_invoke_entities import RagPipelineInvokeEntity -from core.app.entities.task_entities import WorkflowAppBlockingResponse, WorkflowAppStreamResponse +from core.app.entities.task_entities import ( + WorkflowAppBlockingResponse, + WorkflowAppPausedBlockingResponse, + WorkflowAppStreamResponse, +) from core.datasource.entities.datasource_entities import ( DatasourceProviderType, OnlineDriveBrowseFilesRequest, @@ -627,7 +631,11 @@ class PipelineGenerator(BaseAppGenerator): user: Account | EndUser, draft_var_saver_factory: DraftVariableSaverFactory, stream: bool = False, - ) -> WorkflowAppBlockingResponse | Generator[WorkflowAppStreamResponse, None, None]: + ) -> ( + WorkflowAppBlockingResponse + | WorkflowAppPausedBlockingResponse + | Generator[WorkflowAppStreamResponse, None, None] + ): """ Handle response. :param application_generate_entity: application generate entity diff --git a/api/core/app/apps/streaming_utils.py b/api/core/app/apps/streaming_utils.py index af3441aca3..5743bad4b6 100644 --- a/api/core/app/apps/streaming_utils.py +++ b/api/core/app/apps/streaming_utils.py @@ -59,7 +59,7 @@ def stream_topic_events( def _normalize_terminal_events(terminal_events: Iterable[str | StreamEvent] | None) -> set[str]: - if not terminal_events: + if terminal_events is None: return {StreamEvent.WORKFLOW_FINISHED.value, StreamEvent.WORKFLOW_PAUSED.value} values: set[str] = set() for item in terminal_events: diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index 6937014a06..e811c2b2e0 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/api/core/app/apps/workflow/app_generator.py @@ -25,7 +25,11 @@ from core.app.apps.workflow.app_runner import WorkflowAppRunner from core.app.apps.workflow.generate_response_converter import WorkflowAppGenerateResponseConverter from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity -from core.app.entities.task_entities import WorkflowAppBlockingResponse, WorkflowAppStreamResponse +from core.app.entities.task_entities import ( + WorkflowAppBlockingResponse, + WorkflowAppPausedBlockingResponse, + WorkflowAppStreamResponse, +) from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, PauseStatePersistenceLayer from core.db.session_factory import session_factory from core.helper.trace_id_helper import extract_external_trace_id_from_args @@ -612,7 +616,11 @@ class WorkflowAppGenerator(BaseAppGenerator): user: Account | EndUser, draft_var_saver_factory: DraftVariableSaverFactory, stream: bool = False, - ) -> WorkflowAppBlockingResponse | Generator[WorkflowAppStreamResponse, None, None]: + ) -> ( + WorkflowAppBlockingResponse + | WorkflowAppPausedBlockingResponse + | Generator[WorkflowAppStreamResponse, None, None] + ): """ Handle response. :param application_generate_entity: application generate entity diff --git a/api/core/app/apps/workflow/generate_response_converter.py b/api/core/app/apps/workflow/generate_response_converter.py index c69826cbef..4037388798 100644 --- a/api/core/app/apps/workflow/generate_response_converter.py +++ b/api/core/app/apps/workflow/generate_response_converter.py @@ -9,24 +9,29 @@ from core.app.entities.task_entities import ( NodeStartStreamResponse, PingStreamResponse, WorkflowAppBlockingResponse, + WorkflowAppPausedBlockingResponse, WorkflowAppStreamResponse, ) -class WorkflowAppGenerateResponseConverter(AppGenerateResponseConverter): - _blocking_response_type = WorkflowAppBlockingResponse - +class WorkflowAppGenerateResponseConverter( + AppGenerateResponseConverter[WorkflowAppBlockingResponse | WorkflowAppPausedBlockingResponse] +): @classmethod - def convert_blocking_full_response(cls, blocking_response: WorkflowAppBlockingResponse): # type: ignore[override] + def convert_blocking_full_response( + cls, blocking_response: WorkflowAppBlockingResponse | WorkflowAppPausedBlockingResponse + ) -> dict[str, Any]: """ Convert blocking full response. :param blocking_response: blocking response :return: """ - return blocking_response.model_dump() + return dict(blocking_response.model_dump()) @classmethod - def convert_blocking_simple_response(cls, blocking_response: WorkflowAppBlockingResponse): # type: ignore[override] + def convert_blocking_simple_response( + cls, blocking_response: WorkflowAppBlockingResponse | WorkflowAppPausedBlockingResponse + ) -> dict[str, Any]: """ Convert blocking simple response. :param blocking_response: blocking response diff --git a/api/core/app/apps/workflow/generate_task_pipeline.py b/api/core/app/apps/workflow/generate_task_pipeline.py index 15645add57..87d9b73078 100644 --- a/api/core/app/apps/workflow/generate_task_pipeline.py +++ b/api/core/app/apps/workflow/generate_task_pipeline.py @@ -42,12 +42,15 @@ from core.app.entities.queue_entities import ( ) from core.app.entities.task_entities import ( ErrorStreamResponse, + HumanInputRequiredPauseReasonPayload, + HumanInputRequiredResponse, MessageAudioEndStreamResponse, MessageAudioStreamResponse, PingStreamResponse, StreamResponse, TextChunkStreamResponse, WorkflowAppBlockingResponse, + WorkflowAppPausedBlockingResponse, WorkflowAppStreamResponse, WorkflowFinishStreamResponse, WorkflowPauseStreamResponse, @@ -118,7 +121,11 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport): ) self._graph_runtime_state: GraphRuntimeState | None = self._base_task_pipeline.queue_manager.graph_runtime_state - def process(self) -> Union[WorkflowAppBlockingResponse, Generator[WorkflowAppStreamResponse, None, None]]: + def process( + self, + ) -> Union[ + WorkflowAppBlockingResponse, WorkflowAppPausedBlockingResponse, Generator[WorkflowAppStreamResponse, None, None] + ]: """ Process generate task pipeline. :return: @@ -129,19 +136,24 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport): else: return self._to_blocking_response(generator) - def _to_blocking_response(self, generator: Generator[StreamResponse, None, None]) -> WorkflowAppBlockingResponse: + def _to_blocking_response( + self, generator: Generator[StreamResponse, None, None] + ) -> Union[WorkflowAppBlockingResponse, WorkflowAppPausedBlockingResponse]: """ To blocking response. :return: """ + human_input_responses: list[HumanInputRequiredResponse] = [] for stream_response in generator: if isinstance(stream_response, ErrorStreamResponse): raise stream_response.err + elif isinstance(stream_response, HumanInputRequiredResponse): + human_input_responses.append(stream_response) elif isinstance(stream_response, WorkflowPauseStreamResponse): - response = WorkflowAppBlockingResponse( + return WorkflowAppPausedBlockingResponse( task_id=self._application_generate_entity.task_id, workflow_run_id=stream_response.data.workflow_run_id, - data=WorkflowAppBlockingResponse.Data( + data=WorkflowAppPausedBlockingResponse.Data( id=stream_response.data.workflow_run_id, workflow_id=self._workflow.id, status=stream_response.data.status, @@ -152,12 +164,13 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport): total_steps=stream_response.data.total_steps, created_at=stream_response.data.created_at, finished_at=None, + paused_nodes=stream_response.data.paused_nodes, + reasons=stream_response.data.reasons, ), ) - return response elif isinstance(stream_response, WorkflowFinishStreamResponse): - response = WorkflowAppBlockingResponse( + return WorkflowAppBlockingResponse( task_id=self._application_generate_entity.task_id, workflow_run_id=stream_response.data.id, data=WorkflowAppBlockingResponse.Data( @@ -174,12 +187,44 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport): ), ) - return response else: continue + if human_input_responses: + return self._build_paused_blocking_response_from_human_input(human_input_responses) + raise ValueError("queue listening stopped unexpectedly.") + def _build_paused_blocking_response_from_human_input( + self, human_input_responses: list[HumanInputRequiredResponse] + ) -> WorkflowAppPausedBlockingResponse: + runtime_state = self._resolve_graph_runtime_state() + paused_nodes = list(dict.fromkeys(response.data.node_id for response in human_input_responses)) + created_at = int(runtime_state.start_at) + reasons = [ + HumanInputRequiredPauseReasonPayload.from_response_data(response.data).model_dump(mode="json") + for response in human_input_responses + ] + + return WorkflowAppPausedBlockingResponse( + task_id=self._application_generate_entity.task_id, + workflow_run_id=human_input_responses[-1].workflow_run_id, + data=WorkflowAppPausedBlockingResponse.Data( + id=human_input_responses[-1].workflow_run_id, + workflow_id=self._workflow.id, + status=WorkflowExecutionStatus.PAUSED, + outputs={}, + error=None, + elapsed_time=time.perf_counter() - self._base_task_pipeline.start_at, + total_tokens=runtime_state.total_tokens, + total_steps=runtime_state.node_run_steps, + created_at=created_at, + finished_at=None, + paused_nodes=paused_nodes, + reasons=reasons, + ), + ) + def _to_stream_response( self, generator: Generator[StreamResponse, None, None] ) -> Generator[WorkflowAppStreamResponse, None, None]: diff --git a/api/core/app/entities/task_entities.py b/api/core/app/entities/task_entities.py index 6e4ca69cf0..ad05566521 100644 --- a/api/core/app/entities/task_entities.py +++ b/api/core/app/entities/task_entities.py @@ -1,12 +1,13 @@ from collections.abc import Mapping, Sequence from enum import StrEnum -from typing import Any +from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, JsonValue from core.app.entities.agent_strategy import AgentStrategyInfo from core.rag.entities import RetrievalSourceMetadata from graphon.entities import WorkflowStartReason +from graphon.entities.pause_reason import PauseReasonType from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage from graphon.nodes.human_input.entities import FormInput, UserAction @@ -295,6 +296,40 @@ class HumanInputRequiredResponse(StreamResponse): data: Data +class HumanInputRequiredPauseReasonPayload(BaseModel): + """ + Public pause-reason payload used by blocking responses when only + ``human_input_required`` events are available. + """ + + TYPE: Literal[PauseReasonType.HUMAN_INPUT_REQUIRED] = PauseReasonType.HUMAN_INPUT_REQUIRED + form_id: str + node_id: str + node_title: str + form_content: str + inputs: Sequence[FormInput] = Field(default_factory=list) + actions: Sequence[UserAction] = Field(default_factory=list) + display_in_ui: bool = False + form_token: str | None = None + resolved_default_values: Mapping[str, Any] = Field(default_factory=dict) + expiration_time: int + + @classmethod + def from_response_data(cls, data: HumanInputRequiredResponse.Data) -> "HumanInputRequiredPauseReasonPayload": + return cls( + form_id=data.form_id, + node_id=data.node_id, + node_title=data.node_title, + form_content=data.form_content, + inputs=data.inputs, + actions=data.actions, + display_in_ui=data.display_in_ui, + form_token=data.form_token, + resolved_default_values=data.resolved_default_values, + expiration_time=data.expiration_time, + ) + + class HumanInputFormFilledResponse(StreamResponse): class Data(BaseModel): """ @@ -355,7 +390,7 @@ class NodeStartStreamResponse(StreamResponse): workflow_run_id: str data: Data - def to_ignore_detail_dict(self): + def to_ignore_detail_dict(self) -> dict[str, JsonValue]: return { "event": self.event.value, "task_id": self.task_id, @@ -412,7 +447,7 @@ class NodeFinishStreamResponse(StreamResponse): workflow_run_id: str data: Data - def to_ignore_detail_dict(self): + def to_ignore_detail_dict(self) -> dict[str, JsonValue]: return { "event": self.event.value, "task_id": self.task_id, @@ -774,6 +809,34 @@ class ChatbotAppBlockingResponse(AppBlockingResponse): data: Data +class AdvancedChatPausedBlockingResponse(AppBlockingResponse): + """ + ChatbotAppPausedBlockingResponse entity + """ + + class Data(BaseModel): + """ + Data entity + """ + + id: str + mode: str + conversation_id: str + message_id: str + workflow_run_id: str + answer: str + metadata: Mapping[str, object] = Field(default_factory=dict) + created_at: int + paused_nodes: Sequence[str] = Field(default_factory=list) + reasons: Sequence[Mapping[str, Any]] = Field(default_factory=list[Mapping[str, Any]]) + status: WorkflowExecutionStatus + elapsed_time: float + total_tokens: int + total_steps: int + + data: Data + + class CompletionAppBlockingResponse(AppBlockingResponse): """ CompletionAppBlockingResponse entity @@ -819,6 +882,33 @@ class WorkflowAppBlockingResponse(AppBlockingResponse): data: Data +class WorkflowAppPausedBlockingResponse(AppBlockingResponse): + """ + WorkflowAppPausedBlockingResponse entity + """ + + class Data(BaseModel): + """ + Data entity + """ + + id: str + workflow_id: str + status: WorkflowExecutionStatus + outputs: Mapping[str, Any] | None = None + error: str | None = None + elapsed_time: float + total_tokens: int + total_steps: int + created_at: int + finished_at: int | None + paused_nodes: Sequence[str] = Field(default_factory=list) + reasons: Sequence[Mapping[str, Any]] = Field(default_factory=list) + + workflow_run_id: str + data: Data + + class AgentLogStreamResponse(StreamResponse): """ AgentLogStreamResponse entity diff --git a/api/core/workflow/human_input_forms.py b/api/core/workflow/human_input_forms.py index f124b321d4..b02f69ec33 100644 --- a/api/core/workflow/human_input_forms.py +++ b/api/core/workflow/human_input_forms.py @@ -12,20 +12,16 @@ from collections.abc import Sequence from sqlalchemy import select from sqlalchemy.orm import Session +from core.workflow.human_input_policy import HumanInputSurface, get_preferred_form_token from extensions.ext_database import db from models.human_input import HumanInputFormRecipient, RecipientType -_FORM_TOKEN_PRIORITY = { - RecipientType.BACKSTAGE: 0, - RecipientType.CONSOLE: 1, - RecipientType.STANDALONE_WEB_APP: 2, -} - def load_form_tokens_by_form_id( form_ids: Sequence[str], *, session: Session | None = None, + surface: HumanInputSurface | None = None, ) -> dict[str, str]: """Load the preferred access token for each human input form.""" unique_form_ids = list(dict.fromkeys(form_ids)) @@ -33,23 +29,43 @@ def load_form_tokens_by_form_id( return {} if session is not None: - return _load_form_tokens_by_form_id(session, unique_form_ids) + return _load_form_tokens_by_form_id(session, unique_form_ids, surface=surface) with Session(bind=db.engine, expire_on_commit=False) as new_session: - return _load_form_tokens_by_form_id(new_session, unique_form_ids) + return _load_form_tokens_by_form_id(new_session, unique_form_ids, surface=surface) -def _load_form_tokens_by_form_id(session: Session, form_ids: Sequence[str]) -> dict[str, str]: - tokens_by_form_id: dict[str, tuple[int, str]] = {} +def _load_form_tokens_by_form_id( + session: Session, + form_ids: Sequence[str], + *, + surface: HumanInputSurface | None = None, +) -> dict[str, str]: + recipients_by_form_id: dict[str, list[tuple[RecipientType, str]]] = {} stmt = select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids)) for recipient in session.scalars(stmt): - priority = _FORM_TOKEN_PRIORITY.get(recipient.recipient_type) - if priority is None or not recipient.access_token: + if not recipient.access_token: continue + recipients_by_form_id.setdefault(recipient.form_id, []).append( + (recipient.recipient_type, recipient.access_token) + ) - candidate = (priority, recipient.access_token) - current = tokens_by_form_id.get(recipient.form_id) - if current is None or candidate[0] < current[0]: - tokens_by_form_id[recipient.form_id] = candidate + tokens_by_form_id: dict[str, str] = {} + for form_id, recipients in recipients_by_form_id.items(): + token = _get_surface_form_token(recipients, surface=surface) + if token is not None: + tokens_by_form_id[form_id] = token + return tokens_by_form_id - return {form_id: token for form_id, (_, token) in tokens_by_form_id.items()} + +def _get_surface_form_token( + recipients: Sequence[tuple[RecipientType, str]], + *, + surface: HumanInputSurface | None, +) -> str | None: + if surface == HumanInputSurface.SERVICE_API: + for recipient_type, token in recipients: + if recipient_type == RecipientType.STANDALONE_WEB_APP and token: + return token + + return get_preferred_form_token(recipients) diff --git a/api/core/workflow/human_input_policy.py b/api/core/workflow/human_input_policy.py new file mode 100644 index 0000000000..798eb8723f --- /dev/null +++ b/api/core/workflow/human_input_policy.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from enum import StrEnum +from typing import Any + +from graphon.entities.pause_reason import PauseReasonType +from models.human_input import RecipientType + + +class HumanInputSurface(StrEnum): + SERVICE_API = "service_api" + CONSOLE = "console" + + +# Service API is intentionally narrower than other surfaces: app-token callers +# should only be able to act on end-user web forms, not internal console flows. +_ALLOWED_RECIPIENT_TYPES_BY_SURFACE: dict[HumanInputSurface, frozenset[RecipientType]] = { + HumanInputSurface.SERVICE_API: frozenset({RecipientType.STANDALONE_WEB_APP}), + HumanInputSurface.CONSOLE: frozenset({RecipientType.CONSOLE, RecipientType.BACKSTAGE}), +} + +# A single HITL form can have multiple recipient records; this shared priority +# keeps every API surface consistent about which resume token to expose. +_RECIPIENT_TOKEN_PRIORITY: dict[RecipientType, int] = { + RecipientType.BACKSTAGE: 0, + RecipientType.CONSOLE: 1, + RecipientType.STANDALONE_WEB_APP: 2, +} + + +def is_recipient_type_allowed_for_surface( + recipient_type: RecipientType | None, + surface: HumanInputSurface, +) -> bool: + if recipient_type is None: + return False + return recipient_type in _ALLOWED_RECIPIENT_TYPES_BY_SURFACE[surface] + + +def get_preferred_form_token( + recipients: Sequence[tuple[RecipientType, str]], +) -> str | None: + chosen_token: str | None = None + chosen_priority: int | None = None + for recipient_type, token in recipients: + priority = _RECIPIENT_TOKEN_PRIORITY.get(recipient_type) + if priority is None or not token: + continue + if chosen_priority is None or priority < chosen_priority: + chosen_priority = priority + chosen_token = token + return chosen_token + + +def enrich_human_input_pause_reasons( + reasons: Sequence[Mapping[str, Any]], + *, + form_tokens_by_form_id: Mapping[str, str], + expiration_times_by_form_id: Mapping[str, int], +) -> list[dict[str, Any]]: + enriched: list[dict[str, Any]] = [] + for reason in reasons: + updated = dict(reason) + if updated.get("TYPE") == PauseReasonType.HUMAN_INPUT_REQUIRED: + form_id = updated.get("form_id") + if isinstance(form_id, str): + updated["form_token"] = form_tokens_by_form_id.get(form_id) + expiration_time = expiration_times_by_form_id.get(form_id) + if expiration_time is not None: + updated["expiration_time"] = expiration_time + enriched.append(updated) + return enriched diff --git a/api/repositories/sqlalchemy_api_workflow_run_repository.py b/api/repositories/sqlalchemy_api_workflow_run_repository.py index 474b200fc5..71a2554a60 100644 --- a/api/repositories/sqlalchemy_api_workflow_run_repository.py +++ b/api/repositories/sqlalchemy_api_workflow_run_repository.py @@ -42,7 +42,7 @@ from libs.helper import convert_datetime_to_date from libs.infinite_scroll_pagination import InfiniteScrollPagination from libs.time_parser import get_time_threshold from models.enums import WorkflowRunTriggeredFrom -from models.human_input import HumanInputForm +from models.human_input import HumanInputForm, HumanInputFormRecipient from models.workflow import WorkflowAppLog, WorkflowArchiveLog, WorkflowPause, WorkflowPauseReason, WorkflowRun from repositories.api_workflow_run_repository import APIWorkflowRunRepository, RunsWithRelatedCountsDict from repositories.entities.workflow_pause import WorkflowPauseEntity @@ -63,6 +63,7 @@ class _WorkflowRunError(Exception): def _build_human_input_required_reason( reason_model: WorkflowPauseReason, form_model: HumanInputForm | None, + recipients: Sequence[HumanInputFormRecipient] = (), ) -> HumanInputRequired: form_content = "" inputs = [] @@ -89,7 +90,7 @@ def _build_human_input_required_reason( resolved_default_values = dict(definition.default_values) node_title = definition.node_title or node_title - return HumanInputRequired( + reason = HumanInputRequired( form_id=form_id, form_content=form_content, inputs=inputs, @@ -98,6 +99,7 @@ def _build_human_input_required_reason( node_title=node_title, resolved_default_values=resolved_default_values, ) + return reason class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): @@ -804,12 +806,23 @@ class DifyAPISQLAlchemyWorkflowRunRepository(APIWorkflowRunRepository): form_stmt = select(HumanInputForm).where(HumanInputForm.id.in_(form_ids)) for form in session.scalars(form_stmt).all(): form_models[form.id] = form + recipients_by_form_id: dict[str, list[HumanInputFormRecipient]] = {} + if form_ids: + recipient_stmt = select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids)) + for recipient in session.scalars(recipient_stmt).all(): + recipients_by_form_id.setdefault(recipient.form_id, []).append(recipient) pause_reasons: list[PauseReason] = [] for reason in pause_reason_models: if reason.type_ == PauseReasonType.HUMAN_INPUT_REQUIRED: form_model = form_models.get(reason.form_id) - pause_reasons.append(_build_human_input_required_reason(reason, form_model)) + pause_reasons.append( + _build_human_input_required_reason( + reason, + form_model, + recipients_by_form_id.get(reason.form_id, ()), + ) + ) else: pause_reasons.append(reason.to_entity()) return pause_reasons diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 5e8c7aa337..8ff53d143b 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -162,6 +162,7 @@ class AppGenerateService: invoke_from=invoke_from, streaming=True, call_depth=0, + workflow_run_id=str(uuid.uuid4()), ) payload_json = payload.model_dump_json() @@ -183,6 +184,10 @@ class AppGenerateService: else: # Blocking mode: run synchronously and return JSON instead of SSE # Keep behaviour consistent with WORKFLOW blocking branch. + pause_config = PauseStateLayerConfig( + session_factory=session_factory.get_session_maker(), + state_owner_user_id=workflow.created_by, + ) advanced_generator = AdvancedChatAppGenerator() return rate_limit.generate( advanced_generator.convert_to_event_stream( @@ -194,6 +199,7 @@ class AppGenerateService: invoke_from=invoke_from, workflow_run_id=str(uuid.uuid4()), streaming=False, + pause_state_config=pause_config, ) ), request_id=request_id, diff --git a/api/services/workflow_event_snapshot_service.py b/api/services/workflow_event_snapshot_service.py index 5fca444723..94f88f8c49 100644 --- a/api/services/workflow_event_snapshot_service.py +++ b/api/services/workflow_event_snapshot_service.py @@ -14,6 +14,7 @@ from sqlalchemy.orm import Session, sessionmaker from core.app.apps.message_generator import MessageGenerator from core.app.entities.task_entities import ( + HumanInputRequiredResponse, MessageReplaceStreamResponse, NodeFinishStreamResponse, NodeStartStreamResponse, @@ -22,10 +23,14 @@ from core.app.entities.task_entities import ( WorkflowStartStreamResponse, ) from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext +from core.workflow.human_input_forms import load_form_tokens_by_form_id +from core.workflow.human_input_policy import HumanInputSurface, enrich_human_input_pause_reasons from graphon.entities import WorkflowStartReason +from graphon.entities.pause_reason import PauseReasonType from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus from graphon.runtime import GraphRuntimeState from graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter +from models.human_input import HumanInputForm from models.model import AppMode, Message from models.workflow import WorkflowNodeExecutionTriggeredFrom, WorkflowRun from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot @@ -59,8 +64,10 @@ def build_workflow_event_stream( tenant_id: str, app_id: str, session_maker: sessionmaker[Session], + human_input_surface: HumanInputSurface | None = None, idle_timeout: float = 300, ping_interval: float = 10.0, + close_on_pause: bool = True, ) -> Generator[Mapping[str, Any] | str, None, None]: topic = MessageGenerator.get_response_topic(app_mode, workflow_run.id) workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) @@ -115,13 +122,15 @@ def build_workflow_event_stream( message_context=message_context, pause_entity=pause_entity, resumption_context=resumption_context, + session_maker=session_maker, + human_input_surface=human_input_surface, ) for event in snapshot_events: last_msg_time = time.time() last_ping_time = last_msg_time yield event - if _is_terminal_event(event, include_paused=True): + if _is_terminal_event(event, close_on_pause=close_on_pause): return while True: @@ -146,7 +155,7 @@ def build_workflow_event_stream( last_msg_time = time.time() last_ping_time = last_msg_time yield event - if _is_terminal_event(event, include_paused=True): + if _is_terminal_event(event, close_on_pause=close_on_pause): return finally: buffer_state.stop_event.set() @@ -207,6 +216,8 @@ def _build_snapshot_events( message_context: MessageContext | None, pause_entity: WorkflowPauseEntity | None, resumption_context: WorkflowResumptionContext | None, + session_maker: sessionmaker[Session] | None = None, + human_input_surface: HumanInputSurface | None = None, ) -> list[Mapping[str, Any]]: events: list[Mapping[str, Any]] = [] @@ -241,12 +252,24 @@ def _build_snapshot_events( events.append(node_finished) if workflow_run.status == WorkflowExecutionStatus.PAUSED and pause_entity is not None: + for human_input_event in _build_human_input_required_events( + workflow_run_id=workflow_run.id, + task_id=task_id, + pause_entity=pause_entity, + session_maker=session_maker, + human_input_surface=human_input_surface, + ): + _apply_message_context(human_input_event, message_context) + events.append(human_input_event) + pause_event = _build_pause_event( workflow_run=workflow_run, workflow_run_id=workflow_run.id, task_id=task_id, pause_entity=pause_entity, resumption_context=resumption_context, + session_maker=session_maker, + human_input_surface=human_input_surface, ) if pause_event is not None: _apply_message_context(pause_event, message_context) @@ -314,6 +337,97 @@ def _build_node_started_event( return response.to_ignore_detail_dict() +def _build_human_input_required_events( + *, + workflow_run_id: str, + task_id: str, + pause_entity: WorkflowPauseEntity, + session_maker: sessionmaker[Session] | None, + human_input_surface: HumanInputSurface | None, +) -> list[dict[str, Any]]: + reasons = [reason.model_dump(mode="json") for reason in pause_entity.get_pause_reasons()] + human_input_form_ids = [ + form_id + for reason in reasons + if reason.get("TYPE") == PauseReasonType.HUMAN_INPUT_REQUIRED + for form_id in [reason.get("form_id")] + if isinstance(form_id, str) + ] + + expiration_times_by_form_id: dict[str, int] = {} + display_in_ui_by_form_id: dict[str, bool] = {} + form_tokens_by_form_id: dict[str, str] = {} + if human_input_form_ids and session_maker is not None: + stmt = select(HumanInputForm.id, HumanInputForm.expiration_time, HumanInputForm.form_definition).where( + HumanInputForm.id.in_(human_input_form_ids) + ) + with session_maker() as session: + for form_id, expiration_time, form_definition in session.execute(stmt): + expiration_times_by_form_id[str(form_id)] = int(expiration_time.timestamp()) + try: + definition_payload = json.loads(form_definition) if form_definition else {} + except (TypeError, json.JSONDecodeError): + definition_payload = {} + display_in_ui_by_form_id[str(form_id)] = bool(definition_payload.get("display_in_ui")) + form_tokens_by_form_id = load_form_tokens_by_form_id( + human_input_form_ids, + session=session, + surface=human_input_surface, + ) + + events: list[dict[str, Any]] = [] + for reason in reasons: + if reason.get("TYPE") != PauseReasonType.HUMAN_INPUT_REQUIRED: + continue + + form_id_raw = reason.get("form_id") + node_id_raw = reason.get("node_id") + node_title_raw = reason.get("node_title") + form_content_raw = reason.get("form_content") + if not isinstance(form_id_raw, str): + continue + if not isinstance(node_id_raw, str): + continue + if not isinstance(node_title_raw, str): + continue + if not isinstance(form_content_raw, str): + continue + form_id = form_id_raw + node_id = node_id_raw + node_title = node_title_raw + form_content = form_content_raw + + inputs = reason.get("inputs") + actions = reason.get("actions") + resolved_default_values = reason.get("resolved_default_values") + + expiration_time = expiration_times_by_form_id.get(form_id) + if expiration_time is None: + continue + + response = HumanInputRequiredResponse( + task_id=task_id, + workflow_run_id=workflow_run_id, + data=HumanInputRequiredResponse.Data( + form_id=form_id, + node_id=node_id, + node_title=node_title, + form_content=form_content, + inputs=inputs if isinstance(inputs, list) else [], + actions=actions if isinstance(actions, list) else [], + display_in_ui=display_in_ui_by_form_id.get(form_id, False), + form_token=form_tokens_by_form_id.get(form_id), + resolved_default_values=(resolved_default_values if isinstance(resolved_default_values, dict) else {}), + expiration_time=expiration_time, + ), + ) + payload = response.model_dump(mode="json") + payload["event"] = response.event.value + events.append(payload) + + return events + + def _build_node_finished_event( *, workflow_run_id: str, @@ -356,6 +470,8 @@ def _build_pause_event( task_id: str, pause_entity: WorkflowPauseEntity, resumption_context: WorkflowResumptionContext | None, + session_maker: sessionmaker[Session] | None, + human_input_surface: HumanInputSurface | None = None, ) -> dict[str, Any] | None: paused_nodes: list[str] = [] outputs: dict[str, Any] = {} @@ -365,6 +481,36 @@ def _build_pause_event( outputs = dict(WorkflowRuntimeTypeConverter().to_json_encodable(state.outputs or {})) reasons = [reason.model_dump(mode="json") for reason in pause_entity.get_pause_reasons()] + human_input_form_ids = [ + form_id + for reason in reasons + if reason.get("TYPE") == PauseReasonType.HUMAN_INPUT_REQUIRED + for form_id in [reason.get("form_id")] + if isinstance(form_id, str) + ] + form_tokens_by_form_id: dict[str, str] = {} + expiration_times_by_form_id: dict[str, int] = {} + if human_input_form_ids and session_maker is not None: + with session_maker() as session: + form_tokens_by_form_id = load_form_tokens_by_form_id( + human_input_form_ids, + session=session, + surface=human_input_surface, + ) + stmt = select(HumanInputForm.id, HumanInputForm.expiration_time).where( + HumanInputForm.id.in_(human_input_form_ids) + ) + for row in session.execute(stmt): + form_id, expiration_time, *_rest = row + expiration_times_by_form_id[str(form_id)] = int(expiration_time.timestamp()) + # Reconnect paths must preserve the same pause-reason contract as live streams; + # otherwise clients see schema drift after resume. + reasons = enrich_human_input_pause_reasons( + reasons, + form_tokens_by_form_id=form_tokens_by_form_id, + expiration_times_by_form_id=expiration_times_by_form_id, + ) + response = WorkflowPauseStreamResponse( task_id=task_id, workflow_run_id=workflow_run_id, @@ -449,12 +595,19 @@ def _parse_event_message(message: bytes) -> Mapping[str, Any] | None: return event -def _is_terminal_event(event: Mapping[str, Any] | str, include_paused=False) -> bool: +def _is_terminal_event( + event: Mapping[str, Any] | str, + close_on_pause: bool = True, + *, + include_paused: bool | None = None, +) -> bool: + if include_paused is not None: + close_on_pause = include_paused if not isinstance(event, Mapping): return False event_type = event.get("event") if event_type == StreamEvent.WORKFLOW_FINISHED.value: return True - if include_paused: + if close_on_pause: return event_type == StreamEvent.WORKFLOW_PAUSED.value return False diff --git a/api/tasks/app_generate/workflow_execute_task.py b/api/tasks/app_generate/workflow_execute_task.py index c22e7e9918..5ceeb302c8 100644 --- a/api/tasks/app_generate/workflow_execute_task.py +++ b/api/tasks/app_generate/workflow_execute_task.py @@ -399,6 +399,8 @@ def _resume_advanced_chat( workflow_run_id: str, workflow_run: WorkflowRun, ) -> None: + resumed_generate_entity = generate_entity.model_copy(update={"stream": True}) + try: triggered_from = WorkflowRunTriggeredFrom(workflow_run.triggered_from) except ValueError: @@ -426,7 +428,7 @@ def _resume_advanced_chat( user=user, conversation=conversation, message=message, - application_generate_entity=generate_entity, + application_generate_entity=resumed_generate_entity, workflow_execution_repository=workflow_execution_repository, workflow_node_execution_repository=workflow_node_execution_repository, graph_runtime_state=graph_runtime_state, @@ -436,9 +438,8 @@ def _resume_advanced_chat( logger.exception("Failed to resume chatflow execution for workflow run %s", workflow_run_id) raise - if generate_entity.stream: - assert isinstance(response, Generator) - _publish_streaming_response(response, workflow_run_id, AppMode.ADVANCED_CHAT) + assert isinstance(response, Generator) + _publish_streaming_response(response, workflow_run_id, AppMode.ADVANCED_CHAT) def _resume_workflow( @@ -455,6 +456,8 @@ def _resume_workflow( workflow_run_repo, pause_entity, ) -> None: + resumed_generate_entity = generate_entity.model_copy(update={"stream": True}) + try: triggered_from = WorkflowRunTriggeredFrom(workflow_run.triggered_from) except ValueError: @@ -480,7 +483,7 @@ def _resume_workflow( app_model=app_model, workflow=workflow, user=user, - application_generate_entity=generate_entity, + application_generate_entity=resumed_generate_entity, graph_runtime_state=graph_runtime_state, workflow_execution_repository=workflow_execution_repository, workflow_node_execution_repository=workflow_node_execution_repository, @@ -490,11 +493,18 @@ def _resume_workflow( logger.exception("Failed to resume workflow execution for workflow run %s", workflow_run_id) raise - if generate_entity.stream: - assert isinstance(response, Generator) - _publish_streaming_response(response, workflow_run_id, AppMode.WORKFLOW) + assert isinstance(response, Generator) + _publish_streaming_response(response, workflow_run_id, AppMode.WORKFLOW) - workflow_run_repo.delete_workflow_pause(pause_entity) + try: + workflow_run_repo.delete_workflow_pause(pause_entity) + except Exception as exc: + if exc.__class__.__name__ != "_WorkflowRunError" or "WorkflowPause not found" not in str(exc): + raise + logger.info( + "Skipped deleting workflow pause %s after resume because it was already replaced or removed", + pause_entity.id, + ) @shared_task(queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE, name="resume_app_execution") diff --git a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py index aebe87839c..d9828e19c5 100644 --- a/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py +++ b/api/tests/test_containers_integration_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py @@ -2,6 +2,7 @@ from __future__ import annotations +import secrets from dataclasses import dataclass, field from datetime import datetime, timedelta from unittest.mock import Mock @@ -11,6 +12,7 @@ import pytest from sqlalchemy import Engine, delete, select from sqlalchemy.orm import Session, sessionmaker +from core.workflow.human_input_adapter import DeliveryMethodType from extensions.ext_storage import storage from graphon.entities import WorkflowExecution from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType @@ -20,9 +22,11 @@ from graphon.nodes.human_input.enums import FormInputType, HumanInputFormStatus from libs.datetime_utils import naive_utc_now from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom from models.human_input import ( + BackstageRecipientPayload, HumanInputDelivery, HumanInputForm, HumanInputFormRecipient, + RecipientType, ) from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowPause, WorkflowPauseReason, WorkflowRun from repositories.entities.workflow_pause import WorkflowPauseEntity @@ -628,12 +632,12 @@ class TestPrivateWorkflowPauseEntity: class TestBuildHumanInputRequiredReason: """Integration tests for _build_human_input_required_reason using real DB models.""" - def test_builds_reason_from_form_definition( + def test_prefers_standalone_web_app_token_when_available( self, db_session_with_containers: Session, test_scope: _TestScope, ) -> None: - """Build the graph pause reason from the stored form definition.""" + """Use the public standalone web-app token for service API payloads.""" expiration_time = naive_utc_now() form_definition = FormDefinition( @@ -660,6 +664,40 @@ class TestBuildHumanInputRequiredReason: db_session_with_containers.add(form_model) db_session_with_containers.flush() + delivery = HumanInputDelivery( + form_id=form_model.id, + delivery_method_type=DeliveryMethodType.WEBAPP, + channel_payload="{}", + ) + db_session_with_containers.add(delivery) + db_session_with_containers.flush() + + backstage_access_token = secrets.token_urlsafe(8) + backstage_recipient = HumanInputFormRecipient( + form_id=form_model.id, + delivery_id=delivery.id, + recipient_type=RecipientType.BACKSTAGE, + recipient_payload=BackstageRecipientPayload().model_dump_json(), + access_token=backstage_access_token, + ) + console_access_token = secrets.token_urlsafe(8) + console_recipient = HumanInputFormRecipient( + form_id=form_model.id, + delivery_id=delivery.id, + recipient_type=RecipientType.CONSOLE, + recipient_payload="{}", + access_token=console_access_token, + ) + web_app_access_token = secrets.token_urlsafe(8) + web_app_recipient = HumanInputFormRecipient( + form_id=form_model.id, + delivery_id=delivery.id, + recipient_type=RecipientType.STANDALONE_WEB_APP, + recipient_payload="{}", + access_token=web_app_access_token, + ) + db_session_with_containers.add_all([backstage_recipient, console_recipient, web_app_recipient]) + db_session_with_containers.flush() # Create a pause so the reason has a valid pause_id workflow_run = _create_workflow_run( db_session_with_containers, @@ -688,8 +726,15 @@ class TestBuildHumanInputRequiredReason: # Refresh to ensure we have DB-round-tripped objects db_session_with_containers.refresh(form_model) db_session_with_containers.refresh(reason_model) + db_session_with_containers.refresh(backstage_recipient) + db_session_with_containers.refresh(console_recipient) + db_session_with_containers.refresh(web_app_recipient) - reason = _build_human_input_required_reason(reason_model, form_model) + reason = _build_human_input_required_reason( + reason_model, + form_model, + [backstage_recipient, console_recipient, web_app_recipient], + ) assert isinstance(reason, HumanInputRequired) assert reason.node_title == "Ask Name" @@ -697,3 +742,92 @@ class TestBuildHumanInputRequiredReason: assert reason.inputs[0].output_variable_name == "name" assert reason.actions[0].id == "approve" assert reason.resolved_default_values == {"name": "Alice"} + assert not hasattr(reason, "form_token") + + def test_falls_back_to_console_token_when_web_app_token_missing( + self, + db_session_with_containers: Session, + test_scope: _TestScope, + ) -> None: + """Use the console token only when no standalone web-app token exists.""" + + expiration_time = naive_utc_now() + form_definition = FormDefinition( + form_content="content", + inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")], + user_actions=[UserAction(id="approve", title="Approve")], + rendered_content="rendered", + expiration_time=expiration_time, + default_values={"name": "Alice"}, + node_title="Ask Name", + display_in_ui=True, + ) + + form_model = HumanInputForm( + tenant_id=test_scope.tenant_id, + app_id=test_scope.app_id, + workflow_run_id=str(uuid4()), + node_id="node-1", + form_definition=form_definition.model_dump_json(), + rendered_content="rendered", + status=HumanInputFormStatus.WAITING, + expiration_time=expiration_time, + ) + db_session_with_containers.add(form_model) + db_session_with_containers.flush() + + delivery = HumanInputDelivery( + form_id=form_model.id, + delivery_method_type=DeliveryMethodType.WEBAPP, + channel_payload="{}", + ) + db_session_with_containers.add(delivery) + db_session_with_containers.flush() + + backstage_access_token = secrets.token_urlsafe(8) + backstage_recipient = HumanInputFormRecipient( + form_id=form_model.id, + delivery_id=delivery.id, + recipient_type=RecipientType.BACKSTAGE, + recipient_payload=BackstageRecipientPayload().model_dump_json(), + access_token=backstage_access_token, + ) + console_access_token = secrets.token_urlsafe(8) + console_recipient = HumanInputFormRecipient( + form_id=form_model.id, + delivery_id=delivery.id, + recipient_type=RecipientType.CONSOLE, + recipient_payload="{}", + access_token=console_access_token, + ) + db_session_with_containers.add_all([backstage_recipient, console_recipient]) + db_session_with_containers.flush() + + workflow_run = _create_workflow_run( + db_session_with_containers, + test_scope, + status=WorkflowExecutionStatus.RUNNING, + ) + pause = WorkflowPause( + workflow_id=test_scope.workflow_id, + workflow_run_id=workflow_run.id, + state_object_key=f"workflow-state-{uuid4()}.json", + ) + db_session_with_containers.add(pause) + db_session_with_containers.flush() + test_scope.state_keys.add(pause.state_object_key) + + reason_model = WorkflowPauseReason( + pause_id=pause.id, + type_=PauseReasonType.HUMAN_INPUT_REQUIRED, + form_id=form_model.id, + node_id="node-1", + message="", + ) + db_session_with_containers.add(reason_model) + db_session_with_containers.commit() + + reason = _build_human_input_required_reason(reason_model, form_model, [backstage_recipient, console_recipient]) + + assert isinstance(reason, HumanInputRequired) + assert not hasattr(reason, "form_token") diff --git a/api/tests/unit_tests/controllers/console/test_human_input_form.py b/api/tests/unit_tests/controllers/console/test_human_input_form.py index 232b6eee79..ebf803cac9 100644 --- a/api/tests/unit_tests/controllers/console/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/console/test_human_input_form.py @@ -122,6 +122,35 @@ def test_post_form_invalid_recipient_type(app, monkeypatch: pytest.MonkeyPatch) handler(api, form_token="token") +def test_post_form_rejects_webapp_recipient_type(app, monkeypatch: pytest.MonkeyPatch) -> None: + form = SimpleNamespace(tenant_id="tenant-1", recipient_type=RecipientType.STANDALONE_WEB_APP) + + class _ServiceStub: + def __init__(self, *_args, **_kwargs): + pass + + def get_form_by_token(self, _token): + return form + + monkeypatch.setattr("controllers.console.human_input_form.HumanInputService", _ServiceStub) + monkeypatch.setattr( + "controllers.console.human_input_form.current_account_with_tenant", + lambda: (SimpleNamespace(id="user-1"), "tenant-1"), + ) + monkeypatch.setattr("controllers.console.human_input_form.db", SimpleNamespace(engine=object())) + + api = ConsoleHumanInputFormApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/console/api/form/human_input/token", + method="POST", + json={"inputs": {"content": "ok"}, "action": "approve"}, + ): + with pytest.raises(NotFoundError): + handler(api, form_token="token") + + def test_post_form_success(app, monkeypatch: pytest.MonkeyPatch) -> None: submit_mock = Mock() form = SimpleNamespace(tenant_id="tenant-1", recipient_type=RecipientType.CONSOLE) diff --git a/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py new file mode 100644 index 0000000000..846d5368f3 --- /dev/null +++ b/api/tests/unit_tests/controllers/service_api/app/test_hitl_service_api.py @@ -0,0 +1,707 @@ +"""Dedicated tests for HITL behavior exposed through the Service API.""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import ANY, MagicMock, Mock + +import pytest + +import services.app_generate_service as ags_module +from controllers.service_api.app.workflow_events import WorkflowEventsApi +from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig +from core.app.apps.common import workflow_response_converter +from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter +from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity +from core.app.entities.queue_entities import QueueWorkflowPausedEvent +from core.app.entities.task_entities import ( + AdvancedChatPausedBlockingResponse, + HumanInputRequiredResponse, + WorkflowAppPausedBlockingResponse, + WorkflowPauseStreamResponse, +) +from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper +from core.workflow.human_input_policy import HumanInputSurface +from core.workflow.system_variables import build_system_variables +from graphon.entities import WorkflowStartReason +from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType +from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus +from graphon.nodes.human_input.entities import FormInput, UserAction +from graphon.nodes.human_input.enums import FormInputType +from graphon.runtime import GraphRuntimeState, VariablePool +from models.account import Account +from models.enums import CreatorUserRole +from models.model import AppMode +from models.workflow import WorkflowRun +from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot +from repositories.entities.workflow_pause import WorkflowPauseEntity +from services.app_generate_service import AppGenerateService +from services.workflow_event_snapshot_service import _build_snapshot_events +from tests.unit_tests.controllers.service_api.conftest import _unwrap + + +class _DummyRateLimit: + @staticmethod + def gen_request_key() -> str: + return "dummy-request-id" + + def __init__(self, client_id: str, max_active_requests: int) -> None: + self.client_id = client_id + self.max_active_requests = max_active_requests + + def enter(self, request_id: str | None = None) -> str: + return request_id or "dummy-request-id" + + def exit(self, request_id: str) -> None: + return None + + def generate(self, generator, request_id: str): + return generator + + +def _mock_repo_for_run(monkeypatch: pytest.MonkeyPatch, workflow_run): + workflow_events_module = sys.modules["controllers.service_api.app.workflow_events"] + repo = SimpleNamespace(get_workflow_run_by_id_and_tenant_id=lambda **_kwargs: workflow_run) + monkeypatch.setattr( + workflow_events_module.DifyAPIRepositoryFactory, + "create_api_workflow_run_repository", + lambda *_args, **_kwargs: repo, + ) + monkeypatch.setattr(workflow_events_module, "db", SimpleNamespace(engine=object())) + return workflow_events_module + + +def _build_service_api_pause_converter() -> WorkflowResponseConverter: + application_generate_entity = SimpleNamespace( + inputs={}, + files=[], + invoke_from=InvokeFrom.SERVICE_API, + app_config=SimpleNamespace(app_id="app-id", tenant_id="tenant-id"), + ) + system_variables = build_system_variables( + user_id="user", + app_id="app-id", + workflow_id="workflow-id", + workflow_execution_id="run-id", + ) + user = MagicMock(spec=Account) + user.id = "account-id" + user.name = "Tester" + user.email = "tester@example.com" + return WorkflowResponseConverter( + application_generate_entity=application_generate_entity, + user=user, + system_variables=system_variables, + ) + + +def _build_advanced_chat_paused_blocking_response() -> AdvancedChatPausedBlockingResponse: + data = AdvancedChatPausedBlockingResponse.Data( + id="msg-1", + mode="chat", + conversation_id="c1", + message_id="m1", + workflow_run_id="run-1", + answer="partial", + metadata={"usage": {"total_tokens": 1}}, + created_at=1, + paused_nodes=["node-1"], + reasons=[ + { + "type": PauseReasonType.HUMAN_INPUT_REQUIRED, + "form_id": "form-1", + "expiration_time": 100, + } + ], + status=WorkflowExecutionStatus.PAUSED, + elapsed_time=0.1, + total_tokens=0, + total_steps=0, + ) + return AdvancedChatPausedBlockingResponse(task_id="t1", data=data) + + +def _build_workflow_paused_blocking_response() -> WorkflowAppPausedBlockingResponse: + return WorkflowAppPausedBlockingResponse( + task_id="t1", + workflow_run_id="r1", + data=WorkflowAppPausedBlockingResponse.Data( + id="r1", + workflow_id="wf-1", + status=WorkflowExecutionStatus.PAUSED, + outputs={}, + error=None, + elapsed_time=0.5, + total_tokens=0, + total_steps=2, + created_at=1, + finished_at=None, + paused_nodes=["node-1"], + reasons=[{"TYPE": "human_input_required", "form_id": "form-1", "expiration_time": 100}], + ), + ) + + +@dataclass(frozen=True) +class _FakePauseEntity(WorkflowPauseEntity): + pause_id: str + workflow_run_id: str + paused_at_value: datetime + pause_reasons: Sequence[HumanInputRequired] + + @property + def id(self) -> str: + return self.pause_id + + @property + def workflow_execution_id(self) -> str: + return self.workflow_run_id + + def get_state(self) -> bytes: + raise AssertionError("state is not required for snapshot tests") + + @property + def resumed_at(self) -> datetime | None: + return None + + @property + def paused_at(self) -> datetime: + return self.paused_at_value + + def get_pause_reasons(self) -> Sequence[HumanInputRequired]: + return self.pause_reasons + + +def _build_workflow_run(status: WorkflowExecutionStatus) -> WorkflowRun: + return WorkflowRun( + id="run-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + type="workflow", + triggered_from="app-run", + version="v1", + graph=None, + inputs=json.dumps({"input": "value"}), + status=status, + outputs=json.dumps({}), + error=None, + elapsed_time=0.0, + total_tokens=0, + total_steps=0, + created_by_role=CreatorUserRole.END_USER, + created_by="user-1", + created_at=datetime(2024, 1, 1, tzinfo=UTC), + ) + + +def _build_snapshot(status: WorkflowNodeExecutionStatus) -> WorkflowNodeExecutionSnapshot: + created_at = datetime(2024, 1, 1, tzinfo=UTC) + finished_at = datetime(2024, 1, 1, 0, 0, 5, tzinfo=UTC) + return WorkflowNodeExecutionSnapshot( + execution_id="exec-1", + node_id="node-1", + node_type="human-input", + title="Human Input", + index=1, + status=status.value, + elapsed_time=0.5, + created_at=created_at, + finished_at=finished_at, + iteration_id=None, + loop_id=None, + ) + + +def _build_resumption_context(task_id: str) -> WorkflowResumptionContext: + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant-1", + app_id="app-1", + app_mode=AppMode.WORKFLOW, + workflow_id="workflow-1", + ) + generate_entity = WorkflowAppGenerateEntity( + task_id=task_id, + app_config=app_config, + inputs={}, + files=[], + user_id="user-1", + stream=True, + invoke_from=InvokeFrom.EXPLORE, + call_depth=0, + workflow_execution_id="run-1", + ) + runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + runtime_state.register_paused_node("node-1") + runtime_state.outputs = {"result": "value"} + wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity) + return WorkflowResumptionContext( + generate_entity=wrapper, + serialized_graph_runtime_state=runtime_state.dumps(), + ) + + +class TestHitlServiceApi: + # Service API event-stream continuation + def test_workflow_events_continue_on_pause_keeps_stream_open(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = SimpleNamespace( + id="run-1", + app_id="app-1", + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + finished_at=None, + ) + workflow_events_module = _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + msg_generator = Mock() + msg_generator.retrieve_events.return_value = ["raw-event"] + workflow_generator = Mock() + workflow_generator.convert_to_event_stream.return_value = iter(["data: streamed\n\n"]) + monkeypatch.setattr(workflow_events_module, "MessageGenerator", lambda: msg_generator) + monkeypatch.setattr(workflow_events_module, "WorkflowAppGenerator", lambda: workflow_generator) + + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1&continue_on_pause=true", method="GET"): + response = handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + assert response.get_data(as_text=True) == "data: streamed\n\n" + msg_generator.retrieve_events.assert_called_once_with( + AppMode.WORKFLOW, + "run-1", + terminal_events=[], + ) + workflow_generator.convert_to_event_stream.assert_called_once_with(["raw-event"]) + + def test_workflow_events_snapshot_continue_on_pause_keeps_pause_open( + self, app, monkeypatch: pytest.MonkeyPatch + ) -> None: + workflow_run = SimpleNamespace( + id="run-1", + app_id="app-1", + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + finished_at=None, + ) + workflow_events_module = _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + msg_generator = Mock() + workflow_generator = Mock() + workflow_generator.convert_to_event_stream.return_value = iter(["data: snapshot\n\n"]) + snapshot_builder = Mock(return_value=["snapshot-events"]) + monkeypatch.setattr(workflow_events_module, "MessageGenerator", lambda: msg_generator) + monkeypatch.setattr(workflow_events_module, "WorkflowAppGenerator", lambda: workflow_generator) + monkeypatch.setattr(workflow_events_module, "build_workflow_event_stream", snapshot_builder) + + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context( + "/workflow/run-1/events?user=u1&include_state_snapshot=true&continue_on_pause=true", + method="GET", + ): + response = handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + assert response.get_data(as_text=True) == "data: snapshot\n\n" + msg_generator.retrieve_events.assert_not_called() + snapshot_builder.assert_called_once_with( + app_mode=AppMode.WORKFLOW, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=ANY, + human_input_surface=HumanInputSurface.SERVICE_API, + close_on_pause=False, + ) + workflow_generator.convert_to_event_stream.assert_called_once_with(["snapshot-events"]) + + def test_advanced_chat_blocking_injects_pause_state_config(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", False) + monkeypatch.setattr(ags_module, "RateLimit", _DummyRateLimit) + + workflow = MagicMock() + workflow.created_by = "owner-id" + monkeypatch.setattr(AppGenerateService, "_get_workflow", lambda *args, **kwargs: workflow) + monkeypatch.setattr(ags_module.session_factory, "get_session_maker", lambda: "session-maker") + + generator_instance = MagicMock() + generator_instance.generate.return_value = {"result": "advanced-blocking"} + generator_instance.convert_to_event_stream.side_effect = lambda payload: payload + monkeypatch.setattr(ags_module, "AdvancedChatAppGenerator", lambda: generator_instance) + + app_model = MagicMock() + app_model.mode = AppMode.ADVANCED_CHAT + app_model.id = "app-id" + app_model.tenant_id = "tenant-id" + app_model.max_active_requests = 0 + app_model.is_agent = False + + user = MagicMock() + user.id = "user-id" + + result = AppGenerateService.generate( + app_model=app_model, + user=user, + args={"workflow_id": None, "query": "hi", "inputs": {}}, + invoke_from=InvokeFrom.SERVICE_API, + streaming=False, + ) + + assert result == {"result": "advanced-blocking"} + call_kwargs = generator_instance.generate.call_args.kwargs + assert call_kwargs["streaming"] is False + assert call_kwargs["pause_state_config"] is not None + assert call_kwargs["pause_state_config"].session_factory == "session-maker" + assert call_kwargs["pause_state_config"].state_owner_user_id == "owner-id" + + # Blocking payload contract + def test_advanced_chat_blocking_pause_payload_contract(self) -> None: + from core.app.apps.advanced_chat.generate_response_converter import AdvancedChatAppGenerateResponseConverter + + response = AdvancedChatAppGenerateResponseConverter.convert_blocking_full_response( + _build_advanced_chat_paused_blocking_response() + ) + + assert response["event"] == "workflow_paused" + assert response["workflow_run_id"] == "run-1" + assert response["answer"] == "partial" + assert response["data"]["reasons"][0]["type"] == PauseReasonType.HUMAN_INPUT_REQUIRED + assert response["data"]["reasons"][0]["expiration_time"] == 100 + assert "human_input_forms" not in response["data"] + + def test_workflow_blocking_pause_payload_contract(self) -> None: + from core.app.apps.workflow.generate_response_converter import WorkflowAppGenerateResponseConverter + + response = WorkflowAppGenerateResponseConverter.convert_blocking_full_response( + _build_workflow_paused_blocking_response() + ) + + assert response["workflow_run_id"] == "r1" + assert response["data"]["status"] == WorkflowExecutionStatus.PAUSED + assert response["data"]["paused_nodes"] == ["node-1"] + assert response["data"]["reasons"] == [ + {"TYPE": "human_input_required", "form_id": "form-1", "expiration_time": 100} + ] + assert "human_input_forms" not in response["data"] + + def test_advanced_chat_blocking_pipeline_pause_payload_contract(self) -> None: + from core.app.app_config.entities import AppAdditionalFeatures + from core.app.apps.advanced_chat.generate_task_pipeline import AdvancedChatAppGenerateTaskPipeline + from models.enums import MessageStatus + from models.model import EndUser + + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant", + app_id="app", + app_mode=AppMode.ADVANCED_CHAT, + additional_features=AppAdditionalFeatures(), + variables=[], + workflow_id="workflow-id", + ) + application_generate_entity = AdvancedChatAppGenerateEntity.model_construct( + task_id="task", + app_config=app_config, + inputs={}, + query="hello", + files=[], + user_id="user", + stream=False, + invoke_from=InvokeFrom.WEB_APP, + extras={}, + trace_manager=None, + workflow_run_id="run-id", + ) + pipeline = AdvancedChatAppGenerateTaskPipeline( + application_generate_entity=application_generate_entity, + workflow=SimpleNamespace(id="workflow-id", tenant_id="tenant", features_dict={}), + queue_manager=SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, graph_runtime_state=None), + conversation=SimpleNamespace(id="conv-id", mode=AppMode.ADVANCED_CHAT), + message=SimpleNamespace( + id="message-id", + query="hello", + created_at=datetime.utcnow(), + status=MessageStatus.NORMAL, + answer="", + ), + user=EndUser(tenant_id="tenant", type="session", name="tester", session_id="session"), + stream=False, + dialogue_count=1, + draft_var_saver_factory=lambda **kwargs: None, + ) + pipeline._task_state.answer = "partial answer" + pipeline._workflow_run_id = "run-id" + + def _gen(): + yield HumanInputRequiredResponse( + task_id="task", + workflow_run_id="run-id", + data=HumanInputRequiredResponse.Data( + form_id="form-1", + node_id="node-1", + node_title="Approval", + form_content="Need approval", + inputs=[], + actions=[UserAction(id="approve", title="Approve")], + display_in_ui=True, + form_token="token-1", + resolved_default_values={}, + expiration_time=123, + ), + ) + yield WorkflowPauseStreamResponse( + task_id="task", + workflow_run_id="run-id", + data=WorkflowPauseStreamResponse.Data( + workflow_run_id="run-id", + paused_nodes=["node-1"], + outputs={}, + reasons=[ + { + "type": PauseReasonType.HUMAN_INPUT_REQUIRED, + "form_id": "form-1", + "node_id": "node-1", + "expiration_time": 123, + }, + ], + status="paused", + created_at=1, + elapsed_time=0.1, + total_tokens=0, + total_steps=0, + ), + ) + + response = pipeline._to_blocking_response(_gen()) + + assert isinstance(response, AdvancedChatPausedBlockingResponse) + assert response.data.answer == "partial answer" + assert response.data.workflow_run_id == "run-id" + assert response.data.reasons[0]["form_id"] == "form-1" + assert response.data.reasons[0]["expiration_time"] == 123 + + def test_workflow_blocking_pipeline_pause_payload_contract(self, monkeypatch: pytest.MonkeyPatch) -> None: + from core.app.apps.workflow import generate_task_pipeline as workflow_pipeline_module + from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline + + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant", + app_id="app", + app_mode=AppMode.WORKFLOW, + additional_features=AppAdditionalFeatures(), + variables=[], + workflow_id="workflow-id", + ) + application_generate_entity = WorkflowAppGenerateEntity.model_construct( + task_id="task", + app_config=app_config, + inputs={}, + files=[], + user_id="user", + stream=False, + invoke_from=InvokeFrom.WEB_APP, + trace_manager=None, + workflow_execution_id="run-id", + extras={}, + call_depth=0, + ) + pipeline = WorkflowAppGenerateTaskPipeline( + application_generate_entity=application_generate_entity, + workflow=SimpleNamespace(id="workflow-id", tenant_id="tenant", features_dict={}), + queue_manager=SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, graph_runtime_state=None), + user=SimpleNamespace(id="user", session_id="session"), + stream=False, + draft_var_saver_factory=lambda **kwargs: None, + ) + monkeypatch.setattr(workflow_pipeline_module.time, "time", lambda: 1700000000) + + def _gen(): + yield HumanInputRequiredResponse( + task_id="task", + workflow_run_id="run", + data=HumanInputRequiredResponse.Data( + form_id="form-1", + node_id="node-1", + node_title="Human Input", + form_content="content", + expiration_time=1, + ), + ) + yield WorkflowPauseStreamResponse( + task_id="task", + workflow_run_id="run", + data=WorkflowPauseStreamResponse.Data( + workflow_run_id="run", + status=WorkflowExecutionStatus.PAUSED, + outputs={}, + paused_nodes=["node-1"], + reasons=[{"TYPE": "human_input_required", "form_id": "form-1", "expiration_time": 1}], + created_at=1, + elapsed_time=0.1, + total_tokens=0, + total_steps=0, + ), + ) + + response = pipeline._to_blocking_response(_gen()) + + assert isinstance(response, WorkflowAppPausedBlockingResponse) + assert response.data.status == WorkflowExecutionStatus.PAUSED + assert response.data.paused_nodes == ["node-1"] + assert response.data.reasons == [{"TYPE": "human_input_required", "form_id": "form-1", "expiration_time": 1}] + + def test_service_api_pause_event_serializes_hitl_reason(self, monkeypatch: pytest.MonkeyPatch) -> None: + converter = _build_service_api_pause_converter() + converter.workflow_start_to_stream_response( + task_id="task", + workflow_run_id="run-id", + workflow_id="workflow-id", + reason=WorkflowStartReason.INITIAL, + ) + + expiration_time = datetime(2024, 1, 1, tzinfo=UTC) + + class _FakeSession: + def execute(self, _stmt): + return [("form-1", expiration_time, '{"display_in_ui": true}')] + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: _FakeSession()) + monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr( + workflow_response_converter, + "load_form_tokens_by_form_id", + lambda form_ids, session=None, surface=None: {"form-1": "token"}, + ) + + reason = HumanInputRequired( + form_id="form-1", + form_content="Rendered", + inputs=[ + FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="field", default=None), + ], + actions=[UserAction(id="approve", title="Approve")], + display_in_ui=True, + node_id="node-id", + node_title="Human Step", + form_token="token", + ) + queue_event = QueueWorkflowPausedEvent( + reasons=[reason], + outputs={"answer": "value"}, + paused_nodes=["node-id"], + ) + + runtime_state = SimpleNamespace(total_tokens=0, node_run_steps=0) + responses = converter.workflow_pause_to_stream_response( + event=queue_event, + task_id="task", + graph_runtime_state=runtime_state, + ) + + assert isinstance(responses[-1], WorkflowPauseStreamResponse) + pause_resp = responses[-1] + assert pause_resp.workflow_run_id == "run-id" + assert pause_resp.data.paused_nodes == ["node-id"] + assert pause_resp.data.outputs == {} + assert pause_resp.data.reasons[0]["TYPE"] == "human_input_required" + assert pause_resp.data.reasons[0]["form_id"] == "form-1" + assert pause_resp.data.reasons[0]["form_token"] == "token" + assert pause_resp.data.reasons[0]["expiration_time"] == int(expiration_time.timestamp()) + + assert isinstance(responses[0], HumanInputRequiredResponse) + hi_resp = responses[0] + assert hi_resp.data.form_id == "form-1" + assert hi_resp.data.node_id == "node-id" + assert hi_resp.data.node_title == "Human Step" + assert hi_resp.data.inputs[0].output_variable_name == "field" + assert hi_resp.data.actions[0].id == "approve" + assert hi_resp.data.display_in_ui is True + assert hi_resp.data.form_token == "token" + assert hi_resp.data.expiration_time == int(expiration_time.timestamp()) + + # Snapshot payload contract + def test_snapshot_events_include_pause_payload_contract(self, monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED) + snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED) + resumption_context = _build_resumption_context("task-ctx") + monkeypatch.setattr( + "services.workflow_event_snapshot_service.load_form_tokens_by_form_id", + lambda form_ids, session=None, surface=None: {"form-1": "wtok"}, + ) + + class _SessionContext: + def __init__(self, session): + self._session = session + + def __enter__(self): + return self._session + + def __exit__(self, exc_type, exc, tb): + return False + + def session_maker() -> _SessionContext: + return _SessionContext( + SimpleNamespace( + execute=lambda _stmt: [("form-1", datetime(2024, 1, 1, tzinfo=UTC), '{"display_in_ui": true}')], + ) + ) + + pause_entity = _FakePauseEntity( + pause_id="pause-1", + workflow_run_id="run-1", + paused_at_value=datetime(2024, 1, 1, tzinfo=UTC), + pause_reasons=[ + HumanInputRequired( + form_id="form-1", + form_content="content", + node_id="node-1", + node_title="Human Input", + form_token="wtok", + ) + ], + ) + + events = _build_snapshot_events( + workflow_run=workflow_run, + node_snapshots=[snapshot], + task_id="task-ctx", + message_context=None, + pause_entity=pause_entity, + resumption_context=resumption_context, + session_maker=session_maker, + ) + + assert [event["event"] for event in events] == [ + "workflow_started", + "node_started", + "node_finished", + "human_input_required", + "workflow_paused", + ] + assert events[2]["data"]["status"] == WorkflowNodeExecutionStatus.PAUSED.value + assert events[3]["data"]["form_token"] == "wtok" + assert events[3]["data"]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) + pause_data = events[-1]["data"] + assert pause_data["paused_nodes"] == ["node-1"] + assert pause_data["outputs"] == {"result": "value"} + assert pause_data["reasons"][0]["TYPE"] == "human_input_required" + assert pause_data["reasons"][0]["form_token"] == "wtok" + assert pause_data["reasons"][0]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) + assert pause_data["status"] == WorkflowExecutionStatus.PAUSED.value + assert pause_data["created_at"] == int(workflow_run.created_at.timestamp()) + assert pause_data["elapsed_time"] == workflow_run.elapsed_time + assert pause_data["total_tokens"] == workflow_run.total_tokens + assert pause_data["total_steps"] == workflow_run.total_steps diff --git a/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py new file mode 100644 index 0000000000..531f722ceb --- /dev/null +++ b/api/tests/unit_tests/controllers/service_api/app/test_human_input_form.py @@ -0,0 +1,184 @@ +"""Unit tests for Service API human input form endpoints.""" + +from __future__ import annotations + +import json +import sys +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from werkzeug.exceptions import NotFound + +from controllers.service_api.app.human_input_form import WorkflowHumanInputFormApi +from models.human_input import RecipientType +from tests.unit_tests.controllers.service_api.conftest import _unwrap + + +class TestWorkflowHumanInputFormApi: + def test_get_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + definition = SimpleNamespace( + model_dump=lambda: { + "rendered_content": "Rendered form content", + "inputs": [{"output_variable_name": "name"}], + "default_values": {"name": "Alice", "age": 30, "meta": {"k": "v"}}, + "user_actions": [{"id": "approve", "title": "Approve"}], + } + ) + form = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + recipient_type=RecipientType.STANDALONE_WEB_APP, + expiration_time=datetime(2099, 1, 1, tzinfo=UTC), + get_definition=lambda: definition, + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + workflow_module = sys.modules["controllers.service_api.app.human_input_form"] + monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) + + api = WorkflowHumanInputFormApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + + with app.test_request_context("/form/human_input/token-1", method="GET"): + response = handler(api, app_model=app_model, form_token="token-1") + + payload = json.loads(response.get_data(as_text=True)) + assert payload == { + "form_content": "Rendered form content", + "inputs": [{"output_variable_name": "name"}], + "resolved_default_values": {"name": "Alice", "age": "30", "meta": '{"k": "v"}'}, + "user_actions": [{"id": "approve", "title": "Approve"}], + "expiration_time": int(form.expiration_time.timestamp()), + } + service_mock.get_form_by_token.assert_called_once_with("token-1") + service_mock.ensure_form_active.assert_called_once_with(form) + + def test_get_form_not_in_app(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + form = SimpleNamespace( + app_id="another-app", + tenant_id="tenant-1", + expiration_time=datetime(2099, 1, 1, tzinfo=UTC), + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + workflow_module = sys.modules["controllers.service_api.app.human_input_form"] + monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) + + api = WorkflowHumanInputFormApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + + with app.test_request_context("/form/human_input/token-1", method="GET"): + with pytest.raises(NotFound): + handler(api, app_model=app_model, form_token="token-1") + + @pytest.mark.parametrize( + "recipient_type", + [ + RecipientType.CONSOLE, + RecipientType.BACKSTAGE, + RecipientType.EMAIL_MEMBER, + RecipientType.EMAIL_EXTERNAL, + ], + ) + def test_get_rejects_non_service_api_recipient_types( + self, app, monkeypatch: pytest.MonkeyPatch, recipient_type: RecipientType + ) -> None: + form = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + recipient_type=recipient_type, + expiration_time=datetime(2099, 1, 1, tzinfo=UTC), + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + workflow_module = sys.modules["controllers.service_api.app.human_input_form"] + monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) + + api = WorkflowHumanInputFormApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + + with app.test_request_context("/form/human_input/token-1", method="GET"): + with pytest.raises(NotFound): + handler(api, app_model=app_model, form_token="token-1") + + service_mock.ensure_form_active.assert_not_called() + + def test_post_success(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + form = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + recipient_type=RecipientType.STANDALONE_WEB_APP, + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + workflow_module = sys.modules["controllers.service_api.app.human_input_form"] + monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) + + api = WorkflowHumanInputFormApi() + handler = _unwrap(api.post) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context( + "/form/human_input/token-1", + method="POST", + json={"inputs": {"name": "Alice"}, "action": "approve", "user": "external-1"}, + ): + response, status = handler(api, app_model=app_model, end_user=end_user, form_token="token-1") + + assert response == {} + assert status == 200 + service_mock.submit_form_by_token.assert_called_once_with( + recipient_type=RecipientType.STANDALONE_WEB_APP, + form_token="token-1", + selected_action_id="approve", + form_data={"name": "Alice"}, + submission_end_user_id="end-user-1", + ) + + @pytest.mark.parametrize( + "recipient_type", + [ + RecipientType.CONSOLE, + RecipientType.BACKSTAGE, + RecipientType.EMAIL_MEMBER, + RecipientType.EMAIL_EXTERNAL, + ], + ) + def test_post_rejects_non_service_api_recipient_types( + self, app, monkeypatch: pytest.MonkeyPatch, recipient_type: RecipientType + ) -> None: + form = SimpleNamespace( + app_id="app-1", + tenant_id="tenant-1", + recipient_type=recipient_type, + ) + service_mock = Mock() + service_mock.get_form_by_token.return_value = form + workflow_module = sys.modules["controllers.service_api.app.human_input_form"] + monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock) + monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object())) + + api = WorkflowHumanInputFormApi() + handler = _unwrap(api.post) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1") + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context( + "/form/human_input/token-1", + method="POST", + json={"inputs": {"name": "Alice"}, "action": "approve", "user": "external-1"}, + ): + with pytest.raises(NotFound): + handler(api, app_model=app_model, end_user=end_user, form_token="token-1") + + service_mock.submit_form_by_token.assert_not_called() diff --git a/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py b/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py new file mode 100644 index 0000000000..f45a7f9632 --- /dev/null +++ b/api/tests/unit_tests/controllers/service_api/app/test_workflow_events.py @@ -0,0 +1,166 @@ +"""Unit tests for Service API workflow event stream endpoints.""" + +from __future__ import annotations + +import json +import sys +from datetime import UTC, datetime +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from werkzeug.exceptions import NotFound + +from controllers.service_api.app.error import NotWorkflowAppError +from controllers.service_api.app.workflow_events import WorkflowEventsApi +from models.enums import CreatorUserRole +from models.model import AppMode +from tests.unit_tests.controllers.service_api.conftest import _unwrap + + +def _mock_repo_for_run(monkeypatch: pytest.MonkeyPatch, workflow_run): + workflow_events_module = sys.modules["controllers.service_api.app.workflow_events"] + repo = SimpleNamespace(get_workflow_run_by_id_and_tenant_id=lambda **_kwargs: workflow_run) + monkeypatch.setattr( + workflow_events_module.DifyAPIRepositoryFactory, + "create_api_workflow_run_repository", + lambda *_args, **_kwargs: repo, + ) + monkeypatch.setattr(workflow_events_module, "db", SimpleNamespace(engine=object())) + return workflow_events_module + + +class TestWorkflowEventsApi: + def test_wrong_app_mode(self, app) -> None: + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(mode=AppMode.CHAT.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1", method="GET"): + with pytest.raises(NotWorkflowAppError): + handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + def test_workflow_run_not_found(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + _mock_repo_for_run(monkeypatch, workflow_run=None) + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1", method="GET"): + with pytest.raises(NotFound): + handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + def test_workflow_run_permission_denied(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = SimpleNamespace( + id="run-1", + app_id="app-1", + created_by_role=CreatorUserRole.ACCOUNT, + created_by="another-user", + finished_at=None, + ) + _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1", method="GET"): + with pytest.raises(NotFound): + handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + def test_finished_run_returns_sse(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = SimpleNamespace( + id="run-1", + app_id="app-1", + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + finished_at=datetime(2099, 1, 1, tzinfo=UTC), + ) + workflow_events_module = _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + monkeypatch.setattr( + workflow_events_module.WorkflowResponseConverter, + "workflow_run_result_to_finish_response", + lambda **_kwargs: SimpleNamespace( + model_dump=lambda mode="json": {"task_id": "run-1", "status": "succeeded"}, + event=SimpleNamespace(value="workflow_finished"), + ), + ) + + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1", method="GET"): + response = handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + assert response.mimetype == "text/event-stream" + body = response.get_data(as_text=True).strip() + assert body.startswith("data: ") + payload = json.loads(body[len("data: ") :]) + assert payload["task_id"] == "run-1" + assert payload["event"] == "workflow_finished" + + def test_running_run_streams_events(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = SimpleNamespace( + id="run-1", + app_id="app-1", + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + finished_at=None, + ) + workflow_events_module = _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + msg_generator = Mock() + msg_generator.retrieve_events.return_value = ["raw-event"] + workflow_generator = Mock() + workflow_generator.convert_to_event_stream.return_value = iter(["data: streamed\n\n"]) + monkeypatch.setattr(workflow_events_module, "MessageGenerator", lambda: msg_generator) + monkeypatch.setattr(workflow_events_module, "WorkflowAppGenerator", lambda: workflow_generator) + + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1", method="GET"): + response = handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + assert response.get_data(as_text=True) == "data: streamed\n\n" + msg_generator.retrieve_events.assert_called_once_with( + AppMode.WORKFLOW, + "run-1", + terminal_events=None, + ) + workflow_generator.convert_to_event_stream.assert_called_once_with(["raw-event"]) + + def test_running_run_with_snapshot(self, app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = SimpleNamespace( + id="run-1", + app_id="app-1", + created_by_role=CreatorUserRole.END_USER, + created_by="end-user-1", + finished_at=None, + ) + workflow_events_module = _mock_repo_for_run(monkeypatch, workflow_run=workflow_run) + msg_generator = Mock() + workflow_generator = Mock() + workflow_generator.convert_to_event_stream.return_value = iter(["data: snapshot\n\n"]) + snapshot_builder = Mock(return_value=["snapshot-events"]) + monkeypatch.setattr(workflow_events_module, "MessageGenerator", lambda: msg_generator) + monkeypatch.setattr(workflow_events_module, "WorkflowAppGenerator", lambda: workflow_generator) + monkeypatch.setattr(workflow_events_module, "build_workflow_event_stream", snapshot_builder) + + api = WorkflowEventsApi() + handler = _unwrap(api.get) + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW.value) + end_user = SimpleNamespace(id="end-user-1") + + with app.test_request_context("/workflow/run-1/events?user=u1&include_state_snapshot=true", method="GET"): + response = handler(api, app_model=app_model, end_user=end_user, task_id="run-1") + + assert response.get_data(as_text=True) == "data: snapshot\n\n" + msg_generator.retrieve_events.assert_not_called() + snapshot_builder.assert_called_once() + workflow_generator.convert_to_event_stream.assert_called_once_with(["snapshot-events"]) diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_response_converter.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_response_converter.py index f2df35d7d0..6debeb4fdd 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_response_converter.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_response_converter.py @@ -1,7 +1,10 @@ from collections.abc import Generator +import pytest + from core.app.apps.advanced_chat.generate_response_converter import AdvancedChatAppGenerateResponseConverter from core.app.entities.task_entities import ( + AdvancedChatPausedBlockingResponse, ChatbotAppBlockingResponse, ChatbotAppStreamResponse, ErrorStreamResponse, @@ -10,7 +13,8 @@ from core.app.entities.task_entities import ( NodeStartStreamResponse, PingStreamResponse, ) -from graphon.enums import WorkflowNodeExecutionStatus +from graphon.entities.pause_reason import PauseReasonType +from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus class TestAdvancedChatGenerateResponseConverter: @@ -28,6 +32,37 @@ class TestAdvancedChatGenerateResponseConverter: response = AdvancedChatAppGenerateResponseConverter.convert_blocking_simple_response(blocking) assert "usage" not in response["metadata"] + def test_blocking_full_response_derives_pause_data_from_model_dump(self, monkeypatch: pytest.MonkeyPatch): + data = AdvancedChatPausedBlockingResponse.Data( + id="msg-1", + mode="chat", + conversation_id="c1", + message_id="m1", + workflow_run_id="run-1", + answer="partial", + metadata={"usage": {"total_tokens": 1}}, + created_at=1, + paused_nodes=["node-1"], + reasons=[{"type": PauseReasonType.HUMAN_INPUT_REQUIRED, "form_id": "form-1"}], + status=WorkflowExecutionStatus.PAUSED, + elapsed_time=0.1, + total_tokens=0, + total_steps=0, + ) + original_model_dump = type(data).model_dump + + def _model_dump_with_future_field(self, *args, **kwargs): + payload = original_model_dump(self, *args, **kwargs) + payload["future_field"] = "future-value" + return payload + + monkeypatch.setattr(type(data), "model_dump", _model_dump_with_future_field) + blocking = AdvancedChatPausedBlockingResponse(task_id="t1", data=data) + + response = AdvancedChatAppGenerateResponseConverter.convert_blocking_full_response(blocking) + + assert response["data"]["future_field"] == "future-value" + def test_stream_simple_response_includes_node_events(self): node_start = NodeStartStreamResponse( task_id="t1", diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py index 29fd63c063..64bcfa9a18 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py @@ -39,15 +39,19 @@ from core.app.entities.queue_entities import ( QueueWorkflowSucceededEvent, ) from core.app.entities.task_entities import ( + AdvancedChatPausedBlockingResponse, AnnotationReply, AnnotationReplyAccount, + HumanInputRequiredResponse, MessageAudioStreamResponse, MessageEndStreamResponse, PingStreamResponse, ) from core.base.tts.app_generator_tts_publisher import AudioTrunk from core.workflow.system_variables import build_system_variables +from graphon.entities.pause_reason import PauseReasonType from graphon.enums import BuiltinNodeTypes +from graphon.nodes.human_input.entities import UserAction from graphon.runtime import GraphRuntimeState, VariablePool from libs.datetime_utils import naive_utc_now from models.enums import MessageStatus @@ -123,6 +127,57 @@ class TestAdvancedChatGenerateTaskPipeline: assert response.data.answer == "done" assert response.data.metadata == {"k": "v"} + def test_to_blocking_response_falls_back_to_human_input_required_when_pause_event_missing(self): + pipeline = _make_pipeline() + pipeline._task_state.answer = "partial answer" + pipeline._workflow_run_id = "run-id" + pipeline._graph_runtime_state = GraphRuntimeState( + variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + start_at=0.0, + total_tokens=7, + node_run_steps=3, + ) + + def _gen(): + yield HumanInputRequiredResponse( + task_id="task", + workflow_run_id="run-id", + data=HumanInputRequiredResponse.Data( + form_id="form-1", + node_id="node-1", + node_title="Approval", + form_content="Need approval", + inputs=[], + actions=[UserAction(id="approve", title="Approve")], + display_in_ui=True, + form_token="token-1", + resolved_default_values={}, + expiration_time=123, + ), + ) + + response = pipeline._to_blocking_response(_gen()) + + assert isinstance(response, AdvancedChatPausedBlockingResponse) + assert response.data.workflow_run_id == "run-id" + assert response.data.status == "paused" + assert response.data.paused_nodes == ["node-1"] + assert response.data.reasons == [ + { + "TYPE": PauseReasonType.HUMAN_INPUT_REQUIRED, + "form_id": "form-1", + "node_id": "node-1", + "node_title": "Approval", + "form_content": "Need approval", + "inputs": [], + "actions": [{"id": "approve", "title": "Approve", "button_style": "default"}], + "display_in_ui": True, + "form_token": "token-1", + "resolved_default_values": {}, + "expiration_time": 123, + } + ] + def test_handle_text_chunk_event_updates_state(self): pipeline = _make_pipeline() pipeline._message_cycle_manager = SimpleNamespace( diff --git a/api/tests/unit_tests/core/app/apps/test_base_app_generate_response_converter.py b/api/tests/unit_tests/core/app/apps/test_base_app_generate_response_converter.py new file mode 100644 index 0000000000..560652f8cb --- /dev/null +++ b/api/tests/unit_tests/core/app/apps/test_base_app_generate_response_converter.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +from collections.abc import Generator + +from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter +from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.entities.task_entities import ( + AppStreamResponse, + PingStreamResponse, + WorkflowAppBlockingResponse, + WorkflowAppStreamResponse, +) +from graphon.enums import WorkflowExecutionStatus + + +class _DummyConverter(AppGenerateResponseConverter[WorkflowAppBlockingResponse]): + blocking_full_calls: list[WorkflowAppBlockingResponse] = [] + blocking_simple_calls: list[WorkflowAppBlockingResponse] = [] + stream_full_calls: list[Generator[AppStreamResponse, None, None]] = [] + stream_simple_calls: list[Generator[AppStreamResponse, None, None]] = [] + + @classmethod + def reset(cls) -> None: + cls.blocking_full_calls = [] + cls.blocking_simple_calls = [] + cls.stream_full_calls = [] + cls.stream_simple_calls = [] + + @classmethod + def convert_blocking_full_response(cls, blocking_response: WorkflowAppBlockingResponse) -> dict[str, object]: + cls.blocking_full_calls.append(blocking_response) + return {"kind": "blocking-full", "task_id": blocking_response.task_id} + + @classmethod + def convert_blocking_simple_response(cls, blocking_response: WorkflowAppBlockingResponse) -> dict[str, object]: + cls.blocking_simple_calls.append(blocking_response) + return {"kind": "blocking-simple", "task_id": blocking_response.task_id} + + @classmethod + def convert_stream_full_response( + cls, stream_response: Generator[AppStreamResponse, None, None] + ) -> Generator[dict | str, None, None]: + cls.stream_full_calls.append(stream_response) + yield {"kind": "stream-full"} + + @classmethod + def convert_stream_simple_response( + cls, stream_response: Generator[AppStreamResponse, None, None] + ) -> Generator[dict | str, None, None]: + cls.stream_simple_calls.append(stream_response) + yield {"kind": "stream-simple"} + + +def _build_blocking_response() -> WorkflowAppBlockingResponse: + return WorkflowAppBlockingResponse( + task_id="task-1", + workflow_run_id="run-1", + data=WorkflowAppBlockingResponse.Data( + id="run-1", + workflow_id="workflow-1", + status=WorkflowExecutionStatus.SUCCEEDED, + outputs={"ok": True}, + error=None, + elapsed_time=0.1, + total_tokens=0, + total_steps=1, + created_at=1, + finished_at=2, + ), + ) + + +def _build_stream_response() -> Generator[AppStreamResponse, None, None]: + yield WorkflowAppStreamResponse( + workflow_run_id="run-1", + stream_response=PingStreamResponse(task_id="task-1"), + ) + + +def test_convert_routes_blocking_response_by_invoke_from() -> None: + _DummyConverter.reset() + blocking_response = _build_blocking_response() + + full_result = _DummyConverter.convert(blocking_response, InvokeFrom.SERVICE_API) + simple_result = _DummyConverter.convert(blocking_response, InvokeFrom.WEB_APP) + + assert full_result == {"kind": "blocking-full", "task_id": "task-1"} + assert simple_result == {"kind": "blocking-simple", "task_id": "task-1"} + assert _DummyConverter.blocking_full_calls == [blocking_response] + assert _DummyConverter.blocking_simple_calls == [blocking_response] + + +def test_convert_routes_stream_response_by_invoke_from() -> None: + _DummyConverter.reset() + + full_result = list(_DummyConverter.convert(_build_stream_response(), InvokeFrom.SERVICE_API)) + simple_result = list(_DummyConverter.convert(_build_stream_response(), InvokeFrom.WEB_APP)) + + assert full_result == [{"kind": "stream-full"}] + assert simple_result == [{"kind": "stream-simple"}] + assert len(_DummyConverter.stream_full_calls) == 1 + assert len(_DummyConverter.stream_simple_calls) == 1 diff --git a/api/tests/unit_tests/core/app/apps/test_message_generator.py b/api/tests/unit_tests/core/app/apps/test_message_generator.py index 25377e633e..90c9abf35c 100644 --- a/api/tests/unit_tests/core/app/apps/test_message_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_message_generator.py @@ -1,6 +1,7 @@ from unittest.mock import Mock, patch from core.app.apps.message_generator import MessageGenerator +from core.app.entities.task_entities import StreamEvent from models.model import AppMode @@ -23,7 +24,21 @@ class TestMessageGenerator: "core.app.apps.message_generator.stream_topic_events", return_value=iter([{"event": "ping"}]) ) as mock_stream, ): - events = list(MessageGenerator.retrieve_events(AppMode.WORKFLOW, "run-1", idle_timeout=1, ping_interval=2)) + events = list( + MessageGenerator.retrieve_events( + AppMode.WORKFLOW, + "run-1", + idle_timeout=1, + ping_interval=2, + terminal_events=[StreamEvent.WORKFLOW_FINISHED.value], + ) + ) assert events == [{"event": "ping"}] - mock_stream.assert_called_once() + mock_stream.assert_called_once_with( + topic="topic", + idle_timeout=1, + ping_interval=2, + on_subscribe=None, + terminal_events=[StreamEvent.WORKFLOW_FINISHED.value], + ) diff --git a/api/tests/unit_tests/core/app/apps/test_streaming_utils.py b/api/tests/unit_tests/core/app/apps/test_streaming_utils.py index a7714c56ce..58f0e47a4b 100644 --- a/api/tests/unit_tests/core/app/apps/test_streaming_utils.py +++ b/api/tests/unit_tests/core/app/apps/test_streaming_utils.py @@ -88,6 +88,10 @@ def test_normalize_terminal_events_defaults(): } +def test_normalize_terminal_events_empty_values(): + assert _normalize_terminal_events([]) == set({}) + + def test_stream_topic_events_emits_ping_and_idle_timeout(monkeypatch): topic = FakeTopic() times = [1000.0, 1000.0, 1001.0, 1001.0, 1002.0] @@ -106,3 +110,21 @@ def test_stream_topic_events_emits_ping_and_idle_timeout(monkeypatch): assert next(generator) == StreamEvent.PING.value # next receive yields None -> ping interval triggers assert next(generator) == StreamEvent.PING.value + + +def test_stream_topic_events_can_continue_past_pause(): + topic = FakeTopic() + topic.publish(json.dumps({"event": StreamEvent.WORKFLOW_PAUSED.value}).encode()) + topic.publish(json.dumps({"event": StreamEvent.WORKFLOW_FINISHED.value}).encode()) + + generator = stream_topic_events( + topic=topic, + idle_timeout=1.0, + terminal_events=[StreamEvent.WORKFLOW_FINISHED.value], + ) + + assert next(generator) == StreamEvent.PING.value + assert next(generator)["event"] == StreamEvent.WORKFLOW_PAUSED.value + assert next(generator)["event"] == StreamEvent.WORKFLOW_FINISHED.value + with pytest.raises(StopIteration): + next(generator) diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py index 99433478d3..0bcc1029b0 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py @@ -36,11 +36,12 @@ from core.app.entities.queue_entities import ( ) from core.app.entities.task_entities import ( ErrorStreamResponse, + HumanInputRequiredResponse, MessageAudioEndStreamResponse, MessageAudioStreamResponse, PingStreamResponse, + WorkflowAppPausedBlockingResponse, WorkflowFinishStreamResponse, - WorkflowPauseStreamResponse, WorkflowStartStreamResponse, ) from core.base.tts.app_generator_tts_publisher import AudioTrunk @@ -91,27 +92,50 @@ def _make_pipeline(): class TestWorkflowGenerateTaskPipeline: - def test_to_blocking_response_handles_pause(self): + def test_to_blocking_response_falls_back_to_human_input_required_when_pause_event_missing(self): pipeline = _make_pipeline() + pipeline._graph_runtime_state = GraphRuntimeState( + variable_pool=VariablePool(system_variables=build_system_variables(workflow_execution_id="run-id")), + start_at=0.0, + total_tokens=5, + node_run_steps=2, + ) def _gen(): - yield WorkflowPauseStreamResponse( + yield HumanInputRequiredResponse( task_id="task", - workflow_run_id="run", - data=WorkflowPauseStreamResponse.Data( - workflow_run_id="run", - status=WorkflowExecutionStatus.PAUSED, - outputs={}, - created_at=1, - elapsed_time=0.1, - total_tokens=0, - total_steps=0, + workflow_run_id="run-id", + data=HumanInputRequiredResponse.Data( + form_id="form-1", + node_id="node-1", + node_title="Human Input", + form_content="content", + expiration_time=1, ), ) response = pipeline._to_blocking_response(_gen()) + assert isinstance(response, WorkflowAppPausedBlockingResponse) + assert response.workflow_run_id == "run-id" assert response.data.status == WorkflowExecutionStatus.PAUSED + assert response.data.created_at == 0 + assert response.data.paused_nodes == ["node-1"] + assert response.data.reasons == [ + { + "TYPE": "human_input_required", + "form_id": "form-1", + "node_id": "node-1", + "node_title": "Human Input", + "form_content": "content", + "inputs": [], + "actions": [], + "display_in_ui": False, + "form_token": None, + "resolved_default_values": {}, + "expiration_time": 1, + } + ] def test_to_blocking_response_handles_finish(self): pipeline = _make_pipeline() diff --git a/api/tests/unit_tests/core/workflow/test_human_input_forms.py b/api/tests/unit_tests/core/workflow/test_human_input_forms.py index 6071a95a57..e508815b35 100644 --- a/api/tests/unit_tests/core/workflow/test_human_input_forms.py +++ b/api/tests/unit_tests/core/workflow/test_human_input_forms.py @@ -1,6 +1,7 @@ from types import SimpleNamespace -from core.workflow.human_input_forms import load_form_tokens_by_form_id +from core.workflow.human_input_forms import _load_form_tokens_by_form_id, load_form_tokens_by_form_id +from core.workflow.human_input_policy import HumanInputSurface from models.human_input import RecipientType @@ -53,3 +54,50 @@ def test_load_form_tokens_by_form_id_ignores_unsupported_recipients() -> None: ) assert load_form_tokens_by_form_id(["form-1"], session=session) == {} + + +def test_load_form_tokens_by_form_id_uses_shared_priority() -> None: + session = _FakeSession( + recipients=[ + SimpleNamespace( + form_id="form-1", + recipient_type=RecipientType.STANDALONE_WEB_APP, + access_token="web-token", + ), + SimpleNamespace( + form_id="form-1", + recipient_type=RecipientType.CONSOLE, + access_token="console-token", + ), + ] + ) + + assert _load_form_tokens_by_form_id(session, ["form-1"]) == {"form-1": "console-token"} + + +def test_load_form_tokens_by_form_id_uses_web_token_for_service_api_surface() -> None: + session = _FakeSession( + recipients=[ + SimpleNamespace( + form_id="form-1", + recipient_type=RecipientType.STANDALONE_WEB_APP, + access_token="web-token", + ), + SimpleNamespace( + form_id="form-1", + recipient_type=RecipientType.CONSOLE, + access_token="console-token", + ), + SimpleNamespace( + form_id="form-1", + recipient_type=RecipientType.BACKSTAGE, + access_token="backstage-token", + ), + ] + ) + + assert load_form_tokens_by_form_id( + ["form-1"], + session=session, + surface=HumanInputSurface.SERVICE_API, + ) == {"form-1": "web-token"} diff --git a/api/tests/unit_tests/core/workflow/test_human_input_policy.py b/api/tests/unit_tests/core/workflow/test_human_input_policy.py new file mode 100644 index 0000000000..e6d0366af5 --- /dev/null +++ b/api/tests/unit_tests/core/workflow/test_human_input_policy.py @@ -0,0 +1,50 @@ +from core.workflow.human_input_policy import ( + HumanInputSurface, + get_preferred_form_token, + is_recipient_type_allowed_for_surface, +) +from models.human_input import RecipientType + + +def test_service_api_only_allows_public_webapp_forms() -> None: + assert is_recipient_type_allowed_for_surface( + RecipientType.STANDALONE_WEB_APP, + HumanInputSurface.SERVICE_API, + ) + assert not is_recipient_type_allowed_for_surface( + RecipientType.CONSOLE, + HumanInputSurface.SERVICE_API, + ) + assert not is_recipient_type_allowed_for_surface( + RecipientType.BACKSTAGE, + HumanInputSurface.SERVICE_API, + ) + assert not is_recipient_type_allowed_for_surface( + RecipientType.EMAIL_MEMBER, + HumanInputSurface.SERVICE_API, + ) + + +def test_console_only_allows_internal_console_surfaces() -> None: + assert is_recipient_type_allowed_for_surface( + RecipientType.CONSOLE, + HumanInputSurface.CONSOLE, + ) + assert is_recipient_type_allowed_for_surface( + RecipientType.BACKSTAGE, + HumanInputSurface.CONSOLE, + ) + assert not is_recipient_type_allowed_for_surface( + RecipientType.STANDALONE_WEB_APP, + HumanInputSurface.CONSOLE, + ) + + +def test_preferred_form_token_uses_shared_priority_order() -> None: + recipients = [ + (RecipientType.STANDALONE_WEB_APP, "web-token"), + (RecipientType.CONSOLE, "console-token"), + (RecipientType.BACKSTAGE, "backstage-token"), + ] + + assert get_preferred_form_token(recipients) == "backstage-token" 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..ac4b087b91 --- /dev/null +++ b/api/tests/unit_tests/repositories/test_sqlalchemy_api_workflow_run_repository.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from datetime import UTC, datetime +from types import SimpleNamespace + +from graphon.nodes.human_input.entities import FormDefinition, FormInput, UserAction +from graphon.nodes.human_input.enums import FormInputType +from models.human_input import RecipientType +from repositories.sqlalchemy_api_workflow_run_repository import _build_human_input_required_reason + + +def _build_form_model() -> SimpleNamespace: + expiration_time = datetime(2024, 1, 1, tzinfo=UTC) + definition = FormDefinition( + form_content="content", + inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")], + user_actions=[UserAction(id="approve", title="Approve")], + rendered_content="rendered", + expiration_time=expiration_time, + default_values={"name": "Alice"}, + node_title="Ask Name", + display_in_ui=True, + ) + return SimpleNamespace( + id="form-1", + node_id="node-1", + form_definition=definition.model_dump_json(), + expiration_time=expiration_time, + ) + + +def _build_reason_model() -> SimpleNamespace: + return SimpleNamespace(form_id="form-1", node_id="node-1") + + +def test_build_human_input_required_reason_prefers_standalone_web_app_token() -> None: + reason = _build_human_input_required_reason( + _build_reason_model(), + _build_form_model(), + [ + SimpleNamespace(recipient_type=RecipientType.BACKSTAGE, access_token="btok"), + SimpleNamespace(recipient_type=RecipientType.CONSOLE, access_token="ctok"), + SimpleNamespace(recipient_type=RecipientType.STANDALONE_WEB_APP, access_token="wtok"), + ], + ) + + assert reason.node_title == "Ask Name" + assert reason.resolved_default_values == {"name": "Alice"} + assert not hasattr(reason, "form_token") + + +def test_build_human_input_required_reason_falls_back_to_console_token() -> None: + reason = _build_human_input_required_reason( + _build_reason_model(), + _build_form_model(), + [ + SimpleNamespace(recipient_type=RecipientType.BACKSTAGE, access_token="btok"), + SimpleNamespace(recipient_type=RecipientType.CONSOLE, access_token="ctok"), + ], + ) + + assert reason.node_id == "node-1" + assert reason.actions[0].id == "approve" + assert not hasattr(reason, "form_token") diff --git a/api/tests/unit_tests/services/test_app_generate_service.py b/api/tests/unit_tests/services/test_app_generate_service.py index c2b430c551..119a7adc45 100644 --- a/api/tests/unit_tests/services/test_app_generate_service.py +++ b/api/tests/unit_tests/services/test_app_generate_service.py @@ -327,7 +327,8 @@ class TestGenerate: streaming=False, ) assert result == {"result": "advanced-blocking"} - assert gen_spy.call_args.kwargs.get("streaming") is False + call_kwargs = gen_spy.call_args.kwargs + assert call_kwargs.get("streaming") is False retrieve_spy.assert_not_called() # -- ADVANCED_CHAT streaming -------------------------------------------- diff --git a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py index d570dce107..dfdbd9acd6 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_event_snapshot_service.py @@ -1,14 +1,20 @@ import json import queue -from collections.abc import Sequence +from collections.abc import Mapping, Sequence from dataclasses import dataclass from datetime import UTC, datetime +from itertools import cycle from threading import Event +from types import SimpleNamespace +from typing import Any, cast +from unittest.mock import MagicMock import pytest +from sqlalchemy.orm import Session, sessionmaker from core.app.app_config.entities import WorkflowUIBasedAppConfig from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity +from core.app.entities.task_entities import StreamEvent from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper from graphon.entities.pause_reason import HumanInputRequired from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus @@ -18,11 +24,14 @@ from models.model import AppMode from models.workflow import WorkflowRun from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot from repositories.entities.workflow_pause import WorkflowPauseEntity +from services import workflow_event_snapshot_service as service_module from services.workflow_event_snapshot_service import ( BufferState, MessageContext, _build_snapshot_events, + _is_terminal_event, _resolve_task_id, + build_workflow_event_stream, ) @@ -125,50 +134,6 @@ def _build_resumption_context(task_id: str) -> WorkflowResumptionContext: ) -def test_build_snapshot_events_includes_pause_event() -> None: - workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED) - snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED) - resumption_context = _build_resumption_context("task-ctx") - pause_entity = _FakePauseEntity( - pause_id="pause-1", - workflow_run_id="run-1", - paused_at_value=datetime(2024, 1, 1, tzinfo=UTC), - pause_reasons=[ - HumanInputRequired( - form_id="form-1", - form_content="content", - node_id="node-1", - node_title="Human Input", - ) - ], - ) - - events = _build_snapshot_events( - workflow_run=workflow_run, - node_snapshots=[snapshot], - task_id="task-ctx", - message_context=None, - pause_entity=pause_entity, - resumption_context=resumption_context, - ) - - assert [event["event"] for event in events] == [ - "workflow_started", - "node_started", - "node_finished", - "workflow_paused", - ] - assert events[2]["data"]["status"] == WorkflowNodeExecutionStatus.PAUSED.value - pause_data = events[-1]["data"] - assert pause_data["paused_nodes"] == ["node-1"] - assert pause_data["outputs"] == {"result": "value"} - assert pause_data["status"] == WorkflowExecutionStatus.PAUSED.value - assert pause_data["created_at"] == int(workflow_run.created_at.timestamp()) - assert pause_data["elapsed_time"] == workflow_run.elapsed_time - assert pause_data["total_tokens"] == workflow_run.total_tokens - assert pause_data["total_steps"] == workflow_run.total_steps - - def test_build_snapshot_events_applies_message_context() -> None: workflow_run = _build_workflow_run(WorkflowExecutionStatus.RUNNING) snapshot = _build_snapshot(WorkflowNodeExecutionStatus.SUCCEEDED) @@ -222,3 +187,656 @@ def test_resolve_task_id_priority(context_task_id, buffered_task_id, expected) - buffer_state.task_id_ready.set() task_id = _resolve_task_id(resumption_context, buffer_state, "run-1", wait_timeout=0.0) assert task_id == expected + + +def _build_workflow_run_additional(status: WorkflowExecutionStatus = WorkflowExecutionStatus.RUNNING) -> WorkflowRun: + return WorkflowRun( + id="run-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_id="workflow-1", + type="workflow", + triggered_from="app-run", + version="v1", + graph=None, + inputs=json.dumps({"query": "hello"}), + status=status, + outputs=json.dumps({}), + error=None, + elapsed_time=1.2, + total_tokens=5, + total_steps=2, + created_by_role=CreatorUserRole.END_USER, + created_by="user-1", + created_at=datetime(2024, 1, 1, tzinfo=UTC), + ) + + +def _build_resumption_context_additional(task_id: str) -> WorkflowResumptionContext: + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant-1", + app_id="app-1", + app_mode=AppMode.WORKFLOW, + workflow_id="workflow-1", + ) + generate_entity = WorkflowAppGenerateEntity( + task_id=task_id, + app_config=app_config, + inputs={}, + files=[], + user_id="user-1", + stream=True, + invoke_from=InvokeFrom.EXPLORE, + call_depth=0, + workflow_execution_id="run-1", + ) + runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0) + runtime_state.outputs = {"answer": "ok"} + wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity) + return WorkflowResumptionContext( + generate_entity=wrapper, + serialized_graph_runtime_state=runtime_state.dumps(), + ) + + +class _SessionContext: + def __init__(self, session: Any) -> None: + self._session = session + + def __enter__(self) -> Any: + return self._session + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: + return False + + +class _SessionMaker: + def __init__(self, session: Any) -> None: + self._session = session + + def __call__(self) -> _SessionContext: + return _SessionContext(self._session) + + +class _SubscriptionContext: + def __init__(self, subscription: Any) -> None: + self._subscription = subscription + + def __enter__(self) -> Any: + return self._subscription + + def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool: + return False + + +class _Topic: + def __init__(self, subscription: Any) -> None: + self._subscription = subscription + + def subscribe(self) -> _SubscriptionContext: + return _SubscriptionContext(self._subscription) + + +class _StaticSubscription: + def receive(self, timeout: int = 1) -> None: + return None + + +@dataclass(frozen=True) +class _PauseEntity(WorkflowPauseEntity): + state: bytes + + @property + def id(self) -> str: + return "pause-1" + + @property + def workflow_execution_id(self) -> str: + return "run-1" + + @property + def resumed_at(self) -> datetime | None: + return None + + @property + def paused_at(self) -> datetime: + return datetime(2024, 1, 1, tzinfo=UTC) + + def get_state(self) -> bytes: + return self.state + + def get_pause_reasons(self) -> list[Any]: + return [] + + +def test_get_message_context_should_return_none_when_no_message() -> None: + # Arrange + session = SimpleNamespace(scalar=MagicMock(return_value=None)) + session_maker = _SessionMaker(session) + + # Act + result = service_module._get_message_context(cast(sessionmaker[Session], session_maker), "run-1") + + # Assert + assert result is None + + +def test_get_message_context_should_default_created_at_to_zero_when_message_has_no_timestamp() -> None: + # Arrange + message = SimpleNamespace( + id="msg-1", + conversation_id="conv-1", + created_at=None, + answer="answer", + ) + session = SimpleNamespace(scalar=MagicMock(return_value=message)) + session_maker = _SessionMaker(session) + + # Act + result = service_module._get_message_context(cast(sessionmaker[Session], session_maker), "run-1") + + # Assert + assert result is not None + assert result.created_at == 0 + assert result.message_id == "msg-1" + assert result.conversation_id == "conv-1" + assert result.answer == "answer" + + +def test_load_resumption_context_should_return_none_when_pause_entity_missing() -> None: + # Arrange + + # Act + result = service_module._load_resumption_context(None) + + # Assert + assert result is None + + +def test_load_resumption_context_should_return_none_when_pause_entity_state_is_invalid() -> None: + # Arrange + pause_entity = _PauseEntity(state=b"not-a-valid-state") + + # Act + result = service_module._load_resumption_context(pause_entity) + + # Assert + assert result is None + + +def test_load_resumption_context_should_parse_valid_state_into_context() -> None: + # Arrange + context = _build_resumption_context_additional(task_id="task-ctx") + pause_entity = _PauseEntity(state=context.dumps().encode()) + + # Act + result = service_module._load_resumption_context(pause_entity) + + # Assert + assert result is not None + assert result.get_generate_entity().task_id == "task-ctx" + + +def test_resolve_task_id_should_return_workflow_run_id_when_buffer_state_is_missing() -> None: + # Arrange + + # Act + result = service_module._resolve_task_id( + resumption_context=None, + buffer_state=None, + workflow_run_id="run-1", + ) + + # Assert + assert result == "run-1" + + +@pytest.mark.parametrize( + ("payload", "expected"), + [ + (b'{"event":"node_started"}', {"event": "node_started"}), + (b"invalid-json", None), + (b"[]", None), + ], +) +def test_parse_event_message_should_parse_only_json_object( + payload: bytes, + expected: dict[str, Any] | None, +) -> None: + # Arrange + + # Act + result = service_module._parse_event_message(payload) + + # Assert + assert result == expected + + +def test_is_terminal_event_should_recognize_finished_and_optional_paused_events() -> None: + # Arrange + finished_event = {"event": StreamEvent.WORKFLOW_FINISHED.value} + paused_event = {"event": StreamEvent.WORKFLOW_PAUSED.value} + + # Act + is_finished = service_module._is_terminal_event(finished_event, close_on_pause=False) + paused_without_flag = service_module._is_terminal_event(paused_event, close_on_pause=False) + paused_with_flag = service_module._is_terminal_event(paused_event, close_on_pause=True) + + # Assert + assert is_finished is True + assert paused_without_flag is False + assert paused_with_flag is True + assert service_module._is_terminal_event(StreamEvent.PING.value, close_on_pause=True) is False + + +def test_apply_message_context_should_update_payload_when_context_exists() -> None: + # Arrange + payload: dict[str, Any] = {"event": "workflow_started"} + context = MessageContext(conversation_id="conv-1", message_id="msg-1", created_at=1700000000) + + # Act + service_module._apply_message_context(payload, context) + + # Assert + assert payload["conversation_id"] == "conv-1" + assert payload["message_id"] == "msg-1" + assert payload["created_at"] == 1700000000 + + +def test_start_buffering_should_capture_task_id_and_enqueue_event() -> None: + # Arrange + class Subscription: + def __init__(self) -> None: + self._calls = 0 + + def receive(self, timeout: int = 1) -> bytes | None: + self._calls += 1 + if self._calls == 1: + return b'{"event":"node_started","task_id":"task-1"}' + return None + + subscription = Subscription() + + # Act + buffer_state = service_module._start_buffering(subscription) + ready = buffer_state.task_id_ready.wait(timeout=1) + event = buffer_state.queue.get(timeout=1) + buffer_state.stop_event.set() + finished = buffer_state.done_event.wait(timeout=1) + + # Assert + assert ready is True + assert finished is True + assert buffer_state.task_id_hint == "task-1" + assert event["event"] == "node_started" + + +def test_start_buffering_should_drop_old_event_when_queue_is_full( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + class QueueWithSingleFull: + def __init__(self) -> None: + self._first_put = True + self.items: list[dict[str, Any]] = [{"event": "old"}] + + def put_nowait(self, item: dict[str, Any]) -> None: + if self._first_put: + self._first_put = False + raise queue.Full + self.items.append(item) + + def get_nowait(self) -> dict[str, Any]: + if not self.items: + raise queue.Empty + return self.items.pop(0) + + def empty(self) -> bool: + return len(self.items) == 0 + + fake_queue = QueueWithSingleFull() + monkeypatch.setattr(service_module.queue, "Queue", lambda maxsize=2048: fake_queue) + + class Subscription: + def __init__(self) -> None: + self._calls = 0 + + def receive(self, timeout: int = 1) -> bytes | None: + self._calls += 1 + if self._calls == 1: + return b'{"event":"node_started","task_id":"task-2"}' + return None + + subscription = Subscription() + + # Act + buffer_state = service_module._start_buffering(subscription) + ready = buffer_state.task_id_ready.wait(timeout=1) + buffer_state.stop_event.set() + finished = buffer_state.done_event.wait(timeout=1) + + # Assert + assert ready is True + assert finished is True + assert fake_queue.items[-1]["task_id"] == "task-2" + + +def test_start_buffering_should_set_done_event_when_subscription_raises() -> None: + # Arrange + class Subscription: + def receive(self, timeout: int = 1) -> bytes | None: + raise RuntimeError("subscription failure") + + subscription = Subscription() + + # Act + buffer_state = service_module._start_buffering(subscription) + finished = buffer_state.done_event.wait(timeout=1) + + # Assert + assert finished is True + + +def test_build_workflow_event_stream_should_emit_ping_and_terminal_snapshot_event( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.RUNNING) + topic = _Topic(_StaticSubscription()) + workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock()) + node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) + factory = SimpleNamespace( + create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), + create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), + ) + monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) + monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) + monkeypatch.setattr( + service_module, + "_get_message_context", + MagicMock(return_value=MessageContext("conv-1", "msg-1", 1700000000)), + ) + monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) + buffer_state = BufferState( + queue=queue.Queue(), + stop_event=Event(), + done_event=Event(), + task_id_ready=Event(), + task_id_hint="task-1", + ) + monkeypatch.setattr(service_module, "_start_buffering", MagicMock(return_value=buffer_state)) + monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) + monkeypatch.setattr( + service_module, + "_build_snapshot_events", + MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED.value, "task_id": "task-1"}]), + ) + + # Act + events = list( + build_workflow_event_stream( + app_mode=AppMode.ADVANCED_CHAT, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=MagicMock(), + ) + ) + + # Assert + assert events[0] == StreamEvent.PING.value + finished_event = cast(Mapping[str, Any], events[1]) + assert finished_event["event"] == StreamEvent.WORKFLOW_FINISHED.value + assert buffer_state.stop_event.is_set() is True + node_repo.get_execution_snapshots_by_workflow_run.assert_called_once() + called_kwargs = node_repo.get_execution_snapshots_by_workflow_run.call_args.kwargs + assert called_kwargs["workflow_run_id"] == "run-1" + + +def test_build_workflow_event_stream_should_emit_periodic_ping_and_stop_after_idle_timeout( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.RUNNING) + topic = _Topic(_StaticSubscription()) + workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock()) + node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) + factory = SimpleNamespace( + create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), + create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), + ) + monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) + monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) + monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) + monkeypatch.setattr(service_module, "_build_snapshot_events", MagicMock(return_value=[])) + monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) + + class AlwaysEmptyQueue: + def empty(self) -> bool: + return False + + def get(self, timeout: int = 1) -> None: + raise queue.Empty + + buffer_state = BufferState( + queue=AlwaysEmptyQueue(), # type: ignore[arg-type] + stop_event=Event(), + done_event=Event(), + task_id_ready=Event(), + task_id_hint="task-1", + ) + monkeypatch.setattr(service_module, "_start_buffering", MagicMock(return_value=buffer_state)) + time_values = cycle([0.0, 6.0, 21.0, 26.0]) + monkeypatch.setattr(service_module.time, "time", lambda: next(time_values)) + + # Act + events = list( + build_workflow_event_stream( + app_mode=AppMode.WORKFLOW, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=MagicMock(), + idle_timeout=20.0, + ping_interval=5.0, + ) + ) + + # Assert + assert events == [StreamEvent.PING.value, StreamEvent.PING.value] + assert buffer_state.stop_event.is_set() is True + + +def test_build_workflow_event_stream_should_exit_when_buffer_done_and_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.RUNNING) + topic = _Topic(_StaticSubscription()) + workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock()) + node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) + factory = SimpleNamespace( + create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), + create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), + ) + monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) + monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) + monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) + monkeypatch.setattr(service_module, "_build_snapshot_events", MagicMock(return_value=[])) + monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) + buffer_state = BufferState( + queue=queue.Queue(), + stop_event=Event(), + done_event=Event(), + task_id_ready=Event(), + task_id_hint="task-1", + ) + buffer_state.done_event.set() + monkeypatch.setattr(service_module, "_start_buffering", MagicMock(return_value=buffer_state)) + + # Act + events = list( + build_workflow_event_stream( + app_mode=AppMode.WORKFLOW, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=MagicMock(), + ) + ) + + # Assert + assert events == [StreamEvent.PING.value] + assert buffer_state.stop_event.is_set() is True + + +def test_build_workflow_event_stream_should_continue_when_pause_loading_fails( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Arrange + workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.PAUSED) + topic = _Topic(_StaticSubscription()) + workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock(side_effect=RuntimeError("boom"))) + node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) + factory = SimpleNamespace( + create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), + create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), + ) + monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) + monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) + monkeypatch.setattr(service_module, "_load_resumption_context", MagicMock(return_value=None)) + monkeypatch.setattr(service_module, "_resolve_task_id", MagicMock(return_value="task-1")) + snapshot_builder = MagicMock(return_value=[{"event": StreamEvent.WORKFLOW_FINISHED.value}]) + monkeypatch.setattr(service_module, "_build_snapshot_events", snapshot_builder) + buffer_state = BufferState( + queue=queue.Queue(), + stop_event=Event(), + done_event=Event(), + task_id_ready=Event(), + task_id_hint="task-1", + ) + monkeypatch.setattr(service_module, "_start_buffering", MagicMock(return_value=buffer_state)) + + # Act + events = list( + build_workflow_event_stream( + app_mode=AppMode.WORKFLOW, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=MagicMock(), + ) + ) + + # Assert + assert events[0] == StreamEvent.PING.value + assert snapshot_builder.call_args.kwargs["pause_entity"] is None + + +def test_is_terminal_event_respects_close_on_pause_flag() -> None: + pause_event = {"event": "workflow_paused"} + finish_event = {"event": "workflow_finished"} + + assert _is_terminal_event(pause_event, close_on_pause=True) is True + assert _is_terminal_event(pause_event, close_on_pause=False) is False + assert _is_terminal_event(finish_event, close_on_pause=False) is True + + +def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.MonkeyPatch) -> None: + workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED) + snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED) + resumption_context = _build_resumption_context("task-ctx") + monkeypatch.setattr( + service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"} + ) + session_maker = _SessionMaker( + SimpleNamespace( + execute=lambda _stmt: [("form-1", datetime(2024, 1, 1, tzinfo=UTC), '{"display_in_ui": true}')], + ) + ) + pause_entity = _FakePauseEntity( + pause_id="pause-1", + workflow_run_id="run-1", + paused_at_value=datetime(2024, 1, 1, tzinfo=UTC), + pause_reasons=[ + HumanInputRequired( + form_id="form-1", + form_content="content", + node_id="node-1", + node_title="Human Input", + form_token="wtok", + ) + ], + ) + + events = _build_snapshot_events( + workflow_run=workflow_run, + node_snapshots=[snapshot], + task_id="task-ctx", + message_context=None, + pause_entity=pause_entity, + resumption_context=resumption_context, + session_maker=cast(sessionmaker[Session], session_maker), + ) + + assert events[-2]["event"] == StreamEvent.HUMAN_INPUT_REQUIRED.value + assert events[-2]["data"]["form_token"] == "wtok" + assert events[-2]["data"]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) + pause_data = events[-1]["data"] + assert pause_data["reasons"][0]["form_token"] == "wtok" + assert pause_data["reasons"][0]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) + + +def test_build_workflow_event_stream_loads_pause_tokens_without_flask_app_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + workflow_run = _build_workflow_run_additional(status=WorkflowExecutionStatus.PAUSED) + topic = _Topic(_StaticSubscription()) + pause_entity = _FakePauseEntity( + pause_id="pause-1", + workflow_run_id="run-1", + paused_at_value=datetime(2024, 1, 1, tzinfo=UTC), + pause_reasons=[ + HumanInputRequired( + form_id="form-1", + form_content="content", + node_id="node-1", + node_title="Human Input", + ) + ], + ) + workflow_run_repo = SimpleNamespace(get_workflow_pause=MagicMock(return_value=pause_entity)) + node_repo = SimpleNamespace(get_execution_snapshots_by_workflow_run=MagicMock(return_value=[])) + factory = SimpleNamespace( + create_api_workflow_run_repository=MagicMock(return_value=workflow_run_repo), + create_api_workflow_node_execution_repository=MagicMock(return_value=node_repo), + ) + monkeypatch.setattr(service_module, "DifyAPIRepositoryFactory", factory) + monkeypatch.setattr(service_module.MessageGenerator, "get_response_topic", MagicMock(return_value=topic)) + monkeypatch.setattr( + service_module, "_load_resumption_context", MagicMock(return_value=_build_resumption_context("task-1")) + ) + monkeypatch.setattr( + service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"} + ) + + session = SimpleNamespace( + scalar=MagicMock(return_value=None), + execute=lambda _stmt: [("form-1", datetime(2024, 1, 1, tzinfo=UTC), '{"display_in_ui": true}')], + ) + session_maker = _SessionMaker(session) + + events = list( + build_workflow_event_stream( + app_mode=AppMode.WORKFLOW, + workflow_run=workflow_run, + tenant_id="tenant-1", + app_id="app-1", + session_maker=cast(sessionmaker[Session], session_maker), + ) + ) + + pause_event = cast(Mapping[str, Any], events[-1]) + assert pause_event["event"] == StreamEvent.WORKFLOW_PAUSED.value + assert pause_event["data"]["reasons"][0]["form_token"] == "wtok" + assert pause_event["data"]["reasons"][0]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp()) diff --git a/api/tests/unit_tests/tasks/test_workflow_execute_task.py b/api/tests/unit_tests/tasks/test_workflow_execute_task.py index d3cf632b47..72508bef52 100644 --- a/api/tests/unit_tests/tasks/test_workflow_execute_task.py +++ b/api/tests/unit_tests/tasks/test_workflow_execute_task.py @@ -7,11 +7,17 @@ from unittest.mock import MagicMock import pytest -from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom +from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity from models.enums import CreatorUserRole from models.model import App, AppMode, Conversation from models.workflow import Workflow, WorkflowRun -from tasks.app_generate.workflow_execute_task import _publish_streaming_response, _resume_app_execution +from repositories.sqlalchemy_api_workflow_run_repository import _WorkflowRunError +from tasks.app_generate.workflow_execute_task import ( + _publish_streaming_response, + _resume_advanced_chat, + _resume_app_execution, + _resume_workflow, +) class _FakeSessionContext: @@ -38,12 +44,28 @@ def _build_advanced_chat_generate_entity(conversation_id: str | None) -> Advance ) +def _build_workflow_generate_entity(stream: bool) -> WorkflowAppGenerateEntity: + return WorkflowAppGenerateEntity( + task_id="task-id", + inputs={}, + files=[], + user_id="user-id", + stream=stream, + invoke_from=InvokeFrom.WEB_APP, + workflow_execution_id="workflow-run-id", + ) + + +def _single_event_generator(payload): + yield payload + + @pytest.fixture -def mock_topic(mocker) -> MagicMock: +def mock_topic(monkeypatch: pytest.MonkeyPatch) -> MagicMock: topic = MagicMock() - mocker.patch( + monkeypatch.setattr( "tasks.app_generate.workflow_execute_task.MessageBasedAppGenerator.get_response_topic", - return_value=topic, + lambda *_args, **_kwargs: topic, ) return topic @@ -67,31 +89,35 @@ def test_publish_streaming_response_coerces_string_uuid(mock_topic: MagicMock): mock_topic.publish.assert_called_once_with(json.dumps({"event": "bar"}).encode()) -def test_resume_app_execution_queries_message_by_conversation_and_workflow_run(mocker): +def test_resume_app_execution_queries_message_by_conversation_and_workflow_run(monkeypatch: pytest.MonkeyPatch): workflow_run_id = "run-id" conversation_id = "conversation-id" message = MagicMock() - mocker.patch("tasks.app_generate.workflow_execute_task.db", SimpleNamespace(engine=object())) + monkeypatch.setattr("tasks.app_generate.workflow_execute_task.db", SimpleNamespace(engine=object())) pause_entity = MagicMock() pause_entity.get_state.return_value = b"state" workflow_run_repo = MagicMock() workflow_run_repo.get_workflow_pause.return_value = pause_entity - mocker.patch( + monkeypatch.setattr( "tasks.app_generate.workflow_execute_task.DifyAPIRepositoryFactory.create_api_workflow_run_repository", - return_value=workflow_run_repo, + lambda *_args, **_kwargs: workflow_run_repo, ) generate_entity = _build_advanced_chat_generate_entity(conversation_id) resumption_context = MagicMock() resumption_context.serialized_graph_runtime_state = "{}" resumption_context.get_generate_entity.return_value = generate_entity - mocker.patch( - "tasks.app_generate.workflow_execute_task.WorkflowResumptionContext.loads", return_value=resumption_context + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.WorkflowResumptionContext.loads", + lambda *_args, **_kwargs: resumption_context, + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.GraphRuntimeState.from_snapshot", + lambda *_args, **_kwargs: MagicMock(), ) - mocker.patch("tasks.app_generate.workflow_execute_task.GraphRuntimeState.from_snapshot", return_value=MagicMock()) workflow_run = SimpleNamespace( workflow_id="wf-id", @@ -120,10 +146,15 @@ def test_resume_app_execution_queries_message_by_conversation_and_workflow_run(m session.get.side_effect = _session_get session.scalar.return_value = message - mocker.patch("tasks.app_generate.workflow_execute_task.Session", return_value=_FakeSessionContext(session)) - mocker.patch("tasks.app_generate.workflow_execute_task._resolve_user_for_run", return_value=MagicMock()) - resume_advanced_chat = mocker.patch("tasks.app_generate.workflow_execute_task._resume_advanced_chat") - mocker.patch("tasks.app_generate.workflow_execute_task._resume_workflow") + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.Session", lambda *_args, **_kwargs: _FakeSessionContext(session) + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task._resolve_user_for_run", lambda *_args, **_kwargs: MagicMock() + ) + resume_advanced_chat = MagicMock() + monkeypatch.setattr("tasks.app_generate.workflow_execute_task._resume_advanced_chat", resume_advanced_chat) + monkeypatch.setattr("tasks.app_generate.workflow_execute_task._resume_workflow", MagicMock()) _resume_app_execution({"workflow_run_id": workflow_run_id}) @@ -144,29 +175,35 @@ def test_resume_app_execution_queries_message_by_conversation_and_workflow_run(m assert resume_advanced_chat.call_args.kwargs["message"] is message -def test_resume_app_execution_returns_early_when_advanced_chat_missing_conversation_id(mocker): +def test_resume_app_execution_returns_early_when_advanced_chat_missing_conversation_id( + monkeypatch: pytest.MonkeyPatch, +): workflow_run_id = "run-id" - mocker.patch("tasks.app_generate.workflow_execute_task.db", SimpleNamespace(engine=object())) + monkeypatch.setattr("tasks.app_generate.workflow_execute_task.db", SimpleNamespace(engine=object())) pause_entity = MagicMock() pause_entity.get_state.return_value = b"state" workflow_run_repo = MagicMock() workflow_run_repo.get_workflow_pause.return_value = pause_entity - mocker.patch( + monkeypatch.setattr( "tasks.app_generate.workflow_execute_task.DifyAPIRepositoryFactory.create_api_workflow_run_repository", - return_value=workflow_run_repo, + lambda *_args, **_kwargs: workflow_run_repo, ) generate_entity = _build_advanced_chat_generate_entity(conversation_id=None) resumption_context = MagicMock() resumption_context.serialized_graph_runtime_state = "{}" resumption_context.get_generate_entity.return_value = generate_entity - mocker.patch( - "tasks.app_generate.workflow_execute_task.WorkflowResumptionContext.loads", return_value=resumption_context + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.WorkflowResumptionContext.loads", + lambda *_args, **_kwargs: resumption_context, + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.GraphRuntimeState.from_snapshot", + lambda *_args, **_kwargs: MagicMock(), ) - mocker.patch("tasks.app_generate.workflow_execute_task.GraphRuntimeState.from_snapshot", return_value=MagicMock()) workflow_run = SimpleNamespace( workflow_id="wf-id", @@ -191,12 +228,152 @@ def test_resume_app_execution_returns_early_when_advanced_chat_missing_conversat session.get.side_effect = _session_get - mocker.patch("tasks.app_generate.workflow_execute_task.Session", return_value=_FakeSessionContext(session)) - mocker.patch("tasks.app_generate.workflow_execute_task._resolve_user_for_run", return_value=MagicMock()) - resume_advanced_chat = mocker.patch("tasks.app_generate.workflow_execute_task._resume_advanced_chat") + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.Session", lambda *_args, **_kwargs: _FakeSessionContext(session) + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task._resolve_user_for_run", lambda *_args, **_kwargs: MagicMock() + ) + resume_advanced_chat = MagicMock() + monkeypatch.setattr("tasks.app_generate.workflow_execute_task._resume_advanced_chat", resume_advanced_chat) _resume_app_execution({"workflow_run_id": workflow_run_id}) session.scalar.assert_not_called() workflow_run_repo.resume_workflow_pause.assert_not_called() resume_advanced_chat.assert_not_called() + + +def test_resume_advanced_chat_publishes_events_for_originally_blocking_runs(monkeypatch: pytest.MonkeyPatch): + generate_entity = _build_advanced_chat_generate_entity(conversation_id="conversation-id") + generate_entity.stream = False + + generator_instance = MagicMock() + response_stream = _single_event_generator({"event": "message"}) + generator_instance.resume.return_value = response_stream + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.AdvancedChatAppGenerator", + lambda: generator_instance, + ) + + publish_streaming_response = MagicMock() + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task._publish_streaming_response", publish_streaming_response + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.DifyCoreRepositoryFactory.create_workflow_execution_repository", + lambda **kwargs: MagicMock(), + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", + lambda **kwargs: MagicMock(), + ) + + _resume_advanced_chat( + app_model=SimpleNamespace(id="app-id"), + workflow=SimpleNamespace(created_by="workflow-owner"), + user=MagicMock(), + conversation=SimpleNamespace(id="conversation-id"), + message=MagicMock(), + generate_entity=generate_entity, + graph_runtime_state=MagicMock(), + session_factory=MagicMock(), + pause_state_config=MagicMock(), + workflow_run_id="workflow-run-id", + workflow_run=SimpleNamespace(triggered_from="app_run"), + ) + + resumed_entity = generator_instance.resume.call_args.kwargs["application_generate_entity"] + assert resumed_entity.stream is True + publish_streaming_response.assert_called_once_with(response_stream, "workflow-run-id", AppMode.ADVANCED_CHAT) + + +def test_resume_workflow_publishes_events_for_originally_blocking_runs(monkeypatch: pytest.MonkeyPatch): + generate_entity = _build_workflow_generate_entity(stream=False) + + generator_instance = MagicMock() + response_stream = _single_event_generator({"event": "workflow_finished"}) + generator_instance.resume.return_value = response_stream + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.WorkflowAppGenerator", + lambda: generator_instance, + ) + + publish_streaming_response = MagicMock() + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task._publish_streaming_response", publish_streaming_response + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.DifyCoreRepositoryFactory.create_workflow_execution_repository", + lambda **kwargs: MagicMock(), + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", + lambda **kwargs: MagicMock(), + ) + workflow_run_repo = MagicMock() + pause_entity = MagicMock() + + _resume_workflow( + app_model=SimpleNamespace(id="app-id"), + workflow=SimpleNamespace(created_by="workflow-owner"), + user=MagicMock(), + generate_entity=generate_entity, + graph_runtime_state=MagicMock(), + session_factory=MagicMock(), + pause_state_config=MagicMock(), + workflow_run_id="workflow-run-id", + workflow_run=SimpleNamespace(triggered_from="app_run"), + workflow_run_repo=workflow_run_repo, + pause_entity=pause_entity, + ) + + resumed_entity = generator_instance.resume.call_args.kwargs["application_generate_entity"] + assert resumed_entity.stream is True + publish_streaming_response.assert_called_once_with(response_stream, "workflow-run-id", AppMode.WORKFLOW) + workflow_run_repo.delete_workflow_pause.assert_called_once_with(pause_entity) + + +def test_resume_workflow_ignores_missing_old_pause_after_repause(monkeypatch: pytest.MonkeyPatch): + generate_entity = _build_workflow_generate_entity(stream=False) + + generator_instance = MagicMock() + response_stream = _single_event_generator({"event": "workflow_paused"}) + generator_instance.resume.return_value = response_stream + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.WorkflowAppGenerator", + lambda: generator_instance, + ) + + publish_streaming_response = MagicMock() + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task._publish_streaming_response", publish_streaming_response + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.DifyCoreRepositoryFactory.create_workflow_execution_repository", + lambda **kwargs: MagicMock(), + ) + monkeypatch.setattr( + "tasks.app_generate.workflow_execute_task.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", + lambda **kwargs: MagicMock(), + ) + workflow_run_repo = MagicMock() + workflow_run_repo.delete_workflow_pause.side_effect = _WorkflowRunError("WorkflowPause not found: old-pause") + pause_entity = MagicMock() + + _resume_workflow( + app_model=SimpleNamespace(id="app-id"), + workflow=SimpleNamespace(created_by="workflow-owner"), + user=MagicMock(), + generate_entity=generate_entity, + graph_runtime_state=MagicMock(), + session_factory=MagicMock(), + pause_state_config=MagicMock(), + workflow_run_id="workflow-run-id", + workflow_run=SimpleNamespace(triggered_from="app_run"), + workflow_run_repo=workflow_run_repo, + pause_entity=pause_entity, + ) + + publish_streaming_response.assert_called_once_with(response_stream, "workflow-run-id", AppMode.WORKFLOW) + workflow_run_repo.delete_workflow_pause.assert_called_once_with(pause_entity) diff --git a/web/app/components/develop/template/template_advanced_chat.en.mdx b/web/app/components/develop/template/template_advanced_chat.en.mdx index bdfe7a41c1..d9ee9bcc1e 100644 --- a/web/app/components/develop/template/template_advanced_chat.en.mdx +++ b/web/app/components/develop/template/template_advanced_chat.en.mdx @@ -191,6 +191,24 @@ Chat applications support session persistence, allowing previous chat history to - `total_price` (decimal) optional Total cost - `currency` (string) optional e.g. `USD` / `RMB` - `created_at` (timestamp) timestamp of start, e.g., 1705395332 + - `event: human_input_required` Workflow paused and requires Human-in-the-Loop input + - `task_id` (string) Task ID, used for request tracking + - `workflow_run_id` (string) Unique ID of workflow execution + - `event` (string) fixed to `human_input_required` + - `data` (object) detail + - `form_id` (string) Human input form ID + - `node_id` (string) Human input node ID + - `node_title` (string) Human input node title + - `form_content` (string) Rendered form content + - `inputs` (array[object]) Input field definitions + - `actions` (array[object]) User action buttons + - `id` (string) Action ID + - `title` (string) Button text + - `button_style` (string) Button style + - `display_in_ui` (bool) Whether this form should be shown in UI + - `form_token` (string) Token used by `/form/human_input/:form_token` APIs + - `resolved_default_values` (object) Runtime-resolved default values + - `expiration_time` (timestamp) Form expiration time (Unix seconds) - `event: workflow_finished` workflow execution ends, success or failure in different states in the same event - `task_id` (string) Task ID, used for request tracking and the below Stop Generate API - `workflow_run_id` (string) Unique ID of workflow execution @@ -254,6 +272,12 @@ Chat applications support session persistence, allowing previous chat history to }'`} /> ### Blocking Mode + Blocking mode can return a normal chat message or a paused workflow response. + + When advanced chat pauses for Human-in-the-Loop, `event` becomes `workflow_paused`. + The payload still includes `message_id`, `conversation_id`, `answer`, and `workflow_run_id`, and `data` adds `paused_nodes` plus `reasons`. + For `human_input_required`, each reason contains the `form_id` and its `expiration_time`. + ```json {{ title: 'Response' }} { @@ -296,6 +320,83 @@ Chat applications support session persistence, allowing previous chat history to } ``` + + ```json {{ title: 'Paused Response Example' }} + { + "event": "workflow_paused", + "task_id": "8a9cbfcf-e7e0-4b17-aeef-24de57a2659a", + "id": "31714374-88cb-485f-9fa4-e3ab2a9ed95e", + "message_id": "31714374-88cb-485f-9fa4-e3ab2a9ed95e", + "conversation_id": "098e19be-356a-435d-9ec3-a406f4f1a97a", + "mode": "advanced-chat", + "answer": "", + "metadata": { + "annotation_reply": null, + "retriever_resources": [], + "usage": null + }, + "created_at": 1776074715, + "workflow_run_id": "7a4d6509-8a65-4c7d-a4fd-cf081dcf169f", + "data": { + "id": "31714374-88cb-485f-9fa4-e3ab2a9ed95e", + "mode": "advanced-chat", + "conversation_id": "098e19be-356a-435d-9ec3-a406f4f1a97a", + "message_id": "31714374-88cb-485f-9fa4-e3ab2a9ed95e", + "workflow_run_id": "7a4d6509-8a65-4c7d-a4fd-cf081dcf169f", + "answer": "", + "metadata": { + "annotation_reply": null, + "retriever_resources": [], + "usage": null + }, + "created_at": 1776074715, + "paused_nodes": [ + "1775724080699" + ], + "reasons": [ + { + "form_id": "019d864d-6f55-752c-9f4c-feee67508d5b", + "form_content": "this is form 2:\n\n{{#$output.some_field_2#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field_2", + "default": { + "type": "constant", + "selector": [], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "yes", + "button_style": "default" + }, + { + "id": "reject", + "title": "no", + "button_style": "default" + } + ], + "display_in_ui": true, + "node_id": "1775724080699", + "node_title": "Human Input 2", + "resolved_default_values": {}, + "form_token": "0dvwTdpTFXgCZmAo2FoiJ5", + "type": "human_input_required", + "expiration_time": 1776333914 + } + ], + "status": "paused", + "elapsed_time": 0.034081, + "total_tokens": 0, + "total_steps": 2 + } + } + ``` + ### Streaming Mode ```streaming {{ title: 'Response' }} @@ -314,6 +415,220 @@ Chat applications support session persistence, allowing previous chat history to data: {"event": "tts_message_end", "conversation_id": "23dd85f3-1a41-4ea0-b7a9-062734ccfaf9", "message_id": "a8bdc41c-13b2-4c18-bfd9-054b9803038c", "created_at": 1721205487, "task_id": "3bf8a0bb-e73b-4690-9e66-4e429bad8ee7", "audio": ""} ``` + Streaming mode can also pause for Human-in-the-Loop. In that case, the SSE stream emits `human_input_required` first and then `workflow_paused`. + + + ```streaming {{ title: 'Paused Streaming Response Example' }} + event: ping + + data: { + "event": "workflow_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "inputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "created_at": 1776129228, + "reason": "initial" + } + } + + data: { + "event": "node_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "7d9bb041-5ecb-497f-a674-d8706eed0ab1", + "node_id": "1775717266623", + "node_type": "start", + "title": "User Input", + "index": 1, + "predecessor_node_id": null, + "inputs": null, + "inputs_truncated": false, + "created_at": 1776129228, + "extras": {}, + "iteration_id": null, + "loop_id": null, + "agent_strategy": null + } + } + + data: { + "event": "node_finished", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "7d9bb041-5ecb-497f-a674-d8706eed0ab1", + "node_id": "1775717266623", + "node_type": "start", + "title": "User Input", + "index": 1, + "predecessor_node_id": null, + "inputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "sys.timestamp": 1776129228 + }, + "inputs_truncated": false, + "process_data": {}, + "process_data_truncated": false, + "outputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "sys.timestamp": 1776129228 + }, + "outputs_truncated": false, + "status": "succeeded", + "error": null, + "elapsed_time": 0.000097, + "execution_metadata": null, + "created_at": 1776129228, + "finished_at": 1776129228, + "files": [], + "iteration_id": null, + "loop_id": null + } + } + + data: { + "event": "node_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "c09ff568-1d55-4f0d-9a07-512bcbfeb289", + "node_id": "1775717346519", + "node_type": "human-input", + "title": "Human Input", + "index": 1, + "predecessor_node_id": null, + "inputs": null, + "inputs_truncated": false, + "created_at": 1776129228, + "extras": {}, + "iteration_id": null, + "loop_id": null, + "agent_strategy": null + } + } + + data: { + "event": "human_input_required", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "form_id": "019d898d-3d80-7105-b920-9899ead4ff3e", + "node_id": "1775717346519", + "node_title": "Human Input", + "form_content": "this is form 1:\n{{#$output.some_field#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field", + "default": { + "type": "variable", + "selector": [ + "sys", + "workflow_run_id" + ], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "YES", + "button_style": "default" + }, + { + "id": "reject", + "title": "NO", + "button_style": "default" + } + ], + "display_in_ui": true, + "form_token": "0Tb1nXYe4hzQUD706nHB4y", + "resolved_default_values": { + "some_field": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "expiration_time": 1776388428 + } + } + + data: { + "event": "workflow_paused", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "paused_nodes": [ + "1775717346519" + ], + "outputs": {}, + "reasons": [ + { + "form_id": "019d898d-3d80-7105-b920-9899ead4ff3e", + "form_content": "this is form 1:\n{{#$output.some_field#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field", + "default": { + "type": "variable", + "selector": [ + "sys", + "workflow_run_id" + ], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "YES", + "button_style": "default" + }, + { + "id": "reject", + "title": "NO", + "button_style": "default" + } + ], + "display_in_ui": true, + "node_id": "1775717346519", + "node_title": "Human Input", + "resolved_default_values": { + "some_field": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "form_token": "0Tb1nXYe4hzQUD706nHB4y", + "type": "human_input_required", + "expiration_time": 1776388428 + } + ], + "status": "paused", + "created_at": 1776129228, + "elapsed_time": 0.070478, + "total_tokens": 0, + "total_steps": 2 + } + } + ``` + @@ -578,6 +893,198 @@ Chat applications support session persistence, allowing previous chat history to --- + + + + Retrieve a pending Human-in-the-Loop form by `form_token`. + + Use this endpoint when streaming returns `human_input_required` with a `form_token`. + + ### Path + - `form_token` (string) Required, token returned by the pause event. + + ### Response + - `form_content` (string) Rendered form content (markdown/plain text) + - `inputs` (array[object]) Form input definitions + - `resolved_default_values` (object) Default values resolved to strings + - `user_actions` (array[object]) Action buttons + - `expiration_time` (timestamp) Form expiration time (Unix seconds) + + ### Errors + - 404, form not found or does not belong to current app + - 412, `human_input_form_submitted`, form already submitted + - 412, `human_input_form_expired`, form expired + + + + + + ```json {{ title: 'Response' }} + { + "form_content": "Please confirm the final answer: {{#$output.answer#}}", + "inputs": [ + { + "label": "Answer", + "type": "text-input", + "required": true, + "output_variable_name": "answer" + } + ], + "resolved_default_values": { + "answer": "Initial value" + }, + "user_actions": [ + { "id": "approve", "title": "Approve", "button_style": "primary" }, + { "id": "reject", "title": "Reject", "button_style": "warning" } + ], + "expiration_time": 1735689600 + } + ``` + + + + +--- + + + + + Submit a pending Human-in-the-Loop form. + + ### Path + - `form_token` (string) Required, token returned by the pause event. + + ### Request Body + - `inputs` (object) Required, key/value pairs for form fields. + - `action` (string) Required, selected action ID from `user_actions`. + - `user` (string) Required, end-user identifier. + + ### Response + Returns an empty object on success. + + ### Errors + - 400, `invalid_form_data`, submitted data does not match the form schema + - 404, form not found or does not belong to current app + - 412, `human_input_form_submitted`, form already submitted + - 412, `human_input_form_expired`, form expired + + + + + + ```json {{ title: 'Response' }} + {} + ``` + + + + +--- + + + + + Continue receiving workflow events after submitting a human input form. + + This endpoint returns `text/event-stream` and can be used to observe resumed execution until completion. + + ### Path + - `task_id` (string) Required, workflow run ID (`workflow_run_id`). + + ### Query + - `user` (string) Required, end-user identifier. + - `include_state_snapshot` (bool) Optional, set to `true` to replay from persisted state snapshot before continuing with live events. + - `continue_on_pause` (bool) Optional, set to `true` to keep the stream open across `workflow_paused` events until `workflow_finished`. + + ### Response + Server-Sent Events stream (`text/event-stream`). + Typical events include `workflow_paused`, `node_started`, `node_finished`, `human_input_form_filled`, `human_input_form_timeout`, and `workflow_finished`. + If the workflow has already finished when you call this endpoint, the server returns a single finished event immediately. + + + + + + ```streaming {{ title: 'Response' }} + event: ping + + data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}} + + data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}} + + data: {"event":"node_finished","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"process_data":null,"outputs":null,"status":"succeeded","error":null,"elapsed_time":0.00032,"execution_metadata":null,"created_at":1776087863,"finished_at":1776087863,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":2,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}} + + data: {"event":"node_finished","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":2,"predecessor_node_id":null,"inputs":null,"process_data":null,"outputs":null,"status":"paused","error":null,"elapsed_time":0.007381,"execution_metadata":null,"created_at":1776087863,"finished_at":1776087863,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"workflow_paused","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","paused_nodes":["1775717346519"],"outputs":{},"reasons":[{"form_id":"019d8716-0fde-75da-8207-1458ccde76e5","form_content":"this is form 1:\n{{#$output.some_field#}}\n","inputs":[{"type":"paragraph","output_variable_name":"some_field","default":{"type":"variable","selector":["sys","workflow_run_id"],"value":""}}],"actions":[{"id":"approve","title":"YES","button_style":"default"},{"id":"reject","title":"NO","button_style":"default"}],"display_in_ui":true,"node_id":"1775717346519","node_title":"Human Input","resolved_default_values":{"some_field":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"form_token":"n7hFG4ZDYdGcgZ5VDc7EGM","type":"human_input_required"}],"status":"paused","created_at":1776087863,"elapsed_time":0.0,"total_tokens":0,"total_steps":2}} + + data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"human_input_form_filled","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"node_id":"1775717346519","node_title":"Human Input","rendered_content":"this is form 1:\nfield 1 filled!\n","action_id":"approve","action_text":"YES"}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":{},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"some_field":"field 1 filled!","some_field_2":"from bruno with love","__action_id":"approve","__rendered_content":"this is form 1:\nfield 1 filled!\n"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.004431,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"6d8fc3cb-19f7-440b-b83e-eed4e847a332","node_id":"1775717350710","node_type":"template-transform","title":"Template","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"text_chunk","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"text":"field 1 filled!","from_variable_selector":["1775717350710","output"]}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"6d8fc3cb-19f7-440b-b83e-eed4e847a332","node_id":"1775717350710","node_type":"template-transform","title":"Template","index":1,"predecessor_node_id":null,"inputs":{"some_field":"field 1 filled!"},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"output":"field 1 filled!"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.264614,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"e88dec7e-aa2c-41f7-8d73-032b749e23f5","node_id":"1775717354177","node_type":"end","title":"Output","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"e88dec7e-aa2c-41f7-8d73-032b749e23f5","node_id":"1775717354177","node_type":"end","title":"Output","index":1,"predecessor_node_id":null,"inputs":{"output":"field 1 filled!"},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"output":"field 1 filled!"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.00003,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"workflow_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","status":"succeeded","outputs":{"output":"field 1 filled!"},"error":null,"elapsed_time":0.364935,"total_tokens":0,"total_steps":5,"created_by":{"id":"7932d34c-dcf4-4fba-b770-f2a9de88c0a0","user":"abc-123"},"created_at":1776087877,"finished_at":1776087877,"exceptions_count":0,"files":[]}} + ``` + + + + +--- + ### ブロッキングモード + ブロッキングモードでは、通常のチャット応答、または一時停止したワークフロー応答のいずれかが返されます。 + + Advanced Chat が Human-in-the-Loop で一時停止すると、`event` は `workflow_paused` になります。 + それでもペイロードには `message_id`、`conversation_id`、`answer`、`workflow_run_id` が含まれ、`data` には `paused_nodes` と `reasons` が追加されます。 + `human_input_required` の各 reason には `form_id` と `expiration_time` が含まれます。 + ```json {{ title: '応答' }} { @@ -296,6 +320,83 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' } ``` + + ```json {{ title: '一時停止レスポンス例' }} + { + "event": "workflow_paused", + "task_id": "8a9cbfcf-e7e0-4b17-aeef-24de57a2659a", + "id": "31714374-88cb-485f-9fa4-e3ab2a9ed95e", + "message_id": "31714374-88cb-485f-9fa4-e3ab2a9ed95e", + "conversation_id": "098e19be-356a-435d-9ec3-a406f4f1a97a", + "mode": "advanced-chat", + "answer": "", + "metadata": { + "annotation_reply": null, + "retriever_resources": [], + "usage": null + }, + "created_at": 1776074715, + "workflow_run_id": "7a4d6509-8a65-4c7d-a4fd-cf081dcf169f", + "data": { + "id": "31714374-88cb-485f-9fa4-e3ab2a9ed95e", + "mode": "advanced-chat", + "conversation_id": "098e19be-356a-435d-9ec3-a406f4f1a97a", + "message_id": "31714374-88cb-485f-9fa4-e3ab2a9ed95e", + "workflow_run_id": "7a4d6509-8a65-4c7d-a4fd-cf081dcf169f", + "answer": "", + "metadata": { + "annotation_reply": null, + "retriever_resources": [], + "usage": null + }, + "created_at": 1776074715, + "paused_nodes": [ + "1775724080699" + ], + "reasons": [ + { + "form_id": "019d864d-6f55-752c-9f4c-feee67508d5b", + "form_content": "this is form 2:\n\n{{#$output.some_field_2#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field_2", + "default": { + "type": "constant", + "selector": [], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "yes", + "button_style": "default" + }, + { + "id": "reject", + "title": "no", + "button_style": "default" + } + ], + "display_in_ui": true, + "node_id": "1775724080699", + "node_title": "Human Input 2", + "resolved_default_values": {}, + "form_token": "0dvwTdpTFXgCZmAo2FoiJ5", + "type": "human_input_required", + "expiration_time": 1776333914 + } + ], + "status": "paused", + "elapsed_time": 0.034081, + "total_tokens": 0, + "total_steps": 2 + } + } + ``` + ### ストリーミングモード ```streaming {{ title: '応答' }} @@ -314,6 +415,220 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' data: {"event": "tts_message_end", "conversation_id": "23dd85f3-1a41-4ea0-b7a9-062734ccfaf9", "message_id": "a8bdc41c-13b2-4c18-bfd9-054b9803038c", "created_at": 1721205487, "task_id": "3bf8a0bb-e73b-4690-9e66-4e429bad8ee7", "audio": ""} ``` + ストリーミングモードでも Human-in-the-Loop により一時停止する場合があります。その場合、SSE ストリームではまず `human_input_required` が送られ、その後に `workflow_paused` が送られます。 + + + ```streaming {{ title: '一時停止ストリーミングレスポンス例' }} + event: ping + + data: { + "event": "workflow_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "inputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "created_at": 1776129228, + "reason": "initial" + } + } + + data: { + "event": "node_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "7d9bb041-5ecb-497f-a674-d8706eed0ab1", + "node_id": "1775717266623", + "node_type": "start", + "title": "User Input", + "index": 1, + "predecessor_node_id": null, + "inputs": null, + "inputs_truncated": false, + "created_at": 1776129228, + "extras": {}, + "iteration_id": null, + "loop_id": null, + "agent_strategy": null + } + } + + data: { + "event": "node_finished", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "7d9bb041-5ecb-497f-a674-d8706eed0ab1", + "node_id": "1775717266623", + "node_type": "start", + "title": "User Input", + "index": 1, + "predecessor_node_id": null, + "inputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "sys.timestamp": 1776129228 + }, + "inputs_truncated": false, + "process_data": {}, + "process_data_truncated": false, + "outputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "sys.timestamp": 1776129228 + }, + "outputs_truncated": false, + "status": "succeeded", + "error": null, + "elapsed_time": 0.000097, + "execution_metadata": null, + "created_at": 1776129228, + "finished_at": 1776129228, + "files": [], + "iteration_id": null, + "loop_id": null + } + } + + data: { + "event": "node_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "c09ff568-1d55-4f0d-9a07-512bcbfeb289", + "node_id": "1775717346519", + "node_type": "human-input", + "title": "Human Input", + "index": 1, + "predecessor_node_id": null, + "inputs": null, + "inputs_truncated": false, + "created_at": 1776129228, + "extras": {}, + "iteration_id": null, + "loop_id": null, + "agent_strategy": null + } + } + + data: { + "event": "human_input_required", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "form_id": "019d898d-3d80-7105-b920-9899ead4ff3e", + "node_id": "1775717346519", + "node_title": "Human Input", + "form_content": "this is form 1:\n{{#$output.some_field#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field", + "default": { + "type": "variable", + "selector": [ + "sys", + "workflow_run_id" + ], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "YES", + "button_style": "default" + }, + { + "id": "reject", + "title": "NO", + "button_style": "default" + } + ], + "display_in_ui": true, + "form_token": "0Tb1nXYe4hzQUD706nHB4y", + "resolved_default_values": { + "some_field": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "expiration_time": 1776388428 + } + } + + data: { + "event": "workflow_paused", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "paused_nodes": [ + "1775717346519" + ], + "outputs": {}, + "reasons": [ + { + "form_id": "019d898d-3d80-7105-b920-9899ead4ff3e", + "form_content": "this is form 1:\n{{#$output.some_field#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field", + "default": { + "type": "variable", + "selector": [ + "sys", + "workflow_run_id" + ], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "YES", + "button_style": "default" + }, + { + "id": "reject", + "title": "NO", + "button_style": "default" + } + ], + "display_in_ui": true, + "node_id": "1775717346519", + "node_title": "Human Input", + "resolved_default_values": { + "some_field": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "form_token": "0Tb1nXYe4hzQUD706nHB4y", + "type": "human_input_required", + "expiration_time": 1776388428 + } + ], + "status": "paused", + "created_at": 1776129228, + "elapsed_time": 0.070478, + "total_tokens": 0, + "total_steps": 2 + } + } + ``` + @@ -579,6 +894,198 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' --- + + + + `form_token` から保留中の Human-in-the-Loop フォームを取得します。 + + ストリーミングイベントで `human_input_required`(`form_token` を含む)が返された際に使用します。 + + ### パス + - `form_token` (string) 必須、一時停止イベントで返されたフォームトークン + + ### 応答 + - `form_content` (string) レンダリング済みフォーム内容(markdown/plain text) + - `inputs` (array[object]) 入力項目定義 + - `resolved_default_values` (object) 解決済みデフォルト値(文字列) + - `user_actions` (array[object]) アクションボタン一覧 + - `expiration_time` (timestamp) フォーム有効期限(Unix 秒) + + ### エラー + - 404, フォームが存在しない、または現在のアプリに属していない + - 412, `human_input_form_submitted`, 既に送信済み + - 412, `human_input_form_expired`, 期限切れ + + + + + + ```json {{ title: '応答' }} + { + "form_content": "最終回答を確認してください: {{#$output.answer#}}", + "inputs": [ + { + "label": "回答", + "type": "text-input", + "required": true, + "output_variable_name": "answer" + } + ], + "resolved_default_values": { + "answer": "初期値" + }, + "user_actions": [ + { "id": "approve", "title": "承認", "button_style": "primary" }, + { "id": "reject", "title": "却下", "button_style": "warning" } + ], + "expiration_time": 1735689600 + } + ``` + + + + +--- + + + + + 保留中の Human-in-the-Loop フォームを送信します。 + + ### パス + - `form_token` (string) 必須、一時停止イベントで返されたフォームトークン + + ### リクエストボディ + - `inputs` (object) 必須、フォーム項目の key/value + - `action` (string) 必須、`user_actions` から選択したアクション ID + - `user` (string) 必須、エンドユーザー識別子 + + ### 応答 + 成功時は空オブジェクトを返します。 + + ### エラー + - 400, `invalid_form_data`, 送信データがフォームスキーマに一致しない + - 404, フォームが存在しない、または現在のアプリに属していない + - 412, `human_input_form_submitted`, 既に送信済み + - 412, `human_input_form_expired`, 期限切れ + + + + + + ```json {{ title: '応答' }} + {} + ``` + + + + +--- + + + + + Human Input フォーム送信後に、ワークフロー再開後のイベントを継続受信します。 + + このエンドポイントは `text/event-stream` を返し、完了までイベントを購読できます。 + + ### パス + - `task_id` (string) 必須、workflow 実行 ID(`workflow_run_id`) + + ### クエリ + - `user` (string) 必須、エンドユーザー識別子 + - `include_state_snapshot` (bool) 任意、`true` の場合は永続化済み状態スナップショットを先に再生してからリアルタイムイベントへ移行 + - `continue_on_pause` (bool) 任意、`true` にすると `workflow_paused` イベントをまたいでもストリームを維持し、`workflow_finished` で終了します + + ### 応答 + Server-Sent Events ストリーム(`text/event-stream`)。 + 主なイベントは `workflow_paused`、`node_started`、`node_finished`、`human_input_form_filled`、`human_input_form_timeout`、`workflow_finished` です。 + 呼び出し時点でワークフローがすでに完了している場合、このエンドポイントは完了イベントを 1 件だけ即座に返します。 + + + + + + ```streaming {{ title: '応答' }} + event: ping + + data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}} + + data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}} + + data: {"event":"node_finished","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"process_data":null,"outputs":null,"status":"succeeded","error":null,"elapsed_time":0.00032,"execution_metadata":null,"created_at":1776087863,"finished_at":1776087863,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":2,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}} + + data: {"event":"node_finished","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":2,"predecessor_node_id":null,"inputs":null,"process_data":null,"outputs":null,"status":"paused","error":null,"elapsed_time":0.007381,"execution_metadata":null,"created_at":1776087863,"finished_at":1776087863,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"workflow_paused","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","paused_nodes":["1775717346519"],"outputs":{},"reasons":[{"form_id":"019d8716-0fde-75da-8207-1458ccde76e5","form_content":"this is form 1:\n{{#$output.some_field#}}\n","inputs":[{"type":"paragraph","output_variable_name":"some_field","default":{"type":"variable","selector":["sys","workflow_run_id"],"value":""}}],"actions":[{"id":"approve","title":"YES","button_style":"default"},{"id":"reject","title":"NO","button_style":"default"}],"display_in_ui":true,"node_id":"1775717346519","node_title":"Human Input","resolved_default_values":{"some_field":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"form_token":"n7hFG4ZDYdGcgZ5VDc7EGM","type":"human_input_required"}],"status":"paused","created_at":1776087863,"elapsed_time":0.0,"total_tokens":0,"total_steps":2}} + + data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"human_input_form_filled","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"node_id":"1775717346519","node_title":"Human Input","rendered_content":"this is form 1:\nfield 1 filled!\n","action_id":"approve","action_text":"YES"}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":{},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"some_field":"field 1 filled!","some_field_2":"from bruno with love","__action_id":"approve","__rendered_content":"this is form 1:\nfield 1 filled!\n"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.004431,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"6d8fc3cb-19f7-440b-b83e-eed4e847a332","node_id":"1775717350710","node_type":"template-transform","title":"Template","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"text_chunk","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"text":"field 1 filled!","from_variable_selector":["1775717350710","output"]}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"6d8fc3cb-19f7-440b-b83e-eed4e847a332","node_id":"1775717350710","node_type":"template-transform","title":"Template","index":1,"predecessor_node_id":null,"inputs":{"some_field":"field 1 filled!"},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"output":"field 1 filled!"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.264614,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"e88dec7e-aa2c-41f7-8d73-032b749e23f5","node_id":"1775717354177","node_type":"end","title":"Output","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"e88dec7e-aa2c-41f7-8d73-032b749e23f5","node_id":"1775717354177","node_type":"end","title":"Output","index":1,"predecessor_node_id":null,"inputs":{"output":"field 1 filled!"},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"output":"field 1 filled!"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.00003,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"workflow_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","status":"succeeded","outputs":{"output":"field 1 filled!"},"error":null,"elapsed_time":0.364935,"total_tokens":0,"total_steps":5,"created_by":{"id":"7932d34c-dcf4-4fba-b770-f2a9de88c0a0","user":"abc-123"},"created_at":1776087877,"finished_at":1776087877,"exceptions_count":0,"files":[]}} + ``` + + + + +--- + ### 阻塞模式 + 阻塞模式可能返回普通聊天响应,也可能返回暂停中的工作流响应。 + + 当 Advanced Chat 因 Human-in-the-Loop 暂停时,`event` 会变为 `workflow_paused`。 + 响应仍然包含 `message_id`、`conversation_id`、`answer` 和 `workflow_run_id`,并且 `data` 中会新增 `paused_nodes` 和 `reasons`。 + 对于 `human_input_required`,每个 reason 都会包含 `form_id` 和 `expiration_time`。 + ```json {{ title: 'Response' }} { @@ -295,6 +319,83 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' } ``` + + ```json {{ title: 'Paused Response Example' }} + { + "event": "workflow_paused", + "task_id": "8a9cbfcf-e7e0-4b17-aeef-24de57a2659a", + "id": "31714374-88cb-485f-9fa4-e3ab2a9ed95e", + "message_id": "31714374-88cb-485f-9fa4-e3ab2a9ed95e", + "conversation_id": "098e19be-356a-435d-9ec3-a406f4f1a97a", + "mode": "advanced-chat", + "answer": "", + "metadata": { + "annotation_reply": null, + "retriever_resources": [], + "usage": null + }, + "created_at": 1776074715, + "workflow_run_id": "7a4d6509-8a65-4c7d-a4fd-cf081dcf169f", + "data": { + "id": "31714374-88cb-485f-9fa4-e3ab2a9ed95e", + "mode": "advanced-chat", + "conversation_id": "098e19be-356a-435d-9ec3-a406f4f1a97a", + "message_id": "31714374-88cb-485f-9fa4-e3ab2a9ed95e", + "workflow_run_id": "7a4d6509-8a65-4c7d-a4fd-cf081dcf169f", + "answer": "", + "metadata": { + "annotation_reply": null, + "retriever_resources": [], + "usage": null + }, + "created_at": 1776074715, + "paused_nodes": [ + "1775724080699" + ], + "reasons": [ + { + "form_id": "019d864d-6f55-752c-9f4c-feee67508d5b", + "form_content": "this is form 2:\n\n{{#$output.some_field_2#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field_2", + "default": { + "type": "constant", + "selector": [], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "yes", + "button_style": "default" + }, + { + "id": "reject", + "title": "no", + "button_style": "default" + } + ], + "display_in_ui": true, + "node_id": "1775724080699", + "node_title": "Human Input 2", + "resolved_default_values": {}, + "form_token": "0dvwTdpTFXgCZmAo2FoiJ5", + "type": "human_input_required", + "expiration_time": 1776333914 + } + ], + "status": "paused", + "elapsed_time": 0.034081, + "total_tokens": 0, + "total_steps": 2 + } + } + ``` + ### 流式模式 ```streaming {{ title: 'Response' }} @@ -313,6 +414,220 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' data: {"event": "tts_message_end", "conversation_id": "23dd85f3-1a41-4ea0-b7a9-062734ccfaf9", "message_id": "a8bdc41c-13b2-4c18-bfd9-054b9803038c", "created_at": 1721205487, "task_id": "3bf8a0bb-e73b-4690-9e66-4e429bad8ee7", "audio": ""} ``` + 流式模式同样可能因为 Human-in-the-Loop 而暂停。此时 SSE 流会先返回 `human_input_required`,随后返回 `workflow_paused`。 + + + ```streaming {{ title: 'Paused Streaming Response Example' }} + event: ping + + data: { + "event": "workflow_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "inputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "created_at": 1776129228, + "reason": "initial" + } + } + + data: { + "event": "node_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "7d9bb041-5ecb-497f-a674-d8706eed0ab1", + "node_id": "1775717266623", + "node_type": "start", + "title": "User Input", + "index": 1, + "predecessor_node_id": null, + "inputs": null, + "inputs_truncated": false, + "created_at": 1776129228, + "extras": {}, + "iteration_id": null, + "loop_id": null, + "agent_strategy": null + } + } + + data: { + "event": "node_finished", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "7d9bb041-5ecb-497f-a674-d8706eed0ab1", + "node_id": "1775717266623", + "node_type": "start", + "title": "User Input", + "index": 1, + "predecessor_node_id": null, + "inputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "sys.timestamp": 1776129228 + }, + "inputs_truncated": false, + "process_data": {}, + "process_data_truncated": false, + "outputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "sys.timestamp": 1776129228 + }, + "outputs_truncated": false, + "status": "succeeded", + "error": null, + "elapsed_time": 0.000097, + "execution_metadata": null, + "created_at": 1776129228, + "finished_at": 1776129228, + "files": [], + "iteration_id": null, + "loop_id": null + } + } + + data: { + "event": "node_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "c09ff568-1d55-4f0d-9a07-512bcbfeb289", + "node_id": "1775717346519", + "node_type": "human-input", + "title": "Human Input", + "index": 1, + "predecessor_node_id": null, + "inputs": null, + "inputs_truncated": false, + "created_at": 1776129228, + "extras": {}, + "iteration_id": null, + "loop_id": null, + "agent_strategy": null + } + } + + data: { + "event": "human_input_required", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "form_id": "019d898d-3d80-7105-b920-9899ead4ff3e", + "node_id": "1775717346519", + "node_title": "Human Input", + "form_content": "this is form 1:\n{{#$output.some_field#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field", + "default": { + "type": "variable", + "selector": [ + "sys", + "workflow_run_id" + ], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "YES", + "button_style": "default" + }, + { + "id": "reject", + "title": "NO", + "button_style": "default" + } + ], + "display_in_ui": true, + "form_token": "0Tb1nXYe4hzQUD706nHB4y", + "resolved_default_values": { + "some_field": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "expiration_time": 1776388428 + } + } + + data: { + "event": "workflow_paused", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "paused_nodes": [ + "1775717346519" + ], + "outputs": {}, + "reasons": [ + { + "form_id": "019d898d-3d80-7105-b920-9899ead4ff3e", + "form_content": "this is form 1:\n{{#$output.some_field#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field", + "default": { + "type": "variable", + "selector": [ + "sys", + "workflow_run_id" + ], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "YES", + "button_style": "default" + }, + { + "id": "reject", + "title": "NO", + "button_style": "default" + } + ], + "display_in_ui": true, + "node_id": "1775717346519", + "node_title": "Human Input", + "resolved_default_values": { + "some_field": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "form_token": "0Tb1nXYe4hzQUD706nHB4y", + "type": "human_input_required", + "expiration_time": 1776388428 + } + ], + "status": "paused", + "created_at": 1776129228, + "elapsed_time": 0.070478, + "total_tokens": 0, + "total_steps": 2 + } + } + ``` + @@ -572,6 +887,198 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' --- + + + + 通过 `form_token` 获取待处理的 Human-in-the-Loop 表单。 + + 当流式事件返回 `human_input_required`(包含 `form_token`)时,可调用此接口拉取表单详情。 + + ### Path + - `form_token` (string) 必填,暂停事件返回的表单 token + + ### Response + - `form_content` (string) 已渲染的表单内容(markdown/plain text) + - `inputs` (array[object]) 表单输入项定义 + - `resolved_default_values` (object) 已解析的默认值(字符串) + - `user_actions` (array[object]) 操作按钮列表 + - `expiration_time` (timestamp) 表单过期时间(Unix 秒) + + ### Errors + - 404,表单不存在或不属于当前应用 + - 412,`human_input_form_submitted`,表单已被提交 + - 412,`human_input_form_expired`,表单已过期 + + + + + + ```json {{ title: 'Response' }} + { + "form_content": "请确认最终结果:{{#$output.answer#}}", + "inputs": [ + { + "label": "答案", + "type": "text-input", + "required": true, + "output_variable_name": "answer" + } + ], + "resolved_default_values": { + "answer": "初始值" + }, + "user_actions": [ + { "id": "approve", "title": "通过", "button_style": "primary" }, + { "id": "reject", "title": "拒绝", "button_style": "warning" } + ], + "expiration_time": 1735689600 + } + ``` + + + + +--- + + + + + 提交待处理的 Human-in-the-Loop 表单。 + + ### Path + - `form_token` (string) 必填,暂停事件返回的表单 token + + ### Request Body + - `inputs` (object) 必填,表单字段的 key/value + - `action` (string) 必填,从 `user_actions` 中选择的动作 ID + - `user` (string) 必填,终端用户标识 + + ### Response + 成功时返回空对象。 + + ### Errors + - 400,`invalid_form_data`,提交数据与表单 schema 不匹配 + - 404,表单不存在或不属于当前应用 + - 412,`human_input_form_submitted`,表单已被提交 + - 412,`human_input_form_expired`,表单已过期 + + + + + + ```json {{ title: 'Response' }} + {} + ``` + + + + +--- + + + + + 在提交人工输入表单后,继续订阅工作流后续执行事件。 + + 返回 `text/event-stream`,可持续接收直到工作流结束。 + + ### Path + - `task_id` (string) 必填,workflow 运行 ID(`workflow_run_id`) + + ### Query + - `user` (string) 必填,终端用户标识 + - `include_state_snapshot` (bool) 可选,设为 `true` 时会先回放持久化状态快照,再继续实时事件 + - `continue_on_pause` (bool) 可选,设为 `true` 时,流会在 `workflow_paused` 事件之间保持连接,直到 `workflow_finished` 才结束 + + ### Response + Server-Sent Events 流(`text/event-stream`)。 + 常见事件包括 `workflow_paused`、`node_started`、`node_finished`、`human_input_form_filled`、`human_input_form_timeout`、`workflow_finished`。 + 如果调用该接口时工作流已经结束,服务端会立即返回单个完成事件。 + + + + + + ```streaming {{ title: 'Response' }} + event: ping + + data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}} + + data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}} + + data: {"event":"node_finished","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"process_data":null,"outputs":null,"status":"succeeded","error":null,"elapsed_time":0.00032,"execution_metadata":null,"created_at":1776087863,"finished_at":1776087863,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":2,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}} + + data: {"event":"node_finished","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":2,"predecessor_node_id":null,"inputs":null,"process_data":null,"outputs":null,"status":"paused","error":null,"elapsed_time":0.007381,"execution_metadata":null,"created_at":1776087863,"finished_at":1776087863,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"workflow_paused","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","paused_nodes":["1775717346519"],"outputs":{},"reasons":[{"form_id":"019d8716-0fde-75da-8207-1458ccde76e5","form_content":"this is form 1:\n{{#$output.some_field#}}\n","inputs":[{"type":"paragraph","output_variable_name":"some_field","default":{"type":"variable","selector":["sys","workflow_run_id"],"value":""}}],"actions":[{"id":"approve","title":"YES","button_style":"default"},{"id":"reject","title":"NO","button_style":"default"}],"display_in_ui":true,"node_id":"1775717346519","node_title":"Human Input","resolved_default_values":{"some_field":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"form_token":"n7hFG4ZDYdGcgZ5VDc7EGM","type":"human_input_required"}],"status":"paused","created_at":1776087863,"elapsed_time":0.0,"total_tokens":0,"total_steps":2}} + + data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"human_input_form_filled","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"node_id":"1775717346519","node_title":"Human Input","rendered_content":"this is form 1:\nfield 1 filled!\n","action_id":"approve","action_text":"YES"}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":{},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"some_field":"field 1 filled!","some_field_2":"from bruno with love","__action_id":"approve","__rendered_content":"this is form 1:\nfield 1 filled!\n"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.004431,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"6d8fc3cb-19f7-440b-b83e-eed4e847a332","node_id":"1775717350710","node_type":"template-transform","title":"Template","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"text_chunk","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"text":"field 1 filled!","from_variable_selector":["1775717350710","output"]}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"6d8fc3cb-19f7-440b-b83e-eed4e847a332","node_id":"1775717350710","node_type":"template-transform","title":"Template","index":1,"predecessor_node_id":null,"inputs":{"some_field":"field 1 filled!"},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"output":"field 1 filled!"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.264614,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"e88dec7e-aa2c-41f7-8d73-032b749e23f5","node_id":"1775717354177","node_type":"end","title":"Output","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"e88dec7e-aa2c-41f7-8d73-032b749e23f5","node_id":"1775717354177","node_type":"end","title":"Output","index":1,"predecessor_node_id":null,"inputs":{"output":"field 1 filled!"},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"output":"field 1 filled!"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.00003,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"workflow_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","status":"succeeded","outputs":{"output":"field 1 filled!"},"error":null,"elapsed_time":0.364935,"total_tokens":0,"total_steps":5,"created_by":{"id":"7932d34c-dcf4-4fba-b770-f2a9de88c0a0","user":"abc-123"},"created_at":1776087877,"finished_at":1776087877,"exceptions_count":0,"files":[]}} + ``` + + + + +--- + ### Blocking Mode + Blocking mode can return either a completed workflow result or a paused workflow result. + + When execution pauses for Human-in-the-Loop, the response still includes `workflow_run_id` and `task_id`, but `data.status` becomes `paused`. + The paused payload also includes `paused_nodes` and `reasons`. For `human_input_required`, each reason contains the `form_id` and its `expiration_time`. + ```json {{ title: 'Response' }} { @@ -236,6 +259,70 @@ Workflow applications offers non-session support and is ideal for translation, a } ``` + + ```json {{ title: 'Paused Response Example' }} + { + "task_id": "3938b985-f4c6-4806-87b6-215e0aca9d81", + "workflow_run_id": "4a80f375-682b-49c5-b199-e950aac4968f", + "data": { + "id": "4a80f375-682b-49c5-b199-e950aac4968f", + "workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "status": "paused", + "outputs": {}, + "error": null, + "elapsed_time": 0.035667, + "total_tokens": 0, + "total_steps": 2, + "created_at": 1776074783, + "finished_at": null, + "paused_nodes": [ + "1775717346519" + ], + "reasons": [ + { + "form_id": "019d864e-7a36-74a2-b94e-e5660c47f5a7", + "form_content": "this is form 1:\n{{#$output.some_field#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field", + "default": { + "type": "variable", + "selector": [ + "sys", + "workflow_run_id" + ], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "YES", + "button_style": "default" + }, + { + "id": "reject", + "title": "NO", + "button_style": "default" + } + ], + "display_in_ui": true, + "node_id": "1775717346519", + "node_title": "Human Input", + "resolved_default_values": { + "some_field": "4a80f375-682b-49c5-b199-e950aac4968f" + }, + "form_token": "SZwvfmL47fTIsZynP2Jr9i", + "type": "human_input_required", + "expiration_time": 1776333983 + } + ] + } + } + ``` + ### Streaming Mode ```streaming {{ title: 'Response' }} @@ -247,6 +334,220 @@ Workflow applications offers non-session support and is ideal for translation, a data: {"event": "tts_message_end", "conversation_id": "23dd85f3-1a41-4ea0-b7a9-062734ccfaf9", "message_id": "a8bdc41c-13b2-4c18-bfd9-054b9803038c", "created_at": 1721205487, "task_id": "3bf8a0bb-e73b-4690-9e66-4e429bad8ee7", "audio": ""} ``` + Streaming mode can also pause for Human-in-the-Loop. In that case, the SSE stream emits `human_input_required` first and then `workflow_paused`. + + + ```streaming {{ title: 'Paused Streaming Response Example' }} + event: ping + + data: { + "event": "workflow_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "inputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "created_at": 1776129228, + "reason": "initial" + } + } + + data: { + "event": "node_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "7d9bb041-5ecb-497f-a674-d8706eed0ab1", + "node_id": "1775717266623", + "node_type": "start", + "title": "User Input", + "index": 1, + "predecessor_node_id": null, + "inputs": null, + "inputs_truncated": false, + "created_at": 1776129228, + "extras": {}, + "iteration_id": null, + "loop_id": null, + "agent_strategy": null + } + } + + data: { + "event": "node_finished", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "7d9bb041-5ecb-497f-a674-d8706eed0ab1", + "node_id": "1775717266623", + "node_type": "start", + "title": "User Input", + "index": 1, + "predecessor_node_id": null, + "inputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "sys.timestamp": 1776129228 + }, + "inputs_truncated": false, + "process_data": {}, + "process_data_truncated": false, + "outputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "sys.timestamp": 1776129228 + }, + "outputs_truncated": false, + "status": "succeeded", + "error": null, + "elapsed_time": 0.000097, + "execution_metadata": null, + "created_at": 1776129228, + "finished_at": 1776129228, + "files": [], + "iteration_id": null, + "loop_id": null + } + } + + data: { + "event": "node_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "c09ff568-1d55-4f0d-9a07-512bcbfeb289", + "node_id": "1775717346519", + "node_type": "human-input", + "title": "Human Input", + "index": 1, + "predecessor_node_id": null, + "inputs": null, + "inputs_truncated": false, + "created_at": 1776129228, + "extras": {}, + "iteration_id": null, + "loop_id": null, + "agent_strategy": null + } + } + + data: { + "event": "human_input_required", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "form_id": "019d898d-3d80-7105-b920-9899ead4ff3e", + "node_id": "1775717346519", + "node_title": "Human Input", + "form_content": "this is form 1:\n{{#$output.some_field#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field", + "default": { + "type": "variable", + "selector": [ + "sys", + "workflow_run_id" + ], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "YES", + "button_style": "default" + }, + { + "id": "reject", + "title": "NO", + "button_style": "default" + } + ], + "display_in_ui": true, + "form_token": "0Tb1nXYe4hzQUD706nHB4y", + "resolved_default_values": { + "some_field": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "expiration_time": 1776388428 + } + } + + data: { + "event": "workflow_paused", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "paused_nodes": [ + "1775717346519" + ], + "outputs": {}, + "reasons": [ + { + "form_id": "019d898d-3d80-7105-b920-9899ead4ff3e", + "form_content": "this is form 1:\n{{#$output.some_field#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field", + "default": { + "type": "variable", + "selector": [ + "sys", + "workflow_run_id" + ], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "YES", + "button_style": "default" + }, + { + "id": "reject", + "title": "NO", + "button_style": "default" + } + ], + "display_in_ui": true, + "node_id": "1775717346519", + "node_title": "Human Input", + "resolved_default_values": { + "some_field": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "form_token": "0Tb1nXYe4hzQUD706nHB4y", + "type": "human_input_required", + "expiration_time": 1776388428 + } + ], + "status": "paused", + "created_at": 1776129228, + "elapsed_time": 0.070478, + "total_tokens": 0, + "total_steps": 2 + } + } + ``` + ```json {{ title: 'File upload sample code' }} import requests @@ -457,6 +758,24 @@ Workflow applications offers non-session support and is ideal for translation, a - `total_price` (decimal) optional total cost - `currency` (string) optional currency, such as `USD` / `RMB` - `created_at` (timestamp) timestamp of start, e.g., 1705395332 + - `event: human_input_required` Workflow paused and requires Human-in-the-Loop input + - `task_id` (string) Task ID, used for request tracking + - `workflow_run_id` (string) Unique ID of workflow execution + - `event` (string) fixed to `human_input_required` + - `data` (object) detail + - `form_id` (string) Human input form ID + - `node_id` (string) Human input node ID + - `node_title` (string) Human input node title + - `form_content` (string) Rendered form content + - `inputs` (array[object]) Input field definitions + - `actions` (array[object]) User action buttons + - `id` (string) Action ID + - `title` (string) Button text + - `button_style` (string) Button style + - `display_in_ui` (bool) Whether this form should be shown in UI + - `form_token` (string) Token used by `/form/human_input/:form_token` APIs + - `resolved_default_values` (object) Runtime-resolved default values + - `expiration_time` (timestamp) Form expiration time (Unix seconds) - `event: workflow_finished` workflow execution finished, success and failure are different states in the same event - `task_id` (string) Task ID, used for request tracking and the below Stop Generate API - `workflow_run_id` (string) Unique ID of workflow execution @@ -666,6 +985,198 @@ Workflow applications offers non-session support and is ideal for translation, a --- + + + + Retrieve a pending Human-in-the-Loop form by `form_token`. + + Use this endpoint when a workflow pauses with `human_input_required` and returns a `form_token`. + + ### Path + - `form_token` (string) Required, token returned by the pause event. + + ### Response + - `form_content` (string) Rendered form content (markdown/plain text) + - `inputs` (array[object]) Form input definitions + - `resolved_default_values` (object) Default values resolved to strings + - `user_actions` (array[object]) Action buttons + - `expiration_time` (timestamp) Form expiration time (Unix seconds) + + ### Errors + - 404, form not found or does not belong to current app + - 412, `human_input_form_submitted`, form already submitted + - 412, `human_input_form_expired`, form expired + + + + + + ```json {{ title: 'Response' }} + { + "form_content": "Please confirm the final answer: {{#$output.answer#}}", + "inputs": [ + { + "label": "Answer", + "type": "text-input", + "required": true, + "output_variable_name": "answer" + } + ], + "resolved_default_values": { + "answer": "Initial value" + }, + "user_actions": [ + { "id": "approve", "title": "Approve", "button_style": "primary" }, + { "id": "reject", "title": "Reject", "button_style": "warning" } + ], + "expiration_time": 1735689600 + } + ``` + + + + +--- + + + + + Submit a pending Human-in-the-Loop form. + + ### Path + - `form_token` (string) Required, token returned by the pause event. + + ### Request Body + - `inputs` (object) Required, key/value pairs for form fields. + - `action` (string) Required, selected action ID from `user_actions`. + - `user` (string) Required, end-user identifier. + + ### Response + Returns an empty object on success. + + ### Errors + - 400, `invalid_form_data`, submitted data does not match the form schema + - 404, form not found or does not belong to current app + - 412, `human_input_form_submitted`, form already submitted + - 412, `human_input_form_expired`, form expired + + + + + + ```json {{ title: 'Response' }} + {} + ``` + + + + +--- + + + + + Continue receiving workflow events after submitting a human input form. + + This endpoint returns `text/event-stream` and can be used to observe the resumed run until completion. + + ### Path + - `task_id` (string) Required, workflow run ID (`workflow_run_id`). + + ### Query + - `user` (string) Required, end-user identifier. + - `include_state_snapshot` (bool) Optional, set to `true` to replay from persisted state snapshot before continuing with live events. + - `continue_on_pause` (bool) Optional, set to `true` to keep the stream open across `workflow_paused` events until `workflow_finished`. + + ### Response + Server-Sent Events stream (`text/event-stream`). + Typical events include `workflow_paused`, `node_started`, `node_finished`, `human_input_form_filled`, `human_input_form_timeout`, and `workflow_finished`. + If the workflow has already finished when you call this endpoint, the server returns a single finished event immediately. + + + + + + ```streaming {{ title: 'Response' }} + event: ping + + data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}} + + data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}} + + data: {"event":"node_finished","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"process_data":null,"outputs":null,"status":"succeeded","error":null,"elapsed_time":0.00032,"execution_metadata":null,"created_at":1776087863,"finished_at":1776087863,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":2,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}} + + data: {"event":"node_finished","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":2,"predecessor_node_id":null,"inputs":null,"process_data":null,"outputs":null,"status":"paused","error":null,"elapsed_time":0.007381,"execution_metadata":null,"created_at":1776087863,"finished_at":1776087863,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"workflow_paused","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","paused_nodes":["1775717346519"],"outputs":{},"reasons":[{"form_id":"019d8716-0fde-75da-8207-1458ccde76e5","form_content":"this is form 1:\n{{#$output.some_field#}}\n","inputs":[{"type":"paragraph","output_variable_name":"some_field","default":{"type":"variable","selector":["sys","workflow_run_id"],"value":""}}],"actions":[{"id":"approve","title":"YES","button_style":"default"},{"id":"reject","title":"NO","button_style":"default"}],"display_in_ui":true,"node_id":"1775717346519","node_title":"Human Input","resolved_default_values":{"some_field":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"form_token":"n7hFG4ZDYdGcgZ5VDc7EGM","type":"human_input_required"}],"status":"paused","created_at":1776087863,"elapsed_time":0.0,"total_tokens":0,"total_steps":2}} + + data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"human_input_form_filled","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"node_id":"1775717346519","node_title":"Human Input","rendered_content":"this is form 1:\nfield 1 filled!\n","action_id":"approve","action_text":"YES"}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":{},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"some_field":"field 1 filled!","some_field_2":"from bruno with love","__action_id":"approve","__rendered_content":"this is form 1:\nfield 1 filled!\n"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.004431,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"6d8fc3cb-19f7-440b-b83e-eed4e847a332","node_id":"1775717350710","node_type":"template-transform","title":"Template","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"text_chunk","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"text":"field 1 filled!","from_variable_selector":["1775717350710","output"]}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"6d8fc3cb-19f7-440b-b83e-eed4e847a332","node_id":"1775717350710","node_type":"template-transform","title":"Template","index":1,"predecessor_node_id":null,"inputs":{"some_field":"field 1 filled!"},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"output":"field 1 filled!"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.264614,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"e88dec7e-aa2c-41f7-8d73-032b749e23f5","node_id":"1775717354177","node_type":"end","title":"Output","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"e88dec7e-aa2c-41f7-8d73-032b749e23f5","node_id":"1775717354177","node_type":"end","title":"Output","index":1,"predecessor_node_id":null,"inputs":{"output":"field 1 filled!"},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"output":"field 1 filled!"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.00003,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"workflow_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","status":"succeeded","outputs":{"output":"field 1 filled!"},"error":null,"elapsed_time":0.364935,"total_tokens":0,"total_steps":5,"created_by":{"id":"7932d34c-dcf4-4fba-b770-f2a9de88c0a0","user":"abc-123"},"created_at":1776087877,"finished_at":1776087877,"exceptions_count":0,"files":[]}} + ``` + + + + +--- + ### ブロッキングモード + ブロッキングモードでは、完了済みのワークフロー結果、または一時停止中のワークフロー結果のいずれかが返されます。 + + Human-in-the-Loop で実行が一時停止した場合も、レスポンスには `workflow_run_id` と `task_id` が含まれますが、`data.status` は `paused` になります。 + 一時停止レスポンスには `paused_nodes` と `reasons` も含まれます。`human_input_required` の各 reason には `form_id` と `expiration_time` が含まれます。 + ```json {{ title: '応答' }} { @@ -236,6 +259,70 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' } ``` + + ```json {{ title: '一時停止レスポンス例' }} + { + "task_id": "3938b985-f4c6-4806-87b6-215e0aca9d81", + "workflow_run_id": "4a80f375-682b-49c5-b199-e950aac4968f", + "data": { + "id": "4a80f375-682b-49c5-b199-e950aac4968f", + "workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "status": "paused", + "outputs": {}, + "error": null, + "elapsed_time": 0.035667, + "total_tokens": 0, + "total_steps": 2, + "created_at": 1776074783, + "finished_at": null, + "paused_nodes": [ + "1775717346519" + ], + "reasons": [ + { + "form_id": "019d864e-7a36-74a2-b94e-e5660c47f5a7", + "form_content": "this is form 1:\n{{#$output.some_field#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field", + "default": { + "type": "variable", + "selector": [ + "sys", + "workflow_run_id" + ], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "YES", + "button_style": "default" + }, + { + "id": "reject", + "title": "NO", + "button_style": "default" + } + ], + "display_in_ui": true, + "node_id": "1775717346519", + "node_title": "Human Input", + "resolved_default_values": { + "some_field": "4a80f375-682b-49c5-b199-e950aac4968f" + }, + "form_token": "SZwvfmL47fTIsZynP2Jr9i", + "type": "human_input_required", + "expiration_time": 1776333983 + } + ] + } + } + ``` + ### ストリーミングモード ```streaming {{ title: '応答' }} @@ -247,6 +334,220 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' data: {"event": "tts_message_end", "conversation_id": "23dd85f3-1a41-4ea0-b7a9-062734ccfaf9", "message_id": "a8bdc41c-13b2-4c18-bfd9-054b9803038c", "created_at": 1721205487, "task_id": "3bf8a0bb-e73b-4690-9e66-4e429bad8ee7", "audio": ""} ``` + ストリーミングモードでも Human-in-the-Loop により一時停止する場合があります。その場合、SSE ストリームではまず `human_input_required` が送られ、その後に `workflow_paused` が送られます。 + + + ```streaming {{ title: '一時停止ストリーミングレスポンス例' }} + event: ping + + data: { + "event": "workflow_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "inputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "created_at": 1776129228, + "reason": "initial" + } + } + + data: { + "event": "node_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "7d9bb041-5ecb-497f-a674-d8706eed0ab1", + "node_id": "1775717266623", + "node_type": "start", + "title": "User Input", + "index": 1, + "predecessor_node_id": null, + "inputs": null, + "inputs_truncated": false, + "created_at": 1776129228, + "extras": {}, + "iteration_id": null, + "loop_id": null, + "agent_strategy": null + } + } + + data: { + "event": "node_finished", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "7d9bb041-5ecb-497f-a674-d8706eed0ab1", + "node_id": "1775717266623", + "node_type": "start", + "title": "User Input", + "index": 1, + "predecessor_node_id": null, + "inputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "sys.timestamp": 1776129228 + }, + "inputs_truncated": false, + "process_data": {}, + "process_data_truncated": false, + "outputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "sys.timestamp": 1776129228 + }, + "outputs_truncated": false, + "status": "succeeded", + "error": null, + "elapsed_time": 0.000097, + "execution_metadata": null, + "created_at": 1776129228, + "finished_at": 1776129228, + "files": [], + "iteration_id": null, + "loop_id": null + } + } + + data: { + "event": "node_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "c09ff568-1d55-4f0d-9a07-512bcbfeb289", + "node_id": "1775717346519", + "node_type": "human-input", + "title": "Human Input", + "index": 1, + "predecessor_node_id": null, + "inputs": null, + "inputs_truncated": false, + "created_at": 1776129228, + "extras": {}, + "iteration_id": null, + "loop_id": null, + "agent_strategy": null + } + } + + data: { + "event": "human_input_required", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "form_id": "019d898d-3d80-7105-b920-9899ead4ff3e", + "node_id": "1775717346519", + "node_title": "Human Input", + "form_content": "this is form 1:\n{{#$output.some_field#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field", + "default": { + "type": "variable", + "selector": [ + "sys", + "workflow_run_id" + ], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "YES", + "button_style": "default" + }, + { + "id": "reject", + "title": "NO", + "button_style": "default" + } + ], + "display_in_ui": true, + "form_token": "0Tb1nXYe4hzQUD706nHB4y", + "resolved_default_values": { + "some_field": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "expiration_time": 1776388428 + } + } + + data: { + "event": "workflow_paused", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "paused_nodes": [ + "1775717346519" + ], + "outputs": {}, + "reasons": [ + { + "form_id": "019d898d-3d80-7105-b920-9899ead4ff3e", + "form_content": "this is form 1:\n{{#$output.some_field#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field", + "default": { + "type": "variable", + "selector": [ + "sys", + "workflow_run_id" + ], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "YES", + "button_style": "default" + }, + { + "id": "reject", + "title": "NO", + "button_style": "default" + } + ], + "display_in_ui": true, + "node_id": "1775717346519", + "node_title": "Human Input", + "resolved_default_values": { + "some_field": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "form_token": "0Tb1nXYe4hzQUD706nHB4y", + "type": "human_input_required", + "expiration_time": 1776388428 + } + ], + "status": "paused", + "created_at": 1776129228, + "elapsed_time": 0.070478, + "total_tokens": 0, + "total_steps": 2 + } + } + ``` + ```json {{ title: 'ファイルアップロードのサンプルコード' }} import requests @@ -452,6 +753,24 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' - `total_price` (decimal) オプション 総費用 - `currency` (string) オプション 通貨、例:`USD` / `RMB` - `created_at` (timestamp) 開始時間 + - `event: human_input_required` ワークフローが一時停止し、Human-in-the-Loop 入力が必要 + - `task_id` (string) タスク ID、リクエスト追跡に使用 + - `workflow_run_id` (string) ワークフロー実行 ID + - `event` (string) `human_input_required` に固定 + - `data` (object) 詳細内容 + - `form_id` (string) ヒューマン入力フォーム ID + - `node_id` (string) Human Input ノード ID + - `node_title` (string) Human Input ノードタイトル + - `form_content` (string) レンダリング済みフォーム内容 + - `inputs` (array[object]) フォーム入力項目の定義 + - `actions` (array[object]) ユーザーが選択できるアクションボタン + - `id` (string) アクション ID + - `title` (string) ボタンラベル + - `button_style` (string) ボタンスタイル + - `display_in_ui` (bool) UI にこのフォームを表示するかどうか + - `form_token` (string) `/form/human_input/:form_token` API で使用するトークン + - `resolved_default_values` (object) 実行時に解決されたデフォルト値 + - `expiration_time` (timestamp) フォームの有効期限(Unix 秒) - `event: workflow_finished` ワークフロー実行終了、成功と失敗は同じイベント内の異なる状態 - `task_id` (string) タスクID、リクエスト追跡と以下の停止応答インターフェースに使用 - `workflow_run_id` (string) ワークフロー実行ID @@ -661,6 +980,198 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx' --- + + + + `form_token` から保留中の Human-in-the-Loop フォームを取得します。 + + Workflow が `human_input_required`(`form_token` を含む)で一時停止した際に使用します。 + + ### パス + - `form_token` (string) 必須、一時停止イベントで返されたフォームトークン + + ### 応答 + - `form_content` (string) レンダリング済みフォーム内容(markdown/plain text) + - `inputs` (array[object]) 入力項目定義 + - `resolved_default_values` (object) 解決済みデフォルト値(文字列) + - `user_actions` (array[object]) アクションボタン一覧 + - `expiration_time` (timestamp) フォーム有効期限(Unix 秒) + + ### エラー + - 404, フォームが存在しない、または現在のアプリに属していない + - 412, `human_input_form_submitted`, 既に送信済み + - 412, `human_input_form_expired`, 期限切れ + + + + + + ```json {{ title: '応答' }} + { + "form_content": "最終回答を確認してください: {{#$output.answer#}}", + "inputs": [ + { + "label": "回答", + "type": "text-input", + "required": true, + "output_variable_name": "answer" + } + ], + "resolved_default_values": { + "answer": "初期値" + }, + "user_actions": [ + { "id": "approve", "title": "承認", "button_style": "primary" }, + { "id": "reject", "title": "却下", "button_style": "warning" } + ], + "expiration_time": 1735689600 + } + ``` + + + + +--- + + + + + 保留中の Human-in-the-Loop フォームを送信します。 + + ### パス + - `form_token` (string) 必須、一時停止イベントで返されたフォームトークン + + ### リクエストボディ + - `inputs` (object) 必須、フォーム項目の key/value + - `action` (string) 必須、`user_actions` から選択したアクション ID + - `user` (string) 必須、エンドユーザー識別子 + + ### 応答 + 成功時は空オブジェクトを返します。 + + ### エラー + - 400, `invalid_form_data`, 送信データがフォームスキーマに一致しない + - 404, フォームが存在しない、または現在のアプリに属していない + - 412, `human_input_form_submitted`, 既に送信済み + - 412, `human_input_form_expired`, 期限切れ + + + + + + ```json {{ title: '応答' }} + {} + ``` + + + + +--- + + + + + Human Input フォーム送信後に、ワークフロー再開後のイベントを継続受信します。 + + このエンドポイントは `text/event-stream` を返し、完了までイベントを購読できます。 + + ### パス + - `task_id` (string) 必須、workflow 実行 ID(`workflow_run_id`) + + ### クエリ + - `user` (string) 必須、エンドユーザー識別子 + - `include_state_snapshot` (bool) 任意、`true` の場合は永続化済み状態スナップショットを先に再生してからリアルタイムイベントへ移行 + - `continue_on_pause` (bool) 任意、`true` にすると `workflow_paused` イベントをまたいでもストリームを維持し、`workflow_finished` で終了します + + ### 応答 + Server-Sent Events ストリーム(`text/event-stream`)。 + 主なイベントは `workflow_paused`、`node_started`、`node_finished`、`human_input_form_filled`、`human_input_form_timeout`、`workflow_finished` です。 + 呼び出し時点でワークフローがすでに完了している場合、このエンドポイントは完了イベントを 1 件だけ即座に返します。 + + + + + + ```streaming {{ title: '応答' }} + event: ping + + data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}} + + data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}} + + data: {"event":"node_finished","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"process_data":null,"outputs":null,"status":"succeeded","error":null,"elapsed_time":0.00032,"execution_metadata":null,"created_at":1776087863,"finished_at":1776087863,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":2,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}} + + data: {"event":"node_finished","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":2,"predecessor_node_id":null,"inputs":null,"process_data":null,"outputs":null,"status":"paused","error":null,"elapsed_time":0.007381,"execution_metadata":null,"created_at":1776087863,"finished_at":1776087863,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"workflow_paused","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","paused_nodes":["1775717346519"],"outputs":{},"reasons":[{"form_id":"019d8716-0fde-75da-8207-1458ccde76e5","form_content":"this is form 1:\n{{#$output.some_field#}}\n","inputs":[{"type":"paragraph","output_variable_name":"some_field","default":{"type":"variable","selector":["sys","workflow_run_id"],"value":""}}],"actions":[{"id":"approve","title":"YES","button_style":"default"},{"id":"reject","title":"NO","button_style":"default"}],"display_in_ui":true,"node_id":"1775717346519","node_title":"Human Input","resolved_default_values":{"some_field":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"form_token":"n7hFG4ZDYdGcgZ5VDc7EGM","type":"human_input_required"}],"status":"paused","created_at":1776087863,"elapsed_time":0.0,"total_tokens":0,"total_steps":2}} + + data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"human_input_form_filled","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"node_id":"1775717346519","node_title":"Human Input","rendered_content":"this is form 1:\nfield 1 filled!\n","action_id":"approve","action_text":"YES"}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":{},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"some_field":"field 1 filled!","some_field_2":"from bruno with love","__action_id":"approve","__rendered_content":"this is form 1:\nfield 1 filled!\n"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.004431,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"6d8fc3cb-19f7-440b-b83e-eed4e847a332","node_id":"1775717350710","node_type":"template-transform","title":"Template","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"text_chunk","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"text":"field 1 filled!","from_variable_selector":["1775717350710","output"]}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"6d8fc3cb-19f7-440b-b83e-eed4e847a332","node_id":"1775717350710","node_type":"template-transform","title":"Template","index":1,"predecessor_node_id":null,"inputs":{"some_field":"field 1 filled!"},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"output":"field 1 filled!"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.264614,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"e88dec7e-aa2c-41f7-8d73-032b749e23f5","node_id":"1775717354177","node_type":"end","title":"Output","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"e88dec7e-aa2c-41f7-8d73-032b749e23f5","node_id":"1775717354177","node_type":"end","title":"Output","index":1,"predecessor_node_id":null,"inputs":{"output":"field 1 filled!"},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"output":"field 1 filled!"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.00003,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"workflow_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","status":"succeeded","outputs":{"output":"field 1 filled!"},"error":null,"elapsed_time":0.364935,"total_tokens":0,"total_steps":5,"created_by":{"id":"7932d34c-dcf4-4fba-b770-f2a9de88c0a0","user":"abc-123"},"created_at":1776087877,"finished_at":1776087877,"exceptions_count":0,"files":[]}} + ``` + + + + +--- + ### Blocking Mode + 阻塞模式可能返回已完成的工作流结果,也可能返回暂停中的工作流结果。 + + 当执行因 Human-in-the-Loop 暂停时,响应仍然会包含 `workflow_run_id` 和 `task_id`,但 `data.status` 会变为 `paused`。 + 暂停响应还会包含 `paused_nodes` 和 `reasons`。对于 `human_input_required`,每个 reason 都会包含 `form_id` 和 `expiration_time`。 + ```json {{ title: 'Response' }} { @@ -226,6 +249,70 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等 } ``` + + ```json {{ title: 'Paused Response Example' }} + { + "task_id": "3938b985-f4c6-4806-87b6-215e0aca9d81", + "workflow_run_id": "4a80f375-682b-49c5-b199-e950aac4968f", + "data": { + "id": "4a80f375-682b-49c5-b199-e950aac4968f", + "workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "status": "paused", + "outputs": {}, + "error": null, + "elapsed_time": 0.035667, + "total_tokens": 0, + "total_steps": 2, + "created_at": 1776074783, + "finished_at": null, + "paused_nodes": [ + "1775717346519" + ], + "reasons": [ + { + "form_id": "019d864e-7a36-74a2-b94e-e5660c47f5a7", + "form_content": "this is form 1:\n{{#$output.some_field#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field", + "default": { + "type": "variable", + "selector": [ + "sys", + "workflow_run_id" + ], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "YES", + "button_style": "default" + }, + { + "id": "reject", + "title": "NO", + "button_style": "default" + } + ], + "display_in_ui": true, + "node_id": "1775717346519", + "node_title": "Human Input", + "resolved_default_values": { + "some_field": "4a80f375-682b-49c5-b199-e950aac4968f" + }, + "form_token": "SZwvfmL47fTIsZynP2Jr9i", + "type": "human_input_required", + "expiration_time": 1776333983 + } + ] + } + } + ``` + ### Streaming Mode ```streaming {{ title: 'Response' }} @@ -237,6 +324,220 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等 data: {"event": "tts_message_end", "conversation_id": "23dd85f3-1a41-4ea0-b7a9-062734ccfaf9", "message_id": "a8bdc41c-13b2-4c18-bfd9-054b9803038c", "created_at": 1721205487, "task_id": "3bf8a0bb-e73b-4690-9e66-4e429bad8ee7", "audio": ""} ``` + 流式模式同样可能因为 Human-in-the-Loop 而暂停。此时 SSE 流会先返回 `human_input_required`,随后返回 `workflow_paused`。 + + + ```streaming {{ title: 'Paused Streaming Response Example' }} + event: ping + + data: { + "event": "workflow_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "inputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "created_at": 1776129228, + "reason": "initial" + } + } + + data: { + "event": "node_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "7d9bb041-5ecb-497f-a674-d8706eed0ab1", + "node_id": "1775717266623", + "node_type": "start", + "title": "User Input", + "index": 1, + "predecessor_node_id": null, + "inputs": null, + "inputs_truncated": false, + "created_at": 1776129228, + "extras": {}, + "iteration_id": null, + "loop_id": null, + "agent_strategy": null + } + } + + data: { + "event": "node_finished", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "7d9bb041-5ecb-497f-a674-d8706eed0ab1", + "node_id": "1775717266623", + "node_type": "start", + "title": "User Input", + "index": 1, + "predecessor_node_id": null, + "inputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "sys.timestamp": 1776129228 + }, + "inputs_truncated": false, + "process_data": {}, + "process_data_truncated": false, + "outputs": { + "sys.files": [], + "sys.user_id": "abc-123", + "sys.app_id": "d1074979-f67e-4114-8691-e35878df9a89", + "sys.workflow_id": "e46514f1-c008-41ff-94b0-4f33d4b97d36", + "sys.workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "sys.timestamp": 1776129228 + }, + "outputs_truncated": false, + "status": "succeeded", + "error": null, + "elapsed_time": 0.000097, + "execution_metadata": null, + "created_at": 1776129228, + "finished_at": 1776129228, + "files": [], + "iteration_id": null, + "loop_id": null + } + } + + data: { + "event": "node_started", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "id": "c09ff568-1d55-4f0d-9a07-512bcbfeb289", + "node_id": "1775717346519", + "node_type": "human-input", + "title": "Human Input", + "index": 1, + "predecessor_node_id": null, + "inputs": null, + "inputs_truncated": false, + "created_at": 1776129228, + "extras": {}, + "iteration_id": null, + "loop_id": null, + "agent_strategy": null + } + } + + data: { + "event": "human_input_required", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "form_id": "019d898d-3d80-7105-b920-9899ead4ff3e", + "node_id": "1775717346519", + "node_title": "Human Input", + "form_content": "this is form 1:\n{{#$output.some_field#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field", + "default": { + "type": "variable", + "selector": [ + "sys", + "workflow_run_id" + ], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "YES", + "button_style": "default" + }, + { + "id": "reject", + "title": "NO", + "button_style": "default" + } + ], + "display_in_ui": true, + "form_token": "0Tb1nXYe4hzQUD706nHB4y", + "resolved_default_values": { + "some_field": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "expiration_time": 1776388428 + } + } + + data: { + "event": "workflow_paused", + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "task_id": "0399c5c2-181b-4493-a78e-1421914e8a25", + "data": { + "workflow_run_id": "a4959eb4-c852-4e0c-ac7a-348233f7f345", + "paused_nodes": [ + "1775717346519" + ], + "outputs": {}, + "reasons": [ + { + "form_id": "019d898d-3d80-7105-b920-9899ead4ff3e", + "form_content": "this is form 1:\n{{#$output.some_field#}}\n", + "inputs": [ + { + "type": "paragraph", + "output_variable_name": "some_field", + "default": { + "type": "variable", + "selector": [ + "sys", + "workflow_run_id" + ], + "value": "" + } + } + ], + "actions": [ + { + "id": "approve", + "title": "YES", + "button_style": "default" + }, + { + "id": "reject", + "title": "NO", + "button_style": "default" + } + ], + "display_in_ui": true, + "node_id": "1775717346519", + "node_title": "Human Input", + "resolved_default_values": { + "some_field": "a4959eb4-c852-4e0c-ac7a-348233f7f345" + }, + "form_token": "0Tb1nXYe4hzQUD706nHB4y", + "type": "human_input_required", + "expiration_time": 1776388428 + } + ], + "status": "paused", + "created_at": 1776129228, + "elapsed_time": 0.070478, + "total_tokens": 0, + "total_steps": 2 + } + } + ``` + ```json {{ title: 'File upload sample code' }} import requests @@ -445,6 +746,24 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等 - `total_price` (decimal) optional 总费用 - `currency` (string) optional 货币,如 `USD` / `RMB` - `created_at` (timestamp) 开始时间 + - `event: human_input_required` Workflow 已暂停,等待 Human-in-the-Loop 输入 + - `task_id` (string) 任务 ID,用于请求跟踪 + - `workflow_run_id` (string) workflow 执行 ID + - `event` (string) 固定为 `human_input_required` + - `data` (object) 详细内容 + - `form_id` (string) 人工输入表单 ID + - `node_id` (string) Human Input 节点 ID + - `node_title` (string) Human Input 节点标题 + - `form_content` (string) 渲染后的表单内容 + - `inputs` (array[object]) 表单输入项定义 + - `actions` (array[object]) 用户可选动作按钮 + - `id` (string) 动作 ID + - `title` (string) 按钮文案 + - `button_style` (string) 按钮样式 + - `display_in_ui` (bool) 是否需要在 UI 展示该表单 + - `form_token` (string) 用于 `/form/human_input/:form_token` 接口的令牌 + - `resolved_default_values` (object) 运行时解析后的默认值 + - `expiration_time` (timestamp) 表单过期时间(Unix 秒级时间戳) - `event: workflow_finished` workflow 执行结束,成功失败同一事件中不同状态 - `task_id` (string) 任务 ID,用于请求跟踪和下方的停止响应接口 - `workflow_run_id` (string) workflow 执行 ID @@ -654,6 +973,198 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等 --- + + + + 通过 `form_token` 获取待处理的 Human-in-the-Loop 表单。 + + 当 Workflow 在流式事件中返回 `human_input_required`(包含 `form_token`)时,可调用此接口拉取表单详情。 + + ### Path + - `form_token` (string) 必填,暂停事件返回的表单 token + + ### Response + - `form_content` (string) 已渲染的表单内容(markdown/plain text) + - `inputs` (array[object]) 表单输入项定义 + - `resolved_default_values` (object) 已解析的默认值(字符串) + - `user_actions` (array[object]) 操作按钮列表 + - `expiration_time` (timestamp) 表单过期时间(Unix 秒) + + ### Errors + - 404,表单不存在或不属于当前应用 + - 412,`human_input_form_submitted`,表单已被提交 + - 412,`human_input_form_expired`,表单已过期 + + + + + + ```json {{ title: 'Response' }} + { + "form_content": "请确认最终结果:{{#$output.answer#}}", + "inputs": [ + { + "label": "答案", + "type": "text-input", + "required": true, + "output_variable_name": "answer" + } + ], + "resolved_default_values": { + "answer": "初始值" + }, + "user_actions": [ + { "id": "approve", "title": "通过", "button_style": "primary" }, + { "id": "reject", "title": "拒绝", "button_style": "warning" } + ], + "expiration_time": 1735689600 + } + ``` + + + + +--- + + + + + 提交待处理的 Human-in-the-Loop 表单。 + + ### Path + - `form_token` (string) 必填,暂停事件返回的表单 token + + ### Request Body + - `inputs` (object) 必填,表单字段的 key/value + - `action` (string) 必填,从 `user_actions` 中选择的动作 ID + - `user` (string) 必填,终端用户标识 + + ### Response + 成功时返回空对象。 + + ### Errors + - 400,`invalid_form_data`,提交数据与表单 schema 不匹配 + - 404,表单不存在或不属于当前应用 + - 412,`human_input_form_submitted`,表单已被提交 + - 412,`human_input_form_expired`,表单已过期 + + + + + + ```json {{ title: 'Response' }} + {} + ``` + + + + +--- + + + + + 在提交人工输入表单后,继续订阅工作流后续执行事件。 + + 返回 `text/event-stream`,可持续接收直到工作流结束。 + + ### Path + - `task_id` (string) 必填,workflow 运行 ID(`workflow_run_id`) + + ### Query + - `user` (string) 必填,终端用户标识 + - `include_state_snapshot` (bool) 可选,设为 `true` 时会先回放持久化状态快照,再继续实时事件 + - `continue_on_pause` (bool) 可选,设为 `true` 时,流会在 `workflow_paused` 事件之间保持连接,直到 `workflow_finished` 才结束 + + ### Response + Server-Sent Events 流(`text/event-stream`)。 + 常见事件包括 `workflow_paused`、`node_started`、`node_finished`、`human_input_form_filled`、`human_input_form_timeout`、`workflow_finished`。 + 如果调用该接口时工作流已经结束,服务端会立即返回单个完成事件。 + + + + + + ```streaming {{ title: 'Response' }} + event: ping + + data: {"event":"workflow_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","sys.timestamp":1776087863},"created_at":1776087863,"reason":"initial"}} + + data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}} + + data: {"event":"node_finished","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"b552d685-1119-4e6a-9a81-e91a23e5324b","node_id":"1775717266623","node_type":"start","title":"User Input","index":1,"predecessor_node_id":null,"inputs":null,"process_data":null,"outputs":null,"status":"succeeded","error":null,"elapsed_time":0.00032,"execution_metadata":null,"created_at":1776087863,"finished_at":1776087863,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":2,"predecessor_node_id":null,"inputs":null,"created_at":1776087863,"extras":{},"iteration_id":null,"loop_id":null}} + + data: {"event":"node_finished","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":2,"predecessor_node_id":null,"inputs":null,"process_data":null,"outputs":null,"status":"paused","error":null,"elapsed_time":0.007381,"execution_metadata":null,"created_at":1776087863,"finished_at":1776087863,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"workflow_paused","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","data":{"workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","paused_nodes":["1775717346519"],"outputs":{},"reasons":[{"form_id":"019d8716-0fde-75da-8207-1458ccde76e5","form_content":"this is form 1:\n{{#$output.some_field#}}\n","inputs":[{"type":"paragraph","output_variable_name":"some_field","default":{"type":"variable","selector":["sys","workflow_run_id"],"value":""}}],"actions":[{"id":"approve","title":"YES","button_style":"default"},{"id":"reject","title":"NO","button_style":"default"}],"display_in_ui":true,"node_id":"1775717346519","node_title":"Human Input","resolved_default_values":{"some_field":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"form_token":"n7hFG4ZDYdGcgZ5VDc7EGM","type":"human_input_required"}],"status":"paused","created_at":1776087863,"elapsed_time":0.0,"total_tokens":0,"total_steps":2}} + + data: {"event":"workflow_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","inputs":{"sys.files":[],"sys.user_id":"abc-123","sys.app_id":"d1074979-f67e-4114-8691-e35878df9a89","sys.workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","sys.workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c"},"created_at":1776087877,"reason":"resumption"}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"human_input_form_filled","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"node_id":"1775717346519","node_title":"Human Input","rendered_content":"this is form 1:\nfield 1 filled!\n","action_id":"approve","action_text":"YES"}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"8d7e8e01-5159-4089-a4b6-3aa394992cc2","node_id":"1775717346519","node_type":"human-input","title":"Human Input","index":1,"predecessor_node_id":null,"inputs":{},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"some_field":"field 1 filled!","some_field_2":"from bruno with love","__action_id":"approve","__rendered_content":"this is form 1:\nfield 1 filled!\n"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.004431,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"6d8fc3cb-19f7-440b-b83e-eed4e847a332","node_id":"1775717350710","node_type":"template-transform","title":"Template","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"text_chunk","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"text":"field 1 filled!","from_variable_selector":["1775717350710","output"]}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"6d8fc3cb-19f7-440b-b83e-eed4e847a332","node_id":"1775717350710","node_type":"template-transform","title":"Template","index":1,"predecessor_node_id":null,"inputs":{"some_field":"field 1 filled!"},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"output":"field 1 filled!"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.264614,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"node_started","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"e88dec7e-aa2c-41f7-8d73-032b749e23f5","node_id":"1775717354177","node_type":"end","title":"Output","index":1,"predecessor_node_id":null,"inputs":null,"inputs_truncated":false,"created_at":1776087877,"extras":{},"iteration_id":null,"loop_id":null,"agent_strategy":null}} + + data: {"event":"node_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"e88dec7e-aa2c-41f7-8d73-032b749e23f5","node_id":"1775717354177","node_type":"end","title":"Output","index":1,"predecessor_node_id":null,"inputs":{"output":"field 1 filled!"},"inputs_truncated":false,"process_data":{},"process_data_truncated":false,"outputs":{"output":"field 1 filled!"},"outputs_truncated":false,"status":"succeeded","error":null,"elapsed_time":0.00003,"execution_metadata":null,"created_at":1776087877,"finished_at":1776087877,"files":[],"iteration_id":null,"loop_id":null}} + + data: {"event":"workflow_finished","workflow_run_id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","task_id":"1784c3dd-20eb-4919-bd5d-a8d800b74ada","data":{"id":"5d7ef348-e1c1-4f6d-bb9b-62cc2fb2ef3c","workflow_id":"e46514f1-c008-41ff-94b0-4f33d4b97d36","status":"succeeded","outputs":{"output":"field 1 filled!"},"error":null,"elapsed_time":0.364935,"total_tokens":0,"total_steps":5,"created_by":{"id":"7932d34c-dcf4-4fba-b770-f2a9de88c0a0","user":"abc-123"},"created_at":1776087877,"finished_at":1776087877,"exceptions_count":0,"files":[]}} + ``` + + + + +--- + Date: Fri, 24 Apr 2026 15:53:14 +0800 Subject: [PATCH 006/128] feat: marketplace and oauth fixes (#35509) Co-authored-by: Claude Opus 4.6 (1M context) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com> --- api/.env.example | 5 + api/commands/plugin.py | 6 +- api/configs/feature/__init__.py | 22 + api/controllers/console/app/app.py | 26 + api/core/helper/creators.py | 41 ++ ...uth_encryption.py => system_encryption.py} | 82 +-- api/services/feature_service.py | 4 + .../tools/builtin_tools_manage_service.py | 4 +- .../trigger/trigger_provider_service.py | 4 +- .../unit_tests/core/helper/test_creators.py | 106 +++ .../utils/test_system_oauth_encryption.py | 48 +- .../services/test_trigger_provider_service.py | 4 +- .../test_builtin_tools_manage_service.py | 2 +- .../encryption/test_system_encryption.py | 619 ++++++++++++++++++ .../test_system_oauth_encryption.py | 619 ------------------ docker/.env.example | 5 + docker/docker-compose.yaml | 3 + web/app/account/oauth/authorize/page.tsx | 15 +- web/app/components/app-initializer.tsx | 2 +- .../app-publisher/__tests__/index.spec.tsx | 73 +++ .../components/app/app-publisher/index.tsx | 35 +- .../__tests__/index.spec.tsx | 4 +- .../app/create-from-dsl-modal/index.tsx | 2 +- .../components/apps/__tests__/index.spec.tsx | 96 ++- ...import-from-marketplace-template-modal.tsx | 182 +++++ web/app/components/apps/index.tsx | 38 ++ .../__tests__/panel-contextmenu.spec.tsx | 2 +- .../components/workflow/panel-contextmenu.tsx | 2 +- .../components/workflow/update-dsl-modal.tsx | 2 +- web/app/page.tsx | 31 +- web/app/signin/check-code/page.tsx | 2 +- .../components/mail-and-password-auth.tsx | 2 +- web/app/signin/invite-settings/page.tsx | 2 +- web/app/signin/normal-form.tsx | 2 +- web/app/signin/utils/post-login-redirect.ts | 68 +- web/contract/marketplace.ts | 13 + web/contract/router.ts | 3 +- web/i18n/en-US/app.json | 17 + web/i18n/en-US/workflow.json | 3 + web/i18n/zh-Hans/app.json | 17 + web/i18n/zh-Hans/workflow.json | 3 + web/next/navigation.ts | 2 + web/service/__tests__/base.spec.ts | 68 ++ web/service/apps.ts | 8 + web/service/base.ts | 18 +- web/service/marketplace-templates.ts | 18 + web/types/feature.ts | 2 + web/types/marketplace-template.ts | 11 + 48 files changed, 1604 insertions(+), 739 deletions(-) create mode 100644 api/core/helper/creators.py rename api/core/tools/utils/{system_oauth_encryption.py => system_encryption.py} (57%) create mode 100644 api/tests/unit_tests/core/helper/test_creators.py create mode 100644 api/tests/unit_tests/utils/encryption/test_system_encryption.py delete mode 100644 api/tests/unit_tests/utils/oauth_encryption/test_system_oauth_encryption.py create mode 100644 web/app/components/apps/import-from-marketplace-template-modal.tsx create mode 100644 web/service/__tests__/base.spec.ts create mode 100644 web/service/marketplace-templates.ts create mode 100644 web/types/marketplace-template.ts diff --git a/api/.env.example b/api/.env.example index 6cfe0266c2..f6f65011ea 100644 --- a/api/.env.example +++ b/api/.env.example @@ -659,6 +659,11 @@ INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y MARKETPLACE_ENABLED=true MARKETPLACE_API_URL=https://marketplace.dify.ai +# Creators Platform configuration +CREATORS_PLATFORM_FEATURES_ENABLED=true +CREATORS_PLATFORM_API_URL=https://creators.dify.ai +CREATORS_PLATFORM_OAUTH_CLIENT_ID= + # Endpoint configuration ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id} diff --git a/api/commands/plugin.py b/api/commands/plugin.py index c34391025a..8bd5392d7b 100644 --- a/api/commands/plugin.py +++ b/api/commands/plugin.py @@ -11,7 +11,7 @@ from configs import dify_config from core.helper import encrypter from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.impl.plugin import PluginInstaller -from core.tools.utils.system_oauth_encryption import encrypt_system_oauth_params +from core.tools.utils.system_encryption import encrypt_system_params from extensions.ext_database import db from models import Tenant from models.oauth import DatasourceOauthParamConfig, DatasourceProvider @@ -44,7 +44,7 @@ def setup_system_tool_oauth_client(provider, client_params): click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow")) click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow")) - oauth_client_params = encrypt_system_oauth_params(client_params_dict) + oauth_client_params = encrypt_system_params(client_params_dict) click.echo(click.style("Client params encrypted successfully.", fg="green")) except Exception as e: click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red")) @@ -94,7 +94,7 @@ def setup_system_trigger_oauth_client(provider, client_params): click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow")) click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow")) - oauth_client_params = encrypt_system_oauth_params(client_params_dict) + oauth_client_params = encrypt_system_params(client_params_dict) click.echo(click.style("Client params encrypted successfully.", fg="green")) except Exception as e: click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red")) diff --git a/api/configs/feature/__init__.py b/api/configs/feature/__init__.py index ae49ae47d0..52e33c1789 100644 --- a/api/configs/feature/__init__.py +++ b/api/configs/feature/__init__.py @@ -287,6 +287,27 @@ class MarketplaceConfig(BaseSettings): ) +class CreatorsPlatformConfig(BaseSettings): + """ + Configuration for Creators Platform integration + """ + + CREATORS_PLATFORM_FEATURES_ENABLED: bool = Field( + description="Enable or disable Creators Platform features", + default=True, + ) + + CREATORS_PLATFORM_API_URL: HttpUrl = Field( + description="Creators Platform API URL", + default=HttpUrl("https://creators.dify.ai"), + ) + + CREATORS_PLATFORM_OAUTH_CLIENT_ID: str = Field( + description="OAuth client ID for Creators Platform integration", + default="", + ) + + class EndpointConfig(BaseSettings): """ Configuration for various application endpoints and URLs @@ -1379,6 +1400,7 @@ class FeatureConfig( AuthConfig, # Changed from OAuthConfig to AuthConfig BillingConfig, CodeExecutionSandboxConfig, + CreatorsPlatformConfig, TriggerConfig, AsyncWorkflowConfig, PluginConfig, diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index 9102983d86..a736fc8bc8 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -692,6 +692,32 @@ class AppExportApi(Resource): return payload.model_dump(mode="json") +@console_ns.route("/apps//publish-to-creators-platform") +class AppPublishToCreatorsPlatformApi(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=None) + @edit_permission_required + def post(self, app_model): + """Publish app to Creators Platform""" + from configs import dify_config + from core.helper.creators import get_redirect_url, upload_dsl + + if not dify_config.CREATORS_PLATFORM_FEATURES_ENABLED: + return {"error": "Creators Platform features are not enabled"}, 403 + + current_user, _ = current_account_with_tenant() + + dsl_content = AppDslService.export_dsl(app_model=app_model, include_secret=False) + dsl_bytes = dsl_content.encode("utf-8") + + claim_code = upload_dsl(dsl_bytes) + redirect_url = get_redirect_url(str(current_user.id), claim_code) + + return {"redirect_url": redirect_url} + + @console_ns.route("/apps//name") class AppNameApi(Resource): @console_ns.doc("check_app_name") diff --git a/api/core/helper/creators.py b/api/core/helper/creators.py new file mode 100644 index 0000000000..b01e16f18a --- /dev/null +++ b/api/core/helper/creators.py @@ -0,0 +1,41 @@ +""" +Helper module for Creators Platform integration. + +Provides functionality to upload DSL files to the Creators Platform +and generate redirect URLs with OAuth authorization codes. +""" + +import logging +from urllib.parse import urlencode + +import httpx +from yarl import URL + +from configs import dify_config + +logger = logging.getLogger(__name__) + +creators_platform_api_url = URL(str(dify_config.CREATORS_PLATFORM_API_URL)) + + +def upload_dsl(dsl_file_bytes: bytes, filename: str = "template.yaml") -> str: + url = str(creators_platform_api_url / "api/v1/templates/anonymous-upload") + response = httpx.post(url, files={"file": (filename, dsl_file_bytes)}, timeout=30) + response.raise_for_status() + data = response.json() + claim_code = data.get("data", {}).get("claim_code") + if not claim_code: + raise ValueError("Creators Platform did not return a valid claim_code") + return claim_code + + +def get_redirect_url(user_account_id: str, claim_code: str) -> str: + base_url = str(dify_config.CREATORS_PLATFORM_API_URL).rstrip("/") + params: dict[str, str] = {"dsl_claim_code": claim_code} + client_id = str(dify_config.CREATORS_PLATFORM_OAUTH_CLIENT_ID or "") + if client_id: + from services.oauth_server import OAuthServerService + + oauth_code = OAuthServerService.sign_oauth_authorization_code(client_id, user_account_id) + params["oauth_code"] = oauth_code + return f"{base_url}?{urlencode(params)}" diff --git a/api/core/tools/utils/system_oauth_encryption.py b/api/core/tools/utils/system_encryption.py similarity index 57% rename from api/core/tools/utils/system_oauth_encryption.py rename to api/core/tools/utils/system_encryption.py index 6b7007842d..ca7e6a13fe 100644 --- a/api/core/tools/utils/system_oauth_encryption.py +++ b/api/core/tools/utils/system_encryption.py @@ -14,23 +14,23 @@ from configs import dify_config logger = logging.getLogger(__name__) -class OAuthEncryptionError(Exception): - """OAuth encryption/decryption specific error""" +class EncryptionError(Exception): + """Encryption/decryption specific error""" pass -class SystemOAuthEncrypter: +class SystemEncrypter: """ - A simple OAuth parameters encrypter using AES-CBC encryption. + A simple parameters encrypter using AES-CBC encryption. - This class provides methods to encrypt and decrypt OAuth parameters + This class provides methods to encrypt and decrypt parameters using AES-CBC mode with a key derived from the application's SECRET_KEY. """ def __init__(self, secret_key: str | None = None): """ - Initialize the OAuth encrypter. + Initialize the encrypter. Args: secret_key: Optional secret key. If not provided, uses dify_config.SECRET_KEY @@ -43,19 +43,19 @@ class SystemOAuthEncrypter: # Generate a fixed 256-bit key using SHA-256 self.key = hashlib.sha256(secret_key.encode()).digest() - def encrypt_oauth_params(self, oauth_params: Mapping[str, Any]) -> str: + def encrypt_params(self, params: Mapping[str, Any]) -> str: """ - Encrypt OAuth parameters. + Encrypt parameters. Args: - oauth_params: OAuth parameters dictionary, e.g., {"client_id": "xxx", "client_secret": "xxx"} + params: Parameters dictionary, e.g., {"client_id": "xxx", "client_secret": "xxx"} Returns: Base64-encoded encrypted string Raises: - OAuthEncryptionError: If encryption fails - ValueError: If oauth_params is invalid + EncryptionError: If encryption fails + ValueError: If params is invalid """ try: @@ -66,7 +66,7 @@ class SystemOAuthEncrypter: cipher = AES.new(self.key, AES.MODE_CBC, iv) # Encrypt data - padded_data = pad(TypeAdapter(dict).dump_json(dict(oauth_params)), AES.block_size) + padded_data = pad(TypeAdapter(dict).dump_json(dict(params)), AES.block_size) encrypted_data = cipher.encrypt(padded_data) # Combine IV and encrypted data @@ -76,20 +76,20 @@ class SystemOAuthEncrypter: return base64.b64encode(combined).decode() except Exception as e: - raise OAuthEncryptionError(f"Encryption failed: {str(e)}") from e + raise EncryptionError(f"Encryption failed: {str(e)}") from e - def decrypt_oauth_params(self, encrypted_data: str) -> Mapping[str, Any]: + def decrypt_params(self, encrypted_data: str) -> Mapping[str, Any]: """ - Decrypt OAuth parameters. + Decrypt parameters. Args: encrypted_data: Base64-encoded encrypted string Returns: - Decrypted OAuth parameters dictionary + Decrypted parameters dictionary Raises: - OAuthEncryptionError: If decryption fails + EncryptionError: If decryption fails ValueError: If encrypted_data is invalid """ if not isinstance(encrypted_data, str): @@ -118,70 +118,70 @@ class SystemOAuthEncrypter: unpadded_data = unpad(decrypted_data, AES.block_size) # Parse JSON - oauth_params: Mapping[str, Any] = TypeAdapter(Mapping[str, Any]).validate_json(unpadded_data) + params: Mapping[str, Any] = TypeAdapter(Mapping[str, Any]).validate_json(unpadded_data) - if not isinstance(oauth_params, dict): + if not isinstance(params, dict): raise ValueError("Decrypted data is not a valid dictionary") - return oauth_params + return params except Exception as e: - raise OAuthEncryptionError(f"Decryption failed: {str(e)}") from e + raise EncryptionError(f"Decryption failed: {str(e)}") from e # Factory function for creating encrypter instances -def create_system_oauth_encrypter(secret_key: str | None = None) -> SystemOAuthEncrypter: +def create_system_encrypter(secret_key: str | None = None) -> SystemEncrypter: """ - Create an OAuth encrypter instance. + Create an encrypter instance. Args: secret_key: Optional secret key. If not provided, uses dify_config.SECRET_KEY Returns: - SystemOAuthEncrypter instance + SystemEncrypter instance """ - return SystemOAuthEncrypter(secret_key=secret_key) + return SystemEncrypter(secret_key=secret_key) # Global encrypter instance (for backward compatibility) -_oauth_encrypter: SystemOAuthEncrypter | None = None +_encrypter: SystemEncrypter | None = None -def get_system_oauth_encrypter() -> SystemOAuthEncrypter: +def get_system_encrypter() -> SystemEncrypter: """ - Get the global OAuth encrypter instance. + Get the global encrypter instance. Returns: - SystemOAuthEncrypter instance + SystemEncrypter instance """ - global _oauth_encrypter - if _oauth_encrypter is None: - _oauth_encrypter = SystemOAuthEncrypter() - return _oauth_encrypter + global _encrypter + if _encrypter is None: + _encrypter = SystemEncrypter() + return _encrypter # Convenience functions for backward compatibility -def encrypt_system_oauth_params(oauth_params: Mapping[str, Any]) -> str: +def encrypt_system_params(params: Mapping[str, Any]) -> str: """ - Encrypt OAuth parameters using the global encrypter. + Encrypt parameters using the global encrypter. Args: - oauth_params: OAuth parameters dictionary + params: Parameters dictionary Returns: Base64-encoded encrypted string """ - return get_system_oauth_encrypter().encrypt_oauth_params(oauth_params) + return get_system_encrypter().encrypt_params(params) -def decrypt_system_oauth_params(encrypted_data: str) -> Mapping[str, Any]: +def decrypt_system_params(encrypted_data: str) -> Mapping[str, Any]: """ - Decrypt OAuth parameters using the global encrypter. + Decrypt parameters using the global encrypter. Args: encrypted_data: Base64-encoded encrypted string Returns: - Decrypted OAuth parameters dictionary + Decrypted parameters dictionary """ - return get_system_oauth_encrypter().decrypt_oauth_params(encrypted_data) + return get_system_encrypter().decrypt_params(encrypted_data) diff --git a/api/services/feature_service.py b/api/services/feature_service.py index e18eb096c9..38518378f7 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -177,6 +177,7 @@ class SystemFeatureModel(BaseModel): enable_change_email: bool = True plugin_manager: PluginManagerModel = PluginManagerModel() trial_models: list[str] = [] + enable_creators_platform: bool = False enable_trial_app: bool = False enable_explore_banner: bool = False @@ -241,6 +242,9 @@ class FeatureService: if dify_config.MARKETPLACE_ENABLED: system_features.enable_marketplace = True + if dify_config.CREATORS_PLATFORM_FEATURES_ENABLED: + system_features.enable_creators_platform = True + return system_features @classmethod diff --git a/api/services/tools/builtin_tools_manage_service.py b/api/services/tools/builtin_tools_manage_service.py index 7bd056b8a0..b8242ab3a5 100644 --- a/api/services/tools/builtin_tools_manage_service.py +++ b/api/services/tools/builtin_tools_manage_service.py @@ -26,7 +26,7 @@ from core.tools.plugin_tool.provider import PluginToolProviderController from core.tools.tool_label_manager import ToolLabelManager from core.tools.tool_manager import ToolManager from core.tools.utils.encryption import create_provider_encrypter -from core.tools.utils.system_oauth_encryption import decrypt_system_oauth_params +from core.tools.utils.system_encryption import decrypt_system_params from extensions.ext_database import db from extensions.ext_redis import redis_client from models.provider_ids import ToolProviderID @@ -521,7 +521,7 @@ class BuiltinToolManageService: ) if system_client: try: - oauth_params = decrypt_system_oauth_params(system_client.encrypted_oauth_params) + oauth_params = decrypt_system_params(system_client.encrypted_oauth_params) except Exception as e: raise ValueError(f"Error decrypting system oauth params: {e}") diff --git a/api/services/trigger/trigger_provider_service.py b/api/services/trigger/trigger_provider_service.py index 6e14d996ea..b8a76e4945 100644 --- a/api/services/trigger/trigger_provider_service.py +++ b/api/services/trigger/trigger_provider_service.py @@ -14,7 +14,7 @@ from core.helper.provider_cache import NoOpProviderCredentialCache from core.helper.provider_encryption import ProviderConfigEncrypter, create_provider_encrypter from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.impl.oauth import OAuthHandler -from core.tools.utils.system_oauth_encryption import decrypt_system_oauth_params +from core.tools.utils.system_encryption import decrypt_system_params from core.trigger.entities.api_entities import ( TriggerProviderApiEntity, TriggerProviderSubscriptionApiEntity, @@ -635,7 +635,7 @@ class TriggerProviderService: if system_client: try: - oauth_params = decrypt_system_oauth_params(system_client.encrypted_oauth_params) + oauth_params = decrypt_system_params(system_client.encrypted_oauth_params) except Exception as e: raise ValueError(f"Error decrypting system oauth params: {e}") diff --git a/api/tests/unit_tests/core/helper/test_creators.py b/api/tests/unit_tests/core/helper/test_creators.py new file mode 100644 index 0000000000..df67d3f513 --- /dev/null +++ b/api/tests/unit_tests/core/helper/test_creators.py @@ -0,0 +1,106 @@ +"""Tests for the Creators Platform helper module.""" + +from unittest.mock import MagicMock, patch + +import httpx +import pytest +from yarl import URL + + +@pytest.fixture(autouse=True) +def _patch_creators_url(monkeypatch): + """Patch the module-level creators_platform_api_url for all tests.""" + monkeypatch.setattr( + "core.helper.creators.creators_platform_api_url", + URL("https://creators.example.com"), + ) + + +class TestUploadDSL: + @patch("core.helper.creators.httpx.post") + def test_returns_claim_code(self, mock_post): + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.return_value = {"data": {"claim_code": "abc123"}} + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + from core.helper.creators import upload_dsl + + result = upload_dsl(b"app: demo", "demo.yaml") + + assert result == "abc123" + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + assert "anonymous-upload" in call_kwargs.args[0] + assert call_kwargs.kwargs["timeout"] == 30 + + @patch("core.helper.creators.httpx.post") + def test_raises_on_missing_claim_code(self, mock_post): + mock_response = MagicMock(spec=httpx.Response) + mock_response.json.return_value = {"data": {}} + mock_response.raise_for_status = MagicMock() + mock_post.return_value = mock_response + + from core.helper.creators import upload_dsl + + with pytest.raises(ValueError, match="claim_code"): + upload_dsl(b"app: demo") + + @patch("core.helper.creators.httpx.post") + def test_raises_on_http_error(self, mock_post): + mock_response = MagicMock(spec=httpx.Response) + mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( + "Server Error", + request=MagicMock(), + response=MagicMock(), + ) + mock_post.return_value = mock_response + + from core.helper.creators import upload_dsl + + with pytest.raises(httpx.HTTPStatusError): + upload_dsl(b"app: demo") + + +class TestGetRedirectUrl: + @patch("core.helper.creators.dify_config") + def test_without_oauth_client_id(self, mock_config): + mock_config.CREATORS_PLATFORM_API_URL = "https://creators.example.com" + mock_config.CREATORS_PLATFORM_OAUTH_CLIENT_ID = "" + + from core.helper.creators import get_redirect_url + + url = get_redirect_url("user-1", "claim-abc") + + assert "dsl_claim_code=claim-abc" in url + assert "oauth_code" not in url + assert url.startswith("https://creators.example.com") + + @patch("core.helper.creators.dify_config") + def test_with_oauth_client_id(self, mock_config): + mock_config.CREATORS_PLATFORM_API_URL = "https://creators.example.com" + mock_config.CREATORS_PLATFORM_OAUTH_CLIENT_ID = "client-xyz" + + with patch( + "services.oauth_server.OAuthServerService.sign_oauth_authorization_code", + return_value="oauth-code-123", + ) as mock_sign: + from core.helper.creators import get_redirect_url + + url = get_redirect_url("user-1", "claim-abc") + + mock_sign.assert_called_once_with("client-xyz", "user-1") + assert "dsl_claim_code=claim-abc" in url + assert "oauth_code=oauth-code-123" in url + + @patch("core.helper.creators.dify_config") + def test_strips_trailing_slash(self, mock_config): + mock_config.CREATORS_PLATFORM_API_URL = "https://creators.example.com/" + mock_config.CREATORS_PLATFORM_OAUTH_CLIENT_ID = "" + + from core.helper.creators import get_redirect_url + + url = get_redirect_url("user-1", "claim-abc") + + assert url.startswith("https://creators.example.com?") + assert "creators.example.com/?" not in url diff --git a/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py b/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py index 5691f33e65..6bb86ebe78 100644 --- a/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py +++ b/api/tests/unit_tests/core/tools/utils/test_system_oauth_encryption.py @@ -2,50 +2,50 @@ from __future__ import annotations import pytest -from core.tools.utils import system_oauth_encryption as oauth_encryption -from core.tools.utils.system_oauth_encryption import OAuthEncryptionError, SystemOAuthEncrypter +from core.tools.utils import system_encryption as encryption +from core.tools.utils.system_encryption import EncryptionError, SystemEncrypter -def test_system_oauth_encrypter_roundtrip(): - encrypter = SystemOAuthEncrypter(secret_key="test-secret") +def test_system_encrypter_roundtrip(): + encrypter = SystemEncrypter(secret_key="test-secret") payload = {"client_id": "cid", "client_secret": "csecret", "grant_type": "authorization_code"} - encrypted = encrypter.encrypt_oauth_params(payload) - decrypted = encrypter.decrypt_oauth_params(encrypted) + encrypted = encrypter.encrypt_params(payload) + decrypted = encrypter.decrypt_params(encrypted) assert encrypted assert dict(decrypted) == payload -def test_system_oauth_encrypter_decrypt_validates_input(): - encrypter = SystemOAuthEncrypter(secret_key="test-secret") +def test_system_encrypter_decrypt_validates_input(): + encrypter = SystemEncrypter(secret_key="test-secret") with pytest.raises(ValueError, match="must be a string"): - encrypter.decrypt_oauth_params(123) # type: ignore[arg-type] + encrypter.decrypt_params(123) # type: ignore[arg-type] with pytest.raises(ValueError, match="cannot be empty"): - encrypter.decrypt_oauth_params("") + encrypter.decrypt_params("") -def test_system_oauth_encrypter_raises_oauth_error_for_invalid_ciphertext(): - encrypter = SystemOAuthEncrypter(secret_key="test-secret") +def test_system_encrypter_raises_error_for_invalid_ciphertext(): + encrypter = SystemEncrypter(secret_key="test-secret") - with pytest.raises(OAuthEncryptionError, match="Decryption failed"): - encrypter.decrypt_oauth_params("not-base64") + with pytest.raises(EncryptionError, match="Decryption failed"): + encrypter.decrypt_params("not-base64") -def test_system_oauth_helpers_use_global_cached_instance(monkeypatch): - monkeypatch.setattr(oauth_encryption, "_oauth_encrypter", None) - monkeypatch.setattr("core.tools.utils.system_oauth_encryption.dify_config.SECRET_KEY", "global-secret") +def test_system_helpers_use_global_cached_instance(monkeypatch): + monkeypatch.setattr(encryption, "_encrypter", None) + monkeypatch.setattr("core.tools.utils.system_encryption.dify_config.SECRET_KEY", "global-secret") - first = oauth_encryption.get_system_oauth_encrypter() - second = oauth_encryption.get_system_oauth_encrypter() + first = encryption.get_system_encrypter() + second = encryption.get_system_encrypter() assert first is second - encrypted = oauth_encryption.encrypt_system_oauth_params({"k": "v"}) - assert oauth_encryption.decrypt_system_oauth_params(encrypted) == {"k": "v"} + encrypted = encryption.encrypt_system_params({"k": "v"}) + assert encryption.decrypt_system_params(encrypted) == {"k": "v"} -def test_create_system_oauth_encrypter_factory(): - encrypter = oauth_encryption.create_system_oauth_encrypter(secret_key="factory-secret") - assert isinstance(encrypter, SystemOAuthEncrypter) +def test_create_system_encrypter_factory(): + encrypter = encryption.create_system_encrypter(secret_key="factory-secret") + assert isinstance(encrypter, SystemEncrypter) diff --git a/api/tests/unit_tests/services/test_trigger_provider_service.py b/api/tests/unit_tests/services/test_trigger_provider_service.py index ebf1b36610..6eba60e5f1 100644 --- a/api/tests/unit_tests/services/test_trigger_provider_service.py +++ b/api/tests/unit_tests/services/test_trigger_provider_service.py @@ -694,7 +694,7 @@ def test_get_oauth_client_should_return_decrypted_system_client_when_verified( _mock_get_trigger_provider(mocker, provider_controller) mocker.patch("services.trigger.trigger_provider_service.PluginService.is_plugin_verified", return_value=True) mocker.patch( - "services.trigger.trigger_provider_service.decrypt_system_oauth_params", + "services.trigger.trigger_provider_service.decrypt_system_params", return_value={"client_id": "system"}, ) @@ -716,7 +716,7 @@ def test_get_oauth_client_should_raise_error_when_system_decryption_fails( _mock_get_trigger_provider(mocker, provider_controller) mocker.patch("services.trigger.trigger_provider_service.PluginService.is_plugin_verified", return_value=True) mocker.patch( - "services.trigger.trigger_provider_service.decrypt_system_oauth_params", + "services.trigger.trigger_provider_service.decrypt_system_params", side_effect=RuntimeError("bad data"), ) diff --git a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py index 79a2d30f57..ce0d94398d 100644 --- a/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py +++ b/api/tests/unit_tests/services/tools/test_builtin_tools_manage_service.py @@ -280,7 +280,7 @@ class TestGetOauthClient: assert result == {"client_id": "id", "client_secret": "secret"} - @patch(f"{MODULE}.decrypt_system_oauth_params", return_value={"sys_key": "sys_val"}) + @patch(f"{MODULE}.decrypt_system_params", return_value={"sys_key": "sys_val"}) @patch(f"{MODULE}.PluginService") @patch(f"{MODULE}.create_provider_encrypter") @patch(f"{MODULE}.ToolManager") diff --git a/api/tests/unit_tests/utils/encryption/test_system_encryption.py b/api/tests/unit_tests/utils/encryption/test_system_encryption.py new file mode 100644 index 0000000000..0435facfdb --- /dev/null +++ b/api/tests/unit_tests/utils/encryption/test_system_encryption.py @@ -0,0 +1,619 @@ +import base64 +import hashlib +from unittest.mock import patch + +import pytest +from Crypto.Cipher import AES +from Crypto.Random import get_random_bytes +from Crypto.Util.Padding import pad + +from core.tools.utils.system_encryption import ( + EncryptionError, + SystemEncrypter, + create_system_encrypter, + decrypt_system_params, + encrypt_system_params, + get_system_encrypter, +) + + +class TestSystemEncrypter: + """Test cases for SystemEncrypter class""" + + def test_init_with_secret_key(self): + """Test initialization with provided secret key""" + secret_key = "test_secret_key" + encrypter = SystemEncrypter(secret_key=secret_key) + expected_key = hashlib.sha256(secret_key.encode()).digest() + assert encrypter.key == expected_key + + def test_init_with_none_secret_key(self): + """Test initialization with None secret key falls back to config""" + with patch("core.tools.utils.system_encryption.dify_config") as mock_config: + mock_config.SECRET_KEY = "config_secret" + encrypter = SystemEncrypter(secret_key=None) + expected_key = hashlib.sha256(b"config_secret").digest() + assert encrypter.key == expected_key + + def test_init_with_empty_secret_key(self): + """Test initialization with empty secret key""" + encrypter = SystemEncrypter(secret_key="") + expected_key = hashlib.sha256(b"").digest() + assert encrypter.key == expected_key + + def test_init_without_secret_key_uses_config(self): + """Test initialization without secret key uses config""" + with patch("core.tools.utils.system_encryption.dify_config") as mock_config: + mock_config.SECRET_KEY = "default_secret" + encrypter = SystemEncrypter() + expected_key = hashlib.sha256(b"default_secret").digest() + assert encrypter.key == expected_key + + def test_encrypt_params_basic(self): + """Test basic parameters encryption""" + encrypter = SystemEncrypter("test_secret") + params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted = encrypter.encrypt_params(params) + + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + # Should be valid base64 + try: + base64.b64decode(encrypted) + except Exception: + pytest.fail("Encrypted result is not valid base64") + + def test_encrypt_params_empty_dict(self): + """Test encryption with empty dictionary""" + encrypter = SystemEncrypter("test_secret") + params = {} + + encrypted = encrypter.encrypt_params(params) + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + + def test_encrypt_params_complex_data(self): + """Test encryption with complex data structures""" + encrypter = SystemEncrypter("test_secret") + params = { + "client_id": "test_id", + "client_secret": "test_secret", + "scopes": ["read", "write", "admin"], + "metadata": {"issuer": "test_issuer", "expires_in": 3600, "is_active": True}, + "numeric_value": 42, + "boolean_value": False, + "null_value": None, + } + + encrypted = encrypter.encrypt_params(params) + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + + def test_encrypt_params_unicode_data(self): + """Test encryption with unicode data""" + encrypter = SystemEncrypter("test_secret") + params = {"client_id": "test_id", "client_secret": "test_secret", "description": "This is a test case 🚀"} + + encrypted = encrypter.encrypt_params(params) + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + + def test_encrypt_params_large_data(self): + """Test encryption with large data""" + encrypter = SystemEncrypter("test_secret") + params = { + "client_id": "test_id", + "large_data": "x" * 10000, # 10KB of data + } + + encrypted = encrypter.encrypt_params(params) + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + + def test_encrypt_params_invalid_input(self): + """Test encryption with invalid input types""" + encrypter = SystemEncrypter("test_secret") + + with pytest.raises(Exception): # noqa: B017 + encrypter.encrypt_params(None) + + with pytest.raises(Exception): # noqa: B017 + encrypter.encrypt_params("not_a_dict") + + def test_decrypt_params_basic(self): + """Test basic parameters decryption""" + encrypter = SystemEncrypter("test_secret") + original_params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted = encrypter.encrypt_params(original_params) + decrypted = encrypter.decrypt_params(encrypted) + + assert decrypted == original_params + + def test_decrypt_params_empty_dict(self): + """Test decryption of empty dictionary""" + encrypter = SystemEncrypter("test_secret") + original_params = {} + + encrypted = encrypter.encrypt_params(original_params) + decrypted = encrypter.decrypt_params(encrypted) + + assert decrypted == original_params + + def test_decrypt_params_complex_data(self): + """Test decryption with complex data structures""" + encrypter = SystemEncrypter("test_secret") + original_params = { + "client_id": "test_id", + "client_secret": "test_secret", + "scopes": ["read", "write", "admin"], + "metadata": {"issuer": "test_issuer", "expires_in": 3600, "is_active": True}, + "numeric_value": 42, + "boolean_value": False, + "null_value": None, + } + + encrypted = encrypter.encrypt_params(original_params) + decrypted = encrypter.decrypt_params(encrypted) + + assert decrypted == original_params + + def test_decrypt_params_unicode_data(self): + """Test decryption with unicode data""" + encrypter = SystemEncrypter("test_secret") + original_params = { + "client_id": "test_id", + "client_secret": "test_secret", + "description": "This is a test case 🚀", + } + + encrypted = encrypter.encrypt_params(original_params) + decrypted = encrypter.decrypt_params(encrypted) + + assert decrypted == original_params + + def test_decrypt_params_large_data(self): + """Test decryption with large data""" + encrypter = SystemEncrypter("test_secret") + original_params = { + "client_id": "test_id", + "large_data": "x" * 10000, # 10KB of data + } + + encrypted = encrypter.encrypt_params(original_params) + decrypted = encrypter.decrypt_params(encrypted) + + assert decrypted == original_params + + def test_decrypt_params_invalid_base64(self): + """Test decryption with invalid base64 data""" + encrypter = SystemEncrypter("test_secret") + + with pytest.raises(EncryptionError): + encrypter.decrypt_params("invalid_base64!") + + def test_decrypt_params_empty_string(self): + """Test decryption with empty string""" + encrypter = SystemEncrypter("test_secret") + + with pytest.raises(ValueError) as exc_info: + encrypter.decrypt_params("") + + assert "encrypted_data cannot be empty" in str(exc_info.value) + + def test_decrypt_params_non_string_input(self): + """Test decryption with non-string input""" + encrypter = SystemEncrypter("test_secret") + + with pytest.raises(ValueError) as exc_info: + encrypter.decrypt_params(123) + + assert "encrypted_data must be a string" in str(exc_info.value) + + with pytest.raises(ValueError) as exc_info: + encrypter.decrypt_params(None) + + assert "encrypted_data must be a string" in str(exc_info.value) + + def test_decrypt_params_too_short_data(self): + """Test decryption with too short encrypted data""" + encrypter = SystemEncrypter("test_secret") + + # Create data that's too short (less than 32 bytes) + short_data = base64.b64encode(b"short").decode() + + with pytest.raises(EncryptionError) as exc_info: + encrypter.decrypt_params(short_data) + + assert "Invalid encrypted data format" in str(exc_info.value) + + def test_decrypt_params_corrupted_data(self): + """Test decryption with corrupted data""" + encrypter = SystemEncrypter("test_secret") + + # Create corrupted data (valid base64 but invalid encrypted content) + corrupted_data = base64.b64encode(b"x" * 48).decode() # 48 bytes of garbage + + with pytest.raises(EncryptionError): + encrypter.decrypt_params(corrupted_data) + + def test_decrypt_params_wrong_key(self): + """Test decryption with wrong key""" + encrypter1 = SystemEncrypter("secret1") + encrypter2 = SystemEncrypter("secret2") + + original_params = {"client_id": "test_id", "client_secret": "test_secret"} + encrypted = encrypter1.encrypt_params(original_params) + + with pytest.raises(EncryptionError): + encrypter2.decrypt_params(encrypted) + + def test_encryption_decryption_consistency(self): + """Test that encryption and decryption are consistent""" + encrypter = SystemEncrypter("test_secret") + + test_cases = [ + {}, + {"simple": "value"}, + {"client_id": "id", "client_secret": "secret"}, + {"complex": {"nested": {"deep": "value"}}}, + {"unicode": "test 🚀"}, + {"numbers": 42, "boolean": True, "null": None}, + {"array": [1, 2, 3, "four", {"five": 5}]}, + ] + + for original_params in test_cases: + encrypted = encrypter.encrypt_params(original_params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == original_params, f"Failed for case: {original_params}" + + def test_encryption_randomness(self): + """Test that encryption produces different results for same input""" + encrypter = SystemEncrypter("test_secret") + params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted1 = encrypter.encrypt_params(params) + encrypted2 = encrypter.encrypt_params(params) + + # Should be different due to random IV + assert encrypted1 != encrypted2 + + # But should decrypt to same result + decrypted1 = encrypter.decrypt_params(encrypted1) + decrypted2 = encrypter.decrypt_params(encrypted2) + assert decrypted1 == decrypted2 == params + + def test_different_secret_keys_produce_different_results(self): + """Test that different secret keys produce different encrypted results""" + encrypter1 = SystemEncrypter("secret1") + encrypter2 = SystemEncrypter("secret2") + + params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted1 = encrypter1.encrypt_params(params) + encrypted2 = encrypter2.encrypt_params(params) + + # Should produce different encrypted results + assert encrypted1 != encrypted2 + + # But each should decrypt correctly with its own key + decrypted1 = encrypter1.decrypt_params(encrypted1) + decrypted2 = encrypter2.decrypt_params(encrypted2) + assert decrypted1 == decrypted2 == params + + @patch("core.tools.utils.system_encryption.get_random_bytes") + def test_encrypt_params_crypto_error(self, mock_get_random_bytes): + """Test encryption when crypto operation fails""" + mock_get_random_bytes.side_effect = Exception("Crypto error") + + encrypter = SystemEncrypter("test_secret") + params = {"client_id": "test_id"} + + with pytest.raises(EncryptionError) as exc_info: + encrypter.encrypt_params(params) + + assert "Encryption failed" in str(exc_info.value) + + @patch("core.tools.utils.system_encryption.TypeAdapter") + def test_encrypt_params_serialization_error(self, mock_type_adapter): + """Test encryption when JSON serialization fails""" + mock_type_adapter.return_value.dump_json.side_effect = Exception("Serialization error") + + encrypter = SystemEncrypter("test_secret") + params = {"client_id": "test_id"} + + with pytest.raises(EncryptionError) as exc_info: + encrypter.encrypt_params(params) + + assert "Encryption failed" in str(exc_info.value) + + def test_decrypt_params_invalid_json(self): + """Test decryption with invalid JSON data""" + encrypter = SystemEncrypter("test_secret") + + # Create valid encrypted data but with invalid JSON content + iv = get_random_bytes(16) + cipher = AES.new(encrypter.key, AES.MODE_CBC, iv) + invalid_json = b"invalid json content" + padded_data = pad(invalid_json, AES.block_size) + encrypted_data = cipher.encrypt(padded_data) + combined = iv + encrypted_data + encoded = base64.b64encode(combined).decode() + + with pytest.raises(EncryptionError): + encrypter.decrypt_params(encoded) + + def test_key_derivation_consistency(self): + """Test that key derivation is consistent""" + secret_key = "test_secret" + encrypter1 = SystemEncrypter(secret_key) + encrypter2 = SystemEncrypter(secret_key) + + assert encrypter1.key == encrypter2.key + + # Keys should be 32 bytes (256 bits) + assert len(encrypter1.key) == 32 + + +class TestFactoryFunctions: + """Test cases for factory functions""" + + def test_create_system_encrypter_with_secret(self): + """Test factory function with secret key""" + secret_key = "test_secret" + encrypter = create_system_encrypter(secret_key) + + assert isinstance(encrypter, SystemEncrypter) + expected_key = hashlib.sha256(secret_key.encode()).digest() + assert encrypter.key == expected_key + + def test_create_system_encrypter_without_secret(self): + """Test factory function without secret key""" + with patch("core.tools.utils.system_encryption.dify_config") as mock_config: + mock_config.SECRET_KEY = "config_secret" + encrypter = create_system_encrypter() + + assert isinstance(encrypter, SystemEncrypter) + expected_key = hashlib.sha256(b"config_secret").digest() + assert encrypter.key == expected_key + + def test_create_system_encrypter_with_none_secret(self): + """Test factory function with None secret key""" + with patch("core.tools.utils.system_encryption.dify_config") as mock_config: + mock_config.SECRET_KEY = "config_secret" + encrypter = create_system_encrypter(None) + + assert isinstance(encrypter, SystemEncrypter) + expected_key = hashlib.sha256(b"config_secret").digest() + assert encrypter.key == expected_key + + +class TestGlobalEncrypterInstance: + """Test cases for global encrypter instance""" + + def test_get_system_encrypter_singleton(self): + """Test that get_system_encrypter returns singleton instance""" + # Clear the global instance first + import core.tools.utils.system_encryption + + core.tools.utils.system_encryption._encrypter = None + + encrypter1 = get_system_encrypter() + encrypter2 = get_system_encrypter() + + assert encrypter1 is encrypter2 + assert isinstance(encrypter1, SystemEncrypter) + + def test_get_system_encrypter_uses_config(self): + """Test that global encrypter uses config""" + # Clear the global instance first + import core.tools.utils.system_encryption + + core.tools.utils.system_encryption._encrypter = None + + with patch("core.tools.utils.system_encryption.dify_config") as mock_config: + mock_config.SECRET_KEY = "global_secret" + encrypter = get_system_encrypter() + + expected_key = hashlib.sha256(b"global_secret").digest() + assert encrypter.key == expected_key + + +class TestConvenienceFunctions: + """Test cases for convenience functions""" + + def test_encrypt_system_params(self): + """Test encrypt_system_params convenience function""" + params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted = encrypt_system_params(params) + + assert isinstance(encrypted, str) + assert len(encrypted) > 0 + + def test_decrypt_system_params(self): + """Test decrypt_system_params convenience function""" + params = {"client_id": "test_id", "client_secret": "test_secret"} + + encrypted = encrypt_system_params(params) + decrypted = decrypt_system_params(encrypted) + + assert decrypted == params + + def test_convenience_functions_consistency(self): + """Test that convenience functions work consistently""" + test_cases = [ + {}, + {"simple": "value"}, + {"client_id": "id", "client_secret": "secret"}, + {"complex": {"nested": {"deep": "value"}}}, + {"unicode": "test 🚀"}, + {"numbers": 42, "boolean": True, "null": None}, + ] + + for original_params in test_cases: + encrypted = encrypt_system_params(original_params) + decrypted = decrypt_system_params(encrypted) + assert decrypted == original_params, f"Failed for case: {original_params}" + + def test_convenience_functions_with_errors(self): + """Test convenience functions with error conditions""" + # Test encryption with invalid input + with pytest.raises(Exception): # noqa: B017 + encrypt_system_params(None) + + # Test decryption with invalid input + with pytest.raises(ValueError): + decrypt_system_params("") + + with pytest.raises(ValueError): + decrypt_system_params(None) + + +class TestErrorHandling: + """Test cases for error handling""" + + def test_encryption_error_inheritance(self): + """Test that EncryptionError is a proper exception""" + error = EncryptionError("Test error") + assert isinstance(error, Exception) + assert str(error) == "Test error" + + def test_encryption_error_with_cause(self): + """Test EncryptionError with cause""" + original_error = ValueError("Original error") + error = EncryptionError("Wrapper error") + error.__cause__ = original_error + + assert isinstance(error, Exception) + assert str(error) == "Wrapper error" + assert error.__cause__ is original_error + + def test_error_messages_are_informative(self): + """Test that error messages are informative""" + encrypter = SystemEncrypter("test_secret") + + # Test empty string error + with pytest.raises(ValueError) as exc_info: + encrypter.decrypt_params("") + assert "encrypted_data cannot be empty" in str(exc_info.value) + + # Test non-string error + with pytest.raises(ValueError) as exc_info: + encrypter.decrypt_params(123) + assert "encrypted_data must be a string" in str(exc_info.value) + + # Test invalid format error + short_data = base64.b64encode(b"short").decode() + with pytest.raises(EncryptionError) as exc_info: + encrypter.decrypt_params(short_data) + assert "Invalid encrypted data format" in str(exc_info.value) + + +class TestEdgeCases: + """Test cases for edge cases and boundary conditions""" + + def test_very_long_secret_key(self): + """Test with very long secret key""" + long_secret = "x" * 10000 + encrypter = SystemEncrypter(long_secret) + + # Key should still be 32 bytes due to SHA-256 + assert len(encrypter.key) == 32 + + # Should still work normally + params = {"client_id": "test_id"} + encrypted = encrypter.encrypt_params(params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == params + + def test_special_characters_in_secret_key(self): + """Test with special characters in secret key""" + special_secret = "!@#$%^&*()_+-=[]{}|;':\",./<>?`~test🚀" + encrypter = SystemEncrypter(special_secret) + + params = {"client_id": "test_id"} + encrypted = encrypter.encrypt_params(params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == params + + def test_empty_values_in_params(self): + """Test with empty values in params""" + params = { + "client_id": "", + "client_secret": "", + "empty_dict": {}, + "empty_list": [], + "empty_string": "", + "zero": 0, + "false": False, + "none": None, + } + + encrypter = SystemEncrypter("test_secret") + encrypted = encrypter.encrypt_params(params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == params + + def test_deeply_nested_params(self): + """Test with deeply nested params""" + params = {"level1": {"level2": {"level3": {"level4": {"level5": {"deep_value": "found"}}}}}} + + encrypter = SystemEncrypter("test_secret") + encrypted = encrypter.encrypt_params(params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == params + + def test_params_with_all_json_types(self): + """Test with all JSON-supported data types""" + params = { + "string": "test_string", + "integer": 42, + "float": 3.14159, + "boolean_true": True, + "boolean_false": False, + "null_value": None, + "empty_string": "", + "array": [1, "two", 3.0, True, False, None], + "object": {"nested_string": "nested_value", "nested_number": 123, "nested_bool": True}, + } + + encrypter = SystemEncrypter("test_secret") + encrypted = encrypter.encrypt_params(params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == params + + +class TestPerformance: + """Test cases for performance considerations""" + + def test_large_params(self): + """Test with large params""" + large_value = "x" * 100000 # 100KB + params = {"client_id": "test_id", "large_data": large_value} + + encrypter = SystemEncrypter("test_secret") + encrypted = encrypter.encrypt_params(params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == params + + def test_many_fields_params(self): + """Test with many fields in params""" + params = {f"field_{i}": f"value_{i}" for i in range(1000)} + + encrypter = SystemEncrypter("test_secret") + encrypted = encrypter.encrypt_params(params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == params + + def test_repeated_encryption_decryption(self): + """Test repeated encryption and decryption operations""" + encrypter = SystemEncrypter("test_secret") + params = {"client_id": "test_id", "client_secret": "test_secret"} + + # Test multiple rounds of encryption/decryption + for i in range(100): + encrypted = encrypter.encrypt_params(params) + decrypted = encrypter.decrypt_params(encrypted) + assert decrypted == params diff --git a/api/tests/unit_tests/utils/oauth_encryption/test_system_oauth_encryption.py b/api/tests/unit_tests/utils/oauth_encryption/test_system_oauth_encryption.py deleted file mode 100644 index e2607f0fb1..0000000000 --- a/api/tests/unit_tests/utils/oauth_encryption/test_system_oauth_encryption.py +++ /dev/null @@ -1,619 +0,0 @@ -import base64 -import hashlib -from unittest.mock import patch - -import pytest -from Crypto.Cipher import AES -from Crypto.Random import get_random_bytes -from Crypto.Util.Padding import pad - -from core.tools.utils.system_oauth_encryption import ( - OAuthEncryptionError, - SystemOAuthEncrypter, - create_system_oauth_encrypter, - decrypt_system_oauth_params, - encrypt_system_oauth_params, - get_system_oauth_encrypter, -) - - -class TestSystemOAuthEncrypter: - """Test cases for SystemOAuthEncrypter class""" - - def test_init_with_secret_key(self): - """Test initialization with provided secret key""" - secret_key = "test_secret_key" - encrypter = SystemOAuthEncrypter(secret_key=secret_key) - expected_key = hashlib.sha256(secret_key.encode()).digest() - assert encrypter.key == expected_key - - def test_init_with_none_secret_key(self): - """Test initialization with None secret key falls back to config""" - with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config: - mock_config.SECRET_KEY = "config_secret" - encrypter = SystemOAuthEncrypter(secret_key=None) - expected_key = hashlib.sha256(b"config_secret").digest() - assert encrypter.key == expected_key - - def test_init_with_empty_secret_key(self): - """Test initialization with empty secret key""" - encrypter = SystemOAuthEncrypter(secret_key="") - expected_key = hashlib.sha256(b"").digest() - assert encrypter.key == expected_key - - def test_init_without_secret_key_uses_config(self): - """Test initialization without secret key uses config""" - with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config: - mock_config.SECRET_KEY = "default_secret" - encrypter = SystemOAuthEncrypter() - expected_key = hashlib.sha256(b"default_secret").digest() - assert encrypter.key == expected_key - - def test_encrypt_oauth_params_basic(self): - """Test basic OAuth parameters encryption""" - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} - - encrypted = encrypter.encrypt_oauth_params(oauth_params) - - assert isinstance(encrypted, str) - assert len(encrypted) > 0 - # Should be valid base64 - try: - base64.b64decode(encrypted) - except Exception: - pytest.fail("Encrypted result is not valid base64") - - def test_encrypt_oauth_params_empty_dict(self): - """Test encryption with empty dictionary""" - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = {} - - encrypted = encrypter.encrypt_oauth_params(oauth_params) - assert isinstance(encrypted, str) - assert len(encrypted) > 0 - - def test_encrypt_oauth_params_complex_data(self): - """Test encryption with complex data structures""" - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = { - "client_id": "test_id", - "client_secret": "test_secret", - "scopes": ["read", "write", "admin"], - "metadata": {"issuer": "test_issuer", "expires_in": 3600, "is_active": True}, - "numeric_value": 42, - "boolean_value": False, - "null_value": None, - } - - encrypted = encrypter.encrypt_oauth_params(oauth_params) - assert isinstance(encrypted, str) - assert len(encrypted) > 0 - - def test_encrypt_oauth_params_unicode_data(self): - """Test encryption with unicode data""" - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = {"client_id": "test_id", "client_secret": "test_secret", "description": "This is a test case 🚀"} - - encrypted = encrypter.encrypt_oauth_params(oauth_params) - assert isinstance(encrypted, str) - assert len(encrypted) > 0 - - def test_encrypt_oauth_params_large_data(self): - """Test encryption with large data""" - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = { - "client_id": "test_id", - "large_data": "x" * 10000, # 10KB of data - } - - encrypted = encrypter.encrypt_oauth_params(oauth_params) - assert isinstance(encrypted, str) - assert len(encrypted) > 0 - - def test_encrypt_oauth_params_invalid_input(self): - """Test encryption with invalid input types""" - encrypter = SystemOAuthEncrypter("test_secret") - - with pytest.raises(Exception): # noqa: B017 - encrypter.encrypt_oauth_params(None) - - with pytest.raises(Exception): # noqa: B017 - encrypter.encrypt_oauth_params("not_a_dict") - - def test_decrypt_oauth_params_basic(self): - """Test basic OAuth parameters decryption""" - encrypter = SystemOAuthEncrypter("test_secret") - original_params = {"client_id": "test_id", "client_secret": "test_secret"} - - encrypted = encrypter.encrypt_oauth_params(original_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - - assert decrypted == original_params - - def test_decrypt_oauth_params_empty_dict(self): - """Test decryption of empty dictionary""" - encrypter = SystemOAuthEncrypter("test_secret") - original_params = {} - - encrypted = encrypter.encrypt_oauth_params(original_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - - assert decrypted == original_params - - def test_decrypt_oauth_params_complex_data(self): - """Test decryption with complex data structures""" - encrypter = SystemOAuthEncrypter("test_secret") - original_params = { - "client_id": "test_id", - "client_secret": "test_secret", - "scopes": ["read", "write", "admin"], - "metadata": {"issuer": "test_issuer", "expires_in": 3600, "is_active": True}, - "numeric_value": 42, - "boolean_value": False, - "null_value": None, - } - - encrypted = encrypter.encrypt_oauth_params(original_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - - assert decrypted == original_params - - def test_decrypt_oauth_params_unicode_data(self): - """Test decryption with unicode data""" - encrypter = SystemOAuthEncrypter("test_secret") - original_params = { - "client_id": "test_id", - "client_secret": "test_secret", - "description": "This is a test case 🚀", - } - - encrypted = encrypter.encrypt_oauth_params(original_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - - assert decrypted == original_params - - def test_decrypt_oauth_params_large_data(self): - """Test decryption with large data""" - encrypter = SystemOAuthEncrypter("test_secret") - original_params = { - "client_id": "test_id", - "large_data": "x" * 10000, # 10KB of data - } - - encrypted = encrypter.encrypt_oauth_params(original_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - - assert decrypted == original_params - - def test_decrypt_oauth_params_invalid_base64(self): - """Test decryption with invalid base64 data""" - encrypter = SystemOAuthEncrypter("test_secret") - - with pytest.raises(OAuthEncryptionError): - encrypter.decrypt_oauth_params("invalid_base64!") - - def test_decrypt_oauth_params_empty_string(self): - """Test decryption with empty string""" - encrypter = SystemOAuthEncrypter("test_secret") - - with pytest.raises(ValueError) as exc_info: - encrypter.decrypt_oauth_params("") - - assert "encrypted_data cannot be empty" in str(exc_info.value) - - def test_decrypt_oauth_params_non_string_input(self): - """Test decryption with non-string input""" - encrypter = SystemOAuthEncrypter("test_secret") - - with pytest.raises(ValueError) as exc_info: - encrypter.decrypt_oauth_params(123) - - assert "encrypted_data must be a string" in str(exc_info.value) - - with pytest.raises(ValueError) as exc_info: - encrypter.decrypt_oauth_params(None) - - assert "encrypted_data must be a string" in str(exc_info.value) - - def test_decrypt_oauth_params_too_short_data(self): - """Test decryption with too short encrypted data""" - encrypter = SystemOAuthEncrypter("test_secret") - - # Create data that's too short (less than 32 bytes) - short_data = base64.b64encode(b"short").decode() - - with pytest.raises(OAuthEncryptionError) as exc_info: - encrypter.decrypt_oauth_params(short_data) - - assert "Invalid encrypted data format" in str(exc_info.value) - - def test_decrypt_oauth_params_corrupted_data(self): - """Test decryption with corrupted data""" - encrypter = SystemOAuthEncrypter("test_secret") - - # Create corrupted data (valid base64 but invalid encrypted content) - corrupted_data = base64.b64encode(b"x" * 48).decode() # 48 bytes of garbage - - with pytest.raises(OAuthEncryptionError): - encrypter.decrypt_oauth_params(corrupted_data) - - def test_decrypt_oauth_params_wrong_key(self): - """Test decryption with wrong key""" - encrypter1 = SystemOAuthEncrypter("secret1") - encrypter2 = SystemOAuthEncrypter("secret2") - - original_params = {"client_id": "test_id", "client_secret": "test_secret"} - encrypted = encrypter1.encrypt_oauth_params(original_params) - - with pytest.raises(OAuthEncryptionError): - encrypter2.decrypt_oauth_params(encrypted) - - def test_encryption_decryption_consistency(self): - """Test that encryption and decryption are consistent""" - encrypter = SystemOAuthEncrypter("test_secret") - - test_cases = [ - {}, - {"simple": "value"}, - {"client_id": "id", "client_secret": "secret"}, - {"complex": {"nested": {"deep": "value"}}}, - {"unicode": "test 🚀"}, - {"numbers": 42, "boolean": True, "null": None}, - {"array": [1, 2, 3, "four", {"five": 5}]}, - ] - - for original_params in test_cases: - encrypted = encrypter.encrypt_oauth_params(original_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == original_params, f"Failed for case: {original_params}" - - def test_encryption_randomness(self): - """Test that encryption produces different results for same input""" - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} - - encrypted1 = encrypter.encrypt_oauth_params(oauth_params) - encrypted2 = encrypter.encrypt_oauth_params(oauth_params) - - # Should be different due to random IV - assert encrypted1 != encrypted2 - - # But should decrypt to same result - decrypted1 = encrypter.decrypt_oauth_params(encrypted1) - decrypted2 = encrypter.decrypt_oauth_params(encrypted2) - assert decrypted1 == decrypted2 == oauth_params - - def test_different_secret_keys_produce_different_results(self): - """Test that different secret keys produce different encrypted results""" - encrypter1 = SystemOAuthEncrypter("secret1") - encrypter2 = SystemOAuthEncrypter("secret2") - - oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} - - encrypted1 = encrypter1.encrypt_oauth_params(oauth_params) - encrypted2 = encrypter2.encrypt_oauth_params(oauth_params) - - # Should produce different encrypted results - assert encrypted1 != encrypted2 - - # But each should decrypt correctly with its own key - decrypted1 = encrypter1.decrypt_oauth_params(encrypted1) - decrypted2 = encrypter2.decrypt_oauth_params(encrypted2) - assert decrypted1 == decrypted2 == oauth_params - - @patch("core.tools.utils.system_oauth_encryption.get_random_bytes") - def test_encrypt_oauth_params_crypto_error(self, mock_get_random_bytes): - """Test encryption when crypto operation fails""" - mock_get_random_bytes.side_effect = Exception("Crypto error") - - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = {"client_id": "test_id"} - - with pytest.raises(OAuthEncryptionError) as exc_info: - encrypter.encrypt_oauth_params(oauth_params) - - assert "Encryption failed" in str(exc_info.value) - - @patch("core.tools.utils.system_oauth_encryption.TypeAdapter") - def test_encrypt_oauth_params_serialization_error(self, mock_type_adapter): - """Test encryption when JSON serialization fails""" - mock_type_adapter.return_value.dump_json.side_effect = Exception("Serialization error") - - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = {"client_id": "test_id"} - - with pytest.raises(OAuthEncryptionError) as exc_info: - encrypter.encrypt_oauth_params(oauth_params) - - assert "Encryption failed" in str(exc_info.value) - - def test_decrypt_oauth_params_invalid_json(self): - """Test decryption with invalid JSON data""" - encrypter = SystemOAuthEncrypter("test_secret") - - # Create valid encrypted data but with invalid JSON content - iv = get_random_bytes(16) - cipher = AES.new(encrypter.key, AES.MODE_CBC, iv) - invalid_json = b"invalid json content" - padded_data = pad(invalid_json, AES.block_size) - encrypted_data = cipher.encrypt(padded_data) - combined = iv + encrypted_data - encoded = base64.b64encode(combined).decode() - - with pytest.raises(OAuthEncryptionError): - encrypter.decrypt_oauth_params(encoded) - - def test_key_derivation_consistency(self): - """Test that key derivation is consistent""" - secret_key = "test_secret" - encrypter1 = SystemOAuthEncrypter(secret_key) - encrypter2 = SystemOAuthEncrypter(secret_key) - - assert encrypter1.key == encrypter2.key - - # Keys should be 32 bytes (256 bits) - assert len(encrypter1.key) == 32 - - -class TestFactoryFunctions: - """Test cases for factory functions""" - - def test_create_system_oauth_encrypter_with_secret(self): - """Test factory function with secret key""" - secret_key = "test_secret" - encrypter = create_system_oauth_encrypter(secret_key) - - assert isinstance(encrypter, SystemOAuthEncrypter) - expected_key = hashlib.sha256(secret_key.encode()).digest() - assert encrypter.key == expected_key - - def test_create_system_oauth_encrypter_without_secret(self): - """Test factory function without secret key""" - with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config: - mock_config.SECRET_KEY = "config_secret" - encrypter = create_system_oauth_encrypter() - - assert isinstance(encrypter, SystemOAuthEncrypter) - expected_key = hashlib.sha256(b"config_secret").digest() - assert encrypter.key == expected_key - - def test_create_system_oauth_encrypter_with_none_secret(self): - """Test factory function with None secret key""" - with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config: - mock_config.SECRET_KEY = "config_secret" - encrypter = create_system_oauth_encrypter(None) - - assert isinstance(encrypter, SystemOAuthEncrypter) - expected_key = hashlib.sha256(b"config_secret").digest() - assert encrypter.key == expected_key - - -class TestGlobalEncrypterInstance: - """Test cases for global encrypter instance""" - - def test_get_system_oauth_encrypter_singleton(self): - """Test that get_system_oauth_encrypter returns singleton instance""" - # Clear the global instance first - import core.tools.utils.system_oauth_encryption - - core.tools.utils.system_oauth_encryption._oauth_encrypter = None - - encrypter1 = get_system_oauth_encrypter() - encrypter2 = get_system_oauth_encrypter() - - assert encrypter1 is encrypter2 - assert isinstance(encrypter1, SystemOAuthEncrypter) - - def test_get_system_oauth_encrypter_uses_config(self): - """Test that global encrypter uses config""" - # Clear the global instance first - import core.tools.utils.system_oauth_encryption - - core.tools.utils.system_oauth_encryption._oauth_encrypter = None - - with patch("core.tools.utils.system_oauth_encryption.dify_config") as mock_config: - mock_config.SECRET_KEY = "global_secret" - encrypter = get_system_oauth_encrypter() - - expected_key = hashlib.sha256(b"global_secret").digest() - assert encrypter.key == expected_key - - -class TestConvenienceFunctions: - """Test cases for convenience functions""" - - def test_encrypt_system_oauth_params(self): - """Test encrypt_system_oauth_params convenience function""" - oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} - - encrypted = encrypt_system_oauth_params(oauth_params) - - assert isinstance(encrypted, str) - assert len(encrypted) > 0 - - def test_decrypt_system_oauth_params(self): - """Test decrypt_system_oauth_params convenience function""" - oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} - - encrypted = encrypt_system_oauth_params(oauth_params) - decrypted = decrypt_system_oauth_params(encrypted) - - assert decrypted == oauth_params - - def test_convenience_functions_consistency(self): - """Test that convenience functions work consistently""" - test_cases = [ - {}, - {"simple": "value"}, - {"client_id": "id", "client_secret": "secret"}, - {"complex": {"nested": {"deep": "value"}}}, - {"unicode": "test 🚀"}, - {"numbers": 42, "boolean": True, "null": None}, - ] - - for original_params in test_cases: - encrypted = encrypt_system_oauth_params(original_params) - decrypted = decrypt_system_oauth_params(encrypted) - assert decrypted == original_params, f"Failed for case: {original_params}" - - def test_convenience_functions_with_errors(self): - """Test convenience functions with error conditions""" - # Test encryption with invalid input - with pytest.raises(Exception): # noqa: B017 - encrypt_system_oauth_params(None) - - # Test decryption with invalid input - with pytest.raises(ValueError): - decrypt_system_oauth_params("") - - with pytest.raises(ValueError): - decrypt_system_oauth_params(None) - - -class TestErrorHandling: - """Test cases for error handling""" - - def test_oauth_encryption_error_inheritance(self): - """Test that OAuthEncryptionError is a proper exception""" - error = OAuthEncryptionError("Test error") - assert isinstance(error, Exception) - assert str(error) == "Test error" - - def test_oauth_encryption_error_with_cause(self): - """Test OAuthEncryptionError with cause""" - original_error = ValueError("Original error") - error = OAuthEncryptionError("Wrapper error") - error.__cause__ = original_error - - assert isinstance(error, Exception) - assert str(error) == "Wrapper error" - assert error.__cause__ is original_error - - def test_error_messages_are_informative(self): - """Test that error messages are informative""" - encrypter = SystemOAuthEncrypter("test_secret") - - # Test empty string error - with pytest.raises(ValueError) as exc_info: - encrypter.decrypt_oauth_params("") - assert "encrypted_data cannot be empty" in str(exc_info.value) - - # Test non-string error - with pytest.raises(ValueError) as exc_info: - encrypter.decrypt_oauth_params(123) - assert "encrypted_data must be a string" in str(exc_info.value) - - # Test invalid format error - short_data = base64.b64encode(b"short").decode() - with pytest.raises(OAuthEncryptionError) as exc_info: - encrypter.decrypt_oauth_params(short_data) - assert "Invalid encrypted data format" in str(exc_info.value) - - -class TestEdgeCases: - """Test cases for edge cases and boundary conditions""" - - def test_very_long_secret_key(self): - """Test with very long secret key""" - long_secret = "x" * 10000 - encrypter = SystemOAuthEncrypter(long_secret) - - # Key should still be 32 bytes due to SHA-256 - assert len(encrypter.key) == 32 - - # Should still work normally - oauth_params = {"client_id": "test_id"} - encrypted = encrypter.encrypt_oauth_params(oauth_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == oauth_params - - def test_special_characters_in_secret_key(self): - """Test with special characters in secret key""" - special_secret = "!@#$%^&*()_+-=[]{}|;':\",./<>?`~test🚀" - encrypter = SystemOAuthEncrypter(special_secret) - - oauth_params = {"client_id": "test_id"} - encrypted = encrypter.encrypt_oauth_params(oauth_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == oauth_params - - def test_empty_values_in_oauth_params(self): - """Test with empty values in oauth params""" - oauth_params = { - "client_id": "", - "client_secret": "", - "empty_dict": {}, - "empty_list": [], - "empty_string": "", - "zero": 0, - "false": False, - "none": None, - } - - encrypter = SystemOAuthEncrypter("test_secret") - encrypted = encrypter.encrypt_oauth_params(oauth_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == oauth_params - - def test_deeply_nested_oauth_params(self): - """Test with deeply nested oauth params""" - oauth_params = {"level1": {"level2": {"level3": {"level4": {"level5": {"deep_value": "found"}}}}}} - - encrypter = SystemOAuthEncrypter("test_secret") - encrypted = encrypter.encrypt_oauth_params(oauth_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == oauth_params - - def test_oauth_params_with_all_json_types(self): - """Test with all JSON-supported data types""" - oauth_params = { - "string": "test_string", - "integer": 42, - "float": 3.14159, - "boolean_true": True, - "boolean_false": False, - "null_value": None, - "empty_string": "", - "array": [1, "two", 3.0, True, False, None], - "object": {"nested_string": "nested_value", "nested_number": 123, "nested_bool": True}, - } - - encrypter = SystemOAuthEncrypter("test_secret") - encrypted = encrypter.encrypt_oauth_params(oauth_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == oauth_params - - -class TestPerformance: - """Test cases for performance considerations""" - - def test_large_oauth_params(self): - """Test with large oauth params""" - large_value = "x" * 100000 # 100KB - oauth_params = {"client_id": "test_id", "large_data": large_value} - - encrypter = SystemOAuthEncrypter("test_secret") - encrypted = encrypter.encrypt_oauth_params(oauth_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == oauth_params - - def test_many_fields_oauth_params(self): - """Test with many fields in oauth params""" - oauth_params = {f"field_{i}": f"value_{i}" for i in range(1000)} - - encrypter = SystemOAuthEncrypter("test_secret") - encrypted = encrypter.encrypt_oauth_params(oauth_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == oauth_params - - def test_repeated_encryption_decryption(self): - """Test repeated encryption and decryption operations""" - encrypter = SystemOAuthEncrypter("test_secret") - oauth_params = {"client_id": "test_id", "client_secret": "test_secret"} - - # Test multiple rounds of encryption/decryption - for i in range(100): - encrypted = encrypter.encrypt_oauth_params(oauth_params) - decrypted = encrypter.decrypt_oauth_params(encrypted) - assert decrypted == oauth_params diff --git a/docker/.env.example b/docker/.env.example index ec7d572057..29741474fa 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -1467,6 +1467,11 @@ ENDPOINT_URL_TEMPLATE=http://localhost/e/{hook_id} MARKETPLACE_ENABLED=true MARKETPLACE_API_URL=https://marketplace.dify.ai +# Creators Platform configuration +CREATORS_PLATFORM_FEATURES_ENABLED=true +CREATORS_PLATFORM_API_URL=https://creators.dify.ai +CREATORS_PLATFORM_OAUTH_CLIENT_ID= + FORCE_VERIFYING_SIGNATURE=true ENFORCE_LANGGENIUS_PLUGIN_SIGNATURES=true diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index aaf099453a..60ba510f44 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -629,6 +629,9 @@ x-shared-env: &shared-api-worker-env ENDPOINT_URL_TEMPLATE: ${ENDPOINT_URL_TEMPLATE:-http://localhost/e/{hook_id}} MARKETPLACE_ENABLED: ${MARKETPLACE_ENABLED:-true} MARKETPLACE_API_URL: ${MARKETPLACE_API_URL:-https://marketplace.dify.ai} + CREATORS_PLATFORM_FEATURES_ENABLED: ${CREATORS_PLATFORM_FEATURES_ENABLED:-true} + CREATORS_PLATFORM_API_URL: ${CREATORS_PLATFORM_API_URL:-https://creators.dify.ai} + CREATORS_PLATFORM_OAUTH_CLIENT_ID: ${CREATORS_PLATFORM_OAUTH_CLIENT_ID:-} 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} diff --git a/web/app/account/oauth/authorize/page.tsx b/web/app/account/oauth/authorize/page.tsx index dd95dc04ba..55666db193 100644 --- a/web/app/account/oauth/authorize/page.tsx +++ b/web/app/account/oauth/authorize/page.tsx @@ -16,9 +16,9 @@ import { useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' import Loading from '@/app/components/base/loading' import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks' -import { setPostLoginRedirect } from '@/app/signin/utils/post-login-redirect' +import { setOAuthPendingRedirect } from '@/app/signin/utils/post-login-redirect' import { useRouter, useSearchParams } from '@/next/navigation' -import { isLegacyBase401, userProfileQueryOptions } from '@/service/use-common' +import { isLegacyBase401, useLogout, userProfileQueryOptions } from '@/service/use-common' import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth' function buildReturnUrl(pathname: string, search: string) { @@ -73,14 +73,17 @@ export default function OAuthAuthorize() { const userProfile = userProfileResp?.profile const { data: authAppInfo, isLoading: isOAuthLoading, isError } = useOAuthAppInfo(client_id, redirect_uri) const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp() + const { mutateAsync: logout } = useLogout() const hasNotifiedRef = useRef(false) const isLoading = isOAuthLoading || isProfileLoading - const onLoginSwitchClick = () => { + const onLoginSwitchClick = async () => { try { - const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`) - setPostLoginRedirect(returnUrl) - router.push('/signin') + const returnUrl = buildReturnUrl('/account/oauth/authorize', `?${searchParams.toString()}`) + setOAuthPendingRedirect(returnUrl) + if (isLoggedIn) + await logout() + router.push(`/signin?redirect_url=${encodeURIComponent(returnUrl)}`) } catch { router.push('/signin') diff --git a/web/app/components/app-initializer.tsx b/web/app/components/app-initializer.tsx index 2c50312590..3d2af1ce61 100644 --- a/web/app/components/app-initializer.tsx +++ b/web/app/components/app-initializer.tsx @@ -85,7 +85,7 @@ export const AppInitializer = ({ return } - const redirectUrl = resolvePostLoginRedirect() + const redirectUrl = resolvePostLoginRedirect(searchParams) if (redirectUrl) { location.replace(redirectUrl) return diff --git a/web/app/components/app/app-publisher/__tests__/index.spec.tsx b/web/app/components/app/app-publisher/__tests__/index.spec.tsx index aa9cda8e34..5df331767b 100644 --- a/web/app/components/app/app-publisher/__tests__/index.spec.tsx +++ b/web/app/components/app/app-publisher/__tests__/index.spec.tsx @@ -80,8 +80,11 @@ vi.mock('@/service/explore', () => ({ fetchInstalledAppList: (...args: unknown[]) => mockFetchInstalledAppList(...args), })) +const mockPublishToCreatorsPlatform = vi.fn() + vi.mock('@/service/apps', () => ({ fetchAppDetailDirect: (...args: unknown[]) => mockFetchAppDetailDirect(...args), + publishToCreatorsPlatform: (...args: unknown[]) => mockPublishToCreatorsPlatform(...args), })) vi.mock('@/service/use-workflow', () => ({ @@ -434,6 +437,76 @@ describe('AppPublisher', () => { }) }) + it('should show marketplace button and open redirect URL on success', async () => { + mockPublishToCreatorsPlatform.mockResolvedValue({ redirect_url: 'https://marketplace.example.com/publish?code=abc' }) + const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null) + + renderWithSystemFeatures( + , + { systemFeatures: { webapp_auth: { enabled: true }, enable_creators_platform: true } }, + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('common.publishToMarketplace')) + + await waitFor(() => { + expect(mockPublishToCreatorsPlatform).toHaveBeenCalledWith({ appID: 'app-1' }) + expect(windowOpenSpy).toHaveBeenCalledWith('https://marketplace.example.com/publish?code=abc', '_blank') + }) + + windowOpenSpy.mockRestore() + }) + + it('should show toast error when publish to marketplace fails', async () => { + mockPublishToCreatorsPlatform.mockRejectedValue(new Error('network error')) + + renderWithSystemFeatures( + , + { systemFeatures: { webapp_auth: { enabled: true }, enable_creators_platform: true } }, + ) + + fireEvent.click(screen.getByText('common.publish')) + fireEvent.click(screen.getByText('common.publishToMarketplace')) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('common.publishToMarketplaceFailed') + }) + }) + + it('should disable marketplace button when not yet published', () => { + renderWithSystemFeatures( + , + { systemFeatures: { webapp_auth: { enabled: true }, enable_creators_platform: true } }, + ) + + fireEvent.click(screen.getByText('common.publish')) + const marketplaceButton = screen.getByText('common.publishToMarketplace').closest('a, button, div[role="button"]') as HTMLElement + expect(marketplaceButton).toBeInTheDocument() + // clicking should not call the API because publishedAt is undefined + fireEvent.click(screen.getByText('common.publishToMarketplace')) + expect(mockPublishToCreatorsPlatform).not.toHaveBeenCalled() + }) + + it('should hide marketplace button when enable_creators_platform is false', () => { + render( + , + ) + + fireEvent.click(screen.getByText('common.publish')) + expect(screen.queryByText('common.publishToMarketplace')).not.toBeInTheDocument() + }) + it('should keep access control open when app detail is unavailable during confirmation', async () => { mockAppDetail = null diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index b85e888557..fe6fe5806f 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -5,6 +5,7 @@ import type { PublishWorkflowParams } from '@/types/workflow' import { Button } from '@langgenius/dify-ui/button' import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' +import { RiStoreLine } from '@remixicon/react' import { useSuspenseQuery } from '@tanstack/react-query' import { useKeyPress } from 'ahooks' import { @@ -26,7 +27,7 @@ import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now' import { AccessMode } from '@/models/access-control' import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control' -import { fetchAppDetailDirect } from '@/service/apps' +import { fetchAppDetailDirect, publishToCreatorsPlatform } from '@/service/apps' import { fetchInstalledAppList } from '@/service/explore' import { systemFeaturesQueryOptions } from '@/service/system-features' import { useInvalidateAppWorkflow } from '@/service/use-workflow' @@ -40,6 +41,7 @@ import { PublisherActionsSection, PublisherSummarySection, } from './sections' +import SuggestedAction from './suggested-action' import { getDisabledFunctionTooltip, getPublisherAppUrl, @@ -100,6 +102,7 @@ const AppPublisher = ({ const [showAppAccessControl, setShowAppAccessControl] = useState(false) const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false) + const [publishingToMarketplace, setPublishingToMarketplace] = useState(false) const workflowStore = useContext(WorkflowContext) const appDetail = useAppStore(state => state.appDetail) @@ -219,6 +222,23 @@ const AppPublisher = ({ } }, [appDetail, setAppDetail]) + const handlePublishToMarketplace = useCallback(async () => { + if (!appDetail?.id || publishingToMarketplace) + return + setPublishingToMarketplace(true) + try { + const res = await publishToCreatorsPlatform({ appID: appDetail.id }) + if (res.redirect_url) + window.open(res.redirect_url, '_blank') + } + catch { + toast.error(t('common.publishToMarketplaceFailed', { ns: 'workflow' })) + } + finally { + setPublishingToMarketplace(false) + } + }, [appDetail?.id, publishingToMarketplace, t]) + useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => { e.preventDefault() if (publishDisabled || published) @@ -336,6 +356,19 @@ const AppPublisher = ({ workflowToolAvailable={workflowToolAvailable} workflowToolMessage={workflowToolMessage} /> + {systemFeatures.enable_creators_platform && ( +
+ } + disabled={!publishedAt || publishingToMarketplace} + onClick={handlePublishToMarketplace} + > + {publishingToMarketplace + ? t('common.publishingToMarketplace', { ns: 'workflow' }) + : t('common.publishToMarketplace', { ns: 'workflow' })} + +
+ )}
{ />, ) - expect(screen.getByText('importFromDSL'))!.toBeInTheDocument() + expect(screen.getByText('importApp'))!.toBeInTheDocument() await waitFor(() => { expect(screen.getByText('demo.yml'))!.toBeInTheDocument() @@ -161,7 +161,7 @@ describe('CreateFromDSLModal', () => { }) expect(screen.getByPlaceholderText('importFromDSLUrlPlaceholder'))!.toBeInTheDocument() - const closeTrigger = screen.getByText('importFromDSL').parentElement?.querySelector('.cursor-pointer.items-center') as HTMLElement + const closeTrigger = screen.getByText('importApp').parentElement?.querySelector('.cursor-pointer.items-center') as HTMLElement fireEvent.click(closeTrigger) expect(handleClose).toHaveBeenCalledTimes(1) }) diff --git a/web/app/components/app/create-from-dsl-modal/index.tsx b/web/app/components/app/create-from-dsl-modal/index.tsx index 4f99fe9027..bc5f352634 100644 --- a/web/app/components/app/create-from-dsl-modal/index.tsx +++ b/web/app/components/app/create-from-dsl-modal/index.tsx @@ -225,7 +225,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS onClose={noop} >
- {t('importFromDSL', { ns: 'app' })} + {t('importApp', { ns: 'app' })}
onClose()} diff --git a/web/app/components/apps/__tests__/index.spec.tsx b/web/app/components/apps/__tests__/index.spec.tsx index 2e0d1bcc84..94fa9f3484 100644 --- a/web/app/components/apps/__tests__/index.spec.tsx +++ b/web/app/components/apps/__tests__/index.spec.tsx @@ -7,9 +7,21 @@ import { useContextSelector } from 'use-context-selector' import AppListContext from '@/context/app-list-context' import { fetchAppDetail } from '@/service/explore' import { AppModeEnum } from '@/types/app' - import Apps from '../index' +vi.mock('@/next/dynamic', () => ({ + default: (loader: () => Promise<{ default: React.ComponentType }>) => { + const LazyComp = React.lazy(loader) + return function DynamicWrapper(props: Record) { + return React.createElement( + React.Suspense, + { fallback: null }, + React.createElement(LazyComp, props), + ) + } + }, +})) + let documentTitleCalls: string[] = [] let educationInitCalls: number = 0 const mockHandleImportDSL = vi.fn() @@ -65,6 +77,16 @@ vi.mock('@/hooks/use-import-dsl', () => ({ }), })) +const mockReplace = vi.fn() +let mockSearchParams = new URLSearchParams() + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + replace: mockReplace, + }), + useSearchParams: () => mockSearchParams, +})) + vi.mock('../list', () => { const MockList = () => { const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel) @@ -129,6 +151,16 @@ vi.mock('../../app/create-from-dsl-modal/dsl-confirm-modal', () => ({ ), })) +vi.mock('../import-from-marketplace-template-modal', () => ({ + default: ({ templateId, onClose, onConfirm }: { templateId: string, onClose: () => void, onConfirm: (dsl: string) => void }) => ( +
+ {templateId} + + +
+ ), +})) + vi.mock('@/service/explore', () => ({ fetchAppDetail: vi.fn(), })) @@ -161,6 +193,8 @@ describe('Apps', () => { vi.clearAllMocks() documentTitleCalls = [] educationInitCalls = 0 + mockSearchParams = new URLSearchParams() + mockReplace.mockClear() mockFetchAppDetail.mockResolvedValue({ id: 'template-1', name: 'Sample App', @@ -304,6 +338,66 @@ describe('Apps', () => { }) }) + describe('Marketplace Template', () => { + it('should render the template modal when template-id is in search params', async () => { + mockSearchParams = new URLSearchParams('template-id=tpl-42') + renderWithClient() + + expect(await screen.findByTestId('marketplace-template-modal')).toBeInTheDocument() + expect(screen.getByTestId('template-id')).toHaveTextContent('tpl-42') + }) + + it('should not render the template modal when no template-id is present', () => { + renderWithClient() + + expect(screen.queryByTestId('marketplace-template-modal')).not.toBeInTheDocument() + }) + + it('should close the template modal and remove template-id from URL', async () => { + mockSearchParams = new URLSearchParams('template-id=tpl-42') + renderWithClient() + + fireEvent.click(await screen.findByTestId('close-template')) + + expect(mockReplace).toHaveBeenCalledTimes(1) + const replaceArg = mockReplace.mock.calls[0]![0] as string + expect(replaceArg).not.toContain('template-id') + }) + + it('should import DSL from marketplace template on confirm', async () => { + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onSuccess?: () => void }) => { + options.onSuccess?.() + }) + mockSearchParams = new URLSearchParams('template-id=tpl-42') + renderWithClient() + + fireEvent.click(await screen.findByTestId('confirm-template')) + + await waitFor(() => { + expect(mockHandleImportDSL).toHaveBeenCalledWith( + { mode: 'yaml-content', yaml_content: 'yaml-dsl-content' }, + expect.objectContaining({ onSuccess: expect.any(Function) }), + ) + expect(mockReplace).toHaveBeenCalled() + }) + }) + + it('should show DSL confirm modal when marketplace import is pending', async () => { + mockHandleImportDSL.mockImplementation(async (_payload: unknown, options: { onPending?: () => void }) => { + options.onPending?.() + }) + mockSearchParams = new URLSearchParams('template-id=tpl-42') + renderWithClient() + + fireEvent.click(await screen.findByTestId('confirm-template')) + + await waitFor(() => { + expect(screen.getByTestId('dsl-confirm-modal')).toBeInTheDocument() + expect(mockReplace).toHaveBeenCalled() + }) + }) + }) + describe('Styling', () => { it('should have overflow-y-auto class', () => { const { container } = renderWithClient() diff --git a/web/app/components/apps/import-from-marketplace-template-modal.tsx b/web/app/components/apps/import-from-marketplace-template-modal.tsx new file mode 100644 index 0000000000..a6a3dee8e4 --- /dev/null +++ b/web/app/components/apps/import-from-marketplace-template-modal.tsx @@ -0,0 +1,182 @@ +'use client' + +import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog' +import { toast } from '@langgenius/dify-ui/toast' +import { RiCloseLine } from '@remixicon/react' +import { useCallback, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { + fetchMarketplaceTemplateDSL, + useMarketplaceTemplateDetail, +} from '@/service/marketplace-templates' + +type ImportFromMarketplaceTemplateModalProps = { + templateId: string + onClose: () => void + onConfirm: (dslContent: string) => void +} + +const ImportFromMarketplaceTemplateModal = ({ + templateId, + onClose, + onConfirm, +}: ImportFromMarketplaceTemplateModalProps) => { + const { t } = useTranslation() + const { data, isLoading, isError } = useMarketplaceTemplateDetail(templateId) + const template = data?.data + const [importing, setImporting] = useState(false) + const isImportingRef = useRef(false) + + const CATEGORY_I18N_MAP: Record = useMemo(() => ({ + marketing: t('marketplace.template.category.marketing', { ns: 'app' }), + sales: t('marketplace.template.category.sales', { ns: 'app' }), + support: t('marketplace.template.category.support', { ns: 'app' }), + operations: t('marketplace.template.category.operations', { ns: 'app' }), + it: t('marketplace.template.category.it', { ns: 'app' }), + knowledge: t('marketplace.template.category.knowledge', { ns: 'app' }), + design: t('marketplace.template.category.design', { ns: 'app' }), + }), [t]) + + const translateCategory = useCallback((slug: string) => { + return CATEGORY_I18N_MAP[slug] ?? slug + }, [CATEGORY_I18N_MAP]) + + const handleConfirm = useCallback(async () => { + if (isImportingRef.current) + return + isImportingRef.current = true + setImporting(true) + try { + const dsl = await fetchMarketplaceTemplateDSL(templateId) + onConfirm(dsl) + } + catch { + toast.error(t('marketplace.template.importFailed', { ns: 'app' })) + } + finally { + setImporting(false) + isImportingRef.current = false + } + }, [templateId, onConfirm, t]) + + return ( + { + if (!open) + onClose() + }} + > + +
+ {t('marketplace.template.modalTitle', { ns: 'app' })} +
+ +
+
+ +
+ {isLoading && ( +
+
Loading...
+
+ )} + + {isError && ( +
+
+ {t('marketplace.template.fetchFailed', { ns: 'app' })} +
+
+ )} + + {template && ( +
+
+ {template.icon_file_key + ? ( + {template.template_name} + ) + : ( +
+ {template.icon || '📄'} +
+ )} +
+
{template.template_name}
+
+ + {t('marketplace.template.publishedBy', { ns: 'app' })} + {' '} + {template.publisher_unique_handle} + + · + + {t('marketplace.template.usageCount', { ns: 'app' })} + {' '} + {template.usage_count} + +
+
+
+ + {template.overview && ( +
+
+ {t('marketplace.template.overview', { ns: 'app' })} +
+
+ {template.overview} +
+
+ )} + + {template.categories.length > 0 && ( +
+ {template.categories.map(cat => ( + + {translateCategory(cat)} + + ))} +
+ )} +
+ )} +
+ +
+ + +
+
+
+ ) +} + +export default ImportFromMarketplaceTemplateModal diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index 9bf07e81e6..9d74968605 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -9,6 +9,7 @@ import useDocumentTitle from '@/hooks/use-document-title' import { useImportDSL } from '@/hooks/use-import-dsl' import { DSLImportMode } from '@/models/app' import dynamic from '@/next/dynamic' +import { useRouter, useSearchParams } from '@/next/navigation' import { fetchAppDetail } from '@/service/explore' import { trackCreateApp } from '@/utils/create-app-tracking' import List from './list' @@ -16,9 +17,14 @@ import List from './list' const DSLConfirmModal = dynamic(() => import('../app/create-from-dsl-modal/dsl-confirm-modal'), { ssr: false }) const CreateAppModal = dynamic(() => import('../explore/create-app-modal'), { ssr: false }) const TryApp = dynamic(() => import('../explore/try-app'), { ssr: false }) +const ImportFromMarketplaceTemplateModal = dynamic(() => import('./import-from-marketplace-template-modal'), { ssr: false }) const Apps = () => { const { t } = useTranslation() + const searchParams = useSearchParams() + const { replace } = useRouter() + const templateId = searchParams.get('template-id') + const templateDismissedRef = useRef(false) useDocumentTitle(t('menus.apps', { ns: 'common' })) useEducationInit() @@ -58,6 +64,14 @@ const Apps = () => { const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false) + const handleCloseTemplateModal = useCallback(() => { + templateDismissedRef.current = true + const params = new URLSearchParams(searchParams.toString()) + params.delete('template-id') + const query = params.toString() + replace(query ? `?${query}` : window.location.pathname, { scroll: false }) + }, [searchParams, replace]) + const { handleImportDSL, handleImportDSLConfirm, @@ -74,6 +88,22 @@ const Apps = () => { }) }, [handleImportDSLConfirm, onSuccess, trackCurrentCreateApp]) + const handleMarketplaceTemplateConfirm = useCallback(async (dslContent: string) => { + await handleImportDSL({ + mode: DSLImportMode.YAML_CONTENT, + yaml_content: dslContent, + }, { + onSuccess: () => { + handleCloseTemplateModal() + onSuccess() + }, + onPending: () => { + handleCloseTemplateModal() + setShowDSLConfirmModal(true) + }, + }) + }, [handleImportDSL, handleCloseTemplateModal, onSuccess]) + const onCreate: CreateAppModalProps['onConfirm'] = useCallback(async ({ name, icon_type, @@ -152,6 +182,14 @@ const Apps = () => { onHide={() => setIsShowCreateModal(false)} /> )} + + {templateId && !templateDismissedRef.current && ( + + )}
) diff --git a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx index 7a02781c17..08ac245172 100644 --- a/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx +++ b/web/app/components/workflow/__tests__/panel-contextmenu.spec.tsx @@ -156,7 +156,7 @@ describe('PanelContextmenu', () => { fireEvent.click(screen.getByText('common.run')) fireEvent.click(screen.getByText('common.pasteHere')) fireEvent.click(screen.getByText('export')) - fireEvent.click(screen.getByText('common.importDSL')) + fireEvent.click(screen.getByText('importApp')) clickAwayHandler?.() expect(mockHandleAddNote).toHaveBeenCalledTimes(1) diff --git a/web/app/components/workflow/panel-contextmenu.tsx b/web/app/components/workflow/panel-contextmenu.tsx index ffe88d3dc9..4478839077 100644 --- a/web/app/components/workflow/panel-contextmenu.tsx +++ b/web/app/components/workflow/panel-contextmenu.tsx @@ -137,7 +137,7 @@ const PanelContextmenu = () => { className="flex h-8 cursor-pointer items-center justify-between rounded-lg px-3 text-sm text-text-secondary hover:bg-state-base-hover" onClick={() => setShowImportDSLModal(true)} > - {t('common.importDSL', { ns: 'workflow' })} + {t('importApp', { ns: 'app' })}
diff --git a/web/app/components/workflow/update-dsl-modal.tsx b/web/app/components/workflow/update-dsl-modal.tsx index cfa9c995eb..549dee487f 100644 --- a/web/app/components/workflow/update-dsl-modal.tsx +++ b/web/app/components/workflow/update-dsl-modal.tsx @@ -205,7 +205,7 @@ const UpdateDSLModal = ({ onClose={onCancel} >
-
{t('common.importDSL', { ns: 'workflow' })}
+
{t('importApp', { ns: 'app' })}
diff --git a/web/app/page.tsx b/web/app/page.tsx index 65f8827e01..a866fd4c39 100644 --- a/web/app/page.tsx +++ b/web/app/page.tsx @@ -1,18 +1,23 @@ -import Loading from '@/app/components/base/loading' -import Link from '@/next/link' +import { redirect } from '@/next/navigation' -const Home = async () => { - return ( -
+type HomePageProps = { + searchParams: Promise> +} -
- -
- 🚀 -
-
-
- ) +const Home = async ({ searchParams }: HomePageProps) => { + const resolvedSearchParams = await searchParams + const urlSearchParams = new URLSearchParams() + Object.entries(resolvedSearchParams).forEach(([key, value]) => { + if (value === undefined) + return + if (Array.isArray(value)) { + value.forEach(item => urlSearchParams.append(key, item)) + return + } + urlSearchParams.set(key, value) + }) + const queryString = urlSearchParams.toString() + redirect(queryString ? `/apps?${queryString}` : '/apps') } export default Home diff --git a/web/app/signin/check-code/page.tsx b/web/app/signin/check-code/page.tsx index fb52e0b5b7..42024c561b 100644 --- a/web/app/signin/check-code/page.tsx +++ b/web/app/signin/check-code/page.tsx @@ -51,7 +51,7 @@ export default function CheckCode() { router.replace(`/signin/invite-settings?${searchParams.toString()}`) } else { - const redirectUrl = resolvePostLoginRedirect() + const redirectUrl = resolvePostLoginRedirect(searchParams) router.replace(redirectUrl || '/apps') } } diff --git a/web/app/signin/components/mail-and-password-auth.tsx b/web/app/signin/components/mail-and-password-auth.tsx index 6feaf11426..30bc78666c 100644 --- a/web/app/signin/components/mail-and-password-auth.tsx +++ b/web/app/signin/components/mail-and-password-auth.tsx @@ -75,7 +75,7 @@ export default function MailAndPasswordAuth({ isInvite, isEmailSetup, allowRegis router.replace(`/signin/invite-settings?${searchParams.toString()}`) } else { - const redirectUrl = resolvePostLoginRedirect() + const redirectUrl = resolvePostLoginRedirect(searchParams) router.replace(redirectUrl || '/apps') } } diff --git a/web/app/signin/invite-settings/page.tsx b/web/app/signin/invite-settings/page.tsx index 7066ab041c..43ca96ab05 100644 --- a/web/app/signin/invite-settings/page.tsx +++ b/web/app/signin/invite-settings/page.tsx @@ -65,7 +65,7 @@ export default function InviteSettingsPage() { if (res.result === 'success') { // Tokens are now stored in cookies by the backend await setLocaleOnClient(language!, false) - const redirectUrl = resolvePostLoginRedirect() + const redirectUrl = resolvePostLoginRedirect(searchParams) router.replace(redirectUrl || '/apps') } } diff --git a/web/app/signin/normal-form.tsx b/web/app/signin/normal-form.tsx index 779aba5c9c..a32c7e9b3d 100644 --- a/web/app/signin/normal-form.tsx +++ b/web/app/signin/normal-form.tsx @@ -49,7 +49,7 @@ const NormalForm = () => { try { if (isLoggedIn) { setIsRedirecting(true) - const redirectUrl = resolvePostLoginRedirect() + const redirectUrl = resolvePostLoginRedirect(searchParams) router.replace(redirectUrl || '/apps') return } diff --git a/web/app/signin/utils/post-login-redirect.ts b/web/app/signin/utils/post-login-redirect.ts index a94fb2ad79..0015296a41 100644 --- a/web/app/signin/utils/post-login-redirect.ts +++ b/web/app/signin/utils/post-login-redirect.ts @@ -1,15 +1,63 @@ -let postLoginRedirect: string | null = null +import type { ReadonlyURLSearchParams } from '@/next/navigation' -export const setPostLoginRedirect = (value: string | null) => { - postLoginRedirect = value +const OAUTH_AUTHORIZE_PENDING_KEY = 'oauth_authorize_pending_redirect' +const REDIRECT_URL_KEY = 'redirect_url' + +type OAuthPendingRedirect = { + value?: string + expiry?: number } -export const resolvePostLoginRedirect = () => { - if (postLoginRedirect) { - const redirectUrl = postLoginRedirect - postLoginRedirect = null - return redirectUrl +const getCurrentUnixTimestamp = () => Math.floor(Date.now() / 1000) + +function removeOAuthPendingRedirect() { + try { + localStorage.removeItem(OAUTH_AUTHORIZE_PENDING_KEY) } - - return null + catch {} +} + +function getOAuthPendingRedirect(): string | null { + try { + const raw = localStorage.getItem(OAUTH_AUTHORIZE_PENDING_KEY) + if (!raw) + return null + removeOAuthPendingRedirect() + const item: OAuthPendingRedirect = JSON.parse(raw) + if (!item.value || typeof item.expiry !== 'number') + return null + return getCurrentUnixTimestamp() > item.expiry ? null : item.value + } + catch { + removeOAuthPendingRedirect() + return null + } +} + +export function setOAuthPendingRedirect(url: string, ttlSeconds: number = 300) { + try { + const item: OAuthPendingRedirect = { + value: url, + expiry: getCurrentUnixTimestamp() + ttlSeconds, + } + localStorage.setItem(OAUTH_AUTHORIZE_PENDING_KEY, JSON.stringify(item)) + } + catch {} +} + +export const resolvePostLoginRedirect = (searchParams?: ReadonlyURLSearchParams) => { + if (searchParams) { + const redirectUrl = searchParams.get(REDIRECT_URL_KEY) + if (redirectUrl) { + try { + removeOAuthPendingRedirect() + return decodeURIComponent(redirectUrl) + } + catch { + removeOAuthPendingRedirect() + return redirectUrl + } + } + } + return getOAuthPendingRedirect() } diff --git a/web/contract/marketplace.ts b/web/contract/marketplace.ts index 3573ba5c24..9f2475041e 100644 --- a/web/contract/marketplace.ts +++ b/web/contract/marketplace.ts @@ -1,5 +1,6 @@ import type { CollectionsAndPluginsSearchParams, MarketplaceCollection, PluginsSearchParams } from '@/app/components/plugins/marketplace/types' import type { Plugin, PluginsFromMarketplaceResponse } from '@/app/components/plugins/types' +import type { MarketplaceTemplate } from '@/types/marketplace-template' import { type } from '@orpc/contract' import { base } from './base' @@ -54,3 +55,15 @@ export const searchAdvancedContract = base body: Omit }>()) .output(type<{ data: PluginsFromMarketplaceResponse }>()) + +export const templateDetailContract = base + .route({ + path: '/templates/{templateId}', + method: 'GET', + }) + .input(type<{ + params: { + templateId: string + } + }>()) + .output(type<{ data: MarketplaceTemplate }>()) diff --git a/web/contract/router.ts b/web/contract/router.ts index 45f514a820..086b94f248 100644 --- a/web/contract/router.ts +++ b/web/contract/router.ts @@ -42,12 +42,13 @@ import { workflowDraftUpdateFeaturesContract, } from './console/workflow' import { workflowCommentContracts } from './console/workflow-comment' -import { collectionPluginsContract, collectionsContract, searchAdvancedContract } from './marketplace' +import { collectionPluginsContract, collectionsContract, searchAdvancedContract, templateDetailContract } from './marketplace' export const marketplaceRouterContract = { collections: collectionsContract, collectionPlugins: collectionPluginsContract, searchAdvanced: searchAdvancedContract, + templateDetail: templateDetailContract, } export type MarketPlaceInputs = InferContractRouterInputs diff --git a/web/i18n/en-US/app.json b/web/i18n/en-US/app.json index 0ad608d53c..0efa33de07 100644 --- a/web/i18n/en-US/app.json +++ b/web/i18n/en-US/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "Emoji", "iconPicker.image": "Image", "iconPicker.ok": "OK", + "importApp": "Import App", "importDSL": "Import DSL file", "importFromDSL": "Import from DSL", "importFromDSLFile": "From DSL file", "importFromDSLUrl": "From URL", "importFromDSLUrlPlaceholder": "Paste DSL link here", "join": "Join the community", + "marketplace.template.categories": "Categories", + "marketplace.template.category.design": "Design", + "marketplace.template.category.it": "IT", + "marketplace.template.category.knowledge": "Knowledge", + "marketplace.template.category.marketing": "Marketing", + "marketplace.template.category.operations": "Operations", + "marketplace.template.category.sales": "Sales", + "marketplace.template.category.support": "Support", + "marketplace.template.fetchFailed": "Failed to fetch template", + "marketplace.template.importConfirm": "Import", + "marketplace.template.importFailed": "Failed to import template", + "marketplace.template.modalTitle": "Import from Marketplace", + "marketplace.template.overview": "Overview", + "marketplace.template.publishedBy": "By", + "marketplace.template.usageCount": "Usage", + "marketplace.template.viewOnMarketplace": "View on Marketplace", "maxActiveRequests": "Max concurrent requests", "maxActiveRequestsPlaceholder": "Enter 0 for unlimited", "maxActiveRequestsTip": "Maximum number of concurrent active requests per app (0 for unlimited)", diff --git a/web/i18n/en-US/workflow.json b/web/i18n/en-US/workflow.json index 23516274a9..3bb285d501 100644 --- a/web/i18n/en-US/workflow.json +++ b/web/i18n/en-US/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "Enter content in the box below to start debugging the Chatbot", "common.processData": "Process Data", "common.publish": "Publish", + "common.publishToMarketplace": "Publish to Marketplace", + "common.publishToMarketplaceFailed": "Failed to publish to Marketplace", "common.publishUpdate": "Publish Update", "common.published": "Published", "common.publishedAt": "Published", + "common.publishingToMarketplace": "Publishing...", "common.redo": "Redo", "common.restart": "Restart", "common.restore": "Restore", diff --git a/web/i18n/zh-Hans/app.json b/web/i18n/zh-Hans/app.json index 278a1b782d..8f46a4433e 100644 --- a/web/i18n/zh-Hans/app.json +++ b/web/i18n/zh-Hans/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "表情符号", "iconPicker.image": "图片", "iconPicker.ok": "确认", + "importApp": "导入应用", "importDSL": "导入 DSL 文件", "importFromDSL": "导入 DSL", "importFromDSLFile": "文件", "importFromDSLUrl": "URL", "importFromDSLUrlPlaceholder": "输入 DSL 文件的 URL", "join": "参与社区", + "marketplace.template.categories": "分类", + "marketplace.template.category.design": "设计", + "marketplace.template.category.it": "IT", + "marketplace.template.category.knowledge": "知识", + "marketplace.template.category.marketing": "营销", + "marketplace.template.category.operations": "运营", + "marketplace.template.category.sales": "销售", + "marketplace.template.category.support": "支持", + "marketplace.template.fetchFailed": "获取模板失败", + "marketplace.template.importConfirm": "导入", + "marketplace.template.importFailed": "导入模板失败", + "marketplace.template.modalTitle": "从市场导入", + "marketplace.template.overview": "概述", + "marketplace.template.publishedBy": "来自", + "marketplace.template.usageCount": "使用次数", + "marketplace.template.viewOnMarketplace": "在市场查看", "maxActiveRequests": "最大活跃请求数", "maxActiveRequestsPlaceholder": "0 表示不限制", "maxActiveRequestsTip": "当前应用的最大活跃请求数(0 表示不限制)", diff --git a/web/i18n/zh-Hans/workflow.json b/web/i18n/zh-Hans/workflow.json index 593a88f4db..ac3a27af11 100644 --- a/web/i18n/zh-Hans/workflow.json +++ b/web/i18n/zh-Hans/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "在下面的框中输入内容开始调试聊天机器人", "common.processData": "数据处理", "common.publish": "发布", + "common.publishToMarketplace": "发布到市场", + "common.publishToMarketplaceFailed": "发布到市场失败", "common.publishUpdate": "发布更新", "common.published": "已发布", "common.publishedAt": "发布于", + "common.publishingToMarketplace": "发布中...", "common.redo": "重做", "common.restart": "重新开始", "common.restore": "恢复", diff --git a/web/next/navigation.ts b/web/next/navigation.ts index ec7c112645..f8ff821d1f 100644 --- a/web/next/navigation.ts +++ b/web/next/navigation.ts @@ -1,4 +1,5 @@ export { + redirect, useParams, usePathname, useRouter, @@ -6,3 +7,4 @@ export { useSelectedLayoutSegment, useSelectedLayoutSegments, } from 'next/navigation' +export type { ReadonlyURLSearchParams } from 'next/navigation' diff --git a/web/service/__tests__/base.spec.ts b/web/service/__tests__/base.spec.ts new file mode 100644 index 0000000000..a4d1dcbfe7 --- /dev/null +++ b/web/service/__tests__/base.spec.ts @@ -0,0 +1,68 @@ +import { buildSigninUrlWithRedirect } from '../base' + +vi.mock('@/utils/var', () => ({ + basePath: '/app', + API_PREFIX: '/console/api', + PUBLIC_API_PREFIX: '/api', + IS_CE_EDITION: false, +})) + +describe('buildSigninUrlWithRedirect', () => { + const originalLocation = globalThis.location + + beforeEach(() => { + Object.defineProperty(globalThis, 'location', { + value: { + origin: 'https://example.com', + pathname: '/apps', + href: 'https://example.com/apps', + }, + writable: true, + configurable: true, + }) + }) + + afterEach(() => { + Object.defineProperty(globalThis, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }) + }) + + it('should return plain signin URL for non-OAuth pages', () => { + const url = buildSigninUrlWithRedirect() + expect(url).toBe('https://example.com/app/signin') + }) + + it('should append redirect_url for OAuth authorize pages', () => { + const oauthHref = 'https://example.com/account/oauth/authorize?client_id=abc&state=xyz' + Object.defineProperty(globalThis, 'location', { + value: { + origin: 'https://example.com', + pathname: '/account/oauth/authorize', + href: oauthHref, + }, + writable: true, + configurable: true, + }) + + const url = buildSigninUrlWithRedirect() + expect(url).toBe(`https://example.com/app/signin?redirect_url=${encodeURIComponent(oauthHref)}`) + }) + + it('should not include redirect_url for other paths containing partial match', () => { + Object.defineProperty(globalThis, 'location', { + value: { + origin: 'https://example.com', + pathname: '/settings/oauth', + href: 'https://example.com/settings/oauth', + }, + writable: true, + configurable: true, + }) + + const url = buildSigninUrlWithRedirect() + expect(url).toBe('https://example.com/app/signin') + }) +}) diff --git a/web/service/apps.ts b/web/service/apps.ts index b6a5386fe0..d2c6593a34 100644 --- a/web/service/apps.ts +++ b/web/service/apps.ts @@ -192,3 +192,11 @@ export const updateTracingConfig = ({ appId, body }: { appId: string, body: Trac export const removeTracingConfig = ({ appId, provider }: { appId: string, provider: TracingProvider }): Promise => { return del(`/apps/${appId}/trace-config?tracing_provider=${provider}`) } + +type PublishToCreatorsPlatformResponse = { + redirect_url: string +} + +export const publishToCreatorsPlatform = ({ appID }: { appID: string }): Promise => { + return post(`apps/${appID}/publish-to-creators-platform`, { body: {} }) +} diff --git a/web/service/base.ts b/web/service/base.ts index 64d13ef59a..d1ef06c314 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -140,6 +140,20 @@ function jumpTo(url: string) { globalThis.location.href = url } +const OAUTH_AUTHORIZE_PATH = '/account/oauth/authorize' + +export const buildSigninUrlWithRedirect = (): string => { + const loginUrl = `${globalThis.location.origin}${basePath}/signin` + + // Only preserve redirect URL for OAuth authorize pages + if (globalThis.location.pathname.includes(OAUTH_AUTHORIZE_PATH)) { + const currentUrl = globalThis.location.href + return `${loginUrl}?redirect_url=${encodeURIComponent(currentUrl)}` + } + + return loginUrl +} + function unicodeToChar(text: string) { if (!text) return '' @@ -795,14 +809,14 @@ export const request = async(url: string, options = {}, otherOptions?: IOther if (refreshErr === null) return baseFetch(url, options, otherOptionsForBaseFetch) if (location.pathname !== `${basePath}/signin` || !IS_CE_EDITION) { - jumpTo(loginUrl) + jumpTo(buildSigninUrlWithRedirect()) return Promise.reject(err) } if (!silent) { toast.error(message) return Promise.reject(err) } - jumpTo(loginUrl) + jumpTo(buildSigninUrlWithRedirect()) return Promise.reject(err) } else { diff --git a/web/service/marketplace-templates.ts b/web/service/marketplace-templates.ts new file mode 100644 index 0000000000..d9ff7f314f --- /dev/null +++ b/web/service/marketplace-templates.ts @@ -0,0 +1,18 @@ +import { useQuery } from '@tanstack/react-query' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { marketplaceQuery } from './client' + +export const useMarketplaceTemplateDetail = (templateId: string | null) => { + return useQuery({ + ...marketplaceQuery.templateDetail.queryOptions({ input: { params: { templateId: templateId ?? '' } } }), + enabled: !!templateId, + }) +} + +export const fetchMarketplaceTemplateDSL = async (templateId: string): Promise => { + const url = `${MARKETPLACE_API_PREFIX}/templates/${templateId}/dsl` + const response = await fetch(url) + if (!response.ok) + throw new Error(`Failed to fetch DSL: ${response.statusText}`) + return response.text() +} diff --git a/web/types/feature.ts b/web/types/feature.ts index 635221f2be..77d4045318 100644 --- a/web/types/feature.ts +++ b/web/types/feature.ts @@ -64,6 +64,7 @@ export type SystemFeatures = { allow_email_code_login: boolean allow_email_password_login: boolean } + enable_creators_platform: boolean enable_trial_app: boolean enable_explore_banner: boolean } @@ -108,6 +109,7 @@ export const defaultSystemFeatures: SystemFeatures = { allow_email_code_login: false, allow_email_password_login: false, }, + enable_creators_platform: false, enable_trial_app: false, enable_explore_banner: false, } diff --git a/web/types/marketplace-template.ts b/web/types/marketplace-template.ts new file mode 100644 index 0000000000..ac2b7cb2aa --- /dev/null +++ b/web/types/marketplace-template.ts @@ -0,0 +1,11 @@ +export type MarketplaceTemplate = { + id: string + template_name: string + overview: string + icon: string + icon_background: string + icon_file_key: string + publisher_unique_handle: string + usage_count: number + categories: string[] +} From 9bd5c2f8ec55928d4ecdb0f4c066895ae009f770 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Fri, 24 Apr 2026 15:59:37 +0800 Subject: [PATCH 007/128] fix: app icon could not only change background (#35537) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 5 ----- .../app-icon-picker/__tests__/index.spec.tsx | 19 ++++++++++++++-- .../components/base/app-icon-picker/index.tsx | 14 +++++++++++- .../components/base/emoji-picker/Inner.tsx | 11 ++++++---- .../emoji-picker/__tests__/Inner.spec.tsx | 9 ++++++++ .../create-app-modal/__tests__/index.spec.tsx | 22 ++++++------------- .../explore/create-app-modal/index.tsx | 6 ++--- 7 files changed, 56 insertions(+), 30 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 943d38878e..0da7515286 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1080,11 +1080,6 @@ "count": 1 } }, - "web/app/components/base/emoji-picker/Inner.tsx": { - "react/set-state-in-effect": { - "count": 1 - } - }, "web/app/components/base/emoji-picker/index.tsx": { "no-restricted-imports": { "count": 1 diff --git a/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx b/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx index 07dd809f41..7f452e64e9 100644 --- a/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx +++ b/web/app/components/base/app-icon-picker/__tests__/index.spec.tsx @@ -1,3 +1,4 @@ +import type { ComponentProps } from 'react' import type { Area } from 'react-easy-crop' import type { ImageFile } from '@/types/app' import { fireEvent, render, screen, waitFor } from '@testing-library/react' @@ -122,11 +123,11 @@ describe('AppIconPicker', () => { }) } - const renderPicker = () => { + const renderPicker = (props: Partial> = {}) => { const onSelect = vi.fn() const onClose = vi.fn() - const { container } = render() + const { container } = render() return { onSelect, onClose, container } } @@ -220,6 +221,20 @@ describe('AppIconPicker', () => { expect(onSelect).not.toHaveBeenCalled() }) + + it('should submit the initial emoji when provided', async () => { + const { onSelect } = renderPicker({ initialEmoji: { icon: 'rabbit', background: '#E4FBCC' } }) + + await userEvent.click(screen.getByText(/ok/i)) + + await waitFor(() => { + expect(onSelect).toHaveBeenCalledWith({ + type: 'emoji', + icon: 'rabbit', + background: '#E4FBCC', + }) + }) + }) }) describe('Image Upload', () => { diff --git a/web/app/components/base/app-icon-picker/index.tsx b/web/app/components/base/app-icon-picker/index.tsx index 77bc0cd434..64a88f16e1 100644 --- a/web/app/components/base/app-icon-picker/index.tsx +++ b/web/app/components/base/app-icon-picker/index.tsx @@ -34,12 +34,17 @@ export type AppIconSelection = AppIconEmojiSelection | AppIconImageSelection type AppIconPickerProps = { onSelect?: (payload: AppIconSelection) => void onClose?: () => void + initialEmoji?: { + icon: string + background?: string | null + } className?: string } const AppIconPicker: FC = ({ onSelect, onClose, + initialEmoji, className, }) => { const { t } = useTranslation() @@ -138,7 +143,14 @@ const AppIconPicker: FC = ({
)} - {activeTab === 'emoji' && } + {activeTab === 'emoji' && ( + + )} {activeTab === 'image' && } diff --git a/web/app/components/base/emoji-picker/Inner.tsx b/web/app/components/base/emoji-picker/Inner.tsx index e2595c5efb..36a98f7dd1 100644 --- a/web/app/components/base/emoji-picker/Inner.tsx +++ b/web/app/components/base/emoji-picker/Inner.tsx @@ -45,20 +45,21 @@ type IEmojiPickerInnerProps = { } const EmojiPickerInner: FC = ({ + emoji, + background, onSelect, className, }) => { const { categories } = data as EmojiMartData - const [selectedEmoji, setSelectedEmoji] = useState('') - const [selectedBackground, setSelectedBackground] = useState(backgroundColors[0]) - const [showStyleColors, setShowStyleColors] = useState(false) + const [selectedEmoji, setSelectedEmoji] = useState(emoji || '') + const [selectedBackground, setSelectedBackground] = useState(background || backgroundColors[0]) + const [showStyleColors, setShowStyleColors] = useState(!!emoji) const [searchedEmojis, setSearchedEmojis] = useState([]) const [isSearching, setIsSearching] = useState(false) React.useEffect(() => { if (selectedEmoji) { - setShowStyleColors(true) /* v8 ignore next 2 - @preserve */ if (selectedBackground) onSelect?.(selectedEmoji, selectedBackground) @@ -105,6 +106,7 @@ const EmojiPickerInner: FC = ({ className="inline-flex h-10 w-10 items-center justify-center rounded-lg" onClick={() => { setSelectedEmoji(emoji) + setShowStyleColors(true) }} >
@@ -130,6 +132,7 @@ const EmojiPickerInner: FC = ({ className="inline-flex h-10 w-10 items-center justify-center rounded-lg" onClick={() => { setSelectedEmoji(emoji) + setShowStyleColors(true) }} >
diff --git a/web/app/components/base/emoji-picker/__tests__/Inner.spec.tsx b/web/app/components/base/emoji-picker/__tests__/Inner.spec.tsx index f0cf3091d7..41683d7af3 100644 --- a/web/app/components/base/emoji-picker/__tests__/Inner.spec.tsx +++ b/web/app/components/base/emoji-picker/__tests__/Inner.spec.tsx @@ -45,6 +45,15 @@ describe('EmojiPickerInner', () => { expect(screen.getByText('food'))!.toBeInTheDocument() expect(screen.getByPlaceholderText('Search emojis...'))!.toBeInTheDocument() }) + + it('initializes selected emoji and background when provided', async () => { + render() + + expect(screen.getByText('Choose Style'))!.toBeInTheDocument() + await waitFor(() => { + expect(mockOnSelect).toHaveBeenCalledWith('rabbit', '#E4FBCC') + }) + }) }) describe('User Interactions', () => { diff --git a/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx index 5a14807478..882dc8f9a0 100644 --- a/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx +++ b/web/app/components/explore/create-app-modal/__tests__/index.spec.tsx @@ -359,7 +359,7 @@ describe('CreateAppModal', () => { } }) - it('should reset emoji icon to initial props when picker is cancelled', async () => { + it('should allow changing only the background for the current emoji icon', async () => { vi.useFakeTimers() try { const { onConfirm } = await setup({ @@ -370,22 +370,14 @@ describe('CreateAppModal', () => { fireEvent.click(getAppIconTrigger()) - const categoryLabel = screen.getByText('people') - const emojiGrid = categoryLabel.nextElementSibling - const clickableEmojiWrapper = emojiGrid?.firstElementChild - if (!(clickableEmojiWrapper instanceof HTMLElement)) - throw new Error('Failed to locate emoji wrapper') - fireEvent.click(clickableEmojiWrapper) + const colorOption = Array.from(document.querySelectorAll('[style^="background:"]')) + .find(element => element.getAttribute('style')?.includes('#E4FBCC')) + if (!(colorOption instanceof HTMLElement) || !(colorOption.parentElement instanceof HTMLElement)) + throw new Error('Failed to locate background color option') + fireEvent.click(colorOption.parentElement) fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.ok' })) - expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument() - - fireEvent.click(getAppIconTrigger()) - fireEvent.click(screen.getByRole('button', { name: 'app.iconPicker.cancel' })) - - expect(screen.queryByRole('button', { name: 'app.iconPicker.cancel' })).not.toBeInTheDocument() - fireEvent.click(screen.getByRole('button', { name: /common\.operation\.create/ })) await act(async () => { vi.advanceTimersByTime(300) @@ -396,7 +388,7 @@ describe('CreateAppModal', () => { expect(payload).toMatchObject({ icon_type: 'emoji', icon: '🤖', - icon_background: '#FFEAD5', + icon_background: '#E4FBCC', }) } finally { diff --git a/web/app/components/explore/create-app-modal/index.tsx b/web/app/components/explore/create-app-modal/index.tsx index ebe5a79a16..a7c9e06655 100644 --- a/web/app/components/explore/create-app-modal/index.tsx +++ b/web/app/components/explore/create-app-modal/index.tsx @@ -206,14 +206,14 @@ const CreateAppModal = ({ {showAppIconPicker && ( { setAppIcon(payload) setShowAppIconPicker(false) }} onClose={() => { - setAppIcon(appIconType === 'image' - ? { type: 'image' as const, url: appIconUrl, fileId: _appIcon } - : { type: 'emoji' as const, icon: _appIcon, background: appIconBackground }) setShowAppIconPicker(false) }} /> From 2d09c4788d5255c5132ffdabf6693b16f26b05c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Fri, 24 Apr 2026 16:02:30 +0800 Subject: [PATCH 008/128] fix: suggest questions more max_tokens (#35533) --- api/core/llm_generator/llm_generator.py | 6 ++---- .../suggested_questions_after_answer.py | 9 ++++++++- api/core/llm_generator/prompts.py | 3 --- .../core/llm_generator/test_llm_generator.py | 12 ++++-------- 4 files changed, 14 insertions(+), 16 deletions(-) diff --git a/api/core/llm_generator/llm_generator.py b/api/core/llm_generator/llm_generator.py index 6454f4f0dc..af2611bb0b 100644 --- a/api/core/llm_generator/llm_generator.py +++ b/api/core/llm_generator/llm_generator.py @@ -13,8 +13,6 @@ from core.llm_generator.output_parser.rule_config_generator import RuleConfigGen from core.llm_generator.output_parser.suggested_questions_after_answer import SuggestedQuestionsAfterAnswerOutputParser from core.llm_generator.prompts import ( CONVERSATION_TITLE_PROMPT, - DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS, - DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE, GENERATOR_QA_PROMPT, JAVASCRIPT_CODE_GENERATOR_PROMPT_TEMPLATE, LLM_MODIFY_CODE_SYSTEM, @@ -217,8 +215,8 @@ class LLMGenerator: else: # Default-model generation keeps the built-in suggested-questions tuning. model_parameters = { - "max_tokens": DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS, - "temperature": DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE, + "max_tokens": 2560, + "temperature": 0.0, } stop = [] diff --git a/api/core/llm_generator/output_parser/suggested_questions_after_answer.py b/api/core/llm_generator/output_parser/suggested_questions_after_answer.py index c030802c79..7ac340926d 100644 --- a/api/core/llm_generator/output_parser/suggested_questions_after_answer.py +++ b/api/core/llm_generator/output_parser/suggested_questions_after_answer.py @@ -10,7 +10,14 @@ logger = logging.getLogger(__name__) class SuggestedQuestionsAfterAnswerOutputParser: def __init__(self, instruction_prompt: str | None = None) -> None: - self._instruction_prompt = instruction_prompt or DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT + self._instruction_prompt = self._build_instruction_prompt(instruction_prompt) + + @staticmethod + def _build_instruction_prompt(instruction_prompt: str | None) -> str: + if not instruction_prompt or not instruction_prompt.strip(): + return DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT + + return f'{instruction_prompt}\nYou must output a JSON array like ["question1", "question2", "question3"].' def get_format_instructions(self) -> str: return self._instruction_prompt diff --git a/api/core/llm_generator/prompts.py b/api/core/llm_generator/prompts.py index 855a00c9cd..3c6f8c468a 100644 --- a/api/core/llm_generator/prompts.py +++ b/api/core/llm_generator/prompts.py @@ -104,9 +104,6 @@ DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT = ( '["question1","question2","question3"]\n' ) -DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS = 256 -DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE = 0.0 - GENERATOR_QA_PROMPT = ( " The user will send a long text. Generate a Question and Answer pairs only using the knowledge" " in the long text. Please think step by step." diff --git a/api/tests/unit_tests/core/llm_generator/test_llm_generator.py b/api/tests/unit_tests/core/llm_generator/test_llm_generator.py index 3b64ce6b5c..7d6c8f983f 100644 --- a/api/tests/unit_tests/core/llm_generator/test_llm_generator.py +++ b/api/tests/unit_tests/core/llm_generator/test_llm_generator.py @@ -6,10 +6,6 @@ import pytest from core.app.app_config.entities import ModelConfig from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload from core.llm_generator.llm_generator import LLMGenerator -from core.llm_generator.prompts import ( - DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS, - DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE, -) from graphon.model_runtime.entities.llm_entities import LLMMode, LLMResult from graphon.model_runtime.entities.model_entities import ModelType from graphon.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeError @@ -102,8 +98,8 @@ class TestLLMGenerator: assert len(questions) == 2 assert questions[0] == "Question 1?" assert mock_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == { - "max_tokens": DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS, - "temperature": DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE, + "max_tokens": 2560, + "temperature": 0.0, } def test_generate_suggested_questions_after_answer_auth_error(self, mock_model_instance): @@ -181,8 +177,8 @@ class TestLLMGenerator: model_type=ModelType.LLM, ) assert default_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == { - "max_tokens": DEFAULT_SUGGESTED_QUESTIONS_MAX_TOKENS, - "temperature": DEFAULT_SUGGESTED_QUESTIONS_TEMPERATURE, + "max_tokens": 2560, + "temperature": 0.0, } assert default_model_instance.invoke_llm.call_args.kwargs["stop"] == [] From 791fc5819d71a659b59d1f769664fc5b98d6116c Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:12:23 +0800 Subject: [PATCH 009/128] test(dify-ui): disable base ui animations globally (#35467) --- packages/dify-ui/README.md | 16 ++++++ .../src/toast/__tests__/index.spec.tsx | 52 ++++++------------- packages/dify-ui/vite.config.ts | 1 + packages/dify-ui/vitest.setup.ts | 5 ++ 4 files changed, 39 insertions(+), 35 deletions(-) create mode 100644 packages/dify-ui/vitest.setup.ts diff --git a/packages/dify-ui/README.md b/packages/dify-ui/README.md index e9c762073d..cd9485c400 100644 --- a/packages/dify-ui/README.md +++ b/packages/dify-ui/README.md @@ -90,6 +90,22 @@ See `[web/docs/overlay-migration.md](../../web/docs/overlay-migration.md)` for t - `pnpm -C packages/dify-ui storybook` — Storybook on the default port. Each primitive has `index.stories.tsx`. - `pnpm -C packages/dify-ui type-check` — `tsgo --noEmit` for this package only. +### Disabling Animations In Tests + +Base UI can wait for `element.getAnimations()` to finish before it unmounts overlays, panels, and transition-driven components. Browser-based test runners can make that timing unstable, especially when tests assert final DOM state rather than animation behavior. + +Set the Base UI test flag in a Vitest setup file to skip those waits: + +```ts +( + globalThis as typeof globalThis & { + BASE_UI_ANIMATIONS_DISABLED: boolean + } +).BASE_UI_ANIMATIONS_DISABLED = true +``` + +`packages/dify-ui/vitest.setup.ts` already applies this for primitive tests. + See `[AGENTS.md](./AGENTS.md)` for: - Component authoring rules (one-component-per-folder, `cva` + `cn`, relative imports inside the package, subpath imports from consumers). diff --git a/packages/dify-ui/src/toast/__tests__/index.spec.tsx b/packages/dify-ui/src/toast/__tests__/index.spec.tsx index edbdacd203..51fccf70d8 100644 --- a/packages/dify-ui/src/toast/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/toast/__tests__/index.spec.tsx @@ -3,19 +3,20 @@ import { toast, ToastHost } from '../index' const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement -declare global { - // eslint-disable-next-line vars-on-top - var BASE_UI_ANIMATIONS_DISABLED: boolean | undefined +const dispatchToastMouseOver = (element: HTMLElement | SVGElement) => { + element.dispatchEvent(new MouseEvent('mouseover', { + bubbles: true, + })) +} + +const dispatchToastMouseOut = (element: HTMLElement | SVGElement) => { + element.dispatchEvent(new MouseEvent('mouseout', { + bubbles: true, + relatedTarget: document.body, + })) } describe('@langgenius/dify-ui/toast', () => { - beforeAll(() => { - // Base UI waits for `requestAnimationFrame` + `getAnimations().finished` - // before unmounting a toast. Fake timers can't reliably drive that path, - // so short-circuit it to keep auto-dismiss assertions deterministic in CI. - globalThis.BASE_UI_ANIMATIONS_DISABLED = true - }) - beforeEach(() => { vi.clearAllMocks() vi.useFakeTimers() @@ -28,10 +29,6 @@ describe('@langgenius/dify-ui/toast', () => { vi.useRealTimers() }) - afterAll(() => { - globalThis.BASE_UI_ANIMATIONS_DISABLED = undefined - }) - it('should render a success toast when called through the typed shortcut', async () => { const screen = await render() @@ -62,13 +59,13 @@ describe('@langgenius/dify-ui/toast', () => { expect(document.body.querySelectorAll('[role="dialog"]')).toHaveLength(3) expect(document.body.querySelectorAll('button[aria-label="Close notification"][aria-hidden="true"]')).toHaveLength(3) - screen.getByRole('region', { name: 'Notifications' }).element().dispatchEvent(new MouseEvent('mouseover', { - bubbles: true, - })) + const viewport = screen.getByRole('region', { name: 'Notifications' }).element() + dispatchToastMouseOver(viewport) await vi.waitFor(() => { expect(document.body.querySelector('button[aria-label="Close notification"][aria-hidden="true"]')).not.toBeInTheDocument() }) + dispatchToastMouseOut(viewport) }) it('should render a neutral toast when called directly', async () => { @@ -115,11 +112,11 @@ describe('@langgenius/dify-ui/toast', () => { onClose, }) - screen.getByRole('region', { name: 'Notifications' }).element().dispatchEvent(new MouseEvent('mouseover', { - bubbles: true, - })) + const viewport = screen.getByRole('region', { name: 'Notifications' }).element() + dispatchToastMouseOver(viewport) await expect.element(screen.getByRole('button', { name: 'Close notification' })).toBeInTheDocument() + dispatchToastMouseOut(viewport) asHTMLElement(screen.getByRole('button', { name: 'Close notification' }).element()).click() await vi.waitFor(() => { @@ -128,21 +125,6 @@ describe('@langgenius/dify-ui/toast', () => { expect(onClose).toHaveBeenCalledTimes(1) }) - it('should auto dismiss toasts with the Base UI default timeout', async () => { - const screen = await render() - - toast('Default timeout') - await expect.element(screen.getByText('Default timeout')).toBeInTheDocument() - - await vi.advanceTimersByTimeAsync(4999) - expect(document.body).toHaveTextContent('Default timeout') - - await vi.advanceTimersByTimeAsync(1) - await vi.waitFor(() => { - expect(document.body).not.toHaveTextContent('Default timeout') - }) - }) - it('should respect the host timeout configuration', async () => { const screen = await render() diff --git a/packages/dify-ui/vite.config.ts b/packages/dify-ui/vite.config.ts index 5f3533c706..f2a2d24e57 100644 --- a/packages/dify-ui/vite.config.ts +++ b/packages/dify-ui/vite.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ }, test: { globals: true, + setupFiles: ['./vitest.setup.ts'], browser: { enabled: true, provider: playwright(), diff --git a/packages/dify-ui/vitest.setup.ts b/packages/dify-ui/vitest.setup.ts new file mode 100644 index 0000000000..285d6e7760 --- /dev/null +++ b/packages/dify-ui/vitest.setup.ts @@ -0,0 +1,5 @@ +( + globalThis as typeof globalThis & { + BASE_UI_ANIMATIONS_DISABLED: boolean + } +).BASE_UI_ANIMATIONS_DISABLED = true From 7bcedcbaab90021a3f390a3f3942cbd9f3ca3690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Fri, 24 Apr 2026 16:31:19 +0800 Subject: [PATCH 010/128] fix: right click node not display the node detail panel (#35554) --- web/app/components/workflow/hooks/use-nodes-interactions.ts | 2 +- web/app/components/workflow/nodes/_base/node.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/web/app/components/workflow/hooks/use-nodes-interactions.ts b/web/app/components/workflow/hooks/use-nodes-interactions.ts index beb3f3733d..f885236ad9 100644 --- a/web/app/components/workflow/hooks/use-nodes-interactions.ts +++ b/web/app/components/workflow/hooks/use-nodes-interactions.ts @@ -1712,7 +1712,7 @@ export const useNodesInteractions = () => { nodeId: node.id, }, }) - handleNodeSelect(node.id) + handleNodeSelect(node.id, true) }, [workflowStore, handleNodeSelect], ) diff --git a/web/app/components/workflow/nodes/_base/node.tsx b/web/app/components/workflow/nodes/_base/node.tsx index ed83c58b6e..43f8e5773e 100644 --- a/web/app/components/workflow/nodes/_base/node.tsx +++ b/web/app/components/workflow/nodes/_base/node.tsx @@ -79,6 +79,7 @@ const BaseNode: FC = ({ const appId = useStore(s => s.appId) const { nodePanelPresence } = useCollaboration(appId as string) const controlMode = useStore(s => s.controlMode) + const isContextMenuTarget = useStore(s => s.nodeMenu?.nodeId === id) const currentUserPresence = useMemo(() => { const userId = userProfile?.id || '' @@ -123,7 +124,7 @@ const BaseNode: FC = ({ const { hasNodeInspectVars } = useInspectVarsCrud() const isLoading = data._runningStatus === NodeRunningStatus.Running || data._singleRunningStatus === NodeRunningStatus.Running const hasVarValue = hasNodeInspectVars(id) - const showSelectedBorder = Boolean(data.selected || data._isBundled || data._isEntering) + const showSelectedBorder = Boolean(data.selected || isContextMenuTarget || data._isBundled || data._isEntering) const { showRunningBorder, showSuccessBorder, From 0baefa6163321aeb07fa49d7d1f3adc9198f5293 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:33:11 +0800 Subject: [PATCH 011/128] chore(i18n): sync translations with en-US (#35552) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- web/i18n/ar-TN/app.json | 17 +++++++++++++++++ web/i18n/ar-TN/workflow.json | 3 +++ web/i18n/de-DE/app.json | 17 +++++++++++++++++ web/i18n/de-DE/workflow.json | 3 +++ web/i18n/es-ES/app.json | 17 +++++++++++++++++ web/i18n/es-ES/workflow.json | 3 +++ web/i18n/fa-IR/app.json | 17 +++++++++++++++++ web/i18n/fa-IR/workflow.json | 3 +++ web/i18n/fr-FR/app.json | 17 +++++++++++++++++ web/i18n/fr-FR/workflow.json | 3 +++ web/i18n/hi-IN/app.json | 17 +++++++++++++++++ web/i18n/hi-IN/workflow.json | 3 +++ web/i18n/id-ID/app.json | 17 +++++++++++++++++ web/i18n/id-ID/workflow.json | 3 +++ web/i18n/it-IT/app.json | 17 +++++++++++++++++ web/i18n/it-IT/workflow.json | 3 +++ web/i18n/ja-JP/app.json | 17 +++++++++++++++++ web/i18n/ja-JP/workflow.json | 3 +++ web/i18n/ko-KR/app.json | 17 +++++++++++++++++ web/i18n/ko-KR/workflow.json | 3 +++ web/i18n/nl-NL/app.json | 17 +++++++++++++++++ web/i18n/nl-NL/workflow.json | 3 +++ web/i18n/pl-PL/app.json | 17 +++++++++++++++++ web/i18n/pl-PL/workflow.json | 3 +++ web/i18n/pt-BR/app.json | 17 +++++++++++++++++ web/i18n/pt-BR/workflow.json | 3 +++ web/i18n/ro-RO/app.json | 17 +++++++++++++++++ web/i18n/ro-RO/workflow.json | 3 +++ web/i18n/ru-RU/app.json | 17 +++++++++++++++++ web/i18n/ru-RU/workflow.json | 3 +++ web/i18n/sl-SI/app.json | 17 +++++++++++++++++ web/i18n/sl-SI/workflow.json | 3 +++ web/i18n/th-TH/app.json | 17 +++++++++++++++++ web/i18n/th-TH/workflow.json | 3 +++ web/i18n/tr-TR/app.json | 17 +++++++++++++++++ web/i18n/tr-TR/workflow.json | 3 +++ web/i18n/uk-UA/app.json | 17 +++++++++++++++++ web/i18n/uk-UA/workflow.json | 3 +++ web/i18n/vi-VN/app.json | 17 +++++++++++++++++ web/i18n/vi-VN/workflow.json | 3 +++ web/i18n/zh-Hant/app.json | 17 +++++++++++++++++ web/i18n/zh-Hant/workflow.json | 3 +++ 42 files changed, 420 insertions(+) diff --git a/web/i18n/ar-TN/app.json b/web/i18n/ar-TN/app.json index 154758077b..7bc25ccf42 100644 --- a/web/i18n/ar-TN/app.json +++ b/web/i18n/ar-TN/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "رموز تعبيرية", "iconPicker.image": "صورة", "iconPicker.ok": "موافق", + "importApp": "استيراد التطبيق", "importDSL": "استيراد ملف DSL", "importFromDSL": "استيراد من DSL", "importFromDSLFile": "من ملف DSL", "importFromDSLUrl": "من رابط", "importFromDSLUrlPlaceholder": "لصق رابط DSL هنا", "join": "انضم إلى المجتمع", + "marketplace.template.categories": "الفئات", + "marketplace.template.category.design": "التصميم", + "marketplace.template.category.it": "تكنولوجيا المعلومات", + "marketplace.template.category.knowledge": "المعرفة", + "marketplace.template.category.marketing": "التسويق", + "marketplace.template.category.operations": "العمليات", + "marketplace.template.category.sales": "المبيعات", + "marketplace.template.category.support": "الدعم", + "marketplace.template.fetchFailed": "فشل في جلب القالب", + "marketplace.template.importConfirm": "استيراد", + "marketplace.template.importFailed": "فشل في استيراد القالب", + "marketplace.template.modalTitle": "استيراد من Marketplace", + "marketplace.template.overview": "نظرة عامة", + "marketplace.template.publishedBy": "بواسطة", + "marketplace.template.usageCount": "الاستخدام", + "marketplace.template.viewOnMarketplace": "عرض على Marketplace", "maxActiveRequests": "أقصى عدد للطلبات المتزامنة", "maxActiveRequestsPlaceholder": "أدخل 0 لغير محدود", "maxActiveRequestsTip": "الحد الأقصى لعدد الطلبات النشطة المتزامنة لكل تطبيق (0 لغير محدود)", diff --git a/web/i18n/ar-TN/workflow.json b/web/i18n/ar-TN/workflow.json index 04a618fb3b..cc6c533ca1 100644 --- a/web/i18n/ar-TN/workflow.json +++ b/web/i18n/ar-TN/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "أدخل المحتوى في المربع أدناه لبدء تصحيح أخطاء Chatbot", "common.processData": "معالجة البيانات", "common.publish": "نشر", + "common.publishToMarketplace": "نشر على Marketplace", + "common.publishToMarketplaceFailed": "فشل النشر على Marketplace", "common.publishUpdate": "نشر التحديث", "common.published": "منشور", "common.publishedAt": "تم النشر في", + "common.publishingToMarketplace": "جارٍ النشر...", "common.redo": "إعادة", "common.restart": "إعادة تشغيل", "common.restore": "استعادة", diff --git a/web/i18n/de-DE/app.json b/web/i18n/de-DE/app.json index b316dcebce..c429e37802 100644 --- a/web/i18n/de-DE/app.json +++ b/web/i18n/de-DE/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "Emoji", "iconPicker.image": "Bild", "iconPicker.ok": "OK", + "importApp": "App importieren", "importDSL": "DSL-Datei importieren", "importFromDSL": "Import von DSL", "importFromDSLFile": "Aus DSL-Datei", "importFromDSLUrl": "Von URL", "importFromDSLUrlPlaceholder": "DSL-Link hier einfügen", "join": "Treten Sie der Gemeinschaft bei", + "marketplace.template.categories": "Kategorien", + "marketplace.template.category.design": "Design", + "marketplace.template.category.it": "IT", + "marketplace.template.category.knowledge": "Wissen", + "marketplace.template.category.marketing": "Marketing", + "marketplace.template.category.operations": "Betrieb", + "marketplace.template.category.sales": "Vertrieb", + "marketplace.template.category.support": "Support", + "marketplace.template.fetchFailed": "Vorlage konnte nicht abgerufen werden", + "marketplace.template.importConfirm": "Importieren", + "marketplace.template.importFailed": "Vorlage konnte nicht importiert werden", + "marketplace.template.modalTitle": "Aus Marketplace importieren", + "marketplace.template.overview": "Übersicht", + "marketplace.template.publishedBy": "Von", + "marketplace.template.usageCount": "Nutzung", + "marketplace.template.viewOnMarketplace": "Im Marketplace ansehen", "maxActiveRequests": "Maximale gleichzeitige Anfragen", "maxActiveRequestsPlaceholder": "Geben Sie 0 für unbegrenzt ein", "maxActiveRequestsTip": "Maximale Anzahl gleichzeitiger aktiver Anfragen pro App (0 für unbegrenzt)", diff --git a/web/i18n/de-DE/workflow.json b/web/i18n/de-DE/workflow.json index fe50c09651..426c023259 100644 --- a/web/i18n/de-DE/workflow.json +++ b/web/i18n/de-DE/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "Geben Sie den Inhalt in das Feld unten ein, um das Debuggen des Chatbots zu starten", "common.processData": "Daten verarbeiten", "common.publish": "Veröffentlichen", + "common.publishToMarketplace": "Im Marketplace veröffentlichen", + "common.publishToMarketplaceFailed": "Veröffentlichung im Marketplace fehlgeschlagen", "common.publishUpdate": "Update veröffentlichen", "common.published": "Veröffentlicht", "common.publishedAt": "Veröffentlicht am", + "common.publishingToMarketplace": "Wird veröffentlicht...", "common.redo": "Wiederholen", "common.restart": "Neustarten", "common.restore": "Wiederherstellen", diff --git a/web/i18n/es-ES/app.json b/web/i18n/es-ES/app.json index 251746db7f..5cc805c8f6 100644 --- a/web/i18n/es-ES/app.json +++ b/web/i18n/es-ES/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "Emoji", "iconPicker.image": "Imagen", "iconPicker.ok": "OK", + "importApp": "Importar App", "importDSL": "Importar archivo DSL", "importFromDSL": "Importar desde DSL", "importFromDSLFile": "Desde el archivo DSL", "importFromDSLUrl": "URL de origen", "importFromDSLUrlPlaceholder": "Pegar enlace DSL aquí", "join": "Únete a la comunidad", + "marketplace.template.categories": "Categorías", + "marketplace.template.category.design": "Diseño", + "marketplace.template.category.it": "IT", + "marketplace.template.category.knowledge": "Conocimiento", + "marketplace.template.category.marketing": "Marketing", + "marketplace.template.category.operations": "Operaciones", + "marketplace.template.category.sales": "Ventas", + "marketplace.template.category.support": "Soporte", + "marketplace.template.fetchFailed": "Error al obtener la plantilla", + "marketplace.template.importConfirm": "Importar", + "marketplace.template.importFailed": "Error al importar la plantilla", + "marketplace.template.modalTitle": "Importar desde Marketplace", + "marketplace.template.overview": "Vista general", + "marketplace.template.publishedBy": "Por", + "marketplace.template.usageCount": "Uso", + "marketplace.template.viewOnMarketplace": "Ver en Marketplace", "maxActiveRequests": "Máximas solicitudes concurrentes", "maxActiveRequestsPlaceholder": "Introduce 0 para ilimitado", "maxActiveRequestsTip": "Número máximo de solicitudes activas concurrentes por aplicación (0 para ilimitado)", diff --git a/web/i18n/es-ES/workflow.json b/web/i18n/es-ES/workflow.json index 5da69241e7..c55ffdfc1e 100644 --- a/web/i18n/es-ES/workflow.json +++ b/web/i18n/es-ES/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "Ingrese contenido en el cuadro de abajo para comenzar a depurar el Chatbot", "common.processData": "Procesar datos", "common.publish": "Publicar", + "common.publishToMarketplace": "Publicar en Marketplace", + "common.publishToMarketplaceFailed": "Error al publicar en Marketplace", "common.publishUpdate": "Publicar actualización", "common.published": "Publicado", "common.publishedAt": "Publicado el", + "common.publishingToMarketplace": "Publicando...", "common.redo": "Rehacer", "common.restart": "Reiniciar", "common.restore": "Restaurar", diff --git a/web/i18n/fa-IR/app.json b/web/i18n/fa-IR/app.json index ed253fc569..3bdba44440 100644 --- a/web/i18n/fa-IR/app.json +++ b/web/i18n/fa-IR/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "ایموجی", "iconPicker.image": "تصویر", "iconPicker.ok": "باشه", + "importApp": "وارد کردن برنامه", "importDSL": "وارد کردن فایل DSL", "importFromDSL": "وارد کردن از DSL", "importFromDSLFile": "از فایل DSL", "importFromDSLUrl": "از URL", "importFromDSLUrlPlaceholder": "لینک DSL را اینجا بچسبانید", "join": "پیوستن به جامعه", + "marketplace.template.categories": "دسته‌بندی‌ها", + "marketplace.template.category.design": "طراحی", + "marketplace.template.category.it": "فناوری اطلاعات", + "marketplace.template.category.knowledge": "دانش", + "marketplace.template.category.marketing": "بازاریابی", + "marketplace.template.category.operations": "عملیات", + "marketplace.template.category.sales": "فروش", + "marketplace.template.category.support": "پشتیبانی", + "marketplace.template.fetchFailed": "دریافت قالب ناموفق بود", + "marketplace.template.importConfirm": "وارد کردن", + "marketplace.template.importFailed": "وارد کردن قالب ناموفق بود", + "marketplace.template.modalTitle": "وارد کردن از Marketplace", + "marketplace.template.overview": "نمای کلی", + "marketplace.template.publishedBy": "توسط", + "marketplace.template.usageCount": "استفاده", + "marketplace.template.viewOnMarketplace": "مشاهده در Marketplace", "maxActiveRequests": "بیشترین درخواست‌های همزمان", "maxActiveRequestsPlaceholder": "برای نامحدود، 0 را وارد کنید", "maxActiveRequestsTip": "حداکثر تعداد درخواست‌های فعال همزمان در هر برنامه (0 برای نامحدود)", diff --git a/web/i18n/fa-IR/workflow.json b/web/i18n/fa-IR/workflow.json index 3210cf8919..c23c781a04 100644 --- a/web/i18n/fa-IR/workflow.json +++ b/web/i18n/fa-IR/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "محتوا را در کادر زیر وارد کنید تا اشکال‌زدایی چت‌بات آغاز شود", "common.processData": "پردازش داده‌ها", "common.publish": "انتشار", + "common.publishToMarketplace": "انتشار در Marketplace", + "common.publishToMarketplaceFailed": "انتشار در Marketplace ناموفق بود", "common.publishUpdate": "انتشار به‌روزرسانی", "common.published": "منتشر شده", "common.publishedAt": "منتشر شده در", + "common.publishingToMarketplace": "در حال انتشار...", "common.redo": "بازانجام", "common.restart": "راه‌اندازی مجدد", "common.restore": "بازیابی", diff --git a/web/i18n/fr-FR/app.json b/web/i18n/fr-FR/app.json index f6af8380bb..f90623ce18 100644 --- a/web/i18n/fr-FR/app.json +++ b/web/i18n/fr-FR/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "Emoji", "iconPicker.image": "Image", "iconPicker.ok": "OK", + "importApp": "Importer l'App", "importDSL": "Importer le fichier DSL", "importFromDSL": "Importation à partir d'une DSL", "importFromDSLFile": "À partir d’un fichier DSL", "importFromDSLUrl": "À partir de l’URL", "importFromDSLUrlPlaceholder": "Collez le lien DSL ici", "join": "Rejoindre la communauté", + "marketplace.template.categories": "Catégories", + "marketplace.template.category.design": "Design", + "marketplace.template.category.it": "IT", + "marketplace.template.category.knowledge": "Connaissance", + "marketplace.template.category.marketing": "Marketing", + "marketplace.template.category.operations": "Opérations", + "marketplace.template.category.sales": "Ventes", + "marketplace.template.category.support": "Support", + "marketplace.template.fetchFailed": "Échec de la récupération du modèle", + "marketplace.template.importConfirm": "Importer", + "marketplace.template.importFailed": "Échec de l'importation du modèle", + "marketplace.template.modalTitle": "Importer depuis le Marketplace", + "marketplace.template.overview": "Aperçu", + "marketplace.template.publishedBy": "Par", + "marketplace.template.usageCount": "Utilisation", + "marketplace.template.viewOnMarketplace": "Voir sur le Marketplace", "maxActiveRequests": "Nombre maximal de requêtes simultanées", "maxActiveRequestsPlaceholder": "Entrez 0 pour illimité", "maxActiveRequestsTip": "Nombre maximum de requêtes actives concurrentes par application (0 pour illimité)", diff --git a/web/i18n/fr-FR/workflow.json b/web/i18n/fr-FR/workflow.json index da3e69dab3..727c3a91e6 100644 --- a/web/i18n/fr-FR/workflow.json +++ b/web/i18n/fr-FR/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "Entrez le contenu dans la boîte ci-dessous pour commencer à déboguer le Chatbot", "common.processData": "Traiter les données", "common.publish": "Publier", + "common.publishToMarketplace": "Publier sur le Marketplace", + "common.publishToMarketplaceFailed": "Échec de la publication sur le Marketplace", "common.publishUpdate": "Publier une mise à jour", "common.published": "Publié", "common.publishedAt": "Publié le", + "common.publishingToMarketplace": "Publication en cours...", "common.redo": "Réexécuter", "common.restart": "Redémarrer", "common.restore": "Restaurer", diff --git a/web/i18n/hi-IN/app.json b/web/i18n/hi-IN/app.json index 3705c4dec1..a7cc347820 100644 --- a/web/i18n/hi-IN/app.json +++ b/web/i18n/hi-IN/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "इमोजी", "iconPicker.image": "छवि", "iconPicker.ok": "ठीक है", + "importApp": "ऐप आयात करें", "importDSL": "डीएसएल फ़ाइल आयात करें", "importFromDSL": "DSL से आयात करें", "importFromDSLFile": "डीएसएल फ़ाइल से", "importFromDSLUrl": "यूआरएल से", "importFromDSLUrlPlaceholder": "डीएसएल लिंक यहां पेस्ट करें", "join": "समुदाय में शामिल हों", + "marketplace.template.categories": "श्रेणियाँ", + "marketplace.template.category.design": "डिज़ाइन", + "marketplace.template.category.it": "आईटी", + "marketplace.template.category.knowledge": "ज्ञान", + "marketplace.template.category.marketing": "मार्केटिंग", + "marketplace.template.category.operations": "संचालन", + "marketplace.template.category.sales": "बिक्री", + "marketplace.template.category.support": "समर्थन", + "marketplace.template.fetchFailed": "टेम्पलेट प्राप्त करने में विफल", + "marketplace.template.importConfirm": "आयात करें", + "marketplace.template.importFailed": "टेम्पलेट आयात करने में विफल", + "marketplace.template.modalTitle": "Marketplace से आयात करें", + "marketplace.template.overview": "अवलोकन", + "marketplace.template.publishedBy": "द्वारा", + "marketplace.template.usageCount": "उपयोग", + "marketplace.template.viewOnMarketplace": "Marketplace पर देखें", "maxActiveRequests": "अधिकतम समवर्ती अनुरोध", "maxActiveRequestsPlaceholder": "असीमित के लिए 0 दर्ज करें", "maxActiveRequestsTip": "प्रति ऐप सक्रिय अनुरोधों की अधिकतम संख्या (असीमित के लिए 0)", diff --git a/web/i18n/hi-IN/workflow.json b/web/i18n/hi-IN/workflow.json index 20845af0b8..8b5ea73535 100644 --- a/web/i18n/hi-IN/workflow.json +++ b/web/i18n/hi-IN/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "चैटबॉट का डीबग शुरू करने के लिए नीचे दिए गए बॉक्स में सामग्री दर्ज करें", "common.processData": "डेटा प्रोसेस करें", "common.publish": "प्रकाशित करें", + "common.publishToMarketplace": "Marketplace पर प्रकाशित करें", + "common.publishToMarketplaceFailed": "Marketplace पर प्रकाशित करने में विफल", "common.publishUpdate": "अपडेट प्रकाशित करें", "common.published": "प्रकाशित", "common.publishedAt": "प्रकाशित", + "common.publishingToMarketplace": "प्रकाशित हो रहा है...", "common.redo": "फिर से करें", "common.restart": "पुनः आरंभ करें", "common.restore": "पुनर्स्थापित करें", diff --git a/web/i18n/id-ID/app.json b/web/i18n/id-ID/app.json index 23aadc9da6..c47dda1886 100644 --- a/web/i18n/id-ID/app.json +++ b/web/i18n/id-ID/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "Emoji", "iconPicker.image": "Citra", "iconPicker.ok": "OK", + "importApp": "Impor Aplikasi", "importDSL": "Impor file DSL", "importFromDSL": "Impor dari DSL", "importFromDSLFile": "Dari file DSL", "importFromDSLUrl": "Dari URL", "importFromDSLUrlPlaceholder": "Tempel tautan DSL di sini", "join": "Bergabunglah dengan komunitas", + "marketplace.template.categories": "Kategori", + "marketplace.template.category.design": "Desain", + "marketplace.template.category.it": "IT", + "marketplace.template.category.knowledge": "Pengetahuan", + "marketplace.template.category.marketing": "Pemasaran", + "marketplace.template.category.operations": "Operasi", + "marketplace.template.category.sales": "Penjualan", + "marketplace.template.category.support": "Dukungan", + "marketplace.template.fetchFailed": "Gagal mengambil templat", + "marketplace.template.importConfirm": "Impor", + "marketplace.template.importFailed": "Gagal mengimpor templat", + "marketplace.template.modalTitle": "Impor dari Marketplace", + "marketplace.template.overview": "Ikhtisar", + "marketplace.template.publishedBy": "Oleh", + "marketplace.template.usageCount": "Penggunaan", + "marketplace.template.viewOnMarketplace": "Lihat di Marketplace", "maxActiveRequests": "Permintaan bersamaan maksimum", "maxActiveRequestsPlaceholder": "Masukkan 0 untuk tidak terbatas", "maxActiveRequestsTip": "Jumlah maksimum permintaan aktif bersamaan per aplikasi (0 untuk tidak terbatas)", diff --git a/web/i18n/id-ID/workflow.json b/web/i18n/id-ID/workflow.json index 2c32f25aab..058c15334b 100644 --- a/web/i18n/id-ID/workflow.json +++ b/web/i18n/id-ID/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "Masukkan konten di kotak di bawah ini untuk mulai men-debug Chatbot", "common.processData": "Proses Data", "common.publish": "Menerbitkan", + "common.publishToMarketplace": "Publikasikan ke Marketplace", + "common.publishToMarketplaceFailed": "Gagal mempublikasikan ke Marketplace", "common.publishUpdate": "Publikasikan Pembaruan", "common.published": "Diterbitkan", "common.publishedAt": "Diterbitkan", + "common.publishingToMarketplace": "Mempublikasikan...", "common.redo": "Ulangi", "common.restart": "Restart", "common.restore": "Mengembalikan", diff --git a/web/i18n/it-IT/app.json b/web/i18n/it-IT/app.json index e721ecf655..0719a49571 100644 --- a/web/i18n/it-IT/app.json +++ b/web/i18n/it-IT/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "Emoji", "iconPicker.image": "Immagine", "iconPicker.ok": "OK", + "importApp": "Importa App", "importDSL": "Importa file DSL", "importFromDSL": "Importazione da DSL", "importFromDSLFile": "Da file DSL", "importFromDSLUrl": "Dall'URL", "importFromDSLUrlPlaceholder": "Incolla qui il link DSL", "join": "Unisciti alla comunità", + "marketplace.template.categories": "Categorie", + "marketplace.template.category.design": "Design", + "marketplace.template.category.it": "IT", + "marketplace.template.category.knowledge": "Conoscenza", + "marketplace.template.category.marketing": "Marketing", + "marketplace.template.category.operations": "Operazioni", + "marketplace.template.category.sales": "Vendite", + "marketplace.template.category.support": "Supporto", + "marketplace.template.fetchFailed": "Impossibile recuperare il modello", + "marketplace.template.importConfirm": "Importa", + "marketplace.template.importFailed": "Impossibile importare il modello", + "marketplace.template.modalTitle": "Importa dal Marketplace", + "marketplace.template.overview": "Panoramica", + "marketplace.template.publishedBy": "Di", + "marketplace.template.usageCount": "Utilizzo", + "marketplace.template.viewOnMarketplace": "Visualizza sul Marketplace", "maxActiveRequests": "Massimo numero di richieste concorrenti", "maxActiveRequestsPlaceholder": "Inserisci 0 per illimitato", "maxActiveRequestsTip": "Numero massimo di richieste attive concorrenti per app (0 per illimitato)", diff --git a/web/i18n/it-IT/workflow.json b/web/i18n/it-IT/workflow.json index 1c779d1365..fbd3041fb9 100644 --- a/web/i18n/it-IT/workflow.json +++ b/web/i18n/it-IT/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "Inserisci contenuto nella casella sottostante per avviare il debug del Chatbot", "common.processData": "Elabora Dati", "common.publish": "Pubblica", + "common.publishToMarketplace": "Pubblica sul Marketplace", + "common.publishToMarketplaceFailed": "Pubblicazione sul Marketplace non riuscita", "common.publishUpdate": "Pubblica aggiornamento", "common.published": "Pubblicato", "common.publishedAt": "Pubblicato", + "common.publishingToMarketplace": "Pubblicazione...", "common.redo": "Ripeti", "common.restart": "Riavvia", "common.restore": "Ripristina", diff --git a/web/i18n/ja-JP/app.json b/web/i18n/ja-JP/app.json index 925095d447..8ccaaababe 100644 --- a/web/i18n/ja-JP/app.json +++ b/web/i18n/ja-JP/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "絵文字", "iconPicker.image": "画像", "iconPicker.ok": "OK", + "importApp": "アプリをインポート", "importDSL": "DSL ファイルをインポート", "importFromDSL": "DSL からインポート", "importFromDSLFile": "DSL ファイルから", "importFromDSLUrl": "URL から", "importFromDSLUrlPlaceholder": "DSL リンクをここに貼り付けます", "join": "コミュニティに参加する", + "marketplace.template.categories": "カテゴリ", + "marketplace.template.category.design": "デザイン", + "marketplace.template.category.it": "IT", + "marketplace.template.category.knowledge": "知識", + "marketplace.template.category.marketing": "マーケティング", + "marketplace.template.category.operations": "オペレーション", + "marketplace.template.category.sales": "セールス", + "marketplace.template.category.support": "サポート", + "marketplace.template.fetchFailed": "テンプレートの取得に失敗しました", + "marketplace.template.importConfirm": "インポート", + "marketplace.template.importFailed": "テンプレートのインポートに失敗しました", + "marketplace.template.modalTitle": "マーケットプレイスからインポート", + "marketplace.template.overview": "概要", + "marketplace.template.publishedBy": "提供者", + "marketplace.template.usageCount": "使用数", + "marketplace.template.viewOnMarketplace": "マーケットプレイスで見る", "maxActiveRequests": "最大同時リクエスト数", "maxActiveRequestsPlaceholder": "無制限のために0を入力してください", "maxActiveRequestsTip": "アプリごとの同時アクティブリクエストの最大数(無制限の場合は0)", diff --git a/web/i18n/ja-JP/workflow.json b/web/i18n/ja-JP/workflow.json index 1ee43c17cf..1154a5baba 100644 --- a/web/i18n/ja-JP/workflow.json +++ b/web/i18n/ja-JP/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "入力欄にテキストを入力してチャットボットのデバッグを開始", "common.processData": "データ処理", "common.publish": "公開する", + "common.publishToMarketplace": "マーケットプレイスに公開", + "common.publishToMarketplaceFailed": "マーケットプレイスへの公開に失敗しました", "common.publishUpdate": "更新を公開", "common.published": "公開済み", "common.publishedAt": "公開日時", + "common.publishingToMarketplace": "公開中...", "common.redo": "やり直し", "common.restart": "再起動", "common.restore": "復元", diff --git a/web/i18n/ko-KR/app.json b/web/i18n/ko-KR/app.json index 4f29da5f1e..b9dd592f03 100644 --- a/web/i18n/ko-KR/app.json +++ b/web/i18n/ko-KR/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "이모지", "iconPicker.image": "이미지", "iconPicker.ok": "확인", + "importApp": "앱 가져오기", "importDSL": "DSL 파일 가져오기", "importFromDSL": "DSL 에서 가져오기", "importFromDSLFile": "DSL 파일에서", "importFromDSLUrl": "URL 에서", "importFromDSLUrlPlaceholder": "여기에 DSL 링크 붙여 넣기", "join": "커뮤니티에 참여하기", + "marketplace.template.categories": "카테고리", + "marketplace.template.category.design": "디자인", + "marketplace.template.category.it": "IT", + "marketplace.template.category.knowledge": "지식", + "marketplace.template.category.marketing": "마케팅", + "marketplace.template.category.operations": "운영", + "marketplace.template.category.sales": "영업", + "marketplace.template.category.support": "지원", + "marketplace.template.fetchFailed": "템플릿 가져오기 실패", + "marketplace.template.importConfirm": "가져오기", + "marketplace.template.importFailed": "템플릿 가져오기 실패", + "marketplace.template.modalTitle": "마켓플레이스에서 가져오기", + "marketplace.template.overview": "개요", + "marketplace.template.publishedBy": "제공:", + "marketplace.template.usageCount": "사용량", + "marketplace.template.viewOnMarketplace": "마켓플레이스에서 보기", "maxActiveRequests": "동시 최대 요청 수", "maxActiveRequestsPlaceholder": "무제한 사용을 원하시면 0을 입력하세요.", "maxActiveRequestsTip": "앱당 최대 동시 활성 요청 수(무제한은 0)", diff --git a/web/i18n/ko-KR/workflow.json b/web/i18n/ko-KR/workflow.json index b6291e4366..a80c34b294 100644 --- a/web/i18n/ko-KR/workflow.json +++ b/web/i18n/ko-KR/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "디버깅을 시작하려면 아래 상자에 내용을 입력하세요", "common.processData": "데이터 처리", "common.publish": "게시하기", + "common.publishToMarketplace": "마켓플레이스에 게시", + "common.publishToMarketplaceFailed": "마켓플레이스 게시 실패", "common.publishUpdate": "업데이트 게시", "common.published": "게시됨", "common.publishedAt": "발행일", + "common.publishingToMarketplace": "게시 중...", "common.redo": "다시 실행", "common.restart": "재시작", "common.restore": "복원", diff --git a/web/i18n/nl-NL/app.json b/web/i18n/nl-NL/app.json index 0ad608d53c..9bd50b5b92 100644 --- a/web/i18n/nl-NL/app.json +++ b/web/i18n/nl-NL/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "Emoji", "iconPicker.image": "Image", "iconPicker.ok": "OK", + "importApp": "App importeren", "importDSL": "Import DSL file", "importFromDSL": "Import from DSL", "importFromDSLFile": "From DSL file", "importFromDSLUrl": "From URL", "importFromDSLUrlPlaceholder": "Paste DSL link here", "join": "Join the community", + "marketplace.template.categories": "Categorieën", + "marketplace.template.category.design": "Ontwerp", + "marketplace.template.category.it": "IT", + "marketplace.template.category.knowledge": "Kennis", + "marketplace.template.category.marketing": "Marketing", + "marketplace.template.category.operations": "Operaties", + "marketplace.template.category.sales": "Verkoop", + "marketplace.template.category.support": "Ondersteuning", + "marketplace.template.fetchFailed": "Template ophalen mislukt", + "marketplace.template.importConfirm": "Importeren", + "marketplace.template.importFailed": "Template importeren mislukt", + "marketplace.template.modalTitle": "Importeren vanuit Marketplace", + "marketplace.template.overview": "Overzicht", + "marketplace.template.publishedBy": "Door", + "marketplace.template.usageCount": "Gebruik", + "marketplace.template.viewOnMarketplace": "Bekijken op Marketplace", "maxActiveRequests": "Max concurrent requests", "maxActiveRequestsPlaceholder": "Enter 0 for unlimited", "maxActiveRequestsTip": "Maximum number of concurrent active requests per app (0 for unlimited)", diff --git a/web/i18n/nl-NL/workflow.json b/web/i18n/nl-NL/workflow.json index c3d5824ef7..c8e8753eb4 100644 --- a/web/i18n/nl-NL/workflow.json +++ b/web/i18n/nl-NL/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "Enter content in the box below to start debugging the Chatbot", "common.processData": "Process Data", "common.publish": "Publish", + "common.publishToMarketplace": "Publiceren op Marketplace", + "common.publishToMarketplaceFailed": "Publiceren op Marketplace mislukt", "common.publishUpdate": "Publish Update", "common.published": "Published", "common.publishedAt": "Published", + "common.publishingToMarketplace": "Publiceren...", "common.redo": "Redo", "common.restart": "Restart", "common.restore": "Restore", diff --git a/web/i18n/pl-PL/app.json b/web/i18n/pl-PL/app.json index a3ae06e3cd..0f6f5cd298 100644 --- a/web/i18n/pl-PL/app.json +++ b/web/i18n/pl-PL/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "Emoji", "iconPicker.image": "Obraz", "iconPicker.ok": "OK", + "importApp": "Importuj aplikację", "importDSL": "Importuj plik DSL", "importFromDSL": "Importowanie z DSL", "importFromDSLFile": "Z pliku DSL", "importFromDSLUrl": "Z adresu URL", "importFromDSLUrlPlaceholder": "Wklej tutaj link DSL", "join": "Dołącz do społeczności", + "marketplace.template.categories": "Kategorie", + "marketplace.template.category.design": "Design", + "marketplace.template.category.it": "IT", + "marketplace.template.category.knowledge": "Wiedza", + "marketplace.template.category.marketing": "Marketing", + "marketplace.template.category.operations": "Operacje", + "marketplace.template.category.sales": "Sprzedaż", + "marketplace.template.category.support": "Wsparcie", + "marketplace.template.fetchFailed": "Nie udało się pobrać szablonu", + "marketplace.template.importConfirm": "Importuj", + "marketplace.template.importFailed": "Nie udało się zaimportować szablonu", + "marketplace.template.modalTitle": "Importuj z Marketplace", + "marketplace.template.overview": "Przegląd", + "marketplace.template.publishedBy": "Przez", + "marketplace.template.usageCount": "Użycie", + "marketplace.template.viewOnMarketplace": "Zobacz na Marketplace", "maxActiveRequests": "Maksymalne równoczesne żądania", "maxActiveRequestsPlaceholder": "Wprowadź 0, aby uzyskać nielimitowane", "maxActiveRequestsTip": "Maksymalna liczba jednoczesnych aktywnych żądań na aplikację (0 dla nieograniczonej)", diff --git a/web/i18n/pl-PL/workflow.json b/web/i18n/pl-PL/workflow.json index 6b0bda1ff8..805960a851 100644 --- a/web/i18n/pl-PL/workflow.json +++ b/web/i18n/pl-PL/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "Wprowadź treść w poniższym polu, aby rozpocząć debugowanie Chatbota", "common.processData": "Przetwórz dane", "common.publish": "Opublikuj", + "common.publishToMarketplace": "Publikuj na Marketplace", + "common.publishToMarketplaceFailed": "Nie udało się opublikować na Marketplace", "common.publishUpdate": "Opublikuj aktualizację", "common.published": "Opublikowane", "common.publishedAt": "Opublikowane", + "common.publishingToMarketplace": "Publikowanie...", "common.redo": "Ponów", "common.restart": "Uruchom ponownie", "common.restore": "Przywróć", diff --git a/web/i18n/pt-BR/app.json b/web/i18n/pt-BR/app.json index 43447c970c..3c59423e99 100644 --- a/web/i18n/pt-BR/app.json +++ b/web/i18n/pt-BR/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "Emoji", "iconPicker.image": "Imagem", "iconPicker.ok": "OK", + "importApp": "Importar App", "importDSL": "Importar arquivo DSL", "importFromDSL": "Importar de DSL", "importFromDSLFile": "Do arquivo DSL", "importFromDSLUrl": "Do URL", "importFromDSLUrlPlaceholder": "Cole o link DSL aqui", "join": "Participe da comunidade", + "marketplace.template.categories": "Categorias", + "marketplace.template.category.design": "Design", + "marketplace.template.category.it": "TI", + "marketplace.template.category.knowledge": "Conhecimento", + "marketplace.template.category.marketing": "Marketing", + "marketplace.template.category.operations": "Operações", + "marketplace.template.category.sales": "Vendas", + "marketplace.template.category.support": "Suporte", + "marketplace.template.fetchFailed": "Falha ao buscar modelo", + "marketplace.template.importConfirm": "Importar", + "marketplace.template.importFailed": "Falha ao importar modelo", + "marketplace.template.modalTitle": "Importar do Marketplace", + "marketplace.template.overview": "Visão geral", + "marketplace.template.publishedBy": "Por", + "marketplace.template.usageCount": "Uso", + "marketplace.template.viewOnMarketplace": "Ver no Marketplace", "maxActiveRequests": "Máximo de solicitações simultâneas", "maxActiveRequestsPlaceholder": "Digite 0 para ilimitado", "maxActiveRequestsTip": "Número máximo de solicitações ativas simultâneas por aplicativo (0 para ilimitado)", diff --git a/web/i18n/pt-BR/workflow.json b/web/i18n/pt-BR/workflow.json index a8a7511100..de6c882e0c 100644 --- a/web/i18n/pt-BR/workflow.json +++ b/web/i18n/pt-BR/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "Digite o conteúdo na caixa abaixo para começar a depurar o Chatbot", "common.processData": "Processar dados", "common.publish": "Publicar", + "common.publishToMarketplace": "Publicar no Marketplace", + "common.publishToMarketplaceFailed": "Falha ao publicar no Marketplace", "common.publishUpdate": "Publicar Atualização", "common.published": "Publicado", "common.publishedAt": "Publicado em", + "common.publishingToMarketplace": "Publicando...", "common.redo": "Refazer", "common.restart": "Reiniciar", "common.restore": "Restaurar", diff --git a/web/i18n/ro-RO/app.json b/web/i18n/ro-RO/app.json index cfa0b8aedc..f93e4f10a0 100644 --- a/web/i18n/ro-RO/app.json +++ b/web/i18n/ro-RO/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "Emoji", "iconPicker.image": "Imagine", "iconPicker.ok": "OK", + "importApp": "Importați aplicația", "importDSL": "Importă fișier DSL", "importFromDSL": "Import din DSL", "importFromDSLFile": "Din fișierul DSL", "importFromDSLUrl": "De la URL", "importFromDSLUrlPlaceholder": "Lipiți linkul DSL aici", "join": "Alătură-te comunității", + "marketplace.template.categories": "Categorii", + "marketplace.template.category.design": "Design", + "marketplace.template.category.it": "IT", + "marketplace.template.category.knowledge": "Cunoaștere", + "marketplace.template.category.marketing": "Marketing", + "marketplace.template.category.operations": "Operațiuni", + "marketplace.template.category.sales": "Vânzări", + "marketplace.template.category.support": "Suport", + "marketplace.template.fetchFailed": "Eroare la obținerea șablonului", + "marketplace.template.importConfirm": "Importați", + "marketplace.template.importFailed": "Eroare la importul șablonului", + "marketplace.template.modalTitle": "Importați din Marketplace", + "marketplace.template.overview": "Prezentare generală", + "marketplace.template.publishedBy": "De", + "marketplace.template.usageCount": "Utilizare", + "marketplace.template.viewOnMarketplace": "Vizualizați pe Marketplace", "maxActiveRequests": "Maxime cereri simultane", "maxActiveRequestsPlaceholder": "Introduceți 0 pentru nelimitat", "maxActiveRequestsTip": "Numărul maxim de cereri active concurente pe aplicație (0 pentru nelimitat)", diff --git a/web/i18n/ro-RO/workflow.json b/web/i18n/ro-RO/workflow.json index c15e8508ab..7b551294e8 100644 --- a/web/i18n/ro-RO/workflow.json +++ b/web/i18n/ro-RO/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "Introduceți conținutul în caseta de mai jos pentru a începe depanarea Chatbotului", "common.processData": "Procesează date", "common.publish": "Publică", + "common.publishToMarketplace": "Publicați pe Marketplace", + "common.publishToMarketplaceFailed": "Eroare la publicarea pe Marketplace", "common.publishUpdate": "Publicați actualizarea", "common.published": "Publicat", "common.publishedAt": "Publicat la", + "common.publishingToMarketplace": "Se publică...", "common.redo": "Refă", "common.restart": "Repornește", "common.restore": "Restaurează", diff --git a/web/i18n/ru-RU/app.json b/web/i18n/ru-RU/app.json index 7b53ea61fb..8a9327e81e 100644 --- a/web/i18n/ru-RU/app.json +++ b/web/i18n/ru-RU/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "Эмодзи", "iconPicker.image": "Изображение", "iconPicker.ok": "ОК", + "importApp": "Импортировать приложение", "importDSL": "Импортировать файл DSL", "importFromDSL": "Импортировать из DSL", "importFromDSLFile": "Из файла DSL", "importFromDSLUrl": "Из URL", "importFromDSLUrlPlaceholder": "Вставьте ссылку DSL сюда", "join": "Присоединяйтесь к сообществу", + "marketplace.template.categories": "Категории", + "marketplace.template.category.design": "Дизайн", + "marketplace.template.category.it": "ИТ", + "marketplace.template.category.knowledge": "Знания", + "marketplace.template.category.marketing": "Маркетинг", + "marketplace.template.category.operations": "Операции", + "marketplace.template.category.sales": "Продажи", + "marketplace.template.category.support": "Поддержка", + "marketplace.template.fetchFailed": "Не удалось получить шаблон", + "marketplace.template.importConfirm": "Импортировать", + "marketplace.template.importFailed": "Не удалось импортировать шаблон", + "marketplace.template.modalTitle": "Импортировать из Marketplace", + "marketplace.template.overview": "Обзор", + "marketplace.template.publishedBy": "От", + "marketplace.template.usageCount": "Использование", + "marketplace.template.viewOnMarketplace": "Открыть в Marketplace", "maxActiveRequests": "Максимальное количество параллельных запросов", "maxActiveRequestsPlaceholder": "Введите 0 для неограниченного количества", "maxActiveRequestsTip": "Максимальное количество одновременно активных запросов на одно приложение (0 для неограниченного количества)", diff --git a/web/i18n/ru-RU/workflow.json b/web/i18n/ru-RU/workflow.json index 55622ec730..89d2657208 100644 --- a/web/i18n/ru-RU/workflow.json +++ b/web/i18n/ru-RU/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "Введите текст в поле ниже, чтобы начать отладку чат-бота", "common.processData": "Обработка данных", "common.publish": "Опубликовать", + "common.publishToMarketplace": "Опубликовать в Marketplace", + "common.publishToMarketplaceFailed": "Не удалось опубликовать в Marketplace", "common.publishUpdate": "Опубликовать обновление", "common.published": "Опубликовано", "common.publishedAt": "Опубликовано", + "common.publishingToMarketplace": "Публикация...", "common.redo": "Повторить", "common.restart": "Перезапустить", "common.restore": "Восстановить", diff --git a/web/i18n/sl-SI/app.json b/web/i18n/sl-SI/app.json index ce09d32059..a8a14d7488 100644 --- a/web/i18n/sl-SI/app.json +++ b/web/i18n/sl-SI/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "Emoji", "iconPicker.image": "Slika", "iconPicker.ok": "V redu", + "importApp": "Uvozi aplikacijo", "importDSL": "Uvozi datoteko DSL", "importFromDSL": "Uvozi iz DSL", "importFromDSLFile": "Iz datoteke DSL", "importFromDSLUrl": "Iz URL-ja", "importFromDSLUrlPlaceholder": "Tukaj prilepi povezavo DSL", "join": "Pridruži se skupnosti", + "marketplace.template.categories": "Kategorije", + "marketplace.template.category.design": "Oblikovanje", + "marketplace.template.category.it": "IT", + "marketplace.template.category.knowledge": "Znanje", + "marketplace.template.category.marketing": "Trženje", + "marketplace.template.category.operations": "Operacije", + "marketplace.template.category.sales": "Prodaja", + "marketplace.template.category.support": "Podpora", + "marketplace.template.fetchFailed": "Pridobivanje predloge ni uspelo", + "marketplace.template.importConfirm": "Uvozi", + "marketplace.template.importFailed": "Uvoz predloge ni uspel", + "marketplace.template.modalTitle": "Uvozi iz Marketplace", + "marketplace.template.overview": "Pregled", + "marketplace.template.publishedBy": "Avtor", + "marketplace.template.usageCount": "Uporaba", + "marketplace.template.viewOnMarketplace": "Ogled na Marketplace", "maxActiveRequests": "Maksimalno število hkratnih zahtevkov", "maxActiveRequestsPlaceholder": "Vnesite 0 za neomejeno", "maxActiveRequestsTip": "Največje število hkrati aktivnih zahtevkov na aplikacijo (0 za neomejeno)", diff --git a/web/i18n/sl-SI/workflow.json b/web/i18n/sl-SI/workflow.json index 7ea8dedec4..a7c2914626 100644 --- a/web/i18n/sl-SI/workflow.json +++ b/web/i18n/sl-SI/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "Vnesite vsebino v spodnje polje, da začnete odpravljati napake v chatbotu", "common.processData": "Obdelava podatkov", "common.publish": "Objavi", + "common.publishToMarketplace": "Objavi na Marketplace", + "common.publishToMarketplaceFailed": "Objava na Marketplace ni uspela", "common.publishUpdate": "Objavi posodobitev", "common.published": "Objavljeno", "common.publishedAt": "Objavljeno", + "common.publishingToMarketplace": "Objavljanje...", "common.redo": "Ponovno naredi", "common.restart": "Znova zaženi", "common.restore": "Obnovi", diff --git a/web/i18n/th-TH/app.json b/web/i18n/th-TH/app.json index d59a5b8505..624d7b9ec9 100644 --- a/web/i18n/th-TH/app.json +++ b/web/i18n/th-TH/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "อิโมจิ", "iconPicker.image": "ภาพ", "iconPicker.ok": "ตกลง, ได้", + "importApp": "นำเข้าแอป", "importDSL": "นําเข้าไฟล์ DSL", "importFromDSL": "นําเข้าจาก DSL", "importFromDSLFile": "จากไฟล์ DSL", "importFromDSLUrl": "จาก URL", "importFromDSLUrlPlaceholder": "วางลิงค์ DSL ที่นี่", "join": "เข้าร่วมชุมชนนักพัฒนา", + "marketplace.template.categories": "หมวดหมู่", + "marketplace.template.category.design": "การออกแบบ", + "marketplace.template.category.it": "ไอที", + "marketplace.template.category.knowledge": "ความรู้", + "marketplace.template.category.marketing": "การตลาด", + "marketplace.template.category.operations": "การดำเนินงาน", + "marketplace.template.category.sales": "การขาย", + "marketplace.template.category.support": "การสนับสนุน", + "marketplace.template.fetchFailed": "ดึงข้อมูลเทมเพลตล้มเหลว", + "marketplace.template.importConfirm": "นำเข้า", + "marketplace.template.importFailed": "นำเข้าเทมเพลตล้มเหลว", + "marketplace.template.modalTitle": "นำเข้าจาก Marketplace", + "marketplace.template.overview": "ภาพรวม", + "marketplace.template.publishedBy": "โดย", + "marketplace.template.usageCount": "การใช้งาน", + "marketplace.template.viewOnMarketplace": "ดูบน Marketplace", "maxActiveRequests": "จำนวนคำขอพร้อมกันสูงสุด", "maxActiveRequestsPlaceholder": "ใส่ 0 สำหรับไม่จำกัด", "maxActiveRequestsTip": "จำนวนการร้องขอที่ใช้งานพร้อมกันสูงสุดต่อแอป (0 หมายถึงไม่จำกัด)", diff --git a/web/i18n/th-TH/workflow.json b/web/i18n/th-TH/workflow.json index e1280cf438..d8a9b53f2a 100644 --- a/web/i18n/th-TH/workflow.json +++ b/web/i18n/th-TH/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "ป้อนเนื้อหาในช่องด้านล่างเพื่อเริ่มแก้ไขข้อบกพร่องของแชทบอท", "common.processData": "ประมวลผลข้อมูล", "common.publish": "ตีพิมพ์", + "common.publishToMarketplace": "เผยแพร่ไปยัง Marketplace", + "common.publishToMarketplaceFailed": "เผยแพร่ไปยัง Marketplace ล้มเหลว", "common.publishUpdate": "เผยแพร่การอัปเดต", "common.published": "เผย แพร่", "common.publishedAt": "เผย แพร่", + "common.publishingToMarketplace": "กำลังเผยแพร่...", "common.redo": "พร้อม", "common.restart": "เริ่มใหม่", "common.restore": "ซ่อมแซม", diff --git a/web/i18n/tr-TR/app.json b/web/i18n/tr-TR/app.json index 2978f7cffd..aa10f954e9 100644 --- a/web/i18n/tr-TR/app.json +++ b/web/i18n/tr-TR/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "Emoji", "iconPicker.image": "Görsel", "iconPicker.ok": "Tamam", + "importApp": "Uygulamayı İçe Aktar", "importDSL": "DSL dosyasını içe aktar", "importFromDSL": "DSL içe aktar", "importFromDSLFile": "DSL dosyasından", "importFromDSLUrl": "URL'den", "importFromDSLUrlPlaceholder": "DSL bağlantısını buraya yapıştır", "join": "Topluluğa katıl", + "marketplace.template.categories": "Kategoriler", + "marketplace.template.category.design": "Tasarım", + "marketplace.template.category.it": "BT", + "marketplace.template.category.knowledge": "Bilgi", + "marketplace.template.category.marketing": "Pazarlama", + "marketplace.template.category.operations": "Operasyonlar", + "marketplace.template.category.sales": "Satış", + "marketplace.template.category.support": "Destek", + "marketplace.template.fetchFailed": "Şablon alınamadı", + "marketplace.template.importConfirm": "İçe Aktar", + "marketplace.template.importFailed": "Şablon içe aktarılamadı", + "marketplace.template.modalTitle": "Marketplace'den İçe Aktar", + "marketplace.template.overview": "Genel Bakış", + "marketplace.template.publishedBy": "Yayıncı", + "marketplace.template.usageCount": "Kullanım", + "marketplace.template.viewOnMarketplace": "Marketplace'de Görüntüle", "maxActiveRequests": "Maksimum eş zamanlı istekler", "maxActiveRequestsPlaceholder": "Sınırsız için 0 girin", "maxActiveRequestsTip": "Her uygulama için maksimum eşzamanlı aktif istek sayısı (sınırsız için 0)", diff --git a/web/i18n/tr-TR/workflow.json b/web/i18n/tr-TR/workflow.json index 54ee28cf1c..7cd69d7df1 100644 --- a/web/i18n/tr-TR/workflow.json +++ b/web/i18n/tr-TR/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "Sohbet Robotunu hata ayıklamak için aşağıdaki kutuya içerik girin", "common.processData": "Veriyi İşle", "common.publish": "Yayınla", + "common.publishToMarketplace": "Marketplace'de Yayınla", + "common.publishToMarketplaceFailed": "Marketplace'de Yayınlama Başarısız", "common.publishUpdate": "Güncellemeyi Yayınla", "common.published": "Yayınlandı", "common.publishedAt": "Yayınlandı", + "common.publishingToMarketplace": "Yayınlanıyor...", "common.redo": "Yinele", "common.restart": "Yeniden Başlat", "common.restore": "Geri Yükle", diff --git a/web/i18n/uk-UA/app.json b/web/i18n/uk-UA/app.json index f224f0c31f..f88e1e60f9 100644 --- a/web/i18n/uk-UA/app.json +++ b/web/i18n/uk-UA/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "Емодзі", "iconPicker.image": "Зображення", "iconPicker.ok": "OK", + "importApp": "Імпортувати додаток", "importDSL": "Імпортувати файл DSL", "importFromDSL": "Імпорт з DSL", "importFromDSLFile": "З DSL-файлу", "importFromDSLUrl": "З URL", "importFromDSLUrlPlaceholder": "Вставте посилання на DSL тут", "join": "Приєднуйтесь до спільноти", + "marketplace.template.categories": "Категорії", + "marketplace.template.category.design": "Дизайн", + "marketplace.template.category.it": "ІТ", + "marketplace.template.category.knowledge": "Знання", + "marketplace.template.category.marketing": "Маркетинг", + "marketplace.template.category.operations": "Операції", + "marketplace.template.category.sales": "Продажі", + "marketplace.template.category.support": "Підтримка", + "marketplace.template.fetchFailed": "Не вдалося отримати шаблон", + "marketplace.template.importConfirm": "Імпортувати", + "marketplace.template.importFailed": "Не вдалося імпортувати шаблон", + "marketplace.template.modalTitle": "Імпортувати з Marketplace", + "marketplace.template.overview": "Огляд", + "marketplace.template.publishedBy": "Від", + "marketplace.template.usageCount": "Використання", + "marketplace.template.viewOnMarketplace": "Переглянути на Marketplace", "maxActiveRequests": "Максимальна кількість одночасних запитів", "maxActiveRequestsPlaceholder": "Введіть 0 для необмеженого", "maxActiveRequestsTip": "Максимальна кількість одночасних активних запитів на додаток (0 для необмеженої кількості)", diff --git a/web/i18n/uk-UA/workflow.json b/web/i18n/uk-UA/workflow.json index 94f869845e..44d527618e 100644 --- a/web/i18n/uk-UA/workflow.json +++ b/web/i18n/uk-UA/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "Введіть вміст у поле нижче, щоб розпочати налагодження чат-бота", "common.processData": "Обробити дані", "common.publish": "Опублікувати", + "common.publishToMarketplace": "Опублікувати на Marketplace", + "common.publishToMarketplaceFailed": "Не вдалося опублікувати на Marketplace", "common.publishUpdate": "Опублікувати оновлення", "common.published": "Опубліковано", "common.publishedAt": "Опубліковано о", + "common.publishingToMarketplace": "Публікація...", "common.redo": "Повторити", "common.restart": "Перезапустити", "common.restore": "Відновити", diff --git a/web/i18n/vi-VN/app.json b/web/i18n/vi-VN/app.json index 399d2dccf5..2be7906afb 100644 --- a/web/i18n/vi-VN/app.json +++ b/web/i18n/vi-VN/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "Biểu tượng cảm xúc", "iconPicker.image": "Hình ảnh", "iconPicker.ok": "Đồng ý", + "importApp": "Nhập App", "importDSL": "Nhập tệp DSL", "importFromDSL": "Nhập từ DSL", "importFromDSLFile": "Từ tệp DSL", "importFromDSLUrl": "Từ URL", "importFromDSLUrlPlaceholder": "Dán liên kết DSL vào đây", "join": "Tham gia cộng đồng", + "marketplace.template.categories": "Danh mục", + "marketplace.template.category.design": "Thiết kế", + "marketplace.template.category.it": "CNTT", + "marketplace.template.category.knowledge": "Kiến thức", + "marketplace.template.category.marketing": "Marketing", + "marketplace.template.category.operations": "Vận hành", + "marketplace.template.category.sales": "Bán hàng", + "marketplace.template.category.support": "Hỗ trợ", + "marketplace.template.fetchFailed": "Không thể lấy mẫu", + "marketplace.template.importConfirm": "Nhập", + "marketplace.template.importFailed": "Không thể nhập mẫu", + "marketplace.template.modalTitle": "Nhập từ Marketplace", + "marketplace.template.overview": "Tổng quan", + "marketplace.template.publishedBy": "Bởi", + "marketplace.template.usageCount": "Lượt sử dụng", + "marketplace.template.viewOnMarketplace": "Xem trên Marketplace", "maxActiveRequests": "Số yêu cầu đồng thời tối đa", "maxActiveRequestsPlaceholder": "Nhập 0 để không giới hạn", "maxActiveRequestsTip": "Số yêu cầu hoạt động đồng thời tối đa cho mỗi ứng dụng (0 để không giới hạn)", diff --git a/web/i18n/vi-VN/workflow.json b/web/i18n/vi-VN/workflow.json index 377a794464..231c01bc82 100644 --- a/web/i18n/vi-VN/workflow.json +++ b/web/i18n/vi-VN/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "Nhập nội dung vào hộp bên dưới để bắt đầu gỡ lỗi Chatbot", "common.processData": "Xử lý dữ liệu", "common.publish": "Xuất bản", + "common.publishToMarketplace": "Xuất bản lên Marketplace", + "common.publishToMarketplaceFailed": "Xuất bản lên Marketplace thất bại", "common.publishUpdate": "Cập nhật xuất bản", "common.published": "Đã xuất bản", "common.publishedAt": "Đã xuất bản lúc", + "common.publishingToMarketplace": "Đang xuất bản...", "common.redo": "Làm lại", "common.restart": "Khởi động lại", "common.restore": "Khôi phục", diff --git a/web/i18n/zh-Hant/app.json b/web/i18n/zh-Hant/app.json index a7fbcfd65f..7c485b6520 100644 --- a/web/i18n/zh-Hant/app.json +++ b/web/i18n/zh-Hant/app.json @@ -118,12 +118,29 @@ "iconPicker.emoji": "表情符號", "iconPicker.image": "圖片", "iconPicker.ok": "確認", + "importApp": "匯入應用", "importDSL": "匯入 DSL 檔案", "importFromDSL": "從 DSL 導入", "importFromDSLFile": "從 DSL 檔", "importFromDSLUrl": "寄件者 URL", "importFromDSLUrlPlaceholder": "在此處貼上 DSL 連結", "join": "參與社群", + "marketplace.template.categories": "分類", + "marketplace.template.category.design": "設計", + "marketplace.template.category.it": "IT", + "marketplace.template.category.knowledge": "知識", + "marketplace.template.category.marketing": "行銷", + "marketplace.template.category.operations": "營運", + "marketplace.template.category.sales": "銷售", + "marketplace.template.category.support": "支援", + "marketplace.template.fetchFailed": "獲取模板失敗", + "marketplace.template.importConfirm": "匯入", + "marketplace.template.importFailed": "匯入模板失敗", + "marketplace.template.modalTitle": "從市場匯入", + "marketplace.template.overview": "概覽", + "marketplace.template.publishedBy": "由", + "marketplace.template.usageCount": "使用次數", + "marketplace.template.viewOnMarketplace": "在市場上查看", "maxActiveRequests": "同時最大請求數", "maxActiveRequestsPlaceholder": "輸入 0 以表示無限", "maxActiveRequestsTip": "每個應用程式可同時活躍請求的最大數量(0為無限制)", diff --git a/web/i18n/zh-Hant/workflow.json b/web/i18n/zh-Hant/workflow.json index 1e10badec0..9d296250db 100644 --- a/web/i18n/zh-Hant/workflow.json +++ b/web/i18n/zh-Hant/workflow.json @@ -229,9 +229,12 @@ "common.previewPlaceholder": "在下面的框中輸入內容開始測試聊天機器人", "common.processData": "資料處理", "common.publish": "發佈", + "common.publishToMarketplace": "發佈到市場", + "common.publishToMarketplaceFailed": "發佈到市場失敗", "common.publishUpdate": "發布更新", "common.published": "已發佈", "common.publishedAt": "發佈於", + "common.publishingToMarketplace": "發佈中...", "common.redo": "重做", "common.restart": "重新開始", "common.restore": "恢復", From c3aebb8403a24cef7c8e3cc323a4d74a1ad60fda Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 24 Apr 2026 17:35:20 +0900 Subject: [PATCH 012/128] chore: fix use select style api in orm (#35531) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: WH-2099 --- .../data_exporter/test_traceclient.py | 2 + .../entities/test_aliyun_trace_entity.py | 13 +- .../aliyun_trace/test_aliyun_trace.py | 95 +- .../aliyun_trace/test_aliyun_trace_utils.py | 8 +- .../tests/unit_tests/test_config_entity.py | 6 +- .../test_arize_phoenix_trace.py | 3 +- .../tests/unit_tests/test_config_entity.py | 6 +- .../tests/unit_tests/test_langfuse_trace.py | 3 +- .../tests/unit_tests/test_config_entity.py | 6 +- .../mlflow_trace/test_mlflow_trace.py | 6 - .../tests/unit_tests/test_opik_trace.py | 34 +- .../unit_tests/tencent_trace/test_client.py | 95 +- .../tests/unit_tests/test_config_entity.py | 6 +- .../app/test_chat_message_permissions.py | 28 +- .../web/test_web_forgot_password.py | 1 - .../services/test_feedback_service.py | 210 +- api/tests/unit_tests/conftest.py | 27 +- .../console/app/test_annotation_security.py | 8 - .../auth/test_authentication_security.py | 4 - .../console/auth/test_email_verification.py | 14 - .../console/auth/test_login_logout.py | 12 - .../console/billing/test_billing.py | 1 - .../console/test_workspace_account.py | 15 - .../console/test_workspace_members.py | 7 +- .../controllers/console/test_wraps.py | 1 - .../console/workspace/test_tool_providers.py | 2 +- .../controllers/service_api/app/test_app.py | 20 +- .../controllers/service_api/conftest.py | 12 +- .../service_api/dataset/test_document.py | 4 +- .../controllers/service_api/test_wraps.py | 13 +- .../unit_tests/controllers/web/conftest.py | 18 +- .../controllers/web/test_human_input_form.py | 12 - .../controllers/web/test_web_login.py | 1 - .../test_app_runner_conversation_variables.py | 3 - .../apps/pipeline/test_pipeline_generator.py | 2 +- .../app/apps/pipeline/test_pipeline_runner.py | 9 +- .../core/datasource/test_notion_provider.py | 10 - .../unit_tests/core/helper/test_encrypter.py | 32 +- .../core/llm_generator/test_llm_generator.py | 8 +- .../datasource/keyword/jieba/test_jieba.py | 9 - .../datasource/test_datasource_retrieval.py | 11 - .../rag/datasource/vdb/test_vector_factory.py | 8 - .../core/rag/indexing/test_indexing_runner.py | 7 +- .../rag/retrieval/test_dataset_retrieval.py | 11 +- .../test_dataset_retrieval_methods.py | 11 +- .../services/document_indexing_task_proxy.py | 1291 ------------ .../services/external_dataset_service.py | 925 --------- .../rag_pipeline/test_rag_pipeline_service.py | 25 +- .../unit_tests/services/segment_service.py | 1115 ---------- .../unit_tests/services/services_test_help.py | 59 - .../services/test_account_service.py | 14 +- .../services/test_dataset_service_segment.py | 1 - .../test_datasource_provider_service.py | 18 +- .../test_webhook_service_additional.py | 17 - .../services/test_workflow_service.py | 6 - .../unit_tests/services/vector_service.py | 1793 ----------------- .../tasks/test_dataset_indexing_task.py | 26 - 57 files changed, 347 insertions(+), 5757 deletions(-) delete mode 100644 api/tests/unit_tests/services/document_indexing_task_proxy.py delete mode 100644 api/tests/unit_tests/services/external_dataset_service.py delete mode 100644 api/tests/unit_tests/services/segment_service.py delete mode 100644 api/tests/unit_tests/services/services_test_help.py delete mode 100644 api/tests/unit_tests/services/vector_service.py diff --git a/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/data_exporter/test_traceclient.py b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/data_exporter/test_traceclient.py index 286dda419c..ac09060e9d 100644 --- a/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/data_exporter/test_traceclient.py +++ b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/data_exporter/test_traceclient.py @@ -225,8 +225,10 @@ class TestSpanBuilder: span = builder.build_span(span_data) assert isinstance(span, ReadableSpan) assert span.name == "test-span" + assert span.context is not None assert span.context.trace_id == 123 assert span.context.span_id == 456 + assert span.parent is not None assert span.parent.span_id == 789 assert span.resource == resource assert span.attributes == {"attr1": "val1"} diff --git a/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/entities/test_aliyun_trace_entity.py b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/entities/test_aliyun_trace_entity.py index 38d33dd21b..a6808fec0a 100644 --- a/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/entities/test_aliyun_trace_entity.py +++ b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/entities/test_aliyun_trace_entity.py @@ -64,12 +64,13 @@ class TestSpanData: def test_span_data_missing_required_fields(self): with pytest.raises(ValidationError): - SpanData( - trace_id=123, - # span_id missing - name="test_span", - start_time=1000, - end_time=2000, + SpanData.model_validate( + { + "trace_id": 123, + "name": "test_span", + "start_time": 1000, + "end_time": 2000, + } ) def test_span_data_arbitrary_types_allowed(self): diff --git a/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace.py b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace.py index c1b11c9186..fa00829653 100644 --- a/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace.py +++ b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace.py @@ -2,12 +2,14 @@ from __future__ import annotations from datetime import UTC, datetime from types import SimpleNamespace +from typing import cast from unittest.mock import MagicMock import dify_trace_aliyun.aliyun_trace as aliyun_trace_module import pytest from dify_trace_aliyun.aliyun_trace import AliyunDataTrace from dify_trace_aliyun.config import AliyunConfig +from dify_trace_aliyun.entities.aliyun_trace_entity import SpanData, TraceMetadata from dify_trace_aliyun.entities.semconv import ( GEN_AI_COMPLETION, GEN_AI_INPUT_MESSAGE, @@ -44,7 +46,7 @@ class RecordingTraceClient: self.endpoint = endpoint self.added_spans: list[object] = [] - def add_span(self, span) -> None: + def add_span(self, span: object) -> None: self.added_spans.append(span) def api_check(self) -> bool: @@ -63,11 +65,35 @@ def _make_link(trace_id: int = 1, span_id: int = 2) -> Link: trace_id=trace_id, span_id=span_id, is_remote=False, - trace_flags=TraceFlags.SAMPLED, + trace_flags=TraceFlags(TraceFlags.SAMPLED), ) return Link(context) +def _make_trace_metadata( + trace_id: int = 1, + workflow_span_id: int = 2, + session_id: str = "s", + user_id: str = "u", + links: list[Link] | None = None, +) -> TraceMetadata: + return TraceMetadata( + trace_id=trace_id, + workflow_span_id=workflow_span_id, + session_id=session_id, + user_id=user_id, + links=[] if links is None else links, + ) + + +def _recording_trace_client(trace_instance: AliyunDataTrace) -> RecordingTraceClient: + return cast(RecordingTraceClient, trace_instance.trace_client) + + +def _recorded_span_data(trace_instance: AliyunDataTrace) -> list[SpanData]: + return cast(list[SpanData], _recording_trace_client(trace_instance).added_spans) + + def _make_workflow_trace_info(**overrides) -> WorkflowTraceInfo: defaults = { "workflow_id": "workflow-id", @@ -263,20 +289,20 @@ def test_workflow_trace_adds_workflow_and_node_spans(trace_instance: AliyunDataT trace_instance.workflow_trace(trace_info) add_workflow_span.assert_called_once() - passed_trace_metadata = add_workflow_span.call_args.args[1] + passed_trace_metadata = cast(TraceMetadata, add_workflow_span.call_args.args[1]) assert passed_trace_metadata.trace_id == 111 assert passed_trace_metadata.workflow_span_id == 222 assert passed_trace_metadata.session_id == "c" assert passed_trace_metadata.user_id == "u" assert passed_trace_metadata.links == [] - assert trace_instance.trace_client.added_spans == ["span-1", "span-2"] + assert _recording_trace_client(trace_instance).added_spans == ["span-1", "span-2"] def test_message_trace_returns_early_if_no_message_data(trace_instance: AliyunDataTrace): trace_info = _make_message_trace_info(message_data=None) trace_instance.message_trace(trace_info) - assert trace_instance.trace_client.added_spans == [] + assert _recording_trace_client(trace_instance).added_spans == [] def test_message_trace_creates_message_and_llm_spans(trace_instance: AliyunDataTrace, monkeypatch: pytest.MonkeyPatch): @@ -302,8 +328,9 @@ def test_message_trace_creates_message_and_llm_spans(trace_instance: AliyunDataT ) trace_instance.message_trace(trace_info) - assert len(trace_instance.trace_client.added_spans) == 2 - message_span, llm_span = trace_instance.trace_client.added_spans + spans = _recorded_span_data(trace_instance) + assert len(spans) == 2 + message_span, llm_span = spans assert message_span.name == "message" assert message_span.trace_id == 10 @@ -324,7 +351,7 @@ def test_message_trace_creates_message_and_llm_spans(trace_instance: AliyunDataT def test_dataset_retrieval_trace_returns_early_if_no_message_data(trace_instance: AliyunDataTrace): trace_info = _make_dataset_retrieval_trace_info(message_data=None) trace_instance.dataset_retrieval_trace(trace_info) - assert trace_instance.trace_client.added_spans == [] + assert _recording_trace_client(trace_instance).added_spans == [] def test_dataset_retrieval_trace_creates_span(trace_instance: AliyunDataTrace, monkeypatch: pytest.MonkeyPatch): @@ -338,8 +365,9 @@ def test_dataset_retrieval_trace_creates_span(trace_instance: AliyunDataTrace, m monkeypatch.setattr(aliyun_trace_module, "extract_retrieval_documents", lambda _: [{"doc": "d"}]) trace_instance.dataset_retrieval_trace(_make_dataset_retrieval_trace_info(inputs="query")) - assert len(trace_instance.trace_client.added_spans) == 1 - span = trace_instance.trace_client.added_spans[0] + spans = _recorded_span_data(trace_instance) + assert len(spans) == 1 + span = spans[0] assert span.name == "dataset_retrieval" assert span.attributes[RETRIEVAL_QUERY] == "query" assert span.attributes[RETRIEVAL_DOCUMENT] == '[{"doc": "d"}]' @@ -348,7 +376,7 @@ def test_dataset_retrieval_trace_creates_span(trace_instance: AliyunDataTrace, m def test_tool_trace_returns_early_if_no_message_data(trace_instance: AliyunDataTrace): trace_info = _make_tool_trace_info(message_data=None) trace_instance.tool_trace(trace_info) - assert trace_instance.trace_client.added_spans == [] + assert _recording_trace_client(trace_instance).added_spans == [] def test_tool_trace_creates_span(trace_instance: AliyunDataTrace, monkeypatch: pytest.MonkeyPatch): @@ -371,8 +399,9 @@ def test_tool_trace_creates_span(trace_instance: AliyunDataTrace, monkeypatch: p ) ) - assert len(trace_instance.trace_client.added_spans) == 1 - span = trace_instance.trace_client.added_spans[0] + spans = _recorded_span_data(trace_instance) + assert len(spans) == 1 + span = spans[0] assert span.name == "my-tool" assert span.status == status assert span.attributes[TOOL_NAME] == "my-tool" @@ -409,7 +438,7 @@ def test_get_workflow_node_executions_builds_repo_and_fetches( def test_build_workflow_node_span_routes_llm_type(trace_instance: AliyunDataTrace, monkeypatch: pytest.MonkeyPatch): node_execution = MagicMock(spec=WorkflowNodeExecution) trace_info = _make_workflow_trace_info() - trace_metadata = MagicMock() + trace_metadata = _make_trace_metadata() monkeypatch.setattr(trace_instance, "build_workflow_llm_span", MagicMock(return_value="llm")) @@ -422,7 +451,7 @@ def test_build_workflow_node_span_routes_knowledge_retrieval_type( ): node_execution = MagicMock(spec=WorkflowNodeExecution) trace_info = _make_workflow_trace_info() - trace_metadata = MagicMock() + trace_metadata = _make_trace_metadata() monkeypatch.setattr(trace_instance, "build_workflow_retrieval_span", MagicMock(return_value="retrieval")) @@ -433,7 +462,7 @@ def test_build_workflow_node_span_routes_knowledge_retrieval_type( def test_build_workflow_node_span_routes_tool_type(trace_instance: AliyunDataTrace, monkeypatch: pytest.MonkeyPatch): node_execution = MagicMock(spec=WorkflowNodeExecution) trace_info = _make_workflow_trace_info() - trace_metadata = MagicMock() + trace_metadata = _make_trace_metadata() monkeypatch.setattr(trace_instance, "build_workflow_tool_span", MagicMock(return_value="tool")) @@ -444,7 +473,7 @@ def test_build_workflow_node_span_routes_tool_type(trace_instance: AliyunDataTra def test_build_workflow_node_span_routes_code_type(trace_instance: AliyunDataTrace, monkeypatch: pytest.MonkeyPatch): node_execution = MagicMock(spec=WorkflowNodeExecution) trace_info = _make_workflow_trace_info() - trace_metadata = MagicMock() + trace_metadata = _make_trace_metadata() monkeypatch.setattr(trace_instance, "build_workflow_task_span", MagicMock(return_value="task")) @@ -457,7 +486,7 @@ def test_build_workflow_node_span_handles_errors( ): node_execution = MagicMock(spec=WorkflowNodeExecution) trace_info = _make_workflow_trace_info() - trace_metadata = MagicMock() + trace_metadata = _make_trace_metadata() monkeypatch.setattr(trace_instance, "build_workflow_task_span", MagicMock(side_effect=RuntimeError("boom"))) node_execution.node_type = BuiltinNodeTypes.CODE @@ -472,7 +501,7 @@ def test_build_workflow_task_span(trace_instance: AliyunDataTrace, monkeypatch: status = Status(StatusCode.OK) monkeypatch.setattr(aliyun_trace_module, "get_workflow_node_status", lambda _: status) - trace_metadata = SimpleNamespace(trace_id=1, workflow_span_id=2, session_id="s", user_id="u", links=[]) + trace_metadata = _make_trace_metadata() node_execution = MagicMock(spec=WorkflowNodeExecution) node_execution.id = "node-id" node_execution.title = "title" @@ -494,7 +523,7 @@ def test_build_workflow_tool_span(trace_instance: AliyunDataTrace, monkeypatch: status = Status(StatusCode.OK) monkeypatch.setattr(aliyun_trace_module, "get_workflow_node_status", lambda _: status) - trace_metadata = SimpleNamespace(trace_id=1, workflow_span_id=2, session_id="s", user_id="u", links=[_make_link()]) + trace_metadata = _make_trace_metadata(links=[_make_link()]) node_execution = MagicMock(spec=WorkflowNodeExecution) node_execution.id = "node-id" node_execution.title = "my-tool" @@ -527,7 +556,7 @@ def test_build_workflow_retrieval_span(trace_instance: AliyunDataTrace, monkeypa aliyun_trace_module, "format_retrieval_documents", lambda docs: [{"formatted": True}] if docs else [] ) - trace_metadata = SimpleNamespace(trace_id=1, workflow_span_id=2, session_id="s", user_id="u", links=[]) + trace_metadata = _make_trace_metadata() node_execution = MagicMock(spec=WorkflowNodeExecution) node_execution.id = "node-id" node_execution.title = "retrieval" @@ -556,7 +585,7 @@ def test_build_workflow_llm_span(trace_instance: AliyunDataTrace, monkeypatch: p monkeypatch.setattr(aliyun_trace_module, "format_input_messages", lambda _: "in") monkeypatch.setattr(aliyun_trace_module, "format_output_messages", lambda _: "out") - trace_metadata = SimpleNamespace(trace_id=1, workflow_span_id=2, session_id="s", user_id="u", links=[]) + trace_metadata = _make_trace_metadata() node_execution = MagicMock(spec=WorkflowNodeExecution) node_execution.id = "node-id" node_execution.title = "llm" @@ -594,7 +623,7 @@ def test_add_workflow_span(trace_instance: AliyunDataTrace, monkeypatch: pytest. status = Status(StatusCode.OK) monkeypatch.setattr(aliyun_trace_module, "create_status_from_error", lambda _: status) - trace_metadata = SimpleNamespace(trace_id=1, workflow_span_id=2, session_id="s", user_id="u", links=[]) + trace_metadata = _make_trace_metadata() # CASE 1: With message_id trace_info = _make_workflow_trace_info( @@ -602,9 +631,11 @@ def test_add_workflow_span(trace_instance: AliyunDataTrace, monkeypatch: pytest. ) trace_instance.add_workflow_span(trace_info, trace_metadata) - assert len(trace_instance.trace_client.added_spans) == 2 - message_span = trace_instance.trace_client.added_spans[0] - workflow_span = trace_instance.trace_client.added_spans[1] + client = _recording_trace_client(trace_instance) + spans = _recorded_span_data(trace_instance) + assert len(spans) == 2 + message_span = spans[0] + workflow_span = spans[1] assert message_span.name == "message" assert message_span.span_kind == SpanKind.SERVER @@ -614,13 +645,14 @@ def test_add_workflow_span(trace_instance: AliyunDataTrace, monkeypatch: pytest. assert workflow_span.span_kind == SpanKind.INTERNAL assert workflow_span.parent_span_id == 20 - trace_instance.trace_client.added_spans.clear() + client.added_spans.clear() # CASE 2: Without message_id trace_info_no_msg = _make_workflow_trace_info(message_id=None) trace_instance.add_workflow_span(trace_info_no_msg, trace_metadata) - assert len(trace_instance.trace_client.added_spans) == 1 - span = trace_instance.trace_client.added_spans[0] + spans = _recorded_span_data(trace_instance) + assert len(spans) == 1 + span = spans[0] assert span.name == "workflow" assert span.span_kind == SpanKind.SERVER assert span.parent_span_id is None @@ -641,7 +673,8 @@ def test_suggested_question_trace(trace_instance: AliyunDataTrace, monkeypatch: trace_info = _make_suggested_question_trace_info(suggested_question=["how?"]) trace_instance.suggested_question_trace(trace_info) - assert len(trace_instance.trace_client.added_spans) == 1 - span = trace_instance.trace_client.added_spans[0] + spans = _recorded_span_data(trace_instance) + assert len(spans) == 1 + span = spans[0] assert span.name == "suggested_question" assert span.attributes[GEN_AI_COMPLETION] == '["how?"]' diff --git a/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace_utils.py b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace_utils.py index a9e7b80c2a..1b97746dea 100644 --- a/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace_utils.py +++ b/api/providers/trace/trace-aliyun/tests/unit_tests/aliyun_trace/test_aliyun_trace_utils.py @@ -1,4 +1,6 @@ import json +from collections.abc import Mapping +from typing import Any, cast from unittest.mock import MagicMock from dify_trace_aliyun.entities.semconv import ( @@ -170,7 +172,7 @@ def test_create_common_span_attributes(): def test_format_retrieval_documents(): # Not a list - assert format_retrieval_documents("not a list") == [] + assert format_retrieval_documents(cast(list[object], "not a list")) == [] # Valid list docs = [ @@ -211,7 +213,7 @@ def test_format_retrieval_documents(): def test_format_input_messages(): # Not a dict - assert format_input_messages(None) == serialize_json_data([]) + assert format_input_messages(cast(Mapping[str, Any], None)) == serialize_json_data([]) # No prompts assert format_input_messages({}) == serialize_json_data([]) @@ -244,7 +246,7 @@ def test_format_input_messages(): def test_format_output_messages(): # Not a dict - assert format_output_messages(None) == serialize_json_data([]) + assert format_output_messages(cast(Mapping[str, Any], None)) == serialize_json_data([]) # No text assert format_output_messages({"finish_reason": "stop"}) == serialize_json_data([]) diff --git a/api/providers/trace/trace-aliyun/tests/unit_tests/test_config_entity.py b/api/providers/trace/trace-aliyun/tests/unit_tests/test_config_entity.py index 1b24ee7421..8068ee1328 100644 --- a/api/providers/trace/trace-aliyun/tests/unit_tests/test_config_entity.py +++ b/api/providers/trace/trace-aliyun/tests/unit_tests/test_config_entity.py @@ -25,13 +25,13 @@ class TestAliyunConfig: def test_missing_required_fields(self): """Test that required fields are enforced""" with pytest.raises(ValidationError): - AliyunConfig() + AliyunConfig.model_validate({}) with pytest.raises(ValidationError): - AliyunConfig(license_key="test_license") + AliyunConfig.model_validate({"license_key": "test_license"}) with pytest.raises(ValidationError): - AliyunConfig(endpoint="https://tracing-analysis-dc-hz.aliyuncs.com") + AliyunConfig.model_validate({"endpoint": "https://tracing-analysis-dc-hz.aliyuncs.com"}) def test_app_name_validation_empty(self): """Test app_name validation with empty value""" diff --git a/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py index b0691a87ea..e9ecc2e083 100644 --- a/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py +++ b/api/providers/trace/trace-arize-phoenix/tests/unit_tests/arize_phoenix_trace/test_arize_phoenix_trace.py @@ -1,4 +1,5 @@ from datetime import UTC, datetime, timedelta +from typing import cast from unittest.mock import MagicMock, patch import pytest @@ -129,7 +130,7 @@ def test_set_span_status(): return "SilentErrorRepr" span.reset_mock() - set_span_status(span, SilentError()) + set_span_status(span, cast(Exception | str | None, SilentError())) assert span.add_event.call_args[1]["attributes"][OTELSpanAttributes.EXCEPTION_MESSAGE] == "SilentErrorRepr" diff --git a/api/providers/trace/trace-langfuse/tests/unit_tests/test_config_entity.py b/api/providers/trace/trace-langfuse/tests/unit_tests/test_config_entity.py index 103d888eef..0c3c3fc81e 100644 --- a/api/providers/trace/trace-langfuse/tests/unit_tests/test_config_entity.py +++ b/api/providers/trace/trace-langfuse/tests/unit_tests/test_config_entity.py @@ -28,13 +28,13 @@ class TestLangfuseConfig: def test_missing_required_fields(self): """Test that required fields are enforced""" with pytest.raises(ValidationError): - LangfuseConfig() + LangfuseConfig.model_validate({}) with pytest.raises(ValidationError): - LangfuseConfig(public_key="public") + LangfuseConfig.model_validate({"public_key": "public"}) with pytest.raises(ValidationError): - LangfuseConfig(secret_key="secret") + LangfuseConfig.model_validate({"secret_key": "secret"}) def test_host_validation_empty(self): """Test host validation with empty value""" diff --git a/api/providers/trace/trace-langfuse/tests/unit_tests/test_langfuse_trace.py b/api/providers/trace/trace-langfuse/tests/unit_tests/test_langfuse_trace.py index 0340ffb669..82d69b6180 100644 --- a/api/providers/trace/trace-langfuse/tests/unit_tests/test_langfuse_trace.py +++ b/api/providers/trace/trace-langfuse/tests/unit_tests/test_langfuse_trace.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from types import SimpleNamespace +from typing import cast from unittest.mock import MagicMock, patch from dify_trace_langfuse.config import LangfuseConfig @@ -134,4 +135,4 @@ class TestLangFuseDataTraceCompletionStartTime: assert trace._get_completion_start_time(start_time, None) is None assert trace._get_completion_start_time(start_time, -1) is None - assert trace._get_completion_start_time(start_time, "invalid") is None + assert trace._get_completion_start_time(start_time, cast(float | int | None, "invalid")) is None diff --git a/api/providers/trace/trace-langsmith/tests/unit_tests/test_config_entity.py b/api/providers/trace/trace-langsmith/tests/unit_tests/test_config_entity.py index 37efaf69cf..bd226c9f1a 100644 --- a/api/providers/trace/trace-langsmith/tests/unit_tests/test_config_entity.py +++ b/api/providers/trace/trace-langsmith/tests/unit_tests/test_config_entity.py @@ -21,13 +21,13 @@ class TestLangSmithConfig: def test_missing_required_fields(self): """Test that required fields are enforced""" with pytest.raises(ValidationError): - LangSmithConfig() + LangSmithConfig.model_validate({}) with pytest.raises(ValidationError): - LangSmithConfig(api_key="key") + LangSmithConfig.model_validate({"api_key": "key"}) with pytest.raises(ValidationError): - LangSmithConfig(project="project") + LangSmithConfig.model_validate({"project": "project"}) def test_endpoint_validation_https_only(self): """Test endpoint validation only allows HTTPS""" diff --git a/api/providers/trace/trace-mlflow/tests/unit_tests/mlflow_trace/test_mlflow_trace.py b/api/providers/trace/trace-mlflow/tests/unit_tests/mlflow_trace/test_mlflow_trace.py index 20211456e3..46c9750a5d 100644 --- a/api/providers/trace/trace-mlflow/tests/unit_tests/mlflow_trace/test_mlflow_trace.py +++ b/api/providers/trace/trace-mlflow/tests/unit_tests/mlflow_trace/test_mlflow_trace.py @@ -599,7 +599,6 @@ class TestMessageTrace: span = MagicMock() mock_tracing["start"].return_value = span mock_tracing["set"].return_value = "token" - mock_db.session.query.return_value.where.return_value.first.return_value = None trace_instance.message_trace(_make_message_trace_info()) mock_tracing["start"].assert_called_once() @@ -609,7 +608,6 @@ class TestMessageTrace: span = MagicMock() mock_tracing["start"].return_value = span mock_tracing["set"].return_value = "token" - mock_db.session.query.return_value.where.return_value.first.return_value = None trace_info = _make_message_trace_info(error="something broke") trace_instance.message_trace(trace_info) @@ -620,7 +618,6 @@ class TestMessageTrace: span = MagicMock() mock_tracing["start"].return_value = span mock_tracing["set"].return_value = "token" - mock_db.session.query.return_value.where.return_value.first.return_value = None monkeypatch.setenv("FILES_URL", "http://files.test") file_data = SimpleNamespace(url="path/to/file.png") @@ -638,7 +635,6 @@ class TestMessageTrace: span = MagicMock() mock_tracing["start"].return_value = span mock_tracing["set"].return_value = "token" - mock_db.session.query.return_value.where.return_value.first.return_value = None trace_info = _make_message_trace_info(file_list=None, message_file_data=None) trace_instance.message_trace(trace_info) @@ -651,7 +647,6 @@ class TestMessageTrace: end_user = MagicMock() end_user.session_id = "session-xyz" - mock_db.session.query.return_value.where.return_value.first.return_value = end_user trace_info = _make_message_trace_info( metadata={"from_end_user_id": "eu-1", "conversation_id": "c1"}, @@ -664,7 +659,6 @@ class TestMessageTrace: span = MagicMock() mock_tracing["start"].return_value = span mock_tracing["set"].return_value = "token" - mock_db.session.query.return_value.where.return_value.first.return_value = None trace_info = _make_message_trace_info( metadata={"from_account_id": "acc-1"}, diff --git a/api/providers/trace/trace-opik/tests/unit_tests/test_opik_trace.py b/api/providers/trace/trace-opik/tests/unit_tests/test_opik_trace.py index fba290f5b8..2e0796c291 100644 --- a/api/providers/trace/trace-opik/tests/unit_tests/test_opik_trace.py +++ b/api/providers/trace/trace-opik/tests/unit_tests/test_opik_trace.py @@ -12,6 +12,7 @@ from __future__ import annotations import uuid from datetime import datetime +from typing import cast from unittest.mock import MagicMock, patch from dify_trace_opik.opik_trace import OpikDataTrace, _seed_to_uuid4, prepare_opik_uuid @@ -69,6 +70,14 @@ def _make_opik_trace_instance() -> OpikDataTrace: return instance +def _add_trace_mock(instance: OpikDataTrace) -> MagicMock: + return cast(MagicMock, instance.add_trace) + + +def _add_span_mock(instance: OpikDataTrace) -> MagicMock: + return cast(MagicMock, instance.add_span) + + # --------------------------------------------------------------------------- # _seed_to_uuid4 # --------------------------------------------------------------------------- @@ -155,21 +164,21 @@ class TestWorkflowTraceWithoutMessageId: def test_root_span_is_created(self): trace_info = _make_workflow_trace_info(message_id=None) instance = self._run(trace_info) - assert instance.add_span.called + assert _add_span_mock(instance).called def test_root_span_id_matches_expected(self): trace_info = _make_workflow_trace_info(message_id=None) instance = self._run(trace_info) expected = self._expected_root_span_id(trace_info) - root_span_kwargs = instance.add_span.call_args_list[0][0][0] + root_span_kwargs = _add_span_mock(instance).call_args_list[0][0][0] assert root_span_kwargs["id"] == expected def test_root_span_has_no_parent(self): trace_info = _make_workflow_trace_info(message_id=None) instance = self._run(trace_info) - root_span_kwargs = instance.add_span.call_args_list[0][0][0] + root_span_kwargs = _add_span_mock(instance).call_args_list[0][0][0] assert root_span_kwargs["parent_span_id"] is None def test_trace_name_is_workflow_trace(self): @@ -177,21 +186,21 @@ class TestWorkflowTraceWithoutMessageId: trace_info = _make_workflow_trace_info(message_id=None) instance = self._run(trace_info) - trace_kwargs = instance.add_trace.call_args_list[0][0][0] + trace_kwargs = _add_trace_mock(instance).call_args_list[0][0][0] assert trace_kwargs["name"] == TraceTaskName.WORKFLOW_TRACE def test_root_span_name_is_workflow_trace(self): trace_info = _make_workflow_trace_info(message_id=None) instance = self._run(trace_info) - root_span_kwargs = instance.add_span.call_args_list[0][0][0] + root_span_kwargs = _add_span_mock(instance).call_args_list[0][0][0] assert root_span_kwargs["name"] == TraceTaskName.WORKFLOW_TRACE def test_root_span_has_workflow_tag(self): trace_info = _make_workflow_trace_info(message_id=None) instance = self._run(trace_info) - root_span_kwargs = instance.add_span.call_args_list[0][0][0] + root_span_kwargs = _add_span_mock(instance).call_args_list[0][0][0] assert "workflow" in root_span_kwargs["tags"] def test_node_execution_spans_are_parented_to_root(self): @@ -214,8 +223,9 @@ class TestWorkflowTraceWithoutMessageId: instance = self._run(trace_info, node_executions=[node_exec]) # call_args_list[0] = root span, [1] = node execution span - assert instance.add_span.call_count == 2 - node_span_kwargs = instance.add_span.call_args_list[1][0][0] + add_span = _add_span_mock(instance) + assert add_span.call_count == 2 + node_span_kwargs = add_span.call_args_list[1][0][0] assert node_span_kwargs["parent_span_id"] == expected_root_span_id def test_node_span_not_parented_to_workflow_app_log_id(self): @@ -240,7 +250,7 @@ class TestWorkflowTraceWithoutMessageId: instance = self._run(trace_info, node_executions=[node_exec]) old_parent_id = prepare_opik_uuid(trace_info.start_time, trace_info.workflow_app_log_id) - node_span_kwargs = instance.add_span.call_args_list[1][0][0] + node_span_kwargs = _add_span_mock(instance).call_args_list[1][0][0] assert node_span_kwargs["parent_span_id"] != old_parent_id def test_root_span_id_differs_from_trace_id(self): @@ -283,7 +293,7 @@ class TestWorkflowTraceWithMessageId: trace_info = _make_workflow_trace_info(message_id=self._MESSAGE_ID) instance = self._run(trace_info) - trace_kwargs = instance.add_trace.call_args_list[0][0][0] + trace_kwargs = _add_trace_mock(instance).call_args_list[0][0][0] assert trace_kwargs["name"] == TraceTaskName.MESSAGE_TRACE def test_root_span_uses_workflow_run_id_directly(self): @@ -292,7 +302,7 @@ class TestWorkflowTraceWithMessageId: instance = self._run(trace_info) expected_root_span_id = prepare_opik_uuid(trace_info.start_time, trace_info.workflow_run_id) - root_span_kwargs = instance.add_span.call_args_list[0][0][0] + root_span_kwargs = _add_span_mock(instance).call_args_list[0][0][0] assert root_span_kwargs["id"] == expected_root_span_id def test_root_span_id_differs_from_no_message_id_case(self): @@ -326,5 +336,5 @@ class TestWorkflowTraceWithMessageId: instance = self._run(trace_info, node_executions=[node_exec]) - node_span_kwargs = instance.add_span.call_args_list[1][0][0] + node_span_kwargs = _add_span_mock(instance).call_args_list[1][0][0] assert node_span_kwargs["parent_span_id"] == expected_root_span_id diff --git a/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_client.py b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_client.py index 1e656e2462..3cd918f408 100644 --- a/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_client.py +++ b/api/providers/trace/trace-tencent/tests/unit_tests/tencent_trace/test_client.py @@ -5,6 +5,7 @@ from __future__ import annotations import sys import types from types import SimpleNamespace +from typing import Any, TypedDict, cast from unittest.mock import MagicMock import pytest @@ -12,7 +13,7 @@ from dify_trace_tencent import client as client_module from dify_trace_tencent.client import TencentTraceClient, _get_opentelemetry_sdk_version from dify_trace_tencent.entities.tencent_trace_entity import SpanData from opentelemetry.sdk.trace import Event -from opentelemetry.trace import Status, StatusCode +from opentelemetry.trace import SpanContext, Status, StatusCode, TraceFlags metric_reader_instances: list[DummyMetricReader] = [] meter_provider_instances: list[DummyMeterProvider] = [] @@ -80,6 +81,16 @@ class DummyJsonMetricExporterNoTemporality: self.kwargs = kwargs +class PatchedCoreComponents(TypedDict): + span_exporter: MagicMock + span_processor: MagicMock + tracer: MagicMock + span: MagicMock + tracer_provider: MagicMock + logger: MagicMock + trace_api: Any + + def _add_stub_modules(monkeypatch: pytest.MonkeyPatch) -> None: """Drop fake metric modules into sys.modules so the client imports resolve.""" @@ -118,7 +129,7 @@ def stub_metric_modules(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.fixture(autouse=True) -def patch_core_components(monkeypatch: pytest.MonkeyPatch) -> dict[str, object]: +def patch_core_components(monkeypatch: pytest.MonkeyPatch) -> PatchedCoreComponents: span_exporter = MagicMock(name="span_exporter") monkeypatch.setattr(client_module, "OTLPSpanExporter", MagicMock(return_value=span_exporter)) @@ -168,6 +179,15 @@ def patch_core_components(monkeypatch: pytest.MonkeyPatch) -> dict[str, object]: } +def _make_span_context(trace_id: int = 1, span_id: int = 2) -> SpanContext: + return SpanContext( + trace_id=trace_id, + span_id=span_id, + is_remote=False, + trace_flags=TraceFlags(TraceFlags.SAMPLED), + ) + + def _build_client() -> TencentTraceClient: return TencentTraceClient( service_name="service", @@ -208,7 +228,7 @@ def test_resolve_grpc_target_parsable_variants(endpoint: str, expected: tuple[st def test_resolve_grpc_target_handles_errors() -> None: - assert TencentTraceClient._resolve_grpc_target(123) == ("localhost:4317", True, "localhost", 4317) + assert TencentTraceClient._resolve_grpc_target(cast(str, 123)) == ("localhost:4317", True, "localhost", 4317) @pytest.mark.parametrize( @@ -248,7 +268,7 @@ def test_record_methods_skip_when_histogram_missing() -> None: client.record_trace_duration(0.5) -def test_record_llm_duration_handles_exceptions(patch_core_components: dict[str, object]) -> None: +def test_record_llm_duration_handles_exceptions(patch_core_components: PatchedCoreComponents) -> None: client = _build_client() client.hist_llm_duration = MagicMock(name="hist_llm_duration") client.hist_llm_duration.record.side_effect = RuntimeError("boom") @@ -258,10 +278,11 @@ def test_record_llm_duration_handles_exceptions(patch_core_components: dict[str, logger.debug.assert_called() -def test_create_and_export_span_sets_attributes(patch_core_components: dict[str, object]) -> None: +def test_create_and_export_span_sets_attributes(patch_core_components: PatchedCoreComponents) -> None: client = _build_client() span = patch_core_components["span"] - span.get_span_context.return_value = "ctx" + ctx = _make_span_context(span_id=2) + span.get_span_context.return_value = ctx data = SpanData( trace_id=1, @@ -280,14 +301,15 @@ def test_create_and_export_span_sets_attributes(patch_core_components: dict[str, span.add_event.assert_called_once() span.set_status.assert_called_once() span.end.assert_called_once_with(end_time=20) - assert client.span_contexts[2] == "ctx" + assert client.span_contexts[2] == ctx -def test_create_and_export_span_uses_parent_context(patch_core_components: dict[str, object]) -> None: +def test_create_and_export_span_uses_parent_context(patch_core_components: PatchedCoreComponents) -> None: client = _build_client() - client.span_contexts[10] = "existing" + existing_context = _make_span_context(span_id=10) + client.span_contexts[10] = existing_context span = patch_core_components["span"] - span.get_span_context.return_value = "child" + span.get_span_context.return_value = _make_span_context(span_id=11) data = SpanData( trace_id=1, @@ -302,14 +324,14 @@ def test_create_and_export_span_uses_parent_context(patch_core_components: dict[ client._create_and_export_span(data) trace_api = patch_core_components["trace_api"] - trace_api.NonRecordingSpan.assert_called_once_with("existing") + trace_api.NonRecordingSpan.assert_called_once_with(existing_context) trace_api.set_span_in_context.assert_called_once() -def test_create_and_export_span_exception_logs_error(patch_core_components: dict[str, object]) -> None: +def test_create_and_export_span_exception_logs_error(patch_core_components: PatchedCoreComponents) -> None: client = _build_client() span = patch_core_components["span"] - span.get_span_context.return_value = "ctx" + span.get_span_context.return_value = _make_span_context(span_id=2) client.tracer.start_span.side_effect = RuntimeError("boom") client._create_and_export_span( @@ -385,7 +407,7 @@ def test_get_project_url() -> None: assert client.get_project_url() == "https://console.cloud.tencent.com/apm" -def test_shutdown_flushes_all_components(patch_core_components: dict[str, object]) -> None: +def test_shutdown_flushes_all_components(patch_core_components: PatchedCoreComponents) -> None: client = _build_client() span_processor = patch_core_components["span_processor"] tracer_provider = patch_core_components["tracer_provider"] @@ -401,10 +423,11 @@ def test_shutdown_flushes_all_components(patch_core_components: dict[str, object metric_reader.shutdown.assert_called_once() -def test_shutdown_logs_when_meter_provider_fails(patch_core_components: dict[str, object]) -> None: +def test_shutdown_logs_when_meter_provider_fails(patch_core_components: PatchedCoreComponents) -> None: client = _build_client() meter_provider = meter_provider_instances[-1] meter_provider.shutdown.side_effect = RuntimeError("boom") + assert client.metric_reader is not None client.metric_reader.shutdown.side_effect = RuntimeError("boom") client.shutdown() @@ -433,7 +456,7 @@ def test_metrics_initialization_failure_sets_histogram_attributes(monkeypatch: p assert client.metric_reader is None -def test_add_span_logs_exception(monkeypatch: pytest.MonkeyPatch, patch_core_components: dict[str, object]) -> None: +def test_add_span_logs_exception(monkeypatch: pytest.MonkeyPatch, patch_core_components: PatchedCoreComponents) -> None: client = _build_client() monkeypatch.setattr(client, "_create_and_export_span", MagicMock(side_effect=RuntimeError("boom"))) @@ -454,10 +477,10 @@ def test_add_span_logs_exception(monkeypatch: pytest.MonkeyPatch, patch_core_com logger.exception.assert_called_once() -def test_create_and_export_span_converts_attribute_types(patch_core_components: dict[str, object]) -> None: +def test_create_and_export_span_converts_attribute_types(patch_core_components: PatchedCoreComponents) -> None: client = _build_client() span = patch_core_components["span"] - span.get_span_context.return_value = "ctx" + span.get_span_context.return_value = _make_span_context(span_id=2) data = SpanData.model_construct( trace_id=1, @@ -485,7 +508,7 @@ def test_record_llm_duration_converts_attributes() -> None: hist_mock = MagicMock(name="hist_llm_duration") client.hist_llm_duration = hist_mock - client.record_llm_duration(0.3, {"foo": object(), "bar": 2}) + client.record_llm_duration(0.3, cast(dict[str, str], {"foo": object(), "bar": 2})) _, attrs = hist_mock.record.call_args.args assert isinstance(attrs["foo"], str) assert attrs["bar"] == 2 @@ -496,7 +519,7 @@ def test_record_trace_duration_converts_attributes() -> None: hist_mock = MagicMock(name="hist_trace_duration") client.hist_trace_duration = hist_mock - client.record_trace_duration(1.0, {"meta": object(), "ok": True}) + client.record_trace_duration(1.0, cast(dict[str, str], {"meta": object(), "ok": True})) _, attrs = hist_mock.record.call_args.args assert isinstance(attrs["meta"], str) assert attrs["ok"] is True @@ -512,7 +535,7 @@ def test_record_trace_duration_converts_attributes() -> None: ], ) def test_record_methods_handle_exceptions( - method: str, attr_name: str, args: tuple[object, ...], patch_core_components: dict[str, object] + method: str, attr_name: str, args: tuple[object, ...], patch_core_components: PatchedCoreComponents ) -> None: client = _build_client() hist_mock = MagicMock(name=attr_name) @@ -527,35 +550,38 @@ def test_record_methods_handle_exceptions( def test_metrics_initializes_grpc_metric_exporter() -> None: client = _build_client() metric_reader = metric_reader_instances[-1] + exporter = cast(DummyGrpcMetricExporter, metric_reader.exporter) - assert isinstance(metric_reader.exporter, DummyGrpcMetricExporter) + assert isinstance(exporter, DummyGrpcMetricExporter) assert metric_reader.export_interval_millis == client.metrics_export_interval_sec * 1000 - assert metric_reader.exporter.kwargs["endpoint"] == "trace.example.com:4317" - assert metric_reader.exporter.kwargs["insecure"] is False - assert metric_reader.exporter.kwargs["headers"]["authorization"] == "Bearer token" + assert exporter.kwargs["endpoint"] == "trace.example.com:4317" + assert exporter.kwargs["insecure"] is False + assert cast(dict[str, dict[str, str]], exporter.kwargs)["headers"]["authorization"] == "Bearer token" def test_metrics_initializes_http_protobuf_metric_exporter(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") client = _build_client() metric_reader = metric_reader_instances[-1] + exporter = cast(DummyHttpMetricExporter, metric_reader.exporter) - assert isinstance(metric_reader.exporter, DummyHttpMetricExporter) + assert isinstance(exporter, DummyHttpMetricExporter) assert metric_reader.export_interval_millis == client.metrics_export_interval_sec * 1000 - assert metric_reader.exporter.kwargs["endpoint"] == client.endpoint - assert metric_reader.exporter.kwargs["headers"]["authorization"] == "Bearer token" + assert exporter.kwargs["endpoint"] == client.endpoint + assert cast(dict[str, dict[str, str]], exporter.kwargs)["headers"]["authorization"] == "Bearer token" def test_metrics_initializes_http_json_metric_exporter(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json") client = _build_client() metric_reader = metric_reader_instances[-1] + exporter = cast(DummyJsonMetricExporter, metric_reader.exporter) - assert isinstance(metric_reader.exporter, DummyJsonMetricExporter) + assert isinstance(exporter, DummyJsonMetricExporter) assert metric_reader.export_interval_millis == client.metrics_export_interval_sec * 1000 - assert metric_reader.exporter.kwargs["endpoint"] == client.endpoint - assert metric_reader.exporter.kwargs["headers"]["authorization"] == "Bearer token" - assert "preferred_temporality" in metric_reader.exporter.kwargs + assert exporter.kwargs["endpoint"] == client.endpoint + assert cast(dict[str, dict[str, str]], exporter.kwargs)["headers"]["authorization"] == "Bearer token" + assert "preferred_temporality" in exporter.kwargs def test_metrics_http_json_metric_exporter_falls_back_without_temporality(monkeypatch: pytest.MonkeyPatch) -> None: @@ -564,9 +590,10 @@ def test_metrics_http_json_metric_exporter_falls_back_without_temporality(monkey monkeypatch.setattr(exporter_module, "OTLPMetricExporter", DummyJsonMetricExporterNoTemporality) _ = _build_client() metric_reader = metric_reader_instances[-1] + exporter = cast(DummyJsonMetricExporterNoTemporality, metric_reader.exporter) - assert isinstance(metric_reader.exporter, DummyJsonMetricExporterNoTemporality) - assert "preferred_temporality" not in metric_reader.exporter.kwargs + assert isinstance(exporter, DummyJsonMetricExporterNoTemporality) + assert "preferred_temporality" not in exporter.kwargs def test_metrics_http_json_uses_http_fallback_when_no_json_exporter(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/api/providers/trace/trace-weave/tests/unit_tests/test_config_entity.py b/api/providers/trace/trace-weave/tests/unit_tests/test_config_entity.py index eeb1fe1d87..377c768198 100644 --- a/api/providers/trace/trace-weave/tests/unit_tests/test_config_entity.py +++ b/api/providers/trace/trace-weave/tests/unit_tests/test_config_entity.py @@ -31,13 +31,13 @@ class TestWeaveConfig: def test_missing_required_fields(self): """Test that required fields are enforced""" with pytest.raises(ValidationError): - WeaveConfig() + WeaveConfig.model_validate({}) with pytest.raises(ValidationError): - WeaveConfig(api_key="key") + WeaveConfig.model_validate({"api_key": "key"}) with pytest.raises(ValidationError): - WeaveConfig(project="project") + WeaveConfig.model_validate({"project": "project"}) def test_endpoint_validation_https_only(self): """Test endpoint validation only allows HTTPS""" diff --git a/api/tests/integration_tests/controllers/console/app/test_chat_message_permissions.py b/api/tests/integration_tests/controllers/console/app/test_chat_message_permissions.py index d10e5ed13c..3b5e822b90 100644 --- a/api/tests/integration_tests/controllers/console/app/test_chat_message_permissions.py +++ b/api/tests/integration_tests/controllers/console/app/test_chat_message_permissions.py @@ -171,35 +171,13 @@ class TestChatMessageApiPermissions: parent_message_id=None, ) - class MockQuery: - def __init__(self, model): - self.model = model - - def where(self, *args, **kwargs): - return self - - def first(self): - if getattr(self.model, "__name__", "") == "Conversation": - return mock_conversation - return None - - def order_by(self, *args, **kwargs): - return self - - def limit(self, *_): - return self - - def all(self): - if getattr(self.model, "__name__", "") == "Message": - return [mock_message] - return [] - mock_session = mock.Mock() - mock_session.query.side_effect = MockQuery - mock_session.scalar.return_value = False + mock_session.scalar.return_value = mock_conversation + mock_session.scalars.return_value.all.return_value = [mock_message] monkeypatch.setattr(message_api, "db", SimpleNamespace(session=mock_session)) monkeypatch.setattr(message_api, "current_user", mock_account) + monkeypatch.setattr(message_api, "attach_message_extra_contents", mock.Mock()) class DummyPagination: def __init__(self, data, limit, has_more): diff --git a/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py b/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py index f14b2c0ae5..635cfee2da 100644 --- a/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py +++ b/api/tests/test_containers_integration_tests/controllers/web/test_web_forgot_password.py @@ -24,7 +24,6 @@ def _patch_wraps(): patch("controllers.console.wraps.dify_config", dify_settings), patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), ): - mock_db.session.query.return_value.first.return_value = MagicMock() yield diff --git a/api/tests/test_containers_integration_tests/services/test_feedback_service.py b/api/tests/test_containers_integration_tests/services/test_feedback_service.py index d82933ccb9..3dcd6586e2 100644 --- a/api/tests/test_containers_integration_tests/services/test_feedback_service.py +++ b/api/tests/test_containers_integration_tests/services/test_feedback_service.py @@ -13,6 +13,12 @@ from models.model import App, Conversation, Message from services.feedback_service import FeedbackService +def _execute_result(rows): + result = mock.Mock() + result.all.return_value = rows + return result + + class TestFeedbackService: """Test FeedbackService methods.""" @@ -81,25 +87,17 @@ class TestFeedbackService: def test_export_feedbacks_csv_format(self, mock_db_session, sample_data): """Test exporting feedback data in CSV format.""" - - # Setup mock query result - mock_query = mock.Mock() - mock_query.join.return_value = mock_query - mock_query.outerjoin.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.filter.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.all.return_value = [ - ( - sample_data["user_feedback"], - sample_data["message"], - sample_data["conversation"], - sample_data["app"], - sample_data["user_feedback"].from_account, - ) - ] - - mock_db_session.execute.return_value = mock_query + mock_db_session.execute.return_value = _execute_result( + [ + ( + sample_data["user_feedback"], + sample_data["message"], + sample_data["conversation"], + sample_data["app"], + sample_data["user_feedback"].from_account, + ) + ] + ) # Test CSV export result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv") @@ -120,25 +118,17 @@ class TestFeedbackService: def test_export_feedbacks_json_format(self, mock_db_session, sample_data): """Test exporting feedback data in JSON format.""" - - # Setup mock query result - mock_query = mock.Mock() - mock_query.join.return_value = mock_query - mock_query.outerjoin.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.filter.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.all.return_value = [ - ( - sample_data["admin_feedback"], - sample_data["message"], - sample_data["conversation"], - sample_data["app"], - sample_data["admin_feedback"].from_account, - ) - ] - - mock_db_session.execute.return_value = mock_query + mock_db_session.execute.return_value = _execute_result( + [ + ( + sample_data["admin_feedback"], + sample_data["message"], + sample_data["conversation"], + sample_data["app"], + sample_data["admin_feedback"].from_account, + ) + ] + ) # Test JSON export result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json") @@ -157,25 +147,17 @@ class TestFeedbackService: def test_export_feedbacks_with_filters(self, mock_db_session, sample_data): """Test exporting feedback with various filters.""" - - # Setup mock query result - mock_query = mock.Mock() - mock_query.join.return_value = mock_query - mock_query.outerjoin.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.filter.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.all.return_value = [ - ( - sample_data["admin_feedback"], - sample_data["message"], - sample_data["conversation"], - sample_data["app"], - sample_data["admin_feedback"].from_account, - ) - ] - - mock_db_session.execute.return_value = mock_query + mock_db_session.execute.return_value = _execute_result( + [ + ( + sample_data["admin_feedback"], + sample_data["message"], + sample_data["conversation"], + sample_data["app"], + sample_data["admin_feedback"].from_account, + ) + ] + ) # Test with filters result = FeedbackService.export_feedbacks( @@ -193,17 +175,7 @@ class TestFeedbackService: def test_export_feedbacks_no_data(self, mock_db_session, sample_data): """Test exporting feedback when no data exists.""" - - # Setup mock query result with no data - mock_query = mock.Mock() - mock_query.join.return_value = mock_query - mock_query.outerjoin.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.filter.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.all.return_value = [] - - mock_db_session.execute.return_value = mock_query + mock_db_session.execute.return_value = _execute_result([]) result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv") @@ -251,24 +223,17 @@ class TestFeedbackService: created_at=datetime(2024, 1, 1, 10, 0, 0), ) - # Setup mock query result - mock_query = mock.Mock() - mock_query.join.return_value = mock_query - mock_query.outerjoin.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.filter.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.all.return_value = [ - ( - sample_data["user_feedback"], - long_message, - sample_data["conversation"], - sample_data["app"], - sample_data["user_feedback"].from_account, - ) - ] - - mock_db_session.execute.return_value = mock_query + mock_db_session.execute.return_value = _execute_result( + [ + ( + sample_data["user_feedback"], + long_message, + sample_data["conversation"], + sample_data["app"], + sample_data["user_feedback"].from_account, + ) + ] + ) # Test export result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json") @@ -309,24 +274,17 @@ class TestFeedbackService: created_at=datetime(2024, 1, 1, 10, 0, 0), ) - # Setup mock query result - mock_query = mock.Mock() - mock_query.join.return_value = mock_query - mock_query.outerjoin.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.filter.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.all.return_value = [ - ( - chinese_feedback, - chinese_message, - sample_data["conversation"], - sample_data["app"], - None, # No account for user feedback - ) - ] - - mock_db_session.execute.return_value = mock_query + mock_db_session.execute.return_value = _execute_result( + [ + ( + chinese_feedback, + chinese_message, + sample_data["conversation"], + sample_data["app"], + None, + ) + ] + ) # Test export result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="csv") @@ -339,32 +297,24 @@ class TestFeedbackService: def test_export_feedbacks_emoji_ratings(self, mock_db_session, sample_data): """Test that rating emojis are properly formatted in export.""" - - # Setup mock query result with both like and dislike feedback - mock_query = mock.Mock() - mock_query.join.return_value = mock_query - mock_query.outerjoin.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.filter.return_value = mock_query - mock_query.order_by.return_value = mock_query - mock_query.all.return_value = [ - ( - sample_data["user_feedback"], - sample_data["message"], - sample_data["conversation"], - sample_data["app"], - sample_data["user_feedback"].from_account, - ), - ( - sample_data["admin_feedback"], - sample_data["message"], - sample_data["conversation"], - sample_data["app"], - sample_data["admin_feedback"].from_account, - ), - ] - - mock_db_session.execute.return_value = mock_query + mock_db_session.execute.return_value = _execute_result( + [ + ( + sample_data["user_feedback"], + sample_data["message"], + sample_data["conversation"], + sample_data["app"], + sample_data["user_feedback"].from_account, + ), + ( + sample_data["admin_feedback"], + sample_data["message"], + sample_data["conversation"], + sample_data["app"], + sample_data["admin_feedback"].from_account, + ), + ] + ) # Test export result = FeedbackService.export_feedbacks(app_id=sample_data["app"].id, format_type="json") diff --git a/api/tests/unit_tests/conftest.py b/api/tests/unit_tests/conftest.py index 55873b06a8..7174530e97 100644 --- a/api/tests/unit_tests/conftest.py +++ b/api/tests/unit_tests/conftest.py @@ -121,33 +121,32 @@ def _configure_session_factory(_unit_test_engine): configure_session_factory(_unit_test_engine, expire_on_commit=False) -def setup_mock_tenant_account_query(mock_db, mock_tenant, mock_account): +def setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_owner): """ - Helper to set up the mock DB execute chain for tenant/account authentication. + Helper to stub the tenant-owner execute result for service API app authentication. - This configures the mock to return (tenant, account) for the - db.session.execute(select(...).join().join().where()).one_or_none() - query used by validate_app_token decorator. + The validate_app_token decorator currently resolves the active tenant owner + via db.session.execute(select(Tenant, Account)...).one_or_none(). Args: mock_db: The mocked db object mock_tenant: Mock tenant object to return - mock_account: Mock account object to return + mock_owner: Mock owner object to return from the execute result """ - mock_db.session.execute.return_value.one_or_none.return_value = (mock_tenant, mock_account) + mock_db.session.execute.return_value.one_or_none.return_value = (mock_tenant, mock_owner) -def setup_mock_dataset_tenant_query(mock_db, mock_tenant, mock_ta): +def setup_mock_dataset_owner_execute_result(mock_db, mock_tenant, mock_tenant_account_join): """ - Helper to set up the mock DB execute chain for dataset tenant authentication. + Helper to stub the tenant-owner execute result for dataset token authentication. - This configures the mock to return (tenant, tenant_account) for the - db.session.execute(select(...).where().where().where().where()).one_or_none() - query used by validate_dataset_token decorator. + The validate_dataset_token decorator currently resolves the owner mapping via + db.session.execute(select(Tenant, TenantAccountJoin)...).one_or_none(), and + then loads the Account separately via db.session.get(...). Args: mock_db: The mocked db object mock_tenant: Mock tenant object to return - mock_ta: Mock tenant account object to return + mock_tenant_account_join: Mock tenant-account join object to return """ - mock_db.session.execute.return_value.one_or_none.return_value = (mock_tenant, mock_ta) + mock_db.session.execute.return_value.one_or_none.return_value = (mock_tenant, mock_tenant_account_join) diff --git a/api/tests/unit_tests/controllers/console/app/test_annotation_security.py b/api/tests/unit_tests/controllers/console/app/test_annotation_security.py index 9f1ff9b40f..bfa4048191 100644 --- a/api/tests/unit_tests/controllers/console/app/test_annotation_security.py +++ b/api/tests/unit_tests/controllers/console/app/test_annotation_security.py @@ -208,8 +208,6 @@ class TestAnnotationImportServiceValidation: file = FileStorage(stream=io.BytesIO(csv_content.encode()), filename="test.csv", content_type="text/csv") - mock_db_session.query.return_value.where.return_value.first.return_value = mock_app - with patch("services.annotation_service.current_account_with_tenant") as mock_auth: mock_auth.return_value = (MagicMock(id="user_id"), "tenant_id") @@ -230,8 +228,6 @@ class TestAnnotationImportServiceValidation: file = FileStorage(stream=io.BytesIO(csv_content.encode()), filename="test.csv", content_type="text/csv") - mock_db_session.query.return_value.where.return_value.first.return_value = mock_app - with patch("services.annotation_service.current_account_with_tenant") as mock_auth: mock_auth.return_value = (MagicMock(id="user_id"), "tenant_id") @@ -248,8 +244,6 @@ class TestAnnotationImportServiceValidation: csv_content = 'invalid,csv,format\nwith,unbalanced,quotes,and"stuff' file = FileStorage(stream=io.BytesIO(csv_content.encode()), filename="test.csv", content_type="text/csv") - mock_db_session.query.return_value.where.return_value.first.return_value = mock_app - with ( patch("services.annotation_service.current_account_with_tenant") as mock_auth, patch("services.annotation_service.pd.read_csv", side_effect=ParserError("malformed CSV")), @@ -269,8 +263,6 @@ class TestAnnotationImportServiceValidation: file = FileStorage(stream=io.BytesIO(csv_content.encode()), filename="test.csv", content_type="text/csv") - mock_db_session.query.return_value.where.return_value.first.return_value = mock_app - with patch("services.annotation_service.current_account_with_tenant") as mock_auth: mock_auth.return_value = (MagicMock(id="user_id"), "tenant_id") diff --git a/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py b/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py index cb4fe40944..17bee94c52 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py +++ b/api/tests/unit_tests/controllers/console/auth/test_authentication_security.py @@ -43,7 +43,6 @@ class TestAuthenticationSecurity: mock_is_rate_limit.return_value = False mock_get_invitation.return_value = None mock_authenticate.side_effect = services.errors.account.AccountPasswordError("Invalid email or password.") - mock_db.session.query.return_value.first.return_value = MagicMock() # Mock setup exists mock_features.return_value.is_allow_register = True # Act @@ -76,7 +75,6 @@ class TestAuthenticationSecurity: mock_is_rate_limit.return_value = False mock_get_invitation.return_value = None mock_authenticate.side_effect = services.errors.account.AccountPasswordError("Wrong password") - mock_db.session.query.return_value.first.return_value = MagicMock() # Mock setup exists # Act with self.app.test_request_context( @@ -109,7 +107,6 @@ class TestAuthenticationSecurity: mock_is_rate_limit.return_value = False mock_get_invitation.return_value = None mock_authenticate.side_effect = services.errors.account.AccountPasswordError("Invalid email or password.") - mock_db.session.query.return_value.first.return_value = MagicMock() # Mock setup exists mock_features.return_value.is_allow_register = False # Act @@ -135,7 +132,6 @@ class TestAuthenticationSecurity: def test_reset_password_with_existing_account(self, mock_send_email, mock_get_user, mock_features, mock_db): """Test that reset password returns success with token for existing accounts.""" # Mock the setup check - mock_db.session.query.return_value.first.return_value = MagicMock() # Mock setup exists # Test with existing account mock_get_user.return_value = MagicMock(email="existing@example.com") diff --git a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py index 9929a71120..b7bc73da5f 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_email_verification.py +++ b/api/tests/unit_tests/controllers/console/auth/test_email_verification.py @@ -65,7 +65,6 @@ class TestEmailCodeLoginSendEmailApi: - IP rate limiting is checked """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_is_ip_limit.return_value = False mock_get_user.return_value = mock_account mock_send_email.return_value = "email_token_123" @@ -98,7 +97,6 @@ class TestEmailCodeLoginSendEmailApi: - Registration is allowed by system features """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_is_ip_limit.return_value = False mock_get_user.return_value = None mock_get_features.return_value.is_allow_register = True @@ -130,7 +128,6 @@ class TestEmailCodeLoginSendEmailApi: - Registration is blocked by system features """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_is_ip_limit.return_value = False mock_get_user.return_value = None mock_get_features.return_value.is_allow_register = False @@ -152,7 +149,6 @@ class TestEmailCodeLoginSendEmailApi: - Prevents spam and abuse """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_is_ip_limit.return_value = True # Act & Assert @@ -172,7 +168,6 @@ class TestEmailCodeLoginSendEmailApi: - AccountInFreezeError is raised for frozen accounts """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_is_ip_limit.return_value = False mock_get_user.side_effect = AccountRegisterError("Account frozen") @@ -213,7 +208,6 @@ class TestEmailCodeLoginSendEmailApi: - Defaults to en-US when not specified """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_is_ip_limit.return_value = False mock_get_user.return_value = mock_account mock_send_email.return_value = "token" @@ -286,7 +280,6 @@ class TestEmailCodeLoginApi: - User is logged in with token pair """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_get_data.return_value = {"email": "test@example.com", "code": "123456"} mock_get_user.return_value = mock_account mock_get_tenants.return_value = [MagicMock()] @@ -335,7 +328,6 @@ class TestEmailCodeLoginApi: - User is logged in after account creation """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_get_data.return_value = {"email": "newuser@example.com", "code": "123456"} mock_get_user.return_value = None mock_create_account.return_value = mock_account @@ -369,7 +361,6 @@ class TestEmailCodeLoginApi: - InvalidTokenError is raised for invalid/expired tokens """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_get_data.return_value = None # Act & Assert @@ -392,7 +383,6 @@ class TestEmailCodeLoginApi: - InvalidEmailError is raised when email doesn't match token """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_get_data.return_value = {"email": "original@example.com", "code": "123456"} # Act & Assert @@ -415,7 +405,6 @@ class TestEmailCodeLoginApi: - EmailCodeError is raised for wrong verification code """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_get_data.return_value = {"email": "test@example.com", "code": "123456"} # Act & Assert @@ -453,7 +442,6 @@ class TestEmailCodeLoginApi: - User is added as owner of new workspace """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_get_data.return_value = {"email": "test@example.com", "code": "123456"} mock_get_user.return_value = mock_account mock_get_tenants.return_value = [] @@ -496,7 +484,6 @@ class TestEmailCodeLoginApi: - WorkspacesLimitExceeded is raised when limit reached """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_get_data.return_value = {"email": "test@example.com", "code": "123456"} mock_get_user.return_value = mock_account mock_get_tenants.return_value = [] @@ -538,7 +525,6 @@ class TestEmailCodeLoginApi: - NotAllowedCreateWorkspace is raised when creation disabled """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_get_data.return_value = {"email": "test@example.com", "code": "123456"} mock_get_user.return_value = mock_account mock_get_tenants.return_value = [] diff --git a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py index 0cf97da878..d089be8905 100644 --- a/api/tests/unit_tests/controllers/console/auth/test_login_logout.py +++ b/api/tests/unit_tests/controllers/console/auth/test_login_logout.py @@ -110,7 +110,6 @@ class TestLoginApi: - Rate limit is reset after successful login """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_is_rate_limit.return_value = False mock_get_invitation.return_value = None mock_authenticate.return_value = mock_account @@ -162,7 +161,6 @@ class TestLoginApi: - Authentication proceeds with invitation token """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_is_rate_limit.return_value = False mock_get_invitation.return_value = {"data": {"email": "test@example.com"}} mock_authenticate.return_value = mock_account @@ -199,7 +197,6 @@ class TestLoginApi: - EmailPasswordLoginLimitError is raised when limit exceeded """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_is_rate_limit.return_value = True mock_get_invitation.return_value = None @@ -228,7 +225,6 @@ class TestLoginApi: - AccountInFreezeError is raised for frozen accounts """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_is_frozen.return_value = True # Act & Assert @@ -268,7 +264,6 @@ class TestLoginApi: - Generic error message prevents user enumeration """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_is_rate_limit.return_value = False mock_get_invitation.return_value = None mock_authenticate.side_effect = AccountPasswordError("Invalid password") @@ -305,7 +300,6 @@ class TestLoginApi: - Login is prevented even with valid credentials """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_is_rate_limit.return_value = False mock_get_invitation.return_value = None mock_authenticate.side_effect = AccountLoginError("Account is banned") @@ -351,7 +345,6 @@ class TestLoginApi: - User cannot login without an assigned workspace """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_is_rate_limit.return_value = False mock_get_invitation.return_value = None mock_authenticate.return_value = mock_account @@ -383,7 +376,6 @@ class TestLoginApi: - Security check prevents invitation token abuse """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_is_rate_limit.return_value = False mock_get_invitation.return_value = {"data": {"email": "invited@example.com"}} @@ -425,7 +417,6 @@ class TestLoginApi: mock_token_pair, ): """Test that login retries with lowercase email when uppercase lookup fails.""" - mock_db.session.query.return_value.first.return_value = MagicMock() mock_is_rate_limit.return_value = False mock_get_invitation.return_value = None mock_authenticate.side_effect = [AccountPasswordError("Invalid"), mock_account] @@ -459,7 +450,6 @@ class TestLoginApi: mock_db, app, ): - mock_db.session.query.return_value.first.return_value = MagicMock() mock_get_token_data.return_value = {"email": "User@Example.com", "code": "123456"} mock_get_account.side_effect = Unauthorized("Account is banned.") @@ -513,7 +503,6 @@ class TestLogoutApi: - Success response is returned """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() mock_current_account.return_value = (mock_account, MagicMock()) # Act @@ -539,7 +528,6 @@ class TestLogoutApi: - Success response is returned """ # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() # Create a mock anonymous user that will pass isinstance check anonymous_user = MagicMock() mock_flask_login.AnonymousUserMixin = type("AnonymousUserMixin", (), {}) diff --git a/api/tests/unit_tests/controllers/console/billing/test_billing.py b/api/tests/unit_tests/controllers/console/billing/test_billing.py index c80758c857..810f1b94fc 100644 --- a/api/tests/unit_tests/controllers/console/billing/test_billing.py +++ b/api/tests/unit_tests/controllers/console/billing/test_billing.py @@ -46,7 +46,6 @@ class TestPartnerTenants: patch("libs.login.dify_config.LOGIN_DISABLED", False), patch("libs.login.check_csrf_token") as mock_csrf, ): - mock_db.session.query.return_value.first.return_value = MagicMock() # Mock setup exists mock_csrf.return_value = None yield {"db": mock_db, "csrf": mock_csrf} diff --git a/api/tests/unit_tests/controllers/console/test_workspace_account.py b/api/tests/unit_tests/controllers/console/test_workspace_account.py index 26ff264f18..0b1a32581a 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_account.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_account.py @@ -24,10 +24,6 @@ def app(): return app -def _mock_wraps_db(mock_db): - mock_db.session.query.return_value.first.return_value = MagicMock() - - def _build_account(email: str, account_id: str = "acc", tenant: object | None = None) -> Account: tenant_obj = tenant if tenant is not None else SimpleNamespace(id="tenant-id") account = Account(name=account_id, email=email) @@ -64,7 +60,6 @@ class TestChangeEmailSend: mock_db, app, ): - _mock_wraps_db(mock_db) mock_features.return_value = SimpleNamespace(enable_change_email=True) mock_account = _build_account("current@example.com", "acc1") mock_current_account.return_value = (mock_account, None) @@ -117,7 +112,6 @@ class TestChangeEmailSend: """GHSA-4q3w-q5mc-45rq: a phase-1 token must not unlock the new-email send step.""" from controllers.console.auth.error import InvalidTokenError - _mock_wraps_db(mock_db) mock_features.return_value = SimpleNamespace(enable_change_email=True) mock_account = _build_account("current@example.com", "acc1") mock_current_account.return_value = (mock_account, None) @@ -163,7 +157,6 @@ class TestChangeEmailValidity: mock_db, app, ): - _mock_wraps_db(mock_db) mock_features.return_value = SimpleNamespace(enable_change_email=True) mock_account = _build_account("user@example.com", "acc2") mock_current_account.return_value = (mock_account, None) @@ -223,7 +216,6 @@ class TestChangeEmailValidity: mock_db, app, ): - _mock_wraps_db(mock_db) mock_features.return_value = SimpleNamespace(enable_change_email=True) mock_current_account.return_value = (_build_account("old@example.com", "acc"), None) mock_is_rate_limit.return_value = False @@ -280,7 +272,6 @@ class TestChangeEmailValidity: """A token whose phase marker is a string but not a known transition must be rejected.""" from controllers.console.auth.error import InvalidTokenError - _mock_wraps_db(mock_db) mock_features.return_value = SimpleNamespace(enable_change_email=True) mock_current_account.return_value = (_build_account("old@example.com", "acc"), None) mock_is_rate_limit.return_value = False @@ -330,7 +321,6 @@ class TestChangeEmailValidity: """A token minted without a phase marker (e.g. a hand-crafted token) must not validate.""" from controllers.console.auth.error import InvalidTokenError - _mock_wraps_db(mock_db) mock_features.return_value = SimpleNamespace(enable_change_email=True) mock_current_account.return_value = (_build_account("old@example.com", "acc"), None) mock_is_rate_limit.return_value = False @@ -378,7 +368,6 @@ class TestChangeEmailReset: mock_db, app, ): - _mock_wraps_db(mock_db) mock_features.return_value = SimpleNamespace(enable_change_email=True) current_user = _build_account("old@example.com", "acc3") mock_current_account.return_value = (current_user, None) @@ -434,7 +423,6 @@ class TestChangeEmailReset: """GHSA-4q3w-q5mc-45rq PoC: phase-1 token must not be usable against /reset.""" from controllers.console.auth.error import InvalidTokenError - _mock_wraps_db(mock_db) mock_features.return_value = SimpleNamespace(enable_change_email=True) current_user = _build_account("old@example.com", "acc3") mock_current_account.return_value = (current_user, None) @@ -488,7 +476,6 @@ class TestChangeEmailReset: """A verified token for address A must not be replayed to change to address B.""" from controllers.console.auth.error import InvalidTokenError - _mock_wraps_db(mock_db) mock_features.return_value = SimpleNamespace(enable_change_email=True) current_user = _build_account("old@example.com", "acc3") mock_current_account.return_value = (current_user, None) @@ -561,7 +548,6 @@ class TestAccountDeletionFeedback: @patch("controllers.console.wraps.db") @patch("controllers.console.workspace.account.BillingService.update_account_deletion_feedback") def test_should_normalize_feedback_email(self, mock_update, mock_db, app): - _mock_wraps_db(mock_db) with app.test_request_context( "/account/delete/feedback", method="POST", @@ -578,7 +564,6 @@ class TestCheckEmailUnique: @patch("controllers.console.workspace.account.AccountService.check_email_unique") @patch("controllers.console.workspace.account.AccountService.is_account_in_freeze") def test_should_normalize_email(self, mock_is_freeze, mock_check_unique, mock_db, app): - _mock_wraps_db(mock_db) mock_is_freeze.return_value = False mock_check_unique.return_value = True diff --git a/api/tests/unit_tests/controllers/console/test_workspace_members.py b/api/tests/unit_tests/controllers/console/test_workspace_members.py index 239fec8430..811bf5b1e7 100644 --- a/api/tests/unit_tests/controllers/console/test_workspace_members.py +++ b/api/tests/unit_tests/controllers/console/test_workspace_members.py @@ -1,5 +1,5 @@ from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from flask import Flask, g @@ -16,10 +16,6 @@ def app(): return flask_app -def _mock_wraps_db(mock_db): - mock_db.session.query.return_value.first.return_value = MagicMock() - - def _build_feature_flags(): placeholder_quota = SimpleNamespace(limit=0, size=0) workspace_members = SimpleNamespace(is_available=lambda count: True) @@ -49,7 +45,6 @@ class TestMemberInviteEmailApi: mock_get_features, app, ): - _mock_wraps_db(mock_db) mock_get_features.return_value = _build_feature_flags() mock_invite_member.return_value = "token-abc" diff --git a/api/tests/unit_tests/controllers/console/test_wraps.py b/api/tests/unit_tests/controllers/console/test_wraps.py index f6e096a97b..aa4973851a 100644 --- a/api/tests/unit_tests/controllers/console/test_wraps.py +++ b/api/tests/unit_tests/controllers/console/test_wraps.py @@ -310,7 +310,6 @@ class TestSystemSetup: def test_should_allow_when_setup_complete(self, mock_db): """Test that requests are allowed when setup is complete""" # Arrange - mock_db.session.query.return_value.first.return_value = MagicMock() # Setup exists @setup_required def admin_view(): diff --git a/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py b/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py index 44feacf2ad..1422f29849 100644 --- a/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py +++ b/api/tests/unit_tests/controllers/console/workspace/test_tool_providers.py @@ -22,7 +22,7 @@ _WRAPS_MODULE: ModuleType | None = None @contextmanager def _mock_db(): - mock_session = SimpleNamespace(query=lambda *args, **kwargs: SimpleNamespace(first=lambda: True)) + mock_session = SimpleNamespace(scalar=lambda *args, **kwargs: True) with patch("extensions.ext_database.db.session", mock_session): yield diff --git a/api/tests/unit_tests/controllers/service_api/app/test_app.py b/api/tests/unit_tests/controllers/service_api/app/test_app.py index f48ace427d..f5d93b5ac3 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_app.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_app.py @@ -12,7 +12,7 @@ from controllers.service_api.app.app import AppInfoApi, AppMetaApi, AppParameter from controllers.service_api.app.error import AppUnavailableError from models.account import TenantStatus from models.model import App, AppMode -from tests.unit_tests.conftest import setup_mock_tenant_account_query +from tests.unit_tests.conftest import setup_mock_tenant_owner_execute_result class TestAppParameterApi: @@ -74,7 +74,7 @@ class TestAppParameterApi: # Mock tenant owner info for login mock_account = Mock() mock_account.current_tenant = mock_tenant - setup_mock_tenant_account_query(mock_db, mock_tenant, mock_account) + setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account) # Act with app.test_request_context("/parameters", method="GET", headers={"Authorization": "Bearer test_token"}): @@ -120,7 +120,7 @@ class TestAppParameterApi: mock_account = Mock() mock_account.current_tenant = mock_tenant - setup_mock_tenant_account_query(mock_db, mock_tenant, mock_account) + setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account) # Act with app.test_request_context("/parameters", method="GET", headers={"Authorization": "Bearer test_token"}): @@ -161,7 +161,7 @@ class TestAppParameterApi: mock_account = Mock() mock_account.current_tenant = mock_tenant - setup_mock_tenant_account_query(mock_db, mock_tenant, mock_account) + setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account) # Act & Assert with app.test_request_context("/parameters", method="GET", headers={"Authorization": "Bearer test_token"}): @@ -200,7 +200,7 @@ class TestAppParameterApi: mock_account = Mock() mock_account.current_tenant = mock_tenant - setup_mock_tenant_account_query(mock_db, mock_tenant, mock_account) + setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account) # Act & Assert with app.test_request_context("/parameters", method="GET", headers={"Authorization": "Bearer test_token"}): @@ -263,7 +263,7 @@ class TestAppMetaApi: mock_account = Mock() mock_account.current_tenant = mock_tenant - setup_mock_tenant_account_query(mock_db, mock_tenant, mock_account) + setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account) # Act with app.test_request_context("/meta", method="GET", headers={"Authorization": "Bearer test_token"}): @@ -331,7 +331,7 @@ class TestAppInfoApi: mock_account = Mock() mock_account.current_tenant = mock_tenant - setup_mock_tenant_account_query(mock_db, mock_tenant, mock_account) + setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account) # Act with app.test_request_context("/info", method="GET", headers={"Authorization": "Bearer test_token"}): @@ -388,7 +388,7 @@ class TestAppInfoApi: mock_account = Mock() mock_account.current_tenant = mock_tenant - setup_mock_tenant_account_query(mock_db, mock_tenant, mock_account) + setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account) # Act with app.test_request_context("/info", method="GET", headers={"Authorization": "Bearer test_token"}): @@ -434,7 +434,7 @@ class TestAppInfoApi: mock_account = Mock() mock_account.current_tenant = mock_tenant - setup_mock_tenant_account_query(mock_db, mock_tenant, mock_account) + setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account) # Act with app.test_request_context("/info", method="GET", headers={"Authorization": "Bearer test_token"}): @@ -486,7 +486,7 @@ class TestAppInfoApi: mock_account = Mock() mock_account.current_tenant = mock_tenant - setup_mock_tenant_account_query(mock_db, mock_tenant, mock_account) + setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account) # Act with app.test_request_context("/info", method="GET", headers={"Authorization": "Bearer test_token"}): diff --git a/api/tests/unit_tests/controllers/service_api/conftest.py b/api/tests/unit_tests/controllers/service_api/conftest.py index eddba5a517..8c89812cb4 100644 --- a/api/tests/unit_tests/controllers/service_api/conftest.py +++ b/api/tests/unit_tests/controllers/service_api/conftest.py @@ -15,7 +15,10 @@ from flask import Flask from core.rag.index_processor.constant.index_type import IndexStructureType from models.account import TenantStatus from models.model import App, AppMode, EndUser -from tests.unit_tests.conftest import setup_mock_tenant_account_query +from tests.unit_tests.conftest import ( + setup_mock_dataset_owner_execute_result, + setup_mock_tenant_owner_execute_result, +) @pytest.fixture @@ -123,9 +126,7 @@ class AuthenticationMocker: mock_db.session.get.side_effect = [mock_app, mock_tenant] if mock_account: - mock_ta = Mock() - mock_ta.account_id = mock_account.id - setup_mock_tenant_account_query(mock_db, mock_tenant, mock_ta) + setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account) @staticmethod def setup_dataset_auth(mock_db, mock_tenant, mock_account): @@ -133,8 +134,7 @@ class AuthenticationMocker: mock_ta = Mock() mock_ta.account_id = mock_account.id - mock_db.session.execute.return_value.one_or_none.return_value = (mock_tenant, mock_ta) - + setup_mock_dataset_owner_execute_result(mock_db, mock_tenant, mock_ta) mock_db.session.get.return_value = mock_account diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py index 288659b192..1b391e67ec 100644 --- a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py +++ b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py @@ -701,8 +701,8 @@ class TestDocumentApiDelete: ``delete`` is wrapped by ``@cloud_edition_billing_rate_limit_check`` which internally calls ``validate_and_get_api_token``. To bypass the decorator we call the original function via ``__wrapped__`` (preserved by - ``functools.wraps``). ``delete`` queries the dataset via - ``db.session.query(Dataset)`` directly, so we patch ``db`` at the + ``functools.wraps``). ``delete`` loads the dataset via + ``db.session.scalar(select(Dataset)...)``, so we patch ``db`` at the controller module. """ diff --git a/api/tests/unit_tests/controllers/service_api/test_wraps.py b/api/tests/unit_tests/controllers/service_api/test_wraps.py index a2008e024b..6dfbdcf98e 100644 --- a/api/tests/unit_tests/controllers/service_api/test_wraps.py +++ b/api/tests/unit_tests/controllers/service_api/test_wraps.py @@ -24,8 +24,8 @@ from enums.cloud_plan import CloudPlan from models.account import TenantStatus from models.model import ApiToken from tests.unit_tests.conftest import ( - setup_mock_dataset_tenant_query, - setup_mock_tenant_account_query, + setup_mock_dataset_owner_execute_result, + setup_mock_tenant_owner_execute_result, ) @@ -141,14 +141,11 @@ class TestValidateAppToken: mock_account = Mock() mock_account.id = str(uuid.uuid4()) - mock_ta = Mock() - mock_ta.account_id = mock_account.id - # Use side_effect to return app first, then tenant via session.get() mock_db.session.get.side_effect = [mock_app, mock_tenant] - # Mock the tenant owner query (execute(select(...)).one_or_none()) - setup_mock_tenant_account_query(mock_db, mock_tenant, mock_ta) + # Mock the tenant owner execute result (execute(select(...)).one_or_none()) + setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, mock_account) @validate_app_token def protected_view(app_model): @@ -471,7 +468,7 @@ class TestValidateDatasetToken: mock_account.current_tenant = mock_tenant # Mock the tenant account join query (execute(select(...)).one_or_none()) - setup_mock_dataset_tenant_query(mock_db, mock_tenant, mock_ta) + setup_mock_dataset_owner_execute_result(mock_db, mock_tenant, mock_ta) # Mock the account lookup via session.get() mock_db.session.get.return_value = mock_account diff --git a/api/tests/unit_tests/controllers/web/conftest.py b/api/tests/unit_tests/controllers/web/conftest.py index 274d78c9cf..b7f3244c6c 100644 --- a/api/tests/unit_tests/controllers/web/conftest.py +++ b/api/tests/unit_tests/controllers/web/conftest.py @@ -22,18 +22,16 @@ class FakeSession: def __init__(self, mapping: dict[str, Any] | None = None): self._mapping: dict[str, Any] = mapping or {} - self._model_name: str | None = None - def query(self, model: type) -> FakeSession: - self._model_name = model.__name__ - return self + def get(self, model: type, _ident: object) -> Any: + return self._mapping.get(model.__name__) - def where(self, *_args: object, **_kwargs: object) -> FakeSession: - return self - - def first(self) -> Any: - assert self._model_name is not None - return self._mapping.get(self._model_name) + def scalar(self, stmt: Any) -> Any: + try: + model = stmt.column_descriptions[0]["entity"] + except (AttributeError, IndexError, KeyError, TypeError): + return None + return self._mapping.get(model.__name__) class FakeDB: diff --git a/api/tests/unit_tests/controllers/web/test_human_input_form.py b/api/tests/unit_tests/controllers/web/test_human_input_form.py index a1dbc80b20..5f2dc19aab 100644 --- a/api/tests/unit_tests/controllers/web/test_human_input_form.py +++ b/api/tests/unit_tests/controllers/web/test_human_input_form.py @@ -36,18 +36,6 @@ class _FakeSession: def __init__(self, mapping: dict[str, Any]): self._mapping = mapping - self._model_name: str | None = None - - def query(self, model): - self._model_name = model.__name__ - return self - - def where(self, *args, **kwargs): - return self - - def first(self): - assert self._model_name is not None - return self._mapping.get(self._model_name) def get(self, model, ident): return self._mapping.get(model.__name__) diff --git a/api/tests/unit_tests/controllers/web/test_web_login.py b/api/tests/unit_tests/controllers/web/test_web_login.py index a01587d64a..13b953c04d 100644 --- a/api/tests/unit_tests/controllers/web/test_web_login.py +++ b/api/tests/unit_tests/controllers/web/test_web_login.py @@ -34,7 +34,6 @@ def _patch_wraps(): patch("controllers.console.wraps.FeatureService.get_system_features", return_value=wraps_features), patch("controllers.web.login.dify_config", web_dify), ): - mock_db.session.query.return_value.first.return_value = MagicMock() yield diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py index 45d4b0e321..370f7abb8b 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py @@ -154,7 +154,6 @@ class TestAdvancedChatAppRunnerConversationVariables: mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session mock_sessionmaker.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) mock_session_class.return_value.__enter__.return_value = MagicMock() - mock_db.session.query.return_value.where.return_value.first.return_value = MagicMock() # App exists mock_db.engine = MagicMock() # Mock GraphRuntimeState to accept the variable pool @@ -301,7 +300,6 @@ class TestAdvancedChatAppRunnerConversationVariables: mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session mock_sessionmaker.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) mock_session_class.return_value.__enter__.return_value = MagicMock() - mock_db.session.query.return_value.where.return_value.first.return_value = MagicMock() # App exists mock_db.engine = MagicMock() # Mock ConversationVariable.from_variable to return mock objects @@ -453,7 +451,6 @@ class TestAdvancedChatAppRunnerConversationVariables: mock_sessionmaker.return_value.begin.return_value.__enter__.return_value = mock_session mock_sessionmaker.return_value.begin.return_value.__exit__ = MagicMock(return_value=False) mock_session_class.return_value.__enter__.return_value = MagicMock() - mock_db.session.query.return_value.where.return_value.first.return_value = MagicMock() # App exists mock_db.engine = MagicMock() # Mock GraphRuntimeState to accept the variable pool diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py index 9a2dc38f74..c36edf48fc 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generator.py @@ -375,7 +375,7 @@ def test_generate_success_returns_converted(generator, mocker): workflow = MagicMock(id="wf", tenant_id="tenant", app_id="pipe", graph_dict={}) session = MagicMock() - session.query.return_value.where.return_value.first.return_value = workflow + session.get.return_value = workflow mocker.patch.object(module.db, "session", session) queue_manager = MagicMock() diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py index 618c8fd76f..603062a51c 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py @@ -132,11 +132,8 @@ def test_run_pipeline_not_found(mocker): app_generate_entity.single_iteration_run = None app_generate_entity.single_loop_run = None - query = MagicMock() - query.where.return_value.first.return_value = None - session = MagicMock() - session.query.return_value = query + session.get.side_effect = [None, None] mocker.patch.object(module.db, "session", session) runner = PipelineRunner( @@ -157,11 +154,9 @@ def test_run_workflow_not_initialized(mocker): app_generate_entity = _build_app_generate_entity() pipeline = MagicMock(id="pipe") - query_pipeline = MagicMock() - query_pipeline.where.return_value.first.return_value = pipeline session = MagicMock() - session.query.return_value = query_pipeline + session.get.side_effect = [None, pipeline] mocker.patch.object(module.db, "session", session) runner = PipelineRunner( diff --git a/api/tests/unit_tests/core/datasource/test_notion_provider.py b/api/tests/unit_tests/core/datasource/test_notion_provider.py index e4bd7d3bdf..d21b9e471b 100644 --- a/api/tests/unit_tests/core/datasource/test_notion_provider.py +++ b/api/tests/unit_tests/core/datasource/test_notion_provider.py @@ -775,9 +775,6 @@ class TestNotionExtractorLastEditedTime: "last_edited_time": "2024-11-27T18:00:00.000Z", } mock_request.return_value = mock_response - mock_query = Mock() - mock_db.session.query.return_value = mock_query - mock_query.filter_by.return_value = mock_query # Act extractor_page.update_last_edited_time(mock_document_model) @@ -863,9 +860,6 @@ class TestNotionExtractorIntegration: } mock_request.side_effect = [last_edited_response, block_response] - mock_query = Mock() - mock_db.session.query.return_value = mock_query - mock_query.filter_by.return_value = mock_query # Act documents = extractor.extract() @@ -919,10 +913,6 @@ class TestNotionExtractorIntegration: } mock_post.return_value = database_response - mock_query = Mock() - mock_db.session.query.return_value = mock_query - mock_query.filter_by.return_value = mock_query - # Act documents = extractor.extract() diff --git a/api/tests/unit_tests/core/helper/test_encrypter.py b/api/tests/unit_tests/core/helper/test_encrypter.py index f3ef7fccd0..73e081a570 100644 --- a/api/tests/unit_tests/core/helper/test_encrypter.py +++ b/api/tests/unit_tests/core/helper/test_encrypter.py @@ -40,11 +40,11 @@ class TestObfuscatedToken: class TestEncryptToken: @patch("extensions.ext_database.db.session.get") @patch("libs.rsa.encrypt") - def test_successful_encryption(self, mock_encrypt, mock_query): + def test_successful_encryption(self, mock_encrypt, mock_get): """Test successful token encryption""" mock_tenant = MagicMock() mock_tenant.encrypt_public_key = "mock_public_key" - mock_query.return_value = mock_tenant + mock_get.return_value = mock_tenant mock_encrypt.return_value = b"encrypted_data" result = encrypt_token("tenant-123", "test_token") @@ -53,9 +53,9 @@ class TestEncryptToken: mock_encrypt.assert_called_with("test_token", "mock_public_key") @patch("extensions.ext_database.db.session.get") - def test_tenant_not_found(self, mock_query): + def test_tenant_not_found(self, mock_get): """Test error when tenant doesn't exist""" - mock_query.return_value = None + mock_get.return_value = None with pytest.raises(ValueError) as exc_info: encrypt_token("invalid-tenant", "test_token") @@ -122,12 +122,12 @@ class TestEncryptDecryptIntegration: @patch("extensions.ext_database.db.session.get") @patch("libs.rsa.encrypt") @patch("libs.rsa.decrypt") - def test_should_encrypt_and_decrypt_consistently(self, mock_decrypt, mock_encrypt, mock_query): + def test_should_encrypt_and_decrypt_consistently(self, mock_decrypt, mock_encrypt, mock_get): """Test that encryption and decryption are consistent""" # Setup mock tenant mock_tenant = MagicMock() mock_tenant.encrypt_public_key = "mock_public_key" - mock_query.return_value = mock_tenant + mock_get.return_value = mock_tenant # Setup mock encryption/decryption original_token = "test_token_123" @@ -148,12 +148,12 @@ class TestSecurity: @patch("extensions.ext_database.db.session.get") @patch("libs.rsa.encrypt") - def test_cross_tenant_isolation(self, mock_encrypt, mock_query): + def test_cross_tenant_isolation(self, mock_encrypt, mock_get): """Ensure tokens encrypted for one tenant cannot be used by another""" # Setup mock tenant mock_tenant = MagicMock() mock_tenant.encrypt_public_key = "tenant1_public_key" - mock_query.return_value = mock_tenant + mock_get.return_value = mock_tenant mock_encrypt.return_value = b"encrypted_for_tenant1" # Encrypt token for tenant1 @@ -183,10 +183,10 @@ class TestSecurity: @patch("extensions.ext_database.db.session.get") @patch("libs.rsa.encrypt") - def test_encryption_randomness(self, mock_encrypt, mock_query): + def test_encryption_randomness(self, mock_encrypt, mock_get): """Ensure same plaintext produces different ciphertext""" mock_tenant = MagicMock(encrypt_public_key="key") - mock_query.return_value = mock_tenant + mock_get.return_value = mock_tenant # Different outputs for same input mock_encrypt.side_effect = [b"enc1", b"enc2", b"enc3"] @@ -207,11 +207,11 @@ class TestEdgeCases: @patch("extensions.ext_database.db.session.get") @patch("libs.rsa.encrypt") - def test_should_handle_empty_token_encryption(self, mock_encrypt, mock_query): + def test_should_handle_empty_token_encryption(self, mock_encrypt, mock_get): """Test encryption of empty token""" mock_tenant = MagicMock() mock_tenant.encrypt_public_key = "mock_public_key" - mock_query.return_value = mock_tenant + mock_get.return_value = mock_tenant mock_encrypt.return_value = b"encrypted_empty" result = encrypt_token("tenant-123", "") @@ -221,11 +221,11 @@ class TestEdgeCases: @patch("extensions.ext_database.db.session.get") @patch("libs.rsa.encrypt") - def test_should_handle_special_characters_in_token(self, mock_encrypt, mock_query): + def test_should_handle_special_characters_in_token(self, mock_encrypt, mock_get): """Test tokens containing special/unicode characters""" mock_tenant = MagicMock() mock_tenant.encrypt_public_key = "mock_public_key" - mock_query.return_value = mock_tenant + mock_get.return_value = mock_tenant mock_encrypt.return_value = b"encrypted_special" # Test various special characters @@ -244,11 +244,11 @@ class TestEdgeCases: @patch("extensions.ext_database.db.session.get") @patch("libs.rsa.encrypt") - def test_should_handle_rsa_size_limits(self, mock_encrypt, mock_query): + def test_should_handle_rsa_size_limits(self, mock_encrypt, mock_get): """Test behavior when token exceeds RSA encryption limits""" mock_tenant = MagicMock() mock_tenant.encrypt_public_key = "mock_public_key" - mock_query.return_value = mock_tenant + mock_get.return_value = mock_tenant # RSA 2048-bit can only encrypt ~245 bytes # The actual limit depends on padding scheme diff --git a/api/tests/unit_tests/core/llm_generator/test_llm_generator.py b/api/tests/unit_tests/core/llm_generator/test_llm_generator.py index 7d6c8f983f..c4e610d5b0 100644 --- a/api/tests/unit_tests/core/llm_generator/test_llm_generator.py +++ b/api/tests/unit_tests/core/llm_generator/test_llm_generator.py @@ -491,7 +491,7 @@ class TestLLMGenerator: def test_instruction_modify_workflow_no_last_run_fallback(self, mock_model_instance, model_config_entity): with patch("extensions.ext_database.db.session") as mock_session: - mock_session.return_value.query.return_value.where.return_value.first.return_value = MagicMock() + mock_session.return_value.scalar.return_value = MagicMock() workflow = MagicMock() workflow.graph_dict = {"graph": {"nodes": [{"id": "node_id", "data": {"type": "code"}}]}} @@ -517,7 +517,7 @@ class TestLLMGenerator: def test_instruction_modify_workflow_node_type_fallback(self, mock_model_instance, model_config_entity): with patch("extensions.ext_database.db.session") as mock_session: - mock_session.return_value.query.return_value.where.return_value.first.return_value = MagicMock() + mock_session.return_value.scalar.return_value = MagicMock() workflow = MagicMock() # Cause exception in node_type logic workflow.graph_dict = {"graph": {"nodes": []}} @@ -544,7 +544,7 @@ class TestLLMGenerator: def test_instruction_modify_workflow_empty_agent_log(self, mock_model_instance, model_config_entity): with patch("extensions.ext_database.db.session") as mock_session: - mock_session.return_value.query.return_value.where.return_value.first.return_value = MagicMock() + mock_session.return_value.scalar.return_value = MagicMock() workflow = MagicMock() workflow.graph_dict = {"graph": {"nodes": [{"id": "node_id", "data": {"type": "llm"}}]}} @@ -632,7 +632,7 @@ class TestLLMGenerator: instance.invoke_llm.return_value = mock_response with patch("extensions.ext_database.db.session") as mock_session: - mock_session.return_value.query.return_value.where.return_value.first.return_value = MagicMock() + mock_session.return_value.scalar.return_value = MagicMock() workflow = MagicMock() workflow.graph_dict = {"graph": {"nodes": [{"id": "node_id", "data": {"type": "other"}}]}} diff --git a/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba.py b/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba.py index 136ac0c72a..1e91c2dd88 100644 --- a/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba.py +++ b/api/tests/unit_tests/core/rag/datasource/keyword/jieba/test_jieba.py @@ -29,15 +29,6 @@ class _Field: return ("in", self._name, tuple(values)) -class _FakeQuery: - def __init__(self): - self.where_calls: list[tuple] = [] - - def where(self, *conditions): - self.where_calls.append(conditions) - return self - - class _FakeExecuteResult: def __init__(self, segments: list[SimpleNamespace]): self._segments = segments diff --git a/api/tests/unit_tests/core/rag/datasource/test_datasource_retrieval.py b/api/tests/unit_tests/core/rag/datasource/test_datasource_retrieval.py index 0baf85c314..b0ecad4d0c 100644 --- a/api/tests/unit_tests/core/rag/datasource/test_datasource_retrieval.py +++ b/api/tests/unit_tests/core/rag/datasource/test_datasource_retrieval.py @@ -109,17 +109,6 @@ class _FakeExecuteResult: return _FakeExecuteScalarResult(self._data) -class _FakeSummaryQuery: - def __init__(self, summaries: list) -> None: - self._summaries = summaries - - def filter(self, *args, **kwargs): - return self - - def all(self) -> list: - return self._summaries - - class _FakeScalarsResult: def __init__(self, data: list) -> None: self._data = data diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py b/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py index dc21d378a2..9de04c80ba 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py +++ b/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py @@ -372,19 +372,11 @@ def test_vector_delegation_methods(vector_factory_module): def test_search_by_file_handles_missing_and_existing_upload(vector_factory_module, monkeypatch): - class _Field: - def __eq__(self, value): - return value - - upload_query = MagicMock() - upload_query.where.return_value = upload_query - vector = vector_factory_module.Vector.__new__(vector_factory_module.Vector) vector._embeddings = MagicMock() vector._vector_processor = MagicMock() mock_session = SimpleNamespace(get=lambda _model, _id: None) - monkeypatch.setattr(vector_factory_module, "UploadFile", SimpleNamespace(id=_Field())) monkeypatch.setattr(vector_factory_module, "db", SimpleNamespace(session=mock_session)) assert vector.search_by_file("file-1") == [] diff --git a/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py b/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py index 7c4defc180..b4bb343533 100644 --- a/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py +++ b/api/tests/unit_tests/core/rag/indexing/test_indexing_runner.py @@ -1484,11 +1484,8 @@ class TestIndexingRunnerProcessChunk: mock_dependencies["redis"].get.return_value = None - # Mock database query for segment updates - mock_query = MagicMock() - mock_dependencies["db"].session.query.return_value = mock_query - mock_query.where.return_value = mock_query - mock_query.update.return_value = None + # Mock database update for segment status + mock_dependencies["db"].session.execute.return_value = None # Create a proper context manager mock mock_context = MagicMock() diff --git a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py index 89830f7517..fd607210f1 100644 --- a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py +++ b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval.py @@ -2417,12 +2417,11 @@ class TestDatasetRetrievalKnowledgeRetrieval: mock_document.data_source_type = "upload_file" mock_document.doc_metadata = {} - mock_session.query.return_value.filter.return_value.all.return_value = [ - mock_dataset_from_db - ] - mock_session.query.return_value.filter.return_value.all.__iter__ = lambda self: iter( - [mock_dataset_from_db, mock_document] - ) + mock_datasets = MagicMock() + mock_datasets.all.return_value = [mock_dataset_from_db] + mock_documents = MagicMock() + mock_documents.all.return_value = [mock_document] + mock_session.scalars.side_effect = [mock_datasets, mock_documents] # Act result = dataset_retrieval.knowledge_retrieval(request) diff --git a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval_methods.py b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval_methods.py index 90feb4cf01..aace419d15 100644 --- a/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval_methods.py +++ b/api/tests/unit_tests/core/rag/retrieval/test_dataset_retrieval_methods.py @@ -451,12 +451,11 @@ class TestDatasetRetrievalKnowledgeRetrieval: mock_document.data_source_type = "upload_file" mock_document.doc_metadata = {} - mock_session.query.return_value.filter.return_value.all.return_value = [ - mock_dataset_from_db - ] - mock_session.query.return_value.filter.return_value.all.__iter__ = lambda self: iter( - [mock_dataset_from_db, mock_document] - ) + mock_datasets = MagicMock() + mock_datasets.all.return_value = [mock_dataset_from_db] + mock_documents = MagicMock() + mock_documents.all.return_value = [mock_document] + mock_session.scalars.side_effect = [mock_datasets, mock_documents] # Act result = dataset_retrieval.knowledge_retrieval(request) diff --git a/api/tests/unit_tests/services/document_indexing_task_proxy.py b/api/tests/unit_tests/services/document_indexing_task_proxy.py deleted file mode 100644 index ff243b8dc3..0000000000 --- a/api/tests/unit_tests/services/document_indexing_task_proxy.py +++ /dev/null @@ -1,1291 +0,0 @@ -""" -Comprehensive unit tests for DocumentIndexingTaskProxy service. - -This module contains extensive unit tests for the DocumentIndexingTaskProxy class, -which is responsible for routing document indexing tasks to appropriate Celery queues -based on tenant billing configuration and managing tenant-isolated task queues. - -The DocumentIndexingTaskProxy handles: -- Task scheduling and queuing (direct vs tenant-isolated queues) -- Priority vs normal task routing based on billing plans -- Tenant isolation using TenantIsolatedTaskQueue -- Batch indexing operations with multiple document IDs -- Error handling and retry logic through queue management - -This test suite ensures: -- Correct task routing based on billing configuration -- Proper tenant isolation queue management -- Accurate batch operation handling -- Comprehensive error condition coverage -- Edge cases are properly handled - -================================================================================ -ARCHITECTURE OVERVIEW -================================================================================ - -The DocumentIndexingTaskProxy is a critical component in the document indexing -workflow. It acts as a proxy/router that determines which Celery queue to use -for document indexing tasks based on tenant billing configuration. - -1. Task Queue Routing: - - Direct Queue: Bypasses tenant isolation, used for self-hosted/enterprise - - Tenant Queue: Uses tenant isolation, queues tasks when another task is running - - Default Queue: Normal priority with tenant isolation (SANDBOX plan) - - Priority Queue: High priority with tenant isolation (TEAM/PRO plans) - - Priority Direct Queue: High priority without tenant isolation (billing disabled) - -2. Tenant Isolation: - - Uses TenantIsolatedTaskQueue to ensure only one indexing task runs per tenant - - When a task is running, new tasks are queued in Redis - - When a task completes, it pulls the next task from the queue - - Prevents resource contention and ensures fair task distribution - -3. Billing Configuration: - - SANDBOX plan: Uses default tenant queue (normal priority, tenant isolated) - - TEAM/PRO plans: Uses priority tenant queue (high priority, tenant isolated) - - Billing disabled: Uses priority direct queue (high priority, no isolation) - -4. Batch Operations: - - Supports indexing multiple documents in a single task - - DocumentTask entity serializes task information - - Tasks are queued with all document IDs for batch processing - -================================================================================ -TESTING STRATEGY -================================================================================ - -This test suite follows a comprehensive testing strategy that covers: - -1. Initialization and Configuration: - - Proxy initialization with various parameters - - TenantIsolatedTaskQueue initialization - - Features property caching - - Edge cases (empty document_ids, single document, large batches) - -2. Task Queue Routing: - - Direct queue routing (bypasses tenant isolation) - - Tenant queue routing with existing task key (pushes to waiting queue) - - Tenant queue routing without task key (sets flag and executes immediately) - - DocumentTask serialization and deserialization - - Task function delay() call with correct parameters - -3. Queue Type Selection: - - Default tenant queue routing (normal_document_indexing_task) - - Priority tenant queue routing (priority_document_indexing_task with isolation) - - Priority direct queue routing (priority_document_indexing_task without isolation) - -4. Dispatch Logic: - - Billing enabled + SANDBOX plan → default tenant queue - - Billing enabled + non-SANDBOX plan (TEAM, PRO, etc.) → priority tenant queue - - Billing disabled (self-hosted/enterprise) → priority direct queue - - All CloudPlan enum values handling - - Edge cases: None plan, empty plan string - -5. Tenant Isolation and Queue Management: - - Task key existence checking (get_task_key) - - Task waiting time setting (set_task_waiting_time) - - Task pushing to queue (push_tasks) - - Queue state transitions (idle → active → idle) - - Multiple concurrent task handling - -6. Batch Operations: - - Single document indexing - - Multiple document batch indexing - - Large batch handling - - Empty batch handling (edge case) - -7. Error Handling and Retry Logic: - - Task function delay() failure handling - - Queue operation failures (Redis errors) - - Feature service failures - - Invalid task data handling - - Retry mechanism through queue pull operations - -8. Integration Points: - - FeatureService integration (billing features, subscription plans) - - TenantIsolatedTaskQueue integration (Redis operations) - - Celery task integration (normal_document_indexing_task, priority_document_indexing_task) - - DocumentTask entity serialization - -================================================================================ -""" - -from unittest.mock import Mock, patch - -import pytest - -from core.entities.document_task import DocumentTask -from core.rag.pipeline.queue import TenantIsolatedTaskQueue -from enums.cloud_plan import CloudPlan -from services.document_indexing_proxy.document_indexing_task_proxy import DocumentIndexingTaskProxy - -# ============================================================================ -# Test Data Factory -# ============================================================================ - - -class DocumentIndexingTaskProxyTestDataFactory: - """ - Factory class for creating test data and mock objects for DocumentIndexingTaskProxy tests. - - This factory provides static methods to create mock objects for: - - FeatureService features with billing configuration - - TenantIsolatedTaskQueue mocks with various states - - DocumentIndexingTaskProxy instances with different configurations - - DocumentTask entities for testing serialization - - The factory methods help maintain consistency across tests and reduce - code duplication when setting up test scenarios. - """ - - @staticmethod - def create_mock_features(billing_enabled: bool = False, plan: CloudPlan = CloudPlan.SANDBOX) -> Mock: - """ - Create mock features with billing configuration. - - This method creates a mock FeatureService features object with - billing configuration that can be used to test different billing - scenarios in the DocumentIndexingTaskProxy. - - Args: - billing_enabled: Whether billing is enabled for the tenant - plan: The CloudPlan enum value for the subscription plan - - Returns: - Mock object configured as FeatureService features with billing info - """ - features = Mock() - - features.billing = Mock() - - features.billing.enabled = billing_enabled - - features.billing.subscription = Mock() - - features.billing.subscription.plan = plan - - return features - - @staticmethod - def create_mock_tenant_queue(has_task_key: bool = False) -> Mock: - """ - Create mock TenantIsolatedTaskQueue. - - This method creates a mock TenantIsolatedTaskQueue that can simulate - different queue states for testing tenant isolation logic. - - Args: - has_task_key: Whether the queue has an active task key (task running) - - Returns: - Mock object configured as TenantIsolatedTaskQueue - """ - queue = Mock(spec=TenantIsolatedTaskQueue) - - queue.get_task_key.return_value = "task_key" if has_task_key else None - - queue.push_tasks = Mock() - - queue.set_task_waiting_time = Mock() - - queue.delete_task_key = Mock() - - return queue - - @staticmethod - def create_document_task_proxy( - tenant_id: str = "tenant-123", dataset_id: str = "dataset-456", document_ids: list[str] | None = None - ) -> DocumentIndexingTaskProxy: - """ - Create DocumentIndexingTaskProxy instance for testing. - - This method creates a DocumentIndexingTaskProxy instance with default - or specified parameters for use in test cases. - - Args: - tenant_id: Tenant identifier for the proxy - dataset_id: Dataset identifier for the proxy - document_ids: List of document IDs to index (defaults to 3 documents) - - Returns: - DocumentIndexingTaskProxy instance configured for testing - """ - if document_ids is None: - document_ids = ["doc-1", "doc-2", "doc-3"] - - return DocumentIndexingTaskProxy(tenant_id, dataset_id, document_ids) - - @staticmethod - def create_document_task( - tenant_id: str = "tenant-123", dataset_id: str = "dataset-456", document_ids: list[str] | None = None - ) -> DocumentTask: - """ - Create DocumentTask entity for testing. - - This method creates a DocumentTask entity that can be used to test - task serialization and deserialization logic. - - Args: - tenant_id: Tenant identifier for the task - dataset_id: Dataset identifier for the task - document_ids: List of document IDs to index (defaults to 3 documents) - - Returns: - DocumentTask entity configured for testing - """ - if document_ids is None: - document_ids = ["doc-1", "doc-2", "doc-3"] - - return DocumentTask(tenant_id=tenant_id, dataset_id=dataset_id, document_ids=document_ids) - - -# ============================================================================ -# Test Classes -# ============================================================================ - - -class TestDocumentIndexingTaskProxy: - """ - Comprehensive unit tests for DocumentIndexingTaskProxy class. - - This test class covers all methods and scenarios of the DocumentIndexingTaskProxy, - including initialization, task routing, queue management, dispatch logic, and - error handling. - """ - - # ======================================================================== - # Initialization Tests - # ======================================================================== - - def test_initialization(self): - """ - Test DocumentIndexingTaskProxy initialization. - - This test verifies that the proxy is correctly initialized with - the provided tenant_id, dataset_id, and document_ids, and that - the TenantIsolatedTaskQueue is properly configured. - """ - # Arrange - tenant_id = "tenant-123" - - dataset_id = "dataset-456" - - document_ids = ["doc-1", "doc-2", "doc-3"] - - # Act - proxy = DocumentIndexingTaskProxy(tenant_id, dataset_id, document_ids) - - # Assert - assert proxy._tenant_id == tenant_id - - assert proxy._dataset_id == dataset_id - - assert proxy._document_ids == document_ids - - assert isinstance(proxy._tenant_isolated_task_queue, TenantIsolatedTaskQueue) - - assert proxy._tenant_isolated_task_queue._tenant_id == tenant_id - - assert proxy._tenant_isolated_task_queue._unique_key == "document_indexing" - - def test_initialization_with_empty_document_ids(self): - """ - Test initialization with empty document_ids list. - - This test verifies that the proxy can be initialized with an empty - document_ids list, which may occur in edge cases or error scenarios. - """ - # Arrange - tenant_id = "tenant-123" - - dataset_id = "dataset-456" - - document_ids = [] - - # Act - proxy = DocumentIndexingTaskProxy(tenant_id, dataset_id, document_ids) - - # Assert - assert proxy._tenant_id == tenant_id - - assert proxy._dataset_id == dataset_id - - assert proxy._document_ids == document_ids - - assert len(proxy._document_ids) == 0 - - def test_initialization_with_single_document_id(self): - """ - Test initialization with single document_id. - - This test verifies that the proxy can be initialized with a single - document ID, which is a common use case for single document indexing. - """ - # Arrange - tenant_id = "tenant-123" - - dataset_id = "dataset-456" - - document_ids = ["doc-1"] - - # Act - proxy = DocumentIndexingTaskProxy(tenant_id, dataset_id, document_ids) - - # Assert - assert proxy._tenant_id == tenant_id - - assert proxy._dataset_id == dataset_id - - assert proxy._document_ids == document_ids - - assert len(proxy._document_ids) == 1 - - def test_initialization_with_large_batch(self): - """ - Test initialization with large batch of document IDs. - - This test verifies that the proxy can handle large batches of - document IDs, which may occur in bulk indexing scenarios. - """ - # Arrange - tenant_id = "tenant-123" - - dataset_id = "dataset-456" - - document_ids = [f"doc-{i}" for i in range(100)] - - # Act - proxy = DocumentIndexingTaskProxy(tenant_id, dataset_id, document_ids) - - # Assert - assert proxy._tenant_id == tenant_id - - assert proxy._dataset_id == dataset_id - - assert proxy._document_ids == document_ids - - assert len(proxy._document_ids) == 100 - - # ======================================================================== - # Features Property Tests - # ======================================================================== - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.FeatureService") - def test_features_property(self, mock_feature_service): - """ - Test cached_property features. - - This test verifies that the features property is correctly cached - and that FeatureService.get_features is called only once, even when - the property is accessed multiple times. - """ - # Arrange - mock_features = DocumentIndexingTaskProxyTestDataFactory.create_mock_features() - - mock_feature_service.get_features.return_value = mock_features - - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - # Act - features1 = proxy.features - - features2 = proxy.features # Second call should use cached property - - # Assert - assert features1 == mock_features - - assert features2 == mock_features - - assert features1 is features2 # Should be the same instance due to caching - - mock_feature_service.get_features.assert_called_once_with("tenant-123") - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.FeatureService") - def test_features_property_with_different_tenants(self, mock_feature_service): - """ - Test features property with different tenant IDs. - - This test verifies that the features property correctly calls - FeatureService.get_features with the correct tenant_id for each - proxy instance. - """ - # Arrange - mock_features1 = DocumentIndexingTaskProxyTestDataFactory.create_mock_features() - - mock_features2 = DocumentIndexingTaskProxyTestDataFactory.create_mock_features() - - mock_feature_service.get_features.side_effect = [mock_features1, mock_features2] - - proxy1 = DocumentIndexingTaskProxy("tenant-1", "dataset-1", ["doc-1"]) - - proxy2 = DocumentIndexingTaskProxy("tenant-2", "dataset-2", ["doc-2"]) - - # Act - features1 = proxy1.features - - features2 = proxy2.features - - # Assert - assert features1 == mock_features1 - - assert features2 == mock_features2 - - mock_feature_service.get_features.assert_any_call("tenant-1") - - mock_feature_service.get_features.assert_any_call("tenant-2") - - # ======================================================================== - # Direct Queue Routing Tests - # ======================================================================== - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.normal_document_indexing_task") - def test_send_to_direct_queue(self, mock_task): - """ - Test _send_to_direct_queue method. - - This test verifies that _send_to_direct_queue correctly calls - task_func.delay() with the correct parameters, bypassing tenant - isolation queue management. - """ - # Arrange - tenant_id = "tenant-direct-queue" - dataset_id = "dataset-direct-queue" - document_ids = ["doc-direct-1", "doc-direct-2"] - proxy = DocumentIndexingTaskProxy(tenant_id, dataset_id, document_ids) - mock_task.delay = Mock() - - # Act - proxy._send_to_direct_queue(mock_task) - - # Assert - mock_task.delay.assert_called_once_with(tenant_id=tenant_id, dataset_id=dataset_id, document_ids=document_ids) - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.priority_document_indexing_task") - def test_send_to_direct_queue_with_priority_task(self, mock_task): - """ - Test _send_to_direct_queue with priority task function. - - This test verifies that _send_to_direct_queue works correctly - with priority_document_indexing_task as the task function. - """ - # Arrange - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - mock_task.delay = Mock() - - # Act - proxy._send_to_direct_queue(mock_task) - - # Assert - mock_task.delay.assert_called_once_with( - tenant_id="tenant-123", dataset_id="dataset-456", document_ids=["doc-1", "doc-2", "doc-3"] - ) - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.normal_document_indexing_task") - def test_send_to_direct_queue_with_single_document(self, mock_task): - """ - Test _send_to_direct_queue with single document ID. - - This test verifies that _send_to_direct_queue correctly handles - a single document ID in the document_ids list. - """ - # Arrange - proxy = DocumentIndexingTaskProxy("tenant-123", "dataset-456", ["doc-1"]) - - mock_task.delay = Mock() - - # Act - proxy._send_to_direct_queue(mock_task) - - # Assert - mock_task.delay.assert_called_once_with( - tenant_id="tenant-123", dataset_id="dataset-456", document_ids=["doc-1"] - ) - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.normal_document_indexing_task") - def test_send_to_direct_queue_with_empty_documents(self, mock_task): - """ - Test _send_to_direct_queue with empty document_ids list. - - This test verifies that _send_to_direct_queue correctly handles - an empty document_ids list, which may occur in edge cases. - """ - # Arrange - proxy = DocumentIndexingTaskProxy("tenant-123", "dataset-456", []) - - mock_task.delay = Mock() - - # Act - proxy._send_to_direct_queue(mock_task) - - # Assert - mock_task.delay.assert_called_once_with(tenant_id="tenant-123", dataset_id="dataset-456", document_ids=[]) - - # ======================================================================== - # Tenant Queue Routing Tests - # ======================================================================== - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.normal_document_indexing_task") - def test_send_to_tenant_queue_with_existing_task_key(self, mock_task): - """ - Test _send_to_tenant_queue when task key exists. - - This test verifies that when a task key exists (indicating another - task is running), the new task is pushed to the waiting queue instead - of being executed immediately. - """ - # Arrange - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._tenant_isolated_task_queue = DocumentIndexingTaskProxyTestDataFactory.create_mock_tenant_queue( - has_task_key=True - ) - - mock_task.delay = Mock() - - # Act - proxy._send_to_tenant_queue(mock_task) - - # Assert - proxy._tenant_isolated_task_queue.push_tasks.assert_called_once() - - pushed_tasks = proxy._tenant_isolated_task_queue.push_tasks.call_args[0][0] - - assert len(pushed_tasks) == 1 - - expected_task_data = { - "tenant_id": "tenant-123", - "dataset_id": "dataset-456", - "document_ids": ["doc-1", "doc-2", "doc-3"], - } - assert pushed_tasks[0] == expected_task_data - - assert pushed_tasks[0]["document_ids"] == ["doc-1", "doc-2", "doc-3"] - - mock_task.delay.assert_not_called() - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.normal_document_indexing_task") - def test_send_to_tenant_queue_without_task_key(self, mock_task): - """ - Test _send_to_tenant_queue when no task key exists. - - This test verifies that when no task key exists (indicating no task - is currently running), the task is executed immediately and the - task waiting time flag is set. - """ - # Arrange - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._tenant_isolated_task_queue = DocumentIndexingTaskProxyTestDataFactory.create_mock_tenant_queue( - has_task_key=False - ) - - mock_task.delay = Mock() - - # Act - proxy._send_to_tenant_queue(mock_task) - - # Assert - proxy._tenant_isolated_task_queue.set_task_waiting_time.assert_called_once() - - mock_task.delay.assert_called_once_with( - tenant_id="tenant-123", dataset_id="dataset-456", document_ids=["doc-1", "doc-2", "doc-3"] - ) - - proxy._tenant_isolated_task_queue.push_tasks.assert_not_called() - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.priority_document_indexing_task") - def test_send_to_tenant_queue_with_priority_task(self, mock_task): - """ - Test _send_to_tenant_queue with priority task function. - - This test verifies that _send_to_tenant_queue works correctly - with priority_document_indexing_task as the task function. - """ - # Arrange - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._tenant_isolated_task_queue = DocumentIndexingTaskProxyTestDataFactory.create_mock_tenant_queue( - has_task_key=False - ) - - mock_task.delay = Mock() - - # Act - proxy._send_to_tenant_queue(mock_task) - - # Assert - proxy._tenant_isolated_task_queue.set_task_waiting_time.assert_called_once() - - mock_task.delay.assert_called_once_with( - tenant_id="tenant-123", dataset_id="dataset-456", document_ids=["doc-1", "doc-2", "doc-3"] - ) - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.normal_document_indexing_task") - def test_send_to_tenant_queue_document_task_serialization(self, mock_task): - """ - Test DocumentTask serialization in _send_to_tenant_queue. - - This test verifies that DocumentTask entities are correctly - serialized to dictionaries when pushing to the waiting queue. - """ - # Arrange - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._tenant_isolated_task_queue = DocumentIndexingTaskProxyTestDataFactory.create_mock_tenant_queue( - has_task_key=True - ) - - mock_task.delay = Mock() - - # Act - proxy._send_to_tenant_queue(mock_task) - - # Assert - pushed_tasks = proxy._tenant_isolated_task_queue.push_tasks.call_args[0][0] - - task_dict = pushed_tasks[0] - - # Verify the task can be deserialized back to DocumentTask - document_task = DocumentTask(**task_dict) - - assert document_task.tenant_id == "tenant-123" - - assert document_task.dataset_id == "dataset-456" - - assert document_task.document_ids == ["doc-1", "doc-2", "doc-3"] - - # ======================================================================== - # Queue Type Selection Tests - # ======================================================================== - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.normal_document_indexing_task") - def test_send_to_default_tenant_queue(self, mock_task): - """ - Test _send_to_default_tenant_queue method. - - This test verifies that _send_to_default_tenant_queue correctly - calls _send_to_tenant_queue with normal_document_indexing_task. - """ - # Arrange - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._send_to_tenant_queue = Mock() - - # Act - proxy._send_to_default_tenant_queue() - - # Assert - proxy._send_to_tenant_queue.assert_called_once_with(mock_task) - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.priority_document_indexing_task") - def test_send_to_priority_tenant_queue(self, mock_task): - """ - Test _send_to_priority_tenant_queue method. - - This test verifies that _send_to_priority_tenant_queue correctly - calls _send_to_tenant_queue with priority_document_indexing_task. - """ - # Arrange - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._send_to_tenant_queue = Mock() - - # Act - proxy._send_to_priority_tenant_queue() - - # Assert - proxy._send_to_tenant_queue.assert_called_once_with(mock_task) - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.priority_document_indexing_task") - def test_send_to_priority_direct_queue(self, mock_task): - """ - Test _send_to_priority_direct_queue method. - - This test verifies that _send_to_priority_direct_queue correctly - calls _send_to_direct_queue with priority_document_indexing_task. - """ - # Arrange - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._send_to_direct_queue = Mock() - - # Act - proxy._send_to_priority_direct_queue() - - # Assert - proxy._send_to_direct_queue.assert_called_once_with(mock_task) - - # ======================================================================== - # Dispatch Logic Tests - # ======================================================================== - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.FeatureService") - def test_dispatch_with_billing_enabled_sandbox_plan(self, mock_feature_service): - """ - Test _dispatch method when billing is enabled with SANDBOX plan. - - This test verifies that when billing is enabled and the subscription - plan is SANDBOX, the dispatch method routes to the default tenant queue. - """ - # Arrange - mock_features = DocumentIndexingTaskProxyTestDataFactory.create_mock_features( - billing_enabled=True, plan=CloudPlan.SANDBOX - ) - - mock_feature_service.get_features.return_value = mock_features - - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._send_to_default_tenant_queue = Mock() - - # Act - proxy._dispatch() - - # Assert - proxy._send_to_default_tenant_queue.assert_called_once() - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.FeatureService") - def test_dispatch_with_billing_enabled_team_plan(self, mock_feature_service): - """ - Test _dispatch method when billing is enabled with TEAM plan. - - This test verifies that when billing is enabled and the subscription - plan is TEAM, the dispatch method routes to the priority tenant queue. - """ - # Arrange - mock_features = DocumentIndexingTaskProxyTestDataFactory.create_mock_features( - billing_enabled=True, plan=CloudPlan.TEAM - ) - - mock_feature_service.get_features.return_value = mock_features - - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._send_to_priority_tenant_queue = Mock() - - # Act - proxy._dispatch() - - # Assert - proxy._send_to_priority_tenant_queue.assert_called_once() - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.FeatureService") - def test_dispatch_with_billing_enabled_professional_plan(self, mock_feature_service): - """ - Test _dispatch method when billing is enabled with PROFESSIONAL plan. - - This test verifies that when billing is enabled and the subscription - plan is PROFESSIONAL, the dispatch method routes to the priority tenant queue. - """ - # Arrange - mock_features = DocumentIndexingTaskProxyTestDataFactory.create_mock_features( - billing_enabled=True, plan=CloudPlan.PROFESSIONAL - ) - - mock_feature_service.get_features.return_value = mock_features - - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._send_to_priority_tenant_queue = Mock() - - # Act - proxy._dispatch() - - # Assert - proxy._send_to_priority_tenant_queue.assert_called_once() - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.FeatureService") - def test_dispatch_with_billing_disabled(self, mock_feature_service): - """ - Test _dispatch method when billing is disabled. - - This test verifies that when billing is disabled (e.g., self-hosted - or enterprise), the dispatch method routes to the priority direct queue. - """ - # Arrange - mock_features = DocumentIndexingTaskProxyTestDataFactory.create_mock_features(billing_enabled=False) - - mock_feature_service.get_features.return_value = mock_features - - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._send_to_priority_direct_queue = Mock() - - # Act - proxy._dispatch() - - # Assert - proxy._send_to_priority_direct_queue.assert_called_once() - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.FeatureService") - def test_dispatch_edge_case_empty_plan(self, mock_feature_service): - """ - Test _dispatch method with empty plan string. - - This test verifies that when billing is enabled but the plan is an - empty string, the dispatch method routes to the priority tenant queue - (treats it as a non-SANDBOX plan). - """ - # Arrange - mock_features = DocumentIndexingTaskProxyTestDataFactory.create_mock_features(billing_enabled=True, plan="") - - mock_feature_service.get_features.return_value = mock_features - - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._send_to_priority_tenant_queue = Mock() - - # Act - proxy._dispatch() - - # Assert - proxy._send_to_priority_tenant_queue.assert_called_once() - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.FeatureService") - def test_dispatch_edge_case_none_plan(self, mock_feature_service): - """ - Test _dispatch method with None plan. - - This test verifies that when billing is enabled but the plan is None, - the dispatch method routes to the priority tenant queue (treats it as - a non-SANDBOX plan). - """ - # Arrange - mock_features = DocumentIndexingTaskProxyTestDataFactory.create_mock_features(billing_enabled=True, plan=None) - - mock_feature_service.get_features.return_value = mock_features - - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._send_to_priority_tenant_queue = Mock() - - # Act - proxy._dispatch() - - # Assert - proxy._send_to_priority_tenant_queue.assert_called_once() - - # ======================================================================== - # Delay Method Tests - # ======================================================================== - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.FeatureService") - def test_delay_method(self, mock_feature_service): - """ - Test delay method integration. - - This test verifies that the delay method correctly calls _dispatch, - which is the public interface for scheduling document indexing tasks. - """ - # Arrange - mock_features = DocumentIndexingTaskProxyTestDataFactory.create_mock_features( - billing_enabled=True, plan=CloudPlan.SANDBOX - ) - - mock_feature_service.get_features.return_value = mock_features - - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._send_to_default_tenant_queue = Mock() - - # Act - proxy.delay() - - # Assert - proxy._send_to_default_tenant_queue.assert_called_once() - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.FeatureService") - def test_delay_method_with_team_plan(self, mock_feature_service): - """ - Test delay method with TEAM plan. - - This test verifies that the delay method correctly routes to the - priority tenant queue when the subscription plan is TEAM. - """ - # Arrange - mock_features = DocumentIndexingTaskProxyTestDataFactory.create_mock_features( - billing_enabled=True, plan=CloudPlan.TEAM - ) - - mock_feature_service.get_features.return_value = mock_features - - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._send_to_priority_tenant_queue = Mock() - - # Act - proxy.delay() - - # Assert - proxy._send_to_priority_tenant_queue.assert_called_once() - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.FeatureService") - def test_delay_method_with_billing_disabled(self, mock_feature_service): - """ - Test delay method with billing disabled. - - This test verifies that the delay method correctly routes to the - priority direct queue when billing is disabled. - """ - # Arrange - mock_features = DocumentIndexingTaskProxyTestDataFactory.create_mock_features(billing_enabled=False) - - mock_feature_service.get_features.return_value = mock_features - - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._send_to_priority_direct_queue = Mock() - - # Act - proxy.delay() - - # Assert - proxy._send_to_priority_direct_queue.assert_called_once() - - # ======================================================================== - # DocumentTask Entity Tests - # ======================================================================== - - def test_document_task_dataclass(self): - """ - Test DocumentTask dataclass. - - This test verifies that DocumentTask entities can be created and - accessed correctly, which is important for task serialization. - """ - # Arrange - tenant_id = "tenant-123" - - dataset_id = "dataset-456" - - document_ids = ["doc-1", "doc-2"] - - # Act - task = DocumentTask(tenant_id=tenant_id, dataset_id=dataset_id, document_ids=document_ids) - - # Assert - assert task.tenant_id == tenant_id - - assert task.dataset_id == dataset_id - - assert task.document_ids == document_ids - - def test_document_task_serialization(self): - """ - Test DocumentTask serialization to dictionary. - - This test verifies that DocumentTask entities can be correctly - serialized to dictionaries using asdict() for queue storage. - """ - # Arrange - from dataclasses import asdict - - task = DocumentIndexingTaskProxyTestDataFactory.create_document_task() - - # Act - task_dict = asdict(task) - - # Assert - assert task_dict["tenant_id"] == "tenant-123" - - assert task_dict["dataset_id"] == "dataset-456" - - assert task_dict["document_ids"] == ["doc-1", "doc-2", "doc-3"] - - def test_document_task_deserialization(self): - """ - Test DocumentTask deserialization from dictionary. - - This test verifies that DocumentTask entities can be correctly - deserialized from dictionaries when pulled from the queue. - """ - # Arrange - task_dict = { - "tenant_id": "tenant-123", - "dataset_id": "dataset-456", - "document_ids": ["doc-1", "doc-2", "doc-3"], - } - - # Act - task = DocumentTask(**task_dict) - - # Assert - assert task.tenant_id == "tenant-123" - - assert task.dataset_id == "dataset-456" - - assert task.document_ids == ["doc-1", "doc-2", "doc-3"] - - # ======================================================================== - # Batch Operations Tests - # ======================================================================== - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.normal_document_indexing_task") - def test_batch_operation_with_multiple_documents(self, mock_task): - """ - Test batch operation with multiple documents. - - This test verifies that the proxy correctly handles batch operations - with multiple document IDs in a single task. - """ - # Arrange - document_ids = [f"doc-{i}" for i in range(10)] - - proxy = DocumentIndexingTaskProxy("tenant-123", "dataset-456", document_ids) - - mock_task.delay = Mock() - - # Act - proxy._send_to_direct_queue(mock_task) - - # Assert - mock_task.delay.assert_called_once_with( - tenant_id="tenant-123", dataset_id="dataset-456", document_ids=document_ids - ) - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.normal_document_indexing_task") - def test_batch_operation_with_large_batch(self, mock_task): - """ - Test batch operation with large batch of documents. - - This test verifies that the proxy correctly handles large batches - of document IDs, which may occur in bulk indexing scenarios. - """ - # Arrange - document_ids = [f"doc-{i}" for i in range(100)] - - proxy = DocumentIndexingTaskProxy("tenant-123", "dataset-456", document_ids) - - mock_task.delay = Mock() - - # Act - proxy._send_to_direct_queue(mock_task) - - # Assert - mock_task.delay.assert_called_once_with( - tenant_id="tenant-123", dataset_id="dataset-456", document_ids=document_ids - ) - - assert len(mock_task.delay.call_args[1]["document_ids"]) == 100 - - # ======================================================================== - # Error Handling Tests - # ======================================================================== - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.normal_document_indexing_task") - def test_send_to_direct_queue_task_delay_failure(self, mock_task): - """ - Test _send_to_direct_queue when task.delay() raises an exception. - - This test verifies that exceptions raised by task.delay() are - propagated correctly and not swallowed. - """ - # Arrange - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - mock_task.delay.side_effect = Exception("Task delay failed") - - # Act & Assert - with pytest.raises(Exception, match="Task delay failed"): - proxy._send_to_direct_queue(mock_task) - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.normal_document_indexing_task") - def test_send_to_tenant_queue_push_tasks_failure(self, mock_task): - """ - Test _send_to_tenant_queue when push_tasks raises an exception. - - This test verifies that exceptions raised by push_tasks are - propagated correctly when a task key exists. - """ - # Arrange - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - mock_queue = DocumentIndexingTaskProxyTestDataFactory.create_mock_tenant_queue(has_task_key=True) - - mock_queue.push_tasks.side_effect = Exception("Push tasks failed") - - proxy._tenant_isolated_task_queue = mock_queue - - # Act & Assert - with pytest.raises(Exception, match="Push tasks failed"): - proxy._send_to_tenant_queue(mock_task) - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.normal_document_indexing_task") - def test_send_to_tenant_queue_set_waiting_time_failure(self, mock_task): - """ - Test _send_to_tenant_queue when set_task_waiting_time raises an exception. - - This test verifies that exceptions raised by set_task_waiting_time are - propagated correctly when no task key exists. - """ - # Arrange - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - mock_queue = DocumentIndexingTaskProxyTestDataFactory.create_mock_tenant_queue(has_task_key=False) - - mock_queue.set_task_waiting_time.side_effect = Exception("Set waiting time failed") - - proxy._tenant_isolated_task_queue = mock_queue - - # Act & Assert - with pytest.raises(Exception, match="Set waiting time failed"): - proxy._send_to_tenant_queue(mock_task) - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.FeatureService") - def test_dispatch_feature_service_failure(self, mock_feature_service): - """ - Test _dispatch when FeatureService.get_features raises an exception. - - This test verifies that exceptions raised by FeatureService.get_features - are propagated correctly during dispatch. - """ - # Arrange - mock_feature_service.get_features.side_effect = Exception("Feature service failed") - - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - # Act & Assert - with pytest.raises(Exception, match="Feature service failed"): - proxy._dispatch() - - # ======================================================================== - # Integration Tests - # ======================================================================== - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.FeatureService") - @patch("services.document_indexing_proxy.document_indexing_task_proxy.normal_document_indexing_task") - def test_full_flow_sandbox_plan(self, mock_task, mock_feature_service): - """ - Test full flow for SANDBOX plan with tenant queue. - - This test verifies the complete flow from delay() call to task - scheduling for a SANDBOX plan tenant, including tenant isolation. - """ - # Arrange - mock_features = DocumentIndexingTaskProxyTestDataFactory.create_mock_features( - billing_enabled=True, plan=CloudPlan.SANDBOX - ) - - mock_feature_service.get_features.return_value = mock_features - - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._tenant_isolated_task_queue = DocumentIndexingTaskProxyTestDataFactory.create_mock_tenant_queue( - has_task_key=False - ) - - mock_task.delay = Mock() - - # Act - proxy.delay() - - # Assert - proxy._tenant_isolated_task_queue.set_task_waiting_time.assert_called_once() - - mock_task.delay.assert_called_once_with( - tenant_id="tenant-123", dataset_id="dataset-456", document_ids=["doc-1", "doc-2", "doc-3"] - ) - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.FeatureService") - @patch("services.document_indexing_proxy.document_indexing_task_proxy.priority_document_indexing_task") - def test_full_flow_team_plan(self, mock_task, mock_feature_service): - """ - Test full flow for TEAM plan with priority tenant queue. - - This test verifies the complete flow from delay() call to task - scheduling for a TEAM plan tenant, including priority routing. - """ - # Arrange - mock_features = DocumentIndexingTaskProxyTestDataFactory.create_mock_features( - billing_enabled=True, plan=CloudPlan.TEAM - ) - - mock_feature_service.get_features.return_value = mock_features - - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._tenant_isolated_task_queue = DocumentIndexingTaskProxyTestDataFactory.create_mock_tenant_queue( - has_task_key=False - ) - - mock_task.delay = Mock() - - # Act - proxy.delay() - - # Assert - proxy._tenant_isolated_task_queue.set_task_waiting_time.assert_called_once() - - mock_task.delay.assert_called_once_with( - tenant_id="tenant-123", dataset_id="dataset-456", document_ids=["doc-1", "doc-2", "doc-3"] - ) - - @patch("services.document_indexing_proxy.document_indexing_task_proxy.FeatureService") - @patch("services.document_indexing_proxy.document_indexing_task_proxy.priority_document_indexing_task") - def test_full_flow_billing_disabled(self, mock_task, mock_feature_service): - """ - Test full flow for billing disabled (self-hosted/enterprise). - - This test verifies the complete flow from delay() call to task - scheduling when billing is disabled, using priority direct queue. - """ - # Arrange - mock_features = DocumentIndexingTaskProxyTestDataFactory.create_mock_features(billing_enabled=False) - - mock_feature_service.get_features.return_value = mock_features - - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - mock_task.delay = Mock() - - # Act - proxy.delay() - - # Assert - mock_task.delay.assert_called_once_with( - tenant_id="tenant-123", dataset_id="dataset-456", document_ids=["doc-1", "doc-2", "doc-3"] - ) - - @patch("services.document_indexing_task_proxy.FeatureService") - @patch("services.document_indexing_task_proxy.normal_document_indexing_task") - def test_full_flow_with_existing_task_key(self, mock_task, mock_feature_service): - """ - Test full flow when task key exists (task queuing). - - This test verifies the complete flow when another task is already - running, ensuring the new task is queued correctly. - """ - # Arrange - mock_features = DocumentIndexingTaskProxyTestDataFactory.create_mock_features( - billing_enabled=True, plan=CloudPlan.SANDBOX - ) - - mock_feature_service.get_features.return_value = mock_features - - proxy = DocumentIndexingTaskProxyTestDataFactory.create_document_task_proxy() - - proxy._tenant_isolated_task_queue = DocumentIndexingTaskProxyTestDataFactory.create_mock_tenant_queue( - has_task_key=True - ) - - mock_task.delay = Mock() - - # Act - proxy.delay() - - # Assert - proxy._tenant_isolated_task_queue.push_tasks.assert_called_once() - - pushed_tasks = proxy._tenant_isolated_task_queue.push_tasks.call_args[0][0] - - expected_task_data = { - "tenant_id": "tenant-123", - "dataset_id": "dataset-456", - "document_ids": ["doc-1", "doc-2", "doc-3"], - } - assert pushed_tasks[0] == expected_task_data - - assert pushed_tasks[0]["document_ids"] == ["doc-1", "doc-2", "doc-3"] - - mock_task.delay.assert_not_called() diff --git a/api/tests/unit_tests/services/external_dataset_service.py b/api/tests/unit_tests/services/external_dataset_service.py deleted file mode 100644 index 83bae370eb..0000000000 --- a/api/tests/unit_tests/services/external_dataset_service.py +++ /dev/null @@ -1,925 +0,0 @@ -""" -Extensive unit tests for ``ExternalDatasetService``. - -This module focuses on the *external dataset service* surface area, which is responsible -for integrating with **external knowledge APIs** and wiring them into Dify datasets. - -The goal of this test suite is twofold: - -- Provide **high‑confidence regression coverage** for all public helpers on - ``ExternalDatasetService``. -- Serve as **executable documentation** for how external API integration is expected - to behave in different scenarios (happy paths, validation failures, and error codes). - -The file intentionally contains **rich comments and generous spacing** in order to make -each scenario easy to scan during reviews. -""" - -from __future__ import annotations - -from types import SimpleNamespace -from typing import Any, cast -from unittest.mock import MagicMock, Mock, patch - -import httpx -import pytest - -from constants import HIDDEN_VALUE -from models.dataset import Dataset, ExternalKnowledgeApis, ExternalKnowledgeBindings -from services.entities.external_knowledge_entities.external_knowledge_entities import ( - Authorization, - AuthorizationConfig, - ExternalKnowledgeApiSetting, -) -from services.errors.dataset import DatasetNameDuplicateError -from services.external_knowledge_service import ExternalDatasetService - - -class ExternalDatasetTestDataFactory: - """ - Factory helpers for building *lightweight* mocks for external knowledge tests. - - These helpers are intentionally small and explicit: - - - They avoid pulling in unnecessary fixtures. - - They reflect the minimal contract that the service under test cares about. - """ - - @staticmethod - def create_external_api( - api_id: str = "api-123", - tenant_id: str = "tenant-1", - name: str = "Test API", - description: str = "Description", - settings: dict[str, Any] | None = None, - ) -> ExternalKnowledgeApis: - """ - Create a concrete ``ExternalKnowledgeApis`` instance with minimal fields. - - Using the real SQLAlchemy model (instead of a pure Mock) makes it easier to - exercise ``settings_dict`` and other convenience properties if needed. - """ - - instance = ExternalKnowledgeApis( - tenant_id=tenant_id, - name=name, - description=description, - settings=None if settings is None else cast(str, pytest.approx), # type: ignore[assignment] - ) - - # Overwrite generated id for determinism in assertions. - instance.id = api_id - return instance - - @staticmethod - def create_dataset( - dataset_id: str = "ds-1", - tenant_id: str = "tenant-1", - name: str = "External Dataset", - provider: str = "external", - ) -> Dataset: - """ - Build a small ``Dataset`` instance representing an external dataset. - """ - - dataset = Dataset( - tenant_id=tenant_id, - name=name, - description="", - provider=provider, - created_by="user-1", - ) - dataset.id = dataset_id - return dataset - - @staticmethod - def create_external_binding( - tenant_id: str = "tenant-1", - dataset_id: str = "ds-1", - api_id: str = "api-1", - external_knowledge_id: str = "knowledge-1", - ) -> ExternalKnowledgeBindings: - """ - Small helper for a binding between dataset and external knowledge API. - """ - - binding = ExternalKnowledgeBindings( - tenant_id=tenant_id, - dataset_id=dataset_id, - external_knowledge_api_id=api_id, - external_knowledge_id=external_knowledge_id, - created_by="user-1", - ) - return binding - - -# --------------------------------------------------------------------------- -# get_external_knowledge_apis -# --------------------------------------------------------------------------- - - -class TestExternalDatasetServiceGetExternalKnowledgeApis: - """ - Tests for ``ExternalDatasetService.get_external_knowledge_apis``. - - These tests focus on: - - - Basic pagination wiring via ``db.paginate``. - - Optional search keyword behaviour. - """ - - @pytest.fixture - def mock_db_paginate(self): - """ - Patch ``db.paginate`` so we do not touch the real database layer. - """ - - with ( - patch("services.external_knowledge_service.db.paginate", autospec=True) as mock_paginate, - patch("services.external_knowledge_service.select", autospec=True), - ): - yield mock_paginate - - def test_get_external_knowledge_apis_basic_pagination(self, mock_db_paginate: MagicMock): - """ - It should return ``items`` and ``total`` coming from the paginate object. - """ - - # Arrange - tenant_id = "tenant-1" - page = 1 - per_page = 20 - - mock_items = [Mock(spec=ExternalKnowledgeApis), Mock(spec=ExternalKnowledgeApis)] - mock_pagination = SimpleNamespace(items=mock_items, total=42) - mock_db_paginate.return_value = mock_pagination - - # Act - items, total = ExternalDatasetService.get_external_knowledge_apis(page, per_page, tenant_id) - - # Assert - assert items is mock_items - assert total == 42 - - mock_db_paginate.assert_called_once() - call_kwargs = mock_db_paginate.call_args.kwargs - assert call_kwargs["page"] == page - assert call_kwargs["per_page"] == per_page - assert call_kwargs["max_per_page"] == 100 - assert call_kwargs["error_out"] is False - - def test_get_external_knowledge_apis_with_search_keyword(self, mock_db_paginate: MagicMock): - """ - When a search keyword is provided, the query should be adjusted - (we simply assert that paginate is still called and does not explode). - """ - - # Arrange - tenant_id = "tenant-1" - page = 2 - per_page = 10 - search = "foo" - - mock_pagination = SimpleNamespace(items=[], total=0) - mock_db_paginate.return_value = mock_pagination - - # Act - items, total = ExternalDatasetService.get_external_knowledge_apis(page, per_page, tenant_id, search=search) - - # Assert - assert items == [] - assert total == 0 - mock_db_paginate.assert_called_once() - - -# --------------------------------------------------------------------------- -# validate_api_list -# --------------------------------------------------------------------------- - - -class TestExternalDatasetServiceValidateApiList: - """ - Lightweight validation tests for ``validate_api_list``. - """ - - def test_validate_api_list_success(self): - """ - A minimal valid configuration (endpoint + api_key) should pass. - """ - - config = {"endpoint": "https://example.com", "api_key": "secret"} - - # Act & Assert – no exception expected - ExternalDatasetService.validate_api_list(config) - - @pytest.mark.parametrize( - ("config", "expected_message"), - [ - ({}, "api list is empty"), - ({"api_key": "k"}, "endpoint is required"), - ({"endpoint": "https://example.com"}, "api_key is required"), - ], - ) - def test_validate_api_list_failures(self, config: dict[str, Any], expected_message: str): - """ - Invalid configs should raise ``ValueError`` with a clear message. - """ - - with pytest.raises(ValueError, match=expected_message): - ExternalDatasetService.validate_api_list(config) - - -# --------------------------------------------------------------------------- -# create_external_knowledge_api & get/update/delete -# --------------------------------------------------------------------------- - - -class TestExternalDatasetServiceCrudExternalKnowledgeApi: - """ - CRUD tests for external knowledge API templates. - """ - - @pytest.fixture - def mock_db_session(self): - """ - Patch ``db.session`` for all CRUD tests in this class. - """ - - with patch("services.external_knowledge_service.db.session", autospec=True) as mock_session: - yield mock_session - - def test_create_external_knowledge_api_success(self, mock_db_session: MagicMock): - """ - ``create_external_knowledge_api`` should persist a new record - when settings are present and valid. - """ - - tenant_id = "tenant-1" - user_id = "user-1" - args = { - "name": "API", - "description": "desc", - "settings": {"endpoint": "https://api.example.com", "api_key": "secret"}, - } - - # We do not want to actually call the remote endpoint here, so we patch the validator. - with patch.object(ExternalDatasetService, "check_endpoint_and_api_key", autospec=True) as mock_check: - result = ExternalDatasetService.create_external_knowledge_api(tenant_id, user_id, args) - - assert isinstance(result, ExternalKnowledgeApis) - mock_check.assert_called_once_with(args["settings"]) - mock_db_session.add.assert_called_once() - mock_db_session.commit.assert_called_once() - - def test_create_external_knowledge_api_missing_settings_raises(self, mock_db_session: MagicMock): - """ - Missing ``settings`` should result in a ``ValueError``. - """ - - tenant_id = "tenant-1" - user_id = "user-1" - args = {"name": "API", "description": "desc"} - - with pytest.raises(ValueError, match="settings is required"): - ExternalDatasetService.create_external_knowledge_api(tenant_id, user_id, args) - - mock_db_session.add.assert_not_called() - mock_db_session.commit.assert_not_called() - - def test_get_external_knowledge_api_found(self, mock_db_session: MagicMock): - """ - ``get_external_knowledge_api`` should return the first matching record. - """ - - api = Mock(spec=ExternalKnowledgeApis) - mock_db_session.scalar.return_value = api - - result = ExternalDatasetService.get_external_knowledge_api("api-id", "tenant-id") - assert result is api - - def test_get_external_knowledge_api_not_found_raises(self, mock_db_session: MagicMock): - """ - When the record is absent, a ``ValueError`` is raised. - """ - - mock_db_session.scalar.return_value = None - - with pytest.raises(ValueError, match="api template not found"): - ExternalDatasetService.get_external_knowledge_api("missing-id", "tenant-id") - - def test_update_external_knowledge_api_success_with_hidden_api_key(self, mock_db_session: MagicMock): - """ - Updating an API should keep the existing API key when the special hidden - value placeholder is sent from the UI. - """ - - tenant_id = "tenant-1" - user_id = "user-1" - api_id = "api-1" - - existing_api = Mock(spec=ExternalKnowledgeApis) - existing_api.settings_dict = {"api_key": "stored-key"} - existing_api.settings = '{"api_key":"stored-key"}' - mock_db_session.scalar.return_value = existing_api - - args = { - "name": "New Name", - "description": "New Desc", - "settings": {"endpoint": "https://api.example.com", "api_key": HIDDEN_VALUE}, - } - - result = ExternalDatasetService.update_external_knowledge_api(tenant_id, user_id, api_id, args) - - assert result is existing_api - # The placeholder should be replaced with stored key. - assert args["settings"]["api_key"] == "stored-key" - mock_db_session.commit.assert_called_once() - - def test_update_external_knowledge_api_not_found_raises(self, mock_db_session: MagicMock): - """ - Updating a non‑existent API template should raise ``ValueError``. - """ - - mock_db_session.scalar.return_value = None - - with pytest.raises(ValueError, match="api template not found"): - ExternalDatasetService.update_external_knowledge_api( - tenant_id="tenant-1", - user_id="user-1", - external_knowledge_api_id="missing-id", - args={"name": "n", "description": "d", "settings": {}}, - ) - - def test_delete_external_knowledge_api_success(self, mock_db_session: MagicMock): - """ - ``delete_external_knowledge_api`` should delete and commit when found. - """ - - api = Mock(spec=ExternalKnowledgeApis) - mock_db_session.scalar.return_value = api - - ExternalDatasetService.delete_external_knowledge_api("tenant-1", "api-1") - - mock_db_session.delete.assert_called_once_with(api) - mock_db_session.commit.assert_called_once() - - def test_delete_external_knowledge_api_not_found_raises(self, mock_db_session: MagicMock): - """ - Deletion of a missing template should raise ``ValueError``. - """ - - mock_db_session.scalar.return_value = None - - with pytest.raises(ValueError, match="api template not found"): - ExternalDatasetService.delete_external_knowledge_api("tenant-1", "missing") - - -# --------------------------------------------------------------------------- -# external_knowledge_api_use_check & binding lookups -# --------------------------------------------------------------------------- - - -class TestExternalDatasetServiceUsageAndBindings: - """ - Tests for usage checks and dataset binding retrieval. - """ - - @pytest.fixture - def mock_db_session(self): - with patch("services.external_knowledge_service.db.session", autospec=True) as mock_session: - yield mock_session - - def test_external_knowledge_api_use_check_in_use(self, mock_db_session: MagicMock): - """ - When there are bindings, ``external_knowledge_api_use_check`` returns True and count. - """ - - mock_db_session.scalar.return_value = 3 - - in_use, count = ExternalDatasetService.external_knowledge_api_use_check("api-1", "tenant-1") - - assert in_use is True - assert count == 3 - assert "tenant_id" in str(mock_db_session.scalar.call_args.args[0]) - - def test_external_knowledge_api_use_check_not_in_use(self, mock_db_session: MagicMock): - """ - Zero bindings should return ``(False, 0)``. - """ - - mock_db_session.scalar.return_value = 0 - - in_use, count = ExternalDatasetService.external_knowledge_api_use_check("api-1", "tenant-1") - - assert in_use is False - assert count == 0 - - def test_get_external_knowledge_binding_with_dataset_id_found(self, mock_db_session: MagicMock): - """ - Binding lookup should return the first record when present. - """ - - binding = Mock(spec=ExternalKnowledgeBindings) - mock_db_session.scalar.return_value = binding - - result = ExternalDatasetService.get_external_knowledge_binding_with_dataset_id("tenant-1", "ds-1") - assert result is binding - - def test_get_external_knowledge_binding_with_dataset_id_not_found_raises(self, mock_db_session: MagicMock): - """ - Missing binding should result in a ``ValueError``. - """ - - mock_db_session.scalar.return_value = None - - with pytest.raises(ValueError, match="external knowledge binding not found"): - ExternalDatasetService.get_external_knowledge_binding_with_dataset_id("tenant-1", "ds-1") - - -# --------------------------------------------------------------------------- -# document_create_args_validate -# --------------------------------------------------------------------------- - - -class TestExternalDatasetServiceDocumentCreateArgsValidate: - """ - Tests for ``document_create_args_validate``. - """ - - @pytest.fixture - def mock_db_session(self): - with patch("services.external_knowledge_service.db.session", autospec=True) as mock_session: - yield mock_session - - def test_document_create_args_validate_success(self, mock_db_session: MagicMock): - """ - All required custom parameters present – validation should pass. - """ - - external_api = Mock(spec=ExternalKnowledgeApis) - external_api.settings = json_settings = ( - '[{"document_process_setting":[{"name":"foo","required":true},{"name":"bar","required":false}]}]' - ) - # Raw string; the service itself calls json.loads on it - mock_db_session.scalar.return_value = external_api - - process_parameter = {"foo": "value", "bar": "optional"} - - # Act & Assert – no exception - ExternalDatasetService.document_create_args_validate("tenant-1", "api-1", process_parameter) - - assert json_settings in external_api.settings # simple sanity check on our test data - - def test_document_create_args_validate_missing_template_raises(self, mock_db_session: MagicMock): - """ - When the referenced API template is missing, a ``ValueError`` is raised. - """ - - mock_db_session.scalar.return_value = None - - with pytest.raises(ValueError, match="api template not found"): - ExternalDatasetService.document_create_args_validate("tenant-1", "missing", {}) - - def test_document_create_args_validate_missing_required_parameter_raises(self, mock_db_session: MagicMock): - """ - Required document process parameters must be supplied. - """ - - external_api = Mock(spec=ExternalKnowledgeApis) - external_api.settings = ( - '[{"document_process_setting":[{"name":"foo","required":true},{"name":"bar","required":false}]}]' - ) - mock_db_session.scalar.return_value = external_api - - process_parameter = {"bar": "present"} # missing "foo" - - with pytest.raises(ValueError, match="foo is required"): - ExternalDatasetService.document_create_args_validate("tenant-1", "api-1", process_parameter) - - -# --------------------------------------------------------------------------- -# process_external_api -# --------------------------------------------------------------------------- - - -class TestExternalDatasetServiceProcessExternalApi: - """ - Tests focused on the HTTP request assembly and method mapping behaviour. - """ - - def test_process_external_api_valid_method_post(self): - """ - For a supported HTTP verb we should delegate to the correct ``ssrf_proxy`` function. - """ - - settings = ExternalKnowledgeApiSetting( - url="https://example.com/path", - request_method="POST", - headers={"X-Test": "1"}, - params={"foo": "bar"}, - ) - - fake_response = httpx.Response(200) - - with patch("services.external_knowledge_service.ssrf_proxy.post", autospec=True) as mock_post: - mock_post.return_value = fake_response - - result = ExternalDatasetService.process_external_api(settings, files=None) - - assert result is fake_response - mock_post.assert_called_once() - kwargs = mock_post.call_args.kwargs - assert kwargs["url"] == settings.url - assert kwargs["headers"] == settings.headers - assert kwargs["follow_redirects"] is True - assert "data" in kwargs - - def test_process_external_api_invalid_method_raises(self): - """ - An unsupported HTTP verb should raise ``InvalidHttpMethodError``. - """ - - settings = ExternalKnowledgeApiSetting( - url="https://example.com", - request_method="INVALID", - headers=None, - params={}, - ) - - from graphon.nodes.http_request.exc import InvalidHttpMethodError - - with pytest.raises(InvalidHttpMethodError): - ExternalDatasetService.process_external_api(settings, files=None) - - -# --------------------------------------------------------------------------- -# assembling_headers -# --------------------------------------------------------------------------- - - -class TestExternalDatasetServiceAssemblingHeaders: - """ - Tests for header assembly based on different authentication flavours. - """ - - def test_assembling_headers_bearer_token(self): - """ - For bearer auth we expect ``Authorization: Bearer `` by default. - """ - - auth = Authorization( - type="api-key", - config=AuthorizationConfig(type="bearer", api_key="secret", header=None), - ) - - headers = ExternalDatasetService.assembling_headers(auth) - - assert headers["Authorization"] == "Bearer secret" - - def test_assembling_headers_basic_token_with_custom_header(self): - """ - For basic auth we honour the configured header name. - """ - - auth = Authorization( - type="api-key", - config=AuthorizationConfig(type="basic", api_key="abc123", header="X-Auth"), - ) - - headers = ExternalDatasetService.assembling_headers(auth, headers={"Existing": "1"}) - - assert headers["Existing"] == "1" - assert headers["X-Auth"] == "Basic abc123" - - def test_assembling_headers_custom_type(self): - """ - Custom auth type should inject the raw API key. - """ - - auth = Authorization( - type="api-key", - config=AuthorizationConfig(type="custom", api_key="raw-key", header="X-API-KEY"), - ) - - headers = ExternalDatasetService.assembling_headers(auth, headers=None) - - assert headers["X-API-KEY"] == "raw-key" - - def test_assembling_headers_missing_config_raises(self): - """ - Missing config object should be rejected. - """ - - auth = Authorization(type="api-key", config=None) - - with pytest.raises(ValueError, match="authorization config is required"): - ExternalDatasetService.assembling_headers(auth) - - def test_assembling_headers_missing_api_key_raises(self): - """ - ``api_key`` is required when type is ``api-key``. - """ - - auth = Authorization( - type="api-key", - config=AuthorizationConfig(type="bearer", api_key=None, header="Authorization"), - ) - - with pytest.raises(ValueError, match="api_key is required"): - ExternalDatasetService.assembling_headers(auth) - - def test_assembling_headers_no_auth_type_leaves_headers_unchanged(self): - """ - For ``no-auth`` we should not modify the headers mapping. - """ - - auth = Authorization(type="no-auth", config=None) - - base_headers = {"X": "1"} - result = ExternalDatasetService.assembling_headers(auth, headers=base_headers) - - # A copy is returned, original is not mutated. - assert result == base_headers - assert result is not base_headers - - -# --------------------------------------------------------------------------- -# get_external_knowledge_api_settings -# --------------------------------------------------------------------------- - - -class TestExternalDatasetServiceGetExternalKnowledgeApiSettings: - """ - Simple shape test for ``get_external_knowledge_api_settings``. - """ - - def test_get_external_knowledge_api_settings(self): - settings_dict: dict[str, Any] = { - "url": "https://example.com/retrieval", - "request_method": "post", - "headers": {"Content-Type": "application/json"}, - "params": {"foo": "bar"}, - } - - result = ExternalDatasetService.get_external_knowledge_api_settings(settings_dict) - - assert isinstance(result, ExternalKnowledgeApiSetting) - assert result.url == settings_dict["url"] - assert result.request_method == settings_dict["request_method"] - assert result.headers == settings_dict["headers"] - assert result.params == settings_dict["params"] - - -# --------------------------------------------------------------------------- -# create_external_dataset -# --------------------------------------------------------------------------- - - -class TestExternalDatasetServiceCreateExternalDataset: - """ - Tests around creating the external dataset and its binding row. - """ - - @pytest.fixture - def mock_db_session(self): - with patch("services.external_knowledge_service.db.session", autospec=True) as mock_session: - yield mock_session - - def test_create_external_dataset_success(self, mock_db_session: MagicMock): - """ - A brand new dataset name with valid external knowledge references - should create both the dataset and its binding. - """ - - tenant_id = "tenant-1" - user_id = "user-1" - - args = { - "name": "My Dataset", - "description": "desc", - "external_knowledge_api_id": "api-1", - "external_knowledge_id": "knowledge-1", - "external_retrieval_model": {"top_k": 3}, - } - - # No existing dataset with same name. - mock_db_session.scalar.side_effect = [ - None, # duplicate‑name check - Mock(spec=ExternalKnowledgeApis), # external knowledge api - ] - - dataset = ExternalDatasetService.create_external_dataset(tenant_id, user_id, args) - - assert isinstance(dataset, Dataset) - assert dataset.provider == "external" - assert dataset.retrieval_model == args["external_retrieval_model"] - - assert mock_db_session.add.call_count >= 2 # dataset + binding - mock_db_session.flush.assert_called_once() - mock_db_session.commit.assert_called_once() - - def test_create_external_dataset_duplicate_name_raises(self, mock_db_session: MagicMock): - """ - When a dataset with the same name already exists, - ``DatasetNameDuplicateError`` is raised. - """ - - existing_dataset = Mock(spec=Dataset) - mock_db_session.scalar.return_value = existing_dataset - - args = { - "name": "Existing", - "external_knowledge_api_id": "api-1", - "external_knowledge_id": "knowledge-1", - } - - with pytest.raises(DatasetNameDuplicateError): - ExternalDatasetService.create_external_dataset("tenant-1", "user-1", args) - - mock_db_session.add.assert_not_called() - mock_db_session.commit.assert_not_called() - - def test_create_external_dataset_missing_api_template_raises(self, mock_db_session: MagicMock): - """ - If the referenced external knowledge API does not exist, a ``ValueError`` is raised. - """ - - # First call: duplicate name check – not found. - mock_db_session.scalar.side_effect = [ - None, - None, # external knowledge api lookup - ] - - args = { - "name": "Dataset", - "external_knowledge_api_id": "missing", - "external_knowledge_id": "knowledge-1", - } - - with pytest.raises(ValueError, match="api template not found"): - ExternalDatasetService.create_external_dataset("tenant-1", "user-1", args) - - def test_create_external_dataset_missing_required_ids_raise(self, mock_db_session: MagicMock): - """ - ``external_knowledge_id`` and ``external_knowledge_api_id`` are mandatory. - """ - - # duplicate name check — two calls to create_external_dataset, each does 2 scalar calls - mock_db_session.scalar.side_effect = [ - None, - Mock(spec=ExternalKnowledgeApis), - None, - Mock(spec=ExternalKnowledgeApis), - ] - - args_missing_knowledge_id = { - "name": "Dataset", - "external_knowledge_api_id": "api-1", - "external_knowledge_id": None, - } - - with pytest.raises(ValueError, match="external_knowledge_id is required"): - ExternalDatasetService.create_external_dataset("tenant-1", "user-1", args_missing_knowledge_id) - - args_missing_api_id = { - "name": "Dataset", - "external_knowledge_api_id": None, - "external_knowledge_id": "k-1", - } - - with pytest.raises(ValueError, match="external_knowledge_api_id is required"): - ExternalDatasetService.create_external_dataset("tenant-1", "user-1", args_missing_api_id) - - -# --------------------------------------------------------------------------- -# fetch_external_knowledge_retrieval -# --------------------------------------------------------------------------- - - -class TestExternalDatasetServiceFetchExternalKnowledgeRetrieval: - """ - Tests for ``fetch_external_knowledge_retrieval`` which orchestrates - external retrieval requests and normalises the response payload. - """ - - @pytest.fixture - def mock_db_session(self): - with patch("services.external_knowledge_service.db.session", autospec=True) as mock_session: - yield mock_session - - def test_fetch_external_knowledge_retrieval_success(self, mock_db_session: MagicMock): - """ - With a valid binding and API template, records from the external - service should be returned when the HTTP response is 200. - """ - - tenant_id = "tenant-1" - dataset_id = "ds-1" - query = "test query" - external_retrieval_parameters = {"top_k": 3, "score_threshold_enabled": True, "score_threshold": 0.5} - - binding = ExternalDatasetTestDataFactory.create_external_binding( - tenant_id=tenant_id, - dataset_id=dataset_id, - api_id="api-1", - external_knowledge_id="knowledge-1", - ) - - api = Mock(spec=ExternalKnowledgeApis) - api.settings = '{"endpoint":"https://example.com","api_key":"secret"}' - - # First query: binding; second query: api. - mock_db_session.scalar.side_effect = [ - binding, - api, - ] - - fake_records = [{"content": "doc", "score": 0.9}] - fake_response = Mock(spec=httpx.Response) - fake_response.status_code = 200 - fake_response.json.return_value = {"records": fake_records} - - metadata_condition = SimpleNamespace(model_dump=lambda: {"field": "value"}) - - with patch.object( - ExternalDatasetService, "process_external_api", return_value=fake_response, autospec=True - ) as mock_process: - result = ExternalDatasetService.fetch_external_knowledge_retrieval( - tenant_id=tenant_id, - dataset_id=dataset_id, - query=query, - external_retrieval_parameters=external_retrieval_parameters, - metadata_condition=metadata_condition, - ) - - assert result == fake_records - - mock_process.assert_called_once() - setting_arg = mock_process.call_args.args[0] - assert isinstance(setting_arg, ExternalKnowledgeApiSetting) - assert setting_arg.url.endswith("/retrieval") - - def test_fetch_external_knowledge_retrieval_binding_not_found_raises(self, mock_db_session: MagicMock): - """ - Missing binding should raise ``ValueError``. - """ - - mock_db_session.scalar.return_value = None - - with pytest.raises(ValueError, match="external knowledge binding not found"): - ExternalDatasetService.fetch_external_knowledge_retrieval( - tenant_id="tenant-1", - dataset_id="missing", - query="q", - external_retrieval_parameters={}, - metadata_condition=None, - ) - - def test_fetch_external_knowledge_retrieval_missing_api_template_raises(self, mock_db_session: MagicMock): - """ - When the API template is missing or has no settings, a ``ValueError`` is raised. - """ - - binding = ExternalDatasetTestDataFactory.create_external_binding() - mock_db_session.scalar.side_effect = [ - binding, - None, - ] - - with pytest.raises(ValueError, match="external api template not found"): - ExternalDatasetService.fetch_external_knowledge_retrieval( - tenant_id="tenant-1", - dataset_id="ds-1", - query="q", - external_retrieval_parameters={}, - metadata_condition=None, - ) - - def test_fetch_external_knowledge_retrieval_non_200_status_returns_empty_list(self, mock_db_session: MagicMock): - """ - Non‑200 responses should be treated as an empty result set. - """ - - binding = ExternalDatasetTestDataFactory.create_external_binding() - api = Mock(spec=ExternalKnowledgeApis) - api.settings = '{"endpoint":"https://example.com","api_key":"secret"}' - - mock_db_session.scalar.side_effect = [ - binding, - api, - ] - - fake_response = Mock(spec=httpx.Response) - fake_response.status_code = 500 - fake_response.json.return_value = {} - - with patch.object(ExternalDatasetService, "process_external_api", return_value=fake_response, autospec=True): - result = ExternalDatasetService.fetch_external_knowledge_retrieval( - tenant_id="tenant-1", - dataset_id="ds-1", - query="q", - external_retrieval_parameters={}, - metadata_condition=None, - ) - - assert result == [] diff --git a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py index 327281d07f..efb79aadde 100644 --- a/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py +++ b/api/tests/unit_tests/services/rag_pipeline/test_rag_pipeline_service.py @@ -374,24 +374,14 @@ def test_publish_workflow_success(mocker, rag_pipeline_service) -> None: mock_db = mocker.Mock() mocker.patch("services.rag_pipeline.rag_pipeline.db", mock_db) mock_dataset_service_class = mocker.patch("services.dataset_service.DatasetService") - mock_dataset_service = mock_dataset_service_class.return_value - # 6. Mock session and its scalar/query methods + # 6. Mock session and dataset lookup mock_session = mocker.Mock() mock_session.scalar.return_value = draft_wf - # Mock dataset update query (needed even if service is mocked, as rag_pipeline fetches it first) dataset = mocker.Mock() dataset.retrieval_model_dict = {} - dataset_query = mocker.Mock() - dataset_query.where.return_value.first.return_value = dataset - - # Mock node execution copy - node_exec_query = mocker.Mock() - node_exec_query.where.return_value.all.return_value = [] - - # Mocked session query side effects - mock_session.query.side_effect = [node_exec_query, dataset_query] + pipeline.retrieve_dataset.return_value = dataset # 7. Run test result = rag_pipeline_service.publish_workflow(session=mock_session, pipeline=pipeline, account=account) @@ -1524,7 +1514,6 @@ def test_handle_node_run_result_marks_document_error_for_published_invoke(mocker ) document = SimpleNamespace(indexing_status="waiting", error=None) - query = mocker.Mock() mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", return_value=document) add_mock = mocker.patch("services.rag_pipeline.rag_pipeline.db.session.add") commit_mock = mocker.patch("services.rag_pipeline.rag_pipeline.db.session.commit") @@ -1595,7 +1584,6 @@ def test_publish_customized_pipeline_template_raises_for_missing_workflow_id(moc def test_get_pipeline_raises_when_dataset_missing(mocker, rag_pipeline_service) -> None: - query = mocker.Mock() mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=None) with pytest.raises(ValueError, match="Dataset not found"): @@ -1604,7 +1592,6 @@ def test_get_pipeline_raises_when_dataset_missing(mocker, rag_pipeline_service) def test_get_pipeline_raises_when_pipeline_missing(mocker, rag_pipeline_service) -> None: dataset = SimpleNamespace(pipeline_id="p1") - query = mocker.Mock() mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[dataset, None]) with pytest.raises(ValueError, match="Pipeline not found"): @@ -1644,7 +1631,6 @@ def test_get_pipeline_templates_builtin_en_us_no_fallback(mocker) -> None: def test_update_customized_pipeline_template_commits_when_name_empty(mocker) -> None: template = SimpleNamespace(name="old", description="old", icon={}, updated_by=None) - query = mocker.Mock() mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=template) commit = mocker.patch("services.rag_pipeline.rag_pipeline.db.session.commit") mocker.patch("services.rag_pipeline.rag_pipeline.current_user", SimpleNamespace(id="u1", current_tenant_id="t1")) @@ -1871,7 +1857,6 @@ def test_run_free_workflow_node_delegates_to_handle_result(mocker, rag_pipeline_ def test_publish_customized_pipeline_template_raises_when_workflow_missing(mocker, rag_pipeline_service) -> None: pipeline = SimpleNamespace(id="p1", tenant_id="t1", workflow_id="wf-1") - query = mocker.Mock() mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", side_effect=[pipeline, None]) with pytest.raises(ValueError, match="Workflow not found"): @@ -1910,7 +1895,6 @@ def test_get_recommended_plugins_skips_manifest_when_missing(mocker, rag_pipelin def test_retry_error_document_raises_when_pipeline_missing(mocker, rag_pipeline_service) -> None: exec_log = SimpleNamespace(pipeline_id="p1") - query = mocker.Mock() mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=exec_log) mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", return_value=None) @@ -1923,7 +1907,6 @@ def test_retry_error_document_raises_when_pipeline_missing(mocker, rag_pipeline_ def test_retry_error_document_raises_when_workflow_missing(mocker, rag_pipeline_service) -> None: exec_log = SimpleNamespace(pipeline_id="p1") pipeline = SimpleNamespace(id="p1") - query = mocker.Mock() mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", return_value=exec_log) mocker.patch("services.rag_pipeline.rag_pipeline.db.session.get", return_value=pipeline) mocker.patch.object(rag_pipeline_service, "get_published_workflow", return_value=None) @@ -1940,7 +1923,6 @@ def test_get_datasource_plugins_returns_empty_for_non_datasource_nodes(mocker, r workflow = SimpleNamespace( graph_dict={"nodes": [{"id": "n1", "data": {"type": "start"}}]}, rag_pipeline_variables=[] ) - query = mocker.Mock() mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[dataset, pipeline]) mocker.patch.object(rag_pipeline_service, "get_published_workflow", return_value=workflow) @@ -2103,7 +2085,6 @@ def test_get_datasource_plugins_handles_empty_datasource_data_and_non_published( graph_dict={"nodes": [{"id": "n1", "data": {"type": "datasource", "datasource_parameters": {}}}]}, rag_pipeline_variables=[{"variable": "v1", "belong_to_node_id": "shared"}], ) - query = mocker.Mock() mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[dataset, pipeline]) mocker.patch.object(rag_pipeline_service, "get_draft_workflow", return_value=workflow) mocker.patch( @@ -2143,7 +2124,6 @@ def test_get_datasource_plugins_extracts_user_inputs_and_credentials(mocker, rag {"variable": "v3", "belong_to_node_id": "shared"}, ], ) - query = mocker.Mock() mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[dataset, pipeline]) mocker.patch.object(rag_pipeline_service, "get_published_workflow", return_value=workflow) mocker.patch( @@ -2161,7 +2141,6 @@ def test_get_datasource_plugins_extracts_user_inputs_and_credentials(mocker, rag def test_get_pipeline_returns_pipeline_when_found(mocker, rag_pipeline_service) -> None: dataset = SimpleNamespace(pipeline_id="p1") pipeline = SimpleNamespace(id="p1") - query = mocker.Mock() mocker.patch("services.rag_pipeline.rag_pipeline.db.session.scalar", side_effect=[dataset, pipeline]) result = rag_pipeline_service.get_pipeline("t1", "d1") diff --git a/api/tests/unit_tests/services/segment_service.py b/api/tests/unit_tests/services/segment_service.py deleted file mode 100644 index f0a66a00d4..0000000000 --- a/api/tests/unit_tests/services/segment_service.py +++ /dev/null @@ -1,1115 +0,0 @@ -from unittest.mock import MagicMock, Mock, patch - -import pytest - -from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType -from models.account import Account -from models.dataset import ChildChunk, Dataset, Document, DocumentSegment -from models.enums import SegmentType -from services.dataset_service import SegmentService -from services.entities.knowledge_entities.knowledge_entities import SegmentUpdateArgs -from services.errors.chunk import ChildChunkDeleteIndexError, ChildChunkIndexingError - - -class SegmentTestDataFactory: - """Factory class for creating test data and mock objects for segment service tests.""" - - @staticmethod - def create_segment_mock( - segment_id: str = "segment-123", - document_id: str = "doc-123", - dataset_id: str = "dataset-123", - tenant_id: str = "tenant-123", - content: str = "Test segment content", - position: int = 1, - enabled: bool = True, - status: str = "completed", - word_count: int = 3, - tokens: int = 5, - **kwargs, - ) -> Mock: - """Create a mock segment with specified attributes.""" - segment = Mock(spec=DocumentSegment) - segment.id = segment_id - segment.document_id = document_id - segment.dataset_id = dataset_id - segment.tenant_id = tenant_id - segment.content = content - segment.position = position - segment.enabled = enabled - segment.status = status - segment.word_count = word_count - segment.tokens = tokens - segment.index_node_id = f"node-{segment_id}" - segment.index_node_hash = "hash-123" - segment.keywords = [] - segment.answer = None - segment.disabled_at = None - segment.disabled_by = None - segment.updated_by = None - segment.updated_at = None - segment.indexing_at = None - segment.completed_at = None - segment.error = None - for key, value in kwargs.items(): - setattr(segment, key, value) - return segment - - @staticmethod - def create_child_chunk_mock( - chunk_id: str = "chunk-123", - segment_id: str = "segment-123", - document_id: str = "doc-123", - dataset_id: str = "dataset-123", - tenant_id: str = "tenant-123", - content: str = "Test child chunk content", - position: int = 1, - word_count: int = 3, - **kwargs, - ) -> Mock: - """Create a mock child chunk with specified attributes.""" - chunk = Mock(spec=ChildChunk) - chunk.id = chunk_id - chunk.segment_id = segment_id - chunk.document_id = document_id - chunk.dataset_id = dataset_id - chunk.tenant_id = tenant_id - chunk.content = content - chunk.position = position - chunk.word_count = word_count - chunk.index_node_id = f"node-{chunk_id}" - chunk.index_node_hash = "hash-123" - chunk.type = SegmentType.AUTOMATIC - chunk.created_by = "user-123" - chunk.updated_by = None - chunk.updated_at = None - for key, value in kwargs.items(): - setattr(chunk, key, value) - return chunk - - @staticmethod - def create_document_mock( - document_id: str = "doc-123", - dataset_id: str = "dataset-123", - tenant_id: str = "tenant-123", - doc_form: str = IndexStructureType.PARAGRAPH_INDEX, - word_count: int = 100, - **kwargs, - ) -> Mock: - """Create a mock document with specified attributes.""" - document = Mock(spec=Document) - document.id = document_id - document.dataset_id = dataset_id - document.tenant_id = tenant_id - document.doc_form = doc_form - document.word_count = word_count - for key, value in kwargs.items(): - setattr(document, key, value) - return document - - @staticmethod - def create_dataset_mock( - dataset_id: str = "dataset-123", - tenant_id: str = "tenant-123", - indexing_technique: str = IndexTechniqueType.HIGH_QUALITY, - embedding_model: str = "text-embedding-ada-002", - embedding_model_provider: str = "openai", - **kwargs, - ) -> Mock: - """Create a mock dataset with specified attributes.""" - dataset = Mock(spec=Dataset) - dataset.id = dataset_id - dataset.tenant_id = tenant_id - dataset.indexing_technique = indexing_technique - dataset.embedding_model = embedding_model - dataset.embedding_model_provider = embedding_model_provider - for key, value in kwargs.items(): - setattr(dataset, key, value) - return dataset - - @staticmethod - def create_user_mock( - user_id: str = "user-789", - tenant_id: str = "tenant-123", - **kwargs, - ) -> Mock: - """Create a mock user with specified attributes.""" - user = Mock(spec=Account) - user.id = user_id - user.current_tenant_id = tenant_id - user.name = "Test User" - for key, value in kwargs.items(): - setattr(user, key, value) - return user - - -class TestSegmentServiceCreateSegment: - """Tests for SegmentService.create_segment method.""" - - @pytest.fixture - def mock_db_session(self): - """Mock database session.""" - with patch("services.dataset_service.db.session", autospec=True) as mock_db: - yield mock_db - - @pytest.fixture - def mock_current_user(self): - """Mock current_user.""" - user = SegmentTestDataFactory.create_user_mock() - with patch("services.dataset_service.current_user", user): - yield user - - def test_create_segment_success(self, mock_db_session, mock_current_user): - """Test successful creation of a segment.""" - # Arrange - document = SegmentTestDataFactory.create_document_mock(word_count=100) - dataset = SegmentTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.ECONOMY) - args = {"content": "New segment content", "keywords": ["test", "segment"]} - - mock_query = MagicMock() - mock_query.where.return_value.scalar.return_value = None # No existing segments - mock_db_session.query.return_value = mock_query - - mock_segment = SegmentTestDataFactory.create_segment_mock() - mock_db_session.query.return_value.where.return_value.first.return_value = mock_segment - - with ( - patch("services.dataset_service.redis_client.lock", autospec=True) as mock_lock, - patch( - "services.dataset_service.VectorService.create_segments_vector", autospec=True - ) as mock_vector_service, - patch("services.dataset_service.helper.generate_text_hash", autospec=True) as mock_hash, - patch("services.dataset_service.naive_utc_now", autospec=True) as mock_now, - ): - mock_lock.return_value.__enter__ = Mock() - mock_lock.return_value.__exit__ = Mock(return_value=None) - mock_hash.return_value = "hash-123" - mock_now.return_value = "2024-01-01T00:00:00" - - # Act - result = SegmentService.create_segment(args, document, dataset) - - # Assert - assert mock_db_session.add.call_count == 2 - - created_segment = mock_db_session.add.call_args_list[0].args[0] - assert isinstance(created_segment, DocumentSegment) - assert created_segment.content == args["content"] - assert created_segment.word_count == len(args["content"]) - - mock_db_session.commit.assert_called_once() - - mock_vector_service.assert_called_once() - vector_call_args = mock_vector_service.call_args[0] - assert vector_call_args[0] == [args["keywords"]] - assert vector_call_args[1][0] == created_segment - assert vector_call_args[2] == dataset - assert vector_call_args[3] == document.doc_form - - assert result == mock_segment - - def test_create_segment_with_qa_model(self, mock_db_session, mock_current_user): - """Test creation of segment with QA model (requires answer).""" - # Arrange - document = SegmentTestDataFactory.create_document_mock(doc_form=IndexStructureType.QA_INDEX, word_count=100) - dataset = SegmentTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.ECONOMY) - args = {"content": "What is AI?", "answer": "AI is Artificial Intelligence", "keywords": ["ai"]} - - mock_query = MagicMock() - mock_query.where.return_value.scalar.return_value = None - mock_db_session.query.return_value = mock_query - - mock_segment = SegmentTestDataFactory.create_segment_mock() - mock_db_session.query.return_value.where.return_value.first.return_value = mock_segment - - with ( - patch("services.dataset_service.redis_client.lock", autospec=True) as mock_lock, - patch( - "services.dataset_service.VectorService.create_segments_vector", autospec=True - ) as mock_vector_service, - patch("services.dataset_service.helper.generate_text_hash", autospec=True) as mock_hash, - patch("services.dataset_service.naive_utc_now", autospec=True) as mock_now, - ): - mock_lock.return_value.__enter__ = Mock() - mock_lock.return_value.__exit__ = Mock(return_value=None) - mock_hash.return_value = "hash-123" - mock_now.return_value = "2024-01-01T00:00:00" - - # Act - result = SegmentService.create_segment(args, document, dataset) - - # Assert - assert result == mock_segment - mock_db_session.add.assert_called() - mock_db_session.commit.assert_called() - - def test_create_segment_with_high_quality_indexing(self, mock_db_session, mock_current_user): - """Test creation of segment with high quality indexing technique.""" - # Arrange - document = SegmentTestDataFactory.create_document_mock(word_count=100) - dataset = SegmentTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.HIGH_QUALITY) - args = {"content": "New segment content", "keywords": ["test"]} - - mock_query = MagicMock() - mock_query.where.return_value.scalar.return_value = None - mock_db_session.query.return_value = mock_query - - mock_embedding_model = MagicMock() - mock_embedding_model.get_text_embedding_num_tokens.return_value = [10] - mock_model_manager = MagicMock() - mock_model_manager.get_model_instance.return_value = mock_embedding_model - - mock_segment = SegmentTestDataFactory.create_segment_mock() - mock_db_session.query.return_value.where.return_value.first.return_value = mock_segment - - with ( - patch("services.dataset_service.redis_client.lock", autospec=True) as mock_lock, - patch( - "services.dataset_service.VectorService.create_segments_vector", autospec=True - ) as mock_vector_service, - patch("services.dataset_service.ModelManager.for_tenant", autospec=True) as mock_model_manager_class, - patch("services.dataset_service.helper.generate_text_hash", autospec=True) as mock_hash, - patch("services.dataset_service.naive_utc_now", autospec=True) as mock_now, - ): - mock_lock.return_value.__enter__ = Mock() - mock_lock.return_value.__exit__ = Mock(return_value=None) - mock_model_manager_class.return_value = mock_model_manager - mock_hash.return_value = "hash-123" - mock_now.return_value = "2024-01-01T00:00:00" - - # Act - result = SegmentService.create_segment(args, document, dataset) - - # Assert - assert result == mock_segment - mock_model_manager.get_model_instance.assert_called_once() - mock_embedding_model.get_text_embedding_num_tokens.assert_called_once() - - def test_create_segment_vector_index_failure(self, mock_db_session, mock_current_user): - """Test segment creation when vector indexing fails.""" - # Arrange - document = SegmentTestDataFactory.create_document_mock(word_count=100) - dataset = SegmentTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.ECONOMY) - args = {"content": "New segment content", "keywords": ["test"]} - - mock_query = MagicMock() - mock_query.where.return_value.scalar.return_value = None - mock_db_session.query.return_value = mock_query - - mock_segment = SegmentTestDataFactory.create_segment_mock(enabled=False, status="error") - mock_db_session.query.return_value.where.return_value.first.return_value = mock_segment - - with ( - patch("services.dataset_service.redis_client.lock", autospec=True) as mock_lock, - patch( - "services.dataset_service.VectorService.create_segments_vector", autospec=True - ) as mock_vector_service, - patch("services.dataset_service.helper.generate_text_hash", autospec=True) as mock_hash, - patch("services.dataset_service.naive_utc_now", autospec=True) as mock_now, - ): - mock_lock.return_value.__enter__ = Mock() - mock_lock.return_value.__exit__ = Mock(return_value=None) - mock_vector_service.side_effect = Exception("Vector indexing failed") - mock_hash.return_value = "hash-123" - mock_now.return_value = "2024-01-01T00:00:00" - - # Act - result = SegmentService.create_segment(args, document, dataset) - - # Assert - assert result == mock_segment - assert mock_db_session.commit.call_count == 2 # Once for creation, once for error update - - -class TestSegmentServiceUpdateSegment: - """Tests for SegmentService.update_segment method.""" - - @pytest.fixture - def mock_db_session(self): - """Mock database session.""" - with patch("services.dataset_service.db.session", autospec=True) as mock_db: - yield mock_db - - @pytest.fixture - def mock_current_user(self): - """Mock current_user.""" - user = SegmentTestDataFactory.create_user_mock() - with patch("services.dataset_service.current_user", user): - yield user - - def test_update_segment_content_success(self, mock_db_session, mock_current_user): - """Test successful update of segment content.""" - # Arrange - segment = SegmentTestDataFactory.create_segment_mock(enabled=True, word_count=10) - document = SegmentTestDataFactory.create_document_mock(word_count=100) - dataset = SegmentTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.ECONOMY) - args = SegmentUpdateArgs(content="Updated content", keywords=["updated"]) - - mock_db_session.query.return_value.where.return_value.first.return_value = segment - - with ( - patch("services.dataset_service.redis_client.get", autospec=True) as mock_redis_get, - patch("services.dataset_service.VectorService.update_segment_vector", autospec=True) as mock_vector_service, - patch("services.dataset_service.helper.generate_text_hash", autospec=True) as mock_hash, - patch("services.dataset_service.naive_utc_now", autospec=True) as mock_now, - ): - mock_redis_get.return_value = None # Not indexing - mock_hash.return_value = "new-hash" - mock_now.return_value = "2024-01-01T00:00:00" - - # Act - result = SegmentService.update_segment(args, segment, document, dataset) - - # Assert - assert result == segment - assert segment.content == "Updated content" - assert segment.keywords == ["updated"] - assert segment.word_count == len("Updated content") - assert document.word_count == 100 + (len("Updated content") - 10) - mock_db_session.add.assert_called() - mock_db_session.commit.assert_called() - - def test_update_segment_disable(self, mock_db_session, mock_current_user): - """Test disabling a segment.""" - # Arrange - segment = SegmentTestDataFactory.create_segment_mock(enabled=True) - document = SegmentTestDataFactory.create_document_mock() - dataset = SegmentTestDataFactory.create_dataset_mock() - args = SegmentUpdateArgs(enabled=False) - - with ( - patch("services.dataset_service.redis_client.get", autospec=True) as mock_redis_get, - patch("services.dataset_service.redis_client.setex", autospec=True) as mock_redis_setex, - patch("services.dataset_service.disable_segment_from_index_task", autospec=True) as mock_task, - patch("services.dataset_service.naive_utc_now", autospec=True) as mock_now, - ): - mock_redis_get.return_value = None - mock_now.return_value = "2024-01-01T00:00:00" - - # Act - result = SegmentService.update_segment(args, segment, document, dataset) - - # Assert - assert result == segment - assert segment.enabled is False - mock_db_session.add.assert_called() - mock_db_session.commit.assert_called() - mock_task.delay.assert_called_once() - - def test_update_segment_indexing_in_progress(self, mock_db_session, mock_current_user): - """Test update fails when segment is currently indexing.""" - # Arrange - segment = SegmentTestDataFactory.create_segment_mock(enabled=True) - document = SegmentTestDataFactory.create_document_mock() - dataset = SegmentTestDataFactory.create_dataset_mock() - args = SegmentUpdateArgs(content="Updated content") - - with patch("services.dataset_service.redis_client.get", autospec=True) as mock_redis_get: - mock_redis_get.return_value = "1" # Indexing in progress - - # Act & Assert - with pytest.raises(ValueError, match="Segment is indexing"): - SegmentService.update_segment(args, segment, document, dataset) - - def test_update_segment_disabled_segment(self, mock_db_session, mock_current_user): - """Test update fails when segment is disabled.""" - # Arrange - segment = SegmentTestDataFactory.create_segment_mock(enabled=False) - document = SegmentTestDataFactory.create_document_mock() - dataset = SegmentTestDataFactory.create_dataset_mock() - args = SegmentUpdateArgs(content="Updated content") - - with patch("services.dataset_service.redis_client.get", autospec=True) as mock_redis_get: - mock_redis_get.return_value = None - - # Act & Assert - with pytest.raises(ValueError, match="Can't update disabled segment"): - SegmentService.update_segment(args, segment, document, dataset) - - def test_update_segment_with_qa_model(self, mock_db_session, mock_current_user): - """Test update segment with QA model (includes answer).""" - # Arrange - segment = SegmentTestDataFactory.create_segment_mock(enabled=True, word_count=10) - document = SegmentTestDataFactory.create_document_mock(doc_form=IndexStructureType.QA_INDEX, word_count=100) - dataset = SegmentTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.ECONOMY) - args = SegmentUpdateArgs(content="Updated question", answer="Updated answer", keywords=["qa"]) - - mock_db_session.query.return_value.where.return_value.first.return_value = segment - - with ( - patch("services.dataset_service.redis_client.get", autospec=True) as mock_redis_get, - patch("services.dataset_service.VectorService.update_segment_vector", autospec=True) as mock_vector_service, - patch("services.dataset_service.helper.generate_text_hash", autospec=True) as mock_hash, - patch("services.dataset_service.naive_utc_now", autospec=True) as mock_now, - ): - mock_redis_get.return_value = None - mock_hash.return_value = "new-hash" - mock_now.return_value = "2024-01-01T00:00:00" - - # Act - result = SegmentService.update_segment(args, segment, document, dataset) - - # Assert - assert result == segment - assert segment.content == "Updated question" - assert segment.answer == "Updated answer" - assert segment.keywords == ["qa"] - new_word_count = len("Updated question") + len("Updated answer") - assert segment.word_count == new_word_count - assert document.word_count == 100 + (new_word_count - 10) - mock_db_session.commit.assert_called() - - -class TestSegmentServiceDeleteSegment: - """Tests for SegmentService.delete_segment method.""" - - @pytest.fixture - def mock_db_session(self): - """Mock database session.""" - with patch("services.dataset_service.db.session", autospec=True) as mock_db: - yield mock_db - - def test_delete_segment_success(self, mock_db_session): - """Test successful deletion of a segment.""" - # Arrange - segment = SegmentTestDataFactory.create_segment_mock(enabled=True, word_count=50) - document = SegmentTestDataFactory.create_document_mock(word_count=100) - dataset = SegmentTestDataFactory.create_dataset_mock() - - mock_scalars = MagicMock() - mock_scalars.all.return_value = [] - mock_db_session.scalars.return_value = mock_scalars - - with ( - patch("services.dataset_service.redis_client.get", autospec=True) as mock_redis_get, - patch("services.dataset_service.redis_client.setex", autospec=True) as mock_redis_setex, - patch("services.dataset_service.delete_segment_from_index_task", autospec=True) as mock_task, - patch("services.dataset_service.select", autospec=True) as mock_select, - ): - mock_redis_get.return_value = None - mock_select.return_value.where.return_value = mock_select - - # Act - SegmentService.delete_segment(segment, document, dataset) - - # Assert - mock_db_session.delete.assert_called_once_with(segment) - mock_db_session.commit.assert_called_once() - mock_task.delay.assert_called_once() - - def test_delete_segment_disabled(self, mock_db_session): - """Test deletion of disabled segment (no index deletion).""" - # Arrange - segment = SegmentTestDataFactory.create_segment_mock(enabled=False, word_count=50) - document = SegmentTestDataFactory.create_document_mock(word_count=100) - dataset = SegmentTestDataFactory.create_dataset_mock() - - with ( - patch("services.dataset_service.redis_client.get", autospec=True) as mock_redis_get, - patch("services.dataset_service.delete_segment_from_index_task", autospec=True) as mock_task, - ): - mock_redis_get.return_value = None - - # Act - SegmentService.delete_segment(segment, document, dataset) - - # Assert - mock_db_session.delete.assert_called_once_with(segment) - mock_db_session.commit.assert_called_once() - mock_task.delay.assert_not_called() - - def test_delete_segment_indexing_in_progress(self, mock_db_session): - """Test deletion fails when segment is currently being deleted.""" - # Arrange - segment = SegmentTestDataFactory.create_segment_mock(enabled=True) - document = SegmentTestDataFactory.create_document_mock() - dataset = SegmentTestDataFactory.create_dataset_mock() - - with patch("services.dataset_service.redis_client.get", autospec=True) as mock_redis_get: - mock_redis_get.return_value = "1" # Deletion in progress - - # Act & Assert - with pytest.raises(ValueError, match="Segment is deleting"): - SegmentService.delete_segment(segment, document, dataset) - - -class TestSegmentServiceDeleteSegments: - """Tests for SegmentService.delete_segments method.""" - - @pytest.fixture - def mock_db_session(self): - """Mock database session.""" - with patch("services.dataset_service.db.session", autospec=True) as mock_db: - yield mock_db - - @pytest.fixture - def mock_current_user(self): - """Mock current_user.""" - user = SegmentTestDataFactory.create_user_mock() - with patch("services.dataset_service.current_user", user): - yield user - - def test_delete_segments_success(self, mock_db_session, mock_current_user): - """Test successful deletion of multiple segments.""" - # Arrange - segment_ids = ["segment-1", "segment-2"] - document = SegmentTestDataFactory.create_document_mock(word_count=200) - dataset = SegmentTestDataFactory.create_dataset_mock() - - segments_info = [ - ("node-1", "segment-1", 50), - ("node-2", "segment-2", 30), - ] - - mock_query = MagicMock() - mock_query.with_entities.return_value.where.return_value.all.return_value = segments_info - mock_db_session.query.return_value = mock_query - - mock_scalars = MagicMock() - mock_scalars.all.return_value = [] - mock_select = MagicMock() - mock_select.where.return_value = mock_select - mock_db_session.scalars.return_value = mock_scalars - - with ( - patch("services.dataset_service.delete_segment_from_index_task", autospec=True) as mock_task, - patch("services.dataset_service.select", autospec=True) as mock_select_func, - ): - mock_select_func.return_value = mock_select - - # Act - SegmentService.delete_segments(segment_ids, document, dataset) - - # Assert - mock_db_session.query.return_value.where.return_value.delete.assert_called_once() - mock_db_session.commit.assert_called_once() - mock_task.delay.assert_called_once() - - def test_delete_segments_empty_list(self, mock_db_session, mock_current_user): - """Test deletion with empty list (should return early).""" - # Arrange - document = SegmentTestDataFactory.create_document_mock() - dataset = SegmentTestDataFactory.create_dataset_mock() - - # Act - SegmentService.delete_segments([], document, dataset) - - # Assert - mock_db_session.query.assert_not_called() - - -class TestSegmentServiceUpdateSegmentsStatus: - """Tests for SegmentService.update_segments_status method.""" - - @pytest.fixture - def mock_db_session(self): - """Mock database session.""" - with patch("services.dataset_service.db.session", autospec=True) as mock_db: - yield mock_db - - @pytest.fixture - def mock_current_user(self): - """Mock current_user.""" - user = SegmentTestDataFactory.create_user_mock() - with patch("services.dataset_service.current_user", user): - yield user - - def test_update_segments_status_enable(self, mock_db_session, mock_current_user): - """Test enabling multiple segments.""" - # Arrange - segment_ids = ["segment-1", "segment-2"] - document = SegmentTestDataFactory.create_document_mock() - dataset = SegmentTestDataFactory.create_dataset_mock() - - segments = [ - SegmentTestDataFactory.create_segment_mock(segment_id="segment-1", enabled=False), - SegmentTestDataFactory.create_segment_mock(segment_id="segment-2", enabled=False), - ] - - mock_scalars = MagicMock() - mock_scalars.all.return_value = segments - mock_select = MagicMock() - mock_select.where.return_value = mock_select - mock_db_session.scalars.return_value = mock_scalars - - with ( - patch("services.dataset_service.redis_client.get", autospec=True) as mock_redis_get, - patch("services.dataset_service.enable_segments_to_index_task", autospec=True) as mock_task, - patch("services.dataset_service.select", autospec=True) as mock_select_func, - ): - mock_redis_get.return_value = None - mock_select_func.return_value = mock_select - - # Act - SegmentService.update_segments_status(segment_ids, "enable", dataset, document) - - # Assert - assert all(seg.enabled is True for seg in segments) - mock_db_session.commit.assert_called_once() - mock_task.delay.assert_called_once() - - def test_update_segments_status_disable(self, mock_db_session, mock_current_user): - """Test disabling multiple segments.""" - # Arrange - segment_ids = ["segment-1", "segment-2"] - document = SegmentTestDataFactory.create_document_mock() - dataset = SegmentTestDataFactory.create_dataset_mock() - - segments = [ - SegmentTestDataFactory.create_segment_mock(segment_id="segment-1", enabled=True), - SegmentTestDataFactory.create_segment_mock(segment_id="segment-2", enabled=True), - ] - - mock_scalars = MagicMock() - mock_scalars.all.return_value = segments - mock_select = MagicMock() - mock_select.where.return_value = mock_select - mock_db_session.scalars.return_value = mock_scalars - - with ( - patch("services.dataset_service.redis_client.get", autospec=True) as mock_redis_get, - patch("services.dataset_service.disable_segments_from_index_task", autospec=True) as mock_task, - patch("services.dataset_service.naive_utc_now", autospec=True) as mock_now, - patch("services.dataset_service.select", autospec=True) as mock_select_func, - ): - mock_redis_get.return_value = None - mock_now.return_value = "2024-01-01T00:00:00" - mock_select_func.return_value = mock_select - - # Act - SegmentService.update_segments_status(segment_ids, "disable", dataset, document) - - # Assert - assert all(seg.enabled is False for seg in segments) - mock_db_session.commit.assert_called_once() - mock_task.delay.assert_called_once() - - def test_update_segments_status_empty_list(self, mock_db_session, mock_current_user): - """Test update with empty list (should return early).""" - # Arrange - document = SegmentTestDataFactory.create_document_mock() - dataset = SegmentTestDataFactory.create_dataset_mock() - - # Act - SegmentService.update_segments_status([], "enable", dataset, document) - - # Assert - mock_db_session.scalars.assert_not_called() - - -class TestSegmentServiceGetSegments: - """Tests for SegmentService.get_segments method.""" - - @pytest.fixture - def mock_db_session(self): - """Mock database session.""" - with patch("services.dataset_service.db.session", autospec=True) as mock_db: - yield mock_db - - @pytest.fixture - def mock_current_user(self): - """Mock current_user.""" - user = SegmentTestDataFactory.create_user_mock() - with patch("services.dataset_service.current_user", user): - yield user - - def test_get_segments_success(self, mock_db_session, mock_current_user): - """Test successful retrieval of segments.""" - # Arrange - document_id = "doc-123" - tenant_id = "tenant-123" - segments = [ - SegmentTestDataFactory.create_segment_mock(segment_id="segment-1"), - SegmentTestDataFactory.create_segment_mock(segment_id="segment-2"), - ] - - mock_paginate = MagicMock() - mock_paginate.items = segments - mock_paginate.total = 2 - mock_db_session.paginate.return_value = mock_paginate - - # Act - items, total = SegmentService.get_segments(document_id, tenant_id) - - # Assert - assert len(items) == 2 - assert total == 2 - mock_db_session.paginate.assert_called_once() - - def test_get_segments_with_status_filter(self, mock_db_session, mock_current_user): - """Test retrieval with status filter.""" - # Arrange - document_id = "doc-123" - tenant_id = "tenant-123" - status_list = ["completed", "error"] - - mock_paginate = MagicMock() - mock_paginate.items = [] - mock_paginate.total = 0 - mock_db_session.paginate.return_value = mock_paginate - - # Act - items, total = SegmentService.get_segments(document_id, tenant_id, status_list=status_list) - - # Assert - assert len(items) == 0 - assert total == 0 - - def test_get_segments_with_keyword(self, mock_db_session, mock_current_user): - """Test retrieval with keyword search.""" - # Arrange - document_id = "doc-123" - tenant_id = "tenant-123" - keyword = "test" - - mock_paginate = MagicMock() - mock_paginate.items = [SegmentTestDataFactory.create_segment_mock()] - mock_paginate.total = 1 - mock_db_session.paginate.return_value = mock_paginate - - # Act - items, total = SegmentService.get_segments(document_id, tenant_id, keyword=keyword) - - # Assert - assert len(items) == 1 - assert total == 1 - - -class TestSegmentServiceGetSegmentById: - """Tests for SegmentService.get_segment_by_id method.""" - - @pytest.fixture - def mock_db_session(self): - """Mock database session.""" - with patch("services.dataset_service.db.session", autospec=True) as mock_db: - yield mock_db - - def test_get_segment_by_id_success(self, mock_db_session): - """Test successful retrieval of segment by ID.""" - # Arrange - segment_id = "segment-123" - tenant_id = "tenant-123" - segment = SegmentTestDataFactory.create_segment_mock(segment_id=segment_id) - - mock_query = MagicMock() - mock_query.where.return_value.first.return_value = segment - mock_db_session.query.return_value = mock_query - - # Act - result = SegmentService.get_segment_by_id(segment_id, tenant_id) - - # Assert - assert result == segment - - def test_get_segment_by_id_not_found(self, mock_db_session): - """Test retrieval when segment is not found.""" - # Arrange - segment_id = "non-existent" - tenant_id = "tenant-123" - - mock_query = MagicMock() - mock_query.where.return_value.first.return_value = None - mock_db_session.query.return_value = mock_query - - # Act - result = SegmentService.get_segment_by_id(segment_id, tenant_id) - - # Assert - assert result is None - - -class TestSegmentServiceGetChildChunks: - """Tests for SegmentService.get_child_chunks method.""" - - @pytest.fixture - def mock_db_session(self): - """Mock database session.""" - with patch("services.dataset_service.db.session", autospec=True) as mock_db: - yield mock_db - - @pytest.fixture - def mock_current_user(self): - """Mock current_user.""" - user = SegmentTestDataFactory.create_user_mock() - with patch("services.dataset_service.current_user", user): - yield user - - def test_get_child_chunks_success(self, mock_db_session, mock_current_user): - """Test successful retrieval of child chunks.""" - # Arrange - segment_id = "segment-123" - document_id = "doc-123" - dataset_id = "dataset-123" - page = 1 - limit = 20 - - mock_paginate = MagicMock() - mock_paginate.items = [ - SegmentTestDataFactory.create_child_chunk_mock(chunk_id="chunk-1"), - SegmentTestDataFactory.create_child_chunk_mock(chunk_id="chunk-2"), - ] - mock_paginate.total = 2 - mock_db_session.paginate.return_value = mock_paginate - - # Act - result = SegmentService.get_child_chunks(segment_id, document_id, dataset_id, page, limit) - - # Assert - assert result == mock_paginate - mock_db_session.paginate.assert_called_once() - - def test_get_child_chunks_with_keyword(self, mock_db_session, mock_current_user): - """Test retrieval with keyword search.""" - # Arrange - segment_id = "segment-123" - document_id = "doc-123" - dataset_id = "dataset-123" - page = 1 - limit = 20 - keyword = "test" - - mock_paginate = MagicMock() - mock_paginate.items = [] - mock_paginate.total = 0 - mock_db_session.paginate.return_value = mock_paginate - - # Act - result = SegmentService.get_child_chunks(segment_id, document_id, dataset_id, page, limit, keyword=keyword) - - # Assert - assert result == mock_paginate - - -class TestSegmentServiceGetChildChunkById: - """Tests for SegmentService.get_child_chunk_by_id method.""" - - @pytest.fixture - def mock_db_session(self): - """Mock database session.""" - with patch("services.dataset_service.db.session", autospec=True) as mock_db: - yield mock_db - - def test_get_child_chunk_by_id_success(self, mock_db_session): - """Test successful retrieval of child chunk by ID.""" - # Arrange - chunk_id = "chunk-123" - tenant_id = "tenant-123" - chunk = SegmentTestDataFactory.create_child_chunk_mock(chunk_id=chunk_id) - - mock_query = MagicMock() - mock_query.where.return_value.first.return_value = chunk - mock_db_session.query.return_value = mock_query - - # Act - result = SegmentService.get_child_chunk_by_id(chunk_id, tenant_id) - - # Assert - assert result == chunk - - def test_get_child_chunk_by_id_not_found(self, mock_db_session): - """Test retrieval when child chunk is not found.""" - # Arrange - chunk_id = "non-existent" - tenant_id = "tenant-123" - - mock_query = MagicMock() - mock_query.where.return_value.first.return_value = None - mock_db_session.query.return_value = mock_query - - # Act - result = SegmentService.get_child_chunk_by_id(chunk_id, tenant_id) - - # Assert - assert result is None - - -class TestSegmentServiceCreateChildChunk: - """Tests for SegmentService.create_child_chunk method.""" - - @pytest.fixture - def mock_db_session(self): - """Mock database session.""" - with patch("services.dataset_service.db.session", autospec=True) as mock_db: - yield mock_db - - @pytest.fixture - def mock_current_user(self): - """Mock current_user.""" - user = SegmentTestDataFactory.create_user_mock() - with patch("services.dataset_service.current_user", user): - yield user - - def test_create_child_chunk_success(self, mock_db_session, mock_current_user): - """Test successful creation of a child chunk.""" - # Arrange - content = "New child chunk content" - segment = SegmentTestDataFactory.create_segment_mock() - document = SegmentTestDataFactory.create_document_mock() - dataset = SegmentTestDataFactory.create_dataset_mock() - - mock_query = MagicMock() - mock_query.where.return_value.scalar.return_value = None - mock_db_session.query.return_value = mock_query - - with ( - patch("services.dataset_service.redis_client.lock", autospec=True) as mock_lock, - patch( - "services.dataset_service.VectorService.create_child_chunk_vector", autospec=True - ) as mock_vector_service, - patch("services.dataset_service.helper.generate_text_hash", autospec=True) as mock_hash, - ): - mock_lock.return_value.__enter__ = Mock() - mock_lock.return_value.__exit__ = Mock(return_value=None) - mock_hash.return_value = "hash-123" - - # Act - result = SegmentService.create_child_chunk(content, segment, document, dataset) - - # Assert - assert result is not None - mock_db_session.add.assert_called_once() - mock_db_session.commit.assert_called_once() - mock_vector_service.assert_called_once() - - def test_create_child_chunk_vector_index_failure(self, mock_db_session, mock_current_user): - """Test child chunk creation when vector indexing fails.""" - # Arrange - content = "New child chunk content" - segment = SegmentTestDataFactory.create_segment_mock() - document = SegmentTestDataFactory.create_document_mock() - dataset = SegmentTestDataFactory.create_dataset_mock() - - mock_query = MagicMock() - mock_query.where.return_value.scalar.return_value = None - mock_db_session.query.return_value = mock_query - - with ( - patch("services.dataset_service.redis_client.lock", autospec=True) as mock_lock, - patch( - "services.dataset_service.VectorService.create_child_chunk_vector", autospec=True - ) as mock_vector_service, - patch("services.dataset_service.helper.generate_text_hash", autospec=True) as mock_hash, - ): - mock_lock.return_value.__enter__ = Mock() - mock_lock.return_value.__exit__ = Mock(return_value=None) - mock_vector_service.side_effect = Exception("Vector indexing failed") - mock_hash.return_value = "hash-123" - - # Act & Assert - with pytest.raises(ChildChunkIndexingError): - SegmentService.create_child_chunk(content, segment, document, dataset) - - mock_db_session.rollback.assert_called_once() - - -class TestSegmentServiceUpdateChildChunk: - """Tests for SegmentService.update_child_chunk method.""" - - @pytest.fixture - def mock_db_session(self): - """Mock database session.""" - with patch("services.dataset_service.db.session", autospec=True) as mock_db: - yield mock_db - - @pytest.fixture - def mock_current_user(self): - """Mock current_user.""" - user = SegmentTestDataFactory.create_user_mock() - with patch("services.dataset_service.current_user", user): - yield user - - def test_update_child_chunk_success(self, mock_db_session, mock_current_user): - """Test successful update of a child chunk.""" - # Arrange - content = "Updated child chunk content" - chunk = SegmentTestDataFactory.create_child_chunk_mock() - segment = SegmentTestDataFactory.create_segment_mock() - document = SegmentTestDataFactory.create_document_mock() - dataset = SegmentTestDataFactory.create_dataset_mock() - - with ( - patch( - "services.dataset_service.VectorService.update_child_chunk_vector", autospec=True - ) as mock_vector_service, - patch("services.dataset_service.naive_utc_now", autospec=True) as mock_now, - ): - mock_now.return_value = "2024-01-01T00:00:00" - - # Act - result = SegmentService.update_child_chunk(content, chunk, segment, document, dataset) - - # Assert - assert result == chunk - assert chunk.content == content - assert chunk.word_count == len(content) - mock_db_session.add.assert_called_once_with(chunk) - mock_db_session.commit.assert_called_once() - mock_vector_service.assert_called_once() - - def test_update_child_chunk_vector_index_failure(self, mock_db_session, mock_current_user): - """Test child chunk update when vector indexing fails.""" - # Arrange - content = "Updated content" - chunk = SegmentTestDataFactory.create_child_chunk_mock() - segment = SegmentTestDataFactory.create_segment_mock() - document = SegmentTestDataFactory.create_document_mock() - dataset = SegmentTestDataFactory.create_dataset_mock() - - with ( - patch( - "services.dataset_service.VectorService.update_child_chunk_vector", autospec=True - ) as mock_vector_service, - patch("services.dataset_service.naive_utc_now", autospec=True) as mock_now, - ): - mock_vector_service.side_effect = Exception("Vector indexing failed") - mock_now.return_value = "2024-01-01T00:00:00" - - # Act & Assert - with pytest.raises(ChildChunkIndexingError): - SegmentService.update_child_chunk(content, chunk, segment, document, dataset) - - mock_db_session.rollback.assert_called_once() - - -class TestSegmentServiceDeleteChildChunk: - """Tests for SegmentService.delete_child_chunk method.""" - - @pytest.fixture - def mock_db_session(self): - """Mock database session.""" - with patch("services.dataset_service.db.session", autospec=True) as mock_db: - yield mock_db - - def test_delete_child_chunk_success(self, mock_db_session): - """Test successful deletion of a child chunk.""" - # Arrange - chunk = SegmentTestDataFactory.create_child_chunk_mock() - dataset = SegmentTestDataFactory.create_dataset_mock() - - with patch( - "services.dataset_service.VectorService.delete_child_chunk_vector", autospec=True - ) as mock_vector_service: - # Act - SegmentService.delete_child_chunk(chunk, dataset) - - # Assert - mock_db_session.delete.assert_called_once_with(chunk) - mock_db_session.commit.assert_called_once() - mock_vector_service.assert_called_once_with(chunk, dataset) - - def test_delete_child_chunk_vector_index_failure(self, mock_db_session): - """Test child chunk deletion when vector indexing fails.""" - # Arrange - chunk = SegmentTestDataFactory.create_child_chunk_mock() - dataset = SegmentTestDataFactory.create_dataset_mock() - - with patch( - "services.dataset_service.VectorService.delete_child_chunk_vector", autospec=True - ) as mock_vector_service: - mock_vector_service.side_effect = Exception("Vector deletion failed") - - # Act & Assert - with pytest.raises(ChildChunkDeleteIndexError): - SegmentService.delete_child_chunk(chunk, dataset) - - mock_db_session.rollback.assert_called_once() diff --git a/api/tests/unit_tests/services/services_test_help.py b/api/tests/unit_tests/services/services_test_help.py deleted file mode 100644 index c6b962f7fc..0000000000 --- a/api/tests/unit_tests/services/services_test_help.py +++ /dev/null @@ -1,59 +0,0 @@ -from unittest.mock import MagicMock - - -class ServiceDbTestHelper: - """ - Helper class for service database query tests. - """ - - @staticmethod - def setup_db_query_filter_by_mock(mock_db, query_results): - """ - Smart database query mock that responds based on model type and query parameters. - - Args: - mock_db: Mock database session - query_results: Dict mapping (model_name, filter_key, filter_value) to return value - Example: {('Account', 'email', 'test@example.com'): mock_account} - """ - - def query_side_effect(model): - mock_query = MagicMock() - - def filter_by_side_effect(**kwargs): - mock_filter_result = MagicMock() - - def first_side_effect(): - # Find matching result based on model and filter parameters - for (model_name, filter_key, filter_value), result in query_results.items(): - if model.__name__ == model_name and filter_key in kwargs and kwargs[filter_key] == filter_value: - return result - return None - - mock_filter_result.first.side_effect = first_side_effect - - # Handle order_by calls for complex queries - def order_by_side_effect(*args, **kwargs): - mock_order_result = MagicMock() - - def order_first_side_effect(): - # Look for order_by results in the same query_results dict - for (model_name, filter_key, filter_value), result in query_results.items(): - if ( - model.__name__ == model_name - and filter_key == "order_by" - and filter_value == "first_available" - ): - return result - return None - - mock_order_result.first.side_effect = order_first_side_effect - return mock_order_result - - mock_filter_result.order_by.side_effect = order_by_side_effect - return mock_filter_result - - mock_query.filter_by.side_effect = filter_by_side_effect - return mock_query - - mock_db.session.query.side_effect = query_side_effect diff --git a/api/tests/unit_tests/services/test_account_service.py b/api/tests/unit_tests/services/test_account_service.py index c4f5f57153..e9d2f1481e 100644 --- a/api/tests/unit_tests/services/test_account_service.py +++ b/api/tests/unit_tests/services/test_account_service.py @@ -14,7 +14,6 @@ from services.errors.account import ( AccountRegisterError, CurrentPasswordIncorrectError, ) -from tests.unit_tests.services.services_test_help import ServiceDbTestHelper class TestAccountAssociatedDataFactory: @@ -149,7 +148,6 @@ class TestAccountService: # Setup basic session methods mock_session.add = MagicMock() mock_session.commit = MagicMock() - mock_session.query = MagicMock() yield mock_db @@ -1572,15 +1570,9 @@ class TestRegisterService: account_id="existing-user-456", email="existing@example.com", status="active" ) - # Mock database queries - query_results = { - ( - "TenantAccountJoin", - "tenant_id", - "tenant-456", - ): TestAccountAssociatedDataFactory.create_tenant_join_mock(), - } - ServiceDbTestHelper.setup_db_query_filter_by_mock(mock_db_dependencies["db"], query_results) + mock_db_dependencies[ + "db" + ].session.scalar.return_value = TestAccountAssociatedDataFactory.create_tenant_join_mock() # Mock TenantService methods with ( diff --git a/api/tests/unit_tests/services/test_dataset_service_segment.py b/api/tests/unit_tests/services/test_dataset_service_segment.py index d6c104708c..5cfef76719 100644 --- a/api/tests/unit_tests/services/test_dataset_service_segment.py +++ b/api/tests/unit_tests/services/test_dataset_service_segment.py @@ -714,7 +714,6 @@ class TestSegmentServiceMutations: patch("services.dataset_service.db") as mock_db, patch("services.dataset_service.delete_segment_from_index_task") as delete_task, ): - segments_query = MagicMock() # execute().all() for segments_info (multi-column) execute_result = MagicMock() execute_result.all.return_value = [ diff --git a/api/tests/unit_tests/services/test_datasource_provider_service.py b/api/tests/unit_tests/services/test_datasource_provider_service.py index d304e0ec44..c389c4a635 100644 --- a/api/tests/unit_tests/services/test_datasource_provider_service.py +++ b/api/tests/unit_tests/services/test_datasource_provider_service.py @@ -36,9 +36,7 @@ class TestDatasourceProviderService: @pytest.fixture def mock_db_session(self): """ - Robust, chainable query mock. - q returns itself for .filter_by(), .order_by(), .where() so any - SQLAlchemy chaining pattern works without multiple brittle sub-mocks. + Mock session with scalar/scalars defaults for current SQLAlchemy access paths. """ with ( patch("services.datasource_provider_service.Session") as mock_cls, @@ -46,20 +44,6 @@ class TestDatasourceProviderService: ): sess = MagicMock(spec=Session) - q = MagicMock() - sess.query.return_value = q - - # Self-returning chain — any method called on q returns q - q.filter_by.return_value = q - q.order_by.return_value = q - q.where.return_value = q - - # Default terminal values (tests override per-case) - q.first.return_value = None - q.all.return_value = [] - q.count.return_value = 0 - q.delete.return_value = 1 - # Default values for select()-style calls (tests override per-case) sess.scalar.return_value = None sess.scalars.return_value.all.return_value = [] diff --git a/api/tests/unit_tests/services/test_webhook_service_additional.py b/api/tests/unit_tests/services/test_webhook_service_additional.py index 776cb5dc3f..491dd94842 100644 --- a/api/tests/unit_tests/services/test_webhook_service_additional.py +++ b/api/tests/unit_tests/services/test_webhook_service_additional.py @@ -17,23 +17,6 @@ from services.trigger import webhook_service as service_module from services.trigger.webhook_service import WebhookService -class _FakeQuery: - def __init__(self, result: Any) -> None: - self._result = result - - def where(self, *args: Any, **kwargs: Any) -> "_FakeQuery": - return self - - def filter(self, *args: Any, **kwargs: Any) -> "_FakeQuery": - return self - - def order_by(self, *args: Any, **kwargs: Any) -> "_FakeQuery": - return self - - def first(self) -> Any: - return self._result - - @pytest.fixture def flask_app() -> Flask: return Flask(__name__) diff --git a/api/tests/unit_tests/services/test_workflow_service.py b/api/tests/unit_tests/services/test_workflow_service.py index 0015e8b908..feafada59a 100644 --- a/api/tests/unit_tests/services/test_workflow_service.py +++ b/api/tests/unit_tests/services/test_workflow_service.py @@ -1649,8 +1649,6 @@ class TestWorkflowServiceCredentialValidation: """Missing BuiltinToolProvider → plugin requires no credentials → no error.""" # Arrange with patch("services.workflow_service.db") as mock_db: - mock_db.session.query.return_value.where.return_value.order_by.return_value.first.return_value = None - # Act + Assert (should NOT raise) service._check_default_tool_credential("tenant-1", "some-provider") @@ -1662,10 +1660,6 @@ class TestWorkflowServiceCredentialValidation: patch("services.workflow_service.db") as mock_db, patch("core.helper.credential_utils.check_credential_policy_compliance", side_effect=Exception("denied")), ): - mock_db.session.query.return_value.where.return_value.order_by.return_value.first.return_value = ( - mock_provider - ) - # Act + Assert with pytest.raises(ValueError, match="Failed to validate default credential"): service._check_default_tool_credential("tenant-1", "some-provider") diff --git a/api/tests/unit_tests/services/vector_service.py b/api/tests/unit_tests/services/vector_service.py deleted file mode 100644 index ad80beb4e3..0000000000 --- a/api/tests/unit_tests/services/vector_service.py +++ /dev/null @@ -1,1793 +0,0 @@ -""" -Comprehensive unit tests for VectorService and Vector classes. - -This module contains extensive unit tests for the VectorService and Vector -classes, which are critical components in the RAG (Retrieval-Augmented Generation) -pipeline that handle vector database operations, collection management, embedding -storage and retrieval, and metadata filtering. - -The VectorService provides methods for: -- Creating vector embeddings for document segments -- Updating segment vector embeddings -- Generating child chunks for hierarchical indexing -- Managing child chunk vectors (create, update, delete) - -The Vector class provides methods for: -- Vector database operations (create, add, delete, search) -- Collection creation and management with Redis locking -- Embedding storage and retrieval -- Vector index operations (HNSW, L2 distance, etc.) -- Metadata filtering in vector space -- Support for multiple vector database backends - -This test suite ensures: -- Correct vector database operations -- Proper collection creation and management -- Accurate embedding storage and retrieval -- Comprehensive vector search functionality -- Metadata filtering and querying -- Error conditions are handled correctly -- Edge cases are properly validated - -================================================================================ -ARCHITECTURE OVERVIEW -================================================================================ - -The Vector service system is a critical component that bridges document -segments and vector databases, enabling semantic search and retrieval. - -1. VectorService: - - High-level service for managing vector operations on document segments - - Handles both regular segments and hierarchical (parent-child) indexing - - Integrates with IndexProcessor for document transformation - - Manages embedding model instances via ModelManager - -2. Vector Class: - - Wrapper around BaseVector implementations - - Handles embedding generation via ModelManager - - Supports multiple vector database backends (Chroma, Milvus, Qdrant, etc.) - - Manages collection creation with Redis locking for concurrency control - - Provides batch processing for large document sets - -3. BaseVector Abstract Class: - - Defines interface for vector database operations - - Implemented by various vector database backends - - Provides methods for CRUD operations on vectors - - Supports both vector similarity search and full-text search - -4. Collection Management: - - Uses Redis locks to prevent concurrent collection creation - - Caches collection existence status in Redis - - Supports collection deletion with cache invalidation - -5. Embedding Generation: - - Uses ModelManager to get embedding model instances - - Supports cached embeddings for performance - - Handles batch processing for large document sets - - Generates embeddings for both documents and queries - -================================================================================ -TESTING STRATEGY -================================================================================ - -This test suite follows a comprehensive testing strategy that covers: - -1. VectorService Methods: - - create_segments_vector: Regular and hierarchical indexing - - update_segment_vector: Vector and keyword index updates - - generate_child_chunks: Child chunk generation with full doc mode - - create_child_chunk_vector: Child chunk vector creation - - update_child_chunk_vector: Batch child chunk updates - - delete_child_chunk_vector: Child chunk deletion - -2. Vector Class Methods: - - Initialization with dataset and attributes - - Collection creation with Redis locking - - Embedding generation and batch processing - - Vector operations (create, add_texts, delete_by_ids, etc.) - - Search operations (by vector, by full text) - - Metadata filtering and querying - - Duplicate checking logic - - Vector factory selection - -3. Integration Points: - - ModelManager integration for embedding models - - IndexProcessor integration for document transformation - - Redis integration for locking and caching - - Database session management - - Vector database backend abstraction - -4. Error Handling: - - Invalid vector store configuration - - Missing embedding models - - Collection creation failures - - Search operation errors - - Metadata filtering errors - -5. Edge Cases: - - Empty document lists - - Missing metadata fields - - Duplicate document IDs - - Large batch processing - - Concurrent collection creation - -================================================================================ -""" - -from typing import Any -from unittest.mock import Mock, patch - -import pytest - -from core.rag.datasource.vdb.vector_base import BaseVector -from core.rag.datasource.vdb.vector_factory import Vector -from core.rag.datasource.vdb.vector_type import VectorType -from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType -from core.rag.models.document import Document -from models.dataset import ChildChunk, Dataset, DatasetDocument, DatasetProcessRule, DocumentSegment -from services.vector_service import VectorService - -# ============================================================================ -# Test Data Factory -# ============================================================================ - - -class VectorServiceTestDataFactory: - """ - Factory class for creating test data and mock objects for Vector service tests. - - This factory provides static methods to create mock objects for: - - Dataset instances with various configurations - - DocumentSegment instances - - ChildChunk instances - - Document instances (RAG documents) - - Embedding model instances - - Vector processor mocks - - Index processor mocks - - The factory methods help maintain consistency across tests and reduce - code duplication when setting up test scenarios. - """ - - @staticmethod - def create_dataset_mock( - dataset_id: str = "dataset-123", - tenant_id: str = "tenant-123", - doc_form: str = IndexStructureType.PARAGRAPH_INDEX, - indexing_technique: str = IndexTechniqueType.HIGH_QUALITY, - embedding_model_provider: str = "openai", - embedding_model: str = "text-embedding-ada-002", - index_struct_dict: dict[str, Any] | None = None, - **kwargs, - ) -> Mock: - """ - Create a mock Dataset with specified attributes. - - Args: - dataset_id: Unique identifier for the dataset - tenant_id: Tenant identifier - doc_form: Document form type - indexing_technique: Indexing technique (high_quality or economy) - embedding_model_provider: Embedding model provider - embedding_model: Embedding model name - index_struct_dict: Index structure dictionary - **kwargs: Additional attributes to set on the mock - - Returns: - Mock object configured as a Dataset instance - """ - dataset = Mock(spec=Dataset) - - dataset.id = dataset_id - - dataset.tenant_id = tenant_id - - dataset.doc_form = doc_form - - dataset.indexing_technique = indexing_technique - - dataset.embedding_model_provider = embedding_model_provider - - dataset.embedding_model = embedding_model - - dataset.index_struct_dict = index_struct_dict - - for key, value in kwargs.items(): - setattr(dataset, key, value) - - return dataset - - @staticmethod - def create_document_segment_mock( - segment_id: str = "segment-123", - document_id: str = "doc-123", - dataset_id: str = "dataset-123", - content: str = "Test segment content", - index_node_id: str = "node-123", - index_node_hash: str = "hash-123", - **kwargs, - ) -> Mock: - """ - Create a mock DocumentSegment with specified attributes. - - Args: - segment_id: Unique identifier for the segment - document_id: Parent document identifier - dataset_id: Dataset identifier - content: Segment content text - index_node_id: Index node identifier - index_node_hash: Index node hash - **kwargs: Additional attributes to set on the mock - - Returns: - Mock object configured as a DocumentSegment instance - """ - segment = Mock(spec=DocumentSegment) - - segment.id = segment_id - - segment.document_id = document_id - - segment.dataset_id = dataset_id - - segment.content = content - - segment.index_node_id = index_node_id - - segment.index_node_hash = index_node_hash - - for key, value in kwargs.items(): - setattr(segment, key, value) - - return segment - - @staticmethod - def create_child_chunk_mock( - chunk_id: str = "chunk-123", - segment_id: str = "segment-123", - document_id: str = "doc-123", - dataset_id: str = "dataset-123", - tenant_id: str = "tenant-123", - content: str = "Test child chunk content", - index_node_id: str = "node-chunk-123", - index_node_hash: str = "hash-chunk-123", - position: int = 1, - **kwargs, - ) -> Mock: - """ - Create a mock ChildChunk with specified attributes. - - Args: - chunk_id: Unique identifier for the child chunk - segment_id: Parent segment identifier - document_id: Parent document identifier - dataset_id: Dataset identifier - tenant_id: Tenant identifier - content: Child chunk content text - index_node_id: Index node identifier - index_node_hash: Index node hash - position: Position in parent segment - **kwargs: Additional attributes to set on the mock - - Returns: - Mock object configured as a ChildChunk instance - """ - chunk = Mock(spec=ChildChunk) - - chunk.id = chunk_id - - chunk.segment_id = segment_id - - chunk.document_id = document_id - - chunk.dataset_id = dataset_id - - chunk.tenant_id = tenant_id - - chunk.content = content - - chunk.index_node_id = index_node_id - - chunk.index_node_hash = index_node_hash - - chunk.position = position - - for key, value in kwargs.items(): - setattr(chunk, key, value) - - return chunk - - @staticmethod - def create_dataset_document_mock( - document_id: str = "doc-123", - dataset_id: str = "dataset-123", - tenant_id: str = "tenant-123", - dataset_process_rule_id: str = "rule-123", - doc_language: str = "en", - created_by: str = "user-123", - **kwargs, - ) -> Mock: - """ - Create a mock DatasetDocument with specified attributes. - - Args: - document_id: Unique identifier for the document - dataset_id: Dataset identifier - tenant_id: Tenant identifier - dataset_process_rule_id: Process rule identifier - doc_language: Document language - created_by: Creator user ID - **kwargs: Additional attributes to set on the mock - - Returns: - Mock object configured as a DatasetDocument instance - """ - document = Mock(spec=DatasetDocument) - - document.id = document_id - - document.dataset_id = dataset_id - - document.tenant_id = tenant_id - - document.dataset_process_rule_id = dataset_process_rule_id - - document.doc_language = doc_language - - document.created_by = created_by - - for key, value in kwargs.items(): - setattr(document, key, value) - - return document - - @staticmethod - def create_dataset_process_rule_mock( - rule_id: str = "rule-123", - **kwargs, - ) -> Mock: - """ - Create a mock DatasetProcessRule with specified attributes. - - Args: - rule_id: Unique identifier for the process rule - **kwargs: Additional attributes to set on the mock - - Returns: - Mock object configured as a DatasetProcessRule instance - """ - rule = Mock(spec=DatasetProcessRule) - - rule.id = rule_id - - rule.to_dict = Mock(return_value={"rules": {"parent_mode": "chunk"}}) - - for key, value in kwargs.items(): - setattr(rule, key, value) - - return rule - - @staticmethod - def create_rag_document_mock( - page_content: str = "Test document content", - doc_id: str = "doc-123", - doc_hash: str = "hash-123", - document_id: str = "doc-123", - dataset_id: str = "dataset-123", - **kwargs, - ) -> Document: - """ - Create a RAG Document with specified attributes. - - Args: - page_content: Document content text - doc_id: Document identifier in metadata - doc_hash: Document hash in metadata - document_id: Parent document ID in metadata - dataset_id: Dataset ID in metadata - **kwargs: Additional metadata fields - - Returns: - Document instance configured for testing - """ - metadata = { - "doc_id": doc_id, - "doc_hash": doc_hash, - "document_id": document_id, - "dataset_id": dataset_id, - } - - metadata.update(kwargs) - - return Document(page_content=page_content, metadata=metadata) - - @staticmethod - def create_embedding_model_instance_mock() -> Mock: - """ - Create a mock embedding model instance. - - Returns: - Mock object configured as an embedding model instance - """ - model_instance = Mock() - - model_instance.embed_documents = Mock(return_value=[[0.1] * 1536]) - - model_instance.embed_query = Mock(return_value=[0.1] * 1536) - - return model_instance - - @staticmethod - def create_vector_processor_mock() -> Mock: - """ - Create a mock vector processor (BaseVector implementation). - - Returns: - Mock object configured as a BaseVector instance - """ - processor = Mock(spec=BaseVector) - - processor.collection_name = "test_collection" - - processor.create = Mock() - - processor.add_texts = Mock() - - processor.text_exists = Mock(return_value=False) - - processor.delete_by_ids = Mock() - - processor.delete_by_metadata_field = Mock() - - processor.search_by_vector = Mock(return_value=[]) - - processor.search_by_full_text = Mock(return_value=[]) - - processor.delete = Mock() - - return processor - - @staticmethod - def create_index_processor_mock() -> Mock: - """ - Create a mock index processor. - - Returns: - Mock object configured as an index processor instance - """ - processor = Mock() - - processor.load = Mock() - - processor.clean = Mock() - - processor.transform = Mock(return_value=[]) - - return processor - - -# ============================================================================ -# Tests for VectorService -# ============================================================================ - - -class TestVectorService: - """ - Comprehensive unit tests for VectorService class. - - This test class covers all methods of the VectorService class, including - segment vector operations, child chunk operations, and integration with - various components like IndexProcessor and ModelManager. - """ - - # ======================================================================== - # Tests for create_segments_vector - # ======================================================================== - - @patch("services.vector_service.IndexProcessorFactory") - @patch("services.vector_service.db") - def test_create_segments_vector_regular_indexing(self, mock_db, mock_index_processor_factory): - """ - Test create_segments_vector with regular indexing (non-hierarchical). - - This test verifies that segments are correctly converted to RAG documents - and loaded into the index processor for regular indexing scenarios. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock( - doc_form=IndexStructureType.PARAGRAPH_INDEX, indexing_technique=IndexTechniqueType.HIGH_QUALITY - ) - - segment = VectorServiceTestDataFactory.create_document_segment_mock() - - keywords_list = [["keyword1", "keyword2"]] - - mock_index_processor = VectorServiceTestDataFactory.create_index_processor_mock() - - mock_index_processor_factory.return_value.init_index_processor.return_value = mock_index_processor - - # Act - VectorService.create_segments_vector(keywords_list, [segment], dataset, IndexStructureType.PARAGRAPH_INDEX) - - # Assert - mock_index_processor.load.assert_called_once() - - call_args = mock_index_processor.load.call_args - - assert call_args[0][0] == dataset - - assert len(call_args[0][1]) == 1 - - assert call_args[1]["with_keywords"] is True - - assert call_args[1]["keywords_list"] == keywords_list - - @patch("services.vector_service.VectorService.generate_child_chunks") - @patch("services.vector_service.ModelManager.for_tenant") - @patch("services.vector_service.db") - def test_create_segments_vector_parent_child_indexing( - self, mock_db, mock_model_manager, mock_generate_child_chunks - ): - """ - Test create_segments_vector with parent-child indexing. - - This test verifies that for hierarchical indexing, child chunks are - generated instead of regular segment indexing. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock( - doc_form="parent_child_model", indexing_technique=IndexTechniqueType.HIGH_QUALITY - ) - - segment = VectorServiceTestDataFactory.create_document_segment_mock() - - dataset_document = VectorServiceTestDataFactory.create_dataset_document_mock() - - processing_rule = VectorServiceTestDataFactory.create_dataset_process_rule_mock() - - mock_db.session.query.return_value.filter_by.return_value.first.return_value = dataset_document - - mock_db.session.query.return_value.where.return_value.first.return_value = processing_rule - - mock_embedding_model = VectorServiceTestDataFactory.create_embedding_model_instance_mock() - - mock_model_manager.return_value.get_model_instance.return_value = mock_embedding_model - - # Act - VectorService.create_segments_vector(None, [segment], dataset, "parent_child_model") - - # Assert - mock_generate_child_chunks.assert_called_once() - - @patch("services.vector_service.db") - def test_create_segments_vector_missing_document(self, mock_db): - """ - Test create_segments_vector when document is missing. - - This test verifies that when a document is not found, the segment - is skipped with a warning log. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock( - doc_form="parent_child_model", indexing_technique=IndexTechniqueType.HIGH_QUALITY - ) - - segment = VectorServiceTestDataFactory.create_document_segment_mock() - - mock_db.session.query.return_value.filter_by.return_value.first.return_value = None - - # Act - VectorService.create_segments_vector(None, [segment], dataset, "parent_child_model") - - # Assert - # Should not raise an error, just skip the segment - - @patch("services.vector_service.db") - def test_create_segments_vector_missing_processing_rule(self, mock_db): - """ - Test create_segments_vector when processing rule is missing. - - This test verifies that when a processing rule is not found, a - ValueError is raised. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock( - doc_form="parent_child_model", indexing_technique=IndexTechniqueType.HIGH_QUALITY - ) - - segment = VectorServiceTestDataFactory.create_document_segment_mock() - - dataset_document = VectorServiceTestDataFactory.create_dataset_document_mock() - - mock_db.session.query.return_value.filter_by.return_value.first.return_value = dataset_document - - mock_db.session.query.return_value.where.return_value.first.return_value = None - - # Act & Assert - with pytest.raises(ValueError, match="No processing rule found"): - VectorService.create_segments_vector(None, [segment], dataset, "parent_child_model") - - @patch("services.vector_service.db") - def test_create_segments_vector_economy_indexing_technique(self, mock_db): - """ - Test create_segments_vector with economy indexing technique. - - This test verifies that when indexing_technique is not high_quality, - a ValueError is raised for parent-child indexing. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock( - doc_form="parent_child_model", indexing_technique=IndexTechniqueType.ECONOMY - ) - - segment = VectorServiceTestDataFactory.create_document_segment_mock() - - dataset_document = VectorServiceTestDataFactory.create_dataset_document_mock() - - processing_rule = VectorServiceTestDataFactory.create_dataset_process_rule_mock() - - mock_db.session.query.return_value.filter_by.return_value.first.return_value = dataset_document - - mock_db.session.query.return_value.where.return_value.first.return_value = processing_rule - - # Act & Assert - with pytest.raises(ValueError, match="The knowledge base index technique is not high quality"): - VectorService.create_segments_vector(None, [segment], dataset, "parent_child_model") - - @patch("services.vector_service.IndexProcessorFactory") - @patch("services.vector_service.db") - def test_create_segments_vector_empty_documents(self, mock_db, mock_index_processor_factory): - """ - Test create_segments_vector with empty documents list. - - This test verifies that when no documents are created, the index - processor is not called. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - mock_index_processor = VectorServiceTestDataFactory.create_index_processor_mock() - - mock_index_processor_factory.return_value.init_index_processor.return_value = mock_index_processor - - # Act - VectorService.create_segments_vector(None, [], dataset, IndexStructureType.PARAGRAPH_INDEX) - - # Assert - mock_index_processor.load.assert_not_called() - - # ======================================================================== - # Tests for update_segment_vector - # ======================================================================== - - @patch("services.vector_service.Vector") - @patch("services.vector_service.db") - def test_update_segment_vector_high_quality(self, mock_db, mock_vector_class): - """ - Test update_segment_vector with high_quality indexing technique. - - This test verifies that segments are correctly updated in the vector - store when using high_quality indexing. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.HIGH_QUALITY) - - segment = VectorServiceTestDataFactory.create_document_segment_mock() - - mock_vector = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_vector_class.return_value = mock_vector - - # Act - VectorService.update_segment_vector(None, segment, dataset) - - # Assert - mock_vector.delete_by_ids.assert_called_once_with([segment.index_node_id]) - - mock_vector.add_texts.assert_called_once() - - @patch("services.vector_service.Keyword") - @patch("services.vector_service.db") - def test_update_segment_vector_economy_with_keywords(self, mock_db, mock_keyword_class): - """ - Test update_segment_vector with economy indexing and keywords. - - This test verifies that segments are correctly updated in the keyword - index when using economy indexing with keywords. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.ECONOMY) - - segment = VectorServiceTestDataFactory.create_document_segment_mock() - - keywords = ["keyword1", "keyword2"] - - mock_keyword = Mock() - - mock_keyword.delete_by_ids = Mock() - - mock_keyword.add_texts = Mock() - - mock_keyword_class.return_value = mock_keyword - - # Act - VectorService.update_segment_vector(keywords, segment, dataset) - - # Assert - mock_keyword.delete_by_ids.assert_called_once_with([segment.index_node_id]) - - mock_keyword.add_texts.assert_called_once() - - call_args = mock_keyword.add_texts.call_args - - assert call_args[1]["keywords_list"] == [keywords] - - @patch("services.vector_service.Keyword") - @patch("services.vector_service.db") - def test_update_segment_vector_economy_without_keywords(self, mock_db, mock_keyword_class): - """ - Test update_segment_vector with economy indexing without keywords. - - This test verifies that segments are correctly updated in the keyword - index when using economy indexing without keywords. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.ECONOMY) - - segment = VectorServiceTestDataFactory.create_document_segment_mock() - - mock_keyword = Mock() - - mock_keyword.delete_by_ids = Mock() - - mock_keyword.add_texts = Mock() - - mock_keyword_class.return_value = mock_keyword - - # Act - VectorService.update_segment_vector(None, segment, dataset) - - # Assert - mock_keyword.delete_by_ids.assert_called_once_with([segment.index_node_id]) - - mock_keyword.add_texts.assert_called_once() - - call_args = mock_keyword.add_texts.call_args - - assert "keywords_list" not in call_args[1] or call_args[1].get("keywords_list") is None - - # ======================================================================== - # Tests for generate_child_chunks - # ======================================================================== - - @patch("services.vector_service.IndexProcessorFactory") - @patch("services.vector_service.db") - def test_generate_child_chunks_with_children(self, mock_db, mock_index_processor_factory): - """ - Test generate_child_chunks when children are generated. - - This test verifies that child chunks are correctly generated and - saved to the database when the index processor returns children. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - segment = VectorServiceTestDataFactory.create_document_segment_mock() - - dataset_document = VectorServiceTestDataFactory.create_dataset_document_mock() - - processing_rule = VectorServiceTestDataFactory.create_dataset_process_rule_mock() - - embedding_model = VectorServiceTestDataFactory.create_embedding_model_instance_mock() - - child_document = VectorServiceTestDataFactory.create_rag_document_mock( - page_content="Child content", doc_id="child-node-123" - ) - - child_document.children = [child_document] - - mock_index_processor = VectorServiceTestDataFactory.create_index_processor_mock() - - mock_index_processor.transform.return_value = [child_document] - - mock_index_processor_factory.return_value.init_index_processor.return_value = mock_index_processor - - # Act - VectorService.generate_child_chunks(segment, dataset_document, dataset, embedding_model, processing_rule, False) - - # Assert - mock_index_processor.transform.assert_called_once() - - mock_index_processor.load.assert_called_once() - - mock_db.session.add.assert_called() - - mock_db.session.commit.assert_called_once() - - @patch("services.vector_service.IndexProcessorFactory") - @patch("services.vector_service.db") - def test_generate_child_chunks_regenerate(self, mock_db, mock_index_processor_factory): - """ - Test generate_child_chunks with regenerate=True. - - This test verifies that when regenerate is True, existing child chunks - are cleaned before generating new ones. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - segment = VectorServiceTestDataFactory.create_document_segment_mock() - - dataset_document = VectorServiceTestDataFactory.create_dataset_document_mock() - - processing_rule = VectorServiceTestDataFactory.create_dataset_process_rule_mock() - - embedding_model = VectorServiceTestDataFactory.create_embedding_model_instance_mock() - - mock_index_processor = VectorServiceTestDataFactory.create_index_processor_mock() - - mock_index_processor.transform.return_value = [] - - mock_index_processor_factory.return_value.init_index_processor.return_value = mock_index_processor - - # Act - VectorService.generate_child_chunks(segment, dataset_document, dataset, embedding_model, processing_rule, True) - - # Assert - mock_index_processor.clean.assert_called_once() - - call_args = mock_index_processor.clean.call_args - - assert call_args[0][0] == dataset - - assert call_args[0][1] == [segment.index_node_id] - - assert call_args[1]["with_keywords"] is True - - assert call_args[1]["delete_child_chunks"] is True - - @patch("services.vector_service.IndexProcessorFactory") - @patch("services.vector_service.db") - def test_generate_child_chunks_no_children(self, mock_db, mock_index_processor_factory): - """ - Test generate_child_chunks when no children are generated. - - This test verifies that when the index processor returns no children, - no child chunks are saved to the database. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - segment = VectorServiceTestDataFactory.create_document_segment_mock() - - dataset_document = VectorServiceTestDataFactory.create_dataset_document_mock() - - processing_rule = VectorServiceTestDataFactory.create_dataset_process_rule_mock() - - embedding_model = VectorServiceTestDataFactory.create_embedding_model_instance_mock() - - mock_index_processor = VectorServiceTestDataFactory.create_index_processor_mock() - - mock_index_processor.transform.return_value = [] - - mock_index_processor_factory.return_value.init_index_processor.return_value = mock_index_processor - - # Act - VectorService.generate_child_chunks(segment, dataset_document, dataset, embedding_model, processing_rule, False) - - # Assert - mock_index_processor.transform.assert_called_once() - - mock_index_processor.load.assert_not_called() - - mock_db.session.add.assert_not_called() - - # ======================================================================== - # Tests for create_child_chunk_vector - # ======================================================================== - - @patch("services.vector_service.Vector") - @patch("services.vector_service.db") - def test_create_child_chunk_vector_high_quality(self, mock_db, mock_vector_class): - """ - Test create_child_chunk_vector with high_quality indexing. - - This test verifies that child chunk vectors are correctly created - when using high_quality indexing. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.HIGH_QUALITY) - - child_chunk = VectorServiceTestDataFactory.create_child_chunk_mock() - - mock_vector = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_vector_class.return_value = mock_vector - - # Act - VectorService.create_child_chunk_vector(child_chunk, dataset) - - # Assert - mock_vector.add_texts.assert_called_once() - - call_args = mock_vector.add_texts.call_args - - assert call_args[1]["duplicate_check"] is True - - @patch("services.vector_service.Vector") - @patch("services.vector_service.db") - def test_create_child_chunk_vector_economy(self, mock_db, mock_vector_class): - """ - Test create_child_chunk_vector with economy indexing. - - This test verifies that child chunk vectors are not created when - using economy indexing. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.ECONOMY) - - child_chunk = VectorServiceTestDataFactory.create_child_chunk_mock() - - mock_vector = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_vector_class.return_value = mock_vector - - # Act - VectorService.create_child_chunk_vector(child_chunk, dataset) - - # Assert - mock_vector.add_texts.assert_not_called() - - # ======================================================================== - # Tests for update_child_chunk_vector - # ======================================================================== - - @patch("services.vector_service.Vector") - @patch("services.vector_service.db") - def test_update_child_chunk_vector_with_all_operations(self, mock_db, mock_vector_class): - """ - Test update_child_chunk_vector with new, update, and delete operations. - - This test verifies that child chunk vectors are correctly updated - when there are new chunks, updated chunks, and deleted chunks. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.HIGH_QUALITY) - - new_chunk = VectorServiceTestDataFactory.create_child_chunk_mock(chunk_id="new-chunk-1") - - update_chunk = VectorServiceTestDataFactory.create_child_chunk_mock(chunk_id="update-chunk-1") - - delete_chunk = VectorServiceTestDataFactory.create_child_chunk_mock(chunk_id="delete-chunk-1") - - mock_vector = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_vector_class.return_value = mock_vector - - # Act - VectorService.update_child_chunk_vector([new_chunk], [update_chunk], [delete_chunk], dataset) - - # Assert - mock_vector.delete_by_ids.assert_called_once() - - delete_ids = mock_vector.delete_by_ids.call_args[0][0] - - assert update_chunk.index_node_id in delete_ids - - assert delete_chunk.index_node_id in delete_ids - - mock_vector.add_texts.assert_called_once() - - call_args = mock_vector.add_texts.call_args - - assert len(call_args[0][0]) == 2 # new_chunk + update_chunk - - assert call_args[1]["duplicate_check"] is True - - @patch("services.vector_service.Vector") - @patch("services.vector_service.db") - def test_update_child_chunk_vector_only_new(self, mock_db, mock_vector_class): - """ - Test update_child_chunk_vector with only new chunks. - - This test verifies that when only new chunks are provided, only - add_texts is called, not delete_by_ids. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.HIGH_QUALITY) - - new_chunk = VectorServiceTestDataFactory.create_child_chunk_mock() - - mock_vector = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_vector_class.return_value = mock_vector - - # Act - VectorService.update_child_chunk_vector([new_chunk], [], [], dataset) - - # Assert - mock_vector.delete_by_ids.assert_not_called() - - mock_vector.add_texts.assert_called_once() - - @patch("services.vector_service.Vector") - @patch("services.vector_service.db") - def test_update_child_chunk_vector_only_delete(self, mock_db, mock_vector_class): - """ - Test update_child_chunk_vector with only deleted chunks. - - This test verifies that when only deleted chunks are provided, only - delete_by_ids is called, not add_texts. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.HIGH_QUALITY) - - delete_chunk = VectorServiceTestDataFactory.create_child_chunk_mock() - - mock_vector = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_vector_class.return_value = mock_vector - - # Act - VectorService.update_child_chunk_vector([], [], [delete_chunk], dataset) - - # Assert - mock_vector.delete_by_ids.assert_called_once_with([delete_chunk.index_node_id]) - - mock_vector.add_texts.assert_not_called() - - @patch("services.vector_service.Vector") - @patch("services.vector_service.db") - def test_update_child_chunk_vector_economy(self, mock_db, mock_vector_class): - """ - Test update_child_chunk_vector with economy indexing. - - This test verifies that child chunk vectors are not updated when - using economy indexing. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.ECONOMY) - - new_chunk = VectorServiceTestDataFactory.create_child_chunk_mock() - - mock_vector = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_vector_class.return_value = mock_vector - - # Act - VectorService.update_child_chunk_vector([new_chunk], [], [], dataset) - - # Assert - mock_vector.delete_by_ids.assert_not_called() - - mock_vector.add_texts.assert_not_called() - - # ======================================================================== - # Tests for delete_child_chunk_vector - # ======================================================================== - - @patch("services.vector_service.Vector") - @patch("services.vector_service.db") - def test_delete_child_chunk_vector_high_quality(self, mock_db, mock_vector_class): - """ - Test delete_child_chunk_vector with high_quality indexing. - - This test verifies that child chunk vectors are correctly deleted - when using high_quality indexing. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.HIGH_QUALITY) - - child_chunk = VectorServiceTestDataFactory.create_child_chunk_mock() - - mock_vector = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_vector_class.return_value = mock_vector - - # Act - VectorService.delete_child_chunk_vector(child_chunk, dataset) - - # Assert - mock_vector.delete_by_ids.assert_called_once_with([child_chunk.index_node_id]) - - @patch("services.vector_service.Vector") - @patch("services.vector_service.db") - def test_delete_child_chunk_vector_economy(self, mock_db, mock_vector_class): - """ - Test delete_child_chunk_vector with economy indexing. - - This test verifies that child chunk vectors are not deleted when - using economy indexing. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock(indexing_technique=IndexTechniqueType.ECONOMY) - - child_chunk = VectorServiceTestDataFactory.create_child_chunk_mock() - - mock_vector = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_vector_class.return_value = mock_vector - - # Act - VectorService.delete_child_chunk_vector(child_chunk, dataset) - - # Assert - mock_vector.delete_by_ids.assert_not_called() - - -# ============================================================================ -# Tests for Vector Class -# ============================================================================ - - -class TestVector: - """ - Comprehensive unit tests for Vector class. - - This test class covers all methods of the Vector class, including - initialization, collection management, embedding operations, vector - database operations, and search functionality. - """ - - # ======================================================================== - # Tests for Vector Initialization - # ======================================================================== - - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - @patch("core.rag.datasource.vdb.vector_factory.Vector._get_embeddings") - def test_vector_initialization_default_attributes(self, mock_get_embeddings, mock_init_vector): - """ - Test Vector initialization with default attributes. - - This test verifies that Vector is correctly initialized with default - attributes when none are provided. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - mock_embeddings = Mock() - - mock_get_embeddings.return_value = mock_embeddings - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_init_vector.return_value = mock_vector_processor - - # Act - vector = Vector(dataset=dataset) - - # Assert - assert vector._dataset == dataset - - assert vector._attributes == ["doc_id", "dataset_id", "document_id", "doc_hash"] - - mock_get_embeddings.assert_called_once() - - mock_init_vector.assert_called_once() - - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - @patch("core.rag.datasource.vdb.vector_factory.Vector._get_embeddings") - def test_vector_initialization_custom_attributes(self, mock_get_embeddings, mock_init_vector): - """ - Test Vector initialization with custom attributes. - - This test verifies that Vector is correctly initialized with custom - attributes when provided. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - custom_attributes = ["custom_attr1", "custom_attr2"] - - mock_embeddings = Mock() - - mock_get_embeddings.return_value = mock_embeddings - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_init_vector.return_value = mock_vector_processor - - # Act - vector = Vector(dataset=dataset, attributes=custom_attributes) - - # Assert - assert vector._dataset == dataset - - assert vector._attributes == custom_attributes - - # ======================================================================== - # Tests for Vector.create - # ======================================================================== - - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - @patch("core.rag.datasource.vdb.vector_factory.Vector._get_embeddings") - def test_vector_create_with_texts(self, mock_get_embeddings, mock_init_vector): - """ - Test Vector.create with texts list. - - This test verifies that documents are correctly embedded and created - in the vector store with batch processing. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - documents = [ - VectorServiceTestDataFactory.create_rag_document_mock(page_content=f"Content {i}") for i in range(5) - ] - - mock_embeddings = Mock() - - mock_embeddings.embed_documents = Mock(return_value=[[0.1] * 1536] * 5) - - mock_get_embeddings.return_value = mock_embeddings - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_init_vector.return_value = mock_vector_processor - - vector = Vector(dataset=dataset) - - # Act - vector.create(texts=documents) - - # Assert - mock_embeddings.embed_documents.assert_called() - - mock_vector_processor.create.assert_called() - - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - @patch("core.rag.datasource.vdb.vector_factory.Vector._get_embeddings") - def test_vector_create_empty_texts(self, mock_get_embeddings, mock_init_vector): - """ - Test Vector.create with empty texts list. - - This test verifies that when texts is None or empty, no operations - are performed. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - mock_embeddings = Mock() - - mock_get_embeddings.return_value = mock_embeddings - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_init_vector.return_value = mock_vector_processor - - vector = Vector(dataset=dataset) - - # Act - vector.create(texts=None) - - # Assert - mock_embeddings.embed_documents.assert_not_called() - - mock_vector_processor.create.assert_not_called() - - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - @patch("core.rag.datasource.vdb.vector_factory.Vector._get_embeddings") - def test_vector_create_large_batch(self, mock_get_embeddings, mock_init_vector): - """ - Test Vector.create with large batch of documents. - - This test verifies that large batches are correctly processed in - chunks of 1000 documents. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - documents = [ - VectorServiceTestDataFactory.create_rag_document_mock(page_content=f"Content {i}") for i in range(2500) - ] - - mock_embeddings = Mock() - - mock_embeddings.embed_documents = Mock(return_value=[[0.1] * 1536] * 1000) - - mock_get_embeddings.return_value = mock_embeddings - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_init_vector.return_value = mock_vector_processor - - vector = Vector(dataset=dataset) - - # Act - vector.create(texts=documents) - - # Assert - # Should be called 3 times (1000, 1000, 500) - assert mock_embeddings.embed_documents.call_count == 3 - - assert mock_vector_processor.create.call_count == 3 - - # ======================================================================== - # Tests for Vector.add_texts - # ======================================================================== - - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - @patch("core.rag.datasource.vdb.vector_factory.Vector._get_embeddings") - def test_vector_add_texts_without_duplicate_check(self, mock_get_embeddings, mock_init_vector): - """ - Test Vector.add_texts without duplicate check. - - This test verifies that documents are added without checking for - duplicates when duplicate_check is False. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - documents = [VectorServiceTestDataFactory.create_rag_document_mock()] - - mock_embeddings = Mock() - - mock_embeddings.embed_documents = Mock(return_value=[[0.1] * 1536]) - - mock_get_embeddings.return_value = mock_embeddings - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_init_vector.return_value = mock_vector_processor - - vector = Vector(dataset=dataset) - - # Act - vector.add_texts(documents, duplicate_check=False) - - # Assert - mock_embeddings.embed_documents.assert_called_once() - - mock_vector_processor.create.assert_called_once() - - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - @patch("core.rag.datasource.vdb.vector_factory.Vector._get_embeddings") - def test_vector_add_texts_with_duplicate_check(self, mock_get_embeddings, mock_init_vector): - """ - Test Vector.add_texts with duplicate check. - - This test verifies that duplicate documents are filtered out when - duplicate_check is True. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - documents = [VectorServiceTestDataFactory.create_rag_document_mock(doc_id="doc-123")] - - mock_embeddings = Mock() - - mock_embeddings.embed_documents = Mock(return_value=[[0.1] * 1536]) - - mock_get_embeddings.return_value = mock_embeddings - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_vector_processor.text_exists = Mock(return_value=True) # Document exists - - mock_init_vector.return_value = mock_vector_processor - - vector = Vector(dataset=dataset) - - # Act - vector.add_texts(documents, duplicate_check=True) - - # Assert - mock_vector_processor.text_exists.assert_called_once_with("doc-123") - - mock_embeddings.embed_documents.assert_not_called() - - mock_vector_processor.create.assert_not_called() - - # ======================================================================== - # Tests for Vector.text_exists - # ======================================================================== - - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - @patch("core.rag.datasource.vdb.vector_factory.Vector._get_embeddings") - def test_vector_text_exists_true(self, mock_get_embeddings, mock_init_vector): - """ - Test Vector.text_exists when text exists. - - This test verifies that text_exists correctly returns True when - a document exists in the vector store. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - mock_embeddings = Mock() - - mock_get_embeddings.return_value = mock_embeddings - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_vector_processor.text_exists = Mock(return_value=True) - - mock_init_vector.return_value = mock_vector_processor - - vector = Vector(dataset=dataset) - - # Act - result = vector.text_exists("doc-123") - - # Assert - assert result is True - - mock_vector_processor.text_exists.assert_called_once_with("doc-123") - - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - @patch("core.rag.datasource.vdb.vector_factory.Vector._get_embeddings") - def test_vector_text_exists_false(self, mock_get_embeddings, mock_init_vector): - """ - Test Vector.text_exists when text does not exist. - - This test verifies that text_exists correctly returns False when - a document does not exist in the vector store. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - mock_embeddings = Mock() - - mock_get_embeddings.return_value = mock_embeddings - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_vector_processor.text_exists = Mock(return_value=False) - - mock_init_vector.return_value = mock_vector_processor - - vector = Vector(dataset=dataset) - - # Act - result = vector.text_exists("doc-123") - - # Assert - assert result is False - - mock_vector_processor.text_exists.assert_called_once_with("doc-123") - - # ======================================================================== - # Tests for Vector.delete_by_ids - # ======================================================================== - - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - @patch("core.rag.datasource.vdb.vector_factory.Vector._get_embeddings") - def test_vector_delete_by_ids(self, mock_get_embeddings, mock_init_vector): - """ - Test Vector.delete_by_ids. - - This test verifies that documents are correctly deleted by their IDs. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - mock_embeddings = Mock() - - mock_get_embeddings.return_value = mock_embeddings - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_init_vector.return_value = mock_vector_processor - - vector = Vector(dataset=dataset) - - ids = ["doc-1", "doc-2", "doc-3"] - - # Act - vector.delete_by_ids(ids) - - # Assert - mock_vector_processor.delete_by_ids.assert_called_once_with(ids) - - # ======================================================================== - # Tests for Vector.delete_by_metadata_field - # ======================================================================== - - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - @patch("core.rag.datasource.vdb.vector_factory.Vector._get_embeddings") - def test_vector_delete_by_metadata_field(self, mock_get_embeddings, mock_init_vector): - """ - Test Vector.delete_by_metadata_field. - - This test verifies that documents are correctly deleted by metadata - field value. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - mock_embeddings = Mock() - - mock_get_embeddings.return_value = mock_embeddings - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_init_vector.return_value = mock_vector_processor - - vector = Vector(dataset=dataset) - - # Act - vector.delete_by_metadata_field("dataset_id", "dataset-123") - - # Assert - mock_vector_processor.delete_by_metadata_field.assert_called_once_with("dataset_id", "dataset-123") - - # ======================================================================== - # Tests for Vector.search_by_vector - # ======================================================================== - - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - @patch("core.rag.datasource.vdb.vector_factory.Vector._get_embeddings") - def test_vector_search_by_vector(self, mock_get_embeddings, mock_init_vector): - """ - Test Vector.search_by_vector. - - This test verifies that vector search correctly embeds the query - and searches the vector store. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - query = "test query" - - query_vector = [0.1] * 1536 - - mock_embeddings = Mock() - - mock_embeddings.embed_query = Mock(return_value=query_vector) - - mock_get_embeddings.return_value = mock_embeddings - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_vector_processor.search_by_vector = Mock(return_value=[]) - - mock_init_vector.return_value = mock_vector_processor - - vector = Vector(dataset=dataset) - - # Act - result = vector.search_by_vector(query) - - # Assert - mock_embeddings.embed_query.assert_called_once_with(query) - - mock_vector_processor.search_by_vector.assert_called_once_with(query_vector) - - assert result == [] - - # ======================================================================== - # Tests for Vector.search_by_full_text - # ======================================================================== - - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - @patch("core.rag.datasource.vdb.vector_factory.Vector._get_embeddings") - def test_vector_search_by_full_text(self, mock_get_embeddings, mock_init_vector): - """ - Test Vector.search_by_full_text. - - This test verifies that full-text search correctly searches the - vector store without embedding the query. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - query = "test query" - - mock_embeddings = Mock() - - mock_get_embeddings.return_value = mock_embeddings - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_vector_processor.search_by_full_text = Mock(return_value=[]) - - mock_init_vector.return_value = mock_vector_processor - - vector = Vector(dataset=dataset) - - # Act - result = vector.search_by_full_text(query) - - # Assert - mock_vector_processor.search_by_full_text.assert_called_once_with(query) - - assert result == [] - - # ======================================================================== - # Tests for Vector.delete - # ======================================================================== - - @patch("core.rag.datasource.vdb.vector_factory.redis_client") - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - @patch("core.rag.datasource.vdb.vector_factory.Vector._get_embeddings") - def test_vector_delete(self, mock_get_embeddings, mock_init_vector, mock_redis_client): - """ - Test Vector.delete. - - This test verifies that the collection is deleted and Redis cache - is cleared. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - mock_embeddings = Mock() - - mock_get_embeddings.return_value = mock_embeddings - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_vector_processor.collection_name = "test_collection" - - mock_init_vector.return_value = mock_vector_processor - - vector = Vector(dataset=dataset) - - # Act - vector.delete() - - # Assert - mock_vector_processor.delete.assert_called_once() - - mock_redis_client.delete.assert_called_once_with("vector_indexing_test_collection") - - # ======================================================================== - # Tests for Vector.get_vector_factory - # ======================================================================== - - def test_vector_get_vector_factory_chroma(self): - """ - Test Vector.get_vector_factory for Chroma. - - This test verifies that the correct factory class is returned for - Chroma vector type. - """ - # Act - factory_class = Vector.get_vector_factory(VectorType.CHROMA) - - # Assert - assert factory_class is not None - - # Verify it's the correct factory by checking the module name - assert "chroma" in factory_class.__module__.lower() - - def test_vector_get_vector_factory_milvus(self): - """ - Test Vector.get_vector_factory for Milvus. - - This test verifies that the correct factory class is returned for - Milvus vector type. - """ - # Act - factory_class = Vector.get_vector_factory(VectorType.MILVUS) - - # Assert - assert factory_class is not None - - assert "milvus" in factory_class.__module__.lower() - - def test_vector_get_vector_factory_invalid_type(self): - """ - Test Vector.get_vector_factory with invalid vector type. - - This test verifies that a ValueError is raised when an invalid - vector type is provided. - """ - # Act & Assert - with pytest.raises(ValueError, match="Vector store .* is not supported"): - Vector.get_vector_factory("invalid_type") - - # ======================================================================== - # Tests for Vector._filter_duplicate_texts - # ======================================================================== - - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - @patch("core.rag.datasource.vdb.vector_factory.Vector._get_embeddings") - def test_vector_filter_duplicate_texts(self, mock_get_embeddings, mock_init_vector): - """ - Test Vector._filter_duplicate_texts. - - This test verifies that duplicate documents are correctly filtered - based on doc_id in metadata. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - mock_embeddings = Mock() - - mock_get_embeddings.return_value = mock_embeddings - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_vector_processor.text_exists = Mock(side_effect=[True, False]) # First exists, second doesn't - - mock_init_vector.return_value = mock_vector_processor - - vector = Vector(dataset=dataset) - - doc1 = VectorServiceTestDataFactory.create_rag_document_mock(doc_id="doc-1") - - doc2 = VectorServiceTestDataFactory.create_rag_document_mock(doc_id="doc-2") - - documents = [doc1, doc2] - - # Act - filtered = vector._filter_duplicate_texts(documents) - - # Assert - assert len(filtered) == 1 - - assert filtered[0].metadata["doc_id"] == "doc-2" - - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - @patch("core.rag.datasource.vdb.vector_factory.Vector._get_embeddings") - def test_vector_filter_duplicate_texts_no_metadata(self, mock_get_embeddings, mock_init_vector): - """ - Test Vector._filter_duplicate_texts with documents without metadata. - - This test verifies that documents without metadata are not filtered. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock() - - mock_embeddings = Mock() - - mock_get_embeddings.return_value = mock_embeddings - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_init_vector.return_value = mock_vector_processor - - vector = Vector(dataset=dataset) - - doc1 = Document(page_content="Content 1", metadata=None) - - doc2 = Document(page_content="Content 2", metadata={}) - - documents = [doc1, doc2] - - # Act - filtered = vector._filter_duplicate_texts(documents) - - # Assert - assert len(filtered) == 2 - - # ======================================================================== - # Tests for Vector._get_embeddings - # ======================================================================== - - @patch("core.rag.datasource.vdb.vector_factory.CacheEmbedding") - @patch("core.rag.datasource.vdb.vector_factory.ModelManager.for_tenant") - @patch("core.rag.datasource.vdb.vector_factory.Vector._init_vector") - def test_vector_get_embeddings(self, mock_init_vector, mock_model_manager, mock_cache_embedding): - """ - Test Vector._get_embeddings. - - This test verifies that embeddings are correctly retrieved from - ModelManager and wrapped in CacheEmbedding. - """ - # Arrange - dataset = VectorServiceTestDataFactory.create_dataset_mock( - embedding_model_provider="openai", embedding_model="text-embedding-ada-002" - ) - - mock_embedding_model = VectorServiceTestDataFactory.create_embedding_model_instance_mock() - - mock_model_manager.return_value.get_model_instance.return_value = mock_embedding_model - - mock_cache_embedding_instance = Mock() - - mock_cache_embedding.return_value = mock_cache_embedding_instance - - mock_vector_processor = VectorServiceTestDataFactory.create_vector_processor_mock() - - mock_init_vector.return_value = mock_vector_processor - - # Act - vector = Vector(dataset=dataset) - - # Assert - mock_model_manager.return_value.get_model_instance.assert_called_once() - - mock_cache_embedding.assert_called_once_with(mock_embedding_model) - - assert vector._embeddings == mock_cache_embedding_instance diff --git a/api/tests/unit_tests/tasks/test_dataset_indexing_task.py b/api/tests/unit_tests/tasks/test_dataset_indexing_task.py index 5dad58b8f1..b74079bd69 100644 --- a/api/tests/unit_tests/tasks/test_dataset_indexing_task.py +++ b/api/tests/unit_tests/tasks/test_dataset_indexing_task.py @@ -89,9 +89,6 @@ def mock_db_session(): session = MagicMock() session._shared_data = {"dataset": None, "documents": []} - # Keep a pointer so repeated Document.first() calls iterate across provided docs - session._doc_first_idx = 0 - def _get_entity(stmt) -> type | None: """Extract the mapped entity class from a SQLAlchemy select statement.""" try: @@ -1591,18 +1588,7 @@ class TestDocumentIndexingTaskSummaryFlow: need_summary=True, ) - dataset_query = MagicMock() - dataset_query.where.return_value = dataset_query - dataset_query.first.return_value = dataset - phase1_docs = [SimpleNamespace(id="doc-1"), SimpleNamespace(id="doc-2"), SimpleNamespace(id="doc-3")] - phase1_document_query = MagicMock() - phase1_document_query.where.return_value = phase1_document_query - phase1_document_query.all.return_value = phase1_docs - - summary_document_query = MagicMock() - summary_document_query.where.return_value = summary_document_query - summary_document_query.all.return_value = [doc_eligible, doc_skip_form, doc_skip_status] session1 = MagicMock() session2 = MagicMock() @@ -1657,18 +1643,6 @@ class TestDocumentIndexingTaskSummaryFlow: need_summary=True, ) - dataset_query = MagicMock() - dataset_query.where.return_value = dataset_query - dataset_query.first.return_value = dataset - - phase1_query = MagicMock() - phase1_query.where.return_value = phase1_query - phase1_query.all.return_value = [SimpleNamespace(id="doc-1")] - - summary_query = MagicMock() - summary_query.where.return_value = summary_query - summary_query.all.return_value = [doc_eligible] - session1 = MagicMock() session2 = MagicMock() session2.begin.return_value = nullcontext() From 70025121064f01d125d798bb656696209a6b89c2 Mon Sep 17 00:00:00 2001 From: Coding On Star <447357187@qq.com> Date: Fri, 24 Apr 2026 16:43:03 +0800 Subject: [PATCH 013/128] feat: refactor modals to use Dialog component and add tests for ApiKeyModal and ProviderConfigModal (#35550) Co-authored-by: CodingOnStar Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 13 - .../__tests__/provider-config-modal.spec.tsx | 346 ++++++++++++++++++ .../tracing/provider-config-modal.tsx | 41 ++- .../__tests__/add-api-key-button.spec.tsx | 24 +- .../__tests__/api-key-modal.spec.tsx | 283 ++++++++++++-- .../authorize/add-api-key-button.tsx | 10 +- .../plugin-auth/authorize/api-key-modal.tsx | 134 +++++-- .../plugins/plugin-auth/authorized/index.tsx | 37 +- 8 files changed, 769 insertions(+), 119 deletions(-) create mode 100644 web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/provider-config-modal.spec.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 0da7515286..1bff82ac17 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -124,11 +124,6 @@ "count": 1 } }, - "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-panel.tsx": { "ts/no-explicit-any": { "count": 1 @@ -3248,14 +3243,6 @@ "count": 2 } }, - "web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/plugins/plugin-auth/authorize/index.tsx": { "no-restricted-imports": { "count": 1 diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/provider-config-modal.spec.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/provider-config-modal.spec.tsx new file mode 100644 index 0000000000..f9e5ea28ee --- /dev/null +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/__tests__/provider-config-modal.spec.tsx @@ -0,0 +1,346 @@ +import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from '../type' +import { toast } from '@langgenius/dify-ui/toast' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { addTracingConfig, removeTracingConfig, updateTracingConfig } from '@/service/apps' +import ConfigBtn from '../config-button' +import ProviderConfigModal from '../provider-config-modal' +import { TracingProvider } from '../type' + +vi.mock('@/service/apps', () => ({ + addTracingConfig: vi.fn(), + removeTracingConfig: vi.fn(), + updateTracingConfig: vi.fn(), +})) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: vi.fn(), +})) + +type ProviderPayload = AliyunConfig | ArizeConfig | DatabricksConfig | LangFuseConfig | LangSmithConfig | MLflowConfig | OpikConfig | PhoenixConfig | TencentConfig | WeaveConfig + +const validConfigs = { + [TracingProvider.arize]: { + api_key: 'arize-api-key', + space_id: 'space-id', + project: 'arize-project', + endpoint: 'https://otlp.arize.com', + }, + [TracingProvider.phoenix]: { + api_key: 'phoenix-api-key', + project: 'phoenix-project', + endpoint: 'https://app.phoenix.arize.com', + }, + [TracingProvider.langSmith]: { + api_key: 'langsmith-api-key', + project: 'langsmith-project', + endpoint: 'https://api.smith.langchain.com', + }, + [TracingProvider.langfuse]: { + public_key: 'public-key', + secret_key: 'secret-key', + host: 'https://cloud.langfuse.com', + }, + [TracingProvider.opik]: { + api_key: 'opik-api-key', + project: 'opik-project', + workspace: 'default', + url: 'https://www.comet.com/opik/api/', + }, + [TracingProvider.weave]: { + api_key: 'weave-api-key', + entity: 'wandb-entity', + project: 'weave-project', + endpoint: 'https://trace.wandb.ai/', + host: 'https://api.wandb.ai', + }, + [TracingProvider.aliyun]: { + app_name: 'aliyun-app', + license_key: 'license-key', + endpoint: 'https://tracing.arms.aliyuncs.com', + }, + [TracingProvider.mlflow]: { + tracking_uri: 'http://localhost:5000', + experiment_id: 'experiment-id', + username: 'mlflow-user', + password: 'mlflow-password', + }, + [TracingProvider.databricks]: { + experiment_id: 'experiment-id', + host: 'https://workspace.cloud.databricks.com', + client_id: 'client-id', + client_secret: 'client-secret', + personal_access_token: 'personal-access-token', + }, + [TracingProvider.tencent]: { + token: 'tencent-token', + endpoint: 'https://your-region.cls.tencentcs.com', + service_name: 'dify_app', + }, +} satisfies Record + +const providerFieldLabels = [ + [TracingProvider.arize, ['API Key', 'Space ID', 'app.tracing.configProvider.project', 'Endpoint']], + [TracingProvider.phoenix, ['API Key', 'app.tracing.configProvider.project', 'Endpoint']], + [TracingProvider.langSmith, ['API Key', 'app.tracing.configProvider.project', 'Endpoint']], + [TracingProvider.langfuse, ['app.tracing.configProvider.secretKey', 'app.tracing.configProvider.publicKey', 'Host']], + [TracingProvider.opik, ['API Key', 'app.tracing.configProvider.project', 'Workspace', 'Url']], + [TracingProvider.weave, ['API Key', 'app.tracing.configProvider.project', 'Entity', 'Endpoint', 'Host']], + [TracingProvider.aliyun, ['License Key', 'Endpoint', 'App Name']], + [TracingProvider.mlflow, ['app.tracing.configProvider.trackingUri', 'app.tracing.configProvider.experimentId', 'app.tracing.configProvider.username', 'app.tracing.configProvider.password']], + [TracingProvider.databricks, ['app.tracing.configProvider.experimentId', 'app.tracing.configProvider.databricksHost', 'app.tracing.configProvider.clientId', 'app.tracing.configProvider.clientSecret', 'app.tracing.configProvider.personalAccessToken']], + [TracingProvider.tencent, ['Token', 'Endpoint', 'Service Name']], +] as const + +const invalidConfigCases: Array<{ + provider: TracingProvider + payload: ProviderPayload + missingField: string +}> = [ + { provider: TracingProvider.arize, payload: { ...validConfigs[TracingProvider.arize], api_key: '' }, missingField: 'API Key' }, + { provider: TracingProvider.arize, payload: { ...validConfigs[TracingProvider.arize], space_id: '' }, missingField: 'Space ID' }, + { provider: TracingProvider.arize, payload: { ...validConfigs[TracingProvider.arize], project: '' }, missingField: 'app.tracing.configProvider.project' }, + { provider: TracingProvider.phoenix, payload: { ...validConfigs[TracingProvider.phoenix], api_key: '' }, missingField: 'API Key' }, + { provider: TracingProvider.phoenix, payload: { ...validConfigs[TracingProvider.phoenix], project: '' }, missingField: 'app.tracing.configProvider.project' }, + { provider: TracingProvider.langSmith, payload: { ...validConfigs[TracingProvider.langSmith], api_key: '' }, missingField: 'API Key' }, + { provider: TracingProvider.langSmith, payload: { ...validConfigs[TracingProvider.langSmith], project: '' }, missingField: 'app.tracing.configProvider.project' }, + { provider: TracingProvider.langfuse, payload: { ...validConfigs[TracingProvider.langfuse], secret_key: '' }, missingField: 'app.tracing.configProvider.secretKey' }, + { provider: TracingProvider.langfuse, payload: { ...validConfigs[TracingProvider.langfuse], public_key: '' }, missingField: 'app.tracing.configProvider.publicKey' }, + { provider: TracingProvider.langfuse, payload: { ...validConfigs[TracingProvider.langfuse], host: '' }, missingField: 'Host' }, + { provider: TracingProvider.weave, payload: { ...validConfigs[TracingProvider.weave], api_key: '' }, missingField: 'API Key' }, + { provider: TracingProvider.weave, payload: { ...validConfigs[TracingProvider.weave], project: '' }, missingField: 'app.tracing.configProvider.project' }, + { provider: TracingProvider.aliyun, payload: { ...validConfigs[TracingProvider.aliyun], app_name: '' }, missingField: 'App Name' }, + { provider: TracingProvider.aliyun, payload: { ...validConfigs[TracingProvider.aliyun], license_key: '' }, missingField: 'License Key' }, + { provider: TracingProvider.aliyun, payload: { ...validConfigs[TracingProvider.aliyun], endpoint: '' }, missingField: 'Endpoint' }, + { provider: TracingProvider.mlflow, payload: { ...validConfigs[TracingProvider.mlflow], tracking_uri: '' }, missingField: 'Tracking URI' }, + { provider: TracingProvider.databricks, payload: { ...validConfigs[TracingProvider.databricks], experiment_id: '' }, missingField: 'Experiment ID' }, + { provider: TracingProvider.databricks, payload: { ...validConfigs[TracingProvider.databricks], host: '' }, missingField: 'Host' }, + { provider: TracingProvider.tencent, payload: { ...validConfigs[TracingProvider.tencent], token: '' }, missingField: 'Token' }, + { provider: TracingProvider.tencent, payload: { ...validConfigs[TracingProvider.tencent], endpoint: '' }, missingField: 'Endpoint' }, + { provider: TracingProvider.tencent, payload: { ...validConfigs[TracingProvider.tencent], service_name: '' }, missingField: 'Service Name' }, +] + +const renderConfigButton = () => { + return render( + + + , + ) +} + +const renderProviderConfigModal = ({ + type = TracingProvider.langfuse, + payload, +}: { + type?: TracingProvider + payload?: ProviderPayload | null +} = {}) => { + const callbacks = { + onCancel: vi.fn(), + onSaved: vi.fn(), + onChosen: vi.fn(), + onRemoved: vi.fn(), + } + + render( + , + ) + + return callbacks +} + +describe('ProviderConfigModal', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(addTracingConfig).mockResolvedValue({ result: 'success' }) + vi.mocked(updateTracingConfig).mockResolvedValue({ result: 'success' }) + vi.mocked(removeTracingConfig).mockResolvedValue({ result: 'success' }) + }) + + describe('Nested Overlay Behavior', () => { + it('should keep the provider config modal open when clicking inside it', async () => { + const user = userEvent.setup() + renderConfigButton() + + await user.click(screen.getByRole('button', { name: 'Open tracing' })) + await waitFor(() => { + expect(screen.getByText('app.tracing.tracing')).toBeInTheDocument() + }) + + const configActions = screen.getAllByText('app.tracing.config') + expect(configActions.length).toBeGreaterThan(0) + await user.click(configActions[0]!) + await waitFor(() => { + expect(screen.getByText('app.tracing.configProvider.titleapp.tracing.langfuse.title')).toBeInTheDocument() + }) + expect(screen.getByRole('dialog')).toBeInTheDocument() + + await user.click(screen.getByPlaceholderText('https://cloud.langfuse.com')) + + expect(screen.getByText('app.tracing.tracing')).toBeInTheDocument() + expect(screen.getByText('app.tracing.configProvider.titleapp.tracing.langfuse.title')).toBeInTheDocument() + }) + }) + + describe('Rendering', () => { + it.each(providerFieldLabels)('should render %s fields when adding a provider', (provider, expectedLabels) => { + renderProviderConfigModal({ type: provider }) + + expect(screen.getByText(`app.tracing.configProvider.titleapp.tracing.${provider}.title`)).toBeInTheDocument() + expectedLabels.forEach((label) => { + expect(screen.getByText(label)).toBeInTheDocument() + }) + expect(screen.getByRole('button', { name: 'common.operation.saveAndEnable' })).toBeInTheDocument() + }) + }) + + describe('Saving', () => { + it('should add and choose the provider when saving a new config', async () => { + const user = userEvent.setup() + const callbacks = renderProviderConfigModal({ type: TracingProvider.langfuse }) + const textboxes = screen.getAllByRole('textbox') + + await user.type(textboxes[0]!, 'secret-key') + await user.type(textboxes[1]!, 'public-key') + await user.type(textboxes[2]!, 'https://cloud.langfuse.com') + await user.click(screen.getByRole('button', { name: 'common.operation.saveAndEnable' })) + + await waitFor(() => { + expect(addTracingConfig).toHaveBeenCalledWith({ + appId: 'app-id', + body: { + tracing_provider: TracingProvider.langfuse, + tracing_config: validConfigs[TracingProvider.langfuse], + }, + }) + }) + expect(callbacks.onSaved).toHaveBeenCalledWith(validConfigs[TracingProvider.langfuse]) + expect(callbacks.onChosen).toHaveBeenCalledWith(TracingProvider.langfuse) + expect(toast).toHaveBeenCalledWith('common.api.success', { type: 'success' }) + }) + + it.each(Object.values(TracingProvider))('should update valid %s config in edit mode', async (provider) => { + const user = userEvent.setup() + const callbacks = renderProviderConfigModal({ + type: provider, + payload: validConfigs[provider], + }) + + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + await waitFor(() => { + expect(updateTracingConfig).toHaveBeenCalledWith({ + appId: 'app-id', + body: { + tracing_provider: provider, + tracing_config: validConfigs[provider], + }, + }) + }) + expect(callbacks.onSaved).toHaveBeenCalledWith(validConfigs[provider]) + expect(callbacks.onChosen).not.toHaveBeenCalled() + }) + + it.each(invalidConfigCases)('should reject $provider config when $missingField is missing', async ({ provider, payload, missingField }) => { + const user = userEvent.setup() + renderProviderConfigModal({ + type: provider, + payload, + }) + + await user.click(screen.getByRole('button', { name: 'common.operation.save' })) + + expect(updateTracingConfig).not.toHaveBeenCalled() + expect(toast).toHaveBeenCalledWith( + expect.stringContaining(missingField), + { type: 'error' }, + ) + }) + }) + + describe('Closing And Removing', () => { + it('should cancel when the cancel button is clicked', async () => { + const user = userEvent.setup() + const callbacks = renderProviderConfigModal() + + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(callbacks.onCancel).toHaveBeenCalledTimes(1) + }) + + it('should cancel when the dialog is closed with Escape', async () => { + const user = userEvent.setup() + const callbacks = renderProviderConfigModal() + + await user.keyboard('{Escape}') + + await waitFor(() => { + expect(callbacks.onCancel).toHaveBeenCalledTimes(1) + }) + }) + + it('should remove an existing provider after confirmation', async () => { + const user = userEvent.setup() + const callbacks = renderProviderConfigModal({ + type: TracingProvider.langfuse, + payload: validConfigs[TracingProvider.langfuse], + }) + + await user.click(screen.getByRole('button', { name: 'common.operation.remove' })) + expect(screen.getByText('app.tracing.configProvider.removeConfirmTitle:{"key":"app.tracing.langfuse.title"}')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'common.operation.confirm' })) + + await waitFor(() => { + expect(removeTracingConfig).toHaveBeenCalledWith({ + appId: 'app-id', + provider: TracingProvider.langfuse, + }) + }) + expect(callbacks.onRemoved).toHaveBeenCalledTimes(1) + expect(toast).toHaveBeenCalledWith('common.api.remove', { type: 'success' }) + }) + + it('should return to the edit dialog when remove confirmation is canceled', async () => { + const user = userEvent.setup() + renderProviderConfigModal({ + type: TracingProvider.langfuse, + payload: validConfigs[TracingProvider.langfuse], + }) + + await user.click(screen.getByRole('button', { name: 'common.operation.remove' })) + await user.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + expect(removeTracingConfig).not.toHaveBeenCalled() + expect(screen.getByText('app.tracing.configProvider.titleapp.tracing.langfuse.title')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx index 4f2497ad71..734b39bd41 100644 --- a/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx +++ b/web/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/provider-config-modal.tsx @@ -11,6 +11,10 @@ import { AlertDialogTitle, } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' +import { + Dialog, + DialogContent, +} from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { useBoolean } from 'ahooks' import * as React from 'react' @@ -19,10 +23,6 @@ import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general' import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security' -import { - PortalToFollowElem, - PortalToFollowElemContent, -} from '@/app/components/base/portal-to-follow-elem' import { addTracingConfig, removeTracingConfig, updateTracingConfig } from '@/service/apps' import { docURL } from './config' import Field from './field' @@ -153,7 +153,11 @@ const ProviderConfigModal: FC = ({ return weaveConfigTemplate })()) - const [isShowRemoveConfirm, { + const [isConfigDialogOpen, { + set: setIsConfigDialogOpen, + }] = useBoolean(true) + const [isRemoveDialogOpen, { + set: setIsRemoveDialogOpen, setTrue: showRemoveConfirm, setFalse: hideRemoveConfirm, }] = useBoolean(false) @@ -291,13 +295,24 @@ const ProviderConfigModal: FC = ({ } }, [appId, checkValid, config, isAdd, isEdit, isSaving, onChosen, onSaved, t, type]) + // Defer onCancel to onOpenChangeComplete so the dialog's exit animation + // (scale/opacity transition) can finish before the parent unmounts this modal. + const handleConfigDialogOpenChangeComplete = useCallback((open: boolean) => { + if (!open) + onCancel() + }, [onCancel]) + return ( <> - {!isShowRemoveConfirm + {!isRemoveDialogOpen ? ( - - -
+ + +
@@ -650,7 +665,7 @@ const ProviderConfigModal: FC = ({ )} @@ -683,11 +698,11 @@ const ProviderConfigModal: FC = ({
- - + +
) : ( - !open && hideRemoveConfirm()}> +
diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-api-key-button.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-api-key-button.spec.tsx index 794f847168..7caef50516 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/add-api-key-button.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/add-api-key-button.spec.tsx @@ -5,11 +5,29 @@ import AddApiKeyButton from '../add-api-key-button' let _mockModalOpen = false vi.mock('../api-key-modal', () => ({ - default: ({ onClose, onUpdate }: { onClose: () => void, onUpdate?: () => void }) => { - _mockModalOpen = true + default: ({ + open = true, + onClose, + onOpenChange, + onUpdate, + }: { + open?: boolean + onClose: () => void + onOpenChange?: (open: boolean) => void + onUpdate?: () => void + }) => { + _mockModalOpen = open + if (!open) + return null + + const handleClose = () => { + onOpenChange?.(false) + onClose() + } + return (
- +
) diff --git a/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx index 2bfa94d2ed..41f1aa3718 100644 --- a/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/__tests__/api-key-modal.spec.tsx @@ -1,5 +1,8 @@ import type { ApiKeyModalProps } from '../api-key-modal' +import type { FormSchema } from '@/app/components/base/form/types' +import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover' import { fireEvent, render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import * as React from 'react' import { beforeEach, describe, expect, it, vi } from 'vitest' import { AuthCategory } from '../../types' @@ -20,17 +23,27 @@ vi.mock('@langgenius/dify-ui/toast', () => ({ })) const mockAddPluginCredential = vi.fn().mockResolvedValue({}) const mockUpdatePluginCredential = vi.fn().mockResolvedValue({}) -const mockFormValues = { isCheckValidated: true, values: { __name__: 'My Key', api_key: 'sk-123' } } +const defaultCredentialSchemas = [ + { name: 'api_key', label: 'API Key', type: 'secret-input', required: true }, +] +type MockFormValues = { + isCheckValidated: boolean + values: Record +} + +const defaultFormValues: MockFormValues = { isCheckValidated: true, values: { __name__: 'My Key', api_key: 'sk-123' } } +let mockCredentialSchemas = defaultCredentialSchemas +let mockIsSchemaLoading = false +let mockFormValues = defaultFormValues +const mockAuthFormProps = vi.fn() vi.mock('../../hooks/use-credential', () => ({ useAddPluginCredentialHook: () => ({ mutateAsync: mockAddPluginCredential, }), useGetPluginCredentialSchemaHook: () => ({ - data: [ - { name: 'api_key', label: 'API Key', type: 'secret-input', required: true }, - ], - isLoading: false, + data: mockCredentialSchemas, + isLoading: mockIsSchemaLoading, }), useUpdatePluginCredentialHook: () => ({ mutateAsync: mockUpdatePluginCredential, @@ -49,36 +62,19 @@ vi.mock('@/app/components/base/encrypted-bottom', () => ({ EncryptedBottom: () =>
, })) -vi.mock('@/app/components/base/modal/modal', () => ({ - default: ({ children, title, onClose, onConfirm, onExtraButtonClick, showExtraButton, disabled }: { - children: React.ReactNode - title: string - onClose?: () => void - onCancel?: () => void - onConfirm?: () => void - onExtraButtonClick?: () => void - showExtraButton?: boolean - disabled?: boolean - [key: string]: unknown - }) => ( -
-
{title}
- {children} - - - {showExtraButton && } -
- ), -})) - -vi.mock('@/app/components/base/form/form-scenarios/auth', () => ({ - default: React.forwardRef((_props: Record, ref: React.Ref) => { +vi.mock('@/app/components/base/form/form-scenarios/auth', () => { + const MockAuthForm = ({ ref, ...props }: { ref?: React.Ref } & Record) => { + mockAuthFormProps(props) React.useImperativeHandle(ref, () => ({ getFormValues: () => mockFormValues, })) return
- }), -})) + } + + return { + default: MockAuthForm, + } +}) vi.mock('@/app/components/base/form/types', () => ({ FormTypeEnum: { textInput: 'text-input' }, @@ -89,11 +85,73 @@ const basePayload = { provider: 'test-provider', } +const PopoverModalHarness = ({ + ApiKeyModal, + onClose, + onPopoverClose, +}: { + ApiKeyModal: React.FC + onClose: () => void + onPopoverClose: () => void +}) => { + const [open, setOpen] = React.useState(true) + + return ( + { + setOpen(nextOpen) + if (!nextOpen) + onPopoverClose() + }} + > + Credentials} /> + +
+ +
+
+
+ ) +} + +const ControlledModalHarness = ({ + ApiKeyModal, + onClose, +}: { + ApiKeyModal: React.FC + onClose: () => void +}) => { + const [open, setOpen] = React.useState(true) + + return ( + <> +
{String(open)}
+ + + ) +} + describe('ApiKeyModal', () => { let ApiKeyModal: React.FC beforeEach(async () => { vi.clearAllMocks() + mockCredentialSchemas = defaultCredentialSchemas + mockIsSchemaLoading = false + mockFormValues = defaultFormValues + mockAddPluginCredential.mockResolvedValue({}) + mockUpdatePluginCredential.mockResolvedValue({}) const mod = await import('../api-key-modal') ApiKeyModal = mod.default }) @@ -110,6 +168,56 @@ describe('ApiKeyModal', () => { expect(screen.getByTestId('auth-form')).toBeInTheDocument() }) + it('should prefer formSchemas prop and apply schema defaults', () => { + const customSchemas: FormSchema[] = [ + { + name: 'custom_api_key', + label: 'Custom API Key', + type: 'secret-input' as FormSchema['type'], + required: true, + default: 'default-key', + }, + ] + + render() + + expect(mockAuthFormProps).toHaveBeenCalledWith(expect.objectContaining({ + formSchemas: expect.arrayContaining([ + expect.objectContaining({ name: 'custom_api_key' }), + ]), + defaultValues: expect.objectContaining({ + custom_api_key: 'default-key', + }), + })) + }) + + it('should not render auth form when credential schema is empty', () => { + mockCredentialSchemas = [] + + render() + + expect(screen.queryByTestId('auth-form')).not.toBeInTheDocument() + }) + + it('should not submit when form ref is unavailable', () => { + mockCredentialSchemas = [] + + render() + + fireEvent.click(screen.getByTestId('modal-confirm')) + + expect(mockAddPluginCredential).not.toHaveBeenCalled() + }) + + it('should disable actions while loading credential schema', () => { + mockIsSchemaLoading = true + + render() + + expect(screen.queryByTestId('auth-form')).not.toBeInTheDocument() + expect(screen.getByTestId('modal-confirm')).toBeDisabled() + }) + it('should show remove button when editValues is provided', () => { render() @@ -130,6 +238,18 @@ describe('ApiKeyModal', () => { expect(mockOnClose).toHaveBeenCalled() }) + it('should close through controlled open state when cancel is clicked', async () => { + const mockOnClose = vi.fn() + render() + + fireEvent.click(screen.getByRole('button', { name: 'common.operation.cancel' })) + + await waitFor(() => { + expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false') + }) + expect(mockOnClose).toHaveBeenCalled() + }) + it('should call addPluginCredential on confirm in add mode', async () => { const mockOnClose = vi.fn() const mockOnUpdate = vi.fn() @@ -145,6 +265,50 @@ describe('ApiKeyModal', () => { }) }) + it('should use empty credential name when authorization name is blank in add mode', async () => { + mockFormValues = { isCheckValidated: true, values: { api_key: 'sk-123' } } + + render() + + fireEvent.click(screen.getByTestId('modal-confirm')) + + await waitFor(() => { + expect(mockAddPluginCredential).toHaveBeenCalledWith(expect.objectContaining({ + name: '', + })) + }) + }) + + it('should not submit when form validation fails', () => { + mockFormValues = { isCheckValidated: false, values: {} } + + render() + + fireEvent.click(screen.getByTestId('modal-confirm')) + + expect(mockAddPluginCredential).not.toHaveBeenCalled() + expect(mockUpdatePluginCredential).not.toHaveBeenCalled() + }) + + it('should ignore repeated confirm while an action is in progress', async () => { + let repeatedClickTriggered = false + mockAddPluginCredential.mockImplementationOnce(async () => { + if (!repeatedClickTriggered) { + repeatedClickTriggered = true + fireEvent.click(screen.getByTestId('modal-confirm')) + } + return {} + }) + + render() + + fireEvent.click(screen.getByTestId('modal-confirm')) + + await waitFor(() => { + expect(mockAddPluginCredential).toHaveBeenCalledTimes(1) + }) + }) + it('should call updatePluginCredential on confirm in edit mode', async () => { render() @@ -155,6 +319,20 @@ describe('ApiKeyModal', () => { }) }) + it('should use empty credential name when authorization name is blank in edit mode', async () => { + mockFormValues = { isCheckValidated: true, values: { api_key: 'updated', __credential_id__: 'cred-1' } } + + render() + + fireEvent.click(screen.getByTestId('modal-confirm')) + + await waitFor(() => { + expect(mockUpdatePluginCredential).toHaveBeenCalledWith(expect.objectContaining({ + name: '', + })) + }) + }) + it('should call onRemove when remove button clicked', () => { const mockOnRemove = vi.fn() render() @@ -163,6 +341,49 @@ describe('ApiKeyModal', () => { expect(mockOnRemove).toHaveBeenCalled() }) + it('should stay open when clicking inside the modal from a popover', async () => { + // Use userEvent instead of fireEvent to avoid CI flakiness: userEvent + // awaits React act() between pointer/mouse/click so base-ui's dialog + // popup ref is guaranteed committed before outside-click detection runs. + const user = userEvent.setup() + const mockOnClose = vi.fn() + const mockOnPopoverClose = vi.fn() + + render( + , + ) + + const form = await screen.findByTestId('auth-form') + + await user.click(form) + + expect(mockOnClose).not.toHaveBeenCalled() + expect(mockOnPopoverClose).not.toHaveBeenCalled() + expect(screen.getByTestId('modal')).toBeInTheDocument() + }) + + it('should close on backdrop click through controlled open state', async () => { + const mockOnClose = vi.fn() + render() + + const backdrop = document.querySelector('.bg-background-overlay') + if (!backdrop) + throw new Error('Expected dialog backdrop to render') + + fireEvent.pointerDown(backdrop) + fireEvent.mouseDown(backdrop) + fireEvent.click(backdrop) + + await waitFor(() => { + expect(screen.getByTestId('modal-open-state')).toHaveTextContent('false') + }) + expect(mockOnClose).toHaveBeenCalled() + }) + it('should render readme entrance when detail is provided', () => { const payload = { ...basePayload, detail: { name: 'Test' } as never } render() diff --git a/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx b/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx index 648a87dabc..38f3f85643 100644 --- a/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/add-api-key-button.tsx @@ -25,20 +25,26 @@ const AddApiKeyButton = ({ formSchemas = [], }: AddApiKeyButtonProps) => { const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false) + const [isApiKeyModalMounted, setIsApiKeyModalMounted] = useState(false) return ( <> { - isApiKeyModalOpen && ( + isApiKeyModalMounted && ( setIsApiKeyModalOpen(false)} onUpdate={onUpdate} diff --git a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx index db513ecb6f..290621141c 100644 --- a/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx +++ b/web/app/components/plugins/plugin-auth/authorize/api-key-modal.tsx @@ -3,6 +3,8 @@ import type { FormRefObject, FormSchema, } from '@/app/components/base/form/types' +import { Button } from '@langgenius/dify-ui/button' +import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog' import { toast } from '@langgenius/dify-ui/toast' import { memo, @@ -16,7 +18,6 @@ import { EncryptedBottom } from '@/app/components/base/encrypted-bottom' import AuthForm from '@/app/components/base/form/form-scenarios/auth' import { FormTypeEnum } from '@/app/components/base/form/types' import Loading from '@/app/components/base/loading' -import Modal from '@/app/components/base/modal/modal' import { ReadmeEntrance } from '../../readme-panel/entrance' import { ReadmeShowType } from '../../readme-panel/store' import { @@ -28,8 +29,10 @@ import { CredentialTypeEnum } from '../types' export type ApiKeyModalProps = { pluginPayload: PluginPayload + open?: boolean + onOpenChange?: (open: boolean) => void onClose?: () => void - editValues?: Record + editValues?: Record onRemove?: () => void disabled?: boolean onUpdate?: () => void @@ -37,6 +40,8 @@ export type ApiKeyModalProps = { } const ApiKeyModal = ({ pluginPayload, + open = true, + onOpenChange, onClose, editValues, onRemove, @@ -73,7 +78,7 @@ const ApiKeyModal = ({ if (schema.default) acc[schema.name] = schema.default return acc - }, {} as Record) + }, {} as Record) const { mutateAsync: addPluginCredential } = useAddPluginCredentialHook(pluginPayload) const { mutateAsync: updatePluginCredential } = useUpdatePluginCredentialHook(pluginPayload) const formRef = useRef(null) @@ -114,53 +119,102 @@ const ApiKeyModal = ({ } toast.success(t('api.actionSuccess', { ns: 'common' })) + onOpenChange?.(false) onClose?.() onUpdate?.() } finally { handleSetDoingAction(false) } - }, [addPluginCredential, onClose, onUpdate, updatePluginCredential, t, editValues, handleSetDoingAction]) + }, [addPluginCredential, onClose, onOpenChange, onUpdate, updatePluginCredential, t, editValues, handleSetDoingAction]) + + const isDisabled = disabled || isLoading || doingAction + const handleOpenChange = useCallback((nextOpen: boolean) => { + onOpenChange?.(nextOpen) + if (!nextOpen) + onClose?.() + }, [onClose, onOpenChange]) return ( -
) - } - bottomSlot={} - onConfirm={handleConfirm} - showExtraButton={!!editValues} - onExtraButtonClick={onRemove} - disabled={disabled || isLoading || doingAction} - clickOutsideNotClose={true} - wrapperClassName="z-1002!" + - {pluginPayload.detail && ( - - )} - { - isLoading && ( -
- + +
+
+ + {t('auth.useApiAuth', { ns: 'plugin' })} + +
+ {t('auth.useApiAuthDesc', { ns: 'plugin' })} +
+
- ) - } - { - !isLoading && !!mergedData.length && ( - - ) - } - +
+ {pluginPayload.detail && ( + + )} + { + isLoading && ( +
+ +
+ ) + } + { + !isLoading && !!mergedData.length && ( + + ) + } +
+
+
+
+ {editValues && ( + <> + +
+ + )} + + +
+
+
+ +
+
+ +
) } diff --git a/web/app/components/plugins/plugin-auth/authorized/index.tsx b/web/app/components/plugins/plugin-auth/authorized/index.tsx index b8b34e33e0..774821b0c8 100644 --- a/web/app/components/plugins/plugin-auth/authorized/index.tsx +++ b/web/app/components/plugins/plugin-auth/authorized/index.tsx @@ -19,9 +19,6 @@ import { PopoverTrigger, } from '@langgenius/dify-ui/popover' import { toast } from '@langgenius/dify-ui/toast' -import { - RiArrowDownSLine, -} from '@remixicon/react' import { memo, useCallback, @@ -93,19 +90,19 @@ const Authorized = ({ }, [onOpenChange]) const oAuthCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.OAUTH2) const apiKeyCredentials = credentials.filter(credential => credential.credential_type === CredentialTypeEnum.API_KEY) - const pendingOperationCredentialId = useRef(null) + const pendingOperationCredentialIdRef = useRef(null) const [deleteCredentialId, setDeleteCredentialId] = useState(null) const { mutateAsync: deletePluginCredential } = useDeletePluginCredentialHook(pluginPayload) const openConfirm = useCallback((credentialId?: string) => { setMergedIsOpen(false) if (credentialId) - pendingOperationCredentialId.current = credentialId + pendingOperationCredentialIdRef.current = credentialId - setDeleteCredentialId(pendingOperationCredentialId.current) + setDeleteCredentialId(pendingOperationCredentialIdRef.current) }, [setMergedIsOpen]) const closeConfirm = useCallback(() => { setDeleteCredentialId(null) - pendingOperationCredentialId.current = null + pendingOperationCredentialIdRef.current = null }, []) const [doingAction, setDoingAction] = useState(false) const doingActionRef = useRef(doingAction) @@ -116,30 +113,37 @@ const Authorized = ({ const handleConfirm = useCallback(async () => { if (doingActionRef.current) return - if (!pendingOperationCredentialId.current) { + if (!pendingOperationCredentialIdRef.current) { setDeleteCredentialId(null) return } try { handleSetDoingAction(true) - await deletePluginCredential({ credential_id: pendingOperationCredentialId.current }) + await deletePluginCredential({ credential_id: pendingOperationCredentialIdRef.current }) toast.success(t('api.actionSuccess', { ns: 'common' })) onUpdate?.() setDeleteCredentialId(null) - pendingOperationCredentialId.current = null + pendingOperationCredentialIdRef.current = null } finally { handleSetDoingAction(false) } }, [deletePluginCredential, onUpdate, t, handleSetDoingAction]) const [editValues, setEditValues] = useState | null>(null) + const [isApiKeyModalOpen, setIsApiKeyModalOpen] = useState(false) const handleEdit = useCallback((id: string, values: Record) => { setMergedIsOpen(false) - pendingOperationCredentialId.current = id + pendingOperationCredentialIdRef.current = id setEditValues(values) + setIsApiKeyModalOpen(true) }, [setMergedIsOpen]) + const handleApiKeyModalOpenChange = useCallback((open: boolean) => { + setIsApiKeyModalOpen(open) + if (!open) + pendingOperationCredentialIdRef.current = null + }, []) const handleRemove = useCallback(() => { - setDeleteCredentialId(pendingOperationCredentialId.current) + setDeleteCredentialId(pendingOperationCredentialIdRef.current) }, []) const { mutateAsync: setPluginDefaultCredential } = useSetPluginDefaultCredentialHook(pluginPayload) const handleSetDefault = useCallback(async (id: string) => { @@ -213,7 +217,7 @@ const Authorized = ({ ` (${unavailableCredentials.length} ${t('auth.unavailable', { ns: 'plugin' })})` ) } - + ) } @@ -356,12 +360,11 @@ const Authorized = ({ { !!editValues && ( { - setEditValues(null) - pendingOperationCredentialId.current = null - }} + onClose={() => handleApiKeyModalOpenChange(false)} onRemove={handleRemove} disabled={disabled || doingAction} onUpdate={onUpdate} From ce50c6cf1c163951e9716e43e3f45d87c5d017c4 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Fri, 24 Apr 2026 18:07:17 +0900 Subject: [PATCH 014/128] chore: port 2 api (#35542) Co-authored-by: WH-2099 --- api/controllers/console/tag/tags.py | 116 +++++++++++++---- .../controllers/console/tag/test_tags.py | 119 ++++++++++++++++-- 2 files changed, 205 insertions(+), 30 deletions(-) diff --git a/api/controllers/console/tag/tags.py b/api/controllers/console/tag/tags.py index 614bf03ea5..f73e2da54e 100644 --- a/api/controllers/console/tag/tags.py +++ b/api/controllers/console/tag/tags.py @@ -37,6 +37,11 @@ class TagBindingRemovePayload(BaseModel): type: TagType = Field(description="Tag type") +class TagBindingItemDeletePayload(BaseModel): + target_id: str = Field(description="Target ID to unbind tag from") + type: TagType = Field(description="Tag type") + + class TagListQueryParam(BaseModel): type: Literal["knowledge", "app", ""] = Field("", description="Tag type filter") keyword: str | None = Field(None, description="Search keyword") @@ -70,6 +75,7 @@ register_schema_models( TagBasePayload, TagBindingPayload, TagBindingRemovePayload, + TagBindingItemDeletePayload, TagListQueryParam, TagResponse, ) @@ -152,41 +158,107 @@ class TagUpdateDeleteApi(Resource): return "", 204 -@console_ns.route("/tag-bindings/create") -class TagBindingCreateApi(Resource): +def _require_tag_binding_edit_permission() -> None: + """ + Ensure the current account can edit tag bindings. + + Tag binding operations are allowed for users who can edit resources (app/dataset) within the current tenant. + """ + current_user, _ = current_account_with_tenant() + # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator + if not (current_user.has_edit_permission or current_user.is_dataset_editor): + raise Forbidden() + + +def _create_tag_bindings() -> tuple[dict[str, str], int]: + _require_tag_binding_edit_permission() + + payload = TagBindingPayload.model_validate(console_ns.payload or {}) + TagService.save_tag_binding( + TagBindingCreatePayload( + tag_ids=payload.tag_ids, + target_id=payload.target_id, + type=payload.type, + ) + ) + return {"result": "success"}, 200 + + +def _remove_tag_binding() -> tuple[dict[str, str], int]: + _require_tag_binding_edit_permission() + + payload = TagBindingRemovePayload.model_validate(console_ns.payload or {}) + TagService.delete_tag_binding( + TagBindingDeletePayload( + tag_id=payload.tag_id, + target_id=payload.target_id, + type=payload.type, + ) + ) + return {"result": "success"}, 200 + + +@console_ns.route("/tag-bindings") +class TagBindingCollectionApi(Resource): + """Canonical collection resource for tag binding creation.""" + + @console_ns.doc("create_tag_binding") @console_ns.expect(console_ns.models[TagBindingPayload.__name__]) @setup_required @login_required @account_initialization_required def post(self): - current_user, _ = current_account_with_tenant() - # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator - if not (current_user.has_edit_permission or current_user.is_dataset_editor): - raise Forbidden() + return _create_tag_bindings() - payload = TagBindingPayload.model_validate(console_ns.payload or {}) - TagService.save_tag_binding( - TagBindingCreatePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=payload.type) + +@console_ns.route("/tag-bindings/") +class TagBindingItemApi(Resource): + """Canonical item resource for tag binding deletion.""" + + @console_ns.doc("delete_tag_binding") + @console_ns.doc(params={"id": "Tag ID"}) + @console_ns.expect(console_ns.models[TagBindingItemDeletePayload.__name__]) + @setup_required + @login_required + @account_initialization_required + def delete(self, id): + _require_tag_binding_edit_permission() + payload = TagBindingItemDeletePayload.model_validate(console_ns.payload or {}) + TagService.delete_tag_binding( + TagBindingDeletePayload( + tag_id=str(id), + target_id=payload.target_id, + type=payload.type, + ) ) - return {"result": "success"}, 200 +@console_ns.route("/tag-bindings/create") +class DeprecatedTagBindingCreateApi(Resource): + """Deprecated verb-based alias for tag binding creation.""" + + @console_ns.doc("create_tag_binding_deprecated") + @console_ns.doc(deprecated=True) + @console_ns.doc(description="Deprecated legacy alias. Use POST /tag-bindings instead.") + @console_ns.expect(console_ns.models[TagBindingPayload.__name__]) + @setup_required + @login_required + @account_initialization_required + def post(self): + return _create_tag_bindings() + + @console_ns.route("/tag-bindings/remove") -class TagBindingDeleteApi(Resource): +class DeprecatedTagBindingRemoveApi(Resource): + """Deprecated verb-based alias for tag binding deletion.""" + + @console_ns.doc("delete_tag_binding_deprecated") + @console_ns.doc(deprecated=True) + @console_ns.doc(description="Deprecated legacy alias. Use DELETE /tag-bindings/{id} instead.") @console_ns.expect(console_ns.models[TagBindingRemovePayload.__name__]) @setup_required @login_required @account_initialization_required def post(self): - current_user, _ = current_account_with_tenant() - # The role of the current user in the ta table must be admin, owner, editor, or dataset_operator - if not (current_user.has_edit_permission or current_user.is_dataset_editor): - raise Forbidden() - - payload = TagBindingRemovePayload.model_validate(console_ns.payload or {}) - TagService.delete_tag_binding( - TagBindingDeletePayload(tag_id=payload.tag_id, target_id=payload.target_id, type=payload.type) - ) - - return {"result": "success"}, 200 + return _remove_tag_binding() diff --git a/api/tests/unit_tests/controllers/console/tag/test_tags.py b/api/tests/unit_tests/controllers/console/tag/test_tags.py index 2be5a21f28..6405558bb4 100644 --- a/api/tests/unit_tests/controllers/console/tag/test_tags.py +++ b/api/tests/unit_tests/controllers/console/tag/test_tags.py @@ -8,8 +8,10 @@ from werkzeug.exceptions import Forbidden import controllers.console.tag.tags as module from controllers.console import console_ns from controllers.console.tag.tags import ( - TagBindingCreateApi, - TagBindingDeleteApi, + DeprecatedTagBindingCreateApi, + DeprecatedTagBindingRemoveApi, + TagBindingCollectionApi, + TagBindingItemApi, TagListApi, TagUpdateDeleteApi, ) @@ -205,9 +207,9 @@ class TestTagUpdateDeleteApi: assert status == 204 -class TestTagBindingCreateApi: +class TestTagBindingCollectionApi: def test_create_success(self, app, admin_user, payload_patch): - api = TagBindingCreateApi() + api = TagBindingCollectionApi() method = unwrap(api.post) payload = { @@ -232,7 +234,7 @@ class TestTagBindingCreateApi: assert result["result"] == "success" def test_create_forbidden(self, app, readonly_user, payload_patch): - api = TagBindingCreateApi() + api = TagBindingCollectionApi() method = unwrap(api.post) with app.test_request_context("/", json={}): @@ -247,9 +249,78 @@ class TestTagBindingCreateApi: method(api) -class TestTagBindingDeleteApi: +class TestDeprecatedTagBindingCreateApi: + def test_create_success(self, app, admin_user, payload_patch): + api = DeprecatedTagBindingCreateApi() + method = unwrap(api.post) + + payload = { + "tag_ids": ["tag-1"], + "target_id": "target-1", + "type": "knowledge", + } + + with app.test_request_context("/", json=payload): + with ( + patch( + "controllers.console.tag.tags.current_account_with_tenant", + return_value=(admin_user, None), + ), + payload_patch(payload), + patch("controllers.console.tag.tags.TagService.save_tag_binding") as save_mock, + ): + result, status = method(api) + + save_mock.assert_called_once() + assert status == 200 + assert result["result"] == "success" + + +class TestTagBindingItemApi: + def test_delete_success(self, app, admin_user, payload_patch): + api = TagBindingItemApi() + method = unwrap(api.delete) + + payload = { + "target_id": "target-1", + "type": "knowledge", + } + + with app.test_request_context("/", json=payload): + with ( + patch( + "controllers.console.tag.tags.current_account_with_tenant", + return_value=(admin_user, None), + ), + payload_patch(payload), + patch("controllers.console.tag.tags.TagService.delete_tag_binding") as delete_mock, + ): + result, status = method(api, "tag-1") + + delete_mock.assert_called_once() + delete_payload = delete_mock.call_args.args[0] + assert delete_payload.tag_id == "tag-1" + assert delete_payload.target_id == "target-1" + assert delete_payload.type == TagType.KNOWLEDGE + assert status == 200 + assert result["result"] == "success" + + def test_delete_forbidden(self, app, readonly_user): + api = TagBindingItemApi() + method = unwrap(api.delete) + + with app.test_request_context("/"): + with patch( + "controllers.console.tag.tags.current_account_with_tenant", + return_value=(readonly_user, None), + ): + with pytest.raises(Forbidden): + method(api, "tag-1") + + +class TestDeprecatedTagBindingRemoveApi: def test_remove_success(self, app, admin_user, payload_patch): - api = TagBindingDeleteApi() + api = DeprecatedTagBindingRemoveApi() method = unwrap(api.post) payload = { @@ -274,7 +345,7 @@ class TestTagBindingDeleteApi: assert result["result"] == "success" def test_remove_forbidden(self, app, readonly_user, payload_patch): - api = TagBindingDeleteApi() + api = DeprecatedTagBindingRemoveApi() method = unwrap(api.post) with app.test_request_context("/", json={}): @@ -297,3 +368,35 @@ class TestTagResponseModel: assert payload["type"] == "knowledge" assert payload["binding_count"] == "1" + + +class TestTagBindingRouteMetadata: + def test_legacy_write_routes_are_marked_deprecated(self): + assert DeprecatedTagBindingCreateApi.post.__apidoc__["deprecated"] is True + assert DeprecatedTagBindingRemoveApi.post.__apidoc__["deprecated"] is True + assert TagBindingCollectionApi.post.__apidoc__.get("deprecated") is not True + assert TagBindingItemApi.delete.__apidoc__.get("deprecated") is not True + + def test_write_routes_have_stable_operation_ids(self): + assert TagBindingCollectionApi.post.__apidoc__["id"] == "create_tag_binding" + assert TagBindingItemApi.delete.__apidoc__["id"] == "delete_tag_binding" + assert DeprecatedTagBindingCreateApi.post.__apidoc__["id"] == "create_tag_binding_deprecated" + assert DeprecatedTagBindingRemoveApi.post.__apidoc__["id"] == "delete_tag_binding_deprecated" + + def test_canonical_and_legacy_write_routes_are_registered(self): + route_map = { + resource.__name__: urls + for resource, urls, _route_doc, _kwargs in console_ns.resources + if resource.__name__ + in { + "TagBindingCollectionApi", + "TagBindingItemApi", + "DeprecatedTagBindingCreateApi", + "DeprecatedTagBindingRemoveApi", + } + } + + assert route_map["TagBindingCollectionApi"] == ("/tag-bindings",) + assert route_map["TagBindingItemApi"] == ("/tag-bindings/",) + assert route_map["DeprecatedTagBindingCreateApi"] == ("/tag-bindings/create",) + assert route_map["DeprecatedTagBindingRemoveApi"] == ("/tag-bindings/remove",) From e6ef774fd5aac14ec5a1d878df0fe52548bd218a Mon Sep 17 00:00:00 2001 From: Mukunda Rao Katta Date: Fri, 24 Apr 2026 02:59:04 -0700 Subject: [PATCH 015/128] docs: fix Kubernetes deployment wording (#35547) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c87472ace3..778028fc76 100644 --- a/README.md +++ b/README.md @@ -147,7 +147,7 @@ Import the dashboard to Grafana, using Dify's PostgreSQL database as data source ### Deployment with Kubernetes -If you'd like to configure a highly-available setup, there are community-contributed [Helm Charts](https://helm.sh/) and YAML files which allow Dify to be deployed on Kubernetes. +If you'd like to configure a highly available setup, there are community-contributed [Helm Charts](https://helm.sh/) and YAML files which allow Dify to be deployed on Kubernetes. - [Helm Chart by @LeoQuote](https://github.com/douban/charts/tree/master/charts/dify) - [Helm Chart by @BorisPolonsky](https://github.com/BorisPolonsky/dify-helm) From f00512dd5d82023adba4d33ff9f44d926be9f50d Mon Sep 17 00:00:00 2001 From: Jingyi Date: Fri, 24 Apr 2026 21:48:17 -0700 Subject: [PATCH 016/128] test: add P0 workflow run, publish, and share scenarios (#35559) --- e2e/features/apps/share-app.feature | 19 +++++ .../apps/workflow-run-publish.feature | 13 +++ .../step-definitions/apps/share-app.steps.ts | 39 +++++++++ .../apps/workflow-run.steps.ts | 23 ++++++ e2e/features/support/world.ts | 2 + e2e/scripts/run-cucumber.ts | 9 +++ e2e/scripts/setup.ts | 31 ++++++- e2e/support/api.ts | 80 +++++++++++++++++++ 8 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 e2e/features/apps/share-app.feature create mode 100644 e2e/features/apps/workflow-run-publish.feature create mode 100644 e2e/features/step-definitions/apps/share-app.steps.ts create mode 100644 e2e/features/step-definitions/apps/workflow-run.steps.ts diff --git a/e2e/features/apps/share-app.feature b/e2e/features/apps/share-app.feature new file mode 100644 index 0000000000..22f89f7ebb --- /dev/null +++ b/e2e/features/apps/share-app.feature @@ -0,0 +1,19 @@ +@apps @authenticated @core +Feature: Share app publicly + + Scenario: Enable public share for a published workflow app + Given I am signed in as the default E2E admin + And a "workflow" app has been created via API + And a minimal runnable workflow draft has been synced + When I open the app from the app list + And I open the publish panel + And I publish the app + And I navigate to the app overview page + And I enable the Web App share + Then the Web App should be in service + + @unauthenticated + Scenario: Access a shared workflow app without authentication + Given a workflow app has been published and shared via API + When I open the shared app URL + Then the shared app page should be accessible diff --git a/e2e/features/apps/workflow-run-publish.feature b/e2e/features/apps/workflow-run-publish.feature new file mode 100644 index 0000000000..8640a7490b --- /dev/null +++ b/e2e/features/apps/workflow-run-publish.feature @@ -0,0 +1,13 @@ +@apps @authenticated @core @mode-matrix +Feature: Workflow run and publish + + Scenario: Run and publish a minimal workflow app + Given I am signed in as the default E2E admin + And a "workflow" app has been created via API + And a minimal runnable workflow draft has been synced + When I open the app from the app list + And I run the workflow + Then the workflow run should succeed + When I open the publish panel + And I publish the app + Then the app should be marked as published diff --git a/e2e/features/step-definitions/apps/share-app.steps.ts b/e2e/features/step-definitions/apps/share-app.steps.ts new file mode 100644 index 0000000000..24da05baab --- /dev/null +++ b/e2e/features/step-definitions/apps/share-app.steps.ts @@ -0,0 +1,39 @@ +import type { DifyWorld } from '../../support/world' +import { Given, Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import { createTestApp, enableAppSiteAndGetURL, publishWorkflowApp, syncRunnableWorkflowDraft } from '../../../support/api' + +When('I enable the Web App share', async function (this: DifyWorld) { + const page = this.getPage() + const appName = this.lastCreatedAppName + if (!appName) + throw new Error('No app name available. Run "a \\"workflow\\" app has been created via API" first.') + + await page.locator('button').filter({ hasText: appName }).filter({ hasText: 'Workflow' }).click() + await expect(page.getByRole('switch').first()).toBeEnabled({ timeout: 15_000 }) + await page.getByRole('switch').first().click() +}) + +Then('the Web App should be in service', async function (this: DifyWorld) { + await expect(this.getPage().getByText('In Service').first()).toBeVisible({ timeout: 10_000 }) +}) + +Given('a workflow app has been published and shared via API', async function (this: DifyWorld) { + const app = await createTestApp(`E2E Share ${Date.now()}`, 'workflow') + this.createdAppIds.push(app.id) + this.lastCreatedAppName = app.name + await syncRunnableWorkflowDraft(app.id) + await publishWorkflowApp(app.id) + this.shareURL = await enableAppSiteAndGetURL(app.id) +}) + +When('I open the shared app URL', async function (this: DifyWorld) { + if (!this.shareURL) + throw new Error('No share URL available. Run "a workflow app has been published and shared via API" first.') + await this.getPage().goto(this.shareURL, { timeout: 20_000 }) +}) + +Then('the shared app page should be accessible', async function (this: DifyWorld) { + await expect(this.getPage()).toHaveURL(/\/(workflow|chat)\/[a-zA-Z0-9]+/, { timeout: 15_000 }) + await expect(this.getPage().locator('body')).toBeVisible({ timeout: 10_000 }) +}) diff --git a/e2e/features/step-definitions/apps/workflow-run.steps.ts b/e2e/features/step-definitions/apps/workflow-run.steps.ts new file mode 100644 index 0000000000..584a33e774 --- /dev/null +++ b/e2e/features/step-definitions/apps/workflow-run.steps.ts @@ -0,0 +1,23 @@ +import type { DifyWorld } from '../../support/world' +import { Given, Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import { syncRunnableWorkflowDraft } from '../../../support/api' + +Given('a minimal runnable workflow draft has been synced', async function (this: DifyWorld) { + const appId = this.createdAppIds.at(-1) + if (!appId) + throw new Error('No app ID found. Run "a \\"workflow\\" app has been created via API" first.') + await syncRunnableWorkflowDraft(appId) +}) + +When('I run the workflow', async function (this: DifyWorld) { + const page = this.getPage() + await page.getByText('Test Run').click() + await expect(page.getByText('Running').first()).toBeVisible({ timeout: 15_000 }) +}) + +Then('the workflow run should succeed', async function (this: DifyWorld) { + const page = this.getPage() + await page.getByText('DETAIL').click() + await expect(page.getByText('SUCCESS').first()).toBeVisible({ timeout: 55_000 }) +}) diff --git a/e2e/features/support/world.ts b/e2e/features/support/world.ts index 986f79c8f9..b53087171f 100644 --- a/e2e/features/support/world.ts +++ b/e2e/features/support/world.ts @@ -15,6 +15,7 @@ export class DifyWorld extends World { lastCreatedAppName: string | undefined createdAppIds: string[] = [] capturedDownloads: Download[] = [] + shareURL: string | undefined constructor(options: IWorldOptions) { super(options) @@ -27,6 +28,7 @@ export class DifyWorld extends World { this.lastCreatedAppName = undefined this.createdAppIds = [] this.capturedDownloads = [] + this.shareURL = undefined } async startSession(browser: Browser, authenticated: boolean) { diff --git a/e2e/scripts/run-cucumber.ts b/e2e/scripts/run-cucumber.ts index d7778e65e2..3c8e895e90 100644 --- a/e2e/scripts/run-cucumber.ts +++ b/e2e/scripts/run-cucumber.ts @@ -67,11 +67,20 @@ const main = async () => { logFilePath: path.join(logDir, 'cucumber-api.log'), }) + const celeryProcess = await startLoggedProcess({ + command: 'npx', + args: ['tsx', './scripts/setup.ts', 'celery'], + cwd: e2eDir, + label: 'celery worker', + logFilePath: path.join(logDir, 'cucumber-celery.log'), + }) + let cleanupPromise: Promise | undefined const cleanup = async () => { if (!cleanupPromise) { cleanupPromise = (async () => { await stopWebServer() + await stopManagedProcess(celeryProcess) await stopManagedProcess(apiProcess) if (startMiddlewareForRun) { diff --git a/e2e/scripts/setup.ts b/e2e/scripts/setup.ts index ba4c011b04..3f77a3f72a 100644 --- a/e2e/scripts/setup.ts +++ b/e2e/scripts/setup.ts @@ -202,6 +202,32 @@ export const startApi = async () => { }) } +export const startCelery = async () => { + const env = await getApiEnvironment() + + await runForegroundProcess({ + command: 'uv', + args: [ + 'run', + '--project', + '.', + '--no-sync', + 'celery', + '-A', + 'app.celery', + 'worker', + '--pool', + 'solo', + '--loglevel', + 'INFO', + '-Q', + 'workflow_based_app_execution', + ], + cwd: apiDir, + env, + }) +} + export const stopMiddleware = async () => { await runCommandOrThrow({ command: 'docker', @@ -308,7 +334,7 @@ export const startMiddleware = async () => { } const printUsage = () => { - console.log('Usage: tsx ./scripts/setup.ts ') + console.log('Usage: tsx ./scripts/setup.ts ') } const main = async () => { @@ -318,6 +344,9 @@ const main = async () => { case 'api': await startApi() return + case 'celery': + await startCelery() + return case 'middleware-down': await stopMiddleware() return diff --git a/e2e/support/api.ts b/e2e/support/api.ts index 7d9fd0264f..74c42d3e73 100644 --- a/e2e/support/api.ts +++ b/e2e/support/api.ts @@ -80,3 +80,83 @@ export async function deleteTestApp(id: string): Promise { await ctx.dispose() } } + +export async function syncRunnableWorkflowDraft(appId: string): Promise { + const ctx = await createApiContext() + try { + await ctx.post(`/console/api/apps/${appId}/workflows/draft`, { + data: { + graph: { + nodes: [ + { + id: 'start', + type: 'custom', + position: { x: 80, y: 282 }, + data: { id: 'start', type: 'start', title: 'Start', variables: [] }, + }, + { + id: 'end', + type: 'custom', + position: { x: 480, y: 282 }, + data: { + id: 'end', + type: 'end', + title: 'End', + outputs: [{ variable: 'result', value_selector: ['sys', 'workflow_run_id'] }], + }, + }, + ], + edges: [ + { + id: 'start-end', + type: 'custom', + source: 'start', + target: 'end', + sourceHandle: 'source', + targetHandle: 'target', + }, + ], + viewport: { x: 0, y: 0, zoom: 1 }, + }, + features: {}, + environment_variables: [], + conversation_variables: [], + }, + }) + } + finally { + await ctx.dispose() + } +} + +export async function publishWorkflowApp(appId: string): Promise { + const ctx = await createApiContext() + try { + await ctx.post(`/console/api/apps/${appId}/workflows/publish`, { + data: { marked_name: '', marked_comment: '' }, + }) + } + finally { + await ctx.dispose() + } +} + +type AppDetailWithSite = { + site: { access_token: string, app_base_url: string, enable_site: boolean } +} + +export async function enableAppSiteAndGetURL(appId: string): Promise { + const ctx = await createApiContext() + try { + await ctx.post(`/console/api/apps/${appId}/site-enable`, { + data: { enable_site: true }, + }) + const res = await ctx.get(`/console/api/apps/${appId}`) + const body = (await res.json()) as AppDetailWithSite + const { app_base_url, access_token } = body.site + return `${app_base_url}/workflow/${access_token}` + } + finally { + await ctx.dispose() + } +} From 7b5c0b50458ba07739c5ac8bdcf8ee8748c94e76 Mon Sep 17 00:00:00 2001 From: 99 Date: Sun, 26 Apr 2026 04:07:28 +0800 Subject: [PATCH 017/128] fix(api): declare flask dependency (#35568) --- api/pyproject.toml | 1 + api/uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/api/pyproject.toml b/api/pyproject.toml index 31a6ea115c..f8d26a376d 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -9,6 +9,7 @@ dependencies = [ "boto3>=1.42.91", "celery>=5.6.3", "croniter>=6.2.2", + "flask>=3.1.3,<4.0.0", "flask-cors>=6.0.2", "gevent>=26.4.0", "gevent-websocket>=0.10.1", diff --git a/api/uv.lock b/api/uv.lock index 7d6777fa06..1fd71b3a1a 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1299,6 +1299,7 @@ dependencies = [ { name = "celery" }, { name = "croniter" }, { name = "fastopenapi", extra = ["flask"] }, + { name = "flask" }, { name = "flask-compress" }, { name = "flask-cors" }, { name = "flask-login" }, @@ -1581,6 +1582,7 @@ requires-dist = [ { name = "celery", specifier = ">=5.6.3" }, { name = "croniter", specifier = ">=6.2.2" }, { name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" }, + { name = "flask", specifier = ">=3.1.3,<4.0.0" }, { name = "flask-compress", specifier = ">=1.24,<2.0.0" }, { name = "flask-cors", specifier = ">=6.0.2" }, { name = "flask-login", specifier = ">=0.6.3,<1.0.0" }, From ef7ff3356d8e0174d806d6bf0a11b57d1b50499f Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Sun, 26 Apr 2026 09:59:22 +0900 Subject: [PATCH 018/128] refactor: port ChildChunk (#30920) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/core/rag/datasource/retrieval_service.py | 1 + api/core/rag/docstore/dataset_docstore.py | 7 ++- api/models/dataset.py | 49 ++++++++++++------- api/services/dataset_service.py | 6 ++- api/services/vector_service.py | 6 ++- .../services/dataset_service_test_helpers.py | 1 - .../services/test_dataset_service_segment.py | 5 +- 7 files changed, 48 insertions(+), 27 deletions(-) diff --git a/api/core/rag/datasource/retrieval_service.py b/api/core/rag/datasource/retrieval_service.py index 2997710daf..c60d19045a 100644 --- a/api/core/rag/datasource/retrieval_service.py +++ b/api/core/rag/datasource/retrieval_service.py @@ -551,6 +551,7 @@ class RetrievalService: child_index_nodes = session.execute(child_chunk_stmt).scalars().all() for i in child_index_nodes: + assert i.index_node_id segment_ids.append(i.segment_id) if i.segment_id in child_chunk_map: child_chunk_map[i.segment_id].append(i) diff --git a/api/core/rag/docstore/dataset_docstore.py b/api/core/rag/docstore/dataset_docstore.py index f4699f6869..78305a6ac0 100644 --- a/api/core/rag/docstore/dataset_docstore.py +++ b/api/core/rag/docstore/dataset_docstore.py @@ -11,6 +11,7 @@ from core.rag.models.document import AttachmentDocument, Document from extensions.ext_database import db from graphon.model_runtime.entities.model_entities import ModelType from models.dataset import ChildChunk, Dataset, DocumentSegment, SegmentAttachmentBinding +from models.enums import SegmentType class DatasetDocumentStore: @@ -127,6 +128,7 @@ class DatasetDocumentStore: if save_child: if doc.children: for position, child in enumerate(doc.children, start=1): + assert self._document_id child_segment = ChildChunk( tenant_id=self._dataset.tenant_id, dataset_id=self._dataset.id, @@ -137,7 +139,7 @@ class DatasetDocumentStore: index_node_hash=child.metadata.get("doc_hash"), content=child.page_content, word_count=len(child.page_content), - type="automatic", + type=SegmentType.AUTOMATIC, created_by=self._user_id, ) db.session.add(child_segment) @@ -163,6 +165,7 @@ class DatasetDocumentStore: ) # add new child chunks for position, child in enumerate(doc.children, start=1): + assert self._document_id child_segment = ChildChunk( tenant_id=self._dataset.tenant_id, dataset_id=self._dataset.id, @@ -173,7 +176,7 @@ class DatasetDocumentStore: index_node_hash=child.metadata.get("doc_hash"), content=child.page_content, word_count=len(child.page_content), - type="automatic", + type=SegmentType.AUTOMATIC, created_by=self._user_id, ) db.session.add(child_segment) diff --git a/api/models/dataset.py b/api/models/dataset.py index eee5c39a0e..a00e9f7640 100644 --- a/api/models/dataset.py +++ b/api/models/dataset.py @@ -1036,7 +1036,7 @@ class DocumentSegment(Base): return attachment_list -class ChildChunk(Base): +class ChildChunk(TypeBase): __tablename__ = "child_chunks" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="child_chunk_pkey"), @@ -1046,29 +1046,42 @@ class ChildChunk(Base): ) # initial fields - id = mapped_column(StringUUID, nullable=False, default=lambda: str(uuid4())) - tenant_id = mapped_column(StringUUID, nullable=False) - dataset_id = mapped_column(StringUUID, nullable=False) - document_id = mapped_column(StringUUID, nullable=False) - segment_id = mapped_column(StringUUID, nullable=False) + id: Mapped[str] = mapped_column(StringUUID, nullable=False, default_factory=lambda: str(uuid4()), init=False) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + dataset_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + document_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + segment_id: Mapped[str] = mapped_column(StringUUID, nullable=False) position: Mapped[int] = mapped_column(sa.Integer, nullable=False) - content = mapped_column(LongText, nullable=False) + content: Mapped[str] = mapped_column(LongText, nullable=False) word_count: Mapped[int] = mapped_column(sa.Integer, nullable=False) # indexing fields - index_node_id = mapped_column(String(255), nullable=True) - index_node_hash = mapped_column(String(255), nullable=True) - type: Mapped[SegmentType] = mapped_column( - EnumText(SegmentType, length=255), nullable=False, server_default=sa.text("'automatic'") + created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=sa.func.current_timestamp(), init=False ) - created_by = mapped_column(StringUUID, nullable=False) - created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=sa.func.current_timestamp()) - updated_by = mapped_column(StringUUID, nullable=True) + updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True, init=False) updated_at: Mapped[datetime] = mapped_column( - DateTime, nullable=False, server_default=sa.func.current_timestamp(), onupdate=func.current_timestamp() + DateTime, + nullable=False, + server_default=sa.func.current_timestamp(), + onupdate=func.current_timestamp(), + init=False, ) - indexing_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) - error = mapped_column(LongText, nullable=True) + indexing_at: Mapped[datetime | None] = mapped_column( + DateTime, nullable=True, insert_default=None, server_default=None, init=False + ) + completed_at: Mapped[datetime | None] = mapped_column( + DateTime, nullable=True, insert_default=None, server_default=None, init=False + ) + index_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + index_node_hash: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) + type: Mapped[SegmentType] = mapped_column( + EnumText(SegmentType, length=255), + nullable=False, + server_default=sa.text("'automatic'"), + default=SegmentType.AUTOMATIC, + ) + error: Mapped[str | None] = mapped_column(LongText, nullable=True, init=False) @property def dataset(self): diff --git a/api/services/dataset_service.py b/api/services/dataset_service.py index 894cb05687..eef38f1ce2 100644 --- a/api/services/dataset_service.py +++ b/api/services/dataset_service.py @@ -3748,6 +3748,7 @@ class SegmentService: ChildChunk.segment_id == segment.id, ) ) + assert current_user.current_tenant_id child_chunk = ChildChunk( tenant_id=current_user.current_tenant_id, dataset_id=dataset.id, @@ -3758,7 +3759,7 @@ class SegmentService: index_node_hash=index_node_hash, content=content, word_count=len(content), - type="customized", + type=SegmentType.CUSTOMIZED, created_by=current_user.id, ) db.session.add(child_chunk) @@ -3818,6 +3819,7 @@ class SegmentService: if new_child_chunks_args: child_chunk_count = len(child_chunks) for position, args in enumerate(new_child_chunks_args, start=child_chunk_count + 1): + assert current_user.current_tenant_id index_node_id = str(uuid.uuid4()) index_node_hash = helper.generate_text_hash(args.content) child_chunk = ChildChunk( @@ -3830,7 +3832,7 @@ class SegmentService: index_node_hash=index_node_hash, content=args.content, word_count=len(args.content), - type="customized", + type=SegmentType.CUSTOMIZED, created_by=current_user.id, ) diff --git a/api/services/vector_service.py b/api/services/vector_service.py index 58193d75a9..7e689af35d 100644 --- a/api/services/vector_service.py +++ b/api/services/vector_service.py @@ -16,6 +16,7 @@ from graphon.model_runtime.entities.model_entities import ModelType from models import UploadFile from models.dataset import ChildChunk, Dataset, DatasetProcessRule, DocumentSegment, SegmentAttachmentBinding from models.dataset import Document as DatasetDocument +from models.enums import SegmentType logger = logging.getLogger(__name__) @@ -178,7 +179,7 @@ class VectorService: index_node_hash=child_chunk.metadata["doc_hash"], content=child_chunk.page_content, word_count=len(child_chunk.page_content), - type="automatic", + type=SegmentType.AUTOMATIC, created_by=dataset_document.created_by, ) db.session.add(child_segment) @@ -222,6 +223,7 @@ class VectorService: ) documents.append(new_child_document) for update_child_chunk in update_child_chunks: + assert update_child_chunk.index_node_id child_document = Document( page_content=update_child_chunk.content, metadata={ @@ -234,6 +236,7 @@ class VectorService: documents.append(child_document) delete_node_ids.append(update_child_chunk.index_node_id) for delete_child_chunk in delete_child_chunks: + assert delete_child_chunk.index_node_id delete_node_ids.append(delete_child_chunk.index_node_id) if dataset.indexing_technique == IndexTechniqueType.HIGH_QUALITY: # update vector index @@ -246,6 +249,7 @@ class VectorService: @classmethod def delete_child_chunk_vector(cls, child_chunk: ChildChunk, dataset: Dataset): vector = Vector(dataset=dataset) + assert child_chunk.index_node_id vector.delete_by_ids([child_chunk.index_node_id]) @classmethod diff --git a/api/tests/unit_tests/services/dataset_service_test_helpers.py b/api/tests/unit_tests/services/dataset_service_test_helpers.py index 3349c1fd8c..806f1e8d91 100644 --- a/api/tests/unit_tests/services/dataset_service_test_helpers.py +++ b/api/tests/unit_tests/services/dataset_service_test_helpers.py @@ -365,7 +365,6 @@ def _make_segment( def _make_child_chunk() -> ChildChunk: return ChildChunk( - id="child-a", tenant_id="tenant-1", dataset_id="dataset-1", document_id="doc-1", diff --git a/api/tests/unit_tests/services/test_dataset_service_segment.py b/api/tests/unit_tests/services/test_dataset_service_segment.py index 5cfef76719..6330e53765 100644 --- a/api/tests/unit_tests/services/test_dataset_service_segment.py +++ b/api/tests/unit_tests/services/test_dataset_service_segment.py @@ -89,7 +89,6 @@ class TestSegmentServiceChildChunks: document = _make_document() segment = _make_segment() existing_a = ChildChunk( - id="child-a", tenant_id="tenant-1", dataset_id="dataset-1", document_id="doc-1", @@ -100,7 +99,6 @@ class TestSegmentServiceChildChunks: created_by="user-1", ) existing_b = ChildChunk( - id="child-b", tenant_id="tenant-1", dataset_id="dataset-1", document_id="doc-1", @@ -110,7 +108,8 @@ class TestSegmentServiceChildChunks: word_count=9, created_by="user-1", ) - + existing_a.id = "child-a" + existing_b.id = "child-b" with ( patch("services.dataset_service.db") as mock_db, patch("services.dataset_service.uuid.uuid4", return_value="node-new"), From 8b346e69d9712b4e62acf864af4764685f1b1384 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:21:27 +0900 Subject: [PATCH 019/128] chore(deps): bump gitpython from 3.1.45 to 3.1.47 in /api (#35570) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/uv.lock b/api/uv.lock index 1fd71b3a1a..d5d541143a 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -2657,14 +2657,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.45" +version = "3.1.47" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9a/c8/dd58967d119baab745caec2f9d853297cec1989ec1d63f677d3880632b88/gitpython-3.1.45.tar.gz", hash = "sha256:85b0ee964ceddf211c41b9f27a49086010a190fd8132a24e21f362a4b36a791c", size = 215076, upload-time = "2025-07-24T03:45:54.871Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/bd/50db468e9b1310529a19fce651b3b0e753b5c07954d486cba31bbee9a5d5/gitpython-3.1.47.tar.gz", hash = "sha256:dba27f922bd2b42cb54c87a8ab3cb6beb6bf07f3d564e21ac848913a05a8a3cd", size = 216978, upload-time = "2026-04-22T02:44:44.059Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/01/61/d4b89fec821f72385526e1b9d9a3a0385dda4a72b206d28049e2c7cd39b8/gitpython-3.1.45-py3-none-any.whl", hash = "sha256:8908cb2e02fb3b93b7eb0f2827125cb699869470432cc885f019b8fd0fccff77", size = 208168, upload-time = "2025-07-24T03:45:52.517Z" }, + { url = "https://files.pythonhosted.org/packages/f2/c5/a1bc0996af85757903cf2bf444a7824e68e0035ce63fb41d6f76f9def68b/gitpython-3.1.47-py3-none-any.whl", hash = "sha256:489f590edfd6d20571b2c0e72c6a6ac6915ee8b8cd04572330e3842207a78905", size = 209547, upload-time = "2026-04-22T02:44:41.271Z" }, ] [[package]] From 7efc887e32a154216bba0dec1b2c2e32b3dee9e2 Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Sun, 26 Apr 2026 20:47:42 +0900 Subject: [PATCH 020/128] refactor: port MessageAnnotation (#31005) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/models/model.py | 7 +++++-- api/services/annotation_service.py | 9 ++++++++- api/tests/unit_tests/models/test_app_models.py | 6 ++++++ api/tests/unit_tests/services/test_annotation_service.py | 2 ++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/api/models/model.py b/api/models/model.py index a632735f39..de83aa1d96 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -1867,15 +1867,18 @@ class MessageAnnotation(TypeBase): ) id: Mapped[str] = mapped_column( - StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False + StringUUID, + insert_default=lambda: str(uuid4()), + default_factory=lambda: str(uuid4()), + init=False, ) app_id: Mapped[str] = mapped_column(StringUUID) question: Mapped[str] = mapped_column(LongText, nullable=False) content: Mapped[str] = mapped_column(LongText, nullable=False) + hit_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("0"), init=False) account_id: Mapped[str] = mapped_column(StringUUID, nullable=False) conversation_id: Mapped[str | None] = mapped_column(StringUUID, sa.ForeignKey("conversations.id"), default=None) message_id: Mapped[str | None] = mapped_column(StringUUID, default=None) - hit_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("0"), default=0) created_at: Mapped[datetime] = mapped_column( sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False ) diff --git a/api/services/annotation_service.py b/api/services/annotation_service.py index ff0882ad5c..0229a1f43a 100644 --- a/api/services/annotation_service.py +++ b/api/services/annotation_service.py @@ -133,7 +133,14 @@ class AppAnnotationService: raise ValueError("'question' is required when 'message_id' is not provided") question = maybe_question - annotation = MessageAnnotation(app_id=app.id, content=answer, question=question, account_id=current_user.id) + annotation = MessageAnnotation( + app_id=app.id, + conversation_id=None, + message_id=None, + content=answer, + question=question, + account_id=current_user.id, + ) db.session.add(annotation) db.session.commit() diff --git a/api/tests/unit_tests/models/test_app_models.py b/api/tests/unit_tests/models/test_app_models.py index 4e46cf9654..e3b8269e15 100644 --- a/api/tests/unit_tests/models/test_app_models.py +++ b/api/tests/unit_tests/models/test_app_models.py @@ -711,6 +711,8 @@ class TestMessageAnnotation: annotation = MessageAnnotation( app_id=app_id, question="What is AI?", + conversation_id=None, + message_id=None, content="AI stands for Artificial Intelligence.", account_id=account_id, ) @@ -728,6 +730,8 @@ class TestMessageAnnotation: annotation = MessageAnnotation( app_id=str(uuid4()), question="Test question", + conversation_id=None, + message_id=None, content="Test content", account_id=str(uuid4()), ) @@ -1068,6 +1072,8 @@ class TestModelIntegration: app_id=app_id, question="What is AI?", content="AI stands for Artificial Intelligence.", + conversation_id=None, + message_id=message_id, account_id=account_id, ) annotation.id = annotation_id diff --git a/api/tests/unit_tests/services/test_annotation_service.py b/api/tests/unit_tests/services/test_annotation_service.py index 4295315f48..5054010e89 100644 --- a/api/tests/unit_tests/services/test_annotation_service.py +++ b/api/tests/unit_tests/services/test_annotation_service.py @@ -238,6 +238,8 @@ class TestAppAnnotationServiceUpInsert: assert result == annotation_instance mock_cls.assert_called_once_with( app_id=app.id, + conversation_id=None, + message_id=None, content="hello", question="q1", account_id=current_user.id, From d6dee43c09cf6e6a6b839888ff7bd37d3f42ca76 Mon Sep 17 00:00:00 2001 From: Luyu Zhang Date: Sun, 26 Apr 2026 11:28:46 -0700 Subject: [PATCH 021/128] chore(ci): migrate runners to depot --- .github/workflows/api-tests.yml | 6 ++--- .github/workflows/autofix.yml | 2 +- .github/workflows/build-push.yml | 6 ++--- .github/workflows/db-migration-test.yml | 4 ++-- .github/workflows/deploy-agent-dev.yml | 2 +- .github/workflows/deploy-dev.yml | 2 +- .github/workflows/deploy-enterprise.yml | 2 +- .github/workflows/deploy-hitl.yml | 2 +- .github/workflows/docker-build.yml | 4 ++-- .github/workflows/labeler.yml | 2 +- .github/workflows/main-ci.yml | 24 +++++++++---------- .github/workflows/pyrefly-diff-comment.yml | 2 +- .github/workflows/pyrefly-diff.yml | 2 +- .../pyrefly-type-coverage-comment.yml | 2 +- .github/workflows/pyrefly-type-coverage.yml | 2 +- .github/workflows/semantic-pull-request.yml | 2 +- .github/workflows/stale.yml | 2 +- .github/workflows/style.yml | 6 ++--- .github/workflows/tool-test-sdks.yaml | 2 +- .github/workflows/translate-i18n-claude.yml | 2 +- .github/workflows/trigger-i18n-sync.yml | 2 +- .github/workflows/vdb-tests-full.yml | 2 +- .github/workflows/vdb-tests.yml | 2 +- .github/workflows/web-e2e.yml | 2 +- .github/workflows/web-tests.yml | 6 ++--- 25 files changed, 46 insertions(+), 46 deletions(-) diff --git a/.github/workflows/api-tests.yml b/.github/workflows/api-tests.yml index 717413937f..bd47abc710 100644 --- a/.github/workflows/api-tests.yml +++ b/.github/workflows/api-tests.yml @@ -16,7 +16,7 @@ concurrency: jobs: api-unit: name: API Unit Tests - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 env: COVERAGE_FILE: coverage-unit defaults: @@ -62,7 +62,7 @@ jobs: api-integration: name: API Integration Tests - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 env: COVERAGE_FILE: coverage-integration STORAGE_TYPE: opendal @@ -137,7 +137,7 @@ jobs: api-coverage: name: API Coverage - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 needs: - api-unit - api-integration diff --git a/.github/workflows/autofix.yml b/.github/workflows/autofix.yml index 35683b112f..8a1719da3c 100644 --- a/.github/workflows/autofix.yml +++ b/.github/workflows/autofix.yml @@ -13,7 +13,7 @@ permissions: jobs: autofix: if: github.repository == 'langgenius/dify' - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - name: Complete merge group check if: github.event_name == 'merge_group' diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index 5f16fc6927..b78f308736 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -35,7 +35,7 @@ jobs: build_context: "{{defaultContext}}:api" file: "Dockerfile" platform: linux/amd64 - runs_on: ubuntu-latest + runs_on: depot-ubuntu-24.04-4 - service_name: "build-api-arm64" image_name_env: "DIFY_API_IMAGE_NAME" artifact_context: "api" @@ -49,7 +49,7 @@ jobs: build_context: "{{defaultContext}}" file: "web/Dockerfile" platform: linux/amd64 - runs_on: ubuntu-latest + runs_on: depot-ubuntu-24.04-4 - service_name: "build-web-arm64" image_name_env: "DIFY_WEB_IMAGE_NAME" artifact_context: "web" @@ -110,7 +110,7 @@ jobs: create-manifest: needs: build - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 if: github.repository == 'langgenius/dify' strategy: matrix: diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml index 17b867dd6d..b1ccf496df 100644 --- a/.github/workflows/db-migration-test.yml +++ b/.github/workflows/db-migration-test.yml @@ -9,7 +9,7 @@ concurrency: jobs: db-migration-test-postgres: - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - name: Checkout code @@ -59,7 +59,7 @@ jobs: run: uv run --directory api flask upgrade-db db-migration-test-mysql: - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - name: Checkout code diff --git a/.github/workflows/deploy-agent-dev.yml b/.github/workflows/deploy-agent-dev.yml index cd5fe9242e..9b9b77e0a2 100644 --- a/.github/workflows/deploy-agent-dev.yml +++ b/.github/workflows/deploy-agent-dev.yml @@ -13,7 +13,7 @@ on: jobs: deploy: - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 if: | github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'deploy/agent-dev' diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 954537663a..c2ff8c6332 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -10,7 +10,7 @@ on: jobs: deploy: - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 if: | github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'deploy/dev' diff --git a/.github/workflows/deploy-enterprise.yml b/.github/workflows/deploy-enterprise.yml index 9cff3a3482..2740541f0f 100644 --- a/.github/workflows/deploy-enterprise.yml +++ b/.github/workflows/deploy-enterprise.yml @@ -13,7 +13,7 @@ on: jobs: deploy: - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 if: | github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'deploy/enterprise' diff --git a/.github/workflows/deploy-hitl.yml b/.github/workflows/deploy-hitl.yml index c6f1cc7e6f..0da241cf95 100644 --- a/.github/workflows/deploy-hitl.yml +++ b/.github/workflows/deploy-hitl.yml @@ -10,7 +10,7 @@ on: jobs: deploy: - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 if: | github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'build/feat/hitl' diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 5752076c36..c02816b979 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -20,7 +20,7 @@ jobs: include: - service_name: "api-amd64" platform: linux/amd64 - runs_on: ubuntu-latest + runs_on: depot-ubuntu-24.04-4 context: "{{defaultContext}}:api" file: "Dockerfile" - service_name: "api-arm64" @@ -30,7 +30,7 @@ jobs: file: "Dockerfile" - service_name: "web-amd64" platform: linux/amd64 - runs_on: ubuntu-latest + runs_on: depot-ubuntu-24.04-4 context: "{{defaultContext}}" file: "web/Dockerfile" - service_name: "web-arm64" diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 278e10bc04..f59cc6be48 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -7,7 +7,7 @@ jobs: permissions: contents: read pull-requests: write - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 with: diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml index ba36b5c07a..278f2ed8d1 100644 --- a/.github/workflows/main-ci.yml +++ b/.github/workflows/main-ci.yml @@ -23,7 +23,7 @@ concurrency: jobs: pre_job: name: Skip Duplicate Checks - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 outputs: should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }} steps: @@ -39,7 +39,7 @@ jobs: name: Check Changed Files needs: pre_job if: needs.pre_job.outputs.should_skip != 'true' - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 outputs: api-changed: ${{ steps.changes.outputs.api }} e2e-changed: ${{ steps.changes.outputs.e2e }} @@ -141,7 +141,7 @@ jobs: - pre_job - check-changes if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed != 'true' - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - name: Report skipped API tests run: echo "No API-related changes detected; skipping API tests." @@ -154,7 +154,7 @@ jobs: - check-changes - api-tests-run - api-tests-skip - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - name: Finalize API Tests status env: @@ -201,7 +201,7 @@ jobs: - pre_job - check-changes if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed != 'true' - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - name: Report skipped web tests run: echo "No web-related changes detected; skipping web tests." @@ -214,7 +214,7 @@ jobs: - check-changes - web-tests-run - web-tests-skip - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - name: Finalize Web Tests status env: @@ -260,7 +260,7 @@ jobs: - pre_job - check-changes if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true' - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - name: Report skipped web full-stack e2e run: echo "No E2E-related changes detected; skipping web full-stack E2E." @@ -273,7 +273,7 @@ jobs: - check-changes - web-e2e-run - web-e2e-skip - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - name: Finalize Web Full-Stack E2E status env: @@ -325,7 +325,7 @@ jobs: - pre_job - check-changes if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed != 'true' - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - name: Report skipped VDB tests run: echo "No VDB-related changes detected; skipping VDB tests." @@ -338,7 +338,7 @@ jobs: - check-changes - vdb-tests-run - vdb-tests-skip - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - name: Finalize VDB Tests status env: @@ -384,7 +384,7 @@ jobs: - pre_job - check-changes if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed != 'true' - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - name: Report skipped DB migration tests run: echo "No migration-related changes detected; skipping DB migration tests." @@ -397,7 +397,7 @@ jobs: - check-changes - db-migration-test-run - db-migration-test-skip - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - name: Finalize DB Migration Test status env: diff --git a/.github/workflows/pyrefly-diff-comment.yml b/.github/workflows/pyrefly-diff-comment.yml index c55b013dbe..7f82942e7e 100644 --- a/.github/workflows/pyrefly-diff-comment.yml +++ b/.github/workflows/pyrefly-diff-comment.yml @@ -12,7 +12,7 @@ permissions: {} jobs: comment: name: Comment PR with pyrefly diff - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 permissions: actions: read contents: read diff --git a/.github/workflows/pyrefly-diff.yml b/.github/workflows/pyrefly-diff.yml index eb15cd6f75..0cf54e3585 100644 --- a/.github/workflows/pyrefly-diff.yml +++ b/.github/workflows/pyrefly-diff.yml @@ -10,7 +10,7 @@ permissions: jobs: pyrefly-diff: - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 permissions: contents: read issues: write diff --git a/.github/workflows/pyrefly-type-coverage-comment.yml b/.github/workflows/pyrefly-type-coverage-comment.yml index 3c6c96a664..52c16f3153 100644 --- a/.github/workflows/pyrefly-type-coverage-comment.yml +++ b/.github/workflows/pyrefly-type-coverage-comment.yml @@ -12,7 +12,7 @@ permissions: {} jobs: comment: name: Comment PR with type coverage - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 permissions: actions: read contents: read diff --git a/.github/workflows/pyrefly-type-coverage.yml b/.github/workflows/pyrefly-type-coverage.yml index 0599c94eef..eae8debf1a 100644 --- a/.github/workflows/pyrefly-type-coverage.yml +++ b/.github/workflows/pyrefly-type-coverage.yml @@ -10,7 +10,7 @@ permissions: jobs: pyrefly-type-coverage: - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 permissions: contents: read issues: write diff --git a/.github/workflows/semantic-pull-request.yml b/.github/workflows/semantic-pull-request.yml index 49d2e94695..6f3193bbf5 100644 --- a/.github/workflows/semantic-pull-request.yml +++ b/.github/workflows/semantic-pull-request.yml @@ -16,7 +16,7 @@ jobs: name: Validate PR title permissions: pull-requests: read - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - name: Complete merge group check if: github.event_name == 'merge_group' diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c74f4a670a..b23648c7c6 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -12,7 +12,7 @@ on: jobs: stale: - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 permissions: issues: write pull-requests: write diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index d8c7ebbad3..35b8f86cab 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -15,7 +15,7 @@ permissions: jobs: python-style: name: Python Style - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - name: Checkout code @@ -57,7 +57,7 @@ jobs: web-style: name: Web Style - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 defaults: run: working-directory: ./web @@ -131,7 +131,7 @@ jobs: superlinter: name: SuperLinter - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 steps: - name: Checkout code diff --git a/.github/workflows/tool-test-sdks.yaml b/.github/workflows/tool-test-sdks.yaml index bf33207a14..79fddb1853 100644 --- a/.github/workflows/tool-test-sdks.yaml +++ b/.github/workflows/tool-test-sdks.yaml @@ -18,7 +18,7 @@ concurrency: jobs: build: name: unit test for Node.js SDK - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 defaults: run: diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index eecbbb1a56..0294e8a859 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -35,7 +35,7 @@ concurrency: jobs: translate: if: github.repository == 'langgenius/dify' - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 timeout-minutes: 120 steps: diff --git a/.github/workflows/trigger-i18n-sync.yml b/.github/workflows/trigger-i18n-sync.yml index 790ea9126d..87c88e2023 100644 --- a/.github/workflows/trigger-i18n-sync.yml +++ b/.github/workflows/trigger-i18n-sync.yml @@ -16,7 +16,7 @@ concurrency: jobs: trigger: if: github.repository == 'langgenius/dify' - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 timeout-minutes: 5 steps: diff --git a/.github/workflows/vdb-tests-full.yml b/.github/workflows/vdb-tests-full.yml index b79e8927d7..5c241af5c5 100644 --- a/.github/workflows/vdb-tests-full.yml +++ b/.github/workflows/vdb-tests-full.yml @@ -16,7 +16,7 @@ jobs: test: name: Full VDB Tests if: github.repository == 'langgenius/dify' - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 strategy: matrix: python-version: diff --git a/.github/workflows/vdb-tests.yml b/.github/workflows/vdb-tests.yml index bd13d662c3..38ec96f00f 100644 --- a/.github/workflows/vdb-tests.yml +++ b/.github/workflows/vdb-tests.yml @@ -13,7 +13,7 @@ concurrency: jobs: test: name: VDB Smoke Tests - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 strategy: matrix: python-version: diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml index 6bd4d4f406..a634830fef 100644 --- a/.github/workflows/web-e2e.yml +++ b/.github/workflows/web-e2e.yml @@ -13,7 +13,7 @@ concurrency: jobs: test: name: Web Full-Stack E2E - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 defaults: run: shell: bash diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index 2a5cf19645..db6a797c15 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -16,7 +16,7 @@ concurrency: jobs: test: name: Web Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 env: VITEST_COVERAGE_SCOPE: app-components strategy: @@ -54,7 +54,7 @@ jobs: name: Merge Test Reports if: ${{ !cancelled() }} needs: [test] - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} defaults: @@ -92,7 +92,7 @@ jobs: dify-ui-test: name: dify-ui Tests - runs-on: ubuntu-latest + runs-on: depot-ubuntu-24.04 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} defaults: From 23648141c9f8c48a46e6820f0034254ef2aff948 Mon Sep 17 00:00:00 2001 From: Luyu Zhang Date: Sun, 26 Apr 2026 16:00:17 -0700 Subject: [PATCH 022/128] chore(ci): move image builds to depot (#35575) --- .github/workflows/build-push.yml | 40 ++++++++++++++++++++++----- .github/workflows/docker-build.yml | 43 +++++++++++++++++++++++++----- depot.json | 1 + 3 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 depot.json diff --git a/.github/workflows/build-push.yml b/.github/workflows/build-push.yml index b78f308736..2d8bde8080 100644 --- a/.github/workflows/build-push.yml +++ b/.github/workflows/build-push.yml @@ -26,6 +26,9 @@ jobs: build: runs-on: ${{ matrix.runs_on }} if: github.repository == 'langgenius/dify' + permissions: + contents: read + id-token: write strategy: matrix: include: @@ -42,7 +45,7 @@ jobs: build_context: "{{defaultContext}}:api" file: "Dockerfile" platform: linux/arm64 - runs_on: ubuntu-24.04-arm + runs_on: depot-ubuntu-24.04-4 - service_name: "build-web-amd64" image_name_env: "DIFY_WEB_IMAGE_NAME" artifact_context: "web" @@ -56,7 +59,7 @@ jobs: build_context: "{{defaultContext}}" file: "web/Dockerfile" platform: linux/arm64 - runs_on: ubuntu-24.04-arm + runs_on: depot-ubuntu-24.04-4 steps: - name: Prepare @@ -70,8 +73,8 @@ jobs: username: ${{ env.DOCKERHUB_USER }} password: ${{ env.DOCKERHUB_TOKEN }} - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - name: Set up Depot CLI + uses: depot/setup-action@v1 - name: Extract metadata for Docker id: meta @@ -81,16 +84,15 @@ jobs: - name: Build Docker image id: build - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: depot/build-push-action@v1 with: + project: ${{ vars.DEPOT_PROJECT_ID }} context: ${{ matrix.build_context }} file: ${{ matrix.file }} platforms: ${{ matrix.platform }} build-args: COMMIT_SHA=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }} labels: ${{ steps.meta.outputs.labels }} outputs: type=image,name=${{ env[matrix.image_name_env] }},push-by-digest=true,name-canonical=true,push=true - cache-from: type=gha,scope=${{ matrix.service_name }} - cache-to: type=gha,mode=max,scope=${{ matrix.service_name }} - name: Export digest env: @@ -108,6 +110,30 @@ jobs: if-no-files-found: error retention-days: 1 + fork-build-validate: + if: github.repository != 'langgenius/dify' + runs-on: ubuntu-24.04 + strategy: + matrix: + include: + - service_name: "validate-api-amd64" + build_context: "{{defaultContext}}:api" + file: "Dockerfile" + - service_name: "validate-web-amd64" + build_context: "{{defaultContext}}" + file: "web/Dockerfile" + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@98e3b2c9eab4f4f98a95c0c0a3ea5e5e672fd2a8 # v3.10.0 + + - name: Validate Docker image + uses: docker/build-push-action@5cd29d66b4a8d8e6f4d5dfe2e9329f0b1d446289 # v6.18.0 + with: + push: false + context: ${{ matrix.build_context }} + file: ${{ matrix.file }} + platforms: linux/amd64 + create-manifest: needs: build runs-on: depot-ubuntu-24.04 diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index c02816b979..b0022b863b 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -14,7 +14,11 @@ concurrency: jobs: build-docker: + if: github.event.pull_request.head.repo.full_name == github.repository runs-on: ${{ matrix.runs_on }} + permissions: + contents: read + id-token: write strategy: matrix: include: @@ -25,7 +29,7 @@ jobs: file: "Dockerfile" - service_name: "api-arm64" platform: linux/arm64 - runs_on: ubuntu-24.04-arm + runs_on: depot-ubuntu-24.04-4 context: "{{defaultContext}}:api" file: "Dockerfile" - service_name: "web-amd64" @@ -35,19 +39,44 @@ jobs: file: "web/Dockerfile" - service_name: "web-arm64" platform: linux/arm64 - runs_on: ubuntu-24.04-arm + runs_on: depot-ubuntu-24.04-4 context: "{{defaultContext}}" file: "web/Dockerfile" steps: - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 + - name: Set up Depot CLI + uses: depot/setup-action@v1 - name: Build Docker Image - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + uses: depot/build-push-action@v1 with: + project: ${{ vars.DEPOT_PROJECT_ID }} push: false context: ${{ matrix.context }} file: ${{ matrix.file }} platforms: ${{ matrix.platform }} - cache-from: type=gha - cache-to: type=gha,mode=max + + build-docker-fork: + if: github.event.pull_request.head.repo.full_name != github.repository + runs-on: ubuntu-24.04 + permissions: + contents: read + strategy: + matrix: + include: + - service_name: "api-amd64" + context: "{{defaultContext}}:api" + file: "Dockerfile" + - service_name: "web-amd64" + context: "{{defaultContext}}" + file: "web/Dockerfile" + steps: + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@98e3b2c9eab4f4f98a95c0c0a3ea5e5e672fd2a8 # v3.10.0 + + - name: Build Docker Image + uses: docker/build-push-action@5cd29d66b4a8d8e6f4d5dfe2e9329f0b1d446289 # v6.18.0 + with: + push: false + context: ${{ matrix.context }} + file: ${{ matrix.file }} + platforms: linux/amd64 diff --git a/depot.json b/depot.json new file mode 100644 index 0000000000..1c8a32f130 --- /dev/null +++ b/depot.json @@ -0,0 +1 @@ +{"id":"smkxz53ddb"} From b1b977e284c2ac4536cfd0a1fff9ca16a3df7ef7 Mon Sep 17 00:00:00 2001 From: hj24 Date: Mon, 27 Apr 2026 09:49:40 +0800 Subject: [PATCH 023/128] refactor: quota v3 integration (#35436) Co-authored-by: Yansong Zhang <916125788@qq.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/enums/quota_type.py | 188 ---------- api/services/app_generate_service.py | 6 +- api/services/async_workflow_service.py | 44 ++- api/services/billing_service.py | 98 ++++- api/services/feature_service.py | 2 +- api/services/quota_service.py | 233 ++++++++++++ api/services/trigger/webhook_service.py | 73 ++-- api/services/workflow_service.py | 21 +- api/tasks/trigger_processing_tasks.py | 97 ++--- api/tasks/workflow_schedule_tasks.py | 35 +- .../services/test_app_generate_service.py | 23 +- .../test_webhook_service_relationships.py | 16 +- .../trigger/test_trigger_e2e.py | 4 +- api/tests/unit_tests/enums/__init__.py | 0 api/tests/unit_tests/enums/test_quota_type.py | 349 ++++++++++++++++++ .../services/test_app_generate_service.py | 12 +- .../services/test_async_workflow_service.py | 26 +- .../services/test_billing_service.py | 160 +++++++- .../tasks/test_trigger_processing_tasks.py | 204 ++++++++++ 19 files changed, 1255 insertions(+), 336 deletions(-) create mode 100644 api/services/quota_service.py create mode 100644 api/tests/unit_tests/enums/__init__.py create mode 100644 api/tests/unit_tests/enums/test_quota_type.py create mode 100644 api/tests/unit_tests/tasks/test_trigger_processing_tasks.py diff --git a/api/enums/quota_type.py b/api/enums/quota_type.py index 9f511b88ef..a10ac21f69 100644 --- a/api/enums/quota_type.py +++ b/api/enums/quota_type.py @@ -1,56 +1,17 @@ -import logging -from dataclasses import dataclass from enum import StrEnum, auto -logger = logging.getLogger(__name__) - - -@dataclass -class QuotaCharge: - """ - Result of a quota consumption operation. - - Attributes: - success: Whether the quota charge succeeded - charge_id: UUID for refund, or None if failed/disabled - """ - - success: bool - charge_id: str | None - _quota_type: "QuotaType" - - def refund(self) -> None: - """ - Refund this quota charge. - - Safe to call even if charge failed or was disabled. - This method guarantees no exceptions will be raised. - """ - if self.charge_id: - self._quota_type.refund(self.charge_id) - logger.info("Refunded quota for %s with charge_id: %s", self._quota_type.value, self.charge_id) - class QuotaType(StrEnum): """ Supported quota types for tenant feature usage. - - Add additional types here whenever new billable features become available. """ - # Trigger execution quota TRIGGER = auto() - - # Workflow execution quota WORKFLOW = auto() - UNLIMITED = auto() @property def billing_key(self) -> str: - """ - Get the billing key for the feature. - """ match self: case QuotaType.TRIGGER: return "trigger_event" @@ -58,152 +19,3 @@ class QuotaType(StrEnum): return "api_rate_limit" case _: raise ValueError(f"Invalid quota type: {self}") - - def consume(self, tenant_id: str, amount: int = 1) -> QuotaCharge: - """ - Consume quota for the feature. - - Args: - tenant_id: The tenant identifier - amount: Amount to consume (default: 1) - - Returns: - QuotaCharge with success status and charge_id for refund - - Raises: - QuotaExceededError: When quota is insufficient - """ - from configs import dify_config - from services.billing_service import BillingService - from services.errors.app import QuotaExceededError - - if not dify_config.BILLING_ENABLED: - logger.debug("Billing disabled, allowing request for %s", tenant_id) - return QuotaCharge(success=True, charge_id=None, _quota_type=self) - - logger.info("Consuming %d %s quota for tenant %s", amount, self.value, tenant_id) - - if amount <= 0: - raise ValueError("Amount to consume must be greater than 0") - - try: - response = BillingService.update_tenant_feature_plan_usage(tenant_id, self.billing_key, delta=amount) - - if response.get("result") != "success": - logger.warning( - "Failed to consume quota for %s, feature %s details: %s", - tenant_id, - self.value, - response.get("detail"), - ) - raise QuotaExceededError(feature=self.value, tenant_id=tenant_id, required=amount) - - charge_id = response.get("history_id") - logger.debug( - "Successfully consumed %d %s quota for tenant %s, charge_id: %s", - amount, - self.value, - tenant_id, - charge_id, - ) - return QuotaCharge(success=True, charge_id=charge_id, _quota_type=self) - - except QuotaExceededError: - raise - except Exception: - # fail-safe: allow request on billing errors - logger.exception("Failed to consume quota for %s, feature %s", tenant_id, self.value) - return unlimited() - - def check(self, tenant_id: str, amount: int = 1) -> bool: - """ - Check if tenant has sufficient quota without consuming. - - Args: - tenant_id: The tenant identifier - amount: Amount to check (default: 1) - - Returns: - True if quota is sufficient, False otherwise - """ - from configs import dify_config - - if not dify_config.BILLING_ENABLED: - return True - - if amount <= 0: - raise ValueError("Amount to check must be greater than 0") - - try: - remaining = self.get_remaining(tenant_id) - return remaining >= amount if remaining != -1 else True - except Exception: - logger.exception("Failed to check quota for %s, feature %s", tenant_id, self.value) - # fail-safe: allow request on billing errors - return True - - def refund(self, charge_id: str) -> None: - """ - Refund quota using charge_id from consume(). - - This method guarantees no exceptions will be raised. - All errors are logged but silently handled. - - Args: - charge_id: The UUID returned from consume() - """ - try: - from configs import dify_config - from services.billing_service import BillingService - - if not dify_config.BILLING_ENABLED: - return - - if not charge_id: - logger.warning("Cannot refund: charge_id is empty") - return - - logger.info("Refunding %s quota with charge_id: %s", self.value, charge_id) - - response = BillingService.refund_tenant_feature_plan_usage(charge_id) - if response.get("result") == "success": - logger.debug("Successfully refunded %s quota, charge_id: %s", self.value, charge_id) - else: - logger.warning("Refund failed for charge_id: %s", charge_id) - - except Exception: - # Catch ALL exceptions - refund must never fail - logger.exception("Failed to refund quota for charge_id: %s", charge_id) - # Don't raise - refund is best-effort and must be silent - - def get_remaining(self, tenant_id: str) -> int: - """ - Get remaining quota for the tenant. - - Args: - tenant_id: The tenant identifier - - Returns: - Remaining quota amount - """ - from services.billing_service import BillingService - - try: - usage_info = BillingService.get_tenant_feature_plan_usage(tenant_id, self.billing_key) - # Assuming the API returns a dict with 'remaining' or 'limit' and 'used' - if isinstance(usage_info, dict): - return usage_info.get("remaining", 0) - # If it returns a simple number, treat it as remaining - return int(usage_info) if usage_info else 0 - except Exception: - logger.exception("Failed to get remaining quota for %s, feature %s", tenant_id, self.value) - return -1 - - -def unlimited() -> QuotaCharge: - """ - Return a quota charge for unlimited quota. - - This is useful for features that are not subject to quota limits, such as the UNLIMITED quota type. - """ - return QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.UNLIMITED) diff --git a/api/services/app_generate_service.py b/api/services/app_generate_service.py index 8ff53d143b..d6c01e9dcc 100644 --- a/api/services/app_generate_service.py +++ b/api/services/app_generate_service.py @@ -18,12 +18,13 @@ from core.app.features.rate_limiting import RateLimit from core.app.features.rate_limiting.rate_limit import rate_limit_context from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig from core.db import session_factory -from enums.quota_type import QuotaType, unlimited +from enums.quota_type import QuotaType from extensions.otel import AppGenerateHandler, trace_span from models.model import Account, App, AppMode, EndUser from models.workflow import Workflow, WorkflowRun from services.errors.app import QuotaExceededError, WorkflowIdFormatError, WorkflowNotFoundError from services.errors.llm import InvokeRateLimitError +from services.quota_service import QuotaService, unlimited from services.workflow_service import WorkflowService from tasks.app_generate.workflow_execute_task import AppExecutionParams, workflow_based_app_execution_task @@ -106,7 +107,7 @@ class AppGenerateService: quota_charge = unlimited() if dify_config.BILLING_ENABLED: try: - quota_charge = QuotaType.WORKFLOW.consume(app_model.tenant_id) + quota_charge = QuotaService.reserve(QuotaType.WORKFLOW, app_model.tenant_id) except QuotaExceededError: raise InvokeRateLimitError(f"Workflow execution quota limit reached for tenant {app_model.tenant_id}") @@ -116,6 +117,7 @@ class AppGenerateService: request_id = RateLimit.gen_request_key() try: request_id = rate_limit.enter(request_id) + quota_charge.commit() effective_mode = ( AppMode.AGENT_CHAT if app_model.is_agent and app_model.mode != AppMode.AGENT_CHAT else app_model.mode ) diff --git a/api/services/async_workflow_service.py b/api/services/async_workflow_service.py index a731d5c048..ceda30e950 100644 --- a/api/services/async_workflow_service.py +++ b/api/services/async_workflow_service.py @@ -22,6 +22,7 @@ from models.trigger import WorkflowTriggerLog, WorkflowTriggerLogDict from models.workflow import Workflow from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository from services.errors.app import QuotaExceededError, WorkflowNotFoundError, WorkflowQuotaLimitError +from services.quota_service import QuotaService, unlimited from services.workflow.entities import AsyncTriggerResponse, TriggerData, WorkflowTaskData from services.workflow.queue_dispatcher import QueueDispatcherManager, QueuePriority from services.workflow_service import WorkflowService @@ -88,7 +89,10 @@ class AsyncWorkflowService: raise WorkflowNotFoundError(f"App not found: {trigger_data.app_id}") # 2. Get workflow - workflow = cls._get_workflow(workflow_service, app_model, trigger_data.workflow_id) + workflow = cls._get_workflow(workflow_service, app_model, trigger_data.workflow_id, session=session) + + # commit read only session before starting the billig rpc call + session.commit() # 3. Get dispatcher based on tenant subscription dispatcher = dispatcher_manager.get_dispatcher(trigger_data.tenant_id) @@ -131,9 +135,10 @@ class AsyncWorkflowService: trigger_log = trigger_log_repo.create(trigger_log) session.commit() - # 7. Check and consume quota + # 7. Reserve quota (commit after successful dispatch) + quota_charge = unlimited() try: - QuotaType.WORKFLOW.consume(trigger_data.tenant_id) + quota_charge = QuotaService.reserve(QuotaType.WORKFLOW, trigger_data.tenant_id) except QuotaExceededError as e: # Update trigger log status trigger_log.status = WorkflowTriggerStatus.RATE_LIMITED @@ -153,13 +158,18 @@ class AsyncWorkflowService: # 9. Dispatch to appropriate queue task_data_dict = task_data.model_dump(mode="json") - task: AsyncResult[Any] | None = None - if queue_name == QueuePriority.PROFESSIONAL: - task = execute_workflow_professional.delay(task_data_dict) - elif queue_name == QueuePriority.TEAM: - task = execute_workflow_team.delay(task_data_dict) - else: # SANDBOX - task = execute_workflow_sandbox.delay(task_data_dict) + try: + task: AsyncResult[Any] | None = None + if queue_name == QueuePriority.PROFESSIONAL: + task = execute_workflow_professional.delay(task_data_dict) + elif queue_name == QueuePriority.TEAM: + task = execute_workflow_team.delay(task_data_dict) + else: # SANDBOX + task = execute_workflow_sandbox.delay(task_data_dict) + quota_charge.commit() + except Exception: + quota_charge.refund() + raise # 10. Update trigger log with task info trigger_log.status = WorkflowTriggerStatus.QUEUED @@ -295,13 +305,21 @@ class AsyncWorkflowService: return [log.to_dict() for log in logs] @staticmethod - def _get_workflow(workflow_service: WorkflowService, app_model: App, workflow_id: str | None = None) -> Workflow: + def _get_workflow( + workflow_service: WorkflowService, + app_model: App, + workflow_id: str | None = None, + session: Session | None = None, + ) -> Workflow: """ Get workflow for the app Args: app_model: App model instance workflow_id: Optional specific workflow ID + session: Reuse this SQLAlchemy session for the lookup when provided, + so the caller's explicit session bears the connection cost + instead of Flask's request-scoped ``db.session``. Returns: Workflow instance @@ -311,12 +329,12 @@ class AsyncWorkflowService: """ if workflow_id: # Get specific published workflow - workflow = workflow_service.get_published_workflow_by_id(app_model, workflow_id) + workflow = workflow_service.get_published_workflow_by_id(app_model, workflow_id, session=session) if not workflow: raise WorkflowNotFoundError(f"Published workflow not found: {workflow_id}") else: # Get default published workflow - workflow = workflow_service.get_published_workflow(app_model) + workflow = workflow_service.get_published_workflow(app_model, session=session) if not workflow: raise WorkflowNotFoundError(f"No published workflow found for app: {app_model.id}") diff --git a/api/services/billing_service.py b/api/services/billing_service.py index a1362ccad6..c0e23cdc6f 100644 --- a/api/services/billing_service.py +++ b/api/services/billing_service.py @@ -32,6 +32,50 @@ class SubscriptionPlan(TypedDict): expiration_date: int +class QuotaReserveResult(TypedDict): + reservation_id: str + available: int + reserved: int + + +class QuotaCommitResult(TypedDict): + available: int + reserved: int + refunded: int + + +class QuotaReleaseResult(TypedDict): + available: int + reserved: int + released: int + + +_quota_reserve_adapter = TypeAdapter(QuotaReserveResult) +_quota_commit_adapter = TypeAdapter(QuotaCommitResult) +_quota_release_adapter = TypeAdapter(QuotaReleaseResult) + + +class _TenantFeatureQuota(TypedDict): + usage: int + limit: int + reset_date: NotRequired[int] + + +class TenantFeatureQuotaInfo(TypedDict): + """Response of /quota/info. + + NOTE (hj24): + - Same convention as BillingInfo: billing may return int fields as str, + always keep non-strict mode to auto-coerce. + """ + + trigger_event: _TenantFeatureQuota + api_rate_limit: _TenantFeatureQuota + + +_tenant_feature_quota_info_adapter = TypeAdapter(TenantFeatureQuotaInfo) + + class _BillingQuota(TypedDict): size: int limit: int @@ -149,11 +193,63 @@ class BillingService: @classmethod def get_tenant_feature_plan_usage_info(cls, tenant_id: str): + """Deprecated: Use get_quota_info instead.""" params = {"tenant_id": tenant_id} - usage_info = cls._send_request("GET", "/tenant-feature-usage/info", params=params) return usage_info + @classmethod + def get_quota_info(cls, tenant_id: str) -> TenantFeatureQuotaInfo: + params = {"tenant_id": tenant_id} + return _tenant_feature_quota_info_adapter.validate_python( + cls._send_request("GET", "/quota/info", params=params) + ) + + @classmethod + def quota_reserve( + cls, tenant_id: str, feature_key: str, request_id: str, amount: int = 1, meta: dict | None = None + ) -> QuotaReserveResult: + """Reserve quota before task execution.""" + payload: dict = { + "tenant_id": tenant_id, + "feature_key": feature_key, + "request_id": request_id, + "amount": amount, + } + if meta: + payload["meta"] = meta + return _quota_reserve_adapter.validate_python(cls._send_request("POST", "/quota/reserve", json=payload)) + + @classmethod + def quota_commit( + cls, tenant_id: str, feature_key: str, reservation_id: str, actual_amount: int, meta: dict | None = None + ) -> QuotaCommitResult: + """Commit a reservation with actual consumption.""" + payload: dict = { + "tenant_id": tenant_id, + "feature_key": feature_key, + "reservation_id": reservation_id, + "actual_amount": actual_amount, + } + if meta: + payload["meta"] = meta + return _quota_commit_adapter.validate_python(cls._send_request("POST", "/quota/commit", json=payload)) + + @classmethod + def quota_release(cls, tenant_id: str, feature_key: str, reservation_id: str) -> QuotaReleaseResult: + """Release a reservation (cancel, return frozen quota).""" + return _quota_release_adapter.validate_python( + cls._send_request( + "POST", + "/quota/release", + json={ + "tenant_id": tenant_id, + "feature_key": feature_key, + "reservation_id": reservation_id, + }, + ) + ) + @classmethod def get_knowledge_rate_limit(cls, tenant_id: str) -> KnowledgeRateLimitDict: params = {"tenant_id": tenant_id} diff --git a/api/services/feature_service.py b/api/services/feature_service.py index 38518378f7..9477c28bf3 100644 --- a/api/services/feature_service.py +++ b/api/services/feature_service.py @@ -290,7 +290,7 @@ class FeatureService: def _fulfill_params_from_billing_api(cls, features: FeatureModel, tenant_id: str): billing_info = BillingService.get_info(tenant_id) - features_usage_info = BillingService.get_tenant_feature_plan_usage_info(tenant_id) + features_usage_info = BillingService.get_quota_info(tenant_id) features.billing.enabled = billing_info["enabled"] features.billing.subscription.plan = billing_info["subscription"]["plan"] diff --git a/api/services/quota_service.py b/api/services/quota_service.py new file mode 100644 index 0000000000..4c784315c7 --- /dev/null +++ b/api/services/quota_service.py @@ -0,0 +1,233 @@ +from __future__ import annotations + +import logging +import uuid +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from configs import dify_config + +if TYPE_CHECKING: + from enums.quota_type import QuotaType + +logger = logging.getLogger(__name__) + + +@dataclass +class QuotaCharge: + """ + Result of a quota reservation (Reserve phase). + + Lifecycle: + charge = QuotaService.consume(QuotaType.TRIGGER, tenant_id) + try: + do_work() + charge.commit() # Confirm consumption + except: + charge.refund() # Release frozen quota + + If neither commit() nor refund() is called, the billing system's + cleanup CronJob will auto-release the reservation within ~75 seconds. + """ + + success: bool + charge_id: str | None # reservation_id + _quota_type: QuotaType + _tenant_id: str | None = None + _feature_key: str | None = None + _amount: int = 0 + _committed: bool = field(default=False, repr=False) + + def commit(self, actual_amount: int | None = None) -> None: + """ + Confirm the consumption with actual amount. + + Args: + actual_amount: Actual amount consumed. Defaults to the reserved amount. + If less than reserved, the difference is refunded automatically. + """ + if self._committed or not self.charge_id or not self._tenant_id or not self._feature_key: + return + + try: + from services.billing_service import BillingService + + amount = actual_amount if actual_amount is not None else self._amount + BillingService.quota_commit( + tenant_id=self._tenant_id, + feature_key=self._feature_key, + reservation_id=self.charge_id, + actual_amount=amount, + ) + self._committed = True + logger.debug( + "Committed %s quota for tenant %s, reservation_id: %s, amount: %d", + self._quota_type, + self._tenant_id, + self.charge_id, + amount, + ) + except Exception: + logger.exception("Failed to commit quota, reservation_id: %s", self.charge_id) + + def refund(self) -> None: + """ + Release the reserved quota (cancel the charge). + + Safe to call even if: + - charge failed or was disabled (charge_id is None) + - already committed (Release after Commit is a no-op) + - already refunded (idempotent) + + This method guarantees no exceptions will be raised. + """ + if not self.charge_id or not self._tenant_id or not self._feature_key: + return + + QuotaService.release(self._quota_type, self.charge_id, self._tenant_id, self._feature_key) + + +def unlimited() -> QuotaCharge: + from enums.quota_type import QuotaType + + return QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.UNLIMITED) + + +class QuotaService: + """Orchestrates quota reserve / commit / release lifecycle via BillingService.""" + + @staticmethod + def consume(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> QuotaCharge: + """ + Reserve + immediate Commit (one-shot mode). + + The returned QuotaCharge supports .refund() which calls Release. + For two-phase usage (e.g. streaming), use reserve() directly. + """ + charge = QuotaService.reserve(quota_type, tenant_id, amount) + if charge.success and charge.charge_id: + charge.commit() + return charge + + @staticmethod + def reserve(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> QuotaCharge: + """ + Reserve quota before task execution (Reserve phase only). + + The caller MUST call charge.commit() after the task succeeds, + or charge.refund() if the task fails. + + Raises: + QuotaExceededError: When quota is insufficient + """ + from services.billing_service import BillingService + from services.errors.app import QuotaExceededError + + if not dify_config.BILLING_ENABLED: + logger.debug("Billing disabled, allowing request for %s", tenant_id) + return QuotaCharge(success=True, charge_id=None, _quota_type=quota_type) + + logger.info("Reserving %d %s quota for tenant %s", amount, quota_type.value, tenant_id) + + if amount <= 0: + raise ValueError("Amount to reserve must be greater than 0") + + request_id = str(uuid.uuid4()) + feature_key = quota_type.billing_key + + try: + reserve_resp = BillingService.quota_reserve( + tenant_id=tenant_id, + feature_key=feature_key, + request_id=request_id, + amount=amount, + ) + + reservation_id = reserve_resp.get("reservation_id") + if not reservation_id: + logger.warning( + "Reserve returned no reservation_id for %s, feature %s, response: %s", + tenant_id, + quota_type.value, + reserve_resp, + ) + raise QuotaExceededError(feature=quota_type.value, tenant_id=tenant_id, required=amount) + + logger.debug( + "Reserved %d %s quota for tenant %s, reservation_id: %s", + amount, + quota_type.value, + tenant_id, + reservation_id, + ) + return QuotaCharge( + success=True, + charge_id=reservation_id, + _quota_type=quota_type, + _tenant_id=tenant_id, + _feature_key=feature_key, + _amount=amount, + ) + + except QuotaExceededError: + raise + except ValueError: + raise + except Exception: + logger.exception("Failed to reserve quota for %s, feature %s", tenant_id, quota_type.value) + return unlimited() + + @staticmethod + def check(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> bool: + if not dify_config.BILLING_ENABLED: + return True + + if amount <= 0: + raise ValueError("Amount to check must be greater than 0") + + try: + remaining = QuotaService.get_remaining(quota_type, tenant_id) + return remaining >= amount if remaining != -1 else True + except Exception: + logger.exception("Failed to check quota for %s, feature %s", tenant_id, quota_type.value) + return True + + @staticmethod + def release(quota_type: QuotaType, reservation_id: str, tenant_id: str, feature_key: str) -> None: + """Release a reservation. Guarantees no exceptions.""" + try: + from services.billing_service import BillingService + + if not dify_config.BILLING_ENABLED: + return + + if not reservation_id: + return + + logger.info("Releasing %s quota, reservation_id: %s", quota_type.value, reservation_id) + BillingService.quota_release( + tenant_id=tenant_id, + feature_key=feature_key, + reservation_id=reservation_id, + ) + except Exception: + logger.exception("Failed to release quota, reservation_id: %s", reservation_id) + + @staticmethod + def get_remaining(quota_type: QuotaType, tenant_id: str) -> int: + from services.billing_service import BillingService + + try: + usage_info = BillingService.get_quota_info(tenant_id) + if isinstance(usage_info, dict): + feature_info = usage_info.get(quota_type.billing_key, {}) + if isinstance(feature_info, dict): + limit = feature_info.get("limit", 0) + usage = feature_info.get("usage", 0) + if limit == -1: + return -1 + return max(0, limit - usage) + return 0 + except Exception: + logger.exception("Failed to get remaining quota for %s, feature %s", tenant_id, quota_type.value) + return -1 diff --git a/api/services/trigger/webhook_service.py b/api/services/trigger/webhook_service.py index ca4e43e516..5d99900a04 100644 --- a/api/services/trigger/webhook_service.py +++ b/api/services/trigger/webhook_service.py @@ -38,6 +38,7 @@ from models.workflow import Workflow from services.async_workflow_service import AsyncWorkflowService from services.end_user_service import EndUserService from services.errors.app import QuotaExceededError +from services.quota_service import QuotaService from services.trigger.app_trigger_service import AppTriggerService from services.workflow.entities import WebhookTriggerData @@ -798,45 +799,47 @@ class WebhookService: Exception: If workflow execution fails """ try: - with Session(db.engine) as session: - # Prepare inputs for the webhook node - # The webhook node expects webhook_data in the inputs - workflow_inputs = cls.build_workflow_inputs(webhook_data) + workflow_inputs = cls.build_workflow_inputs(webhook_data) - # Create trigger data - trigger_data = WebhookTriggerData( - app_id=webhook_trigger.app_id, - workflow_id=workflow.id, - root_node_id=webhook_trigger.node_id, # Start from the webhook node - inputs=workflow_inputs, - tenant_id=webhook_trigger.tenant_id, + trigger_data = WebhookTriggerData( + app_id=webhook_trigger.app_id, + workflow_id=workflow.id, + root_node_id=webhook_trigger.node_id, + inputs=workflow_inputs, + tenant_id=webhook_trigger.tenant_id, + ) + + end_user = EndUserService.get_or_create_end_user_by_type( + type=InvokeFrom.TRIGGER, + tenant_id=webhook_trigger.tenant_id, + app_id=webhook_trigger.app_id, + user_id=None, + ) + + try: + quota_charge = QuotaService.reserve(QuotaType.TRIGGER, webhook_trigger.tenant_id) + except QuotaExceededError: + AppTriggerService.mark_tenant_triggers_rate_limited(webhook_trigger.tenant_id) + logger.info( + "Tenant %s rate limited, skipping webhook trigger %s", + webhook_trigger.tenant_id, + webhook_trigger.webhook_id, ) + raise - end_user = EndUserService.get_or_create_end_user_by_type( - type=InvokeFrom.TRIGGER, - tenant_id=webhook_trigger.tenant_id, - app_id=webhook_trigger.app_id, - user_id=None, - ) - - # consume quota before triggering workflow execution - try: - QuotaType.TRIGGER.consume(webhook_trigger.tenant_id) - except QuotaExceededError: - AppTriggerService.mark_tenant_triggers_rate_limited(webhook_trigger.tenant_id) - logger.info( - "Tenant %s rate limited, skipping webhook trigger %s", - webhook_trigger.tenant_id, - webhook_trigger.webhook_id, + try: + # NOTE: don not use `with sessionmaker(bind=db.engine, expire_on_commit=False).begin()` + # trigger_workflow_async need to handle multipe session commits internally + with Session(db.engine, expire_on_commit=False) as session: + AsyncWorkflowService.trigger_workflow_async( + session, + end_user, + trigger_data, ) - raise - - # Trigger workflow execution asynchronously - AsyncWorkflowService.trigger_workflow_async( - session, - end_user, - trigger_data, - ) + quota_charge.commit() + except Exception: + quota_charge.refund() + raise except Exception: logger.exception("Failed to trigger workflow for webhook %s", webhook_trigger.webhook_id) diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index d4b9095ce5..f97b85dc2b 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -156,11 +156,18 @@ class WorkflowService: # return draft workflow return workflow - def get_published_workflow_by_id(self, app_model: App, workflow_id: str) -> Workflow | None: + def get_published_workflow_by_id( + self, app_model: App, workflow_id: str, session: Session | None = None + ) -> Workflow | None: """ fetch published workflow by workflow_id + + When ``session`` is provided, reuse it so callers that already hold a + Session avoid checking out an extra request-scoped ``db.session`` + connection. Falls back to ``db.session`` for backward compatibility. """ - workflow = db.session.scalar( + bind = session if session is not None else db.session + workflow = bind.scalar( select(Workflow) .where( Workflow.tenant_id == app_model.tenant_id, @@ -178,16 +185,20 @@ class WorkflowService: ) return workflow - def get_published_workflow(self, app_model: App) -> Workflow | None: + def get_published_workflow(self, app_model: App, session: Session | None = None) -> Workflow | None: """ Get published workflow + + When ``session`` is provided, reuse it so callers that already hold a + Session avoid checking out an extra request-scoped ``db.session`` + connection. Falls back to ``db.session`` for backward compatibility. """ if not app_model.workflow_id: return None - # fetch published workflow by workflow_id - workflow = db.session.scalar( + bind = session if session is not None else db.session + workflow = bind.scalar( select(Workflow) .where( Workflow.tenant_id == app_model.tenant_id, diff --git a/api/tasks/trigger_processing_tasks.py b/api/tasks/trigger_processing_tasks.py index 25ea53dfac..8505375b6a 100644 --- a/api/tasks/trigger_processing_tasks.py +++ b/api/tasks/trigger_processing_tasks.py @@ -27,7 +27,7 @@ from core.trigger.entities.entities import TriggerProviderEntity from core.trigger.provider import PluginTriggerProviderController from core.trigger.trigger_manager import TriggerManager from core.workflow.nodes.trigger_plugin.entities import TriggerEventNodeData -from enums.quota_type import QuotaType, unlimited +from enums.quota_type import QuotaType from graphon.enums import WorkflowExecutionStatus from models.enums import ( AppTriggerType, @@ -42,6 +42,7 @@ from models.workflow import Workflow, WorkflowAppLog, WorkflowAppLogCreatedFrom, from services.async_workflow_service import AsyncWorkflowService from services.end_user_service import EndUserService from services.errors.app import QuotaExceededError +from services.quota_service import QuotaService, unlimited from services.trigger.app_trigger_service import AppTriggerService from services.trigger.trigger_provider_service import TriggerProviderService from services.trigger.trigger_request_service import TriggerHttpRequestCachingService @@ -258,59 +259,58 @@ def dispatch_triggered_workflow( tenant_id=subscription.tenant_id, provider_id=TriggerProviderID(subscription.provider_id) ) trigger_entity: TriggerProviderEntity = provider_controller.entity + + # Ensure expire_on_commit is set to False to remain workflows available with session_factory.create_session() as session: workflows: Mapping[str, Workflow] = _get_latest_workflows_by_app_ids(session, subscribers) - end_users: Mapping[str, EndUser] = EndUserService.create_end_user_batch( - type=InvokeFrom.TRIGGER, - tenant_id=subscription.tenant_id, - app_ids=[plugin_trigger.app_id for plugin_trigger in subscribers], - user_id=user_id, - ) - for plugin_trigger in subscribers: - # Get workflow from mapping - workflow: Workflow | None = workflows.get(plugin_trigger.app_id) - if not workflow: - logger.error( - "Workflow not found for app %s", - plugin_trigger.app_id, - ) - continue + end_users: Mapping[str, EndUser] = EndUserService.create_end_user_batch( + type=InvokeFrom.TRIGGER, + tenant_id=subscription.tenant_id, + app_ids=[plugin_trigger.app_id for plugin_trigger in subscribers], + user_id=user_id, + ) - # Find the trigger node in the workflow - event_node = None - for node_id, node_config in workflow.walk_nodes(TRIGGER_PLUGIN_NODE_TYPE): - if node_id == plugin_trigger.node_id: - event_node = node_config - break - - if not event_node: - logger.error("Trigger event node not found for app %s", plugin_trigger.app_id) - continue - - # invoke trigger - trigger_metadata = PluginTriggerMetadata( - plugin_unique_identifier=provider_controller.plugin_unique_identifier or "", - endpoint_id=subscription.endpoint_id, - provider_id=subscription.provider_id, - event_name=event_name, - icon_filename=trigger_entity.identity.icon or "", - icon_dark_filename=trigger_entity.identity.icon_dark or "", + for plugin_trigger in subscribers: + workflow: Workflow | None = workflows.get(plugin_trigger.app_id) + if not workflow: + logger.error( + "Workflow not found for app %s", + plugin_trigger.app_id, ) + continue - # consume quota before invoking trigger - quota_charge = unlimited() - try: - quota_charge = QuotaType.TRIGGER.consume(subscription.tenant_id) - except QuotaExceededError: - AppTriggerService.mark_tenant_triggers_rate_limited(subscription.tenant_id) - logger.info( - "Tenant %s rate limited, skipping plugin trigger %s", subscription.tenant_id, plugin_trigger.id - ) - return 0 + event_node = None + for node_id, node_config in workflow.walk_nodes(TRIGGER_PLUGIN_NODE_TYPE): + if node_id == plugin_trigger.node_id: + event_node = node_config + break - node_data: TriggerEventNodeData = TriggerEventNodeData.model_validate(event_node) - invoke_response: TriggerInvokeEventResponse | None = None + if not event_node: + logger.error("Trigger event node not found for app %s", plugin_trigger.app_id) + continue + + trigger_metadata = PluginTriggerMetadata( + plugin_unique_identifier=provider_controller.plugin_unique_identifier or "", + endpoint_id=subscription.endpoint_id, + provider_id=subscription.provider_id, + event_name=event_name, + icon_filename=trigger_entity.identity.icon or "", + icon_dark_filename=trigger_entity.identity.icon_dark or "", + ) + + quota_charge = unlimited() + try: + quota_charge = QuotaService.reserve(QuotaType.TRIGGER, subscription.tenant_id) + except QuotaExceededError: + AppTriggerService.mark_tenant_triggers_rate_limited(subscription.tenant_id) + logger.info("Tenant %s rate limited, skipping plugin trigger %s", subscription.tenant_id, plugin_trigger.id) + return dispatched_count + + node_data: TriggerEventNodeData = TriggerEventNodeData.model_validate(event_node) + invoke_response: TriggerInvokeEventResponse | None = None + + with session_factory.create_session() as session: try: invoke_response = TriggerManager.invoke_trigger_event( tenant_id=subscription.tenant_id, @@ -387,6 +387,7 @@ def dispatch_triggered_workflow( raise ValueError(f"End user not found for app {plugin_trigger.app_id}") AsyncWorkflowService.trigger_workflow_async(session=session, user=end_user, trigger_data=trigger_data) + quota_charge.commit() dispatched_count += 1 logger.info( "Triggered workflow for app %s with trigger event %s", @@ -401,7 +402,7 @@ def dispatch_triggered_workflow( plugin_trigger.app_id, ) - return dispatched_count + return dispatched_count def dispatch_triggered_workflows( diff --git a/api/tasks/workflow_schedule_tasks.py b/api/tasks/workflow_schedule_tasks.py index 8c64d3ab27..7638652000 100644 --- a/api/tasks/workflow_schedule_tasks.py +++ b/api/tasks/workflow_schedule_tasks.py @@ -8,10 +8,11 @@ from core.workflow.nodes.trigger_schedule.exc import ( ScheduleNotFoundError, TenantOwnerNotFoundError, ) -from enums.quota_type import QuotaType, unlimited +from enums.quota_type import QuotaType from models.trigger import WorkflowSchedulePlan from services.async_workflow_service import AsyncWorkflowService from services.errors.app import QuotaExceededError +from services.quota_service import QuotaService, unlimited from services.trigger.app_trigger_service import AppTriggerService from services.trigger.schedule_service import ScheduleService from services.workflow.entities import ScheduleTriggerData @@ -32,6 +33,7 @@ def run_schedule_trigger(schedule_id: str) -> None: TenantOwnerNotFoundError: If no owner/admin for tenant ScheduleExecutionError: If workflow trigger fails """ + # Ensure expire_on_commit is set to False to remain schedule/tenant_owner available with session_factory.create_session() as session: schedule = session.get(WorkflowSchedulePlan, schedule_id) if not schedule: @@ -41,16 +43,16 @@ def run_schedule_trigger(schedule_id: str) -> None: if not tenant_owner: raise TenantOwnerNotFoundError(f"No owner or admin found for tenant {schedule.tenant_id}") - quota_charge = unlimited() - try: - quota_charge = QuotaType.TRIGGER.consume(schedule.tenant_id) - except QuotaExceededError: - AppTriggerService.mark_tenant_triggers_rate_limited(schedule.tenant_id) - logger.info("Tenant %s rate limited, skipping schedule trigger %s", schedule.tenant_id, schedule_id) - return + quota_charge = unlimited() + try: + quota_charge = QuotaService.reserve(QuotaType.TRIGGER, schedule.tenant_id) + except QuotaExceededError: + AppTriggerService.mark_tenant_triggers_rate_limited(schedule.tenant_id) + logger.info("Tenant %s rate limited, skipping schedule trigger %s", schedule.tenant_id, schedule_id) + return - try: - # Production dispatch: Trigger the workflow normally + try: + with session_factory.create_session() as session: response = AsyncWorkflowService.trigger_workflow_async( session=session, user=tenant_owner, @@ -61,9 +63,10 @@ def run_schedule_trigger(schedule_id: str) -> None: tenant_id=schedule.tenant_id, ), ) - logger.info("Schedule %s triggered workflow: %s", schedule_id, response.workflow_trigger_log_id) - except Exception as e: - quota_charge.refund() - raise ScheduleExecutionError( - f"Failed to trigger workflow for schedule {schedule_id}, app {schedule.app_id}" - ) from e + quota_charge.commit() + logger.info("Schedule %s triggered workflow: %s", schedule_id, response.workflow_trigger_log_id) + except Exception as e: + quota_charge.refund() + raise ScheduleExecutionError( + f"Failed to trigger workflow for schedule {schedule_id}, app {schedule.app_id}" + ) from e diff --git a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py index 5b1a4790f5..3229693fd4 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_generate_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_generate_service.py @@ -36,12 +36,19 @@ class TestAppGenerateService: ) as mock_message_based_generator, patch("services.account_service.FeatureService", autospec=True) as mock_account_feature_service, patch("services.app_generate_service.dify_config", autospec=True) as mock_dify_config, + patch("services.quota_service.dify_config", autospec=True) as mock_quota_dify_config, patch("configs.dify_config", autospec=True) as mock_global_dify_config, ): # Setup default mock returns for billing service - mock_billing_service.update_tenant_feature_plan_usage.return_value = { - "result": "success", - "history_id": "test_history_id", + mock_billing_service.quota_reserve.return_value = { + "reservation_id": "test-reservation-id", + "available": 100, + "reserved": 1, + } + mock_billing_service.quota_commit.return_value = { + "available": 99, + "reserved": 0, + "refunded": 0, } # Setup default mock returns for workflow service @@ -101,6 +108,8 @@ class TestAppGenerateService: mock_dify_config.APP_DEFAULT_ACTIVE_REQUESTS = 100 mock_dify_config.APP_DAILY_RATE_LIMIT = 1000 + mock_quota_dify_config.BILLING_ENABLED = False + mock_global_dify_config.BILLING_ENABLED = False mock_global_dify_config.APP_MAX_ACTIVE_REQUESTS = 100 mock_global_dify_config.APP_DAILY_RATE_LIMIT = 1000 @@ -118,6 +127,7 @@ class TestAppGenerateService: "message_based_generator": mock_message_based_generator, "account_feature_service": mock_account_feature_service, "dify_config": mock_dify_config, + "quota_dify_config": mock_quota_dify_config, "global_dify_config": mock_global_dify_config, } @@ -465,6 +475,7 @@ class TestAppGenerateService: # Set BILLING_ENABLED to True for this test mock_external_service_dependencies["dify_config"].BILLING_ENABLED = True + mock_external_service_dependencies["quota_dify_config"].BILLING_ENABLED = True mock_external_service_dependencies["global_dify_config"].BILLING_ENABLED = True # Setup test arguments @@ -478,8 +489,10 @@ class TestAppGenerateService: # Verify the result assert result == ["test_response"] - # Verify billing service was called to consume quota - mock_external_service_dependencies["billing_service"].update_tenant_feature_plan_usage.assert_called_once() + # Verify billing two-phase quota (reserve + commit) + billing = mock_external_service_dependencies["billing_service"] + billing.quota_reserve.assert_called_once() + billing.quota_commit.assert_called_once() def test_generate_with_invalid_app_mode( self, db_session_with_containers: Session, mock_external_service_dependencies diff --git a/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py b/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py index ec10c51e04..85ce3a6ba6 100644 --- a/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py +++ b/api/tests/test_containers_integration_tests/services/test_webhook_service_relationships.py @@ -10,6 +10,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from core.trigger.constants import TRIGGER_WEBHOOK_NODE_TYPE +from enums.quota_type import QuotaType from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole from models.enums import AppTriggerStatus, AppTriggerType from models.model import App @@ -290,17 +291,26 @@ class TestWebhookServiceTriggerExecutionWithContainers: end_user = SimpleNamespace(id=str(uuid4())) webhook_data = {"body": {"value": 1}, "headers": {}, "query_params": {}, "files": {}, "method": "POST"} + quota_charge = MagicMock() + with ( patch( "services.trigger.webhook_service.EndUserService.get_or_create_end_user_by_type", return_value=end_user, ), - patch("services.trigger.webhook_service.QuotaType.TRIGGER.consume") as mock_consume, + patch( + "services.trigger.webhook_service.QuotaService.reserve", + return_value=quota_charge, + ) as mock_reserve, patch("services.trigger.webhook_service.AsyncWorkflowService.trigger_workflow_async") as mock_trigger, ): WebhookService.trigger_workflow_execution(webhook_trigger, webhook_data, workflow) - mock_consume.assert_called_once_with(webhook_trigger.tenant_id) + mock_reserve.assert_called_once() + reserve_args = mock_reserve.call_args.args + assert reserve_args[0] == QuotaType.TRIGGER + assert reserve_args[1] == webhook_trigger.tenant_id + quota_charge.commit.assert_called_once() mock_trigger.assert_called_once() trigger_args = mock_trigger.call_args.args assert trigger_args[1] is end_user @@ -327,7 +337,7 @@ class TestWebhookServiceTriggerExecutionWithContainers: return_value=SimpleNamespace(id=str(uuid4())), ), patch( - "services.trigger.webhook_service.QuotaType.TRIGGER.consume", + "services.trigger.webhook_service.QuotaService.reserve", side_effect=QuotaExceededError(feature="trigger", tenant_id=tenant.id, required=1), ), patch( diff --git a/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py b/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py index 55aec49878..9c20118e27 100644 --- a/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py +++ b/api/tests/test_containers_integration_tests/trigger/test_trigger_e2e.py @@ -605,9 +605,9 @@ def test_schedule_trigger_creates_trigger_log( ) # Mock quota to avoid rate limiting - from enums import quota_type + from services import quota_service - monkeypatch.setattr(quota_type.QuotaType.TRIGGER, "consume", lambda _tenant_id: quota_type.unlimited()) + monkeypatch.setattr(quota_service.QuotaService, "reserve", lambda *_args, **_kwargs: quota_service.unlimited()) # Execute schedule trigger workflow_schedule_tasks.run_schedule_trigger(plan.id) diff --git a/api/tests/unit_tests/enums/__init__.py b/api/tests/unit_tests/enums/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/api/tests/unit_tests/enums/test_quota_type.py b/api/tests/unit_tests/enums/test_quota_type.py new file mode 100644 index 0000000000..f256ff3b4e --- /dev/null +++ b/api/tests/unit_tests/enums/test_quota_type.py @@ -0,0 +1,349 @@ +"""Unit tests for QuotaType, QuotaService, and QuotaCharge.""" + +from unittest.mock import patch + +import pytest + +from enums.quota_type import QuotaType +from services.quota_service import QuotaCharge, QuotaService, unlimited + + +class TestQuotaType: + def test_billing_key_trigger(self): + assert QuotaType.TRIGGER.billing_key == "trigger_event" + + def test_billing_key_workflow(self): + assert QuotaType.WORKFLOW.billing_key == "api_rate_limit" + + def test_billing_key_unlimited_raises(self): + with pytest.raises(ValueError, match="Invalid quota type"): + _ = QuotaType.UNLIMITED.billing_key + + +class TestQuotaService: + def test_reserve_billing_disabled(self): + with ( + patch("services.quota_service.dify_config") as mock_cfg, + patch("services.billing_service.BillingService"), + ): + mock_cfg.BILLING_ENABLED = False + charge = QuotaService.reserve(QuotaType.TRIGGER, "t1") + assert charge.success is True + assert charge.charge_id is None + + def test_reserve_zero_amount_raises(self): + with patch("services.quota_service.dify_config") as mock_cfg: + mock_cfg.BILLING_ENABLED = True + with pytest.raises(ValueError, match="greater than 0"): + QuotaService.reserve(QuotaType.TRIGGER, "t1", amount=0) + + def test_reserve_success(self): + with ( + patch("services.quota_service.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = True + mock_bs.quota_reserve.return_value = {"reservation_id": "rid-1", "available": 99} + + charge = QuotaService.reserve(QuotaType.TRIGGER, "t1", amount=1) + + assert charge.success is True + assert charge.charge_id == "rid-1" + assert charge._tenant_id == "t1" + assert charge._feature_key == "trigger_event" + assert charge._amount == 1 + mock_bs.quota_reserve.assert_called_once() + + def test_reserve_no_reservation_id_raises(self): + from services.errors.app import QuotaExceededError + + with ( + patch("services.quota_service.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = True + mock_bs.quota_reserve.return_value = {} + + with pytest.raises(QuotaExceededError): + QuotaService.reserve(QuotaType.TRIGGER, "t1") + + def test_reserve_quota_exceeded_propagates(self): + from services.errors.app import QuotaExceededError + + with ( + patch("services.quota_service.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = True + mock_bs.quota_reserve.side_effect = QuotaExceededError(feature="trigger", tenant_id="t1", required=1) + + with pytest.raises(QuotaExceededError): + QuotaService.reserve(QuotaType.TRIGGER, "t1") + + def test_reserve_api_exception_returns_unlimited(self): + with ( + patch("services.quota_service.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = True + mock_bs.quota_reserve.side_effect = RuntimeError("network") + + charge = QuotaService.reserve(QuotaType.TRIGGER, "t1") + assert charge.success is True + assert charge.charge_id is None + + def test_consume_calls_reserve_and_commit(self): + with ( + patch("services.quota_service.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = True + mock_bs.quota_reserve.return_value = {"reservation_id": "rid-c"} + mock_bs.quota_commit.return_value = {} + + charge = QuotaService.consume(QuotaType.TRIGGER, "t1") + assert charge.success is True + mock_bs.quota_commit.assert_called_once() + + def test_check_billing_disabled(self): + with patch("services.quota_service.dify_config") as mock_cfg: + mock_cfg.BILLING_ENABLED = False + assert QuotaService.check(QuotaType.TRIGGER, "t1") is True + + def test_check_zero_amount_raises(self): + with patch("services.quota_service.dify_config") as mock_cfg: + mock_cfg.BILLING_ENABLED = True + with pytest.raises(ValueError, match="greater than 0"): + QuotaService.check(QuotaType.TRIGGER, "t1", amount=0) + + def test_check_sufficient_quota(self): + with ( + patch("services.quota_service.dify_config") as mock_cfg, + patch.object(QuotaService, "get_remaining", return_value=100), + ): + mock_cfg.BILLING_ENABLED = True + assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=50) is True + + def test_check_insufficient_quota(self): + with ( + patch("services.quota_service.dify_config") as mock_cfg, + patch.object(QuotaService, "get_remaining", return_value=5), + ): + mock_cfg.BILLING_ENABLED = True + assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=10) is False + + def test_check_unlimited_quota(self): + with ( + patch("services.quota_service.dify_config") as mock_cfg, + patch.object(QuotaService, "get_remaining", return_value=-1), + ): + mock_cfg.BILLING_ENABLED = True + assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=999) is True + + def test_check_exception_returns_true(self): + with ( + patch("services.quota_service.dify_config") as mock_cfg, + patch.object(QuotaService, "get_remaining", side_effect=RuntimeError), + ): + mock_cfg.BILLING_ENABLED = True + assert QuotaService.check(QuotaType.TRIGGER, "t1") is True + + def test_release_billing_disabled(self): + with ( + patch("services.quota_service.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = False + QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event") + mock_bs.quota_release.assert_not_called() + + def test_release_empty_reservation(self): + with ( + patch("services.quota_service.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = True + QuotaService.release(QuotaType.TRIGGER, "", "t1", "trigger_event") + mock_bs.quota_release.assert_not_called() + + def test_release_success(self): + with ( + patch("services.quota_service.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = True + mock_bs.quota_release.return_value = {} + QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event") + mock_bs.quota_release.assert_called_once_with( + tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1" + ) + + def test_release_exception_swallowed(self): + with ( + patch("services.quota_service.dify_config") as mock_cfg, + patch("services.billing_service.BillingService") as mock_bs, + ): + mock_cfg.BILLING_ENABLED = True + mock_bs.quota_release.side_effect = RuntimeError("fail") + QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event") + + def test_get_remaining_normal(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": 100, "usage": 30}} + assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 70 + + def test_get_remaining_unlimited(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": -1, "usage": 0}} + assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == -1 + + def test_get_remaining_over_limit_returns_zero(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": 10, "usage": 15}} + assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0 + + def test_get_remaining_exception_returns_neg1(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.get_quota_info.side_effect = RuntimeError + assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == -1 + + def test_get_remaining_empty_response(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.get_quota_info.return_value = {} + assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0 + + def test_get_remaining_non_dict_response(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.get_quota_info.return_value = "invalid" + assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0 + + def test_get_remaining_feature_not_in_response(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.get_quota_info.return_value = {"other_feature": {"limit": 100, "usage": 0}} + remaining = QuotaService.get_remaining(QuotaType.TRIGGER, "t1") + assert remaining == 0 + + def test_get_remaining_non_dict_feature_info(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.get_quota_info.return_value = {"trigger_event": "not_a_dict"} + assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0 + + +class TestQuotaCharge: + def test_commit_success(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.quota_commit.return_value = {} + charge = QuotaCharge( + success=True, + charge_id="rid-1", + _quota_type=QuotaType.TRIGGER, + _tenant_id="t1", + _feature_key="trigger_event", + _amount=1, + ) + charge.commit() + mock_bs.quota_commit.assert_called_once_with( + tenant_id="t1", + feature_key="trigger_event", + reservation_id="rid-1", + actual_amount=1, + ) + assert charge._committed is True + + def test_commit_with_actual_amount(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.quota_commit.return_value = {} + charge = QuotaCharge( + success=True, + charge_id="rid-1", + _quota_type=QuotaType.TRIGGER, + _tenant_id="t1", + _feature_key="trigger_event", + _amount=10, + ) + charge.commit(actual_amount=5) + call_kwargs = mock_bs.quota_commit.call_args[1] + assert call_kwargs["actual_amount"] == 5 + + def test_commit_idempotent(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.quota_commit.return_value = {} + charge = QuotaCharge( + success=True, + charge_id="rid-1", + _quota_type=QuotaType.TRIGGER, + _tenant_id="t1", + _feature_key="trigger_event", + _amount=1, + ) + charge.commit() + charge.commit() + assert mock_bs.quota_commit.call_count == 1 + + def test_commit_no_charge_id_noop(self): + with patch("services.billing_service.BillingService") as mock_bs: + charge = QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.TRIGGER) + charge.commit() + mock_bs.quota_commit.assert_not_called() + + def test_commit_no_tenant_id_noop(self): + with patch("services.billing_service.BillingService") as mock_bs: + charge = QuotaCharge( + success=True, + charge_id="rid-1", + _quota_type=QuotaType.TRIGGER, + _tenant_id=None, + _feature_key="trigger_event", + ) + charge.commit() + mock_bs.quota_commit.assert_not_called() + + def test_commit_exception_swallowed(self): + with patch("services.billing_service.BillingService") as mock_bs: + mock_bs.quota_commit.side_effect = RuntimeError("fail") + charge = QuotaCharge( + success=True, + charge_id="rid-1", + _quota_type=QuotaType.TRIGGER, + _tenant_id="t1", + _feature_key="trigger_event", + _amount=1, + ) + charge.commit() + + def test_refund_success(self): + with patch.object(QuotaService, "release") as mock_rel: + charge = QuotaCharge( + success=True, + charge_id="rid-1", + _quota_type=QuotaType.TRIGGER, + _tenant_id="t1", + _feature_key="trigger_event", + ) + charge.refund() + mock_rel.assert_called_once_with(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event") + + def test_refund_no_charge_id_noop(self): + with patch.object(QuotaService, "release") as mock_rel: + charge = QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.TRIGGER) + charge.refund() + mock_rel.assert_not_called() + + def test_refund_no_tenant_id_noop(self): + with patch.object(QuotaService, "release") as mock_rel: + charge = QuotaCharge( + success=True, + charge_id="rid-1", + _quota_type=QuotaType.TRIGGER, + _tenant_id=None, + ) + charge.refund() + mock_rel.assert_not_called() + + +class TestUnlimited: + def test_unlimited_returns_success_with_no_charge_id(self): + charge = unlimited() + assert charge.success is True + assert charge.charge_id is None + assert charge._quota_type == QuotaType.UNLIMITED diff --git a/api/tests/unit_tests/services/test_app_generate_service.py b/api/tests/unit_tests/services/test_app_generate_service.py index 119a7adc45..d3f9c5dd9f 100644 --- a/api/tests/unit_tests/services/test_app_generate_service.py +++ b/api/tests/unit_tests/services/test_app_generate_service.py @@ -23,6 +23,7 @@ import pytest import services.app_generate_service as ags_module from core.app.entities.app_invoke_entities import InvokeFrom +from enums.quota_type import QuotaType from models.model import AppMode from services.app_generate_service import AppGenerateService from services.errors.app import WorkflowIdFormatError, WorkflowNotFoundError @@ -448,8 +449,8 @@ class TestGenerateBilling: def test_billing_enabled_consumes_quota(self, mocker, monkeypatch): monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) quota_charge = MagicMock() - consume_mock = mocker.patch( - "services.app_generate_service.QuotaType.WORKFLOW.consume", + reserve_mock = mocker.patch( + "services.app_generate_service.QuotaService.reserve", return_value=quota_charge, ) mocker.patch( @@ -468,7 +469,8 @@ class TestGenerateBilling: invoke_from=InvokeFrom.SERVICE_API, streaming=False, ) - consume_mock.assert_called_once_with("tenant-id") + reserve_mock.assert_called_once_with(QuotaType.WORKFLOW, "tenant-id") + quota_charge.commit.assert_called_once() def test_billing_quota_exceeded_raises_rate_limit_error(self, mocker, monkeypatch): from services.errors.app import QuotaExceededError @@ -476,7 +478,7 @@ class TestGenerateBilling: monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) mocker.patch( - "services.app_generate_service.QuotaType.WORKFLOW.consume", + "services.app_generate_service.QuotaService.reserve", side_effect=QuotaExceededError(feature="workflow", tenant_id="t", required=1), ) @@ -493,7 +495,7 @@ class TestGenerateBilling: monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True) quota_charge = MagicMock() mocker.patch( - "services.app_generate_service.QuotaType.WORKFLOW.consume", + "services.app_generate_service.QuotaService.reserve", return_value=quota_charge, ) mocker.patch( diff --git a/api/tests/unit_tests/services/test_async_workflow_service.py b/api/tests/unit_tests/services/test_async_workflow_service.py index ca6ff9dc63..1b9cc8a2ff 100644 --- a/api/tests/unit_tests/services/test_async_workflow_service.py +++ b/api/tests/unit_tests/services/test_async_workflow_service.py @@ -57,7 +57,7 @@ class TestAsyncWorkflowService: - repo: SQLAlchemyWorkflowTriggerLogRepository - dispatcher_manager_class: QueueDispatcherManager class - dispatcher: dispatcher instance - - quota_workflow: QuotaType.WORKFLOW + - quota_service: QuotaService mock - get_workflow: AsyncWorkflowService._get_workflow method - professional_task: execute_workflow_professional - team_task: execute_workflow_team @@ -72,7 +72,7 @@ class TestAsyncWorkflowService: mock_repo.create.side_effect = _create_side_effect mock_dispatcher = MagicMock() - quota_workflow = MagicMock() + mock_quota_service = MagicMock() with ( patch.object( @@ -88,8 +88,8 @@ class TestAsyncWorkflowService: ) as mock_get_workflow, patch.object( async_workflow_service_module, - "QuotaType", - new=SimpleNamespace(WORKFLOW=quota_workflow), + "QuotaService", + new=mock_quota_service, ), patch.object(async_workflow_service_module, "execute_workflow_professional") as mock_professional_task, patch.object(async_workflow_service_module, "execute_workflow_team") as mock_team_task, @@ -102,7 +102,7 @@ class TestAsyncWorkflowService: "repo": mock_repo, "dispatcher_manager_class": mock_dispatcher_manager_class, "dispatcher": mock_dispatcher, - "quota_workflow": quota_workflow, + "quota_service": mock_quota_service, "get_workflow": mock_get_workflow, "professional_task": mock_professional_task, "team_task": mock_team_task, @@ -141,6 +141,9 @@ class TestAsyncWorkflowService: mocks["team_task"].delay.return_value = task_result mocks["sandbox_task"].delay.return_value = task_result + quota_charge_mock = MagicMock() + mocks["quota_service"].reserve.return_value = quota_charge_mock + class DummyAccount: def __init__(self, user_id: str): self.id = user_id @@ -158,8 +161,9 @@ class TestAsyncWorkflowService: assert result.status == "queued" assert result.queue == queue_name - mocks["quota_workflow"].consume.assert_called_once_with("tenant-123") - assert session.commit.call_count == 2 + mocks["quota_service"].reserve.assert_called_once() + quota_charge_mock.commit.assert_called_once() + assert session.commit.call_count == 3 created_log = mocks["repo"].create.call_args[0][0] assert created_log.status == WorkflowTriggerStatus.QUEUED @@ -245,7 +249,7 @@ class TestAsyncWorkflowService: mocks = async_workflow_trigger_mocks mocks["dispatcher"].get_queue_name.return_value = QueuePriority.TEAM mocks["get_workflow"].return_value = workflow - mocks["quota_workflow"].consume.side_effect = QuotaExceededError( + mocks["quota_service"].reserve.side_effect = QuotaExceededError( feature="workflow", tenant_id="tenant-123", required=1, @@ -262,7 +266,7 @@ class TestAsyncWorkflowService: trigger_data=trigger_data, ) - assert session.commit.call_count == 2 + assert session.commit.call_count == 3 updated_log = mocks["repo"].update.call_args[0][0] assert updated_log.status == WorkflowTriggerStatus.RATE_LIMITED assert "Quota limit reached" in updated_log.error @@ -465,7 +469,7 @@ class TestAsyncWorkflowServiceGetWorkflow: # Assert assert result == workflow - workflow_service.get_published_workflow_by_id.assert_called_once_with(app_model, "workflow-123") + workflow_service.get_published_workflow_by_id.assert_called_once_with(app_model, "workflow-123", session=None) workflow_service.get_published_workflow.assert_not_called() def test_should_raise_when_specific_workflow_id_not_found(self): @@ -493,7 +497,7 @@ class TestAsyncWorkflowServiceGetWorkflow: # Assert assert result == workflow - workflow_service.get_published_workflow.assert_called_once_with(app_model) + workflow_service.get_published_workflow.assert_called_once_with(app_model, session=None) workflow_service.get_published_workflow_by_id.assert_not_called() def test_should_raise_when_default_published_workflow_not_found(self): diff --git a/api/tests/unit_tests/services/test_billing_service.py b/api/tests/unit_tests/services/test_billing_service.py index 9ab0171eac..36592196c6 100644 --- a/api/tests/unit_tests/services/test_billing_service.py +++ b/api/tests/unit_tests/services/test_billing_service.py @@ -425,7 +425,7 @@ class TestBillingServiceUsageCalculation: yield mock def test_get_tenant_feature_plan_usage_info(self, mock_send_request): - """Test retrieval of tenant feature plan usage information.""" + """Test retrieval of tenant feature plan usage information (legacy endpoint).""" # Arrange tenant_id = "tenant-123" expected_response = {"features": {"trigger": {"used": 50, "limit": 100}, "workflow": {"used": 20, "limit": 50}}} @@ -438,6 +438,20 @@ class TestBillingServiceUsageCalculation: assert result == expected_response mock_send_request.assert_called_once_with("GET", "/tenant-feature-usage/info", params={"tenant_id": tenant_id}) + def test_get_quota_info(self, mock_send_request): + """Test retrieval of quota info from new endpoint.""" + # Arrange + tenant_id = "tenant-123" + expected_response = {"trigger_event": {"limit": 100, "usage": 30}, "api_rate_limit": {"limit": -1, "usage": 0}} + mock_send_request.return_value = expected_response + + # Act + result = BillingService.get_quota_info(tenant_id) + + # Assert + assert result == expected_response + mock_send_request.assert_called_once_with("GET", "/quota/info", params={"tenant_id": tenant_id}) + def test_update_tenant_feature_plan_usage_positive_delta(self, mock_send_request): """Test updating tenant feature usage with positive delta (adding credits).""" # Arrange @@ -515,6 +529,150 @@ class TestBillingServiceUsageCalculation: ) +class TestBillingServiceQuotaOperations: + """Unit tests for quota reserve/commit/release operations.""" + + @pytest.fixture + def mock_send_request(self): + with patch.object(BillingService, "_send_request") as mock: + yield mock + + def test_quota_reserve_success(self, mock_send_request): + expected = {"reservation_id": "rid-1", "available": 99, "reserved": 1} + mock_send_request.return_value = expected + + result = BillingService.quota_reserve(tenant_id="t1", feature_key="trigger_event", request_id="req-1", amount=1) + + assert result == expected + mock_send_request.assert_called_once_with( + "POST", + "/quota/reserve", + json={"tenant_id": "t1", "feature_key": "trigger_event", "request_id": "req-1", "amount": 1}, + ) + + def test_quota_reserve_coerces_string_to_int(self, mock_send_request): + """Test that TypeAdapter coerces string values to int.""" + mock_send_request.return_value = {"reservation_id": "rid-str", "available": "99", "reserved": "1"} + + result = BillingService.quota_reserve(tenant_id="t1", feature_key="trigger_event", request_id="req-s", amount=1) + + assert result["available"] == 99 + assert isinstance(result["available"], int) + assert result["reserved"] == 1 + assert isinstance(result["reserved"], int) + + def test_quota_reserve_with_meta(self, mock_send_request): + mock_send_request.return_value = {"reservation_id": "rid-2", "available": 98, "reserved": 1} + meta = {"source": "webhook"} + + BillingService.quota_reserve( + tenant_id="t1", feature_key="trigger_event", request_id="req-2", amount=1, meta=meta + ) + + call_json = mock_send_request.call_args[1]["json"] + assert call_json["meta"] == {"source": "webhook"} + + def test_quota_commit_success(self, mock_send_request): + expected = {"available": 98, "reserved": 0, "refunded": 0} + mock_send_request.return_value = expected + + result = BillingService.quota_commit( + tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1", actual_amount=1 + ) + + assert result == expected + mock_send_request.assert_called_once_with( + "POST", + "/quota/commit", + json={ + "tenant_id": "t1", + "feature_key": "trigger_event", + "reservation_id": "rid-1", + "actual_amount": 1, + }, + ) + + def test_quota_commit_coerces_string_to_int(self, mock_send_request): + """Test that TypeAdapter coerces string values to int.""" + mock_send_request.return_value = {"available": "97", "reserved": "0", "refunded": "1"} + + result = BillingService.quota_commit( + tenant_id="t1", feature_key="trigger_event", reservation_id="rid-s", actual_amount=1 + ) + + assert result["available"] == 97 + assert isinstance(result["available"], int) + assert result["refunded"] == 1 + assert isinstance(result["refunded"], int) + + def test_quota_commit_with_meta(self, mock_send_request): + mock_send_request.return_value = {"available": 97, "reserved": 0, "refunded": 0} + meta = {"reason": "partial"} + + BillingService.quota_commit( + tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1", actual_amount=1, meta=meta + ) + + call_json = mock_send_request.call_args[1]["json"] + assert call_json["meta"] == {"reason": "partial"} + + def test_quota_release_success(self, mock_send_request): + expected = {"available": 100, "reserved": 0, "released": 1} + mock_send_request.return_value = expected + + result = BillingService.quota_release(tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1") + + assert result == expected + mock_send_request.assert_called_once_with( + "POST", + "/quota/release", + json={"tenant_id": "t1", "feature_key": "trigger_event", "reservation_id": "rid-1"}, + ) + + def test_quota_release_coerces_string_to_int(self, mock_send_request): + """Test that TypeAdapter coerces string values to int.""" + mock_send_request.return_value = {"available": "100", "reserved": "0", "released": "1"} + + result = BillingService.quota_release(tenant_id="t1", feature_key="trigger_event", reservation_id="rid-s") + + assert result["available"] == 100 + assert isinstance(result["available"], int) + assert result["released"] == 1 + assert isinstance(result["released"], int) + + def test_get_quota_info_coerces_string_to_int(self, mock_send_request): + """Test that TypeAdapter coerces string values to int for get_quota_info.""" + mock_send_request.return_value = { + "trigger_event": {"usage": "42", "limit": "3000", "reset_date": "1700000000"}, + "api_rate_limit": {"usage": "10", "limit": "-1", "reset_date": "-1"}, + } + + result = BillingService.get_quota_info("t1") + + assert result["trigger_event"]["usage"] == 42 + assert isinstance(result["trigger_event"]["usage"], int) + assert result["trigger_event"]["limit"] == 3000 + assert isinstance(result["trigger_event"]["limit"], int) + assert result["trigger_event"]["reset_date"] == 1700000000 + assert isinstance(result["trigger_event"]["reset_date"], int) + assert result["api_rate_limit"]["limit"] == -1 + assert isinstance(result["api_rate_limit"]["limit"], int) + + def test_get_quota_info_accepts_int_values(self, mock_send_request): + """Test that get_quota_info works with native int values.""" + expected = { + "trigger_event": {"usage": 42, "limit": 3000, "reset_date": 1700000000}, + "api_rate_limit": {"usage": 0, "limit": -1}, + } + mock_send_request.return_value = expected + + result = BillingService.get_quota_info("t1") + + assert result["trigger_event"]["usage"] == 42 + assert result["trigger_event"]["limit"] == 3000 + assert result["api_rate_limit"]["limit"] == -1 + + class TestBillingServiceRateLimitEnforcement: """Unit tests for rate limit enforcement mechanisms. diff --git a/api/tests/unit_tests/tasks/test_trigger_processing_tasks.py b/api/tests/unit_tests/tasks/test_trigger_processing_tasks.py new file mode 100644 index 0000000000..59da5cc7a2 --- /dev/null +++ b/api/tests/unit_tests/tasks/test_trigger_processing_tasks.py @@ -0,0 +1,204 @@ +from unittest.mock import MagicMock, patch + +import pytest + +import tasks.trigger_processing_tasks as trigger_processing_tasks_module +from services.errors.app import QuotaExceededError +from tasks.trigger_processing_tasks import dispatch_triggered_workflow + + +class TestDispatchTriggeredWorkflow: + """Unit tests covering branch behaviours of ``dispatch_triggered_workflow``. + + The covered branches are: + - workflow missing for ``plugin_trigger.app_id`` → log + ``continue`` + - ``QuotaService.reserve`` raising ``QuotaExceededError`` → + ``mark_tenant_triggers_rate_limited`` + early ``return`` + - ``trigger_workflow_async`` succeeds → + ``quota_charge.commit()`` + ``dispatched_count`` increments + """ + + @pytest.fixture + def subscription(self): + sub = MagicMock() + sub.id = "subscription-123" + sub.tenant_id = "tenant-123" + sub.provider_id = "langgenius/test_plugin/test_plugin" + sub.endpoint_id = "endpoint-123" + sub.credentials = {} + sub.credential_type = "api_key" + return sub + + @pytest.fixture + def plugin_trigger(self): + trigger = MagicMock() + trigger.id = "plugin-trigger-123" + trigger.app_id = "app-123" + trigger.node_id = "node-123" + return trigger + + @pytest.fixture + def provider_controller(self): + controller = MagicMock() + controller.plugin_unique_identifier = "langgenius/test_plugin:0.0.1" + controller.entity.identity.name = "Test Plugin" + controller.entity.identity.icon = "icon.svg" + controller.entity.identity.icon_dark = "icon_dark.svg" + return controller + + @pytest.fixture + def dispatch_mocks(self, subscription, plugin_trigger, provider_controller): + """Patch all external dependencies reached by ``dispatch_triggered_workflow``. + + Defaults are configured so the code flow can reach the final async + trigger block (line ~385); each test overrides specific handles + (``get_workflows``, ``reserve``, ``create_end_user_batch``, ...) to + drive the path it targets. + """ + session_cm = MagicMock() + session_cm.__enter__.return_value = MagicMock() + session_cm.__exit__.return_value = False + + invoke_response = MagicMock() + invoke_response.cancelled = False + invoke_response.variables = {} + + quota_charge = MagicMock() + + with ( + patch.object( + trigger_processing_tasks_module.TriggerHttpRequestCachingService, + "get_request", + return_value=MagicMock(), + ), + patch.object( + trigger_processing_tasks_module.TriggerHttpRequestCachingService, + "get_payload", + return_value=MagicMock(), + ), + patch.object( + trigger_processing_tasks_module.TriggerSubscriptionOperatorService, + "get_subscriber_triggers", + return_value=[plugin_trigger], + ), + patch.object( + trigger_processing_tasks_module.TriggerManager, + "get_trigger_provider", + return_value=provider_controller, + ), + patch.object( + trigger_processing_tasks_module.TriggerManager, + "invoke_trigger_event", + return_value=invoke_response, + ) as invoke_trigger_event, + patch.object( + trigger_processing_tasks_module.TriggerEventNodeData, + "model_validate", + return_value=MagicMock(), + ), + patch.object( + trigger_processing_tasks_module, + "_get_latest_workflows_by_app_ids", + ) as get_workflows, + patch.object( + trigger_processing_tasks_module.EndUserService, + "create_end_user_batch", + return_value={}, + ) as create_end_user_batch, + patch.object( + trigger_processing_tasks_module.session_factory, + "create_session", + return_value=session_cm, + ), + patch.object( + trigger_processing_tasks_module.QuotaService, + "reserve", + return_value=quota_charge, + ) as reserve, + patch.object( + trigger_processing_tasks_module.AppTriggerService, + "mark_tenant_triggers_rate_limited", + ) as mark_rate_limited, + patch.object( + trigger_processing_tasks_module.AsyncWorkflowService, + "trigger_workflow_async", + ) as trigger_workflow_async, + ): + yield { + "get_workflows": get_workflows, + "reserve": reserve, + "quota_charge": quota_charge, + "mark_rate_limited": mark_rate_limited, + "invoke_trigger_event": invoke_trigger_event, + "invoke_response": invoke_response, + "create_end_user_batch": create_end_user_batch, + "trigger_workflow_async": trigger_workflow_async, + } + + def test_dispatch_skips_when_workflow_missing(self, subscription, dispatch_mocks): + """Covers missing workflow → log + ``continue``.""" + dispatch_mocks["get_workflows"].return_value = {} + + dispatched = dispatch_triggered_workflow( + user_id="user-123", + subscription=subscription, + event_name="test_event", + request_id="request-123", + ) + + assert dispatched == 0 + dispatch_mocks["reserve"].assert_not_called() + dispatch_mocks["invoke_trigger_event"].assert_not_called() + dispatch_mocks["mark_rate_limited"].assert_not_called() + + def test_dispatch_marks_rate_limited_when_quota_exceeded(self, subscription, plugin_trigger, dispatch_mocks): + """Covers QuotaExceededError → mark rate-limited + early return.""" + workflow_mock = MagicMock() + workflow_mock.walk_nodes.return_value = iter( + [(plugin_trigger.node_id, {"type": trigger_processing_tasks_module.TRIGGER_PLUGIN_NODE_TYPE})] + ) + dispatch_mocks["get_workflows"].return_value = {plugin_trigger.app_id: workflow_mock} + dispatch_mocks["reserve"].side_effect = QuotaExceededError( + feature="trigger", tenant_id=subscription.tenant_id, required=1 + ) + + dispatched = dispatch_triggered_workflow( + user_id="user-123", + subscription=subscription, + event_name="test_event", + request_id="request-123", + ) + + assert dispatched == 0 + dispatch_mocks["reserve"].assert_called_once() + dispatch_mocks["mark_rate_limited"].assert_called_once_with(subscription.tenant_id) + dispatch_mocks["invoke_trigger_event"].assert_not_called() + + def test_dispatch_commits_quota_and_counts_when_workflow_triggered( + self, subscription, plugin_trigger, dispatch_mocks + ): + """Happy path: end user exists and async trigger succeeds.""" + workflow_mock = MagicMock() + workflow_mock.id = "workflow-123" + workflow_mock.walk_nodes.return_value = iter( + [(plugin_trigger.node_id, {"type": trigger_processing_tasks_module.TRIGGER_PLUGIN_NODE_TYPE})] + ) + dispatch_mocks["get_workflows"].return_value = {plugin_trigger.app_id: workflow_mock} + + end_user_mock = MagicMock() + dispatch_mocks["create_end_user_batch"].return_value = {plugin_trigger.app_id: end_user_mock} + + dispatched = dispatch_triggered_workflow( + user_id="user-123", + subscription=subscription, + event_name="test_event", + request_id="request-123", + ) + + assert dispatched == 1 + dispatch_mocks["trigger_workflow_async"].assert_called_once() + _, kwargs = dispatch_mocks["trigger_workflow_async"].call_args + assert kwargs["user"] is end_user_mock + dispatch_mocks["quota_charge"].commit.assert_called_once() + dispatch_mocks["quota_charge"].refund.assert_not_called() + dispatch_mocks["mark_rate_limited"].assert_not_called() From 3e826c00000056e74363fe53c067b4b45f2da805 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 09:59:22 +0800 Subject: [PATCH 024/128] chore(deps): bump anthropics/claude-code-action from 1.0.101 to 1.0.107 in the github-actions-dependencies group (#35579) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/translate-i18n-claude.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/translate-i18n-claude.yml b/.github/workflows/translate-i18n-claude.yml index 0294e8a859..5f48c22c56 100644 --- a/.github/workflows/translate-i18n-claude.yml +++ b/.github/workflows/translate-i18n-claude.yml @@ -158,7 +158,7 @@ jobs: - name: Run Claude Code for Translation Sync if: steps.context.outputs.CHANGED_FILES != '' - uses: anthropics/claude-code-action@38ec876110f9fbf8b950c79f534430740c3ac009 # v1.0.101 + uses: anthropics/claude-code-action@567fe954a4527e81f132d87d1bdbcc94f7737434 # v1.0.107 with: anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} github_token: ${{ secrets.GITHUB_TOKEN }} From 2d6eaf69f9613d254ad70bba35421329a97a97c5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:08:59 +0800 Subject: [PATCH 025/128] chore(deps-dev): bump the dev group in /api with 5 updates (#35581) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com> --- api/pyproject.toml | 10 +++--- api/uv.lock | 86 +++++++++++++++++++++++----------------------- 2 files changed, 48 insertions(+), 48 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index f8d26a376d..771631da3f 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -118,7 +118,7 @@ dev = [ "faker>=40.15.0", "lxml-stubs>=0.5.1", "basedpyright>=1.39.3", - "ruff>=0.15.11", + "ruff>=0.15.12", "pytest>=9.0.3", "pytest-benchmark>=5.2.3", "pytest-cov>=7.1.0", @@ -145,7 +145,7 @@ dev = [ "types-pexpect>=4.9.0", "types-protobuf>=7.34.1", "types-psutil>=7.2.2", - "types-psycopg2>=2.9.21", + "types-psycopg2>=2.9.21.20260422", "types-pygments>=2.20.0", "types-pymysql>=1.1.0", "types-python-dateutil>=2.9.0", @@ -158,9 +158,9 @@ dev = [ "types-tensorflow>=2.18.0.20260408", "types-tqdm>=4.67.3.20260408", "types-ujson>=5.10.0", - "boto3-stubs>=1.42.92", + "boto3-stubs>=1.42.96", "types-jmespath>=1.1.0.20260408", - "hypothesis>=6.152.1", + "hypothesis>=6.152.3", "types_pyOpenSSL>=24.1.0", "types_cffi>=2.0.0.20260408", "types_setuptools>=82.0.0.20260408", @@ -170,7 +170,7 @@ dev = [ "import-linter>=2.3", "types-redis>=4.6.0.20241004", "celery-types>=0.23.0", - "mypy>=1.20.1", + "mypy>=1.20.2", # "locust>=2.40.4", # Temporarily removed due to compatibility issues. Uncomment when resolved. "pytest-timeout>=2.4.0", "pytest-xdist>=3.8.0", diff --git a/api/uv.lock b/api/uv.lock index d5d541143a..bc6bbf35e1 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -618,15 +618,15 @@ wheels = [ [[package]] name = "boto3-stubs" -version = "1.42.92" +version = "1.42.96" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore-stubs" }, { name = "types-s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fa/b4/7f472d64a89f6aa6b8e8eeadc876667b7e4edfb526c6118efe2b2c98ba17/boto3_stubs-1.42.92.tar.gz", hash = "sha256:4bc934069c5e8c7b3cdd2442569dae14e8272fe207d445bd38aa578b8463638f", size = 102696, upload-time = "2026-04-20T19:55:19.858Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/86/65f45f84621cccc2471871088bab8fe515b4346ba9e48d9001484ec440d6/boto3_stubs-1.42.96.tar.gz", hash = "sha256:1e7819c34d1eae8e5e3cfaf9d144fdcad65aad184b380488871de1d0b2851879", size = 102691, upload-time = "2026-04-24T20:25:13.984Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/ce/2fe2c6456f8dc0b8bb8d80e05e154c7975ec058991bedf54f3aeed634b79/boto3_stubs-1.42.92-py3-none-any.whl", hash = "sha256:b3994e60f0133b2dd3d9a88ceaeef48fa6367d9a9429426e919575768a1ad9c6", size = 70666, upload-time = "2026-04-20T19:55:16.398Z" }, + { url = "https://files.pythonhosted.org/packages/a7/51/bdac1ff9fd4321091183776c5adffce5fc7b4d0fec7e38af9064e24a2497/boto3_stubs-1.42.96-py3-none-any.whl", hash = "sha256:2c112e257f40006147a53f6f62075804689154271973b2807f5656feaa804216", size = 70668, upload-time = "2026-04-24T20:25:09.736Z" }, ] [package.optional-dependencies] @@ -1619,15 +1619,15 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "basedpyright", specifier = ">=1.39.3" }, - { name = "boto3-stubs", specifier = ">=1.42.92" }, + { name = "boto3-stubs", specifier = ">=1.42.96" }, { name = "celery-types", specifier = ">=0.23.0" }, { name = "coverage", specifier = ">=7.13.4" }, { name = "dotenv-linter", specifier = ">=0.7.0" }, { name = "faker", specifier = ">=40.15.0" }, - { name = "hypothesis", specifier = ">=6.152.1" }, + { name = "hypothesis", specifier = ">=6.152.3" }, { name = "import-linter", specifier = ">=2.3" }, { name = "lxml-stubs", specifier = ">=0.5.1" }, - { name = "mypy", specifier = ">=1.20.1" }, + { name = "mypy", specifier = ">=1.20.2" }, { name = "pandas-stubs", specifier = ">=3.0.0" }, { name = "pyrefly", specifier = ">=0.62.0" }, { name = "pytest", specifier = ">=9.0.3" }, @@ -1637,7 +1637,7 @@ dev = [ { name = "pytest-mock", specifier = ">=3.15.1" }, { name = "pytest-timeout", specifier = ">=2.4.0" }, { name = "pytest-xdist", specifier = ">=3.8.0" }, - { name = "ruff", specifier = ">=0.15.11" }, + { name = "ruff", specifier = ">=0.15.12" }, { name = "scipy-stubs", specifier = ">=1.17.1.4" }, { name = "testcontainers", specifier = ">=4.14.2" }, { name = "types-aiofiles", specifier = ">=25.1.0" }, @@ -1662,7 +1662,7 @@ dev = [ { name = "types-pexpect", specifier = ">=4.9.0" }, { name = "types-protobuf", specifier = ">=7.34.1" }, { name = "types-psutil", specifier = ">=7.2.2" }, - { name = "types-psycopg2", specifier = ">=2.9.21" }, + { name = "types-psycopg2", specifier = ">=2.9.21.20260422" }, { name = "types-pygments", specifier = ">=2.20.0" }, { name = "types-pymysql", specifier = ">=1.1.0" }, { name = "types-pyopenssl", specifier = ">=24.1.0" }, @@ -3319,14 +3319,14 @@ wheels = [ [[package]] name = "hypothesis" -version = "6.152.1" +version = "6.152.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "sortedcontainers" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/b1/c32bcddb9aab9e3abc700f1f56faf14e7655c64a16ca47701a57362276ea/hypothesis-6.152.1.tar.gz", hash = "sha256:4f4ed934eee295dd84ee97592477d23e8dc03e9f12ae0ee30a4e7c9ef3fca3b0", size = 465029, upload-time = "2026-04-14T22:29:24.062Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/90/fc0b263b6f2622e5f8d2aa93f2e95ba79718a5faa7d2a74bfab10d6b0905/hypothesis-6.152.3.tar.gz", hash = "sha256:c4e5300d3755b6c8a270a28fe5abff40153e927328e89d2bb0229c1384618998", size = 466478, upload-time = "2026-04-26T17:31:07.657Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/83/860fb3075e00b0fc19a22a2301bc3c96f00437558c3911bdd0a3573a4a53/hypothesis-6.152.1-py3-none-any.whl", hash = "sha256:40a3619d9e0cb97b018857c7986f75cf5de2e5ec0fa8a0b172d00747758f749e", size = 530752, upload-time = "2026-04-14T22:29:20.893Z" }, + { url = "https://files.pythonhosted.org/packages/90/38/15475b91a4c12721d2be3349e9d6cf8649c76ed9bc1287e2de7c8d06c261/hypothesis-6.152.3-py3-none-any.whl", hash = "sha256:4b47f00916c858ed49cf870a2f08b04e5fff5afae0bb78f3b4a6d9c74fd6c7bc", size = 532154, upload-time = "2026-04-26T17:31:04.42Z" }, ] [[package]] @@ -3947,7 +3947,7 @@ wheels = [ [[package]] name = "mypy" -version = "1.20.1" +version = "1.20.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, @@ -3955,16 +3955,16 @@ dependencies = [ { name = "pathspec" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/3d/5b373635b3146264eb7a68d09e5ca11c305bbb058dfffbb47c47daf4f632/mypy-1.20.1.tar.gz", hash = "sha256:6fc3f4ecd52de81648fed1945498bf42fa2993ddfad67c9056df36ae5757f804", size = 3815892, upload-time = "2026-04-13T02:46:51.474Z" } +sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/69/1b/75a7c825a02781ca10bc2f2f12fba2af5202f6d6005aad8d2d1f264d8d78/mypy-1.20.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:36ee2b9c6599c230fea89bbd79f401f9f9f8e9fcf0c777827789b19b7da90f51", size = 14494077, upload-time = "2026-04-13T02:45:55.085Z" }, - { url = "https://files.pythonhosted.org/packages/b0/54/5e5a569ea5c2b4d48b729fb32aa936eeb4246e4fc3e6f5b3d36a2dfbefb9/mypy-1.20.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fba3fb0968a7b48806b0c90f38d39296f10766885a94c83bd21399de1e14eb28", size = 13319495, upload-time = "2026-04-13T02:45:29.674Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a4/a1945b19f33e91721b59deee3abb484f2fa5922adc33bb166daf5325d76d/mypy-1.20.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef1415a637cd3627d6304dfbeddbadd21079dafc2a8a753c477ce4fc0c2af54f", size = 13696948, upload-time = "2026-04-13T02:46:15.006Z" }, - { url = "https://files.pythonhosted.org/packages/b2/c6/75e969781c2359b2f9c15b061f28ec6d67c8b61865ceda176e85c8e7f2de/mypy-1.20.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef3461b1ad5cd446e540016e90b5984657edda39f982f4cc45ca317b628f5a37", size = 14706744, upload-time = "2026-04-13T02:46:00.482Z" }, - { url = "https://files.pythonhosted.org/packages/a8/6e/b221b1de981fc4262fe3e0bf9ec272d292dfe42394a689c2d49765c144c4/mypy-1.20.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:542dd63c9e1339b6092eb25bd515f3a32a1453aee8c9521d2ddb17dacd840237", size = 14949035, upload-time = "2026-04-13T02:45:06.021Z" }, - { url = "https://files.pythonhosted.org/packages/ca/4b/298ba2de0aafc0da3ff2288da06884aae7ba6489bc247c933f87847c41b3/mypy-1.20.1-cp312-cp312-win_amd64.whl", hash = "sha256:1d55c7cd8ca22e31f93af2a01160a9e95465b5878de23dba7e48116052f20a8d", size = 10883216, upload-time = "2026-04-13T02:45:47.232Z" }, - { url = "https://files.pythonhosted.org/packages/c7/f9/5e25b8f0b8cb92f080bfed9c21d3279b2a0b6a601cdca369a039ba84789d/mypy-1.20.1-cp312-cp312-win_arm64.whl", hash = "sha256:f5b84a79070586e0d353ee07b719d9d0a4aa7c8ee90c0ea97747e98cbe193019", size = 9814299, upload-time = "2026-04-13T02:45:21.934Z" }, - { url = "https://files.pythonhosted.org/packages/d8/28/926bd972388e65a39ee98e188ccf67e81beb3aacfd5d6b310051772d974b/mypy-1.20.1-py3-none-any.whl", hash = "sha256:1aae28507f253fe82d883790d1c0a0d35798a810117c88184097fe8881052f06", size = 2636553, upload-time = "2026-04-13T02:46:30.45Z" }, + { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" }, + { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" }, + { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" }, + { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" }, ] [[package]] @@ -5889,27 +5889,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.11" +version = "0.15.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, - { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, - { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, - { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, - { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, - { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, - { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, - { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, - { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, - { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, - { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, + { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, + { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, + { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, + { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, + { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, + { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, + { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, + { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, + { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, ] [[package]] @@ -6782,11 +6782,11 @@ wheels = [ [[package]] name = "types-psycopg2" -version = "2.9.21.20260408" +version = "2.9.21.20260422" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/24/d8ae11a0c056535557aaabeb7d7838423abdfdcf1e5f8dfb2c04d316c65d/types_psycopg2-2.9.21.20260408.tar.gz", hash = "sha256:bb65cd12f53b6633077fd782607a33065e1f3bf585219c9f786b61ad2b72211c", size = 27078, upload-time = "2026-04-08T04:26:15.848Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/a2/ecb04604074a7f2e82231ab1f2d3b5a792589aa3c21a597cb3232a38ece3/types_psycopg2-2.9.21.20260422.tar.gz", hash = "sha256:ad7574fa8e25d9aa96ab96cd280c4dee20872725cd1fe6a6d3facc354f2644d4", size = 27123, upload-time = "2026-04-22T04:36:33.263Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/fe/9aab9239640107b6e46afddcee578a916b8b98bfee36e03da5b0d2c95124/types_psycopg2-2.9.21.20260408-py3-none-any.whl", hash = "sha256:49b086bfc9e0ce901c6537403ead1c19c75275571040b037af0248a8e48c322f", size = 24921, upload-time = "2026-04-08T04:26:14.715Z" }, + { url = "https://files.pythonhosted.org/packages/61/08/82f86c2d0a7ae4d335c6fe3c4ad193c4a57f0d6bfe1a676289cf63667275/types_psycopg2-2.9.21.20260422-py3-none-any.whl", hash = "sha256:e240684ac37946c5a2a058b04ea1f2fd0e4ee2655719b8c3ec9abf37f96da5ba", size = 24918, upload-time = "2026-04-22T04:36:32.108Z" }, ] [[package]] From 4da8afaed570c133cb16ae3483cbcc12fa7b4f5a Mon Sep 17 00:00:00 2001 From: JzoNg Date: Mon, 27 Apr 2026 10:38:51 +0800 Subject: [PATCH 026/128] fix(web): use number_limits for file_list type --- .../base/prompt-editor/plugins/hitl-input-block/input-field.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx index f1fdda8c00..7ceb2dcc80 100644 --- a/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx +++ b/web/app/components/base/prompt-editor/plugins/hitl-input-block/input-field.tsx @@ -174,7 +174,7 @@ const InputField: React.FC = ({ allowed_file_extensions: payload.allowed_file_extensions || [], allowed_file_types: payload.allowed_file_types, allowed_file_upload_methods: payload.allowed_file_upload_methods, - number_limits: payload.number_limits ?? payload.max_length, + number_limits: payload.max_length, } }) }, []) From 2326fb7a835d8e0438c8c0b3791405bbc73c1188 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:44:37 +0800 Subject: [PATCH 027/128] chore(deps): bump psycopg2-binary from 2.9.11 to 2.9.12 in /api in the database group (#35577) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/pyproject.toml | 2 +- api/uv.lock | 28 ++++++++++++++-------------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 771631da3f..846dd84c6e 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "google-api-python-client>=2.194.0", "gunicorn>=25.3.0", "psycogreen>=1.0.2", - "psycopg2-binary>=2.9.11", + "psycopg2-binary>=2.9.12", "python-socketio>=5.13.0", "redis[hiredis]>=7.4.0", "sendgrid>=6.12.5", diff --git a/api/uv.lock b/api/uv.lock index bc6bbf35e1..e75544c88b 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1607,7 +1607,7 @@ requires-dist = [ { name = "opentelemetry-instrumentation-sqlalchemy", specifier = ">=0.62b0,<1.0.0" }, { name = "opentelemetry-propagator-b3", specifier = ">=1.41.0,<2.0.0" }, { name = "psycogreen", specifier = ">=1.0.2" }, - { name = "psycopg2-binary", specifier = ">=2.9.11" }, + { name = "psycopg2-binary", specifier = ">=2.9.12" }, { name = "python-socketio", specifier = ">=5.13.0" }, { name = "readabilipy", specifier = ">=0.3.0,<1.0.0" }, { name = "redis", extras = ["hiredis"], specifier = ">=7.4.0" }, @@ -4982,21 +4982,21 @@ wheels = [ [[package]] name = "psycopg2-binary" -version = "2.9.11" +version = "2.9.12" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/60/a3624f79acea344c16fbef3a94d28b89a8042ddfb8f3e4ca83f538671409/psycopg2_binary-2.9.12.tar.gz", hash = "sha256:5ac9444edc768c02a6b6a591f070b8aae28ff3a99be57560ac996001580f294c", size = 379686, upload-time = "2026-04-21T09:40:34.304Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, - { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, - { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, - { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, - { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, - { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, - { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, - { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, - { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/ef4ef3c8e15083df90ca35265cfd1a081a2f0cc07bb229c6314c6af817f4/psycopg2_binary-2.9.12-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5cdc05117180c5fa9c40eea8ea559ce64d73824c39d928b7da9fb5f6a9392433", size = 3712459, upload-time = "2026-04-20T23:34:30.549Z" }, + { url = "https://files.pythonhosted.org/packages/b5/01/3dd14e46ba48c1e1a6ec58ee599fa1b5efa00c246d5046cd903d0eeb1af1/psycopg2_binary-2.9.12-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3227a3bc228c10d21011a99245edca923e4e8bf461857e869a507d9a41fe9f6", size = 3822936, upload-time = "2026-04-20T23:34:32.77Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/0640e4901119d8a9f7a1784b927f494e2198e213ceb593753d1f2c8b1b30/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:995ce929eede89db6254b50827e2b7fd61e50d11f0b116b29fffe4a2e53c4580", size = 4578676, upload-time = "2026-04-20T23:34:35.18Z" }, + { url = "https://files.pythonhosted.org/packages/b0/55/44df3965b5f297c50cc0b1b594a31c67d6127a9d133045b8a66611b14dfb/psycopg2_binary-2.9.12-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9fe06d93e72f1c048e731a2e3e7854a5bfaa58fc736068df90b352cefe66f03f", size = 4274917, upload-time = "2026-04-20T23:34:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/b0/4b/74535248b1eac0c9336862e8617c765ac94dac76f9e25d7c4a79588c8907/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40e7b28b63aaf737cb3a1edc3a9bbc9a9f4ad3dcb7152e8c1130e4050eddcb7d", size = 5894843, upload-time = "2026-04-20T23:34:40.856Z" }, + { url = "https://files.pythonhosted.org/packages/f2/ba/f1bf8d2ae71868ad800b661099086ee52bc0f8d9f05be1acd8ebb06757cc/psycopg2_binary-2.9.12-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:89d19a9f7899e8eb0656a2b3a08e0da04c720a06db6e0033eab5928aabe60fa9", size = 4110556, upload-time = "2026-04-20T23:34:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/45/46/c15706c338403b7c420bcc0c2905aad116cc064545686d8bf85f1999ea00/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:612b965daee295ae2da8f8218ce1d274645dc76ef3f1abf6a0a94fd57eff876d", size = 3655714, upload-time = "2026-04-20T23:34:46.233Z" }, + { url = "https://files.pythonhosted.org/packages/b3/7c/a2d5dc09b64a4564db242a0fe418fde7d33f6f8259dd2c5b9d7def00fb5a/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b9a339b79d37c1b45f3235265f07cdeb0cb5ad7acd2ac7720a5920989c17c24e", size = 3301154, upload-time = "2026-04-20T23:34:49.528Z" }, + { url = "https://files.pythonhosted.org/packages/c0/e8/cc8c9a4ce71461f9ec548d38cadc41dc184b34c73e6455450775a9334ccd/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3471336e1acfd9c7fe507b8bad5af9317b6a89294f9eb37bd9a030bb7bebcdc6", size = 3048882, upload-time = "2026-04-20T23:34:51.86Z" }, + { url = "https://files.pythonhosted.org/packages/19/6a/31e2296bc0787c5ab75d3d118e40b239db8151b5192b90b77c72bc9256e9/psycopg2_binary-2.9.12-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7af18183109e23502c8b2ae7f6926c0882766f35b5175a4cd737ad825e4d7a1b", size = 3351298, upload-time = "2026-04-20T23:34:54.124Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a8/75f4e3e11203b590150abed2cf7794b9c9c9f7eceddae955191138b44dde/psycopg2_binary-2.9.12-cp312-cp312-win_amd64.whl", hash = "sha256:398fcd4db988c7d7d3713e2b8e18939776fd3fb447052daae4f24fa39daede4c", size = 2757230, upload-time = "2026-04-20T23:34:56.242Z" }, ] [[package]] From 295fb6e74a5253caf6ba64cc545fc1e04b82ac86 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:46:29 +0800 Subject: [PATCH 028/128] chore(deps): bump the opentelemetry group in /api with 7 updates (#35576) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/pyproject.toml | 4 +- api/uv.lock | 94 +++++++++++++++++++++++----------------------- 2 files changed, 49 insertions(+), 49 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 846dd84c6e..2118a123b0 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -33,13 +33,13 @@ dependencies = [ "flask-restx>=1.3.2,<2.0.0", "google-cloud-aiplatform>=1.148.1,<2.0.0", "httpx[socks]>=0.28.1,<1.0.0", - "opentelemetry-distro>=0.62b0,<1.0.0", + "opentelemetry-distro>=0.62b1,<1.0.0", "opentelemetry-instrumentation-celery>=0.62b0,<1.0.0", "opentelemetry-instrumentation-flask>=0.62b0,<1.0.0", "opentelemetry-instrumentation-httpx>=0.62b0,<1.0.0", "opentelemetry-instrumentation-redis>=0.62b0,<1.0.0", "opentelemetry-instrumentation-sqlalchemy>=0.62b0,<1.0.0", - "opentelemetry-propagator-b3>=1.41.0,<2.0.0", + "opentelemetry-propagator-b3>=1.41.1,<2.0.0", "readabilipy>=0.3.0,<1.0.0", "resend>=2.27.0,<3.0.0", diff --git a/api/uv.lock b/api/uv.lock index e75544c88b..fe399f7acf 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1599,13 +1599,13 @@ requires-dist = [ { name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0.0" }, { name = "httpx-sse", specifier = "~=0.4.0" }, { name = "json-repair", specifier = "~=0.59.4" }, - { name = "opentelemetry-distro", specifier = ">=0.62b0,<1.0.0" }, + { name = "opentelemetry-distro", specifier = ">=0.62b1,<1.0.0" }, { name = "opentelemetry-instrumentation-celery", specifier = ">=0.62b0,<1.0.0" }, { name = "opentelemetry-instrumentation-flask", specifier = ">=0.62b0,<1.0.0" }, { name = "opentelemetry-instrumentation-httpx", specifier = ">=0.62b0,<1.0.0" }, { name = "opentelemetry-instrumentation-redis", specifier = ">=0.62b0,<1.0.0" }, { name = "opentelemetry-instrumentation-sqlalchemy", specifier = ">=0.62b0,<1.0.0" }, - { name = "opentelemetry-propagator-b3", specifier = ">=1.41.0,<2.0.0" }, + { name = "opentelemetry-propagator-b3", specifier = ">=1.41.1,<2.0.0" }, { name = "psycogreen", specifier = ">=1.0.2" }, { name = "psycopg2-binary", specifier = ">=2.9.12" }, { name = "python-socketio", specifier = ">=5.13.0" }, @@ -4235,29 +4235,29 @@ wheels = [ [[package]] name = "opentelemetry-api" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "importlib-metadata" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/47/8e/3778a7e87801d994869a9396b9fc2a289e5f9be91ff54a27d41eace494b0/opentelemetry_api-1.41.0.tar.gz", hash = "sha256:9421d911326ec12dee8bc933f7839090cad7a3f13fcfb0f9e82f8174dc003c09", size = 71416, upload-time = "2026-04-09T14:38:34.544Z" } +sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/ee/99ab786653b3bda9c37ade7e24a7b607a1b1f696063172768417539d876d/opentelemetry_api-1.41.0-py3-none-any.whl", hash = "sha256:0e77c806e6a89c9e4f8d372034622f3e1418a11bdbe1c80a50b3d3397ad0fa4f", size = 69007, upload-time = "2026-04-09T14:38:11.833Z" }, + { url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" }, ] [[package]] name = "opentelemetry-distro" -version = "0.62b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-sdk" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/c6/52b0dbcc8fbdecf179047921940516cbb8aaf05f6b737faa526ad76fec51/opentelemetry_distro-0.62b0.tar.gz", hash = "sha256:aa0308fbe50ad8f17d4446982dbf26870e20b8031ba38d8e1224ecf7aedd3184", size = 2611, upload-time = "2026-04-09T14:40:20.404Z" } +sdist = { url = "https://files.pythonhosted.org/packages/45/f1/314e5015e353a001948e03f48a6935ca7ef00e99107b8e3e63871426b0f6/opentelemetry_distro-0.62b1.tar.gz", hash = "sha256:0169b128b9d6d5cab809ae4c4fb3d576bfc5d3f30b32d8a43b770b587f04f253", size = 2606, upload-time = "2026-04-24T13:22:29.403Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/7e/5858bba1c7ed880c7b0fe7d9a1ea40ab8affd18c9ebc1e16c2d69c501da1/opentelemetry_distro-0.62b0-py3-none-any.whl", hash = "sha256:23e9065a35cef12868ad5efb18ce9c88a9103800256b318dec4c9c850c6c78c1", size = 3348, upload-time = "2026-04-09T14:39:17.406Z" }, + { url = "https://files.pythonhosted.org/packages/b9/19/c58c119a299298f03d0797fcb780f221880e8d725959c71bcfb4ae034738/opentelemetry_distro-0.62b1-py3-none-any.whl", hash = "sha256:fd938de6ca1d047ffd15a65fa09d89f4b4ca7dd97ef25601a12d6d10efd693a0", size = 3348, upload-time = "2026-04-24T13:21:27.389Z" }, ] [[package]] @@ -4323,7 +4323,7 @@ wheels = [ [[package]] name = "opentelemetry-instrumentation" -version = "0.62b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -4331,14 +4331,14 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f9/fd/b8e90bb340957f059084376f94cff336b0e871a42feba7d3f7342365e987/opentelemetry_instrumentation-0.62b0.tar.gz", hash = "sha256:aa1b0b9ab2e1722c2a8a5384fb016fc28d30bba51826676c8036074790d2861e", size = 34042, upload-time = "2026-04-09T14:40:22.843Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/cb/0523b92c112a6cc70be43724343dc45225d3af134419844d7879a07755d4/opentelemetry_instrumentation-0.62b1.tar.gz", hash = "sha256:90e92a905ba4f84db06ac3aec96701df6c079b2d66e9379f8739f0a1bdcc7f45", size = 34043, upload-time = "2026-04-24T13:22:31.997Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/b6/3356d2e335e3c449c5183e9b023f30f04f1b7073a6583c68745ea2e704b1/opentelemetry_instrumentation-0.62b0-py3-none-any.whl", hash = "sha256:30d4e76486eae64fb095264a70c2c809c4bed17b73373e53091470661f7d477c", size = 34158, upload-time = "2026-04-09T14:39:21.428Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0f/45adbaea1f81b847cffdcee4f4b5f89297e42facf7fac78c7aaac4c38e75/opentelemetry_instrumentation-0.62b1-py3-none-any.whl", hash = "sha256:976fc6e640f2006599e97429c949e622c108d0c17c2059347d1e6c93c707f257", size = 34163, upload-time = "2026-04-24T13:21:31.722Z" }, ] [[package]] name = "opentelemetry-instrumentation-asgi" -version = "0.62b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "asgiref" }, @@ -4347,28 +4347,28 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/38/999bf777774878971c2716de4b7a03cd57a7decb4af25090e703b79fa0e5/opentelemetry_instrumentation_asgi-0.62b0.tar.gz", hash = "sha256:93cde8c62e5918a3c1ff9ba020518127300e5e0816b7e8b14baf46a26ba619fc", size = 26779, upload-time = "2026-04-09T14:40:26.566Z" } +sdist = { url = "https://files.pythonhosted.org/packages/54/43/b2f0703ff46718ff7b17d7fbf8e9d7f20e26a23c7c325092dd762d09cf9d/opentelemetry_instrumentation_asgi-0.62b1.tar.gz", hash = "sha256:7cf5f5d5c493bbb1edd2bd6d51fa879d964e94048904017258a32ffa47329310", size = 26781, upload-time = "2026-04-24T13:22:37.158Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/cf/29df82f5870178143bdb5c9a7be044b9f78c71e1c5dcf995242e86d80158/opentelemetry_instrumentation_asgi-0.62b0-py3-none-any.whl", hash = "sha256:89b62a6f996b260b162f515c25e6d78e39286e4cbe2f935899e51b32f31027e2", size = 17011, upload-time = "2026-04-09T14:39:27.305Z" }, + { url = "https://files.pythonhosted.org/packages/d0/41/968c1fe12fb90abffca6620e65d4af91451c02ecca8f74a17a62cac490de/opentelemetry_instrumentation_asgi-0.62b1-py3-none-any.whl", hash = "sha256:b7f89be48528512619bd54fa2459f72afb1695ba71d7024d382ad96d467e7fa8", size = 17011, upload-time = "2026-04-24T13:21:38.006Z" }, ] [[package]] name = "opentelemetry-instrumentation-celery" -version = "0.62b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-instrumentation" }, { name = "opentelemetry-semantic-conventions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/01/b4/20a3c8c669dc45aa3703c0370041d67e8be613f1829523cdaf634a5f9626/opentelemetry_instrumentation_celery-0.62b0.tar.gz", hash = "sha256:55e8fa48e5b886bcca448fa32e28a6cc2165157745e8328de479a826d3903095", size = 14808, upload-time = "2026-04-09T14:40:31.603Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/86/9e78c174b2f6ea92af3f99aa7488807b74290a5cd44a8e05bfbfd7b109be/opentelemetry_instrumentation_celery-0.62b1.tar.gz", hash = "sha256:f0035abd464a2989414a9c5ecdd79a25c87bd8c43f96c7f39e07000c6f25dfef", size = 14809, upload-time = "2026-04-24T13:22:45.656Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/60/cf951e6bd6ec62ec55bd2384e0ba9841ea38f2d128c773d85dc60da97172/opentelemetry_instrumentation_celery-0.62b0-py3-none-any.whl", hash = "sha256:cadfd3e65287a36099dce5ba7e05d98e4c5f9479a455241e01d140ecc5c10935", size = 13864, upload-time = "2026-04-09T14:39:35.009Z" }, + { url = "https://files.pythonhosted.org/packages/24/51/f38a31ac8f8e3bd365f301f697661679addaf548d52a05cfdde4448a5493/opentelemetry_instrumentation_celery-0.62b1-py3-none-any.whl", hash = "sha256:50567a47b7adc4ea552d09709de4d73fea7b4ff24ab0e9d38739d03fcd3f95ef", size = 13864, upload-time = "2026-04-24T13:21:46.557Z" }, ] [[package]] name = "opentelemetry-instrumentation-fastapi" -version = "0.62b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -4377,14 +4377,14 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/37/09/92740c6d114d1bef392557a03ae6de64065c83c1b331dae9b57fe718497c/opentelemetry_instrumentation_fastapi-0.62b0.tar.gz", hash = "sha256:e4748e4e575077e08beaf2c5d2f369da63dd90882d89d73c4192a97356637dec", size = 25056, upload-time = "2026-04-09T14:40:36.438Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/38/91780475a25370b6d483afbaed3e1e170459d6351c5f7c08d66b65e2172e/opentelemetry_instrumentation_fastapi-0.62b1.tar.gz", hash = "sha256:b377d4ba32868fb1ff0f64da3fcdd3aa154d698fc83d65f5d380ea21bf31ee19", size = 25054, upload-time = "2026-04-24T13:22:50.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/64/bb/186ffe0fde0ad33ceb50e1d3596cc849b732d3b825592a6a507a40c8c49b/opentelemetry_instrumentation_fastapi-0.62b0-py3-none-any.whl", hash = "sha256:06d3272ad15f9daea5a0a27c32831aff376110a4b0394197120256ef6d610e6e", size = 13482, upload-time = "2026-04-09T14:39:43.446Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6f/602e4081d3fe82731aff7e3e9c2f1662d85701841d6dc25f16a1874e11cd/opentelemetry_instrumentation_fastapi-0.62b1-py3-none-any.whl", hash = "sha256:93fa9cc4f315819aee5f4fceb6196c1e5b0fbd789c5520c631de228bd3e5285b", size = 13484, upload-time = "2026-04-24T13:21:54.538Z" }, ] [[package]] name = "opentelemetry-instrumentation-flask" -version = "0.62b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -4394,14 +4394,14 @@ dependencies = [ { name = "opentelemetry-util-http" }, { name = "packaging" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8e/86/522294f6a80d59560d8f722da59513d2ed2d53c6178fa109789dacc5dd50/opentelemetry_instrumentation_flask-0.62b0.tar.gz", hash = "sha256:330e903c0e92b06aae32f9eb7b8a923599d7a29440f50841a59dbba34ec6dd9f", size = 24100, upload-time = "2026-04-09T14:40:37.111Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/08/e52e6eab550db1736c5657a7e38484c22a101009e77fc67eb00b272a96c1/opentelemetry_instrumentation_flask-0.62b1.tar.gz", hash = "sha256:37662ad159570dab1e3017a2a415193c014a5798fc32d33f3bdd254469e8c69a", size = 24100, upload-time = "2026-04-24T13:22:50.845Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bc/c8/9f3bb38281bcb50c93c3d2358b303645f6917bf972c167484c09f9a97ff1/opentelemetry_instrumentation_flask-0.62b0-py3-none-any.whl", hash = "sha256:8c1f8986ec3887d08899d2eb654625252c929105174911b3b50dcf12b1001807", size = 16006, upload-time = "2026-04-09T14:39:44.401Z" }, + { url = "https://files.pythonhosted.org/packages/2b/58/d0e5e82d225365987bd192576095b1125f6b172decc4db79963373c92b74/opentelemetry_instrumentation_flask-0.62b1-py3-none-any.whl", hash = "sha256:6df32684a7dd5dab5feb499c0748a4628b3fd139bffd8171326fb479aa525367", size = 16007, upload-time = "2026-04-24T13:21:55.462Z" }, ] [[package]] name = "opentelemetry-instrumentation-httpx" -version = "0.62b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -4410,14 +4410,14 @@ dependencies = [ { name = "opentelemetry-util-http" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/77/a7/63e2c6325c8e99cd9b8e0229a8b61c37520ee537214a2c8d514e84486a94/opentelemetry_instrumentation_httpx-0.62b0.tar.gz", hash = "sha256:d865398db3f3c289ba226e355bf4d94460a4301c0c8916e3136caea55ae18000", size = 24182, upload-time = "2026-04-09T14:40:38.719Z" } +sdist = { url = "https://files.pythonhosted.org/packages/33/cb/7a418e69c7dad281803529cb4f6de1b747d802cca44c38032668690b4836/opentelemetry_instrumentation_httpx-0.62b1.tar.gz", hash = "sha256:a1fac9bcc3a6ef5996a7990563f1af0798468b2c146de535fd598369383fba7e", size = 24181, upload-time = "2026-04-24T13:22:52.124Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5e/7d5fc28487637871b015128cd5dbb3c36f6d343a9098b893bd803d5a9cca/opentelemetry_instrumentation_httpx-0.62b0-py3-none-any.whl", hash = "sha256:c7660b939c12608fec67743126e9b4dc23dceef0ed631c415924966b0d1579e3", size = 17200, upload-time = "2026-04-09T14:39:46.618Z" }, + { url = "https://files.pythonhosted.org/packages/c7/e0/eca824e9492ccec00e055bdd243aeda8eb7c5eda746d98af4d7a2d97ecf3/opentelemetry_instrumentation_httpx-0.62b1-py3-none-any.whl", hash = "sha256:88614015df451d61bc7e73f22524e6f223611f80b6caad2f6bdcbe05fa0df653", size = 17201, upload-time = "2026-04-24T13:21:58.072Z" }, ] [[package]] name = "opentelemetry-instrumentation-redis" -version = "0.62b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -4425,14 +4425,14 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/55/7d/5acdb4e4e36c522f9393cfa91f7a431ee089663c77855e524bc97f993020/opentelemetry_instrumentation_redis-0.62b0.tar.gz", hash = "sha256:513bc6679ee251436f0aff7be7ddab6186637dde09a795a8dc9659103f103bef", size = 14796, upload-time = "2026-04-09T14:40:48.391Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/ff/35414ad80409bd9e472c7959832524c5f2c8f63965af08c41c2b42d3a6a6/opentelemetry_instrumentation_redis-0.62b1.tar.gz", hash = "sha256:2d3c421d95e05ade075bee5becbe34e743b1cdf5bdee2085cb524f88c4f13dcb", size = 14796, upload-time = "2026-04-24T13:23:01.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/42/a13a7da074c972a51c14277e7f747e90037b9d815515c73b802e95897690/opentelemetry_instrumentation_redis-0.62b0-py3-none-any.whl", hash = "sha256:92ada3d7bdf395785f660549b0e6e8e5bac7cab80e7f1369a7d02228b27684c3", size = 15501, upload-time = "2026-04-09T14:40:00.69Z" }, + { url = "https://files.pythonhosted.org/packages/31/37/bc2271f3472e3041eeade8b8da1cfd3b06badae76fe5d0ff135b6285e70c/opentelemetry_instrumentation_redis-0.62b1-py3-none-any.whl", hash = "sha256:9aedd02c1acf631251d1d676634db47da9da04e0a626cd0c7d83fe0eb791d165", size = 15501, upload-time = "2026-04-24T13:22:11.705Z" }, ] [[package]] name = "opentelemetry-instrumentation-sqlalchemy" -version = "0.62b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -4441,14 +4441,14 @@ dependencies = [ { name = "packaging" }, { name = "wrapt" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2a/3d/40adc8c38e5be017ceb230a28ca57ca81981d4dc0c4b902cc930c77fd14f/opentelemetry_instrumentation_sqlalchemy-0.62b0.tar.gz", hash = "sha256:d02f85b83f349e9ef70a34cb3f4c3a3481fa15b11747f09209818663e161cac4", size = 18539, upload-time = "2026-04-09T14:40:50.251Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/53/fa511ab998dd66b4eb66a36d8c262d0604cc5bad7a9c82e923be038dda97/opentelemetry_instrumentation_sqlalchemy-0.62b1.tar.gz", hash = "sha256:bdeac015351a1de057e8ea39f1fe26c9e60ea6bedbf1d5ad6a8262a516b3dc7d", size = 18539, upload-time = "2026-04-24T13:23:03.169Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/e0/77954ac593f34740dc32e28a15fe7170e90f6ba6398eaaa5c88b34c05ed1/opentelemetry_instrumentation_sqlalchemy-0.62b0-py3-none-any.whl", hash = "sha256:ec576e0660080d9d15ce4fa44d2a07fff8cb4b796a84344cb0f2c9e5d6e26f79", size = 15534, upload-time = "2026-04-09T14:40:03.957Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c5/aa2abcf8752a435536901636c5d540ba7a2c0ba2c4e98c7d119482e04262/opentelemetry_instrumentation_sqlalchemy-0.62b1-py3-none-any.whl", hash = "sha256:613542ecd52aabeec83d8813b5c287a3fb6c9ac3cd660694c94c0571f066e972", size = 15536, upload-time = "2026-04-24T13:22:14.767Z" }, ] [[package]] name = "opentelemetry-instrumentation-wsgi" -version = "0.62b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, @@ -4456,22 +4456,22 @@ dependencies = [ { name = "opentelemetry-semantic-conventions" }, { name = "opentelemetry-util-http" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b7/5c/ed45ff053d76c94c59173f2bcde3d61052adb10214f70f028f760aa56625/opentelemetry_instrumentation_wsgi-0.62b0.tar.gz", hash = "sha256:d179f969ecce0c29a15ffd4d982580dfae57c8ff2fd4d9366e299a6d4815e668", size = 19922, upload-time = "2026-04-09T14:40:56.227Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/db/19f1d66cead56e52291fccaa235b07ad45a5c24be1c740301a840c68235a/opentelemetry_instrumentation_wsgi-0.62b1.tar.gz", hash = "sha256:02a364fd9c940a46b19c825c5bfe386b007d5292ef91573894164836953fe831", size = 19919, upload-time = "2026-04-24T13:23:09.796Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f6/cb/753dbbe624df88594fa35a3ff26302fea22623385ed64462f6c8ee7c81eb/opentelemetry_instrumentation_wsgi-0.62b0-py3-none-any.whl", hash = "sha256:2714ab5ab2f35e67dc181ffa3a43fa15313c85c09b4d024c36d72cf1efa29c9a", size = 14628, upload-time = "2026-04-09T14:40:13.529Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0e/60fec0780e16929c821df7c55c4f0bea45d6ef562e662c5f27f47d0ff195/opentelemetry_instrumentation_wsgi-0.62b1-py3-none-any.whl", hash = "sha256:a2df11de0113f504043e2b0fa0288238a93ee49ff607bd5100cb2d3a75bc771f", size = 14629, upload-time = "2026-04-24T13:22:23.951Z" }, ] [[package]] name = "opentelemetry-propagator-b3" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ef/43/cea77e171c014324876104cf2a17c78f5e931408b977b9e64979f950912c/opentelemetry_propagator_b3-1.41.0.tar.gz", hash = "sha256:ef98b715b3a05e8b0b03ebaea1bf295b4ad61a0e306e2d1da81d32af7395e6ad", size = 9588, upload-time = "2026-04-09T14:38:43.328Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8a/ef/e2c1093e21fb9b5f8e44fa6cebacf2cbb60b47b4646d652805dcce48f3b8/opentelemetry_propagator_b3-1.41.1.tar.gz", hash = "sha256:e8563b588aa5f1f90740dcd678f04d5634de2d4e0077b7ca4a177c71a02f745d", size = 9587, upload-time = "2026-04-24T13:15:48.349Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/c1/11345c06774ec6ed6d89e3994dd1f62ad2ab41dfeb312eacd6b2a2323280/opentelemetry_propagator_b3-1.41.0-py3-none-any.whl", hash = "sha256:0b085c26ba59fcb66771226f967e91886bdeef998b3b5f2e9da6a604918c6f90", size = 8923, upload-time = "2026-04-09T14:38:26.865Z" }, + { url = "https://files.pythonhosted.org/packages/c8/78/388ea1ae84fd3d2858c782f0410d73d936ffbd1a54711e45874490c576e7/opentelemetry_propagator_b3-1.41.1-py3-none-any.whl", hash = "sha256:f4b045d0aa4b5c17ac25a371bf3d08173a2f4b8f19a94357e57ae690c15415dc", size = 8921, upload-time = "2026-04-24T13:15:30.408Z" }, ] [[package]] @@ -4488,38 +4488,38 @@ wheels = [ [[package]] name = "opentelemetry-sdk" -version = "1.41.0" +version = "1.41.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f8/0e/a586df1186f9f56b5a0879d52653effc40357b8e88fc50fe300038c3c08b/opentelemetry_sdk-1.41.0.tar.gz", hash = "sha256:7bddf3961131b318fc2d158947971a8e37e38b1cd23470cfb72b624e7cc108bd", size = 230181, upload-time = "2026-04-09T14:38:47.225Z" } +sdist = { url = "https://files.pythonhosted.org/packages/58/d0/54ee30dab82fb0acda23d144502771ff76ef8728459c83c3e89ef9fb1825/opentelemetry_sdk-1.41.1.tar.gz", hash = "sha256:724b615e1215b5aeacda0abb8a6a8922c9a1853068948bd0bd225a56d0c792e6", size = 230180, upload-time = "2026-04-24T13:15:50.991Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/13/a7825118208cb32e6a4edcd0a99f925cbef81e77b3b0aedfd9125583c543/opentelemetry_sdk-1.41.0-py3-none-any.whl", hash = "sha256:a596f5687964a3e0d7f8edfdcf5b79cbca9c93c7025ebf5fb00f398a9443b0bd", size = 180214, upload-time = "2026-04-09T14:38:30.657Z" }, + { url = "https://files.pythonhosted.org/packages/b4/e7/a1420b698aad018e1cf60fdbaaccbe49021fb415e2a0d81c242f4c518f54/opentelemetry_sdk-1.41.1-py3-none-any.whl", hash = "sha256:edee379c126c1bce952b0c812b48fe8ff35b30df0eecf17e98afa4d598b7d85d", size = 180213, upload-time = "2026-04-24T13:15:33.767Z" }, ] [[package]] name = "opentelemetry-semantic-conventions" -version = "0.62b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "opentelemetry-api" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a3/b0/c14f723e86c049b7bf8ff431160d982519b97a7be2857ed2247377397a24/opentelemetry_semantic_conventions-0.62b0.tar.gz", hash = "sha256:cbfb3c8fc259575cf68a6e1b94083cc35adc4a6b06e8cf431efa0d62606c0097", size = 145753, upload-time = "2026-04-09T14:38:48.274Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/de/911ac9e309052aca1b20b2d5549d3db45d1011e1a610e552c6ccdd1b64f8/opentelemetry_semantic_conventions-0.62b1.tar.gz", hash = "sha256:c5cc6e04a7f8c7cdd30be2ed81499fa4e75bfbd52c9cb70d40af1f9cd3619802", size = 145750, upload-time = "2026-04-24T13:15:52.236Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/58/6c/5e86fa1759a525ef91c2d8b79d668574760ff3f900d114297765eb8786cb/opentelemetry_semantic_conventions-0.62b0-py3-none-any.whl", hash = "sha256:0ddac1ce59eaf1a827d9987ab60d9315fb27aea23304144242d1fcad9e16b489", size = 231619, upload-time = "2026-04-09T14:38:32.394Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a6/83dc2ab6fa397ee66fba04fe2e74bdf7be3b3870005359ceb7689103c058/opentelemetry_semantic_conventions-0.62b1-py3-none-any.whl", hash = "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c", size = 231620, upload-time = "2026-04-24T13:15:35.454Z" }, ] [[package]] name = "opentelemetry-util-http" -version = "0.62b0" +version = "0.62b1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9b/e7/830f7c57135158eb8a8efd3f94ab191a89e3b8a49bed314a35ee501da3f2/opentelemetry_util_http-0.62b0.tar.gz", hash = "sha256:a62e4b19b8a432c0de657f167dee3455516136bb9c6ed463ca8063019970d835", size = 11393, upload-time = "2026-04-09T14:40:59.442Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/1b/aa71b63e18d30a8384036b9937f40f7618f8030a7aa213155fb54f6f2b47/opentelemetry_util_http-0.62b1.tar.gz", hash = "sha256:adf6facbb89aef8f8bc566e2f04624942ba08a7b678b3479a91051a8f4dc70a3", size = 11393, upload-time = "2026-04-24T13:23:12.994Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3d/7f/5c1b7d4385852b9e5eacd4e7f9d8b565d3d351d17463b24916ad098adf1a/opentelemetry_util_http-0.62b0-py3-none-any.whl", hash = "sha256:c20462808d8cc95b69b0dc4a3e02a9d36beb663347e96c931f51ffd78bd318ad", size = 9294, upload-time = "2026-04-09T14:40:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/5d/85/a9d9d32161c1ced61346267db4c9702da54f81ec5dc88214bc65c23f4e9d/opentelemetry_util_http-0.62b1-py3-none-any.whl", hash = "sha256:c57e8a6c19fc422c288e6074e882f506f85030b69b7376182f74f9257b9261f0", size = 9295, upload-time = "2026-04-24T13:22:28.078Z" }, ] [[package]] From 859756c4f6a3d808376014199d3defc600519907 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 10:50:20 +0800 Subject: [PATCH 029/128] chore(deps-dev): bump xinference-client from 2.5.0 to 2.7.0 in /api in the vdb group (#35580) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/pyproject.toml | 4 ++-- api/uv.lock | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 2118a123b0..834fdfc68e 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -175,7 +175,7 @@ dev = [ "pytest-timeout>=2.4.0", "pytest-xdist>=3.8.0", "pyrefly>=0.62.0", - "xinference-client>=2.5.0", + "xinference-client>=2.7.0", ] ############################################################ @@ -267,7 +267,7 @@ vdb-vastbase = ["dify-vdb-vastbase"] vdb-vikingdb = ["dify-vdb-vikingdb"] vdb-weaviate = ["dify-vdb-weaviate"] # Optional client used by some tests / integrations (not a vector backend plugin) -vdb-xinference = ["xinference-client>=2.5.0"] +vdb-xinference = ["xinference-client>=2.7.0"] trace-all = [ "dify-trace-aliyun", diff --git a/api/uv.lock b/api/uv.lock index fe399f7acf..18a736d4b7 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -1679,7 +1679,7 @@ dev = [ { name = "types-tensorflow", specifier = ">=2.18.0.20260408" }, { name = "types-tqdm", specifier = ">=4.67.3.20260408" }, { name = "types-ujson", specifier = ">=5.10.0" }, - { name = "xinference-client", specifier = ">=2.5.0" }, + { name = "xinference-client", specifier = ">=2.7.0" }, ] storage = [ { name = "azure-storage-blob", specifier = ">=12.28.0" }, @@ -1776,7 +1776,7 @@ vdb-upstash = [{ name = "dify-vdb-upstash", editable = "providers/vdb/vdb-upstas vdb-vastbase = [{ name = "dify-vdb-vastbase", editable = "providers/vdb/vdb-vastbase" }] vdb-vikingdb = [{ name = "dify-vdb-vikingdb", editable = "providers/vdb/vdb-vikingdb" }] vdb-weaviate = [{ name = "dify-vdb-weaviate", editable = "providers/vdb/vdb-weaviate" }] -vdb-xinference = [{ name = "xinference-client", specifier = ">=2.5.0" }] +vdb-xinference = [{ name = "xinference-client", specifier = ">=2.7.0" }] [[package]] name = "dify-trace-aliyun" @@ -7481,7 +7481,7 @@ wheels = [ [[package]] name = "xinference-client" -version = "2.5.0" +version = "2.7.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp" }, @@ -7489,9 +7489,9 @@ dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d0/8a/4d7c72510f3c462195c2e7aa63559cafcf20f7d1901132d533b7498bab1c/xinference_client-2.5.0.tar.gz", hash = "sha256:0680324e2f438b8b208ca80e8a7e1c22e9152fce54f8c024c75e2ce57bfa5639", size = 58430, upload-time = "2026-04-13T07:21:40.145Z" } +sdist = { url = "https://files.pythonhosted.org/packages/99/86/89723d8a4f862bac49581ef99c9e52c014acf42355710335470062efabf1/xinference_client-2.7.0.tar.gz", hash = "sha256:51c174bc1704a505512550097d4b2025480a840d97bed8097dfbfaec2172ca9e", size = 58577, upload-time = "2026-04-25T14:37:37.345Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/dd/4fd501b8092c01f0775142850e3b601d743edf733077b756defe4a01cc37/xinference_client-2.5.0-py3-none-any.whl", hash = "sha256:bb90f069a2c30ac6ea7453ab37a0fadd34c28b655afa51fe20c18e67a361c269", size = 40006, upload-time = "2026-04-13T07:21:38.851Z" }, + { url = "https://files.pythonhosted.org/packages/1c/22/f9b92941be1cba5b2347211bb04c354a6ba2bad0e7b2da41510f77959327/xinference_client-2.7.0-py3-none-any.whl", hash = "sha256:76377804eb7fd2ece8a7d1e5c517d8aed8b5a511834066e43414ad74bcb34c09", size = 40154, upload-time = "2026-04-25T14:37:35.959Z" }, ] [[package]] From e9940094763be4ee5dd1e7b33e754c946b174875 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Mon, 27 Apr 2026 11:13:05 +0800 Subject: [PATCH 030/128] Revert "fix(web): file_list type" This reverts commit 46747993d49c693613a232fa95a791f038c1bce2. --- .../__tests__/type-select.spec.tsx | 6 +- .../workflow-app/__tests__/utils.spec.ts | 59 +------------------ .../hooks/use-nodes-sync-draft.ts | 5 +- .../workflow-app/hooks/use-workflow-init.ts | 9 +-- web/app/components/workflow-app/utils.ts | 55 +---------------- 5 files changed, 7 insertions(+), 127 deletions(-) diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx index c4f0b5b6d6..1d50350229 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/type-select.spec.tsx @@ -24,7 +24,7 @@ describe('TypeSelector', () => { ) await user.click(screen.getByRole('combobox')) - const numberOption = await screen.findByRole('option', { name: 'Number' }) + const [, numberOption] = await screen.findAllByRole('option') await user.click(numberOption) expect(onSelect).toHaveBeenCalledWith({ value: 'number', name: 'Number' }) @@ -46,10 +46,8 @@ describe('TypeSelector', () => { await user.click(screen.getByRole('combobox')) - const numberOption = await screen.findByRole('option', { name: 'Number' }) + const [, numberOption] = await screen.findAllByRole('option') const popup = numberOption.closest('[data-side]') - if (!popup) - throw new Error('Expected popup container to exist') expect(popup).toHaveClass('w-(--anchor-width)') }) diff --git a/web/app/components/workflow-app/__tests__/utils.spec.ts b/web/app/components/workflow-app/__tests__/utils.spec.ts index 1b47ec9f3c..c8a9fffeec 100644 --- a/web/app/components/workflow-app/__tests__/utils.spec.ts +++ b/web/app/components/workflow-app/__tests__/utils.spec.ts @@ -1,23 +1,11 @@ -import type { Node } from '@/app/components/workflow/types' -import { BlockEnum, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' import { buildInitialFeatures, buildTriggerStatusMap, coerceReplayUserInputs, - normalizeWorkflowNodesForBackend, - normalizeWorkflowNodesForFrontend, } from '../utils' -type HumanInputTestField = { - type: string - output_variable_name: string -} - -type HumanInputTestNode = Node<{ - inputs: HumanInputTestField[] -}> - describe('workflow-app utils', () => { it('should map trigger statuses to enabled and disabled states', () => { expect(buildTriggerStatusMap([ @@ -50,51 +38,6 @@ describe('workflow-app utils', () => { expect(coerceReplayUserInputs(null)).toBeNull() }) - it('should normalize human-input multi-file types between frontend and backend payloads', () => { - const nodes: HumanInputTestNode[] = [ - { - id: 'node-1', - type: 'custom', - position: { x: 0, y: 0 }, - data: { - title: 'Human Input', - desc: '', - type: BlockEnum.HumanInput, - inputs: [ - { type: 'paragraph', output_variable_name: 'summary' }, - { type: 'file-list', output_variable_name: 'attachments' }, - ], - }, - }, - ] - - const backendNodes = normalizeWorkflowNodesForBackend(nodes) as HumanInputTestNode[] - expect(backendNodes[0]!.data.inputs).toEqual([ - { type: 'paragraph', output_variable_name: 'summary' }, - { type: 'file_list', output_variable_name: 'attachments' }, - ]) - - const frontendPayloadNodes: HumanInputTestNode[] = [ - { - ...nodes[0]!, - data: { - ...nodes[0]!.data, - inputs: [ - { type: 'paragraph', output_variable_name: 'summary' }, - { type: 'file_list', output_variable_name: 'attachments' }, - ], - }, - }, - ] - - const frontendNodes = normalizeWorkflowNodesForFrontend(frontendPayloadNodes) as HumanInputTestNode[] - - expect(frontendNodes[0]!.data.inputs).toEqual([ - { type: 'paragraph', output_variable_name: 'summary' }, - { type: 'file-list', output_variable_name: 'attachments' }, - ]) - }) - it('should build initial features with file-upload and feature fallbacks', () => { const result = buildInitialFeatures({ file_upload: { diff --git a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts index 76bceb37bb..0ac528c303 100644 --- a/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/workflow-app/hooks/use-nodes-sync-draft.ts @@ -14,7 +14,6 @@ import { postWithKeepalive } from '@/service/fetch' import { systemFeaturesQueryOptions } from '@/service/system-features' import { syncWorkflowDraft } from '@/service/workflow' import { useWorkflowRefreshDraft } from '.' -import { normalizeWorkflowNodesForBackend } from '../utils' export const useNodesSyncDraft = () => { const store = useStoreApi() @@ -47,14 +46,14 @@ export const useNodesSyncDraft = () => { return null const features = featuresStore!.getState().features - const producedNodes = normalizeWorkflowNodesForBackend(produce(nodes, (draft) => { + const producedNodes = produce(nodes, (draft) => { draft.forEach((node) => { Object.keys(node.data).forEach((key) => { if (key.startsWith('_')) delete node.data[key] }) }) - })) + }) const producedEdges = produce(edges.filter(edge => !edge.data?._isTemp), (draft) => { draft.forEach((edge) => { Object.keys(edge.data).forEach((key) => { diff --git a/web/app/components/workflow-app/hooks/use-workflow-init.ts b/web/app/components/workflow-app/hooks/use-workflow-init.ts index a4018dccce..00bff2919f 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-init.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-init.ts @@ -20,7 +20,6 @@ import { syncWorkflowDraft, } from '@/service/workflow' import { AppModeEnum } from '@/types/app' -import { normalizeWorkflowNodesForFrontend } from '../utils' import { useWorkflowTemplate } from './use-workflow-template' const hasConnectedUserInput = (nodes: Node[] = [], edges: Edge[] = []): boolean => { @@ -59,13 +58,7 @@ export const useWorkflowInit = () => { const handleGetInitialWorkflowData = useCallback(async () => { try { const res = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`) - setData({ - ...res, - graph: { - ...res.graph, - nodes: normalizeWorkflowNodesForFrontend(res.graph.nodes), - }, - }) + setData(res) workflowStore.setState({ envSecrets: (res.environment_variables || []).filter(env => env.value_type === 'secret').reduce((acc, env) => { acc[env.id] = env.value diff --git a/web/app/components/workflow-app/utils.ts b/web/app/components/workflow-app/utils.ts index 64f0b5fc17..df344e333b 100644 --- a/web/app/components/workflow-app/utils.ts +++ b/web/app/components/workflow-app/utils.ts @@ -1,8 +1,7 @@ import type { Features as FeaturesData } from '@/app/components/base/features/types' -import type { Node } from '@/app/components/workflow/types' import type { FileUploadConfigResponse } from '@/models/common' import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' -import { BlockEnum, SupportUploadFileTypes } from '@/app/components/workflow/types' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { TransferMethod } from '@/types/app' type TriggerStatusLike = { @@ -34,15 +33,6 @@ type WorkflowFeaturesLike = { sensitive_word_avoidance?: { enabled?: boolean } } -type HumanInputFieldLike = { - type: unknown - [key: string]: unknown -} - -type HumanInputNodeExtra = { - inputs: HumanInputFieldLike[] -} - export const buildTriggerStatusMap = (triggers: TriggerStatusLike[]) => { return triggers.reduce>((acc, trigger) => { acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled' @@ -81,49 +71,6 @@ export const coerceReplayUserInputs = (rawInputs: unknown): Record { - if (direction === 'frontend') - return type === 'file_list' ? 'file-list' : type - - return type === 'file-list' ? 'file_list' : type -} - -const isHumanInputNode = (node: Node): node is Node => { - return node.data.type === BlockEnum.HumanInput && Array.isArray((node.data as Partial).inputs) -} - -const normalizeHumanInputNode = ( - node: Node, - direction: 'frontend' | 'backend', -): Node => { - if (!isHumanInputNode(node)) - return node - - const normalizedNode: Node = { - ...node, - data: { - ...node.data, - inputs: node.data.inputs.map(input => ({ - ...input, - type: normalizeHumanInputFieldType(input.type, direction), - })), - }, - } - - return normalizedNode -} - -export const normalizeWorkflowNodesForFrontend = (nodes: Node[]) => { - return nodes.map(node => normalizeHumanInputNode(node, 'frontend')) -} - -export const normalizeWorkflowNodesForBackend = (nodes: Node[]) => { - return nodes.map(node => normalizeHumanInputNode(node, 'backend')) -} - export const buildInitialFeatures = ( featuresSource: WorkflowFeaturesLike | null | undefined, fileUploadConfigResponse: FileUploadConfigResponse | undefined, From 2677d90860d86626f16d179b89a265f49467142b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:21:37 +0900 Subject: [PATCH 031/128] chore(deps): bump the storage group across 1 directory with 3 updates (#35578) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- api/pyproject.toml | 6 ++--- api/uv.lock | 60 +++++++++++++++++++++++----------------------- 2 files changed, 33 insertions(+), 33 deletions(-) diff --git a/api/pyproject.toml b/api/pyproject.toml index 834fdfc68e..2587d9e0bf 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -6,7 +6,7 @@ requires-python = "~=3.12.0" dependencies = [ # Legacy: mature and widely deployed "bleach>=6.3.0", - "boto3>=1.42.91", + "boto3>=1.42.96", "celery>=5.6.3", "croniter>=6.2.2", "flask>=3.1.3,<4.0.0", @@ -185,12 +185,12 @@ dev = [ storage = [ "azure-storage-blob>=12.28.0", "bce-python-sdk>=0.9.70", - "cos-python-sdk-v5>=1.9.41", + "cos-python-sdk-v5>=1.9.42", "esdk-obs-python>=3.22.2", "google-cloud-storage>=3.10.1", "opendal>=0.46.0", "oss2>=2.19.1", - "supabase>=2.28.3", + "supabase>=2.29.0", "tos>=2.9.0", ] diff --git a/api/uv.lock b/api/uv.lock index 18a736d4b7..1b52f8b53f 100644 --- a/api/uv.lock +++ b/api/uv.lock @@ -604,16 +604,16 @@ wheels = [ [[package]] name = "boto3" -version = "1.42.91" +version = "1.42.96" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "botocore" }, { name = "jmespath" }, { name = "s3transfer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a7/c0/98b8cec7ca22dde776df48c58940ae1abc425593959b7226e270760d726f/boto3-1.42.91.tar.gz", hash = "sha256:03d70532b17f7f84df37ca7e8c21553280454dea53ae12b15d1cfef9b16fcb8a", size = 113181, upload-time = "2026-04-17T19:31:06.251Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a6/2d/69fb3acd50bab83fb295c167d33c4b653faeb5fb0f42bfca4d9b69d6fb68/boto3-1.42.96.tar.gz", hash = "sha256:b38a9e4a3fbbee9017252576f1379780d0a5814768676c08df2f539d31fcdd68", size = 113203, upload-time = "2026-04-24T19:47:18.677Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/02/29/faba6521257c34085cc9b439ef98235b581772580f417fa3629728007270/boto3-1.42.91-py3-none-any.whl", hash = "sha256:04e72071cde022951ce7f81bd9933c90095ab8923e8ced61c8dacfe9edac0f5c", size = 140553, upload-time = "2026-04-17T19:31:02.57Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/b3f617d011c42eb804d993103b8fa9acdce153e181a3042f58bfe33d7cb4/boto3-1.42.96-py3-none-any.whl", hash = "sha256:2f4566da2c209a98bdbfc874d813ef231c84ad24e4f815e9bc91de5f63351a24", size = 140557, upload-time = "2026-04-24T19:47:15.824Z" }, ] [[package]] @@ -636,16 +636,16 @@ bedrock-runtime = [ [[package]] name = "botocore" -version = "1.42.91" +version = "1.42.96" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/bc/a4b7c46471c2e789ad8c4c7acfd7f302fdb481d93ff870f441249b924ae6/botocore-1.42.91.tar.gz", hash = "sha256:d252e27bc454afdbf5ed3dc617aa423f2c855c081e98b7963093399483ecc698", size = 15213010, upload-time = "2026-04-17T19:30:50.793Z" } +sdist = { url = "https://files.pythonhosted.org/packages/61/77/2c333622a1d47cf5bf73cdcab0cb6c92addafbef2ec05f81b9f75687d9e5/botocore-1.42.96.tar.gz", hash = "sha256:75b3b841ffacaa944f645196655a21ca777591dd8911e732bfb6614545af0250", size = 15263344, upload-time = "2026-04-24T19:47:05.283Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/fc/24cc0a47c824f13933e210e9ad034b4fba22f7185b8d904c0fbf5a3b2be8/botocore-1.42.91-py3-none-any.whl", hash = "sha256:7a28c3cc6bfab5724ad18899d52402b776a0de7d87fa20c3c5270bcaaf199ce8", size = 14897344, upload-time = "2026-04-17T19:30:44.245Z" }, + { url = "https://files.pythonhosted.org/packages/45/56/152c3a859ca1b9d77ed16deac3cf81682013677c68cf5715698781fc81bd/botocore-1.42.96-py3-none-any.whl", hash = "sha256:db2c3e2006628be6fde81a24124a6563c363d6982fb92728837cf174bad9d98a", size = 14945920, upload-time = "2026-04-24T19:47:00.323Z" }, ] [[package]] @@ -1058,7 +1058,7 @@ wheels = [ [[package]] name = "cos-python-sdk-v5" -version = "1.9.41" +version = "1.9.42" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "crcmod" }, @@ -1067,9 +1067,9 @@ dependencies = [ { name = "six" }, { name = "xmltodict" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0e/38/c0029f413f51238aa2319715f45d74bcae931768e36c7e4604b02f407c6c/cos_python_sdk_v5-1.9.41.tar.gz", hash = "sha256:68f4be7d8fe27a1d186b3159b93c622816e398effdc236eddd442b86db592b82", size = 102625, upload-time = "2026-01-06T07:00:11.692Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/e3/b903b4acde334510f481d126a686bc4013710c00e2af34bff369511329ac/cos_python_sdk_v5-1.9.42.tar.gz", hash = "sha256:2a01d1868f50c5a70771f2b67da868f1dc6c6f3890f8009715313834404decc4", size = 102670, upload-time = "2026-04-23T11:08:27.949Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/aa/2f/ead3fb551509fdc94e4a42093b770e3de2827ff7227570165df5e35c2a3e/cos_python_sdk_v5-1.9.41-py3-none-any.whl", hash = "sha256:f465aae43a4ba3f1caa8caeaca838d0395932f6848e89d6dde2807725e3c88a0", size = 98285, upload-time = "2026-01-06T06:43:02.754Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/4ea660bb79d91fd41ba394605eccffd3d0943ed547b3fe2bdc6c7a52d2d1/cos_python_sdk_v5-1.9.42-py3-none-any.whl", hash = "sha256:02e583a1094e1794e6c0f56618d5190eb9eb7bfe75909f1dfac41bbee46e46c5", size = 98375, upload-time = "2026-04-23T11:05:14.519Z" }, ] [[package]] @@ -1578,7 +1578,7 @@ requires-dist = [ { name = "aliyun-log-python-sdk", specifier = ">=0.9.44,<1.0.0" }, { name = "azure-identity", specifier = ">=1.25.3,<2.0.0" }, { name = "bleach", specifier = ">=6.3.0" }, - { name = "boto3", specifier = ">=1.42.91" }, + { name = "boto3", specifier = ">=1.42.96" }, { name = "celery", specifier = ">=5.6.3" }, { name = "croniter", specifier = ">=6.2.2" }, { name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" }, @@ -1684,12 +1684,12 @@ dev = [ storage = [ { name = "azure-storage-blob", specifier = ">=12.28.0" }, { name = "bce-python-sdk", specifier = ">=0.9.70" }, - { name = "cos-python-sdk-v5", specifier = ">=1.9.41" }, + { name = "cos-python-sdk-v5", specifier = ">=1.9.42" }, { name = "esdk-obs-python", specifier = ">=3.22.2" }, { name = "google-cloud-storage", specifier = ">=3.10.1" }, { name = "opendal", specifier = ">=0.46.0" }, { name = "oss2", specifier = ">=2.19.1" }, - { name = "supabase", specifier = ">=2.28.3" }, + { name = "supabase", specifier = ">=2.29.0" }, { name = "tos", specifier = ">=2.9.0" }, ] tools = [ @@ -4810,7 +4810,7 @@ wheels = [ [[package]] name = "postgrest" -version = "2.28.3" +version = "2.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, @@ -4818,9 +4818,9 @@ dependencies = [ { name = "pydantic" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/60/9378ddd6e21b6005b34aeb42dc7a9ed9985c673c97c9b6a1858f9c52ebbd/postgrest-2.28.3.tar.gz", hash = "sha256:56336e9304950a78315ec7d6c8eb307cdb964d0878a7bec6111392ddb6c16a45", size = 13758, upload-time = "2026-03-20T14:38:06.542Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/98/f216b8b5c4d116ab6a2fb21339b5821da279ee773e163612418e1c56c012/postgrest-2.29.0.tar.gz", hash = "sha256:a87081858f627fcd57e8e7137004a1ef0adbdf0dbdfed1384e9ea1d7a9c525ec", size = 14217, upload-time = "2026-04-24T13:13:00.281Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/5e/6eeb1d53d010d80e800204c1eee6b3d5419a6a2b985c364f56f36cf48cca/postgrest-2.28.3-py3-none-any.whl", hash = "sha256:5a44d6c6d509abdbe0f928c86f0dc31ef26bda36e0357129836ec54dfb50b083", size = 21865, upload-time = "2026-03-20T14:38:05.55Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0b/08b670a93a90d625c557b9e64b8a5fdeec80c3542d2d0265f0b4d6b16646/postgrest-2.29.0-py3-none-any.whl", hash = "sha256:3ee48e146f726272733d20e2b12de354cdb6cb9dd9cc3a61ed97ce69047aeb96", size = 22735, upload-time = "2026-04-24T13:12:58.405Z" }, ] [[package]] @@ -5723,16 +5723,16 @@ wheels = [ [[package]] name = "realtime" -version = "2.28.3" +version = "2.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/3d/ef6ed9221f98766f3a503e6e3ac68fa7ca25c117b383f1efc448294232ac/realtime-2.28.3.tar.gz", hash = "sha256:5cc83a6217874426799d8bf74e96d904ac6fa77c39fa8982fa99287947eb2cbf", size = 18723, upload-time = "2026-03-20T14:38:08.424Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/f1/08c42a42653942fadfbef495d5b0239356140e7186cc528704956c5f06d4/realtime-2.29.0.tar.gz", hash = "sha256:8efe4a1b3a548a5fda09de701bd041fa0970c5a2fe7d13db0b9861ce11828be2", size = 18715, upload-time = "2026-04-24T13:13:02.315Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/d5/659405f9d4c9b022b7ac02bd52986ccc081f211db081051440f46bf4f358/realtime-2.28.3-py3-none-any.whl", hash = "sha256:efe484d6d39024c7e00ef70f70be600142e9407e5d802de8c96e86e014ce3b36", size = 22378, upload-time = "2026-03-20T14:38:07.144Z" }, + { url = "https://files.pythonhosted.org/packages/77/48/f6375c0a24923beb988f0c71c052604c96641cf43c2d22b91ec1df86afa0/realtime-2.29.0-py3-none-any.whl", hash = "sha256:1a4891e6c82e88ac9d96ac715e435e086f6f8c7665212a8717346de829cbb509", size = 22374, upload-time = "2026-04-24T13:13:01.103Z" }, ] [[package]] @@ -6214,7 +6214,7 @@ wheels = [ [[package]] name = "storage3" -version = "2.28.3" +version = "2.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, @@ -6223,9 +6223,9 @@ dependencies = [ { name = "pyiceberg" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/b5/18df59ba92951d74774eb0265072bf236ead5e3cbc4b802d8bf1cf3581a0/storage3-2.28.3.tar.gz", hash = "sha256:2b3f843cbd44c4a3b483ec076a12c27de88c0ad5358a43067ed44ef08292353f", size = 20109, upload-time = "2026-03-20T14:38:11.467Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/be/771246434b5caf3c6187bfdc932eaede00bf5f2937b47475ab25209ede3e/storage3-2.29.0.tar.gz", hash = "sha256:b0cc2f6714655d725c998d2c5ae8c6fb4f56a513bd31e4f85770df557fe021e3", size = 20160, upload-time = "2026-04-24T13:13:04.626Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/a5/2dbe216954e026a8c2e2dc7dfa5fd7b1a1ae0824d10972e62462f4f15aca/storage3-2.28.3-py3-none-any.whl", hash = "sha256:bac35c5087619174448fdef6a337db4e3dfebf3de69f685bd706de93ddcdad69", size = 28239, upload-time = "2026-03-20T14:38:10.423Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c3/790c31866f52c13b26f108b45759bf50dafae3a0bafb4511fadc98ba7c33/storage3-2.29.0-py3-none-any.whl", hash = "sha256:043ef7ff27cc8b9da12be403cf78ee4586180edfcf62b227ff61e1bd79594b06", size = 28284, upload-time = "2026-04-24T13:13:03.338Z" }, ] [[package]] @@ -6251,7 +6251,7 @@ wheels = [ [[package]] name = "supabase" -version = "2.28.3" +version = "2.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, @@ -6262,37 +6262,37 @@ dependencies = [ { name = "supabase-functions" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5a/98/2f1c95a2269ce995a34f275760b1c2ee71ee7a75649238ca0470afdfc2ef/supabase-2.28.3.tar.gz", hash = "sha256:1200961e46cdec17c7c280a1e09a159544643eada2759591ea69835303a2e1a4", size = 9687, upload-time = "2026-03-20T14:38:13.272Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/a0/2407d616fdf68e8632bbbfb063d1685c38377ac0199e8ca11deaea1f3bf0/supabase-2.29.0.tar.gz", hash = "sha256:a88c4a4eb50fbb903e2e962fbc7c27733b00589140139f9e837bc9fe30dd3615", size = 9689, upload-time = "2026-04-24T13:13:06.728Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/96/1b48eb664153401c22087bbf77f6a428965e830cc8e0d0c6d68324a28342/supabase-2.28.3-py3-none-any.whl", hash = "sha256:52a7ce4a1d2d55fa6d657bf4760672935058143a5bedc64165851be25ce01dbd", size = 16634, upload-time = "2026-03-20T14:38:12.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/52/232f6bbf5326e04ae12e2ef04a24f011a0d7cab379a8b9698652bc8ff78f/supabase-2.29.0-py3-none-any.whl", hash = "sha256:16c3ec4b7094f6b92efc5cd3bb3f96826d3b6dd5d24fe15c89c81166efce88fe", size = 16633, upload-time = "2026-04-24T13:13:05.722Z" }, ] [[package]] name = "supabase-auth" -version = "2.28.3" +version = "2.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"] }, { name = "pydantic" }, { name = "pyjwt", extra = ["crypto"] }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cc/6f/1bf81293374ba71183b321bf5dfd7151c3db0c2e24715f35783bc1c56385/supabase_auth-2.28.3.tar.gz", hash = "sha256:41c049da82f9d7fc2f111808e57e984015f128d033f58caa67fd76f428472807", size = 39160, upload-time = "2026-03-20T14:38:15.128Z" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7f/7ceeb4c7a2caa188062e934897f0e08e1af0a0e47e376c7645c26b4c39d8/supabase_auth-2.29.0.tar.gz", hash = "sha256:46efc6a3455a23957b846dc974303a844ba0413718cfa899425477ac977f95b3", size = 39154, upload-time = "2026-04-24T13:13:08.509Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/d3/e012315aa895b434fa77bc475e2dfeb87119e67918ecca4d88a25f96814d/supabase_auth-2.28.3-py3-none-any.whl", hash = "sha256:e47c5caec7bbf3c258964d027fbbe99f3cc4a956d3a635f898c962b4d22832dd", size = 48378, upload-time = "2026-03-20T14:38:14.169Z" }, + { url = "https://files.pythonhosted.org/packages/f1/ac/3c35cf52281f940b9497cf17abfc5c2050ca49f342d60cfafe22dac3482b/supabase_auth-2.29.0-py3-none-any.whl", hash = "sha256:64de6ef8cae80f97d3aa8d5ca507d5427dda5c89885c0bcfe9f8b0263b6fb9a4", size = 48379, upload-time = "2026-04-24T13:13:07.417Z" }, ] [[package]] name = "supabase-functions" -version = "2.28.3" +version = "2.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx", extra = ["http2"] }, { name = "strenum" }, { name = "yarl" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/19/ea/59bf327960e5384fcc9e69afbdf97260a2cf2684a25c0731968a8a393b9c/supabase_functions-2.28.3.tar.gz", hash = "sha256:5a6255d60a263d44251c5ca250fcdde2408a8483a8bf31f4ac80255de8f3fcae", size = 4679, upload-time = "2026-03-20T14:38:16.742Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/19/1a1d22749f38f2a6cbca93a6f5a35c9f816c2c3c06bfaa077fa336e90537/supabase_functions-2.29.0.tar.gz", hash = "sha256:0f8a14a2ea9f12b1c208f61dc6f55e2f4b1121f81bf01c08f9b487d22888744d", size = 4683, upload-time = "2026-04-24T13:13:10.432Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a5/ca/1e720f1347a88519e3d52b6d801cd031c3a7a5df66640c5dc6e81d925057/supabase_functions-2.28.3-py3-none-any.whl", hash = "sha256:eb30578866103fed9322c54e95dd68c2f1a4b6b177e129d9369edd364637904e", size = 8801, upload-time = "2026-03-20T14:38:15.883Z" }, + { url = "https://files.pythonhosted.org/packages/e0/10/6f8ef0b408ade76b5a439afab588ce5849e9604a23040ca73cfe0b90cb9e/supabase_functions-2.29.0-py3-none-any.whl", hash = "sha256:6f08de52eec5820eae53616868b85e849e181beffaa5d05b8ea1708ceae5e48e", size = 8799, upload-time = "2026-04-24T13:13:09.214Z" }, ] [[package]] From 3db107edc9cb755668f11f29cc22601fddf1b28f Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:46:43 +0800 Subject: [PATCH 032/128] chore(ci): increase tsslint heap limit (#35591) --- .github/workflows/style.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/style.yml b/.github/workflows/style.yml index 35b8f86cab..6b00899cf0 100644 --- a/.github/workflows/style.yml +++ b/.github/workflows/style.yml @@ -110,6 +110,8 @@ jobs: - name: Web tsslint if: steps.changed-files.outputs.any_changed == 'true' working-directory: ./web + env: + NODE_OPTIONS: --max-old-space-size=4096 run: vp run lint:tss - name: Web type check From 818a71d6379efa2634d5d66960dc353098323729 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 27 Apr 2026 13:03:38 +0800 Subject: [PATCH 033/128] refactor(web): migrate simple overlay tooltips (#35588) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 56 --------- .../src/select/__tests__/index.spec.tsx | 6 +- .../base/__tests__/header.spec.tsx | 8 -- .../data-source/base/header.tsx | 37 +++--- .../breadcrumbs/__tests__/bucket.spec.tsx | 6 +- .../file-list/header/breadcrumbs/bucket.tsx | 32 +++-- .../common/__tests__/summary-status.spec.tsx | 3 - .../completed/common/summary-status.tsx | 28 +++-- .../secret-key/__tests__/input-copy.spec.tsx | 6 +- .../develop/secret-key/input-copy.tsx | 33 ++++-- .../base/__tests__/key-value-item.spec.tsx | 16 ++- .../__tests__/icon-with-tooltip.spec.tsx | 49 ++------ .../plugins/base/badges/icon-with-tooltip.tsx | 25 ++-- .../plugins/base/key-value-item.tsx | 24 ++-- .../__tests__/plugin-source-badge.spec.tsx | 74 +++--------- .../components/plugin-source-badge.tsx | 24 ++-- .../plugin-detail-panel/endpoint-card.tsx | 39 +++++-- .../__tests__/task-status-indicator.spec.tsx | 16 +-- .../components/task-status-indicator.tsx | 109 +++++++++--------- .../mcp/detail/__tests__/content.spec.tsx | 11 +- .../components/tools/mcp/detail/content.tsx | 67 +++++++---- 21 files changed, 299 insertions(+), 370 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 1bff82ac17..b3c7a18fea 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2422,21 +2422,11 @@ "count": 1 } }, - "web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/create-from-pipeline/data-source/online-documents/index.tsx": { "ts/no-explicit-any": { "count": 1 } }, - "web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/bucket.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/list/item.tsx": { "no-restricted-imports": { "count": 1 @@ -2525,11 +2515,6 @@ "count": 1 } }, - "web/app/components/datasets/documents/detail/completed/common/summary-status.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/datasets/documents/detail/completed/components/index.ts": { "no-barrel-files/no-barrel-files": { "count": 3 @@ -2789,11 +2774,6 @@ "count": 2 } }, - "web/app/components/develop/secret-key/input-copy.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/develop/secret-key/secret-key-generate.tsx": { "no-restricted-imports": { "count": 1 @@ -3159,16 +3139,6 @@ "count": 1 } }, - "web/app/components/plugins/base/badges/icon-with-tooltip.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, - "web/app/components/plugins/base/key-value-item.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/card/index.tsx": { "ts/no-non-null-asserted-optional-chain": { "count": 1 @@ -3328,24 +3298,11 @@ "count": 2 } }, - "web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/plugin-detail-panel/detail-header/hooks/index.ts": { "no-barrel-files/no-barrel-files": { "count": 3 } }, - "web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "web/app/components/plugins/plugin-detail-panel/endpoint-list.tsx": { "no-restricted-imports": { "count": 1 @@ -3544,11 +3501,6 @@ "count": 1 } }, - "web/app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "web/app/components/plugins/readme-panel/index.tsx": { "react/unsupported-syntax": { "count": 1 @@ -3822,14 +3774,6 @@ "count": 1 } }, - "web/app/components/tools/mcp/detail/content.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 3 - } - }, "web/app/components/tools/mcp/detail/tool-item.tsx": { "no-restricted-imports": { "count": 1 diff --git a/packages/dify-ui/src/select/__tests__/index.spec.tsx b/packages/dify-ui/src/select/__tests__/index.spec.tsx index eab980a607..9e3e945de0 100644 --- a/packages/dify-ui/src/select/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/select/__tests__/index.spec.tsx @@ -231,10 +231,8 @@ describe('Select wrappers', () => { , ) - screen.getByRole('group', { name: 'select positioner' }).element().dispatchEvent(new MouseEvent('mouseover', { - bubbles: true, - })) - asHTMLElement(screen.getByRole('dialog', { name: 'select popup' }).element()).click() + await screen.getByRole('group', { name: 'select positioner' }).hover() + await screen.getByRole('dialog', { name: 'select popup' }).click() screen.getByRole('listbox', { name: 'select list' }).element().dispatchEvent(new FocusEvent('focusin', { bubbles: true, })) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/__tests__/header.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/__tests__/header.spec.tsx index a6abad358e..bc3b025ded 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/__tests__/header.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/__tests__/header.spec.tsx @@ -2,18 +2,10 @@ import { render, screen } from '@testing-library/react' import { describe, expect, it, vi } from 'vitest' import Header from '../header' -vi.mock('@langgenius/dify-ui/button', () => ({ - Button: ({ children }: { children: React.ReactNode }) => , -})) - vi.mock('@/app/components/base/divider', () => ({ default: () => , })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children }: { children: React.ReactNode }) =>
{children}
, -})) - vi.mock('../credential-selector', () => ({ default: () =>
, })) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx index a285946272..c91012bf4a 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/base/header.tsx @@ -1,10 +1,9 @@ import type { CredentialSelectorProps } from './credential-selector' import { Button } from '@langgenius/dify-ui/button' -import { RiBookOpenLine, RiEqualizer2Line } from '@remixicon/react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import * as React from 'react' import { useTranslation } from 'react-i18next' import Divider from '@/app/components/base/divider' -import Tooltip from '@/app/components/base/tooltip' import CredentialSelector from './credential-selector' type HeaderProps = { @@ -22,6 +21,7 @@ const Header = ({ ...rest }: HeaderProps) => { const { t } = useTranslation() + const configurationTip = t('configurationTip', { ns: 'datasetPipeline', pluginName }) return (
@@ -30,20 +30,23 @@ const Header = ({ {...rest} /> - - + + + + + )} + /> + + {configurationTip} +
- + {docTitle}
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/bucket.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/bucket.spec.tsx index 83e17e6e04..b0a49eee0d 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/bucket.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/__tests__/bucket.spec.tsx @@ -5,9 +5,6 @@ import Bucket from '../bucket' vi.mock('@/app/components/base/icons/src/public/knowledge/online-drive', () => ({ BucketsGray: (props: React.SVGProps) => , })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children }: { children?: React.ReactNode }) =>
{children}
, -})) describe('Bucket', () => { const defaultProps = { @@ -32,8 +29,7 @@ describe('Bucket', () => { it('should call handleBackToBucketList on icon button click', () => { render() - const buttons = screen.getAllByRole('button') - fireEvent.click(buttons[0]!) + fireEvent.click(screen.getByRole('button', { name: 'datasetPipeline.onlineDrive.breadcrumbs.allBuckets' })) expect(defaultProps.handleBackToBucketList).toHaveBeenCalledOnce() }) diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/bucket.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/bucket.tsx index 003aee6542..384188502b 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/bucket.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/online-drive/file-list/header/breadcrumbs/bucket.tsx @@ -1,9 +1,10 @@ +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import * as React from 'react' import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { BucketsGray } from '@/app/components/base/icons/src/public/knowledge/online-drive' -import Tooltip from '@/app/components/base/tooltip' type BucketProps = { bucketName: string @@ -27,19 +28,28 @@ const Bucket = ({ if (!disabled) handleClickBucketName() }, [disabled, handleClickBucketName]) + const allBucketsLabel = t('onlineDrive.breadcrumbs.allBuckets', { ns: 'datasetPipeline' }) return ( <> - - + + + + + )} + /> + + {allBucketsLabel} + / + default: ({ + children, + onClick, + ...props + }: React.ButtonHTMLAttributes) => ( + ), })) @@ -54,6 +52,6 @@ describe('KeyValueItem', () => { it('renders copy tooltip', () => { render() - expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'common.operation.copy') + expect(screen.getByRole('button', { name: 'common.operation.copy' })).toBeInTheDocument() }) }) diff --git a/web/app/components/plugins/base/badges/__tests__/icon-with-tooltip.spec.tsx b/web/app/components/plugins/base/badges/__tests__/icon-with-tooltip.spec.tsx index e24aa5a873..d4a87fa8a5 100644 --- a/web/app/components/plugins/base/badges/__tests__/icon-with-tooltip.spec.tsx +++ b/web/app/components/plugins/base/badges/__tests__/icon-with-tooltip.spec.tsx @@ -3,24 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { Theme } from '@/types/app' import IconWithTooltip from '../icon-with-tooltip' -// Mock Tooltip component -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ - children, - popupContent, - popupClassName, - }: { - children: React.ReactNode - popupContent?: string - popupClassName?: string - }) => ( -
- {children} -
- ), -})) - -// Mock icon components const MockLightIcon = ({ className }: { className?: string }) => (
Light Icon
) @@ -44,10 +26,10 @@ describe('IconWithTooltip', () => { />, ) - expect(screen.getByTestId('tooltip')).toBeInTheDocument() + expect(screen.getByTestId('light-icon')).toBeInTheDocument() }) - it('should render Tooltip wrapper', () => { + it('should render tooltip trigger with accessible label when popupContent is provided', () => { render( { />, ) - expect(screen.getByTestId('tooltip')).toHaveAttribute('data-popup-content', 'Test tooltip') - }) - - it('should apply correct popupClassName to Tooltip', () => { - render( - , - ) - - const tooltip = screen.getByTestId('tooltip') - expect(tooltip).toHaveAttribute('data-popup-classname') - expect(tooltip.getAttribute('data-popup-classname')).toContain('border-components-panel-border') + expect(screen.getByLabelText('Test tooltip')).toBeInTheDocument() }) }) @@ -171,10 +139,7 @@ describe('IconWithTooltip', () => { />, ) - expect(screen.getByTestId('tooltip')).toHaveAttribute( - 'data-popup-content', - 'Custom tooltip content', - ) + expect(screen.getByLabelText('Custom tooltip content')).toBeInTheDocument() }) it('should handle undefined popupContent', () => { @@ -186,7 +151,7 @@ describe('IconWithTooltip', () => { />, ) - expect(screen.getByTestId('tooltip')).toBeInTheDocument() + expect(screen.getByTestId('light-icon')).toBeInTheDocument() }) }) @@ -239,7 +204,7 @@ describe('IconWithTooltip', () => { />, ) - expect(screen.getByTestId('tooltip')).toHaveAttribute('data-popup-content', longContent) + expect(screen.getByLabelText(longContent)).toBeInTheDocument() }) it('should handle special characters in popupContent', () => { @@ -253,7 +218,7 @@ describe('IconWithTooltip', () => { />, ) - expect(screen.getByTestId('tooltip')).toHaveAttribute('data-popup-content', specialContent) + expect(screen.getByLabelText(specialContent)).toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/base/badges/icon-with-tooltip.tsx b/web/app/components/plugins/base/badges/icon-with-tooltip.tsx index faabd545fd..2cb40adf0a 100644 --- a/web/app/components/plugins/base/badges/icon-with-tooltip.tsx +++ b/web/app/components/plugins/base/badges/icon-with-tooltip.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import * as React from 'react' -import Tooltip from '@/app/components/base/tooltip' import { Theme } from '@/types/app' type IconWithTooltipProps = { @@ -22,15 +22,24 @@ const IconWithTooltip: FC = ({ const isDark = theme === Theme.dark const iconClassName = cn('h-5 w-5', className) const Icon = isDark ? BadgeIconDark : BadgeIconLight + const icon = ( + + + + ) + + if (!popupContent) + return icon return ( - -
- -
+ + + + {popupContent} + ) } diff --git a/web/app/components/plugins/base/key-value-item.tsx b/web/app/components/plugins/base/key-value-item.tsx index 1ba8e8caf9..a2a3459b5d 100644 --- a/web/app/components/plugins/base/key-value-item.tsx +++ b/web/app/components/plugins/base/key-value-item.tsx @@ -1,16 +1,13 @@ 'use client' import type { FC } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { - RiClipboardLine, -} from '@remixicon/react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import copy from 'copy-to-clipboard' import * as React from 'react' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { CopyCheck } from '../../base/icons/src/vender/line/files' -import Tooltip from '../../base/tooltip' type Props = { label: string @@ -45,7 +42,7 @@ const KeyValueItem: FC = ({ } }, [isCopied]) - const CopyIcon = isCopied ? CopyCheck : RiClipboardLine + const copyLabel = t(`operation.${isCopied ? 'copied' : 'copy'}`, { ns: 'common' }) return (
@@ -54,10 +51,19 @@ const KeyValueItem: FC = ({ {maskedValue || value} - - - - + + + {isCopied + ? + : } + + )} + /> + + {copyLabel} +
diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/plugin-source-badge.spec.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/plugin-source-badge.spec.tsx index 4d60433efb..08f5f836f4 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/plugin-source-badge.spec.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/__tests__/plugin-source-badge.spec.tsx @@ -3,14 +3,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { PluginSource } from '../../../../types' import PluginSourceBadge from '../plugin-source-badge' -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( -
- {children} -
- ), -})) - describe('PluginSourceBadge', () => { beforeEach(() => { vi.clearAllMocks() @@ -20,33 +12,25 @@ describe('PluginSourceBadge', () => { it('should render marketplace source badge', () => { render() - const tooltip = screen.getByTestId('tooltip') - expect(tooltip).toBeInTheDocument() - expect(tooltip).toHaveAttribute('data-content', 'plugin.detailPanel.categoryTip.marketplace') + expect(screen.getByLabelText('plugin.detailPanel.categoryTip.marketplace')).toBeInTheDocument() }) it('should render github source badge', () => { render() - const tooltip = screen.getByTestId('tooltip') - expect(tooltip).toBeInTheDocument() - expect(tooltip).toHaveAttribute('data-content', 'plugin.detailPanel.categoryTip.github') + expect(screen.getByLabelText('plugin.detailPanel.categoryTip.github')).toBeInTheDocument() }) it('should render local source badge', () => { render() - const tooltip = screen.getByTestId('tooltip') - expect(tooltip).toBeInTheDocument() - expect(tooltip).toHaveAttribute('data-content', 'plugin.detailPanel.categoryTip.local') + expect(screen.getByLabelText('plugin.detailPanel.categoryTip.local')).toBeInTheDocument() }) it('should render debugging source badge', () => { render() - const tooltip = screen.getByTestId('tooltip') - expect(tooltip).toBeInTheDocument() - expect(tooltip).toHaveAttribute('data-content', 'plugin.detailPanel.categoryTip.debugging') + expect(screen.getByLabelText('plugin.detailPanel.categoryTip.debugging')).toBeInTheDocument() }) }) @@ -86,71 +70,47 @@ describe('PluginSourceBadge', () => { it('should show marketplace tooltip', () => { render() - expect(screen.getByTestId('tooltip')).toHaveAttribute( - 'data-content', - 'plugin.detailPanel.categoryTip.marketplace', - ) + expect(screen.getByLabelText('plugin.detailPanel.categoryTip.marketplace')).toBeInTheDocument() }) it('should show github tooltip', () => { render() - expect(screen.getByTestId('tooltip')).toHaveAttribute( - 'data-content', - 'plugin.detailPanel.categoryTip.github', - ) + expect(screen.getByLabelText('plugin.detailPanel.categoryTip.github')).toBeInTheDocument() }) it('should show local tooltip', () => { render() - expect(screen.getByTestId('tooltip')).toHaveAttribute( - 'data-content', - 'plugin.detailPanel.categoryTip.local', - ) + expect(screen.getByLabelText('plugin.detailPanel.categoryTip.local')).toBeInTheDocument() }) it('should show debugging tooltip', () => { render() - expect(screen.getByTestId('tooltip')).toHaveAttribute( - 'data-content', - 'plugin.detailPanel.categoryTip.debugging', - ) + expect(screen.getByLabelText('plugin.detailPanel.categoryTip.debugging')).toBeInTheDocument() }) }) describe('Icon Element Structure', () => { it('should render icon inside tooltip for marketplace', () => { - render() - - const tooltip = screen.getByTestId('tooltip') - const iconWrapper = tooltip.querySelector('div') - expect(iconWrapper).toBeInTheDocument() + const { container } = render() + expect(container.querySelector('[aria-label="plugin.detailPanel.categoryTip.marketplace"]')).toBeInTheDocument() }) it('should render icon inside tooltip for github', () => { - render() - - const tooltip = screen.getByTestId('tooltip') - const iconWrapper = tooltip.querySelector('div') - expect(iconWrapper).toBeInTheDocument() + const { container } = render() + expect(container.querySelector('[aria-label="plugin.detailPanel.categoryTip.github"]')).toBeInTheDocument() }) it('should render icon inside tooltip for local', () => { - render() - - const tooltip = screen.getByTestId('tooltip') - const iconWrapper = tooltip.querySelector('div') - expect(iconWrapper).toBeInTheDocument() + const { container } = render() + expect(container.querySelector('[aria-label="plugin.detailPanel.categoryTip.local"]')).toBeInTheDocument() }) it('should render icon inside tooltip for debugging', () => { - render() - - const tooltip = screen.getByTestId('tooltip') - const iconWrapper = tooltip.querySelector('div') - expect(iconWrapper).toBeInTheDocument() + const { container } = render() + expect(container.querySelector('[aria-label="plugin.detailPanel.categoryTip.debugging"]')).toBeInTheDocument() }) }) @@ -188,7 +148,7 @@ describe('PluginSourceBadge', () => { const invalidSource = '' as PluginSource render() - expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() + expect(screen.queryByLabelText(/^plugin\.detailPanel\.categoryTip\./)).not.toBeInTheDocument() }) }) }) diff --git a/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.tsx b/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.tsx index ba15815cde..9b6725da14 100644 --- a/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.tsx +++ b/web/app/components/plugins/plugin-detail-panel/detail-header/components/plugin-source-badge.tsx @@ -1,14 +1,10 @@ 'use client' import type { FC, ReactNode } from 'react' -import { - RiBugLine, - RiHardDrive3Line, -} from '@remixicon/react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useTranslation } from 'react-i18next' import { Github } from '@/app/components/base/icons/src/public/common' import { BoxSparkleFill } from '@/app/components/base/icons/src/vender/plugin' -import Tooltip from '@/app/components/base/tooltip' import { PluginSource } from '../../../types' type SourceConfig = { @@ -30,11 +26,11 @@ const SOURCE_CONFIG_MAP: Record = { tipKey: 'detailPanel.categoryTip.github', }, [PluginSource.local]: { - icon: , + icon: , tipKey: 'detailPanel.categoryTip.local', }, [PluginSource.debugging]: { - icon: , + icon: , tipKey: 'detailPanel.categoryTip.debugging', }, } @@ -45,12 +41,22 @@ const PluginSourceBadge: FC = ({ source }) => { const config = SOURCE_CONFIG_MAP[source] if (!config) return null + const tip = t(config.tipKey as never, { ns: 'plugin' }) return ( <>
·
- -
{config.icon}
+ + + {config.icon} +
+ )} + /> + + {tip} + ) diff --git a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx index e1adc6282d..9aa944c4b3 100644 --- a/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx +++ b/web/app/components/plugins/plugin-detail-panel/endpoint-card.tsx @@ -1,3 +1,4 @@ +import type { ComponentProps } from 'react' import type { EndpointListItem, PluginDetail } from '../types' import { AlertDialog, @@ -9,7 +10,7 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { Switch } from '@langgenius/dify-ui/switch' import { toast } from '@langgenius/dify-ui/toast' -import { RiClipboardLine, RiDeleteBinLine, RiEditLine, RiLoginCircleLine } from '@remixicon/react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useBoolean } from 'ahooks' import copy from 'copy-to-clipboard' import * as React from 'react' @@ -17,7 +18,6 @@ import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' import { CopyCheck } from '@/app/components/base/icons/src/vender/line/files' -import Tooltip from '@/app/components/base/tooltip' import Indicator from '@/app/components/header/indicator' import { addDefaultValue, toolCredentialToFormSchemas } from '@/app/components/tools/utils/to-form-schema' import { @@ -29,6 +29,8 @@ import { import EndpointModal from './endpoint-modal' import { NAME_FIELD } from './utils' +type EndpointModalFormSchemas = ComponentProps['formSchemas'] + type Props = { pluginDetail: PluginDetail data: EndpointListItem @@ -118,7 +120,7 @@ const EndpointCard = ({ toast.error(t('actionMsg.modifiedUnsuccessfully', { ns: 'common' })) }, }) - const handleUpdate = (state: Record) => updateEndpoint({ + const handleUpdate = (state: Record) => updateEndpoint({ endpointID, state, }) @@ -148,22 +150,22 @@ const EndpointCard = ({ } }, [isCopied]) - const CopyIcon = isCopied ? CopyCheck : RiClipboardLine + const copyLabel = t(`operation.${isCopied ? 'copied' : 'copy'}`, { ns: 'common' }) return (
- +
{data.name}
- + - +
@@ -172,10 +174,23 @@ const EndpointCard = ({
{endpoint.method}
{`${data.url}${endpoint.path}`}
- - handleCopy(`${data.url}${endpoint.path}`)}> - - + + handleCopy(`${data.url}${endpoint.path}`)} + > + {isCopied + ? + : } + + )} + /> + + {copyLabel} +
@@ -244,7 +259,7 @@ const EndpointCard = ({ {isShowEndpointModal && ( ({ ), })) -vi.mock('@/app/components/base/tooltip', () => ({ - default: ({ children, popupContent }: { children: React.ReactNode, popupContent: string }) => ( -
{children}
- ), -})) - vi.mock('@/app/components/header/plugins-nav/downloading-icon', () => ({ default: () => , })) @@ -38,18 +32,17 @@ describe('TaskStatusIndicator', () => { describe('Rendering', () => { it('should render without crashing', () => { render() - expect(screen.getByTestId('tooltip')).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Installing plugins' })).toBeInTheDocument() }) - it('should pass tip to tooltip', () => { + it('should use tip as the trigger accessible name', () => { render() - expect(screen.getByTestId('tooltip')).toHaveAttribute('data-tip', 'My tip') + expect(screen.getByRole('button', { name: 'My tip' })).toBeInTheDocument() }) it('should render install icon by default', () => { const { container } = render() - // RiInstallLine renders as svg - expect(container.querySelector('svg')).toBeInTheDocument() + expect(container.querySelector('.i-ri-install-line')).toBeInTheDocument() expect(screen.queryByTestId('downloading-icon')).not.toBeInTheDocument() }) }) @@ -127,7 +120,6 @@ describe('TaskStatusIndicator', () => { totalPluginsLength={3} />, ) - // RiCheckboxCircleFill is rendered as svg with text-text-success const successIcon = container.querySelector('.text-text-success') expect(successIcon).toBeInTheDocument() }) diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.tsx index d1de645f7b..691ee40f4d 100644 --- a/web/app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.tsx +++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/task-status-indicator.tsx @@ -1,12 +1,8 @@ import type { FC } from 'react' +import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - RiCheckboxCircleFill, - RiErrorWarningFill, - RiInstallLine, -} from '@remixicon/react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import ProgressCircle from '@/app/components/base/progress-bar/progress-circle' -import Tooltip from '@/app/components/base/tooltip' import DownloadingIcon from '@/app/components/header/plugins-nav/downloading-icon' type TaskStatusIndicatorProps = { @@ -39,56 +35,61 @@ const TaskStatusIndicator: FC = ({ const showSuccessIcon = isSuccess || (successPluginsLength > 0 && runningPluginsLength === 0) return ( - -
- {/* Main Icon */} - {showDownloadingIcon - ? - : ( - + + + {showDownloadingIcon + ? + : ( + + )} - {/* Status Indicator Badge */} -
- {(isInstalling || isInstallingWithSuccess) && ( - 0 ? successPluginsLength / totalPluginsLength : 0) * 100} - circleFillColor="fill-components-progress-brand-bg" - /> - )} - {isInstallingWithError && ( - 0 ? runningPluginsLength / totalPluginsLength : 0) * 100} - circleFillColor="fill-components-progress-brand-bg" - sectorFillColor="fill-components-progress-error-border" - circleStrokeColor="stroke-components-progress-error-border" - /> - )} - {showSuccessIcon && !isInstalling && !isInstallingWithSuccess && !isInstallingWithError && ( - - )} - {isFailed && ( - - )} -
-
+
+ {(isInstalling || isInstallingWithSuccess) && ( + 0 ? successPluginsLength / totalPluginsLength : 0) * 100} + circleFillColor="fill-components-progress-brand-bg" + /> + )} + {isInstallingWithError && ( + 0 ? runningPluginsLength / totalPluginsLength : 0) * 100} + circleFillColor="fill-components-progress-brand-bg" + sectorFillColor="fill-components-progress-error-border" + circleStrokeColor="stroke-components-progress-error-border" + /> + )} + {showSuccessIcon && !isInstalling && !isInstallingWithSuccess && !isInstallingWithError && ( + + )} + {isFailed && ( + + )} +
+ + )} + /> + {tip}
) } diff --git a/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx b/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx index 5216e9eede..f7bf8181ed 100644 --- a/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx +++ b/web/app/components/tools/mcp/detail/__tests__/content.spec.tsx @@ -698,16 +698,9 @@ describe('MCPDetailContent', () => { const onHide = vi.fn() render(, { wrapper: createWrapper() }) - // Find the close button (ActionButton with RiCloseLine) - const buttons = screen.getAllByRole('button') - const closeButton = buttons.find(btn => - btn.querySelector('svg.h-4.w-4'), - ) + fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' })) - if (closeButton) { - fireEvent.click(closeButton) - expect(onHide).toHaveBeenCalled() - } + expect(onHide).toHaveBeenCalled() }) }) diff --git a/web/app/components/tools/mcp/detail/content.tsx b/web/app/components/tools/mcp/detail/content.tsx index 48ea75723c..35c8a35a6f 100644 --- a/web/app/components/tools/mcp/detail/content.tsx +++ b/web/app/components/tools/mcp/detail/content.tsx @@ -1,5 +1,5 @@ 'use client' -import type { FC } from 'react' +import type { ComponentProps, FC } from 'react' import type { ToolWithProvider } from '../../../workflow/types' import { AlertDialog, @@ -12,18 +12,13 @@ import { } from '@langgenius/dify-ui/alert-dialog' import { Button } from '@langgenius/dify-ui/button' import { cn } from '@langgenius/dify-ui/cn' -import { - RiCloseLine, - RiLoader2Line, - RiLoopLeftLine, -} from '@remixicon/react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip' import { useBoolean } from 'ahooks' import copy from 'copy-to-clipboard' import * as React from 'react' import { useCallback, useEffect } from 'react' import { useTranslation } from 'react-i18next' import ActionButton from '@/app/components/base/action-button' -import Tooltip from '@/app/components/base/tooltip' import Indicator from '@/app/components/header/indicator' import Icon from '@/app/components/plugins/card/base/card-icon' import { useAppContext } from '@/context/app-context' @@ -49,6 +44,11 @@ type Props = { onFirstCreate: () => void } +type MCPModalConfirmPayload = Parameters['onConfirm']>[0] +type MutationResult = { + result?: string +} + const MCPDetailContent: FC = ({ detail, onUpdate, @@ -128,14 +128,14 @@ const MCPDetailContent: FC = ({ } }, [onFirstCreate, isCurrentWorkspaceManager, detail, authorizeMcp, handleUpdateTools, handleOAuthCallback, onUpdate]) - const handleUpdate = useCallback(async (data: any) => { + const handleUpdate = useCallback(async (data: MCPModalConfirmPayload) => { if (!detail) return const res = await updateMCP({ ...data, provider_id: detail.id, - }) - if ((res as any)?.result === 'success') { + }) as MutationResult + if (res.result === 'success') { hideUpdateModal() onUpdate() handleAuthorize() @@ -146,9 +146,9 @@ const MCPDetailContent: FC = ({ if (!detail) return showDeleting() - const res = await deleteMCP(detail.id) + const res = await deleteMCP(detail.id) as MutationResult hideDeleting() - if ((res as any)?.result === 'success') { + if (res.result === 'success') { hideDeleteConfirm() onUpdate(true) } @@ -161,6 +161,8 @@ const MCPDetailContent: FC = ({ if (!detail) return null + const identifierLabel = t('mcp.identifier', { ns: 'tools' }) + const serverUrlLabel = t('mcp.modal.serverUrl', { ns: 'tools' }) return ( <> @@ -174,12 +176,37 @@ const MCPDetailContent: FC = ({
{detail.name}
- -
copy(detail.server_identifier || '')}>{detail.server_identifier}
+ + copy(detail.server_identifier || '')} + > + {detail.server_identifier} + + )} + /> + + {identifierLabel} +
·
- -
{detail.server_url}
+ + + {detail.server_url} +
+ )} + /> + + {serverUrlLabel} +
@@ -188,8 +215,8 @@ const MCPDetailContent: FC = ({ onEdit={showUpdateModal} onRemove={showDeleteConfirm} /> - - + +
@@ -221,7 +248,7 @@ const MCPDetailContent: FC = ({ className="w-full" disabled > - + {t('mcp.authorizing', { ns: 'tools' })} )} @@ -262,7 +289,7 @@ const MCPDetailContent: FC = ({
From 6c089cab6671b23c4017c8cf51d44f9b188e7529 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Mon, 27 Apr 2026 13:27:19 +0800 Subject: [PATCH 034/128] fix(web): migrate variable type selector overlay (#35590) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- eslint-suppressions.json | 8 -- .../__tests__/variable-type-select.spec.tsx | 3 +- .../components/variable-type-select.tsx | 76 ++++++++++--------- 3 files changed, 42 insertions(+), 45 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index b3c7a18fea..1e7a2662ed 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -5338,14 +5338,6 @@ "count": 2 } }, - "web/app/components/workflow/panel/chat-variable-panel/components/variable-type-select.tsx": { - "no-restricted-imports": { - "count": 1 - }, - "ts/no-explicit-any": { - "count": 4 - } - }, "web/app/components/workflow/panel/chat-variable-panel/type.ts": { "erasable-syntax-only/enums": { "count": 1 diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-type-select.spec.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-type-select.spec.tsx index 3a7df8a3bf..d0831c319c 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-type-select.spec.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/__tests__/variable-type-select.spec.tsx @@ -36,8 +36,9 @@ describe('VariableTypeSelector', () => { await user.keyboard('{Escape}') await waitFor(() => { - expect(screen.queryByText('number')).not.toBeInTheDocument() + expect(screen.getByRole('combobox')).toHaveAttribute('aria-expanded', 'false') }) + expect(screen.queryByRole('listbox')).not.toBeInTheDocument() }) it('keeps the custom popup class in in-cell mode', async () => { diff --git a/web/app/components/workflow/panel/chat-variable-panel/components/variable-type-select.tsx b/web/app/components/workflow/panel/chat-variable-panel/components/variable-type-select.tsx index 94a0100de2..e1f776f3d5 100644 --- a/web/app/components/workflow/panel/chat-variable-panel/components/variable-type-select.tsx +++ b/web/app/components/workflow/panel/chat-variable-panel/components/variable-type-select.tsx @@ -1,38 +1,47 @@ 'use client' import { cn } from '@langgenius/dify-ui/cn' -import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react' +import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select' import * as React from 'react' import { useState } from 'react' -import { - PortalToFollowElem, - PortalToFollowElemContent, - PortalToFollowElemTrigger, -} from '@/app/components/base/portal-to-follow-elem' -type Props = { +type Props = { inCell?: boolean - value?: any - list: any - onSelect: (value: any) => void + value?: T + list: readonly T[] + onSelect: (value: T) => void popupClassName?: string } -const VariableTypeSelector = ({ +const VariableTypeSelector = ({ inCell = false, value, list, onSelect, popupClassName, -}: Props) => { +}: Props) => { const [open, setOpen] = useState(false) + const handleValueChange = (nextValue: string | null) => { + if (!nextValue) + return + + const nextItem = list.find(item => item === nextValue) + if (!nextItem) + return + + onSelect(nextItem) + } + return ( - setOpen(v => !v)} - placement="bottom" + onOpenChange={setOpen} + onValueChange={handleValueChange} > - setOpen(v => !v)}> +
{value}
- +
- -
- {list.map((item: any) => ( -
{ - onSelect(item) - setOpen(false) - }} - > -
{item}
- {value === item && } -
- ))} -
-
-
+ + + {list.map(item => ( + + {item} + + + ))} + + ) } From 4036515abe0f4c5e9eb4805d2f408d760baf82c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Mon, 27 Apr 2026 14:07:03 +0800 Subject: [PATCH 035/128] fix: improve variable picker text width allocation (#35587) Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- .../__tests__/var-reference-picker.helpers.spec.ts | 10 ++++++++++ .../variable/var-reference-picker.helpers.ts | 8 ++++++-- .../_base/components/variable/var-reference-picker.tsx | 6 ++++-- 3 files changed, 20 insertions(+), 4 deletions(-) diff --git a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.helpers.spec.ts b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.helpers.spec.ts index 7cef3ddde4..6b9ec7a642 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.helpers.spec.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/__tests__/var-reference-picker.helpers.spec.ts @@ -182,6 +182,16 @@ describe('var-reference-picker.helpers', () => { maxVarNameWidth: expect.any(Number), }) + expect(getWidthAllocations(240, '', 'sys.user_id', 'String')).toEqual({ + maxNodeNameWidth: 0, + maxTypeWidth: 64, + maxVarNameWidth: 119, + }) + + expect(getWidthAllocations(240, 'User Input', 'aa', 'String')).toMatchObject({ + maxVarNameWidth: 16, + }) + expect(getTooltipContent(true, true, true)).toBe('full-path') expect(getTooltipContent(true, false, false)).toBe('invalid-variable') expect(getTooltipContent(false, false, true)).toBeNull() diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.helpers.ts b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.helpers.ts index 6cdcb916e6..f29e99cc37 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.helpers.ts +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.helpers.ts @@ -168,11 +168,15 @@ export const getWidthAllocations = ( ) => { const availableWidth = triggerWidth - 56 const totalTextLength = (nodeTitle + varName + type).length || 1 - const priorityWidth = 15 + const priorityWidth = nodeTitle ? 15 : 0 + const minVarNameWidth = varName ? 16 : 0 return { maxNodeNameWidth: priorityWidth + Math.floor(nodeTitle.length / totalTextLength * availableWidth), maxTypeWidth: Math.floor(type.length / totalTextLength * availableWidth), - maxVarNameWidth: -priorityWidth + Math.floor(varName.length / totalTextLength * availableWidth), + maxVarNameWidth: Math.max( + minVarNameWidth, + -priorityWidth + Math.floor(varName.length / totalTextLength * availableWidth), + ), } } diff --git a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx index 7e99988ae8..c2645ee870 100644 --- a/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx +++ b/web/app/components/workflow/nodes/_base/components/variable/var-reference-picker.tsx @@ -279,13 +279,15 @@ const VarReferencePicker: FC = ({ [outputVarNode?.type, varName], ) const showErrorIcon = hasValue && !isValidVar + const shouldShowNodeName = isShowNodeName && !isEnv && !isChatVar && !isGlobal && !isRagVar + const visibleNodeTitle = shouldShowNodeName ? outputVarNode?.title || '' : '' // 8(left/right-padding) + 14(icon) + 4 + 14 + 2 = 42 + 17 buff const { maxNodeNameWidth, maxTypeWidth, maxVarNameWidth, - } = getWidthAllocations(triggerWidth, outputVarNode?.title || '', varName || '', type || '') + } = getWidthAllocations(triggerWidth, visibleNodeTitle, varName || '', type || '') const hoverPopup = useMemo(() => { const tooltipType = getTooltipContent(hasValue, isShowAPart, isValidVar) @@ -380,7 +382,7 @@ const VarReferencePicker: FC = ({ isJustShowValue={isJustShowValue} isLoading={isLoading} isShowAPart={isShowAPart} - isShowNodeName={isShowNodeName && !isEnv && !isChatVar && !isGlobal && !isRagVar} + isShowNodeName={shouldShowNodeName} isSupportConstantValue={isSupportConstantValue} maxNodeNameWidth={maxNodeNameWidth} maxTypeWidth={maxTypeWidth} From 3a28868a6c9e60eabbe48d183b21ba851f92e19f Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:10:43 +0800 Subject: [PATCH 036/128] ci: upgrade web test runners (#35593) --- .github/workflows/web-tests.yml | 6 +++--- packages/dify-ui/src/select/__tests__/index.spec.tsx | 4 ---- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml index db6a797c15..4619f3c104 100644 --- a/.github/workflows/web-tests.yml +++ b/.github/workflows/web-tests.yml @@ -16,7 +16,7 @@ concurrency: jobs: test: name: Web Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) - runs-on: depot-ubuntu-24.04 + runs-on: depot-ubuntu-24.04-4 env: VITEST_COVERAGE_SCOPE: app-components strategy: @@ -54,7 +54,7 @@ jobs: name: Merge Test Reports if: ${{ !cancelled() }} needs: [test] - runs-on: depot-ubuntu-24.04 + runs-on: depot-ubuntu-24.04-4 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} defaults: @@ -92,7 +92,7 @@ jobs: dify-ui-test: name: dify-ui Tests - runs-on: depot-ubuntu-24.04 + runs-on: depot-ubuntu-24.04-4 env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} defaults: diff --git a/packages/dify-ui/src/select/__tests__/index.spec.tsx b/packages/dify-ui/src/select/__tests__/index.spec.tsx index 9e3e945de0..f2f3221eda 100644 --- a/packages/dify-ui/src/select/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/select/__tests__/index.spec.tsx @@ -194,7 +194,6 @@ describe('Select wrappers', () => { }) it('should forward passthrough props to positioner popup and list when passthrough props are provided', async () => { - const onPositionerMouseEnter = vi.fn() const onPopupClick = vi.fn() const onListFocus = vi.fn() @@ -208,7 +207,6 @@ describe('Select wrappers', () => { 'role': 'group', 'aria-label': 'select positioner', 'id': 'select-positioner', - 'onMouseEnter': onPositionerMouseEnter, }} popupProps={{ 'role': 'dialog', @@ -231,7 +229,6 @@ describe('Select wrappers', () => { , ) - await screen.getByRole('group', { name: 'select positioner' }).hover() await screen.getByRole('dialog', { name: 'select popup' }).click() screen.getByRole('listbox', { name: 'select list' }).element().dispatchEvent(new FocusEvent('focusin', { bubbles: true, @@ -240,7 +237,6 @@ describe('Select wrappers', () => { await expect.element(screen.getByRole('group', { name: 'select positioner' })).toHaveAttribute('id', 'select-positioner') await expect.element(screen.getByRole('dialog', { name: 'select popup' })).toHaveAttribute('id', 'select-popup') await expect.element(screen.getByRole('listbox', { name: 'select list' })).toHaveAttribute('id', 'select-list') - expect(onPositionerMouseEnter).toHaveBeenCalledTimes(1) expect(onPopupClick).toHaveBeenCalledTimes(1) expect(onListFocus).toHaveBeenCalled() }) From 89bf75eba93e14efa70e87f843fac9803f40d30c Mon Sep 17 00:00:00 2001 From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:33:34 +0800 Subject: [PATCH 037/128] fix: enhance file uploader with billing support and update translations (#35583) Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- .../__tests__/upload-dropzone.spec.tsx | 56 ++++++++++++++++++- .../components/upload-dropzone.tsx | 26 ++++++--- .../__tests__/upload-dropzone.spec.tsx | 56 ++++++++++++++++++- .../local-file/components/upload-dropzone.tsx | 26 ++++++--- web/i18n/ar-TN/dataset-creation.json | 3 +- web/i18n/de-DE/dataset-creation.json | 3 +- web/i18n/en-US/dataset-creation.json | 3 +- web/i18n/es-ES/dataset-creation.json | 3 +- web/i18n/fa-IR/dataset-creation.json | 3 +- web/i18n/fr-FR/dataset-creation.json | 3 +- web/i18n/hi-IN/dataset-creation.json | 3 +- web/i18n/id-ID/dataset-creation.json | 3 +- web/i18n/it-IT/dataset-creation.json | 3 +- web/i18n/ja-JP/dataset-creation.json | 3 +- web/i18n/ko-KR/dataset-creation.json | 3 +- web/i18n/nl-NL/dataset-creation.json | 3 +- web/i18n/pl-PL/dataset-creation.json | 3 +- web/i18n/pt-BR/dataset-creation.json | 3 +- web/i18n/ro-RO/dataset-creation.json | 3 +- web/i18n/ru-RU/dataset-creation.json | 3 +- web/i18n/sl-SI/dataset-creation.json | 3 +- web/i18n/th-TH/dataset-creation.json | 3 +- web/i18n/tr-TR/dataset-creation.json | 3 +- web/i18n/uk-UA/dataset-creation.json | 3 +- web/i18n/vi-VN/dataset-creation.json | 3 +- web/i18n/zh-Hans/dataset-creation.json | 3 +- web/i18n/zh-Hant/dataset-creation.json | 3 +- 27 files changed, 190 insertions(+), 43 deletions(-) diff --git a/web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx b/web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx index ee769c110e..ac5014e4b2 100644 --- a/web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx +++ b/web/app/components/datasets/create/file-uploader/components/__tests__/upload-dropzone.spec.tsx @@ -1,9 +1,17 @@ import type { RefObject } from 'react' import type { UploadDropzoneProps } from '../upload-dropzone' +import type { ProviderContextState } from '@/context/provider-context' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import UploadDropzone from '../upload-dropzone' +let mockEnableBilling = false + +vi.mock('@/context/provider-context', () => ({ + useProviderContextSelector: (selector: (state: Pick) => T): T => + selector({ enableBilling: mockEnableBilling }), +})) + // Helper to create mock ref objects for testing const createMockRef = (value: T | null = null): RefObject => ({ current: value }) @@ -27,6 +35,7 @@ describe('UploadDropzone', () => { beforeEach(() => { vi.clearAllMocks() + mockEnableBilling = false }) describe('rendering', () => { @@ -46,7 +55,7 @@ describe('UploadDropzone', () => { it('should render upload icon', () => { render() - const icon = document.querySelector('svg') + const icon = document.querySelector('.i-ri-upload-cloud-2-line') expect(icon).toBeInTheDocument() }) @@ -67,6 +76,51 @@ describe('UploadDropzone', () => { }) }) + describe('tip rendering by billing state', () => { + it('should render tip without total count limit when billing is disabled', () => { + mockEnableBilling = false + + render() + + const tipWithoutTotal = screen.getByText(/datasetCreation\.stepOne\.uploader\.tip(?!WithTotalLimit)/) + expect(tipWithoutTotal).toBeInTheDocument() + expect(screen.queryByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/)).not.toBeInTheDocument() + }) + + it('should render tip with total count limit when billing is enabled', () => { + mockEnableBilling = true + + render() + + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/)).toBeInTheDocument() + expect(screen.queryByText(/datasetCreation\.stepOne\.uploader\.tip(?!WithTotalLimit)/)).not.toBeInTheDocument() + }) + + it('should pass file size, batch count and supported types to tip when billing is disabled', () => { + mockEnableBilling = false + + render() + + const tipText = screen.getByText(/datasetCreation\.stepOne\.uploader\.tip/).textContent ?? '' + expect(tipText).toContain('"size":15') + expect(tipText).toContain('"batchCount":5') + expect(tipText).toContain('"supportTypes":"PDF, DOCX, TXT"') + expect(tipText).not.toContain('"totalCount"') + }) + + it('should additionally pass total count to tip when billing is enabled', () => { + mockEnableBilling = true + + render() + + const tipText = screen.getByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/).textContent ?? '' + expect(tipText).toContain('"size":15') + expect(tipText).toContain('"batchCount":5') + expect(tipText).toContain('"supportTypes":"PDF, DOCX, TXT"') + expect(tipText).toContain('"totalCount":10') + }) + }) + describe('file input configuration', () => { it('should allow multiple files when supportBatchUpload is true', () => { render() diff --git a/web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx b/web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx index 2a2a40d5b8..be05fd55ba 100644 --- a/web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx +++ b/web/app/components/datasets/create/file-uploader/components/upload-dropzone.tsx @@ -2,8 +2,8 @@ import type { RefObject } from 'react' import type { FileUploadConfig } from '../hooks/use-file-upload' import { cn } from '@langgenius/dify-ui/cn' -import { RiUploadCloud2Line } from '@remixicon/react' import { useTranslation } from 'react-i18next' +import { useProviderContextSelector } from '@/context/provider-context' export type UploadDropzoneProps = { dropRef: RefObject @@ -31,6 +31,7 @@ const UploadDropzone = ({ onFileChange, }: UploadDropzoneProps) => { const { t } = useTranslation() + const enableBilling = useProviderContextSelector(state => state.enableBilling) return ( <> @@ -51,7 +52,7 @@ const UploadDropzone = ({ )} >
- + {supportBatchUpload ? t('stepOne.uploader.button', { ns: 'datasetCreation' }) @@ -67,13 +68,20 @@ const UploadDropzone = ({
- {t('stepOne.uploader.tip', { - ns: 'datasetCreation', - size: fileUploadConfig.file_size_limit, - supportTypes: supportTypesShowNames, - batchCount: fileUploadConfig.batch_count_limit, - totalCount: fileUploadConfig.file_upload_limit, - })} + {enableBilling + ? t('stepOne.uploader.tipWithTotalLimit', { + ns: 'datasetCreation', + size: fileUploadConfig.file_size_limit, + supportTypes: supportTypesShowNames, + batchCount: fileUploadConfig.batch_count_limit, + totalCount: fileUploadConfig.file_upload_limit, + }) + : t('stepOne.uploader.tip', { + ns: 'datasetCreation', + size: fileUploadConfig.file_size_limit, + supportTypes: supportTypesShowNames, + batchCount: fileUploadConfig.batch_count_limit, + })}
{dragging &&
}
diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx index 74b4a3b194..3ade486474 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/__tests__/upload-dropzone.spec.tsx @@ -1,9 +1,17 @@ import type { RefObject } from 'react' import type { UploadDropzoneProps } from '../upload-dropzone' +import type { ProviderContextState } from '@/context/provider-context' import { fireEvent, render, screen } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' import UploadDropzone from '../upload-dropzone' +let mockEnableBilling = false + +vi.mock('@/context/provider-context', () => ({ + useProviderContextSelector: (selector: (state: Pick) => T): T => + selector({ enableBilling: mockEnableBilling }), +})) + // Helper to create mock ref objects for testing const createMockRef = (value: T | null = null): RefObject => ({ current: value }) @@ -28,6 +36,7 @@ describe('UploadDropzone', () => { beforeEach(() => { vi.clearAllMocks() + mockEnableBilling = false }) describe('rendering', () => { @@ -50,7 +59,7 @@ describe('UploadDropzone', () => { it('should render upload icon', () => { render() - const icon = document.querySelector('svg') + const icon = document.querySelector('.i-ri-upload-cloud-2-line') expect(icon).toBeInTheDocument() }) @@ -73,6 +82,51 @@ describe('UploadDropzone', () => { }) }) + describe('tip rendering by billing state', () => { + it('should render tip without total count limit when billing is disabled', () => { + mockEnableBilling = false + + render() + + const tipWithoutTotal = screen.getByText(/datasetCreation\.stepOne\.uploader\.tip(?!WithTotalLimit)/) + expect(tipWithoutTotal).toBeInTheDocument() + expect(screen.queryByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/)).not.toBeInTheDocument() + }) + + it('should render tip with total count limit when billing is enabled', () => { + mockEnableBilling = true + + render() + + expect(screen.getByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/)).toBeInTheDocument() + expect(screen.queryByText(/datasetCreation\.stepOne\.uploader\.tip(?!WithTotalLimit)/)).not.toBeInTheDocument() + }) + + it('should pass file size, batch count and supported types to tip when billing is disabled', () => { + mockEnableBilling = false + + render() + + const tipText = screen.getByText(/datasetCreation\.stepOne\.uploader\.tip/).textContent ?? '' + expect(tipText).toContain('"size":15') + expect(tipText).toContain('"batchCount":5') + expect(tipText).toContain('"supportTypes":"PDF, DOCX, TXT"') + expect(tipText).not.toContain('"totalCount"') + }) + + it('should additionally pass total count to tip when billing is enabled', () => { + mockEnableBilling = true + + render() + + const tipText = screen.getByText(/datasetCreation\.stepOne\.uploader\.tipWithTotalLimit/).textContent ?? '' + expect(tipText).toContain('"size":15') + expect(tipText).toContain('"batchCount":5') + expect(tipText).toContain('"supportTypes":"PDF, DOCX, TXT"') + expect(tipText).toContain('"totalCount":10') + }) + }) + describe('file input configuration', () => { it('should allow multiple files when supportBatchUpload is true', () => { render() diff --git a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx index 32aee588df..eab0dd4ce0 100644 --- a/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx +++ b/web/app/components/datasets/documents/create-from-pipeline/data-source/local-file/components/upload-dropzone.tsx @@ -1,7 +1,7 @@ import type { ChangeEvent, RefObject } from 'react' import { cn } from '@langgenius/dify-ui/cn' -import { RiUploadCloud2Line } from '@remixicon/react' import { useTranslation } from 'react-i18next' +import { useProviderContextSelector } from '@/context/provider-context' type FileUploadConfig = { file_size_limit: number @@ -37,6 +37,7 @@ const UploadDropzone = ({ allowedExtensions, }: UploadDropzoneProps) => { const { t } = useTranslation() + const enableBilling = useProviderContextSelector(state => state.enableBilling) return ( <> @@ -57,7 +58,7 @@ const UploadDropzone = ({ )} >
- + {supportBatchUpload ? t('stepOne.uploader.button', { ns: 'datasetCreation' }) : t('stepOne.uploader.buttonSingleFile', { ns: 'datasetCreation' })} {allowedExtensions.length > 0 && ( @@ -66,13 +67,20 @@ const UploadDropzone = ({
- {t('stepOne.uploader.tip', { - ns: 'datasetCreation', - size: fileUploadConfig.file_size_limit, - supportTypes: supportTypesShowNames, - batchCount: fileUploadConfig.batch_count_limit, - totalCount: fileUploadConfig.file_upload_limit, - })} + {enableBilling + ? t('stepOne.uploader.tipWithTotalLimit', { + ns: 'datasetCreation', + size: fileUploadConfig.file_size_limit, + supportTypes: supportTypesShowNames, + batchCount: fileUploadConfig.batch_count_limit, + totalCount: fileUploadConfig.file_upload_limit, + }) + : t('stepOne.uploader.tip', { + ns: 'datasetCreation', + size: fileUploadConfig.file_size_limit, + supportTypes: supportTypesShowNames, + batchCount: fileUploadConfig.batch_count_limit, + })}
{dragging &&
}
diff --git a/web/i18n/ar-TN/dataset-creation.json b/web/i18n/ar-TN/dataset-creation.json index 42e9525954..33d80cac8e 100644 --- a/web/i18n/ar-TN/dataset-creation.json +++ b/web/i18n/ar-TN/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "إلغاء", "stepOne.uploader.change": "تغيير", "stepOne.uploader.failed": "فشل التحميل", - "stepOne.uploader.tip": "يدعم {{supportTypes}}. بحد أقصى {{batchCount}} في الدفعة الواحدة و {{size}} ميجابايت لكل منها. الحد الأقصى الإجمالي {{totalCount}} ملفات.", + "stepOne.uploader.tip": "يدعم {{supportTypes}}. بحد أقصى {{batchCount}} في الدفعة الواحدة و {{size}} ميجابايت لكل منها.", + "stepOne.uploader.tipWithTotalLimit": "يدعم {{supportTypes}}. بحد أقصى {{batchCount}} في الدفعة الواحدة و {{size}} ميجابايت لكل منها. الحد الأقصى الإجمالي {{totalCount}} ملفات.", "stepOne.uploader.title": "تحميل ملف", "stepOne.uploader.validation.count": "ملفات متعددة غير مدعومة", "stepOne.uploader.validation.filesNumber": "لقد وصلت إلى حد تحميل الدفعة البالغ {{filesNumber}}.", diff --git a/web/i18n/de-DE/dataset-creation.json b/web/i18n/de-DE/dataset-creation.json index 4d61c0e26b..523e881a0e 100644 --- a/web/i18n/de-DE/dataset-creation.json +++ b/web/i18n/de-DE/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "Abbrechen", "stepOne.uploader.change": "Ändern", "stepOne.uploader.failed": "Hochladen fehlgeschlagen", - "stepOne.uploader.tip": "Unterstützt {{supportTypes}}. Maximal {{batchCount}} Dateien pro Batch und {{size}} MB pro Datei. Insgesamt maximal {{totalCount}} Dateien.", + "stepOne.uploader.tip": "Unterstützt {{supportTypes}}. Maximal {{batchCount}} Dateien pro Batch und {{size}} MB pro Datei.", + "stepOne.uploader.tipWithTotalLimit": "Unterstützt {{supportTypes}}. Maximal {{batchCount}} Dateien pro Batch und {{size}} MB pro Datei. Insgesamt maximal {{totalCount}} Dateien.", "stepOne.uploader.title": "Textdatei hochladen", "stepOne.uploader.validation.count": "Mehrere Dateien nicht unterstützt", "stepOne.uploader.validation.filesNumber": "Sie haben das Limit für die Stapelverarbeitung von {{filesNumber}} erreicht.", diff --git a/web/i18n/en-US/dataset-creation.json b/web/i18n/en-US/dataset-creation.json index e544aaa097..1628a8641e 100644 --- a/web/i18n/en-US/dataset-creation.json +++ b/web/i18n/en-US/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "Cancel", "stepOne.uploader.change": "Change", "stepOne.uploader.failed": "Upload failed", - "stepOne.uploader.tip": "Supports {{supportTypes}}. Max {{batchCount}} in a batch and {{size}} MB each. Max total {{totalCount}} files.", + "stepOne.uploader.tip": "Supports {{supportTypes}}. Max {{batchCount}} in a batch and {{size}} MB each.", + "stepOne.uploader.tipWithTotalLimit": "Supports {{supportTypes}}. Max {{batchCount}} in a batch and {{size}} MB each. Max total {{totalCount}} files.", "stepOne.uploader.title": "Upload file", "stepOne.uploader.validation.count": "Multiple files not supported", "stepOne.uploader.validation.filesNumber": "You have reached the batch upload limit of {{filesNumber}}.", diff --git a/web/i18n/es-ES/dataset-creation.json b/web/i18n/es-ES/dataset-creation.json index 9712a8ba26..571c94dd6d 100644 --- a/web/i18n/es-ES/dataset-creation.json +++ b/web/i18n/es-ES/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "Cancelar", "stepOne.uploader.change": "Cambiar", "stepOne.uploader.failed": "Error al cargar", - "stepOne.uploader.tip": "Soporta {{supportTypes}}. Máximo {{batchCount}} archivos por lote y {{size}} MB cada uno. Total máximo de {{totalCount}} archivos.", + "stepOne.uploader.tip": "Soporta {{supportTypes}}. Máximo {{batchCount}} archivos por lote y {{size}} MB cada uno.", + "stepOne.uploader.tipWithTotalLimit": "Soporta {{supportTypes}}. Máximo {{batchCount}} archivos por lote y {{size}} MB cada uno. Total máximo de {{totalCount}} archivos.", "stepOne.uploader.title": "Cargar archivo", "stepOne.uploader.validation.count": "No se admiten varios archivos", "stepOne.uploader.validation.filesNumber": "Has alcanzado el límite de carga por lotes de {{filesNumber}}.", diff --git a/web/i18n/fa-IR/dataset-creation.json b/web/i18n/fa-IR/dataset-creation.json index d8717e54c7..98b4ca9c08 100644 --- a/web/i18n/fa-IR/dataset-creation.json +++ b/web/i18n/fa-IR/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "لغو", "stepOne.uploader.change": "تغییر", "stepOne.uploader.failed": "بارگذاری ناموفق بود", - "stepOne.uploader.tip": "پشتیبانی از {{supportTypes}}. حداکثر {{batchCount}} فایل در هر دسته و {{size}} مگابایت برای هر فایل. حداکثر کل {{totalCount}} فایل.", + "stepOne.uploader.tip": "پشتیبانی از {{supportTypes}}. حداکثر {{batchCount}} فایل در هر دسته و {{size}} مگابایت برای هر فایل.", + "stepOne.uploader.tipWithTotalLimit": "پشتیبانی از {{supportTypes}}. حداکثر {{batchCount}} فایل در هر دسته و {{size}} مگابایت برای هر فایل. حداکثر کل {{totalCount}} فایل.", "stepOne.uploader.title": "بارگذاری فایل", "stepOne.uploader.validation.count": "چندین فایل پشتیبانی نمیشود", "stepOne.uploader.validation.filesNumber": "شما به حد مجاز بارگذاری دستهای {{filesNumber}} رسیدهاید.", diff --git a/web/i18n/fr-FR/dataset-creation.json b/web/i18n/fr-FR/dataset-creation.json index 2e415066e9..4d8742945f 100644 --- a/web/i18n/fr-FR/dataset-creation.json +++ b/web/i18n/fr-FR/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "Annuler", "stepOne.uploader.change": "Changer", "stepOne.uploader.failed": "Le téléchargement a échoué", - "stepOne.uploader.tip": "Prend en charge {{supportTypes}}. Maximum {{batchCount}} fichiers par lot et {{size}} MB chacun. Maximum total de {{totalCount}} fichiers.", + "stepOne.uploader.tip": "Prend en charge {{supportTypes}}. Maximum {{batchCount}} fichiers par lot et {{size}} MB chacun.", + "stepOne.uploader.tipWithTotalLimit": "Prend en charge {{supportTypes}}. Maximum {{batchCount}} fichiers par lot et {{size}} MB chacun. Maximum total de {{totalCount}} fichiers.", "stepOne.uploader.title": "Télécharger le fichier texte", "stepOne.uploader.validation.count": "Plusieurs fichiers non pris en charge", "stepOne.uploader.validation.filesNumber": "Vous avez atteint la limite de téléchargement par lot de {{filesNumber}}.", diff --git a/web/i18n/hi-IN/dataset-creation.json b/web/i18n/hi-IN/dataset-creation.json index 7b3cc55537..70e8bf20e5 100644 --- a/web/i18n/hi-IN/dataset-creation.json +++ b/web/i18n/hi-IN/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "रद्द करें", "stepOne.uploader.change": "बदलें", "stepOne.uploader.failed": "अपलोड विफल रहा", - "stepOne.uploader.tip": "{{supportTypes}} समर्थित है। एक बैच में अधिकतम {{batchCount}} फ़ाइलें और प्रत्येक {{size}} MB। कुल अधिकतम {{totalCount}} फ़ाइलें।", + "stepOne.uploader.tip": "{{supportTypes}} समर्थित है। एक बैच में अधिकतम {{batchCount}} फ़ाइलें और प्रत्येक {{size}} MB।", + "stepOne.uploader.tipWithTotalLimit": "{{supportTypes}} समर्थित है। एक बैच में अधिकतम {{batchCount}} फ़ाइलें और प्रत्येक {{size}} MB। कुल अधिकतम {{totalCount}} फ़ाइलें।", "stepOne.uploader.title": "फ़ाइल अपलोड करें", "stepOne.uploader.validation.count": "एकाधिक फ़ाइलें समर्थित नहीं हैं", "stepOne.uploader.validation.filesNumber": "आपने {{filesNumber}} की बैच अपलोड सीमा तक पहुँच गए हैं।", diff --git a/web/i18n/id-ID/dataset-creation.json b/web/i18n/id-ID/dataset-creation.json index 42c6f08a34..a6f06c8c52 100644 --- a/web/i18n/id-ID/dataset-creation.json +++ b/web/i18n/id-ID/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "Membatalkan", "stepOne.uploader.change": "Ubah", "stepOne.uploader.failed": "Upload gagal", - "stepOne.uploader.tip": "Mendukung {{supportTypes}}. Maksimal {{batchCount}} dalam satu batch dan {{size}} MB masing-masing. Total maksimal {{totalCount}} file.", + "stepOne.uploader.tip": "Mendukung {{supportTypes}}. Maksimal {{batchCount}} dalam satu batch dan {{size}} MB masing-masing.", + "stepOne.uploader.tipWithTotalLimit": "Mendukung {{supportTypes}}. Maksimal {{batchCount}} dalam satu batch dan {{size}} MB masing-masing. Total maksimal {{totalCount}} file.", "stepOne.uploader.title": "Unggah file", "stepOne.uploader.validation.count": "Beberapa file tidak didukung", "stepOne.uploader.validation.filesNumber": "Anda telah mencapai batas unggah batch sebanyak {{filesNumber}}.", diff --git a/web/i18n/it-IT/dataset-creation.json b/web/i18n/it-IT/dataset-creation.json index 59226f0a50..b53a9847a6 100644 --- a/web/i18n/it-IT/dataset-creation.json +++ b/web/i18n/it-IT/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "Annulla", "stepOne.uploader.change": "Cambia", "stepOne.uploader.failed": "Caricamento fallito", - "stepOne.uploader.tip": "Supporta {{supportTypes}}. Massimo {{batchCount}} file per batch e {{size}} MB ciascuno. Totale massimo {{totalCount}} file.", + "stepOne.uploader.tip": "Supporta {{supportTypes}}. Massimo {{batchCount}} file per batch e {{size}} MB ciascuno.", + "stepOne.uploader.tipWithTotalLimit": "Supporta {{supportTypes}}. Massimo {{batchCount}} file per batch e {{size}} MB ciascuno. Totale massimo {{totalCount}} file.", "stepOne.uploader.title": "Carica file", "stepOne.uploader.validation.count": "Più file non supportati", "stepOne.uploader.validation.filesNumber": "Hai raggiunto il limite di caricamento batch di {{filesNumber}}.", diff --git a/web/i18n/ja-JP/dataset-creation.json b/web/i18n/ja-JP/dataset-creation.json index 3115b69070..14ab74357d 100644 --- a/web/i18n/ja-JP/dataset-creation.json +++ b/web/i18n/ja-JP/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "キャンセル", "stepOne.uploader.change": "変更", "stepOne.uploader.failed": "アップロードに失敗しました", - "stepOne.uploader.tip": "{{supportTypes}}をサポートしています。1バッチあたり最大{{batchCount}}ファイル、各ファイル{{size}}MB まで。合計最大{{totalCount}}ファイル。", + "stepOne.uploader.tip": "{{supportTypes}}をサポートしています。1バッチあたり最大{{batchCount}}ファイル、各ファイル{{size}}MB まで。", + "stepOne.uploader.tipWithTotalLimit": "{{supportTypes}}をサポートしています。1バッチあたり最大{{batchCount}}ファイル、各ファイル{{size}}MB まで。合計最大{{totalCount}}ファイル。", "stepOne.uploader.title": "テキストファイルをアップロード", "stepOne.uploader.validation.count": "複数のファイルはサポートされていません", "stepOne.uploader.validation.filesNumber": "バッチアップロードの制限({{filesNumber}}個)に達しました。", diff --git a/web/i18n/ko-KR/dataset-creation.json b/web/i18n/ko-KR/dataset-creation.json index be3e198a7b..5a392a93f1 100644 --- a/web/i18n/ko-KR/dataset-creation.json +++ b/web/i18n/ko-KR/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "취소", "stepOne.uploader.change": "변경", "stepOne.uploader.failed": "업로드에 실패했습니다", - "stepOne.uploader.tip": "{{supportTypes}}을(를) 지원합니다. 배치당 최대 {{batchCount}}개 파일, 각 파일당 {{size}}MB까지. 총 최대 {{totalCount}}개 파일.", + "stepOne.uploader.tip": "{{supportTypes}}을(를) 지원합니다. 배치당 최대 {{batchCount}}개 파일, 각 파일당 {{size}}MB까지.", + "stepOne.uploader.tipWithTotalLimit": "{{supportTypes}}을(를) 지원합니다. 배치당 최대 {{batchCount}}개 파일, 각 파일당 {{size}}MB까지. 총 최대 {{totalCount}}개 파일.", "stepOne.uploader.title": "텍스트 파일 업로드", "stepOne.uploader.validation.count": "여러 파일은 지원되지 않습니다", "stepOne.uploader.validation.filesNumber": "일괄 업로드 제한 ({{filesNumber}}개) 에 도달했습니다.", diff --git a/web/i18n/nl-NL/dataset-creation.json b/web/i18n/nl-NL/dataset-creation.json index e544aaa097..1628a8641e 100644 --- a/web/i18n/nl-NL/dataset-creation.json +++ b/web/i18n/nl-NL/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "Cancel", "stepOne.uploader.change": "Change", "stepOne.uploader.failed": "Upload failed", - "stepOne.uploader.tip": "Supports {{supportTypes}}. Max {{batchCount}} in a batch and {{size}} MB each. Max total {{totalCount}} files.", + "stepOne.uploader.tip": "Supports {{supportTypes}}. Max {{batchCount}} in a batch and {{size}} MB each.", + "stepOne.uploader.tipWithTotalLimit": "Supports {{supportTypes}}. Max {{batchCount}} in a batch and {{size}} MB each. Max total {{totalCount}} files.", "stepOne.uploader.title": "Upload file", "stepOne.uploader.validation.count": "Multiple files not supported", "stepOne.uploader.validation.filesNumber": "You have reached the batch upload limit of {{filesNumber}}.", diff --git a/web/i18n/pl-PL/dataset-creation.json b/web/i18n/pl-PL/dataset-creation.json index eab4afed17..72aa227c26 100644 --- a/web/i18n/pl-PL/dataset-creation.json +++ b/web/i18n/pl-PL/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "Anuluj", "stepOne.uploader.change": "Zmień", "stepOne.uploader.failed": "Przesyłanie nie powiodło się", - "stepOne.uploader.tip": "Obsługuje {{supportTypes}}. Maksymalnie {{batchCount}} plików w partii, każdy do {{size}} MB. Łącznie maksymalnie {{totalCount}} plików.", + "stepOne.uploader.tip": "Obsługuje {{supportTypes}}. Maksymalnie {{batchCount}} plików w partii, każdy do {{size}} MB.", + "stepOne.uploader.tipWithTotalLimit": "Obsługuje {{supportTypes}}. Maksymalnie {{batchCount}} plików w partii, każdy do {{size}} MB. Łącznie maksymalnie {{totalCount}} plików.", "stepOne.uploader.title": "Prześlij plik tekstowy", "stepOne.uploader.validation.count": "Nieobsługiwane przesyłanie wielu plików", "stepOne.uploader.validation.filesNumber": "Osiągnąłeś limit przesłania partii {{filesNumber}}.", diff --git a/web/i18n/pt-BR/dataset-creation.json b/web/i18n/pt-BR/dataset-creation.json index 90469db226..9438ddef95 100644 --- a/web/i18n/pt-BR/dataset-creation.json +++ b/web/i18n/pt-BR/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "Cancelar", "stepOne.uploader.change": "Alterar", "stepOne.uploader.failed": "Falha no envio", - "stepOne.uploader.tip": "Suporta {{supportTypes}}. Máximo de {{batchCount}} arquivos por lote e {{size}} MB cada. Total máximo de {{totalCount}} arquivos.", + "stepOne.uploader.tip": "Suporta {{supportTypes}}. Máximo de {{batchCount}} arquivos por lote e {{size}} MB cada.", + "stepOne.uploader.tipWithTotalLimit": "Suporta {{supportTypes}}. Máximo de {{batchCount}} arquivos por lote e {{size}} MB cada. Total máximo de {{totalCount}} arquivos.", "stepOne.uploader.title": "Enviar arquivo de texto", "stepOne.uploader.validation.count": "Vários arquivos não suportados", "stepOne.uploader.validation.filesNumber": "Limite de upload em massa {{filesNumber}}.", diff --git a/web/i18n/ro-RO/dataset-creation.json b/web/i18n/ro-RO/dataset-creation.json index 62ccedceea..fcc22a93a1 100644 --- a/web/i18n/ro-RO/dataset-creation.json +++ b/web/i18n/ro-RO/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "Anulează", "stepOne.uploader.change": "Schimbă", "stepOne.uploader.failed": "Încărcarea a eșuat", - "stepOne.uploader.tip": "Acceptă {{supportTypes}}. Maxim {{batchCount}} fișiere pe lot și {{size}} MB fiecare. Total maxim {{totalCount}} fișiere.", + "stepOne.uploader.tip": "Acceptă {{supportTypes}}. Maxim {{batchCount}} fișiere pe lot și {{size}} MB fiecare.", + "stepOne.uploader.tipWithTotalLimit": "Acceptă {{supportTypes}}. Maxim {{batchCount}} fișiere pe lot și {{size}} MB fiecare. Total maxim {{totalCount}} fișiere.", "stepOne.uploader.title": "Încărcați fișier text", "stepOne.uploader.validation.count": "Nu se acceptă mai multe fișiere", "stepOne.uploader.validation.filesNumber": "Ați atins limita de încărcare în lot de {{filesNumber}} fișiere.", diff --git a/web/i18n/ru-RU/dataset-creation.json b/web/i18n/ru-RU/dataset-creation.json index d5e72438e6..0ff68b948c 100644 --- a/web/i18n/ru-RU/dataset-creation.json +++ b/web/i18n/ru-RU/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "Отмена", "stepOne.uploader.change": "Изменить", "stepOne.uploader.failed": "Ошибка загрузки", - "stepOne.uploader.tip": "Поддерживаются {{supportTypes}}. Максимум {{batchCount}} файлов за раз, каждый до {{size}} МБ. Всего максимум {{totalCount}} файлов.", + "stepOne.uploader.tip": "Поддерживаются {{supportTypes}}. Максимум {{batchCount}} файлов за раз, каждый до {{size}} МБ.", + "stepOne.uploader.tipWithTotalLimit": "Поддерживаются {{supportTypes}}. Максимум {{batchCount}} файлов за раз, каждый до {{size}} МБ. Всего максимум {{totalCount}} файлов.", "stepOne.uploader.title": "Загрузить файл", "stepOne.uploader.validation.count": "Несколько файлов не поддерживаются", "stepOne.uploader.validation.filesNumber": "Вы достигли лимита пакетной загрузки {{filesNumber}} файлов.", diff --git a/web/i18n/sl-SI/dataset-creation.json b/web/i18n/sl-SI/dataset-creation.json index d2ab2cd6bb..37f283ee32 100644 --- a/web/i18n/sl-SI/dataset-creation.json +++ b/web/i18n/sl-SI/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "Prekliči", "stepOne.uploader.change": "Zamenjaj", "stepOne.uploader.failed": "Nalaganje ni uspelo", - "stepOne.uploader.tip": "Podpira {{supportTypes}}. Največje število datotek v seriji: {{batchCount}}, vsaka do {{size}} MB. Skupaj največ {{totalCount}} datotek.", + "stepOne.uploader.tip": "Podpira {{supportTypes}}. Največje število datotek v seriji: {{batchCount}}, vsaka do {{size}} MB.", + "stepOne.uploader.tipWithTotalLimit": "Podpira {{supportTypes}}. Največje število datotek v seriji: {{batchCount}}, vsaka do {{size}} MB. Skupaj največ {{totalCount}} datotek.", "stepOne.uploader.title": "Naloži datoteko", "stepOne.uploader.validation.count": "Podprta je le ena datoteka", "stepOne.uploader.validation.filesNumber": "Dosegli ste omejitev za pošiljanje {{filesNumber}} datotek.", diff --git a/web/i18n/th-TH/dataset-creation.json b/web/i18n/th-TH/dataset-creation.json index 4f8d5dc1a1..eab4eadd78 100644 --- a/web/i18n/th-TH/dataset-creation.json +++ b/web/i18n/th-TH/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "ยกเลิก", "stepOne.uploader.change": "เปลี่ยน", "stepOne.uploader.failed": "อัปโหลดล้มเหลว", - "stepOne.uploader.tip": "รองรับ {{supportTypes}} สูงสุด {{batchCount}} ไฟล์ต่อชุดและ {{size}} MB แต่ละไฟล์ รวมสูงสุด {{totalCount}} ไฟล์", + "stepOne.uploader.tip": "รองรับ {{supportTypes}} สูงสุด {{batchCount}} ไฟล์ต่อชุดและ {{size}} MB แต่ละไฟล์", + "stepOne.uploader.tipWithTotalLimit": "รองรับ {{supportTypes}} สูงสุด {{batchCount}} ไฟล์ต่อชุดและ {{size}} MB แต่ละไฟล์ รวมสูงสุด {{totalCount}} ไฟล์", "stepOne.uploader.title": "อัปโหลดไฟล์", "stepOne.uploader.validation.count": "ไม่รองรับหลายไฟล์", "stepOne.uploader.validation.filesNumber": "คุณถึงขีดจํากัดการอัปโหลดเป็นชุดของ {{filesNumber}} แล้ว", diff --git a/web/i18n/tr-TR/dataset-creation.json b/web/i18n/tr-TR/dataset-creation.json index 81f09945c2..b90a1673ee 100644 --- a/web/i18n/tr-TR/dataset-creation.json +++ b/web/i18n/tr-TR/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "İptal", "stepOne.uploader.change": "Değiştir", "stepOne.uploader.failed": "Yükleme başarısız", - "stepOne.uploader.tip": "{{supportTypes}} destekler. Parti başına en fazla {{batchCount}} dosya ve her biri {{size}} MB. Toplam en fazla {{totalCount}} dosya.", + "stepOne.uploader.tip": "{{supportTypes}} destekler. Parti başına en fazla {{batchCount}} dosya ve her biri {{size}} MB.", + "stepOne.uploader.tipWithTotalLimit": "{{supportTypes}} destekler. Parti başına en fazla {{batchCount}} dosya ve her biri {{size}} MB. Toplam en fazla {{totalCount}} dosya.", "stepOne.uploader.title": "Dosya yükle", "stepOne.uploader.validation.count": "Birden fazla dosya desteklenmiyor", "stepOne.uploader.validation.filesNumber": "Toplu yükleme sınırına ulaştınız, {{filesNumber}} dosya.", diff --git a/web/i18n/uk-UA/dataset-creation.json b/web/i18n/uk-UA/dataset-creation.json index 781151fcd7..cb3a77c301 100644 --- a/web/i18n/uk-UA/dataset-creation.json +++ b/web/i18n/uk-UA/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "Скасувати", "stepOne.uploader.change": "Змінити", "stepOne.uploader.failed": "Завантаження не вдалося", - "stepOne.uploader.tip": "Підтримуються {{supportTypes}}. Максимум {{batchCount}} файлів за раз, кожен до {{size}} МБ. Загалом максимум {{totalCount}} файлів.", + "stepOne.uploader.tip": "Підтримуються {{supportTypes}}. Максимум {{batchCount}} файлів за раз, кожен до {{size}} МБ.", + "stepOne.uploader.tipWithTotalLimit": "Підтримуються {{supportTypes}}. Максимум {{batchCount}} файлів за раз, кожен до {{size}} МБ. Загалом максимум {{totalCount}} файлів.", "stepOne.uploader.title": "Завантажити текстовий файл", "stepOne.uploader.validation.count": "Не підтримується завантаження кількох файлів", "stepOne.uploader.validation.filesNumber": "Ліміт масового завантаження {{filesNumber}}.", diff --git a/web/i18n/vi-VN/dataset-creation.json b/web/i18n/vi-VN/dataset-creation.json index a36a782ca4..c2c64cac51 100644 --- a/web/i18n/vi-VN/dataset-creation.json +++ b/web/i18n/vi-VN/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "Hủy", "stepOne.uploader.change": "Thay đổi", "stepOne.uploader.failed": "Tải lên thất bại", - "stepOne.uploader.tip": "Hỗ trợ {{supportTypes}}. Tối đa {{batchCount}} tệp trong một lô và {{size}} MB mỗi tệp. Tổng tối đa {{totalCount}} tệp.", + "stepOne.uploader.tip": "Hỗ trợ {{supportTypes}}. Tối đa {{batchCount}} tệp trong một lô và {{size}} MB mỗi tệp.", + "stepOne.uploader.tipWithTotalLimit": "Hỗ trợ {{supportTypes}}. Tối đa {{batchCount}} tệp trong một lô và {{size}} MB mỗi tệp. Tổng tối đa {{totalCount}} tệp.", "stepOne.uploader.title": "Tải lên tệp văn bản", "stepOne.uploader.validation.count": "Không hỗ trợ tải lên nhiều tệp", "stepOne.uploader.validation.filesNumber": "Bạn đã đạt đến giới hạn tải lên lô của {{filesNumber}} tệp.", diff --git a/web/i18n/zh-Hans/dataset-creation.json b/web/i18n/zh-Hans/dataset-creation.json index 102f64e5e7..bcf794b163 100644 --- a/web/i18n/zh-Hans/dataset-creation.json +++ b/web/i18n/zh-Hans/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "取消", "stepOne.uploader.change": "更改文件", "stepOne.uploader.failed": "上传失败", - "stepOne.uploader.tip": "已支持 {{supportTypes}},每批最多 {{batchCount}} 个文件,每个文件不超过 {{size}} MB ,总数不超过 {{totalCount}} 个文件。", + "stepOne.uploader.tip": "已支持 {{supportTypes}},每批最多 {{batchCount}} 个文件,每个文件不超过 {{size}} MB。", + "stepOne.uploader.tipWithTotalLimit": "已支持 {{supportTypes}},每批最多 {{batchCount}} 个文件,每个文件不超过 {{size}} MB,总数不超过 {{totalCount}} 个文件。", "stepOne.uploader.title": "上传文本文件", "stepOne.uploader.validation.count": "暂不支持多个文件", "stepOne.uploader.validation.filesNumber": "批量上传限制 {{filesNumber}}。", diff --git a/web/i18n/zh-Hant/dataset-creation.json b/web/i18n/zh-Hant/dataset-creation.json index b72a92ac50..3deef58239 100644 --- a/web/i18n/zh-Hant/dataset-creation.json +++ b/web/i18n/zh-Hant/dataset-creation.json @@ -35,7 +35,8 @@ "stepOne.uploader.cancel": "取消", "stepOne.uploader.change": "更改檔案", "stepOne.uploader.failed": "上傳失敗", - "stepOne.uploader.tip": "支援 {{supportTypes}}。每批最多 {{batchCount}} 個檔案,每個檔案不超過 {{size}} MB,總數不超過 {{totalCount}} 個檔案。", + "stepOne.uploader.tip": "支援 {{supportTypes}}。每批最多 {{batchCount}} 個檔案,每個檔案不超過 {{size}} MB。", + "stepOne.uploader.tipWithTotalLimit": "支援 {{supportTypes}}。每批最多 {{batchCount}} 個檔案,每個檔案不超過 {{size}} MB,總數不超過 {{totalCount}} 個檔案。", "stepOne.uploader.title": "上傳文字檔案", "stepOne.uploader.validation.count": "暫不支援多個檔案", "stepOne.uploader.validation.filesNumber": "批次上傳限制 {{filesNumber}}。", From 6b5d6dacb2f61ad7c7f7c79c00e6864992d3c01c Mon Sep 17 00:00:00 2001 From: Joel Date: Mon, 27 Apr 2026 15:16:10 +0800 Subject: [PATCH 038/128] fix: school name can not input (#35597) --- .../__tests__/search-input.spec.tsx | 101 ++++-------------- web/app/education-apply/search-input.tsx | 3 +- 2 files changed, 24 insertions(+), 80 deletions(-) diff --git a/web/app/education-apply/__tests__/search-input.spec.tsx b/web/app/education-apply/__tests__/search-input.spec.tsx index bb3cd8cc84..ae9b678add 100644 --- a/web/app/education-apply/__tests__/search-input.spec.tsx +++ b/web/app/education-apply/__tests__/search-input.spec.tsx @@ -1,4 +1,3 @@ -import type { ReactNode } from 'react' import { fireEvent, render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { useState } from 'react' @@ -23,73 +22,6 @@ vi.mock('react-i18next', () => ({ }), })) -vi.mock('@/app/components/base/input', () => ({ - default: ({ - value, - onChange, - placeholder, - className, - }: { - value?: string - onChange: (event: { target: { value: string } }) => void - placeholder?: string - className?: string - }) => ( - onChange({ target: { value: e.target.value } })} - /> - ), -})) - -vi.mock('@langgenius/dify-ui/popover', async () => { - const React = await import('react') - const PopoverContext = React.createContext({ - open: false, - setOpen: (_open: boolean) => {}, - }) - - const Popover = ({ - children, - open: controlledOpen, - onOpenChange, - }: { - children: ReactNode - open?: boolean - onOpenChange?: (open: boolean) => void - }) => { - const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false) - const isControlled = controlledOpen !== undefined - const open = isControlled ? !!controlledOpen : uncontrolledOpen - const setOpen = (nextOpen: boolean) => { - if (!isControlled) - setUncontrolledOpen(nextOpen) - onOpenChange?.(nextOpen) - } - - return ( - - {children} - - ) - } - - const PopoverTrigger = ({ render }: { render: ReactNode }) => <>{render} - - const PopoverContent = ({ children }: { children: ReactNode }) => { - const { open } = React.useContext(PopoverContext) - return open ?
{children}
: null - } - - return { - Popover, - PopoverTrigger, - PopoverContent, - } -}) - const ControlledSearchInput = () => { const [value, setValue] = useState('') return @@ -102,27 +34,38 @@ describe('education-apply/search-input', () => { educationMocks.hasNext = false }) - it('opens the popover, queries schools, and closes after selection', async () => { + it('keeps the search field editable when used as the popover trigger', async () => { + const user = userEvent.setup() + educationMocks.schools = [] + + render() + + const input = screen.getByPlaceholderText('form.schoolName.placeholder') as HTMLInputElement + expect(input.type).toBe('text') + + await user.type(input, 'Alpha') + + expect(input).toHaveValue('Alpha') + expect(educationMocks.setSchools).toHaveBeenCalledWith([]) + expect(educationMocks.querySchoolsWithDebounced).toHaveBeenLastCalledWith({ + keywords: 'Alpha', + page: 0, + }) + }) + + it('closes the popover after selecting a school', async () => { const user = userEvent.setup() render() - const input = screen.getByPlaceholderText('form.schoolName.placeholder') - await user.type(input, 'A') + await user.type(screen.getByPlaceholderText('form.schoolName.placeholder'), 'A') - expect(educationMocks.setSchools).toHaveBeenCalledWith([]) - expect(educationMocks.querySchoolsWithDebounced).toHaveBeenLastCalledWith({ - keywords: 'A', - page: 0, - }) - - expect(screen.getByTestId('education-search-popover')).toBeInTheDocument() expect(screen.getByText('Alpha University')).toBeInTheDocument() await user.click(screen.getByText('Beta College')) expect(screen.getByDisplayValue('Beta College')).toBeInTheDocument() - expect(screen.queryByTestId('education-search-popover')).not.toBeInTheDocument() + expect(screen.queryByText('Alpha University')).not.toBeInTheDocument() }) it('loads the next page when the dropdown is scrolled to the bottom', async () => { diff --git a/web/app/education-apply/search-input.tsx b/web/app/education-apply/search-input.tsx index 4f930eb3eb..5125eba439 100644 --- a/web/app/education-apply/search-input.tsx +++ b/web/app/education-apply/search-input.tsx @@ -77,6 +77,7 @@ const SearchInput = ({ return ( )} /> - {!!schools.length && !!value && ( + {open && !!schools.length && !!value && ( Date: Mon, 27 Apr 2026 15:29:42 +0800 Subject: [PATCH 039/128] chore: update dependency catalog (#35594) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- eslint-suppressions.json | 7 +- package.json | 2 +- .../src/toast/__tests__/index.spec.tsx | 15 - pnpm-lock.yaml | 850 +++++++++--------- pnpm-workspace.yaml | 38 +- .../app/configuration/debug/types.ts | 2 +- web/app/components/base/chat/chat/type.ts | 2 +- web/app/components/base/features/store.ts | 4 +- web/app/components/base/features/types.ts | 6 +- .../base/form/form-scenarios/base/types.ts | 2 +- .../form/form-scenarios/input-field/types.ts | 4 +- .../form/form-scenarios/node-panel/types.ts | 4 +- web/app/components/base/form/types.ts | 4 +- .../markdown-with-directive-schema.ts | 4 +- .../components/base/text-generation/types.ts | 1 - web/app/components/base/textarea/index.tsx | 1 - .../detail/completed/segment-list-context.ts | 4 +- .../hooks/use-document-list-query-state.ts | 2 +- .../components/goto-anything/actions/types.ts | 6 +- .../model-provider-page/declarations.ts | 4 +- .../reasoning-config-form.helpers.ts | 4 +- web/app/components/plugins/types.ts | 22 +- web/app/components/tools/types.ts | 4 +- .../hooks/use-workflow-run-utils.ts | 2 +- .../workflow/block-selector/types.ts | 10 +- .../collaboration/types/collaboration.ts | 4 +- .../workflow/collaboration/types/websocket.ts | 2 +- .../components/workflow/hooks-store/store.ts | 2 +- .../workflow/nodes/knowledge-base/types.ts | 2 +- .../components/workflow/nodes/loop/types.ts | 4 +- .../workflow/nodes/trigger-schedule/types.ts | 2 +- .../components/generic-table.tsx | 4 +- web/app/components/workflow/types.ts | 2 +- .../workflow/workflow-history-store.tsx | 4 +- web/context/event-emitter.ts | 2 +- web/contract/console/workflow-comment.ts | 2 +- web/models/app.ts | 2 +- web/models/common.ts | 2 +- web/models/datasets.ts | 52 +- web/models/debug.ts | 2 +- web/models/explore.ts | 2 +- web/models/log.ts | 25 +- web/scripts/gen-doc-paths.ts | 6 +- web/service/base.ts | 56 +- web/types/app.ts | 7 +- web/types/doc-paths.ts | 10 +- web/types/pipeline.tsx | 2 +- web/types/workflow.ts | 2 +- 48 files changed, 586 insertions(+), 616 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 1e7a2662ed..3f3bd5f1f7 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -2059,7 +2059,7 @@ }, "web/app/components/base/text-generation/types.ts": { "no-barrel-files/no-barrel-files": { - "count": 3 + "count": 1 } }, "web/app/components/base/textarea/index.stories.tsx": { @@ -2070,11 +2070,6 @@ "count": 1 } }, - "web/app/components/base/textarea/index.tsx": { - "react-refresh/only-export-components": { - "count": 1 - } - }, "web/app/components/base/video-gallery/VideoPlayer.tsx": { "react/set-state-in-effect": { "count": 1 diff --git a/package.json b/package.json index 5a67b66a9c..42d6961f5f 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "dify", "type": "module", "private": true, - "packageManager": "pnpm@10.33.0", + "packageManager": "pnpm@10.33.2", "engines": { "node": "^22.22.1" }, diff --git a/packages/dify-ui/src/toast/__tests__/index.spec.tsx b/packages/dify-ui/src/toast/__tests__/index.spec.tsx index 51fccf70d8..1e302618c5 100644 --- a/packages/dify-ui/src/toast/__tests__/index.spec.tsx +++ b/packages/dify-ui/src/toast/__tests__/index.spec.tsx @@ -125,21 +125,6 @@ describe('@langgenius/dify-ui/toast', () => { expect(onClose).toHaveBeenCalledTimes(1) }) - it('should respect the host timeout configuration', async () => { - const screen = await render() - - toast('Configured timeout') - await expect.element(screen.getByText('Configured timeout')).toBeInTheDocument() - - await vi.advanceTimersByTimeAsync(2999) - expect(document.body).toHaveTextContent('Configured timeout') - - await vi.advanceTimersByTimeAsync(1) - await vi.waitFor(() => { - expect(document.body).not.toHaveTextContent('Configured timeout') - }) - }) - it('should respect custom timeout values including zero', async () => { const screen = await render() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9408bfb4b3..c802698100 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,11 +7,11 @@ settings: catalogs: default: '@amplitude/analytics-browser': - specifier: 2.41.0 - version: 2.41.0 + specifier: 2.41.1 + version: 2.41.1 '@amplitude/plugin-session-replay-browser': - specifier: 1.27.10 - version: 1.27.10 + specifier: 1.28.0 + version: 1.28.0 '@antfu/eslint-config': specifier: 8.2.0 version: 8.2.0 @@ -22,8 +22,8 @@ catalogs: specifier: 5.1.2 version: 5.1.2 '@cucumber/cucumber': - specifier: 12.8.1 - version: 12.8.1 + specifier: 12.8.2 + version: 12.8.2 '@egoist/tailwindcss-icons': specifier: 1.9.2 version: 1.9.2 @@ -40,8 +40,8 @@ catalogs: specifier: 0.27.19 version: 0.27.19 '@formatjs/intl-localematcher': - specifier: 0.8.3 - version: 0.8.3 + specifier: 0.8.4 + version: 0.8.4 '@headlessui/react': specifier: 2.2.10 version: 2.2.10 @@ -94,17 +94,17 @@ catalogs: specifier: 16.2.4 version: 16.2.4 '@orpc/client': - specifier: 1.13.14 - version: 1.13.14 + specifier: 1.14.0 + version: 1.14.0 '@orpc/contract': - specifier: 1.13.14 - version: 1.13.14 + specifier: 1.14.0 + version: 1.14.0 '@orpc/openapi-client': - specifier: 1.13.14 - version: 1.13.14 + specifier: 1.14.0 + version: 1.14.0 '@orpc/tanstack-query': - specifier: 1.13.14 - version: 1.13.14 + specifier: 1.14.0 + version: 1.14.0 '@playwright/test': specifier: 1.59.1 version: 1.59.1 @@ -115,8 +115,8 @@ catalogs: specifier: 4.2.0 version: 4.2.0 '@sentry/react': - specifier: 10.49.0 - version: 10.49.0 + specifier: 10.50.0 + version: 10.50.0 '@storybook/addon-docs': specifier: 10.3.5 version: 10.3.5 @@ -157,8 +157,8 @@ catalogs: specifier: 4.2.4 version: 4.2.4 '@tanstack/eslint-plugin-query': - specifier: 5.99.2 - version: 5.99.2 + specifier: 5.100.5 + version: 5.100.5 '@tanstack/react-devtools': specifier: 0.10.2 version: 0.10.2 @@ -169,11 +169,11 @@ catalogs: specifier: 0.2.22 version: 0.2.22 '@tanstack/react-query': - specifier: 5.99.2 - version: 5.99.2 + specifier: 5.100.5 + version: 5.100.5 '@tanstack/react-query-devtools': - specifier: 5.99.2 - version: 5.99.2 + specifier: 5.100.5 + version: 5.100.5 '@tanstack/react-virtual': specifier: 3.13.24 version: 3.13.24 @@ -229,20 +229,20 @@ catalogs: specifier: 8.59.0 version: 8.59.0 '@typescript/native-preview': - specifier: 7.0.0-dev.20260422.1 - version: 7.0.0-dev.20260422.1 + specifier: 7.0.0-dev.20260426.1 + version: 7.0.0-dev.20260426.1 '@vitejs/plugin-react': specifier: 6.0.1 version: 6.0.1 '@vitejs/plugin-rsc': - specifier: 0.5.24 - version: 0.5.24 + specifier: 0.5.25 + version: 0.5.25 '@vitest/coverage-v8': specifier: 4.1.5 version: 4.1.5 abcjs: - specifier: 6.6.2 - version: 6.6.2 + specifier: 6.6.3 + version: 6.6.3 agentation: specifier: 3.0.2 version: 3.0.2 @@ -337,8 +337,8 @@ catalogs: specifier: 2.3.6 version: 2.3.6 hono: - specifier: 4.12.14 - version: 4.12.14 + specifier: 4.12.15 + version: 4.12.15 html-entities: specifier: 2.6.0 version: 2.6.0 @@ -376,8 +376,8 @@ catalogs: specifier: 0.16.45 version: 0.16.45 knip: - specifier: 6.6.1 - version: 6.6.1 + specifier: 6.7.0 + version: 6.7.0 ky: specifier: 2.0.2 version: 2.0.2 @@ -388,8 +388,8 @@ catalogs: specifier: 0.43.0 version: 0.43.0 loro-crdt: - specifier: 1.11.1 - version: 1.11.1 + specifier: 1.12.0 + version: 1.12.0 mermaid: specifier: 11.14.0 version: 11.14.0 @@ -418,8 +418,8 @@ catalogs: specifier: 1.59.1 version: 1.59.1 postcss: - specifier: 8.5.10 - version: 8.5.10 + specifier: 8.5.12 + version: 8.5.12 qrcode.react: specifier: 4.2.0 version: 4.2.0 @@ -609,7 +609,7 @@ importers: devDependencies: '@cucumber/cucumber': specifier: 'catalog:' - version: 12.8.1 + version: 12.8.2 '@dify/tsconfig': specifier: workspace:* version: link:../packages/tsconfig @@ -621,7 +621,7 @@ importers: version: 25.6.0 '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260422.1 + version: 7.0.0-dev.20260426.1 tsx: specifier: 'catalog:' version: 4.21.0 @@ -682,7 +682,7 @@ importers: version: 19.2.3(@types/react@19.2.14) '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260422.1 + version: 7.0.0-dev.20260426.1 '@vitejs/plugin-react': specifier: 'catalog:' version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)) @@ -733,7 +733,7 @@ importers: dependencies: '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260422.1 + version: 7.0.0-dev.20260426.1 typescript: specifier: 'catalog:' version: 6.0.3 @@ -772,7 +772,7 @@ importers: version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260422.1 + version: 7.0.0-dev.20260426.1 '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) @@ -796,10 +796,10 @@ importers: dependencies: '@amplitude/analytics-browser': specifier: 'catalog:' - version: 2.41.0 + version: 2.41.1 '@amplitude/plugin-session-replay-browser': specifier: 'catalog:' - version: 1.27.10(@amplitude/rrweb@2.0.0-alpha.37) + version: 1.28.0(@amplitude/rrweb@2.0.0-alpha.37) '@base-ui/react': specifier: 'catalog:' version: 1.4.1(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -811,7 +811,7 @@ importers: version: 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@formatjs/intl-localematcher': specifier: 'catalog:' - version: 0.8.3 + version: 0.8.4 '@headlessui/react': specifier: 'catalog:' version: 2.2.10(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -844,22 +844,22 @@ importers: version: 4.7.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@orpc/client': specifier: 'catalog:' - version: 1.13.14 + version: 1.14.0 '@orpc/contract': specifier: 'catalog:' - version: 1.13.14 + version: 1.14.0 '@orpc/openapi-client': specifier: 'catalog:' - version: 1.13.14 + version: 1.14.0 '@orpc/tanstack-query': specifier: 'catalog:' - version: 1.13.14(@orpc/client@1.13.14)(@tanstack/query-core@5.99.2) + version: 1.14.0(@orpc/client@1.14.0)(@tanstack/query-core@5.100.5) '@remixicon/react': specifier: 'catalog:' version: 4.9.0(react@19.2.5) '@sentry/react': specifier: 'catalog:' - version: 10.49.0(react@19.2.5) + version: 10.50.0(react@19.2.5) '@streamdown/math': specifier: 'catalog:' version: 1.0.2(react@19.2.5) @@ -877,13 +877,13 @@ importers: version: 1.29.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@tanstack/react-query': specifier: 'catalog:' - version: 5.99.2(react@19.2.5) + version: 5.100.5(react@19.2.5) '@tanstack/react-virtual': specifier: 'catalog:' version: 3.13.24(react-dom@19.2.5(react@19.2.5))(react@19.2.5) abcjs: specifier: 'catalog:' - version: 6.6.2 + version: 6.6.3 ahooks: specifier: 'catalog:' version: 3.9.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -982,7 +982,7 @@ importers: version: 0.43.0 loro-crdt: specifier: 'catalog:' - version: 1.11.1 + version: 1.12.0 mermaid: specifier: 'catalog:' version: 11.14.0 @@ -1121,7 +1121,7 @@ importers: version: 3.0.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@hono/node-server': specifier: 'catalog:' - version: 1.19.14(hono@4.12.14) + version: 1.19.14(hono@4.12.15) '@iconify-json/heroicons': specifier: 'catalog:' version: 1.2.3 @@ -1175,7 +1175,7 @@ importers: version: 4.2.4(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)) '@tanstack/eslint-plugin-query': specifier: 'catalog:' - version: 5.99.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + version: 5.100.5(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@tanstack/react-devtools': specifier: 'catalog:' version: 0.10.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1184,7 +1184,7 @@ importers: version: 0.2.22(@types/react@19.2.14)(csstype@3.2.3)(react@19.2.5)(solid-js@1.9.11) '@tanstack/react-query-devtools': specifier: 'catalog:' - version: 5.99.2(@tanstack/react-query@5.99.2(react@19.2.5))(react@19.2.5) + version: 5.100.5(@tanstack/react-query@5.100.5(react@19.2.5))(react@19.2.5) '@testing-library/dom': specifier: 'catalog:' version: 10.4.1 @@ -1235,13 +1235,13 @@ importers: version: 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) '@typescript/native-preview': specifier: 'catalog:' - version: 7.0.0-dev.20260422.1 + version: 7.0.0-dev.20260426.1 '@vitejs/plugin-react': specifier: 'catalog:' version: 6.0.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)) '@vitejs/plugin-rsc': specifier: 'catalog:' - version: 0.5.24(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + version: 0.5.25(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) '@vitest/coverage-v8': specifier: 'catalog:' version: 4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3) @@ -1283,13 +1283,13 @@ importers: version: 20.9.0 hono: specifier: 'catalog:' - version: 4.12.14 + version: 4.12.15 knip: specifier: 'catalog:' - version: 6.6.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + version: 6.7.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) postcss: specifier: 'catalog:' - version: 8.5.10 + version: 8.5.12 react-server-dom-webpack: specifier: 'catalog:' version: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1310,7 +1310,7 @@ importers: version: 3.19.3 vinext: specifier: 'catalog:' - version: 0.0.41(@mdx-js/rollup@3.1.1)(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.24(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@6.0.3) + version: 0.0.41(@mdx-js/rollup@3.1.1)(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.25(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@6.0.3) vite: specifier: npm:@voidzero-dev/vite-plus-core@0.1.19 version: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' @@ -1336,17 +1336,17 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@amplitude/analytics-browser@2.41.0': - resolution: {integrity: sha512-zCfsm4mvytJRCvXxc04vfI0gmDkVUsfFXwoPl6l3g6uo9xC6Z22heDWot4NLUpeqKbQGBWJLYSzaD08HigXZNA==} + '@amplitude/analytics-browser@2.41.1': + resolution: {integrity: sha512-qSUFBtln+VY6XIki/Ym3adUlnBvb3TrfFHhXFp5TVi9rz/8p/vKWmQ9Htsf4I0H70xZCe+sNHv53NOyTt1VzUA==} - '@amplitude/analytics-client-common@2.4.45': - resolution: {integrity: sha512-2lQRpLEiZp3hqFXSpGgzsOVeXCaDwW8hCKJZeXWB6GGcLsGn0ssEC7RNxLpUMNWCctCF7Dfr9a4MSVe54jtiPw==} + '@amplitude/analytics-client-common@2.4.46': + resolution: {integrity: sha512-cvNzR7GY+PqvdT7b1jjs+LhLjkLr/raS8C6Jo4nTD/hDzWI+b73u12atttbgWKGJMCmki+xs+X0oyMt207+qtQ==} '@amplitude/analytics-connector@1.6.4': resolution: {integrity: sha512-SpIv0IQMNIq6SH3UqFGiaZyGSc7PBZwRdq7lvP0pBxW8i4Ny+8zwI0pV+VMfMHQwWY3wdIbWw5WQphNjpdq1/Q==} - '@amplitude/analytics-core@2.47.0': - resolution: {integrity: sha512-LLffKoq7nhEtFtXz/QGcimlcS3vYugEW14JdAeZE03k2empShrAhCzigHL3Xiz+ywW9KC3inUalnbxybVhU0YA==} + '@amplitude/analytics-core@2.47.1': + resolution: {integrity: sha512-ZdtAx5syGZBQpbZVLnc/zp7sMlq7+b1dxo/5gCG/4thNW0vOHfN4nYGlV2+k/VEEw4/hW893t5EPUCbxUJM+OQ==} '@amplitude/analytics-types@2.11.1': resolution: {integrity: sha512-wFEgb0t99ly2uJKm5oZ28Lti0Kh5RecR5XBkwfUpDzn84IoCIZ8GJTsMw/nThu8FZFc7xFDA4UAt76zhZKrs9A==} @@ -1354,29 +1354,29 @@ packages: '@amplitude/experiment-core@0.7.2': resolution: {integrity: sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==} - '@amplitude/plugin-autocapture-browser@1.26.0': - resolution: {integrity: sha512-LCLsMr8usQJK6R6VjCjmiJ3ZRICh0QJ6xbDEwAm5XhuLFGRNsB2b9eRHlvalsPrTXR+b4Hjr71/dh3XNYZ9rqw==} + '@amplitude/plugin-autocapture-browser@1.26.1': + resolution: {integrity: sha512-5Lge/azo8/+JC2YAnX/2YoNYfhKp00MtyAjiZFmFkG5pQUguXnSqTJw0UaUu/gzIZo5VaDAIGFZIk0b++ayTyA==} - '@amplitude/plugin-custom-enrichment-browser@0.1.6': - resolution: {integrity: sha512-oAVR5biFh7kMm4XOji7r684TA/VOwK8N1OLMdACQdwBl8MPiBLJDIPWtkVW5iSXyIjwYkOlrjygtnkei1q2S8g==} + '@amplitude/plugin-custom-enrichment-browser@0.1.7': + resolution: {integrity: sha512-r4hoD38mbtXH91glpxI0EIslwWMrVuupWar2mp/OrbKEHfxdXrOsXfIc17fxYnQHqWpGuBghNMwh0oppRzJtAw==} - '@amplitude/plugin-event-property-attribution-browser@0.1.1': - resolution: {integrity: sha512-2YHF/O+WVX0VxTAh3Jh77Ib+LeUl1xbyF1qW2YzGurY8uBUeAd62+7qFaXQSBWk1qMiTguxjKXrbbtxssfWWWg==} + '@amplitude/plugin-event-property-attribution-browser@0.1.2': + resolution: {integrity: sha512-Zd0EioWcm+UhrkJMls2mn+9AXpA/H9TeuULZOFbggRhfZ3rLtJMF3LqGLRs8UyA7vHXiqKsE7DXLur2Ya8sBzA==} - '@amplitude/plugin-network-capture-browser@1.9.15': - resolution: {integrity: sha512-PkFWjKyOkkzw/9yKKJ2sa19F2Uo9NiSAR0l0NmELcO8h4TVJdfc4HlvM68AnWJ15nkFHh+UoG7SHwb7vp7ZC3Q==} + '@amplitude/plugin-network-capture-browser@1.9.16': + resolution: {integrity: sha512-VzY6OzWM3p6hYWZcOh/Ex+j/OgCfMfKO94wK71vgRL8+U3RTBr8bAw36i2twjL1jvAkSXv/PxTmzied1SEdKqQ==} - '@amplitude/plugin-page-url-enrichment-browser@0.7.7': - resolution: {integrity: sha512-P67Xmi5/oDFZOO2DfsAvvDS280WdzVsl6JTPvgJc4+WJ1YypbYFA7S87LUIiwtuvgnHXFsgOjNUI36bOEVTW4w==} + '@amplitude/plugin-page-url-enrichment-browser@0.7.8': + resolution: {integrity: sha512-/6FevlSaB7a5+R7Pph6I/Hc412JbNOy4z7g4JvzImeTtmmN8xMpg7Shu17Aum2mi2sK7sofHE1UMAXEoULpJEA==} - '@amplitude/plugin-page-view-tracking-browser@2.10.1': - resolution: {integrity: sha512-XEk0Z7ZfN6gV0h1R2hOZkby/SUTIbGU8SgWR8gt4O+DEx+pxfTQEuCM2ya1YaCV2h1SBrTK4bnIHgPax/4/HoA==} + '@amplitude/plugin-page-view-tracking-browser@2.10.2': + resolution: {integrity: sha512-1H/3YAXi5bVLZ0YNRbnHEne2J9c7kXvwmppSOZgQ21LIdxBo9A4WJhWPAJIZKqn9W2BbgrpxI/BjwOUMf5gYQw==} - '@amplitude/plugin-session-replay-browser@1.27.10': - resolution: {integrity: sha512-AWvAtiQ9/T52DCXS3hcjtHQs4GvZxM7rxgs24DgxqFY2uwCTTnI78le4U7nPWhSrj02YK+3b8y7QN3mm23lHyQ==} + '@amplitude/plugin-session-replay-browser@1.28.0': + resolution: {integrity: sha512-alWW4czF7gINNaJAwCO+HXGkAgam7HjixNt/j5fCk/LGfWyHru8Yg1G5TKjOugrWEeZEqaDAVYGz+KcqbX3RVQ==} - '@amplitude/plugin-web-vitals-browser@1.1.30': - resolution: {integrity: sha512-nLZk2dTHG8pLd/fFH0zdIhWnu4u+oPc/DKBYXwZ4zk6YKOkl0V+sbDUNGNnZWlOWRykq+0rkOX/WnUyClvMtaQ==} + '@amplitude/plugin-web-vitals-browser@1.1.31': + resolution: {integrity: sha512-zIGLyfb9I1rgdJQtRVir5d97spEe1er1vrPDzfHbrcwCgrLR8CGEzx1LQQcHCB6vg5tjrHsi7LdvZCLYRj+lCA==} '@amplitude/rrdom@2.0.0-alpha.37': resolution: {integrity: sha512-u4dSnBtlbJ8oU5P/Ywl2RLqvjqWbkl4ScMUbvQA7in4pWcx+0NRN+VVjLZXQcd8Fn7E/rcxjeUh7e7HfwvdasQ==} @@ -1410,8 +1410,8 @@ packages: '@amplitude/rrweb@2.0.0-alpha.37': resolution: {integrity: sha512-jJkSpPYiVgOZB422pb2jOJJn3pvb5E5f9vKK8CEmUlk2mVAl6kPQzW98mb05M65OJFj5nn9tSe9h5r5+Cl93ag==} - '@amplitude/session-replay-browser@1.37.0': - resolution: {integrity: sha512-65KC35dK2yxHoBTDTZeJC8qPchj4lFqTuNjBbH1jaV3hzYoRrGA/xWXLZgxlFvc/7yvcGBbTUW2TeGMAeW6FUg==} + '@amplitude/session-replay-browser@1.38.0': + resolution: {integrity: sha512-SwOdPb/pB7A1ysQico62cwAQ02Y6E8FMN0BNg8KtMC8wXpUxaGKaL952mpNqrvPZs+kwTDYS6dKHA7pg2TfX4w==} '@amplitude/targeting@0.2.0': resolution: {integrity: sha512-/50ywTrC4hfcfJVBbh5DFbqMPPfaIOivZeb5Gb+OGM03QrA+lsUqdvtnKLNuWtceD4H6QQ2KFzPJ5aAJLyzVDA==} @@ -1649,8 +1649,8 @@ packages: '@cucumber/cucumber-expressions@19.0.0': resolution: {integrity: sha512-4FKoOQh2Uf6F6/Ln+1OxuK8LkTg6PyAqekhf2Ix8zqV2M54sH+m7XNJNLhOFOAW/t9nxzRbw2CcvXbCLjcvHZg==} - '@cucumber/cucumber@12.8.1': - resolution: {integrity: sha512-hCXxiStjbZsRVZlV+CMywkqBtJ6RZTQeXSBZGPHm1YoIOI6YB8pCo0KlnJMmxfKfoeUKagtQMNPnpJBXwhkUjQ==} + '@cucumber/cucumber@12.8.2': + resolution: {integrity: sha512-IvprstODr0JYTtVG7CQbphN6AGRpzzAQ1EjG7TSumuS15uvVt0inWm8/9uzX8oJwEv5ReU7JruDFim4938omog==} engines: {node: 20 || 22 || >=24} hasBin: true @@ -1669,8 +1669,8 @@ packages: '@cucumber/gherkin@38.0.0': resolution: {integrity: sha512-duEXK+KDfQUzu3vsSzXjkxQ2tirF5PRsc1Xrts6THKHJO6mjw4RjM8RV+vliuDasmhhrmdLcOcM7d9nurNTJKw==} - '@cucumber/html-formatter@23.0.0': - resolution: {integrity: sha512-WwcRzdM8Ixy4e53j+Frm3fKM5rNuIyWUfy4HajEN+Xk/YcjA6yW0ACGTFDReB++VDZz/iUtwYdTlPRY36NbqJg==} + '@cucumber/html-formatter@23.1.0': + resolution: {integrity: sha512-DcCSFoGs6jbwzXPgX1CwgJKEE+ZMcIEzq/0Memg0o24maNn9NJizBFHmoFWG4iv/OxHza+mvc+56cTHetfHndw==} peerDependencies: '@cucumber/messages': '>=18' @@ -1684,8 +1684,8 @@ packages: peerDependencies: '@cucumber/messages': '>=17.1.1' - '@cucumber/messages@32.2.0': - resolution: {integrity: sha512-oYp1dgL2TByYWL51Z+rNm+/mFtJhiPU9WS03goes9EALb8d9GFcXRbG1JluFLFaChF1YDqIzLac0kkC3tv1DjQ==} + '@cucumber/messages@32.3.1': + resolution: {integrity: sha512-yNQq1KoXRYaEKrWMFmpUQX7TdeQuU9jeGgJAZ3dArTsC/T4NpJ6DnqaJIIgwPnz/wtQIQTNX7/h0rOuF5xY4qQ==} '@cucumber/pretty-formatter@1.0.1': resolution: {integrity: sha512-A1lU4VVP0aUWdOTmpdzvXOyEYuPtBDI0xYwYJnmoMDplzxMdhcHk86lyyvYDoMoPzzq6OkOE3isuosvUU4X7IQ==} @@ -2083,8 +2083,8 @@ packages: '@formatjs/fast-memoize@3.1.2': resolution: {integrity: sha512-vPnriihkfK0lzoQGaXq+qXH23VsYyansRTkTgo2aTG0k1NjLFyZimFVdfj4C9JkSE5dm7CEngcQ5TTc1yAyBfQ==} - '@formatjs/intl-localematcher@0.8.3': - resolution: {integrity: sha512-pHUjWb9NuhnMs8+PxQdzBtZRFJHlGhrURGAbm6Ltwl82BFajeuiIR3jblSa7ia3r62rXe/0YtVpUG3xWr5bFCA==} + '@formatjs/intl-localematcher@0.8.4': + resolution: {integrity: sha512-J51dAnynnqJdVUEXidHoIWn+qYve+yNQEgmFk9Dyfr3p0okzm+5QhQ+9QmsMz08+BeWTVpc1HadIiLfZmRYbAQ==} '@headlessui/react@2.2.10': resolution: {integrity: sha512-5pVLNK9wlpxTUTy9GpgbX/SdcRh+HBnPktjM2wbiLTH4p+2EPHBO1aoSryUCuKUIItdDWO9ITlhUL8UnUN/oIA==} @@ -2429,12 +2429,6 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@napi-rs/wasm-runtime@1.1.2': - resolution: {integrity: sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - '@napi-rs/wasm-runtime@1.1.4': resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} peerDependencies: @@ -2540,165 +2534,165 @@ packages: resolution: {integrity: sha512-y3SvzjuY1ygnzWA4Krwx/WaJAsTMP11DN+e21A8Fa8PW1oDtVB5NSRW7LWurAiS2oKRkuCgcjTYMkBuBkcPCRg==} engines: {node: '>=12.4.0'} - '@orpc/client@1.13.14': - resolution: {integrity: sha512-JQf3lO//UGHmmkd8+9fuWuh1gga1lhWuKnsT19cui7F6WizBy0NdFSVQerOsSy2c1kxOthlD7GnicGgSY2rhQA==} + '@orpc/client@1.14.0': + resolution: {integrity: sha512-TYVcj1s5bN9adggeqIXFdIdoBBUAMUxQwMNv6YagjiaZkGtqWUYd1Y1vU0Rn/9xHWF2+0hBZNUKUmP5qrQhIAw==} - '@orpc/contract@1.13.14': - resolution: {integrity: sha512-MfsjaQQDVcs4wHmdl5N/7vkwMnQ41nlojWXyRfRXNJHQczqBzM6sYaTJuUPXlw4YbIu64KHZ5nbbtwNCO5YXsg==} + '@orpc/contract@1.14.0': + resolution: {integrity: sha512-FUxBNqWr6mOjI+w1JPzO/iHmR3M+GA53ivaxp+eOnQu7g3ZGKB0RS5gJ/oz3cGF1gvuIcCw9FVYKK/5tkB8I1Q==} - '@orpc/openapi-client@1.13.14': - resolution: {integrity: sha512-mHuj/UL5qLqB1JqrRdlAoUYMidbsry8Cr9QOlOZk1mp7+OZhasFv75UNzxyjNNaSjyd3l2k4UkgpcHK4VSD7tQ==} + '@orpc/openapi-client@1.14.0': + resolution: {integrity: sha512-joeVdSX2YYFQM+bY4SdNHmnoiw7aYfc7NDEWDncnjpho6bj3DhnDNsINgFnFX7A9by7mVYaLw45yqjDhNSMprg==} - '@orpc/shared@1.13.14': - resolution: {integrity: sha512-/ri8ttSX+ppoo01d3LdqQ4Xh6VZS5PYRYmHxTvO8tuyiqBJhN18d8P1VtEW4T9hetoK7JZKeU7EAeqVUnCF9WA==} + '@orpc/shared@1.14.0': + resolution: {integrity: sha512-WNzofimsE3sKbkyAAwVKMwG4P7sL0fzDLUhXqEXuJ9Yjll+phy/jSRK9TupNMtsPyz9ViKHKCQcwmsdgIgn9Sg==} peerDependencies: '@opentelemetry/api': '>=1.9.0' peerDependenciesMeta: '@opentelemetry/api': optional: true - '@orpc/standard-server-fetch@1.13.14': - resolution: {integrity: sha512-k2zkCi98qd3NkvWhUX/Yece/qjB+o07g/gHC509YB5HbOGtBV/da3eseYjFyzBx5LDxMz28BOALI8/q/YDhKZw==} + '@orpc/standard-server-fetch@1.14.0': + resolution: {integrity: sha512-qg315ZVbQ+02WnLzep7YvCsXb8BdefZ7Zjt+/emu6+Ypgw4fS0O78jtMHy3r39YvdvC9U2hWt8hff1yKiVlvQA==} - '@orpc/standard-server-peer@1.13.14': - resolution: {integrity: sha512-jinseQ8bn7XQOHjsCXhR1HiF3wAwn1xEQPpnE/av0PoOi4h0ATvhZjDLaRHvRavs8YwrIqwSuAuYT/hDxON58A==} + '@orpc/standard-server-peer@1.14.0': + resolution: {integrity: sha512-Phk8D04uxNJMLvl7JfJlWvfzDXwzfGweh4jmQI69zSV+flihp57dkZuk8gpTE7rfDClFiKCDauVsB/pQxwM09Q==} - '@orpc/standard-server@1.13.14': - resolution: {integrity: sha512-o8PaDERiwREFQpIZO0mQ1PhguchyNzrf1w7m3eK1JB4rPjHu1VJUgqCpy/sV3Id5ji4bX/gKHEC3NZjDX6mEWQ==} + '@orpc/standard-server@1.14.0': + resolution: {integrity: sha512-zN3Q+ajsoLoxLYmONc1RkDyhIg1wENolrTly8HfodcR3gYrfFRcGhUzShqa/KdG47mK49Nps8rdeeMj6NT8EYw==} - '@orpc/tanstack-query@1.13.14': - resolution: {integrity: sha512-5rq1Z1anVTVBseYeNBi5RJSgWPxpD0MqK7MYej3xnt56jjc6mFmWpUGNz9xy0BXPh3KmA/xDTNuB23kKgJ5JmQ==} + '@orpc/tanstack-query@1.14.0': + resolution: {integrity: sha512-Bjx29HULT5PNSaGFkt+rExTqQonZfaqrAMUOLWBBNlI8TtPMvnKtDxlzmvO5J4Aq8k5p0t+cZX1E6HTeH3mqKQ==} peerDependencies: - '@orpc/client': 1.13.14 + '@orpc/client': 1.14.0 '@tanstack/query-core': '>=5.80.2' '@ota-meshi/ast-token-store@0.3.0': resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} - '@oxc-parser/binding-android-arm-eabi@0.126.0': - resolution: {integrity: sha512-svyoHt25J4741QJ5aa4R+h0iiBeSRt63Lr3aAZcxy2c/NeSE1IfDeMnSij6rIg7EjxkdlXzz613wUjeCeilBNA==} + '@oxc-parser/binding-android-arm-eabi@0.127.0': + resolution: {integrity: sha512-0LC7ye4hvqbIKxAzThzvswgHLFu2AURKzYLeSVvLdu2TBOYWQDmHnTqPLeA597BcUCxiLqLsS4CJ5uoI5WYWCQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxc-parser/binding-android-arm64@0.126.0': - resolution: {integrity: sha512-hPEBRKgplp1mG9GkINFsr4JVMDNrGJLOqfDaadTWpAoTnzYR5Rmv8RMvB3hJZpiNvbk1aacopdHUP1pggMQ/cw==} + '@oxc-parser/binding-android-arm64@0.127.0': + resolution: {integrity: sha512-b5jtVTH6AU5CJXHNdj7Jj9IEiR9yVjjnwHzPJhGyHGPdcsZSzBCkS9GBbV33niRMvKthDwQRFRJfI4a+k4PvYg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxc-parser/binding-darwin-arm64@0.126.0': - resolution: {integrity: sha512-ccRpu9sdYmznePJQG5halhs0FW5tw5a8zRSoZXOzM1OjoeZ4jiRRruFiPclsD59edoVAK1l83dvfjWz1nQi6lg==} + '@oxc-parser/binding-darwin-arm64@0.127.0': + resolution: {integrity: sha512-obCE8B7ISKkJidjlhv9xRGJPOSDG2Yu6PRga9Ruaz35uintHxbp1Ki/Yc71wx4rj3Edrm0a1kzG1TAwit0wFpg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxc-parser/binding-darwin-x64@0.126.0': - resolution: {integrity: sha512-CHB4zVjNSKqx8Fw9pHowzQQnjjuq04i4Ng0Avj+DixlwhwAoMYqlFbocYIlbg+q3zOLGlm7vEHm83jqEMitnyg==} + '@oxc-parser/binding-darwin-x64@0.127.0': + resolution: {integrity: sha512-JL6Xb5IwPQT8rUzlpsX7E+AgfcdNklXNPFp8pjCQQ5MQOQo5rtEB2ui+3Hgg9Sn7Y9Egj6YOLLiHhLpdAe12Aw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxc-parser/binding-freebsd-x64@0.126.0': - resolution: {integrity: sha512-RQ3nEJdcDKBfBjmLJ3Vl1d0KQERPV1P8eUrnBm7+VTYyoaJSPLVFuPg1mlD1hk3n0/879VLFMfusFkBal4ssWQ==} + '@oxc-parser/binding-freebsd-x64@0.127.0': + resolution: {integrity: sha512-SDQ/3MQFw58fqQz3Z1PhSKFF3JoCF4gmlNjziDm8X02tTahCw0qJbd7FGPDKw1i4VTBZene9JPyC3mHtSvi+wA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxc-parser/binding-linux-arm-gnueabihf@0.126.0': - resolution: {integrity: sha512-onipc2wCDA7Bauzb4KK1mab0GsEDf4ujiIfWECdnmY/2LlzAoX3xdQRLAUyEDB1kn3yilHBrkmXDdHluyHXxiw==} + '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0': + resolution: {integrity: sha512-Av+D1MIqzV0YMGPT9we2SIZaMKD7Cxs4CvXSx/yxaWHewZjYEjScpOf5igc8IILASViw4WTnjlwUdI1KzVtDHQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm-musleabihf@0.126.0': - resolution: {integrity: sha512-5BuJJPohrV5NJ8lmcYOMbfRCUGoYH5J9HZHeuqOLwkHXWAuPMN3X1h8bC/2mWjmosdbfTtmyIdX3spS/TkqKNg==} + '@oxc-parser/binding-linux-arm-musleabihf@0.127.0': + resolution: {integrity: sha512-Cs2fdJ8cPpFdeebj6p4dag8A4+56hPvZ0AhQQzlaLswGz1tz7bXt1nETLeorrM9+AMcWFFkqxcXwDGfTVidY8g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxc-parser/binding-linux-arm64-gnu@0.126.0': - resolution: {integrity: sha512-r2KApRgm2pOJaduRm6GOT8x0whcr67AyejNkSdzPt34GJ+Y3axcXN2mwlTs+8lfO/SSmpO5ZJGYiHYnxEE0jkw==} + '@oxc-parser/binding-linux-arm64-gnu@0.127.0': + resolution: {integrity: sha512-qdOfTcT6SY8gsJrrV92uyEUyjqMGPpIB5JZUG6QN5dukYd+7/j0kX6MwK1DgQj39jtUYixxPiaRUiEN1+0CXgQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-arm64-musl@0.126.0': - resolution: {integrity: sha512-FQ+MMh7MT0Dr/u8+RWmWKlfoeWPQyHDbhhxJShJlYtROXXPHsRs9EvmQOZZ3sx4Nn7JU8NX+oyw2YzQ7anBJcA==} + '@oxc-parser/binding-linux-arm64-musl@0.127.0': + resolution: {integrity: sha512-EoTCZneNFU/P2qrpEM+RHmQwt+CvDkyGESG6qhr7KaegXLZwePfbrkCDfAk8/rhxbDUVGsZILX+2tqPzFtoFWA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-ppc64-gnu@0.126.0': - resolution: {integrity: sha512-Wv/T8C98hRQhGTlx2XFyLn5raRMp9U1lOQD+YnXNgAr7wHbJJpZ8mDBU7Rw+M3WytGcGTFcr6kqgfyQeHVtLbQ==} + '@oxc-parser/binding-linux-ppc64-gnu@0.127.0': + resolution: {integrity: sha512-zALjmZYgxFLHjXeudcDF0xFGNydTAtkAeXAr2EuC17ywCyFxcmQra4w0BMde0Yi/re4Bi4iwEoEXtYN7l6eBLQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-gnu@0.126.0': - resolution: {integrity: sha512-DHx1rT1zauW0ZbLHOiQh5AC9Xs3UkWx2XmfZHs+7nnWYr3sagrufoUQC+/XPwwjMIlCFXiFGM0sFh3TyOCZwqA==} + '@oxc-parser/binding-linux-riscv64-gnu@0.127.0': + resolution: {integrity: sha512-fPP8M6zQLS7Jz7o9d5ArUSuAuSK3e+WCYVrCpdzeCOejidtZExJ9tjhDrAd3HEPqARBCPmdpqxESPFqy44vkBQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-riscv64-musl@0.126.0': - resolution: {integrity: sha512-umDc2mTShH0U2zcEYf8mIJ163seLJNn54ZUZYeI5jD4qlg9izPwoLrC2aNPKlMJTu6u/ysmQWiEvIiaAG+INkw==} + '@oxc-parser/binding-linux-riscv64-musl@0.127.0': + resolution: {integrity: sha512-7IcC4Ao02oGpfnjt+X/oF4U2mllo2qoSkw5xxiXNKL9MCTsTiAC6616beOuehdxGcnz1bRoPC1RQ2f1GQDdN+g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxc-parser/binding-linux-s390x-gnu@0.126.0': - resolution: {integrity: sha512-PXXeWayclRtO1pxQEeCpiqIglQdhK2mAI2VX5xnsWdImzSB5GpoQ8TNw7vTCKk2k+GZuxl+q1knncidjCyUP9w==} + '@oxc-parser/binding-linux-s390x-gnu@0.127.0': + resolution: {integrity: sha512-pbXIhiNFHoqWeqDNLiJ9JkpHz1IM9k4DXa66x+1GTWMG7iLxtkXgE53iiuKSXwmk3zIYmaPVfBvgcAhS583K4Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-gnu@0.126.0': - resolution: {integrity: sha512-wzocjxm34TbB3bFlqG65JiLtvf6ZDg2ZxRkLLbgXwDQUNU+0MPjQN8zy/0jBKNA5fnPLk3XeVdZ7Uin+7+CVkg==} + '@oxc-parser/binding-linux-x64-gnu@0.127.0': + resolution: {integrity: sha512-MYCguB9RvBvlSd6gbuNI7QwiLoCCAlGnlRJFPrzLI6U1/9wkC/WK6LtBAUln55H1Ctqw45PWmqrobKoMhsYQzQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxc-parser/binding-linux-x64-musl@0.126.0': - resolution: {integrity: sha512-e83uftP60jmkPs2+CW6T6A1GYzN2H6IumDAiTntv9WyHR73PI3ImHNBkYqnA3ukeKI3xjcCbhSh9QeJWmufxGQ==} + '@oxc-parser/binding-linux-x64-musl@0.127.0': + resolution: {integrity: sha512-5eY0B/bxf1xIUxb4NOTvOI3KWtBQfPWYyKAzgcrCt0mDibSZygVpO1Pz8bkeiSZ5Jj9+M09dkggG3H8I5d0Uyg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxc-parser/binding-openharmony-arm64@0.126.0': - resolution: {integrity: sha512-4WiOILHnPrTDY2/L4mE6PZCYwLN1d3ghma6BuTJ452CCgzRMt3uFplCtR+o3r9zdUWJYb370UizpI9CUcWXr1A==} + '@oxc-parser/binding-openharmony-arm64@0.127.0': + resolution: {integrity: sha512-Gld0ajrFTUXNtdw20fVBuTQx66FA75nIVg+//pPfR3sXkuABB4mTBhl3r9JNzrJpgW//qiwxf0nWXUWGJSL3UQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxc-parser/binding-wasm32-wasi@0.126.0': - resolution: {integrity: sha512-Y17hhnrQTrxgAxAyAq401vnN9URsAL4s5AjqpG1NDsXSlhe1yBNnns+rC2P6xcMoitgX5nKH2ryYt9oiFRlzLw==} - engines: {node: '>=14.0.0'} + '@oxc-parser/binding-wasm32-wasi@0.127.0': + resolution: {integrity: sha512-T6KVD7rhLzFlwGRXMnxUFfkCZD8FHnb968wVXW1mXzgRFc5RNXOBY2mPPDZ77x5Ln76ltLMgtPg0cOkU1NSrEQ==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] - '@oxc-parser/binding-win32-arm64-msvc@0.126.0': - resolution: {integrity: sha512-Znug1u1iRvT4VC3jANz6nhGBHsFwEFMxuimYpJFwMtsB6H5FcEoZRMmH26tHkSTD03JvDmG+gB65W3ajLjPcSw==} + '@oxc-parser/binding-win32-arm64-msvc@0.127.0': + resolution: {integrity: sha512-Ujvw4X+LD1CCGULcsQcvb4YNVoBGqt+JHgNNzGGaCImELiZLk477ifUH53gIbE7EKd933NdTi25JWEr9K2HwXw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxc-parser/binding-win32-ia32-msvc@0.126.0': - resolution: {integrity: sha512-qrw7mx5hFFTxVSXToOA40hpnjgNB/DJprZchtB4rDKNLKqkD3F26HbzaQeH1nxAKej0efSZfJd5Sw3qdtOLGhw==} + '@oxc-parser/binding-win32-ia32-msvc@0.127.0': + resolution: {integrity: sha512-0cwxKO7KHQQQfo4Uf4B2SQrhgm+cJaP9OvFFhx52Tkg4bezsacu83GB2/In5bC415Ueeym+kXdnge/57rbSfTw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxc-parser/binding-win32-x64-msvc@0.126.0': - resolution: {integrity: sha512-ibB1s+mPUFXvS7MFJO2jpw/aCNs/P6ifnWlRyTYB+WYBpniOiCcHQQskZneJtwcjQMDRol3RGG3ihoYnzXSY4w==} + '@oxc-parser/binding-win32-x64-msvc@0.127.0': + resolution: {integrity: sha512-rOrnSQSCbhI2kowr9XxE7m9a8oQXnBHjnS6j95LxxAnEZ0+Fz20WlRXG4ondQb+ejjt2KOsa65sE6++L6kUd+w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -2710,6 +2704,9 @@ packages: '@oxc-project/types@0.126.0': resolution: {integrity: sha512-oGfVtjAgwQVVpfBrbtk4e1XDyWHRFta6BS3GWVzrF8xYBT2VGQAk39yJS/wFSMrZqoiCU4oghT3Ch0HaHGIHcQ==} + '@oxc-project/types@0.127.0': + resolution: {integrity: sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==} + '@oxc-resolver/binding-android-arm-eabi@11.19.1': resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==} cpu: [arm] @@ -3386,8 +3383,8 @@ packages: resolution: {integrity: sha512-UuBOt7BOsKVOkFXRe4Ypd/lADuNIfqJXv8GvHqtXaTYXPPKkj2nS2zPllVsrtRjcomDhIJVBnZwfmlI222WH8g==} engines: {node: '>=14.0.0'} - '@rolldown/pluginutils@1.0.0-rc.15': - resolution: {integrity: sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==} + '@rolldown/pluginutils@1.0.0-rc.17': + resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} '@rolldown/pluginutils@1.0.0-rc.7': resolution: {integrity: sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==} @@ -3410,32 +3407,32 @@ packages: rollup: optional: true - '@sentry-internal/browser-utils@10.49.0': - resolution: {integrity: sha512-n0QRx0Ysx6mPfIydTkz7VP0FmwM+/EqMZiRqdsU3aTYsngE9GmEDV0OL1bAy6a8N/C1xf9vntkuAtj6N/8Z51w==} + '@sentry-internal/browser-utils@10.50.0': + resolution: {integrity: sha512-42bxyRTxnCmYlWnvz4CxikuQNanw8UNma2WJrtxJ0f1MAJV2GhQGSHDLnA+lvFlmiz6qct3pfen/NXGyOTegTA==} engines: {node: '>=18'} - '@sentry-internal/feedback@10.49.0': - resolution: {integrity: sha512-JNsUBGv0faCFE7MeZUH99Y9lU9qq3LBALbLxpE1x7ngNrQnVYRlcFgdqaD/btNBKr8awjYL8gmcSkHBWskGqLQ==} + '@sentry-internal/feedback@10.50.0': + resolution: {integrity: sha512-0k9XZF0wn86f77mIO2U3gNNyDZooy139CnEanRzHinrN106vVzvBZ6TUEQoHtoO1fqQxr+nWWVrqV/PXUqk47w==} engines: {node: '>=18'} - '@sentry-internal/replay-canvas@10.49.0': - resolution: {integrity: sha512-7D/NrgH1Qwx5trDYaaTSSJmCb1yVQQLqFG4G/S9x2ltzl9876lSGJL8UeW8ReNQgF3CDAcwbmm/9aXaVSBUNZA==} + '@sentry-internal/replay-canvas@10.50.0': + resolution: {integrity: sha512-jx6RKBmcJSWdI92qDGS/sBv1w+7Cww879Z/moX7bw7ipHa/Ts3iDcB3rgZwvhmi17U+mvYsbJeL2DXkPo3TjPw==} engines: {node: '>=18'} - '@sentry-internal/replay@10.49.0': - resolution: {integrity: sha512-IEy4lwHVMiRE3JAcn+kFKjsTgalDOCSTf20SoFd+nkt6rN/k1RDyr4xpdfF//Kj3UdeTmbuibYjK5H/FLhhnGg==} + '@sentry-internal/replay@10.50.0': + resolution: {integrity: sha512-51FYNfnvVLAWw1rrEWPFfwHuMRb9mkVCFGA4J9/un7SpeGBsQDziGB0Di4fsCxI7+EdSBpfLHPF0csKtCCw0oQ==} engines: {node: '>=18'} - '@sentry/browser@10.49.0': - resolution: {integrity: sha512-bGCHc+wK2Dx67YoSbmtlt04alqWfQ+dasD/GVipVOq50gvw/BBIDHTEWRJEjACl+LrvszeY54V+24p8z4IgysA==} + '@sentry/browser@10.50.0': + resolution: {integrity: sha512-1f6rAvET6myiTaSeYqvaaBwvq1LfxqWjAPIoAW/NVC9bPMkeEcuvgDajHrnZMrBeWoJ81NMyoLkyX+iOc7MoFA==} engines: {node: '>=18'} - '@sentry/core@10.49.0': - resolution: {integrity: sha512-UaFeum3LUM1mB0d67jvKnqId1yWQjyqmaDV6kWngG03x+jqXb08tJdGpSoxjXZe13jFBbiBL/wKDDYIK7rCK4g==} + '@sentry/core@10.50.0': + resolution: {integrity: sha512-J4A+vzUO3adl0TkFCjaN1+4miamrjHiEIYuLHiuu1lmAjq5WIVw32ObvAh4yMwNtxyaEMosTrrh5M6f12XSJFg==} engines: {node: '>=18'} - '@sentry/react@10.49.0': - resolution: {integrity: sha512-WdfJve0orTiumr25Ozgs2p2KaJR9xV82Z5V9IYBi0TadsurSWK6xI6SAFjw84tQht9Fp8q4UCn3QYCnApF4BfA==} + '@sentry/react@10.50.0': + resolution: {integrity: sha512-MZHYjEZAtFIa4zPrWS4oXlo+gMppRvfETqUqF920Sj2jN2U7WjboU03lDmjfDqEcH7QiwjQyl13jHd2nwAyrrw==} engines: {node: '>=18'} peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x @@ -3815,8 +3812,8 @@ packages: engines: {node: '>=18'} hasBin: true - '@tanstack/eslint-plugin-query@5.99.2': - resolution: {integrity: sha512-xiazL4CWOHJRDDgs5ZkfW98qlEAisakFDKh1Djc3BIk84tsvt3ow52AC2EiWSMY1q13IB4UI4jSo7yXlC3NL6g==} + '@tanstack/eslint-plugin-query@5.100.5': + resolution: {integrity: sha512-WKt+xyxvMQkUL4sqMQ8l3gzCplNi9HedVQN32WmBJYKITJ9a5r3H5cpICp8y96V8ZL5rZH0EZRgpO6sy8fAgrQ==} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: ^5.4.0 || ^6.0.0 @@ -3836,11 +3833,11 @@ packages: resolution: {integrity: sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w==} engines: {node: '>=18'} - '@tanstack/query-core@5.99.2': - resolution: {integrity: sha512-1HunU0bXVsR1ZJMZbcOPE6VtaBJxsW809RE9xPe4Gz7MlB0GWwQvuTPhMoEmQ/hIzFKJ/DWAuttIe7BOaWx0tA==} + '@tanstack/query-core@5.100.5': + resolution: {integrity: sha512-t20KrhKkf0HXzqQkPbJ5erhFesup68BAbwFgYmTrS7bxMF7O5MdmL8jUkik4thsG7Hg00fblz30h6yF1d5TxGg==} - '@tanstack/query-devtools@5.99.2': - resolution: {integrity: sha512-TEF1d+RYO9l8oeCwgzmOHIgKwAzXQmw2s/ny2bW8qeg2OMkkLjALfVEivgCMR3OL/jVdMmeTPX56WrV+uvYJFg==} + '@tanstack/query-devtools@5.100.5': + resolution: {integrity: sha512-SuCkVCqqliRYJvm+LEL2U/TcFv92zTnHj6OGrJFHp1v/RsiwamI+ZDgQzbeUrLsJb8/Nj/52aIw0NyDMcVHl4A==} '@tanstack/react-devtools@0.10.2': resolution: {integrity: sha512-1BmZyxOrI5SqmRJ5MgkYZNNdnlLsJxQRI2YgorrAvcF2MxK6x5RcuStvD8+YlXoMw3JtNukPxoITirKAnKYDQA==} @@ -3865,14 +3862,14 @@ packages: '@tanstack/react-start': optional: true - '@tanstack/react-query-devtools@5.99.2': - resolution: {integrity: sha512-8txkK9A9XBNTB8RoxVgfp6W3qwBr25tNP10L4yu3KuyhAdEvccECfIRzesSwMVk/wpVVioAr+hbMtUkMMF+WVw==} + '@tanstack/react-query-devtools@5.100.5': + resolution: {integrity: sha512-bItQERx7dJoiI0WEoS4tIrvNnmk4kUYsaQLdIpm4o9Kttmsi5B6xlY6JBDkavstR3hH/R2+VT5dr3L5LBFPW4g==} peerDependencies: - '@tanstack/react-query': ^5.99.2 + '@tanstack/react-query': ^5.100.5 react: ^18 || ^19 - '@tanstack/react-query@5.99.2': - resolution: {integrity: sha512-vM91UEe45QUS9ED6OklsVL15i8qKcRqNwpWzPTVWvRPRSEgDudDgHpvyTjcdlwHcrKNa80T+xXYcchT2noPnZA==} + '@tanstack/react-query@5.100.5': + resolution: {integrity: sha512-aNwj1mi2v2bQ9IxkyR1grLOUkv3BYWoykHy9KDyLNbjC3tsahbOHJibK+Wjtr1wRhG59/AvJhiJG5OlthaCgJA==} peerDependencies: react: ^18 || ^19 @@ -4287,43 +4284,51 @@ packages: resolution: {integrity: sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260422.1': - resolution: {integrity: sha512-W/lGgoEfbdI/QWYqcNP0fSa4DHQKKEMLzDPsE6fA64zmfCNsTO9M7ttK0acKiLsGB16pr0lubuMDRNN5kXyQ8w==} + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260426.1': + resolution: {integrity: sha512-HzGvERpIFO7p6pMljPN1fIOHqAv2oMeVIqYLSt27TKILkTRpe7fANW3R2OAM+/A+pLtYNNXGDbKl/wR+DHz9KA==} + engines: {node: '>=16.20.0'} cpu: [arm64] os: [darwin] - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260422.1': - resolution: {integrity: sha512-6tZ2yAcKLBIghwKyC74vDqb/7rB99fTpERv9f64iA1tMh6l+WHIuQb6z3mIFVOYBIl2pN9CYasURLroKYtUz1w==} + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260426.1': + resolution: {integrity: sha512-aE17wCPNQ09K4jV7TQYYRYF/Q/6nFS9jLpbyTYHtS+i+0yV1Rrs4VsqboisS1R/iSWsq3m1Yhh3uS4x3/9KUkg==} + engines: {node: '>=16.20.0'} cpu: [x64] os: [darwin] - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260422.1': - resolution: {integrity: sha512-7HL4E7kP0ociYB8R4+QuIbzfT3pjdesNY+ax/q6fP3IMd3/QNAL/qsm/NaokjXke+I7uYxKqQ8Qo/t5MSv/r+A==} + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260426.1': + resolution: {integrity: sha512-6OfhODChD1N6FX+ITzA1lny3WX6uew/Nw9kN7uWhymXlM3/vE0qtaAfsMpgdHdCbTPgcdpGaNFhbcMieju9Vdg==} + engines: {node: '>=16.20.0'} cpu: [arm64] os: [linux] - '@typescript/native-preview-linux-arm@7.0.0-dev.20260422.1': - resolution: {integrity: sha512-EWP1Jq2I8MMSkoF9D6ztXgRmnUy2KcaZfL9FYcdm3Am6ZYuI6/SCR3HVIVYbaixAJXe/qUh5MN3LzJbl/4hefQ==} + '@typescript/native-preview-linux-arm@7.0.0-dev.20260426.1': + resolution: {integrity: sha512-/XJRC8B6JeOOb2/iek/BrzW4r5Nut+fkucG7ntEOQn63IRTsfP+AfJdJodG1VIwXOleNlFgG4RtYTUsvcbDJhg==} + engines: {node: '>=16.20.0'} cpu: [arm] os: [linux] - '@typescript/native-preview-linux-x64@7.0.0-dev.20260422.1': - resolution: {integrity: sha512-fDqkLf2Hv7X1Cy1B5OMcljPt/+8GpnTxFM9rDCFrYAPgOolIQJ9qwkb+xGfvAtxkkE5sZIvGPcqjP9PWQHt2qw==} + '@typescript/native-preview-linux-x64@7.0.0-dev.20260426.1': + resolution: {integrity: sha512-KPDpjmLo/4xY8ugfMGFm7Ona/1igPzZveLt/C0rb6/jNPYuShumRfKYnItGDRXBlmecJY/04lrqkWqQjhtSSPg==} + engines: {node: '>=16.20.0'} cpu: [x64] os: [linux] - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260422.1': - resolution: {integrity: sha512-l1tDnyNQSqxFkKz683dD8EORQtcQqZyWkTDnRtHmaPg2mTRxhxSekL/HcsHx/1/DoGTfl310O+CmXzd2mTq3pQ==} + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260426.1': + resolution: {integrity: sha512-I7ThiopxuNKX/iAcwgMwsm6L32GOwmwLOyPwQmXjh5c3VD2acq3FYyZRDJVk0aUUy1w6bTbODlo5ZHoPnlZtvw==} + engines: {node: '>=16.20.0'} cpu: [arm64] os: [win32] - '@typescript/native-preview-win32-x64@7.0.0-dev.20260422.1': - resolution: {integrity: sha512-VQbDQlp1bjV5nnHagQLXQAhid3S48l1OToIBjvqlw18s0V0YSgoyNL6E/rE7FBdkGrTLf/rtKjo42IZnt3tvqA==} + '@typescript/native-preview-win32-x64@7.0.0-dev.20260426.1': + resolution: {integrity: sha512-4624MJq72vN4H1msiWVBqAIyerJRi5Ni/U6eeE1A1Opqg4c4QoalYQQ+5h5RIuaZ6rY+9kvUn+SjsvbZwyLbjQ==} + engines: {node: '>=16.20.0'} cpu: [x64] os: [win32] - '@typescript/native-preview@7.0.0-dev.20260422.1': - resolution: {integrity: sha512-8CR8zHFlLpSL5OXY4Wbz2DmiDOoat1JBMkydZUHwQIS4cpoTN7SHjk2BN8i51XHUy0jMF5airL0TlY3GOfZmKg==} + '@typescript/native-preview@7.0.0-dev.20260426.1': + resolution: {integrity: sha512-zE7B6TIG4XDYr4Your5E2Bxm1vD2YiPyD8OFG4nD5Odt/uN6gO0Y+T4TIbtGUBmOftMRqEV2Jw1ZC4ka0my1yw==} + engines: {node: '>=16.20.0'} hasBin: true '@ungap/structured-clone@1.3.0': @@ -4380,8 +4385,8 @@ packages: babel-plugin-react-compiler: optional: true - '@vitejs/plugin-rsc@0.5.24': - resolution: {integrity: sha512-FQ7o1Zf1GUB8L5qlIuV2mvIv/KahG2qUYW2gMpxyIN3zF7voDsfvA/t8w/TLjYC0T6p3JwMnK3N+YzMGf/m75A==} + '@vitejs/plugin-rsc@0.5.25': + resolution: {integrity: sha512-u+0l91DPzvCQjZX0YcdVTfv0171f1GzTL1EkRlu2dx9DY6kXu+xi+oCuPYaVI0KGj4q6gJiJCYSWNuCjuT+Otw==} peerDependencies: react: '*' react-dom: '*' @@ -4600,8 +4605,8 @@ packages: '@xstate/fsm@1.6.5': resolution: {integrity: sha512-b5o1I6aLNeYlU/3CPlj/Z91ybk1gUsKT+5NAJI+2W4UjvS5KLG28K9v5UvNoFVjHV8PajVZ00RH3vnjyQO7ZAw==} - abcjs@6.6.2: - resolution: {integrity: sha512-YLbp5lYUq0uOywWZx9EuTdm0TcflKZi7hOzz366A/LFl3qoAXSYIjznJQmr/VeHg8NcLxZYoN8dLi7PqCpxKEA==} + abcjs@6.6.3: + resolution: {integrity: sha512-BerGJCY8+pvJV1+VxZn1Y/VNcuSAk8BysCbBICY0W8fgE5g4W6sA/zB5pKxcgqzY5/gObh8ugl++4ZoaTqUCkw==} acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} @@ -5996,8 +6001,8 @@ packages: resolution: {integrity: sha512-Ox1pJVrDCyGHMG9CFg1tmrRUMRPRsAWYc/PinY0XzJU4K7y7vjNoLKIQ7BR5UJMCxNN8EM1MNDmHWA/B3aZUuw==} engines: {node: '>=6'} - hono@4.12.14: - resolution: {integrity: sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==} + hono@4.12.15: + resolution: {integrity: sha512-qM0jDhFEaCBb4TxoW7f53Qrpv9RBiayUHo0S52JudprkhvpjIrGoU1mnnr29Fvd1U335ZFPZQY1wlkqgfGXyLg==} engines: {node: '>=16.9.0'} hosted-git-info@9.0.2: @@ -6285,8 +6290,8 @@ packages: khroma@2.1.0: resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==} - knip@6.6.1: - resolution: {integrity: sha512-SOmqh25vuAfdynGoDr/kMCxIuD5+PkMIfMSGQeMqfrxwuPTANvJKcVttLgGZjjkATALqukSe/hhDVqcwNkf92g==} + knip@6.7.0: + resolution: {integrity: sha512-ckL51NDH1YJxnv1kNB0iUdDngB4f/e9Igz8uIqYfmNDoyOFmmk1V0WFv3LQ7/hzC63b2Z9X41gGUE9eOWrZpaA==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true @@ -6438,8 +6443,8 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true - loro-crdt@1.11.1: - resolution: {integrity: sha512-R+Ksyy2FPYoOfJAkVY6BqGk11AtlgWZ1B91V/G7TaQxitxuvUvMd1URhO33LYfFUIT2CSn0Nikl+bbRZ2RGuZg==} + loro-crdt@1.12.0: + resolution: {integrity: sha512-+QAqhBEQ3VZqQKRYjVZElZKLMgtQoewaT1l+oZUh74WsCNqvNI5hazy5gM35NQvcOkrebskWc15a33LS6WAR7g==} loupe@3.2.1: resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} @@ -6886,8 +6891,8 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} - oxc-parser@0.126.0: - resolution: {integrity: sha512-FktCvLby/mOHyuijZt22+nOt10dS24gGUZE3XwIbUg7Kf4+rer3/5T7RgwzazlNuVsCjPloZ3p8E+4ONT3A8Kw==} + oxc-parser@0.127.0: + resolution: {integrity: sha512-bkgD4qHlN7WxLdX8bLXdaU54TtQtAIg/ZBAfm0aje/mo3MRDo3P0hZSgr4U7O3xfX+fQmR5AP04JS/TGcZLcFA==} engines: {node: ^20.19.0 || >=22.12.0} oxc-resolver@11.19.1: @@ -7085,8 +7090,8 @@ packages: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.10: - resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} + postcss@8.5.12: + resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==} engines: {node: ^10 || ^12 || >=14} postcss@8.5.9: @@ -7634,8 +7639,8 @@ packages: string-ts@2.3.1: resolution: {integrity: sha512-xSJq+BS52SaFFAVxuStmx6n5aYZU571uYUnUrPXkPFCfdHyZMMlbP2v2Wx5sNBnAVzq/2+0+mcBLBa3Xa5ubYw==} - string-width@8.2.0: - resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + string-width@8.2.1: + resolution: {integrity: sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==} engines: {node: '>=20'} string.prototype.codepointat@0.2.1: @@ -7934,8 +7939,8 @@ packages: engines: {node: '>=0.8.0'} hasBin: true - unbash@2.2.0: - resolution: {integrity: sha512-X2wH19RAPZE3+ldGicOkoj/SIA83OIxcJ6Cuaw23hf8Xc6fQpvZXY0SftE2JgS0QhYLUG4uwodSI3R53keyh7w==} + unbash@3.0.0: + resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==} engines: {node: '>=14'} undici-types@7.19.2: @@ -8394,28 +8399,28 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@amplitude/analytics-browser@2.41.0': + '@amplitude/analytics-browser@2.41.1': dependencies: - '@amplitude/analytics-core': 2.47.0 - '@amplitude/plugin-autocapture-browser': 1.26.0 - '@amplitude/plugin-custom-enrichment-browser': 0.1.6 - '@amplitude/plugin-event-property-attribution-browser': 0.1.1 - '@amplitude/plugin-network-capture-browser': 1.9.15 - '@amplitude/plugin-page-url-enrichment-browser': 0.7.7 - '@amplitude/plugin-page-view-tracking-browser': 2.10.1 - '@amplitude/plugin-web-vitals-browser': 1.1.30 + '@amplitude/analytics-core': 2.47.1 + '@amplitude/plugin-autocapture-browser': 1.26.1 + '@amplitude/plugin-custom-enrichment-browser': 0.1.7 + '@amplitude/plugin-event-property-attribution-browser': 0.1.2 + '@amplitude/plugin-network-capture-browser': 1.9.16 + '@amplitude/plugin-page-url-enrichment-browser': 0.7.8 + '@amplitude/plugin-page-view-tracking-browser': 2.10.2 + '@amplitude/plugin-web-vitals-browser': 1.1.31 tslib: 2.8.1 - '@amplitude/analytics-client-common@2.4.45': + '@amplitude/analytics-client-common@2.4.46': dependencies: '@amplitude/analytics-connector': 1.6.4 - '@amplitude/analytics-core': 2.47.0 + '@amplitude/analytics-core': 2.47.1 '@amplitude/analytics-types': 2.11.1 tslib: 2.8.1 '@amplitude/analytics-connector@1.6.4': {} - '@amplitude/analytics-core@2.47.0': + '@amplitude/analytics-core@2.47.1': dependencies: '@amplitude/analytics-connector': 1.6.4 '@types/zen-observable': 0.8.3 @@ -8429,53 +8434,53 @@ snapshots: dependencies: js-base64: 3.7.8 - '@amplitude/plugin-autocapture-browser@1.26.0': + '@amplitude/plugin-autocapture-browser@1.26.1': dependencies: - '@amplitude/analytics-core': 2.47.0 + '@amplitude/analytics-core': 2.47.1 tslib: 2.8.1 - '@amplitude/plugin-custom-enrichment-browser@0.1.6': + '@amplitude/plugin-custom-enrichment-browser@0.1.7': dependencies: - '@amplitude/analytics-core': 2.47.0 + '@amplitude/analytics-core': 2.47.1 tslib: 2.8.1 - '@amplitude/plugin-event-property-attribution-browser@0.1.1': + '@amplitude/plugin-event-property-attribution-browser@0.1.2': dependencies: - '@amplitude/analytics-core': 2.47.0 + '@amplitude/analytics-core': 2.47.1 tslib: 2.8.1 - '@amplitude/plugin-network-capture-browser@1.9.15': + '@amplitude/plugin-network-capture-browser@1.9.16': dependencies: - '@amplitude/analytics-core': 2.47.0 + '@amplitude/analytics-core': 2.47.1 tslib: 2.8.1 - '@amplitude/plugin-page-url-enrichment-browser@0.7.7': + '@amplitude/plugin-page-url-enrichment-browser@0.7.8': dependencies: - '@amplitude/analytics-core': 2.47.0 + '@amplitude/analytics-core': 2.47.1 tslib: 2.8.1 - '@amplitude/plugin-page-view-tracking-browser@2.10.1': + '@amplitude/plugin-page-view-tracking-browser@2.10.2': dependencies: - '@amplitude/analytics-core': 2.47.0 + '@amplitude/analytics-core': 2.47.1 tslib: 2.8.1 - '@amplitude/plugin-session-replay-browser@1.27.10(@amplitude/rrweb@2.0.0-alpha.37)': + '@amplitude/plugin-session-replay-browser@1.28.0(@amplitude/rrweb@2.0.0-alpha.37)': dependencies: - '@amplitude/analytics-client-common': 2.4.45 - '@amplitude/analytics-core': 2.47.0 + '@amplitude/analytics-client-common': 2.4.46 + '@amplitude/analytics-core': 2.47.1 '@amplitude/analytics-types': 2.11.1 '@amplitude/rrweb-plugin-console-record': 2.0.0-alpha.36(@amplitude/rrweb@2.0.0-alpha.37) '@amplitude/rrweb-record': 2.0.0-alpha.36 - '@amplitude/session-replay-browser': 1.37.0(@amplitude/rrweb@2.0.0-alpha.37) + '@amplitude/session-replay-browser': 1.38.0(@amplitude/rrweb@2.0.0-alpha.37) idb-keyval: 6.2.2 tslib: 2.8.1 transitivePeerDependencies: - '@amplitude/rrweb' - rollup - '@amplitude/plugin-web-vitals-browser@1.1.30': + '@amplitude/plugin-web-vitals-browser@1.1.31': dependencies: - '@amplitude/analytics-core': 2.47.0 + '@amplitude/analytics-core': 2.47.1 tslib: 2.8.1 web-vitals: 5.1.0 @@ -8499,7 +8504,7 @@ snapshots: '@amplitude/rrweb-snapshot@2.0.0-alpha.37': dependencies: - postcss: 8.5.10 + postcss: 8.5.12 '@amplitude/rrweb-types@2.0.0-alpha.36': {} @@ -8520,10 +8525,10 @@ snapshots: base64-arraybuffer: 1.0.2 mitt: 3.0.1 - '@amplitude/session-replay-browser@1.37.0(@amplitude/rrweb@2.0.0-alpha.37)': + '@amplitude/session-replay-browser@1.38.0(@amplitude/rrweb@2.0.0-alpha.37)': dependencies: - '@amplitude/analytics-client-common': 2.4.45 - '@amplitude/analytics-core': 2.47.0 + '@amplitude/analytics-client-common': 2.4.46 + '@amplitude/analytics-core': 2.47.1 '@amplitude/analytics-types': 2.11.1 '@amplitude/experiment-core': 0.7.2 '@amplitude/rrweb-packer': 2.0.0-alpha.36 @@ -8541,8 +8546,8 @@ snapshots: '@amplitude/targeting@0.2.0': dependencies: - '@amplitude/analytics-client-common': 2.4.45 - '@amplitude/analytics-core': 2.47.0 + '@amplitude/analytics-client-common': 2.4.46 + '@amplitude/analytics-core': 2.47.1 '@amplitude/analytics-types': 2.11.1 '@amplitude/experiment-core': 0.7.2 idb: 8.0.0 @@ -8867,18 +8872,18 @@ snapshots: dependencies: regexp-match-indices: 1.0.2 - '@cucumber/cucumber@12.8.1': + '@cucumber/cucumber@12.8.2': dependencies: '@cucumber/ci-environment': 13.0.0 '@cucumber/cucumber-expressions': 19.0.0 '@cucumber/gherkin': 38.0.0 - '@cucumber/gherkin-streams': 6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.1.1(@cucumber/messages@32.2.0))(@cucumber/messages@32.2.0) + '@cucumber/gherkin-streams': 6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.1.1(@cucumber/messages@32.3.1))(@cucumber/messages@32.3.1) '@cucumber/gherkin-utils': 11.0.0 - '@cucumber/html-formatter': 23.0.0(@cucumber/messages@32.2.0) - '@cucumber/junit-xml-formatter': 0.13.3(@cucumber/messages@32.2.0) - '@cucumber/message-streams': 4.1.1(@cucumber/messages@32.2.0) - '@cucumber/messages': 32.2.0 - '@cucumber/pretty-formatter': 1.0.1(@cucumber/cucumber@12.8.1)(@cucumber/messages@32.2.0) + '@cucumber/html-formatter': 23.1.0(@cucumber/messages@32.3.1) + '@cucumber/junit-xml-formatter': 0.13.3(@cucumber/messages@32.3.1) + '@cucumber/message-streams': 4.1.1(@cucumber/messages@32.3.1) + '@cucumber/messages': 32.3.1 + '@cucumber/pretty-formatter': 1.0.1(@cucumber/cucumber@12.8.2)(@cucumber/messages@32.3.1) '@cucumber/tag-expressions': 9.1.0 assertion-error-formatter: 3.0.0 capital-case: 1.0.4 @@ -8909,60 +8914,60 @@ snapshots: yaml: 2.8.3 yup: 1.7.1 - '@cucumber/gherkin-streams@6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.1.1(@cucumber/messages@32.2.0))(@cucumber/messages@32.2.0)': + '@cucumber/gherkin-streams@6.0.0(@cucumber/gherkin@38.0.0)(@cucumber/message-streams@4.1.1(@cucumber/messages@32.3.1))(@cucumber/messages@32.3.1)': dependencies: '@cucumber/gherkin': 38.0.0 - '@cucumber/message-streams': 4.1.1(@cucumber/messages@32.2.0) - '@cucumber/messages': 32.2.0 + '@cucumber/message-streams': 4.1.1(@cucumber/messages@32.3.1) + '@cucumber/messages': 32.3.1 commander: 14.0.0 source-map-support: 0.5.21 '@cucumber/gherkin-utils@11.0.0': dependencies: '@cucumber/gherkin': 38.0.0 - '@cucumber/messages': 32.2.0 + '@cucumber/messages': 32.3.1 '@teppeis/multimaps': 3.0.0 commander: 14.0.2 source-map-support: 0.5.21 '@cucumber/gherkin@38.0.0': dependencies: - '@cucumber/messages': 32.2.0 + '@cucumber/messages': 32.3.1 - '@cucumber/html-formatter@23.0.0(@cucumber/messages@32.2.0)': + '@cucumber/html-formatter@23.1.0(@cucumber/messages@32.3.1)': dependencies: - '@cucumber/messages': 32.2.0 + '@cucumber/messages': 32.3.1 - '@cucumber/junit-xml-formatter@0.13.3(@cucumber/messages@32.2.0)': + '@cucumber/junit-xml-formatter@0.13.3(@cucumber/messages@32.3.1)': dependencies: - '@cucumber/messages': 32.2.0 - '@cucumber/query': 15.0.1(@cucumber/messages@32.2.0) + '@cucumber/messages': 32.3.1 + '@cucumber/query': 15.0.1(@cucumber/messages@32.3.1) '@teppeis/multimaps': 3.0.0 luxon: 3.7.2 xmlbuilder: 15.1.1 - '@cucumber/message-streams@4.1.1(@cucumber/messages@32.2.0)': + '@cucumber/message-streams@4.1.1(@cucumber/messages@32.3.1)': dependencies: - '@cucumber/messages': 32.2.0 + '@cucumber/messages': 32.3.1 mime: 3.0.0 - '@cucumber/messages@32.2.0': + '@cucumber/messages@32.3.1': dependencies: class-transformer: 0.5.1 reflect-metadata: 0.2.2 - '@cucumber/pretty-formatter@1.0.1(@cucumber/cucumber@12.8.1)(@cucumber/messages@32.2.0)': + '@cucumber/pretty-formatter@1.0.1(@cucumber/cucumber@12.8.2)(@cucumber/messages@32.3.1)': dependencies: - '@cucumber/cucumber': 12.8.1 - '@cucumber/messages': 32.2.0 + '@cucumber/cucumber': 12.8.2 + '@cucumber/messages': 32.3.1 ansi-styles: 5.2.0 cli-table3: 0.6.5 figures: 3.2.0 ts-dedent: 2.2.0 - '@cucumber/query@15.0.1(@cucumber/messages@32.2.0)': + '@cucumber/query@15.0.1(@cucumber/messages@32.3.1)': dependencies: - '@cucumber/messages': 32.2.0 + '@cucumber/messages': 32.3.1 '@teppeis/multimaps': 3.0.0 lodash.sortby: 4.7.0 @@ -9349,7 +9354,7 @@ snapshots: '@formatjs/fast-memoize@3.1.2': {} - '@formatjs/intl-localematcher@0.8.3': + '@formatjs/intl-localematcher@0.8.4': dependencies: '@formatjs/fast-memoize': 3.1.2 @@ -9367,9 +9372,9 @@ snapshots: dependencies: react: 19.2.5 - '@hono/node-server@1.19.14(hono@4.12.14)': + '@hono/node-server@1.19.14(hono@4.12.15)': dependencies: - hono: 4.12.14 + hono: 4.12.15 '@humanfs/core@0.19.1': {} @@ -9774,13 +9779,6 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - '@napi-rs/wasm-runtime@1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': - dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.9.2 - '@tybys/wasm-util': 0.10.1 - optional: true - '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: '@emnapi/core': 1.9.2 @@ -9847,136 +9845,138 @@ snapshots: '@nolyfill/side-channel@1.0.44': {} - '@orpc/client@1.13.14': + '@orpc/client@1.14.0': dependencies: - '@orpc/shared': 1.13.14 - '@orpc/standard-server': 1.13.14 - '@orpc/standard-server-fetch': 1.13.14 - '@orpc/standard-server-peer': 1.13.14 + '@orpc/shared': 1.14.0 + '@orpc/standard-server': 1.14.0 + '@orpc/standard-server-fetch': 1.14.0 + '@orpc/standard-server-peer': 1.14.0 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/contract@1.13.14': + '@orpc/contract@1.14.0': dependencies: - '@orpc/client': 1.13.14 - '@orpc/shared': 1.13.14 + '@orpc/client': 1.14.0 + '@orpc/shared': 1.14.0 '@standard-schema/spec': 1.1.0 openapi-types: 12.1.3 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/openapi-client@1.13.14': + '@orpc/openapi-client@1.14.0': dependencies: - '@orpc/client': 1.13.14 - '@orpc/contract': 1.13.14 - '@orpc/shared': 1.13.14 - '@orpc/standard-server': 1.13.14 + '@orpc/client': 1.14.0 + '@orpc/contract': 1.14.0 + '@orpc/shared': 1.14.0 + '@orpc/standard-server': 1.14.0 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/shared@1.13.14': + '@orpc/shared@1.14.0': dependencies: radash: 12.1.1 type-fest: 5.5.0 - '@orpc/standard-server-fetch@1.13.14': + '@orpc/standard-server-fetch@1.14.0': dependencies: - '@orpc/shared': 1.13.14 - '@orpc/standard-server': 1.13.14 + '@orpc/shared': 1.14.0 + '@orpc/standard-server': 1.14.0 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/standard-server-peer@1.13.14': + '@orpc/standard-server-peer@1.14.0': dependencies: - '@orpc/shared': 1.13.14 - '@orpc/standard-server': 1.13.14 + '@orpc/shared': 1.14.0 + '@orpc/standard-server': 1.14.0 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/standard-server@1.13.14': + '@orpc/standard-server@1.14.0': dependencies: - '@orpc/shared': 1.13.14 + '@orpc/shared': 1.14.0 transitivePeerDependencies: - '@opentelemetry/api' - '@orpc/tanstack-query@1.13.14(@orpc/client@1.13.14)(@tanstack/query-core@5.99.2)': + '@orpc/tanstack-query@1.14.0(@orpc/client@1.14.0)(@tanstack/query-core@5.100.5)': dependencies: - '@orpc/client': 1.13.14 - '@orpc/shared': 1.13.14 - '@tanstack/query-core': 5.99.2 + '@orpc/client': 1.14.0 + '@orpc/shared': 1.14.0 + '@tanstack/query-core': 5.100.5 transitivePeerDependencies: - '@opentelemetry/api' '@ota-meshi/ast-token-store@0.3.0': {} - '@oxc-parser/binding-android-arm-eabi@0.126.0': + '@oxc-parser/binding-android-arm-eabi@0.127.0': optional: true - '@oxc-parser/binding-android-arm64@0.126.0': + '@oxc-parser/binding-android-arm64@0.127.0': optional: true - '@oxc-parser/binding-darwin-arm64@0.126.0': + '@oxc-parser/binding-darwin-arm64@0.127.0': optional: true - '@oxc-parser/binding-darwin-x64@0.126.0': + '@oxc-parser/binding-darwin-x64@0.127.0': optional: true - '@oxc-parser/binding-freebsd-x64@0.126.0': + '@oxc-parser/binding-freebsd-x64@0.127.0': optional: true - '@oxc-parser/binding-linux-arm-gnueabihf@0.126.0': + '@oxc-parser/binding-linux-arm-gnueabihf@0.127.0': optional: true - '@oxc-parser/binding-linux-arm-musleabihf@0.126.0': + '@oxc-parser/binding-linux-arm-musleabihf@0.127.0': optional: true - '@oxc-parser/binding-linux-arm64-gnu@0.126.0': + '@oxc-parser/binding-linux-arm64-gnu@0.127.0': optional: true - '@oxc-parser/binding-linux-arm64-musl@0.126.0': + '@oxc-parser/binding-linux-arm64-musl@0.127.0': optional: true - '@oxc-parser/binding-linux-ppc64-gnu@0.126.0': + '@oxc-parser/binding-linux-ppc64-gnu@0.127.0': optional: true - '@oxc-parser/binding-linux-riscv64-gnu@0.126.0': + '@oxc-parser/binding-linux-riscv64-gnu@0.127.0': optional: true - '@oxc-parser/binding-linux-riscv64-musl@0.126.0': + '@oxc-parser/binding-linux-riscv64-musl@0.127.0': optional: true - '@oxc-parser/binding-linux-s390x-gnu@0.126.0': + '@oxc-parser/binding-linux-s390x-gnu@0.127.0': optional: true - '@oxc-parser/binding-linux-x64-gnu@0.126.0': + '@oxc-parser/binding-linux-x64-gnu@0.127.0': optional: true - '@oxc-parser/binding-linux-x64-musl@0.126.0': + '@oxc-parser/binding-linux-x64-musl@0.127.0': optional: true - '@oxc-parser/binding-openharmony-arm64@0.126.0': + '@oxc-parser/binding-openharmony-arm64@0.127.0': optional: true - '@oxc-parser/binding-wasm32-wasi@0.126.0': + '@oxc-parser/binding-wasm32-wasi@0.127.0': dependencies: '@emnapi/core': 1.9.2 '@emnapi/runtime': 1.9.2 '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) optional: true - '@oxc-parser/binding-win32-arm64-msvc@0.126.0': + '@oxc-parser/binding-win32-arm64-msvc@0.127.0': optional: true - '@oxc-parser/binding-win32-ia32-msvc@0.126.0': + '@oxc-parser/binding-win32-ia32-msvc@0.127.0': optional: true - '@oxc-parser/binding-win32-x64-msvc@0.126.0': + '@oxc-parser/binding-win32-x64-msvc@0.127.0': optional: true '@oxc-project/runtime@0.126.0': {} '@oxc-project/types@0.126.0': {} + '@oxc-project/types@0.127.0': {} + '@oxc-resolver/binding-android-arm-eabi@11.19.1': optional: true @@ -10027,7 +10027,7 @@ snapshots: '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: - '@napi-rs/wasm-runtime': 1.1.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -10478,7 +10478,7 @@ snapshots: '@rgrove/parse-xml@4.2.0': {} - '@rolldown/pluginutils@1.0.0-rc.15': {} + '@rolldown/pluginutils@1.0.0-rc.17': {} '@rolldown/pluginutils@1.0.0-rc.7': {} @@ -10493,38 +10493,38 @@ snapshots: estree-walker: 2.0.2 picomatch: 4.0.4 - '@sentry-internal/browser-utils@10.49.0': + '@sentry-internal/browser-utils@10.50.0': dependencies: - '@sentry/core': 10.49.0 + '@sentry/core': 10.50.0 - '@sentry-internal/feedback@10.49.0': + '@sentry-internal/feedback@10.50.0': dependencies: - '@sentry/core': 10.49.0 + '@sentry/core': 10.50.0 - '@sentry-internal/replay-canvas@10.49.0': + '@sentry-internal/replay-canvas@10.50.0': dependencies: - '@sentry-internal/replay': 10.49.0 - '@sentry/core': 10.49.0 + '@sentry-internal/replay': 10.50.0 + '@sentry/core': 10.50.0 - '@sentry-internal/replay@10.49.0': + '@sentry-internal/replay@10.50.0': dependencies: - '@sentry-internal/browser-utils': 10.49.0 - '@sentry/core': 10.49.0 + '@sentry-internal/browser-utils': 10.50.0 + '@sentry/core': 10.50.0 - '@sentry/browser@10.49.0': + '@sentry/browser@10.50.0': dependencies: - '@sentry-internal/browser-utils': 10.49.0 - '@sentry-internal/feedback': 10.49.0 - '@sentry-internal/replay': 10.49.0 - '@sentry-internal/replay-canvas': 10.49.0 - '@sentry/core': 10.49.0 + '@sentry-internal/browser-utils': 10.50.0 + '@sentry-internal/feedback': 10.50.0 + '@sentry-internal/replay': 10.50.0 + '@sentry-internal/replay-canvas': 10.50.0 + '@sentry/core': 10.50.0 - '@sentry/core@10.49.0': {} + '@sentry/core@10.50.0': {} - '@sentry/react@10.49.0(react@19.2.5)': + '@sentry/react@10.50.0(react@19.2.5)': dependencies: - '@sentry/browser': 10.49.0 - '@sentry/core': 10.49.0 + '@sentry/browser': 10.50.0 + '@sentry/core': 10.50.0 react: 19.2.5 '@shikijs/core@4.0.2': @@ -10846,7 +10846,7 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.2.4 '@tailwindcss/oxide': 4.2.4 - postcss: 8.5.10 + postcss: 8.5.12 tailwindcss: 4.2.4 '@tailwindcss/typography@0.5.19(tailwindcss@4.2.4)': @@ -10905,9 +10905,9 @@ snapshots: - csstype - utf-8-validate - '@tanstack/eslint-plugin-query@5.99.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)': + '@tanstack/eslint-plugin-query@5.100.5(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3)': dependencies: - '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) eslint: 10.2.1(jiti@2.6.1) optionalDependencies: typescript: 6.0.3 @@ -10938,9 +10938,9 @@ snapshots: '@tanstack/pacer-lite@0.1.1': {} - '@tanstack/query-core@5.99.2': {} + '@tanstack/query-core@5.100.5': {} - '@tanstack/query-devtools@5.99.2': {} + '@tanstack/query-devtools@5.100.5': {} '@tanstack/react-devtools@0.10.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': dependencies: @@ -10974,15 +10974,15 @@ snapshots: transitivePeerDependencies: - react-dom - '@tanstack/react-query-devtools@5.99.2(@tanstack/react-query@5.99.2(react@19.2.5))(react@19.2.5)': + '@tanstack/react-query-devtools@5.100.5(@tanstack/react-query@5.100.5(react@19.2.5))(react@19.2.5)': dependencies: - '@tanstack/query-devtools': 5.99.2 - '@tanstack/react-query': 5.99.2(react@19.2.5) + '@tanstack/query-devtools': 5.100.5 + '@tanstack/react-query': 5.100.5(react@19.2.5) react: 19.2.5 - '@tanstack/react-query@5.99.2(react@19.2.5)': + '@tanstack/react-query@5.100.5(react@19.2.5)': dependencies: - '@tanstack/query-core': 5.99.2 + '@tanstack/query-core': 5.100.5 react: 19.2.5 '@tanstack/react-store@0.9.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': @@ -11509,36 +11509,36 @@ snapshots: '@typescript-eslint/types': 8.59.0 eslint-visitor-keys: 5.0.1 - '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260422.1': + '@typescript/native-preview-darwin-arm64@7.0.0-dev.20260426.1': optional: true - '@typescript/native-preview-darwin-x64@7.0.0-dev.20260422.1': + '@typescript/native-preview-darwin-x64@7.0.0-dev.20260426.1': optional: true - '@typescript/native-preview-linux-arm64@7.0.0-dev.20260422.1': + '@typescript/native-preview-linux-arm64@7.0.0-dev.20260426.1': optional: true - '@typescript/native-preview-linux-arm@7.0.0-dev.20260422.1': + '@typescript/native-preview-linux-arm@7.0.0-dev.20260426.1': optional: true - '@typescript/native-preview-linux-x64@7.0.0-dev.20260422.1': + '@typescript/native-preview-linux-x64@7.0.0-dev.20260426.1': optional: true - '@typescript/native-preview-win32-arm64@7.0.0-dev.20260422.1': + '@typescript/native-preview-win32-arm64@7.0.0-dev.20260426.1': optional: true - '@typescript/native-preview-win32-x64@7.0.0-dev.20260422.1': + '@typescript/native-preview-win32-x64@7.0.0-dev.20260426.1': optional: true - '@typescript/native-preview@7.0.0-dev.20260422.1': + '@typescript/native-preview@7.0.0-dev.20260426.1': optionalDependencies: - '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260422.1 - '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260422.1 - '@typescript/native-preview-linux-arm': 7.0.0-dev.20260422.1 - '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260422.1 - '@typescript/native-preview-linux-x64': 7.0.0-dev.20260422.1 - '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260422.1 - '@typescript/native-preview-win32-x64': 7.0.0-dev.20260422.1 + '@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260426.1 + '@typescript/native-preview-darwin-x64': 7.0.0-dev.20260426.1 + '@typescript/native-preview-linux-arm': 7.0.0-dev.20260426.1 + '@typescript/native-preview-linux-arm64': 7.0.0-dev.20260426.1 + '@typescript/native-preview-linux-x64': 7.0.0-dev.20260426.1 + '@typescript/native-preview-win32-arm64': 7.0.0-dev.20260426.1 + '@typescript/native-preview-win32-x64': 7.0.0-dev.20260426.1 '@ungap/structured-clone@1.3.0': {} @@ -11595,9 +11595,9 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: '@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' - '@vitejs/plugin-rsc@0.5.24(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': + '@vitejs/plugin-rsc@0.5.25(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)': dependencies: - '@rolldown/pluginutils': 1.0.0-rc.15 + '@rolldown/pluginutils': 1.0.0-rc.17 es-module-lexer: 2.0.0 estree-walker: 3.0.3 magic-string: 0.30.21 @@ -11656,7 +11656,7 @@ snapshots: '@vitest/eslint-plugin@1.6.15(@types/node@25.6.0)(@typescript-eslint/eslint-plugin@8.58.2(@typescript-eslint/parser@8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3))(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(eslint@10.2.1(jiti@2.6.1))(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)': dependencies: '@typescript-eslint/scope-manager': 8.59.0 - '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) eslint: 10.2.1(jiti@2.6.1) vitest: '@voidzero-dev/vite-plus-test@0.1.19(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5(@types/node@25.6.0)(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(esbuild@0.27.2)(happy-dom@20.9.0)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)' optionalDependencies: @@ -11837,7 +11837,7 @@ snapshots: '@xstate/fsm@1.6.5': {} - abcjs@6.6.2: {} + abcjs@6.6.3: {} acorn-jsx@5.3.2(acorn@8.16.0): dependencies: @@ -12111,7 +12111,7 @@ snapshots: cli-table3@0.6.5: dependencies: - string-width: 8.2.0 + string-width: 8.2.1 optionalDependencies: '@colors/colors': 1.5.0 @@ -12796,7 +12796,7 @@ snapshots: micromark-factory-space: 2.0.1 micromark-util-character: 2.1.1 micromark-util-symbol: 2.0.1 - string-width: 8.2.0 + string-width: 8.2.1 transitivePeerDependencies: - supports-color @@ -12806,7 +12806,7 @@ snapshots: enhanced-resolve: 5.20.1 eslint: 10.2.1(jiti@2.6.1) eslint-plugin-es-x: 7.8.0(eslint@10.2.1(jiti@2.6.1)) - get-tsconfig: 4.13.7 + get-tsconfig: 4.14.0 globals: 15.15.0 globrex: 0.1.2 ignore: 5.3.2 @@ -12827,7 +12827,7 @@ snapshots: eslint-plugin-perfectionist@5.8.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3): dependencies: - '@typescript-eslint/utils': 8.58.2(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) + '@typescript-eslint/utils': 8.59.0(eslint@10.2.1(jiti@2.6.1))(typescript@6.0.3) eslint: 10.2.1(jiti@2.6.1) natural-orderby: 5.0.0 transitivePeerDependencies: @@ -13343,7 +13343,7 @@ snapshots: glob@13.0.6: dependencies: - minimatch: 10.2.4 + minimatch: 10.2.5 minipass: 7.1.3 path-scurry: 2.0.2 @@ -13534,7 +13534,7 @@ snapshots: hex-rgb@4.3.0: {} - hono@4.12.14: {} + hono@4.12.15: {} hosted-git-info@9.0.2: dependencies: @@ -13753,20 +13753,20 @@ snapshots: khroma@2.1.0: {} - knip@6.6.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): + knip@6.7.0(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): dependencies: fdir: 6.5.0(picomatch@4.0.4) formatly: 0.3.0 get-tsconfig: 4.14.0 jiti: 2.6.1 minimist: 1.2.8 - oxc-parser: 0.126.0 + oxc-parser: 0.127.0 oxc-resolver: 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) picomatch: 4.0.4 smol-toml: 1.6.1 strip-json-comments: 5.0.3 tinyglobby: 0.2.16 - unbash: 2.2.0 + unbash: 3.0.0 yaml: 2.8.3 zod: 4.3.6 transitivePeerDependencies: @@ -13894,7 +13894,7 @@ snapshots: dependencies: js-tokens: 4.0.0 - loro-crdt@1.11.1: {} + loro-crdt@1.12.0: {} loupe@3.2.1: {} @@ -14652,30 +14652,30 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 - oxc-parser@0.126.0: + oxc-parser@0.127.0: dependencies: - '@oxc-project/types': 0.126.0 + '@oxc-project/types': 0.127.0 optionalDependencies: - '@oxc-parser/binding-android-arm-eabi': 0.126.0 - '@oxc-parser/binding-android-arm64': 0.126.0 - '@oxc-parser/binding-darwin-arm64': 0.126.0 - '@oxc-parser/binding-darwin-x64': 0.126.0 - '@oxc-parser/binding-freebsd-x64': 0.126.0 - '@oxc-parser/binding-linux-arm-gnueabihf': 0.126.0 - '@oxc-parser/binding-linux-arm-musleabihf': 0.126.0 - '@oxc-parser/binding-linux-arm64-gnu': 0.126.0 - '@oxc-parser/binding-linux-arm64-musl': 0.126.0 - '@oxc-parser/binding-linux-ppc64-gnu': 0.126.0 - '@oxc-parser/binding-linux-riscv64-gnu': 0.126.0 - '@oxc-parser/binding-linux-riscv64-musl': 0.126.0 - '@oxc-parser/binding-linux-s390x-gnu': 0.126.0 - '@oxc-parser/binding-linux-x64-gnu': 0.126.0 - '@oxc-parser/binding-linux-x64-musl': 0.126.0 - '@oxc-parser/binding-openharmony-arm64': 0.126.0 - '@oxc-parser/binding-wasm32-wasi': 0.126.0 - '@oxc-parser/binding-win32-arm64-msvc': 0.126.0 - '@oxc-parser/binding-win32-ia32-msvc': 0.126.0 - '@oxc-parser/binding-win32-x64-msvc': 0.126.0 + '@oxc-parser/binding-android-arm-eabi': 0.127.0 + '@oxc-parser/binding-android-arm64': 0.127.0 + '@oxc-parser/binding-darwin-arm64': 0.127.0 + '@oxc-parser/binding-darwin-x64': 0.127.0 + '@oxc-parser/binding-freebsd-x64': 0.127.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.127.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.127.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.127.0 + '@oxc-parser/binding-linux-arm64-musl': 0.127.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.127.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.127.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.127.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.127.0 + '@oxc-parser/binding-linux-x64-gnu': 0.127.0 + '@oxc-parser/binding-linux-x64-musl': 0.127.0 + '@oxc-parser/binding-openharmony-arm64': 0.127.0 + '@oxc-parser/binding-wasm32-wasi': 0.127.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.127.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.127.0 + '@oxc-parser/binding-win32-x64-msvc': 0.127.0 oxc-resolver@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): optionalDependencies: @@ -14934,7 +14934,7 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.10: + postcss@8.5.12: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -15643,7 +15643,7 @@ snapshots: string-ts@2.3.1: {} - string-width@8.2.0: + string-width@8.2.1: dependencies: get-east-asian-width: 1.5.0 strip-ansi: 7.2.0 @@ -15900,7 +15900,7 @@ snapshots: uglify-js@3.19.3: {} - unbash@2.2.0: {} + unbash@3.0.0: {} undici-types@7.19.2: {} @@ -16067,7 +16067,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vinext@0.0.41(@mdx-js/rollup@3.1.1)(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.24(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@6.0.3): + vinext@0.0.41(@mdx-js/rollup@3.1.1)(@vitejs/plugin-react@6.0.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3)))(@vitejs/plugin-rsc@0.5.25(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5))(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)(typescript@6.0.3): dependencies: '@unpic/react': 1.0.2(next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) '@vercel/og': 0.8.6 @@ -16080,7 +16080,7 @@ snapshots: vite-tsconfig-paths: 6.1.1(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(typescript@6.0.3) optionalDependencies: '@mdx-js/rollup': 3.1.1 - '@vitejs/plugin-rsc': 0.5.24(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) + '@vitejs/plugin-rsc': 0.5.25(@voidzero-dev/vite-plus-core@0.1.19(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(tsx@4.21.0)(typescript@6.0.3)(yaml@2.8.3))(react-dom@19.2.5(react@19.2.5))(react-server-dom-webpack@19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5) react-server-dom-webpack: 19.2.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) transitivePeerDependencies: - next diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0d78fed290..3b994ee27a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -47,18 +47,18 @@ overrides: yaml@>=2.0.0 <2.8.3: 2.8.3 yauzl@<3.2.1: 3.2.1 catalog: - '@amplitude/analytics-browser': 2.41.0 - '@amplitude/plugin-session-replay-browser': 1.27.10 + '@amplitude/analytics-browser': 2.41.1 + '@amplitude/plugin-session-replay-browser': 1.28.0 '@antfu/eslint-config': 8.2.0 '@base-ui/react': 1.4.1 '@chromatic-com/storybook': 5.1.2 - '@cucumber/cucumber': 12.8.1 + '@cucumber/cucumber': 12.8.2 '@egoist/tailwindcss-icons': 1.9.2 '@emoji-mart/data': 1.2.1 '@eslint-react/eslint-plugin': 3.0.0 '@eslint/js': 10.0.1 '@floating-ui/react': 0.27.19 - '@formatjs/intl-localematcher': 0.8.3 + '@formatjs/intl-localematcher': 0.8.4 '@headlessui/react': 2.2.10 '@heroicons/react': 2.2.0 '@hono/node-server': 1.19.14 @@ -77,14 +77,14 @@ catalog: '@monaco-editor/react': 4.7.0 '@next/eslint-plugin-next': 16.2.4 '@next/mdx': 16.2.4 - '@orpc/client': 1.13.14 - '@orpc/contract': 1.13.14 - '@orpc/openapi-client': 1.13.14 - '@orpc/tanstack-query': 1.13.14 + '@orpc/client': 1.14.0 + '@orpc/contract': 1.14.0 + '@orpc/openapi-client': 1.14.0 + '@orpc/tanstack-query': 1.14.0 '@playwright/test': 1.59.1 '@remixicon/react': 4.9.0 '@rgrove/parse-xml': 4.2.0 - '@sentry/react': 10.49.0 + '@sentry/react': 10.50.0 '@storybook/addon-docs': 10.3.5 '@storybook/addon-links': 10.3.5 '@storybook/addon-onboarding': 10.3.5 @@ -98,12 +98,12 @@ catalog: '@tailwindcss/postcss': 4.2.4 '@tailwindcss/typography': 0.5.19 '@tailwindcss/vite': 4.2.4 - '@tanstack/eslint-plugin-query': 5.99.2 + '@tanstack/eslint-plugin-query': 5.100.5 '@tanstack/react-devtools': 0.10.2 '@tanstack/react-form': 1.29.1 '@tanstack/react-form-devtools': 0.2.22 - '@tanstack/react-query': 5.99.2 - '@tanstack/react-query-devtools': 5.99.2 + '@tanstack/react-query': 5.100.5 + '@tanstack/react-query-devtools': 5.100.5 '@tanstack/react-virtual': 3.13.24 '@testing-library/dom': 10.4.1 '@testing-library/jest-dom': 6.9.1 @@ -122,11 +122,11 @@ catalog: '@types/sortablejs': 1.15.9 '@typescript-eslint/eslint-plugin': 8.59.0 '@typescript-eslint/parser': 8.59.0 - '@typescript/native-preview': 7.0.0-dev.20260422.1 + '@typescript/native-preview': 7.0.0-dev.20260426.1 '@vitejs/plugin-react': 6.0.1 - '@vitejs/plugin-rsc': 0.5.24 + '@vitejs/plugin-rsc': 0.5.25 '@vitest/coverage-v8': 4.1.5 - abcjs: 6.6.2 + abcjs: 6.6.3 agentation: 3.0.2 ahooks: 3.9.7 class-variance-authority: 0.7.1 @@ -158,7 +158,7 @@ catalog: fast-deep-equal: 3.1.3 happy-dom: 20.9.0 hast-util-to-jsx-runtime: 2.3.6 - hono: 4.12.14 + hono: 4.12.15 html-entities: 2.6.0 html-to-image: 1.11.13 i18next: 26.0.6 @@ -171,11 +171,11 @@ catalog: js-yaml: 4.1.1 jsonschema: 1.5.0 katex: 0.16.45 - knip: 6.6.1 + knip: 6.7.0 ky: 2.0.2 lamejs: 1.2.1 lexical: 0.43.0 - loro-crdt: 1.11.1 + loro-crdt: 1.12.0 mermaid: 11.14.0 mime: 4.1.0 mitt: 3.0.1 @@ -185,7 +185,7 @@ catalog: nuqs: 2.8.9 pinyin-pro: 3.28.1 playwright: 1.59.1 - postcss: 8.5.10 + postcss: 8.5.12 qrcode.react: 4.2.0 qs: 6.15.1 react: 19.2.5 diff --git a/web/app/components/app/configuration/debug/types.ts b/web/app/components/app/configuration/debug/types.ts index ada665a7d2..d4c54eba49 100644 --- a/web/app/components/app/configuration/debug/types.ts +++ b/web/app/components/app/configuration/debug/types.ts @@ -5,7 +5,7 @@ export type ModelAndParameter = { parameters: Record } -export type MultipleAndConfigs = { +type MultipleAndConfigs = { multiple: boolean configs: ModelAndParameter[] } diff --git a/web/app/components/base/chat/chat/type.ts b/web/app/components/base/chat/chat/type.ts index 6ddb4f958e..87aaf1b4b6 100644 --- a/web/app/components/base/chat/chat/type.ts +++ b/web/app/components/base/chat/chat/type.ts @@ -8,7 +8,7 @@ import type { HumanInputFormData, } from '@/types/workflow' -export type MessageMore = { +type MessageMore = { time: string tokens: number latency: number | string diff --git a/web/app/components/base/features/store.ts b/web/app/components/base/features/store.ts index 7a4fe47663..e1f6ee413a 100644 --- a/web/app/components/base/features/store.ts +++ b/web/app/components/base/features/store.ts @@ -2,7 +2,7 @@ import type { Features } from './types' import { createStore } from 'zustand' import { Resolution, TransferMethod } from '@/types/app' -export type FeaturesModal = { +type FeaturesModal = { showFeaturesModal: boolean setShowFeaturesModal: (showFeaturesModal: boolean) => void } @@ -11,7 +11,7 @@ export type FeaturesState = { features: Features } -export type FeaturesAction = { +type FeaturesAction = { setFeatures: (features: Features) => void } diff --git a/web/app/components/base/features/types.ts b/web/app/components/base/features/types.ts index 4401a8276f..0940e8343f 100644 --- a/web/app/components/base/features/types.ts +++ b/web/app/components/base/features/types.ts @@ -6,11 +6,11 @@ import type { TtsAutoPlay, } from '@/types/app' -export type EnabledOrDisabled = { +type EnabledOrDisabled = { enabled?: boolean } -export type MoreLikeThis = EnabledOrDisabled +type MoreLikeThis = EnabledOrDisabled export type OpeningStatement = EnabledOrDisabled & { opening_statement?: string @@ -75,7 +75,7 @@ export type FileUpload = { } } & EnabledOrDisabled -export type AnnotationReplyConfig = { +type AnnotationReplyConfig = { enabled: boolean id?: string score_threshold?: number diff --git a/web/app/components/base/form/form-scenarios/base/types.ts b/web/app/components/base/form/form-scenarios/base/types.ts index 8eeebe2e30..5c778054b5 100644 --- a/web/app/components/base/form/form-scenarios/base/types.ts +++ b/web/app/components/base/form/form-scenarios/base/types.ts @@ -33,7 +33,7 @@ export type SelectConfiguration = { } } -export type FileConfiguration = { +type FileConfiguration = { allowedFileTypes: string[] allowedFileExtensions: string[] allowedFileUploadMethods: TransferMethod[] diff --git a/web/app/components/base/form/form-scenarios/input-field/types.ts b/web/app/components/base/form/form-scenarios/input-field/types.ts index 5ffbacb721..e8832db4ba 100644 --- a/web/app/components/base/form/form-scenarios/input-field/types.ts +++ b/web/app/components/base/form/form-scenarios/input-field/types.ts @@ -13,11 +13,11 @@ export enum InputFieldType { fileTypes = 'fileTypes', } -export type InputTypeSelectConfiguration = { +type InputTypeSelectConfiguration = { supportFile: boolean } -export type NumberSliderConfiguration = { +type NumberSliderConfiguration = { description: string max?: number min?: number diff --git a/web/app/components/base/form/form-scenarios/node-panel/types.ts b/web/app/components/base/form/form-scenarios/node-panel/types.ts index 327ee9b159..0e1e0e82ab 100644 --- a/web/app/components/base/form/form-scenarios/node-panel/types.ts +++ b/web/app/components/base/form/form-scenarios/node-panel/types.ts @@ -14,11 +14,11 @@ export enum InputFieldType { variableOrConstant = 'variableOrConstant', } -export type InputTypeSelectConfiguration = { +type InputTypeSelectConfiguration = { supportFile: boolean } -export type NumberSliderConfiguration = { +type NumberSliderConfiguration = { description: string max?: number min?: number diff --git a/web/app/components/base/form/types.ts b/web/app/components/base/form/types.ts index 4b83b9e4c9..f0fd929250 100644 --- a/web/app/components/base/form/types.ts +++ b/web/app/components/base/form/types.ts @@ -14,7 +14,7 @@ export type TypeWithI18N = { [key: string]: T } -export type FormShowOnObject = { +type FormShowOnObject = { variable: string value: string } @@ -43,7 +43,7 @@ export type FormOption = { icon?: string } -export type AnyValidators = FieldValidators +type AnyValidators = FieldValidators export enum FormItemValidateStatusEnum { Success = 'success', diff --git a/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts b/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts index 4e721b214e..6dbde998d4 100644 --- a/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts +++ b/web/app/components/base/markdown-with-directive/components/markdown-with-directive-schema.ts @@ -3,11 +3,11 @@ import * as z from 'zod' const commonSchema = { className: z.string().min(1).optional(), } -export const withIconCardListPropsSchema = z.object(commonSchema).strict() +const withIconCardListPropsSchema = z.object(commonSchema).strict() const HTTP_URL_REGEX = /^https?:\/\//i -export const withIconCardItemPropsSchema = z.object({ +const withIconCardItemPropsSchema = z.object({ ...commonSchema, icon: z.string().trim().url().refine( value => HTTP_URL_REGEX.test(value), diff --git a/web/app/components/base/text-generation/types.ts b/web/app/components/base/text-generation/types.ts index 62a401c3cb..86be8a0a25 100644 --- a/web/app/components/base/text-generation/types.ts +++ b/web/app/components/base/text-generation/types.ts @@ -4,7 +4,6 @@ import type { VisionFile, } from '@/types/app' -export type { VisionFile } from '@/types/app' export { TransferMethod } from '@/types/app' export type TextGenerationConfig = Omit & { diff --git a/web/app/components/base/textarea/index.tsx b/web/app/components/base/textarea/index.tsx index f5a8dd87f2..7ee66368f6 100644 --- a/web/app/components/base/textarea/index.tsx +++ b/web/app/components/base/textarea/index.tsx @@ -58,4 +58,3 @@ const Textarea = React.forwardRef( Textarea.displayName = 'Textarea' export default Textarea -export { textareaVariants } diff --git a/web/app/components/datasets/documents/detail/completed/segment-list-context.ts b/web/app/components/datasets/documents/detail/completed/segment-list-context.ts index 3ce9f8b987..b81a305614 100644 --- a/web/app/components/datasets/documents/detail/completed/segment-list-context.ts +++ b/web/app/components/datasets/documents/detail/completed/segment-list-context.ts @@ -2,13 +2,13 @@ import type { ChildChunkDetail, SegmentDetailModel } from '@/models/datasets' import { noop } from 'es-toolkit/function' import { createContext, useContextSelector } from 'use-context-selector' -export type CurrSegmentType = { +type CurrSegmentType = { segInfo?: SegmentDetailModel showModal: boolean isEditMode?: boolean } -export type CurrChildChunkType = { +type CurrChildChunkType = { childChunkInfo?: ChildChunkDetail showModal: boolean } diff --git a/web/app/components/datasets/documents/hooks/use-document-list-query-state.ts b/web/app/components/datasets/documents/hooks/use-document-list-query-state.ts index 60717d532c..d06ffe767c 100644 --- a/web/app/components/datasets/documents/hooks/use-document-list-query-state.ts +++ b/web/app/components/datasets/documents/hooks/use-document-list-query-state.ts @@ -49,7 +49,7 @@ const parseAsDocSort = createParser({ const parseAsKeyword = parseAsString.withDefault('') -export const documentListParsers = { +const documentListParsers = { page: parseAsPage, limit: parseAsLimit, keyword: parseAsKeyword, diff --git a/web/app/components/goto-anything/actions/types.ts b/web/app/components/goto-anything/actions/types.ts index 7d1ddfd4e1..da3ca8eb62 100644 --- a/web/app/components/goto-anything/actions/types.ts +++ b/web/app/components/goto-anything/actions/types.ts @@ -5,9 +5,9 @@ import type { CommonNodeType } from '../../workflow/types' import type { DataSet } from '@/models/datasets' import type { App } from '@/types/app' -export type SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node' | 'command' | 'recent' +type SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node' | 'command' | 'recent' -export type BaseSearchResult = { +type BaseSearchResult = { id: string title: string description?: string @@ -29,7 +29,7 @@ export type KnowledgeSearchResult = { type: 'knowledge' } & BaseSearchResult -export type WorkflowNodeSearchResult = { +type WorkflowNodeSearchResult = { type: 'workflow-node' metadata?: { nodeId: string diff --git a/web/app/components/header/account-setting/model-provider-page/declarations.ts b/web/app/components/header/account-setting/model-provider-page/declarations.ts index cc700fe5c6..fc18f019a0 100644 --- a/web/app/components/header/account-setting/model-provider-page/declarations.ts +++ b/web/app/components/header/account-setting/model-provider-page/declarations.ts @@ -92,7 +92,7 @@ export enum CustomConfigurationStatusEnum { noConfigure = 'no-configure', } -export type FormShowOnObject = { +type FormShowOnObject = { variable: string value: string } @@ -155,7 +155,7 @@ export enum QuotaUnitEnum { times = 'times', } -export type QuotaConfiguration = { +type QuotaConfiguration = { quota_type: CurrentSystemQuotaTypeEnum quota_unit: QuotaUnitEnum quota_limit: number diff --git a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.helpers.ts b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.helpers.ts index 6316441f1c..a07d3de500 100644 --- a/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.helpers.ts +++ b/web/app/components/plugins/plugin-detail-panel/tool-selector/components/reasoning-config-form.helpers.ts @@ -7,13 +7,13 @@ import { FormTypeEnum } from '@/app/components/header/account-setting/model-prov import { VarType as VarKindType } from '@/app/components/workflow/nodes/tool/types' import { VarType } from '@/app/components/workflow/types' -export type ReasoningConfigInputValue = { +type ReasoningConfigInputValue = { type?: VarKindType value?: unknown [key: string]: unknown } | null -export type ReasoningConfigInput = { +type ReasoningConfigInput = { value: ReasoningConfigInputValue auto?: 0 | 1 } diff --git a/web/app/components/plugins/types.ts b/web/app/components/plugins/types.ts index bcffad06e0..0f03e17a05 100644 --- a/web/app/components/plugins/types.ts +++ b/web/app/components/plugins/types.ts @@ -22,7 +22,7 @@ export enum PluginSource { debugging = 'remote', } -export type PluginToolDeclaration = { +type PluginToolDeclaration = { identity: { author: string name: string @@ -34,12 +34,12 @@ export type PluginToolDeclaration = { credentials_schema: ToolCredential[] // TODO } -export type PluginEndpointDeclaration = { +type PluginEndpointDeclaration = { settings: ToolCredential[] endpoints: EndpointItem[] } -export type EndpointItem = { +type EndpointItem = { path: string method: string hidden?: boolean @@ -60,7 +60,7 @@ export type EndpointListItem = { hook_id: string } -export type PluginDeclarationMeta = { +type PluginDeclarationMeta = { version: string minimum_dify_version?: string } @@ -96,14 +96,14 @@ export type PluginTriggerSubscriptionConstructor = { parameters: ParametersSchema[] } -export type PluginTriggerDefinition = { +type PluginTriggerDefinition = { events: TriggerEvent[] identity: Identity subscription_constructor: PluginTriggerSubscriptionConstructor subscription_schema: ParametersSchema[] } -export type CredentialsSchema = { +type CredentialsSchema = { name: string label: Record description: Record @@ -117,7 +117,7 @@ export type CredentialsSchema = { placeholder: Record } -export type OauthSchema = { +type OauthSchema = { client_schema: CredentialsSchema[] credentials_schema: CredentialsSchema[] } @@ -352,7 +352,7 @@ export enum InstallStep { installFailed = 'failed', } -export type GitHubAsset = { +type GitHubAsset = { id: number name: string browser_download_url: string @@ -496,7 +496,7 @@ export type PackageDependency = { export type Dependency = GitHubItemAndMarketPlaceDependency | PackageDependency -export type Version = { +type Version = { plugin_org: string plugin_name: string version: string @@ -554,7 +554,7 @@ export type StrategyDetail = { features: AgentFeature[] } -export type Identity = { +type Identity = { author: string name: string label: Record @@ -564,7 +564,7 @@ export type Identity = { tags: string[] } -export type StrategyDeclaration = { +type StrategyDeclaration = { identity: Identity plugin_id: string strategies: StrategyDetail[] diff --git a/web/app/components/tools/types.ts b/web/app/components/tools/types.ts index c7fb9eec2a..1e799c7307 100644 --- a/web/app/components/tools/types.ts +++ b/web/app/components/tools/types.ts @@ -100,7 +100,7 @@ export type ToolParameter = { max?: number } -export type TriggerParameter = { +type TriggerParameter = { name: string label: LocalizedText human_description: LocalizedText @@ -165,7 +165,7 @@ export type CustomCollectionBackend = { labels: string[] } -export type ParamItem = { +type ParamItem = { name: string label: LocalizedText human_description: LocalizedText diff --git a/web/app/components/workflow-app/hooks/use-workflow-run-utils.ts b/web/app/components/workflow-app/hooks/use-workflow-run-utils.ts index fd5669f80a..764687fcbb 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run-utils.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run-utils.ts @@ -10,7 +10,7 @@ import { handleStream, post } from '@/service/base' import { ContentType } from '@/service/fetch' import { AppModeEnum } from '@/types/app' -export type HandleRunMode = TriggerType +type HandleRunMode = TriggerType export type HandleRunOptions = { mode?: HandleRunMode scheduleNodeId?: string diff --git a/web/app/components/workflow/block-selector/types.ts b/web/app/components/workflow/block-selector/types.ts index dc6551c45c..500ca60fdf 100644 --- a/web/app/components/workflow/block-selector/types.ts +++ b/web/app/components/workflow/block-selector/types.ts @@ -124,7 +124,7 @@ export type DataSourceItem = { is_authorized: boolean } -export type TriggerCredentialField = { +type TriggerCredentialField = { type: 'secret-input' | 'text-input' | 'select' | 'boolean' | 'app-selector' | 'model-selector' | 'tools-selector' name: string @@ -226,14 +226,14 @@ export type TriggerLogEntity = { created_at: string } -export type LogRequest = { +type LogRequest = { method: string url: string headers: LogRequestHeaders data: string } -export type LogRequestHeaders = { +type LogRequestHeaders = { 'Host': string 'User-Agent': string 'Content-Length': string @@ -251,13 +251,13 @@ export type LogRequestHeaders = { [key: string]: string } -export type LogResponse = { +type LogResponse = { status_code: number headers: LogResponseHeaders data: string } -export type LogResponseHeaders = { +type LogResponseHeaders = { 'Content-Type': string 'Content-Length': string [key: string]: string diff --git a/web/app/components/workflow/collaboration/types/collaboration.ts b/web/app/components/workflow/collaboration/types/collaboration.ts index ae355a7b51..3a5b71e2d1 100644 --- a/web/app/components/workflow/collaboration/types/collaboration.ts +++ b/web/app/components/workflow/collaboration/types/collaboration.ts @@ -22,7 +22,7 @@ export type NodePanelPresenceUser = { avatar?: string | null } -export type NodePanelPresenceInfo = NodePanelPresenceUser & { +type NodePanelPresenceInfo = NodePanelPresenceUser & { clientId: string timestamp: number } @@ -39,7 +39,7 @@ export type CollaborationState = { error?: string } -export type CollaborationEventType +type CollaborationEventType = | 'mouse_move' | 'vars_and_features_update' | 'sync_request' diff --git a/web/app/components/workflow/collaboration/types/websocket.ts b/web/app/components/workflow/collaboration/types/websocket.ts index dd89df323f..053c655939 100644 --- a/web/app/components/workflow/collaboration/types/websocket.ts +++ b/web/app/components/workflow/collaboration/types/websocket.ts @@ -4,7 +4,7 @@ export type WebSocketConfig = { withCredentials?: boolean } -export type ConnectionInfo = { +type ConnectionInfo = { connected: boolean connecting: boolean socketId?: string diff --git a/web/app/components/workflow/hooks-store/store.ts b/web/app/components/workflow/hooks-store/store.ts index a9d5003fb3..376bec635c 100644 --- a/web/app/components/workflow/hooks-store/store.ts +++ b/web/app/components/workflow/hooks-store/store.ts @@ -27,7 +27,7 @@ export type SyncDraftCallback = { onError?: () => void onSettled?: () => void } -export type CommonHooksFnMap = { +type CommonHooksFnMap = { doSyncWorkflowDraft: ( notRefreshWhenSyncError?: boolean, callback?: SyncDraftCallback, diff --git a/web/app/components/workflow/nodes/knowledge-base/types.ts b/web/app/components/workflow/nodes/knowledge-base/types.ts index dbcc926ee3..afe7370ba6 100644 --- a/web/app/components/workflow/nodes/knowledge-base/types.ts +++ b/web/app/components/workflow/nodes/knowledge-base/types.ts @@ -32,7 +32,7 @@ export type WeightedScore = { } } -export type RetrievalSetting = { +type RetrievalSetting = { search_method?: RETRIEVE_METHOD reranking_enable?: boolean reranking_model?: RerankingModel diff --git a/web/app/components/workflow/nodes/loop/types.ts b/web/app/components/workflow/nodes/loop/types.ts index 3e91506c47..066f0fcbe3 100644 --- a/web/app/components/workflow/nodes/loop/types.ts +++ b/web/app/components/workflow/nodes/loop/types.ts @@ -83,8 +83,8 @@ export type LoopNodeType = CommonNodeType & { loop_variables?: LoopVariable[] } -export type HandleUpdateLoopVariable = (id: string, updateData: Partial) => void -export type HandleRemoveLoopVariable = (id: string) => void +type HandleUpdateLoopVariable = (id: string, updateData: Partial) => void +type HandleRemoveLoopVariable = (id: string) => void export type LoopVariablesComponentShape = { nodeId: string diff --git a/web/app/components/workflow/nodes/trigger-schedule/types.ts b/web/app/components/workflow/nodes/trigger-schedule/types.ts index 3d82709199..9bcecdda82 100644 --- a/web/app/components/workflow/nodes/trigger-schedule/types.ts +++ b/web/app/components/workflow/nodes/trigger-schedule/types.ts @@ -4,7 +4,7 @@ export type ScheduleMode = 'visual' | 'cron' export type ScheduleFrequency = 'hourly' | 'daily' | 'weekly' | 'monthly' -export type VisualConfig = { +type VisualConfig = { time?: string weekdays?: string[] on_minute?: number diff --git a/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx b/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx index 438cf154ba..a14a642c40 100644 --- a/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx +++ b/web/app/components/workflow/nodes/trigger-webhook/components/generic-table.tsx @@ -16,9 +16,9 @@ const isPresent = (v: unknown): boolean => { return !(v === '' || v === null || v === undefined || v === false) } // Column configuration types for table components -export type ColumnType = 'input' | 'select' | 'switch' | 'custom' +type ColumnType = 'input' | 'select' | 'switch' | 'custom' -export type SelectOption = { +type SelectOption = { name: string value: string } diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index efd21e099c..fa1b26074e 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -498,7 +498,7 @@ export type ChildNodeTypeCount = { [key: string]: number } -export const TRIGGER_NODE_TYPES = [ +const TRIGGER_NODE_TYPES = [ BlockEnum.TriggerSchedule, BlockEnum.TriggerWebhook, BlockEnum.TriggerPlugin, diff --git a/web/app/components/workflow/workflow-history-store.tsx b/web/app/components/workflow/workflow-history-store.tsx index efab0dd067..97c9f2ac33 100644 --- a/web/app/components/workflow/workflow-history-store.tsx +++ b/web/app/components/workflow/workflow-history-store.tsx @@ -98,14 +98,14 @@ function createStore({ return store } -export type WorkflowHistoryStore = { +type WorkflowHistoryStore = { nodes: Node[] edges: Edge[] workflowHistoryEvent: WorkflowHistoryEventT | undefined workflowHistoryEventMeta?: WorkflowHistoryEventMeta } -export type WorkflowHistoryActions = { +type WorkflowHistoryActions = { setNodes?: (nodes: Node[]) => void setEdges?: (edges: Edge[]) => void } diff --git a/web/context/event-emitter.ts b/web/context/event-emitter.ts index 781bac1f61..30944cc0ff 100644 --- a/web/context/event-emitter.ts +++ b/web/context/event-emitter.ts @@ -7,7 +7,7 @@ import { createContext, useContext } from 'use-context-selector' * Typed event object emitted via the shared EventEmitter. * Covers workflow updates, prompt-editor commands, DSL export checks, etc. */ -export type EventEmitterMessage = { +type EventEmitterMessage = { type: string payload?: unknown instanceId?: string diff --git a/web/contract/console/workflow-comment.ts b/web/contract/console/workflow-comment.ts index 06defa31af..a4c55a46e0 100644 --- a/web/contract/console/workflow-comment.ts +++ b/web/contract/console/workflow-comment.ts @@ -27,7 +27,7 @@ export type WorkflowCommentList = { participants: UserProfile[] } -export type WorkflowCommentDetailMention = { +type WorkflowCommentDetailMention = { mentioned_user_id: string mentioned_user_account?: UserProfile | null reply_id: string | null diff --git a/web/models/app.ts b/web/models/app.ts index af8238fc55..d14dc1cd6c 100644 --- a/web/models/app.ts +++ b/web/models/app.ts @@ -75,7 +75,7 @@ export type AppTokenCostsResponse = { export type UpdateAppModelConfigResponse = { result: string } -export type ApiKeyItemResponse = { +type ApiKeyItemResponse = { id: string token: string last_used_at: string diff --git a/web/models/common.ts b/web/models/common.ts index 0e44b10e62..505db0e348 100644 --- a/web/models/common.ts +++ b/web/models/common.ts @@ -56,7 +56,7 @@ export type Member = Pick & { dataset_id: string } -export type DataSource = { +type DataSource = { type: DataSourceType info_list: { data_source_type: DataSourceType @@ -513,7 +510,7 @@ export type FullDocumentDetail = SimpleDocumentDetail & { [key: string]: any } -export type DocMetadata = { +type DocMetadata = { title: string language: string author: string @@ -534,16 +531,13 @@ export const CUSTOMIZABLE_DOC_TYPES = [ 'im_chat_log', ] as const -export const FIXED_DOC_TYPES = ['synced_from_github', 'synced_from_notion', 'wikipedia_entry'] as const - -export type CustomizableDocType = typeof CUSTOMIZABLE_DOC_TYPES[number] -export type FixedDocType = typeof FIXED_DOC_TYPES[number] +type CustomizableDocType = typeof CUSTOMIZABLE_DOC_TYPES[number] +type FixedDocType = 'synced_from_github' | 'synced_from_notion' | 'wikipedia_entry' export type DocType = CustomizableDocType | FixedDocType export type DocumentDetailResponse = FullDocumentDetail -export const SEGMENT_STATUS_LIST = ['waiting', 'completed', 'error', 'indexing'] -export type SegmentStatus = typeof SEGMENT_STATUS_LIST[number] +type SegmentStatus = 'waiting' | 'completed' | 'error' | 'indexing' export type Attachment = { id: string @@ -634,7 +628,7 @@ export type ExternalKnowledgeBaseHitTesting = { } } -export type Segment = { +type Segment = { id: string document: Document content: string @@ -648,7 +642,7 @@ export type Segment = { answer: string } -export type Document = { +type Document = { id: string data_source_type: string name: string @@ -663,7 +657,7 @@ export type HitTestingRecordsResponse = { page: number } -export type TsnePosition = { +type TsnePosition = { x: number y: number } @@ -750,7 +744,7 @@ export const DEFAULT_WEIGHTED_SCORE = { }, } -export type ChildChunkType = 'automatic' | 'customized' +type ChildChunkType = 'automatic' | 'customized' export type ChildChunkDetail = { id: string diff --git a/web/models/debug.ts b/web/models/debug.ts index 0714372d94..a0dc9831ee 100644 --- a/web/models/debug.ts +++ b/web/models/debug.ts @@ -114,7 +114,7 @@ export type ModerationConfig = MoreLikeThisConfig & { } & Partial> } -export type RetrieverResourceConfig = MoreLikeThisConfig +type RetrieverResourceConfig = MoreLikeThisConfig export type AgentConfig = { enabled: boolean strategy: AgentStrategy diff --git a/web/models/explore.ts b/web/models/explore.ts index bca92abee5..c05dd1eca1 100644 --- a/web/models/explore.ts +++ b/web/models/explore.ts @@ -1,6 +1,6 @@ import type { AppIconType, AppModeEnum } from '@/types/app' -export type AppBasicInfo = { +type AppBasicInfo = { id: string mode: AppModeEnum icon_type: AppIconType | null diff --git a/web/models/log.ts b/web/models/log.ts index f9cb13ab8e..e3828d4b78 100644 --- a/web/models/log.ts +++ b/web/models/log.ts @@ -6,7 +6,7 @@ import type { } from '@/app/components/workflow/types' import type { VisionFile } from '@/types/app' -export type CompletionParamsType = { +type CompletionParamsType = { max_tokens: number temperature: number top_p: number @@ -15,13 +15,13 @@ export type CompletionParamsType = { frequency_penalty: number } -export type LogModelConfig = { +type LogModelConfig = { name: string provider: string completion_params: CompletionParamsType } -export type ModelConfigDetail = { +type ModelConfigDetail = { introduction: string prompt_template: string prompt_variables: Array<{ @@ -53,7 +53,7 @@ export type Annotation = { created_at?: number } -export type MessageContent = { +type MessageContent = { id: string conversation_id: string query: string @@ -186,8 +186,7 @@ export type ChatMessagesResponse = { limit: number } -export const MessageRatings = ['like', 'dislike', null] as const -export type MessageRating = typeof MessageRatings[number] +export type MessageRating = 'like' | 'dislike' | null export type LogMessageFeedbacksRequest = { message_id: string @@ -229,7 +228,7 @@ export type TriggerMetadata = { icon_dark?: string | null } -export type WorkflowLogDetails = { +type WorkflowLogDetails = { trigger_metadata?: TriggerMetadata } @@ -246,12 +245,12 @@ export type WorkflowRunDetail = { total_steps: number finished_at: number } -export type AccountInfo = { +type AccountInfo = { id: string name: string email: string } -export type EndUserInfo = { +type EndUserInfo = { id: string type: 'browser' | 'service_api' is_anonymous: boolean @@ -303,7 +302,7 @@ export type WorkflowRunDetailResponse = { exceptions_count?: number } -export type AgentLogMeta = { +type AgentLogMeta = { status: string executor: string start_time: string @@ -338,7 +337,7 @@ export type AgentIteration = { } } -export type AgentLogFile = { +type AgentLogFile = { id: string type: string url: string @@ -357,7 +356,7 @@ export type AgentLogDetailResponse = { files: AgentLogFile[] } -export type PauseType = { +type PauseType = { type: 'human_input' form_id: string backstage_input_url: string @@ -365,7 +364,7 @@ export type PauseType = { type: 'breakpoint' } -export type PauseDetail = { +type PauseDetail = { node_id: string node_title: string pause_type: PauseType diff --git a/web/scripts/gen-doc-paths.ts b/web/scripts/gen-doc-paths.ts index fd9cdea02a..c972a33a08 100644 --- a/web/scripts/gen-doc-paths.ts +++ b/web/scripts/gen-doc-paths.ts @@ -275,7 +275,7 @@ function generateTypeDefinitions( typeNames.push(typeName) lines.push(`// ${sectionToTypeName(section)} paths`) - lines.push(`export type ${typeName} =`) + lines.push(`type ${typeName} =`) for (const p of paths) { lines.push(` | '/${p}'`) @@ -297,7 +297,7 @@ function generateTypeDefinitions( if (apiReferencePaths.length > 0) { const sortedPaths = [...apiReferencePaths].sort() lines.push('// API Reference paths (English, use apiReferencePathTranslations for other languages)') - lines.push('export type ApiReferencePath =') + lines.push('type ApiReferencePath =') for (const p of sortedPaths) { lines.push(` | '${p}'`) } @@ -307,7 +307,7 @@ function generateTypeDefinitions( // Generate base combined type lines.push('// Base path without language prefix') - lines.push('export type DocPathWithoutLangBase =') + lines.push('type DocPathWithoutLangBase =') for (const typeName of typeNames) { lines.push(` | ${typeName}`) } diff --git a/web/service/base.ts b/web/service/base.ts index d1ef06c314..ac7ab895a8 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -47,39 +47,39 @@ export type IOnDataMoreInfo = { } export type IOnData = (message: string, isFirstMessage: boolean, moreInfo: IOnDataMoreInfo) => void -export type IOnThought = (though: ThoughtItem) => void -export type IOnFile = (file: VisionFile) => void -export type IOnMessageEnd = (messageEnd: MessageEnd) => void +type IOnThought = (though: ThoughtItem) => void +type IOnFile = (file: VisionFile) => void +type IOnMessageEnd = (messageEnd: MessageEnd) => void export type IOnMessageReplace = (messageReplace: MessageReplace) => void export type IOnCompleted = (hasError?: boolean, errorMessage?: string) => void export type IOnError = (msg: string, code?: string) => void -export type IOnWorkflowStarted = (workflowStarted: WorkflowStartedResponse) => void -export type IOnWorkflowFinished = (workflowFinished: WorkflowFinishedResponse) => void -export type IOnNodeStarted = (nodeStarted: NodeStartedResponse) => void -export type IOnNodeFinished = (nodeFinished: NodeFinishedResponse) => void -export type IOnIterationStarted = (workflowStarted: IterationStartedResponse) => void -export type IOnIterationNext = (workflowStarted: IterationNextResponse) => void -export type IOnNodeRetry = (nodeFinished: NodeFinishedResponse) => void -export type IOnIterationFinished = (workflowFinished: IterationFinishedResponse) => void -export type IOnParallelBranchStarted = (parallelBranchStarted: ParallelBranchStartedResponse) => void -export type IOnParallelBranchFinished = (parallelBranchFinished: ParallelBranchFinishedResponse) => void -export type IOnTextChunk = (textChunk: TextChunkResponse) => void -export type IOnTTSChunk = (messageId: string, audioStr: string, audioType?: string) => void -export type IOnTTSEnd = (messageId: string, audioStr: string, audioType?: string) => void -export type IOnTextReplace = (textReplace: TextReplaceResponse) => void -export type IOnLoopStarted = (workflowStarted: LoopStartedResponse) => void -export type IOnLoopNext = (workflowStarted: LoopNextResponse) => void -export type IOnLoopFinished = (workflowFinished: LoopFinishedResponse) => void -export type IOnAgentLog = (agentLog: AgentLogResponse) => void +type IOnWorkflowStarted = (workflowStarted: WorkflowStartedResponse) => void +type IOnWorkflowFinished = (workflowFinished: WorkflowFinishedResponse) => void +type IOnNodeStarted = (nodeStarted: NodeStartedResponse) => void +type IOnNodeFinished = (nodeFinished: NodeFinishedResponse) => void +type IOnIterationStarted = (workflowStarted: IterationStartedResponse) => void +type IOnIterationNext = (workflowStarted: IterationNextResponse) => void +type IOnNodeRetry = (nodeFinished: NodeFinishedResponse) => void +type IOnIterationFinished = (workflowFinished: IterationFinishedResponse) => void +type IOnParallelBranchStarted = (parallelBranchStarted: ParallelBranchStartedResponse) => void +type IOnParallelBranchFinished = (parallelBranchFinished: ParallelBranchFinishedResponse) => void +type IOnTextChunk = (textChunk: TextChunkResponse) => void +type IOnTTSChunk = (messageId: string, audioStr: string, audioType?: string) => void +type IOnTTSEnd = (messageId: string, audioStr: string, audioType?: string) => void +type IOnTextReplace = (textReplace: TextReplaceResponse) => void +type IOnLoopStarted = (workflowStarted: LoopStartedResponse) => void +type IOnLoopNext = (workflowStarted: LoopNextResponse) => void +type IOnLoopFinished = (workflowFinished: LoopFinishedResponse) => void +type IOnAgentLog = (agentLog: AgentLogResponse) => void -export type IOHumanInputRequired = (humanInputRequired: HumanInputRequiredResponse) => void -export type IOnHumanInputFormFilled = (humanInputFormFilled: HumanInputFormFilledResponse) => void -export type IOnHumanInputFormTimeout = (humanInputFormTimeout: HumanInputFormTimeoutResponse) => void -export type IOWorkflowPaused = (workflowPaused: WorkflowPausedResponse) => void -export type IOnDataSourceNodeProcessing = (dataSourceNodeProcessing: DataSourceNodeProcessingResponse) => void -export type IOnDataSourceNodeCompleted = (dataSourceNodeCompleted: DataSourceNodeCompletedResponse) => void -export type IOnDataSourceNodeError = (dataSourceNodeError: DataSourceNodeErrorResponse) => void +type IOHumanInputRequired = (humanInputRequired: HumanInputRequiredResponse) => void +type IOnHumanInputFormFilled = (humanInputFormFilled: HumanInputFormFilledResponse) => void +type IOnHumanInputFormTimeout = (humanInputFormTimeout: HumanInputFormTimeoutResponse) => void +type IOWorkflowPaused = (workflowPaused: WorkflowPausedResponse) => void +type IOnDataSourceNodeProcessing = (dataSourceNodeProcessing: DataSourceNodeProcessingResponse) => void +type IOnDataSourceNodeCompleted = (dataSourceNodeCompleted: DataSourceNodeCompletedResponse) => void +type IOnDataSourceNodeError = (dataSourceNodeError: DataSourceNodeErrorResponse) => void export type IOtherOptions = { isPublicAPI?: boolean diff --git a/web/types/app.ts b/web/types/app.ts index bd10da42d3..ecd4630363 100644 --- a/web/types/app.ts +++ b/web/types/app.ts @@ -50,8 +50,7 @@ export const AppModes = [AppModeEnum.COMPLETION, AppModeEnum.WORKFLOW, AppModeEn /** * Variable type */ -export const VariableTypes = ['string', 'number', 'select'] as const -export type VariableType = typeof VariableTypes[number] +type VariableType = 'string' | 'number' | 'select' /** * Prompt variable parameter @@ -69,7 +68,7 @@ export type PromptVariable = { max_length?: number } -export type TextTypeFormItem = { +type TextTypeFormItem = { default: string label: string variable: string @@ -78,7 +77,7 @@ export type TextTypeFormItem = { hide: boolean } -export type SelectTypeFormItem = { +type SelectTypeFormItem = { default: string label: string variable: string diff --git a/web/types/doc-paths.ts b/web/types/doc-paths.ts index 3f030a2733..f97883d4d4 100644 --- a/web/types/doc-paths.ts +++ b/web/types/doc-paths.ts @@ -8,7 +8,7 @@ export type DocLanguage = 'en' | 'zh' | 'ja' // UseDify paths -export type UseDifyPath = +type UseDifyPath = | '/use-dify/build/additional-features' | '/use-dify/build/goto-anything' | '/use-dify/build/mcp' @@ -121,7 +121,7 @@ type ExtractNodesPath = T extends `/use-dify/nodes/${infer Path}` ? Path : ne export type UseDifyNodesPath = ExtractNodesPath // SelfHost paths -export type SelfHostPath = +type SelfHostPath = | '/self-host/advanced-deployments/local-source-code' | '/self-host/advanced-deployments/start-the-frontend-docker-container' | '/self-host/configuration/environments' @@ -136,7 +136,7 @@ export type SelfHostPath = | '/self-host/troubleshooting/weaviate-v4-migration' // DevelopPlugin paths -export type DevelopPluginPath = +type DevelopPluginPath = | '/develop-plugin/dev-guides-and-walkthroughs/agent-strategy-plugin' | '/develop-plugin/dev-guides-and-walkthroughs/cheatsheet' | '/develop-plugin/dev-guides-and-walkthroughs/creating-new-model-provider' @@ -178,7 +178,7 @@ export type DevelopPluginPath = | '/develop-plugin/publishing/standards/third-party-signature-verification' // API Reference paths (English, use apiReferencePathTranslations for other languages) -export type ApiReferencePath = +type ApiReferencePath = | '/api-reference/annotations/configure-annotation-reply' | '/api-reference/annotations/create-annotation' | '/api-reference/annotations/delete-annotation' @@ -261,7 +261,7 @@ export type ApiReferencePath = | '/api-reference/workflows/stop-workflow-task' // Base path without language prefix -export type DocPathWithoutLangBase = +type DocPathWithoutLangBase = | UseDifyPath | SelfHostPath | DevelopPluginPath diff --git a/web/types/pipeline.tsx b/web/types/pipeline.tsx index f101853c6c..9377868db7 100644 --- a/web/types/pipeline.tsx +++ b/web/types/pipeline.tsx @@ -6,7 +6,7 @@ export type DataSourceNodeProcessingResponse = { completed: number } -export type OnlineDriveFile = { +type OnlineDriveFile = { id: string name: string size: number diff --git a/web/types/workflow.ts b/web/types/workflow.ts index 9e7dfd7e7a..95d8e47fdb 100644 --- a/web/types/workflow.ts +++ b/web/types/workflow.ts @@ -453,7 +453,7 @@ export const VarInInspectType = { } as const export type VarInInspectType = typeof VarInInspectType[keyof typeof VarInInspectType] -export type FullContent = { +type FullContent = { size_bytes: number download_url: string } From 65a08ed7aba9894cbfdb233ca4a007e60d654648 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Apr 2026 07:51:03 +0000 Subject: [PATCH 040/128] chore(i18n): sync translations with en-US (#35595) Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- web/i18n/nl-NL/dataset-creation.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/i18n/nl-NL/dataset-creation.json b/web/i18n/nl-NL/dataset-creation.json index 1628a8641e..56d99de240 100644 --- a/web/i18n/nl-NL/dataset-creation.json +++ b/web/i18n/nl-NL/dataset-creation.json @@ -35,8 +35,8 @@ "stepOne.uploader.cancel": "Cancel", "stepOne.uploader.change": "Change", "stepOne.uploader.failed": "Upload failed", - "stepOne.uploader.tip": "Supports {{supportTypes}}. Max {{batchCount}} in a batch and {{size}} MB each.", - "stepOne.uploader.tipWithTotalLimit": "Supports {{supportTypes}}. Max {{batchCount}} in a batch and {{size}} MB each. Max total {{totalCount}} files.", + "stepOne.uploader.tip": "Ondersteunt {{supportTypes}}. Maximaal {{batchCount}} per batch en {{size}} MB per bestand.", + "stepOne.uploader.tipWithTotalLimit": "Ondersteunt {{supportTypes}}. Maximaal {{batchCount}} per batch en {{size}} MB per bestand. Maximaal {{totalCount}} bestanden in totaal.", "stepOne.uploader.title": "Upload file", "stepOne.uploader.validation.count": "Multiple files not supported", "stepOne.uploader.validation.filesNumber": "You have reached the batch upload limit of {{filesNumber}}.", From 949f93069881162e188c282574ac4cadc7db2490 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Mon, 27 Apr 2026 16:51:09 +0800 Subject: [PATCH 041/128] fix: keep cleanup tasks resilient to billing API failures (#35600) --- api/core/rag/datasource/vdb/vector_factory.py | 58 +++- api/tasks/clean_document_task.py | 32 +- api/tasks/clean_notion_document_task.py | 29 +- .../tasks/test_clean_notion_document_task.py | 46 ++- .../rag/datasource/vdb/test_vector_factory.py | 54 +++- .../tasks/test_clean_document_task.py | 291 ++++++++++++++++++ 6 files changed, 479 insertions(+), 31 deletions(-) create mode 100644 api/tests/unit_tests/tasks/test_clean_document_task.py diff --git a/api/core/rag/datasource/vdb/vector_factory.py b/api/core/rag/datasource/vdb/vector_factory.py index 59d7f3c3c4..9575377174 100644 --- a/api/core/rag/datasource/vdb/vector_factory.py +++ b/api/core/rag/datasource/vdb/vector_factory.py @@ -39,6 +39,58 @@ class AbstractVectorFactory(ABC): return index_struct_dict +class _LazyEmbeddings(Embeddings): + """Lazy proxy that defers materializing the real embedding model. + + Constructing the real embeddings (via ``ModelManager.get_model_instance``) + transitively calls ``FeatureService.get_features`` → ``BillingService`` + HTTP GETs (see ``provider_manager.py``). Cleanup paths + (``delete_by_ids`` / ``delete`` / ``text_exists``) do not need embeddings + at all, so deferring this until an ``embed_*`` method is actually invoked + keeps cleanup tasks resilient to transient billing-API failures and avoids + leaving stranded ``document_segments`` / ``child_chunks`` whenever billing + hiccups. + + Existing callers that perform create / search operations are unaffected: + the first ``embed_*`` call materializes the underlying model and the + behavior is identical from that point on. + """ + + def __init__(self, dataset: Dataset): + self._dataset = dataset + self._real: Embeddings | None = None + + def _ensure(self) -> Embeddings: + if self._real is None: + model_manager = ModelManager.for_tenant(tenant_id=self._dataset.tenant_id) + embedding_model = model_manager.get_model_instance( + tenant_id=self._dataset.tenant_id, + provider=self._dataset.embedding_model_provider, + model_type=ModelType.TEXT_EMBEDDING, + model=self._dataset.embedding_model, + ) + self._real = CacheEmbedding(embedding_model) + return self._real + + def embed_documents(self, texts: list[str]) -> list[list[float]]: + return self._ensure().embed_documents(texts) + + def embed_multimodal_documents(self, multimodel_documents: list[dict[str, Any]]) -> list[list[float]]: + return self._ensure().embed_multimodal_documents(multimodel_documents) + + def embed_query(self, text: str) -> list[float]: + return self._ensure().embed_query(text) + + def embed_multimodal_query(self, multimodel_document: dict[str, Any]) -> list[float]: + return self._ensure().embed_multimodal_query(multimodel_document) + + async def aembed_documents(self, texts: list[str]) -> list[list[float]]: + return await self._ensure().aembed_documents(texts) + + async def aembed_query(self, text: str) -> list[float]: + return await self._ensure().aembed_query(text) + + class Vector: def __init__(self, dataset: Dataset, attributes: list | None = None): if attributes is None: @@ -60,7 +112,11 @@ class Vector: "original_chunk_id", ] self._dataset = dataset - self._embeddings = self._get_embeddings() + # Use a lazy proxy so cleanup paths (delete_by_ids / delete / text_exists) + # never transitively trigger billing API calls during ``Vector(dataset)`` + # construction. The real embedding model is materialized only when an + # ``embed_*`` method is actually invoked (i.e. create / search paths). + self._embeddings: Embeddings = _LazyEmbeddings(dataset) self._attributes = attributes self._vector_processor = self._init_vector() diff --git a/api/tasks/clean_document_task.py b/api/tasks/clean_document_task.py index a657cd553a..c8d0e31c06 100644 --- a/api/tasks/clean_document_task.py +++ b/api/tasks/clean_document_task.py @@ -61,13 +61,31 @@ def clean_document_task(document_id: str, dataset_id: str, doc_form: str, file_i # check segment is exist if index_node_ids: - index_processor = IndexProcessorFactory(doc_form).init_index_processor() - with session_factory.create_session() as session: - dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) - if dataset: - index_processor.clean( - dataset, index_node_ids, with_keywords=True, delete_child_chunks=True, delete_summaries=True - ) + # Wrap vector / keyword index cleanup in try/except so that a transient + # failure here (e.g. billing API hiccup propagated via FeatureService when + # ModelManager is initialized inside ``Vector(dataset)``) does not abort + # the entire task and leave document_segments / child_chunks / image_files + # / metadata bindings stranded in PG. Mirrors the pattern already used in + # ``clean_dataset_task`` so the document row's hard delete (already + # committed by the caller) does not produce orphan PG rows just because + # the vector backend or one of its transitive dependencies was unhappy. + try: + index_processor = IndexProcessorFactory(doc_form).init_index_processor() + with session_factory.create_session() as session: + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) + if dataset: + index_processor.clean( + dataset, index_node_ids, with_keywords=True, delete_child_chunks=True, delete_summaries=True + ) + except Exception: + logger.exception( + "Failed to clean vector / keyword index in clean_document_task, " + "document_id=%s, dataset_id=%s, index_node_ids_count=%d. " + "Continuing with PG / storage cleanup; vector orphans can be reaped later.", + document_id, + dataset_id, + len(index_node_ids), + ) total_image_files = [] with session_factory.create_session() as session, session.begin(): diff --git a/api/tasks/clean_notion_document_task.py b/api/tasks/clean_notion_document_task.py index e3be24ac74..017d60efac 100644 --- a/api/tasks/clean_notion_document_task.py +++ b/api/tasks/clean_notion_document_task.py @@ -40,12 +40,29 @@ def clean_notion_document_task(document_ids: list[str], dataset_id: str): segments = session.scalars(select(DocumentSegment).where(DocumentSegment.document_id == document_id)).all() total_index_node_ids.extend([segment.index_node_id for segment in segments]) - with session_factory.create_session() as session: - dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) - if dataset: - index_processor.clean( - dataset, total_index_node_ids, with_keywords=True, delete_child_chunks=True, delete_summaries=True - ) + # Wrap vector / keyword index cleanup in try/except so that a transient + # failure here (e.g. billing API hiccup propagated via FeatureService when + # ``ModelManager`` is initialized inside ``Vector(dataset)``) does not abort + # the task and leave the already-deleted documents' segments stranded in PG. + # The Document rows are hard-deleted in the previous session block, so any + # exception escaping this task would produce orphans that no later request + # can reference back. Mirrors the pattern in ``clean_dataset_task``. + try: + with session_factory.create_session() as session: + dataset = session.scalar(select(Dataset).where(Dataset.id == dataset_id).limit(1)) + if dataset: + index_processor.clean( + dataset, total_index_node_ids, with_keywords=True, delete_child_chunks=True, delete_summaries=True + ) + except Exception: + logger.exception( + "Failed to clean vector / keyword index in clean_notion_document_task, " + "dataset_id=%s, document_ids=%s, index_node_ids_count=%d. " + "Continuing with segment deletion; vector orphans can be reaped later.", + dataset_id, + document_ids, + len(total_index_node_ids), + ) with session_factory.create_session() as session, session.begin(): segment_delete_stmt = delete(DocumentSegment).where(DocumentSegment.document_id.in_(document_ids)) diff --git a/api/tests/test_containers_integration_tests/tasks/test_clean_notion_document_task.py b/api/tests/test_containers_integration_tests/tasks/test_clean_notion_document_task.py index fa3ac12cf0..7e5c374b5d 100644 --- a/api/tests/test_containers_integration_tests/tasks/test_clean_notion_document_task.py +++ b/api/tests/test_containers_integration_tests/tasks/test_clean_notion_document_task.py @@ -602,14 +602,25 @@ class TestCleanNotionDocumentTask: # Note: This test successfully verifies database operations. # IndexProcessor verification would require more sophisticated mocking. - def test_clean_notion_document_task_database_transaction_rollback( + def test_clean_notion_document_task_continues_when_index_processor_fails( self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies ): """ - Test cleanup task behavior when database operations fail. + Index processor failure (e.g. transient billing API error propagated via + ``FeatureService`` when ``Vector(dataset)`` lazily resolves the embedding + model) must NOT abort the cleanup task. The Document rows have already + been hard-deleted in the first session block before vector cleanup runs, + so any uncaught exception escaping the task would strand + ``DocumentSegment`` rows in PG with no parent ``Document``. - This test verifies that the task properly handles database errors - and maintains data consistency. + Contract: the task swallows the index_processor exception, logs it, and + proceeds to delete the segments — leaving PG consistent. (Vector orphans, + if any, can be reaped later by an offline scanner.) + + Regression guard for the production incident where ``clean_document_task`` + / ``clean_notion_document_task`` failed with + ``ValueError("Unable to retrieve billing information...")`` and left + tens of thousands of orphan segments per affected tenant. """ fake = Faker() @@ -672,17 +683,28 @@ class TestCleanNotionDocumentTask: db_session_with_containers.add(segment) db_session_with_containers.commit() - # Mock index processor to raise an exception + # Simulate the production failure mode: index_processor.clean() raises a + # ValueError mirroring ``BillingService._send_request`` returning non-200. mock_index_processor = mock_index_processor_factory.return_value.init_index_processor.return_value - mock_index_processor.clean.side_effect = Exception("Index processor error") + mock_index_processor.clean.side_effect = ValueError( + "Unable to retrieve billing information. Please try again later or contact support." + ) - # Execute cleanup task - current implementation propagates the exception - with pytest.raises(Exception, match="Index processor error"): - clean_notion_document_task([document.id], dataset.id) + # Execute cleanup task — must NOT raise even though clean() raises. + # Before the safety-net wrapper this would have re-raised the ValueError, + # aborting the task and leaving DocumentSegment stranded in PG. + clean_notion_document_task([document.id], dataset.id) - # Note: This test demonstrates the task's error handling capability. - # Even with external service errors, the database operations complete successfully. - # In a production environment, proper error handling would determine transaction rollback behavior. + # Vector cleanup was attempted exactly once. + mock_index_processor.clean.assert_called_once() + + # The crucial assertion: despite the index processor failure, the + # final session block (line 51-52, ``DELETE FROM document_segments``) + # still ran and committed. This is what the wrapper buys us — without + # it the production incident left tens of thousands of orphan segments + # per affected tenant. Aligns with the assertion shape used by the + # happy-path test (``test_clean_notion_document_task_success``). + assert _count_segments(db_session_with_containers, DocumentSegment.document_id == document.id) == 0 def test_clean_notion_document_task_with_large_number_of_documents( self, db_session_with_containers, mock_index_processor_factory, mock_external_service_dependencies diff --git a/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py b/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py index 9de04c80ba..f84ce2771f 100644 --- a/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py +++ b/api/tests/unit_tests/core/rag/datasource/vdb/test_vector_factory.py @@ -146,10 +146,7 @@ def test_get_vector_factory_entry_point_overrides_builtin(vector_factory_module, def test_vector_init_uses_default_and_custom_attributes(vector_factory_module): dataset = SimpleNamespace(id="dataset-1") - with ( - patch.object(vector_factory_module.Vector, "_get_embeddings", return_value="embeddings"), - patch.object(vector_factory_module.Vector, "_init_vector", return_value="processor"), - ): + with patch.object(vector_factory_module.Vector, "_init_vector", return_value="processor"): default_vector = vector_factory_module.Vector(dataset) custom_vector = vector_factory_module.Vector(dataset, attributes=["doc_id"]) @@ -166,10 +163,57 @@ def test_vector_init_uses_default_and_custom_attributes(vector_factory_module): "original_chunk_id", ] assert custom_vector._attributes == ["doc_id"] - assert default_vector._embeddings == "embeddings" + # ``_embeddings`` is now a lazy proxy that defers materializing the real + # embedding model until ``embed_*`` is invoked, so cleanup paths never + # trigger billing/feature-service calls during ``Vector(dataset)`` + # construction. See ``_LazyEmbeddings``. + assert isinstance(default_vector._embeddings, vector_factory_module._LazyEmbeddings) assert default_vector._vector_processor == "processor" +def test_lazy_embeddings_defer_real_load_until_first_embed_call(vector_factory_module, monkeypatch): + """``Vector(dataset)`` must not transitively call ``ModelManager`` during + construction. The real embedding model should only be materialized on the + first ``embed_*`` call (i.e. create / search paths) so cleanup paths + (``delete_by_ids`` / ``delete``) remain resilient to billing-API failures. + """ + for_tenant_mock = MagicMock(side_effect=AssertionError("ModelManager.for_tenant must not be called eagerly")) + monkeypatch.setattr(vector_factory_module.ModelManager, "for_tenant", for_tenant_mock) + + dataset = SimpleNamespace( + tenant_id="tenant-1", + embedding_model_provider="openai", + embedding_model="text-embedding-3-small", + ) + + proxy = vector_factory_module._LazyEmbeddings(dataset) + + # Construction alone does not trigger ModelManager / FeatureService / BillingService. + for_tenant_mock.assert_not_called() + + # Exercising an embed_* method materializes the real model exactly once. + inner_model = MagicMock() + inner_model.embed_documents.return_value = [[0.1, 0.2]] + cached_embedding_mock = MagicMock(return_value=inner_model) + real_for_tenant = MagicMock() + real_for_tenant.get_model_instance.return_value = "embedding-model-instance" + monkeypatch.setattr(vector_factory_module.ModelManager, "for_tenant", MagicMock(return_value=real_for_tenant)) + monkeypatch.setattr(vector_factory_module, "CacheEmbedding", cached_embedding_mock) + + result = proxy.embed_documents(["hello"]) + + assert result == [[0.1, 0.2]] + cached_embedding_mock.assert_called_once_with("embedding-model-instance") + inner_model.embed_documents.assert_called_once_with(["hello"]) + + # Subsequent calls reuse the materialized model (no re-resolution). + inner_model.embed_documents.reset_mock() + cached_embedding_mock.reset_mock() + proxy.embed_documents(["world"]) + cached_embedding_mock.assert_not_called() + inner_model.embed_documents.assert_called_once_with(["world"]) + + def test_init_vector_prefers_dataset_index_struct(vector_factory_module, monkeypatch): calls = {"vector_type": None, "init_args": None} diff --git a/api/tests/unit_tests/tasks/test_clean_document_task.py b/api/tests/unit_tests/tasks/test_clean_document_task.py new file mode 100644 index 0000000000..26d7b3e3b6 --- /dev/null +++ b/api/tests/unit_tests/tasks/test_clean_document_task.py @@ -0,0 +1,291 @@ +""" +Unit tests for clean_document_task. + +Focuses on the resilience contract added by the billing-failure fix: +``index_processor.clean()`` is wrapped in ``try/except`` so that a transient +failure inside the vector / keyword cleanup (e.g. ``ValueError("Unable to +retrieve billing information...")`` raised by ``BillingService._send_request`` +when ``Vector(dataset)`` transitively triggers ``FeatureService.get_features``) +does not abort the entire task and leave PG with stranded ``DocumentSegment`` +/ ``ChildChunk`` / ``UploadFile`` / ``DatasetMetadataBinding`` rows. +""" + +import uuid +from unittest.mock import MagicMock, patch + +import pytest + +from tasks.clean_document_task import clean_document_task + + +@pytest.fixture +def document_id(): + return str(uuid.uuid4()) + + +@pytest.fixture +def dataset_id(): + return str(uuid.uuid4()) + + +@pytest.fixture +def tenant_id(): + return str(uuid.uuid4()) + + +@pytest.fixture +def mock_session_factory(): + """Patch ``session_factory.create_session`` to return per-call mock sessions. + + Each call to ``create_session()`` yields a fresh ``MagicMock`` session so we + can assert ``execute()`` calls across the multiple short-lived transactions + used by ``clean_document_task``. + """ + with patch("tasks.clean_document_task.session_factory", autospec=True) as mock_sf: + sessions: list[MagicMock] = [] + + def _create_session(): + session = MagicMock() + session.scalars.return_value.all.return_value = [] + session.execute.return_value.all.return_value = [] + session.scalar.return_value = None + cm = MagicMock() + cm.__enter__.return_value = session + cm.__exit__.return_value = None + sessions.append(session) + return cm + + mock_sf.create_session.side_effect = _create_session + yield mock_sf, sessions + + +@pytest.fixture +def mock_storage(): + with patch("tasks.clean_document_task.storage", autospec=True) as mock: + mock.delete.return_value = None + yield mock + + +@pytest.fixture +def mock_index_processor_factory(): + """Mock ``IndexProcessorFactory`` so we can inject behavior into ``clean``.""" + with patch("tasks.clean_document_task.IndexProcessorFactory", autospec=True) as factory_cls: + processor = MagicMock() + processor.clean.return_value = None + factory_instance = MagicMock() + factory_instance.init_index_processor.return_value = processor + factory_cls.return_value = factory_instance + + yield { + "factory_cls": factory_cls, + "factory_instance": factory_instance, + "processor": processor, + } + + +def _build_segment(segment_id: str, content: str = "segment content") -> MagicMock: + seg = MagicMock() + seg.id = segment_id + seg.index_node_id = f"node-{segment_id}" + seg.content = content + return seg + + +def _build_dataset(dataset_id: str, tenant_id: str) -> MagicMock: + ds = MagicMock() + ds.id = dataset_id + ds.tenant_id = tenant_id + return ds + + +class TestVectorCleanupResilience: + """Vector / keyword cleanup must not abort the task on transient failure.""" + + def test_billing_failure_during_vector_cleanup_does_not_skip_pg_cleanup( + self, + document_id, + dataset_id, + tenant_id, + mock_session_factory, + mock_storage, + mock_index_processor_factory, + ): + """Reproduces the production incident: + + ``Vector(dataset)`` transitively calls ``FeatureService.get_features`` + which calls ``BillingService._send_request("GET", ...)``. When billing + returns non-200 it raises ``ValueError("Unable to retrieve billing + information...")``. Before the fix this propagated out of + ``clean_document_task`` and left ``DocumentSegment`` / ``ChildChunk`` / + ``UploadFile`` / ``DatasetMetadataBinding`` rows orphaned because the + already-deleted ``Document`` row had been hard-committed by the caller + (``dataset_service.delete_document``) before ``.delay()`` was invoked. + + Contract: a billing failure inside ``index_processor.clean()`` must be + caught, logged, and the rest of the task must continue so PG ends up + consistent with the deleted ``Document`` even if Qdrant retains + orphan vectors that can be reaped later. + """ + mock_sf, sessions = mock_session_factory + + # First create_session(): Step 1 (load segments + attachments). + step1_session = MagicMock() + step1_session.scalars.return_value.all.return_value = [ + _build_segment("seg-1"), + _build_segment("seg-2"), + ] + step1_session.execute.return_value.all.return_value = [] + step1_session.scalar.return_value = _build_dataset(dataset_id, tenant_id) + # Second create_session(): Step 2 (vector cleanup). Returns dataset. + step2_session = MagicMock() + step2_session.scalar.return_value = _build_dataset(dataset_id, tenant_id) + step2_session.scalars.return_value.all.return_value = [] + step2_session.execute.return_value.all.return_value = [] + # Subsequent sessions: Step 3+ (image / segment / file / metadata cleanup). + # Default fixture returns empty results which is fine for these short txns. + cm1, cm2 = MagicMock(), MagicMock() + cm1.__enter__.return_value = step1_session + cm1.__exit__.return_value = None + cm2.__enter__.return_value = step2_session + cm2.__exit__.return_value = None + + def _default_cm(): + session = MagicMock() + session.scalars.return_value.all.return_value = [] + session.execute.return_value.all.return_value = [] + session.scalar.return_value = None + cm = MagicMock() + cm.__enter__.return_value = session + cm.__exit__.return_value = None + sessions.append(session) + return cm + + mock_sf.create_session.side_effect = [cm1, cm2] + [_default_cm() for _ in range(10)] + + # Simulate the production failure: index_processor.clean() raises ValueError + # mirroring BillingService._send_request when billing returns non-200. + mock_index_processor_factory["processor"].clean.side_effect = ValueError( + "Unable to retrieve billing information. Please try again later or contact support." + ) + + # Act — must not raise out of the task even though clean() raises. + clean_document_task( + document_id=document_id, + dataset_id=dataset_id, + doc_form="paragraph", + file_id=None, + ) + + # Assert + # 1. Vector cleanup was attempted. + mock_index_processor_factory["processor"].clean.assert_called_once() + # 2. Despite the failure the task continued: at least one DocumentSegment + # delete was issued. We use the count of session.execute calls across + # later short transactions as a proxy for "Step 3+ executed". + execute_calls = sum(s.execute.call_count for s in sessions) + assert execute_calls > 0, ( + "Step 3+ DB cleanup did not run after vector cleanup failure; " + "this regression would re-introduce the orphan-segment bug." + ) + + def test_vector_cleanup_success_path_remains_unaffected( + self, + document_id, + dataset_id, + tenant_id, + mock_session_factory, + mock_storage, + mock_index_processor_factory, + ): + """Backward-compat: the happy path must still call ``clean()`` exactly + once with the expected arguments and complete without errors. + """ + mock_sf, sessions = mock_session_factory + + step1_session = MagicMock() + step1_session.scalars.return_value.all.return_value = [_build_segment("seg-1")] + step1_session.execute.return_value.all.return_value = [] + step1_session.scalar.return_value = _build_dataset(dataset_id, tenant_id) + step2_session = MagicMock() + step2_session.scalar.return_value = _build_dataset(dataset_id, tenant_id) + step2_session.scalars.return_value.all.return_value = [] + step2_session.execute.return_value.all.return_value = [] + cm1, cm2 = MagicMock(), MagicMock() + cm1.__enter__.return_value = step1_session + cm1.__exit__.return_value = None + cm2.__enter__.return_value = step2_session + cm2.__exit__.return_value = None + + def _default_cm(): + session = MagicMock() + session.scalars.return_value.all.return_value = [] + session.execute.return_value.all.return_value = [] + session.scalar.return_value = None + cm = MagicMock() + cm.__enter__.return_value = session + cm.__exit__.return_value = None + sessions.append(session) + return cm + + mock_sf.create_session.side_effect = [cm1, cm2] + [_default_cm() for _ in range(10)] + + clean_document_task( + document_id=document_id, + dataset_id=dataset_id, + doc_form="paragraph", + file_id=None, + ) + + assert mock_index_processor_factory["processor"].clean.call_count == 1 + # Index cleanup invoked with the expected delete_summaries / delete_child_chunks flags. + _, kwargs = mock_index_processor_factory["processor"].clean.call_args + assert kwargs.get("with_keywords") is True + assert kwargs.get("delete_child_chunks") is True + assert kwargs.get("delete_summaries") is True + + def test_no_segments_skips_vector_cleanup( + self, + document_id, + dataset_id, + tenant_id, + mock_session_factory, + mock_storage, + mock_index_processor_factory, + ): + """When the document has no segments (e.g. indexing failed before + producing any), vector cleanup must not be attempted — and therefore + the new try/except wrapper does not change behavior here. + """ + mock_sf, sessions = mock_session_factory + + step1_session = MagicMock() + step1_session.scalars.return_value.all.return_value = [] # no segments + step1_session.execute.return_value.all.return_value = [] + step1_session.scalar.return_value = _build_dataset(dataset_id, tenant_id) + cm1 = MagicMock() + cm1.__enter__.return_value = step1_session + cm1.__exit__.return_value = None + + def _default_cm(): + session = MagicMock() + session.scalars.return_value.all.return_value = [] + session.execute.return_value.all.return_value = [] + session.scalar.return_value = None + cm = MagicMock() + cm.__enter__.return_value = session + cm.__exit__.return_value = None + sessions.append(session) + return cm + + mock_sf.create_session.side_effect = [cm1] + [_default_cm() for _ in range(10)] + + clean_document_task( + document_id=document_id, + dataset_id=dataset_id, + doc_form="paragraph", + file_id=None, + ) + + # Vector cleanup is gated on ``index_node_ids``; when there are no + # segments the IndexProcessorFactory path is never entered. + mock_index_processor_factory["factory_cls"].assert_not_called() From b6aa5a7d69eeff77ac7b67d0cda3680738730f31 Mon Sep 17 00:00:00 2001 From: Benjamin Date: Mon, 27 Apr 2026 18:19:56 +0800 Subject: [PATCH 042/128] fix: download and upload package before invoking upgrade in auto-upgrade task (#35599) Co-authored-by: Claude Opus 4.7 (1M context) --- ...ss_tenant_plugin_autoupgrade_check_task.py | 10 +- ...ss_tenant_plugin_autoupgrade_check_task.py | 289 ++++++++++++++++++ 2 files changed, 294 insertions(+), 5 deletions(-) create mode 100644 api/tests/unit_tests/tasks/test_process_tenant_plugin_autoupgrade_check_task.py diff --git a/api/tasks/process_tenant_plugin_autoupgrade_check_task.py b/api/tasks/process_tenant_plugin_autoupgrade_check_task.py index 5d201bd801..48d1774ce3 100644 --- a/api/tasks/process_tenant_plugin_autoupgrade_check_task.py +++ b/api/tasks/process_tenant_plugin_autoupgrade_check_task.py @@ -11,6 +11,7 @@ from core.plugin.entities.plugin import PluginInstallationSource from core.plugin.impl.plugin import PluginInstaller from extensions.ext_redis import redis_client from models.account import TenantPluginAutoUpgradeStrategy +from services.plugin.plugin_service import PluginService logger = logging.getLogger(__name__) @@ -171,14 +172,13 @@ def process_tenant_plugin_autoupgrade_check_task( fg="green", ) ) - _ = manager.upgrade_plugin( + # Use the service that downloads and uploads the package to the daemon + # first; calling manager.upgrade_plugin directly skips that step and the + # daemon fails because the package never reaches its local bucket. + _ = PluginService.upgrade_plugin_with_marketplace( tenant_id, original_unique_identifier, new_unique_identifier, - PluginInstallationSource.Marketplace, - { - "plugin_unique_identifier": new_unique_identifier, - }, ) except Exception as e: click.echo(click.style(f"Error when upgrading plugin: {e}", fg="red")) diff --git a/api/tests/unit_tests/tasks/test_process_tenant_plugin_autoupgrade_check_task.py b/api/tests/unit_tests/tasks/test_process_tenant_plugin_autoupgrade_check_task.py new file mode 100644 index 0000000000..75d8b92044 --- /dev/null +++ b/api/tests/unit_tests/tasks/test_process_tenant_plugin_autoupgrade_check_task.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from core.plugin.entities.marketplace import MarketplacePluginSnapshot +from core.plugin.entities.plugin import PluginInstallationSource +from models.account import TenantPluginAutoUpgradeStrategy + +MODULE = "tasks.process_tenant_plugin_autoupgrade_check_task" + + +def _make_plugin(plugin_id: str, version: str, source=PluginInstallationSource.Marketplace): + """Build a minimal stand-in for a PluginInstallation entry returned by manager.list_plugins.""" + return SimpleNamespace( + plugin_id=plugin_id, + version=version, + plugin_unique_identifier=f"{plugin_id}:{version}@deadbeef", + source=source, + ) + + +def _make_manifest(plugin_id: str, latest_version: str) -> MarketplacePluginSnapshot: + org, name = plugin_id.split("/", 1) + return MarketplacePluginSnapshot( + org=org, + name=name, + latest_version=latest_version, + latest_package_identifier=f"{plugin_id}:{latest_version}@cafe1234", + latest_package_url=f"https://marketplace.example/{plugin_id}/{latest_version}.difypkg", + ) + + +def _run_task( + *, + plugins: list, + manifests: list[MarketplacePluginSnapshot], + strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST, + upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, + exclude_plugins=None, + include_plugins=None, +): + """ + Execute the celery task synchronously with mocks for the plugin manager, + the marketplace cache and PluginService.upgrade_plugin_with_marketplace. + Returns the upgrade-call recorder so each test can assert on it. + """ + fake_manager = MagicMock() + fake_manager.list_plugins.return_value = plugins + + upgrade_calls: list[tuple[str, str, str]] = [] + + def _record_upgrade(tenant_id, original, new): + upgrade_calls.append((tenant_id, original, new)) + + with ( + patch(f"{MODULE}.PluginInstaller", return_value=fake_manager), + patch(f"{MODULE}.marketplace_batch_fetch_plugin_manifests", return_value=manifests), + patch( + f"{MODULE}.PluginService.upgrade_plugin_with_marketplace", + side_effect=_record_upgrade, + ) as upgrade_mock, + ): + from tasks.process_tenant_plugin_autoupgrade_check_task import ( + process_tenant_plugin_autoupgrade_check_task, + ) + + process_tenant_plugin_autoupgrade_check_task( + "tenant-1", + strategy_setting, + 0, + upgrade_mode, + exclude_plugins or [], + include_plugins or [], + ) + + return upgrade_mock, upgrade_calls + + +class TestUpgradeCallsMarketplaceService: + """ + Regression test for the bug where the auto-upgrade task called + manager.upgrade_plugin directly, which skipped downloading the new package + from marketplace and uploading it to the daemon. The daemon then failed with + "package file not found" and the upgrade silently never completed. + """ + + def test_upgrade_routes_through_plugin_service(self): + plugin = _make_plugin("acme/foo", "1.0.0") + manifest = _make_manifest("acme/foo", "1.0.1") + + upgrade_mock, calls = _run_task(plugins=[plugin], manifests=[manifest]) + + upgrade_mock.assert_called_once() + assert calls == [("tenant-1", plugin.plugin_unique_identifier, manifest.latest_package_identifier)] + + def test_does_not_call_manager_upgrade_plugin_directly(self): + """Locks in that we never go back to the broken path that bypassed download/upload.""" + plugin = _make_plugin("acme/foo", "1.0.0") + manifest = _make_manifest("acme/foo", "1.0.1") + + fake_manager = MagicMock() + fake_manager.list_plugins.return_value = [plugin] + + with ( + patch(f"{MODULE}.PluginInstaller", return_value=fake_manager), + patch(f"{MODULE}.marketplace_batch_fetch_plugin_manifests", return_value=[manifest]), + patch(f"{MODULE}.PluginService.upgrade_plugin_with_marketplace"), + ): + from tasks.process_tenant_plugin_autoupgrade_check_task import ( + process_tenant_plugin_autoupgrade_check_task, + ) + + process_tenant_plugin_autoupgrade_check_task( + "tenant-1", + TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST, + 0, + TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, + [], + [], + ) + + fake_manager.upgrade_plugin.assert_not_called() + + +class TestStrategySetting: + def test_disabled_strategy_skips_everything(self): + upgrade_mock, _ = _run_task( + plugins=[_make_plugin("acme/foo", "1.0.0")], + manifests=[_make_manifest("acme/foo", "1.0.1")], + strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED, + ) + upgrade_mock.assert_not_called() + + def test_fix_only_upgrades_patch_version(self): + upgrade_mock, calls = _run_task( + plugins=[_make_plugin("acme/foo", "1.0.0")], + manifests=[_make_manifest("acme/foo", "1.0.5")], + strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, + ) + upgrade_mock.assert_called_once() + assert calls[0][2].endswith(":1.0.5@cafe1234") + + def test_fix_only_skips_minor_bump(self): + upgrade_mock, _ = _run_task( + plugins=[_make_plugin("acme/foo", "1.0.0")], + manifests=[_make_manifest("acme/foo", "1.1.0")], + strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, + ) + upgrade_mock.assert_not_called() + + def test_fix_only_skips_major_bump(self): + upgrade_mock, _ = _run_task( + plugins=[_make_plugin("acme/foo", "1.0.0")], + manifests=[_make_manifest("acme/foo", "2.0.0")], + strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.FIX_ONLY, + ) + upgrade_mock.assert_not_called() + + def test_latest_strategy_skips_when_versions_equal(self): + upgrade_mock, _ = _run_task( + plugins=[_make_plugin("acme/foo", "1.0.0")], + manifests=[_make_manifest("acme/foo", "1.0.0")], + strategy_setting=TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST, + ) + upgrade_mock.assert_not_called() + + +class TestUpgradeMode: + def test_mode_all_upgrades_every_marketplace_plugin(self): + plugins = [ + _make_plugin("acme/foo", "1.0.0"), + _make_plugin("acme/bar", "2.0.0"), + ] + manifests = [ + _make_manifest("acme/foo", "1.0.1"), + _make_manifest("acme/bar", "2.0.1"), + ] + + upgrade_mock, calls = _run_task( + plugins=plugins, + manifests=manifests, + upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, + ) + + assert upgrade_mock.call_count == 2 + upgraded_ids = sorted(c[1] for c in calls) + assert upgraded_ids == sorted(p.plugin_unique_identifier for p in plugins) + + def test_mode_all_skips_non_marketplace_sources(self): + plugins = [ + _make_plugin("acme/foo", "1.0.0"), + _make_plugin("acme/bar", "2.0.0", source=PluginInstallationSource.Github), + ] + manifests = [ + _make_manifest("acme/foo", "1.0.1"), + _make_manifest("acme/bar", "2.0.1"), + ] + + upgrade_mock, calls = _run_task( + plugins=plugins, + manifests=manifests, + upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, + ) + + assert upgrade_mock.call_count == 1 + assert calls[0][1] == plugins[0].plugin_unique_identifier + + def test_mode_partial_only_upgrades_included_plugins(self): + plugins = [ + _make_plugin("acme/foo", "1.0.0"), + _make_plugin("acme/bar", "2.0.0"), + ] + manifests = [ + _make_manifest("acme/foo", "1.0.1"), + _make_manifest("acme/bar", "2.0.1"), + ] + + upgrade_mock, calls = _run_task( + plugins=plugins, + manifests=manifests, + upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.PARTIAL, + include_plugins=["acme/foo"], + ) + + assert upgrade_mock.call_count == 1 + assert calls[0][1] == plugins[0].plugin_unique_identifier + + def test_mode_exclude_skips_excluded_plugins(self): + plugins = [ + _make_plugin("acme/foo", "1.0.0"), + _make_plugin("acme/bar", "2.0.0"), + ] + manifests = [ + _make_manifest("acme/foo", "1.0.1"), + _make_manifest("acme/bar", "2.0.1"), + ] + + upgrade_mock, calls = _run_task( + plugins=plugins, + manifests=manifests, + upgrade_mode=TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE, + exclude_plugins=["acme/bar"], + ) + + assert upgrade_mock.call_count == 1 + assert calls[0][1] == plugins[0].plugin_unique_identifier + + +class TestErrorIsolation: + def test_one_plugin_failure_does_not_block_others(self): + plugins = [ + _make_plugin("acme/foo", "1.0.0"), + _make_plugin("acme/bar", "2.0.0"), + ] + manifests = [ + _make_manifest("acme/foo", "1.0.1"), + _make_manifest("acme/bar", "2.0.1"), + ] + fake_manager = MagicMock() + fake_manager.list_plugins.return_value = plugins + + seen: list[str] = [] + + def _upgrade(tenant_id, original, new): + seen.append(original) + if "foo" in original: + raise RuntimeError("boom") + + with ( + patch(f"{MODULE}.PluginInstaller", return_value=fake_manager), + patch(f"{MODULE}.marketplace_batch_fetch_plugin_manifests", return_value=manifests), + patch(f"{MODULE}.PluginService.upgrade_plugin_with_marketplace", side_effect=_upgrade), + ): + from tasks.process_tenant_plugin_autoupgrade_check_task import ( + process_tenant_plugin_autoupgrade_check_task, + ) + + process_tenant_plugin_autoupgrade_check_task( + "tenant-1", + TenantPluginAutoUpgradeStrategy.StrategySetting.LATEST, + 0, + TenantPluginAutoUpgradeStrategy.UpgradeMode.ALL, + [], + [], + ) + + assert any("foo" in s for s in seen) + assert any("bar" in s for s in seen) From 90ab734a05214fa96224d89b784b8ba235e84f31 Mon Sep 17 00:00:00 2001 From: JzoNg Date: Mon, 27 Apr 2026 21:33:06 +0800 Subject: [PATCH 043/128] chore(web): replace form_data with submitted_data --- .../chat/chat-with-history/__tests__/hooks.spec.tsx | 2 +- .../__tests__/human-input-filled-form-list.spec.tsx | 2 +- .../human-input-content/__tests__/submitted.spec.tsx | 6 +++--- .../chat/chat/answer/human-input-content/submitted.tsx | 10 +++++----- .../__tests__/human-input-filled-form-list.spec.tsx | 2 +- .../__tests__/hooks/handle-resume.spec.ts | 6 +++--- .../__tests__/hooks/sse-callbacks.spec.ts | 4 ++-- web/types/workflow.ts | 2 +- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx index d567858b36..35478bae7b 100644 --- a/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/__tests__/hooks.spec.tsx @@ -1163,7 +1163,7 @@ describe('useChatWithHistory', () => { rendered_content: 'Submitted summary', action_id: 'submit', action_text: 'Submit', - form_data: { + submitted_data: { summary: 'approved', }, }, diff --git a/web/app/components/base/chat/chat/answer/__tests__/human-input-filled-form-list.spec.tsx b/web/app/components/base/chat/chat/answer/__tests__/human-input-filled-form-list.spec.tsx index 7f874f5e74..09cced154f 100644 --- a/web/app/components/base/chat/chat/answer/__tests__/human-input-filled-form-list.spec.tsx +++ b/web/app/components/base/chat/chat/answer/__tests__/human-input-filled-form-list.spec.tsx @@ -16,7 +16,7 @@ const createFormData = ( rendered_content: 'fallback content', action_id: 'approve', action_text: 'Approve', - form_data: { + submitted_data: { summary: 'Approved', }, diff --git a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted.spec.tsx b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted.spec.tsx index b4ee491150..2afceccb8e 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted.spec.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/__tests__/submitted.spec.tsx @@ -41,7 +41,7 @@ describe('SubmittedHumanInputContent Integration', () => { output_variable_name: 'answer', default: { type: 'constant', value: '', selector: [] }, }], - form_data: { + submitted_data: { answer: 'approved', }, }} @@ -72,7 +72,7 @@ describe('SubmittedHumanInputContent Integration', () => { allowed_file_upload_methods: [], }, ], - form_data: { + submitted_data: { decision: 'approve', attachment: { related_id: 'file-1', @@ -100,7 +100,7 @@ describe('SubmittedHumanInputContent Integration', () => { render( , ) diff --git a/web/app/components/base/chat/chat/answer/human-input-content/submitted.tsx b/web/app/components/base/chat/chat/answer/human-input-content/submitted.tsx index 3cf3a735b3..64a14aa5ef 100644 --- a/web/app/components/base/chat/chat/answer/human-input-content/submitted.tsx +++ b/web/app/components/base/chat/chat/answer/human-input-content/submitted.tsx @@ -8,7 +8,7 @@ import SubmittedFormContent from './submitted-form-content' export const SubmittedHumanInputContent = ({ formData, }: SubmittedHumanInputContentProps) => { - const { rendered_content, action_id, action_text, form_content, form_data, inputs } = formData + const { rendered_content, action_id, action_text, form_content, submitted_data, inputs } = formData const executedAction = useMemo(() => { return { @@ -17,16 +17,16 @@ export const SubmittedHumanInputContent = ({ } }, [action_id, action_text]) - const content = form_content && inputs && form_data && Object.keys(form_data).length > 0 + const content = form_content && inputs && submitted_data && Object.keys(submitted_data).length > 0 ? ( ) - : form_data && Object.keys(form_data).length > 0 - ? + : submitted_data && Object.keys(submitted_data).length > 0 + ? : return ( diff --git a/web/app/components/workflow/panel/__tests__/human-input-filled-form-list.spec.tsx b/web/app/components/workflow/panel/__tests__/human-input-filled-form-list.spec.tsx index 53e7e87b74..4a77e71705 100644 --- a/web/app/components/workflow/panel/__tests__/human-input-filled-form-list.spec.tsx +++ b/web/app/components/workflow/panel/__tests__/human-input-filled-form-list.spec.tsx @@ -9,7 +9,7 @@ const createFilledForm = (overrides: Partial = {}): Hu rendered_content: 'Approved by Alice', action_id: 'approve', action_text: 'Approve', - form_data: { + submitted_data: { summary: 'Approved by Alice', }, ...overrides, diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts index 5a608dcc5b..603db4df80 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/handle-resume.spec.ts @@ -782,7 +782,7 @@ describe('useChat – handleResume', () => { act(() => { capturedResumeOptions.onHumanInputFormFilled({ - data: { node_id: 'rn-human', form_data: { a: 1 } }, + data: { node_id: 'rn-human', submitted_data: { a: 1 } }, }) }) @@ -796,7 +796,7 @@ describe('useChat – handleResume', () => { act(() => { capturedResumeOptions.onHumanInputFormFilled({ - data: { node_id: 'rn-human', form_data: { b: 2 } }, + data: { node_id: 'rn-human', submitted_data: { b: 2 } }, }) }) @@ -937,7 +937,7 @@ describe('useChat – handleResume with bare prevChatTree (no humanInputFormData act(() => { capturedResumeOptions.onHumanInputFormFilled({ - data: { node_id: 'hn-bare', form_data: { x: 1 } }, + data: { node_id: 'hn-bare', submitted_data: { x: 1 } }, }) }) diff --git a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts index 109078db80..a67165fba6 100644 --- a/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts +++ b/web/app/components/workflow/panel/debug-and-preview/__tests__/hooks/sse-callbacks.spec.ts @@ -868,7 +868,7 @@ describe('useChat – handleSend SSE callbacks', () => { act(() => { capturedCallbacks.onHumanInputFormFilled({ - data: { node_id: 'human-node', form_data: { answer: 'yes' } }, + data: { node_id: 'human-node', submitted_data: { answer: 'yes' } }, }) }) @@ -876,7 +876,7 @@ describe('useChat – handleSend SSE callbacks', () => { expect(answer!.humanInputFormDataList).toHaveLength(0) expect(answer!.humanInputFilledFormDataList).toHaveLength(1) expect(answer!.humanInputFilledFormDataList![0]!.node_id).toBe('human-node') - expect((answer!.humanInputFilledFormDataList![0] as any).form_data).toEqual({ answer: 'yes' }) + expect(answer!.humanInputFilledFormDataList![0]!.submitted_data).toEqual({ answer: 'yes' }) expect(answer!.humanInputFilledFormDataList![0]).toEqual(expect.objectContaining({ form_content: '{{#$output.answer#}}', inputs: [], diff --git a/web/types/workflow.ts b/web/types/workflow.ts index 4bcd9fec45..66297ba5c2 100644 --- a/web/types/workflow.ts +++ b/web/types/workflow.ts @@ -355,7 +355,7 @@ export type HumanInputFilledFormData = { action_text: string form_content?: string inputs?: FormInputItem[] - form_data?: Record + submitted_data?: Record } export type HumanInputFormFilledResponse = { From 1065a4840aa36a20eb29295704bd2258debae17d Mon Sep 17 00:00:00 2001 From: Asuka Minato Date: Mon, 27 Apr 2026 23:01:50 +0900 Subject: [PATCH 044/128] refactor: move SegmentAttachmentBinding and UploadFile to TypeBase (#30218) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/models/model.py | 35 +-- api/models/workflow.py | 14 +- .../unit_tests/models/test_workflow_models.py | 238 +++++++++--------- 3 files changed, 146 insertions(+), 141 deletions(-) diff --git a/api/models/model.py b/api/models/model.py index de83aa1d96..25c330b062 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -2182,7 +2182,7 @@ class ApiToken(Base): # bug: this uses setattr so idk the field. return result -class UploadFile(Base): +class UploadFile(TypeBase): __tablename__ = "upload_files" __table_args__ = ( sa.PrimaryKeyConstraint("id", name="upload_file_pkey"), @@ -2190,9 +2190,12 @@ class UploadFile(Base): ) # NOTE: The `id` field is generated within the application to minimize extra roundtrips - # (especially when generating `source_url`). - # The `server_default` serves as a fallback mechanism. - id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4())) + # (especially when generating `source_url`) and keep model metadata portable across databases. + id: Mapped[str] = mapped_column( + StringUUID, + init=False, + default_factory=lambda: str(uuid4()), + ) tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) storage_type: Mapped[StorageType] = mapped_column(EnumText(StorageType, length=255), nullable=False) key: Mapped[str] = mapped_column(String(255), nullable=False) @@ -2200,16 +2203,6 @@ class UploadFile(Base): size: Mapped[int] = mapped_column(sa.Integer, nullable=False) extension: Mapped[str] = mapped_column(String(255), nullable=False) mime_type: Mapped[str] = mapped_column(String(255), nullable=True) - - # The `created_by_role` field indicates whether the file was created by an `Account` or an `EndUser`. - # Its value is derived from the `CreatorUserRole` enumeration. - created_by_role: Mapped[CreatorUserRole] = mapped_column( - EnumText(CreatorUserRole, length=255), - nullable=False, - server_default=sa.text("'account'"), - default=CreatorUserRole.ACCOUNT, - ) - # The `created_by` field stores the ID of the entity that created this upload file. # # If `created_by_role` is `ACCOUNT`, it corresponds to `Account.id`. @@ -2228,10 +2221,18 @@ class UploadFile(Base): # `used` may indicate whether the file has been utilized by another service. used: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false")) + # The `created_by_role` field indicates whether the file was created by an `Account` or an `EndUser`. + # Its value is derived from the `CreatorUserRole` enumeration. + created_by_role: Mapped[CreatorUserRole] = mapped_column( + EnumText(CreatorUserRole, length=255), + nullable=False, + server_default=sa.text("'account'"), + default=CreatorUserRole.ACCOUNT, + ) # `used_by` may indicate the ID of the user who utilized this file. - used_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) - used_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True) - hash: Mapped[str | None] = mapped_column(String(255), nullable=True) + used_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None) + used_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True, default=None) + hash: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None) source_url: Mapped[str] = mapped_column(LongText, default="") def __init__( diff --git a/api/models/workflow.py b/api/models/workflow.py index d127244b0f..cb1723440b 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -50,7 +50,7 @@ from libs.uuid_utils import uuidv7 from ._workflow_exc import NodeNotFoundError, WorkflowDataError if TYPE_CHECKING: - from .model import AppMode, UploadFile + from .model import AppMode from constants import DEFAULT_FILE_NUMBER_LIMITS, HIDDEN_VALUE @@ -63,6 +63,10 @@ from .account import Account from .base import Base, DefaultFieldsDCMixin, TypeBase from .engine import db from .enums import CreatorUserRole, DraftVariableType, ExecutionOffLoadType, WorkflowRunTriggeredFrom + +# UploadFile uses TypeBase while workflow execution offload models use Base, so relationships +# must target the class object directly instead of relying on string lookup across registries. +from .model import UploadFile from .types import EnumText, LongText, StringUUID from .utils.file_input_compat import ( build_file_from_mapping_without_lookup, @@ -1096,8 +1100,6 @@ class WorkflowNodeExecutionModel(Base): # This model is expected to have `offlo @staticmethod def _load_full_content(session: orm.Session, file_id: str, storage: Storage): - from .model import UploadFile - stmt = sa.select(UploadFile).where(UploadFile.id == file_id) file = session.scalars(stmt).first() assert file is not None, f"UploadFile with id {file_id} should exist but not" @@ -1191,10 +1193,11 @@ class WorkflowNodeExecutionOffload(Base): ) file: Mapped[Optional["UploadFile"]] = orm.relationship( + UploadFile, foreign_keys=[file_id], lazy="raise", uselist=False, - primaryjoin="WorkflowNodeExecutionOffload.file_id == UploadFile.id", + primaryjoin=lambda: orm.foreign(WorkflowNodeExecutionOffload.file_id) == UploadFile.id, ) @@ -1968,10 +1971,11 @@ class WorkflowDraftVariableFile(Base): # Relationship to UploadFile upload_file: Mapped["UploadFile"] = orm.relationship( + UploadFile, foreign_keys=[upload_file_id], lazy="raise", uselist=False, - primaryjoin="WorkflowDraftVariableFile.upload_file_id == UploadFile.id", + primaryjoin=lambda: orm.foreign(WorkflowDraftVariableFile.upload_file_id) == UploadFile.id, ) diff --git a/api/tests/unit_tests/models/test_workflow_models.py b/api/tests/unit_tests/models/test_workflow_models.py index eb9fef7587..0953570a31 100644 --- a/api/tests/unit_tests/models/test_workflow_models.py +++ b/api/tests/unit_tests/models/test_workflow_models.py @@ -45,7 +45,7 @@ class TestWorkflowModelValidation: workflow = Workflow.new( tenant_id=tenant_id, app_id=app_id, - type=WorkflowType.WORKFLOW.value, + type=WorkflowType.WORKFLOW, version="draft", graph=graph, features=features, @@ -58,7 +58,7 @@ class TestWorkflowModelValidation: # Assert assert workflow.tenant_id == tenant_id assert workflow.app_id == app_id - assert workflow.type == WorkflowType.WORKFLOW.value + assert workflow.type == WorkflowType.WORKFLOW assert workflow.version == "draft" assert workflow.graph == graph assert workflow.created_by == created_by @@ -68,7 +68,7 @@ class TestWorkflowModelValidation: def test_workflow_type_enum_values(self): """Test WorkflowType enum values.""" # Assert - assert WorkflowType.WORKFLOW.value == "workflow" + assert WorkflowType.WORKFLOW == "workflow" assert WorkflowType.CHAT.value == "chat" assert WorkflowType.RAG_PIPELINE.value == "rag-pipeline" @@ -89,7 +89,7 @@ class TestWorkflowModelValidation: workflow = Workflow.new( tenant_id=str(uuid4()), app_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, + type=WorkflowType.WORKFLOW, version="draft", graph=json.dumps(graph_data), features="{}", @@ -114,7 +114,7 @@ class TestWorkflowModelValidation: workflow = Workflow.new( tenant_id=str(uuid4()), app_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, + type=WorkflowType.WORKFLOW, version="draft", graph="{}", features=json.dumps(features_data), @@ -138,7 +138,7 @@ class TestWorkflowModelValidation: workflow = Workflow.new( tenant_id=str(uuid4()), app_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, + type=WorkflowType.WORKFLOW, version="v1.0", graph="{}", features="{}", @@ -176,11 +176,11 @@ class TestWorkflowRunStateTransitions: tenant_id=tenant_id, app_id=app_id, workflow_id=workflow_id, - type=WorkflowType.WORKFLOW.value, - triggered_from=WorkflowRunTriggeredFrom.DEBUGGING.value, + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.DEBUGGING, version="draft", - status=WorkflowExecutionStatus.RUNNING.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowExecutionStatus.RUNNING, + created_by_role=CreatorUserRole.ACCOUNT, created_by=created_by, ) @@ -188,9 +188,9 @@ class TestWorkflowRunStateTransitions: assert workflow_run.tenant_id == tenant_id assert workflow_run.app_id == app_id assert workflow_run.workflow_id == workflow_id - assert workflow_run.type == WorkflowType.WORKFLOW.value - assert workflow_run.triggered_from == WorkflowRunTriggeredFrom.DEBUGGING.value - assert workflow_run.status == WorkflowExecutionStatus.RUNNING.value + assert workflow_run.type == WorkflowType.WORKFLOW + assert workflow_run.triggered_from == WorkflowRunTriggeredFrom.DEBUGGING + assert workflow_run.status == WorkflowExecutionStatus.RUNNING assert workflow_run.created_by == created_by def test_workflow_run_state_transition_running_to_succeeded(self): @@ -200,21 +200,21 @@ class TestWorkflowRunStateTransitions: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, - triggered_from=WorkflowRunTriggeredFrom.APP_RUN.value, + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, version="v1.0", - status=WorkflowExecutionStatus.RUNNING.value, - created_by_role=CreatorUserRole.END_USER.value, + status=WorkflowExecutionStatus.RUNNING, + created_by_role=CreatorUserRole.END_USER, created_by=str(uuid4()), ) # Act - workflow_run.status = WorkflowExecutionStatus.SUCCEEDED.value + workflow_run.status = WorkflowExecutionStatus.SUCCEEDED workflow_run.finished_at = datetime.now(UTC) workflow_run.elapsed_time = 2.5 # Assert - assert workflow_run.status == WorkflowExecutionStatus.SUCCEEDED.value + assert workflow_run.status == WorkflowExecutionStatus.SUCCEEDED assert workflow_run.finished_at is not None assert workflow_run.elapsed_time == 2.5 @@ -225,21 +225,21 @@ class TestWorkflowRunStateTransitions: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, - triggered_from=WorkflowRunTriggeredFrom.APP_RUN.value, + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, version="v1.0", - status=WorkflowExecutionStatus.RUNNING.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowExecutionStatus.RUNNING, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), ) # Act - workflow_run.status = WorkflowExecutionStatus.FAILED.value + workflow_run.status = WorkflowExecutionStatus.FAILED workflow_run.error = "Node execution failed: Invalid input" workflow_run.finished_at = datetime.now(UTC) # Assert - assert workflow_run.status == WorkflowExecutionStatus.FAILED.value + assert workflow_run.status == WorkflowExecutionStatus.FAILED assert workflow_run.error == "Node execution failed: Invalid input" assert workflow_run.finished_at is not None @@ -250,20 +250,20 @@ class TestWorkflowRunStateTransitions: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, - triggered_from=WorkflowRunTriggeredFrom.DEBUGGING.value, + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.DEBUGGING, version="draft", - status=WorkflowExecutionStatus.RUNNING.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowExecutionStatus.RUNNING, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), ) # Act - workflow_run.status = WorkflowExecutionStatus.STOPPED.value + workflow_run.status = WorkflowExecutionStatus.STOPPED workflow_run.finished_at = datetime.now(UTC) # Assert - assert workflow_run.status == WorkflowExecutionStatus.STOPPED.value + assert workflow_run.status == WorkflowExecutionStatus.STOPPED assert workflow_run.finished_at is not None def test_workflow_run_state_transition_running_to_paused(self): @@ -273,19 +273,19 @@ class TestWorkflowRunStateTransitions: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, - triggered_from=WorkflowRunTriggeredFrom.APP_RUN.value, + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, version="v1.0", - status=WorkflowExecutionStatus.RUNNING.value, - created_by_role=CreatorUserRole.END_USER.value, + status=WorkflowExecutionStatus.RUNNING, + created_by_role=CreatorUserRole.END_USER, created_by=str(uuid4()), ) # Act - workflow_run.status = WorkflowExecutionStatus.PAUSED.value + workflow_run.status = WorkflowExecutionStatus.PAUSED # Assert - assert workflow_run.status == WorkflowExecutionStatus.PAUSED.value + assert workflow_run.status == WorkflowExecutionStatus.PAUSED assert workflow_run.finished_at is None # Not finished when paused def test_workflow_run_state_transition_paused_to_running(self): @@ -295,19 +295,19 @@ class TestWorkflowRunStateTransitions: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, - triggered_from=WorkflowRunTriggeredFrom.APP_RUN.value, + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, version="v1.0", - status=WorkflowExecutionStatus.PAUSED.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowExecutionStatus.PAUSED, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), ) # Act - workflow_run.status = WorkflowExecutionStatus.RUNNING.value + workflow_run.status = WorkflowExecutionStatus.RUNNING # Assert - assert workflow_run.status == WorkflowExecutionStatus.RUNNING.value + assert workflow_run.status == WorkflowExecutionStatus.RUNNING def test_workflow_run_with_partial_succeeded_status(self): """Test workflow run with partial-succeeded status.""" @@ -316,17 +316,17 @@ class TestWorkflowRunStateTransitions: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, - triggered_from=WorkflowRunTriggeredFrom.APP_RUN.value, + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, version="v1.0", - status=WorkflowExecutionStatus.PARTIAL_SUCCEEDED.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowExecutionStatus.PARTIAL_SUCCEEDED, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), exceptions_count=2, ) # Assert - assert workflow_run.status == WorkflowExecutionStatus.PARTIAL_SUCCEEDED.value + assert workflow_run.status == WorkflowExecutionStatus.PARTIAL_SUCCEEDED assert workflow_run.exceptions_count == 2 def test_workflow_run_with_inputs_and_outputs(self): @@ -340,11 +340,11 @@ class TestWorkflowRunStateTransitions: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, - triggered_from=WorkflowRunTriggeredFrom.APP_RUN.value, + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, version="v1.0", - status=WorkflowExecutionStatus.SUCCEEDED.value, - created_by_role=CreatorUserRole.END_USER.value, + status=WorkflowExecutionStatus.SUCCEEDED, + created_by_role=CreatorUserRole.END_USER, created_by=str(uuid4()), inputs=json.dumps(inputs), outputs=json.dumps(outputs), @@ -362,11 +362,11 @@ class TestWorkflowRunStateTransitions: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, - triggered_from=WorkflowRunTriggeredFrom.DEBUGGING.value, + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.DEBUGGING, version="draft", - status=WorkflowExecutionStatus.RUNNING.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowExecutionStatus.RUNNING, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), graph=json.dumps(graph), ) @@ -391,11 +391,11 @@ class TestWorkflowRunStateTransitions: tenant_id=tenant_id, app_id=app_id, workflow_id=workflow_id, - type=WorkflowType.WORKFLOW.value, - triggered_from=WorkflowRunTriggeredFrom.APP_RUN.value, + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, version="v1.0", - status=WorkflowExecutionStatus.SUCCEEDED.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowExecutionStatus.SUCCEEDED, + created_by_role=CreatorUserRole.ACCOUNT, created_by=created_by, total_tokens=1500, total_steps=5, @@ -410,7 +410,7 @@ class TestWorkflowRunStateTransitions: assert result["tenant_id"] == tenant_id assert result["app_id"] == app_id assert result["workflow_id"] == workflow_id - assert result["status"] == WorkflowExecutionStatus.SUCCEEDED.value + assert result["status"] == WorkflowExecutionStatus.SUCCEEDED assert result["total_tokens"] == 1500 assert result["total_steps"] == 5 @@ -422,18 +422,18 @@ class TestWorkflowRunStateTransitions: "tenant_id": str(uuid4()), "app_id": str(uuid4()), "workflow_id": str(uuid4()), - "type": WorkflowType.WORKFLOW.value, - "triggered_from": WorkflowRunTriggeredFrom.APP_RUN.value, + "type": WorkflowType.WORKFLOW, + "triggered_from": WorkflowRunTriggeredFrom.APP_RUN, "version": "v1.0", "graph": {"nodes": [], "edges": []}, "inputs": {"query": "test"}, - "status": WorkflowExecutionStatus.SUCCEEDED.value, + "status": WorkflowExecutionStatus.SUCCEEDED, "outputs": {"result": "success"}, "error": None, "elapsed_time": 3.5, "total_tokens": 2000, "total_steps": 10, - "created_by_role": CreatorUserRole.ACCOUNT.value, + "created_by_role": CreatorUserRole.ACCOUNT, "created_by": str(uuid4()), "created_at": datetime.now(UTC), "finished_at": datetime.now(UTC), @@ -446,7 +446,7 @@ class TestWorkflowRunStateTransitions: # Assert assert workflow_run.id == data["id"] assert workflow_run.workflow_id == data["workflow_id"] - assert workflow_run.status == WorkflowExecutionStatus.SUCCEEDED.value + assert workflow_run.status == WorkflowExecutionStatus.SUCCEEDED assert workflow_run.total_tokens == 2000 @@ -467,14 +467,14 @@ class TestNodeExecutionRelationships: tenant_id=tenant_id, app_id=app_id, workflow_id=workflow_id, - triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value, + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, workflow_run_id=workflow_run_id, index=1, node_id="start", node_type=BuiltinNodeTypes.START, title="Start Node", - status=WorkflowNodeExecutionStatus.SUCCEEDED.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowNodeExecutionStatus.SUCCEEDED, + created_by_role=CreatorUserRole.ACCOUNT, created_by=created_by, ) @@ -498,15 +498,15 @@ class TestNodeExecutionRelationships: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value, + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, workflow_run_id=str(uuid4()), index=2, predecessor_node_id=predecessor_node_id, node_id=current_node_id, node_type=BuiltinNodeTypes.LLM, title="LLM Node", - status=WorkflowNodeExecutionStatus.RUNNING.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowNodeExecutionStatus.RUNNING, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), ) @@ -528,8 +528,8 @@ class TestNodeExecutionRelationships: node_id="llm_test", node_type=BuiltinNodeTypes.LLM, title="Test LLM", - status=WorkflowNodeExecutionStatus.SUCCEEDED.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowNodeExecutionStatus.SUCCEEDED, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), ) @@ -549,14 +549,14 @@ class TestNodeExecutionRelationships: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value, + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, workflow_run_id=str(uuid4()), index=1, node_id="llm_1", node_type=BuiltinNodeTypes.LLM, title="LLM Node", - status=WorkflowNodeExecutionStatus.SUCCEEDED.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowNodeExecutionStatus.SUCCEEDED, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), inputs=json.dumps(inputs), outputs=json.dumps(outputs), @@ -575,24 +575,24 @@ class TestNodeExecutionRelationships: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value, + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, workflow_run_id=str(uuid4()), index=1, node_id="code_1", node_type=BuiltinNodeTypes.CODE, title="Code Node", - status=WorkflowNodeExecutionStatus.RUNNING.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowNodeExecutionStatus.RUNNING, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), ) # Act - transition to succeeded - node_execution.status = WorkflowNodeExecutionStatus.SUCCEEDED.value + node_execution.status = WorkflowNodeExecutionStatus.SUCCEEDED node_execution.elapsed_time = 1.2 node_execution.finished_at = datetime.now(UTC) # Assert - assert node_execution.status == WorkflowNodeExecutionStatus.SUCCEEDED.value + assert node_execution.status == WorkflowNodeExecutionStatus.SUCCEEDED assert node_execution.elapsed_time == 1.2 assert node_execution.finished_at is not None @@ -606,20 +606,20 @@ class TestNodeExecutionRelationships: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value, + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, workflow_run_id=str(uuid4()), index=3, node_id="code_1", node_type=BuiltinNodeTypes.CODE, title="Code Node", - status=WorkflowNodeExecutionStatus.FAILED.value, + status=WorkflowNodeExecutionStatus.FAILED, error=error_message, - created_by_role=CreatorUserRole.ACCOUNT.value, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), ) # Assert - assert node_execution.status == WorkflowNodeExecutionStatus.FAILED.value + assert node_execution.status == WorkflowNodeExecutionStatus.FAILED assert node_execution.error == error_message def test_node_execution_with_metadata(self): @@ -637,14 +637,14 @@ class TestNodeExecutionRelationships: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value, + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, workflow_run_id=str(uuid4()), index=1, node_id="llm_1", node_type=BuiltinNodeTypes.LLM, title="LLM Node", - status=WorkflowNodeExecutionStatus.SUCCEEDED.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowNodeExecutionStatus.SUCCEEDED, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), execution_metadata=json.dumps(metadata), ) @@ -660,14 +660,14 @@ class TestNodeExecutionRelationships: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value, + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, workflow_run_id=str(uuid4()), index=1, node_id="start", node_type=BuiltinNodeTypes.START, title="Start", - status=WorkflowNodeExecutionStatus.SUCCEEDED.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowNodeExecutionStatus.SUCCEEDED, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), execution_metadata=None, ) @@ -696,14 +696,14 @@ class TestNodeExecutionRelationships: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value, + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, workflow_run_id=str(uuid4()), index=1, node_id=f"{node_type}_1", node_type=node_type, title=title, - status=WorkflowNodeExecutionStatus.SUCCEEDED.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowNodeExecutionStatus.SUCCEEDED, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), ) @@ -734,7 +734,7 @@ class TestGraphConfigurationValidation: workflow = Workflow.new( tenant_id=str(uuid4()), app_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, + type=WorkflowType.WORKFLOW, version="draft", graph=json.dumps(graph_config), features="{}", @@ -761,7 +761,7 @@ class TestGraphConfigurationValidation: workflow = Workflow.new( tenant_id=str(uuid4()), app_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, + type=WorkflowType.WORKFLOW, version="draft", graph=json.dumps(graph_config), features="{}", @@ -802,7 +802,7 @@ class TestGraphConfigurationValidation: workflow = Workflow.new( tenant_id=str(uuid4()), app_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, + type=WorkflowType.WORKFLOW, version="draft", graph=json.dumps(graph_config), features="{}", @@ -835,11 +835,11 @@ class TestGraphConfigurationValidation: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, - triggered_from=WorkflowRunTriggeredFrom.APP_RUN.value, + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, version="v1.0", - status=WorkflowExecutionStatus.RUNNING.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowExecutionStatus.RUNNING, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), graph=json.dumps(original_graph), ) @@ -872,7 +872,7 @@ class TestGraphConfigurationValidation: workflow = Workflow.new( tenant_id=str(uuid4()), app_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, + type=WorkflowType.WORKFLOW, version="draft", graph=json.dumps(graph_config), features="{}", @@ -912,7 +912,7 @@ class TestGraphConfigurationValidation: workflow = Workflow.new( tenant_id=str(uuid4()), app_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, + type=WorkflowType.WORKFLOW, version="draft", graph=json.dumps(graph_config), features="{}", @@ -933,7 +933,7 @@ class TestGraphConfigurationValidation: workflow = Workflow.new( tenant_id=str(uuid4()), app_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, + type=WorkflowType.WORKFLOW, version="draft", graph=None, features="{}", @@ -956,11 +956,11 @@ class TestGraphConfigurationValidation: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, - triggered_from=WorkflowRunTriggeredFrom.APP_RUN.value, + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, version="v1.0", - status=WorkflowExecutionStatus.RUNNING.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowExecutionStatus.RUNNING, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), inputs=None, ) @@ -978,11 +978,11 @@ class TestGraphConfigurationValidation: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - type=WorkflowType.WORKFLOW.value, - triggered_from=WorkflowRunTriggeredFrom.APP_RUN.value, + type=WorkflowType.WORKFLOW, + triggered_from=WorkflowRunTriggeredFrom.APP_RUN, version="v1.0", - status=WorkflowExecutionStatus.RUNNING.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowExecutionStatus.RUNNING, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), outputs=None, ) @@ -1000,14 +1000,14 @@ class TestGraphConfigurationValidation: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value, + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, workflow_run_id=str(uuid4()), index=1, node_id="start", node_type=BuiltinNodeTypes.START, title="Start", - status=WorkflowNodeExecutionStatus.SUCCEEDED.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowNodeExecutionStatus.SUCCEEDED, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), inputs=None, ) @@ -1025,14 +1025,14 @@ class TestGraphConfigurationValidation: tenant_id=str(uuid4()), app_id=str(uuid4()), workflow_id=str(uuid4()), - triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN.value, + triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN, workflow_run_id=str(uuid4()), index=1, node_id="start", node_type=BuiltinNodeTypes.START, title="Start", - status=WorkflowNodeExecutionStatus.SUCCEEDED.value, - created_by_role=CreatorUserRole.ACCOUNT.value, + status=WorkflowNodeExecutionStatus.SUCCEEDED, + created_by_role=CreatorUserRole.ACCOUNT, created_by=str(uuid4()), outputs=None, ) From 2d6babeeb49697154c43453cfab05a6fca22dbe5 Mon Sep 17 00:00:00 2001 From: jimmyzhuu Date: Tue, 28 Apr 2026 09:55:56 +0800 Subject: [PATCH 045/128] test: add Baidu OBS storage unit tests (#34330) --- api/tests/unit_tests/oss/__mock/baidu_obs.py | 70 +++++++++++++++++++ .../unit_tests/oss/baidu_obs/__init__.py | 1 + .../oss/baidu_obs/test_baidu_obs.py | 59 ++++++++++++++++ 3 files changed, 130 insertions(+) create mode 100644 api/tests/unit_tests/oss/__mock/baidu_obs.py create mode 100644 api/tests/unit_tests/oss/baidu_obs/__init__.py create mode 100644 api/tests/unit_tests/oss/baidu_obs/test_baidu_obs.py diff --git a/api/tests/unit_tests/oss/__mock/baidu_obs.py b/api/tests/unit_tests/oss/__mock/baidu_obs.py new file mode 100644 index 0000000000..d70a7c2eaa --- /dev/null +++ b/api/tests/unit_tests/oss/__mock/baidu_obs.py @@ -0,0 +1,70 @@ +import base64 +import hashlib +import os +from io import BytesIO +from types import SimpleNamespace + +import pytest +from _pytest.monkeypatch import MonkeyPatch +from baidubce.services.bos.bos_client import BosClient + +from tests.unit_tests.oss.__mock.base import ( + get_example_bucket, + get_example_data, + get_example_filename, + get_example_filepath, +) + + +class MockBaiduObsClass: + def __init__(self, config=None): + self.bucket_name = get_example_bucket() + self.key = get_example_filename() + self.content = get_example_data() + self.filepath = get_example_filepath() + + def put_object(self, bucket_name, key, data, content_length=None, content_md5=None, **kwargs): + assert bucket_name == self.bucket_name + assert key == self.key + assert data == self.content + assert content_length == len(self.content) + expected_md5 = base64.standard_b64encode(hashlib.md5(self.content).digest()) + assert content_md5 == expected_md5 + + def get_object(self, bucket_name, key, **kwargs): + assert bucket_name == self.bucket_name + assert key == self.key + return SimpleNamespace(data=BytesIO(self.content)) + + def get_object_to_file(self, bucket_name, key, file_name, **kwargs): + assert bucket_name == self.bucket_name + assert key == self.key + assert file_name == self.filepath + + def get_object_meta_data(self, bucket_name, key, **kwargs): + assert bucket_name == self.bucket_name + assert key == self.key + return SimpleNamespace(status=200) + + def delete_object(self, bucket_name, key, **kwargs): + assert bucket_name == self.bucket_name + assert key == self.key + + +MOCK = os.getenv("MOCK_SWITCH", "false").lower() == "true" + + +@pytest.fixture +def setup_baidu_obs_mock(monkeypatch: MonkeyPatch): + if MOCK: + monkeypatch.setattr(BosClient, "__init__", MockBaiduObsClass.__init__) + monkeypatch.setattr(BosClient, "put_object", MockBaiduObsClass.put_object) + monkeypatch.setattr(BosClient, "get_object", MockBaiduObsClass.get_object) + monkeypatch.setattr(BosClient, "get_object_to_file", MockBaiduObsClass.get_object_to_file) + monkeypatch.setattr(BosClient, "get_object_meta_data", MockBaiduObsClass.get_object_meta_data) + monkeypatch.setattr(BosClient, "delete_object", MockBaiduObsClass.delete_object) + + yield + + if MOCK: + monkeypatch.undo() diff --git a/api/tests/unit_tests/oss/baidu_obs/__init__.py b/api/tests/unit_tests/oss/baidu_obs/__init__.py new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/api/tests/unit_tests/oss/baidu_obs/__init__.py @@ -0,0 +1 @@ + diff --git a/api/tests/unit_tests/oss/baidu_obs/test_baidu_obs.py b/api/tests/unit_tests/oss/baidu_obs/test_baidu_obs.py new file mode 100644 index 0000000000..18ac762db8 --- /dev/null +++ b/api/tests/unit_tests/oss/baidu_obs/test_baidu_obs.py @@ -0,0 +1,59 @@ +from unittest.mock import MagicMock, patch + +import pytest +from baidubce.auth.bce_credentials import BceCredentials +from baidubce.bce_client_configuration import BceClientConfiguration + +from extensions.storage.baidu_obs_storage import BaiduObsStorage +from tests.unit_tests.oss.__mock.baidu_obs import setup_baidu_obs_mock +from tests.unit_tests.oss.__mock.base import ( + BaseStorageTest, + get_example_bucket, +) + + +class TestBaiduObs(BaseStorageTest): + @pytest.fixture(autouse=True) + def setup_method(self, setup_baidu_obs_mock): + """Executed before each test method.""" + with ( + patch.object(BceCredentials, "__init__", return_value=None), + patch.object(BceClientConfiguration, "__init__", return_value=None), + ): + self.storage = BaiduObsStorage() + self.storage.bucket_name = get_example_bucket() + + +class TestBaiduObsConfiguration: + def test_init_with_config(self): + mock_dify_config = MagicMock() + mock_dify_config.BAIDU_OBS_BUCKET_NAME = "test-bucket" + mock_dify_config.BAIDU_OBS_ACCESS_KEY = "test-access-key" + mock_dify_config.BAIDU_OBS_SECRET_KEY = "test-secret-key" + mock_dify_config.BAIDU_OBS_ENDPOINT = "https://bj.bcebos.com" + + mock_credentials = MagicMock(name="credentials") + mock_config = MagicMock(name="config") + mock_client = MagicMock(name="client") + + with ( + patch("extensions.storage.baidu_obs_storage.dify_config", mock_dify_config), + patch("extensions.storage.baidu_obs_storage.BceCredentials", return_value=mock_credentials) as credentials, + patch( + "extensions.storage.baidu_obs_storage.BceClientConfiguration", return_value=mock_config + ) as configuration, + patch("extensions.storage.baidu_obs_storage.BosClient", return_value=mock_client) as client_cls, + ): + storage = BaiduObsStorage() + + assert storage.bucket_name == "test-bucket" + assert storage.client == mock_client + credentials.assert_called_once_with( + access_key_id="test-access-key", + secret_access_key="test-secret-key", + ) + configuration.assert_called_once_with( + credentials=mock_credentials, + endpoint="https://bj.bcebos.com", + ) + client_cls.assert_called_once_with(config=mock_config) From cbb4cc5d76117d32c5b3dca94bbc0249e54c5e9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Tue, 28 Apr 2026 11:22:47 +0800 Subject: [PATCH 046/128] fix: show full checklist message tooltip instead of truncated (#35613) --- web/app/components/workflow/header/checklist/node-group.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/app/components/workflow/header/checklist/node-group.tsx b/web/app/components/workflow/header/checklist/node-group.tsx index 5cbddcc12a..a161c6cb87 100644 --- a/web/app/components/workflow/header/checklist/node-group.tsx +++ b/web/app/components/workflow/header/checklist/node-group.tsx @@ -49,17 +49,17 @@ export const ChecklistNodeGroup = memo(({
goToEnabled && onItemClick(item)} > - + {sub.message} {goToEnabled && ( -
+
{t('panel.goToFix', { ns: 'workflow' })} From 282561a861d150dca22b59d2d7d3ef3f617120be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?= Date: Tue, 28 Apr 2026 12:29:16 +0800 Subject: [PATCH 047/128] fix: align auto update time picker to the right (#35621) Co-authored-by: yyh Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- web/app/components/base/date-and-time-picker/types.ts | 2 +- .../reference-setting-modal/auto-update-setting/index.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/app/components/base/date-and-time-picker/types.ts b/web/app/components/base/date-and-time-picker/types.ts index 2773fb7bc7..7dda1d013c 100644 --- a/web/app/components/base/date-and-time-picker/types.ts +++ b/web/app/components/base/date-and-time-picker/types.ts @@ -1,4 +1,4 @@ -import type { Placement } from '@floating-ui/react' +import type { Placement } from '@langgenius/dify-ui/popover' import type { Dayjs } from 'dayjs' export enum ViewType { diff --git a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx index dc5d376eb3..d7d6fcd35f 100644 --- a/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx +++ b/web/app/components/plugins/reference-setting-modal/auto-update-setting/index.tsx @@ -105,7 +105,7 @@ const AutoUpdateSetting: FC = ({ const renderTimePickerTrigger = useCallback(({ inputElem, onClick, isOpen }: TriggerParams) => { return (
@@ -137,7 +137,7 @@ const AutoUpdateSetting: FC = ({ <>