Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions backend/chainlit/data/chainlit_data_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions backend/chainlit/data/dynamodb.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion backend/chainlit/data/sql_alchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -615,8 +615,14 @@ async def create_element(self, element: "Element"):
if not element.mime:
element.mime = "application/octet-stream"

content_disposition = element.get_content_disposition()

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(
Expand Down
12 changes: 12 additions & 0 deletions backend/chainlit/element.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
67 changes: 67 additions & 0 deletions backend/tests/data/test_chainlit_data_layer.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down
76 changes: 75 additions & 1 deletion backend/tests/data/test_sql_alchemy.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -149,6 +151,78 @@ 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:
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)

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}"
)


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)

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}"
)


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 = Image(
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)

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}"
)


async def test_get_current_timestamp(data_layer: SQLAlchemyDataLayer):
timestamp = await data_layer.get_current_timestamp()
assert isinstance(timestamp, str)
Expand Down
13 changes: 13 additions & 0 deletions backend/tests/test_element.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
File,
Image,
Pdf,
Plotly,
Task,
TaskList,
TaskStatus,
Expand Down Expand Up @@ -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:
Expand Down