Merge branch 'main' of github.com:infiniflow/ragflow into structured-output

This commit is contained in:
bill 2025-11-21 09:41:05 +08:00
commit f3357354fc
44 changed files with 539 additions and 1064 deletions

View file

@ -137,7 +137,7 @@ class Retrieval(ToolBase, ABC):
if not doc_ids:
doc_ids = None
elif self._param.meta_data_filter.get("method") == "manual":
filters=self._param.meta_data_filter["manual"]
filters = self._param.meta_data_filter["manual"]
for flt in filters:
pat = re.compile(self.variable_ref_patt)
s = flt["value"]
@ -166,8 +166,8 @@ class Retrieval(ToolBase, ABC):
out_parts.append(s[last:])
flt["value"] = "".join(out_parts)
doc_ids.extend(meta_filter(metas, filters, self._param.meta_data_filter.get("logic", "and")))
if not doc_ids:
doc_ids = None
if filters and not doc_ids:
doc_ids = ["-999"]
if self._param.cross_languages:
query = cross_languages(kbs[0].tenant_id, None, query, self._param.cross_languages)

View file

@ -311,8 +311,8 @@ async def retrieval_test():
doc_ids = None
elif meta_data_filter.get("method") == "manual":
doc_ids.extend(meta_filter(metas, meta_data_filter["manual"], meta_data_filter.get("logic", "and")))
if not doc_ids:
doc_ids = None
if meta_data_filter["manual"] and not doc_ids:
doc_ids = ["-999"]
try:
tenants = UserTenantService.query(user_id=current_user.id)

View file

@ -1445,6 +1445,8 @@ 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 and not doc_ids:
doc_ids = ["-999"]
similarity_threshold = float(req.get("similarity_threshold", 0.2))
vector_similarity_weight = float(req.get("vector_similarity_weight", 0.3))
top = int(req.get("top_k", 1024))

View file

@ -446,8 +446,8 @@ async def agent_completions(tenant_id, agent_id):
if req.get("stream", True):
def generate():
for answer in agent_completion(tenant_id=tenant_id, agent_id=agent_id, **req):
async def generate():
async for answer in agent_completion(tenant_id=tenant_id, agent_id=agent_id, **req):
if isinstance(answer, str):
try:
ans = json.loads(answer[5:]) # remove "data:"
@ -471,7 +471,7 @@ async def agent_completions(tenant_id, agent_id):
full_content = ""
reference = {}
final_ans = ""
for answer in agent_completion(tenant_id=tenant_id, agent_id=agent_id, **req):
async for answer in agent_completion(tenant_id=tenant_id, agent_id=agent_id, **req):
try:
ans = json.loads(answer[5:])
@ -873,7 +873,7 @@ async def agent_bot_completions(agent_id):
resp.headers.add_header("Content-Type", "text/event-stream; charset=utf-8")
return resp
for answer in agent_completion(objs[0].tenant_id, agent_id, **req):
async for answer in agent_completion(objs[0].tenant_id, agent_id, **req):
return get_result(data=answer)
@ -981,8 +981,8 @@ async def retrieval_test_embedded():
doc_ids = None
elif meta_data_filter.get("method") == "manual":
doc_ids.extend(meta_filter(metas, meta_data_filter["manual"], meta_data_filter.get("logic", "and")))
if not doc_ids:
doc_ids = None
if meta_data_filter["manual"] and not doc_ids:
doc_ids = ["-999"]
try:
tenants = UserTenantService.query(user_id=tenant_id)

View file

@ -415,9 +415,10 @@ def chat(dialog, messages, stream=True, **kwargs):
if not attachments:
attachments = None
elif dialog.meta_data_filter.get("method") == "manual":
attachments.extend(meta_filter(metas, dialog.meta_data_filter["manual"], dialog.meta_data_filter.get("logic", "and")))
if not attachments:
attachments = None
conds = dialog.meta_data_filter["manual"]
attachments.extend(meta_filter(metas, conds, dialog.meta_data_filter.get("logic", "and")))
if conds and not attachments:
attachments = ["-999"]
if prompt_config.get("keyword", False):
questions[-1] += keyword_extraction(chat_mdl, questions[-1])
@ -787,8 +788,8 @@ def ask(question, kb_ids, tenant_id, chat_llm_name=None, search_config={}):
doc_ids = None
elif meta_data_filter.get("method") == "manual":
doc_ids.extend(meta_filter(metas, meta_data_filter["manual"], meta_data_filter.get("logic", "and")))
if not doc_ids:
doc_ids = None
if meta_data_filter["manual"] and not doc_ids:
doc_ids = ["-999"]
kbinfos = retriever.retrieval(
question=question,
@ -862,8 +863,8 @@ def gen_mindmap(question, kb_ids, tenant_id, search_config={}):
doc_ids = None
elif meta_data_filter.get("method") == "manual":
doc_ids.extend(meta_filter(metas, meta_data_filter["manual"], meta_data_filter.get("logic", "and")))
if not doc_ids:
doc_ids = None
if meta_data_filter["manual"] and not doc_ids:
doc_ids = ["-999"]
ranks = settings.retriever.retrieval(
question=question,

View file

@ -1,38 +1,45 @@
import html
import logging
from collections.abc import Generator
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from urllib.parse import urlparse
from retry import retry
from common.data_source.config import (
INDEX_BATCH_SIZE,
DocumentSource, NOTION_CONNECTOR_DISABLE_RECURSIVE_PAGE_LOOKUP
NOTION_CONNECTOR_DISABLE_RECURSIVE_PAGE_LOOKUP,
DocumentSource,
)
from common.data_source.exceptions import (
ConnectorMissingCredentialError,
ConnectorValidationError,
CredentialExpiredError,
InsufficientPermissionsError,
UnexpectedValidationError,
)
from common.data_source.interfaces import (
LoadConnector,
PollConnector,
SecondsSinceUnixEpoch
SecondsSinceUnixEpoch,
)
from common.data_source.models import (
Document,
TextSection, GenerateDocumentsOutput
)
from common.data_source.exceptions import (
ConnectorValidationError,
CredentialExpiredError,
InsufficientPermissionsError,
UnexpectedValidationError, ConnectorMissingCredentialError
)
from common.data_source.models import (
NotionPage,
GenerateDocumentsOutput,
NotionBlock,
NotionSearchResponse
NotionPage,
NotionSearchResponse,
TextSection,
)
from common.data_source.utils import (
rl_requests,
batch_generator,
datetime_from_string,
fetch_notion_data,
filter_pages_by_time,
properties_to_str,
filter_pages_by_time, datetime_from_string
rl_requests,
)
@ -61,11 +68,9 @@ class NotionConnector(LoadConnector, PollConnector):
self.recursive_index_enabled = recursive_index_enabled or bool(root_page_id)
@retry(tries=3, delay=1, backoff=2)
def _fetch_child_blocks(
self, block_id: str, cursor: Optional[str] = None
) -> dict[str, Any] | None:
def _fetch_child_blocks(self, block_id: str, cursor: Optional[str] = None) -> dict[str, Any] | None:
"""Fetch all child blocks via the Notion API."""
logging.debug(f"Fetching children of block with ID '{block_id}'")
logging.debug(f"[Notion]: Fetching children of block with ID {block_id}")
block_url = f"https://api.notion.com/v1/blocks/{block_id}/children"
query_params = {"start_cursor": cursor} if cursor else None
@ -79,49 +84,42 @@ class NotionConnector(LoadConnector, PollConnector):
response.raise_for_status()
return response.json()
except Exception as e:
if hasattr(e, 'response') and e.response.status_code == 404:
logging.error(
f"Unable to access block with ID '{block_id}'. "
f"This is likely due to the block not being shared with the integration."
)
if hasattr(e, "response") and e.response.status_code == 404:
logging.error(f"[Notion]: Unable to access block with ID {block_id}. This is likely due to the block not being shared with the integration.")
return None
else:
logging.exception(f"Error fetching blocks: {e}")
logging.exception(f"[Notion]: Error fetching blocks: {e}")
raise
@retry(tries=3, delay=1, backoff=2)
def _fetch_page(self, page_id: str) -> NotionPage:
"""Fetch a page from its ID via the Notion API."""
logging.debug(f"Fetching page for ID '{page_id}'")
logging.debug(f"[Notion]: Fetching page for ID {page_id}")
page_url = f"https://api.notion.com/v1/pages/{page_id}"
try:
data = fetch_notion_data(page_url, self.headers, "GET")
return NotionPage(**data)
except Exception as e:
logging.warning(f"Failed to fetch page, trying database for ID '{page_id}': {e}")
logging.warning(f"[Notion]: Failed to fetch page, trying database for ID {page_id}: {e}")
return self._fetch_database_as_page(page_id)
@retry(tries=3, delay=1, backoff=2)
def _fetch_database_as_page(self, database_id: str) -> NotionPage:
"""Attempt to fetch a database as a page."""
logging.debug(f"Fetching database for ID '{database_id}' as a page")
logging.debug(f"[Notion]: Fetching database for ID {database_id} as a page")
database_url = f"https://api.notion.com/v1/databases/{database_id}"
data = fetch_notion_data(database_url, self.headers, "GET")
database_name = data.get("title")
database_name = (
database_name[0].get("text", {}).get("content") if database_name else None
)
database_name = database_name[0].get("text", {}).get("content") if database_name else None
return NotionPage(**data, database_name=database_name)
@retry(tries=3, delay=1, backoff=2)
def _fetch_database(
self, database_id: str, cursor: Optional[str] = None
) -> dict[str, Any]:
def _fetch_database(self, database_id: str, cursor: Optional[str] = None) -> dict[str, Any]:
"""Fetch a database from its ID via the Notion API."""
logging.debug(f"Fetching database for ID '{database_id}'")
logging.debug(f"[Notion]: Fetching database for ID {database_id}")
block_url = f"https://api.notion.com/v1/databases/{database_id}/query"
body = {"start_cursor": cursor} if cursor else None
@ -129,17 +127,12 @@ class NotionConnector(LoadConnector, PollConnector):
data = fetch_notion_data(block_url, self.headers, "POST", body)
return data
except Exception as e:
if hasattr(e, 'response') and e.response.status_code in [404, 400]:
logging.error(
f"Unable to access database with ID '{database_id}'. "
f"This is likely due to the database not being shared with the integration."
)
if hasattr(e, "response") and e.response.status_code in [404, 400]:
logging.error(f"[Notion]: Unable to access database with ID {database_id}. This is likely due to the database not being shared with the integration.")
return {"results": [], "next_cursor": None}
raise
def _read_pages_from_database(
self, database_id: str
) -> tuple[list[NotionBlock], list[str]]:
def _read_pages_from_database(self, database_id: str) -> tuple[list[NotionBlock], list[str]]:
"""Returns a list of top level blocks and all page IDs in the database."""
result_blocks: list[NotionBlock] = []
result_pages: list[str] = []
@ -158,10 +151,10 @@ class NotionConnector(LoadConnector, PollConnector):
if self.recursive_index_enabled:
if obj_type == "page":
logging.debug(f"Found page with ID '{obj_id}' in database '{database_id}'")
logging.debug(f"[Notion]: Found page with ID {obj_id} in database {database_id}")
result_pages.append(result["id"])
elif obj_type == "database":
logging.debug(f"Found database with ID '{obj_id}' in database '{database_id}'")
logging.debug(f"[Notion]: Found database with ID {obj_id} in database {database_id}")
_, child_pages = self._read_pages_from_database(obj_id)
result_pages.extend(child_pages)
@ -172,44 +165,229 @@ class NotionConnector(LoadConnector, PollConnector):
return result_blocks, result_pages
def _read_blocks(self, base_block_id: str) -> tuple[list[NotionBlock], list[str]]:
"""Reads all child blocks for the specified block, returns blocks and child page ids."""
def _extract_rich_text(self, rich_text_array: list[dict[str, Any]]) -> str:
collected_text: list[str] = []
for rich_text in rich_text_array:
content = ""
r_type = rich_text.get("type")
if r_type == "equation":
expr = rich_text.get("equation", {}).get("expression")
if expr:
content = expr
elif r_type == "mention":
mention = rich_text.get("mention", {}) or {}
mention_type = mention.get("type")
mention_value = mention.get(mention_type, {}) if mention_type else {}
if mention_type == "date":
start = mention_value.get("start")
end = mention_value.get("end")
if start and end:
content = f"{start} - {end}"
elif start:
content = start
elif mention_type in {"page", "database"}:
content = mention_value.get("id", rich_text.get("plain_text", ""))
elif mention_type == "link_preview":
content = mention_value.get("url", rich_text.get("plain_text", ""))
else:
content = rich_text.get("plain_text", "") or str(mention_value)
else:
if rich_text.get("plain_text"):
content = rich_text["plain_text"]
elif "text" in rich_text and rich_text["text"].get("content"):
content = rich_text["text"]["content"]
href = rich_text.get("href")
if content and href:
content = f"{content} ({href})"
if content:
collected_text.append(content)
return "".join(collected_text).strip()
def _build_table_html(self, table_block_id: str) -> str | None:
rows: list[str] = []
cursor = None
while True:
data = self._fetch_child_blocks(table_block_id, cursor)
if data is None:
break
for result in data["results"]:
if result.get("type") != "table_row":
continue
cells_html: list[str] = []
for cell in result["table_row"].get("cells", []):
cell_text = self._extract_rich_text(cell)
cell_html = html.escape(cell_text) if cell_text else ""
cells_html.append(f"<td>{cell_html}</td>")
rows.append(f"<tr>{''.join(cells_html)}</tr>")
if data.get("next_cursor") is None:
break
cursor = data["next_cursor"]
if not rows:
return None
return "<table>\n" + "\n".join(rows) + "\n</table>"
def _download_file(self, url: str) -> bytes | None:
try:
response = rl_requests.get(url, timeout=60)
response.raise_for_status()
return response.content
except Exception as exc:
logging.warning(f"[Notion]: Failed to download Notion file from {url}: {exc}")
return None
def _extract_file_metadata(self, result_obj: dict[str, Any], block_id: str) -> tuple[str | None, str, str | None]:
file_source_type = result_obj.get("type")
file_source = result_obj.get(file_source_type, {}) if file_source_type else {}
url = file_source.get("url")
name = result_obj.get("name") or file_source.get("name")
if url and not name:
parsed_name = Path(urlparse(url).path).name
name = parsed_name or f"notion_file_{block_id}"
elif not name:
name = f"notion_file_{block_id}"
caption = self._extract_rich_text(result_obj.get("caption", [])) if "caption" in result_obj else None
return url, name, caption
def _build_attachment_document(
self,
block_id: str,
url: str,
name: str,
caption: Optional[str],
page_last_edited_time: Optional[str],
) -> Document | None:
file_bytes = self._download_file(url)
if file_bytes is None:
return None
extension = Path(name).suffix or Path(urlparse(url).path).suffix or ".bin"
if extension and not extension.startswith("."):
extension = f".{extension}"
if not extension:
extension = ".bin"
updated_at = datetime_from_string(page_last_edited_time) if page_last_edited_time else datetime.now(timezone.utc)
semantic_identifier = caption or name or f"Notion file {block_id}"
return Document(
id=block_id,
blob=file_bytes,
source=DocumentSource.NOTION,
semantic_identifier=semantic_identifier,
extension=extension,
size_bytes=len(file_bytes),
doc_updated_at=updated_at,
)
def _read_blocks(self, base_block_id: str, page_last_edited_time: Optional[str] = None) -> tuple[list[NotionBlock], list[str], list[Document]]:
result_blocks: list[NotionBlock] = []
child_pages: list[str] = []
attachments: list[Document] = []
cursor = None
while True:
data = self._fetch_child_blocks(base_block_id, cursor)
if data is None:
return result_blocks, child_pages
return result_blocks, child_pages, attachments
for result in data["results"]:
logging.debug(f"Found child block for block with ID '{base_block_id}': {result}")
logging.debug(f"[Notion]: Found child block for block with ID {base_block_id}: {result}")
result_block_id = result["id"]
result_type = result["type"]
result_obj = result[result_type]
if result_type in ["ai_block", "unsupported", "external_object_instance_page"]:
logging.warning(f"Skipping unsupported block type '{result_type}'")
logging.warning(f"[Notion]: Skipping unsupported block type {result_type}")
continue
if result_type == "table":
table_html = self._build_table_html(result_block_id)
if table_html:
result_blocks.append(
NotionBlock(
id=result_block_id,
text=table_html,
prefix="\n\n",
)
)
continue
if result_type == "equation":
expr = result_obj.get("expression")
if expr:
result_blocks.append(
NotionBlock(
id=result_block_id,
text=expr,
prefix="\n",
)
)
continue
cur_result_text_arr = []
if "rich_text" in result_obj:
for rich_text in result_obj["rich_text"]:
if "text" in rich_text:
text = rich_text["text"]["content"]
cur_result_text_arr.append(text)
text = self._extract_rich_text(result_obj["rich_text"])
if text:
cur_result_text_arr.append(text)
if result_type == "bulleted_list_item":
if cur_result_text_arr:
cur_result_text_arr[0] = f"- {cur_result_text_arr[0]}"
else:
cur_result_text_arr = ["- "]
if result_type == "numbered_list_item":
if cur_result_text_arr:
cur_result_text_arr[0] = f"1. {cur_result_text_arr[0]}"
else:
cur_result_text_arr = ["1. "]
if result_type == "to_do":
checked = result_obj.get("checked")
checkbox_prefix = "[x]" if checked else "[ ]"
if cur_result_text_arr:
cur_result_text_arr = [f"{checkbox_prefix} {cur_result_text_arr[0]}"] + cur_result_text_arr[1:]
else:
cur_result_text_arr = [checkbox_prefix]
if result_type in {"file", "image", "pdf", "video", "audio"}:
file_url, file_name, caption = self._extract_file_metadata(result_obj, result_block_id)
if file_url:
attachment_doc = self._build_attachment_document(
block_id=result_block_id,
url=file_url,
name=file_name,
caption=caption,
page_last_edited_time=page_last_edited_time,
)
if attachment_doc:
attachments.append(attachment_doc)
attachment_label = caption or file_name
if attachment_label:
cur_result_text_arr.append(f"{result_type.capitalize()}: {attachment_label}")
if result["has_children"]:
if result_type == "child_page":
child_pages.append(result_block_id)
else:
logging.debug(f"Entering sub-block: {result_block_id}")
subblocks, subblock_child_pages = self._read_blocks(result_block_id)
logging.debug(f"Finished sub-block: {result_block_id}")
logging.debug(f"[Notion]: Entering sub-block: {result_block_id}")
subblocks, subblock_child_pages, subblock_attachments = self._read_blocks(result_block_id, page_last_edited_time)
logging.debug(f"[Notion]: Finished sub-block: {result_block_id}")
result_blocks.extend(subblocks)
child_pages.extend(subblock_child_pages)
attachments.extend(subblock_attachments)
if result_type == "child_database":
inner_blocks, inner_child_pages = self._read_pages_from_database(result_block_id)
@ -231,7 +409,7 @@ class NotionConnector(LoadConnector, PollConnector):
cursor = data["next_cursor"]
return result_blocks, child_pages
return result_blocks, child_pages, attachments
def _read_page_title(self, page: NotionPage) -> Optional[str]:
"""Extracts the title from a Notion page."""
@ -245,9 +423,7 @@ class NotionConnector(LoadConnector, PollConnector):
return None
def _read_pages(
self, pages: list[NotionPage]
) -> Generator[Document, None, None]:
def _read_pages(self, pages: list[NotionPage], start: SecondsSinceUnixEpoch | None = None, end: SecondsSinceUnixEpoch | None = None) -> Generator[Document, None, None]:
"""Reads pages for rich text content and generates Documents."""
all_child_page_ids: list[str] = []
@ -255,11 +431,17 @@ class NotionConnector(LoadConnector, PollConnector):
if isinstance(page, dict):
page = NotionPage(**page)
if page.id in self.indexed_pages:
logging.debug(f"Already indexed page with ID '{page.id}'. Skipping.")
logging.debug(f"[Notion]: Already indexed page with ID {page.id}. Skipping.")
continue
logging.info(f"Reading page with ID '{page.id}', with url {page.url}")
page_blocks, child_page_ids = self._read_blocks(page.id)
if start is not None and end is not None:
page_ts = datetime_from_string(page.last_edited_time).timestamp()
if not (page_ts > start and page_ts <= end):
logging.debug(f"[Notion]: Skipping page {page.id} outside polling window.")
continue
logging.info(f"[Notion]: Reading page with ID {page.id}, with url {page.url}")
page_blocks, child_page_ids, attachment_docs = self._read_blocks(page.id, page.last_edited_time)
all_child_page_ids.extend(child_page_ids)
self.indexed_pages.add(page.id)
@ -268,14 +450,12 @@ class NotionConnector(LoadConnector, PollConnector):
if not page_blocks:
if not raw_page_title:
logging.warning(f"No blocks OR title found for page with ID '{page.id}'. Skipping.")
logging.warning(f"[Notion]: No blocks OR title found for page with ID {page.id}. Skipping.")
continue
text = page_title
if page.properties:
text += "\n\n" + "\n".join(
[f"{key}: {value}" for key, value in page.properties.items()]
)
text += "\n\n" + "\n".join([f"{key}: {value}" for key, value in page.properties.items()])
sections = [TextSection(link=page.url, text=text)]
else:
sections = [
@ -286,45 +466,39 @@ class NotionConnector(LoadConnector, PollConnector):
for block in page_blocks
]
blob = ("\n".join([sec.text for sec in sections])).encode("utf-8")
joined_text = "\n".join(sec.text for sec in sections)
blob = joined_text.encode("utf-8")
yield Document(
id=page.id,
blob=blob,
source=DocumentSource.NOTION,
semantic_identifier=page_title,
extension=".txt",
size_bytes=len(blob),
doc_updated_at=datetime_from_string(page.last_edited_time)
id=page.id, blob=blob, source=DocumentSource.NOTION, semantic_identifier=page_title, extension=".txt", size_bytes=len(blob), doc_updated_at=datetime_from_string(page.last_edited_time)
)
for attachment_doc in attachment_docs:
yield attachment_doc
if self.recursive_index_enabled and all_child_page_ids:
for child_page_batch_ids in batch_generator(all_child_page_ids, INDEX_BATCH_SIZE):
child_page_batch = [
self._fetch_page(page_id)
for page_id in child_page_batch_ids
if page_id not in self.indexed_pages
]
yield from self._read_pages(child_page_batch)
child_page_batch = [self._fetch_page(page_id) for page_id in child_page_batch_ids if page_id not in self.indexed_pages]
yield from self._read_pages(child_page_batch, start, end)
@retry(tries=3, delay=1, backoff=2)
def _search_notion(self, query_dict: dict[str, Any]) -> NotionSearchResponse:
"""Search for pages from a Notion database."""
logging.debug(f"Searching for pages in Notion with query_dict: {query_dict}")
logging.debug(f"[Notion]: Searching for pages in Notion with query_dict: {query_dict}")
data = fetch_notion_data("https://api.notion.com/v1/search", self.headers, "POST", query_dict)
return NotionSearchResponse(**data)
def _recursive_load(self) -> Generator[list[Document], None, None]:
def _recursive_load(self, start: SecondsSinceUnixEpoch | None = None, end: SecondsSinceUnixEpoch | None = None) -> Generator[list[Document], None, None]:
"""Recursively load pages starting from root page ID."""
if self.root_page_id is None or not self.recursive_index_enabled:
raise RuntimeError("Recursive page lookup is not enabled")
logging.info(f"Recursively loading pages from Notion based on root page with ID: {self.root_page_id}")
logging.info(f"[Notion]: Recursively loading pages from Notion based on root page with ID: {self.root_page_id}")
pages = [self._fetch_page(page_id=self.root_page_id)]
yield from batch_generator(self._read_pages(pages), self.batch_size)
yield from batch_generator(self._read_pages(pages, start, end), self.batch_size)
def load_credentials(self, credentials: dict[str, Any]) -> dict[str, Any] | None:
"""Applies integration token to headers."""
self.headers["Authorization"] = f'Bearer {credentials["notion_integration_token"]}'
self.headers["Authorization"] = f"Bearer {credentials['notion_integration_token']}"
return None
def load_from_state(self) -> GenerateDocumentsOutput:
@ -348,12 +522,10 @@ class NotionConnector(LoadConnector, PollConnector):
else:
break
def poll_source(
self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch
) -> GenerateDocumentsOutput:
def poll_source(self, start: SecondsSinceUnixEpoch, end: SecondsSinceUnixEpoch) -> GenerateDocumentsOutput:
"""Poll Notion for updated pages within a time period."""
if self.recursive_index_enabled and self.root_page_id:
yield from self._recursive_load()
yield from self._recursive_load(start, end)
return
query_dict = {
@ -367,7 +539,7 @@ class NotionConnector(LoadConnector, PollConnector):
pages = filter_pages_by_time(db_res.results, start, end, "last_edited_time")
if pages:
yield from batch_generator(self._read_pages(pages), self.batch_size)
yield from batch_generator(self._read_pages(pages, start, end), self.batch_size)
if db_res.has_more:
query_dict["start_cursor"] = db_res.next_cursor
else:

View file

@ -187,7 +187,7 @@ class DoclingParser(RAGFlowPdfParser):
bbox = _BBox(int(pn), bb[0], bb[1], bb[2], bb[3])
yield (DoclingContentType.EQUATION.value, text, bbox)
def _transfer_to_sections(self, doc) -> list[tuple[str, str]]:
def _transfer_to_sections(self, doc, parse_method: str) -> list[tuple[str, str]]:
sections: list[tuple[str, str]] = []
for typ, payload, bbox in self._iter_doc_items(doc):
if typ == DoclingContentType.TEXT.value:
@ -200,7 +200,12 @@ class DoclingParser(RAGFlowPdfParser):
continue
tag = self._make_line_tag(bbox) if isinstance(bbox,_BBox) else ""
sections.append((section, tag))
if parse_method == "manual":
sections.append((section, typ, tag))
elif parse_method == "paper":
sections.append((section + tag, typ))
else:
sections.append((section, tag))
return sections
def cropout_docling_table(self, page_no: int, bbox: tuple[float, float, float, float], zoomin: int = 1):
@ -282,7 +287,8 @@ class DoclingParser(RAGFlowPdfParser):
output_dir: Optional[str] = None,
lang: Optional[str] = None,
method: str = "auto",
delete_output: bool = True,
delete_output: bool = True,
parse_method: str = "raw"
):
if not self.check_installation():
@ -318,7 +324,7 @@ class DoclingParser(RAGFlowPdfParser):
if callback:
callback(0.7, f"[Docling] Parsed doc: {getattr(doc, 'num_pages', 'n/a')} pages")
sections = self._transfer_to_sections(doc)
sections = self._transfer_to_sections(doc, parse_method=parse_method)
tables = self._transfer_to_tables(doc)
if callback:

View file

@ -476,7 +476,7 @@ class MinerUParser(RAGFlowPdfParser):
item[key] = str((subdir / item[key]).resolve())
return data
def _transfer_to_sections(self, outputs: list[dict[str, Any]]):
def _transfer_to_sections(self, outputs: list[dict[str, Any]], parse_method: str = None):
sections = []
for output in outputs:
match output["type"]:
@ -497,7 +497,11 @@ class MinerUParser(RAGFlowPdfParser):
case MinerUContentType.DISCARDED:
pass
if section:
if section and parse_method == "manual":
sections.append((section, output["type"], self._line_tag(output)))
elif section and parse_method == "paper":
sections.append((section + self._line_tag(output), output["type"]))
else:
sections.append((section, self._line_tag(output)))
return sections
@ -516,6 +520,7 @@ class MinerUParser(RAGFlowPdfParser):
method: str = "auto",
server_url: Optional[str] = None,
delete_output: bool = True,
parse_method: str = "raw"
) -> tuple:
import shutil
@ -565,7 +570,8 @@ class MinerUParser(RAGFlowPdfParser):
self.logger.info(f"[MinerU] Parsed {len(outputs)} blocks from PDF.")
if callback:
callback(0.75, f"[MinerU] Parsed {len(outputs)} blocks from PDF.")
return self._transfer_to_sections(outputs), self._transfer_to_tables(outputs)
return self._transfer_to_sections(outputs, parse_method), self._transfer_to_tables(outputs)
finally:
if temp_pdf and temp_pdf.exists():
try:

View file

@ -33,6 +33,8 @@ import xgboost as xgb
from huggingface_hub import snapshot_download
from PIL import Image
from pypdf import PdfReader as pdf2_read
from sklearn.cluster import KMeans
from sklearn.metrics import silhouette_score
from common.file_utils import get_project_base_directory
from common.misc_utils import pip_install_torch
@ -353,7 +355,6 @@ class RAGFlowPdfParser:
def _assign_column(self, boxes, zoomin=3):
if not boxes:
return boxes
if all("col_id" in b for b in boxes):
return boxes
@ -361,61 +362,80 @@ class RAGFlowPdfParser:
for b in boxes:
by_page[b["page_number"]].append(b)
page_info = {} # pg -> dict(page_w, left_edge, cand_cols)
counter = Counter()
page_cols = {}
for pg, bxs in by_page.items():
if not bxs:
page_info[pg] = {"page_w": 1.0, "left_edge": 0.0, "cand": 1}
counter[1] += 1
page_cols[pg] = 1
continue
if hasattr(self, "page_images") and self.page_images and len(self.page_images) >= pg:
page_w = self.page_images[pg - 1].size[0] / max(1, zoomin)
left_edge = 0.0
else:
xs0 = [box["x0"] for box in bxs]
xs1 = [box["x1"] for box in bxs]
left_edge = float(min(xs0))
page_w = max(1.0, float(max(xs1) - left_edge))
x0s_raw = np.array([b["x0"] for b in bxs], dtype=float)
widths = [max(1.0, (box["x1"] - box["x0"])) for box in bxs]
median_w = float(np.median(widths)) if widths else 1.0
min_x0 = np.min(x0s_raw)
max_x1 = np.max([b["x1"] for b in bxs])
width = max_x1 - min_x0
raw_cols = int(page_w / max(1.0, median_w))
INDENT_TOL = width * 0.12
x0s = []
for x in x0s_raw:
if abs(x - min_x0) < INDENT_TOL:
x0s.append([min_x0])
else:
x0s.append([x])
x0s = np.array(x0s, dtype=float)
max_try = min(4, len(bxs))
if max_try < 2:
max_try = 1
best_k = 1
best_score = -1
# cand = raw_cols if (raw_cols >= 2 and median_w < page_w / raw_cols * 0.8) else 1
cand = raw_cols
for k in range(1, max_try + 1):
km = KMeans(n_clusters=k, n_init="auto")
labels = km.fit_predict(x0s)
page_info[pg] = {"page_w": page_w, "left_edge": left_edge, "cand": cand}
counter[cand] += 1
centers = np.sort(km.cluster_centers_.flatten())
if len(centers) > 1:
try:
score = silhouette_score(x0s, labels)
except ValueError:
continue
else:
score = 0
print(f"{k=},{score=}",flush=True)
if score > best_score:
best_score = score
best_k = k
logging.info(f"[Page {pg}] median_w={median_w:.2f}, page_w={page_w:.2f}, raw_cols={raw_cols}, cand={cand}")
page_cols[pg] = best_k
logging.info(f"[Page {pg}] best_score={best_score:.2f}, best_k={best_k}")
global_cols = counter.most_common(1)[0][0]
global_cols = Counter(page_cols.values()).most_common(1)[0][0]
logging.info(f"Global column_num decided by majority: {global_cols}")
for pg, bxs in by_page.items():
if not bxs:
continue
k = page_cols[pg]
if len(bxs) < k:
k = 1
x0s = np.array([[b["x0"]] for b in bxs], dtype=float)
km = KMeans(n_clusters=k, n_init="auto")
labels = km.fit_predict(x0s)
page_w = page_info[pg]["page_w"]
left_edge = page_info[pg]["left_edge"]
centers = km.cluster_centers_.flatten()
order = np.argsort(centers)
if global_cols == 1:
for box in bxs:
box["col_id"] = 0
continue
remap = {orig: new for new, orig in enumerate(order)}
for box in bxs:
w = box["x1"] - box["x0"]
if w >= 0.8 * page_w:
box["col_id"] = 0
continue
cx = 0.5 * (box["x0"] + box["x1"])
norm_cx = (cx - left_edge) / page_w
norm_cx = max(0.0, min(norm_cx, 0.999999))
box["col_id"] = int(min(global_cols - 1, norm_cx * global_cols))
for b, lb in zip(bxs, labels):
b["col_id"] = remap[lb]
grouped = defaultdict(list)
for b in bxs:
grouped[b["col_id"]].append(b)
return boxes
@ -1303,7 +1323,10 @@ class RAGFlowPdfParser:
positions = []
for ii, (pns, left, right, top, bottom) in enumerate(poss):
right = left + max_width
if 0 < ii < len(poss) - 1:
right = max(left + 10, right)
else:
right = left + max_width
bottom *= ZM
for pn in pns[1:]:
if 0 <= pn - 1 < page_count:

View file

@ -230,9 +230,16 @@ REGISTER_ENABLED=1
# SANDBOX_MAX_MEMORY=256m # b, k, m, g
# SANDBOX_TIMEOUT=10s # s, m, 1m30s
# Enable DocLing and Mineru
# Enable DocLing
USE_DOCLING=false
# Enable Mineru
USE_MINERU=false
MINERU_EXECUTABLE="$HOME/uv_tools/.venv/bin/mineru"
MINERU_DELETE_OUTPUT=0 # keep output directory
MINERU_BACKEND=pipeline # or another backend you prefer
# pptx support
DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1

View file

@ -113,6 +113,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
lang = lang,
callback = callback,
pdf_cls = Pdf,
layout_recognizer = layout_recognizer,
**kwargs
)

View file

@ -172,6 +172,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
lang = lang,
callback = callback,
pdf_cls = Pdf,
layout_recognizer = layout_recognizer,
**kwargs
)

View file

@ -213,6 +213,8 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
lang = lang,
callback = callback,
pdf_cls = Pdf,
layout_recognizer = layout_recognizer,
parse_method = "manual",
**kwargs
)
@ -225,7 +227,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
elif len(section) != 3:
raise ValueError(f"Unexpected section length: {len(section)} (value={section!r})")
txt, sec_id, poss = section
txt, layoutno, poss = section
if isinstance(poss, str):
poss = pdf_parser.extract_positions(poss)
first = poss[0] # tuple: ([pn], x1, x2, y1, y2)
@ -235,7 +237,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
pn = pn[0] # [pn] -> pn
poss[0] = (pn, *first[1:])
return (txt, sec_id, poss)
return (txt, layoutno, poss)
sections = [_normalize_section(sec) for sec in sections]

View file

@ -59,6 +59,7 @@ def by_mineru(filename, binary=None, from_page=0, to_page=100000, lang="Chinese"
mineru_executable = os.environ.get("MINERU_EXECUTABLE", "mineru")
mineru_api = os.environ.get("MINERU_APISERVER", "http://host.docker.internal:9987")
pdf_parser = MinerUParser(mineru_path=mineru_executable, mineru_api=mineru_api)
parse_method = kwargs.get("parse_method", "raw")
if not pdf_parser.check_installation():
callback(-1, "MinerU not found.")
@ -72,12 +73,14 @@ def by_mineru(filename, binary=None, from_page=0, to_page=100000, lang="Chinese"
backend=os.environ.get("MINERU_BACKEND", "pipeline"),
server_url=os.environ.get("MINERU_SERVER_URL", ""),
delete_output=bool(int(os.environ.get("MINERU_DELETE_OUTPUT", 1))),
parse_method=parse_method
)
return sections, tables, pdf_parser
def by_docling(filename, binary=None, from_page=0, to_page=100000, lang="Chinese", callback=None, pdf_cls = None ,**kwargs):
pdf_parser = DoclingParser()
parse_method = kwargs.get("parse_method", "raw")
if not pdf_parser.check_installation():
callback(-1, "Docling not found.")
@ -89,6 +92,7 @@ def by_docling(filename, binary=None, from_page=0, to_page=100000, lang="Chinese
callback=callback,
output_dir=os.environ.get("MINERU_OUTPUT_DIR", ""),
delete_output=bool(int(os.environ.get("MINERU_DELETE_OUTPUT", 1))),
parse_method=parse_method
)
return sections, tables, pdf_parser

View file

@ -99,6 +99,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
lang = lang,
callback = callback,
pdf_cls = Pdf,
layout_recognizer = layout_recognizer,
**kwargs
)

View file

@ -21,8 +21,10 @@ import re
from deepdoc.parser.figure_parser import vision_figure_parser_pdf_wrapper
from common.constants import ParserType
from rag.nlp import rag_tokenizer, tokenize, tokenize_table, add_positions, bullets_category, title_frequency, tokenize_chunks
from deepdoc.parser import PdfParser, PlainParser
from deepdoc.parser import PdfParser
import numpy as np
from rag.app.naive import by_plaintext, PARSERS
class Pdf(PdfParser):
def __init__(self):
@ -147,19 +149,40 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
"parser_config", {
"chunk_token_num": 512, "delimiter": "\n!?。;!?", "layout_recognize": "DeepDOC"})
if re.search(r"\.pdf$", filename, re.IGNORECASE):
if parser_config.get("layout_recognize", "DeepDOC") == "Plain Text":
pdf_parser = PlainParser()
layout_recognizer = parser_config.get("layout_recognize", "DeepDOC")
if isinstance(layout_recognizer, bool):
layout_recognizer = "DeepDOC" if layout_recognizer else "Plain Text"
name = layout_recognizer.strip().lower()
pdf_parser = PARSERS.get(name, by_plaintext)
callback(0.1, "Start to parse.")
if name == "deepdoc":
pdf_parser = Pdf()
paper = pdf_parser(filename if not binary else binary,
from_page=from_page, to_page=to_page, callback=callback)
else:
sections, tables, pdf_parser = pdf_parser(
filename=filename,
binary=binary,
from_page=from_page,
to_page=to_page,
lang=lang,
callback=callback,
pdf_cls=Pdf,
parse_method="paper",
**kwargs
)
paper = {
"title": filename,
"authors": " ",
"abstract": "",
"sections": pdf_parser(filename if not binary else binary, from_page=from_page, to_page=to_page)[0],
"tables": []
"sections": sections,
"tables": tables
}
else:
pdf_parser = Pdf()
paper = pdf_parser(filename if not binary else binary,
from_page=from_page, to_page=to_page, callback=callback)
tbls=paper["tables"]
tbls=vision_figure_parser_pdf_wrapper(tbls=tbls,callback=callback,**kwargs)
paper["tables"] = tbls

View file

@ -142,6 +142,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000,
lang = lang,
callback = callback,
pdf_cls = Pdf,
layout_recognizer = layout_recognizer,
**kwargs
)

View file

@ -29,7 +29,10 @@ const BackButton: React.FC<BackButtonProps> = ({
return (
<Button
variant="ghost"
className={cn('gap-2 bg-bg-card border border-border-default', className)}
className={cn(
'gap-2 bg-bg-card border border-border-default hover:bg-border-button hover:text-text-primary',
className,
)}
onClick={handleClick}
{...props}
>

View file

@ -44,6 +44,7 @@ export function ConfirmDeleteDialog({
<AlertDialogContent
onSelect={(e) => e.preventDefault()}
onClick={(e) => e.stopPropagation()}
className="bg-bg-base"
>
<AlertDialogHeader>
<AlertDialogTitle>
@ -59,7 +60,7 @@ export function ConfirmDeleteDialog({
{t('common.no')}
</AlertDialogCancel>
<AlertDialogAction
className="bg-state-error text-text-primary"
className="bg-state-error text-text-primary hover:text-text-primary hover:bg-state-error"
onClick={onOk}
>
{t('common.yes')}

View file

@ -68,6 +68,7 @@ export interface FormFieldConfig {
dependencies?: string[];
schema?: ZodSchema;
shouldRender?: (formValues: any) => boolean;
labelClassName?: string;
}
// Component props interface
@ -81,6 +82,7 @@ interface DynamicFormProps<T extends FieldValues> {
fieldName: string,
updatedField: Partial<FormFieldConfig>,
) => void;
labelClassName?: string;
}
// Form ref interface
@ -295,6 +297,7 @@ const DynamicForm = {
children,
defaultValues: formDefaultValues = {} as DefaultValues<T>,
onFieldUpdate,
labelClassName,
}: DynamicFormProps<T>,
ref: React.Ref<any>,
) => {
@ -456,6 +459,7 @@ const DynamicForm = {
required={field.required}
horizontal={field.horizontal}
tooltip={field.tooltip}
labelClassName={labelClassName || field.labelClassName}
>
{(fieldProps) => {
const finalFieldProps = field.onChange
@ -481,6 +485,7 @@ const DynamicForm = {
required={field.required}
horizontal={field.horizontal}
tooltip={field.tooltip}
labelClassName={labelClassName || field.labelClassName}
>
{(fieldProps) => {
const finalFieldProps = field.onChange
@ -511,6 +516,7 @@ const DynamicForm = {
required={field.required}
horizontal={field.horizontal}
tooltip={field.tooltip}
labelClassName={labelClassName || field.labelClassName}
>
{(fieldProps) => {
const finalFieldProps = field.onChange
@ -551,7 +557,10 @@ const DynamicForm = {
{field.label && !field.horizontal && (
<div className="space-y-1 leading-none">
<FormLabel
className="font-normal"
className={cn(
'font-medium',
labelClassName || field.labelClassName,
)}
tooltip={field.tooltip}
>
{field.label}{' '}
@ -564,7 +573,10 @@ const DynamicForm = {
{field.label && field.horizontal && (
<div className="space-y-1 leading-none w-1/4">
<FormLabel
className="font-normal"
className={cn(
'font-medium',
labelClassName || field.labelClassName,
)}
tooltip={field.tooltip}
>
{field.label}{' '}
@ -600,6 +612,7 @@ const DynamicForm = {
required={field.required}
horizontal={field.horizontal}
tooltip={field.tooltip}
labelClassName={labelClassName || field.labelClassName}
>
{(fieldProps) => {
const finalFieldProps = field.onChange
@ -629,6 +642,7 @@ const DynamicForm = {
required={field.required}
horizontal={field.horizontal}
tooltip={field.tooltip}
labelClassName={labelClassName || field.labelClassName}
>
{(fieldProps) => {
const finalFieldProps = field.onChange
@ -748,7 +762,7 @@ const DynamicForm = {
<button
type="button"
onClick={() => handleCancel()}
className="px-2 py-1 border border-input rounded-md hover:bg-muted"
className="px-2 py-1 border border-border-button rounded-md text-text-secondary hover:bg-bg-card hover:text-primary"
>
{cancelText ?? t('modal.cancelText')}
</button>

View file

@ -102,8 +102,8 @@ const EditTag = React.forwardRef<HTMLDivElement, EditTagsProps>(
{Array.isArray(tagChild) && tagChild.length > 0 && <>{tagChild}</>}
{!inputVisible && (
<Button
variant="dashed"
className="w-fit flex items-center justify-center gap-2 bg-bg-card"
variant="ghost"
className="w-fit flex items-center justify-center gap-2 bg-bg-card border-dashed border"
onClick={showInput}
style={tagPlusStyle}
>

View file

@ -272,7 +272,7 @@ export function FileUploader(props: FileUploaderProps) {
<div
{...getRootProps()}
className={cn(
'group relative grid h-72 w-full cursor-pointer place-items-center rounded-lg border-2 border-dashed border-border-default px-5 py-2.5 text-center transition hover:bg-muted/25 bg-accent-primary-5',
'group relative grid h-72 w-full cursor-pointer place-items-center rounded-lg border border-dashed border-border-default px-5 py-2.5 text-center transition hover:bg-border-button bg-bg-card',
'ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isDragActive && 'border-muted-foreground/50',
isDisabled && 'pointer-events-none opacity-60',
@ -285,11 +285,11 @@ export function FileUploader(props: FileUploaderProps) {
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
<div className="rounded-full border border-dashed p-3">
<Upload
className="size-7 text-muted-foreground"
className="size-7 text-text-secondary"
aria-hidden="true"
/>
</div>
<p className="font-medium text-muted-foreground">
<p className="font-medium text-text-secondary">
Drop the files here
</p>
</div>
@ -297,15 +297,15 @@ export function FileUploader(props: FileUploaderProps) {
<div className="flex flex-col items-center justify-center gap-4 sm:px-5">
<div className="rounded-full border border-dashed p-3">
<Upload
className="size-7 text-muted-foreground"
className="size-7 text-text-secondary"
aria-hidden="true"
/>
</div>
<div className="flex flex-col gap-px">
<p className="font-medium text-muted-foreground">
<p className="font-medium text-text-secondary">
{t('knowledgeDetails.uploadTitle')}
</p>
<p className="text-sm text-text-secondary">
<p className="text-sm text-text-disabled">
{description || t('knowledgeDetails.uploadDescription')}
{/* You can upload
{maxFileCount > 1

View file

@ -24,20 +24,6 @@ export default React.forwardRef<HTMLInputElement, InputProps>(
ref={ref}
{...props}
/>
<button
className="text-muted-foreground/80 hover:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 absolute inset-y-0 end-0 flex h-full w-9 items-center justify-center rounded-e-md transition-[color,box-shadow] outline-none focus:z-10 focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
type="button"
onClick={toggleVisibility}
aria-label={isVisible ? 'Hide password' : 'Show password'}
aria-pressed={isVisible}
aria-controls="password"
>
{/* {isVisible ? (
<EyeOffIcon size={16} aria-hidden="true" />
) : (
<EyeIcon size={16} aria-hidden="true" />
)} */}
</button>
</div>
</div>
);

View file

@ -140,7 +140,7 @@ export const SelectWithSearch = forwardRef<
ref={ref}
disabled={disabled}
className={cn(
'!bg-bg-input hover:bg-background border-border-button w-full justify-between px-3 font-normal outline-offset-0 outline-none focus-visible:outline-[3px] [&_svg]:pointer-events-auto',
'!bg-bg-input hover:bg-background border-border-button w-full justify-between px-3 font-normal outline-offset-0 outline-none focus-visible:outline-[3px] [&_svg]:pointer-events-auto group',
triggerClassName,
)}
>
@ -155,12 +155,12 @@ export const SelectWithSearch = forwardRef<
{value && allowClear && (
<>
<XIcon
className="h-4 mx-2 cursor-pointer text-text-disabled"
className="h-4 mx-2 cursor-pointer text-text-disabled hidden group-hover:block"
onClick={handleClear}
/>
<Separator
orientation="vertical"
className="flex min-h-6 h-full"
className=" min-h-6 h-full hidden group-hover:flex"
/>
</>
)}
@ -173,12 +173,17 @@ export const SelectWithSearch = forwardRef<
</Button>
</PopoverTrigger>
<PopoverContent
className="border-input w-full min-w-[var(--radix-popper-anchor-width)] p-0"
className="border-border-button w-full min-w-[var(--radix-popper-anchor-width)] p-0"
align="start"
>
<Command>
<CommandInput placeholder={t('common.search') + '...'} />
<CommandList>
<Command className="p-5">
{options && options.length > 0 && (
<CommandInput
placeholder={t('common.search') + '...'}
className=" placeholder:text-text-disabled"
/>
)}
<CommandList className="mt-2">
<CommandEmpty>{t('common.noDataFound')}</CommandEmpty>
{options.map((group, idx) => {
if (group.options) {
@ -209,6 +214,7 @@ export const SelectWithSearch = forwardRef<
value={group.value}
disabled={group.disabled}
onSelect={handleSelect}
className="min-h-10"
>
<span className="leading-none">{group.label}</span>

View file

@ -39,7 +39,10 @@ const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" data-cmdk-input-wrapper="">
<div
className="flex items-center border rounded-md border-border-default bg-bg-input px-3"
data-cmdk-input-wrapper=""
>
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
@ -66,7 +69,10 @@ const CommandList = React.forwardRef<
*/
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
className={cn(
'max-h-[300px] overflow-y-auto overflow-x-hidden scrollbar-auto',
className,
)}
onWheel={(e) => e.stopPropagation()}
onMouseEnter={(e) => e.currentTarget.focus()}
tabIndex={-1}
@ -96,7 +102,7 @@ const CommandGroup = React.forwardRef<
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-text-secondary',
className,
)}
{...props}

View file

@ -3,7 +3,6 @@ import * as React from 'react';
import { cn } from '@/lib/utils';
import { Eye, EyeOff, Search } from 'lucide-react';
import { useState } from 'react';
import { Button } from './button';
export interface InputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'prefix'> {
@ -50,6 +49,8 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
'pr-12': !!suffix || isPasswordInput,
'pr-24': !!suffix && isPasswordInput,
},
type === 'number' &&
'[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none',
className,
)}
value={inputValue ?? ''}
@ -77,10 +78,10 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
</span>
)}
{isPasswordInput && (
<Button
variant="transparent"
<button
type="button"
className="
p-2 text-text-secondary
absolute border-0 right-1 top-[50%] translate-y-[-50%]
dark:peer-autofill/input:text-text-secondary-inverse
dark:peer-autofill/input:hover:text-text-primary-inverse
@ -93,7 +94,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
) : (
<Eye className="size-[1em]" />
)}
</Button>
</button>
)}
</div>
);

View file

@ -27,7 +27,10 @@ export interface ModalProps {
okText?: ReactNode | string;
onOk?: () => void;
onCancel?: () => void;
okButtonClassName?: string;
cancelButtonClassName?: string;
disabled?: boolean;
style?: React.CSSProperties;
}
export interface ModalType extends FC<ModalProps> {
show: typeof modalIns.show;
@ -56,7 +59,10 @@ const Modal: ModalType = ({
confirmLoading,
cancelText,
okText,
okButtonClassName,
cancelButtonClassName,
disabled = false,
style,
}) => {
const sizeClasses = {
small: 'max-w-md',
@ -111,7 +117,10 @@ const Modal: ModalType = ({
<button
type="button"
onClick={() => handleCancel()}
className="px-2 py-1 border border-border-button rounded-md hover:bg-bg-card hover:text-text-primary "
className={cn(
'px-2 py-1 border border-border-button rounded-md hover:bg-bg-card hover:text-text-primary ',
cancelButtonClassName,
)}
>
{cancelText ?? t('modal.cancelText')}
</button>
@ -122,6 +131,7 @@ const Modal: ModalType = ({
className={cn(
'px-2 py-1 bg-primary text-primary-foreground rounded-md hover:bg-primary/90',
{ 'cursor-not-allowed': disabled },
okButtonClassName,
)}
>
{confirmLoading && (
@ -153,23 +163,26 @@ const Modal: ModalType = ({
handleOk,
showfooter,
footerClassName,
okButtonClassName,
cancelButtonClassName,
]);
return (
<DialogPrimitive.Root open={open} onOpenChange={handleChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay
className="fixed inset-0 z-50 bg-colors-background-neutral-weak/50 backdrop-blur-sm flex items-center justify-center p-4"
className="fixed inset-0 z-50 bg-bg-card backdrop-blur-sm flex items-center justify-center p-4"
onClick={() => maskClosable && onOpenChange?.(false)}
>
<DialogPrimitive.Content
className={`relative w-[700px] ${full ? 'max-w-full' : sizeClasses[size]} ${className} bg-bg-base rounded-lg shadow-lg border border-border-default transition-all focus-visible:!outline-none`}
style={style}
onClick={(e) => e.stopPropagation()}
>
{/* title */}
{(title || closable) && (
<div
className={cn(
'flex items-center px-6 py-4',
'flex items-start px-6 py-4',
{
'justify-end': closable && !title,
'justify-between': closable && title,
@ -187,7 +200,7 @@ const Modal: ModalType = ({
<DialogPrimitive.Close asChild>
<button
type="button"
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-muted focus-visible:outline-none"
className="flex h-7 w-7 items-center justify-center text-text-secondary rounded-full hover:bg-bg-card focus-visible:outline-none"
onClick={handleCancel}
>
{closeIcon}
@ -198,7 +211,7 @@ const Modal: ModalType = ({
)}
{/* content */}
<div className="py-2 px-6 overflow-y-auto scrollbar-auto max-h-[80vh] focus-visible:!outline-none">
<div className="py-2 px-6 overflow-y-auto scrollbar-auto max-h-[calc(100vh-280px)] focus-visible:!outline-none">
{destroyOnClose && !open ? null : children}
</div>

View file

@ -378,12 +378,12 @@ export const MultiSelect = React.forwardRef<
align="start"
onEscapeKeyDown={() => setIsPopoverOpen(false)}
>
<Command>
<Command className="p-5 pb-8">
<CommandInput
placeholder={t('common.search') + '...'}
onKeyDown={handleInputKeyDown}
/>
<CommandList>
<CommandList className="mt-2">
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{showSelectAll && (
@ -437,9 +437,9 @@ export const MultiSelect = React.forwardRef<
})}
</CommandGroup>
))}
<CommandSeparator />
<CommandGroup>
<div className="flex items-center justify-between">
<div className=" absolute bottom-1 left-1 right-1 flex items-center justify-between mx-5 bg-bg-base border-t border-border-button">
<CommandSeparator />
{selectedValues.length > 0 && (
<>
<CommandItem

View file

@ -59,7 +59,7 @@ const SelectScrollUpButton = React.forwardRef<
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
'flex cursor-default items-center justify-center py-1 text-text-secondary hover:text-text-primary',
className,
)}
{...props}
@ -76,7 +76,7 @@ const SelectScrollDownButton = React.forwardRef<
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
'flex cursor-default items-center justify-center py-1',
'flex cursor-default items-center justify-center py-1 text-text-secondary hover:text-text-primary',
className,
)}
{...props}

View file

@ -54,7 +54,7 @@ const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
return (
<textarea
className={cn(
'flex min-h-[80px] w-full bg-bg-input rounded-md border border-input px-3 py-2 text-base ring-offset-background placeholder:text-text-disabled focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm overflow-hidden',
'flex min-h-[80px] w-full bg-bg-input rounded-md border border-border-button px-3 py-2 text-base ring-offset-background placeholder:text-text-disabled focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent-primary focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm overflow-hidden',
className,
)}
rows={autoSize?.minRows ?? props.rows ?? undefined}

View file

@ -20,7 +20,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-auto scrollbar-auto rounded-md whitespace-pre-wrap border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-w-[30vw]',
'z-50 overflow-auto scrollbar-auto rounded-md whitespace-pre-wrap border bg-bg-base px-3 py-1.5 text-sm text-text-primary shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 max-w-[30vw]',
className,
)}
{...props}
@ -39,7 +39,7 @@ export const FormTooltip = ({ tooltip }: { tooltip: React.ReactNode }) => {
e.preventDefault(); // Prevent clicking the tooltip from triggering form save
}}
>
<CircleQuestionMark className="size-3 ml-2" />
<CircleQuestionMark className="size-3 ml-[2px] -translate-y-1" />
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>

View file

@ -1,5 +1,4 @@
import { ExclamationCircleFilled } from '@ant-design/icons';
import { App } from 'antd';
import { Modal } from '@/components/ui/modal/modal';
import isEqual from 'lodash/isEqual';
import { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
@ -84,20 +83,27 @@ interface IProps {
}
export const useShowDeleteConfirm = () => {
const { modal } = App.useApp();
const { t } = useTranslation();
const showDeleteConfirm = useCallback(
({ title, content, onOk, onCancel }: IProps): Promise<number> => {
return new Promise((resolve, reject) => {
modal.confirm({
Modal.show({
title: title ?? t('common.deleteModalTitle'),
icon: <ExclamationCircleFilled />,
content,
visible: true,
onVisibleChange: () => {
Modal.hide();
},
footer: null,
closable: true,
maskClosable: false,
okText: t('common.yes'),
okType: 'danger',
cancelText: t('common.no'),
async onOk() {
style: {
width: '400px',
},
okButtonClassName:
'bg-state-error text-white hover:bg-state-error hover:text-white',
onOk: async () => {
try {
const ret = await onOk?.();
resolve(ret);
@ -106,13 +112,15 @@ export const useShowDeleteConfirm = () => {
reject(error);
}
},
onCancel() {
onCancel: () => {
onCancel?.();
Modal.hide();
},
children: content,
});
});
},
[t, modal],
[t],
);
return showDeleteConfirm;

View file

@ -696,6 +696,8 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s
tocEnhanceTip: ` During the parsing of the document, table of contents information was generated (see the 'Enable Table of Contents Extraction' option in the General method). This allows the large model to return table of contents items relevant to the user's query, thereby using these items to retrieve related chunks and apply weighting to these chunks during the sorting process. This approach is derived from mimicking the behavioral logic of how humans search for knowledge in books.`,
},
setting: {
seconds: 'seconds',
minutes: 'minutes',
edit: 'Edit',
cropTip:
'Drag the selection area to choose the cropping position of the image, and scroll to zoom in/out',

View file

@ -685,6 +685,8 @@ General实体和关系提取提示来自 GitHub - microsoft/graphrag基于
tocEnhanceTip: `解析文档时生成了目录信息见General方法的启用目录抽取让大模型返回和用户问题相关的目录项从而利用目录项拿到相关chunk对这些chunk在排序中进行加权。这种方法来源于模仿人类查询书本中知识的行为逻辑`,
},
setting: {
seconds: '秒',
minutes: '分',
edit: '编辑',
cropTip: '拖动选区可以选择要图片的裁剪位置,滚动可以放大/缩小选区',
cropImage: '剪裁图片',

View file

@ -92,7 +92,7 @@ export function SideBar({ refreshCount }: PropType) {
key={itemIdx}
variant={active ? 'secondary' : 'ghost'}
className={cn(
'w-full justify-start gap-2.5 px-3 relative h-10 text-text-sub-title-invert',
'w-full justify-start gap-2.5 px-3 relative h-10 text-text-secondary',
{
'bg-bg-card': active,
'text-text-primary': active,

View file

@ -62,6 +62,7 @@ const AddDataSourceModal = ({
sourceData?.id as keyof typeof DataSourceFormDefaultValues
] as FieldValues
}
labelClassName="font-normal"
>
<div className=" absolute bottom-0 right-0 left-0 flex items-center justify-end w-full gap-2 py-6 px-6">
<DynamicForm.CancelButton

View file

@ -1,725 +0,0 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { forwardRef, useEffect, useImperativeHandle, useMemo } from 'react';
import {
DefaultValues,
FieldValues,
SubmitHandler,
useForm,
useFormContext,
} from 'react-hook-form';
import { ZodSchema, z } from 'zod';
import EditTag from '@/components/edit-tag';
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { Checkbox } from '@/components/ui/checkbox';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { t } from 'i18next';
import { Loader } from 'lucide-react';
// Field type enumeration
export enum FormFieldType {
Text = 'text',
Email = 'email',
Password = 'password',
Number = 'number',
Textarea = 'textarea',
Select = 'select',
Checkbox = 'checkbox',
Tag = 'tag',
}
// Field configuration interface
export interface FormFieldConfig {
name: string;
label: string;
type: FormFieldType;
hidden?: boolean;
required?: boolean;
placeholder?: string;
options?: { label: string; value: string }[];
defaultValue?: any;
validation?: {
pattern?: RegExp;
minLength?: number;
maxLength?: number;
min?: number;
max?: number;
message?: string;
};
render?: (fieldProps: any) => React.ReactNode;
horizontal?: boolean;
onChange?: (value: any) => void;
}
// Component props interface
interface DynamicFormProps<T extends FieldValues> {
fields: FormFieldConfig[];
onSubmit: SubmitHandler<T>;
className?: string;
children?: React.ReactNode;
defaultValues?: DefaultValues<T>;
}
// Form ref interface
export interface DynamicFormRef {
submit: () => void;
getValues: () => any;
reset: (values?: any) => void;
}
// Generate Zod validation schema based on field configurations
const generateSchema = (fields: FormFieldConfig[]): ZodSchema<any> => {
const schema: Record<string, ZodSchema> = {};
const nestedSchemas: Record<string, Record<string, ZodSchema>> = {};
fields.forEach((field) => {
let fieldSchema: ZodSchema;
// Create base validation schema based on field type
switch (field.type) {
case FormFieldType.Email:
fieldSchema = z.string().email('Please enter a valid email address');
break;
case FormFieldType.Number:
fieldSchema = z.coerce.number();
if (field.validation?.min !== undefined) {
fieldSchema = (fieldSchema as z.ZodNumber).min(
field.validation.min,
field.validation.message ||
`Value cannot be less than ${field.validation.min}`,
);
}
if (field.validation?.max !== undefined) {
fieldSchema = (fieldSchema as z.ZodNumber).max(
field.validation.max,
field.validation.message ||
`Value cannot be greater than ${field.validation.max}`,
);
}
break;
case FormFieldType.Checkbox:
fieldSchema = z.boolean();
break;
case FormFieldType.Tag:
fieldSchema = z.array(z.string());
break;
default:
fieldSchema = z.string();
break;
}
// Handle required fields
if (field.required) {
if (field.type === FormFieldType.Checkbox) {
fieldSchema = (fieldSchema as z.ZodBoolean).refine(
(val) => val === true,
{
message: `${field.label} is required`,
},
);
} else if (field.type === FormFieldType.Tag) {
fieldSchema = (fieldSchema as z.ZodArray<z.ZodString>).min(1, {
message: `${field.label} is required`,
});
} else {
fieldSchema = (fieldSchema as z.ZodString).min(1, {
message: `${field.label} is required`,
});
}
}
if (!field.required) {
fieldSchema = fieldSchema.optional();
}
// Handle other validation rules
if (
field.type !== FormFieldType.Number &&
field.type !== FormFieldType.Checkbox &&
field.type !== FormFieldType.Tag &&
field.required
) {
fieldSchema = fieldSchema as z.ZodString;
if (field.validation?.minLength !== undefined) {
fieldSchema = (fieldSchema as z.ZodString).min(
field.validation.minLength,
field.validation.message ||
`Enter at least ${field.validation.minLength} characters`,
);
}
if (field.validation?.maxLength !== undefined) {
fieldSchema = (fieldSchema as z.ZodString).max(
field.validation.maxLength,
field.validation.message ||
`Enter up to ${field.validation.maxLength} characters`,
);
}
if (field.validation?.pattern) {
fieldSchema = (fieldSchema as z.ZodString).regex(
field.validation.pattern,
field.validation.message || 'Invalid input format',
);
}
}
if (field.name.includes('.')) {
const keys = field.name.split('.');
const firstKey = keys[0];
if (!nestedSchemas[firstKey]) {
nestedSchemas[firstKey] = {};
}
let currentSchema = nestedSchemas[firstKey];
for (let i = 1; i < keys.length - 1; i++) {
const key = keys[i];
if (!currentSchema[key]) {
currentSchema[key] = {};
}
currentSchema = currentSchema[key];
}
const lastKey = keys[keys.length - 1];
currentSchema[lastKey] = fieldSchema;
} else {
schema[field.name] = fieldSchema;
}
});
Object.keys(nestedSchemas).forEach((key) => {
const buildNestedSchema = (obj: Record<string, any>): ZodSchema => {
const nestedSchema: Record<string, ZodSchema> = {};
Object.keys(obj).forEach((subKey) => {
if (
typeof obj[subKey] === 'object' &&
!(obj[subKey] instanceof z.ZodType)
) {
nestedSchema[subKey] = buildNestedSchema(obj[subKey]);
} else {
nestedSchema[subKey] = obj[subKey];
}
});
return z.object(nestedSchema);
};
schema[key] = buildNestedSchema(nestedSchemas[key]);
});
return z.object(schema);
};
// Generate default values based on field configurations
const generateDefaultValues = <T extends FieldValues>(
fields: FormFieldConfig[],
): DefaultValues<T> => {
const defaultValues: Record<string, any> = {};
fields.forEach((field) => {
if (field.name.includes('.')) {
const keys = field.name.split('.');
let current = defaultValues;
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
if (!current[key]) {
current[key] = {};
}
current = current[key];
}
const lastKey = keys[keys.length - 1];
if (field.defaultValue !== undefined) {
current[lastKey] = field.defaultValue;
} else if (field.type === FormFieldType.Checkbox) {
current[lastKey] = false;
} else if (field.type === FormFieldType.Tag) {
current[lastKey] = [];
} else {
current[lastKey] = '';
}
} else {
if (field.defaultValue !== undefined) {
defaultValues[field.name] = field.defaultValue;
} else if (field.type === FormFieldType.Checkbox) {
defaultValues[field.name] = false;
} else if (field.type === FormFieldType.Tag) {
defaultValues[field.name] = [];
} else {
defaultValues[field.name] = '';
}
}
});
return defaultValues as DefaultValues<T>;
};
// Dynamic form component
const DynamicForm = {
Root: forwardRef(
<T extends FieldValues>(
{
fields,
onSubmit,
className = '',
children,
defaultValues: formDefaultValues = {} as DefaultValues<T>,
}: DynamicFormProps<T>,
ref: React.Ref<any>,
) => {
// Generate validation schema and default values
const schema = useMemo(() => generateSchema(fields), [fields]);
const defaultValues = useMemo(() => {
const value = {
...generateDefaultValues(fields),
...formDefaultValues,
};
console.log('generateDefaultValues', fields, value);
return value;
}, [fields, formDefaultValues]);
// Initialize form
const form = useForm<T>({
resolver: zodResolver(schema),
defaultValues,
});
// Expose form methods via ref
useImperativeHandle(ref, () => ({
submit: () => form.handleSubmit(onSubmit)(),
getValues: () => form.getValues(),
reset: (values?: T) => {
if (values) {
form.reset(values);
} else {
form.reset();
}
},
setError: form.setError,
clearErrors: form.clearErrors,
trigger: form.trigger,
}));
useEffect(() => {
if (formDefaultValues && Object.keys(formDefaultValues).length > 0) {
form.reset({
...generateDefaultValues(fields),
...formDefaultValues,
});
}
}, [form, formDefaultValues, fields]);
// Submit handler
// const handleSubmit = form.handleSubmit(onSubmit);
// Render form fields
const renderField = (field: FormFieldConfig) => {
if (field.render) {
return (
<RAGFlowFormItem
name={field.name}
label={field.label}
required={field.required}
horizontal={field.horizontal}
>
{(fieldProps) => {
const finalFieldProps = field.onChange
? {
...fieldProps,
onChange: (e: any) => {
fieldProps.onChange(e);
field.onChange?.(e.target?.value ?? e);
},
}
: fieldProps;
return field.render?.(finalFieldProps);
}}
</RAGFlowFormItem>
);
}
switch (field.type) {
case FormFieldType.Textarea:
return (
<RAGFlowFormItem
name={field.name}
label={field.label}
required={field.required}
horizontal={field.horizontal}
>
{(fieldProps) => {
const finalFieldProps = field.onChange
? {
...fieldProps,
onChange: (e: any) => {
fieldProps.onChange(e);
field.onChange?.(e.target.value);
},
}
: fieldProps;
return (
<Textarea
{...finalFieldProps}
placeholder={field.placeholder}
className="resize-none"
/>
);
}}
</RAGFlowFormItem>
);
case FormFieldType.Select:
return (
<RAGFlowFormItem
name={field.name}
label={field.label}
required={field.required}
horizontal={field.horizontal}
>
{(fieldProps) => {
const finalFieldProps = field.onChange
? {
...fieldProps,
onChange: (value: string) => {
console.log('select value', value);
if (fieldProps.onChange) {
fieldProps.onChange(value);
}
field.onChange?.(value);
},
}
: fieldProps;
return (
<SelectWithSearch
triggerClassName="!shrink"
{...finalFieldProps}
options={field.options}
/>
);
}}
</RAGFlowFormItem>
);
case FormFieldType.Checkbox:
return (
<FormField
control={form.control}
name={field.name as any}
render={({ field: formField }) => (
<FormItem
className={cn('flex items-center', {
'flex-row items-start space-x-3 space-y-0':
!field.horizontal,
})}
>
{field.label && !field.horizontal && (
<div className="space-y-1 leading-none">
<FormLabel className="font-normal">
{field.label}{' '}
{field.required && (
<span className="text-destructive">*</span>
)}
</FormLabel>
</div>
)}
<FormControl>
<Checkbox
checked={formField.value}
onCheckedChange={(checked) => {
formField.onChange(checked);
field.onChange?.(checked);
}}
/>
</FormControl>
{field.label && field.horizontal && (
<div className="space-y-1 leading-none">
<FormLabel className="font-normal">
{field.label}{' '}
{field.required && (
<span className="text-destructive">*</span>
)}
</FormLabel>
</div>
)}
<FormMessage />
</FormItem>
)}
/>
);
case FormFieldType.Tag:
return (
<RAGFlowFormItem
name={field.name}
label={field.label}
required={field.required}
horizontal={field.horizontal}
>
{(fieldProps) => {
const finalFieldProps = field.onChange
? {
...fieldProps,
onChange: (value: string[]) => {
fieldProps.onChange(value);
field.onChange?.(value);
},
}
: fieldProps;
return (
// <TagInput {...fieldProps} placeholder={field.placeholder} />
<div className="w-full">
<EditTag {...finalFieldProps}></EditTag>
</div>
);
}}
</RAGFlowFormItem>
);
default:
return (
<RAGFlowFormItem
name={field.name}
label={field.label}
required={field.required}
horizontal={field.horizontal}
>
{(fieldProps) => {
const finalFieldProps = field.onChange
? {
...fieldProps,
onChange: (e: any) => {
fieldProps.onChange(e);
field.onChange?.(e.target.value);
},
}
: fieldProps;
return (
<Input
{...finalFieldProps}
type={field.type}
placeholder={field.placeholder}
/>
);
}}
</RAGFlowFormItem>
);
}
};
return (
<Form {...form}>
<form
className={`space-y-6 ${className}`}
onSubmit={(e) => {
e.preventDefault();
form.handleSubmit(onSubmit)(e);
}}
>
<>
{fields.map((field) => (
<div key={field.name} className={cn({ hidden: field.hidden })}>
{renderField(field)}
</div>
))}
{children}
</>
</form>
</Form>
);
},
) as <T extends FieldValues>(
props: DynamicFormProps<T> & { ref?: React.Ref<DynamicFormRef> },
) => React.ReactElement,
SavingButton: ({
submitLoading,
buttonText,
submitFunc,
}: {
submitLoading: boolean;
buttonText?: string;
submitFunc?: (values: FieldValues) => void;
}) => {
const form = useFormContext();
return (
<button
type="button"
disabled={submitLoading}
onClick={() => {
console.log('form submit');
(async () => {
console.log('form submit2');
try {
let beValid = await form.formControl.trigger();
console.log('form valid', beValid, form, form.formControl);
if (beValid) {
form.handleSubmit(async (values) => {
console.log('form values', values);
submitFunc?.(values);
})();
}
} catch (e) {
console.error(e);
} finally {
console.log('form submit3');
}
})();
}}
className={cn(
'px-2 py-1 bg-primary text-primary-foreground rounded-md hover:bg-primary/90',
)}
>
{submitLoading && (
<Loader className="inline-block mr-2 h-4 w-4 animate-spin" />
)}
{buttonText ?? t('modal.okText')}
</button>
);
},
CancelButton: ({
handleCancel,
cancelText,
}: {
handleCancel: () => void;
cancelText?: string;
}) => {
return (
<button
type="button"
onClick={() => handleCancel()}
className="px-2 py-1 border border-input rounded-md hover:bg-muted"
>
{cancelText ?? t('modal.cancelText')}
</button>
);
},
};
export { DynamicForm };
/**
* Usage Example 1: Basic Form
*
* <DynamicForm
* fields={[
* {
* name: "username",
* label: "Username",
* type: FormFieldType.Text,
* required: true,
* placeholder: "Please enter username"
* },
* {
* name: "email",
* label: "Email",
* type: FormFieldType.Email,
* required: true,
* placeholder: "Please enter email address"
* }
* ]}
* onSubmit={(data) => {
* console.log(data); // { username: "...", email: "..." }
* }}
* />
*
* Usage Example 2: Nested Object Form
*
* <DynamicForm
* fields={[
* {
* name: "user.name",
* label: "Name",
* type: FormFieldType.Text,
* required: true,
* placeholder: "Please enter name"
* },
* {
* name: "user.email",
* label: "Email",
* type: FormFieldType.Email,
* required: true,
* placeholder: "Please enter email address"
* },
* {
* name: "user.profile.age",
* label: "Age",
* type: FormFieldType.Number,
* required: true,
* validation: {
* min: 18,
* max: 100,
* message: "Age must be between 18 and 100"
* }
* },
* {
* name: "user.profile.bio",
* label: "Bio",
* type: FormFieldType.Textarea,
* placeholder: "Please briefly introduce yourself"
* },
* {
* name: "settings.notifications",
* label: "Enable Notifications",
* type: FormFieldType.Checkbox
* }
* ]}
* onSubmit={(data) => {
* console.log(data);
* // {
* // user: {
* // name: "...",
* // email: "...",
* // profile: {
* // age: ...,
* // bio: "..."
* // }
* // },
* // settings: {
* // notifications: true/false
* // }
* // }
* }}
* />
*
* Usage Example 3: Tag Type Form
*
* <DynamicForm
* fields={[
* {
* name: "skills",
* label: "Skill Tags",
* type: FormFieldType.Tag,
* required: true,
* placeholder: "Enter skill and press Enter to add tag"
* },
* {
* name: "user.hobbies",
* label: "Hobbies",
* type: FormFieldType.Tag,
* placeholder: "Enter hobby and press Enter to add tag"
* }
* ]}
* onSubmit={(data) => {
* console.log(data);
* // {
* // skills: ["JavaScript", "React", "TypeScript"],
* // user: {
* // hobbies: ["Reading", "Swimming", "Travel"]
* // }
* // }
* }}
* />
*/

View file

@ -387,101 +387,6 @@ export const DataSourceFormFields = {
tooltip: t('setting.jiraPasswordTip'),
},
],
// [DataSourceKey.GOOGLE_DRIVE]: [
// {
// label: 'Primary Admin Email',
// name: 'config.credentials.google_primary_admin',
// type: FormFieldType.Text,
// required: true,
// placeholder: 'admin@example.com',
// tooltip: t('setting.google_drivePrimaryAdminTip'),
// },
// {
// label: 'OAuth Token JSON',
// name: 'config.credentials.google_tokens',
// type: FormFieldType.Textarea,
// required: true,
// render: (fieldProps) => (
// <GoogleDriveTokenField
// value={fieldProps.value}
// onChange={fieldProps.onChange}
// placeholder='{ "token": "...", "refresh_token": "...", ... }'
// />
// ),
// tooltip: t('setting.google_driveTokenTip'),
// },
// {
// label: 'My Drive Emails',
// name: 'config.my_drive_emails',
// type: FormFieldType.Text,
// required: true,
// placeholder: 'user1@example.com,user2@example.com',
// tooltip: t('setting.google_driveMyDriveEmailsTip'),
// },
// {
// label: 'Shared Folder URLs',
// name: 'config.shared_folder_urls',
// type: FormFieldType.Textarea,
// required: true,
// placeholder:
// 'https://drive.google.com/drive/folders/XXXXX,https://drive.google.com/drive/folders/YYYYY',
// tooltip: t('setting.google_driveSharedFoldersTip'),
// },
// // The fields below are intentionally disabled for now. Uncomment them when we
// // reintroduce shared drive controls or advanced impersonation options.
// // {
// // label: 'Shared Drive URLs',
// // name: 'config.shared_drive_urls',
// // type: FormFieldType.Text,
// // required: false,
// // placeholder:
// // 'Optional: comma-separated shared drive links if you want to include them.',
// // },
// // {
// // label: 'Specific User Emails',
// // name: 'config.specific_user_emails',
// // type: FormFieldType.Text,
// // required: false,
// // placeholder:
// // 'Optional: comma-separated list of users to impersonate (overrides defaults).',
// // },
// // {
// // label: 'Include My Drive',
// // name: 'config.include_my_drives',
// // type: FormFieldType.Checkbox,
// // required: false,
// // defaultValue: true,
// // },
// // {
// // label: 'Include Shared Drives',
// // name: 'config.include_shared_drives',
// // type: FormFieldType.Checkbox,
// // required: false,
// // defaultValue: false,
// // },
// // {
// // label: 'Include “Shared with me”',
// // name: 'config.include_files_shared_with_me',
// // type: FormFieldType.Checkbox,
// // required: false,
// // defaultValue: false,
// // },
// // {
// // label: 'Allow Images',
// // name: 'config.allow_images',
// // type: FormFieldType.Checkbox,
// // required: false,
// // defaultValue: false,
// // },
// {
// label: '',
// name: 'config.credentials.authentication_method',
// type: FormFieldType.Text,
// required: false,
// hidden: true,
// defaultValue: 'uploaded',
// },
// ],
};
export const DataSourceFormDefaultValues = {

View file

@ -67,11 +67,11 @@ const SourceDetailPage = () => {
<div className="flex items-center gap-1 w-full relative">
<Input {...fieldProps} type={FormFieldType.Number} />
<span className="absolute right-0 -translate-x-[58px] text-text-secondary italic ">
minutes
{t('setting.minutes')}
</span>
<button
type="button"
className="text-text-secondary bg-bg-input rounded-sm text-xs h-full p-2 border border-border-button "
className="text-text-secondary bg-bg-input rounded-sm text-xs h-full p-2 border border-border-button hover:bg-border-button hover:text-text-primary"
onClick={() => {
runSchedule();
}}
@ -112,7 +112,7 @@ const SourceDetailPage = () => {
<div className="flex items-center gap-1 w-full relative">
<Input {...fieldProps} type={FormFieldType.Number} />
<span className="absolute right-0 -translate-x-6 text-text-secondary italic ">
seconds
{t('setting.seconds')}
</span>
</div>
),

View file

@ -150,7 +150,7 @@ export function EditMcpDialog({
</Button>
}
>
<div className="overflow-auto max-h-80 divide-y bg-bg-card rounded-md px-2.5">
<div className="overflow-auto max-h-80 divide-y-[0.5px] divide-border-button bg-bg-card rounded-md px-2.5 scrollbar-auto">
{nextTools?.map((x) => (
<McpToolCard key={x.name} data={x}></McpToolCard>
))}

View file

@ -86,7 +86,7 @@ export default function McpServer() {
{t('mcp.import')}
</Button>
<Button onClick={showEditModal('')}>
<Plus className="size-3.5" /> {t('mcp.addMCP')}
<Plus className="size-3.5 font-medium" /> {t('mcp.addMCP')}
</Button>
</div>
</section>

View file

@ -129,7 +129,9 @@ export const AvailableModels: FC<{
<div className="flex items-center space-x-3 mb-3">
<LlmIcon name={model.name} imgClass="h-8 w-8 text-text-primary" />
<div className="flex-1">
<h3 className="font-medium truncate">{model.name}</h3>
<div className="font-normal text-base truncate">
{model.name}
</div>
</div>
<Button className=" px-2 items-center gap-0 text-xs h-6 rounded-md transition-colors hidden group-hover:flex">
<Plus size={12} />

View file

@ -19,7 +19,7 @@ import { TenantRole } from '../constants';
import { useHandleDeleteUser } from './hooks';
const ColorMap: Record<string, string> = {
[TenantRole.Normal]: 'bg-transparent text-text-primary',
[TenantRole.Normal]: 'bg-transparent text-white',
[TenantRole.Invite]: 'bg-accent-primary-5 bg-accent-primary rounded-sm',
[TenantRole.Owner]: 'bg-red-100 text-red-800',
};
@ -137,7 +137,7 @@ const UserTable = ({ searchUser }: { searchUser: string }) => {
<Button
variant="ghost"
size="icon"
className="h-8 w-8 p-0"
className="h-8 w-8 p-0 hover:bg-state-error-5 hover:text-state-error"
onClick={handleDeleteTenantUser(record.user_id)}
>
<Trash2 className="h-4 w-4" />

View file

@ -100,8 +100,8 @@ export function SideBar() {
<ThemeToggle />
</div>
<Button
variant="outline"
className="w-full gap-3 !bg-bg-base border !border-border-button !text-text-secondary"
variant="ghost"
className="w-full gap-3 bg-bg-base border border-border-button"
onClick={() => {
logout();
}}