mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-05 19:08:38 +08:00
Feature/generic api connector (#13545)
# feat: Add Generic REST API Connector
## What problem does this PR solve?
RAGFlow supports many specific data source connectors (MySQL, Slack,
Google Drive, etc.), but there was no way to connect an arbitrary REST
API as a data source. Users with custom or third-party APIs had to write
a new connector class for each one.
This PR adds a **generic, configuration-driven REST API connector** that
lets users connect any REST API as a data source entirely through the UI
— no code changes needed per API.
---
## Features
### Core Connector (`common/data_source/rest_api_connector.py`)
- Implements `LoadConnector` and `PollConnector` interfaces for full and
incremental sync
- **Configurable authentication:** None, API Key (custom header), Bearer
Token, Basic Auth
- **Pluggable pagination:** Page-based, Offset-based, Cursor-based, or
None
- Smart page-size inference from user's query parameters to avoid
duplicate/conflicting params
- Configurable request delay between pages to prevent API rate limiting
- Auto-detection of the items array in JSON responses (`items`,
`results`, `data`, `records`, or first list found)
- **Advanced field mapping** with dot-notation (`country.name`), array
wildcards (`newsType[*].name`), type hints, and default values
- Optional content template rendering (`"Title: {title}\nBody: {body}"`)
- HTML stripping for content fields
- Stable document IDs via `hash128` from a configurable ID field or
auto-generated from item content
- Pydantic configuration schema with automatic coercion of UI string
inputs to dicts/lists
### Backend Registration (`rag/svr/sync_data_source.py`,
`common/constants.py`, `common/data_source/config.py`)
- `REST_API` sync class wired into RAGFlow's `func_factory`
- Full sync (`load_from_state`) and incremental polling (`poll_source`)
support
- Credentials and config passed from task to connector following
existing patterns (MySQL, SeaFile, etc.)
### Test Connection Endpoint (`api/apps/connector_app.py`)
- `POST /v1/connector/<id>/test` validates config schema,
authentication, and API connectivity without triggering a sync
- Clear error messages for auth failures vs. config issues
### Frontend UI (`web/src/pages/user-setting/data-source/constant/`)
- **Postman-style configuration:** Base URL, Query Parameters (key=value
per line), Auth, Content Fields, Metadata Fields, Pagination Type
- Auth-type-aware form: fields for API key header/value, Bearer token,
or Basic username/password appear only when relevant
- **Advanced Settings** toggle for: Custom Headers, Max Pages, Request
Delay, Poll Timestamp Field, Request Body (POST)
- Connector icon (SVG) and i18n strings (English)
- **"Test Connection"** button to validate before syncing
---
## Controls & Safety
- Configurable max pages safety cap (default: 1000, adjustable in UI)
- Configurable request delay between pages (default: 0.5s, adjustable in
UI)
- Auth errors (401/403) fail immediately without retries; transient
errors retry with exponential backoff
- Diagnostic logging: auth setup confirmation, request details on
failure, content field extraction status
---
## Type of change
- [x] New Feature (non-breaking change which adds functionality)
##Visual Screenshots of Features
<img width="482" height="510" alt="Screenshot 2026-03-11 at 5 19 52 PM"
src="https://github.com/user-attachments/assets/dcb7ab4a-1622-44f3-bb02-d6f0527314c4"
/>
(Connector can be configured within the external data sources tab)
Configuration Parameters:
<img width="661" height="682" alt="Screenshot 2026-03-11 at 5 20 46 PM"
src="https://github.com/user-attachments/assets/5e154e71-4ab5-4872-bfb2-04f02b73c18a"
/>
<img width="661" height="682" alt="Screenshot 2026-03-11 at 5 20 54 PM"
src="https://github.com/user-attachments/assets/00cb14b7-0bcf-4b94-9d71-34e93369ecb2"
/>
Connection can be tested before attaching to dataset:
<img width="981" height="681" alt="Screenshot 2026-03-11 at 5 21 40 PM"
src="https://github.com/user-attachments/assets/aaa6eeeb-89a7-4349-bc34-2423bf8be9ee"
/>
Ingestion tested with API connector (works perfectly fine):
<img width="1062" height="705" alt="Screenshot 2026-03-11 at 5 22 30 PM"
src="https://github.com/user-attachments/assets/afcd0d58-cadd-4152-badc-d2f14d96fbec"
/>
Search & Retrieval works as well with metadata flow:
<img width="1062" height="705" alt="Screenshot 2026-03-11 at 5 23 05 PM"
src="https://github.com/user-attachments/assets/d41ee935-dcf7-4456-b317-22a76ca032c0"
/>
---------
Co-authored-by: Ahmad Intisar <ahmadintisar@Ahmads-MacBook-M4-Pro.local>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -133,6 +133,54 @@ def rm_connector(connector_id):
|
||||
return get_json_result(data=True)
|
||||
|
||||
|
||||
@manager.route("/connectors/<connector_id>/test", methods=["POST"]) # noqa: F821
|
||||
@login_required
|
||||
async def test_connector(connector_id):
|
||||
"""Validate connector configuration without persisting changes or triggering sync.
|
||||
|
||||
For the REST API connector, this uses `RestAPIConnector.validate_config`
|
||||
against the existing saved configuration.
|
||||
"""
|
||||
from common.data_source.rest_api_connector import RestAPIConnector
|
||||
from common.data_source.exceptions import ConnectorMissingCredentialError, ConnectorValidationError
|
||||
|
||||
ok, conn = ConnectorService.get_by_id(connector_id)
|
||||
if not ok:
|
||||
return get_data_error_result(message="Can't find this Connector!")
|
||||
|
||||
if conn.source != DocumentSource.REST_API:
|
||||
return get_json_result(
|
||||
code=RetCode.ARGUMENT_ERROR,
|
||||
message="Test endpoint currently supports only REST API connectors.",
|
||||
data=False,
|
||||
)
|
||||
|
||||
config = conn.config or {}
|
||||
credentials = config.get("credentials") or {}
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
RestAPIConnector.validate_config,
|
||||
config=config,
|
||||
credentials=credentials,
|
||||
)
|
||||
except (ConnectorValidationError, ConnectorMissingCredentialError) as exc:
|
||||
return get_json_result(
|
||||
code=RetCode.DATA_ERROR,
|
||||
message=str(exc),
|
||||
data=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
logging.exception("REST API connector validation failed: %s", exc)
|
||||
return get_json_result(
|
||||
code=RetCode.SERVER_ERROR,
|
||||
message="REST API connector validation failed, please check logs.",
|
||||
data=False,
|
||||
)
|
||||
|
||||
return get_json_result(data=True)
|
||||
|
||||
|
||||
WEB_FLOW_TTL_SECS = 15 * 60
|
||||
|
||||
|
||||
|
||||
@@ -117,6 +117,7 @@ class FileSource(StrEnum):
|
||||
RSS = "rss"
|
||||
S3 = "s3"
|
||||
NOTION = "notion"
|
||||
REST_API = "rest_api"
|
||||
DISCORD = "discord"
|
||||
CONFLUENCE = "confluence"
|
||||
GMAIL = "gmail"
|
||||
|
||||
@@ -44,6 +44,7 @@ from .zendesk_connector import ZendeskConnector
|
||||
from .seafile_connector import SeaFileConnector
|
||||
from .rdbms_connector import RDBMSConnector
|
||||
from .webdav_connector import WebDAVConnector
|
||||
from .rest_api_connector import RestAPIConnector
|
||||
from .config import BlobType, DocumentSource
|
||||
from .models import Document, TextSection, ImageSection, BasicExpertInfo
|
||||
from .exceptions import (
|
||||
@@ -87,4 +88,5 @@ __all__ = [
|
||||
"RDBMSConnector",
|
||||
"WebDAVConnector",
|
||||
"DingTalkAITableConnector",
|
||||
"RestAPIConnector",
|
||||
]
|
||||
|
||||
@@ -43,6 +43,7 @@ class DocumentSource(str, Enum):
|
||||
RSS = "rss"
|
||||
S3 = "s3"
|
||||
NOTION = "notion"
|
||||
REST_API = "rest_api"
|
||||
R2 = "r2"
|
||||
GOOGLE_CLOUD_STORAGE = "google_cloud_storage"
|
||||
OCI_STORAGE = "oci_storage"
|
||||
|
||||
1012
common/data_source/rest_api_connector.py
Normal file
1012
common/data_source/rest_api_connector.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,9 @@ from api.db.services.connector_service import ConnectorService, SyncLogsService
|
||||
from api.db.services.document_service import DocumentService
|
||||
from api.db.services.knowledgebase_service import KnowledgebaseService
|
||||
from common import settings
|
||||
from common.constants import FileSource, TaskStatus
|
||||
from common.config_utils import show_configs
|
||||
from common.data_source.config import INDEX_BATCH_SIZE
|
||||
from common.data_source import (
|
||||
BlobStorageConnector,
|
||||
RSSConnector,
|
||||
@@ -58,9 +60,8 @@ from common.data_source import (
|
||||
SeaFileConnector,
|
||||
RDBMSConnector,
|
||||
DingTalkAITableConnector,
|
||||
RestAPIConnector,
|
||||
)
|
||||
from common.constants import FileSource, TaskStatus
|
||||
from common.data_source.config import INDEX_BATCH_SIZE
|
||||
from common.data_source.models import ConnectorFailure, SeafileSyncScope
|
||||
from common.data_source.webdav_connector import WebDAVConnector
|
||||
from common.data_source.confluence_connector import ConfluenceConnector
|
||||
@@ -70,6 +71,7 @@ from common.data_source.github.connector import GithubConnector
|
||||
from common.data_source.gitlab_connector import GitlabConnector
|
||||
from common.data_source.bitbucket.connector import BitbucketConnector
|
||||
from common.data_source.interfaces import CheckpointOutputWrapper
|
||||
from common.data_source.exceptions import ConnectorValidationError
|
||||
from common.log_utils import init_root_logger
|
||||
from common.signal_utils import start_tracemalloc_and_snapshot, stop_tracemalloc
|
||||
from common.versions import get_ragflow_version
|
||||
@@ -1819,6 +1821,33 @@ class PostgreSQL(_RDBMSBase):
|
||||
DEFAULT_PORT: int = 5432
|
||||
|
||||
|
||||
class REST_API(SyncBase):
|
||||
SOURCE_NAME: str = FileSource.REST_API
|
||||
|
||||
async def _generate(self, task: dict):
|
||||
try:
|
||||
cfg = RestAPIConnector.parse_storage_config(self.conf)
|
||||
except ConnectorValidationError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
self.connector = RestAPIConnector.from_parsed_config(cfg)
|
||||
self.connector.load_credentials(self.conf.get("credentials") or {})
|
||||
|
||||
poll_start = task.get("poll_range_start")
|
||||
if task.get("reindex") == "1" or poll_start is None:
|
||||
document_generator = self.connector.load_from_state()
|
||||
begin_info = "totally"
|
||||
else:
|
||||
document_generator = self.connector.poll_source(
|
||||
poll_start.timestamp(),
|
||||
datetime.now(timezone.utc).timestamp(),
|
||||
)
|
||||
begin_info = f"from {poll_start}"
|
||||
|
||||
logging.info("Connect to REST API: %s %s %s", self.conf.get("method", "GET"), self.conf.get("url"), begin_info)
|
||||
return document_generator
|
||||
|
||||
|
||||
func_factory = {
|
||||
FileSource.RSS: RSS,
|
||||
FileSource.S3: S3,
|
||||
@@ -1849,6 +1878,7 @@ func_factory = {
|
||||
FileSource.MYSQL: MySQL,
|
||||
FileSource.POSTGRESQL: PostgreSQL,
|
||||
FileSource.DINGTALK_AI_TABLE: DingTalkAITable,
|
||||
FileSource.REST_API: REST_API,
|
||||
}
|
||||
|
||||
|
||||
|
||||
0
test/unit_test/data_source/__init__.py
Normal file
0
test/unit_test/data_source/__init__.py
Normal file
36
test/unit_test/data_source/conftest.py
Normal file
36
test/unit_test/data_source/conftest.py
Normal file
@@ -0,0 +1,36 @@
|
||||
#
|
||||
# Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
"""Pre-register the ``common.data_source`` package namespace so that
|
||||
importing individual sub-modules (config, exceptions, rest_api_connector, …)
|
||||
does **not** trigger ``common/data_source/__init__.py``, which pulls in every
|
||||
connector and their heavy transitive dependencies (numpy, xgboost, etc.).
|
||||
|
||||
This file is executed by pytest before any test module in this directory is
|
||||
collected, so the lightweight namespace is always in place.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
|
||||
import common # lightweight top-level package
|
||||
|
||||
if "common.data_source" not in sys.modules:
|
||||
_pkg = types.ModuleType("common.data_source")
|
||||
_pkg.__path__ = [os.path.join(p, "data_source") for p in common.__path__]
|
||||
_pkg.__package__ = "common.data_source"
|
||||
sys.modules["common.data_source"] = _pkg
|
||||
607
test/unit_test/data_source/test_rest_api_connector.py
Normal file
607
test/unit_test/data_source/test_rest_api_connector.py
Normal file
@@ -0,0 +1,607 @@
|
||||
#
|
||||
# Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from common.data_source import utils as _ds_utils
|
||||
from common.data_source.exceptions import (
|
||||
ConnectorMissingCredentialError,
|
||||
ConnectorValidationError,
|
||||
)
|
||||
from common.data_source.rest_api_connector import (
|
||||
AuthType,
|
||||
PaginationType,
|
||||
RestAPIConnector,
|
||||
RestAPIConnectorConfig,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VALID_URL = "https://api.example.com/v1/items"
|
||||
|
||||
_MOCK_DNS_ADDRINFO = [(2, 1, 6, "", ("93.184.216.34", 0))]
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _mocked_rest_api_requests_and_dns():
|
||||
"""Block real DNS/TCP: mock SSRF getaddrinfo and HTTP at the class layer.
|
||||
|
||||
`RestAPIConnector` calls `rl_requests.get` / `.post` on
|
||||
`utils._RateLimitedRequest`. Replacing only module-level `rl_requests` is not
|
||||
reliable everywhere (import/rebind quirks), so we patch the class methods
|
||||
that wrap `requests.get` / `requests.post` and avoid retry backoff delays.
|
||||
"""
|
||||
mock_rl = MagicMock()
|
||||
with patch(
|
||||
"common.data_source.rest_api_connector.socket.getaddrinfo",
|
||||
return_value=_MOCK_DNS_ADDRINFO,
|
||||
), patch.object(_ds_utils._RateLimitedRequest, "get", mock_rl.get), patch.object(
|
||||
_ds_utils._RateLimitedRequest,
|
||||
"post",
|
||||
mock_rl.post,
|
||||
):
|
||||
yield mock_rl
|
||||
|
||||
|
||||
def _make_paged_connector(**overrides) -> RestAPIConnector:
|
||||
defaults = dict(
|
||||
url=VALID_URL,
|
||||
content_fields=["title"],
|
||||
pagination_type=PaginationType.PAGE,
|
||||
pagination_config={"page_param": "page"},
|
||||
max_pages=100,
|
||||
request_delay=0,
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return RestAPIConnector(**defaults)
|
||||
|
||||
|
||||
def _make_connector(**overrides) -> RestAPIConnector:
|
||||
"""Build a RestAPIConnector with sensible defaults, applying *overrides*."""
|
||||
defaults = dict(
|
||||
url=VALID_URL,
|
||||
content_fields=["title", "body"],
|
||||
)
|
||||
defaults.update(overrides)
|
||||
return RestAPIConnector(**defaults)
|
||||
|
||||
|
||||
def _mock_response(json_data, status_code=200):
|
||||
"""Return a ``requests.Response``-like mock."""
|
||||
resp = MagicMock(spec=requests.Response)
|
||||
resp.status_code = status_code
|
||||
resp.url = VALID_URL
|
||||
resp.json.return_value = json_data
|
||||
|
||||
if status_code >= 400:
|
||||
http_error = requests.HTTPError(response=resp)
|
||||
resp.raise_for_status.side_effect = http_error
|
||||
resp.status_code = status_code
|
||||
else:
|
||||
resp.raise_for_status.return_value = None
|
||||
|
||||
return resp
|
||||
|
||||
|
||||
# ===================================================================== #
|
||||
# 1. Config schema validation #
|
||||
# ===================================================================== #
|
||||
|
||||
class TestRestAPIConfig:
|
||||
"""Test Pydantic RestAPIConnectorConfig schema validation."""
|
||||
|
||||
def test_missing_url_raises_validation_error(self):
|
||||
"""Missing url should fail Pydantic validation."""
|
||||
with pytest.raises(Exception):
|
||||
RestAPIConnectorConfig(content_fields=["title"])
|
||||
|
||||
def test_missing_content_fields_detected(self):
|
||||
"""An empty content_fields list should be caught by ensure_required_fields."""
|
||||
cfg = RestAPIConnectorConfig(url=VALID_URL, content_fields=[])
|
||||
with pytest.raises(ConnectorValidationError):
|
||||
cfg.ensure_required_fields()
|
||||
|
||||
def test_valid_minimal_config(self):
|
||||
"""Minimal valid config: url + content_fields."""
|
||||
cfg = RestAPIConnectorConfig(url=VALID_URL, content_fields=["title"])
|
||||
assert str(cfg.url).startswith("https://api.example.com")
|
||||
assert cfg.content_fields == ["title"]
|
||||
|
||||
def test_auth_type_defaults_to_none(self):
|
||||
"""auth_type should default to 'none'."""
|
||||
cfg = RestAPIConnectorConfig(url=VALID_URL, content_fields=["t"])
|
||||
assert cfg.auth_type == AuthType.NONE
|
||||
|
||||
def test_pagination_type_defaults_to_none(self):
|
||||
"""pagination_type should default to 'none'."""
|
||||
cfg = RestAPIConnectorConfig(url=VALID_URL, content_fields=["t"])
|
||||
assert cfg.pagination_type == PaginationType.NONE
|
||||
|
||||
def test_string_to_dict_coercion_for_headers(self):
|
||||
"""A key=value string should be coerced to a dict."""
|
||||
cfg = RestAPIConnectorConfig(
|
||||
url=VALID_URL, content_fields=["t"], headers="X-Custom=hello"
|
||||
)
|
||||
assert cfg.headers == {"X-Custom": "hello"}
|
||||
|
||||
def test_string_to_list_coercion_for_content_fields(self):
|
||||
"""A comma-separated string should be coerced to a list."""
|
||||
cfg = RestAPIConnectorConfig(url=VALID_URL, content_fields="title,content")
|
||||
assert cfg.content_fields == ["title", "content"]
|
||||
|
||||
|
||||
# ===================================================================== #
|
||||
# 2. SSRF URL validation #
|
||||
# ===================================================================== #
|
||||
|
||||
class TestSSRFValidation:
|
||||
"""Test that unsafe URLs are blocked before any HTTP request is made."""
|
||||
|
||||
def test_localhost_blocked(self):
|
||||
"""localhost should be rejected."""
|
||||
with pytest.raises(ConnectorValidationError, match="localhost"):
|
||||
_make_connector(url="http://localhost/api")
|
||||
|
||||
@patch("common.data_source.rest_api_connector.socket.getaddrinfo")
|
||||
def test_loopback_ip_blocked(self, mock_dns):
|
||||
"""127.0.0.1 should be rejected."""
|
||||
mock_dns.return_value = [(2, 1, 6, "", ("127.0.0.1", 0))]
|
||||
with pytest.raises(ConnectorValidationError, match="disallowed"):
|
||||
_make_connector(url="http://127.0.0.1/api")
|
||||
|
||||
@patch("common.data_source.rest_api_connector.socket.getaddrinfo")
|
||||
def test_cloud_metadata_ip_blocked(self, mock_dns):
|
||||
"""169.254.169.254 (cloud metadata endpoint) should be rejected."""
|
||||
mock_dns.return_value = [(2, 1, 6, "", ("169.254.169.254", 0))]
|
||||
with pytest.raises(ConnectorValidationError, match="disallowed"):
|
||||
_make_connector(url="http://169.254.169.254/latest/meta-data/")
|
||||
|
||||
@patch("common.data_source.rest_api_connector.socket.getaddrinfo")
|
||||
def test_private_ip_192_blocked(self, mock_dns):
|
||||
"""192.168.x.x should be rejected."""
|
||||
mock_dns.return_value = [(2, 1, 6, "", ("192.168.1.1", 0))]
|
||||
with pytest.raises(ConnectorValidationError, match="disallowed"):
|
||||
_make_connector(url="http://192.168.1.1/api")
|
||||
|
||||
@patch("common.data_source.rest_api_connector.socket.getaddrinfo")
|
||||
def test_private_ip_10_blocked(self, mock_dns):
|
||||
"""10.x.x.x should be rejected."""
|
||||
mock_dns.return_value = [(2, 1, 6, "", ("10.0.0.1", 0))]
|
||||
with pytest.raises(ConnectorValidationError, match="disallowed"):
|
||||
_make_connector(url="http://10.0.0.1/api")
|
||||
|
||||
@patch("common.data_source.rest_api_connector.socket.getaddrinfo")
|
||||
def test_public_url_passes(self, mock_dns):
|
||||
"""A public IP should pass validation."""
|
||||
mock_dns.return_value = [(2, 1, 6, "", ("93.184.216.34", 0))]
|
||||
c = _make_connector(url="https://example.com/api")
|
||||
assert c.url.startswith("https://")
|
||||
|
||||
def test_ftp_scheme_blocked(self):
|
||||
"""ftp:// should be rejected."""
|
||||
with pytest.raises(ConnectorValidationError, match="scheme"):
|
||||
_make_connector(url="ftp://example.com/file")
|
||||
|
||||
def test_file_scheme_blocked(self):
|
||||
"""file:// should be rejected."""
|
||||
with pytest.raises(ConnectorValidationError, match="scheme"):
|
||||
_make_connector(url="file:///etc/passwd")
|
||||
|
||||
|
||||
# ===================================================================== #
|
||||
# 3. Authentication setup #
|
||||
# ===================================================================== #
|
||||
|
||||
class TestAuthSetup:
|
||||
"""Test _build_auth produces the correct headers / auth objects."""
|
||||
|
||||
@patch("common.data_source.rest_api_connector.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))])
|
||||
def test_auth_none(self, _dns):
|
||||
"""auth_type=none should produce no auth headers."""
|
||||
c = _make_connector(auth_type=AuthType.NONE)
|
||||
c.load_credentials({})
|
||||
assert c._auth_headers == {}
|
||||
assert c._basic_auth is None
|
||||
|
||||
@patch("common.data_source.rest_api_connector.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))])
|
||||
def test_api_key_header(self, _dns):
|
||||
"""api_key_header should set the specified header."""
|
||||
c = _make_connector(
|
||||
auth_type=AuthType.API_KEY_HEADER,
|
||||
auth_config={"header_name": "X-API-Key"},
|
||||
)
|
||||
c.load_credentials({"api_key": "secret123"})
|
||||
assert c._auth_headers == {"X-API-Key": "secret123"}
|
||||
|
||||
@patch("common.data_source.rest_api_connector.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))])
|
||||
def test_bearer_token(self, _dns):
|
||||
"""bearer should set Authorization: Bearer <token>."""
|
||||
c = _make_connector(auth_type=AuthType.BEARER)
|
||||
c.load_credentials({"token": "tok_abc"})
|
||||
assert c._auth_headers == {"Authorization": "Bearer tok_abc"}
|
||||
|
||||
@patch("common.data_source.rest_api_connector.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))])
|
||||
def test_basic_auth(self, _dns):
|
||||
"""basic should produce an HTTPBasicAuth object."""
|
||||
c = _make_connector(auth_type=AuthType.BASIC)
|
||||
c.load_credentials({"username": "user", "password": "pass"})
|
||||
assert c._basic_auth is not None
|
||||
assert c._basic_auth.username == "user"
|
||||
assert c._basic_auth.password == "pass"
|
||||
|
||||
|
||||
# ===================================================================== #
|
||||
# 4. Field extraction #
|
||||
# ===================================================================== #
|
||||
|
||||
class TestFieldExtraction:
|
||||
"""Test _extract_field / _extract_field_values dot-notation paths."""
|
||||
|
||||
@patch("common.data_source.rest_api_connector.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))])
|
||||
def setup_method(self, method, _dns=None):
|
||||
with patch("common.data_source.rest_api_connector.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
self.connector = _make_connector()
|
||||
|
||||
def test_simple_field(self):
|
||||
"""Top-level field extraction."""
|
||||
assert self.connector._extract_field({"title": "Hello"}, "title") == "Hello"
|
||||
|
||||
def test_dot_notation_nested(self):
|
||||
"""Dot-notation nested field."""
|
||||
item = {"country": {"name": "Kuwait"}}
|
||||
assert self.connector._extract_field(item, "country.name") == "Kuwait"
|
||||
|
||||
def test_array_wildcard(self):
|
||||
"""Wildcard [*] returns all array elements."""
|
||||
item = {"tags": [{"name": "A"}, {"name": "B"}]}
|
||||
result = self.connector._extract_field(item, "tags[*].name")
|
||||
assert result == ["A", "B"]
|
||||
|
||||
def test_missing_field_returns_none(self):
|
||||
"""Missing field returns None."""
|
||||
assert self.connector._extract_field({"a": 1}, "nonexistent") is None
|
||||
|
||||
def test_missing_field_with_default(self):
|
||||
"""Missing field returns configured default value."""
|
||||
with patch("common.data_source.rest_api_connector.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
c = _make_connector(field_default_values={"missing": "fallback"})
|
||||
result = c._get_typed_field_value("missing", {"other": 1})
|
||||
assert result == "fallback"
|
||||
|
||||
def test_deeply_nested_path(self):
|
||||
"""Multi-level dot-notation path."""
|
||||
item = {"a": {"b": {"c": {"d": 42}}}}
|
||||
assert self.connector._extract_field(item, "a.b.c.d") == 42
|
||||
|
||||
|
||||
# ===================================================================== #
|
||||
# 5. Items array detection #
|
||||
# ===================================================================== #
|
||||
|
||||
class TestItemsArrayDetection:
|
||||
"""Test _extract_items auto-detection of the items array."""
|
||||
|
||||
@patch("common.data_source.rest_api_connector.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))])
|
||||
def setup_method(self, method, _dns=None):
|
||||
with patch("common.data_source.rest_api_connector.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
self.connector = _make_connector()
|
||||
|
||||
def test_items_key(self):
|
||||
"""Detect 'items' key."""
|
||||
resp = {"items": [{"id": 1}]}
|
||||
assert self.connector._extract_items(resp) == [{"id": 1}]
|
||||
|
||||
def test_results_key(self):
|
||||
"""Detect 'results' key."""
|
||||
resp = {"results": [{"id": 2}]}
|
||||
assert self.connector._extract_items(resp) == [{"id": 2}]
|
||||
|
||||
def test_data_key(self):
|
||||
"""Detect 'data' key."""
|
||||
resp = {"data": [{"id": 3}]}
|
||||
assert self.connector._extract_items(resp) == [{"id": 3}]
|
||||
|
||||
def test_records_key(self):
|
||||
"""Detect 'records' key."""
|
||||
resp = {"records": [{"id": 4}]}
|
||||
assert self.connector._extract_items(resp) == [{"id": 4}]
|
||||
|
||||
def test_custom_key_fallback(self):
|
||||
"""Fall back to the first list value in the dict."""
|
||||
resp = {"totalCount": 5, "stories": [{"id": 5}]}
|
||||
assert self.connector._extract_items(resp) == [{"id": 5}]
|
||||
|
||||
def test_response_is_list(self):
|
||||
"""Response that is directly a list."""
|
||||
resp = [{"id": 6}, {"id": 7}]
|
||||
assert self.connector._extract_items(resp) == [{"id": 6}, {"id": 7}]
|
||||
|
||||
def test_empty_response(self):
|
||||
"""Empty dict returns empty list."""
|
||||
assert self.connector._extract_items({}) == []
|
||||
|
||||
def test_no_list_in_response(self):
|
||||
"""Dict with no list values returns empty list."""
|
||||
assert self.connector._extract_items({"count": 0}) == []
|
||||
|
||||
|
||||
# ===================================================================== #
|
||||
# 6. HTML stripping #
|
||||
# ===================================================================== #
|
||||
|
||||
class TestHTMLStripping:
|
||||
"""Test the _strip_html static method."""
|
||||
|
||||
def test_basic_tag_removal(self):
|
||||
"""Remove simple HTML tags."""
|
||||
assert RestAPIConnector._strip_html("<p>Hello</p>") == "Hello"
|
||||
|
||||
def test_whitespace_collapsing(self):
|
||||
"""Multiple whitespace chars collapse to single space."""
|
||||
assert RestAPIConnector._strip_html("<p>Hello</p> <p>World</p>") == "Hello World"
|
||||
|
||||
def test_empty_string(self):
|
||||
"""Empty input returns empty output."""
|
||||
assert RestAPIConnector._strip_html("") == ""
|
||||
|
||||
def test_plain_text_passthrough(self):
|
||||
"""Text without HTML passes through unchanged."""
|
||||
assert RestAPIConnector._strip_html("Hello World") == "Hello World"
|
||||
|
||||
def test_nested_tags(self):
|
||||
"""Nested HTML tags are all stripped."""
|
||||
result = RestAPIConnector._strip_html("<div><p><b>Bold</b> text</p></div>")
|
||||
assert result == "Bold text"
|
||||
|
||||
def test_html_with_attributes(self):
|
||||
"""Tags with attributes are stripped."""
|
||||
result = RestAPIConnector._strip_html('<a href="http://x.com">Link</a>')
|
||||
assert result == "Link"
|
||||
|
||||
|
||||
# ===================================================================== #
|
||||
# 7. Document creation #
|
||||
# ===================================================================== #
|
||||
|
||||
class TestDocumentCreation:
|
||||
"""Test _item_to_document mapping."""
|
||||
|
||||
@patch("common.data_source.rest_api_connector.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))])
|
||||
def setup_method(self, method, _dns=None):
|
||||
with patch("common.data_source.rest_api_connector.socket.getaddrinfo",
|
||||
return_value=[(2, 1, 6, "", ("93.184.216.34", 0))]):
|
||||
self.connector = _make_connector(
|
||||
id_field="id",
|
||||
content_fields=["title", "body"],
|
||||
metadata_fields=["author"],
|
||||
)
|
||||
|
||||
def test_document_id_from_configured_field(self):
|
||||
"""Document ID uses the configured id_field."""
|
||||
item = {"id": "abc", "title": "T", "body": "B", "author": "A"}
|
||||
doc = self.connector._item_to_document(item)
|
||||
assert doc.id is not None and len(doc.id) > 0
|
||||
|
||||
def test_semantic_identifier_from_first_content_field(self):
|
||||
"""semantic_identifier comes from the first content field."""
|
||||
item = {"id": "1", "title": "My Title", "body": "Body", "author": "A"}
|
||||
doc = self.connector._item_to_document(item)
|
||||
assert "My Title" in doc.semantic_identifier
|
||||
|
||||
def test_content_blob_contains_all_fields(self):
|
||||
"""Blob should contain both content fields."""
|
||||
item = {"id": "1", "title": "Title", "body": "Body text", "author": "A"}
|
||||
doc = self.connector._item_to_document(item)
|
||||
content = doc.blob.decode("utf-8")
|
||||
assert "Title" in content
|
||||
assert "Body text" in content
|
||||
|
||||
def test_metadata_populated(self):
|
||||
"""Metadata dict is populated from configured metadata_fields."""
|
||||
item = {"id": "1", "title": "T", "body": "B", "author": "Jane"}
|
||||
doc = self.connector._item_to_document(item)
|
||||
assert doc.metadata is not None
|
||||
assert doc.metadata["author"] == "Jane"
|
||||
|
||||
def test_html_stripped_from_content(self):
|
||||
"""HTML tags are removed from content fields."""
|
||||
item = {"id": "1", "title": "T", "body": "<p>Clean</p>", "author": "A"}
|
||||
doc = self.connector._item_to_document(item)
|
||||
content = doc.blob.decode("utf-8")
|
||||
assert "<p>" not in content
|
||||
assert "Clean" in content
|
||||
|
||||
def test_extension_is_txt(self):
|
||||
"""Document extension should be .txt."""
|
||||
item = {"id": "1", "title": "T", "body": "B", "author": "A"}
|
||||
doc = self.connector._item_to_document(item)
|
||||
assert doc.extension == ".txt"
|
||||
|
||||
def test_missing_content_fields_graceful(self):
|
||||
"""Missing content fields produce an empty blob gracefully."""
|
||||
item = {"id": "1", "author": "A"}
|
||||
doc = self.connector._item_to_document(item)
|
||||
assert doc.blob == b""
|
||||
|
||||
|
||||
# ===================================================================== #
|
||||
# 8. Pagination behaviour #
|
||||
# ===================================================================== #
|
||||
|
||||
class TestPaginationBehavior:
|
||||
"""Test pagination iteration with mocked HTTP responses."""
|
||||
|
||||
def test_page_pagination_increments(self):
|
||||
"""Page-based pagination should increment the page param."""
|
||||
with _mocked_rest_api_requests_and_dns() as mock_rl:
|
||||
page1 = _mock_response({"items": [{"title": "A"}, {"title": "B"}]})
|
||||
page2 = _mock_response({"items": []})
|
||||
mock_rl.get.side_effect = [page1, page2]
|
||||
|
||||
c = _make_paged_connector()
|
||||
items = list(c._iter_items())
|
||||
assert len(items) == 2
|
||||
assert mock_rl.get.call_count == 2
|
||||
|
||||
def test_offset_pagination_increments(self):
|
||||
"""Offset-based pagination should increment offset by limit."""
|
||||
with _mocked_rest_api_requests_and_dns() as mock_rl:
|
||||
page1 = _mock_response({"items": [{"title": "A"}]})
|
||||
page2 = _mock_response({"items": []})
|
||||
mock_rl.get.side_effect = [page1, page2]
|
||||
|
||||
c = _make_connector(
|
||||
pagination_type=PaginationType.OFFSET,
|
||||
pagination_config={
|
||||
"offset_param": "offset",
|
||||
"limit_param": "limit",
|
||||
"limit": 10,
|
||||
},
|
||||
request_delay=0,
|
||||
)
|
||||
items = list(c._iter_items())
|
||||
assert len(items) == 1
|
||||
|
||||
def test_stops_on_empty_results(self):
|
||||
"""Pagination stops when empty items are returned."""
|
||||
with _mocked_rest_api_requests_and_dns() as mock_rl:
|
||||
mock_rl.get.return_value = _mock_response({"items": []})
|
||||
|
||||
c = _make_paged_connector()
|
||||
items = list(c._iter_items())
|
||||
assert items == []
|
||||
assert mock_rl.get.call_count == 1
|
||||
|
||||
def test_stops_when_fewer_items_than_page_size(self):
|
||||
"""Pagination stops when fewer items than page_size are returned."""
|
||||
with _mocked_rest_api_requests_and_dns() as mock_rl:
|
||||
page1 = _mock_response({"items": [{"title": "A"}]})
|
||||
mock_rl.get.return_value = page1
|
||||
|
||||
c = _make_paged_connector(
|
||||
pagination_config={"page_param": "page", "page_size": 10},
|
||||
)
|
||||
items = list(c._iter_items())
|
||||
assert len(items) == 1
|
||||
assert mock_rl.get.call_count == 1
|
||||
|
||||
def test_max_pages_cap(self):
|
||||
"""Pagination respects the max_pages safety cap."""
|
||||
with _mocked_rest_api_requests_and_dns() as mock_rl:
|
||||
mock_rl.get.return_value = _mock_response(
|
||||
{"items": [{"title": "A"}, {"title": "B"}]}
|
||||
)
|
||||
|
||||
c = _make_paged_connector(
|
||||
max_pages=3,
|
||||
pagination_config={"page_param": "page", "page_size": 2},
|
||||
)
|
||||
list(c._iter_items())
|
||||
assert mock_rl.get.call_count == 3
|
||||
|
||||
def test_request_delay_applied(self):
|
||||
"""request_delay should cause a sleep between pages."""
|
||||
with _mocked_rest_api_requests_and_dns() as mock_rl:
|
||||
with patch("common.data_source.rest_api_connector.time.sleep") as mock_sleep:
|
||||
page1 = _mock_response({"items": [{"title": "A"}, {"title": "B"}]})
|
||||
page2 = _mock_response({"items": []})
|
||||
mock_rl.get.side_effect = [page1, page2]
|
||||
|
||||
c = _make_paged_connector(
|
||||
pagination_config={"page_param": "page", "page_size": 2},
|
||||
)
|
||||
c.request_delay = 1.5
|
||||
list(c._iter_items())
|
||||
mock_sleep.assert_called_once_with(1.5)
|
||||
|
||||
|
||||
# ===================================================================== #
|
||||
# 9. Non-retriable HTTP errors #
|
||||
# ===================================================================== #
|
||||
|
||||
class TestNonRetriableErrors:
|
||||
"""Test that HTTP errors are classified correctly in _fetch_page."""
|
||||
|
||||
def test_401_raises_credential_error(self):
|
||||
"""401 should raise ConnectorMissingCredentialError immediately."""
|
||||
with _mocked_rest_api_requests_and_dns() as mock_rl:
|
||||
mock_rl.get.return_value = _mock_response({}, status_code=401)
|
||||
c = _make_connector(request_delay=0)
|
||||
c.load_credentials({})
|
||||
with pytest.raises(ConnectorMissingCredentialError):
|
||||
c._fetch_page({})
|
||||
|
||||
def test_403_raises_credential_error(self):
|
||||
"""403 should raise ConnectorMissingCredentialError immediately."""
|
||||
with _mocked_rest_api_requests_and_dns() as mock_rl:
|
||||
mock_rl.get.return_value = _mock_response({}, status_code=403)
|
||||
c = _make_connector(request_delay=0)
|
||||
c.load_credentials({})
|
||||
with pytest.raises(ConnectorMissingCredentialError):
|
||||
c._fetch_page({})
|
||||
|
||||
def test_404_raises_validation_error(self):
|
||||
"""404 should raise ConnectorValidationError (no retry)."""
|
||||
with _mocked_rest_api_requests_and_dns() as mock_rl:
|
||||
mock_rl.get.return_value = _mock_response({}, status_code=404)
|
||||
c = _make_connector(request_delay=0)
|
||||
c.load_credentials({})
|
||||
with pytest.raises(ConnectorValidationError, match="non-retriable"):
|
||||
c._fetch_page({})
|
||||
|
||||
def test_400_raises_validation_error(self):
|
||||
"""400 should raise ConnectorValidationError (no retry)."""
|
||||
with _mocked_rest_api_requests_and_dns() as mock_rl:
|
||||
mock_rl.get.return_value = _mock_response({}, status_code=400)
|
||||
c = _make_connector(request_delay=0)
|
||||
c.load_credentials({})
|
||||
with pytest.raises(ConnectorValidationError, match="non-retriable"):
|
||||
c._fetch_page({})
|
||||
|
||||
def test_500_triggers_retry(self):
|
||||
"""500 should raise HTTPError (which the retry decorator catches)."""
|
||||
with _mocked_rest_api_requests_and_dns() as mock_rl:
|
||||
mock_rl.get.return_value = _mock_response({}, status_code=500)
|
||||
c = _make_connector(request_delay=0)
|
||||
c.load_credentials({})
|
||||
with pytest.raises(requests.HTTPError):
|
||||
c._fetch_page({})
|
||||
|
||||
def test_429_triggers_retry(self):
|
||||
"""429 should raise HTTPError (retriable, not ConnectorValidationError)."""
|
||||
with _mocked_rest_api_requests_and_dns() as mock_rl:
|
||||
mock_rl.get.return_value = _mock_response({}, status_code=429)
|
||||
c = _make_connector(request_delay=0)
|
||||
c.load_credentials({})
|
||||
with pytest.raises(requests.HTTPError):
|
||||
c._fetch_page({})
|
||||
58
test/unit_test/rag/conftest.py
Normal file
58
test/unit_test/rag/conftest.py
Normal file
@@ -0,0 +1,58 @@
|
||||
#
|
||||
# Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
"""Restore the real ``common.data_source`` package before importing rag unit tests.
|
||||
|
||||
``test/unit_test/data_source/conftest.py`` registers a lightweight
|
||||
``sys.modules["common.data_source"]`` stub so submodule imports skip the heavy
|
||||
package ``__init__.py``. Pytest collection order visits ``data_source/`` before
|
||||
``rag/``, so without this hook ``rag.svr.sync_data_source`` fails on
|
||||
``from common.data_source import BlobStorageConnector``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
def _restore_common_data_source_package() -> None:
|
||||
mod = sys.modules.get("common.data_source")
|
||||
if mod is None:
|
||||
return
|
||||
# Stub is a bare types.ModuleType with __path__ and no __file__; real package has __init__.py.
|
||||
if getattr(mod, "__file__", None) is not None:
|
||||
return
|
||||
if not isinstance(mod, types.ModuleType) or not getattr(mod, "__path__", None):
|
||||
return
|
||||
keys = [
|
||||
key
|
||||
for key in sys.modules
|
||||
if key == "common.data_source" or key.startswith("common.data_source.")
|
||||
]
|
||||
for key in keys:
|
||||
del sys.modules[key]
|
||||
importlib.invalidate_caches()
|
||||
try:
|
||||
importlib.import_module("common.data_source")
|
||||
except Exception as exc: # pragma: no cover
|
||||
raise ImportError(
|
||||
"conftest: failed to restore real common.data_source package"
|
||||
) from exc
|
||||
|
||||
|
||||
_restore_common_data_source_package()
|
||||
13
web/src/assets/svg/data-source/rest-api.svg
Normal file
13
web/src/assets/svg/data-source/rest-api.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
|
||||
<rect x="16" y="28" width="96" height="72" rx="12" ry="12" fill="#5B8DEF" />
|
||||
<rect x="24" y="36" width="80" height="56" rx="8" ry="8" fill="#fff" />
|
||||
<circle cx="44" cy="54" r="6" fill="#5B8DEF" />
|
||||
<circle cx="64" cy="54" r="6" fill="#5B8DEF" />
|
||||
<circle cx="84" cy="54" r="6" fill="#5B8DEF" />
|
||||
<rect x="36" y="68" width="56" height="6" rx="3" fill="#5B8DEF" opacity="0.5" />
|
||||
<rect x="44" y="78" width="40" height="6" rx="3" fill="#5B8DEF" opacity="0.3" />
|
||||
<path d="M54 14 L64 6 L74 14" stroke="#5B8DEF" stroke-width="4" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="64" y1="6" x2="64" y2="28" stroke="#5B8DEF" stroke-width="4" stroke-linecap="round" />
|
||||
<path d="M54 114 L64 122 L74 114" stroke="#5B8DEF" stroke-width="4" fill="none" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<line x1="64" y1="100" x2="64" y2="122" stroke="#5B8DEF" stroke-width="4" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1000 B |
@@ -41,7 +41,7 @@ export const PptPreviewer: React.FC<PptPreviewerProps> = ({
|
||||
});
|
||||
pptxPrviewer.preview(arrayBuffer);
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
message.error('ppt parse failed');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -347,7 +347,6 @@ export const RenderField = ({
|
||||
field: FormFieldConfig;
|
||||
labelClassName?: string;
|
||||
}) => {
|
||||
const form = useFormContext();
|
||||
if (field.render) {
|
||||
if (field.type === FormFieldType.Custom && field.hideLabel) {
|
||||
return <div className="w-full">{field.render({})}</div>;
|
||||
|
||||
@@ -296,9 +296,11 @@ const FloatingChatWidgetMarkdown = ({
|
||||
className="text-sm leading-relaxed space-y-2 prose-sm max-w-full"
|
||||
components={
|
||||
{
|
||||
p: ({ children, node, ...props }: any) => (
|
||||
<p {...props}>{children}</p>
|
||||
),
|
||||
p: (props: any) => {
|
||||
const { children, node, ...rest } = props;
|
||||
void node;
|
||||
return <p {...rest}>{children}</p>;
|
||||
},
|
||||
'custom-typography': ({ children }: { children: string }) =>
|
||||
renderReference(children),
|
||||
code(props: any) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { getAuthorization } from '@/utils/authorization-util';
|
||||
import { chain, sum } from 'lodash';
|
||||
import { Loader2, Mic, Square } from 'lucide-react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useIsDarkTheme } from '../theme-provider';
|
||||
import { Input } from './input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './popover';
|
||||
|
||||
@@ -18,7 +17,6 @@ const VoiceVisualizer = ({ isRecording }: { isRecording: boolean }) => {
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const animationFrameRef = useRef<number>(0);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const isDark = useIsDarkTheme();
|
||||
|
||||
const draw = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -273,11 +271,9 @@ export const AudioButton = ({
|
||||
// throw new Error('ReadableStream not supported in this browser');
|
||||
// }
|
||||
|
||||
console.log('Response:', response);
|
||||
const { data, code } = await response.json();
|
||||
if (code === 0 && data && data.text) {
|
||||
setTranscript(data.text);
|
||||
console.log('Transcript:', data.text);
|
||||
onOk?.(data.text);
|
||||
}
|
||||
setPopoverOpen(false);
|
||||
@@ -341,7 +337,7 @@ export const AudioButton = ({
|
||||
<Popover
|
||||
open={popoverOpen}
|
||||
onOpenChange={(open) => {
|
||||
setPopoverOpen(true);
|
||||
setPopoverOpen(open);
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
|
||||
@@ -1365,6 +1365,42 @@ Example: Virtual Hosted Style`,
|
||||
'Column to use as unique document ID. If not specified, a hash of the content will be used.',
|
||||
postgresqlTimestampColumnTip:
|
||||
'Datetime/timestamp column for incremental sync. Only rows modified after the last sync will be fetched.',
|
||||
rest_apiDescription:
|
||||
'Connect any REST API endpoint as a data source using a flexible, configuration-driven connector.',
|
||||
restApiQueryParamsTip:
|
||||
'Key=value pairs (one per line) sent as URL query parameters. Use this instead of embedding params in the URL.',
|
||||
restApiHeadersTip:
|
||||
'Optional JSON object of additional HTTP headers to send with every request.',
|
||||
restApiItemsPathTip:
|
||||
'Field name or JSONPath to the array of items in the response. Leave empty to auto-detect (tries "items", "results", "data", etc.).',
|
||||
restApiIdFieldTip:
|
||||
'Field path within each item used to build a stable document ID. Leave empty to auto-generate from content hash.',
|
||||
restApiContentFieldsTip:
|
||||
'Comma-separated list of item fields to concatenate into the document content.',
|
||||
restApiMetadataFieldsTip:
|
||||
'Comma-separated list of item fields to store as metadata.',
|
||||
restApiNextCursorPathTip:
|
||||
'JSONPath expression that resolves to the next-page cursor in the API response.',
|
||||
restApiPollTimestampFieldTip:
|
||||
'Field path in each item that represents the last updated time, used for incremental sync.',
|
||||
restApiRequestBodyTip:
|
||||
'Optional JSON body to send for POST requests. Used together with query params and pagination.',
|
||||
restApiRequestDelayTip:
|
||||
'Delay in seconds between consecutive page requests. Helps avoid rate limiting from the API. Set to 0 to disable.',
|
||||
restApiValidationApiKeyRequired:
|
||||
'API key is required when Auth Type is API Key (Header).',
|
||||
restApiValidationApiKeyHeaderNameRequired:
|
||||
'API key header name is required when Auth Type is API Key (Header).',
|
||||
restApiValidationBearerTokenRequired:
|
||||
'Bearer token is required when Auth Type is Bearer Token.',
|
||||
restApiValidationBasicUsernameRequired:
|
||||
'Username is required when Auth Type is Basic Auth.',
|
||||
restApiValidationBasicPasswordRequired:
|
||||
'Password is required when Auth Type is Basic Auth.',
|
||||
restApiTestConnection: 'Test connection',
|
||||
restApiTestSuccess: 'REST API connector validated successfully.',
|
||||
restApiTestFailed:
|
||||
'REST API connector validation failed. Please check your configuration and logs.',
|
||||
availableSourcesDescription: 'Select a data source to add',
|
||||
availableSources: 'Available sources',
|
||||
datasourceDescription: 'Manage your data source and connections',
|
||||
|
||||
@@ -341,7 +341,7 @@ Prosedürel Bellek: Öğrenilen beceriler, alışkanlıklar ve otomatik prosedü
|
||||
config: {
|
||||
descriptionPlaceholder: 'Belleğinizi açıklayın',
|
||||
memorySizeTooltip: `Her mesajın içeriği + embedding vektörü için geçerlidir (≈ İçerik + Boyutlar × 8 Bayt).
|
||||
Örnek: 1024 boyutlu embedding ile 1 KB\'lık bir mesaj ~9 KB kullanır. 5 MB varsayılan sınır ~500 mesaj tutar.`,
|
||||
Örnek: 1024 boyutlu embedding ile 1 KB'lık bir mesaj ~9 KB kullanır. 5 MB varsayılan sınır ~500 mesaj tutar.`,
|
||||
avatar: 'Avatar',
|
||||
description: 'Açıklama',
|
||||
memorySize: 'Bellek boyutu',
|
||||
@@ -980,9 +980,9 @@ Bu otomatik etiketleme özelliği, mevcut datasete alanına özgü bilgi katman
|
||||
systemTip:
|
||||
'LLM için istemleriniz veya talimatlarınız; rol, yanıtların uzunluğu, tonu ve dili dahil ancak bunlarla sınırlı değildir. Modeliniz doğal olarak akıl yürütmeyi destekliyorsa, akıl yürütmeyi durdurmak için isteme //no_thinking ekleyebilirsiniz.',
|
||||
topN: 'İlk N',
|
||||
topNTip: `Benzerlik eşiğinin üzerindeki tüm parçalar LLM\'ye gönderilmeyecek. Bu, alınanlardan 'İlk N' parçayı seçer.`,
|
||||
topNTip: `Benzerlik eşiğinin üzerindeki tüm parçalar LLM'ye gönderilmeyecek. Bu, alınanlardan 'İlk N' parçayı seçer.`,
|
||||
variable: 'Değişken',
|
||||
variableTip: `RAGFlow\'nun sohbet asistanı yönetim API\'leri ile birlikte kullanılır.`,
|
||||
variableTip: `RAGFlow'nun sohbet asistanı yönetim API'leri ile birlikte kullanılır.`,
|
||||
add: 'Ekle',
|
||||
key: 'Anahtar',
|
||||
optional: 'İsteğe bağlı',
|
||||
@@ -1154,7 +1154,7 @@ Bu otomatik etiketleme özelliği, mevcut datasete alanına özgü bilgi katman
|
||||
"Confluence örneğinizin temel URL'si (örn. https://your-domain.atlassian.net/wiki)",
|
||||
confluenceSpaceKeyTip:
|
||||
'İsteğe bağlı: Belirli bir alanla senkronizasyonu sınırlamak için alan anahtarı belirtin.',
|
||||
s3PrefixTip: `S3 bucket\'ınızdaki dosyaları almak için klasör yolunu belirtin.`,
|
||||
s3PrefixTip: `S3 bucket'ınızdaki dosyaları almak için klasör yolunu belirtin.`,
|
||||
S3CompatibleEndpointUrlTip: `S3 uyumlu Depolama Kutusu için zorunludur.`,
|
||||
S3CompatibleAddressingStyleTip: `S3 uyumlu Depolama Kutusu için zorunludur.`,
|
||||
addDataSourceModalTitle: '{{name}} bağlayıcınızı oluşturun',
|
||||
@@ -1509,7 +1509,7 @@ Bu otomatik etiketleme özelliği, mevcut datasete alanına özgü bilgi katman
|
||||
'Lütfen Google Cloud Hizmet Hesabı Anahtarını base64 formatında girin',
|
||||
addGoogleRegion: 'Google Cloud Bölgesi',
|
||||
GoogleRegionMessage: 'Lütfen Google Cloud Bölgesi girin',
|
||||
modelProvidersWarn: `Lütfen önce <b>Ayarlar > Model sağlayıcıları</b> bölümünde hem embedding modelini hem de LLM\'yi ekleyin.`,
|
||||
modelProvidersWarn: `Lütfen önce <b>Ayarlar > Model sağlayıcıları</b> bölümünde hem embedding modelini hem de LLM'yi ekleyin.`,
|
||||
apiVersion: 'API Sürümü',
|
||||
apiVersionMessage: 'Lütfen API sürümünü girin',
|
||||
add: 'Ekle',
|
||||
@@ -1794,7 +1794,7 @@ En uygun olduğu durumlar: Anlatı bütünlüğünün bitişik paragrafları bir
|
||||
beginDescription: 'Akışın başladığı yer.',
|
||||
answerDescription: `İnsan ve bot arasındaki arayüz olarak hizmet eden bir bileşen.`,
|
||||
retrievalDescription: `Belirtilen datasets içinden bilgi alan bir bileşen.`,
|
||||
generateDescription: `LLM\'yi yanıt üretmeye yönlendiren bir bileşen.`,
|
||||
generateDescription: `LLM'yi yanıt üretmeye yönlendiren bir bileşen.`,
|
||||
categorizeDescription: `Kullanıcı girişlerini önceden tanımlanmış kategorilere sınıflandırmak için LLM kullanan bir bileşen.`,
|
||||
relevantDescription: `Yukarı akış çıktısının kullanıcının son sorgusuna uygun olup olmadığını değerlendirmek için LLM kullanan bir bileşen.`,
|
||||
rewriteQuestionDescription: `Önceki diyalogların bağlamına dayanarak Etkileşim bileşeninden bir kullanıcı sorgusunu yeniden yazan bir bileşen.`,
|
||||
@@ -1802,7 +1802,7 @@ En uygun olduğu durumlar: Anlatı bütünlüğünün bitişik paragrafları bir
|
||||
'Bu bileşen, önceden tanımlanmış mesaj içeriğiyle birlikte iş akışının nihai veri çıktısını döndürür.',
|
||||
keywordDescription: `Kullanıcının girdisinden en fazla N arama sonucunu alan bir bileşen.`,
|
||||
switchDescription: `Önceki bileşenlerin çıktısına göre koşulları değerlendiren ve yürütme akışını yönlendiren bir bileşen.`,
|
||||
wikipediaDescription: `wikipedia.org\'dan arama yapan bir bileşen.`,
|
||||
wikipediaDescription: `wikipedia.org'dan arama yapan bir bileşen.`,
|
||||
promptText: `Lütfen aşağıdaki paragrafları özetleyin. Sayılara dikkat edin, uydurma yapmayın. Paragraflar aşağıdaki gibidir:
|
||||
{input}
|
||||
Yukarısı özetlemeniz gereken içeriktir.`,
|
||||
@@ -1825,7 +1825,7 @@ En uygun olduğu durumlar: Anlatı bütünlüğünün bitişik paragrafları bir
|
||||
keywordExtract: 'Anahtar kelime',
|
||||
keywordExtractDescription: `Bir kullanıcı sorgusundan anahtar kelimeler çıkaran bir bileşen.`,
|
||||
baidu: 'Baidu',
|
||||
baiduDescription: `baidu.com\'dan arama yapan bir bileşen.`,
|
||||
baiduDescription: `baidu.com'dan arama yapan bir bileşen.`,
|
||||
duckDuckGo: 'DuckDuckGo',
|
||||
duckDuckGoDescription: "duckduckgo.com'dan arama yapan bir bileşen.",
|
||||
searXNG: 'SearXNG',
|
||||
@@ -2693,7 +2693,7 @@ Temel Talimatlar:
|
||||
changeStepModalContent: `
|
||||
<p>Şu anda bu aşamanın sonuçlarını düzenliyorsunuz.</p>
|
||||
<p>Daha sonraki bir aşamaya geçerseniz değişiklikleriniz kaybolacak.</p>
|
||||
<p>Korumak için lütfen Yeniden Çalıştır\'a tıklayın.</p>`,
|
||||
<p>Korumak için lütfen Yeniden Çalıştır'a tıklayın.</p>`,
|
||||
changeStepModalConfirmText: 'Yine de Geç',
|
||||
changeStepModalCancelText: 'İptal',
|
||||
unlinkPipelineModalTitle: 'Alım hattı bağlantısını kes',
|
||||
|
||||
@@ -369,7 +369,7 @@ export default function VariablePickerMenuPlugin({
|
||||
const filterStructuredOutput = useGetStructuredOutputByValue();
|
||||
|
||||
const testTriggerFn = React.useCallback((text: string) => {
|
||||
const triggerRegex = /(^|\s|\()([/]((?:[^/\s\()])*))$/;
|
||||
const triggerRegex = /(^|\s|\()([/]((?:[^/\s()])*))$/;
|
||||
const match = triggerRegex.exec(text);
|
||||
|
||||
if (match !== null) {
|
||||
|
||||
@@ -75,7 +75,7 @@ const GroupStartNodeMap = {
|
||||
name: Operator.IterationStart,
|
||||
form: initialIterationStartValues,
|
||||
},
|
||||
extent: 'parent' as 'parent',
|
||||
extent: 'parent' as const,
|
||||
},
|
||||
[Operator.Loop]: {
|
||||
id: `${Operator.LoopStart}:${humanId()}`,
|
||||
@@ -86,7 +86,7 @@ const GroupStartNodeMap = {
|
||||
name: Operator.LoopStart,
|
||||
form: {},
|
||||
},
|
||||
extent: 'parent' as 'parent',
|
||||
extent: 'parent' as const,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -55,7 +55,6 @@ export interface TimelineDataFlowProps {
|
||||
const TimelineDataFlow = ({
|
||||
activeFunc,
|
||||
activeId,
|
||||
data,
|
||||
timelineNodes,
|
||||
}: TimelineDataFlowProps) => {
|
||||
// const [timelineNodeArr,setTimelineNodeArr] = useState<ITimelineNodeObj & {id: number | string}>()
|
||||
|
||||
@@ -27,13 +27,20 @@ export default function ChatBasicSetting() {
|
||||
const form = useFormContext();
|
||||
const emptyResponseValue = form.watch('prompt_config.empty_response');
|
||||
const prologueValue = form.watch('prompt_config.prologue');
|
||||
const kbIds = (useWatch({ control: form.control, name: 'dataset_ids' }) ||
|
||||
[]) as string[];
|
||||
const rawDatasetIds = useWatch({
|
||||
control: form.control,
|
||||
name: 'dataset_ids',
|
||||
});
|
||||
const kbIds = useMemo(
|
||||
() => (rawDatasetIds || []) as string[],
|
||||
[rawDatasetIds],
|
||||
);
|
||||
const metadataInclude = useWatch({
|
||||
control: form.control,
|
||||
name: 'prompt_config.reference_metadata.include',
|
||||
});
|
||||
const { data: metadataKeys } = useFetchKnowledgeMetadataKeys(kbIds);
|
||||
const { data: metadataKeys, loading: metadataKeysLoading } =
|
||||
useFetchKnowledgeMetadataKeys(kbIds);
|
||||
const metadataFieldOptions = useMemo(() => {
|
||||
return (metadataKeys || []).map((key) => ({
|
||||
label: key,
|
||||
@@ -60,7 +67,7 @@ export default function ChatBasicSetting() {
|
||||
} else if (!metadataInclude) {
|
||||
form.setValue('prompt_config.reference_metadata.fields', undefined);
|
||||
}
|
||||
}, [kbIds, metadataKeys, metadataInclude, form]);
|
||||
}, [kbIds, metadataKeys, metadataKeysLoading, metadataInclude, form]);
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@@ -176,4 +183,4 @@ export default function ChatBasicSetting() {
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -90,10 +90,13 @@ const MarkdownContent = ({
|
||||
chunk: IReferenceChunk,
|
||||
isPdf: boolean = false,
|
||||
documentUrl?: string,
|
||||
) =>
|
||||
() => {
|
||||
) => {
|
||||
void isPdf;
|
||||
void documentUrl;
|
||||
return () => {
|
||||
clickDocumentButton?.(documentId, chunk);
|
||||
},
|
||||
};
|
||||
},
|
||||
[clickDocumentButton],
|
||||
);
|
||||
|
||||
@@ -250,9 +253,7 @@ const MarkdownContent = ({
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
components={
|
||||
{
|
||||
p: ({ children, node, ...props }: any) => (
|
||||
<p {...props}>{children}</p>
|
||||
),
|
||||
p: ({ children, ...props }: any) => <p {...props}>{children}</p>,
|
||||
'custom-typography': ({ children }: { children: string }) =>
|
||||
renderReference(children),
|
||||
code(props: any) {
|
||||
|
||||
@@ -217,9 +217,8 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
|
||||
control: formMethods.control,
|
||||
name: 'search_config.reference_metadata.include',
|
||||
});
|
||||
const { data: metadataKeys } = useFetchKnowledgeMetadataKeys(
|
||||
selectedKbIds || [],
|
||||
);
|
||||
const { data: metadataKeys, loading: metadataKeysLoading } =
|
||||
useFetchKnowledgeMetadataKeys(selectedKbIds || []);
|
||||
const metadataFieldOptions = useMemo(() => {
|
||||
return (metadataKeys || []).map((key) => ({
|
||||
label: key,
|
||||
@@ -252,7 +251,13 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
}, [selectedKbIds, metadataKeys, referenceMetadataEnabled, formMethods]);
|
||||
}, [
|
||||
selectedKbIds,
|
||||
metadataKeys,
|
||||
metadataKeysLoading,
|
||||
referenceMetadataEnabled,
|
||||
formMethods,
|
||||
]);
|
||||
|
||||
// Reset top_k to 1024 only when user actively disables rerank (from true to false)
|
||||
const prevRerankEnabled = useRef<boolean | undefined>(undefined);
|
||||
|
||||
@@ -61,20 +61,20 @@ const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ content }) => {
|
||||
const language = match ? match[1] : '';
|
||||
|
||||
if (language) {
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
style={isDarkTheme ? oneDark : oneLight}
|
||||
language={language}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
backgroundColor: 'var(--bg-component)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '1em',
|
||||
}}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
style={isDarkTheme ? oneDark : oneLight}
|
||||
language={language}
|
||||
PreTag="div"
|
||||
customStyle={{
|
||||
backgroundColor: 'var(--bg-component)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '1em',
|
||||
}}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -103,9 +103,11 @@ const SkillDetail: React.FC<SkillDetailProps> = ({
|
||||
const [versionFiles, setVersionFiles] = useState<SkillFileEntry[]>([]);
|
||||
const [versionLoading, setVersionLoading] = useState(false);
|
||||
|
||||
// Check if skill has multiple versions
|
||||
const hasVersions = skill?.versions && skill.versions.length > 0;
|
||||
const availableVersions = skill?.versions || [];
|
||||
const availableVersions = useMemo(
|
||||
() => skill?.versions ?? [],
|
||||
[skill?.versions],
|
||||
);
|
||||
const hasVersions = availableVersions.length > 0;
|
||||
|
||||
// Reset state when skill changes or drawer opens/closes
|
||||
useEffect(() => {
|
||||
@@ -128,20 +130,14 @@ const SkillDetail: React.FC<SkillDetailProps> = ({
|
||||
setSelectedFile(null);
|
||||
setFileContent('');
|
||||
}
|
||||
}, [
|
||||
open,
|
||||
skill?.id,
|
||||
hasVersions,
|
||||
skill?.metadata?.version,
|
||||
availableVersions,
|
||||
]);
|
||||
}, [open, skill, hasVersions, availableVersions]);
|
||||
|
||||
const resolvedVersion = useMemo(() => {
|
||||
if (!skill) return '';
|
||||
return (
|
||||
selectedVersion || skill.metadata?.version || skill.versions?.[0] || ''
|
||||
);
|
||||
}, [selectedVersion, skill?.id, skill?.metadata?.version, skill?.versions]);
|
||||
}, [selectedVersion, skill]);
|
||||
|
||||
// Load files when version or skill changes
|
||||
useEffect(() => {
|
||||
@@ -204,16 +200,7 @@ const SkillDetail: React.FC<SkillDetailProps> = ({
|
||||
return () => {
|
||||
isActive = false;
|
||||
};
|
||||
}, [
|
||||
skill?.id,
|
||||
skill?.source_type,
|
||||
skill?.metadata?.version,
|
||||
skill?.versions,
|
||||
(skill as any)?._folderId,
|
||||
skill?.files,
|
||||
resolvedVersion,
|
||||
getVersionFiles,
|
||||
]);
|
||||
}, [skill, resolvedVersion, getVersionFiles]);
|
||||
|
||||
// Use version files if available, otherwise use skill.files
|
||||
const currentFiles = useMemo(() => {
|
||||
@@ -248,7 +235,7 @@ const SkillDetail: React.FC<SkillDetailProps> = ({
|
||||
);
|
||||
setFileContent(content || '');
|
||||
} catch (error) {
|
||||
console.error('Failed to load file content');
|
||||
console.error('Failed to load file content', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -274,7 +261,7 @@ const SkillDetail: React.FC<SkillDetailProps> = ({
|
||||
handleSelect({ id: targetFile.path } as TreeDataItem);
|
||||
}
|
||||
}
|
||||
}, [open, skill?.id, currentFiles.length]);
|
||||
}, [open, skill, currentFiles, handleSelect, selectedFile]);
|
||||
|
||||
const renderFileContent = () => {
|
||||
if (!selectedFile) {
|
||||
|
||||
@@ -127,7 +127,10 @@ const BoxTokenField = ({ value, onChange }: BoxTokenFieldProps) => {
|
||||
string,
|
||||
any
|
||||
>;
|
||||
const { user_id: _userId, code, ...rest } = credentials;
|
||||
const code = credentials.code;
|
||||
const rest = { ...credentials };
|
||||
delete rest.user_id;
|
||||
delete rest.code;
|
||||
|
||||
const finalValue: Record<string, any> = {
|
||||
...rest,
|
||||
@@ -173,7 +176,7 @@ const BoxTokenField = ({ value, onChange }: BoxTokenFieldProps) => {
|
||||
setWebStatus('error');
|
||||
setWebStatusMessage(errorMessage);
|
||||
clearWebState();
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
message.error('Unable to retrieve authorization result.');
|
||||
setWebStatus('error');
|
||||
setWebStatusMessage('Unable to retrieve authorization result.');
|
||||
@@ -304,7 +307,7 @@ const BoxTokenField = ({ value, onChange }: BoxTokenFieldProps) => {
|
||||
} else {
|
||||
message.error(data.message || 'Failed to start Box authorization.');
|
||||
}
|
||||
} catch (_error) {
|
||||
} catch {
|
||||
message.error('Failed to start Box authorization.');
|
||||
} finally {
|
||||
setSubmitLoading(false);
|
||||
|
||||
@@ -95,11 +95,7 @@ const withRedirectUri = (credentials: string, redirectUri: string): string => {
|
||||
});
|
||||
};
|
||||
|
||||
const GmailTokenField = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
}: GmailTokenFieldProps) => {
|
||||
const GmailTokenField = ({ value, onChange }: GmailTokenFieldProps) => {
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [pendingCredentials, setPendingCredentials] = useState<string>('');
|
||||
const [redirectUri, setRedirectUri] = useState('');
|
||||
@@ -195,7 +191,7 @@ const GmailTokenField = ({
|
||||
}
|
||||
message.error(data.message || 'Authorization failed.');
|
||||
clearWebState();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
message.error('Unable to retrieve authorization result.');
|
||||
clearWebState();
|
||||
}
|
||||
@@ -315,7 +311,7 @@ const GmailTokenField = ({
|
||||
} else {
|
||||
message.error(data.message || 'Failed to start browser authorization.');
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
message.error('Failed to start browser authorization.');
|
||||
} finally {
|
||||
setWebAuthLoading(false);
|
||||
|
||||
@@ -192,7 +192,7 @@ const GoogleDriveTokenField = ({
|
||||
}
|
||||
message.error(data.message || 'Authorization failed.');
|
||||
clearWebState();
|
||||
} catch (err) {
|
||||
} catch {
|
||||
message.error('Unable to retrieve authorization result.');
|
||||
clearWebState();
|
||||
}
|
||||
@@ -312,7 +312,7 @@ const GoogleDriveTokenField = ({
|
||||
} else {
|
||||
message.error(data.message || 'Failed to start browser authorization.');
|
||||
}
|
||||
} catch (err) {
|
||||
} catch {
|
||||
message.error('Failed to start browser authorization.');
|
||||
} finally {
|
||||
setWebAuthLoading(false);
|
||||
|
||||
@@ -41,6 +41,7 @@ export enum DataSourceKey {
|
||||
SEAFILE = 'seafile',
|
||||
MYSQL = 'mysql',
|
||||
POSTGRESQL = 'postgresql',
|
||||
REST_API = 'rest_api',
|
||||
RSS = 'rss',
|
||||
|
||||
// SHAREPOINT = 'sharepoint',
|
||||
@@ -202,6 +203,11 @@ export const generateDataSourceInfo = (t: TFunction) => {
|
||||
description: t(`setting.${DataSourceKey.GMAIL}Description`),
|
||||
icon: <SvgIcon name={'data-source/gmail'} width={38} />,
|
||||
},
|
||||
[DataSourceKey.REST_API]: {
|
||||
name: 'REST API',
|
||||
description: t(`setting.${DataSourceKey.REST_API}Description`),
|
||||
icon: <SvgIcon name={'data-source/rest-api'} width={38} />,
|
||||
},
|
||||
[DataSourceKey.MOODLE]: {
|
||||
name: 'Moodle',
|
||||
description: t(`setting.${DataSourceKey.MOODLE}Description`),
|
||||
@@ -373,47 +379,6 @@ export const getCommonExtraDefaultValues = () => ({
|
||||
},
|
||||
});
|
||||
|
||||
export const getDataSourceFieldsWithExtras = (
|
||||
source?: DataSourceKey,
|
||||
): FormFieldConfig[] => {
|
||||
if (!source) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sourceFields =
|
||||
DataSourceFormFields[source as keyof typeof DataSourceFormFields] || [];
|
||||
const extraFields = getCommonExtraFields(source);
|
||||
|
||||
if (source !== DataSourceKey.JIRA) {
|
||||
return [...sourceFields, ...extraFields];
|
||||
}
|
||||
|
||||
const modeFieldIndex = sourceFields.findIndex(
|
||||
(field) => field.name === 'config.is_cloud',
|
||||
);
|
||||
if (modeFieldIndex < 0) {
|
||||
return [...sourceFields, ...extraFields];
|
||||
}
|
||||
|
||||
const sharedFields = sourceFields.slice(0, modeFieldIndex);
|
||||
const modeFields = sourceFields.slice(modeFieldIndex);
|
||||
|
||||
const sharedCheckboxFieldIndex = sharedFields.findIndex(
|
||||
(field) => field.type === FormFieldType.Checkbox,
|
||||
);
|
||||
|
||||
if (sharedCheckboxFieldIndex < 0) {
|
||||
return [...sharedFields, ...extraFields, ...modeFields];
|
||||
}
|
||||
|
||||
return [
|
||||
...sharedFields.slice(0, sharedCheckboxFieldIndex),
|
||||
...sharedFields.slice(sharedCheckboxFieldIndex),
|
||||
...extraFields,
|
||||
...modeFields,
|
||||
];
|
||||
};
|
||||
|
||||
export const DataSourceFormFields = {
|
||||
[DataSourceKey.RSS]: [
|
||||
{
|
||||
@@ -1123,6 +1088,286 @@ export const DataSourceFormFields = {
|
||||
tooltip: t('setting.postgresqlTimestampColumnTip'),
|
||||
},
|
||||
],
|
||||
[DataSourceKey.REST_API]: [
|
||||
// ── Essential fields ──────────────────────────────────────────────
|
||||
{
|
||||
label: 'Base URL',
|
||||
name: 'config.url',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'https://api.example.com/v1/resources',
|
||||
},
|
||||
{
|
||||
label: 'HTTP Method',
|
||||
name: 'config.method',
|
||||
type: FormFieldType.Select,
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'GET', value: 'GET' },
|
||||
{ label: 'POST', value: 'POST' },
|
||||
],
|
||||
defaultValue: 'GET',
|
||||
},
|
||||
{
|
||||
label: 'Query Parameters',
|
||||
name: 'config.query_params',
|
||||
type: FormFieldType.Textarea,
|
||||
required: false,
|
||||
placeholder: `key=value\none_per_line=true`,
|
||||
tooltip: t('setting.restApiQueryParamsTip'),
|
||||
},
|
||||
{
|
||||
label: 'Items Path',
|
||||
name: 'config.items_path',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: '$.items',
|
||||
tooltip: t('setting.restApiItemsPathTip'),
|
||||
},
|
||||
{
|
||||
label: 'ID Field',
|
||||
name: 'config.id_field',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: 'id',
|
||||
tooltip: t('setting.restApiIdFieldTip'),
|
||||
},
|
||||
{
|
||||
label: 'Auth Type',
|
||||
name: 'config.auth_type',
|
||||
type: FormFieldType.Select,
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'API Key (Header)', value: 'api_key_header' },
|
||||
{ label: 'Bearer Token', value: 'bearer' },
|
||||
{ label: 'Basic Auth', value: 'basic' },
|
||||
],
|
||||
defaultValue: 'none',
|
||||
},
|
||||
{
|
||||
label: 'API Key Header Name',
|
||||
name: 'config.auth_config.header_name',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: 'X-API-Key',
|
||||
shouldRender: (values: any) =>
|
||||
values?.config?.auth_type === 'api_key_header',
|
||||
customValidate: (val: string, values: any) => {
|
||||
if (
|
||||
values?.config?.auth_type === 'api_key_header' &&
|
||||
!(val != null && String(val).trim())
|
||||
) {
|
||||
return t('setting.restApiValidationApiKeyHeaderNameRequired');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'API Key Value',
|
||||
name: 'config.credentials.api_key',
|
||||
type: FormFieldType.Password,
|
||||
required: false,
|
||||
shouldRender: (values: any) =>
|
||||
values?.config?.auth_type === 'api_key_header',
|
||||
customValidate: (val: string, values: any) => {
|
||||
if (values?.config?.auth_type === 'api_key_header' && !val) {
|
||||
return t('setting.restApiValidationApiKeyRequired');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Bearer Token',
|
||||
name: 'config.credentials.token',
|
||||
type: FormFieldType.Password,
|
||||
required: false,
|
||||
shouldRender: (values: any) => values?.config?.auth_type === 'bearer',
|
||||
customValidate: (val: string, values: any) => {
|
||||
if (values?.config?.auth_type === 'bearer' && !val) {
|
||||
return t('setting.restApiValidationBearerTokenRequired');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Username',
|
||||
name: 'config.credentials.username',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
shouldRender: (values: any) => values?.config?.auth_type === 'basic',
|
||||
customValidate: (val: string, values: any) => {
|
||||
if (
|
||||
values?.config?.auth_type === 'basic' &&
|
||||
!(val != null && String(val).trim())
|
||||
) {
|
||||
return t('setting.restApiValidationBasicUsernameRequired');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Password',
|
||||
name: 'config.credentials.password',
|
||||
type: FormFieldType.Password,
|
||||
required: false,
|
||||
shouldRender: (values: any) => values?.config?.auth_type === 'basic',
|
||||
customValidate: (val: string, values: any) => {
|
||||
if (values?.config?.auth_type === 'basic' && !val) {
|
||||
return t('setting.restApiValidationBasicPasswordRequired');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Content Fields',
|
||||
name: 'config.content_fields',
|
||||
type: FormFieldType.Text,
|
||||
required: true,
|
||||
placeholder: 'title,body',
|
||||
tooltip: t('setting.restApiContentFieldsTip'),
|
||||
},
|
||||
{
|
||||
label: 'Metadata Fields',
|
||||
name: 'config.metadata_fields',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: 'author,category',
|
||||
tooltip: t('setting.restApiMetadataFieldsTip'),
|
||||
},
|
||||
{
|
||||
label: 'Pagination Type',
|
||||
name: 'config.pagination_type',
|
||||
type: FormFieldType.Select,
|
||||
required: true,
|
||||
options: [
|
||||
{ label: 'None', value: 'none' },
|
||||
{ label: 'Page', value: 'page' },
|
||||
{ label: 'Offset', value: 'offset' },
|
||||
{ label: 'Cursor', value: 'cursor' },
|
||||
],
|
||||
defaultValue: 'none',
|
||||
},
|
||||
{
|
||||
label: 'Start Page',
|
||||
name: 'config.pagination_config.start_page',
|
||||
type: FormFieldType.Number,
|
||||
required: false,
|
||||
defaultValue: 1,
|
||||
shouldRender: (values: any) => values?.config?.pagination_type === 'page',
|
||||
},
|
||||
{
|
||||
label: 'Offset Param',
|
||||
name: 'config.pagination_config.offset_param',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
defaultValue: 'offset',
|
||||
shouldRender: (values: any) =>
|
||||
values?.config?.pagination_type === 'offset',
|
||||
},
|
||||
{
|
||||
label: 'Start Offset',
|
||||
name: 'config.pagination_config.start_offset',
|
||||
type: FormFieldType.Number,
|
||||
required: false,
|
||||
defaultValue: 0,
|
||||
shouldRender: (values: any) =>
|
||||
values?.config?.pagination_type === 'offset',
|
||||
},
|
||||
{
|
||||
label: 'Cursor Param',
|
||||
name: 'config.pagination_config.cursor_param',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
defaultValue: 'cursor',
|
||||
shouldRender: (values: any) =>
|
||||
values?.config?.pagination_type === 'cursor',
|
||||
},
|
||||
{
|
||||
label: 'Next Cursor JSONPath',
|
||||
name: 'config.pagination_config.next_cursor_path',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: '$.next_cursor',
|
||||
shouldRender: (values: any) =>
|
||||
values?.config?.pagination_type === 'cursor',
|
||||
tooltip: t('setting.restApiNextCursorPathTip'),
|
||||
},
|
||||
// ── Advanced settings toggle ──────────────────────────────────────
|
||||
{
|
||||
label: 'Advanced Settings',
|
||||
name: 'config.show_advanced',
|
||||
type: FormFieldType.Switch,
|
||||
required: false,
|
||||
defaultValue: false,
|
||||
},
|
||||
// ── Advanced fields (hidden until toggled) ────────────────────────
|
||||
{
|
||||
label: 'Custom Headers (JSON)',
|
||||
name: 'config.headers',
|
||||
type: FormFieldType.Textarea,
|
||||
required: false,
|
||||
placeholder: `{"X-Custom-Header": "value"}`,
|
||||
tooltip: t('setting.restApiHeadersTip'),
|
||||
shouldRender: (values: any) => !!values?.config?.show_advanced,
|
||||
},
|
||||
{
|
||||
label: 'Limit Param',
|
||||
name: 'config.pagination_config.limit_param',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: 'limit (leave empty if already in Query Parameters)',
|
||||
shouldRender: (values: any) =>
|
||||
!!values?.config?.show_advanced &&
|
||||
values?.config?.pagination_type === 'offset',
|
||||
},
|
||||
{
|
||||
label: 'Initial Cursor',
|
||||
name: 'config.pagination_config.initial_cursor',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
shouldRender: (values: any) =>
|
||||
!!values?.config?.show_advanced &&
|
||||
values?.config?.pagination_type === 'cursor',
|
||||
},
|
||||
{
|
||||
label: 'Max Pages',
|
||||
name: 'config.max_pages',
|
||||
type: FormFieldType.Number,
|
||||
required: false,
|
||||
defaultValue: 1000,
|
||||
shouldRender: (values: any) => !!values?.config?.show_advanced,
|
||||
},
|
||||
{
|
||||
label: 'Request Delay (seconds)',
|
||||
name: 'config.request_delay',
|
||||
type: FormFieldType.Number,
|
||||
required: false,
|
||||
defaultValue: 0.5,
|
||||
placeholder: '0.5',
|
||||
tooltip: t('setting.restApiRequestDelayTip'),
|
||||
shouldRender: (values: any) => !!values?.config?.show_advanced,
|
||||
},
|
||||
{
|
||||
label: 'Poll Timestamp Field',
|
||||
name: 'config.poll_timestamp_field',
|
||||
type: FormFieldType.Text,
|
||||
required: false,
|
||||
placeholder: 'updated_at',
|
||||
tooltip: t('setting.restApiPollTimestampFieldTip'),
|
||||
shouldRender: (values: any) => !!values?.config?.show_advanced,
|
||||
},
|
||||
{
|
||||
label: 'Request Body (POST) JSON',
|
||||
name: 'config.request_body',
|
||||
type: FormFieldType.Textarea,
|
||||
required: false,
|
||||
placeholder: `{"status": "published"}`,
|
||||
tooltip: t('setting.restApiRequestBodyTip'),
|
||||
shouldRender: (values: any) =>
|
||||
!!values?.config?.show_advanced && values?.config?.method === 'POST',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const DataSourceFormDefaultValues = {
|
||||
@@ -1477,4 +1722,74 @@ export const DataSourceFormDefaultValues = {
|
||||
},
|
||||
},
|
||||
},
|
||||
[DataSourceKey.REST_API]: {
|
||||
name: '',
|
||||
source: DataSourceKey.REST_API,
|
||||
config: {
|
||||
url: '',
|
||||
method: 'GET',
|
||||
query_params: '',
|
||||
headers: '',
|
||||
auth_type: 'none',
|
||||
auth_config: {},
|
||||
items_path: '',
|
||||
id_field: '',
|
||||
content_fields: '',
|
||||
metadata_fields: '',
|
||||
pagination_type: 'none',
|
||||
pagination_config: {},
|
||||
poll_timestamp_field: '',
|
||||
request_body: '',
|
||||
max_pages: 1000,
|
||||
request_delay: 0.5,
|
||||
show_advanced: false,
|
||||
credentials: {
|
||||
api_key: '',
|
||||
token: '',
|
||||
username: '',
|
||||
password: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const getDataSourceFieldsWithExtras = (
|
||||
source?: DataSourceKey,
|
||||
): FormFieldConfig[] => {
|
||||
if (!source) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sourceFields =
|
||||
DataSourceFormFields[source as keyof typeof DataSourceFormFields] || [];
|
||||
const extraFields = getCommonExtraFields(source);
|
||||
|
||||
if (source !== DataSourceKey.JIRA) {
|
||||
return [...sourceFields, ...extraFields];
|
||||
}
|
||||
|
||||
const modeFieldIndex = sourceFields.findIndex(
|
||||
(field) => field.name === 'config.is_cloud',
|
||||
);
|
||||
if (modeFieldIndex < 0) {
|
||||
return [...sourceFields, ...extraFields];
|
||||
}
|
||||
|
||||
const sharedFields = sourceFields.slice(0, modeFieldIndex);
|
||||
const modeFields = sourceFields.slice(modeFieldIndex);
|
||||
|
||||
const sharedCheckboxFieldIndex = sharedFields.findIndex(
|
||||
(field) => field.type === FormFieldType.Checkbox,
|
||||
);
|
||||
|
||||
if (sharedCheckboxFieldIndex < 0) {
|
||||
return [...sharedFields, ...extraFields, ...modeFields];
|
||||
}
|
||||
|
||||
return [
|
||||
...sharedFields.slice(0, sharedCheckboxFieldIndex),
|
||||
...sharedFields.slice(sharedCheckboxFieldIndex),
|
||||
...extraFields,
|
||||
...modeFields,
|
||||
];
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ import { FieldValues } from 'react-hook-form';
|
||||
import {
|
||||
DataSourceFormBaseFields,
|
||||
DataSourceFormDefaultValues,
|
||||
DataSourceKey,
|
||||
getCommonExtraDefaultValues,
|
||||
getDataSourceFieldsWithExtras,
|
||||
mergeDataSourceFormValues,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
useAddDataSource,
|
||||
useDataSourceResume,
|
||||
useFetchDataSourceDetail,
|
||||
useTestDataSource,
|
||||
} from '../hooks';
|
||||
import { DataSourceLogsTable } from './log-table';
|
||||
|
||||
@@ -144,6 +146,7 @@ const SourceDetailPage = () => {
|
||||
}, [detail, runSchedule]);
|
||||
|
||||
const { addLoading, handleAddOk } = useAddDataSource({ isEdit: true });
|
||||
const { loading: testLoading, handleTest } = useTestDataSource();
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
formRef?.current?.submit();
|
||||
@@ -187,7 +190,6 @@ const SourceDetailPage = () => {
|
||||
detail as FieldValues,
|
||||
),
|
||||
};
|
||||
console.log('defaultValue', defaultValueTemp);
|
||||
setDefaultValues(defaultValueTemp);
|
||||
}
|
||||
}, [detail, customFields, onSubmit]);
|
||||
@@ -213,7 +215,18 @@ const SourceDetailPage = () => {
|
||||
defaultValues={defaultValues}
|
||||
/>
|
||||
</div>
|
||||
<div className="max-w-[1200px] flex justify-end">
|
||||
<div className="max-w-[1200px] flex justify-end gap-2">
|
||||
{detail?.source === DataSourceKey.REST_API && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={testLoading}
|
||||
loading={testLoading}
|
||||
>
|
||||
{t('setting.restApiTestConnection')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onSubmit}
|
||||
|
||||
@@ -8,6 +8,7 @@ import dataSourceService, {
|
||||
deleteDataSource,
|
||||
featchDataSourceDetail,
|
||||
getDataSourceLogs,
|
||||
testDataSource,
|
||||
} from '@/services/data-source-service';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
@@ -213,3 +214,28 @@ export const useDataSourceRebuild = () => {
|
||||
);
|
||||
return { handleRebuild };
|
||||
};
|
||||
|
||||
export const useTestDataSource = () => {
|
||||
const [currentQueryParameters] = useSearchParams();
|
||||
const id = currentQueryParameters.get('id');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleTest = useCallback(async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await testDataSource(id);
|
||||
if (data.code === 0) {
|
||||
message.success(t('setting.restApiTestSuccess'));
|
||||
} else {
|
||||
message.error(data.message || t('setting.restApiTestFailed'));
|
||||
}
|
||||
} catch {
|
||||
message.error(t('setting.restApiTestFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
return { loading, handleTest };
|
||||
};
|
||||
|
||||
@@ -37,6 +37,9 @@ export const getDataSourceLogs = (id: string, params?: any) =>
|
||||
export const featchDataSourceDetail = (id: string) =>
|
||||
request.get(api.dataSourceDetail(id));
|
||||
|
||||
export const testDataSource = (id: string) =>
|
||||
request.post(api.dataSourceTest(id));
|
||||
|
||||
export const startGoogleDriveWebAuth = (payload: {
|
||||
credentials: string;
|
||||
redirect_uri?: string;
|
||||
|
||||
@@ -133,19 +133,12 @@ class SkillSpaceService {
|
||||
|
||||
// Create a new skill space
|
||||
async createSpace(request: CreateSpaceRequest): Promise<SkillSpace> {
|
||||
return await this.request<SkillSpace>(
|
||||
'POST',
|
||||
api.skillSpaces,
|
||||
request,
|
||||
);
|
||||
return await this.request<SkillSpace>('POST', api.skillSpaces, request);
|
||||
}
|
||||
|
||||
// Get a skill space by ID
|
||||
async getSpace(spaceId: string): Promise<SkillSpace> {
|
||||
return await this.request<SkillSpace>(
|
||||
'GET',
|
||||
api.skillSpace(spaceId),
|
||||
);
|
||||
return await this.request<SkillSpace>('GET', api.skillSpace(spaceId));
|
||||
}
|
||||
|
||||
// Update a skill space
|
||||
@@ -162,20 +155,14 @@ class SkillSpaceService {
|
||||
|
||||
// Delete a skill space
|
||||
async deleteSpace(spaceId: string): Promise<void> {
|
||||
await this.request<void>(
|
||||
'DELETE',
|
||||
api.skillSpace(spaceId),
|
||||
);
|
||||
await this.request<void>('DELETE', api.skillSpace(spaceId));
|
||||
}
|
||||
|
||||
// Get space by folder ID
|
||||
async getSpaceByFolder(folderId: string): Promise<SkillSpace> {
|
||||
return await this.request<SkillSpace>(
|
||||
'GET',
|
||||
api.skillSpaceByFolder,
|
||||
null,
|
||||
{ folder_id: folderId },
|
||||
);
|
||||
return await this.request<SkillSpace>('GET', api.skillSpaceByFolder, null, {
|
||||
folder_id: folderId,
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Skill Search Config ====================
|
||||
@@ -210,11 +197,7 @@ class SkillSpaceService {
|
||||
|
||||
// Search skills
|
||||
async search(request: SearchRequest): Promise<SearchResult> {
|
||||
return await this.request<SearchResult>(
|
||||
'POST',
|
||||
api.skillSearch,
|
||||
request,
|
||||
);
|
||||
return await this.request<SearchResult>('POST', api.skillSearch, request);
|
||||
}
|
||||
|
||||
// ==================== Skill Indexing ====================
|
||||
@@ -235,21 +218,12 @@ class SkillSpaceService {
|
||||
const params: Record<string, string> = { skill_id: skillId };
|
||||
if (spaceId) params.space_id = spaceId;
|
||||
|
||||
await this.request<void>(
|
||||
'DELETE',
|
||||
api.skillIndex,
|
||||
null,
|
||||
params,
|
||||
);
|
||||
await this.request<void>('DELETE', api.skillIndex, null, params);
|
||||
}
|
||||
|
||||
// Reindex all skills
|
||||
async reindex(request: IndexSkillsRequest): Promise<any> {
|
||||
return await this.request<any>(
|
||||
'POST',
|
||||
api.skillReindex,
|
||||
request,
|
||||
);
|
||||
return await this.request<any>('POST', api.skillReindex, request);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ export default {
|
||||
dataSourceRebuild: (id: string) => `${restAPIv1}/connectors/${id}/rebuild`,
|
||||
dataSourceLogs: (id: string) => `${restAPIv1}/connectors/${id}/logs`,
|
||||
dataSourceDetail: (id: string) => `${restAPIv1}/connectors/${id}`,
|
||||
dataSourceTest: (id: string) => `${restAPIv1}/connectors/${id}/test`,
|
||||
googleWebAuthStart: (type: 'google-drive' | 'gmail') =>
|
||||
`${restAPIv1}/connectors/google/oauth/web/start?type=${type}`,
|
||||
googleWebAuthResult: (type: 'google-drive' | 'gmail') =>
|
||||
|
||||
Reference in New Issue
Block a user