Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4743360620 | ||
|
|
33a61e32ed | ||
|
|
2a7968d210 | ||
|
|
2a0661a769 | ||
|
|
2f7480c900 | ||
|
|
00fe96325d | ||
|
|
037b7f6b5f | ||
|
|
a3c18c561e | ||
|
|
3e6c3ace7b | ||
|
|
f26812310f | ||
|
|
0a9f1d58bd | ||
|
|
a1a2abb533 | ||
|
|
3f3422c37f | ||
|
|
ccbb556a8b | ||
|
|
ad2094c96f | ||
|
|
0f77b623d8 | ||
|
|
87e7e1ca2d | ||
|
|
796328e9f2 | ||
|
|
526059faeb | ||
|
|
272bdeb555 |
@@ -729,7 +729,6 @@ OTEL_MAX_EXPORT_BATCH_SIZE=512
|
||||
OTEL_METRIC_EXPORT_INTERVAL=60000
|
||||
OTEL_BATCH_EXPORT_TIMEOUT=10000
|
||||
OTEL_METRIC_EXPORT_TIMEOUT=30000
|
||||
|
||||
# Prevent Clickjacking
|
||||
ALLOW_EMBED=false
|
||||
|
||||
|
||||
@@ -816,6 +816,41 @@ class UpdateConfig(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
class CommunityTelemetryConfig(BaseSettings):
|
||||
"""
|
||||
Configuration for anonymous self-hosted community telemetry.
|
||||
"""
|
||||
|
||||
DISABLE_TELEMETRY: bool = Field(
|
||||
description="Disable anonymous community telemetry",
|
||||
default=False,
|
||||
)
|
||||
DO_NOT_TRACK: bool = Field(
|
||||
description="Respect the standard do-not-track opt-out signal for telemetry",
|
||||
default=False,
|
||||
)
|
||||
TELEMETRY_ENDPOINT: str = Field(
|
||||
description="Endpoint for anonymous community telemetry events",
|
||||
default="https://otel.dify.ai/v1/events",
|
||||
)
|
||||
TELEMETRY_FALLBACK_ENDPOINT: str = Field(
|
||||
description="Fallback endpoint for anonymous community telemetry events",
|
||||
default="https://otel.dify.cn/v1/events",
|
||||
)
|
||||
TELEMETRY_TIMEOUT_SECONDS: PositiveInt = Field(
|
||||
description="HTTP timeout in seconds for anonymous community telemetry requests",
|
||||
default=3,
|
||||
)
|
||||
TELEMETRY_HEARTBEAT_INTERVAL_MINUTES: PositiveInt = Field(
|
||||
description="Celery beat interval in minutes for checking whether heartbeat telemetry is due",
|
||||
default=30,
|
||||
)
|
||||
CI: bool = Field(
|
||||
description="Whether the process is running in CI; telemetry is skipped when true",
|
||||
default=False,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowVariableTruncationConfig(BaseSettings):
|
||||
WORKFLOW_VARIABLE_TRUNCATION_MAX_SIZE: PositiveInt = Field(
|
||||
# 1000 KiB
|
||||
@@ -1594,6 +1629,7 @@ class FeatureConfig(
|
||||
TenantIsolatedTaskQueueConfig,
|
||||
ToolConfig,
|
||||
UpdateConfig,
|
||||
CommunityTelemetryConfig,
|
||||
WorkflowConfig,
|
||||
WorkflowNodeExecutionConfig,
|
||||
WorkspaceConfig,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Callable, Generator
|
||||
from collections.abc import Callable, Generator, Mapping
|
||||
from typing import Any, cast
|
||||
from urllib.parse import unquote
|
||||
|
||||
@@ -23,6 +23,7 @@ from core.plugin.impl.exc import (
|
||||
PluginLLMPollingUnsupportedError,
|
||||
PluginNotFoundError,
|
||||
PluginPermissionDeniedError,
|
||||
PluginRuntimeError,
|
||||
PluginUniqueIdentifierError,
|
||||
)
|
||||
from core.trigger.errors import (
|
||||
@@ -375,6 +376,18 @@ class BasePluginClient:
|
||||
# type `PluginLLMPollingUnsupportedError`.
|
||||
case PluginLLMPollingUnsupportedError.__name__:
|
||||
raise PluginLLMPollingUnsupportedError(description=error_object.get("message"))
|
||||
case PluginRuntimeError.__name__:
|
||||
args = error_object.get("args")
|
||||
lambda_request_id = args.get("request_id") if isinstance(args, Mapping) else None
|
||||
if not isinstance(lambda_request_id, str):
|
||||
lambda_request_id = None
|
||||
runtime_message = error_object.get("message")
|
||||
if not isinstance(runtime_message, str):
|
||||
runtime_message = "Plugin runtime request failed"
|
||||
raise PluginRuntimeError(
|
||||
description=runtime_message,
|
||||
lambda_request_id=lambda_request_id,
|
||||
)
|
||||
case _:
|
||||
raise PluginInvokeError(description=message)
|
||||
case PluginDaemonInternalServerError.__name__:
|
||||
|
||||
@@ -49,6 +49,18 @@ class PluginDaemonBadRequestError(PluginDaemonClientSideError):
|
||||
description: str = "Bad Request"
|
||||
|
||||
|
||||
class PluginRuntimeError(PluginDaemonInternalError):
|
||||
"""A plugin runtime failed before it could return a valid plugin response."""
|
||||
|
||||
lambda_request_id: str | None
|
||||
|
||||
def __init__(self, description: str, lambda_request_id: str | None = None) -> None:
|
||||
self.lambda_request_id = lambda_request_id
|
||||
if lambda_request_id:
|
||||
description = description.replace(f"RequestId: {lambda_request_id} Error: ", "", 1)
|
||||
super().__init__(description)
|
||||
|
||||
|
||||
class PluginInvokeError(PluginDaemonClientSideError, ValueError):
|
||||
description: str = "Invoke Error"
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
import pytz # type: ignore[import-untyped]
|
||||
from celery import Celery, Task
|
||||
from celery.schedules import crontab
|
||||
from celery.signals import beat_init
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from configs import dify_config
|
||||
@@ -36,6 +37,19 @@ class CeleryBeatScheduleEntry(TypedDict):
|
||||
schedule: crontab | timedelta
|
||||
|
||||
|
||||
def _enqueue_initial_community_telemetry_heartbeat(sender: Any, **_: Any) -> None:
|
||||
task_name = "community_telemetry.send_heartbeat"
|
||||
if "community_telemetry_heartbeat" not in sender.app.conf.beat_schedule:
|
||||
return
|
||||
|
||||
task = sender.app.tasks.get(task_name)
|
||||
if task is not None:
|
||||
task.apply_async()
|
||||
|
||||
|
||||
beat_init.connect(_enqueue_initial_community_telemetry_heartbeat, weak=False)
|
||||
|
||||
|
||||
def get_celery_ssl_options() -> CelerySSLOptionsDict | None:
|
||||
"""Get SSL configuration for Celery broker/backend connections."""
|
||||
# Only apply SSL if we're using Redis as broker/backend
|
||||
@@ -260,6 +274,19 @@ def init_app(app: DifyApp) -> Celery:
|
||||
"schedule": timedelta(minutes=dify_config.API_TOKEN_LAST_USED_UPDATE_INTERVAL),
|
||||
}
|
||||
|
||||
if (
|
||||
dify_config.EDITION == "SELF_HOSTED"
|
||||
and not dify_config.ENTERPRISE_ENABLED
|
||||
and not dify_config.DISABLE_TELEMETRY
|
||||
and not dify_config.DO_NOT_TRACK
|
||||
and not dify_config.CI
|
||||
):
|
||||
imports.append("tasks.community_telemetry_task")
|
||||
beat_schedule["community_telemetry_heartbeat"] = {
|
||||
"task": "community_telemetry.send_heartbeat",
|
||||
"schedule": timedelta(minutes=dify_config.TELEMETRY_HEARTBEAT_INTERVAL_MINUTES),
|
||||
}
|
||||
|
||||
if dify_config.ENTERPRISE_ENABLED and dify_config.ENTERPRISE_TELEMETRY_ENABLED:
|
||||
imports.append("tasks.enterprise_telemetry_task")
|
||||
celery_app.conf.update(beat_schedule=beat_schedule, imports=imports)
|
||||
|
||||
@@ -9,6 +9,8 @@ from werkzeug.http import HTTP_STATUS_CODES
|
||||
|
||||
from configs import dify_config
|
||||
from core.errors.error import AppInvokeQuotaExceededError
|
||||
from core.plugin.impl.exc import PluginRuntimeError
|
||||
from extensions.ext_logging import get_request_id
|
||||
from libs.flask_restx_compat import install_swagger_compatibility
|
||||
from libs.token import build_force_logout_cookie_headers
|
||||
|
||||
@@ -100,6 +102,20 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
|
||||
data = {"code": "too_many_requests", "message": str(e), "status": status_code}
|
||||
return _finalize(e, data, status_code), status_code
|
||||
|
||||
def handle_plugin_runtime_error(e: PluginRuntimeError):
|
||||
got_request_exception.send(current_app, exception=e)
|
||||
status_code = 502
|
||||
details = {"request_id": get_request_id()}
|
||||
if e.lambda_request_id:
|
||||
details["lambda_request_id"] = e.lambda_request_id
|
||||
data = {
|
||||
"code": "plugin_runtime_error",
|
||||
"message": e.description,
|
||||
"details": details,
|
||||
"status": status_code,
|
||||
}
|
||||
return _finalize(e, data, status_code), status_code
|
||||
|
||||
def handle_general_exception(e: Exception):
|
||||
got_request_exception.send(current_app, exception=e)
|
||||
|
||||
@@ -121,6 +137,7 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
|
||||
api.errorhandler(HTTPException)(handle_http_exception)
|
||||
api.errorhandler(ValueError)(handle_value_error)
|
||||
api.errorhandler(AppInvokeQuotaExceededError)(handle_quota_exceeded)
|
||||
api.errorhandler(PluginRuntimeError)(handle_plugin_runtime_error)
|
||||
api.errorhandler(Exception)(handle_general_exception)
|
||||
|
||||
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
"""add telemetry fields to dify_setups
|
||||
|
||||
Revision ID: 6f5a9c2d8e1b
|
||||
Revises: d2825e7b9c10
|
||||
Create Date: 2026-07-23 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "6f5a9c2d8e1b"
|
||||
down_revision = "d2825e7b9c10"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("dify_setups", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("instance_id", sa.String(length=255), nullable=True))
|
||||
batch_op.add_column(sa.Column("install_reported_at", sa.DateTime(), nullable=True))
|
||||
batch_op.add_column(sa.Column("last_heartbeat_at", sa.DateTime(), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("dify_setups", schema=None) as batch_op:
|
||||
batch_op.drop_column("last_heartbeat_at")
|
||||
batch_op.drop_column("install_reported_at")
|
||||
batch_op.drop_column("instance_id")
|
||||
@@ -361,6 +361,9 @@ class DifySetup(TypeBase):
|
||||
__table_args__ = (sa.PrimaryKeyConstraint("version", name="dify_setup_pkey"),)
|
||||
|
||||
version: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
instance_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
|
||||
install_reported_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True, default=None)
|
||||
last_heartbeat_at: Mapped[datetime | None] = mapped_column(sa.DateTime, nullable=True, default=None)
|
||||
setup_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
|
||||
@@ -75,6 +75,7 @@ from services.errors.account import (
|
||||
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
|
||||
from services.feature_service import FeatureService
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
from services.telemetry_service import CommunityTelemetryService
|
||||
from tasks.delete_account_task import delete_account_task
|
||||
from tasks.mail_account_deletion_task import send_account_deletion_verification_code
|
||||
from tasks.mail_change_mail_task import (
|
||||
@@ -1961,7 +1962,7 @@ class RegisterService:
|
||||
|
||||
TenantService.create_owner_tenant_if_not_exist(account=account, is_setup=True, session=session)
|
||||
|
||||
dify_setup = DifySetup(version=dify_config.project.version)
|
||||
dify_setup = DifySetup(version=dify_config.project.version, instance_id=str(uuid.uuid4()))
|
||||
session.add(dify_setup)
|
||||
session.commit()
|
||||
except Exception as e:
|
||||
@@ -1974,6 +1975,11 @@ class RegisterService:
|
||||
logger.exception("Setup account failed, email: %s, name: %s", email, name)
|
||||
raise ValueError(f"Setup failed: {e}")
|
||||
|
||||
try:
|
||||
CommunityTelemetryService.report_install(session=session)
|
||||
except Exception:
|
||||
logger.debug("Failed to report install telemetry", exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def register(
|
||||
cls,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import logging
|
||||
import platform
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.model import DifySetup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TelemetryEvent = Literal["install", "heartbeat"]
|
||||
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
|
||||
class CommunityTelemetryService:
|
||||
@classmethod
|
||||
def report_install(cls, *, session: Session) -> bool:
|
||||
setup = cls._get_setup(session)
|
||||
if setup is None:
|
||||
return False
|
||||
|
||||
if setup.instance_id is None:
|
||||
setup.instance_id = str(uuid.uuid4())
|
||||
session.add(setup)
|
||||
session.commit()
|
||||
|
||||
payload = cls._build_payload(setup, "install")
|
||||
if not cls._send_event(payload):
|
||||
return False
|
||||
|
||||
setup.install_reported_at = naive_utc_now()
|
||||
session.add(setup)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def report_heartbeat(cls, *, session: Session, now: datetime | None = None) -> bool:
|
||||
setup = cls._get_setup(session)
|
||||
if setup is None:
|
||||
return False
|
||||
|
||||
if setup.instance_id is None:
|
||||
setup.instance_id = str(uuid.uuid4())
|
||||
session.add(setup)
|
||||
session.commit()
|
||||
|
||||
now = now or naive_utc_now()
|
||||
if not cls._is_heartbeat_due(setup, now):
|
||||
return False
|
||||
|
||||
if setup.install_reported_at is None:
|
||||
cls.report_install(session=session)
|
||||
|
||||
payload = cls._build_payload(setup, "heartbeat")
|
||||
if not cls._send_event(payload):
|
||||
return False
|
||||
|
||||
setup.last_heartbeat_at = now
|
||||
session.add(setup)
|
||||
session.commit()
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def _get_setup(cls, session: Session) -> DifySetup | None:
|
||||
return session.scalar(select(DifySetup).order_by(DifySetup.setup_at.asc()).limit(1))
|
||||
|
||||
@classmethod
|
||||
def _is_enabled(cls) -> bool:
|
||||
return (
|
||||
dify_config.EDITION == "SELF_HOSTED"
|
||||
and not dify_config.ENTERPRISE_ENABLED
|
||||
and not dify_config.DISABLE_TELEMETRY
|
||||
and not dify_config.DO_NOT_TRACK
|
||||
and not dify_config.CI
|
||||
and bool(dify_config.TELEMETRY_ENDPOINT)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _build_payload(cls, setup: DifySetup, event: TelemetryEvent) -> dict[str, str | int]:
|
||||
payload: dict[str, str | int] = {
|
||||
"event": event,
|
||||
"instance_id": setup.instance_id or "",
|
||||
"version": setup.version if event == "install" else dify_config.project.version,
|
||||
"edition": dify_config.EDITION,
|
||||
"deployment_type": "unknown",
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"os": cls._normalize_os(platform.system()),
|
||||
"arch": cls._normalize_arch(platform.machine()),
|
||||
"sent_at": cls._format_datetime(naive_utc_now()),
|
||||
}
|
||||
|
||||
if event == "install":
|
||||
payload["installed_at"] = cls._format_datetime(setup.setup_at)
|
||||
|
||||
return payload
|
||||
|
||||
@classmethod
|
||||
def _send_event(cls, payload: dict[str, str | int]) -> bool:
|
||||
if not cls._is_enabled():
|
||||
return False
|
||||
|
||||
endpoints = [dify_config.TELEMETRY_ENDPOINT]
|
||||
if dify_config.TELEMETRY_FALLBACK_ENDPOINT not in endpoints:
|
||||
endpoints.append(dify_config.TELEMETRY_FALLBACK_ENDPOINT)
|
||||
|
||||
for endpoint in endpoints:
|
||||
if not endpoint:
|
||||
continue
|
||||
|
||||
try:
|
||||
response = httpx.post(
|
||||
endpoint,
|
||||
json=payload,
|
||||
timeout=dify_config.TELEMETRY_TIMEOUT_SECONDS,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return True
|
||||
except httpx.RequestError:
|
||||
logger.debug("Failed to send community telemetry event to %s", endpoint, exc_info=True)
|
||||
except httpx.HTTPStatusError:
|
||||
logger.debug("Community telemetry endpoint returned an error: %s", endpoint, exc_info=True)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def _is_heartbeat_due(cls, setup: DifySetup, now: datetime) -> bool:
|
||||
if setup.instance_id is None:
|
||||
return False
|
||||
|
||||
if setup.last_heartbeat_at is not None and setup.last_heartbeat_at.date() >= now.date():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _format_datetime(value: datetime) -> str:
|
||||
return value.replace(microsecond=0).isoformat() + "Z"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_os(value: str) -> str:
|
||||
os_name = value.lower()
|
||||
if os_name in {"linux", "darwin", "windows"}:
|
||||
return os_name
|
||||
return "unknown"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_arch(value: str) -> str:
|
||||
arch = value.lower()
|
||||
if arch in {"x86_64", "amd64"}:
|
||||
return "amd64"
|
||||
if arch in {"aarch64", "arm64"}:
|
||||
return "arm64"
|
||||
if arch.startswith("arm"):
|
||||
return "arm"
|
||||
if arch in {"i386", "i686", "x86"}:
|
||||
return "386"
|
||||
return "unknown"
|
||||
@@ -0,0 +1,19 @@
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from extensions.ext_database import db
|
||||
from services.telemetry_service import CommunityTelemetryService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name="community_telemetry.send_heartbeat", queue="schedule_executor")
|
||||
def send_community_telemetry_heartbeat() -> None:
|
||||
session_factory = sessionmaker(bind=db.engine, expire_on_commit=False)
|
||||
with session_factory() as session:
|
||||
try:
|
||||
CommunityTelemetryService.report_heartbeat(session=session)
|
||||
except Exception:
|
||||
logger.debug("Failed to process community telemetry heartbeat", exc_info=True)
|
||||
@@ -7,7 +7,7 @@ from pytest_mock import MockerFixture
|
||||
from core.plugin.endpoint.exc import EndpointSetupFailedError
|
||||
from core.plugin.entities.plugin_daemon import PluginDaemonInnerError
|
||||
from core.plugin.impl.base import PLUGIN_DAEMON_MAX_PATH_LENGTH, BasePluginClient
|
||||
from core.plugin.impl.exc import PluginLLMPollingUnsupportedError
|
||||
from core.plugin.impl.exc import PluginLLMPollingUnsupportedError, PluginRuntimeError
|
||||
from core.trigger.errors import (
|
||||
EventIgnoreError,
|
||||
TriggerInvokeError,
|
||||
@@ -175,3 +175,25 @@ class TestBasePluginClientImpl:
|
||||
|
||||
with pytest.raises(PluginLLMPollingUnsupportedError):
|
||||
client._handle_plugin_daemon_error("PluginInvokeError", message)
|
||||
|
||||
def test_handle_plugin_daemon_error_maps_runtime_error_to_typed_exception(self):
|
||||
client = BasePluginClient()
|
||||
lambda_request_id = "45664803-3d3c-4d4f-93fe-e3b19e43092b"
|
||||
message = json.dumps(
|
||||
{
|
||||
"error_type": PluginRuntimeError.__name__,
|
||||
"message": (
|
||||
"Plugin runtime request failed: Runtime.ExitError: "
|
||||
f"RequestId: {lambda_request_id} Error: Runtime exited with error: exit status 1"
|
||||
),
|
||||
"args": {"request_id": lambda_request_id, "status_code": 200},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(PluginRuntimeError) as exc_info:
|
||||
client._handle_plugin_daemon_error("PluginInvokeError", message)
|
||||
|
||||
assert exc_info.value.description == (
|
||||
"Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1"
|
||||
)
|
||||
assert exc_info.value.lambda_request_id == lambda_request_id
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
from extensions.ext_celery import _enqueue_initial_community_telemetry_heartbeat
|
||||
|
||||
|
||||
def test_beat_start_enqueues_community_telemetry_heartbeat() -> None:
|
||||
task = Mock()
|
||||
sender = SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
conf=SimpleNamespace(beat_schedule={"community_telemetry_heartbeat": {}}),
|
||||
tasks={"community_telemetry.send_heartbeat": task},
|
||||
)
|
||||
)
|
||||
|
||||
_enqueue_initial_community_telemetry_heartbeat(sender)
|
||||
|
||||
task.apply_async.assert_called_once_with()
|
||||
|
||||
|
||||
def test_beat_start_skips_community_telemetry_when_not_scheduled() -> None:
|
||||
task = Mock()
|
||||
sender = SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
conf=SimpleNamespace(beat_schedule={}),
|
||||
tasks={"community_telemetry.send_heartbeat": task},
|
||||
)
|
||||
)
|
||||
|
||||
_enqueue_initial_community_telemetry_heartbeat(sender)
|
||||
|
||||
task.apply_async.assert_not_called()
|
||||
|
||||
|
||||
def test_beat_start_skips_community_telemetry_when_task_is_unavailable() -> None:
|
||||
sender = SimpleNamespace(
|
||||
app=SimpleNamespace(
|
||||
conf=SimpleNamespace(beat_schedule={"community_telemetry_heartbeat": {}}),
|
||||
tasks={},
|
||||
)
|
||||
)
|
||||
|
||||
_enqueue_initial_community_telemetry_heartbeat(sender)
|
||||
@@ -4,6 +4,7 @@ from werkzeug.exceptions import BadRequest, Unauthorized
|
||||
|
||||
from constants import COOKIE_NAME_ACCESS_TOKEN, COOKIE_NAME_CSRF_TOKEN, COOKIE_NAME_REFRESH_TOKEN
|
||||
from core.errors.error import AppInvokeQuotaExceededError
|
||||
from core.plugin.impl.exc import PluginRuntimeError
|
||||
from libs.exception import BaseHTTPException
|
||||
from libs.external_api import ExternalApi
|
||||
from libs.rate_limit import _BearerRateLimited
|
||||
@@ -39,6 +40,14 @@ def _create_api_app():
|
||||
def get(self):
|
||||
raise RuntimeError("oops")
|
||||
|
||||
@api.route("/plugin-runtime-error")
|
||||
class PluginRuntime(Resource):
|
||||
def get(self):
|
||||
raise PluginRuntimeError(
|
||||
"Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1",
|
||||
lambda_request_id="lambda-request-id",
|
||||
)
|
||||
|
||||
# Note: We avoid altering default_mediatype to keep normal error paths
|
||||
|
||||
# Special 400 message rewrite
|
||||
@@ -107,6 +116,24 @@ def test_external_api_json_message_and_bad_request_rewrite():
|
||||
assert res.get_json()["message"] == "Invalid JSON payload received or JSON payload is empty."
|
||||
|
||||
|
||||
def test_external_api_plugin_runtime_error(mocker):
|
||||
mocker.patch("libs.external_api.get_request_id", return_value="api-request-id")
|
||||
app = _create_api_app()
|
||||
|
||||
res = app.test_client().get("/api/plugin-runtime-error")
|
||||
|
||||
assert res.status_code == 502
|
||||
assert res.get_json() == {
|
||||
"code": "plugin_runtime_error",
|
||||
"message": "Plugin runtime request failed: Runtime.ExitError: Runtime exited with error: exit status 1",
|
||||
"details": {
|
||||
"request_id": "api-request-id",
|
||||
"lambda_request_id": "lambda-request-id",
|
||||
},
|
||||
"status": 502,
|
||||
}
|
||||
|
||||
|
||||
def test_external_api_param_mapping_and_quota():
|
||||
app = _create_api_app()
|
||||
client = app.test_client()
|
||||
|
||||
@@ -2,6 +2,7 @@ import json
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event, select
|
||||
@@ -1329,7 +1330,10 @@ class TestRegisterService:
|
||||
with patch("services.account_service.AccountService.create_account") as mock_create_account:
|
||||
mock_create_account.return_value = mock_account
|
||||
|
||||
with patch("services.account_service.TenantService.create_owner_tenant_if_not_exist") as mock_create_tenant:
|
||||
with (
|
||||
patch("services.account_service.TenantService.create_owner_tenant_if_not_exist") as mock_create_tenant,
|
||||
patch("services.account_service.CommunityTelemetryService.report_install") as mock_report_install,
|
||||
):
|
||||
RegisterService.setup(
|
||||
"admin@example.com",
|
||||
"Admin User",
|
||||
@@ -1348,7 +1352,39 @@ class TestRegisterService:
|
||||
session=sqlite_session,
|
||||
)
|
||||
mock_create_tenant.assert_called_once_with(account=mock_account, is_setup=True, session=sqlite_session)
|
||||
assert sqlite_session.scalar(select(DifySetup)) is not None
|
||||
dify_setup = sqlite_session.scalar(select(DifySetup))
|
||||
assert dify_setup is not None
|
||||
assert dify_setup.instance_id is not None
|
||||
assert str(UUID(dify_setup.instance_id)) == dify_setup.instance_id
|
||||
assert dify_setup.install_reported_at is None
|
||||
assert dify_setup.last_heartbeat_at is None
|
||||
mock_report_install.assert_called_once_with(session=sqlite_session)
|
||||
|
||||
def test_setup_succeeds_when_telemetry_install_report_fails(
|
||||
self, sqlite_session: Session, mock_external_service_dependencies
|
||||
):
|
||||
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
|
||||
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
|
||||
mock_account = TestAccountAssociatedDataFactory.create_account_mock()
|
||||
|
||||
with (
|
||||
patch("services.account_service.AccountService.create_account", return_value=mock_account),
|
||||
patch("services.account_service.TenantService.create_owner_tenant_if_not_exist"),
|
||||
patch(
|
||||
"services.account_service.CommunityTelemetryService.report_install",
|
||||
side_effect=RuntimeError("telemetry unavailable"),
|
||||
),
|
||||
):
|
||||
RegisterService.setup(
|
||||
"admin@example.com",
|
||||
"Admin User",
|
||||
"password123",
|
||||
"192.168.1.1",
|
||||
"en-US",
|
||||
session=sqlite_session,
|
||||
)
|
||||
|
||||
assert sqlite_session.scalar(select(DifySetup)) is not None
|
||||
|
||||
def test_setup_failure_rollback(self, sqlite_session: Session, mock_external_service_dependencies):
|
||||
"""Test setup failure with proper rollback."""
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from models.model import DifySetup
|
||||
from services import telemetry_service
|
||||
from services.telemetry_service import CommunityTelemetryService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def telemetry_enabled(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "EDITION", "SELF_HOSTED")
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "ENTERPRISE_ENABLED", False)
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "DISABLE_TELEMETRY", False)
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "DO_NOT_TRACK", False)
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "CI", False)
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_ENDPOINT", "https://telemetry.example.test/v1/events")
|
||||
monkeypatch.setattr(
|
||||
telemetry_service.dify_config,
|
||||
"TELEMETRY_FALLBACK_ENDPOINT",
|
||||
"https://telemetry-cn.example.test/v1/events",
|
||||
)
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_TIMEOUT_SECONDS", 2)
|
||||
|
||||
|
||||
def test_telemetry_is_disabled_for_enterprise(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "EDITION", "SELF_HOSTED")
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "ENTERPRISE_ENABLED", True)
|
||||
|
||||
assert CommunityTelemetryService._is_enabled() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("setting", "value"),
|
||||
[
|
||||
("EDITION", "CLOUD"),
|
||||
("DISABLE_TELEMETRY", True),
|
||||
("DO_NOT_TRACK", True),
|
||||
("CI", True),
|
||||
("TELEMETRY_ENDPOINT", ""),
|
||||
],
|
||||
)
|
||||
def test_telemetry_is_disabled_when_a_required_condition_is_not_met(
|
||||
telemetry_enabled, monkeypatch: pytest.MonkeyPatch, setting: str, value: str | bool
|
||||
):
|
||||
monkeypatch.setattr(telemetry_service.dify_config, setting, value)
|
||||
|
||||
assert CommunityTelemetryService._is_enabled() is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_reporting_without_setup_is_skipped(sqlite_session: Session, telemetry_enabled):
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is False
|
||||
assert CommunityTelemetryService.report_heartbeat(session=sqlite_session) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_install_marks_reported_at(sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch):
|
||||
setup = DifySetup(version="installed-version", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(telemetry_service.dify_config.project, "version", "running-version")
|
||||
|
||||
sent_payloads: list[dict[str, str | int]] = []
|
||||
|
||||
def fake_post(url: str, json: dict[str, str | int], timeout: int):
|
||||
sent_payloads.append(json)
|
||||
return httpx.Response(204, request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is True
|
||||
|
||||
saved_setup = sqlite_session.scalar(select(DifySetup))
|
||||
assert saved_setup is not None
|
||||
assert saved_setup.install_reported_at is not None
|
||||
assert sent_payloads[0]["event"] == "install"
|
||||
assert sent_payloads[0]["instance_id"] == setup.instance_id
|
||||
assert sent_payloads[0]["version"] == "installed-version"
|
||||
assert "installed_at" in sent_payloads[0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_install_generates_missing_instance_id(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="installed-version")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(
|
||||
telemetry_service.httpx,
|
||||
"post",
|
||||
lambda url, json, timeout: httpx.Response(204, request=httpx.Request("POST", url)),
|
||||
)
|
||||
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is True
|
||||
|
||||
assert setup.instance_id is not None
|
||||
assert str(uuid.UUID(setup.instance_id)) == setup.instance_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_heartbeat_generates_missing_instance_id(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="1.0.0", install_reported_at=datetime(2026, 7, 12, 8, 0, 0))
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(
|
||||
telemetry_service.httpx,
|
||||
"post",
|
||||
lambda url, json, timeout: httpx.Response(204, request=httpx.Request("POST", url)),
|
||||
)
|
||||
|
||||
assert CommunityTelemetryService.report_heartbeat(
|
||||
session=sqlite_session, now=datetime(2026, 7, 13, 12, 0, 0)
|
||||
) is True
|
||||
|
||||
assert setup.instance_id is not None
|
||||
assert str(uuid.UUID(setup.instance_id)) == setup.instance_id
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_install_failure_keeps_install_pending(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="1.0.0", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
|
||||
def fake_post(url: str, json: dict[str, str | int], timeout: int):
|
||||
raise httpx.ConnectError("offline", request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is False
|
||||
|
||||
saved_setup = sqlite_session.scalar(select(DifySetup))
|
||||
assert saved_setup is not None
|
||||
assert saved_setup.install_reported_at is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_install_uses_fallback_endpoint_after_network_failure(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="1.0.0", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
|
||||
urls: list[str] = []
|
||||
|
||||
def fake_post(url: str, json: dict[str, str | int], timeout: int):
|
||||
urls.append(url)
|
||||
if url == telemetry_service.dify_config.TELEMETRY_ENDPOINT:
|
||||
raise httpx.ConnectError("offline", request=httpx.Request("POST", url))
|
||||
return httpx.Response(204, request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is True
|
||||
assert urls == [
|
||||
telemetry_service.dify_config.TELEMETRY_ENDPOINT,
|
||||
telemetry_service.dify_config.TELEMETRY_FALLBACK_ENDPOINT,
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_install_does_not_use_fallback_endpoint_after_http_error(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="1.0.0", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
|
||||
post_mock = Mock(
|
||||
return_value=httpx.Response(
|
||||
500,
|
||||
request=httpx.Request("POST", telemetry_service.dify_config.TELEMETRY_ENDPOINT),
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", post_mock)
|
||||
|
||||
assert CommunityTelemetryService.report_install(session=sqlite_session) is False
|
||||
post_mock.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_heartbeat_retries_pending_install_before_heartbeat(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(version="installed-version", instance_id="d246c3a1-350b-406c-92c7-6043df680758")
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(telemetry_service.dify_config.project, "version", "running-version")
|
||||
|
||||
sent_payloads: list[dict[str, str | int]] = []
|
||||
|
||||
def fake_post(url: str, json: dict[str, str | int], timeout: int):
|
||||
sent_payloads.append(json)
|
||||
return httpx.Response(204, request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
now = datetime(2026, 7, 13, 0, 0, 0)
|
||||
assert CommunityTelemetryService.report_heartbeat(session=sqlite_session, now=now) is True
|
||||
|
||||
saved_setup = sqlite_session.scalar(select(DifySetup))
|
||||
assert saved_setup is not None
|
||||
assert saved_setup.install_reported_at is not None
|
||||
assert saved_setup.last_heartbeat_at == now
|
||||
assert [(payload["event"], payload["version"]) for payload in sent_payloads] == [
|
||||
("install", "installed-version"),
|
||||
("heartbeat", "running-version"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_heartbeat_skips_when_already_sent_today(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(
|
||||
version="1.0.0",
|
||||
instance_id="d246c3a1-350b-406c-92c7-6043df680758",
|
||||
install_reported_at=datetime(2026, 7, 13, 8, 0, 0),
|
||||
last_heartbeat_at=datetime(2026, 7, 13, 9, 0, 0),
|
||||
)
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
|
||||
post_mock = Mock()
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", post_mock)
|
||||
|
||||
assert (
|
||||
CommunityTelemetryService.report_heartbeat(session=sqlite_session, now=datetime(2026, 7, 13, 12, 0, 0)) is False
|
||||
)
|
||||
post_mock.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(DifySetup,)], indirect=True)
|
||||
def test_report_heartbeat_failure_does_not_mark_the_day_reported(
|
||||
sqlite_session: Session, telemetry_enabled, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
setup = DifySetup(
|
||||
version="1.0.0",
|
||||
instance_id="d246c3a1-350b-406c-92c7-6043df680758",
|
||||
install_reported_at=datetime(2026, 7, 13, 8, 0, 0),
|
||||
)
|
||||
sqlite_session.add(setup)
|
||||
sqlite_session.commit()
|
||||
|
||||
def fake_post(url: str, json: dict[str, str | int], timeout: int):
|
||||
raise httpx.ConnectError("offline", request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
|
||||
assert CommunityTelemetryService.report_heartbeat(
|
||||
session=sqlite_session, now=datetime(2026, 7, 13, 12, 0, 0)
|
||||
) is False
|
||||
assert setup.last_heartbeat_at is None
|
||||
|
||||
|
||||
def test_send_event_skips_when_telemetry_is_disabled(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "DISABLE_TELEMETRY", True)
|
||||
post_mock = Mock()
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", post_mock)
|
||||
|
||||
assert CommunityTelemetryService._send_event({"event": "heartbeat"}) is False
|
||||
post_mock.assert_not_called()
|
||||
|
||||
|
||||
def test_send_event_skips_an_empty_fallback_endpoint(telemetry_enabled, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(telemetry_service.dify_config, "TELEMETRY_FALLBACK_ENDPOINT", "")
|
||||
|
||||
def fake_post(url: str, json: dict[str, str], timeout: int):
|
||||
raise httpx.ConnectError("offline", request=httpx.Request("POST", url))
|
||||
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", fake_post)
|
||||
|
||||
assert CommunityTelemetryService._send_event({"event": "heartbeat"}) is False
|
||||
|
||||
|
||||
def test_send_event_does_not_retry_the_same_endpoint(telemetry_enabled, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(
|
||||
telemetry_service.dify_config,
|
||||
"TELEMETRY_FALLBACK_ENDPOINT",
|
||||
telemetry_service.dify_config.TELEMETRY_ENDPOINT,
|
||||
)
|
||||
post_mock = Mock(
|
||||
return_value=httpx.Response(
|
||||
204,
|
||||
request=httpx.Request("POST", telemetry_service.dify_config.TELEMETRY_ENDPOINT),
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(telemetry_service.httpx, "post", post_mock)
|
||||
|
||||
assert CommunityTelemetryService._send_event({"event": "heartbeat"}) is True
|
||||
post_mock.assert_called_once()
|
||||
|
||||
|
||||
def test_heartbeat_is_not_due_without_instance_id():
|
||||
setup = DifySetup(version="1.0.0")
|
||||
|
||||
assert CommunityTelemetryService._is_heartbeat_due(setup, datetime(2026, 7, 13, 12, 0, 0)) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("Linux", "linux"),
|
||||
("Plan9", "unknown"),
|
||||
],
|
||||
)
|
||||
def test_normalize_os(value: str, expected: str):
|
||||
assert CommunityTelemetryService._normalize_os(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("x86_64", "amd64"),
|
||||
("aarch64", "arm64"),
|
||||
("armv7l", "arm"),
|
||||
("i686", "386"),
|
||||
("riscv64", "unknown"),
|
||||
],
|
||||
)
|
||||
def test_normalize_arch(value: str, expected: str):
|
||||
assert CommunityTelemetryService._normalize_arch(value) == expected
|
||||
@@ -0,0 +1,40 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from tasks import community_telemetry_task
|
||||
|
||||
|
||||
def _configure_task_session(monkeypatch: pytest.MonkeyPatch) -> Mock:
|
||||
session = Mock()
|
||||
session_factory = MagicMock()
|
||||
session_factory.return_value.__enter__.return_value = session
|
||||
monkeypatch.setattr(community_telemetry_task, "db", SimpleNamespace(engine=object()))
|
||||
monkeypatch.setattr(community_telemetry_task, "sessionmaker", Mock(return_value=session_factory))
|
||||
return session
|
||||
|
||||
|
||||
def test_send_community_telemetry_heartbeat_reports_with_a_database_session(monkeypatch: pytest.MonkeyPatch):
|
||||
session = _configure_task_session(monkeypatch)
|
||||
report_heartbeat = Mock()
|
||||
monkeypatch.setattr(community_telemetry_task.CommunityTelemetryService, "report_heartbeat", report_heartbeat)
|
||||
|
||||
community_telemetry_task.send_community_telemetry_heartbeat.run()
|
||||
|
||||
report_heartbeat.assert_called_once_with(session=session)
|
||||
|
||||
|
||||
def test_send_community_telemetry_heartbeat_swallows_report_errors(monkeypatch: pytest.MonkeyPatch):
|
||||
_configure_task_session(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
community_telemetry_task.CommunityTelemetryService,
|
||||
"report_heartbeat",
|
||||
Mock(side_effect=RuntimeError("telemetry unavailable")),
|
||||
)
|
||||
log_debug = Mock()
|
||||
monkeypatch.setattr(community_telemetry_task.logger, "debug", log_debug)
|
||||
|
||||
community_telemetry_task.send_community_telemetry_heartbeat.run()
|
||||
|
||||
log_debug.assert_called_once_with("Failed to process community telemetry heartbeat", exc_info=True)
|
||||
@@ -835,14 +835,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/audio-btn/audio.ts": {
|
||||
"node-js/prefer-global/buffer": {
|
||||
"count": 1
|
||||
},
|
||||
"typescript/no-explicit-any": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"web/app/components/base/audio-gallery/AudioPlayer.tsx": {
|
||||
"jsx_a11y/media-has-caption": {
|
||||
"count": 1
|
||||
|
||||
@@ -149,7 +149,7 @@ describe('DatasetsLayout', () => {
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['/datasets/create', '/datasets/create-from-pipeline'])(
|
||||
it.each(['/datasets/create', '/datasets/create-from-pipeline', '/datasets/new/create'])(
|
||||
'should redirect direct dataset creation route to /datasets without dataset.create_and_management: %s',
|
||||
async (pathname) => {
|
||||
mockPathname = pathname
|
||||
@@ -186,6 +186,22 @@ describe('DatasetsLayout', () => {
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render direct new knowledge creation route when workspace has dataset.create_and_management', () => {
|
||||
mockPathname = '/datasets/new/create'
|
||||
setConsoleState({
|
||||
workspacePermissionKeys: ['dataset.create_and_management'],
|
||||
})
|
||||
|
||||
render(
|
||||
<DatasetsLayout>
|
||||
<div>datasets</div>
|
||||
</DatasetsLayout>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('datasets')).toBeInTheDocument()
|
||||
expect(mockReplace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should redirect direct external dataset connection route to /datasets without dataset.external.connect', async () => {
|
||||
mockPathname = '/datasets/connect'
|
||||
setConsoleState({
|
||||
|
||||
@@ -17,6 +17,7 @@ const isDatasetCreatePath = (pathname: string) => {
|
||||
return (
|
||||
pathname === '/datasets/create' ||
|
||||
pathname.startsWith('/datasets/create/') ||
|
||||
pathname === '/datasets/new/create' ||
|
||||
pathname === '/datasets/create-from-pipeline' ||
|
||||
pathname.startsWith('/datasets/create-from-pipeline/')
|
||||
)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { KnowledgeRoutePlaceholder } from '@/features/new-rag/knowledge-route-placeholder'
|
||||
|
||||
export default function Page() {
|
||||
return <KnowledgeRoutePlaceholder type="documents" />
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { KnowledgeRouteGuard } from '@/features/new-rag/knowledge-route-guard'
|
||||
import { KnowledgeSpaceShell } from '@/features/new-rag/knowledge-space-shell'
|
||||
|
||||
export default async function Layout({
|
||||
children,
|
||||
params,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
params: Promise<{ knowledgeSpaceId: string }>
|
||||
}) {
|
||||
const { knowledgeSpaceId } = await params
|
||||
|
||||
return (
|
||||
<KnowledgeRouteGuard>
|
||||
<KnowledgeSpaceShell knowledgeSpaceId={knowledgeSpaceId}>{children}</KnowledgeSpaceShell>
|
||||
</KnowledgeRouteGuard>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from '@/next/navigation'
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ knowledgeSpaceId: string }> }) {
|
||||
const { knowledgeSpaceId } = await params
|
||||
redirect(`/datasets/new/${knowledgeSpaceId}/sources`)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { KnowledgeRoutePlaceholder } from '@/features/new-rag/knowledge-route-placeholder'
|
||||
|
||||
export default function Page() {
|
||||
return <KnowledgeRoutePlaceholder type="sources" />
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { CreateKnowledgePage } from '@/features/new-rag/create-knowledge-page'
|
||||
import { KnowledgeRouteGuard } from '@/features/new-rag/knowledge-route-guard'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<KnowledgeRouteGuard>
|
||||
<CreateKnowledgePage />
|
||||
</KnowledgeRouteGuard>
|
||||
)
|
||||
}
|
||||
@@ -12,14 +12,8 @@ type AudioPlayerCtorArgs = [
|
||||
|
||||
type MockAudioPlayerInstance = {
|
||||
setCallback: ReturnType<typeof vi.fn>
|
||||
pauseAudio: ReturnType<typeof vi.fn>
|
||||
destroy: ReturnType<typeof vi.fn>
|
||||
resetMsgId: ReturnType<typeof vi.fn>
|
||||
cacheBuffers: Array<ArrayBuffer>
|
||||
sourceBuffer:
|
||||
| {
|
||||
abort: ReturnType<typeof vi.fn>
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
@@ -31,10 +25,8 @@ const mockAudioPlayerConstructor = vi.hoisted(() => vi.fn())
|
||||
const MockAudioPlayer = vi.hoisted(() => {
|
||||
return class MockAudioPlayerClass {
|
||||
setCallback = vi.fn()
|
||||
pauseAudio = vi.fn()
|
||||
destroy = vi.fn()
|
||||
resetMsgId = vi.fn()
|
||||
cacheBuffers = [new ArrayBuffer(1)]
|
||||
sourceBuffer = { abort: vi.fn() }
|
||||
|
||||
constructor(...args: AudioPlayerCtorArgs) {
|
||||
mockAudioPlayerConstructor(...args)
|
||||
@@ -132,9 +124,7 @@ describe('AudioPlayerManager', () => {
|
||||
callback,
|
||||
)
|
||||
|
||||
expect(previous!.pauseAudio).toHaveBeenCalledTimes(1)
|
||||
expect(previous!.cacheBuffers).toEqual([])
|
||||
expect(previous!.sourceBuffer?.abort).toHaveBeenCalledTimes(1)
|
||||
expect(previous!.destroy).toHaveBeenCalledTimes(1)
|
||||
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(2)
|
||||
expect(next).toBe(mockState.instances[1])
|
||||
})
|
||||
@@ -144,7 +134,7 @@ describe('AudioPlayerManager', () => {
|
||||
const callback = vi.fn()
|
||||
manager.getAudioPlayer('/text-to-audio', false, 'msg-1', 'hello', 'en-US', callback)
|
||||
const previous = mockState.instances[0]
|
||||
previous!.pauseAudio.mockImplementation(() => {
|
||||
previous!.destroy.mockImplementation(() => {
|
||||
throw new Error('cleanup failure')
|
||||
})
|
||||
|
||||
@@ -152,7 +142,7 @@ describe('AudioPlayerManager', () => {
|
||||
manager.getAudioPlayer('/apps/1/text-to-audio', false, 'msg-2', 'world', 'en-US', callback)
|
||||
}).not.toThrow()
|
||||
|
||||
expect(previous!.pauseAudio).toHaveBeenCalledTimes(1)
|
||||
expect(previous!.destroy).toHaveBeenCalledTimes(1)
|
||||
expect(mockAudioPlayerConstructor).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,15 +3,8 @@ import { waitFor } from '@testing-library/react'
|
||||
import { AppSourceType } from '@/service/share'
|
||||
import AudioPlayer from '../audio'
|
||||
|
||||
const mockToastNotify = vi.hoisted(() => vi.fn())
|
||||
const mockTextToAudioStream = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('@langgenius/dify-ui/toast', () => ({
|
||||
toast: {
|
||||
error: (message: string) => mockToastNotify({ type: 'error', message }),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/service/share', () => ({
|
||||
AppSourceType: {
|
||||
webApp: 'webApp',
|
||||
@@ -22,7 +15,7 @@ vi.mock('@/service/share', () => ({
|
||||
|
||||
type AudioEventName =
|
||||
| 'ended'
|
||||
| 'paused'
|
||||
| 'pause'
|
||||
| 'loaded'
|
||||
| 'play'
|
||||
| 'timeupdate'
|
||||
@@ -30,6 +23,7 @@ type AudioEventName =
|
||||
| 'canplay'
|
||||
| 'error'
|
||||
| 'sourceopen'
|
||||
| 'updateend'
|
||||
|
||||
type AudioEventListener = () => void
|
||||
|
||||
@@ -51,12 +45,31 @@ type AudioResponse = {
|
||||
|
||||
class MockSourceBuffer {
|
||||
updating = false
|
||||
private listeners: Partial<Record<AudioEventName, AudioEventListener[]>> = {}
|
||||
|
||||
addEventListener = vi.fn((event: AudioEventName, listener: AudioEventListener) => {
|
||||
const listeners = this.listeners[event] || []
|
||||
listeners.push(listener)
|
||||
this.listeners[event] = listeners
|
||||
})
|
||||
|
||||
removeEventListener = vi.fn((event: AudioEventName, listener: AudioEventListener) => {
|
||||
this.listeners[event] = (this.listeners[event] || []).filter((item) => item !== listener)
|
||||
})
|
||||
|
||||
appendBuffer = vi.fn((_buffer: ArrayBuffer) => undefined)
|
||||
abort = vi.fn(() => undefined)
|
||||
|
||||
emit(event: AudioEventName) {
|
||||
const listeners = this.listeners[event] || []
|
||||
listeners.forEach((listener) => {
|
||||
listener()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
class MockMediaSource {
|
||||
readyState: 'open' | 'closed' = 'open'
|
||||
readyState: 'open' | 'closed' | 'ended' = 'closed'
|
||||
sourceBuffer = new MockSourceBuffer()
|
||||
private listeners: Partial<Record<AudioEventName, AudioEventListener[]>> = {}
|
||||
|
||||
@@ -66,10 +79,15 @@ class MockMediaSource {
|
||||
this.listeners[event] = listeners
|
||||
})
|
||||
|
||||
removeEventListener = vi.fn((event: AudioEventName, listener: AudioEventListener) => {
|
||||
this.listeners[event] = (this.listeners[event] || []).filter((item) => item !== listener)
|
||||
})
|
||||
|
||||
addSourceBuffer = vi.fn((_contentType: string) => this.sourceBuffer)
|
||||
endOfStream = vi.fn(() => undefined)
|
||||
|
||||
emit(event: AudioEventName) {
|
||||
if (event === 'sourceopen') this.readyState = 'open'
|
||||
const listeners = this.listeners[event] || []
|
||||
listeners.forEach((listener) => {
|
||||
listener()
|
||||
@@ -110,7 +128,7 @@ class MockAudio {
|
||||
}
|
||||
|
||||
class MockAudioContext {
|
||||
state: 'running' | 'suspended' = 'running'
|
||||
state: 'interrupted' | 'running' | 'suspended' = 'running'
|
||||
destination = {}
|
||||
connect = vi.fn(() => undefined)
|
||||
createMediaElementSource = vi.fn((_audio: MockAudio) => ({
|
||||
@@ -121,9 +139,11 @@ class MockAudioContext {
|
||||
this.state = 'running'
|
||||
})
|
||||
|
||||
suspend = vi.fn(() => {
|
||||
suspend = vi.fn(async () => {
|
||||
this.state = 'suspended'
|
||||
})
|
||||
|
||||
close = vi.fn(async () => undefined)
|
||||
}
|
||||
|
||||
const testState = {
|
||||
@@ -133,6 +153,8 @@ const testState = {
|
||||
}
|
||||
|
||||
class MockMediaSourceCtor extends MockMediaSource {
|
||||
static isTypeSupported = vi.fn(() => true)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
testState.mediaSources.push(this)
|
||||
@@ -156,6 +178,7 @@ class MockAudioContextCtor extends MockAudioContext {
|
||||
const originalAudio = globalThis.Audio
|
||||
const originalAudioContext = globalThis.AudioContext
|
||||
const originalCreateObjectURL = globalThis.URL.createObjectURL
|
||||
const originalRevokeObjectURL = globalThis.URL.revokeObjectURL
|
||||
const originalMediaSource = window.MediaSource
|
||||
const originalManagedMediaSource = window.ManagedMediaSource
|
||||
|
||||
@@ -192,6 +215,7 @@ describe('AudioPlayer', () => {
|
||||
testState.mediaSources = []
|
||||
testState.audios = []
|
||||
testState.audioContexts = []
|
||||
MockMediaSourceCtor.isTypeSupported.mockReturnValue(true)
|
||||
|
||||
Object.defineProperty(globalThis, 'Audio', {
|
||||
configurable: true,
|
||||
@@ -208,6 +232,11 @@ describe('AudioPlayer', () => {
|
||||
writable: true,
|
||||
value: vi.fn(() => 'blob:mock-url'),
|
||||
})
|
||||
Object.defineProperty(globalThis.URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn(),
|
||||
})
|
||||
|
||||
setMediaSourceSupport({ mediaSource: true, managedMediaSource: false })
|
||||
})
|
||||
@@ -228,6 +257,11 @@ describe('AudioPlayer', () => {
|
||||
writable: true,
|
||||
value: originalCreateObjectURL,
|
||||
})
|
||||
Object.defineProperty(globalThis.URL, 'revokeObjectURL', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: originalRevokeObjectURL,
|
||||
})
|
||||
Object.defineProperty(window, 'MediaSource', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
@@ -256,7 +290,7 @@ describe('AudioPlayer', () => {
|
||||
expect(audioContext!.connect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should notify unsupported browser when no MediaSource implementation exists', () => {
|
||||
it('should use complete-audio fallback when no MediaSource implementation exists', () => {
|
||||
setMediaSourceSupport({ mediaSource: false, managedMediaSource: false })
|
||||
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
@@ -264,12 +298,22 @@ describe('AudioPlayer', () => {
|
||||
|
||||
expect(player.mediaSource).toBeNull()
|
||||
expect(audio!.src).toBe('')
|
||||
expect(mockToastNotify).toHaveBeenCalledTimes(1)
|
||||
expect(mockToastNotify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'error',
|
||||
}),
|
||||
)
|
||||
expect(audio!.autoplay).toBe(false)
|
||||
expect(globalThis.URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use complete-audio fallback when MP3 MediaSource is unsupported', () => {
|
||||
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const audio = testState.audios[0]
|
||||
|
||||
expect(MockMediaSourceCtor.isTypeSupported).toHaveBeenCalledWith('audio/mpeg')
|
||||
expect(player.mediaSource).toBeNull()
|
||||
expect(testState.mediaSources).toHaveLength(0)
|
||||
expect(audio!.src).toBe('')
|
||||
expect(audio!.autoplay).toBe(false)
|
||||
expect(globalThis.URL.createObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should configure fallback audio controls when ManagedMediaSource is used', () => {
|
||||
@@ -283,6 +327,17 @@ describe('AudioPlayer', () => {
|
||||
expect(audio!.disableRemotePlayback).toBe(true)
|
||||
expect(audio!.controls).toBe(true)
|
||||
})
|
||||
|
||||
it('should configure ManagedMediaSource when both media source implementations exist', () => {
|
||||
setMediaSourceSupport({ mediaSource: true, managedMediaSource: true })
|
||||
|
||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, vi.fn())
|
||||
const audio = testState.audios[0]
|
||||
|
||||
expect(player.mediaSource).not.toBeNull()
|
||||
expect(audio!.disableRemotePlayback).toBe(true)
|
||||
expect(audio!.controls).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('event wiring', () => {
|
||||
@@ -294,7 +349,7 @@ describe('AudioPlayer', () => {
|
||||
audio!.emit('play')
|
||||
audio!.emit('ended')
|
||||
audio!.emit('error')
|
||||
audio!.emit('paused')
|
||||
audio!.emit('pause')
|
||||
audio!.emit('loaded')
|
||||
audio!.emit('timeupdate')
|
||||
audio!.emit('loadeddate')
|
||||
@@ -354,6 +409,7 @@ describe('AudioPlayer', () => {
|
||||
})
|
||||
|
||||
it('should emit error callback and reset load flag when stream response status is not 200', async () => {
|
||||
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||
const callback = vi.fn()
|
||||
mockTextToAudioStream.mockResolvedValue(
|
||||
makeAudioResponse(500, [{ value: new Uint8Array([1]), done: true }]),
|
||||
@@ -366,25 +422,171 @@ describe('AudioPlayer', () => {
|
||||
expect(callback).toHaveBeenCalledWith('error')
|
||||
})
|
||||
expect(player.isLoadData).toBe(false)
|
||||
expect(globalThis.URL.createObjectURL).not.toHaveBeenCalled()
|
||||
expect(testState.audios[0]!.play).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should resume and play immediately when playAudio is called in suspended loaded state', async () => {
|
||||
it('should play a complete MP3 blob when MediaSource does not support audio/mpeg', async () => {
|
||||
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||
const callback = vi.fn()
|
||||
mockTextToAudioStream.mockResolvedValue(
|
||||
makeAudioResponse(200, [
|
||||
{ value: new Uint8Array([1, 2]), done: false },
|
||||
{ value: new Uint8Array([3, 4]), done: true },
|
||||
]),
|
||||
)
|
||||
|
||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
||||
const audio = testState.audios[0]
|
||||
|
||||
player.playAudio()
|
||||
|
||||
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
|
||||
expect(player.mediaSource).toBeNull()
|
||||
expect(player.cacheBuffers).toHaveLength(0)
|
||||
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(1)
|
||||
const audioBlob = vi.mocked(globalThis.URL.createObjectURL).mock.calls[0]![0] as Blob
|
||||
expect(audioBlob).toBeInstanceOf(Blob)
|
||||
expect(audioBlob).toMatchObject({ type: 'audio/mpeg', size: 4 })
|
||||
expect(new Uint8Array(await audioBlob.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
|
||||
expect(audio!.src).toBe('blob:mock-url')
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
})
|
||||
|
||||
it('should wait for the complete MP3 before retrying playback without MediaSource', async () => {
|
||||
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||
let resolveResponse: ((response: AudioResponse) => void) | undefined
|
||||
mockTextToAudioStream.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<AudioResponse>((resolve) => {
|
||||
resolveResponse = resolve
|
||||
}),
|
||||
)
|
||||
|
||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, vi.fn())
|
||||
const audio = testState.audios[0]
|
||||
|
||||
player.playAudio()
|
||||
player.playAudio()
|
||||
|
||||
expect(audio!.play).not.toHaveBeenCalled()
|
||||
|
||||
resolveResponse?.(makeAudioResponse(200, [{ value: new Uint8Array([1, 2]), done: true }]))
|
||||
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
|
||||
it.each(['suspended', 'interrupted'] as const)(
|
||||
'should resume and play immediately when playAudio is called in %s loaded state',
|
||||
async (audioContextState) => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer(
|
||||
'/text-to-audio',
|
||||
false,
|
||||
'msg-1',
|
||||
'hello',
|
||||
undefined,
|
||||
callback,
|
||||
)
|
||||
const audio = testState.audios[0]
|
||||
const audioContext = testState.audioContexts[0]
|
||||
|
||||
player.isLoadData = true
|
||||
audioContext!.state = audioContextState
|
||||
player.playAudio()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it('should request media playback before a suspended audio context finishes resuming', async () => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
||||
const audio = testState.audios[0]
|
||||
const audioContext = testState.audioContexts[0]
|
||||
let resolveResume: (() => void) | undefined
|
||||
|
||||
player.isLoadData = true
|
||||
audioContext!.state = 'suspended'
|
||||
audioContext!.resume.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<void>((resolve) => {
|
||||
resolveResume = () => {
|
||||
audioContext!.state = 'running'
|
||||
resolve()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
player.playAudio()
|
||||
|
||||
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).not.toHaveBeenCalledWith('play')
|
||||
|
||||
resolveResume?.()
|
||||
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
|
||||
})
|
||||
|
||||
it.each(['suspended', 'interrupted'] as const)(
|
||||
'should resume a %s audio context when the media element is still playing',
|
||||
async (audioContextState) => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer(
|
||||
'/text-to-audio',
|
||||
false,
|
||||
'msg-1',
|
||||
'hello',
|
||||
undefined,
|
||||
callback,
|
||||
)
|
||||
const audio = testState.audios[0]
|
||||
const audioContext = testState.audioContexts[0]
|
||||
|
||||
player.isLoadData = true
|
||||
audio!.paused = false
|
||||
audioContext!.state = audioContextState
|
||||
player.playAudio()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
})
|
||||
expect(audio!.play).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('should report an error when the audio context remains interrupted and allow retry', async () => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
||||
const audio = testState.audios[0]
|
||||
const audioContext = testState.audioContexts[0]
|
||||
|
||||
player.isLoadData = true
|
||||
audio!.paused = false
|
||||
audioContext!.state = 'suspended'
|
||||
audioContext!.resume.mockImplementationOnce(async () => {
|
||||
audioContext!.state = 'interrupted'
|
||||
})
|
||||
player.playAudio()
|
||||
await Promise.resolve()
|
||||
|
||||
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
await waitFor(() => expect(callback).toHaveBeenCalledWith('error'))
|
||||
expect(callback).not.toHaveBeenCalledWith('play')
|
||||
|
||||
audioContext!.resume.mockImplementationOnce(async () => {
|
||||
audioContext!.state = 'running'
|
||||
})
|
||||
player.playAudio()
|
||||
|
||||
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
|
||||
expect(audioContext!.resume).toHaveBeenCalledTimes(2)
|
||||
expect(audio!.play).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should play ended audio when data is already loaded', () => {
|
||||
it('should play ended audio when data is already loaded', async () => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
||||
const audio = testState.audios[0]
|
||||
@@ -395,11 +597,13 @@ describe('AudioPlayer', () => {
|
||||
audio!.ended = true
|
||||
player.playAudio()
|
||||
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
await waitFor(() => {
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
})
|
||||
})
|
||||
|
||||
it('should only emit play callback without replaying when loaded audio is already playing', () => {
|
||||
it('should report loaded audio that is already playing without replaying it', () => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', false, 'msg-1', 'hello', undefined, callback)
|
||||
const audio = testState.audios[0]
|
||||
@@ -407,6 +611,7 @@ describe('AudioPlayer', () => {
|
||||
|
||||
player.isLoadData = true
|
||||
audioContext!.state = 'running'
|
||||
audio!.paused = false
|
||||
audio!.ended = false
|
||||
player.playAudio()
|
||||
|
||||
@@ -451,22 +656,20 @@ describe('AudioPlayer', () => {
|
||||
})
|
||||
|
||||
it('should end stream without playback when playAudioWithAudio receives empty content', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
|
||||
await player.playAudioWithAudio('', true)
|
||||
await vi.advanceTimersByTimeAsync(40)
|
||||
await player.playAudioWithAudio('', true)
|
||||
|
||||
expect(player.isLoadData).toBe(false)
|
||||
expect(player.cacheBuffers).toHaveLength(0)
|
||||
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
|
||||
expect(callback).not.toHaveBeenCalledWith('play')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
expect(player.isLoadData).toBe(false)
|
||||
expect(player.cacheBuffers).toHaveLength(0)
|
||||
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
|
||||
|
||||
mediaSource!.emit('sourceopen')
|
||||
|
||||
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
|
||||
expect(callback).not.toHaveBeenCalledWith('play')
|
||||
})
|
||||
|
||||
it('should decode base64 and start playback when playAudioWithAudio is called with playable content', async () => {
|
||||
@@ -479,8 +682,8 @@ describe('AudioPlayer', () => {
|
||||
|
||||
mediaSource!.emit('sourceopen')
|
||||
audio!.paused = true
|
||||
audioContext!.state = 'suspended'
|
||||
await player.playAudioWithAudio(audioBase64, true)
|
||||
await Promise.resolve()
|
||||
|
||||
expect(player.isLoadData).toBe(true)
|
||||
expect(player.cacheBuffers).toHaveLength(0)
|
||||
@@ -488,9 +691,11 @@ describe('AudioPlayer', () => {
|
||||
const appendedAudioData = mediaSource!.sourceBuffer.appendBuffer.mock.calls[0]![0]
|
||||
expect(appendedAudioData).toBeInstanceOf(ArrayBuffer)
|
||||
expect(appendedAudioData.byteLength).toBeGreaterThan(0)
|
||||
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
await waitFor(() => {
|
||||
expect(audioContext!.resume).toHaveBeenCalledTimes(1)
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip playback when playAudioWithAudio is called with play=false', async () => {
|
||||
@@ -507,6 +712,88 @@ describe('AudioPlayer', () => {
|
||||
expect(callback).not.toHaveBeenCalledWith('play')
|
||||
})
|
||||
|
||||
it('should combine automatic TTS chunks into a playable MP3 blob without MediaSource', async () => {
|
||||
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||
const audio = testState.audios[0]
|
||||
|
||||
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
|
||||
await player.playAudioWithAudio(Buffer.from([3, 4]).toString('base64'), true)
|
||||
|
||||
expect(audio!.play).not.toHaveBeenCalled()
|
||||
expect(player.cacheBuffers).toHaveLength(2)
|
||||
|
||||
await player.playAudioWithAudio('', false)
|
||||
|
||||
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
|
||||
expect(player.cacheBuffers).toHaveLength(0)
|
||||
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(1)
|
||||
const audioBlob = vi.mocked(globalThis.URL.createObjectURL).mock.calls[0]![0] as Blob
|
||||
expect(audioBlob).toMatchObject({ type: 'audio/mpeg', size: 4 })
|
||||
expect(new Uint8Array(await audioBlob.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4]))
|
||||
expect(audio!.src).toBe('blob:mock-url')
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
})
|
||||
|
||||
it('should not start fallback playback after it is paused while buffering', async () => {
|
||||
MockMediaSourceCtor.isTypeSupported.mockReturnValue(false)
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', vi.fn())
|
||||
const audio = testState.audios[0]
|
||||
|
||||
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
|
||||
player.pauseAudio()
|
||||
await player.playAudioWithAudio('', false)
|
||||
|
||||
expect(audio!.autoplay).toBe(false)
|
||||
expect(audio!.play).not.toHaveBeenCalled()
|
||||
expect(audio!.src).toBe('blob:mock-url')
|
||||
})
|
||||
|
||||
it('should fall back to a complete MP3 when addSourceBuffer throws', async () => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
const audio = testState.audios[0]
|
||||
mediaSource!.addSourceBuffer.mockImplementationOnce(() => {
|
||||
throw new DOMException('Unsupported type', 'NotSupportedError')
|
||||
})
|
||||
|
||||
mediaSource!.emit('sourceopen')
|
||||
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
|
||||
await player.playAudioWithAudio('', false)
|
||||
|
||||
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
|
||||
expect(player.mediaSource).toBeNull()
|
||||
expect(audio!.autoplay).toBe(false)
|
||||
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should complete buffered fallback when addSourceBuffer throws after stream end', async () => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', vi.fn())
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
const audio = testState.audios[0]
|
||||
mediaSource!.addSourceBuffer.mockImplementationOnce(() => {
|
||||
throw new DOMException('Unsupported type', 'NotSupportedError')
|
||||
})
|
||||
|
||||
await player.playAudioWithAudio(Buffer.from([1, 2]).toString('base64'), true)
|
||||
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(1))
|
||||
await player.playAudioWithAudio('', false)
|
||||
audio!.paused = true
|
||||
|
||||
mediaSource!.emit('sourceopen')
|
||||
|
||||
await waitFor(() => expect(audio!.play).toHaveBeenCalledTimes(2))
|
||||
expect(player.mediaSource).toBeNull()
|
||||
expect(player.cacheBuffers).toHaveLength(0)
|
||||
expect(globalThis.URL.createObjectURL).toHaveBeenCalledTimes(2)
|
||||
const audioBlob = vi.mocked(globalThis.URL.createObjectURL).mock.calls[1]![0] as Blob
|
||||
expect(audioBlob).toMatchObject({ type: 'audio/mpeg', size: 2 })
|
||||
expect(new Uint8Array(await audioBlob.arrayBuffer())).toEqual(new Uint8Array([1, 2]))
|
||||
})
|
||||
|
||||
it('should play immediately for ended audio in playAudioWithAudio', async () => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||
@@ -517,7 +804,7 @@ describe('AudioPlayer', () => {
|
||||
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
|
||||
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
|
||||
})
|
||||
|
||||
it('should not replay when played list exists in playAudioWithAudio', async () => {
|
||||
@@ -534,18 +821,63 @@ describe('AudioPlayer', () => {
|
||||
expect(callback).not.toHaveBeenCalledWith('play')
|
||||
})
|
||||
|
||||
it('should replay when paused is false and played list is empty in playAudioWithAudio', async () => {
|
||||
it('should report a play failure and retry without requesting audio again', async () => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||
const audio = testState.audios[0]
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
mockTextToAudioStream.mockResolvedValue(
|
||||
makeAudioResponse(200, [{ value: undefined, done: true }]),
|
||||
)
|
||||
audio!.play.mockRejectedValueOnce(new DOMException('Playback aborted', 'AbortError'))
|
||||
|
||||
audio!.paused = false
|
||||
audio!.ended = false
|
||||
audio!.played = null
|
||||
await player.playAudioWithAudio(Buffer.from('hello').toString('base64'), true)
|
||||
mediaSource!.emit('sourceopen')
|
||||
player.playAudio()
|
||||
|
||||
await waitFor(() => expect(callback).toHaveBeenCalledWith('error'))
|
||||
expect(callback).not.toHaveBeenCalledWith('play')
|
||||
expect(player.isLoadData).toBe(true)
|
||||
|
||||
audio!.play.mockImplementationOnce(async () => {
|
||||
audio!.paused = false
|
||||
})
|
||||
player.playAudio()
|
||||
|
||||
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
|
||||
|
||||
expect(audio!.play).toHaveBeenCalledTimes(2)
|
||||
expect(mockTextToAudioStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should report a resume failure and allow playback to be retried', async () => {
|
||||
const callback = vi.fn()
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', callback)
|
||||
const audio = testState.audios[0]
|
||||
const audioContext = testState.audioContexts[0]
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
mockTextToAudioStream.mockResolvedValue(
|
||||
makeAudioResponse(200, [{ value: undefined, done: true }]),
|
||||
)
|
||||
audioContext!.state = 'suspended'
|
||||
audioContext!.resume.mockRejectedValueOnce(new DOMException('Not allowed', 'NotAllowedError'))
|
||||
|
||||
mediaSource!.emit('sourceopen')
|
||||
player.playAudio()
|
||||
|
||||
await waitFor(() => expect(callback).toHaveBeenCalledWith('error'))
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(callback).toHaveBeenCalledWith('play')
|
||||
expect(player.isLoadData).toBe(true)
|
||||
|
||||
audioContext!.resume.mockImplementationOnce(async () => {
|
||||
audioContext!.state = 'running'
|
||||
})
|
||||
player.playAudio()
|
||||
|
||||
await waitFor(() => expect(callback).toHaveBeenCalledWith('play'))
|
||||
|
||||
expect(audioContext!.resume).toHaveBeenCalledTimes(2)
|
||||
expect(audio!.play).toHaveBeenCalledTimes(1)
|
||||
expect(mockTextToAudioStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -562,7 +894,7 @@ describe('AudioPlayer', () => {
|
||||
expect(finishStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should finish stream when receiveAudioData gets empty bytes while source is open', () => {
|
||||
it('should finish stream when receiveAudioData gets empty bytes', () => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const finishStream = vi
|
||||
.spyOn(player as unknown as { finishStream: () => void }, 'finishStream')
|
||||
@@ -586,6 +918,52 @@ describe('AudioPlayer', () => {
|
||||
expect(player.cacheBuffers.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should preserve audio received before sourceopen and append it once ready', () => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
|
||||
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
|
||||
new Uint8Array([1, 2, 3]),
|
||||
)
|
||||
|
||||
expect(player.cacheBuffers).toHaveLength(1)
|
||||
expect(mediaSource!.sourceBuffer.appendBuffer).not.toHaveBeenCalled()
|
||||
|
||||
mediaSource!.emit('sourceopen')
|
||||
|
||||
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
|
||||
expect(player.cacheBuffers).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should append queued buffers in order after updateend', () => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
mediaSource!.emit('sourceopen')
|
||||
mediaSource!.sourceBuffer.updating = true
|
||||
|
||||
const first = new Uint8Array([1])
|
||||
const second = new Uint8Array([2])
|
||||
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
|
||||
first,
|
||||
)
|
||||
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
|
||||
second,
|
||||
)
|
||||
|
||||
mediaSource!.sourceBuffer.updating = false
|
||||
mediaSource!.sourceBuffer.emit('updateend')
|
||||
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
|
||||
expect(new Uint8Array(mediaSource!.sourceBuffer.appendBuffer.mock.calls[0]![0])).toEqual(
|
||||
first,
|
||||
)
|
||||
|
||||
mediaSource!.sourceBuffer.emit('updateend')
|
||||
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(2)
|
||||
expect(new Uint8Array(mediaSource!.sourceBuffer.appendBuffer.mock.calls[1]![0])).toEqual(
|
||||
second,
|
||||
)
|
||||
})
|
||||
|
||||
it('should append previously queued buffer before new one when source buffer is idle', () => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
@@ -603,19 +981,68 @@ describe('AudioPlayer', () => {
|
||||
expect(player.cacheBuffers.length).toBe(1)
|
||||
})
|
||||
|
||||
it('should append cache chunks and end stream when finishStream drains buffers', () => {
|
||||
vi.useFakeTimers()
|
||||
it('should end the stream only after the final queued buffer is appended', () => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
mediaSource!.emit('sourceopen')
|
||||
mediaSource!.sourceBuffer.updating = false
|
||||
mediaSource!.sourceBuffer.updating = true
|
||||
player.cacheBuffers = [new ArrayBuffer(3)]
|
||||
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||
vi.advanceTimersByTime(50)
|
||||
|
||||
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||
|
||||
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
|
||||
|
||||
mediaSource!.sourceBuffer.updating = false
|
||||
mediaSource!.sourceBuffer.emit('updateend')
|
||||
expect(mediaSource!.sourceBuffer.appendBuffer).toHaveBeenCalledTimes(1)
|
||||
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
|
||||
|
||||
mediaSource!.sourceBuffer.emit('updateend')
|
||||
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should end an open stream at most once', () => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
mediaSource!.emit('sourceopen')
|
||||
|
||||
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||
|
||||
expect(mediaSource!.endOfStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it.each(['closed', 'ended'] as const)('should not end a %s media source', (readyState) => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
mediaSource!.emit('sourceopen')
|
||||
mediaSource!.readyState = readyState
|
||||
|
||||
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||
|
||||
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop buffering and release browser resources after destroy', async () => {
|
||||
const player = new AudioPlayer('/text-to-audio', true, 'msg-1', 'hello', 'en-US', null)
|
||||
const mediaSource = testState.mediaSources[0]
|
||||
const audio = testState.audios[0]
|
||||
const audioContext = testState.audioContexts[0]
|
||||
mediaSource!.emit('sourceopen')
|
||||
|
||||
player.destroy()
|
||||
;(player as unknown as { receiveAudioData: (data: Uint8Array) => void }).receiveAudioData(
|
||||
new Uint8Array([1]),
|
||||
)
|
||||
;(player as unknown as { finishStream: () => void }).finishStream()
|
||||
mediaSource!.sourceBuffer.emit('updateend')
|
||||
await Promise.resolve()
|
||||
|
||||
expect(mediaSource!.sourceBuffer.appendBuffer).not.toHaveBeenCalled()
|
||||
expect(mediaSource!.endOfStream).not.toHaveBeenCalled()
|
||||
expect(audio!.pause).toHaveBeenCalledTimes(1)
|
||||
expect(audioContext!.close).toHaveBeenCalledTimes(1)
|
||||
expect(globalThis.URL.revokeObjectURL).toHaveBeenCalledWith('blob:mock-url')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -35,9 +35,7 @@ export class AudioPlayerManager {
|
||||
} else {
|
||||
if (this.audioPlayers) {
|
||||
try {
|
||||
this.audioPlayers.pauseAudio()
|
||||
this.audioPlayers.cacheBuffers = []
|
||||
this.audioPlayers.sourceBuffer?.abort()
|
||||
this.audioPlayers.destroy()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { AppSourceType, textToAudioStream } from '@/service/share'
|
||||
|
||||
const AUDIO_CONTENT_TYPE = 'audio/mpeg'
|
||||
|
||||
declare global {
|
||||
// oxlint-disable-next-line typescript/consistent-type-definitions
|
||||
interface Window {
|
||||
ManagedMediaSource: any
|
||||
ManagedMediaSource?: typeof MediaSource
|
||||
}
|
||||
}
|
||||
export default class AudioPlayer {
|
||||
mediaSource: MediaSource | null
|
||||
audio: HTMLAudioElement
|
||||
audioContext: AudioContext
|
||||
sourceBuffer?: any
|
||||
sourceBuffer?: SourceBuffer
|
||||
cacheBuffers: ArrayBuffer[] = []
|
||||
pauseTimer: number | null = null
|
||||
msgId: string | undefined
|
||||
msgContent: string | null | undefined = null
|
||||
voice: string | undefined = undefined
|
||||
@@ -21,6 +21,13 @@ export default class AudioPlayer {
|
||||
url: string
|
||||
isPublic: boolean
|
||||
callback: ((event: string) => void) | null
|
||||
private objectUrl = ''
|
||||
private streamEnded = false
|
||||
private endOfStreamCalled = false
|
||||
private destroyed = false
|
||||
private playbackPending = false
|
||||
private playWhenReady = false
|
||||
private sourceOpenListener?: () => void
|
||||
constructor(
|
||||
streamUrl: string,
|
||||
isPublic: boolean,
|
||||
@@ -37,25 +44,26 @@ export default class AudioPlayer {
|
||||
this.voice = voice
|
||||
this.callback = callback
|
||||
// Compatible with iphone ios17 ManagedMediaSource
|
||||
const MediaSource = window.ManagedMediaSource || window.MediaSource
|
||||
if (!MediaSource) {
|
||||
toast.error(
|
||||
'Your browser does not support audio streaming, if you are using an iPhone, please update to iOS 17.1 or later.',
|
||||
)
|
||||
}
|
||||
this.mediaSource = MediaSource ? new MediaSource() : null
|
||||
const MediaSourceConstructor = window.ManagedMediaSource || window.MediaSource
|
||||
const isManagedMediaSource = Boolean(
|
||||
window.ManagedMediaSource && MediaSourceConstructor === window.ManagedMediaSource,
|
||||
)
|
||||
const supportsStreaming = Boolean(MediaSourceConstructor?.isTypeSupported?.(AUDIO_CONTENT_TYPE))
|
||||
this.mediaSource =
|
||||
supportsStreaming && MediaSourceConstructor ? new MediaSourceConstructor() : null
|
||||
this.audio = new Audio()
|
||||
this.setCallback(callback)
|
||||
if (!window.MediaSource) {
|
||||
if (this.mediaSource && isManagedMediaSource) {
|
||||
// if use ManagedMediaSource
|
||||
this.audio.disableRemotePlayback = true
|
||||
this.audio.controls = true
|
||||
}
|
||||
this.audio.src = this.mediaSource ? URL.createObjectURL(this.mediaSource) : ''
|
||||
this.audio.autoplay = true
|
||||
this.listenMediaSource(AUDIO_CONTENT_TYPE)
|
||||
this.objectUrl = this.mediaSource ? URL.createObjectURL(this.mediaSource) : ''
|
||||
this.audio.src = this.objectUrl
|
||||
this.audio.autoplay = Boolean(this.mediaSource)
|
||||
const source = this.audioContext.createMediaElementSource(this.audio)
|
||||
source.connect(this.audioContext.destination)
|
||||
this.listenMediaSource('audio/mpeg')
|
||||
}
|
||||
|
||||
public resetMsgId(msgId: string) {
|
||||
@@ -63,10 +71,77 @@ export default class AudioPlayer {
|
||||
}
|
||||
|
||||
private listenMediaSource(contentType: string) {
|
||||
this.mediaSource?.addEventListener('sourceopen', () => {
|
||||
if (this.sourceBuffer) return
|
||||
this.sourceBuffer = this.mediaSource?.addSourceBuffer(contentType)
|
||||
})
|
||||
this.sourceOpenListener = () => {
|
||||
if (this.destroyed || this.sourceBuffer) return
|
||||
try {
|
||||
this.sourceBuffer = this.mediaSource?.addSourceBuffer(contentType)
|
||||
this.sourceBuffer?.addEventListener('updateend', this.flushBuffers)
|
||||
this.flushBuffers()
|
||||
} catch {
|
||||
this.mediaSource = null
|
||||
this.audio.autoplay = false
|
||||
this.releaseObjectUrl()
|
||||
if (this.streamEnded) this.finishBlobAudio()
|
||||
}
|
||||
}
|
||||
this.mediaSource?.addEventListener('sourceopen', this.sourceOpenListener)
|
||||
}
|
||||
|
||||
private flushBuffers = () => {
|
||||
if (
|
||||
this.destroyed ||
|
||||
!this.sourceBuffer ||
|
||||
this.sourceBuffer.updating ||
|
||||
this.mediaSource?.readyState !== 'open'
|
||||
)
|
||||
return
|
||||
|
||||
const nextBuffer = this.cacheBuffers.shift()
|
||||
if (nextBuffer) {
|
||||
this.sourceBuffer.appendBuffer(nextBuffer)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.streamEnded && !this.endOfStreamCalled) {
|
||||
this.endOfStreamCalled = true
|
||||
this.mediaSource.endOfStream()
|
||||
}
|
||||
}
|
||||
|
||||
private requestPlayback(reportIfPlaying = false) {
|
||||
if (this.destroyed || this.playbackPending) return
|
||||
if (!this.isAudioContextPaused() && !this.audio.paused && !this.audio.ended) {
|
||||
if (reportIfPlaying) this.callback?.('play')
|
||||
return
|
||||
}
|
||||
|
||||
this.playbackPending = true
|
||||
void this.resumeAndPlay()
|
||||
}
|
||||
|
||||
private isAudioContextPaused() {
|
||||
return this.audioContext.state === 'suspended' || this.audioContext.state === 'interrupted'
|
||||
}
|
||||
|
||||
private async resumeAndPlay() {
|
||||
try {
|
||||
const pendingOperations: Promise<unknown>[] = []
|
||||
if (this.isAudioContextPaused()) pendingOperations.push(this.audioContext.resume())
|
||||
if (this.audio.paused || this.audio.ended) pendingOperations.push(this.audio.play())
|
||||
|
||||
await Promise.all(pendingOperations)
|
||||
if (this.destroyed) return
|
||||
if (this.isAudioContextPaused()) {
|
||||
this.callback?.('error')
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.destroyed) this.callback?.('play')
|
||||
} catch {
|
||||
if (!this.destroyed) this.callback?.('error')
|
||||
} finally {
|
||||
this.playbackPending = false
|
||||
}
|
||||
}
|
||||
|
||||
public setCallback(callback: ((event: string) => void) | null) {
|
||||
@@ -80,7 +155,7 @@ export default class AudioPlayer {
|
||||
false,
|
||||
)
|
||||
this.audio.addEventListener(
|
||||
'paused',
|
||||
'pause',
|
||||
() => {
|
||||
callback('paused')
|
||||
},
|
||||
@@ -133,7 +208,7 @@ export default class AudioPlayer {
|
||||
|
||||
private async loadAudio() {
|
||||
try {
|
||||
const audioResponse: any = await textToAudioStream(
|
||||
const audioResponse = (await textToAudioStream(
|
||||
this.url,
|
||||
this.isPublic ? AppSourceType.webApp : AppSourceType.installedApp,
|
||||
{ content_type: 'audio/mpeg' },
|
||||
@@ -143,19 +218,21 @@ export default class AudioPlayer {
|
||||
voice: this.voice,
|
||||
text: this.msgContent,
|
||||
},
|
||||
)
|
||||
)) as Response
|
||||
if (audioResponse.status !== 200) {
|
||||
this.isLoadData = false
|
||||
if (this.callback) this.callback('error')
|
||||
this.callback?.('error')
|
||||
return
|
||||
}
|
||||
if (!audioResponse.body) throw new Error('Audio response body is missing')
|
||||
const reader = audioResponse.body.getReader()
|
||||
while (true) {
|
||||
const { value, done } = await reader.read()
|
||||
if (value?.byteLength) this.receiveAudioData(value)
|
||||
if (done) {
|
||||
this.receiveAudioData(value)
|
||||
this.finishStream()
|
||||
break
|
||||
}
|
||||
this.receiveAudioData(value)
|
||||
}
|
||||
} catch {
|
||||
this.isLoadData = false
|
||||
@@ -166,46 +243,29 @@ export default class AudioPlayer {
|
||||
// play audio
|
||||
public playAudio() {
|
||||
if (this.isLoadData) {
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
this.audioContext.resume().then((_) => {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
})
|
||||
} else if (this.audio.ended) {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
if (!this.mediaSource && !this.objectUrl) {
|
||||
this.playWhenReady = true
|
||||
return
|
||||
}
|
||||
this.callback?.('play')
|
||||
this.requestPlayback(true)
|
||||
} else {
|
||||
this.isLoadData = true
|
||||
this.audioContext.resume().then((_) => {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
})
|
||||
this.playWhenReady = true
|
||||
if (this.mediaSource) this.requestPlayback(true)
|
||||
else if (this.isAudioContextPaused()) void this.audioContext.resume().catch(() => {})
|
||||
this.loadAudio()
|
||||
}
|
||||
}
|
||||
|
||||
private theEndOfStream() {
|
||||
const endTimer = setInterval(() => {
|
||||
if (!this.sourceBuffer?.updating) {
|
||||
this.mediaSource?.endOfStream()
|
||||
clearInterval(endTimer)
|
||||
}
|
||||
}, 10)
|
||||
}
|
||||
|
||||
private finishStream() {
|
||||
const timer = setInterval(() => {
|
||||
if (!this.cacheBuffers.length) {
|
||||
this.theEndOfStream()
|
||||
clearInterval(timer)
|
||||
}
|
||||
if (this.cacheBuffers.length && !this.sourceBuffer?.updating) {
|
||||
const arrayBuffer = this.cacheBuffers.shift()!
|
||||
this.sourceBuffer?.appendBuffer(arrayBuffer)
|
||||
}
|
||||
}, 10)
|
||||
if (this.destroyed) return
|
||||
this.streamEnded = true
|
||||
if (this.mediaSource) {
|
||||
this.flushBuffers()
|
||||
return
|
||||
}
|
||||
|
||||
this.finishBlobAudio()
|
||||
}
|
||||
|
||||
public async playAudioWithAudio(audio: string, play = true) {
|
||||
@@ -213,54 +273,82 @@ export default class AudioPlayer {
|
||||
this.finishStream()
|
||||
return
|
||||
}
|
||||
const audioContent = Buffer.from(audio, 'base64')
|
||||
this.receiveAudioData(new Uint8Array(audioContent))
|
||||
const audioContent = Uint8Array.from(atob(audio), (char) => char.charCodeAt(0))
|
||||
this.receiveAudioData(audioContent)
|
||||
if (play) {
|
||||
this.isLoadData = true
|
||||
if (this.audio.paused) {
|
||||
this.audioContext.resume().then((_) => {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
})
|
||||
} else if (this.audio.ended) {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
} else if (this.audio.played) {
|
||||
/* empty */
|
||||
} else {
|
||||
this.audio.play()
|
||||
this.callback?.('play')
|
||||
}
|
||||
this.playWhenReady = true
|
||||
if (this.mediaSource) this.requestPlayback()
|
||||
}
|
||||
}
|
||||
|
||||
public pauseAudio() {
|
||||
this.playWhenReady = false
|
||||
this.callback?.('paused')
|
||||
this.audio.pause()
|
||||
this.audioContext.suspend()
|
||||
void this.audioContext.suspend().catch(() => {})
|
||||
}
|
||||
|
||||
private receiveAudioData(unit8Array: Uint8Array) {
|
||||
public destroy() {
|
||||
if (this.destroyed) return
|
||||
|
||||
this.destroyed = true
|
||||
this.cacheBuffers = []
|
||||
this.callback?.('paused')
|
||||
this.audio.pause()
|
||||
|
||||
if (this.sourceOpenListener)
|
||||
this.mediaSource?.removeEventListener('sourceopen', this.sourceOpenListener)
|
||||
|
||||
if (this.sourceBuffer) {
|
||||
this.sourceBuffer.removeEventListener('updateend', this.flushBuffers)
|
||||
if (this.mediaSource?.readyState === 'open') {
|
||||
try {
|
||||
this.sourceBuffer.abort()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
void this.audioContext.close().catch(() => {})
|
||||
this.releaseObjectUrl()
|
||||
}
|
||||
|
||||
private receiveAudioData(unit8Array: Uint8Array | undefined) {
|
||||
if (this.destroyed || this.streamEnded) return
|
||||
if (!unit8Array) {
|
||||
this.finishStream()
|
||||
return
|
||||
}
|
||||
const audioData = this.byteArrayToArrayBuffer(unit8Array)
|
||||
if (!audioData.byteLength) {
|
||||
if (this.mediaSource?.readyState === 'open') this.finishStream()
|
||||
this.finishStream()
|
||||
return
|
||||
}
|
||||
if (this.sourceBuffer?.updating) {
|
||||
this.cacheBuffers.push(audioData)
|
||||
} else {
|
||||
if (this.cacheBuffers.length && !this.sourceBuffer?.updating) {
|
||||
this.cacheBuffers.push(audioData)
|
||||
const cacheBuffer = this.cacheBuffers.shift()!
|
||||
this.sourceBuffer?.appendBuffer(cacheBuffer)
|
||||
} else {
|
||||
this.sourceBuffer?.appendBuffer(audioData)
|
||||
}
|
||||
this.cacheBuffers.push(audioData)
|
||||
this.flushBuffers()
|
||||
}
|
||||
|
||||
private finishBlobAudio() {
|
||||
if (!this.cacheBuffers.length) {
|
||||
if (!this.objectUrl) this.isLoadData = false
|
||||
return
|
||||
}
|
||||
|
||||
const audioBlob = new Blob(this.cacheBuffers, { type: AUDIO_CONTENT_TYPE })
|
||||
this.cacheBuffers = []
|
||||
this.releaseObjectUrl()
|
||||
this.objectUrl = URL.createObjectURL(audioBlob)
|
||||
this.audio.src = this.objectUrl
|
||||
this.isLoadData = true
|
||||
if (this.playWhenReady) this.requestPlayback()
|
||||
}
|
||||
|
||||
private releaseObjectUrl() {
|
||||
if (!this.objectUrl) return
|
||||
|
||||
URL.revokeObjectURL(this.objectUrl)
|
||||
this.objectUrl = ''
|
||||
this.audio.src = ''
|
||||
}
|
||||
|
||||
private byteArrayToArrayBuffer(byteArray: Uint8Array): ArrayBuffer {
|
||||
|
||||
@@ -46,6 +46,27 @@ describe('SearchInput', () => {
|
||||
const clearButton = screen.getByLabelText('common.operation.clear')
|
||||
expect(clearButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps a disabled searchbox inert and exposes its description', () => {
|
||||
render(
|
||||
<>
|
||||
<SearchInput
|
||||
disabled
|
||||
aria-describedby="search-unavailable"
|
||||
value="has value"
|
||||
onValueChange={() => {}}
|
||||
/>
|
||||
<span id="search-unavailable">Search unavailable</span>
|
||||
</>,
|
||||
)
|
||||
|
||||
const searchbox = screen.getByRole('searchbox', { name: 'common.operation.search' })
|
||||
expect(searchbox).toBeDisabled()
|
||||
expect(searchbox).toHaveAccessibleDescription('Search unavailable')
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'common.operation.clear' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interaction', () => {
|
||||
|
||||
@@ -11,7 +11,7 @@ type SearchInputProps = {
|
||||
onValueChange: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
} & Pick<InputProps, 'aria-label' | 'autoFocus'>
|
||||
} & Pick<InputProps, 'aria-describedby' | 'aria-label' | 'autoFocus' | 'disabled'>
|
||||
|
||||
export function SearchInput({
|
||||
ref,
|
||||
@@ -20,6 +20,8 @@ export function SearchInput({
|
||||
value,
|
||||
onValueChange,
|
||||
autoFocus,
|
||||
disabled,
|
||||
'aria-describedby': ariaDescribedBy,
|
||||
'aria-label': ariaLabel,
|
||||
}: SearchInputProps) {
|
||||
const { t } = useTranslation()
|
||||
@@ -48,6 +50,7 @@ export function SearchInput({
|
||||
ref={inputRef}
|
||||
type="search"
|
||||
name="query"
|
||||
aria-describedby={ariaDescribedBy}
|
||||
aria-label={ariaLabel ?? t(($) => $['operation.search'], { ns: 'common' })}
|
||||
className={cn(
|
||||
'ps-7',
|
||||
@@ -56,6 +59,7 @@ export function SearchInput({
|
||||
)}
|
||||
placeholder={placeholder ?? t(($) => $['operation.search'], { ns: 'common' })}
|
||||
value={inputValue}
|
||||
disabled={disabled}
|
||||
onValueChange={(nextValue) => {
|
||||
if (isComposingRef.current) {
|
||||
setCompositionValue(nextValue)
|
||||
@@ -90,7 +94,7 @@ export function SearchInput({
|
||||
autoFocus={autoFocus}
|
||||
enterKeyHint="search"
|
||||
/>
|
||||
{!!inputValue && (
|
||||
{!!inputValue && !disabled && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $['operation.clear'], { ns: 'common' })}
|
||||
|
||||
@@ -178,23 +178,41 @@ describe('MainNavLayout', () => {
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['/datasets/create', '/datasets/dataset-1/documents/create', '/deployments/create'])(
|
||||
'keeps the global main nav on collection and creation route %s',
|
||||
(pathname) => {
|
||||
;(usePathname as Mock).mockReturnValue(pathname)
|
||||
it('ignores a retained legacy detail sidebar on New Knowledge routes', () => {
|
||||
;(usePathname as Mock).mockReturnValue('/datasets/new/knowledge-1/sources')
|
||||
|
||||
render(
|
||||
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
|
||||
<div>content</div>
|
||||
</MainNavLayout>,
|
||||
)
|
||||
render(
|
||||
<MainNavLayout
|
||||
detailSidebar={<aside aria-label="Legacy dataset sidebar">Legacy dataset sidebar</aside>}
|
||||
>
|
||||
<div>new knowledge detail</div>
|
||||
</MainNavLayout>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('complementary', { name: 'Detail sidebar' }),
|
||||
).not.toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
expect(screen.queryByTestId('main-nav')).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('complementary', { name: 'Legacy dataset sidebar' }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByRole('main')).toHaveTextContent('new knowledge detail')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'/datasets/create',
|
||||
'/datasets/new/create',
|
||||
'/datasets/dataset-1/documents/create',
|
||||
'/deployments/create',
|
||||
])('keeps the global main nav on collection and creation route %s', (pathname) => {
|
||||
;(usePathname as Mock).mockReturnValue(pathname)
|
||||
|
||||
render(
|
||||
<MainNavLayout detailSidebar={<aside aria-label="Detail sidebar">Detail sidebar</aside>}>
|
||||
<div>content</div>
|
||||
</MainNavLayout>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('main-nav')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
|
||||
@@ -15,7 +15,7 @@ import { isAgentV2Enabled } from '@/features/agent-v2/feature-flag'
|
||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { MainNav } from '.'
|
||||
import { shouldUseDetailSidebar } from './routes'
|
||||
import { shouldHideMainNavigation, shouldUseDetailSidebar } from './routes'
|
||||
import { MAIN_CONTENT_ID, SkipNav } from './skip-nav'
|
||||
|
||||
type MainNavLayoutProps = {
|
||||
@@ -47,7 +47,8 @@ const MainNavLayout = ({ children, detailSidebar }: MainNavLayoutProps) => {
|
||||
const isCurrentWorkspaceDatasetOperator = useAtomValue(isCurrentWorkspaceDatasetOperatorAtom)
|
||||
const isCurrentWorkspaceEditor = useAtomValue(isCurrentWorkspaceEditorAtom)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const shouldHideMainNav = shouldUseDetailSidebar(pathname, {
|
||||
const hideMainNavigation = shouldHideMainNavigation(pathname)
|
||||
const useDetailSidebar = shouldUseDetailSidebar(pathname, {
|
||||
agentV2Enabled: isAgentV2Enabled(),
|
||||
canUseAppDeploy: isCurrentWorkspaceEditor && systemFeatures.enable_app_deploy,
|
||||
isCurrentWorkspaceDatasetOperator,
|
||||
@@ -57,7 +58,7 @@ const MainNavLayout = ({ children, detailSidebar }: MainNavLayoutProps) => {
|
||||
<div className="flex h-0 min-h-0 min-w-0 grow overflow-hidden bg-background-body">
|
||||
<SkipNav>{t(($) => $['navigation.skipToMain'])}</SkipNav>
|
||||
<AppDetailStoreCleanup />
|
||||
{shouldHideMainNav ? detailSidebar : <MainNav />}
|
||||
{hideMainNavigation ? null : useDetailSidebar ? detailSidebar : <MainNav />}
|
||||
<main
|
||||
id={MAIN_CONTENT_ID}
|
||||
tabIndex={-1}
|
||||
|
||||
@@ -131,12 +131,25 @@ function isDatasetDetailPathname(pathname: string) {
|
||||
|
||||
if (DATASET_COLLECTION_ROUTES.has(datasetId)) return false
|
||||
|
||||
if (datasetId === 'new' && subSection === 'create') return false
|
||||
|
||||
if (subSection === 'documents' && action && DATASET_DOCUMENT_CREATION_ROUTES.has(action))
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function shouldHideMainNavigation(pathname: string) {
|
||||
const [section, namespace, knowledgeSpaceId] = pathname.split('/').filter(Boolean)
|
||||
|
||||
return (
|
||||
section === 'datasets' &&
|
||||
namespace === 'new' &&
|
||||
!!knowledgeSpaceId &&
|
||||
knowledgeSpaceId !== 'create'
|
||||
)
|
||||
}
|
||||
|
||||
function isAgentDetailPathname(pathname: string) {
|
||||
const [section, agentId] = pathname.split('/').filter(Boolean)
|
||||
|
||||
|
||||
@@ -4,15 +4,17 @@ Renders the KnowledgeFS-backed knowledge list, creation entry points, and first-
|
||||
|
||||
## Internal Modules
|
||||
|
||||
- `components/create-knowledge-dialog-parts`
|
||||
- `components/create-knowledge-exit-dialog`
|
||||
- `components/knowledge-space-card`
|
||||
- `components/knowledge-view-switcher`
|
||||
- `components/new-knowledge-list-states`
|
||||
- `create-knowledge-workflow`
|
||||
- `storage`
|
||||
|
||||
## External Modules
|
||||
|
||||
- `app/components/apps/first-empty-state/action-card`
|
||||
- `app/components/base/infotip`
|
||||
- `app/components/base/corner-label`
|
||||
- `app/components/base/search-input`
|
||||
- `app/components/base/skeleton`
|
||||
- `app/components/datasets/external-api/external-api-panel`
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { act, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { CreateKnowledgePage } from '../create-knowledge-page'
|
||||
|
||||
const serviceMock = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getPolicy: vi.fn(),
|
||||
patchPolicy: vi.fn(),
|
||||
listKey: vi.fn(() => ['console', 'knowledgeFs', 'listKnowledgeSpaces']),
|
||||
}))
|
||||
|
||||
const routerMock = vi.hoisted(() => ({
|
||||
back: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
}))
|
||||
|
||||
const navigationMock = vi.hoisted(() => ({
|
||||
startMode: null as string | null,
|
||||
}))
|
||||
|
||||
const permissionStateMock = vi.hoisted(() => ({
|
||||
atom: Symbol('workspacePermissionKeysAtom'),
|
||||
keys: ['dataset.create_and_management', 'dataset.acl.access_config'],
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => routerMock,
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => (key === 'start' ? navigationMock.startMode : null),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/permission-state', () => ({
|
||||
workspacePermissionKeysAtom: permissionStateMock.atom,
|
||||
}))
|
||||
|
||||
vi.mock('jotai', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('jotai')>()
|
||||
return {
|
||||
...original,
|
||||
useAtomValue: (atom: unknown) =>
|
||||
atom === permissionStateMock.atom
|
||||
? permissionStateMock.keys
|
||||
: original.useAtomValue(atom as Parameters<typeof original.useAtomValue>[0]),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleClient: {
|
||||
knowledgeFs: {
|
||||
createKnowledgeSpace: serviceMock.create,
|
||||
getKnowledgeSpacesByIdAccessPolicy: serviceMock.getPolicy,
|
||||
patchKnowledgeSpacesByIdAccessPolicy: serviceMock.patchPolicy,
|
||||
},
|
||||
},
|
||||
consoleQuery: {
|
||||
knowledgeFs: {
|
||||
listKnowledgeSpaces: {
|
||||
key: serviceMock.listKey,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
const createdKnowledge = {
|
||||
configurationStatus: 'ready',
|
||||
createdAt: '2026-07-20T00:00:00Z',
|
||||
id: 'e735c1dc-d2b8-4dc4-86dc-abaf2fb7d084',
|
||||
name: 'Product handbook',
|
||||
revision: 1,
|
||||
slug: 'product-handbook',
|
||||
tenantId: 'tenant-1',
|
||||
updatedAt: '2026-07-20T00:00:00Z',
|
||||
}
|
||||
|
||||
function renderPage(
|
||||
queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } }),
|
||||
) {
|
||||
const Wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
)
|
||||
return { queryClient, ...render(<CreateKnowledgePage />, { wrapper: Wrapper }) }
|
||||
}
|
||||
|
||||
async function fillRequiredFields(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' }),
|
||||
' Product handbook ',
|
||||
)
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: /dataset\.newKnowledge\.description/ }),
|
||||
' Internal answers ',
|
||||
)
|
||||
}
|
||||
|
||||
async function choosePermission(user: ReturnType<typeof userEvent.setup>, optionName: string) {
|
||||
await user.click(screen.getByRole('combobox', { name: 'dataset.newKnowledge.permission' }))
|
||||
await user.click(await screen.findByRole('option', { name: optionName }))
|
||||
}
|
||||
|
||||
describe('CreateKnowledgePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
serviceMock.create.mockResolvedValue(createdKnowledge)
|
||||
serviceMock.getPolicy.mockResolvedValue({
|
||||
id: 'policy-1',
|
||||
ownerSubjectId: 'user-1',
|
||||
partialMemberSubjectIds: [],
|
||||
revision: 4,
|
||||
visibility: 'only_me',
|
||||
})
|
||||
serviceMock.patchPolicy.mockResolvedValue({
|
||||
id: 'policy-1',
|
||||
ownerSubjectId: 'user-1',
|
||||
partialMemberSubjectIds: [],
|
||||
revision: 5,
|
||||
visibility: 'all_members',
|
||||
})
|
||||
permissionStateMock.keys = ['dataset.create_and_management', 'dataset.acl.access_config']
|
||||
navigationMock.startMode = null
|
||||
vi.spyOn(globalThis.crypto, 'randomUUID').mockReturnValue(
|
||||
'a9c36c57-2d84-44d6-a36d-841f0d92a179',
|
||||
)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('keeps create reachable and reports an empty knowledge name', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
const createButton = screen.getByRole('button', {
|
||||
name: 'dataset.newKnowledge.createTitle',
|
||||
})
|
||||
expect(createButton).toBeEnabled()
|
||||
|
||||
await user.click(createButton)
|
||||
|
||||
expect(await screen.findByText('dataset.newKnowledge.nameRequired')).toBeInTheDocument()
|
||||
expect(serviceMock.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates a private empty knowledge space, invalidates the list, and navigates', async () => {
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
renderPage(queryClient)
|
||||
await fillRequiredFields(user)
|
||||
await choosePermission(user, 'dataset.newKnowledge.permissionOnlyMe')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(serviceMock.create).toHaveBeenCalledWith({
|
||||
body: {
|
||||
description: 'Internal answers',
|
||||
idempotencyKey: 'a9c36c57-2d84-44d6-a36d-841f0d92a179',
|
||||
name: 'Product handbook',
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(serviceMock.getPolicy).not.toHaveBeenCalled()
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: ['console', 'knowledgeFs', 'listKnowledgeSpaces'],
|
||||
})
|
||||
expect(routerMock.replace).toHaveBeenCalledWith(
|
||||
'/datasets/new/e735c1dc-d2b8-4dc4-86dc-abaf2fb7d084/sources',
|
||||
)
|
||||
})
|
||||
|
||||
it('defaults authorized users to the Figma all-members policy and updates its revision', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
await fillRequiredFields(user)
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: 'dataset.newKnowledge.permission' }),
|
||||
).toHaveTextContent('dataset.newKnowledge.permissionAllMembers')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(serviceMock.patchPolicy).toHaveBeenCalledWith({
|
||||
body: {
|
||||
expectedRevision: 4,
|
||||
partialMemberSubjectIds: [],
|
||||
visibility: 'all_members',
|
||||
},
|
||||
params: { id: createdKnowledge.id },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('forces users without access-config permission to create a private space', async () => {
|
||||
const user = userEvent.setup()
|
||||
permissionStateMock.keys = ['dataset.create_and_management']
|
||||
renderPage()
|
||||
await fillRequiredFields(user)
|
||||
|
||||
const permission = screen.getByRole('combobox', {
|
||||
name: 'dataset.newKnowledge.permission',
|
||||
})
|
||||
expect(permission).toBeDisabled()
|
||||
expect(permission).toHaveTextContent('dataset.newKnowledge.permissionOnlyMe')
|
||||
expect(permission).toHaveAccessibleDescription('dataset.newKnowledge.permissionRestricted')
|
||||
expect(screen.getByText('dataset.newKnowledge.permissionRestricted')).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
|
||||
|
||||
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledOnce())
|
||||
expect(serviceMock.getPolicy).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prevents duplicate pending submissions', async () => {
|
||||
const user = userEvent.setup()
|
||||
let resolveCreate: (value: typeof createdKnowledge) => void = () => undefined
|
||||
serviceMock.create.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveCreate = resolve
|
||||
}),
|
||||
)
|
||||
renderPage()
|
||||
await fillRequiredFields(user)
|
||||
const createButton = screen.getByRole('button', {
|
||||
name: 'dataset.newKnowledge.createTitle',
|
||||
})
|
||||
|
||||
await user.dblClick(createButton)
|
||||
|
||||
expect(serviceMock.create).toHaveBeenCalledOnce()
|
||||
expect(createButton).toHaveAttribute('aria-disabled', 'true')
|
||||
resolveCreate(createdKnowledge)
|
||||
})
|
||||
|
||||
it('keeps the same idempotency key for a safe retry after failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
serviceMock.create.mockRejectedValueOnce(new Error('upstream unavailable'))
|
||||
renderPage()
|
||||
await fillRequiredFields(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('dataset.newKnowledge.createFailed')
|
||||
expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' })).toBeDisabled()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
|
||||
|
||||
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledTimes(2))
|
||||
expect(serviceMock.create.mock.calls[0]?.[0].body.idempotencyKey).toBe(
|
||||
serviceMock.create.mock.calls[1]?.[0].body.idempotencyKey,
|
||||
)
|
||||
})
|
||||
|
||||
it.each([400, 401, 403, 422])(
|
||||
'unlocks editable fields and rotates the idempotency key after a definitive %s rejection',
|
||||
async (status) => {
|
||||
const user = userEvent.setup()
|
||||
vi.mocked(globalThis.crypto.randomUUID)
|
||||
.mockReturnValueOnce('11111111-1111-4111-8111-111111111111')
|
||||
.mockReturnValueOnce('22222222-2222-4222-8222-222222222222')
|
||||
serviceMock.create.mockRejectedValueOnce({ status })
|
||||
renderPage()
|
||||
await fillRequiredFields(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'dataset.newKnowledge.createFailed',
|
||||
)
|
||||
const nameInput = screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' })
|
||||
expect(nameInput).toBeEnabled()
|
||||
await user.clear(nameInput)
|
||||
await user.type(nameInput, 'Updated handbook')
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
|
||||
|
||||
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledTimes(2))
|
||||
expect(serviceMock.create.mock.calls[0]?.[0].body.idempotencyKey).toBe(
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
)
|
||||
expect(serviceMock.create.mock.calls[1]?.[0].body).toMatchObject({
|
||||
idempotencyKey: '22222222-2222-4222-8222-222222222222',
|
||||
name: 'Updated handbook',
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
it.each([409, 429, 503])(
|
||||
'keeps request identity frozen after an ambiguous %s response',
|
||||
async (status) => {
|
||||
const user = userEvent.setup()
|
||||
serviceMock.create.mockRejectedValueOnce({ status })
|
||||
renderPage()
|
||||
await fillRequiredFields(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'dataset.newKnowledge.createFailed',
|
||||
)
|
||||
expect(screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' })).toBeDisabled()
|
||||
expect(
|
||||
screen.getByRole('combobox', { name: 'dataset.newKnowledge.permission' }),
|
||||
).toBeDisabled()
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
|
||||
|
||||
await waitFor(() => expect(serviceMock.create).toHaveBeenCalledTimes(2))
|
||||
expect(serviceMock.create.mock.calls[0]?.[0].body.idempotencyKey).toBe(
|
||||
serviceMock.create.mock.calls[1]?.[0].body.idempotencyKey,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it('safely resumes the permission step after a partial failure', async () => {
|
||||
const user = userEvent.setup()
|
||||
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } })
|
||||
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
|
||||
serviceMock.patchPolicy.mockRejectedValueOnce(new Error('policy update unavailable'))
|
||||
renderPage(queryClient)
|
||||
await fillRequiredFields(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'dataset.newKnowledge.permissionUpdateFailed',
|
||||
)
|
||||
expect(invalidate).toHaveBeenCalledWith({
|
||||
queryKey: ['console', 'knowledgeFs', 'listKnowledgeSpaces'],
|
||||
})
|
||||
const nameInput = screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' })
|
||||
expect(nameInput).toBeDisabled()
|
||||
expect(screen.getByRole('combobox', { name: 'dataset.newKnowledge.permission' })).toBeDisabled()
|
||||
await user.type(nameInput, ' changed')
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
|
||||
|
||||
await waitFor(() => expect(serviceMock.patchPolicy).toHaveBeenCalledTimes(2))
|
||||
expect(serviceMock.create).toHaveBeenCalledOnce()
|
||||
expect(routerMock.replace).toHaveBeenCalledWith(
|
||||
'/datasets/new/e735c1dc-d2b8-4dc4-86dc-abaf2fb7d084/sources',
|
||||
)
|
||||
})
|
||||
|
||||
it('converges after a permission update succeeds but its response is lost', async () => {
|
||||
const user = userEvent.setup()
|
||||
serviceMock.getPolicy
|
||||
.mockResolvedValueOnce({
|
||||
id: 'policy-1',
|
||||
ownerSubjectId: 'user-1',
|
||||
partialMemberSubjectIds: [],
|
||||
revision: 4,
|
||||
visibility: 'only_me',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'policy-1',
|
||||
ownerSubjectId: 'user-1',
|
||||
partialMemberSubjectIds: [],
|
||||
revision: 5,
|
||||
visibility: 'all_members',
|
||||
})
|
||||
serviceMock.patchPolicy.mockRejectedValueOnce(new Error('response lost'))
|
||||
renderPage()
|
||||
await fillRequiredFields(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||
'dataset.newKnowledge.permissionUpdateFailed',
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
|
||||
|
||||
await waitFor(() => expect(routerMock.replace).toHaveBeenCalledOnce())
|
||||
expect(serviceMock.create).toHaveBeenCalledOnce()
|
||||
expect(serviceMock.getPolicy).toHaveBeenCalledTimes(2)
|
||||
expect(serviceMock.patchPolicy).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps source and upload disabled until their stacked flows land', () => {
|
||||
navigationMock.startMode = 'source'
|
||||
renderPage()
|
||||
|
||||
const startEmpty = screen.getByRole('radio', { name: 'dataset.newKnowledge.startEmpty' })
|
||||
expect(startEmpty).toBeChecked()
|
||||
expect(startEmpty).toHaveAccessibleDescription('dataset.newKnowledge.startEmptyDescription')
|
||||
const connectSource = screen.getByRole('radio', {
|
||||
name: 'dataset.newKnowledge.connectSource',
|
||||
})
|
||||
const uploadFiles = screen.getByRole('radio', { name: 'dataset.newKnowledge.uploadFiles' })
|
||||
expect(connectSource).toBeDisabled()
|
||||
expect(connectSource).toHaveAccessibleDescription(
|
||||
'dataset.newKnowledge.connectSourceDescription dataset.cornerLabel.unavailable',
|
||||
)
|
||||
expect(uploadFiles).toBeDisabled()
|
||||
expect(uploadFiles).toHaveAccessibleDescription(
|
||||
'dataset.newKnowledge.uploadFilesDescription dataset.cornerLabel.unavailable',
|
||||
)
|
||||
})
|
||||
|
||||
it('renders the approved creation modal and exposes both dismiss actions', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
const dialog = screen.getByRole('dialog', {
|
||||
name: 'dataset.newKnowledge.createTitle',
|
||||
})
|
||||
expect(
|
||||
within(dialog).getByRole('heading', { name: 'dataset.newKnowledge.createTitle' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByPlaceholderText('dataset.newKnowledge.namePlaceholder')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByPlaceholderText('dataset.newKnowledge.descriptionPlaceholder'),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.newKnowledge.descriptionHelp')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.newKnowledge.startWithHelp')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.newKnowledge.illustrationHeadline')).toBeInTheDocument()
|
||||
expect(document.querySelector('.bg-background-overlay-backdrop')).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
expect(routerMock.replace).toHaveBeenCalledWith('/datasets?view=new')
|
||||
routerMock.replace.mockClear()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
expect(routerMock.replace).toHaveBeenCalledWith('/datasets?view=new')
|
||||
routerMock.replace.mockClear()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
expect(routerMock.replace).toHaveBeenCalledWith('/datasets?view=new')
|
||||
})
|
||||
|
||||
it('asks before discarding an unsaved draft', async () => {
|
||||
const user = userEvent.setup()
|
||||
const historyBack = vi.spyOn(window.history, 'back').mockImplementation(() => undefined)
|
||||
renderPage()
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' }),
|
||||
'Draft knowledge',
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.close' }))
|
||||
|
||||
expect(routerMock.back).not.toHaveBeenCalled()
|
||||
const confirmation = await screen.findByRole('alertdialog', {
|
||||
name: 'dataset.newKnowledge.discardDraftTitle',
|
||||
})
|
||||
expect(confirmation).toHaveTextContent('dataset.newKnowledge.discardDraftDescription')
|
||||
await user.click(
|
||||
within(confirmation).getByRole('button', {
|
||||
name: 'dataset.newKnowledge.discardDraftConfirm',
|
||||
}),
|
||||
)
|
||||
expect(historyBack).toHaveBeenCalledOnce()
|
||||
|
||||
act(() => window.dispatchEvent(new PopStateEvent('popstate')))
|
||||
|
||||
expect(routerMock.replace).toHaveBeenCalledWith('/datasets?view=new')
|
||||
})
|
||||
|
||||
it('protects an unsaved draft from browser unload', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' }),
|
||||
'Draft knowledge',
|
||||
)
|
||||
const event = new Event('beforeunload', { cancelable: true })
|
||||
|
||||
act(() => window.dispatchEvent(event))
|
||||
|
||||
expect(event.defaultPrevented).toBe(true)
|
||||
})
|
||||
|
||||
it('asks before leaving an unsaved draft with browser Back', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' }),
|
||||
'Draft knowledge',
|
||||
)
|
||||
|
||||
act(() => window.dispatchEvent(new PopStateEvent('popstate')))
|
||||
|
||||
const confirmation = await screen.findByRole('alertdialog', {
|
||||
name: 'dataset.newKnowledge.discardDraftTitle',
|
||||
})
|
||||
await user.click(
|
||||
within(confirmation).getByRole('button', {
|
||||
name: 'dataset.newKnowledge.discardDraftConfirm',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(routerMock.replace).toHaveBeenCalledWith('/datasets?view=new')
|
||||
})
|
||||
|
||||
it('does not warn after a draft is cleared before browser Back', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
const nameInput = screen.getByRole('textbox', { name: 'dataset.newKnowledge.name' })
|
||||
await user.type(nameInput, 'Draft knowledge')
|
||||
await user.clear(nameInput)
|
||||
|
||||
act(() => window.dispatchEvent(new PopStateEvent('popstate')))
|
||||
|
||||
expect(
|
||||
screen.queryByRole('alertdialog', {
|
||||
name: 'dataset.newKnowledge.discardDraftTitle',
|
||||
}),
|
||||
).not.toBeInTheDocument()
|
||||
expect(routerMock.replace).toHaveBeenCalledWith('/datasets?view=new')
|
||||
})
|
||||
|
||||
it('warns before leaving a partially created knowledge space', async () => {
|
||||
const user = userEvent.setup()
|
||||
serviceMock.patchPolicy.mockRejectedValueOnce(new Error('policy update unavailable'))
|
||||
renderPage()
|
||||
await fillRequiredFields(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'dataset.newKnowledge.createTitle' }))
|
||||
expect(
|
||||
await screen.findByText('dataset.newKnowledge.permissionUpdateFailed'),
|
||||
).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.cancel' }))
|
||||
|
||||
expect(routerMock.back).not.toHaveBeenCalled()
|
||||
const confirmation = await screen.findByRole('alertdialog', {
|
||||
name: 'dataset.newKnowledge.leavePartialSetupTitle',
|
||||
})
|
||||
expect(confirmation).toHaveTextContent('dataset.newKnowledge.leavePartialSetupDescription')
|
||||
await user.click(
|
||||
within(confirmation).getByRole('button', {
|
||||
name: 'dataset.newKnowledge.leavePartialSetupConfirm',
|
||||
}),
|
||||
)
|
||||
expect(routerMock.replace).toHaveBeenCalledWith(
|
||||
'/datasets/new/e735c1dc-d2b8-4dc4-86dc-abaf2fb7d084/sources',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { render } from '@/test/console/render'
|
||||
import { KnowledgeRouteGuard } from '../knowledge-route-guard'
|
||||
|
||||
const featureMock = vi.hoisted(() => ({
|
||||
enabled: true,
|
||||
atom: Symbol('systemFeaturesAtom'),
|
||||
}))
|
||||
|
||||
const routerMock = vi.hoisted(() => ({ replace: vi.fn() }))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useRouter: () => routerMock,
|
||||
}))
|
||||
|
||||
vi.mock('@/context/system-features-state', () => ({
|
||||
systemFeaturesAtom: featureMock.atom,
|
||||
}))
|
||||
|
||||
vi.mock('jotai', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('jotai')>()
|
||||
return {
|
||||
...original,
|
||||
useAtomValue: (atom: unknown) =>
|
||||
atom === featureMock.atom
|
||||
? { knowledge_fs_enabled: featureMock.enabled }
|
||||
: original.useAtomValue(atom as Parameters<typeof original.useAtomValue>[0]),
|
||||
}
|
||||
})
|
||||
|
||||
describe('KnowledgeRouteGuard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
featureMock.enabled = true
|
||||
})
|
||||
|
||||
it('renders new KnowledgeFS routes while enabled', () => {
|
||||
render(
|
||||
<KnowledgeRouteGuard>
|
||||
<div>protected content</div>
|
||||
</KnowledgeRouteGuard>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('protected content')).toBeInTheDocument()
|
||||
expect(routerMock.replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('redirects without mounting KnowledgeFS route content while disabled', async () => {
|
||||
featureMock.enabled = false
|
||||
|
||||
render(
|
||||
<KnowledgeRouteGuard>
|
||||
<div>protected content</div>
|
||||
</KnowledgeRouteGuard>,
|
||||
)
|
||||
|
||||
expect(screen.queryByText('protected content')).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(routerMock.replace).toHaveBeenCalledWith('/datasets'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,154 @@
|
||||
import { screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { render } from '@/test/console/render'
|
||||
import { KnowledgeSpaceShell } from '../knowledge-space-shell'
|
||||
|
||||
const queryMock = vi.hoisted(() => ({
|
||||
data: undefined as
|
||||
| {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
| undefined,
|
||||
error: null as unknown,
|
||||
isPending: false,
|
||||
refetch: vi.fn(),
|
||||
}))
|
||||
|
||||
const queryOptionsMock = vi.hoisted(() => vi.fn(() => ({})))
|
||||
const useQueryOptionsMock = vi.hoisted(() => vi.fn())
|
||||
const pathnameMock = vi.hoisted(() => ({ value: '/datasets/new/space-1/sources' }))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => pathnameMock.value,
|
||||
}))
|
||||
|
||||
vi.mock('@tanstack/react-query', async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import('@tanstack/react-query')>()
|
||||
return {
|
||||
...original,
|
||||
useQuery: (options: unknown) => {
|
||||
useQueryOptionsMock(options)
|
||||
return queryMock
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/service/client', () => ({
|
||||
consoleQuery: {
|
||||
knowledgeFs: {
|
||||
getKnowledgeSpacesById: {
|
||||
queryOptions: queryOptionsMock,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/hooks/use-document-title', () => ({ default: vi.fn() }))
|
||||
|
||||
describe('KnowledgeSpaceShell', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
queryMock.data = undefined
|
||||
queryMock.error = null
|
||||
queryMock.isPending = false
|
||||
pathnameMock.value = '/datasets/new/space-1/sources'
|
||||
})
|
||||
|
||||
it('loads the real knowledge space contract by route id', () => {
|
||||
queryMock.isPending = true
|
||||
|
||||
render(<KnowledgeSpaceShell knowledgeSpaceId="space-1">content</KnowledgeSpaceShell>)
|
||||
|
||||
expect(queryOptionsMock).toHaveBeenCalledWith({ input: { params: { id: 'space-1' } } })
|
||||
expect(screen.getByRole('status')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders a refresh-safe header and route navigation when loaded', () => {
|
||||
queryMock.data = { id: 'space-1', name: 'Support knowledge' }
|
||||
|
||||
render(<KnowledgeSpaceShell knowledgeSpaceId="space-1">source content</KnowledgeSpaceShell>)
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Support knowledge' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: 'dataset.newKnowledge.sources' })).toHaveAttribute(
|
||||
'href',
|
||||
'/datasets/new/space-1/sources',
|
||||
)
|
||||
expect(screen.getByRole('link', { name: 'dataset.newKnowledge.sources' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
)
|
||||
expect(screen.getByRole('link', { name: 'dataset.newKnowledge.documents' })).toHaveAttribute(
|
||||
'href',
|
||||
'/datasets/new/space-1/documents',
|
||||
)
|
||||
expect(screen.getByText('source content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows a not-found state without rendering children', () => {
|
||||
queryMock.error = { status: 404 }
|
||||
|
||||
render(<KnowledgeSpaceShell knowledgeSpaceId="missing">source content</KnowledgeSpaceShell>)
|
||||
|
||||
expect(screen.getByText('dataset.newKnowledge.notFoundTitle')).toBeInTheDocument()
|
||||
expect(screen.queryByText('source content')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('recognizes the nested status shape returned by the ORPC client', () => {
|
||||
queryMock.error = { data: { status: 404 } }
|
||||
|
||||
render(<KnowledgeSpaceShell knowledgeSpaceId="missing">source content</KnowledgeSpaceShell>)
|
||||
|
||||
expect(screen.getByText('dataset.newKnowledge.notFoundTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('treats forbidden detail responses as a terminal non-disclosing state', () => {
|
||||
queryMock.error = { data: { status: 403 } }
|
||||
|
||||
render(<KnowledgeSpaceShell knowledgeSpaceId="private">source content</KnowledgeSpaceShell>)
|
||||
|
||||
expect(screen.getByText('dataset.newKnowledge.notFoundTitle')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'common.operation.retry' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it.each([{ status: 403 }, { data: { status: 404 } }])(
|
||||
'does not automatically retry terminal detail errors shaped as $error',
|
||||
(error) => {
|
||||
queryMock.error = error
|
||||
|
||||
render(<KnowledgeSpaceShell knowledgeSpaceId="private">source content</KnowledgeSpaceShell>)
|
||||
|
||||
const options = useQueryOptionsMock.mock.lastCall?.[0] as {
|
||||
retry: (failureCount: number, queryError: unknown) => boolean
|
||||
}
|
||||
expect(options.retry(0, error)).toBe(false)
|
||||
expect(options.retry(2, new Error('temporary failure'))).toBe(true)
|
||||
expect(options.retry(3, new Error('temporary failure'))).toBe(false)
|
||||
},
|
||||
)
|
||||
|
||||
it('marks Documents as the only current detail route', () => {
|
||||
pathnameMock.value = '/datasets/new/space-1/documents'
|
||||
queryMock.data = { id: 'space-1', name: 'Support knowledge' }
|
||||
|
||||
render(<KnowledgeSpaceShell knowledgeSpaceId="space-1">document content</KnowledgeSpaceShell>)
|
||||
|
||||
expect(screen.getByRole('link', { name: 'dataset.newKnowledge.sources' })).not.toHaveAttribute(
|
||||
'aria-current',
|
||||
)
|
||||
expect(screen.getByRole('link', { name: 'dataset.newKnowledge.documents' })).toHaveAttribute(
|
||||
'aria-current',
|
||||
'page',
|
||||
)
|
||||
})
|
||||
|
||||
it('offers a real retry for recoverable loading errors', async () => {
|
||||
const user = userEvent.setup()
|
||||
queryMock.error = new Error('temporary failure')
|
||||
|
||||
render(<KnowledgeSpaceShell knowledgeSpaceId="space-1">source content</KnowledgeSpaceShell>)
|
||||
await user.click(screen.getByRole('button', { name: 'common.operation.retry' }))
|
||||
|
||||
expect(queryMock.refetch).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -146,7 +146,7 @@ describe('NewKnowledgeList', () => {
|
||||
expect(options?.getNextPageParam({ items: [] })).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders real knowledge spaces as unavailable until the detail contract is supported', () => {
|
||||
it('links real knowledge spaces to the new detail shell', () => {
|
||||
setResolvedPage([
|
||||
{
|
||||
createdAt: '2026-07-15T00:00:00Z',
|
||||
@@ -172,15 +172,16 @@ describe('NewKnowledgeList', () => {
|
||||
|
||||
renderWithNuqs(<NewKnowledgeList view="new" onViewChange={vi.fn()} />)
|
||||
const list = screen.getByRole('list', { name: 'dataset.knowledge' })
|
||||
const supportCard = within(list).getByRole('article', {
|
||||
name: 'Support knowledge. dataset.cornerLabel.unavailable',
|
||||
const supportCard = within(list).getByRole('link', {
|
||||
name: 'Support knowledge',
|
||||
})
|
||||
expect(supportCard).toHaveAttribute('href', '/datasets/new/space-1/sources')
|
||||
expect(supportCard).toBeInTheDocument()
|
||||
expect(
|
||||
within(list).getByRole('article', {
|
||||
name: 'Engineering handbook. dataset.cornerLabel.unavailable',
|
||||
within(list).getByRole('link', {
|
||||
name: 'Engineering handbook',
|
||||
}),
|
||||
).toBeInTheDocument()
|
||||
).toHaveAttribute('href', '/datasets/new/space-2/sources')
|
||||
expect(within(list).getByText('Answers for customer support')).toBeInTheDocument()
|
||||
expect(within(list).getByText('dataset.newKnowledge.noDescription')).toBeInTheDocument()
|
||||
expect(within(supportCard).getByLabelText('camera')).toBeInTheDocument()
|
||||
@@ -188,7 +189,6 @@ describe('NewKnowledgeList', () => {
|
||||
expect(within(list).getAllByText('dataset.newKnowledge.tags')).toHaveLength(2)
|
||||
expect(within(list).getAllByText('dataset.newKnowledge.documentsUnavailable')).toHaveLength(2)
|
||||
expect(within(list).getAllByText('dataset.newKnowledge.appsUnavailable')).toHaveLength(2)
|
||||
expect(within(list).queryByRole('link')).not.toBeInTheDocument()
|
||||
expect(within(list).queryByRole('button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -225,22 +225,21 @@ describe('NewKnowledgeList', () => {
|
||||
const tags = screen.getByRole('button', { name: 'dataset.newKnowledge.tags' })
|
||||
const creators = screen.getByRole('button', { name: 'dataset.newKnowledge.creators' })
|
||||
const search = screen.getByRole('searchbox', { name: 'common.operation.search' })
|
||||
const create = screen.getByRole('button', { name: 'common.operation.create' })
|
||||
const create = screen.getByRole('link', { name: 'common.operation.create' })
|
||||
|
||||
expect(tags).toBeEnabled()
|
||||
expect(creators).toBeEnabled()
|
||||
await user.click(tags)
|
||||
expect(toastInfoMock).toHaveBeenCalledWith('dataset.newKnowledge.filtersUnavailable')
|
||||
expect(search).toBeEnabled()
|
||||
expect(create).toBeDisabled()
|
||||
expect(create).toHaveAccessibleDescription('dataset.cornerLabel.unavailable')
|
||||
expect(create).toHaveAttribute('href', '/datasets/new/create')
|
||||
|
||||
await user.type(search, 'customer support')
|
||||
expect(screen.getByText('Support knowledge')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Engineering handbook')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows unavailable empty-state creation entries to authorized users', () => {
|
||||
it('keeps stacked creation modes disabled while start empty remains available', () => {
|
||||
setResolvedPage()
|
||||
|
||||
renderWithNuqs(<NewKnowledgeList view="new" onViewChange={vi.fn()} />)
|
||||
@@ -251,7 +250,7 @@ describe('NewKnowledgeList', () => {
|
||||
const uploadFiles = screen.getByRole('button', {
|
||||
name: 'dataset.newKnowledge.uploadFiles',
|
||||
})
|
||||
const startEmpty = screen.getByRole('button', {
|
||||
const startEmpty = screen.getByRole('link', {
|
||||
name: 'dataset.newKnowledge.startEmpty',
|
||||
})
|
||||
|
||||
@@ -263,10 +262,8 @@ describe('NewKnowledgeList', () => {
|
||||
expect(uploadFiles).toHaveAccessibleDescription(
|
||||
'dataset.newKnowledge.uploadFilesDescription dataset.cornerLabel.unavailable',
|
||||
)
|
||||
expect(startEmpty).toBeDisabled()
|
||||
expect(startEmpty).toHaveAccessibleDescription(
|
||||
'dataset.newKnowledge.startEmptyDescription dataset.cornerLabel.unavailable',
|
||||
)
|
||||
expect(startEmpty).toHaveAttribute('href', '/datasets/new/create?start=empty')
|
||||
expect(startEmpty).toHaveAccessibleDescription('dataset.newKnowledge.startEmptyDescription')
|
||||
expect(screen.getByText('dataset.newKnowledge.connectSourceDescription')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.newKnowledge.uploadFilesDescription')).toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.newKnowledge.startEmptyDescription')).toBeInTheDocument()
|
||||
@@ -274,6 +271,19 @@ describe('NewKnowledgeList', () => {
|
||||
expect(screen.queryByTestId('empty-knowledge-card')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show the Create route to users with external-connect permission only', () => {
|
||||
permissionStateMock.workspacePermissionKeys = ['dataset.external.connect']
|
||||
setResolvedPage()
|
||||
|
||||
renderWithNuqs(<NewKnowledgeList view="new" onViewChange={vi.fn()} />)
|
||||
|
||||
expect(screen.queryByRole('link', { name: 'common.operation.create' })).not.toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.newKnowledge.readOnlyEmpty')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'dataset.newKnowledge.connectSource' }),
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides creation entries from read-only users', () => {
|
||||
permissionStateMock.workspacePermissionKeys = []
|
||||
setResolvedPage()
|
||||
@@ -281,10 +291,10 @@ describe('NewKnowledgeList', () => {
|
||||
renderWithNuqs(<NewKnowledgeList view="new" onViewChange={vi.fn()} />)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^dataset\.newKnowledge\.startEmpty/ }),
|
||||
screen.queryByRole('link', { name: /^dataset\.newKnowledge\.startEmpty/ }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /common\.operation\.create/ }),
|
||||
screen.queryByRole('link', { name: /common\.operation\.create/ }),
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByText('dataset.newKnowledge.readOnlyEmpty')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client'
|
||||
|
||||
import type { NewKnowledgeStartMode } from '../routes'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { RadioControl, RadioItem } from '@langgenius/dify-ui/radio'
|
||||
import { useId } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function StartMode({
|
||||
description,
|
||||
disabled = false,
|
||||
icon,
|
||||
title,
|
||||
value,
|
||||
}: {
|
||||
description: string
|
||||
disabled?: boolean
|
||||
icon: string
|
||||
title: string
|
||||
value: NewKnowledgeStartMode
|
||||
}) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const titleId = useId()
|
||||
const descriptionId = useId()
|
||||
const unavailableId = useId()
|
||||
|
||||
return (
|
||||
<RadioItem
|
||||
value={value}
|
||||
nativeButton
|
||||
render={<button type="button" />}
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={disabled ? `${descriptionId} ${unavailableId}` : descriptionId}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'relative flex min-h-16 w-full items-center gap-3 overflow-hidden rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg px-4 py-3.5 text-left outline-hidden transition-colors motion-reduce:transition-none',
|
||||
'hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
'data-checked:border-[1.5px] data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg',
|
||||
'data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:hover:bg-components-option-card-option-bg',
|
||||
)}
|
||||
>
|
||||
<RadioControl aria-hidden />
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-components-option-card-option-border bg-background-default">
|
||||
<span aria-hidden className={`${icon} size-[18px] text-text-accent`} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span id={titleId} className="block system-sm-medium text-text-primary">
|
||||
{title}
|
||||
</span>
|
||||
<span id={descriptionId} className="mt-0.5 block system-xs-regular text-text-tertiary">
|
||||
{description}
|
||||
</span>
|
||||
</span>
|
||||
{value === 'source' && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-4 w-[82px] shrink-0 bg-[url('/images/new-rag/create-knowledge-connectors.svg')] bg-contain bg-center bg-no-repeat"
|
||||
/>
|
||||
)}
|
||||
{disabled && (
|
||||
<span id={unavailableId} className="ml-3 shrink-0 system-xs-medium text-text-disabled">
|
||||
{t(($) => $['cornerLabel.unavailable'])}
|
||||
</span>
|
||||
)}
|
||||
</RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
export function KnowledgeIllustration({ title }: { title: string }) {
|
||||
return (
|
||||
<div className="flex size-full flex-col items-start bg-background-default" aria-hidden>
|
||||
<div className="min-h-0 w-full flex-1 border-b border-divider-subtle" />
|
||||
<div className="flex max-h-full w-full shrink-0 flex-col items-start overflow-hidden pb-[94px]">
|
||||
<div className="flex w-full shrink-0 flex-col items-start gap-4 overflow-hidden py-4 pr-32 pl-8">
|
||||
<span className="flex size-14 shrink-0 items-center justify-center rounded-[10px] backdrop-blur-[6px]">
|
||||
<span className="flex size-full items-center justify-center rounded-xl border border-dashed border-divider-regular bg-components-card-bg p-1 text-text-accent">
|
||||
<span className="i-ri-book-open-line size-6" />
|
||||
</span>
|
||||
</span>
|
||||
<p className="w-full body-2xl-regular font-medium tracking-[-0.09px] text-text-primary">
|
||||
{title}
|
||||
</p>
|
||||
</div>
|
||||
<div className="aspect-[1489/840] w-full shrink-0 overflow-hidden">
|
||||
<img
|
||||
alt=""
|
||||
className="block size-full max-w-none object-contain"
|
||||
src="/images/new-rag/create-knowledge-illustration.svg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-h-0 w-full flex-1" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogActions,
|
||||
AlertDialogCancelButton,
|
||||
AlertDialogConfirmButton,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogTitle,
|
||||
} from '@langgenius/dify-ui/alert-dialog'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type CreateKnowledgeExitReason = 'discard' | 'partial'
|
||||
|
||||
export function CreateKnowledgeExitDialog({
|
||||
onCancel,
|
||||
onConfirm,
|
||||
reason,
|
||||
}: {
|
||||
onCancel: () => void
|
||||
onConfirm: () => void
|
||||
reason: CreateKnowledgeExitReason | null
|
||||
}) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const isPartial = reason === 'partial'
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={reason !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onCancel()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent className="w-120 overflow-hidden! rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-0! text-left align-middle shadow-lg">
|
||||
<div className="flex flex-col items-start gap-2 self-stretch p-6 pb-4">
|
||||
<AlertDialogTitle className="title-2xl-semi-bold text-text-primary">
|
||||
{t(($) =>
|
||||
isPartial
|
||||
? $['newKnowledge.leavePartialSetupTitle']
|
||||
: $['newKnowledge.discardDraftTitle'],
|
||||
)}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription
|
||||
render={<div />}
|
||||
className="system-md-regular text-text-secondary"
|
||||
>
|
||||
{t(($) =>
|
||||
isPartial
|
||||
? $['newKnowledge.leavePartialSetupDescription']
|
||||
: $['newKnowledge.discardDraftDescription'],
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</div>
|
||||
<AlertDialogActions className="gap-2 p-6">
|
||||
<AlertDialogCancelButton variant="secondary">
|
||||
{tCommon(($) => $['operation.cancel'])}
|
||||
</AlertDialogCancelButton>
|
||||
<AlertDialogConfirmButton onClick={onConfirm}>
|
||||
{t(($) =>
|
||||
isPartial
|
||||
? $['newKnowledge.leavePartialSetupConfirm']
|
||||
: $['newKnowledge.discardDraftConfirm'],
|
||||
)}
|
||||
</AlertDialogConfirmButton>
|
||||
</AlertDialogActions>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { KnowledgeSpace } from '@dify/contracts/knowledge-fs/types.gen'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import CornerLabel from '@/app/components/base/corner-label'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import Link from '@/next/link'
|
||||
|
||||
function getBuiltinIconName(iconRef: string | undefined) {
|
||||
if (!iconRef?.startsWith('builtin:')) return undefined
|
||||
@@ -20,15 +20,11 @@ export function KnowledgeSpaceCard({ knowledgeSpace }: { knowledgeSpace: Knowled
|
||||
|
||||
return (
|
||||
<li>
|
||||
<article
|
||||
aria-label={`${knowledgeSpace.name}. ${unavailable}`}
|
||||
className="relative flex h-[166px] w-full cursor-not-allowed flex-col overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg text-left shadow-xs"
|
||||
<Link
|
||||
href={`/datasets/new/${knowledgeSpace.id}/sources`}
|
||||
aria-label={knowledgeSpace.name}
|
||||
className="relative flex h-[166px] w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-card-border bg-components-card-bg text-left shadow-xs outline-hidden transition-shadow hover:shadow-md focus-visible:ring-2 focus-visible:ring-state-accent-solid motion-reduce:transition-none"
|
||||
>
|
||||
<CornerLabel
|
||||
label={unavailable}
|
||||
className="absolute top-0 right-0"
|
||||
labelClassName="rounded-tr-xl"
|
||||
/>
|
||||
<div className="flex w-full items-center gap-3 px-4 pt-4 pb-1.5">
|
||||
<div
|
||||
aria-label={iconName ?? t(($) => $['newKnowledge.cardType'])}
|
||||
@@ -43,14 +39,12 @@ export function KnowledgeSpaceCard({ knowledgeSpace }: { knowledgeSpace: Knowled
|
||||
<span aria-hidden className="i-ri-book-open-line size-5 text-text-tertiary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 py-px pr-16">
|
||||
<div className="min-w-0 flex-1 py-px">
|
||||
<h2 className="truncate system-md-semibold text-text-secondary">
|
||||
{knowledgeSpace.name}
|
||||
</h2>
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-1 system-2xs-medium-uppercase text-text-disabled">
|
||||
<span className="truncate">{t(($) => $['newKnowledge.cardType'])}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span className="shrink-0">{unavailable}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -86,7 +80,7 @@ export function KnowledgeSpaceCard({ knowledgeSpace }: { knowledgeSpace: Knowled
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useId } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import FirstEmptyActionCard from '@/app/components/apps/first-empty-state/action-card'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import CornerLabel from '@/app/components/base/corner-label'
|
||||
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
|
||||
import Link from '@/next/link'
|
||||
import { newKnowledgeCreatePathWithStartMode } from '../routes'
|
||||
|
||||
const LOADING_CARD_IDS = [
|
||||
'loading-card-1',
|
||||
@@ -20,22 +25,6 @@ const EMPTY_GHOST_CARD_IDS = Array.from({ length: 16 }, (_, index) => `empty-gho
|
||||
export const KNOWLEDGE_SPACE_GRID_CLASS_NAME =
|
||||
'grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-2.5'
|
||||
|
||||
export function UnavailableReason({ label, reason }: { label: string; reason: string }) {
|
||||
return (
|
||||
<Infotip
|
||||
aria-label={label}
|
||||
iconVariant="information"
|
||||
iconSize="large"
|
||||
placement="bottom"
|
||||
sideOffset={6}
|
||||
className="size-6 rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary"
|
||||
popupClassName="max-w-[260px] bg-components-tooltip-bg shadow-lg"
|
||||
>
|
||||
{reason}
|
||||
</Infotip>
|
||||
)
|
||||
}
|
||||
|
||||
export function NewKnowledgeLoadingState() {
|
||||
const { t } = useTranslation('common')
|
||||
|
||||
@@ -86,11 +75,13 @@ export function NewKnowledgePageState({
|
||||
|
||||
function EmptyAction({
|
||||
description,
|
||||
href,
|
||||
iconClassName,
|
||||
recommended = false,
|
||||
title,
|
||||
}: {
|
||||
description: string
|
||||
href?: string
|
||||
iconClassName: string
|
||||
recommended?: boolean
|
||||
title: string
|
||||
@@ -98,18 +89,88 @@ function EmptyAction({
|
||||
const { t } = useTranslation('dataset')
|
||||
const unavailable = t(($) => $['cornerLabel.unavailable'])
|
||||
const recommendedLabel = t(($) => $['firstEmpty.recommended'])
|
||||
const descriptionId = useId()
|
||||
const unavailableId = useId()
|
||||
const recommendedId = useId()
|
||||
|
||||
return (
|
||||
<FirstEmptyActionCard
|
||||
disabled
|
||||
disabledReason={unavailable}
|
||||
badge={recommended ? recommendedLabel : undefined}
|
||||
className="min-h-[58px] py-2 backdrop-blur-[6px]"
|
||||
description={description}
|
||||
icon={<span aria-hidden className={`${iconClassName} size-4 text-text-disabled`} />}
|
||||
title={title}
|
||||
visualStyle="list"
|
||||
/>
|
||||
<ButtonOrLink
|
||||
href={href}
|
||||
aria-label={title}
|
||||
aria-describedby={`${descriptionId}${href ? '' : ` ${unavailableId}`}${recommended ? ` ${recommendedId}` : ''}`}
|
||||
className="relative flex min-h-[58px] w-full items-center overflow-hidden rounded-xl bg-components-button-secondary-bg px-3 py-2 text-left text-text-secondary outline-hidden backdrop-blur-[6px] hover:bg-components-button-secondary-bg-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled disabled:hover:bg-components-button-secondary-bg"
|
||||
>
|
||||
<span className="mr-3 flex size-9 shrink-0 items-center justify-center rounded-lg bg-background-default-subtle">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
iconClassName,
|
||||
'size-4',
|
||||
href ? 'text-text-tertiary' : 'text-text-disabled',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span
|
||||
className={cn(
|
||||
'block system-md-medium',
|
||||
href ? 'text-text-secondary' : 'text-text-disabled',
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
<span
|
||||
id={descriptionId}
|
||||
className={cn(
|
||||
'mt-0.5 block system-xs-regular',
|
||||
href ? 'text-text-tertiary' : 'text-text-disabled',
|
||||
)}
|
||||
>
|
||||
{description}
|
||||
</span>
|
||||
</span>
|
||||
{!href && (
|
||||
<span id={unavailableId} className="ml-3 shrink-0 system-xs-medium text-text-disabled">
|
||||
{unavailable}
|
||||
</span>
|
||||
)}
|
||||
{recommended && (
|
||||
<div id={recommendedId}>
|
||||
<CornerLabel
|
||||
label={recommendedLabel}
|
||||
className="absolute top-0 right-0 z-5"
|
||||
cornerClassName="text-util-colors-indigo-indigo-100"
|
||||
labelClassName="-ml-px rounded-tr-xl bg-util-colors-indigo-indigo-100 pr-2"
|
||||
textClassName="text-util-colors-indigo-indigo-700"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</ButtonOrLink>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonOrLink({
|
||||
children,
|
||||
href,
|
||||
...props
|
||||
}: {
|
||||
'aria-describedby': string
|
||||
'aria-label': string
|
||||
children: ReactNode
|
||||
className: string
|
||||
href?: string
|
||||
}) {
|
||||
if (href)
|
||||
return (
|
||||
<Link href={href} {...props}>
|
||||
{children}
|
||||
</Link>
|
||||
)
|
||||
|
||||
return (
|
||||
<button type="button" disabled {...props}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -150,7 +211,7 @@ export function NewKnowledgeEmptyState({
|
||||
canCreate: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const canStart = canConnect || canCreate
|
||||
const canStart = canCreate
|
||||
|
||||
return (
|
||||
<div className="relative isolate flex min-h-[calc(100vh-134px)] items-center justify-center overflow-hidden px-4 py-16 text-center sm:px-6">
|
||||
@@ -197,6 +258,7 @@ export function NewKnowledgeEmptyState({
|
||||
iconClassName="i-ri-folder-6-line"
|
||||
title={t(($) => $['newKnowledge.startEmpty'])}
|
||||
description={t(($) => $['newKnowledge.startEmptyDescription'])}
|
||||
href={newKnowledgeCreatePathWithStartMode('empty')}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
'use client'
|
||||
|
||||
import type { KnowledgeSpaceCreationResponse } from '@dify/contracts/knowledge-fs/types.gen'
|
||||
import type { CreateKnowledgeExitReason } from './components/create-knowledge-exit-dialog'
|
||||
import type { KnowledgeVisibility } from './create-knowledge-workflow'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPopup,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
} from '@langgenius/dify-ui/dialog'
|
||||
import {
|
||||
Field,
|
||||
FieldControl,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
} from '@langgenius/dify-ui/field'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectItemIndicator,
|
||||
SelectItemText,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { DatasetACLPermission, hasPermission } from '@/utils/permission'
|
||||
import { KnowledgeIllustration, StartMode } from './components/create-knowledge-dialog-parts'
|
||||
import { CreateKnowledgeExitDialog } from './components/create-knowledge-exit-dialog'
|
||||
import {
|
||||
createKnowledge,
|
||||
DESCRIPTION_MAX_LENGTH,
|
||||
isDefinitiveCreationRejection,
|
||||
KnowledgeCreationError,
|
||||
NAME_MAX_LENGTH,
|
||||
} from './create-knowledge-workflow'
|
||||
import { newKnowledgeDetailPath, newKnowledgeListPath } from './routes'
|
||||
|
||||
export function CreateKnowledgePage() {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const dialogTitleId = useId()
|
||||
const permissionDescriptionId = useId()
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const canConfigureAccess = hasPermission(
|
||||
workspacePermissionKeys,
|
||||
DatasetACLPermission.AccessConfig,
|
||||
)
|
||||
const defaultVisibility: KnowledgeVisibility = canConfigureAccess ? 'all_members' : 'only_me'
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [visibility, setVisibility] = useState<KnowledgeVisibility>(defaultVisibility)
|
||||
const [createdKnowledge, setCreatedKnowledge] = useState<KnowledgeSpaceCreationResponse>()
|
||||
const [submissionLocked, setSubmissionLocked] = useState(false)
|
||||
const [exitReason, setExitReason] = useState<CreateKnowledgeExitReason | null>(null)
|
||||
const idempotencyKeyRef = useRef<string | undefined>(undefined)
|
||||
const historyGuardArmedRef = useRef(false)
|
||||
const browserBackExitRef = useRef(false)
|
||||
const pendingNavigationRef = useRef<string | undefined>(undefined)
|
||||
const createMutation = useMutation({ mutationFn: createKnowledge })
|
||||
const hasUnsavedChanges = Boolean(
|
||||
name || description || visibility !== defaultVisibility || createdKnowledge,
|
||||
)
|
||||
|
||||
const armHistoryGuard = useCallback(() => {
|
||||
globalThis.history.pushState(globalThis.history.state, '', globalThis.location.href)
|
||||
historyGuardArmedRef.current = true
|
||||
}, [])
|
||||
|
||||
const replaceAfterHistoryGuard = useCallback(
|
||||
(path: string) => {
|
||||
if (!historyGuardArmedRef.current) {
|
||||
router.replace(path)
|
||||
return
|
||||
}
|
||||
|
||||
pendingNavigationRef.current = path
|
||||
globalThis.history.back()
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!hasUnsavedChanges ||
|
||||
historyGuardArmedRef.current ||
|
||||
browserBackExitRef.current ||
|
||||
pendingNavigationRef.current
|
||||
)
|
||||
return
|
||||
|
||||
armHistoryGuard()
|
||||
}, [armHistoryGuard, hasUnsavedChanges])
|
||||
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
if (!historyGuardArmedRef.current) return
|
||||
|
||||
historyGuardArmedRef.current = false
|
||||
const pendingNavigation = pendingNavigationRef.current
|
||||
if (pendingNavigation) {
|
||||
pendingNavigationRef.current = undefined
|
||||
router.replace(pendingNavigation)
|
||||
return
|
||||
}
|
||||
if (!hasUnsavedChanges) {
|
||||
router.replace(newKnowledgeListPath)
|
||||
return
|
||||
}
|
||||
|
||||
browserBackExitRef.current = true
|
||||
setExitReason(createdKnowledge ? 'partial' : 'discard')
|
||||
}
|
||||
|
||||
globalThis.addEventListener('popstate', handlePopState)
|
||||
return () => globalThis.removeEventListener('popstate', handlePopState)
|
||||
}, [createdKnowledge, hasUnsavedChanges, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasUnsavedChanges) return
|
||||
|
||||
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
||||
event.preventDefault()
|
||||
event.returnValue = ''
|
||||
}
|
||||
|
||||
globalThis.addEventListener('beforeunload', handleBeforeUnload)
|
||||
return () => globalThis.removeEventListener('beforeunload', handleBeforeUnload)
|
||||
}, [hasUnsavedChanges])
|
||||
|
||||
const resetUnsubmittedError = () => {
|
||||
if (!submissionLocked) createMutation.reset()
|
||||
}
|
||||
|
||||
const requestClose = () => {
|
||||
if (createMutation.isPending) return
|
||||
if (createdKnowledge) {
|
||||
setExitReason('partial')
|
||||
return
|
||||
}
|
||||
if (name || description || visibility !== defaultVisibility) {
|
||||
setExitReason('discard')
|
||||
return
|
||||
}
|
||||
replaceAfterHistoryGuard(newKnowledgeListPath)
|
||||
}
|
||||
|
||||
const confirmExit = () => {
|
||||
const confirmedReason = exitReason
|
||||
setExitReason(null)
|
||||
if (confirmedReason === 'partial' && createdKnowledge) {
|
||||
browserBackExitRef.current = false
|
||||
replaceAfterHistoryGuard(newKnowledgeDetailPath(createdKnowledge.id))
|
||||
return
|
||||
}
|
||||
browserBackExitRef.current = false
|
||||
replaceAfterHistoryGuard(newKnowledgeListPath)
|
||||
}
|
||||
|
||||
const cancelExit = () => {
|
||||
setExitReason(null)
|
||||
if (!browserBackExitRef.current) return
|
||||
|
||||
browserBackExitRef.current = false
|
||||
armHistoryGuard()
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (createMutation.isPending) return
|
||||
|
||||
const normalizedName = name.trim()
|
||||
const normalizedDescription = description.trim()
|
||||
if (!normalizedName) return
|
||||
|
||||
idempotencyKeyRef.current ??= globalThis.crypto.randomUUID()
|
||||
setSubmissionLocked(true)
|
||||
try {
|
||||
const created = await createMutation.mutateAsync({
|
||||
existingKnowledge: createdKnowledge,
|
||||
description: normalizedDescription,
|
||||
idempotencyKey: idempotencyKeyRef.current,
|
||||
name: normalizedName,
|
||||
onCreated: (knowledgeSpace) => {
|
||||
setCreatedKnowledge(knowledgeSpace)
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.knowledgeFs.listKnowledgeSpaces.key(),
|
||||
})
|
||||
},
|
||||
visibility,
|
||||
})
|
||||
replaceAfterHistoryGuard(newKnowledgeDetailPath(created.id))
|
||||
} catch (error) {
|
||||
if (error instanceof KnowledgeCreationError && error.createdKnowledge)
|
||||
setCreatedKnowledge(error.createdKnowledge)
|
||||
|
||||
if (
|
||||
error instanceof KnowledgeCreationError &&
|
||||
error.stage === 'create' &&
|
||||
isDefinitiveCreationRejection(error.originalError)
|
||||
) {
|
||||
idempotencyKeyRef.current = undefined
|
||||
setSubmissionLocked(false)
|
||||
}
|
||||
// The mutation state renders a retryable, localized error without exposing upstream details.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) requestClose()
|
||||
}}
|
||||
>
|
||||
<DialogPortal>
|
||||
<DialogBackdrop className="bg-background-overlay-backdrop backdrop-blur-[6px]" />
|
||||
<DialogPopup
|
||||
aria-labelledby={dialogTitleId}
|
||||
className="fixed inset-x-3 top-4 bottom-4 grid min-h-0 min-w-0 overflow-hidden xl:grid-cols-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={tCommon(($) => $['operation.close'])}
|
||||
className="absolute top-3 right-3 z-10 flex size-9 items-center justify-center rounded-xl bg-background-section-burn text-text-tertiary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid disabled:cursor-not-allowed disabled:text-text-disabled"
|
||||
onClick={requestClose}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
<span aria-hidden className="i-ri-close-line size-5" />
|
||||
</button>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-col items-end border-divider-subtle xl:border-r">
|
||||
<div className="min-h-6 w-full max-w-[760px] flex-1 [@media(max-height:850px)]:h-6 [@media(max-height:850px)]:flex-none" />
|
||||
<Form
|
||||
className="flex w-full max-w-[760px] shrink-0 flex-col [@media(max-height:850px)]:min-h-0 [@media(max-height:850px)]:flex-1"
|
||||
onFormSubmit={handleSubmit}
|
||||
>
|
||||
<header className="shrink-0 px-6 pt-2 pb-6 sm:px-10">
|
||||
<DialogTitle id={dialogTitleId} className="title-2xl-semi-bold text-text-primary">
|
||||
{t(($) => $['newKnowledge.createTitle'])}
|
||||
</DialogTitle>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-col gap-4 px-6 sm:px-10 [@media(max-height:850px)]:flex-1 [@media(max-height:850px)]:overflow-y-auto">
|
||||
<div className="space-y-4">
|
||||
<Field
|
||||
name="name"
|
||||
className="gap-1.5"
|
||||
validate={(value) => {
|
||||
if (typeof value === 'string' && value.length > 0 && !value.trim())
|
||||
return t(($) => $['newKnowledge.nameRequired'])
|
||||
|
||||
return null
|
||||
}}
|
||||
>
|
||||
<FieldLabel>
|
||||
{t(($) => $['newKnowledge.name'])}
|
||||
<span aria-hidden className="ml-0.5 text-text-destructive">
|
||||
*
|
||||
</span>
|
||||
</FieldLabel>
|
||||
<FieldControl
|
||||
autoComplete="off"
|
||||
disabled={submissionLocked}
|
||||
maxLength={NAME_MAX_LENGTH}
|
||||
placeholder={t(($) => $['newKnowledge.namePlaceholder'])}
|
||||
required
|
||||
value={name}
|
||||
onValueChange={(value) => {
|
||||
setName(value)
|
||||
resetUnsubmittedError()
|
||||
}}
|
||||
/>
|
||||
<FieldError match="valueMissing">
|
||||
{t(($) => $['newKnowledge.nameRequired'])}
|
||||
</FieldError>
|
||||
<FieldError match="customError" />
|
||||
</Field>
|
||||
<Field name="description" className="gap-1.5">
|
||||
<FieldLabel>{t(($) => $['newKnowledge.description'])}</FieldLabel>
|
||||
<Textarea
|
||||
autoComplete="off"
|
||||
className="min-h-20"
|
||||
disabled={submissionLocked}
|
||||
maxLength={DESCRIPTION_MAX_LENGTH}
|
||||
name="description"
|
||||
placeholder={t(($) => $['newKnowledge.descriptionPlaceholder'])}
|
||||
value={description}
|
||||
onValueChange={(value) => {
|
||||
setDescription(value)
|
||||
resetUnsubmittedError()
|
||||
}}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{t(($) => $['newKnowledge.descriptionHelp'])}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
<div className="space-y-1.5">
|
||||
<Select
|
||||
name="permission"
|
||||
value={visibility}
|
||||
disabled={submissionLocked || !canConfigureAccess}
|
||||
onValueChange={(value) => {
|
||||
if (value) setVisibility(value)
|
||||
}}
|
||||
>
|
||||
<SelectLabel>{t(($) => $['newKnowledge.permission'])}</SelectLabel>
|
||||
<SelectTrigger
|
||||
aria-describedby={!canConfigureAccess ? permissionDescriptionId : undefined}
|
||||
>
|
||||
{t(($) =>
|
||||
visibility === 'all_members'
|
||||
? $['newKnowledge.permissionAllMembers']
|
||||
: $['newKnowledge.permissionOnlyMe'],
|
||||
)}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="only_me">
|
||||
<SelectItemText>
|
||||
{t(($) => $['newKnowledge.permissionOnlyMe'])}
|
||||
</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
<SelectItem value="all_members">
|
||||
<SelectItemText>
|
||||
{t(($) => $['newKnowledge.permissionAllMembers'])}
|
||||
</SelectItemText>
|
||||
<SelectItemIndicator />
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!canConfigureAccess && (
|
||||
<p
|
||||
id={permissionDescriptionId}
|
||||
className="py-0.5 body-xs-regular text-text-tertiary"
|
||||
>
|
||||
{t(($) => $['newKnowledge.permissionRestricted'])}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend className="system-md-semibold text-text-secondary">
|
||||
{t(($) => $['newKnowledge.startWith'])}
|
||||
</legend>
|
||||
<p className="pb-0.5 body-xs-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.startWithHelp'])}
|
||||
</p>
|
||||
<RadioGroup
|
||||
value="empty"
|
||||
aria-label={t(($) => $['newKnowledge.startWith'])}
|
||||
className="mt-2 flex-col items-stretch gap-2"
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
<StartMode
|
||||
value="empty"
|
||||
icon="i-ri-folder-6-line"
|
||||
title={t(($) => $['newKnowledge.startEmpty'])}
|
||||
description={t(($) => $['newKnowledge.startEmptyDescription'])}
|
||||
/>
|
||||
<StartMode
|
||||
disabled
|
||||
value="source"
|
||||
icon="i-custom-vender-solid-development-api-connection-mod"
|
||||
title={t(($) => $['newKnowledge.connectSource'])}
|
||||
description={t(($) => $['newKnowledge.connectSourceDescription'])}
|
||||
/>
|
||||
<StartMode
|
||||
disabled
|
||||
value="upload"
|
||||
icon="i-ri-file-text-line"
|
||||
title={t(($) => $['newKnowledge.uploadFiles'])}
|
||||
description={t(($) => $['newKnowledge.uploadFilesDescription'])}
|
||||
/>
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
|
||||
{createMutation.isError && (
|
||||
<div
|
||||
className="mt-5 rounded-lg bg-components-badge-status-light-error-bg px-3 py-2 system-sm-regular text-text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
{t(($) =>
|
||||
createMutation.error instanceof KnowledgeCreationError &&
|
||||
createMutation.error.stage === 'policy'
|
||||
? $['newKnowledge.permissionUpdateFailed']
|
||||
: $['newKnowledge.createFailed'],
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 px-6 pt-5 pb-10 sm:px-10">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" disabled={createMutation.isPending} onClick={requestClose}>
|
||||
{tCommon(($) => $['operation.cancel'])}
|
||||
</Button>
|
||||
<Button type="submit" variant="primary" loading={createMutation.isPending}>
|
||||
{t(($) => $['newKnowledge.createTitle'])}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
<div className="min-h-px w-full max-w-[760px] flex-1 [@media(max-height:850px)]:h-6 [@media(max-height:850px)]:flex-none" />
|
||||
</div>
|
||||
|
||||
<aside className="hidden min-h-0 min-w-0 xl:block">
|
||||
<KnowledgeIllustration title={t(($) => $['newKnowledge.illustrationHeadline'])} />
|
||||
</aside>
|
||||
</DialogPopup>
|
||||
</DialogPortal>
|
||||
<CreateKnowledgeExitDialog
|
||||
reason={exitReason}
|
||||
onCancel={cancelExit}
|
||||
onConfirm={confirmExit}
|
||||
/>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { KnowledgeSpaceCreationResponse } from '@dify/contracts/knowledge-fs/types.gen'
|
||||
import { consoleClient } from '@/service/client'
|
||||
|
||||
export const NAME_MAX_LENGTH = 160
|
||||
export const DESCRIPTION_MAX_LENGTH = 2000
|
||||
|
||||
export type KnowledgeVisibility = 'all_members' | 'only_me'
|
||||
|
||||
type CreateKnowledgeValues = {
|
||||
existingKnowledge?: KnowledgeSpaceCreationResponse
|
||||
description: string
|
||||
idempotencyKey: string
|
||||
name: string
|
||||
onCreated: (knowledgeSpace: KnowledgeSpaceCreationResponse) => void
|
||||
visibility: KnowledgeVisibility
|
||||
}
|
||||
|
||||
export class KnowledgeCreationError extends Error {
|
||||
readonly stage: 'create' | 'policy'
|
||||
readonly originalError: unknown
|
||||
readonly createdKnowledge?: KnowledgeSpaceCreationResponse
|
||||
|
||||
constructor(
|
||||
stage: 'create' | 'policy',
|
||||
originalError: unknown,
|
||||
createdKnowledge?: KnowledgeSpaceCreationResponse,
|
||||
) {
|
||||
super(`Knowledge creation failed during ${stage}`)
|
||||
this.name = 'KnowledgeCreationError'
|
||||
this.stage = stage
|
||||
this.originalError = originalError
|
||||
this.createdKnowledge = createdKnowledge
|
||||
}
|
||||
}
|
||||
|
||||
function responseStatus(error: unknown) {
|
||||
if (error instanceof Response) return error.status
|
||||
if (error && typeof error === 'object' && 'status' in error) return error.status
|
||||
if (error && typeof error === 'object' && 'data' in error) {
|
||||
const data = error.data
|
||||
if (data && typeof data === 'object' && 'status' in data) return data.status
|
||||
}
|
||||
}
|
||||
|
||||
export function isDefinitiveCreationRejection(error: unknown) {
|
||||
const status = responseStatus(error)
|
||||
return status === 400 || status === 401 || status === 403 || status === 422
|
||||
}
|
||||
|
||||
export async function createKnowledge(
|
||||
values: CreateKnowledgeValues,
|
||||
): Promise<KnowledgeSpaceCreationResponse> {
|
||||
let created = values.existingKnowledge
|
||||
if (!created) {
|
||||
try {
|
||||
created = await consoleClient.knowledgeFs.createKnowledgeSpace({
|
||||
body: {
|
||||
description: values.description || undefined,
|
||||
idempotencyKey: values.idempotencyKey,
|
||||
name: values.name,
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
throw new KnowledgeCreationError('create', error)
|
||||
}
|
||||
}
|
||||
values.onCreated(created)
|
||||
|
||||
try {
|
||||
if (values.visibility === 'all_members') {
|
||||
const policy = await consoleClient.knowledgeFs.getKnowledgeSpacesByIdAccessPolicy({
|
||||
params: { id: created.id },
|
||||
})
|
||||
if (policy.visibility !== values.visibility) {
|
||||
await consoleClient.knowledgeFs.patchKnowledgeSpacesByIdAccessPolicy({
|
||||
body: {
|
||||
expectedRevision: policy.revision,
|
||||
partialMemberSubjectIds: [],
|
||||
visibility: values.visibility,
|
||||
},
|
||||
params: { id: created.id },
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
throw new KnowledgeCreationError('policy', error, created)
|
||||
}
|
||||
|
||||
return created
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useEffect } from 'react'
|
||||
import { systemFeaturesAtom } from '@/context/system-features-state'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
|
||||
export function KnowledgeRouteGuard({ children }: { children: ReactNode }) {
|
||||
const { knowledge_fs_enabled: knowledgeFsEnabled } = useAtomValue(systemFeaturesAtom)
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!knowledgeFsEnabled) router.replace('/datasets')
|
||||
}, [knowledgeFsEnabled, router])
|
||||
|
||||
if (!knowledgeFsEnabled) return null
|
||||
|
||||
return children
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export function KnowledgeRoutePlaceholder({ type }: { type: 'documents' | 'sources' }) {
|
||||
const { t } = useTranslation('dataset')
|
||||
|
||||
return (
|
||||
<div className="flex min-h-full flex-col items-center justify-center px-6 py-16 text-center">
|
||||
<span
|
||||
aria-hidden
|
||||
className={`${type === 'sources' ? 'i-ri-links-line' : 'i-ri-file-text-line'} size-7 text-text-tertiary`}
|
||||
/>
|
||||
<h2 className="mt-4 title-xl-semi-bold text-text-primary">
|
||||
{t(($) => (type === 'sources' ? $['newKnowledge.sources'] : $['newKnowledge.documents']))}
|
||||
</h2>
|
||||
<p className="mt-2 max-w-md body-sm-regular text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.routeUnavailable'])}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import Link from '@/next/link'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { newKnowledgeDetailPath, newKnowledgeDocumentsPath, newKnowledgeListPath } from './routes'
|
||||
|
||||
function responseStatus(error: unknown) {
|
||||
if (error instanceof Response) return error.status
|
||||
if (error && typeof error === 'object' && 'status' in error) return error.status
|
||||
if (error && typeof error === 'object' && 'data' in error) {
|
||||
const data = error.data
|
||||
if (data && typeof data === 'object' && 'status' in data) return data.status
|
||||
}
|
||||
}
|
||||
|
||||
export function KnowledgeSpaceShell({
|
||||
children,
|
||||
knowledgeSpaceId,
|
||||
}: {
|
||||
children: ReactNode
|
||||
knowledgeSpaceId: string
|
||||
}) {
|
||||
const { t } = useTranslation('dataset')
|
||||
const { t: tCommon } = useTranslation('common')
|
||||
const pathname = usePathname()
|
||||
const knowledgeSpaceQuery = useQuery({
|
||||
...consoleQuery.knowledgeFs.getKnowledgeSpacesById.queryOptions({
|
||||
input: { params: { id: knowledgeSpaceId } },
|
||||
}),
|
||||
retry: (failureCount, error) => {
|
||||
const status = responseStatus(error)
|
||||
if (status === 403 || status === 404) return false
|
||||
|
||||
return failureCount < 3
|
||||
},
|
||||
})
|
||||
useDocumentTitle(knowledgeSpaceQuery.data?.name ?? t(($) => $.knowledge))
|
||||
|
||||
if (knowledgeSpaceQuery.isPending)
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 items-center justify-center">
|
||||
<Loading />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (knowledgeSpaceQuery.error || !knowledgeSpaceQuery.data) {
|
||||
const status = responseStatus(knowledgeSpaceQuery.error)
|
||||
const notFound = status === 403 || status === 404
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<span aria-hidden className="i-ri-book-open-line size-8 text-text-tertiary" />
|
||||
<h1 className="mt-4 title-2xl-semi-bold text-text-primary">
|
||||
{t(($) =>
|
||||
notFound ? $['newKnowledge.notFoundTitle'] : $['newKnowledge.detailErrorTitle'],
|
||||
)}
|
||||
</h1>
|
||||
<p className="mt-2 max-w-md body-sm-regular text-text-tertiary">
|
||||
{t(($) =>
|
||||
notFound
|
||||
? $['newKnowledge.notFoundDescription']
|
||||
: $['newKnowledge.detailErrorDescription'],
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-5 flex gap-2">
|
||||
<Button render={<Link href={newKnowledgeListPath} />}>
|
||||
{t(($) => $['newKnowledge.backToList'])}
|
||||
</Button>
|
||||
{!notFound && (
|
||||
<Button variant="primary" onClick={() => void knowledgeSpaceQuery.refetch()}>
|
||||
{tCommon(($) => $['operation.retry'])}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sourcesPath = newKnowledgeDetailPath(knowledgeSpaceId)
|
||||
const documentsPath = newKnowledgeDocumentsPath(knowledgeSpaceId)
|
||||
const sourcesActive = pathname === sourcesPath || pathname.startsWith(`${sourcesPath}/`)
|
||||
const documentsActive = pathname === documentsPath || pathname.startsWith(`${documentsPath}/`)
|
||||
const navItemClassName =
|
||||
'flex h-8 shrink-0 items-center gap-2 rounded-lg px-3 system-sm-medium outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid'
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background-body p-1">
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-1 overflow-hidden sm:flex-row">
|
||||
<aside className="flex shrink-0 flex-col overflow-hidden rounded-lg bg-components-panel-bg shadow-xs sm:w-60">
|
||||
<div className="flex h-12 min-w-0 items-center px-1 pr-2">
|
||||
<Link
|
||||
href={newKnowledgeListPath}
|
||||
aria-label={t(($) => $['newKnowledge.backToList'])}
|
||||
className="flex h-8 w-10 shrink-0 items-center justify-center rounded-lg text-text-tertiary outline-hidden hover:bg-state-base-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid"
|
||||
>
|
||||
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
|
||||
<span aria-hidden className="i-ri-home-5-line size-4" />
|
||||
</Link>
|
||||
<span aria-hidden className="text-text-quaternary">
|
||||
/
|
||||
</span>
|
||||
<span className="truncate px-1.5 system-sm-semibold-uppercase text-text-secondary">
|
||||
{t(($) => $.knowledge)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center px-1 py-2">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 rounded-xl p-2">
|
||||
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border-[0.5px] border-divider-regular bg-components-icon-bg-orange-dark-soft">
|
||||
<span aria-hidden className="i-ri-book-open-line size-[18px] text-text-tertiary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h1 className="truncate system-md-semibold text-text-secondary">
|
||||
{knowledgeSpaceQuery.data.name}
|
||||
</h1>
|
||||
<p className="mt-0.5 truncate system-2xs-medium-uppercase text-text-tertiary">
|
||||
{t(($) => $['newKnowledge.cardType'])}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav
|
||||
className="flex gap-0.5 overflow-x-auto px-2 py-1 sm:flex-1 sm:flex-col"
|
||||
aria-label={knowledgeSpaceQuery.data.name}
|
||||
>
|
||||
<Link
|
||||
href={sourcesPath}
|
||||
aria-current={sourcesActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
navItemClassName,
|
||||
sourcesActive ? 'bg-state-base-active text-text-accent' : 'text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-links-line size-4" />
|
||||
{t(($) => $['newKnowledge.sources'])}
|
||||
</Link>
|
||||
<Link
|
||||
href={documentsPath}
|
||||
aria-current={documentsActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
navItemClassName,
|
||||
documentsActive ? 'bg-state-base-active text-text-accent' : 'text-text-secondary',
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="i-ri-file-text-line size-4" />
|
||||
{t(($) => $['newKnowledge.documents'])}
|
||||
</Link>
|
||||
</nav>
|
||||
</aside>
|
||||
<section className="min-h-0 min-w-0 flex-1 overflow-auto rounded-lg bg-components-panel-bg shadow-xs">
|
||||
{children}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,13 +4,14 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { useId, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { SearchInput } from '@/app/components/base/search-input'
|
||||
import ExternalAPIPanel from '@/app/components/datasets/external-api/external-api-panel'
|
||||
import ServiceApi from '@/app/components/datasets/extra-info/service-api'
|
||||
import { useExternalApiPanel } from '@/context/external-api-panel-context'
|
||||
import { workspacePermissionKeysAtom } from '@/context/permission-state'
|
||||
import Link from '@/next/link'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { useDatasetApiBaseUrl } from '@/service/knowledge/use-dataset'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
@@ -21,7 +22,6 @@ import {
|
||||
NewKnowledgeEmptyState,
|
||||
NewKnowledgeLoadingState,
|
||||
NewKnowledgePageState,
|
||||
UnavailableReason,
|
||||
} from './components/new-knowledge-list-states'
|
||||
|
||||
const PAGE_SIZE = 30
|
||||
@@ -64,12 +64,9 @@ export function NewKnowledgeList({
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const canCreate = hasPermission(workspacePermissionKeys, 'dataset.create_and_management')
|
||||
const canConnect = hasPermission(workspacePermissionKeys, 'dataset.external.connect')
|
||||
const showCreateAction = canCreate || canConnect
|
||||
const filtersUnavailable = t(($) => $['newKnowledge.filtersUnavailable'])
|
||||
const showFilterBoundary = () => toast.info(filtersUnavailable)
|
||||
const unavailable = t(($) => $['cornerLabel.unavailable'])
|
||||
const createLabel = tCommon(($) => $['operation.create'])
|
||||
const createUnavailableId = useId()
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const knowledgeSpacesQuery = useInfiniteQuery(
|
||||
consoleQuery.knowledgeFs.listKnowledgeSpaces.infiniteOptions({
|
||||
@@ -137,11 +134,10 @@ export function NewKnowledgeList({
|
||||
onValueChange={setSearchValue}
|
||||
/>
|
||||
</div>
|
||||
{showCreateAction && (
|
||||
{canCreate && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
disabled
|
||||
aria-describedby={createUnavailableId}
|
||||
render={<Link href="/datasets/new/create" />}
|
||||
variant="primary"
|
||||
size="medium"
|
||||
className="gap-0.5 px-2 shadow-xs"
|
||||
@@ -149,10 +145,6 @@ export function NewKnowledgeList({
|
||||
<span aria-hidden className="i-ri-add-line size-4 shrink-0" />
|
||||
<span className="pl-1">{createLabel}</span>
|
||||
</Button>
|
||||
<span id={createUnavailableId} className="sr-only">
|
||||
{unavailable}
|
||||
</span>
|
||||
<UnavailableReason label={`${createLabel}. ${unavailable}`} reason={unavailable} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export type NewKnowledgeStartMode = 'empty' | 'source' | 'upload'
|
||||
|
||||
const newKnowledgeCreatePath = '/datasets/new/create'
|
||||
|
||||
export const newKnowledgeListPath = '/datasets?view=new'
|
||||
|
||||
export const newKnowledgeCreatePathWithStartMode = (startMode: NewKnowledgeStartMode) =>
|
||||
`${newKnowledgeCreatePath}?start=${startMode}`
|
||||
|
||||
export const newKnowledgeDetailPath = (knowledgeSpaceId: string) =>
|
||||
`/datasets/new/${knowledgeSpaceId}/sources`
|
||||
|
||||
export const newKnowledgeDocumentsPath = (knowledgeSpaceId: string) =>
|
||||
`/datasets/new/${knowledgeSpaceId}/documents`
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "مطلوب نموذج إعادة الترتيب لخلط المعرفة الداخلية والخارجية.",
|
||||
"multimodal": "متعدد الوسائط",
|
||||
"newKnowledge.appsUnavailable": "— تطبيقات",
|
||||
"newKnowledge.backToList": "العودة إلى قواعد المعرفة",
|
||||
"newKnowledge.cardType": "مساحة معرفة",
|
||||
"newKnowledge.connectSource": "ربط مصدر",
|
||||
"newKnowledge.connectSourceDescription": "يبقى المحتوى متزامنًا مع Notion والمواقع الإلكترونية والخدمات الأخرى.",
|
||||
"newKnowledge.createFailed": "لم نتمكن من إنشاء قاعدة المعرفة هذه. تحقق من وصولك وحاول مرة أخرى.",
|
||||
"newKnowledge.createTitle": "خلق المعرفة",
|
||||
"newKnowledge.creators": "المنشئون",
|
||||
"newKnowledge.description": "الوصف",
|
||||
"newKnowledge.descriptionHelp": "يساعد الوصف الواضح التطبيقات على تحديد وقت البحث في هذه المعرفة.",
|
||||
"newKnowledge.descriptionPlaceholder": "على سبيل المثال سياسة استرداد الأموال، والأسئلة الشائعة حول الشحن، وخطوات التصعيد - تُستخدم لطرح أسئلة دعم العملاء",
|
||||
"newKnowledge.detailErrorDescription": "لم نتمكن من تحميل قاعدة المعرفة هذه. حاول ثانية.",
|
||||
"newKnowledge.detailErrorTitle": "حدث خطأ ما",
|
||||
"newKnowledge.discardDraftConfirm": "تجاهل المسودة",
|
||||
"newKnowledge.discardDraftDescription": "ستُفقد تغييراتك على مسودة قاعدة المعرفة هذه.",
|
||||
"newKnowledge.discardDraftTitle": "تجاهل مسودة قاعدة المعرفة هذه؟",
|
||||
"newKnowledge.documents": "المستندات",
|
||||
"newKnowledge.documentsUnavailable": "— مستندات",
|
||||
"newKnowledge.emptyDescription": "أضف مستنداتك ليتمكن الوكلاء من قراءتها مباشرة — من دون إعداد الفهرسة.",
|
||||
"newKnowledge.emptyTitle": "طريقة أذكى لبناء قواعد المعرفة",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "فهمت",
|
||||
"newKnowledge.guideDescription": "اربط مصدرًا وستبقى مستنداته متزامنة ومفهرسة تلقائيًا وجاهزة للاستخدام، من دون إعدادات تحتاج إلى إدارة. ستواصل قواعد المعرفة السابقة العمل كما هي.",
|
||||
"newKnowledge.guideTitle": "تعرّف على قاعدة المعرفة الجديدة ✨",
|
||||
"newKnowledge.illustrationHeadline": "اربط محتواك مرة واحدة — ليجيب كل تطبيق بأحدث معرفة.",
|
||||
"newKnowledge.learnMore": "معرفة المزيد",
|
||||
"newKnowledge.leavePartialSetupConfirm": "مغادرة الإعداد",
|
||||
"newKnowledge.leavePartialSetupDescription": "تم إنشاء قاعدة المعرفة، لكن تعذّر تأكيد أذوناتها. عند المغادرة سيتم الاحتفاظ بقاعدة المعرفة؛ يمكنك مراجعة الوصول لاحقًا.",
|
||||
"newKnowledge.leavePartialSetupTitle": "مغادرة الإعداد غير المكتمل؟",
|
||||
"newKnowledge.legacy": "قديم",
|
||||
"newKnowledge.loadMore": "تحميل المزيد",
|
||||
"newKnowledge.name": "اسم المعرفة",
|
||||
"newKnowledge.namePlaceholder": "على سبيل المثال دليل دعم العملاء",
|
||||
"newKnowledge.nameRequired": "أدخل اسم قاعدة المعرفة.",
|
||||
"newKnowledge.new": "جديد",
|
||||
"newKnowledge.noDescription": "لا يوجد وصف",
|
||||
"newKnowledge.notFoundDescription": "ربما تم حذف قاعدة المعرفة هذه أو ربما لا يمكنك الوصول إليها.",
|
||||
"newKnowledge.notFoundTitle": "لم يتم العثور على قاعدة المعرفة",
|
||||
"newKnowledge.permission": "إذن",
|
||||
"newKnowledge.permissionAllMembers": "مساحة العمل · يمكن لجميع الأعضاء العرض والتحرير",
|
||||
"newKnowledge.permissionOnlyMe": "أنا فقط",
|
||||
"newKnowledge.permissionRestricted": "لا تسمح أذونات مساحة العمل الخاصة بك بتغيير هذا الإعداد.",
|
||||
"newKnowledge.permissionUpdateFailed": "تم إنشاء قاعدة المعرفة، لكن تعذّر تأكيد أذوناتها. أعد المحاولة لإكمال الإعداد.",
|
||||
"newKnowledge.readOnlyEmpty": "اطلب من محرر مساحة العمل إنشاء أول قاعدة معرفة.",
|
||||
"newKnowledge.routeUnavailable": "سيصبح هذا القسم متاحًا في خطوة الإعداد التالية.",
|
||||
"newKnowledge.sources": "المصادر",
|
||||
"newKnowledge.startEmpty": "ابدأ فارغًا",
|
||||
"newKnowledge.startEmptyDescription": "أنشئها الآن وأضف المحتوى لاحقًا.",
|
||||
"newKnowledge.startWith": "ابدأ ب",
|
||||
"newKnowledge.startWithHelp": "يمكنك إضافة المزيد من المصادر أو المستندات في أي وقت.",
|
||||
"newKnowledge.tags": "الوسوم",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS غير متاح لمساحة العمل هذه حاليًا.",
|
||||
"newKnowledge.unavailableTitle": "المعرفة الجديدة غير متاحة",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "Das Rerank-Modell ist für die Mischung von internem und externem Wissen erforderlich.",
|
||||
"multimodal": "Multimodal",
|
||||
"newKnowledge.appsUnavailable": "— Apps",
|
||||
"newKnowledge.backToList": "Zurück zu den Wissensdatenbanken",
|
||||
"newKnowledge.cardType": "Wissensbereich",
|
||||
"newKnowledge.connectSource": "Quelle verbinden",
|
||||
"newKnowledge.connectSourceDescription": "Inhalte bleiben mit Notion, Websites und anderen Diensten synchron.",
|
||||
"newKnowledge.createFailed": "Wir konnten diese Wissensdatenbank nicht erstellen. Überprüfen Sie Ihren Zugang und versuchen Sie es erneut.",
|
||||
"newKnowledge.createTitle": "Wissen schaffen",
|
||||
"newKnowledge.creators": "Ersteller",
|
||||
"newKnowledge.description": "Beschreibung",
|
||||
"newKnowledge.descriptionHelp": "Eine klare Beschreibung hilft Apps bei der Entscheidung, wann dieses Wissen durchsucht werden soll.",
|
||||
"newKnowledge.descriptionPlaceholder": "z.B. Rückerstattungsrichtlinien, häufig gestellte Fragen zum Versand und Eskalationsschritte – für Fragen zum Kundensupport verwenden",
|
||||
"newKnowledge.detailErrorDescription": "Diese Wissensdatenbank konnte nicht geladen werden. Versuchen Sie es erneut.",
|
||||
"newKnowledge.detailErrorTitle": "Etwas ist schief gelaufen",
|
||||
"newKnowledge.discardDraftConfirm": "Entwurf verwerfen",
|
||||
"newKnowledge.discardDraftDescription": "Ihre Änderungen an diesem Wissensdatenbank-Entwurf gehen verloren.",
|
||||
"newKnowledge.discardDraftTitle": "Diesen Wissensdatenbank-Entwurf verwerfen?",
|
||||
"newKnowledge.documents": "Dokumente",
|
||||
"newKnowledge.documentsUnavailable": "— Dokumente",
|
||||
"newKnowledge.emptyDescription": "Fügen Sie Ihre Dokumente hinzu, damit Agents sie direkt lesen können — ohne eine Indizierung einzurichten.",
|
||||
"newKnowledge.emptyTitle": "Wissensdatenbanken intelligenter aufbauen",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "Verstanden",
|
||||
"newKnowledge.guideDescription": "Verbinden Sie eine Quelle, und ihre Dokumente bleiben synchron — automatisch indiziert und sofort einsatzbereit, ohne Verwaltungsaufwand. Ihre bisherigen Wissensdatenbanken funktionieren unverändert weiter.",
|
||||
"newKnowledge.guideTitle": "Lernen Sie die neue Wissensdatenbank kennen ✨",
|
||||
"newKnowledge.illustrationHeadline": "Verbinde deine Inhalte einmal – und jede App antwortet mit dem neuesten Wissen.",
|
||||
"newKnowledge.learnMore": "Mehr erfahren",
|
||||
"newKnowledge.leavePartialSetupConfirm": "Einrichtung verlassen",
|
||||
"newKnowledge.leavePartialSetupDescription": "Die Wissensdatenbank wurde erstellt, aber ihre Berechtigung konnte nicht bestätigt werden. Beim Verlassen bleibt die Wissensdatenbank erhalten; Sie können den Zugriff später überprüfen.",
|
||||
"newKnowledge.leavePartialSetupTitle": "Unvollständige Einrichtung verlassen?",
|
||||
"newKnowledge.legacy": "Legacy",
|
||||
"newKnowledge.loadMore": "Mehr laden",
|
||||
"newKnowledge.name": "Wissensname",
|
||||
"newKnowledge.namePlaceholder": "z.B. Kundendiensthandbuch",
|
||||
"newKnowledge.nameRequired": "Geben Sie einen Namen für die Wissensdatenbank ein.",
|
||||
"newKnowledge.new": "Neu",
|
||||
"newKnowledge.noDescription": "Keine Beschreibung",
|
||||
"newKnowledge.notFoundDescription": "Diese Wissensdatenbank wurde möglicherweise gelöscht oder Sie haben keinen Zugriff darauf.",
|
||||
"newKnowledge.notFoundTitle": "Wissensdatenbank nicht gefunden",
|
||||
"newKnowledge.permission": "Erlaubnis",
|
||||
"newKnowledge.permissionAllMembers": "Arbeitsbereich · Alle Mitglieder können ihn ansehen und bearbeiten",
|
||||
"newKnowledge.permissionOnlyMe": "Nur ich",
|
||||
"newKnowledge.permissionRestricted": "Ihre Arbeitsbereichsberechtigungen erlauben keine Änderung dieser Einstellung.",
|
||||
"newKnowledge.permissionUpdateFailed": "Die Wissensdatenbank wurde erstellt, aber ihre Berechtigung konnte nicht bestätigt werden. Versuchen Sie es erneut, um die Einrichtung abzuschließen.",
|
||||
"newKnowledge.readOnlyEmpty": "Bitten Sie einen Arbeitsbereich-Editor, die erste Wissensdatenbank zu erstellen.",
|
||||
"newKnowledge.routeUnavailable": "Dieser Abschnitt wird im nächsten Einrichtungsschritt verfügbar.",
|
||||
"newKnowledge.sources": "Quellen",
|
||||
"newKnowledge.startEmpty": "Leer beginnen",
|
||||
"newKnowledge.startEmptyDescription": "Jetzt erstellen und Inhalte später hinzufügen.",
|
||||
"newKnowledge.startWith": "Beginnen Sie mit",
|
||||
"newKnowledge.startWithHelp": "Sie können jederzeit weitere Quellen oder Dokumente hinzufügen.",
|
||||
"newKnowledge.tags": "Tags",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS ist für diesen Arbeitsbereich derzeit nicht verfügbar.",
|
||||
"newKnowledge.unavailableTitle": "Neues Wissen ist nicht verfügbar",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "The Rerank model is required for mixture of internal and external knowledge.",
|
||||
"multimodal": "Multimodal",
|
||||
"newKnowledge.appsUnavailable": "— apps",
|
||||
"newKnowledge.backToList": "Back to knowledge bases",
|
||||
"newKnowledge.cardType": "Knowledge space",
|
||||
"newKnowledge.connectSource": "Connect a source",
|
||||
"newKnowledge.connectSourceDescription": "Content stays in sync with Notion, websites, and other services.",
|
||||
"newKnowledge.createFailed": "We couldn't create this knowledge base. Check your access and try again.",
|
||||
"newKnowledge.createTitle": "Create Knowledge",
|
||||
"newKnowledge.creators": "Creators",
|
||||
"newKnowledge.description": "Description",
|
||||
"newKnowledge.descriptionHelp": "A clear description helps apps decide when to search this Knowledge.",
|
||||
"newKnowledge.descriptionPlaceholder": "e.g. Refund policy, shipping FAQs, and escalation steps — use for customer support questions",
|
||||
"newKnowledge.detailErrorDescription": "We couldn't load this knowledge base. Try again.",
|
||||
"newKnowledge.detailErrorTitle": "Something went wrong",
|
||||
"newKnowledge.discardDraftConfirm": "Discard draft",
|
||||
"newKnowledge.discardDraftDescription": "Your changes to this knowledge base draft will be lost.",
|
||||
"newKnowledge.discardDraftTitle": "Discard this knowledge base draft?",
|
||||
"newKnowledge.documents": "Documents",
|
||||
"newKnowledge.documentsUnavailable": "— documents",
|
||||
"newKnowledge.emptyDescription": "Add your documents and agents can read them directly — no indexing to configure.",
|
||||
"newKnowledge.emptyTitle": "A smarter way to build knowledge bases",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "Got it",
|
||||
"newKnowledge.guideDescription": "Connect a Source and its Documents stay in sync — indexed automatically and ready to use, with no setup to manage. Your Legacy knowledge bases keep working as-is.",
|
||||
"newKnowledge.guideTitle": "Meet the new Knowledge Base ✨",
|
||||
"newKnowledge.illustrationHeadline": "Connect your content once — every app answers with the freshest knowledge.",
|
||||
"newKnowledge.learnMore": "Learn more",
|
||||
"newKnowledge.leavePartialSetupConfirm": "Leave setup",
|
||||
"newKnowledge.leavePartialSetupDescription": "The knowledge base was created, but its permission could not be confirmed. Leaving keeps the knowledge base; you can review access later.",
|
||||
"newKnowledge.leavePartialSetupTitle": "Leave incomplete setup?",
|
||||
"newKnowledge.legacy": "Legacy",
|
||||
"newKnowledge.loadMore": "Load more",
|
||||
"newKnowledge.name": "Knowledge name",
|
||||
"newKnowledge.namePlaceholder": "e.g. Customer Support Handbook",
|
||||
"newKnowledge.nameRequired": "Enter a knowledge base name.",
|
||||
"newKnowledge.new": "New",
|
||||
"newKnowledge.noDescription": "No description",
|
||||
"newKnowledge.notFoundDescription": "This knowledge base may have been deleted or you may not have access.",
|
||||
"newKnowledge.notFoundTitle": "Knowledge base not found",
|
||||
"newKnowledge.permission": "Permission",
|
||||
"newKnowledge.permissionAllMembers": "Workspace · all members can view and edit",
|
||||
"newKnowledge.permissionOnlyMe": "Only me",
|
||||
"newKnowledge.permissionRestricted": "Your workspace permissions don't allow changing this setting.",
|
||||
"newKnowledge.permissionUpdateFailed": "The knowledge base was created, but its permission could not be confirmed. Retry to finish setup.",
|
||||
"newKnowledge.readOnlyEmpty": "Ask a workspace editor to create the first knowledge base.",
|
||||
"newKnowledge.routeUnavailable": "This section will become available in the next setup step.",
|
||||
"newKnowledge.sources": "Sources",
|
||||
"newKnowledge.startEmpty": "Start empty",
|
||||
"newKnowledge.startEmptyDescription": "Create it now, add content later.",
|
||||
"newKnowledge.startWith": "Start with",
|
||||
"newKnowledge.startWithHelp": "You can add more Sources or Documents anytime.",
|
||||
"newKnowledge.tags": "Tags",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS isn't available for this workspace right now.",
|
||||
"newKnowledge.unavailableTitle": "New Knowledge is unavailable",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "El modelo de Rerank es necesario para la mezcla de conocimiento interno y externo.",
|
||||
"multimodal": "Multimodal",
|
||||
"newKnowledge.appsUnavailable": "— aplicaciones",
|
||||
"newKnowledge.backToList": "Volver a las bases de conocimiento",
|
||||
"newKnowledge.cardType": "Espacio de conocimiento",
|
||||
"newKnowledge.connectSource": "Conectar una fuente",
|
||||
"newKnowledge.connectSourceDescription": "El contenido se mantiene sincronizado con Notion, sitios web y otros servicios.",
|
||||
"newKnowledge.createFailed": "No pudimos crear esta base de conocimientos. Comprueba tu acceso y vuelve a intentarlo.",
|
||||
"newKnowledge.createTitle": "Crear conocimiento",
|
||||
"newKnowledge.creators": "Creadores",
|
||||
"newKnowledge.description": "Descripción",
|
||||
"newKnowledge.descriptionHelp": "Una descripción clara ayuda a las aplicaciones a decidir cuándo buscar este conocimiento.",
|
||||
"newKnowledge.descriptionPlaceholder": "p.ej. Política de reembolso, preguntas frecuentes sobre envíos y pasos a seguir: utilícelos para preguntas de atención al cliente",
|
||||
"newKnowledge.detailErrorDescription": "No pudimos cargar esta base de conocimientos. Intentar otra vez.",
|
||||
"newKnowledge.detailErrorTitle": "algo salió mal",
|
||||
"newKnowledge.discardDraftConfirm": "Descartar borrador",
|
||||
"newKnowledge.discardDraftDescription": "Se perderán los cambios realizados en este borrador de base de conocimientos.",
|
||||
"newKnowledge.discardDraftTitle": "¿Descartar este borrador de base de conocimientos?",
|
||||
"newKnowledge.documents": "Documentos",
|
||||
"newKnowledge.documentsUnavailable": "— documentos",
|
||||
"newKnowledge.emptyDescription": "Añade tus documentos y los agentes podrán leerlos directamente, sin configurar la indexación.",
|
||||
"newKnowledge.emptyTitle": "Una forma más inteligente de crear bases de conocimiento",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "Entendido",
|
||||
"newKnowledge.guideDescription": "Conecta una fuente y sus documentos se mantendrán sincronizados, indexados automáticamente y listos para usar, sin nada que administrar. Tus bases de conocimiento anteriores seguirán funcionando igual.",
|
||||
"newKnowledge.guideTitle": "Conoce la nueva base de conocimiento ✨",
|
||||
"newKnowledge.illustrationHeadline": "Conecta tu contenido una vez: cada aplicación responderá con el conocimiento más reciente.",
|
||||
"newKnowledge.learnMore": "Más información",
|
||||
"newKnowledge.leavePartialSetupConfirm": "Salir de la configuración",
|
||||
"newKnowledge.leavePartialSetupDescription": "La base de conocimientos se creó, pero no se pudo confirmar su permiso. Si sales, la base de conocimientos se conservará; puedes revisar el acceso más adelante.",
|
||||
"newKnowledge.leavePartialSetupTitle": "¿Salir de la configuración incompleta?",
|
||||
"newKnowledge.legacy": "Anterior",
|
||||
"newKnowledge.loadMore": "Cargar más",
|
||||
"newKnowledge.name": "Nombre del conocimiento",
|
||||
"newKnowledge.namePlaceholder": "por ej. Manual de atención al cliente",
|
||||
"newKnowledge.nameRequired": "Introduzca un nombre para la base de conocimientos.",
|
||||
"newKnowledge.new": "Nuevo",
|
||||
"newKnowledge.noDescription": "Sin descripción",
|
||||
"newKnowledge.notFoundDescription": "Es posible que esta base de conocimientos haya sido eliminada o que no tengas acceso.",
|
||||
"newKnowledge.notFoundTitle": "Base de conocimientos no encontrada",
|
||||
"newKnowledge.permission": "Permiso",
|
||||
"newKnowledge.permissionAllMembers": "Espacio de trabajo · todos los miembros pueden ver y editar",
|
||||
"newKnowledge.permissionOnlyMe": "solo yo",
|
||||
"newKnowledge.permissionRestricted": "Los permisos de su espacio de trabajo no permiten cambiar esta configuración.",
|
||||
"newKnowledge.permissionUpdateFailed": "La base de conocimientos se creó, pero no se pudo confirmar su permiso. Vuelve a intentarlo para finalizar la configuración.",
|
||||
"newKnowledge.readOnlyEmpty": "Pide a un editor del espacio de trabajo que cree la primera base de conocimiento.",
|
||||
"newKnowledge.routeUnavailable": "Esta sección estará disponible en el siguiente paso de configuración.",
|
||||
"newKnowledge.sources": "Fuentes",
|
||||
"newKnowledge.startEmpty": "Empezar vacía",
|
||||
"newKnowledge.startEmptyDescription": "Créala ahora y añade contenido más tarde.",
|
||||
"newKnowledge.startWith": "Empezar con",
|
||||
"newKnowledge.startWithHelp": "Puede agregar más fuentes o documentos en cualquier momento.",
|
||||
"newKnowledge.tags": "Etiquetas",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS no está disponible para este espacio de trabajo ahora mismo.",
|
||||
"newKnowledge.unavailableTitle": "El nuevo conocimiento no está disponible",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "مدل Rerank برای آمیختگی دانش درونی و بیرونی مورد نیاز است.",
|
||||
"multimodal": "چندوجهی",
|
||||
"newKnowledge.appsUnavailable": "— برنامه",
|
||||
"newKnowledge.backToList": "بازگشت به پایگاه های دانش",
|
||||
"newKnowledge.cardType": "فضای دانش",
|
||||
"newKnowledge.connectSource": "اتصال منبع",
|
||||
"newKnowledge.connectSourceDescription": "محتوا با Notion، وبسایتها و سرویسهای دیگر همگام میماند.",
|
||||
"newKnowledge.createFailed": "ما نتوانستیم این پایگاه دانش را ایجاد کنیم. دسترسی خود را بررسی کنید و دوباره امتحان کنید.",
|
||||
"newKnowledge.createTitle": "ایجاد دانش",
|
||||
"newKnowledge.creators": "ایجادکنندگان",
|
||||
"newKnowledge.description": "توضیحات",
|
||||
"newKnowledge.descriptionHelp": "توضیح واضح به برنامهها کمک میکند تصمیم بگیرند که چه زمانی این دانش را جستجو کنند.",
|
||||
"newKnowledge.descriptionPlaceholder": "به عنوان مثال خط مشی بازپرداخت، سؤالات متداول حمل و نقل، و مراحل افزایش - برای سؤالات پشتیبانی مشتری استفاده کنید",
|
||||
"newKnowledge.detailErrorDescription": "ما نتوانستیم این پایگاه دانش را بارگیری کنیم. دوباره امتحان کنید.",
|
||||
"newKnowledge.detailErrorTitle": "مشکلی پیش آمد",
|
||||
"newKnowledge.discardDraftConfirm": "حذف پیشنویس",
|
||||
"newKnowledge.discardDraftDescription": "تغییرات شما در این پیشنویس پایگاه دانش از بین خواهد رفت.",
|
||||
"newKnowledge.discardDraftTitle": "این پیشنویس پایگاه دانش حذف شود؟",
|
||||
"newKnowledge.documents": "اسناد",
|
||||
"newKnowledge.documentsUnavailable": "— سند",
|
||||
"newKnowledge.emptyDescription": "اسناد خود را اضافه کنید تا عاملها بتوانند مستقیماً آنها را بخوانند — بدون نیاز به تنظیم نمایهسازی.",
|
||||
"newKnowledge.emptyTitle": "روشی هوشمندتر برای ساخت پایگاههای دانش",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "متوجه شدم",
|
||||
"newKnowledge.guideDescription": "یک منبع را متصل کنید تا اسناد آن همگام، خودکار نمایهسازی و آماده استفاده بمانند؛ بدون تنظیماتی برای مدیریت. پایگاههای دانش قبلی شما همچنان مانند گذشته کار میکنند.",
|
||||
"newKnowledge.guideTitle": "با پایگاه دانش جدید آشنا شوید ✨",
|
||||
"newKnowledge.illustrationHeadline": "محتوای خود را یکبار متصل کنید تا هر برنامه با تازهترین دانش پاسخ دهد.",
|
||||
"newKnowledge.learnMore": "بیشتر بدانید",
|
||||
"newKnowledge.leavePartialSetupConfirm": "خروج از راهاندازی",
|
||||
"newKnowledge.leavePartialSetupDescription": "پایگاه دانش ایجاد شد، اما مجوز آن تأیید نشد. با خروج، پایگاه دانش حفظ میشود؛ میتوانید بعداً دسترسی را بررسی کنید.",
|
||||
"newKnowledge.leavePartialSetupTitle": "از راهاندازی ناتمام خارج میشوید؟",
|
||||
"newKnowledge.legacy": "قدیمی",
|
||||
"newKnowledge.loadMore": "بارگذاری بیشتر",
|
||||
"newKnowledge.name": "نام دانش",
|
||||
"newKnowledge.namePlaceholder": "به عنوان مثال راهنمای پشتیبانی مشتری",
|
||||
"newKnowledge.nameRequired": "یک نام پایگاه دانش وارد کنید.",
|
||||
"newKnowledge.new": "جدید",
|
||||
"newKnowledge.noDescription": "بدون توضیح",
|
||||
"newKnowledge.notFoundDescription": "این پایگاه دانش ممکن است حذف شده باشد یا ممکن است دسترسی نداشته باشید.",
|
||||
"newKnowledge.notFoundTitle": "پایگاه دانش یافت نشد",
|
||||
"newKnowledge.permission": "اجازه",
|
||||
"newKnowledge.permissionAllMembers": "فضای کاری · همه اعضا می توانند مشاهده و ویرایش کنند",
|
||||
"newKnowledge.permissionOnlyMe": "فقط من",
|
||||
"newKnowledge.permissionRestricted": "مجوزهای فضای کاری شما اجازه تغییر این تنظیم را نمی دهد.",
|
||||
"newKnowledge.permissionUpdateFailed": "پایگاه دانش ایجاد شد، اما مجوز آن تأیید نشد. برای تکمیل راهاندازی دوباره تلاش کنید.",
|
||||
"newKnowledge.readOnlyEmpty": "از یک ویرایشگر فضای کاری بخواهید اولین پایگاه دانش را ایجاد کند.",
|
||||
"newKnowledge.routeUnavailable": "این بخش در مرحله راه اندازی بعدی در دسترس خواهد بود.",
|
||||
"newKnowledge.sources": "منابع",
|
||||
"newKnowledge.startEmpty": "شروع از خالی",
|
||||
"newKnowledge.startEmptyDescription": "اکنون ایجاد کنید و بعداً محتوا اضافه کنید.",
|
||||
"newKnowledge.startWith": "شروع کنید با",
|
||||
"newKnowledge.startWithHelp": "میتوانید منابع یا اسناد بیشتری را در هر زمان اضافه کنید.",
|
||||
"newKnowledge.tags": "برچسبها",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS اکنون برای این فضای کاری در دسترس نیست.",
|
||||
"newKnowledge.unavailableTitle": "دانش جدید در دسترس نیست",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "Le modèle Rerank est nécessaire pour mélanger les connaissances internes et externes.",
|
||||
"multimodal": "Multimodal",
|
||||
"newKnowledge.appsUnavailable": "— applications",
|
||||
"newKnowledge.backToList": "Retour aux bases de connaissances",
|
||||
"newKnowledge.cardType": "Espace de connaissances",
|
||||
"newKnowledge.connectSource": "Connecter une source",
|
||||
"newKnowledge.connectSourceDescription": "Le contenu reste synchronisé avec Notion, les sites web et d’autres services.",
|
||||
"newKnowledge.createFailed": "Nous n'avons pas pu créer cette base de connaissances. Vérifiez votre accès et réessayez.",
|
||||
"newKnowledge.createTitle": "Créer des connaissances",
|
||||
"newKnowledge.creators": "Créateurs",
|
||||
"newKnowledge.description": "Descriptif",
|
||||
"newKnowledge.descriptionHelp": "Une description claire aide les applications à décider quand rechercher ces connaissances.",
|
||||
"newKnowledge.descriptionPlaceholder": "par ex. Politique de remboursement, FAQ sur l'expédition et étapes de remontée d'informations – à utiliser pour les questions d'assistance client",
|
||||
"newKnowledge.detailErrorDescription": "Nous n'avons pas pu charger cette base de connaissances. Essayer à nouveau.",
|
||||
"newKnowledge.detailErrorTitle": "Quelque chose s'est mal passé",
|
||||
"newKnowledge.discardDraftConfirm": "Abandonner le brouillon",
|
||||
"newKnowledge.discardDraftDescription": "Les modifications apportées à ce brouillon de base de connaissances seront perdues.",
|
||||
"newKnowledge.discardDraftTitle": "Abandonner ce brouillon de base de connaissances ?",
|
||||
"newKnowledge.documents": "Documents",
|
||||
"newKnowledge.documentsUnavailable": "— documents",
|
||||
"newKnowledge.emptyDescription": "Ajoutez vos documents et les agents pourront les lire directement, sans indexation à configurer.",
|
||||
"newKnowledge.emptyTitle": "Une façon plus intelligente de créer des bases de connaissances",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "Compris",
|
||||
"newKnowledge.guideDescription": "Connectez une source et ses documents restent synchronisés, indexés automatiquement et prêts à l’emploi, sans configuration à gérer. Vos anciennes bases de connaissances continuent de fonctionner telles quelles.",
|
||||
"newKnowledge.guideTitle": "Découvrez la nouvelle base de connaissances ✨",
|
||||
"newKnowledge.illustrationHeadline": "Connectez votre contenu une fois : chaque application répond avec les connaissances les plus récentes.",
|
||||
"newKnowledge.learnMore": "En savoir plus",
|
||||
"newKnowledge.leavePartialSetupConfirm": "Quitter la configuration",
|
||||
"newKnowledge.leavePartialSetupDescription": "La base de connaissances a été créée, mais son autorisation n’a pas pu être confirmée. Si vous quittez, la base de connaissances sera conservée ; vous pourrez vérifier l’accès plus tard.",
|
||||
"newKnowledge.leavePartialSetupTitle": "Quitter la configuration incomplète ?",
|
||||
"newKnowledge.legacy": "Ancien",
|
||||
"newKnowledge.loadMore": "Charger plus",
|
||||
"newKnowledge.name": "Nom de la connaissance",
|
||||
"newKnowledge.namePlaceholder": "par ex. Manuel de support client",
|
||||
"newKnowledge.nameRequired": "Entrez un nom de base de connaissances.",
|
||||
"newKnowledge.new": "Nouveau",
|
||||
"newKnowledge.noDescription": "Aucune description",
|
||||
"newKnowledge.notFoundDescription": "Cette base de connaissances a peut-être été supprimée ou vous n'y avez peut-être pas accès.",
|
||||
"newKnowledge.notFoundTitle": "Base de connaissances introuvable",
|
||||
"newKnowledge.permission": "Autorisation",
|
||||
"newKnowledge.permissionAllMembers": "Espace de travail · tous les membres peuvent consulter et modifier",
|
||||
"newKnowledge.permissionOnlyMe": "Seulement moi",
|
||||
"newKnowledge.permissionRestricted": "Les autorisations de votre espace de travail ne permettent pas de modifier ce paramètre.",
|
||||
"newKnowledge.permissionUpdateFailed": "La base de connaissances a été créée, mais son autorisation n’a pas pu être confirmée. Réessayez pour terminer la configuration.",
|
||||
"newKnowledge.readOnlyEmpty": "Demandez à un éditeur de l’espace de travail de créer la première base de connaissances.",
|
||||
"newKnowledge.routeUnavailable": "Cette section deviendra disponible lors de la prochaine étape de configuration.",
|
||||
"newKnowledge.sources": "Sources",
|
||||
"newKnowledge.startEmpty": "Commencer à vide",
|
||||
"newKnowledge.startEmptyDescription": "Créez-la maintenant et ajoutez du contenu plus tard.",
|
||||
"newKnowledge.startWith": "Commencez par",
|
||||
"newKnowledge.startWithHelp": "Vous pouvez ajouter plus de sources ou de documents à tout moment.",
|
||||
"newKnowledge.tags": "Étiquettes",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS n’est pas disponible pour cet espace de travail actuellement.",
|
||||
"newKnowledge.unavailableTitle": "Les nouvelles connaissances sont indisponibles",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "आंतरिक और बाहरी ज्ञान के मिश्रण के लिए रीरैंक मॉडल की आवश्यकता होती है।",
|
||||
"multimodal": "बहु-मोडल",
|
||||
"newKnowledge.appsUnavailable": "— ऐप",
|
||||
"newKnowledge.backToList": "ज्ञानकोष पर वापस जाएँ",
|
||||
"newKnowledge.cardType": "नॉलेज स्पेस",
|
||||
"newKnowledge.connectSource": "स्रोत कनेक्ट करें",
|
||||
"newKnowledge.connectSourceDescription": "कंटेंट Notion, वेबसाइटों और अन्य सेवाओं के साथ सिंक रहता है।",
|
||||
"newKnowledge.createFailed": "हम यह ज्ञानकोष नहीं बना सके। अपनी पहुंच जांचें और पुनः प्रयास करें।",
|
||||
"newKnowledge.createTitle": "ज्ञान पैदा करें",
|
||||
"newKnowledge.creators": "निर्माता",
|
||||
"newKnowledge.description": "विवरण",
|
||||
"newKnowledge.descriptionHelp": "एक स्पष्ट विवरण ऐप्स को यह निर्णय लेने में मदद करता है कि इस ज्ञान को कब खोजना है।",
|
||||
"newKnowledge.descriptionPlaceholder": "जैसे रिफंड नीति, शिपिंग एफएक्यू और एस्केलेशन चरण - ग्राहक सहायता प्रश्नों के लिए उपयोग करें",
|
||||
"newKnowledge.detailErrorDescription": "हम इस ज्ञानकोष को लोड नहीं कर सके. पुनः प्रयास करें।",
|
||||
"newKnowledge.detailErrorTitle": "कुछ ग़लत हो गया",
|
||||
"newKnowledge.discardDraftConfirm": "ड्राफ़्ट हटाएँ",
|
||||
"newKnowledge.discardDraftDescription": "इस नॉलेज बेस ड्राफ़्ट में किए गए आपके बदलाव खो जाएँगे।",
|
||||
"newKnowledge.discardDraftTitle": "क्या यह नॉलेज बेस ड्राफ़्ट हटाना है?",
|
||||
"newKnowledge.documents": "दस्तावेज़",
|
||||
"newKnowledge.documentsUnavailable": "— दस्तावेज़",
|
||||
"newKnowledge.emptyDescription": "अपने दस्तावेज़ जोड़ें ताकि एजेंट उन्हें सीधे पढ़ सकें — इंडेक्सिंग कॉन्फ़िगर करने की ज़रूरत नहीं।",
|
||||
"newKnowledge.emptyTitle": "नॉलेज बेस बनाने का अधिक स्मार्ट तरीका",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "समझ गया",
|
||||
"newKnowledge.guideDescription": "किसी स्रोत को कनेक्ट करें और उसके दस्तावेज़ सिंक, अपने-आप इंडेक्स और उपयोग के लिए तैयार रहेंगे—किसी सेटअप को संभालने की ज़रूरत नहीं। आपके पुराने नॉलेज बेस पहले की तरह काम करते रहेंगे।",
|
||||
"newKnowledge.guideTitle": "नए नॉलेज बेस से मिलें ✨",
|
||||
"newKnowledge.illustrationHeadline": "अपनी सामग्री एक बार कनेक्ट करें — हर ऐप नवीनतम ज्ञान के साथ जवाब देगा।",
|
||||
"newKnowledge.learnMore": "और जानें",
|
||||
"newKnowledge.leavePartialSetupConfirm": "सेटअप छोड़ें",
|
||||
"newKnowledge.leavePartialSetupDescription": "नॉलेज बेस बन गया है, लेकिन उसकी अनुमति की पुष्टि नहीं हो सकी। छोड़ने पर नॉलेज बेस बना रहेगा; आप बाद में एक्सेस की समीक्षा कर सकते हैं।",
|
||||
"newKnowledge.leavePartialSetupTitle": "क्या अधूरा सेटअप छोड़ना है?",
|
||||
"newKnowledge.legacy": "पुराना",
|
||||
"newKnowledge.loadMore": "और लोड करें",
|
||||
"newKnowledge.name": "ज्ञान नाम",
|
||||
"newKnowledge.namePlaceholder": "जैसे ग्राहक सहायता पुस्तिका",
|
||||
"newKnowledge.nameRequired": "नॉलेज बेस नाम दर्ज करें.",
|
||||
"newKnowledge.new": "नया",
|
||||
"newKnowledge.noDescription": "कोई विवरण नहीं",
|
||||
"newKnowledge.notFoundDescription": "हो सकता है कि यह ज्ञानकोष हटा दिया गया हो या आपके पास पहुंच न हो।",
|
||||
"newKnowledge.notFoundTitle": "ज्ञान का आधार नहीं मिला",
|
||||
"newKnowledge.permission": "अनुमति",
|
||||
"newKnowledge.permissionAllMembers": "कार्यक्षेत्र · सभी सदस्य देख और संपादित कर सकते हैं",
|
||||
"newKnowledge.permissionOnlyMe": "केवल मैं",
|
||||
"newKnowledge.permissionRestricted": "आपकी कार्यस्थान अनुमतियाँ इस सेटिंग को बदलने की अनुमति नहीं देतीं।",
|
||||
"newKnowledge.permissionUpdateFailed": "नॉलेज बेस बन गया है, लेकिन उसकी अनुमति की पुष्टि नहीं हो सकी। सेटअप पूरा करने के लिए फिर से कोशिश करें।",
|
||||
"newKnowledge.readOnlyEmpty": "पहला नॉलेज बेस बनाने के लिए कार्यस्थान संपादक से कहें।",
|
||||
"newKnowledge.routeUnavailable": "यह अनुभाग अगले सेटअप चरण में उपलब्ध हो जाएगा.",
|
||||
"newKnowledge.sources": "सूत्र",
|
||||
"newKnowledge.startEmpty": "खाली शुरू करें",
|
||||
"newKnowledge.startEmptyDescription": "अभी बनाएँ, कंटेंट बाद में जोड़ें।",
|
||||
"newKnowledge.startWith": "से शुरू करें",
|
||||
"newKnowledge.startWithHelp": "आप किसी भी समय अधिक स्रोत या दस्तावेज़ जोड़ सकते हैं।",
|
||||
"newKnowledge.tags": "टैग",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS अभी इस कार्यस्थान के लिए उपलब्ध नहीं है।",
|
||||
"newKnowledge.unavailableTitle": "नया नॉलेज उपलब्ध नहीं है",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "Model Rerank diperlukan untuk campuran pengetahuan internal dan eksternal.",
|
||||
"multimodal": "Multimodal",
|
||||
"newKnowledge.appsUnavailable": "— aplikasi",
|
||||
"newKnowledge.backToList": "Kembali ke basis pengetahuan",
|
||||
"newKnowledge.cardType": "Ruang pengetahuan",
|
||||
"newKnowledge.connectSource": "Hubungkan sumber",
|
||||
"newKnowledge.connectSourceDescription": "Konten tetap tersinkronisasi dengan Notion, situs web, dan layanan lainnya.",
|
||||
"newKnowledge.createFailed": "Kami tidak dapat membuat basis pengetahuan ini. Periksa akses Anda dan coba lagi.",
|
||||
"newKnowledge.createTitle": "Ciptakan Pengetahuan",
|
||||
"newKnowledge.creators": "Pembuat",
|
||||
"newKnowledge.description": "Deskripsi",
|
||||
"newKnowledge.descriptionHelp": "Deskripsi yang jelas membantu aplikasi memutuskan kapan harus mencari Pengetahuan ini.",
|
||||
"newKnowledge.descriptionPlaceholder": "misalnya Kebijakan pengembalian dana, FAQ pengiriman, dan langkah eskalasi — digunakan untuk pertanyaan dukungan pelanggan",
|
||||
"newKnowledge.detailErrorDescription": "Kami tidak dapat memuat basis pengetahuan ini. Coba lagi.",
|
||||
"newKnowledge.detailErrorTitle": "Ada yang tidak beres",
|
||||
"newKnowledge.discardDraftConfirm": "Buang draf",
|
||||
"newKnowledge.discardDraftDescription": "Perubahan pada draf basis pengetahuan ini akan hilang.",
|
||||
"newKnowledge.discardDraftTitle": "Buang draf basis pengetahuan ini?",
|
||||
"newKnowledge.documents": "Dokumen",
|
||||
"newKnowledge.documentsUnavailable": "— dokumen",
|
||||
"newKnowledge.emptyDescription": "Tambahkan dokumen agar agen dapat membacanya langsung — tanpa perlu mengatur pengindeksan.",
|
||||
"newKnowledge.emptyTitle": "Cara yang lebih cerdas untuk membuat basis pengetahuan",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "Mengerti",
|
||||
"newKnowledge.guideDescription": "Hubungkan sumber dan dokumennya akan tetap tersinkronisasi, diindeks otomatis, serta siap digunakan tanpa pengaturan yang perlu dikelola. Basis pengetahuan lama Anda tetap berfungsi seperti biasa.",
|
||||
"newKnowledge.guideTitle": "Kenali Basis Pengetahuan baru ✨",
|
||||
"newKnowledge.illustrationHeadline": "Hubungkan konten Anda sekali — setiap aplikasi menjawab dengan pengetahuan terbaru.",
|
||||
"newKnowledge.learnMore": "Pelajari lebih lanjut",
|
||||
"newKnowledge.leavePartialSetupConfirm": "Keluar dari penyiapan",
|
||||
"newKnowledge.leavePartialSetupDescription": "Basis pengetahuan telah dibuat, tetapi izinnya tidak dapat dikonfirmasi. Jika keluar, basis pengetahuan akan tetap tersimpan; Anda dapat meninjau akses nanti.",
|
||||
"newKnowledge.leavePartialSetupTitle": "Keluar dari penyiapan yang belum selesai?",
|
||||
"newKnowledge.legacy": "Lama",
|
||||
"newKnowledge.loadMore": "Muat lebih banyak",
|
||||
"newKnowledge.name": "Nama pengetahuan",
|
||||
"newKnowledge.namePlaceholder": "misalnya Buku Pegangan Dukungan Pelanggan",
|
||||
"newKnowledge.nameRequired": "Masukkan nama basis pengetahuan.",
|
||||
"newKnowledge.new": "Baru",
|
||||
"newKnowledge.noDescription": "Tidak ada deskripsi",
|
||||
"newKnowledge.notFoundDescription": "Basis pengetahuan ini mungkin telah dihapus atau Anda mungkin tidak memiliki akses.",
|
||||
"newKnowledge.notFoundTitle": "Basis pengetahuan tidak ditemukan",
|
||||
"newKnowledge.permission": "Izin",
|
||||
"newKnowledge.permissionAllMembers": "Ruang Kerja · semua anggota dapat melihat dan mengedit",
|
||||
"newKnowledge.permissionOnlyMe": "Hanya aku",
|
||||
"newKnowledge.permissionRestricted": "Izin ruang kerja Anda tidak mengizinkan perubahan pengaturan ini.",
|
||||
"newKnowledge.permissionUpdateFailed": "Basis pengetahuan telah dibuat, tetapi izinnya tidak dapat dikonfirmasi. Coba lagi untuk menyelesaikan penyiapan.",
|
||||
"newKnowledge.readOnlyEmpty": "Minta editor ruang kerja membuat basis pengetahuan pertama.",
|
||||
"newKnowledge.routeUnavailable": "Bagian ini akan tersedia pada langkah penyiapan berikutnya.",
|
||||
"newKnowledge.sources": "Sumber",
|
||||
"newKnowledge.startEmpty": "Mulai kosong",
|
||||
"newKnowledge.startEmptyDescription": "Buat sekarang, tambahkan konten nanti.",
|
||||
"newKnowledge.startWith": "Mulailah dengan",
|
||||
"newKnowledge.startWithHelp": "Anda dapat menambahkan lebih banyak Sumber atau Dokumen kapan saja.",
|
||||
"newKnowledge.tags": "Tag",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS saat ini tidak tersedia untuk ruang kerja ini.",
|
||||
"newKnowledge.unavailableTitle": "Pengetahuan Baru tidak tersedia",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "Il modello Rerank è necessario per la combinazione di conoscenze interne ed esterne.",
|
||||
"multimodal": "Multimodale",
|
||||
"newKnowledge.appsUnavailable": "— app",
|
||||
"newKnowledge.backToList": "Torniamo alle basi di conoscenza",
|
||||
"newKnowledge.cardType": "Spazio di conoscenza",
|
||||
"newKnowledge.connectSource": "Collega una fonte",
|
||||
"newKnowledge.connectSourceDescription": "I contenuti restano sincronizzati con Notion, siti web e altri servizi.",
|
||||
"newKnowledge.createFailed": "Non è stato possibile creare questa base di conoscenza. Controlla il tuo accesso e riprova.",
|
||||
"newKnowledge.createTitle": "Creare Conoscenza",
|
||||
"newKnowledge.creators": "Autori",
|
||||
"newKnowledge.description": "Descrizione",
|
||||
"newKnowledge.descriptionHelp": "Una descrizione chiara aiuta le app a decidere quando cercare questa conoscenza.",
|
||||
"newKnowledge.descriptionPlaceholder": "per esempio. Politica di rimborso, domande frequenti sulla spedizione e passaggi di escalation: utilizzali per domande sull'assistenza clienti",
|
||||
"newKnowledge.detailErrorDescription": "Impossibile caricare questa knowledge base. Riprova.",
|
||||
"newKnowledge.detailErrorTitle": "Qualcosa è andato storto",
|
||||
"newKnowledge.discardDraftConfirm": "Scarta bozza",
|
||||
"newKnowledge.discardDraftDescription": "Le modifiche a questa bozza della knowledge base andranno perse.",
|
||||
"newKnowledge.discardDraftTitle": "Scartare questa bozza della knowledge base?",
|
||||
"newKnowledge.documents": "Documenti",
|
||||
"newKnowledge.documentsUnavailable": "— documenti",
|
||||
"newKnowledge.emptyDescription": "Aggiungi i tuoi documenti e gli agenti potranno leggerli direttamente, senza configurare l’indicizzazione.",
|
||||
"newKnowledge.emptyTitle": "Un modo più intelligente per creare knowledge base",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "Ho capito",
|
||||
"newKnowledge.guideDescription": "Collega una fonte e i suoi documenti resteranno sincronizzati, indicizzati automaticamente e pronti all’uso, senza nulla da gestire. Le knowledge base precedenti continueranno a funzionare come prima.",
|
||||
"newKnowledge.guideTitle": "Scopri la nuova Knowledge Base ✨",
|
||||
"newKnowledge.illustrationHeadline": "Collega i tuoi contenuti una sola volta: ogni app risponderà con le conoscenze più aggiornate.",
|
||||
"newKnowledge.learnMore": "Scopri di più",
|
||||
"newKnowledge.leavePartialSetupConfirm": "Esci dalla configurazione",
|
||||
"newKnowledge.leavePartialSetupDescription": "La knowledge base è stata creata, ma non è stato possibile confermarne l’autorizzazione. Uscendo, la knowledge base verrà mantenuta; potrai verificare l’accesso in seguito.",
|
||||
"newKnowledge.leavePartialSetupTitle": "Uscire dalla configurazione incompleta?",
|
||||
"newKnowledge.legacy": "Legacy",
|
||||
"newKnowledge.loadMore": "Carica altro",
|
||||
"newKnowledge.name": "Nome della conoscenza",
|
||||
"newKnowledge.namePlaceholder": "ad es. Manuale dell'assistenza clienti",
|
||||
"newKnowledge.nameRequired": "Inserisci un nome per la knowledge base.",
|
||||
"newKnowledge.new": "Nuovo",
|
||||
"newKnowledge.noDescription": "Nessuna descrizione",
|
||||
"newKnowledge.notFoundDescription": "Questa knowledge base potrebbe essere stata eliminata o potresti non avere accesso.",
|
||||
"newKnowledge.notFoundTitle": "Base di conoscenza non trovata",
|
||||
"newKnowledge.permission": "Autorizzazione",
|
||||
"newKnowledge.permissionAllMembers": "Area di lavoro · tutti i membri possono visualizzare e modificare",
|
||||
"newKnowledge.permissionOnlyMe": "Solo io",
|
||||
"newKnowledge.permissionRestricted": "Le autorizzazioni dell'area di lavoro non consentono di modificare questa impostazione.",
|
||||
"newKnowledge.permissionUpdateFailed": "La knowledge base è stata creata, ma non è stato possibile confermarne l’autorizzazione. Riprova per completare la configurazione.",
|
||||
"newKnowledge.readOnlyEmpty": "Chiedi a un editor dello spazio di lavoro di creare la prima knowledge base.",
|
||||
"newKnowledge.routeUnavailable": "Questa sezione diventerà disponibile nella fase di configurazione successiva.",
|
||||
"newKnowledge.sources": "Fonti",
|
||||
"newKnowledge.startEmpty": "Inizia da zero",
|
||||
"newKnowledge.startEmptyDescription": "Creala ora e aggiungi i contenuti in seguito.",
|
||||
"newKnowledge.startWith": "Inizia con",
|
||||
"newKnowledge.startWithHelp": "Puoi aggiungere più fonti o documenti in qualsiasi momento.",
|
||||
"newKnowledge.tags": "Tag",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS non è al momento disponibile per questo spazio di lavoro.",
|
||||
"newKnowledge.unavailableTitle": "La nuova conoscenza non è disponibile",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "内部と外部のナレッジベースを混在させる場合、Rerank モデルが必要です。",
|
||||
"multimodal": "マルチモーダル",
|
||||
"newKnowledge.appsUnavailable": "— 個のアプリ",
|
||||
"newKnowledge.backToList": "ナレッジベースに戻る",
|
||||
"newKnowledge.cardType": "ナレッジスペース",
|
||||
"newKnowledge.connectSource": "ソースを接続",
|
||||
"newKnowledge.connectSourceDescription": "コンテンツは Notion、Web サイト、その他のサービスと同期され続けます。",
|
||||
"newKnowledge.createFailed": "このナレッジ ベースを作成できませんでした。アクセスを確認して再試行してください。",
|
||||
"newKnowledge.createTitle": "知識の創造",
|
||||
"newKnowledge.creators": "作成者",
|
||||
"newKnowledge.description": "説明",
|
||||
"newKnowledge.descriptionHelp": "明確な説明は、アプリがこのナレッジをいつ検索するかを決定するのに役立ちます。",
|
||||
"newKnowledge.descriptionPlaceholder": "例:返金ポリシー、配送に関する FAQ、エスカレーション手順 - カスタマー サポートの質問に使用します",
|
||||
"newKnowledge.detailErrorDescription": "このナレッジベースをロードできませんでした。もう一度やり直してください。",
|
||||
"newKnowledge.detailErrorTitle": "何か問題が発生しました",
|
||||
"newKnowledge.discardDraftConfirm": "下書きを破棄",
|
||||
"newKnowledge.discardDraftDescription": "このナレッジベースの下書きに加えた変更は失われます。",
|
||||
"newKnowledge.discardDraftTitle": "このナレッジベースの下書きを破棄しますか?",
|
||||
"newKnowledge.documents": "書類",
|
||||
"newKnowledge.documentsUnavailable": "— 件のドキュメント",
|
||||
"newKnowledge.emptyDescription": "ドキュメントを追加すると、エージェントが直接読み取れます。インデックス設定は不要です。",
|
||||
"newKnowledge.emptyTitle": "ナレッジベースをよりスマートに構築",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "了解",
|
||||
"newKnowledge.guideDescription": "ソースを接続すると、そのドキュメントは同期され、自動的にインデックス化されてすぐに利用できます。管理する設定はありません。従来のナレッジベースもこれまでどおり動作します。",
|
||||
"newKnowledge.guideTitle": "新しいナレッジベースへようこそ ✨",
|
||||
"newKnowledge.illustrationHeadline": "コンテンツを一度接続すれば、すべてのアプリが最新のナレッジで回答します。",
|
||||
"newKnowledge.learnMore": "詳細を見る",
|
||||
"newKnowledge.leavePartialSetupConfirm": "セットアップを終了",
|
||||
"newKnowledge.leavePartialSetupDescription": "ナレッジベースは作成されましたが、権限を確認できませんでした。終了してもナレッジベースは保持され、アクセス権は後で確認できます。",
|
||||
"newKnowledge.leavePartialSetupTitle": "未完了のセットアップを終了しますか?",
|
||||
"newKnowledge.legacy": "旧バージョン",
|
||||
"newKnowledge.loadMore": "さらに読み込む",
|
||||
"newKnowledge.name": "ナレッジ名",
|
||||
"newKnowledge.namePlaceholder": "例:カスタマーサポートハンドブック",
|
||||
"newKnowledge.nameRequired": "ナレッジベース名を入力します。",
|
||||
"newKnowledge.new": "新バージョン",
|
||||
"newKnowledge.noDescription": "説明なし",
|
||||
"newKnowledge.notFoundDescription": "このナレッジ ベースは削除されているか、アクセスできない可能性があります。",
|
||||
"newKnowledge.notFoundTitle": "ナレッジベースが見つかりません",
|
||||
"newKnowledge.permission": "許可",
|
||||
"newKnowledge.permissionAllMembers": "ワークスペース · すべてのメンバーが表示および編集できます",
|
||||
"newKnowledge.permissionOnlyMe": "私だけ",
|
||||
"newKnowledge.permissionRestricted": "ワークスペースの権限では、この設定を変更することはできません。",
|
||||
"newKnowledge.permissionUpdateFailed": "ナレッジベースは作成されましたが、権限を確認できませんでした。再試行してセットアップを完了してください。",
|
||||
"newKnowledge.readOnlyEmpty": "最初のナレッジベースの作成をワークスペース編集者に依頼してください。",
|
||||
"newKnowledge.routeUnavailable": "このセクションは、次のセットアップ手順で使用できるようになります。",
|
||||
"newKnowledge.sources": "情報源",
|
||||
"newKnowledge.startEmpty": "空から始める",
|
||||
"newKnowledge.startEmptyDescription": "今すぐ作成し、コンテンツは後から追加できます。",
|
||||
"newKnowledge.startWith": "から始める",
|
||||
"newKnowledge.startWithHelp": "いつでもソースやドキュメントを追加できます。",
|
||||
"newKnowledge.tags": "タグ",
|
||||
"newKnowledge.unavailableDescription": "このワークスペースでは現在 KnowledgeFS を利用できません。",
|
||||
"newKnowledge.unavailableTitle": "新しいナレッジは利用できません",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "리랭크 모델은 내부 및 외부 지식의 혼합에 필요합니다.",
|
||||
"multimodal": "멀티모달",
|
||||
"newKnowledge.appsUnavailable": "앱 —개",
|
||||
"newKnowledge.backToList": "지식 베이스로 돌아가기",
|
||||
"newKnowledge.cardType": "지식 공간",
|
||||
"newKnowledge.connectSource": "소스 연결",
|
||||
"newKnowledge.connectSourceDescription": "콘텐츠가 Notion, 웹사이트 및 기타 서비스와 계속 동기화됩니다.",
|
||||
"newKnowledge.createFailed": "이 기술 자료를 만들 수 없습니다. 액세스 권한을 확인하고 다시 시도하세요.",
|
||||
"newKnowledge.createTitle": "지식 창조",
|
||||
"newKnowledge.creators": "생성자",
|
||||
"newKnowledge.description": "설명",
|
||||
"newKnowledge.descriptionHelp": "명확한 설명은 앱이 이 지식을 검색할 시기를 결정하는 데 도움이 됩니다.",
|
||||
"newKnowledge.descriptionPlaceholder": "예를 들어 환불 정책, 배송 FAQ, 에스컬레이션 단계 - 고객 지원 질문에 사용",
|
||||
"newKnowledge.detailErrorDescription": "이 기술 자료를 로드할 수 없습니다. 다시 시도해 보세요.",
|
||||
"newKnowledge.detailErrorTitle": "문제가 발생했습니다.",
|
||||
"newKnowledge.discardDraftConfirm": "초안 삭제",
|
||||
"newKnowledge.discardDraftDescription": "이 지식 베이스 초안의 변경 사항이 삭제됩니다.",
|
||||
"newKnowledge.discardDraftTitle": "이 지식 베이스 초안을 삭제할까요?",
|
||||
"newKnowledge.documents": "문서",
|
||||
"newKnowledge.documentsUnavailable": "문서 —개",
|
||||
"newKnowledge.emptyDescription": "문서를 추가하면 에이전트가 직접 읽을 수 있습니다. 인덱싱 설정은 필요하지 않습니다.",
|
||||
"newKnowledge.emptyTitle": "지식 베이스를 구축하는 더 스마트한 방법",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "확인",
|
||||
"newKnowledge.guideDescription": "소스를 연결하면 문서가 계속 동기화되고 자동으로 인덱싱되어 바로 사용할 수 있습니다. 관리할 설정은 없습니다. 기존 지식 베이스도 그대로 작동합니다.",
|
||||
"newKnowledge.guideTitle": "새로운 지식 베이스를 만나보세요 ✨",
|
||||
"newKnowledge.illustrationHeadline": "콘텐츠를 한 번 연결하면 모든 앱이 최신 지식으로 답변합니다.",
|
||||
"newKnowledge.learnMore": "자세히 알아보기",
|
||||
"newKnowledge.leavePartialSetupConfirm": "설정 나가기",
|
||||
"newKnowledge.leavePartialSetupDescription": "지식 베이스가 생성되었지만 권한을 확인하지 못했습니다. 나가도 지식 베이스는 유지되며 나중에 액세스 권한을 검토할 수 있습니다.",
|
||||
"newKnowledge.leavePartialSetupTitle": "완료되지 않은 설정을 나갈까요?",
|
||||
"newKnowledge.legacy": "레거시",
|
||||
"newKnowledge.loadMore": "더 보기",
|
||||
"newKnowledge.name": "지식 이름",
|
||||
"newKnowledge.namePlaceholder": "예를 들어 고객 지원 핸드북",
|
||||
"newKnowledge.nameRequired": "기술 자료 이름을 입력하세요.",
|
||||
"newKnowledge.new": "신규",
|
||||
"newKnowledge.noDescription": "설명 없음",
|
||||
"newKnowledge.notFoundDescription": "이 기술 자료가 삭제되었거나 액세스 권한이 없을 수 있습니다.",
|
||||
"newKnowledge.notFoundTitle": "기술 자료를 찾을 수 없습니다.",
|
||||
"newKnowledge.permission": "허가",
|
||||
"newKnowledge.permissionAllMembers": "작업공간 · 모든 구성원이 보고 편집할 수 있습니다.",
|
||||
"newKnowledge.permissionOnlyMe": "나만",
|
||||
"newKnowledge.permissionRestricted": "작업공간 권한으로 인해 이 설정을 변경할 수 없습니다.",
|
||||
"newKnowledge.permissionUpdateFailed": "지식 베이스가 생성되었지만 권한을 확인하지 못했습니다. 다시 시도하여 설정을 완료하세요.",
|
||||
"newKnowledge.readOnlyEmpty": "워크스페이스 편집자에게 첫 번째 지식 베이스 생성을 요청하세요.",
|
||||
"newKnowledge.routeUnavailable": "이 섹션은 다음 설정 단계에서 사용할 수 있습니다.",
|
||||
"newKnowledge.sources": "출처",
|
||||
"newKnowledge.startEmpty": "빈 상태로 시작",
|
||||
"newKnowledge.startEmptyDescription": "지금 만들고 콘텐츠는 나중에 추가하세요.",
|
||||
"newKnowledge.startWith": "다음으로 시작",
|
||||
"newKnowledge.startWithHelp": "언제든지 더 많은 소스나 문서를 추가할 수 있습니다.",
|
||||
"newKnowledge.tags": "태그",
|
||||
"newKnowledge.unavailableDescription": "현재 이 워크스페이스에서 KnowledgeFS를 사용할 수 없습니다.",
|
||||
"newKnowledge.unavailableTitle": "새 지식을 사용할 수 없음",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "The Rerank model is required for mixture of internal and external knowledge.",
|
||||
"multimodal": "Multimodal",
|
||||
"newKnowledge.appsUnavailable": "— apps",
|
||||
"newKnowledge.backToList": "Terug naar kennisbanken",
|
||||
"newKnowledge.cardType": "Kennisruimte",
|
||||
"newKnowledge.connectSource": "Bron verbinden",
|
||||
"newKnowledge.connectSourceDescription": "Inhoud blijft gesynchroniseerd met Notion, websites en andere diensten.",
|
||||
"newKnowledge.createFailed": "We konden deze kennisbank niet creëren. Controleer uw toegang en probeer het opnieuw.",
|
||||
"newKnowledge.createTitle": "Creëer kennis",
|
||||
"newKnowledge.creators": "Makers",
|
||||
"newKnowledge.description": "Beschrijving",
|
||||
"newKnowledge.descriptionHelp": "Een duidelijke beschrijving helpt apps beslissen wanneer ze deze kennis moeten doorzoeken.",
|
||||
"newKnowledge.descriptionPlaceholder": "bijv. Terugbetalingsbeleid, veelgestelde vragen over verzending en escalatiestappen - gebruik deze voor vragen over klantenondersteuning",
|
||||
"newKnowledge.detailErrorDescription": "We kunnen deze kennisbank niet laden. Probeer het opnieuw.",
|
||||
"newKnowledge.detailErrorTitle": "Er is iets misgegaan",
|
||||
"newKnowledge.discardDraftConfirm": "Concept verwijderen",
|
||||
"newKnowledge.discardDraftDescription": "Uw wijzigingen aan dit kennisbankconcept gaan verloren.",
|
||||
"newKnowledge.discardDraftTitle": "Dit kennisbankconcept verwijderen?",
|
||||
"newKnowledge.documents": "Documenten",
|
||||
"newKnowledge.documentsUnavailable": "— documenten",
|
||||
"newKnowledge.emptyDescription": "Voeg je documenten toe zodat agents ze direct kunnen lezen, zonder indexering te configureren.",
|
||||
"newKnowledge.emptyTitle": "Een slimmere manier om kennisbanken te bouwen",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "Begrepen",
|
||||
"newKnowledge.guideDescription": "Verbind een bron en de documenten blijven gesynchroniseerd, automatisch geïndexeerd en direct klaar voor gebruik, zonder beheer. Je bestaande kennisbanken blijven ongewijzigd werken.",
|
||||
"newKnowledge.guideTitle": "Maak kennis met de nieuwe Kennisbank ✨",
|
||||
"newKnowledge.illustrationHeadline": "Koppel je content één keer — elke app antwoordt met de meest actuele kennis.",
|
||||
"newKnowledge.learnMore": "Meer informatie",
|
||||
"newKnowledge.leavePartialSetupConfirm": "Configuratie verlaten",
|
||||
"newKnowledge.leavePartialSetupDescription": "De kennisbank is gemaakt, maar de machtiging kon niet worden bevestigd. Als u vertrekt, blijft de kennisbank behouden; u kunt de toegang later controleren.",
|
||||
"newKnowledge.leavePartialSetupTitle": "Onvoltooide configuratie verlaten?",
|
||||
"newKnowledge.legacy": "Oud",
|
||||
"newKnowledge.loadMore": "Meer laden",
|
||||
"newKnowledge.name": "Kennis naam",
|
||||
"newKnowledge.namePlaceholder": "bijv. Handboek voor klantenondersteuning",
|
||||
"newKnowledge.nameRequired": "Voer een kennisbanknaam in.",
|
||||
"newKnowledge.new": "Nieuw",
|
||||
"newKnowledge.noDescription": "Geen beschrijving",
|
||||
"newKnowledge.notFoundDescription": "Deze kennisbank is mogelijk verwijderd of u heeft mogelijk geen toegang.",
|
||||
"newKnowledge.notFoundTitle": "Kennisbank niet gevonden",
|
||||
"newKnowledge.permission": "Toestemming",
|
||||
"newKnowledge.permissionAllMembers": "Werkruimte · alle leden kunnen bekijken en bewerken",
|
||||
"newKnowledge.permissionOnlyMe": "Alleen ik",
|
||||
"newKnowledge.permissionRestricted": "Uw werkruimtemachtigingen staan het wijzigen van deze instelling niet toe.",
|
||||
"newKnowledge.permissionUpdateFailed": "De kennisbank is gemaakt, maar de machtiging kon niet worden bevestigd. Probeer opnieuw om de configuratie te voltooien.",
|
||||
"newKnowledge.readOnlyEmpty": "Vraag een werkruimte-editor om de eerste kennisbank te maken.",
|
||||
"newKnowledge.routeUnavailable": "Deze sectie komt beschikbaar in de volgende installatiestap.",
|
||||
"newKnowledge.sources": "Bronnen",
|
||||
"newKnowledge.startEmpty": "Leeg beginnen",
|
||||
"newKnowledge.startEmptyDescription": "Maak de kennisbank nu en voeg later inhoud toe.",
|
||||
"newKnowledge.startWith": "Begin met",
|
||||
"newKnowledge.startWithHelp": "U kunt op elk gewenst moment meer bronnen of documenten toevoegen.",
|
||||
"newKnowledge.tags": "Tags",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS is momenteel niet beschikbaar voor deze werkruimte.",
|
||||
"newKnowledge.unavailableTitle": "Nieuwe kennis is niet beschikbaar",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "Model Rerank jest wymagany do połączenia wiedzy wewnętrznej i zewnętrznej.",
|
||||
"multimodal": "Multimodalny",
|
||||
"newKnowledge.appsUnavailable": "— aplikacji",
|
||||
"newKnowledge.backToList": "Powrót do baz wiedzy",
|
||||
"newKnowledge.cardType": "Przestrzeń wiedzy",
|
||||
"newKnowledge.connectSource": "Połącz źródło",
|
||||
"newKnowledge.connectSourceDescription": "Treści pozostają zsynchronizowane z Notion, witrynami internetowymi i innymi usługami.",
|
||||
"newKnowledge.createFailed": "Nie mogliśmy utworzyć tej bazy wiedzy. Sprawdź swój dostęp i spróbuj ponownie.",
|
||||
"newKnowledge.createTitle": "Twórz wiedzę",
|
||||
"newKnowledge.creators": "Twórcy",
|
||||
"newKnowledge.description": "Opis",
|
||||
"newKnowledge.descriptionHelp": "Przejrzysty opis pomaga aplikacjom zdecydować, kiedy przeszukiwać tę wiedzę.",
|
||||
"newKnowledge.descriptionPlaceholder": "np. Polityka zwrotów, często zadawane pytania dotyczące wysyłki i etapy eskalacji — używaj w przypadku pytań do obsługi klienta",
|
||||
"newKnowledge.detailErrorDescription": "Nie mogliśmy załadować tej bazy wiedzy. Spróbuj ponownie.",
|
||||
"newKnowledge.detailErrorTitle": "Coś poszło nie tak",
|
||||
"newKnowledge.discardDraftConfirm": "Odrzuć wersję roboczą",
|
||||
"newKnowledge.discardDraftDescription": "Zmiany w tej wersji roboczej bazy wiedzy zostaną utracone.",
|
||||
"newKnowledge.discardDraftTitle": "Odrzucić tę wersję roboczą bazy wiedzy?",
|
||||
"newKnowledge.documents": "Dokumenty",
|
||||
"newKnowledge.documentsUnavailable": "— dokumentów",
|
||||
"newKnowledge.emptyDescription": "Dodaj dokumenty, aby agenci mogli je czytać bezpośrednio — bez konfigurowania indeksowania.",
|
||||
"newKnowledge.emptyTitle": "Inteligentniejszy sposób tworzenia baz wiedzy",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "Rozumiem",
|
||||
"newKnowledge.guideDescription": "Połącz źródło, a jego dokumenty pozostaną zsynchronizowane, automatycznie zindeksowane i gotowe do użycia, bez konieczności zarządzania konfiguracją. Dotychczasowe bazy wiedzy będą nadal działać bez zmian.",
|
||||
"newKnowledge.guideTitle": "Poznaj nową bazę wiedzy ✨",
|
||||
"newKnowledge.illustrationHeadline": "Połącz treści raz — każda aplikacja odpowie, korzystając z najnowszej wiedzy.",
|
||||
"newKnowledge.learnMore": "Dowiedz się więcej",
|
||||
"newKnowledge.leavePartialSetupConfirm": "Opuść konfigurację",
|
||||
"newKnowledge.leavePartialSetupDescription": "Baza wiedzy została utworzona, ale nie udało się potwierdzić jej uprawnień. Po opuszczeniu baza wiedzy zostanie zachowana; dostęp można sprawdzić później.",
|
||||
"newKnowledge.leavePartialSetupTitle": "Opuścić nieukończoną konfigurację?",
|
||||
"newKnowledge.legacy": "Starsza wersja",
|
||||
"newKnowledge.loadMore": "Załaduj więcej",
|
||||
"newKnowledge.name": "Nazwa wiedzy",
|
||||
"newKnowledge.namePlaceholder": "np. Podręcznik obsługi klienta",
|
||||
"newKnowledge.nameRequired": "Wprowadź nazwę bazy wiedzy.",
|
||||
"newKnowledge.new": "Nowa wersja",
|
||||
"newKnowledge.noDescription": "Brak opisu",
|
||||
"newKnowledge.notFoundDescription": "Ta baza wiedzy mogła zostać usunięta lub możesz nie mieć do niej dostępu.",
|
||||
"newKnowledge.notFoundTitle": "Nie znaleziono bazy wiedzy",
|
||||
"newKnowledge.permission": "Pozwolenie",
|
||||
"newKnowledge.permissionAllMembers": "Obszar roboczy · wszyscy członkowie mogą przeglądać i edytować",
|
||||
"newKnowledge.permissionOnlyMe": "Tylko ja",
|
||||
"newKnowledge.permissionRestricted": "Uprawnienia Twojego obszaru roboczego nie pozwalają na zmianę tego ustawienia.",
|
||||
"newKnowledge.permissionUpdateFailed": "Baza wiedzy została utworzona, ale nie udało się potwierdzić jej uprawnień. Spróbuj ponownie, aby ukończyć konfigurację.",
|
||||
"newKnowledge.readOnlyEmpty": "Poproś edytora obszaru roboczego o utworzenie pierwszej bazy wiedzy.",
|
||||
"newKnowledge.routeUnavailable": "Ta sekcja będzie dostępna w następnym kroku konfiguracji.",
|
||||
"newKnowledge.sources": "Źródła",
|
||||
"newKnowledge.startEmpty": "Zacznij od pustej",
|
||||
"newKnowledge.startEmptyDescription": "Utwórz ją teraz, a zawartość dodaj później.",
|
||||
"newKnowledge.startWith": "Zacznij od",
|
||||
"newKnowledge.startWithHelp": "W dowolnym momencie możesz dodać więcej źródeł lub dokumentów.",
|
||||
"newKnowledge.tags": "Tagi",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS nie jest obecnie dostępny dla tego obszaru roboczego.",
|
||||
"newKnowledge.unavailableTitle": "Nowa wiedza jest niedostępna",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "O modelo de Reclassificação é necessário para a mistura de conhecimento interno e externo.",
|
||||
"multimodal": "Multimodal",
|
||||
"newKnowledge.appsUnavailable": "— apps",
|
||||
"newKnowledge.backToList": "Voltar às bases de conhecimento",
|
||||
"newKnowledge.cardType": "Espaço de conhecimento",
|
||||
"newKnowledge.connectSource": "Conectar uma fonte",
|
||||
"newKnowledge.connectSourceDescription": "O conteúdo permanece sincronizado com Notion, sites e outros serviços.",
|
||||
"newKnowledge.createFailed": "Não foi possível criar esta base de conhecimento. Verifique seu acesso e tente novamente.",
|
||||
"newKnowledge.createTitle": "Crie Conhecimento",
|
||||
"newKnowledge.creators": "Criadores",
|
||||
"newKnowledge.description": "Descrição",
|
||||
"newKnowledge.descriptionHelp": "Uma descrição clara ajuda os aplicativos a decidir quando pesquisar esse conhecimento.",
|
||||
"newKnowledge.descriptionPlaceholder": "por exemplo Política de reembolso, perguntas frequentes sobre envio e etapas de escalonamento — use para perguntas de suporte ao cliente",
|
||||
"newKnowledge.detailErrorDescription": "Não foi possível carregar esta base de conhecimento. Tente novamente.",
|
||||
"newKnowledge.detailErrorTitle": "Algo deu errado",
|
||||
"newKnowledge.discardDraftConfirm": "Descartar rascunho",
|
||||
"newKnowledge.discardDraftDescription": "Suas alterações neste rascunho da base de conhecimento serão perdidas.",
|
||||
"newKnowledge.discardDraftTitle": "Descartar este rascunho da base de conhecimento?",
|
||||
"newKnowledge.documents": "Documentos",
|
||||
"newKnowledge.documentsUnavailable": "— documentos",
|
||||
"newKnowledge.emptyDescription": "Adicione seus documentos para que os agentes possam lê-los diretamente, sem configurar a indexação.",
|
||||
"newKnowledge.emptyTitle": "Uma forma mais inteligente de criar bases de conhecimento",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "Entendi",
|
||||
"newKnowledge.guideDescription": "Conecte uma fonte e os documentos permanecerão sincronizados, indexados automaticamente e prontos para uso, sem nada para gerenciar. Suas bases de conhecimento anteriores continuarão funcionando normalmente.",
|
||||
"newKnowledge.guideTitle": "Conheça a nova Base de Conhecimento ✨",
|
||||
"newKnowledge.illustrationHeadline": "Conecte seu conteúdo uma vez — cada aplicativo responderá com o conhecimento mais recente.",
|
||||
"newKnowledge.learnMore": "Saiba mais",
|
||||
"newKnowledge.leavePartialSetupConfirm": "Sair da configuração",
|
||||
"newKnowledge.leavePartialSetupDescription": "A base de conhecimento foi criada, mas não foi possível confirmar sua permissão. Ao sair, a base de conhecimento será mantida; você poderá revisar o acesso depois.",
|
||||
"newKnowledge.leavePartialSetupTitle": "Sair da configuração incompleta?",
|
||||
"newKnowledge.legacy": "Legado",
|
||||
"newKnowledge.loadMore": "Carregar mais",
|
||||
"newKnowledge.name": "Nome do conhecimento",
|
||||
"newKnowledge.namePlaceholder": "por exemplo Manual de Suporte ao Cliente",
|
||||
"newKnowledge.nameRequired": "Insira um nome para a base de conhecimento.",
|
||||
"newKnowledge.new": "Novo",
|
||||
"newKnowledge.noDescription": "Sem descrição",
|
||||
"newKnowledge.notFoundDescription": "Esta base de conhecimento pode ter sido excluída ou você pode não ter acesso.",
|
||||
"newKnowledge.notFoundTitle": "Base de conhecimento não encontrada",
|
||||
"newKnowledge.permission": "Permissão",
|
||||
"newKnowledge.permissionAllMembers": "Espaço de trabalho · todos os membros podem visualizar e editar",
|
||||
"newKnowledge.permissionOnlyMe": "Só eu",
|
||||
"newKnowledge.permissionRestricted": "As permissões do seu espaço de trabalho não permitem alterar essa configuração.",
|
||||
"newKnowledge.permissionUpdateFailed": "A base de conhecimento foi criada, mas não foi possível confirmar sua permissão. Tente novamente para concluir a configuração.",
|
||||
"newKnowledge.readOnlyEmpty": "Peça a um editor do espaço de trabalho para criar a primeira base de conhecimento.",
|
||||
"newKnowledge.routeUnavailable": "Esta seção estará disponível na próxima etapa de configuração.",
|
||||
"newKnowledge.sources": "Fontes",
|
||||
"newKnowledge.startEmpty": "Começar vazia",
|
||||
"newKnowledge.startEmptyDescription": "Crie agora e adicione conteúdo depois.",
|
||||
"newKnowledge.startWith": "Comece com",
|
||||
"newKnowledge.startWithHelp": "Você pode adicionar mais fontes ou documentos a qualquer momento.",
|
||||
"newKnowledge.tags": "Tags",
|
||||
"newKnowledge.unavailableDescription": "O KnowledgeFS não está disponível para este espaço de trabalho no momento.",
|
||||
"newKnowledge.unavailableTitle": "O Novo Conhecimento não está disponível",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "Modelul Rerank este necesar pentru amestecul de cunoștințe interne și externe.",
|
||||
"multimodal": "Multimodal",
|
||||
"newKnowledge.appsUnavailable": "— aplicații",
|
||||
"newKnowledge.backToList": "Înapoi la bazele de cunoștințe",
|
||||
"newKnowledge.cardType": "Spațiu de cunoștințe",
|
||||
"newKnowledge.connectSource": "Conectează o sursă",
|
||||
"newKnowledge.connectSourceDescription": "Conținutul rămâne sincronizat cu Notion, site-uri web și alte servicii.",
|
||||
"newKnowledge.createFailed": "Nu am putut crea această bază de cunoștințe. Verificați accesul și încercați din nou.",
|
||||
"newKnowledge.createTitle": "Creați cunoștințe",
|
||||
"newKnowledge.creators": "Creatori",
|
||||
"newKnowledge.description": "Descriere",
|
||||
"newKnowledge.descriptionHelp": "O descriere clară ajută aplicațiile să decidă când să caute aceste cunoștințe.",
|
||||
"newKnowledge.descriptionPlaceholder": "de ex. Politica de rambursare, întrebări frecvente despre expediere și pași de escaladare — utilizați pentru întrebările de asistență pentru clienți",
|
||||
"newKnowledge.detailErrorDescription": "Nu am putut încărca această bază de cunoștințe. Încearcă din nou.",
|
||||
"newKnowledge.detailErrorTitle": "Ceva a mers prost",
|
||||
"newKnowledge.discardDraftConfirm": "Renunță la schiță",
|
||||
"newKnowledge.discardDraftDescription": "Modificările aduse acestei schițe a bazei de cunoștințe se vor pierde.",
|
||||
"newKnowledge.discardDraftTitle": "Renunțați la această schiță a bazei de cunoștințe?",
|
||||
"newKnowledge.documents": "Documente",
|
||||
"newKnowledge.documentsUnavailable": "— documente",
|
||||
"newKnowledge.emptyDescription": "Adaugă documentele, iar agenții le pot citi direct — fără indexare de configurat.",
|
||||
"newKnowledge.emptyTitle": "O modalitate mai inteligentă de a crea baze de cunoștințe",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "Am înțeles",
|
||||
"newKnowledge.guideDescription": "Conectează o sursă, iar documentele ei rămân sincronizate, indexate automat și gata de utilizare, fără nimic de administrat. Bazele de cunoștințe existente continuă să funcționeze ca înainte.",
|
||||
"newKnowledge.guideTitle": "Descoperă noua bază de cunoștințe ✨",
|
||||
"newKnowledge.illustrationHeadline": "Conectează conținutul o singură dată — fiecare aplicație răspunde cu cele mai noi informații.",
|
||||
"newKnowledge.learnMore": "Află mai multe",
|
||||
"newKnowledge.leavePartialSetupConfirm": "Ieși din configurare",
|
||||
"newKnowledge.leavePartialSetupDescription": "Baza de cunoștințe a fost creată, dar permisiunea nu a putut fi confirmată. Dacă ieșiți, baza de cunoștințe va fi păstrată; puteți verifica accesul mai târziu.",
|
||||
"newKnowledge.leavePartialSetupTitle": "Ieșiți din configurarea incompletă?",
|
||||
"newKnowledge.legacy": "Versiune veche",
|
||||
"newKnowledge.loadMore": "Încarcă mai multe",
|
||||
"newKnowledge.name": "Nume de cunoștințe",
|
||||
"newKnowledge.namePlaceholder": "de ex. Manual de asistență pentru clienți",
|
||||
"newKnowledge.nameRequired": "Introduceți un nume de bază de cunoștințe.",
|
||||
"newKnowledge.new": "Versiune nouă",
|
||||
"newKnowledge.noDescription": "Fără descriere",
|
||||
"newKnowledge.notFoundDescription": "Este posibil ca această bază de cunoștințe să fi fost ștearsă sau este posibil să nu aveți acces.",
|
||||
"newKnowledge.notFoundTitle": "Baza de cunoștințe nu a fost găsită",
|
||||
"newKnowledge.permission": "Permisiune",
|
||||
"newKnowledge.permissionAllMembers": "Spațiu de lucru · toți membrii pot vizualiza și edita",
|
||||
"newKnowledge.permissionOnlyMe": "Doar eu",
|
||||
"newKnowledge.permissionRestricted": "Permisiunile pentru spațiul de lucru nu permit modificarea acestei setări.",
|
||||
"newKnowledge.permissionUpdateFailed": "Baza de cunoștințe a fost creată, dar permisiunea nu a putut fi confirmată. Încercați din nou pentru a finaliza configurarea.",
|
||||
"newKnowledge.readOnlyEmpty": "Cere unui editor al spațiului de lucru să creeze prima bază de cunoștințe.",
|
||||
"newKnowledge.routeUnavailable": "Această secțiune va deveni disponibilă în următorul pas de configurare.",
|
||||
"newKnowledge.sources": "Surse",
|
||||
"newKnowledge.startEmpty": "Începe de la zero",
|
||||
"newKnowledge.startEmptyDescription": "Creeaz-o acum și adaugă conținut mai târziu.",
|
||||
"newKnowledge.startWith": "Începe cu",
|
||||
"newKnowledge.startWithHelp": "Puteți adăuga oricând mai multe surse sau documente.",
|
||||
"newKnowledge.tags": "Etichete",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS nu este disponibil momentan pentru acest spațiu de lucru.",
|
||||
"newKnowledge.unavailableTitle": "Noua bază de cunoștințe nu este disponibilă",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "Модель Rerank необходима для смешивания внутренних и внешних знаний.",
|
||||
"multimodal": "Мультимодальный",
|
||||
"newKnowledge.appsUnavailable": "— приложений",
|
||||
"newKnowledge.backToList": "Вернуться к базам знаний",
|
||||
"newKnowledge.cardType": "Пространство знаний",
|
||||
"newKnowledge.connectSource": "Подключить источник",
|
||||
"newKnowledge.connectSourceDescription": "Контент остаётся синхронизированным с Notion, веб-сайтами и другими сервисами.",
|
||||
"newKnowledge.createFailed": "Мы не смогли создать эту базу знаний. Проверьте доступ и повторите попытку.",
|
||||
"newKnowledge.createTitle": "Создавайте знания",
|
||||
"newKnowledge.creators": "Создатели",
|
||||
"newKnowledge.description": "Описание",
|
||||
"newKnowledge.descriptionHelp": "Четкое описание помогает приложениям решить, когда следует искать эти знания.",
|
||||
"newKnowledge.descriptionPlaceholder": "например Политика возврата, часто задаваемые вопросы о доставке и шаги эскалации — используйте для вопросов поддержки клиентов.",
|
||||
"newKnowledge.detailErrorDescription": "Нам не удалось загрузить эту базу знаний. Попробуйте еще раз.",
|
||||
"newKnowledge.detailErrorTitle": "Что-то пошло не так",
|
||||
"newKnowledge.discardDraftConfirm": "Удалить черновик",
|
||||
"newKnowledge.discardDraftDescription": "Изменения в этом черновике базы знаний будут потеряны.",
|
||||
"newKnowledge.discardDraftTitle": "Удалить этот черновик базы знаний?",
|
||||
"newKnowledge.documents": "Документы",
|
||||
"newKnowledge.documentsUnavailable": "— документов",
|
||||
"newKnowledge.emptyDescription": "Добавьте документы, и агенты смогут читать их напрямую — без настройки индексации.",
|
||||
"newKnowledge.emptyTitle": "Более умный способ создавать базы знаний",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "Понятно",
|
||||
"newKnowledge.guideDescription": "Подключите источник, и его документы будут синхронизироваться, автоматически индексироваться и сразу станут готовы к работе — без дополнительной настройки. Ваши прежние базы знаний продолжат работать как раньше.",
|
||||
"newKnowledge.guideTitle": "Познакомьтесь с новой базой знаний ✨",
|
||||
"newKnowledge.illustrationHeadline": "Подключите контент один раз — и каждое приложение будет отвечать на основе самых свежих знаний.",
|
||||
"newKnowledge.learnMore": "Подробнее",
|
||||
"newKnowledge.leavePartialSetupConfirm": "Выйти из настройки",
|
||||
"newKnowledge.leavePartialSetupDescription": "База знаний создана, но подтвердить её разрешения не удалось. При выходе база знаний сохранится; доступ можно проверить позже.",
|
||||
"newKnowledge.leavePartialSetupTitle": "Выйти из незавершённой настройки?",
|
||||
"newKnowledge.legacy": "Предыдущая версия",
|
||||
"newKnowledge.loadMore": "Загрузить ещё",
|
||||
"newKnowledge.name": "Название знания",
|
||||
"newKnowledge.namePlaceholder": "например Руководство по поддержке клиентов",
|
||||
"newKnowledge.nameRequired": "Введите название базы знаний.",
|
||||
"newKnowledge.new": "Новая версия",
|
||||
"newKnowledge.noDescription": "Нет описания",
|
||||
"newKnowledge.notFoundDescription": "Возможно, эта база знаний была удалена, или у вас нет доступа.",
|
||||
"newKnowledge.notFoundTitle": "База знаний не найдена",
|
||||
"newKnowledge.permission": "Разрешение",
|
||||
"newKnowledge.permissionAllMembers": "Рабочая область · все участники могут просматривать и редактировать",
|
||||
"newKnowledge.permissionOnlyMe": "Только я",
|
||||
"newKnowledge.permissionRestricted": "Разрешения вашей рабочей области не позволяют изменить этот параметр.",
|
||||
"newKnowledge.permissionUpdateFailed": "База знаний создана, но подтвердить её разрешения не удалось. Повторите попытку, чтобы завершить настройку.",
|
||||
"newKnowledge.readOnlyEmpty": "Попросите редактора рабочего пространства создать первую базу знаний.",
|
||||
"newKnowledge.routeUnavailable": "Этот раздел станет доступен на следующем этапе настройки.",
|
||||
"newKnowledge.sources": "Источники",
|
||||
"newKnowledge.startEmpty": "Начать с пустой",
|
||||
"newKnowledge.startEmptyDescription": "Создайте сейчас, а контент добавьте позже.",
|
||||
"newKnowledge.startWith": "Начните с",
|
||||
"newKnowledge.startWithHelp": "Вы можете добавить больше источников или документов в любое время.",
|
||||
"newKnowledge.tags": "Теги",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS сейчас недоступен для этого рабочего пространства.",
|
||||
"newKnowledge.unavailableTitle": "Новая база знаний недоступна",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "Model za ponovno razvrščanje je potreben za mešanico notranjega in zunanjega znanja.",
|
||||
"multimodal": "Multimodalen",
|
||||
"newKnowledge.appsUnavailable": "— aplikacij",
|
||||
"newKnowledge.backToList": "Nazaj k bazam znanja",
|
||||
"newKnowledge.cardType": "Prostor znanja",
|
||||
"newKnowledge.connectSource": "Poveži vir",
|
||||
"newKnowledge.connectSourceDescription": "Vsebina ostaja sinhronizirana z Notionom, spletnimi mesti in drugimi storitvami.",
|
||||
"newKnowledge.createFailed": "Te baze znanja nismo mogli ustvariti. Preverite dostop in poskusite znova.",
|
||||
"newKnowledge.createTitle": "Ustvarite znanje",
|
||||
"newKnowledge.creators": "Ustvarjalci",
|
||||
"newKnowledge.description": "Opis",
|
||||
"newKnowledge.descriptionHelp": "Jasen opis pomaga aplikacijam, da se odločijo, kdaj naj iščejo to znanje.",
|
||||
"newKnowledge.descriptionPlaceholder": "npr. Politika vračil, pogosta vprašanja o pošiljanju in koraki za eskalacijo — uporabite za vprašanja podpore strankam",
|
||||
"newKnowledge.detailErrorDescription": "Te baze znanja nismo mogli naložiti. Poskusite znova.",
|
||||
"newKnowledge.detailErrorTitle": "Nekaj je šlo narobe",
|
||||
"newKnowledge.discardDraftConfirm": "Zavrzi osnutek",
|
||||
"newKnowledge.discardDraftDescription": "Spremembe tega osnutka zbirke znanja bodo izgubljene.",
|
||||
"newKnowledge.discardDraftTitle": "Želite zavreči ta osnutek zbirke znanja?",
|
||||
"newKnowledge.documents": "Dokumenti",
|
||||
"newKnowledge.documentsUnavailable": "— dokumentov",
|
||||
"newKnowledge.emptyDescription": "Dodajte dokumente in agenti jih lahko berejo neposredno — brez nastavljanja indeksiranja.",
|
||||
"newKnowledge.emptyTitle": "Pametnejši način ustvarjanja baz znanja",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "Razumem",
|
||||
"newKnowledge.guideDescription": "Povežite vir in njegovi dokumenti bodo ostali sinhronizirani, samodejno indeksirani in pripravljeni za uporabo brez dodatnega upravljanja. Vaše obstoječe baze znanja bodo še naprej delovale kot doslej.",
|
||||
"newKnowledge.guideTitle": "Spoznajte novo bazo znanja ✨",
|
||||
"newKnowledge.illustrationHeadline": "Vsebino povežite enkrat — vsaka aplikacija bo odgovarjala z najnovejšim znanjem.",
|
||||
"newKnowledge.learnMore": "Več informacij",
|
||||
"newKnowledge.leavePartialSetupConfirm": "Zapusti nastavitev",
|
||||
"newKnowledge.leavePartialSetupDescription": "Zbirka znanja je bila ustvarjena, vendar njenega dovoljenja ni bilo mogoče potrditi. Če zapustite nastavitev, bo zbirka znanja ohranjena; dostop lahko preverite pozneje.",
|
||||
"newKnowledge.leavePartialSetupTitle": "Želite zapustiti nedokončano nastavitev?",
|
||||
"newKnowledge.legacy": "Staro",
|
||||
"newKnowledge.loadMore": "Naloži več",
|
||||
"newKnowledge.name": "Ime znanja",
|
||||
"newKnowledge.namePlaceholder": "npr. Priročnik za podporo strankam",
|
||||
"newKnowledge.nameRequired": "Vnesite ime baze znanja.",
|
||||
"newKnowledge.new": "Novo",
|
||||
"newKnowledge.noDescription": "Brez opisa",
|
||||
"newKnowledge.notFoundDescription": "Ta baza znanja je bila morda izbrisana ali pa nimate dostopa.",
|
||||
"newKnowledge.notFoundTitle": "Baza znanja ni bila najdena",
|
||||
"newKnowledge.permission": "Dovoljenje",
|
||||
"newKnowledge.permissionAllMembers": "Delovni prostor · vsi člani si lahko ogledujejo in urejajo",
|
||||
"newKnowledge.permissionOnlyMe": "Samo jaz",
|
||||
"newKnowledge.permissionRestricted": "Vaša dovoljenja za delovni prostor ne dovoljujejo spreminjanja te nastavitve.",
|
||||
"newKnowledge.permissionUpdateFailed": "Zbirka znanja je bila ustvarjena, vendar njenega dovoljenja ni bilo mogoče potrditi. Poskusite znova, da dokončate nastavitev.",
|
||||
"newKnowledge.readOnlyEmpty": "Prosite urednika delovnega prostora, naj ustvari prvo bazo znanja.",
|
||||
"newKnowledge.routeUnavailable": "Ta razdelek bo na voljo v naslednjem koraku nastavitve.",
|
||||
"newKnowledge.sources": "Viri",
|
||||
"newKnowledge.startEmpty": "Začni prazno",
|
||||
"newKnowledge.startEmptyDescription": "Ustvarite jo zdaj in vsebino dodajte pozneje.",
|
||||
"newKnowledge.startWith": "Začni z",
|
||||
"newKnowledge.startWithHelp": "Kadar koli lahko dodate več virov ali dokumentov.",
|
||||
"newKnowledge.tags": "Oznake",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS trenutno ni na voljo za ta delovni prostor.",
|
||||
"newKnowledge.unavailableTitle": "Novo znanje ni na voljo",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "โมเดล Rerank จําเป็นสําหรับการผสมผสานระหว่างความรู้ภายในและภายนอก",
|
||||
"multimodal": "หลายรูปแบบ",
|
||||
"newKnowledge.appsUnavailable": "— แอป",
|
||||
"newKnowledge.backToList": "กลับสู่ฐานความรู้",
|
||||
"newKnowledge.cardType": "พื้นที่ความรู้",
|
||||
"newKnowledge.connectSource": "เชื่อมต่อแหล่งข้อมูล",
|
||||
"newKnowledge.connectSourceDescription": "เนื้อหาจะซิงค์กับ Notion เว็บไซต์ และบริการอื่น ๆ อยู่เสมอ",
|
||||
"newKnowledge.createFailed": "เราไม่สามารถสร้างฐานความรู้นี้ได้ ตรวจสอบการเข้าถึงของคุณแล้วลองอีกครั้ง",
|
||||
"newKnowledge.createTitle": "สร้างองค์ความรู้",
|
||||
"newKnowledge.creators": "ผู้สร้าง",
|
||||
"newKnowledge.description": "คำอธิบาย",
|
||||
"newKnowledge.descriptionHelp": "คำอธิบายที่ชัดเจนช่วยให้แอปตัดสินใจว่าจะค้นหาความรู้นี้เมื่อใด",
|
||||
"newKnowledge.descriptionPlaceholder": "เช่น นโยบายการคืนเงิน คำถามที่พบบ่อยเกี่ยวกับการจัดส่ง และขั้นตอนการยกระดับ — ใช้สำหรับคำถามด้านการสนับสนุนลูกค้า",
|
||||
"newKnowledge.detailErrorDescription": "เราไม่สามารถโหลดฐานความรู้นี้ได้ ลองอีกครั้ง",
|
||||
"newKnowledge.detailErrorTitle": "มีบางอย่างผิดพลาด",
|
||||
"newKnowledge.discardDraftConfirm": "ละทิ้งฉบับร่าง",
|
||||
"newKnowledge.discardDraftDescription": "การเปลี่ยนแปลงในฉบับร่างฐานความรู้นี้จะสูญหาย",
|
||||
"newKnowledge.discardDraftTitle": "ละทิ้งฉบับร่างฐานความรู้นี้หรือไม่?",
|
||||
"newKnowledge.documents": "เอกสาร",
|
||||
"newKnowledge.documentsUnavailable": "— เอกสาร",
|
||||
"newKnowledge.emptyDescription": "เพิ่มเอกสารของคุณ แล้วเอเจนต์จะอ่านได้โดยตรง โดยไม่ต้องตั้งค่าการทำดัชนี",
|
||||
"newKnowledge.emptyTitle": "วิธีที่ชาญฉลาดกว่าในการสร้างฐานความรู้",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "เข้าใจแล้ว",
|
||||
"newKnowledge.guideDescription": "เชื่อมต่อแหล่งข้อมูล แล้วเอกสารจะซิงค์ ทำดัชนีอัตโนมัติ และพร้อมใช้งานโดยไม่ต้องจัดการการตั้งค่า ฐานความรู้เดิมของคุณยังคงทำงานได้ตามปกติ",
|
||||
"newKnowledge.guideTitle": "พบกับฐานความรู้แบบใหม่ ✨",
|
||||
"newKnowledge.illustrationHeadline": "เชื่อมต่อเนื้อหาครั้งเดียว ทุกแอปจะตอบด้วยความรู้ล่าสุด",
|
||||
"newKnowledge.learnMore": "เรียนรู้เพิ่มเติม",
|
||||
"newKnowledge.leavePartialSetupConfirm": "ออกจากการตั้งค่า",
|
||||
"newKnowledge.leavePartialSetupDescription": "สร้างฐานความรู้แล้ว แต่ไม่สามารถยืนยันสิทธิ์ได้ หากออก ฐานความรู้จะยังคงอยู่ และคุณสามารถตรวจสอบการเข้าถึงภายหลังได้",
|
||||
"newKnowledge.leavePartialSetupTitle": "ออกจากการตั้งค่าที่ยังไม่เสร็จหรือไม่?",
|
||||
"newKnowledge.legacy": "แบบเดิม",
|
||||
"newKnowledge.loadMore": "โหลดเพิ่มเติม",
|
||||
"newKnowledge.name": "ชื่อความรู้",
|
||||
"newKnowledge.namePlaceholder": "เช่น คู่มือการสนับสนุนลูกค้า",
|
||||
"newKnowledge.nameRequired": "ป้อนชื่อฐานความรู้",
|
||||
"newKnowledge.new": "แบบใหม่",
|
||||
"newKnowledge.noDescription": "ไม่มีคำอธิบาย",
|
||||
"newKnowledge.notFoundDescription": "ฐานความรู้นี้อาจถูกลบไปแล้วหรือคุณอาจไม่สามารถเข้าถึงได้",
|
||||
"newKnowledge.notFoundTitle": "ไม่พบฐานความรู้",
|
||||
"newKnowledge.permission": "การอนุญาต",
|
||||
"newKnowledge.permissionAllMembers": "พื้นที่ทำงาน · สมาชิกทุกคนสามารถดูและแก้ไขได้",
|
||||
"newKnowledge.permissionOnlyMe": "มีเพียงฉันเท่านั้น",
|
||||
"newKnowledge.permissionRestricted": "สิทธิ์พื้นที่ทำงานของคุณไม่อนุญาตให้เปลี่ยนการตั้งค่านี้",
|
||||
"newKnowledge.permissionUpdateFailed": "สร้างฐานความรู้แล้ว แต่ไม่สามารถยืนยันสิทธิ์ได้ โปรดลองอีกครั้งเพื่อดำเนินการตั้งค่าให้เสร็จ",
|
||||
"newKnowledge.readOnlyEmpty": "ขอให้ผู้แก้ไขพื้นที่ทำงานสร้างฐานความรู้แรก",
|
||||
"newKnowledge.routeUnavailable": "ส่วนนี้จะพร้อมใช้งานในขั้นตอนการตั้งค่าถัดไป",
|
||||
"newKnowledge.sources": "แหล่งที่มา",
|
||||
"newKnowledge.startEmpty": "เริ่มจากว่างเปล่า",
|
||||
"newKnowledge.startEmptyDescription": "สร้างตอนนี้แล้วเพิ่มเนื้อหาภายหลัง",
|
||||
"newKnowledge.startWith": "เริ่มต้นด้วย",
|
||||
"newKnowledge.startWithHelp": "คุณสามารถเพิ่มแหล่งที่มาหรือเอกสารเพิ่มเติมได้ตลอดเวลา",
|
||||
"newKnowledge.tags": "แท็ก",
|
||||
"newKnowledge.unavailableDescription": "ขณะนี้ KnowledgeFS ไม่พร้อมใช้งานสำหรับพื้นที่ทำงานนี้",
|
||||
"newKnowledge.unavailableTitle": "ความรู้แบบใหม่ไม่พร้อมใช้งาน",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "Yeniden Sıralama modeli, iç ve dış bilgilerin karışımı için gereklidir.",
|
||||
"multimodal": "Çok Modlu",
|
||||
"newKnowledge.appsUnavailable": "— uygulama",
|
||||
"newKnowledge.backToList": "Bilgi tabanlarına geri dön",
|
||||
"newKnowledge.cardType": "Bilgi alanı",
|
||||
"newKnowledge.connectSource": "Kaynak bağla",
|
||||
"newKnowledge.connectSourceDescription": "İçerik Notion, web siteleri ve diğer hizmetlerle senkronize kalır.",
|
||||
"newKnowledge.createFailed": "Bu bilgi tabanını oluşturamadık. Erişiminizi kontrol edip tekrar deneyin.",
|
||||
"newKnowledge.createTitle": "Bilgi Yaratın",
|
||||
"newKnowledge.creators": "Oluşturanlar",
|
||||
"newKnowledge.description": "Açıklama",
|
||||
"newKnowledge.descriptionHelp": "Açık bir açıklama, uygulamaların bu Bilgiyi ne zaman arayacaklarına karar vermesine yardımcı olur.",
|
||||
"newKnowledge.descriptionPlaceholder": "örneğin Geri ödeme politikası, gönderimle ilgili SSS'ler ve üst kademeye yükseltme adımları — müşteri desteği soruları için kullanın",
|
||||
"newKnowledge.detailErrorDescription": "Bu bilgi tabanını yükleyemedik. Tekrar deneyin.",
|
||||
"newKnowledge.detailErrorTitle": "Bir şeyler ters gitti",
|
||||
"newKnowledge.discardDraftConfirm": "Taslağı sil",
|
||||
"newKnowledge.discardDraftDescription": "Bu bilgi tabanı taslağındaki değişiklikleriniz kaybolacak.",
|
||||
"newKnowledge.discardDraftTitle": "Bu bilgi tabanı taslağı silinsin mi?",
|
||||
"newKnowledge.documents": "Belgeler",
|
||||
"newKnowledge.documentsUnavailable": "— belge",
|
||||
"newKnowledge.emptyDescription": "Belgelerinizi ekleyin; ajanlar indeksleme yapılandırmadan belgeleri doğrudan okuyabilsin.",
|
||||
"newKnowledge.emptyTitle": "Bilgi tabanları oluşturmanın daha akıllı yolu",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "Anladım",
|
||||
"newKnowledge.guideDescription": "Bir kaynak bağladığınızda belgeleri senkronize kalır, otomatik olarak indekslenir ve yönetilecek bir kurulum olmadan kullanıma hazır olur. Mevcut bilgi tabanlarınız olduğu gibi çalışmaya devam eder.",
|
||||
"newKnowledge.guideTitle": "Yeni Bilgi Tabanı ile tanışın ✨",
|
||||
"newKnowledge.illustrationHeadline": "İçeriğinizi bir kez bağlayın — her uygulama en güncel bilgilerle yanıt versin.",
|
||||
"newKnowledge.learnMore": "Daha fazla bilgi",
|
||||
"newKnowledge.leavePartialSetupConfirm": "Kurulumdan çık",
|
||||
"newKnowledge.leavePartialSetupDescription": "Bilgi tabanı oluşturuldu ancak izni doğrulanamadı. Çıkarsanız bilgi tabanı korunur; erişimi daha sonra gözden geçirebilirsiniz.",
|
||||
"newKnowledge.leavePartialSetupTitle": "Tamamlanmamış kurulumdan çıkılsın mı?",
|
||||
"newKnowledge.legacy": "Eski",
|
||||
"newKnowledge.loadMore": "Daha fazla yükle",
|
||||
"newKnowledge.name": "Bilgi adı",
|
||||
"newKnowledge.namePlaceholder": "örneğin Müşteri Desteği El Kitabı",
|
||||
"newKnowledge.nameRequired": "Bir bilgi tabanı adı girin.",
|
||||
"newKnowledge.new": "Yeni",
|
||||
"newKnowledge.noDescription": "Açıklama yok",
|
||||
"newKnowledge.notFoundDescription": "Bu bilgi tabanı silinmiş olabilir veya erişiminiz olmayabilir.",
|
||||
"newKnowledge.notFoundTitle": "Bilgi tabanı bulunamadı",
|
||||
"newKnowledge.permission": "İzin",
|
||||
"newKnowledge.permissionAllMembers": "Çalışma alanı · tüm üyeler görüntüleyebilir ve düzenleyebilir",
|
||||
"newKnowledge.permissionOnlyMe": "Sadece ben",
|
||||
"newKnowledge.permissionRestricted": "Çalışma alanı izinleriniz bu ayarın değiştirilmesine izin vermiyor.",
|
||||
"newKnowledge.permissionUpdateFailed": "Bilgi tabanı oluşturuldu ancak izni doğrulanamadı. Kurulumu tamamlamak için yeniden deneyin.",
|
||||
"newKnowledge.readOnlyEmpty": "İlk bilgi tabanını oluşturması için bir çalışma alanı editörüne başvurun.",
|
||||
"newKnowledge.routeUnavailable": "Bu bölüm bir sonraki kurulum adımında kullanılabilir hale gelecektir.",
|
||||
"newKnowledge.sources": "Kaynaklar",
|
||||
"newKnowledge.startEmpty": "Boş başla",
|
||||
"newKnowledge.startEmptyDescription": "Şimdi oluşturun, içeriği daha sonra ekleyin.",
|
||||
"newKnowledge.startWith": "Şununla başla:",
|
||||
"newKnowledge.startWithHelp": "İstediğiniz zaman daha fazla Kaynak veya Belge ekleyebilirsiniz.",
|
||||
"newKnowledge.tags": "Etiketler",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS şu anda bu çalışma alanında kullanılamıyor.",
|
||||
"newKnowledge.unavailableTitle": "Yeni Bilgi kullanılamıyor",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "Модель Rerank необхідна для поєднання внутрішніх і зовнішніх знань.",
|
||||
"multimodal": "Мультимодальний",
|
||||
"newKnowledge.appsUnavailable": "— застосунків",
|
||||
"newKnowledge.backToList": "Назад до баз знань",
|
||||
"newKnowledge.cardType": "Простір знань",
|
||||
"newKnowledge.connectSource": "Підключити джерело",
|
||||
"newKnowledge.connectSourceDescription": "Вміст залишається синхронізованим із Notion, вебсайтами та іншими сервісами.",
|
||||
"newKnowledge.createFailed": "Ми не змогли створити цю базу знань. Перевірте свій доступ і повторіть спробу.",
|
||||
"newKnowledge.createTitle": "Створюйте знання",
|
||||
"newKnowledge.creators": "Автори",
|
||||
"newKnowledge.description": "опис",
|
||||
"newKnowledge.descriptionHelp": "Чіткий опис допомагає програмам вирішити, коли шукати ці знання.",
|
||||
"newKnowledge.descriptionPlaceholder": "напр. Політика відшкодування, поширені запитання щодо доставки та етапи ескалації — використовуйте для запитань служби підтримки клієнтів",
|
||||
"newKnowledge.detailErrorDescription": "Нам не вдалося завантажити цю базу знань. Спробуйте знову.",
|
||||
"newKnowledge.detailErrorTitle": "Щось пішло не так",
|
||||
"newKnowledge.discardDraftConfirm": "Відхилити чернетку",
|
||||
"newKnowledge.discardDraftDescription": "Зміни в цій чернетці бази знань буде втрачено.",
|
||||
"newKnowledge.discardDraftTitle": "Відхилити цю чернетку бази знань?",
|
||||
"newKnowledge.documents": "Документи",
|
||||
"newKnowledge.documentsUnavailable": "— документів",
|
||||
"newKnowledge.emptyDescription": "Додайте документи, і агенти зможуть читати їх безпосередньо — без налаштування індексації.",
|
||||
"newKnowledge.emptyTitle": "Розумніший спосіб створювати бази знань",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "Зрозуміло",
|
||||
"newKnowledge.guideDescription": "Підключіть джерело, і його документи залишатимуться синхронізованими, автоматично індексуватимуться та будуть готові до використання без додаткового налаштування. Ваші попередні бази знань працюватимуть як і раніше.",
|
||||
"newKnowledge.guideTitle": "Познайомтеся з новою базою знань ✨",
|
||||
"newKnowledge.illustrationHeadline": "Підключіть вміст один раз — і кожна програма відповідатиме на основі найсвіжіших знань.",
|
||||
"newKnowledge.learnMore": "Докладніше",
|
||||
"newKnowledge.leavePartialSetupConfirm": "Вийти з налаштування",
|
||||
"newKnowledge.leavePartialSetupDescription": "Базу знань створено, але не вдалося підтвердити її дозвіл. Якщо вийти, базу знань буде збережено; доступ можна перевірити пізніше.",
|
||||
"newKnowledge.leavePartialSetupTitle": "Вийти з незавершеного налаштування?",
|
||||
"newKnowledge.legacy": "Попередня версія",
|
||||
"newKnowledge.loadMore": "Завантажити ще",
|
||||
"newKnowledge.name": "Назва знання",
|
||||
"newKnowledge.namePlaceholder": "напр. Посібник із підтримки клієнтів",
|
||||
"newKnowledge.nameRequired": "Введіть назву бази знань.",
|
||||
"newKnowledge.new": "Нова версія",
|
||||
"newKnowledge.noDescription": "Немає опису",
|
||||
"newKnowledge.notFoundDescription": "Можливо, цю базу знань було видалено або ви не маєте доступу.",
|
||||
"newKnowledge.notFoundTitle": "База знань не знайдена",
|
||||
"newKnowledge.permission": "Дозвіл",
|
||||
"newKnowledge.permissionAllMembers": "Робоча область · усі учасники можуть переглядати та редагувати",
|
||||
"newKnowledge.permissionOnlyMe": "Тільки я",
|
||||
"newKnowledge.permissionRestricted": "Дозволи вашої робочої області не дозволяють змінити це налаштування.",
|
||||
"newKnowledge.permissionUpdateFailed": "Базу знань створено, але не вдалося підтвердити її дозвіл. Повторіть спробу, щоб завершити налаштування.",
|
||||
"newKnowledge.readOnlyEmpty": "Попросіть редактора робочого простору створити першу базу знань.",
|
||||
"newKnowledge.routeUnavailable": "Цей розділ стане доступним на наступному кроці налаштування.",
|
||||
"newKnowledge.sources": "Джерела",
|
||||
"newKnowledge.startEmpty": "Почати з порожньої",
|
||||
"newKnowledge.startEmptyDescription": "Створіть зараз, а вміст додайте пізніше.",
|
||||
"newKnowledge.startWith": "Почніть з",
|
||||
"newKnowledge.startWithHelp": "Ви можете будь-коли додати більше джерел або документів.",
|
||||
"newKnowledge.tags": "Теги",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS зараз недоступний для цього робочого простору.",
|
||||
"newKnowledge.unavailableTitle": "Нова база знань недоступна",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "Mô hình Rerank là cần thiết cho sự kết hợp giữa kiến thức bên trong và bên ngoài.",
|
||||
"multimodal": "Đa phương thức",
|
||||
"newKnowledge.appsUnavailable": "— ứng dụng",
|
||||
"newKnowledge.backToList": "Quay lại cơ sở kiến thức",
|
||||
"newKnowledge.cardType": "Không gian kiến thức",
|
||||
"newKnowledge.connectSource": "Kết nối nguồn",
|
||||
"newKnowledge.connectSourceDescription": "Nội dung luôn được đồng bộ với Notion, trang web và các dịch vụ khác.",
|
||||
"newKnowledge.createFailed": "Chúng tôi không thể tạo ra nền tảng kiến thức này. Hãy kiểm tra quyền truy cập của bạn và thử lại.",
|
||||
"newKnowledge.createTitle": "Tạo kiến thức",
|
||||
"newKnowledge.creators": "Người tạo",
|
||||
"newKnowledge.description": "Mô tả",
|
||||
"newKnowledge.descriptionHelp": "Mô tả rõ ràng giúp ứng dụng quyết định thời điểm tìm kiếm Kiến thức này.",
|
||||
"newKnowledge.descriptionPlaceholder": "ví dụ. Chính sách hoàn tiền, Câu hỏi thường gặp về vận chuyển và các bước báo cáo — sử dụng cho các câu hỏi hỗ trợ khách hàng",
|
||||
"newKnowledge.detailErrorDescription": "Chúng tôi không thể tải cơ sở kiến thức này. Hãy thử lại.",
|
||||
"newKnowledge.detailErrorTitle": "Đã xảy ra lỗi",
|
||||
"newKnowledge.discardDraftConfirm": "Hủy bản nháp",
|
||||
"newKnowledge.discardDraftDescription": "Các thay đổi đối với bản nháp cơ sở kiến thức này sẽ bị mất.",
|
||||
"newKnowledge.discardDraftTitle": "Hủy bản nháp cơ sở kiến thức này?",
|
||||
"newKnowledge.documents": "Tài liệu",
|
||||
"newKnowledge.documentsUnavailable": "— tài liệu",
|
||||
"newKnowledge.emptyDescription": "Thêm tài liệu để tác nhân có thể đọc trực tiếp — không cần cấu hình lập chỉ mục.",
|
||||
"newKnowledge.emptyTitle": "Cách thông minh hơn để xây dựng cơ sở kiến thức",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "Đã hiểu",
|
||||
"newKnowledge.guideDescription": "Kết nối một nguồn và tài liệu của nguồn đó sẽ luôn được đồng bộ, tự động lập chỉ mục và sẵn sàng sử dụng mà không cần quản lý thiết lập. Các cơ sở kiến thức cũ vẫn hoạt động như trước.",
|
||||
"newKnowledge.guideTitle": "Khám phá Cơ sở kiến thức mới ✨",
|
||||
"newKnowledge.illustrationHeadline": "Kết nối nội dung một lần — mọi ứng dụng sẽ trả lời bằng kiến thức mới nhất.",
|
||||
"newKnowledge.learnMore": "Tìm hiểu thêm",
|
||||
"newKnowledge.leavePartialSetupConfirm": "Thoát thiết lập",
|
||||
"newKnowledge.leavePartialSetupDescription": "Cơ sở kiến thức đã được tạo nhưng không thể xác nhận quyền. Nếu thoát, cơ sở kiến thức vẫn được giữ lại; bạn có thể xem lại quyền truy cập sau.",
|
||||
"newKnowledge.leavePartialSetupTitle": "Thoát khỏi thiết lập chưa hoàn tất?",
|
||||
"newKnowledge.legacy": "Cũ",
|
||||
"newKnowledge.loadMore": "Tải thêm",
|
||||
"newKnowledge.name": "Tên kiến thức",
|
||||
"newKnowledge.namePlaceholder": "ví dụ: Sổ tay hỗ trợ khách hàng",
|
||||
"newKnowledge.nameRequired": "Nhập tên cơ sở kiến thức.",
|
||||
"newKnowledge.new": "Mới",
|
||||
"newKnowledge.noDescription": "Không có mô tả",
|
||||
"newKnowledge.notFoundDescription": "Cơ sở kiến thức này có thể đã bị xóa hoặc bạn không có quyền truy cập.",
|
||||
"newKnowledge.notFoundTitle": "Không tìm thấy cơ sở kiến thức",
|
||||
"newKnowledge.permission": "Quyền",
|
||||
"newKnowledge.permissionAllMembers": "Không gian làm việc · tất cả thành viên đều có thể xem và chỉnh sửa",
|
||||
"newKnowledge.permissionOnlyMe": "Chỉ có tôi",
|
||||
"newKnowledge.permissionRestricted": "Quyền không gian làm việc của bạn không cho phép thay đổi cài đặt này.",
|
||||
"newKnowledge.permissionUpdateFailed": "Cơ sở kiến thức đã được tạo nhưng không thể xác nhận quyền. Hãy thử lại để hoàn tất thiết lập.",
|
||||
"newKnowledge.readOnlyEmpty": "Yêu cầu biên tập viên không gian làm việc tạo cơ sở kiến thức đầu tiên.",
|
||||
"newKnowledge.routeUnavailable": "Phần này sẽ có sẵn trong bước thiết lập tiếp theo.",
|
||||
"newKnowledge.sources": "Nguồn",
|
||||
"newKnowledge.startEmpty": "Bắt đầu trống",
|
||||
"newKnowledge.startEmptyDescription": "Tạo ngay bây giờ và thêm nội dung sau.",
|
||||
"newKnowledge.startWith": "Bắt đầu với",
|
||||
"newKnowledge.startWithHelp": "Bạn có thể thêm nhiều Nguồn hoặc Tài liệu hơn bất cứ lúc nào.",
|
||||
"newKnowledge.tags": "Thẻ",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS hiện không khả dụng cho không gian làm việc này.",
|
||||
"newKnowledge.unavailableTitle": "Kiến thức mới không khả dụng",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "混合使用内部和外部知识时需要配置 Rerank 模型。",
|
||||
"multimodal": "多模态",
|
||||
"newKnowledge.appsUnavailable": "— 个应用",
|
||||
"newKnowledge.backToList": "返回知识库列表",
|
||||
"newKnowledge.cardType": "知识空间",
|
||||
"newKnowledge.connectSource": "连接数据源",
|
||||
"newKnowledge.connectSourceDescription": "内容会与 Notion、网站及其他服务保持同步。",
|
||||
"newKnowledge.createFailed": "无法创建此知识库,请检查权限后重试。",
|
||||
"newKnowledge.createTitle": "创建知识库",
|
||||
"newKnowledge.creators": "创建者",
|
||||
"newKnowledge.description": "描述",
|
||||
"newKnowledge.descriptionHelp": "清晰的描述有助于应用判断何时搜索此知识库。",
|
||||
"newKnowledge.descriptionPlaceholder": "例如:退款政策、配送常见问题和升级处理步骤——用于客户支持问题",
|
||||
"newKnowledge.detailErrorDescription": "无法加载此知识库,请重试。",
|
||||
"newKnowledge.detailErrorTitle": "出现错误",
|
||||
"newKnowledge.discardDraftConfirm": "放弃草稿",
|
||||
"newKnowledge.discardDraftDescription": "对此知识库草稿的更改将会丢失。",
|
||||
"newKnowledge.discardDraftTitle": "放弃此知识库草稿?",
|
||||
"newKnowledge.documents": "文档",
|
||||
"newKnowledge.documentsUnavailable": "— 篇文档",
|
||||
"newKnowledge.emptyDescription": "添加文档后,智能体即可直接读取,无需配置索引。",
|
||||
"newKnowledge.emptyTitle": "更智能的知识库构建方式",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "知道了",
|
||||
"newKnowledge.guideDescription": "连接数据源后,其文档会保持同步、自动建立索引并可立即使用,无需管理任何配置。旧版知识库仍会照常运行。",
|
||||
"newKnowledge.guideTitle": "认识全新的知识库 ✨",
|
||||
"newKnowledge.illustrationHeadline": "只需连接一次内容,每个应用都能基于最新知识回答。",
|
||||
"newKnowledge.learnMore": "了解更多",
|
||||
"newKnowledge.leavePartialSetupConfirm": "离开设置",
|
||||
"newKnowledge.leavePartialSetupDescription": "知识库已创建,但无法确认其权限状态。离开后知识库仍会保留,你可以稍后检查访问权限。",
|
||||
"newKnowledge.leavePartialSetupTitle": "离开未完成的设置?",
|
||||
"newKnowledge.legacy": "旧版",
|
||||
"newKnowledge.loadMore": "加载更多",
|
||||
"newKnowledge.name": "知识库名称",
|
||||
"newKnowledge.namePlaceholder": "例如:客户支持手册",
|
||||
"newKnowledge.nameRequired": "请输入知识库名称。",
|
||||
"newKnowledge.new": "新版",
|
||||
"newKnowledge.noDescription": "暂无描述",
|
||||
"newKnowledge.notFoundDescription": "此知识库可能已被删除,或你没有访问权限。",
|
||||
"newKnowledge.notFoundTitle": "未找到知识库",
|
||||
"newKnowledge.permission": "权限",
|
||||
"newKnowledge.permissionAllMembers": "工作区 · 所有成员均可查看和编辑",
|
||||
"newKnowledge.permissionOnlyMe": "仅自己",
|
||||
"newKnowledge.permissionRestricted": "你的工作区权限不允许更改此设置。",
|
||||
"newKnowledge.permissionUpdateFailed": "知识库已创建,但无法确认其权限状态。请重试以完成设置。",
|
||||
"newKnowledge.readOnlyEmpty": "请联系工作区编辑者创建第一个知识库。",
|
||||
"newKnowledge.routeUnavailable": "此部分将在下一设置步骤中开放。",
|
||||
"newKnowledge.sources": "数据源",
|
||||
"newKnowledge.startEmpty": "从空白开始",
|
||||
"newKnowledge.startEmptyDescription": "现在创建,稍后再添加内容。",
|
||||
"newKnowledge.startWith": "创建方式",
|
||||
"newKnowledge.startWithHelp": "你可以随时添加更多数据源或文档。",
|
||||
"newKnowledge.tags": "标签",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS 当前在此工作区不可用。",
|
||||
"newKnowledge.unavailableTitle": "新版知识库不可用",
|
||||
|
||||
@@ -132,10 +132,22 @@
|
||||
"mixtureInternalAndExternalTip": "Rerank 模型是內部和外部知識的混合所必需的。",
|
||||
"multimodal": "多模態",
|
||||
"newKnowledge.appsUnavailable": "— 個應用程式",
|
||||
"newKnowledge.backToList": "返回知識庫列表",
|
||||
"newKnowledge.cardType": "知識空間",
|
||||
"newKnowledge.connectSource": "連接資料來源",
|
||||
"newKnowledge.connectSourceDescription": "內容會與 Notion、網站及其他服務保持同步。",
|
||||
"newKnowledge.createFailed": "無法建立此知識庫,請檢查權限後再試一次。",
|
||||
"newKnowledge.createTitle": "建立知識庫",
|
||||
"newKnowledge.creators": "建立者",
|
||||
"newKnowledge.description": "描述",
|
||||
"newKnowledge.descriptionHelp": "清楚的描述有助於應用程式判斷何時搜尋此知識庫。",
|
||||
"newKnowledge.descriptionPlaceholder": "例如:退款政策、配送常見問題和升級處理步驟——用於客戶支援問題",
|
||||
"newKnowledge.detailErrorDescription": "無法載入此知識庫,請再試一次。",
|
||||
"newKnowledge.detailErrorTitle": "發生錯誤",
|
||||
"newKnowledge.discardDraftConfirm": "放棄草稿",
|
||||
"newKnowledge.discardDraftDescription": "對此知識庫草稿的變更將會遺失。",
|
||||
"newKnowledge.discardDraftTitle": "放棄此知識庫草稿?",
|
||||
"newKnowledge.documents": "文件",
|
||||
"newKnowledge.documentsUnavailable": "— 份文件",
|
||||
"newKnowledge.emptyDescription": "加入文件後,智慧代理即可直接讀取,無需設定索引。",
|
||||
"newKnowledge.emptyTitle": "更聰明的知識庫建立方式",
|
||||
@@ -145,14 +157,32 @@
|
||||
"newKnowledge.gotIt": "知道了",
|
||||
"newKnowledge.guideDescription": "連接資料來源後,其文件會保持同步、自動建立索引並可立即使用,無需管理任何設定。舊版知識庫仍會照常運作。",
|
||||
"newKnowledge.guideTitle": "認識全新的知識庫 ✨",
|
||||
"newKnowledge.illustrationHeadline": "只需連接一次內容,每個應用程式都能以最新知識回答。",
|
||||
"newKnowledge.learnMore": "瞭解更多",
|
||||
"newKnowledge.leavePartialSetupConfirm": "離開設定",
|
||||
"newKnowledge.leavePartialSetupDescription": "知識庫已建立,但無法確認其權限狀態。離開後知識庫仍會保留,你可以稍後檢查存取權限。",
|
||||
"newKnowledge.leavePartialSetupTitle": "離開未完成的設定?",
|
||||
"newKnowledge.legacy": "舊版",
|
||||
"newKnowledge.loadMore": "載入更多",
|
||||
"newKnowledge.name": "知識庫名稱",
|
||||
"newKnowledge.namePlaceholder": "例如:客戶支援手冊",
|
||||
"newKnowledge.nameRequired": "請輸入知識庫名稱。",
|
||||
"newKnowledge.new": "新版",
|
||||
"newKnowledge.noDescription": "暫無描述",
|
||||
"newKnowledge.notFoundDescription": "此知識庫可能已被刪除,或你沒有存取權限。",
|
||||
"newKnowledge.notFoundTitle": "找不到知識庫",
|
||||
"newKnowledge.permission": "權限",
|
||||
"newKnowledge.permissionAllMembers": "工作區 · 所有成員皆可檢視和編輯",
|
||||
"newKnowledge.permissionOnlyMe": "僅自己",
|
||||
"newKnowledge.permissionRestricted": "你的工作區權限不允許變更此設定。",
|
||||
"newKnowledge.permissionUpdateFailed": "知識庫已建立,但無法確認其權限狀態。請重試以完成設定。",
|
||||
"newKnowledge.readOnlyEmpty": "請聯絡工作區編輯者建立第一個知識庫。",
|
||||
"newKnowledge.routeUnavailable": "此區段將在下一個設定步驟中開放。",
|
||||
"newKnowledge.sources": "資料來源",
|
||||
"newKnowledge.startEmpty": "從空白開始",
|
||||
"newKnowledge.startEmptyDescription": "現在建立,稍後再加入內容。",
|
||||
"newKnowledge.startWith": "建立方式",
|
||||
"newKnowledge.startWithHelp": "你可以隨時新增更多資料來源或文件。",
|
||||
"newKnowledge.tags": "標籤",
|
||||
"newKnowledge.unavailableDescription": "KnowledgeFS 目前在此工作區不可用。",
|
||||
"newKnowledge.unavailableTitle": "新版知識庫不可用",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<svg width="82" height="16" viewBox="0 0 82 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.85" clip-path="url(#clip0_2049_48859)">
|
||||
<text fill="white" style="white-space: pre" xml:space="preserve" font-family="Inter" font-size="18" letter-spacing="0em"><tspan x="-1" y="15.74">🔥</tspan></text>
|
||||
<path d="M26.3734 14.299C28.0525 14.299 29.4137 12.9378 29.4137 11.2587C29.4137 9.57966 28.0525 8.21851 26.3734 8.21851C24.6942 8.21851 23.333 9.57966 23.333 11.2587C23.333 12.9378 24.6942 14.299 26.3734 14.299Z" fill="#EB6161"/>
|
||||
<path d="M36.6668 2.3964L36.627 8.21854C36.627 11.537 33.9642 14.2394 30.6457 14.299L30.5859 8.23841L30.5861 2.41628C30.5861 2.01886 30.904 1.70093 31.3014 1.70093H35.9514C36.3488 1.70093 36.6668 1.99899 36.6668 2.3964Z" fill="#009191"/>
|
||||
<g clip-path="url(#clip1_2049_48859)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M46.858 14.6089L45.1383 12.4666C44.7246 11.9511 44.5 11.3173 44.5 10.6649V2.90749C44.5 1.93032 45.2818 1.11948 46.2904 1.05063L54.2661 0.506091C54.8455 0.466531 55.4214 0.620545 55.8974 0.942352L58.6996 2.83696C59.2012 3.17609 59.5 3.73049 59.5 4.32213V13.1416C59.5 14.0979 58.7313 14.8896 57.7438 14.9504L48.8917 15.4953C48.1037 15.5438 47.342 15.2118 46.858 14.6089Z" fill="white"/>
|
||||
<path d="M49.624 6.78935V6.68779C49.624 6.43035 49.8302 6.21683 50.096 6.19909L52.0316 6.06986L54.7085 10.0118V6.55207L54.0195 6.46022V6.41198C54.0195 6.15151 54.2304 5.93659 54.4995 5.9228L56.2608 5.83258V6.08604C56.2608 6.20502 56.1723 6.3068 56.051 6.32731L55.6272 6.39899V12.0018L55.0953 12.1848C54.6509 12.3376 54.1562 12.174 53.9018 11.7901L51.303 7.86859V11.6115L52.1029 11.7646L52.0918 11.8388C52.0569 12.0712 51.8562 12.2469 51.6135 12.2577L49.624 12.3463C49.5977 12.0964 49.7851 11.8728 50.0435 11.8456L50.3051 11.8181V6.82762L49.624 6.78935Z" fill="black"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M54.3374 1.48339L46.3617 2.02793C45.884 2.06054 45.5137 2.44462 45.5137 2.90749V10.6649C45.5137 11.0998 45.6634 11.5224 45.9392 11.866L47.6589 14.0083C47.9369 14.3547 48.3745 14.5454 48.8272 14.5176L57.6793 13.9727C58.133 13.9447 58.4862 13.581 58.4862 13.1416V4.32213C58.4862 4.0503 58.3489 3.79557 58.1185 3.63975L55.3162 1.74515C55.0306 1.55206 54.6851 1.45966 54.3374 1.48339ZM46.7572 3.0285C46.6463 2.94637 46.6991 2.77527 46.8385 2.76528L54.3911 2.22355C54.6317 2.20629 54.8709 2.27256 55.0655 2.41044L56.5808 3.48406C56.6384 3.52482 56.6114 3.61282 56.5402 3.61669L48.5419 4.05168C48.2999 4.06485 48.061 3.99374 47.8685 3.85125L46.7572 3.0285ZM48.1668 5.41536C48.1668 5.15552 48.3767 4.94088 48.6451 4.92626L57.1015 4.46573C57.3631 4.45148 57.5834 4.65267 57.5834 4.90591V12.5426C57.5834 12.802 57.3742 13.0164 57.1063 13.0317L48.7034 13.5098C48.4123 13.5263 48.1668 13.3026 48.1668 13.0207V5.41536Z" fill="black"/>
|
||||
</g>
|
||||
<path d="M69.3333 6.66675C68.6 6.66675 68 7.26675 68 8.00008C68 8.73341 68.6 9.33341 69.3333 9.33341C70.0667 9.33341 70.6667 8.73341 70.6667 8.00008C70.6667 7.26675 70.0667 6.66675 69.3333 6.66675ZM78.6667 6.66675C77.9333 6.66675 77.3333 7.26675 77.3333 8.00008C77.3333 8.73341 77.9333 9.33341 78.6667 9.33341C79.4 9.33341 80 8.73341 80 8.00008C80 7.26675 79.4 6.66675 78.6667 6.66675ZM74 6.66675C73.2667 6.66675 72.6667 7.26675 72.6667 8.00008C72.6667 8.73341 73.2667 9.33341 74 9.33341C74.7333 9.33341 75.3333 8.73341 75.3333 8.00008C75.3333 7.26675 74.7333 6.66675 74 6.66675Z" fill="#101828" fill-opacity="0.3"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2049_48859">
|
||||
<rect width="82" height="16" fill="white"/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_2049_48859">
|
||||
<rect width="16" height="16" fill="white" transform="translate(44)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.6 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 25 KiB |
Reference in New Issue
Block a user