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
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' && (