From afe6d492af147c7fa952ad03685fcb21686e2186 Mon Sep 17 00:00:00 2001 From: axelray-dev <110029405+axelray-dev@users.noreply.github.com> Date: Mon, 8 Jun 2026 07:21:25 +0800 Subject: [PATCH 1/2] fix: set inline disposition for renderable blob elements SQLAlchemyDataLayer.create_element never passed content_disposition to storage_provider.upload_file, so Azure Blob Storage defaulted to Content-Disposition: attachment. This caused PDF elements (and other browser-renderable types) to render blank on chat resume because the browser refused to display them in an iframe. Set content_disposition='inline' for browser-renderable MIME types (application/pdf, image/*, audio/*, video/*) while preserving the existing behavior (None) for non-renderable uploads like generic files. Fixes #2946 Co-Authored-By: OpenAI Codex --- backend/chainlit/data/sql_alchemy.py | 17 ++++++- backend/tests/data/test_sql_alchemy.py | 62 ++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/backend/chainlit/data/sql_alchemy.py b/backend/chainlit/data/sql_alchemy.py index 50a65d2a41..90d051242b 100644 --- a/backend/chainlit/data/sql_alchemy.py +++ b/backend/chainlit/data/sql_alchemy.py @@ -615,8 +615,23 @@ async def create_element(self, element: "Element"): if not element.mime: element.mime = "application/octet-stream" + # Set inline disposition for browser-renderable elements so they display + # correctly on chat resume (e.g. PDF in iframe, images in tags). + content_disposition = None + _RENDERABLE_MIME_PREFIXES = ("image/", "audio/", "video/") + _RENDERABLE_MIME_TYPES = ("application/pdf",) + if element.mime: + if element.mime in _RENDERABLE_MIME_TYPES or element.mime.startswith( + _RENDERABLE_MIME_PREFIXES + ): + content_disposition = "inline" + uploaded_file = await self.storage_provider.upload_file( - object_key=file_object_key, data=content, mime=element.mime, overwrite=True + object_key=file_object_key, + data=content, + mime=element.mime, + overwrite=True, + content_disposition=content_disposition, ) if not uploaded_file: raise ValueError( diff --git a/backend/tests/data/test_sql_alchemy.py b/backend/tests/data/test_sql_alchemy.py index c509c6a95e..94cd1b4f13 100644 --- a/backend/tests/data/test_sql_alchemy.py +++ b/backend/tests/data/test_sql_alchemy.py @@ -149,6 +149,68 @@ async def test_create_and_get_element( # The 'content' field is not part of the ElementDict, so we remove this assertion +async def test_create_element_pdf_inline_disposition( + mock_chainlit_context, data_layer: SQLAlchemyDataLayer +): + """PDF elements must get content_disposition=inline for Azure Blob iframe rendering.""" + async with mock_chainlit_context: + from chainlit.element import Pdf + + pdf_element = Pdf( + id=str(uuid.uuid4()), + name="test.pdf", + mime="application/pdf", + content=b"%PDF-1.4 fake", + for_id="test_step_id", + ) + await data_layer.create_element(pdf_element) + + upload_call = data_layer.storage_provider.upload_file.call_args + assert upload_call.kwargs.get("content_disposition") == "inline", ( + f"Expected content_disposition=inline for PDF, got {upload_call.kwargs}" + ) + + +async def test_create_element_text_default_disposition( + mock_chainlit_context, data_layer: SQLAlchemyDataLayer +): + """Non-renderable elements must keep content_disposition=None (default).""" + async with mock_chainlit_context: + text_element = Text( + id=str(uuid.uuid4()), + name="test.txt", + mime="text/plain", + content="test content", + for_id="test_step_id", + ) + await data_layer.create_element(text_element) + + upload_call = data_layer.storage_provider.upload_file.call_args + assert upload_call.kwargs.get("content_disposition") is None, ( + f"Expected content_disposition=None for text/plain, got {upload_call.kwargs}" + ) + + +async def test_create_element_image_inline_disposition( + mock_chainlit_context, data_layer: SQLAlchemyDataLayer +): + """Image elements must get content_disposition=inline.""" + async with mock_chainlit_context: + img_element = Text( + id=str(uuid.uuid4()), + name="test.png", + mime="image/png", + content=b"fake png", + for_id="test_step_id", + ) + await data_layer.create_element(img_element) + + upload_call = data_layer.storage_provider.upload_file.call_args + assert upload_call.kwargs.get("content_disposition") == "inline", ( + f"Expected content_disposition=inline for image/png, got {upload_call.kwargs}" + ) + + async def test_get_current_timestamp(data_layer: SQLAlchemyDataLayer): timestamp = await data_layer.get_current_timestamp() assert isinstance(timestamp, str) From 3d7346eeff8aa2c35259330e82870058fb597b51 Mon Sep 17 00:00:00 2001 From: axelray-dev <110029405+axelray-dev@users.noreply.github.com> Date: Mon, 3 Aug 2026 08:07:26 +0800 Subject: [PATCH 2/2] fix: centralize blob content disposition on Element type Move renderable disposition policy out of SQLAlchemy into Element.get_content_disposition based on element.type, and use it in SQLAlchemy, DynamoDB, and ChainlitDataLayer so browser-rendered media gets inline disposition consistently. --- backend/chainlit/data/chainlit_data_layer.py | 14 ++-- backend/chainlit/data/dynamodb.py | 1 + backend/chainlit/data/sql_alchemy.py | 11 +-- backend/chainlit/element.py | 12 ++++ .../tests/data/test_chainlit_data_layer.py | 67 +++++++++++++++++++ backend/tests/data/test_sql_alchemy.py | 26 +++++-- backend/tests/test_element.py | 13 ++++ 7 files changed, 119 insertions(+), 25 deletions(-) diff --git a/backend/chainlit/data/chainlit_data_layer.py b/backend/chainlit/data/chainlit_data_layer.py index 4faae56792..0bc9d7b3fb 100644 --- a/backend/chainlit/data/chainlit_data_layer.py +++ b/backend/chainlit/data/chainlit_data_layer.py @@ -195,14 +195,12 @@ async def create_element(self, element: "Element"): else: path = f"files/{element.id}" - content_disposition = ( - f'attachment; filename="{element.name}"' - if not ( - GCSStorageClient is not None - and isinstance(self.storage_client, GCSStorageClient) - ) - else None - ) + content_disposition: str | None = element.get_content_disposition() + if content_disposition is None and not ( + GCSStorageClient is not None + and isinstance(self.storage_client, GCSStorageClient) + ): + content_disposition = f'attachment; filename="{element.name}"' await self.storage_client.upload_file( object_key=path, data=content, diff --git a/backend/chainlit/data/dynamodb.py b/backend/chainlit/data/dynamodb.py index b79d2018cf..2cba7c7e01 100644 --- a/backend/chainlit/data/dynamodb.py +++ b/backend/chainlit/data/dynamodb.py @@ -279,6 +279,7 @@ async def create_element(self, element: "Element"): data=content, mime=element.mime, overwrite=True, + content_disposition=element.get_content_disposition(), ) if not uploaded_file: raise ValueError( diff --git a/backend/chainlit/data/sql_alchemy.py b/backend/chainlit/data/sql_alchemy.py index 90d051242b..06acb4b077 100644 --- a/backend/chainlit/data/sql_alchemy.py +++ b/backend/chainlit/data/sql_alchemy.py @@ -615,16 +615,7 @@ async def create_element(self, element: "Element"): if not element.mime: element.mime = "application/octet-stream" - # Set inline disposition for browser-renderable elements so they display - # correctly on chat resume (e.g. PDF in iframe, images in tags). - content_disposition = None - _RENDERABLE_MIME_PREFIXES = ("image/", "audio/", "video/") - _RENDERABLE_MIME_TYPES = ("application/pdf",) - if element.mime: - if element.mime in _RENDERABLE_MIME_TYPES or element.mime.startswith( - _RENDERABLE_MIME_PREFIXES - ): - content_disposition = "inline" + content_disposition = element.get_content_disposition() uploaded_file = await self.storage_provider.upload_file( object_key=file_object_key, diff --git a/backend/chainlit/element.py b/backend/chainlit/element.py index 901eae2980..288fe67b06 100644 --- a/backend/chainlit/element.py +++ b/backend/chainlit/element.py @@ -72,6 +72,9 @@ class Element: thread_id: str = Field(default_factory=lambda: context.session.thread_id) # The type of the element. This will be used to determine how to display the element in the UI. type: ClassVar[ElementType] + _BROWSER_RENDERED_TYPES: ClassVar[frozenset[ElementType]] = frozenset( + {"image", "pdf", "audio", "video"} + ) # Name of the element, this will be used to reference the element in the UI. name: str = "" # The ID of the element. This is set automatically when the element is sent to the UI. @@ -203,6 +206,15 @@ def infer_type_from_mime(cls, mime_type: str): else: return "file" + @classmethod + def get_content_disposition(cls) -> Literal["inline"] | None: + """Return HTTP Content-Disposition for persisted blob content. + + This is independent of Element.display (UI placement: inline/side/page). + Frontend renders media by element.type, so disposition follows type. + """ + return "inline" if cls.type in cls._BROWSER_RENDERED_TYPES else None + async def _create(self, persist=True) -> bool: if self.persisted and not self.updatable: return True diff --git a/backend/tests/data/test_chainlit_data_layer.py b/backend/tests/data/test_chainlit_data_layer.py index 724334d642..1f2f0a3f6f 100644 --- a/backend/tests/data/test_chainlit_data_layer.py +++ b/backend/tests/data/test_chainlit_data_layer.py @@ -1,9 +1,76 @@ import json +from typing import cast from unittest.mock import AsyncMock import pytest from chainlit.data.chainlit_data_layer import ChainlitDataLayer +from chainlit.data.storage_clients.base import BaseStorageClient +from chainlit.element import Element, File, Image, Pdf + + +@pytest.mark.asyncio +@pytest.mark.parametrize("element_type", [Image, Pdf]) +async def test_create_element_uses_inline_disposition_for_browser_rendered_types( + monkeypatch: pytest.MonkeyPatch, + mock_chainlit_context, + mock_storage_client: BaseStorageClient, + element_type: type[Element], +): + data_layer = ChainlitDataLayer( + database_url="postgresql://test", + storage_client=mock_storage_client, + show_logger=False, + ) + monkeypatch.setattr( + data_layer, "execute_query", AsyncMock(return_value=[{"id": "existing"}]) + ) + + async with mock_chainlit_context: + element = element_type( + name="rendered-element", + content=b"content", + for_id="test-step", + ) + await data_layer.create_element(element) + + upload_file = cast(AsyncMock, mock_storage_client.upload_file) + upload_file.assert_awaited_once() + upload_call = upload_file.await_args + assert upload_call is not None + assert upload_call.kwargs["content_disposition"] == "inline" + + +@pytest.mark.asyncio +async def test_create_element_preserves_attachment_disposition_for_generic_files( + monkeypatch: pytest.MonkeyPatch, + mock_chainlit_context, + mock_storage_client: BaseStorageClient, +): + data_layer = ChainlitDataLayer( + database_url="postgresql://test", + storage_client=mock_storage_client, + show_logger=False, + ) + monkeypatch.setattr( + data_layer, "execute_query", AsyncMock(return_value=[{"id": "existing"}]) + ) + + async with mock_chainlit_context: + element = File( + name="report.txt", + content=b"content", + for_id="test-step", + ) + await data_layer.create_element(element) + + upload_file = cast(AsyncMock, mock_storage_client.upload_file) + upload_file.assert_awaited_once() + upload_call = upload_file.await_args + assert upload_call is not None + assert upload_call.kwargs["content_disposition"] == ( + 'attachment; filename="report.txt"' + ) @pytest.mark.asyncio diff --git a/backend/tests/data/test_sql_alchemy.py b/backend/tests/data/test_sql_alchemy.py index 94cd1b4f13..7f83b9215f 100644 --- a/backend/tests/data/test_sql_alchemy.py +++ b/backend/tests/data/test_sql_alchemy.py @@ -1,6 +1,8 @@ import json import uuid from pathlib import Path +from typing import cast +from unittest.mock import AsyncMock import pytest from sqlalchemy import text @@ -9,7 +11,7 @@ from chainlit import User from chainlit.data.sql_alchemy import SQLAlchemyDataLayer from chainlit.data.storage_clients.base import BaseStorageClient -from chainlit.element import Text +from chainlit.element import Image, Pdf, Text @pytest.fixture @@ -154,8 +156,6 @@ async def test_create_element_pdf_inline_disposition( ): """PDF elements must get content_disposition=inline for Azure Blob iframe rendering.""" async with mock_chainlit_context: - from chainlit.element import Pdf - pdf_element = Pdf( id=str(uuid.uuid4()), name="test.pdf", @@ -165,7 +165,11 @@ async def test_create_element_pdf_inline_disposition( ) await data_layer.create_element(pdf_element) - upload_call = data_layer.storage_provider.upload_file.call_args + assert data_layer.storage_provider is not None + upload_file = cast(AsyncMock, data_layer.storage_provider.upload_file) + upload_file.assert_awaited_once() + upload_call = upload_file.await_args + assert upload_call is not None assert upload_call.kwargs.get("content_disposition") == "inline", ( f"Expected content_disposition=inline for PDF, got {upload_call.kwargs}" ) @@ -185,7 +189,11 @@ async def test_create_element_text_default_disposition( ) await data_layer.create_element(text_element) - upload_call = data_layer.storage_provider.upload_file.call_args + assert data_layer.storage_provider is not None + upload_file = cast(AsyncMock, data_layer.storage_provider.upload_file) + upload_file.assert_awaited_once() + upload_call = upload_file.await_args + assert upload_call is not None assert upload_call.kwargs.get("content_disposition") is None, ( f"Expected content_disposition=None for text/plain, got {upload_call.kwargs}" ) @@ -196,7 +204,7 @@ async def test_create_element_image_inline_disposition( ): """Image elements must get content_disposition=inline.""" async with mock_chainlit_context: - img_element = Text( + img_element = Image( id=str(uuid.uuid4()), name="test.png", mime="image/png", @@ -205,7 +213,11 @@ async def test_create_element_image_inline_disposition( ) await data_layer.create_element(img_element) - upload_call = data_layer.storage_provider.upload_file.call_args + assert data_layer.storage_provider is not None + upload_file = cast(AsyncMock, data_layer.storage_provider.upload_file) + upload_file.assert_awaited_once() + upload_call = upload_file.await_args + assert upload_call is not None assert upload_call.kwargs.get("content_disposition") == "inline", ( f"Expected content_disposition=inline for image/png, got {upload_call.kwargs}" ) diff --git a/backend/tests/test_element.py b/backend/tests/test_element.py index 0a6bbd2bc4..761dec4df3 100644 --- a/backend/tests/test_element.py +++ b/backend/tests/test_element.py @@ -13,6 +13,7 @@ File, Image, Pdf, + Plotly, Task, TaskList, TaskStatus, @@ -164,6 +165,18 @@ async def test_element_infer_type_from_mime(self): assert Element.infer_type_from_mime("text/plain") == "file" assert Element.infer_type_from_mime("application/json") == "file" + @pytest.mark.parametrize("element_type", [Image, Pdf, Audio, Video]) + async def test_browser_rendered_element_content_disposition( + self, element_type: type[Element] + ): + assert element_type.get_content_disposition() == "inline" + + @pytest.mark.parametrize("element_type", [Text, File, Plotly]) + async def test_non_browser_rendered_element_content_disposition( + self, element_type: type[Element] + ): + assert element_type.get_content_disposition() is None + @pytest.mark.asyncio class TestImageElement: