mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-05-03 00:37:48 +08:00
Feature rtl support (#13118)
### What problem does this PR solve? This PR adds comprehensive **Right-to-Left (RTL) language support**, primarily targeting Arabic and other RTL scripts (Hebrew, Persian, Urdu, etc.). Previously, RTL content had multiple rendering issues: - Incorrect sentence splitting for Arabic punctuation in citation logic - Misaligned text in chat messages and markdown components - Improper positioning of blockquotes and “think” sections - Incorrect table alignment - Citation placement ambiguity in RTL prompts - UI layout inconsistencies when mixing LTR and RTL text This PR introduces backend and frontend improvements to properly detect, render, and style RTL content while preserving existing LTR behavior. #### Backend - Updated sentence boundary regex in `rag/nlp/search.py` to include Arabic punctuation: - `،` (comma) - `؛` (semicolon) - `؟` (question mark) - `۔` (Arabic full stop) - Ensures citation insertion works correctly in RTL sentences. - Updated citation prompt instructions to clarify citation placement rules for RTL languages. #### Frontend - Introduced a new utility: `text-direction.ts` - Detects text direction based on Unicode ranges. - Supports Arabic, Hebrew, Syriac, Thaana, and related scripts. - Provides `getDirAttribute()` for automatic `dir` assignment. - Applied dynamic `dir` attributes across: - Markdown rendering - Chat messages - Search results - Tables - Hover cards and reference popovers - Added proper RTL styling in LESS: - Text alignment adjustments - Blockquote border flipping - Section indentation correction - Table direction switching - Use of `<bdi>` for figure labels to prevent bidirectional conflicts #### DevOps / Environment - Added Windows backend launch script with retry handling. - Updated dependency metadata. - Adjusted development-only React debugging behavior. --- ### Type of change - [x] Bug Fix (non-breaking change which fixes RTL rendering and citation issues) - [x] New Feature (non-breaking change which adds RTL detection and dynamic direction handling) --------- Co-authored-by: 6ba3i <isbaaoui09@gmail.com> Co-authored-by: Ahmad Intisar <ahmadintisar@Ahmads-MacBook-M4-Pro.local> Co-authored-by: Ahmad Intisar <168020872+ahmadintisar@users.noreply.github.com> Co-authored-by: Liu An <asiro@qq.com>
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@ -7,7 +7,7 @@ hudet/
|
||||
cv/
|
||||
layout_app.py
|
||||
api/flask_session
|
||||
|
||||
venv/
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
@ -211,3 +211,9 @@ backup
|
||||
# Added by cargo
|
||||
|
||||
/target
|
||||
|
||||
# Do not include in PR (local dev / build artifacts)
|
||||
ragflow.egg-info/
|
||||
uv-aarch64*.tar.gz
|
||||
uv-aarch64-unknown-linux-gnu.tar.gz
|
||||
docker/launch_backend_service_windows.sh
|
||||
|
||||
@ -7,7 +7,7 @@ ARG NEED_MIRROR=0
|
||||
|
||||
WORKDIR /ragflow
|
||||
|
||||
# Copy models downloaded via download_deps.py
|
||||
# copy models downloaded via download_deps.py
|
||||
RUN mkdir -p /ragflow/rag/res/deepdoc /root/.ragflow
|
||||
RUN --mount=type=bind,from=infiniflow/ragflow_deps:latest,source=/huggingface.co,target=/huggingface.co \
|
||||
tar --exclude='.*' -cf - \
|
||||
|
||||
@ -547,18 +547,10 @@ class Canvas(Graph):
|
||||
yield decorate("message", {"content": "", "audio_binary": self.tts(tts_mdl, buff_m)})
|
||||
buff_m = ""
|
||||
cpn_obj.set_output("content", _m)
|
||||
cite = re.search(r"\[ID:[ 0-9]+\]", _m)
|
||||
else:
|
||||
yield decorate("message", {"content": cpn_obj.output("content")})
|
||||
cite = re.search(r"\[ID:[ 0-9]+\]", cpn_obj.output("content"))
|
||||
|
||||
message_end = {}
|
||||
if cpn_obj.get_param("status"):
|
||||
message_end["status"] = cpn_obj.get_param("status")
|
||||
if isinstance(cpn_obj.output("attachment"), dict):
|
||||
message_end["attachment"] = cpn_obj.output("attachment")
|
||||
if cite:
|
||||
message_end["reference"] = self.get_reference()
|
||||
message_end = self._build_message_end(cpn_obj)
|
||||
yield decorate("message_end", message_end)
|
||||
|
||||
while partials:
|
||||
@ -820,6 +812,22 @@ class Canvas(Graph):
|
||||
return {"chunks": {}, "doc_aggs": {}}
|
||||
return self.retrieval[-1]
|
||||
|
||||
def _has_reference(self) -> bool:
|
||||
ref = self.get_reference()
|
||||
if not isinstance(ref, dict):
|
||||
return False
|
||||
return bool(ref.get("chunks") or ref.get("doc_aggs"))
|
||||
|
||||
def _build_message_end(self, cpn_obj) -> dict:
|
||||
message_end = {}
|
||||
if cpn_obj.get_param("status"):
|
||||
message_end["status"] = cpn_obj.get_param("status")
|
||||
if isinstance(cpn_obj.output("attachment"), dict):
|
||||
message_end["attachment"] = cpn_obj.output("attachment")
|
||||
if self._has_reference():
|
||||
message_end["reference"] = self.get_reference()
|
||||
return message_end
|
||||
|
||||
def add_memory(self, user:str, assist:str, summ: str):
|
||||
self.memory.append((user, assist, summ))
|
||||
|
||||
|
||||
@ -35,6 +35,7 @@ from api.db.services.llm_service import LLMBundle
|
||||
from common.metadata_utils import apply_meta_data_filter
|
||||
from api.db.services.tenant_llm_service import TenantLLMService
|
||||
from common.time_utils import current_timestamp, datetime_format
|
||||
from common.text_utils import normalize_arabic_digits
|
||||
from rag.graphrag.general.mind_map_extractor import MindMapExtractor
|
||||
from rag.advanced_rag import DeepResearcher
|
||||
from rag.app.tag import label_question
|
||||
@ -377,10 +378,12 @@ BAD_CITATION_PATTERNS = [
|
||||
re.compile(r"【\s*ID\s*[: ]*\s*(\d+)\s*】"), # 【ID: 12】
|
||||
re.compile(r"ref\s*(\d+)", flags=re.IGNORECASE), # ref12、REF 12
|
||||
]
|
||||
CITATION_MARKER_PATTERN = re.compile(r"\[(?:ID:)?([0-9\u0660-\u0669\u06F0-\u06F9]+)\]")
|
||||
|
||||
|
||||
def repair_bad_citation_formats(answer: str, kbinfos: dict, idx: set):
|
||||
max_index = len(kbinfos["chunks"])
|
||||
normalized_answer = normalize_arabic_digits(answer) or ""
|
||||
|
||||
def safe_add(i):
|
||||
if 0 <= i < max_index:
|
||||
@ -388,19 +391,36 @@ def repair_bad_citation_formats(answer: str, kbinfos: dict, idx: set):
|
||||
return True
|
||||
return False
|
||||
|
||||
def find_and_replace(pattern, group_index=1, repl=lambda i: f"ID:{i}", flags=0):
|
||||
def find_and_replace(pattern, group_index=1, repl=lambda digits: f"ID:{digits}"):
|
||||
nonlocal answer
|
||||
nonlocal normalized_answer
|
||||
|
||||
def replacement(match):
|
||||
matches = list(pattern.finditer(normalized_answer))
|
||||
if not matches:
|
||||
return
|
||||
|
||||
parts = []
|
||||
last_idx = 0
|
||||
for match in matches:
|
||||
parts.append(answer[last_idx:match.start()])
|
||||
try:
|
||||
i = int(match.group(group_index))
|
||||
if safe_add(i):
|
||||
return f"[{repl(i)}]"
|
||||
except Exception:
|
||||
pass
|
||||
return match.group(0)
|
||||
parts.append(answer[match.start():match.end()])
|
||||
last_idx = match.end()
|
||||
continue
|
||||
|
||||
answer = re.sub(pattern, replacement, answer, flags=flags)
|
||||
if safe_add(i):
|
||||
digit_start, digit_end = match.span(group_index)
|
||||
digits_original = answer[digit_start:digit_end]
|
||||
parts.append(f"[{repl(digits_original)}]")
|
||||
else:
|
||||
parts.append(answer[match.start():match.end()])
|
||||
last_idx = match.end()
|
||||
|
||||
parts.append(answer[last_idx:])
|
||||
answer = "".join(parts)
|
||||
normalized_answer = normalize_arabic_digits(answer) or ""
|
||||
|
||||
for pattern in BAD_CITATION_PATTERNS:
|
||||
find_and_replace(pattern)
|
||||
@ -627,7 +647,8 @@ async def async_chat(dialog, messages, stream=True, **kwargs):
|
||||
|
||||
if knowledges and (prompt_config.get("quote", True) and kwargs.get("quote", True)):
|
||||
idx = set([])
|
||||
if embd_mdl and not re.search(r"\[ID:([0-9]+)\]", answer):
|
||||
normalized_answer = normalize_arabic_digits(answer) or ""
|
||||
if embd_mdl and not CITATION_MARKER_PATTERN.search(normalized_answer):
|
||||
answer, idx = retriever.insert_citations(
|
||||
answer,
|
||||
[ck["content_ltks"] for ck in kbinfos["chunks"]],
|
||||
@ -637,7 +658,7 @@ async def async_chat(dialog, messages, stream=True, **kwargs):
|
||||
vtweight=dialog.vector_similarity_weight,
|
||||
)
|
||||
else:
|
||||
for match in re.finditer(r"\[ID:([0-9]+)\]", answer):
|
||||
for match in CITATION_MARKER_PATTERN.finditer(normalized_answer):
|
||||
i = int(match.group(1))
|
||||
if i < len(kbinfos["chunks"]):
|
||||
idx.add(i)
|
||||
|
||||
@ -244,7 +244,7 @@ def init_settings():
|
||||
OAUTH_CONFIG = get_base_config("oauth", {})
|
||||
|
||||
global DOC_ENGINE, DOC_ENGINE_INFINITY, DOC_ENGINE_OCEANBASE, docStoreConn, ES, OB, OS, INFINITY
|
||||
DOC_ENGINE = os.environ.get("DOC_ENGINE", "elasticsearch")
|
||||
DOC_ENGINE = os.environ.get("DOC_ENGINE", "elasticsearch").strip()
|
||||
DOC_ENGINE_INFINITY = (DOC_ENGINE.lower() == "infinity")
|
||||
DOC_ENGINE_OCEANBASE = (DOC_ENGINE.lower() == "oceanbase")
|
||||
lower_case_doc_engine = DOC_ENGINE.lower()
|
||||
|
||||
48
common/text_utils.py
Normal file
48
common/text_utils.py
Normal file
@ -0,0 +1,48 @@
|
||||
#
|
||||
# Copyright 2025 The InfiniFlow Authors. All Rights Reserved.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
ARABIC_PRESENTATION_FORMS_RE = re.compile(r"[\uFB50-\uFDFF\uFE70-\uFEFF]")
|
||||
|
||||
|
||||
def normalize_arabic_digits(text: str | None) -> str | None:
|
||||
if text is None or not isinstance(text, str):
|
||||
return text
|
||||
|
||||
out = []
|
||||
for ch in text:
|
||||
code = ord(ch)
|
||||
if 0x0660 <= code <= 0x0669:
|
||||
out.append(chr(code - 0x0660 + 0x30))
|
||||
elif 0x06F0 <= code <= 0x06F9:
|
||||
out.append(chr(code - 0x06F0 + 0x30))
|
||||
else:
|
||||
out.append(ch)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def normalize_arabic_presentation_forms(text: str | None) -> str | None:
|
||||
"""Normalize Arabic presentation forms to canonical text when present."""
|
||||
if text is None or not isinstance(text, str):
|
||||
return text
|
||||
if not ARABIC_PRESENTATION_FORMS_RE.search(text):
|
||||
return text
|
||||
return unicodedata.normalize("NFKC", text)
|
||||
@ -41,6 +41,7 @@ from deepdoc.parser.docling_parser import DoclingParser
|
||||
from deepdoc.parser.tcadp_parser import TCADPParser
|
||||
from common.float_utils import normalize_overlapped_percent
|
||||
from common.parser_config_utils import normalize_layout_recognizer
|
||||
from common.text_utils import normalize_arabic_presentation_forms
|
||||
from rag.nlp import (
|
||||
concat_img,
|
||||
find_codec,
|
||||
@ -56,6 +57,33 @@ from rag.nlp import (
|
||||
) # noqa: F401
|
||||
|
||||
|
||||
def _normalize_section_text_for_rtl_presentation_forms(sections):
|
||||
if not sections:
|
||||
return sections
|
||||
|
||||
normalized_sections = []
|
||||
for section in sections:
|
||||
if isinstance(section, tuple):
|
||||
if not section:
|
||||
normalized_sections.append(section)
|
||||
continue
|
||||
text = section[0]
|
||||
normalized_text = normalize_arabic_presentation_forms(text)
|
||||
normalized_sections.append((normalized_text, *section[1:]))
|
||||
continue
|
||||
if isinstance(section, list):
|
||||
if not section:
|
||||
normalized_sections.append(section)
|
||||
continue
|
||||
text = section[0]
|
||||
normalized_text = normalize_arabic_presentation_forms(text)
|
||||
normalized_sections.append([normalized_text, *section[1:]])
|
||||
continue
|
||||
normalized_sections.append(normalize_arabic_presentation_forms(section))
|
||||
|
||||
return normalized_sections
|
||||
|
||||
|
||||
def by_deepdoc(filename, binary=None, from_page=0, to_page=100000, lang="Chinese", callback=None, pdf_cls=None, **kwargs):
|
||||
callback = callback
|
||||
binary = binary
|
||||
@ -802,6 +830,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000, lang="Chinese", ca
|
||||
|
||||
# sections = (text, image, tables)
|
||||
sections = Docx()(filename, binary)
|
||||
sections = _normalize_section_text_for_rtl_presentation_forms(sections)
|
||||
|
||||
# chunks list[dict]
|
||||
# images list - index of image chunk in chunks
|
||||
@ -843,6 +872,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000, lang="Chinese", ca
|
||||
paddleocr_llm_name=parser_model_name,
|
||||
**kwargs,
|
||||
)
|
||||
sections = _normalize_section_text_for_rtl_presentation_forms(sections)
|
||||
|
||||
if not sections and not tables:
|
||||
return []
|
||||
@ -873,6 +903,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000, lang="Chinese", ca
|
||||
file_type = "XLSX" if re.search(r"\.xlsx?$", filename, re.IGNORECASE) else "CSV"
|
||||
|
||||
sections, tables = tcadp_parser.parse_pdf(filepath=filename, binary=binary, callback=callback, output_dir=os.environ.get("TCADP_OUTPUT_DIR", ""), file_type=file_type)
|
||||
sections = _normalize_section_text_for_rtl_presentation_forms(sections)
|
||||
parser_config["chunk_token_num"] = 0
|
||||
res = tokenize_table(tables, doc, is_english)
|
||||
callback(0.8, "Finish parsing.")
|
||||
@ -884,10 +915,12 @@ def chunk(filename, binary=None, from_page=0, to_page=100000, lang="Chinese", ca
|
||||
parser_config["chunk_token_num"] = 0
|
||||
else:
|
||||
sections = [(_, "") for _ in excel_parser(binary) if _]
|
||||
sections = _normalize_section_text_for_rtl_presentation_forms(sections)
|
||||
|
||||
elif re.search(r"\.(txt|py|js|java|c|cpp|h|php|go|ts|sh|cs|kt|sql)$", filename, re.IGNORECASE):
|
||||
callback(0.1, "Start to parse.")
|
||||
sections = TxtParser()(filename, binary, parser_config.get("chunk_token_num", 128), parser_config.get("delimiter", "\n!?;。;!?"))
|
||||
sections = _normalize_section_text_for_rtl_presentation_forms(sections)
|
||||
callback(0.8, "Finish parsing.")
|
||||
|
||||
elif re.search(r"\.(md|markdown|mdx)$", filename, re.IGNORECASE):
|
||||
@ -900,6 +933,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000, lang="Chinese", ca
|
||||
delimiter=parser_config.get("delimiter", "\n!?;。;!?"),
|
||||
return_section_images=True,
|
||||
)
|
||||
sections = _normalize_section_text_for_rtl_presentation_forms(sections)
|
||||
|
||||
is_markdown = True
|
||||
|
||||
@ -945,6 +979,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000, lang="Chinese", ca
|
||||
chunk_token_num = int(parser_config.get("chunk_token_num", 128))
|
||||
sections = HtmlParser()(filename, binary, chunk_token_num)
|
||||
sections = [(_, "") for _ in sections if _]
|
||||
sections = _normalize_section_text_for_rtl_presentation_forms(sections)
|
||||
callback(0.8, "Finish parsing.")
|
||||
|
||||
elif re.search(r"\.(json|jsonl|ldjson)$", filename, re.IGNORECASE):
|
||||
@ -952,6 +987,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000, lang="Chinese", ca
|
||||
chunk_token_num = int(parser_config.get("chunk_token_num", 128))
|
||||
sections = JsonParser(chunk_token_num)(binary)
|
||||
sections = [(_, "") for _ in sections if _]
|
||||
sections = _normalize_section_text_for_rtl_presentation_forms(sections)
|
||||
callback(0.8, "Finish parsing.")
|
||||
|
||||
elif re.search(r"\.doc$", filename, re.IGNORECASE):
|
||||
@ -969,6 +1005,7 @@ def chunk(filename, binary=None, from_page=0, to_page=100000, lang="Chinese", ca
|
||||
if doc_parsed.get("content", None) is not None:
|
||||
sections = doc_parsed["content"].split("\n")
|
||||
sections = [(_, "") for _ in sections if _]
|
||||
sections = _normalize_section_text_for_rtl_presentation_forms(sections)
|
||||
callback(0.8, "Finish parsing.")
|
||||
else:
|
||||
error_msg = f"tika.parser got empty content from {filename}."
|
||||
|
||||
@ -193,16 +193,18 @@ class Dealer:
|
||||
i += 1
|
||||
pieces_.append("".join(pieces[st: i]) + "\n")
|
||||
else:
|
||||
# Sentence boundary regex includes Arabic punctuation (، ؛ ؟ ۔)
|
||||
pieces_.extend(
|
||||
re.split(
|
||||
r"([^\|][;。?!!\n]|[a-z][.?;!][ \n])",
|
||||
r"([^\|][;。?!!،؛؟۔\n]|[a-z\u0600-\u06FF][.?;!،؛؟][ \n])",
|
||||
pieces[i]))
|
||||
i += 1
|
||||
pieces = pieces_
|
||||
else:
|
||||
pieces = re.split(r"([^\|][;。?!!\n]|[a-z][.?;!][ \n])", answer)
|
||||
# Sentence boundary regex includes Arabic punctuation (، ؛ ؟ ۔)
|
||||
pieces = re.split(r"([^\|][;。?!!،؛؟۔\n]|[a-z\u0600-\u06FF][.?;!،؛؟][ \n])", answer)
|
||||
for i in range(1, len(pieces)):
|
||||
if re.match(r"([^\|][;。?!!\n]|[a-z][.?;!][ \n])", pieces[i]):
|
||||
if re.match(r"([^\|][;。?!!،؛؟۔\n]|[a-z\u0600-\u06FF][.?;!،؛؟][ \n])", pieces[i]):
|
||||
pieces[i - 1] += pieces[i][0]
|
||||
pieces[i] = pieces[i][1:]
|
||||
idx = []
|
||||
|
||||
@ -9,6 +9,7 @@ Based on the provided document or chat history, add citations to the input text
|
||||
- DO NOT cite content not from <context></context>
|
||||
- DO NOT modify whitespace or original text
|
||||
- STRICTLY prohibit non-standard formatting (~~, etc.)
|
||||
- For RTL languages (Arabic, Hebrew, Persian): Place citations at the logical end of sentences (same position as LTR). The frontend handles bidirectional rendering automatically.
|
||||
|
||||
## What MUST Be Cited:
|
||||
1. **Quantitative data**: Numbers, percentages, statistics, measurements
|
||||
@ -99,6 +100,18 @@ ASSISTANT:
|
||||
Paris is the capital of France. It's known for its rich history, culture, and architecture. The Eiffel Tower was completed in 1889 [ID:301]. The city attracts millions of tourists annually. Paris remains one of the world's most visited destinations.
|
||||
(Note: Only the specific date needs citation, not common knowledge about Paris)
|
||||
|
||||
## Example 6: RTL Language (Arabic)
|
||||
<context>
|
||||
ID: 401
|
||||
└── Content: في أول أيام شهر رمضان، أثار وضع رأس خنزير على مدخل مسجد بمدينة سانت أومير شمالي فرنسا تفاعلات واسعة.
|
||||
</context>
|
||||
|
||||
USER: ماذا حدث في رمضان؟
|
||||
|
||||
ASSISTANT:
|
||||
في أول أيام شهر رمضان، أثار وضع رأس خنزير على مدخل مسجد بمدينة سانت أومير شمالي فرنسا تفاعلات واسعة [ID:401].
|
||||
(Note: Citation is placed at the logical end of the sentence, same as LTR languages. The frontend handles RTL display automatically.)
|
||||
|
||||
--- Examples END ---
|
||||
|
||||
REMEMBER:
|
||||
|
||||
@ -1,8 +1,16 @@
|
||||
import { Toaster as Sonner } from '@/components/ui/sonner';
|
||||
import { Toaster } from '@/components/ui/toaster';
|
||||
import i18n, { changeLanguageAsync } from '@/locales/config';
|
||||
import i18n from '@/locales/config';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { configResponsive } from 'ahooks';
|
||||
import { App, ConfigProvider, ConfigProviderProps, theme } from 'antd';
|
||||
import pt_BR from 'antd/lib/locale/pt_BR';
|
||||
import deDE from 'antd/locale/de_DE';
|
||||
import enUS from 'antd/locale/en_US';
|
||||
import ru_RU from 'antd/locale/ru_RU';
|
||||
import vi_VN from 'antd/locale/vi_VN';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import zh_HK from 'antd/locale/zh_HK';
|
||||
import dayjs from 'dayjs';
|
||||
import advancedFormat from 'dayjs/plugin/advancedFormat';
|
||||
import customParseFormat from 'dayjs/plugin/customParseFormat';
|
||||
@ -10,9 +18,9 @@ import localeData from 'dayjs/plugin/localeData';
|
||||
import weekOfYear from 'dayjs/plugin/weekOfYear';
|
||||
import weekYear from 'dayjs/plugin/weekYear';
|
||||
import weekday from 'dayjs/plugin/weekday';
|
||||
import React, { useEffect } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { RouterProvider } from 'react-router';
|
||||
import { ThemeProvider } from './components/theme-provider';
|
||||
import { ThemeProvider, useTheme } from './components/theme-provider';
|
||||
import { SidebarProvider } from './components/ui/sidebar';
|
||||
import { TooltipProvider } from './components/ui/tooltip';
|
||||
import { ThemeEnum } from './constants/common';
|
||||
@ -38,6 +46,16 @@ dayjs.extend(localeData);
|
||||
dayjs.extend(weekOfYear);
|
||||
dayjs.extend(weekYear);
|
||||
|
||||
const AntLanguageMap = {
|
||||
en: enUS,
|
||||
zh: zhCN,
|
||||
'zh-TRADITIONAL': zh_HK,
|
||||
ru: ru_RU,
|
||||
vi: vi_VN,
|
||||
'pt-BR': pt_BR,
|
||||
de: deDE,
|
||||
};
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
import('@welldone-software/why-did-you-render').then(
|
||||
(whyDidYouRenderModule) => {
|
||||
@ -61,17 +79,19 @@ const queryClient = new QueryClient({
|
||||
},
|
||||
});
|
||||
|
||||
type Locale = ConfigProviderProps['locale'];
|
||||
|
||||
function Root({ children }: React.PropsWithChildren) {
|
||||
useEffect(() => {
|
||||
const lng = storage.getLanguage();
|
||||
if (lng) {
|
||||
document.documentElement.lang = lng;
|
||||
}
|
||||
}, []);
|
||||
const { theme: themeragflow } = useTheme();
|
||||
const getLocale = (lng: string) =>
|
||||
AntLanguageMap[lng as keyof typeof AntLanguageMap] ?? enUS;
|
||||
|
||||
const [locale, setLocal] = useState<Locale>(getLocale(storage.getLanguage()));
|
||||
|
||||
useEffect(() => {
|
||||
const handleLanguageChanged = (lng: string) => {
|
||||
storage.setLanguage(lng);
|
||||
setLocal(getLocale(lng));
|
||||
document.documentElement.lang = lng;
|
||||
};
|
||||
|
||||
@ -81,11 +101,28 @@ function Root({ children }: React.PropsWithChildren) {
|
||||
i18n.off('languageChanged', handleLanguageChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<SidebarProvider className="h-full">
|
||||
<div className="w-full h-dvh relative">{children}</div>
|
||||
</SidebarProvider>
|
||||
<>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
fontFamily:
|
||||
"'Inter', system-ui, -apple-system, 'Segoe UI', sans-serif",
|
||||
},
|
||||
algorithm:
|
||||
themeragflow === 'dark'
|
||||
? theme.darkAlgorithm
|
||||
: theme.defaultAlgorithm,
|
||||
}}
|
||||
locale={locale}
|
||||
>
|
||||
<SidebarProvider className="h-full">
|
||||
<App className="w-full h-dvh relative">{children}</App>
|
||||
</SidebarProvider>
|
||||
<Sonner position={'top-right'} expand richColors closeButton></Sonner>
|
||||
<Toaster />
|
||||
</ConfigProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -93,7 +130,7 @@ const RootProvider = ({ children }: React.PropsWithChildren) => {
|
||||
useEffect(() => {
|
||||
const lng = storage.getLanguage();
|
||||
if (lng) {
|
||||
changeLanguageAsync(lng);
|
||||
i18n.changeLanguage(lng);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@ -105,8 +142,6 @@ const RootProvider = ({ children }: React.PropsWithChildren) => {
|
||||
storageKey="ragflow-ui-theme"
|
||||
>
|
||||
<Root>{children}</Root>
|
||||
<Sonner position={'top-right'} expand richColors closeButton></Sonner>
|
||||
<Toaster />
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
</TooltipProvider>
|
||||
|
||||
@ -8,12 +8,15 @@ import {
|
||||
import { IReference, IReferenceChunk } from '@/interfaces/database/chat';
|
||||
import {
|
||||
currentReg,
|
||||
parseCitationIndex,
|
||||
preprocessLaTeX,
|
||||
replaceTextByOldReg,
|
||||
replaceThinkToSection,
|
||||
showImage,
|
||||
} from '@/utils/chat';
|
||||
import { citationMarkerReg } from '@/utils/citation-utils';
|
||||
import { getExtension } from '@/utils/document-util';
|
||||
import { getDirAttribute } from '@/utils/text-direction';
|
||||
import { InfoCircleOutlined } from '@ant-design/icons';
|
||||
import classNames from 'classnames';
|
||||
import DOMPurify from 'dompurify';
|
||||
@ -41,7 +44,8 @@ import { Button } from './ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
const getChunkIndex = (match: string) => Number(match.replace(/\[|\]/g, ''));
|
||||
const getChunkIndex = (match: string) =>
|
||||
parseCitationIndex(match.replace(/\[|\]/g, ''));
|
||||
|
||||
const FloatingChatWidgetMarkdown = ({
|
||||
reference,
|
||||
@ -281,14 +285,19 @@ const FloatingChatWidgetMarkdown = ({
|
||||
[getPopoverContent, getReferenceInfo, handleDocumentButtonClick],
|
||||
);
|
||||
|
||||
const dir = getDirAttribute(content.replace(citationMarkerReg, ''));
|
||||
|
||||
return (
|
||||
<div className="floating-chat-widget">
|
||||
<div className="floating-chat-widget" dir={dir}>
|
||||
<Markdown
|
||||
rehypePlugins={[rehypeWrapReference, rehypeKatex, rehypeRaw]}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
className="text-sm leading-relaxed space-y-2 prose-sm max-w-full"
|
||||
components={
|
||||
{
|
||||
p: ({ children, node, ...props }: any) => (
|
||||
<p {...props}>{children}</p>
|
||||
),
|
||||
'custom-typography': ({ children }: { children: string }) =>
|
||||
renderReference(children),
|
||||
code(props: any) {
|
||||
|
||||
@ -13,6 +13,8 @@ import remarkMath from 'remark-math';
|
||||
import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you
|
||||
|
||||
import { preprocessLaTeX } from '@/utils/chat';
|
||||
import { citationMarkerReg } from '@/utils/citation-utils';
|
||||
import { getDirAttribute } from '@/utils/text-direction';
|
||||
import { useIsDarkTheme } from '../theme-provider';
|
||||
import styles from './index.module.less';
|
||||
|
||||
@ -22,37 +24,44 @@ const HighLightMarkdown = ({
|
||||
children: string | null | undefined;
|
||||
}) => {
|
||||
const isDarkTheme = useIsDarkTheme();
|
||||
const dir = children
|
||||
? getDirAttribute(children.replace(citationMarkerReg, ''))
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeRaw, rehypeKatex]}
|
||||
className={classNames(styles.text)}
|
||||
components={
|
||||
{
|
||||
code(props: any) {
|
||||
const { children, className, ...rest } = props;
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
{...rest}
|
||||
PreTag="div"
|
||||
language={match[1]}
|
||||
style={isDarkTheme ? oneDark : oneLight}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code {...rest} className={`${className} ${styles.code}`}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
} as any
|
||||
}
|
||||
>
|
||||
{children ? preprocessLaTeX(children) : children}
|
||||
</Markdown>
|
||||
<div dir={dir} className={classNames(styles.text)}>
|
||||
<Markdown
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
rehypePlugins={[rehypeRaw, rehypeKatex]}
|
||||
components={
|
||||
{
|
||||
p: ({ children, node, ...props }: any) => (
|
||||
<p {...props}>{children}</p>
|
||||
),
|
||||
code(props: any) {
|
||||
const { children, className, ...rest } = props;
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
{...rest}
|
||||
PreTag="div"
|
||||
language={match[1]}
|
||||
style={isDarkTheme ? oneDark : oneLight}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code {...rest} className={`${className} ${styles.code}`}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
} as any
|
||||
}
|
||||
>
|
||||
{children ? preprocessLaTeX(children) : children}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -44,7 +44,10 @@ export function HomeCard({
|
||||
<div className="flex flex-col justify-between gap-1 flex-1 h-full w-[calc(100%-50px)]">
|
||||
<section className="flex justify-between">
|
||||
<section className="flex flex-1 min-w-0 gap-1 items-center">
|
||||
<div className="text-base font-bold leading-snug truncate" data-testid="agent-name">
|
||||
<div
|
||||
className="text-base font-bold leading-snug truncate"
|
||||
data-testid="agent-name"
|
||||
>
|
||||
{data.name}
|
||||
</div>
|
||||
{icon}
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
.markdownContentWrapper {
|
||||
:global(section.think) {
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
color: #8b8b8b;
|
||||
border-left: 2px solid #d5d3d3;
|
||||
border-inline-start: 2px solid #d5d3d3;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
:global(blockquote) {
|
||||
padding-left: 10px;
|
||||
border-left: 4px solid #ccc;
|
||||
padding-inline-start: 10px;
|
||||
border-inline-start: 4px solid #ccc;
|
||||
}
|
||||
|
||||
// RTL Support
|
||||
&[dir='rtl'] {
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import Image from '@/components/image';
|
||||
import SvgIcon from '@/components/svg-icon';
|
||||
import { IReference, IReferenceChunk } from '@/interfaces/database/chat';
|
||||
import { citationMarkerReg } from '@/utils/citation-utils';
|
||||
import { getExtension } from '@/utils/document-util';
|
||||
import { getDirAttribute } from '@/utils/text-direction';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
import Markdown from 'react-markdown';
|
||||
@ -19,6 +21,7 @@ import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for
|
||||
import { useFetchDocumentThumbnailsByIds } from '@/hooks/use-document-request';
|
||||
import {
|
||||
currentReg,
|
||||
parseCitationIndex,
|
||||
preprocessLaTeX,
|
||||
replaceTextByOldReg,
|
||||
replaceThinkToSection,
|
||||
@ -35,7 +38,7 @@ import {
|
||||
} from '../ui/hover-card';
|
||||
import styles from './index.module.less';
|
||||
|
||||
const getChunkIndex = (match: string) => Number(match);
|
||||
const getChunkIndex = (match: string) => parseCitationIndex(match);
|
||||
|
||||
// TODO: The display of the table is inconsistent with the display previously placed in the MessageItem.
|
||||
const MarkdownContent = ({
|
||||
@ -169,6 +172,7 @@ const MarkdownContent = ({
|
||||
__html: DOMPurify.sanitize(chunkItem?.content ?? ''),
|
||||
}}
|
||||
className={classNames(styles.chunkContentText)}
|
||||
dir="auto"
|
||||
></div>
|
||||
{documentId && (
|
||||
<section className="flex gap-1">
|
||||
@ -213,9 +217,9 @@ const MarkdownContent = ({
|
||||
return (
|
||||
<HoverCard key={i}>
|
||||
<HoverCardTrigger>
|
||||
<span className="text-text-secondary bg-bg-card rounded-2xl px-1 mx-1 text-nowrap">
|
||||
<bdi className="text-text-secondary bg-bg-card rounded-2xl px-1 mx-1 text-nowrap inline-block">
|
||||
Fig. {chunkIndex + 1}
|
||||
</span>
|
||||
</bdi>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="max-w-3xl">
|
||||
{getPopoverContent(chunkIndex)}
|
||||
@ -229,42 +233,48 @@ const MarkdownContent = ({
|
||||
[getPopoverContent],
|
||||
);
|
||||
|
||||
const dir = getDirAttribute(content.replace(citationMarkerReg, ''));
|
||||
|
||||
return (
|
||||
<Markdown
|
||||
rehypePlugins={[rehypeWrapReference, rehypeKatex, rehypeRaw]}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
className={styles.markdownContentWrapper}
|
||||
components={
|
||||
{
|
||||
'custom-typography': ({ children }: { children: string }) =>
|
||||
renderReference(children),
|
||||
code(props: any) {
|
||||
const { children, className, ...rest } = props;
|
||||
const restProps = omit(rest, 'node');
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
{...restProps}
|
||||
PreTag="div"
|
||||
language={match[1]}
|
||||
wrapLongLines
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
{...restProps}
|
||||
className={classNames(className, 'text-wrap')}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
} as any
|
||||
}
|
||||
>
|
||||
{contentWithCursor}
|
||||
</Markdown>
|
||||
<div dir={dir} className={styles.markdownContentWrapper}>
|
||||
<Markdown
|
||||
rehypePlugins={[rehypeWrapReference, rehypeKatex, rehypeRaw]}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
components={
|
||||
{
|
||||
p: ({ children, node, ...props }: any) => (
|
||||
<p {...props}>{children}</p>
|
||||
),
|
||||
'custom-typography': ({ children }: { children: string }) =>
|
||||
renderReference(children),
|
||||
code(props: any) {
|
||||
const { children, className, ...rest } = props;
|
||||
const restProps = omit(rest, 'node');
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
{...restProps}
|
||||
PreTag="div"
|
||||
language={match[1]}
|
||||
wrapLongLines
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
{...restProps}
|
||||
className={classNames(className, 'text-wrap')}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
} as any
|
||||
}
|
||||
>
|
||||
{contentWithCursor}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { IReference } from '@/interfaces/database/chat';
|
||||
import { currentReg, showImage } from '@/utils/chat';
|
||||
import { currentReg, normalizeCitationDigits, showImage } from '@/utils/chat';
|
||||
|
||||
export interface ReferenceMatch {
|
||||
id: string;
|
||||
@ -15,7 +15,7 @@ export const findAllReferenceMatches = (text: string): ReferenceMatch[] => {
|
||||
let match;
|
||||
while ((match = currentReg.exec(text)) !== null) {
|
||||
matches.push({
|
||||
id: match[1],
|
||||
id: normalizeCitationDigits(match[1]),
|
||||
fullMatch: match[0],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
|
||||
@ -268,7 +268,11 @@ export function NextMessageInput({
|
||||
</div>
|
||||
|
||||
{sendLoading ? (
|
||||
<Button data-testid="chat-stream-status" onClick={stopOutputMessage} size="icon-xs">
|
||||
<Button
|
||||
data-testid="chat-stream-status"
|
||||
onClick={stopOutputMessage}
|
||||
size="icon-xs"
|
||||
>
|
||||
<CircleStop />
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
@ -1,14 +1,19 @@
|
||||
.markdownContentWrapper {
|
||||
:global(section.think) {
|
||||
padding-left: 10px;
|
||||
padding-inline-start: 10px;
|
||||
color: #8b8b8b;
|
||||
border-left: 2px solid #d5d3d3;
|
||||
border-inline-start: 2px solid #d5d3d3;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
:global(blockquote) {
|
||||
padding-left: 10px;
|
||||
border-left: 4px solid #ccc;
|
||||
padding-inline-start: 10px;
|
||||
border-inline-start: 4px solid #ccc;
|
||||
}
|
||||
|
||||
// RTL Support
|
||||
&[dir='rtl'] {
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,6 +41,11 @@
|
||||
.chunkText;
|
||||
max-height: 45vh;
|
||||
overflow-y: auto;
|
||||
|
||||
// RTL Support
|
||||
&[dir='rtl'] {
|
||||
text-align: start;
|
||||
}
|
||||
}
|
||||
.documentLink {
|
||||
padding: 0;
|
||||
|
||||
@ -18,10 +18,13 @@ import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for
|
||||
|
||||
import {
|
||||
currentReg,
|
||||
parseCitationIndex,
|
||||
preprocessLaTeX,
|
||||
replaceTextByOldReg,
|
||||
replaceThinkToSection,
|
||||
} from '@/utils/chat';
|
||||
import { citationMarkerReg } from '@/utils/citation-utils';
|
||||
import { getDirAttribute } from '@/utils/text-direction';
|
||||
|
||||
import { useFetchDocumentThumbnailsByIds } from '@/hooks/use-document-request';
|
||||
import { cn } from '@/lib/utils';
|
||||
@ -37,7 +40,7 @@ import {
|
||||
} from '../ui/hover-card';
|
||||
import styles from './index.module.less';
|
||||
|
||||
const getChunkIndex = (match: string) => Number(match);
|
||||
const getChunkIndex = (match: string) => parseCitationIndex(match);
|
||||
// TODO: The display of the table is inconsistent with the display previously placed in the MessageItem.
|
||||
function MarkdownContent({
|
||||
reference,
|
||||
@ -171,6 +174,7 @@ function MarkdownContent({
|
||||
__html: DOMPurify.sanitize(chunkItem?.content ?? ''),
|
||||
}}
|
||||
className={classNames(styles.chunkContentText, 'w-full')}
|
||||
dir="auto"
|
||||
></div>
|
||||
{documentId && (
|
||||
<div className="flex gap-1">
|
||||
@ -215,9 +219,9 @@ function MarkdownContent({
|
||||
return (
|
||||
<HoverCard key={i}>
|
||||
<HoverCardTrigger>
|
||||
<span className="text-text-secondary bg-bg-card rounded-2xl px-1 mx-1 text-nowrap">
|
||||
<bdi className="text-text-secondary bg-bg-card rounded-2xl px-1 mx-1 text-nowrap inline-block">
|
||||
Fig. {chunkIndex + 1}
|
||||
</span>
|
||||
</bdi>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="max-w-3xl">
|
||||
{renderPopoverContent(chunkIndex)}
|
||||
@ -231,42 +235,48 @@ function MarkdownContent({
|
||||
[renderPopoverContent],
|
||||
);
|
||||
|
||||
const dir = getDirAttribute(content.replace(citationMarkerReg, ''));
|
||||
|
||||
return (
|
||||
<Markdown
|
||||
rehypePlugins={[rehypeWrapReference, rehypeKatex, rehypeRaw]}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
className={styles.markdownContentWrapper}
|
||||
components={
|
||||
{
|
||||
'custom-typography': ({ children }: { children: string }) =>
|
||||
renderReference(children),
|
||||
code(props: any) {
|
||||
const { children, className, ...rest } = props;
|
||||
const restProps = omit(rest, 'node');
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
{...restProps}
|
||||
PreTag="div"
|
||||
language={match[1]}
|
||||
wrapLongLines
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
{...restProps}
|
||||
className={classNames(className, 'text-wrap')}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
} as any
|
||||
}
|
||||
>
|
||||
{contentWithCursor}
|
||||
</Markdown>
|
||||
<div dir={dir} className={styles.markdownContentWrapper}>
|
||||
<Markdown
|
||||
rehypePlugins={[rehypeWrapReference, rehypeKatex, rehypeRaw]}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
components={
|
||||
{
|
||||
p: ({ children, node, ...props }: any) => (
|
||||
<p {...props}>{children}</p>
|
||||
),
|
||||
'custom-typography': ({ children }: { children: string }) =>
|
||||
renderReference(children),
|
||||
code(props: any) {
|
||||
const { children, className, ...rest } = props;
|
||||
const restProps = omit(rest, 'node');
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
{...restProps}
|
||||
PreTag="div"
|
||||
language={match[1]}
|
||||
wrapLongLines
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
{...restProps}
|
||||
className={classNames(className, 'text-wrap')}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
} as any
|
||||
}
|
||||
>
|
||||
{contentWithCursor}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -27,6 +27,11 @@
|
||||
.chunkText();
|
||||
.messageTextBase();
|
||||
word-break: break-word;
|
||||
|
||||
// RTL Support
|
||||
&[dir='rtl'] {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
.messageTextDark {
|
||||
.chunkText();
|
||||
@ -36,6 +41,21 @@
|
||||
color: rgb(166, 166, 166);
|
||||
border-left-color: rgb(78, 78, 86);
|
||||
}
|
||||
|
||||
// RTL Support
|
||||
&[dir='rtl'] {
|
||||
text-align: right;
|
||||
|
||||
:global(section.think) {
|
||||
border-left-color: transparent;
|
||||
border-right-color: rgb(78, 78, 86);
|
||||
border-right-width: 2px;
|
||||
border-right-style: solid;
|
||||
border-left: none;
|
||||
padding-left: 0;
|
||||
padding-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.messageUserText {
|
||||
@ -43,6 +63,11 @@
|
||||
.messageTextBase();
|
||||
word-break: break-word;
|
||||
text-align: justify;
|
||||
|
||||
// RTL Support
|
||||
&[dir='rtl'] {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
.messageEmpty {
|
||||
width: 300px;
|
||||
|
||||
@ -21,6 +21,8 @@ import { INodeEvent, MessageEventType } from '@/hooks/use-send-message';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { AgentChatContext } from '@/pages/agent/context';
|
||||
import { WorkFlowTimeline } from '@/pages/agent/log-sheet/workflow-timeline';
|
||||
import { citationMarkerReg } from '@/utils/citation-utils';
|
||||
import { getDirAttribute } from '@/utils/text-direction';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { Atom, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import MarkdownContent from '../next-markdown-content';
|
||||
@ -149,6 +151,7 @@ function MessageItem({
|
||||
[styles.messageUserText]: !isAssistant,
|
||||
'bg-bg-card': !isAssistant,
|
||||
})}
|
||||
dir={getDirAttribute(messageContent.replace(citationMarkerReg, ''))}
|
||||
>
|
||||
{item.data ? (
|
||||
children
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { currentReg } from '@/utils/chat';
|
||||
import { currentReg, parseCitationIndex } from '@/utils/chat';
|
||||
|
||||
export const extractNumbersFromMessageContent = (content: string) => {
|
||||
const matches = content.match(currentReg);
|
||||
if (matches) {
|
||||
const list = matches
|
||||
.map((match) => {
|
||||
const numMatch = match.match(/\[ID:(\d+)\]/);
|
||||
return numMatch ? parseInt(numMatch[1], 10) : null;
|
||||
const parsed = parseCitationIndex(match);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
})
|
||||
.filter((num) => num !== null) as number[];
|
||||
|
||||
|
||||
@ -164,7 +164,9 @@ export const SelectWithSearch = forwardRef<
|
||||
>
|
||||
{selectLabel || value ? (
|
||||
<span className="flex min-w-0 options-center gap-2">
|
||||
<span className="leading-none truncate">{selectLabel || value}</span>
|
||||
<span className="leading-none truncate">
|
||||
{selectLabel || value}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-text-disabled">{placeholder}</span>
|
||||
|
||||
@ -33,7 +33,12 @@ export function RenameDialog({
|
||||
onOk={onOk}
|
||||
></RenameForm>
|
||||
<DialogFooter>
|
||||
<ButtonLoading data-testid="rename-save" type="submit" form={TagRenameId} loading={loading}>
|
||||
<ButtonLoading
|
||||
data-testid="rename-save"
|
||||
type="submit"
|
||||
form={TagRenameId}
|
||||
loading={loading}
|
||||
>
|
||||
{t('common.save')}
|
||||
</ButtonLoading>
|
||||
</DialogFooter>
|
||||
|
||||
@ -471,7 +471,11 @@ export const MultiSelect = React.forwardRef<
|
||||
key={option.value}
|
||||
isSelected={isSelected}
|
||||
toggleOption={toggleOption}
|
||||
optionTestId={optionTestIdPrefix ? `${optionTestIdPrefix}-option-${idx}` : undefined}
|
||||
optionTestId={
|
||||
optionTestIdPrefix
|
||||
? `${optionTestIdPrefix}-option-${idx}`
|
||||
: undefined
|
||||
}
|
||||
></MultiCommandItem>
|
||||
);
|
||||
},
|
||||
@ -489,7 +493,11 @@ export const MultiSelect = React.forwardRef<
|
||||
key={option.value}
|
||||
isSelected={isSelected}
|
||||
toggleOption={toggleOption}
|
||||
optionTestId={optionTestIdPrefix ? `${optionTestIdPrefix}-option-${optIdx}` : undefined}
|
||||
optionTestId={
|
||||
optionTestIdPrefix
|
||||
? `${optionTestIdPrefix}-option-${optIdx}`
|
||||
: undefined
|
||||
}
|
||||
></MultiCommandItem>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -6,7 +6,12 @@ html {
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Inter;
|
||||
font-family:
|
||||
'Inter',
|
||||
system-ui,
|
||||
-apple-system,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@ -140,7 +140,10 @@ export function Header() {
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<section className="py-5 px-10 flex justify-between items-center " data-testid="top-nav">
|
||||
<section
|
||||
className="py-5 px-10 flex justify-between items-center "
|
||||
data-testid="top-nav"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={'/logo.svg'}
|
||||
@ -158,7 +161,10 @@ export function Header() {
|
||||
onChange={handleChange}
|
||||
activeClassName="text-bg-base bg-metallic-gradient border-b-[#00BEB4] border-b-2"
|
||||
></Segmented>
|
||||
<div className="flex items-center gap-5 text-text-badge" data-testid="auth-status">
|
||||
<div
|
||||
className="flex items-center gap-5 text-text-badge"
|
||||
data-testid="auth-status"
|
||||
>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://discord.com/invite/NjYzJD3GM3"
|
||||
|
||||
@ -40,6 +40,18 @@
|
||||
tr:nth-child(even) {
|
||||
background-color: #f2f2f22a;
|
||||
}
|
||||
|
||||
// RTL Support for tables
|
||||
&[dir='rtl'] {
|
||||
table {
|
||||
direction: rtl;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pointerCursor() {
|
||||
|
||||
@ -1034,19 +1034,18 @@ Beispiel: Virtual Hosted Style`,
|
||||
'Die vollstaendige URL Ihres SeaFile-Servers inklusive Protokoll. Beispiel: https://seafile.example.com - Kein abschliessender Schraegstrich und kein Pfad nach der Domain.',
|
||||
seafileAccountScopeTip:
|
||||
'Synchronisiert alle Bibliotheken, die für den unten angegebenen Konto-API-Token sichtbar sind.',
|
||||
seafileTokenPanelHeading:
|
||||
seafileTokenPanelHeading:
|
||||
'Wählen Sie eine der folgenden Authentifizierungsmethoden:',
|
||||
seafileTokenPanelAccountBullet:
|
||||
seafileTokenPanelAccountBullet:
|
||||
'- gewährt Zugriff auf alle Ihre Bibliotheken.',
|
||||
seafileTokenPanelLibraryBullet:
|
||||
seafileTokenPanelLibraryBullet:
|
||||
'- auf eine einzelne Bibliothek beschränkt (sicherer).',
|
||||
seafileValidationAccountTokenRequired:
|
||||
'Konto-API-Token ist erforderlich für den Umfang „Gesamtes Konto"',
|
||||
seafileValidationTokenRequired:
|
||||
'Geben Sie entweder einen Konto-API-Token oder einen Bibliotheks-Token an',
|
||||
seafileValidationLibraryIdRequired:
|
||||
'Bibliotheks-ID ist erforderlich',
|
||||
seafileValidationDirectoryPathRequired:
|
||||
seafileValidationLibraryIdRequired: 'Bibliotheks-ID ist erforderlich',
|
||||
seafileValidationDirectoryPathRequired:
|
||||
'Verzeichnispfad ist erforderlich',
|
||||
seafileSyncScopeTip:
|
||||
'Legt fest, was synchronisiert wird: ' +
|
||||
|
||||
@ -239,7 +239,8 @@
|
||||
|
||||
.configValue {
|
||||
color: #333;
|
||||
word-break: break-all;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -245,7 +245,11 @@ export default function Agent() {
|
||||
>
|
||||
<MessageSquareCode /> {t('flow.conversationVariable')}
|
||||
</ButtonLoading>
|
||||
<Button data-testid="agent-run" variant={'secondary'} onClick={handleButtonRunClick}>
|
||||
<Button
|
||||
data-testid="agent-run"
|
||||
variant={'secondary'}
|
||||
onClick={handleButtonRunClick}
|
||||
>
|
||||
<CirclePlay />
|
||||
{t('flow.run')}
|
||||
</Button>
|
||||
|
||||
@ -32,7 +32,12 @@ export function CreateAgentDialog({
|
||||
shouldChooseAgent={shouldChooseAgent}
|
||||
></CreateAgentForm>
|
||||
<DialogFooter>
|
||||
<ButtonLoading data-testid="agent-save" type="submit" form={TagRenameId} loading={loading}>
|
||||
<ButtonLoading
|
||||
data-testid="agent-save"
|
||||
type="submit"
|
||||
form={TagRenameId}
|
||||
loading={loading}
|
||||
>
|
||||
{t('common.save')}
|
||||
</ButtonLoading>
|
||||
</DialogFooter>
|
||||
|
||||
@ -118,7 +118,10 @@ export default function Agents() {
|
||||
</EmptyAppCard>
|
||||
</div>
|
||||
)}
|
||||
<section className="flex flex-col w-full flex-1" data-testid="agents-list">
|
||||
<section
|
||||
className="flex flex-col w-full flex-1"
|
||||
data-testid="agents-list"
|
||||
>
|
||||
{(!!data?.length || searchString) && (
|
||||
<>
|
||||
<div className="px-8 pt-8 ">
|
||||
|
||||
@ -17,7 +17,11 @@ export function NameFormField() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<RAGFlowFormItem name="name" required label={t('common.name')}>
|
||||
<Input data-testid="agent-name-input" placeholder={t('common.namePlaceholder')} autoComplete="off" />
|
||||
<Input
|
||||
data-testid="agent-name-input"
|
||||
placeholder={t('common.namePlaceholder')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</RAGFlowFormItem>
|
||||
);
|
||||
}
|
||||
|
||||
@ -26,7 +26,12 @@ export function UploadAgentDialog({
|
||||
</DialogHeader>
|
||||
<UploadAgentForm hideModal={hideModal} onOk={onOk}></UploadAgentForm>
|
||||
<DialogFooter>
|
||||
<ButtonLoading data-testid="agent-import-save" type="submit" form={TagRenameId} loading={loading}>
|
||||
<ButtonLoading
|
||||
data-testid="agent-import-save"
|
||||
type="submit"
|
||||
form={TagRenameId}
|
||||
loading={loading}
|
||||
>
|
||||
{t('common.save')}
|
||||
</ButtonLoading>
|
||||
</DialogFooter>
|
||||
|
||||
@ -21,7 +21,8 @@
|
||||
}
|
||||
|
||||
.contentText {
|
||||
word-break: break-all !important;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.chunkCard {
|
||||
|
||||
@ -19,7 +19,8 @@
|
||||
}
|
||||
|
||||
.contentText {
|
||||
word-break: break-all !important;
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.chunkCard {
|
||||
|
||||
@ -16,11 +16,16 @@ import {
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useTranslate } from '@/hooks/common-hooks';
|
||||
import { getDirAttribute } from '@/utils/text-direction';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
|
||||
export default function ChatBasicSetting() {
|
||||
const { t } = useTranslate('chat');
|
||||
const form = useFormContext();
|
||||
const nameValue = form.watch('name');
|
||||
const descriptionValue = form.watch('description');
|
||||
const emptyResponseValue = form.watch('prompt_config.empty_response');
|
||||
const prologueValue = form.watch('prompt_config.prologue');
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@ -46,7 +51,7 @@ export default function ChatBasicSetting() {
|
||||
<FormItem>
|
||||
<FormLabel required>{t('assistantName')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field}></Input>
|
||||
<Input {...field} dir={getDirAttribute(nameValue || '')}></Input>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -59,7 +64,10 @@ export default function ChatBasicSetting() {
|
||||
<FormItem>
|
||||
<FormLabel>{t('description')}</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field}></Textarea>
|
||||
<Textarea
|
||||
{...field}
|
||||
dir={getDirAttribute(descriptionValue || '')}
|
||||
></Textarea>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -74,7 +82,10 @@ export default function ChatBasicSetting() {
|
||||
{t('emptyResponse')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field}></Textarea>
|
||||
<Textarea
|
||||
{...field}
|
||||
dir={getDirAttribute(emptyResponseValue || '')}
|
||||
></Textarea>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@ -89,7 +100,10 @@ export default function ChatBasicSetting() {
|
||||
{t('setAnOpener')}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea {...field}></Textarea>
|
||||
<Textarea
|
||||
{...field}
|
||||
dir={getDirAttribute(prologueValue || '')}
|
||||
></Textarea>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
||||
@ -15,12 +15,14 @@ import {
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { UseKnowledgeGraphFormField } from '@/components/use-knowledge-graph-item';
|
||||
import { useTranslate } from '@/hooks/common-hooks';
|
||||
import { getDirAttribute } from '@/utils/text-direction';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { DynamicVariableForm } from './dynamic-variable';
|
||||
|
||||
export function ChatPromptEngine() {
|
||||
const { t } = useTranslate('chat');
|
||||
const form = useFormContext();
|
||||
const systemPromptValue = form.watch('prompt_config.system');
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
@ -36,6 +38,7 @@ export function ChatPromptEngine() {
|
||||
rows={8}
|
||||
placeholder={t('messagePlaceholder')}
|
||||
className="overflow-y-auto"
|
||||
dir={getDirAttribute(systemPromptValue || '')}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
import { BlurInput } from '@/components/ui/input';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { getDirAttribute } from '@/utils/text-direction';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
@ -58,53 +59,58 @@ export function DynamicVariableForm() {
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-subgrid items-center col-span-4 gap-y-4">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="contents">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`${name}.${index}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<BlurInput
|
||||
{...field}
|
||||
placeholder={t('common.pleaseInput')}
|
||||
></BlurInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{fields.map((field, index) => {
|
||||
const typeField = `${name}.${index}.key`;
|
||||
const keyValue = form.watch(typeField);
|
||||
return (
|
||||
<div key={field.id} className="contents">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`${name}.${index}.key`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<BlurInput
|
||||
{...field}
|
||||
placeholder={t('common.pleaseInput')}
|
||||
dir={getDirAttribute(keyValue || '')}
|
||||
></BlurInput>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Separator className="w-3 text-text-secondary" />
|
||||
<Separator className="w-3 text-text-secondary" />
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`${name}.${index}.optional`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
></Switch>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`${name}.${index}.optional`}
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
></Switch>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="border-0"
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<X className="text-text-sub-title-invert " />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="border-0"
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<X className="text-text-sub-title-invert " />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -9,7 +9,11 @@ export function SavingButton({ loading }: SaveButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<ButtonLoading data-testid="chat-settings-save" type="submit" loading={loading}>
|
||||
<ButtonLoading
|
||||
data-testid="chat-settings-save"
|
||||
type="submit"
|
||||
loading={loading}
|
||||
>
|
||||
{t('common.save')}
|
||||
</ButtonLoading>
|
||||
);
|
||||
|
||||
@ -18,10 +18,13 @@ import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for
|
||||
|
||||
import {
|
||||
currentReg,
|
||||
parseCitationIndex,
|
||||
preprocessLaTeX,
|
||||
replaceTextByOldReg,
|
||||
replaceThinkToSection,
|
||||
} from '@/utils/chat';
|
||||
import { citationMarkerReg } from '@/utils/citation-utils';
|
||||
import { getDirAttribute } from '@/utils/text-direction';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@ -46,7 +49,7 @@ const styles = {
|
||||
fileThumbnail: 'inline-block max-w-[40px]',
|
||||
};
|
||||
|
||||
const getChunkIndex = (match: string) => Number(match);
|
||||
const getChunkIndex = (match: string) => parseCitationIndex(match);
|
||||
|
||||
// TODO: The display of the table is inconsistent with the display previously placed in the MessageItem.
|
||||
const MarkdownContent = ({
|
||||
@ -234,42 +237,51 @@ const MarkdownContent = ({
|
||||
[getPopoverContent],
|
||||
);
|
||||
|
||||
const dir = getDirAttribute(content.replace(citationMarkerReg, ''));
|
||||
|
||||
return (
|
||||
<Markdown
|
||||
rehypePlugins={[rehypeWrapReference, rehypeKatex, rehypeRaw]}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
<div
|
||||
dir={dir}
|
||||
className="[&>section.think]:pl-[10px] [&>section.think]:text-[#8b8b8b] [&>section.think]:border-l-2 [&>section.think]:border-l-[#d5d3d3] [&>section.think]:mb-[10px] [&>section.think]:text-xs [&>blockquote]:pl-[10px] [&>blockquote]:border-l-4 [&>blockquote]:border-l-[#ccc] text-sm"
|
||||
components={
|
||||
{
|
||||
'custom-typography': ({ children }: { children: string }) =>
|
||||
renderReference(children),
|
||||
code(props: any) {
|
||||
const { children, className, ...rest } = props;
|
||||
const restProps = omit(rest, 'node');
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
{...restProps}
|
||||
PreTag="div"
|
||||
language={match[1]}
|
||||
wrapLongLines
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
{...restProps}
|
||||
className={classNames(className, 'text-wrap')}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
} as any
|
||||
}
|
||||
>
|
||||
{contentWithCursor}
|
||||
</Markdown>
|
||||
<Markdown
|
||||
rehypePlugins={[rehypeWrapReference, rehypeKatex, rehypeRaw]}
|
||||
remarkPlugins={[remarkGfm, remarkMath]}
|
||||
components={
|
||||
{
|
||||
p: ({ children, node, ...props }: any) => (
|
||||
<p {...props}>{children}</p>
|
||||
),
|
||||
'custom-typography': ({ children }: { children: string }) =>
|
||||
renderReference(children),
|
||||
code(props: any) {
|
||||
const { children, className, ...rest } = props;
|
||||
const restProps = omit(rest, 'node');
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
return match ? (
|
||||
<SyntaxHighlighter
|
||||
{...restProps}
|
||||
PreTag="div"
|
||||
language={match[1]}
|
||||
wrapLongLines
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
) : (
|
||||
<code
|
||||
{...restProps}
|
||||
className={classNames(className, 'text-wrap')}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
} as any
|
||||
}
|
||||
>
|
||||
{contentWithCursor}
|
||||
</Markdown>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@ -586,7 +586,11 @@ const SearchSetting: React.FC<SearchSettingProps> = ({
|
||||
>
|
||||
{t('search.cancelText')}
|
||||
</Button>
|
||||
<Button data-testid="search-settings-save" type="submit" disabled={formSubmitLoading}>
|
||||
<Button
|
||||
data-testid="search-settings-save"
|
||||
type="submit"
|
||||
disabled={formSubmitLoading}
|
||||
>
|
||||
{formSubmitLoading && (
|
||||
<div className="size-4">
|
||||
<Spin size="small" />
|
||||
|
||||
@ -14,6 +14,8 @@ import {
|
||||
import { RAGFlowPagination } from '@/components/ui/ragflow-pagination';
|
||||
import { IReference } from '@/interfaces/database/chat';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { citationMarkerReg } from '@/utils/citation-utils';
|
||||
import { getDirAttribute } from '@/utils/text-direction';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { BrainCircuit, Search, X } from 'lucide-react';
|
||||
@ -26,6 +28,10 @@ import './index.less';
|
||||
import MarkdownContent from './markdown-content';
|
||||
import MindMapDrawer from './mindmap-drawer';
|
||||
import RetrievalDocuments from './retrieval-documents';
|
||||
|
||||
const getDirectionText = (content: string) =>
|
||||
content.replace(/<[^>]+>/g, ' ').replace(citationMarkerReg, '');
|
||||
|
||||
export default function SearchingView({
|
||||
setIsSearching,
|
||||
searchData,
|
||||
@ -219,6 +225,13 @@ export default function SearchingView({
|
||||
),
|
||||
}}
|
||||
className="text-sm text-text-primary mb-1"
|
||||
dir={getDirAttribute(
|
||||
getDirectionText(
|
||||
chunk.highlight ??
|
||||
chunk.content_with_weight ??
|
||||
'',
|
||||
),
|
||||
)}
|
||||
></div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="text-text-primary !w-full max-w-lg ">
|
||||
|
||||
@ -12,7 +12,7 @@ import { IDataSourceInfoMap } from '../interface';
|
||||
import { bitbucketConstant } from './bitbucket-constant';
|
||||
import { confluenceConstant } from './confluence-constant';
|
||||
import { S3Constant } from './s3-constant';
|
||||
import { seafileConstant } from './seafile-constant';
|
||||
import { seafileConstant } from './seafile-constant';
|
||||
|
||||
export enum DataSourceKey {
|
||||
CONFLUENCE = 'confluence',
|
||||
@ -1222,14 +1222,14 @@ export const DataSourceFormDefaultValues = {
|
||||
source: DataSourceKey.SEAFILE,
|
||||
config: {
|
||||
seafile_url: '',
|
||||
sync_scope: 'account',
|
||||
repo_id: '',
|
||||
sync_path: '',
|
||||
include_shared: true,
|
||||
sync_scope: 'account',
|
||||
repo_id: '',
|
||||
sync_path: '',
|
||||
include_shared: true,
|
||||
batch_size: 100,
|
||||
credentials: {
|
||||
seafile_token: '',
|
||||
repo_token: '',
|
||||
seafile_token: '',
|
||||
repo_token: '',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -37,24 +37,24 @@ export const seafileConstant = (t: TFunction) => [
|
||||
),
|
||||
},
|
||||
{
|
||||
label: 'Account API Token',
|
||||
name: 'config.credentials.seafile_token',
|
||||
type: FormFieldType.Password,
|
||||
required: false,
|
||||
defaultValue: '',
|
||||
tooltip: t('setting.seafileTokenTip'),
|
||||
shouldRender: (formValues: any) => {
|
||||
const scope = formValues?.config?.sync_scope ?? 'account';
|
||||
return scope === 'account';
|
||||
label: 'Account API Token',
|
||||
name: 'config.credentials.seafile_token',
|
||||
type: FormFieldType.Password,
|
||||
required: false,
|
||||
defaultValue: '',
|
||||
tooltip: t('setting.seafileTokenTip'),
|
||||
shouldRender: (formValues: any) => {
|
||||
const scope = formValues?.config?.sync_scope ?? 'account';
|
||||
return scope === 'account';
|
||||
},
|
||||
customValidate: (val: string, formValues: any) => {
|
||||
const scope = formValues?.config?.sync_scope ?? 'account';
|
||||
if ((!val || val.trim() === '') && scope === 'account') {
|
||||
return t('setting.seafileValidationAccountTokenRequired');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
customValidate: (val: string, formValues: any) => {
|
||||
const scope = formValues?.config?.sync_scope ?? 'account';
|
||||
if ((!val || val.trim() === '') && scope === 'account') {
|
||||
return t('setting.seafileValidationAccountTokenRequired');
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Include Shared Libraries',
|
||||
name: 'config.include_shared',
|
||||
@ -68,7 +68,6 @@ export const seafileConstant = (t: TFunction) => [
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
{
|
||||
// Contextual info panel explaining the two-token choice
|
||||
name: FilterFormField + '.token-tip',
|
||||
@ -80,11 +79,13 @@ export const seafileConstant = (t: TFunction) => [
|
||||
},
|
||||
render: () => (
|
||||
<div className="text-sm text-text-secondary bg-bg-card border border-border-button rounded-md px-3 py-2 space-y-1">
|
||||
<p className="font-medium text-text-primary">{t('setting.seafileTokenPanelHeading')}</p>
|
||||
<p className="font-medium text-text-primary">
|
||||
{t('setting.seafileTokenPanelHeading')}
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-0.5">
|
||||
<li>
|
||||
<span className="font-medium">Account API Token</span>
|
||||
{' ' + t('setting.seafileTokenPanelAccountBullet')}
|
||||
{' ' + t('setting.seafileTokenPanelAccountBullet')}
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-medium">Library Token</span>
|
||||
@ -118,7 +119,11 @@ export const seafileConstant = (t: TFunction) => [
|
||||
customValidate: (val: string, formValues: any) => {
|
||||
const scope = formValues?.config?.sync_scope;
|
||||
const accountToken = formValues?.config?.credentials?.seafile_token;
|
||||
if (!val && !accountToken && (scope === 'library' || scope === 'directory')) {
|
||||
if (
|
||||
!val &&
|
||||
!accountToken &&
|
||||
(scope === 'library' || scope === 'directory')
|
||||
) {
|
||||
return t('setting.seafileValidationTokenRequired');
|
||||
}
|
||||
return true;
|
||||
@ -207,4 +212,3 @@ export const seafileConstant = (t: TFunction) => [
|
||||
hidden: true,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -75,7 +75,11 @@ export const ModelProviderCard: FC<IModelCardProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`w-full rounded-lg border border-border-button`} data-testid="added-model-card" data-provider={item.name}>
|
||||
<div
|
||||
className={`w-full rounded-lg border border-border-button`}
|
||||
data-testid="added-model-card"
|
||||
data-provider={item.name}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex h-16 items-center justify-between p-4 cursor-pointer transition-colors text-text-secondary">
|
||||
<div className="flex items-center space-x-3">
|
||||
|
||||
@ -79,7 +79,10 @@ export const AvailableModels: FC<{
|
||||
};
|
||||
|
||||
return (
|
||||
<div className=" text-text-primary h-full p-4" data-testid="available-models-section">
|
||||
<div
|
||||
className=" text-text-primary h-full p-4"
|
||||
data-testid="available-models-section"
|
||||
>
|
||||
<div className="text-text-primary text-base mb-4">
|
||||
{t('availableModels')}
|
||||
</div>
|
||||
|
||||
@ -11,7 +11,10 @@ export const UsedModel = ({
|
||||
}) => {
|
||||
const { myLlmList: llmList } = useSelectLlmList();
|
||||
return (
|
||||
<div className="flex flex-col w-full gap-5 mb-4" data-testid="added-models-section">
|
||||
<div
|
||||
className="flex flex-col w-full gap-5 mb-4"
|
||||
data-testid="added-models-section"
|
||||
>
|
||||
<div className="text-text-primary text-2xl font-medium mb-2 mt-4">
|
||||
{t('setting.addedModels')}
|
||||
</div>
|
||||
|
||||
@ -68,7 +68,11 @@ export function SideBar() {
|
||||
'bg-bg-card text-text-primary': active === item.key,
|
||||
'bg-bg-base text-text-secondary': active !== item.key,
|
||||
})}
|
||||
data-testid={item.key === Routes.Model ? 'settings-nav-model-providers' : undefined}
|
||||
data-testid={
|
||||
item.key === Routes.Model
|
||||
? 'settings-nav-model-providers'
|
||||
: undefined
|
||||
}
|
||||
onClick={handleMenuClick(item.key)}
|
||||
>
|
||||
<section className="flex items-center gap-2.5">
|
||||
|
||||
@ -10,7 +10,8 @@ describe('preprocessLaTeX', () => {
|
||||
});
|
||||
|
||||
it('does not cut block math at \\right] (Closes #13134)', () => {
|
||||
const content = '\\[ C_{seq}(y|x) = \\frac{1}{|y|} \\sum_{t=1}^{|y|} \\right] \\]';
|
||||
const content =
|
||||
'\\[ C_{seq}(y|x) = \\frac{1}{|y|} \\sum_{t=1}^{|y|} \\right] \\]';
|
||||
const result = preprocessLaTeX(content);
|
||||
expect(result).toContain('\\right]');
|
||||
expect(result).toContain('\\frac{1}{|y|}');
|
||||
|
||||
@ -5,6 +5,11 @@ import {
|
||||
import { IMessage, Message } from '@/interfaces/database/chat';
|
||||
import { omit } from 'lodash';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import {
|
||||
citationMarkerReg,
|
||||
normalizeCitationDigits,
|
||||
parseCitationIndex,
|
||||
} from './citation-utils';
|
||||
|
||||
export const isConversationIdExist = (conversationId: string) => {
|
||||
return conversationId !== EmptyConversationId && conversationId !== '';
|
||||
@ -93,8 +98,9 @@ export function setChatVariableEnabledFieldValuePage() {
|
||||
return variableCheckBoxFieldMap;
|
||||
}
|
||||
|
||||
const oldReg = /(#{2}\d+\${2})/g;
|
||||
export const currentReg = /\[ID:(\d+)\]/g;
|
||||
const oldReg = /(#{2}[0-9\u0660-\u0669\u06F0-\u06F9]+\${2})/g;
|
||||
export const currentReg = citationMarkerReg;
|
||||
export { normalizeCitationDigits, parseCitationIndex };
|
||||
|
||||
// To be compatible with the old index matching mode
|
||||
export const replaceTextByOldReg = (text: string) => {
|
||||
|
||||
24
web/src/utils/citation-utils.ts
Normal file
24
web/src/utils/citation-utils.ts
Normal file
@ -0,0 +1,24 @@
|
||||
export const normalizeCitationDigits = (text: string) => {
|
||||
if (!text) return text;
|
||||
return text.replace(/[٠-٩۰-۹]/g, (char) => {
|
||||
const code = char.charCodeAt(0);
|
||||
if (code >= 0x0660 && code <= 0x0669) {
|
||||
return String.fromCharCode(code - 0x0660 + 0x30);
|
||||
}
|
||||
if (code >= 0x06f0 && code <= 0x06f9) {
|
||||
return String.fromCharCode(code - 0x06f0 + 0x30);
|
||||
}
|
||||
return char;
|
||||
});
|
||||
};
|
||||
|
||||
export const parseCitationIndex = (value: string) => {
|
||||
const normalized = normalizeCitationDigits(value);
|
||||
const markerMatch = normalized.match(/\[(?:ID:)?(\d+)\]/);
|
||||
if (markerMatch) return Number(markerMatch[1]);
|
||||
if (/^\d+$/.test(normalized)) return Number(normalized);
|
||||
return Number.NaN;
|
||||
};
|
||||
|
||||
export const citationMarkerReg =
|
||||
/\[(?:ID:)?([0-9\u0660-\u0669\u06F0-\u06F9]+)\]/g;
|
||||
101
web/src/utils/text-direction.ts
Normal file
101
web/src/utils/text-direction.ts
Normal file
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* RTL (Right-to-Left) text direction utilities
|
||||
* Supports Arabic, Hebrew, Persian/Farsi, Urdu, and other RTL scripts
|
||||
*/
|
||||
|
||||
// Unicode ranges for RTL scripts
|
||||
const RTL_RANGES: [number, number][] = [
|
||||
[0x0600, 0x06ff], // Arabic
|
||||
[0x0750, 0x077f], // Arabic Supplement
|
||||
[0x08a0, 0x08ff], // Arabic Extended-A
|
||||
[0xfb50, 0xfdff], // Arabic Presentation Forms-A
|
||||
[0xfe70, 0xfeff], // Arabic Presentation Forms-B
|
||||
[0x0590, 0x05ff], // Hebrew
|
||||
[0xfb1d, 0xfb4f], // Hebrew Presentation Forms
|
||||
[0x0700, 0x074f], // Syriac
|
||||
[0x0780, 0x07bf], // Thaana (Maldivian)
|
||||
[0x0840, 0x085f], // Mandaic
|
||||
[0x0860, 0x086f], // Syriac Supplement
|
||||
];
|
||||
|
||||
/**
|
||||
* Check if a character code is in RTL Unicode range
|
||||
*/
|
||||
const isRTLCharCode = (charCode: number): boolean => {
|
||||
return RTL_RANGES.some(
|
||||
([start, end]) => charCode >= start && charCode <= end,
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Find the first "strong" directional character in text
|
||||
* Strong characters are letters (not numbers, punctuation, or whitespace)
|
||||
* Returns 'rtl', 'ltr', or 'neutral' if no strong character found
|
||||
*/
|
||||
export const getTextDirection = (text: string): 'rtl' | 'ltr' | 'neutral' => {
|
||||
if (!text) return 'neutral';
|
||||
|
||||
for (const char of text) {
|
||||
const code = char.charCodeAt(0);
|
||||
|
||||
// Skip whitespace, numbers, and common punctuation
|
||||
if (
|
||||
code <= 0x40 || // Control chars, digits, basic punctuation
|
||||
(code >= 0x5b && code <= 0x60) || // [ \ ] ^ _ `
|
||||
(code >= 0x7b && code <= 0x7f) // { | } ~ DEL
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if RTL
|
||||
if (isRTLCharCode(code)) {
|
||||
return 'rtl';
|
||||
}
|
||||
|
||||
// If we found a non-RTL letter, it's LTR
|
||||
// Latin, Greek, Cyrillic, etc.
|
||||
if (
|
||||
(code >= 0x41 && code <= 0x5a) || // A-Z
|
||||
(code >= 0x61 && code <= 0x7a) || // a-z
|
||||
(code >= 0x00c0 && code <= 0x024f) || // Latin Extended
|
||||
(code >= 0x0370 && code <= 0x03ff) || // Greek
|
||||
(code >= 0x0400 && code <= 0x04ff) // Cyrillic
|
||||
) {
|
||||
return 'ltr';
|
||||
}
|
||||
}
|
||||
|
||||
return 'neutral';
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if text contains any RTL characters
|
||||
* Useful for detecting mixed content
|
||||
*/
|
||||
export const containsRTL = (text: string): boolean => {
|
||||
if (!text) return false;
|
||||
|
||||
for (const char of text) {
|
||||
if (isRTLCharCode(char.charCodeAt(0))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if text is predominantly RTL
|
||||
* Returns true if first strong character is RTL
|
||||
*/
|
||||
export const isRTL = (text: string): boolean => {
|
||||
return getTextDirection(text) === 'rtl';
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the appropriate dir attribute value for HTML elements
|
||||
* Returns 'rtl', 'ltr', or 'auto' (for neutral/mixed content)
|
||||
*/
|
||||
export const getDirAttribute = (text: string): 'rtl' | 'ltr' | 'auto' => {
|
||||
const direction = getTextDirection(text);
|
||||
return direction === 'neutral' ? 'auto' : direction;
|
||||
};
|
||||
Reference in New Issue
Block a user