diff --git a/.gitignore b/.gitignore index 5510ce46dc..fbd4bef30b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,7 +12,8 @@ venv .DS_Store .chainlit -!cypress/e2e/**/*/.chainlit/* +!cypress/e2e/**/.chainlit/** +cypress/e2e/**/.chainlit/translations chainlit.md cypress/screenshots diff --git a/backend/chainlit/server.py b/backend/chainlit/server.py index 5118f544a7..49937f3dd8 100644 --- a/backend/chainlit/server.py +++ b/backend/chainlit/server.py @@ -1045,13 +1045,12 @@ async def get_logo(theme: Optional[Theme] = Query(Theme.light)): @router.get("/avatars/{avatar_id:str}") async def get_avatar(avatar_id: str): """Get the avatar for the user based on the avatar_id.""" - if not re.match(r"^[a-zA-Z0-9_ -]+$", avatar_id): + if not re.match(r"^[a-z0-9_]+$", avatar_id): raise HTTPException(status_code=400, detail="Invalid avatar_id") if avatar_id == "default": avatar_id = config.ui.name - - avatar_id = avatar_id.strip().lower().replace(" ", "_") + avatar_id = avatar_id.strip().lower().replace(" ", "_") base_path = Path(APP_ROOT) / "public" / "avatars" avatar_pattern = f"{avatar_id}.*" diff --git a/backend/tests/test_server.py b/backend/tests/test_server.py index 9f4fa65d1e..1ef82d192a 100644 --- a/backend/tests/test_server.py +++ b/backend/tests/test_server.py @@ -184,24 +184,6 @@ def test_get_avatar_custom(test_client: TestClient, monkeypatch: pytest.MonkeyPa os.remove(custom_avatar_path) -def test_get_avatar_with_spaces( - test_client: TestClient, monkeypatch: pytest.MonkeyPatch -): - """Test with custom avatar.""" - custom_avatar_path = os.path.join(APP_ROOT, "public", "avatars", "my_assistant.png") - os.makedirs(os.path.dirname(custom_avatar_path), exist_ok=True) - with open(custom_avatar_path, "wb") as f: - f.write(b"fake image data") - - response = test_client.get("/avatars/My Assistant") - assert response.status_code == 200 - assert response.headers["content-type"].startswith("image/") - assert response.content == b"fake image data" - - # Clean up - os.remove(custom_avatar_path) - - def test_get_avatar_non_existent_favicon( test_client: TestClient, monkeypatch: pytest.MonkeyPatch ): diff --git a/cypress/e2e/echo/.chainlit/config.toml b/cypress/e2e/echo/.chainlit/config.toml new file mode 100644 index 0000000000..ca6e8f776e --- /dev/null +++ b/cypress/e2e/echo/.chainlit/config.toml @@ -0,0 +1,111 @@ +[project] +# Whether to enable telemetry (default: true). No personal data is collected. +enable_telemetry = true + + +# List of environment variables to be provided by each user to use the app. +user_env = [] + +# Duration (in seconds) during which the session is saved when the connection is lost +session_timeout = 3600 + +# Enable third parties caching (e.g LangChain cache) +cache = false + +# Authorized origins +allow_origins = ["*"] + +# Follow symlink for asset mount (see https://github.com/Chainlit/chainlit/issues/317) +# follow_symlink = false + +[features] +# Process and display HTML in messages. This can be a security risk (see https://stackoverflow.com/questions/19603097/why-is-it-dangerous-to-render-user-generated-html-or-javascript) +unsafe_allow_html = false + +# Process and display mathematical expressions. This can clash with "$" characters in messages. +latex = false + +# Automatically tag threads with the current chat profile (if a chat profile is used) +auto_tag_thread = true + +# Allow users to edit their own messages +edit_message = true + +# Authorize users to spontaneously upload files with messages +[features.spontaneous_file_upload] + enabled = true + accept = ["*/*"] + max_files = 20 + max_size_mb = 500 + +[features.audio] + # Sample rate of the audio + sample_rate = 24000 + +[UI] +# Name of the assistant. +name = "My Assistant" + +# Description of the assistant. This is used for HTML tags. +# description = "" + +# Large size content are by default collapsed for a cleaner ui +default_collapse_content = true + +# Chain of Thought (CoT) display mode. Can be "hidden", "tool_call" or "full". +cot = "full" + +# Link to your github repo. This will add a github button in the UI's header. +# github = "" + +# Specify a CSS file that can be used to customize the user interface. +# The CSS file can be served from the public directory or via an external link. +# custom_css = "/public/test.css" + +# Specify a Javascript file that can be used to customize the user interface. +# The Javascript file can be served from the public directory. +# custom_js = "/public/test.js" + +# Specify a custom font url. +# custom_font = "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" + +# Specify a custom meta image url. +# custom_meta_image_url = "https://chainlit-cloud.s3.eu-west-3.amazonaws.com/logo/chainlit_banner.png" + +# Specify a custom build directory for the frontend. +# This can be used to customize the frontend code. +# Be careful: If this is a relative path, it should not start with a slash. +# custom_build = "./public/build" + +[UI.theme] + default = "dark" + #layout = "wide" + #font_family = "Inter, sans-serif" +# Override default MUI light theme. (Check theme.ts) +[UI.theme.light] + #background = "#FAFAFA" + #paper = "#FFFFFF" + + [UI.theme.light.primary] + #main = "#F80061" + #dark = "#980039" + #light = "#FFE7EB" + [UI.theme.light.text] + #primary = "#212121" + #secondary = "#616161" + +# Override default MUI dark theme. (Check theme.ts) +[UI.theme.dark] + #background = "#FAFAFA" + #paper = "#FFFFFF" + + [UI.theme.dark.primary] + #main = "#F80061" + #dark = "#980039" + #light = "#FFE7EB" + [UI.theme.dark.text] + #primary = "#EEEEEE" + #secondary = "#BDBDBD" + +[meta] +generated_by = "1.2.0" diff --git a/cypress/e2e/echo/README.md b/cypress/e2e/echo/README.md new file mode 100644 index 0000000000..1987fb264a --- /dev/null +++ b/cypress/e2e/echo/README.md @@ -0,0 +1,3 @@ +# Echo app + +Based on [In Pure Python](https://docs.chainlit.io/get-started/pure-python) documentation section. diff --git a/cypress/e2e/echo/main.py b/cypress/e2e/echo/main.py new file mode 100644 index 0000000000..4814171006 --- /dev/null +++ b/cypress/e2e/echo/main.py @@ -0,0 +1,12 @@ +import chainlit as cl + + +@cl.on_message +async def main(message: cl.Message): + """Example from 'In Pure Python' docs section.""" + # Your custom logic goes here... + + # Send a response back to the user + await cl.Message( + content=f"Received: {message.content}", + ).send() diff --git a/cypress/e2e/echo/public/avatars/my_assistant.png b/cypress/e2e/echo/public/avatars/my_assistant.png new file mode 100644 index 0000000000..4ebc3aec22 Binary files /dev/null and b/cypress/e2e/echo/public/avatars/my_assistant.png differ diff --git a/cypress/e2e/echo/spec.cy.ts b/cypress/e2e/echo/spec.cy.ts new file mode 100644 index 0000000000..8dcfffb289 --- /dev/null +++ b/cypress/e2e/echo/spec.cy.ts @@ -0,0 +1,68 @@ +import { runTestServer, submitMessage } from '../../support/testUtils'; + +describe('Basic echo example', () => { + before(() => { + runTestServer(); + }); + + describe('Steps', () => { + beforeEach(() => { + cy.visit('/'); + submitMessage("I'm functional."); + cy.get('.step').as('steps'); + }); + + it('should show 2 steps', () => { + cy.get('@steps').should('have.length', 2); + }); + + describe('User message', () => { + beforeEach(() => { + cy.get('@steps').eq(0).as('user_message'); + }); + + it('should contain the submitted message ', () => { + cy.get('@user_message').should('contain', "I'm functional."); + }); + }); + + describe('AI message', () => { + beforeEach(() => { + cy.get('@steps').eq(1).as('ai_message'); + }); + + it('should echo submitted message, prefixed by: "Received: "', () => { + cy.get('@ai_message').should('contain', "Received: I'm functional."); + }); + + describe('avatar', () => { + beforeEach(() => { + cy.get('@ai_message').find('.message-avatar').as('message_avatar'); + cy.get('@message_avatar') + .find('.MuiAvatar-root img') + .as('avatar_img'); + }); + + it('should have "My Assistant" as label', () => { + cy.get('@message_avatar') + .find('.MuiAvatar-root') + .should('have.attr', 'aria-label', 'My Assistant'); + }); + + it('should load /avatars/my_assistant', () => { + cy.get('@avatar_img') + .should('have.length', 1) + .should('have.attr', 'src') + .and('include', '/avatars/my_assistant'); + + cy.get('@avatar_img') + .should('be.visible') + .and(($img) => { + const img = $img[0] as HTMLImageElement; + expect(img.naturalWidth).to.be.greaterThan(0); + }); + }); + }); + }); + }); +}); diff --git a/frontend/src/components/molecules/messages/components/Avatar.tsx b/frontend/src/components/molecules/messages/components/Avatar.tsx index 368abf4bdd..a241a0480b 100644 --- a/frontend/src/components/molecules/messages/components/Avatar.tsx +++ b/frontend/src/components/molecules/messages/components/Avatar.tsx @@ -14,6 +14,16 @@ interface Props { hide?: boolean; } +const getAuthorAvatarId = (author: string | undefined) => { + if (!author) return 'default'; + + return author + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, ''); // Remove leading/trailing underscores +}; + const MessageAvatar = ({ author, hide }: Props) => { const apiClient = useContext(ChainlitContext); const { chatProfile } = useChatSession(); @@ -28,7 +38,9 @@ const MessageAvatar = ({ author, hide }: Props) => { if (isAssistant && selectedChatProfile?.icon) { return selectedChatProfile.icon; } - return apiClient?.buildEndpoint(`/avatars/${author || 'default'}`); + + const authorAvatarId = getAuthorAvatarId(author); + return apiClient?.buildEndpoint(`/avatars/${authorAvatarId}`); }, [apiClient, selectedChatProfile, config, author]); return (