Compare commits

...

11 Commits

Author SHA1 Message Date
a95f22fa88 Feat: output intinity test log (#12097)
### What problem does this PR solve?

Output log to file when run infinity tests.

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
2025-12-22 21:33:08 +08:00
38ac6a7c27 feat: add image context window in dataset config (#12094)
### What problem does this PR solve?

Add image context window configuration in **Dataset** >
**Configduration** and **Dataset** > **Files** > **Parse** > **Ingestion
Pipeline** (**Chunk Method** modal)

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
2025-12-22 19:51:23 +08:00
e5f3d5ae26 Refactor add_llm and add speech to text (#12089)
### What problem does this PR solve?

1. Refactor implementation of add_llm
2. Add speech to text model.

### Type of change

- [x] Refactoring

Signed-off-by: Jin Hai <haijin.chn@gmail.com>
2025-12-22 19:27:26 +08:00
4cbc91f2fa Feat: optimize aws s3 connector (#12078)
### What problem does this PR solve?

Feat: optimize aws s3 connector #12008 

### Type of change

- [x] New Feature (non-breaking change which adds functionality)

---------

Co-authored-by: Kevin Hu <kevinhu.sh@gmail.com>
2025-12-22 19:06:01 +08:00
6d3d3a40ab fix: hide drop-zone upload button when picked an image (#12088)
### What problem does this PR solve?

Hide drop-zone upload button when picked an image in chunk editor dialog

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
2025-12-22 19:04:44 +08:00
51b12841d6 Feature/1217 (#12087)
### What problem does this PR solve?

feature: Complete metadata functionality

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
2025-12-22 17:35:12 +08:00
993bf7c2c8 Fix IDE warnings (#12085)
### What problem does this PR solve?

As title

### Type of change

- [x] Refactoring

Signed-off-by: Jin Hai <haijin.chn@gmail.com>
2025-12-22 16:47:21 +08:00
b42b5fcf65 feat: display chunk type in chunk editor and dialog (#12086)
### What problem does this PR solve?

Display chunk type in chunk editor and dialog, may be one of below:
- Image
- Table
- Text

### Type of change

- [x] New Feature (non-breaking change which adds functionality)
2025-12-22 16:45:47 +08:00
5d391fb1f9 fix: guard Dashscope response attribute access in token/log utils (#12082)
### What problem does this PR solve?

Guard Dashscope response attribute access in token/log utils, since
`dashscope_response` returns dict like object.

### Type of change

- [x] Bug Fix (non-breaking change which fixes an issue)
2025-12-22 16:17:58 +08:00
2ddfcc7cf6 Images that appear consecutively in the dialogue are displayed using a carousel. #12076 (#12077)
### What problem does this PR solve?

Images that appear consecutively in the dialogue are displayed using a
carousel. #12076

### Type of change


- [x] New Feature (non-breaking change which adds functionality)
2025-12-22 14:41:02 +08:00
5ba51b21c9 Feat: When the webhook returns a field in streaming format, the message displays the status field. #10427 (#12075)
### What problem does this PR solve?

Feat: When the webhook returns a field in streaming format, the message
displays the status field. #10427

### Type of change


- [x] New Feature (non-breaking change which adds functionality)

Co-authored-by: balibabu <assassin_cike@163.com>
2025-12-22 14:37:39 +08:00
47 changed files with 1315 additions and 850 deletions

View File

@ -247,7 +247,7 @@ jobs:
echo "Waiting for service to be available..."
sleep 5
done
source .venv/bin/activate && DOC_ENGINE=infinity pytest -s --tb=short --level=${HTTP_API_TEST_LEVEL} test/testcases/test_sdk_api
source .venv/bin/activate && DOC_ENGINE=infinity pytest -s --tb=short --level=${HTTP_API_TEST_LEVEL} test/testcases/test_sdk_api > infinity_sdk_test.log
- name: Run frontend api tests against Infinity
run: |
@ -256,7 +256,7 @@ jobs:
echo "Waiting for service to be available..."
sleep 5
done
source .venv/bin/activate && DOC_ENGINE=infinity pytest -s --tb=short sdk/python/test/test_frontend_api/get_email.py sdk/python/test/test_frontend_api/test_dataset.py
source .venv/bin/activate && DOC_ENGINE=infinity pytest -s --tb=short sdk/python/test/test_frontend_api/get_email.py sdk/python/test/test_frontend_api/test_dataset.py > infinity_api_test.log
- name: Run http api tests against Infinity
run: |
@ -265,7 +265,7 @@ jobs:
echo "Waiting for service to be available..."
sleep 5
done
source .venv/bin/activate && DOC_ENGINE=infinity pytest -s --tb=short --level=${HTTP_API_TEST_LEVEL} test/testcases/test_http_api
source .venv/bin/activate && DOC_ENGINE=infinity pytest -s --tb=short --level=${HTTP_API_TEST_LEVEL} test/testcases/test_http_api > infinity_http_api_test.log
- name: Stop ragflow:nightly
if: always() # always run this step even if previous steps failed

View File

@ -108,7 +108,7 @@ def _load_user():
authorization = request.headers.get("Authorization")
g.user = None
if not authorization:
return
return None
try:
access_token = str(jwt.loads(authorization))

View File

@ -25,7 +25,7 @@ from api.utils.api_utils import get_allowed_llm_factories, get_data_error_result
from common.constants import StatusEnum, LLMType
from api.db.db_models import TenantLLM
from rag.utils.base64_image import test_image
from rag.llm import EmbeddingModel, ChatModel, RerankModel, CvModel, TTSModel, OcrModel
from rag.llm import EmbeddingModel, ChatModel, RerankModel, CvModel, TTSModel, OcrModel, Seq2txtModel
@manager.route("/factories", methods=["GET"]) # noqa: F821
@ -208,70 +208,83 @@ async def add_llm():
msg = ""
mdl_nm = llm["llm_name"].split("___")[0]
extra = {"provider": factory}
if llm["model_type"] == LLMType.EMBEDDING.value:
assert factory in EmbeddingModel, f"Embedding model from {factory} is not supported yet."
mdl = EmbeddingModel[factory](key=llm["api_key"], model_name=mdl_nm, base_url=llm["api_base"])
try:
arr, tc = mdl.encode(["Test if the api key is available"])
if len(arr[0]) == 0:
raise Exception("Fail")
except Exception as e:
msg += f"\nFail to access embedding model({mdl_nm})." + str(e)
elif llm["model_type"] == LLMType.CHAT.value:
assert factory in ChatModel, f"Chat model from {factory} is not supported yet."
mdl = ChatModel[factory](
key=llm["api_key"],
model_name=mdl_nm,
base_url=llm["api_base"],
**extra,
)
try:
m, tc = await mdl.async_chat(None, [{"role": "user", "content": "Hello! How are you doing!"}], {"temperature": 0.9})
if not tc and m.find("**ERROR**:") >= 0:
raise Exception(m)
except Exception as e:
msg += f"\nFail to access model({factory}/{mdl_nm})." + str(e)
elif llm["model_type"] == LLMType.RERANK:
assert factory in RerankModel, f"RE-rank model from {factory} is not supported yet."
try:
mdl = RerankModel[factory](key=llm["api_key"], model_name=mdl_nm, base_url=llm["api_base"])
arr, tc = mdl.similarity("Hello~ RAGFlower!", ["Hi, there!", "Ohh, my friend!"])
if len(arr) == 0:
raise Exception("Not known.")
except KeyError:
msg += f"{factory} dose not support this model({factory}/{mdl_nm})"
except Exception as e:
msg += f"\nFail to access model({factory}/{mdl_nm})." + str(e)
elif llm["model_type"] == LLMType.IMAGE2TEXT.value:
assert factory in CvModel, f"Image to text model from {factory} is not supported yet."
mdl = CvModel[factory](key=llm["api_key"], model_name=mdl_nm, base_url=llm["api_base"])
try:
image_data = test_image
m, tc = mdl.describe(image_data)
if not tc and m.find("**ERROR**:") >= 0:
raise Exception(m)
except Exception as e:
msg += f"\nFail to access model({factory}/{mdl_nm})." + str(e)
elif llm["model_type"] == LLMType.TTS:
assert factory in TTSModel, f"TTS model from {factory} is not supported yet."
mdl = TTSModel[factory](key=llm["api_key"], model_name=mdl_nm, base_url=llm["api_base"])
try:
for resp in mdl.tts("Hello~ RAGFlower!"):
pass
except RuntimeError as e:
msg += f"\nFail to access model({factory}/{mdl_nm})." + str(e)
elif llm["model_type"] == LLMType.OCR.value:
assert factory in OcrModel, f"OCR model from {factory} is not supported yet."
try:
mdl = OcrModel[factory](key=llm["api_key"], model_name=mdl_nm, base_url=llm.get("api_base", ""))
ok, reason = mdl.check_available()
if not ok:
raise RuntimeError(reason or "Model not available")
except Exception as e:
msg += f"\nFail to access model({factory}/{mdl_nm})." + str(e)
else:
# TODO: check other type of models
pass
model_type = llm["model_type"]
model_api_key = llm["api_key"]
model_base_url = llm.get("api_base", "")
match model_type:
case LLMType.EMBEDDING.value:
assert factory in EmbeddingModel, f"Embedding model from {factory} is not supported yet."
mdl = EmbeddingModel[factory](key=model_api_key, model_name=mdl_nm, base_url=model_base_url)
try:
arr, tc = mdl.encode(["Test if the api key is available"])
if len(arr[0]) == 0:
raise Exception("Fail")
except Exception as e:
msg += f"\nFail to access embedding model({mdl_nm})." + str(e)
case LLMType.CHAT.value:
assert factory in ChatModel, f"Chat model from {factory} is not supported yet."
mdl = ChatModel[factory](
key=model_api_key,
model_name=mdl_nm,
base_url=model_base_url,
**extra,
)
try:
m, tc = await mdl.async_chat(None, [{"role": "user", "content": "Hello! How are you doing!"}],
{"temperature": 0.9})
if not tc and m.find("**ERROR**:") >= 0:
raise Exception(m)
except Exception as e:
msg += f"\nFail to access model({factory}/{mdl_nm})." + str(e)
case LLMType.RERANK.value:
assert factory in RerankModel, f"RE-rank model from {factory} is not supported yet."
try:
mdl = RerankModel[factory](key=model_api_key, model_name=mdl_nm, base_url=model_base_url)
arr, tc = mdl.similarity("Hello~ RAGFlower!", ["Hi, there!", "Ohh, my friend!"])
if len(arr) == 0:
raise Exception("Not known.")
except KeyError:
msg += f"{factory} dose not support this model({factory}/{mdl_nm})"
except Exception as e:
msg += f"\nFail to access model({factory}/{mdl_nm})." + str(e)
case LLMType.IMAGE2TEXT.value:
assert factory in CvModel, f"Image to text model from {factory} is not supported yet."
mdl = CvModel[factory](key=model_api_key, model_name=mdl_nm, base_url=model_base_url)
try:
image_data = test_image
m, tc = mdl.describe(image_data)
if not tc and m.find("**ERROR**:") >= 0:
raise Exception(m)
except Exception as e:
msg += f"\nFail to access model({factory}/{mdl_nm})." + str(e)
case LLMType.TTS.value:
assert factory in TTSModel, f"TTS model from {factory} is not supported yet."
mdl = TTSModel[factory](key=model_api_key, model_name=mdl_nm, base_url=model_base_url)
try:
for resp in mdl.tts("Hello~ RAGFlower!"):
pass
except RuntimeError as e:
msg += f"\nFail to access model({factory}/{mdl_nm})." + str(e)
case LLMType.OCR.value:
assert factory in OcrModel, f"OCR model from {factory} is not supported yet."
try:
mdl = OcrModel[factory](key=model_api_key, model_name=mdl_nm, base_url=model_base_url)
ok, reason = mdl.check_available()
if not ok:
raise RuntimeError(reason or "Model not available")
except Exception as e:
msg += f"\nFail to access model({factory}/{mdl_nm})." + str(e)
case LLMType.SPEECH2TEXT:
assert factory in Seq2txtModel, f"Speech model from {factory} is not supported yet."
try:
mdl = Seq2txtModel[factory](key=model_api_key, model_name=mdl_nm, base_url=model_base_url)
# TODO: check the availability
except Exception as e:
msg += f"\nFail to access model({factory}/{mdl_nm})." + str(e)
case _:
raise RuntimeError(f"Unknown model type: {model_type}")
if msg:
return get_data_error_result(message=msg)

View File

@ -326,7 +326,6 @@ async def list_tools() -> Response:
try:
tools = await asyncio.to_thread(tool_call_session.get_tools, timeout)
except Exception as e:
tools = []
return get_data_error_result(message=f"MCP list tools error: {e}")
results[server_key] = []
@ -428,7 +427,6 @@ async def test_mcp() -> Response:
try:
tools = await asyncio.to_thread(tool_call_session.get_tools, timeout)
except Exception as e:
tools = []
return get_data_error_result(message=f"Test MCP error: {e}")
finally:
# PERF: blocking call to close sessions — consider moving to background thread or task queue

View File

@ -287,7 +287,7 @@ def list_chat(tenant_id):
chats = DialogService.get_list(tenant_id, page_number, items_per_page, orderby, desc, id, name)
if not chats:
return get_result(data=[])
list_assts = []
list_assistants = []
key_mapping = {
"parameters": "variables",
"prologue": "opener",
@ -321,5 +321,5 @@ def list_chat(tenant_id):
del res["kb_ids"]
res["datasets"] = kb_list
res["avatar"] = res.pop("icon")
list_assts.append(res)
return get_result(data=list_assts)
list_assistants.append(res)
return get_result(data=list_assistants)

View File

@ -205,7 +205,8 @@ async def create(tenant_id):
if not FileService.is_parent_folder_exist(pf_id):
return get_json_result(data=False, message="Parent Folder Doesn't Exist!", code=RetCode.BAD_REQUEST)
if FileService.query(name=req["name"], parent_id=pf_id):
return get_json_result(data=False, message="Duplicated folder name in the same folder.", code=409)
return get_json_result(data=False, message="Duplicated folder name in the same folder.",
code=RetCode.CONFLICT)
if input_file_type == FileType.FOLDER.value:
file_type = FileType.FOLDER.value
@ -565,11 +566,13 @@ async def rename(tenant_id):
if file.type != FileType.FOLDER.value and pathlib.Path(req["name"].lower()).suffix != pathlib.Path(
file.name.lower()).suffix:
return get_json_result(data=False, message="The extension of file can't be changed", code=RetCode.BAD_REQUEST)
return get_json_result(data=False, message="The extension of file can't be changed",
code=RetCode.BAD_REQUEST)
for existing_file in FileService.query(name=req["name"], pf_id=file.parent_id):
if existing_file.name == req["name"]:
return get_json_result(data=False, message="Duplicated file name in the same folder.", code=409)
return get_json_result(data=False, message="Duplicated file name in the same folder.",
code=RetCode.CONFLICT)
if not FileService.update_by_id(req["file_id"], {"name": req["name"]}):
return get_json_result(message="Database error (File rename)!", code=RetCode.SERVER_ERROR)
@ -631,9 +634,10 @@ async def get(tenant_id, file_id):
except Exception as e:
return server_error_response(e)
@manager.route("/file/download/<attachment_id>", methods=["GET"]) # noqa: F821
@token_required
async def download_attachment(tenant_id,attachment_id):
async def download_attachment(tenant_id, attachment_id):
try:
ext = request.args.get("ext", "markdown")
data = await asyncio.to_thread(settings.STORAGE_IMPL.get, tenant_id, attachment_id)
@ -645,6 +649,7 @@ async def download_attachment(tenant_id,attachment_id):
except Exception as e:
return server_error_response(e)
@manager.route('/file/mv', methods=['POST']) # noqa: F821
@token_required
async def move(tenant_id):

View File

@ -448,7 +448,7 @@ async def chat_completion_openai_like(tenant_id, chat_id):
@token_required
async def agents_completion_openai_compatibility(tenant_id, agent_id):
req = await get_request_json()
tiktokenenc = tiktoken.get_encoding("cl100k_base")
tiktoken_encode = tiktoken.get_encoding("cl100k_base")
messages = req.get("messages", [])
if not messages:
return get_error_data_result("You must provide at least one message.")
@ -456,7 +456,7 @@ async def agents_completion_openai_compatibility(tenant_id, agent_id):
return get_error_data_result(f"You don't own the agent {agent_id}")
filtered_messages = [m for m in messages if m["role"] in ["user", "assistant"]]
prompt_tokens = sum(len(tiktokenenc.encode(m["content"])) for m in filtered_messages)
prompt_tokens = sum(len(tiktoken_encode.encode(m["content"])) for m in filtered_messages)
if not filtered_messages:
return jsonify(
get_data_openai(
@ -464,7 +464,7 @@ async def agents_completion_openai_compatibility(tenant_id, agent_id):
content="No valid messages found (user or assistant).",
finish_reason="stop",
model=req.get("model", ""),
completion_tokens=len(tiktokenenc.encode("No valid messages found (user or assistant).")),
completion_tokens=len(tiktoken_encode.encode("No valid messages found (user or assistant).")),
prompt_tokens=prompt_tokens,
)
)
@ -501,6 +501,8 @@ async def agents_completion_openai_compatibility(tenant_id, agent_id):
):
return jsonify(response)
return None
@manager.route("/agents/<agent_id>/completions", methods=["POST"]) # noqa: F821
@token_required
@ -920,6 +922,7 @@ async def chatbot_completions(dialog_id):
async for answer in iframe_completion(dialog_id, **req):
return get_result(data=answer)
return None
@manager.route("/chatbots/<dialog_id>/info", methods=["GET"]) # noqa: F821
async def chatbots_inputs(dialog_id):
@ -967,6 +970,7 @@ async def agent_bot_completions(agent_id):
async for answer in agent_completion(objs[0].tenant_id, agent_id, **req):
return get_result(data=answer)
return None
@manager.route("/agentbots/<agent_id>/inputs", methods=["GET"]) # noqa: F821
async def begin_inputs(agent_id):

View File

@ -660,7 +660,7 @@ def user_register(user_id, user):
tenant_llm = get_init_tenant_llm(user_id)
if not UserService.save(**user):
return
return None
TenantService.insert(**tenant)
UserTenantService.insert(**usr_tenant)
TenantLLMService.insert_many(tenant_llm)

View File

@ -54,6 +54,7 @@ class RetCode(IntEnum, CustomEnum):
SERVER_ERROR = 500
FORBIDDEN = 403
NOT_FOUND = 404
CONFLICT = 409
class StatusEnum(Enum):

View File

@ -64,15 +64,23 @@ class BlobStorageConnector(LoadConnector, PollConnector):
elif self.bucket_type == BlobType.S3:
authentication_method = credentials.get("authentication_method", "access_key")
if authentication_method == "access_key":
if not all(
credentials.get(key)
for key in ["aws_access_key_id", "aws_secret_access_key"]
):
raise ConnectorMissingCredentialError("Amazon S3")
elif authentication_method == "iam_role":
if not credentials.get("aws_role_arn"):
raise ConnectorMissingCredentialError("Amazon S3 IAM role ARN is required")
elif authentication_method == "assume_role":
pass
else:
raise ConnectorMissingCredentialError("Unsupported S3 authentication method")
elif self.bucket_type == BlobType.GOOGLE_CLOUD_STORAGE:
if not all(
@ -293,4 +301,4 @@ if __name__ == "__main__":
except ConnectorMissingCredentialError as e:
print(f"Error: {e}")
except Exception as e:
print(f"An unexpected error occurred: {e}")
print(f"An unexpected error occurred: {e}")

View File

@ -254,18 +254,21 @@ def create_s3_client(bucket_type: BlobType, credentials: dict[str, Any], europea
elif bucket_type == BlobType.S3:
authentication_method = credentials.get("authentication_method", "access_key")
region_name = credentials.get("region") or None
if authentication_method == "access_key":
session = boto3.Session(
aws_access_key_id=credentials["aws_access_key_id"],
aws_secret_access_key=credentials["aws_secret_access_key"],
region_name=region_name,
)
return session.client("s3")
return session.client("s3", region_name=region_name)
elif authentication_method == "iam_role":
role_arn = credentials["aws_role_arn"]
def _refresh_credentials() -> dict[str, str]:
sts_client = boto3.client("sts")
sts_client = boto3.client("sts", region_name=credentials.get("region") or None)
assumed_role_object = sts_client.assume_role(
RoleArn=role_arn,
RoleSessionName=f"onyx_blob_storage_{int(datetime.now().timestamp())}",
@ -285,11 +288,11 @@ def create_s3_client(bucket_type: BlobType, credentials: dict[str, Any], europea
)
botocore_session = get_session()
botocore_session._credentials = refreshable
session = boto3.Session(botocore_session=botocore_session)
return session.client("s3")
session = boto3.Session(botocore_session=botocore_session, region_name=region_name)
return session.client("s3", region_name=region_name)
elif authentication_method == "assume_role":
return boto3.client("s3")
return boto3.client("s3", region_name=region_name)
else:
raise ValueError("Invalid authentication method for S3.")

View File

@ -75,9 +75,12 @@ def init_root_logger(logfile_basename: str, log_format: str = "%(asctime)-15s %(
def log_exception(e, *args):
logging.exception(e)
for a in args:
if hasattr(a, "text"):
logging.error(a.text)
raise Exception(a.text)
else:
logging.error(str(a))
try:
text = getattr(a, "text")
except Exception:
text = None
if text is not None:
logging.error(text)
raise Exception(text)
logging.error(str(a))
raise e

View File

@ -44,23 +44,23 @@ def total_token_count_from_response(resp):
if resp is None:
return 0
if hasattr(resp, "usage") and hasattr(resp.usage, "total_tokens"):
try:
try:
if hasattr(resp, "usage") and hasattr(resp.usage, "total_tokens"):
return resp.usage.total_tokens
except Exception:
pass
except Exception:
pass
if hasattr(resp, "usage_metadata") and hasattr(resp.usage_metadata, "total_tokens"):
try:
try:
if hasattr(resp, "usage_metadata") and hasattr(resp.usage_metadata, "total_tokens"):
return resp.usage_metadata.total_tokens
except Exception:
pass
except Exception:
pass
if hasattr(resp, "meta") and hasattr(resp.meta, "billed_units") and hasattr(resp.meta.billed_units, "input_tokens"):
try:
return resp.meta.billed_units.input_tokens
except Exception:
pass
try:
if hasattr(resp, "meta") and hasattr(resp.meta, "billed_units") and hasattr(resp.meta.billed_units, "input_tokens"):
return resp.meta.billed_units.input_tokens
except Exception:
pass
if isinstance(resp, dict) and 'usage' in resp and 'total_tokens' in resp['usage']:
try:
@ -85,4 +85,3 @@ def total_token_count_from_response(resp):
def truncate(string: str, max_len: int) -> str:
"""Returns truncated text if the length of text exceed max_len."""
return encoder.decode(encoder.encode(string)[:max_len])

554
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -21,6 +21,7 @@ import { IChangeParserConfigRequestBody } from '@/interfaces/request/document';
import {
ChunkMethodItem,
EnableTocToggle,
ImageContextWindow,
ParseTypeItem,
} from '@/pages/dataset/dataset-setting/configuration/common-item';
import { zodResolver } from '@hookform/resolvers/zod';
@ -119,6 +120,7 @@ export function ChunkMethodDialog({
auto_questions: z.coerce.number().optional(),
html4excel: z.boolean().optional(),
toc_extraction: z.boolean().optional(),
image_context_window: z.coerce.number().optional(),
mineru_parse_method: z.enum(['auto', 'txt', 'ocr']).optional(),
mineru_formula_enable: z.boolean().optional(),
mineru_table_enable: z.boolean().optional(),
@ -364,7 +366,10 @@ export function ChunkMethodDialog({
className="space-y-3"
>
{selectedTag === DocumentParserType.Naive && (
<EnableTocToggle />
<>
<EnableTocToggle />
<ImageContextWindow />
</>
)}
{showAutoKeywords(selectedTag) && (
<>

View File

@ -18,6 +18,7 @@ export function useDefaultParserValues() {
auto_questions: 0,
html4excel: false,
toc_extraction: false,
image_context_window: 0,
mineru_parse_method: 'auto',
mineru_formula_enable: true,
mineru_table_enable: true,

View File

@ -145,6 +145,8 @@ interface FileUploaderProps
*/
maxFileCount?: DropzoneProps['maxFiles'];
hideDropzoneOnMaxFileCount?: boolean;
/**
* Whether the uploader should accept multiple files.
* @type boolean
@ -178,6 +180,7 @@ export function FileUploader(props: FileUploaderProps) {
maxFileCount = 100000000000,
multiple = false,
disabled = false,
hideDropzoneOnMaxFileCount = false,
className,
title,
description,
@ -189,6 +192,8 @@ export function FileUploader(props: FileUploaderProps) {
onChange: onValueChange,
});
const reachesMaxFileCount = (files?.length ?? 0) >= maxFileCount;
const onDrop = React.useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
if (!multiple && maxFileCount === 1 && acceptedFiles.length > 1) {
@ -263,65 +268,68 @@ export function FileUploader(props: FileUploaderProps) {
return (
<div className="relative flex flex-col gap-6 overflow-hidden">
<Dropzone
onDrop={onDrop}
accept={accept}
maxSize={maxSize}
maxFiles={maxFileCount}
multiple={maxFileCount > 1 || multiple}
disabled={isDisabled}
>
{({ getRootProps, getInputProps, isDragActive }) => (
<div
{...getRootProps()}
className={cn(
'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-border-button',
isDisabled && 'pointer-events-none opacity-60',
className,
)}
{...dropzoneProps}
>
<input {...getInputProps()} />
{isDragActive ? (
<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-text-secondary transition-colors group-hover:text-text-primary"
aria-hidden="true"
/>
</div>
<p className="font-medium text-text-secondary">
Drop the files here
</p>
</div>
) : (
<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-text-secondary transition-colors group-hover:text-text-primary"
aria-hidden="true"
/>
</div>
<div className="flex flex-col gap-px">
{!(hideDropzoneOnMaxFileCount && reachesMaxFileCount) && (
<Dropzone
onDrop={onDrop}
accept={accept}
maxSize={maxSize}
maxFiles={maxFileCount}
multiple={maxFileCount > 1 || multiple}
disabled={isDisabled}
>
{({ getRootProps, getInputProps, isDragActive }) => (
<div
{...getRootProps()}
className={cn(
'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-border-button',
isDisabled && 'pointer-events-none opacity-60',
className,
)}
{...dropzoneProps}
>
<input {...getInputProps()} />
{isDragActive ? (
<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-text-secondary transition-colors group-hover:text-text-primary"
aria-hidden="true"
/>
</div>
<p className="font-medium text-text-secondary">
{title || t('knowledgeDetails.uploadTitle')}
</p>
<p className="text-sm text-text-disabled">
{description || t('knowledgeDetails.uploadDescription')}
{/* You can upload
{maxFileCount > 1
? ` ${maxFileCount === Infinity ? 'multiple' : maxFileCount}
files (up to ${formatBytes(maxSize)} each)`
: ` a file with ${formatBytes(maxSize)}`} */}
Drop the files here
</p>
</div>
</div>
)}
</div>
)}
</Dropzone>
) : (
<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-text-secondary transition-colors group-hover:text-text-primary"
aria-hidden="true"
/>
</div>
<div className="flex flex-col gap-px">
<p className="font-medium text-text-secondary">
{title || t('knowledgeDetails.uploadTitle')}
</p>
<p className="text-sm text-text-disabled">
{description || t('knowledgeDetails.uploadDescription')}
{/* You can upload
{maxFileCount > 1
? ` ${maxFileCount === Infinity ? 'multiple' : maxFileCount}
files (up to ${formatBytes(maxSize)} each)`
: ` a file with ${formatBytes(maxSize)}`} */}
</p>
</div>
</div>
)}
</div>
)}
</Dropzone>
)}
{files?.length ? (
<div className="h-fit w-full px-3">
<div className="flex max-h-48 flex-col gap-4 overflow-auto scrollbar-auto">

View File

@ -1,11 +1,10 @@
import { api_host } from '@/utils/api';
import classNames from 'classnames';
import React from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
interface IImage {
interface IImage extends React.ImgHTMLAttributes<HTMLImageElement> {
id: string;
className?: string;
onClick?(): void;
t?: string | number;
}

View File

@ -0,0 +1,170 @@
import { Checkbox } from '@/components/ui/checkbox';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { memo, useState } from 'react';
import { ControllerRenderProps, useFormContext } from 'react-hook-form';
import { FormControl, FormField, FormItem, FormLabel } from '../ui/form';
import { FilterType } from './interface';
const handleCheckChange = ({
checked,
field,
item,
isNestedField = false,
parentId = '',
}: {
checked: boolean;
field: ControllerRenderProps<
{ [x: string]: { [x: string]: any } | string[] },
string
>;
item: FilterType;
isNestedField?: boolean;
parentId?: string;
}) => {
if (isNestedField && parentId) {
const currentValue = field.value || {};
const currentParentValues =
(currentValue as Record<string, string[]>)[parentId] || [];
const newParentValues = checked
? [...currentParentValues, item.id.toString()]
: currentParentValues.filter(
(value: string) => value !== item.id.toString(),
);
const newValue = {
...currentValue,
[parentId]: newParentValues,
};
if (newValue[parentId].length === 0) {
delete newValue[parentId];
}
return field.onChange(newValue);
} else {
const list = checked
? [...(Array.isArray(field.value) ? field.value : []), item.id.toString()]
: (Array.isArray(field.value) ? field.value : []).filter(
(value) => value !== item.id.toString(),
);
return field.onChange(list);
}
};
const FilterItem = memo(
({
item,
field,
level = 0,
}: {
item: FilterType;
field: ControllerRenderProps<
{ [x: string]: { [x: string]: any } | string[] },
string
>;
level: number;
}) => {
return (
<div
className={`flex items-center justify-between text-text-primary text-xs ${level > 0 ? 'ml-4' : ''}`}
>
<FormItem className="flex flex-row space-x-3 space-y-0 items-center">
<FormControl>
<Checkbox
checked={field.value?.includes(item.id.toString())}
onCheckedChange={(checked: boolean) =>
handleCheckChange({ checked, field, item })
}
/>
</FormControl>
<FormLabel onClick={(e) => e.stopPropagation()}>
{item.label}
</FormLabel>
</FormItem>
{item.count !== undefined && (
<span className="text-sm">{item.count}</span>
)}
</div>
);
},
);
export const FilterField = memo(
({
item,
parent,
level = 0,
}: {
item: FilterType;
parent: FilterType;
level?: number;
}) => {
const form = useFormContext();
const [showAll, setShowAll] = useState(false);
const hasNestedList = item.list && item.list.length > 0;
return (
<FormField
key={item.id}
control={form.control}
name={parent.field as string}
render={({ field }) => {
if (hasNestedList) {
return (
<div className={`flex flex-col gap-2 ${level > 0 ? 'ml-4' : ''}`}>
<div
className="flex items-center justify-between cursor-pointer"
onClick={() => {
setShowAll(!showAll);
}}
>
<FormLabel className="text-text-primary">
{item.label}
</FormLabel>
{showAll ? (
<ChevronUp size={12} />
) : (
<ChevronDown size={12} />
)}
</div>
{showAll &&
item.list?.map((child) => (
<FilterField
key={child.id}
item={child}
parent={{
...item,
field: `${parent.field}.${item.field}`,
}}
level={level + 1}
/>
// <FilterItem key={child.id} item={child} field={child.field} level={level+1} />
// <div
// className="flex flex-row space-x-3 space-y-0 items-center"
// key={child.id}
// >
// <FormControl>
// <Checkbox
// checked={field.value?.includes(child.id.toString())}
// onCheckedChange={(checked) =>
// handleCheckChange({ checked, field, item: child })
// }
// />
// </FormControl>
// <FormLabel onClick={(e) => e.stopPropagation()}>
// {child.label}
// </FormLabel>
// </div>
))}
</div>
);
}
return <FilterItem item={item} field={field} level={level} />;
}}
/>
);
},
);
FilterField.displayName = 'FilterField';

View File

@ -4,21 +4,27 @@ import {
PopoverTrigger,
} from '@/components/ui/popover';
import { zodResolver } from '@hookform/resolvers/zod';
import { PropsWithChildren, useCallback, useEffect, useState } from 'react';
import {
PropsWithChildren,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useForm } from 'react-hook-form';
import { ZodArray, ZodString, z } from 'zod';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { t } from 'i18next';
import { FilterField } from './filter-field';
import { FilterChange, FilterCollection, FilterValue } from './interface';
export type CheckboxFormMultipleProps = {
@ -35,29 +41,71 @@ function CheckboxFormMultiple({
onChange,
setOpen,
}: CheckboxFormMultipleProps) {
const fieldsDict = filters?.reduce<Record<string, Array<any>>>((pre, cur) => {
pre[cur.field] = [];
return pre;
}, {});
const [resolvedFilters, setResolvedFilters] =
useState<FilterCollection[]>(filters);
const FormSchema = z.object(
filters.reduce<Record<string, ZodArray<ZodString, 'many'>>>((pre, cur) => {
pre[cur.field] = z.array(z.string());
useEffect(() => {
if (filters && filters.length > 0) {
setResolvedFilters(filters);
}
}, [filters]);
// .refine((value) => value.some((item) => item), {
// message: 'You have to select at least one item.',
// });
const fieldsDict = useMemo(() => {
if (resolvedFilters.length === 0) {
return {};
}
return resolvedFilters.reduce<Record<string, any>>((pre, cur) => {
const hasNested = cur.list?.some(
(item) => item.list && item.list.length > 0,
);
if (hasNested) {
pre[cur.field] = {};
} else {
pre[cur.field] = [];
}
return pre;
}, {}),
);
}, {});
}, [resolvedFilters]);
const FormSchema = useMemo(() => {
if (resolvedFilters.length === 0) {
return z.object({});
}
return z.object(
resolvedFilters.reduce<
Record<
string,
ZodArray<ZodString, 'many'> | z.ZodObject<any> | z.ZodOptional<any>
>
>((pre, cur) => {
const hasNested = cur.list?.some(
(item) => item.list && item.list.length > 0,
);
if (hasNested) {
pre[cur.field] = z
.record(z.string(), z.array(z.string().optional()).optional())
.optional();
} else {
pre[cur.field] = z.array(z.string().optional()).optional();
}
return pre;
}, {}),
);
}, [resolvedFilters]);
const form = useForm<z.infer<typeof FormSchema>>({
resolver: zodResolver(FormSchema),
resolver: resolvedFilters.length > 0 ? zodResolver(FormSchema) : undefined,
defaultValues: fieldsDict,
});
function onSubmit(data: z.infer<typeof FormSchema>) {
onChange?.(data);
function onSubmit() {
const formValues = form.getValues();
onChange?.({ ...formValues });
setOpen(false);
}
@ -67,8 +115,10 @@ function CheckboxFormMultiple({
}, [fieldsDict, onChange, setOpen]);
useEffect(() => {
form.reset(value);
}, [form, value]);
if (resolvedFilters.length > 0) {
form.reset(value || fieldsDict);
}
}, [form, value, resolvedFilters, fieldsDict]);
return (
<Form {...form}>
@ -85,44 +135,21 @@ function CheckboxFormMultiple({
render={() => (
<FormItem className="space-y-4">
<div>
<FormLabel className="text-base text-text-sub-title-invert">
{x.label}
</FormLabel>
<FormLabel className="text-text-primary">{x.label}</FormLabel>
</div>
{x.list.map((item) => (
<FormField
key={item.id}
control={form.control}
name={x.field}
render={({ field }) => {
return (
<div className="flex items-center justify-between text-text-primary text-xs">
<FormItem
key={item.id}
className="flex flex-row space-x-3 space-y-0 items-center "
>
<FormControl>
<Checkbox
checked={field.value?.includes(item.id)}
onCheckedChange={(checked) => {
return checked
? field.onChange([...field.value, item.id])
: field.onChange(
field.value?.filter(
(value) => value !== item.id,
),
);
}}
/>
</FormControl>
<FormLabel>{item.label}</FormLabel>
</FormItem>
<span className=" text-sm">{item.count}</span>
</div>
);
}}
/>
))}
{x.list.map((item) => {
return (
<FilterField
key={item.id}
item={{ ...item }}
parent={{
...x,
id: x.field,
// field: `${x.field}${item.field ? '.' + item.field : ''}`,
}}
/>
);
})}
<FormMessage />
</FormItem>
)}
@ -137,7 +164,13 @@ function CheckboxFormMultiple({
>
{t('common.clear')}
</Button>
<Button type="submit" size={'sm'}>
<Button
type="submit"
onClick={() => {
console.log(form.formState.errors, form.getValues());
}}
size={'sm'}
>
{t('common.submit')}
</Button>
</div>

View File

@ -17,6 +17,7 @@ interface IProps {
onSearchChange?: ChangeEventHandler<HTMLInputElement>;
showFilter?: boolean;
leftPanel?: ReactNode;
preChildren?: ReactNode;
}
export const FilterButton = React.forwardRef<
@ -46,6 +47,7 @@ export const FilterButton = React.forwardRef<
export default function ListFilterBar({
title,
children,
preChildren,
searchString,
onSearchChange,
showFilter = true,
@ -63,7 +65,18 @@ export default function ListFilterBar({
const filterCount = useMemo(() => {
return typeof value === 'object' && value !== null
? Object.values(value).reduce((pre, cur) => {
return pre + cur.length;
if (Array.isArray(cur)) {
return pre + cur.length;
}
if (typeof cur === 'object') {
return (
pre +
Object.values(cur).reduce((pre, cur) => {
return pre + cur.length;
}, 0)
);
}
return pre;
}, 0)
: 0;
}, [value]);
@ -80,6 +93,7 @@ export default function ListFilterBar({
{leftPanel || title}
</div>
<div className="flex gap-5 items-center">
{preChildren}
{showFilter && (
<FilterPopover
value={value}

View File

@ -1,6 +1,9 @@
export type FilterType = {
id: string;
field?: string;
label: string | JSX.Element;
list?: FilterType[];
value?: string | string[];
count?: number;
};
@ -10,6 +13,9 @@ export type FilterCollection = {
list: FilterType[];
};
export type FilterValue = Record<string, Array<string>>;
export type FilterValue = Record<
string,
Array<string> | Record<string, Array<string>>
>;
export type FilterChange = (value: FilterValue) => void;

View File

@ -5,7 +5,6 @@ import { getExtension } from '@/utils/document-util';
import DOMPurify from 'dompurify';
import { memo, useCallback, useEffect, useMemo } from 'react';
import Markdown from 'react-markdown';
import reactStringReplace from 'react-string-replace';
import SyntaxHighlighter from 'react-syntax-highlighter';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
@ -18,7 +17,6 @@ import { useTranslation } from 'react-i18next';
import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you
import {
currentReg,
preprocessLaTeX,
replaceTextByOldReg,
replaceThinkToSection,
@ -31,6 +29,11 @@ import classNames from 'classnames';
import { omit } from 'lodash';
import { pipe } from 'lodash/fp';
import { CircleAlert } from 'lucide-react';
import { ImageCarousel } from '../markdown-content/image-carousel';
import {
groupConsecutiveReferences,
shouldShowCarousel,
} from '../markdown-content/reference-utils';
import { Button } from '../ui/button';
import {
HoverCard,
@ -39,6 +42,19 @@ import {
} from '../ui/hover-card';
import styles from './index.less';
// Helper function to convert IReferenceObject to IReference
const convertReferenceObjectToReference = (
referenceObject: IReferenceObject,
) => {
const chunks = Object.values(referenceObject.chunks);
const docAggs = Object.values(referenceObject.doc_aggs);
return {
chunks,
doc_aggs: docAggs,
total: chunks.length,
};
};
const getChunkIndex = (match: string) => Number(match);
// TODO: The display of the table is inconsistent with the display previously placed in the MessageItem.
function MarkdownContent({
@ -211,47 +227,95 @@ function MarkdownContent({
const renderReference = useCallback(
(text: string) => {
let replacedText = reactStringReplace(text, currentReg, (match, i) => {
const chunkIndex = getChunkIndex(match);
const groups = groupConsecutiveReferences(text);
const elements = [];
let lastIndex = 0;
const { documentUrl, fileExtension, imageId, chunkItem, documentId } =
getReferenceInfo(chunkIndex);
const convertedReference = reference
? convertReferenceObjectToReference(reference)
: null;
const docType = chunkItem?.doc_type;
groups.forEach((group, groupIndex) => {
if (group[0].start > lastIndex) {
elements.push(text.substring(lastIndex, group[0].start));
}
return showImage(docType) ? (
<section>
<Image
id={imageId}
className={styles.referenceInnerChunkImage}
onClick={
documentId
? handleDocumentButtonClick(
documentId,
chunkItem,
fileExtension === 'pdf',
documentUrl,
)
: () => {}
}
></Image>
<span className="text-accent-primary">{imageId}</span>
</section>
) : (
<HoverCard key={i}>
<HoverCardTrigger>
<CircleAlert className="size-4 inline-block" />
</HoverCardTrigger>
<HoverCardContent className="max-w-3xl">
{renderPopoverContent(chunkIndex)}
</HoverCardContent>
</HoverCard>
);
if (
convertedReference &&
shouldShowCarousel(group, convertedReference)
) {
elements.push(
<ImageCarousel
key={`carousel-${groupIndex}`}
group={group}
reference={convertedReference}
fileThumbnails={fileThumbnails}
onImageClick={handleDocumentButtonClick}
/>,
);
} else {
group.forEach((ref) => {
const chunkIndex = getChunkIndex(ref.id);
const {
documentUrl,
fileExtension,
imageId,
chunkItem,
documentId,
} = getReferenceInfo(chunkIndex);
const docType = chunkItem?.doc_type;
if (showImage(docType)) {
elements.push(
<section key={ref.id}>
<Image
id={imageId}
className={styles.referenceInnerChunkImage}
onClick={
documentId
? handleDocumentButtonClick(
documentId,
chunkItem,
fileExtension === 'pdf',
documentUrl,
)
: () => {}
}
/>
<span className="text-accent-primary"> {imageId}</span>
</section>,
);
} else {
elements.push(
<HoverCard key={ref.id}>
<HoverCardTrigger>
<CircleAlert className="size-4 inline-block" />
</HoverCardTrigger>
<HoverCardContent className="max-w-3xl">
{renderPopoverContent(chunkIndex)}
</HoverCardContent>
</HoverCard>,
);
}
});
}
lastIndex = group[group.length - 1].end;
});
return replacedText;
if (lastIndex < text.length) {
elements.push(text.substring(lastIndex));
}
return elements;
},
[renderPopoverContent, getReferenceInfo, handleDocumentButtonClick],
[
renderPopoverContent,
getReferenceInfo,
handleDocumentButtonClick,
reference,
fileThumbnails,
],
);
return (

View File

@ -0,0 +1,19 @@
import { useTranslation } from 'react-i18next';
import { RAGFlowFormItem } from './ragflow-form';
import { Input } from './ui/input';
type WebHookResponseStatusFormFieldProps = {
name: string;
};
export function WebHookResponseStatusFormField({
name,
}: WebHookResponseStatusFormFieldProps) {
const { t } = useTranslation();
return (
<RAGFlowFormItem name={name} label={t('flow.webhook.status')}>
<Input type="number"></Input>
</RAGFlowFormItem>
);
}

View File

@ -124,6 +124,7 @@ export const useFetchDocumentList = () => {
{
suffix: filterValue.type,
run_status: filterValue.run,
metadata: filterValue.metadata,
},
);
if (ret.data.code === 0) {
@ -196,6 +197,7 @@ export const useGetDocumentFilter = (): {
filter: data?.filter || {
run_status: {},
suffix: {},
metadata: {},
},
onOpenChange: handleOnpenChange,
};

View File

@ -60,4 +60,5 @@ interface GraphRag {
export type IDocumentInfoFilter = {
run_status: Record<number, number>;
suffix: Record<string, number>;
metadata: Record<string, Record<string, number>>;
};

View File

@ -116,12 +116,15 @@ export interface ITenantInfo {
tts_id: string;
}
export type ChunkDocType = 'image' | 'table';
export interface IChunk {
available_int: number; // Whether to enable, 0: not enabled, 1: enabled
chunk_id: string;
content_with_weight: string;
doc_id: string;
doc_name: string;
doc_type_kwd?: ChunkDocType;
image_id: string;
important_kwd?: string[];
question_kwd?: string[]; // keywords

View File

@ -364,6 +364,9 @@ Procedural Memory: Learned skills, habits, and automated procedures.`,
},
knowledgeConfiguration: {
settings: 'Settings',
imageContextWindow: 'Image context window',
imageContextWindowTip:
'Captures N tokens of text above and below the image to provide richer background context for the image chunk.',
autoMetadata: 'Auto metadata',
mineruOptions: 'MinerU Options',
mineruParseMethod: 'Parse Method',
@ -604,6 +607,12 @@ This auto-tagging feature enhances retrieval by adding another layer of domain-s
'The document being parsed cannot be deleted',
},
chunk: {
type: 'Type',
docType: {
image: 'Image',
table: 'Table',
text: 'Text',
},
chunk: 'Chunk',
bulk: 'Bulk',
selectAll: 'Select all',
@ -2084,12 +2093,14 @@ Important structured information may include: names, dates, locations, events, k
schema: 'Schema',
response: 'Response',
executionMode: 'Execution mode',
executionModeTip:
'Accepted Response: The system returns an acknowledgment immediately after the request is validated, while the workflow continues to execute asynchronously in the background. /Final Response: The system returns a response only after the workflow execution is completed.',
authMethods: 'Authentication methods',
authType: 'Authentication type',
limit: 'Request limit',
per: 'Time period',
maxBodySize: 'Maximum body size',
ipWhitelist: 'Ip whitelist',
ipWhitelist: 'IP whitelist',
tokenHeader: 'Token header',
tokenValue: 'Token value',
username: 'Username',
@ -2109,6 +2120,8 @@ Important structured information may include: names, dates, locations, events, k
queryParameters: 'Query parameters',
headerParameters: 'Header parameters',
requestBodyParameters: 'Request body parameters',
streaming: 'Accepted response',
immediately: 'Final response',
},
},
llmTools: {

View File

@ -1835,6 +1835,8 @@ Tokenizer 会根据所选方式将内容存储为对应的数据结构。`,
schema: '模式',
response: '响应',
executionMode: '执行模式',
executionModeTip:
'Accepted Response请求校验通过后立即返回接收成功响应工作流在后台异步执行Final Response系统在工作流执行完成后返回最终处理结果',
authMethods: '认证方法',
authType: '认证类型',
limit: '请求限制',

View File

@ -1,8 +1,8 @@
import { Collapse } from '@/components/collapse';
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { WebHookResponseStatusFormField } from '@/components/webhook-response-status';
import { WebhookExecutionMode } from '@/pages/agent/constant';
import { buildOptions } from '@/utils/form';
import { useFormContext, useWatch } from 'react-hook-form';
@ -24,19 +24,17 @@ export function WebhookResponse() {
<RAGFlowFormItem
name="execution_mode"
label={t('flow.webhook.executionMode')}
tooltip={t('flow.webhook.executionModeTip')}
>
<SelectWithSearch
options={buildOptions(WebhookExecutionMode)}
options={buildOptions(WebhookExecutionMode, t, 'flow.webhook')}
></SelectWithSearch>
</RAGFlowFormItem>
{executionMode === WebhookExecutionMode.Immediately && (
<>
<RAGFlowFormItem
<WebHookResponseStatusFormField
name={'response.status'}
label={t('flow.webhook.status')}
>
<Input type="number"></Input>
</RAGFlowFormItem>
></WebHookResponseStatusFormField>
{/* <DynamicResponse
name="response.headers_template"
label={t('flow.webhook.headersTemplate')}

View File

@ -10,6 +10,7 @@ import {
} from '@/components/ui/form';
import { RAGFlowSelect } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { WebHookResponseStatusFormField } from '@/components/webhook-response-status';
import { zodResolver } from '@hookform/resolvers/zod';
import { X } from 'lucide-react';
import { memo } from 'react';
@ -20,6 +21,7 @@ import { ExportFileType } from '../../constant';
import { INextOperatorForm } from '../../interface';
import { FormWrapper } from '../components/form-wrapper';
import { PromptEditor } from '../components/prompt-editor';
import { useShowWebhookResponseStatus } from './use-show-response-status';
import { useValues } from './use-values';
import { useWatchFormChange } from './use-watch-change';
@ -38,6 +40,7 @@ function MessageForm({ node }: INextOperatorForm) {
.optional(),
output_format: z.string().optional(),
auto_play: z.boolean().optional(),
status: z.number().optional(),
});
const form = useForm({
@ -56,9 +59,14 @@ function MessageForm({ node }: INextOperatorForm) {
control: form.control,
});
const showWebhookResponseStatus = useShowWebhookResponseStatus(form);
return (
<Form {...form}>
<FormWrapper>
{showWebhookResponseStatus && (
<WebHookResponseStatusFormField name="status"></WebHookResponseStatusFormField>
)}
<FormContainer>
<FormItem>
<FormLabel tooltip={t('flow.msgTip')}>{t('flow.msg')}</FormLabel>

View File

@ -0,0 +1,30 @@
import { isEmpty } from 'lodash';
import { useEffect, useMemo } from 'react';
import { UseFormReturn } from 'react-hook-form';
import {
AgentDialogueMode,
BeginId,
WebhookExecutionMode,
} from '../../constant';
import useGraphStore from '../../store';
import { BeginFormSchemaType } from '../begin-form/schema';
export function useShowWebhookResponseStatus(form: UseFormReturn<any>) {
const getNode = useGraphStore((state) => state.getNode);
const showWebhookResponseStatus = useMemo(() => {
const formData: BeginFormSchemaType = getNode(BeginId)?.data.form;
return (
formData.mode === AgentDialogueMode.Webhook &&
formData.execution_mode === WebhookExecutionMode.Streaming
);
}, []);
useEffect(() => {
if (showWebhookResponseStatus && isEmpty(form.getValues('status'))) {
form.setValue('status', 200, { shouldValidate: true, shouldDirty: true });
}
}, []);
return showWebhookResponseStatus;
}

View File

@ -4,6 +4,8 @@
}
.imagePreview {
width: 100%;
height: 100%;
max-width: 50vw;
max-height: 50vh;
object-fit: contain;

View File

@ -2,17 +2,19 @@ import Image from '@/components/image';
import { useTheme } from '@/components/theme-provider';
import { Card } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Switch } from '@/components/ui/switch';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { IChunk } from '@/interfaces/database/knowledge';
import { cn } from '@/lib/utils';
import { CheckedState } from '@radix-ui/react-checkbox';
import classNames from 'classnames';
import DOMPurify from 'dompurify';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ChunkTextMode } from '../../constant';
import styles from './index.less';
@ -39,6 +41,7 @@ const ChunkCard = ({
textMode,
t: imageCacheKey,
}: IProps) => {
const { t } = useTranslation();
const available = Number(item.available_int);
const [enabled, setEnabled] = useState(false);
const { theme } = useTheme();
@ -63,51 +66,59 @@ const ChunkCard = ({
useEffect(() => {
setEnabled(available === 1);
}, [available]);
const [open, setOpen] = useState<boolean>(false);
return (
<Card
className={classNames(styles.chunkCard, {
className={classNames('relative flex-none', styles.chunkCard, {
[`${theme === 'dark' ? styles.cardSelectedDark : styles.cardSelected}`]:
selected,
})}
>
<span
className="
absolute top-0 right-0 px-4 py-1
leading-none text-xs text-text-disabled
bg-bg-card rounded-bl-2xl rounded-tr-lg
border-l-0.5 border-b-0.5 border-border-button"
>
{t(
`chunk.docType.${item.doc_type_kwd ? String(item.doc_type_kwd).toLowerCase() : 'text'}`,
)}
</span>
<div className="flex items-start justify-between gap-2">
<Checkbox onCheckedChange={handleCheck} checked={checked}></Checkbox>
{/* Using <Tooltip> instead of <Popover> to avoid flickering when hovering over the image */}
{item.image_id && (
<Popover open={open}>
<PopoverTrigger
asChild
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
>
<div>
<Image
t={imageCacheKey}
id={item.image_id}
className={styles.image}
/>
</div>
</PopoverTrigger>
<PopoverContent
<Tooltip>
<TooltipTrigger>
<Image
t={imageCacheKey}
id={item.image_id}
className={styles.image}
/>
</TooltipTrigger>
<TooltipContent
className="p-0"
align={'start'}
side={'right'}
sideOffset={-20}
tabIndex={-1}
>
<div>
<Image
t={imageCacheKey}
id={item.image_id}
className={styles.imagePreview}
></Image>
</div>
</PopoverContent>
</Popover>
<Image
t={imageCacheKey}
id={item.image_id}
className={styles.imagePreview}
/>
</TooltipContent>
</Tooltip>
)}
<section
onDoubleClick={handleContentDoubleClick}
onClick={handleContentClick}
className={styles.content}
className={cn(styles.content, 'mt-2')}
>
<div
dangerouslySetInnerHTML={{
@ -118,7 +129,8 @@ const ChunkCard = ({
})}
></div>
</section>
<div>
<div className="mt-2">
<Switch
checked={enabled}
onCheckedChange={onChange}

View File

@ -15,6 +15,7 @@ import {
HoverCardContent,
HoverCardTrigger,
} from '@/components/ui/hover-card';
import { Input } from '@/components/ui/input';
import { Modal } from '@/components/ui/modal/modal';
import Space from '@/components/ui/space';
import { Switch } from '@/components/ui/switch';
@ -76,6 +77,7 @@ const ChunkCreatingModal: React.FC<IModalProps<any> & kFProps> = ({
const { removeChunk } = useDeleteChunkByIds();
const { data } = useFetchChunk(chunkId);
const { t } = useTranslation();
const isEditMode = !!chunkId;
const isTagParser = parserId === 'tag';
const onSubmit = useCallback(
@ -144,6 +146,28 @@ const ChunkCreatingModal: React.FC<IModalProps<any> & kFProps> = ({
)}
/>
{/* Do not display the type field in create mode */}
{isEditMode && (
<FormField
control={form.control}
name="doc_type_kwd"
render={({ field }) => (
<FormItem>
<FormLabel>{t(`chunk.type`)}</FormLabel>
<FormControl>
<Input
type="text"
value={t(
`chunk.docType.${field.value ? String(field.value).toLowerCase() : 'text'}`,
)}
readOnly
/>
</FormControl>
</FormItem>
)}
/>
)}
<FormField
control={form.control}
name="image"
@ -151,18 +175,18 @@ const ChunkCreatingModal: React.FC<IModalProps<any> & kFProps> = ({
<FormItem>
<FormLabel className="gap-1">{t('chunk.image')}</FormLabel>
<div className="grid grid-cols-2 gap-4 items-start">
<div className="space-y-4">
{data?.data?.img_id && (
<Image
id={data?.data?.img_id}
className="w-full object-contain"
className="mx-auto w-auto max-w-full object-contain max-h-[800px]"
/>
)}
<div className="col-start-2 col-end-3 only:col-span-2">
<FormControl>
<FileUploader
className="h-48"
className="h-auto p-6"
value={field.value}
onValueChange={field.onChange}
accept={{
@ -171,6 +195,7 @@ const ChunkCreatingModal: React.FC<IModalProps<any> & kFProps> = ({
'image/webp': [],
}}
maxFileCount={1}
hideDropzoneOnMaxFileCount
title={t('chunk.imageUploaderTitle')}
description={<></>}
/>

View File

@ -45,7 +45,7 @@ export default (props: ICheckboxSetProps) => {
}, [selectedChunkIds]);
return (
<div className="flex gap-[40px] py-4 px-2">
<div className="flex gap-[40px] py-4 px-2 h-14">
<div className="flex items-center gap-3 cursor-pointer text-muted-foreground hover:text-text-primary">
<Checkbox
id="all_chunks_checkbox"

View File

@ -204,7 +204,8 @@ export const ManageValuesModal = (props: IManageValuesProps) => {
</div>
</div>
)}
{metaData.restrictDefinedValues && (
{((metaData.restrictDefinedValues && isShowValueSwitch) ||
!isShowValueSwitch) && (
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<div>{t('knowledgeDetails.metadata.values')}</div>

View File

@ -310,6 +310,36 @@ export function EnableTocToggle() {
);
}
export function ImageContextWindow() {
const { t } = useTranslate('knowledgeConfiguration');
const form = useFormContext();
return (
<FormField
control={form.control}
name="parser_config.image_context_window"
render={({ field }) => (
<FormItem>
<FormControl>
<SliderInputFormField
{...field}
label={t('imageContextWindow')}
tooltip={t('imageContextWindowTip')}
defaultValue={0}
min={0}
max={256}
/>
</FormControl>
<div className="flex pt-1">
<div className="w-1/4"></div>
<FormMessage />
</div>
</FormItem>
)}
/>
);
}
export function OverlappedPercent() {
return (
<SliderInputFormField

View File

@ -14,6 +14,7 @@ import {
import {
AutoMetadata,
EnableTocToggle,
ImageContextWindow,
OverlappedPercent,
} from './common-item';
@ -26,6 +27,7 @@ export function NaiveConfiguration() {
<DelimiterFormField></DelimiterFormField>
<ChildrenDelimiterForm />
<EnableTocToggle />
<ImageContextWindow />
<AutoMetadata />
<OverlappedPercent />
</ConfigurationFormContainer>

View File

@ -32,6 +32,7 @@ export const formSchema = z
tag_kb_ids: z.array(z.string()).nullish(),
topn_tags: z.number().optional(),
toc_extraction: z.boolean().optional(),
image_context_window: z.number().optional(),
overlapped_percent: z.number().optional(),
// MinerU-specific options
mineru_parse_method: z.enum(['auto', 'txt', 'ocr']).optional(),

View File

@ -70,6 +70,7 @@ export default function DatasetSettings() {
html4excel: false,
topn_tags: 3,
toc_extraction: false,
image_context_window: 0,
overlapped_percent: 0,
// MinerU-specific defaults
mineru_parse_method: 'auto',

View File

@ -216,12 +216,6 @@ const Generate: React.FC<GenerateProps> = (props) => {
? graphRunData
: raptorRunData
) as ITraceInfo;
console.log(
name,
'data',
data,
!data || (!data.progress && data.progress !== 0),
);
return (
<div key={name}>
<MenuItem

View File

@ -25,12 +25,33 @@ export function useSelectDatasetFilters() {
}));
}
}, [filter.run_status, t]);
const metaDataList = useMemo(() => {
if (filter.metadata) {
return Object.keys(filter.metadata).map((x) => ({
id: x.toString(),
field: x.toString(),
label: x.toString(),
list: Object.keys(filter.metadata[x]).map((y) => ({
id: y.toString(),
field: y.toString(),
label: y.toString(),
value: [y],
count: filter.metadata[x][y],
})),
count: Object.keys(filter.metadata[x]).reduce(
(acc, cur) => acc + filter.metadata[x][cur],
0,
),
}));
}
}, [filter.metadata]);
const filters: FilterCollection[] = useMemo(() => {
return [
{ field: 'type', label: 'File Type', list: fileTypes },
{ field: 'run', label: 'Status', list: fileStatus },
{ field: 'metadata', label: 'metadata', list: metaDataList },
] as FilterCollection[];
}, [fileStatus, fileTypes]);
}, [fileStatus, fileTypes, metaDataList]);
return { filters, onOpenChange };
}

View File

@ -4,9 +4,8 @@ import { IReference, IReferenceChunk } from '@/interfaces/database/chat';
import { getExtension } from '@/utils/document-util';
import { InfoCircleOutlined } from '@ant-design/icons';
import DOMPurify from 'dompurify';
import { memo, useCallback, useEffect, useMemo } from 'react';
import React, { memo, useCallback, useEffect, useMemo } from 'react';
import Markdown from 'react-markdown';
import reactStringReplace from 'react-string-replace';
import SyntaxHighlighter from 'react-syntax-highlighter';
import rehypeKatex from 'rehype-katex';
import rehypeRaw from 'rehype-raw';
@ -18,8 +17,13 @@ import { useTranslation } from 'react-i18next';
import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you
import ImageCarousel from '@/components/markdown-content/image-carousel';
import {
groupConsecutiveReferences,
shouldShowCarousel,
type ReferenceGroup,
} from '@/components/markdown-content/reference-utils';
import {
currentReg,
preprocessLaTeX,
replaceTextByOldReg,
replaceThinkToSection,
@ -37,8 +41,6 @@ import classNames from 'classnames';
import { omit } from 'lodash';
import { pipe } from 'lodash/fp';
const getChunkIndex = (match: string) => Number(match);
// Defining Tailwind CSS class name constants
const styles = {
referenceChunkImage: 'w-[10vw] object-contain',
@ -86,18 +88,11 @@ const MarkdownContent = ({
(
documentId: string,
chunk: IReferenceChunk,
// isPdf: boolean,
// documentUrl?: string,
isPdf: boolean = false,
documentUrl?: string,
) =>
() => {
// if (!isPdf) {
// if (!documentUrl) {
// return;
// }
// window.open(documentUrl, '_blank');
// } else {
clickDocumentButton?.(documentId, chunk);
// }
},
[clickDocumentButton],
);
@ -218,43 +213,83 @@ const MarkdownContent = ({
const renderReference = useCallback(
(text: string) => {
let replacedText = reactStringReplace(text, currentReg, (match, i) => {
const chunkIndex = getChunkIndex(match);
const groups = groupConsecutiveReferences(text);
const elements: React.ReactNode[] = [];
let lastIndex = 0;
const { imageId, chunkItem, documentId } = getReferenceInfo(chunkIndex);
groups.forEach((group: ReferenceGroup) => {
// Add text before the group
if (group[0].start > lastIndex) {
elements.push(text.slice(lastIndex, group[0].start));
}
const docType = chunkItem?.doc_type;
// Determine if this group should be a carousel
if (shouldShowCarousel(group, reference)) {
// Render carousel for consecutive image group
elements.push(
<ImageCarousel
key={`carousel-${group[0].id}`}
group={group}
reference={reference}
fileThumbnails={fileThumbnails}
onImageClick={handleDocumentButtonClick}
/>,
);
} else {
// Render individual references in the group
group.forEach((ref) => {
const chunkIndex = Number(ref.id);
const { imageId, chunkItem, documentId } =
getReferenceInfo(chunkIndex);
const docType = chunkItem?.doc_type;
return showImage(docType) ? (
<Image
id={imageId}
className={styles.referenceInnerChunkImage}
onClick={
documentId
? handleDocumentButtonClick(
documentId,
chunkItem,
// fileExtension === 'pdf',
// documentUrl,
)
: () => {}
if (showImage(docType)) {
elements.push(
<Image
key={ref.id}
id={imageId}
className={styles.referenceInnerChunkImage}
onClick={
documentId
? handleDocumentButtonClick(documentId, chunkItem)
: () => {}
}
/>,
);
} else {
elements.push(
<Popover key={ref.id}>
<PopoverTrigger>
<InfoCircleOutlined className={styles.referenceIcon} />
</PopoverTrigger>
<PopoverContent className="!w-fit">
{getPopoverContent(chunkIndex)}
</PopoverContent>
</Popover>,
);
}
></Image>
) : (
<Popover>
<PopoverTrigger>
<InfoCircleOutlined className={styles.referenceIcon} />
</PopoverTrigger>
<PopoverContent className="!w-fit">
{getPopoverContent(chunkIndex)}
</PopoverContent>
</Popover>
);
// Add the original reference text
elements.push(ref.fullMatch);
});
}
lastIndex = group[group.length - 1].end;
});
return replacedText;
// Add any remaining text after the last group
if (lastIndex < text.length) {
elements.push(text.slice(lastIndex));
}
return elements;
},
[getPopoverContent, getReferenceInfo, handleDocumentButtonClick],
[
reference,
fileThumbnails,
handleDocumentButtonClick,
getReferenceInfo,
getPopoverContent,
],
);
return (
@ -265,7 +300,11 @@ const MarkdownContent = ({
components={
{
'custom-typography': ({ children }: { children: string }) =>
renderReference(children),
Array.isArray(renderReference(children))
? renderReference(children).map((element, index) => (
<React.Fragment key={index}>{element}</React.Fragment>
))
: renderReference(children),
code(props: any) {
const { children, className, ...rest } = props;
const restProps = omit(rest, 'node');

View File

@ -0,0 +1,247 @@
import { useEffect, useMemo, useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { SelectWithSearch } from '@/components/originui/select-with-search';
import { RAGFlowFormItem } from '@/components/ragflow-form';
import { Input } from '@/components/ui/input';
import { Segmented } from '@/components/ui/segmented';
import { t } from 'i18next';
// UI-only auth modes for S3
// access_key: Access Key ID + Secret
// iam_role: only Role ARN
// assume_role: no input fields (uses environment role)
type AuthMode = 'access_key' | 'iam_role' | 'assume_role';
type BlobMode = 's3' | 's3_compatible';
const modeOptions = [
{ label: 'S3', value: 's3' },
{ label: 'S3 Compatible', value: 's3_compatible' },
];
const authOptions = [
{ label: 'Access Key', value: 'access_key' },
{ label: 'IAM Role', value: 'iam_role' },
{ label: 'Assume Role', value: 'assume_role' },
];
const addressingOptions = [
{ label: 'Virtual Hosted Style', value: 'virtual' },
{ label: 'Path Style', value: 'path' },
];
const deriveInitialAuthMode = (credentials: any): AuthMode => {
const authMethod = credentials?.authentication_method;
if (authMethod === 'iam_role') return 'iam_role';
if (authMethod === 'assume_role') return 'assume_role';
if (credentials?.aws_role_arn) return 'iam_role';
if (credentials?.aws_access_key_id || credentials?.aws_secret_access_key)
return 'access_key';
return 'access_key';
};
const deriveInitialMode = (bucketType?: string): BlobMode =>
bucketType === 's3_compatible' ? 's3_compatible' : 's3';
const BlobTokenField = () => {
const form = useFormContext();
const credentials = form.watch('config.credentials');
const watchedBucketType = form.watch('config.bucket_type');
const [mode, setMode] = useState<BlobMode>(
deriveInitialMode(watchedBucketType),
);
const [authMode, setAuthMode] = useState<AuthMode>(() =>
deriveInitialAuthMode(credentials),
);
// Keep bucket_type in sync with UI mode
useEffect(() => {
const nextMode = deriveInitialMode(watchedBucketType);
setMode((prev) => (prev === nextMode ? prev : nextMode));
}, [watchedBucketType]);
useEffect(() => {
form.setValue('config.bucket_type', mode, { shouldDirty: true });
// Default addressing style for compatible mode
if (
mode === 's3_compatible' &&
!form.getValues('config.credentials.addressing_style')
) {
form.setValue('config.credentials.addressing_style', 'virtual', {
shouldDirty: false,
});
}
if (mode === 's3_compatible' && authMode !== 'access_key') {
setAuthMode('access_key');
}
// Persist authentication_method for backend
const nextAuthMethod: AuthMode =
mode === 's3_compatible' ? 'access_key' : authMode;
form.setValue('config.credentials.authentication_method', nextAuthMethod, {
shouldDirty: true,
});
// Clear errors for fields that are not relevant in the current mode/auth selection
const inactiveFields: string[] = [];
if (mode === 's3_compatible') {
inactiveFields.push('config.credentials.aws_role_arn');
} else {
if (authMode === 'iam_role') {
inactiveFields.push('config.credentials.aws_access_key_id');
inactiveFields.push('config.credentials.aws_secret_access_key');
}
if (authMode === 'assume_role') {
inactiveFields.push('config.credentials.aws_access_key_id');
inactiveFields.push('config.credentials.aws_secret_access_key');
inactiveFields.push('config.credentials.aws_role_arn');
}
}
if (inactiveFields.length) {
form.clearErrors(inactiveFields as any);
}
}, [form, mode, authMode]);
const isS3 = mode === 's3';
const requiresAccessKey =
authMode === 'access_key' || mode === 's3_compatible';
const requiresRoleArn = isS3 && authMode === 'iam_role';
// Help text for assume role (no inputs)
const assumeRoleNote = useMemo(
() => t('No credentials required. Uses the default environment role.'),
[t],
);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="text-sm text-text-secondary">Mode</div>
<Segmented
options={modeOptions}
value={mode}
onChange={(val) => setMode(val as BlobMode)}
className="w-full"
itemClassName="flex-1 justify-center"
/>
</div>
{isS3 && (
<div className="flex flex-col gap-2">
<div className="text-sm text-text-secondary">Authentication</div>
<Segmented
options={authOptions}
value={authMode}
onChange={(val) => setAuthMode(val as AuthMode)}
className="w-full"
itemClassName="flex-1 justify-center"
/>
</div>
)}
{requiresAccessKey && (
<RAGFlowFormItem
name="config.credentials.aws_access_key_id"
label="AWS Access Key ID"
required={requiresAccessKey}
rules={{
validate: (val) =>
requiresAccessKey
? Boolean(val) || 'Access Key ID is required'
: true,
}}
>
{(field) => (
<Input {...field} placeholder="AKIA..." autoComplete="off" />
)}
</RAGFlowFormItem>
)}
{requiresAccessKey && (
<RAGFlowFormItem
name="config.credentials.aws_secret_access_key"
label="AWS Secret Access Key"
required={requiresAccessKey}
rules={{
validate: (val) =>
requiresAccessKey
? Boolean(val) || 'Secret Access Key is required'
: true,
}}
>
{(field) => (
<Input
{...field}
type="password"
placeholder="****************"
autoComplete="new-password"
/>
)}
</RAGFlowFormItem>
)}
{requiresRoleArn && (
<RAGFlowFormItem
name="config.credentials.aws_role_arn"
label="Role ARN"
required={requiresRoleArn}
tooltip="The role will be assumed by the runtime environment."
rules={{
validate: (val) =>
requiresRoleArn ? Boolean(val) || 'Role ARN is required' : true,
}}
>
{(field) => (
<Input
{...field}
placeholder="arn:aws:iam::123456789012:role/YourRole"
autoComplete="off"
/>
)}
</RAGFlowFormItem>
)}
{isS3 && authMode === 'assume_role' && (
<div className="text-sm text-text-secondary bg-bg-card border border-border-button rounded-md px-3 py-2">
{assumeRoleNote}
</div>
)}
{mode === 's3_compatible' && (
<div className="flex flex-col gap-4">
<RAGFlowFormItem
name="config.credentials.addressing_style"
label="Addressing Style"
tooltip={t('setting.S3CompatibleAddressingStyleTip')}
required={false}
>
{(field) => (
<SelectWithSearch
triggerClassName="!shrink"
options={addressingOptions}
value={field.value || 'virtual'}
onChange={(val) => field.onChange(val)}
/>
)}
</RAGFlowFormItem>
<RAGFlowFormItem
name="config.credentials.endpoint_url"
label="Endpoint URL"
required={false}
tooltip={t('setting.S3CompatibleEndpointUrlTip')}
>
{(field) => (
<Input
{...field}
placeholder="https://fsn1.your-objectstorage.com"
autoComplete="off"
/>
)}
</RAGFlowFormItem>
</div>
)}
</div>
);
};
export default BlobTokenField;

View File

@ -3,6 +3,8 @@ import SvgIcon from '@/components/svg-icon';
import { t, TFunction } from 'i18next';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { BedrockRegionList } from '../setting-model/constant';
import BlobTokenField from './component/blob-token-field';
import BoxTokenField from './component/box-token-field';
import { ConfluenceIndexingModeField } from './component/confluence-token-field';
import GmailTokenField from './component/gmail-token-field';
@ -105,6 +107,11 @@ export const generateDataSourceInfo = (t: TFunction) => {
};
};
const awsRegionOptions = BedrockRegionList.map((r) => ({
label: r,
value: r,
}));
export const useDataSourceInfo = () => {
const { t } = useTranslation();
const [dataSourceInfo, setDataSourceInfo] = useState<IDataSourceInfoMap>(
@ -222,18 +229,6 @@ export const DataSourceFormFields = {
},
],
[DataSourceKey.S3]: [
{
label: 'AWS Access Key ID',
name: 'config.credentials.aws_access_key_id',
type: FormFieldType.Text,
required: true,
},
{
label: 'AWS Secret Access Key',
name: 'config.credentials.aws_secret_access_key',
type: FormFieldType.Password,
required: true,
},
{
label: 'Bucket Name',
name: 'config.bucket_name',
@ -241,39 +236,21 @@ export const DataSourceFormFields = {
required: true,
},
{
label: 'Bucket Type',
name: 'config.bucket_type',
label: 'Region',
name: 'config.credentials.region',
type: FormFieldType.Select,
options: [
{ label: 'S3', value: 's3' },
{ label: 'S3 Compatible', value: 's3_compatible' },
],
required: true,
},
{
label: 'Addressing Style',
name: 'config.credentials.addressing_style',
type: FormFieldType.Select,
options: [
{ label: 'Virtual Hosted Style', value: 'virtual' },
{ label: 'Path Style', value: 'path' },
],
required: false,
placeholder: 'Virtual Hosted Style',
tooltip: t('setting.S3CompatibleAddressingStyleTip'),
shouldRender: (formValues: any) => {
return formValues?.config?.bucket_type === 's3_compatible';
},
},
{
label: 'Endpoint URL',
name: 'config.credentials.endpoint_url',
type: FormFieldType.Text,
required: false,
placeholder: 'https://fsn1.your-objectstorage.com',
tooltip: t('setting.S3CompatibleEndpointUrlTip'),
shouldRender: (formValues: any) => {
return formValues?.config?.bucket_type === 's3_compatible';
options: awsRegionOptions,
customValidate: (val: string, formValues: any) => {
const credentials = formValues?.config?.credentials || {};
const bucketType = formValues?.config?.bucket_type || 's3';
const hasAccessKey = Boolean(
credentials.aws_access_key_id || credentials.aws_secret_access_key,
);
if (bucketType === 's3' && hasAccessKey) {
return Boolean(val) || 'Region is required when using access key';
}
return true;
},
},
{
@ -283,6 +260,14 @@ export const DataSourceFormFields = {
required: false,
tooltip: t('setting.s3PrefixTip'),
},
{
label: 'Credentials',
name: 'config.credentials.__blob_token',
type: FormFieldType.Custom,
hideLabel: true,
required: false,
render: () => <BlobTokenField />,
},
],
[DataSourceKey.NOTION]: [
{
@ -700,6 +685,9 @@ export const DataSourceFormDefaultValues = {
credentials: {
aws_access_key_id: '',
aws_secret_access_key: '',
region: '',
authentication_method: 'access_key',
aws_role_arn: '',
endpoint_url: '',
addressing_style: 'virtual',
},

View File

@ -10,8 +10,7 @@ import { Input } from '@/components/ui/input';
import { Separator } from '@/components/ui/separator';
import { RunningStatus } from '@/constants/knowledge';
import { t } from 'i18next';
import { debounce } from 'lodash';
import { CirclePause, Repeat } from 'lucide-react';
import { CirclePause, Loader2, Repeat } from 'lucide-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FieldValues } from 'react-hook-form';
import {
@ -120,11 +119,11 @@ const SourceDetailPage = () => {
];
}, [detail, runSchedule]);
const { handleAddOk } = useAddDataSource();
const { addLoading, handleAddOk } = useAddDataSource();
const onSubmit = useCallback(() => {
formRef?.current?.submit();
}, [formRef]);
}, []);
useEffect(() => {
if (detail) {
@ -140,9 +139,7 @@ const SourceDetailPage = () => {
return {
...field,
horizontal: true,
onChange: () => {
onSubmit();
},
onChange: undefined,
};
});
setFields(newFields);
@ -175,12 +172,23 @@ const SourceDetailPage = () => {
<DynamicForm.Root
ref={formRef}
fields={fields}
onSubmit={debounce((data) => {
handleAddOk(data);
}, 500)}
onSubmit={(data) => handleAddOk(data)}
defaultValues={defaultValues}
/>
</div>
<div className="max-w-[1200px] flex justify-end">
<button
type="button"
onClick={onSubmit}
disabled={addLoading}
className="flex items-center justify-center min-w-[100px] px-4 py-2 bg-primary text-white rounded-md disabled:opacity-60"
>
{addLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{addLoading
? t('modal.loadingText', { defaultValue: 'Submitting...' })
: t('modal.okText', { defaultValue: 'Submit' })}
</button>
</div>
<section className="flex flex-col gap-2">
<div className="text-2xl text-text-primary mb-2">
{t('setting.log')}