Merge branch 'feat/end-user-oauth' into deploy/end-user-oauth

This commit is contained in:
zhsama 2025-12-04 17:50:16 +08:00
commit b8236b29f3
128 changed files with 10684 additions and 5994 deletions

View File

@ -1,6 +1,8 @@
import logging import logging
import time import time
from opentelemetry.trace import get_current_span
from configs import dify_config from configs import dify_config
from contexts.wrapper import RecyclableContextVar from contexts.wrapper import RecyclableContextVar
from dify_app import DifyApp from dify_app import DifyApp
@ -26,8 +28,25 @@ def create_flask_app_with_configs() -> DifyApp:
# add an unique identifier to each request # add an unique identifier to each request
RecyclableContextVar.increment_thread_recycles() RecyclableContextVar.increment_thread_recycles()
# add after request hook for injecting X-Trace-Id header from OpenTelemetry span context
@dify_app.after_request
def add_trace_id_header(response):
try:
span = get_current_span()
ctx = span.get_span_context() if span else None
if ctx and ctx.is_valid:
trace_id_hex = format(ctx.trace_id, "032x")
# Avoid duplicates if some middleware added it
if "X-Trace-Id" not in response.headers:
response.headers["X-Trace-Id"] = trace_id_hex
except Exception:
# Never break the response due to tracing header injection
logger.warning("Failed to add trace ID to response header", exc_info=True)
return response
# Capture the decorator's return value to avoid pyright reportUnusedFunction # Capture the decorator's return value to avoid pyright reportUnusedFunction
_ = before_request _ = before_request
_ = add_trace_id_header
return dify_app return dify_app
@ -51,6 +70,7 @@ def initialize_extensions(app: DifyApp):
ext_commands, ext_commands,
ext_compress, ext_compress,
ext_database, ext_database,
ext_forward_refs,
ext_hosting_provider, ext_hosting_provider,
ext_import_modules, ext_import_modules,
ext_logging, ext_logging,
@ -75,6 +95,7 @@ def initialize_extensions(app: DifyApp):
ext_warnings, ext_warnings,
ext_import_modules, ext_import_modules,
ext_orjson, ext_orjson,
ext_forward_refs,
ext_set_secretkey, ext_set_secretkey,
ext_compress, ext_compress,
ext_code_based_extension, ext_code_based_extension,

View File

@ -553,7 +553,10 @@ class LoggingConfig(BaseSettings):
LOG_FORMAT: str = Field( LOG_FORMAT: str = Field(
description="Format string for log messages", description="Format string for log messages",
default="%(asctime)s.%(msecs)03d %(levelname)s [%(threadName)s] [%(filename)s:%(lineno)d] - %(message)s", default=(
"%(asctime)s.%(msecs)03d %(levelname)s [%(threadName)s] "
"[%(filename)s:%(lineno)d] %(trace_id)s - %(message)s"
),
) )
LOG_DATEFORMAT: str | None = Field( LOG_DATEFORMAT: str | None = Field(

View File

@ -324,10 +324,13 @@ class AppListApi(Resource):
NodeType.TRIGGER_PLUGIN, NodeType.TRIGGER_PLUGIN,
} }
for workflow in draft_workflows: for workflow in draft_workflows:
for _, node_data in workflow.walk_nodes(): try:
if node_data.get("type") in trigger_node_types: for _, node_data in workflow.walk_nodes():
draft_trigger_app_ids.add(str(workflow.app_id)) if node_data.get("type") in trigger_node_types:
break draft_trigger_app_ids.add(str(workflow.app_id))
break
except Exception:
continue
for app in app_pagination.items: for app in app_pagination.items:
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids app.has_draft_trigger = str(app.id) in draft_trigger_app_ids

View File

@ -49,7 +49,6 @@ class CompletionConversationQuery(BaseConversationQuery):
class ChatConversationQuery(BaseConversationQuery): class ChatConversationQuery(BaseConversationQuery):
message_count_gte: int | None = Field(default=None, ge=1, description="Minimum message count")
sort_by: Literal["created_at", "-created_at", "updated_at", "-updated_at"] = Field( sort_by: Literal["created_at", "-created_at", "updated_at", "-updated_at"] = Field(
default="-updated_at", description="Sort field and direction" default="-updated_at", description="Sort field and direction"
) )
@ -509,14 +508,6 @@ class ChatConversationApi(Resource):
.having(func.count(MessageAnnotation.id) == 0) .having(func.count(MessageAnnotation.id) == 0)
) )
if args.message_count_gte and args.message_count_gte >= 1:
query = (
query.options(joinedload(Conversation.messages)) # type: ignore
.join(Message, Message.conversation_id == Conversation.id)
.group_by(Conversation.id)
.having(func.count(Message.id) >= args.message_count_gte)
)
if app_model.mode == AppMode.ADVANCED_CHAT: if app_model.mode == AppMode.ADVANCED_CHAT:
query = query.where(Conversation.invoke_from != InvokeFrom.DEBUGGER) query = query.where(Conversation.invoke_from != InvokeFrom.DEBUGGER)

View File

@ -316,18 +316,16 @@ def validate_and_get_api_token(scope: str | None = None):
ApiToken.type == scope, ApiToken.type == scope,
) )
.values(last_used_at=current_time) .values(last_used_at=current_time)
.returning(ApiToken)
) )
stmt = select(ApiToken).where(ApiToken.token == auth_token, ApiToken.type == scope)
result = session.execute(update_stmt) result = session.execute(update_stmt)
api_token = result.scalar_one_or_none() api_token = session.scalar(stmt)
if hasattr(result, "rowcount") and result.rowcount > 0:
session.commit()
if not api_token: if not api_token:
stmt = select(ApiToken).where(ApiToken.token == auth_token, ApiToken.type == scope) raise Unauthorized("Access token is invalid")
api_token = session.scalar(stmt)
if not api_token:
raise Unauthorized("Access token is invalid")
else:
session.commit()
return api_token return api_token

View File

@ -1,3 +1,4 @@
import logging
import time import time
from collections.abc import Mapping, Sequence from collections.abc import Mapping, Sequence
from dataclasses import dataclass from dataclasses import dataclass
@ -55,6 +56,7 @@ from models import Account, EndUser
from services.variable_truncator import BaseTruncator, DummyVariableTruncator, VariableTruncator from services.variable_truncator import BaseTruncator, DummyVariableTruncator, VariableTruncator
NodeExecutionId = NewType("NodeExecutionId", str) NodeExecutionId = NewType("NodeExecutionId", str)
logger = logging.getLogger(__name__)
@dataclass(slots=True) @dataclass(slots=True)
@ -289,26 +291,30 @@ class WorkflowResponseConverter:
), ),
) )
if event.node_type == NodeType.TOOL: try:
response.data.extras["icon"] = ToolManager.get_tool_icon( if event.node_type == NodeType.TOOL:
tenant_id=self._application_generate_entity.app_config.tenant_id, response.data.extras["icon"] = ToolManager.get_tool_icon(
provider_type=ToolProviderType(event.provider_type), tenant_id=self._application_generate_entity.app_config.tenant_id,
provider_id=event.provider_id, provider_type=ToolProviderType(event.provider_type),
) provider_id=event.provider_id,
elif event.node_type == NodeType.DATASOURCE: )
manager = PluginDatasourceManager() elif event.node_type == NodeType.DATASOURCE:
provider_entity = manager.fetch_datasource_provider( manager = PluginDatasourceManager()
self._application_generate_entity.app_config.tenant_id, provider_entity = manager.fetch_datasource_provider(
event.provider_id, self._application_generate_entity.app_config.tenant_id,
) event.provider_id,
response.data.extras["icon"] = provider_entity.declaration.identity.generate_datasource_icon_url( )
self._application_generate_entity.app_config.tenant_id response.data.extras["icon"] = provider_entity.declaration.identity.generate_datasource_icon_url(
) self._application_generate_entity.app_config.tenant_id
elif event.node_type == NodeType.TRIGGER_PLUGIN: )
response.data.extras["icon"] = TriggerManager.get_trigger_plugin_icon( elif event.node_type == NodeType.TRIGGER_PLUGIN:
self._application_generate_entity.app_config.tenant_id, response.data.extras["icon"] = TriggerManager.get_trigger_plugin_icon(
event.provider_id, self._application_generate_entity.app_config.tenant_id,
) event.provider_id,
)
except Exception:
# metadata fetch may fail, for example, the plugin daemon is down or plugin is uninstalled.
logger.warning("failed to fetch icon for %s", event.provider_id)
return response return response

View File

@ -4,15 +4,15 @@ from typing import TYPE_CHECKING, Any, Optional
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator
if TYPE_CHECKING:
from core.ops.ops_trace_manager import TraceQueueManager
from constants import UUID_NIL from constants import UUID_NIL
from core.app.app_config.entities import EasyUIBasedAppConfig, WorkflowUIBasedAppConfig from core.app.app_config.entities import EasyUIBasedAppConfig, WorkflowUIBasedAppConfig
from core.entities.provider_configuration import ProviderModelBundle from core.entities.provider_configuration import ProviderModelBundle
from core.file import File, FileUploadConfig from core.file import File, FileUploadConfig
from core.model_runtime.entities.model_entities import AIModelEntity from core.model_runtime.entities.model_entities import AIModelEntity
if TYPE_CHECKING:
from core.ops.ops_trace_manager import TraceQueueManager
class InvokeFrom(StrEnum): class InvokeFrom(StrEnum):
""" """
@ -275,10 +275,8 @@ class RagPipelineGenerateEntity(WorkflowAppGenerateEntity):
start_node_id: str | None = None start_node_id: str | None = None
# Import TraceQueueManager at runtime to resolve forward references
from core.ops.ops_trace_manager import TraceQueueManager from core.ops.ops_trace_manager import TraceQueueManager
# Rebuild models that use forward references
AppGenerateEntity.model_rebuild() AppGenerateEntity.model_rebuild()
EasyUIBasedAppGenerateEntity.model_rebuild() EasyUIBasedAppGenerateEntity.model_rebuild()
ConversationAppGenerateEntity.model_rebuild() ConversationAppGenerateEntity.model_rebuild()

View File

@ -29,6 +29,7 @@ class SimpleModelProviderEntity(BaseModel):
provider: str provider: str
label: I18nObject label: I18nObject
icon_small: I18nObject | None = None icon_small: I18nObject | None = None
icon_small_dark: I18nObject | None = None
icon_large: I18nObject | None = None icon_large: I18nObject | None = None
supported_model_types: list[ModelType] supported_model_types: list[ModelType]
@ -42,6 +43,7 @@ class SimpleModelProviderEntity(BaseModel):
provider=provider_entity.provider, provider=provider_entity.provider,
label=provider_entity.label, label=provider_entity.label,
icon_small=provider_entity.icon_small, icon_small=provider_entity.icon_small,
icon_small_dark=provider_entity.icon_small_dark,
icon_large=provider_entity.icon_large, icon_large=provider_entity.icon_large,
supported_model_types=provider_entity.supported_model_types, supported_model_types=provider_entity.supported_model_types,
) )

View File

@ -99,6 +99,7 @@ class SimpleProviderEntity(BaseModel):
provider: str provider: str
label: I18nObject label: I18nObject
icon_small: I18nObject | None = None icon_small: I18nObject | None = None
icon_small_dark: I18nObject | None = None
icon_large: I18nObject | None = None icon_large: I18nObject | None = None
supported_model_types: Sequence[ModelType] supported_model_types: Sequence[ModelType]
models: list[AIModelEntity] = [] models: list[AIModelEntity] = []
@ -124,7 +125,6 @@ class ProviderEntity(BaseModel):
icon_small: I18nObject | None = None icon_small: I18nObject | None = None
icon_large: I18nObject | None = None icon_large: I18nObject | None = None
icon_small_dark: I18nObject | None = None icon_small_dark: I18nObject | None = None
icon_large_dark: I18nObject | None = None
background: str | None = None background: str | None = None
help: ProviderHelpEntity | None = None help: ProviderHelpEntity | None = None
supported_model_types: Sequence[ModelType] supported_model_types: Sequence[ModelType]

View File

@ -300,6 +300,14 @@ class ModelProviderFactory:
file_name = provider_schema.icon_small.zh_Hans file_name = provider_schema.icon_small.zh_Hans
else: else:
file_name = provider_schema.icon_small.en_US file_name = provider_schema.icon_small.en_US
elif icon_type.lower() == "icon_small_dark":
if not provider_schema.icon_small_dark:
raise ValueError(f"Provider {provider} does not have small dark icon.")
if lang.lower() == "zh_hans":
file_name = provider_schema.icon_small_dark.zh_Hans
else:
file_name = provider_schema.icon_small_dark.en_US
else: else:
if not provider_schema.icon_large: if not provider_schema.icon_large:
raise ValueError(f"Provider {provider} does not have large icon.") raise ValueError(f"Provider {provider} does not have large icon.")

View File

@ -203,7 +203,7 @@ class WorkflowTool(Tool):
Resolve user object in both HTTP and worker contexts. Resolve user object in both HTTP and worker contexts.
In HTTP context: dereference the current_user LocalProxy (can return Account or EndUser). In HTTP context: dereference the current_user LocalProxy (can return Account or EndUser).
In worker context: load Account from database by user_id (only returns Account, never EndUser). In worker context: load Account(knowledge pipeline) or EndUser(trigger) from database by user_id.
Returns: Returns:
Account | EndUser | None: The resolved user object, or None if resolution fails. Account | EndUser | None: The resolved user object, or None if resolution fails.
@ -224,24 +224,28 @@ class WorkflowTool(Tool):
logger.warning("Failed to resolve user from request context: %s", e) logger.warning("Failed to resolve user from request context: %s", e)
return None return None
def _resolve_user_from_database(self, user_id: str) -> Account | None: def _resolve_user_from_database(self, user_id: str) -> Account | EndUser | None:
""" """
Resolve user from database (worker/Celery context). Resolve user from database (worker/Celery context).
""" """
user_stmt = select(Account).where(Account.id == user_id)
user = db.session.scalar(user_stmt)
if not user:
return None
tenant_stmt = select(Tenant).where(Tenant.id == self.runtime.tenant_id) tenant_stmt = select(Tenant).where(Tenant.id == self.runtime.tenant_id)
tenant = db.session.scalar(tenant_stmt) tenant = db.session.scalar(tenant_stmt)
if not tenant: if not tenant:
return None return None
user.current_tenant = tenant user_stmt = select(Account).where(Account.id == user_id)
user = db.session.scalar(user_stmt)
if user:
user.current_tenant = tenant
return user
return user end_user_stmt = select(EndUser).where(EndUser.id == user_id, EndUser.tenant_id == tenant.id)
end_user = db.session.scalar(end_user_stmt)
if end_user:
return end_user
return None
def _get_workflow(self, app_id: str, version: str) -> Workflow: def _get_workflow(self, app_id: str, version: str) -> Workflow:
""" """

View File

@ -1,7 +1,11 @@
import importlib
import logging import logging
import operator
import pkgutil
from abc import abstractmethod from abc import abstractmethod
from collections.abc import Generator, Mapping, Sequence from collections.abc import Generator, Mapping, Sequence
from functools import singledispatchmethod from functools import singledispatchmethod
from types import MappingProxyType
from typing import Any, ClassVar, Generic, TypeVar, cast, get_args, get_origin from typing import Any, ClassVar, Generic, TypeVar, cast, get_args, get_origin
from uuid import uuid4 from uuid import uuid4
@ -134,6 +138,34 @@ class Node(Generic[NodeDataT]):
cls._node_data_type = node_data_type cls._node_data_type = node_data_type
# Skip base class itself
if cls is Node:
return
# Only register production node implementations defined under core.workflow.nodes.*
# This prevents test helper subclasses from polluting the global registry and
# accidentally overriding real node types (e.g., a test Answer node).
module_name = getattr(cls, "__module__", "")
# Only register concrete subclasses that define node_type and version()
node_type = cls.node_type
version = cls.version()
bucket = Node._registry.setdefault(node_type, {})
if module_name.startswith("core.workflow.nodes."):
# Production node definitions take precedence and may override
bucket[version] = cls # type: ignore[index]
else:
# External/test subclasses may register but must not override production
bucket.setdefault(version, cls) # type: ignore[index]
# Maintain a "latest" pointer preferring numeric versions; fallback to lexicographic
version_keys = [v for v in bucket if v != "latest"]
numeric_pairs: list[tuple[str, int]] = []
for v in version_keys:
numeric_pairs.append((v, int(v)))
if numeric_pairs:
latest_key = max(numeric_pairs, key=operator.itemgetter(1))[0]
else:
latest_key = max(version_keys) if version_keys else version
bucket["latest"] = bucket[latest_key]
@classmethod @classmethod
def _extract_node_data_type_from_generic(cls) -> type[BaseNodeData] | None: def _extract_node_data_type_from_generic(cls) -> type[BaseNodeData] | None:
""" """
@ -165,6 +197,9 @@ class Node(Generic[NodeDataT]):
return None return None
# Global registry populated via __init_subclass__
_registry: ClassVar[dict["NodeType", dict[str, type["Node"]]]] = {}
def __init__( def __init__(
self, self,
id: str, id: str,
@ -395,6 +430,29 @@ class Node(Generic[NodeDataT]):
# in `api/core/workflow/nodes/__init__.py`. # in `api/core/workflow/nodes/__init__.py`.
raise NotImplementedError("subclasses of BaseNode must implement `version` method.") raise NotImplementedError("subclasses of BaseNode must implement `version` method.")
@classmethod
def get_node_type_classes_mapping(cls) -> Mapping["NodeType", Mapping[str, type["Node"]]]:
"""Return mapping of NodeType -> {version -> Node subclass} using __init_subclass__ registry.
Import all modules under core.workflow.nodes so subclasses register themselves on import.
Then we return a readonly view of the registry to avoid accidental mutation.
"""
# Import all node modules to ensure they are loaded (thus registered)
import core.workflow.nodes as _nodes_pkg
for _, _modname, _ in pkgutil.walk_packages(_nodes_pkg.__path__, _nodes_pkg.__name__ + "."):
# Avoid importing modules that depend on the registry to prevent circular imports
# e.g. node_factory imports node_mapping which builds the mapping here.
if _modname in {
"core.workflow.nodes.node_factory",
"core.workflow.nodes.node_mapping",
}:
continue
importlib.import_module(_modname)
# Return a readonly view so callers can't mutate the registry by accident
return {nt: MappingProxyType(ver_map) for nt, ver_map in cls._registry.items()}
@property @property
def retry(self) -> bool: def retry(self) -> bool:
return False return False

View File

@ -1,165 +1,9 @@
from collections.abc import Mapping from collections.abc import Mapping
from core.workflow.enums import NodeType from core.workflow.enums import NodeType
from core.workflow.nodes.agent.agent_node import AgentNode
from core.workflow.nodes.answer.answer_node import AnswerNode
from core.workflow.nodes.base.node import Node from core.workflow.nodes.base.node import Node
from core.workflow.nodes.code import CodeNode
from core.workflow.nodes.datasource.datasource_node import DatasourceNode
from core.workflow.nodes.document_extractor import DocumentExtractorNode
from core.workflow.nodes.end.end_node import EndNode
from core.workflow.nodes.http_request import HttpRequestNode
from core.workflow.nodes.human_input import HumanInputNode
from core.workflow.nodes.if_else import IfElseNode
from core.workflow.nodes.iteration import IterationNode, IterationStartNode
from core.workflow.nodes.knowledge_index import KnowledgeIndexNode
from core.workflow.nodes.knowledge_retrieval import KnowledgeRetrievalNode
from core.workflow.nodes.list_operator import ListOperatorNode
from core.workflow.nodes.llm import LLMNode
from core.workflow.nodes.loop import LoopEndNode, LoopNode, LoopStartNode
from core.workflow.nodes.parameter_extractor import ParameterExtractorNode
from core.workflow.nodes.question_classifier import QuestionClassifierNode
from core.workflow.nodes.start import StartNode
from core.workflow.nodes.template_transform import TemplateTransformNode
from core.workflow.nodes.tool import ToolNode
from core.workflow.nodes.trigger_plugin import TriggerEventNode
from core.workflow.nodes.trigger_schedule import TriggerScheduleNode
from core.workflow.nodes.trigger_webhook import TriggerWebhookNode
from core.workflow.nodes.variable_aggregator import VariableAggregatorNode
from core.workflow.nodes.variable_assigner.v1 import VariableAssignerNode as VariableAssignerNodeV1
from core.workflow.nodes.variable_assigner.v2 import VariableAssignerNode as VariableAssignerNodeV2
LATEST_VERSION = "latest" LATEST_VERSION = "latest"
# NOTE(QuantumGhost): This should be in sync with subclasses of BaseNode. # Mapping is built by Node.get_node_type_classes_mapping(), which imports and walks core.workflow.nodes
# Specifically, if you have introduced new node types, you should add them here. NODE_TYPE_CLASSES_MAPPING: Mapping[NodeType, Mapping[str, type[Node]]] = Node.get_node_type_classes_mapping()
#
# TODO(QuantumGhost): This could be automated with either metaclass or `__init_subclass__`
# hook. Try to avoid duplication of node information.
NODE_TYPE_CLASSES_MAPPING: Mapping[NodeType, Mapping[str, type[Node]]] = {
NodeType.START: {
LATEST_VERSION: StartNode,
"1": StartNode,
},
NodeType.END: {
LATEST_VERSION: EndNode,
"1": EndNode,
},
NodeType.ANSWER: {
LATEST_VERSION: AnswerNode,
"1": AnswerNode,
},
NodeType.LLM: {
LATEST_VERSION: LLMNode,
"1": LLMNode,
},
NodeType.KNOWLEDGE_RETRIEVAL: {
LATEST_VERSION: KnowledgeRetrievalNode,
"1": KnowledgeRetrievalNode,
},
NodeType.IF_ELSE: {
LATEST_VERSION: IfElseNode,
"1": IfElseNode,
},
NodeType.CODE: {
LATEST_VERSION: CodeNode,
"1": CodeNode,
},
NodeType.TEMPLATE_TRANSFORM: {
LATEST_VERSION: TemplateTransformNode,
"1": TemplateTransformNode,
},
NodeType.QUESTION_CLASSIFIER: {
LATEST_VERSION: QuestionClassifierNode,
"1": QuestionClassifierNode,
},
NodeType.HTTP_REQUEST: {
LATEST_VERSION: HttpRequestNode,
"1": HttpRequestNode,
},
NodeType.TOOL: {
LATEST_VERSION: ToolNode,
# This is an issue that caused problems before.
# Logically, we shouldn't use two different versions to point to the same class here,
# but in order to maintain compatibility with historical data, this approach has been retained.
"2": ToolNode,
"1": ToolNode,
},
NodeType.VARIABLE_AGGREGATOR: {
LATEST_VERSION: VariableAggregatorNode,
"1": VariableAggregatorNode,
},
NodeType.LEGACY_VARIABLE_AGGREGATOR: {
LATEST_VERSION: VariableAggregatorNode,
"1": VariableAggregatorNode,
}, # original name of VARIABLE_AGGREGATOR
NodeType.ITERATION: {
LATEST_VERSION: IterationNode,
"1": IterationNode,
},
NodeType.ITERATION_START: {
LATEST_VERSION: IterationStartNode,
"1": IterationStartNode,
},
NodeType.LOOP: {
LATEST_VERSION: LoopNode,
"1": LoopNode,
},
NodeType.LOOP_START: {
LATEST_VERSION: LoopStartNode,
"1": LoopStartNode,
},
NodeType.LOOP_END: {
LATEST_VERSION: LoopEndNode,
"1": LoopEndNode,
},
NodeType.PARAMETER_EXTRACTOR: {
LATEST_VERSION: ParameterExtractorNode,
"1": ParameterExtractorNode,
},
NodeType.VARIABLE_ASSIGNER: {
LATEST_VERSION: VariableAssignerNodeV2,
"1": VariableAssignerNodeV1,
"2": VariableAssignerNodeV2,
},
NodeType.DOCUMENT_EXTRACTOR: {
LATEST_VERSION: DocumentExtractorNode,
"1": DocumentExtractorNode,
},
NodeType.LIST_OPERATOR: {
LATEST_VERSION: ListOperatorNode,
"1": ListOperatorNode,
},
NodeType.AGENT: {
LATEST_VERSION: AgentNode,
# This is an issue that caused problems before.
# Logically, we shouldn't use two different versions to point to the same class here,
# but in order to maintain compatibility with historical data, this approach has been retained.
"2": AgentNode,
"1": AgentNode,
},
NodeType.HUMAN_INPUT: {
LATEST_VERSION: HumanInputNode,
"1": HumanInputNode,
},
NodeType.DATASOURCE: {
LATEST_VERSION: DatasourceNode,
"1": DatasourceNode,
},
NodeType.KNOWLEDGE_INDEX: {
LATEST_VERSION: KnowledgeIndexNode,
"1": KnowledgeIndexNode,
},
NodeType.TRIGGER_WEBHOOK: {
LATEST_VERSION: TriggerWebhookNode,
"1": TriggerWebhookNode,
},
NodeType.TRIGGER_PLUGIN: {
LATEST_VERSION: TriggerEventNode,
"1": TriggerEventNode,
},
NodeType.TRIGGER_SCHEDULE: {
LATEST_VERSION: TriggerScheduleNode,
"1": TriggerScheduleNode,
},
}

View File

@ -12,7 +12,6 @@ from core.tools.entities.tool_entities import ToolInvokeMessage, ToolParameter
from core.tools.errors import ToolInvokeError from core.tools.errors import ToolInvokeError
from core.tools.tool_engine import ToolEngine from core.tools.tool_engine import ToolEngine
from core.tools.utils.message_transformer import ToolFileMessageTransformer from core.tools.utils.message_transformer import ToolFileMessageTransformer
from core.tools.workflow_as_tool.tool import WorkflowTool
from core.variables.segments import ArrayAnySegment, ArrayFileSegment from core.variables.segments import ArrayAnySegment, ArrayFileSegment
from core.variables.variables import ArrayAnyVariable from core.variables.variables import ArrayAnyVariable
from core.workflow.enums import ( from core.workflow.enums import (
@ -430,7 +429,7 @@ class ToolNode(Node[ToolNodeData]):
metadata: dict[WorkflowNodeExecutionMetadataKey, Any] = { metadata: dict[WorkflowNodeExecutionMetadataKey, Any] = {
WorkflowNodeExecutionMetadataKey.TOOL_INFO: tool_info, WorkflowNodeExecutionMetadataKey.TOOL_INFO: tool_info,
} }
if usage.total_tokens > 0: if isinstance(usage.total_tokens, int) and usage.total_tokens > 0:
metadata[WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS] = usage.total_tokens metadata[WorkflowNodeExecutionMetadataKey.TOTAL_TOKENS] = usage.total_tokens
metadata[WorkflowNodeExecutionMetadataKey.TOTAL_PRICE] = usage.total_price metadata[WorkflowNodeExecutionMetadataKey.TOTAL_PRICE] = usage.total_price
metadata[WorkflowNodeExecutionMetadataKey.CURRENCY] = usage.currency metadata[WorkflowNodeExecutionMetadataKey.CURRENCY] = usage.currency
@ -449,8 +448,17 @@ class ToolNode(Node[ToolNodeData]):
@staticmethod @staticmethod
def _extract_tool_usage(tool_runtime: Tool) -> LLMUsage: def _extract_tool_usage(tool_runtime: Tool) -> LLMUsage:
if isinstance(tool_runtime, WorkflowTool): # Avoid importing WorkflowTool at module import time; rely on duck typing
return tool_runtime.latest_usage # Some runtimes expose `latest_usage`; mocks may synthesize arbitrary attributes.
latest = getattr(tool_runtime, "latest_usage", None)
# Normalize into a concrete LLMUsage. MagicMock returns truthy attribute objects
# for any name, so we must type-check here.
if isinstance(latest, LLMUsage):
return latest
if isinstance(latest, dict):
# Allow dict payloads from external runtimes
return LLMUsage.model_validate(latest)
# Fallback to empty usage when attribute is missing or not a valid payload
return LLMUsage.empty_usage() return LLMUsage.empty_usage()
@classmethod @classmethod

View File

@ -6,6 +6,7 @@ BASE_CORS_HEADERS: tuple[str, ...] = ("Content-Type", HEADER_NAME_APP_CODE, HEAD
SERVICE_API_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, "Authorization") SERVICE_API_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, "Authorization")
AUTHENTICATED_HEADERS: tuple[str, ...] = (*SERVICE_API_HEADERS, HEADER_NAME_CSRF_TOKEN) AUTHENTICATED_HEADERS: tuple[str, ...] = (*SERVICE_API_HEADERS, HEADER_NAME_CSRF_TOKEN)
FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN) FILES_HEADERS: tuple[str, ...] = (*BASE_CORS_HEADERS, HEADER_NAME_CSRF_TOKEN)
EXPOSED_HEADERS: tuple[str, ...] = ("X-Version", "X-Env", "X-Trace-Id")
def init_app(app: DifyApp): def init_app(app: DifyApp):
@ -25,6 +26,7 @@ def init_app(app: DifyApp):
service_api_bp, service_api_bp,
allow_headers=list(SERVICE_API_HEADERS), allow_headers=list(SERVICE_API_HEADERS),
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"], methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
expose_headers=list(EXPOSED_HEADERS),
) )
app.register_blueprint(service_api_bp) app.register_blueprint(service_api_bp)
@ -34,7 +36,7 @@ def init_app(app: DifyApp):
supports_credentials=True, supports_credentials=True,
allow_headers=list(AUTHENTICATED_HEADERS), allow_headers=list(AUTHENTICATED_HEADERS),
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"], methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
expose_headers=["X-Version", "X-Env"], expose_headers=list(EXPOSED_HEADERS),
) )
app.register_blueprint(web_bp) app.register_blueprint(web_bp)
@ -44,7 +46,7 @@ def init_app(app: DifyApp):
supports_credentials=True, supports_credentials=True,
allow_headers=list(AUTHENTICATED_HEADERS), allow_headers=list(AUTHENTICATED_HEADERS),
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"], methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
expose_headers=["X-Version", "X-Env"], expose_headers=list(EXPOSED_HEADERS),
) )
app.register_blueprint(console_app_bp) app.register_blueprint(console_app_bp)
@ -52,6 +54,7 @@ def init_app(app: DifyApp):
files_bp, files_bp,
allow_headers=list(FILES_HEADERS), allow_headers=list(FILES_HEADERS),
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"], methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"],
expose_headers=list(EXPOSED_HEADERS),
) )
app.register_blueprint(files_bp) app.register_blueprint(files_bp)
@ -63,5 +66,6 @@ def init_app(app: DifyApp):
trigger_bp, trigger_bp,
allow_headers=["Content-Type", "Authorization", "X-App-Code"], allow_headers=["Content-Type", "Authorization", "X-App-Code"],
methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH", "HEAD"], methods=["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH", "HEAD"],
expose_headers=list(EXPOSED_HEADERS),
) )
app.register_blueprint(trigger_bp) app.register_blueprint(trigger_bp)

View File

@ -0,0 +1,49 @@
import logging
from dify_app import DifyApp
def is_enabled() -> bool:
return True
def init_app(app: DifyApp):
"""Resolve Pydantic forward refs that would otherwise cause circular imports.
Rebuilds models in core.app.entities.app_invoke_entities with the real TraceQueueManager type.
Safe to run multiple times.
"""
logger = logging.getLogger(__name__)
try:
from core.app.entities.app_invoke_entities import (
AdvancedChatAppGenerateEntity,
AgentChatAppGenerateEntity,
AppGenerateEntity,
ChatAppGenerateEntity,
CompletionAppGenerateEntity,
ConversationAppGenerateEntity,
EasyUIBasedAppGenerateEntity,
RagPipelineGenerateEntity,
WorkflowAppGenerateEntity,
)
from core.ops.ops_trace_manager import TraceQueueManager # heavy import, do it at startup only
ns = {"TraceQueueManager": TraceQueueManager}
for Model in (
AppGenerateEntity,
EasyUIBasedAppGenerateEntity,
ConversationAppGenerateEntity,
ChatAppGenerateEntity,
CompletionAppGenerateEntity,
AgentChatAppGenerateEntity,
AdvancedChatAppGenerateEntity,
WorkflowAppGenerateEntity,
RagPipelineGenerateEntity,
):
try:
Model.model_rebuild(_types_namespace=ns)
except Exception as e:
logger.debug("model_rebuild skipped for %s: %s", Model.__name__, e)
except Exception as e:
# Don't block app startup; just log at debug level.
logger.debug("ext_forward_refs init skipped: %s", e)

View File

@ -7,6 +7,7 @@ from logging.handlers import RotatingFileHandler
import flask import flask
from configs import dify_config from configs import dify_config
from core.helper.trace_id_helper import get_trace_id_from_otel_context
from dify_app import DifyApp from dify_app import DifyApp
@ -76,7 +77,9 @@ class RequestIdFilter(logging.Filter):
# the logging format. Note that we're checking if we're in a request # the logging format. Note that we're checking if we're in a request
# context, as we may want to log things before Flask is fully loaded. # context, as we may want to log things before Flask is fully loaded.
def filter(self, record): def filter(self, record):
trace_id = get_trace_id_from_otel_context() or ""
record.req_id = get_request_id() if flask.has_request_context() else "" record.req_id = get_request_id() if flask.has_request_context() else ""
record.trace_id = trace_id
return True return True
@ -84,6 +87,8 @@ class RequestIdFormatter(logging.Formatter):
def format(self, record): def format(self, record):
if not hasattr(record, "req_id"): if not hasattr(record, "req_id"):
record.req_id = "" record.req_id = ""
if not hasattr(record, "trace_id"):
record.trace_id = ""
return super().format(record) return super().format(record)

View File

@ -1,12 +1,14 @@
import json import json
import logging import logging
import time
import flask import flask
import werkzeug.http import werkzeug.http
from flask import Flask from flask import Flask, g
from flask.signals import request_finished, request_started from flask.signals import request_finished, request_started
from configs import dify_config from configs import dify_config
from core.helper.trace_id_helper import get_trace_id_from_otel_context
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -20,6 +22,9 @@ def _is_content_type_json(content_type: str) -> bool:
def _log_request_started(_sender, **_extra): def _log_request_started(_sender, **_extra):
"""Log the start of a request.""" """Log the start of a request."""
# Record start time for access logging
g.__request_started_ts = time.perf_counter()
if not logger.isEnabledFor(logging.DEBUG): if not logger.isEnabledFor(logging.DEBUG):
return return
@ -42,8 +47,39 @@ def _log_request_started(_sender, **_extra):
def _log_request_finished(_sender, response, **_extra): def _log_request_finished(_sender, response, **_extra):
"""Log the end of a request.""" """Log the end of a request.
if not logger.isEnabledFor(logging.DEBUG) or response is None:
Safe to call with or without an active Flask request context.
"""
if response is None:
return
# Always emit a compact access line at INFO with trace_id so it can be grepped
has_ctx = flask.has_request_context()
start_ts = getattr(g, "__request_started_ts", None) if has_ctx else None
duration_ms = None
if start_ts is not None:
duration_ms = round((time.perf_counter() - start_ts) * 1000, 3)
# Request attributes are available only when a request context exists
if has_ctx:
req_method = flask.request.method
req_path = flask.request.path
else:
req_method = "-"
req_path = "-"
trace_id = get_trace_id_from_otel_context() or response.headers.get("X-Trace-Id") or ""
logger.info(
"%s %s %s %s %s",
req_method,
req_path,
getattr(response, "status_code", "-"),
duration_ms if duration_ms is not None else "-",
trace_id,
)
if not logger.isEnabledFor(logging.DEBUG):
return return
if not _is_content_type_json(response.content_type): if not _is_content_type_json(response.content_type):

View File

@ -19,7 +19,7 @@ class StringUUID(TypeDecorator[uuid.UUID | str | None]):
def process_bind_param(self, value: uuid.UUID | str | None, dialect: Dialect) -> str | None: def process_bind_param(self, value: uuid.UUID | str | None, dialect: Dialect) -> str | None:
if value is None: if value is None:
return value return value
elif dialect.name == "postgresql": elif dialect.name in ["postgresql", "mysql"]:
return str(value) return str(value)
else: else:
if isinstance(value, uuid.UUID): if isinstance(value, uuid.UUID):

View File

@ -111,7 +111,7 @@ package = false
dev = [ dev = [
"coverage~=7.2.4", "coverage~=7.2.4",
"dotenv-linter~=0.5.0", "dotenv-linter~=0.5.0",
"faker~=32.1.0", "faker~=38.2.0",
"lxml-stubs~=0.5.1", "lxml-stubs~=0.5.1",
"ty~=0.0.1a19", "ty~=0.0.1a19",
"basedpyright~=1.31.0", "basedpyright~=1.31.0",

View File

@ -10,6 +10,7 @@ from collections.abc import Sequence
from typing import Any, Literal from typing import Any, Literal
import sqlalchemy as sa import sqlalchemy as sa
from redis.exceptions import LockNotOwnedError
from sqlalchemy import exists, func, select from sqlalchemy import exists, func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
@ -1593,173 +1594,176 @@ class DocumentService:
db.session.add(dataset_process_rule) db.session.add(dataset_process_rule)
db.session.flush() db.session.flush()
lock_name = f"add_document_lock_dataset_id_{dataset.id}" lock_name = f"add_document_lock_dataset_id_{dataset.id}"
with redis_client.lock(lock_name, timeout=600): try:
assert dataset_process_rule with redis_client.lock(lock_name, timeout=600):
position = DocumentService.get_documents_position(dataset.id) assert dataset_process_rule
document_ids = [] position = DocumentService.get_documents_position(dataset.id)
duplicate_document_ids = [] document_ids = []
if knowledge_config.data_source.info_list.data_source_type == "upload_file": duplicate_document_ids = []
if not knowledge_config.data_source.info_list.file_info_list: if knowledge_config.data_source.info_list.data_source_type == "upload_file":
raise ValueError("File source info is required") if not knowledge_config.data_source.info_list.file_info_list:
upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids raise ValueError("File source info is required")
for file_id in upload_file_list: upload_file_list = knowledge_config.data_source.info_list.file_info_list.file_ids
file = ( for file_id in upload_file_list:
db.session.query(UploadFile) file = (
.where(UploadFile.tenant_id == dataset.tenant_id, UploadFile.id == file_id) db.session.query(UploadFile)
.first() .where(UploadFile.tenant_id == dataset.tenant_id, UploadFile.id == file_id)
)
# raise error if file not found
if not file:
raise FileNotExistsError()
file_name = file.name
data_source_info: dict[str, str | bool] = {
"upload_file_id": file_id,
}
# check duplicate
if knowledge_config.duplicate:
document = (
db.session.query(Document)
.filter_by(
dataset_id=dataset.id,
tenant_id=current_user.current_tenant_id,
data_source_type="upload_file",
enabled=True,
name=file_name,
)
.first() .first()
) )
if document:
document.dataset_process_rule_id = dataset_process_rule.id
document.updated_at = naive_utc_now()
document.created_from = created_from
document.doc_form = knowledge_config.doc_form
document.doc_language = knowledge_config.doc_language
document.data_source_info = json.dumps(data_source_info)
document.batch = batch
document.indexing_status = "waiting"
db.session.add(document)
documents.append(document)
duplicate_document_ids.append(document.id)
continue
document = DocumentService.build_document(
dataset,
dataset_process_rule.id,
knowledge_config.data_source.info_list.data_source_type,
knowledge_config.doc_form,
knowledge_config.doc_language,
data_source_info,
created_from,
position,
account,
file_name,
batch,
)
db.session.add(document)
db.session.flush()
document_ids.append(document.id)
documents.append(document)
position += 1
elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
notion_info_list = knowledge_config.data_source.info_list.notion_info_list # type: ignore
if not notion_info_list:
raise ValueError("No notion info list found.")
exist_page_ids = []
exist_document = {}
documents = (
db.session.query(Document)
.filter_by(
dataset_id=dataset.id,
tenant_id=current_user.current_tenant_id,
data_source_type="notion_import",
enabled=True,
)
.all()
)
if documents:
for document in documents:
data_source_info = json.loads(document.data_source_info)
exist_page_ids.append(data_source_info["notion_page_id"])
exist_document[data_source_info["notion_page_id"]] = document.id
for notion_info in notion_info_list:
workspace_id = notion_info.workspace_id
for page in notion_info.pages:
if page.page_id not in exist_page_ids:
data_source_info = {
"credential_id": notion_info.credential_id,
"notion_workspace_id": workspace_id,
"notion_page_id": page.page_id,
"notion_page_icon": page.page_icon.model_dump() if page.page_icon else None, # type: ignore
"type": page.type,
}
# Truncate page name to 255 characters to prevent DB field length errors
truncated_page_name = page.page_name[:255] if page.page_name else "nopagename"
document = DocumentService.build_document(
dataset,
dataset_process_rule.id,
knowledge_config.data_source.info_list.data_source_type,
knowledge_config.doc_form,
knowledge_config.doc_language,
data_source_info,
created_from,
position,
account,
truncated_page_name,
batch,
)
db.session.add(document)
db.session.flush()
document_ids.append(document.id)
documents.append(document)
position += 1
else:
exist_document.pop(page.page_id)
# delete not selected documents
if len(exist_document) > 0:
clean_notion_document_task.delay(list(exist_document.values()), dataset.id)
elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
website_info = knowledge_config.data_source.info_list.website_info_list
if not website_info:
raise ValueError("No website info list found.")
urls = website_info.urls
for url in urls:
data_source_info = {
"url": url,
"provider": website_info.provider,
"job_id": website_info.job_id,
"only_main_content": website_info.only_main_content,
"mode": "crawl",
}
if len(url) > 255:
document_name = url[:200] + "..."
else:
document_name = url
document = DocumentService.build_document(
dataset,
dataset_process_rule.id,
knowledge_config.data_source.info_list.data_source_type,
knowledge_config.doc_form,
knowledge_config.doc_language,
data_source_info,
created_from,
position,
account,
document_name,
batch,
)
db.session.add(document)
db.session.flush()
document_ids.append(document.id)
documents.append(document)
position += 1
db.session.commit()
# trigger async task # raise error if file not found
if document_ids: if not file:
DocumentIndexingTaskProxy(dataset.tenant_id, dataset.id, document_ids).delay() raise FileNotExistsError()
if duplicate_document_ids:
duplicate_document_indexing_task.delay(dataset.id, duplicate_document_ids) file_name = file.name
data_source_info: dict[str, str | bool] = {
"upload_file_id": file_id,
}
# check duplicate
if knowledge_config.duplicate:
document = (
db.session.query(Document)
.filter_by(
dataset_id=dataset.id,
tenant_id=current_user.current_tenant_id,
data_source_type="upload_file",
enabled=True,
name=file_name,
)
.first()
)
if document:
document.dataset_process_rule_id = dataset_process_rule.id
document.updated_at = naive_utc_now()
document.created_from = created_from
document.doc_form = knowledge_config.doc_form
document.doc_language = knowledge_config.doc_language
document.data_source_info = json.dumps(data_source_info)
document.batch = batch
document.indexing_status = "waiting"
db.session.add(document)
documents.append(document)
duplicate_document_ids.append(document.id)
continue
document = DocumentService.build_document(
dataset,
dataset_process_rule.id,
knowledge_config.data_source.info_list.data_source_type,
knowledge_config.doc_form,
knowledge_config.doc_language,
data_source_info,
created_from,
position,
account,
file_name,
batch,
)
db.session.add(document)
db.session.flush()
document_ids.append(document.id)
documents.append(document)
position += 1
elif knowledge_config.data_source.info_list.data_source_type == "notion_import":
notion_info_list = knowledge_config.data_source.info_list.notion_info_list # type: ignore
if not notion_info_list:
raise ValueError("No notion info list found.")
exist_page_ids = []
exist_document = {}
documents = (
db.session.query(Document)
.filter_by(
dataset_id=dataset.id,
tenant_id=current_user.current_tenant_id,
data_source_type="notion_import",
enabled=True,
)
.all()
)
if documents:
for document in documents:
data_source_info = json.loads(document.data_source_info)
exist_page_ids.append(data_source_info["notion_page_id"])
exist_document[data_source_info["notion_page_id"]] = document.id
for notion_info in notion_info_list:
workspace_id = notion_info.workspace_id
for page in notion_info.pages:
if page.page_id not in exist_page_ids:
data_source_info = {
"credential_id": notion_info.credential_id,
"notion_workspace_id": workspace_id,
"notion_page_id": page.page_id,
"notion_page_icon": page.page_icon.model_dump() if page.page_icon else None, # type: ignore
"type": page.type,
}
# Truncate page name to 255 characters to prevent DB field length errors
truncated_page_name = page.page_name[:255] if page.page_name else "nopagename"
document = DocumentService.build_document(
dataset,
dataset_process_rule.id,
knowledge_config.data_source.info_list.data_source_type,
knowledge_config.doc_form,
knowledge_config.doc_language,
data_source_info,
created_from,
position,
account,
truncated_page_name,
batch,
)
db.session.add(document)
db.session.flush()
document_ids.append(document.id)
documents.append(document)
position += 1
else:
exist_document.pop(page.page_id)
# delete not selected documents
if len(exist_document) > 0:
clean_notion_document_task.delay(list(exist_document.values()), dataset.id)
elif knowledge_config.data_source.info_list.data_source_type == "website_crawl":
website_info = knowledge_config.data_source.info_list.website_info_list
if not website_info:
raise ValueError("No website info list found.")
urls = website_info.urls
for url in urls:
data_source_info = {
"url": url,
"provider": website_info.provider,
"job_id": website_info.job_id,
"only_main_content": website_info.only_main_content,
"mode": "crawl",
}
if len(url) > 255:
document_name = url[:200] + "..."
else:
document_name = url
document = DocumentService.build_document(
dataset,
dataset_process_rule.id,
knowledge_config.data_source.info_list.data_source_type,
knowledge_config.doc_form,
knowledge_config.doc_language,
data_source_info,
created_from,
position,
account,
document_name,
batch,
)
db.session.add(document)
db.session.flush()
document_ids.append(document.id)
documents.append(document)
position += 1
db.session.commit()
# trigger async task
if document_ids:
DocumentIndexingTaskProxy(dataset.tenant_id, dataset.id, document_ids).delay()
if duplicate_document_ids:
duplicate_document_indexing_task.delay(dataset.id, duplicate_document_ids)
except LockNotOwnedError:
pass
return documents, batch return documents, batch
@ -2699,50 +2703,55 @@ class SegmentService:
# calc embedding use tokens # calc embedding use tokens
tokens = embedding_model.get_text_embedding_num_tokens(texts=[content])[0] tokens = embedding_model.get_text_embedding_num_tokens(texts=[content])[0]
lock_name = f"add_segment_lock_document_id_{document.id}" lock_name = f"add_segment_lock_document_id_{document.id}"
with redis_client.lock(lock_name, timeout=600): try:
max_position = ( with redis_client.lock(lock_name, timeout=600):
db.session.query(func.max(DocumentSegment.position)) max_position = (
.where(DocumentSegment.document_id == document.id) db.session.query(func.max(DocumentSegment.position))
.scalar() .where(DocumentSegment.document_id == document.id)
) .scalar()
segment_document = DocumentSegment( )
tenant_id=current_user.current_tenant_id, segment_document = DocumentSegment(
dataset_id=document.dataset_id, tenant_id=current_user.current_tenant_id,
document_id=document.id, dataset_id=document.dataset_id,
index_node_id=doc_id, document_id=document.id,
index_node_hash=segment_hash, index_node_id=doc_id,
position=max_position + 1 if max_position else 1, index_node_hash=segment_hash,
content=content, position=max_position + 1 if max_position else 1,
word_count=len(content), content=content,
tokens=tokens, word_count=len(content),
status="completed", tokens=tokens,
indexing_at=naive_utc_now(), status="completed",
completed_at=naive_utc_now(), indexing_at=naive_utc_now(),
created_by=current_user.id, completed_at=naive_utc_now(),
) created_by=current_user.id,
if document.doc_form == "qa_model": )
segment_document.word_count += len(args["answer"]) if document.doc_form == "qa_model":
segment_document.answer = args["answer"] segment_document.word_count += len(args["answer"])
segment_document.answer = args["answer"]
db.session.add(segment_document) db.session.add(segment_document)
# update document word count # update document word count
assert document.word_count is not None assert document.word_count is not None
document.word_count += segment_document.word_count document.word_count += segment_document.word_count
db.session.add(document) db.session.add(document)
db.session.commit()
# save vector index
try:
VectorService.create_segments_vector([args["keywords"]], [segment_document], dataset, document.doc_form)
except Exception as e:
logger.exception("create segment index failed")
segment_document.enabled = False
segment_document.disabled_at = naive_utc_now()
segment_document.status = "error"
segment_document.error = str(e)
db.session.commit() db.session.commit()
segment = db.session.query(DocumentSegment).where(DocumentSegment.id == segment_document.id).first()
return segment # save vector index
try:
VectorService.create_segments_vector(
[args["keywords"]], [segment_document], dataset, document.doc_form
)
except Exception as e:
logger.exception("create segment index failed")
segment_document.enabled = False
segment_document.disabled_at = naive_utc_now()
segment_document.status = "error"
segment_document.error = str(e)
db.session.commit()
segment = db.session.query(DocumentSegment).where(DocumentSegment.id == segment_document.id).first()
return segment
except LockNotOwnedError:
pass
@classmethod @classmethod
def multi_create_segment(cls, segments: list, document: Document, dataset: Dataset): def multi_create_segment(cls, segments: list, document: Document, dataset: Dataset):
@ -2751,84 +2760,89 @@ class SegmentService:
lock_name = f"multi_add_segment_lock_document_id_{document.id}" lock_name = f"multi_add_segment_lock_document_id_{document.id}"
increment_word_count = 0 increment_word_count = 0
with redis_client.lock(lock_name, timeout=600): try:
embedding_model = None with redis_client.lock(lock_name, timeout=600):
if dataset.indexing_technique == "high_quality": embedding_model = None
model_manager = ModelManager() if dataset.indexing_technique == "high_quality":
embedding_model = model_manager.get_model_instance( model_manager = ModelManager()
tenant_id=current_user.current_tenant_id, embedding_model = model_manager.get_model_instance(
provider=dataset.embedding_model_provider, tenant_id=current_user.current_tenant_id,
model_type=ModelType.TEXT_EMBEDDING, provider=dataset.embedding_model_provider,
model=dataset.embedding_model, model_type=ModelType.TEXT_EMBEDDING,
model=dataset.embedding_model,
)
max_position = (
db.session.query(func.max(DocumentSegment.position))
.where(DocumentSegment.document_id == document.id)
.scalar()
) )
max_position = ( pre_segment_data_list = []
db.session.query(func.max(DocumentSegment.position)) segment_data_list = []
.where(DocumentSegment.document_id == document.id) keywords_list = []
.scalar() position = max_position + 1 if max_position else 1
) for segment_item in segments:
pre_segment_data_list = [] content = segment_item["content"]
segment_data_list = [] doc_id = str(uuid.uuid4())
keywords_list = [] segment_hash = helper.generate_text_hash(content)
position = max_position + 1 if max_position else 1 tokens = 0
for segment_item in segments: if dataset.indexing_technique == "high_quality" and embedding_model:
content = segment_item["content"] # calc embedding use tokens
doc_id = str(uuid.uuid4()) if document.doc_form == "qa_model":
segment_hash = helper.generate_text_hash(content) tokens = embedding_model.get_text_embedding_num_tokens(
tokens = 0 texts=[content + segment_item["answer"]]
if dataset.indexing_technique == "high_quality" and embedding_model: )[0]
# calc embedding use tokens else:
tokens = embedding_model.get_text_embedding_num_tokens(texts=[content])[0]
segment_document = DocumentSegment(
tenant_id=current_user.current_tenant_id,
dataset_id=document.dataset_id,
document_id=document.id,
index_node_id=doc_id,
index_node_hash=segment_hash,
position=position,
content=content,
word_count=len(content),
tokens=tokens,
keywords=segment_item.get("keywords", []),
status="completed",
indexing_at=naive_utc_now(),
completed_at=naive_utc_now(),
created_by=current_user.id,
)
if document.doc_form == "qa_model": if document.doc_form == "qa_model":
tokens = embedding_model.get_text_embedding_num_tokens( segment_document.answer = segment_item["answer"]
texts=[content + segment_item["answer"]] segment_document.word_count += len(segment_item["answer"])
)[0] increment_word_count += segment_document.word_count
db.session.add(segment_document)
segment_data_list.append(segment_document)
position += 1
pre_segment_data_list.append(segment_document)
if "keywords" in segment_item:
keywords_list.append(segment_item["keywords"])
else: else:
tokens = embedding_model.get_text_embedding_num_tokens(texts=[content])[0] keywords_list.append(None)
# update document word count
segment_document = DocumentSegment( assert document.word_count is not None
tenant_id=current_user.current_tenant_id, document.word_count += increment_word_count
dataset_id=document.dataset_id, db.session.add(document)
document_id=document.id, try:
index_node_id=doc_id, # save vector index
index_node_hash=segment_hash, VectorService.create_segments_vector(
position=position, keywords_list, pre_segment_data_list, dataset, document.doc_form
content=content, )
word_count=len(content), except Exception as e:
tokens=tokens, logger.exception("create segment index failed")
keywords=segment_item.get("keywords", []), for segment_document in segment_data_list:
status="completed", segment_document.enabled = False
indexing_at=naive_utc_now(), segment_document.disabled_at = naive_utc_now()
completed_at=naive_utc_now(), segment_document.status = "error"
created_by=current_user.id, segment_document.error = str(e)
) db.session.commit()
if document.doc_form == "qa_model": return segment_data_list
segment_document.answer = segment_item["answer"] except LockNotOwnedError:
segment_document.word_count += len(segment_item["answer"]) pass
increment_word_count += segment_document.word_count
db.session.add(segment_document)
segment_data_list.append(segment_document)
position += 1
pre_segment_data_list.append(segment_document)
if "keywords" in segment_item:
keywords_list.append(segment_item["keywords"])
else:
keywords_list.append(None)
# update document word count
assert document.word_count is not None
document.word_count += increment_word_count
db.session.add(document)
try:
# save vector index
VectorService.create_segments_vector(keywords_list, pre_segment_data_list, dataset, document.doc_form)
except Exception as e:
logger.exception("create segment index failed")
for segment_document in segment_data_list:
segment_document.enabled = False
segment_document.disabled_at = naive_utc_now()
segment_document.status = "error"
segment_document.error = str(e)
db.session.commit()
return segment_data_list
@classmethod @classmethod
def update_segment(cls, args: SegmentUpdateArgs, segment: DocumentSegment, document: Document, dataset: Dataset): def update_segment(cls, args: SegmentUpdateArgs, segment: DocumentSegment, document: Document, dataset: Dataset):

View File

@ -69,6 +69,7 @@ class ProviderResponse(BaseModel):
label: I18nObject label: I18nObject
description: I18nObject | None = None description: I18nObject | None = None
icon_small: I18nObject | None = None icon_small: I18nObject | None = None
icon_small_dark: I18nObject | None = None
icon_large: I18nObject | None = None icon_large: I18nObject | None = None
background: str | None = None background: str | None = None
help: ProviderHelpEntity | None = None help: ProviderHelpEntity | None = None
@ -92,6 +93,11 @@ class ProviderResponse(BaseModel):
self.icon_small = I18nObject( self.icon_small = I18nObject(
en_US=f"{url_prefix}/icon_small/en_US", zh_Hans=f"{url_prefix}/icon_small/zh_Hans" en_US=f"{url_prefix}/icon_small/en_US", zh_Hans=f"{url_prefix}/icon_small/zh_Hans"
) )
if self.icon_small_dark is not None:
self.icon_small_dark = I18nObject(
en_US=f"{url_prefix}/icon_small_dark/en_US",
zh_Hans=f"{url_prefix}/icon_small_dark/zh_Hans",
)
if self.icon_large is not None: if self.icon_large is not None:
self.icon_large = I18nObject( self.icon_large = I18nObject(
@ -109,6 +115,7 @@ class ProviderWithModelsResponse(BaseModel):
provider: str provider: str
label: I18nObject label: I18nObject
icon_small: I18nObject | None = None icon_small: I18nObject | None = None
icon_small_dark: I18nObject | None = None
icon_large: I18nObject | None = None icon_large: I18nObject | None = None
status: CustomConfigurationStatus status: CustomConfigurationStatus
models: list[ProviderModelWithStatusEntity] models: list[ProviderModelWithStatusEntity]
@ -123,6 +130,11 @@ class ProviderWithModelsResponse(BaseModel):
en_US=f"{url_prefix}/icon_small/en_US", zh_Hans=f"{url_prefix}/icon_small/zh_Hans" en_US=f"{url_prefix}/icon_small/en_US", zh_Hans=f"{url_prefix}/icon_small/zh_Hans"
) )
if self.icon_small_dark is not None:
self.icon_small_dark = I18nObject(
en_US=f"{url_prefix}/icon_small_dark/en_US", zh_Hans=f"{url_prefix}/icon_small_dark/zh_Hans"
)
if self.icon_large is not None: if self.icon_large is not None:
self.icon_large = I18nObject( self.icon_large = I18nObject(
en_US=f"{url_prefix}/icon_large/en_US", zh_Hans=f"{url_prefix}/icon_large/zh_Hans" en_US=f"{url_prefix}/icon_large/en_US", zh_Hans=f"{url_prefix}/icon_large/zh_Hans"
@ -147,6 +159,11 @@ class SimpleProviderEntityResponse(SimpleProviderEntity):
en_US=f"{url_prefix}/icon_small/en_US", zh_Hans=f"{url_prefix}/icon_small/zh_Hans" en_US=f"{url_prefix}/icon_small/en_US", zh_Hans=f"{url_prefix}/icon_small/zh_Hans"
) )
if self.icon_small_dark is not None:
self.icon_small_dark = I18nObject(
en_US=f"{url_prefix}/icon_small_dark/en_US", zh_Hans=f"{url_prefix}/icon_small_dark/zh_Hans"
)
if self.icon_large is not None: if self.icon_large is not None:
self.icon_large = I18nObject( self.icon_large = I18nObject(
en_US=f"{url_prefix}/icon_large/en_US", zh_Hans=f"{url_prefix}/icon_large/zh_Hans" en_US=f"{url_prefix}/icon_large/en_US", zh_Hans=f"{url_prefix}/icon_large/zh_Hans"

View File

@ -79,6 +79,7 @@ class ModelProviderService:
label=provider_configuration.provider.label, label=provider_configuration.provider.label,
description=provider_configuration.provider.description, description=provider_configuration.provider.description,
icon_small=provider_configuration.provider.icon_small, icon_small=provider_configuration.provider.icon_small,
icon_small_dark=provider_configuration.provider.icon_small_dark,
icon_large=provider_configuration.provider.icon_large, icon_large=provider_configuration.provider.icon_large,
background=provider_configuration.provider.background, background=provider_configuration.provider.background,
help=provider_configuration.provider.help, help=provider_configuration.provider.help,
@ -402,6 +403,7 @@ class ModelProviderService:
provider=provider, provider=provider,
label=first_model.provider.label, label=first_model.provider.label,
icon_small=first_model.provider.icon_small, icon_small=first_model.provider.icon_small,
icon_small_dark=first_model.provider.icon_small_dark,
icon_large=first_model.provider.icon_large, icon_large=first_model.provider.icon_large,
status=CustomConfigurationStatus.ACTIVE, status=CustomConfigurationStatus.ACTIVE,
models=[ models=[

View File

@ -233,7 +233,7 @@ workflow:
- value_selector: - value_selector:
- iteration_node - iteration_node
- output - output
value_type: array[array[number]] value_type: array[number]
variable: output variable: output
selected: false selected: false
title: End title: End

View File

@ -227,6 +227,7 @@ class TestModelProviderService:
mock_provider_entity.label = {"en_US": "OpenAI", "zh_Hans": "OpenAI"} mock_provider_entity.label = {"en_US": "OpenAI", "zh_Hans": "OpenAI"}
mock_provider_entity.description = {"en_US": "OpenAI provider", "zh_Hans": "OpenAI 提供商"} mock_provider_entity.description = {"en_US": "OpenAI provider", "zh_Hans": "OpenAI 提供商"}
mock_provider_entity.icon_small = {"en_US": "icon_small.png", "zh_Hans": "icon_small.png"} mock_provider_entity.icon_small = {"en_US": "icon_small.png", "zh_Hans": "icon_small.png"}
mock_provider_entity.icon_small_dark = None
mock_provider_entity.icon_large = {"en_US": "icon_large.png", "zh_Hans": "icon_large.png"} mock_provider_entity.icon_large = {"en_US": "icon_large.png", "zh_Hans": "icon_large.png"}
mock_provider_entity.background = "#FF6B6B" mock_provider_entity.background = "#FF6B6B"
mock_provider_entity.help = None mock_provider_entity.help = None
@ -300,6 +301,7 @@ class TestModelProviderService:
mock_provider_entity_llm.label = {"en_US": "OpenAI", "zh_Hans": "OpenAI"} mock_provider_entity_llm.label = {"en_US": "OpenAI", "zh_Hans": "OpenAI"}
mock_provider_entity_llm.description = {"en_US": "OpenAI provider", "zh_Hans": "OpenAI 提供商"} mock_provider_entity_llm.description = {"en_US": "OpenAI provider", "zh_Hans": "OpenAI 提供商"}
mock_provider_entity_llm.icon_small = {"en_US": "icon_small.png", "zh_Hans": "icon_small.png"} mock_provider_entity_llm.icon_small = {"en_US": "icon_small.png", "zh_Hans": "icon_small.png"}
mock_provider_entity_llm.icon_small_dark = None
mock_provider_entity_llm.icon_large = {"en_US": "icon_large.png", "zh_Hans": "icon_large.png"} mock_provider_entity_llm.icon_large = {"en_US": "icon_large.png", "zh_Hans": "icon_large.png"}
mock_provider_entity_llm.background = "#FF6B6B" mock_provider_entity_llm.background = "#FF6B6B"
mock_provider_entity_llm.help = None mock_provider_entity_llm.help = None
@ -313,6 +315,7 @@ class TestModelProviderService:
mock_provider_entity_embedding.label = {"en_US": "Cohere", "zh_Hans": "Cohere"} mock_provider_entity_embedding.label = {"en_US": "Cohere", "zh_Hans": "Cohere"}
mock_provider_entity_embedding.description = {"en_US": "Cohere provider", "zh_Hans": "Cohere 提供商"} mock_provider_entity_embedding.description = {"en_US": "Cohere provider", "zh_Hans": "Cohere 提供商"}
mock_provider_entity_embedding.icon_small = {"en_US": "icon_small.png", "zh_Hans": "icon_small.png"} mock_provider_entity_embedding.icon_small = {"en_US": "icon_small.png", "zh_Hans": "icon_small.png"}
mock_provider_entity_embedding.icon_small_dark = None
mock_provider_entity_embedding.icon_large = {"en_US": "icon_large.png", "zh_Hans": "icon_large.png"} mock_provider_entity_embedding.icon_large = {"en_US": "icon_large.png", "zh_Hans": "icon_large.png"}
mock_provider_entity_embedding.background = "#4ECDC4" mock_provider_entity_embedding.background = "#4ECDC4"
mock_provider_entity_embedding.help = None mock_provider_entity_embedding.help = None
@ -1023,6 +1026,7 @@ class TestModelProviderService:
provider="openai", provider="openai",
label={"en_US": "OpenAI", "zh_Hans": "OpenAI"}, label={"en_US": "OpenAI", "zh_Hans": "OpenAI"},
icon_small={"en_US": "icon_small.png", "zh_Hans": "icon_small.png"}, icon_small={"en_US": "icon_small.png", "zh_Hans": "icon_small.png"},
icon_small_dark=None,
icon_large={"en_US": "icon_large.png", "zh_Hans": "icon_large.png"}, icon_large={"en_US": "icon_large.png", "zh_Hans": "icon_large.png"},
), ),
model="gpt-3.5-turbo", model="gpt-3.5-turbo",
@ -1040,6 +1044,7 @@ class TestModelProviderService:
provider="openai", provider="openai",
label={"en_US": "OpenAI", "zh_Hans": "OpenAI"}, label={"en_US": "OpenAI", "zh_Hans": "OpenAI"},
icon_small={"en_US": "icon_small.png", "zh_Hans": "icon_small.png"}, icon_small={"en_US": "icon_small.png", "zh_Hans": "icon_small.png"},
icon_small_dark=None,
icon_large={"en_US": "icon_large.png", "zh_Hans": "icon_large.png"}, icon_large={"en_US": "icon_large.png", "zh_Hans": "icon_large.png"},
), ),
model="gpt-4", model="gpt-4",

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,5 @@
from types import SimpleNamespace
import pytest import pytest
from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.app_invoke_entities import InvokeFrom
@ -214,3 +216,76 @@ def test_create_variable_message():
assert message.message.variable_name == var_name assert message.message.variable_name == var_name
assert message.message.variable_value == var_value assert message.message.variable_value == var_value
assert message.message.stream is False assert message.message.stream is False
def test_resolve_user_from_database_falls_back_to_end_user(monkeypatch: pytest.MonkeyPatch):
"""Ensure worker context can resolve EndUser when Account is missing."""
class StubSession:
def __init__(self, results: list):
self.results = results
def scalar(self, _stmt):
return self.results.pop(0)
tenant = SimpleNamespace(id="tenant_id")
end_user = SimpleNamespace(id="end_user_id", tenant_id="tenant_id")
db_stub = SimpleNamespace(session=StubSession([tenant, None, end_user]))
monkeypatch.setattr("core.tools.workflow_as_tool.tool.db", db_stub)
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="tenant_id", invoke_from=InvokeFrom.SERVICE_API)
tool = WorkflowTool(
workflow_app_id="",
workflow_as_tool_id="",
version="1",
workflow_entities={},
workflow_call_depth=1,
entity=entity,
runtime=runtime,
)
resolved_user = tool._resolve_user_from_database(user_id=end_user.id)
assert resolved_user is end_user
def test_resolve_user_from_database_returns_none_when_no_tenant(monkeypatch: pytest.MonkeyPatch):
"""Return None if tenant cannot be found in worker context."""
class StubSession:
def __init__(self, results: list):
self.results = results
def scalar(self, _stmt):
return self.results.pop(0)
db_stub = SimpleNamespace(session=StubSession([None]))
monkeypatch.setattr("core.tools.workflow_as_tool.tool.db", db_stub)
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="missing_tenant", invoke_from=InvokeFrom.SERVICE_API)
tool = WorkflowTool(
workflow_app_id="",
workflow_as_tool_id="",
version="1",
workflow_entities={},
workflow_call_depth=1,
entity=entity,
runtime=runtime,
)
resolved_user = tool._resolve_user_from_database(user_id="any")
assert resolved_user is None

View File

@ -29,7 +29,7 @@ class _TestNode(Node[_TestNodeData]):
@classmethod @classmethod
def version(cls) -> str: def version(cls) -> str:
return "test" return "1"
def __init__( def __init__(
self, self,

View File

@ -7,9 +7,31 @@ This module tests the iteration node's ability to:
""" """
from .test_database_utils import skip_if_database_unavailable from .test_database_utils import skip_if_database_unavailable
from .test_mock_config import MockConfigBuilder, NodeMockConfig
from .test_table_runner import TableTestRunner, WorkflowTestCase from .test_table_runner import TableTestRunner, WorkflowTestCase
def _create_iteration_mock_config():
"""Helper to create a mock config for iteration tests."""
def code_inner_handler(node):
pool = node.graph_runtime_state.variable_pool
item_seg = pool.get(["iteration_node", "item"])
if item_seg is not None:
item = item_seg.to_object()
return {"result": [item, item * 2]}
# This fallback is likely unreachable, but if it is,
# it doesn't simulate iteration with different values as the comment suggests.
return {"result": [1, 2]}
return (
MockConfigBuilder()
.with_node_output("code_node", {"result": [1, 2, 3]})
.with_node_config(NodeMockConfig(node_id="code_inner_node", custom_handler=code_inner_handler))
.build()
)
@skip_if_database_unavailable() @skip_if_database_unavailable()
def test_iteration_with_flatten_output_enabled(): def test_iteration_with_flatten_output_enabled():
""" """
@ -27,7 +49,8 @@ def test_iteration_with_flatten_output_enabled():
inputs={}, inputs={},
expected_outputs={"output": [1, 2, 2, 4, 3, 6]}, expected_outputs={"output": [1, 2, 2, 4, 3, 6]},
description="Iteration with flatten_output=True flattens nested arrays", description="Iteration with flatten_output=True flattens nested arrays",
use_auto_mock=False, # Run code nodes directly use_auto_mock=True, # Use auto-mock to avoid sandbox service
mock_config=_create_iteration_mock_config(),
) )
result = runner.run_test_case(test_case) result = runner.run_test_case(test_case)
@ -56,7 +79,8 @@ def test_iteration_with_flatten_output_disabled():
inputs={}, inputs={},
expected_outputs={"output": [[1, 2], [2, 4], [3, 6]]}, expected_outputs={"output": [[1, 2], [2, 4], [3, 6]]},
description="Iteration with flatten_output=False preserves nested structure", description="Iteration with flatten_output=False preserves nested structure",
use_auto_mock=False, # Run code nodes directly use_auto_mock=True, # Use auto-mock to avoid sandbox service
mock_config=_create_iteration_mock_config(),
) )
result = runner.run_test_case(test_case) result = runner.run_test_case(test_case)
@ -81,14 +105,16 @@ def test_iteration_flatten_output_comparison():
inputs={}, inputs={},
expected_outputs={"output": [1, 2, 2, 4, 3, 6]}, expected_outputs={"output": [1, 2, 2, 4, 3, 6]},
description="flatten_output=True: Flattened output", description="flatten_output=True: Flattened output",
use_auto_mock=False, # Run code nodes directly use_auto_mock=True, # Use auto-mock to avoid sandbox service
mock_config=_create_iteration_mock_config(),
), ),
WorkflowTestCase( WorkflowTestCase(
fixture_path="iteration_flatten_output_disabled_workflow", fixture_path="iteration_flatten_output_disabled_workflow",
inputs={}, inputs={},
expected_outputs={"output": [[1, 2], [2, 4], [3, 6]]}, expected_outputs={"output": [[1, 2], [2, 4], [3, 6]]},
description="flatten_output=False: Nested output", description="flatten_output=False: Nested output",
use_auto_mock=False, # Run code nodes directly use_auto_mock=True, # Use auto-mock to avoid sandbox service
mock_config=_create_iteration_mock_config(),
), ),
] ]

View File

@ -92,7 +92,7 @@ class MockLLMNode(MockNodeMixin, LLMNode):
@classmethod @classmethod
def version(cls) -> str: def version(cls) -> str:
"""Return the version of this mock node.""" """Return the version of this mock node."""
return "mock-1" return "1"
def _run(self) -> Generator: def _run(self) -> Generator:
"""Execute mock LLM node.""" """Execute mock LLM node."""
@ -189,7 +189,7 @@ class MockAgentNode(MockNodeMixin, AgentNode):
@classmethod @classmethod
def version(cls) -> str: def version(cls) -> str:
"""Return the version of this mock node.""" """Return the version of this mock node."""
return "mock-1" return "1"
def _run(self) -> Generator: def _run(self) -> Generator:
"""Execute mock agent node.""" """Execute mock agent node."""
@ -241,7 +241,7 @@ class MockToolNode(MockNodeMixin, ToolNode):
@classmethod @classmethod
def version(cls) -> str: def version(cls) -> str:
"""Return the version of this mock node.""" """Return the version of this mock node."""
return "mock-1" return "1"
def _run(self) -> Generator: def _run(self) -> Generator:
"""Execute mock tool node.""" """Execute mock tool node."""
@ -294,7 +294,7 @@ class MockKnowledgeRetrievalNode(MockNodeMixin, KnowledgeRetrievalNode):
@classmethod @classmethod
def version(cls) -> str: def version(cls) -> str:
"""Return the version of this mock node.""" """Return the version of this mock node."""
return "mock-1" return "1"
def _run(self) -> Generator: def _run(self) -> Generator:
"""Execute mock knowledge retrieval node.""" """Execute mock knowledge retrieval node."""
@ -351,7 +351,7 @@ class MockHttpRequestNode(MockNodeMixin, HttpRequestNode):
@classmethod @classmethod
def version(cls) -> str: def version(cls) -> str:
"""Return the version of this mock node.""" """Return the version of this mock node."""
return "mock-1" return "1"
def _run(self) -> Generator: def _run(self) -> Generator:
"""Execute mock HTTP request node.""" """Execute mock HTTP request node."""
@ -404,7 +404,7 @@ class MockQuestionClassifierNode(MockNodeMixin, QuestionClassifierNode):
@classmethod @classmethod
def version(cls) -> str: def version(cls) -> str:
"""Return the version of this mock node.""" """Return the version of this mock node."""
return "mock-1" return "1"
def _run(self) -> Generator: def _run(self) -> Generator:
"""Execute mock question classifier node.""" """Execute mock question classifier node."""
@ -452,7 +452,7 @@ class MockParameterExtractorNode(MockNodeMixin, ParameterExtractorNode):
@classmethod @classmethod
def version(cls) -> str: def version(cls) -> str:
"""Return the version of this mock node.""" """Return the version of this mock node."""
return "mock-1" return "1"
def _run(self) -> Generator: def _run(self) -> Generator:
"""Execute mock parameter extractor node.""" """Execute mock parameter extractor node."""
@ -502,7 +502,7 @@ class MockDocumentExtractorNode(MockNodeMixin, DocumentExtractorNode):
@classmethod @classmethod
def version(cls) -> str: def version(cls) -> str:
"""Return the version of this mock node.""" """Return the version of this mock node."""
return "mock-1" return "1"
def _run(self) -> Generator: def _run(self) -> Generator:
"""Execute mock document extractor node.""" """Execute mock document extractor node."""
@ -557,7 +557,7 @@ class MockIterationNode(MockNodeMixin, IterationNode):
@classmethod @classmethod
def version(cls) -> str: def version(cls) -> str:
"""Return the version of this mock node.""" """Return the version of this mock node."""
return "mock-1" return "1"
def _create_graph_engine(self, index: int, item: Any): def _create_graph_engine(self, index: int, item: Any):
"""Create a graph engine with MockNodeFactory instead of DifyNodeFactory.""" """Create a graph engine with MockNodeFactory instead of DifyNodeFactory."""
@ -632,7 +632,7 @@ class MockLoopNode(MockNodeMixin, LoopNode):
@classmethod @classmethod
def version(cls) -> str: def version(cls) -> str:
"""Return the version of this mock node.""" """Return the version of this mock node."""
return "mock-1" return "1"
def _create_graph_engine(self, start_at, root_node_id: str): def _create_graph_engine(self, start_at, root_node_id: str):
"""Create a graph engine with MockNodeFactory instead of DifyNodeFactory.""" """Create a graph engine with MockNodeFactory instead of DifyNodeFactory."""
@ -694,7 +694,7 @@ class MockTemplateTransformNode(MockNodeMixin, TemplateTransformNode):
@classmethod @classmethod
def version(cls) -> str: def version(cls) -> str:
"""Return the version of this mock node.""" """Return the version of this mock node."""
return "mock-1" return "1"
def _run(self) -> NodeRunResult: def _run(self) -> NodeRunResult:
"""Execute mock template transform node.""" """Execute mock template transform node."""
@ -780,7 +780,7 @@ class MockCodeNode(MockNodeMixin, CodeNode):
@classmethod @classmethod
def version(cls) -> str: def version(cls) -> str:
"""Return the version of this mock node.""" """Return the version of this mock node."""
return "mock-1" return "1"
def _run(self) -> NodeRunResult: def _run(self) -> NodeRunResult:
"""Execute mock code node.""" """Execute mock code node."""

View File

@ -33,6 +33,10 @@ def test_ensure_subclasses_of_base_node_has_node_type_and_version_method_defined
type_version_set: set[tuple[NodeType, str]] = set() type_version_set: set[tuple[NodeType, str]] = set()
for cls in classes: for cls in classes:
# Only validate production node classes; skip test-defined subclasses and external helpers
module_name = getattr(cls, "__module__", "")
if not module_name.startswith("core."):
continue
# Validate that 'version' is directly defined in the class (not inherited) by checking the class's __dict__ # Validate that 'version' is directly defined in the class (not inherited) by checking the class's __dict__
assert "version" in cls.__dict__, f"class {cls} should have version method defined (NOT INHERITED.)" assert "version" in cls.__dict__, f"class {cls} should have version method defined (NOT INHERITED.)"
node_type = cls.node_type node_type = cls.node_type

View File

@ -0,0 +1,84 @@
import types
from collections.abc import Mapping
from core.workflow.enums import NodeType
from core.workflow.nodes.base.entities import BaseNodeData
from core.workflow.nodes.base.node import Node
# Import concrete nodes we will assert on (numeric version path)
from core.workflow.nodes.variable_assigner.v1.node import (
VariableAssignerNode as VariableAssignerV1,
)
from core.workflow.nodes.variable_assigner.v2.node import (
VariableAssignerNode as VariableAssignerV2,
)
def test_variable_assigner_latest_prefers_highest_numeric_version():
# Act
mapping: Mapping[NodeType, Mapping[str, type[Node]]] = Node.get_node_type_classes_mapping()
# Assert basic presence
assert NodeType.VARIABLE_ASSIGNER in mapping
va_versions = mapping[NodeType.VARIABLE_ASSIGNER]
# Both concrete versions must be present
assert va_versions.get("1") is VariableAssignerV1
assert va_versions.get("2") is VariableAssignerV2
# And latest should point to numerically-highest version ("2")
assert va_versions.get("latest") is VariableAssignerV2
def test_latest_prefers_highest_numeric_version():
# Arrange: define two ephemeral subclasses with numeric versions under a NodeType
# that has no concrete implementations in production to avoid interference.
class _Version1(Node[BaseNodeData]): # type: ignore[misc]
node_type = NodeType.LEGACY_VARIABLE_AGGREGATOR
def init_node_data(self, data):
pass
def _run(self):
raise NotImplementedError
@classmethod
def version(cls) -> str:
return "1"
def _get_error_strategy(self):
return None
def _get_retry_config(self):
return types.SimpleNamespace() # not used
def _get_title(self) -> str:
return "version1"
def _get_description(self):
return None
def _get_default_value_dict(self):
return {}
def get_base_node_data(self):
return types.SimpleNamespace(title="version1")
class _Version2(_Version1): # type: ignore[misc]
@classmethod
def version(cls) -> str:
return "2"
def _get_title(self) -> str:
return "version2"
# Act: build a fresh mapping (it should now see our ephemeral subclasses)
mapping: Mapping[NodeType, Mapping[str, type[Node]]] = Node.get_node_type_classes_mapping()
# Assert: both numeric versions exist for this NodeType; 'latest' points to the higher numeric version
assert NodeType.LEGACY_VARIABLE_AGGREGATOR in mapping
legacy_versions = mapping[NodeType.LEGACY_VARIABLE_AGGREGATOR]
assert legacy_versions.get("1") is _Version1
assert legacy_versions.get("2") is _Version2
assert legacy_versions.get("latest") is _Version2

View File

@ -19,7 +19,7 @@ class _SampleNode(Node[_SampleNodeData]):
@classmethod @classmethod
def version(cls) -> str: def version(cls) -> str:
return "sample-test" return "1"
def _run(self): def _run(self):
raise NotImplementedError raise NotImplementedError

View File

@ -263,3 +263,62 @@ class TestResponseUnmodified:
) )
assert response.text == _RESPONSE_NEEDLE assert response.text == _RESPONSE_NEEDLE
assert response.status_code == 200 assert response.status_code == 200
class TestRequestFinishedInfoAccessLine:
def test_info_access_log_includes_method_path_status_duration_trace_id(self, monkeypatch, caplog):
"""Ensure INFO access line contains expected fields with computed duration and trace id."""
app = _get_test_app()
# Push a real request context so flask.request and g are available
with app.test_request_context("/foo", method="GET"):
# Seed start timestamp via the extension's own start hook and control perf_counter deterministically
seq = iter([100.0, 100.123456])
monkeypatch.setattr(ext_request_logging.time, "perf_counter", lambda: next(seq))
# Provide a deterministic trace id
monkeypatch.setattr(
ext_request_logging,
"get_trace_id_from_otel_context",
lambda: "trace-xyz",
)
# Simulate request_started to record start timestamp on g
ext_request_logging._log_request_started(app)
# Capture logs from the real logger at INFO level only (skip DEBUG branch)
caplog.set_level(logging.INFO, logger=ext_request_logging.__name__)
response = Response(json.dumps({"ok": True}), mimetype="application/json", status=200)
_log_request_finished(app, response)
# Verify a single INFO record with the five fields in order
info_records = [rec for rec in caplog.records if rec.levelno == logging.INFO]
assert len(info_records) == 1
msg = info_records[0].getMessage()
# Expected format: METHOD PATH STATUS DURATION_MS TRACE_ID
assert "GET" in msg
assert "/foo" in msg
assert "200" in msg
assert "123.456" in msg # rounded to 3 decimals
assert "trace-xyz" in msg
def test_info_access_log_uses_dash_without_start_timestamp(self, monkeypatch, caplog):
app = _get_test_app()
with app.test_request_context("/bar", method="POST"):
# No g.__request_started_ts set -> duration should be '-'
monkeypatch.setattr(
ext_request_logging,
"get_trace_id_from_otel_context",
lambda: "tid-no-start",
)
caplog.set_level(logging.INFO, logger=ext_request_logging.__name__)
response = Response("OK", mimetype="text/plain", status=204)
_log_request_finished(app, response)
info_records = [rec for rec in caplog.records if rec.levelno == logging.INFO]
assert len(info_records) == 1
msg = info_records[0].getMessage()
assert "POST" in msg
assert "/bar" in msg
assert "204" in msg
# Duration placeholder
# The fields are space separated; ensure a standalone '-' appears
assert " - " in msg or msg.endswith(" -")
assert "tid-no-start" in msg

View File

@ -0,0 +1,177 @@
import types
from unittest.mock import Mock, create_autospec
import pytest
from redis.exceptions import LockNotOwnedError
from models.account import Account
from models.dataset import Dataset, Document
from services.dataset_service import DocumentService, SegmentService
class FakeLock:
"""Lock that always fails on enter with LockNotOwnedError."""
def __enter__(self):
raise LockNotOwnedError("simulated")
def __exit__(self, exc_type, exc, tb):
# Normal contextmanager signature; return False so exceptions propagate
return False
@pytest.fixture
def fake_current_user(monkeypatch):
user = create_autospec(Account, instance=True)
user.id = "user-1"
user.current_tenant_id = "tenant-1"
monkeypatch.setattr("services.dataset_service.current_user", user)
return user
@pytest.fixture
def fake_features(monkeypatch):
"""Features.billing.enabled == False to skip quota logic."""
features = types.SimpleNamespace(
billing=types.SimpleNamespace(enabled=False, subscription=types.SimpleNamespace(plan="ENTERPRISE")),
documents_upload_quota=types.SimpleNamespace(limit=10_000, size=0),
)
monkeypatch.setattr(
"services.dataset_service.FeatureService.get_features",
lambda tenant_id: features,
)
return features
@pytest.fixture
def fake_lock(monkeypatch):
"""Patch redis_client.lock to always raise LockNotOwnedError on enter."""
def _fake_lock(name, timeout=None, *args, **kwargs):
return FakeLock()
# DatasetService imports redis_client directly from extensions.ext_redis
monkeypatch.setattr("services.dataset_service.redis_client.lock", _fake_lock)
# ---------------------------------------------------------------------------
# 1. Knowledge Pipeline document creation (save_document_with_dataset_id)
# ---------------------------------------------------------------------------
def test_save_document_with_dataset_id_ignores_lock_not_owned(
monkeypatch,
fake_current_user,
fake_features,
fake_lock,
):
# Arrange
dataset = create_autospec(Dataset, instance=True)
dataset.id = "ds-1"
dataset.tenant_id = fake_current_user.current_tenant_id
dataset.data_source_type = "upload_file"
dataset.indexing_technique = "high_quality" # so we skip re-initialization branch
# Minimal knowledge_config stub that satisfies pre-lock code
info_list = types.SimpleNamespace(data_source_type="upload_file")
data_source = types.SimpleNamespace(info_list=info_list)
knowledge_config = types.SimpleNamespace(
doc_form="qa_model",
original_document_id=None, # go into "new document" branch
data_source=data_source,
indexing_technique="high_quality",
embedding_model=None,
embedding_model_provider=None,
retrieval_model=None,
process_rule=None,
duplicate=False,
doc_language="en",
)
account = fake_current_user
# Avoid touching real doc_form logic
monkeypatch.setattr("services.dataset_service.DatasetService.check_doc_form", lambda *a, **k: None)
# Avoid real DB interactions
monkeypatch.setattr("services.dataset_service.db", Mock())
# Act: this would hit the redis lock, whose __enter__ raises LockNotOwnedError.
# Our implementation should catch it and still return (documents, batch).
documents, batch = DocumentService.save_document_with_dataset_id(
dataset=dataset,
knowledge_config=knowledge_config,
account=account,
)
# Assert
# We mainly care that:
# - No exception is raised
# - The function returns a sensible tuple
assert isinstance(documents, list)
assert isinstance(batch, str)
# ---------------------------------------------------------------------------
# 2. Single-segment creation (add_segment)
# ---------------------------------------------------------------------------
def test_add_segment_ignores_lock_not_owned(
monkeypatch,
fake_current_user,
fake_lock,
):
# Arrange
dataset = create_autospec(Dataset, instance=True)
dataset.id = "ds-1"
dataset.tenant_id = fake_current_user.current_tenant_id
dataset.indexing_technique = "economy" # skip embedding/token calculation branch
document = create_autospec(Document, instance=True)
document.id = "doc-1"
document.dataset_id = dataset.id
document.word_count = 0
document.doc_form = "qa_model"
# Minimal args required by add_segment
args = {
"content": "question text",
"answer": "answer text",
"keywords": ["k1", "k2"],
}
# Avoid real DB operations
db_mock = Mock()
db_mock.session = Mock()
monkeypatch.setattr("services.dataset_service.db", db_mock)
monkeypatch.setattr("services.dataset_service.VectorService", Mock())
# Act
result = SegmentService.create_segment(args=args, document=document, dataset=dataset)
# Assert
# Under LockNotOwnedError except, add_segment should swallow the error and return None.
assert result is None
# ---------------------------------------------------------------------------
# 3. Multi-segment creation (multi_create_segment)
# ---------------------------------------------------------------------------
def test_multi_create_segment_ignores_lock_not_owned(
monkeypatch,
fake_current_user,
fake_lock,
):
# Arrange
dataset = create_autospec(Dataset, instance=True)
dataset.id = "ds-1"
dataset.tenant_id = fake_current_user.current_tenant_id
dataset.indexing_technique = "economy" # again, skip high_quality path
document = create_autospec(Document, instance=True)
document.id = "doc-1"
document.dataset_id = dataset.id
document.word_count = 0
document.doc_form = "qa_model"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -233,7 +233,7 @@ NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
# Database type, supported values are `postgresql` and `mysql` # Database type, supported values are `postgresql` and `mysql`
DB_TYPE=postgresql DB_TYPE=postgresql
# For MySQL, only `root` user is supported for now
DB_USERNAME=postgres DB_USERNAME=postgres
DB_PASSWORD=difyai123456 DB_PASSWORD=difyai123456
DB_HOST=db_postgres DB_HOST=db_postgres
@ -1076,24 +1076,10 @@ MAX_TREE_DEPTH=50
# ------------------------------ # ------------------------------
# Environment Variables for database Service # Environment Variables for database Service
# ------------------------------ # ------------------------------
# The name of the default postgres user.
POSTGRES_USER=${DB_USERNAME}
# The password for the default postgres user.
POSTGRES_PASSWORD=${DB_PASSWORD}
# The name of the default postgres database.
POSTGRES_DB=${DB_DATABASE}
# Postgres data directory # Postgres data directory
PGDATA=/var/lib/postgresql/data/pgdata PGDATA=/var/lib/postgresql/data/pgdata
# MySQL Default Configuration # MySQL Default Configuration
# The name of the default mysql user.
MYSQL_USERNAME=${DB_USERNAME}
# The password for the default mysql user.
MYSQL_PASSWORD=${DB_PASSWORD}
# The name of the default mysql database.
MYSQL_DATABASE=${DB_DATABASE}
# MySQL data directory
MYSQL_HOST_VOLUME=./volumes/mysql/data MYSQL_HOST_VOLUME=./volumes/mysql/data
# ------------------------------ # ------------------------------

View File

@ -139,9 +139,9 @@ services:
- postgresql - postgresql
restart: always restart: always
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-difyai123456} POSTGRES_PASSWORD: ${DB_PASSWORD:-difyai123456}
POSTGRES_DB: ${POSTGRES_DB:-dify} POSTGRES_DB: ${DB_DATABASE:-dify}
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata} PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
command: > command: >
postgres -c 'max_connections=${POSTGRES_MAX_CONNECTIONS:-100}' postgres -c 'max_connections=${POSTGRES_MAX_CONNECTIONS:-100}'
@ -161,7 +161,7 @@ services:
"-h", "-h",
"db_postgres", "db_postgres",
"-U", "-U",
"${PGUSER:-postgres}", "${DB_USERNAME:-postgres}",
"-d", "-d",
"${DB_DATABASE:-dify}", "${DB_DATABASE:-dify}",
] ]
@ -176,8 +176,8 @@ services:
- mysql - mysql
restart: always restart: always
environment: environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD:-difyai123456} MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
MYSQL_DATABASE: ${MYSQL_DATABASE:-dify} MYSQL_DATABASE: ${DB_DATABASE:-dify}
command: > command: >
--max_connections=1000 --max_connections=1000
--innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M} --innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M}
@ -193,7 +193,7 @@ services:
"ping", "ping",
"-u", "-u",
"root", "root",
"-p${MYSQL_PASSWORD:-difyai123456}", "-p${DB_PASSWORD:-difyai123456}",
] ]
interval: 1s interval: 1s
timeout: 3s timeout: 3s

View File

@ -9,8 +9,8 @@ services:
env_file: env_file:
- ./middleware.env - ./middleware.env
environment: environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-difyai123456} POSTGRES_PASSWORD: ${DB_PASSWORD:-difyai123456}
POSTGRES_DB: ${POSTGRES_DB:-dify} POSTGRES_DB: ${DB_DATABASE:-dify}
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata} PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
command: > command: >
postgres -c 'max_connections=${POSTGRES_MAX_CONNECTIONS:-100}' postgres -c 'max_connections=${POSTGRES_MAX_CONNECTIONS:-100}'
@ -32,9 +32,9 @@ services:
"-h", "-h",
"db_postgres", "db_postgres",
"-U", "-U",
"${PGUSER:-postgres}", "${DB_USERNAME:-postgres}",
"-d", "-d",
"${POSTGRES_DB:-dify}", "${DB_DATABASE:-dify}",
] ]
interval: 1s interval: 1s
timeout: 3s timeout: 3s
@ -48,8 +48,8 @@ services:
env_file: env_file:
- ./middleware.env - ./middleware.env
environment: environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD:-difyai123456} MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
MYSQL_DATABASE: ${MYSQL_DATABASE:-dify} MYSQL_DATABASE: ${DB_DATABASE:-dify}
command: > command: >
--max_connections=1000 --max_connections=1000
--innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M} --innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M}
@ -67,7 +67,7 @@ services:
"ping", "ping",
"-u", "-u",
"root", "root",
"-p${MYSQL_PASSWORD:-difyai123456}", "-p${DB_PASSWORD:-difyai123456}",
] ]
interval: 1s interval: 1s
timeout: 3s timeout: 3s

View File

@ -455,13 +455,7 @@ x-shared-env: &shared-api-worker-env
TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000} TEXT_GENERATION_TIMEOUT_MS: ${TEXT_GENERATION_TIMEOUT_MS:-60000}
ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false} ALLOW_UNSAFE_DATA_SCHEME: ${ALLOW_UNSAFE_DATA_SCHEME:-false}
MAX_TREE_DEPTH: ${MAX_TREE_DEPTH:-50} MAX_TREE_DEPTH: ${MAX_TREE_DEPTH:-50}
POSTGRES_USER: ${POSTGRES_USER:-${DB_USERNAME}}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-${DB_PASSWORD}}
POSTGRES_DB: ${POSTGRES_DB:-${DB_DATABASE}}
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata} PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
MYSQL_USERNAME: ${MYSQL_USERNAME:-${DB_USERNAME}}
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-${DB_PASSWORD}}
MYSQL_DATABASE: ${MYSQL_DATABASE:-${DB_DATABASE}}
MYSQL_HOST_VOLUME: ${MYSQL_HOST_VOLUME:-./volumes/mysql/data} MYSQL_HOST_VOLUME: ${MYSQL_HOST_VOLUME:-./volumes/mysql/data}
SANDBOX_API_KEY: ${SANDBOX_API_KEY:-dify-sandbox} SANDBOX_API_KEY: ${SANDBOX_API_KEY:-dify-sandbox}
SANDBOX_GIN_MODE: ${SANDBOX_GIN_MODE:-release} SANDBOX_GIN_MODE: ${SANDBOX_GIN_MODE:-release}
@ -774,9 +768,9 @@ services:
- postgresql - postgresql
restart: always restart: always
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_USER: ${DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-difyai123456} POSTGRES_PASSWORD: ${DB_PASSWORD:-difyai123456}
POSTGRES_DB: ${POSTGRES_DB:-dify} POSTGRES_DB: ${DB_DATABASE:-dify}
PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata} PGDATA: ${PGDATA:-/var/lib/postgresql/data/pgdata}
command: > command: >
postgres -c 'max_connections=${POSTGRES_MAX_CONNECTIONS:-100}' postgres -c 'max_connections=${POSTGRES_MAX_CONNECTIONS:-100}'
@ -796,7 +790,7 @@ services:
"-h", "-h",
"db_postgres", "db_postgres",
"-U", "-U",
"${PGUSER:-postgres}", "${DB_USERNAME:-postgres}",
"-d", "-d",
"${DB_DATABASE:-dify}", "${DB_DATABASE:-dify}",
] ]
@ -811,8 +805,8 @@ services:
- mysql - mysql
restart: always restart: always
environment: environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD:-difyai123456} MYSQL_ROOT_PASSWORD: ${DB_PASSWORD:-difyai123456}
MYSQL_DATABASE: ${MYSQL_DATABASE:-dify} MYSQL_DATABASE: ${DB_DATABASE:-dify}
command: > command: >
--max_connections=1000 --max_connections=1000
--innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M} --innodb_buffer_pool_size=${MYSQL_INNODB_BUFFER_POOL_SIZE:-512M}
@ -828,7 +822,7 @@ services:
"ping", "ping",
"-u", "-u",
"root", "root",
"-p${MYSQL_PASSWORD:-difyai123456}", "-p${DB_PASSWORD:-difyai123456}",
] ]
interval: 1s interval: 1s
timeout: 3s timeout: 3s

View File

@ -4,6 +4,7 @@
# Database Configuration # Database Configuration
# Database type, supported values are `postgresql` and `mysql` # Database type, supported values are `postgresql` and `mysql`
DB_TYPE=postgresql DB_TYPE=postgresql
# For MySQL, only `root` user is supported for now
DB_USERNAME=postgres DB_USERNAME=postgres
DB_PASSWORD=difyai123456 DB_PASSWORD=difyai123456
DB_HOST=db_postgres DB_HOST=db_postgres
@ -11,11 +12,6 @@ DB_PORT=5432
DB_DATABASE=dify DB_DATABASE=dify
# PostgreSQL Configuration # PostgreSQL Configuration
POSTGRES_USER=${DB_USERNAME}
# The password for the default postgres user.
POSTGRES_PASSWORD=${DB_PASSWORD}
# The name of the default postgres database.
POSTGRES_DB=${DB_DATABASE}
# postgres data directory # postgres data directory
PGDATA=/var/lib/postgresql/data/pgdata PGDATA=/var/lib/postgresql/data/pgdata
PGDATA_HOST_VOLUME=./volumes/db/data PGDATA_HOST_VOLUME=./volumes/db/data
@ -65,11 +61,6 @@ POSTGRES_STATEMENT_TIMEOUT=0
POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT=0 POSTGRES_IDLE_IN_TRANSACTION_SESSION_TIMEOUT=0
# MySQL Configuration # MySQL Configuration
MYSQL_USERNAME=${DB_USERNAME}
# MySQL password
MYSQL_PASSWORD=${DB_PASSWORD}
# MySQL database name
MYSQL_DATABASE=${DB_DATABASE}
# MySQL data directory host volume # MySQL data directory host volume
MYSQL_HOST_VOLUME=./volumes/mysql/data MYSQL_HOST_VOLUME=./volumes/mysql/data

View File

@ -3,6 +3,7 @@ import type { ReactNode } from 'react'
import SwrInitializer from '@/app/components/swr-initializer' import SwrInitializer from '@/app/components/swr-initializer'
import { AppContextProvider } from '@/context/app-context' import { AppContextProvider } from '@/context/app-context'
import GA, { GaType } from '@/app/components/base/ga' import GA, { GaType } from '@/app/components/base/ga'
import AmplitudeProvider from '@/app/components/base/amplitude'
import HeaderWrapper from '@/app/components/header/header-wrapper' import HeaderWrapper from '@/app/components/header/header-wrapper'
import Header from '@/app/components/header' import Header from '@/app/components/header'
import { EventEmitterContextProvider } from '@/context/event-emitter' import { EventEmitterContextProvider } from '@/context/event-emitter'
@ -18,6 +19,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
return ( return (
<> <>
<GA gaType={GaType.admin} /> <GA gaType={GaType.admin} />
<AmplitudeProvider />
<SwrInitializer> <SwrInitializer>
<AppContextProvider> <AppContextProvider>
<EventEmitterContextProvider> <EventEmitterContextProvider>

View File

@ -8,7 +8,7 @@ const PluginList = async () => {
return ( return (
<PluginPage <PluginPage
plugins={<PluginsPanel />} plugins={<PluginsPanel />}
marketplace={<Marketplace locale={locale} pluginTypeSwitchClassName='top-[60px]' searchBoxAutoAnimate={false} showSearchParams={false} />} marketplace={<Marketplace locale={locale} pluginTypeSwitchClassName='top-[60px]' showSearchParams={false} />}
/> />
) )
} }

View File

@ -1,6 +1,5 @@
'use client' 'use client'
import { useState } from 'react' import { useState } from 'react'
import useSWR from 'swr'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
RiGraduationCapFill, RiGraduationCapFill,
@ -23,8 +22,9 @@ import PremiumBadge from '@/app/components/base/premium-badge'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import EmailChangeModal from './email-change-modal' import EmailChangeModal from './email-change-modal'
import { validPassword } from '@/config' import { validPassword } from '@/config'
import { fetchAppList } from '@/service/apps'
import type { App } from '@/types/app' import type { App } from '@/types/app'
import { useAppList } from '@/service/use-apps'
const titleClassName = ` const titleClassName = `
system-sm-semibold text-text-secondary system-sm-semibold text-text-secondary
@ -36,7 +36,7 @@ const descriptionClassName = `
export default function AccountPage() { export default function AccountPage() {
const { t } = useTranslation() const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore() const { systemFeatures } = useGlobalPublicStore()
const { data: appList } = useSWR({ url: '/apps', params: { page: 1, limit: 100, name: '' } }, fetchAppList) const { data: appList } = useAppList({ page: 1, limit: 100, name: '' })
const apps = appList?.data || [] const apps = appList?.data || []
const { mutateUserProfile, userProfile } = useAppContext() const { mutateUserProfile, userProfile } = useAppContext()
const { isEducationAccount } = useProviderContext() const { isEducationAccount } = useProviderContext()

View File

@ -12,6 +12,7 @@ import { useProviderContext } from '@/context/provider-context'
import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general' import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
import PremiumBadge from '@/app/components/base/premium-badge' import PremiumBadge from '@/app/components/base/premium-badge'
import { useLogout } from '@/service/use-common' import { useLogout } from '@/service/use-common'
import { resetUser } from '@/app/components/base/amplitude/utils'
export type IAppSelector = { export type IAppSelector = {
isMobile: boolean isMobile: boolean
@ -28,6 +29,7 @@ export default function AppSelector() {
await logout() await logout()
localStorage.removeItem('setup_status') localStorage.removeItem('setup_status')
resetUser()
// Tokens are now stored in cookies and cleared by backend // Tokens are now stored in cookies and cleared by backend
router.push('/signin') router.push('/signin')

View File

@ -4,6 +4,7 @@ import Header from './header'
import SwrInitor from '@/app/components/swr-initializer' import SwrInitor from '@/app/components/swr-initializer'
import { AppContextProvider } from '@/context/app-context' import { AppContextProvider } from '@/context/app-context'
import GA, { GaType } from '@/app/components/base/ga' import GA, { GaType } from '@/app/components/base/ga'
import AmplitudeProvider from '@/app/components/base/amplitude'
import HeaderWrapper from '@/app/components/header/header-wrapper' import HeaderWrapper from '@/app/components/header/header-wrapper'
import { EventEmitterContextProvider } from '@/context/event-emitter' import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ProviderContextProvider } from '@/context/provider-context' import { ProviderContextProvider } from '@/context/provider-context'
@ -13,6 +14,7 @@ const Layout = ({ children }: { children: ReactNode }) => {
return ( return (
<> <>
<GA gaType={GaType.admin} /> <GA gaType={GaType.admin} />
<AmplitudeProvider />
<SwrInitor> <SwrInitor>
<AppContextProvider> <AppContextProvider>
<EventEmitterContextProvider> <EventEmitterContextProvider>

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import type { FC, PropsWithChildren } from 'react' import type { FC, PropsWithChildren } from 'react'
import useAccessControlStore from '../../../../context/access-control-store' import useAccessControlStore from '@/context/access-control-store'
import type { AccessMode } from '@/models/access-control' import type { AccessMode } from '@/models/access-control'
type AccessControlItemProps = PropsWithChildren<{ type AccessControlItemProps = PropsWithChildren<{
@ -8,7 +8,8 @@ type AccessControlItemProps = PropsWithChildren<{
}> }>
const AccessControlItem: FC<AccessControlItemProps> = ({ type, children }) => { const AccessControlItem: FC<AccessControlItemProps> = ({ type, children }) => {
const { currentMenu, setCurrentMenu } = useAccessControlStore(s => ({ currentMenu: s.currentMenu, setCurrentMenu: s.setCurrentMenu })) const currentMenu = useAccessControlStore(s => s.currentMenu)
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
if (currentMenu !== type) { if (currentMenu !== type) {
return <div return <div
className="cursor-pointer rounded-[10px] border-[1px] className="cursor-pointer rounded-[10px] border-[1px]

View File

@ -32,6 +32,7 @@ import { canFindTool } from '@/utils'
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools' import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools'
import type { ToolWithProvider } from '@/app/components/workflow/types' import type { ToolWithProvider } from '@/app/components/workflow/types'
import { useMittContextSelector } from '@/context/mitt-context' import { useMittContextSelector } from '@/context/mitt-context'
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
type AgentToolWithMoreInfo = (AgentTool & { icon: any; collection?: Collection; use_end_user_credentials?: boolean; end_user_credential_type?: string }) | null type AgentToolWithMoreInfo = (AgentTool & { icon: any; collection?: Collection; use_end_user_credentials?: boolean; end_user_credential_type?: string }) | null
const AgentTools: FC = () => { const AgentTools: FC = () => {
@ -383,10 +384,138 @@ const AgentTools: FC = () => {
</div> </div>
))} ))}
</div> </div>
<div className='grid grid-cols-1 flex-wrap items-center justify-between gap-1 2xl:grid-cols-2'>
{tools.map((item: AgentTool & { icon: any; collection?: Collection }, index) => (
<div key={index}
className={cn(
'cursor group relative flex w-full items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
isDeleting === index && 'border-state-destructive-border hover:bg-state-destructive-hover',
)}
>
<div className='flex w-0 grow items-center'>
{item.isDeleted && <DefaultToolIcon className='h-5 w-5' />}
{!item.isDeleted && (
<div className={cn((item.notAuthor || !item.enabled) && 'shrink-0 opacity-50')}>
{typeof item.icon === 'string' && <div className='h-5 w-5 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${item.icon})` }} />}
{typeof item.icon !== 'string' && <AppIcon className='rounded-md' size='xs' icon={item.icon?.content} background={item.icon?.background} />}
</div>
)}
<div
className={cn(
'system-xs-regular ml-1.5 flex w-0 grow items-center truncate',
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
)}
>
<span className='system-xs-medium pr-1.5 text-text-secondary'>{getProviderShowName(item)}</span>
<span className='text-text-tertiary'>{item.tool_label}</span>
{!item.isDeleted && (
<Tooltip
popupContent={
<div className='w-[180px]'>
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
</div>
}
>
<div className='h-4 w-4'>
<div className='ml-0.5 hidden group-hover:inline-block'>
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</Tooltip>
)}
</div>
</div>
<div className='ml-1 flex shrink-0 items-center'>
{item.isDeleted && (
<div className='mr-2 flex items-center'>
<Tooltip
popupContent={t('tools.toolRemoved')}
>
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
</div>
</Tooltip>
<div
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
onClick={() => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.splice(index, 1)
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}}
onMouseOver={() => setIsDeleting(index)}
onMouseLeave={() => setIsDeleting(-1)}
>
<RiDeleteBinLine className='h-4 w-4' />
</div>
</div>
)}
{!item.isDeleted && (
<div className='mr-2 hidden items-center gap-1 group-hover:flex'>
{!item.notAuthor && (
<Tooltip
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
needsDelay={false}
>
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
setCurrentTool(item)
setIsShowSettingTool(true)
}}>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
</Tooltip>
)}
<div
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
onClick={() => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.splice(index, 1)
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}}
onMouseOver={() => setIsDeleting(index)}
onMouseLeave={() => setIsDeleting(-1)}
>
<RiDeleteBinLine className='h-4 w-4' />
</div>
</div>
)}
<div className={cn(item.isDeleted && 'opacity-50')}>
{!item.notAuthor && (
<Switch
defaultValue={item.isDeleted ? false : item.enabled}
disabled={item.isDeleted}
size='md'
onChange={(enabled) => {
const newModelConfig = produce(modelConfig, (draft) => {
(draft.agentConfig.tools[index] as any).enabled = enabled
})
setModelConfig(newModelConfig)
formattingChangedDispatcher()
}} />
)}
{item.notAuthor && (
<Button variant='secondary' size='small' onClick={() => {
setCurrentTool(item)
setIsShowSettingTool(true)
}}>
{t('tools.notAuthorized')}
<Indicator className='ml-2' color='orange' />
</Button>
)}
</div>
</div>
</div>
))}
</div>
</div> </div>
))} ))}
</div> </div>
</Panel > </Panel>
{isShowSettingTool && ( {isShowSettingTool && (
<SettingBuiltInTool <SettingBuiltInTool
toolName={currentTool?.tool_name as string} toolName={currentTool?.tool_name as string}

View File

@ -28,6 +28,7 @@ import Input from '@/app/components/base/input'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { DSLImportMode } from '@/models/app' import { DSLImportMode } from '@/models/app'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { trackEvent } from '@/app/components/base/amplitude'
type AppsProps = { type AppsProps = {
onSuccess?: () => void onSuccess?: () => void
@ -141,6 +142,15 @@ const Apps = ({
icon_background, icon_background,
description, description,
}) })
// Track app creation from template
trackEvent('create_app_with_template', {
app_mode: mode,
template_id: currApp?.app.id,
template_name: currApp?.app.name,
description,
})
setIsShowCreateModal(false) setIsShowCreateModal(false)
Toast.notify({ Toast.notify({
type: 'success', type: 'success',

View File

@ -30,6 +30,7 @@ import { getRedirection } from '@/utils/app-redirection'
import FullScreenModal from '@/app/components/base/fullscreen-modal' import FullScreenModal from '@/app/components/base/fullscreen-modal'
import useTheme from '@/hooks/use-theme' import useTheme from '@/hooks/use-theme'
import { useDocLink } from '@/context/i18n' import { useDocLink } from '@/context/i18n'
import { trackEvent } from '@/app/components/base/amplitude'
type CreateAppProps = { type CreateAppProps = {
onSuccess: () => void onSuccess: () => void
@ -82,6 +83,13 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined, icon_background: appIcon.type === 'emoji' ? appIcon.background : undefined,
mode: appMode, mode: appMode,
}) })
// Track app creation success
trackEvent('create_app', {
app_mode: appMode,
description,
})
notify({ type: 'success', message: t('app.newApp.appCreated') }) notify({ type: 'success', message: t('app.newApp.appCreated') })
onSuccess() onSuccess()
onClose() onClose()

View File

@ -28,6 +28,7 @@ import { getRedirection } from '@/utils/app-redirection'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks' import { usePluginDependencies } from '@/app/components/workflow/plugin-dependency/hooks'
import { noop } from 'lodash-es' import { noop } from 'lodash-es'
import { trackEvent } from '@/app/components/base/amplitude'
type CreateFromDSLModalProps = { type CreateFromDSLModalProps = {
show: boolean show: boolean
@ -112,6 +113,13 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
return return
const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response const { id, status, app_id, app_mode, imported_dsl_version, current_dsl_version } = response
if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) { if (status === DSLImportStatus.COMPLETED || status === DSLImportStatus.COMPLETED_WITH_WARNINGS) {
// Track app creation from DSL import
trackEvent('create_app_with_dsl', {
app_mode,
creation_method: currentTab === CreateFromDSLModalTab.FROM_FILE ? 'dsl_file' : 'dsl_url',
has_warnings: status === DSLImportStatus.COMPLETED_WITH_WARNINGS,
})
if (onSuccess) if (onSuccess)
onSuccess() onSuccess()
if (onClose) if (onClose)

View File

@ -3,7 +3,6 @@ import type { FC } from 'react'
import React from 'react' import React from 'react'
import ReactECharts from 'echarts-for-react' import ReactECharts from 'echarts-for-react'
import type { EChartsOption } from 'echarts' import type { EChartsOption } from 'echarts'
import useSWR from 'swr'
import type { Dayjs } from 'dayjs' import type { Dayjs } from 'dayjs'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { get } from 'lodash-es' import { get } from 'lodash-es'
@ -13,7 +12,20 @@ import { formatNumber } from '@/utils/format'
import Basic from '@/app/components/app-sidebar/basic' import Basic from '@/app/components/app-sidebar/basic'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import type { AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyMessagesResponse, AppTokenCostsResponse } from '@/models/app' import type { AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDailyMessagesResponse, AppTokenCostsResponse } from '@/models/app'
import { getAppDailyConversations, getAppDailyEndUsers, getAppDailyMessages, getAppStatistics, getAppTokenCosts, getWorkflowDailyConversations } from '@/service/apps' import {
useAppAverageResponseTime,
useAppAverageSessionInteractions,
useAppDailyConversations,
useAppDailyEndUsers,
useAppDailyMessages,
useAppSatisfactionRate,
useAppTokenCosts,
useAppTokensPerSecond,
useWorkflowAverageInteractions,
useWorkflowDailyConversations,
useWorkflowDailyTerminals,
useWorkflowTokenCosts,
} from '@/service/use-apps'
const valueFormatter = (v: string | number) => v const valueFormatter = (v: string | number) => v
const COLOR_TYPE_MAP = { const COLOR_TYPE_MAP = {
@ -272,8 +284,8 @@ const getDefaultChartData = ({ start, end, key = 'count' }: { start: string; end
export const MessagesChart: FC<IBizChartProps> = ({ id, period }) => { export const MessagesChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response } = useSWR({ url: `/apps/${id}/statistics/daily-messages`, params: period.query }, getAppDailyMessages) const { data: response, isLoading } = useAppDailyMessages(id, period.query)
if (!response) if (isLoading || !response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -286,8 +298,8 @@ export const MessagesChart: FC<IBizChartProps> = ({ id, period }) => {
export const ConversationsChart: FC<IBizChartProps> = ({ id, period }) => { export const ConversationsChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response } = useSWR({ url: `/apps/${id}/statistics/daily-conversations`, params: period.query }, getAppDailyConversations) const { data: response, isLoading } = useAppDailyConversations(id, period.query)
if (!response) if (isLoading || !response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -301,8 +313,8 @@ export const ConversationsChart: FC<IBizChartProps> = ({ id, period }) => {
export const EndUsersChart: FC<IBizChartProps> = ({ id, period }) => { export const EndUsersChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response } = useSWR({ url: `/apps/${id}/statistics/daily-end-users`, id, params: period.query }, getAppDailyEndUsers) const { data: response, isLoading } = useAppDailyEndUsers(id, period.query)
if (!response) if (isLoading || !response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -315,8 +327,8 @@ export const EndUsersChart: FC<IBizChartProps> = ({ id, period }) => {
export const AvgSessionInteractions: FC<IBizChartProps> = ({ id, period }) => { export const AvgSessionInteractions: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response } = useSWR({ url: `/apps/${id}/statistics/average-session-interactions`, params: period.query }, getAppStatistics) const { data: response, isLoading } = useAppAverageSessionInteractions(id, period.query)
if (!response) if (isLoading || !response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -331,8 +343,8 @@ export const AvgSessionInteractions: FC<IBizChartProps> = ({ id, period }) => {
export const AvgResponseTime: FC<IBizChartProps> = ({ id, period }) => { export const AvgResponseTime: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response } = useSWR({ url: `/apps/${id}/statistics/average-response-time`, params: period.query }, getAppStatistics) const { data: response, isLoading } = useAppAverageResponseTime(id, period.query)
if (!response) if (isLoading || !response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -348,8 +360,8 @@ export const AvgResponseTime: FC<IBizChartProps> = ({ id, period }) => {
export const TokenPerSecond: FC<IBizChartProps> = ({ id, period }) => { export const TokenPerSecond: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response } = useSWR({ url: `/apps/${id}/statistics/tokens-per-second`, params: period.query }, getAppStatistics) const { data: response, isLoading } = useAppTokensPerSecond(id, period.query)
if (!response) if (isLoading || !response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -366,8 +378,8 @@ export const TokenPerSecond: FC<IBizChartProps> = ({ id, period }) => {
export const UserSatisfactionRate: FC<IBizChartProps> = ({ id, period }) => { export const UserSatisfactionRate: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response } = useSWR({ url: `/apps/${id}/statistics/user-satisfaction-rate`, params: period.query }, getAppStatistics) const { data: response, isLoading } = useAppSatisfactionRate(id, period.query)
if (!response) if (isLoading || !response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -384,8 +396,8 @@ export const UserSatisfactionRate: FC<IBizChartProps> = ({ id, period }) => {
export const CostChart: FC<IBizChartProps> = ({ id, period }) => { export const CostChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response } = useSWR({ url: `/apps/${id}/statistics/token-costs`, params: period.query }, getAppTokenCosts) const { data: response, isLoading } = useAppTokenCosts(id, period.query)
if (!response) if (isLoading || !response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -398,8 +410,8 @@ export const CostChart: FC<IBizChartProps> = ({ id, period }) => {
export const WorkflowMessagesChart: FC<IBizChartProps> = ({ id, period }) => { export const WorkflowMessagesChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response } = useSWR({ url: `/apps/${id}/workflow/statistics/daily-conversations`, params: period.query }, getWorkflowDailyConversations) const { data: response, isLoading } = useWorkflowDailyConversations(id, period.query)
if (!response) if (isLoading || !response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -414,8 +426,8 @@ export const WorkflowMessagesChart: FC<IBizChartProps> = ({ id, period }) => {
export const WorkflowDailyTerminalsChart: FC<IBizChartProps> = ({ id, period }) => { export const WorkflowDailyTerminalsChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response } = useSWR({ url: `/apps/${id}/workflow/statistics/daily-terminals`, id, params: period.query }, getAppDailyEndUsers) const { data: response, isLoading } = useWorkflowDailyTerminals(id, period.query)
if (!response) if (isLoading || !response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -429,8 +441,8 @@ export const WorkflowDailyTerminalsChart: FC<IBizChartProps> = ({ id, period })
export const WorkflowCostChart: FC<IBizChartProps> = ({ id, period }) => { export const WorkflowCostChart: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response } = useSWR({ url: `/apps/${id}/workflow/statistics/token-costs`, params: period.query }, getAppTokenCosts) const { data: response, isLoading } = useWorkflowTokenCosts(id, period.query)
if (!response) if (isLoading || !response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart
@ -443,8 +455,8 @@ export const WorkflowCostChart: FC<IBizChartProps> = ({ id, period }) => {
export const AvgUserInteractions: FC<IBizChartProps> = ({ id, period }) => { export const AvgUserInteractions: FC<IBizChartProps> = ({ id, period }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { data: response } = useSWR({ url: `/apps/${id}/workflow/statistics/average-app-interactions`, params: period.query }, getAppStatistics) const { data: response, isLoading } = useWorkflowAverageInteractions(id, period.query)
if (!response) if (isLoading || !response)
return <Loading /> return <Loading />
const noDataFlag = !response.data || response.data.length === 0 const noDataFlag = !response.data || response.data.length === 0
return <Chart return <Chart

View File

@ -8,6 +8,7 @@ import quarterOfYear from 'dayjs/plugin/quarterOfYear'
import type { QueryParam } from './index' import type { QueryParam } from './index'
import Chip from '@/app/components/base/chip' import Chip from '@/app/components/base/chip'
import Input from '@/app/components/base/input' import Input from '@/app/components/base/input'
import { trackEvent } from '@/app/components/base/amplitude/utils'
dayjs.extend(quarterOfYear) dayjs.extend(quarterOfYear)
const today = dayjs() const today = dayjs()
@ -37,6 +38,9 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps)
value={queryParams.status || 'all'} value={queryParams.status || 'all'}
onSelect={(item) => { onSelect={(item) => {
setQueryParams({ ...queryParams, status: item.value as string }) setQueryParams({ ...queryParams, status: item.value as string })
trackEvent('workflow_log_filter_status_selected', {
workflow_log_filter_status: item.value as string,
})
}} }}
onClear={() => setQueryParams({ ...queryParams, status: 'all' })} onClear={() => setQueryParams({ ...queryParams, status: 'all' })}
items={[{ value: 'all', name: 'All' }, items={[{ value: 'all', name: 'All' },

View File

@ -23,7 +23,7 @@ const Empty = () => {
return ( return (
<> <>
<DefaultCards /> <DefaultCards />
<div className='absolute inset-0 z-20 flex items-center justify-center bg-gradient-to-t from-background-body to-transparent pointer-events-none'> <div className='pointer-events-none absolute inset-0 z-20 flex items-center justify-center bg-gradient-to-t from-background-body to-transparent'>
<span className='system-md-medium text-text-tertiary'> <span className='system-md-medium text-text-tertiary'>
{t('app.newApp.noAppsFound')} {t('app.newApp.noAppsFound')}
</span> </span>

View File

@ -4,7 +4,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'
import { import {
useRouter, useRouter,
} from 'next/navigation' } from 'next/navigation'
import useSWRInfinite from 'swr/infinite'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useDebounceFn } from 'ahooks' import { useDebounceFn } from 'ahooks'
import { import {
@ -19,8 +18,6 @@ import AppCard from './app-card'
import NewAppCard from './new-app-card' import NewAppCard from './new-app-card'
import useAppsQueryState from './hooks/use-apps-query-state' import useAppsQueryState from './hooks/use-apps-query-state'
import { useDSLDragDrop } from './hooks/use-dsl-drag-drop' import { useDSLDragDrop } from './hooks/use-dsl-drag-drop'
import type { AppListResponse } from '@/models/app'
import { fetchAppList } from '@/service/apps'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config' import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { CheckModal } from '@/hooks/use-pay' import { CheckModal } from '@/hooks/use-pay'
@ -35,6 +32,7 @@ import Empty from './empty'
import Footer from './footer' import Footer from './footer'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { useInfiniteAppList } from '@/service/use-apps'
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), { const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
ssr: false, ssr: false,
@ -43,30 +41,6 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
ssr: false, ssr: false,
}) })
const getKey = (
pageIndex: number,
previousPageData: AppListResponse,
activeTab: string,
isCreatedByMe: boolean,
tags: string[],
keywords: string,
) => {
if (!pageIndex || previousPageData.has_more) {
const params: any = { url: 'apps', params: { page: pageIndex + 1, limit: 30, name: keywords, is_created_by_me: isCreatedByMe } }
if (activeTab !== 'all')
params.params.mode = activeTab
else
delete params.params.mode
if (tags.length)
params.params.tag_ids = tags
return params
}
return null
}
const List = () => { const List = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore() const { systemFeatures } = useGlobalPublicStore()
@ -102,16 +76,24 @@ const List = () => {
enabled: isCurrentWorkspaceEditor, enabled: isCurrentWorkspaceEditor,
}) })
const { data, isLoading, error, setSize, mutate } = useSWRInfinite( const appListQueryParams = {
(pageIndex: number, previousPageData: AppListResponse) => getKey(pageIndex, previousPageData, activeTab, isCreatedByMe, tagIDs, searchKeywords), page: 1,
fetchAppList, limit: 30,
{ name: searchKeywords,
revalidateFirstPage: true, tag_ids: tagIDs,
shouldRetryOnError: false, is_created_by_me: isCreatedByMe,
dedupingInterval: 500, ...(activeTab !== 'all' ? { mode: activeTab as AppModeEnum } : {}),
errorRetryCount: 3, }
},
) const {
data,
isLoading,
isFetchingNextPage,
fetchNextPage,
hasNextPage,
error,
refetch,
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
const anchorRef = useRef<HTMLDivElement>(null) const anchorRef = useRef<HTMLDivElement>(null)
const options = [ const options = [
@ -126,9 +108,9 @@ const List = () => {
useEffect(() => { useEffect(() => {
if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') { if (localStorage.getItem(NEED_REFRESH_APP_LIST_KEY) === '1') {
localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY) localStorage.removeItem(NEED_REFRESH_APP_LIST_KEY)
mutate() refetch()
} }
}, [mutate, t]) }, [refetch])
useEffect(() => { useEffect(() => {
if (isCurrentWorkspaceDatasetOperator) if (isCurrentWorkspaceDatasetOperator)
@ -136,7 +118,9 @@ const List = () => {
}, [router, isCurrentWorkspaceDatasetOperator]) }, [router, isCurrentWorkspaceDatasetOperator])
useEffect(() => { useEffect(() => {
const hasMore = data?.at(-1)?.has_more ?? true if (isCurrentWorkspaceDatasetOperator)
return
const hasMore = hasNextPage ?? true
let observer: IntersectionObserver | undefined let observer: IntersectionObserver | undefined
if (error) { if (error) {
@ -151,8 +135,8 @@ const List = () => {
const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value const dynamicMargin = Math.max(100, Math.min(containerHeight * 0.2, 200)) // Clamps to 100-200px range, using 20% of container height as the base value
observer = new IntersectionObserver((entries) => { observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isLoading && !error && hasMore) if (entries[0].isIntersecting && !isLoading && !isFetchingNextPage && !error && hasMore)
setSize((size: number) => size + 1) fetchNextPage()
}, { }, {
root: containerRef.current, root: containerRef.current,
rootMargin: `${dynamicMargin}px`, rootMargin: `${dynamicMargin}px`,
@ -161,7 +145,7 @@ const List = () => {
observer.observe(anchorRef.current) observer.observe(anchorRef.current)
} }
return () => observer?.disconnect() return () => observer?.disconnect()
}, [isLoading, setSize, data, error]) }, [isLoading, isFetchingNextPage, fetchNextPage, error, hasNextPage, isCurrentWorkspaceDatasetOperator])
const { run: handleSearch } = useDebounceFn(() => { const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords) setSearchKeywords(keywords)
@ -185,6 +169,9 @@ const List = () => {
setQuery(prev => ({ ...prev, isCreatedByMe: newValue })) setQuery(prev => ({ ...prev, isCreatedByMe: newValue }))
}, [isCreatedByMe, setQuery]) }, [isCreatedByMe, setQuery])
const pages = data?.pages ?? []
const hasAnyApp = (pages[0]?.total ?? 0) > 0
return ( return (
<> <>
<div ref={containerRef} className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'> <div ref={containerRef} className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'>
@ -217,17 +204,17 @@ const List = () => {
/> />
</div> </div>
</div> </div>
{(data && data[0].total > 0) {hasAnyApp
? <div className='relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'> ? <div className='relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'>
{isCurrentWorkspaceEditor {isCurrentWorkspaceEditor
&& <NewAppCard ref={newAppCardRef} onSuccess={mutate} selectedAppType={activeTab} />} && <NewAppCard ref={newAppCardRef} onSuccess={refetch} selectedAppType={activeTab} />}
{data.map(({ data: apps }) => apps.map(app => ( {pages.map(({ data: apps }) => apps.map(app => (
<AppCard key={app.id} app={app} onRefresh={mutate} /> <AppCard key={app.id} app={app} onRefresh={refetch} />
)))} )))}
</div> </div>
: <div className='relative grid grow grid-cols-1 content-start gap-4 overflow-hidden px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'> : <div className='relative grid grow grid-cols-1 content-start gap-4 overflow-hidden px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'>
{isCurrentWorkspaceEditor {isCurrentWorkspaceEditor
&& <NewAppCard ref={newAppCardRef} className='z-10' onSuccess={mutate} selectedAppType={activeTab} />} && <NewAppCard ref={newAppCardRef} className='z-10' onSuccess={refetch} selectedAppType={activeTab} />}
<Empty /> <Empty />
</div>} </div>}
@ -261,7 +248,7 @@ const List = () => {
onSuccess={() => { onSuccess={() => {
setShowCreateFromDSLModal(false) setShowCreateFromDSLModal(false)
setDroppedDSLFile(undefined) setDroppedDSLFile(undefined)
mutate() refetch()
}} }}
droppedFile={droppedDSLFile} droppedFile={droppedDSLFile}
/> />

View File

@ -0,0 +1,46 @@
'use client'
import type { FC } from 'react'
import React, { useEffect } from 'react'
import * as amplitude from '@amplitude/analytics-browser'
import { sessionReplayPlugin } from '@amplitude/plugin-session-replay-browser'
import { IS_CLOUD_EDITION } from '@/config'
export type IAmplitudeProps = {
apiKey?: string
sessionReplaySampleRate?: number
}
const AmplitudeProvider: FC<IAmplitudeProps> = ({
apiKey = process.env.NEXT_PUBLIC_AMPLITUDE_API_KEY ?? '',
sessionReplaySampleRate = 1,
}) => {
useEffect(() => {
// Only enable in Saas edition
if (!IS_CLOUD_EDITION)
return
// Initialize Amplitude
amplitude.init(apiKey, {
defaultTracking: {
sessions: true,
pageViews: true,
formInteractions: true,
fileDownloads: true,
},
// Enable debug logs in development environment
logLevel: amplitude.Types.LogLevel.Warn,
})
// Add Session Replay plugin
const sessionReplay = sessionReplayPlugin({
sampleRate: sessionReplaySampleRate,
})
amplitude.add(sessionReplay)
}, [])
// This is a client component that renders nothing
return null
}
export default React.memo(AmplitudeProvider)

View File

@ -0,0 +1,2 @@
export { default } from './AmplitudeProvider'
export { resetUser, setUserId, setUserProperties, trackEvent } from './utils'

View File

@ -0,0 +1,37 @@
import * as amplitude from '@amplitude/analytics-browser'
/**
* Track custom event
* @param eventName Event name
* @param eventProperties Event properties (optional)
*/
export const trackEvent = (eventName: string, eventProperties?: Record<string, any>) => {
amplitude.track(eventName, eventProperties)
}
/**
* Set user ID
* @param userId User ID
*/
export const setUserId = (userId: string) => {
amplitude.setUserId(userId)
}
/**
* Set user properties
* @param properties User properties
*/
export const setUserProperties = (properties: Record<string, any>) => {
const identifyEvent = new amplitude.Identify()
Object.entries(properties).forEach(([key, value]) => {
identifyEvent.set(key, value)
})
amplitude.identify(identifyEvent)
}
/**
* Reset user (e.g., when user logs out)
*/
export const resetUser = () => {
amplitude.reset()
}

View File

@ -24,6 +24,10 @@ import cn from '@/utils/classnames'
import type { FileEntity } from '../../file-uploader/types' import type { FileEntity } from '../../file-uploader/types'
import { formatBooleanInputs } from '@/utils/model-config' import { formatBooleanInputs } from '@/utils/model-config'
import Avatar from '../../avatar' import Avatar from '../../avatar'
import ServiceConnectionPanel from '@/app/components/base/service-connection-panel'
import type { AuthType, ServiceConnectionItem as ServiceConnectionItemType } from '@/app/components/base/service-connection-panel'
import { Notion } from '@/app/components/base/icons/src/public/common'
import { Google } from '@/app/components/base/icons/src/public/plugins'
const ChatWrapper = () => { const ChatWrapper = () => {
const { const {
@ -167,6 +171,53 @@ const ChatWrapper = () => {
const [collapsed, setCollapsed] = useState(!!currentConversationId) const [collapsed, setCollapsed] = useState(!!currentConversationId)
// Demo: Service connection state
const [serviceConnections, setServiceConnections] = useState<ServiceConnectionItemType[]>([
{
id: 'notion',
name: 'Notion Page Search',
icon: <Notion className="h-6 w-6" />,
authType: 'oauth',
status: 'pending',
},
{
id: 'gmail',
name: 'Gmail Tools',
icon: <img src="https://www.gstatic.com/images/branding/product/1x/gmail_2020q4_32dp.png" alt="Gmail" className="h-6 w-6" />,
authType: 'oauth',
status: 'pending',
},
{
id: 'youtube',
name: 'YouTube Data Upload',
icon: <img src="https://www.youtube.com/s/desktop/f506bd45/img/favicon_32x32.png" alt="YouTube" className="h-6 w-6" />,
authType: 'oauth',
status: 'pending',
},
{
id: 'google-serp',
name: 'Google SerpApi Search',
icon: <Google className="h-6 w-6" />,
authType: 'api_key',
status: 'pending',
},
])
const [showServiceConnection, setShowServiceConnection] = useState(true)
const handleServiceConnect = useCallback((serviceId: string, _authType: AuthType) => {
// Demo: 模拟连接成功
setServiceConnections(prev => prev.map(service =>
service.id === serviceId
? { ...service, status: 'connected' as const }
: service,
))
}, [])
const handleServiceContinue = useCallback(() => {
setShowServiceConnection(false)
}, [])
const chatNode = useMemo(() => { const chatNode = useMemo(() => {
if (allInputsHidden || !inputsForms.length) if (allInputsHidden || !inputsForms.length)
return null return null
@ -253,6 +304,23 @@ const ChatWrapper = () => {
/> />
: null : null
// 如果需要显示服务连接面板,则显示面板而非聊天界面
if (showServiceConnection) {
return (
<div className={cn(
'flex h-full items-center justify-center overflow-auto bg-chatbot-bg',
isMobile && 'px-4 py-8',
)}>
<ServiceConnectionPanel
services={serviceConnections}
onConnect={handleServiceConnect}
onContinue={handleServiceContinue}
className={cn(isMobile && 'max-w-full')}
/>
</div>
)
}
return ( return (
<div <div
className='h-full overflow-hidden bg-chatbot-bg' className='h-full overflow-hidden bg-chatbot-bg'

View File

@ -11,7 +11,10 @@ import {
RiThumbDownLine, RiThumbDownLine,
RiThumbUpLine, RiThumbUpLine,
} from '@remixicon/react' } from '@remixicon/react'
import type { ChatItem } from '../../types' import type {
ChatItem,
Feedback,
} from '../../types'
import { useChatContext } from '../context' import { useChatContext } from '../context'
import copy from 'copy-to-clipboard' import copy from 'copy-to-clipboard'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
@ -22,6 +25,7 @@ import ActionButton, { ActionButtonState } from '@/app/components/base/action-bu
import NewAudioButton from '@/app/components/base/new-audio-button' import NewAudioButton from '@/app/components/base/new-audio-button'
import Modal from '@/app/components/base/modal/modal' import Modal from '@/app/components/base/modal/modal'
import Textarea from '@/app/components/base/textarea' import Textarea from '@/app/components/base/textarea'
import Tooltip from '@/app/components/base/tooltip'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
type OperationProps = { type OperationProps = {
@ -66,8 +70,9 @@ const Operation: FC<OperationProps> = ({
adminFeedback, adminFeedback,
agent_thoughts, agent_thoughts,
} = item } = item
const [localFeedback, setLocalFeedback] = useState(config?.supportAnnotation ? adminFeedback : feedback) const [userLocalFeedback, setUserLocalFeedback] = useState(feedback)
const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback) const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback)
const [feedbackTarget, setFeedbackTarget] = useState<'user' | 'admin'>('user')
// Separate feedback types for display // Separate feedback types for display
const userFeedback = feedback const userFeedback = feedback
@ -79,24 +84,68 @@ const Operation: FC<OperationProps> = ({
return messageContent return messageContent
}, [agent_thoughts, messageContent]) }, [agent_thoughts, messageContent])
const handleFeedback = async (rating: 'like' | 'dislike' | null, content?: string) => { const displayUserFeedback = userLocalFeedback ?? userFeedback
const hasUserFeedback = !!displayUserFeedback?.rating
const hasAdminFeedback = !!adminLocalFeedback?.rating
const shouldShowUserFeedbackBar = !isOpeningStatement && config?.supportFeedback && !!onFeedback && !config?.supportAnnotation
const shouldShowAdminFeedbackBar = !isOpeningStatement && config?.supportFeedback && !!onFeedback && !!config?.supportAnnotation
const userFeedbackLabel = t('appLog.table.header.userRate') || 'User feedback'
const adminFeedbackLabel = t('appLog.table.header.adminRate') || 'Admin feedback'
const feedbackTooltipClassName = 'max-w-[260px]'
const buildFeedbackTooltip = (feedbackData?: Feedback | null, label = userFeedbackLabel) => {
if (!feedbackData?.rating)
return label
const ratingLabel = feedbackData.rating === 'like'
? (t('appLog.detail.operation.like') || 'like')
: (t('appLog.detail.operation.dislike') || 'dislike')
const feedbackText = feedbackData.content?.trim()
if (feedbackText)
return `${label}: ${ratingLabel} - ${feedbackText}`
return `${label}: ${ratingLabel}`
}
const handleFeedback = async (rating: 'like' | 'dislike' | null, content?: string, target: 'user' | 'admin' = 'user') => {
if (!config?.supportFeedback || !onFeedback) if (!config?.supportFeedback || !onFeedback)
return return
await onFeedback?.(id, { rating, content }) await onFeedback?.(id, { rating, content })
setLocalFeedback({ rating })
// Update admin feedback state separately if annotation is supported const nextFeedback = rating === null ? { rating: null } : { rating, content }
if (config?.supportAnnotation)
setAdminLocalFeedback(rating ? { rating } : undefined) if (target === 'admin')
setAdminLocalFeedback(nextFeedback)
else
setUserLocalFeedback(nextFeedback)
} }
const handleThumbsDown = () => { const handleLikeClick = (target: 'user' | 'admin') => {
const currentRating = target === 'admin' ? adminLocalFeedback?.rating : displayUserFeedback?.rating
if (currentRating === 'like') {
handleFeedback(null, undefined, target)
return
}
handleFeedback('like', undefined, target)
}
const handleDislikeClick = (target: 'user' | 'admin') => {
const currentRating = target === 'admin' ? adminLocalFeedback?.rating : displayUserFeedback?.rating
if (currentRating === 'dislike') {
handleFeedback(null, undefined, target)
return
}
setFeedbackTarget(target)
setIsShowFeedbackModal(true) setIsShowFeedbackModal(true)
} }
const handleFeedbackSubmit = async () => { const handleFeedbackSubmit = async () => {
await handleFeedback('dislike', feedbackContent) await handleFeedback('dislike', feedbackContent, feedbackTarget)
setFeedbackContent('') setFeedbackContent('')
setIsShowFeedbackModal(false) setIsShowFeedbackModal(false)
} }
@ -116,12 +165,13 @@ const Operation: FC<OperationProps> = ({
width += 26 width += 26
if (!isOpeningStatement && config?.supportAnnotation && config?.annotation_reply?.enabled) if (!isOpeningStatement && config?.supportAnnotation && config?.annotation_reply?.enabled)
width += 26 width += 26
if (config?.supportFeedback && !localFeedback?.rating && onFeedback && !isOpeningStatement) if (shouldShowUserFeedbackBar)
width += 60 + 8 width += hasUserFeedback ? 28 + 8 : 60 + 8
if (config?.supportFeedback && localFeedback?.rating && onFeedback && !isOpeningStatement) if (shouldShowAdminFeedbackBar)
width += 28 + 8 width += (hasAdminFeedback ? 28 : 60) + 8 + (hasUserFeedback ? 28 : 0)
return width return width
}, [isOpeningStatement, showPromptLog, config?.text_to_speech?.enabled, config?.supportAnnotation, config?.annotation_reply?.enabled, config?.supportFeedback, localFeedback?.rating, onFeedback]) }, [config?.annotation_reply?.enabled, config?.supportAnnotation, config?.text_to_speech?.enabled, hasAdminFeedback, hasUserFeedback, isOpeningStatement, shouldShowAdminFeedbackBar, shouldShowUserFeedbackBar, showPromptLog])
const positionRight = useMemo(() => operationWidth < maxSize, [operationWidth, maxSize]) const positionRight = useMemo(() => operationWidth < maxSize, [operationWidth, maxSize])
@ -136,6 +186,110 @@ const Operation: FC<OperationProps> = ({
)} )}
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}} style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
> >
{shouldShowUserFeedbackBar && (
<div className={cn(
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
hasUserFeedback ? 'flex' : 'hidden group-hover:flex',
)}>
{hasUserFeedback ? (
<Tooltip
popupContent={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
>
<ActionButton
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Destructive}
onClick={() => handleFeedback(null, undefined, 'user')}
>
{displayUserFeedback?.rating === 'like'
? <RiThumbUpLine className='h-4 w-4' />
: <RiThumbDownLine className='h-4 w-4' />}
</ActionButton>
</Tooltip>
) : (
<>
<ActionButton
state={displayUserFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
onClick={() => handleLikeClick('user')}
>
<RiThumbUpLine className='h-4 w-4' />
</ActionButton>
<ActionButton
state={displayUserFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
onClick={() => handleDislikeClick('user')}
>
<RiThumbDownLine className='h-4 w-4' />
</ActionButton>
</>
)}
</div>
)}
{shouldShowAdminFeedbackBar && (
<div className={cn(
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
(hasAdminFeedback || hasUserFeedback) ? 'flex' : 'hidden group-hover:flex',
)}>
{/* User Feedback Display */}
{displayUserFeedback?.rating && (
<Tooltip
popupContent={buildFeedbackTooltip(displayUserFeedback, userFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
>
{displayUserFeedback.rating === 'like' ? (
<ActionButton state={ActionButtonState.Active}>
<RiThumbUpLine className='h-4 w-4' />
</ActionButton>
) : (
<ActionButton state={ActionButtonState.Destructive}>
<RiThumbDownLine className='h-4 w-4' />
</ActionButton>
)}
</Tooltip>
)}
{/* Admin Feedback Controls */}
{displayUserFeedback?.rating && <div className='mx-1 h-3 w-[0.5px] bg-components-actionbar-border' />}
{hasAdminFeedback ? (
<Tooltip
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
>
<ActionButton
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Destructive}
onClick={() => handleFeedback(null, undefined, 'admin')}
>
{adminLocalFeedback?.rating === 'like'
? <RiThumbUpLine className='h-4 w-4' />
: <RiThumbDownLine className='h-4 w-4' />}
</ActionButton>
</Tooltip>
) : (
<>
<Tooltip
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
>
<ActionButton
state={adminLocalFeedback?.rating === 'like' ? ActionButtonState.Active : ActionButtonState.Default}
onClick={() => handleLikeClick('admin')}
>
<RiThumbUpLine className='h-4 w-4' />
</ActionButton>
</Tooltip>
<Tooltip
popupContent={buildFeedbackTooltip(adminLocalFeedback, adminFeedbackLabel)}
popupClassName={feedbackTooltipClassName}
>
<ActionButton
state={adminLocalFeedback?.rating === 'dislike' ? ActionButtonState.Destructive : ActionButtonState.Default}
onClick={() => handleDislikeClick('admin')}
>
<RiThumbDownLine className='h-4 w-4' />
</ActionButton>
</Tooltip>
</>
)}
</div>
)}
{showPromptLog && !isOpeningStatement && ( {showPromptLog && !isOpeningStatement && (
<div className='hidden group-hover:block'> <div className='hidden group-hover:block'>
<Log logItem={item} /> <Log logItem={item} />
@ -174,69 +328,6 @@ const Operation: FC<OperationProps> = ({
)} )}
</div> </div>
)} )}
{!isOpeningStatement && config?.supportFeedback && !localFeedback?.rating && onFeedback && (
<div className='ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex'>
{!localFeedback?.rating && (
<>
<ActionButton onClick={() => handleFeedback('like')}>
<RiThumbUpLine className='h-4 w-4' />
</ActionButton>
<ActionButton onClick={handleThumbsDown}>
<RiThumbDownLine className='h-4 w-4' />
</ActionButton>
</>
)}
</div>
)}
{!isOpeningStatement && config?.supportFeedback && onFeedback && (
<div className='ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm'>
{/* User Feedback Display */}
{userFeedback?.rating && (
<div className='flex items-center'>
<span className='mr-1 text-xs text-text-tertiary'>User</span>
{userFeedback.rating === 'like' ? (
<ActionButton state={ActionButtonState.Active} title={userFeedback.content ? `User liked this response: ${userFeedback.content}` : 'User liked this response'}>
<RiThumbUpLine className='h-3 w-3' />
</ActionButton>
) : (
<ActionButton state={ActionButtonState.Destructive} title={userFeedback.content ? `User disliked this response: ${userFeedback.content}` : 'User disliked this response'}>
<RiThumbDownLine className='h-3 w-3' />
</ActionButton>
)}
</div>
)}
{/* Admin Feedback Controls */}
{config?.supportAnnotation && (
<div className='flex items-center'>
{userFeedback?.rating && <div className='mx-1 h-3 w-[0.5px] bg-components-actionbar-border' />}
{!adminLocalFeedback?.rating ? (
<>
<ActionButton onClick={() => handleFeedback('like')}>
<RiThumbUpLine className='h-4 w-4' />
</ActionButton>
<ActionButton onClick={handleThumbsDown}>
<RiThumbDownLine className='h-4 w-4' />
</ActionButton>
</>
) : (
<>
{adminLocalFeedback.rating === 'like' ? (
<ActionButton state={ActionButtonState.Active} onClick={() => handleFeedback(null)}>
<RiThumbUpLine className='h-4 w-4' />
</ActionButton>
) : (
<ActionButton state={ActionButtonState.Destructive} onClick={() => handleFeedback(null)}>
<RiThumbDownLine className='h-4 w-4' />
</ActionButton>
)}
</>
)}
</div>
)}
</div>
)}
</div> </div>
<EditReplyModal <EditReplyModal
isShow={isShowReplyModal} isShow={isShowReplyModal}

View File

@ -1,5 +1,4 @@
'use client' 'use client'
import useSWR from 'swr'
import { produce } from 'immer' import { produce } from 'immer'
import React, { Fragment } from 'react' import React, { Fragment } from 'react'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
@ -9,7 +8,6 @@ import { Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } fro
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid' import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks' import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import type { Item } from '@/app/components/base/select' import type { Item } from '@/app/components/base/select'
import { fetchAppVoices } from '@/service/apps'
import Tooltip from '@/app/components/base/tooltip' import Tooltip from '@/app/components/base/tooltip'
import Switch from '@/app/components/base/switch' import Switch from '@/app/components/base/switch'
import AudioBtn from '@/app/components/base/audio-btn' import AudioBtn from '@/app/components/base/audio-btn'
@ -17,6 +15,7 @@ import { languages } from '@/i18n-config/language'
import { TtsAutoPlay } from '@/types/app' import { TtsAutoPlay } from '@/types/app'
import type { OnFeaturesChange } from '@/app/components/base/features/types' import type { OnFeaturesChange } from '@/app/components/base/features/types'
import classNames from '@/utils/classnames' import classNames from '@/utils/classnames'
import { useAppVoices } from '@/service/use-apps'
type VoiceParamConfigProps = { type VoiceParamConfigProps = {
onClose: () => void onClose: () => void
@ -39,7 +38,7 @@ const VoiceParamConfig = ({
const localLanguagePlaceholder = languageItem?.name || t('common.placeholder.select') const localLanguagePlaceholder = languageItem?.name || t('common.placeholder.select')
const language = languageItem?.value const language = languageItem?.value
const voiceItems = useSWR({ appId, language }, fetchAppVoices).data const { data: voiceItems } = useAppVoices(appId, language)
let voiceItem = voiceItems?.find(item => item.value === text2speech?.voice) let voiceItem = voiceItems?.find(item => item.value === text2speech?.voice)
if (voiceItems && !voiceItem) if (voiceItems && !voiceItem)
voiceItem = voiceItems[0] voiceItem = voiceItems[0]

View File

@ -0,0 +1,79 @@
'use client'
import type { FC } from 'react'
import { memo, useMemo } from 'react'
import { RiArrowRightLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import ServiceItem from './service-item'
import type { ServiceConnectionPanelProps } from './types'
import cn from '@/utils/classnames'
const ServiceConnectionPanel: FC<ServiceConnectionPanelProps> = ({
title,
description,
services,
onConnect,
onContinue,
continueDisabled,
continueText,
className,
}) => {
const { t } = useTranslation()
const allConnected = useMemo(() => {
return services.every(service => service.status === 'connected')
}, [services])
const displayTitle = title || t('share.serviceConnection.title')
const displayDescription = description || t('share.serviceConnection.description', { count: services.length })
return (
<div className={cn(
'flex w-full max-w-[600px] flex-col items-center',
className,
)}>
<div className="mb-6 text-center">
<h2 className="system-xl-semibold mb-1 text-text-primary">
{displayTitle}
</h2>
<p className="system-sm-regular text-text-tertiary">
{displayDescription}
</p>
</div>
<div className="w-full space-y-2">
{services.map(service => (
<ServiceItem
key={service.id}
service={service}
onConnect={onConnect}
/>
))}
</div>
{onContinue && (
<div className="mt-6 flex w-full justify-end">
<Button
variant="primary"
disabled={continueDisabled ?? !allConnected}
onClick={onContinue}
>
{continueText || t('share.serviceConnection.continue')}
<RiArrowRightLine className="ml-1 h-4 w-4" />
</Button>
</div>
)}
</div>
)
}
export default memo(ServiceConnectionPanel)
export { default as ServiceItem } from './service-item'
export type {
ServiceConnectionPanelProps,
ServiceConnectionItem,
AuthType,
ServiceConnectionStatus,
} from './types'

View File

@ -0,0 +1,72 @@
'use client'
import type { FC } from 'react'
import { memo } from 'react'
import { RiAddLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import type { AuthType, ServiceConnectionItem } from './types'
import cn from '@/utils/classnames'
type ServiceItemProps = {
service: ServiceConnectionItem
onConnect: (serviceId: string, authType: AuthType) => void
}
const ServiceItem: FC<ServiceItemProps> = ({
service,
onConnect,
}) => {
const { t } = useTranslation()
const handleConnect = () => {
onConnect(service.id, service.authType)
}
const getButtonText = () => {
if (service.status === 'connected')
return t('share.serviceConnection.connected')
if (service.authType === 'api_key')
return t('share.serviceConnection.addApiKey')
return t('share.serviceConnection.connect')
}
const isConnected = service.status === 'connected'
return (
<div className={cn(
'flex items-center justify-between gap-3 rounded-xl border border-components-panel-border-subtle bg-components-panel-bg px-4 py-3',
'hover:border-components-panel-border hover:shadow-xs',
'transition-all duration-200',
)}>
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center">
{service.icon}
</div>
<div className="flex flex-col">
<span className="system-sm-medium text-text-secondary">
{service.name}
</span>
{service.description && (
<span className="system-xs-regular text-text-tertiary">
{service.description}
</span>
)}
</div>
</div>
<Button
variant={isConnected ? 'secondary' : 'secondary-accent'}
size="small"
onClick={handleConnect}
disabled={isConnected}
>
{!isConnected && <RiAddLine className="mr-0.5 h-3.5 w-3.5" />}
{getButtonText()}
</Button>
</div>
)
}
export default memo(ServiceItem)

View File

@ -0,0 +1,25 @@
import type { ReactNode } from 'react'
export type AuthType = 'oauth' | 'api_key'
export type ServiceConnectionStatus = 'pending' | 'connected' | 'error'
export type ServiceConnectionItem = {
id: string
name: string
icon: ReactNode
authType: AuthType
status: ServiceConnectionStatus
description?: string
}
export type ServiceConnectionPanelProps = {
title?: string
description?: string
services: ServiceConnectionItem[]
onConnect: (serviceId: string, authType: AuthType) => void
onContinue?: () => void
continueDisabled?: boolean
continueText?: string
className?: string
}

View File

@ -1,8 +1,8 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import React from 'react' import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { import {
RiBook2Line, RiBook2Line,
RiFileEditLine, RiFileEditLine,
@ -25,6 +25,8 @@ import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/con
import { useEducationVerify } from '@/service/use-education' import { useEducationVerify } from '@/service/use-education'
import { useModalContextSelector } from '@/context/modal-context' import { useModalContextSelector } from '@/context/modal-context'
import { Enterprise, Professional, Sandbox, Team } from './assets' import { Enterprise, Professional, Sandbox, Team } from './assets'
import { Loading } from '../../base/icons/src/public/thought'
import { useUnmountedRef } from 'ahooks'
type Props = { type Props = {
loc: string loc: string
@ -35,6 +37,7 @@ const PlanComp: FC<Props> = ({
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const router = useRouter() const router = useRouter()
const path = usePathname()
const { userProfile } = useAppContext() const { userProfile } = useAppContext()
const { plan, enableEducationPlan, allowRefreshEducationVerify, isEducationAccount } = useProviderContext() const { plan, enableEducationPlan, allowRefreshEducationVerify, isEducationAccount } = useProviderContext()
const isAboutToExpire = allowRefreshEducationVerify const isAboutToExpire = allowRefreshEducationVerify
@ -61,17 +64,24 @@ const PlanComp: FC<Props> = ({
})() })()
const [showModal, setShowModal] = React.useState(false) const [showModal, setShowModal] = React.useState(false)
const { mutateAsync } = useEducationVerify() const { mutateAsync, isPending } = useEducationVerify()
const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal) const setShowAccountSettingModal = useModalContextSelector(s => s.setShowAccountSettingModal)
const unmountedRef = useUnmountedRef()
const handleVerify = () => { const handleVerify = () => {
if (isPending) return
mutateAsync().then((res) => { mutateAsync().then((res) => {
localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM) localStorage.removeItem(EDUCATION_VERIFYING_LOCALSTORAGE_ITEM)
if (unmountedRef.current) return
router.push(`/education-apply?token=${res.token}`) router.push(`/education-apply?token=${res.token}`)
setShowAccountSettingModal(null)
}).catch(() => { }).catch(() => {
setShowModal(true) setShowModal(true)
}) })
} }
useEffect(() => {
// setShowAccountSettingModal would prevent navigation
if (path.startsWith('/education-apply'))
setShowAccountSettingModal(null)
}, [path, setShowAccountSettingModal])
return ( return (
<div className='relative rounded-2xl border-[0.5px] border-effects-highlight-lightmode-off bg-background-section-burn'> <div className='relative rounded-2xl border-[0.5px] border-effects-highlight-lightmode-off bg-background-section-burn'>
<div className='p-6 pb-2'> <div className='p-6 pb-2'>
@ -96,9 +106,10 @@ const PlanComp: FC<Props> = ({
</div> </div>
<div className='flex shrink-0 items-center gap-1'> <div className='flex shrink-0 items-center gap-1'>
{enableEducationPlan && (!isEducationAccount || isAboutToExpire) && ( {enableEducationPlan && (!isEducationAccount || isAboutToExpire) && (
<Button variant='ghost' onClick={handleVerify}> <Button variant='ghost' onClick={handleVerify} disabled={isPending} >
<RiGraduationCapLine className='mr-1 h-4 w-4' /> <RiGraduationCapLine className='mr-1 h-4 w-4' />
{t('education.toVerified')} {t('education.toVerified')}
{isPending && <Loading className='ml-1 animate-spin-slow' />}
</Button> </Button>
)} )}
{(plan.type as any) !== SelfHostedPlan.enterprise && ( {(plan.type as any) !== SelfHostedPlan.enterprise && (

View File

@ -1,24 +0,0 @@
import type { CrawlResultItem } from '@/models/datasets'
const result: CrawlResultItem[] = [
{
title: 'Start the frontend Docker container separately',
content: 'Markdown 1',
description: 'Description 1',
source_url: 'https://example.com/1',
},
{
title: 'Advanced Tool Integration',
content: 'Markdown 2',
description: 'Description 2',
source_url: 'https://example.com/2',
},
{
title: 'Local Source Code Start | English | Dify',
content: 'Markdown 3',
description: 'Description 3',
source_url: 'https://example.com/3',
},
]
export default result

View File

@ -5,7 +5,7 @@ import {
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { RiDeleteBinLine } from '@remixicon/react' import { RiDeleteBinLine } from '@remixicon/react'
import { PlusIcon, XMarkIcon } from '@heroicons/react/20/solid' import { PlusIcon, XMarkIcon } from '@heroicons/react/20/solid'
import useSWR, { useSWRConfig } from 'swr' import useSWR from 'swr'
import SecretKeyGenerateModal from './secret-key-generate' import SecretKeyGenerateModal from './secret-key-generate'
import s from './style.module.css' import s from './style.module.css'
import ActionButton from '@/app/components/base/action-button' import ActionButton from '@/app/components/base/action-button'
@ -15,7 +15,6 @@ import CopyFeedback from '@/app/components/base/copy-feedback'
import { import {
createApikey as createAppApikey, createApikey as createAppApikey,
delApikey as delAppApikey, delApikey as delAppApikey,
fetchApiKeysList as fetchAppApiKeysList,
} from '@/service/apps' } from '@/service/apps'
import { import {
createApikey as createDatasetApikey, createApikey as createDatasetApikey,
@ -27,6 +26,7 @@ import Loading from '@/app/components/base/loading'
import Confirm from '@/app/components/base/confirm' import Confirm from '@/app/components/base/confirm'
import useTimestamp from '@/hooks/use-timestamp' import useTimestamp from '@/hooks/use-timestamp'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useAppApiKeys, useInvalidateAppApiKeys } from '@/service/use-apps'
type ISecretKeyModalProps = { type ISecretKeyModalProps = {
isShow: boolean isShow: boolean
@ -45,12 +45,14 @@ const SecretKeyModal = ({
const [showConfirmDelete, setShowConfirmDelete] = useState(false) const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [isVisible, setVisible] = useState(false) const [isVisible, setVisible] = useState(false)
const [newKey, setNewKey] = useState<CreateApiKeyResponse | undefined>(undefined) const [newKey, setNewKey] = useState<CreateApiKeyResponse | undefined>(undefined)
const { mutate } = useSWRConfig() const invalidateAppApiKeys = useInvalidateAppApiKeys()
const commonParams = appId const { data: appApiKeys, isLoading: isAppApiKeysLoading } = useAppApiKeys(appId, { enabled: !!appId && isShow })
? { url: `/apps/${appId}/api-keys`, params: {} } const { data: datasetApiKeys, isLoading: isDatasetApiKeysLoading, mutate: mutateDatasetApiKeys } = useSWR(
: { url: '/datasets/api-keys', params: {} } !appId && isShow ? { url: '/datasets/api-keys', params: {} } : null,
const fetchApiKeysList = appId ? fetchAppApiKeysList : fetchDatasetApiKeysList fetchDatasetApiKeysList,
const { data: apiKeysList } = useSWR(commonParams, fetchApiKeysList) )
const apiKeysList = appId ? appApiKeys : datasetApiKeys
const isApiKeysLoading = appId ? isAppApiKeysLoading : isDatasetApiKeysLoading
const [delKeyID, setDelKeyId] = useState('') const [delKeyID, setDelKeyId] = useState('')
@ -64,7 +66,10 @@ const SecretKeyModal = ({
? { url: `/apps/${appId}/api-keys/${delKeyID}`, params: {} } ? { url: `/apps/${appId}/api-keys/${delKeyID}`, params: {} }
: { url: `/datasets/api-keys/${delKeyID}`, params: {} } : { url: `/datasets/api-keys/${delKeyID}`, params: {} }
await delApikey(params) await delApikey(params)
mutate(commonParams) if (appId)
invalidateAppApiKeys(appId)
else
mutateDatasetApiKeys()
} }
const onCreate = async () => { const onCreate = async () => {
@ -75,7 +80,10 @@ const SecretKeyModal = ({
const res = await createApikey(params) const res = await createApikey(params)
setVisible(true) setVisible(true)
setNewKey(res) setNewKey(res)
mutate(commonParams) if (appId)
invalidateAppApiKeys(appId)
else
mutateDatasetApiKeys()
} }
const generateToken = (token: string) => { const generateToken = (token: string) => {
@ -88,7 +96,7 @@ const SecretKeyModal = ({
<XMarkIcon className="h-6 w-6 cursor-pointer text-text-tertiary" onClick={onClose} /> <XMarkIcon className="h-6 w-6 cursor-pointer text-text-tertiary" onClick={onClose} />
</div> </div>
<p className='mt-1 shrink-0 text-[13px] font-normal leading-5 text-text-tertiary'>{t('appApi.apiKeyModal.apiSecretKeyTips')}</p> <p className='mt-1 shrink-0 text-[13px] font-normal leading-5 text-text-tertiary'>{t('appApi.apiKeyModal.apiSecretKeyTips')}</p>
{!apiKeysList && <div className='mt-4'><Loading /></div>} {isApiKeysLoading && <div className='mt-4'><Loading /></div>}
{ {
!!apiKeysList?.data?.length && ( !!apiKeysList?.data?.length && (
<div className='mt-4 flex grow flex-col overflow-hidden'> <div className='mt-4 flex grow flex-col overflow-hidden'>

View File

@ -214,8 +214,12 @@ export const searchAnything = async (
actionItem?: ActionItem, actionItem?: ActionItem,
dynamicActions?: Record<string, ActionItem>, dynamicActions?: Record<string, ActionItem>,
): Promise<SearchResult[]> => { ): Promise<SearchResult[]> => {
const trimmedQuery = query.trim()
if (actionItem) { if (actionItem) {
const searchTerm = query.replace(actionItem.key, '').replace(actionItem.shortcut, '').trim() const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const prefixPattern = new RegExp(`^(${escapeRegExp(actionItem.key)}|${escapeRegExp(actionItem.shortcut)})\\s*`)
const searchTerm = trimmedQuery.replace(prefixPattern, '').trim()
try { try {
return await actionItem.search(query, searchTerm, locale) return await actionItem.search(query, searchTerm, locale)
} }
@ -225,10 +229,12 @@ export const searchAnything = async (
} }
} }
if (query.startsWith('@') || query.startsWith('/')) if (trimmedQuery.startsWith('@') || trimmedQuery.startsWith('/'))
return [] return []
const globalSearchActions = Object.values(dynamicActions || Actions) const globalSearchActions = Object.values(dynamicActions || Actions)
// Exclude slash commands from general search results
.filter(action => action.key !== '/')
// Use Promise.allSettled to handle partial failures gracefully // Use Promise.allSettled to handle partial failures gracefully
const searchPromises = globalSearchActions.map(async (action) => { const searchPromises = globalSearchActions.map(async (action) => {

View File

@ -177,31 +177,42 @@ const GotoAnything: FC<Props> = ({
} }
}, [router]) }, [router])
const dedupedResults = useMemo(() => {
const seen = new Set<string>()
return searchResults.filter((result) => {
const key = `${result.type}-${result.id}`
if (seen.has(key))
return false
seen.add(key)
return true
})
}, [searchResults])
// Group results by type // Group results by type
const groupedResults = useMemo(() => searchResults.reduce((acc, result) => { const groupedResults = useMemo(() => dedupedResults.reduce((acc, result) => {
if (!acc[result.type]) if (!acc[result.type])
acc[result.type] = [] acc[result.type] = []
acc[result.type].push(result) acc[result.type].push(result)
return acc return acc
}, {} as { [key: string]: SearchResult[] }), }, {} as { [key: string]: SearchResult[] }),
[searchResults]) [dedupedResults])
useEffect(() => { useEffect(() => {
if (isCommandsMode) if (isCommandsMode)
return return
if (!searchResults.length) if (!dedupedResults.length)
return return
const currentValueExists = searchResults.some(result => `${result.type}-${result.id}` === cmdVal) const currentValueExists = dedupedResults.some(result => `${result.type}-${result.id}` === cmdVal)
if (!currentValueExists) if (!currentValueExists)
setCmdVal(`${searchResults[0].type}-${searchResults[0].id}`) setCmdVal(`${dedupedResults[0].type}-${dedupedResults[0].id}`)
}, [isCommandsMode, searchResults, cmdVal]) }, [isCommandsMode, dedupedResults, cmdVal])
const emptyResult = useMemo(() => { const emptyResult = useMemo(() => {
if (searchResults.length || !searchQuery.trim() || isLoading || isCommandsMode) if (dedupedResults.length || !searchQuery.trim() || isLoading || isCommandsMode)
return null return null
const isCommandSearch = searchMode !== 'general' const isCommandSearch = searchMode !== 'general'
@ -246,7 +257,7 @@ const GotoAnything: FC<Props> = ({
</div> </div>
</div> </div>
) )
}, [searchResults, searchQuery, Actions, searchMode, isLoading, isError, isCommandsMode]) }, [dedupedResults, searchQuery, Actions, searchMode, isLoading, isError, isCommandsMode])
const defaultUI = useMemo(() => { const defaultUI = useMemo(() => {
if (searchQuery.trim()) if (searchQuery.trim())
@ -430,14 +441,14 @@ const GotoAnything: FC<Props> = ({
{/* Always show footer to prevent height jumping */} {/* Always show footer to prevent height jumping */}
<div className='border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary'> <div className='border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary'>
<div className='flex min-h-[16px] items-center justify-between'> <div className='flex min-h-[16px] items-center justify-between'>
{(!!searchResults.length || isError) ? ( {(!!dedupedResults.length || isError) ? (
<> <>
<span> <span>
{isError ? ( {isError ? (
<span className='text-red-500'>{t('app.gotoAnything.someServicesUnavailable')}</span> <span className='text-red-500'>{t('app.gotoAnything.someServicesUnavailable')}</span>
) : ( ) : (
<> <>
{t('app.gotoAnything.resultCount', { count: searchResults.length })} {t('app.gotoAnything.resultCount', { count: dedupedResults.length })}
{searchMode !== 'general' && ( {searchMode !== 'general' && (
<span className='ml-2 opacity-60'> <span className='ml-2 opacity-60'>
{t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })} {t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })}

View File

@ -34,6 +34,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n' import { useDocLink } from '@/context/i18n'
import { useLogout } from '@/service/use-common' import { useLogout } from '@/service/use-common'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants' import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { resetUser } from '@/app/components/base/amplitude/utils'
export default function AppSelector() { export default function AppSelector() {
const itemClassName = ` const itemClassName = `
@ -53,7 +54,7 @@ export default function AppSelector() {
const { mutateAsync: logout } = useLogout() const { mutateAsync: logout } = useLogout()
const handleLogout = async () => { const handleLogout = async () => {
await logout() await logout()
resetUser()
localStorage.removeItem('setup_status') localStorage.removeItem('setup_status')
// Tokens are now stored in cookies and cleared by backend // Tokens are now stored in cookies and cleared by backend

View File

@ -1,39 +1,28 @@
import { import {
useCallback,
useEffect, useEffect,
useMemo, useMemo,
useState,
} from 'react' } from 'react'
import { import {
useMarketplacePlugins, useMarketplacePlugins,
useMarketplacePluginsByCollectionId,
} from '@/app/components/plugins/marketplace/hooks' } from '@/app/components/plugins/marketplace/hooks'
import type { Plugin } from '@/app/components/plugins/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { getMarketplacePluginsByCollectionId } from '@/app/components/plugins/marketplace/utils'
export const useMarketplaceAllPlugins = (providers: any[], searchText: string) => { export const useMarketplaceAllPlugins = (providers: any[], searchText: string) => {
const exclude = useMemo(() => { const exclude = useMemo(() => {
return providers.map(provider => provider.plugin_id) return providers.map(provider => provider.plugin_id)
}, [providers]) }, [providers])
const [collectionPlugins, setCollectionPlugins] = useState<Plugin[]>([]) const {
plugins: collectionPlugins = [],
isLoading: isCollectionLoading,
} = useMarketplacePluginsByCollectionId('__datasource-settings-pinned-datasources')
const { const {
plugins, plugins,
queryPlugins, queryPlugins,
queryPluginsWithDebounced, queryPluginsWithDebounced,
isLoading, isLoading: isPluginsLoading,
} = useMarketplacePlugins() } = useMarketplacePlugins()
const getCollectionPlugins = useCallback(async () => {
const collectionPlugins = await getMarketplacePluginsByCollectionId('__datasource-settings-pinned-datasources')
setCollectionPlugins(collectionPlugins)
}, [])
useEffect(() => {
getCollectionPlugins()
}, [getCollectionPlugins])
useEffect(() => { useEffect(() => {
if (searchText) { if (searchText) {
queryPluginsWithDebounced({ queryPluginsWithDebounced({
@ -75,6 +64,6 @@ export const useMarketplaceAllPlugins = (providers: any[], searchText: string) =
return { return {
plugins: allPlugins, plugins: allPlugins,
isLoading, isLoading: isCollectionLoading || isPluginsLoading,
} }
} }

View File

@ -217,6 +217,7 @@ export type ModelProvider = {
url: TypeWithI18N url: TypeWithI18N
} }
icon_small: TypeWithI18N icon_small: TypeWithI18N
icon_small_dark?: TypeWithI18N
icon_large: TypeWithI18N icon_large: TypeWithI18N
background?: string background?: string
supported_model_types: ModelTypeEnum[] supported_model_types: ModelTypeEnum[]
@ -255,6 +256,7 @@ export type Model = {
provider: string provider: string
icon_large: TypeWithI18N icon_large: TypeWithI18N
icon_small: TypeWithI18N icon_small: TypeWithI18N
icon_small_dark?: TypeWithI18N
label: TypeWithI18N label: TypeWithI18N
models: ModelItem[] models: ModelItem[]
status: ModelStatusEnum status: ModelStatusEnum

View File

@ -33,10 +33,9 @@ import {
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { import {
useMarketplacePlugins, useMarketplacePlugins,
useMarketplacePluginsByCollectionId,
} from '@/app/components/plugins/marketplace/hooks' } from '@/app/components/plugins/marketplace/hooks'
import type { Plugin } from '@/app/components/plugins/types'
import { PluginCategoryEnum } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { getMarketplacePluginsByCollectionId } from '@/app/components/plugins/marketplace/utils'
import { useModalContextSelector } from '@/context/modal-context' import { useModalContextSelector } from '@/context/modal-context'
import { useEventEmitterContextContext } from '@/context/event-emitter' import { useEventEmitterContextContext } from '@/context/event-emitter'
import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './provider-added-card' import { UPDATE_MODEL_PROVIDER_CUSTOM_MODEL_LIST } from './provider-added-card'
@ -255,25 +254,17 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText:
const exclude = useMemo(() => { const exclude = useMemo(() => {
return providers.map(provider => provider.provider.replace(/(.+)\/([^/]+)$/, '$1')) return providers.map(provider => provider.provider.replace(/(.+)\/([^/]+)$/, '$1'))
}, [providers]) }, [providers])
const [collectionPlugins, setCollectionPlugins] = useState<Plugin[]>([]) const {
plugins: collectionPlugins = [],
isLoading: isCollectionLoading,
} = useMarketplacePluginsByCollectionId('__model-settings-pinned-models')
const { const {
plugins, plugins,
queryPlugins, queryPlugins,
queryPluginsWithDebounced, queryPluginsWithDebounced,
isLoading, isLoading: isPluginsLoading,
} = useMarketplacePlugins() } = useMarketplacePlugins()
const getCollectionPlugins = useCallback(async () => {
const collectionPlugins = await getMarketplacePluginsByCollectionId('__model-settings-pinned-models')
setCollectionPlugins(collectionPlugins)
}, [])
useEffect(() => {
getCollectionPlugins()
}, [getCollectionPlugins])
useEffect(() => { useEffect(() => {
if (searchText) { if (searchText) {
queryPluginsWithDebounced({ queryPluginsWithDebounced({
@ -315,7 +306,7 @@ export const useMarketplaceAllPlugins = (providers: ModelProvider[], searchText:
return { return {
plugins: allPlugins, plugins: allPlugins,
isLoading, isLoading: isCollectionLoading || isPluginsLoading,
} }
} }

View File

@ -6,8 +6,10 @@ import type {
import { useLanguage } from '../hooks' import { useLanguage } from '../hooks'
import { Group } from '@/app/components/base/icons/src/vender/other' import { Group } from '@/app/components/base/icons/src/vender/other'
import { OpenaiBlue, OpenaiTeal, OpenaiViolet, OpenaiYellow } from '@/app/components/base/icons/src/public/llm' import { OpenaiBlue, OpenaiTeal, OpenaiViolet, OpenaiYellow } from '@/app/components/base/icons/src/public/llm'
import cn from '@/utils/classnames'
import { renderI18nObject } from '@/i18n-config' import { renderI18nObject } from '@/i18n-config'
import { Theme } from '@/types/app'
import cn from '@/utils/classnames'
import useTheme from '@/hooks/use-theme'
type ModelIconProps = { type ModelIconProps = {
provider?: Model | ModelProvider provider?: Model | ModelProvider
@ -23,6 +25,7 @@ const ModelIcon: FC<ModelIconProps> = ({
iconClassName, iconClassName,
isDeprecated = false, isDeprecated = false,
}) => { }) => {
const { theme } = useTheme()
const language = useLanguage() const language = useLanguage()
if (provider?.provider && ['openai', 'langgenius/openai/openai'].includes(provider.provider) && modelName?.startsWith('o')) if (provider?.provider && ['openai', 'langgenius/openai/openai'].includes(provider.provider) && modelName?.startsWith('o'))
return <div className='flex items-center justify-center'><OpenaiYellow className={cn('h-5 w-5', className)} /></div> return <div className='flex items-center justify-center'><OpenaiYellow className={cn('h-5 w-5', className)} /></div>
@ -36,7 +39,16 @@ const ModelIcon: FC<ModelIconProps> = ({
if (provider?.icon_small) { if (provider?.icon_small) {
return ( return (
<div className={cn('flex h-5 w-5 items-center justify-center', isDeprecated && 'opacity-50', className)}> <div className={cn('flex h-5 w-5 items-center justify-center', isDeprecated && 'opacity-50', className)}>
<img alt='model-icon' src={renderI18nObject(provider.icon_small, language)} className={iconClassName} /> <img
alt='model-icon'
src={renderI18nObject(
theme === Theme.dark && provider.icon_small_dark
? provider.icon_small_dark
: provider.icon_small,
language,
)}
className={iconClassName}
/>
</div> </div>
) )
} }

View File

@ -40,7 +40,12 @@ const ProviderIcon: FC<ProviderIconProps> = ({
<div className={cn('inline-flex items-center gap-2', className)}> <div className={cn('inline-flex items-center gap-2', className)}>
<img <img
alt='provider-icon' alt='provider-icon'
src={renderI18nObject(provider.icon_small, language)} src={renderI18nObject(
theme === Theme.dark && provider.icon_small_dark
? provider.icon_small_dark
: provider.icon_small,
language,
)}
className='h-6 w-6' className='h-6 w-6'
/> />
<div className='system-md-semibold text-text-primary'> <div className='system-md-semibold text-text-primary'>

View File

@ -3,7 +3,6 @@
import { useCallback, useEffect, useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useParams } from 'next/navigation' import { useParams } from 'next/navigation'
import useSWRInfinite from 'swr/infinite'
import { flatten } from 'lodash-es' import { flatten } from 'lodash-es'
import { produce } from 'immer' import { produce } from 'immer'
import { import {
@ -12,33 +11,13 @@ import {
} from '@remixicon/react' } from '@remixicon/react'
import Nav from '../nav' import Nav from '../nav'
import type { NavItem } from '../nav/nav-selector' import type { NavItem } from '../nav/nav-selector'
import { fetchAppList } from '@/service/apps'
import CreateAppTemplateDialog from '@/app/components/app/create-app-dialog' import CreateAppTemplateDialog from '@/app/components/app/create-app-dialog'
import CreateAppModal from '@/app/components/app/create-app-modal' import CreateAppModal from '@/app/components/app/create-app-modal'
import CreateFromDSLModal from '@/app/components/app/create-from-dsl-modal' import CreateFromDSLModal from '@/app/components/app/create-from-dsl-modal'
import type { AppListResponse } from '@/models/app'
import { useAppContext } from '@/context/app-context' import { useAppContext } from '@/context/app-context'
import { useStore as useAppStore } from '@/app/components/app/store' import { useStore as useAppStore } from '@/app/components/app/store'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { useInfiniteAppList } from '@/service/use-apps'
const getKey = (
pageIndex: number,
previousPageData: AppListResponse,
activeTab: string,
keywords: string,
) => {
if (!pageIndex || previousPageData.has_more) {
const params: any = { url: 'apps', params: { page: pageIndex + 1, limit: 30, name: keywords } }
if (activeTab !== 'all')
params.params.mode = activeTab
else
delete params.params.mode
return params
}
return null
}
const AppNav = () => { const AppNav = () => {
const { t } = useTranslation() const { t } = useTranslation()
@ -50,17 +29,21 @@ const AppNav = () => {
const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false) const [showCreateFromDSLModal, setShowCreateFromDSLModal] = useState(false)
const [navItems, setNavItems] = useState<NavItem[]>([]) const [navItems, setNavItems] = useState<NavItem[]>([])
const { data: appsData, setSize, mutate } = useSWRInfinite( const {
appId data: appsData,
? (pageIndex: number, previousPageData: AppListResponse) => getKey(pageIndex, previousPageData, 'all', '') fetchNextPage,
: () => null, hasNextPage,
fetchAppList, refetch,
{ revalidateFirstPage: false }, } = useInfiniteAppList({
) page: 1,
limit: 30,
name: '',
}, { enabled: !!appId })
const handleLoadMore = useCallback(() => { const handleLoadMore = useCallback(() => {
setSize(size => size + 1) if (hasNextPage)
}, [setSize]) fetchNextPage()
}, [fetchNextPage, hasNextPage])
const openModal = (state: string) => { const openModal = (state: string) => {
if (state === 'blank') if (state === 'blank')
@ -73,7 +56,7 @@ const AppNav = () => {
useEffect(() => { useEffect(() => {
if (appsData) { if (appsData) {
const appItems = flatten(appsData?.map(appData => appData.data)) const appItems = flatten((appsData.pages ?? []).map(appData => appData.data))
const navItems = appItems.map((app) => { const navItems = appItems.map((app) => {
const link = ((isCurrentWorkspaceEditor, app) => { const link = ((isCurrentWorkspaceEditor, app) => {
if (!isCurrentWorkspaceEditor) { if (!isCurrentWorkspaceEditor) {
@ -132,17 +115,17 @@ const AppNav = () => {
<CreateAppModal <CreateAppModal
show={showNewAppDialog} show={showNewAppDialog}
onClose={() => setShowNewAppDialog(false)} onClose={() => setShowNewAppDialog(false)}
onSuccess={() => mutate()} onSuccess={() => refetch()}
/> />
<CreateAppTemplateDialog <CreateAppTemplateDialog
show={showNewAppTemplateDialog} show={showNewAppTemplateDialog}
onClose={() => setShowNewAppTemplateDialog(false)} onClose={() => setShowNewAppTemplateDialog(false)}
onSuccess={() => mutate()} onSuccess={() => refetch()}
/> />
<CreateFromDSLModal <CreateFromDSLModal
show={showCreateFromDSLModal} show={showCreateFromDSLModal}
onClose={() => setShowCreateFromDSLModal(false)} onClose={() => setShowCreateFromDSLModal(false)}
onSuccess={() => mutate()} onSuccess={() => refetch()}
/> />
</> </>
) )

View File

@ -6,6 +6,8 @@ import { getLanguage } from '@/i18n-config/language'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { RiAlertFill } from '@remixicon/react' import { RiAlertFill } from '@remixicon/react'
import React from 'react' import React from 'react'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import Partner from '../base/badges/partner' import Partner from '../base/badges/partner'
import Verified from '../base/badges/verified' import Verified from '../base/badges/verified'
import Icon from '../card/base/card-icon' import Icon from '../card/base/card-icon'
@ -50,7 +52,9 @@ const Card = ({
const locale = localeFromProps ? getLanguage(localeFromProps) : defaultLocale const locale = localeFromProps ? getLanguage(localeFromProps) : defaultLocale
const { t } = useMixedTranslation(localeFromProps) const { t } = useMixedTranslation(localeFromProps)
const { categoriesMap } = useCategories(t, true) const { categoriesMap } = useCategories(t, true)
const { category, type, name, org, label, brief, icon, verified, badges = [] } = payload const { category, type, name, org, label, brief, icon, icon_dark, verified, badges = [] } = payload
const { theme } = useTheme()
const iconSrc = theme === Theme.dark && icon_dark ? icon_dark : icon
const getLocalizedText = (obj: Record<string, string> | undefined) => const getLocalizedText = (obj: Record<string, string> | undefined) =>
obj ? renderI18nObject(obj, locale) : '' obj ? renderI18nObject(obj, locale) : ''
const isPartner = badges.includes('partner') const isPartner = badges.includes('partner')
@ -71,7 +75,7 @@ const Card = ({
{!hideCornerMark && <CornerMark text={categoriesMap[type === 'bundle' ? type : category]?.label} />} {!hideCornerMark && <CornerMark text={categoriesMap[type === 'bundle' ? type : category]?.label} />}
{/* Header */} {/* Header */}
<div className="flex"> <div className="flex">
<Icon src={icon} installed={installed} installFailed={installFailed} /> <Icon src={iconSrc} installed={installed} installFailed={installFailed} />
<div className="ml-3 w-0 grow"> <div className="ml-3 w-0 grow">
<div className="flex h-5 items-center"> <div className="flex h-5 items-center">
<Title title={getLocalizedText(label)} /> <Title title={getLocalizedText(label)} />

View File

@ -64,10 +64,12 @@ const InstallFromLocalPackage: React.FC<InstallFromLocalPackageProps> = ({
uniqueIdentifier, uniqueIdentifier,
} = result } = result
const icon = await getIconUrl(manifest!.icon) const icon = await getIconUrl(manifest!.icon)
const iconDark = manifest.icon_dark ? await getIconUrl(manifest.icon_dark) : undefined
setUniqueIdentifier(uniqueIdentifier) setUniqueIdentifier(uniqueIdentifier)
setManifest({ setManifest({
...manifest, ...manifest,
icon, icon,
icon_dark: iconDark,
}) })
setStep(InstallStep.readyToInstall) setStep(InstallStep.readyToInstall)
}, [getIconUrl]) }, [getIconUrl])

View File

@ -17,6 +17,7 @@ export const pluginManifestToCardPluginProps = (pluginManifest: PluginDeclaratio
brief: pluginManifest.description, brief: pluginManifest.description,
description: pluginManifest.description, description: pluginManifest.description,
icon: pluginManifest.icon, icon: pluginManifest.icon,
icon_dark: pluginManifest.icon_dark,
verified: pluginManifest.verified, verified: pluginManifest.verified,
introduction: '', introduction: '',
repository: '', repository: '',

View File

@ -2,3 +2,5 @@ export const DEFAULT_SORT = {
sortBy: 'install_count', sortBy: 'install_count',
sortOrder: 'DESC', sortOrder: 'DESC',
} }
export const SCROLL_BOTTOM_THRESHOLD = 100

View File

@ -41,8 +41,6 @@ import { useInstalledPluginList } from '@/service/use-plugins'
import { debounce, noop } from 'lodash-es' import { debounce, noop } from 'lodash-es'
export type MarketplaceContextValue = { export type MarketplaceContextValue = {
intersected: boolean
setIntersected: (intersected: boolean) => void
searchPluginText: string searchPluginText: string
handleSearchPluginTextChange: (text: string) => void handleSearchPluginTextChange: (text: string) => void
filterPluginTags: string[] filterPluginTags: string[]
@ -50,7 +48,7 @@ export type MarketplaceContextValue = {
activePluginType: string activePluginType: string
handleActivePluginTypeChange: (type: string) => void handleActivePluginTypeChange: (type: string) => void
page: number page: number
handlePageChange: (page: number) => void handlePageChange: () => void
plugins?: Plugin[] plugins?: Plugin[]
pluginsTotal?: number pluginsTotal?: number
resetPlugins: () => void resetPlugins: () => void
@ -67,8 +65,6 @@ export type MarketplaceContextValue = {
} }
export const MarketplaceContext = createContext<MarketplaceContextValue>({ export const MarketplaceContext = createContext<MarketplaceContextValue>({
intersected: true,
setIntersected: noop,
searchPluginText: '', searchPluginText: '',
handleSearchPluginTextChange: noop, handleSearchPluginTextChange: noop,
filterPluginTags: [], filterPluginTags: [],
@ -121,15 +117,12 @@ export const MarketplaceContextProvider = ({
const hasValidTags = !!tagsFromSearchParams.length const hasValidTags = !!tagsFromSearchParams.length
const hasValidCategory = getValidCategoryKeys(searchParams?.category) const hasValidCategory = getValidCategoryKeys(searchParams?.category)
const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all const categoryFromSearchParams = hasValidCategory || PLUGIN_TYPE_SEARCH_MAP.all
const [intersected, setIntersected] = useState(true)
const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams) const [searchPluginText, setSearchPluginText] = useState(queryFromSearchParams)
const searchPluginTextRef = useRef(searchPluginText) const searchPluginTextRef = useRef(searchPluginText)
const [filterPluginTags, setFilterPluginTags] = useState<string[]>(tagsFromSearchParams) const [filterPluginTags, setFilterPluginTags] = useState<string[]>(tagsFromSearchParams)
const filterPluginTagsRef = useRef(filterPluginTags) const filterPluginTagsRef = useRef(filterPluginTags)
const [activePluginType, setActivePluginType] = useState(categoryFromSearchParams) const [activePluginType, setActivePluginType] = useState(categoryFromSearchParams)
const activePluginTypeRef = useRef(activePluginType) const activePluginTypeRef = useRef(activePluginType)
const [page, setPage] = useState(1)
const pageRef = useRef(page)
const [sort, setSort] = useState(DEFAULT_SORT) const [sort, setSort] = useState(DEFAULT_SORT)
const sortRef = useRef(sort) const sortRef = useRef(sort)
const { const {
@ -149,7 +142,11 @@ export const MarketplaceContextProvider = ({
queryPluginsWithDebounced, queryPluginsWithDebounced,
cancelQueryPluginsWithDebounced, cancelQueryPluginsWithDebounced,
isLoading: isPluginsLoading, isLoading: isPluginsLoading,
fetchNextPage: fetchNextPluginsPage,
hasNextPage: hasNextPluginsPage,
page: pluginsPage,
} = useMarketplacePlugins() } = useMarketplacePlugins()
const page = Math.max(pluginsPage || 0, 1)
useEffect(() => { useEffect(() => {
if (queryFromSearchParams || hasValidTags || hasValidCategory) { if (queryFromSearchParams || hasValidTags || hasValidCategory) {
@ -160,7 +157,6 @@ export const MarketplaceContextProvider = ({
sortBy: sortRef.current.sortBy, sortBy: sortRef.current.sortBy,
sortOrder: sortRef.current.sortOrder, sortOrder: sortRef.current.sortOrder,
type: getMarketplaceListFilterType(activePluginTypeRef.current), type: getMarketplaceListFilterType(activePluginTypeRef.current),
page: pageRef.current,
}) })
const url = new URL(window.location.href) const url = new URL(window.location.href)
if (searchParams?.language) if (searchParams?.language)
@ -221,7 +217,6 @@ export const MarketplaceContextProvider = ({
sortOrder: sortRef.current.sortOrder, sortOrder: sortRef.current.sortOrder,
exclude, exclude,
type: getMarketplaceListFilterType(activePluginTypeRef.current), type: getMarketplaceListFilterType(activePluginTypeRef.current),
page: pageRef.current,
}) })
} }
else { else {
@ -233,7 +228,6 @@ export const MarketplaceContextProvider = ({
sortOrder: sortRef.current.sortOrder, sortOrder: sortRef.current.sortOrder,
exclude, exclude,
type: getMarketplaceListFilterType(activePluginTypeRef.current), type: getMarketplaceListFilterType(activePluginTypeRef.current),
page: pageRef.current,
}) })
} }
}, [exclude, queryPluginsWithDebounced, queryPlugins, handleUpdateSearchParams]) }, [exclude, queryPluginsWithDebounced, queryPlugins, handleUpdateSearchParams])
@ -252,8 +246,6 @@ export const MarketplaceContextProvider = ({
const handleSearchPluginTextChange = useCallback((text: string) => { const handleSearchPluginTextChange = useCallback((text: string) => {
setSearchPluginText(text) setSearchPluginText(text)
searchPluginTextRef.current = text searchPluginTextRef.current = text
setPage(1)
pageRef.current = 1
handleQuery(true) handleQuery(true)
}, [handleQuery]) }, [handleQuery])
@ -261,8 +253,6 @@ export const MarketplaceContextProvider = ({
const handleFilterPluginTagsChange = useCallback((tags: string[]) => { const handleFilterPluginTagsChange = useCallback((tags: string[]) => {
setFilterPluginTags(tags) setFilterPluginTags(tags)
filterPluginTagsRef.current = tags filterPluginTagsRef.current = tags
setPage(1)
pageRef.current = 1
handleQuery() handleQuery()
}, [handleQuery]) }, [handleQuery])
@ -270,8 +260,6 @@ export const MarketplaceContextProvider = ({
const handleActivePluginTypeChange = useCallback((type: string) => { const handleActivePluginTypeChange = useCallback((type: string) => {
setActivePluginType(type) setActivePluginType(type)
activePluginTypeRef.current = type activePluginTypeRef.current = type
setPage(1)
pageRef.current = 1
handleQuery() handleQuery()
}, [handleQuery]) }, [handleQuery])
@ -279,20 +267,14 @@ export const MarketplaceContextProvider = ({
const handleSortChange = useCallback((sort: PluginsSort) => { const handleSortChange = useCallback((sort: PluginsSort) => {
setSort(sort) setSort(sort)
sortRef.current = sort sortRef.current = sort
setPage(1)
pageRef.current = 1
handleQueryPlugins() handleQueryPlugins()
}, [handleQueryPlugins]) }, [handleQueryPlugins])
const handlePageChange = useCallback(() => { const handlePageChange = useCallback(() => {
if (pluginsTotal && plugins && pluginsTotal > plugins.length) { if (hasNextPluginsPage)
setPage(pageRef.current + 1) fetchNextPluginsPage()
pageRef.current++ }, [fetchNextPluginsPage, hasNextPluginsPage])
handleQueryPlugins()
}
}, [handleQueryPlugins, plugins, pluginsTotal])
const handleMoreClick = useCallback((searchParams: SearchParamsFromCollection) => { const handleMoreClick = useCallback((searchParams: SearchParamsFromCollection) => {
setSearchPluginText(searchParams?.query || '') setSearchPluginText(searchParams?.query || '')
@ -305,9 +287,6 @@ export const MarketplaceContextProvider = ({
sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy, sortBy: searchParams?.sort_by || DEFAULT_SORT.sortBy,
sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder, sortOrder: searchParams?.sort_order || DEFAULT_SORT.sortOrder,
} }
setPage(1)
pageRef.current = 1
handleQueryPlugins() handleQueryPlugins()
}, [handleQueryPlugins]) }, [handleQueryPlugins])
@ -316,8 +295,6 @@ export const MarketplaceContextProvider = ({
return ( return (
<MarketplaceContext.Provider <MarketplaceContext.Provider
value={{ value={{
intersected,
setIntersected,
searchPluginText, searchPluginText,
handleSearchPluginTextChange, handleSearchPluginTextChange,
filterPluginTags, filterPluginTags,

View File

@ -3,6 +3,11 @@ import {
useEffect, useEffect,
useState, useState,
} from 'react' } from 'react'
import {
useInfiniteQuery,
useQuery,
useQueryClient,
} from '@tanstack/react-query'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useDebounceFn } from 'ahooks' import { useDebounceFn } from 'ahooks'
import type { import type {
@ -16,39 +21,41 @@ import type {
import { import {
getFormattedPlugin, getFormattedPlugin,
getMarketplaceCollectionsAndPlugins, getMarketplaceCollectionsAndPlugins,
getMarketplacePluginsByCollectionId,
} from './utils' } from './utils'
import { SCROLL_BOTTOM_THRESHOLD } from './constants'
import i18n from '@/i18n-config/i18next-config' import i18n from '@/i18n-config/i18next-config'
import { import { postMarketplace } from '@/service/base'
useMutationPluginsFromMarketplace, import type { PluginsFromMarketplaceResponse } from '@/app/components/plugins/types'
} from '@/service/use-plugins'
export const useMarketplaceCollectionsAndPlugins = () => { export const useMarketplaceCollectionsAndPlugins = () => {
const [isLoading, setIsLoading] = useState(false) const [queryParams, setQueryParams] = useState<CollectionsAndPluginsSearchParams>()
const [isSuccess, setIsSuccess] = useState(false) const [marketplaceCollectionsOverride, setMarketplaceCollections] = useState<MarketplaceCollection[]>()
const [marketplaceCollections, setMarketplaceCollections] = useState<MarketplaceCollection[]>() const [marketplaceCollectionPluginsMapOverride, setMarketplaceCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
const [marketplaceCollectionPluginsMap, setMarketplaceCollectionPluginsMap] = useState<Record<string, Plugin[]>>()
const queryMarketplaceCollectionsAndPlugins = useCallback(async (query?: CollectionsAndPluginsSearchParams) => { const {
try { data,
setIsLoading(true) isFetching,
setIsSuccess(false) isSuccess,
const { marketplaceCollections, marketplaceCollectionPluginsMap } = await getMarketplaceCollectionsAndPlugins(query) isPending,
setIsLoading(false) } = useQuery({
setIsSuccess(true) queryKey: ['marketplaceCollectionsAndPlugins', queryParams],
setMarketplaceCollections(marketplaceCollections) queryFn: ({ signal }) => getMarketplaceCollectionsAndPlugins(queryParams, { signal }),
setMarketplaceCollectionPluginsMap(marketplaceCollectionPluginsMap) enabled: queryParams !== undefined,
} staleTime: 1000 * 60 * 5,
// eslint-disable-next-line unused-imports/no-unused-vars gcTime: 1000 * 60 * 10,
catch (e) { retry: false,
setIsLoading(false) })
setIsSuccess(false)
} const queryMarketplaceCollectionsAndPlugins = useCallback((query?: CollectionsAndPluginsSearchParams) => {
setQueryParams(query ? { ...query } : {})
}, []) }, [])
const isLoading = !!queryParams && (isFetching || isPending)
return { return {
marketplaceCollections, marketplaceCollections: marketplaceCollectionsOverride ?? data?.marketplaceCollections,
setMarketplaceCollections, setMarketplaceCollections,
marketplaceCollectionPluginsMap, marketplaceCollectionPluginsMap: marketplaceCollectionPluginsMapOverride ?? data?.marketplaceCollectionPluginsMap,
setMarketplaceCollectionPluginsMap, setMarketplaceCollectionPluginsMap,
queryMarketplaceCollectionsAndPlugins, queryMarketplaceCollectionsAndPlugins,
isLoading, isLoading,
@ -56,37 +63,128 @@ export const useMarketplaceCollectionsAndPlugins = () => {
} }
} }
export const useMarketplacePlugins = () => { export const useMarketplacePluginsByCollectionId = (
collectionId?: string,
query?: CollectionsAndPluginsSearchParams,
) => {
const { const {
data, data,
mutateAsync, isFetching,
reset, isSuccess,
isPending, isPending,
} = useMutationPluginsFromMarketplace() } = useQuery({
queryKey: ['marketplaceCollectionPlugins', collectionId, query],
queryFn: ({ signal }) => {
if (!collectionId)
return Promise.resolve<Plugin[]>([])
return getMarketplacePluginsByCollectionId(collectionId, query, { signal })
},
enabled: !!collectionId,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
retry: false,
})
const [prevPlugins, setPrevPlugins] = useState<Plugin[] | undefined>() return {
plugins: data || [],
isLoading: !!collectionId && (isFetching || isPending),
isSuccess,
}
}
export const useMarketplacePlugins = () => {
const queryClient = useQueryClient()
const [queryParams, setQueryParams] = useState<PluginsSearchParams>()
const normalizeParams = useCallback((pluginsSearchParams: PluginsSearchParams) => {
const pageSize = pluginsSearchParams.pageSize || 40
return {
...pluginsSearchParams,
pageSize,
}
}, [])
const marketplacePluginsQuery = useInfiniteQuery({
queryKey: ['marketplacePlugins', queryParams],
queryFn: async ({ pageParam = 1, signal }) => {
if (!queryParams) {
return {
plugins: [] as Plugin[],
total: 0,
page: 1,
pageSize: 40,
}
}
const params = normalizeParams(queryParams)
const {
query,
sortBy,
sortOrder,
category,
tags,
exclude,
type,
pageSize,
} = params
const pluginOrBundle = type === 'bundle' ? 'bundles' : 'plugins'
try {
const res = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>(`/${pluginOrBundle}/search/advanced`, {
body: {
page: pageParam,
page_size: pageSize,
query,
sort_by: sortBy,
sort_order: sortOrder,
category: category !== 'all' ? category : '',
tags,
exclude,
type,
},
signal,
})
const resPlugins = res.data.bundles || res.data.plugins || []
return {
plugins: resPlugins.map(plugin => getFormattedPlugin(plugin)),
total: res.data.total,
page: pageParam,
pageSize,
}
}
catch {
return {
plugins: [],
total: 0,
page: pageParam,
pageSize,
}
}
},
getNextPageParam: (lastPage) => {
const nextPage = lastPage.page + 1
const loaded = lastPage.page * lastPage.pageSize
return loaded < (lastPage.total || 0) ? nextPage : undefined
},
initialPageParam: 1,
enabled: !!queryParams,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 10,
retry: false,
})
const resetPlugins = useCallback(() => { const resetPlugins = useCallback(() => {
reset() setQueryParams(undefined)
setPrevPlugins(undefined) queryClient.removeQueries({
}, [reset]) queryKey: ['marketplacePlugins'],
})
}, [queryClient])
const handleUpdatePlugins = useCallback((pluginsSearchParams: PluginsSearchParams) => { const handleUpdatePlugins = useCallback((pluginsSearchParams: PluginsSearchParams) => {
mutateAsync(pluginsSearchParams).then((res) => { setQueryParams(normalizeParams(pluginsSearchParams))
const currentPage = pluginsSearchParams.page || 1 }, [normalizeParams])
const resPlugins = res.data.bundles || res.data.plugins
if (currentPage > 1) {
setPrevPlugins(prevPlugins => [...(prevPlugins || []), ...resPlugins.map((plugin) => {
return getFormattedPlugin(plugin)
})])
}
else {
setPrevPlugins(resPlugins.map((plugin) => {
return getFormattedPlugin(plugin)
}))
}
})
}, [mutateAsync])
const { run: queryPluginsWithDebounced, cancel: cancelQueryPluginsWithDebounced } = useDebounceFn((pluginsSearchParams: PluginsSearchParams) => { const { run: queryPluginsWithDebounced, cancel: cancelQueryPluginsWithDebounced } = useDebounceFn((pluginsSearchParams: PluginsSearchParams) => {
handleUpdatePlugins(pluginsSearchParams) handleUpdatePlugins(pluginsSearchParams)
@ -94,14 +192,29 @@ export const useMarketplacePlugins = () => {
wait: 500, wait: 500,
}) })
const hasQuery = !!queryParams
const hasData = marketplacePluginsQuery.data !== undefined
const plugins = hasQuery && hasData
? marketplacePluginsQuery.data.pages.flatMap(page => page.plugins)
: undefined
const total = hasQuery && hasData ? marketplacePluginsQuery.data.pages?.[0]?.total : undefined
const isPluginsLoading = hasQuery && (
marketplacePluginsQuery.isPending
|| (marketplacePluginsQuery.isFetching && !marketplacePluginsQuery.data)
)
return { return {
plugins: prevPlugins, plugins,
total: data?.data?.total, total,
resetPlugins, resetPlugins,
queryPlugins: handleUpdatePlugins, queryPlugins: handleUpdatePlugins,
queryPluginsWithDebounced, queryPluginsWithDebounced,
cancelQueryPluginsWithDebounced, cancelQueryPluginsWithDebounced,
isLoading: isPending, isLoading: isPluginsLoading,
isFetchingNextPage: marketplacePluginsQuery.isFetchingNextPage,
hasNextPage: marketplacePluginsQuery.hasNextPage,
fetchNextPage: marketplacePluginsQuery.fetchNextPage,
page: marketplacePluginsQuery.data?.pages?.length || (marketplacePluginsQuery.isPending && hasQuery ? 1 : 0),
} }
} }
@ -131,7 +244,7 @@ export const useMarketplaceContainerScroll = (
scrollHeight, scrollHeight,
clientHeight, clientHeight,
} = target } = target
if (scrollTop + clientHeight >= scrollHeight - 5 && scrollTop > 0) if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0)
callback() callback()
}, [callback]) }, [callback])
@ -146,34 +259,3 @@ export const useMarketplaceContainerScroll = (
} }
}, [handleScroll]) }, [handleScroll])
} }
export const useSearchBoxAutoAnimate = (searchBoxAutoAnimate?: boolean) => {
const [searchBoxCanAnimate, setSearchBoxCanAnimate] = useState(true)
const handleSearchBoxCanAnimateChange = useCallback(() => {
if (!searchBoxAutoAnimate) {
const clientWidth = document.documentElement.clientWidth
if (clientWidth < 1400)
setSearchBoxCanAnimate(false)
else
setSearchBoxCanAnimate(true)
}
}, [searchBoxAutoAnimate])
useEffect(() => {
handleSearchBoxCanAnimateChange()
}, [handleSearchBoxCanAnimateChange])
useEffect(() => {
window.addEventListener('resize', handleSearchBoxCanAnimateChange)
return () => {
window.removeEventListener('resize', handleSearchBoxCanAnimateChange)
}
}, [handleSearchBoxCanAnimateChange])
return {
searchBoxCanAnimate,
}
}

View File

@ -1,37 +1,32 @@
import { MarketplaceContextProvider } from './context' import { MarketplaceContextProvider } from './context'
import Description from './description' import Description from './description'
import IntersectionLine from './intersection-line' import StickySearchAndSwitchWrapper from './sticky-search-and-switch-wrapper'
import SearchBoxWrapper from './search-box/search-box-wrapper'
import PluginTypeSwitch from './plugin-type-switch'
import ListWrapper from './list/list-wrapper' import ListWrapper from './list/list-wrapper'
import type { SearchParams } from './types' import type { MarketplaceCollection, SearchParams } from './types'
import type { Plugin } from '@/app/components/plugins/types'
import { getMarketplaceCollectionsAndPlugins } from './utils' import { getMarketplaceCollectionsAndPlugins } from './utils'
import { TanstackQueryInitializer } from '@/context/query-client' import { TanstackQueryInitializer } from '@/context/query-client'
type MarketplaceProps = { type MarketplaceProps = {
locale: string locale: string
searchBoxAutoAnimate?: boolean
showInstallButton?: boolean showInstallButton?: boolean
shouldExclude?: boolean shouldExclude?: boolean
searchParams?: SearchParams searchParams?: SearchParams
pluginTypeSwitchClassName?: string pluginTypeSwitchClassName?: string
intersectionContainerId?: string
scrollContainerId?: string scrollContainerId?: string
showSearchParams?: boolean showSearchParams?: boolean
} }
const Marketplace = async ({ const Marketplace = async ({
locale, locale,
searchBoxAutoAnimate = true,
showInstallButton = true, showInstallButton = true,
shouldExclude, shouldExclude,
searchParams, searchParams,
pluginTypeSwitchClassName, pluginTypeSwitchClassName,
intersectionContainerId,
scrollContainerId, scrollContainerId,
showSearchParams = true, showSearchParams = true,
}: MarketplaceProps) => { }: MarketplaceProps) => {
let marketplaceCollections: any = [] let marketplaceCollections: MarketplaceCollection[] = []
let marketplaceCollectionPluginsMap = {} let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {}
if (!shouldExclude) { if (!shouldExclude) {
const marketplaceCollectionsAndPluginsData = await getMarketplaceCollectionsAndPlugins() const marketplaceCollectionsAndPluginsData = await getMarketplaceCollectionsAndPlugins()
marketplaceCollections = marketplaceCollectionsAndPluginsData.marketplaceCollections marketplaceCollections = marketplaceCollectionsAndPluginsData.marketplaceCollections
@ -47,15 +42,9 @@ const Marketplace = async ({
showSearchParams={showSearchParams} showSearchParams={showSearchParams}
> >
<Description locale={locale} /> <Description locale={locale} />
<IntersectionLine intersectionContainerId={intersectionContainerId} /> <StickySearchAndSwitchWrapper
<SearchBoxWrapper
locale={locale} locale={locale}
searchBoxAutoAnimate={searchBoxAutoAnimate} pluginTypeSwitchClassName={pluginTypeSwitchClassName}
/>
<PluginTypeSwitch
locale={locale}
className={pluginTypeSwitchClassName}
searchBoxAutoAnimate={searchBoxAutoAnimate}
showSearchParams={showSearchParams} showSearchParams={showSearchParams}
/> />
<ListWrapper <ListWrapper

View File

@ -1,30 +0,0 @@
import { useEffect } from 'react'
import { useMarketplaceContext } from '@/app/components/plugins/marketplace/context'
export const useScrollIntersection = (
anchorRef: React.RefObject<HTMLDivElement | null>,
intersectionContainerId = 'marketplace-container',
) => {
const intersected = useMarketplaceContext(v => v.intersected)
const setIntersected = useMarketplaceContext(v => v.setIntersected)
useEffect(() => {
const container = document.getElementById(intersectionContainerId)
let observer: IntersectionObserver | undefined
if (container && anchorRef.current) {
observer = new IntersectionObserver((entries) => {
const isIntersecting = entries[0].isIntersecting
if (isIntersecting && !intersected)
setIntersected(true)
if (!isIntersecting && intersected)
setIntersected(false)
}, {
root: container,
})
observer.observe(anchorRef.current)
}
return () => observer?.disconnect()
}, [anchorRef, intersected, setIntersected, intersectionContainerId])
}

View File

@ -1,21 +0,0 @@
'use client'
import { useRef } from 'react'
import { useScrollIntersection } from './hooks'
type IntersectionLineProps = {
intersectionContainerId?: string
}
const IntersectionLine = ({
intersectionContainerId,
}: IntersectionLineProps) => {
const ref = useRef<HTMLDivElement>(null)
useScrollIntersection(ref, intersectionContainerId)
return (
<div ref={ref} className='mb-4 h-px shrink-0 bg-transparent'></div>
)
}
export default IntersectionLine

View File

@ -28,13 +28,20 @@ const ListWrapper = ({
const isLoading = useMarketplaceContext(v => v.isLoading) const isLoading = useMarketplaceContext(v => v.isLoading)
const isSuccessCollections = useMarketplaceContext(v => v.isSuccessCollections) const isSuccessCollections = useMarketplaceContext(v => v.isSuccessCollections)
const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins) const handleQueryPlugins = useMarketplaceContext(v => v.handleQueryPlugins)
const searchPluginText = useMarketplaceContext(v => v.searchPluginText)
const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags)
const page = useMarketplaceContext(v => v.page) const page = useMarketplaceContext(v => v.page)
const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick) const handleMoreClick = useMarketplaceContext(v => v.handleMoreClick)
useEffect(() => { useEffect(() => {
if (!marketplaceCollectionsFromClient?.length && isSuccessCollections) if (
!marketplaceCollectionsFromClient?.length
&& isSuccessCollections
&& !searchPluginText
&& !filterPluginTags.length
)
handleQueryPlugins() handleQueryPlugins()
}, [handleQueryPlugins, marketplaceCollections, marketplaceCollectionsFromClient, isSuccessCollections]) }, [handleQueryPlugins, marketplaceCollections, marketplaceCollectionsFromClient, isSuccessCollections, searchPluginText, filterPluginTags])
return ( return (
<div <div

View File

@ -12,10 +12,7 @@ import {
import { useCallback, useEffect } from 'react' import { useCallback, useEffect } from 'react'
import { PluginCategoryEnum } from '../types' import { PluginCategoryEnum } from '../types'
import { useMarketplaceContext } from './context' import { useMarketplaceContext } from './context'
import { import { useMixedTranslation } from './hooks'
useMixedTranslation,
useSearchBoxAutoAnimate,
} from './hooks'
export const PLUGIN_TYPE_SEARCH_MAP = { export const PLUGIN_TYPE_SEARCH_MAP = {
all: 'all', all: 'all',
@ -30,19 +27,16 @@ export const PLUGIN_TYPE_SEARCH_MAP = {
type PluginTypeSwitchProps = { type PluginTypeSwitchProps = {
locale?: string locale?: string
className?: string className?: string
searchBoxAutoAnimate?: boolean
showSearchParams?: boolean showSearchParams?: boolean
} }
const PluginTypeSwitch = ({ const PluginTypeSwitch = ({
locale, locale,
className, className,
searchBoxAutoAnimate,
showSearchParams, showSearchParams,
}: PluginTypeSwitchProps) => { }: PluginTypeSwitchProps) => {
const { t } = useMixedTranslation(locale) const { t } = useMixedTranslation(locale)
const activePluginType = useMarketplaceContext(s => s.activePluginType) const activePluginType = useMarketplaceContext(s => s.activePluginType)
const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange) const handleActivePluginTypeChange = useMarketplaceContext(s => s.handleActivePluginTypeChange)
const { searchBoxCanAnimate } = useSearchBoxAutoAnimate(searchBoxAutoAnimate)
const options = [ const options = [
{ {
@ -105,7 +99,6 @@ const PluginTypeSwitch = ({
return ( return (
<div className={cn( <div className={cn(
'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3', 'flex shrink-0 items-center justify-center space-x-2 bg-background-body py-3',
searchBoxCanAnimate && 'sticky top-[56px] z-10',
className, className,
)}> )}>
{ {

View File

@ -1,36 +1,24 @@
'use client' 'use client'
import { useMarketplaceContext } from '../context' import { useMarketplaceContext } from '../context'
import { import { useMixedTranslation } from '../hooks'
useMixedTranslation,
useSearchBoxAutoAnimate,
} from '../hooks'
import SearchBox from './index' import SearchBox from './index'
import cn from '@/utils/classnames'
type SearchBoxWrapperProps = { type SearchBoxWrapperProps = {
locale?: string locale?: string
searchBoxAutoAnimate?: boolean
} }
const SearchBoxWrapper = ({ const SearchBoxWrapper = ({
locale, locale,
searchBoxAutoAnimate,
}: SearchBoxWrapperProps) => { }: SearchBoxWrapperProps) => {
const { t } = useMixedTranslation(locale) const { t } = useMixedTranslation(locale)
const intersected = useMarketplaceContext(v => v.intersected)
const searchPluginText = useMarketplaceContext(v => v.searchPluginText) const searchPluginText = useMarketplaceContext(v => v.searchPluginText)
const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange) const handleSearchPluginTextChange = useMarketplaceContext(v => v.handleSearchPluginTextChange)
const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags) const filterPluginTags = useMarketplaceContext(v => v.filterPluginTags)
const handleFilterPluginTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange) const handleFilterPluginTagsChange = useMarketplaceContext(v => v.handleFilterPluginTagsChange)
const { searchBoxCanAnimate } = useSearchBoxAutoAnimate(searchBoxAutoAnimate)
return ( return (
<SearchBox <SearchBox
wrapperClassName={cn( wrapperClassName='z-[11] mx-auto w-[640px] shrink-0'
'z-[0] mx-auto w-[640px] shrink-0',
searchBoxCanAnimate && 'sticky top-3 z-[11]',
!intersected && searchBoxCanAnimate && 'w-[508px] transition-[width] duration-300',
)}
inputClassName='w-full' inputClassName='w-full'
search={searchPluginText} search={searchPluginText}
onSearchChange={handleSearchPluginTextChange} onSearchChange={handleSearchPluginTextChange}

View File

@ -0,0 +1,37 @@
'use client'
import SearchBoxWrapper from './search-box/search-box-wrapper'
import PluginTypeSwitch from './plugin-type-switch'
import cn from '@/utils/classnames'
type StickySearchAndSwitchWrapperProps = {
locale?: string
pluginTypeSwitchClassName?: string
showSearchParams?: boolean
}
const StickySearchAndSwitchWrapper = ({
locale,
pluginTypeSwitchClassName,
showSearchParams,
}: StickySearchAndSwitchWrapperProps) => {
const hasCustomTopClass = pluginTypeSwitchClassName?.includes('top-')
return (
<div
className={cn(
'mt-4 bg-background-body',
hasCustomTopClass && 'sticky z-10',
pluginTypeSwitchClassName,
)}
>
<SearchBoxWrapper locale={locale} />
<PluginTypeSwitch
locale={locale}
showSearchParams={showSearchParams}
/>
</div>
)
}
export default StickySearchAndSwitchWrapper

View File

@ -13,6 +13,14 @@ import {
} from '@/config' } from '@/config'
import { getMarketplaceUrl } from '@/utils/var' import { getMarketplaceUrl } from '@/utils/var'
type MarketplaceFetchOptions = {
signal?: AbortSignal
}
const getMarketplaceHeaders = () => new Headers({
'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0',
})
export const getPluginIconInMarketplace = (plugin: Plugin) => { export const getPluginIconInMarketplace = (plugin: Plugin) => {
if (plugin.type === 'bundle') if (plugin.type === 'bundle')
return `${MARKETPLACE_API_PREFIX}/bundles/${plugin.org}/${plugin.name}/icon` return `${MARKETPLACE_API_PREFIX}/bundles/${plugin.org}/${plugin.name}/icon`
@ -46,20 +54,23 @@ export const getPluginDetailLinkInMarketplace = (plugin: Plugin) => {
return `/plugins/${plugin.org}/${plugin.name}` return `/plugins/${plugin.org}/${plugin.name}`
} }
export const getMarketplacePluginsByCollectionId = async (collectionId: string, query?: CollectionsAndPluginsSearchParams) => { export const getMarketplacePluginsByCollectionId = async (
let plugins: Plugin[] collectionId: string,
query?: CollectionsAndPluginsSearchParams,
options?: MarketplaceFetchOptions,
) => {
let plugins: Plugin[] = []
try { try {
const url = `${MARKETPLACE_API_PREFIX}/collections/${collectionId}/plugins` const url = `${MARKETPLACE_API_PREFIX}/collections/${collectionId}/plugins`
const headers = new Headers({ const headers = getMarketplaceHeaders()
'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0',
})
const marketplaceCollectionPluginsData = await globalThis.fetch( const marketplaceCollectionPluginsData = await globalThis.fetch(
url, url,
{ {
cache: 'no-store', cache: 'no-store',
method: 'POST', method: 'POST',
headers, headers,
signal: options?.signal,
body: JSON.stringify({ body: JSON.stringify({
category: query?.category, category: query?.category,
exclude: query?.exclude, exclude: query?.exclude,
@ -68,9 +79,7 @@ export const getMarketplacePluginsByCollectionId = async (collectionId: string,
}, },
) )
const marketplaceCollectionPluginsDataJson = await marketplaceCollectionPluginsData.json() const marketplaceCollectionPluginsDataJson = await marketplaceCollectionPluginsData.json()
plugins = marketplaceCollectionPluginsDataJson.data.plugins.map((plugin: Plugin) => { plugins = (marketplaceCollectionPluginsDataJson.data.plugins || []).map((plugin: Plugin) => getFormattedPlugin(plugin))
return getFormattedPlugin(plugin)
})
} }
// eslint-disable-next-line unused-imports/no-unused-vars // eslint-disable-next-line unused-imports/no-unused-vars
catch (e) { catch (e) {
@ -80,23 +89,31 @@ export const getMarketplacePluginsByCollectionId = async (collectionId: string,
return plugins return plugins
} }
export const getMarketplaceCollectionsAndPlugins = async (query?: CollectionsAndPluginsSearchParams) => { export const getMarketplaceCollectionsAndPlugins = async (
let marketplaceCollections = [] as MarketplaceCollection[] query?: CollectionsAndPluginsSearchParams,
let marketplaceCollectionPluginsMap = {} as Record<string, Plugin[]> options?: MarketplaceFetchOptions,
) => {
let marketplaceCollections: MarketplaceCollection[] = []
let marketplaceCollectionPluginsMap: Record<string, Plugin[]> = {}
try { try {
let marketplaceUrl = `${MARKETPLACE_API_PREFIX}/collections?page=1&page_size=100` let marketplaceUrl = `${MARKETPLACE_API_PREFIX}/collections?page=1&page_size=100`
if (query?.condition) if (query?.condition)
marketplaceUrl += `&condition=${query.condition}` marketplaceUrl += `&condition=${query.condition}`
if (query?.type) if (query?.type)
marketplaceUrl += `&type=${query.type}` marketplaceUrl += `&type=${query.type}`
const headers = new Headers({ const headers = getMarketplaceHeaders()
'X-Dify-Version': !IS_MARKETPLACE ? APP_VERSION : '999.0.0', const marketplaceCollectionsData = await globalThis.fetch(
}) marketplaceUrl,
const marketplaceCollectionsData = await globalThis.fetch(marketplaceUrl, { headers, cache: 'no-store' }) {
headers,
cache: 'no-store',
signal: options?.signal,
},
)
const marketplaceCollectionsDataJson = await marketplaceCollectionsData.json() const marketplaceCollectionsDataJson = await marketplaceCollectionsData.json()
marketplaceCollections = marketplaceCollectionsDataJson.data.collections marketplaceCollections = marketplaceCollectionsDataJson.data.collections || []
await Promise.all(marketplaceCollections.map(async (collection: MarketplaceCollection) => { await Promise.all(marketplaceCollections.map(async (collection: MarketplaceCollection) => {
const plugins = await getMarketplacePluginsByCollectionId(collection.name, query) const plugins = await getMarketplacePluginsByCollectionId(collection.name, query, options)
marketplaceCollectionPluginsMap[collection.name] = plugins marketplaceCollectionPluginsMap[collection.name] = plugins
})) }))

View File

@ -15,32 +15,10 @@ import type {
OffsetOptions, OffsetOptions,
Placement, Placement,
} from '@floating-ui/react' } from '@floating-ui/react'
import useSWRInfinite from 'swr/infinite' import { useInfiniteAppList } from '@/service/use-apps'
import { fetchAppList } from '@/service/apps'
import type { AppListResponse } from '@/models/app'
const PAGE_SIZE = 20 const PAGE_SIZE = 20
const getKey = (
pageIndex: number,
previousPageData: AppListResponse,
searchText: string,
) => {
if (pageIndex === 0 || (previousPageData && previousPageData.has_more)) {
const params: any = {
url: 'apps',
params: {
page: pageIndex + 1,
limit: PAGE_SIZE,
name: searchText,
},
}
return params
}
return null
}
type Props = { type Props = {
value?: { value?: {
app_id: string app_id: string
@ -72,30 +50,32 @@ const AppSelector: FC<Props> = ({
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const [isLoadingMore, setIsLoadingMore] = useState(false) const [isLoadingMore, setIsLoadingMore] = useState(false)
const { data, isLoading, setSize } = useSWRInfinite( const {
(pageIndex: number, previousPageData: AppListResponse) => getKey(pageIndex, previousPageData, searchText), data,
fetchAppList, isLoading,
{ isFetchingNextPage,
revalidateFirstPage: true, fetchNextPage,
shouldRetryOnError: false, hasNextPage,
dedupingInterval: 500, } = useInfiniteAppList({
errorRetryCount: 3, page: 1,
}, limit: PAGE_SIZE,
) name: searchText,
})
const pages = data?.pages ?? []
const displayedApps = useMemo(() => { const displayedApps = useMemo(() => {
if (!data) return [] if (!pages.length) return []
return data.flatMap(({ data: apps }) => apps) return pages.flatMap(({ data: apps }) => apps)
}, [data]) }, [pages])
const hasMore = data?.at(-1)?.has_more ?? true const hasMore = hasNextPage ?? true
const handleLoadMore = useCallback(async () => { const handleLoadMore = useCallback(async () => {
if (isLoadingMore || !hasMore) return if (isLoadingMore || isFetchingNextPage || !hasMore) return
setIsLoadingMore(true) setIsLoadingMore(true)
try { try {
await setSize((size: number) => size + 1) await fetchNextPage()
} }
finally { finally {
// Add a small delay to ensure state updates are complete // Add a small delay to ensure state updates are complete
@ -103,7 +83,7 @@ const AppSelector: FC<Props> = ({
setIsLoadingMore(false) setIsLoadingMore(false)
}, 300) }, 300)
} }
}, [isLoadingMore, hasMore, setSize]) }, [isLoadingMore, isFetchingNextPage, hasMore, fetchNextPage])
const handleTriggerClick = () => { const handleTriggerClick = () => {
if (disabled) return if (disabled) return
@ -185,7 +165,7 @@ const AppSelector: FC<Props> = ({
onSelect={handleSelectApp} onSelect={handleSelectApp}
scope={scope || 'all'} scope={scope || 'all'}
apps={displayedApps} apps={displayedApps}
isLoading={isLoading || isLoadingMore} isLoading={isLoading || isLoadingMore || isFetchingNextPage}
hasMore={hasMore} hasMore={hasMore}
onLoadMore={handleLoadMore} onLoadMore={handleLoadMore}
searchText={searchText} searchText={searchText}

View File

@ -28,9 +28,9 @@ import {
RiHardDrive3Line, RiHardDrive3Line,
} from '@remixicon/react' } from '@remixicon/react'
import { useBoolean } from 'ahooks' import { useBoolean } from 'ahooks'
import { useTheme } from 'next-themes'
import React, { useCallback, useMemo, useState } from 'react' import React, { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useTheme from '@/hooks/use-theme'
import Verified from '../base/badges/verified' import Verified from '../base/badges/verified'
import { AutoUpdateLine } from '../../base/icons/src/vender/system' import { AutoUpdateLine } from '../../base/icons/src/vender/system'
import DeprecationNotice from '../base/deprecation-notice' import DeprecationNotice from '../base/deprecation-notice'
@ -86,7 +86,7 @@ const DetailHeader = ({
alternative_plugin_id, alternative_plugin_id,
} = detail } = detail
const { author, category, name, label, description, icon, verified, tool } = detail.declaration || detail const { author, category, name, label, description, icon, icon_dark, verified, tool } = detail.declaration || detail
const isTool = category === PluginCategoryEnum.tool const isTool = category === PluginCategoryEnum.tool
const providerBriefInfo = tool?.identity const providerBriefInfo = tool?.identity
const providerKey = `${plugin_id}/${providerBriefInfo?.name}` const providerKey = `${plugin_id}/${providerBriefInfo?.name}`
@ -109,6 +109,11 @@ const DetailHeader = ({
return false return false
}, [isFromMarketplace, latest_version, version]) }, [isFromMarketplace, latest_version, version])
const iconFileName = theme === 'dark' && icon_dark ? icon_dark : icon
const iconSrc = iconFileName
? (iconFileName.startsWith('http') ? iconFileName : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${iconFileName}`)
: ''
const detailUrl = useMemo(() => { const detailUrl = useMemo(() => {
if (isFromGitHub) if (isFromGitHub)
return `https://github.com/${meta!.repo}` return `https://github.com/${meta!.repo}`
@ -214,7 +219,7 @@ const DetailHeader = ({
<div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3', isReadmeView && 'border-b-0 bg-transparent p-0')}> <div className={cn('shrink-0 border-b border-divider-subtle bg-components-panel-bg p-4 pb-3', isReadmeView && 'border-b-0 bg-transparent p-0')}>
<div className="flex"> <div className="flex">
<div className={cn('overflow-hidden rounded-xl border border-components-panel-border-subtle', isReadmeView && 'bg-components-panel-bg')}> <div className={cn('overflow-hidden rounded-xl border border-components-panel-border-subtle', isReadmeView && 'bg-components-panel-bg')}>
<Icon src={icon.startsWith('http') ? icon : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${icon}`} /> <Icon src={iconSrc} />
</div> </div>
<div className="ml-3 w-0 grow"> <div className="ml-3 w-0 grow">
<div className="flex h-5 items-center"> <div className="flex h-5 items-center">

View File

@ -14,11 +14,11 @@ import {
RiHardDrive3Line, RiHardDrive3Line,
RiLoginCircleLine, RiLoginCircleLine,
} from '@remixicon/react' } from '@remixicon/react'
import { useTheme } from 'next-themes'
import type { FC } from 'react' import type { FC } from 'react'
import React, { useCallback, useMemo } from 'react' import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { gte } from 'semver' import { gte } from 'semver'
import useTheme from '@/hooks/use-theme'
import Verified from '../base/badges/verified' import Verified from '../base/badges/verified'
import Badge from '../../base/badge' import Badge from '../../base/badge'
import { Github } from '../../base/icons/src/public/common' import { Github } from '../../base/icons/src/public/common'
@ -58,7 +58,7 @@ const PluginItem: FC<Props> = ({
status, status,
deprecated_reason, deprecated_reason,
} = plugin } = plugin
const { category, author, name, label, description, icon, verified, meta: declarationMeta } = plugin.declaration const { category, author, name, label, description, icon, icon_dark, verified, meta: declarationMeta } = plugin.declaration
const orgName = useMemo(() => { const orgName = useMemo(() => {
return [PluginSource.github, PluginSource.marketplace].includes(source) ? author : '' return [PluginSource.github, PluginSource.marketplace].includes(source) ? author : ''
@ -84,6 +84,10 @@ const PluginItem: FC<Props> = ({
const title = getValueFromI18nObject(label) const title = getValueFromI18nObject(label)
const descriptionText = getValueFromI18nObject(description) const descriptionText = getValueFromI18nObject(description)
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures) const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const iconFileName = theme === 'dark' && icon_dark ? icon_dark : icon
const iconSrc = iconFileName
? (iconFileName.startsWith('http') ? iconFileName : `${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${iconFileName}`)
: ''
return ( return (
<div <div
@ -105,7 +109,7 @@ const PluginItem: FC<Props> = ({
<div className='flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl border-[1px] border-components-panel-border-subtle'> <div className='flex h-10 w-10 items-center justify-center overflow-hidden rounded-xl border-[1px] border-components-panel-border-subtle'>
<img <img
className='h-full w-full' className='h-full w-full'
src={`${API_PREFIX}/workspaces/current/plugin/icon?tenant_id=${tenant_id}&filename=${icon}`} src={iconSrc}
alt={`plugin-${plugin_unique_identifier}-logo`} alt={`plugin-${plugin_unique_identifier}-logo`}
/> />
</div> </div>

View File

@ -71,6 +71,7 @@ export type PluginDeclaration = {
version: string version: string
author: string author: string
icon: string icon: string
icon_dark?: string
name: string name: string
category: PluginCategoryEnum category: PluginCategoryEnum
label: Record<Locale, string> label: Record<Locale, string>
@ -248,7 +249,7 @@ export type PluginInfoFromMarketPlace = {
} }
export type Plugin = { export type Plugin = {
type: 'plugin' | 'bundle' | 'model' | 'extension' | 'tool' | 'agent_strategy' type: 'plugin' | 'bundle' | 'model' | 'extension' | 'tool' | 'agent_strategy' | 'datasource' | 'trigger'
org: string org: string
author?: string author?: string
name: string name: string
@ -257,6 +258,7 @@ export type Plugin = {
latest_version: string latest_version: string
latest_package_identifier: string latest_package_identifier: string
icon: string icon: string
icon_dark?: string
verified: boolean verified: boolean
label: Record<Locale, string> label: Record<Locale, string>
brief: Record<Locale, string> brief: Record<Locale, string>

View File

@ -0,0 +1,22 @@
'use client'
import { scan } from 'react-scan'
import { useEffect } from 'react'
import { IS_DEV } from '@/config'
export function ReactScan() {
useEffect(() => {
if (IS_DEV) {
scan({
enabled: true,
// HACK: react-scan's getIsProduction() incorrectly detects Next.js dev as production
// because Next.js devtools overlay uses production React build
// Issue: https://github.com/aidenybai/react-scan/issues/402
// TODO: remove this option after upstream fix
dangerouslyForceRunInProduction: true,
})
}
}, [])
return null
}

View File

@ -3,12 +3,12 @@ import {
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState,
} from 'react' } from 'react'
import { import {
useMarketplaceCollectionsAndPlugins, useMarketplaceCollectionsAndPlugins,
useMarketplacePlugins, useMarketplacePlugins,
} from '@/app/components/plugins/marketplace/hooks' } from '@/app/components/plugins/marketplace/hooks'
import { SCROLL_BOTTOM_THRESHOLD } from '@/app/components/plugins/marketplace/constants'
import { PluginCategoryEnum } from '@/app/components/plugins/types' import { PluginCategoryEnum } from '@/app/components/plugins/types'
import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils' import { getMarketplaceListCondition } from '@/app/components/plugins/marketplace/utils'
import { useAllToolProviders } from '@/service/use-tools' import { useAllToolProviders } from '@/service/use-tools'
@ -31,10 +31,10 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
queryPlugins, queryPlugins,
queryPluginsWithDebounced, queryPluginsWithDebounced,
isLoading: isPluginsLoading, isLoading: isPluginsLoading,
total: pluginsTotal, fetchNextPage,
hasNextPage,
page: pluginsPage,
} = useMarketplacePlugins() } = useMarketplacePlugins()
const [page, setPage] = useState(1)
const pageRef = useRef(page)
const searchPluginTextRef = useRef(searchPluginText) const searchPluginTextRef = useRef(searchPluginText)
const filterPluginTagsRef = useRef(filterPluginTags) const filterPluginTagsRef = useRef(filterPluginTags)
@ -44,9 +44,6 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
}, [searchPluginText, filterPluginTags]) }, [searchPluginText, filterPluginTags])
useEffect(() => { useEffect(() => {
if ((searchPluginText || filterPluginTags.length) && isSuccess) { if ((searchPluginText || filterPluginTags.length) && isSuccess) {
setPage(1)
pageRef.current = 1
if (searchPluginText) { if (searchPluginText) {
queryPluginsWithDebounced({ queryPluginsWithDebounced({
category: PluginCategoryEnum.tool, category: PluginCategoryEnum.tool,
@ -54,7 +51,6 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
tags: filterPluginTags, tags: filterPluginTags,
exclude, exclude,
type: 'plugin', type: 'plugin',
page: pageRef.current,
}) })
return return
} }
@ -64,7 +60,6 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
tags: filterPluginTags, tags: filterPluginTags,
exclude, exclude,
type: 'plugin', type: 'plugin',
page: pageRef.current,
}) })
} }
else { else {
@ -87,24 +82,13 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
scrollHeight, scrollHeight,
clientHeight, clientHeight,
} = target } = target
if (scrollTop + clientHeight >= scrollHeight - 5 && scrollTop > 0) { if (scrollTop + clientHeight >= scrollHeight - SCROLL_BOTTOM_THRESHOLD && scrollTop > 0) {
const searchPluginText = searchPluginTextRef.current const searchPluginText = searchPluginTextRef.current
const filterPluginTags = filterPluginTagsRef.current const filterPluginTags = filterPluginTagsRef.current
if (pluginsTotal && plugins && pluginsTotal > plugins.length && (!!searchPluginText || !!filterPluginTags.length)) { if (hasNextPage && (!!searchPluginText || !!filterPluginTags.length))
setPage(pageRef.current + 1) fetchNextPage()
pageRef.current++
queryPlugins({
category: PluginCategoryEnum.tool,
query: searchPluginText,
tags: filterPluginTags,
exclude,
type: 'plugin',
page: pageRef.current,
})
}
} }
}, [exclude, plugins, pluginsTotal, queryPlugins]) }, [exclude, fetchNextPage, hasNextPage, plugins, queryPlugins])
return { return {
isLoading: isLoading || isPluginsLoading, isLoading: isLoading || isPluginsLoading,
@ -112,6 +96,6 @@ export const useMarketplace = (searchPluginText: string, filterPluginTags: strin
marketplaceCollectionPluginsMap, marketplaceCollectionPluginsMap,
plugins, plugins,
handleScroll, handleScroll,
page, page: Math.max(pluginsPage || 0, 1),
} }
} }

View File

@ -1,154 +0,0 @@
const tools = [
{
author: 'Novice',
name: 'NOTION_ADD_PAGE_CONTENT',
label: {
en_US: 'NOTION_ADD_PAGE_CONTENT',
zh_Hans: 'NOTION_ADD_PAGE_CONTENT',
pt_BR: 'NOTION_ADD_PAGE_CONTENT',
ja_JP: 'NOTION_ADD_PAGE_CONTENT',
},
description: {
en_US: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)',
zh_Hans: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)',
pt_BR: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)',
ja_JP: 'Adds a single content block to a notion page. multiple calls needed for multiple blocks. note: only supports adding to notion pages. blocks that can contain children: - page (any block type) - toggle (any nested content) - to-do (nested to-dos/blocks) - bulleted list (nested lists/blocks) - numbered list (nested lists/blocks) - callout (child blocks) - quote (nested blocks)',
},
parameters: [
{
name: 'after',
label: {
en_US: 'after',
zh_Hans: 'after',
pt_BR: 'after',
ja_JP: 'after',
},
placeholder: null,
scope: null,
auto_generate: null,
template: null,
required: false,
default: null,
min: null,
max: null,
precision: null,
options: [],
type: 'string',
human_description: {
en_US: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
zh_Hans: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
pt_BR: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
ja_JP: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
},
form: 'llm',
llm_description: 'The ID of the existing block that the new block should be appended after. If not provided, content will be appended at the end of the page.',
},
{
name: 'content_block',
label: {
en_US: 'content_block',
zh_Hans: 'content_block',
pt_BR: 'content_block',
ja_JP: 'content_block',
},
placeholder: null,
scope: null,
auto_generate: null,
template: null,
required: false,
default: null,
min: null,
max: null,
precision: null,
options: [],
type: 'string',
human_description: {
en_US: 'Child content to append to a page.',
zh_Hans: 'Child content to append to a page.',
pt_BR: 'Child content to append to a page.',
ja_JP: 'Child content to append to a page.',
},
form: 'llm',
llm_description: 'Child content to append to a page.',
},
{
name: 'parent_block_id',
label: {
en_US: 'parent_block_id',
zh_Hans: 'parent_block_id',
pt_BR: 'parent_block_id',
ja_JP: 'parent_block_id',
},
placeholder: null,
scope: null,
auto_generate: null,
template: null,
required: false,
default: null,
min: null,
max: null,
precision: null,
options: [],
type: 'string',
human_description: {
en_US: 'The ID of the page which the children will be added.',
zh_Hans: 'The ID of the page which the children will be added.',
pt_BR: 'The ID of the page which the children will be added.',
ja_JP: 'The ID of the page which the children will be added.',
},
form: 'llm',
llm_description: 'The ID of the page which the children will be added.',
},
],
labels: [],
output_schema: null,
},
]
export const listData = [
{
id: 'fdjklajfkljadslf111',
author: 'KVOJJJin',
name: 'GOGOGO',
icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US',
server_url: 'https://mcp.composio.dev/notion/****/abc',
type: 'mcp',
is_team_authorization: true,
tools,
update_elapsed_time: 1744793369,
label: {
en_US: 'GOGOGO',
zh_Hans: 'GOGOGO',
},
},
{
id: 'fdjklajfkljadslf222',
author: 'KVOJJJin',
name: 'GOGOGO2',
icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US',
server_url: 'https://mcp.composio.dev/notion/****/abc',
type: 'mcp',
is_team_authorization: false,
tools: [],
update_elapsed_time: 1744793369,
label: {
en_US: 'GOGOGO2',
zh_Hans: 'GOGOGO2',
},
},
{
id: 'fdjklajfkljadslf333',
author: 'KVOJJJin',
name: 'GOGOGO3',
icon: 'https://cloud.dify.dev/console/api/workspaces/694cc430-fa36-4458-86a0-4a98c09c4684/model-providers/langgenius/openai/openai/icon_small/en_US',
server_url: 'https://mcp.composio.dev/notion/****/abc',
type: 'mcp',
is_team_authorization: true,
tools,
update_elapsed_time: 1744793369,
label: {
en_US: 'GOGOGO3',
zh_Hans: 'GOGOGO3',
},
},
]

View File

@ -49,6 +49,7 @@ export type Collection = {
author: string author: string
description: TypeWithI18N description: TypeWithI18N
icon: string | Emoji icon: string | Emoji
icon_dark?: string | Emoji
label: TypeWithI18N label: TypeWithI18N
type: CollectionType | string type: CollectionType | string
team_credentials: Record<string, any> team_credentials: Record<string, any>

View File

@ -1,6 +1,6 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import React from 'react' import React, { useMemo } from 'react'
import type { ToolWithProvider } from '../../types' import type { ToolWithProvider } from '../../types'
import { BlockEnum } from '../../types' import { BlockEnum } from '../../types'
import type { ToolDefaultValue } from '../types' import type { ToolDefaultValue } from '../types'
@ -10,9 +10,13 @@ import { useGetLanguage } from '@/context/i18n'
import BlockIcon from '../../block-icon' import BlockIcon from '../../block-icon'
import cn from '@/utils/classnames' import cn from '@/utils/classnames'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { basePath } from '@/utils/var' import { basePath } from '@/utils/var'
const normalizeProviderIcon = (icon: ToolWithProvider['icon']) => { const normalizeProviderIcon = (icon?: ToolWithProvider['icon']) => {
if (!icon)
return icon
if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`)) if (typeof icon === 'string' && basePath && icon.startsWith('/') && !icon.startsWith(`${basePath}/`))
return `${basePath}${icon}` return `${basePath}${icon}`
return icon return icon
@ -36,6 +40,20 @@ const ToolItem: FC<Props> = ({
const { t } = useTranslation() const { t } = useTranslation()
const language = useGetLanguage() const language = useGetLanguage()
const { theme } = useTheme()
const normalizedIcon = useMemo<ToolWithProvider['icon']>(() => {
return normalizeProviderIcon(provider.icon) ?? provider.icon
}, [provider.icon])
const normalizedIconDark = useMemo(() => {
if (!provider.icon_dark)
return undefined
return normalizeProviderIcon(provider.icon_dark) ?? provider.icon_dark
}, [provider.icon_dark])
const providerIcon = useMemo(() => {
if (theme === Theme.dark && normalizedIconDark)
return normalizedIconDark
return normalizedIcon
}, [theme, normalizedIcon, normalizedIconDark])
return ( return (
<Tooltip <Tooltip
@ -49,7 +67,7 @@ const ToolItem: FC<Props> = ({
size='md' size='md'
className='mb-2' className='mb-2'
type={BlockEnum.Tool} type={BlockEnum.Tool}
toolIcon={provider.icon} toolIcon={providerIcon}
/> />
<div className='mb-1 text-sm leading-5 text-text-primary'>{payload.label[language]}</div> <div className='mb-1 text-sm leading-5 text-text-primary'>{payload.label[language]}</div>
<div className='text-xs leading-[18px] text-text-secondary'>{payload.description[language]}</div> <div className='text-xs leading-[18px] text-text-secondary'>{payload.description[language]}</div>
@ -73,7 +91,8 @@ const ToolItem: FC<Props> = ({
provider_name: provider.name, provider_name: provider.name,
plugin_id: provider.plugin_id, plugin_id: provider.plugin_id,
plugin_unique_identifier: provider.plugin_unique_identifier, plugin_unique_identifier: provider.plugin_unique_identifier,
provider_icon: normalizeProviderIcon(provider.icon), provider_icon: normalizedIcon,
provider_icon_dark: normalizedIconDark,
tool_name: payload.name, tool_name: payload.name,
tool_label: payload.label[language], tool_label: payload.label[language],
tool_description: payload.description[language], tool_description: payload.description[language],

Some files were not shown because too many files have changed in this diff Show More