diff --git a/api/.vscode/launch.json.example b/api/.vscode/launch.json.example index 092c66e798..6bdfa2c039 100644 --- a/api/.vscode/launch.json.example +++ b/api/.vscode/launch.json.example @@ -54,7 +54,7 @@ "--loglevel", "DEBUG", "-Q", - "dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor" + "dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,workflow_based_app_execution,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor" ] } ] diff --git a/api/configs/middleware/__init__.py b/api/configs/middleware/__init__.py index a15e42babf..0532a42371 100644 --- a/api/configs/middleware/__init__.py +++ b/api/configs/middleware/__init__.py @@ -259,11 +259,20 @@ class CeleryConfig(DatabaseConfig): description="Password of the Redis Sentinel master.", default=None, ) + CELERY_SENTINEL_SOCKET_TIMEOUT: PositiveFloat | None = Field( description="Timeout for Redis Sentinel socket operations in seconds.", default=0.1, ) + CELERY_TASK_ANNOTATIONS: dict[str, Any] | None = Field( + description=( + "Annotations for Celery tasks as a JSON mapping of task name -> options " + "(for example, rate limits or other task-specific settings)." + ), + default=None, + ) + @computed_field def CELERY_RESULT_BACKEND(self) -> str | None: if self.CELERY_BACKEND in ("database", "rabbitmq"): diff --git a/api/controllers/console/app/conversation.py b/api/controllers/console/app/conversation.py index c8b4e83ae6..5eb61493c3 100644 --- a/api/controllers/console/app/conversation.py +++ b/api/controllers/console/app/conversation.py @@ -599,7 +599,12 @@ def _get_conversation(app_model, conversation_id): db.session.execute( sa.update(Conversation) .where(Conversation.id == conversation_id, Conversation.read_at.is_(None)) - .values(read_at=naive_utc_now(), read_account_id=current_user.id) + # Keep updated_at unchanged when only marking a conversation as read. + .values( + read_at=naive_utc_now(), + read_account_id=current_user.id, + updated_at=Conversation.updated_at, + ) ) db.session.commit() db.session.refresh(conversation) diff --git a/api/core/tools/__base/tool.py b/api/core/tools/__base/tool.py index 24fc11aefc..c8048888b1 100644 --- a/api/core/tools/__base/tool.py +++ b/api/core/tools/__base/tool.py @@ -5,7 +5,7 @@ from collections.abc import Generator from copy import deepcopy from typing import TYPE_CHECKING, Any -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from models.model import File from core.model_runtime.entities.message_entities import PromptMessageTool @@ -226,7 +226,7 @@ class Tool(ABC): def create_file_message(self, file: File) -> ToolInvokeMessage: return ToolInvokeMessage( type=ToolInvokeMessage.MessageType.FILE, - message=ToolInvokeMessage.FileMessage(), + message=ToolInvokeMessage.FileMessage(file_marker="file_marker"), meta={"file": file}, ) diff --git a/api/extensions/ext_celery.py b/api/extensions/ext_celery.py index 9917b4c88a..7b6a73af52 100644 --- a/api/extensions/ext_celery.py +++ b/api/extensions/ext_celery.py @@ -80,8 +80,14 @@ def init_app(app: DifyApp) -> Celery: worker_hijack_root_logger=False, timezone=pytz.timezone(dify_config.LOG_TZ or "UTC"), task_ignore_result=True, + task_annotations=dify_config.CELERY_TASK_ANNOTATIONS, ) + if dify_config.CELERY_BACKEND == "redis": + celery_app.conf.update( + result_backend_transport_options=broker_transport_options, + ) + # Apply SSL configuration if enabled ssl_options = _get_celery_ssl_options() if ssl_options: diff --git a/api/tests/unit_tests/controllers/console/app/test_conversation_read_timestamp.py b/api/tests/unit_tests/controllers/console/app/test_conversation_read_timestamp.py new file mode 100644 index 0000000000..7bab73d6c6 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/app/test_conversation_read_timestamp.py @@ -0,0 +1,34 @@ +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from controllers.console.app.conversation import _get_conversation + + +def test_get_conversation_mark_read_keeps_updated_at_unchanged(): + app_model = SimpleNamespace(id="app-id") + account = SimpleNamespace(id="account-id") + conversation = MagicMock() + conversation.id = "conversation-id" + + with ( + patch("controllers.console.app.conversation.current_account_with_tenant", return_value=(account, None)), + patch("controllers.console.app.conversation.naive_utc_now", return_value=datetime(2026, 2, 9, 0, 0, 0)), + patch("controllers.console.app.conversation.db.session") as mock_session, + ): + mock_session.query.return_value.where.return_value.first.return_value = conversation + + _get_conversation(app_model, "conversation-id") + + statement = mock_session.execute.call_args[0][0] + compiled = statement.compile() + sql_text = str(compiled).lower() + compact_sql_text = sql_text.replace(" ", "") + params = compiled.params + + assert "updated_at=current_timestamp" not in compact_sql_text + assert "updated_at=conversations.updated_at" in compact_sql_text + assert "read_at=:read_at" in compact_sql_text + assert "read_account_id=:read_account_id" in compact_sql_text + assert params["read_at"] == datetime(2026, 2, 9, 0, 0, 0) + assert params["read_account_id"] == "account-id" diff --git a/api/tests/unit_tests/core/tools/test_base_tool.py b/api/tests/unit_tests/core/tools/test_base_tool.py new file mode 100644 index 0000000000..23d3e77c1d --- /dev/null +++ b/api/tests/unit_tests/core/tools/test_base_tool.py @@ -0,0 +1,211 @@ +from __future__ import annotations + +from collections.abc import Generator +from dataclasses import dataclass +from typing import Any, cast + +from core.app.entities.app_invoke_entities import InvokeFrom +from core.tools.__base.tool import Tool +from core.tools.__base.tool_runtime import ToolRuntime +from core.tools.entities.common_entities import I18nObject +from core.tools.entities.tool_entities import ToolEntity, ToolIdentity, ToolInvokeMessage, ToolProviderType + + +class DummyCastType: + def cast_value(self, value: Any) -> str: + return f"cast:{value}" + + +@dataclass +class DummyParameter: + name: str + type: DummyCastType + form: str = "llm" + required: bool = False + default: Any = None + options: list[Any] | None = None + llm_description: str | None = None + + +class DummyTool(Tool): + def __init__(self, entity: ToolEntity, runtime: ToolRuntime): + super().__init__(entity=entity, runtime=runtime) + self.result: ToolInvokeMessage | list[ToolInvokeMessage] | Generator[ToolInvokeMessage, None, None] = ( + self.create_text_message("default") + ) + self.runtime_parameter_overrides: list[Any] | None = None + self.last_invocation: dict[str, Any] | None = None + + def tool_provider_type(self) -> ToolProviderType: + return ToolProviderType.BUILT_IN + + def _invoke( + self, + user_id: str, + tool_parameters: dict[str, Any], + conversation_id: str | None = None, + app_id: str | None = None, + message_id: str | None = None, + ) -> ToolInvokeMessage | list[ToolInvokeMessage] | Generator[ToolInvokeMessage, None, None]: + self.last_invocation = { + "user_id": user_id, + "tool_parameters": tool_parameters, + "conversation_id": conversation_id, + "app_id": app_id, + "message_id": message_id, + } + return self.result + + def get_runtime_parameters( + self, + conversation_id: str | None = None, + app_id: str | None = None, + message_id: str | None = None, + ): + if self.runtime_parameter_overrides is not None: + return self.runtime_parameter_overrides + return super().get_runtime_parameters( + conversation_id=conversation_id, + app_id=app_id, + message_id=message_id, + ) + + +def _build_tool(runtime: ToolRuntime | None = None) -> DummyTool: + entity = ToolEntity( + identity=ToolIdentity(author="test", name="dummy", label=I18nObject(en_US="dummy"), provider="test"), + parameters=[], + description=None, + has_runtime_parameters=False, + ) + runtime = runtime or ToolRuntime(tenant_id="tenant-1", invoke_from=InvokeFrom.DEBUGGER, runtime_parameters={}) + return DummyTool(entity=entity, runtime=runtime) + + +def test_invoke_supports_single_message_and_parameter_casting(): + runtime = ToolRuntime( + tenant_id="tenant-1", + invoke_from=InvokeFrom.DEBUGGER, + runtime_parameters={"from_runtime": "runtime-value"}, + ) + tool = _build_tool(runtime) + tool.entity.parameters = cast( + Any, + [ + DummyParameter(name="unused", type=DummyCastType()), + DummyParameter(name="age", type=DummyCastType()), + ], + ) + tool.result = tool.create_text_message("ok") + + messages = list( + tool.invoke( + user_id="user-1", + tool_parameters={"age": "18", "raw": "keep"}, + conversation_id="conv-1", + app_id="app-1", + message_id="msg-1", + ) + ) + + assert len(messages) == 1 + assert messages[0].message.text == "ok" + assert tool.last_invocation == { + "user_id": "user-1", + "tool_parameters": {"age": "cast:18", "raw": "keep", "from_runtime": "runtime-value"}, + "conversation_id": "conv-1", + "app_id": "app-1", + "message_id": "msg-1", + } + + +def test_invoke_supports_list_and_generator_results(): + tool = _build_tool() + tool.result = [tool.create_text_message("a"), tool.create_text_message("b")] + list_messages = list(tool.invoke(user_id="user-1", tool_parameters={})) + assert [msg.message.text for msg in list_messages] == ["a", "b"] + + def _message_generator() -> Generator[ToolInvokeMessage, None, None]: + yield tool.create_text_message("g1") + yield tool.create_text_message("g2") + + tool.result = _message_generator() + generated_messages = list(tool.invoke(user_id="user-2", tool_parameters={})) + assert [msg.message.text for msg in generated_messages] == ["g1", "g2"] + + +def test_fork_tool_runtime_returns_new_tool_with_copied_entity(): + tool = _build_tool() + new_runtime = ToolRuntime(tenant_id="tenant-2", invoke_from=InvokeFrom.EXPLORE, runtime_parameters={}) + + forked = tool.fork_tool_runtime(new_runtime) + + assert isinstance(forked, DummyTool) + assert forked is not tool + assert forked.runtime == new_runtime + assert forked.entity == tool.entity + assert forked.entity is not tool.entity + + +def test_get_runtime_parameters_and_merge_runtime_parameters(): + tool = _build_tool() + original = DummyParameter(name="temperature", type=DummyCastType(), form="schema", required=True, default="0.7") + tool.entity.parameters = cast(Any, [original]) + + default_runtime_parameters = tool.get_runtime_parameters() + assert default_runtime_parameters == [original] + + override = DummyParameter(name="temperature", type=DummyCastType(), form="llm", required=False, default="0.5") + appended = DummyParameter(name="new_param", type=DummyCastType(), form="form", required=False, default="x") + tool.runtime_parameter_overrides = [override, appended] + + merged = tool.get_merged_runtime_parameters() + assert len(merged) == 2 + assert merged[0].name == "temperature" + assert merged[0].form == "llm" + assert merged[0].required is False + assert merged[0].default == "0.5" + assert merged[1].name == "new_param" + + +def test_message_factory_helpers(): + tool = _build_tool() + + image_message = tool.create_image_message("https://example.com/image.png") + assert image_message.type == ToolInvokeMessage.MessageType.IMAGE + assert image_message.message.text == "https://example.com/image.png" + + file_obj = object() + file_message = tool.create_file_message(file_obj) # type: ignore[arg-type] + assert file_message.type == ToolInvokeMessage.MessageType.FILE + assert file_message.message.file_marker == "file_marker" + assert file_message.meta == {"file": file_obj} + + link_message = tool.create_link_message("https://example.com") + assert link_message.type == ToolInvokeMessage.MessageType.LINK + assert link_message.message.text == "https://example.com" + + text_message = tool.create_text_message("hello") + assert text_message.type == ToolInvokeMessage.MessageType.TEXT + assert text_message.message.text == "hello" + + blob_message = tool.create_blob_message(b"blob", meta={"source": "unit-test"}) + assert blob_message.type == ToolInvokeMessage.MessageType.BLOB + assert blob_message.message.blob == b"blob" + assert blob_message.meta == {"source": "unit-test"} + + json_message = tool.create_json_message({"k": "v"}, suppress_output=True) + assert json_message.type == ToolInvokeMessage.MessageType.JSON + assert json_message.message.json_object == {"k": "v"} + assert json_message.message.suppress_output is True + + variable_message = tool.create_variable_message("answer", 42, stream=False) + assert variable_message.type == ToolInvokeMessage.MessageType.VARIABLE + assert variable_message.message.variable_name == "answer" + assert variable_message.message.variable_value == 42 + assert variable_message.message.stream is False + + +def test_base_abstract_invoke_placeholder_returns_none(): + tool = _build_tool() + assert Tool._invoke(tool, user_id="u", tool_parameters={}) is None diff --git a/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py b/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py index bbedfdb6ae..36fdb0218c 100644 --- a/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py +++ b/api/tests/unit_tests/core/tools/workflow_as_tool/test_tool.py @@ -255,6 +255,32 @@ def test_create_variable_message(): assert message.message.stream is False +def test_create_file_message_should_include_file_marker(): + entity = ToolEntity( + identity=ToolIdentity(author="test", name="test tool", label=I18nObject(en_US="test tool"), provider="test"), + parameters=[], + description=None, + has_runtime_parameters=False, + ) + runtime = ToolRuntime(tenant_id="test_tool", invoke_from=InvokeFrom.EXPLORE) + tool = WorkflowTool( + workflow_app_id="", + workflow_as_tool_id="", + version="1", + workflow_entities={}, + workflow_call_depth=1, + entity=entity, + runtime=runtime, + ) + + file_obj = object() + message = tool.create_file_message(file_obj) # type: ignore[arg-type] + + assert message.type == ToolInvokeMessage.MessageType.FILE + assert message.message.file_marker == "file_marker" + assert message.meta == {"file": file_obj} + + def test_resolve_user_from_database_falls_back_to_end_user(monkeypatch: pytest.MonkeyPatch): """Ensure worker context can resolve EndUser when Account is missing.""" diff --git a/docker/.env.example b/docker/.env.example index 01cca2b636..1018f04c12 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -62,6 +62,9 @@ LANG=C.UTF-8 LC_ALL=C.UTF-8 PYTHONIOENCODING=utf-8 +# Set UV cache directory to avoid permission issues with non-existent home directory +UV_CACHE_DIR=/tmp/.uv-cache + # ------------------------------ # Server Configuration # ------------------------------ @@ -389,6 +392,8 @@ CELERY_USE_SENTINEL=false CELERY_SENTINEL_MASTER_NAME= CELERY_SENTINEL_PASSWORD= CELERY_SENTINEL_SOCKET_TIMEOUT=0.1 +# e.g. {"tasks.add": {"rate_limit": "10/s"}} +CELERY_TASK_ANNOTATIONS=null # ------------------------------ # CORS Configuration diff --git a/docker/docker-compose.yaml b/docker/docker-compose.yaml index aeb87b0892..a5919a7a9a 100644 --- a/docker/docker-compose.yaml +++ b/docker/docker-compose.yaml @@ -16,6 +16,7 @@ x-shared-env: &shared-api-worker-env LANG: ${LANG:-C.UTF-8} LC_ALL: ${LC_ALL:-C.UTF-8} PYTHONIOENCODING: ${PYTHONIOENCODING:-utf-8} + UV_CACHE_DIR: ${UV_CACHE_DIR:-/tmp/.uv-cache} LOG_LEVEL: ${LOG_LEVEL:-INFO} LOG_OUTPUT_FORMAT: ${LOG_OUTPUT_FORMAT:-text} LOG_FILE: ${LOG_FILE:-/app/logs/server.log} @@ -106,6 +107,7 @@ x-shared-env: &shared-api-worker-env CELERY_SENTINEL_MASTER_NAME: ${CELERY_SENTINEL_MASTER_NAME:-} CELERY_SENTINEL_PASSWORD: ${CELERY_SENTINEL_PASSWORD:-} CELERY_SENTINEL_SOCKET_TIMEOUT: ${CELERY_SENTINEL_SOCKET_TIMEOUT:-0.1} + CELERY_TASK_ANNOTATIONS: ${CELERY_TASK_ANNOTATIONS:-null} WEB_API_CORS_ALLOW_ORIGINS: ${WEB_API_CORS_ALLOW_ORIGINS:-*} CONSOLE_CORS_ALLOW_ORIGINS: ${CONSOLE_CORS_ALLOW_ORIGINS:-*} COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} diff --git a/web/__tests__/workflow-parallel-limit.test.tsx b/web/__tests__/workflow-parallel-limit.test.tsx deleted file mode 100644 index ba3840ac3e..0000000000 --- a/web/__tests__/workflow-parallel-limit.test.tsx +++ /dev/null @@ -1,261 +0,0 @@ -/** - * MAX_PARALLEL_LIMIT Configuration Bug Test - * - * This test reproduces and verifies the fix for issue #23083: - * MAX_PARALLEL_LIMIT environment variable does not take effect in iteration panel - */ - -import { render, screen } from '@testing-library/react' -import * as React from 'react' - -// Mock environment variables before importing constants -const originalEnv = process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT - -// Test with different environment values -function setupEnvironment(value?: string) { - if (value) - process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT = value - else - delete process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT - - // Clear module cache to force re-evaluation - vi.resetModules() -} - -function restoreEnvironment() { - if (originalEnv) - process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT = originalEnv - else - delete process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT - - vi.resetModules() -} - -// Mock i18next with proper implementation -vi.mock('react-i18next', () => ({ - useTranslation: () => ({ - t: (key: string) => { - if (key.includes('MaxParallelismTitle')) - return 'Max Parallelism' - if (key.includes('MaxParallelismDesc')) - return 'Maximum number of parallel executions' - if (key.includes('parallelMode')) - return 'Parallel Mode' - if (key.includes('parallelPanelDesc')) - return 'Enable parallel execution' - if (key.includes('errorResponseMethod')) - return 'Error Response Method' - return key - }, - }), - initReactI18next: { - type: '3rdParty', - init: vi.fn(), - }, -})) - -// Mock i18next module completely to prevent initialization issues -vi.mock('i18next', () => ({ - use: vi.fn().mockReturnThis(), - init: vi.fn().mockReturnThis(), - t: vi.fn(key => key), - isInitialized: true, -})) - -// Mock the useConfig hook -vi.mock('@/app/components/workflow/nodes/iteration/use-config', () => ({ - default: () => ({ - inputs: { - is_parallel: true, - parallel_nums: 5, - error_handle_mode: 'terminated', - }, - changeParallel: vi.fn(), - changeParallelNums: vi.fn(), - changeErrorHandleMode: vi.fn(), - }), -})) - -// Mock other components -vi.mock('@/app/components/workflow/nodes/_base/components/variable/var-reference-picker', () => ({ - default: function MockVarReferencePicker() { - return
VarReferencePicker
- }, -})) - -vi.mock('@/app/components/workflow/nodes/_base/components/split', () => ({ - default: function MockSplit() { - return
Split
- }, -})) - -vi.mock('@/app/components/workflow/nodes/_base/components/field', () => ({ - default: function MockField({ title, children }: { title: string, children: React.ReactNode }) { - return ( -
- - {children} -
- ) - }, -})) - -const getParallelControls = () => ({ - numberInput: screen.getByRole('spinbutton'), - slider: screen.getByRole('slider'), -}) - -describe('MAX_PARALLEL_LIMIT Configuration Bug', () => { - const mockNodeData = { - id: 'test-iteration-node', - type: 'iteration' as const, - data: { - title: 'Test Iteration', - desc: 'Test iteration node', - iterator_selector: ['test'], - output_selector: ['output'], - is_parallel: true, - parallel_nums: 5, - error_handle_mode: 'terminated' as const, - }, - } - - beforeEach(() => { - vi.clearAllMocks() - }) - - afterEach(() => { - restoreEnvironment() - }) - - afterAll(() => { - restoreEnvironment() - }) - - describe('Environment Variable Parsing', () => { - it('should parse MAX_PARALLEL_LIMIT from NEXT_PUBLIC_MAX_PARALLEL_LIMIT environment variable', async () => { - setupEnvironment('25') - const { MAX_PARALLEL_LIMIT } = await import('@/config') - expect(MAX_PARALLEL_LIMIT).toBe(25) - }) - - it('should fallback to default when environment variable is not set', async () => { - setupEnvironment() // No environment variable - const { MAX_PARALLEL_LIMIT } = await import('@/config') - expect(MAX_PARALLEL_LIMIT).toBe(10) - }) - - it('should handle invalid environment variable values', async () => { - setupEnvironment('invalid') - const { MAX_PARALLEL_LIMIT } = await import('@/config') - - // Should fall back to default when parsing fails - expect(MAX_PARALLEL_LIMIT).toBe(10) - }) - - it('should handle empty environment variable', async () => { - setupEnvironment('') - const { MAX_PARALLEL_LIMIT } = await import('@/config') - - // Should fall back to default when empty - expect(MAX_PARALLEL_LIMIT).toBe(10) - }) - - // Edge cases for boundary values - it('should clamp MAX_PARALLEL_LIMIT to MIN when env is 0 or negative', async () => { - setupEnvironment('0') - let { MAX_PARALLEL_LIMIT } = await import('@/config') - expect(MAX_PARALLEL_LIMIT).toBe(10) // Falls back to default - - setupEnvironment('-5') - ;({ MAX_PARALLEL_LIMIT } = await import('@/config')) - expect(MAX_PARALLEL_LIMIT).toBe(10) // Falls back to default - }) - - it('should handle float numbers by parseInt behavior', async () => { - setupEnvironment('12.7') - const { MAX_PARALLEL_LIMIT } = await import('@/config') - // parseInt truncates to integer - expect(MAX_PARALLEL_LIMIT).toBe(12) - }) - }) - - describe('UI Component Integration (Main Fix Verification)', () => { - it('should render iteration panel with environment-configured max value', async () => { - // Set environment variable to a different value - setupEnvironment('30') - - // Import Panel after setting environment - const Panel = await import('@/app/components/workflow/nodes/iteration/panel').then(mod => mod.default) - const { MAX_PARALLEL_LIMIT } = await import('@/config') - - render( - , - ) - - // Behavior-focused assertion: UI max should equal MAX_PARALLEL_LIMIT - const { numberInput, slider } = getParallelControls() - expect(numberInput).toHaveAttribute('max', String(MAX_PARALLEL_LIMIT)) - expect(slider).toHaveAttribute('aria-valuemax', String(MAX_PARALLEL_LIMIT)) - - // Verify the actual values - expect(MAX_PARALLEL_LIMIT).toBe(30) - expect(numberInput.getAttribute('max')).toBe('30') - expect(slider.getAttribute('aria-valuemax')).toBe('30') - }) - - it('should maintain UI consistency with different environment values', async () => { - setupEnvironment('15') - const Panel = await import('@/app/components/workflow/nodes/iteration/panel').then(mod => mod.default) - const { MAX_PARALLEL_LIMIT } = await import('@/config') - - render( - , - ) - - // Both input and slider should use the same max value from MAX_PARALLEL_LIMIT - const { numberInput, slider } = getParallelControls() - - expect(numberInput.getAttribute('max')).toBe(slider.getAttribute('aria-valuemax')) - expect(numberInput.getAttribute('max')).toBe(String(MAX_PARALLEL_LIMIT)) - }) - }) - - describe('Legacy Constant Verification (For Transition Period)', () => { - // Marked as transition/deprecation tests - it('should maintain MAX_ITERATION_PARALLEL_NUM for backward compatibility', async () => { - const { MAX_ITERATION_PARALLEL_NUM } = await import('@/app/components/workflow/constants') - expect(typeof MAX_ITERATION_PARALLEL_NUM).toBe('number') - expect(MAX_ITERATION_PARALLEL_NUM).toBe(10) // Hardcoded legacy value - }) - - it('should demonstrate MAX_PARALLEL_LIMIT vs legacy constant difference', async () => { - setupEnvironment('50') - const { MAX_PARALLEL_LIMIT } = await import('@/config') - const { MAX_ITERATION_PARALLEL_NUM } = await import('@/app/components/workflow/constants') - - // MAX_PARALLEL_LIMIT is configurable, MAX_ITERATION_PARALLEL_NUM is not - expect(MAX_PARALLEL_LIMIT).toBe(50) - expect(MAX_ITERATION_PARALLEL_NUM).toBe(10) - expect(MAX_PARALLEL_LIMIT).not.toBe(MAX_ITERATION_PARALLEL_NUM) - }) - }) - - describe('Constants Validation', () => { - it('should validate that required constants exist and have correct types', async () => { - const { MAX_PARALLEL_LIMIT } = await import('@/config') - const { MIN_ITERATION_PARALLEL_NUM } = await import('@/app/components/workflow/constants') - expect(typeof MAX_PARALLEL_LIMIT).toBe('number') - expect(typeof MIN_ITERATION_PARALLEL_NUM).toBe('number') - expect(MAX_PARALLEL_LIMIT).toBeGreaterThanOrEqual(MIN_ITERATION_PARALLEL_NUM) - }) - }) -}) diff --git a/web/app/components/base/form/components/field/input-type-select/types.tsx b/web/app/components/base/form/components/field/input-type-select/types.tsx index abf4bbd2a7..6104ea26b2 100644 --- a/web/app/components/base/form/components/field/input-type-select/types.tsx +++ b/web/app/components/base/form/components/field/input-type-select/types.tsx @@ -1,5 +1,5 @@ import type { RemixiconComponentType } from '@remixicon/react' -import { z } from 'zod' +import * as z from 'zod' export const InputTypeEnum = z.enum([ 'text-input', diff --git a/web/app/components/base/form/form-scenarios/base/utils.ts b/web/app/components/base/form/form-scenarios/base/utils.ts index 2c617aa1c6..221d43e000 100644 --- a/web/app/components/base/form/form-scenarios/base/utils.ts +++ b/web/app/components/base/form/form-scenarios/base/utils.ts @@ -1,6 +1,6 @@ import type { ZodNumber, ZodSchema, ZodString } from 'zod' import type { BaseConfiguration } from './types' -import { z } from 'zod' +import * as z from 'zod' import { BaseFieldType } from './types' export const generateZodSchema = (fields: BaseConfiguration[]) => { diff --git a/web/app/components/base/form/form-scenarios/demo/types.ts b/web/app/components/base/form/form-scenarios/demo/types.ts index c4e626ef63..91ab1c7747 100644 --- a/web/app/components/base/form/form-scenarios/demo/types.ts +++ b/web/app/components/base/form/form-scenarios/demo/types.ts @@ -1,4 +1,4 @@ -import { z } from 'zod' +import * as z from 'zod' const ContactMethod = z.union([ z.literal('email'), @@ -22,10 +22,10 @@ export const UserSchema = z.object({ .min(3, 'Surname must be at least 3 characters long') .regex(/^[A-Z]/, 'Surname must start with a capital letter'), isAcceptingTerms: z.boolean().refine(val => val, { - message: 'You must accept the terms and conditions', + error: 'You must accept the terms and conditions', }), contact: z.object({ - email: z.string().email('Invalid email address'), + email: z.email('Invalid email address'), phone: z.string().optional(), preferredContactMethod: ContactMethod, }), diff --git a/web/app/components/base/form/form-scenarios/input-field/utils.ts b/web/app/components/base/form/form-scenarios/input-field/utils.ts index cd670c448c..151d7979b8 100644 --- a/web/app/components/base/form/form-scenarios/input-field/utils.ts +++ b/web/app/components/base/form/form-scenarios/input-field/utils.ts @@ -1,6 +1,6 @@ import type { ZodSchema, ZodString } from 'zod' import type { InputFieldConfiguration } from './types' -import { z } from 'zod' +import * as z from 'zod' import { SupportedFileTypes, TransferMethod } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/schema' import { InputFieldType } from './types' diff --git a/web/app/components/base/param-item/top-k-item.tsx b/web/app/components/base/param-item/top-k-item.tsx index 2692875df1..9e9b7323db 100644 --- a/web/app/components/base/param-item/top-k-item.tsx +++ b/web/app/components/base/param-item/top-k-item.tsx @@ -2,6 +2,7 @@ import type { FC } from 'react' import * as React from 'react' import { useTranslation } from 'react-i18next' +import { env } from '@/env' import ParamItem from '.' type Props = { @@ -11,12 +12,7 @@ type Props = { enable: boolean } -const maxTopK = (() => { - const configValue = Number.parseInt(globalThis.document?.body?.getAttribute('data-public-top-k-max-value') || '', 10) - if (configValue && !isNaN(configValue)) - return configValue - return 10 -})() +const maxTopK = env.NEXT_PUBLIC_TOP_K_MAX_VALUE const VALUE_LIMIT = { default: 2, step: 1, diff --git a/web/app/components/base/with-input-validation/index.spec.tsx b/web/app/components/base/with-input-validation/index.spec.tsx index daf3fd9a74..3bfcbfc9e4 100644 --- a/web/app/components/base/with-input-validation/index.spec.tsx +++ b/web/app/components/base/with-input-validation/index.spec.tsx @@ -1,6 +1,6 @@ import { render, screen } from '@testing-library/react' import { noop } from 'es-toolkit/function' -import { z } from 'zod' +import * as z from 'zod' import withValidation from '.' describe('withValidation HOC', () => { diff --git a/web/app/components/base/with-input-validation/index.stories.tsx b/web/app/components/base/with-input-validation/index.stories.tsx index cb06d45956..bd5230c68b 100644 --- a/web/app/components/base/with-input-validation/index.stories.tsx +++ b/web/app/components/base/with-input-validation/index.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/nextjs-vite' -import { z } from 'zod' +import * as z from 'zod' import withValidation from '.' // Sample components to wrap with validation @@ -65,7 +65,7 @@ const ProductCard = ({ name, price, category, inStock }: ProductCardProps) => { // Create validated versions const userSchema = z.object({ name: z.string().min(1, 'Name is required'), - email: z.string().email('Invalid email'), + email: z.email('Invalid email'), age: z.number().min(0).max(150), }) @@ -371,7 +371,7 @@ export const ConfigurationValidation: Story = { ) const configSchema = z.object({ - apiUrl: z.string().url('Must be valid URL'), + apiUrl: z.url('Must be valid URL'), timeout: z.number().min(0).max(30000), retries: z.number().min(0).max(5), debug: z.boolean(), @@ -430,7 +430,7 @@ export const UsageDocumentation: Story = {

Usage Example

-            {`import { z } from 'zod'
+            {`import * as z from 'zod'
 import withValidation from './withValidation'
 
 // Define your component
diff --git a/web/app/components/datasets/create/step-two/components/inputs.tsx b/web/app/components/datasets/create/step-two/components/inputs.tsx
index 38e46c90f1..7c65d04d23 100644
--- a/web/app/components/datasets/create/step-two/components/inputs.tsx
+++ b/web/app/components/datasets/create/step-two/components/inputs.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'
 import Input from '@/app/components/base/input'
 import { InputNumber } from '@/app/components/base/input-number'
 import Tooltip from '@/app/components/base/tooltip'
+import { env } from '@/env'
 
 const TextLabel: FC = (props) => {
   return 
@@ -46,7 +47,7 @@ export const DelimiterInput: FC = (props) =>
 }
 
 export const MaxLengthInput: FC = (props) => {
-  const maxValue = Number.parseInt(globalThis.document?.body?.getAttribute('data-public-indexing-max-segmentation-tokens-length') || '4000', 10)
+  const maxValue = env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH
 
   const { t } = useTranslation()
   return (
diff --git a/web/app/components/datasets/create/step-two/hooks/use-segmentation-state.ts b/web/app/components/datasets/create/step-two/hooks/use-segmentation-state.ts
index 503704276e..abef8a98cb 100644
--- a/web/app/components/datasets/create/step-two/hooks/use-segmentation-state.ts
+++ b/web/app/components/datasets/create/step-two/hooks/use-segmentation-state.ts
@@ -1,5 +1,6 @@
 import type { ParentMode, PreProcessingRule, ProcessRule, Rules, SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
 import { useCallback, useRef, useState } from 'react'
+import { env } from '@/env'
 import { ChunkingMode, ProcessMode } from '@/models/datasets'
 import escape from './escape'
 import unescape from './unescape'
@@ -8,10 +9,7 @@ import unescape from './unescape'
 export const DEFAULT_SEGMENT_IDENTIFIER = '\\n\\n'
 export const DEFAULT_MAXIMUM_CHUNK_LENGTH = 1024
 export const DEFAULT_OVERLAP = 50
-export const MAXIMUM_CHUNK_TOKEN_LENGTH = Number.parseInt(
-  globalThis.document?.body?.getAttribute('data-public-indexing-max-segmentation-tokens-length') || '4000',
-  10,
-)
+export const MAXIMUM_CHUNK_TOKEN_LENGTH = env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH
 
 export type ParentChildConfig = {
   chunkForContext: ParentMode
diff --git a/web/app/components/datasets/documents/create-from-pipeline/process-documents/components.spec.tsx b/web/app/components/datasets/documents/create-from-pipeline/process-documents/components.spec.tsx
index 322e6edd49..6f47575b27 100644
--- a/web/app/components/datasets/documents/create-from-pipeline/process-documents/components.spec.tsx
+++ b/web/app/components/datasets/documents/create-from-pipeline/process-documents/components.spec.tsx
@@ -1,7 +1,7 @@
 import type { BaseConfiguration } from '@/app/components/base/form/form-scenarios/base/types'
 import { fireEvent, render, screen, waitFor } from '@testing-library/react'
 import * as React from 'react'
-import { z } from 'zod'
+import * as z from 'zod'
 import { BaseFieldType } from '@/app/components/base/form/form-scenarios/base/types'
 import Toast from '@/app/components/base/toast'
 import Actions from './actions'
@@ -53,7 +53,7 @@ const createFailingSchema = () => {
         issues: [{ path: ['field1'], message: 'is required' }],
       },
     }),
-  } as unknown as z.ZodSchema
+  } as unknown as z.ZodType
 }
 
 // ==========================================
diff --git a/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx b/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx
new file mode 100644
index 0000000000..d6f6e72da2
--- /dev/null
+++ b/web/app/components/datasets/documents/detail/metadata/components/doc-type-selector.tsx
@@ -0,0 +1,129 @@
+'use client'
+import type { FC } from 'react'
+import type { DocType } from '@/models/datasets'
+import { useTranslation } from 'react-i18next'
+import Button from '@/app/components/base/button'
+import Radio from '@/app/components/base/radio'
+import Tooltip from '@/app/components/base/tooltip'
+import { useMetadataMap } from '@/hooks/use-metadata'
+import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets'
+import { cn } from '@/utils/classnames'
+import s from '../style.module.css'
+
+const TypeIcon: FC<{ iconName: string, className?: string }> = ({ iconName, className = '' }) => {
+  return 
+} + +const IconButton: FC<{ type: DocType, isChecked: boolean }> = ({ type, isChecked = false }) => { + const metadataMap = useMetadataMap() + return ( + + + + ) +} + +type DocTypeSelectorProps = { + docType: DocType | '' + documentType?: DocType | '' + tempDocType: DocType | '' + onTempDocTypeChange: (type: DocType | '') => void + onConfirm: () => void + onCancel: () => void +} + +const DocTypeSelector: FC = ({ + docType, + documentType, + tempDocType, + onTempDocTypeChange, + onConfirm, + onCancel, +}) => { + const { t } = useTranslation() + const isFirstTime = !docType && !documentType + const currValue = tempDocType ?? documentType + + return ( + <> + {isFirstTime && ( +
{t('metadata.desc', { ns: 'datasetDocuments' })}
+ )} +
+ {isFirstTime && ( + {t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })} + )} + {documentType && ( + <> + {t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })} + {t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })} + + )} + + {CUSTOMIZABLE_DOC_TYPES.map(type => ( + + + + ))} + + {isFirstTime && ( + + )} + {documentType && ( +
+ + +
+ )} +
+ + ) +} + +type DocumentTypeDisplayProps = { + displayType: DocType | '' + showChangeLink?: boolean + onChangeClick?: () => void +} + +export const DocumentTypeDisplay: FC = ({ + displayType, + showChangeLink = false, + onChangeClick, +}) => { + const { t } = useTranslation() + const metadataMap = useMetadataMap() + const effectiveType = displayType || 'book' + + return ( +
+ {(displayType || !showChangeLink) && ( + <> + + {metadataMap[effectiveType].text} + {showChangeLink && ( +
+ · +
+ {t('operation.change', { ns: 'common' })} +
+
+ )} + + )} +
+ ) +} + +export default DocTypeSelector diff --git a/web/app/components/datasets/documents/detail/metadata/components/field-info.tsx b/web/app/components/datasets/documents/detail/metadata/components/field-info.tsx new file mode 100644 index 0000000000..fca21dd165 --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/field-info.tsx @@ -0,0 +1,89 @@ +'use client' +import type { FC, ReactNode } from 'react' +import type { inputType } from '@/hooks/use-metadata' +import { useTranslation } from 'react-i18next' +import AutoHeightTextarea from '@/app/components/base/auto-height-textarea' +import Input from '@/app/components/base/input' +import { SimpleSelect } from '@/app/components/base/select' +import { getTextWidthWithCanvas } from '@/utils' +import { cn } from '@/utils/classnames' +import s from '../style.module.css' + +type FieldInfoProps = { + label: string + value?: string + valueIcon?: ReactNode + displayedValue?: string + defaultValue?: string + showEdit?: boolean + inputType?: inputType + selectOptions?: Array<{ value: string, name: string }> + onUpdate?: (v: string) => void +} + +const FieldInfo: FC = ({ + label, + value = '', + valueIcon, + displayedValue = '', + defaultValue, + showEdit = false, + inputType = 'input', + selectOptions = [], + onUpdate, +}) => { + const { t } = useTranslation() + const textNeedWrap = getTextWidthWithCanvas(displayedValue) > 190 + const editAlignTop = showEdit && inputType === 'textarea' + const readAlignTop = !showEdit && textNeedWrap + + const renderContent = () => { + if (!showEdit) + return displayedValue + + if (inputType === 'select') { + return ( + onUpdate?.(value as string)} + items={selectOptions} + defaultValue={value} + className={s.select} + wrapperClassName={s.selectWrapper} + placeholder={`${t('metadata.placeholder.select', { ns: 'datasetDocuments' })}${label}`} + /> + ) + } + + if (inputType === 'textarea') { + return ( + onUpdate?.(e.target.value)} + value={value} + className={s.textArea} + placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`} + /> + ) + } + + return ( + onUpdate?.(e.target.value)} + value={value} + defaultValue={defaultValue} + placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`} + /> + ) + } + + return ( +
+
{label}
+
+ {valueIcon} + {renderContent()} +
+
+ ) +} + +export default FieldInfo diff --git a/web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx b/web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx new file mode 100644 index 0000000000..9f452279ed --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/components/metadata-field-list.tsx @@ -0,0 +1,88 @@ +'use client' +import type { FC } from 'react' +import type { metadataType } from '@/hooks/use-metadata' +import type { FullDocumentDetail } from '@/models/datasets' +import { get } from 'es-toolkit/compat' +import { useBookCategories, useBusinessDocCategories, useLanguages, useMetadataMap, usePersonalDocCategories } from '@/hooks/use-metadata' +import FieldInfo from './field-info' + +const map2Options = (map: Record) => { + return Object.keys(map).map(key => ({ value: key, name: map[key] })) +} + +function useCategoryMapResolver(mainField: metadataType | '') { + const languageMap = useLanguages() + const bookCategoryMap = useBookCategories() + const personalDocCategoryMap = usePersonalDocCategories() + const businessDocCategoryMap = useBusinessDocCategories() + + return (field: string): Record => { + if (field === 'language') + return languageMap + if (field === 'category' && mainField === 'book') + return bookCategoryMap + if (field === 'document_type') { + if (mainField === 'personal_document') + return personalDocCategoryMap + if (mainField === 'business_document') + return businessDocCategoryMap + } + return {} + } +} + +type MetadataFieldListProps = { + mainField: metadataType | '' + canEdit?: boolean + metadata?: Record + docDetail?: FullDocumentDetail + onFieldUpdate?: (field: string, value: string) => void +} + +const MetadataFieldList: FC = ({ + mainField, + canEdit = false, + metadata, + docDetail, + onFieldUpdate, +}) => { + const metadataMap = useMetadataMap() + const getCategoryMap = useCategoryMapResolver(mainField) + + if (!mainField) + return null + + const fieldMap = metadataMap[mainField]?.subFieldsMap + const isFixedField = ['originInfo', 'technicalParameters'].includes(mainField) + const sourceData = isFixedField ? docDetail : metadata + + const getDisplayValue = (field: string) => { + const val = get(sourceData, field, '') + if (!val && val !== 0) + return '-' + if (fieldMap[field]?.inputType === 'select') + return getCategoryMap(field)[val] + if (fieldMap[field]?.render) + return fieldMap[field]?.render?.(val, field === 'hit_count' ? get(sourceData, 'segment_count', 0) as number : undefined) + return val + } + + return ( +
+ {Object.keys(fieldMap).map(field => ( + onFieldUpdate?.(field, val)} + selectOptions={map2Options(getCategoryMap(field))} + /> + ))} +
+ ) +} + +export default MetadataFieldList diff --git a/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-state.ts b/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-state.ts new file mode 100644 index 0000000000..08651b699e --- /dev/null +++ b/web/app/components/datasets/documents/detail/metadata/hooks/use-metadata-state.ts @@ -0,0 +1,137 @@ +'use client' +import type { CommonResponse } from '@/models/common' +import type { DocType, FullDocumentDetail } from '@/models/datasets' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useContext } from 'use-context-selector' +import { ToastContext } from '@/app/components/base/toast' +import { modifyDocMetadata } from '@/service/datasets' +import { asyncRunSafe } from '@/utils' +import { useDocumentContext } from '../../context' + +type MetadataState = { + documentType?: DocType | '' + metadata: Record +} + +/** + * Normalize raw doc_type: treat 'others' as empty string. + */ +const normalizeDocType = (rawDocType: string): DocType | '' => { + return rawDocType === 'others' ? '' : rawDocType as DocType | '' +} + +type UseMetadataStateOptions = { + docDetail?: FullDocumentDetail + onUpdate?: () => void +} + +export function useMetadataState({ docDetail, onUpdate }: UseMetadataStateOptions) { + const { doc_metadata = {} } = docDetail || {} + const rawDocType = docDetail?.doc_type ?? '' + const docType = normalizeDocType(rawDocType) + + const { t } = useTranslation() + const { notify } = useContext(ToastContext) + const datasetId = useDocumentContext(s => s.datasetId) + const documentId = useDocumentContext(s => s.documentId) + + // If no documentType yet, start in editing + showDocTypes mode + const [editStatus, setEditStatus] = useState(!docType) + const [metadataParams, setMetadataParams] = useState( + docType + ? { documentType: docType, metadata: (doc_metadata || {}) as Record } + : { metadata: {} }, + ) + const [showDocTypes, setShowDocTypes] = useState(!docType) + const [tempDocType, setTempDocType] = useState('') + const [saveLoading, setSaveLoading] = useState(false) + + // Sync local state when the upstream docDetail changes (e.g. after save or navigation). + // These setters are intentionally called together to batch-reset multiple pieces + // of derived editing state that cannot be expressed as pure derived values. + useEffect(() => { + if (docDetail?.doc_type) { + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setEditStatus(false) + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setShowDocTypes(false) + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setTempDocType(docType) + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setMetadataParams({ + documentType: docType, + metadata: (docDetail?.doc_metadata || {}) as Record, + }) + } + }, [docDetail?.doc_type, docDetail?.doc_metadata, docType]) + + const confirmDocType = () => { + if (!tempDocType) + return + setMetadataParams({ + documentType: tempDocType, + // Clear metadata when switching to a different doc type + metadata: tempDocType === metadataParams.documentType ? metadataParams.metadata : {}, + }) + setEditStatus(true) + setShowDocTypes(false) + } + + const cancelDocType = () => { + setTempDocType(metadataParams.documentType ?? '') + setEditStatus(true) + setShowDocTypes(false) + } + + const enableEdit = () => { + setEditStatus(true) + } + + const cancelEdit = () => { + setMetadataParams({ documentType: docType || '', metadata: { ...(docDetail?.doc_metadata || {}) } }) + setEditStatus(!docType) + if (!docType) + setShowDocTypes(true) + } + + const saveMetadata = async () => { + setSaveLoading(true) + const [e] = await asyncRunSafe(modifyDocMetadata({ + datasetId, + documentId, + body: { + doc_type: metadataParams.documentType || docType || '', + doc_metadata: metadataParams.metadata, + }, + }) as Promise) + if (!e) + notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) }) + else + notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) }) + onUpdate?.() + setEditStatus(false) + setSaveLoading(false) + } + + const updateMetadataField = (field: string, value: string) => { + setMetadataParams(prev => ({ ...prev, metadata: { ...prev.metadata, [field]: value } })) + } + + return { + docType, + editStatus, + showDocTypes, + tempDocType, + saveLoading, + metadataParams, + setTempDocType, + setShowDocTypes, + confirmDocType, + cancelDocType, + enableEdit, + cancelEdit, + saveMetadata, + updateMetadataField, + } +} diff --git a/web/app/components/datasets/documents/detail/metadata/index.spec.tsx b/web/app/components/datasets/documents/detail/metadata/index.spec.tsx index 367449f1b9..6efc9661d5 100644 --- a/web/app/components/datasets/documents/detail/metadata/index.spec.tsx +++ b/web/app/components/datasets/documents/detail/metadata/index.spec.tsx @@ -1,7 +1,6 @@ import type { FullDocumentDetail } from '@/models/datasets' import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { beforeEach, describe, expect, it, vi } from 'vitest' - import Metadata, { FieldInfo } from './index' // Mock document context @@ -121,7 +120,6 @@ vi.mock('@/hooks/use-metadata', () => ({ }), })) -// Mock getTextWidthWithCanvas vi.mock('@/utils', () => ({ asyncRunSafe: async (promise: Promise) => { try { @@ -135,33 +133,32 @@ vi.mock('@/utils', () => ({ getTextWidthWithCanvas: () => 100, })) +const createMockDocDetail = (overrides = {}): FullDocumentDetail => ({ + id: 'doc-1', + name: 'Test Document', + doc_type: 'book', + doc_metadata: { + title: 'Test Book', + author: 'Test Author', + language: 'en', + }, + data_source_type: 'upload_file', + segment_count: 10, + hit_count: 5, + ...overrides, +} as FullDocumentDetail) + describe('Metadata', () => { beforeEach(() => { vi.clearAllMocks() }) - const createMockDocDetail = (overrides = {}): FullDocumentDetail => ({ - id: 'doc-1', - name: 'Test Document', - doc_type: 'book', - doc_metadata: { - title: 'Test Book', - author: 'Test Author', - language: 'en', - }, - data_source_type: 'upload_file', - segment_count: 10, - hit_count: 5, - ...overrides, - } as FullDocumentDetail) - const defaultProps = { docDetail: createMockDocDetail(), loading: false, onUpdate: vi.fn(), } - // Rendering tests describe('Rendering', () => { it('should render without crashing', () => { // Arrange & Act @@ -191,7 +188,7 @@ describe('Metadata', () => { // Arrange & Act render() - // Assert - Loading component should be rendered + // Assert - Loading component should be rendered, title should not expect(screen.queryByText(/metadata\.title/i)).not.toBeInTheDocument() }) @@ -204,7 +201,7 @@ describe('Metadata', () => { }) }) - // Edit mode tests + // Edit mode (tests useMetadataState hook integration) describe('Edit Mode', () => { it('should enter edit mode when edit button is clicked', () => { // Arrange @@ -303,7 +300,7 @@ describe('Metadata', () => { }) }) - // Document type selection + // Document type selection (tests DocTypeSelector sub-component integration) describe('Document Type Selection', () => { it('should show doc type selection when no doc_type exists', () => { // Arrange @@ -353,13 +350,13 @@ describe('Metadata', () => { }) }) - // Origin info and technical parameters + // Fixed fields (tests MetadataFieldList sub-component integration) describe('Fixed Fields', () => { it('should render origin info fields', () => { // Arrange & Act render() - // Assert - Origin info fields should be displayed + // Assert expect(screen.getByText('Data Source Type')).toBeInTheDocument() }) @@ -382,7 +379,7 @@ describe('Metadata', () => { // Act const { container } = render() - // Assert - should render without crashing + // Assert expect(container.firstChild).toBeInTheDocument() }) @@ -390,7 +387,7 @@ describe('Metadata', () => { // Arrange & Act const { container } = render() - // Assert - should render without crashing + // Assert expect(container.firstChild).toBeInTheDocument() }) @@ -425,7 +422,6 @@ describe('Metadata', () => { }) }) -// FieldInfo component tests describe('FieldInfo', () => { beforeEach(() => { vi.clearAllMocks() @@ -543,3 +539,149 @@ describe('FieldInfo', () => { }) }) }) + +// --- useMetadataState hook coverage tests (via component interactions) --- +describe('useMetadataState coverage', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + const defaultProps = { + docDetail: createMockDocDetail(), + loading: false, + onUpdate: vi.fn(), + } + + describe('cancelDocType', () => { + it('should cancel doc type change and return to edit mode', () => { + // Arrange + render() + + // Enter edit mode → click change to open doc type selector + fireEvent.click(screen.getByText(/operation\.edit/i)) + fireEvent.click(screen.getByText(/operation\.change/i)) + + // Now in doc type selector mode — should show cancel button + expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument() + + // Act — cancel the doc type change + fireEvent.click(screen.getByText(/operation\.cancel/i)) + + // Assert — should be back to edit mode (cancel + save buttons visible) + expect(screen.getByText(/operation\.save/i)).toBeInTheDocument() + }) + }) + + describe('confirmDocType', () => { + it('should confirm same doc type and return to edit mode keeping metadata', () => { + // Arrange — useEffect syncs tempDocType='book' from docDetail + render() + + // Enter edit mode → click change to open doc type selector + fireEvent.click(screen.getByText(/operation\.edit/i)) + fireEvent.click(screen.getByText(/operation\.change/i)) + + // DocTypeSelector shows save/cancel buttons + expect(screen.getByText(/metadata\.docTypeChangeTitle/i)).toBeInTheDocument() + + // Act — click save to confirm same doc type (tempDocType='book') + fireEvent.click(screen.getByText(/operation\.save/i)) + + // Assert — should return to edit mode with metadata fields visible + expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument() + expect(screen.getByText(/operation\.save/i)).toBeInTheDocument() + }) + }) + + describe('cancelEdit when no docType', () => { + it('should show doc type selection when cancel is clicked with doc_type others', () => { + // Arrange — doc with 'others' type normalizes to '' internally. + // The useEffect sees doc_type='others' (truthy) and syncs state, + // so the component initially shows view mode. Enter edit → cancel to trigger cancelEdit. + const docDetail = createMockDocDetail({ doc_type: 'others' }) + render() + + // 'others' is normalized to '' → useEffect fires (doc_type truthy) → view mode + // The rendered type uses default 'book' fallback for display + expect(screen.getByText(/operation\.edit/i)).toBeInTheDocument() + + // Enter edit mode + fireEvent.click(screen.getByText(/operation\.edit/i)) + expect(screen.getByText(/operation\.cancel/i)).toBeInTheDocument() + + // Act — cancel edit; internally docType is '' so cancelEdit goes to showDocTypes + fireEvent.click(screen.getByText(/operation\.cancel/i)) + + // Assert — should show doc type selection since normalized docType was '' + expect(screen.getByText(/metadata\.docTypeSelectTitle/i)).toBeInTheDocument() + }) + }) + + describe('updateMetadataField', () => { + it('should update metadata field value via input', () => { + // Arrange + render() + + // Enter edit mode + fireEvent.click(screen.getByText(/operation\.edit/i)) + + // Act — find an input and change its value (Title field) + const inputs = screen.getAllByRole('textbox') + expect(inputs.length).toBeGreaterThan(0) + fireEvent.change(inputs[0], { target: { value: 'Updated Title' } }) + + // Assert — the input should have the new value + expect(inputs[0]).toHaveValue('Updated Title') + }) + }) + + describe('saveMetadata calls modifyDocMetadata with correct body', () => { + it('should pass doc_type and doc_metadata in save request', async () => { + // Arrange + mockModifyDocMetadata.mockResolvedValueOnce({}) + render() + + // Enter edit mode + fireEvent.click(screen.getByText(/operation\.edit/i)) + + // Act — save + fireEvent.click(screen.getByText(/operation\.save/i)) + + // Assert + await waitFor(() => { + expect(mockModifyDocMetadata).toHaveBeenCalledWith( + expect.objectContaining({ + datasetId: 'test-dataset-id', + documentId: 'test-document-id', + body: expect.objectContaining({ + doc_type: 'book', + }), + }), + ) + }) + }) + }) + + describe('useEffect sync', () => { + it('should handle doc_metadata being null in effect sync', () => { + // Arrange — first render with null metadata + const { rerender } = render( + , + ) + + // Act — rerender with a different doc_type to trigger useEffect sync + rerender( + , + ) + + // Assert — should render without crashing, showing Paper type + expect(screen.getByText('Paper')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/datasets/documents/detail/metadata/index.tsx b/web/app/components/datasets/documents/detail/metadata/index.tsx index 7d1c65b1cd..87110ddc1d 100644 --- a/web/app/components/datasets/documents/detail/metadata/index.tsx +++ b/web/app/components/datasets/documents/detail/metadata/index.tsx @@ -1,422 +1,124 @@ 'use client' -import type { FC, ReactNode } from 'react' -import type { inputType, metadataType } from '@/hooks/use-metadata' -import type { CommonResponse } from '@/models/common' -import type { DocType, FullDocumentDetail } from '@/models/datasets' +import type { FC } from 'react' +import type { FullDocumentDetail } from '@/models/datasets' import { PencilIcon } from '@heroicons/react/24/outline' -import { get } from 'es-toolkit/compat' -import * as React from 'react' -import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' -import AutoHeightTextarea from '@/app/components/base/auto-height-textarea' import Button from '@/app/components/base/button' import Divider from '@/app/components/base/divider' -import Input from '@/app/components/base/input' import Loading from '@/app/components/base/loading' -import Radio from '@/app/components/base/radio' -import { SimpleSelect } from '@/app/components/base/select' -import { ToastContext } from '@/app/components/base/toast' -import Tooltip from '@/app/components/base/tooltip' -import { useBookCategories, useBusinessDocCategories, useLanguages, useMetadataMap, usePersonalDocCategories } from '@/hooks/use-metadata' -import { CUSTOMIZABLE_DOC_TYPES } from '@/models/datasets' -import { modifyDocMetadata } from '@/service/datasets' -import { asyncRunSafe, getTextWidthWithCanvas } from '@/utils' -import { cn } from '@/utils/classnames' -import { useDocumentContext } from '../context' +import { useMetadataMap } from '@/hooks/use-metadata' +import DocTypeSelector, { DocumentTypeDisplay } from './components/doc-type-selector' +import MetadataFieldList from './components/metadata-field-list' +import { useMetadataState } from './hooks/use-metadata-state' import s from './style.module.css' -const map2Options = (map: { [key: string]: string }) => { - return Object.keys(map).map(key => ({ value: key, name: map[key] })) -} +export { default as FieldInfo } from './components/field-info' -type IFieldInfoProps = { - label: string - value?: string - valueIcon?: ReactNode - displayedValue?: string - defaultValue?: string - showEdit?: boolean - inputType?: inputType - selectOptions?: Array<{ value: string, name: string }> - onUpdate?: (v: any) => void -} - -export const FieldInfo: FC = ({ - label, - value = '', - valueIcon, - displayedValue = '', - defaultValue, - showEdit = false, - inputType = 'input', - selectOptions = [], - onUpdate, -}) => { - const { t } = useTranslation() - const textNeedWrap = getTextWidthWithCanvas(displayedValue) > 190 - const editAlignTop = showEdit && inputType === 'textarea' - const readAlignTop = !showEdit && textNeedWrap - - const renderContent = () => { - if (!showEdit) - return displayedValue - - if (inputType === 'select') { - return ( - onUpdate?.(value as string)} - items={selectOptions} - defaultValue={value} - className={s.select} - wrapperClassName={s.selectWrapper} - placeholder={`${t('metadata.placeholder.select', { ns: 'datasetDocuments' })}${label}`} - /> - ) - } - - if (inputType === 'textarea') { - return ( - onUpdate?.(e.target.value)} - value={value} - className={s.textArea} - placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`} - /> - ) - } - - return ( - onUpdate?.(e.target.value)} - value={value} - defaultValue={defaultValue} - placeholder={`${t('metadata.placeholder.add', { ns: 'datasetDocuments' })}${label}`} - /> - ) - } - - return ( -
-
{label}
-
- {valueIcon} - {renderContent()} -
-
- ) -} - -const TypeIcon: FC<{ iconName: string, className?: string }> = ({ iconName, className = '' }) => { - return ( -
- ) -} - -const IconButton: FC<{ - type: DocType - isChecked: boolean -}> = ({ type, isChecked = false }) => { - const metadataMap = useMetadataMap() - - return ( - - - - ) -} - -type IMetadataProps = { +type MetadataProps = { docDetail?: FullDocumentDetail loading: boolean onUpdate: () => void } -type MetadataState = { - documentType?: DocType | '' - metadata: Record -} - -const Metadata: FC = ({ docDetail, loading, onUpdate }) => { - const { doc_metadata = {} } = docDetail || {} - const rawDocType = docDetail?.doc_type ?? '' - const doc_type = rawDocType === 'others' ? '' : rawDocType - +const Metadata: FC = ({ docDetail, loading, onUpdate }) => { const { t } = useTranslation() const metadataMap = useMetadataMap() - const languageMap = useLanguages() - const bookCategoryMap = useBookCategories() - const personalDocCategoryMap = usePersonalDocCategories() - const businessDocCategoryMap = useBusinessDocCategories() - const [editStatus, setEditStatus] = useState(!doc_type) // if no documentType, in editing status by default - // the initial values are according to the documentType - const [metadataParams, setMetadataParams] = useState( - doc_type - ? { - documentType: doc_type as DocType, - metadata: (doc_metadata || {}) as Record, - } - : { metadata: {} }, - ) - const [showDocTypes, setShowDocTypes] = useState(!doc_type) // whether show doc types - const [tempDocType, setTempDocType] = useState('') // for remember icon click - const [saveLoading, setSaveLoading] = useState(false) - const { notify } = useContext(ToastContext) - const datasetId = useDocumentContext(s => s.datasetId) - const documentId = useDocumentContext(s => s.documentId) - - useEffect(() => { - if (docDetail?.doc_type) { - setEditStatus(false) - setShowDocTypes(false) - setTempDocType(doc_type as DocType | '') - setMetadataParams({ - documentType: doc_type as DocType | '', - metadata: (docDetail?.doc_metadata || {}) as Record, - }) - } - }, [docDetail?.doc_type, docDetail?.doc_metadata, doc_type]) - - // confirm doc type - const confirmDocType = () => { - if (!tempDocType) - return - setMetadataParams({ - documentType: tempDocType, - metadata: tempDocType === metadataParams.documentType ? metadataParams.metadata : {} as Record, // change doc type, clear metadata - }) - setEditStatus(true) - setShowDocTypes(false) - } - - // cancel doc type - const cancelDocType = () => { - setTempDocType(metadataParams.documentType ?? '') - setEditStatus(true) - setShowDocTypes(false) - } - - // show doc type select - const renderSelectDocType = () => { - const { documentType } = metadataParams + const { + docType, + editStatus, + showDocTypes, + tempDocType, + saveLoading, + metadataParams, + setTempDocType, + setShowDocTypes, + confirmDocType, + cancelDocType, + enableEdit, + cancelEdit, + saveMetadata, + updateMetadataField, + } = useMetadataState({ docDetail, onUpdate }) + if (loading) { return ( - <> - {!doc_type && !documentType && ( - <> -
{t('metadata.desc', { ns: 'datasetDocuments' })}
- - )} -
- {!doc_type && !documentType && ( - <> - {t('metadata.docTypeSelectTitle', { ns: 'datasetDocuments' })} - - )} - {documentType && ( - <> - {t('metadata.docTypeChangeTitle', { ns: 'datasetDocuments' })} - {t('metadata.docTypeSelectWarning', { ns: 'datasetDocuments' })} - - )} - - {CUSTOMIZABLE_DOC_TYPES.map((type, index) => { - const currValue = tempDocType ?? documentType - return ( - - - - ) - })} - - {!doc_type && !documentType && ( - - )} - {documentType && ( -
- - -
- )} -
- - ) - } - - // show metadata info and edit - const renderFieldInfos = ({ mainField = 'book', canEdit }: { mainField?: metadataType | '', canEdit?: boolean }) => { - if (!mainField) - return null - const fieldMap = metadataMap[mainField]?.subFieldsMap - const sourceData = ['originInfo', 'technicalParameters'].includes(mainField) ? docDetail : metadataParams.metadata - - const getTargetMap = (field: string) => { - if (field === 'language') - return languageMap - if (field === 'category' && mainField === 'book') - return bookCategoryMap - - if (field === 'document_type') { - if (mainField === 'personal_document') - return personalDocCategoryMap - if (mainField === 'business_document') - return businessDocCategoryMap - } - return {} as any - } - - const getTargetValue = (field: string) => { - const val = get(sourceData, field, '') - if (!val && val !== 0) - return '-' - if (fieldMap[field]?.inputType === 'select') - return getTargetMap(field)[val] - if (fieldMap[field]?.render) - return fieldMap[field]?.render?.(val, field === 'hit_count' ? get(sourceData, 'segment_count', 0) as number : undefined) - return val - } - - return ( -
- {Object.keys(fieldMap).map((field) => { - return ( - { - setMetadataParams(pre => ({ ...pre, metadata: { ...pre.metadata, [field]: val } })) - }} - selectOptions={map2Options(getTargetMap(field))} - /> - ) - })} +
+
) } - const enabledEdit = () => { - setEditStatus(true) - } - - const onCancel = () => { - setMetadataParams({ documentType: doc_type || '', metadata: { ...docDetail?.doc_metadata } }) - setEditStatus(!doc_type) - if (!doc_type) - setShowDocTypes(true) - } - - const onSave = async () => { - setSaveLoading(true) - const [e] = await asyncRunSafe(modifyDocMetadata({ - datasetId, - documentId, - body: { - doc_type: metadataParams.documentType || doc_type || '', - doc_metadata: metadataParams.metadata, - }, - }) as Promise) - if (!e) - notify({ type: 'success', message: t('actionMsg.modifiedSuccessfully', { ns: 'common' }) }) - else - notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) }) - onUpdate?.() - setEditStatus(false) - setSaveLoading(false) - } - return (
- {loading - ? () - : ( - <> -
- {t('metadata.title', { ns: 'datasetDocuments' })} - {!editStatus - ? ( - - ) - : showDocTypes - ? null - : ( -
- - -
- )} + {/* Header: title + action buttons */} +
+ {t('metadata.title', { ns: 'datasetDocuments' })} + {!editStatus + ? ( + + ) + : !showDocTypes && ( +
+ +
- {/* show selected doc type and changing entry */} - {!editStatus - ? ( -
- - {metadataMap[doc_type || 'book'].text} -
- ) - : showDocTypes - ? null - : ( -
- {metadataParams.documentType && ( - <> - - {metadataMap[metadataParams.documentType || 'book'].text} - {editStatus && ( -
- · -
{ setShowDocTypes(true) }} - className="cursor-pointer hover:text-text-accent" - > - {t('operation.change', { ns: 'common' })} -
-
- )} - - )} -
- )} - {(!doc_type && showDocTypes) ? null : } - {showDocTypes ? renderSelectDocType() : renderFieldInfos({ mainField: metadataParams.documentType, canEdit: editStatus })} - {/* show fixed fields */} - - {renderFieldInfos({ mainField: 'originInfo', canEdit: false })} -
{metadataMap.technicalParameters.text}
- - {renderFieldInfos({ mainField: 'technicalParameters', canEdit: false })} - + )} +
+ + {/* Document type display / selector */} + {!editStatus + ? + : showDocTypes + ? null + : ( + setShowDocTypes(true)} + /> + )} + + {/* Divider between type display and fields (skip when in first-time selection) */} + {(!docType && showDocTypes) ? null : } + + {/* Doc type selector or editable metadata fields */} + {showDocTypes + ? ( + + ) + : ( + )} + + {/* Fixed fields: origin info */} + + + + {/* Fixed fields: technical parameters */} +
{metadataMap.technicalParameters.text}
+ +
) } diff --git a/web/app/components/header/account-dropdown/index.tsx b/web/app/components/header/account-dropdown/index.tsx index 0f17b3a747..c61ffb4a7a 100644 --- a/web/app/components/header/account-dropdown/index.tsx +++ b/web/app/components/header/account-dropdown/index.tsx @@ -28,6 +28,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context' import { useDocLink } from '@/context/i18n' import { useModalContext } from '@/context/modal-context' import { useProviderContext } from '@/context/provider-context' +import { env } from '@/env' import { useLogout } from '@/service/use-common' import { cn } from '@/utils/classnames' import AccountAbout from '../account-about' @@ -178,7 +179,7 @@ export default function AppSelector() { { - document?.body?.getAttribute('data-public-site-about') !== 'hide' && ( + env.NEXT_PUBLIC_SITE_ABOUT !== 'hide' && (
{children} } - const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '' + const basePath = env.NEXT_PUBLIC_BASE_PATH const swUrl = `${basePath}/serwist/sw.js` return ( diff --git a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/schema.ts b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/schema.ts index 7433111466..056f399a70 100644 --- a/web/app/components/rag-pipeline/components/panel/input-field/editor/form/schema.ts +++ b/web/app/components/rag-pipeline/components/panel/input-field/editor/form/schema.ts @@ -1,6 +1,6 @@ import type { TFunction } from 'i18next' import type { SchemaOptions } from './types' -import { z } from 'zod' +import * as z from 'zod' import { InputTypeEnum } from '@/app/components/base/form/components/field/input-type-select/types' import { MAX_VAR_KEY_LENGTH } from '@/config' import { PipelineInputVarType } from '@/models/pipeline' diff --git a/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx b/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx index 6643d8239d..addfa3dc53 100644 --- a/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx +++ b/web/app/components/rag-pipeline/components/update-dsl-modal.spec.tsx @@ -1,6 +1,6 @@ import type { PropsWithChildren } from 'react' -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { DSLImportStatus } from '@/models/app' import UpdateDSLModal from './update-dsl-modal' @@ -145,11 +145,6 @@ vi.mock('@/app/components/workflow/constants', () => ({ WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE', })) -afterEach(() => { - cleanup() - vi.clearAllMocks() -}) - describe('UpdateDSLModal', () => { const mockOnCancel = vi.fn() const mockOnBackup = vi.fn() diff --git a/web/app/components/rag-pipeline/components/update-dsl-modal.tsx b/web/app/components/rag-pipeline/components/update-dsl-modal.tsx index 3582b02324..ccc0d1fc45 100644 --- a/web/app/components/rag-pipeline/components/update-dsl-modal.tsx +++ b/web/app/components/rag-pipeline/components/update-dsl-modal.tsx @@ -1,40 +1,17 @@ 'use client' -import type { MouseEventHandler } from 'react' import { RiAlertFill, RiCloseLine, RiFileDownloadLine, } from '@remixicon/react' -import { - memo, - useCallback, - useRef, - useState, -} from 'react' +import { memo } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' import Uploader from '@/app/components/app/create-from-dsl-modal/uploader' import Button from '@/app/components/base/button' import Modal from '@/app/components/base/modal' -import { ToastContext } from '@/app/components/base/toast' -import { WORKFLOW_DATA_UPDATE } from '@/app/components/workflow/constants' -import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' -import { useWorkflowStore } from '@/app/components/workflow/store' -import { - initialEdges, - initialNodes, -} from '@/app/components/workflow/utils' -import { useEventEmitterContextContext } from '@/context/event-emitter' -import { - DSLImportMode, - DSLImportStatus, -} from '@/models/app' -import { - useImportPipelineDSL, - useImportPipelineDSLConfirm, -} from '@/service/use-pipeline' -import { fetchWorkflowDraft } from '@/service/workflow' +import { useUpdateDSLModal } from '../hooks/use-update-dsl-modal' +import VersionMismatchModal from './version-mismatch-modal' type UpdateDSLModalProps = { onCancel: () => void @@ -48,146 +25,17 @@ const UpdateDSLModal = ({ onImport, }: UpdateDSLModalProps) => { const { t } = useTranslation() - const { notify } = useContext(ToastContext) - const [currentFile, setDSLFile] = useState() - const [fileContent, setFileContent] = useState() - const [loading, setLoading] = useState(false) - const { eventEmitter } = useEventEmitterContextContext() - const [show, setShow] = useState(true) - const [showErrorModal, setShowErrorModal] = useState(false) - const [versions, setVersions] = useState<{ importedVersion: string, systemVersion: string }>() - const [importId, setImportId] = useState() - const { handleCheckPluginDependencies } = usePluginDependencies() - const { mutateAsync: importDSL } = useImportPipelineDSL() - const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm() - const workflowStore = useWorkflowStore() - - const readFile = (file: File) => { - const reader = new FileReader() - reader.onload = function (event) { - const content = event.target?.result - setFileContent(content as string) - } - reader.readAsText(file) - } - - const handleFile = (file?: File) => { - setDSLFile(file) - if (file) - readFile(file) - if (!file) - setFileContent('') - } - - const handleWorkflowUpdate = useCallback(async (pipelineId: string) => { - const { - graph, - hash, - rag_pipeline_variables, - } = await fetchWorkflowDraft(`/rag/pipelines/${pipelineId}/workflows/draft`) - - const { nodes, edges, viewport } = graph - - eventEmitter?.emit({ - type: WORKFLOW_DATA_UPDATE, - payload: { - nodes: initialNodes(nodes, edges), - edges: initialEdges(edges, nodes), - viewport, - hash, - rag_pipeline_variables: rag_pipeline_variables || [], - }, - } as any) - }, [eventEmitter]) - - const isCreatingRef = useRef(false) - const handleImport: MouseEventHandler = useCallback(async () => { - const { pipelineId } = workflowStore.getState() - if (isCreatingRef.current) - return - isCreatingRef.current = true - if (!currentFile) - return - try { - if (pipelineId && fileContent) { - setLoading(true) - const response = await importDSL({ mode: DSLImportMode.YAML_CONTENT, yaml_content: fileContent, pipeline_id: pipelineId }) - const { id, status, pipeline_id, imported_dsl_version, current_dsl_version } = response - - if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { - if (!pipeline_id) { - notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) }) - return - } - handleWorkflowUpdate(pipeline_id) - if (onImport) - onImport() - notify({ - type: status === DSLImportStatus.COMPLETED ? 'success' : 'warning', - message: t(status === DSLImportStatus.COMPLETED ? 'common.importSuccess' : 'common.importWarning', { ns: 'workflow' }), - children: status === DSLImportStatus.COMPLETED_WITH_WARNINGS && t('common.importWarningDetails', { ns: 'workflow' }), - }) - await handleCheckPluginDependencies(pipeline_id, true) - setLoading(false) - onCancel() - } - else if (status === DSLImportStatus.PENDING) { - setShow(false) - setTimeout(() => { - setShowErrorModal(true) - }, 300) - setVersions({ - importedVersion: imported_dsl_version ?? '', - systemVersion: current_dsl_version ?? '', - }) - setImportId(id) - } - else { - setLoading(false) - notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) }) - } - } - } - // eslint-disable-next-line unused-imports/no-unused-vars - catch (e) { - setLoading(false) - notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) }) - } - isCreatingRef.current = false - }, [currentFile, fileContent, onCancel, notify, t, onImport, handleWorkflowUpdate, handleCheckPluginDependencies, workflowStore, importDSL]) - - const onUpdateDSLConfirm: MouseEventHandler = async () => { - try { - if (!importId) - return - const response = await importDSLConfirm(importId) - - const { status, pipeline_id } = response - - if (status === DSLImportStatus.COMPLETED) { - if (!pipeline_id) { - notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) }) - return - } - handleWorkflowUpdate(pipeline_id) - await handleCheckPluginDependencies(pipeline_id, true) - if (onImport) - onImport() - notify({ type: 'success', message: t('common.importSuccess', { ns: 'workflow' }) }) - setLoading(false) - onCancel() - } - else if (status === DSLImportStatus.FAILED) { - setLoading(false) - notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) }) - } - } - // eslint-disable-next-line unused-imports/no-unused-vars - catch (e) { - setLoading(false) - notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) }) - } - } + const { + currentFile, + handleFile, + show, + showErrorModal, + setShowErrorModal, + loading, + versions, + handleImport, + onUpdateDSLConfirm, + } = useUpdateDSLModal({ onCancel, onImport }) return ( <> @@ -250,32 +98,12 @@ const UpdateDSLModal = ({
- setShowErrorModal(false)} - className="w-[480px]" - > -
-
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
-
-
{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}
-
{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}
-
-
- {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} - {versions?.importedVersion} -
-
- {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} - {versions?.systemVersion} -
-
-
-
- - -
-
+ onConfirm={onUpdateDSLConfirm} + /> ) } diff --git a/web/app/components/rag-pipeline/components/version-mismatch-modal.spec.tsx b/web/app/components/rag-pipeline/components/version-mismatch-modal.spec.tsx new file mode 100644 index 0000000000..b14cdcf9c1 --- /dev/null +++ b/web/app/components/rag-pipeline/components/version-mismatch-modal.spec.tsx @@ -0,0 +1,117 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import VersionMismatchModal from './version-mismatch-modal' + +describe('VersionMismatchModal', () => { + const mockOnClose = vi.fn() + const mockOnConfirm = vi.fn() + + const defaultVersions = { + importedVersion: '0.8.0', + systemVersion: '1.0.0', + } + + const defaultProps = { + isShow: true, + versions: defaultVersions, + onClose: mockOnClose, + onConfirm: mockOnConfirm, + } + + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('rendering', () => { + it('should render dialog when isShow is true', () => { + render() + + expect(screen.getByRole('dialog')).toBeInTheDocument() + }) + + it('should not render dialog when isShow is false', () => { + render() + + expect(screen.queryByRole('dialog')).not.toBeInTheDocument() + }) + + it('should render error title', () => { + render() + + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + }) + + it('should render all error description parts', () => { + render() + + expect(screen.getByText('app.newApp.appCreateDSLErrorPart1')).toBeInTheDocument() + expect(screen.getByText('app.newApp.appCreateDSLErrorPart2')).toBeInTheDocument() + expect(screen.getByText('app.newApp.appCreateDSLErrorPart3')).toBeInTheDocument() + expect(screen.getByText('app.newApp.appCreateDSLErrorPart4')).toBeInTheDocument() + }) + + it('should display imported and system version numbers', () => { + render() + + expect(screen.getByText('0.8.0')).toBeInTheDocument() + expect(screen.getByText('1.0.0')).toBeInTheDocument() + }) + + it('should render cancel and confirm buttons', () => { + render() + + expect(screen.getByRole('button', { name: /app\.newApp\.Cancel/ })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /app\.newApp\.Confirm/ })).toBeInTheDocument() + }) + }) + + describe('user interactions', () => { + it('should call onClose when cancel button is clicked', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Cancel/ })) + + expect(mockOnClose).toHaveBeenCalledTimes(1) + }) + + it('should call onConfirm when confirm button is clicked', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /app\.newApp\.Confirm/ })) + + expect(mockOnConfirm).toHaveBeenCalledTimes(1) + }) + }) + + describe('button variants', () => { + it('should render cancel button with secondary variant', () => { + render() + + const cancelBtn = screen.getByRole('button', { name: /app\.newApp\.Cancel/ }) + expect(cancelBtn).toHaveClass('btn-secondary') + }) + + it('should render confirm button with primary destructive variant', () => { + render() + + const confirmBtn = screen.getByRole('button', { name: /app\.newApp\.Confirm/ }) + expect(confirmBtn).toHaveClass('btn-primary') + expect(confirmBtn).toHaveClass('btn-destructive') + }) + }) + + describe('edge cases', () => { + it('should handle undefined versions gracefully', () => { + render() + + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + }) + + it('should handle empty version strings', () => { + const emptyVersions = { importedVersion: '', systemVersion: '' } + render() + + expect(screen.getByText('app.newApp.appCreateDSLErrorTitle')).toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/rag-pipeline/components/version-mismatch-modal.tsx b/web/app/components/rag-pipeline/components/version-mismatch-modal.tsx new file mode 100644 index 0000000000..3828db50e4 --- /dev/null +++ b/web/app/components/rag-pipeline/components/version-mismatch-modal.tsx @@ -0,0 +1,54 @@ +import type { MouseEventHandler } from 'react' +import { useTranslation } from 'react-i18next' +import Button from '@/app/components/base/button' +import Modal from '@/app/components/base/modal' + +type VersionMismatchModalProps = { + isShow: boolean + versions?: { + importedVersion: string + systemVersion: string + } + onClose: () => void + onConfirm: MouseEventHandler +} + +const VersionMismatchModal = ({ + isShow, + versions, + onClose, + onConfirm, +}: VersionMismatchModalProps) => { + const { t } = useTranslation() + + return ( + +
+
{t('newApp.appCreateDSLErrorTitle', { ns: 'app' })}
+
+
{t('newApp.appCreateDSLErrorPart1', { ns: 'app' })}
+
{t('newApp.appCreateDSLErrorPart2', { ns: 'app' })}
+
+
+ {t('newApp.appCreateDSLErrorPart3', { ns: 'app' })} + {versions?.importedVersion} +
+
+ {t('newApp.appCreateDSLErrorPart4', { ns: 'app' })} + {versions?.systemVersion} +
+
+
+
+ + +
+
+ ) +} + +export default VersionMismatchModal diff --git a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.spec.ts b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.spec.ts index 5817d187ac..5788c860d1 100644 --- a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.spec.ts +++ b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.spec.ts @@ -68,23 +68,20 @@ vi.mock('@/config', () => ({ API_PREFIX: '/api', })) +// Mock postWithKeepalive from service/fetch +const mockPostWithKeepalive = vi.fn() +vi.mock('@/service/fetch', () => ({ + postWithKeepalive: (...args: unknown[]) => mockPostWithKeepalive(...args), +})) + // ============================================================================ // Tests // ============================================================================ describe('useNodesSyncDraft', () => { - const mockSendBeacon = vi.fn() - beforeEach(() => { vi.clearAllMocks() - // Setup navigator.sendBeacon mock - Object.defineProperty(navigator, 'sendBeacon', { - value: mockSendBeacon, - writable: true, - configurable: true, - }) - // Default store state mockStoreGetState.mockReturnValue({ getNodes: mockGetNodes, @@ -134,7 +131,7 @@ describe('useNodesSyncDraft', () => { }) describe('syncWorkflowDraftWhenPageClose', () => { - it('should not call sendBeacon when nodes are read only', () => { + it('should not call postWithKeepalive when nodes are read only', () => { mockGetNodesReadOnly.mockReturnValue(true) const { result } = renderHook(() => useNodesSyncDraft()) @@ -143,10 +140,10 @@ describe('useNodesSyncDraft', () => { result.current.syncWorkflowDraftWhenPageClose() }) - expect(mockSendBeacon).not.toHaveBeenCalled() + expect(mockPostWithKeepalive).not.toHaveBeenCalled() }) - it('should call sendBeacon with correct URL and params', () => { + it('should call postWithKeepalive with correct URL and params', () => { mockGetNodesReadOnly.mockReturnValue(false) mockGetNodes.mockReturnValue([ { id: 'node-1', data: { type: 'start' }, position: { x: 0, y: 0 } }, @@ -158,13 +155,16 @@ describe('useNodesSyncDraft', () => { result.current.syncWorkflowDraftWhenPageClose() }) - expect(mockSendBeacon).toHaveBeenCalledWith( + expect(mockPostWithKeepalive).toHaveBeenCalledWith( '/api/rag/pipelines/test-pipeline-id/workflows/draft', - expect.any(String), + expect.objectContaining({ + graph: expect.any(Object), + hash: 'test-hash', + }), ) }) - it('should not call sendBeacon when pipelineId is missing', () => { + it('should not call postWithKeepalive when pipelineId is missing', () => { mockWorkflowStoreGetState.mockReturnValue({ pipelineId: undefined, environmentVariables: [], @@ -178,10 +178,10 @@ describe('useNodesSyncDraft', () => { result.current.syncWorkflowDraftWhenPageClose() }) - expect(mockSendBeacon).not.toHaveBeenCalled() + expect(mockPostWithKeepalive).not.toHaveBeenCalled() }) - it('should not call sendBeacon when nodes array is empty', () => { + it('should not call postWithKeepalive when nodes array is empty', () => { mockGetNodes.mockReturnValue([]) const { result } = renderHook(() => useNodesSyncDraft()) @@ -190,7 +190,7 @@ describe('useNodesSyncDraft', () => { result.current.syncWorkflowDraftWhenPageClose() }) - expect(mockSendBeacon).not.toHaveBeenCalled() + expect(mockPostWithKeepalive).not.toHaveBeenCalled() }) it('should filter out temp nodes', () => { @@ -204,8 +204,8 @@ describe('useNodesSyncDraft', () => { result.current.syncWorkflowDraftWhenPageClose() }) - // Should not call sendBeacon because after filtering temp nodes, array is empty - expect(mockSendBeacon).not.toHaveBeenCalled() + // Should not call postWithKeepalive because after filtering temp nodes, array is empty + expect(mockPostWithKeepalive).not.toHaveBeenCalled() }) it('should remove underscore-prefixed data keys from nodes', () => { @@ -219,9 +219,9 @@ describe('useNodesSyncDraft', () => { result.current.syncWorkflowDraftWhenPageClose() }) - expect(mockSendBeacon).toHaveBeenCalled() - const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1]) - expect(sentData.graph.nodes[0].data._privateData).toBeUndefined() + expect(mockPostWithKeepalive).toHaveBeenCalled() + const sentParams = mockPostWithKeepalive.mock.calls[0][1] + expect(sentParams.graph.nodes[0].data._privateData).toBeUndefined() }) }) @@ -395,8 +395,8 @@ describe('useNodesSyncDraft', () => { result.current.syncWorkflowDraftWhenPageClose() }) - const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1]) - expect(sentData.graph.viewport).toEqual({ x: 100, y: 200, zoom: 1.5 }) + const sentParams = mockPostWithKeepalive.mock.calls[0][1] + expect(sentParams.graph.viewport).toEqual({ x: 100, y: 200, zoom: 1.5 }) }) it('should include environment variables in params', () => { @@ -418,8 +418,8 @@ describe('useNodesSyncDraft', () => { result.current.syncWorkflowDraftWhenPageClose() }) - const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1]) - expect(sentData.environment_variables).toEqual([{ key: 'API_KEY', value: 'secret' }]) + const sentParams = mockPostWithKeepalive.mock.calls[0][1] + expect(sentParams.environment_variables).toEqual([{ key: 'API_KEY', value: 'secret' }]) }) it('should include rag pipeline variables in params', () => { @@ -441,8 +441,8 @@ describe('useNodesSyncDraft', () => { result.current.syncWorkflowDraftWhenPageClose() }) - const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1]) - expect(sentData.rag_pipeline_variables).toEqual([{ variable: 'input', type: 'text-input' }]) + const sentParams = mockPostWithKeepalive.mock.calls[0][1] + expect(sentParams.rag_pipeline_variables).toEqual([{ variable: 'input', type: 'text-input' }]) }) it('should remove underscore-prefixed keys from edges', () => { @@ -461,9 +461,9 @@ describe('useNodesSyncDraft', () => { result.current.syncWorkflowDraftWhenPageClose() }) - const sentData = JSON.parse(mockSendBeacon.mock.calls[0][1]) - expect(sentData.graph.edges[0].data._hidden).toBeUndefined() - expect(sentData.graph.edges[0].data.visible).toBe(false) + const sentParams = mockPostWithKeepalive.mock.calls[0][1] + expect(sentParams.graph.edges[0].data._hidden).toBeUndefined() + expect(sentParams.graph.edges[0].data.visible).toBe(false) }) }) }) diff --git a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts index a5dd72d898..33de84835c 100644 --- a/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts +++ b/web/app/components/rag-pipeline/hooks/use-nodes-sync-draft.ts @@ -9,6 +9,7 @@ import { useWorkflowStore, } from '@/app/components/workflow/store' import { API_PREFIX } from '@/config' +import { postWithKeepalive } from '@/service/fetch' import { syncWorkflowDraft } from '@/service/workflow' import { usePipelineRefreshDraft } from '.' @@ -76,12 +77,8 @@ export const useNodesSyncDraft = () => { return const postParams = getPostParams() - if (postParams) { - navigator.sendBeacon( - `${API_PREFIX}${postParams.url}`, - JSON.stringify(postParams.params), - ) - } + if (postParams) + postWithKeepalive(`${API_PREFIX}${postParams.url}`, postParams.params) }, [getPostParams, getNodesReadOnly]) const performSync = useCallback(async ( diff --git a/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.spec.ts b/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.spec.ts new file mode 100644 index 0000000000..adf756c10f --- /dev/null +++ b/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.spec.ts @@ -0,0 +1,551 @@ +import { act, renderHook } from '@testing-library/react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DSLImportMode, DSLImportStatus } from '@/models/app' +import { useUpdateDSLModal } from './use-update-dsl-modal' + +// --- FileReader stub --- +class MockFileReader { + onload: ((this: FileReader, event: ProgressEvent) => void) | null = null + + readAsText(_file: Blob) { + const event = { target: { result: 'test content' } } as unknown as ProgressEvent + this.onload?.call(this as unknown as FileReader, event) + } +} +vi.stubGlobal('FileReader', MockFileReader as unknown as typeof FileReader) + +// --- Module-level mock functions --- +const mockNotify = vi.fn() +const mockEmit = vi.fn() +const mockImportDSL = vi.fn() +const mockImportDSLConfirm = vi.fn() +const mockHandleCheckPluginDependencies = vi.fn() + +// --- Mocks --- +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})) + +vi.mock('use-context-selector', () => ({ + useContext: () => ({ notify: mockNotify }), +})) + +vi.mock('@/app/components/base/toast', () => ({ + ToastContext: {}, +})) + +vi.mock('@/context/event-emitter', () => ({ + useEventEmitterContextContext: () => ({ + eventEmitter: { emit: mockEmit }, + }), +})) + +vi.mock('@/app/components/workflow/store', () => ({ + useWorkflowStore: () => ({ + getState: () => ({ pipelineId: 'test-pipeline-id' }), + }), +})) + +vi.mock('@/app/components/workflow/utils', () => ({ + initialNodes: (nodes: unknown[]) => nodes, + initialEdges: (edges: unknown[]) => edges, +})) + +vi.mock('@/app/components/workflow/constants', () => ({ + WORKFLOW_DATA_UPDATE: 'WORKFLOW_DATA_UPDATE', +})) + +vi.mock('@/app/components/workflow/plugin-dependency/hooks', () => ({ + usePluginDependencies: () => ({ + handleCheckPluginDependencies: mockHandleCheckPluginDependencies, + }), +})) + +vi.mock('@/service/use-pipeline', () => ({ + useImportPipelineDSL: () => ({ mutateAsync: mockImportDSL }), + useImportPipelineDSLConfirm: () => ({ mutateAsync: mockImportDSLConfirm }), +})) + +vi.mock('@/service/workflow', () => ({ + fetchWorkflowDraft: vi.fn().mockResolvedValue({ + graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }, + hash: 'test-hash', + rag_pipeline_variables: [], + }), +})) + +// --- Helpers --- +const createFile = () => new File(['test content'], 'test.pipeline', { type: 'text/yaml' }) + +// Cast MouseEventHandler to a plain callable for tests (event param is unused) +type AsyncFn = () => Promise + +describe('useUpdateDSLModal', () => { + const mockOnCancel = vi.fn() + const mockOnImport = vi.fn() + + const renderUpdateDSLModal = (overrides?: { onImport?: () => void }) => + renderHook(() => + useUpdateDSLModal({ + onCancel: mockOnCancel, + onImport: overrides?.onImport ?? mockOnImport, + }), + ) + + beforeEach(() => { + vi.clearAllMocks() + mockImportDSL.mockResolvedValue({ + id: 'import-id', + status: DSLImportStatus.COMPLETED, + pipeline_id: 'test-pipeline-id', + }) + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + }) + + // Initial state values + describe('initial state', () => { + it('should return correct defaults', () => { + const { result } = renderUpdateDSLModal() + + expect(result.current.currentFile).toBeUndefined() + expect(result.current.show).toBe(true) + expect(result.current.showErrorModal).toBe(false) + expect(result.current.loading).toBe(false) + expect(result.current.versions).toBeUndefined() + }) + }) + + // File handling + describe('handleFile', () => { + it('should set currentFile when file is provided', () => { + const { result } = renderUpdateDSLModal() + const file = createFile() + + act(() => { + result.current.handleFile(file) + }) + + expect(result.current.currentFile).toBe(file) + }) + + it('should clear currentFile when called with undefined', () => { + const { result } = renderUpdateDSLModal() + + act(() => { + result.current.handleFile(createFile()) + }) + act(() => { + result.current.handleFile(undefined) + }) + + expect(result.current.currentFile).toBeUndefined() + }) + }) + + // Modal state management + describe('modal state', () => { + it('should allow toggling showErrorModal', () => { + const { result } = renderUpdateDSLModal() + + expect(result.current.showErrorModal).toBe(false) + + act(() => { + result.current.setShowErrorModal(true) + }) + expect(result.current.showErrorModal).toBe(true) + + act(() => { + result.current.setShowErrorModal(false) + }) + expect(result.current.showErrorModal).toBe(false) + }) + }) + + // Import flow + describe('handleImport', () => { + it('should call importDSL with correct parameters', async () => { + const { result } = renderUpdateDSLModal() + + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockImportDSL).toHaveBeenCalledWith({ + mode: DSLImportMode.YAML_CONTENT, + yaml_content: 'test content', + pipeline_id: 'test-pipeline-id', + }) + }) + + it('should not call importDSL when no file is selected', async () => { + const { result } = renderUpdateDSLModal() + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockImportDSL).not.toHaveBeenCalled() + }) + + // COMPLETED status + it('should notify success on COMPLETED status', async () => { + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })) + }) + + it('should call onImport on successful import', async () => { + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockOnImport).toHaveBeenCalled() + }) + + it('should call onCancel on successful import', async () => { + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockOnCancel).toHaveBeenCalled() + }) + + it('should emit workflow update event on success', async () => { + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockEmit).toHaveBeenCalled() + }) + + it('should call handleCheckPluginDependencies on success', async () => { + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockHandleCheckPluginDependencies).toHaveBeenCalledWith('test-pipeline-id', true) + }) + + // COMPLETED_WITH_WARNINGS status + it('should notify warning on COMPLETED_WITH_WARNINGS status', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-id', + status: DSLImportStatus.COMPLETED_WITH_WARNINGS, + pipeline_id: 'test-pipeline-id', + }) + + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'warning' })) + }) + + // PENDING status (version mismatch) + it('should switch to version mismatch modal on PENDING status', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue({ + id: 'import-id', + status: DSLImportStatus.PENDING, + pipeline_id: 'test-pipeline-id', + imported_dsl_version: '0.8.0', + current_dsl_version: '1.0.0', + }) + + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + await vi.advanceTimersByTimeAsync(350) + }) + + expect(result.current.show).toBe(false) + expect(result.current.showErrorModal).toBe(true) + expect(result.current.versions).toEqual({ + importedVersion: '0.8.0', + systemVersion: '1.0.0', + }) + + vi.useRealTimers() + }) + + it('should default version strings to empty when undefined', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue({ + id: 'import-id', + status: DSLImportStatus.PENDING, + pipeline_id: 'test-pipeline-id', + imported_dsl_version: undefined, + current_dsl_version: undefined, + }) + + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + await vi.advanceTimersByTimeAsync(350) + }) + + expect(result.current.versions).toEqual({ + importedVersion: '', + systemVersion: '', + }) + + vi.useRealTimers() + }) + + // FAILED / unknown status + it('should notify error on FAILED status', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-id', + status: DSLImportStatus.FAILED, + pipeline_id: 'test-pipeline-id', + }) + + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + // Exception + it('should notify error when importDSL throws', async () => { + mockImportDSL.mockRejectedValue(new Error('Network error')) + + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + // Missing pipeline_id + it('should notify error when pipeline_id is missing on success', async () => { + mockImportDSL.mockResolvedValue({ + id: 'import-id', + status: DSLImportStatus.COMPLETED, + pipeline_id: undefined, + }) + + const { result } = renderUpdateDSLModal() + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + }) + + // Confirm flow (after PENDING → version mismatch) + describe('onUpdateDSLConfirm', () => { + // Helper: drive the hook into PENDING state so importId is set + const setupPendingState = async (result: { current: ReturnType }) => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + + mockImportDSL.mockResolvedValue({ + id: 'import-id', + status: DSLImportStatus.PENDING, + pipeline_id: 'test-pipeline-id', + imported_dsl_version: '0.8.0', + current_dsl_version: '1.0.0', + }) + + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + await vi.advanceTimersByTimeAsync(350) + }) + + vi.useRealTimers() + vi.clearAllMocks() + mockHandleCheckPluginDependencies.mockResolvedValue(undefined) + } + + it('should call importDSLConfirm with the stored importId', async () => { + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.COMPLETED, + pipeline_id: 'test-pipeline-id', + }) + + const { result } = renderUpdateDSLModal() + await setupPendingState(result) + + await act(async () => { + await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() + }) + + expect(mockImportDSLConfirm).toHaveBeenCalledWith('import-id') + }) + + it('should notify success and call onCancel after successful confirm', async () => { + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.COMPLETED, + pipeline_id: 'test-pipeline-id', + }) + + const { result } = renderUpdateDSLModal() + await setupPendingState(result) + + await act(async () => { + await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'success' })) + expect(mockOnCancel).toHaveBeenCalled() + }) + + it('should call onImport after successful confirm', async () => { + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.COMPLETED, + pipeline_id: 'test-pipeline-id', + }) + + const { result } = renderUpdateDSLModal() + await setupPendingState(result) + + await act(async () => { + await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() + }) + + expect(mockOnImport).toHaveBeenCalled() + }) + + it('should notify error on FAILED confirm status', async () => { + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.FAILED, + pipeline_id: 'test-pipeline-id', + }) + + const { result } = renderUpdateDSLModal() + await setupPendingState(result) + + await act(async () => { + await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should notify error when confirm throws exception', async () => { + mockImportDSLConfirm.mockRejectedValue(new Error('Confirm failed')) + + const { result } = renderUpdateDSLModal() + await setupPendingState(result) + + await act(async () => { + await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should notify error when confirm succeeds but pipeline_id is missing', async () => { + mockImportDSLConfirm.mockResolvedValue({ + status: DSLImportStatus.COMPLETED, + pipeline_id: undefined, + }) + + const { result } = renderUpdateDSLModal() + await setupPendingState(result) + + await act(async () => { + await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() + }) + + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })) + }) + + it('should not call importDSLConfirm when importId is not set', async () => { + const { result } = renderUpdateDSLModal() + + // No pending state → importId is undefined + await act(async () => { + await (result.current.onUpdateDSLConfirm as unknown as AsyncFn)() + }) + + expect(mockImportDSLConfirm).not.toHaveBeenCalled() + }) + }) + + // Optional onImport callback + describe('optional onImport', () => { + it('should work without onImport callback', async () => { + const { result } = renderHook(() => + useUpdateDSLModal({ onCancel: mockOnCancel }), + ) + + act(() => { + result.current.handleFile(createFile()) + }) + + await act(async () => { + await (result.current.handleImport as unknown as AsyncFn)() + }) + + // Should succeed without throwing + expect(mockOnCancel).toHaveBeenCalled() + }) + }) +}) diff --git a/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts b/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts new file mode 100644 index 0000000000..3b86937417 --- /dev/null +++ b/web/app/components/rag-pipeline/hooks/use-update-dsl-modal.ts @@ -0,0 +1,205 @@ +import type { MouseEventHandler } from 'react' +import { + useCallback, + useRef, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { useContext } from 'use-context-selector' +import { ToastContext } from '@/app/components/base/toast' +import { WORKFLOW_DATA_UPDATE } from '@/app/components/workflow/constants' +import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' +import { useWorkflowStore } from '@/app/components/workflow/store' +import { + initialEdges, + initialNodes, +} from '@/app/components/workflow/utils' +import { useEventEmitterContextContext } from '@/context/event-emitter' +import { + DSLImportMode, + DSLImportStatus, +} from '@/models/app' +import { + useImportPipelineDSL, + useImportPipelineDSLConfirm, +} from '@/service/use-pipeline' +import { fetchWorkflowDraft } from '@/service/workflow' + +type VersionInfo = { + importedVersion: string + systemVersion: string +} + +type UseUpdateDSLModalParams = { + onCancel: () => void + onImport?: () => void +} + +const isCompletedStatus = (status: DSLImportStatus): boolean => + status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS + +export const useUpdateDSLModal = ({ onCancel, onImport }: UseUpdateDSLModalParams) => { + const { t } = useTranslation() + const { notify } = useContext(ToastContext) + const { eventEmitter } = useEventEmitterContextContext() + const workflowStore = useWorkflowStore() + const { handleCheckPluginDependencies } = usePluginDependencies() + const { mutateAsync: importDSL } = useImportPipelineDSL() + const { mutateAsync: importDSLConfirm } = useImportPipelineDSLConfirm() + + // File state + const [currentFile, setDSLFile] = useState() + const [fileContent, setFileContent] = useState() + + // Modal state + const [show, setShow] = useState(true) + const [showErrorModal, setShowErrorModal] = useState(false) + + // Import state + const [loading, setLoading] = useState(false) + const [versions, setVersions] = useState() + const [importId, setImportId] = useState() + const isCreatingRef = useRef(false) + + const readFile = (file: File) => { + const reader = new FileReader() + reader.onload = (event) => { + setFileContent(event.target?.result as string) + } + reader.readAsText(file) + } + + const handleFile = (file?: File) => { + setDSLFile(file) + if (file) + readFile(file) + if (!file) + setFileContent('') + } + + const notifyError = useCallback(() => { + setLoading(false) + notify({ type: 'error', message: t('common.importFailure', { ns: 'workflow' }) }) + }, [notify, t]) + + const updateWorkflow = useCallback(async (pipelineId: string) => { + const { graph, hash, rag_pipeline_variables } = await fetchWorkflowDraft( + `/rag/pipelines/${pipelineId}/workflows/draft`, + ) + const { nodes, edges, viewport } = graph + + eventEmitter?.emit({ + type: WORKFLOW_DATA_UPDATE, + payload: { + nodes: initialNodes(nodes, edges), + edges: initialEdges(edges, nodes), + viewport, + hash, + rag_pipeline_variables: rag_pipeline_variables || [], + }, + }) + }, [eventEmitter]) + + const completeImport = useCallback(async ( + pipelineId: string | undefined, + status: DSLImportStatus = DSLImportStatus.COMPLETED, + ) => { + if (!pipelineId) { + notifyError() + return + } + + updateWorkflow(pipelineId) + onImport?.() + + const isWarning = status === DSLImportStatus.COMPLETED_WITH_WARNINGS + notify({ + type: isWarning ? 'warning' : 'success', + message: t(isWarning ? 'common.importWarning' : 'common.importSuccess', { ns: 'workflow' }), + children: isWarning && t('common.importWarningDetails', { ns: 'workflow' }), + }) + + await handleCheckPluginDependencies(pipelineId, true) + setLoading(false) + onCancel() + }, [updateWorkflow, onImport, notify, t, handleCheckPluginDependencies, onCancel, notifyError]) + + const showVersionMismatch = useCallback(( + id: string, + importedVersion?: string, + systemVersion?: string, + ) => { + setShow(false) + setTimeout(() => setShowErrorModal(true), 300) + setVersions({ + importedVersion: importedVersion ?? '', + systemVersion: systemVersion ?? '', + }) + setImportId(id) + }, []) + + const handleImport: MouseEventHandler = useCallback(async () => { + const { pipelineId } = workflowStore.getState() + if (isCreatingRef.current) + return + isCreatingRef.current = true + if (!currentFile) + return + + try { + if (!pipelineId || !fileContent) + return + + setLoading(true) + const response = await importDSL({ + mode: DSLImportMode.YAML_CONTENT, + yaml_content: fileContent, + pipeline_id: pipelineId, + }) + const { id, status, pipeline_id, imported_dsl_version, current_dsl_version } = response + + if (isCompletedStatus(status)) + await completeImport(pipeline_id, status) + else if (status === DSLImportStatus.PENDING) + showVersionMismatch(id, imported_dsl_version, current_dsl_version) + else + notifyError() + } + catch { + notifyError() + } + isCreatingRef.current = false + }, [currentFile, fileContent, workflowStore, importDSL, completeImport, showVersionMismatch, notifyError]) + + const onUpdateDSLConfirm: MouseEventHandler = useCallback(async () => { + if (!importId) + return + + try { + const { status, pipeline_id } = await importDSLConfirm(importId) + + if (status === DSLImportStatus.COMPLETED) { + await completeImport(pipeline_id) + return + } + + if (status === DSLImportStatus.FAILED) + notifyError() + } + catch { + notifyError() + } + }, [importId, importDSLConfirm, completeImport, notifyError]) + + return { + currentFile, + handleFile, + show, + showErrorModal, + setShowErrorModal, + loading, + versions, + handleImport, + onUpdateDSLConfirm, + } +} diff --git a/web/app/components/sentry-initializer.tsx b/web/app/components/sentry-initializer.tsx index ee161647e3..8a7286f908 100644 --- a/web/app/components/sentry-initializer.tsx +++ b/web/app/components/sentry-initializer.tsx @@ -4,12 +4,13 @@ import * as Sentry from '@sentry/react' import { useEffect } from 'react' import { IS_DEV } from '@/config' +import { env } from '@/env' const SentryInitializer = ({ children, }: { children: React.ReactElement }) => { useEffect(() => { - const SENTRY_DSN = document?.body?.getAttribute('data-public-sentry-dsn') + const SENTRY_DSN = env.NEXT_PUBLIC_SENTRY_DSN if (!IS_DEV && SENTRY_DSN) { Sentry.init({ dsn: SENTRY_DSN, diff --git a/web/app/components/tools/workflow-tool/utils.test.ts b/web/app/components/tools/workflow-tool/utils.test.ts index bc2dc98c19..ef95699af6 100644 --- a/web/app/components/tools/workflow-tool/utils.test.ts +++ b/web/app/components/tools/workflow-tool/utils.test.ts @@ -13,6 +13,54 @@ describe('buildWorkflowOutputParameters', () => { expect(result).toBe(params) }) + it('fills missing output description and type from schema when array input exists', () => { + const params: WorkflowToolProviderOutputParameter[] = [ + { name: 'answer', description: '', type: undefined }, + { name: 'files', description: 'keep this description', type: VarType.arrayFile }, + ] + const schema: WorkflowToolProviderOutputSchema = { + type: 'object', + properties: { + answer: { + type: VarType.string, + description: 'Generated answer', + }, + files: { + type: VarType.arrayFile, + description: 'Schema files description', + }, + }, + } + + const result = buildWorkflowOutputParameters(params, schema) + + expect(result).toEqual([ + { name: 'answer', description: 'Generated answer', type: VarType.string }, + { name: 'files', description: 'keep this description', type: VarType.arrayFile }, + ]) + }) + + it('falls back to empty description when both payload and schema descriptions are missing', () => { + const params: WorkflowToolProviderOutputParameter[] = [ + { name: 'missing_desc', description: '', type: undefined }, + ] + const schema: WorkflowToolProviderOutputSchema = { + type: 'object', + properties: { + other_field: { + type: VarType.string, + description: 'Other', + }, + }, + } + + const result = buildWorkflowOutputParameters(params, schema) + + expect(result).toEqual([ + { name: 'missing_desc', description: '', type: undefined }, + ]) + }) + it('derives parameters from schema when explicit array missing', () => { const schema: WorkflowToolProviderOutputSchema = { type: 'object', @@ -44,4 +92,56 @@ describe('buildWorkflowOutputParameters', () => { it('returns empty array when no source information is provided', () => { expect(buildWorkflowOutputParameters(null, null)).toEqual([]) }) + + it('derives parameters from schema when explicit array is empty', () => { + const schema: WorkflowToolProviderOutputSchema = { + type: 'object', + properties: { + output_text: { + type: VarType.string, + description: 'Output text', + }, + }, + } + + const result = buildWorkflowOutputParameters([], schema) + + expect(result).toEqual([ + { name: 'output_text', description: 'Output text', type: VarType.string }, + ]) + }) + + it('returns undefined type when schema output type is missing', () => { + const schema = { + type: 'object', + properties: { + answer: { + description: 'Answer without type', + }, + }, + } as unknown as WorkflowToolProviderOutputSchema + + const result = buildWorkflowOutputParameters(undefined, schema) + + expect(result).toEqual([ + { name: 'answer', description: 'Answer without type', type: undefined }, + ]) + }) + + it('falls back to empty description when schema-derived description is missing', () => { + const schema = { + type: 'object', + properties: { + answer: { + type: VarType.string, + }, + }, + } as unknown as WorkflowToolProviderOutputSchema + + const result = buildWorkflowOutputParameters(undefined, schema) + + expect(result).toEqual([ + { name: 'answer', description: '', type: VarType.string }, + ]) + }) }) diff --git a/web/app/components/tools/workflow-tool/utils.ts b/web/app/components/tools/workflow-tool/utils.ts index 80d832fb47..c5a5ef17d9 100644 --- a/web/app/components/tools/workflow-tool/utils.ts +++ b/web/app/components/tools/workflow-tool/utils.ts @@ -14,15 +14,28 @@ export const buildWorkflowOutputParameters = ( outputParameters: WorkflowToolProviderOutputParameter[] | null | undefined, outputSchema?: WorkflowToolProviderOutputSchema | null, ): WorkflowToolProviderOutputParameter[] => { - if (Array.isArray(outputParameters)) - return outputParameters + const schemaProperties = outputSchema?.properties - if (!outputSchema?.properties) + if (Array.isArray(outputParameters) && outputParameters.length > 0) { + if (!schemaProperties) + return outputParameters + + return outputParameters.map((item) => { + const schema = schemaProperties[item.name] + return { + ...item, + description: item.description || schema?.description || '', + type: normalizeVarType(item.type || schema?.type), + } + }) + } + + if (!schemaProperties) return [] - return Object.entries(outputSchema.properties).map(([name, schema]) => ({ + return Object.entries(schemaProperties).map(([name, schema]) => ({ name, - description: schema.description, + description: schema.description || '', type: normalizeVarType(schema.type), })) } 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 930eb9a334..2a85f61200 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 @@ -1,6 +1,5 @@ import type { WorkflowDraftFeaturesPayload } from '@/service/workflow' import { produce } from 'immer' -import { useParams } from 'next/navigation' import { useCallback } from 'react' import { useStoreApi } from 'reactflow' import { useFeaturesStore } from '@/app/components/base/features/hooks' @@ -10,6 +9,7 @@ import { useNodesReadOnly } from '@/app/components/workflow/hooks/use-workflow' import { useWorkflowStore } from '@/app/components/workflow/store' import { API_PREFIX } from '@/config' import { useGlobalPublicStore } from '@/context/global-public-context' +import { postWithKeepalive } from '@/service/fetch' import { syncWorkflowDraft } from '@/service/workflow' import { useWorkflowRefreshDraft } from '.' @@ -19,7 +19,6 @@ export const useNodesSyncDraft = () => { const featuresStore = useFeaturesStore() const { getNodesReadOnly } = useNodesReadOnly() const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft() - const params = useParams() const isCollaborationEnabled = useGlobalPublicStore(s => s.systemFeatures.enable_collaboration_mode) const getPostParams = useCallback(() => { @@ -104,13 +103,9 @@ export const useNodesSyncDraft = () => { const postParams = getPostParams() - if (postParams) { - navigator.sendBeacon( - `${API_PREFIX}/apps/${params.appId}/workflows/draft`, - JSON.stringify(postParams.params), - ) - } - }, [getPostParams, params.appId, getNodesReadOnly, isCollaborationEnabled]) + if (postParams) + postWithKeepalive(`${API_PREFIX}${postParams.url}`, postParams.params) + }, [getPostParams, getNodesReadOnly, isCollaborationEnabled]) const performSync = useCallback(async ( notRefreshWhenSyncError?: boolean, 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 9ef10f0fbf..2157a8c631 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 @@ -434,10 +434,10 @@ const VarReferenceVars: FC = ({ const nodeVars = matchedByTitle ? node.vars : node.vars.filter((v) => { - if (v.variable.toLowerCase().includes(normalizedSearchTextLower)) - return true - return matchesNestedVar(v, normalizedSearchTextLower) - }) + if (v.variable.toLowerCase().includes(normalizedSearchTextLower)) + return true + return matchesNestedVar(v, normalizedSearchTextLower) + }) if (nodeVars.length === 0) return res.push({ diff --git a/web/app/components/workflow/nodes/human-input/__tests__/human-input.test.tsx b/web/app/components/workflow/nodes/human-input/__tests__/human-input.test.tsx new file mode 100644 index 0000000000..cfb88d3507 --- /dev/null +++ b/web/app/components/workflow/nodes/human-input/__tests__/human-input.test.tsx @@ -0,0 +1,567 @@ +import type { ReactNode } from 'react' +import type { HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types' +import type { + Edge, + Node, +} from '@/app/components/workflow/types' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node' +import humanInputDefault from '@/app/components/workflow/nodes/human-input/default' +import HumanInputNode from '@/app/components/workflow/nodes/human-input/node' +import { + DeliveryMethodType, + UserActionButtonType, +} from '@/app/components/workflow/nodes/human-input/types' +import { BlockEnum } from '@/app/components/workflow/types' +import { initialNodes, preprocessNodesAndEdges } from '@/app/components/workflow/utils/workflow-init' + +// Mock reactflow which is needed by initialNodes and NodeSourceHandle +vi.mock('reactflow', async () => { + const reactflow = await vi.importActual('reactflow') + return { + ...reactflow, + Handle: ({ children }: { children?: ReactNode }) =>
{children}
, + } +}) + +// Minimal store state mirroring the fields that NodeSourceHandle selects +const mockStoreState = { + shouldAutoOpenStartNodeSelector: false, + setShouldAutoOpenStartNodeSelector: vi.fn(), + setHasSelectedStartNode: vi.fn(), +} + +// Mock workflow store used by NodeSourceHandle +// useStore accepts a selector and applies it to the state, so tests break +// if the component starts selecting fields that aren't provided here. +vi.mock('@/app/components/workflow/store', () => ({ + useStore: vi.fn((selector?: (s: typeof mockStoreState) => unknown) => + selector ? selector(mockStoreState) : mockStoreState, + ), + useWorkflowStore: vi.fn(() => ({ + getState: () => ({ + getNodes: () => [], + }), + })), +})) + +// Mock workflow hooks barrel (used by NodeSourceHandle via ../../../hooks) +vi.mock('@/app/components/workflow/hooks', () => ({ + useNodesInteractions: () => ({ + handleNodeAdd: vi.fn(), + }), + useNodesReadOnly: () => ({ + getNodesReadOnly: () => false, + nodesReadOnly: false, + }), + useAvailableBlocks: () => ({ + availableNextBlocks: [], + availablePrevBlocks: [], + }), + useIsChatMode: () => false, +})) + +// ── Factory: Build a realistic human-input node as it would appear after DSL import ── +const createHumanInputNode = (overrides?: Partial): Node => ({ + id: 'human-input-1', + type: 'custom', + position: { x: 400, y: 200 }, + data: { + type: BlockEnum.HumanInput, + title: 'Human Input', + desc: 'Wait for human input', + delivery_methods: [ + { + id: 'dm-1', + type: DeliveryMethodType.WebApp, + enabled: true, + }, + { + id: 'dm-2', + type: DeliveryMethodType.Email, + enabled: true, + config: { + recipients: { whole_workspace: false, items: [] }, + subject: 'Please review', + body: 'Please review the form', + debug_mode: false, + }, + }, + ], + form_content: '# Review Form\nPlease fill in the details below.', + inputs: [ + { + type: 'text-input', + output_variable_name: 'review_result', + default: { selector: [], type: 'constant' as const, value: '' }, + }, + ], + user_actions: [ + { + id: 'approve', + title: 'Approve', + button_style: UserActionButtonType.Primary, + }, + { + id: 'reject', + title: 'Reject', + button_style: UserActionButtonType.Default, + }, + ], + timeout: 3, + timeout_unit: 'day' as const, + ...overrides, + } as HumanInputNodeType, +}) + +const createStartNode = (): Node => ({ + id: 'start-1', + type: 'custom', + position: { x: 100, y: 200 }, + data: { + type: BlockEnum.Start, + title: 'Start', + desc: '', + } as Node['data'], +}) + +const createEdge = (source: string, target: string, sourceHandle = 'source', targetHandle = 'target'): Edge => ({ + id: `${source}-${sourceHandle}-${target}-${targetHandle}`, + type: 'custom', + source, + sourceHandle, + target, + targetHandle, + data: {}, +} as Edge) + +describe('DSL Import with Human Input Node', () => { + // ── preprocessNodesAndEdges: human-input nodes pass through without error ── + describe('preprocessNodesAndEdges', () => { + it('should pass through a workflow containing a human-input node unchanged', () => { + const humanInputNode = createHumanInputNode() + const startNode = createStartNode() + const nodes = [startNode, humanInputNode] + const edges = [createEdge('start-1', 'human-input-1')] + + const result = preprocessNodesAndEdges(nodes as Node[], edges as Edge[]) + + expect(result.nodes).toHaveLength(2) + expect(result.edges).toHaveLength(1) + expect(result.nodes).toEqual(nodes) + expect(result.edges).toEqual(edges) + }) + + it('should not treat human-input node as an iteration or loop node', () => { + const humanInputNode = createHumanInputNode() + const nodes = [humanInputNode] + + const result = preprocessNodesAndEdges(nodes as Node[], []) + + // No extra iteration/loop start nodes should be injected + expect(result.nodes).toHaveLength(1) + expect(result.nodes[0].data.type).toBe(BlockEnum.HumanInput) + }) + }) + + // ── initialNodes: human-input nodes are properly initialized ── + describe('initialNodes', () => { + it('should initialize a human-input node with connected handle IDs', () => { + const humanInputNode = createHumanInputNode() + const startNode = createStartNode() + const nodes = [startNode, humanInputNode] + const edges = [createEdge('start-1', 'human-input-1')] + + const result = initialNodes(nodes as Node[], edges as Edge[]) + + const processedHumanInput = result.find(n => n.id === 'human-input-1') + expect(processedHumanInput).toBeDefined() + expect(processedHumanInput!.data.type).toBe(BlockEnum.HumanInput) + // initialNodes sets _connectedSourceHandleIds and _connectedTargetHandleIds + expect(processedHumanInput!.data._connectedSourceHandleIds).toBeDefined() + expect(processedHumanInput!.data._connectedTargetHandleIds).toBeDefined() + }) + + it('should preserve human-input node data after initialization', () => { + const humanInputNode = createHumanInputNode() + const nodes = [humanInputNode] + + const result = initialNodes(nodes as Node[], []) + + const processed = result[0] + const nodeData = processed.data as HumanInputNodeType + expect(nodeData.delivery_methods).toHaveLength(2) + expect(nodeData.user_actions).toHaveLength(2) + expect(nodeData.form_content).toBe('# Review Form\nPlease fill in the details below.') + expect(nodeData.timeout).toBe(3) + expect(nodeData.timeout_unit).toBe('day') + }) + + it('should set node type to custom if not set', () => { + const humanInputNode = createHumanInputNode() + delete (humanInputNode as Record).type + + const result = initialNodes([humanInputNode] as Node[], []) + + expect(result[0].type).toBe('custom') + }) + }) + + // ── Node component: renders without crashing for all data variations ── + describe('HumanInputNode Component', () => { + it('should render without crashing with full DSL data', () => { + const node = createHumanInputNode() + + expect(() => { + render( + , + ) + }).not.toThrow() + }) + + it('should display delivery method labels when methods are present', () => { + const node = createHumanInputNode() + + render( + , + ) + + // Delivery method type labels are rendered in lowercase + expect(screen.getByText('webapp')).toBeInTheDocument() + expect(screen.getByText('email')).toBeInTheDocument() + }) + + it('should display user action IDs', () => { + const node = createHumanInputNode() + + render( + , + ) + + expect(screen.getByText('approve')).toBeInTheDocument() + expect(screen.getByText('reject')).toBeInTheDocument() + }) + + it('should always display Timeout handle', () => { + const node = createHumanInputNode() + + render( + , + ) + + expect(screen.getByText('Timeout')).toBeInTheDocument() + }) + + it('should render without crashing when delivery_methods is empty', () => { + const node = createHumanInputNode({ delivery_methods: [] }) + + expect(() => { + render( + , + ) + }).not.toThrow() + + // Delivery method section should not be rendered + expect(screen.queryByText('webapp')).not.toBeInTheDocument() + expect(screen.queryByText('email')).not.toBeInTheDocument() + }) + + it('should render without crashing when user_actions is empty', () => { + const node = createHumanInputNode({ user_actions: [] }) + + expect(() => { + render( + , + ) + }).not.toThrow() + + // Timeout handle should still exist + expect(screen.getByText('Timeout')).toBeInTheDocument() + }) + + it('should render without crashing when both delivery_methods and user_actions are empty', () => { + const node = createHumanInputNode({ + delivery_methods: [], + user_actions: [], + form_content: '', + inputs: [], + }) + + expect(() => { + render( + , + ) + }).not.toThrow() + }) + + it('should render with only webapp delivery method', () => { + const node = createHumanInputNode({ + delivery_methods: [ + { id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true }, + ], + }) + + render( + , + ) + + expect(screen.getByText('webapp')).toBeInTheDocument() + expect(screen.queryByText('email')).not.toBeInTheDocument() + }) + + it('should render with multiple user actions', () => { + const node = createHumanInputNode({ + user_actions: [ + { id: 'action_1', title: 'Approve', button_style: UserActionButtonType.Primary }, + { id: 'action_2', title: 'Reject', button_style: UserActionButtonType.Default }, + { id: 'action_3', title: 'Escalate', button_style: UserActionButtonType.Accent }, + ], + }) + + render( + , + ) + + expect(screen.getByText('action_1')).toBeInTheDocument() + expect(screen.getByText('action_2')).toBeInTheDocument() + expect(screen.getByText('action_3')).toBeInTheDocument() + }) + }) + + // ── Node registration: human-input is included in the workflow node registry ── + // Verify via WORKFLOW_COMMON_NODES (lightweight metadata-only imports) instead + // of NodeComponentMap/PanelComponentMap which pull in every node's heavy UI deps. + describe('Node Registration', () => { + it('should have HumanInput included in WORKFLOW_COMMON_NODES', () => { + const entry = WORKFLOW_COMMON_NODES.find( + n => n.metaData.type === BlockEnum.HumanInput, + ) + expect(entry).toBeDefined() + }) + }) + + // ── Default config & validation ── + describe('HumanInput Default Configuration', () => { + it('should provide default values for a new human-input node', () => { + const defaultValue = humanInputDefault.defaultValue + + expect(defaultValue.delivery_methods).toEqual([]) + expect(defaultValue.user_actions).toEqual([]) + expect(defaultValue.form_content).toBe('') + expect(defaultValue.inputs).toEqual([]) + expect(defaultValue.timeout).toBe(3) + expect(defaultValue.timeout_unit).toBe('day') + }) + + it('should validate that delivery methods are required', () => { + const t = (key: string) => key + const payload = { + ...humanInputDefault.defaultValue, + delivery_methods: [], + } as HumanInputNodeType + + const result = humanInputDefault.checkValid(payload, t) + + expect(result.isValid).toBe(false) + expect(result.errorMessage).toBeTruthy() + }) + + it('should validate that at least one delivery method is enabled', () => { + const t = (key: string) => key + const payload = { + ...humanInputDefault.defaultValue, + delivery_methods: [ + { id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: false }, + ], + user_actions: [ + { id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary }, + ], + } as HumanInputNodeType + + const result = humanInputDefault.checkValid(payload, t) + + expect(result.isValid).toBe(false) + }) + + it('should validate that user actions are required', () => { + const t = (key: string) => key + const payload = { + ...humanInputDefault.defaultValue, + delivery_methods: [ + { id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true }, + ], + user_actions: [], + } as HumanInputNodeType + + const result = humanInputDefault.checkValid(payload, t) + + expect(result.isValid).toBe(false) + }) + + it('should validate that user action IDs are not duplicated', () => { + const t = (key: string) => key + const payload = { + ...humanInputDefault.defaultValue, + delivery_methods: [ + { id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true }, + ], + user_actions: [ + { id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary }, + { id: 'approve', title: 'Also Approve', button_style: UserActionButtonType.Default }, + ], + } as HumanInputNodeType + + const result = humanInputDefault.checkValid(payload, t) + + expect(result.isValid).toBe(false) + }) + + it('should pass validation with correct configuration', () => { + const t = (key: string) => key + const payload = { + ...humanInputDefault.defaultValue, + delivery_methods: [ + { id: 'dm-1', type: DeliveryMethodType.WebApp, enabled: true }, + ], + user_actions: [ + { id: 'approve', title: 'Approve', button_style: UserActionButtonType.Primary }, + { id: 'reject', title: 'Reject', button_style: UserActionButtonType.Default }, + ], + } as HumanInputNodeType + + const result = humanInputDefault.checkValid(payload, t) + + expect(result.isValid).toBe(true) + expect(result.errorMessage).toBe('') + }) + }) + + // ── Output variables generation ── + describe('HumanInput Output Variables', () => { + it('should generate output variables from form inputs', () => { + const payload = { + ...humanInputDefault.defaultValue, + inputs: [ + { type: 'text-input', output_variable_name: 'review_result', default: { selector: [], type: 'constant' as const, value: '' } }, + { type: 'text-input', output_variable_name: 'comment', default: { selector: [], type: 'constant' as const, value: '' } }, + ], + } as HumanInputNodeType + + const outputVars = humanInputDefault.getOutputVars!(payload, {}, []) + + expect(outputVars).toEqual([ + { variable: 'review_result', type: 'string' }, + { variable: 'comment', type: 'string' }, + ]) + }) + + it('should return empty output variables when no form inputs exist', () => { + const payload = { + ...humanInputDefault.defaultValue, + inputs: [], + } as HumanInputNodeType + + const outputVars = humanInputDefault.getOutputVars!(payload, {}, []) + + expect(outputVars).toEqual([]) + }) + }) + + // ── Full DSL import simulation: start → human-input → end ── + describe('Full Workflow with Human Input Node', () => { + it('should process a start → human-input → end workflow without errors', () => { + const startNode = createStartNode() + const humanInputNode = createHumanInputNode() + const endNode: Node = { + id: 'end-1', + type: 'custom', + position: { x: 700, y: 200 }, + data: { + type: BlockEnum.End, + title: 'End', + desc: '', + outputs: [], + } as Node['data'], + } + + const nodes = [startNode, humanInputNode, endNode] + const edges = [ + createEdge('start-1', 'human-input-1'), + createEdge('human-input-1', 'end-1', 'approve', 'target'), + ] + + const processed = preprocessNodesAndEdges(nodes as Node[], edges as Edge[]) + expect(processed.nodes).toHaveLength(3) + expect(processed.edges).toHaveLength(2) + + const initialized = initialNodes(nodes as Node[], edges as Edge[]) + expect(initialized).toHaveLength(3) + + // All node types should be preserved + const types = initialized.map(n => n.data.type) + expect(types).toContain(BlockEnum.Start) + expect(types).toContain(BlockEnum.HumanInput) + expect(types).toContain(BlockEnum.End) + }) + + it('should handle multiple branches from human-input user actions', () => { + const startNode = createStartNode() + const humanInputNode = createHumanInputNode() + const approveEndNode: Node = { + id: 'approve-end', + type: 'custom', + position: { x: 700, y: 100 }, + data: { type: BlockEnum.End, title: 'Approve End', desc: '', outputs: [] } as Node['data'], + } + const rejectEndNode: Node = { + id: 'reject-end', + type: 'custom', + position: { x: 700, y: 300 }, + data: { type: BlockEnum.End, title: 'Reject End', desc: '', outputs: [] } as Node['data'], + } + + const nodes = [startNode, humanInputNode, approveEndNode, rejectEndNode] + const edges = [ + createEdge('start-1', 'human-input-1'), + createEdge('human-input-1', 'approve-end', 'approve', 'target'), + createEdge('human-input-1', 'reject-end', 'reject', 'target'), + ] + + const initialized = initialNodes(nodes as Node[], edges as Edge[]) + expect(initialized).toHaveLength(4) + + // Human input node should still have correct data + const hiNode = initialized.find(n => n.id === 'human-input-1')! + expect((hiNode.data as HumanInputNodeType).user_actions).toHaveLength(2) + expect((hiNode.data as HumanInputNodeType).delivery_methods).toHaveLength(2) + }) + }) +}) 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 7e694e9f55..b09b0cfff6 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 @@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next' import { InputNumber } from '@/app/components/base/input-number' import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' +import { env } from '@/env' export type TopKAndScoreThresholdProps = { topK: number @@ -15,12 +16,7 @@ export type TopKAndScoreThresholdProps = { hiddenScoreThreshold?: boolean } -const maxTopK = (() => { - const configValue = Number.parseInt(globalThis.document?.body?.getAttribute('data-public-top-k-max-value') || '', 10) - if (configValue && !isNaN(configValue)) - return configValue - return 10 -})() +const maxTopK = env.NEXT_PUBLIC_TOP_K_MAX_VALUE const TOP_K_VALUE_LIMIT = { amount: 1, min: 1, diff --git a/web/app/components/workflow/nodes/llm/utils.ts b/web/app/components/workflow/nodes/llm/utils.ts index 9922529cb5..6d32419f36 100644 --- a/web/app/components/workflow/nodes/llm/utils.ts +++ b/web/app/components/workflow/nodes/llm/utils.ts @@ -1,6 +1,6 @@ import type { ValidationError } from 'jsonschema' import type { ArrayItems, Field, LLMNodeType } from './types' -import { z } from 'zod' +import * as z from 'zod' import { draft07Validator, forbidBooleanProperties } from '@/utils/validators' import { ArrayType, FILE_REF_FORMAT, Type } from './types' diff --git a/web/app/components/workflow/variable-inspect/utils.tsx b/web/app/components/workflow/variable-inspect/utils.tsx index cd5ffe4abd..5aebb30400 100644 --- a/web/app/components/workflow/variable-inspect/utils.tsx +++ b/web/app/components/workflow/variable-inspect/utils.tsx @@ -1,6 +1,6 @@ import type { EnvironmentVariable } from '@/app/components/workflow/types' import type { VarInInspect } from '@/types/workflow' -import { z } from 'zod' +import * as z from 'zod' import { VarType } from '@/app/components/workflow/types' import { VarInInspectType } from '@/types/workflow' @@ -11,11 +11,10 @@ const arrayNumberSchemaParttern = z.array(z.number()) const literalSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]) type Literal = z.infer type Json = Literal | { [key: string]: Json } | Json[] -const jsonSchema: z.ZodType = z.lazy(() => z.union([literalSchema, z.array(jsonSchema), z.record(jsonSchema)])) +const jsonSchema: z.ZodType = z.lazy(() => z.union([literalSchema, z.array(jsonSchema), z.record(z.string(), jsonSchema)])) const arrayJsonSchema: z.ZodType = z.lazy(() => z.array(jsonSchema)) type JsonSchemaType = 'array[string]' | 'array[number]' | 'object' | 'array[object]' | 'array[message]' -type JsonSchemaValue = Json | Json[] | string[] | number[] const isJsonSchemaType = (value: string): value is JsonSchemaType => { return value === 'array[string]' @@ -25,7 +24,7 @@ const isJsonSchemaType = (value: string): value is JsonSchemaType => { || value === 'array[message]' } -const validateKnownJSONSchema = (schema: unknown, type: JsonSchemaType): z.SafeParseReturnType => { +const validateKnownJSONSchema = (schema: unknown, type: JsonSchemaType) => { if (type === 'array[string]') return arrayStringSchemaParttern.safeParse(schema) if (type === 'array[number]') @@ -63,7 +62,7 @@ export const toEnvVarInInspect = (envVar: EnvironmentVariable): VarInInspect => } } -export const validateJSONSchema = (schema: unknown, type: string): z.SafeParseReturnType => { +export const validateJSONSchema = (schema: unknown, type: string) => { if (!isJsonSchemaType(type)) return z.unknown().safeParse(schema) return validateKnownJSONSchema(schema, type) diff --git a/web/app/forgot-password/ForgotPasswordForm.tsx b/web/app/forgot-password/ForgotPasswordForm.tsx index ff33cccc82..274c2fd4e6 100644 --- a/web/app/forgot-password/ForgotPasswordForm.tsx +++ b/web/app/forgot-password/ForgotPasswordForm.tsx @@ -7,7 +7,7 @@ import { useRouter } from 'next/navigation' import * as React from 'react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import { z } from 'zod' +import * as z from 'zod' import Button from '@/app/components/base/button' import { formContext, useAppForm } from '@/app/components/base/form' import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator' @@ -22,10 +22,10 @@ import Input from '../components/base/input' import Loading from '../components/base/loading' const accountFormSchema = z.object({ - email: z - .string() - .min(1, { message: 'error.emailInValid' }) - .email('error.emailInValid'), + email: z.email('error.emailInValid') + .min(1, { + error: 'error.emailInValid', + }), }) const ForgotPasswordForm = () => { diff --git a/web/app/install/installForm.tsx b/web/app/install/installForm.tsx index d729d92abf..47de6d1fb3 100644 --- a/web/app/install/installForm.tsx +++ b/web/app/install/installForm.tsx @@ -7,7 +7,7 @@ import { useRouter } from 'next/navigation' import * as React from 'react' import { useEffect } from 'react' import { useTranslation } from 'react-i18next' -import { z } from 'zod' +import * as z from 'zod' import Button from '@/app/components/base/button' import { formContext, useAppForm } from '@/app/components/base/form' import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-validator' @@ -22,13 +22,15 @@ import { encryptPassword as encodePassword } from '@/utils/encryption' import Loading from '../components/base/loading' const accountFormSchema = z.object({ - email: z - .string() - .min(1, { message: 'error.emailInValid' }) - .email('error.emailInValid'), - name: z.string().min(1, { message: 'error.nameEmpty' }), + email: z.email('error.emailInValid') + .min(1, { + error: 'error.emailInValid', + }), + name: z.string().min(1, { + error: 'error.nameEmpty', + }), password: z.string().min(8, { - message: 'error.passwordLengthInValid', + error: 'error.passwordLengthInValid', }).regex(validPassword, 'error.passwordInvalid'), }) diff --git a/web/app/layout.tsx b/web/app/layout.tsx index 199c12e814..a19d5e1e57 100644 --- a/web/app/layout.tsx +++ b/web/app/layout.tsx @@ -5,8 +5,8 @@ import { Instrument_Serif } from 'next/font/google' import { NuqsAdapter } from 'nuqs/adapters/next/app' import GlobalPublicStoreProvider from '@/context/global-public-context' import { TanstackQueryInitializer } from '@/context/query-client' +import { getDatasetMap } from '@/env' import { getLocaleOnServer } from '@/i18n-config/server' -import { DatasetAttr } from '@/types/feature' import { cn } from '@/utils/classnames' import { ToastProvider } from './components/base/toast' import BrowserInitializer from './components/browser-initializer' @@ -39,41 +39,7 @@ const LocaleLayout = async ({ children: React.ReactNode }) => { const locale = await getLocaleOnServer() - - const datasetMap: Record = { - [DatasetAttr.DATA_API_PREFIX]: process.env.NEXT_PUBLIC_API_PREFIX, - [DatasetAttr.DATA_PUBLIC_API_PREFIX]: process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX, - [DatasetAttr.DATA_MARKETPLACE_API_PREFIX]: process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX, - [DatasetAttr.DATA_MARKETPLACE_URL_PREFIX]: process.env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX, - [DatasetAttr.DATA_PUBLIC_EDITION]: process.env.NEXT_PUBLIC_EDITION, - [DatasetAttr.DATA_PUBLIC_AMPLITUDE_API_KEY]: process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY, - [DatasetAttr.DATA_PUBLIC_COOKIE_DOMAIN]: process.env.NEXT_PUBLIC_COOKIE_DOMAIN, - [DatasetAttr.DATA_PUBLIC_SOCKET_URL]: process.env.NEXT_PUBLIC_SOCKET_URL, - [DatasetAttr.DATA_PUBLIC_SUPPORT_MAIL_LOGIN]: process.env.NEXT_PUBLIC_SUPPORT_MAIL_LOGIN, - [DatasetAttr.DATA_PUBLIC_SENTRY_DSN]: process.env.NEXT_PUBLIC_SENTRY_DSN, - [DatasetAttr.DATA_PUBLIC_MAINTENANCE_NOTICE]: process.env.NEXT_PUBLIC_MAINTENANCE_NOTICE, - [DatasetAttr.DATA_PUBLIC_SITE_ABOUT]: process.env.NEXT_PUBLIC_SITE_ABOUT, - [DatasetAttr.DATA_PUBLIC_TEXT_GENERATION_TIMEOUT_MS]: process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS, - [DatasetAttr.DATA_PUBLIC_MAX_TOOLS_NUM]: process.env.NEXT_PUBLIC_MAX_TOOLS_NUM, - [DatasetAttr.DATA_PUBLIC_MAX_PARALLEL_LIMIT]: process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT, - [DatasetAttr.DATA_PUBLIC_TOP_K_MAX_VALUE]: process.env.NEXT_PUBLIC_TOP_K_MAX_VALUE, - [DatasetAttr.DATA_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH]: process.env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH, - [DatasetAttr.DATA_PUBLIC_LOOP_NODE_MAX_COUNT]: process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT, - [DatasetAttr.DATA_PUBLIC_MAX_ITERATIONS_NUM]: process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM, - [DatasetAttr.DATA_PUBLIC_MAX_TREE_DEPTH]: process.env.NEXT_PUBLIC_MAX_TREE_DEPTH, - [DatasetAttr.DATA_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME]: process.env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME, - [DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_JINAREADER]: process.env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER, - [DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_FIRECRAWL]: process.env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL, - [DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_WATERCRAWL]: process.env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL, - [DatasetAttr.DATA_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX]: process.env.NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX, - [DatasetAttr.NEXT_PUBLIC_ZENDESK_WIDGET_KEY]: process.env.NEXT_PUBLIC_ZENDESK_WIDGET_KEY, - [DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT]: process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT, - [DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION]: process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION, - [DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL]: process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL, - [DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID]: process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID, - [DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN]: process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN, - [DatasetAttr.DATA_PUBLIC_BATCH_CONCURRENCY]: process.env.NEXT_PUBLIC_BATCH_CONCURRENCY, - } + const datasetMap = getDatasetMap() return ( diff --git a/web/app/serwist/[path]/route.ts b/web/app/serwist/[path]/route.ts index beca2cd412..aac0aad17d 100644 --- a/web/app/serwist/[path]/route.ts +++ b/web/app/serwist/[path]/route.ts @@ -1,6 +1,7 @@ import { createSerwistRoute } from '@serwist/turbopack' +import { env } from '@/env' -const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '' +const basePath = env.NEXT_PUBLIC_BASE_PATH export const { dynamic, dynamicParams, revalidate, generateStaticParams, GET } = createSerwistRoute({ swSrc: 'app/sw.ts', diff --git a/web/config/index.ts b/web/config/index.ts index b845a2dab8..3ae4c7afdb 100644 --- a/web/config/index.ts +++ b/web/config/index.ts @@ -1,101 +1,51 @@ import type { ModelParameterRule } from '@/app/components/header/account-setting/model-provider-page/declarations' import { InputVarType } from '@/app/components/workflow/types' +import { env } from '@/env' import { PromptRole } from '@/models/debug' import { PipelineInputVarType } from '@/models/pipeline' import { AgentStrategy } from '@/types/app' -import { DatasetAttr } from '@/types/feature' import pkg from '../package.json' -const getBooleanConfig = ( - envVar: string | undefined, - dataAttrKey: DatasetAttr, - defaultValue: boolean = true, -) => { - if (envVar !== undefined && envVar !== '') - return envVar === 'true' - const attrValue = globalThis.document?.body?.getAttribute(dataAttrKey) - if (attrValue !== undefined && attrValue !== '') - return attrValue === 'true' - return defaultValue -} - -const getNumberConfig = ( - envVar: string | undefined, - dataAttrKey: DatasetAttr, - defaultValue: number, -) => { - if (envVar) { - const parsed = Number.parseInt(envVar) - if (!Number.isNaN(parsed) && parsed > 0) - return parsed - } - - const attrValue = globalThis.document?.body?.getAttribute(dataAttrKey) - if (attrValue) { - const parsed = Number.parseInt(attrValue) - if (!Number.isNaN(parsed) && parsed > 0) - return parsed - } - return defaultValue -} - const getStringConfig = ( envVar: string | undefined, - dataAttrKey: DatasetAttr, defaultValue: string, ) => { if (envVar) return envVar - - const attrValue = globalThis.document?.body?.getAttribute(dataAttrKey) - if (attrValue) - return attrValue return defaultValue } export const API_PREFIX = getStringConfig( - process.env.NEXT_PUBLIC_API_PREFIX, - DatasetAttr.DATA_API_PREFIX, + env.NEXT_PUBLIC_API_PREFIX, 'http://localhost:5001/console/api', ) export const PUBLIC_API_PREFIX = getStringConfig( - process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX, - DatasetAttr.DATA_PUBLIC_API_PREFIX, + env.NEXT_PUBLIC_PUBLIC_API_PREFIX, 'http://localhost:5001/api', ) export const MARKETPLACE_API_PREFIX = getStringConfig( - process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX, - DatasetAttr.DATA_MARKETPLACE_API_PREFIX, + env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX, 'http://localhost:5002/api', ) export const MARKETPLACE_URL_PREFIX = getStringConfig( - process.env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX, - DatasetAttr.DATA_MARKETPLACE_URL_PREFIX, + env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX, '', ) -const EDITION = getStringConfig( - process.env.NEXT_PUBLIC_EDITION, - DatasetAttr.DATA_PUBLIC_EDITION, - 'SELF_HOSTED', -) +const EDITION = env.NEXT_PUBLIC_EDITION export const IS_CE_EDITION = EDITION === 'SELF_HOSTED' export const IS_CLOUD_EDITION = EDITION === 'CLOUD' export const AMPLITUDE_API_KEY = getStringConfig( - process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY, - DatasetAttr.DATA_PUBLIC_AMPLITUDE_API_KEY, + env.NEXT_PUBLIC_AMPLITUDE_API_KEY, '', ) -export const IS_DEV = process.env.NODE_ENV === 'development' -export const IS_PROD = process.env.NODE_ENV === 'production' +export const IS_DEV = env.NODE_ENV === 'development' +export const IS_PROD = env.NODE_ENV === 'production' -export const SUPPORT_MAIL_LOGIN = !!( - process.env.NEXT_PUBLIC_SUPPORT_MAIL_LOGIN - || globalThis.document?.body?.getAttribute('data-public-support-mail-login') -) +export const SUPPORT_MAIL_LOGIN = env.NEXT_PUBLIC_SUPPORT_MAIL_LOGIN export const TONE_LIST = [ { @@ -161,21 +111,15 @@ export const getMaxToken = (modelId: string) => { export const LOCALE_COOKIE_NAME = 'locale' const COOKIE_DOMAIN = getStringConfig( - process.env.NEXT_PUBLIC_COOKIE_DOMAIN, - DatasetAttr.DATA_PUBLIC_COOKIE_DOMAIN, + env.NEXT_PUBLIC_COOKIE_DOMAIN, '', ).trim() export const SOCKET_URL = getStringConfig( - process.env.NEXT_PUBLIC_SOCKET_URL, - DatasetAttr.DATA_PUBLIC_SOCKET_URL, + env.NEXT_PUBLIC_SOCKET_URL, 'ws://localhost:5001', ).trim() -export const BATCH_CONCURRENCY = getNumberConfig( - process.env.NEXT_PUBLIC_BATCH_CONCURRENCY, - DatasetAttr.DATA_PUBLIC_BATCH_CONCURRENCY, - 5, // default -) +export const BATCH_CONCURRENCY = env.NEXT_PUBLIC_BATCH_CONCURRENCY export const CSRF_COOKIE_NAME = () => { if (COOKIE_DOMAIN) @@ -349,112 +293,62 @@ export const resetReg = () => (VAR_REGEX.lastIndex = 0) export const HITL_INPUT_REG = /\{\{(#\$output\.(?:[a-z_]\w{0,29}){1,10}#)\}\}/gi export const resetHITLInputReg = () => HITL_INPUT_REG.lastIndex = 0 -export const DISABLE_UPLOAD_IMAGE_AS_ICON = process.env.NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON === 'true' +export const DISABLE_UPLOAD_IMAGE_AS_ICON = env.NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON export const GITHUB_ACCESS_TOKEN - = process.env.NEXT_PUBLIC_GITHUB_ACCESS_TOKEN || '' + = env.NEXT_PUBLIC_GITHUB_ACCESS_TOKEN export const SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS = '.difypkg,.difybndl' export const FULL_DOC_PREVIEW_LENGTH = 50 export const JSON_SCHEMA_MAX_DEPTH = 10 -export const MAX_TOOLS_NUM = getNumberConfig( - process.env.NEXT_PUBLIC_MAX_TOOLS_NUM, - DatasetAttr.DATA_PUBLIC_MAX_TOOLS_NUM, - 10, -) -export const MAX_PARALLEL_LIMIT = getNumberConfig( - process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT, - DatasetAttr.DATA_PUBLIC_MAX_PARALLEL_LIMIT, - 10, -) -export const TEXT_GENERATION_TIMEOUT_MS = getNumberConfig( - process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS, - DatasetAttr.DATA_PUBLIC_TEXT_GENERATION_TIMEOUT_MS, - 60000, -) -export const LOOP_NODE_MAX_COUNT = getNumberConfig( - process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT, - DatasetAttr.DATA_PUBLIC_LOOP_NODE_MAX_COUNT, - 100, -) -export const MAX_ITERATIONS_NUM = getNumberConfig( - process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM, - DatasetAttr.DATA_PUBLIC_MAX_ITERATIONS_NUM, - 99, -) -export const MAX_TREE_DEPTH = getNumberConfig( - process.env.NEXT_PUBLIC_MAX_TREE_DEPTH, - DatasetAttr.DATA_PUBLIC_MAX_TREE_DEPTH, - 50, -) +export const MAX_TOOLS_NUM = env.NEXT_PUBLIC_MAX_TOOLS_NUM +export const MAX_PARALLEL_LIMIT = env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT +export const TEXT_GENERATION_TIMEOUT_MS = env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS +export const LOOP_NODE_MAX_COUNT = env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT +export const MAX_ITERATIONS_NUM = env.NEXT_PUBLIC_MAX_ITERATIONS_NUM +export const MAX_TREE_DEPTH = env.NEXT_PUBLIC_MAX_TREE_DEPTH -export const ALLOW_UNSAFE_DATA_SCHEME = getBooleanConfig( - process.env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME, - DatasetAttr.DATA_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME, - false, -) -export const ENABLE_WEBSITE_JINAREADER = getBooleanConfig( - process.env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER, - DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_JINAREADER, - true, -) -export const ENABLE_WEBSITE_FIRECRAWL = getBooleanConfig( - process.env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL, - DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_FIRECRAWL, - true, -) -export const ENABLE_WEBSITE_WATERCRAWL = getBooleanConfig( - process.env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL, - DatasetAttr.DATA_PUBLIC_ENABLE_WEBSITE_WATERCRAWL, - false, -) -export const ENABLE_SINGLE_DOLLAR_LATEX = getBooleanConfig( - process.env.NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX, - DatasetAttr.DATA_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX, - false, -) +export const ALLOW_UNSAFE_DATA_SCHEME = env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME +export const ENABLE_WEBSITE_JINAREADER = env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER +export const ENABLE_WEBSITE_FIRECRAWL = env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL +export const ENABLE_WEBSITE_WATERCRAWL = env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL +export const ENABLE_SINGLE_DOLLAR_LATEX = env.NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX export const VALUE_SELECTOR_DELIMITER = '@@@' export const validPassword = /^(?=.*[a-z])(?=.*\d)\S{8,}$/i export const ZENDESK_WIDGET_KEY = getStringConfig( - process.env.NEXT_PUBLIC_ZENDESK_WIDGET_KEY, - DatasetAttr.NEXT_PUBLIC_ZENDESK_WIDGET_KEY, + env.NEXT_PUBLIC_ZENDESK_WIDGET_KEY, '', ) export const ZENDESK_FIELD_IDS = { ENVIRONMENT: getStringConfig( - process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT, - DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT, + env.NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT, '', ), VERSION: getStringConfig( - process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION, - DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION, + env.NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION, '', ), EMAIL: getStringConfig( - process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL, - DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL, + env.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL, '', ), WORKSPACE_ID: getStringConfig( - process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID, - DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID, + env.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID, '', ), PLAN: getStringConfig( - process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN, - DatasetAttr.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN, + env.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN, '', ), } export const APP_VERSION = pkg.version -export const IS_MARKETPLACE = globalThis.document?.body?.getAttribute('data-is-marketplace') === 'true' +export const IS_MARKETPLACE = env.NEXT_PUBLIC_IS_MARKETPLACE export const RAG_PIPELINE_PREVIEW_CHUNK_NUM = 20 diff --git a/web/context/app-context.tsx b/web/context/app-context.tsx index 12000044d6..dfcada3423 100644 --- a/web/context/app-context.tsx +++ b/web/context/app-context.tsx @@ -10,6 +10,7 @@ import { setUserId, setUserProperties } from '@/app/components/base/amplitude' import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils' import MaintenanceNotice from '@/app/components/header/maintenance-notice' import { ZENDESK_FIELD_IDS } from '@/config' +import { env } from '@/env' import { useCurrentWorkspace, useLangGeniusVersion, @@ -204,7 +205,7 @@ export const AppContextProvider: FC = ({ children }) => }} >
- {globalThis.document?.body?.getAttribute('data-public-maintenance-notice') && } + {env.NEXT_PUBLIC_MAINTENANCE_NOTICE && }
{children}
diff --git a/web/context/event-emitter.tsx b/web/context/event-emitter.tsx index ab311bed02..da46f522d4 100644 --- a/web/context/event-emitter.tsx +++ b/web/context/event-emitter.tsx @@ -4,9 +4,20 @@ import type { EventEmitter } from 'ahooks/lib/useEventEmitter' import { useEventEmitter } from 'ahooks' import { createContext, useContext } from 'use-context-selector' -export type EventPayload = string | ({ type: string } & Record) +/** + * Typed event object emitted via the shared EventEmitter. + * Covers workflow updates, prompt-editor commands, DSL export checks, etc. + */ +export type EventEmitterMessage = { + type: string + payload?: unknown + instanceId?: string +} -const EventEmitterContext = createContext<{ eventEmitter: EventEmitter | null }>({ +export type EventEmitterValue = string | EventEmitterMessage +export type EventPayload = EventEmitterValue + +const EventEmitterContext = createContext<{ eventEmitter: EventEmitter | null }>({ eventEmitter: null, }) @@ -18,7 +29,7 @@ type EventEmitterContextProviderProps = { export const EventEmitterContextProvider = ({ children, }: EventEmitterContextProviderProps) => { - const eventEmitter = useEventEmitter() + const eventEmitter = useEventEmitter() return ( diff --git a/web/env.ts b/web/env.ts new file mode 100644 index 0000000000..0c7ce72490 --- /dev/null +++ b/web/env.ts @@ -0,0 +1,243 @@ +import type { CamelCase, Replace } from 'string-ts' +import { createEnv } from '@t3-oss/env-nextjs' +import { concat, kebabCase, length, slice } from 'string-ts' +import * as z from 'zod' +import { isClient, isServer } from './utils/client' +import { ObjectFromEntries, ObjectKeys } from './utils/object' + +const CLIENT_ENV_PREFIX = 'NEXT_PUBLIC_' +type ClientSchema = Record<`${typeof CLIENT_ENV_PREFIX}${string}`, z.ZodType> + +const coercedBoolean = z.string() + .refine(s => s === 'true' || s === 'false' || s === '0' || s === '1') + .transform(s => s === 'true' || s === '1') +const coercedNumber = z.coerce.number().int().positive() + +/// keep-sorted +const clientSchema = { + /** + * Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking + */ + NEXT_PUBLIC_ALLOW_EMBED: coercedBoolean.default(false), + /** + * Allow rendering unsafe URLs which have "data:" scheme. + */ + NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME: coercedBoolean.default(false), + /** + * The API key of amplitude + */ + NEXT_PUBLIC_AMPLITUDE_API_KEY: z.string().optional(), + /** + * The base URL of console application, refers to the Console base URL of WEB service if console domain is + * different from api or web app domain. + * example: http://cloud.dify.ai/console/api + */ + NEXT_PUBLIC_API_PREFIX: z.string().optional(), + /** + * The base path for the application + */ + NEXT_PUBLIC_BASE_PATH: z.string().regex(/^\/.*[^/]$/).or(z.literal('')).default(''), + /** + * number of concurrency + */ + NEXT_PUBLIC_BATCH_CONCURRENCY: coercedNumber.default(5), + /** + * When the frontend and backend run on different subdomains, set NEXT_PUBLIC_COOKIE_DOMAIN=1. + */ + NEXT_PUBLIC_COOKIE_DOMAIN: z.string().optional(), + /** + * CSP https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP + */ + NEXT_PUBLIC_CSP_WHITELIST: z.string().optional(), + /** + * For production release, change this to PRODUCTION + */ + NEXT_PUBLIC_DEPLOY_ENV: z.enum(['DEVELOPMENT', 'PRODUCTION', 'TESTING']).optional(), + NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON: coercedBoolean.default(false), + /** + * The deployment edition, SELF_HOSTED + */ + NEXT_PUBLIC_EDITION: z.enum(['SELF_HOSTED', 'CLOUD']).default('SELF_HOSTED'), + /** + * Enable inline LaTeX rendering with single dollar signs ($...$) + * Default is false for security reasons to prevent conflicts with regular text + */ + NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX: coercedBoolean.default(false), + NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL: coercedBoolean.default(true), + NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER: coercedBoolean.default(true), + NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL: coercedBoolean.default(false), + /** + * Github Access Token, used for invoking Github API + */ + NEXT_PUBLIC_GITHUB_ACCESS_TOKEN: z.string().optional(), + /** + * The maximum number of tokens for segmentation + */ + NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: coercedNumber.default(4000), + NEXT_PUBLIC_IS_MARKETPLACE: coercedBoolean.default(false), + /** + * Maximum loop count in the workflow + */ + NEXT_PUBLIC_LOOP_NODE_MAX_COUNT: coercedNumber.default(100), + NEXT_PUBLIC_MAINTENANCE_NOTICE: z.string().optional(), + /** + * The API PREFIX for MARKETPLACE + */ + NEXT_PUBLIC_MARKETPLACE_API_PREFIX: z.url().optional(), + /** + * The URL for MARKETPLACE + */ + NEXT_PUBLIC_MARKETPLACE_URL_PREFIX: z.url().optional(), + /** + * The maximum number of iterations for agent setting + */ + NEXT_PUBLIC_MAX_ITERATIONS_NUM: coercedNumber.default(99), + /** + * Maximum number of Parallelism branches in the workflow + */ + NEXT_PUBLIC_MAX_PARALLEL_LIMIT: coercedNumber.default(10), + /** + * Maximum number of tools in the agent/workflow + */ + NEXT_PUBLIC_MAX_TOOLS_NUM: coercedNumber.default(10), + /** + * The maximum number of tree node depth for workflow + */ + NEXT_PUBLIC_MAX_TREE_DEPTH: coercedNumber.default(50), + /** + * The URL for Web APP, refers to the Web App base URL of WEB service if web app domain is different from + * console or api domain. + * example: http://udify.app/api + */ + NEXT_PUBLIC_PUBLIC_API_PREFIX: z.string().optional(), + /** + * SENTRY + */ + NEXT_PUBLIC_SENTRY_DSN: z.string().optional(), + NEXT_PUBLIC_SITE_ABOUT: z.string().optional(), + NEXT_PUBLIC_SOCKET_URL: z.string().optional(), + NEXT_PUBLIC_SUPPORT_MAIL_LOGIN: coercedBoolean.default(false), + /** + * The timeout for the text generation in millisecond + */ + NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS: coercedNumber.default(60000), + /** + * The maximum number of top-k value for RAG. + */ + NEXT_PUBLIC_TOP_K_MAX_VALUE: coercedNumber.default(10), + /** + * Disable Upload Image as WebApp icon default is false + */ + NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON: coercedBoolean.default(false), + NEXT_PUBLIC_WEB_PREFIX: z.url().optional(), + NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL: z.string().optional(), + NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT: z.string().optional(), + NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN: z.string().optional(), + NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION: z.string().optional(), + NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID: z.string().optional(), + NEXT_PUBLIC_ZENDESK_WIDGET_KEY: z.string().optional(), +} satisfies ClientSchema + +export const env = createEnv({ + server: { + /** + * Build-time source map switches for production build. + * Priority is handled in next.config.ts. + */ + ENABLE_PROD_SOURCEMAP: z.string().optional(), + ENABLE_SOURCE_MAP: z.string().optional(), + /** + * Maximum length of segmentation tokens for indexing + */ + INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: coercedNumber.default(4000), + /** + * Disable Next.js Telemetry (https://nextjs.org/telemetry) + */ + NEXT_TELEMETRY_DISABLED: coercedBoolean.optional(), + PORT: coercedNumber.default(3000), + /** + * The timeout for the text generation in millisecond + */ + TEXT_GENERATION_TIMEOUT_MS: coercedNumber.default(60000), + }, + shared: { + NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), + }, + client: clientSchema, + experimental__runtimeEnv: { + NODE_ENV: process.env.NODE_ENV, + NEXT_PUBLIC_ALLOW_EMBED: isServer ? process.env.NEXT_PUBLIC_ALLOW_EMBED : getRuntimeEnvFromBody('allowEmbed'), + NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME: isServer ? process.env.NEXT_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME : getRuntimeEnvFromBody('allowUnsafeDataScheme'), + NEXT_PUBLIC_AMPLITUDE_API_KEY: isServer ? process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY : getRuntimeEnvFromBody('amplitudeApiKey'), + NEXT_PUBLIC_API_PREFIX: isServer ? process.env.NEXT_PUBLIC_API_PREFIX : getRuntimeEnvFromBody('apiPrefix'), + NEXT_PUBLIC_BASE_PATH: isServer ? process.env.NEXT_PUBLIC_BASE_PATH : getRuntimeEnvFromBody('basePath'), + NEXT_PUBLIC_BATCH_CONCURRENCY: isServer ? process.env.NEXT_PUBLIC_BATCH_CONCURRENCY : getRuntimeEnvFromBody('batchConcurrency'), + NEXT_PUBLIC_COOKIE_DOMAIN: isServer ? process.env.NEXT_PUBLIC_COOKIE_DOMAIN : getRuntimeEnvFromBody('cookieDomain'), + NEXT_PUBLIC_CSP_WHITELIST: isServer ? process.env.NEXT_PUBLIC_CSP_WHITELIST : getRuntimeEnvFromBody('cspWhitelist'), + NEXT_PUBLIC_DEPLOY_ENV: isServer ? process.env.NEXT_PUBLIC_DEPLOY_ENV : getRuntimeEnvFromBody('deployEnv'), + NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON: isServer ? process.env.NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON : getRuntimeEnvFromBody('disableUploadImageAsIcon'), + NEXT_PUBLIC_EDITION: isServer ? process.env.NEXT_PUBLIC_EDITION : getRuntimeEnvFromBody('edition'), + NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX: isServer ? process.env.NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX : getRuntimeEnvFromBody('enableSingleDollarLatex'), + NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL: isServer ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL : getRuntimeEnvFromBody('enableWebsiteFirecrawl'), + NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER: isServer ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER : getRuntimeEnvFromBody('enableWebsiteJinareader'), + NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL: isServer ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL : getRuntimeEnvFromBody('enableWebsiteWatercrawl'), + NEXT_PUBLIC_GITHUB_ACCESS_TOKEN: isServer ? process.env.NEXT_PUBLIC_GITHUB_ACCESS_TOKEN : getRuntimeEnvFromBody('githubAccessToken'), + NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH: isServer ? process.env.NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH : getRuntimeEnvFromBody('indexingMaxSegmentationTokensLength'), + NEXT_PUBLIC_IS_MARKETPLACE: isServer ? process.env.NEXT_PUBLIC_IS_MARKETPLACE : getRuntimeEnvFromBody('isMarketplace'), + NEXT_PUBLIC_LOOP_NODE_MAX_COUNT: isServer ? process.env.NEXT_PUBLIC_LOOP_NODE_MAX_COUNT : getRuntimeEnvFromBody('loopNodeMaxCount'), + NEXT_PUBLIC_MAINTENANCE_NOTICE: isServer ? process.env.NEXT_PUBLIC_MAINTENANCE_NOTICE : getRuntimeEnvFromBody('maintenanceNotice'), + NEXT_PUBLIC_MARKETPLACE_API_PREFIX: isServer ? process.env.NEXT_PUBLIC_MARKETPLACE_API_PREFIX : getRuntimeEnvFromBody('marketplaceApiPrefix'), + NEXT_PUBLIC_MARKETPLACE_URL_PREFIX: isServer ? process.env.NEXT_PUBLIC_MARKETPLACE_URL_PREFIX : getRuntimeEnvFromBody('marketplaceUrlPrefix'), + NEXT_PUBLIC_MAX_ITERATIONS_NUM: isServer ? process.env.NEXT_PUBLIC_MAX_ITERATIONS_NUM : getRuntimeEnvFromBody('maxIterationsNum'), + NEXT_PUBLIC_MAX_PARALLEL_LIMIT: isServer ? process.env.NEXT_PUBLIC_MAX_PARALLEL_LIMIT : getRuntimeEnvFromBody('maxParallelLimit'), + NEXT_PUBLIC_MAX_TOOLS_NUM: isServer ? process.env.NEXT_PUBLIC_MAX_TOOLS_NUM : getRuntimeEnvFromBody('maxToolsNum'), + NEXT_PUBLIC_MAX_TREE_DEPTH: isServer ? process.env.NEXT_PUBLIC_MAX_TREE_DEPTH : getRuntimeEnvFromBody('maxTreeDepth'), + NEXT_PUBLIC_PUBLIC_API_PREFIX: isServer ? process.env.NEXT_PUBLIC_PUBLIC_API_PREFIX : getRuntimeEnvFromBody('publicApiPrefix'), + NEXT_PUBLIC_SENTRY_DSN: isServer ? process.env.NEXT_PUBLIC_SENTRY_DSN : getRuntimeEnvFromBody('sentryDsn'), + NEXT_PUBLIC_SOCKET_URL: isServer ? process.env.NEXT_PUBLIC_SOCKET_URL : getRuntimeEnvFromBody('socketUrl'), + NEXT_PUBLIC_SITE_ABOUT: isServer ? process.env.NEXT_PUBLIC_SITE_ABOUT : getRuntimeEnvFromBody('siteAbout'), + NEXT_PUBLIC_SUPPORT_MAIL_LOGIN: isServer ? process.env.NEXT_PUBLIC_SUPPORT_MAIL_LOGIN : getRuntimeEnvFromBody('supportMailLogin'), + NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS: isServer ? process.env.NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS : getRuntimeEnvFromBody('textGenerationTimeoutMs'), + NEXT_PUBLIC_TOP_K_MAX_VALUE: isServer ? process.env.NEXT_PUBLIC_TOP_K_MAX_VALUE : getRuntimeEnvFromBody('topKMaxValue'), + NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON: isServer ? process.env.NEXT_PUBLIC_UPLOAD_IMAGE_AS_ICON : getRuntimeEnvFromBody('uploadImageAsIcon'), + NEXT_PUBLIC_WEB_PREFIX: isServer ? process.env.NEXT_PUBLIC_WEB_PREFIX : getRuntimeEnvFromBody('webPrefix'), + NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL: isServer ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL : getRuntimeEnvFromBody('zendeskFieldIdEmail'), + NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT: isServer ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT : getRuntimeEnvFromBody('zendeskFieldIdEnvironment'), + NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN: isServer ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN : getRuntimeEnvFromBody('zendeskFieldIdPlan'), + NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION: isServer ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION : getRuntimeEnvFromBody('zendeskFieldIdVersion'), + NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID: isServer ? process.env.NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID : getRuntimeEnvFromBody('zendeskFieldIdWorkspaceId'), + NEXT_PUBLIC_ZENDESK_WIDGET_KEY: isServer ? process.env.NEXT_PUBLIC_ZENDESK_WIDGET_KEY : getRuntimeEnvFromBody('zendeskWidgetKey'), + }, + emptyStringAsUndefined: true, +}) + +type ClientEnvKey = keyof typeof clientSchema +type DatasetKey = CamelCase> + +/** + * Browser-only function to get runtime env value from HTML body dataset. + */ +function getRuntimeEnvFromBody(key: DatasetKey) { + if (typeof window === 'undefined') { + throw new TypeError('getRuntimeEnvFromBody can only be called in the browser') + } + + const value = document.body.dataset[key] + return value || undefined +} + +/** + * Server-only function to get dataset map for embedding into the HTML body. + */ +export function getDatasetMap() { + if (isClient) { + throw new TypeError('getDatasetMap can only be called on the server') + } + return ObjectFromEntries( + ObjectKeys(clientSchema) + .map(envKey => [ + concat('data-', kebabCase(slice(envKey, length(CLIENT_ENV_PREFIX)))), + env[envKey], + ]), + ) +} diff --git a/web/eslint-suppressions.json b/web/eslint-suppressions.json index ca0eec75f7..ccc80aa896 100644 --- a/web/eslint-suppressions.json +++ b/web/eslint-suppressions.json @@ -1414,11 +1414,6 @@ "count": 1 } }, - "app/components/base/param-item/top-k-item.tsx": { - "unicorn/prefer-number-properties": { - "count": 1 - } - }, "app/components/base/portal-to-follow-elem/index.tsx": { "react-refresh/only-export-components": { "count": 2 @@ -1990,14 +1985,6 @@ "count": 1 } }, - "app/components/datasets/documents/detail/metadata/index.tsx": { - "react-hooks-extra/no-direct-set-state-in-use-effect": { - "count": 4 - }, - "ts/no-explicit-any": { - "count": 2 - } - }, "app/components/datasets/documents/detail/new-segment.tsx": { "ts/no-explicit-any": { "count": 1 @@ -2707,11 +2694,6 @@ "count": 2 } }, - "app/components/rag-pipeline/components/update-dsl-modal.tsx": { - "ts/no-explicit-any": { - "count": 1 - } - }, "app/components/rag-pipeline/hooks/use-DSL.ts": { "ts/no-explicit-any": { "count": 1 @@ -3560,11 +3542,6 @@ "count": 4 } }, - "app/components/workflow/nodes/knowledge-base/components/retrieval-setting/top-k-and-score-threshold.tsx": { - "unicorn/prefer-number-properties": { - "count": 1 - } - }, "app/components/workflow/nodes/knowledge-base/components/retrieval-setting/type.ts": { "ts/no-explicit-any": { "count": 2 diff --git a/web/next.config.ts b/web/next.config.ts index 58505528c4..cac8041a07 100644 --- a/web/next.config.ts +++ b/web/next.config.ts @@ -1,10 +1,8 @@ import type { NextConfig } from 'next' -import process from 'node:process' -import withBundleAnalyzerInit from '@next/bundle-analyzer' import createMDX from '@next/mdx' import { codeInspectorPlugin } from 'code-inspector-plugin' +import { env } from './env' -const isDev = process.env.NODE_ENV === 'development' const parseBooleanEnv = (value: string | undefined): boolean | undefined => { if (value === 'true') return true @@ -14,9 +12,10 @@ const parseBooleanEnv = (value: string | undefined): boolean | undefined => { return undefined } -const enableSourceMap = parseBooleanEnv(process.env.ENABLE_SOURCE_MAP) -const enableProdSourceMapsFallback = parseBooleanEnv(process.env.ENABLE_PROD_SOURCEMAP) ?? false +const enableSourceMap = parseBooleanEnv(env.ENABLE_SOURCE_MAP) +const enableProdSourceMapsFallback = parseBooleanEnv(env.ENABLE_PROD_SOURCEMAP) ?? false const enableProdSourceMaps = enableSourceMap ?? enableProdSourceMapsFallback +const isDev = env.NODE_ENV === 'development' const withMDX = createMDX({ extension: /\.mdx?$/, options: { @@ -29,20 +28,17 @@ const withMDX = createMDX({ // providerImportSource: "@mdx-js/react", }, }) -const withBundleAnalyzer = withBundleAnalyzerInit({ - enabled: process.env.ANALYZE === 'true', -}) // the default url to prevent parse url error when running jest -const hasSetWebPrefix = process.env.NEXT_PUBLIC_WEB_PREFIX -const port = process.env.PORT || 3000 +const hasSetWebPrefix = env.NEXT_PUBLIC_WEB_PREFIX +const port = env.PORT const locImageURLs = !hasSetWebPrefix ? [new URL(`http://localhost:${port}/**`), new URL(`http://127.0.0.1:${port}/**`)] : [] -const remoteImageURLs = ([hasSetWebPrefix ? new URL(`${process.env.NEXT_PUBLIC_WEB_PREFIX}/**`) : '', ...locImageURLs].filter(item => !!item)) as URL[] +const remoteImageURLs = ([hasSetWebPrefix ? new URL(`${env.NEXT_PUBLIC_WEB_PREFIX}/**`) : '', ...locImageURLs].filter(item => !!item)) as URL[] const nextConfig: NextConfig = { - basePath: process.env.NEXT_PUBLIC_BASE_PATH || '', + basePath: env.NEXT_PUBLIC_BASE_PATH, serverExternalPackages: ['esbuild'], - transpilePackages: ['echarts', 'zrender'], + transpilePackages: ['@t3-oss/env-core', '@t3-oss/env-nextjs', 'echarts', 'zrender'], turbopack: { rules: codeInspectorPlugin({ bundler: 'turbopack', @@ -97,4 +93,4 @@ const nextConfig: NextConfig = { }, } -export default withBundleAnalyzer(withMDX(nextConfig)) +export default withMDX(nextConfig) diff --git a/web/package.json b/web/package.json index 4e18e3f071..434fafeec9 100644 --- a/web/package.json +++ b/web/package.json @@ -54,9 +54,8 @@ "storybook": "storybook dev -p 6006", "storybook:build": "storybook build", "preinstall": "npx only-allow pnpm", - "analyze": "ANALYZE=true pnpm build", - "knip": "knip", - "fetch-skill-templates": "tsx ./scripts/fetch-skill-templates.ts" + "analyze": "next experimental-analyze", + "knip": "knip" }, "dependencies": { "@amplitude/analytics-browser": "2.33.1", @@ -83,6 +82,7 @@ "@remixicon/react": "4.7.0", "@sentry/react": "8.55.0", "@svgdotjs/svg.js": "3.2.5", + "@t3-oss/env-nextjs": "0.13.10", "@tailwindcss/typography": "0.5.19", "@tanstack/react-form": "1.23.7", "@tanstack/react-query": "5.90.5", @@ -166,7 +166,7 @@ "use-context-selector": "2.0.0", "uuid": "10.0.0", "wa-sqlite": "1.0.0", - "zod": "3.25.76", + "zod": "4.3.6", "zundo": "2.3.0", "zustand": "5.0.9" }, @@ -179,7 +179,6 @@ "@iconify-json/ri": "1.2.9", "@mdx-js/loader": "3.1.1", "@mdx-js/react": "3.1.1", - "@next/bundle-analyzer": "16.1.5", "@next/eslint-plugin-next": "16.1.6", "@next/mdx": "16.1.5", "@rgrove/parse-xml": "4.2.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 66cdf29c78..94895d499e 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: '@svgdotjs/svg.js': specifier: 3.2.5 version: 3.2.5 + '@t3-oss/env-nextjs': + specifier: 0.13.10 + version: 0.13.10(typescript@5.9.3)(valibot@1.2.0(typescript@5.9.3))(zod@4.3.6) '@tailwindcss/typography': specifier: 0.5.19 version: 0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2)) @@ -375,8 +378,8 @@ importers: specifier: 1.0.0 version: 1.0.0 zod: - specifier: 3.25.76 - version: 3.25.76 + specifier: 4.3.6 + version: 4.3.6 zundo: specifier: 2.3.0 version: 2.3.0(zustand@5.0.9(@types/react@19.2.9)(immer@11.1.0)(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4))) @@ -408,9 +411,6 @@ importers: '@mdx-js/react': specifier: 3.1.1 version: 3.1.1(@types/react@19.2.9)(react@19.2.4) - '@next/bundle-analyzer': - specifier: 16.1.5 - version: 16.1.5 '@next/eslint-plugin-next': specifier: 16.1.6 version: 16.1.6 @@ -960,10 +960,6 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} - '@discoveryjs/json-ext@0.5.7': - resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==} - engines: {node: '>=10.0.0'} - '@egoist/tailwindcss-icons@1.9.2': resolution: {integrity: sha512-I6XsSykmhu2cASg5Hp/ICLsJ/K/1aXPaSKjgbWaNp2xYnb4We/arWMmkhhV+9CglOFCUbqx0A3mM2kWV32ZIhw==} peerDependencies: @@ -1785,9 +1781,6 @@ packages: '@neoconfetti/react@1.0.0': resolution: {integrity: sha512-klcSooChXXOzIm+SE5IISIAn3bYzYfPjbX7D7HoqZL84oAfgREeSg5vSIaSFH+DaGzzvImTyWe1OyrJ67vik4A==} - '@next/bundle-analyzer@16.1.5': - resolution: {integrity: sha512-/iPMrxbvgMZQX1huKZu+rnh7bxo2m5/o0PpOWLMRcAlQ2METpZ7/a3SP/aXFePZAyrQpgpndTldXW3LxPXM/KA==} - '@next/env@16.0.0': resolution: {integrity: sha512-s5j2iFGp38QsG1LWRQaE2iUY3h1jc014/melHFfLdrsMJPqxqDQwWNwyQTcNoUSGZlCVZuM7t7JDMmSyRilsnA==} @@ -2889,6 +2882,40 @@ packages: '@swc/types@0.1.25': resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} + '@t3-oss/env-core@0.13.10': + resolution: {integrity: sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g==} + peerDependencies: + arktype: ^2.1.0 + typescript: '>=5.0.0' + valibot: ^1.0.0-beta.7 || ^1.0.0 + zod: ^3.24.0 || ^4.0.0 + peerDependenciesMeta: + arktype: + optional: true + typescript: + optional: true + valibot: + optional: true + zod: + optional: true + + '@t3-oss/env-nextjs@0.13.10': + resolution: {integrity: sha512-JfSA2WXOnvcc/uMdp31paMsfbYhhdvLLRxlwvrnlPE9bwM/n0Z+Qb9xRv48nPpvfMhOrkrTYw1I5Yc06WIKBJQ==} + peerDependencies: + arktype: ^2.1.0 + typescript: '>=5.0.0' + valibot: ^1.0.0-beta.7 || ^1.0.0 + zod: ^3.24.0 || ^4.0.0 + peerDependenciesMeta: + arktype: + optional: true + typescript: + optional: true + valibot: + optional: true + zod: + optional: true + '@tailwindcss/typography@0.5.19': resolution: {integrity: sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==} peerDependencies: @@ -3659,10 +3686,6 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn-walk@8.3.4: - resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} - engines: {node: '>=0.4.0'} - acorn@8.15.0: resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} engines: {node: '>=0.4.0'} @@ -4322,9 +4345,6 @@ packages: dayjs@1.11.19: resolution: {integrity: sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==} - debounce@1.2.1: - resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} - debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} @@ -4449,9 +4469,6 @@ packages: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} - duplexer@0.1.2: - resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} - echarts-for-react@3.0.5: resolution: {integrity: sha512-YpEI5Ty7O/2nvCfQ7ybNa+S90DwE8KYZWacGvJW4luUqywP7qStQ+pxDlYOmr4jGDu10mhEkiAuMKcUlT4W5vg==} peerDependencies: @@ -5131,10 +5148,6 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - gzip-size@6.0.0: - resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} - engines: {node: '>=10'} - hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -5411,10 +5424,6 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} - is-plain-object@5.0.0: - resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} - engines: {node: '>=0.10.0'} - is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -6150,10 +6159,6 @@ packages: openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} - opener@1.5.2: - resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} - hasBin: true - optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -6925,10 +6930,6 @@ packages: simple-swizzle@0.2.4: resolution: {integrity: sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==} - sirv@2.0.4: - resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} - engines: {node: '>= 10'} - sirv@3.0.2: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} @@ -7652,11 +7653,6 @@ packages: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} - webpack-bundle-analyzer@4.10.1: - resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==} - engines: {node: '>= 10.13.0'} - hasBin: true - webpack-sources@3.3.3: resolution: {integrity: sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==} engines: {node: '>=10.13.0'} @@ -7723,18 +7719,6 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - ws@7.5.10: - resolution: {integrity: sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==} - engines: {node: '>=8.3.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: ^5.0.2 - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - ws@8.18.3: resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} engines: {node: '>=10.0.0'} @@ -7821,9 +7805,6 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -8332,8 +8313,6 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} - '@discoveryjs/json-ext@0.5.7': {} - '@egoist/tailwindcss-icons@1.9.2(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@iconify/utils': 3.1.0 @@ -8529,7 +8508,7 @@ snapshots: eslint: 9.39.2(jiti@1.21.7) ts-pattern: 5.9.0 typescript: 5.9.3 - zod: 3.25.76 + zod: 4.3.6 transitivePeerDependencies: - supports-color @@ -9261,13 +9240,6 @@ snapshots: '@neoconfetti/react@1.0.0': {} - '@next/bundle-analyzer@16.1.5': - dependencies: - webpack-bundle-analyzer: 4.10.1 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - '@next/env@16.0.0': {} '@next/env@16.1.5': {} @@ -9568,7 +9540,8 @@ snapshots: '@pkgr/core@0.2.9': {} - '@polka/url@1.0.0-next.29': {} + '@polka/url@1.0.0-next.29': + optional: true '@preact/signals-core@1.12.2': {} @@ -10291,6 +10264,20 @@ snapshots: dependencies: '@swc/counter': 0.1.3 + '@t3-oss/env-core@0.13.10(typescript@5.9.3)(valibot@1.2.0(typescript@5.9.3))(zod@4.3.6)': + optionalDependencies: + typescript: 5.9.3 + valibot: 1.2.0(typescript@5.9.3) + zod: 4.3.6 + + '@t3-oss/env-nextjs@0.13.10(typescript@5.9.3)(valibot@1.2.0(typescript@5.9.3))(zod@4.3.6)': + dependencies: + '@t3-oss/env-core': 0.13.10(typescript@5.9.3)(valibot@1.2.0(typescript@5.9.3))(zod@4.3.6) + optionalDependencies: + typescript: 5.9.3 + valibot: 1.2.0(typescript@5.9.3) + zod: 4.3.6 + '@tailwindcss/typography@0.5.19(tailwindcss@3.4.19(tsx@4.21.0)(yaml@2.8.2))': dependencies: postcss-selector-parser: 6.0.10 @@ -11296,10 +11283,6 @@ snapshots: dependencies: acorn: 8.15.0 - acorn-walk@8.3.4: - dependencies: - acorn: 8.15.0 - acorn@8.15.0: {} agent-base@7.1.4: {} @@ -11977,8 +11960,6 @@ snapshots: dayjs@1.11.19: {} - debounce@1.2.1: {} - debug@4.3.7: dependencies: ms: 2.1.3 @@ -12082,8 +12063,6 @@ snapshots: dotenv@16.6.1: {} - duplexer@0.1.2: {} - echarts-for-react@3.0.5(echarts@5.6.0)(react@19.2.4): dependencies: echarts: 5.6.0 @@ -12401,8 +12380,8 @@ snapshots: '@babel/parser': 7.28.6 eslint: 9.39.2(jiti@1.21.7) hermes-parser: 0.25.1 - zod: 3.25.76 - zod-validation-error: 4.0.2(zod@3.25.76) + zod: 4.3.6 + zod-validation-error: 4.0.2(zod@4.3.6) transitivePeerDependencies: - supports-color @@ -12968,10 +12947,6 @@ snapshots: graphemer@1.4.0: {} - gzip-size@6.0.0: - dependencies: - duplexer: 0.1.2 - hachure-fill@0.5.2: {} has-flag@4.0.0: {} @@ -13304,8 +13279,6 @@ snapshots: is-plain-obj@4.1.0: {} - is-plain-object@5.0.0: {} - is-potential-custom-element-name@1.0.1: {} is-stream@3.0.0: {} @@ -14201,7 +14174,8 @@ snapshots: mri@1.2.0: {} - mrmime@2.0.1: {} + mrmime@2.0.1: + optional: true ms@2.1.3: {} @@ -14321,8 +14295,6 @@ snapshots: openapi-types@12.1.3: {} - opener@1.5.2: {} - optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -15300,12 +15272,6 @@ snapshots: dependencies: is-arrayish: 0.3.4 - sirv@2.0.4: - dependencies: - '@polka/url': 1.0.0-next.29 - mrmime: 2.0.1 - totalist: 3.0.1 - sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 @@ -15644,7 +15610,8 @@ snapshots: dependencies: eslint-visitor-keys: 5.0.0 - totalist@3.0.1: {} + totalist@3.0.1: + optional: true tough-cookie@6.0.0: dependencies: @@ -16032,25 +15999,6 @@ snapshots: webidl-conversions@8.0.1: {} - webpack-bundle-analyzer@4.10.1: - dependencies: - '@discoveryjs/json-ext': 0.5.7 - acorn: 8.15.0 - acorn-walk: 8.3.4 - commander: 7.2.0 - debounce: 1.2.1 - escape-string-regexp: 4.0.0 - gzip-size: 6.0.0 - html-escaper: 2.0.2 - is-plain-object: 5.0.0 - opener: 1.5.2 - picocolors: 1.1.1 - sirv: 2.0.4 - ws: 7.5.10 - transitivePeerDependencies: - - bufferutil - - utf-8-validate - webpack-sources@3.3.3: optional: true @@ -16139,8 +16087,6 @@ snapshots: wrappy@1.0.2: {} - ws@7.5.10: {} - ws@8.18.3: {} ws@8.19.0: {} @@ -16188,11 +16134,9 @@ snapshots: zen-observable@0.8.15: {} - zod-validation-error@4.0.2(zod@3.25.76): + zod-validation-error@4.0.2(zod@4.3.6): dependencies: - zod: 3.25.76 - - zod@3.25.76: {} + zod: 4.3.6 zod@4.3.6: {} diff --git a/web/proxy.ts b/web/proxy.ts index 26b03eacd9..78e8805a39 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -1,13 +1,14 @@ import type { NextRequest } from 'next/server' import { Buffer } from 'node:buffer' import { NextResponse } from 'next/server' +import { env } from '@/env' const NECESSARY_DOMAIN = '*.sentry.io http://localhost:* http://127.0.0.1:* https://analytics.google.com googletagmanager.com *.googletagmanager.com https://www.google-analytics.com https://api.github.com https://api2.amplitude.com *.amplitude.com' const wrapResponseWithXFrameOptions = (response: NextResponse, pathname: string) => { // prevent clickjacking: https://owasp.org/www-community/attacks/Clickjacking // Chatbot page should be allowed to be embedded in iframe. It's a feature - if (process.env.NEXT_PUBLIC_ALLOW_EMBED !== 'true' && !pathname.startsWith('/chat') && !pathname.startsWith('/workflow') && !pathname.startsWith('/completion') && !pathname.startsWith('/webapp-signin')) + if (env.NEXT_PUBLIC_ALLOW_EMBED !== true && !pathname.startsWith('/chat') && !pathname.startsWith('/workflow') && !pathname.startsWith('/completion') && !pathname.startsWith('/webapp-signin')) response.headers.set('X-Frame-Options', 'DENY') return response @@ -21,11 +22,11 @@ export function proxy(request: NextRequest) { }, }) - const isWhiteListEnabled = !!process.env.NEXT_PUBLIC_CSP_WHITELIST && process.env.NODE_ENV === 'production' + const isWhiteListEnabled = !!env.NEXT_PUBLIC_CSP_WHITELIST && env.NODE_ENV === 'production' if (!isWhiteListEnabled) return wrapResponseWithXFrameOptions(response, pathname) - const whiteList = `${process.env.NEXT_PUBLIC_CSP_WHITELIST} ${NECESSARY_DOMAIN}` + const whiteList = `${env.NEXT_PUBLIC_CSP_WHITELIST} ${NECESSARY_DOMAIN}` const nonce = Buffer.from(crypto.randomUUID()).toString('base64') const csp = `'nonce-${nonce}'` diff --git a/web/service/client.spec.ts b/web/service/client.spec.ts index d8b46ad4b6..95bf720bfe 100644 --- a/web/service/client.spec.ts +++ b/web/service/client.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const loadGetBaseURL = async (isClientValue: boolean) => { vi.resetModules() - vi.doMock('@/utils/client', () => ({ isClient: isClientValue })) + vi.doMock('@/utils/client', () => ({ isClient: isClientValue, isServer: !isClientValue })) const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) // eslint-disable-next-line next/no-assign-module-variable const module = await import('./client') diff --git a/web/service/fetch.ts b/web/service/fetch.ts index 04dfe74cc2..a8f29263d7 100644 --- a/web/service/fetch.ts +++ b/web/service/fetch.ts @@ -240,4 +240,30 @@ async function base(url: string, options: FetchOptionType = {}, otherOptions: return await res.json() as T } +/** + * Fire-and-forget POST with `keepalive: true` for use during page unload. + * Includes credentials, Authorization (if available), and CSRF header + * so the request is authenticated, matching the headers sent by the + * standard `base()` fetch wrapper. + */ +export function postWithKeepalive(url: string, body: Record): void { + const headers: Record = { + 'Content-Type': ContentType.json, + [CSRF_HEADER_NAME]: Cookies.get(CSRF_COOKIE_NAME()) || '', + } + + // Add Authorization header if an access token is available + const accessToken = getWebAppAccessToken() + if (accessToken) + headers.Authorization = `Bearer ${accessToken}` + + globalThis.fetch(url, { + method: 'POST', + keepalive: true, + credentials: 'include', + headers, + body: JSON.stringify(body), + }).catch(() => {}) +} + export { base } diff --git a/web/types/feature.ts b/web/types/feature.ts index a8142cfefb..5abfb1e1a0 100644 --- a/web/types/feature.ts +++ b/web/types/feature.ts @@ -109,38 +109,3 @@ export const defaultSystemFeatures: SystemFeatures = { enable_trial_app: false, enable_explore_banner: false, } - -export enum DatasetAttr { - DATA_API_PREFIX = 'data-api-prefix', - DATA_PUBLIC_API_PREFIX = 'data-public-api-prefix', - DATA_MARKETPLACE_API_PREFIX = 'data-marketplace-api-prefix', - DATA_MARKETPLACE_URL_PREFIX = 'data-marketplace-url-prefix', - DATA_PUBLIC_EDITION = 'data-public-edition', - DATA_PUBLIC_AMPLITUDE_API_KEY = 'data-public-amplitude-api-key', - DATA_PUBLIC_COOKIE_DOMAIN = 'data-public-cookie-domain', - DATA_PUBLIC_SOCKET_URL = 'data-public-socket-url', - DATA_PUBLIC_SUPPORT_MAIL_LOGIN = 'data-public-support-mail-login', - DATA_PUBLIC_SENTRY_DSN = 'data-public-sentry-dsn', - DATA_PUBLIC_MAINTENANCE_NOTICE = 'data-public-maintenance-notice', - DATA_PUBLIC_SITE_ABOUT = 'data-public-site-about', - DATA_PUBLIC_TEXT_GENERATION_TIMEOUT_MS = 'data-public-text-generation-timeout-ms', - DATA_PUBLIC_MAX_TOOLS_NUM = 'data-public-max-tools-num', - DATA_PUBLIC_MAX_PARALLEL_LIMIT = 'data-public-max-parallel-limit', - DATA_PUBLIC_TOP_K_MAX_VALUE = 'data-public-top-k-max-value', - DATA_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH = 'data-public-indexing-max-segmentation-tokens-length', - DATA_PUBLIC_LOOP_NODE_MAX_COUNT = 'data-public-loop-node-max-count', - DATA_PUBLIC_MAX_ITERATIONS_NUM = 'data-public-max-iterations-num', - DATA_PUBLIC_MAX_TREE_DEPTH = 'data-public-max-tree-depth', - DATA_PUBLIC_ALLOW_UNSAFE_DATA_SCHEME = 'data-public-allow-unsafe-data-scheme', - DATA_PUBLIC_ENABLE_WEBSITE_JINAREADER = 'data-public-enable-website-jinareader', - DATA_PUBLIC_ENABLE_WEBSITE_FIRECRAWL = 'data-public-enable-website-firecrawl', - DATA_PUBLIC_ENABLE_WEBSITE_WATERCRAWL = 'data-public-enable-website-watercrawl', - DATA_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX = 'data-public-enable-single-dollar-latex', - NEXT_PUBLIC_ZENDESK_WIDGET_KEY = 'next-public-zendesk-widget-key', - NEXT_PUBLIC_ZENDESK_FIELD_ID_ENVIRONMENT = 'next-public-zendesk-field-id-environment', - NEXT_PUBLIC_ZENDESK_FIELD_ID_VERSION = 'next-public-zendesk-field-id-version', - NEXT_PUBLIC_ZENDESK_FIELD_ID_EMAIL = 'next-public-zendesk-field-id-email', - NEXT_PUBLIC_ZENDESK_FIELD_ID_WORKSPACE_ID = 'next-public-zendesk-field-id-workspace-id', - NEXT_PUBLIC_ZENDESK_FIELD_ID_PLAN = 'next-public-zendesk-field-id-plan', - DATA_PUBLIC_BATCH_CONCURRENCY = 'data-public-batch-concurrency', -} diff --git a/web/utils/var.ts b/web/utils/var.ts index 1851084b2e..efad8794eb 100644 --- a/web/utils/var.ts +++ b/web/utils/var.ts @@ -8,6 +8,7 @@ import { } from '@/app/components/base/prompt-editor/constants' import { InputVarType } from '@/app/components/workflow/types' import { getMaxVarNameLength, MARKETPLACE_URL_PREFIX, MAX_VAR_KEY_LENGTH, VAR_ITEM_TEMPLATE, VAR_ITEM_TEMPLATE_IN_WORKFLOW } from '@/config' +import { env } from '@/env' const otherAllowedRegex = /^\w+$/ @@ -129,7 +130,7 @@ export const getVars = (value: string) => { // Set the value of basePath // example: /dify -export const basePath = process.env.NEXT_PUBLIC_BASE_PATH || '' +export const basePath = env.NEXT_PUBLIC_BASE_PATH export function getMarketplaceUrl(path: string, params?: Record) { const searchParams = new URLSearchParams({ source: encodeURIComponent(window.location.origin) }) diff --git a/web/utils/zod.spec.ts b/web/utils/zod.spec.ts deleted file mode 100644 index e3676aa054..0000000000 --- a/web/utils/zod.spec.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { z, ZodError } from 'zod' - -describe('Zod Features', () => { - it('should support string', () => { - const stringSchema = z.string() - const numberLikeStringSchema = z.coerce.string() // 12 would be converted to '12' - const stringSchemaWithError = z.string({ - required_error: 'Name is required', - invalid_type_error: 'Invalid name type, expected string', - }) - - const urlSchema = z.string().url() - const uuidSchema = z.string().uuid() - - expect(stringSchema.parse('hello')).toBe('hello') - expect(() => stringSchema.parse(12)).toThrow() - expect(numberLikeStringSchema.parse('12')).toBe('12') - expect(numberLikeStringSchema.parse(12)).toBe('12') - expect(() => stringSchemaWithError.parse(undefined)).toThrow('Name is required') - expect(() => stringSchemaWithError.parse(12)).toThrow('Invalid name type, expected string') - - expect(urlSchema.parse('https://dify.ai')).toBe('https://dify.ai') - expect(uuidSchema.parse('123e4567-e89b-12d3-a456-426614174000')).toBe('123e4567-e89b-12d3-a456-426614174000') - }) - - it('should support enum', () => { - enum JobStatus { - waiting = 'waiting', - processing = 'processing', - completed = 'completed', - } - expect(z.nativeEnum(JobStatus).parse(JobStatus.waiting)).toBe(JobStatus.waiting) - expect(z.nativeEnum(JobStatus).parse('completed')).toBe('completed') - expect(() => z.nativeEnum(JobStatus).parse('invalid')).toThrow() - }) - - it('should support number', () => { - const numberSchema = z.number() - const numberWithMin = z.number().gt(0) // alias min - const numberWithMinEqual = z.number().gte(0) - const numberWithMax = z.number().lt(100) // alias max - - expect(numberSchema.parse(123)).toBe(123) - expect(numberWithMin.parse(50)).toBe(50) - expect(numberWithMinEqual.parse(0)).toBe(0) - expect(() => numberWithMin.parse(-1)).toThrow() - expect(numberWithMax.parse(50)).toBe(50) - expect(() => numberWithMax.parse(101)).toThrow() - }) - - it('should support boolean', () => { - const booleanSchema = z.boolean() - expect(booleanSchema.parse(true)).toBe(true) - expect(booleanSchema.parse(false)).toBe(false) - expect(() => booleanSchema.parse('true')).toThrow() - }) - - it('should support date', () => { - const dateSchema = z.date() - expect(dateSchema.parse(new Date('2023-01-01'))).toEqual(new Date('2023-01-01')) - }) - - it('should support object', () => { - const userSchema = z.object({ - id: z.union([z.string(), z.number()]), - name: z.string(), - email: z.string().email(), - age: z.number().min(0).max(120).optional(), - }) - - type User = z.infer - - const validUser: User = { - id: 1, - name: 'John', - email: 'john@example.com', - age: 30, - } - - expect(userSchema.parse(validUser)).toEqual(validUser) - }) - - it('should support object optional field', () => { - const userSchema = z.object({ - name: z.string(), - optionalField: z.optional(z.string()), - }) - type User = z.infer - - const user: User = { - name: 'John', - } - const userWithOptionalField: User = { - name: 'John', - optionalField: 'optional', - } - expect(userSchema.safeParse(user).success).toEqual(true) - expect(userSchema.safeParse(userWithOptionalField).success).toEqual(true) - }) - - it('should support object intersection', () => { - const Person = z.object({ - name: z.string(), - }) - - const Employee = z.object({ - role: z.string(), - }) - - const EmployedPerson = z.intersection(Person, Employee) - const validEmployedPerson = { - name: 'John', - role: 'Developer', - } - expect(EmployedPerson.parse(validEmployedPerson)).toEqual(validEmployedPerson) - }) - - it('should support record', () => { - const recordSchema = z.record(z.string(), z.number()) - const validRecord = { - a: 1, - b: 2, - } - expect(recordSchema.parse(validRecord)).toEqual(validRecord) - }) - - it('should support array', () => { - const numbersSchema = z.array(z.number()) - const stringArraySchema = z.string().array() - - expect(numbersSchema.parse([1, 2, 3])).toEqual([1, 2, 3]) - expect(stringArraySchema.parse(['a', 'b', 'c'])).toEqual(['a', 'b', 'c']) - }) - - it('should support promise', async () => { - const promiseSchema = z.promise(z.string()) - const validPromise = Promise.resolve('success') - - await expect(promiseSchema.parse(validPromise)).resolves.toBe('success') - }) - - it('should support unions', () => { - const unionSchema = z.union([z.string(), z.number()]) - - expect(unionSchema.parse('success')).toBe('success') - expect(unionSchema.parse(404)).toBe(404) - }) - - it('should support functions', () => { - const functionSchema = z.function().args(z.string(), z.number(), z.optional(z.string())).returns(z.number()) - const validFunction = (name: string, age: number, _optional?: string): number => { - return age - } - expect(functionSchema.safeParse(validFunction).success).toEqual(true) - }) - - it('should support undefined, null, any, and void', () => { - const undefinedSchema = z.undefined() - const nullSchema = z.null() - const anySchema = z.any() - - expect(undefinedSchema.parse(undefined)).toBeUndefined() - expect(nullSchema.parse(null)).toBeNull() - expect(anySchema.parse('anything')).toBe('anything') - expect(anySchema.parse(3)).toBe(3) - }) - - it('should safeParse would not throw', () => { - expect(z.string().safeParse('abc').success).toBe(true) - expect(z.string().safeParse(123).success).toBe(false) - expect(z.string().safeParse(123).error).toBeInstanceOf(ZodError) - }) -})