mirror of https://github.com/langgenius/dify.git
Merge branch 'feat/end-user-oauth' into deploy/end-user-oauth
This commit is contained in:
commit
b8236b29f3
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -324,10 +324,13 @@ class AppListApi(Resource):
|
||||||
NodeType.TRIGGER_PLUGIN,
|
NodeType.TRIGGER_PLUGIN,
|
||||||
}
|
}
|
||||||
for workflow in draft_workflows:
|
for workflow in draft_workflows:
|
||||||
|
try:
|
||||||
for _, node_data in workflow.walk_nodes():
|
for _, node_data in workflow.walk_nodes():
|
||||||
if node_data.get("type") in trigger_node_types:
|
if node_data.get("type") in trigger_node_types:
|
||||||
draft_trigger_app_ids.add(str(workflow.app_id))
|
draft_trigger_app_ids.add(str(workflow.app_id))
|
||||||
break
|
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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
|
||||||
stmt = select(ApiToken).where(ApiToken.token == auth_token, ApiToken.type == scope)
|
|
||||||
api_token = session.scalar(stmt)
|
|
||||||
if not api_token:
|
if not api_token:
|
||||||
raise Unauthorized("Access token is invalid")
|
raise Unauthorized("Access token is invalid")
|
||||||
else:
|
|
||||||
session.commit()
|
|
||||||
|
|
||||||
return api_token
|
return api_token
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,6 +291,7 @@ class WorkflowResponseConverter:
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
if event.node_type == NodeType.TOOL:
|
if event.node_type == NodeType.TOOL:
|
||||||
response.data.extras["icon"] = ToolManager.get_tool_icon(
|
response.data.extras["icon"] = ToolManager.get_tool_icon(
|
||||||
tenant_id=self._application_generate_entity.app_config.tenant_id,
|
tenant_id=self._application_generate_entity.app_config.tenant_id,
|
||||||
|
|
@ -309,6 +312,9 @@ class WorkflowResponseConverter:
|
||||||
self._application_generate_entity.app_config.tenant_id,
|
self._application_generate_entity.app_config.tenant_id,
|
||||||
event.provider_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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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.")
|
||||||
|
|
|
||||||
|
|
@ -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,25 +224,29 @@ 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_stmt = select(Account).where(Account.id == user_id)
|
||||||
|
user = db.session.scalar(user_stmt)
|
||||||
|
if user:
|
||||||
user.current_tenant = tenant
|
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:
|
||||||
"""
|
"""
|
||||||
get the workflow by app id and version
|
get the workflow by app id and version
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,6 +1594,7 @@ 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}"
|
||||||
|
try:
|
||||||
with redis_client.lock(lock_name, timeout=600):
|
with redis_client.lock(lock_name, timeout=600):
|
||||||
assert dataset_process_rule
|
assert dataset_process_rule
|
||||||
position = DocumentService.get_documents_position(dataset.id)
|
position = DocumentService.get_documents_position(dataset.id)
|
||||||
|
|
@ -1760,6 +1762,8 @@ class DocumentService:
|
||||||
DocumentIndexingTaskProxy(dataset.tenant_id, dataset.id, document_ids).delay()
|
DocumentIndexingTaskProxy(dataset.tenant_id, dataset.id, document_ids).delay()
|
||||||
if duplicate_document_ids:
|
if duplicate_document_ids:
|
||||||
duplicate_document_indexing_task.delay(dataset.id, duplicate_document_ids)
|
duplicate_document_indexing_task.delay(dataset.id, duplicate_document_ids)
|
||||||
|
except LockNotOwnedError:
|
||||||
|
pass
|
||||||
|
|
||||||
return documents, batch
|
return documents, batch
|
||||||
|
|
||||||
|
|
@ -2699,6 +2703,7 @@ 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}"
|
||||||
|
try:
|
||||||
with redis_client.lock(lock_name, timeout=600):
|
with redis_client.lock(lock_name, timeout=600):
|
||||||
max_position = (
|
max_position = (
|
||||||
db.session.query(func.max(DocumentSegment.position))
|
db.session.query(func.max(DocumentSegment.position))
|
||||||
|
|
@ -2733,7 +2738,9 @@ class SegmentService:
|
||||||
|
|
||||||
# save vector index
|
# save vector index
|
||||||
try:
|
try:
|
||||||
VectorService.create_segments_vector([args["keywords"]], [segment_document], dataset, document.doc_form)
|
VectorService.create_segments_vector(
|
||||||
|
[args["keywords"]], [segment_document], dataset, document.doc_form
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("create segment index failed")
|
logger.exception("create segment index failed")
|
||||||
segment_document.enabled = False
|
segment_document.enabled = False
|
||||||
|
|
@ -2743,6 +2750,8 @@ class SegmentService:
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
segment = db.session.query(DocumentSegment).where(DocumentSegment.id == segment_document.id).first()
|
segment = db.session.query(DocumentSegment).where(DocumentSegment.id == segment_document.id).first()
|
||||||
return segment
|
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,6 +2760,7 @@ 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
|
||||||
|
try:
|
||||||
with redis_client.lock(lock_name, timeout=600):
|
with redis_client.lock(lock_name, timeout=600):
|
||||||
embedding_model = None
|
embedding_model = None
|
||||||
if dataset.indexing_technique == "high_quality":
|
if dataset.indexing_technique == "high_quality":
|
||||||
|
|
@ -2819,7 +2829,9 @@ class SegmentService:
|
||||||
db.session.add(document)
|
db.session.add(document)
|
||||||
try:
|
try:
|
||||||
# save vector index
|
# save vector index
|
||||||
VectorService.create_segments_vector(keywords_list, pre_segment_data_list, dataset, document.doc_form)
|
VectorService.create_segments_vector(
|
||||||
|
keywords_list, pre_segment_data_list, dataset, document.doc_form
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.exception("create segment index failed")
|
logger.exception("create segment index failed")
|
||||||
for segment_document in segment_data_list:
|
for segment_document in segment_data_list:
|
||||||
|
|
@ -2829,6 +2841,8 @@ class SegmentService:
|
||||||
segment_document.error = str(e)
|
segment_document.error = str(e)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return segment_data_list
|
return segment_data_list
|
||||||
|
except LockNotOwnedError:
|
||||||
|
pass
|
||||||
|
|
||||||
@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):
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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=[
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
4630
api/uv.lock
4630
api/uv.lock
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||||
|
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} />}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</Panel >
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
{isShowSettingTool && (
|
{isShowSettingTool && (
|
||||||
<SettingBuiltInTool
|
<SettingBuiltInTool
|
||||||
toolName={currentTool?.tool_name as string}
|
toolName={currentTool?.tool_name as string}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default } from './AmplitudeProvider'
|
||||||
|
export { resetUser, setUserId, setUserProperties, trackEvent } from './utils'
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
|
@ -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'
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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'>
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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('@', '') })}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'>
|
||||||
|
|
|
||||||
|
|
@ -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()}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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)} />
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -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: '',
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,5 @@ export const DEFAULT_SORT = {
|
||||||
sortBy: 'install_count',
|
sortBy: 'install_count',
|
||||||
sortOrder: 'DESC',
|
sortOrder: 'DESC',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const SCROLL_BOTTOM_THRESHOLD = 100
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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])
|
|
||||||
}
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)}>
|
)}>
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
}))
|
}))
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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, fetchNextPage, hasNextPage, plugins, queryPlugins])
|
||||||
}, [exclude, plugins, pluginsTotal, 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),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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
Loading…
Reference in New Issue