Compare commits

...

10 Commits

Author SHA1 Message Date
fa9b7b259c Feat: create datasets from http api supports ingestion pipeline (#11597)
### What problem does this PR solve?

Feat: create datasets from http api supports ingestion pipeline

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
2025-11-28 19:55:24 +08:00
14616cf845 Feat: add child parent chunking method in backend. (#11598)
### What problem does this PR solve?

#7996

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
2025-11-28 19:25:32 +08:00
d2915f6984 Fix: Error 102 "Can't find dialog by ID" when embedding agent with from=agent** #11552 (#11594)
### What problem does this PR solve?

Fix: Error 102 "Can't find dialog by ID" when embedding agent with
from=agent** #11552

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
2025-11-28 19:05:43 +08:00
ccce8beeeb Feat: Replace antd in the chat message with shadcn. #10427 (#11590)
### What problem does this PR solve?

Feat: Replace antd in the chat message with shadcn. #10427

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
2025-11-28 17:15:01 +08:00
3d2e0f1a1b fix: tolerate null mergeable status in tests workflow 2025-11-28 17:09:58 +08:00
918d5a9ff8 [issue-11572]fix:metadata_condition filtering failed (#11573)
### What problem does this PR solve?

When using the 'metadata_condition' for metadata filtering, if no
documents match the filtering criteria, the system will return the
search results of all documents instead of returning an empty result.

When the metadata_condition has conditions but no matching documents,
simply return an empty result.
#11572

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)

Co-authored-by: Chenguang Wang <chenguangwang@deepglint.com>
2025-11-28 14:04:14 +08:00
7d05d4ced7 Fix: Added styles for empty states on the page. #10703 (#11588)
### What problem does this PR solve?

Fix: Added styles for empty states on the page.
### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
2025-11-28 14:03:20 +08:00
dbdda0fbab Feat: optimize meta filter generation for better structure handling (#11586)
### What problem does this PR solve?

optimize meta filter generation for better structure handling

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
2025-11-28 13:30:53 +08:00
cf7fdd274b Feat: add gmail connector (#11549)
### What problem does this PR solve?

_Briefly describe what this PR aims to solve. Include background context
that will help reviewers understand the purpose of the PR._

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
2025-11-28 13:09:40 +08:00
982ed233a2 Fix: doc_aggs not correctly returned when no chunks retrieved. (#11578)
### What problem does this PR solve?

Fix: doc_aggs not correctly returned when no chunks retrieved.

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
2025-11-28 13:09:05 +08:00
72 changed files with 2214 additions and 764 deletions

View File

@ -31,7 +31,7 @@ jobs:
name: ragflow_tests
# https://docs.github.com/en/actions/using-jobs/using-conditions-to-control-job-execution
# https://github.com/orgs/community/discussions/26261
if: ${{ github.event_name != 'pull_request_target' || (contains(github.event.pull_request.labels.*.name, 'ci') && github.event.pull_request.mergeable == true) }}
if: ${{ github.event_name != 'pull_request_target' || (contains(github.event.pull_request.labels.*.name, 'ci') && github.event.pull_request.mergeable != false) }}
runs-on: [ "self-hosted", "ragflow-test" ]
steps:
# https://github.com/hmarr/debug-action

View File

@ -13,7 +13,6 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
import base64
import json
import logging
import re
@ -25,6 +24,7 @@ from typing import Any, Union, Tuple
from agent.component import component_class
from agent.component.base import ComponentBase
from api.db.services.file_service import FileService
from api.db.services.task_service import has_canceled
from common.misc_utils import get_uuid, hash_str2int
from common.exceptions import TaskCanceledException
@ -372,7 +372,7 @@ class Canvas(Graph):
for k in kwargs.keys():
if k in ["query", "user_id", "files"] and kwargs[k]:
if k == "files":
self.globals[f"sys.{k}"] = self.get_files(kwargs[k])
self.globals[f"sys.{k}"] = FileService.get_files(kwargs[k])
else:
self.globals[f"sys.{k}"] = kwargs[k]
if not self.globals["sys.conversation_turns"] :
@ -621,22 +621,6 @@ class Canvas(Graph):
def get_component_input_elements(self, cpnnm):
return self.components[cpnnm]["obj"].get_input_elements()
def get_files(self, files: Union[None, list[dict]]) -> list[str]:
from api.db.services.file_service import FileService
if not files:
return []
def image_to_base64(file):
return "data:{};base64,{}".format(file["mime_type"],
base64.b64encode(FileService.get_blob(file["created_by"], file["id"])).decode("utf-8"))
exe = ThreadPoolExecutor(max_workers=5)
threads = []
for file in files:
if file["mime_type"].find("image") >=0:
threads.append(exe.submit(image_to_base64, file))
continue
threads.append(exe.submit(FileService.parse, file["name"], FileService.get_blob(file["created_by"], file["id"]), True, file["created_by"]))
return [th.result() for th in threads]
def tool_use_callback(self, agent_id: str, func_name: str, params: dict, result: Any, elapsed_time=None):
agent_ids = agent_id.split("-->")
agent_name = self.get_component_name(agent_ids[0])

View File

@ -14,6 +14,7 @@
# limitations under the License.
#
from agent.component.fillup import UserFillUpParam, UserFillUp
from api.db.services.file_service import FileService
class BeginParam(UserFillUpParam):
@ -48,7 +49,7 @@ class Begin(UserFillUp):
if v.get("optional") and v.get("value", None) is None:
v = None
else:
v = self._canvas.get_files([v["value"]])
v = FileService.get_files([v["value"]])
else:
v = v.get("value")
self.set_output(k, v)

View File

@ -15,13 +15,10 @@
#
import json
import logging
import re
import sys
from functools import partial
import trio
from quart import request, Response, make_response
from agent.component import LLM
from api.db import CanvasCategory, FileType
from api.db import CanvasCategory
from api.db.services.canvas_service import CanvasTemplateService, UserCanvasService, API4ConversationService
from api.db.services.document_service import DocumentService
from api.db.services.file_service import FileService
@ -38,7 +35,6 @@ from peewee import MySQLDatabase, PostgresqlDatabase
from api.db.db_models import APIToken, Task
import time
from api.utils.file_utils import filename_type, read_potential_broken_pdf
from rag.flow.pipeline import Pipeline
from rag.nlp import search
from rag.utils.redis_conn import REDIS_CONN
@ -250,71 +246,10 @@ async def upload(canvas_id):
return get_data_error_result(message="canvas not found.")
user_id = cvs["user_id"]
def structured(filename, filetype, blob, content_type):
nonlocal user_id
if filetype == FileType.PDF.value:
blob = read_potential_broken_pdf(blob)
location = get_uuid()
FileService.put_blob(user_id, location, blob)
return {
"id": location,
"name": filename,
"size": sys.getsizeof(blob),
"extension": filename.split(".")[-1].lower(),
"mime_type": content_type,
"created_by": user_id,
"created_at": time.time(),
"preview_url": None
}
if request.args.get("url"):
from crawl4ai import (
AsyncWebCrawler,
BrowserConfig,
CrawlerRunConfig,
DefaultMarkdownGenerator,
PruningContentFilter,
CrawlResult
)
try:
url = request.args.get("url")
filename = re.sub(r"\?.*", "", url.split("/")[-1])
async def adownload():
browser_config = BrowserConfig(
headless=True,
verbose=False,
)
async with AsyncWebCrawler(config=browser_config) as crawler:
crawler_config = CrawlerRunConfig(
markdown_generator=DefaultMarkdownGenerator(
content_filter=PruningContentFilter()
),
pdf=True,
screenshot=False
)
result: CrawlResult = await crawler.arun(
url=url,
config=crawler_config
)
return result
page = trio.run(adownload())
if page.pdf:
if filename.split(".")[-1].lower() != "pdf":
filename += ".pdf"
return get_json_result(data=structured(filename, "pdf", page.pdf, page.response_headers["content-type"]))
return get_json_result(data=structured(filename, "html", str(page.markdown).encode("utf-8"), page.response_headers["content-type"], user_id))
except Exception as e:
return server_error_response(e)
files = await request.files
file = files['file']
file = files['file'] if files and files.get("file") else None
try:
DocumentService.check_doc_health(user_id, file.filename)
return get_json_result(data=structured(file.filename, filename_type(file.filename), file.read(), file.content_type))
return get_json_result(data=FileService.upload_info(user_id, file, request.args.get("url")))
except Exception as e:
return server_error_response(e)

View File

@ -28,8 +28,8 @@ from api.db import InputType
from api.db.services.connector_service import ConnectorService, SyncLogsService
from api.utils.api_utils import get_data_error_result, get_json_result, validate_request
from common.constants import RetCode, TaskStatus
from common.data_source.config import GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI, DocumentSource
from common.data_source.google_util.constant import GOOGLE_DRIVE_WEB_OAUTH_POPUP_TEMPLATE, GOOGLE_SCOPES
from common.data_source.config import GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI, GMAIL_WEB_OAUTH_REDIRECT_URI, DocumentSource
from common.data_source.google_util.constant import GOOGLE_WEB_OAUTH_POPUP_TEMPLATE, GOOGLE_SCOPES
from common.misc_utils import get_uuid
from rag.utils.redis_conn import REDIS_CONN
from api.apps import login_required, current_user
@ -122,12 +122,30 @@ GOOGLE_WEB_FLOW_RESULT_PREFIX = "google_drive_web_flow_result"
WEB_FLOW_TTL_SECS = 15 * 60
def _web_state_cache_key(flow_id: str) -> str:
return f"{GOOGLE_WEB_FLOW_STATE_PREFIX}:{flow_id}"
def _web_state_cache_key(flow_id: str, source_type: str | None = None) -> str:
"""Return Redis key for web OAuth state.
The default prefix keeps backward compatibility for Google Drive.
When source_type == "gmail", a different prefix is used so that
Drive/Gmail flows don't clash in Redis.
"""
if source_type == "gmail":
prefix = "gmail_web_flow_state"
else:
prefix = GOOGLE_WEB_FLOW_STATE_PREFIX
return f"{prefix}:{flow_id}"
def _web_result_cache_key(flow_id: str) -> str:
return f"{GOOGLE_WEB_FLOW_RESULT_PREFIX}:{flow_id}"
def _web_result_cache_key(flow_id: str, source_type: str | None = None) -> str:
"""Return Redis key for web OAuth result.
Mirrors _web_state_cache_key logic for result storage.
"""
if source_type == "gmail":
prefix = "gmail_web_flow_result"
else:
prefix = GOOGLE_WEB_FLOW_RESULT_PREFIX
return f"{prefix}:{flow_id}"
def _load_credentials(payload: str | dict[str, Any]) -> dict[str, Any]:
@ -146,19 +164,22 @@ def _get_web_client_config(credentials: dict[str, Any]) -> dict[str, Any]:
return {"web": web_section}
async def _render_web_oauth_popup(flow_id: str, success: bool, message: str):
async def _render_web_oauth_popup(flow_id: str, success: bool, message: str, source="drive"):
status = "success" if success else "error"
auto_close = "window.close();" if success else ""
escaped_message = escape(message)
payload_json = json.dumps(
{
"type": "ragflow-google-drive-oauth",
# TODO(google-oauth): include connector type (drive/gmail) in payload type if needed
"type": f"ragflow-google-{source}-oauth",
"status": status,
"flowId": flow_id or "",
"message": message,
}
)
html = GOOGLE_DRIVE_WEB_OAUTH_POPUP_TEMPLATE.format(
# TODO(google-oauth): title/heading/message may need to reflect drive/gmail based on cached type
html = GOOGLE_WEB_OAUTH_POPUP_TEMPLATE.format(
title=f"Google {source.capitalize()} Authorization",
heading="Authorization complete" if success else "Authorization failed",
message=escaped_message,
payload_json=payload_json,
@ -169,20 +190,33 @@ async def _render_web_oauth_popup(flow_id: str, success: bool, message: str):
return response
@manager.route("/google-drive/oauth/web/start", methods=["POST"]) # noqa: F821
@manager.route("/google/oauth/web/start", methods=["POST"]) # noqa: F821
@login_required
@validate_request("credentials")
async def start_google_drive_web_oauth():
if not GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI:
async def start_google_web_oauth():
source = request.args.get("type", "google-drive")
if source not in ("google-drive", "gmail"):
return get_json_result(code=RetCode.ARGUMENT_ERROR, message="Invalid Google OAuth type.")
if source == "gmail":
redirect_uri = GMAIL_WEB_OAUTH_REDIRECT_URI
scopes = GOOGLE_SCOPES[DocumentSource.GMAIL]
else:
redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI if source == "google-drive" else GMAIL_WEB_OAUTH_REDIRECT_URI
scopes = GOOGLE_SCOPES[DocumentSource.GOOGLE_DRIVE if source == "google-drive" else DocumentSource.GMAIL]
if not redirect_uri:
return get_json_result(
code=RetCode.SERVER_ERROR,
message="Google Drive OAuth redirect URI is not configured on the server.",
message="Google OAuth redirect URI is not configured on the server.",
)
req = await request.json or {}
raw_credentials = req.get("credentials", "")
try:
credentials = _load_credentials(raw_credentials)
print(credentials)
except ValueError as exc:
return get_json_result(code=RetCode.ARGUMENT_ERROR, message=str(exc))
@ -199,8 +233,8 @@ async def start_google_drive_web_oauth():
flow_id = str(uuid.uuid4())
try:
flow = Flow.from_client_config(client_config, scopes=GOOGLE_SCOPES[DocumentSource.GOOGLE_DRIVE])
flow.redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI
flow = Flow.from_client_config(client_config, scopes=scopes)
flow.redirect_uri = redirect_uri
authorization_url, _ = flow.authorization_url(
access_type="offline",
include_granted_scopes="true",
@ -219,7 +253,7 @@ async def start_google_drive_web_oauth():
"client_config": client_config,
"created_at": int(time.time()),
}
REDIS_CONN.set_obj(_web_state_cache_key(flow_id), cache_payload, WEB_FLOW_TTL_SECS)
REDIS_CONN.set_obj(_web_state_cache_key(flow_id, source), cache_payload, WEB_FLOW_TTL_SECS)
return get_json_result(
data={
@ -230,60 +264,122 @@ async def start_google_drive_web_oauth():
)
@manager.route("/google-drive/oauth/web/callback", methods=["GET"]) # noqa: F821
async def google_drive_web_oauth_callback():
@manager.route("/gmail/oauth/web/callback", methods=["GET"]) # noqa: F821
async def google_gmail_web_oauth_callback():
state_id = request.args.get("state")
error = request.args.get("error")
source = "gmail"
if source != 'gmail':
return await _render_web_oauth_popup("", False, "Invalid Google OAuth type.", source)
error_description = request.args.get("error_description") or error
if not state_id:
return await _render_web_oauth_popup("", False, "Missing OAuth state parameter.")
return await _render_web_oauth_popup("", False, "Missing OAuth state parameter.", source)
state_cache = REDIS_CONN.get(_web_state_cache_key(state_id))
state_cache = REDIS_CONN.get(_web_state_cache_key(state_id, source))
if not state_cache:
return await _render_web_oauth_popup(state_id, False, "Authorization session expired. Please restart from the main window.")
return await _render_web_oauth_popup(state_id, False, "Authorization session expired. Please restart from the main window.", source)
state_obj = json.loads(state_cache)
client_config = state_obj.get("client_config")
if not client_config:
REDIS_CONN.delete(_web_state_cache_key(state_id))
return await _render_web_oauth_popup(state_id, False, "Authorization session was invalid. Please retry.")
REDIS_CONN.delete(_web_state_cache_key(state_id, source))
return await _render_web_oauth_popup(state_id, False, "Authorization session was invalid. Please retry.", source)
if error:
REDIS_CONN.delete(_web_state_cache_key(state_id))
return await _render_web_oauth_popup(state_id, False, error_description or "Authorization was cancelled.")
REDIS_CONN.delete(_web_state_cache_key(state_id, source))
return await _render_web_oauth_popup(state_id, False, error_description or "Authorization was cancelled.", source)
code = request.args.get("code")
if not code:
return await _render_web_oauth_popup(state_id, False, "Missing authorization code from Google.")
return await _render_web_oauth_popup(state_id, False, "Missing authorization code from Google.", source)
try:
flow = Flow.from_client_config(client_config, scopes=GOOGLE_SCOPES[DocumentSource.GOOGLE_DRIVE])
flow.redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI
# TODO(google-oauth): branch scopes/redirect_uri based on source_type (drive vs gmail)
flow = Flow.from_client_config(client_config, scopes=GOOGLE_SCOPES[DocumentSource.GMAIL])
flow.redirect_uri = GMAIL_WEB_OAUTH_REDIRECT_URI
flow.fetch_token(code=code)
except Exception as exc: # pragma: no cover - defensive
logging.exception("Failed to exchange Google OAuth code: %s", exc)
REDIS_CONN.delete(_web_state_cache_key(state_id))
return await _render_web_oauth_popup(state_id, False, "Failed to exchange tokens with Google. Please retry.")
REDIS_CONN.delete(_web_state_cache_key(state_id, source))
return await _render_web_oauth_popup(state_id, False, "Failed to exchange tokens with Google. Please retry.", source)
creds_json = flow.credentials.to_json()
result_payload = {
"user_id": state_obj.get("user_id"),
"credentials": creds_json,
}
REDIS_CONN.set_obj(_web_result_cache_key(state_id), result_payload, WEB_FLOW_TTL_SECS)
REDIS_CONN.delete(_web_state_cache_key(state_id))
REDIS_CONN.set_obj(_web_result_cache_key(state_id, source), result_payload, WEB_FLOW_TTL_SECS)
return await _render_web_oauth_popup(state_id, True, "Authorization completed successfully.")
print("\n\n", _web_result_cache_key(state_id, source), "\n\n")
REDIS_CONN.delete(_web_state_cache_key(state_id, source))
return await _render_web_oauth_popup(state_id, True, "Authorization completed successfully.", source)
@manager.route("/google-drive/oauth/web/result", methods=["POST"]) # noqa: F821
@manager.route("/google-drive/oauth/web/callback", methods=["GET"]) # noqa: F821
async def google_drive_web_oauth_callback():
state_id = request.args.get("state")
error = request.args.get("error")
source = "google-drive"
if source not in ("google-drive", "gmail"):
return await _render_web_oauth_popup("", False, "Invalid Google OAuth type.", source)
error_description = request.args.get("error_description") or error
if not state_id:
return await _render_web_oauth_popup("", False, "Missing OAuth state parameter.", source)
state_cache = REDIS_CONN.get(_web_state_cache_key(state_id, source))
if not state_cache:
return await _render_web_oauth_popup(state_id, False, "Authorization session expired. Please restart from the main window.", source)
state_obj = json.loads(state_cache)
client_config = state_obj.get("client_config")
if not client_config:
REDIS_CONN.delete(_web_state_cache_key(state_id, source))
return await _render_web_oauth_popup(state_id, False, "Authorization session was invalid. Please retry.", source)
if error:
REDIS_CONN.delete(_web_state_cache_key(state_id, source))
return await _render_web_oauth_popup(state_id, False, error_description or "Authorization was cancelled.", source)
code = request.args.get("code")
if not code:
return await _render_web_oauth_popup(state_id, False, "Missing authorization code from Google.", source)
try:
# TODO(google-oauth): branch scopes/redirect_uri based on source_type (drive vs gmail)
flow = Flow.from_client_config(client_config, scopes=GOOGLE_SCOPES[DocumentSource.GOOGLE_DRIVE])
flow.redirect_uri = GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI
flow.fetch_token(code=code)
except Exception as exc: # pragma: no cover - defensive
logging.exception("Failed to exchange Google OAuth code: %s", exc)
REDIS_CONN.delete(_web_state_cache_key(state_id, source))
return await _render_web_oauth_popup(state_id, False, "Failed to exchange tokens with Google. Please retry.", source)
creds_json = flow.credentials.to_json()
result_payload = {
"user_id": state_obj.get("user_id"),
"credentials": creds_json,
}
REDIS_CONN.set_obj(_web_result_cache_key(state_id, source), result_payload, WEB_FLOW_TTL_SECS)
REDIS_CONN.delete(_web_state_cache_key(state_id, source))
return await _render_web_oauth_popup(state_id, True, "Authorization completed successfully.", source)
@manager.route("/google/oauth/web/result", methods=["POST"]) # noqa: F821
@login_required
@validate_request("flow_id")
async def poll_google_drive_web_result():
async def poll_google_web_result():
req = await request.json or {}
source = request.args.get("type")
if source not in ("google-drive", "gmail"):
return get_json_result(code=RetCode.ARGUMENT_ERROR, message="Invalid Google OAuth type.")
flow_id = req.get("flow_id")
cache_raw = REDIS_CONN.get(_web_result_cache_key(flow_id))
cache_raw = REDIS_CONN.get(_web_result_cache_key(flow_id, source))
if not cache_raw:
return get_json_result(code=RetCode.RUNNING, message="Authorization is still pending.")
@ -291,5 +387,5 @@ async def poll_google_drive_web_result():
if result.get("user_id") != current_user.id:
return get_json_result(code=RetCode.PERMISSION_ERROR, message="You are not allowed to access this authorization result.")
REDIS_CONN.delete(_web_result_cache_key(flow_id))
REDIS_CONN.delete(_web_result_cache_key(flow_id, source))
return get_json_result(data={"credentials": result.get("credentials")})

View File

@ -607,7 +607,7 @@ async def get_image(image_id):
@login_required
@validate_request("conversation_id")
async def upload_and_parse():
files = await request.file
files = await request.files
if "file" not in files:
return get_json_result(data=False, message="No file part!", code=RetCode.ARGUMENT_ERROR)
@ -705,3 +705,12 @@ async def set_meta():
return get_json_result(data=True)
except Exception as e:
return server_error_response(e)
@manager.route("/upload_info", methods=["POST"]) # noqa: F821
async def upload_info():
files = await request.files
file = files['file'] if files and files.get("file") else None
try:
return get_json_result(data=FileService.upload_info(current_user.id, file, request.args.get("url")))
except Exception as e:
return server_error_response(e)

View File

@ -1446,6 +1446,9 @@ async def retrieval_test(tenant_id):
metadata_condition = req.get("metadata_condition", {}) or {}
metas = DocumentService.get_meta_by_kbs(kb_ids)
doc_ids = meta_filter(metas, convert_conditions(metadata_condition), metadata_condition.get("logic", "and"))
# If metadata_condition has conditions but no docs match, return empty result
if not doc_ids and metadata_condition.get("conditions"):
return get_result(data={"total": 0, "chunks": [], "doc_aggs": {}})
if metadata_condition and not doc_ids:
doc_ids = ["-999"]
similarity_threshold = float(req.get("similarity_threshold", 0.2))

View File

@ -121,8 +121,8 @@ async def login():
response_data = user.to_json()
user.access_token = get_uuid()
login_user(user)
user.update_time = (current_timestamp(),)
user.update_date = (datetime_format(datetime.now()),)
user.update_time = current_timestamp()
user.update_date = datetime_format(datetime.now())
user.save()
msg = "Welcome back!"
@ -1002,8 +1002,8 @@ async def forget():
# Auto login (reuse login flow)
user.access_token = get_uuid()
login_user(user)
user.update_time = (current_timestamp(),)
user.update_date = (datetime_format(datetime.now()),)
user.update_time = current_timestamp()
user.update_date = datetime_format(datetime.now())
user.save()
msg = "Password reset successful. Logged in."
return construct_response(data=user.to_json(), auth=user.get_id(), message=msg)

View File

@ -25,6 +25,7 @@ import trio
from langfuse import Langfuse
from peewee import fn
from agentic_reasoning import DeepResearcher
from api.db.services.file_service import FileService
from common.constants import LLMType, ParserType, StatusEnum
from api.db.db_models import DB, Dialog
from api.db.services.common_service import CommonService
@ -380,8 +381,11 @@ def chat(dialog, messages, stream=True, **kwargs):
retriever = settings.retriever
questions = [m["content"] for m in messages if m["role"] == "user"][-3:]
attachments = kwargs["doc_ids"].split(",") if "doc_ids" in kwargs else []
attachments_= ""
if "doc_ids" in messages[-1]:
attachments = messages[-1]["doc_ids"]
if "files" in messages[-1]:
attachments_ = "\n\n".join(FileService.get_files(messages[-1]["files"]))
prompt_config = dialog.prompt_config
field_map = KnowledgebaseService.get_field_map(dialog.kb_ids)
@ -451,7 +455,7 @@ def chat(dialog, messages, stream=True, **kwargs):
),
)
for think in reasoner.thinking(kbinfos, " ".join(questions)):
for think in reasoner.thinking(kbinfos, attachments_ + " ".join(questions)):
if isinstance(think, str):
thought = think
knowledges = [t for t in think.split("\n") if t]
@ -503,7 +507,7 @@ def chat(dialog, messages, stream=True, **kwargs):
kwargs["knowledge"] = "\n------\n" + "\n\n------\n\n".join(knowledges)
gen_conf = dialog.llm_setting
msg = [{"role": "system", "content": prompt_config["system"].format(**kwargs)}]
msg = [{"role": "system", "content": prompt_config["system"].format(**kwargs)+attachments_}]
prompt4citation = ""
if knowledges and (prompt_config.get("quote", True) and kwargs.get("quote", True)):
prompt4citation = citation_prompt()

View File

@ -13,10 +13,15 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
import asyncio
import base64
import logging
import re
import sys
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import Union
from peewee import fn
@ -520,7 +525,7 @@ class FileService(CommonService):
if img_base64 and file_type == FileType.VISUAL.value:
return GptV4.image2base64(blob)
cks = FACTORY.get(FileService.get_parser(filename_type(filename), filename, ""), naive).chunk(filename, blob, **kwargs)
return "\n".join([ck["content_with_weight"] for ck in cks])
return f"\n -----------------\nFile: {filename}\nContent as following: \n" + "\n".join([ck["content_with_weight"] for ck in cks])
@staticmethod
def get_parser(doc_type, filename, default):
@ -588,3 +593,80 @@ class FileService(CommonService):
errors += str(e)
return errors
@staticmethod
def upload_info(user_id, file, url: str|None=None):
def structured(filename, filetype, blob, content_type):
nonlocal user_id
if filetype == FileType.PDF.value:
blob = read_potential_broken_pdf(blob)
location = get_uuid()
FileService.put_blob(user_id, location, blob)
return {
"id": location,
"name": filename,
"size": sys.getsizeof(blob),
"extension": filename.split(".")[-1].lower(),
"mime_type": content_type,
"created_by": user_id,
"created_at": time.time(),
"preview_url": None
}
if url:
from crawl4ai import (
AsyncWebCrawler,
BrowserConfig,
CrawlerRunConfig,
DefaultMarkdownGenerator,
PruningContentFilter,
CrawlResult
)
filename = re.sub(r"\?.*", "", url.split("/")[-1])
async def adownload():
browser_config = BrowserConfig(
headless=True,
verbose=False,
)
async with AsyncWebCrawler(config=browser_config) as crawler:
crawler_config = CrawlerRunConfig(
markdown_generator=DefaultMarkdownGenerator(
content_filter=PruningContentFilter()
),
pdf=True,
screenshot=False
)
result: CrawlResult = await crawler.arun(
url=url,
config=crawler_config
)
return result
page = asyncio.run(adownload())
if page.pdf:
if filename.split(".")[-1].lower() != "pdf":
filename += ".pdf"
return structured(filename, "pdf", page.pdf, page.response_headers["content-type"])
return structured(filename, "html", str(page.markdown).encode("utf-8"), page.response_headers["content-type"], user_id)
DocumentService.check_doc_health(user_id, file.filename)
return structured(file.filename, filename_type(file.filename), file.read(), file.content_type)
@staticmethod
def get_files(self, files: Union[None, list[dict]]) -> list[str]:
if not files:
return []
def image_to_base64(file):
return "data:{};base64,{}".format(file["mime_type"],
base64.b64encode(FileService.get_blob(file["created_by"], file["id"])).decode("utf-8"))
exe = ThreadPoolExecutor(max_workers=5)
threads = []
for file in files:
if file["mime_type"].find("image") >=0:
threads.append(exe.submit(image_to_base64, file))
continue
threads.append(exe.submit(FileService.parse, file["name"], FileService.get_blob(file["created_by"], file["id"]), True, file["created_by"]))
return [th.result() for th in threads]

View File

@ -14,6 +14,7 @@
# limitations under the License.
#
from collections import Counter
import string
from typing import Annotated, Any, Literal
from uuid import UUID
@ -25,6 +26,7 @@ from pydantic import (
StringConstraints,
ValidationError,
field_validator,
model_validator,
)
from pydantic_core import PydanticCustomError
from werkzeug.exceptions import BadRequest, UnsupportedMediaType
@ -361,10 +363,9 @@ class CreateDatasetReq(Base):
description: Annotated[str | None, Field(default=None, max_length=65535)]
embedding_model: Annotated[str | None, Field(default=None, max_length=255, serialization_alias="embd_id")]
permission: Annotated[Literal["me", "team"], Field(default="me", min_length=1, max_length=16)]
chunk_method: Annotated[
Literal["naive", "book", "email", "laws", "manual", "one", "paper", "picture", "presentation", "qa", "table", "tag"],
Field(default="naive", min_length=1, max_length=32, serialization_alias="parser_id"),
]
chunk_method: Annotated[str | None, Field(default=None, serialization_alias="parser_id")]
parse_type: Annotated[int | None, Field(default=None, ge=0, le=64)]
pipeline_id: Annotated[str | None, Field(default=None, min_length=32, max_length=32, serialization_alias="pipeline_id")]
parser_config: Annotated[ParserConfig | None, Field(default=None)]
@field_validator("avatar", mode="after")
@ -525,6 +526,93 @@ class CreateDatasetReq(Base):
raise PydanticCustomError("string_too_long", "Parser config exceeds size limit (max 65,535 characters). Current size: {actual}", {"actual": len(json_str)})
return v
@field_validator("pipeline_id", mode="after")
@classmethod
def validate_pipeline_id(cls, v: str | None) -> str | None:
"""Validate pipeline_id as 32-char lowercase hex string if provided.
Rules:
- None or empty string: treat as None (not set)
- Must be exactly length 32
- Must contain only hex digits (0-9a-fA-F); normalized to lowercase
"""
if v is None:
return None
if v == "":
return None
if len(v) != 32:
raise PydanticCustomError("format_invalid", "pipeline_id must be 32 hex characters")
if any(ch not in string.hexdigits for ch in v):
raise PydanticCustomError("format_invalid", "pipeline_id must be hexadecimal")
return v.lower()
@model_validator(mode="after")
def validate_parser_dependency(self) -> "CreateDatasetReq":
"""
Mixed conditional validation:
- If parser_id is omitted (field not set):
* If both parse_type and pipeline_id are omitted → default chunk_method = "naive"
* If both parse_type and pipeline_id are provided → allow ingestion pipeline mode
- If parser_id is provided (valid enum) → parse_type and pipeline_id must be None (disallow mixed usage)
Raises:
PydanticCustomError with code 'dependency_error' on violation.
"""
# Omitted chunk_method (not in fields) logic
if self.chunk_method is None and "chunk_method" not in self.model_fields_set:
# All three absent → default naive
if self.parse_type is None and self.pipeline_id is None:
object.__setattr__(self, "chunk_method", "naive")
return self
# parser_id omitted: require BOTH parse_type & pipeline_id present (no partial allowed)
if self.parse_type is None or self.pipeline_id is None:
missing = []
if self.parse_type is None:
missing.append("parse_type")
if self.pipeline_id is None:
missing.append("pipeline_id")
raise PydanticCustomError(
"dependency_error",
"parser_id omitted → required fields missing: {fields}",
{"fields": ", ".join(missing)},
)
# Both provided → allow pipeline mode
return self
# parser_id provided (valid): MUST NOT have parse_type or pipeline_id
if isinstance(self.chunk_method, str):
if self.parse_type is not None or self.pipeline_id is not None:
invalid = []
if self.parse_type is not None:
invalid.append("parse_type")
if self.pipeline_id is not None:
invalid.append("pipeline_id")
raise PydanticCustomError(
"dependency_error",
"parser_id provided → disallowed fields present: {fields}",
{"fields": ", ".join(invalid)},
)
return self
@field_validator("chunk_method", mode="wrap")
@classmethod
def validate_chunk_method(cls, v: Any, handler) -> Any:
"""Wrap validation to unify error messages, including type errors (e.g. list)."""
allowed = {"naive", "book", "email", "laws", "manual", "one", "paper", "picture", "presentation", "qa", "table", "tag"}
error_msg = "Input should be 'naive', 'book', 'email', 'laws', 'manual', 'one', 'paper', 'picture', 'presentation', 'qa', 'table' or 'tag'"
# Omitted field: handler won't be invoked (wrap still gets value); None treated as explicit invalid
if v is None:
raise PydanticCustomError("literal_error", error_msg)
try:
# Run inner validation (type checking)
result = handler(v)
except Exception:
raise PydanticCustomError("literal_error", error_msg)
# After handler, enforce enumeration
if not isinstance(result, str) or result == "" or result not in allowed:
raise PydanticCustomError("literal_error", error_msg)
return result
class UpdateDatasetReq(CreateDatasetReq):
dataset_id: Annotated[str, Field(...)]

View File

@ -217,6 +217,7 @@ OAUTH_GOOGLE_DRIVE_CLIENT_SECRET = os.environ.get(
"OAUTH_GOOGLE_DRIVE_CLIENT_SECRET", ""
)
GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI = os.environ.get("GOOGLE_DRIVE_WEB_OAUTH_REDIRECT_URI", "http://localhost:9380/v1/connector/google-drive/oauth/web/callback")
GMAIL_WEB_OAUTH_REDIRECT_URI = os.environ.get("GMAIL_WEB_OAUTH_REDIRECT_URI", "http://localhost:9380/v1/connector/gmail/oauth/web/callback")
CONFLUENCE_OAUTH_TOKEN_URL = "https://auth.atlassian.com/oauth/token"
RATE_LIMIT_MESSAGE_LOWERCASE = "Rate limit exceeded".lower()

View File

@ -1,6 +1,6 @@
import logging
import os
from typing import Any
from google.oauth2.credentials import Credentials as OAuthCredentials
from google.oauth2.service_account import Credentials as ServiceAccountCredentials
from googleapiclient.errors import HttpError
@ -9,10 +9,10 @@ from common.data_source.config import INDEX_BATCH_SIZE, SLIM_BATCH_SIZE, Documen
from common.data_source.google_util.auth import get_google_creds
from common.data_source.google_util.constant import DB_CREDENTIALS_PRIMARY_ADMIN_KEY, MISSING_SCOPES_ERROR_STR, SCOPE_INSTRUCTIONS, USER_FIELDS
from common.data_source.google_util.resource import get_admin_service, get_gmail_service
from common.data_source.google_util.util import _execute_single_retrieval, execute_paginated_retrieval
from common.data_source.google_util.util import _execute_single_retrieval, execute_paginated_retrieval, sanitize_filename, clean_string
from common.data_source.interfaces import LoadConnector, PollConnector, SecondsSinceUnixEpoch, SlimConnectorWithPermSync
from common.data_source.models import BasicExpertInfo, Document, ExternalAccess, GenerateDocumentsOutput, GenerateSlimDocumentOutput, SlimDocument, TextSection
from common.data_source.utils import build_time_range_query, clean_email_and_extract_name, get_message_body, is_mail_service_disabled_error, time_str_to_utc
from common.data_source.utils import build_time_range_query, clean_email_and_extract_name, get_message_body, is_mail_service_disabled_error, gmail_time_str_to_utc
# Constants for Gmail API fields
THREAD_LIST_FIELDS = "nextPageToken, threads(id)"
@ -67,7 +67,6 @@ def message_to_section(message: dict[str, Any]) -> tuple[TextSection, dict[str,
message_data += f"{name}: {value}\n"
message_body_text: str = get_message_body(payload)
return TextSection(link=link, text=message_body_text + message_data), metadata
@ -97,13 +96,15 @@ def thread_to_document(full_thread: dict[str, Any], email_used_to_fetch_thread:
if not semantic_identifier:
semantic_identifier = message_metadata.get("subject", "")
semantic_identifier = clean_string(semantic_identifier)
semantic_identifier = sanitize_filename(semantic_identifier)
if message_metadata.get("updated_at"):
updated_at = message_metadata.get("updated_at")
updated_at_datetime = None
if updated_at:
updated_at_datetime = time_str_to_utc(updated_at)
updated_at_datetime = gmail_time_str_to_utc(updated_at)
thread_id = full_thread.get("id")
if not thread_id:
@ -115,15 +116,24 @@ def thread_to_document(full_thread: dict[str, Any], email_used_to_fetch_thread:
if not semantic_identifier:
semantic_identifier = "(no subject)"
combined_sections = "\n\n".join(
sec.text for sec in sections if hasattr(sec, "text")
)
blob = combined_sections
size_bytes = len(blob)
extension = '.txt'
return Document(
id=thread_id,
semantic_identifier=semantic_identifier,
sections=sections,
blob=blob,
size_bytes=size_bytes,
extension=extension,
source=DocumentSource.GMAIL,
primary_owners=primary_owners,
secondary_owners=secondary_owners,
doc_updated_at=updated_at_datetime,
metadata={},
metadata=message_metadata,
external_access=ExternalAccess(
external_user_emails={email_used_to_fetch_thread},
external_user_group_ids=set(),
@ -214,15 +224,13 @@ class GmailConnector(LoadConnector, PollConnector, SlimConnectorWithPermSync):
q=query,
continue_on_404_or_403=True,
):
full_threads = _execute_single_retrieval(
full_thread = _execute_single_retrieval(
retrieval_function=gmail_service.users().threads().get,
list_key=None,
userId=user_email,
fields=THREAD_FIELDS,
id=thread["id"],
continue_on_404_or_403=True,
)
full_thread = list(full_threads)[0]
doc = thread_to_document(full_thread, user_email)
if doc is None:
continue
@ -310,4 +318,30 @@ class GmailConnector(LoadConnector, PollConnector, SlimConnectorWithPermSync):
if __name__ == "__main__":
pass
import time
import os
from common.data_source.google_util.util import get_credentials_from_env
logging.basicConfig(level=logging.INFO)
try:
email = os.environ.get("GMAIL_TEST_EMAIL", "newyorkupperbay@gmail.com")
creds = get_credentials_from_env(email, oauth=True, source="gmail")
print("Credentials loaded successfully")
print(f"{creds=}")
connector = GmailConnector(batch_size=2)
print("GmailConnector initialized")
connector.load_credentials(creds)
print("Credentials loaded into connector")
print("Gmail is ready to use")
for file in connector._fetch_threads(
int(time.time()) - 1 * 24 * 60 * 60,
int(time.time()),
):
print("new batch","-"*80)
for f in file:
print(f)
print("\n\n")
except Exception as e:
logging.exception(f"Error loading credentials: {e}")

View File

@ -1,7 +1,6 @@
"""Google Drive connector"""
import copy
import json
import logging
import os
import sys
@ -32,7 +31,6 @@ from common.data_source.google_drive.file_retrieval import (
from common.data_source.google_drive.model import DriveRetrievalStage, GoogleDriveCheckpoint, GoogleDriveFileType, RetrievedDriveFile, StageCompletion
from common.data_source.google_util.auth import get_google_creds
from common.data_source.google_util.constant import DB_CREDENTIALS_PRIMARY_ADMIN_KEY, MISSING_SCOPES_ERROR_STR, USER_FIELDS
from common.data_source.google_util.oauth_flow import ensure_oauth_token_dict
from common.data_source.google_util.resource import GoogleDriveService, get_admin_service, get_drive_service
from common.data_source.google_util.util import GoogleFields, execute_paginated_retrieval, get_file_owners
from common.data_source.google_util.util_threadpool_concurrency import ThreadSafeDict
@ -1138,39 +1136,6 @@ class GoogleDriveConnector(SlimConnectorWithPermSync, CheckpointedConnectorWithP
return GoogleDriveCheckpoint.model_validate_json(checkpoint_json)
def get_credentials_from_env(email: str, oauth: bool = False) -> dict:
try:
if oauth:
raw_credential_string = os.environ["GOOGLE_DRIVE_OAUTH_CREDENTIALS_JSON_STR"]
else:
raw_credential_string = os.environ["GOOGLE_DRIVE_SERVICE_ACCOUNT_JSON_STR"]
except KeyError:
raise ValueError("Missing Google Drive credentials in environment variables")
try:
credential_dict = json.loads(raw_credential_string)
except json.JSONDecodeError:
raise ValueError("Invalid JSON in Google Drive credentials")
if oauth:
credential_dict = ensure_oauth_token_dict(credential_dict, DocumentSource.GOOGLE_DRIVE)
refried_credential_string = json.dumps(credential_dict)
DB_CREDENTIALS_DICT_TOKEN_KEY = "google_tokens"
DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY = "google_service_account_key"
DB_CREDENTIALS_PRIMARY_ADMIN_KEY = "google_primary_admin"
DB_CREDENTIALS_AUTHENTICATION_METHOD = "authentication_method"
cred_key = DB_CREDENTIALS_DICT_TOKEN_KEY if oauth else DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY
return {
cred_key: refried_credential_string,
DB_CREDENTIALS_PRIMARY_ADMIN_KEY: email,
DB_CREDENTIALS_AUTHENTICATION_METHOD: "uploaded",
}
class CheckpointOutputWrapper:
"""
Wraps a CheckpointOutput generator to give things back in a more digestible format.
@ -1236,7 +1201,7 @@ def yield_all_docs_from_checkpoint_connector(
if __name__ == "__main__":
import time
from common.data_source.google_util.util import get_credentials_from_env
logging.basicConfig(level=logging.DEBUG)
try:
@ -1245,7 +1210,7 @@ if __name__ == "__main__":
creds = get_credentials_from_env(email, oauth=True)
print("Credentials loaded successfully")
print(f"{creds=}")
sys.exit(0)
connector = GoogleDriveConnector(
include_shared_drives=False,
shared_drive_urls=None,

View File

@ -49,11 +49,11 @@ MISSING_SCOPES_ERROR_STR = "client not authorized for any of the scopes requeste
SCOPE_INSTRUCTIONS = ""
GOOGLE_DRIVE_WEB_OAUTH_POPUP_TEMPLATE = """<!DOCTYPE html>
GOOGLE_WEB_OAUTH_POPUP_TEMPLATE = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Google Drive Authorization</title>
<title>{title}</title>
<style>
body {{
font-family: Arial, sans-serif;

View File

@ -1,12 +1,17 @@
import json
import logging
import os
import re
import socket
from collections.abc import Callable, Iterator
from enum import Enum
from typing import Any
import unicodedata
from googleapiclient.errors import HttpError # type: ignore # type: ignore
from common.data_source.config import DocumentSource
from common.data_source.google_drive.model import GoogleDriveFileType
from common.data_source.google_util.oauth_flow import ensure_oauth_token_dict
# See https://developers.google.com/drive/api/reference/rest/v3/files/list for more
@ -117,6 +122,7 @@ def _execute_single_retrieval(
"""Execute a single retrieval from Google Drive API"""
try:
results = retrieval_function(**request_kwargs).execute()
except HttpError as e:
if e.resp.status >= 500:
results = retrieval_function()
@ -148,5 +154,110 @@ def _execute_single_retrieval(
error,
)
results = retrieval_function()
return results
def get_credentials_from_env(email: str, oauth: bool = False, source="drive") -> dict:
try:
if oauth:
raw_credential_string = os.environ["GOOGLE_OAUTH_CREDENTIALS_JSON_STR"]
else:
raw_credential_string = os.environ["GOOGLE_SERVICE_ACCOUNT_JSON_STR"]
except KeyError:
raise ValueError("Missing Google Drive credentials in environment variables")
try:
credential_dict = json.loads(raw_credential_string)
except json.JSONDecodeError:
raise ValueError("Invalid JSON in Google Drive credentials")
if oauth and source == "drive":
credential_dict = ensure_oauth_token_dict(credential_dict, DocumentSource.GOOGLE_DRIVE)
else:
credential_dict = ensure_oauth_token_dict(credential_dict, DocumentSource.GMAIL)
refried_credential_string = json.dumps(credential_dict)
DB_CREDENTIALS_DICT_TOKEN_KEY = "google_tokens"
DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY = "google_service_account_key"
DB_CREDENTIALS_PRIMARY_ADMIN_KEY = "google_primary_admin"
DB_CREDENTIALS_AUTHENTICATION_METHOD = "authentication_method"
cred_key = DB_CREDENTIALS_DICT_TOKEN_KEY if oauth else DB_CREDENTIALS_DICT_SERVICE_ACCOUNT_KEY
return {
cred_key: refried_credential_string,
DB_CREDENTIALS_PRIMARY_ADMIN_KEY: email,
DB_CREDENTIALS_AUTHENTICATION_METHOD: "uploaded",
}
def sanitize_filename(name: str) -> str:
"""
Soft sanitize for MinIO/S3:
- Replace only prohibited characters with a space.
- Preserve readability (no ugly underscores).
- Collapse multiple spaces.
"""
if name is None:
return "file.txt"
name = str(name).strip()
# Characters that MUST NOT appear in S3/MinIO object keys
# Replace them with a space (not underscore)
forbidden = r'[\\\?\#\%\*\:\|\<\>"]'
name = re.sub(forbidden, " ", name)
# Replace slashes "/" (S3 interprets as folder) with space
name = name.replace("/", " ")
# Collapse multiple spaces into one
name = re.sub(r"\s+", " ", name)
# Trim both ends
name = name.strip()
# Enforce reasonable max length
if len(name) > 200:
base, ext = os.path.splitext(name)
name = base[:180].rstrip() + ext
# Ensure there is an extension (your original logic)
if not os.path.splitext(name)[1]:
name += ".txt"
return name
def clean_string(text: str | None) -> str | None:
"""
Clean a string to make it safe for insertion into MySQL (utf8mb4).
- Normalize Unicode
- Remove control characters / zero-width characters
- Optionally remove high-plane emoji and symbols
"""
if text is None:
return None
# 0. Ensure the value is a string
text = str(text)
# 1. Normalize Unicode (NFC)
text = unicodedata.normalize("NFC", text)
# 2. Remove ASCII control characters (except tab, newline, carriage return)
text = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]", "", text)
# 3. Remove zero-width characters / BOM
text = re.sub(r"[\u200b-\u200d\uFEFF]", "", text)
# 4. Remove high Unicode characters (emoji, special symbols)
text = re.sub(r"[\U00010000-\U0010FFFF]", "", text)
# 5. Final fallback: strip any invalid UTF-8 sequences
try:
text.encode("utf-8")
except UnicodeEncodeError:
text = text.encode("utf-8", errors="ignore").decode("utf-8")
return text

View File

@ -30,7 +30,6 @@ class LoadConnector(ABC):
"""Load documents from state"""
pass
@abstractmethod
def validate_connector_settings(self) -> None:
"""Validate connector settings"""
pass

View File

@ -733,7 +733,7 @@ def build_time_range_query(
"""Build time range query for Gmail API"""
query = ""
if time_range_start is not None and time_range_start != 0:
query += f"after:{int(time_range_start)}"
query += f"after:{int(time_range_start) + 1}"
if time_range_end is not None and time_range_end != 0:
query += f" before:{int(time_range_end)}"
query = query.strip()
@ -778,6 +778,15 @@ def time_str_to_utc(time_str: str):
return datetime.fromisoformat(time_str.replace("Z", "+00:00"))
def gmail_time_str_to_utc(time_str: str):
"""Convert Gmail RFC 2822 time string to UTC."""
from email.utils import parsedate_to_datetime
from datetime import timezone
dt = parsedate_to_datetime(time_str)
return dt.astimezone(timezone.utc)
# Notion Utilities
T = TypeVar("T")

View File

@ -419,7 +419,15 @@ Creates a dataset.
- `"embedding_model"`: `string`
- `"permission"`: `string`
- `"chunk_method"`: `string`
- `"parser_config"`: `object`
- "parser_config": `object`
- "parse_type": `int`
- "pipeline_id": `string`
Note: Choose exactly one ingestion mode when creating a dataset.
- Chunking method: provide `"chunk_method"` (optionally with `"parser_config"`).
- Ingestion pipeline: provide both `"parse_type"` and `"pipeline_id"` and do not provide `"chunk_method"`.
These options are mutually exclusive. If all three of `chunk_method`, `parse_type`, and `pipeline_id` are omitted, the system defaults to `chunk_method = "naive"`.
##### Request example
@ -433,6 +441,26 @@ curl --request POST \
}'
```
##### Request example (ingestion pipeline)
Use this form when specifying an ingestion pipeline (do not include `chunk_method`).
```bash
curl --request POST \
--url http://{address}/api/v1/datasets \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <YOUR_API_KEY>' \
--data '{
"name": "test-sdk",
"parse_type": <NUMBER_OF_FORMATS_IN_PARSE>,
"pipeline_id": "<PIPELINE_ID_32_HEX>"
}'
```
Notes:
- `parse_type` is an integer. Replace `<NUMBER_OF_FORMATS_IN_PARSE>` with your pipeline's parse-type value.
- `pipeline_id` must be a 32-character lowercase hexadecimal string.
##### Request parameters
- `"name"`: (*Body parameter*), `string`, *Required*
@ -473,6 +501,7 @@ curl --request POST \
- `"qa"`: Q&A
- `"table"`: Table
- `"tag"`: Tag
- Mutually exclusive with `parse_type` and `pipeline_id`. If you set `chunk_method`, do not include `parse_type` or `pipeline_id`.
- `"parser_config"`: (*Body parameter*), `object`
The configuration settings for the dataset parser. The attributes in this JSON object vary with the selected `"chunk_method"`:
@ -509,6 +538,15 @@ curl --request POST \
- Defaults to: `{"use_raptor": false}`.
- If `"chunk_method"` is `"table"`, `"picture"`, `"one"`, or `"email"`, `"parser_config"` is an empty JSON object.
- "parse_type": (*Body parameter*), `int`
The ingestion pipeline parse type identifier. Required if and only if you are using an ingestion pipeline (together with `"pipeline_id"`). Must not be provided when `"chunk_method"` is set.
- "pipeline_id": (*Body parameter*), `string`
The ingestion pipeline ID. Required if and only if you are using an ingestion pipeline (together with `"parse_type"`).
- Must not be provided when `"chunk_method"` is set.
Note: If none of `chunk_method`, `parse_type`, and `pipeline_id` are provided, the system will default to `chunk_method = "naive"`.
#### Response
Success:

View File

@ -39,6 +39,7 @@ from deepdoc.parser.docling_parser import DoclingParser
from deepdoc.parser.tcadp_parser import TCADPParser
from rag.nlp import concat_img, find_codec, naive_merge, naive_merge_with_images, naive_merge_docx, rag_tokenizer, tokenize_chunks, tokenize_chunks_with_images, tokenize_table, attach_media_context
def by_deepdoc(filename, binary=None, from_page=0, to_page=100000, lang="Chinese", callback=None, pdf_cls = None ,**kwargs):
callback = callback
binary = binary
@ -600,8 +601,7 @@ def load_from_xml_v2(baseURI, rels_item_xml):
srels._srels.append(_SerializedRelationship(baseURI, rel_elm))
return srels
def chunk(filename, binary=None, from_page=0, to_page=100000,
lang="Chinese", callback=None, **kwargs):
def chunk(filename, binary=None, from_page=0, to_page=100000, lang="Chinese", callback=None, **kwargs):
"""
Supported file formats are docx, pdf, excel, txt.
This method apply the naive ways to chunk files.
@ -611,14 +611,18 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
urls = set()
url_res = []
is_english = lang.lower() == "english" # is_english(cks)
parser_config = kwargs.get(
"parser_config", {
"chunk_token_num": 512, "delimiter": "\n!?。;!?", "layout_recognize": "DeepDOC", "analyze_hyperlink": True})
child_deli = re.findall(r"`([^`]+)`", parser_config.get("children_delimiter", ""))
child_deli = sorted(set(child_deli), key=lambda x: -len(x))
child_deli = "|".join(re.escape(t) for t in child_deli if t)
is_markdown = False
table_context_size = max(0, int(parser_config.get("table_context_size", 0) or 0))
image_context_size = max(0, int(parser_config.get("image_context_size", 0) or 0))
final_sections = False
doc = {
"docnm_kwd": filename,
"title_tks": rag_tokenizer.tokenize(re.sub(r"\.[a-zA-Z]+$", "", filename))
@ -679,12 +683,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
"chunk_token_num", 128)), parser_config.get(
"delimiter", "\n!?。;!?"))
if kwargs.get("section_only", False):
chunks.extend(embed_res)
chunks.extend(url_res)
return chunks
res.extend(tokenize_chunks_with_images(chunks, doc, is_english, images))
res.extend(tokenize_chunks_with_images(chunks, doc, is_english, images, child_delimiters_pattern=child_deli))
logging.info("naive_merge({}): {}".format(filename, timer() - st))
res.extend(embed_res)
res.extend(url_res)
@ -780,7 +779,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
return_section_images=True,
)
final_sections = True
is_markdown = True
try:
vision_model = LLMBundle(kwargs["tenant_id"], LLMType.IMAGE2TEXT)
@ -857,7 +856,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
"file type not supported yet(pdf, xlsx, doc, docx, txt supported)")
st = timer()
if final_sections:
if is_markdown:
merged_chunks = []
merged_images = []
chunk_limit = max(0, int(parser_config.get("chunk_token_num", 128)))
@ -900,13 +899,11 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
chunks = merged_chunks
has_images = merged_images and any(img is not None for img in merged_images)
if kwargs.get("section_only", False):
chunks.extend(embed_res)
return chunks
if has_images:
res.extend(tokenize_chunks_with_images(chunks, doc, is_english, merged_images))
res.extend(tokenize_chunks_with_images(chunks, doc, is_english, merged_images, child_delimiters_pattern=child_deli))
else:
res.extend(tokenize_chunks(chunks, doc, is_english, pdf_parser))
res.extend(tokenize_chunks(chunks, doc, is_english, pdf_parser, child_delimiters_pattern=child_deli))
else:
if section_images:
if all(image is None for image in section_images):
@ -917,21 +914,14 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
int(parser_config.get(
"chunk_token_num", 128)), parser_config.get(
"delimiter", "\n!?。;!?"))
if kwargs.get("section_only", False):
chunks.extend(embed_res)
return chunks
res.extend(tokenize_chunks_with_images(chunks, doc, is_english, images))
res.extend(tokenize_chunks_with_images(chunks, doc, is_english, images, child_delimiters_pattern=child_deli))
else:
chunks = naive_merge(
sections, int(parser_config.get(
"chunk_token_num", 128)), parser_config.get(
"delimiter", "\n!?。;!?"))
if kwargs.get("section_only", False):
chunks.extend(embed_res)
return chunks
res.extend(tokenize_chunks(chunks, doc, is_english, pdf_parser))
res.extend(tokenize_chunks(chunks, doc, is_english, pdf_parser, child_delimiters_pattern=child_deli))
if urls and parser_config.get("analyze_hyperlink", False) and is_root:
for index, url in enumerate(urls):

View File

@ -13,10 +13,10 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import random
import re
from copy import deepcopy
from functools import partial
import trio
from common.misc_utils import get_uuid
from rag.utils.base64_image import id2image, image2id
from deepdoc.parser.pdf_parser import RAGFlowPdfParser
@ -32,6 +32,7 @@ class SplitterParam(ProcessParamBase):
self.chunk_token_size = 512
self.delimiters = ["\n"]
self.overlapped_percent = 0
self.children_delimiters = []
def check(self):
self.check_empty(self.delimiters, "Delimiters.")
@ -58,6 +59,14 @@ class Splitter(ProcessBase):
deli += f"`{d}`"
else:
deli += d
child_deli = ""
for d in self._param.children_delimiters:
if len(d) > 1:
child_deli += f"`{d}`"
else:
child_deli += d
child_deli = [m.group(1) for m in re.finditer(r"`([^`]+)`", child_deli)]
custom_pattern = "|".join(re.escape(t) for t in sorted(set(child_deli), key=len, reverse=True))
self.set_output("output_format", "chunks")
self.callback(random.randint(1, 5) / 100.0, "Start to split into chunks.")
@ -78,7 +87,23 @@ class Splitter(ProcessBase):
deli,
self._param.overlapped_percent,
)
self.set_output("chunks", [{"text": c.strip()} for c in cks if c.strip()])
if custom_pattern:
docs = []
for c in cks:
if not c.strip():
continue
split_sec = re.split(r"(%s)" % custom_pattern, c, flags=re.DOTALL)
if split_sec:
for txt in split_sec:
docs.append({
"text": txt,
"mom": c
})
else:
docs.append({"text": c})
self.set_output("chunks", docs)
else:
self.set_output("chunks", [{"text": c.strip()} for c in cks if c.strip()])
self.callback(1, "Done.")
return
@ -100,12 +125,27 @@ class Splitter(ProcessBase):
{
"text": RAGFlowPdfParser.remove_tag(c),
"image": img,
"positions": [[pos[0][-1]+1, *pos[1:]] for pos in RAGFlowPdfParser.extract_positions(c)],
"positions": [[pos[0][-1]+1, *pos[1:]] for pos in RAGFlowPdfParser.extract_positions(c)]
}
for c, img in zip(chunks, images) if c.strip()
]
async with trio.open_nursery() as nursery:
for d in cks:
nursery.start_soon(image2id, d, partial(settings.STORAGE_IMPL.put, tenant_id=self._canvas._tenant_id), get_uuid())
self.set_output("chunks", cks)
if custom_pattern:
docs = []
for c in cks:
split_sec = re.split(r"(%s)" % custom_pattern, c["text"], flags=re.DOTALL)
if split_sec:
c["mom"] = c["text"]
for txt in split_sec:
cc = deepcopy(c)
cc["text"] = txt
docs.append(cc)
else:
docs.append(c)
self.set_output("chunks", docs)
else:
self.set_output("chunks", cks)
self.callback(1, "Done.")

View File

@ -264,14 +264,14 @@ def is_chinese(text):
return False
def tokenize(d, t, eng):
d["content_with_weight"] = t
t = re.sub(r"</?(table|td|caption|tr|th)( [^<>]{0,12})?>", " ", t)
def tokenize(d, txt, eng):
d["content_with_weight"] = txt
t = re.sub(r"</?(table|td|caption|tr|th)( [^<>]{0,12})?>", " ", txt)
d["content_ltks"] = rag_tokenizer.tokenize(t)
d["content_sm_ltks"] = rag_tokenizer.fine_grained_tokenize(d["content_ltks"])
def tokenize_chunks(chunks, doc, eng, pdf_parser=None):
def tokenize_chunks(chunks, doc, eng, pdf_parser=None, child_delimiters_pattern=None):
res = []
# wrap up as es documents
for ii, ck in enumerate(chunks):
@ -288,12 +288,21 @@ def tokenize_chunks(chunks, doc, eng, pdf_parser=None):
pass
else:
add_positions(d, [[ii]*5])
if child_delimiters_pattern:
d["mom_with_weight"] = ck
for txt in re.split(r"(%s)" % child_delimiters_pattern, ck, flags=re.DOTALL):
dd = copy.deepcopy(d)
tokenize(dd, txt, eng)
res.append(dd)
continue
tokenize(d, ck, eng)
res.append(d)
return res
def tokenize_chunks_with_images(chunks, doc, eng, images):
def tokenize_chunks_with_images(chunks, doc, eng, images, child_delimiters_pattern=None):
res = []
# wrap up as es documents
for ii, (ck, image) in enumerate(zip(chunks, images)):
@ -303,6 +312,13 @@ def tokenize_chunks_with_images(chunks, doc, eng, images):
d = copy.deepcopy(doc)
d["image"] = image
add_positions(d, [[ii]*5])
if child_delimiters_pattern:
d["mom_with_weight"] = ck
for txt in re.split(r"(%s)" % child_delimiters_pattern, ck, flags=re.DOTALL):
dd = copy.deepcopy(d)
tokenize(dd, txt, eng)
res.append(dd)
continue
tokenize(d, ck, eng)
res.append(d)
return res

View File

@ -424,6 +424,7 @@ class Dealer:
sim_np = np.array(sim, dtype=np.float64)
if sim_np.size == 0:
ranks["doc_aggs"] = []
return ranks
sorted_idx = np.argsort(sim_np * -1)
@ -433,6 +434,7 @@ class Dealer:
ranks["total"] = int(filtered_count)
if filtered_count == 0:
ranks["doc_aggs"] = []
return ranks
max_pages = max(RERANK_LIMIT // max(page_size, 1), 1)

View File

@ -430,9 +430,13 @@ def rank_memories(chat_mdl, goal:str, sub_goal:str, tool_call_summaries: list[st
def gen_meta_filter(chat_mdl, meta_data:dict, query: str) -> dict:
meta_data_structure = {}
for key, values in meta_data.items():
meta_data_structure[key] = list(values.keys()) if isinstance(values, dict) else values
sys_prompt = PROMPT_JINJA_ENV.from_string(META_FILTER).render(
current_date=datetime.datetime.today().strftime('%Y-%m-%d'),
metadata_keys=json.dumps(meta_data),
metadata_keys=json.dumps(meta_data_structure),
user_question=query
)
user_prompt = "Generate filters:"

View File

@ -41,6 +41,7 @@ from common.data_source import BlobStorageConnector, NotionConnector, DiscordCon
from common.constants import FileSource, TaskStatus
from common.data_source.config import INDEX_BATCH_SIZE
from common.data_source.confluence_connector import ConfluenceConnector
from common.data_source.gmail_connector import GmailConnector
from common.data_source.interfaces import CheckpointOutputWrapper
from common.data_source.utils import load_all_docs_from_checkpoint_connector
from common.log_utils import init_root_logger
@ -230,7 +231,64 @@ class Gmail(SyncBase):
SOURCE_NAME: str = FileSource.GMAIL
async def _generate(self, task: dict):
pass
# Gmail sync reuses the generic LoadConnector/PollConnector interface
# implemented by common.data_source.gmail_connector.GmailConnector.
#
# Config expectations (self.conf):
# credentials: Gmail / Workspace OAuth JSON (with primary admin email)
# batch_size: optional, defaults to INDEX_BATCH_SIZE
batch_size = self.conf.get("batch_size", INDEX_BATCH_SIZE)
self.connector = GmailConnector(batch_size=batch_size)
credentials = self.conf.get("credentials")
if not credentials:
raise ValueError("Gmail connector is missing credentials.")
new_credentials = self.connector.load_credentials(credentials)
if new_credentials:
# Persist rotated / refreshed credentials back to connector config
try:
updated_conf = copy.deepcopy(self.conf)
updated_conf["credentials"] = new_credentials
ConnectorService.update_by_id(task["connector_id"], {"config": updated_conf})
self.conf = updated_conf
logging.info(
"Persisted refreshed Gmail credentials for connector %s",
task["connector_id"],
)
except Exception:
logging.exception(
"Failed to persist refreshed Gmail credentials for connector %s",
task["connector_id"],
)
# Decide between full reindex and incremental polling by time range.
if task["reindex"] == "1" or not task.get("poll_range_start"):
start_time = None
end_time = None
begin_info = "totally"
document_generator = self.connector.load_from_state()
else:
poll_start = task["poll_range_start"]
# Defensive: if poll_start is somehow None, fall back to full load
if poll_start is None:
start_time = None
end_time = None
begin_info = "totally"
document_generator = self.connector.load_from_state()
else:
start_time = poll_start.timestamp()
end_time = datetime.now(timezone.utc).timestamp()
begin_info = f"from {poll_start}"
document_generator = self.connector.poll_source(start_time, end_time)
try:
admin_email = self.connector.primary_admin_email
except RuntimeError:
admin_email = "unknown"
logging.info(f"Connect to Gmail as {admin_email} {begin_info}")
return document_generator
class Dropbox(SyncBase):

View File

@ -128,9 +128,6 @@ def signal_handler(sig, frame):
sys.exit(0)
def set_progress(task_id, from_page=0, to_page=-1, prog=None, msg="Processing..."):
try:
if prog is not None and prog < 0:
@ -720,6 +717,34 @@ async def delete_image(kb_id, chunk_id):
async def insert_es(task_id, task_tenant_id, task_dataset_id, chunks, progress_callback):
mothers = []
mother_ids = set([])
for ck in chunks:
mom = ck.get("mom") or ck.get("mom_with_weight") or ""
if not mom:
continue
id = xxhash.xxh64(mom.encode("utf-8")).hexdigest()
if id in mother_ids:
continue
mother_ids.add(id)
ck["mom_id"] = id
mom_ck = copy.deepcopy(ck)
mom_ck["id"] = id
mom_ck["content_with_weight"] = mom
mom_ck["available_int"] = 0
flds = list(mom_ck.keys())
for fld in flds:
if fld not in ["id", "content_with_weight", "doc_id", "kb_id", "available_int"]:
del mom_ck[fld]
mothers.append(mom_ck)
for b in range(0, len(mothers), settings.DOC_BULK_SIZE):
await trio.to_thread.run_sync(lambda: settings.docStoreConn.insert(mothers[b:b + settings.DOC_BULK_SIZE], search.index_name(task_tenant_id), task_dataset_id))
task_canceled = has_canceled(task_id)
if task_canceled:
progress_callback(-1, msg="Task has been canceled.")
return False
for b in range(0, len(chunks), settings.DOC_BULK_SIZE):
doc_store_result = await trio.to_thread.run_sync(lambda: settings.docStoreConn.insert(chunks[b:b + settings.DOC_BULK_SIZE], search.index_name(task_tenant_id), task_dataset_id))
task_canceled = has_canceled(task_id)

View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="52 42 88 66">
<path fill="#4285f4" d="M58 108h14V74L52 59v43c0 3.32 2.69 6 6 6"/>
<path fill="#34a853" d="M120 108h14c3.32 0 6-2.69 6-6V59l-20 15"/>
<path fill="#fbbc04" d="M120 48v26l20-15v-8c0-7.42-8.47-11.65-14.4-7.2"/>
<path fill="#ea4335" d="M72 74V48l24 18 24-18v26L96 92"/>
<path fill="#c5221f" d="M52 51v8l20 15V48l-5.6-4.2c-5.94-4.45-14.4-.22-14.4 7.2"/>
</svg>

After

Width:  |  Height:  |  Size: 419 B

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -0,0 +1,35 @@
<svg width="84" height="104" viewBox="0 0 84 104" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="search nothing">
<path id="Vector" d="M60.6654 71.3333V39.3333C60.6654 37.9188 60.1035 36.5623 59.1033 35.5621C58.1031 34.5619 56.7465 34 55.332 34H20.6654M20.6654 34C22.0799 34 23.4364 34.5619 24.4366 35.5621C25.4368 36.5623 25.9987 37.9188 25.9987 39.3333V76.6667C25.9987 78.0812 26.5606 79.4377 27.5608 80.4379C28.561 81.4381 29.9175 82 31.332 82M20.6654 34C19.2509 34 17.8943 34.5619 16.8941 35.5621C15.8939 36.5623 15.332 37.9188 15.332 39.3333V44.6667C15.332 45.3739 15.613 46.0522 16.1131 46.5523C16.6132 47.0524 17.2915 47.3333 17.9987 47.3333H25.9987M31.332 82H63.332C64.7465 82 66.1031 81.4381 67.1033 80.4379C68.1035 79.4377 68.6654 78.0812 68.6654 76.6667V74C68.6654 73.2928 68.3844 72.6145 67.8843 72.1144C67.3842 71.6143 66.7059 71.3333 65.9987 71.3333H39.332C38.6248 71.3333 37.9465 71.6143 37.4464 72.1144C36.9463 72.6145 36.6654 73.2928 36.6654 74V76.6667C36.6654 78.0812 36.1035 79.4377 35.1033 80.4379C34.1031 81.4381 32.7465 82 31.332 82Z" stroke="url(#paint0_linear_493_43922)" stroke-opacity="0.2" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round"/>
<g id="Vector_2">
<path d="M60.6654 65.3333V33.3333C60.6654 31.9188 60.1035 30.5623 59.1033 29.5621C58.1031 28.5619 56.7465 28 55.332 28H20.6654" fill="white"/>
<path d="M31.332 76H63.332C64.7465 76 66.1031 75.4381 67.1033 74.4379C68.1035 73.4377 68.6654 72.0812 68.6654 70.6667V68C68.6654 67.2928 68.3844 66.6145 67.8843 66.1144C67.3842 65.6143 66.7059 65.3333 65.9987 65.3333H39.332C38.6248 65.3333 37.9465 65.6143 37.4464 66.1144C36.9463 66.6145 36.6654 67.2928 36.6654 68V70.6667C36.6654 72.0812 36.1035 73.4377 35.1033 74.4379C34.1031 75.4381 32.7465 76 31.332 76C29.9175 76 28.561 75.4381 27.5608 74.4379C26.5606 73.4377 25.9987 72.0812 25.9987 70.6667V33.3333C25.9987 31.9188 25.4368 30.5623 24.4366 29.5621C23.4364 28.5619 22.0799 28 20.6654 28C19.2509 28 17.8943 28.5619 16.8941 29.5621C15.8939 30.5623 15.332 31.9188 15.332 33.3333V38.6667C15.332 39.3739 15.613 40.0522 16.1131 40.5523C16.6132 41.0524 17.2915 41.3333 17.9987 41.3333H25.9987" fill="white"/>
<path d="M60.6654 65.3333V33.3333C60.6654 31.9188 60.1035 30.5623 59.1033 29.5621C58.1031 28.5619 56.7465 28 55.332 28H20.6654M20.6654 28C22.0799 28 23.4364 28.5619 24.4366 29.5621C25.4368 30.5623 25.9987 31.9188 25.9987 33.3333V70.6667C25.9987 72.0812 26.5606 73.4377 27.5608 74.4379C28.561 75.4381 29.9175 76 31.332 76M20.6654 28C19.2509 28 17.8943 28.5619 16.8941 29.5621C15.8939 30.5623 15.332 31.9188 15.332 33.3333V38.6667C15.332 39.3739 15.613 40.0522 16.1131 40.5523C16.6132 41.0524 17.2915 41.3333 17.9987 41.3333H25.9987M31.332 76H63.332C64.7465 76 66.1031 75.4381 67.1033 74.4379C68.1035 73.4377 68.6654 72.0812 68.6654 70.6667V68C68.6654 67.2928 68.3844 66.6145 67.8843 66.1144C67.3842 65.6143 66.7059 65.3333 65.9987 65.3333H39.332C38.6248 65.3333 37.9465 65.6143 37.4464 66.1144C36.9463 66.6145 36.6654 67.2928 36.6654 68V70.6667C36.6654 72.0812 36.1035 73.4377 35.1033 74.4379C34.1031 75.4381 32.7465 76 31.332 76Z" stroke="url(#paint1_linear_493_43922)" stroke-opacity="0.6" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<path id="Vector_3" d="M61.3333 59.3333V27.3333C61.3333 25.9188 60.7714 24.5623 59.7712 23.5621C58.771 22.5619 57.4145 22 56 22H21.3333M21.3333 22C22.7478 22 24.1044 22.5619 25.1046 23.5621C26.1048 24.5623 26.6667 25.9188 26.6667 27.3333V64.6667C26.6667 66.0812 27.2286 67.4377 28.2288 68.4379C29.229 69.4381 30.5855 70 32 70M21.3333 22C19.9188 22 18.5623 22.5619 17.5621 23.5621C16.5619 24.5623 16 25.9188 16 27.3333V32.6667C16 33.3739 16.281 34.0522 16.781 34.5523C17.2811 35.0524 17.9594 35.3333 18.6667 35.3333H26.6667M32 70H64C65.4145 70 66.771 69.4381 67.7712 68.4379C68.7714 67.4377 69.3333 66.0812 69.3333 64.6667V62C69.3333 61.2928 69.0524 60.6145 68.5523 60.1144C68.0522 59.6143 67.3739 59.3333 66.6667 59.3333H40C39.2928 59.3333 38.6145 59.6143 38.1144 60.1144C37.6143 60.6145 37.3333 61.2928 37.3333 62V64.6667C37.3333 66.0812 36.7714 67.4377 35.7712 68.4379C34.771 69.4381 33.4145 70 32 70Z" stroke="url(#paint2_linear_493_43922)" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_4" d="M61.3333 59.3333V27.3333C61.3333 25.9188 60.7714 24.5623 59.7712 23.5621C58.771 22.5619 57.4145 22 56 22H21.3333M21.3333 22C22.7478 22 24.1044 22.5619 25.1046 23.5621C26.1048 24.5623 26.6667 25.9188 26.6667 27.3333V64.6667C26.6667 66.0812 27.2286 67.4377 28.2288 68.4379C29.229 69.4381 30.5855 70 32 70M21.3333 22C19.9188 22 18.5623 22.5619 17.5621 23.5621C16.5619 24.5623 16 25.9188 16 27.3333V32.6667C16 33.3739 16.281 34.0522 16.781 34.5523C17.2811 35.0524 17.9594 35.3333 18.6667 35.3333H26.6667M32 70H64C65.4145 70 66.771 69.4381 67.7712 68.4379C68.7714 67.4377 69.3333 66.0812 69.3333 64.6667V62C69.3333 61.2928 69.0524 60.6145 68.5523 60.1144C68.0522 59.6143 67.3739 59.3333 66.6667 59.3333H40C39.2928 59.3333 38.6145 59.6143 38.1144 60.1144C37.6143 60.6145 37.3333 61.2928 37.3333 62V64.6667C37.3333 66.0812 36.7714 67.4377 35.7712 68.4379C34.771 69.4381 33.4145 70 32 70Z" stroke="url(#paint3_linear_493_43922)" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round"/>
<path id="Vector_5" d="M56 52L51.1778 47.1778M53.7778 40.8889C53.7778 45.7981 49.7981 49.7778 44.8889 49.7778C39.9797 49.7778 36 45.7981 36 40.8889C36 35.9797 39.9797 32 44.8889 32C49.7981 32 53.7778 35.9797 53.7778 40.8889Z" stroke="url(#paint4_linear_493_43922)" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<linearGradient id="paint0_linear_493_43922" x1="41.9987" y1="34" x2="41.9987" y2="82" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#7B7B7C"/>
</linearGradient>
<linearGradient id="paint1_linear_493_43922" x1="41.9987" y1="28" x2="41.9987" y2="76" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#7B7B7C"/>
</linearGradient>
<linearGradient id="paint2_linear_493_43922" x1="42.6667" y1="22" x2="42.6667" y2="70" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#7B7B7C"/>
</linearGradient>
<linearGradient id="paint3_linear_493_43922" x1="42.6667" y1="22" x2="42.6667" y2="70" gradientUnits="userSpaceOnUse">
<stop stop-color="#161618"/>
<stop offset="1" stop-color="#7B7B7C"/>
</linearGradient>
<linearGradient id="paint4_linear_493_43922" x1="46" y1="32" x2="46" y2="52" gradientUnits="userSpaceOnUse">
<stop stop-color="#161618"/>
<stop offset="1" stop-color="#7B7B7C"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@ -0,0 +1,39 @@
<svg width="84" height="104" viewBox="0 0 84 104" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="search nothing">
<path id="Vector" d="M60.6654 71.3333V39.3333C60.6654 37.9188 60.1035 36.5623 59.1033 35.5621C58.1031 34.5619 56.7465 34 55.332 34H20.6654M20.6654 34C22.0799 34 23.4364 34.5619 24.4366 35.5621C25.4368 36.5623 25.9987 37.9188 25.9987 39.3333V76.6667C25.9987 78.0812 26.5606 79.4377 27.5608 80.4379C28.561 81.4381 29.9175 82 31.332 82M20.6654 34C19.2509 34 17.8943 34.5619 16.8941 35.5621C15.8939 36.5623 15.332 37.9188 15.332 39.3333V44.6667C15.332 45.3739 15.613 46.0522 16.1131 46.5523C16.6132 47.0524 17.2915 47.3333 17.9987 47.3333H25.9987M31.332 82H63.332C64.7465 82 66.1031 81.4381 67.1033 80.4379C68.1035 79.4377 68.6654 78.0812 68.6654 76.6667V74C68.6654 73.2928 68.3844 72.6145 67.8843 72.1144C67.3842 71.6143 66.7059 71.3333 65.9987 71.3333H39.332C38.6248 71.3333 37.9465 71.6143 37.4464 72.1144C36.9463 72.6145 36.6654 73.2928 36.6654 74V76.6667C36.6654 78.0812 36.1035 79.4377 35.1033 80.4379C34.1031 81.4381 32.7465 82 31.332 82Z" stroke="url(#paint0_linear_486_6812)" stroke-opacity="0.2" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round"/>
<g id="Vector_2">
<path d="M60.6654 65.3333V33.3333C60.6654 31.9188 60.1035 30.5623 59.1033 29.5621C58.1031 28.5619 56.7465 28 55.332 28H20.6654" fill="#161618"/>
<path d="M31.332 76H63.332C64.7465 76 66.1031 75.4381 67.1033 74.4379C68.1035 73.4377 68.6654 72.0812 68.6654 70.6667V68C68.6654 67.2928 68.3844 66.6145 67.8843 66.1144C67.3842 65.6143 66.7059 65.3333 65.9987 65.3333H39.332C38.6248 65.3333 37.9465 65.6143 37.4464 66.1144C36.9463 66.6145 36.6654 67.2928 36.6654 68V70.6667C36.6654 72.0812 36.1035 73.4377 35.1033 74.4379C34.1031 75.4381 32.7465 76 31.332 76C29.9175 76 28.561 75.4381 27.5608 74.4379C26.5606 73.4377 25.9987 72.0812 25.9987 70.6667V33.3333C25.9987 31.9188 25.4368 30.5623 24.4366 29.5621C23.4364 28.5619 22.0799 28 20.6654 28C19.2509 28 17.8943 28.5619 16.8941 29.5621C15.8939 30.5623 15.332 31.9188 15.332 33.3333V38.6667C15.332 39.3739 15.613 40.0522 16.1131 40.5523C16.6132 41.0524 17.2915 41.3333 17.9987 41.3333H25.9987" fill="#161618"/>
<path d="M60.6654 65.3333V33.3333C60.6654 31.9188 60.1035 30.5623 59.1033 29.5621C58.1031 28.5619 56.7465 28 55.332 28H20.6654M20.6654 28C22.0799 28 23.4364 28.5619 24.4366 29.5621C25.4368 30.5623 25.9987 31.9188 25.9987 33.3333V70.6667C25.9987 72.0812 26.5606 73.4377 27.5608 74.4379C28.561 75.4381 29.9175 76 31.332 76M20.6654 28C19.2509 28 17.8943 28.5619 16.8941 29.5621C15.8939 30.5623 15.332 31.9188 15.332 33.3333V38.6667C15.332 39.3739 15.613 40.0522 16.1131 40.5523C16.6132 41.0524 17.2915 41.3333 17.9987 41.3333H25.9987M31.332 76H63.332C64.7465 76 66.1031 75.4381 67.1033 74.4379C68.1035 73.4377 68.6654 72.0812 68.6654 70.6667V68C68.6654 67.2928 68.3844 66.6145 67.8843 66.1144C67.3842 65.6143 66.7059 65.3333 65.9987 65.3333H39.332C38.6248 65.3333 37.9465 65.6143 37.4464 66.1144C36.9463 66.6145 36.6654 67.2928 36.6654 68V70.6667C36.6654 72.0812 36.1035 73.4377 35.1033 74.4379C34.1031 75.4381 32.7465 76 31.332 76Z" stroke="url(#paint1_linear_486_6812)" stroke-opacity="0.6" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<path id="Vector_3" d="M61.3333 59.3333V27.3333C61.3333 25.9188 60.7714 24.5623 59.7712 23.5621C58.771 22.5619 57.4145 22 56 22H21.3333M21.3333 22C22.7478 22 24.1044 22.5619 25.1046 23.5621C26.1048 24.5623 26.6667 25.9188 26.6667 27.3333V64.6667C26.6667 66.0812 27.2286 67.4377 28.2288 68.4379C29.229 69.4381 30.5855 70 32 70M21.3333 22C19.9188 22 18.5623 22.5619 17.5621 23.5621C16.5619 24.5623 16 25.9188 16 27.3333V32.6667C16 33.3739 16.281 34.0522 16.781 34.5523C17.2811 35.0524 17.9594 35.3333 18.6667 35.3333H26.6667M32 70H64C65.4145 70 66.771 69.4381 67.7712 68.4379C68.7714 67.4377 69.3333 66.0812 69.3333 64.6667V62C69.3333 61.2928 69.0524 60.6145 68.5523 60.1144C68.0522 59.6143 67.3739 59.3333 66.6667 59.3333H40C39.2928 59.3333 38.6145 59.6143 38.1144 60.1144C37.6143 60.6145 37.3333 61.2928 37.3333 62V64.6667C37.3333 66.0812 36.7714 67.4377 35.7712 68.4379C34.771 69.4381 33.4145 70 32 70Z" stroke="url(#paint2_linear_486_6812)" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round"/>
<g id="Vector_4">
<path d="M61.3333 59.3333V27.3333C61.3333 25.9188 60.7714 24.5623 59.7712 23.5621C58.771 22.5619 57.4145 22 56 22H21.3333" fill="#161618"/>
<path d="M32 70H64C65.4145 70 66.771 69.4381 67.7712 68.4379C68.7714 67.4377 69.3333 66.0812 69.3333 64.6667V62C69.3333 61.2928 69.0524 60.6145 68.5523 60.1144C68.0522 59.6143 67.3739 59.3333 66.6667 59.3333H40C39.2928 59.3333 38.6145 59.6143 38.1144 60.1144C37.6143 60.6145 37.3333 61.2928 37.3333 62V64.6667C37.3333 66.0812 36.7714 67.4377 35.7712 68.4379C34.771 69.4381 33.4145 70 32 70C30.5855 70 29.229 69.4381 28.2288 68.4379C27.2286 67.4377 26.6667 66.0812 26.6667 64.6667V27.3333C26.6667 25.9188 26.1048 24.5623 25.1046 23.5621C24.1044 22.5619 22.7478 22 21.3333 22C19.9188 22 18.5623 22.5619 17.5621 23.5621C16.5619 24.5623 16 25.9188 16 27.3333V32.6667C16 33.3739 16.281 34.0522 16.781 34.5523C17.2811 35.0524 17.9594 35.3333 18.6667 35.3333H26.6667" fill="#161618"/>
<path d="M61.3333 59.3333V27.3333C61.3333 25.9188 60.7714 24.5623 59.7712 23.5621C58.771 22.5619 57.4145 22 56 22H21.3333M21.3333 22C22.7478 22 24.1044 22.5619 25.1046 23.5621C26.1048 24.5623 26.6667 25.9188 26.6667 27.3333V64.6667C26.6667 66.0812 27.2286 67.4377 28.2288 68.4379C29.229 69.4381 30.5855 70 32 70M21.3333 22C19.9188 22 18.5623 22.5619 17.5621 23.5621C16.5619 24.5623 16 25.9188 16 27.3333V32.6667C16 33.3739 16.281 34.0522 16.781 34.5523C17.2811 35.0524 17.9594 35.3333 18.6667 35.3333H26.6667M32 70H64C65.4145 70 66.771 69.4381 67.7712 68.4379C68.7714 67.4377 69.3333 66.0812 69.3333 64.6667V62C69.3333 61.2928 69.0524 60.6145 68.5523 60.1144C68.0522 59.6143 67.3739 59.3333 66.6667 59.3333H40C39.2928 59.3333 38.6145 59.6143 38.1144 60.1144C37.6143 60.6145 37.3333 61.2928 37.3333 62V64.6667C37.3333 66.0812 36.7714 67.4377 35.7712 68.4379C34.771 69.4381 33.4145 70 32 70Z" stroke="url(#paint3_linear_486_6812)" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<path id="Vector_5" d="M56 52L51.1778 47.1778M53.7778 40.8889C53.7778 45.7981 49.7981 49.7778 44.8889 49.7778C39.9797 49.7778 36 45.7981 36 40.8889C36 35.9797 39.9797 32 44.8889 32C49.7981 32 53.7778 35.9797 53.7778 40.8889Z" stroke="url(#paint4_linear_486_6812)" stroke-width="0.5" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<linearGradient id="paint0_linear_486_6812" x1="41.9987" y1="34" x2="41.9987" y2="82" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#7B7B7C"/>
</linearGradient>
<linearGradient id="paint1_linear_486_6812" x1="41.9987" y1="28" x2="41.9987" y2="76" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#7B7B7C"/>
</linearGradient>
<linearGradient id="paint2_linear_486_6812" x1="42.6667" y1="22" x2="42.6667" y2="70" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#7B7B7C"/>
</linearGradient>
<linearGradient id="paint3_linear_486_6812" x1="42.6667" y1="22" x2="42.6667" y2="70" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#7B7B7C"/>
</linearGradient>
<linearGradient id="paint4_linear_486_6812" x1="46" y1="32" x2="46" y2="52" gradientUnits="userSpaceOnUse">
<stop stop-color="white"/>
<stop offset="1" stop-color="#7B7B7C"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 7.4 KiB

View File

@ -0,0 +1,33 @@
import { t } from 'i18next';
import { HomeIcon } from '../svg-icon';
export enum EmptyType {
Data = 'data',
SearchData = 'search-data',
}
export enum EmptyCardType {
Agent = 'agent',
Dataset = 'dataset',
Chat = 'chat',
Search = 'search',
}
export const EmptyCardData = {
[EmptyCardType.Agent]: {
icon: <HomeIcon name="agents" width={'24'} />,
title: t('empty.agentTitle'),
},
[EmptyCardType.Dataset]: {
icon: <HomeIcon name="datasets" width={'24'} />,
title: t('empty.datasetTitle'),
},
[EmptyCardType.Chat]: {
icon: <HomeIcon name="chats" width={'24'} />,
title: t('empty.chatTitle'),
},
[EmptyCardType.Search]: {
icon: <HomeIcon name="searches" width={'24'} />,
title: t('empty.searchTitle'),
},
};

View File

@ -2,79 +2,33 @@ import { cn } from '@/lib/utils';
import { t } from 'i18next';
import { useIsDarkTheme } from '../theme-provider';
import noDataIcon from './no data bri.svg';
import noDataIconDark from './no data.svg';
type EmptyProps = {
className?: string;
children?: React.ReactNode;
};
const EmptyIcon = () => {
const isDarkTheme = useIsDarkTheme();
import { Plus } from 'lucide-react';
import { useMemo } from 'react';
import SvgIcon from '../svg-icon';
import { EmptyCardData, EmptyCardType, EmptyType } from './constant';
import { EmptyCardProps, EmptyProps } from './interface';
const EmptyIcon = ({ name, width }: { name: string; width?: number }) => {
return (
<img
className="h-20"
src={isDarkTheme ? noDataIconDark : noDataIcon}
alt={t('common.noData')}
/>
);
return (
<svg
width="184"
height="152"
viewBox="0 0 184 152"
xmlns="http://www.w3.org/2000/svg"
>
<title>{t('common.noData')}</title>
<g fill="none" fillRule="evenodd">
<g transform="translate(24 31.67)">
<ellipse
fillOpacity=".8"
fill={isDarkTheme ? '#28282A' : '#F5F5F7'}
cx="67.797"
cy="106.89"
rx="67.797"
ry="12.668"
></ellipse>
<path
d="M122.034 69.674L98.109 40.229c-1.148-1.386-2.826-2.225-4.593-2.225h-51.44c-1.766 0-3.444.839-4.592 2.225L13.56 69.674v15.383h108.475V69.674z"
fill={isDarkTheme ? '#736960' : '#AEB8C2'}
></path>
<path
d="M101.537 86.214L80.63 61.102c-1.001-1.207-2.507-1.867-4.048-1.867H31.724c-1.54 0-3.047.66-4.048 1.867L6.769 86.214v13.792h94.768V86.214z"
fill="url(#linearGradient-1)"
transform="translate(13.56)"
></path>
<path
d="M33.83 0h67.933a4 4 0 0 1 4 4v93.344a4 4 0 0 1-4 4H33.83a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4z"
fill={isDarkTheme ? '#28282A' : '#F5F5F7'}
></path>
<path
d="M42.678 9.953h50.237a2 2 0 0 1 2 2V36.91a2 2 0 0 1-2 2H42.678a2 2 0 0 1-2-2V11.953a2 2 0 0 1 2-2zM42.94 49.767h49.713a2.262 2.262 0 1 1 0 4.524H42.94a2.262 2.262 0 0 1 0-4.524zM42.94 61.53h49.713a2.262 2.262 0 1 1 0 4.525H42.94a2.262 2.262 0 0 1 0-4.525zM121.813 105.032c-.775 3.071-3.497 5.36-6.735 5.36H20.515c-3.238 0-5.96-2.29-6.734-5.36a7.309 7.309 0 0 1-.222-1.79V69.675h26.318c2.907 0 5.25 2.448 5.25 5.42v.04c0 2.971 2.37 5.37 5.277 5.37h34.785c2.907 0 5.277-2.421 5.277-5.393V75.1c0-2.972 2.343-5.426 5.25-5.426h26.318v33.569c0 .617-.077 1.216-.221 1.789z"
fill={isDarkTheme ? '#45413A' : '#DCE0E6'}
></path>
</g>
<path
d="M149.121 33.292l-6.83 2.65a1 1 0 0 1-1.317-1.23l1.937-6.207c-2.589-2.944-4.109-6.534-4.109-10.408C138.802 8.102 148.92 0 161.402 0 173.881 0 184 8.102 184 18.097c0 9.995-10.118 18.097-22.599 18.097-4.528 0-8.744-1.066-12.28-2.902z"
fill={isDarkTheme ? '#45413A' : '#DCE0E6'}
></path>
<g
transform="translate(149.65 15.383)"
fill={isDarkTheme ? '#222' : '#FFF'}
>
<ellipse cx="20.654" cy="3.167" rx="2.849" ry="2.815"></ellipse>
<path d="M5.698 5.63H0L2.898.704zM9.259.704h4.985V5.63H9.259z"></path>
</g>
</g>
</svg>
// <img
// className="h-20"
// src={isDarkTheme ? noDataIconDark : noDataIcon}
// alt={t('common.noData')}
// />
<SvgIcon name={name || 'empty/no-data-dark'} width={width || 42} />
);
};
const Empty = (props: EmptyProps) => {
const { className, children } = props;
const { className, children, type, text, iconWidth } = props;
const isDarkTheme = useIsDarkTheme();
const name = useMemo(() => {
return isDarkTheme
? `empty/no-${type || EmptyType.Data}-dark`
: `empty/no-${type || EmptyType.Data}-bri`;
}, [isDarkTheme, type]);
return (
<div
className={cn(
@ -82,11 +36,12 @@ const Empty = (props: EmptyProps) => {
className,
)}
>
<EmptyIcon />
<EmptyIcon name={name} width={iconWidth} />
{!children && (
<div className="empty-text text-text-secondary">
{t('common.noData')}
<div className="empty-text text-text-secondary text-sm">
{text ||
(type === 'data' ? t('common.noData') : t('common.noResults'))}
</div>
)}
{children}
@ -95,3 +50,65 @@ const Empty = (props: EmptyProps) => {
};
export default Empty;
export const EmptyCard = (props: EmptyCardProps) => {
const { icon, className, children, title, description, style } = props;
return (
<div
className={cn(
'flex flex-col gap-3 items-start justify-start border border-dashed border-border-button rounded-md p-5 w-fit',
className,
)}
style={style}
>
{icon}
{title && <div className="text-text-primary text-base">{title}</div>}
{description && (
<div className="text-text-secondary text-sm">{description}</div>
)}
{children}
</div>
);
};
export const EmptyAppCard = (props: {
type: EmptyCardType;
onClick?: () => void;
showIcon?: boolean;
className?: string;
size?: 'small' | 'large';
}) => {
const { type, showIcon, className } = props;
let defaultClass = '';
let style = {};
switch (props.size) {
case 'small':
style = { width: '256px' };
defaultClass = 'mt-1';
break;
case 'large':
style = { width: '480px' };
defaultClass = 'mt-5';
break;
default:
defaultClass = '';
break;
}
return (
<div className=" cursor-pointer " onClick={props.onClick}>
<EmptyCard
icon={showIcon ? EmptyCardData[type].icon : undefined}
title={EmptyCardData[type].title}
className={className}
style={style}
// description={EmptyCardData[type].description}
>
<div
className={cn(defaultClass, 'flex items-center justify-start w-full')}
>
<Plus size={24} />
</div>
</EmptyCard>
</div>
);
};

View File

@ -0,0 +1,18 @@
import { EmptyType } from './constant';
export type EmptyProps = {
className?: string;
children?: React.ReactNode;
type?: EmptyType;
text?: string;
iconWidth?: number;
};
export type EmptyCardProps = {
icon?: React.ReactNode;
className?: string;
children?: React.ReactNode;
title?: string;
description?: string;
style?: React.CSSProperties;
};

View File

@ -1,8 +1,10 @@
import PdfDrawer from '@/components/pdf-drawer';
import PdfSheet from '@/components/pdf-drawer';
import { useClickDrawer } from '@/components/pdf-drawer/hooks';
import { MessageType } from '@/constants/chat';
import { MessageType, SharedFrom } from '@/constants/chat';
import { useFetchExternalAgentInputs } from '@/hooks/use-agent-request';
import { useFetchExternalChatInfo } from '@/hooks/use-chat-request';
import i18n from '@/locales/config';
import { useSendNextSharedMessage } from '@/pages/agent/hooks/use-send-shared-message';
import { MessageCircle, Minimize2, Send, X } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
@ -20,7 +22,13 @@ const FloatingChatWidget = () => {
const [isLoaded, setIsLoaded] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const { sharedId: conversationId, locale } = useGetSharedChatSearchParams();
const {
sharedId: conversationId,
locale,
from,
} = useGetSharedChatSearchParams();
const isFromAgent = from === SharedFrom.Agent;
// Check if we're in button-only mode or window-only mode
const urlParams = new URLSearchParams(window.location.search);
@ -34,7 +42,7 @@ const FloatingChatWidget = () => {
sendLoading,
derivedMessages,
hasError,
} = useSendSharedMessage();
} = (isFromAgent ? useSendNextSharedMessage : useSendSharedMessage)(() => {});
// Sync our local input with the hook's value when needed
useEffect(() => {
@ -43,7 +51,11 @@ const FloatingChatWidget = () => {
}
}, [hookValue, inputValue]);
const { data: chatInfo } = useFetchExternalChatInfo();
const { data } = (
isFromAgent ? useFetchExternalAgentInputs : useFetchExternalChatInfo
)();
const title = data.title;
const { visible, hideModal, documentId, selectedChunk, clickDocumentButton } =
useClickDrawer();
@ -372,7 +384,7 @@ const FloatingChatWidget = () => {
</div>
<div>
<h3 className="font-semibold text-sm">
{chatInfo?.title || 'Chat Support'}
{title || 'Chat Support'}
</h3>
<p className="text-xs text-blue-100">
We typically reply instantly
@ -494,14 +506,16 @@ const FloatingChatWidget = () => {
</div>
</div>
</div>
<PdfDrawer
visible={visible}
hideModal={hideModal}
documentId={documentId}
chunk={selectedChunk}
width={'100vw'}
height={'100vh'}
/>
{visible && (
<PdfSheet
visible={visible}
hideModal={hideModal}
documentId={documentId}
chunk={selectedChunk}
width={'100vw'}
height={'100vh'}
/>
)}
</>
);
} // Full mode - render everything together (original behavior)
@ -524,7 +538,7 @@ const FloatingChatWidget = () => {
</div>
<div>
<h3 className="font-semibold text-sm">
{chatInfo?.title || 'Chat Support'}
{title || 'Chat Support'}
</h3>
<p className="text-xs text-blue-100">
We typically reply instantly
@ -695,7 +709,7 @@ const FloatingChatWidget = () => {
</div>
)}
</div>
<PdfDrawer
<PdfSheet
visible={visible}
hideModal={hideModal}
documentId={documentId}

View File

@ -2,8 +2,6 @@ import Image from '@/components/image';
import SvgIcon from '@/components/svg-icon';
import { IReference, IReferenceChunk } from '@/interfaces/database/chat';
import { getExtension } from '@/utils/document-util';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Button, Flex, Popover } from 'antd';
import DOMPurify from 'dompurify';
import { useCallback, useEffect, useMemo } from 'react';
import Markdown from 'react-markdown';
@ -27,10 +25,16 @@ import {
replaceThinkToSection,
showImage,
} from '@/utils/chat';
import classNames from 'classnames';
import { omit } from 'lodash';
import { pipe } from 'lodash/fp';
import { CircleAlert } from 'lucide-react';
import { Button } from '../ui/button';
import {
HoverCard,
HoverCardContent,
HoverCardTrigger,
} from '../ui/hover-card';
import styles from './index.less';
const getChunkIndex = (match: string) => Number(match);
@ -145,20 +149,20 @@ const MarkdownContent = ({
return (
<div key={chunkItem?.id} className="flex gap-2">
{imageId && (
<Popover
placement="left"
content={
<HoverCard>
<HoverCardTrigger>
<Image
id={imageId}
className={styles.referenceChunkImage}
></Image>
</HoverCardTrigger>
<HoverCardContent>
<Image
id={imageId}
className={styles.referenceImagePreview}
></Image>
}
>
<Image
id={imageId}
className={styles.referenceChunkImage}
></Image>
</Popover>
</HoverCardContent>
</HoverCard>
)}
<div className={'space-y-2 max-w-[40vw]'}>
<div
@ -168,7 +172,7 @@ const MarkdownContent = ({
className={classNames(styles.chunkContentText)}
></div>
{documentId && (
<Flex gap={'small'}>
<section className="flex gap-1">
{fileThumbnail ? (
<img
src={fileThumbnail}
@ -182,8 +186,8 @@ const MarkdownContent = ({
></SvgIcon>
)}
<Button
type="link"
className={classNames(styles.documentLink, 'text-wrap')}
variant="link"
className={'text-wrap p-0'}
onClick={handleDocumentButtonClick(
documentId,
chunkItem,
@ -193,7 +197,7 @@ const MarkdownContent = ({
>
{document?.doc_name}
</Button>
</Flex>
</section>
)}
</div>
</div>
@ -228,9 +232,14 @@ const MarkdownContent = ({
}
></Image>
) : (
<Popover content={getPopoverContent(chunkIndex)} key={i}>
<InfoCircleOutlined className={styles.referenceIcon} />
</Popover>
<HoverCard key={i}>
<HoverCardTrigger>
<CircleAlert className="size-4 inline-block" />
</HoverCardTrigger>
<HoverCardContent className="max-w-3xl">
{getPopoverContent(chunkIndex)}
</HoverCardContent>
</HoverCard>
);
});

View File

@ -14,10 +14,10 @@ import {
} from '@/hooks/document-hooks';
import { IRegenerateMessage, IRemoveMessageById } from '@/hooks/logic-hooks';
import { cn } from '@/lib/utils';
import { Avatar, Flex, Space } from 'antd';
import MarkdownContent from '../markdown-content';
import { ReferenceDocumentList } from '../next-message-item/reference-document-list';
import { InnerUploadedMessageFiles } from '../next-message-item/uploaded-message-files';
import { RAGFlowAvatar } from '../ragflow-avatar';
import { useTheme } from '../theme-provider';
import { AssistantGroupButton, UserGroupButton } from './group-button';
import styles from './index.less';
@ -98,40 +98,43 @@ const MessageItem = ({
>
{visibleAvatar &&
(item.role === MessageType.User ? (
<Avatar size={40} src={avatar ?? '/logo.svg'} />
<RAGFlowAvatar
className="size-10"
avatar={avatar ?? '/logo.svg'}
isPerson
/>
) : avatarDialog ? (
<Avatar size={40} src={avatarDialog} />
<RAGFlowAvatar
className="size-10"
avatar={avatarDialog}
isPerson
/>
) : (
<AssistantIcon />
))}
<Flex vertical gap={8} flex={1}>
<Space>
{isAssistant ? (
index !== 0 && (
<AssistantGroupButton
messageId={item.id}
content={item.content}
prompt={item.prompt}
showLikeButton={showLikeButton}
audioBinary={item.audio_binary}
showLoudspeaker={showLoudspeaker}
></AssistantGroupButton>
)
) : (
<UserGroupButton
content={item.content}
<section className="flex gap-2 flex-1 flex-col">
{isAssistant ? (
index !== 0 && (
<AssistantGroupButton
messageId={item.id}
removeMessageById={removeMessageById}
regenerateMessage={
regenerateMessage && handleRegenerateMessage
}
sendLoading={sendLoading}
></UserGroupButton>
)}
content={item.content}
prompt={item.prompt}
showLikeButton={showLikeButton}
audioBinary={item.audio_binary}
showLoudspeaker={showLoudspeaker}
></AssistantGroupButton>
)
) : (
<UserGroupButton
content={item.content}
messageId={item.id}
removeMessageById={removeMessageById}
regenerateMessage={regenerateMessage && handleRegenerateMessage}
sendLoading={sendLoading}
></UserGroupButton>
)}
{/* <b>{isAssistant ? '' : nickname}</b> */}
</Space>
<div
className={cn(
isAssistant
@ -159,7 +162,7 @@ const MessageItem = ({
files={documentList}
></InnerUploadedMessageFiles>
)}
</Flex>
</section>
</div>
</section>
</div>

View File

@ -1,8 +1,9 @@
import { IModalProps } from '@/interfaces/common';
import { IReferenceChunk } from '@/interfaces/database/chat';
import { IChunk } from '@/interfaces/database/knowledge';
import { Drawer } from 'antd';
import { cn } from '@/lib/utils';
import DocumentPreviewer from '../pdf-previewer';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '../ui/sheet';
interface IProps extends IModalProps<any> {
documentId: string;
@ -11,7 +12,7 @@ interface IProps extends IModalProps<any> {
height?: string | number;
}
export const PdfDrawer = ({
export const PdfSheet = ({
visible = false,
hideModal,
documentId,
@ -20,20 +21,25 @@ export const PdfDrawer = ({
height,
}: IProps) => {
return (
<Drawer
title="Document Previewer"
onClose={hideModal}
open={visible}
width={width}
height={height}
>
<DocumentPreviewer
documentId={documentId}
chunk={chunk}
visible={visible}
></DocumentPreviewer>
</Drawer>
<Sheet open onOpenChange={hideModal}>
<SheetContent
className={cn(`max-w-full`)}
style={{
width: width,
height: height ? height : undefined,
}}
>
<SheetHeader>
<SheetTitle>Document Previewer</SheetTitle>
</SheetHeader>
<DocumentPreviewer
documentId={documentId}
chunk={chunk}
visible={visible}
></DocumentPreviewer>
</SheetContent>
</Sheet>
);
};
export default PdfDrawer;
export default PdfSheet;

View File

@ -58,7 +58,7 @@ export const useSelectLlmOptions = () => {
function buildLlmOptionsWithIcon(x: IThirdOAIModel) {
return {
label: (
<div className="flex items-center justify-center gap-6">
<div className="flex items-center justify-center gap-2">
<LlmIcon
name={getLLMIconName(x.fid, x.llm_name)}
width={24}

View File

@ -14,9 +14,16 @@ export const useNavigatePage = () => {
const [searchParams] = useSearchParams();
const { id } = useParams();
const navigateToDatasetList = useCallback(() => {
navigate(Routes.Datasets);
}, [navigate]);
const navigateToDatasetList = useCallback(
({ isCreate = false }: { isCreate?: boolean }) => {
if (isCreate) {
navigate(Routes.Datasets + '?isCreate=true');
} else {
navigate(Routes.Datasets);
}
},
[navigate],
);
const navigateToDataset = useCallback(
(id: string) => () => {

View File

@ -54,7 +54,7 @@ export const useFetchTenantInfo = (
): ResponseGetType<ITenantInfo> => {
const { t } = useTranslation();
const { data, isFetching: loading } = useQuery({
queryKey: ['tenantInfo'],
queryKey: ['tenantInfo', showEmptyModelWarn],
initialData: {},
gcTime: 0,
queryFn: async () => {
@ -99,7 +99,6 @@ export const useSelectParserList = (): Array<{
label: string;
}> => {
const { data: tenantInfo } = useFetchTenantInfo(true);
const parserList = useMemo(() => {
const parserArray: Array<string> = tenantInfo?.parser_ids?.split(',') ?? [];
return parserArray.map((x) => {

View File

@ -3,7 +3,7 @@ export default {
common: {
confirm: 'Confirm',
back: 'Back',
noResults: 'No results.',
noResults: 'No results found',
selectPlaceholder: 'select value',
selectAll: 'Select all',
delete: 'Delete',
@ -51,7 +51,7 @@ export default {
remove: 'Remove',
search: 'Search',
noDataFound: 'No data found.',
noData: 'No data',
noData: 'No data available',
promptPlaceholder: `Please input or use / to quickly insert variables.`,
mcp: {
namePlaceholder: 'My MCP Server',
@ -739,6 +739,7 @@ Example: Virtual Hosted Style`,
'Sync pages and databases from Notion for knowledge retrieval.',
google_driveDescription:
'Connect your Google Drive via OAuth and sync specific folders or drives.',
gmailDescription: 'Connect your Gmail via OAuth to sync emails.',
webdavDescription: 'Connect to WebDAV servers to sync files.',
webdavRemotePathTip:
'Optional: Specify a folder path on the WebDAV server (e.g., /Documents). Leave empty to sync from root.',
@ -750,6 +751,10 @@ Example: Virtual Hosted Style`,
'Comma-separated emails whose "My Drive" contents should be indexed (include the primary admin).',
google_driveSharedFoldersTip:
'Comma-separated Google Drive folder links to crawl.',
gmailPrimaryAdminTip:
'Primary admin email with Gmail / Workspace access, used to enumerate domain users and as the default sync account.',
gmailTokenTip:
'Upload the OAuth JSON generated from Google Console. If it only contains client credentials, run the browser-based verification once to mint long-lived refresh tokens.',
dropboxDescription:
'Connect your Dropbox to sync files and folders from a chosen account.',
dropboxAccessTokenTip:
@ -2021,6 +2026,7 @@ Important structured information may include: names, dates, locations, events, k
processingSuccessTip: 'Total successfully processed files',
processingFailedTip: 'Total failed processes',
processing: 'Processing',
noData: 'No log yet',
},
deleteModal: {
@ -2034,6 +2040,15 @@ Important structured information may include: names, dates, locations, events, k
delMember: 'Delete member',
},
empty: {
noMCP: 'No MCP servers available',
agentTitle: 'No agent app created yet',
datasetTitle: 'No dataset created yet',
chatTitle: 'No chat app created yet',
searchTitle: 'No search app created yet',
addNow: 'Add Now',
},
admin: {
loginTitle: 'Admin Console',
title: 'RAGFlow',

View File

@ -736,6 +736,8 @@ export default {
'Синхронизируйте страницы и базы данных из Notion для извлечения знаний.',
google_driveDescription:
'Подключите ваш Google Drive через OAuth и синхронизируйте определенные папки или диски.',
gmailDescription:
'Подключите ваш Gmail / Google Workspace аккаунт для синхронизации писем и их метаданных, чтобы построить корпоративную почтовую базу знаний и поиск с учетом прав доступа.',
google_driveTokenTip:
'Загрузите JSON токена OAuth, сгенерированный из помощника OAuth или Google Cloud Console. Вы также можете загрузить client_secret JSON из "установленного" или "веб" приложения. Если это ваша первая синхронизация, откроется окно браузера для завершения согласия OAuth. Если JSON уже содержит токен обновления, он будет автоматически повторно использован.',
google_drivePrimaryAdminTip:
@ -744,6 +746,10 @@ export default {
'Электронные почты через запятую, чье содержимое "Мой диск" должно индексироваться (включите основного администратора).',
google_driveSharedFoldersTip:
'Ссылки на папки Google Drive через запятую для обхода.',
gmailPrimaryAdminTip:
'Основной административный email с доступом к Gmail / Workspace, используется для перечисления пользователей домена и как аккаунт синхронизации по умолчанию.',
gmailTokenTip:
'Загрузите OAuth JSON, сгенерированный в Google Console. Если он содержит только учетные данные клиента, выполните одноразовое подтверждение в браузере, чтобы получить долгоживущие токены обновления.',
jiraDescription:
'Подключите ваше рабочее пространство Jira для синхронизации задач, комментариев и вложений.',
jiraBaseUrlTip:

View File

@ -3,7 +3,7 @@ export default {
common: {
confirm: '确定',
back: '返回',
noResults: '结果',
noResults: '未查到结果',
selectPlaceholder: '请选择',
selectAll: '全选',
delete: '删除',
@ -718,6 +718,7 @@ General实体和关系提取提示来自 GitHub - microsoft/graphrag基于
notionDescription: ' 同步 Notion 页面与数据库,用于知识检索。',
google_driveDescription:
'通过 OAuth 连接 Google Drive并同步指定的文件夹或云端硬盘。',
gmailDescription: '通过 OAuth 连接 Gmail用于同步邮件。',
google_driveTokenTip:
'请上传由 OAuth helper 或 Google Cloud Console 导出的 OAuth token JSON。也支持上传 “installed” 或 “web” 类型的 client_secret JSON。若为首次同步将自动弹出浏览器完成 OAuth 授权流程;如果该 JSON 已包含 refresh token将会被自动复用。',
google_drivePrimaryAdminTip: '拥有相应 Drive 访问权限的管理员邮箱。',
@ -725,6 +726,10 @@ General实体和关系提取提示来自 GitHub - microsoft/graphrag基于
'需要索引其 “我的云端硬盘” 的邮箱,多个邮箱用逗号分隔(建议包含管理员)。',
google_driveSharedFoldersTip:
'需要同步的 Google Drive 文件夹链接,多个链接用逗号分隔。',
gmailPrimaryAdminTip:
'拥有 Gmail / Workspace 访问权限的主要管理员邮箱,用于列出域内用户并作为默认同步账号。',
gmailTokenTip:
'请上传由 Google Console 生成的 OAuth JSON。如果仅包含 client credentials请通过浏览器授权一次以获取长期有效的刷新 Token。',
dropboxDescription: '连接 Dropbox同步指定账号下的文件与文件夹。',
dropboxAccessTokenTip:
'请在 Dropbox App Console 生成 Access Token并勾选 files.metadata.read、files.content.read、sharing.read 等必要权限。',
@ -1873,6 +1878,27 @@ Tokenizer 会根据所选方式将内容存储为对应的数据结构。`,
downloadFailedTip: '下载失败总数',
processingSuccessTip: '处理成功的文件总数',
processingFailedTip: '处理失败的文件总数',
noData: '暂无日志',
},
deleteModal: {
delAgent: '删除智能体',
delDataset: '删除知识库',
delSearch: '删除搜索',
delFile: '删除文件',
delFiles: '删除文件',
delFilesContent: '已选择 {{count}} 个文件',
delChat: '删除聊天',
delMember: '删除成员',
},
empty: {
noMCP: '暂无 MCP 服务器可用',
agentTitle: '尚未创建智能体',
datasetTitle: '尚未创建数据集',
chatTitle: '尚未创建聊天应用',
searchTitle: '尚未创建搜索应用',
addNow: '立即添加',
},
deleteModal: {

View File

@ -5,7 +5,7 @@ import { useSendAgentMessage } from './use-send-agent-message';
import { FileUploadProps } from '@/components/file-upload';
import { NextMessageInput } from '@/components/message-input/next';
import MessageItem from '@/components/next-message-item';
import PdfDrawer from '@/components/pdf-drawer';
import PdfSheet from '@/components/pdf-drawer';
import { useClickDrawer } from '@/components/pdf-drawer/hooks';
import {
useFetchAgent,
@ -127,12 +127,14 @@ function AgentChatBox() {
/>
)}
</section>
<PdfDrawer
visible={visible}
hideModal={hideModal}
documentId={documentId}
chunk={selectedChunk}
></PdfDrawer>
{visible && (
<PdfSheet
visible={visible}
hideModal={hideModal}
documentId={documentId}
chunk={selectedChunk}
></PdfSheet>
)}
</>
);
}

View File

@ -2,7 +2,7 @@ import { EmbedContainer } from '@/components/embed-container';
import { FileUploadProps } from '@/components/file-upload';
import { NextMessageInput } from '@/components/message-input/next';
import MessageItem from '@/components/next-message-item';
import PdfDrawer from '@/components/pdf-drawer';
import PdfSheet from '@/components/pdf-drawer';
import { useClickDrawer } from '@/components/pdf-drawer/hooks';
import { MessageType } from '@/constants/chat';
import { useUploadCanvasFileWithProgress } from '@/hooks/use-agent-request';
@ -204,12 +204,12 @@ const ChatContainer = () => {
</div>
</EmbedContainer>
{visible && (
<PdfDrawer
<PdfSheet
visible={visible}
hideModal={hideModal}
documentId={documentId}
chunk={selectedChunk}
></PdfDrawer>
></PdfSheet>
)}
{parameterDialogVisible && (
<ParameterDialog

View File

@ -1,4 +1,6 @@
import { CardContainer } from '@/components/card-container';
import { EmptyCardType } from '@/components/empty/constant';
import { EmptyAppCard } from '@/components/empty/empty';
import ListFilterBar from '@/components/list-filter-bar';
import { RenameDialog } from '@/components/rename-dialog';
import { Button } from '@/components/ui/button';
@ -14,7 +16,8 @@ import { useFetchAgentListByPage } from '@/hooks/use-agent-request';
import { t } from 'i18next';
import { pick } from 'lodash';
import { Clipboard, ClipboardPlus, FileInput, Plus } from 'lucide-react';
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { useSearchParams } from 'umi';
import { AgentCard } from './agent-card';
import { CreateAgentDialog } from './create-agent-dialog';
import { useCreateAgentOrPipeline } from './hooks/use-create-agent';
@ -67,95 +70,120 @@ export default function Agents() {
},
[setPagination],
);
const [searchUrl, setSearchUrl] = useSearchParams();
const isCreate = searchUrl.get('isCreate') === 'true';
useEffect(() => {
if (isCreate) {
showCreatingModal();
searchUrl.delete('isCreate');
setSearchUrl(searchUrl);
}
}, [isCreate, showCreatingModal, searchUrl, setSearchUrl]);
return (
<section className="flex flex-col w-full flex-1">
<div className="px-8 pt-8 ">
<ListFilterBar
title={t('flow.agents')}
searchString={searchString}
onSearchChange={handleInputChange}
icon="agents"
filters={filters}
onChange={handleFilterSubmit}
value={filterValue}
>
<DropdownMenu>
<DropdownMenuTrigger>
<Button>
<Plus className="mr-2 h-4 w-4" />
{t('flow.createGraph')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
justifyBetween={false}
onClick={showCreatingModal}
>
<Clipboard />
{t('flow.createFromBlank')}
</DropdownMenuItem>
<DropdownMenuItem
justifyBetween={false}
onClick={navigateToAgentTemplates}
>
<ClipboardPlus />
{t('flow.createFromTemplate')}
</DropdownMenuItem>
<DropdownMenuItem
justifyBetween={false}
onClick={handleImportJson}
>
<FileInput />
{t('flow.importJsonFile')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ListFilterBar>
</div>
<div className="flex-1 overflow-auto">
<CardContainer className="max-h-[calc(100dvh-280px)] overflow-auto px-8">
{data.map((x) => {
return (
<AgentCard
key={x.id}
data={x}
showAgentRenameModal={showAgentRenameModal}
></AgentCard>
);
})}
</CardContainer>
</div>
<div className="mt-8 px-8 pb-8">
<RAGFlowPagination
{...pick(pagination, 'current', 'pageSize')}
total={pagination.total}
onChange={handlePageChange}
></RAGFlowPagination>
</div>
{agentRenameVisible && (
<RenameDialog
hideModal={hideAgentRenameModal}
onOk={onAgentRenameOk}
initialName={initialAgentName}
loading={agentRenameLoading}
></RenameDialog>
<>
{(!data?.length || data?.length <= 0) && (
<div className="flex w-full items-center justify-center h-[calc(100vh-164px)]">
<EmptyAppCard
showIcon
size="large"
className="w-[480px] p-14"
type={EmptyCardType.Agent}
onClick={() => showCreatingModal()}
/>
</div>
)}
{creatingVisible && (
<CreateAgentDialog
loading={loading}
visible={creatingVisible}
hideModal={hideCreatingModal}
shouldChooseAgent
onOk={handleCreateAgentOrPipeline}
></CreateAgentDialog>
)}
{fileUploadVisible && (
<UploadAgentDialog
hideModal={hideFileUploadModal}
onOk={onFileUploadOk}
></UploadAgentDialog>
)}
</section>
<section className="flex flex-col w-full flex-1">
{!!data?.length && (
<>
<div className="px-8 pt-8 ">
<ListFilterBar
title={t('flow.agents')}
searchString={searchString}
onSearchChange={handleInputChange}
icon="agents"
filters={filters}
onChange={handleFilterSubmit}
value={filterValue}
>
<DropdownMenu>
<DropdownMenuTrigger>
<Button>
<Plus className="mr-2 h-4 w-4" />
{t('flow.createGraph')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
justifyBetween={false}
onClick={showCreatingModal}
>
<Clipboard />
{t('flow.createFromBlank')}
</DropdownMenuItem>
<DropdownMenuItem
justifyBetween={false}
onClick={navigateToAgentTemplates}
>
<ClipboardPlus />
{t('flow.createFromTemplate')}
</DropdownMenuItem>
<DropdownMenuItem
justifyBetween={false}
onClick={handleImportJson}
>
<FileInput />
{t('flow.importJsonFile')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</ListFilterBar>
</div>
<div className="flex-1 overflow-auto">
<CardContainer className="max-h-[calc(100dvh-280px)] overflow-auto px-8">
{data.map((x) => {
return (
<AgentCard
key={x.id}
data={x}
showAgentRenameModal={showAgentRenameModal}
></AgentCard>
);
})}
</CardContainer>
</div>
<div className="mt-8 px-8 pb-8">
<RAGFlowPagination
{...pick(pagination, 'current', 'pageSize')}
total={pagination.total}
onChange={handlePageChange}
></RAGFlowPagination>
</div>
</>
)}
{agentRenameVisible && (
<RenameDialog
hideModal={hideAgentRenameModal}
onOk={onAgentRenameOk}
initialName={initialAgentName}
loading={agentRenameLoading}
></RenameDialog>
)}
{creatingVisible && (
<CreateAgentDialog
loading={loading}
visible={creatingVisible}
hideModal={hideCreatingModal}
shouldChooseAgent
onOk={handleCreateAgentOrPipeline}
></CreateAgentDialog>
)}
{fileUploadVisible && (
<UploadAgentDialog
hideModal={hideFileUploadModal}
onOk={onFileUploadOk}
></UploadAgentDialog>
)}
</section>
</>
);
}

View File

@ -1,3 +1,5 @@
import { EmptyType } from '@/components/empty/constant';
import Empty from '@/components/empty/empty';
import FileStatusBadge from '@/components/file-status-badge';
import { FileIcon, IconFontFill } from '@/components/icon-font';
import { RAGFlowAvatar } from '@/components/ragflow-avatar';
@ -344,6 +346,7 @@ const FileLogsTable: FC<FileLogsTableProps> = ({
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [rowSelection, setRowSelection] = useState({});
const { t } = useTranslate('knowledgeDetails');
const { t: tDatasetOverview } = useTranslate('datasetOverview');
const [isModalVisible, setIsModalVisible] = useState(false);
const { navigateToDataflowResult } = useNavigatePage();
const [logInfo, setLogInfo] = useState<IFileLogItem>();
@ -445,7 +448,10 @@ const FileLogsTable: FC<FileLogsTableProps> = ({
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
<Empty
type={EmptyType.Data}
text={tDatasetOverview('noData')}
/>
</TableCell>
</TableRow>
)}

View File

@ -14,6 +14,8 @@ import {
import * as React from 'react';
import { ChunkMethodDialog } from '@/components/chunk-method-dialog';
import { EmptyType } from '@/components/empty/constant';
import Empty from '@/components/empty/empty';
import { RenameDialog } from '@/components/rename-dialog';
import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
import {
@ -164,7 +166,7 @@ export function DatasetTable({
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
<Empty type={EmptyType.Data} />
</TableCell>
</TableRow>
)}

View File

@ -136,7 +136,7 @@ export function useDatasetTableColumns({
{
DataSourceInfo[
row.original.source_type as keyof typeof DataSourceInfo
].icon
]?.icon
}
</div>
)}

View File

@ -1,3 +1,4 @@
import { EmptyType } from '@/components/empty/constant';
import Empty from '@/components/empty/empty';
import { FormContainer } from '@/components/form-container';
import { FilterButton } from '@/components/list-filter-bar';
@ -101,7 +102,7 @@ export function TestingResult({
{!data.chunks?.length && !loading && (
<div className="flex justify-center items-center w-full h-[calc(100vh-241px)]">
<div>
<Empty>
<Empty type={EmptyType.SearchData}>
{data.isRuned && (
<div className="text-text-secondary">
{t('knowledgeDetails.noTestResultsForRuned')}

View File

@ -1,13 +1,17 @@
import { CardContainer } from '@/components/card-container';
import { EmptyCardType } from '@/components/empty/constant';
import { EmptyAppCard } from '@/components/empty/empty';
import ListFilterBar from '@/components/list-filter-bar';
import { RenameDialog } from '@/components/rename-dialog';
import { Button } from '@/components/ui/button';
import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
import { useFetchNextKnowledgeListByPage } from '@/hooks/use-knowledge-request';
import { useQueryClient } from '@tanstack/react-query';
import { pick } from 'lodash';
import { Plus } from 'lucide-react';
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'umi';
import { DatasetCard } from './dataset-card';
import { DatasetCreatingDialog } from './dataset-creating-dialog';
import { useSaveKnowledge } from './hooks';
@ -52,59 +56,86 @@ export default function Datasets() {
},
[setPagination],
);
const [searchUrl, setSearchUrl] = useSearchParams();
const isCreate = searchUrl.get('isCreate') === 'true';
const queryClient = useQueryClient();
useEffect(() => {
if (isCreate) {
queryClient.invalidateQueries({ queryKey: ['tenantInfo'] });
showModal();
searchUrl.delete('isCreate');
setSearchUrl(searchUrl);
}
}, [isCreate, showModal, searchUrl, setSearchUrl]);
return (
<section className="py-4 flex-1 flex flex-col">
<ListFilterBar
title={t('header.dataset')}
searchString={searchString}
onSearchChange={handleInputChange}
value={filterValue}
filters={owners}
onChange={handleFilterSubmit}
className="px-8"
icon={'datasets'}
>
<Button onClick={showModal}>
<Plus className=" size-2.5" />
{t('knowledgeList.createKnowledgeBase')}
</Button>
</ListFilterBar>
<div className="flex-1">
<CardContainer className="max-h-[calc(100dvh-280px)] overflow-auto px-8">
{kbs.map((dataset) => {
return (
<DatasetCard
dataset={dataset}
key={dataset.id}
showDatasetRenameModal={showDatasetRenameModal}
></DatasetCard>
);
})}
</CardContainer>
</div>
<div className="mt-8 px-8">
<RAGFlowPagination
{...pick(pagination, 'current', 'pageSize')}
total={total}
onChange={handlePageChange}
></RAGFlowPagination>
</div>
{visible && (
<DatasetCreatingDialog
hideModal={hideModal}
onOk={onCreateOk}
loading={creatingLoading}
></DatasetCreatingDialog>
)}
{datasetRenameVisible && (
<RenameDialog
hideModal={hideDatasetRenameModal}
onOk={onDatasetRenameOk}
initialName={initialDatasetName}
loading={datasetRenameLoading}
></RenameDialog>
)}
</section>
<>
<section className="py-4 flex-1 flex flex-col">
{(!kbs?.length || kbs?.length <= 0) && (
<div className="flex w-full items-center justify-center h-[calc(100vh-164px)]">
<EmptyAppCard
showIcon
size="large"
className="w-[480px] p-14"
type={EmptyCardType.Dataset}
onClick={() => showModal()}
/>
</div>
)}
{!!kbs?.length && (
<>
<ListFilterBar
title={t('header.dataset')}
searchString={searchString}
onSearchChange={handleInputChange}
value={filterValue}
filters={owners}
onChange={handleFilterSubmit}
className="px-8"
icon={'datasets'}
>
<Button onClick={showModal}>
<Plus className=" size-2.5" />
{t('knowledgeList.createKnowledgeBase')}
</Button>
</ListFilterBar>
<div className="flex-1">
<CardContainer className="max-h-[calc(100dvh-280px)] overflow-auto px-8">
{kbs.map((dataset) => {
return (
<DatasetCard
dataset={dataset}
key={dataset.id}
showDatasetRenameModal={showDatasetRenameModal}
></DatasetCard>
);
})}
</CardContainer>
</div>
<div className="mt-8 px-8">
<RAGFlowPagination
{...pick(pagination, 'current', 'pageSize')}
total={total}
onChange={handlePageChange}
></RAGFlowPagination>
</div>
</>
)}
{visible && (
<DatasetCreatingDialog
hideModal={hideModal}
onOk={onCreateOk}
loading={creatingLoading}
></DatasetCreatingDialog>
)}
{datasetRenameVisible && (
<RenameDialog
hideModal={hideDatasetRenameModal}
onOk={onDatasetRenameOk}
initialName={initialDatasetName}
loading={datasetRenameLoading}
></RenameDialog>
)}
</section>
</>
);
}

View File

@ -3,11 +3,18 @@ import { MoreButton } from '@/components/more-button';
import { RenameDialog } from '@/components/rename-dialog';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
import { useFetchAgentListByPage } from '@/hooks/use-agent-request';
import { useEffect } from 'react';
import { AgentDropdown } from '../agents/agent-dropdown';
import { useRenameAgent } from '../agents/use-rename-agent';
export function Agents() {
const { data } = useFetchAgentListByPage();
export function Agents({
setListLength,
setLoading,
}: {
setListLength: (length: number) => void;
setLoading?: (loading: boolean) => void;
}) {
const { data, loading } = useFetchAgentListByPage();
const { navigateToAgent } = useNavigatePage();
const {
agentRenameLoading,
@ -18,6 +25,11 @@ export function Agents() {
showAgentRenameModal,
} = useRenameAgent();
useEffect(() => {
setListLength(data?.length || 0);
setLoading?.(loading || false);
}, [data, setListLength, loading, setLoading]);
return (
<>
{data.slice(0, 10).map((x) => (

View File

@ -1,4 +1,6 @@
import { CardSineLineContainer } from '@/components/card-singleline-container';
import { EmptyCardType } from '@/components/empty/constant';
import { EmptyAppCard } from '@/components/empty/empty';
import { HomeIcon } from '@/components/svg-icon';
import { Segmented, SegmentedValue } from '@/components/ui/segmented';
import { Routes } from '@/routes';
@ -16,14 +18,29 @@ const IconMap = {
[Routes.Agents]: 'agents',
};
const EmptyTypeMap = {
[Routes.Chats]: EmptyCardType.Chat,
[Routes.Searches]: EmptyCardType.Search,
[Routes.Agents]: EmptyCardType.Agent,
};
export function Applications() {
const [val, setVal] = useState(Routes.Chats);
const { t } = useTranslation();
const navigate = useNavigate();
const [listLength, setListLength] = useState(0);
const [loading, setLoading] = useState(false);
const handleNavigate = useCallback(() => {
navigate(val);
}, [navigate, val]);
const handleNavigate = useCallback(
({ isCreate }: { isCreate?: boolean }) => {
if (isCreate) {
navigate(val + '?isCreate=true');
} else {
navigate(val);
}
},
[navigate, val],
);
const options = useMemo(
() => [
@ -36,16 +53,14 @@ export function Applications() {
const handleChange = (path: SegmentedValue) => {
setVal(path as Routes);
setListLength(0);
setLoading(true);
};
return (
<section className="mt-12">
<div className="flex justify-between items-center mb-5">
<h2 className="text-2xl font-semibold flex gap-2.5">
{/* <IconFont
name={IconMap[val as keyof typeof IconMap]}
className="size-8"
></IconFont> */}
<HomeIcon
name={`${IconMap[val as keyof typeof IconMap]}`}
width={'32'}
@ -63,11 +78,36 @@ export function Applications() {
</div>
{/* <div className="flex flex-wrap gap-4"> */}
<CardSineLineContainer>
{val === Routes.Agents && <Agents></Agents>}
{val === Routes.Chats && <ChatList></ChatList>}
{val === Routes.Searches && <SearchList></SearchList>}
{<SeeAllAppCard click={handleNavigate}></SeeAllAppCard>}
{val === Routes.Agents && (
<Agents
setListLength={(length: number) => setListLength(length)}
setLoading={(loading: boolean) => setLoading(loading)}
></Agents>
)}
{val === Routes.Chats && (
<ChatList
setListLength={(length: number) => setListLength(length)}
setLoading={(loading: boolean) => setLoading(loading)}
></ChatList>
)}
{val === Routes.Searches && (
<SearchList
setListLength={(length: number) => setListLength(length)}
setLoading={(loading: boolean) => setLoading(loading)}
></SearchList>
)}
{listLength > 0 && (
<SeeAllAppCard
click={() => handleNavigate({ isCreate: false })}
></SeeAllAppCard>
)}
</CardSineLineContainer>
{listLength <= 0 && !loading && (
<EmptyAppCard
type={EmptyTypeMap[val as keyof typeof EmptyTypeMap]}
onClick={() => handleNavigate({ isCreate: true })}
/>
)}
{/* </div> */}
</section>
);

View File

@ -3,13 +3,20 @@ import { MoreButton } from '@/components/more-button';
import { RenameDialog } from '@/components/rename-dialog';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
import { useFetchDialogList } from '@/hooks/use-chat-request';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { ChatDropdown } from '../next-chats/chat-dropdown';
import { useRenameChat } from '../next-chats/hooks/use-rename-chat';
export function ChatList() {
export function ChatList({
setListLength,
setLoading,
}: {
setListLength: (length: number) => void;
setLoading?: (loading: boolean) => void;
}) {
const { t } = useTranslation();
const { data } = useFetchDialogList();
const { data, loading } = useFetchDialogList();
const { navigateToChat } = useNavigatePage();
const {
@ -20,7 +27,10 @@ export function ChatList() {
onChatRenameOk,
chatRenameLoading,
} = useRenameChat();
useEffect(() => {
setListLength(data?.dialogs?.length || 0);
setLoading?.(loading || false);
}, [data, setListLength, loading, setLoading]);
return (
<>
{data.dialogs.slice(0, 10).map((x) => (

View File

@ -1,4 +1,6 @@
import { CardSineLineContainer } from '@/components/card-singleline-container';
import { EmptyCardType } from '@/components/empty/constant';
import { EmptyAppCard } from '@/components/empty/empty';
import { RenameDialog } from '@/components/rename-dialog';
import { HomeIcon } from '@/components/svg-icon';
import { CardSkeleton } from '@/components/ui/skeleton';
@ -35,18 +37,32 @@ export function Datasets() {
<CardSkeleton />
</div>
) : (
<CardSineLineContainer>
{kbs
?.slice(0, 6)
.map((dataset) => (
<DatasetCard
key={dataset.id}
dataset={dataset}
showDatasetRenameModal={showDatasetRenameModal}
></DatasetCard>
))}
{<SeeAllAppCard click={navigateToDatasetList}></SeeAllAppCard>}
</CardSineLineContainer>
<>
{kbs?.length > 0 && (
<CardSineLineContainer>
{kbs
?.slice(0, 6)
.map((dataset) => (
<DatasetCard
key={dataset.id}
dataset={dataset}
showDatasetRenameModal={showDatasetRenameModal}
></DatasetCard>
))}
{
<SeeAllAppCard
click={() => navigateToDatasetList({ isCreate: false })}
></SeeAllAppCard>
}
</CardSineLineContainer>
)}
{kbs?.length <= 0 && (
<EmptyAppCard
type={EmptyCardType.Dataset}
onClick={() => navigateToDatasetList({ isCreate: true })}
/>
)}
</>
// </div>
)}
</div>

View File

@ -3,11 +3,18 @@ import { IconFont } from '@/components/icon-font';
import { MoreButton } from '@/components/more-button';
import { RenameDialog } from '@/components/rename-dialog';
import { useNavigatePage } from '@/hooks/logic-hooks/navigate-hooks';
import { useEffect } from 'react';
import { useFetchSearchList, useRenameSearch } from '../next-searches/hooks';
import { SearchDropdown } from '../next-searches/search-dropdown';
export function SearchList() {
const { data, refetch: refetchList } = useFetchSearchList();
export function SearchList({
setListLength,
setLoading,
}: {
setListLength: (length: number) => void;
setLoading?: (loading: boolean) => void;
}) {
const { data, refetch: refetchList, isLoading } = useFetchSearchList();
const { navigateToSearch } = useNavigatePage();
const {
openCreateModal,
@ -22,6 +29,11 @@ export function SearchList() {
refetchList();
});
};
useEffect(() => {
setListLength(data?.data?.search_apps?.length || 0);
setLoading?.(isLoading || false);
}, [data, setListLength, isLoading, setLoading]);
return (
<>
{data?.data.search_apps.slice(0, 10).map((x) => (

View File

@ -2,7 +2,7 @@ import { LargeModelFormFieldWithoutFilter } from '@/components/large-model-form-
import { LlmSettingSchema } from '@/components/llm-setting-items/next';
import { NextMessageInput } from '@/components/message-input/next';
import MessageItem from '@/components/message-item';
import PdfDrawer from '@/components/pdf-drawer';
import PdfSheet from '@/components/pdf-drawer';
import { useClickDrawer } from '@/components/pdf-drawer/hooks';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@ -257,12 +257,12 @@ export function MultipleChatBox({
/>
</div>
{visible && (
<PdfDrawer
<PdfSheet
visible={visible}
hideModal={hideModal}
documentId={documentId}
chunk={selectedChunk}
></PdfDrawer>
></PdfSheet>
)}
</section>
);

View File

@ -1,6 +1,6 @@
import { NextMessageInput } from '@/components/message-input/next';
import MessageItem from '@/components/message-item';
import PdfDrawer from '@/components/pdf-drawer';
import PdfSheet from '@/components/pdf-drawer';
import { useClickDrawer } from '@/components/pdf-drawer/hooks';
import { MessageType } from '@/constants/chat';
import {
@ -101,12 +101,12 @@ export function SingleChatBox({ controller, stopOutputMessage }: IProps) {
removeFile={removeFile}
/>
{visible && (
<PdfDrawer
<PdfSheet
visible={visible}
hideModal={hideModal}
documentId={documentId}
chunk={selectedChunk}
></PdfDrawer>
></PdfSheet>
)}
</section>
);

View File

@ -1,4 +1,6 @@
import { CardContainer } from '@/components/card-container';
import { EmptyCardType } from '@/components/empty/constant';
import { EmptyAppCard } from '@/components/empty/empty';
import ListFilterBar from '@/components/list-filter-bar';
import { RenameDialog } from '@/components/rename-dialog';
import { Button } from '@/components/ui/button';
@ -6,8 +8,9 @@ import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
import { useFetchDialogList } from '@/hooks/use-chat-request';
import { pick } from 'lodash';
import { Plus } from 'lucide-react';
import { useCallback } from 'react';
import { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'umi';
import { ChatCard } from './chat-card';
import { useRenameChat } from './hooks/use-rename-chat';
@ -35,41 +38,66 @@ export default function ChatList() {
showChatRenameModal();
}, [showChatRenameModal]);
const [searchParams, setSearchParams] = useSearchParams();
const isCreate = searchParams.get('isCreate') === 'true';
useEffect(() => {
if (isCreate) {
handleShowCreateModal();
searchParams.delete('isCreate');
setSearchParams(searchParams);
}
}, [isCreate, handleShowCreateModal, searchParams, setSearchParams]);
return (
<section className="flex flex-col w-full flex-1">
<div className="px-8 pt-8">
<ListFilterBar
title={t('chat.chatApps')}
icon="chats"
onSearchChange={handleInputChange}
searchString={searchString}
>
<Button onClick={handleShowCreateModal}>
<Plus className="size-2.5" />
{t('chat.createChat')}
</Button>
</ListFilterBar>
</div>
<div className="flex-1 overflow-auto">
<CardContainer className="max-h-[calc(100dvh-280px)] overflow-auto px-8">
{data.dialogs.map((x) => {
return (
<ChatCard
key={x.id}
data={x}
showChatRenameModal={showChatRenameModal}
></ChatCard>
);
})}
</CardContainer>
</div>
<div className="mt-8 px-8 pb-8">
<RAGFlowPagination
{...pick(pagination, 'current', 'pageSize')}
total={pagination.total}
onChange={handlePageChange}
></RAGFlowPagination>
</div>
{data.dialogs?.length <= 0 && (
<div className="flex w-full items-center justify-center h-[calc(100vh-164px)]">
<EmptyAppCard
showIcon
size="large"
className="w-[480px] p-14"
type={EmptyCardType.Chat}
onClick={() => handleShowCreateModal()}
/>
</div>
)}
{data.dialogs?.length > 0 && (
<>
<div className="px-8 pt-8">
<ListFilterBar
title={t('chat.chatApps')}
icon="chats"
onSearchChange={handleInputChange}
searchString={searchString}
>
<Button onClick={handleShowCreateModal}>
<Plus className="size-2.5" />
{t('chat.createChat')}
</Button>
</ListFilterBar>
</div>
<div className="flex-1 overflow-auto">
<CardContainer className="max-h-[calc(100dvh-280px)] overflow-auto px-8">
{data.dialogs.map((x) => {
return (
<ChatCard
key={x.id}
data={x}
showChatRenameModal={showChatRenameModal}
></ChatCard>
);
})}
</CardContainer>
</div>
<div className="mt-8 px-8 pb-8">
<RAGFlowPagination
{...pick(pagination, 'current', 'pageSize')}
total={pagination.total}
onChange={handlePageChange}
></RAGFlowPagination>
</div>
</>
)}
{chatRenameVisible && (
<RenameDialog
hideModal={hideChatRenameModal}

View File

@ -1,7 +1,7 @@
import { EmbedContainer } from '@/components/embed-container';
import { NextMessageInput } from '@/components/message-input/next';
import MessageItem from '@/components/message-item';
import PdfDrawer from '@/components/pdf-drawer';
import PdfSheet from '@/components/pdf-drawer';
import { useClickDrawer } from '@/components/pdf-drawer/hooks';
import { MessageType, SharedFrom } from '@/constants/chat';
import { useFetchNextConversationSSE } from '@/hooks/chat-hooks';
@ -123,12 +123,12 @@ const ChatContainer = () => {
</div>
</EmbedContainer>
{visible && (
<PdfDrawer
<PdfSheet
visible={visible}
hideModal={hideModal}
documentId={documentId}
chunk={selectedChunk}
></PdfDrawer>
></PdfSheet>
)}
</>
);

View File

@ -17,26 +17,39 @@ import {
import { Separator } from '@/components/ui/separator';
import {
useAllTestingResult,
useChunkIsTesting,
useSelectTestingResult,
} from '@/hooks/knowledge-hooks';
import { cn } from '@/lib/utils';
import { CheckIcon, ChevronDown, Files, XIcon } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
interface IProps {
onTesting(documentIds: string[]): void;
setSelectedDocumentIds(documentIds: string[]): void;
selectedDocumentIds: string[];
setLoading?: (loading: boolean) => void;
}
const RetrievalDocuments = ({
onTesting,
selectedDocumentIds,
setSelectedDocumentIds,
setLoading,
}: IProps) => {
const { documents: documentsAll } = useAllTestingResult();
const { documents } = useSelectTestingResult();
const isTesting = useChunkIsTesting();
const [isPopoverOpen, setIsPopoverOpen] = useState(false);
useEffect(() => {
if (isTesting) {
setLoading?.(true);
} else {
setLoading?.(false);
}
}, [isTesting, setLoading]);
const { documents: useDocuments } = {
documents:
documentsAll?.length > documents?.length ? documentsAll : documents,
@ -45,6 +58,9 @@ const RetrievalDocuments = ({
useState<string[]>(selectedDocumentIds);
const multiOptions = useMemo(() => {
if (!useDocuments || !useDocuments.length) {
return [];
}
return useDocuments?.map((item) => {
return {
label: item.doc_name,
@ -97,36 +113,38 @@ const RetrievalDocuments = ({
return (
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
<PopoverTrigger asChild>
<Button
onClick={handleTogglePopover}
className={cn(
'flex w-full p-1 rounded-md text-base text-text-primary border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-inherit [&_svg]:pointer-events-auto',
)}
>
<div className="flex justify-between items-center w-full">
<div className="flex flex-wrap items-center gap-2">
<Files />
<span>
{selectedDocumentIds?.length ?? 0}/{useDocuments?.length ?? 0}
</span>
Files
{useDocuments?.length && (
<Button
onClick={handleTogglePopover}
className={cn(
'flex w-full p-1 rounded-md text-base text-text-primary border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-inherit [&_svg]:pointer-events-auto',
)}
>
<div className="flex justify-between items-center w-full">
<div className="flex flex-wrap items-center gap-2">
<Files />
<span>
{selectedDocumentIds?.length ?? 0}/{useDocuments?.length ?? 0}
</span>
Files
</div>
<div className="flex items-center justify-between">
<XIcon
className="h-4 mx-2 cursor-pointer text-muted-foreground"
onClick={(event) => {
event.stopPropagation();
handleClear();
}}
/>
<Separator
orientation="vertical"
className="flex min-h-6 h-full"
/>
<ChevronDown className="h-4 mx-2 cursor-pointer text-muted-foreground" />
</div>
</div>
<div className="flex items-center justify-between">
<XIcon
className="h-4 mx-2 cursor-pointer text-muted-foreground"
onClick={(event) => {
event.stopPropagation();
handleClear();
}}
/>
<Separator
orientation="vertical"
className="flex min-h-6 h-full"
/>
<ChevronDown className="h-4 mx-2 cursor-pointer text-muted-foreground" />
</div>
</div>
</Button>
</Button>
)}
</PopoverTrigger>
<PopoverContent
className="w-auto p-0"

View File

@ -1,3 +1,5 @@
import { EmptyType } from '@/components/empty/constant';
import Empty from '@/components/empty/empty';
import { FileIcon } from '@/components/icon-font';
import { ImageWithPopover } from '@/components/image';
import { Input } from '@/components/originui/input';
@ -65,6 +67,7 @@ export default function SearchingView({
// changeLanguage();
// }, [i18n]);
const [searchtext, setSearchtext] = useState<string>('');
const [retrievalLoading, setRetrievalLoading] = useState(false);
useEffect(() => {
setSearchtext(searchStr);
@ -182,6 +185,9 @@ export default function SearchingView({
selectedDocumentIds={selectedDocumentIds}
setSelectedDocumentIds={setSelectedDocumentIds}
onTesting={handleTestChunk}
setLoading={(loading: boolean) => {
setRetrievalLoading(loading);
}}
></RetrievalDocuments>
</div>
{/* <div className="w-full border-b border-border-default/80 my-6"></div> */}
@ -264,6 +270,17 @@ export default function SearchingView({
</>
)}
</div>
{!isSearchStrEmpty &&
!retrievalLoading &&
!answer.answer &&
!sendingLoading &&
total <= 0 &&
chunks?.length <= 0 &&
relatedQuestions?.length <= 0 && (
<div className="h-2/5 flex items-center justify-center">
<Empty type={EmptyType.SearchData} iconWidth={80} />
</div>
)}
</div>
{total > 0 && (

View File

@ -1,4 +1,6 @@
import { CardContainer } from '@/components/card-container';
import { EmptyCardType } from '@/components/empty/constant';
import { EmptyAppCard } from '@/components/empty/empty';
import { IconFont } from '@/components/icon-font';
import ListFilterBar from '@/components/list-filter-bar';
import { RenameDialog } from '@/components/rename-dialog';
@ -6,6 +8,8 @@ import { Button } from '@/components/ui/button';
import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
import { useTranslate } from '@/hooks/common-hooks';
import { Plus } from 'lucide-react';
import { useCallback, useEffect } from 'react';
import { useSearchParams } from 'umi';
import { useFetchSearchList, useRenameSearch } from './hooks';
import { SearchCard } from './search-card';
@ -27,6 +31,7 @@ export default function SearchList() {
onSearchRenameOk,
initialSearchName,
} = useRenameSearch();
const handleSearchChange = (value: string) => {
console.log(value);
};
@ -35,61 +40,86 @@ export default function SearchList() {
refetchList();
});
};
const openCreateModalFun = () => {
const openCreateModalFun = useCallback(() => {
// setIsEdit(false);
showSearchRenameModal();
};
}, [showSearchRenameModal]);
const handlePageChange = (page: number, pageSize: number) => {
// setIsEdit(false);
setSearchListParams({ ...searchParams, page, page_size: pageSize });
};
const [searchUrl, setSearchUrl] = useSearchParams();
const isCreate = searchUrl.get('isCreate') === 'true';
useEffect(() => {
if (isCreate) {
openCreateModalFun();
searchUrl.delete('isCreate');
setSearchUrl(searchUrl);
}
}, [isCreate, openCreateModalFun, searchUrl, setSearchUrl]);
return (
<section className="w-full h-full flex flex-col">
<div className="px-8 pt-8">
<ListFilterBar
icon="searches"
title={t('searchApps')}
showFilter={false}
onSearchChange={(e) => handleSearchChange(e.target.value)}
>
<Button
variant={'default'}
onClick={() => {
openCreateModalFun();
}}
>
<Plus className="mr-2 h-4 w-4" />
{t('createSearch')}
</Button>
</ListFilterBar>
</div>
<div className="flex-1">
<CardContainer className="max-h-[calc(100dvh-280px)] overflow-auto px-8">
{list?.data.search_apps.map((x) => {
return (
<SearchCard
key={x.id}
data={x}
showSearchRenameModal={() => {
showSearchRenameModal(x);
}}
></SearchCard>
);
})}
</CardContainer>
</div>
{list?.data.total && list?.data.total > 0 && (
<div className="px-8 mb-4">
<RAGFlowPagination
current={searchParams.page}
pageSize={searchParams.page_size}
total={list?.data.total}
onChange={handlePageChange}
{(!list?.data?.search_apps?.length ||
list?.data?.search_apps?.length <= 0) && (
<div className="flex w-full items-center justify-center h-[calc(100vh-164px)]">
<EmptyAppCard
showIcon
size="large"
className="w-[480px] p-14"
type={EmptyCardType.Search}
onClick={() => openCreateModalFun()}
/>
</div>
)}
{!!list?.data?.search_apps?.length && (
<>
<div className="px-8 pt-8">
<ListFilterBar
icon="searches"
title={t('searchApps')}
showFilter={false}
onSearchChange={(e) => handleSearchChange(e.target.value)}
>
<Button
variant={'default'}
onClick={() => {
openCreateModalFun();
}}
>
<Plus className="mr-2 h-4 w-4" />
{t('createSearch')}
</Button>
</ListFilterBar>
</div>
<div className="flex-1">
<CardContainer className="max-h-[calc(100dvh-280px)] overflow-auto px-8">
{list?.data.search_apps.map((x) => {
return (
<SearchCard
key={x.id}
data={x}
showSearchRenameModal={() => {
showSearchRenameModal(x);
}}
></SearchCard>
);
})}
</CardContainer>
</div>
{list?.data.total && list?.data.total > 0 && (
<div className="px-8 mb-4">
<RAGFlowPagination
current={searchParams.page}
pageSize={searchParams.page_size}
total={list?.data.total}
onChange={handlePageChange}
/>
</div>
)}
</>
)}
{openCreateModal && (
<RenameDialog
hideModal={hideSearchRenameModal}

View File

@ -47,6 +47,7 @@ const AddDataSourceModal = ({
}
open={visible || false}
onOpenChange={(open) => !open && hideModal?.()}
maskClosable={false}
// onOk={() => handleOk()}
okText={t('common.confirm')}
cancelText={t('common.cancel')}

View File

@ -0,0 +1,391 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FileUploader } from '@/components/file-uploader';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import message from '@/components/ui/message';
import { FileMimeType } from '@/constants/common';
import {
pollGmailWebAuthResult,
startGmailWebAuth,
} from '@/services/data-source-service';
import { Loader2 } from 'lucide-react';
export type GmailTokenFieldProps = {
value?: string;
onChange: (value: any) => void;
placeholder?: string;
};
const credentialHasRefreshToken = (content: string) => {
try {
const parsed = JSON.parse(content);
return Boolean(parsed?.refresh_token);
} catch {
return false;
}
};
const describeCredentials = (content?: string) => {
if (!content) return '';
try {
const parsed = JSON.parse(content);
if (parsed?.refresh_token) {
return 'Uploaded OAuth tokens with a refresh token.';
}
if (parsed?.installed || parsed?.web) {
return 'Client credentials detected. Complete verification to mint long-lived tokens.';
}
return 'Stored Google credential JSON.';
} catch {
return '';
}
};
const GmailTokenField = ({
value,
onChange,
placeholder,
}: GmailTokenFieldProps) => {
const [files, setFiles] = useState<File[]>([]);
const [pendingCredentials, setPendingCredentials] = useState<string>('');
const [dialogOpen, setDialogOpen] = useState(false);
const [webAuthLoading, setWebAuthLoading] = useState(false);
const [webFlowId, setWebFlowId] = useState<string | null>(null);
const [webStatus, setWebStatus] = useState<
'idle' | 'waiting' | 'success' | 'error'
>('idle');
const [webStatusMessage, setWebStatusMessage] = useState('');
const webFlowIdRef = useRef<string | null>(null);
const webPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearWebState = useCallback(() => {
if (webPollTimerRef.current) {
clearTimeout(webPollTimerRef.current);
webPollTimerRef.current = null;
}
webFlowIdRef.current = null;
setWebFlowId(null);
setWebStatus('idle');
setWebStatusMessage('');
}, []);
useEffect(() => {
return () => {
if (webPollTimerRef.current) {
clearTimeout(webPollTimerRef.current);
}
};
}, []);
useEffect(() => {
webFlowIdRef.current = webFlowId;
}, [webFlowId]);
const credentialSummary = useMemo(() => describeCredentials(value), [value]);
const hasVerifiedTokens = useMemo(
() => Boolean(value && credentialHasRefreshToken(value)),
[value],
);
const hasUploadedButUnverified = useMemo(
() => Boolean(value && !hasVerifiedTokens),
[hasVerifiedTokens, value],
);
const resetDialog = useCallback(
(shouldResetState: boolean) => {
setDialogOpen(false);
clearWebState();
if (shouldResetState) {
setPendingCredentials('');
setFiles([]);
}
},
[clearWebState],
);
const fetchWebResult = useCallback(
async (flowId: string) => {
try {
const { data } = await pollGmailWebAuthResult({
flow_id: flowId,
});
if (data.code === 0 && data.data?.credentials) {
onChange(data.data.credentials);
setPendingCredentials('');
message.success('Gmail credentials verified.');
resetDialog(false);
return;
}
if (data.code === 106) {
setWebStatus('waiting');
setWebStatusMessage('Authorization confirmed. Finalizing tokens...');
if (webPollTimerRef.current) {
clearTimeout(webPollTimerRef.current);
}
webPollTimerRef.current = setTimeout(
() => fetchWebResult(flowId),
1500,
);
return;
}
message.error(data.message || 'Authorization failed.');
clearWebState();
} catch (err) {
message.error('Unable to retrieve authorization result.');
clearWebState();
}
},
[clearWebState, onChange, resetDialog],
);
useEffect(() => {
const handler = (event: MessageEvent) => {
const payload = event.data;
if (!payload || payload.type !== 'ragflow-gmail-oauth') {
return;
}
if (!payload.flowId) {
return;
}
if (webFlowIdRef.current && webFlowIdRef.current !== payload.flowId) {
return;
}
if (payload.status === 'success') {
setWebStatus('waiting');
setWebStatusMessage('Authorization confirmed. Finalizing tokens...');
fetchWebResult(payload.flowId);
} else {
message.error(
payload.message || 'Authorization window reported an error.',
);
clearWebState();
}
};
window.addEventListener('message', handler);
return () => window.removeEventListener('message', handler);
}, [clearWebState, fetchWebResult]);
const handleValueChange = useCallback(
(nextFiles: File[]) => {
if (!nextFiles.length) {
setFiles([]);
onChange('');
setPendingCredentials('');
clearWebState();
return;
}
const file = nextFiles[nextFiles.length - 1];
file
.text()
.then((text) => {
try {
JSON.parse(text);
} catch {
message.error('Invalid JSON file.');
setFiles([]);
clearWebState();
return;
}
setFiles([file]);
clearWebState();
if (credentialHasRefreshToken(text)) {
onChange(text);
setPendingCredentials('');
message.success('Gmail OAuth credentials uploaded.');
return;
}
setPendingCredentials(text);
setDialogOpen(true);
message.info(
'Client configuration uploaded. Verification is required to finish setup.',
);
})
.catch(() => {
message.error('Unable to read the uploaded file.');
setFiles([]);
});
},
[clearWebState, onChange],
);
const handleStartWebAuthorization = useCallback(async () => {
if (!pendingCredentials) {
message.error('No Google credential file detected.');
return;
}
setWebAuthLoading(true);
clearWebState();
try {
const { data } = await startGmailWebAuth({
credentials: pendingCredentials,
});
if (data.code === 0 && data.data?.authorization_url) {
const flowId = data.data.flow_id;
const popup = window.open(
data.data.authorization_url,
'ragflow-gmail-oauth',
'width=600,height=720',
);
if (!popup) {
message.error(
'Popup was blocked. Please allow popups for this site.',
);
return;
}
popup.focus();
webFlowIdRef.current = flowId;
setWebFlowId(flowId);
setWebStatus('waiting');
setWebStatusMessage('Complete the Google consent in the popup window.');
} else {
message.error(data.message || 'Failed to start browser authorization.');
}
} catch (err) {
message.error('Failed to start browser authorization.');
} finally {
setWebAuthLoading(false);
}
}, [clearWebState, pendingCredentials]);
const handleManualWebCheck = useCallback(() => {
if (!webFlowId) {
message.info('Start browser authorization first.');
return;
}
setWebStatus('waiting');
setWebStatusMessage('Checking authorization status...');
fetchWebResult(webFlowId);
}, [fetchWebResult, webFlowId]);
const handleCancel = useCallback(() => {
message.warning(
'Verification canceled. Upload the credential again to restart.',
);
resetDialog(true);
}, [resetDialog]);
return (
<div className="flex flex-col gap-3">
{(credentialSummary ||
hasVerifiedTokens ||
hasUploadedButUnverified ||
pendingCredentials) && (
<div className="flex flex-wrap items-center gap-3 rounded-md border border-dashed border-muted-foreground/40 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-2">
{hasVerifiedTokens ? (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-emerald-700">
Verified
</span>
) : null}
{hasUploadedButUnverified ? (
<span className="rounded-full bg-amber-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-amber-700">
Needs authorization
</span>
) : null}
{pendingCredentials && !hasVerifiedTokens ? (
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-blue-700">
Uploaded (pending)
</span>
) : null}
</div>
{credentialSummary ? (
<p className="m-0">{credentialSummary}</p>
) : null}
</div>
)}
<FileUploader
className="py-4 border-[0.5px] bg-bg-card text-text-secondary"
value={files}
onValueChange={handleValueChange}
accept={{ '*.json': [FileMimeType.Json] }}
maxFileCount={1}
description={'Upload your Gmail OAuth JSON file.'}
/>
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
if (!open && dialogOpen) {
handleCancel();
}
}}
>
<DialogContent
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Complete Gmail verification</DialogTitle>
<DialogDescription>
The uploaded client credentials do not contain a refresh token.
Run the verification flow once to mint reusable tokens.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/10 px-4 py-4 text-sm text-muted-foreground">
<div className="text-sm font-semibold text-foreground">
Authorize in browser
</div>
<p className="mt-2">
We will open Google&apos;s consent page in a new window. Sign in
with the admin account, grant access, and return here. Your
credentials will update automatically.
</p>
{webStatus !== 'idle' && (
<p
className={`mt-2 text-xs ${
webStatus === 'error'
? 'text-destructive'
: 'text-muted-foreground'
}`}
>
{webStatusMessage}
</p>
)}
<div className="mt-3 flex flex-wrap gap-2">
<Button
onClick={handleStartWebAuthorization}
disabled={webAuthLoading}
>
{webAuthLoading && (
<Loader2 className="mr-2 size-4 animate-spin" />
)}
Authorize with Google
</Button>
{webFlowId ? (
<Button
variant="outline"
onClick={handleManualWebCheck}
disabled={webStatus === 'success'}
>
Refresh status
</Button>
) : null}
</div>
</div>
</div>
<DialogFooter className="pt-2">
<Button variant="ghost" onClick={handleCancel}>
Cancel
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default GmailTokenField;

View File

@ -1,5 +1,3 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FileUploader } from '@/components/file-uploader';
import { Button } from '@/components/ui/button';
import {
@ -17,6 +15,7 @@ import {
startGoogleDriveWebAuth,
} from '@/services/data-source-service';
import { Loader2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
type GoogleDriveTokenFieldProps = {
value?: string;
@ -313,12 +312,16 @@ const GoogleDriveTokenField = ({
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
if (!open) {
if (!open && dialogOpen) {
handleCancel();
}
}}
>
<DialogContent>
<DialogContent
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>Complete Google verification</DialogTitle>
<DialogDescription>
@ -326,7 +329,6 @@ const GoogleDriveTokenField = ({
Run the verification flow once to mint reusable tokens.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md border border-dashed border-muted-foreground/40 bg-muted/10 px-4 py-4 text-sm text-muted-foreground">
<div className="text-sm font-semibold text-foreground">
@ -370,7 +372,6 @@ const GoogleDriveTokenField = ({
</div>
</div>
</div>
<DialogFooter className="pt-2">
<Button variant="ghost" onClick={handleCancel}>
Cancel

View File

@ -1,6 +1,7 @@
import { FormFieldType } from '@/components/dynamic-form';
import SvgIcon from '@/components/svg-icon';
import { t } from 'i18next';
import GmailTokenField from './component/gmail-token-field';
import GoogleDriveTokenField from './component/google-drive-token-field';
export enum DataSourceKey {
@ -10,7 +11,7 @@ export enum DataSourceKey {
DISCORD = 'discord',
GOOGLE_DRIVE = 'google_drive',
MOODLE = 'moodle',
// GMAIL = 'gmail',
GMAIL = 'gmail',
JIRA = 'jira',
WEBDAV = 'webdav',
DROPBOX = 'dropbox',
@ -45,6 +46,11 @@ export const DataSourceInfo = {
description: t(`setting.${DataSourceKey.GOOGLE_DRIVE}Description`),
icon: <SvgIcon name={'data-source/google-drive'} width={38} />,
},
[DataSourceKey.GMAIL]: {
name: 'Gmail',
description: t(`setting.${DataSourceKey.GMAIL}Description`),
icon: <SvgIcon name={'data-source/gmail'} width={38} />,
},
[DataSourceKey.MOODLE]: {
name: 'Moodle',
description: t(`setting.${DataSourceKey.MOODLE}Description`),
@ -320,6 +326,38 @@ export const DataSourceFormFields = {
defaultValue: 'uploaded',
},
],
[DataSourceKey.GMAIL]: [
{
label: 'Primary Admin Email',
name: 'config.credentials.google_primary_admin',
type: FormFieldType.Text,
required: true,
placeholder: 'admin@example.com',
tooltip: t('setting.gmailPrimaryAdminTip'),
},
{
label: 'OAuth Token JSON',
name: 'config.credentials.google_tokens',
type: FormFieldType.Textarea,
required: true,
render: (fieldProps: any) => (
<GmailTokenField
value={fieldProps.value}
onChange={fieldProps.onChange}
placeholder='{ "token": "...", "refresh_token": "...", ... }'
/>
),
tooltip: t('setting.gmailTokenTip'),
},
{
label: '',
name: 'config.credentials.authentication_method',
type: FormFieldType.Text,
required: false,
hidden: true,
defaultValue: 'uploaded',
},
],
[DataSourceKey.MOODLE]: [
{
label: 'Moodle URL',
@ -550,6 +588,17 @@ export const DataSourceFormDefaultValues = {
},
},
},
[DataSourceKey.GMAIL]: {
name: '',
source: DataSourceKey.GMAIL,
config: {
credentials: {
google_primary_admin: '',
google_tokens: '',
authentication_method: 'uploaded',
},
},
},
[DataSourceKey.MOODLE]: {
name: '',
source: DataSourceKey.MOODLE,

View File

@ -96,62 +96,77 @@ export default function McpServer() {
</>
}
>
{isSelectionMode && (
<section className="pb-5 flex items-center">
<Checkbox id="all" onCheckedChange={handleSelectAll} />
<Label
className="pl-2 text-text-primary cursor-pointer"
htmlFor="all"
>
{t('common.selectAll')}
</Label>
<span className="text-text-secondary pr-10 pl-5">
{t('mcp.selected')} {selectedList.length}
</span>
<div className="flex gap-10 items-center">
<Button variant={'secondary'} onClick={handleExportMcp}>
<Upload className="size-3.5"></Upload>
{t('mcp.export')}
</Button>
<ConfirmDeleteDialog
onOk={handleDelete}
title={t('common.delete') + ' ' + t('mcp.mcpServers')}
content={{
title: t('common.deleteThem'),
node: (
<ConfirmDeleteDialogNode
name={`${t('mcp.selected')} ${selectedList.length} ${t('mcp.mcpServers')}`}
></ConfirmDeleteDialogNode>
),
}}
>
<Button variant={'danger'}>
<Trash2 className="size-3.5 cursor-pointer" />
{t('common.delete')}
</Button>
</ConfirmDeleteDialog>
</div>
</section>
{!data.mcp_servers?.length && (
<div
className="flex items-center justify-between border border-dashed border-border-button rounded-md p-10 cursor-pointer w-[590px]"
onClick={showEditModal('')}
>
<div className="text-text-secondary text-sm">{t('empty.noMCP')}</div>
<Button variant={'ghost'} className="border border-border-button">
<Plus className="size-3.5 font-medium" /> {t('empty.addNow')}
</Button>
</div>
)}
{!!data.mcp_servers?.length && (
<>
{isSelectionMode && (
<section className="pb-5 flex items-center">
<Checkbox id="all" onCheckedChange={handleSelectAll} />
<Label
className="pl-2 text-text-primary cursor-pointer"
htmlFor="all"
>
{t('common.selectAll')}
</Label>
<span className="text-text-secondary pr-10 pl-5">
{t('mcp.selected')} {selectedList.length}
</span>
<div className="flex gap-10 items-center">
<Button variant={'secondary'} onClick={handleExportMcp}>
<Upload className="size-3.5"></Upload>
{t('mcp.export')}
</Button>
<ConfirmDeleteDialog
onOk={handleDelete}
title={t('common.delete') + ' ' + t('mcp.mcpServers')}
content={{
title: t('common.deleteThem'),
node: (
<ConfirmDeleteDialogNode
name={`${t('mcp.selected')} ${selectedList.length} ${t('mcp.mcpServers')}`}
></ConfirmDeleteDialogNode>
),
}}
>
<Button variant={'danger'}>
<Trash2 className="size-3.5 cursor-pointer" />
{t('common.delete')}
</Button>
</ConfirmDeleteDialog>
</div>
</section>
)}
<CardContainer>
{data.mcp_servers.map((item) => (
<McpCard
key={item.id}
data={item}
selectedList={selectedList}
handleSelectChange={handleSelectChange}
showEditModal={showEditModal}
isSelectionMode={isSelectionMode}
></McpCard>
))}
</CardContainer>
<div className="mt-8">
<RAGFlowPagination
{...pick(pagination, 'current', 'pageSize')}
total={pagination.total || 0}
onChange={handlePageChange}
></RAGFlowPagination>
</div>
</>
)}
<CardContainer>
{data.mcp_servers.map((item) => (
<McpCard
key={item.id}
data={item}
selectedList={selectedList}
handleSelectChange={handleSelectChange}
showEditModal={showEditModal}
isSelectionMode={isSelectionMode}
></McpCard>
))}
</CardContainer>
<div className="mt-8">
<RAGFlowPagination
{...pick(pagination, 'current', 'pageSize')}
total={pagination.total || 0}
onChange={handlePageChange}
></RAGFlowPagination>
</div>
{editVisible && (
<EditMcpDialog
hideModal={hideEditModal}

View File

@ -75,7 +75,9 @@ export const ModelProviderCard: FC<IModelCardProps> = ({
<div className="flex items-center space-x-3">
<LlmIcon name={item.name} />
<div>
<div className="font-medium text-xl">{item.name}</div>
<div className="font-medium text-xl text-text-primary">
{item.name}
</div>
</div>
</div>

View File

@ -34,9 +34,17 @@ export const featchDataSourceDetail = (id: string) =>
request.get(api.dataSourceDetail(id));
export const startGoogleDriveWebAuth = (payload: { credentials: string }) =>
request.post(api.googleDriveWebAuthStart, { data: payload });
request.post(api.googleWebAuthStart('google-drive'), { data: payload });
export const pollGoogleDriveWebAuthResult = (payload: { flow_id: string }) =>
request.post(api.googleDriveWebAuthResult, { data: payload });
request.post(api.googleWebAuthResult('google-drive'), { data: payload });
// Gmail web auth follows the same pattern as Google Drive, but uses
// Gmail-specific endpoints and is consumed by the GmailTokenField UI.
export const startGmailWebAuth = (payload: { credentials: string }) =>
request.post(api.googleWebAuthStart('gmail'), { data: payload });
export const pollGmailWebAuthResult = (payload: { flow_id: string }) =>
request.post(api.googleWebAuthResult('gmail'), { data: payload });
export default dataSourceService;

View File

@ -42,8 +42,10 @@ export default {
dataSourceRebuild: (id: string) => `${api_host}/connector/${id}/rebuild`,
dataSourceLogs: (id: string) => `${api_host}/connector/${id}/logs`,
dataSourceDetail: (id: string) => `${api_host}/connector/${id}`,
googleDriveWebAuthStart: `${api_host}/connector/google-drive/oauth/web/start`,
googleDriveWebAuthResult: `${api_host}/connector/google-drive/oauth/web/result`,
googleWebAuthStart: (type: 'google-drive' | 'gmail') =>
`${api_host}/connector/google/oauth/web/start?type=${type}`,
googleWebAuthResult: (type: 'google-drive' | 'gmail') =>
`${api_host}/connector/google/oauth/web/result?type=${type}`,
// plugin
llm_tools: `${api_host}/plugin/llm_tools`,