Skip to content

Commit 0292c5b

Browse files
authored
Merge branch 'release-1.8.0' into fix-knowledge-global-llm
2 parents 02a04f6 + 84ba5a5 commit 0292c5b

8 files changed

Lines changed: 316 additions & 8 deletions

File tree

src/backend/base/langflow/api/utils/kb_helpers.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from langchain_chroma import Chroma
1616
from langchain_core.documents import Document
1717
from langchain_text_splitters import RecursiveCharacterTextSplitter
18+
from lfx.base.data.utils import extract_text_from_bytes
1819
from lfx.base.models.unified_models import get_embedding_model_options
1920
from lfx.components.models_and_agents.embedding_model import EmbeddingModelComponent
2021
from lfx.log import logger
@@ -330,7 +331,7 @@ async def perform_ingestion(
330331
job_id_str = str(task_job_id)
331332
for file_name, file_content in files_data:
332333
await logger.ainfo("Starting ingestion of %s for %s", file_name, kb_name)
333-
content = file_content.decode("utf-8", errors="ignore")
334+
content = extract_text_from_bytes(file_name, file_content)
334335
if not content.strip():
335336
continue
336337

src/backend/base/langflow/api/v1/knowledge_bases.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile
1313
from langchain_chroma import Chroma
1414
from langchain_text_splitters import RecursiveCharacterTextSplitter
15+
from lfx.base.data.utils import extract_text_from_bytes
1516
from lfx.log import logger
1617

1718
from langflow.api.utils import CurrentActiveUser
@@ -170,7 +171,7 @@ async def preview_chunks(
170171
try:
171172
file_content = await uploaded_file.read()
172173
file_name = uploaded_file.filename or "unknown"
173-
text_content = file_content.decode("utf-8", errors="ignore")
174+
text_content = extract_text_from_bytes(file_name, file_content)
174175

175176
if not text_content.strip():
176177
file_previews.append(
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
from io import BytesIO
2+
from unittest.mock import MagicMock, patch
3+
4+
import pytest
5+
from lfx.base.data.utils import extract_text_from_bytes
6+
from pypdf import PdfWriter
7+
8+
9+
def _make_blank_pdf(num_pages: int = 1) -> bytes:
10+
"""Create a valid PDF with blank pages."""
11+
writer = PdfWriter()
12+
for _ in range(num_pages):
13+
writer.add_blank_page(width=612, height=792)
14+
buf = BytesIO()
15+
writer.write(buf)
16+
return buf.getvalue()
17+
18+
19+
def _mock_pdf_reader(pages_text: list[str]):
20+
"""Create a mock PdfReader that returns pages with given text."""
21+
mock_reader = MagicMock()
22+
mock_pages = []
23+
for text in pages_text:
24+
page = MagicMock()
25+
page.extract_text.return_value = text
26+
mock_pages.append(page)
27+
mock_reader.pages = mock_pages
28+
mock_reader.__enter__ = MagicMock(return_value=mock_reader)
29+
mock_reader.__exit__ = MagicMock(return_value=False)
30+
return mock_reader
31+
32+
33+
class TestExtractTextFromBytesPDF:
34+
@patch("lfx.base.data.utils.PdfReader")
35+
def test_should_extract_text_from_valid_pdf(self, mock_reader_cls):
36+
mock_reader_cls.return_value = _mock_pdf_reader(["Hello World"])
37+
result = extract_text_from_bytes("document.pdf", _make_blank_pdf())
38+
assert "Hello World" in result
39+
40+
@patch("lfx.base.data.utils.PdfReader")
41+
def test_should_extract_text_from_multi_page_pdf(self, mock_reader_cls):
42+
mock_reader_cls.return_value = _mock_pdf_reader(["Page one content", "Page two content"])
43+
result = extract_text_from_bytes("multi.pdf", _make_blank_pdf(2))
44+
assert "Page one content" in result
45+
assert "Page two content" in result
46+
47+
@patch("lfx.base.data.utils.PdfReader")
48+
def test_should_join_pages_with_double_newline(self, mock_reader_cls):
49+
mock_reader_cls.return_value = _mock_pdf_reader(["First", "Second"])
50+
result = extract_text_from_bytes("test.pdf", _make_blank_pdf(2))
51+
assert result == "First\n\nSecond"
52+
53+
@patch("lfx.base.data.utils.PdfReader")
54+
def test_should_be_case_insensitive_on_extension(self, mock_reader_cls):
55+
mock_reader_cls.return_value = _mock_pdf_reader(["Test"])
56+
result = extract_text_from_bytes("DOC.PDF", _make_blank_pdf())
57+
assert "Test" in result
58+
59+
def test_should_raise_value_error_for_corrupted_pdf(self):
60+
with pytest.raises(ValueError, match="Failed to parse PDF file"):
61+
extract_text_from_bytes("bad.pdf", b"this is not a pdf")
62+
63+
def test_should_raise_value_error_for_empty_pdf_bytes(self):
64+
with pytest.raises(ValueError, match="Failed to parse PDF file"):
65+
extract_text_from_bytes("empty.pdf", b"")
66+
67+
def test_should_handle_pdf_with_blank_pages(self):
68+
result = extract_text_from_bytes("blank.pdf", _make_blank_pdf())
69+
assert isinstance(result, str)
70+
71+
@patch("lfx.base.data.utils.PdfReader")
72+
def test_should_handle_page_returning_none(self, mock_reader_cls):
73+
mock_reader_cls.return_value = _mock_pdf_reader(["Text"])
74+
mock_reader_cls.return_value.pages[0].extract_text.return_value = None
75+
mock_reader_cls.return_value.__enter__.return_value = mock_reader_cls.return_value
76+
result = extract_text_from_bytes("null_page.pdf", _make_blank_pdf())
77+
assert isinstance(result, str)
78+
79+
80+
class TestExtractTextFromBytesDOCX:
81+
def test_should_extract_text_from_valid_docx(self):
82+
from docx import Document
83+
84+
doc = Document()
85+
doc.add_paragraph("Hello from DOCX")
86+
buf = BytesIO()
87+
doc.save(buf)
88+
89+
result = extract_text_from_bytes("file.docx", buf.getvalue())
90+
assert "Hello from DOCX" in result
91+
92+
def test_should_extract_multiple_paragraphs(self):
93+
from docx import Document
94+
95+
doc = Document()
96+
doc.add_paragraph("First paragraph")
97+
doc.add_paragraph("Second paragraph")
98+
buf = BytesIO()
99+
doc.save(buf)
100+
101+
result = extract_text_from_bytes("file.docx", buf.getvalue())
102+
assert "First paragraph" in result
103+
assert "Second paragraph" in result
104+
assert "\n\n" in result
105+
106+
def test_should_be_case_insensitive_on_extension(self):
107+
from docx import Document
108+
109+
doc = Document()
110+
doc.add_paragraph("Case test")
111+
buf = BytesIO()
112+
doc.save(buf)
113+
114+
result = extract_text_from_bytes("FILE.DOCX", buf.getvalue())
115+
assert "Case test" in result
116+
117+
def test_should_raise_value_error_for_corrupted_docx(self):
118+
with pytest.raises(ValueError, match="Failed to parse DOCX file"):
119+
extract_text_from_bytes("bad.docx", b"not a valid docx")
120+
121+
def test_should_raise_value_error_for_empty_docx_bytes(self):
122+
with pytest.raises(ValueError, match="Failed to parse DOCX file"):
123+
extract_text_from_bytes("empty.docx", b"")
124+
125+
def test_should_handle_docx_with_no_paragraphs(self):
126+
from docx import Document
127+
128+
doc = Document()
129+
buf = BytesIO()
130+
doc.save(buf)
131+
132+
result = extract_text_from_bytes("empty_doc.docx", buf.getvalue())
133+
assert isinstance(result, str)
134+
135+
136+
class TestExtractTextFromBytesPlainText:
137+
def test_should_decode_utf8_text(self):
138+
content = b"Hello plain text"
139+
result = extract_text_from_bytes("readme.txt", content)
140+
assert result == "Hello plain text"
141+
142+
def test_should_handle_non_utf8_gracefully(self):
143+
content = b"\xff\xfe\x00\x01 some text"
144+
result = extract_text_from_bytes("binary.txt", content)
145+
assert isinstance(result, str)
146+
assert "some text" in result
147+
148+
def test_should_handle_empty_content(self):
149+
result = extract_text_from_bytes("empty.txt", b"")
150+
assert result == ""
151+
152+
def test_should_handle_csv_as_plain_text(self):
153+
content = b"col1,col2\nval1,val2"
154+
result = extract_text_from_bytes("data.csv", content)
155+
assert "col1,col2" in result
156+
157+
def test_should_handle_json_as_plain_text(self):
158+
content = b'{"key": "value"}'
159+
result = extract_text_from_bytes("data.json", content)
160+
assert '"key"' in result
161+
162+
def test_should_handle_unknown_extension_as_plain_text(self):
163+
content = b"some content"
164+
result = extract_text_from_bytes("file.xyz", content)
165+
assert result == "some content"
166+
167+
def test_should_handle_file_without_extension(self):
168+
content = b"no extension"
169+
result = extract_text_from_bytes("Makefile", content)
170+
assert result == "no extension"
171+
172+
def test_should_preserve_unicode_characters(self):
173+
content = "café résumé naïve".encode()
174+
result = extract_text_from_bytes("unicode.txt", content)
175+
assert result == "café résumé naïve"

src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/__tests__/ModelInputComponent.test.tsx

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,20 @@ jest.mock("@/stores/alertStore", () => ({
1616
}),
1717
}));
1818

19+
// Mock useRefreshModelInputs with controllable promise
20+
let mockRefreshResolve: () => void;
21+
const mockRefreshAllModelInputs = jest.fn(
22+
() =>
23+
new Promise<void>((resolve) => {
24+
mockRefreshResolve = resolve;
25+
}),
26+
);
27+
jest.mock("@/hooks/use-refresh-model-inputs", () => ({
28+
useRefreshModelInputs: () => ({
29+
refreshAllModelInputs: mockRefreshAllModelInputs,
30+
}),
31+
}));
32+
1933
jest.mock("@/stores/flowStore", () => ({
2034
__esModule: true,
2135
default: {
@@ -310,6 +324,92 @@ describe("ModelInputComponent", () => {
310324
});
311325
});
312326

327+
describe("Refresh List", () => {
328+
it("should close popover before entering loading state when refresh is clicked", async () => {
329+
const user = userEvent.setup();
330+
renderWithQueryClient(<ModelInputComponent {...defaultProps} />);
331+
332+
const trigger = screen.getByRole("combobox");
333+
await user.click(trigger);
334+
335+
await waitFor(() => {
336+
expect(screen.getByTestId("refresh-model-list")).toBeInTheDocument();
337+
});
338+
339+
const refreshButton = screen.getByTestId("refresh-model-list");
340+
await user.click(refreshButton);
341+
342+
await waitFor(() => {
343+
expect(screen.getByText("Loading models")).toBeInTheDocument();
344+
});
345+
346+
mockRefreshResolve();
347+
348+
await waitFor(() => {
349+
expect(screen.getByRole("combobox")).toBeInTheDocument();
350+
});
351+
352+
// Popover must be closed after refresh to prevent width measurement glitch
353+
expect(screen.queryByTestId("gpt-4-option")).not.toBeInTheDocument();
354+
expect(screen.queryByText("OpenAI")).not.toBeInTheDocument();
355+
});
356+
357+
it("should not crash when component renders without popover open during refresh", () => {
358+
mockRefreshAllModelInputs.mockImplementationOnce(() => Promise.resolve());
359+
renderWithQueryClient(<ModelInputComponent {...defaultProps} />);
360+
361+
expect(screen.getByRole("combobox")).toBeInTheDocument();
362+
expect(screen.queryByTestId("gpt-4-option")).not.toBeInTheDocument();
363+
});
364+
365+
it("should call refresh with silent flag exactly once per click", async () => {
366+
const user = userEvent.setup();
367+
renderWithQueryClient(<ModelInputComponent {...defaultProps} />);
368+
369+
const trigger = screen.getByRole("combobox");
370+
await user.click(trigger);
371+
372+
await waitFor(() => {
373+
expect(screen.getByTestId("refresh-model-list")).toBeInTheDocument();
374+
});
375+
376+
const refreshButton = screen.getByTestId("refresh-model-list");
377+
await user.click(refreshButton);
378+
379+
expect(mockRefreshAllModelInputs).toHaveBeenCalledTimes(1);
380+
expect(mockRefreshAllModelInputs).toHaveBeenCalledWith({ silent: true });
381+
382+
mockRefreshResolve();
383+
});
384+
385+
it("should recover to normal state when refresh rejects", async () => {
386+
// handleRefreshButtonPress uses try/finally, so refreshOptions resets even on error
387+
mockRefreshAllModelInputs.mockImplementationOnce(() =>
388+
Promise.reject(new Error("Network error")),
389+
);
390+
391+
const user = userEvent.setup();
392+
renderWithQueryClient(<ModelInputComponent {...defaultProps} />);
393+
394+
const trigger = screen.getByRole("combobox");
395+
await user.click(trigger);
396+
397+
await waitFor(() => {
398+
expect(screen.getByTestId("refresh-model-list")).toBeInTheDocument();
399+
});
400+
401+
const refreshButton = screen.getByTestId("refresh-model-list");
402+
await user.click(refreshButton);
403+
404+
// finally block sets refreshOptions=false, restoring the combobox
405+
await waitFor(() => {
406+
expect(screen.getByRole("combobox")).toBeInTheDocument();
407+
});
408+
409+
expect(screen.queryByText("Loading models")).not.toBeInTheDocument();
410+
});
411+
});
412+
313413
describe("Edge Cases", () => {
314414
it("should filter out disabled provider models from grouped options", () => {
315415
const optionsWithDisabled: ModelOption[] = [

src/frontend/src/components/core/parameterRenderComponent/components/modelInputComponent/index.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,9 +185,12 @@ export default function ModelInputComponent({
185185
);
186186

187187
const handleRefreshButtonPress = useCallback(async () => {
188+
setOpen(false);
188189
setRefreshOptions(true);
189190
try {
190191
await refreshAllModelInputs({ silent: true });
192+
} catch {
193+
// refreshAllModelInputs handles its own error notifications via alertStore
191194
} finally {
192195
setRefreshOptions(false);
193196
}

src/frontend/src/modals/knowledgeBaseUploadModal/__tests__/KnowledgeBaseUploadModal.test.tsx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -559,8 +559,8 @@ describe("KnowledgeBaseUploadModal", () => {
559559
const validFile = new File(["content"], "valid.md", {
560560
type: "text/markdown",
561561
});
562-
const invalidFile = new File(["content"], "invalid.pdf", {
563-
type: "application/pdf",
562+
const invalidFile = new File(["content"], "invalid.exe", {
563+
type: "application/x-msdownload",
564564
});
565565

566566
// Manually trigger the change event
@@ -573,11 +573,11 @@ describe("KnowledgeBaseUploadModal", () => {
573573
fireEvent.change(folderInput, event);
574574

575575
expect(screen.getByText("valid.md")).toBeInTheDocument();
576-
expect(screen.queryByText("invalid.pdf")).not.toBeInTheDocument();
576+
expect(screen.queryByText("invalid.exe")).not.toBeInTheDocument();
577577

578578
expect(mockSetErrorData).toHaveBeenCalledWith(
579579
expect.objectContaining({
580-
list: expect.arrayContaining(["invalid.pdf"]),
580+
list: expect.arrayContaining(["invalid.exe"]),
581581
}),
582582
);
583583
});

src/frontend/src/modals/knowledgeBaseUploadModal/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ export const KB_INGEST_FORMATS: Record<string, string[]> = {
2626
"adoc",
2727
"asciidoc",
2828
"asc",
29+
"pdf",
30+
"docx",
2931
],
3032
spreadsheets: ["csv"],
3133
code: ["py", "js", "ts", "tsx", "sh", "sql"],

0 commit comments

Comments
 (0)