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:
Attili-sys
2026-03-02 08:03:44 +03:00
committed by GitHub
parent a897aedea9
commit 21bc1ab7ec
54 changed files with 828 additions and 303 deletions

8
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = []

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -6,7 +6,12 @@ html {
}
body {
font-family: Inter;
font-family:
'Inter',
system-ui,
-apple-system,
'Segoe UI',
sans-serif;
margin: 0;
height: 100%;
}

View File

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

View File

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

View File

@ -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: ' +

View File

@ -239,7 +239,8 @@
.configValue {
color: #333;
word-break: break-all;
word-break: break-word;
overflow-wrap: break-word;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -21,7 +21,8 @@
}
.contentText {
word-break: break-all !important;
word-break: break-word;
overflow-wrap: break-word;
}
.chunkCard {

View File

@ -19,7 +19,8 @@
}
.contentText {
word-break: break-all !important;
word-break: break-word;
overflow-wrap: break-word;
}
.chunkCard {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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