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:
Ahmad Intisar
2026-05-13 17:35:01 +05:00
committed by GitHub
parent 30d1c1dc28
commit e994051eb9
34 changed files with 2338 additions and 167 deletions

View File

@@ -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

View File

@@ -117,6 +117,7 @@ class FileSource(StrEnum):
RSS = "rss"
S3 = "s3"
NOTION = "notion"
REST_API = "rest_api"
DISCORD = "discord"
CONFLUENCE = "confluence"
GMAIL = "gmail"

View File

@@ -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",
]

View File

@@ -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"

File diff suppressed because it is too large Load Diff

View File

@@ -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,
}

View File

View 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

View 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({})

View 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()

View 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

View File

@@ -41,7 +41,7 @@ export const PptPreviewer: React.FC<PptPreviewerProps> = ({
});
pptxPrviewer.preview(arrayBuffer);
}
} catch (err) {
} catch {
message.error('ppt parse failed');
}
};

View File

@@ -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>;

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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',

View File

@@ -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',

View File

@@ -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) {

View File

@@ -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,
},
};

View File

@@ -55,7 +55,6 @@ export interface TimelineDataFlowProps {
const TimelineDataFlow = ({
activeFunc,
activeId,
data,
timelineNodes,
}: TimelineDataFlowProps) => {
// const [timelineNodeArr,setTimelineNodeArr] = useState<ITimelineNodeObj & {id: number | string}>()

View File

@@ -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>
);
}
}

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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 (

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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,
];
};

View File

@@ -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}

View File

@@ -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 };
};

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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') =>