From a725e114f9c4d0bacb8dec7c0267728cf4f8e24d Mon Sep 17 00:00:00 2001 From: Haruko386 Date: Thu, 21 May 2026 18:28:06 +0800 Subject: [PATCH 01/10] Go: implement ASR and TTS for Xinference (#15096) ### What problem does this PR solve? implement ASR and TTS for Xinference ### Type of change - [x] New Feature (non-breaking change which adds functionality) - [x] Refactoring --- conf/models/novita.json | 1 + internal/entity/models/fishaudio.go | 7 +- internal/entity/models/moonshot.go | 61 +++---- internal/entity/models/novita.go | 68 +++++++- internal/entity/models/xinference.go | 162 +++++++++++++++++- .../test_dify_retrieval_routes_unit.py | 6 +- .../api/apps/sdk/test_dify_retrieval.py | 5 +- 7 files changed, 266 insertions(+), 44 deletions(-) diff --git a/conf/models/novita.json b/conf/models/novita.json index 6dad88c2fa8..dfc11e03828 100644 --- a/conf/models/novita.json +++ b/conf/models/novita.json @@ -7,6 +7,7 @@ "chat": "openai/v1/chat/completions", "models": "openai/v1/models", "embedding": "openai/v1/embeddings", + "balance": "openapi/v1/billing/balance/detail", "rerank": "openai/v1/rerank" }, "class": "novita", diff --git a/internal/entity/models/fishaudio.go b/internal/entity/models/fishaudio.go index 70f67076211..5e2696f51f3 100644 --- a/internal/entity/models/fishaudio.go +++ b/internal/entity/models/fishaudio.go @@ -66,7 +66,6 @@ func (f *FishAudioModel) Rerank(modelName *string, query string, documents []str // TranscribeAudio transcribe audio func (f *FishAudioModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { - if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { return nil, fmt.Errorf("FishAudio API key is missing") } @@ -151,11 +150,7 @@ func (f *FishAudioModel) TranscribeAudio(modelName *string, file *string, apiCon } if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf( - "FishAudio ASR error: %s - %s", - resp.Status, - string(respBody), - ) + return nil, fmt.Errorf("FishAudio ASR error: %s - %s", resp.Status, string(respBody)) } // result diff --git a/internal/entity/models/moonshot.go b/internal/entity/models/moonshot.go index fa1ad76ec4e..6114bd2546b 100644 --- a/internal/entity/models/moonshot.go +++ b/internal/entity/models/moonshot.go @@ -210,7 +210,7 @@ func (k *MoonshotModel) ChatStreamlyWithSender(modelName string, messages []Mess region = *apiConfig.Region } - url := fmt.Sprintf("%s/chat/completions", k.BaseURL[region]) + url := fmt.Sprintf("%s/%s", k.BaseURL[region], k.URLSuffix.Chat) // Convert messages to API format apiMessages := make([]map[string]interface{}, len(messages)) @@ -228,38 +228,40 @@ func (k *MoonshotModel) ChatStreamlyWithSender(modelName string, messages []Mess "stream": true, } - if chatModelConfig.Stream != nil { - reqBody["stream"] = *chatModelConfig.Stream - } + if chatModelConfig != nil { + if chatModelConfig.Stream != nil { + reqBody["stream"] = *chatModelConfig.Stream + } - if chatModelConfig.MaxTokens != nil { - reqBody["max_tokens"] = *chatModelConfig.MaxTokens - } + if chatModelConfig.MaxTokens != nil { + reqBody["max_tokens"] = *chatModelConfig.MaxTokens + } - if chatModelConfig.Temperature != nil { - reqBody["temperature"] = *chatModelConfig.Temperature - } + if chatModelConfig.Temperature != nil { + reqBody["temperature"] = *chatModelConfig.Temperature + } - if chatModelConfig.DoSample != nil { - reqBody["do_sample"] = *chatModelConfig.DoSample - } + if chatModelConfig.DoSample != nil { + reqBody["do_sample"] = *chatModelConfig.DoSample + } - if chatModelConfig.TopP != nil { - reqBody["top_p"] = *chatModelConfig.TopP - } + if chatModelConfig.TopP != nil { + reqBody["top_p"] = *chatModelConfig.TopP + } - if chatModelConfig.Stop != nil { - reqBody["stop"] = *chatModelConfig.Stop - } + if chatModelConfig.Stop != nil { + reqBody["stop"] = *chatModelConfig.Stop + } - if chatModelConfig.Thinking != nil { - if *chatModelConfig.Thinking { - reqBody["thinking"] = map[string]interface{}{ - "type": "enabled", - } - } else { - reqBody["thinking"] = map[string]interface{}{ - "type": "disabled", + if chatModelConfig.Thinking != nil { + if *chatModelConfig.Thinking { + reqBody["thinking"] = map[string]interface{}{ + "type": "enabled", + } + } else { + reqBody["thinking"] = map[string]interface{}{ + "type": "disabled", + } } } } @@ -364,7 +366,7 @@ func (z *MoonshotModel) Embed(modelName *string, texts []string, apiConfig *APIC func (z *MoonshotModel) ListModels(apiConfig *APIConfig) ([]string, error) { var region = "default" - if apiConfig.Region != nil { + if apiConfig.Region != nil && *apiConfig.Region != "" { region = *apiConfig.Region } @@ -419,9 +421,8 @@ func (z *MoonshotModel) ListModels(apiConfig *APIConfig) ([]string, error) { } func (z *MoonshotModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { - var region = "default" - if apiConfig.Region != nil { + if apiConfig.Region != nil && *apiConfig.Region != "" { region = *apiConfig.Region } diff --git a/internal/entity/models/novita.go b/internal/entity/models/novita.go index 7335dbff687..980a61949b9 100644 --- a/internal/entity/models/novita.go +++ b/internal/entity/models/novita.go @@ -24,6 +24,7 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" "time" ) @@ -841,9 +842,72 @@ func (n *NovitaModel) Rerank(modelName *string, query string, documents []string return &rerankResponse, nil } -// Balance is not exposed by the Novita API. +// Balance Get remaining credit func (n *NovitaModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { - return nil, fmt.Errorf("%s, no such method", n.Name()) + var region = "default" + if apiConfig.Region != nil && *apiConfig.Region != "" { + region = *apiConfig.Region + } + + url := fmt.Sprintf("%s/%s", n.BaseURL[region], n.URLSuffix.Balance) + + // Build request body + reqBody := map[string]interface{}{} + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest("GET", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + + resp, err := n.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(body)) + } + + // Parse response + var result map[string]interface{} + if err = json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("failed to parse response: %w", err) + } + + balanceInterface, exists := result["availableBalance"] + if !exists || balanceInterface == nil { + return nil, fmt.Errorf("missing 'availableBalance' in response. Raw body: %s", string(body)) + } + + balanceStr, ok := balanceInterface.(string) + if !ok { + return nil, fmt.Errorf("'availableBalance' is not a string. Raw body: %s", string(body)) + } + balance, err := strconv.ParseFloat(balanceStr, 64) + if err != nil { + return nil, fmt.Errorf("failed to parse 'availableBalance' as float: %w. Raw body: %s", err, string(body)) + } + + var response = map[string]interface{}{ + "balance": balance, + "currency": "USD", + } + + return response, nil } func (n *NovitaModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { diff --git a/internal/entity/models/xinference.go b/internal/entity/models/xinference.go index 971948cfc42..52307da44d7 100644 --- a/internal/entity/models/xinference.go +++ b/internal/entity/models/xinference.go @@ -12,7 +12,6 @@ // 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. -// package models @@ -23,7 +22,11 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" + "os" + "path/filepath" + "strconv" "strings" "sync" "time" @@ -589,15 +592,166 @@ func (x *XinferenceModel) Rerank(modelName *string, query string, documents []st } func (x *XinferenceModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { - return nil, fmt.Errorf("%s, no such method", x.Name()) + if file == nil || *file == "" { + return nil, fmt.Errorf("file is missing") + } + + region := "default" + if apiConfig.Region != nil && *apiConfig.Region != "" { + region = *apiConfig.Region + } + + url := fmt.Sprintf("%s/%s", x.BaseURL[region], x.URLSuffix.ASR) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + // audio file + audioFile, err := os.Open(*file) + if err != nil { + return nil, fmt.Errorf("failed to open audio file: %w", err) + } + defer audioFile.Close() + + part, err := writer.CreateFormFile("file", filepath.Base(*file)) + if err != nil { + return nil, fmt.Errorf("failed to create multipart file: %w", err) + } + + if _, err = io.Copy(part, audioFile); err != nil { + return nil, fmt.Errorf("failed to copy audio data: %w", err) + } + + if err = writer.WriteField("model", *modelName); err != nil { + return nil, fmt.Errorf("failed to write model name: %w", err) + } + + // extra params + if asrConfig != nil && asrConfig.Params != nil { + for key, value := range asrConfig.Params { + + var val string + + switch v := value.(type) { + case string: + val = v + case bool: + val = strconv.FormatBool(v) + case int: + val = strconv.Itoa(v) + case float64: + val = strconv.FormatFloat(v, 'f', -1, 64) + default: + val = fmt.Sprintf("%v", v) + } + + if err := writer.WriteField(key, val); err != nil { + return nil, fmt.Errorf("failed to write field %s: %w", key, err) + } + } + } + + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("failed to close multipart writer: %w", err) + } + + // request + req, err := http.NewRequest("POST", url, &body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := x.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("FishAudio ASR error: %s - %s", resp.Status, string(respBody)) + } + + // result + var result struct { + Text string `json:"text"` + } + + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &ASRResponse{ + Text: result.Text, + }, nil } func (x *XinferenceModel) TranscribeAudioWithSender(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig, sender func(*string, *string) error) error { return fmt.Errorf("%s, no such method", x.Name()) } -func (x *XinferenceModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, asrConfig *TTSConfig) (*TTSResponse, error) { - return nil, fmt.Errorf("%s, no such method", x.Name()) +func (x *XinferenceModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { + if audioContent == nil || *audioContent == "" { + return nil, fmt.Errorf("text content is missing") + } + + var region = "default" + if apiConfig.Region != nil && *apiConfig.Region != "" { + region = *apiConfig.Region + } + + url := fmt.Sprintf("%s/%s", x.BaseURL[region], x.URLSuffix.TTS) + + reqBody := map[string]interface{}{ + "model": *modelName, + "input": *audioContent, + } + + if ttsConfig != nil && ttsConfig.Params != nil { + for key, value := range ttsConfig.Params { + reqBody[key] = value + } + } + if ttsConfig != nil && ttsConfig.Format != "" { + reqBody["format"] = ttsConfig.Format + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + + resp, err := x.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s - %s", resp.Status, string(body)) + } + + return &TTSResponse{Audio: body}, nil } func (x *XinferenceModel) AudioSpeechWithSender(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig, sender func(*string, *string) error) error { diff --git a/test/testcases/restful_api/test_dify_retrieval_routes_unit.py b/test/testcases/restful_api/test_dify_retrieval_routes_unit.py index 01b23e61071..b3485030303 100644 --- a/test/testcases/restful_api/test_dify_retrieval_routes_unit.py +++ b/test/testcases/restful_api/test_dify_retrieval_routes_unit.py @@ -290,7 +290,11 @@ async def retrieval(self, *_args, **_kwargs): } monkeypatch.setattr(module.settings, "kg_retriever", _DummyKgRetriever()) - monkeypatch.setattr(module.DocumentService, "get_by_id", lambda doc_id: (True, SimpleNamespace(meta_fields={"origin": f"meta-{doc_id}"}))) + monkeypatch.setattr( + module.DocumentService, + "get_by_ids", + lambda doc_ids, cols=None: [SimpleNamespace(id=doc_id, meta_fields={"origin": f"meta-{doc_id}"}) for doc_id in doc_ids], + ) monkeypatch.setattr(module, "label_question", lambda *_args, **_kwargs: []) res = _run(inspect.unwrap(module.retrieval)("tenant-1")) diff --git a/test/unit_test/api/apps/sdk/test_dify_retrieval.py b/test/unit_test/api/apps/sdk/test_dify_retrieval.py index 113ff139f00..a74da5a6497 100644 --- a/test/unit_test/api/apps/sdk/test_dify_retrieval.py +++ b/test/unit_test/api/apps/sdk/test_dify_retrieval.py @@ -97,7 +97,10 @@ def _load_dify_retrieval(monkeypatch, *, kb, accessible, request_body, chunks=No _stub( monkeypatch, "api.db.services.document_service", - DocumentService=SimpleNamespace(get_by_id=lambda _id: (True, SimpleNamespace(meta_fields={}))), + DocumentService=SimpleNamespace( + get_by_id=lambda _id: (True, SimpleNamespace(id=_id, meta_fields={})), + get_by_ids=lambda ids, cols=None: [SimpleNamespace(id=doc_id, meta_fields={}) for doc_id in ids], + ), ) _stub( monkeypatch, From 775ea5567948ea12783c514be12abb62defcbe63 Mon Sep 17 00:00:00 2001 From: Jin Hai Date: Thu, 21 May 2026 19:09:19 +0800 Subject: [PATCH 02/10] Docs: update python version to 3.13 (#15103) ### What problem does this PR solve? 1. update python version to 3.13 2. upgrade ormsgpack to 1.6.0 ### Type of change - [x] Refactoring --------- Signed-off-by: Jin Hai --- AGENTS.md | 2 +- README.md | 2 +- README_ar.md | 2 +- README_fr.md | 2 +- README_id.md | 2 +- README_ja.md | 2 +- README_ko.md | 2 +- README_pt_br.md | 2 +- README_tr.md | 2 +- README_tzh.md | 2 +- README_zh.md | 2 +- admin/client/user.py | 2 +- docs/develop/launch_ragflow_from_source.md | 4 ++-- docs/develop/mcp/launch_mcp_server.md | 2 +- pyproject.toml | 2 +- test/README.md | 2 +- test/benchmark/README.md | 2 +- test/benchmark/auth.py | 2 +- 18 files changed, 19 insertions(+), 19 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b558df135a1..775394d43d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,7 +34,7 @@ The project uses **uv** for dependency management. 1. **Setup Environment**: ```bash - uv sync --python 3.12 --all-extras + uv sync --python 3.13 --all-extras uv run python3 download_deps.py ``` diff --git a/README.md b/README.md index 2a86b2490fb..80d85f42017 100644 --- a/README.md +++ b/README.md @@ -328,7 +328,7 @@ docker build --platform linux/amd64 \ ```bash git clone https://github.com/infiniflow/ragflow.git cd ragflow/ - uv sync --python 3.12 # install RAGFlow dependent python modules + uv sync --python 3.13 # install RAGFlow dependent python modules uv run python3 download_deps.py pre-commit install ``` diff --git a/README_ar.md b/README_ar.md index 1f7393945e7..9fe02bdb483 100644 --- a/README_ar.md +++ b/README_ar.md @@ -328,7 +328,7 @@ docker build --platform linux/amd64 \ ```bash git clone https://github.com/infiniflow/ragflow.git cd ragflow/ - uv sync --python 3.12 # install RAGFlow dependent python modules + uv sync --python 3.13 # install RAGFlow dependent python modules uv run python3 download_deps.py pre-commit install ``` diff --git a/README_fr.md b/README_fr.md index b21d380ec34..135cc3865a3 100644 --- a/README_fr.md +++ b/README_fr.md @@ -319,7 +319,7 @@ docker build --platform linux/amd64 \ ```bash git clone https://github.com/infiniflow/ragflow.git cd ragflow/ - uv sync --python 3.12 # install RAGFlow dependent python modules + uv sync --python 3.13 # install RAGFlow dependent python modules uv run python3 download_deps.py pre-commit install ``` diff --git a/README_id.md b/README_id.md index a9d45317feb..e9582d3715a 100644 --- a/README_id.md +++ b/README_id.md @@ -302,7 +302,7 @@ docker build --platform linux/amd64 \ ```bash git clone https://github.com/infiniflow/ragflow.git cd ragflow/ - uv sync --python 3.12 # install RAGFlow dependent python modules + uv sync --python 3.13 # install RAGFlow dependent python modules uv run python3 download_deps.py pre-commit install ``` diff --git a/README_ja.md b/README_ja.md index 185d6e9c360..e5b4529461c 100644 --- a/README_ja.md +++ b/README_ja.md @@ -302,7 +302,7 @@ docker build --platform linux/amd64 \ ```bash git clone https://github.com/infiniflow/ragflow.git cd ragflow/ - uv sync --python 3.12 # install RAGFlow dependent python modules + uv sync --python 3.13 # install RAGFlow dependent python modules uv run python3 download_deps.py pre-commit install ``` diff --git a/README_ko.md b/README_ko.md index ee243344eab..281d7723851 100644 --- a/README_ko.md +++ b/README_ko.md @@ -297,7 +297,7 @@ docker build --platform linux/amd64 \ ```bash git clone https://github.com/infiniflow/ragflow.git cd ragflow/ - uv sync --python 3.12 # install RAGFlow dependent python modules + uv sync --python 3.13 # install RAGFlow dependent python modules uv run python3 download_deps.py pre-commit install ``` diff --git a/README_pt_br.md b/README_pt_br.md index 2bebd2c2611..66a6e35e86d 100644 --- a/README_pt_br.md +++ b/README_pt_br.md @@ -319,7 +319,7 @@ docker build --platform linux/amd64 \ ```bash git clone https://github.com/infiniflow/ragflow.git cd ragflow/ - uv sync --python 3.12 # instala os módulos Python dependentes do RAGFlow + uv sync --python 3.13 # instala os módulos Python dependentes do RAGFlow uv run python3 download_deps.py pre-commit install ``` diff --git a/README_tr.md b/README_tr.md index 778d4122fbc..64de012e6a9 100644 --- a/README_tr.md +++ b/README_tr.md @@ -323,7 +323,7 @@ docker build --platform linux/amd64 \ ```bash git clone https://github.com/infiniflow/ragflow.git cd ragflow/ - uv sync --python 3.12 # RAGFlow'un bağımlı Python modüllerini yükler + uv sync --python 3.13 # RAGFlow'un bağımlı Python modüllerini yükler uv run python3 download_deps.py pre-commit install ``` diff --git a/README_tzh.md b/README_tzh.md index 0d6c95af03f..4a9e5fc4de8 100644 --- a/README_tzh.md +++ b/README_tzh.md @@ -329,7 +329,7 @@ docker build --platform linux/amd64 \ ```bash git clone https://github.com/infiniflow/ragflow.git cd ragflow/ - uv sync --python 3.12 # install RAGFlow dependent python modules + uv sync --python 3.13 # install RAGFlow dependent python modules uv run python3 download_deps.py pre-commit install ``` diff --git a/README_zh.md b/README_zh.md index c2222a95ab4..18de3b82237 100644 --- a/README_zh.md +++ b/README_zh.md @@ -329,7 +329,7 @@ docker build --platform linux/amd64 \ ```bash git clone https://github.com/infiniflow/ragflow.git cd ragflow/ - uv sync --python 3.12 # install RAGFlow dependent python modules + uv sync --python 3.13 # install RAGFlow dependent python modules uv run python3 download_deps.py pre-commit install ``` diff --git a/admin/client/user.py b/admin/client/user.py index 6e6a36eeea2..b00e64e711d 100644 --- a/admin/client/user.py +++ b/admin/client/user.py @@ -41,7 +41,7 @@ def crypt(line): return base64.b64encode(encrypted_password).decode('utf-8') except Exception as exc: raise AuthException( - "Password encryption unavailable; install pycryptodomex (uv sync --python 3.12 --group test)." + "Password encryption unavailable; install pycryptodomex (uv sync --python 3.13 --group test)." ) from exc return crypt(password_plain) diff --git a/docs/develop/launch_ragflow_from_source.md b/docs/develop/launch_ragflow_from_source.md index 22f127f34c2..5e16b6600bc 100644 --- a/docs/develop/launch_ragflow_from_source.md +++ b/docs/develop/launch_ragflow_from_source.md @@ -46,14 +46,14 @@ cd ragflow/ 2. Install RAGFlow service's Python dependencies: ```bash - uv sync --python 3.12 --frozen + uv sync --python 3.13 --frozen ``` *A virtual environment named `.venv` is created, and all Python dependencies are installed into the new environment.* If you need to run tests against the RAGFlow service, install the test dependencies: ```bash - uv sync --python 3.12 --group test --frozen && uv pip install sdk/python --group test + uv sync --python 3.13 --group test --frozen && uv pip install sdk/python --group test ``` ### Launch third-party services diff --git a/docs/develop/mcp/launch_mcp_server.md b/docs/develop/mcp/launch_mcp_server.md index 99633fd3238..306b3425cf2 100644 --- a/docs/develop/mcp/launch_mcp_server.md +++ b/docs/develop/mcp/launch_mcp_server.md @@ -178,7 +178,7 @@ This section is contributed by our community contributor [yiminghub2024](https:/ iii. Copy [docker/entrypoint.sh](https://github.com/infiniflow/ragflow/blob/main/docker/entrypoint.sh) locally. iv. Install the required dependencies using `uv`: - Run `uv add mcp` or - - Copy [pyproject.toml](https://github.com/infiniflow/ragflow/blob/main/pyproject.toml) locally and run `uv sync --python 3.12`. + - Copy [pyproject.toml](https://github.com/infiniflow/ragflow/blob/main/pyproject.toml) locally and run `uv sync --python 3.13`. 2. Edit **docker-compose.yml** to enable MCP (disabled by default). 3. Launch the MCP server: diff --git a/pyproject.toml b/pyproject.toml index 994766d5c68..651f1cc2608 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -80,7 +80,7 @@ dependencies = [ "opencv-python-headless==4.10.0.84", "opendal>=0.45.0,<0.46.0", "opensearch-py==2.7.1", - "ormsgpack>=1.5.0", + "ormsgpack>=1.6.0", "pdfplumber==0.10.4", "pluginlib>=0.10.0", "psycopg2-binary>=2.9.11,<3.0.0", diff --git a/test/README.md b/test/README.md index 81d33c4f489..fa622041d2e 100644 --- a/test/README.md +++ b/test/README.md @@ -10,7 +10,7 @@ **Install Python dependencies (including test dependencies):** ```bash -uv sync --python 3.12 --only-group test --no-default-groups --frozen +uv sync --python 3.13 --only-group test --no-default-groups --frozen ``` diff --git a/test/benchmark/README.md b/test/benchmark/README.md index 085f7826213..6f1ca539de8 100644 --- a/test/benchmark/README.md +++ b/test/benchmark/README.md @@ -251,7 +251,7 @@ These scripts create a dataset, upload/parse docs from test/benchmark/test_docs, run the benchmark, and clean up. The both script runs retrieval then chat on the same dataset, then deletes it. -- Make sure to run ```uv sync --python 3.12 --group test ``` before running the commands. +- Make sure to run ```uv sync --python 3.13 --group test ``` before running the commands. - It is also necessary to run these commands prior to initializing your containers if you plan on using the built-in embedded model: ```echo -e "TEI_MODEL=BAAI/bge-small-en-v1.5" >> docker/.env``` and ```echo -e "COMPOSE_PROFILES=\${COMPOSE_PROFILES},tei-cpu" >> docker/.env``` diff --git a/test/benchmark/auth.py b/test/benchmark/auth.py index 135907dafa5..49c6efdcfb7 100644 --- a/test/benchmark/auth.py +++ b/test/benchmark/auth.py @@ -12,7 +12,7 @@ def encrypt_password(password_plain: str) -> str: from api.utils.crypt import crypt except Exception as exc: raise AuthError( - "Password encryption unavailable; install pycryptodomex (uv sync --python 3.12 --group test)." + "Password encryption unavailable; install pycryptodomex (uv sync --python 3.13 --group test)." ) from exc return crypt(password_plain) From c5a46fda448c943aef48f9e477330af02f89a0a8 Mon Sep 17 00:00:00 2001 From: Wang Qi Date: Thu, 21 May 2026 19:23:41 +0800 Subject: [PATCH 03/10] Fix: is bound to a different event loop (#15100) Fix: is bound to a different event loop --- common/asyncio_utils.py | 56 ++++++++++++++++++++++++++++++++++++++++ rag/graphrag/utils.py | 3 ++- rag/svr/task_executor.py | 11 ++++---- 3 files changed, 64 insertions(+), 6 deletions(-) create mode 100644 common/asyncio_utils.py diff --git a/common/asyncio_utils.py b/common/asyncio_utils.py new file mode 100644 index 00000000000..12f5e0220a4 --- /dev/null +++ b/common/asyncio_utils.py @@ -0,0 +1,56 @@ +# +# Copyright 2026 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. +# + +import asyncio +import weakref + + +class LoopLocalSemaphore: + """ + Asyncio synchronization primitives bind to the event loop that waits on them. + Keep one semaphore per running loop for module-level concurrency limiters. + """ + + def __init__(self, value: int): + self._value = int(value) + self._semaphores: "weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, asyncio.Semaphore]" = ( + weakref.WeakKeyDictionary() + ) + + def _get(self) -> asyncio.Semaphore: + loop = asyncio.get_running_loop() + for cached_loop in list(self._semaphores): + if cached_loop.is_closed(): + self._semaphores.pop(cached_loop, None) + sem = self._semaphores.get(loop) + if sem is None: + sem = asyncio.Semaphore(self._value) + self._semaphores[loop] = sem + return sem + + async def acquire(self) -> bool: + return await self._get().acquire() + + def release(self) -> None: + self._get().release() + + async def __aenter__(self): + await self.acquire() + return self + + async def __aexit__(self, exc_type, exc, tb): + self.release() + return False diff --git a/rag/graphrag/utils.py b/rag/graphrag/utils.py index fa29ebe3899..c92fe0c6feb 100644 --- a/rag/graphrag/utils.py +++ b/rag/graphrag/utils.py @@ -28,6 +28,7 @@ from common.misc_utils import get_uuid from common.connection_utils import timeout +from common.asyncio_utils import LoopLocalSemaphore from rag.nlp import rag_tokenizer, search from rag.utils.redis_conn import REDIS_CONN from common import settings @@ -37,7 +38,7 @@ ErrorHandlerFn = Callable[[BaseException | None, str | None, dict | None], None] -chat_limiter = asyncio.Semaphore(int(os.environ.get("MAX_CONCURRENT_CHATS", 10))) +chat_limiter = LoopLocalSemaphore(int(os.environ.get("MAX_CONCURRENT_CHATS", 10))) # Doc-store insert batching for GraphRAG subgraph/node/edge/community_report # chunks. Defaults (64 docs per batch, up to 4 batches in flight) mirror the diff --git a/rag/svr/task_executor.py b/rag/svr/task_executor.py index e639ba6e46d..9e263e8b5ab 100644 --- a/rag/svr/task_executor.py +++ b/rag/svr/task_executor.py @@ -89,6 +89,7 @@ from rag.graphrag.utils import chat_limiter from common.signal_utils import start_tracemalloc_and_snapshot, stop_tracemalloc from common.exceptions import TaskCanceledException +from common.asyncio_utils import LoopLocalSemaphore from common import settings from common.constants import PAGERANK_FLD, TAG_FLD, SVR_CONSUMER_GROUP_NAME from rag.utils.table_es_metadata import ( @@ -142,11 +143,11 @@ MAX_CONCURRENT_TASKS = int(os.environ.get('MAX_CONCURRENT_TASKS', "5")) MAX_CONCURRENT_CHUNK_BUILDERS = int(os.environ.get('MAX_CONCURRENT_CHUNK_BUILDERS', "1")) MAX_CONCURRENT_MINIO = int(os.environ.get('MAX_CONCURRENT_MINIO', '10')) -task_limiter = asyncio.Semaphore(MAX_CONCURRENT_TASKS) -chunk_limiter = asyncio.Semaphore(MAX_CONCURRENT_CHUNK_BUILDERS) -embed_limiter = asyncio.Semaphore(MAX_CONCURRENT_CHUNK_BUILDERS) -minio_limiter = asyncio.Semaphore(MAX_CONCURRENT_MINIO) -kg_limiter = asyncio.Semaphore(2) +task_limiter = LoopLocalSemaphore(MAX_CONCURRENT_TASKS) +chunk_limiter = LoopLocalSemaphore(MAX_CONCURRENT_CHUNK_BUILDERS) +embed_limiter = LoopLocalSemaphore(MAX_CONCURRENT_CHUNK_BUILDERS) +minio_limiter = LoopLocalSemaphore(MAX_CONCURRENT_MINIO) +kg_limiter = LoopLocalSemaphore(2) WORKER_HEARTBEAT_TIMEOUT = int(os.environ.get('WORKER_HEARTBEAT_TIMEOUT', '120')) stop_event = threading.Event() From 1ece1c81da7b170506cd7085570dd3029f86122e Mon Sep 17 00:00:00 2001 From: Haruko386 Date: Thu, 21 May 2026 20:57:04 +0800 Subject: [PATCH 04/10] Go: implement rerank, asr, tts for TogetherAI (#15107) ### What problem does this PR solve? implement rerank, asr, tts for TogetherAI ### Type of change - [x] New Feature (non-breaking change which adds functionality) --- conf/models/togetherai.json | 24 +- internal/entity/models/togetherai.go | 345 ++++++++++++++++++++++++++- 2 files changed, 364 insertions(+), 5 deletions(-) diff --git a/conf/models/togetherai.json b/conf/models/togetherai.json index 0608641a67f..4907b8db438 100644 --- a/conf/models/togetherai.json +++ b/conf/models/togetherai.json @@ -6,7 +6,10 @@ "url_suffix": { "chat": "chat/completions", "models": "models", - "embedding": "embeddings" + "embedding": "embeddings", + "rerank": "rerank", + "asr": "audio/transcriptions", + "tts": "audio/speech" }, "class": "together", "models": [ @@ -51,6 +54,25 @@ "model_types": [ "embedding" ] + }, + { + "name": "mixedbread-ai/mxbai-rerank-large-v2", + "max_tokens": "16384", + "model_types": [ + "rerank" + ] + }, + { + "name": "openai/whisper-large-v3", + "model_types": [ + "asr" + ] + }, + { + "name": "canopylabs/orpheus-3b-0.1-ft", + "model_types": [ + "tts" + ] } ] } diff --git a/internal/entity/models/togetherai.go b/internal/entity/models/togetherai.go index 6aea12c2068..35d2a65016a 100644 --- a/internal/entity/models/togetherai.go +++ b/internal/entity/models/togetherai.go @@ -20,10 +20,15 @@ import ( "bufio" "bytes" "context" + "encoding/base64" "encoding/json" "fmt" "io" + "mime/multipart" "net/http" + "os" + "path/filepath" + "strconv" "strings" "time" ) @@ -490,7 +495,78 @@ func (t *TogetherAIModel) Embed(modelName *string, texts []string, apiConfig *AP } func (t *TogetherAIModel) Rerank(modelName *string, query string, documents []string, apiConfig *APIConfig, rerankConfig *RerankConfig) (*RerankResponse, error) { - return nil, fmt.Errorf("%s, no such method", t.Name()) + if len(documents) == 0 { + return &RerankResponse{}, nil + } + + var region = "default" + if apiConfig != nil && apiConfig.Region != nil && *apiConfig.Region != "" { + region = *apiConfig.Region + } + + url := fmt.Sprintf("%s/%s", t.BaseURL[region], t.URLSuffix.Rerank) + + var topN = rerankConfig.TopN + if rerankConfig.TopN != 0 { + topN = rerankConfig.TopN + } + + reqBody := map[string]interface{}{ + "model": *modelName, + "query": query, + "documents": documents, + "top_n": topN, + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("TogetherAI Rerank API error: status %d, body: %s", resp.StatusCode, string(body)) + } + + var rerankResp struct { + Results []struct { + Index int `json:"index"` + RelevanceScore float64 `json:"relevance_score"` + } `json:"results"` + } + + if err = json.Unmarshal(body, &rerankResp); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + var rerankResponse RerankResponse + for _, result := range rerankResp.Results { + rerankResult := RerankResult{ + Index: result.Index, + RelevanceScore: result.RelevanceScore, + } + rerankResponse.Data = append(rerankResponse.Data, rerankResult) + } + + return &rerankResponse, nil } func (t *TogetherAIModel) Balance(apiConfig *APIConfig) (map[string]interface{}, error) { @@ -498,7 +574,105 @@ func (t *TogetherAIModel) Balance(apiConfig *APIConfig) (map[string]interface{}, } func (t *TogetherAIModel) TranscribeAudio(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig) (*ASRResponse, error) { - return nil, fmt.Errorf("%s, no such method", t.Name()) + if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { + return nil, fmt.Errorf("TogetherAI API key is missing") + } + + if file == nil || *file == "" { + return nil, fmt.Errorf("file is missing") + } + + region := "default" + if apiConfig.Region != nil && *apiConfig.Region != "" { + region = *apiConfig.Region + } + + url := fmt.Sprintf("%s/%s", t.BaseURL[region], t.URLSuffix.ASR) + + var body bytes.Buffer + writer := multipart.NewWriter(&body) + + // audio file + audioFile, err := os.Open(*file) + if err != nil { + return nil, fmt.Errorf("failed to open audio file: %w", err) + } + defer audioFile.Close() + + part, err := writer.CreateFormFile("file", filepath.Base(*file)) + if err != nil { + return nil, fmt.Errorf("failed to create multipart file: %w", err) + } + + if _, err = io.Copy(part, audioFile); err != nil { + return nil, fmt.Errorf("failed to copy audio data: %w", err) + } + + // extra params + if asrConfig != nil && asrConfig.Params != nil { + for key, value := range asrConfig.Params { + + var val string + + switch v := value.(type) { + case string: + val = v + case bool: + val = strconv.FormatBool(v) + case int: + val = strconv.Itoa(v) + case float64: + val = strconv.FormatFloat(v, 'f', -1, 64) + default: + val = fmt.Sprintf("%v", v) + } + + if err := writer.WriteField(key, val); err != nil { + return nil, fmt.Errorf("failed to write field %s: %w", key, err) + } + } + } + + if err := writer.Close(); err != nil { + return nil, fmt.Errorf("failed to close multipart writer: %w", err) + } + + // request + req, err := http.NewRequest("POST", url, &body) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("TogetherAI ASR error: %s - %s", resp.Status, string(respBody)) + } + + // result + var result struct { + Text string `json:"text"` + } + + if err := json.Unmarshal(respBody, &result); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &ASRResponse{ + Text: result.Text, + }, nil } func (t *TogetherAIModel) TranscribeAudioWithSender(modelName *string, file *string, apiConfig *APIConfig, asrConfig *ASRConfig, sender func(*string, *string) error) error { @@ -506,11 +680,174 @@ func (t *TogetherAIModel) TranscribeAudioWithSender(modelName *string, file *str } func (t *TogetherAIModel) AudioSpeech(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig) (*TTSResponse, error) { - return nil, fmt.Errorf("%s, no such method", t.Name()) + if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { + return nil, fmt.Errorf("TogetherAI API key is missing") + } + + if audioContent == nil || *audioContent == "" { + return nil, fmt.Errorf("text content is missing") + } + + var region = "default" + if apiConfig.Region != nil && *apiConfig.Region != "" { + region = *apiConfig.Region + } + + url := fmt.Sprintf("%s/%s", t.BaseURL[region], t.URLSuffix.TTS) + + reqBody := map[string]interface{}{ + "model": *modelName, + "input": *audioContent, + } + + if ttsConfig != nil && ttsConfig.Params != nil { + for key, value := range ttsConfig.Params { + reqBody[key] = value + } + } + if ttsConfig != nil && ttsConfig.Format != "" { + reqBody["response_format"] = ttsConfig.Format + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + + resp, err := t.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s - %s", resp.Status, string(body)) + } + + return &TTSResponse{Audio: body}, nil } func (t *TogetherAIModel) AudioSpeechWithSender(modelName *string, audioContent *string, apiConfig *APIConfig, ttsConfig *TTSConfig, sender func(*string, *string) error) error { - return fmt.Errorf("%s, no such method", t.Name()) + if apiConfig == nil || apiConfig.ApiKey == nil || *apiConfig.ApiKey == "" { + return fmt.Errorf("TogetherAI API key is missing") + } + + if audioContent == nil || *audioContent == "" { + return fmt.Errorf("text content is missing") + } + + var region = "default" + if apiConfig.Region != nil && *apiConfig.Region != "" { + region = *apiConfig.Region + } + + cleanBaseURL := strings.TrimRight(t.BaseURL[region], "/") + cleanSuffix := strings.TrimLeft(t.URLSuffix.TTS, "/") + url := fmt.Sprintf("%s/%s", cleanBaseURL, cleanSuffix) + + // Build Request body + reqBody := map[string]interface{}{ + "model": *modelName, + "input": *audioContent, + "stream": true, + } + + if ttsConfig != nil { + if ttsConfig.Format != "" { + reqBody["response_format"] = ttsConfig.Format + } + if ttsConfig.Params != nil { + for key, value := range ttsConfig.Params { + reqBody[key] = value + } + } + } + + jsonData, err := json.Marshal(reqBody) + if err != nil { + return fmt.Errorf("failed to marshal request: %w", err) + } + + // Build Request + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", *apiConfig.ApiKey)) + + resp, err := t.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + buf := make([]byte, 1024) + n, _ := resp.Body.Read(buf) + return fmt.Errorf("TogetherAI stream API error: %d - %s", resp.StatusCode, string(buf[:n])) + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 64*1024), 8*1024*1024) + + for scanner.Scan() { + line := scanner.Text() + + if !strings.HasPrefix(line, "data: ") { + continue + } + + dataStr := strings.TrimSpace(line[6:]) + if dataStr == "" { + continue + } + + // End + if dataStr == "[DONE]" { + break + } + + var event struct { + Type string `json:"type"` + Delta string `json:"delta"` + } + + if err := json.Unmarshal([]byte(dataStr), &event); err != nil { + continue + } + + // Parse delta audio + if event.Type == "conversation.item.audio_output.delta" && event.Delta != "" { + audioBytes, err := base64.StdEncoding.DecodeString(event.Delta) + if err == nil && len(audioBytes) > 0 { + chunk := string(audioBytes) + if errSend := sender(&chunk, nil); errSend != nil { + return errSend + } + } + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("error reading TogetherAI stream: %w", err) + } + + return nil } func (t *TogetherAIModel) OCRFile(modelName *string, content []byte, url *string, apiConfig *APIConfig, ocrConfig *OCRConfig) (*OCRFileResponse, error) { From 8995662ee6e073e3d28ba2d6cb7173e060f324cf Mon Sep 17 00:00:00 2001 From: writinwaters <93570324+writinwaters@users.noreply.github.com> Date: Thu, 21 May 2026 22:04:44 +0800 Subject: [PATCH 05/10] Docs: Updated v0.25.5 release notes (#15109) ### What problem does this PR solve? Updated v0.25.5 release notes. ### Type of change - [x] Documentation Update --- docs/release_notes.md | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/docs/release_notes.md b/docs/release_notes.md index 46a2e791516..40b25775d82 100644 --- a/docs/release_notes.md +++ b/docs/release_notes.md @@ -15,32 +15,31 @@ Released on May 20, 2026. ### New features -- Adds local & SSH providers in admin panel. [#15039](https://github.com/infiniflow/ragflow/pull/15039) +- Introduces local and SSH provider options for sandbox environment settings within the admin interface, allowing administrators to configure execution environments without editing environment variables. [#15039](https://github.com/infiniflow/ragflow/pull/15039) ### Improvements -- Accelerated dataset search path, reducing latency by 50–100% by removing expensive vector fetch and rerank similarity computation steps. [#14970](https://github.com/infiniflow/ragflow/pull/14970) -- Pushes metadata filters down to Infinity, significantly speeding up metadata filtering. [#14974](https://github.com/infiniflow/ragflow/pull/14974) -- added Redis caching for TTS. [#14851](https://github.com/infiniflow/ragflow/pull/14851) -- atomic document counter updates [#14867](https://github.com/infiniflow/ragflow/pull/14974) -- Improved server startup speed and memory usage [#14973](https://github.com/infiniflow/ragflow/pull/14973) -- Agent: structured output aggregation [#13384](https://github.com/infiniflow/ragflow/issues/13384) [#14848](https://github.com/infiniflow/ragflow/pull/14848) -- Agent: metadata filter reuse. [#14849](https://github.com/infiniflow/ragflow/pull/14849) -- Optimizes connector dashboard. [#14979](https://github.com/infiniflow/ragflow/pull/14979) -- Data source: GitHub connector now syncs PRs/Issues by default. -- Bump minimum supported Python version to 3.13. [#14767](https://github.com/infiniflow/ragflow/pull/14767) -- Bump nginx to 1.31.0. [#15007](https://github.com/infiniflow/ragflow/pull/15007) +- Elasticsearch: Accelerates the retrieval process by removing unnecessary vector fetches during the main search phase, reducing latency by 50–100%. [#14970](https://github.com/infiniflow/ragflow/pull/14970) +- Pushes metadata filters down to the [Infinity](https://github.com/infiniflow/infinity) document engine, significantly improving retrieval performance. [#14974](https://github.com/infiniflow/ragflow/pull/14974) +- Introduces Redis-based caching for Text-to-Speech model outputs, eliminating redundant API calls for identical text to reduce latency and save provider quota. [#14851](https://github.com/infiniflow/ragflow/pull/14851) +- Reduces server startup time by 5-9 seconds and saves roughly 200MB of memory by replacing heavy module-level imports with lazy runtime loading. [#14973](https://github.com/infiniflow/ragflow/pull/14973) +- Optimizes the connector dashboard. [#14979](https://github.com/infiniflow/ragflow/pull/14979) +- Bumps minimum supported Python version to 3.13. [#14767](https://github.com/infiniflow/ragflow/pull/14767) ### Bug fixes -- Fixed Tongyi-Qianwen embedding API calls [#14784](https://github.com/infiniflow/ragflow/pull/14784) -- Agent: Fixed MCP tool name duplication. +- Atomic database updates: Wraps document and dataset chunk counter updates in atomic database transactions to prevent data drift. [#14866](https://github.com/infiniflow/ragflow/issues/14866)[#14867](https://github.com/infiniflow/ragflow/pull/14867) +- Data source: the GitHub data source connector was failing to sync any content by default. [#13975](https://github.com/infiniflow/ragflow/issues/13975)[#14062](https://github.com/infiniflow/ragflow/pull/14062) +- The Tongyi-Qianwen text embedding models were hitting the wrong API endpoints when configured with international or Chinese regional URLs. [#14784](https://github.com/infiniflow/ragflow/pull/14784) +- Agent: Fully aggregates message content, reference data, and structured outputs across all generated events to fix incomplete responses in the non-streaming `/api/v1/agentbots//completions` endpoint. [#13384](https://github.com/infiniflow/ragflow/issues/13384)[#14848](https://github.com/infiniflow/ragflow/pull/14848) +- Prevents the **Retrieval** component's manual metadata filters from getting stuck on the first loop's value by making a temporary copy of the filter settings to preserve the original placeholder.[#12582](https://github.com/infiniflow/ragflow/issues/12582)[#14849](https://github.com/infiniflow/ragflow/pull/14849) +- Agent: Fixed MCP tool name duplication.[#14217](https://github.com/infiniflow/ragflow/pull/14217) - Agent: top_k passing issues [#14760](https://github.com/infiniflow/ragflow/pull/14760) -- Chat file attachment loss. +- Chat file attachment loss. [#13993](https://github.com/infiniflow/ragflow/pull/13993) - IMAP multi-address parsing [#15006](https://github.com/infiniflow/ragflow/pull/15006) - Langfuse token usage reporting. [#13294](https://github.com/infiniflow/ragflow/pull/13294) -- Reranking robustness. - +- Reranking robustness. [#14264](https://github.com/infiniflow/ragflow/pull/14264) +- Bumps nginx to 1.31.0. [#14928](https://github.com/infiniflow/ragflow/issues/14928)[#15007](https://github.com/infiniflow/ragflow/pull/15007) ## v0.25.4 @@ -108,7 +107,7 @@ Released on May 11, 2026. - Metadata visibility issues during v0.24.0 to v0.25.0 upgrades. - Duplicate chat output. -- Metadata filtering was handled in-memory instead of leveraging Elasticsearch, incurring performance bottlenecks. [#14576](https://github.com/infiniflow/ragflow/pull/14576) +- {#meta-es} Metadata filtering was handled in-memory instead of leveraging Elasticsearch, incurring performance bottlenecks. [#14576](https://github.com/infiniflow/ragflow/pull/14576) ## v0.25.1 From 57ddd791832f652d6ef673ee3cc2b233820b654c Mon Sep 17 00:00:00 2001 From: writinwaters <93570324+writinwaters@users.noreply.github.com> Date: Thu, 21 May 2026 22:43:49 +0800 Subject: [PATCH 06/10] Docs: Fixed a deployment issue (#15114) ### What problem does this PR solve? Fixed a docusaurus deployment issue. ### Type of change - [x] Documentation Update --- docs/release_notes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release_notes.md b/docs/release_notes.md index 40b25775d82..3c99964f89b 100644 --- a/docs/release_notes.md +++ b/docs/release_notes.md @@ -107,7 +107,7 @@ Released on May 11, 2026. - Metadata visibility issues during v0.24.0 to v0.25.0 upgrades. - Duplicate chat output. -- {#meta-es} Metadata filtering was handled in-memory instead of leveraging Elasticsearch, incurring performance bottlenecks. [#14576](https://github.com/infiniflow/ragflow/pull/14576) +- Metadata filtering was handled in-memory instead of leveraging Elasticsearch, incurring performance bottlenecks. [#14576](https://github.com/infiniflow/ragflow/pull/14576) ## v0.25.1 From 417ab34bfa088feb38a0a5d6774e8529a4ffc82e Mon Sep 17 00:00:00 2001 From: rene0422 Date: Wed, 20 May 2026 13:27:07 -0700 Subject: [PATCH 07/10] Feat: replace delimiter text input with block-based builder (#15057) --- web/src/components/delimiter-form-field.tsx | 199 +++++++++++++++++++- web/src/locales/en.ts | 6 + web/src/locales/zh.ts | 6 + 3 files changed, 206 insertions(+), 5 deletions(-) diff --git a/web/src/components/delimiter-form-field.tsx b/web/src/components/delimiter-form-field.tsx index 40f97a2c810..9db1b2b98eb 100644 --- a/web/src/components/delimiter-form-field.tsx +++ b/web/src/components/delimiter-form-field.tsx @@ -1,5 +1,16 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input, InputProps } from '@/components/ui/input'; import { cn } from '@/lib/utils'; -import { forwardRef } from 'react'; +import { Plus, X } from 'lucide-react'; +import { + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { @@ -9,7 +20,6 @@ import { FormLabel, FormMessage, } from './ui/form'; -import { Input, InputProps } from './ui/input'; interface IProps { value?: string | undefined; @@ -47,6 +57,182 @@ export const DelimiterInput = forwardRef( }, ); +// Parse a delimiter string into discrete blocks. +// Multi-char delimiters are wrapped in backticks per the chunker contract +// (see rag/nlp/__init__.py::naive_merge): every other char is a single-char +// delimiter on its own. +export function parseDelimiterBlocks(s: string): string[] { + const out: string[] = []; + let i = 0; + while (i < s.length) { + if (s[i] === '`') { + const end = s.indexOf('`', i + 1); + if (end > i) { + const inner = s.slice(i + 1, end); + if (inner) out.push(inner); + i = end + 1; + continue; + } + } + out.push(s[i]); + i++; + } + return out; +} + +export function serializeDelimiterBlocks(blocks: string[]): string { + return blocks + .filter((b) => b.length > 0) + .map((b) => (b.length > 1 ? `\`${b}\`` : b)) + .join(''); +} + +function describeBlock(b: string): string { + if (b === '\n') return '↵'; + if (b === '\t') return '⇥'; + if (b === '\r') return '↩'; + return b; +} + +interface ChipProps { + block: string; + onRemove: () => void; +} + +function DelimiterChip({ block, onRemove }: ChipProps) { + return ( + + {describeBlock(block)} + + + ); +} + +interface BuilderProps { + value?: string; + onChange?: (val: string) => void; + presets?: Array<{ label: string; value: string }>; +} + +export function DelimiterBuilder({ value, onChange, presets }: BuilderProps) { + const { t } = useTranslation(); + const blocks = useMemo(() => parseDelimiterBlocks(value ?? ''), [value]); + const [adding, setAdding] = useState(false); + const [draft, setDraft] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + if (adding) inputRef.current?.focus(); + }, [adding]); + + const commit = useCallback( + (next: string[]) => onChange?.(serializeDelimiterBlocks(next)), + [onChange], + ); + + const appendRaw = useCallback( + (raw: string) => { + if (!raw) return; + const decoded = raw + .replaceAll('\\n', '\n') + .replaceAll('\\t', '\t') + .replaceAll('\\r', '\r'); + commit([...blocks, decoded]); + }, + [blocks, commit], + ); + + const handleSubmit = useCallback(() => { + appendRaw(draft); + setDraft(''); + setAdding(false); + }, [appendRaw, draft]); + + const defaultPresets = useMemo( + () => [ + { label: `↵ ${t('knowledgeDetails.delimiterPresetNewline')}`, value: '\n' }, + { label: `⇥ ${t('knowledgeDetails.delimiterPresetTab')}`, value: '\t' }, + { label: `## ${t('knowledgeDetails.delimiterPresetHeading')}`, value: '##' }, + { label: `--- ${t('knowledgeDetails.delimiterPresetHr')}`, value: '---' }, + ], + [t], + ); + + const effectivePresets = presets ?? defaultPresets; + + return ( +
+
+ {blocks.map((b, i) => ( + commit(blocks.filter((_, idx) => idx !== i))} + /> + ))} + {adding ? ( + setDraft(e.target.value)} + onBlur={handleSubmit} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + handleSubmit(); + } else if (e.key === 'Escape') { + e.preventDefault(); + setDraft(''); + setAdding(false); + } + }} + placeholder={t('knowledgeDetails.delimiterAddPlaceholder')} + className="h-7 w-32 px-2 py-1 text-sm" + /> + ) : ( + + )} +
+
+ + {t('knowledgeDetails.delimiterPresetsLabel')} + + {effectivePresets.map((p) => ( + + ))} +
+
+ ); +} + export function DelimiterFormField() { const { t } = useTranslation(); const form = useFormContext(); @@ -62,17 +248,20 @@ export function DelimiterFormField() { } return ( -
+
{t('knowledgeDetails.delimiter')}
- +
diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index 18f988691c6..668c05f287c 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -568,6 +568,12 @@ Example: A 1 KB message with 1024-dim embedding uses ~9 KB. The 5 MB default lim delimiter: `Delimiter for text`, delimiterTip: 'A delimiter or separator can consist of one or multiple special characters. If it is multiple characters, ensure they are enclosed in backticks( ``). For example, if you configure your delimiters like this: \\n`##`;, then your texts will be separated at line breaks, double hash symbols (##), and semicolons.', + delimiterAddPlaceholder: 'Type and press Enter', + delimiterPresetsLabel: 'Presets:', + delimiterPresetNewline: 'Newline', + delimiterPresetTab: 'Tab', + delimiterPresetHeading: 'Heading', + delimiterPresetHr: 'Horizontal rule', enableChildrenDelimiter: 'Child chunk are used for retrieval', childrenDelimiter: 'Delimiter for text', childrenDelimiterTip: diff --git a/web/src/locales/zh.ts b/web/src/locales/zh.ts index 17c368ee195..f31fac2395e 100644 --- a/web/src/locales/zh.ts +++ b/web/src/locales/zh.ts @@ -512,6 +512,12 @@ export default { delimiter: `文本分段标识符`, delimiterTip: '支持多字符作为分隔符,多字符用两个反引号 \\`\\` 分隔符包裹。若配置成:\\n`##`; 系统将首先使用换行符、两个#号以及分号先对文本进行分割,随后再对分得的小文本块按照「建议文本块大小」设定的大小进行拼装。在设置文本分段标识符前请确保理解上述文本分段切片机制。', + delimiterAddPlaceholder: '输入后按回车确认', + delimiterPresetsLabel: '快捷分隔符:', + delimiterPresetNewline: '换行', + delimiterPresetTab: '制表符', + delimiterPresetHeading: '标题', + delimiterPresetHr: '分隔线', enableChildrenDelimiter: '子文本块用于检索', childrenDelimiter: '文本分段标识符', childrenDelimiterTip: From 9c783bfbcc871ba451b7cf34f667170a31745e63 Mon Sep 17 00:00:00 2001 From: rene0422 Date: Wed, 20 May 2026 14:14:43 -0700 Subject: [PATCH 08/10] i18n: localize delimiter chip aria-label --- web/src/components/delimiter-form-field.tsx | 3 ++- web/src/locales/en.ts | 1 + web/src/locales/zh.ts | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/web/src/components/delimiter-form-field.tsx b/web/src/components/delimiter-form-field.tsx index 9db1b2b98eb..5ac61a48d09 100644 --- a/web/src/components/delimiter-form-field.tsx +++ b/web/src/components/delimiter-form-field.tsx @@ -100,6 +100,7 @@ interface ChipProps { } function DelimiterChip({ block, onRemove }: ChipProps) { + const { t } = useTranslation(); return ( {describeBlock(block)} From 30c925bf9b20695f5e37da14a9e4fbd14180a208 Mon Sep 17 00:00:00 2001 From: rene0422 Date: Thu, 21 May 2026 08:26:55 -0700 Subject: [PATCH 10/10] fix: guard delimiter input Enter against IME composition --- web/src/components/delimiter-form-field.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/web/src/components/delimiter-form-field.tsx b/web/src/components/delimiter-form-field.tsx index 177de329216..f39c5149a73 100644 --- a/web/src/components/delimiter-form-field.tsx +++ b/web/src/components/delimiter-form-field.tsx @@ -144,10 +144,15 @@ export function DelimiterBuilder({ value, onChange, presets }: BuilderProps) { const appendRaw = useCallback( (raw: string) => { if (!raw) return; + // Wire format reserves the backtick to wrap multi-char blocks and has + // no escape syntax (see rag/nlp/__init__.py::get_delimiters), so a + // user-entered ` cannot round-trip — strip it at the input boundary. const decoded = raw .replaceAll('\\n', '\n') .replaceAll('\\t', '\t') - .replaceAll('\\r', '\r'); + .replaceAll('\\r', '\r') + .replaceAll('`', ''); + if (!decoded) return; commit([...blocks, decoded]); }, [blocks, commit], @@ -189,6 +194,7 @@ export function DelimiterBuilder({ value, onChange, presets }: BuilderProps) { onBlur={handleSubmit} onKeyDown={(e) => { if (e.key === 'Enter') { + if (e.nativeEvent.isComposing || e.keyCode === 229) return; e.preventDefault(); handleSubmit(); } else if (e.key === 'Escape') {