Repository: Chainlit/chainlit Branch: main Commit: 3843e8a401ca Files: 588 Total size: 2.1 MB Directory structure: gitextract_rcugvcbq/ ├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ ├── actions/ │ │ ├── pnpm-node-install/ │ │ │ └── action.yaml │ │ └── uv-python-install/ │ │ └── action.yaml │ ├── copilot-instructions.md │ └── workflows/ │ ├── ci.yaml │ ├── close_stale.yml │ ├── copilot-setup-steps.yaml │ ├── e2e-tests.yaml │ ├── lint-backend.yaml │ ├── lint-ui.yaml │ ├── publish-libs.yaml │ ├── publish.yaml │ └── pytest.yaml ├── .gitignore ├── .husky/ │ └── pre-commit ├── .npmrc ├── .prettierrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── PRIVACY_POLICY.md ├── RELENG.md ├── backend/ │ ├── build.py │ ├── chainlit/ │ │ ├── __init__.py │ │ ├── __main__.py │ │ ├── _utils.py │ │ ├── action.py │ │ ├── auth/ │ │ │ ├── __init__.py │ │ │ ├── cookie.py │ │ │ └── jwt.py │ │ ├── cache.py │ │ ├── callbacks.py │ │ ├── chat_context.py │ │ ├── chat_settings.py │ │ ├── cli/ │ │ │ └── __init__.py │ │ ├── config.py │ │ ├── context.py │ │ ├── data/ │ │ │ ├── __init__.py │ │ │ ├── acl.py │ │ │ ├── base.py │ │ │ ├── chainlit_data_layer.py │ │ │ ├── dynamodb.py │ │ │ ├── literalai.py │ │ │ ├── sql_alchemy.py │ │ │ ├── storage_clients/ │ │ │ │ ├── __init__.py │ │ │ │ ├── azure.py │ │ │ │ ├── azure_blob.py │ │ │ │ ├── base.py │ │ │ │ ├── gcs.py │ │ │ │ └── s3.py │ │ │ └── utils.py │ │ ├── discord/ │ │ │ ├── __init__.py │ │ │ └── app.py │ │ ├── element.py │ │ ├── emitter.py │ │ ├── input_widget.py │ │ ├── langchain/ │ │ │ ├── __init__.py │ │ │ └── callbacks.py │ │ ├── langflow/ │ │ │ └── __init__.py │ │ ├── llama_index/ │ │ │ ├── __init__.py │ │ │ └── callbacks.py │ │ ├── logger.py │ │ ├── markdown.py │ │ ├── mcp.py │ │ ├── message.py │ │ ├── mistralai/ │ │ │ └── __init__.py │ │ ├── mode.py │ │ ├── oauth_providers.py │ │ ├── openai/ │ │ │ └── __init__.py │ │ ├── py.typed │ │ ├── sample/ │ │ │ ├── hello.py │ │ │ └── starters_demo.py │ │ ├── secret.py │ │ ├── semantic_kernel/ │ │ │ └── __init__.py │ │ ├── server.py │ │ ├── session.py │ │ ├── sidebar.py │ │ ├── slack/ │ │ │ ├── __init__.py │ │ │ └── app.py │ │ ├── socket.py │ │ ├── step.py │ │ ├── sync.py │ │ ├── teams/ │ │ │ ├── __init__.py │ │ │ └── app.py │ │ ├── translations/ │ │ │ ├── ar-SA.json │ │ │ ├── bn.json │ │ │ ├── da-DK.json │ │ │ ├── de-DE.json │ │ │ ├── el-GR.json │ │ │ ├── en-US.json │ │ │ ├── es.json │ │ │ ├── fr-FR.json │ │ │ ├── gu.json │ │ │ ├── he-IL.json │ │ │ ├── hi.json │ │ │ ├── it.json │ │ │ ├── ja.json │ │ │ ├── kn.json │ │ │ ├── ko.json │ │ │ ├── ml.json │ │ │ ├── mr.json │ │ │ ├── nl.json │ │ │ ├── ta.json │ │ │ ├── te.json │ │ │ ├── zh-CN.json │ │ │ └── zh-TW.json │ │ ├── translations.py │ │ ├── types.py │ │ ├── user.py │ │ ├── user_session.py │ │ ├── utils.py │ │ └── version.py │ ├── pyproject.toml │ └── tests/ │ ├── __init__.py │ ├── auth/ │ │ ├── __init__.py │ │ └── test_cookie.py │ ├── conftest.py │ ├── data/ │ │ ├── __init__.py │ │ ├── conftest.py │ │ ├── storage_clients/ │ │ │ ├── test_gcs.py │ │ │ └── test_s3.py │ │ ├── test_chainlit_data_layer.py │ │ ├── test_get_data_layer.py │ │ ├── test_literalai.py │ │ └── test_sql_alchemy.py │ ├── langchain/ │ │ ├── __init__.py │ │ ├── test_async_callback.py │ │ ├── test_chain_types.py │ │ └── test_sync_callback.py │ ├── llama_index/ │ │ └── test_callbacks.py │ ├── test_action.py │ ├── test_cache.py │ ├── test_callbacks.py │ ├── test_chat_context.py │ ├── test_chat_settings.py │ ├── test_context.py │ ├── test_element.py │ ├── test_emitter.py │ ├── test_input_widget.py │ ├── test_markdown.py │ ├── test_mcp.py │ ├── test_message.py │ ├── test_modes.py │ ├── test_oauth_providers.py │ ├── test_server.py │ ├── test_session.py │ ├── test_sidebar.py │ ├── test_slack_socket_mode.py │ ├── test_socket.py │ ├── test_step.py │ ├── test_translations.py │ ├── test_user_session.py │ └── test_utils.py ├── cypress/ │ ├── e2e/ │ │ ├── action/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── ask_custom_element/ │ │ │ ├── main.py │ │ │ ├── public/ │ │ │ │ └── elements/ │ │ │ │ └── JiraTicket.jsx │ │ │ └── spec.cy.ts │ │ ├── ask_file/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── ask_multiple_files/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── ask_user/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── audio_element/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── auth/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── blinking_cursor/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── chat_context/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── chat_prefill/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── chat_profiles/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── chat_settings/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── command/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── config_overrides/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── context/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── copilot/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── custom_build/ │ │ │ ├── .gitignore │ │ │ ├── main.py │ │ │ ├── public/ │ │ │ │ ├── .gitignore │ │ │ │ └── build/ │ │ │ │ ├── assets/ │ │ │ │ │ └── .PLACEHOLDER │ │ │ │ └── index.html │ │ │ └── spec.cy.ts │ │ ├── custom_data_layer/ │ │ │ └── sql_alchemy.py │ │ ├── custom_element/ │ │ │ ├── main.py │ │ │ ├── public/ │ │ │ │ └── elements/ │ │ │ │ └── Counter.jsx │ │ │ └── spec.cy.ts │ │ ├── custom_element_auth/ │ │ │ ├── main.py │ │ │ ├── spec.cy.ts │ │ │ └── test.txt │ │ ├── custom_element_command/ │ │ │ ├── main.py │ │ │ ├── public/ │ │ │ │ └── elements/ │ │ │ │ └── Commander.jsx │ │ │ └── spec.cy.ts │ │ ├── custom_theme/ │ │ │ ├── main.py │ │ │ ├── public/ │ │ │ │ └── theme.json │ │ │ └── spec.cy.ts │ │ ├── data_layer/ │ │ │ ├── .gitignore │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── dataframe/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── edit_message/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── elements/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── error_handling/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── file_element/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── header_auth/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── llama_index_cb/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── modes/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── on_chat_start/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── password_auth/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── plotly/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── pyplot/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── readme/ │ │ │ ├── chainlit_pt-BR.md │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── remove_elements/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── remove_step/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── sidebar/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── starters/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── starters_categories/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── step/ │ │ │ ├── async-spec.cy.ts │ │ │ ├── main_async.py │ │ │ ├── main_sync.py │ │ │ ├── sync-spec.cy.ts │ │ │ └── tests.ts │ │ ├── stop_task/ │ │ │ ├── async-spec.cy.ts │ │ │ ├── main_async.py │ │ │ ├── main_sync.py │ │ │ ├── sync-spec.cy.ts │ │ │ └── tests.ts │ │ ├── streaming/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── tasklist/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── thread_resume/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── update_step/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── upload_attachments/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── user_env/ │ │ │ ├── .gitignore │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ ├── user_session/ │ │ │ ├── main.py │ │ │ └── spec.cy.ts │ │ └── window_message/ │ │ ├── main.py │ │ ├── public/ │ │ │ └── iframe.html │ │ └── spec.cy.ts │ ├── fixtures/ │ │ ├── hello.cpp │ │ ├── hello.py │ │ └── state_of_the_union.txt │ └── support/ │ ├── e2e.ts │ ├── run.ts │ └── testUtils.ts ├── cypress.config.ts ├── frontend/ │ ├── .eslintignore │ ├── .gitignore │ ├── components.json │ ├── index.html │ ├── package.json │ ├── postcss.config.js │ ├── src/ │ │ ├── App.tsx │ │ ├── AppWrapper.tsx │ │ ├── api/ │ │ │ └── index.ts │ │ ├── components/ │ │ │ ├── Alert.tsx │ │ │ ├── AudioPresence.tsx │ │ │ ├── AutoResizeTextarea.tsx │ │ │ ├── AutoResumeThread.tsx │ │ │ ├── BlinkingCursor.tsx │ │ │ ├── ButtonLink.tsx │ │ │ ├── ChatSettings/ │ │ │ │ ├── ChatSettingsSidebar.tsx │ │ │ │ ├── CheckboxInput.tsx │ │ │ │ ├── DatePickerInput.tsx │ │ │ │ ├── FormInput.tsx │ │ │ │ ├── InputLabel.tsx │ │ │ │ ├── InputStateHandler.tsx │ │ │ │ ├── MultiSelectInput.tsx │ │ │ │ ├── NotificationCount.tsx │ │ │ │ ├── RadioButtonGroup.tsx │ │ │ │ ├── SelectInput.tsx │ │ │ │ ├── SliderInput.tsx │ │ │ │ ├── SwitchInput.tsx │ │ │ │ ├── TagsInput.tsx │ │ │ │ ├── TextInput.tsx │ │ │ │ └── index.tsx │ │ │ ├── CodeSnippet.tsx │ │ │ ├── CopyButton.tsx │ │ │ ├── ElementSideView.tsx │ │ │ ├── ElementView.tsx │ │ │ ├── Elements/ │ │ │ │ ├── Audio.tsx │ │ │ │ ├── CustomElement/ │ │ │ │ │ ├── Imports.ts │ │ │ │ │ ├── Renderer.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── Dataframe.tsx │ │ │ │ ├── ElementRef.tsx │ │ │ │ ├── File.tsx │ │ │ │ ├── Image.tsx │ │ │ │ ├── LazyDataframe.tsx │ │ │ │ ├── PDF.tsx │ │ │ │ ├── Plotly.tsx │ │ │ │ ├── Text.tsx │ │ │ │ ├── Video.tsx │ │ │ │ └── index.tsx │ │ │ ├── ErrorBoundary.tsx │ │ │ ├── Icon.tsx │ │ │ ├── Kbd.tsx │ │ │ ├── LeftSidebar/ │ │ │ │ ├── Search.tsx │ │ │ │ ├── ThreadHistory.tsx │ │ │ │ ├── ThreadList.tsx │ │ │ │ ├── ThreadOptions.tsx │ │ │ │ └── index.tsx │ │ │ ├── Loader.tsx │ │ │ ├── LoginForm.tsx │ │ │ ├── Logo.tsx │ │ │ ├── Markdown.tsx │ │ │ ├── MarkdownAlert.tsx │ │ │ ├── ProviderButton.tsx │ │ │ ├── QuiltedGrid.tsx │ │ │ ├── ReadOnlyThread.tsx │ │ │ ├── Tasklist/ │ │ │ │ ├── Task.tsx │ │ │ │ ├── TaskStatusIcon.tsx │ │ │ │ └── index.tsx │ │ │ ├── ThemeProvider.tsx │ │ │ ├── WaterMark.tsx │ │ │ ├── chat/ │ │ │ │ ├── Footer.tsx │ │ │ │ ├── MessageComposer/ │ │ │ │ │ ├── Attachment.tsx │ │ │ │ │ ├── Attachments.tsx │ │ │ │ │ ├── CommandButtons.tsx │ │ │ │ │ ├── CommandPopoverButton.tsx │ │ │ │ │ ├── FavoriteButton.tsx │ │ │ │ │ ├── Input.tsx │ │ │ │ │ ├── Mcp/ │ │ │ │ │ │ ├── AddForm.tsx │ │ │ │ │ │ ├── AnimatedPlugIcon.tsx │ │ │ │ │ │ ├── List.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ ├── ModePicker.tsx │ │ │ │ │ ├── SubmitButton.tsx │ │ │ │ │ ├── UploadButton.tsx │ │ │ │ │ ├── VoiceButton.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── Messages/ │ │ │ │ │ ├── Message/ │ │ │ │ │ │ ├── AskActionButtons.tsx │ │ │ │ │ │ ├── AskFileButton.tsx │ │ │ │ │ │ ├── Avatar.tsx │ │ │ │ │ │ ├── Buttons/ │ │ │ │ │ │ │ ├── Actions/ │ │ │ │ │ │ │ │ ├── ActionButton.tsx │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ ├── DebugButton.tsx │ │ │ │ │ │ │ ├── FeedbackButtons.tsx │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ ├── Content/ │ │ │ │ │ │ │ ├── InlinedElements/ │ │ │ │ │ │ │ │ ├── InlineCustomElementList.tsx │ │ │ │ │ │ │ │ ├── InlinedAudioList.tsx │ │ │ │ │ │ │ │ ├── InlinedDataframeList.tsx │ │ │ │ │ │ │ │ ├── InlinedFileList.tsx │ │ │ │ │ │ │ │ ├── InlinedImageList.tsx │ │ │ │ │ │ │ │ ├── InlinedPDFList.tsx │ │ │ │ │ │ │ │ ├── InlinedPlotlyList.tsx │ │ │ │ │ │ │ │ ├── InlinedTextList.tsx │ │ │ │ │ │ │ │ ├── InlinedVideoList.tsx │ │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ │ └── index.tsx │ │ │ │ │ │ ├── Step.tsx │ │ │ │ │ │ ├── UserMessage.tsx │ │ │ │ │ │ └── index.tsx │ │ │ │ │ └── index.tsx │ │ │ │ ├── MessagesContainer/ │ │ │ │ │ └── index.tsx │ │ │ │ ├── ScrollContainer.tsx │ │ │ │ ├── ScrollDownButton.tsx │ │ │ │ ├── Starter.tsx │ │ │ │ ├── StarterCategory.tsx │ │ │ │ ├── Starters.tsx │ │ │ │ ├── WelcomeScreen.tsx │ │ │ │ └── index.tsx │ │ │ ├── header/ │ │ │ │ ├── ApiKeys.tsx │ │ │ │ ├── ChatProfiles.tsx │ │ │ │ ├── NewChat.tsx │ │ │ │ ├── Readme.tsx │ │ │ │ ├── Share.tsx │ │ │ │ ├── SidebarTrigger.tsx │ │ │ │ ├── ThemeToggle.tsx │ │ │ │ ├── UserNav.tsx │ │ │ │ └── index.tsx │ │ │ ├── i18n/ │ │ │ │ ├── Translator.tsx │ │ │ │ └── index.ts │ │ │ ├── icons/ │ │ │ │ ├── Auth0.tsx │ │ │ │ ├── Cognito.tsx │ │ │ │ ├── Descope.tsx │ │ │ │ ├── EditSquare.tsx │ │ │ │ ├── Github.tsx │ │ │ │ ├── Gitlab.tsx │ │ │ │ ├── Google.tsx │ │ │ │ ├── Microsoft.tsx │ │ │ │ ├── Okta.tsx │ │ │ │ ├── PaperClip.tsx │ │ │ │ ├── Pencil.tsx │ │ │ │ ├── Search.tsx │ │ │ │ ├── Send.tsx │ │ │ │ ├── Settings.tsx │ │ │ │ ├── Sidebar.tsx │ │ │ │ ├── Stop.tsx │ │ │ │ ├── ToolBox.tsx │ │ │ │ └── VoiceLines.tsx │ │ │ ├── share/ │ │ │ │ └── ShareDialog.tsx │ │ │ └── ui/ │ │ │ ├── accordion.tsx │ │ │ ├── alert-dialog.tsx │ │ │ ├── aspect-ratio.tsx │ │ │ ├── avatar.tsx │ │ │ ├── badge.tsx │ │ │ ├── button.tsx │ │ │ ├── calendar.tsx │ │ │ ├── card.tsx │ │ │ ├── carousel.tsx │ │ │ ├── checkbox.tsx │ │ │ ├── command.tsx │ │ │ ├── dialog.tsx │ │ │ ├── dropdown-menu.tsx │ │ │ ├── form.tsx │ │ │ ├── hover-card.tsx │ │ │ ├── input.tsx │ │ │ ├── label.tsx │ │ │ ├── pagination.tsx │ │ │ ├── popover.tsx │ │ │ ├── progress.tsx │ │ │ ├── radio-group.tsx │ │ │ ├── resizable.tsx │ │ │ ├── scroll-area.tsx │ │ │ ├── select.tsx │ │ │ ├── separator.tsx │ │ │ ├── sheet.tsx │ │ │ ├── sidebar.tsx │ │ │ ├── skeleton.tsx │ │ │ ├── slider.tsx │ │ │ ├── sonner.tsx │ │ │ ├── switch.tsx │ │ │ ├── table.tsx │ │ │ ├── tabs.tsx │ │ │ ├── textarea.tsx │ │ │ └── tooltip.tsx │ │ ├── contexts/ │ │ │ └── MessageContext.tsx │ │ ├── hooks/ │ │ │ ├── query.ts │ │ │ ├── use-mobile.tsx │ │ │ ├── useCommandNavigation.tsx │ │ │ ├── useFetch.tsx │ │ │ ├── useLayoutMaxWidth.tsx │ │ │ ├── usePlatform.ts │ │ │ └── useUpload.tsx │ │ ├── i18n/ │ │ │ ├── dateLocale.ts │ │ │ └── index.ts │ │ ├── index.css │ │ ├── index.d.ts │ │ ├── lib/ │ │ │ ├── message.ts │ │ │ ├── router.ts │ │ │ └── utils.ts │ │ ├── main.tsx │ │ ├── pages/ │ │ │ ├── AuthCallback.tsx │ │ │ ├── Element.tsx │ │ │ ├── Env.tsx │ │ │ ├── Home.tsx │ │ │ ├── Login.tsx │ │ │ ├── Page.tsx │ │ │ └── Thread.tsx │ │ ├── router.tsx │ │ ├── state/ │ │ │ ├── chat.ts │ │ │ ├── project.ts │ │ │ └── user.ts │ │ ├── types/ │ │ │ ├── Input.ts │ │ │ ├── NotificationCount.tsx │ │ │ ├── chat.ts │ │ │ ├── index.ts │ │ │ └── messageContext.ts │ │ └── vite-env.d.ts │ ├── tailwind.config.js │ ├── tests/ │ │ ├── FavoriteButton.spec.tsx │ │ ├── NewChat.spec.tsx │ │ ├── content.spec.tsx │ │ ├── icon.spec.tsx │ │ ├── setup-tests.ts │ │ └── tsconfig.json │ ├── tsconfig.json │ ├── tsconfig.node.json │ ├── vite.config.ts │ └── vitest.config.ts ├── libs/ │ ├── copilot/ │ │ ├── .storybook/ │ │ │ ├── main.ts │ │ │ └── preview.ts │ │ ├── components.json │ │ ├── index.tsx │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── sonner.css │ │ ├── src/ │ │ │ ├── ThemeProvider.tsx │ │ │ ├── api.ts │ │ │ ├── app.tsx │ │ │ ├── appWrapper.tsx │ │ │ ├── chat/ │ │ │ │ ├── body.tsx │ │ │ │ └── index.tsx │ │ │ ├── components/ │ │ │ │ ├── ElementSideView.tsx │ │ │ │ ├── Header.tsx │ │ │ │ └── WelcomeScreen.tsx │ │ │ ├── hooks/ │ │ │ │ ├── index.ts │ │ │ │ └── useCopilotInteract.ts │ │ │ ├── index.css │ │ │ ├── lib/ │ │ │ │ └── utils.ts │ │ │ ├── state.ts │ │ │ ├── types.ts │ │ │ └── widget.tsx │ │ ├── stories/ │ │ │ └── App.stories.ts │ │ ├── tailwind.config.js │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ └── react-client/ │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── api/ │ │ │ ├── hooks/ │ │ │ │ ├── api.ts │ │ │ │ └── auth/ │ │ │ │ ├── config.ts │ │ │ │ ├── index.ts │ │ │ │ ├── sessionManagement.ts │ │ │ │ ├── state.ts │ │ │ │ ├── types.ts │ │ │ │ └── userManagement.ts │ │ │ └── index.tsx │ │ ├── context.ts │ │ ├── index.ts │ │ ├── state.ts │ │ ├── types/ │ │ │ ├── action.ts │ │ │ ├── audio.ts │ │ │ ├── command.ts │ │ │ ├── config.ts │ │ │ ├── element.ts │ │ │ ├── feedback.ts │ │ │ ├── file.ts │ │ │ ├── history.ts │ │ │ ├── index.ts │ │ │ ├── mcp.ts │ │ │ ├── mode.ts │ │ │ ├── step.ts │ │ │ ├── thread.ts │ │ │ └── user.ts │ │ ├── useAudio.ts │ │ ├── useChatData.ts │ │ ├── useChatInteract.ts │ │ ├── useChatMessages.ts │ │ ├── useChatSession.ts │ │ ├── useConfig.ts │ │ ├── utils/ │ │ │ ├── group.ts │ │ │ └── message.ts │ │ └── wavtools/ │ │ ├── analysis/ │ │ │ ├── audio_analysis.js │ │ │ └── constants.js │ │ ├── index.ts │ │ ├── wav_packer.js │ │ ├── wav_recorder.js │ │ ├── wav_renderer.ts │ │ ├── wav_stream_player.js │ │ └── worklets/ │ │ ├── audio_processor.js │ │ └── stream_processor.js │ ├── tsconfig.build.json │ └── tsconfig.json ├── lint-staged.config.js ├── package.json ├── pnpm-workspace.yaml └── tsconfig.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] end_of_line = lf [*.{py,ts,tsx}] indent_style = space insert_final_newline = true [*.py] indent_size = 4 trim_trailing_whitespace = true [*.{ts,tsx}] indent_size = 2 ================================================ FILE: .eslintignore ================================================ node_modules dist ================================================ FILE: .eslintrc ================================================ { "root": true, "parser": "@typescript-eslint/parser", "ignorePatterns": ["**/*.jsx"], "plugins": ["@typescript-eslint"], "extends": [ "eslint:recommended", "plugin:@typescript-eslint/eslint-recommended", "plugin:@typescript-eslint/recommended" ], "rules": { "@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/no-explicit-any": "off", "no-unused-vars": "off", "@typescript-eslint/no-unused-vars": [ "error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "caughtErrorsIgnorePattern": "^_", "ignoreRestSiblings": true } ] } } ================================================ FILE: .github/CODEOWNERS ================================================ * @hayescode @asvishnyakov @sandangel ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: Bug report about: Create a report to help us improve title: '' labels: needs-triage assignees: '' type: 'Bug' --- **Describe the bug** A clear and concise description of what the bug is. **To Reproduce** Steps to reproduce the behavior: 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' 4. See error **Expected behavior** A clear and concise description of what you expected to happen. **Screenshots** If applicable, add screenshots to help explain your problem. **Desktop (please complete the following information):** - OS: [e.g. iOS] - Browser [e.g. chrome, safari] - Version [e.g. 22] **Smartphone (please complete the following information):** - Device: [e.g. iPhone6] - OS: [e.g. iOS8.1] - Browser [e.g. stock browser, safari] - Version [e.g. 22] **Additional context** Add any other context about the problem here. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: Feature request about: Suggest an idea for this project title: '' labels: needs-triage assignees: '' type: 'Feature' --- **Is your feature request related to a problem? Please describe.** A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] **Describe the solution you'd like** A clear and concise description of what you want to happen. **Describe alternatives you've considered** A clear and concise description of any alternative solutions or features you've considered. **Additional context** Add any other context or screenshots about the feature request here. ================================================ FILE: .github/actions/pnpm-node-install/action.yaml ================================================ name: Install Node, pnpm and dependencies. description: Install Node, pnpm and dependencies using cache. inputs: node-version: description: Node.js version required: true default: '24.3.0' # Switch to 'lts' as soon as Node 24 reaches LTS status. runs: using: composite steps: - uses: pnpm/action-setup@v4 name: Install pnpm with: run_install: false - name: Use Node.js uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} registry-url: 'https://registry.npmjs.org' cache: 'pnpm' cache-dependency-path: '**/pnpm-lock.yaml' - name: Install JS dependencies run: pnpm install shell: bash ================================================ FILE: .github/actions/uv-python-install/action.yaml ================================================ name: Install Python, uv and dependencies. description: Install Python, uv and project dependencies using cache inputs: python-version: description: Python version required: true default: '3.10' uv-version: description: uv version required: true default: 'latest' working-directory: description: Working directory for uv command. required: false default: . extra-dependencies: description: Extra dependencies to install, e.g. --extra tests --extra dev. required: false runs: using: composite steps: - name: Install uv uses: astral-sh/setup-uv@v4 with: version: ${{ inputs.uv-version }} enable-cache: true - name: Set up Python ${{ inputs.python-version }} id: setup_python uses: actions/setup-python@v5 with: python-version: ${{ inputs.python-version }} - name: Install Python dependencies run: uv sync --no-install-project --no-editable ${{ inputs.extra-dependencies }} shell: bash working-directory: ${{ inputs.working-directory }} ================================================ FILE: .github/copilot-instructions.md ================================================ # Chainlit Development Instructions Chainlit is a Python framework for building conversational AI applications with Python backend and React frontend. It uses uv for Python dependency management and pnpm for Node.js packages. Always reference these instructions first and fallback to search or bash commands only when you encounter unexpected information that does not match the info here. ## Working Effectively ### Bootstrap, Build, and Test the Repository **CRITICAL**: All commands must complete - NEVER CANCEL any build or test operations. Use appropriate timeouts. 1. **Install Dependencies (Required first)**: ```bash # Install uv (if not available) python3 -m pip install pipx python3 -m pipx install uv export PATH="$HOME/.local/bin:$PATH" # Install pnpm (if not available) npm install -g pnpm # Install Python dependencies - takes ~2 minutes, NEVER CANCEL cd backend uv sync --extra tests --extra mypy --extra dev --extra custom-data # Timeout: Use 300+ seconds (5+ minutes) # Install Node.js dependencies - takes ~3 minutes, NEVER CANCEL cd .. pnpm install --frozen-lockfile # Timeout: Use 600+ seconds (10+ minutes) # NOTE: Cypress download may fail due to network restrictions - this is expected in CI environments ``` 2. **Build the Frontend - takes ~1 minute, NEVER CANCEL**: ```bash pnpm run buildUi # Timeout: Use 300+ seconds (5+ minutes) ``` 3. **Run Tests**: ```bash # Backend tests - takes ~17 seconds, NEVER CANCEL cd backend export PATH="$HOME/.local/bin:$PATH" uv run pytest --cov=chainlit/ # Timeout: Use 120+ seconds (2+ minutes) # Frontend tests - takes ~4 seconds cd ../frontend pnpm test # Timeout: Use 60 seconds # E2E tests require Cypress download - may not work in restricted environments # If available: pnpm test (takes variable time depending on tests) ``` 4. **Run Development Servers**: ```bash # Start backend (in one terminal) cd backend export PATH="$HOME/.local/bin:$PATH" uv run chainlit run chainlit/sample/hello.py -h # Available at http://localhost:8000 # Start frontend dev server (in another terminal) cd frontend pnpm run dev # Available at http://localhost:5173/ ``` ## Validation ### Manual Validation Requirements - **ALWAYS** manually validate any changes by running complete scenarios. - **ALWAYS** test the Chainlit application after making changes. - Create a test app and verify it runs: `uv run chainlit run /path/to/test.py -h` - **ALWAYS** run through at least one complete user workflow after making changes. ### Linting and Formatting - takes ~2 minutes, NEVER CANCEL ```bash # Run all linting (UI + Python) pnpm run lint # Timeout: Use 300+ seconds (5+ minutes) # Format UI code - takes ~5 seconds pnpm run formatUi # Format Python code using ruff (preferred) cd backend export PATH="$HOME/.local/bin:$PATH" uv run ruff format chainlit/ tests/ # NOTE: pnpm run formatPython may fail if black is not installed # Use ruff format instead as shown above ``` ### CI Requirements - **ALWAYS** run `pnpm run lint` before committing or the CI (.github/workflows/ci.yaml) will fail. - The CI runs: pytest, lint-backend, lint-ui, and e2e-tests. - **NEVER CANCEL** any CI commands - they take time but must complete. ## Key Project Structure ### Repository Root ``` / ├── README.md ├── CONTRIBUTING.md ├── package.json # Root pnpm workspace config ├── pnpm-workspace.yaml # Workspace definition ├── backend/ # Python backend with uv ├── frontend/ # React frontend app ├── libs/ │ ├── react-client/ # React client library │ └── copilot/ # Copilot functionality ├── cypress/ # E2E tests └── .github/ ├── workflows/ # CI/CD pipelines └── actions/ # Reusable GitHub actions ``` ### Working with the Backend - **Technology**: Python 3.10+ with uv, FastAPI, SocketIO - **Entry point**: `backend/chainlit/` - **Tests**: `backend/tests/` - **Dependencies**: Defined in `backend/pyproject.toml` - **Hello app**: `backend/chainlit/sample/hello.py` ### Working with the Frontend - **Technology**: React 18+ with Vite, TypeScript, Tailwind CSS - **Entry point**: `frontend/src/` - **Dependencies**: Defined in `frontend/package.json` - **Build output**: `frontend/dist/` ## Common Tasks ### Creating a New Chainlit App ```python # Create app.py import chainlit as cl @cl.on_message async def main(message: cl.Message): await cl.Message(content=f"You said: {message.content}").send() # Run it uv run chainlit run app.py -w ``` ### Timing Expectations - **pnpm install**: ~3 minutes (may fail on Cypress - this is normal) - **uv install**: ~2 minutes - **pnpm run buildUi**: ~1 minute - **pnpm run lint**: ~2 minutes - **Backend tests**: ~17 seconds - **Frontend tests**: ~4 seconds - **pnpm run formatUi**: ~5 seconds ### Common Gotchas - **NEVER CANCEL** long-running operations - they need time to complete. - Cypress download often fails in CI environments - this is expected. - Use `uv run` prefix for all Python commands in backend. - Use `export PATH="$HOME/.local/bin:$PATH"` to ensure uv is available. - The `pnpm run formatPython` command may fail - use `uv run ruff format` instead. - Frontend dev server connects to backend at localhost:8000. - Always start backend before frontend for development. ### File Locations for Quick Reference - **Main CLI**: `backend/chainlit/cli/` - **Server code**: `backend/chainlit/server.py` - **Frontend app**: `frontend/src/App.tsx` - **React client**: `libs/react-client/src/` - **CI workflows**: `.github/workflows/ci.yaml` - **uv config**: `backend/pyproject.toml` - **Frontend config**: `frontend/package.json` ## Requirements - **Python**: >= 3.10 - **Node.js**: >= 20 (24+ recommended) - **uv**: 2.1.3 (install via pipx) - **pnpm**: Latest (install via npm) ================================================ FILE: .github/workflows/ci.yaml ================================================ name: CI on: workflow_call: workflow_dispatch: merge_group: pull_request: branches: [main, dev, 'release/**'] paths-ignore: - '*.md' - LICENSE push: branches: [main, dev, 'release/**'] paths-ignore: - '*.md' - LICENSE permissions: read-all jobs: pytest: uses: ./.github/workflows/pytest.yaml secrets: inherit lint-backend: uses: ./.github/workflows/lint-backend.yaml secrets: inherit e2e-tests: uses: ./.github/workflows/e2e-tests.yaml secrets: inherit lint-ui: uses: ./.github/workflows/lint-ui.yaml secrets: inherit ci: runs-on: ubuntu-latest name: Run CI if: always() # This ensures the job always runs needs: [lint-backend, pytest, lint-ui, e2e-tests] steps: # Propagate failure - name: Check dependent jobs if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') || contains(needs.*.result, 'action_required') || contains(needs.*.result, 'timed_out') run: | echo "Not all required jobs succeeded" exit 1 ================================================ FILE: .github/workflows/close_stale.yml ================================================ name: Close inactive issues and pull requests on: schedule: - cron: "30 1 * * *" workflow_dispatch: jobs: close-issues: runs-on: ubuntu-latest permissions: issues: write pull-requests: write steps: - uses: actions/stale@v9 with: operations-per-run: 400 ascending: true days-before-issue-stale: 14 days-before-issue-close: 7 stale-issue-label: "stale" exempt-issue-labels: "enhancement,dev-tooling,e2e-tests,unit-tests,keep-for-a-while" stale-issue-message: "This issue is stale because it has been open for 14 days with no activity." close-issue-message: "This issue was closed because it has been inactive for 7 days since being marked as stale." days-before-pr-stale: 14 days-before-pr-close: 7 stale-pr-label: "stale" exempt-pr-labels: "enhancement,dev-tooling,e2e-tests,unit-tests,keep-for-a-while" stale-pr-message: "This PR is stale because it has been open for 14 days with no activity." close-pr-message: "This PR was closed because it has been inactive for 7 days since being marked as stale." repo-token: ${{ secrets.GITHUB_TOKEN }} ================================================ FILE: .github/workflows/copilot-setup-steps.yaml ================================================ name: "Copilot Setup Steps" # Automatically run the setup steps when they are changed to allow for easy validation, and # allow manual testing through the repository's "Actions" tab # This workflow optimizes the GitHub Copilot coding agent's ephemeral development environment on: workflow_dispatch: push: paths: - .github/workflows/copilot-setup-steps.yaml pull_request: paths: - .github/workflows/copilot-setup-steps.yaml jobs: # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. copilot-setup-steps: runs-on: ubuntu-latest timeout-minutes: 15 # Set the permissions to the lowest permissions possible needed for your steps. # Copilot will be given its own token for its operations. permissions: # If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete. contents: read # You can define any steps you want, and they will run before the agent starts. # If you do not check out your code, Copilot will do this for you. steps: - name: Checkout code uses: actions/checkout@v4 - name: Install Node.js, pnpm and dependencies uses: ./.github/actions/pnpm-node-install - name: Install Python, uv and dependencies uses: ./.github/actions/uv-python-install with: python-version: "3.10" uv-version: "latest" working-directory: "./backend" extra-dependencies: "--extra tests --extra mypy --extra dev --extra custom-data" - name: Build UI components run: pnpm run buildUi timeout-minutes: 5 ================================================ FILE: .github/workflows/e2e-tests.yaml ================================================ name: E2ETests on: [workflow_call] permissions: read-all jobs: ci: runs-on: ${{ matrix.os }} strategy: matrix: os: [ubuntu-latest, windows-latest] env: BACKEND_DIR: ./backend steps: - uses: actions/checkout@v4 - uses: ./.github/actions/pnpm-node-install name: Install Node, pnpm and dependencies. - name: Install Cypress uses: cypress-io/github-action@v6 with: runTests: false - uses: ./.github/actions/uv-python-install name: Install Python, uv and Python & pnpm (uv does it automatically) dependencies with: working-directory: ${{ env.BACKEND_DIR }} extra-dependencies: --extra tests - name: Build UI components run: pnpm run buildUi timeout-minutes: 5 - name: Run tests env: CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} run: pnpm test shell: bash - name: Upload screenshots uses: actions/upload-artifact@v4 if: always() && hashFiles('cypress/screenshots/**') != '' with: name: cypress-screenshots-${{ matrix.os }} path: cypress/screenshots ================================================ FILE: .github/workflows/lint-backend.yaml ================================================ name: LintBackend on: [workflow_call] permissions: read-all jobs: lint-backend: runs-on: ubuntu-latest env: BACKEND_DIR: ./backend steps: - uses: actions/checkout@v6 - uses: ./.github/actions/uv-python-install name: Install Python, uv and Python dependencies with: extra-dependencies: --extra tests --extra mypy --extra custom-data working-directory: ${{ env.BACKEND_DIR }} - name: Lint with ruff uses: astral-sh/ruff-action@v1 with: src: ${{ env.BACKEND_DIR }} changed-files: "true" - name: Check formatting with ruff uses: astral-sh/ruff-action@v1 with: src: ${{ env.BACKEND_DIR }} changed-files: "true" args: "format --check" - name: Run Mypy run: uv run --no-project mypy chainlit/ tests/ working-directory: ${{ env.BACKEND_DIR }} ================================================ FILE: .github/workflows/lint-ui.yaml ================================================ name: LintUI on: [workflow_call] permissions: read-all jobs: ci: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: ./.github/actions/pnpm-node-install name: Install Node, pnpm and dependencies. - name: Build UI run: pnpm run buildUi - name: Lint UI run: pnpm run lintUi ================================================ FILE: .github/workflows/publish-libs.yaml ================================================ name: Publish libs on: workflow_dispatch: inputs: dry_run: description: 'Dry run (test publishing)' required: false default: false type: boolean release: types: [published] permissions: read-all jobs: validate: name: Validate inputs runs-on: ubuntu-latest steps: - name: Validate publishing branch and destination package index run: | if [[ "${{ github.ref_name }}" != "main" && "${{ github.event_name }}" != "release" ]]; then if [[ "${{ inputs.dry_run }}" != "true" ]]; then echo "❌ Error: Only build from main branch or release tag can be published to npm registry." echo "Please check 'Dry run (test publishing)' when running from branch: ${{ github.ref_name }}" exit 1 fi fi echo "✅ Validation passed" ci: needs: [validate] uses: ./.github/workflows/ci.yaml secrets: inherit build-n-publish: name: Upload libs release to npm registry runs-on: ubuntu-latest needs: [ci] permissions: contents: read id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - uses: actions/checkout@v4 - uses: ./.github/actions/pnpm-node-install name: Install Node, pnpm and dependencies. - name: Build libs run: pnpm build:libs - name: Publish packages to npm # --no-git-checks allows testing from non-main branches and publishing from release tags run: pnpm publish --recursive --no-git-checks ${{ inputs.dry_run && '--dry-run' || '' }} env: NODE_AUTH_TOKEN: ${{ secrets.NPM_REACT_CLIENT }} ================================================ FILE: .github/workflows/publish.yaml ================================================ name: Publish on: workflow_dispatch: inputs: use_testpypi: description: 'Publish to TestPyPI instead of PyPI' required: false default: false type: boolean release: types: [published] permissions: read-all jobs: validate: name: Validate inputs runs-on: ubuntu-latest steps: - name: Validate publishing branch and destination package index run: | if [[ "${{ github.ref_name }}" != "main" && "${{ github.event_name }}" != "release" ]]; then if [[ "${{ inputs.use_testpypi }}" != "true" ]]; then echo "❌ Error: Only build from main branch or release tag can be published to PyPI." echo "Please check 'Publish to TestPyPI instead of PyPI' when running from branch: ${{ github.ref_name }}" exit 1 fi fi echo "✅ Validation passed" ci: needs: [validate] uses: ./.github/workflows/ci.yaml secrets: inherit build-n-publish: name: Upload release to PyPI/TestPyPI runs-on: ubuntu-latest needs: [ci] env: name: ${{ inputs.use_testpypi && 'testpypi' || 'pypi' }} url: ${{ inputs.use_testpypi && 'https://test.pypi.org/project/chainlit' || 'https://pypi.org/project/chainlit' }} BACKEND_DIR: ./backend permissions: contents: read id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - uses: actions/checkout@v4 - uses: ./.github/actions/pnpm-node-install name: Install Node, pnpm and dependencies. - uses: ./.github/actions/uv-python-install name: Install Python, uv and Python dependencies with: working-directory: ${{ env.BACKEND_DIR }} - name: Build Python distribution run: uv build working-directory: ${{ env.BACKEND_DIR }} - name: Check frontend and copilot folder included run: | pip install wheel python -m wheel unpack dist/chainlit-*.whl -d unpacked ls unpacked/chainlit-*/chainlit/frontend/dist ls unpacked/chainlit-*/chainlit/copilot/dist working-directory: ${{ env.BACKEND_DIR }} - name: Publish package distributions to TestPyPI if: inputs.use_testpypi uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: backend/dist repository-url: https://test.pypi.org/legacy/ password: ${{ secrets.TEST_PYPI_API_TOKEN }} verbose: true - name: Publish package distributions to PyPI if: ${{ !inputs.use_testpypi }} uses: pypa/gh-action-pypi-publish@release/v1 with: packages-dir: backend/dist password: ${{ secrets.PYPI_API_TOKEN }} ================================================ FILE: .github/workflows/pytest.yaml ================================================ name: Pytest on: [workflow_call] permissions: read-all jobs: pytest: runs-on: ubuntu-latest strategy: matrix: python-version: ['3.10', '3.11', '3.12', '3.13'] env: BACKEND_DIR: ./backend steps: - uses: actions/checkout@v4 - uses: ./.github/actions/pnpm-node-install name: Install Node, pnpm and dependencies. - uses: ./.github/actions/uv-python-install name: Install Python, uv and Python dependencies with: python-version: ${{ matrix.python-version }} extra-dependencies: --extra tests --extra mypy --extra custom-data working-directory: ${{ env.BACKEND_DIR }} - name: Build UI components run: pnpm run buildUi timeout-minutes: 5 - name: Run Pytest run: uv run --no-project pytest --cov=chainlit/ working-directory: ${{ env.BACKEND_DIR }} ================================================ FILE: .gitignore ================================================ build dist *.egg-info .env *.files venv .venv .DS_Store **/.chainlit/* chainlit.md cypress/screenshots cypress/videos cypress/downloads __pycache__ .ipynb_checkpoints *.db .mypy_cache chat_files .chroma # Logs logs *.log npm-debug.log* yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* node_modules .pnpm-store dist dist-ssr *.local # Editor directories and files .vscode/* !.vscode/extensions.json .idea .DS_Store *.suo *.ntvs* *.njsproj *.sln *.sw? .aider* .coverage backend/README.md backend/.dmypy.json .history ================================================ FILE: .husky/pre-commit ================================================ pnpm lint-staged ================================================ FILE: .npmrc ================================================ shared-workspace-lockfile=false public-hoist-pattern[]=*eslint* public-hoist-pattern[]=*prettier* public-hoist-pattern[]=@types* side-effects-cache=false ================================================ FILE: .prettierrc ================================================ { "semi": true, "trailingComma": "none", "singleQuote": true, "printWidth": 80, "plugins": ["@trivago/prettier-plugin-sort-imports"], "importOrder": [ "pages/(.*)$", "@chainlit/(.*)$", "components/(.*)$", "assets/(.*)$", "hooks/(.*)$", "state/(.*)$", "types/(.*)$", "^./*.*.css", "^[./]" ], "importOrderSeparation": true, "importOrderSortSpecifiers": true } ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to Chainlit will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [2.10.0] - 2026-03-05 ### Added - Add starter categories for grouped starters - Always show the favorite messages button with an empty state - Add option to disable rendering markdown in user messages - Allow easy deletion of favorites - Make state cookie lifetime configurable via env var - Add Arabic translation - Add Danish translation - Add settings change listener - Add image preview - Add selected option for command pre-selection - Add `auto_collapse` parameter to `Step` - Add `/health` endpoint for container orchestration - Add `hidden` option for `default_sidebar_state` - Make avatar size configurable via `config.toml` ### Fixed - Reorder chat history sidebar after messages in existing chats - Use login error detail for credential failures - Convert UUID fields to strings in feedback extraction - Preserve thread metadata when updated without metadata - Reset audio UI when microphone permission is denied - Fix sidebar inset overflow causing horizontal scroll - Prevent empty strings from overwriting step content on upsert - Use correct URL scheme when SSL is configured ## [2.9.6] - 2026-01-20 ### Added - Allow skip new chat creation - Add data picker input widget - Toggle chat settings in sidebar instead of composer ### Fixed - Fix: Starters now correctly use the selected/default mode if configured ## [2.9.5] - 2026-01-08 ### Added - Add favorite messages (prompt templates) ### Fixed - Fix: Starters now correctly use the selected/default mode if configured ## [2.9.4] - 2025-12-24 ### Added - Add an icon for shared thread - New option to allow disabling auto scroll of assistant messages - Add modes: you may allow users to select an LLM model, a mode (for example, planning), allow to enable reasoning etc. - Breaking change: you need to run `ALTER TABLE steps ADD COLUMN IF NOT EXISTS modes JSONB;` for migration ### Fixed - Fix tiny avatar for long messages - Security vulnerability in Chainlit: added missed sanitization to custom elements update endpoint ### Changed - Bumped watchfiles version ## [2.9.3] - 2025-12-04 ### Added - Add tests for oauth providers and messages - Merge metadata in chainlit data layer - Add native video support in markdown rendering - Optimize chat message rendering - Add language configuration option to config.toml - Upgrade langchain imports for v1 compatibility - Improve icon name formatting issues ### Fixed - Fixed page blinking issue with header_auth - Set environ when restoring websocket session - Move hello.py to avoid import issues - Fix issue showing thread sharing when disabled - Disable Chainlit from setting logging globally ## [2.9.2] - 2025-11-22 ### Added - Add tests for socket, chat context, cache, translations & oauth providers ### Fixed - Fix copilot breaking change introduced in 2.8.5 ## [2.9.1] - 2025-11-20 ### Added - Add support for tabs in chat settings - Support markdown in watermark - Add italian translation to translations folder - Add query param prefill for chat - Add tests for utils, markdown, sidebar, chat settings, mcp, input widget, langchain, elements, steps, and actions ## [2.9.0] - 2025-11-06 ### Added - Add better support for Multi-Agent implementations - Nested steps are now step.input -> child step -> step.output - Improved formatting and styling of Tasklist ## [2.8.5] - 2025-11-07 ### Added - Add display_name to ChatProfile - Add slack reaction event callback - Add raw response from OAuth providers ### Fixed - Security vulnerability in Chainlint: added missed ACL check for session initialization ### Changed - Remove FastAPI version restrictions ## [2.8.4] - 2025-10-29 ### Added - Add support for GitHub Enterprise OAuth provider - Explicit disable on input widgets ### Fixed - Tasklist tasks are now properly reconnected to their steps/messages - ci: fix pnpm publish checks - fix: missing / in url with base path when connecting Streamable HTTP MCP - fix - persist custom_elements to data layer without cloud storage - fix: propagate IME composition events in AutoResizeTextarea - fix: confirm when enter - Fix(translation): correct French translation of chat watermark - fix(ui): add fallback logo if custom logo is missing ## [2.8.3] - 2025-10-06 ### Added - Support for the `target` attribute in header links, which can be configured through the configuration options ### Changed - `@chainlit/react-client` automatic publishing ## [2.8.2] - 2025-10-01 ### Changed - Remove autofocus in mobile message composer - Improve error handling in sqlalchemy data layer `get_read_url()` ### Fixed - Fix voice hotkey (P) triggering when typing in chat input - Properly finalize data layers - Fix `on_chat_start` not always firing ## [2.8.1] - 2025-09-24 ### Added - Add German and Korean translations - Add support for custom_meta_url in config.toml ### Changed - `cl.on_thread_share_view` will allow shared thread viewing if it returns `True` to enable custom/admin viewing. ### Fixed - Removed redundant message sending in Slack when images are present. - Generate signed url when loading elements using SQLAlchemy data layer. ## [2.8.0] - 2025-09-12 ### Added - Add ability to share threads. See documentation for how to enable it. - https://docs.chainlit.io/api-reference/lifecycle-hooks/on-shared-thread-view - Add new chat settings: multi-select, radio-group, and checkbox - Add optional language parameter to set_starters - Add neutral Spanish translation - Allow sending commands from custom elements ### Changed - Reordered message composer elements ### Fixed - Default to plaintext code blocks for unsupported languages like CSV - Sort threads by updated_at field - Replace hardcoded strings with translation keys - GCP storage provider dependency is now optional - CI/CD fixes - Fixed issues with hot-reloading in dev mode (`-w` flag) - Take overridden config into account in audio handlers ## [2.7.2] - 2025-08-26 ### Added - Added LiteralAI data layer deprecation warning - Added context to `@cl.on_feedback` callback - Added Traditional Chinese (Taiwan) translations - Added configurable user_env persistence to database - New `persist_user_env` and `mask_user_env` field in `config.toml` - Added new command translations to all languages - Added CODEOWNERS ### Fixed - Improved dynamic config overrides for chat profiles - Import GCSStorageClient only when needed to avoid requiring optional dependencies - Updated CONTRIBUTING.md for `uv` usage ## [2.7.1.1] - 2025-08-21 - Fix publishing to include frontend and copilot folders ## [2.7.1] - 2025-08-20 - Fix publishing to work with uv ## [2.7.0] - 2025-08-20 ### Added - New ChatGPT-style command selection and improve message input handling - Added the ability to override certain config.toml settings for Chat Profiles, so some profiles can have MCP and some can't for example. [Documentation Updated](https://docs.chainlit.io/api-reference/chat-profiles#dynamic-configuration). - You must now explicity enable audio and MCP as these are no longer inferred by the presence of `on_audio_start` or `on_mcp_connect` callbacks - Delete your `config.toml`, run `chainlit init`, and update your settings - Added copilot setup instructions for GitHub Copilot SWE Agent - Added Slack socket mode support - AskFileButton can now upload file with proper checking and it's own limits - Added content-disposition metadata to azure blob uploads to persist download file name - Migrated from poetry to uv ### Fixed - Changed thread sorting to use updated time instead of creation time - Add missing headers when connecting Streamable HTTP MCP - Remove undocumented `CHAINLIT_CUSTOM_AUTH` environment variable used in Copilot ## [2.6.9] - 2025-08-14 ### Added - Add GitHub Copilot instructions for automated PRs - (Slack) Add threadId for user feedback - (Copilot) Add new optional opened property has been added to the widget config ### Fixed - Fix blinking cursor indicator - (Copilot) Rename copilot inner div id `chainlit-copilot` to `chainlit-copilot-chat` due to naming conflict with the outer div - Disable gzip for websocket-relaed http endpoint (Safari compatibility) - Prevent constant refresh on the login screen when using custom authenication - Fix MCP type hints ## [2.6.8] - 2025-08-08 ### Other - Reverted PR with newline preservation in messages due to incorrect rendering in child components like lists ## [2.6.7] - 2025-08-07 ### Fixed - Formatting when pasting HTML code and newlines in received messages ## [2.6.6] - 2025-08-05 ### Added - Add support for emoji reaction on message received in Slack - Add Greek translation - Copy both plain text and rich text to clipboard, if available (rich text pasting to editors like Word) - Rename `CHAINLIT_COOKIE_PATH` to `CHAINLIT_AUTH_COOKIE_PATH` and now espect CHAINLIT_ROOT_PATH - Add language parameter to Copilot widget configuration ### Fixed - Prevent HTML code in user message to be rendered as HTML instead of displaying as code - Properly parse `user_env` when `config.project.user_env` is empty ## [2.6.5] - 2025-08-02 ### Fixed - Properly escape HTML on paste - Enable gzip compression for frontend - Address security vulnerabilities in dependencies by upgrading them to the closest safe versions - CI e2e tests and pnpm cache issues ## [2.6.4] - 2025-08-01 ### Added - Add streamable HTTP MCP support - Improve e2e test stability and performance - Add configuration for expanded copilot mode - Add French translation ### Fixed - Fix inputs/outputs for langchain callbacks - Fix blinking indicator for in-progress steps - Avoid unnecessary logo fetching when supplied in config.toml ### Other - Bump dependencies ## [2.6.3] - 2025-07-25 ### Added - Ability to send empty commands - Wider element view in copilot and improved styling - Support signed urls for elements using dynamoDB persistence - Support additional connection arguments in SQLAlchemy data layer - Added `CHAINLIT_COOKIE_PATH` environment variable to set the cookie path ### Fixed - Message inputs formatting - Language pattern to allow `tzm-Latn-DZ` - Properly encode parentheses in markdown links - Fix chainlit data layer metadata upserts - Improve database connection handling - Fixed cookie path - Improve lanchain callbacks ### Other - Improve robustness of E2E tests - Removed watermark "Built with Chainlit" ## [2.6.2] - 2025-07-16 Technical release due to missed `frontend` and `copilot` folders in previous one. ## [2.6.1] - 2025-07-15 ### Added - New `on_feedback` callback - Relaxed restriction on number of starters (now more than 4 can be displayed) ### Fixed - Command persistence when `"button": True` is missing from command definition - `openai` and `mistralai` sub-modules fail due to incorrect `timestamp_utc` import - Temporarily reverted fix caused the following issues with Chainlit data layer: - `null value in column "metadata" of relation "Thread"` - `syntax error at or near ";"` - Google Cloud Storage private bucket support in Chainlit data layer - Portals (popups, dialogs, etc.) now render correctly inside Copilot’s shadow DOM ### Other - Removed telemetry - Updated versions for Node.js, Poetry, and pnpm; added Corepack support ## [2.6.0] - 2025-07-01 ### Added - Add commands to starters - Collapse command buttons to icons for small screens - Add timegated custom elements - Added ADC support for google cloud storage adapter - Added scope as env variable (`OAUTH_COGNITO_SCOPE`) to Cognito auth provider - Add MarkdownAlert Style Switcher. Control via `alert_style` in `config.toml`. - Allow custom s3 endpoint for the official data layer - Added container prop to dialog portal in Copilot shadow DOM - Bump dependencies - Add python 3.13 support ### Fixed - Fix chat input double-spacing issue - Resolve python deprecation warning for utc_now() and logger.warn - Fixed an issue where the portal for the ChatProfiles selector was being rendered outside the Copilot shadow DOM - Add mime type to element emitter - Handle float/Decimal conversion for DynamoDB persistence - Fix cancel button in Chat settings - Only update thread metadata when not empty ### Breaking - **LiteralAI** is being sunset and will be removed in one of the next releases. Please migrate to the official data layer instead. - Telemetry is now opt-in by default and will be removed in the next release. ## [2.5.5] - 2025-04-14 ### Added - Avatars now support `.` in their name (will be replaced with `_`). - Typed session accessors for user session - Allow set attributes for the tags of the custom_js or custom_css - Hovering a past chat in the sidebar will display the full title of the chat in a tooltip - The `X-Chainlit-Session-id` header is now automatically set to facilitate sticky sessions with websockets - `cl.ErrorMessage` now have a different avatar - The copy button is now only displayed on the final message of a run, like feedback buttons - CopilotFunction is now usable in custom JS - Header link now have an optional `display_name` to display text next to the icon - The default .env file loaded by chainlit is now configurable with `CHAINLIT_ENV_FILE` ### Changed - **[breaking]**: `http_referer`, `http_cookie` and `languages` are no longer directly available in the session object. Instead, `environ` is available containing all of those plus other HTTP headers - The scroll to the bottom animation is now smooth ## [2.4.400] - 2025-03-29 ### Added - `@cl.on_app_startup` and `@cl.on_app_shutdown` - Configuration option for chat history default open state - Configuration option for login page background image and filter - Most commonly customized ui elements now have specific IDs ### Fixed - App should no longer flicker on load - Attachments icons for microsoft files should now correctly display - Pasting should no longer be duplicated ## [2.4.302] - 2025-03-26 ### Added - Add thinking token support to langchain callback handler ### Fixed - Pasting issues in the chat input - Rename nl-NL.json to nl.json ## [2.4.301] - 2025-03-24 ### Fixed - Mcp button should not be displayed if `@on_mcp_connect` is not defined ## [2.4.3] - 2025-03-23 ### Added - Canvas mode for the element side bar if title == `canvas` - Allow list for MCP stdio commands - `key` parameter to `ElementSidebar.set_elements` method ### Fixed - Literal AI should now correctly store custom elements props - Element should correctly load from azure storage - Plotly elements should now take full width ## [2.4.2] - 2025-03-19 ### Added - Hide commands button if all commands are specified as button. ### Fixed - Chat profiles tooltip should no longer freeze is hover rapidly ## [2.4.1] - 2025-03-13 ### Added - The user message auto scroll behavior is now a feature `config.features.user_message_autoscroll` - Stdio MCP commands now support environment variables ### Fixed - Submounting a Chainlit app to a FastAPI app with a root path should now work ## [2.4.0] - 2025-03-11 ### Changed - Chainlit now requires python `>=3.10` ### Added - MCP support through `@cl.on_mcp_connect` and `@cl.on_mcp_disconnect` ### Fixed - Pasting text/images into Chainlit Copilot should now work - OAuth redirection should work when submounting Chainlit with root path `/` - Successive AskUser messages should no longer collide ### Removed - Outdated Haystack integration ## [2.3.0] - 2025-03-09 ### Added - New user messages are now placed/scrolled to the top of the chat to enhance readability - Commands have a new optional boolean field `button` to turn them into buttons - Custom elements have access to a new API `sendUserMessage` ### Fixed - Chainlit app using a custom root path should now work correctly when running in docker containers - Chat history time groups should now be sorted properly ## [2.2.1] - 2025-02-14 ### Added - `default_open` parameter to the step decorator/class ### Fixed - Input should not replace <,>,& - Starters should be disabled if no ws connection - Prevent orphaned thread record when deleting active conversation ## [2.2.0] - 2025-02-08 ### Added - You can now add custom buttons in the header ### Fixed - Step open/close is now animated - prevent unstyled flash when streaming code blocks - Docking/undocking scroll while streaming show now work better ## [2.1.2] - 2025-02-05 ### Fixed - The default loader should now be displayed if the chat is running and no response is yet sent - Pasting HTML in the chat input show now work - React warnings and accessibility issues - Command filtering now works with `includes` instead of `startWith` - The submit button should be disabled in the chat input is empty ## [2.1.1] - 2025-02-03 ### Fixed - Reintroduce including URL location after UI refactor - Ensure SAS token start time is set to UTC - Prevent showing 0's on resumed thread if AskAction/File was used - Remove 22px element ref height - Update Microsoft OAuth offline_access scope to be fully qualified with the prefix ## [2.1.0] - 2025-01-30 ### Added - You can now send toasts with `cl.context.emitter.send_toast` - Markdown now supports alerts - Theme options are now translatable - Copilot can now load custom css ### Fixed - Mounting Chainlit as a sub app should no longer break the parent's app endpoints - Pasting text in the chat input should now remove extra formatting and preserve new lines ## [2.0.603] - 2025-01-28 ### Added - Data layer initialization to the telemetry ### Fixed - Gap between the word `Used` and tool name in step name ## [2.0.602] - 2025-01-27 ### Fixed - Chat input should now auto focus - When unfolding a step, the `Output` title should only show if there is an input to display ## [2.0.601] - 2025-01-25 ### Fixed - Element sidebar should take full height ## [2.0.6] - 2025-01-24 ### Added - The element sidebar is now controllable from the python code ### Fixed - The auth cookie no longer has a maximal size - Pasting text in the chat input should now work - Long text in AskAction buttons are now gracefully displayed - Server connection error translation path ## [2.0.5] - 2025-01-21 ### Added - Chat GPT like commands - Translation options. The translation schema has been simplified ### Fixed - Warnings around file upload mime types - `uvicorn` and `packaging` version requirement have been relaxed ## [2.0.4] - 2025-01-17 ### Added - Overhaul element reference link styling - Japanese translations - Improved Chinese translations - Translations for feedback buttons ### Fixed - Cookie max age should now correctly use the config `user_session_timeout` field - Thread grouping in the chat history should now correctly handle timezones - File from `AskFileMessage` should now share ID with the data layer - Data layer boolean casting issues - Chat settings modal scrolling issue ## [2.0.3] - 2025-01-14 ### Added - `CustomElement.update()` to update a custom element props server side - Translation for the copy button ### Fixed - The official data layer should not overwrite elements anymore - A bug where resuming a thread would not load the thread - Prevent authentication before the app is fully loaded - Installing Chainlit from github should work again - `tool` steps should count as a thread start ## [2.0.2] - 2025-01-10 ### Added - `http_cookie` is now available in the user session and websocket session ### Fixed - Chat profile description on the welcome screen now supports custom html and latex - Thread history batch size has been increased to 35 to ensure scroll on a taller screens - Chat settings modal should now scroll if too tall - Errors in thread resume (like thread not found) now properly redirects to the the home page - Elements like Dataframe, Plotly or text should now load correctly from cloud storages - AskFileMessage is now usable even if spontaneous uploads are disabled - Remove element objects from cloud storage on thread removal (Official & SQLAlchemy data layers) - Fix custom element `props` storage for SQL Alchemy data layer ## [2.0.1] - 2025-01-09 ### Added - `window.toggleChainlitCopilot()` to toggle the copilot ### Fixed - Chat profiles icon and description should now be displayed on the welcome screen - Action should be able to trigger the first interaction - Raw code blocks should now be displayed correctly - TextInput for chat settings should now work - Upload attachement button should not be displayed when upload is disabled - Removed unused numpy dependency ## [2.0.0] - 2025-01-06 The Chainlit UI (including the copilot) has been completely re-written with Shadcn/Tailwind. This brings several advantages: 1. The codebase is simpler and more contribution friendly. 2. It enabled the new custom element feature. 3. The theme customisation is more powerful. ### Added - Custom Elements (code your own elements) - `Cmd+k` thread search - Thread rename - Official PostGres open source data layer - New `@data_layer` decorator for configuring custom data layers declaratively ### Changed - Authentication is now based on cookies. Cross Origins are disallowed unless added in `allow_origins` in the `config.toml` file - No longer need to click on `resume` to resume a thread - **[breaking]**: Theme customisation is now handled in `public/theme.json` instead of `config.toml`. - **[breaking]**: Changed fields on the `Action` class: - The `value` field has replaced with `payload` which accepts a Python dict - The `description` field has been renamed `tooltip` - The field `icon` has been added - The `collapsed` field has been removed. - **[breaking]**: Completely revamped audio implementation (#1401, #1410): - Replaced `AudioChunk` with `InputAudioChunk` and `OutputAudioChunk` - Changed default audio sampling rate from 44100 to 24000 - Removed several audio configuration options (`min_decibels`, `initial_silence_timeout`, `silence_timeout`, `chunk_duration`, `max_duration`) ### Fixed - Autoscaling of Chainlit app behind a load balancer should now work. Don't forget to enable sticky sessions ## [2.1.dev0] - 2024-11-14 Pre-release: developer preview. ### Added - New `@data_layer` decorator for configuring custom data layers declaratively - Unit tests for `get_data_layer()` and `@data_layer` functionality ### Changed - Data layer configuration system now prioritizes `@data_layer` decorator over environment variables - Data layer initialization is now more explicit and testable through the decorator pattern - Updated example code in `/cypress/e2e/custom_data_layer` and `/cypress/e2e/data_layer` to use the new decorator ### Developer Experience - Improved test infrastructure with new fixtures for data layer mocking - Added comprehensive tests for data layer configuration scenarios ## [1.3.2] - 2024-11-08 ### Security Advisory **IMPORTANT**: - This release drops support for FastAPI versions before 0.115.3 and Starlette versions before 0.41.2 due to a severe security vulnerability (CVE-2024-47874). We strongly encourage all downstream dependencies to upgrade as well. - This release still contains a known security vulnerability in the element feature that could allow unauthorized file access. We strongly recommend against using elements in production environments until a comprehensive fix is implemented in an upcoming release. ### Security - **[breaking]** Updated dependencies to address critical issues (#1493): - Upgraded fastapi to 0.115.3 to address CVE-2024-47874 in Starlette - Upgraded starlette to 0.41.2 (required for security fix) - Upgraded werkzeug to 3.0.6 Note: This is a breaking change as older FastAPI versions are no longer supported. To prioritize security, we opted to break with semver on this particular occasion. ### Fixed - Resolved incorrect message ordering in UI (#1501) ## [2.0rc0] - 2024-11-08 ### Security Advisory **IMPORTANT**: - The element feature currently contains a known security vulnerability that could allow unauthorized file access. We strongly recommend against using elements in production environments until a comprehensive fix is implemented in an upcoming release. ### Changed - **[breaking]**: Completely revamped audio implementation (#1401, #1410): - Replaced `AudioChunk` with `InputAudioChunk` and `OutputAudioChunk` - Changed default audio sampling rate from 44100 to 24000 - Removed several audio configuration options (`min_decibels`, `initial_silence_timeout`, `silence_timeout`, `chunk_duration`, `max_duration`) - Removed `RecordScreen` component - Factored storage clients into separate modules (#1363) ### Added - Realtime audio streaming and processing (#1401, #1406, #1410): - New `AudioPresence` component for visual representation - Implemented `WavRecorder` and `WavStreamPlayer` classes - Introduced new `on_audio_start` callback - Added audio interruption functionality - New audio connection signaling with `on` and `off` states - Interactive DataFrame display with auto-fit content using MUI Data Grid (#1373, #1467) - Optional websocket connection in react-client (#1379) - Enhanced image interaction with popup view and download option (#1402) - Current URL included in message payload (#1403) - Allow empty chat input when submitting attachments (#1261) ### Fixes - Various backend fixes and cleanup (#1432): - Use importlib.util.find_spec to check if a package is installed - Use `raise... from` to wrap exceptions - Fix error message in Discord integration - Several minor fixups/cleanup ### Development - Implemented ruff for linting and formatting (#1495) - Added mypy daemon for faster type-checking (#1495) - Added GitHub Actions linting (#1445) - Enabled direct installation from GitHub (#1423) - Various build script improvements (#1462) ## [1.3.1] - 2024-10-25 ### Security Advisory - **IMPORTANT**: This release temporarily reverts the file access security improvements from 1.3.0 to restore element functionality. The element feature currently has a known security vulnerability that could allow unauthorized access to files. We strongly recommend against using elements in production environments until the next release. - A comprehensive security fix will be implemented in an upcoming release. ### Changed - Reverted authentication requirements for file access endpoints to restore element functionality (#1474) ### Development - Work in progress on implementing HTTP-only cookie authentication for proper security (#1472) ## [1.3.0] - 2024-10-22 ### Security - Fixed critical endpoint security vulnerabilities (#1441) - Enhanced authentication for file-related endpoints (#1431) - Upgraded frontend and backend dependencies to address security issues (#1431) ### Added - SQLite support in SQLAlchemy integration (#1319) - Support for IETF BCP 47 language tags, enabling localized languages like es-419 (#1399) - Environment variables `OAUTH__PROMPT` and `OAUTH_PROMPT` to override oauth prompt parameter. Enabling users to explicitly enable login/consent prompts for oauth, e.g. `OAUTH_PROMPT=consent` to prevent automatic re-login. (#1362, #1456). - Added `get_element()` method to SQLAlchemyDataLayer (#1346) ### Changed - Bumped LiteralAI dependency to version 0.0.625 (#1376) - Optimized LiteralDataLayer for improved performance and consistency (#1376) - Refactored context handling in SQLAlchemy data layer (#1319) - Updated package metadata with correct authors, license, and documentation links (#1413) - Enhanced GitHub Actions workflow with restricted permissions (#1349) ### Fixed - Resolved dialog boxes extending beyond window bounds (#1446) - Fixed tasklist functionality when Chainlit is submounted (#1433) - Corrected handling of `display_name` in PersistentUser during authentication (#1425) - Fixed SQLAlchemy identifier quoting (#1395) - Improved spaces handling in avatar filenames (#1418) ### Development - Implemented extensive test coverage for LiteralDataLayer and SQLAlchemyDataLayer - Added comprehensive unit tests for file-related endpoints - Enhanced code organization and import structure - Improved Python code style and linting (#1353) - Resolved various small text and documentation issues (#1347, #1348) ## [1.2.0] - 2024-09-16 ### Security - Fixed critical vulnerabilities allowing arbitrary file read access (#1326) - Improved path traversal protection in various endpoints (#1326) ### Added - Hebrew translation JSON (#1322) - Translation files for Indian languages (#1321) - Support for displaying function calls as tools in Chain of Thought for LlamaIndexCallbackHandler (#1285) - Improved feedback UI with refined type handling (#1325) ### Changed - Upgraded cryptography from 43.0.0 to 43.0.1 in backend dependencies (#1298) - Improved GitHub Actions workflow (#1301) - Enhanced data layer cleanup for better performance (#1288) - Factored out callbacks with extensive test coverage (#1292) - Adopted strict adherence to Semantic Versioning (SemVer) ### Fixed - Websocket connection issues when submounting Chainlit (#1337) - Show_input functionality on chat resume for SQLAlchemy (#1221) - Negative feedback class incorrectness (#1332) - Interaction issues with Chat Profile Description Popover (#1276) - Centered steps within assistant messages (#1324) - Minor spelling errors (#1341) ### Development - Added documentation for release engineering process (#1293) - Implemented testing for FastAPI version matrix (#1306) - Removed wait statements from E2E tests for improved performance (#1270) - Bumped dataclasses to latest version (#1291) - Ensured environment loading before other imports (#1328) ## [1.1.404] - 2024-09-04 ### Security - **[breaking]**: Listen to 127.0.0.1 (localhost) instead on 0.0.0.0 (public) (#861). - **[breaking]**: Dropped support for Python 3.8, solving dependency resolution, addressing vulnerable dependencies (#1192, #1236, #1250). ### Fixed - Frontend connection resuming after connection loss (#828). - Gracefully handle HTTP errors in data layers (#1232). - AttributeError: 'ChatCompletionChunk' object has no attribute 'get' in llama_index (#1229). - `edit_message` in correct place in default config, allowing users to edit messages (#1218). ### Added - `CHAINLIT_APP_ROOT` environment variable to modify `APP_ROOT`, enabling the ability to set the location of `config.toml` and other setting files (#1259). - Poetry lockfile in GIT repository for reproducible builds (#1191). - pytest-based testing infrastructure, first unit tests of backend and testing on all supported Python versions (#1245 and #1271). - Black and isort added to dev dependencies group (#1217). ## [1.1.403rc0] - 2024-08-13 ### Fixed - Langchain Callback handler IndexError - Attempt to fix websocket issues ## [1.1.402] - 2024-08-07 ### Added - The `User` class now has a `display_name` field. It will not be persisted by the data layer. - The logout button will now reload the page (needed for custom auth providers) ## [1.1.401] - 2024-08-02 ### Changed - Directly log step input args by name instead of wrapping them in "args" for readability. ### Fixed - Langchain Callback handler ValueError('not enough values to unpack (expected 2, got 0)') ## [1.1.400] - 2024-07-29 ### Changed - hide_cot becomes cot and has three possible values: hidden, tool_call, full - User feedback are now scoring an entire run instead of a specific message - Slack/Teams/Discord DM threads are now split by day - Slack DM now also use threads - Avatars are always displayed at the root level of the conversation ### Removed - disable_feedback has been removed - root_message has been removed ## [1.1.306] - 2024-07-03 ### Added - Messages are now editable. You can disable this feature with `config.features.edit_message = false` - `cl.chat_context` to help keeping track of the messages of the current thread - You can now enable debug_mode when mounting Chainlit as a sub app by setting the `CHAINLIT_DEBUG` to `true`. ### Fixed - Message are now collapsible if too long - Only first level tool calls are displayed - OAuth redirection when mounting Chainlit on a FastAPI app should now work - The Langchain callback handler should better capture chain runs - The Llama Index callback handler should now work with other decorators ## [1.1.305] - 2024-06-26 ### Added - Mistral AI instrumentation ## [1.1.304] - 2024-06-21 ### Fixed - OAuth final redirection should account for root path if provided ## [1.1.303] - 2024-06-20 ### Fixed - OAuth URL redirection should be correctly formed when using CHAINLIT_URL + submounted chainlit app ## [1.1.302] - 2024-06-16 ### Added - Width and height option for the copilot bubble ### Fixed - Chat profile icon in copilot should load - Theme should work with Copilot ### Removed - Running toast when an action is running ## [1.1.301] - 2024-06-14 ### Fixed - Azure AD oauth get_user_info not implemented error ## [1.1.300] - 2024-06-13 ### Added - `@cl.set_starters` and `cl.Starter` to suggest conversation starters to the user - Teams integration - Expand copilot button - Debug mode when starting with `-d`. Only available if the data layer supports it. This replaces the Prompt Playground. - `default` theme config in `config.toml` - If only one OAuth provider is set, automatically redirect the user to it - Input streaming for tool calls ### Changed - **[BREAKING]** Custom endpoints have been reworked. You should now mount your Chainlit app as a FastAPI subapp. - **[BREAKING]** Avatars have been reworked. `cl.Avatar` has been removed, instead place your avatars by name in `/public/avatars/*` - **[BREAKING]** The `running`, `took_one` and `took_other` translations have been replaced by `used`. - **[BREAKING]** `root` attribute of `cl.Step` has been removed. Use `cl.Message` to send root level messages. - Chain of Thought has been reworked. Only steps of type `tool` will be displayed if `hide_cot` is false - The `show_readme_as_default` config has been removed - No longer collapse root level messages - The blue alert "Continuing chat" has been removed. ### Fix - The Chat Profile description should now disappear when not hovered. - Error handling of steps has been improved - No longer stream the first token twice - Copilot should now work as expected even if the user is closing/reopening it - Copilot CSS should no longer leak/be impacted by the host website CSS - Fix various `cl.Context` errors - Reworked message padding and spacing - Chat profile should now support non-ASCII characters (like chinese) ## [1.1.202] - 2024-05-22 ### Added - Support for video players like youtube or vimeo ### Fixed - Fix audio capture on windows browsers ## [1.1.201] - 2024-05-21 ### Fixed - Intermediary steps button placement ## [1.1.200] - 2024-05-21 ### Changed - User message UI has been updated - Loading indicator has been improved and visually updated - Icons have been updated - Dark theme is now the default ### Fixed - Scroll issues on mobile browsers - Github button now showing ## [1.1.101] - 2024-05-14 ### Added - The discord bot now shows "typing" while responding ### Fixed - Discord and Slack bots should no longer fail to respond if the data layer fails ## [1.1.0] - 2024-05-13 ### Added - You can know serve your Chainlit app as a Slack bot - You can know serve your Chainlit app as a Discord bot - `cl.on_audio_chunk` decorator to process incoming the user incoming audio stream - `cl.on_audio_end` decorator to react to the end of the user audio stream - The `cl.Audio` element now has an `auto_play` property - `layout` theme config, wide or default - `http_referer` is now available in `cl.user_session` ### Changed - The UI has been revamped, especially the navigation - The arrow up button has been removed from the input bar, however pressing the arrow up key still opens the last inputs menu - The user session will no longer be persisted as metadata if > 1mb - **[breaking]** the `send()` method on `cl.Message` now returns the message instead of the message id - **[breaking]** The `multi_modal` feature has been renamed `spontaneous_file_upload` in the config - Element display property now defaults to `inline` instead of `side` - The SQL Alchemy data layer logging has been improved ### Fixed - Fixed a bug disconnecting the user when loading the chat history - Elements based on an URL should now have a mime type - Stopping a task should now work better (using asyncio task.cancel) ## [1.0.506] - 2024-04-30 ### Added - add support for multiline option in TextInput chat settings field - @kevinwmerritt ### Changed - disable gzip middleware to prevent a compression issue on safari ### Fixed - pasting from microsoft products generates text instead of an image - do not prevent thread history revalidation - @kevinwmerritt - display the label instead of the value for menu item - @kevinwmerritt ### Added ## [1.0.505] - 2024-04-23 ### Added - The user's browser language configuration is available in `cl.user_session.get("languages")` - Allow html in text elements - @jdb78 - Allow for setting a ChatProfile default - @kevinwmerritt ### Changed - The thread history refreshes right after a new thread is created. - The thread auto-tagging feature is now opt-in using `auto_tag_thread` in the config.toml file ### Fixed - Fixed incorrect step ancestor in the OpenAI instrumentation - Enabled having a `storage_provider` set to `None` in SQLAlchemyDataLayer - @mohamedalani - Correctly serialize `generation` in SQLAlchemyDataLayer - @mohamedalani ## [1.0.504] - 2024-04-16 ### Changed - Chainlit apps should function correctly even if the data layer is down ## [1.0.503] - 2024-04-15 ### Added - Enable persisting threads using a Custom Data Layer (through SQLAlchemy) - @hayescode ### Changed - React-client: Expose `sessionId` in `useChatSession` - Add chat profile as thread tag metadata ### Fixed - Add quotes around the chainlit create-secret CLI output to avoid any issues with special characters ## [1.0.502] - 2024-04-08 ### Added - Actions now trigger conversation persistence ## [1.0.501] - 2024-04-08 ### Added - Messages and steps now accept tags and metadata (useful for the data layer) ### Changed - The LLama Index callback handler should now show retrieved chunks in the intermadiary steps - Renamed the Literal environment variable to `LITERAL_API_URL` (it used to be `LITERAL_SERVER`) ### Fixed - Starting a new conversation should close the element side bar - Resolved security issues by upgrading starlette dependency ## [1.0.500] - 2024-04-02 ### Added - Added a new command `chainlit lint-translations` to check that translations file are OK - Added new sections to the translations, like signin page - chainlit.md now supports translations based on the browser's language. Like chainlit_pt-BR.md - A health check endpoint is now available through a HEAD http call at root - You can now specify a custom frontend build path ### Fixed - Translated will no longer flash at app load - Llama Index callback handler has been updated - File watcher should now properly refresh the app when the code changes - Markdown titles should now have the correct line height ### Changed - `multi_modal` is now under feature in the config.toml and has more granularity - Feedback no longer has a -1 value. Instead a delete_feedback method has been added to the data layer - ThreadDict no longer has the full User object. Instead it has user_id and user_identifier fields ## [1.0.400] - 2024-03-06 ### Added - OpenAI integration ### Fixed - Langchain final answer streaming should work again - Elements with public URLs should be correctly persisted by the data layer ### Changed - Enforce UTC DateTimes ## [1.0.300] - 2024-02-19 ### Added - Custom js script injection - First token and token throughput per second metrics ### Changed - The `ChatGeneration` and `CompletionGeneration` has been reworked to better match the OpenAI semantics ## [1.0.200] - 2024-01-22 ### Added - Chainlit Copilot - Translations - Custom font ### Fixed - Tasklist flickering ## [1.0.101] - 2024-01-12 ### Fixed - Llama index callback handler should now correctly nest the intermediary steps - Toggling hide_cot parameter in the UI should correctly hide the `took n steps` buttons - `running` loading button should only be displayed once when `hide_cot` is true and a message is being streamed ## [1.0.100] - 2024-01-10 ### Added - `on_logout` hook allowing to clear cookies when a user logs out ### Changed - Chainlit apps won't crash anymore if the data layer is not reachable ### Fixed - File upload now works when switching chat profiles - Avatar with an image no longer have a background color - If `hide_cot` is set to `true`, the UI will never get the intermediary steps (but they will still be persisted) - Fixed a bug preventing to open past chats ## [1.0.0] - 2024-01-08 ### Added - Scroll down button - If `hide_cot` is set to `true`, a `running` loader is displayed by default under the last message when a task is running. ### Changed - Avatars are now always displayed - Chat history sidebar has been revamped - Stop task button has been moved to the input bar ### Fixed - If `hide_cot` is set to `true`, the UI will never get the intermediary steps (but they will still be persisted) ## [1.0.0rc3] - 2023-12-21 ### Fixed - Elements are now working when authenticated - First interaction is correctly set when resuming a chat ### Changed - The copy button is hidden if `disable_feedback` is `true` ## [1.0.0rc2] - 2023-12-18 ### Added - Copy button under messages - OAuth samesite cookie policy is now configurable through the `CHAINLIT_COOKIE_SAMESITE` env var ### Changed - Relax Python version requirements - If `hide_cot` is configured to `true`, steps will never be sent to the UI, but still persisted. - Message buttons are now positioned below ## [1.0.0rc0] - 2023-12-12 ### Added - cl.Step ### Changed - File upload uses HTTP instead of WS and no longer has size limitation - `cl.AppUser` becomes `cl.User` - `Prompt` has been split in `ChatGeneration` and `CompletionGeneration` - `Action` now display a toaster in the UI while running ## [0.7.700] - 2023-11-28 ### Added - Support for custom HTML in message content is now an opt in feature in the config - Uvicorn `ws_per_message_deflate` config param is now configurable like `UVICORN_WS_PER_MESSAGE_DEFLATE=false` ### Changed - Latex support is no longer enabled by default and is now a feature in the config ### Fixed - Fixed LCEL memory message order in the prompt playground - Fixed a key error when using the file watcher (-w) - Fixed several user experience issues with `on_chat_resume` - `on_chat_end` is now always called when a chat ends - Switching chat profiles correctly clears previous AskMessages ## [0.7.604] - 2023-11-15 ### Fixed - `on_chat_resume` now works properly with non json serializable objects - `LangchainCallbackHandler` no longer send tokens to the wrong user under high concurrency - Langchain cache should work when `cache` is to `true` in `config.toml` ## [0.7.603] - 2023-11-15 ### Fixed - Markdown links special characters are no longer encoded - Collapsed messages no longer make the chat scroll - Stringified Python objects are now displayed in a Python code block ## [0.7.602] - 2023-11-14 ### Added - Latex support (only supporting $$ notation) - Go back button on element page ### Fixed - Code blocks should no longer flicker or display `[object object]`. - Now properly displaying empty messages with inlined elements - Fixed `Too many values to unpack error` in langchain callback - Langchain final streamed answer is now annotable with human feedback - AzureOpenAI should now work properly in the Prompt Playground ### Changed - Code blocks display has been enhanced - Replaced aiohttp with httpx - Prompt Playground has been updated to work with the new openai release (v1). Including tools - Auth0 oauth provider has a new configurable env variable `OAUTH_AUTH0_ORIGINAL_DOMAIN` ## [0.7.500] - 2023-11-07 ### Added - `cl.on_chat_resume` decorator to enable users to continue a conversation. - Support for OpenAI functions in the Prompt Playground - Ability to add/remove messages in the Prompt Playground - Plotly element to display interactive charts ### Fixed - Langchain intermediate steps display are now much more readable - Chat history loading latency has been enhanced - UTF-8 characters are now correctly displayed in json code blocks - Select widget `items` attribute is now working properly - Chat profiles widget is no longer scrolling horizontally ## [0.7.400] - 2023-10-27 ### Added - Support for Langchain Expression Language. https://docs.chainlit.io/integrations/langchain - UI rendering optimization to guarantee high framerate - Chainlit Cloud latency optimization - Speech recognition to type messages. https://docs.chainlit.io/backend/config/features - Descope OAuth provider ### Changed - `LangchainCallbackHandler` is now displaying inputs and outputs of intermediate steps. ### Fixed - AskUserMessage now work properly with data persistence - You can now use a custom okta authorization server for authentication ## [0.7.3] - 2023-10-17 ### Added - `ChatProfile` allows to configure different agents that the user can freely chose - Multi modal support at the input bar level. Enabled by `features.multi_modal` in the config - `cl.AskUserAction` allows to block code execution until the user clicked an action. - Displaying readme when chat is empty is now configurable through `ui.show_readme_as_default` in the config ### Changed - `cl.on_message` is no longer taking a string as parameter but rather a `cl.Message` ### Fixed - Chat history is now correctly displayed on mobile - Azure AD OAuth authentication should now correctly display the user profile picture ### Removed - `@cl.on_file_upload` is replaced by true multi modal support at the input bar level ## [0.7.2] - 2023-10-10 ### Added - Logo is displayed in the UI header (works with custom logo) - Azure AD single tenant is now supported - `collapsed` attribute on the `Action` class - Latency improvements when data persistence is enabled ### Changed - Chat history has been entirely reworked - Chat messages redesign - `config.ui.base_url` becomes `CHAINLIT_URL` env variable ### Fixed - File watcher (-w) is now working with nested module imports - Unsupported character during OAuth authentication ## [0.7.1] - 2023-09-29 ### Added - Pydantic v2 support - Okta auth provider - Auth0 auth provider - Prompt playground support for mix of template/formatted prompts - `@cl.on_chat_end` decorator - Textual comments to user feedback ### Fixed - Langchain errors are now correctly indented - Langchain nested chains prompts are now correctly displayed - Langchain error TypeError: 'NoneType' object is not a mapping. - Actions are now displayed on mobile - Custom logo is now working as intended ## [0.7.0] - 2023-09-13 ### Changed - Authentication is now unopinionated: 1. `@cl.password_auth_callback` for login/password auth 2. `@cl.oauth_callback` for oAuth auth 3. `@cl.header_auth_callback` for header auth - Data persistence is now enabled through `CHAINLIT_API_KEY` env variable ### Removed - `@cl.auth_client_factory` (see new authentication) - `@cl.db_client_factory` (see new data persistence) ### Added - `disable_human_feedback` parameter on `cl.Message` - Configurable logo - Configurable favicon - Custom CSS injection - GCP Vertex AI LLM provider - Long message collpasing feature flag - Enable Prompt Playground feature flag ### Fixed - History page filters now work properly - History page does not show empty conversations anymore - Langchain callback handler Message errors ## [0.6.4] - 2023-08-30 ### Added - `@cl.on_file_upload` to enable spontaneous file uploads - `LangchainGenericProvider` to add any Langchain LLM in the Prompt Playground - `cl.Message` content now support dict (previously only supported string) - Long messages are now collapsed by default ### Fixed - Deadlock in the Llama Index callback handler - Langchain MessagesPlaceholder and FunctionMessage are now correctly supported ## [0.6.3] - 2023-08-22 ### Added - Complete rework of the Prompt playground. Now supports custom LLMs, templates, variables and more - Enhanced Langchain final answer streaming - `remove_actions` method on the `Message` class - Button to clear message history ### Fixed - Chainlit CLI performance issue - Llama Index v0.8+ callback handler. Now supports messages prompts - Tasklist display, persistence and `.remove()` - Custom headers growing infinitely large - Action callback can now handle multiple actions - Langflow integration load_flow_from_json - Video and audio elements on Safari ## [0.6.2] - 2023-08-06 ### Added - Make the chat experience configurable with Chat Settings - Authenticate users based on custom headers with the Custom Auth client ### Fixed - Author rename now works with all kinds of messages - Create message error with chainlit cloud (chenjuneking) ## [0.6.1] - 2023-07-24 ### Added - Security improvements - Haystack callback handler - Theme customizability ### Fixed - Allow multiple browser tabs to connect to one Chainlit app - Sidebar blocking the send button on mobile ## [0.6.0] - 2023-07-20 ### Breaking changes - Factories, run and post process decorators are removed. - langchain_rename becomes author_rename and works globally - Message.update signature changed Migration guide available [here](https://docs.chainlit.io/guides/migration/0.6.0). ### Added - Langchain final answer streaming - Redesign of chainlit input elements - Possibility to add custom endpoints to the fast api server - New File Element - Copy button in code blocks ### Fixed - Persist session between websocket reconnection - The UI is now more mobile friendly - Avatar element Path parameter - Increased web socket message max size to 100 mb - Duplicated conversations in the history tab ## [0.5.2] - 2023-07-10 ### Added - Add the video element ### Fixed - Fix the inline element flashing when scrolling the page, due to un-needed re-rendering - Fix the orange flash effect on messages ## [0.5.1] - 2023-07-06 ### Added - Task list element - Audio element - All elements can use the `.remove()` method to remove themselves from the UI - Can now use cloud auth with any data persistence mode (like local) - Microsoft auth ### Fixed - Files in app dir are now properly served (typical use case is displaying an image in the readme) - Add missing attribute `size` to Pyplot element ## [0.5.0] - 2023-06-28 ### Added - Llama Index integration. Learn more [here](https://docs.chainlit.io/integrations/llama-index). - Langflow integration. Learn more [here](https://docs.chainlit.io/integrations/langflow). ### Fixed - AskUserMessage.remove() now works properly - Avatar element cannot be referenced in messages anymore ## [0.4.2] - 2023-06-26 ### Added - New data persistence mode `local` and `custom` are available on top of the pre-existing `cloud` one. Learn more [here](https://docs.chainlit.io/data). ## [0.4.101] - 2023-06-24 ### Fixed - Performance improvements and bug fixes on run_sync and asyncify ## [0.4.1] - 2023-06-20 ### Added - File watcher now reloads the app when the config is updated - cl.cache to avoid wasting time reloading expensive resources every time the app reloads ### Fixed - Bug introduced by 0.4.0 preventing to run private apps - Long line content breaking the sidebar with Text elements - File watcher preventing to keyboard interrupt the chainlit process - Updated socket io to fix a security issue - Bug preventing config settings to be the default values for the settings in the UI ## [0.4.0] - 2023-06-16 ### Added - Pyplot chart element - Config option `default_expand_messages` to enable the default expand message settings by default in the UI (breaking change) ### Fixed - Scoped elements sharing names are now correctly displayed - Clickable Element refs are now correctly displayed, even if another ref being a substring of it exists ## [0.3.0] - 2023-06-13 ### Added - Moving from sync to async runtime (breaking change): - Support async implementation (eg openai, langchain) - Performance improvements - Removed patching of different libraries - Elements: - Merged LocalImage and RemoteImage to Image (breaking change) - New Avatar element to display avatars in messages - AskFileMessage now supports multi file uploads (small breaking change) - New settings interface including a new "Expand all" messages setting - The element sidebar is resizable ### Fixed - Secure origin issues when running on HTTP - Updated the callback handler to langchain 0.0.198 latest changes - Filewatcher issues - Blank screen issues - Port option in the CLI does not fail anymore because of os import ## [0.2.111] - 2023-06-09 ### Fixed - Pdf element reloading issue - CI is more stable ## [0.2.110] - 2023-06-08 ### Added - `AskFileMessage`'s accept parameter can now can take a Dict to allow more fine grained rules. More infos here https://react-dropzone.org/#!/Accepting%20specific%20file%20types. - The PDF viewer element helps you display local or remote PDF files ([documentation](https://docs.chainlit.io/api-reference/elements/pdf-viewer)). ### Fixed - When running the tests, the chainlit cli is installed is installed in editable mode to run faster. ## [0.2.109] - 2023-05-31 ### Added - URL preview for social media share ### Fixed - `max_http_buffer_size` is now set to 100mb, fixing the `max_size_mb` parameter of `AskFileMessage` ## [0.2.108] - 2023-05-30 ### Fixed - Enhanced security - Global element display - Display elements with display `page` based on their ids instead of their names ## [0.2.107] - 2023-05-28 ### Added - Rework of the Message, AskUserMessage and AskFileMessage APIs: - `cl.send_message(...)` becomes `cl.Message(...).send()` - `cl.send_ask_user(...)` becomes `cl.AskUserMessage(...).send()` - `cl.send_ask_file(...)` becomes `cl.AskFileMessage(...).send()` - `update` and `remove` methods to the `cl.Message` class ### Fixed - Blank screen for windows users (https://github.com/Chainlit/chainlit/issues/3) - Header navigation for mobile (https://github.com/Chainlit/chainlit/issues/12) ## [0.2.106] - 2023-05-26 ### Added - Starting to log changes in CHANGELOG.md - Port and hostname are now configurable through the `CHAINLIT_HOST` and `CHAINLIT_PORT` env variables. You can also use `--host` and `--port` when running `chainlit run ...`. - A label attribute to Actions to facilitate localization. ### Fixed - Clicks on inlined `RemoteImage` now opens the image in a NEW tab. ================================================ FILE: CONTRIBUTING.md ================================================ # Contribute to Chainlit To contribute to Chainlit, you first need to set up the project on your local machine. ## Table of Contents - [Contribute to Chainlit](#contribute-to-chainlit) - [Table of Contents](#table-of-contents) - [Local setup](#local-setup) - [Requirements](#requirements) - [Set up the repo](#set-up-the-repo) - [Install dependencies](#install-dependencies) - [Build Frontend](#build-frontend) - [Start the Chainlit server from source](#start-the-chainlit-server-from-source) - [Start the UI from source](#start-the-ui-from-source) - [Run the tests](#run-the-tests) - [Backend unit tests](#backend-unit-tests) - [E2E tests](#e2e-tests) - [Headed/debugging](#headeddebugging) ## Local setup ### Requirements 1. Python >= `3.10` 2. uv ([See how to install](https://docs.astral.sh/uv/getting-started/installation/)) 3. NodeJS >= `24` ([See how to install](https://nodejs.org/en/download)) 4. Pnpm ([See how to install](https://pnpm.io/installation)) > **Note** > If you are on windows, some pnpm commands like `pnpm run formatPython` won't work. You can fix this by changing the pnpm script-shell to bash: `pnpm config set script-shell "C:\\Program Files\\git\\bin\\bash.exe"` (default x64 install location, [Info](https://pnpm.io/cli/run#script-shell)) ### Set up the repo With this setup you can easily code in your fork and fetch updates from the main repository. 1. Go to [https://github.com/Chainlit/chainlit/fork](https://github.com/Chainlit/chainlit/fork) to fork the chainlit code into your own repository. 2. Clone your fork locally ```sh git clone https://github.com/YOUR_USERNAME/YOUR_FORK.git ``` 3. Go into your fork and list the current configured remote repository. ```sh $ git remote -v > origin https://github.com/YOUR_USERNAME/YOUR_FORK.git (fetch) > origin https://github.com/YOUR_USERNAME/YOUR_FORK.git (push) ``` 4. Specify the new remote upstream repository that will be synced with the fork. ```sh git remote add upstream https://github.com/Chainlit/chainlit.git ``` 5. Verify the new upstream repository you've specified for your fork. ```sh $ git remote -v > origin https://github.com/YOUR_USERNAME/YOUR_FORK.git (fetch) > origin https://github.com/YOUR_USERNAME/YOUR_FORK.git (push) > upstream https://github.com/Chainlit/chainlit.git (fetch) > upstream https://github.com/Chainlit/chainlit.git (push) ``` ### Install dependencies The following command will install Python dependencies, Node (pnpm) dependencies and build the frontend. ```sh cd backend uv sync --extra tests --extra mypy --extra dev --extra custom-data ``` ## Start the Chainlit server from source Start by running `backend/chainlit/sample/hello.py` as an example. ```sh cd backend uv run chainlit run chainlit/sample/hello.py ``` You should now be able to access the Chainlit app you just launched on `http://127.0.0.1:8000`. If you've made it this far, you can now replace `chainlit/sample/hello.py` by your own target. 😎 ## Start the UI from source First, you will have to start the server either [from source](#start-the-chainlit-server-from-source) or with `chainlit run...`. Since we are starting the UI from source, you can start the server with the `-h` (headless) option. Then, start the UI. ```sh cd frontend pnpm run dev ``` If you visit `http://localhost:5173/`, it should connect to your local server. If the local server is not running, it should say that it can't connect to the server. ## Run the tests ### Backend unit tests This will run the backend's unit tests. ```sh cd backend uv run pytest --cov=chainlit ``` ### E2E tests You may need additional configuration or dependency installation to run Cypress. See the [Cypress system requirements](https://docs.cypress.io/app/get-started/install-cypress#System-requirements) for details. This will run end to end tests, assessing both the frontend, the backend and their interaction. First install cypress with `pnpm exec cypress install`, and then run: ```sh // from root pnpm test // will do cypress run pnpm test -- --spec cypress/e2e/copilot // will run single test with the name copilot pnpm test -- --spec "cypress/e2e/copilot,cypress/e2e/data_layer" // will run two tests with the names copilot and data_layer pnpm test -- --spec "cypress/e2e/**/async-*" // will run all async tests pnpm test -- --spec "cypress/e2e/**/sync-*" // will run all sync tests pnpm test -- --spec "cypress/e2e/**/spec.cy.ts" // will run all usual tests ``` (Go grab a cup of something, this will take a while.) For debugging purposes, you can use the **interactive mode** (Cypress UI). Run: ``` pnpm test:interactive // runs `cypress open` ``` Once you create a pull request, the tests will automatically run. It is a good practice to run the tests locally before pushing. Make sure to run `uv sync` again whenever you've updated the frontend! ### Headed/debugging Causes the Electron browser to be shown on screen and keeps it open after tests are done. Extremely useful for debugging! ```sh SINGLE_TEST=password_auth CYPRESS_OPTIONS='--headed --no-exit' pnpm test ``` ================================================ FILE: LICENSE ================================================ Copyright 2023- The Chainlit team. All rights reserved. Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] 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. ================================================ FILE: PRIVACY_POLICY.md ================================================ # Privacy Policy Chainlit doesn't collect any data from its users after 2.6.1 release. ================================================ FILE: RELENG.md ================================================ # Release Engineering Instructions This document outlines the steps for maintainers to create a new release of the project. ## Prerequisites - You must have maintainer permissions on the repo to create a new release. ## Steps 1. **Determine the new version number**: - We use semantic versioning (major.minor.patch). - Increment the major version for breaking changes, minor version for new features, patch version for bug fixes only. - If unsure, discuss with the maintainers to determine if it should be a major/minor version bump or new patch version. 2. **Bump the package version**: - Update `version` in `backend/chainlit/version.py`. - Update `version` in `libs/*/package.json` if there were any changes in the corresponding directories. 3. **Update the changelog**: - Create a pull request to update the CHANGELOG.md file with the changes for the new release. - Mark any breaking changes clearly. - Get the changelog update PR reviewed and merged. 4. **Create a new release**: - In the GitHub repo, go to the "Releases" page and click "Draft a new release". - Input the new version number as the tag (e.g. 4.0.4). - Use the "Generate release notes" button to auto-populate the release notes from the changelog. - Review the release notes, make any needed edits for clarity. - If this is a full release after an RC, remove any "-rc" suffix from the version number. - Publish the release. 5. **Update any associated documentation and examples**: - If needed, create PRs to update the version referenced in the docs and example code to match the newly released version. - Especially important for documented breaking changes. ## RC (Release Candidate) Releases - We create RC releases to allow testing before a full stable release - Append "-rc" to the version number (e.g. 4.0.4-rc) - Normally only bug fixes, no new features, between an RC and the final release version Ping @dokterbob or @willydouhard for any questions or issues with the release process. Happy releasing! ================================================ FILE: backend/build.py ================================================ """Build script gets called on uv/pip build.""" import pathlib import shutil import subprocess import sys from hatchling.builders.hooks.plugin.interface import BuildHookInterface class BuildError(Exception): """Custom exception for build failures""" pass def run_subprocess(cmd: list[str], cwd: pathlib.Path) -> None: """ Run a subprocess, allowing natural signal propagation. Args: cmd: Command and arguments as a list of strings cwd: Working directory for the subprocess """ print(f"-- Running: {' '.join(cmd)}") subprocess.run(cmd, cwd=cwd, check=True) def pnpm_install(project_root: pathlib.Path, pnpm_path: str): run_subprocess([pnpm_path, "install", "--frozen-lockfile"], project_root) def pnpm_buildui(project_root: pathlib.Path, pnpm_path: str): run_subprocess([pnpm_path, "buildUi"], project_root) def copy_directory(src: pathlib.Path, dst: pathlib.Path, description: str): """Copy directory with proper error handling""" print(f"Copying {description} from {src} to {dst}") try: if dst.exists(): shutil.rmtree(dst) dst.mkdir(parents=True) shutil.copytree(src, dst, dirs_exist_ok=True) except KeyboardInterrupt: print("\nInterrupt received during copy operation...") # Clean up partial copies if dst.exists(): shutil.rmtree(dst) raise except Exception as e: raise BuildError(f"Failed to copy {src} to {dst}: {e!s}") def copy_frontend(project_root: pathlib.Path): """Copy the frontend dist directory to the backend for inclusion in the package.""" backend_frontend_dir = project_root / "backend" / "chainlit" / "frontend" / "dist" frontend_dist = project_root / "frontend" / "dist" copy_directory(frontend_dist, backend_frontend_dir, "frontend assets") def copy_copilot(project_root: pathlib.Path): """Copy the copilot dist directory to the backend for inclusion in the package.""" backend_copilot_dir = project_root / "backend" / "chainlit" / "copilot" / "dist" copilot_dist = project_root / "libs" / "copilot" / "dist" copy_directory(copilot_dist, backend_copilot_dir, "copilot assets") def build(): """Main build function with proper error handling""" print( "\n-- Building frontend, this might take a while!\n\n" " If you don't need to build the frontend and just want dependencies installed, use:\n" " `uv sync --no-install-project --no-editable`\n" ) try: # Find directory containing this file backend_dir = pathlib.Path(__file__).resolve().parent project_root = backend_dir.parent # Dirty hack to distinguish between building wheel from sdist and from source code if not (project_root / "package.json").exists(): return pnpm = shutil.which("pnpm") if not pnpm: raise BuildError("pnpm not found!") pnpm_install(project_root, pnpm) pnpm_buildui(project_root, pnpm) copy_frontend(project_root) copy_copilot(project_root) except KeyboardInterrupt: print("\nBuild interrupted by user") sys.exit(1) except BuildError as e: print(f"\nBuild failed: {e!s}") sys.exit(1) except Exception as e: print(f"\nUnexpected error: {e!s}") sys.exit(1) class CustomBuildHook(BuildHookInterface): def initialize(self, _, __): build() ================================================ FILE: backend/chainlit/__init__.py ================================================ import os from dotenv import load_dotenv # ruff: noqa: E402 # Keep this here to ensure imports have environment available. env_file = os.getenv("CHAINLIT_ENV_FILE", ".env") env_found = load_dotenv(dotenv_path=os.path.join(os.getcwd(), env_file)) from chainlit.logger import logger if env_found: logger.info(f"Loaded {env_file} file") import asyncio from typing import TYPE_CHECKING, Any, Dict from literalai import ChatGeneration, CompletionGeneration, GenerationMessage from pydantic.dataclasses import dataclass import chainlit.input_widget as input_widget from chainlit.action import Action from chainlit.cache import cache from chainlit.chat_context import chat_context from chainlit.chat_settings import ChatSettings from chainlit.context import context from chainlit.element import ( Audio, CustomElement, Dataframe, File, Image, Pdf, Plotly, Pyplot, Task, TaskList, TaskStatus, Text, Video, ) from chainlit.message import ( AskActionMessage, AskElementMessage, AskFileMessage, AskUserMessage, ErrorMessage, Message, ) from chainlit.mode import Mode, ModeOption from chainlit.sidebar import ElementSidebar from chainlit.step import Step, step from chainlit.sync import make_async, run_sync from chainlit.types import ( ChatProfile, InputAudioChunk, OutputAudioChunk, Starter, StarterCategory, ) from chainlit.user import PersistedUser, User from chainlit.user_session import user_session from chainlit.utils import make_module_getattr from chainlit.version import __version__ from .callbacks import ( action_callback, author_rename, data_layer, header_auth_callback, oauth_callback, on_app_shutdown, on_app_startup, on_audio_chunk, on_audio_end, on_audio_start, on_chat_end, on_chat_resume, on_chat_start, on_feedback, on_logout, on_mcp_connect, on_mcp_disconnect, on_message, on_settings_edit, on_settings_update, on_shared_thread_view, on_slack_reaction_added, on_stop, on_window_message, password_auth_callback, send_window_message, set_chat_profiles, set_starter_categories, set_starters, ) if TYPE_CHECKING: from chainlit.langchain.callbacks import ( AsyncLangchainCallbackHandler, LangchainCallbackHandler, ) from chainlit.llama_index.callbacks import LlamaIndexCallbackHandler from chainlit.mistralai import instrument_mistralai from chainlit.openai import instrument_openai from chainlit.semantic_kernel import SemanticKernelFilter def sleep(duration: int): """ Sleep for a given duration. Args: duration (int): The duration in seconds. """ return asyncio.sleep(duration) @dataclass() class CopilotFunction: name: str args: Dict[str, Any] def acall(self): return context.emitter.send_call_fn(self.name, self.args) __getattr__ = make_module_getattr( { "LangchainCallbackHandler": "chainlit.langchain.callbacks", "AsyncLangchainCallbackHandler": "chainlit.langchain.callbacks", "LlamaIndexCallbackHandler": "chainlit.llama_index.callbacks", "instrument_openai": "chainlit.openai", "instrument_mistralai": "chainlit.mistralai", "SemanticKernelFilter": "chainlit.semantic_kernel", "server": "chainlit.server", } ) __all__ = [ "Action", "AskActionMessage", "AskElementMessage", "AskFileMessage", "AskUserMessage", "AsyncLangchainCallbackHandler", "Audio", "ChatGeneration", "ChatProfile", "ChatSettings", "CompletionGeneration", "CopilotFunction", "CustomElement", "Dataframe", "ElementSidebar", "ErrorMessage", "File", "GenerationMessage", "Image", "InputAudioChunk", "LangchainCallbackHandler", "LlamaIndexCallbackHandler", "Message", "Mode", "ModeOption", "OutputAudioChunk", "Pdf", "PersistedUser", "Plotly", "Pyplot", "SemanticKernelFilter", "Starter", "StarterCategory", "Step", "Task", "TaskList", "TaskStatus", "Text", "User", "Video", "__version__", "action_callback", "author_rename", "cache", "chat_context", "context", "data_layer", "header_auth_callback", "input_widget", "instrument_mistralai", "instrument_openai", "make_async", "oauth_callback", "on_app_shutdown", "on_app_startup", "on_audio_chunk", "on_audio_end", "on_audio_start", "on_chat_end", "on_chat_resume", "on_chat_start", "on_feedback", "on_logout", "on_mcp_connect", "on_mcp_disconnect", "on_message", "on_settings_edit", "on_settings_update", "on_shared_thread_view", "on_slack_reaction_added", "on_stop", "on_window_message", "password_auth_callback", "run_sync", "send_window_message", "set_chat_profiles", "set_starter_categories", "set_starters", "sleep", "step", "user_session", ] def __dir__(): return __all__ ================================================ FILE: backend/chainlit/__main__.py ================================================ from chainlit.cli import cli if __name__ == "__main__": cli(prog_name="chainlit") ================================================ FILE: backend/chainlit/_utils.py ================================================ """Util functions which are explicitly not part of the public API.""" from pathlib import Path def is_path_inside(child_path: Path, parent_path: Path) -> bool: """Check if the child path is inside the parent path.""" return parent_path.resolve() in child_path.resolve().parents ================================================ FILE: backend/chainlit/action.py ================================================ import uuid from typing import Dict, Optional from dataclasses_json import DataClassJsonMixin from pydantic import Field from pydantic.dataclasses import dataclass from chainlit.context import context @dataclass class Action(DataClassJsonMixin): # Name of the action, this should be used in the action_callback name: str # The parameters to call this action with. payload: Dict # The label of the action. This is what the user will see. label: str = "" # The tooltip of the action button. This is what the user will see when they hover the action. tooltip: str = "" # The lucid icon name for this action. icon: Optional[str] = None # This should not be set manually, only used internally. forId: Optional[str] = None # The ID of the action id: str = Field(default_factory=lambda: str(uuid.uuid4())) async def send(self, for_id: str): self.forId = for_id await context.emitter.emit("action", self.to_dict()) async def remove(self): await context.emitter.emit("remove_action", self.to_dict()) ================================================ FILE: backend/chainlit/auth/__init__.py ================================================ import os from fastapi import Depends, HTTPException from chainlit.config import config from chainlit.data import get_data_layer from chainlit.logger import logger from chainlit.oauth_providers import get_configured_oauth_providers from .cookie import ( OAuth2PasswordBearerWithCookie, clear_auth_cookie, get_token_from_cookies, set_auth_cookie, ) from .jwt import create_jwt, decode_jwt, get_jwt_secret reuseable_oauth = OAuth2PasswordBearerWithCookie(tokenUrl="/login", auto_error=False) def ensure_jwt_secret(): if require_login() and get_jwt_secret() is None: raise ValueError( "You must provide a JWT secret in the environment to use authentication. Run `chainlit create-secret` to generate one." ) def is_oauth_enabled(): return config.code.oauth_callback and len(get_configured_oauth_providers()) > 0 def require_login(): return ( bool(os.environ.get("CHAINLIT_CUSTOM_AUTH")) or config.code.password_auth_callback is not None or config.code.header_auth_callback is not None or is_oauth_enabled() ) def get_configuration(): return { "requireLogin": require_login(), "passwordAuth": config.code.password_auth_callback is not None, "headerAuth": config.code.header_auth_callback is not None, "oauthProviders": ( get_configured_oauth_providers() if is_oauth_enabled() else [] ), "default_theme": config.ui.default_theme, "ui": { "login_page_image": config.ui.login_page_image, "login_page_image_filter": config.ui.login_page_image_filter, "login_page_image_dark_filter": config.ui.login_page_image_dark_filter, }, } async def authenticate_user(token: str = Depends(reuseable_oauth)): try: user = decode_jwt(token) except Exception as e: raise HTTPException( status_code=401, detail="Invalid authentication token" ) from e if data_layer := get_data_layer(): # Get or create persistent user if we've a data layer available. try: persisted_user = await data_layer.get_user(user.identifier) if persisted_user is None: persisted_user = await data_layer.create_user(user) assert persisted_user except Exception as e: logger.exception("Unable to get persisted_user from data layer: %s", e) return user if user and user.display_name: # Copy ephemeral display_name from authenticated user to persistent user. persisted_user.display_name = user.display_name return persisted_user return user async def get_current_user(token: str = Depends(reuseable_oauth)): if not require_login(): return None return await authenticate_user(token) __all__ = [ "clear_auth_cookie", "create_jwt", "get_configuration", "get_current_user", "get_token_from_cookies", "set_auth_cookie", ] ================================================ FILE: backend/chainlit/auth/cookie.py ================================================ import os from typing import Literal, Optional, cast from fastapi import Request, Response from fastapi.exceptions import HTTPException from fastapi.security.base import SecurityBase from fastapi.security.utils import get_authorization_scheme_param from starlette.status import HTTP_401_UNAUTHORIZED from chainlit.config import config """ Module level cookie settings. """ _cookie_samesite = cast( Literal["lax", "strict", "none"], os.environ.get("CHAINLIT_COOKIE_SAMESITE", "lax"), ) assert _cookie_samesite in [ "lax", "strict", "none", ], ( "Invalid value for CHAINLIT_COOKIE_SAMESITE. Must be one of 'lax', 'strict' or 'none'." ) _cookie_secure = _cookie_samesite == "none" if _cookie_root_path := os.environ.get("CHAINLIT_ROOT_PATH", None): _cookie_path = os.environ.get(_cookie_root_path, "/") else: _cookie_path = os.environ.get("CHAINLIT_AUTH_COOKIE_PATH", "/") _state_cookie_lifetime = int( os.environ.get("CHAINLIT_STATE_COOKIE_LIFETIME", str(3 * 60)) ) _auth_cookie_name = os.environ.get("CHAINLIT_AUTH_COOKIE_NAME", "access_token") _state_cookie_name = "oauth_state" class OAuth2PasswordBearerWithCookie(SecurityBase): """ OAuth2 password flow with cookie support with fallback to bearer token. """ def __init__( self, tokenUrl: str, scheme_name: Optional[str] = None, auto_error: bool = True, ): self.tokenUrl = tokenUrl self.scheme_name = scheme_name or self.__class__.__name__ self.auto_error = auto_error async def __call__(self, request: Request) -> Optional[str]: # First try to get the token from the cookie token = get_token_from_cookies(request.cookies) # If no cookie, try the Authorization header as fallback if not token: # TODO: Only bother to check if cookie auth is explicitly disabled. authorization = request.headers.get("Authorization") if authorization: scheme, token = get_authorization_scheme_param(authorization) if scheme.lower() != "bearer": if self.auto_error: raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, detail="Invalid authentication credentials", headers={"WWW-Authenticate": "Bearer"}, ) else: return None else: if self.auto_error: raise HTTPException( status_code=HTTP_401_UNAUTHORIZED, detail="Not authenticated", headers={"WWW-Authenticate": "Bearer"}, ) else: return None return token def _get_chunked_cookie(cookies: dict[str, str], name: str) -> Optional[str]: # Gather all auth_chunk_i cookies, sorted by their index chunk_parts = [] i = 0 while True: cookie_key = f"{_auth_cookie_name}_{i}" if cookie_key not in cookies: break chunk_parts.append(cookies[cookie_key]) i += 1 joined = "".join(chunk_parts) return joined if joined != "" else None def get_token_from_cookies(cookies: dict[str, str]) -> Optional[str]: """ Read all chunk cookies and reconstruct the token """ # Default/unchunked cookies if value := cookies.get(_auth_cookie_name): return value return _get_chunked_cookie(cookies, _auth_cookie_name) def set_auth_cookie(request: Request, response: Response, token: str): """ Helper function to set the authentication cookie with secure parameters and remove any leftover chunks from a previously larger token. """ _chunk_size = 3000 existing_cookies = { k for k in request.cookies.keys() if k.startswith(_auth_cookie_name) } if len(token) > _chunk_size: chunks = [token[i : i + _chunk_size] for i in range(0, len(token), _chunk_size)] for i, chunk in enumerate(chunks): k = f"{_auth_cookie_name}_{i}" response.set_cookie( key=k, value=chunk, httponly=True, secure=_cookie_secure, samesite=_cookie_samesite, max_age=config.project.user_session_timeout, ) existing_cookies.discard(k) else: # Default (shorter cookies) response.set_cookie( key=_auth_cookie_name, value=token, httponly=True, secure=_cookie_secure, samesite=_cookie_samesite, max_age=config.project.user_session_timeout, ) existing_cookies.discard(_auth_cookie_name) # Delete remaining prior cookies/cookie chunks for k in existing_cookies: response.delete_cookie( key=k, path=_cookie_path, secure=_cookie_secure, samesite=_cookie_samesite ) def clear_auth_cookie(request: Request, response: Response): """ Helper function to clear the authentication cookie """ existing_cookies = { k for k in request.cookies.keys() if k.startswith(_auth_cookie_name) } for k in existing_cookies: response.delete_cookie( key=k, path=_cookie_path, secure=_cookie_secure, samesite=_cookie_samesite ) def set_oauth_state_cookie(response: Response, token: str): response.set_cookie( _state_cookie_name, token, httponly=True, samesite=_cookie_samesite, secure=_cookie_secure, max_age=_state_cookie_lifetime, ) def validate_oauth_state_cookie(request: Request, state: str): """Check the state from the oauth provider against the browser cookie.""" oauth_state = request.cookies.get(_state_cookie_name) if oauth_state != state: raise Exception("oauth state does not correspond") def clear_oauth_state_cookie(response: Response): """Oauth complete, delete state token.""" response.delete_cookie(_state_cookie_name) # Do we set path here? ================================================ FILE: backend/chainlit/auth/jwt.py ================================================ import os from datetime import datetime, timedelta, timezone from typing import Any, Dict, Optional import jwt as pyjwt from chainlit.config import config from chainlit.user import User def get_jwt_secret() -> Optional[str]: return os.environ.get("CHAINLIT_AUTH_SECRET") def create_jwt(data: User) -> str: to_encode: Dict[str, Any] = data.to_dict() to_encode.update( { "exp": datetime.now(timezone.utc) + timedelta(seconds=config.project.user_session_timeout), "iat": datetime.now(timezone.utc), # Add issued at time } ) secret = get_jwt_secret() assert secret encoded_jwt = pyjwt.encode(to_encode, secret, algorithm="HS256") return encoded_jwt def decode_jwt(token: str) -> User: secret = get_jwt_secret() assert secret dict = pyjwt.decode( token, secret, algorithms=["HS256"], options={"verify_signature": True}, ) del dict["exp"] return User(**dict) ================================================ FILE: backend/chainlit/cache.py ================================================ import importlib.util import os import threading from typing import Any from chainlit.config import config from chainlit.logger import logger def init_lc_cache(): use_cache = config.project.cache is True and config.run.no_cache is False if use_cache and importlib.util.find_spec("langchain") is not None: from langchain.cache import SQLiteCache from langchain.globals import set_llm_cache if config.project.lc_cache_path is not None: set_llm_cache(SQLiteCache(database_path=config.project.lc_cache_path)) if not os.path.exists(config.project.lc_cache_path): logger.info( f"LangChain cache created at: {config.project.lc_cache_path}" ) _cache: dict[tuple, Any] = {} _cache_lock = threading.Lock() def cache(func): def wrapper(*args, **kwargs): # Create a cache key based on the function name, arguments, and keyword arguments cache_key = ( (func.__name__,) + args + tuple((k, v) for k, v in sorted(kwargs.items())) ) with _cache_lock: # Check if the result is already in the cache if cache_key not in _cache: # If not, call the function and store the result in the cache _cache[cache_key] = func(*args, **kwargs) return _cache[cache_key] return wrapper ================================================ FILE: backend/chainlit/callbacks.py ================================================ import inspect from typing import Any, Awaitable, Callable, Dict, List, Optional, Union, overload from fastapi import Request, Response from mcp import ClientSession from starlette.datastructures import Headers from chainlit.action import Action from chainlit.config import config from chainlit.context import context from chainlit.data.base import BaseDataLayer from chainlit.mcp import McpConnection from chainlit.message import Message from chainlit.oauth_providers import get_configured_oauth_providers from chainlit.step import Step, step from chainlit.types import ChatProfile, Starter, StarterCategory, ThreadDict from chainlit.user import User from chainlit.utils import wrap_user_function def on_app_startup(func: Callable[[], Union[None, Awaitable[None]]]) -> Callable: """ Hook to run code when the Chainlit application starts. Useful for initializing resources, loading models, setting up database connections, etc. The function can be synchronous or asynchronous. Args: func (Callable[[], Union[None, Awaitable[None]]]): The startup hook to execute. Takes no arguments. Example: @cl.on_app_startup async def startup(): print("Application is starting!") # Initialize resources here Returns: Callable[[], Union[None, Awaitable[None]]]: The decorated startup hook. """ config.code.on_app_startup = wrap_user_function(func, with_task=False) return func def on_app_shutdown(func: Callable[[], Union[None, Awaitable[None]]]) -> Callable: """ Hook to run code when the Chainlit application shuts down. Useful for cleaning up resources, closing connections, saving state, etc. The function can be synchronous or asynchronous. Args: func (Callable[[], Union[None, Awaitable[None]]]): The shutdown hook to execute. Takes no arguments. Example: @cl.on_app_shutdown async def shutdown(): print("Application is shutting down!") # Clean up resources here Returns: Callable[[], Union[None, Awaitable[None]]]: The decorated shutdown hook. """ config.code.on_app_shutdown = wrap_user_function(func, with_task=False) return func def password_auth_callback( func: Callable[[str, str], Awaitable[Optional[User]]], ) -> Callable: """ Framework agnostic decorator to authenticate the user. Args: func (Callable[[str, str], Awaitable[Optional[User]]]): The authentication callback to execute. Takes the email and password as parameters. Example: @cl.password_auth_callback async def password_auth_callback(username: str, password: str) -> Optional[User]: Returns: Callable[[str, str], Awaitable[Optional[User]]]: The decorated authentication callback. """ config.code.password_auth_callback = wrap_user_function(func) return func def header_auth_callback( func: Callable[[Headers], Awaitable[Optional[User]]], ) -> Callable: """ Framework agnostic decorator to authenticate the user via a header Args: func (Callable[[Headers], Awaitable[Optional[User]]]): The authentication callback to execute. Example: @cl.header_auth_callback async def header_auth_callback(headers: Headers) -> Optional[User]: Returns: Callable[[Headers], Awaitable[Optional[User]]]: The decorated authentication callback. """ config.code.header_auth_callback = wrap_user_function(func) return func def oauth_callback( func: Callable[ [str, str, Dict[str, str], User, Optional[str]], Awaitable[Optional[User]] ], ) -> Callable: """ Framework agnostic decorator to authenticate the user via oauth Args: func (Callable[[str, str, Dict[str, str], User, Optional[str]], Awaitable[Optional[User]]]): The authentication callback to execute. Example: @cl.oauth_callback async def oauth_callback(provider_id: str, token: str, raw_user_data: Dict[str, str], default_app_user: User, id_token: Optional[str]) -> Optional[User]: Returns: Callable[[str, str, Dict[str, str], User, Optional[str]], Awaitable[Optional[User]]]: The decorated authentication callback. """ if len(get_configured_oauth_providers()) == 0: raise ValueError( "You must set the environment variable for at least one oauth provider to use oauth authentication." ) config.code.oauth_callback = wrap_user_function(func) return func def on_logout(func: Callable[[Request, Response], Any]) -> Callable: """ Function called when the user logs out. Takes the FastAPI request and response as parameters. """ config.code.on_logout = wrap_user_function(func) return func def on_message(func: Callable) -> Callable: """ Framework agnostic decorator to react to messages coming from the UI. The decorated function is called every time a new message is received. Args: func (Callable[[Message], Any]): The function to be called when a new message is received. Takes a cl.Message. Returns: Callable[[str], Any]: The decorated on_message function. """ async def with_parent_id(message: Message): async with Step(name="on_message", type="run", parent_id=message.id) as s: s.input = message.content if len(inspect.signature(func).parameters) > 0: await func(message) else: await func() config.code.on_message = wrap_user_function(with_parent_id) return func async def send_window_message(data: Any): """ Send custom data to the host window via a window.postMessage event. Args: data (Any): The data to send with the event. """ await context.emitter.send_window_message(data) def on_window_message(func: Callable[[str], Any]) -> Callable: """ Hook to react to javascript postMessage events coming from the UI. Args: func (Callable[[str], Any]): The function to be called when a window message is received. Takes the message content as a string parameter. Returns: Callable[[str], Any]: The decorated on_window_message function. """ config.code.on_window_message = wrap_user_function(func) return func def on_chat_start(func: Callable) -> Callable: """ Hook to react to the user websocket connection event. Args: func (Callable[], Any]): The connection hook to execute. Returns: Callable[], Any]: The decorated hook. """ config.code.on_chat_start = wrap_user_function( step(func, name="on_chat_start", type="run"), with_task=True ) return func def on_chat_resume(func: Callable[[ThreadDict], Any]) -> Callable: """ Hook to react to resume websocket connection event. Args: func (Callable[], Any]): The connection hook to execute. Returns: Callable[], Any]: The decorated hook. """ config.code.on_chat_resume = wrap_user_function(func, with_task=True) return func @overload def set_chat_profiles( func: Callable[[Optional["User"]], Awaitable[List["ChatProfile"]]], ) -> Callable[[Optional["User"]], Awaitable[List["ChatProfile"]]]: ... @overload def set_chat_profiles( func: Callable[[Optional["User"], Optional["str"]], Awaitable[List["ChatProfile"]]], ) -> Callable[[Optional["User"], Optional["str"]], Awaitable[List["ChatProfile"]]]: ... def set_chat_profiles(func): """ Programmatic declaration of the available chat profiles (can depend on the User from the session if authentication is setup). Args: func (Callable[[Optional["User"]], Awaitable[List["ChatProfile"]]]): The function declaring the chat profiles. Returns: Callable[[Optional["User"]], Awaitable[List["ChatProfile"]]]: The decorated function. """ config.code.set_chat_profiles = wrap_user_function(func) return func @overload def set_starters( func: Callable[[Optional["User"]], Awaitable[List["Starter"]]], ) -> Callable[[Optional["User"]], Awaitable[List["Starter"]]]: ... @overload def set_starters( func: Callable[[Optional["User"], Optional["str"]], Awaitable[List["Starter"]]], ) -> Callable[[Optional["User"], Optional["str"]], Awaitable[List["Starter"]]]: ... def set_starters(func): """ Programmatic declaration of the available starter (can depend on the User from the session if authentication is setup). Args: func (Callable[[Optional["User"], Optional["str"]], Awaitable[List["Starter"]]]): The function declaring the starters with optional user and language arguments. Returns: Callable[[Optional["User"], Optional["str"]], Awaitable[List["Starter"]]]: The decorated function. """ config.code.set_starters = wrap_user_function(func) return func @overload def set_starter_categories( func: Callable[[Optional["User"]], Awaitable[List["StarterCategory"]]], ) -> Callable[[Optional["User"]], Awaitable[List["StarterCategory"]]]: ... @overload def set_starter_categories( func: Callable[ [Optional["User"], Optional["str"]], Awaitable[List["StarterCategory"]] ], ) -> Callable[ [Optional["User"], Optional["str"]], Awaitable[List["StarterCategory"]] ]: ... def set_starter_categories(func): """ Programmatic declaration of starter categories with grouped starters. Args: func (Callable[[Optional["User"], Optional["str"]], Awaitable[List["StarterCategory"]]]): The function declaring the starter categories with optional user and language arguments. Returns: Callable[[Optional["User"], Optional["str"]], Awaitable[List["StarterCategory"]]]: The decorated function. """ config.code.set_starter_categories = wrap_user_function(func) return func def on_chat_end(func: Callable) -> Callable: """ Hook to react to the user websocket disconnect event. Args: func (Callable[], Any]): The disconnect hook to execute. Returns: Callable[], Any]: The decorated hook. """ config.code.on_chat_end = wrap_user_function(func, with_task=True) return func def on_audio_start(func: Callable) -> Callable: """ Hook to react to the user initiating audio. Returns: Callable[], Any]: The decorated hook. """ config.code.on_audio_start = wrap_user_function(func, with_task=False) return func def on_audio_chunk(func: Callable) -> Callable: """ Hook to react to the audio chunks being sent. Args: chunk (InputAudioChunk): The audio chunk being sent. Returns: Callable[], Any]: The decorated hook. """ config.code.on_audio_chunk = wrap_user_function(func, with_task=False) return func def on_audio_end(func: Callable) -> Callable: """ Hook to react to the audio stream ending. This is called after the last audio chunk is sent. Returns: Callable[], Any]: The decorated hook. """ config.code.on_audio_end = wrap_user_function( step(func, name="on_audio_end", type="run"), with_task=True ) return func def author_rename( func: Callable[[str], Awaitable[str]], ) -> Callable[[str], Awaitable[str]]: """ Useful to rename the author of message to display more friendly author names in the UI. Args: func (Callable[[str], Awaitable[str]]): The function to be called to rename an author. Takes the original author name as parameter. Returns: Callable[[Any, str], Awaitable[Any]]: The decorated function. """ config.code.author_rename = wrap_user_function(func) return func def on_mcp_connect( func: Callable[[McpConnection, ClientSession], Awaitable[None]], ) -> Callable[[McpConnection, ClientSession], Awaitable[None]]: """ Called everytime an MCP is connected """ config.code.on_mcp_connect = wrap_user_function(func) return func def on_mcp_disconnect( func: Callable[[str, ClientSession], Awaitable[None]], ) -> Callable[[str, ClientSession], Awaitable[None]]: """ Called everytime an MCP is disconnected """ config.code.on_mcp_disconnect = wrap_user_function(func) return func def on_stop(func: Callable) -> Callable: """ Hook to react to the user stopping a thread. Args: func (Callable[[], Any]): The stop hook to execute. Returns: Callable[[], Any]: The decorated stop hook. """ config.code.on_stop = wrap_user_function(func) return func def action_callback(name: str) -> Callable: """ Callback to call when an action is clicked in the UI. Args: func (Callable[[Action], Any]): The action callback to execute. First parameter is the action. """ def decorator(func: Callable[[Action], Any]): config.code.action_callbacks[name] = wrap_user_function(func, with_task=False) return func return decorator def on_settings_update( func: Callable[[Dict[str, Any]], Any], ) -> Callable[[Dict[str, Any]], Any]: """ Hook to react to the user changing any settings. Args: func (Callable[], Any]): The hook to execute after settings were changed. Returns: Callable[], Any]: The decorated hook. """ config.code.on_settings_update = wrap_user_function(func, with_task=True) return func def on_settings_edit( func: Callable[[Dict[str, Any]], Any], ) -> Callable[[Dict[str, Any]], Any]: """ Hook to react to the user editing any settings (on the fly). Args: func (Callable[], Any]): The hook to execute while settings are being edited. Returns: Callable[], Any]: The decorated hook. """ config.code.on_settings_edit = wrap_user_function(func, with_task=True) return func def data_layer( func: Callable[[], BaseDataLayer], ) -> Callable[[], BaseDataLayer]: """ Hook to configure custom data layer. """ # We don't use wrap_user_function here because: # 1. We don't need to support async here and; # 2. We don't want to change the API for get_data_layer() to be async, everywhere (at this point). config.code.data_layer = func return func def on_feedback(func: Callable) -> Callable: """ Hook to react to user feedback events from the UI. The decorated function is called every time feedback is received. Args: func (Callable[[Feedback], Any]): The function to be called when feedback is received. Takes a cl.Feedback object. Example: @cl.on_feedback async def on_feedback(feedback: Feedback): print(f"Received feedback: {feedback.value} for step {feedback.forId}") # Handle feedback here Returns: Callable[[Feedback], Any]: The decorated on_feedback function. """ config.code.on_feedback = wrap_user_function(func) return func def on_slack_reaction_added(func: Callable[[Dict[str, Any]], Any]) -> Callable: """ Hook to react to Slack reaction_added events. The decorated function is called every time a user adds a reaction to a message in Slack. Args: func (Callable[[Dict[str, Any]], Any]): The function to be called when a reaction is added. Takes a Slack event dictionary containing: - reaction: The emoji reaction name (e.g., "thumbsup") - user: The user ID who added the reaction - item: Dictionary with type, ts, and channel of the reacted item Example: @cl.on_slack_reaction_added async def handle_reaction(event: Dict[str, Any]): reaction = event.get("reaction") user_id = event.get("user") print(f"User {user_id} added reaction {reaction}") # Handle reaction here Returns: Callable[[Dict[str, Any]], Any]: The decorated on_slack_reaction_added function. """ config.code.on_slack_reaction_added = wrap_user_function(func) return func def on_shared_thread_view( func: Callable[[ThreadDict, Optional[User]], Awaitable[bool]], ) -> Callable[[ThreadDict, Optional[User]], Awaitable[bool]]: """Hook to authorize viewing a shared thread. Users must implement and return True to allow a non-author to view a thread. Thread metadata contains "is_shared" boolean flag and "shared_at" timestamp for custom thread sharing. Signature: async (thread: ThreadDict, viewer: Optional[User]) -> bool """ config.code.on_shared_thread_view = wrap_user_function(func) return func ================================================ FILE: backend/chainlit/chat_context.py ================================================ from typing import TYPE_CHECKING, Dict, List from chainlit.context import context if TYPE_CHECKING: from chainlit.message import Message chat_contexts: Dict[str, List["Message"]] = {} class ChatContext: def get(self) -> List["Message"]: if not context.session: return [] if context.session.id not in chat_contexts: # Create a new chat context chat_contexts[context.session.id] = [] return chat_contexts[context.session.id].copy() def add(self, message: "Message"): if not context.session: return if context.session.id not in chat_contexts: chat_contexts[context.session.id] = [] if message not in chat_contexts[context.session.id]: chat_contexts[context.session.id].append(message) return message def remove(self, message: "Message") -> bool: if not context.session: return False if context.session.id not in chat_contexts: return False if message in chat_contexts[context.session.id]: chat_contexts[context.session.id].remove(message) return True return False def clear(self) -> None: if context.session and context.session.id in chat_contexts: chat_contexts[context.session.id] = [] def to_openai(self): messages = [] for message in self.get(): if message.type == "assistant_message": messages.append({"role": "assistant", "content": message.content}) elif message.type == "user_message": messages.append({"role": "user", "content": message.content}) else: messages.append({"role": "system", "content": message.content}) return messages chat_context = ChatContext() ================================================ FILE: backend/chainlit/chat_settings.py ================================================ from typing import Any, List from pydantic import Field from pydantic.dataclasses import dataclass from chainlit.context import context from chainlit.input_widget import InputWidget, Tab @dataclass class ChatSettings: """Useful to create chat settings that the user can change.""" inputs: List[InputWidget] | List[Tab] = Field(default_factory=list, exclude=True) def __init__( self, inputs: List[InputWidget] | List[Tab], ) -> None: self.inputs = inputs def settings(self): def collect_settings( values: dict[str, Any], inputs: List[InputWidget] | List[Tab] ) -> None: for input in inputs: if isinstance(input, Tab): collect_settings(values, input.inputs) else: values[input.id] = input.initial settings: dict[str, Any] = {} collect_settings(settings, self.inputs) return settings async def send(self): settings = self.settings() context.emitter.set_chat_settings(settings) inputs_content = [input_widget.to_dict() for input_widget in self.inputs] await context.emitter.emit("chat_settings", inputs_content) return settings ================================================ FILE: backend/chainlit/cli/__init__.py ================================================ import asyncio import logging import os import sys import click import nest_asyncio import uvicorn # Not sure if it is necessary to call nest_asyncio.apply() before the other imports nest_asyncio.apply() # ruff: noqa: E402 from chainlit.auth import ensure_jwt_secret from chainlit.cache import init_lc_cache from chainlit.config import ( BACKEND_ROOT, DEFAULT_HOST, DEFAULT_PORT, DEFAULT_ROOT_PATH, config, init_config, lint_translations, load_module, ) from chainlit.logger import logger from chainlit.markdown import init_markdown from chainlit.secret import random_secret from chainlit.utils import check_file logging.basicConfig( level=logging.INFO, stream=sys.stdout, format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S", ) def assert_app(): if ( not config.code.on_chat_start and not config.code.on_message and not config.code.on_audio_chunk ): raise Exception( "You need to configure at least one of on_chat_start, on_message or on_audio_chunk callback" ) # Create the main command group for Chainlit CLI @click.group(context_settings={"auto_envvar_prefix": "CHAINLIT"}) @click.version_option(prog_name="Chainlit") def cli(): return # Define the function to run Chainlit with provided options def run_chainlit(target: str): host = os.environ.get("CHAINLIT_HOST", DEFAULT_HOST) port = int(os.environ.get("CHAINLIT_PORT", DEFAULT_PORT)) root_path = os.environ.get("CHAINLIT_ROOT_PATH", DEFAULT_ROOT_PATH) ssl_certfile = os.environ.get("CHAINLIT_SSL_CERT", None) ssl_keyfile = os.environ.get("CHAINLIT_SSL_KEY", None) ws_per_message_deflate_env = os.environ.get( "UVICORN_WS_PER_MESSAGE_DEFLATE", "true" ) ws_per_message_deflate = ws_per_message_deflate_env.lower() in [ "true", "1", "yes", ] # Convert to boolean ws_protocol = os.environ.get("UVICORN_WS_PROTOCOL", "auto") config.run.host = host config.run.port = port config.run.root_path = root_path from chainlit.server import app check_file(target) # Load the module provided by the user config.run.module_name = target load_module(config.run.module_name) ensure_jwt_secret() assert_app() # Create the chainlit.md file if it doesn't exist init_markdown(config.root) # Initialize the LangChain cache if installed and enabled init_lc_cache() log_level = "debug" if config.run.debug else "error" # Start the server async def start(): config = uvicorn.Config( app, host=host, port=port, ws=ws_protocol, log_level=log_level, ws_per_message_deflate=ws_per_message_deflate, ssl_keyfile=ssl_keyfile, ssl_certfile=ssl_certfile, ) server = uvicorn.Server(config) await server.serve() # Run the asyncio event loop instead of uvloop to enable re entrance asyncio.run(start()) # uvicorn.run(app, host=host, port=port, log_level=log_level) # Define the "run" command for Chainlit CLI @cli.command("run") @click.argument("target", required=True, envvar="RUN_TARGET") @click.option( "-w", "--watch", default=False, is_flag=True, envvar="WATCH", help="Reload the app when the module changes", ) @click.option( "-h", "--headless", default=False, is_flag=True, envvar="HEADLESS", help="Will prevent to auto open the app in the browser", ) @click.option( "-d", "--debug", default=False, is_flag=True, envvar="DEBUG", help="Set the log level to debug", ) @click.option( "-c", "--ci", default=False, is_flag=True, envvar="CI", help="Flag to run in CI mode", ) @click.option( "--no-cache", default=False, is_flag=True, envvar="NO_CACHE", help="Useful to disable third parties cache, such as langchain.", ) @click.option( "--ssl-cert", default=None, envvar="CHAINLIT_SSL_CERT", help="Specify the file path for the SSL certificate.", ) @click.option( "--ssl-key", default=None, envvar="CHAINLIT_SSL_KEY", help="Specify the file path for the SSL key", ) @click.option("--host", help="Specify a different host to run the server on") @click.option("--port", help="Specify a different port to run the server on") @click.option("--root-path", help="Specify a different root path to run the server on") def chainlit_run( target, watch, headless, debug, ci, no_cache, ssl_cert, ssl_key, host, port, root_path, ): if host: os.environ["CHAINLIT_HOST"] = host if port: os.environ["CHAINLIT_PORT"] = port if bool(ssl_cert) != bool(ssl_key): raise click.UsageError( "Both --ssl-cert and --ssl-key must be provided together." ) if ssl_cert: os.environ["CHAINLIT_SSL_CERT"] = ssl_cert os.environ["CHAINLIT_SSL_KEY"] = ssl_key if root_path: os.environ["CHAINLIT_ROOT_PATH"] = root_path if ci: logger.info("Running in CI mode") no_cache = True # This is required to have OpenAI LLM providers available for the CI run os.environ["OPENAI_API_KEY"] = "sk-FAKE-OPENAI-API-KEY" config.run.headless = headless config.run.debug = debug config.run.no_cache = no_cache config.run.ci = ci config.run.watch = watch config.run.ssl_cert = ssl_cert config.run.ssl_key = ssl_key run_chainlit(target) @cli.command("hello") @click.argument("args", nargs=-1) def chainlit_hello(args=None, **kwargs): hello_path = os.path.join(BACKEND_ROOT, "sample", "hello.py") run_chainlit(hello_path) @cli.command("init") @click.argument("args", nargs=-1) def chainlit_init(args=None, **kwargs): init_config(log=True) @cli.command("create-secret") @click.argument("args", nargs=-1) def chainlit_create_secret(args=None, **kwargs): print( f'Copy the following secret into your .env file. Once it is set, changing it will logout all users with active sessions.\nCHAINLIT_AUTH_SECRET="{random_secret()}"' ) @cli.command("lint-translations") @click.argument("args", nargs=-1) def chainlit_lint_translations(args=None, **kwargs): lint_translations() ================================================ FILE: backend/chainlit/config.py ================================================ import json import os import site import sys from importlib import util from pathlib import Path from typing import ( TYPE_CHECKING, Any, Awaitable, Callable, Dict, List, Literal, Optional, Union, ) import tomli from pydantic import BaseModel, Field from pydantic_settings import BaseSettings from starlette.datastructures import Headers from chainlit.data.base import BaseDataLayer from chainlit.logger import logger from chainlit.translations import lint_translation_json from chainlit.version import __version__ from ._utils import is_path_inside if TYPE_CHECKING: from fastapi import Request, Response from chainlit.action import Action from chainlit.message import Message from chainlit.types import ( ChatProfile, Feedback, InputAudioChunk, Starter, StarterCategory, ThreadDict, ) from chainlit.user import User else: # Pydantic needs to resolve forward annotations. Because all of these are used # within `typing.Callable`, alias to `Any` as Pydantic does not perform validation # of callable argument/return types anyway. Request = Response = Action = Message = ChatProfile = InputAudioChunk = Starter = StarterCategory = ThreadDict = User = Feedback = Any # fmt: off BACKEND_ROOT = os.path.dirname(__file__) PACKAGE_ROOT = os.path.dirname(os.path.dirname(BACKEND_ROOT)) TRANSLATIONS_DIR = os.path.join(BACKEND_ROOT, "translations") # Get the directory the script is running from APP_ROOT = os.getenv("CHAINLIT_APP_ROOT", os.getcwd()) # Create the directory to store the uploaded files FILES_DIRECTORY = Path(APP_ROOT) / ".files" FILES_DIRECTORY.mkdir(exist_ok=True) config_dir = os.path.join(APP_ROOT, ".chainlit") public_dir = os.path.join(APP_ROOT, "public") config_file = os.path.join(config_dir, "config.toml") config_translation_dir = os.path.join(config_dir, "translations") # Default config file created if none exists DEFAULT_CONFIG_STR = f"""[project] # 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 # Duration (in seconds) of the user session expiry user_session_timeout = 1296000 # 15 days # Enable third parties caching (e.g., LangChain cache) cache = false # Whether to persist user environment variables (API keys) to the database # Set to true to store user env vars in DB, false to exclude them for security persist_user_env = false # Whether to mask user environment variables (API keys) in the UI with password type # Set to true to show API keys as ***, false to show them as plain text mask_user_env = false # Authorized origins allow_origins = ["*"] [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 # Enable rendering of user messages markdown user_message_markdown = true # Autoscroll new user messages at the top of the window user_message_autoscroll = true # Autoscroll new assistant messages assistant_message_autoscroll = true # 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 # Allow users to share threads (backend + UI). Requires an app-defined on_shared_thread_view callback. allow_thread_sharing = false # Enable favorite messages favorites = false [features.slack] # Add emoji reaction when message is received (requires reactions:write OAuth scope) reaction_on_message_received = false # Authorize users to spontaneously upload files with messages [features.spontaneous_file_upload] enabled = true # Define accepted file types using MIME types # Examples: # 1. For specific file types: # accept = ["image/jpeg", "image/png", "application/pdf"] # 2. For all files of certain type: # accept = ["image/*", "audio/*", "video/*"] # 3. For specific file extensions: # accept = {{ "application/octet-stream" = [".xyz", ".pdb"] }} # Note: Using "*/*" is not recommended as it may cause browser warnings accept = ["*/*"] max_files = 20 max_size_mb = 500 [features.audio] # Enable audio features enabled = false # Sample rate of the audio sample_rate = 24000 [features.mcp] # Enable Model Context Protocol (MCP) features enabled = false [features.mcp.sse] enabled = true [features.mcp.streamable-http] enabled = true [features.mcp.stdio] enabled = true # Only the executables in the allow list can be used for MCP stdio server. # Only need the base name of the executable, e.g. "npx", not "/usr/bin/npx". # Please don't comment this line for now, we need it to parse the executable name. allowed_executables = [ "npx", "uvx" ] [UI] # Name of the assistant. name = "Assistant" # default_theme = "dark" # Force a specific language for all users (e.g., "en-US", "he-IL", "fr-FR") # If not set, the browser's language will be used # language = "en-US" # layout = "wide" # default_sidebar_state = "open" # Options: "open", "closed", "hidden" # Chat settings display location: "message_composer" (default) or "sidebar" (header) # chat_settings_location = "message_composer" # Default state of chat settings sidebar when location is "sidebar" # default_chat_settings_open = false # Whether to prompt user confirmation on clicking 'New Chat' confirm_new_chat = true # Description of the assistant. This is used for HTML tags. # description = "" # Chain of Thought (CoT) display mode. Can be "hidden", "tool_call" or "full". cot = "full" # 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 additional attributes for a custom CSS file # custom_css_attributes = "media=\\\"print\\\"" # 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" # The style of alert boxes. Can be "classic" or "modern". alert_style = "classic" # Specify additional attributes for custom JS file # custom_js_attributes = "async type = \\\"module\\\"" # Custom login page image, relative to public directory or external URL # login_page_image = "/public/custom-background.jpg" # Custom login page image filter (Tailwind internal filters, no dark/light variants) # login_page_image_filter = "brightness-50 grayscale" # login_page_image_dark_filter = "contrast-200 blur-sm" # Specify a custom meta URL (used for meta tags like og:url) # custom_meta_url = "https://github.com/Chainlit/chainlit" # Specify a custom meta image url. # custom_meta_image_url = "https://chainlit-cloud.s3.eu-west-3.amazonaws.com/logo/chainlit_banner.png" # Load assistant logo directly from URL. logo_file_url = "" # Load assistant avatar image directly from URL. default_avatar_file_url = "" # Avatar size in pixels (default: 20). # avatar_size = 20 # 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" # Specify optional one or more custom links in the header. # [[UI.header_links]] # name = "Issues" # display_name = "Report Issue" # icon_url = "https://avatars.githubusercontent.com/u/128686189?s=200&v=4" # url = "https://github.com/Chainlit/chainlit/issues" # target = "_blank" (default) # Optional: "_self", "_parent", "_top". [meta] generated_by = "{__version__}" """ DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8000 DEFAULT_ROOT_PATH = "" class RunSettings(BaseModel): # Name of the module (python file) used in the run command module_name: Optional[str] = None host: str = DEFAULT_HOST port: int = DEFAULT_PORT ssl_cert: Optional[str] = None ssl_key: Optional[str] = None root_path: str = DEFAULT_ROOT_PATH headless: bool = False watch: bool = False no_cache: bool = False debug: bool = False ci: bool = False class PaletteOptions(BaseModel): main: Optional[str] = "" light: Optional[str] = "" dark: Optional[str] = "" class TextOptions(BaseModel): primary: Optional[str] = "" secondary: Optional[str] = "" class Palette(BaseModel): primary: Optional[PaletteOptions] = None background: Optional[str] = "" paper: Optional[str] = "" text: Optional[TextOptions] = None class SpontaneousFileUploadFeature(BaseModel): enabled: Optional[bool] = None accept: Optional[Union[List[str], Dict[str, List[str]]]] = None max_files: Optional[int] = None max_size_mb: Optional[int] = None class AudioFeature(BaseModel): sample_rate: int = 24000 enabled: bool = False class McpSseFeature(BaseModel): enabled: bool = True class McpStreamableHttpFeature(BaseModel): enabled: bool = True class McpStdioFeature(BaseModel): enabled: bool = True allowed_executables: Optional[list[str]] = None class SlackFeature(BaseModel): reaction_on_message_received: bool = False class McpFeature(BaseModel): enabled: bool = False sse: McpSseFeature = Field(default_factory=McpSseFeature) streamable_http: McpStreamableHttpFeature = Field( default_factory=McpStreamableHttpFeature ) stdio: McpStdioFeature = Field(default_factory=McpStdioFeature) class FeaturesSettings(BaseModel): spontaneous_file_upload: Optional[SpontaneousFileUploadFeature] = None audio: Optional[AudioFeature] = Field(default_factory=AudioFeature) mcp: McpFeature = Field(default_factory=McpFeature) slack: SlackFeature = Field(default_factory=SlackFeature) latex: bool = False user_message_markdown: bool = True user_message_autoscroll: bool = True assistant_message_autoscroll: bool = True unsafe_allow_html: bool = False auto_tag_thread: bool = True edit_message: bool = True allow_thread_sharing: bool = False favorites: bool = False class HeaderLink(BaseModel): name: str icon_url: str url: str display_name: Optional[str] = None target: Optional[Literal["_blank", "_self", "_parent", "_top"]] = None class UISettings(BaseModel): name: str description: str = "" cot: Literal["hidden", "tool_call", "full"] = "full" default_theme: Optional[Literal["light", "dark"]] = "dark" language: Optional[str] = None layout: Optional[Literal["default", "wide"]] = "default" default_sidebar_state: Optional[Literal["open", "closed", "hidden"]] = "open" chat_settings_location: Optional[Literal["message_composer", "sidebar"]] = ( "message_composer" ) default_chat_settings_open: bool = False confirm_new_chat: bool = True github: Optional[str] = None custom_css: Optional[str] = None custom_css_attributes: Optional[str] = "" custom_js: Optional[str] = None alert_style: Optional[Literal["classic", "modern"]] = "classic" custom_js_attributes: Optional[str] = "defer" login_page_image: Optional[str] = None login_page_image_filter: Optional[str] = None login_page_image_dark_filter: Optional[str] = None custom_meta_url: Optional[str] = None custom_meta_image_url: Optional[str] = None logo_file_url: Optional[str] = None default_avatar_file_url: Optional[str] = None avatar_size: Optional[int] = None custom_build: Optional[str] = None header_links: Optional[List[HeaderLink]] = None class CodeSettings(BaseModel): # App action functions action_callbacks: Dict[str, Callable[["Action"], Any]] # Module object loaded from the module_name module: Any = None # App life cycle callbacks on_app_startup: Optional[Callable[[], Union[None, Awaitable[None]]]] = None on_app_shutdown: Optional[Callable[[], Union[None, Awaitable[None]]]] = None # Session life cycle callbacks on_logout: Optional[Callable[["Request", "Response"], Any]] = None on_stop: Optional[Callable[[], Any]] = None on_chat_start: Optional[Callable[[], Any]] = None on_chat_end: Optional[Callable[[], Any]] = None on_chat_resume: Optional[Callable[["ThreadDict"], Any]] = None on_message: Optional[Callable[["Message"], Any]] = None on_feedback: Optional[Callable[["Feedback"], Any]] = None on_slack_reaction_added: Optional[Callable[[Dict[str, Any]], Any]] = None on_audio_start: Optional[Callable[[], Any]] = None on_audio_chunk: Optional[Callable[["InputAudioChunk"], Any]] = None on_audio_end: Optional[Callable[[], Any]] = None on_mcp_connect: Optional[Callable] = None on_mcp_disconnect: Optional[Callable] = None on_settings_edit: Optional[Callable[[Dict[str, Any]], Any]] = None on_settings_update: Optional[Callable[[Dict[str, Any]], Any]] = None set_chat_profiles: Optional[ Callable[[Optional["User"], Optional["str"]], Awaitable[List["ChatProfile"]]] ] = None set_starters: Optional[ Callable[[Optional["User"], Optional["str"]], Awaitable[List["Starter"]]] ] = None set_starter_categories: Optional[ Callable[ [Optional["User"], Optional["str"]], Awaitable[List["StarterCategory"]] ] ] = None on_shared_thread_view: Optional[ Callable[["ThreadDict", Optional["User"]], Awaitable[bool]] ] = None # Auth callbacks password_auth_callback: Optional[ Callable[[str, str], Awaitable[Optional["User"]]] ] = None header_auth_callback: Optional[Callable[[Headers], Awaitable[Optional["User"]]]] = ( None ) oauth_callback: Optional[ Callable[[str, str, Dict[str, str], "User"], Awaitable[Optional["User"]]] ] = None # Helpers on_window_message: Optional[Callable[[str], Any]] = None author_rename: Optional[Callable[[str], Awaitable[str]]] = None data_layer: Optional[Callable[[], BaseDataLayer]] = None class ProjectSettings(BaseModel): allow_origins: List[str] = Field(default_factory=lambda: ["*"]) # Socket.io client transports option transports: Optional[List[str]] = None # List of environment variables to be provided by each user to use the app. If empty, no environment variables will be asked to the user. user_env: Optional[List[str]] = None # Path to the local langchain cache database lc_cache_path: Optional[str] = None # Path to the local chat db # Duration (in seconds) during which the session is saved when the connection is lost session_timeout: int = 300 # Duration (in seconds) of the user session expiry user_session_timeout: int = 1296000 # 15 days # Enable third parties caching (e.g LangChain cache) cache: bool = False # Whether to persist user environment variables (API keys) to the database persist_user_env: Optional[bool] = False # Whether to mask user environment variables (API keys) in the UI with password type mask_user_env: Optional[bool] = False class ChainlitConfigOverrides(BaseModel): """Configuration overrides that can be applied to specific chat profiles.""" ui: Optional[UISettings] = None features: Optional[FeaturesSettings] = None project: Optional[ProjectSettings] = None class ChainlitConfig(BaseSettings): root: str = APP_ROOT chainlit_server: str = Field(default="") run: RunSettings = Field(default_factory=RunSettings) features: FeaturesSettings ui: UISettings project: ProjectSettings code: CodeSettings def load_translation(self, language: str): translation = {} default_language = "en-US" # fallback to root language (ex: `de` when `de-DE` is not found) parent_language = language.split("-")[0] translation_dir = Path(config_translation_dir) translation_lib_file_path = translation_dir / f"{language}.json" translation_lib_parent_language_file_path = ( translation_dir / f"{parent_language}.json" ) default_translation_lib_file_path = translation_dir / f"{default_language}.json" if ( is_path_inside(translation_lib_file_path, translation_dir) and translation_lib_file_path.is_file() ): translation = json.loads( translation_lib_file_path.read_text(encoding="utf-8") ) elif ( is_path_inside(translation_lib_parent_language_file_path, translation_dir) and translation_lib_parent_language_file_path.is_file() ): logger.warning( f"Translation file for {language} not found. Using parent translation {parent_language}." ) translation = json.loads( translation_lib_parent_language_file_path.read_text(encoding="utf-8") ) elif ( is_path_inside(default_translation_lib_file_path, translation_dir) and default_translation_lib_file_path.is_file() ): logger.warning( f"Translation file for {language} not found. Using default translation {default_language}." ) translation = json.loads( default_translation_lib_file_path.read_text(encoding="utf-8") ) return translation def with_overrides( self, overrides: "ChainlitConfigOverrides | None" ) -> "ChainlitConfig": base = self.model_dump() patch = overrides.model_dump(exclude_unset=True) if overrides else {} def _merge(a, b): if isinstance(a, dict) and isinstance(b, dict): out = dict(a) for k, v in b.items(): out[k] = _merge(out.get(k), v) return out return b merged = _merge(base, patch) if patch else base return type(self).model_validate(merged) def init_config(log: bool = False): """Initialize the configuration file if it doesn't exist.""" if not os.path.exists(config_file): os.makedirs(config_dir, exist_ok=True) with open(config_file, "w", encoding="utf-8") as f: f.write(DEFAULT_CONFIG_STR) logger.info(f"Created default config file at {config_file}") elif log: logger.info(f"Config file already exists at {config_file}") if not os.path.exists(config_translation_dir): os.makedirs(config_translation_dir, exist_ok=True) logger.info( f"Created default translation directory at {config_translation_dir}" ) for file in os.listdir(TRANSLATIONS_DIR): if file.endswith(".json"): dst = os.path.join(config_translation_dir, file) if not os.path.exists(dst): src = os.path.join(TRANSLATIONS_DIR, file) with open(src, encoding="utf-8") as f: translation = json.load(f) with open(dst, "w", encoding="utf-8") as f: json.dump(translation, f, indent=4) logger.info(f"Created default translation file at {dst}") def load_module(target: str, force_refresh: bool = False): """Load the specified module.""" # Get the target's directory target_dir = os.path.dirname(os.path.abspath(target)) # Add the target's directory to the Python path sys.path.insert(0, target_dir) if force_refresh: # Get current site packages dirs site_package_dirs = site.getsitepackages() # Clear the modules related to the app from sys.modules for module_name, module in list(sys.modules.items()): if ( hasattr(module, "__file__") and module.__file__ and module.__file__.startswith(target_dir) and not any(module.__file__.startswith(p) for p in site_package_dirs) ): sys.modules.pop(module_name, None) spec = util.spec_from_file_location(target, target) if not spec or not spec.loader: sys.path.pop(0) return module = util.module_from_spec(spec) if not module: sys.path.pop(0) return spec.loader.exec_module(module) sys.modules[target] = module # Remove the target's directory from the Python path sys.path.pop(0) def load_settings(): with open(config_file, "rb") as f: toml_dict = tomli.load(f) # Load project settings project_config = toml_dict.get("project", {}) features_settings = toml_dict.get("features", {}) ui_settings = toml_dict.get("UI", {}) meta = toml_dict.get("meta") if not meta or meta.get("generated_by") <= "0.3.0": raise ValueError( f"Your config file '{config_file}' is outdated. Please delete it and restart the app to regenerate it." ) lc_cache_path = os.path.join(config_dir, ".langchain.db") project_settings = ProjectSettings( lc_cache_path=lc_cache_path, **project_config, ) features_settings = FeaturesSettings(**features_settings) ui_settings = UISettings(**ui_settings) code_settings = CodeSettings(action_callbacks={}) return { "features": features_settings, "ui": ui_settings, "project": project_settings, "code": code_settings, } def reload_config(): """Reload the configuration from the config file.""" global config if config is None: return # Preserve the module_name during config reload to ensure hot reload works original_module_name = config.run.module_name if config.run else None new_cfg = ChainlitConfig(**load_settings()) config.root = new_cfg.root config.chainlit_server = new_cfg.chainlit_server config.run = new_cfg.run config.features = new_cfg.features config.ui = new_cfg.ui # Restore the preserved module_name if original_module_name and config.run: config.run.module_name = original_module_name config.project = new_cfg.project config.code = new_cfg.code def load_config(): """Load the configuration from the config file.""" init_config() settings = load_settings() return ChainlitConfig(**settings) def lint_translations(): # Load the ground truth (en-US.json file from chainlit source code) src = os.path.join(TRANSLATIONS_DIR, "en-US.json") with open(src, encoding="utf-8") as f: truth = json.load(f) # Find the local app translations for file in os.listdir(config_translation_dir): if file.endswith(".json"): # Load the translation file to_lint = os.path.join(config_translation_dir, file) with open(to_lint, encoding="utf-8") as f2: translation = json.load(f2) # Lint the translation file lint_translation_json(file, truth, translation) config = load_config() ================================================ FILE: backend/chainlit/context.py ================================================ import asyncio import uuid from contextvars import ContextVar from typing import TYPE_CHECKING, Dict, List, Optional, Union from lazify import LazyProxy from chainlit.session import ClientType, HTTPSession, WebsocketSession if TYPE_CHECKING: from chainlit.emitter import BaseChainlitEmitter from chainlit.step import Step from chainlit.user import PersistedUser, User CL_RUN_NAMES = ["on_chat_start", "on_message", "on_audio_end"] class ChainlitContextException(Exception): def __init__(self, msg="Chainlit context not found", *args, **kwargs): super().__init__(msg, *args, **kwargs) class ChainlitContext: loop: asyncio.AbstractEventLoop emitter: "BaseChainlitEmitter" session: Union["HTTPSession", "WebsocketSession"] @property def current_step(self): if previous_steps := local_steps.get(): return previous_steps[-1] @property def current_run(self): if previous_steps := local_steps.get(): return next( (step for step in previous_steps if step.name in CL_RUN_NAMES), None ) def __init__( self, session: Union["HTTPSession", "WebsocketSession"], emitter: Optional["BaseChainlitEmitter"] = None, ): from chainlit.emitter import BaseChainlitEmitter, ChainlitEmitter self.loop = asyncio.get_running_loop() self.session = session if emitter: self.emitter = emitter elif isinstance(self.session, HTTPSession): self.emitter = BaseChainlitEmitter(self.session) elif isinstance(self.session, WebsocketSession): self.emitter = ChainlitEmitter(self.session) context_var: ContextVar[ChainlitContext] = ContextVar("chainlit") local_steps: ContextVar[Optional[List["Step"]]] = ContextVar( "local_steps", default=None ) def init_ws_context(session_or_sid: Union[WebsocketSession, str]) -> ChainlitContext: if not isinstance(session_or_sid, WebsocketSession): session = WebsocketSession.require(session_or_sid) else: session = session_or_sid context = ChainlitContext(session) context_var.set(context) return context def init_http_context( thread_id: Optional[str] = None, user: Optional[Union["User", "PersistedUser"]] = None, auth_token: Optional[str] = None, user_env: Optional[Dict[str, str]] = None, client_type: ClientType = "webapp", ) -> ChainlitContext: from chainlit.data import get_data_layer session_id = str(uuid.uuid4()) thread_id = thread_id or str(uuid.uuid4()) session = HTTPSession( id=session_id, thread_id=thread_id, token=auth_token, user=user, client_type=client_type, user_env=user_env, ) context = ChainlitContext(session) context_var.set(context) if data_layer := get_data_layer(): if user_id := getattr(user, "id", None): asyncio.create_task( data_layer.update_thread(thread_id=thread_id, user_id=user_id) ) return context def get_context() -> ChainlitContext: try: return context_var.get() except LookupError as e: raise ChainlitContextException from e context: ChainlitContext = LazyProxy(get_context, enable_cache=False) ================================================ FILE: backend/chainlit/data/__init__.py ================================================ import os import warnings from typing import Optional from .base import BaseDataLayer from .utils import ( queue_until_user_message as queue_until_user_message, # TODO: Consider deprecating re-export.; Redundant alias tells type checkers to STFU. ) _data_layer: Optional[BaseDataLayer] = None _data_layer_initialized = False def get_data_layer(): global _data_layer, _data_layer_initialized if not _data_layer_initialized: if _data_layer: # Data layer manually set, warn user that this is deprecated. warnings.warn( "Setting data layer manually is deprecated. Use @data_layer instead.", DeprecationWarning, ) else: from chainlit.config import config if config.code.data_layer: # When @data_layer is configured, call it to get data layer. _data_layer = config.code.data_layer() elif database_url := os.environ.get("DATABASE_URL"): from .chainlit_data_layer import ChainlitDataLayer if os.environ.get("LITERAL_API_KEY"): warnings.warn( "Both LITERAL_API_KEY and DATABASE_URL specified. Ignoring Literal AI data layer and relying on data layer pointing to DATABASE_URL." ) bucket_name = os.environ.get("BUCKET_NAME") # AWS S3 aws_region = os.getenv("APP_AWS_REGION") aws_access_key = os.getenv("APP_AWS_ACCESS_KEY") aws_secret_key = os.getenv("APP_AWS_SECRET_KEY") dev_aws_endpoint = os.getenv("DEV_AWS_ENDPOINT") is_using_s3 = bool(aws_access_key and aws_secret_key and aws_region) # Google Cloud Storage gcs_project_id = os.getenv("APP_GCS_PROJECT_ID") gcs_client_email = os.getenv("APP_GCS_CLIENT_EMAIL") gcs_private_key = os.getenv("APP_GCS_PRIVATE_KEY") is_using_gcs = bool(gcs_project_id) # Azure Storage azure_storage_account = os.getenv("APP_AZURE_STORAGE_ACCOUNT") azure_storage_key = os.getenv("APP_AZURE_STORAGE_ACCESS_KEY") is_using_azure = bool(azure_storage_account and azure_storage_key) storage_client = None if sum([is_using_s3, is_using_gcs, is_using_azure]) > 1: warnings.warn( "Multiple storage configurations detected. Please use only one." ) elif is_using_s3: from chainlit.data.storage_clients.s3 import S3StorageClient storage_client = S3StorageClient( bucket=bucket_name, region_name=aws_region, aws_access_key_id=aws_access_key, aws_secret_access_key=aws_secret_key, endpoint_url=dev_aws_endpoint, ) elif is_using_gcs: from chainlit.data.storage_clients.gcs import GCSStorageClient storage_client = GCSStorageClient( project_id=gcs_project_id, client_email=gcs_client_email, private_key=gcs_private_key, bucket_name=bucket_name, ) elif is_using_azure: from chainlit.data.storage_clients.azure_blob import ( AzureBlobStorageClient, ) storage_client = AzureBlobStorageClient( container_name=bucket_name, storage_account=azure_storage_account, storage_key=azure_storage_key, ) _data_layer = ChainlitDataLayer( database_url=database_url, storage_client=storage_client ) elif api_key := os.environ.get("LITERAL_API_KEY"): # When LITERAL_API_KEY is defined, use Literal AI data layer from .literalai import LiteralDataLayer # support legacy LITERAL_SERVER variable as fallback server = os.environ.get("LITERAL_API_URL") or os.environ.get( "LITERAL_SERVER" ) _data_layer = LiteralDataLayer(api_key=api_key, server=server) _data_layer_initialized = True return _data_layer ================================================ FILE: backend/chainlit/data/acl.py ================================================ from fastapi import HTTPException from chainlit.data import get_data_layer async def is_thread_author(username: str, thread_id: str): data_layer = get_data_layer() if not data_layer: raise HTTPException(status_code=400, detail="Data layer not initialized") thread_author = await data_layer.get_thread_author(thread_id) if not thread_author: raise HTTPException(status_code=404, detail="Thread not found") if thread_author != username: raise HTTPException(status_code=401, detail="Unauthorized") else: return True ================================================ FILE: backend/chainlit/data/base.py ================================================ from abc import ABC, abstractmethod from typing import TYPE_CHECKING, Dict, List, Optional from chainlit.types import ( Feedback, PaginatedResponse, Pagination, ThreadDict, ThreadFilter, ) from .utils import queue_until_user_message if TYPE_CHECKING: from chainlit.element import Element, ElementDict from chainlit.step import StepDict from chainlit.user import PersistedUser, User class BaseDataLayer(ABC): """Base class for data persistence.""" @abstractmethod async def get_user(self, identifier: str) -> Optional["PersistedUser"]: pass @abstractmethod async def create_user(self, user: "User") -> Optional["PersistedUser"]: pass @abstractmethod async def delete_feedback( self, feedback_id: str, ) -> bool: pass @abstractmethod async def upsert_feedback( self, feedback: Feedback, ) -> str: pass @queue_until_user_message() @abstractmethod async def create_element(self, element: "Element"): pass @abstractmethod async def get_element( self, thread_id: str, element_id: str ) -> Optional["ElementDict"]: pass @queue_until_user_message() @abstractmethod async def delete_element(self, element_id: str, thread_id: Optional[str] = None): pass @queue_until_user_message() @abstractmethod async def create_step(self, step_dict: "StepDict"): pass @queue_until_user_message() @abstractmethod async def update_step(self, step_dict: "StepDict"): pass @queue_until_user_message() @abstractmethod async def delete_step(self, step_id: str): pass @abstractmethod async def get_thread_author(self, thread_id: str) -> str: return "" @abstractmethod async def delete_thread(self, thread_id: str): pass @abstractmethod async def list_threads( self, pagination: "Pagination", filters: "ThreadFilter" ) -> "PaginatedResponse[ThreadDict]": pass @abstractmethod async def get_thread(self, thread_id: str) -> "Optional[ThreadDict]": pass @abstractmethod async def update_thread( self, thread_id: str, name: Optional[str] = None, user_id: Optional[str] = None, metadata: Optional[Dict] = None, tags: Optional[List[str]] = None, ): pass @abstractmethod async def build_debug_url(self) -> str: pass @abstractmethod async def close(self) -> None: pass @abstractmethod async def get_favorite_steps(self, user_id: str) -> List["StepDict"]: pass async def set_step_favorite( self, step_dict: "StepDict", favorite: bool ) -> "StepDict": metadata = step_dict.get("metadata") or {} metadata["favorite"] = favorite step_dict["metadata"] = metadata await self.update_step(step_dict) return step_dict ================================================ FILE: backend/chainlit/data/chainlit_data_layer.py ================================================ import json import uuid from datetime import datetime from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union import aiofiles import asyncpg # type: ignore from chainlit.data.base import BaseDataLayer from chainlit.data.storage_clients.base import BaseStorageClient from chainlit.data.utils import queue_until_user_message from chainlit.element import ElementDict from chainlit.logger import logger from chainlit.step import StepDict from chainlit.types import ( Feedback, FeedbackDict, PageInfo, PaginatedResponse, Pagination, ThreadDict, ThreadFilter, ) from chainlit.user import PersistedUser, User # Import for runtime usage (isinstance checks) try: from chainlit.data.storage_clients.gcs import GCSStorageClient except ImportError: GCSStorageClient = None # type: ignore[assignment,misc] if TYPE_CHECKING: from chainlit.data.storage_clients.gcs import GCSStorageClient from chainlit.element import Element, ElementDict from chainlit.step import StepDict ISO_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" class ChainlitDataLayer(BaseDataLayer): def __init__( self, database_url: str, storage_client: Optional[BaseStorageClient] = None, show_logger: bool = False, ): self.database_url = database_url self.pool: Optional[asyncpg.Pool] = None self.storage_client = storage_client self.show_logger = show_logger async def connect(self): if not self.pool: self.pool = await asyncpg.create_pool(self.database_url) async def get_current_timestamp(self) -> datetime: return datetime.now() async def execute_query( self, query: str, params: Union[Dict, None] = None ) -> List[Dict[str, Any]]: if not self.pool: await self.connect() try: async with self.pool.acquire() as connection: # type: ignore try: if params: records = await connection.fetch(query, *params.values()) else: records = await connection.fetch(query) return [dict(record) for record in records] except Exception as e: logger.error(f"Database error: {e!s}") raise except ( asyncpg.exceptions.ConnectionDoesNotExistError, asyncpg.exceptions.InterfaceError, ) as e: # Handle connection issues by cleaning up and rethrowing logger.error(f"Connection error: {e!s}") await self.cleanup() raise async def get_user(self, identifier: str) -> Optional[PersistedUser]: query = """ SELECT * FROM "User" WHERE identifier = $1 """ result = await self.execute_query(query, {"identifier": identifier}) if not result or len(result) == 0: return None row = result[0] return PersistedUser( id=str(row.get("id")), identifier=str(row.get("identifier")), createdAt=row.get("createdAt").isoformat(), # type: ignore metadata=json.loads(row.get("metadata", "{}")), ) async def create_user(self, user: User) -> Optional[PersistedUser]: query = """ INSERT INTO "User" (id, identifier, metadata, "createdAt", "updatedAt") VALUES ($1, $2, $3, $4, $5) ON CONFLICT (identifier) DO UPDATE SET metadata = $3 RETURNING * """ now = await self.get_current_timestamp() params = { "id": str(uuid.uuid4()), "identifier": user.identifier, "metadata": json.dumps(user.metadata), "created_at": now, "updated_at": now, } result = await self.execute_query(query, params) row = result[0] return PersistedUser( id=str(row.get("id")), identifier=str(row.get("identifier")), createdAt=row.get("createdAt").isoformat(), # type: ignore metadata=json.loads(row.get("metadata", "{}")), ) async def delete_feedback(self, feedback_id: str) -> bool: query = """ DELETE FROM "Feedback" WHERE id = $1 """ await self.execute_query(query, {"feedback_id": feedback_id}) return True async def upsert_feedback(self, feedback: Feedback) -> str: query = """ INSERT INTO "Feedback" (id, "stepId", name, value, comment) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET value = $4, comment = $5 RETURNING id """ feedback_id = feedback.id or str(uuid.uuid4()) params = { "id": feedback_id, "step_id": feedback.forId, "name": "user_feedback", "value": float(feedback.value), "comment": feedback.comment, } results = await self.execute_query(query, params) return str(results[0]["id"]) @queue_until_user_message() async def create_element(self, element: "Element"): if not element.for_id: return if element.thread_id: query = 'SELECT id FROM "Thread" WHERE id = $1' results = await self.execute_query(query, {"thread_id": element.thread_id}) if not results: await self.update_thread(thread_id=element.thread_id) if element.for_id: query = 'SELECT id FROM "Step" WHERE id = $1' results = await self.execute_query(query, {"step_id": element.for_id}) if not results: await self.create_step( { "id": element.for_id, "metadata": {}, "type": "run", "start_time": await self.get_current_timestamp(), "end_time": await self.get_current_timestamp(), } ) # Handle file uploads only if storage_client is configured path = None if self.storage_client: content: Optional[Union[bytes, str]] = None if element.path: async with aiofiles.open(element.path, "rb") as f: content = await f.read() elif element.content: content = element.content elif not element.url: raise ValueError("Element url, path or content must be provided") if content is not None: if element.thread_id: path = f"threads/{element.thread_id}/files/{element.id}" 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 ) await self.storage_client.upload_file( object_key=path, data=content, mime=element.mime or "application/octet-stream", overwrite=True, content_disposition=content_disposition, ) else: # Log warning only if element has file content that needs uploading if element.path or element.url or element.content: logger.warning( "Data Layer: No storage client configured. " "File will not be uploaded." ) # Always persist element metadata to database query = """ INSERT INTO "Element" ( id, "threadId", "stepId", metadata, mime, name, "objectKey", url, "chainlitKey", display, size, language, page, props ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14 ) ON CONFLICT (id) DO UPDATE SET props = EXCLUDED.props """ params = { "id": element.id, "thread_id": element.thread_id, "step_id": element.for_id, "metadata": json.dumps( { "size": element.size, "language": element.language, "display": element.display, "type": element.type, "page": getattr(element, "page", None), } ), "mime": element.mime, "name": element.name, "object_key": path, "url": element.url, "chainlit_key": element.chainlit_key, "display": element.display, "size": element.size, "language": element.language, "page": getattr(element, "page", None), "props": json.dumps(getattr(element, "props", {})), } await self.execute_query(query, params) async def get_element( self, thread_id: str, element_id: str ) -> Optional[ElementDict]: query = """ SELECT * FROM "Element" WHERE id = $1 AND "threadId" = $2 """ results = await self.execute_query( query, {"element_id": element_id, "thread_id": thread_id} ) if not results: return None row = results[0] metadata = json.loads(row.get("metadata", "{}")) return ElementDict( id=str(row["id"]), threadId=str(row["threadId"]), type=metadata.get("type", "file"), url=str(row["url"]), name=str(row["name"]), mime=str(row["mime"]), objectKey=str(row["objectKey"]), forId=str(row["stepId"]), chainlitKey=row.get("chainlitKey"), display=row["display"], size=row["size"], language=row["language"], page=row["page"], autoPlay=row.get("autoPlay"), playerConfig=row.get("playerConfig"), props=json.loads(row.get("props", "{}")), ) @queue_until_user_message() async def delete_element(self, element_id: str, thread_id: Optional[str] = None): query = """ SELECT * FROM "Element" WHERE id = $1 """ elements = await self.execute_query(query, {"id": element_id}) if self.storage_client is not None and len(elements) > 0: if elements[0]["objectKey"]: await self.storage_client.delete_file( object_key=elements[0]["objectKey"] ) query = """ DELETE FROM "Element" WHERE id = $1 """ params = {"id": element_id} if thread_id: query += ' AND "threadId" = $2' params["thread_id"] = thread_id await self.execute_query(query, params) @queue_until_user_message() async def create_step(self, step_dict: StepDict): if step_dict.get("threadId"): thread_query = 'SELECT id FROM "Thread" WHERE id = $1' thread_results = await self.execute_query( thread_query, {"thread_id": step_dict["threadId"]} ) if not thread_results: await self.update_thread(thread_id=step_dict["threadId"]) if step_dict.get("parentId"): parent_query = 'SELECT id FROM "Step" WHERE id = $1' parent_results = await self.execute_query( parent_query, {"parent_id": step_dict["parentId"]} ) if not parent_results: await self.create_step( { "id": step_dict["parentId"], "metadata": {}, "type": "run", "createdAt": step_dict.get("createdAt"), } ) query = """ INSERT INTO "Step" ( id, "threadId", "parentId", input, metadata, name, output, type, "startTime", "endTime", "showInput", "isError" ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12 ) ON CONFLICT (id) DO UPDATE SET "parentId" = COALESCE(EXCLUDED."parentId", "Step"."parentId"), input = COALESCE(NULLIF(EXCLUDED.input, ''), "Step".input), metadata = CASE WHEN EXCLUDED.metadata <> '{}' THEN EXCLUDED.metadata ELSE "Step".metadata END, name = COALESCE(EXCLUDED.name, "Step".name), output = COALESCE(NULLIF(EXCLUDED.output, ''), "Step".output), type = CASE WHEN EXCLUDED.type = 'run' THEN "Step".type ELSE EXCLUDED.type END, "threadId" = COALESCE(EXCLUDED."threadId", "Step"."threadId"), "endTime" = COALESCE(EXCLUDED."endTime", "Step"."endTime"), "startTime" = LEAST(EXCLUDED."startTime", "Step"."startTime"), "showInput" = COALESCE(EXCLUDED."showInput", "Step"."showInput"), "isError" = COALESCE(EXCLUDED."isError", "Step"."isError") """ timestamp = await self.get_current_timestamp() created_at = step_dict.get("createdAt") if created_at: timestamp = datetime.strptime(created_at, ISO_FORMAT) params = { "id": step_dict["id"], "thread_id": step_dict.get("threadId"), "parent_id": step_dict.get("parentId"), "input": step_dict.get("input"), "metadata": json.dumps(step_dict.get("metadata", {})), "name": step_dict.get("name"), "output": step_dict.get("output"), "type": step_dict["type"], "start_time": timestamp, "end_time": timestamp, "show_input": str(step_dict.get("showInput", "json")), "is_error": step_dict.get("isError", False), } await self.execute_query(query, params) @queue_until_user_message() async def update_step(self, step_dict: StepDict): await self.create_step(step_dict) @queue_until_user_message() async def delete_step(self, step_id: str): # Delete associated elements and feedbacks first await self.execute_query( 'DELETE FROM "Element" WHERE "stepId" = $1', {"step_id": step_id} ) await self.execute_query( 'DELETE FROM "Feedback" WHERE "stepId" = $1', {"step_id": step_id} ) # Delete the step await self.execute_query( 'DELETE FROM "Step" WHERE id = $1', {"step_id": step_id} ) async def get_step(self, step_id: str) -> Optional[StepDict]: # Get step and related feedback query = """ SELECT s.*, f.id feedback_id, f.value feedback_value, f."comment" feedback_comment FROM "Step" s left join "Feedback" f on s.id = f."stepId" WHERE s.id = $1 """ result = await self.execute_query(query, {"step_id": step_id}) if not result: return None return self._convert_step_row_to_dict(result[0]) async def get_thread_author(self, thread_id: str) -> str: query = """ SELECT u.identifier FROM "Thread" t JOIN "User" u ON t."userId" = u.id WHERE t.id = $1 """ results = await self.execute_query(query, {"thread_id": thread_id}) if not results: raise ValueError(f"Thread {thread_id} not found") return results[0]["identifier"] async def delete_thread(self, thread_id: str): elements_query = """ SELECT * FROM "Element" WHERE "threadId" = $1 """ elements_results = await self.execute_query( elements_query, {"thread_id": thread_id} ) if self.storage_client is not None: for elem in elements_results: if elem["objectKey"]: await self.storage_client.delete_file(object_key=elem["objectKey"]) await self.execute_query( 'DELETE FROM "Thread" WHERE id = $1', {"thread_id": thread_id} ) async def list_threads( self, pagination: Pagination, filters: ThreadFilter ) -> PaginatedResponse[ThreadDict]: query = """ SELECT t.*, u.identifier as user_identifier, (SELECT COUNT(*) FROM "Thread" WHERE "userId" = t."userId") as total FROM "Thread" t LEFT JOIN "User" u ON t."userId" = u.id WHERE t."deletedAt" IS NULL """ params: Dict[str, Any] = {} param_count = 1 if filters.search: query += f" AND t.name ILIKE ${param_count}" params["name"] = f"%{filters.search}%" param_count += 1 if filters.userId: query += f' AND t."userId" = ${param_count}' params["user_id"] = filters.userId param_count += 1 if pagination.cursor: query += f' AND t."updatedAt" < (SELECT "updatedAt" FROM "Thread" WHERE id = ${param_count})' params["cursor"] = pagination.cursor param_count += 1 query += f' ORDER BY t."updatedAt" DESC LIMIT ${param_count}' params["limit"] = pagination.first + 1 results = await self.execute_query(query, params) threads = results has_next_page = len(threads) > pagination.first if has_next_page: threads = threads[:-1] thread_dicts = [] for thread in threads: thread_dict = ThreadDict( id=str(thread["id"]), createdAt=thread["updatedAt"].isoformat(), name=thread["name"], userId=str(thread["userId"]) if thread["userId"] else None, userIdentifier=thread["user_identifier"], metadata=json.loads(thread["metadata"]), steps=[], elements=[], tags=[], ) thread_dicts.append(thread_dict) return PaginatedResponse( pageInfo=PageInfo( hasNextPage=has_next_page, startCursor=thread_dicts[0]["id"] if thread_dicts else None, endCursor=thread_dicts[-1]["id"] if thread_dicts else None, ), data=thread_dicts, ) async def get_thread(self, thread_id: str) -> Optional[ThreadDict]: query = """ SELECT t.*, u.identifier as user_identifier FROM "Thread" t LEFT JOIN "User" u ON t."userId" = u.id WHERE t.id = $1 AND t."deletedAt" IS NULL """ results = await self.execute_query(query, {"thread_id": thread_id}) if not results: return None thread = results[0] # Get steps and related feedback steps_query = """ SELECT s.*, f.id feedback_id, f.value feedback_value, f."comment" feedback_comment FROM "Step" s left join "Feedback" f on s.id = f."stepId" WHERE s."threadId" = $1 ORDER BY "startTime" """ steps_results = await self.execute_query(steps_query, {"thread_id": thread_id}) # Get elements elements_query = """ SELECT * FROM "Element" WHERE "threadId" = $1 """ elements_results = await self.execute_query( elements_query, {"thread_id": thread_id} ) if self.storage_client is not None: for elem in elements_results: if not elem["url"] and elem["objectKey"]: elem["url"] = await self.storage_client.get_read_url( object_key=elem["objectKey"], ) return ThreadDict( id=str(thread["id"]), createdAt=thread["createdAt"].isoformat(), name=thread["name"], userId=str(thread["userId"]) if thread["userId"] else None, userIdentifier=thread["user_identifier"], metadata=json.loads(thread["metadata"]), steps=[self._convert_step_row_to_dict(step) for step in steps_results], elements=[ self._convert_element_row_to_dict(elem) for elem in elements_results ], tags=[], ) async def update_thread( self, thread_id: str, name: Optional[str] = None, user_id: Optional[str] = None, metadata: Optional[Dict] = None, tags: Optional[List[str]] = None, ): if self.show_logger: logger.info(f"asyncpg: update_thread, thread_id={thread_id}") thread_name = truncate( name if name is not None else (metadata.get("name") if metadata and "name" in metadata else None) ) # Merge incoming metadata with existing metadata, deleting incoming keys with None values merged_metadata = None if metadata is not None: existing = await self.execute_query( 'SELECT "metadata" FROM "Thread" WHERE id = $1', {"thread_id": thread_id}, ) base = {} if isinstance(existing, list) and existing: raw = existing[0].get("metadata") or {} if isinstance(raw, str): try: base = json.loads(raw) except json.JSONDecodeError: base = {} elif isinstance(raw, dict): base = raw to_delete = {k for k, v in metadata.items() if v is None} incoming = {k: v for k, v in metadata.items() if v is not None} base = {k: v for k, v in base.items() if k not in to_delete} merged_metadata = {**base, **incoming} data = { "id": thread_id, "name": thread_name, "userId": user_id, "tags": tags, "metadata": json.dumps(merged_metadata) if merged_metadata is not None else None, "updatedAt": datetime.now(), } # Remove None values data = {k: v for k, v in data.items() if v is not None} # Build the query dynamically based on available fields columns = [f'"{k}"' for k in data.keys()] placeholders = [f"${i + 1}" for i in range(len(data))] values = list(data.values()) update_sets = [f'"{k}" = EXCLUDED."{k}"' for k in data.keys() if k != "id"] if update_sets: query = f""" INSERT INTO "Thread" ({", ".join(columns)}) VALUES ({", ".join(placeholders)}) ON CONFLICT (id) DO UPDATE SET {", ".join(update_sets)}; """ else: query = f""" INSERT INTO "Thread" ({", ".join(columns)}) VALUES ({", ".join(placeholders)}) ON CONFLICT (id) DO NOTHING """ await self.execute_query(query, {str(i + 1): v for i, v in enumerate(values)}) async def get_favorite_steps(self, user_id: str) -> List[StepDict]: query = """ SELECT s.* FROM "Step" s JOIN "Thread" t ON s."threadId" = t.id WHERE t."userId" = $1 AND s.metadata::jsonb->>'favorite' = 'true' ORDER BY s."createdAt" DESC \ """ results = await self.execute_query(query, {"user_id": user_id}) return [self._convert_step_row_to_dict(row) for row in results] def _extract_feedback_dict_from_step_row(self, row: Dict) -> Optional[FeedbackDict]: if row.get("feedback_id", None) is not None: return FeedbackDict( forId=str(row["id"]), id=str(row["feedback_id"]), value=row["feedback_value"], comment=row["feedback_comment"], ) return None def _convert_step_row_to_dict(self, row: Dict) -> StepDict: return StepDict( id=str(row["id"]), threadId=str(row["threadId"]) if row.get("threadId") else "", parentId=str(row["parentId"]) if row.get("parentId") else None, name=str(row.get("name")), type=row["type"], input=row.get("input", {}), output=row.get("output", {}), metadata=json.loads(row.get("metadata", "{}")), createdAt=row["createdAt"].isoformat() if row.get("createdAt") else None, start=row["startTime"].isoformat() if row.get("startTime") else None, showInput=row.get("showInput"), isError=row.get("isError"), end=row["endTime"].isoformat() if row.get("endTime") else None, feedback=self._extract_feedback_dict_from_step_row(row), ) def _convert_element_row_to_dict(self, row: Dict) -> ElementDict: metadata = json.loads(row.get("metadata", "{}")) return ElementDict( id=str(row["id"]), threadId=str(row["threadId"]) if row.get("threadId") else None, type=metadata.get("type", "file"), url=row["url"], name=row["name"], mime=row["mime"], objectKey=row["objectKey"], forId=str(row["stepId"]), chainlitKey=row.get("chainlitKey"), display=row["display"], size=row["size"], language=row["language"], page=row["page"], autoPlay=row.get("autoPlay"), playerConfig=row.get("playerConfig"), props=json.loads(row.get("props") or "{}"), ) async def build_debug_url(self) -> str: return "" async def cleanup(self): """Cleanup database connections""" if self.pool: logger.debug("Cleaning up connection pool") await self.pool.close() self.pool = None async def close(self) -> None: if self.storage_client: await self.storage_client.close() await self.cleanup() def truncate(text: Optional[str], max_length: int = 255) -> Optional[str]: return None if text is None else text[:max_length] ================================================ FILE: backend/chainlit/data/dynamodb.py ================================================ import asyncio import json import logging import os import random from dataclasses import asdict from datetime import datetime from decimal import Decimal from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union, cast import aiofiles import aiohttp import boto3 # type: ignore from boto3.dynamodb.types import TypeDeserializer, TypeSerializer from chainlit.context import context from chainlit.data.base import BaseDataLayer from chainlit.data.storage_clients.base import BaseStorageClient from chainlit.data.utils import queue_until_user_message from chainlit.element import ElementDict from chainlit.logger import logger from chainlit.step import StepDict from chainlit.types import ( Feedback, PageInfo, PaginatedResponse, Pagination, ThreadDict, ThreadFilter, ) from chainlit.user import PersistedUser, User if TYPE_CHECKING: from mypy_boto3_dynamodb import DynamoDBClient from chainlit.element import Element _logger = logger.getChild("DynamoDB") _logger.setLevel(logging.WARNING) class DynamoDBDataLayer(BaseDataLayer): def __init__( self, table_name: str, client: Optional["DynamoDBClient"] = None, storage_provider: Optional[BaseStorageClient] = None, user_thread_limit: int = 10, ): if client: self.client = client else: region_name = os.environ.get("AWS_REGION", "us-east-1") self.client = boto3.client("dynamodb", region_name=region_name) # type: ignore self.table_name = table_name self.storage_provider = storage_provider self.user_thread_limit = user_thread_limit self._type_deserializer = TypeDeserializer() self._type_serializer = TypeSerializer() def _get_current_timestamp(self) -> str: return datetime.now().isoformat() + "Z" def _serialize_item(self, item: dict[str, Any]) -> dict[str, Any]: def convert_floats(obj): if isinstance(obj, float): return Decimal(str(obj)) elif isinstance(obj, dict): return {k: convert_floats(v) for k, v in obj.items()} elif isinstance(obj, list): return [convert_floats(v) for v in obj] else: return obj return { key: self._type_serializer.serialize(convert_floats(value)) for key, value in item.items() } def _deserialize_item(self, item: dict[str, Any]) -> dict[str, Any]: def convert_decimals(obj): if isinstance(obj, Decimal): return float(obj) elif isinstance(obj, dict): return {k: convert_decimals(v) for k, v in obj.items()} elif isinstance(obj, list): return [convert_decimals(v) for v in obj] else: return obj return { key: convert_decimals(self._type_deserializer.deserialize(value)) for key, value in item.items() } def _update_item(self, key: Dict[str, Any], updates: Dict[str, Any]): update_expr: List[str] = [] expression_attribute_names = {} expression_attribute_values = {} for index, (attr, value) in enumerate(updates.items()): if not value: continue k, v = f"#{index}", f":{index}" update_expr.append(f"{k} = {v}") expression_attribute_names[k] = attr expression_attribute_values[v] = value self.client.update_item( TableName=self.table_name, Key=self._serialize_item(key), UpdateExpression="SET " + ", ".join(update_expr), ExpressionAttributeNames=expression_attribute_names, ExpressionAttributeValues=self._serialize_item(expression_attribute_values), ) @property def context(self): return context async def get_user(self, identifier: str) -> Optional["PersistedUser"]: _logger.info("DynamoDB: get_user identifier=%s", identifier) response = self.client.get_item( TableName=self.table_name, Key={ "PK": {"S": f"USER#{identifier}"}, "SK": {"S": "USER"}, }, ) if "Item" not in response: return None user = self._deserialize_item(response["Item"]) return PersistedUser( id=user["id"], identifier=user["identifier"], createdAt=user["createdAt"], metadata=user["metadata"], ) async def create_user(self, user: "User") -> Optional["PersistedUser"]: _logger.info("DynamoDB: create_user user.identifier=%s", user.identifier) ts = self._get_current_timestamp() metadata: Dict[Any, Any] = user.metadata # type: ignore item = { "PK": f"USER#{user.identifier}", "SK": "USER", "id": user.identifier, "identifier": user.identifier, "metadata": metadata, "createdAt": ts, } self.client.put_item( TableName=self.table_name, Item=self._serialize_item(item), ) return PersistedUser( id=user.identifier, identifier=user.identifier, createdAt=ts, metadata=metadata, ) async def delete_feedback(self, feedback_id: str) -> bool: _logger.info("DynamoDB: delete_feedback feedback_id=%s", feedback_id) # feedback id = THREAD#{thread_id}::STEP#{step_id} thread_id, step_id = feedback_id.split("::") thread_id = thread_id.strip("THREAD#") step_id = step_id.strip("STEP#") self.client.update_item( TableName=self.table_name, Key={ "PK": {"S": f"THREAD#{thread_id}"}, "SK": {"S": f"STEP#{step_id}"}, }, UpdateExpression="REMOVE #feedback", ExpressionAttributeNames={"#feedback": "feedback"}, ) return True async def upsert_feedback(self, feedback: Feedback) -> str: _logger.info( "DynamoDB: upsert_feedback thread=%s step=%s value=%s", feedback.threadId, feedback.forId, feedback.value, ) if not feedback.forId: raise ValueError( "DynamoDB data layer expects value for feedback.threadId got None" ) feedback.id = f"THREAD#{feedback.threadId}::STEP#{feedback.forId}" serialized_feedback = self._type_serializer.serialize(asdict(feedback)) self.client.update_item( TableName=self.table_name, Key={ "PK": {"S": f"THREAD#{feedback.threadId}"}, "SK": {"S": f"STEP#{feedback.forId}"}, }, UpdateExpression="SET #feedback = :feedback", ExpressionAttributeNames={"#feedback": "feedback"}, ExpressionAttributeValues={":feedback": serialized_feedback}, ) return feedback.id @queue_until_user_message() async def create_element(self, element: "Element"): _logger.info( "DynamoDB: create_element thread=%s step=%s type=%s", element.thread_id, element.for_id, element.type, ) _logger.debug("DynamoDB: create_element: %s", element.to_dict()) if not element.for_id: return if not self.storage_provider: _logger.warning( "DynamoDB: create_element error. No storage_provider is configured!" ) return content: Optional[Union[bytes, str]] = None if element.content: content = element.content elif element.path: _logger.debug("DynamoDB: create_element reading file %s", element.path) async with aiofiles.open(element.path, "rb") as f: content = await f.read() elif element.url: _logger.debug("DynamoDB: create_element http %s", element.url) async with aiohttp.ClientSession() as session: async with session.get(element.url) as response: if response.status == 200: content = await response.read() else: raise ValueError( f"Failed to read content from {element.url} status {response.status}", ) else: raise ValueError("Element url, path or content must be provided") if content is None: raise ValueError("Content is None, cannot upload file") if not element.mime: element.mime = "application/octet-stream" context_user = self.context.session.user user_folder = getattr(context_user, "id", "unknown") file_object_key = f"{user_folder}/{element.thread_id}/{element.id}" uploaded_file = await self.storage_provider.upload_file( object_key=file_object_key, data=content, mime=element.mime, overwrite=True, ) if not uploaded_file: raise ValueError( "DynamoDB Error: create_element, Failed to persist data in storage_provider", ) element_dict: Dict[str, Any] = element.to_dict() # type: ignore element_dict.update( { "PK": f"THREAD#{element.thread_id}", "SK": f"ELEMENT#{element.id}", "url": uploaded_file.get("url"), "objectKey": uploaded_file.get("object_key"), } ) self.client.put_item( TableName=self.table_name, Item=self._serialize_item(element_dict), ) async def get_element( self, thread_id: str, element_id: str ) -> Optional["ElementDict"]: _logger.info( "DynamoDB: get_element thread=%s element=%s", thread_id, element_id ) response = self.client.get_item( TableName=self.table_name, Key={ "PK": {"S": f"THREAD#{thread_id}"}, "SK": {"S": f"ELEMENT#{element_id}"}, }, ) if "Item" not in response: return None return self._deserialize_item(response["Item"]) # type: ignore @queue_until_user_message() async def delete_element(self, element_id: str, thread_id: Optional[str] = None): thread_id = self.context.session.thread_id _logger.info( "DynamoDB: delete_element thread=%s element=%s", thread_id, element_id ) self.client.delete_item( TableName=self.table_name, Key={ "PK": {"S": f"THREAD#{thread_id}"}, "SK": {"S": f"ELEMENT#{element_id}"}, }, ) @queue_until_user_message() async def create_step(self, step_dict: "StepDict"): _logger.info( "DynamoDB: create_step thread=%s step=%s", step_dict.get("threadId"), step_dict.get("id"), ) _logger.debug("DynamoDB: create_step: %s", step_dict) item = dict(step_dict) item.update( { # ignore type, dynamo needs these so we want to fail if not set "PK": f"THREAD#{step_dict['threadId']}", # type: ignore "SK": f"STEP#{step_dict['id']}", # type: ignore } ) self.client.put_item( TableName=self.table_name, Item=self._serialize_item(item), ) @queue_until_user_message() async def update_step(self, step_dict: "StepDict"): _logger.info( "DynamoDB: update_step thread=%s step=%s", step_dict.get("threadId"), step_dict.get("id"), ) _logger.debug("DynamoDB: update_step: %s", step_dict) self._update_item( key={ # ignore type, dynamo needs these so we want to fail if not set "PK": f"THREAD#{step_dict['threadId']}", # type: ignore "SK": f"STEP#{step_dict['id']}", # type: ignore }, updates=step_dict, # type: ignore ) @queue_until_user_message() async def delete_step(self, step_id: str): thread_id = self.context.session.thread_id _logger.info("DynamoDB: delete_feedback thread=%s step=%s", thread_id, step_id) self.client.delete_item( TableName=self.table_name, Key={ "PK": {"S": f"THREAD#{thread_id}"}, "SK": {"S": f"STEP#{step_id}"}, }, ) async def get_thread_author(self, thread_id: str) -> str: _logger.info("DynamoDB: get_thread_author thread=%s", thread_id) response = self.client.get_item( TableName=self.table_name, Key={ "PK": {"S": f"THREAD#{thread_id}"}, "SK": {"S": "THREAD"}, }, ProjectionExpression="userId", ) if "Item" not in response: raise ValueError(f"Author not found for thread_id {thread_id}") item = self._deserialize_item(response["Item"]) return item["userId"] async def delete_thread(self, thread_id: str): _logger.info("DynamoDB: delete_thread thread=%s", thread_id) thread = await self.get_thread(thread_id) if not thread: return items: List[Any] = thread["steps"] if thread["elements"]: items.extend(thread["elements"]) delete_requests = [] for item in items: key = self._serialize_item({"PK": item["PK"], "SK": item["SK"]}) req = {"DeleteRequest": {"Key": key}} delete_requests.append(req) BATCH_ITEM_SIZE = 25 # pylint: disable=invalid-name for i in range(0, len(delete_requests), BATCH_ITEM_SIZE): chunk = delete_requests[i : i + BATCH_ITEM_SIZE] response = self.client.batch_write_item( RequestItems={ self.table_name: chunk, # type: ignore } ) backoff_time = 1 while response.get("UnprocessedItems"): backoff_time *= 2 # Cap the backoff time at 32 seconds & add jitter delay = min(backoff_time, 32) + random.uniform(0, 1) await asyncio.sleep(delay) response = self.client.batch_write_item( RequestItems=response["UnprocessedItems"] ) self.client.delete_item( TableName=self.table_name, Key={ "PK": {"S": f"THREAD#{thread_id}"}, "SK": {"S": "THREAD"}, }, ) async def list_threads( self, pagination: "Pagination", filters: "ThreadFilter" ) -> "PaginatedResponse[ThreadDict]": _logger.info("DynamoDB: list_threads filters.userId=%s", filters.userId) if filters.feedback: _logger.warning("DynamoDB: filters on feedback not supported") paginated_response: PaginatedResponse[ThreadDict] = PaginatedResponse( data=[], pageInfo=PageInfo( hasNextPage=False, startCursor=pagination.cursor, endCursor=None ), ) query_args: Dict[str, Any] = { "TableName": self.table_name, "IndexName": "UserThread", "ScanIndexForward": False, "Limit": self.user_thread_limit, "KeyConditionExpression": "#UserThreadPK = :pk", "ExpressionAttributeNames": { "#UserThreadPK": "UserThreadPK", }, "ExpressionAttributeValues": { ":pk": {"S": f"USER#{filters.userId}"}, }, } if pagination.cursor: query_args["ExclusiveStartKey"] = json.loads(pagination.cursor) if filters.search: query_args["FilterExpression"] = "contains(#name, :search)" query_args["ExpressionAttributeNames"]["#name"] = "name" query_args["ExpressionAttributeValues"][":search"] = {"S": filters.search} response = self.client.query(**query_args) # type: ignore if "LastEvaluatedKey" in response: paginated_response.pageInfo.hasNextPage = True paginated_response.pageInfo.endCursor = json.dumps( response["LastEvaluatedKey"] ) for item in response["Items"]: deserialized_item: Dict[str, Any] = self._deserialize_item(item) thread = ThreadDict( # type: ignore id=deserialized_item["PK"].strip("THREAD#"), createdAt=deserialized_item["UserThreadSK"].strip("TS#"), name=deserialized_item["name"], ) paginated_response.data.append(thread) return paginated_response async def get_thread(self, thread_id: str) -> "Optional[ThreadDict]": _logger.info("DynamoDB: get_thread thread=%s", thread_id) # Get all thread records thread_items: List[Any] = [] cursor: Dict[str, Any] = {} while True: response = self.client.query( TableName=self.table_name, KeyConditionExpression="#pk = :pk", ExpressionAttributeNames={"#pk": "PK"}, ExpressionAttributeValues={":pk": {"S": f"THREAD#{thread_id}"}}, **cursor, ) deserialized_items = map(self._deserialize_item, response["Items"]) thread_items.extend(deserialized_items) if "LastEvaluatedKey" not in response: break cursor["ExclusiveStartKey"] = response["LastEvaluatedKey"] if len(thread_items) == 0: return None # process accordingly thread_dict: Optional[ThreadDict] = None steps = [] elements = [] for item in thread_items: if item["SK"] == "THREAD": thread_dict = item elif item["SK"].startswith("ELEMENT"): if self.storage_provider is not None: item["url"] = await self.storage_provider.get_read_url( object_key=item["objectKey"], ) elements.append(item) elif item["SK"].startswith("STEP"): if "feedback" in item: # Decimal is not json serializable item["feedback"]["value"] = int(item["feedback"]["value"]) steps.append(item) if not thread_dict: if len(thread_items) > 0: _logger.warning( "DynamoDB: found orphaned items for thread=%s", thread_id ) return None steps.sort(key=lambda i: i["createdAt"]) thread_dict.update( { "steps": steps, "elements": elements, } ) return thread_dict async def update_thread( self, thread_id: str, name: Optional[str] = None, user_id: Optional[str] = None, metadata: Optional[Dict] = None, tags: Optional[List[str]] = None, ): _logger.info("DynamoDB: update_thread thread=%s userId=%s", thread_id, user_id) _logger.debug( "DynamoDB: update_thread name=%s tags=%s metadata=%s", name, tags, metadata ) ts = self._get_current_timestamp() item = { # GSI: UserThread "UserThreadSK": f"TS#{ts}", # "id": thread_id, "createdAt": ts, "name": name, "userId": user_id, "userIdentifier": user_id, "tags": tags, "metadata": metadata, } if user_id: # user_id may be None on subsequent calls, don't update UserThreadPK to "USER#{None}" item["UserThreadPK"] = f"USER#{user_id}" self._update_item( key={ "PK": f"THREAD#{thread_id}", "SK": "THREAD", }, updates=item, ) async def get_favorite_steps(self, user_id: str) -> List["StepDict"]: _logger.info("DynamoDB: get_favorite_steps user_id=%s", user_id) thread_ids = [] query_args: Dict[str, Any] = { "TableName": self.table_name, "IndexName": "UserThread", "KeyConditionExpression": "#UserThreadPK = :pk", "ExpressionAttributeNames": {"#UserThreadPK": "UserThreadPK"}, "ExpressionAttributeValues": {":pk": {"S": f"USER#{user_id}"}}, } while True: response = self.client.query(**query_args) # type: ignore for item in response.get("Items", []): pk = item.get("PK", {}).get("S") if pk: thread_ids.append(pk.removeprefix("THREAD#")) if "LastEvaluatedKey" not in response: break query_args["ExclusiveStartKey"] = response["LastEvaluatedKey"] favorite_steps: List[Dict[str, Any]] = [] for thread_id in thread_ids: t_query_args: Dict[str, Any] = { "TableName": self.table_name, "KeyConditionExpression": "#pk = :pk AND begins_with(#sk, :sk_prefix)", "FilterExpression": "#metadata.#favorite = :true", "ExpressionAttributeNames": { "#pk": "PK", "#sk": "SK", "#metadata": "metadata", "#favorite": "favorite", }, "ExpressionAttributeValues": { ":pk": {"S": f"THREAD#{thread_id}"}, ":sk_prefix": {"S": "STEP#"}, ":true": {"BOOL": True}, }, } while True: response = self.client.query(**t_query_args) # type: ignore for item in response.get("Items", []): step = self._deserialize_item(item) if "PK" in step: del step["PK"] if "SK" in step: del step["SK"] if "feedback" in step: del step["feedback"] favorite_steps.append(step) if "LastEvaluatedKey" not in response: break t_query_args["ExclusiveStartKey"] = response["LastEvaluatedKey"] favorite_steps.sort(key=lambda x: x.get("createdAt", ""), reverse=True) return cast(List["StepDict"], favorite_steps) async def build_debug_url(self) -> str: return "" async def close(self) -> None: if self.storage_provider: await self.storage_provider.close() self.client.close() ================================================ FILE: backend/chainlit/data/literalai.py ================================================ import json # Deprecation warning for users of this provider import sys import warnings from typing import Dict, List, Literal, Optional, Union, cast import aiofiles from httpx import HTTPStatusError, RequestError from literalai import ( Attachment as LiteralAttachment, Score as LiteralScore, Step as LiteralStep, Thread as LiteralThread, ) from literalai.observability.filter import threads_filters as LiteralThreadsFilters from literalai.observability.step import StepDict as LiteralStepDict from chainlit.data.base import BaseDataLayer from chainlit.data.utils import queue_until_user_message from chainlit.element import Audio, Element, ElementDict, File, Image, Pdf, Text, Video from chainlit.logger import logger from chainlit.step import ( FeedbackDict, Step, StepDict, StepType, TrueStepType, check_add_step_in_cot, stub_step, ) from chainlit.types import ( Feedback, PageInfo, PaginatedResponse, Pagination, ThreadDict, ThreadFilter, ) from chainlit.user import PersistedUser, User def _show_deprecation_warning(): message = ( "\n\033[93mWARNING: The LiteralAI data provider is being deprecated and will be turned off on October 31st, 2025.\033[0m\n" "Please migrate your data layer to another provider as soon as possible.\n" ) print(message, file=sys.stderr) warnings.warn(message, DeprecationWarning, stacklevel=2) _show_deprecation_warning() class LiteralToChainlitConverter: @classmethod def steptype_to_steptype(cls, step_type: Optional[StepType]) -> TrueStepType: return cast(TrueStepType, step_type or "undefined") @classmethod def score_to_feedbackdict( cls, score: Optional[LiteralScore], ) -> "Optional[FeedbackDict]": if not score: return None return { "id": score.id or "", "forId": score.step_id or "", "value": cast(Literal[0, 1], score.value), "comment": score.comment, } @classmethod def step_to_stepdict(cls, step: LiteralStep) -> "StepDict": metadata = step.metadata or {} input = (step.input or {}).get("content") or ( json.dumps(step.input) if step.input and step.input != {} else "" ) output = (step.output or {}).get("content") or ( json.dumps(step.output) if step.output and step.output != {} else "" ) user_feedback = ( next( ( s for s in step.scores if s.type == "HUMAN" and s.name == "user-feedback" ), None, ) if step.scores else None ) return { "createdAt": step.created_at, "id": step.id or "", "threadId": step.thread_id or "", "parentId": step.parent_id, "feedback": cls.score_to_feedbackdict(user_feedback), "start": step.start_time, "end": step.end_time, "type": step.type or "undefined", "name": step.name or "", "generation": step.generation.to_dict() if step.generation else None, "input": input, "output": output, "showInput": metadata.get("showInput", False), "language": metadata.get("language"), "isError": bool(step.error), "waitForAnswer": metadata.get("waitForAnswer", False), } @classmethod def attachment_to_elementdict(cls, attachment: LiteralAttachment) -> ElementDict: metadata = attachment.metadata or {} return { "chainlitKey": None, "display": metadata.get("display", "side"), "language": metadata.get("language"), "autoPlay": metadata.get("autoPlay", None), "playerConfig": metadata.get("playerConfig", None), "page": metadata.get("page"), "props": metadata.get("props"), "size": metadata.get("size"), "type": metadata.get("type", "file"), "forId": attachment.step_id, "id": attachment.id or "", "mime": attachment.mime, "name": attachment.name or "", "objectKey": attachment.object_key, "url": attachment.url, "threadId": attachment.thread_id, } @classmethod def attachment_to_element( cls, attachment: LiteralAttachment, thread_id: Optional[str] = None ) -> Element: metadata = attachment.metadata or {} element_type = metadata.get("type", "file") element_class = { "file": File, "image": Image, "audio": Audio, "video": Video, "text": Text, "pdf": Pdf, }.get(element_type, Element) assert thread_id or attachment.thread_id element = element_class( name=attachment.name or "", display=metadata.get("display", "side"), language=metadata.get("language"), size=metadata.get("size"), url=attachment.url, mime=attachment.mime, thread_id=thread_id or attachment.thread_id, ) element.id = attachment.id or "" element.for_id = attachment.step_id element.object_key = attachment.object_key return element @classmethod def step_to_step(cls, step: LiteralStep) -> Step: chainlit_step = Step( name=step.name or "", type=cls.steptype_to_steptype(step.type), id=step.id, parent_id=step.parent_id, thread_id=step.thread_id or None, ) chainlit_step.start = step.start_time chainlit_step.end = step.end_time chainlit_step.created_at = step.created_at chainlit_step.input = step.input.get("content", "") if step.input else "" chainlit_step.output = step.output.get("content", "") if step.output else "" chainlit_step.is_error = bool(step.error) chainlit_step.metadata = step.metadata or {} chainlit_step.tags = step.tags chainlit_step.generation = step.generation if step.attachments: chainlit_step.elements = [ cls.attachment_to_element(attachment, chainlit_step.thread_id) for attachment in step.attachments ] return chainlit_step @classmethod def thread_to_threaddict(cls, thread: LiteralThread) -> ThreadDict: return { "id": thread.id, "createdAt": getattr(thread, "created_at", ""), "name": thread.name, "userId": thread.participant_id, "userIdentifier": thread.participant_identifier, "tags": thread.tags, "metadata": thread.metadata, "steps": [cls.step_to_stepdict(step) for step in thread.steps] if thread.steps else [], "elements": [ cls.attachment_to_elementdict(attachment) for step in thread.steps for attachment in step.attachments ] if thread.steps else [], } class LiteralDataLayer(BaseDataLayer): def __init__(self, api_key: str, server: Optional[str]): from literalai import AsyncLiteralClient self.client = AsyncLiteralClient(api_key=api_key, url=server) logger.info("Chainlit data layer initialized") async def build_debug_url(self) -> str: try: project_id = await self.client.api.get_my_project_id() return f"{self.client.api.url}/projects/{project_id}/logs/threads/[thread_id]?currentStepId=[step_id]" except Exception as e: logger.error(f"Error building debug url: {e}") return "" async def get_user(self, identifier: str) -> Optional[PersistedUser]: user = await self.client.api.get_user(identifier=identifier) if not user: return None return PersistedUser( id=user.id or "", identifier=user.identifier or "", metadata=user.metadata, createdAt=user.created_at or "", ) async def create_user(self, user: User) -> Optional[PersistedUser]: _user = await self.client.api.get_user(identifier=user.identifier) if not _user: _user = await self.client.api.create_user( identifier=user.identifier, metadata=user.metadata ) elif _user.id: await self.client.api.update_user(id=_user.id, metadata=user.metadata) return PersistedUser( id=_user.id or "", identifier=_user.identifier or "", metadata=user.metadata, createdAt=_user.created_at or "", ) async def delete_feedback( self, feedback_id: str, ): if feedback_id: await self.client.api.delete_score( id=feedback_id, ) return True return False async def upsert_feedback( self, feedback: Feedback, ): if feedback.id: await self.client.api.update_score( id=feedback.id, update_params={ "comment": feedback.comment, "value": feedback.value, }, ) return feedback.id else: created = await self.client.api.create_score( step_id=feedback.forId, value=feedback.value, comment=feedback.comment, name="user-feedback", type="HUMAN", ) return created.id or "" async def safely_send_steps(self, steps): try: await self.client.api.send_steps(steps) except HTTPStatusError as e: logger.error(f"HTTP Request: error sending steps: {e.response.status_code}") except RequestError as e: logger.error(f"HTTP Request: error for {e.request.url!r}.") @queue_until_user_message() async def create_element(self, element: "Element"): metadata = { "size": element.size, "language": element.language, "display": element.display, "type": element.type, "page": getattr(element, "page", None), "props": getattr(element, "props", None), } if not element.for_id: return object_key = None if not element.url: if element.path: async with aiofiles.open(element.path, "rb") as f: content: Union[bytes, str] = await f.read() elif element.content: content = element.content else: raise ValueError("Either path or content must be provided") uploaded = await self.client.api.upload_file( content=content, mime=element.mime, thread_id=element.thread_id ) object_key = uploaded["object_key"] await self.safely_send_steps( [ { "id": element.for_id, "threadId": element.thread_id, "attachments": [ { "id": element.id, "name": element.name, "metadata": metadata, "mime": element.mime, "url": element.url, "objectKey": object_key, } ], } ] ) async def get_element( self, thread_id: str, element_id: str ) -> Optional["ElementDict"]: attachment = await self.client.api.get_attachment(id=element_id) if not attachment: return None return LiteralToChainlitConverter.attachment_to_elementdict(attachment) @queue_until_user_message() async def delete_element(self, element_id: str, thread_id: Optional[str] = None): await self.client.api.delete_attachment(id=element_id) @queue_until_user_message() async def create_step(self, step_dict: "StepDict"): metadata = dict( step_dict.get("metadata", {}), waitForAnswer=step_dict.get("waitForAnswer"), language=step_dict.get("language"), showInput=step_dict.get("showInput"), ) step: LiteralStepDict = { "createdAt": step_dict.get("createdAt"), "startTime": step_dict.get("start"), "endTime": step_dict.get("end"), "generation": step_dict.get("generation"), "id": step_dict.get("id"), "parentId": step_dict.get("parentId"), "name": step_dict.get("name"), "threadId": step_dict.get("threadId"), "type": step_dict.get("type"), "tags": step_dict.get("tags"), "metadata": metadata, } if step_dict.get("input"): step["input"] = {"content": step_dict.get("input")} if step_dict.get("output"): step["output"] = {"content": step_dict.get("output")} if step_dict.get("isError"): step["error"] = step_dict.get("output") await self.safely_send_steps([step]) @queue_until_user_message() async def update_step(self, step_dict: "StepDict"): await self.create_step(step_dict) @queue_until_user_message() async def delete_step(self, step_id: str): await self.client.api.delete_step(id=step_id) async def get_thread_author(self, thread_id: str) -> str: thread = await self.get_thread(thread_id) if not thread: return "" user_identifier = thread.get("userIdentifier") if not user_identifier: return "" return user_identifier async def delete_thread(self, thread_id: str): await self.client.api.delete_thread(id=thread_id) async def list_threads( self, pagination: "Pagination", filters: "ThreadFilter" ) -> "PaginatedResponse[ThreadDict]": if not filters.userId: raise ValueError("userId is required") literal_filters: LiteralThreadsFilters = [ { "field": "participantId", "operator": "eq", "value": filters.userId, } ] if filters.search: literal_filters.append( { "field": "stepOutput", "operator": "ilike", "value": filters.search, "path": "content", } ) if filters.feedback is not None: literal_filters.append( { "field": "scoreValue", "operator": "eq", "value": filters.feedback, "path": "user-feedback", } ) literal_response = await self.client.api.list_threads( first=pagination.first, after=pagination.cursor, filters=literal_filters, order_by={"column": "createdAt", "direction": "DESC"}, ) chainlit_threads = [ *map(LiteralToChainlitConverter.thread_to_threaddict, literal_response.data) ] return PaginatedResponse( pageInfo=PageInfo( hasNextPage=literal_response.page_info.has_next_page, startCursor=literal_response.page_info.start_cursor, endCursor=literal_response.page_info.end_cursor, ), data=chainlit_threads, ) async def get_thread(self, thread_id: str) -> Optional[ThreadDict]: thread = await self.client.api.get_thread(id=thread_id) if not thread: return None elements: List[ElementDict] = [] steps: List[StepDict] = [] if thread.steps: for step in thread.steps: for attachment in step.attachments: elements.append( LiteralToChainlitConverter.attachment_to_elementdict(attachment) ) chainlit_step = LiteralToChainlitConverter.step_to_step(step) if check_add_step_in_cot(chainlit_step): steps.append( LiteralToChainlitConverter.step_to_stepdict(step) ) # TODO: chainlit_step.to_dict() else: steps.append(stub_step(chainlit_step)) return { "createdAt": thread.created_at or "", "id": thread.id, "name": thread.name or None, "steps": steps, "elements": elements, "metadata": thread.metadata, "userId": thread.participant_id, "userIdentifier": thread.participant_identifier, "tags": thread.tags, } async def update_thread( self, thread_id: str, name: Optional[str] = None, user_id: Optional[str] = None, metadata: Optional[Dict] = None, tags: Optional[List[str]] = None, ): await self.client.api.upsert_thread( id=thread_id, name=name, participant_id=user_id, metadata=metadata, tags=tags, ) async def get_favorite_steps(self, user_id: str) -> List[StepDict]: """noop for literalai""" return [] async def close(self): self.client.flush_and_stop() ================================================ FILE: backend/chainlit/data/sql_alchemy.py ================================================ import json import ssl import uuid from dataclasses import asdict from datetime import datetime from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union import aiofiles import aiohttp from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from chainlit.data.base import BaseDataLayer from chainlit.data.storage_clients.base import BaseStorageClient from chainlit.data.utils import queue_until_user_message from chainlit.element import ElementDict from chainlit.logger import logger from chainlit.step import StepDict from chainlit.types import ( Feedback, FeedbackDict, PageInfo, PaginatedResponse, Pagination, ThreadDict, ThreadFilter, ) from chainlit.user import PersistedUser, User if TYPE_CHECKING: from chainlit.element import Element, ElementDict from chainlit.step import StepDict class SQLAlchemyDataLayer(BaseDataLayer): def __init__( self, conninfo: str, connect_args: Optional[dict[str, Any]] = None, ssl_require: bool = False, storage_provider: Optional[BaseStorageClient] = None, user_thread_limit: Optional[int] = 1000, show_logger: Optional[bool] = False, ): self._conninfo = conninfo self.user_thread_limit = user_thread_limit self.show_logger = show_logger if connect_args is None: connect_args = {} if ssl_require: # Create an SSL context to require an SSL connection ssl_context = ssl.create_default_context() ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE connect_args["ssl"] = ssl_context self.engine: AsyncEngine = create_async_engine( self._conninfo, connect_args=connect_args ) self.async_session = sessionmaker( bind=self.engine, expire_on_commit=False, class_=AsyncSession ) # type: ignore if storage_provider: self.storage_provider: Optional[BaseStorageClient] = storage_provider if self.show_logger: logger.info("SQLAlchemyDataLayer storage client initialized") else: self.storage_provider = None logger.warning( "SQLAlchemyDataLayer storage client is not initialized and elements will not be persisted!" ) async def build_debug_url(self) -> str: return "" ###### SQL Helpers ###### async def execute_sql( self, query: str, parameters: dict ) -> Union[List[Dict[str, Any]], int, None]: parameterized_query = text(query) async with self.async_session() as session: try: await session.begin() result = await session.execute(parameterized_query, parameters) await session.commit() if result.returns_rows: json_result = [dict(row._mapping) for row in result.fetchall()] clean_json_result = self.clean_result(json_result) assert isinstance(clean_json_result, list) or isinstance( clean_json_result, int ) return clean_json_result else: return result.rowcount except SQLAlchemyError as e: await session.rollback() logger.warning(f"An error occurred: {e}") return None except Exception as e: await session.rollback() logger.warning(f"An unexpected error occurred: {e}") return None async def get_current_timestamp(self) -> str: return datetime.now().isoformat() + "Z" def clean_result(self, obj): """Recursively change UUID -> str and serialize dictionaries""" if isinstance(obj, dict): return {k: self.clean_result(v) for k, v in obj.items()} elif isinstance(obj, list): return [self.clean_result(item) for item in obj] elif isinstance(obj, uuid.UUID): return str(obj) return obj ###### User ###### async def get_user(self, identifier: str) -> Optional[PersistedUser]: if self.show_logger: logger.info(f"SQLAlchemy: get_user, identifier={identifier}") query = "SELECT * FROM users WHERE identifier = :identifier" parameters = {"identifier": identifier} result = await self.execute_sql(query=query, parameters=parameters) if result and isinstance(result, list): user_data = result[0] # SQLite returns JSON as string, we most convert it. (#1137) metadata = user_data.get("metadata", {}) if isinstance(metadata, str): metadata = json.loads(metadata) assert isinstance(metadata, dict) assert isinstance(user_data["id"], str) assert isinstance(user_data["identifier"], str) assert isinstance(user_data["createdAt"], str) return PersistedUser( id=user_data["id"], identifier=user_data["identifier"], createdAt=user_data["createdAt"], metadata=metadata, ) return None async def _get_user_identifer_by_id(self, user_id: str) -> str: if self.show_logger: logger.info(f"SQLAlchemy: _get_user_identifer_by_id, user_id={user_id}") query = "SELECT identifier FROM users WHERE id = :user_id" parameters = {"user_id": user_id} result = await self.execute_sql(query=query, parameters=parameters) assert result assert isinstance(result, list) return result[0]["identifier"] async def _get_user_id_by_thread(self, thread_id: str) -> Optional[str]: if self.show_logger: logger.info(f"SQLAlchemy: _get_user_id_by_thread, thread_id={thread_id}") query = """SELECT "userId" FROM threads WHERE id = :thread_id""" parameters = {"thread_id": thread_id} result = await self.execute_sql(query=query, parameters=parameters) if result: assert isinstance(result, list) return result[0]["userId"] return None async def create_user(self, user: User) -> Optional[PersistedUser]: if self.show_logger: logger.info(f"SQLAlchemy: create_user, user_identifier={user.identifier}") existing_user: Optional[PersistedUser] = await self.get_user(user.identifier) user_dict: Dict[str, Any] = { "identifier": str(user.identifier), "metadata": json.dumps(user.metadata) or {}, } if not existing_user: # create the user if self.show_logger: logger.info("SQLAlchemy: create_user, creating the user") user_dict["id"] = str(uuid.uuid4()) user_dict["createdAt"] = await self.get_current_timestamp() query = """INSERT INTO users ("id", "identifier", "createdAt", "metadata") VALUES (:id, :identifier, :createdAt, :metadata)""" await self.execute_sql(query=query, parameters=user_dict) else: # update the user if self.show_logger: logger.info("SQLAlchemy: update user metadata") query = """UPDATE users SET "metadata" = :metadata WHERE "identifier" = :identifier""" await self.execute_sql( query=query, parameters=user_dict ) # We want to update the metadata return await self.get_user(user.identifier) ###### Threads ###### async def get_thread_author(self, thread_id: str) -> str: if self.show_logger: logger.info(f"SQLAlchemy: get_thread_author, thread_id={thread_id}") query = """SELECT "userIdentifier" FROM threads WHERE "id" = :id""" parameters = {"id": thread_id} result = await self.execute_sql(query=query, parameters=parameters) if isinstance(result, list) and result: author_identifier = result[0].get("userIdentifier") if author_identifier is not None: return author_identifier raise ValueError(f"Author not found for thread_id {thread_id}") async def get_thread(self, thread_id: str) -> Optional[ThreadDict]: if self.show_logger: logger.info(f"SQLAlchemy: get_thread, thread_id={thread_id}") user_threads: Optional[List[ThreadDict]] = await self.get_all_user_threads( thread_id=thread_id ) if user_threads: return user_threads[0] else: return None async def update_thread( self, thread_id: str, name: Optional[str] = None, user_id: Optional[str] = None, metadata: Optional[Dict] = None, tags: Optional[List[str]] = None, ): if self.show_logger: logger.info(f"SQLAlchemy: update_thread, thread_id={thread_id}") user_identifier = None if user_id: user_identifier = await self._get_user_identifer_by_id(user_id) if metadata is not None: existing = await self.execute_sql( query='SELECT "metadata" FROM threads WHERE "id" = :id', parameters={"id": thread_id}, ) base = {} if isinstance(existing, list) and existing: raw = existing[0].get("metadata") or {} if isinstance(raw, str): try: base = json.loads(raw) except json.JSONDecodeError: base = {} elif isinstance(raw, dict): base = raw incoming = {k: v for k, v in metadata.items() if v is not None} metadata = {**base, **incoming} name_value = name if name_value is None and metadata: name_value = metadata.get("name") created_at_value = ( await self.get_current_timestamp() if metadata is None else None ) data = { "id": thread_id, "createdAt": created_at_value, "name": name_value, "userId": user_id, "userIdentifier": user_identifier, "tags": tags, "metadata": json.dumps(metadata) if metadata else None, } parameters = { key: value for key, value in data.items() if value is not None } # Remove keys with None values columns = ", ".join(f'"{key}"' for key in parameters.keys()) values = ", ".join(f":{key}" for key in parameters.keys()) updates = ", ".join( f'"{key}" = EXCLUDED."{key}"' for key in parameters.keys() if key != "id" ) query = f""" INSERT INTO threads ({columns}) VALUES ({values}) ON CONFLICT ("id") DO UPDATE SET {updates}; """ await self.execute_sql(query=query, parameters=parameters) async def delete_thread(self, thread_id: str): if self.show_logger: logger.info(f"SQLAlchemy: delete_thread, thread_id={thread_id}") elements_query = """SELECT * FROM elements WHERE "threadId" = :id""" elements = await self.execute_sql(elements_query, {"id": thread_id}) if self.storage_provider is not None and isinstance(elements, list): for elem in filter(lambda x: x["objectKey"], elements): await self.storage_provider.delete_file(object_key=elem["objectKey"]) # Delete feedbacks/elements/steps/thread feedbacks_query = """DELETE FROM feedbacks WHERE "forId" IN (SELECT "id" FROM steps WHERE "threadId" = :id)""" elements_query = """DELETE FROM elements WHERE "threadId" = :id""" steps_query = """DELETE FROM steps WHERE "threadId" = :id""" thread_query = """DELETE FROM threads WHERE "id" = :id""" parameters = {"id": thread_id} await self.execute_sql(query=feedbacks_query, parameters=parameters) await self.execute_sql(query=elements_query, parameters=parameters) await self.execute_sql(query=steps_query, parameters=parameters) await self.execute_sql(query=thread_query, parameters=parameters) async def list_threads( self, pagination: Pagination, filters: ThreadFilter ) -> PaginatedResponse: if self.show_logger: logger.info( f"SQLAlchemy: list_threads, pagination={pagination}, filters={filters}" ) if not filters.userId: raise ValueError("userId is required") all_user_threads: List[ThreadDict] = ( await self.get_all_user_threads(user_id=filters.userId) or [] ) search_keyword = filters.search.lower() if filters.search else None feedback_value = int(filters.feedback) if filters.feedback else None filtered_threads = [] for thread in all_user_threads: keyword_match = True feedback_match = True if search_keyword or feedback_value is not None: if search_keyword: keyword_match = any( search_keyword in step["output"].lower() for step in thread["steps"] if "output" in step ) if feedback_value is not None: feedback_match = False # Assume no match until found for step in thread["steps"]: feedback = step.get("feedback") if feedback and feedback.get("value") == feedback_value: feedback_match = True break if keyword_match and feedback_match: filtered_threads.append(thread) start = 0 if pagination.cursor: for i, thread in enumerate(filtered_threads): if ( thread["id"] == pagination.cursor ): # Find the start index using pagination.cursor start = i + 1 break end = start + pagination.first paginated_threads = filtered_threads[start:end] or [] has_next_page = len(filtered_threads) > end start_cursor = paginated_threads[0]["id"] if paginated_threads else None end_cursor = paginated_threads[-1]["id"] if paginated_threads else None return PaginatedResponse( pageInfo=PageInfo( hasNextPage=has_next_page, startCursor=start_cursor, endCursor=end_cursor, ), data=paginated_threads, ) ###### Steps ###### @queue_until_user_message() async def create_step(self, step_dict: "StepDict"): await self.update_thread(step_dict["threadId"]) if self.show_logger: logger.info(f"SQLAlchemy: create_step, step_id={step_dict.get('id')}") step_dict["showInput"] = ( str(step_dict.get("showInput", "")).lower() if "showInput" in step_dict else None ) parameters = { key: value for key, value in step_dict.items() if value is not None and not (isinstance(value, dict) and not value) } parameters["metadata"] = json.dumps(step_dict.get("metadata", {})) parameters["generation"] = json.dumps(step_dict.get("generation", {})) columns = ", ".join(f'"{key}"' for key in parameters.keys()) values = ", ".join(f":{key}" for key in parameters.keys()) updates = ", ".join( f'"{key}" = :{key}' for key in parameters.keys() if key != "id" ) query = f""" INSERT INTO steps ({columns}) VALUES ({values}) ON CONFLICT (id) DO UPDATE SET {updates}; """ await self.execute_sql(query=query, parameters=parameters) @queue_until_user_message() async def update_step(self, step_dict: "StepDict"): if self.show_logger: logger.info(f"SQLAlchemy: update_step, step_id={step_dict.get('id')}") await self.create_step(step_dict) @queue_until_user_message() async def delete_step(self, step_id: str): if self.show_logger: logger.info(f"SQLAlchemy: delete_step, step_id={step_id}") # Delete feedbacks/elements/steps feedbacks_query = """DELETE FROM feedbacks WHERE "forId" = :id""" elements_query = """DELETE FROM elements WHERE "forId" = :id""" steps_query = """DELETE FROM steps WHERE "id" = :id""" parameters = {"id": step_id} await self.execute_sql(query=feedbacks_query, parameters=parameters) await self.execute_sql(query=elements_query, parameters=parameters) await self.execute_sql(query=steps_query, parameters=parameters) async def get_step(self, step_id: str) -> Optional["StepDict"]: if self.show_logger: logger.info(f"SQLAlchemy: get_step, step_id={step_id}") steps_feedbacks_query = """ SELECT s."id" AS step_id, s."name" AS step_name, s."type" AS step_type, s."threadId" AS step_threadid, s."parentId" AS step_parentid, s."streaming" AS step_streaming, s."waitForAnswer" AS step_waitforanswer, s."isError" AS step_iserror, s."metadata" AS step_metadata, s."tags" AS step_tags, s."input" AS step_input, s."output" AS step_output, s."createdAt" AS step_createdat, s."start" AS step_start, s."end" AS step_end, s."generation" AS step_generation, s."showInput" AS step_showinput, s."language" AS step_language, f."value" AS feedback_value, f."comment" AS feedback_comment, f."id" AS feedback_id FROM steps s LEFT JOIN feedbacks f ON s."id" = f."forId" WHERE s."id" = :step_id """ steps_feedbacks = await self.execute_sql( query=steps_feedbacks_query, parameters={"step_id": step_id} ) if not isinstance(steps_feedbacks, list) or not steps_feedbacks: return None step_feedback = steps_feedbacks[0] feedback = None if step_feedback["feedback_value"] is not None: feedback = FeedbackDict( forId=step_feedback["step_id"], id=step_feedback.get("feedback_id"), value=step_feedback["feedback_value"], comment=step_feedback.get("feedback_comment"), ) return StepDict( id=step_feedback["step_id"], name=step_feedback["step_name"], type=step_feedback["step_type"], threadId=step_feedback.get("step_threadid", ""), parentId=step_feedback.get("step_parentid"), streaming=step_feedback.get("step_streaming", False), waitForAnswer=step_feedback.get("step_waitforanswer"), isError=step_feedback.get("step_iserror"), metadata=( step_feedback["step_metadata"] if step_feedback.get("step_metadata") is not None else {} ), tags=step_feedback.get("step_tags"), input=( step_feedback.get("step_input", "") if step_feedback.get("step_showinput") not in [None, "false"] else "" ), output=step_feedback.get("step_output", ""), createdAt=step_feedback.get("step_createdat"), start=step_feedback.get("step_start"), end=step_feedback.get("step_end"), generation=step_feedback.get("step_generation"), showInput=step_feedback.get("step_showinput"), language=step_feedback.get("step_language"), feedback=feedback, ) ###### Feedback ###### async def upsert_feedback(self, feedback: Feedback) -> str: if self.show_logger: logger.info(f"SQLAlchemy: upsert_feedback, feedback_id={feedback.id}") feedback.id = feedback.id or str(uuid.uuid4()) feedback_dict = asdict(feedback) parameters = { key: value for key, value in feedback_dict.items() if value is not None } columns = ", ".join(f'"{key}"' for key in parameters.keys()) values = ", ".join(f":{key}" for key in parameters.keys()) updates = ", ".join( f'"{key}" = :{key}' for key in parameters.keys() if key != "id" ) query = f""" INSERT INTO feedbacks ({columns}) VALUES ({values}) ON CONFLICT (id) DO UPDATE SET {updates}; """ await self.execute_sql(query=query, parameters=parameters) return feedback.id async def delete_feedback(self, feedback_id: str) -> bool: if self.show_logger: logger.info(f"SQLAlchemy: delete_feedback, feedback_id={feedback_id}") query = """DELETE FROM feedbacks WHERE "id" = :feedback_id""" parameters = {"feedback_id": feedback_id} await self.execute_sql(query=query, parameters=parameters) return True ###### Elements ###### async def get_element( self, thread_id: str, element_id: str ) -> Optional["ElementDict"]: if self.show_logger: logger.info( f"SQLAlchemy: get_element, thread_id={thread_id}, element_id={element_id}" ) query = """SELECT * FROM elements WHERE "threadId" = :thread_id AND "id" = :element_id""" parameters = {"thread_id": thread_id, "element_id": element_id} element: Union[List[Dict[str, Any]], int, None] = await self.execute_sql( query=query, parameters=parameters ) if isinstance(element, list) and element: element_dict: Dict[str, Any] = element[0] return ElementDict( id=element_dict["id"], threadId=element_dict.get("threadId"), type=element_dict["type"], chainlitKey=element_dict.get("chainlitKey"), url=element_dict.get("url"), objectKey=element_dict.get("objectKey"), name=element_dict["name"], props=json.loads(element_dict.get("props", "{}")), display=element_dict["display"], size=element_dict.get("size"), language=element_dict.get("language"), page=element_dict.get("page"), autoPlay=element_dict.get("autoPlay"), playerConfig=element_dict.get("playerConfig"), forId=element_dict.get("forId"), mime=element_dict.get("mime"), ) else: return None @queue_until_user_message() async def create_element(self, element: "Element"): if self.show_logger: logger.info(f"SQLAlchemy: create_element, element_id = {element.id}") if not self.storage_provider: logger.warning( "SQLAlchemy: create_element error. No blob_storage_client is configured!" ) return if not element.for_id: return content: Optional[Union[bytes, str]] = None if element.path: async with aiofiles.open(element.path, "rb") as f: content = await f.read() elif element.url: async with aiohttp.ClientSession() as session: async with session.get(element.url) as response: if response.status == 200: content = await response.read() else: content = None elif element.content: content = element.content else: raise ValueError("Element url, path or content must be provided") if content is None: raise ValueError("Content is None, cannot upload file") user_id: str = await self._get_user_id_by_thread(element.thread_id) or "unknown" file_object_key = f"{user_id}/{element.id}" + ( f"/{element.name}" if element.name else "" ) if not element.mime: element.mime = "application/octet-stream" uploaded_file = await self.storage_provider.upload_file( object_key=file_object_key, data=content, mime=element.mime, overwrite=True ) if not uploaded_file: raise ValueError( "SQLAlchemy Error: create_element, Failed to persist data in storage_provider" ) element_dict: ElementDict = element.to_dict() element_dict["url"] = uploaded_file.get("url") element_dict["objectKey"] = uploaded_file.get("object_key") element_dict_cleaned = {k: v for k, v in element_dict.items() if v is not None} if "props" in element_dict_cleaned: element_dict_cleaned["props"] = json.dumps(element_dict_cleaned["props"]) columns = ", ".join(f'"{column}"' for column in element_dict_cleaned.keys()) placeholders = ", ".join(f":{column}" for column in element_dict_cleaned.keys()) updates = ", ".join( f'"{column}" = :{column}' for column in element_dict_cleaned.keys() if column != "id" ) query = f"INSERT INTO elements ({columns}) VALUES ({placeholders}) ON CONFLICT (id) DO UPDATE SET {updates};" await self.execute_sql(query=query, parameters=element_dict_cleaned) @queue_until_user_message() async def delete_element(self, element_id: str, thread_id: Optional[str] = None): if self.show_logger: logger.info(f"SQLAlchemy: delete_element, element_id={element_id}") query = """SELECT * FROM elements WHERE "id" = :id""" elements = await self.execute_sql(query, {"id": element_id}) if ( self.storage_provider is not None and isinstance(elements, list) and len(elements) > 0 and elements[0]["objectKey"] ): await self.storage_provider.delete_file(object_key=elements[0]["objectKey"]) query = """DELETE FROM elements WHERE "id" = :id""" parameters = {"id": element_id} await self.execute_sql(query=query, parameters=parameters) async def get_all_user_threads( self, user_id: Optional[str] = None, thread_id: Optional[str] = None ) -> Optional[List[ThreadDict]]: """Fetch all user threads up to self.user_thread_limit, or one thread by id if thread_id is provided.""" if self.show_logger: logger.info("SQLAlchemy: get_all_user_threads") user_threads_query = """ SELECT t."id" AS thread_id, t."createdAt" AS thread_createdat, t."name" AS thread_name, t."userId" AS user_id, t."userIdentifier" AS user_identifier, t."tags" AS thread_tags, t."metadata" AS thread_metadata, MAX(s."createdAt") AS updatedAt FROM threads t LEFT JOIN steps s ON t."id" = s."threadId" WHERE t."userId" = :user_id OR t."id" = :thread_id GROUP BY t."id", t."createdAt", t."name", t."userId", t."userIdentifier", t."tags", t."metadata" ORDER BY updatedAt DESC NULLS LAST LIMIT :limit """ user_threads = await self.execute_sql( query=user_threads_query, parameters={ "user_id": user_id, "limit": self.user_thread_limit, "thread_id": thread_id, }, ) if not isinstance(user_threads, list): return None if not user_threads: return [] else: thread_ids = ( "('" + "','".join(map(str, [thread["thread_id"] for thread in user_threads])) + "')" ) steps_feedbacks_query = f""" SELECT s."id" AS step_id, s."name" AS step_name, s."type" AS step_type, s."threadId" AS step_threadid, s."parentId" AS step_parentid, s."streaming" AS step_streaming, s."waitForAnswer" AS step_waitforanswer, s."isError" AS step_iserror, s."metadata" AS step_metadata, s."tags" AS step_tags, s."input" AS step_input, s."output" AS step_output, s."createdAt" AS step_createdat, s."start" AS step_start, s."end" AS step_end, s."generation" AS step_generation, s."showInput" AS step_showinput, s."language" AS step_language, f."value" AS feedback_value, f."comment" AS feedback_comment, f."id" AS feedback_id FROM steps s LEFT JOIN feedbacks f ON s."id" = f."forId" WHERE s."threadId" IN {thread_ids} ORDER BY s."createdAt" ASC """ steps_feedbacks = await self.execute_sql( query=steps_feedbacks_query, parameters={} ) elements_query = f""" SELECT e."id" AS element_id, e."threadId" as element_threadid, e."type" AS element_type, e."chainlitKey" AS element_chainlitkey, e."url" AS element_url, e."objectKey" as element_objectkey, e."name" AS element_name, e."display" AS element_display, e."size" AS element_size, e."language" AS element_language, e."page" AS element_page, e."forId" AS element_forid, e."mime" AS element_mime, e."props" AS props FROM elements e WHERE e."threadId" IN {thread_ids} """ elements = await self.execute_sql(query=elements_query, parameters={}) thread_dicts = {} for thread in user_threads: thread_id = thread["thread_id"] if thread_id is not None: thread_dicts[thread_id] = ThreadDict( id=thread_id, createdAt=thread["thread_createdat"], name=thread["thread_name"], userId=thread["user_id"], userIdentifier=thread["user_identifier"], tags=thread["thread_tags"], metadata=thread["thread_metadata"], steps=[], elements=[], ) # Process steps_feedbacks to populate the steps in the corresponding ThreadDict if isinstance(steps_feedbacks, list): for step_feedback in steps_feedbacks: thread_id = step_feedback["step_threadid"] if thread_id is not None: feedback = None if step_feedback["feedback_value"] is not None: feedback = FeedbackDict( forId=step_feedback["step_id"], id=step_feedback.get("feedback_id"), value=step_feedback["feedback_value"], comment=step_feedback.get("feedback_comment"), ) step_dict = StepDict( id=step_feedback["step_id"], name=step_feedback["step_name"], type=step_feedback["step_type"], threadId=thread_id, parentId=step_feedback.get("step_parentid"), streaming=step_feedback.get("step_streaming", False), waitForAnswer=step_feedback.get("step_waitforanswer"), isError=step_feedback.get("step_iserror"), metadata=( step_feedback["step_metadata"] if step_feedback.get("step_metadata") is not None else {} ), tags=step_feedback.get("step_tags"), input=( step_feedback.get("step_input", "") if step_feedback.get("step_showinput") not in [None, "false"] else "" ), output=step_feedback.get("step_output", ""), createdAt=step_feedback.get("step_createdat"), start=step_feedback.get("step_start"), end=step_feedback.get("step_end"), generation=step_feedback.get("step_generation"), showInput=step_feedback.get("step_showinput"), language=step_feedback.get("step_language"), feedback=feedback, ) # Append the step to the steps list of the corresponding ThreadDict thread_dicts[thread_id]["steps"].append(step_dict) if isinstance(elements, list): for element in elements: thread_id = element["element_threadid"] if thread_id is not None: element_url: str | None = None object_key_val = element.get("element_objectkey") if ( self.storage_provider is not None and isinstance(object_key_val, str) and object_key_val.strip() ): try: element_url = await self.storage_provider.get_read_url( object_key=object_key_val, ) except Exception as e: logger.warning( f"Failed to get read URL for object_key '{object_key_val}': {e}. Falling back to stored URL." ) element_url = element.get("element_url") else: element_url = element.get("element_url") element_dict = ElementDict( id=element["element_id"], threadId=thread_id, type=element["element_type"], chainlitKey=element.get("element_chainlitkey"), url=element_url, objectKey=element.get("element_objectkey"), name=element["element_name"], display=element["element_display"], size=element.get("element_size"), language=element.get("element_language"), autoPlay=element.get("element_autoPlay"), playerConfig=element.get("element_playerconfig"), page=element.get("element_page"), props=element.get("props", "{}"), forId=element.get("element_forid"), mime=element.get("element_mime"), ) thread_dicts[thread_id]["elements"].append(element_dict) # type: ignore return list(thread_dicts.values()) async def get_favorite_steps(self, user_id: str) -> List[StepDict]: if self.show_logger: logger.info(f"SQLAlchemy: get_favorite_steps, user_id={user_id}") query = """ SELECT s."id" AS step_id, s."name" AS step_name, s."type" AS step_type, s."threadId" AS step_threadid, s."parentId" AS step_parentid, s."streaming" AS step_streaming, s."waitForAnswer" AS step_waitforanswer, s."isError" AS step_iserror, s."metadata" AS step_metadata, s."tags" AS step_tags, s."input" AS step_input, s."output" AS step_output, s."createdAt" AS step_createdat, s."start" AS step_start, s."end" AS step_end, s."generation" AS step_generation, s."showInput" AS step_showinput, s."language" AS step_language FROM steps s JOIN threads t ON s."threadId" = t.id WHERE t."userId" = :user_id AND s."metadata" LIKE :favorite_pattern ORDER BY s."createdAt" DESC \ """ result = await self.execute_sql( query, {"user_id": user_id, "favorite_pattern": '%"favorite": true%'} ) steps = [] if isinstance(result, list): for row in result: metadata_raw = row["step_metadata"] meta_dict = {} if isinstance(metadata_raw, str): try: meta_dict = json.loads(metadata_raw) except Exception: pass elif isinstance(metadata_raw, dict): meta_dict = metadata_raw if meta_dict.get("favorite"): steps.append( StepDict( id=row["step_id"], name=row["step_name"], type=row["step_type"], threadId=row["step_threadid"], parentId=row["step_parentid"], streaming=row.get("step_streaming", False), waitForAnswer=row.get("step_waitforanswer"), isError=row.get("step_iserror"), metadata=meta_dict, tags=row.get("step_tags"), input=( row.get("step_input", "") if row.get("step_showinput") not in [None, "false"] else "" ), output=row.get("step_output", ""), createdAt=row.get("step_createdat"), start=row.get("step_start"), end=row.get("step_end"), generation=row.get("step_generation"), showInput=row.get("step_showinput"), language=row.get("step_language"), feedback=None, ) ) return steps async def close(self) -> None: if self.storage_provider: await self.storage_provider.close() await self.engine.dispose() ================================================ FILE: backend/chainlit/data/storage_clients/__init__.py ================================================ ================================================ FILE: backend/chainlit/data/storage_clients/azure.py ================================================ from typing import TYPE_CHECKING, Any, Dict, Optional, Union from azure.storage.filedatalake import ( ContentSettings, DataLakeFileClient, DataLakeServiceClient, FileSystemClient, ) from chainlit.data.storage_clients.base import BaseStorageClient from chainlit.logger import logger if TYPE_CHECKING: from azure.core.credentials import ( AzureNamedKeyCredential, AzureSasCredential, TokenCredential, ) class AzureStorageClient(BaseStorageClient): """ Class to enable Azure Data Lake Storage (ADLS) Gen2 parms: account_url: "https://.dfs.core.windows.net" credential: Access credential (AzureKeyCredential) sas_token: Optionally include SAS token to append to urls """ def __init__( self, account_url: str, container: str, credential: Optional[ Union[ str, Dict[str, str], "AzureNamedKeyCredential", "AzureSasCredential", "TokenCredential", ] ], sas_token: Optional[str] = None, ): try: self.data_lake_client = DataLakeServiceClient( account_url=account_url, credential=credential ) self.container_client: FileSystemClient = ( self.data_lake_client.get_file_system_client(file_system=container) ) self.sas_token = sas_token logger.info("AzureStorageClient initialized") except Exception as e: logger.warning(f"AzureStorageClient initialization error: {e}") async def upload_file( self, object_key: str, data: Union[bytes, str], mime: str = "application/octet-stream", overwrite: bool = True, content_disposition: str | None = None, ) -> Dict[str, Any]: try: file_client: DataLakeFileClient = self.container_client.get_file_client( object_key ) content_settings = ContentSettings( content_type=mime, content_disposition=content_disposition ) file_client.upload_data( data, overwrite=overwrite, content_settings=content_settings ) url = ( f"{file_client.url}{self.sas_token}" if self.sas_token else file_client.url ) return {"object_key": object_key, "url": url} except Exception as e: logger.warning(f"AzureStorageClient, upload_file error: {e}") return {} async def close(self) -> None: self.container_client.close() self.data_lake_client.close() ================================================ FILE: backend/chainlit/data/storage_clients/azure_blob.py ================================================ from datetime import datetime, timedelta, timezone from typing import Any, Dict, Union from azure.storage.blob import BlobSasPermissions, ContentSettings, generate_blob_sas from azure.storage.blob.aio import BlobServiceClient as AsyncBlobServiceClient from chainlit.data.storage_clients.base import BaseStorageClient, storage_expiry_time from chainlit.logger import logger class AzureBlobStorageClient(BaseStorageClient): def __init__(self, container_name: str, storage_account: str, storage_key: str): self.container_name = container_name self.storage_account = storage_account self.storage_key = storage_key connection_string = ( f"DefaultEndpointsProtocol=https;" f"AccountName={storage_account};" f"AccountKey={storage_key};" f"EndpointSuffix=core.windows.net" ) self.service_client = AsyncBlobServiceClient.from_connection_string( connection_string ) self.container_client = self.service_client.get_container_client( self.container_name ) logger.info("AzureBlobStorageClient initialized") async def get_read_url(self, object_key: str) -> str: if not self.storage_key: raise Exception("Not using Azure Storage") sas_permissions = BlobSasPermissions(read=True) start_time = datetime.now(tz=timezone.utc) expiry_time = start_time + timedelta(seconds=storage_expiry_time) sas_token = generate_blob_sas( account_name=self.storage_account, container_name=self.container_name, blob_name=object_key, account_key=self.storage_key, permission=sas_permissions, start=start_time, expiry=expiry_time, ) return f"https://{self.storage_account}.blob.core.windows.net/{self.container_name}/{object_key}?{sas_token}" async def upload_file( self, object_key: str, data: Union[bytes, str], mime: str = "application/octet-stream", overwrite: bool = True, content_disposition: str | None = None, ) -> Dict[str, Any]: try: blob_client = self.container_client.get_blob_client(object_key) if isinstance(data, str): data = data.encode("utf-8") content_settings = ContentSettings( content_type=mime, content_disposition=content_disposition ) await blob_client.upload_blob( data, overwrite=overwrite, content_settings=content_settings ) properties = await blob_client.get_blob_properties() return { "path": object_key, "object_key": object_key, "url": await self.get_read_url(object_key), "size": properties.size, "last_modified": properties.last_modified, "etag": properties.etag, "content_type": properties.content_settings.content_type, } except Exception as e: raise Exception(f"Failed to upload file to Azure Blob Storage: {e!s}") async def delete_file(self, object_key: str) -> bool: try: blob_client = self.container_client.get_blob_client(blob=object_key) await blob_client.delete_blob() return True except Exception as e: logger.warning(f"AzureBlobStorageClient, delete_file error: {e}") return False async def close(self) -> None: await self.container_client.close() await self.service_client.close() ================================================ FILE: backend/chainlit/data/storage_clients/base.py ================================================ import os from abc import ABC, abstractmethod from typing import Any, Dict, Union storage_expiry_time = int(os.getenv("STORAGE_EXPIRY_TIME", 3600)) class BaseStorageClient(ABC): """Base class for non-text data persistence like Azure Data Lake, S3, Google Storage, etc.""" @abstractmethod async def upload_file( self, object_key: str, data: Union[bytes, str], mime: str = "application/octet-stream", overwrite: bool = True, content_disposition: str | None = None, ) -> Dict[str, Any]: pass @abstractmethod async def delete_file(self, object_key: str) -> bool: pass @abstractmethod async def get_read_url(self, object_key: str) -> str: pass @abstractmethod async def close(self) -> None: pass ================================================ FILE: backend/chainlit/data/storage_clients/gcs.py ================================================ from typing import Any, Dict, Optional, Union from google.auth import default from google.cloud import storage # type: ignore from google.oauth2 import service_account from chainlit import make_async from chainlit.data.storage_clients.base import BaseStorageClient, storage_expiry_time from chainlit.logger import logger class GCSStorageClient(BaseStorageClient): def __init__( self, bucket_name: str, project_id: Optional[str] = None, client_email: Optional[str] = None, private_key: Optional[str] = None, ): if client_email and private_key and project_id: # Go to IAM & Admin, click on Service Accounts, and generate a new JSON key logger.info("Using Private Key from Environment Variable") credentials = service_account.Credentials.from_service_account_info( { "type": "service_account", "project_id": project_id, "private_key": private_key, "client_email": client_email, "token_uri": "https://oauth2.googleapis.com/token", } ) else: # Application Default Credentials (e.g. in Google Cloud Run) logger.info("Using Application Default Credentials.") credentials, default_project_id = default() if not project_id: project_id = default_project_id self.client = storage.Client(project=project_id, credentials=credentials) self.bucket = self.client.bucket(bucket_name) logger.info("GCSStorageClient initialized") def sync_get_read_url(self, object_key: str) -> str: return self.bucket.blob(object_key).generate_signed_url( version="v4", expiration=storage_expiry_time, method="GET" ) async def get_read_url(self, object_key: str) -> str: return await make_async(self.sync_get_read_url)(object_key) def sync_upload_file( self, object_key: str, data: Union[bytes, str], mime: str = "application/octet-stream", overwrite: bool = True, ) -> Dict[str, Any]: try: blob = self.bucket.blob(object_key) if not overwrite and blob.exists(): raise Exception( f"File {object_key} already exists and overwrite is False" ) if isinstance(data, str): data = data.encode("utf-8") blob.upload_from_string(data, content_type=mime) # Return signed URL return { "object_key": object_key, "url": self.sync_get_read_url(object_key), } except Exception as e: raise Exception(f"Failed to upload file to GCS: {e!s}") async def upload_file( self, object_key: str, data: Union[bytes, str], mime: str = "application/octet-stream", overwrite: bool = True, content_disposition: str | None = None, ) -> Dict[str, Any]: return await make_async(self.sync_upload_file)( object_key, data, mime, overwrite ) def sync_delete_file(self, object_key: str) -> bool: try: self.bucket.blob(object_key).delete() return True except Exception as e: logger.warning(f"GCSStorageClient, delete_file error: {e}") return False async def delete_file(self, object_key: str) -> bool: return await make_async(self.sync_delete_file)(object_key) async def close(self) -> None: self.client.close() ================================================ FILE: backend/chainlit/data/storage_clients/s3.py ================================================ import os from typing import Any, Dict, Union import boto3 # type: ignore from chainlit import make_async from chainlit.data.storage_clients.base import BaseStorageClient, storage_expiry_time from chainlit.logger import logger class S3StorageClient(BaseStorageClient): """ Class to enable Amazon S3 storage provider """ def __init__(self, bucket: str, **kwargs: Any): try: self.bucket = bucket self.client = boto3.client("s3", **kwargs) logger.info("S3StorageClient initialized") except Exception as e: logger.warning(f"S3StorageClient initialization error: {e}") def sync_get_read_url(self, object_key: str) -> str: try: url = self.client.generate_presigned_url( "get_object", Params={"Bucket": self.bucket, "Key": object_key}, ExpiresIn=storage_expiry_time, ) return url except Exception as e: logger.warning(f"S3StorageClient, get_read_url error: {e}") return object_key async def get_read_url(self, object_key: str) -> str: return await make_async(self.sync_get_read_url)(object_key) def sync_upload_file( self, object_key: str, data: Union[bytes, str], mime: str = "application/octet-stream", overwrite: bool = True, content_disposition: str | None = None, ) -> Dict[str, Any]: try: if content_disposition is not None: self.client.put_object( Bucket=self.bucket, Key=object_key, Body=data, ContentType=mime, ContentDisposition=content_disposition, ) else: self.client.put_object( Bucket=self.bucket, Key=object_key, Body=data, ContentType=mime ) endpoint = os.environ.get("DEV_AWS_ENDPOINT", "amazonaws.com") url = f"https://{self.bucket}.s3.{endpoint}/{object_key}" return {"object_key": object_key, "url": url} except Exception as e: logger.warning(f"S3StorageClient, upload_file error: {e}") return {} async def upload_file( self, object_key: str, data: Union[bytes, str], mime: str = "application/octet-stream", overwrite: bool = True, content_disposition: str | None = None, ) -> Dict[str, Any]: return await make_async(self.sync_upload_file)( object_key, data, mime, overwrite, content_disposition ) def sync_delete_file(self, object_key: str) -> bool: try: self.client.delete_object(Bucket=self.bucket, Key=object_key) return True except Exception as e: logger.warning(f"S3StorageClient, delete_file error: {e}") return False async def delete_file(self, object_key: str) -> bool: return await make_async(self.sync_delete_file)(object_key) async def close(self) -> None: await self.client.close() ================================================ FILE: backend/chainlit/data/utils.py ================================================ import functools from collections import deque from chainlit.context import context from chainlit.session import WebsocketSession def queue_until_user_message(): def decorator(method): @functools.wraps(method) async def wrapper(self, *args, **kwargs): if ( isinstance(context.session, WebsocketSession) and not context.session.has_first_interaction ): # Queue the method invocation waiting for the first user message queues = context.session.thread_queues method_name = method.__name__ if method_name not in queues: queues[method_name] = deque() queues[method_name].append((method, self, args, kwargs)) else: # Otherwise, Execute the method immediately return await method(self, *args, **kwargs) return wrapper return decorator ================================================ FILE: backend/chainlit/discord/__init__.py ================================================ import importlib.util if importlib.util.find_spec("discord") is None: raise ValueError( "The discord package is required to integrate Chainlit with a Discord app. Run `pip install discord --upgrade`" ) ================================================ FILE: backend/chainlit/discord/app.py ================================================ import asyncio import mimetypes import re import uuid from datetime import datetime from io import BytesIO from typing import TYPE_CHECKING, Dict, List, Optional, Union if TYPE_CHECKING: from discord.abc import MessageableChannel import discord import filetype import httpx from discord.ui import Button, View from chainlit.config import config from chainlit.context import ChainlitContext, HTTPSession, context, context_var from chainlit.data import get_data_layer from chainlit.element import Element, ElementDict from chainlit.emitter import BaseChainlitEmitter from chainlit.logger import logger from chainlit.message import Message, StepDict from chainlit.types import Feedback from chainlit.user import PersistedUser, User from chainlit.user_session import user_session class FeedbackView(View): def __init__(self, step_id: str): super().__init__(timeout=None) self.step_id = step_id @discord.ui.button(label="👎") async def thumbs_down(self, interaction: discord.Interaction, button: Button): if data_layer := get_data_layer(): try: feedback = Feedback(forId=self.step_id, value=0) await data_layer.upsert_feedback(feedback) except Exception as e: logger.error(f"Error upserting feedback: {e}") if interaction.message: await interaction.message.edit(view=None) await interaction.message.add_reaction("👎") @discord.ui.button(label="👍") async def thumbs_up(self, interaction: discord.Interaction, button: Button): if data_layer := get_data_layer(): try: feedback = Feedback(forId=self.step_id, value=1) await data_layer.upsert_feedback(feedback) except Exception as e: logger.error(f"Error upserting feedback: {e}") if interaction.message: await interaction.message.edit(view=None) await interaction.message.add_reaction("👍") class DiscordEmitter(BaseChainlitEmitter): def __init__(self, session: HTTPSession, channel: "MessageableChannel"): super().__init__(session) self.channel = channel async def send_element(self, element_dict: ElementDict): if element_dict.get("display") != "inline": return persisted_file = self.session.files.get(element_dict.get("chainlitKey") or "") file: Optional[Union[BytesIO, str]] = None mime: Optional[str] = None if persisted_file: file = str(persisted_file["path"]) mime = element_dict.get("mime") elif file_url := element_dict.get("url"): async with httpx.AsyncClient() as client: response = await client.get(file_url) if response.status_code == 200: file = BytesIO(response.content) mime = filetype.guess_mime(file) if not file: return element_name: str = element_dict.get("name", "Untitled") if mime: file_extension = mimetypes.guess_extension(mime) if file_extension: element_name += file_extension file_obj = discord.File(file, filename=element_name) await self.channel.send(file=file_obj) async def send_step(self, step_dict: StepDict): if not step_dict["type"] == "assistant_message": return step_type = step_dict.get("type") is_message = step_type in [ "user_message", "assistant_message", ] is_empty_output = not step_dict.get("output") if is_empty_output or not is_message: return else: enable_feedback = get_data_layer() message = await self.channel.send(step_dict["output"]) if enable_feedback: current_run = context.current_run scorable_id = current_run.id if current_run else step_dict.get("id") if not scorable_id: return view = FeedbackView(scorable_id) await message.edit(view=view) async def update_step(self, step_dict: StepDict): if not step_dict["type"] == "assistant_message": return await self.send_step(step_dict) intents = discord.Intents.default() intents.message_content = True client = discord.Client(intents=intents) def init_discord_context( session: HTTPSession, channel: "MessageableChannel", message: discord.Message, ) -> ChainlitContext: emitter = DiscordEmitter(session=session, channel=channel) context = ChainlitContext(session=session, emitter=emitter) context_var.set(context) user_session.set("discord_message", message) user_session.set("discord_channel", channel) return context users_by_discord_id: Dict[int, Union[User, PersistedUser]] = {} USER_PREFIX = "discord_" async def get_user(discord_user: Union[discord.User, discord.Member]): if discord_user.id in users_by_discord_id: return users_by_discord_id[discord_user.id] metadata = { "name": discord_user.name, "id": discord_user.id, } user = User(identifier=USER_PREFIX + str(discord_user.name), metadata=metadata) users_by_discord_id[discord_user.id] = user if data_layer := get_data_layer(): try: persisted_user = await data_layer.create_user(user) if persisted_user: users_by_discord_id[discord_user.id] = persisted_user except Exception as e: logger.error(f"Error creating user: {e}") return users_by_discord_id[discord_user.id] async def download_discord_file(url: str): async with httpx.AsyncClient() as client: response = await client.get(url) if response.status_code == 200: return response.content else: return None async def download_discord_files( session: HTTPSession, attachments: List[discord.Attachment] ): download_coros = [ download_discord_file(attachment.url) for attachment in attachments ] file_bytes_list = await asyncio.gather(*download_coros) file_refs = [] for idx, file_bytes in enumerate(file_bytes_list): if file_bytes: name = attachments[idx].filename mime_type = attachments[idx].content_type or "application/octet-stream" file_ref = await session.persist_file( name=name, mime=mime_type, content=file_bytes ) file_refs.append(file_ref) files_dicts = [ session.files[file["id"]] for file in file_refs if file["id"] in session.files ] elements = [ Element.from_dict( { "id": file["id"], "name": file["name"], "path": str(file["path"]), "chainlitKey": file["id"], "display": "inline", "type": Element.infer_type_from_mime(file["type"]), } ) for file in files_dicts ] return elements def clean_content(message: discord.Message): if not client.user: return message.content # Regex to find mentions of the bot bot_mention = f"<@!?{client.user.id}>" # Replace the bot's mention with nothing return re.sub(bot_mention, "", message.content).strip() async def process_discord_message( message: discord.Message, thread_id: str, thread_name: str, channel: "MessageableChannel", bind_thread_to_user=False, ): user = await get_user(message.author) text = clean_content(message) discord_files = message.attachments session_id = str(uuid.uuid4()) session = HTTPSession( id=session_id, thread_id=thread_id, user=user, client_type="discord", ) ctx = init_discord_context( session=session, channel=channel, message=message, ) file_elements = await download_discord_files(session, discord_files) if on_chat_start := config.code.on_chat_start: await on_chat_start() msg = Message( content=text, elements=file_elements, type="user_message", author=user.metadata.get("name"), ) await msg.send() if on_message := config.code.on_message: async with channel.typing(): await on_message(msg) if on_chat_end := config.code.on_chat_end: await on_chat_end() if data_layer := get_data_layer(): user_id = None if isinstance(user, PersistedUser): user_id = user.id if bind_thread_to_user else None try: await data_layer.update_thread( thread_id=thread_id, name=thread_name, metadata=ctx.session.to_persistable(), user_id=user_id, ) except Exception as e: logger.error(f"Error updating thread: {e}") await ctx.session.delete() @client.event async def on_ready(): logger.info(f"Logged in as {client.user}") @client.event async def on_message(message: discord.Message): if not client.user or message.author == client.user: return is_dm = isinstance(message.channel, discord.DMChannel) if not client.user.mentioned_in(message) and not is_dm: return thread_name: str = "" thread_id: str = "" bind_thread_to_user = False channel = message.channel if isinstance(message.channel, discord.Thread): thread_name = f"{message.channel.name}" thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, str(channel.id))) elif isinstance(message.channel, discord.ForumChannel): thread_name = f"{message.channel.name}" thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, str(channel.id))) elif isinstance(message.channel, discord.DMChannel): thread_id = str( uuid.uuid5( uuid.NAMESPACE_DNS, str(channel.id) + datetime.today().strftime("%Y-%m-%d"), ) ) thread_name = ( f"{message.author} Discord DM {datetime.today().strftime('%Y-%m-%d')}" ) bind_thread_to_user = True elif isinstance(message.channel, discord.GroupChannel): thread_id = str( uuid.uuid5( uuid.NAMESPACE_DNS, str(channel.id) + datetime.today().strftime("%Y-%m-%d"), ) ) thread_name = f"{message.channel.name}" elif isinstance(message.channel, discord.TextChannel): # Discord limits thread names to 100 characters and does not create # threads from empty messages. thread_id = str( uuid.uuid5( uuid.NAMESPACE_DNS, str(channel.id) + datetime.today().strftime("%Y-%m-%d"), ) ) discord_thread_name = clean_content(message)[:100] or "Untitled" channel = await message.channel.create_thread( name=discord_thread_name, message=message ) thread_name = f"{channel.name}" else: logger.warning(f"Unsupported channel type: {message.channel.type}") return await process_discord_message( message=message, thread_id=thread_id, thread_name=thread_name, channel=channel, bind_thread_to_user=bind_thread_to_user, ) ================================================ FILE: backend/chainlit/element.py ================================================ import json import mimetypes import uuid from enum import Enum from io import BytesIO from typing import ( Any, ClassVar, Dict, List, Literal, Optional, TypedDict, TypeVar, Union, ) import filetype from pydantic import Field from pydantic.dataclasses import dataclass from syncer import asyncio from chainlit.context import context from chainlit.data import get_data_layer from chainlit.logger import logger mime_types = { "text": "text/plain", "tasklist": "application/json", "plotly": "application/json", } ElementType = Literal[ "image", "text", "pdf", "tasklist", "audio", "video", "file", "plotly", "dataframe", "custom", ] ElementDisplay = Literal["inline", "side", "page"] ElementSize = Literal["small", "medium", "large"] class ElementDict(TypedDict, total=False): id: str threadId: Optional[str] type: ElementType chainlitKey: Optional[str] path: Optional[str] url: Optional[str] objectKey: Optional[str] name: str display: ElementDisplay size: Optional[ElementSize] language: Optional[str] page: Optional[int] props: Optional[Dict] autoPlay: Optional[bool] playerConfig: Optional[dict] forId: Optional[str] mime: Optional[str] @dataclass class Element: # Thread id 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] # 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. id: str = Field(default_factory=lambda: str(uuid.uuid4())) # The key of the element hosted on Chainlit. chainlit_key: Optional[str] = None # The URL of the element if already hosted somewhere else. url: Optional[str] = None # The S3 object key. object_key: Optional[str] = None # The local path of the element. path: Optional[str] = None # The byte content of the element. content: Optional[Union[bytes, str]] = None # Controls how the image element should be displayed in the UI. Choices are “side” (default), “inline”, or “page”. display: ElementDisplay = Field(default="inline") # Controls element size size: Optional[ElementSize] = None # The ID of the message this element is associated with. for_id: Optional[str] = None # The language, if relevant language: Optional[str] = None # Mime type, inferred based on content if not provided mime: Optional[str] = None def __post_init__(self) -> None: self.persisted = False self.updatable = False if not self.url and not self.path and not self.content: raise ValueError("Must provide url, path or content to instantiate element") def to_dict(self) -> ElementDict: _dict = ElementDict( { "id": self.id, "threadId": self.thread_id, "type": self.type, "url": self.url, "chainlitKey": self.chainlit_key, "name": self.name, "display": self.display, "objectKey": getattr(self, "object_key", None), "size": getattr(self, "size", None), "props": getattr(self, "props", None), "page": getattr(self, "page", None), "autoPlay": getattr(self, "auto_play", None), "playerConfig": getattr(self, "player_config", None), "language": getattr(self, "language", None), "forId": getattr(self, "for_id", None), "mime": getattr(self, "mime", None), } ) return _dict @classmethod def from_dict(cls, e_dict: ElementDict): """ Create an Element instance from a dictionary representation. Args: _dict (ElementDict): Dictionary containing element data Returns: Element: An instance of the appropriate Element subclass """ element_id = e_dict.get("id", str(uuid.uuid4())) for_id = e_dict.get("forId") name = e_dict.get("name", "") type = e_dict.get("type", "file") path = str(e_dict.get("path")) if e_dict.get("path") else None url = str(e_dict.get("url")) if e_dict.get("url") else None content = str(e_dict.get("content")) if e_dict.get("content") else None object_key = e_dict.get("objectKey") chainlit_key = e_dict.get("chainlitKey") display = e_dict.get("display", "inline") mime_type = e_dict.get("mime", "") # Common parameters for all element types common_params = { "id": element_id, "for_id": for_id, "name": name, "content": content, "path": path, "url": url, "object_key": object_key, "chainlit_key": chainlit_key, "display": display, "mime": mime_type, } if type == "image": return Image(size="medium", **common_params) # type: ignore[arg-type] elif type == "audio": return Audio(auto_play=e_dict.get("autoPlay", False), **common_params) # type: ignore[arg-type] elif type == "video": return Video( player_config=e_dict.get("playerConfig"), **common_params, # type: ignore[arg-type] ) elif type == "plotly": return Plotly(size=e_dict.get("size", "medium"), **common_params) # type: ignore[arg-type] elif type == "custom": return CustomElement(props=e_dict.get("props", {}), **common_params) # type: ignore[arg-type] else: # Default to File for any other type return File(**common_params) # type: ignore[arg-type] @classmethod def infer_type_from_mime(cls, mime_type: str): """Infer the element type from a mime type. Useful to know which element to instantiate from a file upload.""" if "image" in mime_type: return "image" elif mime_type == "application/pdf": return "pdf" elif "audio" in mime_type: return "audio" elif "video" in mime_type: return "video" else: return "file" async def _create(self, persist=True) -> bool: if self.persisted and not self.updatable: return True if (data_layer := get_data_layer()) and persist: try: asyncio.create_task(data_layer.create_element(self)) except Exception as e: logger.error(f"Failed to create element: {e!s}") if not self.url and (not self.chainlit_key or self.updatable): file_dict = await context.session.persist_file( name=self.name, path=self.path, content=self.content, mime=self.mime or "", ) self.chainlit_key = file_dict["id"] self.persisted = True return True async def remove(self): data_layer = get_data_layer() if data_layer: await data_layer.delete_element(self.id, self.thread_id) await context.emitter.emit("remove_element", {"id": self.id}) async def send(self, for_id: str, persist=True): self.for_id = for_id if not self.mime: if self.type in mime_types: self.mime = mime_types[self.type] elif self.path or isinstance(self.content, (bytes, bytearray)): file_type = filetype.guess(self.path or self.content) if file_type: self.mime = file_type.mime elif self.url: self.mime = mimetypes.guess_type(self.url)[0] await self._create(persist=persist) if not self.url and not self.chainlit_key: raise ValueError("Must provide url or chainlit key to send element") await context.emitter.send_element(self.to_dict()) ElementBased = TypeVar("ElementBased", bound=Element) @dataclass class Image(Element): type: ClassVar[ElementType] = "image" size: ElementSize = "medium" @dataclass class Text(Element): """Useful to send a text (not a message) to the UI.""" type: ClassVar[ElementType] = "text" language: Optional[str] = None @dataclass class Pdf(Element): """Useful to send a pdf to the UI.""" mime: str = "application/pdf" page: Optional[int] = None type: ClassVar[ElementType] = "pdf" @dataclass class Pyplot(Element): """Useful to send a pyplot to the UI.""" # We reuse the frontend image element to display the chart type: ClassVar[ElementType] = "image" size: ElementSize = "medium" # The type is set to Any because the figure is not serializable # and its actual type is checked in __post_init__. figure: Any = None def __post_init__(self) -> None: from matplotlib.figure import Figure if not isinstance(self.figure, Figure): raise TypeError("figure must be a matplotlib.figure.Figure") image = BytesIO() self.figure.savefig( image, dpi=200, bbox_inches="tight", backend="Agg", format="png" ) self.content = image.getvalue() super().__post_init__() class TaskStatus(Enum): READY = "ready" RUNNING = "running" FAILED = "failed" DONE = "done" @dataclass class Task: title: str status: TaskStatus = TaskStatus.READY forId: Optional[str] = None def __init__( self, title: str, status: TaskStatus = TaskStatus.READY, forId: Optional[str] = None, ): self.title = title self.status = status self.forId = forId @dataclass class TaskList(Element): type: ClassVar[ElementType] = "tasklist" tasks: List[Task] = Field(default_factory=list, exclude=True) status: str = "Ready" name: str = "tasklist" content: str = "dummy content to pass validation" def __post_init__(self) -> None: super().__post_init__() self.updatable = True async def add_task(self, task: Task): self.tasks.append(task) async def update(self): await self.send() async def send(self): await self.preprocess_content() await super().send(for_id="") async def preprocess_content(self): # serialize enum tasks = [ {"title": task.title, "status": task.status.value, "forId": task.forId} for task in self.tasks ] # store stringified json in content so that it's correctly stored in the database self.content = json.dumps( { "status": self.status, "tasks": tasks, }, indent=4, ensure_ascii=False, ) @dataclass class Audio(Element): type: ClassVar[ElementType] = "audio" auto_play: bool = False @dataclass class Video(Element): type: ClassVar[ElementType] = "video" size: ElementSize = "medium" # Override settings for each type of player in ReactPlayer # https://github.com/cookpete/react-player?tab=readme-ov-file#config-prop player_config: Optional[dict] = None @dataclass class File(Element): type: ClassVar[ElementType] = "file" @dataclass class Plotly(Element): """Useful to send a plotly to the UI.""" type: ClassVar[ElementType] = "plotly" size: ElementSize = "medium" # The type is set to Any because the figure is not serializable # and its actual type is checked in __post_init__. figure: Any = None content: str = "" def __post_init__(self) -> None: from plotly import graph_objects as go, io as pio if not isinstance(self.figure, go.Figure): raise TypeError("figure must be a plotly.graph_objects.Figure") self.figure.layout.autosize = True self.figure.layout.width = None self.figure.layout.height = None self.content = pio.to_json(self.figure, validate=True) self.mime = "application/json" super().__post_init__() @dataclass class Dataframe(Element): """Useful to send a pandas DataFrame to the UI.""" type: ClassVar[ElementType] = "dataframe" size: ElementSize = "large" data: Any = None # The type is Any because it is checked in __post_init__. def __post_init__(self) -> None: """Ensures the data is a pandas DataFrame and converts it to JSON.""" from pandas import DataFrame if not isinstance(self.data, DataFrame): raise TypeError("data must be a pandas.DataFrame") self.content = self.data.to_json(orient="split", date_format="iso") super().__post_init__() @dataclass class CustomElement(Element): """Useful to send a custom element to the UI.""" type: ClassVar[ElementType] = "custom" mime: str = "application/json" props: Dict = Field(default_factory=dict) def __post_init__(self) -> None: self.content = json.dumps(self.props) super().__post_init__() self.updatable = True async def update(self): await super().send(self.for_id) ================================================ FILE: backend/chainlit/emitter.py ================================================ import asyncio import uuid from typing import Any, Dict, List, Literal, Optional, Union, cast, get_args from socketio.exceptions import TimeoutError from chainlit.chat_context import chat_context from chainlit.config import config from chainlit.data import get_data_layer from chainlit.element import Element, ElementDict, File from chainlit.logger import logger from chainlit.message import Message from chainlit.mode import Mode from chainlit.session import BaseSession, WebsocketSession from chainlit.step import StepDict from chainlit.types import ( AskActionResponse, AskElementResponse, AskFileSpec, AskSpec, CommandDict, FileDict, FileReference, MessagePayload, OutputAudioChunk, ThreadDict, ToastType, ) from chainlit.user import PersistedUser from chainlit.utils import utc_now class BaseChainlitEmitter: """ Chainlit Emitter Stub class. This class is used for testing purposes. It stubs the ChainlitEmitter class and does nothing on function calls. """ session: BaseSession enabled: bool = True def __init__(self, session: BaseSession) -> None: """Initialize with the user session.""" self.session = session async def emit(self, event: str, data: Any): """Stub method to get the 'emit' property from the session.""" pass async def emit_call(self): """Stub method to get the 'emit_call' property from the session.""" pass async def resume_thread(self, thread_dict: ThreadDict): """Stub method to resume a thread.""" pass async def send_resume_thread_error(self, error: str): """Stub method to send a resume thread error.""" pass async def send_element(self, element_dict: ElementDict): """Stub method to send an element to the UI.""" pass async def update_audio_connection(self, state: Literal["on", "off"]): """Audio connection signaling.""" pass async def send_audio_chunk(self, chunk: OutputAudioChunk): """Stub method to send an audio chunk to the UI.""" pass async def send_audio_interrupt(self): """Stub method to interrupt the current audio response.""" pass async def send_step(self, step_dict: StepDict): """Stub method to send a message to the UI.""" pass async def update_step(self, step_dict: StepDict): """Stub method to update a message in the UI.""" pass async def delete_step(self, step_dict: StepDict): """Stub method to delete a message in the UI.""" pass def send_timeout(self, event: Literal["ask_timeout", "call_fn_timeout"]): """Stub method to send a timeout to the UI.""" pass def clear(self, event: Literal["clear_ask", "clear_call_fn"]): pass async def init_thread(self, interaction: str): pass async def process_message(self, payload: MessagePayload) -> Message: """Stub method to process user message.""" return Message(content="") async def send_ask_user( self, step_dict: StepDict, spec: AskSpec, raise_on_timeout=False ) -> Optional[ Union["StepDict", "AskActionResponse", "AskElementResponse", List["FileDict"]] ]: """Stub method to send a prompt to the UI and wait for a response.""" pass async def send_call_fn( self, name: str, args: Dict[str, Any], timeout=300, raise_on_timeout=False ) -> Optional[Dict[str, Any]]: """Stub method to send a call function event to the copilot and wait for a response.""" pass async def update_token_count(self, count: int): """Stub method to update the token count for the UI.""" pass async def task_start(self): """Stub method to send a task start signal to the UI.""" pass async def task_end(self): """Stub method to send a task end signal to the UI.""" pass async def stream_start(self, step_dict: StepDict): """Stub method to send a stream start signal to the UI.""" pass async def send_token(self, id: str, token: str, is_sequence=False, is_input=False): """Stub method to send a message token to the UI.""" pass async def set_chat_settings(self, settings: dict): """Stub method to set chat settings.""" pass async def set_commands(self, commands: List[CommandDict]): """Stub method to send the available commands to the UI.""" pass async def set_modes(self, modes: List[Mode]): """Stub method to send the available modes to the UI.""" pass async def send_window_message(self, data: Any): """Stub method to send custom data to the host window.""" pass def send_toast(self, message: str, type: Optional[ToastType] = "info"): """Stub method to send a toast message to the UI.""" pass async def set_favorites(self, steps: List[StepDict]): """Stub method to send the favorite messages to the UI.""" pass class ChainlitEmitter(BaseChainlitEmitter): """ Chainlit Emitter class. The Emitter is not directly exposed to the developer. Instead, the developer interacts with the Emitter through the methods and classes exposed in the __init__ file. """ session: WebsocketSession def __init__(self, session: WebsocketSession) -> None: """Initialize with the user session.""" self.session = session def _get_session_property(self, property_name: str, raise_error=True): """Helper method to get a property from the session.""" if not hasattr(self, "session") or not hasattr(self.session, property_name): if raise_error: raise ValueError(f"Session does not have property '{property_name}'") else: return None return getattr(self.session, property_name) @property def emit(self): """Get the 'emit' property from the session.""" return self._get_session_property("emit") @property def emit_call(self): """Get the 'emit_call' property from the session.""" return self._get_session_property("emit_call") def resume_thread(self, thread_dict: ThreadDict): """Send a thread to the UI to resume it""" return self.emit("resume_thread", thread_dict) def send_resume_thread_error(self, error: str): """Send a thread resume error to the UI""" return self.emit("resume_thread_error", error) async def update_audio_connection(self, state: Literal["on", "off"]): """Audio connection signaling.""" await self.emit("audio_connection", state) async def send_audio_chunk(self, chunk: OutputAudioChunk): """Send an audio chunk to the UI.""" await self.emit("audio_chunk", chunk) async def send_audio_interrupt(self): """Method to interrupt the current audio response.""" await self.emit("audio_interrupt", {}) async def send_element(self, element_dict: ElementDict): """Stub method to send an element to the UI.""" await self.emit("element", element_dict) def send_step(self, step_dict: StepDict): """Send a message to the UI.""" return self.emit("new_message", step_dict) def update_step(self, step_dict: StepDict): """Update a message in the UI.""" return self.emit("update_message", step_dict) def delete_step(self, step_dict: StepDict): """Delete a message in the UI.""" return self.emit("delete_message", step_dict) def send_timeout(self, event: Literal["ask_timeout", "call_fn_timeout"]): return self.emit(event, {}) def clear(self, event: Literal["clear_ask", "clear_call_fn"]): return self.emit(event, {}) async def flush_thread_queues(self, interaction: str): if data_layer := get_data_layer(): if isinstance(self.session.user, PersistedUser): user_id = self.session.user.id else: user_id = None try: should_tag_thread = ( self.session.chat_profile and config.features.auto_tag_thread ) tags = [self.session.chat_profile] if should_tag_thread else None await data_layer.update_thread( thread_id=self.session.thread_id, name=interaction, user_id=user_id, tags=tags, ) except Exception as e: logger.error(f"Error updating thread: {e}") asyncio.create_task(self.session.flush_method_queue()) async def init_thread(self, interaction: str): await self.flush_thread_queues(interaction) await self.emit( "first_interaction", { "interaction": interaction, "thread_id": self.session.thread_id, }, ) async def process_message(self, payload: MessagePayload): step_dict = payload["message"] file_refs = payload.get("fileReferences") # UUID generated by the frontend should use v4 assert uuid.UUID(step_dict["id"]).version == 4 message = Message.from_dict(step_dict) # Overwrite the created_at timestamp with the current time message.created_at = utc_now() chat_context.add(message) asyncio.create_task(message._create()) if not self.session.has_first_interaction: self.session.has_first_interaction = True asyncio.create_task(self.init_thread(message.content)) if file_refs: files = [ self.session.files[file["id"]] for file in file_refs if file["id"] in self.session.files ] elements = [ Element.from_dict( { "id": file["id"], "name": file["name"], "path": str(file["path"]), "chainlitKey": file["id"], "display": "inline", "type": Element.infer_type_from_mime(file["type"]), "mime": file["type"], } ) for file in files ] message.elements = elements async def send_elements(): for element in message.elements: await element.send(for_id=message.id) asyncio.create_task(send_elements()) return message async def send_ask_user( self, step_dict: StepDict, spec: AskSpec, raise_on_timeout=False ): """Send a prompt to the UI and wait for a response.""" parent_id = str(step_dict["parentId"]) try: if spec.type == "file": self.session.files_spec[parent_id] = cast(AskFileSpec, spec) # Send the prompt to the UI user_res = await self.emit_call( "ask", {"msg": step_dict, "spec": spec.to_dict()}, spec.timeout ) # type: Optional[Union["StepDict", "AskActionResponse", "AskElementResponse", List["FileReference"]]] # End the task temporarily so that the User can answer the prompt await self.task_end() final_res: Optional[ Union[StepDict, AskActionResponse, AskElementResponse, List[FileDict]] ] = None if user_res: interaction: Union[str, None] = None if spec.type == "text": message_dict_res = cast(StepDict, user_res) await self.process_message( {"message": message_dict_res, "fileReferences": None} ) interaction = message_dict_res["output"] final_res = message_dict_res elif spec.type == "file": file_refs = cast(List[FileReference], user_res) files = [ self.session.files[file["id"]] for file in file_refs if file["id"] in self.session.files ] final_res = files interaction = ",".join([file["name"] for file in files]) if get_data_layer(): coros = [ File( id=file["id"], name=file["name"], path=str(file["path"]), mime=file["type"], chainlit_key=file["id"], for_id=step_dict["id"], )._create() for file in files ] await asyncio.gather(*coros) elif spec.type == "action": action_res = cast(AskActionResponse, user_res) final_res = action_res interaction = action_res["name"] elif spec.type == "element": final_res = cast(AskElementResponse, user_res) interaction = "custom_element" if not self.session.has_first_interaction and interaction: self.session.has_first_interaction = True await self.init_thread(interaction=interaction) await self.clear("clear_ask") return final_res except TimeoutError as e: await self.send_timeout("ask_timeout") if raise_on_timeout: raise e finally: if parent_id in self.session.files_spec: del self.session.files_spec[parent_id] await self.task_start() async def send_call_fn( self, name: str, args: Dict[str, Any], timeout=300, raise_on_timeout=False ) -> Optional[Dict[str, Any]]: """Stub method to send a call function event to the copilot and wait for a response.""" try: call_fn_res = await self.emit_call( "call_fn", {"name": name, "args": args}, timeout ) # type: Dict await self.clear("clear_call_fn") return call_fn_res except TimeoutError as e: await self.send_timeout("call_fn_timeout") if raise_on_timeout: raise e return None def update_token_count(self, count: int): """Update the token count for the UI.""" return self.emit("token_usage", count) def task_start(self): """ Send a task start signal to the UI. """ return self.emit("task_start", {}) def task_end(self): """Send a task end signal to the UI.""" return self.emit("task_end", {}) def stream_start(self, step_dict: StepDict): """Send a stream start signal to the UI.""" return self.emit( "stream_start", step_dict, ) def send_token(self, id: str, token: str, is_sequence=False, is_input=False): """Send a message token to the UI.""" return self.emit( "stream_token", {"id": id, "token": token, "isSequence": is_sequence, "isInput": is_input}, ) def set_chat_settings(self, settings: Dict[str, Any]): self.session.chat_settings = settings def set_commands(self, commands: List[CommandDict]): """Send the available commands to the UI.""" return self.emit( "set_commands", commands, ) def set_modes(self, modes: List[Mode]): """Send the available modes to the UI.""" return self.emit( "set_modes", [mode.to_dict() for mode in modes], ) def set_favorites(self, steps: List[StepDict]): """Send the favorite messages to the UI.""" return self.emit( "set_favorites", steps, ) def send_window_message(self, data: Any): """Send custom data to the host window.""" return self.emit("window_message", data) def send_toast(self, message: str, type: Optional[ToastType] = "info"): """Send a toast message to the UI.""" # check that the type is valid using ToastType if type not in get_args(ToastType): raise ValueError(f"Invalid toast type: {type}") return self.emit("toast", {"message": message, "type": type}) ================================================ FILE: backend/chainlit/input_widget.py ================================================ from abc import abstractmethod from datetime import date from typing import Any, Dict, List, Literal, Optional from pydantic import Field from pydantic.dataclasses import dataclass from chainlit.types import InputWidgetType @dataclass class InputWidget: id: str label: str initial: Any = None tooltip: Optional[str] = None description: Optional[str] = None disabled: Optional[bool] = False def __post_init__( self, ) -> None: if not self.id or not self.label: raise ValueError("Must provide key and label to load InputWidget") @abstractmethod def to_dict(self) -> Dict[str, Any]: pass @dataclass class Switch(InputWidget): """Useful to create a switch input.""" type: InputWidgetType = "switch" initial: bool = False def to_dict(self) -> Dict[str, Any]: return { "type": self.type, "id": self.id, "label": self.label, "initial": self.initial, "tooltip": self.tooltip, "description": self.description, "disabled": self.disabled, } @dataclass class Slider(InputWidget): """Useful to create a slider input.""" type: InputWidgetType = "slider" initial: float = 0 min: float = 0 max: float = 10 step: float = 1 def to_dict(self) -> Dict[str, Any]: return { "type": self.type, "id": self.id, "label": self.label, "initial": self.initial, "min": self.min, "max": self.max, "step": self.step, "tooltip": self.tooltip, "description": self.description, "disabled": self.disabled, } @dataclass class Select(InputWidget): """Useful to create a select input.""" type: InputWidgetType = "select" initial: Optional[str] = None initial_index: Optional[int] = None initial_value: Optional[str] = None values: List[str] = Field(default_factory=list) items: Dict[str, str] = Field(default_factory=dict) def __post_init__( self, ) -> None: super().__post_init__() if not self.values and not self.items: raise ValueError("Must provide values or items to create a Select") if self.values and self.items: raise ValueError( "You can only provide either values or items to create a Select" ) if not self.values and self.initial_index is not None: raise ValueError( "Initial_index can only be used in combination with values to create a Select" ) if self.items: self.initial = self.initial_value elif self.values: self.items = {value: value for value in self.values} self.initial = ( self.values[self.initial_index] if self.initial_index is not None else self.initial_value ) def to_dict(self) -> Dict[str, Any]: return { "type": self.type, "id": self.id, "label": self.label, "initial": self.initial, "items": [ {"label": id, "value": value} for id, value in self.items.items() ], "tooltip": self.tooltip, "description": self.description, "disabled": self.disabled, } @dataclass class TextInput(InputWidget): """Useful to create a text input.""" type: InputWidgetType = "textinput" initial: Optional[str] = None placeholder: Optional[str] = None multiline: bool = False def to_dict(self) -> Dict[str, Any]: return { "type": self.type, "id": self.id, "label": self.label, "initial": self.initial, "placeholder": self.placeholder, "tooltip": self.tooltip, "description": self.description, "multiline": self.multiline, "disabled": self.disabled, } @dataclass class NumberInput(InputWidget): """Useful to create a number input.""" type: InputWidgetType = "numberinput" initial: Optional[float] = None placeholder: Optional[str] = None def to_dict(self) -> Dict[str, Any]: return { "type": self.type, "id": self.id, "label": self.label, "initial": self.initial, "placeholder": self.placeholder, "tooltip": self.tooltip, "description": self.description, "disabled": self.disabled, } @dataclass class Tags(InputWidget): """Useful to create an input for an array of strings.""" type: InputWidgetType = "tags" initial: List[str] = Field(default_factory=list) values: List[str] = Field(default_factory=list) def to_dict(self) -> Dict[str, Any]: return { "type": self.type, "id": self.id, "label": self.label, "initial": self.initial, "tooltip": self.tooltip, "description": self.description, "disabled": self.disabled, } @dataclass class MultiSelect(InputWidget): """Useful to create a multi-select input.""" type: InputWidgetType = "multiselect" initial: List[str] = Field(default_factory=list) values: List[str] = Field(default_factory=list) items: Dict[str, str] = Field(default_factory=dict) def __post_init__( self, ) -> None: super().__post_init__() if not self.values and not self.items: raise ValueError("Must provide values or items to create a MultiSelect") if self.values and self.items: raise ValueError( "You can only provide either values or items to create a MultiSelect" ) if self.values: self.items = {value: value for value in self.values} def to_dict(self) -> Dict[str, Any]: return { "type": self.type, "id": self.id, "label": self.label, "initial": self.initial, "items": [ {"label": id, "value": value} for id, value in self.items.items() ], "tooltip": self.tooltip, "description": self.description, "disabled": self.disabled, } @dataclass class Checkbox(InputWidget): """Useful to create a checkbox input.""" type: InputWidgetType = "checkbox" initial: bool = False def to_dict(self) -> Dict[str, Any]: return { "type": self.type, "id": self.id, "label": self.label, "initial": self.initial, "tooltip": self.tooltip, "description": self.description, "disabled": self.disabled, } @dataclass class RadioGroup(InputWidget): """Useful to create a radio button input.""" type: InputWidgetType = "radio" initial: Optional[str] = None initial_index: Optional[int] = None initial_value: Optional[str] = None values: List[str] = Field(default_factory=list) items: Dict[str, str] = Field(default_factory=dict) def __post_init__( self, ) -> None: super().__post_init__() if not self.values and not self.items: raise ValueError("Must provide values or items to create a RadioButton") if self.values and self.items: raise ValueError( "You can only provide either values or items to create a RadioButton" ) if not self.values and self.initial_index is not None: raise ValueError( "Initial_index can only be used in combination with values to create a RadioButton" ) if self.items: self.initial = self.initial_value elif self.values: self.items = {value: value for value in self.values} self.initial = ( self.values[self.initial_index] if self.initial_index is not None else self.initial_value ) def to_dict(self) -> Dict[str, Any]: return { "type": self.type, "id": self.id, "label": self.label, "initial": self.initial, "items": [ {"label": id, "value": value} for id, value in self.items.items() ], "tooltip": self.tooltip, "description": self.description, "disabled": self.disabled, } @dataclass class Tab: id: str label: str inputs: list[InputWidget] = Field(default_factory=list, exclude=True) def to_dict(self) -> dict[str, Any]: return { "id": self.id, "label": self.label, "inputs": [input.to_dict() for input in self.inputs], } @dataclass class DatePicker(InputWidget): """ Datepicker input widget. Supports both single date and date range selection. """ type: InputWidgetType = "datepicker" mode: Literal["single", "range"] = "single" initial: str | date | tuple[str | date, str | date] | None = None min_date: str | date | None = None max_date: str | date | None = None format: str | None = None """date-fns format string""" placeholder: str | None = None """Placeholder to use when no date is selected""" def __post_init__(self) -> None: super().__post_init__() if self.mode not in ("single", "range"): raise ValueError("mode must be 'single' or 'range'") if ( self.mode == "range" and self.initial is not None and not isinstance(self.initial, tuple) ): raise ValueError("'initial' must be a tuple for range mode") (initial_start, initial_end), min_date, max_date = ( [ DatePicker._validate_iso_format(date, "initial") for date in ( self.initial if isinstance(self.initial, tuple) else [self.initial, None] ) ], DatePicker._validate_iso_format(self.min_date, "min_date"), DatePicker._validate_iso_format(self.max_date, "max_date"), ) if self.mode == "range": self._validate_range(initial_start, initial_end, "initial") self._validate_range(min_date, max_date, "[min_date, max_date]") # Validate that initial value(s) are within min_date and max_date bounds for d in [initial_start, initial_end]: if d is not None and ( (min_date is not None and d < min_date) or (max_date is not None and d > max_date) ): raise ValueError( "'initial' must be within 'min_date' and 'max_date' bounds" ) @staticmethod def _validate_range( start: date | None, end: date | None, field_name: str, ) -> None: if start is not None and end is not None and start > end: raise ValueError( f"'{field_name}' range is invalid, start must be before end." ) @staticmethod def _validate_iso_format( date_value: str | date | None, field_name: str ) -> date | None: if isinstance(date_value, str): try: return date.fromisoformat(date_value) except ValueError as e: raise ValueError(f"'{field_name}' must be in ISO format") from e return date_value @staticmethod def _format_date(date_value: str | date | None) -> str | None: if isinstance(date_value, date): return date_value.isoformat() return date_value def to_dict(self) -> dict[str, Any]: return { "type": self.type, "id": self.id, "label": self.label, "tooltip": self.tooltip, "description": self.description, "mode": self.mode, "initial": ( self._format_date(self.initial[0]), self._format_date(self.initial[1]), ) if isinstance(self.initial, tuple) else DatePicker._format_date(self.initial), "min_date": DatePicker._format_date(self.min_date), "max_date": DatePicker._format_date(self.max_date), "format": self.format, "placeholder": self.placeholder, } ================================================ FILE: backend/chainlit/langchain/__init__.py ================================================ from chainlit.utils import check_module_version if not check_module_version("langchain", "0.0.198"): raise ValueError( "Expected LangChain version >= 0.0.198. Run `pip install langchain --upgrade`" ) ================================================ FILE: backend/chainlit/langchain/callbacks.py ================================================ import time from typing import Any, Dict, List, Optional, Tuple, TypedDict, Union from uuid import UUID import pydantic from langchain_core.load import dumps from langchain_core.messages import BaseMessage from langchain_core.outputs import ChatGenerationChunk, GenerationChunk from langchain_core.tracers.base import AsyncBaseTracer from langchain_core.tracers.schemas import Run from literalai import ChatGeneration, CompletionGeneration, GenerationMessage from literalai.observability.step import TrueStepType from chainlit.context import context_var from chainlit.message import Message from chainlit.step import Step from chainlit.utils import utc_now DEFAULT_ANSWER_PREFIX_TOKENS = ["Final", "Answer", ":"] class FinalStreamHelper: # The stream we can use to stream the final answer from a chain final_stream: Union[Message, None] # Should we stream the final answer? stream_final_answer: bool = False # Token sequence that prefixes the answer answer_prefix_tokens: List[str] # Ignore white spaces and new lines when comparing answer_prefix_tokens to last tokens? (to determine if answer has been reached) strip_tokens: bool answer_reached: bool def __init__( self, answer_prefix_tokens: Optional[List[str]] = None, stream_final_answer: bool = False, force_stream_final_answer: bool = False, strip_tokens: bool = True, ) -> None: # Langchain final answer streaming logic if answer_prefix_tokens is None: self.answer_prefix_tokens = DEFAULT_ANSWER_PREFIX_TOKENS else: self.answer_prefix_tokens = answer_prefix_tokens if strip_tokens: self.answer_prefix_tokens_stripped = [ token.strip() for token in self.answer_prefix_tokens ] else: self.answer_prefix_tokens_stripped = self.answer_prefix_tokens self.last_tokens = [""] * len(self.answer_prefix_tokens) self.last_tokens_stripped = [""] * len(self.answer_prefix_tokens) self.strip_tokens = strip_tokens self.answer_reached = force_stream_final_answer # Our own final answer streaming logic self.stream_final_answer = stream_final_answer self.final_stream = None self.has_streamed_final_answer = False def _check_if_answer_reached(self) -> bool: if self.strip_tokens: return self._compare_last_tokens(self.last_tokens_stripped) else: return self._compare_last_tokens(self.last_tokens) def _compare_last_tokens(self, last_tokens: List[str]): if last_tokens == self.answer_prefix_tokens_stripped: # If tokens match perfectly we are done return True else: # Some LLMs will consider all the tokens of the final answer as one token # so we check if any last token contains all answer tokens return any( [ all( answer_token in last_token for answer_token in self.answer_prefix_tokens_stripped ) for last_token in last_tokens ] ) def _append_to_last_tokens(self, token: str) -> None: self.last_tokens.append(token) self.last_tokens_stripped.append(token.strip()) if len(self.last_tokens) > len(self.answer_prefix_tokens): self.last_tokens.pop(0) self.last_tokens_stripped.pop(0) class ChatGenerationStart(TypedDict): input_messages: List[BaseMessage] start: float token_count: int tt_first_token: Optional[float] class CompletionGenerationStart(TypedDict): prompt: str start: float token_count: int tt_first_token: Optional[float] class GenerationHelper: chat_generations: Dict[str, ChatGenerationStart] completion_generations: Dict[str, CompletionGenerationStart] generation_inputs: Dict[str, Dict] def __init__(self) -> None: self.chat_generations = {} self.completion_generations = {} self.generation_inputs = {} def ensure_values_serializable(self, data): """ Recursively ensures that all values in the input (dict or list) are JSON serializable. """ if isinstance(data, dict): return { key: self.ensure_values_serializable(value) for key, value in data.items() } elif isinstance(data, pydantic.BaseModel): # Fallback to support pydantic v1 # https://docs.pydantic.dev/latest/migration/#changes-to-pydanticbasemodel if pydantic.VERSION.startswith("1"): return data.dict() # pydantic v2 return data.model_dump() # pyright: ignore reportAttributeAccessIssue elif isinstance(data, list): return [self.ensure_values_serializable(item) for item in data] elif isinstance(data, (str, int, float, bool, type(None))): return data elif isinstance(data, (tuple, set)): return list(data) # Convert tuples and sets to lists else: return str(data) # Fallback: convert other types to string def _convert_message_role(self, role: str): if "human" in role.lower(): return "user" elif "system" in role.lower(): return "system" elif "function" in role.lower(): return "function" elif "tool" in role.lower(): return "tool" else: return "assistant" def _convert_message_dict( self, message: Dict, ): class_name = message["id"][-1] kwargs = message.get("kwargs", {}) function_call = kwargs.get("additional_kwargs", {}).get("function_call") msg = GenerationMessage( role=self._convert_message_role(class_name), content="", ) if name := kwargs.get("name"): msg["name"] = name if function_call: msg["function_call"] = function_call else: content = kwargs.get("content") if isinstance(content, list): tool_calls = [] content_parts = [] for item in content: if item.get("type") == "tool_use": tool_calls.append( { "id": item.get("id"), "type": "function", "function": { "name": item.get("name"), "arguments": item.get("input"), }, } ) elif item.get("type") == "text": content_parts.append({"type": "text", "text": item.get("text")}) if tool_calls: msg["tool_calls"] = tool_calls if content_parts: msg["content"] = content_parts # type: ignore else: msg["content"] = content # type: ignore return msg def _convert_message( self, message: Union[Dict, BaseMessage], ): if isinstance(message, dict): return self._convert_message_dict( message, ) function_call = message.additional_kwargs.get("function_call") msg = GenerationMessage( role=self._convert_message_role(message.type), content="", ) if literal_uuid := message.additional_kwargs.get("uuid"): msg["uuid"] = literal_uuid msg["templated"] = True if name := getattr(message, "name", None): msg["name"] = name if function_call: msg["function_call"] = function_call else: if isinstance(message.content, list): tool_calls = [] content_parts = [] for item in message.content: if isinstance(item, str): continue if item.get("type") == "tool_use": tool_calls.append( { "id": item.get("id"), "type": "function", "function": { "name": item.get("name"), "arguments": item.get("input"), }, } ) elif item.get("type") == "text": content_parts.append({"type": "text", "text": item.get("text")}) if tool_calls: msg["tool_calls"] = tool_calls if content_parts: msg["content"] = content_parts # type: ignore else: msg["content"] = message.content # type: ignore return msg def _build_llm_settings( self, serialized: Dict, invocation_params: Optional[Dict] = None, ): # invocation_params = run.extra.get("invocation_params") if invocation_params is None: return None, None provider = invocation_params.pop("_type", "") # type: str model_kwargs = invocation_params.pop("model_kwargs", {}) if model_kwargs is None: model_kwargs = {} merged = { **invocation_params, **model_kwargs, **serialized.get("kwargs", {}), } # make sure there is no api key specification settings = {k: v for k, v in merged.items() if not k.endswith("_api_key")} model_keys = ["azure_deployment", "deployment_name", "model", "model_name"] model = next((settings[k] for k in model_keys if k in settings), None) if isinstance(model, str): model = model.replace("models/", "") tools = None if "functions" in settings: tools = [{"type": "function", "function": f} for f in settings["functions"]] if "tools" in settings: tools = [ {"type": "function", "function": t} if t.get("type") != "function" else t for t in settings["tools"] ] return provider, model, tools, settings def process_content(content: Any) -> Tuple[Dict | str, Optional[str]]: if content is None: return {}, None if isinstance(content, str): return {"content": content}, "text" else: return dumps(content), "json" DEFAULT_TO_IGNORE = [ "RunnableSequence", "RunnableParallel", "RunnableAssign", "RunnableLambda", "", ] DEFAULT_TO_KEEP = ["retriever", "llm", "agent", "chain", "tool"] class LangchainTracer(AsyncBaseTracer, GenerationHelper, FinalStreamHelper): steps: Dict[str, Step] parent_id_map: Dict[str, str] ignored_runs: set def __init__( self, # Token sequence that prefixes the answer answer_prefix_tokens: Optional[List[str]] = None, # Should we stream the final answer? stream_final_answer: bool = False, # Should force stream the first response? force_stream_final_answer: bool = False, # Runs to ignore to enhance readability to_ignore: Optional[List[str]] = None, # Runs to keep within ignored runs to_keep: Optional[List[str]] = None, **kwargs: Any, ) -> None: AsyncBaseTracer.__init__(self, **kwargs) GenerationHelper.__init__(self) FinalStreamHelper.__init__( self, answer_prefix_tokens=answer_prefix_tokens, stream_final_answer=stream_final_answer, force_stream_final_answer=force_stream_final_answer, ) self.context = context_var.get() self.steps = {} self.parent_id_map = {} self.ignored_runs = set() if self.context.current_step: self.root_parent_id = self.context.current_step.id else: self.root_parent_id = None if to_ignore is None: self.to_ignore = DEFAULT_TO_IGNORE else: self.to_ignore = to_ignore if to_keep is None: self.to_keep = DEFAULT_TO_KEEP else: self.to_keep = to_keep async def on_chat_model_start( self, serialized: Dict[str, Any], messages: List[List[BaseMessage]], *, run_id: "UUID", parent_run_id: Optional["UUID"] = None, tags: Optional[List[str]] = None, metadata: Optional[Dict[str, Any]] = None, name: Optional[str] = None, **kwargs: Any, ) -> Run: lc_messages = messages[0] self.chat_generations[str(run_id)] = { "input_messages": lc_messages, "start": time.time(), "token_count": 0, "tt_first_token": None, } return await super().on_chat_model_start( serialized, messages, run_id=run_id, parent_run_id=parent_run_id, tags=tags, metadata=metadata, name=name, **kwargs, ) async def on_llm_start( self, serialized: Dict[str, Any], prompts: List[str], *, run_id: "UUID", parent_run_id: Optional[UUID] = None, tags: Optional[List[str]] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs: Any, ) -> None: await super().on_llm_start( serialized, prompts, run_id=run_id, parent_run_id=parent_run_id, tags=tags, metadata=metadata, **kwargs, ) self.completion_generations[str(run_id)] = { "prompt": prompts[0], "start": time.time(), "token_count": 0, "tt_first_token": None, } return None async def on_llm_new_token( self, token: str, *, chunk: Optional[Union[GenerationChunk, ChatGenerationChunk]] = None, run_id: "UUID", parent_run_id: Optional["UUID"] = None, **kwargs: Any, ) -> None: await super().on_llm_new_token( token=token, chunk=chunk, run_id=run_id, parent_run_id=parent_run_id, **kwargs, ) if isinstance(chunk, ChatGenerationChunk): start = self.chat_generations[str(run_id)] else: start = self.completion_generations[str(run_id)] # type: ignore start["token_count"] += 1 if start["tt_first_token"] is None: start["tt_first_token"] = (time.time() - start["start"]) * 1000 # Process token to ensure it's a string, as strip() will be called on it. processed_token: str # Handle case where token is a list (can occur with some model outputs). # Join all elements into a single string to maintain compatibility with downstream processing. if isinstance(token, list): # If token is a list, join its elements (converted to strings) into a single string. processed_token = "".join(map(str, token)) elif not isinstance(token, str): # If token is neither a list nor a string, convert it to a string. processed_token = str(token) else: # If token is already a string, use it as is. processed_token = token if self.stream_final_answer: self._append_to_last_tokens(processed_token) if self.answer_reached: if not self.final_stream: self.final_stream = Message(content="") await self.final_stream.send() await self.final_stream.stream_token(processed_token) self.has_streamed_final_answer = True else: self.answer_reached = self._check_if_answer_reached() async def _persist_run(self, run: Run) -> None: pass def _get_run_parent_id(self, run: Run): parent_id = str(run.parent_run_id) if run.parent_run_id else self.root_parent_id return parent_id def _get_non_ignored_parent_id(self, current_parent_id: Optional[str] = None): if not current_parent_id: return self.root_parent_id if current_parent_id not in self.parent_id_map: return None while current_parent_id in self.parent_id_map: # If the parent id is in the ignored runs, we need to get the parent id of the ignored run if current_parent_id in self.ignored_runs: current_parent_id = self.parent_id_map[current_parent_id] else: return current_parent_id return self.root_parent_id def _should_ignore_run(self, run: Run): parent_id = self._get_run_parent_id(run) if parent_id: # Add the parent id of the ignored run in the mapping # so we can re-attach a kept child to the right parent id self.parent_id_map[str(run.id)] = parent_id ignore_by_name = False ignore_by_parent = parent_id in self.ignored_runs for filter in self.to_ignore: if filter in run.name: ignore_by_name = True break ignore = ignore_by_name or ignore_by_parent # If the ignore cause is the parent being ignored, check if we should nonetheless keep the child if ignore_by_parent and not ignore_by_name and run.run_type in self.to_keep: return False, self._get_non_ignored_parent_id(parent_id) else: if ignore: # Tag the run as ignored self.ignored_runs.add(str(run.id)) return ignore, parent_id async def _start_trace(self, run: Run) -> None: await super()._start_trace(run) context_var.set(self.context) ignore, parent_id = self._should_ignore_run(run) if run.run_type in ["chain", "prompt"]: self.generation_inputs[str(run.id)] = self.ensure_values_serializable( run.inputs ) if ignore: return step_type: TrueStepType = "undefined" if run.run_type == "agent": step_type = "run" elif run.run_type == "chain": if not self.steps: step_type = "run" elif run.run_type == "llm": step_type = "llm" elif run.run_type == "retriever": step_type = "tool" elif run.run_type == "tool": step_type = "tool" elif run.run_type == "embedding": step_type = "embedding" step = Step( id=str(run.id), name=run.name, type=step_type, parent_id=parent_id, ) step.start = utc_now() if step_type != "llm": step.input, language = process_content(run.inputs) step.show_input = language or False step.tags = run.tags self.steps[str(run.id)] = step await step.send() async def _on_run_update(self, run: Run) -> None: """Process a run upon update.""" context_var.set(self.context) ignore, _parent_id = self._should_ignore_run(run) if ignore: return current_step = self.steps.get(str(run.id), None) if run.run_type == "llm" and current_step: provider, model, tools, llm_settings = self._build_llm_settings( (run.serialized or {}), (run.extra or {}).get("invocation_params") ) generations = (run.outputs or {}).get("generations", []) generation = generations[0][0] variables = self.generation_inputs.get(str(run.parent_run_id), {}) variables = {k: str(v) for k, v in variables.items() if v is not None} if message := generation.get("message"): chat_start = self.chat_generations[str(run.id)] duration = time.time() - chat_start["start"] if duration and chat_start["token_count"]: throughput = chat_start["token_count"] / duration else: throughput = None message_completion = self._convert_message(message) current_step.generation = ChatGeneration( provider=provider, model=model, tools=tools, variables=variables, settings=llm_settings, duration=duration, token_throughput_in_s=throughput, tt_first_token=chat_start.get("tt_first_token"), messages=[ self._convert_message(m) for m in chat_start["input_messages"] ], message_completion=message_completion, ) # find first message with prompt_id for m in chat_start["input_messages"]: if m.additional_kwargs.get("prompt_id"): current_step.generation.prompt_id = m.additional_kwargs[ "prompt_id" ] if custom_variables := m.additional_kwargs.get("variables"): current_step.generation.variables = { k: str(v) for k, v in custom_variables.items() if v is not None } break current_step.language = "json" else: completion_start = self.completion_generations[str(run.id)] completion = generation.get("text", "") duration = time.time() - completion_start["start"] if duration and completion_start["token_count"]: throughput = completion_start["token_count"] / duration else: throughput = None current_step.generation = CompletionGeneration( provider=provider, model=model, settings=llm_settings, variables=variables, duration=duration, token_throughput_in_s=throughput, tt_first_token=completion_start.get("tt_first_token"), prompt=completion_start["prompt"], completion=completion, ) current_step.output = completion if current_step: current_step.end = utc_now() await current_step.update() if self.final_stream and self.has_streamed_final_answer: await self.final_stream.update() return if current_step: if current_step.type != "llm": current_step.output, current_step.language = process_content( run.outputs ) current_step.end = utc_now() await current_step.update() async def _on_error(self, error: BaseException, *, run_id: UUID, **kwargs: Any): context_var.set(self.context) if current_step := self.steps.get(str(run_id), None): current_step.is_error = True current_step.output = str(error) current_step.end = utc_now() await current_step.update() on_llm_error = _on_error on_chain_error = _on_error on_tool_error = _on_error on_retriever_error = _on_error LangchainCallbackHandler = LangchainTracer AsyncLangchainCallbackHandler = LangchainTracer ================================================ FILE: backend/chainlit/langflow/__init__.py ================================================ from chainlit.utils import check_module_version if not check_module_version("langflow", "0.1.4"): raise ValueError( "Expected Langflow version >= 0.1.4. Run `pip install langflow --upgrade`" ) from typing import Dict, Optional, Union import httpx async def load_flow(schema: Union[Dict, str], tweaks: Optional[Dict] = None): from langflow import load_flow_from_json if isinstance(schema, str): async with httpx.AsyncClient() as client: response = await client.get(schema) if response.status_code != 200: raise ValueError(f"Error: {response.text}") schema = response.json() flow = load_flow_from_json(flow=schema, tweaks=tweaks) return flow ================================================ FILE: backend/chainlit/llama_index/__init__.py ================================================ from chainlit.utils import check_module_version if not check_module_version("llama_index.core", "0.10.15"): raise ValueError( "Expected LlamaIndex version >= 0.10.15. Run `pip install llama_index --upgrade`" ) ================================================ FILE: backend/chainlit/llama_index/callbacks.py ================================================ from typing import Any, Dict, List, Optional from literalai import ChatGeneration, CompletionGeneration, GenerationMessage from llama_index.core.callbacks import TokenCountingHandler from llama_index.core.callbacks.schema import CBEventType, EventPayload from llama_index.core.llms import ChatMessage, ChatResponse, CompletionResponse from llama_index.core.tools.types import ToolMetadata from chainlit.context import context_var from chainlit.element import Text from chainlit.step import Step, StepType from chainlit.utils import utc_now DEFAULT_IGNORE = [ CBEventType.CHUNKING, CBEventType.SYNTHESIZE, CBEventType.EMBEDDING, CBEventType.NODE_PARSING, CBEventType.TREE, ] class LlamaIndexCallbackHandler(TokenCountingHandler): """Base callback handler that can be used to track event starts and ends.""" steps: Dict[str, Step] def __init__( self, event_starts_to_ignore: List[CBEventType] = DEFAULT_IGNORE, event_ends_to_ignore: List[CBEventType] = DEFAULT_IGNORE, ) -> None: """Initialize the base callback handler.""" super().__init__( event_starts_to_ignore=event_starts_to_ignore, event_ends_to_ignore=event_ends_to_ignore, ) self.steps = {} def _get_parent_id(self, event_parent_id: Optional[str] = None) -> Optional[str]: if event_parent_id and event_parent_id in self.steps: return event_parent_id elif context_var.get().current_step: return context_var.get().current_step.id else: return None def on_event_start( self, event_type: CBEventType, payload: Optional[Dict[str, Any]] = None, event_id: str = "", parent_id: str = "", **kwargs: Any, ) -> str: """Run when an event starts and return id of event.""" step_type: StepType = "undefined" step_name: str = event_type.value step_input: Optional[Dict[str, Any]] = payload if event_type == CBEventType.FUNCTION_CALL: step_type = "tool" if payload: metadata: Optional[ToolMetadata] = payload.get(EventPayload.TOOL) if metadata: step_name = getattr(metadata, "name", step_name) step_input = payload.get(EventPayload.FUNCTION_CALL) elif event_type == CBEventType.RETRIEVE: step_type = "tool" elif event_type == CBEventType.QUERY: step_type = "tool" elif event_type == CBEventType.LLM: step_type = "llm" else: return event_id step = Step( name=step_name, type=step_type, parent_id=self._get_parent_id(parent_id), id=event_id, ) self.steps[event_id] = step step.start = utc_now() step.input = step_input or {} context_var.get().loop.create_task(step.send()) return event_id def on_event_end( self, event_type: CBEventType, payload: Optional[Dict[str, Any]] = None, event_id: str = "", **kwargs: Any, ) -> None: """Run when an event ends.""" step = self.steps.get(event_id, None) if payload is None or step is None: return step.end = utc_now() if event_type == CBEventType.FUNCTION_CALL: response = payload.get(EventPayload.FUNCTION_OUTPUT) if response: step.output = f"{response}" context_var.get().loop.create_task(step.update()) elif event_type == CBEventType.QUERY: response = payload.get(EventPayload.RESPONSE) source_nodes = getattr(response, "source_nodes", None) if source_nodes: source_refs = ", ".join( [f"Source {idx}" for idx, _ in enumerate(source_nodes)] ) step.elements = [ Text( name=f"Source {idx}", content=source.text or "Empty node", display="side", ) for idx, source in enumerate(source_nodes) ] step.output = f"Retrieved the following sources: {source_refs}" context_var.get().loop.create_task(step.update()) elif event_type == CBEventType.RETRIEVE: sources = payload.get(EventPayload.NODES) if sources: source_refs = ", ".join( [f"Source {idx}" for idx, _ in enumerate(sources)] ) step.elements = [ Text( name=f"Source {idx}", display="side", content=source.node.get_text() or "Empty node", ) for idx, source in enumerate(sources) ] step.output = f"Retrieved the following sources: {source_refs}" context_var.get().loop.create_task(step.update()) elif event_type == CBEventType.LLM: formatted_messages = payload.get(EventPayload.MESSAGES) # type: Optional[List[ChatMessage]] formatted_prompt = payload.get(EventPayload.PROMPT) response = payload.get(EventPayload.RESPONSE) if formatted_messages: messages = [ GenerationMessage( role=m.role.value, # type: ignore content=m.content or "", ) for m in formatted_messages ] else: messages = None if isinstance(response, ChatResponse): content = response.message.content or "" elif isinstance(response, CompletionResponse): content = response.text else: content = "" step.output = content token_count = self.total_llm_token_count or None raw_response = response.raw if response else None model = getattr(raw_response, "model", None) if messages and isinstance(response, ChatResponse): msg: ChatMessage = response.message step.generation = ChatGeneration( model=model, messages=messages, message_completion=GenerationMessage( role=msg.role.value, # type: ignore content=content, ), token_count=token_count, ) elif formatted_prompt: step.generation = CompletionGeneration( model=model, prompt=formatted_prompt, completion=content, token_count=token_count, ) context_var.get().loop.create_task(step.update()) else: step.output = payload context_var.get().loop.create_task(step.update()) self.steps.pop(event_id, None) def _noop(self, *args, **kwargs): pass start_trace = _noop end_trace = _noop ================================================ FILE: backend/chainlit/logger.py ================================================ import logging logging.getLogger("socketio").setLevel(logging.ERROR) logging.getLogger("engineio").setLevel(logging.ERROR) logging.getLogger("numexpr").setLevel(logging.ERROR) logger = logging.getLogger("chainlit") ================================================ FILE: backend/chainlit/markdown.py ================================================ import os from pathlib import Path from typing import Optional from chainlit.logger import logger from ._utils import is_path_inside # Default chainlit.md file created if none exists DEFAULT_MARKDOWN_STR = """# Welcome to Chainlit! 🚀🤖 Hi there, Developer! 👋 We're excited to have you on board. Chainlit is a powerful tool designed to help you prototype, debug and share applications built on top of LLMs. ## Useful Links 🔗 - **Documentation:** Get started with our comprehensive [Chainlit Documentation](https://docs.chainlit.io) 📚 - **Discord Community:** Join our friendly [Chainlit Discord](https://discord.gg/k73SQ3FyUh) to ask questions, share your projects, and connect with other developers! 💬 We can't wait to see what you create with Chainlit! Happy coding! 💻😊 ## Welcome screen To modify the welcome screen, edit the `chainlit.md` file at the root of your project. If you do not want a welcome screen, just leave this file empty. """ def init_markdown(root: str): """Initialize the chainlit.md file if it doesn't exist.""" chainlit_md_file = os.path.join(root, "chainlit.md") if not os.path.exists(chainlit_md_file): with open(chainlit_md_file, "w", encoding="utf-8") as f: f.write(DEFAULT_MARKDOWN_STR) logger.info(f"Created default chainlit markdown file at {chainlit_md_file}") def get_markdown_str(root: str, language: str) -> Optional[str]: """Get the chainlit.md file as a string.""" root_path = Path(root) translated_chainlit_md_path = root_path / f"chainlit_{language}.md" default_chainlit_md_path = root_path / "chainlit.md" if ( is_path_inside(translated_chainlit_md_path, root_path) and translated_chainlit_md_path.is_file() ): chainlit_md_path = translated_chainlit_md_path else: chainlit_md_path = default_chainlit_md_path logger.warning( f"Translated markdown file for {language} not found. Defaulting to chainlit.md." ) if chainlit_md_path.is_file(): return chainlit_md_path.read_text(encoding="utf-8") else: return None ================================================ FILE: backend/chainlit/mcp.py ================================================ import shlex from typing import Dict, Literal, Optional, Union from pydantic import BaseModel from chainlit.config import config class StdioMcpConnection(BaseModel): name: str command: str args: list[str] clientType: Literal["stdio"] = "stdio" class SseMcpConnection(BaseModel): name: str url: str headers: Optional[Dict[str, str]] = None clientType: Literal["sse"] = "sse" class HttpMcpConnection(BaseModel): name: str url: str headers: Optional[Dict[str, str]] = None clientType: Literal["streamable-http"] = "streamable-http" McpConnection = Union[StdioMcpConnection, SseMcpConnection, HttpMcpConnection] def validate_mcp_command(command_string: str): """ Validates that a command string uses command in the allowed list as the executable and returns the executable and list of arguments suitable for subprocess calls. This function handles potential command prefixes, flags, and options to ensure only commands in allowed list are allowed. Args: command_string (str): The full command string to validate Returns: tuple: (env, executable, args_list) where: - env (dict): Environment variables as a dictionary - executable (str): The executable name or path - args_list (list): List of command arguments Raises: ValueError: If the command doesn't use an allowed executable """ # Split the command string into parts while respecting quotes and escapes # Using shlex.split provides POSIX-compatible parsing so that arguments # wrapped in quotes (e.g. "--header \"Authorization: Bearer TOKEN\"") # or environment variable assignments such as # MY_VAR="value with spaces" are preserved as single list items. # On Windows, shlex also works as long as posix=False is not required for # our use-case (Chainlit targets POSIX-style shells for the MCP command). try: parts = shlex.split(command_string, posix=True) except ValueError as exc: # Provide a clearer error message when the command cannot be parsed raise ValueError(f"Invalid command string: {exc}") from exc if not parts: raise ValueError("Empty command string") # Look for the actual executable in the command executable = None executable_index = None allowed_executables = config.features.mcp.stdio.allowed_executables for i, part in enumerate(parts): # Remove any path components to get the base executable name base_exec = part.split("/")[-1].split("\\")[-1] if allowed_executables is None or base_exec in allowed_executables: executable = part executable_index = i break if executable is None or executable_index is None: raise ValueError( f"Only commands in ({', '.join(allowed_executables)}) are allowed" if allowed_executables else "No allowed executables found" ) # Return `executable` as the executable and everything after it as args args_list = parts[executable_index + 1 :] env_list = parts[:executable_index] env = {} for env_var in env_list: if "=" in env_var: key, value = env_var.split("=", 1) env[key] = value else: raise ValueError(f"Invalid environment variable format: {env_var}") return env, executable, args_list ================================================ FILE: backend/chainlit/message.py ================================================ import asyncio import json import time import uuid from abc import ABC from typing import Dict, List, Optional, Union, cast from literalai.observability.step import MessageStepType from chainlit.action import Action from chainlit.chat_context import chat_context from chainlit.config import config from chainlit.context import context, local_steps from chainlit.data import get_data_layer from chainlit.element import CustomElement, ElementBased from chainlit.logger import logger from chainlit.step import StepDict from chainlit.types import ( AskActionResponse, AskActionSpec, AskElementResponse, AskElementSpec, AskFileResponse, AskFileSpec, AskSpec, FileDict, ) from chainlit.utils import utc_now class MessageBase(ABC): id: str thread_id: str author: str content: str = "" type: MessageStepType = "assistant_message" streaming = False created_at: Union[str, None] = None fail_on_persist_error: bool = False persisted = False is_error = False command: Optional[str] = None modes: Optional[Dict[str, str]] = None parent_id: Optional[str] = None language: Optional[str] = None metadata: Optional[Dict] = None tags: Optional[List[str]] = None wait_for_answer = False def __post_init__(self) -> None: self.thread_id = context.session.thread_id previous_steps = local_steps.get() or [] parent_step = previous_steps[-1] if previous_steps else None if parent_step: self.parent_id = parent_step.id if not getattr(self, "id", None): self.id = str(uuid.uuid4()) @classmethod def from_dict(self, _dict: StepDict): type = _dict.get("type", "assistant_message") return Message( id=_dict["id"], parent_id=_dict.get("parentId"), created_at=_dict["createdAt"], content=_dict["output"], author=_dict.get("name", config.ui.name), command=_dict.get("command"), modes=_dict.get("modes"), type=type, # type: ignore language=_dict.get("language"), metadata=_dict.get("metadata", {}), ) def to_dict(self) -> StepDict: _dict: StepDict = { "id": self.id, "threadId": self.thread_id, "parentId": self.parent_id, "createdAt": self.created_at, "command": self.command, "modes": self.modes, "start": self.created_at, "end": self.created_at, "output": self.content, "name": self.author, "type": self.type, "language": self.language, "streaming": self.streaming, "isError": self.is_error, "waitForAnswer": self.wait_for_answer, "metadata": self.metadata or {}, "tags": self.tags, } return _dict async def update( self, ): """ Update a message already sent to the UI. """ if self.streaming: self.streaming = False step_dict = self.to_dict() chat_context.add(self) data_layer = get_data_layer() if data_layer: try: asyncio.create_task(data_layer.update_step(step_dict)) except Exception as e: if self.fail_on_persist_error: raise e logger.error(f"Failed to persist message update: {e!s}") await context.emitter.update_step(step_dict) return True async def remove(self): """ Remove a message already sent to the UI. """ chat_context.remove(self) step_dict = self.to_dict() data_layer = get_data_layer() if data_layer: try: asyncio.create_task(data_layer.delete_step(step_dict["id"])) except Exception as e: if self.fail_on_persist_error: raise e logger.error(f"Failed to persist message deletion: {e!s}") await context.emitter.delete_step(step_dict) return True async def _create(self): step_dict = self.to_dict() data_layer = get_data_layer() if data_layer and not self.persisted: try: asyncio.create_task(data_layer.create_step(step_dict)) self.persisted = True except Exception as e: if self.fail_on_persist_error: raise e logger.error(f"Failed to persist message creation: {e!s}") return step_dict async def send(self): if not self.created_at: self.created_at = utc_now() if self.content is None: self.content = "" if config.code.author_rename: self.author = await config.code.author_rename(self.author) if self.streaming: self.streaming = False step_dict = await self._create() chat_context.add(self) await context.emitter.send_step(step_dict) return self async def stream_token(self, token: str, is_sequence=False): """ Sends a token to the UI. This is useful for streaming messages. Once all tokens have been streamed, call .send() to end the stream and persist the message if persistence is enabled. """ if not token: return if is_sequence: self.content = token else: self.content += token assert self.id if not self.streaming: self.streaming = True step_dict = self.to_dict() await context.emitter.stream_start(step_dict) else: await context.emitter.send_token( id=self.id, token=token, is_sequence=is_sequence ) class Message(MessageBase): """ Send a message to the UI Args: content (Union[str, Dict]): The content of the message. author (str, optional): The author of the message, this will be used in the UI. Defaults to the assistant name (see config). language (str, optional): Language of the code is the content is code. See https://react-code-blocks-rajinwonderland.vercel.app/?path=/story/codeblock--supported-languages for a list of supported languages. actions (List[Action], optional): A list of actions to send with the message. elements (List[ElementBased], optional): A list of elements to send with the message. """ def __init__( self, content: Union[str, Dict], author: Optional[str] = None, language: Optional[str] = None, actions: Optional[List[Action]] = None, elements: Optional[List[ElementBased]] = None, type: MessageStepType = "assistant_message", metadata: Optional[Dict] = None, tags: Optional[List[str]] = None, id: Optional[str] = None, parent_id: Optional[str] = None, command: Optional[str] = None, modes: Optional[Dict[str, str]] = None, created_at: Union[str, None] = None, ): time.sleep(0.001) self.language = language if isinstance(content, dict): try: self.content = json.dumps(content, indent=4, ensure_ascii=False) self.language = "json" except TypeError: self.content = str(content) self.language = "text" elif isinstance(content, str): self.content = content else: self.content = str(content) self.language = "text" if id: self.id = str(id) if parent_id: self.parent_id = str(parent_id) if command: self.command = str(command) if modes: self.modes = modes if created_at: self.created_at = created_at self.metadata = metadata self.tags = tags self.author = author or config.ui.name self.type = type self.actions = actions if actions is not None else [] self.elements = elements if elements is not None else [] super().__post_init__() async def send(self): """ Send the message to the UI and persist it in the cloud if a project ID is configured. Return the ID of the message. """ await super().send() # Create tasks for all actions and elements tasks = [action.send(for_id=self.id) for action in self.actions] tasks.extend(element.send(for_id=self.id) for element in self.elements) # Run all tasks concurrently await asyncio.gather(*tasks) return self async def update(self): """ Send the message to the UI and persist it in the cloud if a project ID is configured. Return the ID of the message. """ await super().update() # Update tasks for all actions and elements tasks = [ action.send(for_id=self.id) for action in self.actions if action.forId is None ] tasks.extend(element.send(for_id=self.id) for element in self.elements) # Run all tasks concurrently await asyncio.gather(*tasks) return True async def remove_actions(self): for action in self.actions: await action.remove() class ErrorMessage(MessageBase): """ Send an error message to the UI If a project ID is configured, the message will be persisted in the cloud. Args: content (str): Text displayed above the upload button. author (str, optional): The author of the message, this will be used in the UI. Defaults to the assistant name (see config). """ def __init__( self, content: str, author: str = config.ui.name, fail_on_persist_error: bool = False, ): self.content = content self.author = author self.type = "assistant_message" self.is_error = True self.fail_on_persist_error = fail_on_persist_error super().__post_init__() async def send(self): """ Send the error message to the UI and persist it in the cloud if a project ID is configured. Return the ID of the message. """ return await super().send() class AskMessageBase(MessageBase): async def remove(self): removed = await super().remove() if removed: await context.emitter.clear("clear_ask") class AskUserMessage(AskMessageBase): """ Ask for the user input before continuing. If the user does not answer in time (see timeout), a TimeoutError will be raised or None will be returned depending on raise_on_timeout. If a project ID is configured, the message will be uploaded to the cloud storage. Args: content (str): The content of the prompt. author (str, optional): The author of the message, this will be used in the UI. Defaults to the assistant name (see config). timeout (int, optional): The number of seconds to wait for an answer before raising a TimeoutError. raise_on_timeout (bool, optional): Whether to raise a socketio TimeoutError if the user does not answer in time. """ def __init__( self, content: str, author: str = config.ui.name, type: MessageStepType = "assistant_message", timeout: int = 60, raise_on_timeout: bool = False, ): self.content = content self.author = author self.timeout = timeout self.type = type self.raise_on_timeout = raise_on_timeout super().__post_init__() async def send(self) -> Union[StepDict, None]: """ Sends the question to ask to the UI and waits for the reply. """ if not self.created_at: self.created_at = utc_now() if config.code.author_rename: self.author = await config.code.author_rename(self.author) if self.streaming: self.streaming = False self.wait_for_answer = True step_dict = await self._create() spec = AskSpec(type="text", step_id=step_dict["id"], timeout=self.timeout) res = cast( Union[None, StepDict], await context.emitter.send_ask_user(step_dict, spec, self.raise_on_timeout), ) self.wait_for_answer = False return res class AskFileMessage(AskMessageBase): """ Ask the user to upload a file before continuing. If the user does not answer in time (see timeout), a TimeoutError will be raised or None will be returned depending on raise_on_timeout. If a project ID is configured, the file will be uploaded to the cloud storage. Args: content (str): Text displayed above the upload button. accept (Union[List[str], Dict[str, List[str]]]): List of mime type to accept like ["text/csv", "application/pdf"] or a dict like {"text/plain": [".txt", ".py"]}. max_size_mb (int, optional): Maximum size per file in MB. Maximum value is 100. max_files (int, optional): Maximum number of files to upload. Maximum value is 10. author (str, optional): The author of the message, this will be used in the UI. Defaults to the assistant name (see config). timeout (int, optional): The number of seconds to wait for an answer before raising a TimeoutError. raise_on_timeout (bool, optional): Whether to raise a socketio TimeoutError if the user does not answer in time. """ def __init__( self, content: str, accept: Union[List[str], Dict[str, List[str]]], max_size_mb=2, max_files=1, author=config.ui.name, type: MessageStepType = "assistant_message", timeout=90, raise_on_timeout=False, ): self.content = content self.max_size_mb = max_size_mb self.max_files = max_files self.accept = accept self.type = type self.author = author self.timeout = timeout self.raise_on_timeout = raise_on_timeout super().__post_init__() async def send(self) -> Union[List[AskFileResponse], None]: """ Sends the message to request a file from the user to the UI and waits for the reply. """ if not self.created_at: self.created_at = utc_now() if self.streaming: self.streaming = False if config.code.author_rename: self.author = await config.code.author_rename(self.author) self.wait_for_answer = True step_dict = await self._create() spec = AskFileSpec( type="file", step_id=step_dict["id"], accept=self.accept, max_size_mb=self.max_size_mb, max_files=self.max_files, timeout=self.timeout, ) res = cast( Union[None, List[FileDict]], await context.emitter.send_ask_user(step_dict, spec, self.raise_on_timeout), ) self.wait_for_answer = False if res: return [ AskFileResponse( id=r["id"], name=r["name"], path=str(r["path"]), size=r["size"], type=r["type"], ) for r in res ] else: return None class AskActionMessage(AskMessageBase): """ Ask the user to select an action before continuing. If the user does not answer in time (see timeout), a TimeoutError will be raised or None will be returned depending on raise_on_timeout. """ def __init__( self, content: str, actions: List[Action], author=config.ui.name, timeout=90, raise_on_timeout=False, ): self.content = content self.actions = actions self.author = author self.timeout = timeout self.raise_on_timeout = raise_on_timeout super().__post_init__() async def send(self) -> Union[AskActionResponse, None]: """ Sends the question to ask to the UI and waits for the reply """ if not self.created_at: self.created_at = utc_now() if self.streaming: self.streaming = False if config.code.author_rename: self.author = await config.code.author_rename(self.author) self.wait_for_answer = True step_dict = await self._create() action_keys = [] for action in self.actions: action_keys.append(action.id) await action.send(for_id=str(step_dict["id"])) spec = AskActionSpec( type="action", step_id=step_dict["id"], timeout=self.timeout, keys=action_keys, ) res = cast( Union[AskActionResponse, None], await context.emitter.send_ask_user(step_dict, spec, self.raise_on_timeout), ) for action in self.actions: await action.remove() if res is None: self.content = "Timed out: no action was taken" else: self.content = f"**Selected:** {res['label']}" self.wait_for_answer = False await self.update() return res class AskElementMessage(AskMessageBase): """Ask the user to submit a custom element.""" def __init__( self, content: str, element: CustomElement, author=config.ui.name, timeout=90, raise_on_timeout=False, ): self.content = content self.element = element self.author = author self.timeout = timeout self.raise_on_timeout = raise_on_timeout super().__post_init__() async def send(self) -> Union[AskElementResponse, None]: """Send the custom element to the UI and wait for the reply.""" if not self.created_at: self.created_at = utc_now() if self.streaming: self.streaming = False if config.code.author_rename: self.author = await config.code.author_rename(self.author) self.wait_for_answer = True step_dict = await self._create() await self.element.send(for_id=str(step_dict["id"])) spec = AskElementSpec( type="element", step_id=step_dict["id"], timeout=self.timeout, element_id=self.element.id, ) res = cast( Union[AskElementResponse, None], await context.emitter.send_ask_user(step_dict, spec, self.raise_on_timeout), ) await self.element.remove() if res is None: self.content = "Timed out" elif res.get("submitted"): self.content = "Thanks for submitting" else: self.content = "Cancelled" self.wait_for_answer = False await self.update() return res ================================================ FILE: backend/chainlit/mistralai/__init__.py ================================================ import asyncio from typing import Union from literalai import ChatGeneration, CompletionGeneration from chainlit.context import get_context from chainlit.step import Step from chainlit.utils import timestamp_utc def instrument_mistralai(): from literalai.instrumentation.mistralai import instrument_mistralai def on_new_generation( generation: Union["ChatGeneration", "CompletionGeneration"], timing ): context = get_context() parent_id = None if context.current_step: parent_id = context.current_step.id step = Step( name=generation.model if generation.model else generation.provider, type="llm", parent_id=parent_id, ) step.generation = generation # Convert start/end time from seconds to milliseconds step.start = ( timestamp_utc(timing.get("start")) if timing.get("start", None) is not None else None ) step.end = ( timestamp_utc(timing.get("end")) if timing.get("end", None) is not None else None ) if isinstance(generation, ChatGeneration): step.input = generation.messages # type: ignore step.output = generation.message_completion # type: ignore else: step.input = generation.prompt # type: ignore step.output = generation.completion # type: ignore asyncio.create_task(step.send()) instrument_mistralai(None, on_new_generation) ================================================ FILE: backend/chainlit/mode.py ================================================ """Mode and ModeOption dataclasses for the Modes system. The Modes system allows developers to define multiple picker categories (e.g., Model, Approach, Reasoning Effort) that users can select from in the chat composer. """ from dataclasses import dataclass, field from typing import List, Optional from dataclasses_json import DataClassJsonMixin @dataclass class ModeOption(DataClassJsonMixin): """A single selectable option within a Mode. Attributes: id: Unique identifier for this option (e.g., "gpt-5", "planning") name: Display name shown in the UI (e.g., "GPT-5", "Planning") description: Optional description shown in the dropdown icon: Optional icon - can be a Lucide icon name, local path, or URL default: Whether this is the default selected option for its mode """ id: str name: str description: Optional[str] = None icon: Optional[str] = None default: bool = False @dataclass class Mode(DataClassJsonMixin): """A category of options the user can select from. Each Mode represents a picker dropdown in the chat composer. Users select exactly one option per mode. Attributes: id: Unique identifier for this mode (e.g., "llm", "approach") name: Display name shown in the UI (e.g., "Model", "Approach") options: List of available options for this mode """ id: str name: str options: List[ModeOption] = field(default_factory=list) def get_default_option(self) -> Optional[ModeOption]: """Get the default option for this mode, or the first option if none is default.""" for option in self.options: if option.default: return option return self.options[0] if self.options else None def get_option_by_id(self, option_id: str) -> Optional[ModeOption]: """Get an option by its ID.""" for option in self.options: if option.id == option_id: return option return None ================================================ FILE: backend/chainlit/oauth_providers.py ================================================ import base64 import os import urllib.parse from typing import Dict, List, Optional, Tuple import httpx from fastapi import HTTPException from chainlit.secret import random_secret from chainlit.user import User ACCESS_TOKEN_MISSING = "Access token missing in the response" class OAuthProvider: id: str env: List[str] client_id: str client_secret: str authorize_url: str authorize_params: Dict[str, str] default_prompt: Optional[str] = None def is_configured(self): return all([os.environ.get(env) for env in self.env]) async def get_raw_token_response(self, code: str, url: str) -> dict: raise NotImplementedError async def get_token(self, code: str, url: str) -> str: raise NotImplementedError async def get_user_info(self, token: str) -> Tuple[Dict[str, str], User]: raise NotImplementedError def get_env_prefix(self) -> str: """Return environment prefix, like AZURE_AD.""" return self.id.replace("-", "_").upper() def get_prompt(self) -> Optional[str]: """Return OAuth prompt param.""" if prompt := os.environ.get(f"OAUTH_{self.get_env_prefix()}_PROMPT"): return prompt if prompt := os.environ.get("OAUTH_PROMPT"): return prompt return self.default_prompt class GithubOAuthProvider(OAuthProvider): id = "github" env = ["OAUTH_GITHUB_CLIENT_ID", "OAUTH_GITHUB_CLIENT_SECRET"] authorize_url = os.environ.get( "OAUTH_GITHUB_AUTH_URL", "https://github.com/login/oauth/authorize" ) token_url = os.environ.get( "OAUTH_GITHUB_TOKEN_URL", "https://github.com/login/oauth/access_token" ) user_info_url = os.environ.get( "OAUTH_GITHUB_USER_INFO_URL", "https://api.github.com/user" ) def __init__(self): self.client_id = os.environ.get("OAUTH_GITHUB_CLIENT_ID") self.client_secret = os.environ.get("OAUTH_GITHUB_CLIENT_SECRET") self.authorize_params = { "scope": "user:email", } if prompt := self.get_prompt(): self.authorize_params["prompt"] = prompt async def get_raw_token_response(self, code: str, url: str) -> Dict[str, List[str]]: payload = { "client_id": self.client_id, "client_secret": self.client_secret, "code": code, } async with httpx.AsyncClient() as client: response = await client.post( self.token_url, data=payload, ) response.raise_for_status() return urllib.parse.parse_qs(response.text) async def get_token(self, code: str, url: str): content = await self.get_raw_token_response(code, url) token = content.get("access_token", [""])[0] if not token: raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING) return token async def get_user_info(self, token: str): async with httpx.AsyncClient() as client: user_response = await client.get( self.user_info_url, headers={"Authorization": f"token {token}"}, ) user_response.raise_for_status() github_user = user_response.json() emails_response = await client.get( urllib.parse.urljoin(self.user_info_url + "/", "emails"), headers={"Authorization": f"token {token}"}, ) emails_response.raise_for_status() emails = emails_response.json() github_user.update({"emails": emails}) user = User( identifier=github_user["login"], metadata={"image": github_user["avatar_url"], "provider": "github"}, ) return (github_user, user) class GoogleOAuthProvider(OAuthProvider): id = "google" env = ["OAUTH_GOOGLE_CLIENT_ID", "OAUTH_GOOGLE_CLIENT_SECRET"] authorize_url = "https://accounts.google.com/o/oauth2/v2/auth" def __init__(self): self.client_id = os.environ.get("OAUTH_GOOGLE_CLIENT_ID") self.client_secret = os.environ.get("OAUTH_GOOGLE_CLIENT_SECRET") self.authorize_params = { "scope": "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email", "response_type": "code", "access_type": "offline", } if prompt := self.get_prompt(): self.authorize_params["prompt"] = prompt async def get_raw_token_response(self, code: str, url: str) -> dict: payload = { "client_id": self.client_id, "client_secret": self.client_secret, "code": code, "grant_type": "authorization_code", "redirect_uri": url, } async with httpx.AsyncClient() as client: response = await client.post( "https://oauth2.googleapis.com/token", data=payload, ) response.raise_for_status() return response.json() async def get_token(self, code: str, url: str): json = await self.get_raw_token_response(code, url) token = json.get("access_token") if not token: raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING) return token async def get_user_info(self, token: str): async with httpx.AsyncClient() as client: response = await client.get( "https://www.googleapis.com/userinfo/v2/me", headers={"Authorization": f"Bearer {token}"}, ) response.raise_for_status() google_user = response.json() user = User( identifier=google_user["email"], metadata={"image": google_user["picture"], "provider": "google"}, ) return (google_user, user) class AzureADOAuthProvider(OAuthProvider): id = "azure-ad" env = [ "OAUTH_AZURE_AD_CLIENT_ID", "OAUTH_AZURE_AD_CLIENT_SECRET", "OAUTH_AZURE_AD_TENANT_ID", ] authorize_url = ( f"https://login.microsoftonline.com/{os.environ.get('OAUTH_AZURE_AD_TENANT_ID', '')}/oauth2/v2.0/authorize" if os.environ.get("OAUTH_AZURE_AD_ENABLE_SINGLE_TENANT") else "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" ) token_url = ( f"https://login.microsoftonline.com/{os.environ.get('OAUTH_AZURE_AD_TENANT_ID', '')}/oauth2/v2.0/token" if os.environ.get("OAUTH_AZURE_AD_ENABLE_SINGLE_TENANT") else "https://login.microsoftonline.com/common/oauth2/v2.0/token" ) def __init__(self): self.client_id = os.environ.get("OAUTH_AZURE_AD_CLIENT_ID") self.client_secret = os.environ.get("OAUTH_AZURE_AD_CLIENT_SECRET") self.authorize_params = { "tenant": os.environ.get("OAUTH_AZURE_AD_TENANT_ID"), "response_type": "code", "scope": "https://graph.microsoft.com/User.Read offline_access", "response_mode": "query", } if prompt := self.get_prompt(): self.authorize_params["prompt"] = prompt async def get_raw_token_response(self, code: str, url: str) -> dict: payload = { "client_id": self.client_id, "client_secret": self.client_secret, "code": code, "grant_type": "authorization_code", "redirect_uri": url, } async with httpx.AsyncClient() as client: response = await client.post( self.token_url, data=payload, ) response.raise_for_status() return response.json() async def get_token(self, code: str, url: str): json = await self.get_raw_token_response(code, url) token = json["access_token"] refresh_token = json.get("refresh_token") if not token: raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING) self._refresh_token = refresh_token return token async def get_user_info(self, token: str): async with httpx.AsyncClient() as client: response = await client.get( "https://graph.microsoft.com/v1.0/me", headers={"Authorization": f"Bearer {token}"}, ) response.raise_for_status() azure_user = response.json() try: photo_response = await client.get( "https://graph.microsoft.com/v1.0/me/photos/48x48/$value", headers={"Authorization": f"Bearer {token}"}, ) photo_data = await photo_response.aread() base64_image = base64.b64encode(photo_data) azure_user["image"] = ( f"data:{photo_response.headers['Content-Type']};base64,{base64_image.decode('utf-8')}" ) except Exception: # Ignore errors getting the photo pass user = User( identifier=azure_user["userPrincipalName"], metadata={ "image": azure_user.get("image"), "provider": "azure-ad", "refresh_token": getattr(self, "_refresh_token", None), }, ) return (azure_user, user) class AzureADHybridOAuthProvider(OAuthProvider): id = "azure-ad-hybrid" env = [ "OAUTH_AZURE_AD_HYBRID_CLIENT_ID", "OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET", "OAUTH_AZURE_AD_HYBRID_TENANT_ID", ] authorize_url = ( f"https://login.microsoftonline.com/{os.environ.get('OAUTH_AZURE_AD_HYBRID_TENANT_ID', '')}/oauth2/v2.0/authorize" if os.environ.get("OAUTH_AZURE_AD_HYBRID_ENABLE_SINGLE_TENANT") else "https://login.microsoftonline.com/common/oauth2/v2.0/authorize" ) token_url = ( f"https://login.microsoftonline.com/{os.environ.get('OAUTH_AZURE_AD_HYBRID_TENANT_ID', '')}/oauth2/v2.0/token" if os.environ.get("OAUTH_AZURE_AD_HYBRID_ENABLE_SINGLE_TENANT") else "https://login.microsoftonline.com/common/oauth2/v2.0/token" ) def __init__(self): self.client_id = os.environ.get("OAUTH_AZURE_AD_HYBRID_CLIENT_ID") self.client_secret = os.environ.get("OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET") nonce = random_secret(16) self.authorize_params = { "tenant": os.environ.get("OAUTH_AZURE_AD_HYBRID_TENANT_ID"), "response_type": "code id_token", "scope": "https://graph.microsoft.com/User.Read https://graph.microsoft.com/openid offline_access", "response_mode": "form_post", "nonce": nonce, } if prompt := self.get_prompt(): self.authorize_params["prompt"] = prompt async def get_raw_token_response(self, code: str, url: str) -> dict: payload = { "client_id": self.client_id, "client_secret": self.client_secret, "code": code, "grant_type": "authorization_code", "redirect_uri": url, } async with httpx.AsyncClient() as client: response = await client.post( self.token_url, data=payload, ) response.raise_for_status() return response.json() async def get_token(self, code: str, url: str): json = await self.get_raw_token_response(code, url) token = json["access_token"] refresh_token = json.get("refresh_token") if not token: raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING) self._refresh_token = refresh_token return token async def get_user_info(self, token: str): async with httpx.AsyncClient() as client: response = await client.get( "https://graph.microsoft.com/v1.0/me", headers={"Authorization": f"Bearer {token}"}, ) response.raise_for_status() azure_user = response.json() try: photo_response = await client.get( "https://graph.microsoft.com/v1.0/me/photos/48x48/$value", headers={"Authorization": f"Bearer {token}"}, ) photo_data = await photo_response.aread() base64_image = base64.b64encode(photo_data) azure_user["image"] = ( f"data:{photo_response.headers['Content-Type']};base64,{base64_image.decode('utf-8')}" ) except Exception: # Ignore errors getting the photo pass user = User( identifier=azure_user["userPrincipalName"], metadata={ "image": azure_user.get("image"), "provider": "azure-ad", "refresh_token": getattr(self, "_refresh_token", None), }, ) return (azure_user, user) class OktaOAuthProvider(OAuthProvider): id = "okta" env = [ "OAUTH_OKTA_CLIENT_ID", "OAUTH_OKTA_CLIENT_SECRET", "OAUTH_OKTA_DOMAIN", ] # Avoid trailing slash in domain if supplied domain = f"https://{os.environ.get('OAUTH_OKTA_DOMAIN', '').rstrip('/')}" def __init__(self): self.client_id = os.environ.get("OAUTH_OKTA_CLIENT_ID") self.client_secret = os.environ.get("OAUTH_OKTA_CLIENT_SECRET") self.authorization_server_id = os.environ.get( "OAUTH_OKTA_AUTHORIZATION_SERVER_ID", "" ) self.authorize_url = ( f"{self.domain}/oauth2{self.get_authorization_server_path()}/v1/authorize" ) self.authorize_params = { "response_type": "code", "scope": "openid profile email", "response_mode": "query", } if prompt := self.get_prompt(): self.authorize_params["prompt"] = prompt def get_authorization_server_path(self): if not self.authorization_server_id: return "/default" if self.authorization_server_id == "false": return "" return f"/{self.authorization_server_id}" async def get_raw_token_response(self, code: str, url: str) -> dict: payload = { "client_id": self.client_id, "client_secret": self.client_secret, "code": code, "grant_type": "authorization_code", "redirect_uri": url, } async with httpx.AsyncClient() as client: response = await client.post( f"{self.domain}/oauth2{self.get_authorization_server_path()}/v1/token", data=payload, ) response.raise_for_status() return response.json() async def get_token(self, code: str, url: str): json_data = await self.get_raw_token_response(code, url) token = json_data.get("access_token") if not token: raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING) return token async def get_user_info(self, token: str): async with httpx.AsyncClient() as client: response = await client.get( f"{self.domain}/oauth2{self.get_authorization_server_path()}/v1/userinfo", headers={"Authorization": f"Bearer {token}"}, ) response.raise_for_status() okta_user = response.json() user = User( identifier=okta_user.get("email"), metadata={"image": "", "provider": "okta"}, ) return (okta_user, user) class Auth0OAuthProvider(OAuthProvider): id = "auth0" env = ["OAUTH_AUTH0_CLIENT_ID", "OAUTH_AUTH0_CLIENT_SECRET", "OAUTH_AUTH0_DOMAIN"] def __init__(self): self.client_id = os.environ.get("OAUTH_AUTH0_CLIENT_ID") self.client_secret = os.environ.get("OAUTH_AUTH0_CLIENT_SECRET") # Ensure that the domain does not have a trailing slash self.domain = f"https://{os.environ.get('OAUTH_AUTH0_DOMAIN', '').rstrip('/')}" self.original_domain = ( f"https://{os.environ.get('OAUTH_AUTH0_ORIGINAL_DOMAIN').rstrip('/')}" if os.environ.get("OAUTH_AUTH0_ORIGINAL_DOMAIN") else self.domain ) self.authorize_url = f"{self.domain}/authorize" self.authorize_params = { "response_type": "code", "scope": "openid profile email", "audience": f"{self.original_domain}/userinfo", } if prompt := self.get_prompt(): self.authorize_params["prompt"] = prompt async def get_raw_token_response(self, code: str, url: str) -> dict: payload = { "client_id": self.client_id, "client_secret": self.client_secret, "code": code, "grant_type": "authorization_code", "redirect_uri": url, } async with httpx.AsyncClient() as client: response = await client.post( f"{self.domain}/oauth/token", data=payload, ) response.raise_for_status() return response.json() async def get_token(self, code: str, url: str): json_content = await self.get_raw_token_response(code, url) token = json_content.get("access_token") if not token: raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING) return token async def get_user_info(self, token: str): async with httpx.AsyncClient() as client: response = await client.get( f"{self.original_domain}/userinfo", headers={"Authorization": f"Bearer {token}"}, ) response.raise_for_status() auth0_user = response.json() user = User( identifier=auth0_user.get("email"), metadata={ "image": auth0_user.get("picture", ""), "provider": "auth0", }, ) return (auth0_user, user) class DescopeOAuthProvider(OAuthProvider): id = "descope" env = ["OAUTH_DESCOPE_CLIENT_ID", "OAUTH_DESCOPE_CLIENT_SECRET"] # Ensure that the domain does not have a trailing slash domain = "https://api.descope.com/oauth2/v1" authorize_url = f"{domain}/authorize" def __init__(self): self.client_id = os.environ.get("OAUTH_DESCOPE_CLIENT_ID") self.client_secret = os.environ.get("OAUTH_DESCOPE_CLIENT_SECRET") self.authorize_params = { "response_type": "code", "scope": "openid profile email", "audience": f"{self.domain}/userinfo", } if prompt := self.get_prompt(): self.authorize_params["prompt"] = prompt async def get_raw_token_response(self, code: str, url: str) -> dict: payload = { "client_id": self.client_id, "client_secret": self.client_secret, "code": code, "grant_type": "authorization_code", "redirect_uri": url, } async with httpx.AsyncClient() as client: response = await client.post( f"{self.domain}/token", data=payload, ) response.raise_for_status() return response.json() async def get_token(self, code: str, url: str): json_content = await self.get_raw_token_response(code, url) token = json_content.get("access_token") if not token: raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING) return token async def get_user_info(self, token: str): async with httpx.AsyncClient() as client: response = await client.get( f"{self.domain}/userinfo", headers={"Authorization": f"Bearer {token}"} ) response.raise_for_status() # This will raise an exception for 4xx/5xx responses descope_user = response.json() user = User( identifier=descope_user.get("email"), metadata={"image": "", "provider": "descope"}, ) return (descope_user, user) class AWSCognitoOAuthProvider(OAuthProvider): id = "aws-cognito" env = [ "OAUTH_COGNITO_CLIENT_ID", "OAUTH_COGNITO_CLIENT_SECRET", "OAUTH_COGNITO_DOMAIN", ] authorize_url = f"https://{os.environ.get('OAUTH_COGNITO_DOMAIN')}/login" token_url = f"https://{os.environ.get('OAUTH_COGNITO_DOMAIN')}/oauth2/token" def __init__(self): self.client_id = os.environ.get("OAUTH_COGNITO_CLIENT_ID") self.client_secret = os.environ.get("OAUTH_COGNITO_CLIENT_SECRET") self.scopes = os.environ.get("OAUTH_COGNITO_SCOPE", "openid profile email") self.authorize_params = { "response_type": "code", "client_id": self.client_id, "scope": self.scopes, } if prompt := self.get_prompt(): self.authorize_params["prompt"] = prompt async def get_raw_token_response(self, code: str, url: str) -> dict: payload = { "client_id": self.client_id, "client_secret": self.client_secret, "code": code, "grant_type": "authorization_code", "redirect_uri": url, } async with httpx.AsyncClient() as client: response = await client.post( self.token_url, data=payload, ) response.raise_for_status() return response.json() async def get_token(self, code: str, url: str): json = await self.get_raw_token_response(code, url) token = json.get("access_token") if not token: raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING) return token async def get_user_info(self, token: str): user_info_url = ( f"https://{os.environ.get('OAUTH_COGNITO_DOMAIN')}/oauth2/userInfo" ) async with httpx.AsyncClient() as client: response = await client.get( user_info_url, headers={"Authorization": f"Bearer {token}"}, ) response.raise_for_status() cognito_user = response.json() # Customize user metadata as needed user = User( identifier=cognito_user["email"], metadata={ "image": cognito_user.get("picture", ""), "provider": "aws-cognito", }, ) return (cognito_user, user) class GitlabOAuthProvider(OAuthProvider): id = "gitlab" env = [ "OAUTH_GITLAB_CLIENT_ID", "OAUTH_GITLAB_CLIENT_SECRET", "OAUTH_GITLAB_DOMAIN", ] def __init__(self): self.client_id = os.environ.get("OAUTH_GITLAB_CLIENT_ID") self.client_secret = os.environ.get("OAUTH_GITLAB_CLIENT_SECRET") # Ensure that the domain does not have a trailing slash self.domain = f"https://{os.environ.get('OAUTH_GITLAB_DOMAIN', '').rstrip('/')}" self.authorize_url = f"{self.domain}/oauth/authorize" self.authorize_params = { "scope": "openid profile email", "response_type": "code", } if prompt := self.get_prompt(): self.authorize_params["prompt"] = prompt async def get_raw_token_response(self, code: str, url: str) -> dict: payload = { "client_id": self.client_id, "client_secret": self.client_secret, "code": code, "grant_type": "authorization_code", "redirect_uri": url, } async with httpx.AsyncClient() as client: response = await client.post( f"{self.domain}/oauth/token", data=payload, ) response.raise_for_status() return response.json() async def get_token(self, code: str, url: str): json_content = await self.get_raw_token_response(code, url) token = json_content.get("access_token") if not token: raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING) return token async def get_user_info(self, token: str): async with httpx.AsyncClient() as client: response = await client.get( f"{self.domain}/oauth/userinfo", headers={"Authorization": f"Bearer {token}"}, ) response.raise_for_status() gitlab_user = response.json() user = User( identifier=gitlab_user.get("email"), metadata={ "image": gitlab_user.get("picture", ""), "provider": "gitlab", }, ) return (gitlab_user, user) class KeycloakOAuthProvider(OAuthProvider): env = [ "OAUTH_KEYCLOAK_CLIENT_ID", "OAUTH_KEYCLOAK_CLIENT_SECRET", "OAUTH_KEYCLOAK_REALM", "OAUTH_KEYCLOAK_BASE_URL", ] id = os.environ.get("OAUTH_KEYCLOAK_NAME", "keycloak") def __init__(self): self.refresh_token = None self.client_id = os.environ.get("OAUTH_KEYCLOAK_CLIENT_ID") self.client_secret = os.environ.get("OAUTH_KEYCLOAK_CLIENT_SECRET") self.realm = os.environ.get("OAUTH_KEYCLOAK_REALM") self.base_url = os.environ.get("OAUTH_KEYCLOAK_BASE_URL") self.authorize_url = ( f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/auth" ) self.authorize_params = { "scope": "profile email openid", "response_type": "code", } if prompt := self.get_prompt(): self.authorize_params["prompt"] = prompt async def get_raw_token_response(self, code: str, url: str) -> dict: payload = { "client_id": self.client_id, "client_secret": self.client_secret, "code": code, "grant_type": "authorization_code", "redirect_uri": url, } async with httpx.AsyncClient() as client: response = await client.post( f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token", data=payload, ) response.raise_for_status() return response.json() async def get_token(self, code: str, url: str): json = await self.get_raw_token_response(code, url) token = json.get("access_token") refresh_token = json.get("refresh_token") if not token: raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING) self.refresh_token = refresh_token return token async def get_user_info(self, token: str): async with httpx.AsyncClient() as client: response = await client.get( f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/userinfo", headers={"Authorization": f"Bearer {token}"}, ) response.raise_for_status() kc_user = response.json() user = User( identifier=kc_user["email"], metadata={"provider": "keycloak"}, ) return (kc_user, user) class GenericOAuthProvider(OAuthProvider): env = [ "OAUTH_GENERIC_CLIENT_ID", "OAUTH_GENERIC_CLIENT_SECRET", "OAUTH_GENERIC_AUTH_URL", "OAUTH_GENERIC_TOKEN_URL", "OAUTH_GENERIC_USER_INFO_URL", "OAUTH_GENERIC_SCOPES", ] id = os.environ.get("OAUTH_GENERIC_NAME", "generic") def __init__(self): self.client_id = os.environ.get("OAUTH_GENERIC_CLIENT_ID") self.client_secret = os.environ.get("OAUTH_GENERIC_CLIENT_SECRET") self.authorize_url = os.environ.get("OAUTH_GENERIC_AUTH_URL") self.token_url = os.environ.get("OAUTH_GENERIC_TOKEN_URL") self.user_info_url = os.environ.get("OAUTH_GENERIC_USER_INFO_URL") self.scopes = os.environ.get("OAUTH_GENERIC_SCOPES") self.user_identifier = os.environ.get("OAUTH_GENERIC_USER_IDENTIFIER", "email") self.authorize_params = { "scope": self.scopes, "response_type": "code", } if prompt := self.get_prompt(): self.authorize_params["prompt"] = prompt async def get_raw_token_response(self, code: str, url: str) -> dict: payload = { "client_id": self.client_id, "client_secret": self.client_secret, "code": code, "grant_type": "authorization_code", "redirect_uri": url, } async with httpx.AsyncClient() as client: response = await client.post(self.token_url, data=payload) response.raise_for_status() return response.json() async def get_token(self, code: str, url: str) -> str: json = await self.get_raw_token_response(code, url) token = json.get("access_token") if not token: raise HTTPException(status_code=400, detail=ACCESS_TOKEN_MISSING) return token async def get_user_info(self, token: str): async with httpx.AsyncClient() as client: response = await client.get( self.user_info_url, headers={"Authorization": f"Bearer {token}"}, ) response.raise_for_status() server_user = response.json() user = User( identifier=server_user.get(self.user_identifier), metadata={ "provider": self.id, }, ) return (server_user, user) providers = [ GithubOAuthProvider(), GoogleOAuthProvider(), AzureADOAuthProvider(), AzureADHybridOAuthProvider(), OktaOAuthProvider(), Auth0OAuthProvider(), DescopeOAuthProvider(), AWSCognitoOAuthProvider(), GitlabOAuthProvider(), KeycloakOAuthProvider(), GenericOAuthProvider(), ] def get_oauth_provider(provider: str) -> Optional[OAuthProvider]: for p in providers: if p.id == provider: return p return None def get_configured_oauth_providers(): return [p.id for p in providers if p.is_configured()] ================================================ FILE: backend/chainlit/openai/__init__.py ================================================ import asyncio from typing import Union from literalai import ChatGeneration, CompletionGeneration from chainlit.context import local_steps from chainlit.step import Step from chainlit.utils import check_module_version, timestamp_utc def instrument_openai(): if not check_module_version("openai", "1.0.0"): raise ValueError( "Expected OpenAI version >= 1.0.0. Run `pip install openai --upgrade`" ) from literalai.instrumentation.openai import instrument_openai def on_new_generation( generation: Union["ChatGeneration", "CompletionGeneration"], timing ): previous_steps = local_steps.get() parent_id = previous_steps[-1].id if previous_steps else None step = Step( name=generation.model if generation.model else generation.provider, type="llm", parent_id=parent_id, ) step.generation = generation # Convert start/end time from seconds to milliseconds step.start = ( timestamp_utc(timing.get("start")) if timing.get("start", None) is not None else None ) step.end = ( timestamp_utc(timing.get("end")) if timing.get("end", None) is not None else None ) if isinstance(generation, ChatGeneration): step.input = generation.messages # type: ignore step.output = generation.message_completion # type: ignore else: step.input = generation.prompt # type: ignore step.output = generation.completion # type: ignore asyncio.create_task(step.send()) instrument_openai(None, on_new_generation) ================================================ FILE: backend/chainlit/py.typed ================================================ ================================================ FILE: backend/chainlit/sample/hello.py ================================================ # This is a simple example of a chainlit app. from chainlit import AskUserMessage, Message, on_chat_start @on_chat_start async def main(): res = await AskUserMessage(content="What is your name?", timeout=30).send() if res: await Message( content=f"Your name is: {res['output']}.\nChainlit installation is working!\nYou can now start building your own chainlit apps!", ).send() ================================================ FILE: backend/chainlit/sample/starters_demo.py ================================================ from typing import Optional import chainlit as cl @cl.set_starter_categories async def starter_categories(user: Optional[cl.User] = None): return [ cl.StarterCategory( label="Creative", icon="https://cdn-icons-png.flaticon.com/512/3094/3094837.png", starters=[ cl.Starter( label="Write a poem about nature", message="Write a poem about nature", ), cl.Starter( label="Create a short story", message="Create a short story about adventure", ), cl.Starter( label="Generate a creative name", message="Generate creative names for a tech startup", ), ], ), cl.StarterCategory( label="Learning", icon="https://cdn-icons-png.flaticon.com/512/3976/3976625.png", starters=[ cl.Starter( label="Explain a complex topic", message="Explain quantum computing in simple terms", ), cl.Starter( label="Help me learn a language", message="Teach me basic French phrases", ), ], ), cl.StarterCategory( label="Productivity", icon="https://cdn-icons-png.flaticon.com/512/1055/1055646.png", starters=[ cl.Starter( label="Summarize a topic", message="Summarize the key points of machine learning", ), cl.Starter( label="Create a plan", message="Help me create a weekly study plan" ), ], ), ] @cl.on_message async def on_message(msg: cl.Message): await cl.Message(f"You said: {msg.content}").send() ================================================ FILE: backend/chainlit/secret.py ================================================ import secrets import string # Using punctuation, without chars that can break in the cli (quotes, backslash, backtick...) chars = string.ascii_letters + string.digits + "$%*,-./:=>?@^_~" def random_secret(length: int = 64): return "".join(secrets.choice(chars) for i in range(length)) ================================================ FILE: backend/chainlit/semantic_kernel/__init__.py ================================================ from collections.abc import Awaitable, Callable from typing import TYPE_CHECKING, Any from pydantic import BaseModel from chainlit import Step if TYPE_CHECKING: from semantic_kernel import Kernel from semantic_kernel.filters import FunctionInvocationContext from semantic_kernel.functions import KernelArguments class SemanticKernelFilter(BaseModel): """Semantic Kernel Filter for Chainlit. This filter wraps any function calls that are executed and will capture the input and output of that function as a Chainlit Step. You can pass your kernel into the constructor, or you can call `add_to_kernel` later. Args: excluded_plugins: a list of plugin_names that will be excluded from displaying steps. excluded_functions: a list of function names that will be excluded from displaying steps. kernel: the Kernel to add the filter to. If not provided, you can call `add_to_kernel` later. Methods: add_to_kernel: this method takes a Kernel and adds the filter to that kernel. parse_arguments: this method is called with KernelArguments used for the function it can be subclassed to customize how to represent the input arguments. Example:: filter = SemanticKernelFilter(kernel=kernel) # or when you create your kernel later on: filter = SemanticKernelFilter() # ... # other code, including kernel creation. # ... filter.add_to_kernel(kernel) """ excluded_plugins: list[str] | None = None excluded_functions: list[str] | None = None def __init__( self, excluded_plugins: list[str] | None = None, excluded_functions: list[str] | None = None, *, kernel: "Kernel | None" = None, ) -> None: super().__init__( excluded_plugins=excluded_plugins, excluded_functions=excluded_functions ) if kernel: self.add_to_kernel(kernel) def add_to_kernel(self, kernel: "Kernel") -> None: """Adds the filter to the provided kernel. Args: kernel: the Kernel to add the filter to. """ kernel.add_filter("function_invocation", self._function_invocation_filter) # type: ignore[arg-type] def parse_arguments(self, arguments: "KernelArguments") -> dict[str, Any] | str: """Parse the KernelArguments used for the function. This function can be subclassed to easily adopt how the input arguments are displayed. Args: arguments: KernelArguments Returns: a dict or string with the input. """ if len(arguments) == 0: return "" input_dict = {} for key, value in arguments.items(): if isinstance(value, BaseModel): input_dict[key] = value.model_dump(exclude_none=True, by_alias=True) else: input_dict[key] = value return input_dict async def _function_invocation_filter( self, context: "FunctionInvocationContext", next: Callable[["FunctionInvocationContext"], Awaitable[None]], ): if ( self.excluded_plugins and context.function.plugin_name in self.excluded_plugins ) or ( self.excluded_functions and context.function.name in self.excluded_functions ): await next(context) return async with Step( type="tool", name=context.function.fully_qualified_name ) as step: step.input = self.parse_arguments(context.arguments) await step.send() await next(context) if context.result: step.output = context.result.value await step.update() ================================================ FILE: backend/chainlit/server.py ================================================ import asyncio import fnmatch import glob import json import mimetypes import os import re import shutil import urllib.parse import webbrowser from contextlib import AsyncExitStack, asynccontextmanager from pathlib import Path from typing import TYPE_CHECKING, List, Optional, Union, cast import socketio from fastapi import ( APIRouter, Depends, FastAPI, Form, HTTPException, Query, Request, Response, UploadFile, status, ) from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, RedirectResponse from fastapi.security import OAuth2PasswordRequestForm from starlette.datastructures import URL from starlette.middleware.cors import CORSMiddleware from starlette.types import Receive, Scope, Send from typing_extensions import Annotated from watchfiles import awatch from chainlit.auth import create_jwt, decode_jwt, get_configuration, get_current_user from chainlit.auth.cookie import ( clear_auth_cookie, clear_oauth_state_cookie, set_auth_cookie, set_oauth_state_cookie, validate_oauth_state_cookie, ) from chainlit.config import ( APP_ROOT, BACKEND_ROOT, DEFAULT_HOST, FILES_DIRECTORY, PACKAGE_ROOT, ChainlitConfig, config, load_module, public_dir, reload_config, ) from chainlit.data import get_data_layer from chainlit.data.acl import is_thread_author from chainlit.logger import logger from chainlit.markdown import get_markdown_str from chainlit.oauth_providers import get_oauth_provider from chainlit.secret import random_secret from chainlit.types import ( AskFileSpec, CallActionRequest, ConnectMCPRequest, DeleteFeedbackRequest, DeleteThreadRequest, DisconnectMCPRequest, ElementRequest, GetThreadsRequest, ShareThreadRequest, Theme, UpdateFeedbackRequest, UpdateThreadRequest, ) from chainlit.user import PersistedUser, User from chainlit.utils import utc_now from ._utils import is_path_inside if TYPE_CHECKING: from chainlit.element import CustomElement, ElementDict mimetypes.add_type("application/javascript", ".js") mimetypes.add_type("text/css", ".css") @asynccontextmanager async def lifespan(app: FastAPI): """Context manager to handle app start and shutdown.""" if config.code.on_app_startup: await config.code.on_app_startup() host = config.run.host port = config.run.port root_path = os.getenv("CHAINLIT_ROOT_PATH", "") scheme = "https" if config.run.ssl_cert else "http" if host == DEFAULT_HOST: url = f"{scheme}://localhost:{port}{root_path}" else: url = f"{scheme}://{host}:{port}{root_path}" logger.info(f"Your app is available at {url}") if not config.run.headless: # Add a delay before opening the browser await asyncio.sleep(1) webbrowser.open(url) watch_task = None stop_event = asyncio.Event() if config.run.watch: async def watch_files_for_changes(): extensions = [".py"] files = ["chainlit.md", "config.toml"] async for changes in awatch(config.root, stop_event=stop_event): for change_type, file_path in changes: file_name = os.path.basename(file_path) file_ext = os.path.splitext(file_name)[1] if file_ext.lower() in extensions or file_name.lower() in files: logger.info( f"File {change_type.name}: {file_name}. Reloading app..." ) try: reload_config() except Exception as e: logger.error(f"Error reloading config: {e}") break # Reload the module if the module name is specified in the config if config.run.module_name: try: load_module(config.run.module_name, force_refresh=True) except Exception as e: logger.error(f"Error reloading module: {e}") await asyncio.sleep(1) await sio.emit("reload", {}) break watch_task = asyncio.create_task(watch_files_for_changes()) discord_task = None if discord_bot_token := os.environ.get("DISCORD_BOT_TOKEN"): from chainlit.discord.app import client discord_task = asyncio.create_task(client.start(discord_bot_token)) slack_task = None # Slack Socket Handler if env variable SLACK_WEBSOCKET_TOKEN is set if os.environ.get("SLACK_BOT_TOKEN") and os.environ.get("SLACK_WEBSOCKET_TOKEN"): from chainlit.slack.app import start_socket_mode slack_task = asyncio.create_task(start_socket_mode()) try: yield finally: try: if config.code.on_app_shutdown: await config.code.on_app_shutdown() if watch_task: stop_event.set() watch_task.cancel() await watch_task if discord_task: discord_task.cancel() await discord_task if slack_task: slack_task.cancel() await slack_task if data_layer := get_data_layer(): await data_layer.close() except asyncio.exceptions.CancelledError: pass if FILES_DIRECTORY.is_dir(): shutil.rmtree(FILES_DIRECTORY) # Force exit the process to avoid potential AnyIO threads still running os._exit(0) def get_build_dir(local_target: str, packaged_target: str) -> str: """ Get the build directory based on the UI build strategy. Args: local_target (str): The local target directory. packaged_target (str): The packaged target directory. Returns: str: The build directory """ local_build_dir = os.path.join(PACKAGE_ROOT, local_target, "dist") packaged_build_dir = os.path.join(BACKEND_ROOT, packaged_target, "dist") if config.ui.custom_build and os.path.exists( os.path.join(APP_ROOT, config.ui.custom_build) ): return os.path.join(APP_ROOT, config.ui.custom_build) elif os.path.exists(local_build_dir): return local_build_dir elif os.path.exists(packaged_build_dir): return packaged_build_dir else: raise FileNotFoundError(f"{local_target} built UI dir not found") build_dir = get_build_dir("frontend", "frontend") copilot_build_dir = get_build_dir(os.path.join("libs", "copilot"), "copilot") app = FastAPI(lifespan=lifespan) sio = socketio.AsyncServer(cors_allowed_origins=[], async_mode="asgi") asgi_app = socketio.ASGIApp(socketio_server=sio, socketio_path="") # config.run.root_path is only set when started with --root-path. Not on submounts. SOCKET_IO_PATH = f"{config.run.root_path}/ws/socket.io" app.mount(SOCKET_IO_PATH, asgi_app) app.add_middleware( CORSMiddleware, allow_origins=config.project.allow_origins, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) class SafariWebSocketsCompatibleGZipMiddleware(GZipMiddleware): async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http": return await self.app(scope, receive, send) # Prevent gzip compression for HTTP requests to socket.io path due to a bug in Safari if URL(scope=scope).path.startswith(SOCKET_IO_PATH): await self.app(scope, receive, send) else: await super().__call__(scope, receive, send) app.add_middleware(SafariWebSocketsCompatibleGZipMiddleware) # config.run.root_path is only set when started with --root-path. Not on submounts. router = APIRouter(prefix=config.run.root_path) @router.get("/public/{filename:path}") async def serve_public_file( filename: str, ): """Serve a file from public dir.""" base_path = Path(public_dir) file_path = (base_path / filename).resolve() if not is_path_inside(file_path, base_path): raise HTTPException(status_code=400, detail="Invalid filename") if file_path.is_file(): return FileResponse(file_path) else: raise HTTPException(status_code=404, detail="File not found") @router.get("/assets/{filename:path}") async def serve_asset_file( filename: str, ): """Serve a file from assets dir.""" base_path = Path(os.path.join(build_dir, "assets")) file_path = (base_path / filename).resolve() if not is_path_inside(file_path, base_path): raise HTTPException(status_code=400, detail="Invalid filename") if file_path.is_file(): return FileResponse(file_path) else: raise HTTPException(status_code=404, detail="File not found") @router.get("/copilot/{filename:path}") async def serve_copilot_file( filename: str, ): """Serve a file from assets dir.""" base_path = Path(copilot_build_dir) file_path = (base_path / filename).resolve() if not is_path_inside(file_path, base_path): raise HTTPException(status_code=400, detail="Invalid filename") if file_path.is_file(): return FileResponse(file_path) else: raise HTTPException(status_code=404, detail="File not found") # ------------------------------------------------------------------------------- # SLACK HTTP HANDLER # ------------------------------------------------------------------------------- if ( os.environ.get("SLACK_BOT_TOKEN") and os.environ.get("SLACK_SIGNING_SECRET") and not os.environ.get("SLACK_WEBSOCKET_TOKEN") ): from chainlit.slack.app import slack_app_handler @router.post("/slack/events") async def slack_endpoint(req: Request): return await slack_app_handler.handle(req) # ------------------------------------------------------------------------------- # TEAMS HANDLER # ------------------------------------------------------------------------------- if os.environ.get("TEAMS_APP_ID") and os.environ.get("TEAMS_APP_PASSWORD"): from botbuilder.schema import Activity from chainlit.teams.app import adapter, bot @router.post("/teams/events") async def teams_endpoint(req: Request): body = await req.json() activity = Activity().deserialize(body) auth_header = req.headers.get("Authorization", "") response = await adapter.process_activity(activity, auth_header, bot.on_turn) return response # ------------------------------------------------------------------------------- # HTTP HANDLERS # ------------------------------------------------------------------------------- def replace_between_tags( text: str, start_tag: str, end_tag: str, replacement: str ) -> str: """Replace text between two tags in a string.""" pattern = start_tag + ".*?" + end_tag return re.sub(pattern, start_tag + replacement + end_tag, text, flags=re.DOTALL) def get_html_template(root_path): """ Get HTML template for the index view. """ root_path = root_path.rstrip("/") # Avoid duplicated / when joining with root path. custom_theme = None custom_theme_file_path = Path(public_dir) / "theme.json" if ( is_path_inside(custom_theme_file_path, Path(public_dir)) and custom_theme_file_path.is_file() ): custom_theme = json.loads(custom_theme_file_path.read_text(encoding="utf-8")) PLACEHOLDER = "" JS_PLACEHOLDER = "" CSS_PLACEHOLDER = "" default_url = config.ui.custom_meta_url or "https://github.com/Chainlit/chainlit" default_meta_image_url = ( "https://chainlit-cloud.s3.eu-west-3.amazonaws.com/logo/chainlit_banner.png" ) meta_image_url = config.ui.custom_meta_image_url or default_meta_image_url favicon_path = "/favicon" tags = f"""{config.ui.name} """ js = f"""""" css = None if config.ui.custom_css: css = f"""""" if config.ui.custom_js: js += f"""""" font = None if custom_theme and custom_theme.get("custom_fonts"): font = "\n".join( f"""""" for font in custom_theme.get("custom_fonts") ) index_html_file_path = os.path.join(build_dir, "index.html") with open(index_html_file_path, encoding="utf-8") as f: content = f.read() content = content.replace(PLACEHOLDER, tags) if js: content = content.replace(JS_PLACEHOLDER, js) if css: content = content.replace(CSS_PLACEHOLDER, css) if font: content = replace_between_tags( content, "", "", font ) content = content.replace('href="/', f'href="{root_path}/') content = content.replace('src="/', f'src="{root_path}/') return content def get_user_facing_url(url: URL): """ Return the user facing URL for a given URL. Handles deployment with proxies (like cloud run). """ chainlit_url = os.environ.get("CHAINLIT_URL") # No config, we keep the URL as is if not chainlit_url: url = url.replace(query="", fragment="") return url.__str__() config_url = URL(chainlit_url).replace( query="", fragment="", ) # Remove trailing slash from config URL if config_url.path.endswith("/"): config_url = config_url.replace(path=config_url.path[:-1]) return config_url.__str__() + url.path @router.get("/auth/config") async def auth(request: Request): return get_configuration() def _get_response_dict(access_token: str) -> dict: """Get the response dictionary for the auth response.""" return {"success": True} def _get_auth_response(access_token: str, redirect_to_callback: bool) -> Response: """Get the redirect params for the OAuth callback.""" response_dict = _get_response_dict(access_token) if redirect_to_callback: root_path = os.environ.get("CHAINLIT_ROOT_PATH", "") root_path = "" if root_path == "/" else root_path redirect_url = ( f"{root_path}/login/callback?{urllib.parse.urlencode(response_dict)}" ) return RedirectResponse( # FIXME: redirect to the right frontend base url to improve the dev environment url=redirect_url, status_code=302, ) return JSONResponse(response_dict) def _get_oauth_redirect_error(request: Request, error: str) -> Response: """Get the redirect response for an OAuth error.""" params = urllib.parse.urlencode( { "error": error, } ) response = RedirectResponse(url=str(request.url_for("login")) + "?" + params) return response async def _authenticate_user( request: Request, user: Optional[User], redirect_to_callback: bool = False ) -> Response: """Authenticate a user and return the response.""" if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="credentialssignin", ) # If a data layer is defined, attempt to persist user. if data_layer := get_data_layer(): try: await data_layer.create_user(user) except Exception as e: # Catch and log exceptions during user creation. # TODO: Make this catch only specific errors and allow others to propagate. logger.error(f"Error creating user: {e}") access_token = create_jwt(user) response = _get_auth_response(access_token, redirect_to_callback) set_auth_cookie(request, response, access_token) return response @router.post("/login") async def login( request: Request, response: Response, form_data: OAuth2PasswordRequestForm = Depends(), ): """ Login a user using the password auth callback. """ if not config.code.password_auth_callback: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No auth_callback defined" ) user = await config.code.password_auth_callback( form_data.username, form_data.password ) return await _authenticate_user(request, user) @router.post("/logout") async def logout(request: Request, response: Response): """Logout the user by calling the on_logout callback.""" clear_auth_cookie(request, response) if config.code.on_logout: return await config.code.on_logout(request, response) return {"success": True} @router.post("/auth/jwt") async def jwt_auth(request: Request): """Login a user using a valid jwt.""" from jwt import InvalidTokenError auth_header: Optional[str] = request.headers.get("Authorization") if not auth_header: raise HTTPException(status_code=401, detail="Authorization header missing") # Check if it starts with "Bearer " try: scheme, token = auth_header.split() if scheme.lower() != "bearer": raise HTTPException( status_code=401, detail="Invalid authentication scheme. Please use Bearer", ) except ValueError: raise HTTPException( status_code=401, detail="Invalid authorization header format" ) try: user = decode_jwt(token) return await _authenticate_user(request, user) except InvalidTokenError: raise HTTPException(status_code=401, detail="Invalid token") @router.post("/auth/header") async def header_auth(request: Request): """Login a user using the header_auth_callback.""" if not config.code.header_auth_callback: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No header_auth_callback defined", ) user = await config.code.header_auth_callback(request.headers) return await _authenticate_user(request, user) @router.get("/auth/oauth/{provider_id}") async def oauth_login(provider_id: str, request: Request): """Redirect the user to the oauth provider login page.""" if config.code.oauth_callback is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No oauth_callback defined", ) provider = get_oauth_provider(provider_id) if not provider: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Provider {provider_id} not found", ) random = random_secret(32) params = urllib.parse.urlencode( { "client_id": provider.client_id, "redirect_uri": f"{get_user_facing_url(request.url)}/callback", "state": random, **provider.authorize_params, } ) response = RedirectResponse( url=f"{provider.authorize_url}?{params}", ) set_oauth_state_cookie(response, random) return response @router.get("/auth/oauth/{provider_id}/callback") async def oauth_callback( provider_id: str, request: Request, error: Optional[str] = None, code: Optional[str] = None, state: Optional[str] = None, ): """Handle the oauth callback and login the user.""" if config.code.oauth_callback is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No oauth_callback defined", ) provider = get_oauth_provider(provider_id) if not provider: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Provider {provider_id} not found", ) if error: return _get_oauth_redirect_error(request, error) if not code or not state: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Missing code or state", ) try: validate_oauth_state_cookie(request, state) except Exception as e: logger.exception("Unable to validate oauth state: %1", e) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized", ) url = get_user_facing_url(request.url) token = await provider.get_token(code, url) (raw_user_data, default_user) = await provider.get_user_info(token) user = await config.code.oauth_callback( provider_id, token, raw_user_data, default_user ) response = await _authenticate_user(request, user, redirect_to_callback=True) clear_oauth_state_cookie(response) return response # specific route for azure ad hybrid flow @router.post("/auth/oauth/azure-ad-hybrid/callback") async def oauth_azure_hf_callback( request: Request, error: Optional[str] = None, code: Annotated[Optional[str], Form()] = None, id_token: Annotated[Optional[str], Form()] = None, ): """Handle the azure ad hybrid flow callback and login the user.""" provider_id = "azure-ad-hybrid" if config.code.oauth_callback is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No oauth_callback defined", ) provider = get_oauth_provider(provider_id) if not provider: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Provider {provider_id} not found", ) if error: return _get_oauth_redirect_error(request, error) if not code: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Missing code", ) url = get_user_facing_url(request.url) token = await provider.get_token(code, url) (raw_user_data, default_user) = await provider.get_user_info(token) user = await config.code.oauth_callback( provider_id, token, raw_user_data, default_user, id_token ) response = await _authenticate_user(request, user, redirect_to_callback=True) clear_oauth_state_cookie(response) return response GenericUser = Union[User, PersistedUser, None] UserParam = Annotated[GenericUser, Depends(get_current_user)] @router.get("/user") async def get_user(current_user: UserParam) -> GenericUser: return current_user _language_pattern = ( "^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,4})?(-[a-zA-Z0-9]{2,8})?(-x-[a-zA-Z0-9]{1,8})?$" ) @router.post("/set-session-cookie") async def set_session_cookie(request: Request, response: Response): body = await request.json() session_id = body.get("session_id") is_local = request.client and request.client.host in ["127.0.0.1", "localhost"] response.set_cookie( key="X-Chainlit-Session-id", value=session_id, path="/", httponly=True, secure=not is_local, samesite="lax" if is_local else "none", ) return {"message": "Session cookie set"} @router.get("/project/translations") async def project_translations( language: str = Query( default="en-US", description="Language code", pattern=_language_pattern ), ): """Return project translations.""" # Use configured language if set, otherwise use the language from query effective_language = config.ui.language or language # Load translation based on the effective language translation = config.load_translation(effective_language) return JSONResponse( content={ "translation": translation, } ) @router.get("/project/settings") async def project_settings( current_user: UserParam, language: str = Query( default="en-US", description="Language code", pattern=_language_pattern ), chat_profile: Optional[str] = Query( default=None, description="Current chat profile name" ), ): """Return project settings. This is called by the UI before the establishing the websocket connection.""" # Use configured language if set, otherwise use the language from query effective_language = config.ui.language or language # Load the markdown file based on the provided language markdown = get_markdown_str(config.root, effective_language) chat_profiles = [] profiles: list[dict] = [] if config.code.set_chat_profiles: chat_profiles = await config.code.set_chat_profiles( current_user, effective_language ) if chat_profiles: for p in chat_profiles: d = p.to_dict() d.pop("config_overrides", None) profiles.append(d) starters = [] if config.code.set_starters: s = await config.code.set_starters(current_user, effective_language) if s: starters = [it.to_dict() for it in s] starter_categories = [] if config.code.set_starter_categories: sc = await config.code.set_starter_categories(current_user, effective_language) if sc: starter_categories = [it.to_dict() for it in sc] data_layer = get_data_layer() debug_url = ( await data_layer.build_debug_url() if data_layer and config.run.debug else None ) cfg = config if chat_profile and chat_profiles: current_profile = next( (p for p in chat_profiles if p.name == chat_profile), None ) if current_profile and getattr(current_profile, "config_overrides", None): cfg = config.with_overrides(current_profile.config_overrides) return JSONResponse( content={ "ui": cfg.ui.model_dump(), "features": cfg.features.model_dump(), "userEnv": cfg.project.user_env, "maskUserEnv": cfg.project.mask_user_env, "dataPersistence": data_layer is not None, "threadResumable": bool(config.code.on_chat_resume), # Expose whether shared threads feature is enabled (flag + app callback) "threadSharing": bool( getattr(cfg.features, "allow_thread_sharing", False) and getattr(config.code, "on_shared_thread_view", None) ), "markdown": markdown, "chatProfiles": profiles, "starters": starters, "starterCategories": starter_categories, "debugUrl": debug_url, } ) @router.put("/feedback") async def update_feedback( request: Request, update: UpdateFeedbackRequest, current_user: UserParam, ): """Update the human feedback for a particular message.""" data_layer = get_data_layer() if not data_layer: raise HTTPException(status_code=500, detail="Data persistence is not enabled") try: feedback_id = await data_layer.upsert_feedback(feedback=update.feedback) if config.code.on_feedback: try: from chainlit.context import init_ws_context from chainlit.session import WebsocketSession session = WebsocketSession.get_by_id(update.sessionId) init_ws_context(session) await config.code.on_feedback(update.feedback) except Exception as callback_error: logger.error( f"Error in user-provided on_feedback callback: {callback_error}" ) # Optionally, you could continue without raising an exception to avoid disrupting the endpoint. except Exception as e: raise HTTPException(detail=str(e), status_code=500) from e return JSONResponse(content={"success": True, "feedbackId": feedback_id}) @router.delete("/feedback") async def delete_feedback( request: Request, payload: DeleteFeedbackRequest, current_user: UserParam, ): """Delete a feedback.""" data_layer = get_data_layer() if not data_layer: raise HTTPException(status_code=400, detail="Data persistence is not enabled") feedback_id = payload.feedbackId await data_layer.delete_feedback(feedback_id) return JSONResponse(content={"success": True}) @router.post("/project/threads") async def get_user_threads( request: Request, payload: GetThreadsRequest, current_user: UserParam, ): """Get the threads page by page.""" data_layer = get_data_layer() if not data_layer: raise HTTPException(status_code=400, detail="Data persistence is not enabled") if not current_user: raise HTTPException(status_code=401, detail="Unauthorized") if not isinstance(current_user, PersistedUser): persisted_user = await data_layer.get_user(identifier=current_user.identifier) if not persisted_user: raise HTTPException(status_code=404, detail="User not found") payload.filter.userId = persisted_user.id else: payload.filter.userId = current_user.id res = await data_layer.list_threads(payload.pagination, payload.filter) return JSONResponse(content=res.to_dict()) @router.get("/project/thread/{thread_id}") async def get_thread( request: Request, thread_id: str, current_user: UserParam, ): """Get a specific thread.""" data_layer = get_data_layer() if not data_layer: raise HTTPException(status_code=400, detail="Data persistence is not enabled") if not current_user: raise HTTPException(status_code=401, detail="Unauthorized") await is_thread_author(current_user.identifier, thread_id) res = await data_layer.get_thread(thread_id) return JSONResponse(content=res) @router.get("/project/share/{thread_id}") async def get_shared_thread( request: Request, thread_id: str, current_user: UserParam, ): """Get a shared thread (read-only for everyone). This endpoint is separate from the resume endpoint and does not require the caller to be the author of the thread. It only returns the thread if its metadata contains is_shared=True. Otherwise, it returns 404 to avoid leaking existence. """ data_layer = get_data_layer() if not data_layer: raise HTTPException(status_code=400, detail="Data persistence is not enabled") # No auth required: allow anonymous access to shared threads thread = await data_layer.get_thread(thread_id) if not thread: raise HTTPException(status_code=404, detail="Thread not found") # Extract and normalize metadata (may be dict, strified JSON, or None) metadata = (thread.get("metadata") if isinstance(thread, dict) else {}) or {} if isinstance(metadata, str): try: metadata = json.loads(metadata) except Exception: metadata = {} if not isinstance(metadata, dict): metadata = {} user_can_view = False if getattr(config.code, "on_shared_thread_view", None): try: user_can_view = await config.code.on_shared_thread_view( thread, current_user ) except Exception: user_can_view = False is_shared = bool(metadata.get("is_shared")) # Proceed only raise an error if both conditions are False. if (not user_can_view) and (not is_shared): raise HTTPException(status_code=404, detail="Thread not found") metadata.pop("chat_profile", None) metadata.pop("chat_settings", None) metadata.pop("env", None) thread["metadata"] = metadata return JSONResponse(content=thread) @router.get("/project/thread/{thread_id}/element/{element_id}") async def get_thread_element( request: Request, thread_id: str, element_id: str, current_user: UserParam, ): """Get a specific thread element.""" data_layer = get_data_layer() if not data_layer: raise HTTPException(status_code=400, detail="Data persistence is not enabled") if not current_user: raise HTTPException(status_code=401, detail="Unauthorized") await is_thread_author(current_user.identifier, thread_id) res = await data_layer.get_element(thread_id, element_id) return JSONResponse(content=res) @router.put("/project/element") async def update_thread_element( payload: ElementRequest, current_user: UserParam, ): """Update a specific thread element.""" from chainlit.context import init_ws_context from chainlit.element import ElementDict from chainlit.session import WebsocketSession session = WebsocketSession.get_by_id(payload.sessionId) context = init_ws_context(session) element_dict = cast(ElementDict, payload.element) if element_dict["type"] != "custom": return {"success": False} element = _sanitize_custom_element(element_dict) if current_user: if ( not context.session.user or context.session.user.identifier != current_user.identifier ): raise HTTPException( status_code=401, detail="You are not authorized to update elements for this session", ) await element.update() return {"success": True} @router.delete("/project/element") async def delete_thread_element( payload: ElementRequest, current_user: UserParam, ): """Delete a specific thread element.""" from chainlit.context import init_ws_context from chainlit.element import ElementDict from chainlit.session import WebsocketSession session = WebsocketSession.get_by_id(payload.sessionId) context = init_ws_context(session) element_dict = cast(ElementDict, payload.element) if element_dict["type"] != "custom": return {"success": False} element = _sanitize_custom_element(element_dict) if current_user: if ( not context.session.user or context.session.user.identifier != current_user.identifier ): raise HTTPException( status_code=401, detail="You are not authorized to remove elements for this session", ) await element.remove() return {"success": True} def _sanitize_custom_element(element_dict: "ElementDict") -> "CustomElement": from chainlit.element import CustomElement return CustomElement( id=element_dict["id"], for_id=element_dict.get("forId") or "", thread_id=element_dict.get("threadId") or "", name=element_dict["name"], props=element_dict.get("props") or {}, display=element_dict["display"], ) @router.put("/project/thread") async def rename_thread( request: Request, payload: UpdateThreadRequest, current_user: UserParam, ): """Rename a thread.""" data_layer = get_data_layer() if not data_layer: raise HTTPException(status_code=400, detail="Data persistence is not enabled") if not current_user: raise HTTPException(status_code=401, detail="Unauthorized") thread_id = payload.threadId await is_thread_author(current_user.identifier, thread_id) await data_layer.update_thread(thread_id, name=payload.name) return JSONResponse(content={"success": True}) @router.put("/project/thread/share") async def share_thread( request: Request, payload: ShareThreadRequest, current_user: UserParam, ): """Share or un-share a thread (author only).""" data_layer = get_data_layer() if not data_layer: raise HTTPException(status_code=400, detail="Data persistence is not enabled") if not current_user: raise HTTPException(status_code=401, detail="Unauthorized") thread_id = payload.threadId await is_thread_author(current_user.identifier, thread_id) # Fetch current thread and metadata, then toggle is_shared thread = await data_layer.get_thread(thread_id=thread_id) metadata = (thread.get("metadata") if thread else {}) or {} if isinstance(metadata, str): try: metadata = json.loads(metadata) except Exception: metadata = {} if not isinstance(metadata, dict): metadata = {} metadata = dict(metadata) is_shared = bool(payload.isShared) metadata["is_shared"] = is_shared if is_shared: metadata["shared_at"] = utc_now() else: metadata.pop("shared_at", None) try: await data_layer.update_thread(thread_id=thread_id, metadata=metadata) logger.debug( "[share_thread] updated metadata for thread=%s to %s", thread_id, metadata, ) except Exception as e: logger.exception("[share_thread] update_thread failed: %s", e) raise return JSONResponse(content={"success": True}) @router.delete("/project/thread") async def delete_thread( request: Request, payload: DeleteThreadRequest, current_user: UserParam, ): """Delete a thread.""" data_layer = get_data_layer() if not data_layer: raise HTTPException(status_code=400, detail="Data persistence is not enabled") if not current_user: raise HTTPException(status_code=401, detail="Unauthorized") thread_id = payload.threadId await is_thread_author(current_user.identifier, thread_id) await data_layer.delete_thread(thread_id) return JSONResponse(content={"success": True}) @router.post("/project/action") async def call_action( payload: CallActionRequest, current_user: UserParam, ): """Run an action.""" from chainlit.action import Action from chainlit.context import init_ws_context from chainlit.session import WebsocketSession session = WebsocketSession.get_by_id(payload.sessionId) context = init_ws_context(session) config: ChainlitConfig = session.get_config() action = Action(**payload.action) if current_user: if ( not context.session.user or context.session.user.identifier != current_user.identifier ): raise HTTPException( status_code=401, detail="You are not authorized to upload files for this session", ) callback = config.code.action_callbacks.get(action.name) if callback: if not context.session.has_first_interaction: context.session.has_first_interaction = True asyncio.create_task(context.emitter.init_thread(action.name)) response = await callback(action) else: raise HTTPException( status_code=404, detail=f"No callback found for action {action.name}", ) return JSONResponse(content={"success": True, "response": response}) @router.post("/mcp") async def connect_mcp( payload: ConnectMCPRequest, current_user: UserParam, ): from mcp import ClientSession from mcp.client.sse import sse_client from mcp.client.stdio import ( StdioServerParameters, get_default_environment, stdio_client, ) from mcp.client.streamable_http import streamablehttp_client from chainlit.context import init_ws_context from chainlit.mcp import ( HttpMcpConnection, McpConnection, SseMcpConnection, StdioMcpConnection, validate_mcp_command, ) from chainlit.session import WebsocketSession session = WebsocketSession.get_by_id(payload.sessionId) context = init_ws_context(session) config: ChainlitConfig = session.get_config() if current_user: if ( not context.session.user or context.session.user.identifier != current_user.identifier ): raise HTTPException( status_code=401, ) mcp_enabled = config.features.mcp.enabled if mcp_enabled: if payload.name in session.mcp_sessions: old_client_session, old_exit_stack = session.mcp_sessions[payload.name] if on_mcp_disconnect := config.code.on_mcp_disconnect: await on_mcp_disconnect(payload.name, old_client_session) try: await old_exit_stack.aclose() except Exception: pass try: exit_stack = AsyncExitStack() mcp_connection: McpConnection if payload.clientType == "sse": if not config.features.mcp.sse.enabled: raise HTTPException( status_code=400, detail="SSE MCP is not enabled", ) mcp_connection = SseMcpConnection( url=payload.url, name=payload.name, headers=getattr(payload, "headers", None), ) transport = await exit_stack.enter_async_context( sse_client( url=mcp_connection.url, headers=mcp_connection.headers, ) ) elif payload.clientType == "stdio": if not config.features.mcp.stdio.enabled: raise HTTPException( status_code=400, detail="Stdio MCP is not enabled", ) env_from_cmd, command, args = validate_mcp_command(payload.fullCommand) mcp_connection = StdioMcpConnection( command=command, args=args, name=payload.name ) env = get_default_environment() env.update(env_from_cmd) # Create the server parameters server_params = StdioServerParameters( command=command, args=args, env=env ) transport = await exit_stack.enter_async_context( stdio_client(server_params) ) elif payload.clientType == "streamable-http": if not config.features.mcp.streamable_http.enabled: raise HTTPException( status_code=400, detail="HTTP MCP is not enabled", ) mcp_connection = HttpMcpConnection( url=payload.url, name=payload.name, headers=getattr(payload, "headers", None), ) transport = await exit_stack.enter_async_context( streamablehttp_client( url=mcp_connection.url, headers=mcp_connection.headers, ) ) # The transport can return (read, write) for stdio, sse # Or (read, write, get_session_id) for streamable-http # We are only interested in the read and write streams here. read, write = transport[:2] mcp_session: ClientSession = await exit_stack.enter_async_context( ClientSession( read_stream=read, write_stream=write, sampling_callback=None ) ) # Initialize the session await mcp_session.initialize() # Store the session session.mcp_sessions[mcp_connection.name] = (mcp_session, exit_stack) # Call the callback if config.code.on_mcp_connect: await config.code.on_mcp_connect(mcp_connection, mcp_session) except Exception as e: raise HTTPException( status_code=400, detail=f"Could not connect to the MCP: {e!s}", ) else: raise HTTPException( status_code=400, detail="This app does not support MCP.", ) tool_list = await mcp_session.list_tools() return JSONResponse( content={ "success": True, "mcp": { "name": payload.name, "tools": [{"name": t.name} for t in tool_list.tools], "clientType": payload.clientType, "command": payload.fullCommand if payload.clientType == "stdio" else None, "url": getattr(payload, "url", None) if payload.clientType in ["sse", "streamable-http"] else None, # Include optional headers for SSE and streamable-http connections "headers": getattr(payload, "headers", None) if payload.clientType in ["sse", "streamable-http"] else None, }, } ) @router.delete("/mcp") async def disconnect_mcp( payload: DisconnectMCPRequest, current_user: UserParam, ): from chainlit.context import init_ws_context from chainlit.session import WebsocketSession session = WebsocketSession.get_by_id(payload.sessionId) context = init_ws_context(session) if current_user: if ( not context.session.user or context.session.user.identifier != current_user.identifier ): raise HTTPException( status_code=401, ) callback = config.code.on_mcp_disconnect if payload.name in session.mcp_sessions: try: client_session, exit_stack = session.mcp_sessions[payload.name] if callback: await callback(payload.name, client_session) try: await exit_stack.aclose() except Exception: pass del session.mcp_sessions[payload.name] except Exception as e: raise HTTPException( status_code=400, detail=f"Could not disconnect to the MCP: {e!s}", ) return JSONResponse(content={"success": True}) @router.post("/project/file") async def upload_file( current_user: UserParam, session_id: str, file: UploadFile, ask_parent_id: Optional[str] = None, ): """Upload a file to the session files directory.""" from chainlit.session import WebsocketSession session = WebsocketSession.get_by_id(session_id) if not session: raise HTTPException( status_code=404, detail="Session not found", ) if current_user: if not session.user or session.user.identifier != current_user.identifier: raise HTTPException( status_code=401, detail="You are not authorized to upload files for this session", ) session.files_dir.mkdir(exist_ok=True) try: content = await file.read() assert file.filename, "No filename for uploaded file" assert file.content_type, "No content type for uploaded file" spec: AskFileSpec = session.files_spec.get(ask_parent_id, None) if not spec and ask_parent_id: raise HTTPException( status_code=404, detail="Parent message not found", ) try: validate_file_upload(file, spec=spec) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) file_response = await session.persist_file( name=file.filename, content=content, mime=file.content_type ) return JSONResponse(content=file_response) finally: await file.close() def validate_file_upload(file: UploadFile, spec: Optional[AskFileSpec] = None): """Validate the file upload as configured in config.features.spontaneous_file_upload or by AskFileSpec for a specific message. Args: file (UploadFile): The file to validate. spec (AskFileSpec): The file spec to validate against if any. Raises: ValueError: If the file is not allowed. """ if not spec and config.features.spontaneous_file_upload is None: """Default for a missing config is to allow the fileupload without any restrictions""" return if not spec and not config.features.spontaneous_file_upload.enabled: raise ValueError("File upload is not enabled") validate_file_mime_type(file, spec) validate_file_size(file, spec) def validate_file_mime_type(file: UploadFile, spec: Optional[AskFileSpec]): """Validate the file mime type as configured in config.features.spontaneous_file_upload. Args: file (UploadFile): The file to validate. Raises: ValueError: If the file type is not allowed. """ if not spec and ( config.features.spontaneous_file_upload is None or config.features.spontaneous_file_upload.accept is None ): "Accept is not configured, allowing all file types" return accept = config.features.spontaneous_file_upload.accept if not spec else spec.accept assert isinstance(accept, List) or isinstance(accept, dict), ( "Invalid configuration for spontaneous_file_upload, accept must be a list or a dict" ) if isinstance(accept, List): for pattern in accept: if fnmatch.fnmatch(str(file.content_type), pattern): return elif isinstance(accept, dict): for pattern, extensions in accept.items(): if fnmatch.fnmatch(str(file.content_type), pattern): if len(extensions) == 0: return for extension in extensions: if file.filename is not None and file.filename.lower().endswith( extension.lower() ): return raise ValueError("File type not allowed") def validate_file_size(file: UploadFile, spec: Optional[AskFileSpec]): """Validate the file size as configured in config.features.spontaneous_file_upload. Args: file (UploadFile): The file to validate. Raises: ValueError: If the file size is too large. """ if not spec and ( config.features.spontaneous_file_upload is None or config.features.spontaneous_file_upload.max_size_mb is None ): return max_size_mb = ( config.features.spontaneous_file_upload.max_size_mb if not spec else spec.max_size_mb ) if file.size is not None and file.size > max_size_mb * 1024 * 1024: raise ValueError("File size too large") @router.get("/project/file/{file_id}") async def get_file( file_id: str, session_id: str, current_user: UserParam, ): """Get a file from the session files directory.""" from chainlit.session import WebsocketSession session = WebsocketSession.get_by_id(session_id) if session_id else None if not session: raise HTTPException( status_code=401, detail="Unauthorized", ) if current_user: if not session.user or session.user.identifier != current_user.identifier: raise HTTPException( status_code=401, detail="You are not authorized to download files from this session", ) if file_id in session.files: file = session.files[file_id] return FileResponse(file["path"], media_type=file["type"]) else: raise HTTPException(status_code=404, detail="File not found") @router.get("/favicon") async def get_favicon(): """Get the favicon for the UI.""" custom_favicon_path = os.path.join(APP_ROOT, "public", "favicon.*") files = glob.glob(custom_favicon_path) if files: favicon_path = files[0] else: favicon_path = os.path.join(build_dir, "favicon.svg") media_type, _ = mimetypes.guess_type(favicon_path) return FileResponse(favicon_path, media_type=media_type) @router.get("/logo") async def get_logo(theme: Optional[Theme] = Query(Theme.light)): """Get the default logo for the UI.""" theme_value = theme.value if theme else Theme.light.value logo_path = None for path in [ os.path.join(APP_ROOT, "public", f"logo_{theme_value}.*"), os.path.join(build_dir, "assets", f"logo_{theme_value}*.*"), ]: files = glob.glob(path) if files: logo_path = files[0] break if not logo_path: logo_path = os.path.join( os.path.dirname(__file__), "frontend", "dist", f"logo_{theme_value}.svg", ) logger.info("Missing custom logo. Falling back to default logo.") media_type, _ = mimetypes.guess_type(logo_path) return FileResponse(logo_path, media_type=media_type) @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): 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(" ", "_").replace(".", "_") base_path = Path(APP_ROOT) / "public" / "avatars" avatar_pattern = f"{avatar_id}.*" matching_files = base_path.glob(avatar_pattern) if avatar_path := next(matching_files, None): if not is_path_inside(avatar_path, base_path): raise HTTPException(status_code=400, detail="Invalid filename") media_type, _ = mimetypes.guess_type(str(avatar_path)) return FileResponse(avatar_path, media_type=media_type) return await get_favicon() @router.head("/") def status_check(): """Check if the site is operational.""" return {"message": "Site is operational"} @router.get("/health") def health_check(): """Health check endpoint for container orchestration and monitoring.""" return {"status": "ok"} @router.get("/{full_path:path}") async def serve(request: Request): """Serve the UI files.""" root_path = os.getenv("CHAINLIT_PARENT_ROOT_PATH", "") + os.getenv( "CHAINLIT_ROOT_PATH", "" ) html_template = get_html_template(root_path) response = HTMLResponse(content=html_template, status_code=200) return response app.include_router(router) import chainlit.socket # noqa ================================================ FILE: backend/chainlit/session.py ================================================ import asyncio import json import mimetypes import re import shutil import uuid from contextlib import AsyncExitStack from typing import TYPE_CHECKING, Any, Callable, Deque, Dict, Literal, Optional, Union import aiofiles from chainlit.logger import logger from chainlit.types import AskFileSpec, FileReference if TYPE_CHECKING: from mcp import ClientSession from chainlit.config import ChainlitConfig from chainlit.types import FileDict from chainlit.user import PersistedUser, User ClientType = Literal["webapp", "copilot", "teams", "slack", "discord"] class JSONEncoderIgnoreNonSerializable(json.JSONEncoder): def default(self, o): try: return super().default(o) except TypeError: return None def clean_metadata(metadata: Dict, max_size: int = 1048576): cleaned_metadata = json.loads( json.dumps(metadata, cls=JSONEncoderIgnoreNonSerializable, ensure_ascii=False) ) metadata_size = len(json.dumps(cleaned_metadata).encode("utf-8")) if metadata_size > max_size: # Redact the metadata if it exceeds the maximum size cleaned_metadata = { "message": f"Metadata size exceeds the limit of {max_size} bytes. Redacted." } return cleaned_metadata class BaseSession: """Base object.""" thread_id_to_resume: Optional[str] = None client_type: ClientType current_task: Optional[asyncio.Task] = None def __init__( self, # Id of the session id: str, client_type: ClientType, # Thread id thread_id: Optional[str], # Logged-in user information user: Optional[Union["User", "PersistedUser"]], # Logged-in user token token: Optional[str], # User specific environment variables. Empty if no user environment variables are required. user_env: Optional[Dict[str, str]], # WSGI environment variables for the connection request environ: Optional[dict[str, Any]] = None, # Chat profile selected before the session was created chat_profile: Optional[str] = None, ): if thread_id: self.thread_id_to_resume = thread_id self.thread_id = thread_id or str(uuid.uuid4()) self.user = user self.client_type = client_type self.token = token self.has_first_interaction = False self.user_env = user_env or {} self.environ = environ or {} self.chat_profile = chat_profile self.files: Dict[str, FileDict] = {} self.files_spec: Dict[str, AskFileSpec] = {} self.id = id self.chat_settings: Dict[str, Any] = {} @property def files_dir(self): from chainlit.config import FILES_DIRECTORY return FILES_DIRECTORY / self.id async def persist_file( self, name: str, mime: str, path: Optional[str] = None, content: Optional[Union[bytes, str]] = None, ) -> FileReference: if not path and not content: raise ValueError( "Either path or content must be provided to persist a file" ) self.files_dir.mkdir(exist_ok=True) file_id = str(uuid.uuid4()) file_path = self.files_dir / file_id file_extension = mimetypes.guess_extension(mime) if file_extension: file_path = file_path.with_suffix(file_extension) if path: # Copy the file from the given path async with ( aiofiles.open(path, "rb") as src, aiofiles.open(file_path, "wb") as dst, ): await dst.write(await src.read()) elif content: # Write the provided content to the file async with aiofiles.open(file_path, "wb") as buffer: if isinstance(content, str): content = content.encode("utf-8") await buffer.write(content) # Get the file size file_size = file_path.stat().st_size # Store the file content in memory self.files[file_id] = { "id": file_id, "path": file_path, "name": name, "type": mime, "size": file_size, } return {"id": file_id} def to_persistable(self) -> Dict: from chainlit.config import config from chainlit.user_session import user_sessions user_session = user_sessions.get(self.id) or {} # type: Dict user_session["chat_settings"] = self.chat_settings user_session["chat_profile"] = self.chat_profile user_session["client_type"] = self.client_type # Check config setting for whether to persist user environment variables user_session_copy = user_session.copy() if not config.project.persist_user_env: # Remove user environment variables (API keys) before persisting to database user_session_copy["env"] = {} metadata = clean_metadata(user_session_copy) return metadata class HTTPSession(BaseSession): """Internal HTTP session object. Used to consume Chainlit through API (no websocket).""" def __init__( self, # Id of the session id: str, client_type: ClientType, # Thread id thread_id: Optional[str] = None, # Logged-in user information user: Optional[Union["User", "PersistedUser"]] = None, # Logged-in user token token: Optional[str] = None, user_env: Optional[Dict[str, str]] = None, # WSGI environment variables for the connection request environ: Optional[dict[str, Any]] = None, ): super().__init__( id=id, thread_id=thread_id, user=user, token=token, client_type=client_type, user_env=user_env, environ=environ, ) async def delete(self): """Delete the session.""" if self.files_dir.is_dir(): shutil.rmtree(self.files_dir) ThreadQueue = Deque[tuple[Callable, object, tuple, Dict]] class WebsocketSession(BaseSession): """Internal web socket session object. A socket id is an ephemeral id that can't be used as a session id (as it is for instance regenerated after each reconnection). The Session object store an internal mapping between socket id and a server generated session id, allowing to persists session between socket reconnection but also retrieving a session by socket id for convenience. """ to_clear: bool = False mcp_sessions: dict[str, tuple["ClientSession", AsyncExitStack]] def __init__( self, # Id from the session cookie id: str, # Associated socket id socket_id: str, # Function to emit to the client emit: Callable[[str, Any], None], # Function to emit to the client and wait for a response emit_call: Callable[[Literal["ask", "call_fn"], Any, Optional[int]], Any], # User specific environment variables. Empty if no user environment variables are required. user_env: Dict[str, str], client_type: ClientType, # WSGI environment variables for the connection request environ: Optional[dict[str, Any]] = None, # Thread id thread_id: Optional[str] = None, # Logged-in user information user: Optional[Union["User", "PersistedUser"]] = None, # Logged-in user token token: Optional[str] = None, # Chat profile selected before the session was created chat_profile: Optional[str] = None, ): super().__init__( id=id, thread_id=thread_id, user=user, token=token, user_env=user_env, client_type=client_type, chat_profile=chat_profile, environ=environ, ) self.socket_id = socket_id self.emit_call = emit_call self.emit = emit self.restored = False self.thread_queues: Dict[str, ThreadQueue] = {} self.mcp_sessions = {} match = ( re.match( r"^\s*([a-zA-Z0-9-]+)", environ.get("HTTP_ACCEPT_LANGUAGE", "en-US") ) if environ else None ) self.language = match.group(1) if match else "en-US" self.config: ChainlitConfig = self.get_config() ws_sessions_id[self.id] = self ws_sessions_sid[socket_id] = self def get_config(self) -> "ChainlitConfig": """ Return the config for this session: overridden if chat profile exists and has overrides, else global config. """ from chainlit.config import config as global_config # If no chat profile, always fallback to global config if not self.chat_profile: return global_config # If already computed, use self.config if hasattr(self, "config") and self.config: return self.config # Try to compute overrides cfg = global_config if global_config.code.set_chat_profiles: import asyncio try: profiles = asyncio.get_event_loop().run_until_complete( global_config.code.set_chat_profiles(self.user, self.language) ) current_profile = next( (p for p in profiles if p.name == self.chat_profile), None ) if current_profile and getattr( current_profile, "config_overrides", None ): cfg = global_config.with_overrides(current_profile.config_overrides) except Exception: pass self.config = cfg return cfg def restore(self, new_socket_id: str): """Associate a new socket id to the session.""" ws_sessions_sid.pop(self.socket_id, None) ws_sessions_sid[new_socket_id] = self self.socket_id = new_socket_id self.restored = True async def delete(self): """Delete the session.""" if self.files_dir.is_dir(): shutil.rmtree(self.files_dir) ws_sessions_sid.pop(self.socket_id, None) ws_sessions_id.pop(self.id, None) for _, exit_stack in self.mcp_sessions.values(): try: await exit_stack.aclose() except Exception: pass async def flush_method_queue(self): for method_name, queue in self.thread_queues.items(): while queue: method, self, args, kwargs = queue.popleft() try: await method(self, *args, **kwargs) except Exception as e: logger.error(f"Error while flushing {method_name}: {e}") @classmethod def get(cls, socket_id: str): """Get session by socket id.""" return ws_sessions_sid.get(socket_id) @classmethod def get_by_id(cls, session_id: str): """Get session by session id.""" return ws_sessions_id.get(session_id) @classmethod def require(cls, socket_id: str): """Throws an exception if the session is not found.""" if session := cls.get(socket_id): return session raise ValueError("Session not found") ws_sessions_sid: Dict[str, WebsocketSession] = {} ws_sessions_id: Dict[str, WebsocketSession] = {} ================================================ FILE: backend/chainlit/sidebar.py ================================================ import asyncio from typing import List, Optional from chainlit.context import context from chainlit.element import ElementBased class ElementSidebar: """Helper class to open/close the element sidebar server side. The element sidebar accepts a title and list of elements.""" @staticmethod async def set_title(title: str): """ Sets the title of the element sidebar and opens it. The sidebar will automatically open when a title is set using this method. Args: title (str): The title to display at the top of the sidebar. Returns: None: This method does not return anything. """ await context.emitter.emit("set_sidebar_title", title) @staticmethod async def set_elements(elements: List[ElementBased], key: Optional[str] = None): """ Sets the elements to display in the sidebar and controls sidebar visibility. This method sends all provided elements to the client and updates the sidebar. Passing an empty list will close the sidebar, while passing at least one element will open it. Args: elements (List[ElementBased]): A list of ElementBased objects to display in the sidebar. key (Optional[str], optional): If the sidebar is already opened with the same key, elements will not be replaced. Returns: None: This method does not return anything. Note: This method first sends each element separately using their send() method, then emits an event with all element dictionaries and the optional key. """ coros = [ element.send(for_id=element.for_id or "", persist=False) for element in elements ] await asyncio.gather(*coros) await context.emitter.emit( "set_sidebar_elements", {"elements": [el.to_dict() for el in elements], "key": key}, ) ================================================ FILE: backend/chainlit/slack/__init__.py ================================================ import importlib.util if importlib.util.find_spec("slack_bolt") is None: raise ValueError( "The slack_bolt package is required to integrate Chainlit with a Slack app. Run `pip install slack_bolt --upgrade`" ) ================================================ FILE: backend/chainlit/slack/app.py ================================================ import asyncio import os import re import uuid from functools import partial from typing import Dict, List, Optional, Union import httpx from slack_bolt.adapter.fastapi.async_handler import AsyncSlackRequestHandler from slack_bolt.adapter.socket_mode.async_handler import AsyncSocketModeHandler from slack_bolt.async_app import AsyncApp from chainlit.config import config from chainlit.context import ChainlitContext, HTTPSession, context, context_var from chainlit.data import get_data_layer from chainlit.element import Element, ElementDict from chainlit.emitter import BaseChainlitEmitter from chainlit.logger import logger from chainlit.message import Message, StepDict from chainlit.types import Feedback from chainlit.user import PersistedUser, User from chainlit.user_session import user_session class SlackEmitter(BaseChainlitEmitter): def __init__( self, session: HTTPSession, app: AsyncApp, channel_id: str, say, thread_ts: Optional[str] = None, ): super().__init__(session) self.app = app self.channel_id = channel_id self.say = say self.thread_ts = thread_ts async def send_element(self, element_dict: ElementDict): if element_dict.get("display") != "inline": return persisted_file = self.session.files.get(element_dict.get("chainlitKey") or "") file: Optional[Union[bytes, str]] = None if persisted_file: file = str(persisted_file["path"]) elif file_url := element_dict.get("url"): async with httpx.AsyncClient() as client: response = await client.get(file_url) if response.status_code == 200: file = response.content if not file: return await self.app.client.files_upload_v2( channel=self.channel_id, thread_ts=self.thread_ts, file=file, title=element_dict.get("name"), ) async def send_step(self, step_dict: StepDict): step_type = step_dict.get("type") is_assistant_message = step_type == "assistant_message" is_empty_output = not step_dict.get("output") if is_empty_output or not is_assistant_message: return enable_feedback = get_data_layer() blocks: List[Dict] = [ { "type": "section", "text": {"type": "mrkdwn", "text": step_dict["output"]}, } ] if enable_feedback: current_run = context.current_run scorable_id = current_run.id if current_run else step_dict.get("id") blocks.append( { "type": "actions", "elements": [ { "action_id": "thumbdown", "type": "button", "text": { "type": "plain_text", "emoji": True, "text": ":thumbsdown:", }, "value": scorable_id, }, { "action_id": "thumbup", "type": "button", "text": { "type": "plain_text", "emoji": True, "text": ":thumbsup:", }, "value": scorable_id, }, ], } ) await self.say( text=step_dict["output"], blocks=blocks, thread_ts=self.thread_ts ) async def update_step(self, step_dict: StepDict): is_assistant_message = step_dict["type"] == "assistant_message" if not is_assistant_message: return await self.send_step(step_dict) slack_app = AsyncApp( token=os.environ.get("SLACK_BOT_TOKEN"), signing_secret=os.environ.get("SLACK_SIGNING_SECRET"), ) async def start_socket_mode(): """ Initializes and starts the Slack app in Socket Mode asynchronously. Uses the SLACK_WEBSOCKET_TOKEN from environment variables to authenticate. """ handler = AsyncSocketModeHandler(slack_app, os.environ.get("SLACK_WEBSOCKET_TOKEN")) await handler.start_async() def init_slack_context( session: HTTPSession, slack_channel_id: str, event, say, thread_ts: Optional[str] = None, ) -> ChainlitContext: emitter = SlackEmitter( session=session, app=slack_app, channel_id=slack_channel_id, say=say, thread_ts=thread_ts, ) context = ChainlitContext(session=session, emitter=emitter) context_var.set(context) user_session.set("slack_event", event) user_session.set( "fetch_slack_message_history", partial( fetch_message_history, channel_id=slack_channel_id, thread_ts=thread_ts ), ) return context slack_app_handler = AsyncSlackRequestHandler(slack_app) users_by_slack_id: Dict[str, Union[User, PersistedUser]] = {} USER_PREFIX = "slack_" bot_user_id: Optional[str] = None async def get_bot_user_id() -> Optional[str]: """Get and cache the bot's user ID.""" global bot_user_id if bot_user_id: return bot_user_id try: result = await slack_app.client.auth_test() if result.get("ok"): bot_user_id = result.get("user_id") return bot_user_id except Exception as e: logger.error(f"Failed to get bot user ID: {e}") return None def clean_content(message: str): cleaned_text = re.sub(r"<@[\w]+>", "", message).strip() return cleaned_text async def get_user(slack_user_id: str): if slack_user_id in users_by_slack_id: return users_by_slack_id[slack_user_id] slack_user = await slack_app.client.users_info(user=slack_user_id) slack_user_profile = slack_user["user"]["profile"] user_identifier = slack_user_profile.get("email") or slack_user_id user = User(identifier=USER_PREFIX + user_identifier, metadata=slack_user_profile) users_by_slack_id[slack_user_id] = user if data_layer := get_data_layer(): try: persisted_user = await data_layer.create_user(user) if persisted_user: users_by_slack_id[slack_user_id] = persisted_user except Exception as e: logger.error(f"Error creating user: {e}") return users_by_slack_id[slack_user_id] async def fetch_message_history( channel_id: str, thread_ts: Optional[str] = None, limit=30 ): if not thread_ts: result = await slack_app.client.conversations_history( channel=channel_id, limit=limit ) else: result = await slack_app.client.conversations_replies( channel=channel_id, ts=thread_ts, limit=limit ) if result["ok"]: messages = result["messages"] return messages else: raise Exception(f"Failed to fetch messages: {result['error']}") async def download_slack_file(url, token): headers = {"Authorization": f"Bearer {token}"} async with httpx.AsyncClient() as client: response = await client.get(url, headers=headers) if response.status_code == 200: return response.content else: return None async def download_slack_files(session: HTTPSession, files, token): download_coros = [ download_slack_file(file.get("url_private"), token) for file in files ] file_bytes_list = await asyncio.gather(*download_coros) file_refs = [] for idx, file_bytes in enumerate(file_bytes_list): if file_bytes: name = files[idx].get("name") mime_type = files[idx].get("mimetype") file_ref = await session.persist_file( name=name, mime=mime_type, content=file_bytes ) file_refs.append(file_ref) files_dicts = [ session.files[file["id"]] for file in file_refs if file["id"] in session.files ] elements = [ Element.from_dict( { "id": file["id"], "name": file["name"], "path": str(file["path"]), "chainlitKey": file["id"], "display": "inline", "type": Element.infer_type_from_mime(file["type"]), } ) for file in files_dicts ] return elements async def add_reaction_if_enabled(event, emoji: str = "eyes"): if config.features.slack.reaction_on_message_received: try: await slack_app.client.reactions_add( channel=event["channel"], timestamp=event["ts"], name=emoji ) except Exception as e: logger.warning(f"Failed to add reaction: {e}") async def process_slack_message( event, say, thread_id: str, thread_name: Optional[str] = None, bind_thread_to_user=False, thread_ts: Optional[str] = None, ): await add_reaction_if_enabled(event) user = await get_user(event["user"]) channel_id = event["channel"] text = event.get("text") slack_files = event.get("files", []) session_id = str(uuid.uuid4()) session = HTTPSession( id=session_id, thread_id=thread_id, user=user, client_type="slack", ) ctx = init_slack_context( session=session, slack_channel_id=channel_id, event=event, say=say, thread_ts=thread_ts, ) file_elements = await download_slack_files( session, slack_files, slack_app.client.token ) if on_chat_start := config.code.on_chat_start: await on_chat_start() msg = Message( content=clean_content(text), elements=file_elements, type="user_message", author=user.metadata.get("real_name"), ) if on_message := config.code.on_message: await on_message(msg) if on_chat_end := config.code.on_chat_end: await on_chat_end() if data_layer := get_data_layer(): user_id = None if isinstance(user, PersistedUser): user_id = user.id if bind_thread_to_user else None try: await data_layer.update_thread( thread_id=thread_id, name=thread_name or msg.content, metadata=ctx.session.to_persistable(), user_id=user_id, ) except Exception as e: logger.error(f"Error updating thread: {e}") await ctx.session.delete() @slack_app.event("app_home_opened") async def handle_app_home_opened(event, say): pass @slack_app.event("app_mention") async def handle_app_mentions(event, say): thread_ts = event.get("thread_ts", event["ts"]) thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts)) await process_slack_message(event, say, thread_id=thread_id, thread_ts=thread_ts) @slack_app.event("message") async def handle_message(message, say): thread_ts = message.get("thread_ts", message["ts"]) thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts)) await process_slack_message( event=message, say=say, thread_id=thread_id, bind_thread_to_user=True, thread_ts=thread_ts, ) @slack_app.event("reaction_added") async def handle_reaction_added(event): bot_id = await get_bot_user_id() if event.get("user") == bot_id: return item = event.get("item", {}) channel_id = item.get("channel") thread_ts = item.get("ts") if not channel_id: logger.warning( "reaction_added event missing channel_id, skipping context setup" ) return try: result = await slack_app.client.conversations_replies( channel=channel_id, ts=thread_ts, limit=1 ) if result.get("ok"): messages = result.get("messages") message = messages[0] message_user = message.get("user") message_bot_id = message.get("bot_id") if message_user != bot_id and message_bot_id != bot_id: return else: raise Exception( f"Failed to fetch message: {result.get('error', 'Unknown error')}" ) except Exception as e: logger.warning(f"Failed to fetch message for reaction: {e}") return async def say(text: str = "", **kwargs): await slack_app.client.chat_postMessage( channel=channel_id, text=text, thread_ts=thread_ts, **kwargs ) user = await get_user(event["user"]) thread_id = ( str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts)) if thread_ts else str(uuid.uuid4()) ) session_id = str(uuid.uuid4()) session = HTTPSession( id=session_id, thread_id=thread_id, user=user, client_type="slack", ) ctx = init_slack_context( session=session, slack_channel_id=channel_id, event=event, say=say, thread_ts=thread_ts, ) try: if on_chat_start := config.code.on_chat_start: await on_chat_start() if on_slack_reaction_added := config.code.on_slack_reaction_added: await on_slack_reaction_added(event) finally: await ctx.session.delete() @slack_app.block_action("thumbdown") async def thumb_down(ack, context, body): await ack() step_id = body["actions"][0]["value"] thread_ts = body["message"]["thread_ts"] thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts)) if data_layer := get_data_layer(): feedback = Feedback(forId=step_id, value=0, threadId=thread_id) await data_layer.upsert_feedback(feedback) text = body["message"]["text"] blocks = body["message"]["blocks"] updated_blocks = [block for block in blocks if block["type"] != "actions"] updated_blocks.append( { "type": "section", "text": {"type": "mrkdwn", "text": ":thumbsdown: Feedback received."}, } ) await context.client.chat_update( channel=body["channel"]["id"], ts=body["container"]["message_ts"], text=text, blocks=updated_blocks, ) @slack_app.block_action("thumbup") async def thumb_up(ack, context, body): await ack() step_id = body["actions"][0]["value"] thread_ts = body["message"]["thread_ts"] thread_id = str(uuid.uuid5(uuid.NAMESPACE_DNS, thread_ts)) if data_layer := get_data_layer(): feedback = Feedback(forId=step_id, value=1, threadId=thread_id) await data_layer.upsert_feedback(feedback) text = body["message"]["text"] blocks = body["message"]["blocks"] updated_blocks = [block for block in blocks if block["type"] != "actions"] updated_blocks.append( { "type": "section", "text": {"type": "mrkdwn", "text": ":thumbsup: Feedback received."}, } ) await context.client.chat_update( channel=body["channel"]["id"], ts=body["container"]["message_ts"], text=text, blocks=updated_blocks, ) ================================================ FILE: backend/chainlit/socket.py ================================================ import asyncio import json from typing import Any, Dict, Literal, Optional, Tuple, TypedDict, Union from urllib.parse import unquote from starlette.requests import cookie_parser from typing_extensions import TypeAlias from chainlit.auth import ( get_current_user, get_token_from_cookies, require_login, ) from chainlit.chat_context import chat_context from chainlit.config import ChainlitConfig, config from chainlit.context import init_ws_context from chainlit.data import get_data_layer from chainlit.logger import logger from chainlit.message import ErrorMessage, Message from chainlit.server import sio from chainlit.session import ClientType, WebsocketSession from chainlit.types import ( InputAudioChunk, InputAudioChunkPayload, MessagePayload, ) from chainlit.user import PersistedUser, User from chainlit.user_session import user_sessions WSGIEnvironment: TypeAlias = dict[str, Any] class WebSocketSessionAuth(TypedDict): sessionId: str userEnv: str | None clientType: ClientType chatProfile: str | None threadId: str | None def restore_existing_session(sid, session_id, emit_fn, emit_call_fn, environ): """Restore a session from the sessionId provided by the client.""" if session := WebsocketSession.get_by_id(session_id): session.restore(new_socket_id=sid) session.emit = emit_fn session.emit_call = emit_call_fn session.environ = environ return True return False async def persist_user_session(thread_id: str, metadata: Dict): if data_layer := get_data_layer(): await data_layer.update_thread(thread_id=thread_id, metadata=metadata) async def resume_thread(session: WebsocketSession): data_layer = get_data_layer() if not data_layer or not session.user or not session.thread_id_to_resume: return thread = await data_layer.get_thread(thread_id=session.thread_id_to_resume) if not thread: return author = thread.get("userIdentifier") user_is_author = author == session.user.identifier if user_is_author: metadata = thread.get("metadata") or {} if isinstance(metadata, str): metadata = json.loads(metadata) user_sessions[session.id] = metadata.copy() if chat_profile := metadata.get("chat_profile"): session.chat_profile = chat_profile if chat_settings := metadata.get("chat_settings"): session.chat_settings = chat_settings return thread def load_user_env(user_env): if user_env: user_env_dict = json.loads(user_env) # Check user env if config.project.user_env: if not user_env_dict: raise ConnectionRefusedError("Missing user environment variables") # Check if requested user environment variables are provided for key in config.project.user_env: if key not in user_env_dict: raise ConnectionRefusedError( "Missing user environment variable: " + key ) return user_env_dict def _get_token_from_cookie(environ: WSGIEnvironment) -> Optional[str]: if cookie_header := environ.get("HTTP_COOKIE", None): cookies = cookie_parser(cookie_header) return get_token_from_cookies(cookies) return None def _get_token(environ: WSGIEnvironment) -> Optional[str]: """Take WSGI environ, return access token.""" return _get_token_from_cookie(environ) async def _authenticate_connection( environ: WSGIEnvironment, ) -> Union[Tuple[Union[User, PersistedUser], str], Tuple[None, None]]: if token := _get_token(environ): user = await get_current_user(token=token) if user: return user, token return None, None @sio.on("connect") # pyright: ignore [reportOptionalCall] async def connect(sid: str, environ: WSGIEnvironment, auth: WebSocketSessionAuth): user: User | PersistedUser | None = None token: str | None = None thread_id = auth.get("threadId", None) if require_login(): try: user, token = await _authenticate_connection(environ) except Exception as e: logger.exception("Exception authenticating connection: %s", e) if not user: logger.error("Authentication failed in websocket connect.") raise ConnectionRefusedError("authentication failed") if thread_id: if data_layer := get_data_layer(): thread = await data_layer.get_thread(thread_id) if thread and not (thread["userIdentifier"] == user.identifier): logger.error("Authorization for the thread failed.") raise ConnectionRefusedError("authorization failed") # Session scoped function to emit to the client def emit_fn(event, data): return sio.emit(event, data, to=sid) # Session scoped function to emit to the client and wait for a response def emit_call_fn(event: Literal["ask", "call_fn"], data, timeout): return sio.call(event, data, timeout=timeout, to=sid) session_id = auth["sessionId"] if restore_existing_session(sid, session_id, emit_fn, emit_call_fn, environ): return True user_env_string = auth.get("userEnv", None) user_env = load_user_env(user_env_string) client_type = auth["clientType"] url_encoded_chat_profile = auth.get("chatProfile", None) chat_profile = ( unquote(url_encoded_chat_profile) if url_encoded_chat_profile else None ) WebsocketSession( id=session_id, socket_id=sid, emit=emit_fn, emit_call=emit_call_fn, client_type=client_type, user_env=user_env, user=user, token=token, chat_profile=chat_profile, thread_id=thread_id, environ=environ, ) return True @sio.on("connection_successful") # pyright: ignore [reportOptionalCall] async def connection_successful(sid): context = init_ws_context(sid) await context.emitter.task_end() await context.emitter.clear("clear_ask") await context.emitter.clear("clear_call_fn") if context.session.restored and not context.session.has_first_interaction: if config.code.on_chat_start: task = asyncio.create_task(config.code.on_chat_start()) context.session.current_task = task return if context.session.thread_id_to_resume and config.code.on_chat_resume: thread = await resume_thread(context.session) if thread: context.session.has_first_interaction = True await context.emitter.emit( "first_interaction", {"interaction": "resume", "thread_id": thread.get("id")}, ) await config.code.on_chat_resume(thread) for step in thread.get("steps", []): if "message" in step["type"]: chat_context.add(Message.from_dict(step)) await context.emitter.resume_thread(thread) return else: await context.emitter.send_resume_thread_error("Thread not found.") if config.code.on_chat_start: task = asyncio.create_task(config.code.on_chat_start()) context.session.current_task = task @sio.on("clear_session") # pyright: ignore [reportOptionalCall] async def clean_session(sid): session = WebsocketSession.get(sid) if session: session.to_clear = True @sio.on("disconnect") # pyright: ignore [reportOptionalCall] async def disconnect(sid): session = WebsocketSession.get(sid) if not session: return init_ws_context(session) if config.code.on_chat_end: await config.code.on_chat_end() if session.thread_id and session.has_first_interaction: await persist_user_session(session.thread_id, session.to_persistable()) async def clear(_sid): if session := WebsocketSession.get(_sid): # Clean up the user session if session.id in user_sessions: user_sessions.pop(session.id) # Clean up the session await session.delete() if session.to_clear: await clear(sid) else: async def clear_on_timeout(_sid): await asyncio.sleep(config.project.session_timeout) await clear(_sid) asyncio.ensure_future(clear_on_timeout(sid)) @sio.on("stop") # pyright: ignore [reportOptionalCall] async def stop(sid): if session := WebsocketSession.get(sid): init_ws_context(session) await Message(content="Task manually stopped.").send() if session.current_task: session.current_task.cancel() if config.code.on_stop: await config.code.on_stop() async def process_message(session: WebsocketSession, payload: MessagePayload): """Process a message from the user.""" try: context = init_ws_context(session) await context.emitter.task_start() message = await context.emitter.process_message(payload) if config.code.on_message: await asyncio.sleep(0.001) await config.code.on_message(message) except asyncio.CancelledError: pass except Exception as e: logger.exception(e) await ErrorMessage( author="Error", content=str(e) or e.__class__.__name__ ).send() finally: await context.emitter.task_end() @sio.on("edit_message") # pyright: ignore [reportOptionalCall] async def edit_message(sid, payload: MessagePayload): """Handle a message sent by the User.""" session = WebsocketSession.require(sid) context = init_ws_context(session) messages = chat_context.get() orig_message = None for message in messages: if orig_message: await message.remove() if message.id == payload["message"]["id"]: message.content = payload["message"]["output"] await message.update() orig_message = message await context.emitter.task_start() if config.code.on_message: try: await config.code.on_message(orig_message) except asyncio.CancelledError: pass finally: await context.emitter.task_end() @sio.on("message_favorite") # pyright: ignore [reportOptionalCall] async def message_favorite(sid, payload: MessagePayload): """Handle a message favorite toggle.""" session = WebsocketSession.require(sid) context = init_ws_context(session) data_layer = get_data_layer() if not config.features.favorites or not session.user: return payload_message = payload["message"] payload_metadata = payload_message.get("metadata") or {} favorite = bool(payload_metadata.get("favorite", False)) step_dict = None if favorite: for message in chat_context.get(): if message.id == payload_message["id"]: message.metadata = message.metadata or {} message.metadata["favorite"] = favorite step_dict = message.to_dict() break elif data_layer: favorites = await data_layer.get_favorite_steps(session.user.id) for fav in favorites: if fav["id"] == payload_message["id"]: step_dict = fav break if step_dict is None: logger.error("Could not find step to update favorite status.") return created_at = step_dict.get("createdAt") if created_at and not created_at.endswith("Z"): step_dict["createdAt"] = f"{created_at}Z" if data_layer: step_dict = await data_layer.set_step_favorite(step_dict, favorite) await context.emitter.update_step(step_dict) await fetch_favorites(sid) @sio.on("fetch_favorites") # pyright: ignore [reportOptionalCall] async def fetch_favorites(sid): session = WebsocketSession.require(sid) context = init_ws_context(session) if session.user and config.features.favorites: if data_layer := get_data_layer(): favorites = await data_layer.get_favorite_steps(session.user.id) await context.emitter.set_favorites(favorites) @sio.on("client_message") # pyright: ignore [reportOptionalCall] async def message(sid, payload: MessagePayload): """Handle a message sent by the User.""" session = WebsocketSession.require(sid) task = asyncio.create_task(process_message(session, payload)) session.current_task = task @sio.on("window_message") # pyright: ignore [reportOptionalCall] async def window_message(sid, data): """Handle a message send by the host window.""" session = WebsocketSession.require(sid) init_ws_context(session) if config.code.on_window_message: try: await config.code.on_window_message(data) except asyncio.CancelledError: pass @sio.on("audio_start") # pyright: ignore [reportOptionalCall] async def audio_start(sid): """Handle audio init.""" session = WebsocketSession.require(sid) context = init_ws_context(session) config: ChainlitConfig = session.get_config() # type: ignore if config.features.audio and config.features.audio.enabled: connected = bool(await config.code.on_audio_start()) connection_state = "on" if connected else "off" await context.emitter.update_audio_connection(connection_state) @sio.on("audio_chunk") async def audio_chunk(sid, payload: InputAudioChunkPayload): """Handle an audio chunk sent by the user.""" session = WebsocketSession.require(sid) init_ws_context(session) config: ChainlitConfig = session.get_config() if ( config.features.audio and config.features.audio.enabled and config.code.on_audio_chunk ): asyncio.create_task(config.code.on_audio_chunk(InputAudioChunk(**payload))) @sio.on("audio_end") async def audio_end(sid): """Handle the end of the audio stream.""" session = WebsocketSession.require(sid) try: context = init_ws_context(session) await context.emitter.task_start() if not session.has_first_interaction: session.has_first_interaction = True asyncio.create_task(context.emitter.init_thread("audio")) config: ChainlitConfig = session.get_config() # type: ignore if config.features.audio and config.features.audio.enabled: await config.code.on_audio_end() except asyncio.CancelledError: pass except Exception as e: logger.exception(e) await ErrorMessage( author="Error", content=str(e) or e.__class__.__name__ ).send() finally: await context.emitter.task_end() @sio.on("chat_settings_change") async def change_settings(sid, settings: Dict[str, Any]): """Handle change settings submit from the UI.""" context = init_ws_context(sid) for key, value in settings.items(): context.session.chat_settings[key] = value if config.code.on_settings_update: await config.code.on_settings_update(settings) @sio.on("chat_settings_edit") async def edit_settings(sid, settings: Dict[str, Any]): """Handle change settings edit from the UI (on the fly).""" init_ws_context(sid) if config.code.on_settings_edit: await config.code.on_settings_edit(settings) ================================================ FILE: backend/chainlit/step.py ================================================ import asyncio import inspect import json import time import uuid from copy import deepcopy from functools import wraps from typing import Callable, Dict, List, Optional, TypedDict, Union from literalai import BaseGeneration from literalai.observability.step import StepType, TrueStepType from chainlit.config import config from chainlit.context import CL_RUN_NAMES, context, local_steps from chainlit.data import get_data_layer from chainlit.element import Element from chainlit.logger import logger from chainlit.types import FeedbackDict from chainlit.utils import utc_now def check_add_step_in_cot(step: "Step"): is_message = step.type in [ "user_message", "assistant_message", ] is_cl_run = step.name in CL_RUN_NAMES and step.type == "run" if config.ui.cot == "hidden" and not is_message and not is_cl_run: return False return True def stub_step(step: "Step") -> "StepDict": return { "type": step.type, "name": step.name, "id": step.id, "parentId": step.parent_id, "threadId": step.thread_id, "input": "", "output": "", } class StepDict(TypedDict, total=False): name: str type: StepType id: str threadId: str parentId: Optional[str] command: Optional[str] modes: Optional[Dict[str, str]] streaming: bool waitForAnswer: Optional[bool] isError: Optional[bool] metadata: Dict tags: Optional[List[str]] input: str output: str createdAt: Optional[str] start: Optional[str] end: Optional[str] generation: Optional[Dict] showInput: Optional[Union[bool, str]] defaultOpen: Optional[bool] autoCollapse: Optional[bool] language: Optional[str] feedback: Optional[FeedbackDict] def flatten_args_kwargs(func, args, kwargs): signature = inspect.signature(func) bound_arguments = signature.bind(*args, **kwargs) bound_arguments.apply_defaults() return {k: deepcopy(v) for k, v in bound_arguments.arguments.items()} def step( original_function: Optional[Callable] = None, *, name: Optional[str] = "", type: TrueStepType = "undefined", id: Optional[str] = None, parent_id: Optional[str] = None, tags: Optional[List[str]] = None, metadata: Optional[Dict] = None, language: Optional[str] = None, show_input: Union[bool, str] = "json", default_open: bool = False, auto_collapse: bool = False, ): """Step decorator for async and sync functions.""" def wrapper(func: Callable): nonlocal name if not name: name = func.__name__ # Handle async decorator if inspect.iscoroutinefunction(func): @wraps(func) async def async_wrapper(*args, **kwargs): async with Step( type=type, name=name, id=id, parent_id=parent_id, tags=tags, language=language, show_input=show_input, default_open=default_open, auto_collapse=auto_collapse, metadata=metadata, ) as step: try: step.input = flatten_args_kwargs(func, args, kwargs) except Exception as e: logger.exception(e) result = await func(*args, **kwargs) try: if result and not step.output: step.output = result except Exception as e: step.is_error = True step.output = str(e) return result return async_wrapper else: # Handle sync decorator @wraps(func) def sync_wrapper(*args, **kwargs): with Step( type=type, name=name, id=id, parent_id=parent_id, tags=tags, language=language, show_input=show_input, default_open=default_open, auto_collapse=auto_collapse, metadata=metadata, ) as step: try: step.input = flatten_args_kwargs(func, args, kwargs) except Exception as e: logger.exception(e) result = func(*args, **kwargs) try: if result and not step.output: step.output = result except Exception as e: step.is_error = True step.output = str(e) return result return sync_wrapper func = original_function if not func: return wrapper else: return wrapper(func) class Step: # Constructor name: str type: TrueStepType id: str parent_id: Optional[str] streaming: bool persisted: bool show_input: Union[bool, str] is_error: Optional[bool] metadata: Dict tags: Optional[List[str]] thread_id: str created_at: Union[str, None] start: Union[str, None] end: Union[str, None] generation: Optional[BaseGeneration] language: Optional[str] default_open: Optional[bool] auto_collapse: Optional[bool] elements: Optional[List[Element]] fail_on_persist_error: bool def __init__( self, name: Optional[str] = config.ui.name, type: TrueStepType = "undefined", id: Optional[str] = None, parent_id: Optional[str] = None, elements: Optional[List[Element]] = None, metadata: Optional[Dict] = None, tags: Optional[List[str]] = None, language: Optional[str] = None, default_open: Optional[bool] = False, auto_collapse: Optional[bool] = False, show_input: Union[bool, str] = "json", thread_id: Optional[str] = None, ): time.sleep(0.001) self._input = "" self._output = "" self.thread_id = thread_id or context.session.thread_id self.name = name or "" self.type = type self.id = id or str(uuid.uuid4()) self.metadata = metadata or {} self.tags = tags self.is_error = False self.show_input = show_input self.parent_id = parent_id self.language = language self.default_open = default_open self.auto_collapse = auto_collapse self.generation = None self.elements = elements or [] self.created_at = utc_now() self.start = None self.end = None self.streaming = False self.persisted = False self.fail_on_persist_error = False def _clean_content(self, content): """ Recursively checks and converts bytes objects in content. """ def handle_bytes(item): if isinstance(item, bytes): return "STRIPPED_BINARY_DATA" elif isinstance(item, dict): return {k: handle_bytes(v) for k, v in item.items()} elif isinstance(item, list): return [handle_bytes(i) for i in item] elif isinstance(item, tuple): return tuple(handle_bytes(i) for i in item) return item return handle_bytes(content) def _process_content(self, content, set_language=False): if content is None: return "" content = self._clean_content(content) if ( isinstance(content, dict) or isinstance(content, list) or isinstance(content, tuple) ): try: processed_content = json.dumps(content, indent=4, ensure_ascii=False) if set_language: self.language = "json" except TypeError: processed_content = str(content).replace("\\n", "\n") if set_language: self.language = "text" elif isinstance(content, str): processed_content = content else: processed_content = str(content).replace("\\n", "\n") if set_language: self.language = "text" return processed_content @property def input(self): return self._input @input.setter def input(self, content: Union[Dict, str]): self._input = self._process_content(content, set_language=False) @property def output(self): return self._output @output.setter def output(self, content: Union[Dict, str]): self._output = self._process_content(content, set_language=True) def to_dict(self) -> StepDict: _dict: StepDict = { "name": self.name, "type": self.type, "id": self.id, "threadId": self.thread_id, "parentId": self.parent_id, "streaming": self.streaming, "metadata": self.metadata, "tags": self.tags, "input": self.input, "isError": self.is_error, "output": self.output, "createdAt": self.created_at, "start": self.start, "end": self.end, "language": self.language, "defaultOpen": self.default_open, "autoCollapse": self.auto_collapse, "showInput": self.show_input, "generation": self.generation.to_dict() if self.generation else None, } return _dict async def update(self): """ Update a step already sent to the UI. """ if self.streaming: self.streaming = False step_dict = self.to_dict() data_layer = get_data_layer() if data_layer: try: asyncio.create_task(data_layer.update_step(step_dict.copy())) except Exception as e: if self.fail_on_persist_error: raise e logger.error(f"Failed to persist step update: {e!s}") tasks = [el.send(for_id=self.id) for el in self.elements] await asyncio.gather(*tasks) if not check_add_step_in_cot(self): await context.emitter.update_step(stub_step(self)) else: await context.emitter.update_step(step_dict) return True async def remove(self): """ Remove a step already sent to the UI. """ step_dict = self.to_dict() data_layer = get_data_layer() if data_layer: try: asyncio.create_task(data_layer.delete_step(self.id)) except Exception as e: if self.fail_on_persist_error: raise e logger.error(f"Failed to persist step deletion: {e!s}") await context.emitter.delete_step(step_dict) return True async def send(self): if self.persisted: return self if config.code.author_rename: self.name = await config.code.author_rename(self.name) if self.streaming: self.streaming = False step_dict = self.to_dict() data_layer = get_data_layer() if data_layer: try: asyncio.create_task(data_layer.create_step(step_dict.copy())) self.persisted = True except Exception as e: if self.fail_on_persist_error: raise e logger.error(f"Failed to persist step creation: {e!s}") tasks = [el.send(for_id=self.id) for el in self.elements] await asyncio.gather(*tasks) if not check_add_step_in_cot(self): await context.emitter.send_step(stub_step(self)) else: await context.emitter.send_step(step_dict) return self async def stream_token(self, token: str, is_sequence=False, is_input=False): """ Sends a token to the UI. Once all tokens have been streamed, call .send() to end the stream and persist the step if persistence is enabled. """ if not token: return if is_sequence: if is_input: self.input = token else: self.output = token else: if is_input: self.input += token else: self.output += token assert self.id if not check_add_step_in_cot(self): await context.emitter.send_step(stub_step(self)) return if not self.streaming: self.streaming = True step_dict = self.to_dict() await context.emitter.stream_start(step_dict) else: await context.emitter.send_token( id=self.id, token=token, is_sequence=is_sequence, is_input=is_input ) # Handle parameter less decorator def __call__(self, func): return step( original_function=func, type=self.type, name=self.name, id=self.id, parent_id=self.parent_id, thread_id=self.thread_id, ) # Handle Context Manager Protocol async def __aenter__(self): self.start = utc_now() previous_steps = local_steps.get() or [] parent_step = previous_steps[-1] if previous_steps else None if not self.parent_id: if parent_step: self.parent_id = parent_step.id local_steps.set(previous_steps + [self]) await self.send() return self async def __aexit__(self, exc_type, exc_val, exc_tb): self.end = utc_now() if exc_type: self.output = str(exc_val) self.is_error = True current_steps = local_steps.get() if current_steps and self in current_steps: current_steps.remove(self) local_steps.set(current_steps) await self.update() def __enter__(self): self.start = utc_now() previous_steps = local_steps.get() or [] parent_step = previous_steps[-1] if previous_steps else None if not self.parent_id: if parent_step: self.parent_id = parent_step.id local_steps.set(previous_steps + [self]) asyncio.create_task(self.send()) return self def __exit__(self, exc_type, exc_val, exc_tb): self.end = utc_now() if exc_type: self.output = str(exc_val) self.is_error = True current_steps = local_steps.get() if current_steps and self in current_steps: current_steps.remove(self) local_steps.set(current_steps) asyncio.create_task(self.update()) ================================================ FILE: backend/chainlit/sync.py ================================================ import sys from typing import Any, Coroutine, TypeVar if sys.version_info >= (3, 10): from typing import ParamSpec else: from typing_extensions import ParamSpec import asyncio import threading from asyncer import asyncify from syncer import sync from chainlit.context import context_var make_async = asyncify T_Retval = TypeVar("T_Retval") T_ParamSpec = ParamSpec("T_ParamSpec") T = TypeVar("T") def run_sync(co: Coroutine[Any, Any, T_Retval]) -> T_Retval: """Run the coroutine synchronously.""" # Copy the current context current_context = context_var.get() # Define a wrapper coroutine that sets the context before running the original coroutine async def context_preserving_coroutine(): # Set the copied context to the coroutine context_var.set(current_context) return await co # Execute from the main thread in the main event loop if threading.current_thread() == threading.main_thread(): return sync(context_preserving_coroutine()) else: # Execute from a thread in the main event loop result = asyncio.run_coroutine_threadsafe( context_preserving_coroutine(), loop=current_context.loop ) return result.result() ================================================ FILE: backend/chainlit/teams/__init__.py ================================================ import importlib.util if importlib.util.find_spec("botbuilder") is None: raise ValueError( "The botbuilder-core package is required to integrate Chainlit with a Slack app. Run `pip install botbuilder-core --upgrade`" ) ================================================ FILE: backend/chainlit/teams/app.py ================================================ import asyncio import base64 import mimetypes import os import uuid from datetime import datetime from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union import filetype if TYPE_CHECKING: from botbuilder.core import TurnContext from botbuilder.schema import Activity import httpx from botbuilder.core import ( BotFrameworkAdapter, BotFrameworkAdapterSettings, MessageFactory, TurnContext, ) from botbuilder.schema import ( ActionTypes, Activity, ActivityTypes, Attachment, CardAction, ChannelAccount, HeroCard, ) from chainlit.config import config from chainlit.context import ChainlitContext, HTTPSession, context, context_var from chainlit.data import get_data_layer from chainlit.element import Element, ElementDict from chainlit.emitter import BaseChainlitEmitter from chainlit.logger import logger from chainlit.message import Message, StepDict from chainlit.types import Feedback from chainlit.user import PersistedUser, User from chainlit.user_session import user_session class TeamsEmitter(BaseChainlitEmitter): def __init__(self, session: HTTPSession, turn_context: TurnContext): super().__init__(session) self.turn_context = turn_context async def send_element(self, element_dict: ElementDict): if element_dict.get("display") != "inline": return persisted_file = self.session.files.get(element_dict.get("chainlitKey") or "") attachment: Optional[Attachment] = None mime: Optional[str] = None element_name: str = element_dict.get("name", "Untitled") if mime: file_extension = mimetypes.guess_extension(mime) if file_extension: element_name += file_extension if persisted_file: mime = element_dict.get("mime") with open(persisted_file["path"], "rb") as file: dencoded_string = base64.b64encode(file.read()).decode() content_url = f"data:{mime};base64,{dencoded_string}" attachment = Attachment( content_type=mime, content_url=content_url, name=element_name ) elif url := element_dict.get("url"): attachment = Attachment( content_type=mime, content_url=url, name=element_name ) if not attachment: return await self.turn_context.send_activity(Activity(attachments=[attachment])) async def send_step(self, step_dict: StepDict): if not step_dict["type"] == "assistant_message": return step_type = step_dict.get("type") is_message = step_type in [ "user_message", "assistant_message", ] is_empty_output = not step_dict.get("output") if is_empty_output or not is_message: return else: reply = MessageFactory.text(step_dict["output"]) enable_feedback = get_data_layer() if enable_feedback: current_run = context.current_run scorable_id = current_run.id if current_run else step_dict["id"] like_button = CardAction( type=ActionTypes.message_back, title="👍", text="like", value={"feedback": "like", "step_id": scorable_id}, ) dislike_button = CardAction( type=ActionTypes.message_back, title="👎", text="dislike", value={"feedback": "dislike", "step_id": scorable_id}, ) card = HeroCard(buttons=[like_button, dislike_button]) attachment = Attachment( content_type="application/vnd.microsoft.card.hero", content=card ) reply.attachments = [attachment] await self.turn_context.send_activity(reply) async def update_step(self, step_dict: StepDict): if not step_dict["type"] == "assistant_message": return await self.send_step(step_dict) adapter_settings = BotFrameworkAdapterSettings( app_id=os.environ.get("TEAMS_APP_ID"), app_password=os.environ.get("TEAMS_APP_PASSWORD"), ) adapter = BotFrameworkAdapter(adapter_settings) def init_teams_context( session: HTTPSession, turn_context: TurnContext, ) -> ChainlitContext: emitter = TeamsEmitter(session=session, turn_context=turn_context) context = ChainlitContext(session=session, emitter=emitter) context_var.set(context) user_session.set("teams_turn_context", turn_context) return context users_by_teams_id: Dict[str, Union[User, PersistedUser]] = {} USER_PREFIX = "teams_" async def get_user(teams_user: ChannelAccount): if teams_user.id in users_by_teams_id: return users_by_teams_id[teams_user.id] metadata = { "name": teams_user.name, "id": teams_user.id, } user = User(identifier=USER_PREFIX + str(teams_user.name), metadata=metadata) users_by_teams_id[teams_user.id] = user if data_layer := get_data_layer(): try: persisted_user = await data_layer.create_user(user) if persisted_user: users_by_teams_id[teams_user.id] = persisted_user except Exception as e: logger.error(f"Error creating user: {e}") return users_by_teams_id[teams_user.id] async def download_teams_file(url: str): async with httpx.AsyncClient() as client: response = await client.get(url) if response.status_code == 200: return response.content else: return None async def download_teams_files( session: HTTPSession, attachments: Optional[List[Attachment]] = None ): if not attachments: return [] attachments = [ attachment for attachment in attachments if isinstance(attachment.content, dict) ] download_coros = [ download_teams_file(attachment.content.get("downloadUrl")) for attachment in attachments ] file_bytes_list = await asyncio.gather(*download_coros) file_refs = [] for idx, file_bytes in enumerate(file_bytes_list): if file_bytes: name = attachments[idx].name mime_type = filetype.guess_mime(file_bytes) or "application/octet-stream" file_ref = await session.persist_file( name=name, mime=mime_type, content=file_bytes ) file_refs.append(file_ref) files_dicts = [ session.files[file["id"]] for file in file_refs if file["id"] in session.files ] elements = [ Element.from_dict( { "id": file["id"], "name": file["name"], "path": str(file["path"]), "chainlitKey": file["id"], "display": "inline", "type": Element.infer_type_from_mime(file["type"]), } ) for file in files_dicts ] return elements def clean_content(activity: Activity): return activity.text.strip() async def process_teams_message( turn_context: TurnContext, thread_name: str, ): user = await get_user(turn_context.activity.from_property) thread_id = str( uuid.uuid5( uuid.NAMESPACE_DNS, str( turn_context.activity.conversation.id + datetime.today().strftime("%Y-%m-%d") ), ) ) text = clean_content(turn_context.activity) teams_files = turn_context.activity.attachments session_id = str(uuid.uuid4()) session = HTTPSession( id=session_id, thread_id=thread_id, user=user, client_type="teams", ) ctx = init_teams_context( session=session, turn_context=turn_context, ) file_elements = await download_teams_files(session, teams_files) if on_chat_start := config.code.on_chat_start: await on_chat_start() msg = Message( content=text, elements=file_elements, type="user_message", author=user.metadata.get("name"), ) await msg.send() if on_message := config.code.on_message: await on_message(msg) if on_chat_end := config.code.on_chat_end: await on_chat_end() if data_layer := get_data_layer(): if isinstance(user, PersistedUser): try: await data_layer.update_thread( thread_id=thread_id, name=thread_name, metadata=ctx.session.to_persistable(), user_id=user.id, ) except Exception as e: logger.error(f"Error updating thread: {e}") await ctx.session.delete() async def handle_message(turn_context: TurnContext): if turn_context.activity.type == ActivityTypes.message: if ( turn_context.activity.text == "like" or turn_context.activity.text == "dislike" ): feedback_value: Literal[0, 1] = ( 0 if turn_context.activity.text == "dislike" else 1 ) step_id = turn_context.activity.value.get("step_id") if data_layer := get_data_layer(): await data_layer.upsert_feedback( Feedback(forId=step_id, value=feedback_value) ) updated_text = "👍" if turn_context.activity.text == "like" else "👎" # Update the existing message to remove the buttons updated_message = Activity( type=ActivityTypes.message, id=turn_context.activity.reply_to_id, text=updated_text, attachments=[], ) await turn_context.update_activity(updated_message) else: # Send typing activity typing_activity = Activity( type=ActivityTypes.typing, from_property=turn_context.activity.recipient, recipient=turn_context.activity.from_property, conversation=turn_context.activity.conversation, ) await turn_context.send_activity(typing_activity) thread_name = f"{turn_context.activity.from_property.name} Teams DM {datetime.today().strftime('%Y-%m-%d')}" await process_teams_message(turn_context, thread_name) async def on_turn(turn_context: TurnContext): await handle_message(turn_context) # Create the main bot class class TeamsBot: async def on_turn(self, turn_context: TurnContext): await on_turn(turn_context) # Create the bot instance bot = TeamsBot() ================================================ FILE: backend/chainlit/translations/ar-SA.json ================================================ { "common": { "actions": { "cancel": "إلغاء", "confirm": "تأكيد", "continue": "متابعة", "goBack": "رجوع", "reset": "إعادة تعيين", "submit": "إرسال" }, "status": { "loading": "جاري التحميل...", "error": { "default": "حدث خطأ", "serverConnection": "تعذر الاتصال بالخادم" } } }, "auth": { "login": { "title": "قم بتسجيل الدخول للوصول إلى التطبيق", "form": { "email": { "label": "البريد الإلكتروني", "required": "البريد الإلكتروني حقل إلزامي", "placeholder": "me@example.com" }, "password": { "label": "كلمة المرور", "required": "كلمة المرور حقل إلزامي" }, "actions": { "signin": "تسجيل الدخول" }, "alternativeText": { "or": "أو" } }, "errors": { "default": "تعذر تسجيل الدخول", "signin": "حاول تسجيل الدخول بحساب آخر", "oauthSignin": "حاول تسجيل الدخول بحساب آخر", "redirectUriMismatch": "عنوان URI لإعادة التوجيه لا يتطابق مع تكوين تطبيق OAuth", "oauthCallback": "حاول تسجيل الدخول بحساب آخر", "oauthCreateAccount": "حاول تسجيل الدخول بحساب آخر", "emailCreateAccount": "حاول تسجيل الدخول بحساب آخر", "callback": "حاول تسجيل الدخول بحساب آخر", "oauthAccountNotLinked": "لتأكيد هويتك، قم بتسجيل الدخول بنفس الحساب الذي استخدمته في الأصل", "emailSignin": "تعذر إرسال البريد الإلكتروني", "emailVerify": "يرجى التحقق من بريدك الإلكتروني، تم إرسال بريد إلكتروني جديد", "credentialsSignin": "فشل تسجيل الدخول. تحقق من صحة المعلومات المقدمة", "sessionRequired": "يرجى تسجيل الدخول للوصول إلى هذه الصفحة" } }, "provider": { "continue": "متابعة مع {{provider}}" } }, "chat": { "input": { "placeholder": "اكتب رسالتك هنا...", "actions": { "send": "إرسال الرسالة", "stop": "إيقاف المهمة", "attachFiles": "إرفاق ملفات" } }, "favorites": { "use": "استخدام رسالة مفضلة", "headline": "الرسائل المفضلة", "empty": { "title": "لا توجد رسائل محفوظة بعد", "description": "ابدأ بإرسال رسالة وقم بتمييزها بنجمة أو ميّز رسالة من محادثاتك السابقة" } }, "commands": { "button": "أدوات", "changeTool": "تغيير الأداة", "availableTools": "الأدوات المتاحة" }, "speech": { "start": "بدء التسجيل", "stop": "إيقاف التسجيل", "connecting": "جاري الاتصال" }, "fileUpload": { "dragDrop": "اسحب وأفلت الملفات هنا", "browse": "تصفح الملفات", "sizeLimit": "الحد الأقصى:", "errors": { "failed": "فشل التحميل", "cancelled": "تم إلغاء تحميل" }, "actions": { "cancelUpload": "إلغاء التحميل", "removeAttachment": "إزالة المرفق" } }, "messages": { "status": { "using": "يستخدم", "used": "مستخدم" }, "actions": { "copy": { "button": "نسخ إلى الحافظة", "success": "تم النسخ!" } }, "feedback": { "positive": "مفيد", "negative": "غير مفيد", "edit": "تعديل التعليق", "dialog": { "title": "إضافة تعليق", "submit": "إرسال التعليق", "yourFeedback": "رأيك..." }, "status": { "updating": "جاري التحديث", "updated": "تم تحديث التعليق" } } }, "history": { "title": "المدخلات الأخيرة", "empty": "فارغ تماماً...", "show": "عرض السجل" }, "settings": { "title": "لوحة الإعدادات", "customize": "خصص إعدادات المحادثة هنا" }, "watermark": "قد تخطئ نماذج الذكاء الاصطناعي. تحقق من المعلومات المهمة." }, "threadHistory": { "sidebar": { "title": "المحادثات السابقة", "filters": { "search": "بحث", "placeholder": "البحث في المحادثات..." }, "timeframes": { "today": "اليوم", "yesterday": "أمس", "previous7days": "آخر 7 أيام", "previous30days": "آخر 30 يوماً" }, "empty": "لم يتم العثور على محادثات", "actions": { "close": "إغلاق الشريط الجانبي", "open": "فتح الشريط الجانبي" } }, "thread": { "untitled": "محادثة بدون عنوان", "menu": { "rename": "إعادة تسمية", "share": "مشاركة", "delete": "حذف" }, "actions": { "share": { "title": "مشاركة رابط المحادثة", "button": "مشاركة", "status": { "copied": "تم نسخ الرابط", "created": "تم إنشاء رابط المشاركة!", "unshared": "تم تعطيل المشاركة لهذه المحادثة" }, "error": { "create": "فشل إنشاء رابط المشاركة", "unshare": "فشل تعطيل مشاركة المحادثة" } }, "delete": { "title": "تأكيد الحذف", "description": "سيؤدي هذا إلى حذف المحادثة مع رسائلها وعناصرها. لا يمكن التراجع عن هذا الإجراء", "success": "تم حذف المحادثة", "inProgress": "جاري حذف المحادثة" }, "rename": { "title": "إعادة تسمية المحادثة", "description": "أدخل اسماً جديداً لهذه المحادثة", "form": { "name": { "label": "الاسم", "placeholder": "أدخل الاسم الجديد" } }, "success": "تمت إعادة تسمية المحادثة!", "inProgress": "جاري إعادة تسمية المحادثة" } } } }, "navigation": { "header": { "chat": "محادثة", "readme": "اقرأني", "theme": { "light": "السمة الفاتحة", "dark": "السمة الداكنة", "system": "متابعة النظام" } }, "newChat": { "button": "محادثة جديدة", "dialog": { "title": "إنشاء محادثة جديدة", "description": "سيؤدي هذا إلى مسح سجل المحادثة الحالي. هل أنت متأكد من أنك تريد المتابعة؟", "tooltip": "محادثة جديدة" } }, "user": { "menu": { "settings": "الإعدادات", "settingsKey": "S", "apiKeys": "مفاتيح API", "logout": "تسجيل الخروج" } } }, "apiKeys": { "title": "مفاتيح API المطلوبة", "description": "لاستخدام هذا التطبيق، مفاتيح API التالية مطلوبة. يتم تخزين المفاتيح في التخزين المحلي لجهازك.", "success": { "saved": "تم الحفظ بنجاح" } }, "alerts": { "info": "معلومات", "note": "ملاحظة", "tip": "نصيحة", "important": "مهم", "warning": "تحذير", "caution": "تنبيه", "debug": "تصحيح", "example": "مثال", "success": "نجاح", "help": "مساعدة", "idea": "فكرة", "pending": "قيد الانتظار", "security": "أمان", "beta": "تجريبي", "best-practice": "أفضل ممارسة" }, "components": { "MultiSelectInput": { "placeholder": "اختر..." }, "DatePickerInput": { "placeholder": { "single": "اختر تاريخاً", "range": "اختر نطاقاً من التواريخ" } } } } ================================================ FILE: backend/chainlit/translations/bn.json ================================================ { "common": { "actions": { "cancel": "বাতিল করুন", "confirm": "নিশ্চিত করুন", "continue": "চালিয়ে যান", "goBack": "পিছনে যান", "reset": "রিসেট করুন", "submit": "জমা দিন" }, "status": { "loading": "লোড হচ্ছে...", "error": { "default": "একটি ত্রুটি ঘটেছে", "serverConnection": "সার্ভারের সাথে সংযোগ করা যাচ্ছে না" } } }, "auth": { "login": { "title": "অ্যাপ্লিকেশন ব্যবহার করতে লগইন করুন", "form": { "email": { "label": "ইমেইল ঠিকানা", "required": "ইমেইল একটি আবশ্যক ক্ষেত্র", "placeholder": "me@example.com" }, "password": { "label": "পাসওয়ার্ড", "required": "পাসওয়ার্ড একটি আবশ্যক ক্ষেত্র" }, "actions": { "signin": "সাইন ইন করুন" }, "alternativeText": { "or": "অথবা" } }, "errors": { "default": "সাইন ইন করা সম্ভব হচ্ছে না", "signin": "অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন", "oauthSignin": "অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন", "redirectUriMismatch": "রিডাইরেক্ট URI ওআথ অ্যাপ কনফিগারেশনের সাথে মিলছে না", "oauthCallback": "অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন", "oauthCreateAccount": "অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন", "emailCreateAccount": "অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন", "callback": "অন্য একটি অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন", "oauthAccountNotLinked": "আপনার পরিচয় নিশ্চিত করতে, আপনি যে অ্যাকাউন্টটি মূলত ব্যবহার করেছিলেন সেটি দিয়ে সাইন ইন করুন", "emailSignin": "ইমেইল পাঠানো যায়নি", "emailVerify": "অনুগ্রহ করে আপনার ইমেইল যাচাই করুন, একটি নতুন ইমেইল পাঠানো হয়েছে", "credentialsSignin": "সাইন ইন ব্যর্থ হয়েছে। আপনার দেওয়া তথ্য সঠিক কিনা যাচাই করুন", "sessionRequired": "এই পৃষ্ঠা দেখতে অনুগ্রহ করে সাইন ইন করুন" } }, "provider": { "continue": "{{provider}} দিয়ে চালিয়ে যান" } }, "chat": { "input": { "placeholder": "আপনার বার্তা এখানে টাইপ করুন...", "actions": { "send": "বার্তা পাঠান", "stop": "কাজ বন্ধ করুন", "attachFiles": "ফাইল সংযুক্ত করুন" } }, "speech": { "start": "রেকর্ডিং শুরু করুন", "stop": "রেকর্ডিং বন্ধ করুন", "connecting": "সংযোগ করা হচ্ছে" }, "favorites": { "use": "একটি পছন্দের মেসেজ ব্যবহার করুন", "headline": "পছন্দের মেসেজ", "remove": "পছন্দ বাতিল করুন", "empty": { "title": "এখনও কোনো প্রম্পট সংরক্ষিত নেই", "description": "একটি প্রম্পট পাঠিয়ে এবং তাতে তারকা চিহ্ন দিয়ে শুরু করুন বা আগের চ্যাট থেকে একটি প্রম্পটে তারকা চিহ্ন দিন" } }, "commands": { "button": "টুলস", "changeTool": "টুল পরিবর্তন করুন", "availableTools": "উপলব্ধ টুলস" }, "fileUpload": { "dragDrop": "এখানে ফাইল টেনে আনুন", "browse": "ফাইল ব্রাউজ করুন", "sizeLimit": "সীমা:", "errors": { "failed": "আপলোড ব্যর্থ হয়েছে", "cancelled": "আপলোড বাতিল করা হয়েছে" }, "actions": { "cancelUpload": "আপলোড বাতিল করুন", "removeAttachment": "সংযুক্তি মুছে ফেলুন" } }, "messages": { "status": { "using": "ব্যবহার করছে", "used": "ব্যবহৃত" }, "actions": { "copy": { "button": "ক্লিপবোর্ডে কপি করুন", "success": "কপি করা হয়েছে!" } }, "feedback": { "positive": "সহায়ক", "negative": "সহায়ক নয়", "edit": "প্রতিক্রিয়া সম্পাদনা করুন", "dialog": { "title": "মন্তব্য যোগ করুন", "submit": "প্রতিক্রিয়া জমা দিন", "yourFeedback": "আপনার প্রতিক্রিয়া..." }, "status": { "updating": "হালনাগাদ করা হচ্ছে", "updated": "প্রতিক্রিয়া হালনাগাদ করা হয়েছে" } } }, "history": { "title": "সর্বশেষ ইনপুট", "empty": "কোনো তথ্য নেই...", "show": "ইতিহাস দেখুন" }, "settings": { "title": "সেটিংস প্যানেল", "customize": "এখানে আপনার চ্যাট সেটিংস কাস্টমাইজ করুন" }, "watermark": "এলএলএম ভুল করতে পারে। গুরুত্বপূর্ণ তথ্য যাচাই করার কথা বিবেচনা করুন।" }, "threadHistory": { "sidebar": { "title": "পূর্ববর্তী চ্যাট", "filters": { "search": "অনুসন্ধান", "placeholder": "Search conversations..." }, "timeframes": { "today": "আজ", "yesterday": "গতকাল", "previous7days": "গত ৭ দিন", "previous30days": "গত ৩০ দিন" }, "empty": "কোনো থ্রেড পাওয়া যায়নি", "actions": { "close": "সাইডবার বন্ধ করুন", "open": "সাইডবার খুলুন" } }, "thread": { "untitled": "শিরোনামহীন আলোচনা", "menu": { "rename": "পুনঃনামকরণ", "share": "শেয়ার", "delete": "Delete" }, "actions": { "share": { "title": "চ্যাটের লিঙ্ক শেয়ার করুন", "button": "শেয়ার", "status": { "copied": "লিঙ্ক কপি করা হয়েছে", "created": "শেয়ার লিঙ্ক তৈরি হয়েছে!", "unshared": "এই থ্রেডের জন্য শেয়ারিং অক্ষম করা হয়েছে" }, "error": { "create": "শেয়ার লিঙ্ক তৈরি করতে ব্যর্থ", "unshare": "থ্রেডের শেয়ারিং বন্ধ করতে ব্যর্থ" } }, "delete": { "title": "মুছে ফেলা নিশ্চিত করুন", "description": "এটি থ্রেড এবং এর বার্তা ও উপাদানগুলি মুছে ফেলবে। এই কাজটি পূর্বাবস্থায় ফেরানো যাবে না", "success": "চ্যাট মুছে ফেলা হয়েছে", "inProgress": "চ্যাট মুছে ফেলা হচ্ছে" }, "rename": { "title": "থ্রেডের নাম পরিবর্তন করুন", "description": "এই থ্রেডের জন্য একটি নতুন নাম দিন", "form": { "name": { "label": "নাম", "placeholder": "নতুন নাম লিখুন" } }, "success": "থ্রেডের নাম পরিবর্তন করা হয়েছে!", "inProgress": "থ্রেডের নাম পরিবর্তন করা হচ্ছে" } } } }, "navigation": { "header": { "chat": "চ্যাট", "readme": "রিডমি", "theme": { "light": "Light Theme", "dark": "Dark Theme", "system": "Follow System" } }, "newChat": { "button": "নতুন চ্যাট", "dialog": { "title": "নতুন চ্যাট তৈরি করুন", "description": "এটি আপনার বর্তমান চ্যাট ইতিহাস মুছে ফেলবে। আপনি কি চালিয়ে যেতে চান?", "tooltip": "নতুন চ্যাট" } }, "user": { "menu": { "settings": "সেটিংস", "settingsKey": "S", "apiKeys": "এপিআই কী", "logout": "লগআউট" } } }, "apiKeys": { "title": "প্রয়োজনীয় এপিআই কী", "description": "এই অ্যাপ্লিকেশন ব্যবহার করতে নিম্নলিখিত এপিআই কী প্রয়োজন। কীগুলি আপনার ডিভাইসের লোকাল স্টোরেজে সংরক্ষিত থাকে।", "success": { "saved": "সফলভাবে সংরক্ষিত হয়েছে" } }, "alerts": { "info": "Info", "note": "Note", "tip": "Tip", "important": "Important", "warning": "Warning", "caution": "Caution", "debug": "Debug", "example": "Example", "success": "Success", "help": "Help", "idea": "Idea", "pending": "Pending", "security": "Security", "beta": "Beta", "best-practice": "Best Practice" }, "components": { "MultiSelectInput": { "placeholder": "বেছে নিন..." }, "DatePickerInput": { "placeholder": { "single": "একটি তারিখ বেছে নিন", "range": "তারিখের পরিসীমা বেছে নিন" } } } } ================================================ FILE: backend/chainlit/translations/da-DK.json ================================================ { "common": { "actions": { "cancel": "Annuller", "confirm": "Bekræft", "continue": "Fortsæt", "goBack": "Gå tilbage", "reset": "Nulstil", "submit": "Indsend" }, "status": { "loading": "Indlæser...", "error": { "default": "Der opstod en fejl", "serverConnection": "Kunne ikke nå serveren" } } }, "auth": { "login": { "title": "Log ind for at få adgang til appen", "form": { "email": { "label": "E-mailadresse", "required": "e-mail er et påkrævet felt", "placeholder": "me@example.com" }, "password": { "label": "Adgangskode", "required": "adgangskode er et påkrævet felt" }, "actions": { "signin": "Log ind" }, "alternativeText": { "or": "ELLER" } }, "errors": { "default": "Kunne ikke logge ind", "signin": "Prøv at logge ind med en anden konto", "oauthSignin": "Prøv at logge ind med en anden konto", "redirectUriMismatch": "Omdirigerings-URI'en matcher ikke oauth-app konfigurationen", "oauthCallback": "Prøv at logge ind med en anden konto", "oauthCreateAccount": "Prøv at logge ind med en anden konto", "emailCreateAccount": "Prøv at logge ind med en anden konto", "callback": "Prøv at logge ind med en anden konto", "oauthAccountNotLinked": "For at bekræfte din identitet, log ind med samme konto, som du oprindeligt brugte", "emailSignin": "E-mailen kunne ikke sendes", "emailVerify": "Bekræft venligst din e-mail, en ny e-mail er blevet sendt", "credentialsSignin": "Login mislykkedes. Kontroller at de angivne oplysninger er korrekte", "sessionRequired": "Log venligst ind for at få adgang til denne side" } }, "provider": { "continue": "Fortsæt med {{provider}}" } }, "chat": { "input": { "placeholder": "Skriv din besked her...", "actions": { "send": "Send besked", "stop": "Stop opgave", "attachFiles": "Vedhæft filer" } }, "favorites": { "use": "Brug en favorit besked", "headline": "Favorit beskeder", "empty": { "title": "Ingen gemte prompts endnu", "description": "Start med at sende en prompt og markere den med en stjerne, eller vælg en prompt fra tidligere samtaler" } }, "commands": { "button": "Værktøjer", "changeTool": "Skift værktøj", "availableTools": "Tilgængelige værktøjer" }, "speech": { "start": "Start optagelse", "stop": "Stop optagelse", "connecting": "Forbinder" }, "fileUpload": { "dragDrop": "Træk og slip filer her", "browse": "Gennemse filer", "sizeLimit": "Grænse:", "errors": { "failed": "Upload mislykkedes", "cancelled": "Annullerede upload af" }, "actions": { "cancelUpload": "Annullere upload", "removeAttachment": "Fjern vedhæftning" } }, "messages": { "status": { "using": "Bruger", "used": "Brugte" }, "actions": { "copy": { "button": "Kopier til udklipsholder", "success": "Kopieret!" } }, "feedback": { "positive": "Hjælpsom", "negative": "Ikke hjælpsom", "edit": "Rediger feedback", "dialog": { "title": "Tilføj en kommentar", "submit": "Indsend feedback", "yourFeedback": "Din feedback..." }, "status": { "updating": "Opdaterer", "updated": "Feedback opdateret" } } }, "history": { "title": "Seneste input", "empty": "Så tomt...", "show": "Vis historik" }, "settings": { "title": "Indstillingspanel", "customize": "Tilpas dine chatindstillinger her" }, "watermark": "Bygget med" }, "threadHistory": { "sidebar": { "title": "Tidligere samtaler", "filters": { "search": "Søg", "placeholder": "Søg i samtaler..." }, "timeframes": { "today": "I dag", "yesterday": "I går", "previous7days": "Seneste 7 dage", "previous30days": "Seneste 30 dage" }, "empty": "Ingen tråde fundet", "actions": { "close": "Luk sidepanel", "open": "Åbn sidepanel" } }, "thread": { "untitled": "Unavngivet samtale", "menu": { "rename": "Omdøb", "share": "Del", "delete": "Slet" }, "actions": { "share": { "title": "Del link til chat", "button": "Del", "status": { "copied": "Link kopieret", "created": "Delingslink oprettet!", "unshared": "Deling deaktiveret for denne tråd" }, "error": { "create": "Kunne ikke oprette delingslink", "unshare": "Kunne ikke fjerne deling af tråd" } }, "delete": { "title": "Bekræft sletning", "description": "Dette vil slette tråden samt dens beskeder og elementer. Denne handling kan ikke fortrydes", "success": "Chat slettet", "inProgress": "Sletter chat" }, "rename": { "title": "Omdøb tråd", "description": "Indtast et nyt navn til denne tråd", "form": { "name": { "label": "Navn", "placeholder": "Indtast nyt navn" } }, "success": "Tråd omdøbt!", "inProgress": "Omdøber tråd" } } } }, "navigation": { "header": { "chat": "Chat", "readme": "📖", "theme": { "light": "Lyst tema", "dark": "Mørkt tema", "system": "Følg system" } }, "newChat": { "button": "Ny chat", "dialog": { "title": "Opret ny chat", "description": "Dette vil rydde din nuværende chathistorik. Er du sikker på, at du vil fortsætte?", "tooltip": "Ny chat" } }, "user": { "menu": { "settings": "Indstillinger", "settingsKey": "S", "apiKeys": "API-nøgler", "logout": "Log ud" } } }, "apiKeys": { "title": "Påkrævede API-nøgler", "description": "For at bruge denne app kræves følgende API-nøgler. Nøglerne gemmes på din enheds lokale lager.", "success": { "saved": "Gemt succesfuldt" } }, "alerts": { "info": "Info", "note": "Bemærk", "tip": "Tip", "important": "Vigtigt", "warning": "Advarsel", "caution": "Forsigtig", "debug": "Fejlfinding", "example": "Eksempel", "success": "Succes", "help": "Hjælp", "idea": "Idé", "pending": "Afventer", "security": "Sikkerhed", "beta": "Beta", "best-practice": "Bedste praksis" }, "components": { "MultiSelectInput": { "placeholder": "Vælg..." }, "DatePickerInput": { "placeholder": { "single": "Vælg en dato", "range": "Vælg et datointerval" } } } } ================================================ FILE: backend/chainlit/translations/de-DE.json ================================================ { "common": { "actions": { "cancel": "Abbrechen", "confirm": "Bestätigen", "continue": "Fortfahren", "goBack": "Zurück", "reset": "Zurücksetzen", "submit": "Absenden" }, "status": { "loading": "Lädt...", "error": { "default": "Ein Fehler ist aufgetreten", "serverConnection": "Server konnte nicht erreicht werden" } } }, "auth": { "login": { "title": "Melde dich an, um auf die App zuzugreifen", "form": { "email": { "label": "E-Mail Adresse", "required": "E-Mail Adresse ist ein Pflichtfeld", "placeholder": "me@example.com" }, "password": { "label": "Passwort", "required": "Passwort ist ein Pflichtfeld" }, "actions": { "signin": "Anmelden" }, "alternativeText": { "or": "ODER" } }, "errors": { "default": "Anmeldung fehlgeschlagen", "signin": "Versuche dich mit einem anderen Konto anzumelden", "oauthSignin": "Versuche dich mit einem anderen Konto anzumelden", "redirectUriMismatch": "Der Redirect-URI stimmt nicht mit der Konfiguration der Oauth-Anwendung überein", "oauthCallback": "Versuche dich mit einem anderen Konto anzumelden", "oauthCreateAccount": "Versuche dich mit einem anderen Konto anzumelden", "emailCreateAccount": "Versuche dich mit einem anderen Konto anzumelden", "callback": "Versuche dich mit einem anderen Konto anzumelden", "oauthAccountNotLinked": "Um die Identität zu bestätigen, melde dich mit demselben Konto an, das du ursprünglich verwendet hast", "emailSignin": "Die E-Mail konnte nicht gesendet werden", "emailVerify": "Es wurde eine neue E-Mail versandt. Bitte überprüfe dein E-Mail Postfach", "credentialsSignin": "Anmeldung fehlgeschlagen. Überprüfe, ob die angegebenen Benutzerdaten korrekt sind", "sessionRequired": "Bitte melde dich an, um auf diese Seite zuzugreifen" } }, "provider": { "continue": "Fortfahren mit {{provider}}" } }, "chat": { "input": { "placeholder": "Nachricht eingeben...", "actions": { "send": "Nachricht senden", "stop": "Aufgabe stoppen", "attachFiles": "Dateien anhängen" } }, "favorites": { "use": "Eine favorisierte Nachricht verwenden", "headline": "Favorisierte Nachrichten", "remove": "Favorit entfernen", "empty": { "title": "Noch keine Prompts gespeichert", "description": "Beginne, indem du einen Prompt sendest und mit einem Stern markierst oder markiere einen Prompt aus vorherigen Chats" } }, "commands": { "button": "Tools", "changeTool": "Tool wechseln", "availableTools": "Verfügbare Tools" }, "speech": { "start": "Aufnahme starten", "stop": "Aufnahme stoppen", "connecting": "Verbinde" }, "fileUpload": { "dragDrop": "Ziehe deine Dateien hierher", "browse": "Dateien durchsuchen", "sizeLimit": "Limit:", "errors": { "failed": "Hochladen fehlgeschlagen", "cancelled": "Abbruch des hochladens von" }, "actions": { "cancelUpload": "Upload abbrechen", "removeAttachment": "Anhang entfernen" } }, "messages": { "status": { "using": "Verwendet", "used": "Verwendete" }, "actions": { "copy": { "button": "In Zwischenablage kopieren", "success": "Kopiert!" } }, "feedback": { "positive": "Hilfreich", "negative": "Nicht hilfreich", "edit": "Feedback editieren", "dialog": { "title": "Füge einen Kommentar hinzu", "submit": "Feedback absenden", "yourFeedback": "Dein Feedback..." }, "status": { "updating": "Aktualisiert", "updated": "Feedback aktualisiert" } } }, "history": { "title": "Vergangene Eingaben", "empty": "Leer...", "show": "Historie anzeigen" }, "settings": { "title": "Einstellungen", "customize": "Passe die Chat Einstellungen hier an" }, "watermark": "LLMs können Fehler machen. Überprüfe bitte stets die Inhalte." }, "threadHistory": { "sidebar": { "title": "Vergangene Chats", "filters": { "search": "Suche", "placeholder": "Suche konversationen..." }, "timeframes": { "today": "Heute", "yesterday": "Gestern", "previous7days": "Vor 7 Tagen", "previous30days": "Vor 30 Tagen" }, "empty": "Kein Chat gefunden", "actions": { "close": "Seitenleiste schließen", "open": "Seitenleiste öffnen" } }, "thread": { "untitled": "Unbenannter Thread", "menu": { "rename": "Umbenennen", "share": "Teilen", "delete": "Löschen" }, "actions": { "share": { "title": "Thread löschen bestätigen", "button": "Teilen", "status": { "copied": "Link kopiert", "created": "Freigabelink erstellt!", "unshared": "Teilen ist für diesen Thread deaktiviert" }, "error": { "create": "Fehler beim Erstellen des Freigabelinks", "unshare": "Freigabe des Threads konnte nicht aufgehoben werden" } }, "delete": { "title": "Löschen bestätigen", "description": "Dies wird den Thread sowie seine Nachrichten und Elemente löschen. Dies kann nicht rückgängig gemacht werden", "success": "Chat gelöscht", "inProgress": "Chat wird gelöscht" }, "rename": { "title": "Thread umbenennen", "description": "Gebe einen neuen Namen für den Thread ein", "form": { "name": { "label": "Name", "placeholder": "Neuen Namen eingeben" } }, "success": "Thread umbenannt!", "inProgress": "Thread wird umbenannt" } } } }, "navigation": { "header": { "chat": "Chat", "readme": "Anleitung", "theme": { "light": "Helles Design", "dark": "Dunkles Design", "system": "System Design" } }, "newChat": { "button": "Neuer Chat", "dialog": { "title": "Möchtest du einen neuen Chat erstellen?", "description": "Es werden die aktuellen Nachrichten gelöscht und ein neuer Chat geöffnet.", "tooltip": "Neuer Chat" } }, "user": { "menu": { "settings": "Einstellungen", "settingsKey": "S", "apiKeys": "API Schlüssel", "logout": "Abmelden" } } }, "apiKeys": { "title": "Benötigte API Schlüssel", "description": "Um diese App zu nutzen, werden die folgenden API Schlüssel benötigt. Die Schlüssel werden im lokalen Speicher Ihres Geräts gespeichert.", "success": { "saved": "Erfolgreich gespeichert" } }, "alerts": { "info": "Info", "note": "Hinweis", "tip": "Tipp", "important": "Wichtig", "warning": "Warnung", "caution": "Vorsicht", "debug": "Debug", "example": "Beispiel", "success": "Erfolg", "help": "Hilfe", "idea": "Idee", "pending": "Ausstehend", "security": "Sicherheit", "beta": "Beta", "best-practice": "Bewährte Praxis" }, "components": { "MultiSelectInput": { "placeholder": "Wähle aus..." } } } ================================================ FILE: backend/chainlit/translations/el-GR.json ================================================ { "common": { "actions": { "cancel": "Άκυρο", "confirm": "Επιβεβαίωση", "continue": "Συνέχεια", "goBack": "Επιστροφή", "reset": "Επαναφορά", "submit": "Υποβολή" }, "status": { "loading": "Φόρτωση...", "error": { "default": "Παρουσιάστηκε σφάλμα", "serverConnection": "Δεν ήταν δυνατή η επικοινωνία με τον διακομιστή" } } }, "auth": { "login": { "title": "Συνδεθείτε για να αποκτήσετε πρόσβαση στην εφαρμογή", "form": { "email": { "label": "Διεύθυνση ηλεκτρονικού ταχυδρομείου", "required": "Το email είναι υποχρεωτικό πεδίο", "placeholder": "me@example.com" }, "password": { "label": "Κωδικός πρόσβασης", "required": "Ο κωδικός πρόσβασης είναι υποχρεωτικό πεδίο" }, "actions": { "signin": "Σύνδεση" }, "alternativeText": { "or": "ή" } }, "errors": { "default": "Δεν είναι δυνατή η σύνδεση", "signin": "Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό", "oauthSignin": "Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό", "redirectUriMismatch": "Ο σύνδεσμος ανακατεύθυνσης δεν ταιριάζει με τη ρύθμιση της αυθεντικοποιήσης της εφαρμογής", "oauthCallback": "Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό", "oauthCreateAccount": "Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό", "emailCreateAccount": "Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό", "callback": "Δοκιμάστε να συνδεθείτε με διαφορετικό λογαριασμό", "oauthAccountNotLinked": "Για να επιβεβαιώσετε την ταυτότητά σας, συνδεθείτε με τον ίδιο λογαριασμό που χρησιμοποιήσατε αρχικά", "emailSignin": "Δεν ήταν δυνατή η αποστολή του email", "emailVerify": "Παρακαλώ επαληθεύστε την διεύθυνση ηλεκτρονικού ταχυδρομείου σας, ένα νέο email σας έχει σταλεί", "credentialsSignin": "Η σύνδεση απέτυχε. Ελέγξτε ότι τα στοιχεία που δώσατε είναι σωστά", "sessionRequired": "Παρακαλώ συνδεθείτε για να αποκτήσετε πρόσβαση σε αυτήν τη σελίδα" } }, "provider": { "continue": "Συνέχεια με {{provider}}" } }, "chat": { "input": { "placeholder": "Πληκτρολογήστε το μήνυμά σας εδώ...", "actions": { "send": "Αποστολή μηνύματος", "stop": "Διακοπή εργασίας", "attachFiles": "Επισύναψη αρχείων" } }, "favorites": { "use": "Χρησιμοποιήστε ένα αγαπημένο μήνυμα", "headline": "Αγαπημένα μηνύματα", "remove": "Αφαίρεση αγαπημένου", "empty": { "title": "Δεν υπάρχουν αποθηκευμένες προτροπές ακόμα", "description": "Ξεκινήστε στέλνοντας μια προτροπή και προσθέστε την στα αγαπημένα ή προσθέστε μια προτροπή από προηγούμενες συνομιλίες" } }, "commands": { "button": "Εργαλεία", "changeTool": "Αλλαγή Εργαλείου", "availableTools": "Διαθέσιμα Εργαλεία" }, "speech": { "start": "Έναρξη εγγραφής", "stop": "Διακοπή εγγραφής", "connecting": "Σύνδεση" }, "fileUpload": { "dragDrop": "Σύρετε αρχεία εδώ", "browse": "Αναζήτηση αρχείων", "sizeLimit": "Όριο:", "errors": { "failed": "Η μεταφόρτωση απέτυχε", "cancelled": "Ακυρώθηκε η μεταφόρτωση του" }, "actions": { "cancelUpload": "Ακύρωση μεταφόρτωσης", "removeAttachment": "Αφαίρεση επισύναψης" } }, "messages": { "status": { "using": "Με τη χρήση", "used": "Χρησιμοποιήθηκε" }, "actions": { "copy": { "button": "Αντιγραφή στο πρόχειρο", "success": "Αντιγράφηκε!" } }, "feedback": { "positive": "Χρήσιμος", "negative": "Μη χρήσιμος", "edit": "Επεξεργασία σχολίων", "dialog": { "title": "Προσθήκη σχολίου", "submit": "Υποβολή σχολίων", "yourFeedback": "Η γνώμη σας" }, "status": { "updating": "Ενημερώνεται", "updated": "Τα σχόλια ενημερώθηκαν" } } }, "history": { "title": "Τελευταίες εισαγωγές", "empty": "Τόσο άδειο...", "show": "Προβολή ιστορικού" }, "settings": { "title": "Πίνακας ρυθμίσεων", "customize": "Προσαρμογή" }, "watermark": "Τα ΜΓΜ μπορεί να κάνουν λάθη. Ελέγξτε σημαντικές πληροφορίες." }, "threadHistory": { "sidebar": { "title": "Παλαιότερες συνομιλίες", "filters": { "search": "Αναζήτηση", "placeholder": "Αναζήτηση συνομιλιών..." }, "timeframes": { "today": "Σήμερα", "yesterday": "Χθες", "previous7days": "Προηγούμενες 7 ημέρες", "previous30days": "Προηγούμενες 30 ημέρες" }, "empty": "Δεν βρέθηκαν νήματα", "actions": { "close": "Κλείσιμο πλαϊνής γραμμής", "open": "Άνοιγμα πλαϊνής γραμμής" } }, "thread": { "untitled": "Συνομιλία χωρίς τίτλο", "menu": { "rename": "Μετονομασία", "share": "Κοινοποίηση", "delete": "Διαγραφή" }, "actions": { "share": { "title": "Κοινοποίηση συνδέσμου συνομιλίας", "button": "Κοινοποίηση", "status": { "copied": "Ο σύνδεσμος αντιγράφηκε", "created": "Ο σύνδεσμος κοινοποίησης δημιουργήθηκε!", "unshared": "Η κοινοποίηση απενεργοποιήθηκε για αυτό το νήμα" }, "error": { "create": "Αποτυχία δημιουργίας συνδέσμου κοινοποίησης", "unshare": "Αποτυχία διακοπής κοινοποίησης νήματος" } }, "delete": { "title": "Επιβεβαίωση διαγραφής", "description": "Αυτό θα διαγράψει το νήμα καθώς και τα μηνύματα και τα στοιχεία του. Αυτή η ενέργεια δεν μπορεί να αναιρεθεί.", "success": "Η συνομιλία διαγράφηκε", "inProgress": "Διαγραφή συνομιλίας" }, "rename": { "title": "Μετονομασία Νήματος", "description": "Εισαγάγετε ένα νέο όνομα για αυτό το νήμα", "form": { "name": { "label": "Όνομα", "placeholder": "Εισαγάγετε νέο όνομα" } }, "success": "Το νήμα μετονομάστηκε!", "inProgress": "Μετονομασία Νήματος" } } } }, "navigation": { "header": { "chat": "Συνομιλία", "readme": "Διάβασέ με", "theme": { "light": "Φωτεινό Θέμα", "dark": "Σκοτεινό θέμα", "system": "Ακολουθήστε το σύστημα" } }, "newChat": { "button": "Νέα Συνομιλία", "dialog": { "title": "Δημιουργία Νέας Συνομιλίας", "description": "Αυτό θα διαγράψει το τρέχον ιστορικό συνομιλίας σας. Είστε βέβαιοι ότι θέλετε να συνεχίσετε;", "tooltip": "Νέα Συνομιλία" } }, "user": { "menu": { "settings": "Ρυθμίσεις", "settingsKey": "S", "apiKeys": "Κλειδιά API", "logout": "Αποσύνδεση" } } }, "apiKeys": { "title": "Απαιτούμενα κλειδιά API", "description": "Για να χρησιμοποιήσετε αυτήν την εφαρμογή, απαιτούνται τα ακόλουθα κλειδιά API. Τα κλειδιά είναι αποθηκευμένα στον τοπικό χώρο αποθήκευσης της συσκευής σας.", "success": { "saved": "Αποθηκεύτηκε με επιτυχία" } }, "alerts": { "info": "Πληροφορίες", "note": "Σημείωση", "tip": "Συμβουλή", "important": "Σημαντικό", "warning": "Προειδοποίηση", "caution": "Προσοχή", "debug": "Εντοπισμός σφαλμάτων", "example": "Παράδειγμα", "success": "Επιτυχία", "help": "Βοήθεια", "idea": "Ιδέα", "pending": "Σε εκκρεμότητα", "security": "Ασφάλεια", "beta": "Beta", "best-practice": "Βέλτιστη Πρακτική" }, "components": { "MultiSelectInput": { "placeholder": "Επιλέξτε..." }, "DatePickerInput": { "placeholder": { "single": "Επιλέξτε ημερομηνία", "range": "Επιλέξτε εύρος ημερομηνιών" } } } } ================================================ FILE: backend/chainlit/translations/en-US.json ================================================ { "common": { "actions": { "cancel": "Cancel", "confirm": "Confirm", "continue": "Continue", "goBack": "Go Back", "reset": "Reset", "submit": "Submit" }, "status": { "loading": "Loading...", "error": { "default": "An error occurred", "serverConnection": "Could not reach the server" } } }, "auth": { "login": { "title": "Login to access the app", "form": { "email": { "label": "Email address", "required": "email is a required field", "placeholder": "me@example.com" }, "password": { "label": "Password", "required": "password is a required field" }, "actions": { "signin": "Sign In" }, "alternativeText": { "or": "OR" } }, "errors": { "default": "Unable to sign in", "signin": "Try signing in with a different account", "oauthSignin": "Try signing in with a different account", "redirectUriMismatch": "The redirect URI is not matching the oauth app configuration", "oauthCallback": "Try signing in with a different account", "oauthCreateAccount": "Try signing in with a different account", "emailCreateAccount": "Try signing in with a different account", "callback": "Try signing in with a different account", "oauthAccountNotLinked": "To confirm your identity, sign in with the same account you used originally", "emailSignin": "The e-mail could not be sent", "emailVerify": "Please verify your email, a new email has been sent", "credentialsSignin": "Sign in failed. Check the details you provided are correct", "sessionRequired": "Please sign in to access this page" } }, "provider": { "continue": "Continue with {{provider}}" } }, "chat": { "input": { "placeholder": "Type your message here...", "actions": { "send": "Send message", "stop": "Stop Task", "attachFiles": "Attach files" } }, "favorites": { "use": "Use a favorite message", "headline": "Favorite Messages", "remove": "Remove favorite", "empty": { "title": "No Saved Prompts Yet", "description": "Start by sending a prompt and star it or star a prompt from previous chats" } }, "commands": { "button": "Tools", "changeTool": "Change Tool", "availableTools": "Available Tools" }, "speech": { "start": "Start recording", "stop": "Stop recording", "connecting": "Connecting" }, "fileUpload": { "dragDrop": "Drag and drop files here", "browse": "Browse Files", "sizeLimit": "Limit:", "errors": { "failed": "Failed to upload", "cancelled": "Cancelled upload of" }, "actions": { "cancelUpload": "Cancel upload", "removeAttachment": "Remove attachment" } }, "messages": { "status": { "using": "Using", "used": "Used" }, "actions": { "copy": { "button": "Copy to clipboard", "success": "Copied!" } }, "feedback": { "positive": "Helpful", "negative": "Not helpful", "edit": "Edit feedback", "dialog": { "title": "Add a comment", "submit": "Submit feedback", "yourFeedback": "Your feedback..." }, "status": { "updating": "Updating", "updated": "Feedback updated" } } }, "history": { "title": "Last Inputs", "empty": "Such empty...", "show": "Show history" }, "settings": { "title": "Settings panel", "customize": "Customize your chat settings here" }, "watermark": "LLMs can make mistakes. Check important info." }, "threadHistory": { "sidebar": { "title": "Past Chats", "filters": { "search": "Search", "placeholder": "Search conversations..." }, "timeframes": { "today": "Today", "yesterday": "Yesterday", "previous7days": "Previous 7 days", "previous30days": "Previous 30 days" }, "empty": "No threads found", "actions": { "close": "Close sidebar", "open": "Open sidebar" } }, "thread": { "untitled": "Untitled Conversation", "menu": { "rename": "Rename", "share": "Share", "delete": "Delete" }, "actions": { "share": { "title": "Share link to chat", "button": "Share", "status": { "copied": "Link copied", "created": "Share link created!", "unshared": "Sharing disabled for this thread" }, "error": { "create": "Failed to create share link", "unshare": "Failed to unshare thread" } }, "delete": { "title": "Confirm deletion", "description": "This will delete the thread as well as its messages and elements. This action cannot be undone", "success": "Chat deleted", "inProgress": "Deleting chat" }, "rename": { "title": "Rename Thread", "description": "Enter a new name for this thread", "form": { "name": { "label": "Name", "placeholder": "Enter new name" } }, "success": "Thread renamed!", "inProgress": "Renaming thread" } } } }, "navigation": { "header": { "chat": "Chat", "readme": "Readme", "theme": { "light": "Light Theme", "dark": "Dark Theme", "system": "Follow System" } }, "newChat": { "button": "New Chat", "dialog": { "title": "Create New Chat", "description": "This will clear your current chat history. Are you sure you want to continue?", "tooltip": "New Chat" } }, "user": { "menu": { "settings": "Settings", "settingsKey": "S", "apiKeys": "API Keys", "logout": "Logout" } } }, "apiKeys": { "title": "Required API Keys", "description": "To use this app, the following API keys are required. The keys are stored on your device's local storage.", "success": { "saved": "Saved successfully" } }, "alerts": { "info": "Info", "note": "Note", "tip": "Tip", "important": "Important", "warning": "Warning", "caution": "Caution", "debug": "Debug", "example": "Example", "success": "Success", "help": "Help", "idea": "Idea", "pending": "Pending", "security": "Security", "beta": "Beta", "best-practice": "Best Practice" }, "components": { "MultiSelectInput": { "placeholder": "Select..." }, "DatePickerInput": { "placeholder": { "single": "Pick a date", "range": "Pick a date range" } } } } ================================================ FILE: backend/chainlit/translations/es.json ================================================ { "common": { "actions": { "cancel": "Cancelar", "confirm": "Confirmar", "continue": "Continuar", "goBack": "Volver", "reset": "Restablecer", "submit": "Enviar" }, "status": { "loading": "Cargando...", "error": { "default": "Ocurrió un error", "serverConnection": "No se pudo conectar con el servidor" } } }, "auth": { "login": { "title": "Inicia sesión para acceder a la aplicación", "form": { "email": { "label": "Correo electrónico", "required": "el correo electrónico es obligatorio", "placeholder": "me@example.com" }, "password": { "label": "Contraseña", "required": "la contraseña es obligatoria" }, "actions": { "signin": "Iniciar sesión" }, "alternativeText": { "or": "O" } }, "errors": { "default": "No se pudo iniciar sesión", "signin": "Intenta iniciar sesión con otra cuenta", "oauthSignin": "Intenta iniciar sesión con otra cuenta", "redirectUriMismatch": "El URI de redirección no coincide con la configuración de la aplicación OAuth", "oauthCallback": "Intenta iniciar sesión con otra cuenta", "oauthCreateAccount": "Intenta iniciar sesión con otra cuenta", "emailCreateAccount": "Intenta iniciar sesión con otra cuenta", "callback": "Intenta iniciar sesión con otra cuenta", "oauthAccountNotLinked": "Para confirmar tu identidad, inicia sesión con la misma cuenta que usaste originalmente", "emailSignin": "No se pudo enviar el correo electrónico", "emailVerify": "Por favor verifica tu correo, se ha enviado un nuevo correo", "credentialsSignin": "Error al iniciar sesión. Verifica que los datos proporcionados sean correctos", "sessionRequired": "Por favor inicia sesión para acceder a esta página" } }, "provider": { "continue": "Continuar con {{provider}}" } }, "chat": { "input": { "placeholder": "Escribe tu mensaje aquí...", "actions": { "send": "Enviar mensaje", "stop": "Detener tarea", "attachFiles": "Adjuntar archivos" } }, "favorites": { "use": "Usar un mensaje favorito", "headline": "Mensajes favoritos", "remove": "Eliminar favorito", "empty": { "title": "Aún no hay prompts guardados", "description": "Comienza enviando un prompt y márcalo con estrella o marca un prompt de chats anteriores" } }, "commands": { "button": "Herramientas", "changeTool": "Cambiar herramienta", "availableTools": "Herramientas disponibles" }, "speech": { "start": "Comenzar grabación", "stop": "Detener grabación", "connecting": "Conectando" }, "fileUpload": { "dragDrop": "Arrastra y suelta archivos aquí", "browse": "Buscar archivos", "sizeLimit": "Límite:", "errors": { "failed": "Error al subir", "cancelled": "Carga cancelada de" }, "actions": { "cancelUpload": "Cancelar subida", "removeAttachment": "Eliminar adjunto" } }, "messages": { "status": { "using": "Usando", "used": "Usado" }, "actions": { "copy": { "button": "Copiar al portapapeles", "success": "¡Copiado!" } }, "feedback": { "positive": "Útil", "negative": "No útil", "edit": "Editar comentario", "dialog": { "title": "Agregar un comentario", "submit": "Enviar comentario", "yourFeedback": "Tu comentario..." }, "status": { "updating": "Actualizando", "updated": "Comentario actualizado" } } }, "history": { "title": "Últimas entradas", "empty": "Tan vacío...", "show": "Mostrar historial" }, "settings": { "title": "Panel de configuración", "customize": "Personaliza la configuración de tu chat aquí" }, "watermark": "Los LLM pueden cometer errores. Verifica la información importante." }, "threadHistory": { "sidebar": { "title": "Chats anteriores", "filters": { "search": "Buscar", "placeholder": "Buscar conversaciones..." }, "timeframes": { "today": "Hoy", "yesterday": "Ayer", "previous7days": "Últimos 7 días", "previous30days": "Últimos 30 días" }, "empty": "No se encontraron conversaciones", "actions": { "close": "Cerrar barra lateral", "open": "Abrir barra lateral" } }, "thread": { "untitled": "Conversación sin título", "menu": { "rename": "Renombrar", "share": "Compartir", "delete": "Eliminar" }, "actions": { "share": { "title": "Compartir enlace del chat", "button": "Compartir", "status": { "copied": "Enlace copiado", "created": "¡Enlace de uso compartido creado!", "unshared": "Uso compartido deshabilitado para este hilo" }, "error": { "create": "Error al crear el enlace de uso compartido", "unshare": "Error al dejar de compartir el hilo" } }, "delete": { "title": "Confirmar eliminación", "description": "Esto eliminará la conversación, sus mensajes y elementos. Esta acción no se puede deshacer", "success": "Chat eliminado", "inProgress": "Eliminando chat" }, "rename": { "title": "Renombrar conversación", "description": "Ingresa un nuevo nombre para esta conversación", "form": { "name": { "label": "Nombre", "placeholder": "Ingresa nuevo nombre" } }, "success": "¡Conversación renombrada!", "inProgress": "Renombrando conversación" } } } }, "navigation": { "header": { "chat": "Chat", "readme": "Léeme", "theme": { "light": "Tema claro", "dark": "Tema oscuro", "system": "Seguir sistema" } }, "newChat": { "button": "Nuevo chat", "dialog": { "title": "Crear nuevo chat", "description": "Esto borrará tu historial de chat actual. ¿Seguro que quieres continuar?", "tooltip": "Nuevo chat" } }, "user": { "menu": { "settings": "Configuración", "settingsKey": "S", "apiKeys": "Claves API", "logout": "Cerrar sesión" } } }, "apiKeys": { "title": "Claves API requeridas", "description": "Para usar esta aplicación, se requieren las siguientes claves API. Las claves se almacenan en el almacenamiento local de tu dispositivo.", "success": { "saved": "Guardado exitosamente" } }, "alerts": { "info": "Información", "note": "Nota", "tip": "Consejo", "important": "Importante", "warning": "Advertencia", "caution": "Precaución", "debug": "Depuración", "example": "Ejemplo", "success": "Éxito", "help": "Ayuda", "idea": "Idea", "pending": "Pendiente", "security": "Seguridad", "beta": "Beta", "best-practice": "Mejor práctica" }, "components": { "MultiSelectInput": { "placeholder": "Seleccionar..." }, "DatePickerInput": { "placeholder": { "single": "Elige una fecha", "range": "Elige un rango de fechas" } } } } ================================================ FILE: backend/chainlit/translations/fr-FR.json ================================================ { "common": { "actions": { "cancel": "Annuler", "confirm": "Confirmer", "continue": "Continuer", "goBack": "Retour", "reset": "Réinitialiser", "submit": "Envoyer" }, "status": { "loading": "Chargement...", "error": { "default": "Une erreur est survenue", "serverConnection": "Impossible de joindre le serveur" } } }, "auth": { "login": { "title": "Connectez-vous pour accéder à l'application", "form": { "email": { "label": "Adresse e-mail", "required": "l'e-mail est un champ obligatoire", "placeholder": "me@example.com" }, "password": { "label": "Mot de passe", "required": "le mot de passe est un champ obligatoire" }, "actions": { "signin": "Se connecter" }, "alternativeText": { "or": "OU" } }, "errors": { "default": "Impossible de se connecter", "signin": "Essayez de vous connecter avec un autre compte", "oauthSignin": "Essayez de vous connecter avec un autre compte", "redirectUriMismatch": "L'URI de redirection ne correspond pas à la configuration de l'application oauth", "oauthCallback": "Essayez de vous connecter avec un autre compte", "oauthCreateAccount": "Essayez de vous connecter avec un autre compte", "emailCreateAccount": "Essayez de vous connecter avec un autre compte", "callback": "Essayez de vous connecter avec un autre compte", "oauthAccountNotLinked": "Pour confirmer votre identité, connectez-vous avec le même compte que vous avez utilisé à l'origine", "emailSignin": "L'e-mail n'a pas pu être envoyé", "emailVerify": "Veuillez vérifier votre e-mail, un nouvel e-mail a été envoyé", "credentialsSignin": "La connexion a échoué. Vérifiez que les informations que vous avez fournies sont correctes", "sessionRequired": "Veuillez vous connecter pour accéder à cette page" } }, "provider": { "continue": "Continuer avec {{provider}}" } }, "chat": { "input": { "placeholder": "Tapez votre message ici...", "actions": { "send": "Envoyer le message", "stop": "Arrêter la tâche", "attachFiles": "Joindre des fichiers" } }, "favorites": { "use": "Utiliser un message favori", "headline": "Messages favoris", "remove": "Supprimer des favoris", "empty": { "title": "Aucun prompt enregistré pour le moment", "description": "Commencez par envoyer un prompt et ajoutez-le aux favoris ou ajoutez un prompt de discussions précédentes aux favoris" } }, "commands": { "button": "Outils", "changeTool": "Changer d'outil", "availableTools": "Outils disponibles" }, "speech": { "start": "Démarrer l'enregistrement", "stop": "Arrêter l'enregistrement", "connecting": "Connexion en cours" }, "fileUpload": { "dragDrop": "Glissez et déposez des fichiers ici", "browse": "Parcourir les fichiers", "sizeLimit": "Limite :", "errors": { "failed": "Échec du téléversement", "cancelled": "Téléversement annulé de" }, "actions": { "cancelUpload": "Annuler le téléversement", "removeAttachment": "Supprimer la pièce jointe" } }, "messages": { "status": { "using": "Utilise", "used": "Utilisé" }, "actions": { "copy": { "button": "Copier dans le presse-papiers", "success": "Copié !" } }, "feedback": { "positive": "Utile", "negative": "Pas utile", "edit": "Modifier le commentaire", "dialog": { "title": "Ajouter un commentaire", "submit": "Envoyer le commentaire", "yourFeedback": "Votre avis..." }, "status": { "updating": "Mise à jour", "updated": "Commentaire mis à jour" } } }, "history": { "title": "Dernières entrées", "empty": "Tellement vide...", "show": "Afficher l'historique" }, "settings": { "title": "Panneau des paramètres", "customize": "Personnalisez vos paramètres de chat ici" }, "watermark": "Les LLMs peuvent se tromper. Vérifiez les réponses." }, "threadHistory": { "sidebar": { "title": "Discussions passées", "filters": { "search": "Rechercher", "placeholder": "Rechercher des conversations..." }, "timeframes": { "today": "Aujourd'hui", "yesterday": "Hier", "previous7days": "Les 7 derniers jours", "previous30days": "Les 30 derniers jours" }, "empty": "Aucun fil de discussion trouvé", "actions": { "close": "Fermer la barre latérale", "open": "Ouvrir la barre latérale" } }, "thread": { "untitled": "Conversation sans titre", "menu": { "rename": "Renommer", "share": "Partager", "delete": "Supprimer" }, "actions": { "share": { "title": "Partager le lien de la discussion", "button": "Partager", "status": { "copied": "Lien copié", "created": "Lien de partage créé !", "unshared": "Partage désactivé pour ce fil" }, "error": { "create": "Échec de la création du lien de partage", "unshare": "Échec de la désactivation du partage du fil" } }, "delete": { "title": "Confirmer la suppression", "description": "Cela supprimera le fil de discussion ainsi que ses messages et éléments. Cette action ne peut pas être annulée", "success": "Discussion supprimée", "inProgress": "Suppression de la discussion" }, "rename": { "title": "Renommer le fil de discussion", "description": "Entrez un nouveau nom pour ce fil de discussion", "form": { "name": { "label": "Nom", "placeholder": "Entrez le nouveau nom" } }, "success": "Fil de discussion renommé !", "inProgress": "Renommage du fil de discussion" } } } }, "navigation": { "header": { "chat": "Discussion", "readme": "Lisez-moi", "theme": { "light": "Thème clair", "dark": "Thème sombre", "system": "Suivre le système" } }, "newChat": { "button": "Nouvelle discussion", "dialog": { "title": "Créer une nouvelle discussion", "description": "Cela effacera votre historique de discussion actuel. Êtes-vous sûr de vouloir continuer ?", "tooltip": "Nouvelle discussion" } }, "user": { "menu": { "settings": "Paramètres", "settingsKey": "S", "apiKeys": "Clés API", "logout": "Se déconnecter" } } }, "apiKeys": { "title": "Clés API requises", "description": "Pour utiliser cette application, les clés API suivantes sont requises. Les clés sont stockées dans le stockage local de votre appareil.", "success": { "saved": "Enregistré avec succès" } }, "alerts": { "info": "Info", "note": "Note", "tip": "Astuce", "important": "Important", "warning": "Avertissement", "caution": "Attention", "debug": "Débogage", "example": "Exemple", "success": "Succès", "help": "Aide", "idea": "Idée", "pending": "En attente", "security": "Sécurité", "beta": "Bêta", "best-practice": "Meilleure pratique" }, "components": { "MultiSelectInput": { "placeholder": "Sélectionner..." }, "DatePickerInput": { "placeholder": { "single": "Choisir une date", "range": "Choisir une plage de dates" } } } } ================================================ FILE: backend/chainlit/translations/gu.json ================================================ { "common": { "actions": { "cancel": "રદ કરો", "confirm": "પુષ્ટિ કરો", "continue": "ચાલુ રાખો", "goBack": "પાછા જાઓ", "reset": "રીસેટ કરો", "submit": "સબમિટ કરો" }, "status": { "loading": "લોડ થઈ રહ્યું છે...", "error": { "default": "એક ભૂલ થઈ", "serverConnection": "સર્વર સુધી પહોંચી શકાયું નથી" } } }, "auth": { "login": { "title": "એપ્લિકેશન ઍક્સેસ કરવા માટે લૉગિન કરો", "form": { "email": { "label": "ઈમેલ એડ્રેસ", "required": "ઈમેલ આવશ્યક છે", "placeholder": "me@example.com" }, "password": { "label": "પાસવર્ડ", "required": "પાસવર્ડ આવશ્યક છે" }, "actions": { "signin": "સાઇન ઇન કરો" }, "alternativeText": { "or": "અથવા" } }, "errors": { "default": "સાઇન ઇન કરી શકાયું નથી", "signin": "અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો", "oauthSignin": "અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો", "redirectUriMismatch": "રીડાયરેક્ટ URI oauth ઍપ કન્ફિગરેશન સાથે મેળ ખાતો નથી", "oauthCallback": "અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો", "oauthCreateAccount": "અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો", "emailCreateAccount": "અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો", "callback": "અલગ એકાઉન્ટથી સાઇન ઇન કરવાનો પ્રયાસ કરો", "oauthAccountNotLinked": "તમારી ઓળખની પુષ્ટિ કરવા માટે, મૂળ રૂપે વાપરેલા એકાઉન્ટથી સાઇન ઇન કરો", "emailSignin": "ઈમેલ મોકલી શકાયો નથી", "emailVerify": "કૃપા કરી તમારો ઈમેલ ચકાસો, નવો ઈમેલ મોકલવામાં આવ્યો છે", "credentialsSignin": "સાઇન ઇન નિષ્ફળ. આપેલી વિગતો સાચી છે કે નહીં તે ચકાસો", "sessionRequired": "આ પેજને ઍક્સેસ કરવા માટે કૃપા કરી સાઇન ઇન કરો" } }, "provider": { "continue": "{{provider}} સાથે ચાલુ રાખો" } }, "chat": { "input": { "placeholder": "અહીં તમારો સંદેશ લખો...", "actions": { "send": "સંદેશ મોકલો", "stop": "કાર્ય રોકો", "attachFiles": "ફાઇલ્સ જોડો" } }, "speech": { "start": "રેકોર્ડિંગ શરૂ કરો", "stop": "રેકોર્ડિંગ બંધ કરો", "connecting": "કનેક્ટ થઈ રહ્યું છે" }, "favorites": { "use": "મનપસંદ સંદેશનો ઉપયોગ કરો", "headline": "મનપસંદ સંદેશાઓ", "remove": "મનપસંદ સંદેશ દૂર કરો", "empty": { "title": "હજી સુધી કોઈ પ્રોમ્પ્ટ સાચવેલ નથી", "description": "એક પ્રોમ્પ્ટ મોકલીને અને તેને સ્ટાર કરીને શરૂઆત કરો અથવા અગાઉની ચેટમાંથી કોઈ પ્રોમ્પ્ટને સ્ટાર કરો" } }, "commands": { "button": "ટૂલ્સ", "changeTool": "ટૂલ બદલો", "availableTools": "ઉપલબ્ધ ટૂલ્સ" }, "fileUpload": { "dragDrop": "અહીં ફાઇલ્સ ખેંચો અને છોડો", "browse": "ફાઇલ્સ બ્રાઉઝ કરો", "sizeLimit": "મર્યાદા:", "errors": { "failed": "અપલોડ કરવામાં નિષ્ફળ", "cancelled": "અપલોડ રદ કર્યું" }, "actions": { "cancelUpload": "અપલોડ રદ કરો", "removeAttachment": "જોડાણ દૂર કરો" } }, "messages": { "status": { "using": "વાપરી રહ્યા છે", "used": "વપરાયેલ" }, "actions": { "copy": { "button": "ક્લિપબોર્ડ પર કૉપિ કરો", "success": "કૉપિ થયું!" } }, "feedback": { "positive": "ઉપયોગી", "negative": "બિનઉપયોગી", "edit": "પ્રતિસાદ સંપાદિત કરો", "dialog": { "title": "ટિપ્પણી ઉમેરો", "submit": "પ્રતિસાદ સબમિટ કરો", "yourFeedback": "તમારો પ્રતિસાદ..." }, "status": { "updating": "અપડેટ થઈ રહ્યું છે", "updated": "પ્રતિસાદ અપડેટ થયો" } } }, "history": { "title": "છેલ્લા ઇનપુટ્સ", "empty": "ખાલી છે...", "show": "ઇતિહાસ બતાવો" }, "settings": { "title": "સેટિંગ્સ પેનલ", "customize": "તમારા ચેટ સેટિંગ્સ અહીં કસ્ટમાઇઝ કરો" }, "watermark": "LLM ભૂલો કરી શકે છે. મહત્વપૂર્ણ માહિતી તપાસવાનું વિચારો." }, "threadHistory": { "sidebar": { "title": "પાછલી ચેટ્સ", "filters": { "search": "શોધો", "placeholder": "Search conversations..." }, "timeframes": { "today": "આજે", "yesterday": "ગઈકાલે", "previous7days": "પાછલા 7 દિવસ", "previous30days": "પાછલા 30 દિવસ" }, "empty": "કોઈ થ્રેડ્સ મળ્યા નથી", "actions": { "close": "સાઇડબાર બંધ કરો", "open": "સાઇડબાર ખોલો" } }, "thread": { "untitled": "શીર્ષક વગરની વાતચીત", "menu": { "rename": "નામ બદલો", "share": "શેર કરો", "delete": "Delete" }, "actions": { "share": { "title": "ચેટની લિંક શેર કરો", "button": "શેર કરો", "status": { "copied": "લિંક કૉપિ થઈ", "created": "શેર લિંક બનાવાઈ!", "unshared": "આ થ્રેડ માટે શેરિંગ નિષ્ક્રિય છે" }, "error": { "create": "શેર લિંક બનાવવામાં નિષ્ફળ", "unshare": "થ્રેડ અનશેર કરવામાં નિષ્ફળ" } }, "delete": { "title": "કાઢી નાખવાની પુષ્ટિ કરો", "description": "આ થ્રેડ અને તેના સંદેશાઓ અને તત્વોને કાઢી નાખશે. આ ક્રિયા પાછી ફેરવી શકાશે નહીં", "success": "ચેટ કાઢી નાખી", "inProgress": "ચેટ કાઢી નાખી રહ્યા છીએ" }, "rename": { "title": "થ્રેડનું નામ બદલો", "description": "આ થ્રેડ માટે નવું નામ દાખલ કરો", "form": { "name": { "label": "નામ", "placeholder": "નવું નામ દાખલ કરો" } }, "success": "થ્રેડનું નામ બદલાયું!", "inProgress": "થ્રેડનું નામ બદલી રહ્યા છીએ" } } } }, "navigation": { "header": { "chat": "ચેટ", "readme": "વાંચો", "theme": { "light": "Light Theme", "dark": "Dark Theme", "system": "Follow System" } }, "newChat": { "button": "નવી ચેટ", "dialog": { "title": "નવી ચેટ બનાવો", "description": "આ તમારો વર્તમાન ચેટ ઇતિહાસ સાફ કરશે. શું તમે ચાલુ રાખવા માંગો છો?", "tooltip": "નવી ચેટ" } }, "user": { "menu": { "settings": "સેટિંગ્સ", "settingsKey": "S", "apiKeys": "API કી", "logout": "લૉગઆઉટ" } } }, "apiKeys": { "title": "જરૂરી API કી", "description": "આ એપ્લિકેશન વાપરવા માટે, નીચેની API કી જરૂરી છે. કી તમારા ડિવાઇસના લોકલ સ્ટોરેજમાં સંગ્રહિત થશે.", "success": { "saved": "સફળતાપૂર્વક સાચવ્યું" } }, "alerts": { "info": "Info", "note": "Note", "tip": "Tip", "important": "Important", "warning": "Warning", "caution": "Caution", "debug": "Debug", "example": "Example", "success": "Success", "help": "Help", "idea": "Idea", "pending": "Pending", "security": "Security", "beta": "Beta", "best-practice": "Best Practice" }, "components": { "MultiSelectInput": { "placeholder": "બેંચી લો..." }, "DatePickerInput": { "placeholder": { "single": "તારીખ પસંદ કરો", "range": "તારીખની શ્રેણી પસંદ કરો" } } } } ================================================ FILE: backend/chainlit/translations/he-IL.json ================================================ { "common": { "actions": { "cancel": "ביטול", "confirm": "אישור", "continue": "המשך", "goBack": "חזור", "reset": "איפוס", "submit": "שלח" }, "status": { "loading": "טוען...", "error": { "default": "אירעה שגיאה", "serverConnection": "לא ניתן להתחבר לשרת" } } }, "auth": { "login": { "title": "התחבר כדי לגשת לאפליקציה", "form": { "email": { "label": "כתובת אימייל", "required": "שדה האימייל הוא שדה חובה", "placeholder": "me@example.com" }, "password": { "label": "סיסמה", "required": "שדה הסיסמה הוא שדה חובה" }, "actions": { "signin": "התחבר" }, "alternativeText": { "or": "או" } }, "errors": { "default": "לא ניתן להתחבר", "signin": "נסה להתחבר עם חשבון אחר", "oauthSignin": "נסה להתחבר עם חשבון אחר", "redirectUriMismatch": "כתובת ההפניה אינה תואמת את תצורת אפליקציית OAuth", "oauthCallback": "נסה להתחבר עם חשבון אחר", "oauthCreateAccount": "נסה להתחבר עם חשבון אחר", "emailCreateAccount": "נסה להתחבר עם חשבון אחר", "callback": "נסה להתחבר עם חשבון אחר", "oauthAccountNotLinked": "כדי לאמת את זהותך, התחבר עם אותו חשבון בו השתמשת במקור", "emailSignin": "לא ניתן היה לשלוח את האימייל", "emailVerify": "אנא אמת את האימייל שלך, נשלח אימייל חדש", "credentialsSignin": "ההתחברות נכשלה. בדוק שהפרטים שהזנת נכונים", "sessionRequired": "אנא התחבר כדי לגשת לדף זה" } }, "provider": { "continue": "המשך עם {{provider}}" } }, "chat": { "input": { "placeholder": "הקלד את ההודעה שלך כאן...", "actions": { "send": "שלח הודעה", "stop": "עצור משימה", "attachFiles": "צרף קבצים" } }, "speech": { "start": "התחל הקלטה", "stop": "עצור הקלטה", "connecting": "מתחבר" }, "favorites": { "use": "השתמש בהודעה מועדפת", "headline": "הודעות מועדפות", "remove": "הסר מהמועדפים", "empty": { "title": "עדיין אין הנחיות שמורות", "description": "התחל בשליחת הנחיה וסמן אותה בכוכב או סמן הנחיה משיחות קודמות" } }, "commands": { "button": "כלים", "changeTool": "שנה כלי", "availableTools": "כלים זמינים" }, "fileUpload": { "dragDrop": "גרור ושחרר קבצים כאן", "browse": "עיין בקבצים", "sizeLimit": "מגבלה:", "errors": { "failed": "העלאה נכשלה", "cancelled": "העלאה בוטלה של" }, "actions": { "cancelUpload": "ביטול העלאה", "removeAttachment": "הסרת קובץ מצורף" } }, "messages": { "status": { "using": "משתמש ב", "used": "השתמש ב" }, "actions": { "copy": { "button": "העתק ללוח", "success": "הועתק!" } }, "feedback": { "positive": "מועיל", "negative": "לא מועיל", "edit": "ערוך משוב", "dialog": { "title": "הוסף תגובה", "submit": "שלח משוב", "yourFeedback": "המשוב שלך..." }, "status": { "updating": "מעדכן", "updated": "המשוב עודכן" } } }, "history": { "title": "קלטים אחרונים", "empty": "כל כך ריק...", "show": "הצג היסטוריה" }, "settings": { "title": "פאנל הגדרות", "customize": "התאם אישית את הגדרות הצ'אט שלך כאן" }, "watermark": "מודלי שפה גדולים עלולים לעשות טעויות. כדאי לבדוק מידע חשוב." }, "threadHistory": { "sidebar": { "title": "צ'אטים קודמים", "filters": { "search": "חיפוש", "placeholder": "Search conversations..." }, "timeframes": { "today": "היום", "yesterday": "אתמול", "previous7days": "7 ימים אחרונים", "previous30days": "30 ימים אחרונים" }, "empty": "לא נמצאו שיחות", "actions": { "close": "סגור סרגל צד", "open": "פתח סרגל צד" } }, "thread": { "untitled": "שיחה ללא כותרת", "menu": { "rename": "שינוי שם", "share": "שיתוף", "delete": "Delete" }, "actions": { "share": { "title": "שיתוף קישור לשיחה", "button": "שיתוף", "status": { "copied": "הקישור הועתק", "created": "קישור השיתוף נוצר!", "unshared": "השיתוף בוטל עבור שיחה זו" }, "error": { "create": "יצירת קישור השיתוף נכשלה", "unshare": "ביטול השיתוף של השיחה נכשל" } }, "delete": { "title": "אשר מחיקה", "description": "פעולה זו תמחק את השיחה וכן את ההודעות והאלמנטים שלה. לא ניתן לבטל פעולה זו", "success": "הצ'אט נמחק", "inProgress": "מוחק צ'אט" }, "rename": { "title": "שנה שם שיחה", "description": "הזן שם חדש לשיחה זו", "form": { "name": { "label": "שם", "placeholder": "הזן שם חדש" } }, "success": "שם השיחה שונה!", "inProgress": "משנה שם שיחה" } } } }, "navigation": { "header": { "chat": "צ'אט", "readme": "קרא אותי", "theme": { "light": "Light Theme", "dark": "Dark Theme", "system": "Follow System" } }, "newChat": { "button": "צ'אט חדש", "dialog": { "title": "צור צ'אט חדש", "description": "פעולה זו תנקה את היסטוריית הצ'אט הנוכחית שלך. האם אתה בטוח שברצונך להמשיך?", "tooltip": "צ'אט חדש" } }, "user": { "menu": { "settings": "הגדרות", "settingsKey": "ה", "apiKeys": "מפתחות API", "logout": "התנתק" } } }, "apiKeys": { "title": "מפתחות API נדרשים", "description": "כדי להשתמש באפליקציה זו, נדרשים מפתחות API הבאים. המפתחות מאוחסנים באחסון המקומי של המכשיר שלך.", "success": { "saved": "נשמר בהצלחה" } }, "alerts": { "info": "Info", "note": "Note", "tip": "Tip", "important": "Important", "warning": "Warning", "caution": "Caution", "debug": "Debug", "example": "Example", "success": "Success", "help": "Help", "idea": "Idea", "pending": "Pending", "security": "Security", "beta": "Beta", "best-practice": "Best Practice" }, "components": { "MultiSelectInput": { "placeholder": "בחר..." }, "DatePickerInput": { "placeholder": { "single": "בחר תאריך", "range": "בחר טווח תאריכים" } } } } ================================================ FILE: backend/chainlit/translations/hi.json ================================================ { "common": { "actions": { "cancel": "रद्द करें", "confirm": "पुष्टि करें", "continue": "जारी रखें", "goBack": "वापस जाएं", "reset": "रीसेट करें", "submit": "जमा करें" }, "status": { "loading": "लोड हो रहा है...", "error": { "default": "एक त्रुटि हुई", "serverConnection": "सर्वर से संपर्क नहीं हो पा रहा" } } }, "auth": { "login": { "title": "ऐप का उपयोग करने के लिए लॉगिन करें", "form": { "email": { "label": "ईमेल पता", "required": "ईमेल एक आवश्यक फ़ील्ड है", "placeholder": "me@example.com" }, "password": { "label": "पासवर्ड", "required": "पासवर्ड एक आवश्यक फ़ील्ड है" }, "actions": { "signin": "साइन इन करें" }, "alternativeText": { "or": "या" } }, "errors": { "default": "साइन इन करने में असमर्थ", "signin": "किसी दूसरे खाते से साइन इन करने का प्रयास करें", "oauthSignin": "किसी दूसरे खाते से साइन इन करने का प्रयास करें", "redirectUriMismatch": "रीडायरेक्ट URI oauth ऐप कॉन्फ़िगरेशन से मेल नहीं खा रहा", "oauthCallback": "किसी दूसरे खाते से साइन इन करने का प्रयास करें", "oauthCreateAccount": "किसी दूसरे खाते से साइन इन करने का प्रयास करें", "emailCreateAccount": "किसी दूसरे खाते से साइन इन करने का प्रयास करें", "callback": "किसी दूसरे खाते से साइन इन करने का प्रयास करें", "oauthAccountNotLinked": "अपनी पहचान की पुष्टि करने के लिए, उसी खाते से साइन इन करें जिसका उपयोग आपने मूल रूप से किया था", "emailSignin": "ईमेल नहीं भेजा जा सका", "emailVerify": "कृपया अपना ईमेल सत्यापित करें, एक नया ईमेल भेजा गया है", "credentialsSignin": "साइन इन विफल। आपके द्वारा प्रदान किए गए विवरण की जांच करें", "sessionRequired": "इस पृष्ठ तक पहुंचने के लिए कृपया साइन इन करें" } }, "provider": { "continue": "{{provider}} के साथ जारी रखें" } }, "chat": { "input": { "placeholder": "अपना संदेश यहां टाइप करें...", "actions": { "send": "संदेश भेजें", "stop": "कार्य रोकें", "attachFiles": "फ़ाइलें संलग्न करें" } }, "speech": { "start": "रिकॉर्डिंग शुरू करें", "stop": "रिकॉर्डिंग रोकें", "connecting": "कनेक्ट हो रहा है" }, "fileUpload": { "dragDrop": "फ़ाइलों को यहां खींचें और छोड़ें", "browse": "फ़ाइलें ब्राउज़ करें", "sizeLimit": "सीमा:", "errors": { "failed": "अपलोड करने में विफल", "cancelled": "का अपलोड रद्द किया गया" }, "actions": { "cancelUpload": "अपलोड रद्द करें", "removeAttachment": "संलग्नक हटाएं" } }, "favorites": { "use": "पसंदीदा संदेश का उपयोग करें", "headline": "पसंदीदा संदेश", "remove": "पसंदीदा हटाएं", "empty": { "title": "अभी तक कोई प्रॉम्प्ट सहेजा नहीं गया", "description": "एक प्रॉम्प्ट भेजकर और उसे स्टार करके शुरू करें या पिछली चैट से किसी प्रॉम्प्ट को स्टार करें" } }, "commands": { "button": "उपकरण", "changeTool": "उपकरण बदलें", "availableTools": "उपलब्ध उपकरण" }, "messages": { "status": { "using": "उपयोग कर रहे हैं", "used": "उपयोग किया" }, "actions": { "copy": { "button": "क्लिपबोर्ड पर कॉपी करें", "success": "कॉपी किया गया!" } }, "feedback": { "positive": "सहायक", "negative": "सहायक नहीं", "edit": "प्रतिक्रिया संपादित करें", "dialog": { "title": "टिप्पणी जोड़ें", "submit": "प्रतिक्रिया जमा करें", "yourFeedback": "आपकी प्रतिक्रिया..." }, "status": { "updating": "अपडेट हो रहा है", "updated": "प्रतिक्रिया अपडेट की गई" } } }, "history": { "title": "पिछले इनपुट", "empty": "कुछ भी नहीं है...", "show": "इतिहास दिखाएं" }, "settings": { "title": "सेटिंग्स पैनल", "customize": "अपने चैट सेटिंग्स को यहां अनुकूलित करें" }, "watermark": "एलएलएम गलतियां कर सकते हैं। महत्वपूर्ण जानकारी की जांच करने पर विचार करें।" }, "threadHistory": { "sidebar": { "title": "पिछली चैट", "filters": { "search": "खोजें", "placeholder": "Search conversations..." }, "timeframes": { "today": "आज", "yesterday": "कल", "previous7days": "पिछले 7 दिन", "previous30days": "पिछले 30 दिन" }, "empty": "कोई थ्रेड नहीं मिला", "actions": { "close": "साइडबार बंद करें", "open": "साइडबार खोलें" } }, "thread": { "untitled": "शीर्षकहीन वार्तालाप", "menu": { "rename": "नाम बदलें", "share": "साझा करें", "delete": "Delete" }, "actions": { "share": { "title": "चैट का लिंक साझा करें", "button": "साझा करें", "status": { "copied": "लिंक कॉपी किया गया", "created": "शेयर लिंक बनाया गया!", "unshared": "इस थ्रेड के लिए साझा करना निष्क्रिय है" }, "error": { "create": "शेयर लिंक बनाने में विफल", "unshare": "थ्रेड को अनशेयर करने में विफल" } }, "delete": { "title": "हटाने की पुष्टि करें", "description": "यह थ्रेड और इसके संदेशों और तत्वों को हटा देगा। यह क्रिया वापस नहीं की जा सकती", "success": "चैट हटा दी गई", "inProgress": "चैट हटाई जा रही है" }, "rename": { "title": "थ्रेड का नाम बदलें", "description": "इस थ्रेड के लिए एक नया नाम दर्ज करें", "form": { "name": { "label": "नाम", "placeholder": "नया नाम दर्ज करें" } }, "success": "थ्रेड का नाम बदल दिया गया!", "inProgress": "थ्रेड का नाम बदला जा रहा है" } } } }, "navigation": { "header": { "chat": "चैट", "readme": "रीडमी", "theme": { "light": "Light Theme", "dark": "Dark Theme", "system": "Follow System" } }, "newChat": { "button": "नई चैट", "dialog": { "title": "नई चैट बनाएं", "description": "यह आपका वर्तमान चैट इतिहास साफ़ कर देगा। क्या आप जारी रखना चाहते हैं?", "tooltip": "नई चैट" } }, "user": { "menu": { "settings": "सेटिंग्स", "settingsKey": "S", "apiKeys": "API कुंजियां", "logout": "लॉगआउट" } } }, "apiKeys": { "title": "आवश्यक API कुंजियां", "description": "इस ऐप का उपयोग करने के लिए, निम्नलिखित API कुंजियां आवश्यक हैं। कुंजियां आपके डिवाइस के स्थानीय संग्रहण में संग्रहीत की जाती हैं।", "success": { "saved": "सफलतापूर्वक सहेजा गया" } }, "alerts": { "info": "Info", "note": "Note", "tip": "Tip", "important": "Important", "warning": "Warning", "caution": "Caution", "debug": "Debug", "example": "Example", "success": "Success", "help": "Help", "idea": "Idea", "pending": "Pending", "security": "Security", "beta": "Beta", "best-practice": "Best Practice" }, "components": { "MultiSelectInput": { "placeholder": "चुनें..." }, "DatePickerInput": { "placeholder": { "single": "एक तारीख चुनें", "range": "तारीख सीमा चुनें" } } } } ================================================ FILE: backend/chainlit/translations/it.json ================================================ { "common": { "actions": { "cancel": "Cancella", "confirm": "Conferma", "continue": "Continua", "goBack": "Ritorna", "reset": "Reset", "submit": "Invia" }, "status": { "loading": "Caricamento...", "error": { "default": "Si è verificato un errore", "serverConnection": "Impossibile connettersi al server" } } }, "auth": { "login": { "title": "Accedi per utilizzare l'app", "form": { "email": { "label": "Indirizzo email", "required": "l'email è un campo obbligatorio", "placeholder": "me@example.com" }, "password": { "label": "Password", "required": "la password è un campo obbligatorio" }, "actions": { "signin": "Accedi" }, "alternativeText": { "or": "O" } }, "errors": { "default": "Impossibile effettuare l'accesso", "signin": "Prova ad accedere con un account diverso", "oauthSignin": "Prova ad accedere con un account diverso", "redirectUriMismatch": "L'URI di reindirizzamento non corrisponde alla configurazione dell'app OAuth", "oauthCallback": "Prova ad accedere con un account diverso", "oauthCreateAccount": "Prova ad accedere con un account diverso", "emailCreateAccount": "Prova ad accedere con un account diverso", "callback": "Prova ad accedere con un account diverso", "oauthAccountNotLinked": "Per confermare la tua identità, accedi con lo stesso account che hai usato in precedenza", "emailSignin": "Impossibile inviare l'email", "emailVerify": "Verifica la tua email, è stata inviata una nuova email", "credentialsSignin": "Accesso non riuscito. Verifica che i dati forniti siano corretti", "sessionRequired": "Accedi per visualizzare questa pagina" } }, "provider": { "continue": "Continua con {{provider}}" } }, "chat": { "input": { "placeholder": "Scrivi un messaggio...", "actions": { "send": "Invia messaggio", "stop": "Interrompi attività", "attachFiles": "Allega file" } }, "favorites": { "use": "Usa un messaggio preferito", "headline": "Messaggi preferiti", "remove": "Rimuovi preferito", "empty": { "title": "Nessun prompt salvato ancora", "description": "Inizia inviando un prompt e aggiungilo ai preferiti o aggiungi un prompt dalle chat precedenti" } }, "commands": { "button": "Strumenti", "changeTool": "Cambia strumento", "availableTools": "Strumenti disponibili" }, "speech": { "start": "Inizia registrazione", "stop": "Interrompi registrazione", "connecting": "Connettendo" }, "fileUpload": { "dragDrop": "Trascina e rilascia i file qui", "browse": "Sfoglia file", "sizeLimit": "Limite:", "errors": { "failed": "Caricamento file non riuscito", "cancelled": "Caricamento annullato di" }, "actions": { "cancelUpload": "Annulla caricamento", "removeAttachment": "Rimuovi allegato" } }, "messages": { "status": { "using": "In uso", "used": "Utilizzato" }, "actions": { "copy": { "button": "Copia negli appunti", "success": "Copiato!" } }, "feedback": { "positive": "Utile", "negative": "Non utile", "edit": "Modifica feedback", "dialog": { "title": "Aggiungi un commento", "submit": "Invia feedback", "yourFeedback": "Il tuo feedback..." }, "status": { "updating": "Aggiornamento", "updated": "Feedback aggiornato" } } }, "history": { "title": "Cronologia chat", "empty": "Così vuoto...", "show": "Mostra cronologia" }, "settings": { "title": "Impostazioni", "customize": "Personalizza le impostazioni della tua chat qui" }, "watermark": "Gli LLMS possono commettere errori. Verifica le info importanti." }, "threadHistory": { "sidebar": { "title": "Chat precedenti", "filters": { "search": "Cerca", "placeholder": "Cerca conversazioni..." }, "timeframes": { "today": "Oggi", "yesterday": "Ieri", "previous7days": "Ultimi 7 giorni", "previous30days": "Ultimi 30 giorni" }, "empty": "Nessuna chat trovata", "actions": { "close": "Chiudi barra laterale", "open": "Apri barra laterale" } }, "thread": { "untitled": "Conversazione senza titolo", "menu": { "rename": "Rinomina", "share": "Condividi", "delete": "Elimina" }, "actions": { "share": { "title": "Condividi link conversazione", "button": "Condividi", "status": { "copied": "Link copiato", "created": "Link di condivisione creato!", "unshared": "Condivisione disabilitata per questa chat" }, "error": { "create": "Impossibile creare il link di condivisione", "unshare": "Impossibile annullare la condivisione della chat" } }, "delete": { "title": "Conferma eliminazione", "description": "Stai per eliminare la chat insieme ai suoi messaggi ed elementi. Questa azione non può essere annullata", "success": "Chat eliminata", "inProgress": "Eliminazione chat" }, "rename": { "title": "Rinomina chat", "description": "Inserisci un nuovo nome per questa conversazione", "form": { "name": { "label": "Nome", "placeholder": "Inserisci nuovo nome" } }, "success": "Chat rinominata!", "inProgress": "Rinomina chat" } } } }, "navigation": { "header": { "chat": "Chat", "readme": "Leggimi", "theme": { "light": "Tema Chiaro", "dark": "Tema Scuro", "system": "Usa tema di sistema" } }, "newChat": { "button": "Nuova Chat", "dialog": { "title": "Crea Nuova Chat", "description": "Sei sicuro di voler creare una nuova chat? La chat corrente verrà chiusa.", "tooltip": "Nuova Chat" } }, "user": { "menu": { "settings": "Impostazioni", "settingsKey": "S", "apiKeys": "Chiavi API", "logout": "Disconnettiti" } } }, "apiKeys": { "title": "Chiavi API richieste", "description": "Per utilizzare l'app, sono necessarie le seguenti chiavi API. Le chiavi sono salvate nella memoria locale del tuo dispositivo.", "success": { "saved": "Salvataggio riuscito" } }, "alerts": { "info": "Info", "note": "Nota", "tip": "Suggerimento", "important": "Importante", "warning": "Avviso", "caution": "Attenzione", "debug": "Debug", "example": "Esempio", "success": "Successo", "help": "Aiuto", "idea": "Idea", "pending": "In sospeso", "security": "Sicurezza", "beta": "Beta", "best-practice": "Miglior Soluzione" }, "components": { "MultiSelectInput": { "placeholder": "Seleziona..." } } } ================================================ FILE: backend/chainlit/translations/ja.json ================================================ { "common": { "actions": { "cancel": "キャンセル", "confirm": "確認", "continue": "続ける", "goBack": "戻る", "reset": "リセット", "submit": "送信" }, "status": { "loading": "読み込み中...", "error": { "default": "エラーが発生しました", "serverConnection": "サーバーに接続できませんでした" } } }, "auth": { "login": { "title": "アプリにログイン", "form": { "email": { "label": "メールアドレス", "required": "メールアドレスは必須項目です", "placeholder": "me@example.com" }, "password": { "label": "パスワード", "required": "パスワードは必須項目です" }, "actions": { "signin": "サインイン" }, "alternativeText": { "or": "または" } }, "errors": { "default": "サインインできません", "signin": "別のアカウントでサインインしてください", "oauthSignin": "別のアカウントでサインインしてください", "redirectUriMismatch": "リダイレクトURIがOAuthアプリの設定と一致しません", "oauthCallback": "別のアカウントでサインインしてください", "oauthCreateAccount": "別のアカウントでサインインしてください", "emailCreateAccount": "別のアカウントでサインインしてください", "callback": "別のアカウントでサインインしてください", "oauthAccountNotLinked": "本人確認のため、最初に使用したのと同じアカウントでサインインしてください", "emailSignin": "メールを送信できませんでした", "emailVerify": "メールアドレスを確認してください。新しいメールが送信されました", "credentialsSignin": "サインインに失敗しました。入力した情報が正しいか確認してください", "sessionRequired": "このページにアクセスするにはサインインしてください" } }, "provider": { "continue": "{{provider}}で続ける" } }, "chat": { "input": { "placeholder": "メッセージを入力してください...", "actions": { "send": "メッセージを送信", "stop": "タスクを停止", "attachFiles": "ファイルを添付" } }, "speech": { "start": "録音開始", "stop": "録音停止", "connecting": "接続中" }, "favorites": { "use": "お気に入りのメッセージを使用", "headline": "お気に入りのメッセージ", "remove": "お気に入りを削除", "empty": { "title": "保存されたプロンプトがまだありません", "description": "プロンプトを送信してスターを付けるか、以前のチャットからプロンプトをスターしてください" } }, "commands": { "button": "ツール", "changeTool": "ツールを変更", "availableTools": "利用可能なツール" }, "fileUpload": { "dragDrop": "ここにファイルをドラッグ&ドロップ", "sizeLimit": "制限:", "errors": { "failed": "アップロードに失敗しました", "cancelled": "アップロードをキャンセルしました:" }, "actions": { "cancelUpload": "アップロードをキャンセル", "removeAttachment": "添付ファイルを削除" } }, "messages": { "status": { "using": "使用中", "used": "使用済み" }, "actions": { "copy": { "button": "クリップボードにコピー", "success": "コピーしました!" } }, "feedback": { "positive": "役に立った", "negative": "役に立たなかった", "edit": "フィードバックを編集", "dialog": { "title": "コメントを追加", "submit": "フィードバックを送信", "yourFeedback": "あなたのフィードバック..." }, "status": { "updating": "更新中", "updated": "フィードバックを更新しました" } } }, "history": { "title": "最近の入力", "empty": "何もありません...", "show": "履歴を表示" }, "settings": { "title": "設定パネル", "customize": "ここでチャット設定をカスタマイズします" }, "watermark": "大規模言語モデルは間違いを犯す可能性があります。重要な情報については確認を検討してください。" }, "threadHistory": { "sidebar": { "title": "過去のチャット", "filters": { "search": "検索", "placeholder": "Search conversations..." }, "timeframes": { "today": "今日", "yesterday": "昨日", "previous7days": "過去7日間", "previous30days": "過去30日間" }, "empty": "スレッドが見つかりません", "actions": { "close": "サイドバーを閉じる", "open": "サイドバーを開く" } }, "thread": { "untitled": "無題の会話", "menu": { "rename": "名前を変更", "share": "共有", "delete": "削除" }, "actions": { "share": { "title": "チャットのリンクを共有", "button": "共有", "status": { "copied": "リンクをコピーしました", "created": "共有リンクを作成しました!", "unshared": "このスレッドの共有を無効にしました" }, "error": { "create": "共有リンクの作成に失敗しました", "unshare": "スレッドの共有解除に失敗しました" } }, "delete": { "title": "削除の確認", "description": "このスレッドとそのメッセージ、要素が削除されます。この操作は取り消せません", "success": "チャットを削除しました", "inProgress": "チャットを削除中" }, "rename": { "title": "スレッドの名前を変更", "description": "このスレッドの新しい名前を入力してください", "form": { "name": { "label": "名前", "placeholder": "新しい名前を入力" } }, "success": "スレッド名を変更しました!", "inProgress": "スレッド名を変更中" } } } }, "navigation": { "header": { "chat": "チャット", "readme": "説明書", "theme": { "light": "Light Theme", "dark": "Dark Theme", "system": "Follow System" } }, "newChat": { "button": "新規チャット", "dialog": { "title": "新規チャットの作成", "description": "現在のチャット履歴がクリアされます。続行しますか?", "tooltip": "新規チャット" } }, "user": { "menu": { "settings": "設定", "settingsKey": "S", "apiKeys": "APIキー", "logout": "ログアウト" } } }, "apiKeys": { "title": "必要なAPIキー", "description": "このアプリを使用するには、以下のAPIキーが必要です。キーはお使いのデバイスのローカルストレージに保存されます。", "success": { "saved": "保存が完了しました" } }, "alerts": { "info": "Info", "note": "Note", "tip": "Tip", "important": "Important", "warning": "Warning", "caution": "Caution", "debug": "Debug", "example": "Example", "success": "Success", "help": "Help", "idea": "Idea", "pending": "Pending", "security": "Security", "beta": "Beta", "best-practice": "Best Practice" }, "components": { "MultiSelectInput": { "placeholder": "選択..." }, "DatePickerInput": { "placeholder": { "single": "日付を選択", "range": "日付範囲を選択" } } } } ================================================ FILE: backend/chainlit/translations/kn.json ================================================ { "common": { "actions": { "cancel": "ರದ್ದುಮಾಡಿ", "confirm": "ದೃಢೀಕರಿಸಿ", "continue": "ಮುಂದುವರಿಸಿ", "goBack": "ಹಿಂದೆ ಹೋಗಿ", "reset": "ಮರುಹೊಂದಿಸಿ", "submit": "ಸಲ್ಲಿಸಿ" }, "status": { "loading": "ಲೋಡ್ ಆಗುತ್ತಿದೆ...", "error": { "default": "ದೋಷ ಸಂಭವಿಸಿದೆ", "serverConnection": "ಸರ್ವರ್‌ ಅನ್ನು ತಲುಪಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ" } } }, "auth": { "login": { "title": "ಅಪ್ಲಿಕೇಶನ್‌ಗೆ ಪ್ರವೇಶಿಸಲು ಲಾಗಿನ್ ಮಾಡಿ", "form": { "email": { "label": "ಇಮೇಲ್ ವಿಳಾಸ", "required": "ಇಮೇಲ್ ಅಗತ್ಯವಿರುವ ಕ್ಷೇತ್ರ", "placeholder": "me@example.com" }, "password": { "label": "ಪಾಸ್‌ವರ್ಡ್", "required": "ಪಾಸ್‌ವರ್ಡ್ ಅಗತ್ಯವಿರುವ ಕ್ಷೇತ್ರ" }, "actions": { "signin": "ಸೈನ್ ಇನ್ ಮಾಡಿ" }, "alternativeText": { "or": "ಅಥವಾ" } }, "errors": { "default": "ಸೈನ್ ಇನ್ ಮಾಡಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ", "signin": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ", "oauthSignin": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ", "redirectUriMismatch": "ರೀಡೈರೆಕ್ಟ್ URI ಓಥ್ ಅಪ್ಲಿಕೇಶನ್ ಕಾನ್ಫಿಗರೇಶನ್‌ಗೆ ಹೊಂದಿಕೆಯಾಗುತ್ತಿಲ್ಲ", "oauthCallback": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ", "oauthCreateAccount": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ", "emailCreateAccount": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ", "callback": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ", "oauthAccountNotLinked": "ನಿಮ್ಮ ಗುರುತನ್ನು ದೃಢೀಕರಿಸಲು, ನೀವು ಮೊದಲು ಬಳಸಿದ ಅದೇ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಿ", "emailSignin": "ಇಮೇಲ್ ಕಳುಹಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ", "emailVerify": "ದಯವಿಟ್ಟು ನಿಮ್ಮ ಇಮೇಲ್ ಪರಿಶೀಲಿಸಿ, ಹೊಸ ಇಮೇಲ್ ಕಳುಹಿಸಲಾಗಿದೆ", "credentialsSignin": "ಸೈನ್ ಇನ್ ವಿಫಲವಾಗಿದೆ. ನೀವು ಒದಗಿಸಿದ ವಿವರಗಳು ಸರಿಯಾಗಿವೆಯೇ ಎಂದು ಪರಿಶೀಲಿಸಿ", "sessionRequired": "ಈ ಪುಟವನ್ನು ಪ್ರವೇಶಿಸಲು ದಯವಿಟ್ಟು ಸೈನ್ ಇನ್ ಮಾಡಿ" } }, "provider": { "continue": "{{provider}} ನೊಂದಿಗೆ ಮುಂದುವರಿಸಿ" } }, "chat": { "input": { "placeholder": "ನಿಮ್ಮ ಸಂದೇಶವನ್ನು ಇಲ್ಲಿ ಟೈಪ್ ಮಾಡಿ...", "actions": { "send": "ಸಂದೇಶ ಕಳುಹಿಸಿ", "stop": "ಕಾರ್ಯ ನಿಲ್ಲಿಸಿ", "attachFiles": "ಫೈಲ್‌ಗಳನ್ನು ಲಗತ್ತಿಸಿ" } }, "favorites": { "use": "ಮೆಚ್ಚಿನ ಸಂದೇಶವನ್ನು ಬಳಸಿ", "headline": "ಮೆಚ್ಚಿನ ಸಂದೇಶಗಳು", "remove": "ಮೆಚ್ಚಿನ ಸಂದೇಶವನ್ನು ತೆಗೆದುಹಾಕಿ", "empty": { "title": "ಇನ್ನೂ ಯಾವುದೇ ಪ್ರಾಂಪ್ಟ್‌ಗಳನ್ನು ಉಳಿಸಲಾಗಿಲ್ಲ", "description": "ಪ್ರಾಂಪ್ಟ್ ಕಳುಹಿಸಿ ಮತ್ತು ಅದಕ್ಕೆ ಸ್ಟಾರ್ ಮಾಡಿ ಅಥವಾ ಹಿಂದಿನ ಚಾಟ್‌ಗಳಿಂದ ಪ್ರಾಂಪ್ಟ್‌ಗೆ ಸ್ಟಾರ್ ಮಾಡಿ" } }, "commands": { "button": "ಉಪಕರಣಗಳು", "changeTool": "ಉಪಕರಣವನ್ನು ಬದಲಿಸಿ", "availableTools": "ಲಭ್ಯವಿರುವ ಉಪಕರಣಗಳು" }, "speech": { "start": "ರೆಕಾರ್ಡಿಂಗ್ ಪ್ರಾರಂಭಿಸಿ", "stop": "ರೆಕಾರ್ಡಿಂಗ್ ನಿಲ್ಲಿಸಿ", "connecting": "ಸಂಪರ್ಕಿಸಲಾಗುತ್ತಿದೆ" }, "fileUpload": { "dragDrop": "ಫೈಲ್‌ಗಳನ್ನು ಇಲ್ಲಿ ಎಳೆದು ಬಿಡಿ", "browse": "ಫೈಲ್‌ಗಳನ್ನು ಬ್ರೌಸ್ ಮಾಡಿ", "sizeLimit": "ಮಿತಿ:", "errors": { "failed": "ಅಪ್‌ಲೋಡ್ ವಿಫಲವಾಗಿದೆ", "cancelled": "ಅಪ್‌ಲೋಡ್ ರದ್ದುಗೊಳಿಸಲಾಗಿದೆ" }, "actions": { "cancelUpload": "ಅಪ್‌ಲೋಡ್ ರದ್ದುಗೊಳಿಸಿ", "removeAttachment": "ಅಟ್ಯಾಚ್‌ಮೆಂಟ್ ಅನ್ನು ತೆಗೆದುಹಾಕಿ" } }, "messages": { "status": { "using": "ಬಳಸುತ್ತಿರುವುದು", "used": "ಬಳಸಲಾಗಿದೆ" }, "actions": { "copy": { "button": "ಕ್ಲಿಪ್‌ಬೋರ್ಡ್‌ಗೆ ನಕಲಿಸಿ", "success": "ನಕಲಿಸಲಾಗಿದೆ!" } }, "feedback": { "positive": "ಸಹಾಯಕವಾಗಿದೆ", "negative": "ಸಹಾಯಕವಾಗಿಲ್ಲ", "edit": "ಪ್ರತಿಕ್ರಿಯೆ ಸಂಪಾದಿಸಿ", "dialog": { "title": "ಕಾಮೆಂಟ್ ಸೇರಿಸಿ", "submit": "ಪ್ರತಿಕ್ರಿಯೆ ಸಲ್ಲಿಸಿ", "yourFeedback": "ನಿಮ್ಮ ಪ್ರತಿಕ್ರಿಯೆ..." }, "status": { "updating": "ನವೀಕರಿಸಲಾಗುತ್ತಿದೆ", "updated": "ಪ್ರತಿಕ್ರಿಯೆ ನವೀಕರಿಸಲಾಗಿದೆ" } } }, "history": { "title": "ಕೊನೆಯ ಇನ್‌ಪುಟ್‌ಗಳು", "empty": "ಖಾಲಿಯಾಗಿದೆ...", "show": "ಇತಿಹಾಸ ತೋರಿಸಿ" }, "settings": { "title": "ಸೆಟ್ಟಿಂಗ್‌ಗಳ ಪ್ಯಾನೆಲ್", "customize": "ಈಗ ನಿಮ್ಮ ಚಾಟ್ ಸೆಟ್ಟಿಂಗ್‌ಗಳನ್ನು ಕಸ್ಟಮೈಸ್ ಮಾಡಿ" }, "watermark": "LLM ಗಳು ತಪ್ಪುಗಳನ್ನು ಮಾಡಬಹುದು. ಪ್ರಮುಖ ಮಾಹಿತಿಯನ್ನು ಪರಿಶೀಲಿಸುವುದನ್ನು ಪರಿಗಣಿಸಿ." }, "threadHistory": { "sidebar": { "title": "ಹಿಂದಿನ ಸಂಭಾಷಣೆಗಳು", "filters": { "search": "ಹುಡುಕಿ", "placeholder": "Search conversations..." }, "timeframes": { "today": "ಇಂದು", "yesterday": "ನಿನ್ನೆ", "previous7days": "ಹಿಂದಿನ 7 ದಿನಗಳು", "previous30days": "ಹಿಂದಿನ 30 ದಿನಗಳು" }, "empty": "ಯಾವುದೇ ಸಂಭಾಷಣೆಗಳು ಕಂಡುಬಂದಿಲ್ಲ", "actions": { "close": "ಪಕ್ಕದ ಪಟ್ಟಿ ಮುಚ್ಚಿ", "open": "ಪಕ್ಕದ ಪಟ್ಟಿ ತೆರೆಯಿರಿ" } }, "thread": { "untitled": "ಶೀರ್ಷಿಕೆರಹಿತ ಸಂಭಾಷಣೆ", "menu": { "rename": "ಮರುಹೆಸರಿಸಿ", "share": "ಹಂಚಿಕೊಳ್ಳಿ", "delete": "ಅಳಿಸಿ" }, "actions": { "share": { "title": "ಚಾಟ್‌ಗೆ ಲಿಂಕ್ ಹಂಚಿಕೊಳ್ಳಿ", "button": "ಹಂಚಿಕೊಳ್ಳಿ", "status": { "copied": "ಲಿಂಕ್ ಪ್ರತಿಲಿಪಿ ಮಾಡಲಾಗಿದೆ", "created": "ಹಂಚಿಕೆಯ ಲಿಂಕ್ ರಚಿಸಲಾಗಿದೆ!", "unshared": "ಈ ಸಂಭಾಷಣೆಗೆ ಹಂಚಿಕೆ ನಿಷ್ಕ್ರಿಯಗೊಳಿಸಲಾಗಿದೆ" }, "error": { "create": "ಹಂಚಿಕೆಯ ಲಿಂಕ್ ರಚಿಸಲು ವಿಫಲವಾಗಿದೆ", "unshare": "ಸಂಭಾಷಣೆ ಹಂಚಿಕೆಯನ್ನು ರದ್ದು ಮಾಡಲು ವಿಫಲವಾಗಿದೆ" } }, "delete": { "title": "ಅಳಿಸುವಿಕೆಯನ್ನು ದೃಢೀಕರಿಸಿ", "description": "ಇದು ಸಂಭಾಷಣೆಯನ್ನು ಹಾಗೂ ಅದರ ಸಂದೇಶಗಳು ಮತ್ತು ಅಂಶಗಳನ್ನು ಅಳಿಸುತ್ತದೆ. ಈ ಕ್ರಿಯೆಯನ್ನು ರದ್ದುಗೊಳಿಸಲು ಸಾಧ್ಯವಿಲ್ಲ", "success": "ಸಂಭಾಷಣೆ ಅಳಿಸಲಾಗಿದೆ", "inProgress": "ಸಂಭಾಷಣೆ ಅಳಿಸಲಾಗುತ್ತಿದೆ" }, "rename": { "title": "ಸಂಭಾಷಣೆಯ ಹೆಸರು ಬದಲಾಯಿಸಿ", "description": "ಈ ಸಂಭಾಷಣೆಗೆ ಹೊಸ ಹೆಸರನ್ನು ನಮೂದಿಸಿ", "form": { "name": { "label": "ಹೆಸರು", "placeholder": "ಹೊಸ ಹೆಸರನ್ನು ನಮೂದಿಸಿ" } }, "success": "ಸಂಭಾಷಣೆಯ ಹೆಸರು ಬದಲಾಯಿಸಲಾಗಿದೆ!", "inProgress": "ಸಂಭಾಷಣೆಯ ಹೆಸರು ಬದಲಾಯಿಸಲಾಗುತ್ತಿದೆ" } } } }, "navigation": { "header": { "chat": "ಸಂಭಾಷಣೆ", "readme": "ಓದಿ", "theme": { "light": "Light Theme", "dark": "Dark Theme", "system": "Follow System" } }, "newChat": { "button": "ಹೊಸ ಸಂಭಾಷಣೆ", "dialog": { "title": "ಹೊಸ ಸಂಭಾಷಣೆ ರಚಿಸಿ", "description": "ಇದು ನಿಮ್ಮ ಪ್ರಸ್ತುತ ಸಂಭಾಷಣೆಯ ಇತಿಹಾಸವನ್ನು ಅಳಿಸುತ್ತದೆ. ನೀವು ಮುಂದುವರೆಯಲು ಬಯಸುವಿರಾ?", "tooltip": "ಹೊಸ ಸಂಭಾಷಣೆ" } }, "user": { "menu": { "settings": "ಸೆಟ್ಟಿಂಗ್‌ಗಳು", "settingsKey": "S", "apiKeys": "API ಕೀಗಳು", "logout": "ಲಾಗ್ ಔಟ್" } } }, "apiKeys": { "title": "ಅಗತ್ಯವಿರುವ API ಕೀಗಳು", "description": "ಈ ಅಪ್ಲಿಕೇಶನ್ ಬಳಸಲು, ಈ ಕೆಳಗಿನ API ಕೀಗಳು ಅಗತ್ಯವಿರುತ್ತವೆ. ಕೀಗಳನ್ನು ನಿಮ್ಮ ಸಾಧನದ ಸ್ಥಳೀಯ ಸಂಗ್ರಹಣೆಯಲ್ಲಿ ಸಂಗ್ರಹಿಸಲಾಗುತ್ತದೆ.", "success": { "saved": "ಯಶಸ್ವಿಯಾಗಿ ಉಳಿಸಲಾಗಿದೆ" } }, "alerts": { "info": "Info", "note": "Note", "tip": "Tip", "important": "Important", "warning": "Warning", "caution": "Caution", "debug": "Debug", "example": "Example", "success": "Success", "help": "Help", "idea": "Idea", "pending": "Pending", "security": "Security", "beta": "Beta", "best-practice": "Best Practice" }, "components": { "MultiSelectInput": { "placeholder": "ಚುನಾಯಿಸಿ..." }, "DatePickerInput": { "placeholder": { "single": "ದಿನಾಂಕವನ್ನು ಆಯ್ಕೆಮಾಡಿ", "range": "ದಿನಾಂಕ ಶ್ರೇಣಿಯನ್ನು ಆಯ್ಕೆಮಾಡಿ" } } } } ================================================ FILE: backend/chainlit/translations/ko.json ================================================ { "common": { "actions": { "cancel": "취소", "confirm": "확인", "continue": "계속", "goBack": "뒤로 가기", "reset": "초기화", "submit": "제출" }, "status": { "loading": "로딩 중...", "error": { "default": "오류가 발생했습니다", "serverConnection": "서버에 연결할 수 없습니다" } } }, "auth": { "login": { "title": "앱에 접근하려면 로그인하세요", "form": { "email": { "label": "이메일 주소", "required": "이메일은 필수 입력 항목입니다", "placeholder": "me@example.com" }, "password": { "label": "비밀번호", "required": "비밀번호는 필수 입력 항목입니다" }, "actions": { "signin": "로그인" }, "alternativeText": { "or": "또는" } }, "errors": { "default": "로그인할 수 없습니다", "signin": "다른 계정으로 로그인해보세요", "oauthSignin": "다른 계정으로 로그인해보세요", "redirectUriMismatch": "리다이렉트 URI가 OAuth 앱 설정과 일치하지 않습니다", "oauthCallback": "다른 계정으로 로그인해보세요", "oauthCreateAccount": "다른 계정으로 로그인해보세요", "emailCreateAccount": "다른 계정으로 로그인해보세요", "callback": "다른 계정으로 로그인해보세요", "oauthAccountNotLinked": "신원을 확인하려면 원래 사용했던 계정으로 로그인하세요", "emailSignin": "이메일을 보낼 수 없습니다", "emailVerify": "이메일을 확인해주세요. 새로운 이메일이 발송되었습니다", "credentialsSignin": "로그인 실패. 제공한 정보가 올바른지 확인하세요", "sessionRequired": "이 페이지에 접근하려면 로그인해주세요" } }, "provider": { "continue": "{{provider}}로 계속하기" } }, "chat": { "input": { "placeholder": "여기에 메시지를 입력하세요...", "actions": { "send": "메시지 보내기", "stop": "작업 중지", "attachFiles": "파일 첨부" } }, "favorites": { "use": "즐겨찾기 메시지 사용", "headline": "즐겨찾기 메시지", "remove": "즐겨찾기 제거", "empty": { "title": "저장된 프롬프트가 아직 없습니다", "description": "프롬프트를 보내고 별표를 추가하거나 이전 대화에서 프롬프트에 별표를 추가하세요" } }, "commands": { "button": "도구", "changeTool": "도구 변경", "availableTools": "사용 가능한 도구" }, "speech": { "start": "녹음 시작", "stop": "녹음 중지", "connecting": "연결 중" }, "fileUpload": { "dragDrop": "여기에 파일을 드래그 앤 드롭하세요", "browse": "파일 찾아보기", "sizeLimit": "제한:", "errors": { "failed": "업로드 실패", "cancelled": "업로드 취소:" }, "actions": { "cancelUpload": "업로드 취소", "removeAttachment": "첨부 파일 제거" } }, "messages": { "status": { "using": "사용 중", "used": "사용됨" }, "actions": { "copy": { "button": "클립보드로 복사", "success": "복사되었습니다!" } }, "feedback": { "positive": "도움이 되었음", "negative": "도움이 되지 않음", "edit": "피드백 수정", "dialog": { "title": "댓글 추가", "submit": "피드백 제출", "yourFeedback": "귀하의 피드백..." }, "status": { "updating": "업데이트 중", "updated": "피드백이 업데이트되었습니다" } } }, "history": { "title": "최근 입력", "empty": "비어 있습니다...", "show": "기록 표시" }, "settings": { "title": "설정 패널", "customize": "여기에서 채팅 설정을 사용자 지정하세요" }, "watermark": "LLM은 실수할 수 있습니다. 중요한 정보는 확인하세요." }, "threadHistory": { "sidebar": { "title": "이전 채팅", "filters": { "search": "검색", "placeholder": "대화 검색..." }, "timeframes": { "today": "오늘", "yesterday": "어제", "previous7days": "지난 7일", "previous30days": "지난 30일" }, "empty": "스레드를 찾을 수 없습니다", "actions": { "close": "사이드바 닫기", "open": "사이드바 열기" } }, "thread": { "untitled": "제목 없는 대화", "menu": { "rename": "이름 변경", "share": "공유", "delete": "삭제" }, "actions": { "share": { "title": "채팅 링크 공유", "button": "공유", "status": { "copied": "링크 복사됨", "created": "공유 링크가 생성되었습니다!", "unshared": "이 스레드의 공유가 비활성화되었습니다" }, "error": { "create": "공유 링크 생성 실패", "unshare": "스레드 공유 해제 실패" } }, "delete": { "title": "삭제 확인", "description": "이렇게 하면 스레드와 그 메시지 및 요소가 삭제됩니다. 이 작업은 취소할 수 없습니다", "success": "채팅이 삭제되었습니다", "inProgress": "채팅 삭제 중" }, "rename": { "title": "스레드 이름 변경", "description": "이 스레드의 새 이름을 입력하세요", "form": { "name": { "label": "이름", "placeholder": "새 이름 입력" } }, "success": "스레드 이름이 변경되었습니다!", "inProgress": "스레드 이름 변경 중" } } } }, "navigation": { "header": { "chat": "채팅", "readme": "읽어보기", "theme": { "light": "밝은 테마", "dark": "어두운 테마", "system": "시스템 따라가기" } }, "newChat": { "button": "새 채팅", "dialog": { "title": "새 채팅 만들기", "description": "이렇게 하면 현재 채팅 기록이 지워집니다. 계속하시겠습니까?", "tooltip": "새 채팅" } }, "user": { "menu": { "settings": "설정", "settingsKey": "S", "apiKeys": "API 키", "logout": "로그아웃" } } }, "apiKeys": { "title": "필요한 API 키", "description": "이 앱을 사용하려면 다음 API 키가 필요합니다. 키는 기기의 로컬 저장소에 저장됩니다.", "success": { "saved": "성공적으로 저장되었습니다" } }, "alerts": { "info": "정보", "note": "참고", "tip": "팁", "important": "중요", "warning": "경고", "caution": "주의", "debug": "디버그", "example": "예시", "success": "성공", "help": "도움말", "idea": "아이디어", "pending": "대기 중", "security": "보안", "beta": "베타", "best-practice": "모범 사례" }, "components": { "MultiSelectInput": { "placeholder": "선택..." } } } ================================================ FILE: backend/chainlit/translations/ml.json ================================================ { "common": { "actions": { "cancel": "റദ്ദാക്കുക", "confirm": "സ്ഥിരീകരിക്കുക", "continue": "തുടരുക", "goBack": "തിരികെ പോകുക", "reset": "പുനഃസജ്ജമാക്കുക", "submit": "സമർപ്പിക്കുക" }, "status": { "loading": "ലോഡ് ചെയ്യുന്നു...", "error": { "default": "ഒരു പിശക് സംഭവിച്ചു", "serverConnection": "സെർവറുമായി ബന്ധപ്പെടാൻ കഴിഞ്ഞില്ല" } } }, "auth": { "login": { "title": "ആപ്പ് ഉപയോഗിക്കാൻ ലോഗിൻ ചെയ്യുക", "form": { "email": { "label": "ഇമെയിൽ വിലാസം", "required": "ഇമെയിൽ ഒരു ആവശ്യമായ ഫീൽഡ് ആണ്", "placeholder": "me@example.com" }, "password": { "label": "പാസ്‌വേഡ്", "required": "പാസ്‌വേഡ് ഒരു ആവശ്യമായ ഫീൽഡ് ആണ്" }, "actions": { "signin": "സൈൻ ഇൻ" }, "alternativeText": { "or": "അല്ലെങ്കിൽ" } }, "errors": { "default": "സൈൻ ഇൻ ചെയ്യാൻ കഴിയുന്നില്ല", "signin": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക", "oauthSignin": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക", "redirectUriMismatch": "റീഡയറക്ട് URI oauth ആപ്പ് കോൺഫിഗറേഷനുമായി പൊരുത്തപ്പെടുന്നില്ല", "oauthCallback": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക", "oauthCreateAccount": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക", "emailCreateAccount": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക", "callback": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യാൻ ശ്രമിക്കുക", "oauthAccountNotLinked": "നിങ്ങളുടെ വ്യക്തിത്വം സ്ഥിരീകരിക്കാൻ, ആദ്യം ഉപയോഗിച്ച അതേ അക്കൗണ്ട് ഉപയോഗിച്ച് സൈൻ ഇൻ ചെയ്യുക", "emailSignin": "ഇമെയിൽ അയയ്ക്കാൻ കഴിഞ്ഞില്ല", "emailVerify": "നിങ്ങളുടെ ഇമെയിൽ പരിശോധിക്കുക, ഒരു പുതിയ ഇമെയിൽ അയച്ചിട്ടുണ്ട്", "credentialsSignin": "സൈൻ ഇൻ പരാജയപ്പെട്ടു. നിങ്ങൾ നൽകിയ വിവരങ്ങൾ ശരിയാണെന്ന് പരിശോധിക്കുക", "sessionRequired": "ഈ പേജ് ആക്സസ് ചെയ്യാൻ ദയവായി സൈൻ ഇൻ ചെയ്യുക" } }, "provider": { "continue": "{{provider}} ഉപയോഗിച്ച് തുടരുക" } }, "chat": { "input": { "placeholder": "നിങ്ങളുടെ സന്ദേശം ഇവിടെ ടൈപ്പ് ചെയ്യുക...", "actions": { "send": "സന്ദേശം അയയ്ക്കുക", "stop": "ടാസ്ക് നിർത്തുക", "attachFiles": "ഫയലുകൾ അറ്റാച്ച് ചെയ്യുക" } }, "favorites": { "use": "പ്രിയപ്പെട്ട സന്ദേശം ഉപയോഗിക്കുക", "headline": "പ്രിയപ്പെട്ട സന്ദേശങ്ങൾ", "remove": "ഇഷ്ടപ്പെട്ടത് നീക്കം ചെയ്യുക", "empty": { "title": "ഇതുവരെ സംരക്ഷിച്ച പ്രോംപ്റ്റുകളൊന്നുമില്ല", "description": "ഒരു പ്രോംപ്റ്റ് അയച്ച് അതിന് സ്റ്റാർ ചെയ്തുകൊണ്ട് ആരംഭിക്കുക അല്ലെങ്കിൽ മുൻ ചാറ്റുകളിൽ നിന്ന് ഒരു പ്രോംപ്റ്റിന് സ്റ്റാർ ചെയ്യുക" } }, "commands": { "button": "ഉപകരണങ്ങൾ", "changeTool": "ഉപകരണം മാറ്റുക", "availableTools": "ലഭ്യമായ ഉപകരണങ്ങൾ" }, "speech": { "start": "റെക്കോർഡിംഗ് ആരംഭിക്കുക", "stop": "റെക്കോർഡിംഗ് നിർത്തുക", "connecting": "ബന്ധിപ്പിക്കുന്നു" }, "fileUpload": { "dragDrop": "ഫയലുകൾ ഇവിടെ വലിച്ചിടുക", "browse": "ഫയലുകൾ തിരയുക", "sizeLimit": "പരിധി:", "errors": { "failed": "അപ്‌ലോഡ് പരാജയപ്പെട്ടു", "cancelled": "അപ്‌ലോഡ് റദ്ദാക്കി" }, "actions": { "cancelUpload": "അപ്‌ಲോഡ് റദ്ദുചെയ്യുക", "removeAttachment": "അറ്റാച്ച്‌മെന്റ് നീക്കം ചെയ്യുക" } }, "messages": { "status": { "using": "ഉപയോഗിക്കുന്നു", "used": "ഉപയോഗിച്ചു" }, "actions": { "copy": { "button": "ക്ലിപ്പ്ബോർഡിലേക്ക് പകർത്തുക", "success": "പകർത്തി!" } }, "feedback": { "positive": "സഹായകരം", "negative": "സഹായകരമല്ല", "edit": "ഫീഡ്ബാക്ക് എഡിറ്റ് ചെയ്യുക", "dialog": { "title": "ഒരു കമന്റ് ചേർക്കുക", "submit": "ഫീഡ്ബാക്ക് സമർപ്പിക്കുക", "yourFeedback": "നിങ്ങളുടെ പ്രതികരണം..." }, "status": { "updating": "അപ്ഡേറ്റ് ചെയ്യുന്നു", "updated": "ഫീഡ്ബാക്ക് അപ്ഡേറ്റ് ചെയ്തു" } } }, "history": { "title": "അവസാന ഇൻപുട്ടുകൾ", "empty": "ഒന്നുമില്ല...", "show": "ഹിസ്റ്ററി കാണിക്കുക" }, "settings": { "title": "ക്രമീകരണങ്ങൾ പാനൽ", "customize": "ഈ സമയം നിങ്ങളുടെ ചാറ്റ് ക്രമീകരണങ്ങൾ കസ്റ്റമൈസ് ചെയ്യുക" }, "watermark": "LLM കൾക്ക് തെറ്റുകൾ വരുത്താം. പ്രധാനപ്പെട്ട വിവരങ്ങൾ പരിശോധിക്കുന്നത് പരിഗണിക്കുക." }, "threadHistory": { "sidebar": { "title": "മുൻ ചാറ്റുകൾ", "filters": { "search": "തിരയുക", "placeholder": "Search conversations..." }, "timeframes": { "today": "ഇന്ന്", "yesterday": "ഇന്നലെ", "previous7days": "കഴിഞ്ഞ 7 ദിവസം", "previous30days": "കഴിഞ്ഞ 30 ദിവസം" }, "empty": "ത്രെഡുകൾ കണ്ടെത്തിയില്ല", "actions": { "close": "സൈഡ്ബാർ അടയ്ക്കുക", "open": "സൈഡ്ബാർ തുറക്കുക" } }, "thread": { "untitled": "പേരില്ലാത്ത സംഭാഷണം", "menu": { "rename": "പേര് മാറ്റുക", "share": "പങ്കിടുക", "delete": "ഡിലീറ്റ്" }, "actions": { "share": { "title": "ചാറ്റിലേക്ക് ലിങ്ക് പങ്കിടുക", "button": "പങ്കിടുക", "status": { "copied": "ലിങ്ക് പകർത്തി", "created": "പങ്കിടൽ ലിങ്ക് സൃഷ്ടിച്ചു!", "unshared": "ഈ ത്രെഡിനായി പങ്കിടൽ അപ്രാപ്തമാക്കി" }, "error": { "create": "പങ്കിടൽ ലിങ്ക് സൃഷ്ടിക്കൽ പരാജയപ്പെട്ടു", "unshare": "ത്രെഡ് പങ്കിടൽ അവസാനിപ്പിക്കൽ പരാജയപ്പെട്ടു" } }, "delete": { "title": "ഡിലീറ്റ് സ്ഥിരീകരിക്കുക", "description": "ഇത് ത്രെഡും അതിന്റെ സന്ദേശങ്ങളും ഘടകങ്ങളും ഡിലീറ്റ് ചെയ്യും. ഈ പ്രവർത്തി പഴയപടിയാക്കാൻ കഴിയില്ല", "success": "ചാറ്റ് ഡിലീറ്റ് ചെയ്തു", "inProgress": "ചാറ്റ് ഡിലീറ്റ് ചെയ്യുന്നു" }, "rename": { "title": "ത്രെഡ് പുനർനാമകരണം ചെയ്യുക", "description": "ഈ ത്രെഡിന് ഒരു പുതിയ പേര് നൽകുക", "form": { "name": { "label": "പേര്", "placeholder": "പുതിയ പേര് നൽകുക" } }, "success": "ത്രെഡ് പുനർനാമകരണം ചെയ്തു!", "inProgress": "ത്രെഡ് പുനർനാമകരണം ചെയ്യുന്നു" } } } }, "navigation": { "header": { "chat": "ചാറ്റ്", "readme": "വായിക്കുക", "theme": { "light": "Light Theme", "dark": "Dark Theme", "system": "Follow System" } }, "newChat": { "button": "പുതിയ ചാറ്റ്", "dialog": { "title": "പുതിയ ചാറ്റ് സൃഷ്ടിക്കുക", "description": "ഇത് നിങ്ങളുടെ നിലവിലെ ചാറ്റ് ഹിസ്റ്ററി മായ്ക്കും. തുടരാൻ താൽപ്പര്യമുണ്ടോ?", "tooltip": "പുതിയ ചാറ്റ്" } }, "user": { "menu": { "settings": "ക്രമീകരണങ്ങൾ", "settingsKey": "S", "apiKeys": "API കീകൾ", "logout": "ലോഗ്ഔട്ട്" } } }, "apiKeys": { "title": "ആവശ്യമായ API കീകൾ", "description": "ഈ ആപ്പ് ഉപയോഗിക്കാൻ, താഴെപ്പറയുന്ന API കീകൾ ആവശ്യമാണ്. കീകൾ നിങ്ങളുടെ ഉപകരണത്തിന്റെ ലോക്കൽ സ്റ്റോറേജിൽ സംഭരിക്കപ്പെടുന്നു.", "success": { "saved": "വിജയകരമായി സംരക്ഷിച്ചു" } }, "alerts": { "info": "Info", "note": "Note", "tip": "Tip", "important": "Important", "warning": "Warning", "caution": "Caution", "debug": "Debug", "example": "Example", "success": "Success", "help": "Help", "idea": "Idea", "pending": "Pending", "security": "Security", "beta": "Beta", "best-practice": "Best Practice" }, "components": { "MultiSelectInput": { "placeholder": "ചൂണ്ടിക്കാണിക്കുക..." }, "DatePickerInput": { "placeholder": { "single": "തീയതി തിരഞ്ഞെടുക്കുക", "range": "തീയതി ശ്രേണി തിരഞ്ഞെടുക്കുക" } } } } ================================================ FILE: backend/chainlit/translations/mr.json ================================================ { "common": { "actions": { "cancel": "रद्द करा", "confirm": "पुष्टी करा", "continue": "पुढे जा", "goBack": "मागे जा", "reset": "रीसेट करा", "submit": "सबमिट करा" }, "status": { "loading": "लोड करत आहे...", "error": { "default": "एक त्रुटी आली", "serverConnection": "सर्व्हरशी कनेक्ट होऊ शकले नाही" } } }, "auth": { "login": { "title": "अॅप वापरण्यासाठी लॉगिन करा", "form": { "email": { "label": "ईमेल पत्ता", "required": "ईमेल आवश्यक आहे", "placeholder": "me@example.com" }, "password": { "label": "पासवर्ड", "required": "पासवर्ड आवश्यक आहे" }, "actions": { "signin": "साइन इन करा" }, "alternativeText": { "or": "किंवा" } }, "errors": { "default": "साइन इन करू शकत नाही", "signin": "वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा", "oauthSignin": "वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा", "redirectUriMismatch": "रीडायरेक्ट URI ओथ अॅप कॉन्फिगरेशनशी जुळत नाही", "oauthCallback": "वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा", "oauthCreateAccount": "वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा", "emailCreateAccount": "वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा", "callback": "वेगळ्या खात्याने साइन इन करण्याचा प्रयत्न करा", "oauthAccountNotLinked": "तुमची ओळख पटवण्यासाठी, मूळ वापरलेल्या खात्यानेच साइन इन करा", "emailSignin": "ईमेल पाठवू शकले नाही", "emailVerify": "कृपया तुमचा ईमेल तपासा, नवीन ईमेल पाठवला गेला आहे", "credentialsSignin": "साइन इन अयशस्वी. तुम्ही दिलेली माहिती योग्य आहे का ते तपासा", "sessionRequired": "या पृष्ठावर प्रवेश करण्यासाठी कृपया साइन इन करा" } }, "provider": { "continue": "{{provider}} सह पुढे जा" } }, "chat": { "input": { "placeholder": "तुमचा संदेश येथे टाइप करा...", "actions": { "send": "संदेश पाठवा", "stop": "कार्य थांबवा", "attachFiles": "फाइल्स जोडा" } }, "speech": { "start": "रेकॉर्डिंग सुरू करा", "stop": "रेकॉर्डिंग थांबवा", "connecting": "कनेक्ट करत आहे" }, "favorites": { "use": "आवडता संदेश वापरा", "headline": "आवडते संदेश", "remove": "आवडता संदेश काढा", "empty": { "title": "अद्याप कोणतेही प्रॉम्प्ट जतन केलेले नाहीत", "description": "एक प्रॉम्प्ट पाठवून आणि त्यावर स्टार करून सुरुवात करा किंवा मागील चॅटमधून प्रॉम्प्टवर स्टार करा" } }, "commands": { "button": "साधने", "changeTool": "साधन बदला", "availableTools": "उपलब्ध साधने" }, "fileUpload": { "dragDrop": "फाइल्स येथे ड्रॅग आणि ड्रॉप करा", "browse": "फाइल्स ब्राउझ करा", "sizeLimit": "मर्यादा:", "errors": { "failed": "अपलोड अयशस्वी", "cancelled": "यांचे अपलोड रद्द केले" }, "actions": { "cancelUpload": "अपलोड रद्द करा", "removeAttachment": "अटॅचमेंट काढा" } }, "messages": { "status": { "using": "वापरत आहे", "used": "वापरले" }, "actions": { "copy": { "button": "क्लिपबोर्डवर कॉपी करा", "success": "कॉपी केले!" } }, "feedback": { "positive": "उपयुक्त", "negative": "उपयुक्त नाही", "edit": "फीडबॅक संपादित करा", "dialog": { "title": "टिप्पणी जोडा", "submit": "फीडबॅक सबमिट करा", "yourFeedback": "तुमची प्रतिक्रिया..." }, "status": { "updating": "अपडेट करत आहे", "updated": "फीडबॅक अपडेट केले" } } }, "history": { "title": "शेवटचे इनपुट", "empty": "रिकामे आहे...", "show": "इतिहास दाखवा" }, "settings": { "title": "सेटिंग्ज पॅनल", "customize": "या वेळी तुमच्या चॅट सेटिंग्ज कस्टमाइझ करा" }, "watermark": "LLM चुका करू शकतात. महत्त्वाची माहिती तपासण्याचा विचार करा." }, "threadHistory": { "sidebar": { "title": "मागील चॅट्स", "filters": { "search": "शोधा", "placeholder": "Search conversations..." }, "timeframes": { "today": "आज", "yesterday": "काल", "previous7days": "मागील 7 दिवस", "previous30days": "मागील 30 दिवस" }, "empty": "कोणतेही थ्रेड सापडले नाहीत", "actions": { "close": "साइडबार बंद करा", "open": "साइडबार उघडा" } }, "thread": { "untitled": "शीर्षकविरहित संभाषण", "menu": { "rename": "नाव बदला", "share": "शेअर करा", "delete": "हटवा" }, "actions": { "share": { "title": "चॅटचा दुवा शेअर करा", "button": "शेअर करा", "status": { "copied": "दुवा कॉपी केला", "created": "शेअर दुवा तयार झाला!", "unshared": "या थ्रेडसाठी शेअरिंग अक्षम केले" }, "error": { "create": "शेअर दुवा तयार करण्यात अपयश", "unshare": "थ्रेडचे शेअरिंग थांबवण्यात अपयश" } }, "delete": { "title": "हटविण्याची पुष्टी करा", "description": "हे थ्रेड आणि त्याचे संदेश व घटक हटवेल. ही क्रिया पूर्ववत केली जाऊ शकत नाही", "success": "चॅट हटवला", "inProgress": "चॅट हटवत आहे" }, "rename": { "title": "थ्रेडचे नाव बदला", "description": "या थ्रेडसाठी नवीन नाव प्रविष्ट करा", "form": { "name": { "label": "नाव", "placeholder": "नवीन नाव प्रविष्ट करा" } }, "success": "थ्रेडचे नाव बदलले!", "inProgress": "थ्रेडचे नाव बदलत आहे" } } } }, "navigation": { "header": { "chat": "चॅट", "readme": "वाचा", "theme": { "light": "Light Theme", "dark": "Dark Theme", "system": "Follow System" } }, "newChat": { "button": "नवीन चॅट", "dialog": { "title": "नवीन चॅट तयार करा", "description": "हे तुमचा सध्याचा चॅट इतिहास साफ करेल. तुम्हाला खात्री आहे की तुम्ही पुढे जाऊ इच्छिता?", "tooltip": "नवीन चॅट" } }, "user": { "menu": { "settings": "सेटिंग्ज", "settingsKey": "S", "apiKeys": "API कीज", "logout": "लॉगआउट" } } }, "apiKeys": { "title": "आवश्यक API कीज", "description": "हे अॅप वापरण्यासाठी खालील API कीज आवश्यक आहेत. कीज तुमच्या डिव्हाइसच्या लोकल स्टोरेजमध्ये साठवल्या जातात.", "success": { "saved": "यशस्वीरित्या जतन केले" } }, "alerts": { "info": "Info", "note": "Note", "tip": "Tip", "important": "Important", "warning": "Warning", "caution": "Caution", "debug": "Debug", "example": "Example", "success": "Success", "help": "Help", "idea": "Idea", "pending": "Pending", "security": "Security", "beta": "Beta", "best-practice": "Best Practice" }, "components": { "MultiSelectInput": { "placeholder": "चुनें..." }, "DatePickerInput": { "placeholder": { "single": "तारीख निवडा", "range": "तारीख श्रेणी निवडा" } } } } ================================================ FILE: backend/chainlit/translations/nl.json ================================================ { "common": { "actions": { "cancel": "Annuleren", "confirm": "Bevestigen", "continue": "Doorgaan", "goBack": "Terug", "reset": "Herstellen", "submit": "Versturen" }, "status": { "loading": "Laden...", "error": { "default": "Er is een fout opgetreden", "serverConnection": "Kon geen verbinding maken met de server" } } }, "auth": { "login": { "title": "Inloggen om toegang te krijgen tot de app", "form": { "email": { "label": "E-mailadres", "required": "e-mail is een verplicht veld", "placeholder": "me@example.com" }, "password": { "label": "Wachtwoord", "required": "wachtwoord is een verplicht veld" }, "actions": { "signin": "Inloggen" }, "alternativeText": { "or": "OF" } }, "errors": { "default": "Kan niet inloggen", "signin": "Probeer in te loggen met een ander account", "oauthSignin": "Probeer in te loggen met een ander account", "redirectUriMismatch": "De redirect URI komt niet overeen met de oauth app configuratie", "oauthCallback": "Probeer in te loggen met een ander account", "oauthCreateAccount": "Probeer in te loggen met een ander account", "emailCreateAccount": "Probeer in te loggen met een ander account", "callback": "Probeer in te loggen met een ander account", "oauthAccountNotLinked": "Om je identiteit te bevestigen, log in met hetzelfde account dat je oorspronkelijk hebt gebruikt", "emailSignin": "De e-mail kon niet worden verzonden", "emailVerify": "Verifieer je e-mail, er is een nieuwe e-mail verzonden", "credentialsSignin": "Inloggen mislukt. Controleer of de ingevoerde gegevens correct zijn", "sessionRequired": "Log in om toegang te krijgen tot deze pagina" } }, "provider": { "continue": "Doorgaan met {{provider}}" } }, "chat": { "input": { "placeholder": "Typ hier je bericht...", "actions": { "send": "Bericht versturen", "stop": "Taak stoppen", "attachFiles": "Bestanden bijvoegen" } }, "speech": { "start": "Start opname", "stop": "Stop opname", "connecting": "Verbinden" }, "fileUpload": { "dragDrop": "Sleep bestanden hierheen", "browse": "Bestanden zoeken", "sizeLimit": "Limiet:", "errors": { "failed": "Uploaden mislukt", "cancelled": "Upload geannuleerd van" }, "actions": { "cancelUpload": "Annuleer upload", "removeAttachment": "Verwijder bijlage" } }, "favorites": { "use": "Gebruik een favoriet bericht", "headline": "Favoriete berichten", "remove": "Verwijder favoriet", "empty": { "title": "Nog geen opgeslagen prompts", "description": "Begin door een prompt te versturen en voeg deze toe aan favorieten of voeg een prompt uit eerdere chats toe" } }, "commands": { "button": "Hulpmiddelen", "changeTool": "Wijzig hulpmiddel", "availableTools": "Beschikbare hulpmiddelen" }, "messages": { "status": { "using": "In gebruik", "used": "Gebruikt" }, "actions": { "copy": { "button": "Kopiëren naar klembord", "success": "Gekopieerd!" } }, "feedback": { "positive": "Nuttig", "negative": "Niet nuttig", "edit": "Feedback bewerken", "dialog": { "title": "Voeg een opmerking toe", "submit": "Feedback versturen", "yourFeedback": "Je feedback..." }, "status": { "updating": "Bijwerken", "updated": "Feedback bijgewerkt" } } }, "history": { "title": "Laatste invoer", "empty": "Zo leeg...", "show": "Toon geschiedenis" }, "settings": { "title": "Instellingenpaneel", "customize": "Pas hier je chatinstellingen aan" }, "watermark": "LLM's kunnen fouten maken. Overweeg het controleren van belangrijke informatie." }, "threadHistory": { "sidebar": { "title": "Eerdere chats", "filters": { "search": "Zoeken", "placeholder": "Search conversations..." }, "timeframes": { "today": "Vandaag", "yesterday": "Gisteren", "previous7days": "Afgelopen 7 dagen", "previous30days": "Afgelopen 30 dagen" }, "empty": "Geen gesprekken gevonden", "actions": { "close": "Zijbalk sluiten", "open": "Zijbalk openen" } }, "thread": { "untitled": "Naamloos gesprek", "menu": { "rename": "Hernoemen", "share": "Delen", "delete": "Verwijderen" }, "actions": { "share": { "title": "Deel link naar chat", "button": "Delen", "status": { "copied": "Link gekopieerd", "created": "Deellink gemaakt!", "unshared": "Delen uitgeschakeld voor dit gesprek" }, "error": { "create": "Aanmaken van deellink mislukt", "unshare": "Delen van gesprek stoppen mislukt" } }, "delete": { "title": "Verwijdering bevestigen", "description": "Dit zal het gesprek en bijbehorende berichten en elementen verwijderen. Deze actie kan niet ongedaan worden gemaakt", "success": "Chat verwijderd", "inProgress": "Chat verwijderen" }, "rename": { "title": "Gesprek hernoemen", "description": "Voer een nieuwe naam in voor dit gesprek", "form": { "name": { "label": "Naam", "placeholder": "Voer nieuwe naam in" } }, "success": "Gesprek hernoemd!", "inProgress": "Gesprek hernoemen" } } } }, "navigation": { "header": { "chat": "Chat", "readme": "Leesmij", "theme": { "light": "Light Theme", "dark": "Dark Theme", "system": "Follow System" } }, "newChat": { "button": "Nieuwe chat", "dialog": { "title": "Nieuwe chat aanmaken", "description": "Dit zal je huidige chatgeschiedenis wissen. Weet je zeker dat je door wilt gaan?", "tooltip": "Nieuwe chat" } }, "user": { "menu": { "settings": "Instellingen", "settingsKey": "I", "apiKeys": "API-sleutels", "logout": "Uitloggen" } } }, "apiKeys": { "title": "Vereiste API-sleutels", "description": "Om deze app te gebruiken zijn de volgende API-sleutels vereist. De sleutels worden opgeslagen in de lokale opslag van je apparaat.", "success": { "saved": "Succesvol opgeslagen" } }, "alerts": { "info": "Info", "note": "Note", "tip": "Tip", "important": "Important", "warning": "Warning", "caution": "Caution", "debug": "Debug", "example": "Example", "success": "Success", "help": "Help", "idea": "Idea", "pending": "Pending", "security": "Security", "beta": "Beta", "best-practice": "Best Practice" }, "components": { "MultiSelectInput": { "placeholder": "Selecteer..." }, "DatePickerInput": { "placeholder": { "single": "Kies een datum", "range": "Kies een datumbereik" } } } } ================================================ FILE: backend/chainlit/translations/ta.json ================================================ { "common": { "actions": { "cancel": "ரத்து செய்", "confirm": "உறுதிப்படுத்து", "continue": "தொடர்க", "goBack": "திரும்பிச் செல்", "reset": "மீட்டமை", "submit": "சமர்ப்பி" }, "status": { "loading": "ஏற்றுகிறது...", "error": { "default": "பிழை ஏற்பட்டது", "serverConnection": "சேவையகத்தை அடைய முடியவில்லை" } } }, "auth": { "login": { "title": "பயன்பாட்டை அணுக உள்நுழையவும்", "form": { "email": { "label": "மின்னஞ்சல் முகவரி", "required": "மின்னஞ்சல் தேவையான புலம்", "placeholder": "me@example.com" }, "password": { "label": "கடவுச்சொல்", "required": "கடவுச்சொல் தேவையான புலம்" }, "actions": { "signin": "உள்நுழைக" }, "alternativeText": { "or": "அல்லது" } }, "errors": { "default": "உள்நுழைய முடியவில்லை", "signin": "வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்", "oauthSignin": "வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்", "redirectUriMismatch": "திசைதிருப்பல் URI ஓஆத் பயன்பாட்டு கட்டமைப்புடன் பொருந்தவில்லை", "oauthCallback": "வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்", "oauthCreateAccount": "வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்", "emailCreateAccount": "வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்", "callback": "வேறு கணக்குடன் உள்நுழைய முயற்சிக்கவும்", "oauthAccountNotLinked": "உங்கள் அடையாளத்தை உறுதிப்படுத்த, முதலில் பயன்படுத்திய அதே கணக்குடன் உள்நுழையவும்", "emailSignin": "மின்னஞ்சலை அனுப்ப முடியவில்லை", "emailVerify": "உங்கள் மின்னஞ்சலை சரிபார்க்கவும், புதிய மின்னஞ்சல் அனுப்பப்பட்டுள்ளது", "credentialsSignin": "உள்நுழைவு தோல்வியடைந்தது. நீங்கள் வழங்கிய விவரங்கள் சரியானவை என சரிபார்க்கவும்", "sessionRequired": "இந்தப் பக்கத்தை அணுக உள்நுழையவும்" } }, "provider": { "continue": "{{provider}} மூலம் தொடரவும்" } }, "chat": { "input": { "placeholder": "உங்கள் செய்தியை இங்கே தட்டச்சு செய்யவும்...", "actions": { "send": "செய்தி அனுப்பு", "stop": "பணியை நிறுத்து", "attachFiles": "கோப்புகளை இணை" } }, "favorites": { "use": "விருப்பமான செய்தியைப் பயன்படுத்தவும்", "headline": "விருப்பமான செய்திகள்", "remove": "பிடித்ததை நீக்கு", "empty": { "title": "இன்னும் சேமிக்கப்பட்ட ப்ராம்ப்ட்கள் இல்லை", "description": "ஒரு ப்ராம்ப்ட் அனுப்பி அதை ஸ்டார் செய்வதன் மூலம் தொடங்கவும் அல்லது முந்தைய அரட்டைகளில் இருந்து ஒரு ப்ராம்ப்ட்டை ஸ்டார் செய்யவும்" } }, "commands": { "button": "கருவிகள்", "changeTool": "கருவியை மாற்றவும்", "availableTools": "கிடைக்கும் கருவிகள்" }, "speech": { "start": "பதிவு தொடங்கு", "stop": "பதிவை நிறுத்து", "connecting": "இணைக்கிறது" }, "fileUpload": { "dragDrop": "கோப்புகளை இங்கே இழுத்து விடவும்", "browse": "கோப்புகளை உலாவு", "sizeLimit": "வரம்பு:", "errors": { "failed": "பதிவேற்றம் தோல்வியடைந்தது", "cancelled": "பதிவேற்றம் ரத்து செய்யப்பட்டது" }, "actions": { "cancelUpload": "ரத்து செய்", "removeAttachment": "இணைப்பை அகற்று" } }, "messages": { "status": { "using": "பயன்படுத்துகிறது", "used": "பயன்படுத்தப்பட்டது" }, "actions": { "copy": { "button": "கிளிப்போர்டுக்கு நகலெடு", "success": "நகலெடுக்கப்பட்டது!" } }, "feedback": { "positive": "பயனுள்ளதாக இருந்தது", "negative": "பயனுள்ளதாக இல்லை", "edit": "கருத்தை திருத்து", "dialog": { "title": "கருத்தைச் சேர்", "submit": "கருத்தை சமர்ப்பி", "yourFeedback": "உங்கள் கருத்து..." }, "status": { "updating": "புதுப்பிக்கிறது", "updated": "கருத்து புதுப்பிக்கப்பட்டது" } } }, "history": { "title": "கடைசி உள்ளீடுகள்", "empty": "காலியாக உள்ளது...", "show": "வரலாற்றைக் காட்டு" }, "settings": { "title": "அமைப்புகள் பலகம்", "customize": "உங்கள் உரையாடல் அமைப்புகளை இங்கே தனிப்பயனாக்கவும்" }, "watermark": "LLM கள் தவறுகள் செய்யலாம். முக்கியமான தகவல்களைச் சரிபார்ப்பதைக் கருத்தில் கொள்ளுங்கள்." }, "threadHistory": { "sidebar": { "title": "கடந்த உரையாடல்கள்", "filters": { "search": "தேடு", "placeholder": "Search conversations..." }, "timeframes": { "today": "இன்று", "yesterday": "நேற்று", "previous7days": "கடந்த 7 நாட்கள்", "previous30days": "கடந்த 30 நாட்கள்" }, "empty": "உரையாடல்கள் எதுவும் இல்லை", "actions": { "close": "பக்கப்பட்டியை மூடு", "open": "பக்கப்பட்டியை திற" } }, "thread": { "untitled": "தலைப்பிடாத உரையாடல்", "menu": { "rename": "பெயர் மாற்று", "share": "பகிர்", "delete": "அழி" }, "actions": { "share": { "title": "உரையாடல் இணைப்பை பகிரவும்", "button": "பகிர்", "status": { "copied": "இணைப்பு நகலெடுக்கப்பட்டது", "created": "பகிர்வு இணைப்பு உருவாக்கப்பட்டது!", "unshared": "இந்த உரையாடலுக்கு பகிர்வு முடக்கப்பட்டது" }, "error": { "create": "பகிர்வு இணைப்பை உருவாக்க முடியவில்லை", "unshare": "உரையாடல் பகிர்வை நிறுத்த முடியவில்லை" } }, "delete": { "title": "நீக்குவதை உறுதிப்படுத்து", "description": "இது உரையாடல் மற்றும் அதன் செய்திகள், உறுப்புகளை நீக்கும். இந்த செயலை மீட்டெடுக்க முடியாது", "success": "உரையாடல் நீக்கப்பட்டது", "inProgress": "உரையாடலை நீக்குகிறது" }, "rename": { "title": "உரையாடலை மறுபெயரிடு", "description": "இந்த உரையாடலுக்கு புதிய பெயரை உள்ளிடவும்", "form": { "name": { "label": "பெயர்", "placeholder": "புதிய பெயரை உள்ளிடவும்" } }, "success": "உரையாடல் மறுபெயரிடப்பட்டது!", "inProgress": "உரையாடலை மறுபெயரிடுகிறது" } } } }, "navigation": { "header": { "chat": "உரையாடல்", "readme": "படிக்கவும்", "theme": { "light": "Light Theme", "dark": "Dark Theme", "system": "Follow System" } }, "newChat": { "button": "புதிய உரையாடல்", "dialog": { "title": "புதிய உரையாடலை உருவாக்கு", "description": "இது உங்கள் தற்போதைய உரையாடல் வரலாற்றை அழிக்கும். தொடர விரும்புகிறீர்களா?", "tooltip": "புதிய உரையாடல்" } }, "user": { "menu": { "settings": "அமைப்புகள்", "settingsKey": "S", "apiKeys": "API விசைகள்", "logout": "வெளியேறு" } } }, "apiKeys": { "title": "தேவையான API விசைகள்", "description": "இந்த பயன்பாட்டைப் பயன்படுத்த, பின்வரும் API விசைகள் தேவை. விசைகள் உங்கள் சாதனத்தின் உள்ளூர் சேமிப்பகத்தில் சேமிக்கப்படும்.", "success": { "saved": "வெற்றிகரமாக சேமிக்கப்பட்டது" } }, "alerts": { "info": "Info", "note": "Note", "tip": "Tip", "important": "Important", "warning": "Warning", "caution": "Caution", "debug": "Debug", "example": "Example", "success": "Success", "help": "Help", "idea": "Idea", "pending": "Pending", "security": "Security", "beta": "Beta", "best-practice": "Best Practice" }, "components": { "MultiSelectInput": { "placeholder": "தேர்ந்தெடுக்கவும்..." }, "DatePickerInput": { "placeholder": { "single": "தேதியைத் தேர்ந்தெடுக்கவும்", "range": "தேதி வரம்பைத் தேர்ந்தெடுக்கவும்" } } } } ================================================ FILE: backend/chainlit/translations/te.json ================================================ { "common": { "actions": { "cancel": "రద్దు చేయండి", "confirm": "నిర్ధారించండి", "continue": "కొనసాగించండి", "goBack": "వెనక్కి వెళ్ళండి", "reset": "రీసెట్ చేయండి", "submit": "సమర్పించండి" }, "status": { "loading": "లోడ్ అవుతోంది...", "error": { "default": "లోపం సంభవించింది", "serverConnection": "సర్వర్‌ని చేరుకోలేకపోయాము" } } }, "auth": { "login": { "title": "యాప్‌ని ఉపయోగించడానికి లాగిన్ చేయండి", "form": { "email": { "label": "ఇమెయిల్ చిరునామా", "required": "ఇమెయిల్ తప్పనిసరి", "placeholder": "me@example.com" }, "password": { "label": "పాస్‌వర్డ్", "required": "పాస్‌వర్డ్ తప్పనిసరి" }, "actions": { "signin": "సైన్ ఇన్ చేయండి" }, "alternativeText": { "or": "లేదా" } }, "errors": { "default": "సైన్ ఇన్ చేయలేకపోయాము", "signin": "వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి", "oauthSignin": "వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి", "redirectUriMismatch": "రీడైరెక్ట్ URI oauth యాప్ కాన్ఫిగరేషన్‌తో సరిపోలడం లేదు", "oauthCallback": "వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి", "oauthCreateAccount": "వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి", "emailCreateAccount": "వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి", "callback": "వేరే ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి", "oauthAccountNotLinked": "మీ గుర్తింపును నిర్ధారించడానికి, మీరు మొదట ఉపయోగించిన అదే ఖాతాతో సైన్ ఇన్ చేయండి", "emailSignin": "ఇమెయిల్ పంపడం సాధ్యం కాలేదు", "emailVerify": "దయచేసి మీ ఇమెయిల్‌ని ధృవీకరించండి, కొత్త ఇమెయిల్ పంపబడింది", "credentialsSignin": "సైన్ ఇన్ విఫలమైంది. మీరు అందించిన వివరాలు సరైనవేనా అని తనిఖీ చేయండి", "sessionRequired": "ఈ పేజీని యాక్సెస్ చేయడానికి దయచేసి సైన్ ఇన్ చేయండి" } }, "provider": { "continue": "{{provider}}తో కొనసాగించండి" } }, "chat": { "input": { "placeholder": "మీ సందేశాన్ని ఇక్కడ టైప్ చేయండి...", "actions": { "send": "సందేశం పంపండి", "stop": "పని ఆపండి", "attachFiles": "ఫైల్స్ జోడించండి" } }, "speech": { "start": "రికార్డింగ్ ప్రారంభించండి", "stop": "రికార్డింగ్ ఆపండి", "connecting": "అనుసంధానిస్తోంది" }, "favorites": { "use": "ఇష్టమైన సందేశాన్ని ఉపయోగించండి", "headline": "ఇష్టమైన సందేశాలు", "remove": "ఇష్టమైనదాన్ని తొలగించండి", "empty": { "title": "ఇంకా ప్రాంప్ట్‌లు సేవ్ చేయలేదు", "description": "ఒక ప్రాంప్ట్ పంపి దానికి స్టార్ చేయడం ద్వారా ప్రారంభించండి లేదా మునుపటి చాట్‌ల నుండి ప్రాంప్ట్‌కు స్టార్ చేయండి" } }, "commands": { "button": "పరికరాలు", "changeTool": "పరికరాన్ని మార్చండి", "availableTools": "లభ్యమైన పరికరాలు" }, "fileUpload": { "dragDrop": "ఫైల్స్‌ని ఇక్కడ డ్రాగ్ చేసి డ్రాప్ చేయండి", "browse": "ఫైల్స్ బ్రౌజ్ చేయండి", "sizeLimit": "పరిమితి:", "errors": { "failed": "అప్‌లోడ్ విఫలమైంది", "cancelled": "అప్‌లోడ్ రద్దు చేయబడింది" }, "actions": { "cancelUpload": "రద్దు చేయండి", "removeAttachment": "అనుబంధాన్ని తొలగించండి" } }, "messages": { "status": { "using": "ఉపయోగిస్తోంది", "used": "ఉపయోగించబడింది" }, "actions": { "copy": { "button": "క్లిప్‌బోర్డ్‌కి కాపీ చేయండి", "success": "కాపీ చేయబడింది!" } }, "feedback": { "positive": "సహాయకరం", "negative": "సహాయకరం కాదు", "edit": "అభిప్రాయాన్ని సవరించండి", "dialog": { "title": "వ్యాఖ్య జోడించండి", "submit": "అభిప్రాయాన్ని సమర్పించండి", "yourFeedback": "మీ అభిప్రాయం..." }, "status": { "updating": "నవీకరిస్తోంది", "updated": "అభిప్రాయం నవీకరించబడింది" } } }, "history": { "title": "చివరి ఇన్‌పుట్‌లు", "empty": "ఖాళీగా ఉంది...", "show": "చరిత్రను చూపించు" }, "settings": { "title": "సెట్టింగ్‌ల ప్యానెల్", "customize": "మీ చాట్ సెట్టింగ్‌లను ఇక్కడ అనుకూలీకరించండి" }, "watermark": "LLMలు తప్పులు చేయవచ్చు. ముఖ్యమైన సమాచారాన్ని తనిఖీ చేయడాన్ని పరిగణించండి." }, "threadHistory": { "sidebar": { "title": "గత చాట్‌లు", "filters": { "search": "వెతకండి", "placeholder": "Search conversations..." }, "timeframes": { "today": "ఈరోజు", "yesterday": "నిన్న", "previous7days": "గత 7 రోజులు", "previous30days": "గత 30 రోజులు" }, "empty": "థ్రెడ్‌లు కనుగొనబడలేదు", "actions": { "close": "సైడ్‌బార్ మూసివేయండి", "open": "సైడ్‌బార్ తెరవండి" } }, "thread": { "untitled": "పేరు లేని సంభాషణ", "menu": { "rename": "పేరు మార్చండి", "share": "షేర్ చేయండి", "delete": "తొలగించండి" }, "actions": { "share": { "title": "చాట్ లింక్‌ను షేర్ చేయండి", "button": "షేర్ చేయండి", "status": { "copied": "లింక్ కాపీ చేయబడింది", "created": "షేర్ లింక్ సృష్టించబడింది!", "unshared": "ఈ థ్రెడ్‌కు షేరింగ్ ఆపివేయబడింది" }, "error": { "create": "షేర్ లింక్ సృష్టించడం విఫలమైంది", "unshare": "థ్రెడ్ షేరింగ్ నిలిపివేయడం విఫలమైంది" } }, "delete": { "title": "తొలగింపును నిర్ధారించండి", "description": "ఇది థ్రెడ్‌తో పాటు దాని సందేశాలను మరియు అంశాలను తొలగిస్తుంది. ఈ చర్యను రద్దు చేయలేరు", "success": "చాట్ తొలగించబడింది", "inProgress": "చాట్‌ని తొలగిస్తోంది" }, "rename": { "title": "థ్రెడ్ పేరు మార్చండి", "description": "ఈ థ్రెడ్ కోసం కొత్త పేరును నమోదు చేయండి", "form": { "name": { "label": "పేరు", "placeholder": "కొత్త పేరును నమోదు చేయండి" } }, "success": "థ్రెడ్ పేరు మార్చబడింది!", "inProgress": "థ్రెడ్ పేరు మారుస్తోంది" } } } }, "navigation": { "header": { "chat": "చాట్", "readme": "చదవండి", "theme": { "light": "Light Theme", "dark": "Dark Theme", "system": "Follow System" } }, "newChat": { "button": "కొత్త చాట్", "dialog": { "title": "కొత్త చాట్ సృష్టించండి", "description": "ఇది మీ ప్రస్తుత చాట్ చరిత్రను తుడిచివేస్తుంది. మీరు కొనసాగించాలనుకుంటున్నారా?", "tooltip": "కొత్త చాట్" } }, "user": { "menu": { "settings": "సెట్టింగ్‌లు", "settingsKey": "S", "apiKeys": "API కీలు", "logout": "లాగ్ అవుట్" } } }, "apiKeys": { "title": "అవసరమైన API కీలు", "description": "ఈ యాప్‌ని ఉపయోగించడానికి, కింది API కీలు అవసరం. కీలు మీ పరికరం యొక్క స్థానిక నిల్వలో నిల్వ చేయబడతాయి.", "success": { "saved": "విజయవంతంగా సేవ్ చేయబడింది" } }, "alerts": { "info": "Info", "note": "Note", "tip": "Tip", "important": "Important", "warning": "Warning", "caution": "Caution", "debug": "Debug", "example": "Example", "success": "Success", "help": "Help", "idea": "Idea", "pending": "Pending", "security": "Security", "beta": "Beta", "best-practice": "Best Practice" }, "components": { "MultiSelectInput": { "placeholder": "ఎంచుకోండి..." }, "DatePickerInput": { "placeholder": { "single": "తేదీని ఎంచుకోండి", "range": "తేదీ పరిధిని ఎంచుకోండి" } } } } ================================================ FILE: backend/chainlit/translations/zh-CN.json ================================================ { "common": { "actions": { "cancel": "取消", "confirm": "确认", "continue": "继续", "goBack": "返回", "reset": "重置", "submit": "提交" }, "status": { "loading": "加载中...", "error": { "default": "发生错误", "serverConnection": "无法连接到服务器" } } }, "auth": { "login": { "title": "登录以访问应用", "form": { "email": { "label": "电子邮箱", "required": "邮箱是必填项", "placeholder": "me@example.com" }, "password": { "label": "密码", "required": "密码是必填项" }, "actions": { "signin": "登录" }, "alternativeText": { "or": "或" } }, "errors": { "default": "无法登录", "signin": "请尝试使用其他账号登录", "oauthSignin": "请尝试使用其他账号登录", "redirectUriMismatch": "重定向URI与OAuth应用配置不匹配", "oauthCallback": "请尝试使用其他账号登录", "oauthCreateAccount": "请尝试使用其他账号登录", "emailCreateAccount": "请尝试使用其他账号登录", "callback": "请尝试使用其他账号登录", "oauthAccountNotLinked": "为确认您的身份,请使用原始账号登录", "emailSignin": "邮件发送失败", "emailVerify": "请验证您的邮箱,新的验证邮件已发送", "credentialsSignin": "登录失败。请检查您提供的信息是否正确", "sessionRequired": "请登录以访问此页面" } }, "provider": { "continue": "继续使用{{provider}}" } }, "chat": { "input": { "placeholder": "在此输入您的消息...", "actions": { "send": "发送消息", "stop": "停止任务", "attachFiles": "附加文件" } }, "speech": { "start": "开始录音", "stop": "停止录音", "connecting": "连接中" }, "fileUpload": { "dragDrop": "将文件拖放到这里", "browse": "浏览文件", "sizeLimit": "限制:", "errors": { "failed": "上传失败", "cancelled": "已取消上传" }, "actions": { "cancelUpload": "取消上传", "removeAttachment": "移除附件" } }, "favorites": { "use": "使用收藏的消息", "headline": "收藏的消息", "remove": "移除收藏", "empty": { "title": "尚未保存的提示", "description": "从发送提示并加星标开始,或从之前的聊天中加星标提示" } }, "commands": { "button": "工具", "changeTool": "更换工具", "availableTools": "可用工具" }, "messages": { "status": { "using": "使用中", "used": "已使用" }, "actions": { "copy": { "button": "复制到剪贴板", "success": "已复制!" } }, "feedback": { "positive": "有帮助", "negative": "没有帮助", "edit": "编辑反馈", "dialog": { "title": "添加评论", "submit": "提交反馈", "yourFeedback": "您的反馈..." }, "status": { "updating": "更新中", "updated": "反馈已更新" } } }, "history": { "title": "最近输入", "empty": "空空如也...", "show": "显示历史" }, "settings": { "title": "设置面板", "customize": "在此自定义您的聊天设置" }, "watermark": "大语言模型可能会犯错。请核实重要信息。" }, "threadHistory": { "sidebar": { "title": "历史对话", "filters": { "search": "搜索", "placeholder": "搜索会话..." }, "timeframes": { "today": "今天", "yesterday": "昨天", "previous7days": "过去7天", "previous30days": "过去30天" }, "empty": "未找到对话", "actions": { "close": "关闭侧边栏", "open": "打开侧边栏" } }, "thread": { "untitled": "未命名对话", "menu": { "rename": "重命名", "share": "分享", "delete": "删除" }, "actions": { "share": { "title": "分享聊天链接", "button": "分享", "status": { "copied": "链接已复制", "created": "分享链接已创建!", "unshared": "已禁用此对话的分享" }, "error": { "create": "创建分享链接失败", "unshare": "取消对话分享失败" } }, "delete": { "title": "确认删除", "description": "这将删除该对话及其所有消息和元素。此操作无法撤销", "success": "对话已删除", "inProgress": "正在删除对话" }, "rename": { "title": "重命名对话", "description": "为此对话输入新名称", "form": { "name": { "label": "名称", "placeholder": "输入新名称" } }, "success": "对话已重命名!", "inProgress": "正在重命名对话" } } } }, "navigation": { "header": { "chat": "聊天", "readme": "说明", "theme": { "light": "浅色主题", "dark": "深色主题", "system": "跟随系统" } }, "newChat": { "button": "新建对话", "dialog": { "title": "创建新对话", "description": "这将清除您当前的聊天记录。确定要继续吗?", "tooltip": "新建对话" } }, "user": { "menu": { "settings": "设置", "settingsKey": "S", "apiKeys": "API密钥", "logout": "退出登录" } } }, "apiKeys": { "title": "所需API密钥", "description": "使用此应用需要以下API密钥。这些密钥存储在您设备的本地存储中。", "success": { "saved": "保存成功" } }, "alerts": { "info": "信息", "note": "注释", "tip": "提示", "important": "重要", "warning": "警告", "caution": "注意", "debug": "调试", "example": "示例", "success": "成功", "help": "帮助", "idea": "想法", "pending": "待处理", "security": "安全", "beta": "测试", "best-practice": "最佳实践" }, "components": { "MultiSelectInput": { "placeholder": "选择..." }, "DatePickerInput": { "placeholder": { "single": "选择日期", "range": "选择日期范围" } } } } ================================================ FILE: backend/chainlit/translations/zh-TW.json ================================================ { "common": { "actions": { "cancel": "取消", "confirm": "確認", "continue": "繼續", "goBack": "返回", "reset": "重設", "submit": "送出" }, "status": { "loading": "載入中...", "error": { "default": "發生錯誤", "serverConnection": "無法連線到伺服器" } } }, "auth": { "login": { "title": "登入以存取應用程式", "form": { "email": { "label": "電子信箱", "required": "信箱是必填項目", "placeholder": "me@example.com" }, "password": { "label": "密碼", "required": "密碼是必填項目" }, "actions": { "signin": "登入" }, "alternativeText": { "or": "或" } }, "errors": { "default": "無法登入", "signin": "請嘗試使用其它帳號登入", "oauthSignin": "請嘗試使用其它帳號登入", "redirectUriMismatch": "重新導向URI與OAuth App設定不相符", "oauthCallback": "請嘗試使用其它帳號登入", "oauthCreateAccount": "請嘗試使用其它帳號登入", "emailCreateAccount": "請嘗試使用其它帳號登入", "callback": "請嘗試使用其它帳號登入", "oauthAccountNotLinked": "為確認您的身份,請以原本使用的帳號登入", "emailSignin": "電子郵件發送失敗", "emailVerify": "請驗證您的電子信箱,新的驗證郵件已發送", "credentialsSignin": "登入失敗。請檢查您提供的資訊是否正確", "sessionRequired": "請登入以存取此頁面" } }, "provider": { "continue": "繼續使用{{provider}}" } }, "chat": { "input": { "placeholder": "在此輸入您的訊息...", "actions": { "send": "發送訊息", "stop": "停止任務", "attachFiles": "附加檔案" } }, "speech": { "start": "開始錄音", "stop": "停止錄音", "connecting": "連線中" }, "fileUpload": { "dragDrop": "拖曳檔案到這裡", "browse": "瀏覽檔案", "sizeLimit": "限制:", "errors": { "failed": "上傳失敗", "cancelled": "已取消上傳" }, "actions": { "cancelUpload": "取消上傳", "removeAttachment": "移除附件" } }, "favorites": { "use": "使用收藏的訊息", "headline": "收藏的訊息", "remove": "移除收藏", "empty": { "title": "尚未儲存的提示", "description": "從發送提示並加星號開始,或從之前的聊天中加星號提示" } }, "commands": { "button": "工具", "changeTool": "更換工具", "availableTools": "可用工具" }, "messages": { "status": { "using": "正在使用", "used": "已使用" }, "actions": { "copy": { "button": "複製到剪貼簿", "success": "已複製!" } }, "feedback": { "positive": "有幫助", "negative": "沒有幫助", "edit": "編輯回饋", "dialog": { "title": "新增評論", "submit": "送出回饋", "yourFeedback": "您的回饋..." }, "status": { "updating": "更新中", "updated": "回饋已更新" } } }, "history": { "title": "最近輸入", "empty": "空空如也...", "show": "顯示歷史" }, "settings": { "title": "設定面板", "customize": "在此自定義您的聊天設定" }, "watermark": "大型語言模型可能會犯錯。請核實重要資訊。" }, "threadHistory": { "sidebar": { "title": "歷史對話", "filters": { "search": "搜尋", "placeholder": "搜尋對話..." }, "timeframes": { "today": "今天", "yesterday": "昨天", "previous7days": "過去7天", "previous30days": "過去30天" }, "empty": "未找到對話", "actions": { "close": "關閉側邊欄", "open": "打開側邊欄" } }, "thread": { "untitled": "未命名對話", "menu": { "rename": "重新命名", "share": "分享", "delete": "刪除" }, "actions": { "share": { "title": "分享聊天連結", "button": "分享", "status": { "copied": "連結已複製", "created": "分享連結已建立!", "unshared": "已停用此對話的分享" }, "error": { "create": "建立分享連結失敗", "unshare": "取消對話分享失敗" } }, "delete": { "title": "確認刪除", "description": "這將刪除該對話及其所有訊息和元件。此操作無法復原。", "success": "對話已刪除", "inProgress": "正在刪除對話" }, "rename": { "title": "重新命名對話", "description": "為此對話輸入新名稱", "form": { "name": { "label": "名稱", "placeholder": "輸入新名稱" } }, "success": "對話已重新命名!", "inProgress": "正在重新命名對話" } } } }, "navigation": { "header": { "chat": "聊天", "readme": "說明", "theme": { "light": "淺色主題", "dark": "深色主題", "system": "跟隨系統" } }, "newChat": { "button": "新建對話", "dialog": { "title": "創建新對話", "description": "這將清除您當前的聊天記錄。確定要繼續嗎?", "tooltip": "新建對話" } }, "user": { "menu": { "settings": "設定", "settingsKey": "S", "apiKeys": "API金鑰", "logout": "登出" } } }, "apiKeys": { "title": "所需API金鑰", "description": "使用此應用程式需要以下API金鑰。這些金鑰儲存在您設備的本地儲存空間中。", "success": { "saved": "儲存成功" } }, "alerts": { "info": "資訊", "note": "注釋", "tip": "提示", "important": "重要", "warning": "警告", "caution": "注意", "debug": "除錯", "example": "範例", "success": "成功", "help": "幫助", "idea": "想法", "pending": "待處理", "security": "安全", "beta": "測試", "best-practice": "最佳實踐" }, "components": { "MultiSelectInput": { "placeholder": "選擇..." }, "DatePickerInput": { "placeholder": { "single": "選擇日期", "range": "選擇日期範圍" } } } } ================================================ FILE: backend/chainlit/translations.py ================================================ # TODO: # - Support linting plural # - Support interpolation def compare_json_structures(truth, to_compare, path=""): """ Compare the structure of two deeply nested JSON objects. Args: truth (dict): The 'truth' JSON object. to_compare (dict): The 'to_compare' JSON object. path (str): The current path for error reporting (used internally). Returns: A list of differences found. """ if not isinstance(truth, dict) or not isinstance(to_compare, dict): raise ValueError("Both inputs must be dictionaries.") errors = [] truth_keys = set(truth.keys()) to_compare_keys = set(to_compare.keys()) extra_keys = to_compare_keys - truth_keys missing_keys = truth_keys - to_compare_keys for key in extra_keys: errors.append(f"⚠️ Extra key: '{path + '.' + key if path else key}'") for key in missing_keys: errors.append(f"❌ Missing key: '{path + '.' + key if path else key}'") for key in truth_keys & to_compare_keys: if isinstance(truth[key], dict) and isinstance(to_compare[key], dict): # Recursive call to navigate through nested dictionaries errors += compare_json_structures( truth[key], to_compare[key], path + "." + key if path else key ) elif not isinstance(truth[key], dict) and not isinstance(to_compare[key], dict): # If both are not dicts, we are at leaf nodes and structure matches; skip value comparison continue else: # Structure mismatch: one is a dict, the other is not errors.append( f"❌ Structure mismatch at: '{path + '.' + key if path else key}'" ) return errors def lint_translation_json(file, truth, to_compare): print(f"\nLinting {file}...") errors = compare_json_structures(truth, to_compare) if errors: for error in errors: print(f"{error}") else: print(f"✅ No errors found in {file}") ================================================ FILE: backend/chainlit/types.py ================================================ from enum import Enum from pathlib import Path from typing import ( TYPE_CHECKING, Any, Dict, Generic, List, Literal, Optional, Protocol, TypedDict, TypeVar, Union, ) if TYPE_CHECKING: from chainlit.element import ElementDict from chainlit.step import StepDict from dataclasses import field from dataclasses_json import DataClassJsonMixin from pydantic import BaseModel from pydantic.dataclasses import dataclass InputWidgetType = Literal[ "switch", "slider", "select", "textinput", "tags", "numberinput", "multiselect", "checkbox", "radio", "datepicker", ] ToastType = Literal["info", "success", "warning", "error"] class ThreadDict(TypedDict): id: str createdAt: str name: Optional[str] userId: Optional[str] userIdentifier: Optional[str] tags: Optional[List[str]] metadata: Optional[Dict] steps: List["StepDict"] elements: Optional[List["ElementDict"]] class Pagination(BaseModel): first: int cursor: Optional[str] = None class ThreadFilter(BaseModel): feedback: Literal[0, 1] | None = None userId: str | None = None search: str | None = None @dataclass class PageInfo: hasNextPage: bool startCursor: Optional[str] endCursor: Optional[str] def to_dict(self): return { "hasNextPage": self.hasNextPage, "startCursor": self.startCursor, "endCursor": self.endCursor, } @classmethod def from_dict(cls, page_info_dict: Dict) -> "PageInfo": hasNextPage = page_info_dict.get("hasNextPage", False) startCursor = page_info_dict.get("startCursor", None) endCursor = page_info_dict.get("endCursor", None) return cls( hasNextPage=hasNextPage, startCursor=startCursor, endCursor=endCursor ) T = TypeVar("T", covariant=True) class HasFromDict(Protocol[T]): @classmethod def from_dict(cls, obj_dict: Any) -> T: raise NotImplementedError @dataclass class PaginatedResponse(Generic[T]): pageInfo: PageInfo data: List[T] def to_dict(self): return { "pageInfo": self.pageInfo.to_dict(), "data": [ (d.to_dict() if hasattr(d, "to_dict") and callable(d.to_dict) else d) for d in self.data ], } @classmethod def from_dict( cls, paginated_response_dict: Dict, the_class: HasFromDict[T] ) -> "PaginatedResponse[T]": pageInfo = PageInfo.from_dict(paginated_response_dict.get("pageInfo", {})) data = [the_class.from_dict(d) for d in paginated_response_dict.get("data", [])] return cls(pageInfo=pageInfo, data=data) @dataclass class FileSpec(DataClassJsonMixin): accept: Union[List[str], Dict[str, List[str]]] max_files: int max_size_mb: int @dataclass class ActionSpec(DataClassJsonMixin): keys: List[str] @dataclass class AskSpec(DataClassJsonMixin): """Specification for asking the user.""" timeout: int type: Literal["text", "file", "action", "element"] step_id: str @dataclass class AskFileSpec(FileSpec, AskSpec, DataClassJsonMixin): """Specification for asking the user a file.""" @dataclass class AskActionSpec(ActionSpec, AskSpec, DataClassJsonMixin): """Specification for asking the user an action""" @dataclass class AskElementSpec(AskSpec, DataClassJsonMixin): """Specification for asking the user a custom element""" element_id: str class FileReference(TypedDict): id: str class FileDict(TypedDict): id: str name: str path: Path size: int type: str class MessagePayload(TypedDict): message: "StepDict" fileReferences: Optional[List[FileReference]] class InputAudioChunkPayload(TypedDict): isStart: bool mimeType: str elapsedTime: float data: bytes @dataclass class InputAudioChunk: isStart: bool mimeType: str elapsedTime: float data: bytes class OutputAudioChunk(TypedDict): track: str mimeType: str data: bytes @dataclass class AskFileResponse: id: str name: str path: str size: int type: str class AskActionResponse(TypedDict): name: str payload: Dict label: str tooltip: str forId: str id: str class AskElementResponse(TypedDict, total=False): submitted: bool class UpdateThreadRequest(BaseModel): threadId: str name: str class ShareThreadRequest(BaseModel): threadId: str isShared: bool class DeleteThreadRequest(BaseModel): threadId: str class DeleteFeedbackRequest(BaseModel): feedbackId: str class GetThreadsRequest(BaseModel): pagination: Pagination filter: ThreadFilter class CallActionRequest(BaseModel): action: Dict sessionId: str class ConnectStdioMCPRequest(BaseModel): sessionId: str clientType: Literal["stdio"] name: str fullCommand: str class ConnectSseMCPRequest(BaseModel): sessionId: str clientType: Literal["sse"] name: str url: str # Optional HTTP headers to forward to the MCP transport (e.g. Authorization) headers: Optional[Dict[str, str]] = None class ConnectStreamableHttpMCPRequest(BaseModel): sessionId: str clientType: Literal["streamable-http"] name: str url: str # Optional HTTP headers to forward to the MCP transport (e.g. Authorization) headers: Dict[str, str] | None = None ConnectMCPRequest = Union[ ConnectStdioMCPRequest, ConnectSseMCPRequest, ConnectStreamableHttpMCPRequest ] class DisconnectMCPRequest(BaseModel): sessionId: str name: str class ElementRequest(BaseModel): element: Dict sessionId: str class Theme(str, Enum): light = "light" dark = "dark" @dataclass class Starter(DataClassJsonMixin): """Specification for a starter that can be chosen by the user at the thread start.""" label: str message: str command: Optional[str] = None icon: Optional[str] = None @dataclass class StarterCategory(DataClassJsonMixin): """A category/group of starters with an optional icon.""" label: str icon: Optional[str] = None starters: List[Starter] = field(default_factory=list) @dataclass class ChatProfile(DataClassJsonMixin): """Specification for a chat profile that can be chosen by the user at the thread start.""" name: str markdown_description: str icon: Optional[str] = None display_name: Optional[str] = None default: bool = False starters: Optional[List[Starter]] = None config_overrides: Any = None FeedbackStrategy = Literal["BINARY"] class CommandDict(TypedDict): # The identifier of the command, will be displayed in the UI id: str # The description of the command, will be displayed in the UI description: str # The lucide icon name icon: str # Display the command as a button in the composer button: Optional[bool] # Whether the command will be persistent unless the user toggles it persistent: Optional[bool] # Whether the command should be pre-selected when loaded selected: Optional[bool] class FeedbackDict(TypedDict): forId: str id: Optional[str] value: Literal[0, 1] comment: Optional[str] @dataclass class Feedback: forId: str value: Literal[0, 1] threadId: Optional[str] = None id: Optional[str] = None comment: Optional[str] = None class UpdateFeedbackRequest(BaseModel): feedback: Feedback sessionId: str ================================================ FILE: backend/chainlit/user.py ================================================ from typing import Dict, Literal, Optional, TypedDict from dataclasses_json import DataClassJsonMixin from pydantic import Field from pydantic.dataclasses import dataclass Provider = Literal[ "credentials", "header", "github", "google", "azure-ad", "azure-ad-hybrid", "okta", "auth0", "descope", ] class UserDict(TypedDict): id: str identifier: str display_name: Optional[str] metadata: Dict # Used when logging-in a user @dataclass class User(DataClassJsonMixin): identifier: str display_name: Optional[str] = None metadata: Dict = Field(default_factory=dict) @dataclass class PersistedUserFields: id: str createdAt: str @dataclass class PersistedUser(User, PersistedUserFields): pass ================================================ FILE: backend/chainlit/user_session.py ================================================ from typing import Callable, Dict, Generic, Optional, TypeVar from chainlit.context import context user_sessions: Dict[str, Dict] = {} T = TypeVar("T") class UserSession: """ Developer facing user session class. Useful for the developer to store user specific data between calls. """ def get(self, key, default=None): if not context.session: return default if context.session.id not in user_sessions: # Create a new user session user_sessions[context.session.id] = {} user_session = user_sessions[context.session.id] # Copy important fields from the session user_session["id"] = context.session.id user_session["env"] = context.session.user_env user_session["chat_settings"] = context.session.chat_settings user_session["user"] = context.session.user user_session["chat_profile"] = context.session.chat_profile user_session["client_type"] = context.session.client_type return user_session.get(key, default) def set(self, key, value): if not context.session: return None if context.session.id not in user_sessions: user_sessions[context.session.id] = {} user_session = user_sessions[context.session.id] user_session[key] = value def create_accessor( self, key: str, default: T, *, apply_fn: Optional[Callable[[T], T]] = None ) -> "SessionAccessor[T]": """ Create a typed session accessor object for the given key and default value. #### Note: Creates the accessor configuration. The session value itself is only stored/updated when `.set()`, `.reset()`, or `.apply()` are called. Parameters ---------- key : str The session dictionary key to store the value under default : T Default value to return when key is not present in session apply_fn : Optional[Callable[[T], T]], default None Optional function to transform the value when apply() is called Returns ------- SessionAccessor[T] A typed accessor object bound to the specified session key Examples -------- ```python count = cl.user_session.create_accessor("count", 0) count.get() # returns 0 count.set(5) # type-safe setter count.get() # returns 5 # With transform function counter = cl.user_session.create_accessor("counter", 0, apply_fn=lambda x: x + 1) counter.apply() # increments value and returns new value (1) @cl.on_message async def on_message(message: cl.Message): await cl.Message(content=f"You sent {counter.apply()} messages").send() # You sent 2 messages ``` """ return SessionAccessor(key, default, apply_fn=apply_fn) user_session = UserSession() class SessionAccessor(Generic[T]): """ Extended session accessor class to store user specific data between calls with type safety. Provides a typed wrapper around user_session dictionary access with default values and optional transform functions. The session value is only stored in memory when explicitly modified through `.set()`, `.reset()`, or `.apply()` methods. Examples -------- ```python count = cl.user_session.create_accessor("count", 0) count.get() # returns 0 count.set(5) # type-safe setter count.get() # returns 5 # With transform function counter = cl.user_session.create_accessor("counter", 0, apply_fn=lambda x: x + 1) counter.apply() # increments value and returns new value (1) @cl.on_message async def on_message(message: cl.Message): await cl.Message(content=f"You sent {counter.apply()} messages").send() # You sent 2 messages ``` """ def __init__( self, key: str, default: T, *, apply_fn: Optional[Callable[[T], T]] = None ): self._key = key self._default = default self._apply_fn = apply_fn def get(self) -> T: """ Get the current value of the accessor. """ return user_session.get(self._key, self._default) def set(self, value: T) -> None: """ Set the value of the accessor. """ return user_session.set(self._key, value) def reset(self) -> None: """ Reset the value to the default. """ return self.set(self._default) def apply(self) -> T: """ Apply the transform function to the current value, store the result, and return it. Returns the current value if no transform function is provided. """ value = self.get() if self._apply_fn: value = self._apply_fn(value) self.set(value) return value ================================================ FILE: backend/chainlit/utils.py ================================================ import functools import importlib import inspect import os from asyncio import CancelledError from datetime import datetime, timezone from typing import Callable import click from fastapi import FastAPI, Request from fastapi.responses import JSONResponse from packaging import version from starlette.middleware.base import BaseHTTPMiddleware from chainlit.auth import ensure_jwt_secret from chainlit.context import context from chainlit.logger import logger def utc_now(): dt = datetime.now(timezone.utc).replace(tzinfo=None) return dt.isoformat() + "Z" def timestamp_utc(timestamp: float): dt = datetime.fromtimestamp(timestamp, timezone.utc).replace(tzinfo=None) return dt.isoformat() + "Z" def wrap_user_function(user_function: Callable, with_task=False) -> Callable: """ Wraps a user-defined function to accept arguments as a dictionary. Args: user_function (Callable): The user-defined function to wrap. Returns: Callable: The wrapped function. """ @functools.wraps(user_function) async def wrapper(*args): # Get the parameter names of the user-defined function user_function_params = list(inspect.signature(user_function).parameters.keys()) # Create a dictionary of parameter names and their corresponding values from *args params_values = { param_name: arg for param_name, arg in zip(user_function_params, args) } if with_task: await context.emitter.task_start() try: # Call the user-defined function with the arguments if inspect.iscoroutinefunction(user_function): return await user_function(**params_values) else: return user_function(**params_values) except CancelledError: pass except Exception as e: logger.exception(e) if with_task: from chainlit.message import ErrorMessage await ErrorMessage( content=str(e) or e.__class__.__name__, author="Error" ).send() finally: if with_task: await context.emitter.task_end() return wrapper def make_module_getattr(registry): """Leverage PEP 562 to make imports lazy in an __init__.py The registry must be a dictionary with the items to import as keys and the modules they belong to as a value. """ def __getattr__(name): module_path = registry[name] module = importlib.import_module(module_path, __package__) return getattr(module, name) return __getattr__ def check_module_version(name, required_version): """ Check the version of a module. Args: name (str): A module name. version (str): Minimum version. Returns: (bool): Return True if the module is installed and the version match the minimum required version. """ try: module = importlib.import_module(name) except ModuleNotFoundError: return False return version.parse(module.__version__) >= version.parse(required_version) def check_file(target: str): # Define accepted file extensions for Chainlit ACCEPTED_FILE_EXTENSIONS = ("py", "py3") _, extension = os.path.splitext(target) # Check file extension if extension[1:] not in ACCEPTED_FILE_EXTENSIONS: if extension[1:] == "": raise click.BadArgumentUsage( "Chainlit requires raw Python (.py) files, but the provided file has no extension." ) else: raise click.BadArgumentUsage( f"Chainlit requires raw Python (.py) files, not {extension}." ) if not os.path.exists(target): raise click.BadParameter(f"File does not exist: {target}") def mount_chainlit(app: FastAPI, target: str, path="/chainlit"): from chainlit.config import config, load_module from chainlit.server import app as chainlit_app config.run.debug = os.environ.get("CHAINLIT_DEBUG", False) os.environ["CHAINLIT_ROOT_PATH"] = path api_full_path = path if app.root_path: parent_root_path = app.root_path.rstrip("/") api_full_path = parent_root_path + path os.environ["CHAINLIT_PARENT_ROOT_PATH"] = parent_root_path check_file(target) # Load the module provided by the user config.run.module_name = target load_module(config.run.module_name) ensure_jwt_secret() class ChainlitMiddleware(BaseHTTPMiddleware): """Middleware to handle path routing for submounted Chainlit applications. When Chainlit is submounted within a larger FastAPI application, its default route `@router.get("/{full_path:path}")` can conflict with the main app's routing. This middleware ensures requests are only forwarded to Chainlit if they match the designated subpath, preventing routing collisions. If a request's path doesn't start with the configured subpath, the middleware returns a 404 response instead of forwarding to Chainlit's default route. """ async def dispatch(self, request: Request, call_next): if not request.url.path.startswith(api_full_path): return JSONResponse(status_code=404, content={"detail": "Not found"}) return await call_next(request) chainlit_app.add_middleware(ChainlitMiddleware) app.mount(path, chainlit_app) ================================================ FILE: backend/chainlit/version.py ================================================ __version__ = "2.10.0" ================================================ FILE: backend/pyproject.toml ================================================ [project] name = "chainlit" dynamic = ["version"] keywords = [ "LLM", "Agents", "MCP", "gen ai", "chat ui", "chatbot ui", "openai", "copilot", "langchain", "conversational ai", ] description = "Build Conversational AI." authors = [ { name = "Willy Douhard" }, { name = "Dan Andre Constantini" } ] license = { text = "Apache-2.0" } readme = "README.md" requires-python = ">=3.10,<4.0.0" classifiers = [ "Framework :: FastAPI", "Topic :: Scientific/Engineering :: Artificial Intelligence", "Topic :: Communications :: Chat", "Programming Language :: JavaScript", "Topic :: Software Development :: User Interfaces", "Topic :: Software Development :: Libraries :: Python Modules", "Environment :: Web Environment", ] dependencies = [ "httpx>=0.23.0", "literalai==0.1.201", "dataclasses_json>=0.6.7,<0.7.0", "fastapi>=0.116.1", "starlette>=0.47.2", "uvicorn>=0.35.0", "python-socketio>=5.11.0,<6.0.0", "aiofiles>=23.1.0,<25.0.0", "syncer>=2.0.3,<3.0.0", "asyncer>=0.0.8,<0.1.0", "mcp>=1.11.0,<2.0.0", "nest-asyncio>=1.6.0,<2.0.0", "click>=8.1.3,<9.0.0", "tomli>=2.0.1,<3.0.0", "pydantic>=2.7.2,<3", "python-dotenv>=1.0.0,<2.0.0", "watchfiles>=1.1.1,<2.0.0", "filetype>=1.2.0,<2.0.0", "lazify>=0.4.0,<0.5.0", "packaging>=23.1", "python-multipart>=0.0.18,<1.0.0", "pyjwt>=2.8.0,<3.0.0", "audioop-lts>=0.2.1,<0.3.0; python_version>='3.13'", "pydantic-settings>=2.10.1" ] [project.urls] Homepage = "https://chainlit.io/" Documentation = "https://docs.chainlit.io/" Repository = "https://github.com/Chainlit/chainlit" [project.scripts] chainlit = "chainlit.cli:cli" [project.optional-dependencies] tests = [ "pytest>=8.3.2,<9.0.0", "pytest-asyncio>=0.23.8,<1.0.0", "pytest-cov>=5.0.0,<6.0.0", "openai>=1.11.1,<2.0.0", "langchain>=0.2.4,<0.3.0", "llama-index>=0.13.0,<1.0.0", "semantic-kernel>=1.24.0,<2.0.0", "tenacity>=8.4.1,<9.0.0", "transformers>=4.38,<5.0", "matplotlib>=3.7.1,<4.0.0", "plotly>=5.18.0,<6.0.0", "slack_bolt>=1.18.1,<2.0.0", "discord>=2.3.2,<3.0.0", "botbuilder-core>=4.15.0,<5.0.0", "aiosqlite>=0.20.0,<1.0.0", "pandas>=2.2.2,<3.0.0", "moto>=5.0.14,<6.0.0", ] dev = [ "ruff>=0.9.0,<1.0.0", ] mypy = [ "mypy>=1.13,<2.0.0", "types-requests>=2.31.0.2,<3.0.0", "types-aiofiles>=23.1.0.5,<24.0.0", "mypy-boto3-dynamodb>=1.34.113,<2.0.0", "pandas-stubs>=2.2.2,<3.0.0; python_version>='3.9'", ] custom-data = [ "asyncpg>=0.30.0,<1.0.0", "SQLAlchemy>=2.0.28,<3.0.0", "boto3>=1.34.73,<2.0.0", "azure-identity>=1.14.1,<2.0.0", "azure-storage-file-datalake>=12.14.0,<13.0.0", "azure-storage-blob>=12.24.0,<13.0.0", "google-cloud-storage>=2.19.0,<3.0.0", ] [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build] exclude = [ "chainlit/frontend/**/*", "chainlit/copilot/**/**/" ] [tool.hatch.build.hooks.custom] path = "build.py" [tool.hatch.build.targets.sdist] artifacts = [ "chainlit/frontend/dist/**/*", "chainlit/copilot/dist/**/*" ] [tool.hatch.build.targets.wheel] packages = ["chainlit"] artifacts = [ "chainlit/frontend/dist/**/*", "chainlit/copilot/dist/**/*" ] [tool.hatch.version] path = "chainlit/version.py" [tool.mypy] python_version = "3.10" [[tool.mypy.overrides]] module = [ "boto3.dynamodb.types", "botbuilder.*", "filetype", "langflow", "lazify", "plotly", "nest_asyncio", "socketio.*", "syncer", "azure.storage.filedatalake", "google-cloud-storage", "azure.storage.blob", "azure.storage.blob.aio", "google.auth", "google.oauth2", ] ignore_missing_imports = true [tool.pytest.ini_options] testpaths = ["tests"] asyncio_mode = "auto" [tool.ruff] target-version = "py39" [tool.ruff.lint] select = [ "E", "F", "I", "LOG", "UP", "T10", "ISC", "ICN", "LOG", "G", "PIE", "PT", "Q", "RSE", "FURB", "RUF", ] ignore = [ "E712", "E501", "UP006", "UP007", "UP035", "UP045", "PIE790", "RUF005", "RUF006", "RUF012", "ISC001", ] [tool.ruff.lint.isort] combine-as-imports = true ================================================ FILE: backend/tests/__init__.py ================================================ ================================================ FILE: backend/tests/auth/__init__.py ================================================ ================================================ FILE: backend/tests/auth/test_cookie.py ================================================ import importlib import pytest from fastapi import FastAPI, Form from fastapi.testclient import TestClient from starlette.requests import Request from starlette.responses import Response import chainlit.auth.cookie as cookie_module from chainlit.auth import ( clear_auth_cookie, get_token_from_cookies, set_auth_cookie, ) @pytest.fixture def test_app(): app = FastAPI() @app.post("/set-cookie") async def set_cookie_endpoint(request: Request, token: str = Form()): response = Response() set_auth_cookie(request, response, token) return response @app.get("/get-token") async def get_token_endpoint(request: Request): token = get_token_from_cookies(request.cookies) return {"token": token} @app.delete("/clear-cookie") async def clear_cookie_endpoint(request: Request): response = Response() clear_auth_cookie(request, response) return response return app @pytest.fixture def client(test_app): return TestClient(test_app) def test_short_token(client): """Test with a <3000 shorter token.""" # Set a short token short_token = "x" * 1000 set_response = client.post("/set-cookie", data={"token": short_token}) assert set_response.status_code == 200 # Verify cookies were set cookies = set_response.cookies assert cookies, "No cookies set" assert "access_token" in cookies, f"No chunking for short cookies: {cookies}" # Read back the token using client's cookie jar get_response = client.get("/get-token") assert get_response.status_code == 200 assert get_response.json()["token"] == short_token def test_set_and_read_4kb_token(client): """Test full cookie lifecycle using actual client cookie handling.""" # Set a 4KB token token_4kb = "x" * 4000 set_response = client.post("/set-cookie", data={"token": token_4kb}) assert set_response.status_code == 200 # Verify cookies were set cookies = set_response.cookies assert f"{cookies.keys()} should contain chunked cookies", any( key.startswith("access_token_") for key in cookies.keys() ) # Read back the token using client's cookie jar get_response = client.get("/get-token") assert get_response.status_code == 200 response_token = get_response.json()["token"] assert len(response_token) == len(token_4kb) assert response_token == token_4kb def test_overwrite_shorter_token_chunked(client): """Test cookie chunk cleanup when replacing a large token with a smaller one.""" # Set initial long token long_token = "LONG" * 2000 # 8000 characters client.post("/set-cookie", data={"token": long_token}) # Verify initial chunks exist first_cookies = client.cookies assert len([k for k in first_cookies if k.startswith("access_token_")]) > 1 # Set shorter token (should clear previous chunks) short_token = "SHORT" * 1000 # 4000 characters client.post("/set-cookie", data={"token": short_token}) # Verify new cookie state final_response = client.get("/get-token") assert final_response.json()["token"] == short_token # Verify only two chunks remain final_cookies = client.cookies chunk_cookies = [k for k in final_cookies if k.startswith("access_token_")] assert len(chunk_cookies) == 2, f"Found {len(chunk_cookies)} residual cookies" def test_overwrite_shorter_token_unchunked(client): """Test cookie chunk cleanup when replacing a large token with a smaller one.""" # Set initial long token long_token = "LONG" * 1000 # 4000 characters client.post("/set-cookie", data={"token": long_token}) # Verify initial chunks exist first_cookies = client.cookies assert len([k for k in first_cookies if k.startswith("access_token_")]) > 1 # Set shorter token (should clear previous chunks) short_token = "SHORT" client.post("/set-cookie", data={"token": short_token}) # Verify new cookie state final_response = client.get("/get-token") assert final_response.json()["token"] == short_token # Verify no chunks remain final_cookies = client.cookies chunk_cookies = [k for k in final_cookies if k.startswith("access_token_")] assert len(chunk_cookies) == 0, f"Found {len(chunk_cookies)} residual cookies" def test_state_cookie_lifetime_default(monkeypatch): """Test that _state_cookie_lifetime defaults to 180 seconds (3 minutes).""" monkeypatch.delenv("CHAINLIT_STATE_COOKIE_LIFETIME", raising=False) importlib.reload(cookie_module) assert cookie_module._state_cookie_lifetime == 180 def test_state_cookie_lifetime_custom(monkeypatch): """Test that _state_cookie_lifetime can be set via environment variable.""" monkeypatch.setenv("CHAINLIT_STATE_COOKIE_LIFETIME", "600") importlib.reload(cookie_module) assert cookie_module._state_cookie_lifetime == 600 def test_clear_auth_cookie(client): """Test cookie clearing removes all chunks.""" # Set initial token client.post("/set-cookie", data={"token": "x" * 4000}) # Verify cookies exist assert len(client.cookies) > 0 # Clear cookies clear_response = client.delete("/clear-cookie") assert clear_response.status_code == 200 # Verify cookies were cleared assert len(clear_response.cookies) == 0 final_response = client.get("/get-token") assert final_response.json()["token"] is None ================================================ FILE: backend/tests/conftest.py ================================================ import datetime from contextlib import asynccontextmanager from pathlib import Path from typing import Callable from unittest.mock import AsyncMock, Mock import pytest import pytest_asyncio from chainlit import config from chainlit.callbacks import data_layer from chainlit.context import ChainlitContext, context_var from chainlit.data.base import BaseDataLayer from chainlit.session import HTTPSession, WebsocketSession from chainlit.user import PersistedUser from chainlit.user_session import UserSession @pytest.fixture def persisted_test_user(): return PersistedUser( id="test_user_id", createdAt=datetime.datetime.now().isoformat(), identifier="test_user_identifier", ) @pytest.fixture def mock_session_factory(persisted_test_user: PersistedUser) -> Callable[..., Mock]: def create_mock_session(**kwargs) -> Mock: mock = Mock(spec=WebsocketSession) mock.user = kwargs.get("user", persisted_test_user) mock.id = kwargs.get("id", "test_session_id") mock.user_env = kwargs.get("user_env", {"test_env": "value"}) mock.chat_settings = kwargs.get("chat_settings", {}) mock.chat_profile = kwargs.get("chat_profile", None) mock.environ = kwargs.get("environ", None) mock.client_type = kwargs.get("client_type", "webapp") mock.thread_id = kwargs.get("thread_id", "test_thread_id") mock.emit = AsyncMock() mock.has_first_interaction = kwargs.get("has_first_interaction", True) mock.files = kwargs.get("files", {}) mock.files_spec = kwargs.get("files_spec", {}) return mock return create_mock_session @pytest.fixture def mock_session(mock_session_factory) -> Mock: return mock_session_factory() @asynccontextmanager async def create_chainlit_context(mock_session): from chainlit.emitter import BaseChainlitEmitter # Create a mock emitter with all necessary methods mock_emitter = Mock(spec=BaseChainlitEmitter) mock_emitter.send_step = AsyncMock() mock_emitter.update_step = AsyncMock() mock_emitter.delete_step = AsyncMock() mock_emitter.stream_start = AsyncMock() mock_emitter.send_element = AsyncMock() mock_emitter.send_action = AsyncMock() mock_emitter.remove_action = AsyncMock() mock_emitter.emit = AsyncMock() mock_emitter.set_chat_settings = Mock() # Sync method, not async context = ChainlitContext(mock_session, emitter=mock_emitter) token = context_var.set(context) try: yield context finally: context_var.reset(token) @pytest_asyncio.fixture async def mock_chainlit_context(persisted_test_user, mock_session): mock_session.user = persisted_test_user return create_chainlit_context(mock_session) @pytest.fixture def user_session(): return UserSession() @pytest.fixture def mock_websocket_session(): session = Mock(spec=WebsocketSession) session.emit = AsyncMock() return session @pytest.fixture def mock_http_session(): return Mock(spec=HTTPSession) @pytest.fixture def mock_data_layer(monkeypatch: pytest.MonkeyPatch) -> AsyncMock: mock_data_layer = AsyncMock(spec=BaseDataLayer) return mock_data_layer @pytest.fixture def mock_get_data_layer(mock_data_layer: AsyncMock, test_config: config.ChainlitConfig): # Instantiate mock data layer mock_get_data_layer = Mock(return_value=mock_data_layer) # Configure it using @data_layer decorator return data_layer(mock_get_data_layer) @pytest.fixture def test_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): monkeypatch.setenv("CHAINLIT_ROOT_PATH", str(tmp_path)) test_config = config.load_config() monkeypatch.setattr("chainlit.callbacks.config", test_config) monkeypatch.setattr("chainlit.server.config", test_config) monkeypatch.setattr("chainlit.config.config", test_config) return test_config ================================================ FILE: backend/tests/data/__init__.py ================================================ ================================================ FILE: backend/tests/data/conftest.py ================================================ from unittest.mock import AsyncMock import pytest from chainlit.data.storage_clients.base import BaseStorageClient from chainlit.user import User @pytest.fixture def mock_storage_client(): mock_client = AsyncMock(spec=BaseStorageClient) mock_client.upload_file.return_value = { "url": "https://example.com/test.txt", "object_key": "test_user/test_element/test.txt", } return mock_client @pytest.fixture def test_user() -> User: return User(identifier="test_user_identifier", metadata={}) ================================================ FILE: backend/tests/data/storage_clients/test_gcs.py ================================================ from unittest.mock import MagicMock, patch import pytest from chainlit.data.storage_clients.base import storage_expiry_time from chainlit.data.storage_clients.gcs import GCSStorageClient @pytest.fixture def mock_gcs_client(): """Create a mock Google Cloud Storage client.""" # First mock the service_account with patch( "chainlit.data.storage_clients.gcs.service_account" ) as mock_service_account: mock_credentials = MagicMock() mock_service_account.Credentials.from_service_account_info.return_value = ( mock_credentials ) # Then mock the storage client with patch("chainlit.data.storage_clients.gcs.storage") as mock_storage: mock_client = MagicMock() mock_storage.Client.return_value = mock_client mock_bucket = MagicMock() mock_client.bucket.return_value = mock_bucket mock_blob = MagicMock() mock_bucket.blob.return_value = mock_blob yield { "storage": mock_storage, "client": mock_client, "bucket": mock_bucket, "blob": mock_blob, "service_account": mock_service_account, "credentials": mock_credentials, } class TestGCSStorageClient: def test_init(self, mock_gcs_client): """Test initialization of GCS client.""" # Remove client assignment, directly call the constructor GCSStorageClient( bucket_name="test-bucket", project_id="test-project", client_email="test@example.com", private_key="test-key", ) # Verify service account credentials were created correctly mock_gcs_client[ "service_account" ].Credentials.from_service_account_info.assert_called_once_with( { "type": "service_account", "project_id": "test-project", "private_key": "test-key", "client_email": "test@example.com", "token_uri": "https://oauth2.googleapis.com/token", } ) # Verify storage client was initialized with credentials mock_gcs_client["storage"].Client.assert_called_once_with( project="test-project", credentials=mock_gcs_client["credentials"] ) # Verify bucket was retrieved mock_gcs_client["client"].bucket.assert_called_once_with("test-bucket") def test_sync_get_read_url(self, mock_gcs_client): """Test generating a signed URL.""" # Set up the mock to return a URL mock_gcs_client[ "blob" ].generate_signed_url.return_value = "https://signed-url.example.com" client = GCSStorageClient( bucket_name="test-bucket", project_id="test-project", client_email="test@example.com", private_key="test-key", ) # Reset mocks to ensure clean state mock_gcs_client["bucket"].reset_mock() mock_gcs_client["blob"].reset_mock() # Test the method url = client.sync_get_read_url("test/path/file.txt") # Verify correct methods were called mock_gcs_client["bucket"].blob.assert_called_once_with("test/path/file.txt") mock_gcs_client["blob"].generate_signed_url.assert_called_once_with( version="v4", expiration=storage_expiry_time, method="GET" ) assert url == "https://signed-url.example.com" @pytest.mark.asyncio async def test_get_read_url(self, mock_gcs_client): """Test the async wrapper for getting a read URL.""" # Set up the mock to return a URL mock_gcs_client[ "blob" ].generate_signed_url.return_value = "https://signed-url.example.com" client = GCSStorageClient( bucket_name="test-bucket", project_id="test-project", client_email="test@example.com", private_key="test-key", ) # Reset mocks to ensure clean state mock_gcs_client["bucket"].reset_mock() mock_gcs_client["blob"].reset_mock() # Test the async method url = await client.get_read_url("test/path/file.txt") # Verify correct methods were called mock_gcs_client["bucket"].blob.assert_called_once_with("test/path/file.txt") mock_gcs_client["blob"].generate_signed_url.assert_called_once_with( version="v4", expiration=storage_expiry_time, method="GET" ) assert url == "https://signed-url.example.com" def test_sync_upload_file(self, mock_gcs_client): """Test uploading a file to GCS.""" client = GCSStorageClient( bucket_name="test-bucket", project_id="test-project", client_email="test@example.com", private_key="test-key", ) # Reset the mock to ensure clean state mock_gcs_client["bucket"].reset_mock() mock_gcs_client["blob"].reset_mock() # Mock the bucket name property mock_gcs_client["bucket"].name = "test-bucket" # Test with binary data binary_data = b"test content" object_key = "test/path/file.txt" # Remove the unused result assignment client.sync_upload_file( object_key=object_key, data=binary_data, mime="text/plain", overwrite=True ) # Check that the blob was called with the correct object key (using assert_any_call instead of assert_called_once_with) mock_gcs_client["bucket"].blob.assert_any_call(object_key) mock_gcs_client["blob"].upload_from_string.assert_called_once_with( binary_data, content_type="text/plain" ) def test_sync_upload_file_string_data(self, mock_gcs_client): """Test uploading string data to GCS.""" client = GCSStorageClient( bucket_name="test-bucket", project_id="test-project", client_email="test@example.com", private_key="test-key", ) # Reset the mock to ensure clean state mock_gcs_client["bucket"].reset_mock() mock_gcs_client["blob"].reset_mock() mock_gcs_client["bucket"].name = "test-bucket" # Test with string data that should be encoded string_data = "test content" object_key = "test/path/file.txt" # Remove the unused result assignment client.sync_upload_file( object_key=object_key, data=string_data, mime="text/plain", overwrite=True ) # Check that the correct methods were called mock_gcs_client["bucket"].blob.assert_any_call(object_key) mock_gcs_client["blob"].upload_from_string.assert_called_once_with( b"test content", content_type="text/plain" ) def test_sync_upload_file_no_overwrite(self, mock_gcs_client): """Test upload with overwrite=False and existing file.""" client = GCSStorageClient( bucket_name="test-bucket", project_id="test-project", client_email="test@example.com", private_key="test-key", ) # Reset the mock to ensure clean state mock_gcs_client["bucket"].reset_mock() mock_gcs_client["blob"].reset_mock() # Configure blob to indicate file exists mock_gcs_client["blob"].exists.return_value = True with pytest.raises( Exception, match=r"Failed to upload file to GCS: File test/path/existing\.txt already exists and overwrite is False", ): client.sync_upload_file( object_key="test/path/existing.txt", data=b"test content", overwrite=False, ) mock_gcs_client["blob"].exists.assert_called_once() mock_gcs_client["blob"].upload_from_string.assert_not_called() def test_sync_upload_file_error(self, mock_gcs_client): """Test error handling during upload.""" client = GCSStorageClient( bucket_name="test-bucket", project_id="test-project", client_email="test@example.com", private_key="test-key", ) # Reset the mock to ensure clean state mock_gcs_client["bucket"].reset_mock() mock_gcs_client["blob"].reset_mock() # Configure upload to throw an exception mock_gcs_client["blob"].upload_from_string.side_effect = ValueError( "Upload failed" ) with pytest.raises( Exception, match="Failed to upload file to GCS: Upload failed" ): client.sync_upload_file( object_key="test/path/file.txt", data=b"test content" ) @pytest.mark.asyncio async def test_upload_file(self, mock_gcs_client): """Test the async wrapper for uploading a file.""" client = GCSStorageClient( bucket_name="test-bucket", project_id="test-project", client_email="test@example.com", private_key="test-key", ) # Reset the mock to ensure clean state mock_gcs_client["bucket"].reset_mock() mock_gcs_client["blob"].reset_mock() mock_gcs_client["bucket"].name = "test-bucket" # Test with binary data binary_data = b"test content" object_key = "test/path/file.txt" # Remove the unused result assignment await client.upload_file( object_key=object_key, data=binary_data, mime="text/plain", overwrite=True ) # Check that the correct methods were called mock_gcs_client["bucket"].blob.assert_any_call(object_key) mock_gcs_client["blob"].upload_from_string.assert_called_once_with( binary_data, content_type="text/plain" ) def test_sync_delete_file(self, mock_gcs_client): """Test deleting a file from GCS.""" client = GCSStorageClient( bucket_name="test-bucket", project_id="test-project", client_email="test@example.com", private_key="test-key", ) # Reset the mock to ensure clean state mock_gcs_client["bucket"].reset_mock() mock_gcs_client["blob"].reset_mock() # Test successful delete result = client.sync_delete_file("test/path/file.txt") mock_gcs_client["bucket"].blob.assert_called_once_with("test/path/file.txt") mock_gcs_client["blob"].delete.assert_called_once() assert result is True def test_sync_delete_file_error(self, mock_gcs_client): """Test error handling during file deletion.""" client = GCSStorageClient( bucket_name="test-bucket", project_id="test-project", client_email="test@example.com", private_key="test-key", ) # Reset the mock to ensure clean state mock_gcs_client["bucket"].reset_mock() mock_gcs_client["blob"].reset_mock() # Configure delete to throw an exception mock_gcs_client["blob"].delete.side_effect = ValueError("Delete failed") # The method should catch the exception and return False result = client.sync_delete_file("test/path/file.txt") mock_gcs_client["bucket"].blob.assert_called_once_with("test/path/file.txt") mock_gcs_client["blob"].delete.assert_called_once() assert result is False @pytest.mark.asyncio async def test_delete_file(self, mock_gcs_client): """Test the async wrapper for deleting a file.""" client = GCSStorageClient( bucket_name="test-bucket", project_id="test-project", client_email="test@example.com", private_key="test-key", ) # Reset the mock to ensure clean state mock_gcs_client["bucket"].reset_mock() mock_gcs_client["blob"].reset_mock() # Test successful delete result = await client.delete_file("test/path/file.txt") mock_gcs_client["bucket"].blob.assert_called_once_with("test/path/file.txt") mock_gcs_client["blob"].delete.assert_called_once() assert result is True ================================================ FILE: backend/tests/data/storage_clients/test_s3.py ================================================ import os import boto3 # type: ignore import pytest from moto import mock_aws from chainlit.data.storage_clients.s3 import S3StorageClient # Fixtures for setting up the DynamoDB table @pytest.fixture def aws_credentials(): """Mocked AWS Credentials for moto.""" os.environ["AWS_ACCESS_KEY_ID"] = "testing" os.environ["AWS_SECRET_ACCESS_KEY"] = "testing" os.environ["AWS_SECURITY_TOKEN"] = "testing" os.environ["AWS_SESSION_TOKEN"] = "testing" os.environ["AWS_DEFAULT_REGION"] = "us-east-1" @pytest.fixture def s3_mock(aws_credentials): """Moto mock S3 setup.""" with mock_aws(): s3 = boto3.client("s3", region_name="us-east-1") # Create a mock bucket s3.create_bucket(Bucket="my-test-bucket") yield s3 @pytest.mark.asyncio async def test_upload_file(s3_mock): # Initialize the S3StorageClient with the mock bucket client = S3StorageClient(bucket="my-test-bucket") # Call the upload_file method and await the result result = await client.upload_file( object_key="test.txt", data="This is a test file", mime="text/plain" ) # Assert that the file upload returned the correct URL assert result["object_key"] == "test.txt" assert result["url"] == "https://my-test-bucket.s3.amazonaws.com/test.txt" # Verify that the file exists in the mock S3 response = s3_mock.get_object(Bucket="my-test-bucket", Key="test.txt") assert response["Body"].read().decode() == "This is a test file" ================================================ FILE: backend/tests/data/test_chainlit_data_layer.py ================================================ import json from unittest.mock import AsyncMock import pytest from chainlit.data.chainlit_data_layer import ChainlitDataLayer @pytest.mark.asyncio async def test_update_thread_preserves_metadata_when_none(): """Test that update_thread does not overwrite existing metadata when metadata=None.""" # Create a mock data layer data_layer = ChainlitDataLayer( database_url="postgresql://test", storage_client=None, show_logger=False ) # Mock the execute_query method data_layer.execute_query = AsyncMock() # Simulate calling update_thread with only a name, metadata=None (default) await data_layer.update_thread(thread_id="test-thread-123", name="Updated Name") # Verify execute_query was called assert data_layer.execute_query.called # Get the query and params from the call call_args = data_layer.execute_query.call_args query = call_args[0][0] params = call_args[0][1] # The query should NOT include metadata in the update # because metadata was None and should be excluded from the data dict assert "metadata" not in query.lower() assert "metadata" not in str(params.values()) @pytest.mark.asyncio async def test_update_thread_merges_metadata_when_provided(): """Test that update_thread merges metadata correctly when provided.""" # Create a mock data layer data_layer = ChainlitDataLayer( database_url="postgresql://test", storage_client=None, show_logger=False ) # Mock the execute_query method to return existing metadata existing_metadata = {"is_shared": True, "custom_field": "original"} async def mock_execute_query(query, params): if "SELECT" in query and "metadata" in query: # Return existing thread metadata return [{"metadata": json.dumps(existing_metadata)}] # For the UPDATE/INSERT, return None return None data_layer.execute_query = AsyncMock(side_effect=mock_execute_query) # Call update_thread with partial metadata update new_metadata = {"custom_field": "updated", "new_field": "added"} await data_layer.update_thread( thread_id="test-thread-123", name="Updated Name", metadata=new_metadata ) # Verify execute_query was called twice (once for SELECT, once for UPDATE) assert data_layer.execute_query.call_count == 2 # Get the UPDATE call update_call = data_layer.execute_query.call_args_list[1] update_params = update_call[0][1] # The metadata should be merged # Expected: {"is_shared": True, "custom_field": "updated", "new_field": "added"} # Find the JSON metadata in the params metadata_json = None for value in update_params.values(): if isinstance(value, str) and value.startswith("{"): try: metadata_json = json.loads(value) break except json.JSONDecodeError: pass assert metadata_json is not None assert metadata_json.get("is_shared") is True assert metadata_json.get("custom_field") == "updated" assert metadata_json.get("new_field") == "added" @pytest.mark.asyncio async def test_update_thread_deletes_keys_with_none_values(): """Test that update_thread deletes keys when value is None.""" # Create a mock data layer data_layer = ChainlitDataLayer( database_url="postgresql://test", storage_client=None, show_logger=False ) # Mock the execute_query method to return existing metadata existing_metadata = { "is_shared": True, "to_delete": "will be removed", "keep": "stays", } async def mock_execute_query(query, params): if "SELECT" in query and "metadata" in query: # Return existing thread metadata return [{"metadata": json.dumps(existing_metadata)}] # For the UPDATE/INSERT, return None return None data_layer.execute_query = AsyncMock(side_effect=mock_execute_query) # Call update_thread with None value to delete a key new_metadata = {"to_delete": None, "new_field": "added"} await data_layer.update_thread(thread_id="test-thread-123", metadata=new_metadata) # Verify execute_query was called twice assert data_layer.execute_query.call_count == 2 # Get the UPDATE call update_call = data_layer.execute_query.call_args_list[1] update_params = update_call[0][1] # The metadata should have deleted "to_delete" key and added "new_field" # Expected: {"is_shared": True, "keep": "stays", "new_field": "added"} metadata_json = None for value in update_params.values(): if isinstance(value, str) and value.startswith("{"): try: metadata_json = json.loads(value) break except json.JSONDecodeError: pass if metadata_json: # Verify "to_delete" is not in the merged metadata assert "to_delete" not in metadata_json # Verify "new_field" was added assert metadata_json.get("new_field") == "added" # Verify "is_shared" and "keep" are preserved assert metadata_json.get("is_shared") is True assert metadata_json.get("keep") == "stays" @pytest.mark.asyncio async def test_create_step_uses_nullif_for_output_and_input(): """Empty-string output/input should not overwrite existing content. Regression test for https://github.com/Chainlit/chainlit/issues/2789 The SQL uses NULLIF(EXCLUDED.output, '') so that an empty string from the initial Step.send() is treated as NULL by COALESCE, preventing it from overwriting non-empty content saved by a subsequent Step.update(). """ import inspect source = inspect.getsource(ChainlitDataLayer.create_step) assert "NULLIF(EXCLUDED.output, '')" in source, ( "output should use NULLIF to treat empty string as NULL" ) assert "NULLIF(EXCLUDED.input, '')" in source, ( "input should use NULLIF to treat empty string as NULL" ) ================================================ FILE: backend/tests/data/test_get_data_layer.py ================================================ from unittest.mock import AsyncMock, Mock from chainlit.data import get_data_layer async def test_get_data_layer( mock_data_layer: AsyncMock, mock_get_data_layer: Mock, ): # Check whether the data layer is properly set assert mock_data_layer == get_data_layer() mock_get_data_layer.assert_called_once() # Getting the data layer again, should not result in additional call assert mock_data_layer == get_data_layer() mock_get_data_layer.assert_called_once() ================================================ FILE: backend/tests/data/test_literalai.py ================================================ import datetime import uuid from unittest.mock import ANY, AsyncMock, Mock, patch import pytest from httpx import HTTPStatusError, RequestError from literalai import ( AsyncLiteralClient, Attachment, Attachment as LiteralAttachment, PageInfo, PaginatedResponse, Score as LiteralScore, Step as LiteralStep, Thread, Thread as LiteralThread, User as LiteralUser, UserDict, ) from literalai.api import AsyncLiteralAPI from literalai.observability.step import ( AttachmentDict as LiteralAttachmentDict, StepDict as LiteralStepDict, ) from literalai.observability.thread import ThreadDict as LiteralThreadDict from chainlit.data.literalai import LiteralDataLayer, LiteralToChainlitConverter from chainlit.element import Audio, File, Image, Pdf, Text, Video from chainlit.step import Step, StepDict from chainlit.types import ( Feedback, Pagination, ThreadFilter, ) from chainlit.user import PersistedUser, User @pytest.fixture async def mock_literal_client(monkeypatch: pytest.MonkeyPatch): client = Mock(spec=AsyncLiteralClient) client.api = AsyncMock(spec=AsyncLiteralAPI) monkeypatch.setattr("literalai.AsyncLiteralClient", client) return client @pytest.fixture async def literal_data_layer(mock_literal_client): data_layer = LiteralDataLayer(api_key="fake_api_key", server="https://fake.server") data_layer.client = mock_literal_client return data_layer @pytest.fixture def test_thread(): return LiteralThread.from_dict( { "id": "test_thread_id", "name": "Test Thread", "createdAt": "2023-01-01T00:00:00Z", "metadata": {}, "participant": {}, "steps": [], "tags": [], } ) @pytest.fixture def test_step_dict(test_thread) -> StepDict: return { "createdAt": "2023-01-01T00:00:00Z", "start": "2023-01-01T00:00:00Z", "end": "2023-01-01T00:00:00Z", "generation": {}, "id": "test_step_id", "name": "Test Step", "threadId": test_thread.id, "type": "user_message", "tags": [], "metadata": {"key": "value"}, "input": "test input", "output": "test output", "waitForAnswer": True, "showInput": True, "language": "en", } @pytest.fixture def test_step(test_thread: LiteralThread): return LiteralStep.from_dict( { "id": str(uuid.uuid4()), "name": "Test Step", "type": "user_message", "environment": None, "threadId": test_thread.id, "error": None, "input": {}, "output": {}, "metadata": {}, "tags": [], "parentId": None, "createdAt": "2023-01-01T00:00:00Z", "startTime": "2023-01-01T00:00:00Z", "endTime": "2023-01-01T00:00:00Z", "generation": {}, "scores": [], "attachments": [], "rootRunId": None, } ) @pytest.fixture def literal_test_user(test_user: User): return LiteralUser( id=str(uuid.uuid4()), created_at=datetime.datetime.now().isoformat(), identifier=test_user.identifier, metadata=test_user.metadata, ) @pytest.fixture def test_filters() -> ThreadFilter: return ThreadFilter(feedback=1, userId="user1", search="test") @pytest.fixture def test_pagination() -> Pagination: return Pagination(first=10, cursor=None) @pytest.fixture def test_attachment( test_thread: LiteralThread, test_step: LiteralStep ) -> LiteralAttachment: return Attachment( id="test_attachment_id", step_id=test_step.id, thread_id=test_thread.id, metadata={ "display": "side", "language": "python", "type": "file", }, mime="text/plain", name="test_file.txt", object_key="test_object_key", url="https://example.com/test_file.txt", ) async def test_create_step( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, test_step_dict: StepDict, mock_chainlit_context, ): async with mock_chainlit_context: await literal_data_layer.create_step(test_step_dict) mock_literal_client.api.send_steps.assert_awaited_once_with( [ { "createdAt": "2023-01-01T00:00:00Z", "startTime": "2023-01-01T00:00:00Z", "endTime": "2023-01-01T00:00:00Z", "generation": {}, "id": "test_step_id", "parentId": None, "name": "Test Step", "threadId": "test_thread_id", "type": "user_message", "tags": [], "metadata": { "key": "value", "waitForAnswer": True, "language": "en", "showInput": True, }, "input": {"content": "test input"}, "output": {"content": "test output"}, } ] ) async def test_safely_send_steps_success( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, mock_chainlit_context, ): test_steps = [{"id": "test_step_id", "name": "Test Step"}] async with mock_chainlit_context: await literal_data_layer.safely_send_steps(test_steps) mock_literal_client.api.send_steps.assert_awaited_once_with(test_steps) async def test_safely_send_steps_http_status_error( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, mock_chainlit_context, caplog, ): test_steps = [{"id": "test_step_id", "name": "Test Step"}] mock_literal_client.api.send_steps.side_effect = HTTPStatusError( "HTTP Error", request=Mock(), response=Mock(status_code=500) ) async with mock_chainlit_context: await literal_data_layer.safely_send_steps(test_steps) mock_literal_client.api.send_steps.assert_awaited_once_with(test_steps) assert "HTTP Request: error sending steps: 500" in caplog.text async def test_safely_send_steps_request_error( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, mock_chainlit_context, caplog, ): test_steps = [{"id": "test_step_id", "name": "Test Step"}] mock_request = Mock() mock_request.url = "https://example.com/api" mock_literal_client.api.send_steps.side_effect = RequestError( "Request Error", request=mock_request ) async with mock_chainlit_context: await literal_data_layer.safely_send_steps(test_steps) mock_literal_client.api.send_steps.assert_awaited_once_with(test_steps) assert "HTTP Request: error for 'https://example.com/api'." in caplog.text async def test_get_user( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, literal_test_user: LiteralUser, persisted_test_user: PersistedUser, ): mock_literal_client.api.get_user.return_value = literal_test_user user = await literal_data_layer.get_user("test_user_id") assert user is not None assert user.id == literal_test_user.id assert user.identifier == literal_test_user.identifier mock_literal_client.api.get_user.assert_awaited_once_with(identifier="test_user_id") async def test_get_user_not_found( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock ): mock_literal_client.api.get_user.return_value = None user = await literal_data_layer.get_user("non_existent_user_id") assert user is None mock_literal_client.api.get_user.assert_awaited_once_with( identifier="non_existent_user_id" ) async def test_create_user_not_existing( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, test_user: User, literal_test_user: LiteralUser, ): mock_literal_client.api.get_user.return_value = None mock_literal_client.api.create_user.return_value = literal_test_user persisted_user = await literal_data_layer.create_user(test_user) mock_literal_client.api.create_user.assert_awaited_once_with( identifier=test_user.identifier, metadata=test_user.metadata ) assert persisted_user is not None assert isinstance(persisted_user, PersistedUser) assert persisted_user.id == literal_test_user.id assert persisted_user.identifier == literal_test_user.identifier async def test_create_user_update_existing( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, test_user: User, literal_test_user: LiteralUser, persisted_test_user: PersistedUser, ): mock_literal_client.api.get_user.return_value = literal_test_user persisted_user = await literal_data_layer.create_user(test_user) mock_literal_client.api.create_user.assert_not_called() mock_literal_client.api.update_user.assert_awaited_once_with( id=literal_test_user.id, metadata=test_user.metadata ) assert persisted_user is not None assert isinstance(persisted_user, PersistedUser) assert persisted_user.id == literal_test_user.id assert persisted_user.identifier == literal_test_user.identifier async def test_create_user_id_none( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, test_user: User, literal_test_user: LiteralUser, ): """Weird edge case; persisted user without an id. Do we need this!??""" literal_test_user.id = None mock_literal_client.api.get_user.return_value = literal_test_user persisted_user = await literal_data_layer.create_user(test_user) mock_literal_client.api.create_user.assert_not_called() mock_literal_client.api.update_user.assert_not_called() assert persisted_user is not None assert isinstance(persisted_user, PersistedUser) assert persisted_user.id == "" assert persisted_user.identifier == literal_test_user.identifier async def test_update_thread( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, test_thread: LiteralThread, ): await literal_data_layer.update_thread(test_thread.id, name=test_thread.name) mock_literal_client.api.upsert_thread.assert_awaited_once_with( id=test_thread.id, name=test_thread.name, participant_id=None, metadata=None, tags=None, ) async def test_get_thread_author( literal_data_layer, mock_literal_client: Mock, test_thread: LiteralThread ): test_thread.participant_identifier = "test_user_identifier" mock_literal_client.api.get_thread.return_value = test_thread author = await literal_data_layer.get_thread_author(test_thread.id) assert author == "test_user_identifier" mock_literal_client.api.get_thread.assert_awaited_once_with(id=test_thread.id) async def test_get_thread( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, test_thread: LiteralThread, test_step: LiteralStep, ): assert isinstance(test_thread.steps, list) test_thread.steps.append(test_step) mock_literal_client.api.get_thread.return_value = test_thread thread = await literal_data_layer.get_thread(test_thread.id) mock_literal_client.api.get_thread.assert_awaited_once_with(id=test_thread.id) assert thread is not None assert thread["id"] == test_thread.id assert thread["name"] == test_thread.name assert len(thread["steps"]) == 1 assert thread["steps"][0].get("id") == test_step.id async def test_get_thread_with_stub_step( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, test_thread: LiteralThread, ): # Create a step that should be stubbed stub_step = LiteralStep.from_dict( { "id": "stub_step_id", "name": "Stub Step", "type": "undefined", "threadId": test_thread.id, "createdAt": "2023-01-01T00:00:00Z", } ) test_thread.steps = [stub_step] mock_literal_client.api.get_thread.return_value = test_thread # Mock the config.ui.cot value to ensure check_add_step_in_cot returns False with patch("chainlit.config.config.ui.cot", "hidden"): thread = await literal_data_layer.get_thread(test_thread.id) mock_literal_client.api.get_thread.assert_awaited_once_with(id=test_thread.id) assert thread is not None assert thread["id"] == test_thread.id assert thread["name"] == test_thread.name assert len(thread["steps"]) == 1 assert thread["steps"][0].get("id") == "stub_step_id" assert thread["steps"][0].get("type") == "undefined" assert thread["steps"][0].get("input") == "" assert thread["steps"][0].get("output") == "" # Additional assertions to ensure the step is stubbed assert "metadata" not in thread["steps"][0] assert "createdAt" not in thread["steps"][0] async def test_get_thread_with_attachment( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, test_thread: LiteralThread, test_step: LiteralStep, test_attachment: LiteralAttachment, ): # Add the attachment to the test step test_step.attachments = [test_attachment] test_thread.steps = [test_step] mock_literal_client.api.get_thread.return_value = test_thread thread = await literal_data_layer.get_thread(test_thread.id) mock_literal_client.api.get_thread.assert_awaited_once_with(id=test_thread.id) assert thread is not None assert thread["id"] == test_thread.id assert thread["name"] == test_thread.name assert thread["steps"] is not None assert len(thread["steps"]) == 1 assert thread["elements"] is not None assert len(thread["elements"]) == 1 element = thread["elements"][0] if thread["elements"] else None assert element is not None assert element["id"] == "test_attachment_id" assert element["forId"] == test_step.id assert element["threadId"] == test_thread.id assert element["type"] == "file" assert element["display"] == "side" assert element["language"] == "python" assert element["mime"] == "text/plain" assert element["name"] == "test_file.txt" assert element["objectKey"] == "test_object_key" assert element["url"] == "https://example.com/test_file.txt" async def test_get_thread_non_existing( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock ): mock_literal_client.api.get_thread.return_value = None thread = await literal_data_layer.get_thread("non_existent_thread_id") mock_literal_client.api.get_thread.assert_awaited_once_with( id="non_existent_thread_id" ) assert thread is None async def test_delete_thread( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, test_thread: LiteralThread, ): await literal_data_layer.delete_thread(test_thread.id) mock_literal_client.api.delete_thread.assert_awaited_once_with(id=test_thread.id) async def test_list_threads( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, test_filters: ThreadFilter, test_pagination: Pagination, ): response: PaginatedResponse[Thread] = PaginatedResponse( page_info=PageInfo( has_next_page=True, start_cursor="start_cursor", end_cursor="end_cursor" ), data=[ Thread( id="thread1", name="Thread 1", ), Thread( id="thread2", name="Thread 2", ), ], ) mock_literal_client.api.list_threads.return_value = response result = await literal_data_layer.list_threads(test_pagination, test_filters) mock_literal_client.api.list_threads.assert_awaited_once_with( first=10, after=None, filters=[ {"field": "participantId", "operator": "eq", "value": "user1"}, { "field": "stepOutput", "operator": "ilike", "value": "test", "path": "content", }, { "field": "scoreValue", "operator": "eq", "value": 1, "path": "user-feedback", }, ], order_by={"column": "createdAt", "direction": "DESC"}, ) assert result.pageInfo.hasNextPage assert result.pageInfo.startCursor == "start_cursor" assert result.pageInfo.endCursor == "end_cursor" assert len(result.data) == 2 assert result.data[0]["id"] == "thread1" assert result.data[1]["id"] == "thread2" async def test_create_element( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, mock_chainlit_context, ): mock_literal_client.api.upload_file.return_value = {"object_key": "test_object_key"} 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 literal_data_layer.create_element(text_element) mock_literal_client.api.upload_file.assert_awaited_once_with( content=text_element.content, mime=text_element.mime, thread_id=text_element.thread_id, ) mock_literal_client.api.send_steps.assert_awaited_once_with( [ { "id": text_element.for_id, "threadId": text_element.thread_id, "attachments": [ { "id": ANY, "name": text_element.name, "metadata": { "size": None, "language": None, "display": text_element.display, "type": text_element.type, "page": None, "props": None, }, "mime": text_element.mime, "url": None, "objectKey": "test_object_key", } ], } ] ) async def test_get_element( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, test_attachment: LiteralAttachment, ): mock_literal_client.api.get_attachment.return_value = test_attachment element_dict = await literal_data_layer.get_element( "test_thread_id", "test_element_id" ) mock_literal_client.api.get_attachment.assert_awaited_once_with( id="test_element_id" ) assert element_dict is not None # Compare element_dict attributes to attachment attributes assert element_dict["id"] == test_attachment.id assert element_dict["forId"] == test_attachment.step_id assert element_dict["threadId"] == test_attachment.thread_id assert element_dict["name"] == test_attachment.name assert element_dict["mime"] == test_attachment.mime assert element_dict["url"] == test_attachment.url assert element_dict["objectKey"] == test_attachment.object_key assert test_attachment.metadata assert element_dict["display"] == test_attachment.metadata["display"] assert element_dict["language"] == test_attachment.metadata["language"] assert element_dict["type"] == test_attachment.metadata["type"] async def test_upsert_feedback_create( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, ): feedback = Feedback(forId="test_step_id", value=1, comment="Great!") mock_literal_client.api.create_score.return_value = Mock(id="new_feedback_id") result = await literal_data_layer.upsert_feedback(feedback) mock_literal_client.api.create_score.assert_awaited_once_with( step_id="test_step_id", value=1, comment="Great!", name="user-feedback", type="HUMAN", ) assert result == "new_feedback_id" async def test_upsert_feedback_update( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, ): feedback = Feedback( id="existing_feedback_id", forId="test_step_id", value=0, comment="Needs improvement", ) result = await literal_data_layer.upsert_feedback(feedback) mock_literal_client.api.update_score.assert_awaited_once_with( id="existing_feedback_id", update_params={ "comment": "Needs improvement", "value": 0, }, ) assert result == "existing_feedback_id" async def test_delete_feedback( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, ): feedback_id = "test_feedback_id" result = await literal_data_layer.delete_feedback(feedback_id) mock_literal_client.api.delete_score.assert_awaited_once_with(id=feedback_id) assert result is True async def test_delete_feedback_empty_id( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, ): feedback_id = "" result = await literal_data_layer.delete_feedback(feedback_id) mock_literal_client.api.delete_score.assert_not_awaited() assert result is False async def test_build_debug_url( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, ): mock_literal_client.api.get_my_project_id.return_value = "test_project_id" mock_literal_client.api.url = "https://api.example.com" result = await literal_data_layer.build_debug_url() mock_literal_client.api.get_my_project_id.assert_awaited_once() assert ( result == "https://api.example.com/projects/test_project_id/logs/threads/[thread_id]?currentStepId=[step_id]" ) async def test_build_debug_url_error( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, caplog, ): mock_literal_client.api.get_my_project_id.side_effect = Exception("API Error") result = await literal_data_layer.build_debug_url() mock_literal_client.api.get_my_project_id.assert_awaited_once() assert result == "" assert "Error building debug url: API Error" in caplog.text async def test_delete_element( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, mock_chainlit_context, ): element_id = "test_element_id" async with mock_chainlit_context: await literal_data_layer.delete_element(element_id) mock_literal_client.api.delete_attachment.assert_awaited_once_with(id=element_id) async def test_delete_step( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, mock_chainlit_context, ): step_id = "test_step_id" async with mock_chainlit_context: await literal_data_layer.delete_step(step_id) mock_literal_client.api.delete_step.assert_awaited_once_with(id=step_id) async def test_update_step( literal_data_layer: LiteralDataLayer, mock_literal_client: Mock, mock_chainlit_context, test_step_dict: StepDict, ): async with mock_chainlit_context: await literal_data_layer.update_step(test_step_dict) mock_literal_client.api.send_steps.assert_awaited_once_with( [ { "createdAt": "2023-01-01T00:00:00Z", "startTime": "2023-01-01T00:00:00Z", "endTime": "2023-01-01T00:00:00Z", "generation": {}, "id": "test_step_id", "parentId": None, "name": "Test Step", "threadId": "test_thread_id", "type": "user_message", "tags": [], "metadata": { "key": "value", "waitForAnswer": True, "language": "en", "showInput": True, }, "input": {"content": "test input"}, "output": {"content": "test output"}, } ] ) def test_steptype_to_steptype(): assert ( LiteralToChainlitConverter.steptype_to_steptype("user_message") == "user_message" ) assert ( LiteralToChainlitConverter.steptype_to_steptype("assistant_message") == "assistant_message" ) assert ( LiteralToChainlitConverter.steptype_to_steptype("system_message") == "system_message" ) assert LiteralToChainlitConverter.steptype_to_steptype("tool") == "tool" assert LiteralToChainlitConverter.steptype_to_steptype(None) == "undefined" def test_score_to_feedbackdict(): score = LiteralScore( id="test_score_id", step_id="test_step_id", value=1, comment="Great job!", name="user-feedback", type="HUMAN", dataset_experiment_item_id=None, tags=None, ) feedback_dict = LiteralToChainlitConverter.score_to_feedbackdict(score) assert feedback_dict == { "id": "test_score_id", "forId": "test_step_id", "value": 1, "comment": "Great job!", } assert LiteralToChainlitConverter.score_to_feedbackdict(None) is None score.value = 0 feedback_dict = LiteralToChainlitConverter.score_to_feedbackdict(score) assert feedback_dict is not None assert feedback_dict["value"] == 0 score.id = None score.step_id = None feedback_dict = LiteralToChainlitConverter.score_to_feedbackdict(score) assert feedback_dict is not None assert feedback_dict["id"] == "" assert feedback_dict["forId"] == "" def test_step_to_stepdict(): literal_step = LiteralStep.from_dict( { "id": "test_step_id", "threadId": "test_thread_id", "type": "user_message", "name": "Test Step", "input": {"content": "test input"}, "output": {"content": "test output"}, "startTime": "2023-01-01T00:00:00Z", "endTime": "2023-01-01T00:00:01Z", "createdAt": "2023-01-01T00:00:00Z", "metadata": {"showInput": True, "language": "en"}, "error": None, "scores": [ { "id": "test_score_id", "stepId": "test_step_id", "value": 1, "comment": "Great job!", "name": "user-feedback", "type": "HUMAN", } ], } ) step_dict = LiteralToChainlitConverter.step_to_stepdict(literal_step) assert step_dict.get("id") == "test_step_id" assert step_dict.get("threadId") == "test_thread_id" assert step_dict.get("type") == "user_message" assert step_dict.get("name") == "Test Step" assert step_dict.get("input") == "test input" assert step_dict.get("output") == "test output" assert step_dict.get("start") == "2023-01-01T00:00:00Z" assert step_dict.get("end") == "2023-01-01T00:00:01Z" assert step_dict.get("createdAt") == "2023-01-01T00:00:00Z" assert step_dict.get("showInput") == True assert step_dict.get("language") == "en" assert step_dict.get("isError") == False assert step_dict.get("feedback") == { "id": "test_score_id", "forId": "test_step_id", "value": 1, "comment": "Great job!", } def test_attachment_to_elementdict(): attachment = Attachment( id="test_attachment_id", step_id="test_step_id", thread_id="test_thread_id", name="test.txt", mime="text/plain", url="https://example.com/test.txt", object_key="test_object_key", metadata={ "display": "side", "language": "python", "type": "file", "size": "large", }, ) element_dict = LiteralToChainlitConverter.attachment_to_elementdict(attachment) assert element_dict["id"] == "test_attachment_id" assert element_dict["forId"] == "test_step_id" assert element_dict["threadId"] == "test_thread_id" assert element_dict["name"] == "test.txt" assert element_dict["mime"] == "text/plain" assert element_dict["url"] == "https://example.com/test.txt" assert element_dict["objectKey"] == "test_object_key" assert element_dict["display"] == "side" assert element_dict["language"] == "python" assert element_dict["type"] == "file" assert element_dict["size"] == "large" def test_attachment_to_element(): attachment = Attachment( id="test_attachment_id", step_id="test_step_id", thread_id="test_thread_id", name="test.txt", mime="text/plain", url="https://example.com/test.txt", object_key="test_object_key", metadata={ "display": "side", "language": "python", "type": "text", "size": "small", }, ) element = LiteralToChainlitConverter.attachment_to_element(attachment) assert isinstance(element, Text) assert element.id == "test_attachment_id" assert element.for_id == "test_step_id" assert element.thread_id == "test_thread_id" assert element.name == "test.txt" assert element.mime == "text/plain" assert element.url == "https://example.com/test.txt" assert element.object_key == "test_object_key" assert element.display == "side" assert element.language == "python" assert element.size == "small" # Test other element types for element_type in ["file", "image", "audio", "video", "pdf"]: attachment.metadata = {"type": element_type, "size": "small"} element = LiteralToChainlitConverter.attachment_to_element(attachment) assert isinstance( element, { "file": File, "image": Image, "audio": Audio, "video": Video, "text": Text, "pdf": Pdf, }[element_type], ) def test_step_to_step(): literal_step = LiteralStep.from_dict( { "id": "test_step_id", "threadId": "test_thread_id", "type": "user_message", "name": "Test Step", "input": {"content": "test input"}, "output": {"content": "test output"}, "startTime": "2023-01-01T00:00:00Z", "endTime": "2023-01-01T00:00:01Z", "createdAt": "2023-01-01T00:00:00Z", "metadata": {"showInput": True, "language": "en"}, "error": None, "attachments": [ { "id": "test_attachment_id", "name": "test.txt", "mime": "text/plain", "url": "https://example.com/test.txt", "objectKey": "test_object_key", "metadata": { "display": "side", "language": "python", "type": "text", }, } ], } ) chainlit_step = LiteralToChainlitConverter.step_to_step(literal_step) assert isinstance(chainlit_step, Step) assert chainlit_step.id == "test_step_id" assert chainlit_step.thread_id == "test_thread_id" assert chainlit_step.type == "user_message" assert chainlit_step.name == "Test Step" assert chainlit_step.input == "test input" assert chainlit_step.output == "test output" assert chainlit_step.start == "2023-01-01T00:00:00Z" assert chainlit_step.end == "2023-01-01T00:00:01Z" assert chainlit_step.created_at == "2023-01-01T00:00:00Z" assert chainlit_step.metadata == {"showInput": True, "language": "en"} assert not chainlit_step.is_error assert chainlit_step.elements is not None assert len(chainlit_step.elements) == 1 assert isinstance(chainlit_step.elements[0], Text) def test_thread_to_threaddict(): attachment_dict = LiteralAttachmentDict( id="test_attachment_id", stepId="test_step_id", threadId="test_thread_id", name="test.txt", mime="text/plain", url="https://example.com/test.txt", objectKey="test_object_key", metadata={ "display": "side", "language": "python", "type": "text", }, ) step_dict = LiteralStepDict( id="test_step_id", threadId="test_thread_id", type="user_message", name="Test Step", input={"content": "test input"}, output={"content": "test output"}, startTime="2023-01-01T00:00:00Z", endTime="2023-01-01T00:00:01Z", createdAt="2023-01-01T00:00:00Z", metadata={"showInput": True, "language": "en"}, error=None, attachments=[attachment_dict], ) literal_thread = LiteralThread.from_dict( LiteralThreadDict( id="test_thread_id", name="Test Thread", createdAt="2023-01-01T00:00:00Z", participant=UserDict(id="test_user_id", identifier="test_user_identifier_"), tags=["tag1", "tag2"], metadata={"key": "value"}, steps=[step_dict], ) ) thread_dict = LiteralToChainlitConverter.thread_to_threaddict(literal_thread) assert thread_dict["id"] == "test_thread_id" assert thread_dict["name"] == "Test Thread" assert thread_dict["createdAt"] == "2023-01-01T00:00:00Z" assert thread_dict["userId"] == "test_user_id" assert thread_dict["userIdentifier"] == "test_user_identifier_" assert thread_dict["tags"] == ["tag1", "tag2"] assert thread_dict["metadata"] == {"key": "value"} assert thread_dict["steps"] is not None assert len(thread_dict["steps"]) == 1 assert thread_dict["elements"] is not None assert len(thread_dict["elements"]) == 1 ================================================ FILE: backend/tests/data/test_sql_alchemy.py ================================================ import uuid from pathlib import Path import pytest from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from chainlit import User from chainlit.data.sql_alchemy import SQLAlchemyDataLayer from chainlit.data.storage_clients.base import BaseStorageClient from chainlit.element import Text @pytest.fixture async def data_layer(mock_storage_client: BaseStorageClient, tmp_path: Path): db_file = tmp_path / "test_db.sqlite" conninfo = f"sqlite+aiosqlite:///{db_file}" # Create async engine engine = create_async_engine(conninfo) # Execute initialization statements # Ref: https://docs.chainlit.io/data-persistence/custom#sql-alchemy-data-layer async with engine.begin() as conn: await conn.execute( text( """ CREATE TABLE users ( "id" UUID PRIMARY KEY, "identifier" TEXT NOT NULL UNIQUE, "metadata" JSONB NOT NULL, "createdAt" TEXT ); """ ) ) await conn.execute( text( """ CREATE TABLE IF NOT EXISTS threads ( "id" UUID PRIMARY KEY, "createdAt" TEXT, "name" TEXT, "userId" UUID, "userIdentifier" TEXT, "tags" TEXT[], "metadata" JSONB, FOREIGN KEY ("userId") REFERENCES users("id") ON DELETE CASCADE ); """ ) ) await conn.execute( text( """ CREATE TABLE IF NOT EXISTS steps ( "id" UUID PRIMARY KEY, "name" TEXT NOT NULL, "type" TEXT NOT NULL, "threadId" UUID NOT NULL, "parentId" UUID, "disableFeedback" BOOLEAN NOT NULL, "streaming" BOOLEAN NOT NULL, "waitForAnswer" BOOLEAN, "isError" BOOLEAN, "metadata" JSONB, "tags" TEXT[], "input" TEXT, "output" TEXT, "createdAt" TEXT, "start" TEXT, "end" TEXT, "generation" JSONB, "showInput" TEXT, "language" TEXT, "indent" INT ); """ ) ) await conn.execute( text( """ CREATE TABLE IF NOT EXISTS elements ( "id" UUID PRIMARY KEY, "threadId" UUID, "type" TEXT, "url" TEXT, "chainlitKey" TEXT, "name" TEXT NOT NULL, "display" TEXT, "objectKey" TEXT, "size" TEXT, "page" INT, "language" TEXT, "forId" UUID, "mime" TEXT ); """ ) ) await conn.execute( text( """ CREATE TABLE IF NOT EXISTS feedbacks ( "id" UUID PRIMARY KEY, "forId" UUID NOT NULL, "threadId" UUID NOT NULL, "value" INT NOT NULL, "comment" TEXT ); """ ) ) # Create SQLAlchemyDataLayer instance data_layer = SQLAlchemyDataLayer(conninfo, storage_provider=mock_storage_client) return data_layer async def test_create_and_get_element( mock_chainlit_context, data_layer: SQLAlchemyDataLayer ): 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", ) # Needs context because of wrapper in utils.py await data_layer.create_element(text_element) retrieved_element = await data_layer.get_element( text_element.thread_id, text_element.id ) assert retrieved_element is not None assert retrieved_element["id"] == text_element.id assert retrieved_element["name"] == text_element.name assert retrieved_element["mime"] == text_element.mime # The 'content' field is not part of the ElementDict, so we remove this assertion async def test_get_current_timestamp(data_layer: SQLAlchemyDataLayer): timestamp = await data_layer.get_current_timestamp() assert isinstance(timestamp, str) async def test_get_user(test_user: User, data_layer: SQLAlchemyDataLayer): persisted_user = await data_layer.create_user(test_user) assert persisted_user fetched_user = await data_layer.get_user(persisted_user.identifier) assert fetched_user assert fetched_user.createdAt == persisted_user.createdAt assert fetched_user.id == persisted_user.id nonexistent_user = await data_layer.get_user("nonexistent") assert nonexistent_user is None async def test_create_user(test_user: User, data_layer: SQLAlchemyDataLayer): persisted_user = await data_layer.create_user(test_user) assert persisted_user assert persisted_user.identifier == test_user.identifier assert persisted_user.createdAt assert persisted_user.id # Assert id is valid uuid assert uuid.UUID(persisted_user.id) async def test_update_thread(test_user: User, data_layer: SQLAlchemyDataLayer): persisted_user = await data_layer.create_user(test_user) assert persisted_user await data_layer.update_thread("test_thread") async def test_get_thread_author(test_user: User, data_layer: SQLAlchemyDataLayer): persisted_user = await data_layer.create_user(test_user) assert persisted_user await data_layer.update_thread("test_thread", user_id=persisted_user.id) author = await data_layer.get_thread_author("test_thread") assert author == persisted_user.identifier async def test_get_thread(test_user: User, data_layer: SQLAlchemyDataLayer): persisted_user = await data_layer.create_user(test_user) assert persisted_user await data_layer.update_thread("test_thread") result = await data_layer.get_thread("test_thread") assert result is not None result = await data_layer.get_thread("nonexisting_thread") assert result is None async def test_delete_thread(test_user: User, data_layer: SQLAlchemyDataLayer): persisted_user = await data_layer.create_user(test_user) assert persisted_user await data_layer.update_thread("test_thread", "test_user") await data_layer.delete_thread("test_thread") thread = await data_layer.get_thread("test_thread") assert thread is None ================================================ FILE: backend/tests/langchain/__init__.py ================================================ ================================================ FILE: backend/tests/langchain/test_async_callback.py ================================================ """Tests for async LangChain callback handlers.""" from datetime import datetime from unittest.mock import AsyncMock, Mock, patch from uuid import uuid4 import pytest from langchain_core.outputs import GenerationChunk from chainlit.langchain.callbacks import LangchainTracer from chainlit.step import Step def create_mock_run(**kwargs): """Helper to create a properly mocked Run object with all required attributes.""" run = Mock() run.id = kwargs.get("id", uuid4()) run.parent_run_id = kwargs.get("parent_run_id", None) run.name = kwargs.get("name", "test_run") run.run_type = kwargs.get("run_type", "llm") run.tags = kwargs.get("tags", []) run.inputs = kwargs.get("inputs", {}) run.outputs = kwargs.get("outputs", None) run.serialized = kwargs.get("serialized", {}) run.extra = kwargs.get("extra", {}) run.start_time = kwargs.get("start_time", datetime.now()) run.end_time = kwargs.get("end_time", None) run.error = kwargs.get("error", None) return run @pytest.fixture def mock_run(): """Create a mock LangChain Run object.""" return create_mock_run( name="test_run", run_type="llm", inputs={"input": "test input"}, serialized={"name": "test_llm"}, ) async def test_tracer_initialization(mock_chainlit_context): """Test LangchainTracer initialization.""" async with mock_chainlit_context: tracer = LangchainTracer() assert tracer.steps == {} assert tracer.parent_id_map == {} assert tracer.ignored_runs == set() assert tracer.stream_final_answer is False assert tracer.answer_reached is False async def test_on_llm_start(mock_chainlit_context): """Test on_llm_start callback.""" async with mock_chainlit_context: tracer = LangchainTracer() run_id = uuid4() prompts = ["Test prompt"] await tracer.on_llm_start( serialized={"name": "test_llm"}, prompts=prompts, run_id=run_id, ) assert str(run_id) in tracer.completion_generations completion_gen = tracer.completion_generations[str(run_id)] assert completion_gen["prompt"] == "Test prompt" assert completion_gen["token_count"] == 0 assert completion_gen["tt_first_token"] is None assert "start" in completion_gen async def test_on_llm_new_token(mock_chainlit_context): """Test on_llm_new_token with token streaming.""" async with mock_chainlit_context: tracer = LangchainTracer() run_id = uuid4() await tracer.on_llm_start( serialized={"name": "test_llm"}, prompts=["Test prompt"], run_id=run_id, ) chunk = GenerationChunk(text="Hello") await tracer.on_llm_new_token( token="Hello", chunk=chunk, run_id=run_id, ) completion_gen = tracer.completion_generations[str(run_id)] assert completion_gen["token_count"] == 1 assert completion_gen["tt_first_token"] is not None async def test_start_trace(mock_chainlit_context): """Test _start_trace creates steps correctly.""" async with mock_chainlit_context: tracer = LangchainTracer() # Test LLM run llm_run = create_mock_run( id=uuid4(), name="test_llm", run_type="llm", inputs={"input": "test"}, ) with patch.object(Step, "send", new_callable=AsyncMock): await tracer._start_trace(llm_run) assert str(llm_run.id) in tracer.steps assert tracer.steps[str(llm_run.id)].type == "llm" # Test ignored run ignored_run = create_mock_run( id=uuid4(), name="RunnableSequence", run_type="chain", ) tracer.to_ignore = ["RunnableSequence"] await tracer._start_trace(ignored_run) assert str(ignored_run.id) in tracer.ignored_runs async def test_on_run_update(mock_chainlit_context): """Test _on_run_update updates steps.""" async with mock_chainlit_context: tracer = LangchainTracer() run_id = uuid4() step = Step(id=str(run_id), name="test_tool", type="tool") tracer.steps[str(run_id)] = step run = create_mock_run( id=run_id, name="test_tool", run_type="tool", outputs={"output": "result"}, ) with patch.object(step, "update", new_callable=AsyncMock): await tracer._on_run_update(run) assert step.output is not None async def test_error_handling(mock_chainlit_context): """Test error handling in callbacks.""" async with mock_chainlit_context: tracer = LangchainTracer() run_id = uuid4() step = Step(id=str(run_id), name="test_step", type="llm") tracer.steps[str(run_id)] = step error = ValueError("Test error") with patch.object(step, "update", new_callable=AsyncMock): await tracer._on_error(error, run_id=run_id) assert step.is_error is True assert step.output == "Test error" ================================================ FILE: backend/tests/langchain/test_chain_types.py ================================================ """Tests for different LangChain chain types and their integration with Chainlit.""" from datetime import datetime from unittest.mock import AsyncMock, Mock, patch from uuid import uuid4 from chainlit.langchain.callbacks import LangchainTracer from chainlit.step import Step def create_mock_run(**kwargs): """Helper to create a properly mocked Run object with all required attributes.""" run = Mock() run.id = kwargs.get("id", uuid4()) run.parent_run_id = kwargs.get("parent_run_id", None) run.name = kwargs.get("name", "test_run") run.run_type = kwargs.get("run_type", "llm") run.tags = kwargs.get("tags", []) run.inputs = kwargs.get("inputs", {}) run.outputs = kwargs.get("outputs", None) run.serialized = kwargs.get("serialized", {}) run.extra = kwargs.get("extra", {}) run.start_time = kwargs.get("start_time", datetime.now()) run.end_time = kwargs.get("end_time", None) run.error = kwargs.get("error", None) return run async def test_different_run_types(mock_chainlit_context): """Test different LangChain run types create correct steps.""" async with mock_chainlit_context: tracer = LangchainTracer() # Test agent run agent_run = create_mock_run( id=uuid4(), name="test_agent", run_type="agent", inputs={"input": "test"}, ) with patch.object(Step, "send", new_callable=AsyncMock): await tracer._start_trace(agent_run) assert str(agent_run.id) in tracer.steps assert tracer.steps[str(agent_run.id)].type == "run" # Test tool run tool_run = create_mock_run( id=uuid4(), name="test_tool", run_type="tool", inputs={"query": "test"}, ) with patch.object(Step, "send", new_callable=AsyncMock): await tracer._start_trace(tool_run) assert str(tool_run.id) in tracer.steps assert tracer.steps[str(tool_run.id)].type == "tool" async def test_nested_chain_hierarchy(mock_chainlit_context): """Test nested chain with LLM calls.""" async with mock_chainlit_context: tracer = LangchainTracer() # Create parent chain chain_run = create_mock_run( id=uuid4(), name="parent_chain", run_type="chain", inputs={"input": "test"}, ) with patch.object(Step, "send", new_callable=AsyncMock): await tracer._start_trace(chain_run) # Create nested LLM llm_run = create_mock_run( id=uuid4(), parent_run_id=chain_run.id, name="nested_llm", run_type="llm", inputs={}, ) with patch.object(Step, "send", new_callable=AsyncMock): await tracer._start_trace(llm_run) # Both should exist assert str(chain_run.id) in tracer.steps assert str(llm_run.id) in tracer.steps # LLM should have chain as parent llm_step = tracer.steps[str(llm_run.id)] assert llm_step.parent_id == str(chain_run.id) async def test_ignored_runs(mock_chainlit_context): """Test that default ignored runs are properly filtered.""" async with mock_chainlit_context: tracer = LangchainTracer() # Test RunnableSequence is ignored sequence_run = create_mock_run( id=uuid4(), name="RunnableSequence", run_type="chain", inputs={}, ) await tracer._start_trace(sequence_run) assert str(sequence_run.id) not in tracer.steps assert str(sequence_run.id) in tracer.ignored_runs async def test_custom_filtering(mock_chainlit_context): """Test custom to_ignore and to_keep lists.""" async with mock_chainlit_context: tracer = LangchainTracer(to_ignore=["CustomIgnore"], to_keep=["llm", "tool"]) # Test custom ignore custom_run = create_mock_run( id=uuid4(), name="CustomIgnore", run_type="chain", ) await tracer._start_trace(custom_run) assert str(custom_run.id) not in tracer.steps assert str(custom_run.id) in tracer.ignored_runs ================================================ FILE: backend/tests/langchain/test_sync_callback.py ================================================ """Tests for synchronous LangChain callback operations and helper classes.""" from datetime import datetime from unittest.mock import Mock from uuid import uuid4 from langchain.schema import AIMessage, HumanMessage from chainlit.langchain.callbacks import ( FinalStreamHelper, GenerationHelper, LangchainTracer, ) def create_mock_run(**kwargs): """Helper to create a properly mocked Run object with all required attributes.""" run = Mock() run.id = kwargs.get("id", uuid4()) run.parent_run_id = kwargs.get("parent_run_id", None) run.name = kwargs.get("name", "test_run") run.run_type = kwargs.get("run_type", "llm") run.tags = kwargs.get("tags", []) run.inputs = kwargs.get("inputs", {}) run.outputs = kwargs.get("outputs", None) run.serialized = kwargs.get("serialized", {}) run.extra = kwargs.get("extra", {}) run.start_time = kwargs.get("start_time", datetime.now()) run.end_time = kwargs.get("end_time", None) run.error = kwargs.get("error", None) return run class TestFinalStreamHelper: """Test FinalStreamHelper class.""" def test_initialization(self): """Test FinalStreamHelper initialization.""" helper = FinalStreamHelper() assert helper.answer_prefix_tokens == ["Final", "Answer", ":"] assert helper.stream_final_answer is False assert helper.answer_reached is False def test_check_if_answer_reached(self): """Test _check_if_answer_reached.""" helper = FinalStreamHelper() helper._append_to_last_tokens("Final") helper._append_to_last_tokens("Answer") helper._append_to_last_tokens(":") assert helper._check_if_answer_reached() is True class TestGenerationHelper: """Test GenerationHelper class.""" def test_initialization(self): """Test GenerationHelper initialization.""" helper = GenerationHelper() assert helper.chat_generations == {} assert helper.completion_generations == {} assert helper.generation_inputs == {} def test_ensure_values_serializable(self): """Test ensure_values_serializable method.""" helper = GenerationHelper() # Test dict result = helper.ensure_values_serializable({"key": "value", "num": 42}) assert result == {"key": "value", "num": 42} # Test list result = helper.ensure_values_serializable([1, 2, "three"]) assert result == [1, 2, "three"] def test_convert_message(self): """Test _convert_message method.""" helper = GenerationHelper() # Test HumanMessage msg = HumanMessage(content="Hello") result = helper._convert_message(msg) assert result["role"] == "user" assert result["content"] == "Hello" # Test AIMessage msg = AIMessage(content="Hi there") result = helper._convert_message(msg) assert result["role"] == "assistant" assert result["content"] == "Hi there" def test_build_llm_settings(self): """Test _build_llm_settings method.""" helper = GenerationHelper() serialized = {"name": "ChatOpenAI"} invocation_params = { "_type": "openai", "model": "gpt-4", "temperature": 0.7, } provider, model, _, settings = helper._build_llm_settings( serialized, invocation_params ) assert provider == "openai" assert model == "gpt-4" assert settings["temperature"] == 0.7 async def test_should_ignore_run(mock_chainlit_context): """Test _should_ignore_run method.""" async with mock_chainlit_context: tracer = LangchainTracer(to_ignore=["RunnableSequence"]) # Test ignored by name run = create_mock_run(name="RunnableSequence", run_type="chain") ignore, _ = tracer._should_ignore_run(run) assert ignore is True # Test not ignored run = create_mock_run(name="MyChain", run_type="chain") ignore, _ = tracer._should_ignore_run(run) assert ignore is False async def test_get_non_ignored_parent_id(mock_chainlit_context): """Test _get_non_ignored_parent_id.""" async with mock_chainlit_context: tracer = LangchainTracer() # Setup parent chain grandparent_id = str(uuid4()) parent_id = str(uuid4()) # Both parent and grandparent need to be in parent_id_map for the traversal to work tracer.parent_id_map[parent_id] = grandparent_id tracer.parent_id_map[grandparent_id] = None # grandparent has no parent tracer.ignored_runs.add(parent_id) result = tracer._get_non_ignored_parent_id(parent_id) assert result == grandparent_id ================================================ FILE: backend/tests/llama_index/test_callbacks.py ================================================ from unittest.mock import patch from llama_index.core.callbacks.schema import CBEventType, EventPayload from llama_index.core.tools.types import ToolMetadata from chainlit.llama_index.callbacks import LlamaIndexCallbackHandler from chainlit.step import Step async def test_on_event_start_for_function_calls(mock_chainlit_context): TEST_EVENT_ID = "test_event_id" async with mock_chainlit_context: handler = LlamaIndexCallbackHandler() with patch.object(Step, "send") as mock_send: result = handler.on_event_start( CBEventType.FUNCTION_CALL, { EventPayload.TOOL: ToolMetadata( name="test_tool", description="test_description" ), EventPayload.FUNCTION_CALL: {"arg1": "value1"}, }, TEST_EVENT_ID, ) assert result == TEST_EVENT_ID assert TEST_EVENT_ID in handler.steps step = handler.steps[TEST_EVENT_ID] assert isinstance(step, Step) assert step.name == "test_tool" assert step.type == "tool" assert step.id == TEST_EVENT_ID assert step.input == '{\n "arg1": "value1"\n}' mock_send.assert_called_once() async def test_on_event_start_for_function_calls_missing_payload(mock_chainlit_context): TEST_EVENT_ID = "test_event_id" async with mock_chainlit_context: handler = LlamaIndexCallbackHandler() with patch.object(Step, "send") as mock_send: result = handler.on_event_start( CBEventType.FUNCTION_CALL, None, TEST_EVENT_ID, ) assert result == TEST_EVENT_ID assert TEST_EVENT_ID in handler.steps step = handler.steps[TEST_EVENT_ID] assert isinstance(step, Step) assert step.name == "function_call" assert step.type == "tool" assert step.id == TEST_EVENT_ID assert step.input == "{}" mock_send.assert_called_once() async def test_on_event_end_for_function_calls(mock_chainlit_context): TEST_EVENT_ID = "test_event_id" async with mock_chainlit_context: handler = LlamaIndexCallbackHandler() # Pretend that we have started a step before. step = Step(name="test_tool", type="tool", id=TEST_EVENT_ID) handler.steps[TEST_EVENT_ID] = step with patch.object(step, "update") as mock_send: handler.on_event_end( CBEventType.FUNCTION_CALL, payload={EventPayload.FUNCTION_OUTPUT: "test_output"}, event_id=TEST_EVENT_ID, ) assert step.output == "test_output" assert TEST_EVENT_ID not in handler.steps mock_send.assert_called_once() async def test_on_event_end_for_function_calls_missing_payload(mock_chainlit_context): TEST_EVENT_ID = "test_event_id" async with mock_chainlit_context: handler = LlamaIndexCallbackHandler() # Pretend that we have started a step before. step = Step(name="test_tool", type="tool", id=TEST_EVENT_ID) handler.steps[TEST_EVENT_ID] = step with patch.object(step, "update") as mock_send: handler.on_event_end( CBEventType.FUNCTION_CALL, payload=None, event_id=TEST_EVENT_ID, ) # TODO: Is this the desired behavior? Shouldn't we still remove the step as long as we've been told it has ended, even if the payload is missing? assert TEST_EVENT_ID in handler.steps mock_send.assert_not_called() ================================================ FILE: backend/tests/test_action.py ================================================ import uuid import pytest from chainlit.action import Action @pytest.mark.asyncio class TestAction: """Test suite for the Action class.""" async def test_action_initialization_with_required_fields(self): """Test Action initialization with only required fields.""" action = Action( name="test_action", payload={"key": "value"}, ) assert action.name == "test_action" assert action.payload == {"key": "value"} assert action.label == "" assert action.tooltip == "" assert action.icon is None assert action.forId is None assert isinstance(action.id, str) # Verify ID is a valid UUID uuid.UUID(action.id) async def test_action_initialization_with_all_fields(self): """Test Action initialization with all fields provided.""" test_id = str(uuid.uuid4()) action = Action( name="custom_action", payload={"param1": "value1", "param2": 42}, label="Click Me", tooltip="This is a tooltip", icon="check-circle", id=test_id, ) assert action.name == "custom_action" assert action.payload == {"param1": "value1", "param2": 42} assert action.label == "Click Me" assert action.tooltip == "This is a tooltip" assert action.icon == "check-circle" assert action.id == test_id assert action.forId is None async def test_action_id_auto_generation(self): """Test that Action generates unique IDs automatically.""" action1 = Action(name="action1", payload={}) action2 = Action(name="action2", payload={}) assert action1.id != action2.id # Verify both are valid UUIDs uuid.UUID(action1.id) uuid.UUID(action2.id) async def test_action_to_dict(self): """Test Action serialization to dictionary.""" test_id = str(uuid.uuid4()) action = Action( name="test_action", payload={"data": "test"}, label="Test Label", tooltip="Test Tooltip", icon="star", id=test_id, ) action_dict = action.to_dict() assert action_dict["name"] == "test_action" assert action_dict["payload"] == {"data": "test"} assert action_dict["label"] == "Test Label" assert action_dict["tooltip"] == "Test Tooltip" assert action_dict["icon"] == "star" assert action_dict["id"] == test_id assert action_dict.get("forId") is None async def test_action_to_dict_with_for_id(self): """Test Action serialization includes forId when set.""" action = Action(name="test", payload={}) action.forId = "message_123" action_dict = action.to_dict() assert action_dict["forId"] == "message_123" async def test_action_send(self, mock_chainlit_context): """Test Action.send() method emits correct event.""" async with mock_chainlit_context as ctx: action = Action( name="send_action", payload={"test": "data"}, label="Send Test", ) for_id = "target_message_id" await action.send(for_id=for_id) # Verify forId was set assert action.forId == for_id # Verify emit was called with correct parameters ctx.emitter.emit.assert_called_once() call_args = ctx.emitter.emit.call_args assert call_args[0][0] == "action" emitted_dict = call_args[0][1] assert emitted_dict["name"] == "send_action" assert emitted_dict["payload"] == {"test": "data"} assert emitted_dict["label"] == "Send Test" assert emitted_dict["forId"] == for_id async def test_action_send_updates_for_id(self, mock_chainlit_context): """Test that send() updates the forId field.""" async with mock_chainlit_context: action = Action(name="test", payload={}) # Initially forId should be None assert action.forId is None # Send with first for_id await action.send(for_id="first_id") assert action.forId == "first_id" # Send with different for_id should update await action.send(for_id="second_id") assert action.forId == "second_id" async def test_action_remove(self, mock_chainlit_context): """Test Action.remove() method emits correct event.""" async with mock_chainlit_context as ctx: action = Action( name="remove_action", payload={"key": "value"}, label="Remove Test", ) action.forId = "message_123" await action.remove() # Verify emit was called with correct parameters ctx.emitter.emit.assert_called_once() call_args = ctx.emitter.emit.call_args assert call_args[0][0] == "remove_action" emitted_dict = call_args[0][1] assert emitted_dict["name"] == "remove_action" assert emitted_dict["payload"] == {"key": "value"} assert emitted_dict["forId"] == "message_123" async def test_action_remove_without_for_id(self, mock_chainlit_context): """Test Action.remove() works even without forId set.""" async with mock_chainlit_context as ctx: action = Action(name="test", payload={}) await action.remove() ctx.emitter.emit.assert_called_once() call_args = ctx.emitter.emit.call_args assert call_args[0][0] == "remove_action" async def test_action_with_complex_payload(self): """Test Action with complex nested payload.""" complex_payload = { "nested": { "data": [1, 2, 3], "info": {"key": "value"}, }, "list": ["a", "b", "c"], "number": 42, "boolean": True, "null": None, } action = Action(name="complex", payload=complex_payload) assert action.payload == complex_payload action_dict = action.to_dict() assert action_dict["payload"] == complex_payload async def test_action_with_empty_payload(self): """Test Action with empty payload.""" action = Action(name="empty", payload={}) assert action.payload == {} assert action.to_dict()["payload"] == {} async def test_action_with_empty_strings(self): """Test Action handles empty strings correctly.""" action = Action( name="test", payload={}, label="", tooltip="", ) assert action.label == "" assert action.tooltip == "" action_dict = action.to_dict() assert action_dict["label"] == "" assert action_dict["tooltip"] == "" async def test_action_serialization_deserialization(self): """Test Action can be serialized and deserialized.""" original = Action( name="serialize_test", payload={"data": "test"}, label="Test", tooltip="Tooltip", icon="icon-name", ) # Serialize to dict serialized = original.to_dict() # Deserialize from dict deserialized = Action.from_dict(serialized) assert deserialized.name == original.name assert deserialized.payload == original.payload assert deserialized.label == original.label assert deserialized.tooltip == original.tooltip assert deserialized.icon == original.icon assert deserialized.id == original.id async def test_multiple_actions_with_same_name(self): """Test that multiple actions can have the same name but different IDs.""" action1 = Action(name="duplicate", payload={"num": 1}) action2 = Action(name="duplicate", payload={"num": 2}) assert action1.name == action2.name assert action1.id != action2.id assert action1.payload != action2.payload async def test_action_send_multiple_times(self, mock_chainlit_context): """Test that an action can be sent multiple times.""" async with mock_chainlit_context as ctx: action = Action(name="multi_send", payload={}) await action.send(for_id="id1") await action.send(for_id="id2") await action.send(for_id="id3") # Should have been called 3 times assert ctx.emitter.emit.call_count == 3 # Last forId should be id3 assert action.forId == "id3" async def test_action_with_special_characters_in_payload(self): """Test Action handles special characters in payload.""" special_payload = { "unicode": "Hello 世界 🌍", "quotes": 'He said "Hello"', "newlines": "Line1\nLine2\nLine3", "tabs": "Col1\tCol2\tCol3", } action = Action(name="special", payload=special_payload) assert action.payload == special_payload action_dict = action.to_dict() assert action_dict["payload"] == special_payload async def test_action_icon_variations(self): """Test Action with different icon values.""" # With icon action_with_icon = Action(name="test", payload={}, icon="check") assert action_with_icon.icon == "check" # Without icon (None) action_no_icon = Action(name="test", payload={}, icon=None) assert action_no_icon.icon is None # Default (should be None) action_default = Action(name="test", payload={}) assert action_default.icon is None ================================================ FILE: backend/tests/test_cache.py ================================================ import sys import threading from unittest.mock import Mock, patch import pytest from chainlit.cache import cache, init_lc_cache # Import the actual cache module to access _cache dict cache_module = sys.modules["chainlit.cache"] class TestCacheDecorator: """Test suite for the @cache decorator.""" def setup_method(self): """Clear the cache before each test.""" cache_module._cache.clear() def teardown_method(self): """Clear the cache after each test.""" cache_module._cache.clear() def test_cache_basic_function(self): """Test that cache decorator caches function results.""" call_count = 0 @cache def add(a, b): nonlocal call_count call_count += 1 return a + b # First call result1 = add(2, 3) assert result1 == 5 assert call_count == 1 # Second call with same args should use cache result2 = add(2, 3) assert result2 == 5 assert call_count == 1 # Function not called again def test_cache_different_arguments(self): """Test that different arguments create different cache entries.""" call_count = 0 @cache def multiply(x, y): nonlocal call_count call_count += 1 return x * y result1 = multiply(2, 3) assert result1 == 6 assert call_count == 1 result2 = multiply(4, 5) assert result2 == 20 assert call_count == 2 # Different args, function called again # Same args as first call, should use cache result3 = multiply(2, 3) assert result3 == 6 assert call_count == 2 # Cache hit, no new call def test_cache_with_kwargs(self): """Test that cache works with keyword arguments.""" call_count = 0 @cache def greet(name, greeting="Hello"): nonlocal call_count call_count += 1 return f"{greeting}, {name}!" result1 = greet("Alice", greeting="Hi") assert result1 == "Hi, Alice!" assert call_count == 1 # Same call should use cache result2 = greet("Alice", greeting="Hi") assert result2 == "Hi, Alice!" assert call_count == 1 # Different kwargs should call function result3 = greet("Alice", greeting="Hello") assert result3 == "Hello, Alice!" assert call_count == 2 def test_cache_kwargs_order_independence(self): """Test that kwargs order doesn't affect cache key.""" call_count = 0 @cache def func(a=1, b=2, c=3): nonlocal call_count call_count += 1 return a + b + c result1 = func(a=1, b=2, c=3) assert result1 == 6 assert call_count == 1 # Same kwargs, different order - should use cache result2 = func(c=3, a=1, b=2) assert result2 == 6 assert call_count == 1 # Cache hit def test_cache_mixed_args_and_kwargs(self): """Test cache with both positional and keyword arguments.""" call_count = 0 @cache def compute(x, y, z=10): nonlocal call_count call_count += 1 return x + y + z result1 = compute(1, 2, z=3) assert result1 == 6 assert call_count == 1 result2 = compute(1, 2, z=3) assert result2 == 6 assert call_count == 1 # Cache hit result3 = compute(1, 2, z=5) assert result3 == 8 assert call_count == 2 # Different z value def test_cache_with_no_arguments(self): """Test cache with functions that take no arguments.""" call_count = 0 @cache def get_constant(): nonlocal call_count call_count += 1 return 42 result1 = get_constant() assert result1 == 42 assert call_count == 1 result2 = get_constant() assert result2 == 42 assert call_count == 1 # Cache hit def test_cache_with_mutable_return_value(self): """Test that cache returns the same object reference.""" call_count = 0 @cache def get_list(): nonlocal call_count call_count += 1 return [1, 2, 3] result1 = get_list() assert result1 == [1, 2, 3] assert call_count == 1 result2 = get_list() assert result2 == [1, 2, 3] assert call_count == 1 # Both results should be the same object assert result1 is result2 def test_cache_thread_safety(self): """Test that cache is thread-safe.""" call_count = 0 call_lock = threading.Lock() @cache def slow_function(x): nonlocal call_count with call_lock: call_count += 1 return x * 2 results = [] def worker(): result = slow_function(5) results.append(result) threads = [threading.Thread(target=worker) for _ in range(10)] for t in threads: t.start() for t in threads: t.join() # All results should be the same assert all(r == 10 for r in results) # Function should only be called once despite multiple threads assert call_count == 1 def test_cache_different_functions_same_args(self): """Test that different functions with same args have separate cache.""" call_count_1 = 0 call_count_2 = 0 @cache def func1(x): nonlocal call_count_1 call_count_1 += 1 return x + 1 @cache def func2(x): nonlocal call_count_2 call_count_2 += 1 return x + 2 result1 = func1(5) assert result1 == 6 assert call_count_1 == 1 result2 = func2(5) assert result2 == 7 assert call_count_2 == 1 # Both should use their own cache func1(5) func2(5) assert call_count_1 == 1 assert call_count_2 == 1 def test_cache_with_none_arguments(self): """Test cache with None as argument.""" call_count = 0 @cache def process(value): nonlocal call_count call_count += 1 return value result1 = process(None) assert result1 is None assert call_count == 1 result2 = process(None) assert result2 is None assert call_count == 1 # Cache hit def test_cache_preserves_function_behavior(self): """Test that cache decorator preserves original function behavior.""" @cache def divide(a, b): return a / b assert divide(10, 2) == 5.0 assert divide(10, 2) == 5.0 # Cached with pytest.raises(ZeroDivisionError): divide(10, 0) class TestInitLcCache: """Test suite for init_lc_cache function.""" def test_init_lc_cache_disabled_by_config(self): """Test that cache is not initialized when disabled in config.""" with patch.object(cache_module.config, "project") as mock_project: mock_project.cache = False with patch.object(cache_module.config, "run") as mock_run: mock_run.no_cache = False with patch.object( cache_module.importlib.util, "find_spec" ) as mock_find_spec: init_lc_cache() # Should not check for langchain if cache is disabled mock_find_spec.assert_not_called() def test_init_lc_cache_disabled_by_no_cache_flag(self): """Test that cache is not initialized when no_cache flag is set.""" with patch.object(cache_module.config, "project") as mock_project: mock_project.cache = True with patch.object(cache_module.config, "run") as mock_run: mock_run.no_cache = True with patch.object( cache_module.importlib.util, "find_spec" ) as mock_find_spec: init_lc_cache() # Should not check for langchain if no_cache is True mock_find_spec.assert_not_called() def test_init_lc_cache_langchain_not_installed(self): """Test behavior when langchain is not installed.""" with patch.object(cache_module.config, "project") as mock_project: mock_project.cache = True with patch.object(cache_module.config, "run") as mock_run: mock_run.no_cache = False with patch.object( cache_module.importlib.util, "find_spec", return_value=None ) as mock_find_spec: # Should not raise an error init_lc_cache() mock_find_spec.assert_called_once_with("langchain") def test_init_lc_cache_with_langchain_installed(self): """Test cache initialization when langchain is installed.""" with patch.object(cache_module.config, "project") as mock_project: mock_project.cache = True mock_project.lc_cache_path = "/tmp/test_cache.db" with patch.object(cache_module.config, "run") as mock_run: mock_run.no_cache = False mock_spec = Mock() with patch.object( cache_module.importlib.util, "find_spec", return_value=mock_spec ): # Mock langchain modules mock_sqlite_cache = Mock() mock_set_llm_cache = Mock() with patch.dict( sys.modules, { "langchain": Mock(), "langchain.cache": Mock(SQLiteCache=mock_sqlite_cache), "langchain.globals": Mock(set_llm_cache=mock_set_llm_cache), }, ): with patch("os.path.exists", return_value=True): init_lc_cache() mock_sqlite_cache.assert_called_once_with( database_path="/tmp/test_cache.db" ) mock_set_llm_cache.assert_called_once() def test_init_lc_cache_creates_new_cache_file(self): """Test that logger is called when creating new cache file.""" with patch.object(cache_module.config, "project") as mock_project: mock_project.cache = True mock_project.lc_cache_path = "/tmp/new_cache.db" with patch.object(cache_module.config, "run") as mock_run: mock_run.no_cache = False mock_spec = Mock() with patch.object( cache_module.importlib.util, "find_spec", return_value=mock_spec ): mock_sqlite_cache = Mock() mock_set_llm_cache = Mock() with patch.dict( sys.modules, { "langchain": Mock(), "langchain.cache": Mock(SQLiteCache=mock_sqlite_cache), "langchain.globals": Mock(set_llm_cache=mock_set_llm_cache), }, ): with patch("os.path.exists", return_value=False): with patch.object(cache_module, "logger") as mock_logger: init_lc_cache() mock_logger.info.assert_called_once() assert "LangChain cache created at" in str( mock_logger.info.call_args ) def test_init_lc_cache_without_cache_path(self): """Test that cache is not initialized when cache path is None.""" with patch.object(cache_module.config, "project") as mock_project: mock_project.cache = True mock_project.lc_cache_path = None with patch.object(cache_module.config, "run") as mock_run: mock_run.no_cache = False mock_spec = Mock() with patch.object( cache_module.importlib.util, "find_spec", return_value=mock_spec ): mock_sqlite_cache = Mock() mock_set_llm_cache = Mock() with patch.dict( sys.modules, { "langchain": Mock(), "langchain.cache": Mock(SQLiteCache=mock_sqlite_cache), "langchain.globals": Mock(set_llm_cache=mock_set_llm_cache), }, ): init_lc_cache() # Should not call SQLiteCache if path is None mock_sqlite_cache.assert_not_called() mock_set_llm_cache.assert_not_called() class TestCacheEdgeCases: """Test suite for cache edge cases.""" def setup_method(self): """Clear the cache before each test.""" cache_module._cache.clear() def teardown_method(self): """Clear the cache after each test.""" cache_module._cache.clear() def test_cache_with_unhashable_arguments(self): """Test that cache handles unhashable arguments gracefully.""" @cache def process_list(items): return sum(items) # Lists are unhashable and will cause an error with pytest.raises(TypeError): process_list([1, 2, 3]) def test_cache_with_string_arguments(self): """Test cache with string arguments.""" call_count = 0 @cache def process_string(s): nonlocal call_count call_count += 1 return s.upper() result1 = process_string("hello") assert result1 == "HELLO" assert call_count == 1 result2 = process_string("hello") assert result2 == "HELLO" assert call_count == 1 # Cache hit def test_cache_with_tuple_arguments(self): """Test cache with tuple arguments.""" call_count = 0 @cache def process_tuple(t): nonlocal call_count call_count += 1 return sum(t) result1 = process_tuple((1, 2, 3)) assert result1 == 6 assert call_count == 1 result2 = process_tuple((1, 2, 3)) assert result2 == 6 assert call_count == 1 # Cache hit def test_cache_with_boolean_arguments(self): """Test cache with boolean arguments.""" call_count = 0 @cache def process_bool(flag): nonlocal call_count call_count += 1 return "yes" if flag else "no" result1 = process_bool(True) assert result1 == "yes" assert call_count == 1 result2 = process_bool(True) assert result2 == "yes" assert call_count == 1 # Cache hit result3 = process_bool(False) assert result3 == "no" assert call_count == 2 def test_cache_global_state(self): """Test that cache is global across function calls.""" @cache def func(x): return x * 2 func(5) assert len(cache_module._cache) == 1 func(10) assert len(cache_module._cache) == 2 func(5) # Cache hit assert len(cache_module._cache) == 2 # No new entry ================================================ FILE: backend/tests/test_callbacks.py ================================================ from __future__ import annotations import asyncio from unittest.mock import AsyncMock, Mock from chainlit import config from chainlit.callbacks import password_auth_callback from chainlit.data.base import BaseDataLayer from chainlit.types import ThreadDict from chainlit.user import User async def test_password_auth_callback(test_config: config.ChainlitConfig): @password_auth_callback async def auth_func(username: str, password: str) -> User | None: if username == "testuser" and password == "testpass": # nosec B105 return User(identifier="testuser") return None # Test that the callback is properly registered assert test_config.code.password_auth_callback is not None # Test the wrapped function result = await test_config.code.password_auth_callback("testuser", "testpass") assert isinstance(result, User) assert result.identifier == "testuser" # Test with incorrect credentials result = await test_config.code.password_auth_callback("wronguser", "wrongpass") assert result is None async def test_header_auth_callback(test_config: config.ChainlitConfig): from starlette.datastructures import Headers from chainlit.callbacks import header_auth_callback @header_auth_callback async def auth_func(headers: Headers) -> User | None: if headers.get("Authorization") == "Bearer valid_token": return User(identifier="testuser") return None # Test that the callback is properly registered assert test_config.code.header_auth_callback is not None # Test the wrapped function with valid header valid_headers = Headers({"Authorization": "Bearer valid_token"}) result = await test_config.code.header_auth_callback(valid_headers) assert isinstance(result, User) assert result.identifier == "testuser" # Test with invalid header invalid_headers = Headers({"Authorization": "Bearer invalid_token"}) result = await test_config.code.header_auth_callback(invalid_headers) assert result is None # Test with missing header missing_headers = Headers({}) result = await test_config.code.header_auth_callback(missing_headers) assert result is None async def test_oauth_callback(test_config: config.ChainlitConfig): from unittest.mock import patch from chainlit.callbacks import oauth_callback from chainlit.user import User # Mock the get_configured_oauth_providers function with patch( "chainlit.callbacks.get_configured_oauth_providers", return_value=["google"] ): @oauth_callback async def auth_func( provider_id: str, token: str, raw_user_data: dict, default_app_user: User, id_token: str | None = None, ) -> User | None: if provider_id == "google" and token == "valid_token": # nosec B105 return User(identifier="oauth_user") return None # Test that the callback is properly registered assert test_config.code.oauth_callback is not None # Test the wrapped function with valid data result = await test_config.code.oauth_callback( "google", "valid_token", {}, User(identifier="default_user") ) assert isinstance(result, User) assert result.identifier == "oauth_user" # Test with invalid data result = await test_config.code.oauth_callback( "facebook", "invalid_token", {}, User(identifier="default_user") ) assert result is None async def test_on_message(mock_chainlit_context, test_config: config.ChainlitConfig): from chainlit.callbacks import on_message from chainlit.message import Message async with mock_chainlit_context: message_received = None @on_message async def handle_message(message: Message): nonlocal message_received message_received = message # Test that the callback is properly registered assert test_config.code.on_message is not None # Create a test message test_message = Message(content="Test message", author="User") # Call the registered callback await test_config.code.on_message(test_message) # Check that the message was received by our handler assert message_received is not None assert message_received.content == "Test message" assert message_received.author == "User" async def test_on_stop(mock_chainlit_context, test_config: config.ChainlitConfig): from chainlit.callbacks import on_stop async with mock_chainlit_context: stop_called = False @on_stop async def handle_stop(): nonlocal stop_called stop_called = True # Test that the callback is properly registered assert test_config.code.on_stop is not None # Call the registered callback await test_config.code.on_stop() # Check that the stop_called flag was set assert stop_called async def test_action_callback( mock_chainlit_context, test_config: config.ChainlitConfig ): from chainlit.action import Action from chainlit.callbacks import action_callback async with mock_chainlit_context: action_handled = False @action_callback("test_action") async def handle_action(action: Action): nonlocal action_handled action_handled = True assert action.name == "test_action" # Test that the callback is properly registered assert "test_action" in test_config.code.action_callbacks # Call the registered callback test_action = Action(name="test_action", payload={"value": "test_value"}) await test_config.code.action_callbacks["test_action"](test_action) # Check that the action_handled flag was set assert action_handled async def test_on_settings_update( mock_chainlit_context, test_config: config.ChainlitConfig ): from chainlit.callbacks import on_settings_update async with mock_chainlit_context: settings_updated = False @on_settings_update async def handle_settings_update(settings: dict): nonlocal settings_updated settings_updated = True assert settings == {"test_setting": "test_value"} # Test that the callback is properly registered assert test_config.code.on_settings_update is not None # Call the registered callback await test_config.code.on_settings_update({"test_setting": "test_value"}) # Check that the settings_updated flag was set assert settings_updated async def test_author_rename(test_config: config.ChainlitConfig): from chainlit.callbacks import author_rename @author_rename async def rename_author(author: str) -> str: if author == "AI": return "Assistant" return author # Test that the callback is properly registered assert test_config.code.author_rename is not None # Call the registered callback result = await test_config.code.author_rename("AI") assert result == "Assistant" result = await test_config.code.author_rename("Human") assert result == "Human" # Test that the callback is properly registered assert test_config.code.author_rename is not None # Call the registered callback result = await test_config.code.author_rename("AI") assert result == "Assistant" result = await test_config.code.author_rename("Human") assert result == "Human" async def test_on_app_startup(test_config: config.ChainlitConfig): """Test the on_app_startup callback registration and execution for sync and async functions.""" from chainlit.callbacks import on_app_startup # Test with synchronous function sync_startup_called = False @on_app_startup def sync_startup(): nonlocal sync_startup_called sync_startup_called = True assert test_config.code.on_app_startup is not None, ( "Sync startup callback not registered" ) # Call the wrapped function (which might be async due to wrap_user_function) result = test_config.code.on_app_startup() if asyncio.iscoroutine(result): await result assert sync_startup_called, "Sync startup function was not called" # Reset for async test test_config.code.on_app_startup = None # Explicitly clear previous registration # Test with asynchronous function async_startup_called = False @on_app_startup async def async_startup(): nonlocal async_startup_called await asyncio.sleep(0) # Simulate async work async_startup_called = True assert test_config.code.on_app_startup is not None, ( "Async startup callback not registered" ) # Call the wrapped function (which should be async) result = test_config.code.on_app_startup() assert asyncio.iscoroutine(result), ( "Async startup function did not return a coroutine" ) await result assert async_startup_called, "Async startup function was not called" async def test_on_app_shutdown(test_config: config.ChainlitConfig): """Test the on_app_shutdown callback registration and execution for sync and async functions.""" from chainlit.callbacks import on_app_shutdown # Test with synchronous function sync_shutdown_called = False @on_app_shutdown def sync_shutdown(): nonlocal sync_shutdown_called sync_shutdown_called = True assert test_config.code.on_app_shutdown is not None, ( "Sync shutdown callback not registered" ) # Call the wrapped function result = test_config.code.on_app_shutdown() if asyncio.iscoroutine(result): await result assert sync_shutdown_called, "Sync shutdown function was not called" # Reset for async test test_config.code.on_app_shutdown = None # Explicitly clear previous registration # Test with asynchronous function async_shutdown_called = False @on_app_shutdown async def async_shutdown(): nonlocal async_shutdown_called await asyncio.sleep(0) # Simulate async work async_shutdown_called = True assert test_config.code.on_app_shutdown is not None, ( "Async shutdown callback not registered" ) # Call the wrapped function result = test_config.code.on_app_shutdown() assert asyncio.iscoroutine(result), ( "Async shutdown function did not return a coroutine" ) await result assert async_shutdown_called, "Async shutdown function was not called" async def test_on_chat_start(mock_chainlit_context, test_config: config.ChainlitConfig): from chainlit.callbacks import on_chat_start async with mock_chainlit_context: chat_started = False @on_chat_start async def handle_chat_start(): nonlocal chat_started chat_started = True # Test that the callback is properly registered assert test_config.code.on_chat_start is not None # Call the registered callback await test_config.code.on_chat_start() # Check that the chat_started flag was set assert chat_started async def test_on_chat_resume( mock_chainlit_context, test_config: config.ChainlitConfig ): from chainlit.callbacks import on_chat_resume async with mock_chainlit_context: chat_resumed = False @on_chat_resume async def handle_chat_resume(thread: ThreadDict): nonlocal chat_resumed chat_resumed = True assert thread["id"] == "test_thread_id" # Test that the callback is properly registered assert test_config.code.on_chat_resume is not None # Call the registered callback await test_config.code.on_chat_resume( { "id": "test_thread_id", "createdAt": "2023-01-01T00:00:00Z", "name": "Test Thread", "userId": "test_user_id", "userIdentifier": "test_user", "tags": [], "metadata": {}, "steps": [], "elements": [], } ) # Check that the chat_resumed flag was set assert chat_resumed async def test_set_chat_profiles( mock_chainlit_context, test_config: config.ChainlitConfig ): from chainlit.callbacks import set_chat_profiles from chainlit.types import ChatProfile async with mock_chainlit_context: @set_chat_profiles async def get_chat_profiles(user, language): return [ ChatProfile(name="Test Profile", markdown_description="A test profile") ] # Test that the callback is properly registered assert test_config.code.set_chat_profiles is not None # Call the registered callback result = await test_config.code.set_chat_profiles(None, None) # Check the result assert result is not None assert isinstance(result, list) assert len(result) == 1 assert isinstance(result[0], ChatProfile) assert result[0].name == "Test Profile" assert result[0].markdown_description == "A test profile" async def test_set_chat_profiles_language( mock_chainlit_context, test_config: config.ChainlitConfig ): from chainlit.callbacks import set_chat_profiles from chainlit.types import ChatProfile async with mock_chainlit_context: @set_chat_profiles async def get_chat_profiles(user, language): if language == "fr-CA": return [ ChatProfile( name="Profil de test", markdown_description="Un profil de test" ) ] return [ ChatProfile(name="Test Profile", markdown_description="A test profile") ] # Test that the callback is properly registered assert test_config.code.set_chat_profiles is not None # Call the registered callback result = await test_config.code.set_chat_profiles(None, "fr-CA") # Check the result assert result is not None assert isinstance(result, list) assert len(result) == 1 assert isinstance(result[0], ChatProfile) assert result[0].name == "Profil de test" assert result[0].markdown_description == "Un profil de test" async def test_set_starters(mock_chainlit_context, test_config: config.ChainlitConfig): from chainlit.callbacks import set_starters from chainlit.types import Starter async with mock_chainlit_context: @set_starters async def get_starters(user): return [ Starter( label="Test Label", message="Test Message", ) ] # Test that the callback is properly registered assert test_config.code.set_starters is not None # Call the registered callback result = await test_config.code.set_starters(None, None) # Check the result assert result is not None assert isinstance(result, list) assert len(result) == 1 assert isinstance(result[0], Starter) assert result[0].label == "Test Label" assert result[0].message == "Test Message" async def test_set_starters_language( mock_chainlit_context, test_config: config.ChainlitConfig ): from chainlit.callbacks import set_starters from chainlit.types import Starter async with mock_chainlit_context: @set_starters async def get_starters(user, language): if language == "fr-CA": return [ Starter( label="Étiquette de test", message="Message de test", ) ] return [ Starter( label="Test Label", message="Test Message", ) ] # Test that the callback is properly registered assert test_config.code.set_starters is not None # Call the registered callback result = await test_config.code.set_starters(None, "fr-CA") # Check the result assert result is not None assert isinstance(result, list) assert len(result) == 1 assert isinstance(result[0], Starter) assert result[0].label == "Étiquette de test" assert result[0].message == "Message de test" async def test_set_starter_categories( mock_chainlit_context, test_config: config.ChainlitConfig ): from chainlit.callbacks import set_starter_categories from chainlit.types import Starter, StarterCategory async with mock_chainlit_context: @set_starter_categories async def get_starter_categories(user, language): return [ StarterCategory( label="Creative", icon="https://example.com/creative.png", starters=[ Starter(label="Write a poem", message="Write a poem"), Starter(label="Write a story", message="Write a story"), ], ), StarterCategory( label="Educational", starters=[ Starter(label="Explain concept", message="Explain it"), ], ), ] assert test_config.code.set_starter_categories is not None result = await test_config.code.set_starter_categories(None, None) assert result is not None assert isinstance(result, list) assert len(result) == 2 assert result[0].label == "Creative" assert result[0].icon == "https://example.com/creative.png" assert len(result[0].starters) == 2 assert result[0].starters[0].label == "Write a poem" assert result[1].label == "Educational" assert result[1].icon is None assert len(result[1].starters) == 1 category_dict = result[0].to_dict() assert category_dict["label"] == "Creative" assert category_dict["icon"] == "https://example.com/creative.png" starters_list = category_dict["starters"] assert isinstance(starters_list, list) assert len(starters_list) == 2 async def test_on_shared_thread_view_allow( mock_chainlit_context, test_config: config.ChainlitConfig ): from chainlit.callbacks import on_shared_thread_view from chainlit.user import User async with mock_chainlit_context: # Simulate a viewer with access to certain chat profiles allowed_profiles_by_user = {"viewer": {"pro", "basic"}} @on_shared_thread_view async def allow_shared_view(thread, viewer: User | None): md = thread.get("metadata") or {} chat_profile = (md or {}).get("chat_profile") if not md.get("is_shared"): return False if not viewer: return False return chat_profile in allowed_profiles_by_user.get( viewer.identifier, set() ) assert test_config.code.on_shared_thread_view is not None thread: ThreadDict = { "id": "t1", "createdAt": "2025-09-03T00:00:00Z", "name": "Shared Thread", "userId": "author_id", "userIdentifier": "author", "tags": [], "metadata": {"is_shared": True, "chat_profile": "pro"}, "steps": [], "elements": [], } viewer = User(identifier="viewer") res = await test_config.code.on_shared_thread_view(thread, viewer) assert res is True async def test_on_shared_thread_view_block_and_exception( mock_chainlit_context, test_config: config.ChainlitConfig ): from chainlit.callbacks import on_shared_thread_view from chainlit.user import User async with mock_chainlit_context: # Case 1: Explicitly return False when profile not allowed @on_shared_thread_view async def deny_when_not_allowed(thread, viewer: User | None): md = thread.get("metadata") or {} return md.get("chat_profile") == "allowed" assert test_config.code.on_shared_thread_view is not None thread: ThreadDict = { "id": "t2", "createdAt": "2025-09-03T00:00:00Z", "name": "Shared Thread", "userId": "author_id", "userIdentifier": "author", "tags": [], "metadata": {"is_shared": True, "chat_profile": "restricted"}, "steps": [], "elements": [], } viewer = User(identifier="viewer") res = await test_config.code.on_shared_thread_view(thread, viewer) assert not res # Case 2: Raise an exception inside callback; wrapper should swallow and result should be falsy @on_shared_thread_view async def raise_on_forbidden(thread, viewer: User | None): md = thread.get("metadata") or {} if md.get("chat_profile") == "forbidden": raise ValueError("Viewer not allowed for this profile") return True assert test_config.code.on_shared_thread_view is not None thread_err: ThreadDict = { "id": "t3", "createdAt": "2025-09-03T00:00:00Z", "name": "Shared Thread", "userId": "author_id", "userIdentifier": "author", "tags": [], "metadata": {"is_shared": True, "chat_profile": "forbidden"}, "steps": [], "elements": [], } res2 = await test_config.code.on_shared_thread_view(thread_err, viewer) assert not res2 async def test_on_chat_end(mock_chainlit_context, test_config: config.ChainlitConfig): from chainlit.callbacks import on_chat_end async with mock_chainlit_context: chat_ended = False @on_chat_end async def handle_chat_end(): nonlocal chat_ended chat_ended = True # Test that the callback is properly registered assert test_config.code.on_chat_end is not None # Call the registered callback await test_config.code.on_chat_end() # Check that the chat_ended flag was set assert chat_ended def test_data_layer_config( mock_data_layer: AsyncMock, test_config: config.ChainlitConfig, mock_get_data_layer: Mock, ): """Test whether we can properly configure a data layer.""" # Test that the callback is properly registered assert test_config.code.data_layer is not None # Call the registered callback result = test_config.code.data_layer() # Check that the result is an instance of MockDataLayer assert isinstance(result, BaseDataLayer) mock_get_data_layer.assert_called_once() def test_chat_profile_with_config_overrides(): """Test that ChatProfile can be created with config_overrides.""" from chainlit.config import ( ChainlitConfigOverrides, FeaturesSettings, McpFeature, UISettings, ) from chainlit.types import ChatProfile # Test creating a profile without config_overrides basic_profile = ChatProfile( name="Basic Profile", markdown_description="A basic profile without overrides" ) assert basic_profile.config_overrides is None # Test creating a profile with config_overrides config_overrides = ChainlitConfigOverrides( features=FeaturesSettings(mcp=McpFeature(enabled=True)), ui=UISettings( name="Custom App Name", description="Custom description", default_theme="light", ), ) profile_with_overrides = ChatProfile( name="MCP Profile", markdown_description="A profile with MCP enabled", config_overrides=config_overrides, ) # Verify the profile was created successfully assert profile_with_overrides.name == "MCP Profile" assert profile_with_overrides.config_overrides is not None assert profile_with_overrides.config_overrides.features.mcp.enabled is True assert profile_with_overrides.config_overrides.ui.name == "Custom App Name" assert profile_with_overrides.config_overrides.ui.default_theme == "light" async def test_set_chat_profiles_with_config_overrides( mock_chainlit_context, test_config: config.ChainlitConfig ): """Test that set_chat_profiles callback works with profiles that have config_overrides.""" from chainlit.callbacks import set_chat_profiles from chainlit.config import ( ChainlitConfigOverrides, FeaturesSettings, McpFeature, UISettings, ) from chainlit.types import ChatProfile async with mock_chainlit_context: @set_chat_profiles async def get_chat_profiles(user, language): return [ ChatProfile( name="Basic Profile", markdown_description="A basic profile without overrides", ), ChatProfile( name="MCP Profile", markdown_description="A profile with MCP enabled", config_overrides=ChainlitConfigOverrides( features=FeaturesSettings(mcp=McpFeature(enabled=True)), ui=UISettings(name="MCP Assistant", default_theme="dark"), ), ), ChatProfile( name="Light Theme Profile", markdown_description="A profile with light theme", config_overrides=ChainlitConfigOverrides( ui=UISettings(name="Light Theme App", default_theme="light") ), ), ] # Test that the callback is properly registered assert test_config.code.set_chat_profiles is not None # Call the registered callback result = await test_config.code.set_chat_profiles(None, None) # Check the result assert result is not None assert isinstance(result, list) assert len(result) == 3 # Test basic profile basic_profile = result[0] assert basic_profile.name == "Basic Profile" assert basic_profile.config_overrides is None # Test MCP profile mcp_profile = result[1] assert mcp_profile.name == "MCP Profile" assert mcp_profile.config_overrides is not None assert mcp_profile.config_overrides.features.mcp.enabled is True assert mcp_profile.config_overrides.ui.name == "MCP Assistant" assert mcp_profile.config_overrides.ui.default_theme == "dark" # Test light theme profile light_profile = result[2] assert light_profile.name == "Light Theme Profile" assert light_profile.config_overrides is not None assert light_profile.config_overrides.ui.name == "Light Theme App" assert light_profile.config_overrides.ui.default_theme == "light" ================================================ FILE: backend/tests/test_chat_context.py ================================================ import asyncio from contextlib import contextmanager from unittest.mock import Mock, patch from chainlit.chat_context import chat_context, chat_contexts from chainlit.context import ChainlitContext, context_var @contextmanager def mock_chainlit_context(session=None): """Context manager to set up and tear down Chainlit context.""" # Mock the event loop since we're not in an async context mock_loop = Mock(spec=asyncio.AbstractEventLoop) with patch("asyncio.get_running_loop", return_value=mock_loop): mock_context = ChainlitContext(session=session) token = context_var.set(mock_context) try: yield mock_context finally: context_var.reset(token) class TestChatContext: """Test suite for ChatContext class.""" def setup_method(self): """Clear chat_contexts before each test.""" chat_contexts.clear() def teardown_method(self): """Clear chat_contexts after each test.""" chat_contexts.clear() def test_get_without_session(self): """Test get returns empty list when no session exists.""" with mock_chainlit_context(session=None): result = chat_context.get() assert result == [] def test_get_with_new_session(self): """Test get creates new chat context for new session.""" mock_session = Mock() mock_session.id = "session_123" with mock_chainlit_context(session=mock_session): result = chat_context.get() assert result == [] assert "session_123" in chat_contexts assert chat_contexts["session_123"] == [] def test_get_returns_copy(self): """Test get returns a copy of the chat context.""" mock_session = Mock() mock_session.id = "session_123" mock_message = Mock() chat_contexts["session_123"] = [mock_message] with mock_chainlit_context(session=mock_session): result = chat_context.get() assert result == [mock_message] # Verify it's a copy, not the original assert result is not chat_contexts["session_123"] def test_get_with_existing_messages(self): """Test get returns existing messages.""" mock_session = Mock() mock_session.id = "session_123" mock_msg1 = Mock() mock_msg2 = Mock() chat_contexts["session_123"] = [mock_msg1, mock_msg2] with mock_chainlit_context(session=mock_session): result = chat_context.get() assert len(result) == 2 assert mock_msg1 in result assert mock_msg2 in result def test_add_without_session(self): """Test add does nothing when no session exists.""" mock_message = Mock() with mock_chainlit_context(session=None): result = chat_context.add(mock_message) assert result is None assert len(chat_contexts) == 0 def test_add_with_new_session(self): """Test add creates new chat context and adds message.""" mock_session = Mock() mock_session.id = "session_123" mock_message = Mock() with mock_chainlit_context(session=mock_session): result = chat_context.add(mock_message) assert result == mock_message assert "session_123" in chat_contexts assert mock_message in chat_contexts["session_123"] def test_add_message_to_existing_context(self): """Test add appends message to existing context.""" mock_session = Mock() mock_session.id = "session_123" mock_msg1 = Mock() mock_msg2 = Mock() chat_contexts["session_123"] = [mock_msg1] with mock_chainlit_context(session=mock_session): result = chat_context.add(mock_msg2) assert result == mock_msg2 assert len(chat_contexts["session_123"]) == 2 assert mock_msg1 in chat_contexts["session_123"] assert mock_msg2 in chat_contexts["session_123"] def test_add_duplicate_message(self): """Test add does not add duplicate messages.""" mock_session = Mock() mock_session.id = "session_123" mock_message = Mock() chat_contexts["session_123"] = [mock_message] with mock_chainlit_context(session=mock_session): result = chat_context.add(mock_message) assert result == mock_message assert len(chat_contexts["session_123"]) == 1 def test_remove_without_session(self): """Test remove returns False when no session exists.""" mock_message = Mock() with mock_chainlit_context(session=None): result = chat_context.remove(mock_message) assert result is False def test_remove_with_nonexistent_context(self): """Test remove returns False when context doesn't exist.""" mock_session = Mock() mock_session.id = "session_123" mock_message = Mock() with mock_chainlit_context(session=mock_session): result = chat_context.remove(mock_message) assert result is False def test_remove_nonexistent_message(self): """Test remove returns False when message not in context.""" mock_session = Mock() mock_session.id = "session_123" mock_msg1 = Mock() mock_msg2 = Mock() chat_contexts["session_123"] = [mock_msg1] with mock_chainlit_context(session=mock_session): result = chat_context.remove(mock_msg2) assert result is False assert mock_msg1 in chat_contexts["session_123"] def test_remove_existing_message(self): """Test remove successfully removes message.""" mock_session = Mock() mock_session.id = "session_123" mock_msg1 = Mock() mock_msg2 = Mock() chat_contexts["session_123"] = [mock_msg1, mock_msg2] with mock_chainlit_context(session=mock_session): result = chat_context.remove(mock_msg1) assert result is True assert mock_msg1 not in chat_contexts["session_123"] assert mock_msg2 in chat_contexts["session_123"] assert len(chat_contexts["session_123"]) == 1 def test_clear_without_session(self): """Test clear does nothing when no session exists.""" chat_contexts["session_123"] = [Mock()] with mock_chainlit_context(session=None): chat_context.clear() # Original context should remain assert "session_123" in chat_contexts def test_clear_with_nonexistent_context(self): """Test clear does nothing when context doesn't exist.""" mock_session = Mock() mock_session.id = "session_456" chat_contexts["session_123"] = [Mock()] with mock_chainlit_context(session=mock_session): chat_context.clear() # Original context should remain assert "session_123" in chat_contexts def test_clear_existing_context(self): """Test clear empties existing context.""" mock_session = Mock() mock_session.id = "session_123" mock_msg1 = Mock() mock_msg2 = Mock() chat_contexts["session_123"] = [mock_msg1, mock_msg2] with mock_chainlit_context(session=mock_session): chat_context.clear() assert "session_123" in chat_contexts assert chat_contexts["session_123"] == [] def test_to_openai_with_assistant_message(self): """Test to_openai converts assistant messages correctly.""" mock_session = Mock() mock_session.id = "session_123" mock_message = Mock() mock_message.type = "assistant_message" mock_message.content = "Hello, how can I help?" chat_contexts["session_123"] = [mock_message] with mock_chainlit_context(session=mock_session): result = chat_context.to_openai() assert len(result) == 1 assert result[0] == { "role": "assistant", "content": "Hello, how can I help?", } def test_to_openai_with_user_message(self): """Test to_openai converts user messages correctly.""" mock_session = Mock() mock_session.id = "session_123" mock_message = Mock() mock_message.type = "user_message" mock_message.content = "What is the weather?" chat_contexts["session_123"] = [mock_message] with mock_chainlit_context(session=mock_session): result = chat_context.to_openai() assert len(result) == 1 assert result[0] == {"role": "user", "content": "What is the weather?"} def test_to_openai_with_system_message(self): """Test to_openai converts system messages correctly.""" mock_session = Mock() mock_session.id = "session_123" mock_message = Mock() mock_message.type = "system_message" mock_message.content = "You are a helpful assistant." chat_contexts["session_123"] = [mock_message] with mock_chainlit_context(session=mock_session): result = chat_context.to_openai() assert len(result) == 1 assert result[0] == { "role": "system", "content": "You are a helpful assistant.", } def test_to_openai_with_unknown_message_type(self): """Test to_openai treats unknown types as system messages.""" mock_session = Mock() mock_session.id = "session_123" mock_message = Mock() mock_message.type = "unknown_type" mock_message.content = "Unknown message" chat_contexts["session_123"] = [mock_message] with mock_chainlit_context(session=mock_session): result = chat_context.to_openai() assert len(result) == 1 assert result[0] == {"role": "system", "content": "Unknown message"} def test_to_openai_with_multiple_messages(self): """Test to_openai converts multiple messages correctly.""" mock_session = Mock() mock_session.id = "session_123" mock_msg1 = Mock() mock_msg1.type = "user_message" mock_msg1.content = "Hello" mock_msg2 = Mock() mock_msg2.type = "assistant_message" mock_msg2.content = "Hi there!" mock_msg3 = Mock() mock_msg3.type = "user_message" mock_msg3.content = "How are you?" chat_contexts["session_123"] = [mock_msg1, mock_msg2, mock_msg3] with mock_chainlit_context(session=mock_session): result = chat_context.to_openai() assert len(result) == 3 assert result[0] == {"role": "user", "content": "Hello"} assert result[1] == {"role": "assistant", "content": "Hi there!"} assert result[2] == {"role": "user", "content": "How are you?"} def test_to_openai_with_empty_context(self): """Test to_openai returns empty list for empty context.""" mock_session = Mock() mock_session.id = "session_123" chat_contexts["session_123"] = [] with mock_chainlit_context(session=mock_session): result = chat_context.to_openai() assert result == [] def test_to_openai_without_session(self): """Test to_openai returns empty list when no session exists.""" with mock_chainlit_context(session=None): result = chat_context.to_openai() assert result == [] class TestChatContextEdgeCases: """Test suite for chat_context edge cases.""" def setup_method(self): """Clear chat_contexts before each test.""" chat_contexts.clear() def teardown_method(self): """Clear chat_contexts after each test.""" chat_contexts.clear() def test_multiple_sessions_isolated(self): """Test that different sessions have isolated contexts.""" mock_session1 = Mock() mock_session1.id = "session_1" mock_session2 = Mock() mock_session2.id = "session_2" mock_msg1 = Mock() mock_msg2 = Mock() with mock_chainlit_context(session=mock_session1): chat_context.add(mock_msg1) with mock_chainlit_context(session=mock_session2): chat_context.add(mock_msg2) assert len(chat_contexts) == 2 assert mock_msg1 in chat_contexts["session_1"] assert mock_msg2 in chat_contexts["session_2"] assert mock_msg1 not in chat_contexts["session_2"] assert mock_msg2 not in chat_contexts["session_1"] def test_add_then_remove_then_add_again(self): """Test adding, removing, and re-adding the same message.""" mock_session = Mock() mock_session.id = "session_123" mock_message = Mock() with mock_chainlit_context(session=mock_session): # Add chat_context.add(mock_message) assert len(chat_contexts["session_123"]) == 1 # Remove result = chat_context.remove(mock_message) assert result is True assert len(chat_contexts["session_123"]) == 0 # Add again chat_context.add(mock_message) assert len(chat_contexts["session_123"]) == 1 def test_clear_then_add(self): """Test adding messages after clearing context.""" mock_session = Mock() mock_session.id = "session_123" mock_msg1 = Mock() mock_msg2 = Mock() with mock_chainlit_context(session=mock_session): chat_context.add(mock_msg1) chat_context.clear() chat_context.add(mock_msg2) result = chat_context.get() assert len(result) == 1 assert mock_msg2 in result assert mock_msg1 not in result def test_to_openai_with_mixed_message_types(self): """Test to_openai with various message types in sequence.""" mock_session = Mock() mock_session.id = "session_123" messages = [ Mock(type="system_message", content="System prompt"), Mock(type="user_message", content="User query"), Mock(type="assistant_message", content="Assistant response"), Mock(type="other_type", content="Other message"), ] chat_contexts["session_123"] = messages with mock_chainlit_context(session=mock_session): result = chat_context.to_openai() assert len(result) == 4 assert result[0]["role"] == "system" assert result[1]["role"] == "user" assert result[2]["role"] == "assistant" assert result[3]["role"] == "system" # Unknown types default to system def test_chat_context_singleton(self): """Test that chat_context is a singleton instance.""" from chainlit.chat_context import chat_context as imported_context assert chat_context is imported_context def test_add_returns_message(self): """Test that add returns the message for chaining.""" mock_session = Mock() mock_session.id = "session_123" mock_message = Mock() with mock_chainlit_context(session=mock_session): result = chat_context.add(mock_message) assert result is mock_message ================================================ FILE: backend/tests/test_chat_settings.py ================================================ import pytest from chainlit.chat_settings import ChatSettings from chainlit.input_widget import ( Checkbox, NumberInput, Select, Slider, Switch, Tab, TextInput, ) @pytest.mark.asyncio class TestChatSettings: """Test suite for ChatSettings class.""" async def test_chat_settings_initialization(self, mock_chainlit_context): """Test ChatSettings initialization with input widgets.""" async with mock_chainlit_context: switch = Switch(id="enable_feature", label="Enable Feature") slider = Slider(id="temperature", label="Temperature", initial=0.7) settings = ChatSettings(inputs=[switch, slider]) assert len(settings.inputs) == 2 assert settings.inputs[0] == switch assert settings.inputs[1] == slider async def test_chat_settings_with_empty_inputs(self, mock_chainlit_context): """Test ChatSettings with empty inputs list.""" async with mock_chainlit_context: settings = ChatSettings(inputs=[]) assert settings.inputs == [] async def test_chat_settings_settings_method(self, mock_chainlit_context): """Test ChatSettings.settings() method returns initial values.""" async with mock_chainlit_context: switch = Switch(id="enable_feature", label="Enable", initial=True) slider = Slider(id="temperature", label="Temperature", initial=0.7) text = TextInput(id="model", label="Model", initial="gpt-4") settings = ChatSettings(inputs=[switch, slider, text]) result = settings.settings() assert result["enable_feature"] is True assert result["temperature"] == 0.7 assert result["model"] == "gpt-4" async def test_chat_settings_with_tabs(self, mock_chainlit_context): """Test ChatSettings with Tab containers.""" async with mock_chainlit_context: # Create inputs for tabs switch1 = Switch(id="switch1", label="Switch 1", initial=True) slider1 = Slider(id="slider1", label="Slider 1", initial=5) switch2 = Switch(id="switch2", label="Switch 2", initial=False) text2 = TextInput(id="text2", label="Text 2", initial="value") # Create tabs tab1 = Tab(id="tab1", label="Tab 1", inputs=[switch1, slider1]) tab2 = Tab(id="tab2", label="Tab 2", inputs=[switch2, text2]) settings = ChatSettings(inputs=[tab1, tab2]) assert len(settings.inputs) == 2 assert isinstance(settings.inputs[0], Tab) assert isinstance(settings.inputs[1], Tab) async def test_chat_settings_settings_with_tabs(self, mock_chainlit_context): """Test ChatSettings.settings() collects values from tabs.""" async with mock_chainlit_context: switch1 = Switch(id="switch1", label="Switch 1", initial=True) slider1 = Slider(id="slider1", label="Slider 1", initial=5) switch2 = Switch(id="switch2", label="Switch 2", initial=False) text2 = TextInput(id="text2", label="Text 2", initial="value") tab1 = Tab(id="tab1", label="Tab 1", inputs=[switch1, slider1]) tab2 = Tab(id="tab2", label="Tab 2", inputs=[switch2, text2]) settings = ChatSettings(inputs=[tab1, tab2]) result = settings.settings() # Should collect all inputs from all tabs assert result["switch1"] is True assert result["slider1"] == 5 assert result["switch2"] is False assert result["text2"] == "value" async def test_chat_settings_send(self, mock_chainlit_context): """Test ChatSettings.send() method.""" async with mock_chainlit_context as ctx: switch = Switch(id="enable", label="Enable", initial=True) slider = Slider(id="temp", label="Temperature", initial=0.8) settings = ChatSettings(inputs=[switch, slider]) result = await settings.send() # Verify settings were returned assert result["enable"] is True assert result["temp"] == 0.8 # Verify emitter methods were called ctx.emitter.set_chat_settings.assert_called_once_with(result) ctx.emitter.emit.assert_called_once() # Verify emit was called with correct arguments call_args = ctx.emitter.emit.call_args assert call_args[0][0] == "chat_settings" assert len(call_args[0][1]) == 2 # Two inputs async def test_chat_settings_send_with_tabs(self, mock_chainlit_context): """Test ChatSettings.send() with tabs.""" async with mock_chainlit_context as ctx: switch = Switch(id="switch1", label="Switch", initial=True) slider = Slider(id="slider1", label="Slider", initial=5) tab = Tab(id="tab1", label="Settings", inputs=[switch, slider]) settings = ChatSettings(inputs=[tab]) result = await settings.send() # Verify settings collected from tab assert result["switch1"] is True assert result["slider1"] == 5 # Verify emitter was called ctx.emitter.set_chat_settings.assert_called_once() ctx.emitter.emit.assert_called_once() async def test_chat_settings_with_all_widget_types(self, mock_chainlit_context): """Test ChatSettings with all widget types.""" async with mock_chainlit_context: widgets = [ Switch(id="switch", label="Switch", initial=True), Slider(id="slider", label="Slider", initial=5, min=0, max=10), Select( id="select", label="Select", values=["a", "b", "c"], initial_index=1, ), TextInput(id="text", label="Text", initial="hello"), NumberInput(id="number", label="Number", initial=42.0), Checkbox(id="checkbox", label="Checkbox", initial=False), ] settings = ChatSettings(inputs=widgets) result = settings.settings() assert result["switch"] is True assert result["slider"] == 5 assert result["select"] == "b" assert result["text"] == "hello" assert result["number"] == 42.0 assert result["checkbox"] is False async def test_chat_settings_with_nested_tabs(self, mock_chainlit_context): """Test ChatSettings.settings() with nested structure.""" async with mock_chainlit_context: # Create multiple tabs with different inputs tab1_inputs = [ Switch(id="t1_switch", label="T1 Switch", initial=True), Slider(id="t1_slider", label="T1 Slider", initial=3), ] tab2_inputs = [ TextInput(id="t2_text", label="T2 Text", initial="test"), Checkbox(id="t2_check", label="T2 Check", initial=True), ] tab1 = Tab(id="tab1", label="Tab 1", inputs=tab1_inputs) tab2 = Tab(id="tab2", label="Tab 2", inputs=tab2_inputs) settings = ChatSettings(inputs=[tab1, tab2]) result = settings.settings() # All inputs from all tabs should be collected assert result["t1_switch"] is True assert result["t1_slider"] == 3 assert result["t2_text"] == "test" assert result["t2_check"] is True assert len(result) == 4 async def test_chat_settings_only_widgets_or_only_tabs(self, mock_chainlit_context): """Test that ChatSettings accepts either all widgets or all tabs, not mixed.""" async with mock_chainlit_context: # Test with only widgets widgets = [ Switch(id="switch", label="Switch", initial=True), Slider(id="slider", label="Slider", initial=7), ] settings_widgets = ChatSettings(inputs=widgets) result_widgets = settings_widgets.settings() assert result_widgets["switch"] is True assert result_widgets["slider"] == 7 # Test with only tabs tab1 = Tab( id="tab1", label="Tab 1", inputs=[Switch(id="t1_switch", label="Switch", initial=False)], ) tab2 = Tab( id="tab2", label="Tab 2", inputs=[Slider(id="t2_slider", label="Slider", initial=3)], ) settings_tabs = ChatSettings(inputs=[tab1, tab2]) result_tabs = settings_tabs.settings() assert result_tabs["t1_switch"] is False assert result_tabs["t2_slider"] == 3 async def test_chat_settings_with_none_initial_values(self, mock_chainlit_context): """Test ChatSettings with widgets having None initial values.""" async with mock_chainlit_context: text = TextInput(id="text", label="Text", initial=None) number = NumberInput(id="number", label="Number", initial=None) select = Select( id="select", label="Select", values=["a", "b"], initial_value=None ) settings = ChatSettings(inputs=[text, number, select]) result = settings.settings() assert result["text"] is None assert result["number"] is None assert result["select"] is None @pytest.mark.asyncio class TestChatSettingsEdgeCases: """Test suite for ChatSettings edge cases.""" async def test_chat_settings_empty_tabs(self, mock_chainlit_context): """Test ChatSettings with empty tabs.""" async with mock_chainlit_context: empty_tab = Tab(id="empty", label="Empty Tab", inputs=[]) settings = ChatSettings(inputs=[empty_tab]) result = settings.settings() assert result == {} async def test_chat_settings_duplicate_ids(self, mock_chainlit_context): """Test ChatSettings behavior with duplicate IDs (last one wins).""" async with mock_chainlit_context: switch1 = Switch(id="duplicate", label="Switch 1", initial=True) switch2 = Switch(id="duplicate", label="Switch 2", initial=False) settings = ChatSettings(inputs=[switch1, switch2]) result = settings.settings() # Last value should win assert result["duplicate"] is False async def test_chat_settings_send_returns_settings(self, mock_chainlit_context): """Test that send() returns the settings dictionary.""" async with mock_chainlit_context: switch = Switch(id="test", label="Test", initial=True) settings = ChatSettings(inputs=[switch]) result = await settings.send() assert isinstance(result, dict) assert "test" in result assert result["test"] is True async def test_chat_settings_to_dict_serialization(self, mock_chainlit_context): """Test that inputs are properly serialized in send().""" async with mock_chainlit_context as ctx: switch = Switch(id="switch", label="Switch", initial=True) slider = Slider(id="slider", label="Slider", initial=5) settings = ChatSettings(inputs=[switch, slider]) await settings.send() # Check that emit was called with serialized inputs call_args = ctx.emitter.emit.call_args inputs_content = call_args[0][1] assert len(inputs_content) == 2 assert inputs_content[0]["type"] == "switch" assert inputs_content[0]["id"] == "switch" assert inputs_content[1]["type"] == "slider" assert inputs_content[1]["id"] == "slider" async def test_chat_settings_with_complex_tab_structure( self, mock_chainlit_context ): """Test ChatSettings with complex tab structure.""" async with mock_chainlit_context: # Create a complex structure with multiple tabs tab1 = Tab( id="general", label="General", inputs=[ Switch(id="enabled", label="Enabled", initial=True), TextInput(id="name", label="Name", initial="MyApp"), ], ) tab2 = Tab( id="advanced", label="Advanced", inputs=[ Slider(id="timeout", label="Timeout", initial=30, min=0, max=60), Select( id="mode", label="Mode", values=["dev", "prod"], initial_index=0, ), ], ) settings = ChatSettings(inputs=[tab1, tab2]) result = settings.settings() assert result["enabled"] is True assert result["name"] == "MyApp" assert result["timeout"] == 30 assert result["mode"] == "dev" assert len(result) == 4 ================================================ FILE: backend/tests/test_context.py ================================================ from unittest.mock import Mock import pytest from chainlit.context import ( ChainlitContext, ChainlitContextException, get_context, init_http_context, init_ws_context, ) from chainlit.emitter import BaseChainlitEmitter, ChainlitEmitter from chainlit.session import HTTPSession @pytest.fixture def mock_emitter(): return Mock(spec=BaseChainlitEmitter) async def test_chainlit_context_init_with_websocket( mock_websocket_session, mock_emitter ): context = ChainlitContext(mock_websocket_session, mock_emitter) assert isinstance(context.emitter, BaseChainlitEmitter) assert context.session == mock_websocket_session async def test_chainlit_context_init_with_http(mock_http_session): context = ChainlitContext(mock_http_session) assert isinstance(context.emitter, BaseChainlitEmitter) assert context.session == mock_http_session async def test_init_ws_context(mock_websocket_session): context = init_ws_context(mock_websocket_session) assert isinstance(context, ChainlitContext) assert context.session == mock_websocket_session assert isinstance(context.emitter, ChainlitEmitter) async def test_init_http_context(): context = init_http_context() assert isinstance(context, ChainlitContext) assert isinstance(context.session, HTTPSession) assert isinstance(context.emitter, BaseChainlitEmitter) async def test_get_context(): with pytest.raises(ChainlitContextException): get_context() init_http_context() # Initialize a context context = get_context() assert isinstance(context, ChainlitContext) ================================================ FILE: backend/tests/test_element.py ================================================ import uuid from unittest.mock import AsyncMock import pytest from chainlit.element import ( Audio, CustomElement, Element, ElementDict, File, Image, Pdf, Task, TaskList, TaskStatus, Text, Video, ) @pytest.mark.asyncio class TestElementBase: """Test suite for the base Element class.""" async def test_element_initialization_with_url(self, mock_chainlit_context): """Test Element initialization with URL.""" async with mock_chainlit_context: element = File(name="test_file", url="https://example.com/file.pdf") assert element.name == "test_file" assert element.url == "https://example.com/file.pdf" assert isinstance(element.id, str) uuid.UUID(element.id) # Verify valid UUID assert element.persisted is False assert element.updatable is False async def test_element_initialization_with_content(self, mock_chainlit_context): """Test Element initialization with content.""" async with mock_chainlit_context: content = b"test content" element = File(name="test_file", content=content) assert element.name == "test_file" assert element.content == content assert element.url is None assert element.path is None async def test_element_initialization_with_path(self, mock_chainlit_context): """Test Element initialization with path.""" async with mock_chainlit_context: element = File(name="test_file", path="/path/to/file.txt") assert element.name == "test_file" assert element.path == "/path/to/file.txt" assert element.url is None assert element.content is None async def test_element_requires_url_path_or_content(self, mock_chainlit_context): """Test that Element raises error without url, path, or content.""" async with mock_chainlit_context: with pytest.raises(ValueError, match="Must provide url, path or content"): File(name="test_file") async def test_element_to_dict(self, mock_chainlit_context): """Test Element serialization to dictionary.""" async with mock_chainlit_context as ctx: element = File( name="test_file", url="https://example.com/file.pdf", display="inline", ) element_dict = element.to_dict() assert element_dict["name"] == "test_file" assert element_dict["url"] == "https://example.com/file.pdf" assert element_dict["type"] == "file" assert element_dict["id"] == element.id assert element_dict["threadId"] == ctx.session.thread_id assert element_dict["display"] == "inline" async def test_element_send(self, mock_chainlit_context): """Test Element.send() method.""" async with mock_chainlit_context as ctx: element = File(name="test_file", url="https://example.com/file.pdf") await element.send(for_id="message_123") assert element.for_id == "message_123" ctx.emitter.send_element.assert_called_once() async def test_element_remove(self, mock_chainlit_context): """Test Element.remove() method.""" async with mock_chainlit_context as ctx: element = File(name="test_file", url="https://example.com/file.pdf") await element.remove() ctx.emitter.emit.assert_called_once_with( "remove_element", {"id": element.id} ) async def test_element_display_options(self, mock_chainlit_context): """Test Element display options.""" async with mock_chainlit_context: element_inline = File( name="test", url="https://example.com/file.pdf", display="inline" ) element_side = File( name="test", url="https://example.com/file.pdf", display="side" ) element_page = File( name="test", url="https://example.com/file.pdf", display="page" ) assert element_inline.display == "inline" assert element_side.display == "side" assert element_page.display == "page" async def test_element_from_dict_file(self, mock_chainlit_context): """Test Element.from_dict() for File type.""" async with mock_chainlit_context: element_dict: ElementDict = { "id": str(uuid.uuid4()), "name": "test_file", "type": "file", "url": "https://example.com/file.pdf", "display": "inline", } element = Element.from_dict(element_dict) assert isinstance(element, File) assert element.name == "test_file" assert element.url == "https://example.com/file.pdf" async def test_element_from_dict_image(self, mock_chainlit_context): """Test Element.from_dict() for Image type.""" async with mock_chainlit_context: element_dict: ElementDict = { "id": str(uuid.uuid4()), "name": "test_image", "type": "image", "url": "https://example.com/image.png", "display": "inline", } element = Element.from_dict(element_dict) assert isinstance(element, Image) assert element.name == "test_image" assert element.type == "image" async def test_element_infer_type_from_mime(self): """Test Element.infer_type_from_mime() method.""" assert Element.infer_type_from_mime("image/png") == "image" assert Element.infer_type_from_mime("image/jpeg") == "image" assert Element.infer_type_from_mime("application/pdf") == "pdf" assert Element.infer_type_from_mime("audio/mp3") == "audio" assert Element.infer_type_from_mime("video/mp4") == "video" assert Element.infer_type_from_mime("text/plain") == "file" assert Element.infer_type_from_mime("application/json") == "file" @pytest.mark.asyncio class TestImageElement: """Test suite for Image element.""" async def test_image_initialization(self, mock_chainlit_context): """Test Image element initialization.""" async with mock_chainlit_context: image = Image( name="test_image", url="https://example.com/image.png", size="large", ) assert image.type == "image" assert image.name == "test_image" assert image.size == "large" async def test_image_size_options(self, mock_chainlit_context): """Test Image size options.""" async with mock_chainlit_context: small = Image(name="test", url="https://example.com/img.png", size="small") medium = Image( name="test", url="https://example.com/img.png", size="medium" ) large = Image(name="test", url="https://example.com/img.png", size="large") assert small.size == "small" assert medium.size == "medium" assert large.size == "large" @pytest.mark.asyncio class TestTextElement: """Test suite for Text element.""" async def test_text_initialization(self, mock_chainlit_context): """Test Text element initialization.""" async with mock_chainlit_context: text = Text(name="test_text", content="Hello, World!", language="python") assert text.type == "text" assert text.name == "test_text" assert text.content == "Hello, World!" assert text.language == "python" async def test_text_without_language(self, mock_chainlit_context): """Test Text element without language.""" async with mock_chainlit_context: text = Text(name="test_text", content="Plain text") assert text.language is None @pytest.mark.asyncio class TestPdfElement: """Test suite for Pdf element.""" async def test_pdf_initialization(self, mock_chainlit_context): """Test Pdf element initialization.""" async with mock_chainlit_context: pdf = Pdf(name="test_pdf", url="https://example.com/document.pdf", page=5) assert pdf.type == "pdf" assert pdf.name == "test_pdf" assert pdf.mime == "application/pdf" assert pdf.page == 5 async def test_pdf_without_page(self, mock_chainlit_context): """Test Pdf element without page number.""" async with mock_chainlit_context: pdf = Pdf(name="test_pdf", url="https://example.com/document.pdf") assert pdf.page is None @pytest.mark.asyncio class TestAudioElement: """Test suite for Audio element.""" async def test_audio_initialization(self, mock_chainlit_context): """Test Audio element initialization.""" async with mock_chainlit_context: audio = Audio( name="test_audio", url="https://example.com/audio.mp3", auto_play=True, ) assert audio.type == "audio" assert audio.name == "test_audio" assert audio.auto_play is True async def test_audio_default_auto_play(self, mock_chainlit_context): """Test Audio element default auto_play.""" async with mock_chainlit_context: audio = Audio(name="test_audio", url="https://example.com/audio.mp3") assert audio.auto_play is False @pytest.mark.asyncio class TestVideoElement: """Test suite for Video element.""" async def test_video_initialization(self, mock_chainlit_context): """Test Video element initialization.""" async with mock_chainlit_context: player_config = {"youtube": {"playerVars": {"showinfo": 1}}} video = Video( name="test_video", url="https://example.com/video.mp4", size="large", player_config=player_config, ) assert video.type == "video" assert video.name == "test_video" assert video.size == "large" assert video.player_config == player_config async def test_video_without_player_config(self, mock_chainlit_context): """Test Video element without player config.""" async with mock_chainlit_context: video = Video(name="test_video", url="https://example.com/video.mp4") assert video.player_config is None @pytest.mark.asyncio class TestFileElement: """Test suite for File element.""" async def test_file_initialization(self, mock_chainlit_context): """Test File element initialization.""" async with mock_chainlit_context: file = File(name="test_file", url="https://example.com/file.txt") assert file.type == "file" assert file.name == "test_file" async def test_file_with_content(self, mock_chainlit_context): """Test File element with content.""" async with mock_chainlit_context: content = b"File content" file = File(name="test_file", content=content) assert file.content == content @pytest.mark.asyncio class TestTaskListElement: """Test suite for TaskList element.""" async def test_tasklist_initialization(self, mock_chainlit_context): """Test TaskList element initialization.""" async with mock_chainlit_context: tasklist = TaskList(name="test_tasklist") assert tasklist.type == "tasklist" assert tasklist.name == "test_tasklist" assert tasklist.tasks == [] assert tasklist.status == "Ready" assert tasklist.updatable is True async def test_tasklist_add_task(self, mock_chainlit_context): """Test adding tasks to TaskList.""" async with mock_chainlit_context: tasklist = TaskList(name="test_tasklist") task1 = Task(title="Task 1", status=TaskStatus.READY) task2 = Task(title="Task 2", status=TaskStatus.RUNNING) await tasklist.add_task(task1) await tasklist.add_task(task2) assert len(tasklist.tasks) == 2 assert tasklist.tasks[0].title == "Task 1" assert tasklist.tasks[1].title == "Task 2" async def test_tasklist_preprocess_content(self, mock_chainlit_context): """Test TaskList content preprocessing.""" async with mock_chainlit_context: tasklist = TaskList(name="test_tasklist", status="In Progress") task = Task(title="Test Task", status=TaskStatus.DONE) await tasklist.add_task(task) await tasklist.preprocess_content() assert isinstance(tasklist.content, str) assert "Test Task" in tasklist.content assert "done" in tasklist.content assert "In Progress" in tasklist.content @pytest.mark.asyncio class TestTaskClass: """Test suite for Task class.""" def test_task_initialization(self): """Test Task initialization.""" task = Task(title="Test Task", status=TaskStatus.READY) assert task.title == "Test Task" assert task.status == TaskStatus.READY assert task.forId is None def test_task_with_for_id(self): """Test Task with forId.""" task = Task(title="Test Task", status=TaskStatus.RUNNING, forId="step_123") assert task.forId == "step_123" def test_task_status_enum(self): """Test TaskStatus enum values.""" assert TaskStatus.READY.value == "ready" assert TaskStatus.RUNNING.value == "running" assert TaskStatus.FAILED.value == "failed" assert TaskStatus.DONE.value == "done" @pytest.mark.asyncio class TestCustomElement: """Test suite for CustomElement.""" async def test_custom_element_initialization(self, mock_chainlit_context): """Test CustomElement initialization.""" async with mock_chainlit_context: props = {"key1": "value1", "key2": 42} custom = CustomElement(name="test_custom", props=props) assert custom.type == "custom" assert custom.name == "test_custom" assert custom.props == props assert custom.mime == "application/json" assert custom.updatable is True async def test_custom_element_content_serialization(self, mock_chainlit_context): """Test CustomElement content serialization.""" async with mock_chainlit_context: props = {"nested": {"data": [1, 2, 3]}} custom = CustomElement(name="test_custom", props=props) assert isinstance(custom.content, str) assert "nested" in custom.content assert "data" in custom.content async def test_custom_element_update(self, mock_chainlit_context): """Test CustomElement update method.""" async with mock_chainlit_context as ctx: custom = CustomElement( name="test_custom", props={"key": "value"}, url="https://example.com/custom", ) custom.for_id = "message_123" await custom.update() ctx.emitter.send_element.assert_called() @pytest.mark.asyncio class TestElementEdgeCases: """Test suite for Element edge cases.""" async def test_element_with_custom_id(self, mock_chainlit_context): """Test Element with custom ID.""" async with mock_chainlit_context: custom_id = str(uuid.uuid4()) element = File( id=custom_id, name="test_file", url="https://example.com/file.txt" ) assert element.id == custom_id async def test_element_with_object_key(self, mock_chainlit_context): """Test Element with object_key.""" async with mock_chainlit_context: element = File( name="test_file", url="https://example.com/file.txt", object_key="s3://bucket/key", ) assert element.object_key == "s3://bucket/key" async def test_element_with_chainlit_key(self, mock_chainlit_context): """Test Element with chainlit_key.""" async with mock_chainlit_context: element = File( name="test_file", url="https://example.com/file.txt", chainlit_key="chainlit_key_123", ) assert element.chainlit_key == "chainlit_key_123" async def test_element_send_without_url_or_key_raises_error( self, mock_chainlit_context ): """Test that send() raises error without url or chainlit_key.""" async with mock_chainlit_context as ctx: # Mock persist_file to not set chainlit_key ctx.session.persist_file = AsyncMock(return_value={"id": None}) element = File(name="test_file", content=b"test content") with pytest.raises(ValueError, match="Must provide url or chainlit key"): await element.send(for_id="message_123", persist=False) async def test_element_from_dict_with_missing_fields(self, mock_chainlit_context): """Test Element.from_dict() with minimal fields.""" async with mock_chainlit_context: element_dict: ElementDict = { "type": "file", "url": "https://example.com/file.txt", } element = Element.from_dict(element_dict) assert isinstance(element, File) assert element.name == "" assert element.url == "https://example.com/file.txt" async def test_element_id_uniqueness(self, mock_chainlit_context): """Test that each Element gets a unique ID.""" async with mock_chainlit_context: element1 = File(name="file1", url="https://example.com/file1.txt") element2 = File(name="file2", url="https://example.com/file2.txt") element3 = File(name="file3", url="https://example.com/file3.txt") ids = {element1.id, element2.id, element3.id} assert len(ids) == 3 # All unique ================================================ FILE: backend/tests/test_emitter.py ================================================ from unittest.mock import MagicMock import pytest from chainlit.element import ElementDict from chainlit.emitter import ChainlitEmitter from chainlit.step import StepDict @pytest.fixture def emitter(mock_websocket_session): return ChainlitEmitter(mock_websocket_session) async def test_send_element( emitter: ChainlitEmitter, mock_websocket_session: MagicMock ) -> None: element_dict: ElementDict = { "id": "test_element", "threadId": None, "type": "text", "chainlitKey": None, "url": None, "objectKey": None, "name": "Test Element", "display": "inline", "size": None, "language": None, "page": None, "props": None, "autoPlay": None, "playerConfig": None, "forId": None, "mime": None, } await emitter.send_element(element_dict) mock_websocket_session.emit.assert_called_once_with("element", element_dict) async def test_send_step( emitter: ChainlitEmitter, mock_websocket_session: MagicMock ) -> None: step_dict: StepDict = { "id": "test_step", "type": "user_message", "name": "Test Step", "output": "This is a test step", } await emitter.send_step(step_dict) mock_websocket_session.emit.assert_called_once_with("new_message", step_dict) async def test_update_step( emitter: ChainlitEmitter, mock_websocket_session: MagicMock ) -> None: step_dict: StepDict = { "id": "test_step", "type": "assistant_message", "name": "Updated Test Step", "output": "This is an updated test step", } await emitter.update_step(step_dict) mock_websocket_session.emit.assert_called_once_with("update_message", step_dict) async def test_delete_step( emitter: ChainlitEmitter, mock_websocket_session: MagicMock ) -> None: step_dict: StepDict = { "id": "test_step", "type": "system_message", "name": "Deleted Test Step", "output": "This step will be deleted", } await emitter.delete_step(step_dict) mock_websocket_session.emit.assert_called_once_with("delete_message", step_dict) async def test_send_timeout(emitter, mock_websocket_session): await emitter.send_timeout("ask_timeout") mock_websocket_session.emit.assert_called_once_with("ask_timeout", {}) async def test_clear(emitter, mock_websocket_session): await emitter.clear("clear_ask") mock_websocket_session.emit.assert_called_once_with("clear_ask", {}) async def test_send_token( emitter: ChainlitEmitter, mock_websocket_session: MagicMock ) -> None: await emitter.send_token("test_id", "test_token", is_sequence=True, is_input=False) mock_websocket_session.emit.assert_called_once_with( "stream_token", {"id": "test_id", "token": "test_token", "isSequence": True, "isInput": False}, ) async def test_set_chat_settings(emitter, mock_websocket_session): settings = {"key": "value"} emitter.set_chat_settings(settings) assert emitter.session.chat_settings == settings async def test_update_token_count(emitter, mock_websocket_session): count = 100 await emitter.update_token_count(count) mock_websocket_session.emit.assert_called_once_with("token_usage", count) async def test_task_start(emitter, mock_websocket_session): await emitter.task_start() mock_websocket_session.emit.assert_called_once_with("task_start", {}) async def test_task_end(emitter, mock_websocket_session): await emitter.task_end() mock_websocket_session.emit.assert_called_once_with("task_end", {}) async def test_stream_start( emitter: ChainlitEmitter, mock_websocket_session: MagicMock ) -> None: step_dict: StepDict = { "id": "test_stream", "type": "run", "name": "Test Stream", "output": "This is a test stream", } await emitter.stream_start(step_dict) mock_websocket_session.emit.assert_called_once_with("stream_start", step_dict) async def test_send_toast( emitter: ChainlitEmitter, mock_websocket_session: MagicMock ) -> None: message = "This is a test message" await emitter.send_toast(message) mock_websocket_session.emit.assert_called_once_with( "toast", {"message": message, "type": "info"} ) async def test_send_toast_with_type( emitter: ChainlitEmitter, mock_websocket_session: MagicMock ) -> None: message = "This is a test message" await emitter.send_toast(message, type="error") mock_websocket_session.emit.assert_called_once_with( "toast", {"message": message, "type": "error"} ) async def test_send_toast_invalid_type(emitter: ChainlitEmitter) -> None: message = "This is a test message" with pytest.raises(ValueError, match="Invalid toast type: invalid"): await emitter.send_toast(message, type="invalid") # type: ignore[arg-type] ================================================ FILE: backend/tests/test_input_widget.py ================================================ import pytest from chainlit.input_widget import ( Checkbox, MultiSelect, NumberInput, RadioGroup, Select, Slider, Switch, Tab, Tags, TextInput, ) class TestInputWidgetBase: """Test suite for base InputWidget validation.""" def test_input_widget_requires_id_and_label(self): """Test that InputWidget requires both id and label.""" with pytest.raises(ValueError, match="Must provide key and label"): Switch(id="", label="Test Label") with pytest.raises(ValueError, match="Must provide key and label"): Switch(id="test_id", label="") class TestSwitchWidget: """Test suite for Switch input widget.""" def test_switch_initialization(self): """Test Switch widget initialization.""" switch = Switch(id="test_switch", label="Enable Feature") assert switch.id == "test_switch" assert switch.label == "Enable Feature" assert switch.type == "switch" assert switch.initial is False assert switch.disabled is False def test_switch_with_initial_value(self): """Test Switch widget with initial value.""" switch = Switch(id="test_switch", label="Enable Feature", initial=True) assert switch.initial is True def test_switch_with_tooltip_and_description(self): """Test Switch widget with tooltip and description.""" switch = Switch( id="test_switch", label="Enable Feature", tooltip="Toggle this feature", description="This enables the advanced feature", ) assert switch.tooltip == "Toggle this feature" assert switch.description == "This enables the advanced feature" def test_switch_disabled(self): """Test Switch widget in disabled state.""" switch = Switch(id="test_switch", label="Enable Feature", disabled=True) assert switch.disabled is True def test_switch_to_dict(self): """Test Switch widget serialization.""" switch = Switch( id="test_switch", label="Enable Feature", initial=True, tooltip="Toggle", description="Description", disabled=False, ) result = switch.to_dict() assert result["type"] == "switch" assert result["id"] == "test_switch" assert result["label"] == "Enable Feature" assert result["initial"] is True assert result["tooltip"] == "Toggle" assert result["description"] == "Description" assert result["disabled"] is False class TestSliderWidget: """Test suite for Slider input widget.""" def test_slider_initialization(self): """Test Slider widget initialization.""" slider = Slider(id="test_slider", label="Temperature") assert slider.id == "test_slider" assert slider.label == "Temperature" assert slider.type == "slider" assert slider.initial == 0 assert slider.min == 0 assert slider.max == 10 assert slider.step == 1 def test_slider_with_custom_range(self): """Test Slider widget with custom range.""" slider = Slider( id="test_slider", label="Temperature", initial=0.5, min=0.0, max=1.0, step=0.1, ) assert slider.initial == 0.5 assert slider.min == 0.0 assert slider.max == 1.0 assert slider.step == 0.1 def test_slider_to_dict(self): """Test Slider widget serialization.""" slider = Slider( id="test_slider", label="Temperature", initial=0.7, min=0.0, max=2.0, step=0.1, tooltip="Adjust temperature", ) result = slider.to_dict() assert result["type"] == "slider" assert result["id"] == "test_slider" assert result["label"] == "Temperature" assert result["initial"] == 0.7 assert result["min"] == 0.0 assert result["max"] == 2.0 assert result["step"] == 0.1 assert result["tooltip"] == "Adjust temperature" class TestSelectWidget: """Test suite for Select input widget.""" def test_select_with_values(self): """Test Select widget with values list.""" select = Select( id="test_select", label="Choose Model", values=["gpt-4", "gpt-3.5", "claude"], ) assert select.id == "test_select" assert select.label == "Choose Model" assert select.type == "select" assert select.items == { "gpt-4": "gpt-4", "gpt-3.5": "gpt-3.5", "claude": "claude", } def test_select_with_items(self): """Test Select widget with items dict.""" items = {"gpt4": "GPT-4", "gpt35": "GPT-3.5", "claude": "Claude"} select = Select(id="test_select", label="Choose Model", items=items) assert select.items == items def test_select_with_initial_index(self): """Test Select widget with initial_index.""" select = Select( id="test_select", label="Choose Model", values=["gpt-4", "gpt-3.5", "claude"], initial_index=1, ) assert select.initial == "gpt-3.5" def test_select_with_initial_value(self): """Test Select widget with initial_value.""" select = Select( id="test_select", label="Choose Model", values=["gpt-4", "gpt-3.5", "claude"], initial_value="claude", ) assert select.initial == "claude" def test_select_requires_values_or_items(self): """Test that Select requires either values or items.""" with pytest.raises(ValueError, match="Must provide values or items"): Select(id="test_select", label="Choose Model") def test_select_cannot_have_both_values_and_items(self): """Test that Select cannot have both values and items.""" with pytest.raises(ValueError, match="only provide either values or items"): Select( id="test_select", label="Choose Model", values=["a", "b"], items={"a": "A"}, ) def test_select_initial_index_requires_values(self): """Test that initial_index requires values.""" with pytest.raises( ValueError, match="Initial_index can only be used in combination with values", ): Select( id="test_select", label="Choose Model", items={"a": "A"}, initial_index=0, ) def test_select_to_dict(self): """Test Select widget serialization.""" select = Select( id="test_select", label="Choose Model", values=["gpt-4", "gpt-3.5"], initial_index=0, tooltip="Select a model", ) result = select.to_dict() assert result["type"] == "select" assert result["id"] == "test_select" assert result["label"] == "Choose Model" assert result["initial"] == "gpt-4" assert len(result["items"]) == 2 assert result["items"][0] == {"label": "gpt-4", "value": "gpt-4"} assert result["tooltip"] == "Select a model" class TestTextInputWidget: """Test suite for TextInput widget.""" def test_textinput_initialization(self): """Test TextInput widget initialization.""" text_input = TextInput(id="test_input", label="Enter Name") assert text_input.id == "test_input" assert text_input.label == "Enter Name" assert text_input.type == "textinput" assert text_input.initial is None assert text_input.placeholder is None assert text_input.multiline is False def test_textinput_with_initial_and_placeholder(self): """Test TextInput widget with initial value and placeholder.""" text_input = TextInput( id="test_input", label="Enter Name", initial="John Doe", placeholder="Enter your name", ) assert text_input.initial == "John Doe" assert text_input.placeholder == "Enter your name" def test_textinput_multiline(self): """Test TextInput widget in multiline mode.""" text_input = TextInput( id="test_input", label="Enter Description", multiline=True ) assert text_input.multiline is True def test_textinput_to_dict(self): """Test TextInput widget serialization.""" text_input = TextInput( id="test_input", label="Enter Name", initial="Default", placeholder="Type here", multiline=True, tooltip="Enter your name", ) result = text_input.to_dict() assert result["type"] == "textinput" assert result["id"] == "test_input" assert result["label"] == "Enter Name" assert result["initial"] == "Default" assert result["placeholder"] == "Type here" assert result["multiline"] is True assert result["tooltip"] == "Enter your name" class TestNumberInputWidget: """Test suite for NumberInput widget.""" def test_numberinput_initialization(self): """Test NumberInput widget initialization.""" number_input = NumberInput(id="test_number", label="Enter Age") assert number_input.id == "test_number" assert number_input.label == "Enter Age" assert number_input.type == "numberinput" assert number_input.initial is None assert number_input.placeholder is None def test_numberinput_with_initial(self): """Test NumberInput widget with initial value.""" number_input = NumberInput( id="test_number", label="Enter Age", initial=25.5, placeholder="Age" ) assert number_input.initial == 25.5 assert number_input.placeholder == "Age" def test_numberinput_to_dict(self): """Test NumberInput widget serialization.""" number_input = NumberInput( id="test_number", label="Enter Age", initial=30.0, placeholder="Enter a number", tooltip="Your age", ) result = number_input.to_dict() assert result["type"] == "numberinput" assert result["id"] == "test_number" assert result["label"] == "Enter Age" assert result["initial"] == 30.0 assert result["placeholder"] == "Enter a number" assert result["tooltip"] == "Your age" class TestTagsWidget: """Test suite for Tags widget.""" def test_tags_initialization(self): """Test Tags widget initialization.""" tags = Tags(id="test_tags", label="Add Tags") assert tags.id == "test_tags" assert tags.label == "Add Tags" assert tags.type == "tags" assert tags.initial == [] assert tags.values == [] def test_tags_with_initial_values(self): """Test Tags widget with initial values.""" tags = Tags( id="test_tags", label="Add Tags", initial=["python", "javascript"], values=["python", "javascript", "go", "rust"], ) assert tags.initial == ["python", "javascript"] assert tags.values == ["python", "javascript", "go", "rust"] def test_tags_to_dict(self): """Test Tags widget serialization.""" tags = Tags( id="test_tags", label="Add Tags", initial=["tag1"], tooltip="Add your tags", ) result = tags.to_dict() assert result["type"] == "tags" assert result["id"] == "test_tags" assert result["label"] == "Add Tags" assert result["initial"] == ["tag1"] assert result["tooltip"] == "Add your tags" class TestMultiSelectWidget: """Test suite for MultiSelect widget.""" def test_multiselect_with_values(self): """Test MultiSelect widget with values list.""" multi_select = MultiSelect( id="test_multiselect", label="Choose Languages", values=["Python", "JavaScript", "Go"], ) assert multi_select.id == "test_multiselect" assert multi_select.label == "Choose Languages" assert multi_select.type == "multiselect" assert multi_select.items == { "Python": "Python", "JavaScript": "JavaScript", "Go": "Go", } def test_multiselect_with_items(self): """Test MultiSelect widget with items dict.""" items = {"py": "Python", "js": "JavaScript", "go": "Go"} multi_select = MultiSelect( id="test_multiselect", label="Choose Languages", items=items ) assert multi_select.items == items def test_multiselect_with_initial(self): """Test MultiSelect widget with initial selection.""" multi_select = MultiSelect( id="test_multiselect", label="Choose Languages", values=["Python", "JavaScript", "Go"], initial=["Python", "Go"], ) assert multi_select.initial == ["Python", "Go"] def test_multiselect_requires_values_or_items(self): """Test that MultiSelect requires either values or items.""" with pytest.raises(ValueError, match="Must provide values or items"): MultiSelect(id="test_multiselect", label="Choose Languages") def test_multiselect_cannot_have_both_values_and_items(self): """Test that MultiSelect cannot have both values and items.""" with pytest.raises(ValueError, match="only provide either values or items"): MultiSelect( id="test_multiselect", label="Choose Languages", values=["a", "b"], items={"a": "A"}, ) def test_multiselect_to_dict(self): """Test MultiSelect widget serialization.""" multi_select = MultiSelect( id="test_multiselect", label="Choose Languages", values=["Python", "JavaScript"], initial=["Python"], tooltip="Select languages", ) result = multi_select.to_dict() assert result["type"] == "multiselect" assert result["id"] == "test_multiselect" assert result["label"] == "Choose Languages" assert result["initial"] == ["Python"] assert len(result["items"]) == 2 assert result["tooltip"] == "Select languages" class TestCheckboxWidget: """Test suite for Checkbox widget.""" def test_checkbox_initialization(self): """Test Checkbox widget initialization.""" checkbox = Checkbox(id="test_checkbox", label="Accept Terms") assert checkbox.id == "test_checkbox" assert checkbox.label == "Accept Terms" assert checkbox.type == "checkbox" assert checkbox.initial is False def test_checkbox_with_initial_value(self): """Test Checkbox widget with initial value.""" checkbox = Checkbox(id="test_checkbox", label="Accept Terms", initial=True) assert checkbox.initial is True def test_checkbox_to_dict(self): """Test Checkbox widget serialization.""" checkbox = Checkbox( id="test_checkbox", label="Accept Terms", initial=True, tooltip="Check to accept", description="Terms and conditions", ) result = checkbox.to_dict() assert result["type"] == "checkbox" assert result["id"] == "test_checkbox" assert result["label"] == "Accept Terms" assert result["initial"] is True assert result["tooltip"] == "Check to accept" assert result["description"] == "Terms and conditions" class TestRadioGroupWidget: """Test suite for RadioGroup widget.""" def test_radiogroup_with_values(self): """Test RadioGroup widget with values list.""" radio = RadioGroup( id="test_radio", label="Choose Size", values=["Small", "Medium", "Large"] ) assert radio.id == "test_radio" assert radio.label == "Choose Size" assert radio.type == "radio" assert radio.items == {"Small": "Small", "Medium": "Medium", "Large": "Large"} def test_radiogroup_with_items(self): """Test RadioGroup widget with items dict.""" items = {"s": "Small", "m": "Medium", "l": "Large"} radio = RadioGroup(id="test_radio", label="Choose Size", items=items) assert radio.items == items def test_radiogroup_with_initial_index(self): """Test RadioGroup widget with initial_index.""" radio = RadioGroup( id="test_radio", label="Choose Size", values=["Small", "Medium", "Large"], initial_index=1, ) assert radio.initial == "Medium" def test_radiogroup_with_initial_value(self): """Test RadioGroup widget with initial_value.""" radio = RadioGroup( id="test_radio", label="Choose Size", values=["Small", "Medium", "Large"], initial_value="Large", ) assert radio.initial == "Large" def test_radiogroup_requires_values_or_items(self): """Test that RadioGroup requires either values or items.""" with pytest.raises(ValueError, match="Must provide values or items"): RadioGroup(id="test_radio", label="Choose Size") def test_radiogroup_cannot_have_both_values_and_items(self): """Test that RadioGroup cannot have both values and items.""" with pytest.raises(ValueError, match="only provide either values or items"): RadioGroup( id="test_radio", label="Choose Size", values=["a", "b"], items={"a": "A"}, ) def test_radiogroup_initial_index_requires_values(self): """Test that initial_index requires values.""" with pytest.raises( ValueError, match="Initial_index can only be used in combination with values", ): RadioGroup( id="test_radio", label="Choose Size", items={"a": "A"}, initial_index=0 ) def test_radiogroup_to_dict(self): """Test RadioGroup widget serialization.""" radio = RadioGroup( id="test_radio", label="Choose Size", values=["Small", "Medium"], initial_index=0, tooltip="Select size", ) result = radio.to_dict() assert result["type"] == "radio" assert result["id"] == "test_radio" assert result["label"] == "Choose Size" assert result["initial"] == "Small" assert len(result["items"]) == 2 assert result["items"][0] == {"label": "Small", "value": "Small"} assert result["tooltip"] == "Select size" class TestTabWidget: """Test suite for Tab widget.""" def test_tab_initialization(self): """Test Tab initialization.""" tab = Tab(id="test_tab", label="Settings") assert tab.id == "test_tab" assert tab.label == "Settings" assert tab.inputs == [] def test_tab_with_inputs(self): """Test Tab with input widgets.""" switch = Switch(id="switch1", label="Enable") slider = Slider(id="slider1", label="Value") tab = Tab(id="test_tab", label="Settings", inputs=[switch, slider]) assert len(tab.inputs) == 2 assert tab.inputs[0] == switch assert tab.inputs[1] == slider def test_tab_to_dict(self): """Test Tab serialization.""" switch = Switch(id="switch1", label="Enable", initial=True) slider = Slider(id="slider1", label="Value", initial=5) tab = Tab(id="test_tab", label="Settings", inputs=[switch, slider]) result = tab.to_dict() assert result["id"] == "test_tab" assert result["label"] == "Settings" assert len(result["inputs"]) == 2 assert result["inputs"][0]["type"] == "switch" assert result["inputs"][0]["id"] == "switch1" assert result["inputs"][1]["type"] == "slider" assert result["inputs"][1]["id"] == "slider1" def test_tab_to_dict_empty_inputs(self): """Test Tab serialization with no inputs.""" tab = Tab(id="test_tab", label="Empty Tab") result = tab.to_dict() assert result["id"] == "test_tab" assert result["label"] == "Empty Tab" assert result["inputs"] == [] class TestInputWidgetEdgeCases: """Test suite for InputWidget edge cases.""" def test_all_widgets_have_consistent_common_fields(self): """Test that all widgets support common fields.""" widgets = [ Switch( id="test", label="Test", tooltip="Tooltip", description="Description", disabled=True, ), Slider( id="test", label="Test", tooltip="Tooltip", description="Description", disabled=True, ), Checkbox( id="test", label="Test", tooltip="Tooltip", description="Description", disabled=True, ), TextInput( id="test", label="Test", tooltip="Tooltip", description="Description", disabled=True, ), NumberInput( id="test", label="Test", tooltip="Tooltip", description="Description", disabled=True, ), Tags( id="test", label="Test", tooltip="Tooltip", description="Description", disabled=True, ), ] for widget in widgets: assert widget.tooltip == "Tooltip" assert widget.description == "Description" assert widget.disabled is True result = widget.to_dict() assert result["tooltip"] == "Tooltip" assert result["description"] == "Description" assert result["disabled"] is True def test_select_with_complex_items(self): """Test Select with complex item labels and values.""" items = { "option_1": "Option One with Spaces", "option_2": "Option Two (with parentheses)", "option_3": "Option Three - with dashes", } select = Select(id="test_select", label="Choose", items=items) result = select.to_dict() assert len(result["items"]) == 3 assert {"label": "option_1", "value": "Option One with Spaces"} in result[ "items" ] def test_multiselect_initial_with_multiple_values(self): """Test MultiSelect with multiple initial values.""" multi_select = MultiSelect( id="test", label="Choose", values=["A", "B", "C", "D"], initial=["A", "C", "D"], ) assert len(multi_select.initial) == 3 assert "A" in multi_select.initial assert "C" in multi_select.initial assert "D" in multi_select.initial def test_slider_with_negative_range(self): """Test Slider with negative range.""" slider = Slider(id="test", label="Test", min=-10, max=10, initial=-5, step=1) assert slider.min == -10 assert slider.max == 10 assert slider.initial == -5 def test_textinput_empty_initial_value(self): """Test TextInput with empty string as initial value.""" text_input = TextInput(id="test", label="Test", initial="") assert text_input.initial == "" result = text_input.to_dict() assert result["initial"] == "" ================================================ FILE: backend/tests/test_markdown.py ================================================ import os import tempfile from unittest.mock import patch import pytest from chainlit.markdown import DEFAULT_MARKDOWN_STR, get_markdown_str, init_markdown class TestInitMarkdown: """Test suite for init_markdown function.""" def test_init_markdown_creates_file(self): """Test that init_markdown creates chainlit.md if it doesn't exist.""" with tempfile.TemporaryDirectory() as tmpdir: init_markdown(tmpdir) chainlit_md_path = os.path.join(tmpdir, "chainlit.md") assert os.path.exists(chainlit_md_path) # Verify content is the default markdown with open(chainlit_md_path, encoding="utf-8") as f: content = f.read() assert content == DEFAULT_MARKDOWN_STR def test_init_markdown_does_not_overwrite_existing(self): """Test that init_markdown doesn't overwrite existing chainlit.md.""" with tempfile.TemporaryDirectory() as tmpdir: chainlit_md_path = os.path.join(tmpdir, "chainlit.md") custom_content = "# My Custom Markdown" # Create existing file with open(chainlit_md_path, "w", encoding="utf-8") as f: f.write(custom_content) # Call init_markdown init_markdown(tmpdir) # Verify content is unchanged with open(chainlit_md_path, encoding="utf-8") as f: content = f.read() assert content == custom_content def test_init_markdown_with_nonexistent_directory(self): """Test init_markdown with a directory that doesn't exist.""" with tempfile.TemporaryDirectory() as tmpdir: nonexistent_dir = os.path.join(tmpdir, "nonexistent") # Should raise an error when trying to create file in nonexistent dir with pytest.raises(FileNotFoundError): init_markdown(nonexistent_dir) def test_init_markdown_creates_utf8_file(self): """Test that init_markdown creates file with UTF-8 encoding.""" with tempfile.TemporaryDirectory() as tmpdir: init_markdown(tmpdir) chainlit_md_path = os.path.join(tmpdir, "chainlit.md") # Verify UTF-8 encoding by reading with explicit encoding with open(chainlit_md_path, encoding="utf-8") as f: content = f.read() # Should contain emoji characters from DEFAULT_MARKDOWN_STR assert "🚀" in content assert "🤖" in content class TestGetMarkdownStr: """Test suite for get_markdown_str function.""" def test_get_markdown_str_returns_default(self): """Test get_markdown_str returns default chainlit.md content.""" with tempfile.TemporaryDirectory() as tmpdir: chainlit_md_path = os.path.join(tmpdir, "chainlit.md") content = "# Default Chainlit Markdown" with open(chainlit_md_path, "w", encoding="utf-8") as f: f.write(content) result = get_markdown_str(tmpdir, "en") assert result == content def test_get_markdown_str_returns_translated(self): """Test get_markdown_str returns translated markdown when available.""" with tempfile.TemporaryDirectory() as tmpdir: # Create default markdown default_content = "# Default English" with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: f.write(default_content) # Create translated markdown translated_content = "# Français" with open( os.path.join(tmpdir, "chainlit_fr.md"), "w", encoding="utf-8" ) as f: f.write(translated_content) result = get_markdown_str(tmpdir, "fr") assert result == translated_content def test_get_markdown_str_falls_back_to_default(self): """Test get_markdown_str falls back to default when translation missing.""" with tempfile.TemporaryDirectory() as tmpdir: default_content = "# Default English" with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: f.write(default_content) # Request non-existent translation with patch("chainlit.markdown.logger") as mock_logger: result = get_markdown_str(tmpdir, "es") assert result == default_content mock_logger.warning.assert_called_once() assert "es" in str(mock_logger.warning.call_args) def test_get_markdown_str_returns_none_when_no_file(self): """Test get_markdown_str returns None when no markdown file exists.""" with tempfile.TemporaryDirectory() as tmpdir: result = get_markdown_str(tmpdir, "en") assert result is None def test_get_markdown_str_with_utf8_content(self): """Test get_markdown_str handles UTF-8 content correctly.""" with tempfile.TemporaryDirectory() as tmpdir: content = "# Welcome 欢迎 🎉\n\nこんにちは" with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: f.write(content) result = get_markdown_str(tmpdir, "en") assert result == content assert "欢迎" in result assert "こんにちは" in result def test_get_markdown_str_prevents_path_traversal(self): """Test get_markdown_str prevents path traversal attacks.""" with tempfile.TemporaryDirectory() as tmpdir: # Create default markdown default_content = "# Default" with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: f.write(default_content) # Try to access file outside root using path traversal # The is_path_inside check should prevent this result = get_markdown_str(tmpdir, "../../../etc/passwd") # Should fall back to default since traversal is blocked assert result == default_content def test_get_markdown_str_with_multiple_languages(self): """Test get_markdown_str with multiple language files.""" with tempfile.TemporaryDirectory() as tmpdir: # Create multiple language files languages = { "en": "# English", "fr": "# Français", "es": "# Español", "ja": "# 日本語", } # Create default with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: f.write(languages["en"]) # Create translations for lang, content in languages.items(): if lang != "en": path = os.path.join(tmpdir, f"chainlit_{lang}.md") with open(path, "w", encoding="utf-8") as f: f.write(content) # Test each language for lang, expected_content in languages.items(): result = get_markdown_str(tmpdir, lang) assert result == expected_content def test_get_markdown_str_with_empty_file(self): """Test get_markdown_str with empty markdown file.""" with tempfile.TemporaryDirectory() as tmpdir: # Create empty file with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: f.write("") result = get_markdown_str(tmpdir, "en") assert result == "" def test_get_markdown_str_with_large_file(self): """Test get_markdown_str with large markdown file.""" with tempfile.TemporaryDirectory() as tmpdir: # Create large content large_content = "# Header\n" + ("Lorem ipsum dolor sit amet.\n" * 1000) with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: f.write(large_content) result = get_markdown_str(tmpdir, "en") assert result == large_content assert len(result) > 10000 class TestDefaultMarkdownStr: """Test suite for DEFAULT_MARKDOWN_STR constant.""" def test_default_markdown_str_is_string(self): """Test that DEFAULT_MARKDOWN_STR is a string.""" assert isinstance(DEFAULT_MARKDOWN_STR, str) def test_default_markdown_str_not_empty(self): """Test that DEFAULT_MARKDOWN_STR is not empty.""" assert len(DEFAULT_MARKDOWN_STR) > 0 def test_default_markdown_str_contains_welcome(self): """Test that DEFAULT_MARKDOWN_STR contains welcome message.""" assert "Welcome to Chainlit" in DEFAULT_MARKDOWN_STR def test_default_markdown_str_contains_links(self): """Test that DEFAULT_MARKDOWN_STR contains useful links.""" assert "Documentation" in DEFAULT_MARKDOWN_STR assert "Discord" in DEFAULT_MARKDOWN_STR assert "https://docs.chainlit.io" in DEFAULT_MARKDOWN_STR def test_default_markdown_str_is_valid_markdown(self): """Test that DEFAULT_MARKDOWN_STR contains valid markdown syntax.""" assert "#" in DEFAULT_MARKDOWN_STR # Headers assert "**" in DEFAULT_MARKDOWN_STR # Bold assert "[" in DEFAULT_MARKDOWN_STR # Links assert "](" in DEFAULT_MARKDOWN_STR # Link syntax class TestMarkdownEdgeCases: """Test suite for markdown edge cases.""" def test_init_markdown_with_special_characters_in_path(self): """Test init_markdown with special characters in directory path.""" with tempfile.TemporaryDirectory() as tmpdir: special_dir = os.path.join(tmpdir, "test dir with spaces") os.makedirs(special_dir) init_markdown(special_dir) chainlit_md_path = os.path.join(special_dir, "chainlit.md") assert os.path.exists(chainlit_md_path) def test_get_markdown_str_with_symlink(self): """Test get_markdown_str with symlinked markdown file.""" with tempfile.TemporaryDirectory() as tmpdir: # Create original file original_dir = os.path.join(tmpdir, "original") os.makedirs(original_dir) original_file = os.path.join(original_dir, "chainlit.md") content = "# Original Content" with open(original_file, "w", encoding="utf-8") as f: f.write(content) # Create symlink directory link_dir = os.path.join(tmpdir, "link") os.makedirs(link_dir) link_file = os.path.join(link_dir, "chainlit.md") # Create symlink (skip on Windows if no permissions) try: os.symlink(original_file, link_file) result = get_markdown_str(link_dir, "en") assert result == content except OSError: pytest.skip("Symlink creation not supported") def test_get_markdown_str_with_relative_path(self): """Test get_markdown_str with relative path.""" with tempfile.TemporaryDirectory() as tmpdir: content = "# Test Content" with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: f.write(content) # Use relative path original_cwd = os.getcwd() try: os.chdir(tmpdir) result = get_markdown_str(".", "en") assert result == content finally: os.chdir(original_cwd) def test_get_markdown_str_language_case_sensitivity(self): """Test get_markdown_str language code is used as-is in filename.""" with tempfile.TemporaryDirectory() as tmpdir: default_content = "# Default" with open(os.path.join(tmpdir, "chainlit.md"), "w", encoding="utf-8") as f: f.write(default_content) # Create a language file fr_content = "# Français" with open( os.path.join(tmpdir, "chainlit_fr.md"), "w", encoding="utf-8" ) as f: f.write(fr_content) # Test exact match - should get the file result = get_markdown_str(tmpdir, "fr") assert result == fr_content # Test different case that doesn't exist - should fall back to default # Note: On case-insensitive file systems (Windows), this might still find the file # On case-sensitive file systems (Linux), it will fall back to default with patch("chainlit.markdown.logger"): result_different = get_markdown_str(tmpdir, "es") assert result_different == default_content def test_init_markdown_concurrent_calls(self): """Test init_markdown with concurrent calls (race condition).""" with tempfile.TemporaryDirectory() as tmpdir: # Call init_markdown multiple times init_markdown(tmpdir) init_markdown(tmpdir) init_markdown(tmpdir) # Should only have one file with default content chainlit_md_path = os.path.join(tmpdir, "chainlit.md") assert os.path.exists(chainlit_md_path) with open(chainlit_md_path, encoding="utf-8") as f: content = f.read() assert content == DEFAULT_MARKDOWN_STR ================================================ FILE: backend/tests/test_mcp.py ================================================ import sys from unittest.mock import patch import pytest from pydantic import ValidationError from chainlit.mcp import ( HttpMcpConnection, SseMcpConnection, StdioMcpConnection, validate_mcp_command, ) class TestStdioMcpConnection: """Test suite for StdioMcpConnection model.""" def test_stdio_connection_initialization(self): """Test StdioMcpConnection initialization.""" connection = StdioMcpConnection( name="test_server", command="python", args=["-m", "mcp_server"] ) assert connection.name == "test_server" assert connection.command == "python" assert connection.args == ["-m", "mcp_server"] assert connection.clientType == "stdio" def test_stdio_connection_with_empty_args(self): """Test StdioMcpConnection with empty args list.""" connection = StdioMcpConnection(name="test_server", command="node", args=[]) assert connection.args == [] assert connection.clientType == "stdio" def test_stdio_connection_requires_name(self): """Test that StdioMcpConnection requires name.""" with pytest.raises(ValidationError): StdioMcpConnection(command="python", args=[]) def test_stdio_connection_requires_command(self): """Test that StdioMcpConnection requires command.""" with pytest.raises(ValidationError): StdioMcpConnection(name="test_server", args=[]) def test_stdio_connection_requires_args(self): """Test that StdioMcpConnection requires args.""" with pytest.raises(ValidationError): StdioMcpConnection(name="test_server", command="python") def test_stdio_connection_client_type_is_literal(self): """Test that clientType is always 'stdio'.""" connection = StdioMcpConnection(name="test_server", command="python", args=[]) assert connection.clientType == "stdio" def test_stdio_connection_serialization(self): """Test StdioMcpConnection serialization.""" connection = StdioMcpConnection( name="test_server", command="python", args=["-m", "server"] ) data = connection.model_dump() assert data["name"] == "test_server" assert data["command"] == "python" assert data["args"] == ["-m", "server"] assert data["clientType"] == "stdio" class TestSseMcpConnection: """Test suite for SseMcpConnection model.""" def test_sse_connection_initialization(self): """Test SseMcpConnection initialization.""" connection = SseMcpConnection(name="test_server", url="https://example.com/mcp") assert connection.name == "test_server" assert connection.url == "https://example.com/mcp" assert connection.headers is None assert connection.clientType == "sse" def test_sse_connection_with_headers(self): """Test SseMcpConnection with headers.""" headers = {"Authorization": "Bearer token123", "X-Custom": "value"} connection = SseMcpConnection( name="test_server", url="https://example.com/mcp", headers=headers ) assert connection.headers == headers def test_sse_connection_requires_name(self): """Test that SseMcpConnection requires name.""" with pytest.raises(ValidationError): SseMcpConnection(url="https://example.com/mcp") def test_sse_connection_requires_url(self): """Test that SseMcpConnection requires url.""" with pytest.raises(ValidationError): SseMcpConnection(name="test_server") def test_sse_connection_client_type_is_literal(self): """Test that clientType is always 'sse'.""" connection = SseMcpConnection(name="test_server", url="https://example.com/mcp") assert connection.clientType == "sse" def test_sse_connection_serialization(self): """Test SseMcpConnection serialization.""" headers = {"Authorization": "Bearer token"} connection = SseMcpConnection( name="test_server", url="https://example.com/mcp", headers=headers ) data = connection.model_dump() assert data["name"] == "test_server" assert data["url"] == "https://example.com/mcp" assert data["headers"] == headers assert data["clientType"] == "sse" class TestHttpMcpConnection: """Test suite for HttpMcpConnection model.""" def test_http_connection_initialization(self): """Test HttpMcpConnection initialization.""" connection = HttpMcpConnection( name="test_server", url="https://example.com/mcp" ) assert connection.name == "test_server" assert connection.url == "https://example.com/mcp" assert connection.headers is None assert connection.clientType == "streamable-http" def test_http_connection_with_headers(self): """Test HttpMcpConnection with headers.""" headers = { "Authorization": "Bearer token123", "Content-Type": "application/json", } connection = HttpMcpConnection( name="test_server", url="https://example.com/mcp", headers=headers ) assert connection.headers == headers def test_http_connection_requires_name(self): """Test that HttpMcpConnection requires name.""" with pytest.raises(ValidationError): HttpMcpConnection(url="https://example.com/mcp") def test_http_connection_requires_url(self): """Test that HttpMcpConnection requires url.""" with pytest.raises(ValidationError): HttpMcpConnection(name="test_server") def test_http_connection_client_type_is_literal(self): """Test that clientType is always 'streamable-http'.""" connection = HttpMcpConnection( name="test_server", url="https://example.com/mcp" ) assert connection.clientType == "streamable-http" def test_http_connection_serialization(self): """Test HttpMcpConnection serialization.""" headers = {"Authorization": "Bearer token"} connection = HttpMcpConnection( name="test_server", url="https://example.com/mcp", headers=headers ) data = connection.model_dump() assert data["name"] == "test_server" assert data["url"] == "https://example.com/mcp" assert data["headers"] == headers assert data["clientType"] == "streamable-http" class TestValidateMcpCommand: """Test suite for validate_mcp_command function.""" def test_validate_simple_command(self): """Test validation of a simple command.""" mcp_module = sys.modules["chainlit.mcp"] with patch.object(mcp_module, "config") as mock_config: mock_config.features.mcp.stdio.allowed_executables = ["python", "node"] env, executable, args = validate_mcp_command("python -m mcp_server") assert env == {} assert executable == "python" assert args == ["-m", "mcp_server"] def test_validate_command_with_path(self): """Test validation of command with full path.""" mcp_module = sys.modules["chainlit.mcp"] with patch.object(mcp_module, "config") as mock_config: mock_config.features.mcp.stdio.allowed_executables = ["python"] env, executable, args = validate_mcp_command( "/usr/bin/python -m mcp_server" ) assert env == {} assert executable == "/usr/bin/python" assert args == ["-m", "mcp_server"] def test_validate_command_with_windows_path(self): """Test validation of command with Windows path (using forward slashes or quoted).""" mcp_module = sys.modules["chainlit.mcp"] with patch.object(mcp_module, "config") as mock_config: mock_config.features.mcp.stdio.allowed_executables = ["python.exe"] # Use forward slashes which work on Windows and don't get escaped by shlex env, executable, args = validate_mcp_command( "C:/Python/python.exe -m mcp_server" ) assert env == {} assert executable == "C:/Python/python.exe" assert args == ["-m", "mcp_server"] def test_validate_command_with_env_vars(self): """Test validation of command with environment variables.""" mcp_module = sys.modules["chainlit.mcp"] with patch.object(mcp_module, "config") as mock_config: mock_config.features.mcp.stdio.allowed_executables = ["node"] env, executable, args = validate_mcp_command( "MY_VAR=value NODE_ENV=production node server.js" ) assert env == {"MY_VAR": "value", "NODE_ENV": "production"} assert executable == "node" assert args == ["server.js"] def test_validate_command_with_env_var_with_spaces(self): """Test validation of command with env var containing spaces.""" mcp_module = sys.modules["chainlit.mcp"] with patch.object(mcp_module, "config") as mock_config: mock_config.features.mcp.stdio.allowed_executables = ["python"] env, executable, args = validate_mcp_command( 'MY_VAR="value with spaces" python script.py' ) assert env == {"MY_VAR": "value with spaces"} assert executable == "python" assert args == ["script.py"] def test_validate_command_with_quoted_args(self): """Test validation of command with quoted arguments.""" mcp_module = sys.modules["chainlit.mcp"] with patch.object(mcp_module, "config") as mock_config: mock_config.features.mcp.stdio.allowed_executables = ["python"] env, executable, args = validate_mcp_command( 'python script.py --arg "value with spaces"' ) assert env == {} assert executable == "python" assert args == ["script.py", "--arg", "value with spaces"] def test_validate_command_with_multiple_args(self): """Test validation of command with multiple arguments.""" mcp_module = sys.modules["chainlit.mcp"] with patch.object(mcp_module, "config") as mock_config: mock_config.features.mcp.stdio.allowed_executables = ["node"] env, executable, args = validate_mcp_command( "node server.js --port 3000 --host localhost" ) assert env == {} assert executable == "node" assert args == ["server.js", "--port", "3000", "--host", "localhost"] def test_validate_command_not_in_allowed_list(self): """Test that validation fails for disallowed executable.""" mcp_module = sys.modules["chainlit.mcp"] with patch.object(mcp_module, "config") as mock_config: mock_config.features.mcp.stdio.allowed_executables = ["python", "node"] with pytest.raises(ValueError, match="Only commands in"): validate_mcp_command("bash script.sh") def test_validate_empty_command(self): """Test that validation fails for empty command.""" mcp_module = sys.modules["chainlit.mcp"] with patch.object(mcp_module, "config") as mock_config: mock_config.features.mcp.stdio.allowed_executables = ["python"] with pytest.raises(ValueError, match="Empty command string"): validate_mcp_command("") def test_validate_command_with_invalid_syntax(self): """Test that validation fails for invalid command syntax.""" mcp_module = sys.modules["chainlit.mcp"] with patch.object(mcp_module, "config") as mock_config: mock_config.features.mcp.stdio.allowed_executables = ["python"] with pytest.raises(ValueError, match="Invalid command string"): validate_mcp_command('python "unclosed quote') def test_validate_command_with_none_allowed_executables(self): """Test validation when allowed_executables is None (all allowed).""" mcp_module = sys.modules["chainlit.mcp"] with patch.object(mcp_module, "config") as mock_config: mock_config.features.mcp.stdio.allowed_executables = None env, executable, args = validate_mcp_command("any_command --arg value") assert env == {} assert executable == "any_command" assert args == ["--arg", "value"] def test_validate_command_with_invalid_env_var_format(self): """Test that validation fails for invalid env var format.""" mcp_module = sys.modules["chainlit.mcp"] with patch.object(mcp_module, "config") as mock_config: mock_config.features.mcp.stdio.allowed_executables = ["python"] with pytest.raises(ValueError, match="Invalid environment variable format"): validate_mcp_command("INVALID_ENV python script.py") def test_validate_command_with_complex_env_vars(self): """Test validation with complex environment variables.""" mcp_module = sys.modules["chainlit.mcp"] with patch.object(mcp_module, "config") as mock_config: mock_config.features.mcp.stdio.allowed_executables = ["python"] env, executable, args = validate_mcp_command( 'API_KEY=sk-123456 BASE_URL="https://api.example.com" python app.py' ) assert env == { "API_KEY": "sk-123456", "BASE_URL": "https://api.example.com", } assert executable == "python" assert args == ["app.py"] def test_validate_command_with_equals_in_arg(self): """Test validation with equals sign in argument.""" mcp_module = sys.modules["chainlit.mcp"] with patch.object(mcp_module, "config") as mock_config: mock_config.features.mcp.stdio.allowed_executables = ["python"] env, executable, args = validate_mcp_command( "python script.py --config=value" ) assert env == {} assert executable == "python" assert args == ["script.py", "--config=value"] def test_validate_command_preserves_arg_order(self): """Test that argument order is preserved.""" mcp_module = sys.modules["chainlit.mcp"] with patch.object(mcp_module, "config") as mock_config: mock_config.features.mcp.stdio.allowed_executables = ["node"] _, _, args = validate_mcp_command( "node app.js arg1 arg2 arg3 --flag1 --flag2" ) assert args == ["app.js", "arg1", "arg2", "arg3", "--flag1", "--flag2"] class TestMcpConnectionEdgeCases: """Test suite for MCP connection edge cases.""" def test_stdio_connection_with_complex_args(self): """Test StdioMcpConnection with complex arguments.""" connection = StdioMcpConnection( name="complex_server", command="python", args=[ "-m", "mcp_server", "--config", "/path/to/config.json", "--verbose", ], ) assert len(connection.args) == 5 assert connection.args[0] == "-m" assert connection.args[3] == "/path/to/config.json" def test_sse_connection_with_multiple_headers(self): """Test SseMcpConnection with multiple headers.""" headers = { "Authorization": "Bearer token", "X-API-Key": "key123", "Content-Type": "application/json", "Accept": "application/json", } connection = SseMcpConnection( name="multi_header_server", url="https://api.example.com", headers=headers ) assert len(connection.headers) == 4 assert connection.headers["Authorization"] == "Bearer token" assert connection.headers["X-API-Key"] == "key123" def test_http_connection_with_localhost_url(self): """Test HttpMcpConnection with localhost URL.""" connection = HttpMcpConnection( name="local_server", url="http://localhost:8000/mcp" ) assert connection.url == "http://localhost:8000/mcp" def test_connection_names_can_be_descriptive(self): """Test that connection names can be descriptive strings.""" stdio_conn = StdioMcpConnection( name="My Custom MCP Server (Python)", command="python", args=[] ) sse_conn = SseMcpConnection( name="Production API Server", url="https://api.example.com" ) http_conn = HttpMcpConnection( name="Development Server - Local", url="http://localhost:3000" ) assert "Python" in stdio_conn.name assert "Production" in sse_conn.name assert "Development" in http_conn.name def test_validate_command_with_special_characters_in_path(self): """Test validation with special characters in path.""" mcp_module = sys.modules["chainlit.mcp"] with patch.object(mcp_module, "config") as mock_config: mock_config.features.mcp.stdio.allowed_executables = ["python"] _, executable, args = validate_mcp_command( "/opt/my-app/bin/python script.py" ) assert executable == "/opt/my-app/bin/python" assert args == ["script.py"] ================================================ FILE: backend/tests/test_message.py ================================================ import asyncio import json from contextlib import contextmanager from unittest.mock import AsyncMock, Mock, patch import pytest from chainlit.action import Action from chainlit.context import ChainlitContext, context_var from chainlit.message import ( AskActionMessage, AskElementMessage, AskFileMessage, AskUserMessage, ErrorMessage, Message, MessageBase, ) @contextmanager def mock_chainlit_context(session=None): """Context manager to set up and tear down Chainlit context.""" mock_loop = Mock(spec=asyncio.AbstractEventLoop) mock_session = session or Mock() mock_session.thread_id = "thread_123" with patch("asyncio.get_running_loop", return_value=mock_loop): mock_emitter = AsyncMock() mock_context = ChainlitContext(session=mock_session, emitter=mock_emitter) token = context_var.set(mock_context) try: yield mock_context finally: context_var.reset(token) class TestMessageBase: """Test suite for MessageBase class.""" def test_post_init_sets_thread_id(self): """Test that __post_init__ sets thread_id from session.""" with mock_chainlit_context(): msg = Message(content="test") assert msg.thread_id == "thread_123" def test_post_init_generates_id_if_not_provided(self): """Test that __post_init__ generates UUID if id not provided.""" with mock_chainlit_context(): msg = Message(content="test") assert msg.id is not None assert len(msg.id) == 36 def test_post_init_uses_provided_id(self): """Test that __post_init__ uses provided id.""" with mock_chainlit_context(): msg = Message(content="test", id="custom_id") assert msg.id == "custom_id" def test_from_dict_creates_message(self): """Test creating message from dictionary.""" step_dict = { "id": "msg_123", "parentId": "parent_123", "createdAt": "2024-01-01T00:00:00Z", "output": "Hello world", "name": "Assistant", "command": "/test", "type": "user_message", "language": "python", "metadata": {"key": "value"}, } with mock_chainlit_context(): msg = MessageBase.from_dict(step_dict) assert msg.id == "msg_123" assert msg.parent_id == "parent_123" assert msg.created_at == "2024-01-01T00:00:00Z" assert msg.content == "Hello world" assert msg.author == "Assistant" assert msg.command == "/test" assert msg.type == "user_message" assert msg.language == "python" assert msg.metadata == {"key": "value"} def test_from_dict_with_minimal_data(self): """Test from_dict with minimal required fields.""" step_dict = { "id": "msg_123", "createdAt": "2024-01-01T00:00:00Z", "output": "Hello", } with mock_chainlit_context(): with patch("chainlit.message.config") as mock_config: mock_config.ui.name = "DefaultBot" msg = MessageBase.from_dict(step_dict) assert msg.id == "msg_123" assert msg.content == "Hello" assert msg.author == "DefaultBot" assert msg.type == "assistant_message" def test_to_dict_returns_step_dict(self): """Test converting message to dictionary.""" with mock_chainlit_context(): msg = Message( content="Test content", author="TestBot", language="python", type="user_message", metadata={"key": "value"}, tags=["tag1", "tag2"], id="msg_123", parent_id="parent_123", command="/test", ) msg.created_at = "2024-01-01T00:00:00Z" result = msg.to_dict() assert result["id"] == "msg_123" assert result["threadId"] == "thread_123" assert result["parentId"] == "parent_123" assert result["createdAt"] == "2024-01-01T00:00:00Z" assert result["command"] == "/test" assert result["output"] == "Test content" assert result["name"] == "TestBot" assert result["type"] == "user_message" assert result["language"] == "python" assert result["streaming"] is False assert result["isError"] is False assert result["waitForAnswer"] is False assert result["metadata"] == {"key": "value"} assert result["tags"] == ["tag1", "tag2"] @pytest.mark.asyncio async def test_update_stops_streaming(self): """Test that update stops streaming.""" with mock_chainlit_context() as ctx: msg = Message(content="test") msg.streaming = True with patch("chainlit.message.chat_context") as mock_chat_ctx: with patch("chainlit.message.get_data_layer", return_value=None): result = await msg.update() assert msg.streaming is False assert result is True mock_chat_ctx.add.assert_called_once_with(msg) ctx.emitter.update_step.assert_called_once() @pytest.mark.asyncio async def test_update_with_data_layer(self): """Test update with data layer.""" with mock_chainlit_context() as ctx: msg = Message(content="test") mock_data_layer = AsyncMock() with patch("chainlit.message.chat_context"): with patch( "chainlit.message.get_data_layer", return_value=mock_data_layer ): with patch("asyncio.create_task") as mock_create_task: await msg.update() mock_create_task.assert_called_once() ctx.emitter.update_step.assert_called_once() @pytest.mark.asyncio async def test_remove_from_chat_context(self): """Test removing message from chat context.""" with mock_chainlit_context() as ctx: msg = Message(content="test", id="msg_123") with patch("chainlit.message.chat_context") as mock_chat_ctx: with patch("chainlit.message.get_data_layer", return_value=None): result = await msg.remove() assert result is True mock_chat_ctx.remove.assert_called_once_with(msg) ctx.emitter.delete_step.assert_called_once() class TestMessage: """Test suite for Message class.""" def test_message_with_string_content(self): """Test creating message with string content.""" with mock_chainlit_context(): msg = Message(content="Hello world") assert msg.content == "Hello world" assert msg.language is None def test_message_with_dict_content(self): """Test creating message with dict content.""" with mock_chainlit_context(): content_dict = {"key": "value", "number": 42} msg = Message(content=content_dict) expected = json.dumps(content_dict, indent=4, ensure_ascii=False) assert msg.content == expected assert msg.language == "json" def test_message_with_non_serializable_dict(self): """Test message with non-JSON-serializable dict.""" with mock_chainlit_context(): class NonSerializable: pass content_dict = {"obj": NonSerializable()} msg = Message(content=content_dict) assert msg.language == "text" assert "NonSerializable" in msg.content def test_message_with_non_string_content(self): """Test message with non-string, non-dict content.""" with mock_chainlit_context(): msg = Message(content=12345) assert msg.content == "12345" assert msg.language == "text" def test_message_with_custom_author(self): """Test message with custom author.""" with mock_chainlit_context(): msg = Message(content="test", author="CustomBot") assert msg.author == "CustomBot" def test_message_with_default_author(self): """Test message uses default author from config.""" with mock_chainlit_context(): with patch("chainlit.message.config") as mock_config: mock_config.ui.name = "DefaultBot" msg = Message(content="test") assert msg.author == "DefaultBot" def test_message_with_actions(self): """Test message with actions.""" with mock_chainlit_context(): action1 = Mock(spec=Action) action2 = Mock(spec=Action) msg = Message(content="test", actions=[action1, action2]) assert len(msg.actions) == 2 assert action1 in msg.actions assert action2 in msg.actions def test_message_with_elements(self): """Test message with elements.""" with mock_chainlit_context(): element1 = Mock() element2 = Mock() msg = Message(content="test", elements=[element1, element2]) assert len(msg.elements) == 2 assert element1 in msg.elements assert element2 in msg.elements @pytest.mark.asyncio async def test_message_send(self): """Test sending a message.""" with mock_chainlit_context() as ctx: msg = Message(content="test") with patch("chainlit.message.chat_context") as mock_chat_ctx: with patch("chainlit.message.get_data_layer", return_value=None): with patch("chainlit.message.config") as mock_config: mock_config.code.author_rename = None result = await msg.send() assert result == msg assert msg.created_at is not None assert msg.streaming is False mock_chat_ctx.add.assert_called_once_with(msg) ctx.emitter.send_step.assert_called_once() @pytest.mark.asyncio async def test_message_send_with_author_rename(self): """Test sending message with author rename.""" with mock_chainlit_context(): msg = Message(content="test", author="OldName") async def rename_author(name): return "NewName" with patch("chainlit.message.chat_context"): with patch("chainlit.message.get_data_layer", return_value=None): with patch("chainlit.message.config") as mock_config: mock_config.code.author_rename = rename_author await msg.send() assert msg.author == "NewName" @pytest.mark.asyncio async def test_message_send_with_actions_and_elements(self): """Test sending message with actions and elements.""" with mock_chainlit_context(): action = AsyncMock(spec=Action) element = AsyncMock() msg = Message(content="test", actions=[action], elements=[element]) with patch("chainlit.message.chat_context"): with patch("chainlit.message.get_data_layer", return_value=None): with patch("chainlit.message.config") as mock_config: mock_config.code.author_rename = None await msg.send() action.send.assert_called_once() element.send.assert_called_once() @pytest.mark.asyncio async def test_message_update_with_actions(self): """Test updating message with new actions.""" with mock_chainlit_context(): action1 = AsyncMock(spec=Action) action1.forId = None action2 = AsyncMock(spec=Action) action2.forId = "existing_id" msg = Message(content="test", actions=[action1, action2]) with patch("chainlit.message.chat_context"): with patch("chainlit.message.get_data_layer", return_value=None): result = await msg.update() assert result is True action1.send.assert_called_once() action2.send.assert_not_called() @pytest.mark.asyncio async def test_message_remove_actions(self): """Test removing all actions from message.""" with mock_chainlit_context(): action1 = AsyncMock(spec=Action) action2 = AsyncMock(spec=Action) msg = Message(content="test", actions=[action1, action2]) await msg.remove_actions() action1.remove.assert_called_once() action2.remove.assert_called_once() @pytest.mark.asyncio async def test_stream_token_starts_streaming(self): """Test that stream_token starts streaming.""" with mock_chainlit_context() as ctx: msg = Message(content="") await msg.stream_token("Hello") assert msg.streaming is True assert msg.content == "Hello" ctx.emitter.stream_start.assert_called_once() @pytest.mark.asyncio async def test_stream_token_appends_content(self): """Test that stream_token appends to content.""" with mock_chainlit_context() as ctx: msg = Message(content="Hello") msg.streaming = True await msg.stream_token(" world") assert msg.content == "Hello world" ctx.emitter.send_token.assert_called_once_with( id=msg.id, token=" world", is_sequence=False ) @pytest.mark.asyncio async def test_stream_token_with_sequence(self): """Test stream_token with is_sequence=True.""" with mock_chainlit_context() as ctx: msg = Message(content="Old content") msg.streaming = True await msg.stream_token("New content", is_sequence=True) assert msg.content == "New content" ctx.emitter.send_token.assert_called_once_with( id=msg.id, token="New content", is_sequence=True ) @pytest.mark.asyncio async def test_stream_token_ignores_empty_token(self): """Test that empty tokens are ignored.""" with mock_chainlit_context() as ctx: msg = Message(content="test") await msg.stream_token("") assert msg.content == "test" ctx.emitter.stream_start.assert_not_called() class TestErrorMessage: """Test suite for ErrorMessage class.""" def test_error_message_initialization(self): """Test ErrorMessage initialization.""" with mock_chainlit_context(): msg = ErrorMessage(content="An error occurred") assert msg.content == "An error occurred" assert msg.author is not None assert msg.type == "assistant_message" assert msg.is_error is True assert msg.fail_on_persist_error is False def test_error_message_with_custom_author(self): """Test ErrorMessage with custom author.""" with mock_chainlit_context(): msg = ErrorMessage(content="Error", author="ErrorBot") assert msg.author == "ErrorBot" def test_error_message_with_fail_on_persist(self): """Test ErrorMessage with fail_on_persist_error=True.""" with mock_chainlit_context(): msg = ErrorMessage(content="Error", fail_on_persist_error=True) assert msg.fail_on_persist_error is True @pytest.mark.asyncio async def test_error_message_send(self): """Test sending error message.""" with mock_chainlit_context() as ctx: msg = ErrorMessage(content="Error occurred") with patch("chainlit.message.chat_context"): with patch("chainlit.message.get_data_layer", return_value=None): with patch("chainlit.message.config") as mock_config: mock_config.code.author_rename = None result = await msg.send() assert result == msg ctx.emitter.send_step.assert_called_once() class TestAskUserMessage: """Test suite for AskUserMessage class.""" def test_ask_user_message_initialization(self): """Test AskUserMessage initialization.""" with mock_chainlit_context(): msg = AskUserMessage(content="What is your name?") assert msg.content == "What is your name?" assert msg.author is not None assert msg.timeout == 60 assert msg.raise_on_timeout is False def test_ask_user_message_with_custom_timeout(self): """Test AskUserMessage with custom timeout.""" with mock_chainlit_context(): msg = AskUserMessage(content="Question?", timeout=120) assert msg.timeout == 120 @pytest.mark.asyncio async def test_ask_user_message_send(self): """Test sending AskUserMessage.""" with mock_chainlit_context() as ctx: msg = AskUserMessage(content="Question?") ctx.emitter.send_ask_user = AsyncMock(return_value={"output": "Answer"}) with patch("chainlit.message.get_data_layer", return_value=None): with patch("chainlit.message.config") as mock_config: mock_config.code.author_rename = None result = await msg.send() assert result == {"output": "Answer"} assert msg.wait_for_answer is False ctx.emitter.send_ask_user.assert_called_once() class TestAskFileMessage: """Test suite for AskFileMessage class.""" def test_ask_file_message_initialization(self): """Test AskFileMessage initialization.""" with mock_chainlit_context(): with patch("chainlit.message.config") as mock_config: mock_config.ui.name = "Bot" msg = AskFileMessage( content="Upload a file", accept=["text/plain", "application/pdf"] ) assert msg.content == "Upload a file" assert msg.accept == ["text/plain", "application/pdf"] assert msg.max_size_mb == 2 assert msg.max_files == 1 def test_ask_file_message_with_custom_limits(self): """Test AskFileMessage with custom limits.""" with mock_chainlit_context(): msg = AskFileMessage( content="Upload", accept=["image/*"], max_size_mb=10, max_files=5 ) assert msg.max_size_mb == 10 assert msg.max_files == 5 @pytest.mark.asyncio async def test_ask_file_message_send_with_response(self): """Test AskFileMessage send with file response.""" with mock_chainlit_context() as ctx: msg = AskFileMessage(content="Upload", accept=["text/plain"]) file_response = [ { "id": "file_123", "name": "test.txt", "path": "/path/to/test.txt", "size": 1024, "type": "text/plain", } ] ctx.emitter.send_ask_user = AsyncMock(return_value=file_response) with patch("chainlit.message.get_data_layer", return_value=None): with patch("chainlit.message.config") as mock_config: mock_config.code.author_rename = None result = await msg.send() assert result is not None assert len(result) == 1 assert result[0].id == "file_123" assert result[0].name == "test.txt" assert result[0].path == "/path/to/test.txt" @pytest.mark.asyncio async def test_ask_file_message_send_with_no_response(self): """Test AskFileMessage send with no response.""" with mock_chainlit_context() as ctx: msg = AskFileMessage(content="Upload", accept=["text/plain"]) ctx.emitter.send_ask_user = AsyncMock(return_value=None) with patch("chainlit.message.get_data_layer", return_value=None): with patch("chainlit.message.config") as mock_config: mock_config.code.author_rename = None result = await msg.send() assert result is None class TestAskActionMessage: """Test suite for AskActionMessage class.""" def test_ask_action_message_initialization(self): """Test AskActionMessage initialization.""" with mock_chainlit_context(): with patch("chainlit.message.config") as mock_config: mock_config.ui.name = "Bot" action1 = Mock(spec=Action) action2 = Mock(spec=Action) msg = AskActionMessage( content="Choose an action", actions=[action1, action2] ) assert msg.content == "Choose an action" assert len(msg.actions) == 2 @pytest.mark.asyncio async def test_ask_action_message_send_with_response(self): """Test AskActionMessage send with action response.""" with mock_chainlit_context() as ctx: action = AsyncMock(spec=Action) action.id = "action_123" msg = AskActionMessage(content="Choose", actions=[action]) ctx.emitter.send_ask_user = AsyncMock( return_value={"id": "action_123", "label": "Confirm"} ) with patch("chainlit.message.get_data_layer", return_value=None): with patch("chainlit.message.config") as mock_config: mock_config.code.author_rename = None with patch("chainlit.message.chat_context"): result = await msg.send() assert result == {"id": "action_123", "label": "Confirm"} assert msg.content == "**Selected:** Confirm" action.send.assert_called_once() action.remove.assert_called_once() @pytest.mark.asyncio async def test_ask_action_message_send_timeout(self): """Test AskActionMessage send with timeout.""" with mock_chainlit_context() as ctx: action = AsyncMock(spec=Action) action.id = "action_123" msg = AskActionMessage(content="Choose", actions=[action]) ctx.emitter.send_ask_user = AsyncMock(return_value=None) with patch("chainlit.message.get_data_layer", return_value=None): with patch("chainlit.message.config") as mock_config: mock_config.code.author_rename = None with patch("chainlit.message.chat_context"): result = await msg.send() assert result is None assert msg.content == "Timed out: no action was taken" class TestAskElementMessage: """Test suite for AskElementMessage class.""" def test_ask_element_message_initialization(self): """Test AskElementMessage initialization.""" with mock_chainlit_context(): with patch("chainlit.message.config") as mock_config: mock_config.ui.name = "Bot" element = Mock() msg = AskElementMessage(content="Submit form", element=element) assert msg.content == "Submit form" assert msg.element == element @pytest.mark.asyncio async def test_ask_element_message_send_submitted(self): """Test AskElementMessage send with submitted response.""" with mock_chainlit_context() as ctx: element = AsyncMock() element.id = "element_123" msg = AskElementMessage(content="Submit", element=element) ctx.emitter.send_ask_user = AsyncMock( return_value={"submitted": True, "data": {"field": "value"}} ) with patch("chainlit.message.get_data_layer", return_value=None): with patch("chainlit.message.config") as mock_config: mock_config.code.author_rename = None with patch("chainlit.message.chat_context"): result = await msg.send() assert result == {"submitted": True, "data": {"field": "value"}} assert msg.content == "Thanks for submitting" element.send.assert_called_once() element.remove.assert_called_once() @pytest.mark.asyncio async def test_ask_element_message_send_cancelled(self): """Test AskElementMessage send with cancelled response.""" with mock_chainlit_context() as ctx: element = AsyncMock() element.id = "element_123" msg = AskElementMessage(content="Submit", element=element) ctx.emitter.send_ask_user = AsyncMock(return_value={"submitted": False}) with patch("chainlit.message.get_data_layer", return_value=None): with patch("chainlit.message.config") as mock_config: mock_config.code.author_rename = None with patch("chainlit.message.chat_context"): result = await msg.send() assert result == {"submitted": False} assert msg.content == "Cancelled" @pytest.mark.asyncio async def test_ask_element_message_send_timeout(self): """Test AskElementMessage send with timeout.""" with mock_chainlit_context() as ctx: element = AsyncMock() element.id = "element_123" msg = AskElementMessage(content="Submit", element=element) ctx.emitter.send_ask_user = AsyncMock(return_value=None) with patch("chainlit.message.get_data_layer", return_value=None): with patch("chainlit.message.config") as mock_config: mock_config.code.author_rename = None with patch("chainlit.message.chat_context"): result = await msg.send() assert result is None assert msg.content == "Timed out" class TestMessageEdgeCases: """Test suite for message edge cases.""" def test_message_with_none_content(self): """Test message handles None content.""" with mock_chainlit_context(): msg = Message(content=None) assert msg.content == "None" def test_message_language_override(self): """Test that dict content sets language to json.""" with mock_chainlit_context(): msg = Message(content={"key": "value"}, language="python") # Dict content always sets language to json, overriding the parameter assert msg.language == "json" @pytest.mark.asyncio async def test_message_send_with_none_content(self): """Test sending message with None content.""" with mock_chainlit_context(): msg = Message(content="test") msg.content = None with patch("chainlit.message.chat_context"): with patch("chainlit.message.get_data_layer", return_value=None): with patch("chainlit.message.config") as mock_config: mock_config.code.author_rename = None await msg.send() assert msg.content == "" @pytest.mark.asyncio async def test_ask_message_remove_clears_ask(self): """Test that AskMessage remove clears ask state.""" with mock_chainlit_context() as ctx: msg = AskUserMessage(content="Question?") with patch("chainlit.message.chat_context"): with patch("chainlit.message.get_data_layer", return_value=None): await msg.remove() ctx.emitter.clear.assert_called_once_with("clear_ask") def test_message_metadata_and_tags(self): """Test message with metadata and tags.""" with mock_chainlit_context(): metadata = {"key1": "value1", "key2": 123} tags = ["important", "user-query"] msg = Message(content="test", metadata=metadata, tags=tags) assert msg.metadata == metadata assert msg.tags == tags def test_message_to_dict_with_none_metadata(self): """Test to_dict with None metadata.""" with mock_chainlit_context(): msg = Message(content="test") msg.metadata = None result = msg.to_dict() assert result["metadata"] == {} ================================================ FILE: backend/tests/test_modes.py ================================================ """Tests for Modes system functionality.""" from unittest.mock import MagicMock, patch import pytest import chainlit as cl from chainlit.emitter import ChainlitEmitter from chainlit.mode import Mode, ModeOption @pytest.fixture def mock_modes(): """Fixture providing sample modes for testing.""" return [ Mode( id="model", name="Model", options=[ ModeOption( id="gpt-4", name="GPT-4", description="Most capable model", icon="sparkles", default=True, ), ModeOption( id="gpt-3.5-turbo", name="GPT-3.5 Turbo", description="Fast and efficient", icon="bolt", default=False, ), ], ), Mode( id="reasoning", name="Reasoning", options=[ ModeOption(id="high", name="High", description="Maximum depth"), ModeOption( id="medium", name="Medium", description="Balanced", default=True ), ModeOption(id="low", name="Low", description="Quick responses"), ], ), ] @pytest.mark.asyncio class TestModeOption: """Test suite for ModeOption dataclass.""" def test_mode_option_required_fields(self): """Test ModeOption with required fields only.""" option = ModeOption(id="test", name="Test Option") assert option.id == "test" assert option.name == "Test Option" assert option.description is None assert option.icon is None assert option.default is False def test_mode_option_all_fields(self): """Test ModeOption with all fields.""" option = ModeOption( id="gpt-4", name="GPT-4", description="Most capable model", icon="sparkles", default=True, ) assert option.id == "gpt-4" assert option.name == "GPT-4" assert option.description == "Most capable model" assert option.icon == "sparkles" assert option.default is True def test_mode_option_to_dict(self): """Test ModeOption serialization.""" option = ModeOption( id="test", name="Test", description="Test desc", icon="star", default=True, ) option_dict = option.to_dict() assert option_dict["id"] == "test" assert option_dict["name"] == "Test" assert option_dict["description"] == "Test desc" assert option_dict["icon"] == "star" assert option_dict["default"] is True @pytest.mark.asyncio class TestMode: """Test suite for Mode dataclass.""" def test_mode_creation(self, mock_modes): """Test Mode creation with options.""" mode = mock_modes[0] assert mode.id == "model" assert mode.name == "Model" assert len(mode.options) == 2 def test_mode_to_dict(self, mock_modes): """Test Mode serialization.""" mode = mock_modes[0] mode_dict = mode.to_dict() assert mode_dict["id"] == "model" assert mode_dict["name"] == "Model" assert len(mode_dict["options"]) == 2 assert mode_dict["options"][0]["id"] == "gpt-4" def test_mode_default_option(self, mock_modes): """Test finding default option in mode.""" mode = mock_modes[0] default_option = next( (opt for opt in mode.options if opt.default), mode.options[0] ) assert default_option.id == "gpt-4" def test_mode_fallback_to_first(self, mock_modes): """Test fallback to first option when no default set.""" mode = Mode( id="test", name="Test", options=[ ModeOption(id="opt1", name="Option 1"), ModeOption(id="opt2", name="Option 2"), ], ) default_option = next( (opt for opt in mode.options if opt.default), mode.options[0] if mode.options else None, ) assert default_option is not None assert default_option.id == "opt1" @pytest.mark.asyncio class TestMessageWithModes: """Test suite for Message with modes field.""" async def test_message_with_modes(self, mock_chainlit_context): """Test that Message can be created with modes field.""" async with mock_chainlit_context: modes = {"model": "gpt-4", "reasoning": "high"} message = cl.Message(content="Test message", modes=modes) assert message.modes == modes assert message.content == "Test message" async def test_message_to_dict_includes_modes(self, mock_chainlit_context): """Test that Message.to_dict() includes the modes field.""" async with mock_chainlit_context: modes = {"model": "gpt-4", "reasoning": "medium"} message = cl.Message(content="Test", modes=modes) message_dict = message.to_dict() assert "modes" in message_dict assert message_dict["modes"] == modes async def test_message_from_dict_with_modes(self, mock_chainlit_context): """Test that Message.from_dict() correctly handles modes field.""" async with mock_chainlit_context: message_dict = { "id": "test-id", "content": "Test message", "modes": {"model": "gpt-3.5-turbo", "reasoning": "low"}, "type": "user_message", "createdAt": "2024-01-01T00:00:00", "output": "Test message", } message = cl.Message.from_dict(message_dict) assert message.modes == {"model": "gpt-3.5-turbo", "reasoning": "low"} assert message.content == "Test message" async def test_message_without_modes(self, mock_chainlit_context): """Test that Message works without modes field (backward compatibility).""" async with mock_chainlit_context: message = cl.Message(content="Test message") assert message.modes is None message_dict = message.to_dict() assert message_dict.get("modes") is None async def test_message_send_with_modes(self, mock_chainlit_context): """Test that sending a message with modes works.""" async with mock_chainlit_context as ctx: modes = {"model": "gpt-4", "reasoning": "high"} message = cl.Message(content="Test", modes=modes) with patch("chainlit.message.chat_context") as mock_chat_ctx: with patch("chainlit.message.get_data_layer", return_value=None): with patch("chainlit.message.config") as mock_config: mock_config.code.author_rename = None result = await message.send() assert result == message assert message.modes == modes mock_chat_ctx.add.assert_called_once_with(message) ctx.emitter.send_step.assert_called_once() # Verify the dict sent to emitter includes modes call_args = ctx.emitter.send_step.call_args[0][0] assert call_args["modes"] == modes @pytest.mark.asyncio class TestEmitterSetModes: """Test suite for emitter set_modes functionality.""" async def test_set_modes( self, mock_modes, mock_websocket_session: MagicMock ) -> None: """Test set_modes emits correct event.""" emitter = ChainlitEmitter(mock_websocket_session) modes_dicts = [mode.to_dict() for mode in mock_modes] await emitter.set_modes(mock_modes) mock_websocket_session.emit.assert_called_once_with("set_modes", modes_dicts) async def test_set_modes_empty_list( self, mock_websocket_session: MagicMock ) -> None: """Test set_modes with empty list.""" emitter = ChainlitEmitter(mock_websocket_session) await emitter.set_modes([]) mock_websocket_session.emit.assert_called_once_with("set_modes", []) async def test_set_modes_single_mode( self, mock_websocket_session: MagicMock ) -> None: """Test set_modes with single mode.""" emitter = ChainlitEmitter(mock_websocket_session) mode = Mode( id="model", name="Model", options=[ModeOption(id="gpt-4", name="GPT-4", default=True)], ) await emitter.set_modes([mode]) mock_websocket_session.emit.assert_called_once() call_args = mock_websocket_session.emit.call_args assert call_args[0][0] == "set_modes" assert len(call_args[0][1]) == 1 assert call_args[0][1][0]["id"] == "model" @pytest.mark.asyncio class TestModeExports: """Test that Mode and ModeOption are properly exported.""" def test_mode_exported_from_chainlit(self): """Test Mode is exported from chainlit package.""" assert hasattr(cl, "Mode") assert cl.Mode is Mode def test_mode_option_exported_from_chainlit(self): """Test ModeOption is exported from chainlit package.""" assert hasattr(cl, "ModeOption") assert cl.ModeOption is ModeOption ================================================ FILE: backend/tests/test_oauth_providers.py ================================================ import os from unittest.mock import AsyncMock, Mock, patch import httpx import pytest from fastapi import HTTPException from chainlit.oauth_providers import ( ACCESS_TOKEN_MISSING, Auth0OAuthProvider, AWSCognitoOAuthProvider, AzureADHybridOAuthProvider, AzureADOAuthProvider, DescopeOAuthProvider, GenericOAuthProvider, GithubOAuthProvider, GitlabOAuthProvider, GoogleOAuthProvider, KeycloakOAuthProvider, OAuthProvider, OktaOAuthProvider, get_configured_oauth_providers, get_oauth_provider, ) from chainlit.user import User class TestOAuthProviderBase: """Test suite for OAuthProvider base class.""" def test_oauth_provider_has_required_methods(self): """Test that OAuthProvider defines required methods.""" provider = OAuthProvider() # These should be methods assert hasattr(provider, "is_configured") assert hasattr(provider, "get_raw_token_response") assert hasattr(provider, "get_token") assert hasattr(provider, "get_user_info") assert hasattr(provider, "get_env_prefix") assert hasattr(provider, "get_prompt") def test_oauth_provider_is_configured_returns_false_when_env_missing(self): """Test is_configured returns False when environment variables are missing.""" provider = OAuthProvider() provider.env = ["MISSING_VAR_1", "MISSING_VAR_2"] assert provider.is_configured() is False def test_oauth_provider_is_configured_returns_true_when_env_present(self): """Test is_configured returns True when all environment variables are present.""" provider = OAuthProvider() provider.env = ["TEST_VAR_1", "TEST_VAR_2"] with patch.dict(os.environ, {"TEST_VAR_1": "value1", "TEST_VAR_2": "value2"}): assert provider.is_configured() is True def test_oauth_provider_get_env_prefix(self): """Test get_env_prefix converts id to uppercase with underscores.""" provider = OAuthProvider() provider.id = "azure-ad" assert provider.get_env_prefix() == "AZURE_AD" def test_oauth_provider_get_prompt_returns_provider_specific(self): """Test get_prompt returns provider-specific prompt.""" provider = OAuthProvider() provider.id = "github" provider.default_prompt = None with patch.dict(os.environ, {"OAUTH_GITHUB_PROMPT": "consent"}): assert provider.get_prompt() == "consent" def test_oauth_provider_get_prompt_returns_global(self): """Test get_prompt returns global prompt when provider-specific not set.""" provider = OAuthProvider() provider.id = "github" provider.default_prompt = None with patch.dict(os.environ, {"OAUTH_PROMPT": "select_account"}): assert provider.get_prompt() == "select_account" def test_oauth_provider_get_prompt_returns_default(self): """Test get_prompt returns default when no env vars set.""" provider = OAuthProvider() provider.id = "github" provider.default_prompt = "login" assert provider.get_prompt() == "login" @pytest.mark.asyncio async def test_oauth_provider_abstract_methods_raise_not_implemented(self): """Test that abstract methods raise NotImplementedError.""" provider = OAuthProvider() with pytest.raises(NotImplementedError): await provider.get_raw_token_response("code", "url") with pytest.raises(NotImplementedError): await provider.get_token("code", "url") with pytest.raises(NotImplementedError): await provider.get_user_info("token") class TestGithubOAuthProvider: """Test suite for GithubOAuthProvider.""" def test_github_provider_initialization(self): """Test GithubOAuthProvider initialization.""" with patch.dict( os.environ, { "OAUTH_GITHUB_CLIENT_ID": "test_client_id", "OAUTH_GITHUB_CLIENT_SECRET": "test_secret", }, ): provider = GithubOAuthProvider() assert provider.id == "github" assert provider.client_id == "test_client_id" assert provider.client_secret == "test_secret" assert "scope" in provider.authorize_params assert provider.authorize_params["scope"] == "user:email" def test_github_provider_with_custom_urls(self): """Test GithubOAuthProvider with custom URLs.""" # Need to set env vars before importing/instantiating since they're class-level with patch.dict( os.environ, { "OAUTH_GITHUB_CLIENT_ID": "test_id", "OAUTH_GITHUB_CLIENT_SECRET": "test_secret", "OAUTH_GITHUB_AUTH_URL": "https://custom.github.com/oauth/authorize", "OAUTH_GITHUB_TOKEN_URL": "https://custom.github.com/oauth/token", "OAUTH_GITHUB_USER_INFO_URL": "https://custom.github.com/api/user", }, clear=False, ): # Re-import to get the updated class-level attributes from importlib import reload import chainlit.oauth_providers as oauth_module reload(oauth_module) provider = oauth_module.GithubOAuthProvider() assert provider.authorize_url == "https://custom.github.com/oauth/authorize" assert provider.token_url == "https://custom.github.com/oauth/token" assert provider.user_info_url == "https://custom.github.com/api/user" @pytest.mark.asyncio async def test_github_get_raw_token_response_success(self): """Test GitHub get_raw_token_response with successful response.""" with patch.dict( os.environ, { "OAUTH_GITHUB_CLIENT_ID": "test_id", "OAUTH_GITHUB_CLIENT_SECRET": "test_secret", }, ): provider = GithubOAuthProvider() mock_response = Mock() mock_response.text = "access_token=test_token&token_type=bearer" mock_response.raise_for_status = Mock() with patch("httpx.AsyncClient") as mock_client: mock_client.return_value.__aenter__.return_value.post = AsyncMock( return_value=mock_response ) result = await provider.get_raw_token_response( "test_code", "http://localhost" ) assert "access_token" in result assert result["access_token"][0] == "test_token" @pytest.mark.asyncio async def test_github_get_token_success(self): """Test GitHub get_token with successful response.""" with patch.dict( os.environ, { "OAUTH_GITHUB_CLIENT_ID": "test_id", "OAUTH_GITHUB_CLIENT_SECRET": "test_secret", }, ): provider = GithubOAuthProvider() mock_response = Mock() mock_response.text = "access_token=github_token_123&token_type=bearer" mock_response.raise_for_status = Mock() with patch("httpx.AsyncClient") as mock_client: mock_client.return_value.__aenter__.return_value.post = AsyncMock( return_value=mock_response ) token = await provider.get_token("test_code", "http://localhost") assert token == "github_token_123" @pytest.mark.asyncio async def test_github_get_token_missing_access_token(self): """Test GitHub get_token raises error when access_token is missing.""" with patch.dict( os.environ, { "OAUTH_GITHUB_CLIENT_ID": "test_id", "OAUTH_GITHUB_CLIENT_SECRET": "test_secret", }, ): provider = GithubOAuthProvider() mock_response = Mock() mock_response.text = "error=invalid_grant" mock_response.raise_for_status = Mock() with patch("httpx.AsyncClient") as mock_client: mock_client.return_value.__aenter__.return_value.post = AsyncMock( return_value=mock_response ) with pytest.raises(HTTPException) as exc_info: await provider.get_token("test_code", "http://localhost") assert exc_info.value.status_code == 400 assert ACCESS_TOKEN_MISSING in str(exc_info.value.detail) @pytest.mark.asyncio async def test_github_get_user_info_success(self): """Test GitHub get_user_info with successful response.""" with patch.dict( os.environ, { "OAUTH_GITHUB_CLIENT_ID": "test_id", "OAUTH_GITHUB_CLIENT_SECRET": "test_secret", }, ): provider = GithubOAuthProvider() mock_user_response = Mock() mock_user_response.json.return_value = { "login": "testuser", "avatar_url": "https://github.com/avatar.png", "email": "test@example.com", } mock_user_response.raise_for_status = Mock() mock_emails_response = Mock() mock_emails_response.json.return_value = [ {"email": "test@example.com", "primary": True, "verified": True} ] mock_emails_response.raise_for_status = Mock() with patch("httpx.AsyncClient") as mock_client: mock_get = AsyncMock( side_effect=[mock_user_response, mock_emails_response] ) mock_client.return_value.__aenter__.return_value.get = mock_get github_user, user = await provider.get_user_info("test_token") assert github_user["login"] == "testuser" assert "emails" in github_user assert isinstance(user, User) assert user.identifier == "testuser" assert user.metadata["provider"] == "github" assert user.metadata["image"] == "https://github.com/avatar.png" class TestGoogleOAuthProvider: """Test suite for GoogleOAuthProvider.""" def test_google_provider_initialization(self): """Test GoogleOAuthProvider initialization.""" with patch.dict( os.environ, { "OAUTH_GOOGLE_CLIENT_ID": "google_client_id", "OAUTH_GOOGLE_CLIENT_SECRET": "google_secret", }, ): provider = GoogleOAuthProvider() assert provider.id == "google" assert provider.client_id == "google_client_id" assert provider.client_secret == "google_secret" assert ( provider.authorize_url == "https://accounts.google.com/o/oauth2/v2/auth" ) assert "scope" in provider.authorize_params assert "userinfo.profile" in provider.authorize_params["scope"] @pytest.mark.asyncio async def test_google_get_token_success(self): """Test Google get_token with successful response.""" with patch.dict( os.environ, { "OAUTH_GOOGLE_CLIENT_ID": "google_id", "OAUTH_GOOGLE_CLIENT_SECRET": "google_secret", }, ): provider = GoogleOAuthProvider() mock_response = Mock() mock_response.json.return_value = { "access_token": "google_access_token", "token_type": "Bearer", } mock_response.raise_for_status = Mock() with patch("httpx.AsyncClient") as mock_client: mock_client.return_value.__aenter__.return_value.post = AsyncMock( return_value=mock_response ) token = await provider.get_token( "auth_code", "http://localhost/callback" ) assert token == "google_access_token" @pytest.mark.asyncio async def test_google_get_user_info_success(self): """Test Google get_user_info with successful response.""" with patch.dict( os.environ, { "OAUTH_GOOGLE_CLIENT_ID": "google_id", "OAUTH_GOOGLE_CLIENT_SECRET": "google_secret", }, ): provider = GoogleOAuthProvider() mock_response = Mock() mock_response.json.return_value = { "email": "user@gmail.com", "name": "Test User", "picture": "https://google.com/photo.jpg", } mock_response.raise_for_status = Mock() with patch("httpx.AsyncClient") as mock_client: mock_client.return_value.__aenter__.return_value.get = AsyncMock( return_value=mock_response ) google_user, user = await provider.get_user_info("test_token") assert google_user["email"] == "user@gmail.com" assert isinstance(user, User) assert user.identifier == "user@gmail.com" assert user.metadata["provider"] == "google" assert user.metadata["image"] == "https://google.com/photo.jpg" class TestAzureADOAuthProvider: """Test suite for AzureADOAuthProvider.""" def test_azure_ad_provider_initialization(self): """Test AzureADOAuthProvider initialization.""" with patch.dict( os.environ, { "OAUTH_AZURE_AD_CLIENT_ID": "azure_client_id", "OAUTH_AZURE_AD_CLIENT_SECRET": "azure_secret", "OAUTH_AZURE_AD_TENANT_ID": "tenant_123", }, ): provider = AzureADOAuthProvider() assert provider.id == "azure-ad" assert provider.client_id == "azure_client_id" assert provider.client_secret == "azure_secret" assert "tenant" in provider.authorize_params assert provider.authorize_params["tenant"] == "tenant_123" def test_azure_ad_single_tenant_urls(self): """Test Azure AD uses tenant-specific URLs when single tenant enabled.""" # Azure AD URLs are set at class definition time, need to reload module with patch.dict( os.environ, { "OAUTH_AZURE_AD_CLIENT_ID": "azure_id", "OAUTH_AZURE_AD_CLIENT_SECRET": "azure_secret", "OAUTH_AZURE_AD_TENANT_ID": "tenant_abc", "OAUTH_AZURE_AD_ENABLE_SINGLE_TENANT": "true", }, clear=False, ): from importlib import reload import chainlit.oauth_providers as oauth_module reload(oauth_module) provider = oauth_module.AzureADOAuthProvider() assert "tenant_abc" in provider.authorize_url assert "tenant_abc" in provider.token_url @pytest.mark.asyncio async def test_azure_ad_get_token_with_refresh_token(self): """Test Azure AD get_token stores refresh token.""" with patch.dict( os.environ, { "OAUTH_AZURE_AD_CLIENT_ID": "azure_id", "OAUTH_AZURE_AD_CLIENT_SECRET": "azure_secret", "OAUTH_AZURE_AD_TENANT_ID": "tenant_123", }, ): provider = AzureADOAuthProvider() mock_response = Mock() mock_response.json.return_value = { "access_token": "azure_access_token", "refresh_token": "azure_refresh_token", } mock_response.raise_for_status = Mock() with patch("httpx.AsyncClient") as mock_client: mock_client.return_value.__aenter__.return_value.post = AsyncMock( return_value=mock_response ) token = await provider.get_token( "auth_code", "http://localhost/callback" ) assert token == "azure_access_token" assert provider._refresh_token == "azure_refresh_token" @pytest.mark.asyncio async def test_azure_ad_get_user_info_with_photo(self): """Test Azure AD get_user_info includes photo when available.""" with patch.dict( os.environ, { "OAUTH_AZURE_AD_CLIENT_ID": "azure_id", "OAUTH_AZURE_AD_CLIENT_SECRET": "azure_secret", "OAUTH_AZURE_AD_TENANT_ID": "tenant_123", }, ): provider = AzureADOAuthProvider() provider._refresh_token = "refresh_token_123" mock_user_response = Mock() mock_user_response.json.return_value = { "userPrincipalName": "user@company.com", "displayName": "Test User", } mock_user_response.raise_for_status = Mock() mock_photo_response = Mock() mock_photo_response.aread = AsyncMock(return_value=b"photo_data") mock_photo_response.headers = {"Content-Type": "image/jpeg"} with patch("httpx.AsyncClient") as mock_client: mock_get = AsyncMock( side_effect=[mock_user_response, mock_photo_response] ) mock_client.return_value.__aenter__.return_value.get = mock_get azure_user, user = await provider.get_user_info("test_token") assert azure_user["userPrincipalName"] == "user@company.com" assert "image" in azure_user assert isinstance(user, User) assert user.identifier == "user@company.com" assert user.metadata["provider"] == "azure-ad" assert user.metadata["refresh_token"] == "refresh_token_123" class TestOktaOAuthProvider: """Test suite for OktaOAuthProvider.""" def test_okta_provider_initialization(self): """Test OktaOAuthProvider initialization.""" with patch.dict( os.environ, { "OAUTH_OKTA_CLIENT_ID": "okta_client_id", "OAUTH_OKTA_CLIENT_SECRET": "okta_secret", "OAUTH_OKTA_DOMAIN": "dev-12345.okta.com", }, clear=False, ): from importlib import reload import chainlit.oauth_providers as oauth_module reload(oauth_module) provider = oauth_module.OktaOAuthProvider() assert provider.id == "okta" assert provider.client_id == "okta_client_id" assert "dev-12345.okta.com" in provider.authorize_url def test_okta_authorization_server_path_default(self): """Test Okta uses default authorization server.""" with patch.dict( os.environ, { "OAUTH_OKTA_CLIENT_ID": "okta_id", "OAUTH_OKTA_CLIENT_SECRET": "okta_secret", "OAUTH_OKTA_DOMAIN": "dev-12345.okta.com", }, ): provider = OktaOAuthProvider() assert provider.get_authorization_server_path() == "/default" def test_okta_authorization_server_path_custom(self): """Test Okta uses custom authorization server.""" with patch.dict( os.environ, { "OAUTH_OKTA_CLIENT_ID": "okta_id", "OAUTH_OKTA_CLIENT_SECRET": "okta_secret", "OAUTH_OKTA_DOMAIN": "dev-12345.okta.com", "OAUTH_OKTA_AUTHORIZATION_SERVER_ID": "custom_server", }, ): provider = OktaOAuthProvider() assert provider.get_authorization_server_path() == "/custom_server" def test_okta_authorization_server_path_false(self): """Test Okta with no authorization server.""" with patch.dict( os.environ, { "OAUTH_OKTA_CLIENT_ID": "okta_id", "OAUTH_OKTA_CLIENT_SECRET": "okta_secret", "OAUTH_OKTA_DOMAIN": "dev-12345.okta.com", "OAUTH_OKTA_AUTHORIZATION_SERVER_ID": "false", }, ): provider = OktaOAuthProvider() assert provider.get_authorization_server_path() == "" class TestAuth0OAuthProvider: """Test suite for Auth0OAuthProvider.""" def test_auth0_provider_initialization(self): """Test Auth0OAuthProvider initialization.""" with patch.dict( os.environ, { "OAUTH_AUTH0_CLIENT_ID": "auth0_client_id", "OAUTH_AUTH0_CLIENT_SECRET": "auth0_secret", "OAUTH_AUTH0_DOMAIN": "dev-12345.auth0.com", }, ): provider = Auth0OAuthProvider() assert provider.id == "auth0" assert provider.client_id == "auth0_client_id" assert "dev-12345.auth0.com" in provider.domain def test_auth0_with_original_domain(self): """Test Auth0 with separate original domain.""" with patch.dict( os.environ, { "OAUTH_AUTH0_CLIENT_ID": "auth0_id", "OAUTH_AUTH0_CLIENT_SECRET": "auth0_secret", "OAUTH_AUTH0_DOMAIN": "custom.domain.com", "OAUTH_AUTH0_ORIGINAL_DOMAIN": "dev-12345.auth0.com", }, ): provider = Auth0OAuthProvider() assert "custom.domain.com" in provider.domain assert "dev-12345.auth0.com" in provider.original_domain class TestGenericOAuthProvider: """Test suite for GenericOAuthProvider.""" def test_generic_provider_initialization(self): """Test GenericOAuthProvider initialization.""" with patch.dict( os.environ, { "OAUTH_GENERIC_CLIENT_ID": "generic_id", "OAUTH_GENERIC_CLIENT_SECRET": "generic_secret", "OAUTH_GENERIC_AUTH_URL": "https://auth.example.com/oauth/authorize", "OAUTH_GENERIC_TOKEN_URL": "https://auth.example.com/oauth/token", "OAUTH_GENERIC_USER_INFO_URL": "https://auth.example.com/oauth/userinfo", "OAUTH_GENERIC_SCOPES": "openid profile email", }, ): provider = GenericOAuthProvider() assert provider.id == "generic" assert provider.client_id == "generic_id" assert provider.authorize_url == "https://auth.example.com/oauth/authorize" assert provider.token_url == "https://auth.example.com/oauth/token" assert provider.user_info_url == "https://auth.example.com/oauth/userinfo" def test_generic_provider_custom_name(self): """Test GenericOAuthProvider with custom name.""" # Generic provider id is set at class definition time with patch.dict( os.environ, { "OAUTH_GENERIC_NAME": "my-custom-provider", "OAUTH_GENERIC_CLIENT_ID": "generic_id", "OAUTH_GENERIC_CLIENT_SECRET": "generic_secret", "OAUTH_GENERIC_AUTH_URL": "https://auth.example.com/oauth/authorize", "OAUTH_GENERIC_TOKEN_URL": "https://auth.example.com/oauth/token", "OAUTH_GENERIC_USER_INFO_URL": "https://auth.example.com/oauth/userinfo", "OAUTH_GENERIC_SCOPES": "openid profile", }, clear=False, ): from importlib import reload import chainlit.oauth_providers as oauth_module reload(oauth_module) provider = oauth_module.GenericOAuthProvider() assert provider.id == "my-custom-provider" def test_generic_provider_custom_user_identifier(self): """Test GenericOAuthProvider with custom user identifier field.""" with patch.dict( os.environ, { "OAUTH_GENERIC_CLIENT_ID": "generic_id", "OAUTH_GENERIC_CLIENT_SECRET": "generic_secret", "OAUTH_GENERIC_AUTH_URL": "https://auth.example.com/oauth/authorize", "OAUTH_GENERIC_TOKEN_URL": "https://auth.example.com/oauth/token", "OAUTH_GENERIC_USER_INFO_URL": "https://auth.example.com/oauth/userinfo", "OAUTH_GENERIC_SCOPES": "openid", "OAUTH_GENERIC_USER_IDENTIFIER": "username", }, ): provider = GenericOAuthProvider() assert provider.user_identifier == "username" class TestAzureADHybridOAuthProvider: """Test suite for AzureADHybridOAuthProvider.""" def test_azure_ad_hybrid_provider_initialization(self): """Test AzureADHybridOAuthProvider initialization.""" with patch.dict( os.environ, { "OAUTH_AZURE_AD_HYBRID_CLIENT_ID": "hybrid_client_id", "OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET": "hybrid_secret", "OAUTH_AZURE_AD_HYBRID_TENANT_ID": "tenant_456", }, ): provider = AzureADHybridOAuthProvider() assert provider.id == "azure-ad-hybrid" assert provider.client_id == "hybrid_client_id" assert provider.client_secret == "hybrid_secret" assert "tenant" in provider.authorize_params assert provider.authorize_params["tenant"] == "tenant_456" assert provider.authorize_params["response_type"] == "code id_token" assert provider.authorize_params["response_mode"] == "form_post" assert "nonce" in provider.authorize_params @pytest.mark.asyncio async def test_azure_ad_hybrid_get_token_success(self): """Test AzureADHybrid get_token with successful response.""" with patch.dict( os.environ, { "OAUTH_AZURE_AD_HYBRID_CLIENT_ID": "hybrid_id", "OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET": "hybrid_secret", "OAUTH_AZURE_AD_HYBRID_TENANT_ID": "tenant_789", }, ): provider = AzureADHybridOAuthProvider() mock_response = Mock() mock_response.json.return_value = { "access_token": "hybrid_access_token", "refresh_token": "hybrid_refresh_token", } mock_response.raise_for_status = Mock() with patch("httpx.AsyncClient") as mock_client: mock_client.return_value.__aenter__.return_value.post = AsyncMock( return_value=mock_response ) token = await provider.get_token( "auth_code", "http://localhost/callback" ) assert token == "hybrid_access_token" assert provider._refresh_token == "hybrid_refresh_token" @pytest.mark.asyncio async def test_azure_ad_hybrid_get_user_info_success(self): """Test AzureADHybrid get_user_info with successful response.""" with patch.dict( os.environ, { "OAUTH_AZURE_AD_HYBRID_CLIENT_ID": "hybrid_id", "OAUTH_AZURE_AD_HYBRID_CLIENT_SECRET": "hybrid_secret", "OAUTH_AZURE_AD_HYBRID_TENANT_ID": "tenant_789", }, ): provider = AzureADHybridOAuthProvider() provider._refresh_token = "refresh_token_hybrid" mock_user_response = Mock() mock_user_response.json.return_value = { "userPrincipalName": "hybrid@company.com", "displayName": "Hybrid User", } mock_user_response.raise_for_status = Mock() mock_photo_response = Mock() mock_photo_response.aread = AsyncMock(return_value=b"photo_bytes") mock_photo_response.headers = {"Content-Type": "image/png"} with patch("httpx.AsyncClient") as mock_client: mock_get = AsyncMock( side_effect=[mock_user_response, mock_photo_response] ) mock_client.return_value.__aenter__.return_value.get = mock_get azure_user, user = await provider.get_user_info("test_token") assert azure_user["userPrincipalName"] == "hybrid@company.com" assert isinstance(user, User) assert user.identifier == "hybrid@company.com" assert user.metadata["provider"] == "azure-ad" assert user.metadata["refresh_token"] == "refresh_token_hybrid" class TestDescopeOAuthProvider: """Test suite for DescopeOAuthProvider.""" def test_descope_provider_initialization(self): """Test DescopeOAuthProvider initialization.""" with patch.dict( os.environ, { "OAUTH_DESCOPE_CLIENT_ID": "descope_client_id", "OAUTH_DESCOPE_CLIENT_SECRET": "descope_secret", }, ): provider = DescopeOAuthProvider() assert provider.id == "descope" assert provider.client_id == "descope_client_id" assert provider.client_secret == "descope_secret" assert "openid profile email" in provider.authorize_params["scope"] @pytest.mark.asyncio async def test_descope_get_token_success(self): """Test Descope get_token with successful response.""" with patch.dict( os.environ, { "OAUTH_DESCOPE_CLIENT_ID": "descope_id", "OAUTH_DESCOPE_CLIENT_SECRET": "descope_secret", }, ): provider = DescopeOAuthProvider() mock_response = Mock() mock_response.json.return_value = { "access_token": "descope_access_token", "token_type": "Bearer", } mock_response.raise_for_status = Mock() with patch("httpx.AsyncClient") as mock_client: mock_client.return_value.__aenter__.return_value.post = AsyncMock( return_value=mock_response ) token = await provider.get_token( "auth_code", "http://localhost/callback" ) assert token == "descope_access_token" @pytest.mark.asyncio async def test_descope_get_user_info_success(self): """Test Descope get_user_info with successful response.""" with patch.dict( os.environ, { "OAUTH_DESCOPE_CLIENT_ID": "descope_id", "OAUTH_DESCOPE_CLIENT_SECRET": "descope_secret", }, ): provider = DescopeOAuthProvider() mock_response = Mock() mock_response.json.return_value = { "email": "user@descope.com", "name": "Descope User", } mock_response.raise_for_status = Mock() with patch("httpx.AsyncClient") as mock_client: mock_client.return_value.__aenter__.return_value.get = AsyncMock( return_value=mock_response ) descope_user, user = await provider.get_user_info("test_token") assert descope_user["email"] == "user@descope.com" assert isinstance(user, User) assert user.identifier == "user@descope.com" assert user.metadata["provider"] == "descope" class TestAWSCognitoOAuthProvider: """Test suite for AWSCognitoOAuthProvider.""" def test_cognito_provider_initialization(self): """Test AWSCognitoOAuthProvider initialization.""" with patch.dict( os.environ, { "OAUTH_COGNITO_CLIENT_ID": "cognito_client_id", "OAUTH_COGNITO_CLIENT_SECRET": "cognito_secret", "OAUTH_COGNITO_DOMAIN": "my-app.auth.us-east-1.amazoncognito.com", }, ): provider = AWSCognitoOAuthProvider() assert provider.id == "aws-cognito" assert provider.client_id == "cognito_client_id" assert provider.client_secret == "cognito_secret" assert "openid profile email" in provider.scopes def test_cognito_provider_custom_scopes(self): """Test AWSCognitoOAuthProvider with custom scopes.""" with patch.dict( os.environ, { "OAUTH_COGNITO_CLIENT_ID": "cognito_id", "OAUTH_COGNITO_CLIENT_SECRET": "cognito_secret", "OAUTH_COGNITO_DOMAIN": "my-app.auth.us-east-1.amazoncognito.com", "OAUTH_COGNITO_SCOPE": "openid email phone", }, ): provider = AWSCognitoOAuthProvider() assert provider.scopes == "openid email phone" assert provider.authorize_params["scope"] == "openid email phone" @pytest.mark.asyncio async def test_cognito_get_token_success(self): """Test Cognito get_token with successful response.""" with patch.dict( os.environ, { "OAUTH_COGNITO_CLIENT_ID": "cognito_id", "OAUTH_COGNITO_CLIENT_SECRET": "cognito_secret", "OAUTH_COGNITO_DOMAIN": "my-app.auth.us-east-1.amazoncognito.com", }, ): provider = AWSCognitoOAuthProvider() mock_response = Mock() mock_response.json.return_value = { "access_token": "cognito_access_token", "token_type": "Bearer", } mock_response.raise_for_status = Mock() with patch("httpx.AsyncClient") as mock_client: mock_client.return_value.__aenter__.return_value.post = AsyncMock( return_value=mock_response ) token = await provider.get_token( "auth_code", "http://localhost/callback" ) assert token == "cognito_access_token" @pytest.mark.asyncio async def test_cognito_get_user_info_success(self): """Test Cognito get_user_info with successful response.""" with patch.dict( os.environ, { "OAUTH_COGNITO_CLIENT_ID": "cognito_id", "OAUTH_COGNITO_CLIENT_SECRET": "cognito_secret", "OAUTH_COGNITO_DOMAIN": "my-app.auth.us-east-1.amazoncognito.com", }, ): provider = AWSCognitoOAuthProvider() mock_response = Mock() mock_response.json.return_value = { "email": "user@cognito.com", "picture": "https://cognito.com/photo.jpg", } mock_response.raise_for_status = Mock() with patch("httpx.AsyncClient") as mock_client: mock_client.return_value.__aenter__.return_value.get = AsyncMock( return_value=mock_response ) cognito_user, user = await provider.get_user_info("test_token") assert cognito_user["email"] == "user@cognito.com" assert isinstance(user, User) assert user.identifier == "user@cognito.com" assert user.metadata["provider"] == "aws-cognito" assert user.metadata["image"] == "https://cognito.com/photo.jpg" class TestGitlabOAuthProvider: """Test suite for GitlabOAuthProvider.""" def test_gitlab_provider_initialization(self): """Test GitlabOAuthProvider initialization.""" with patch.dict( os.environ, { "OAUTH_GITLAB_CLIENT_ID": "gitlab_client_id", "OAUTH_GITLAB_CLIENT_SECRET": "gitlab_secret", "OAUTH_GITLAB_DOMAIN": "gitlab.example.com", }, ): provider = GitlabOAuthProvider() assert provider.id == "gitlab" assert provider.client_id == "gitlab_client_id" assert provider.client_secret == "gitlab_secret" assert "gitlab.example.com" in provider.domain assert "openid profile email" in provider.authorize_params["scope"] def test_gitlab_provider_strips_trailing_slash(self): """Test GitlabOAuthProvider strips trailing slash from domain.""" with patch.dict( os.environ, { "OAUTH_GITLAB_CLIENT_ID": "gitlab_id", "OAUTH_GITLAB_CLIENT_SECRET": "gitlab_secret", "OAUTH_GITLAB_DOMAIN": "gitlab.example.com/", }, ): provider = GitlabOAuthProvider() assert not provider.domain.endswith("/") @pytest.mark.asyncio async def test_gitlab_get_token_success(self): """Test Gitlab get_token with successful response.""" with patch.dict( os.environ, { "OAUTH_GITLAB_CLIENT_ID": "gitlab_id", "OAUTH_GITLAB_CLIENT_SECRET": "gitlab_secret", "OAUTH_GITLAB_DOMAIN": "gitlab.example.com", }, ): provider = GitlabOAuthProvider() mock_response = Mock() mock_response.json.return_value = { "access_token": "gitlab_access_token", "token_type": "Bearer", } mock_response.raise_for_status = Mock() with patch("httpx.AsyncClient") as mock_client: mock_client.return_value.__aenter__.return_value.post = AsyncMock( return_value=mock_response ) token = await provider.get_token( "auth_code", "http://localhost/callback" ) assert token == "gitlab_access_token" @pytest.mark.asyncio async def test_gitlab_get_user_info_success(self): """Test Gitlab get_user_info with successful response.""" with patch.dict( os.environ, { "OAUTH_GITLAB_CLIENT_ID": "gitlab_id", "OAUTH_GITLAB_CLIENT_SECRET": "gitlab_secret", "OAUTH_GITLAB_DOMAIN": "gitlab.example.com", }, ): provider = GitlabOAuthProvider() mock_response = Mock() mock_response.json.return_value = { "email": "user@gitlab.com", "picture": "https://gitlab.com/avatar.png", } mock_response.raise_for_status = Mock() with patch("httpx.AsyncClient") as mock_client: mock_client.return_value.__aenter__.return_value.get = AsyncMock( return_value=mock_response ) gitlab_user, user = await provider.get_user_info("test_token") assert gitlab_user["email"] == "user@gitlab.com" assert isinstance(user, User) assert user.identifier == "user@gitlab.com" assert user.metadata["provider"] == "gitlab" assert user.metadata["image"] == "https://gitlab.com/avatar.png" class TestKeycloakOAuthProvider: """Test suite for KeycloakOAuthProvider.""" def test_keycloak_provider_initialization(self): """Test KeycloakOAuthProvider initialization.""" with patch.dict( os.environ, { "OAUTH_KEYCLOAK_CLIENT_ID": "keycloak_client_id", "OAUTH_KEYCLOAK_CLIENT_SECRET": "keycloak_secret", "OAUTH_KEYCLOAK_REALM": "my-realm", "OAUTH_KEYCLOAK_BASE_URL": "https://keycloak.example.com", }, ): provider = KeycloakOAuthProvider() assert provider.client_id == "keycloak_client_id" assert provider.client_secret == "keycloak_secret" assert provider.realm == "my-realm" assert provider.base_url == "https://keycloak.example.com" assert "profile email openid" in provider.authorize_params["scope"] def test_keycloak_provider_custom_name(self): """Test KeycloakOAuthProvider with custom name.""" with patch.dict( os.environ, { "OAUTH_KEYCLOAK_NAME": "my-keycloak", "OAUTH_KEYCLOAK_CLIENT_ID": "keycloak_id", "OAUTH_KEYCLOAK_CLIENT_SECRET": "keycloak_secret", "OAUTH_KEYCLOAK_REALM": "my-realm", "OAUTH_KEYCLOAK_BASE_URL": "https://keycloak.example.com", }, clear=False, ): from importlib import reload import chainlit.oauth_providers as oauth_module reload(oauth_module) provider = oauth_module.KeycloakOAuthProvider() assert provider.id == "my-keycloak" @pytest.mark.asyncio async def test_keycloak_get_token_success(self): """Test Keycloak get_token with successful response.""" with patch.dict( os.environ, { "OAUTH_KEYCLOAK_CLIENT_ID": "keycloak_id", "OAUTH_KEYCLOAK_CLIENT_SECRET": "keycloak_secret", "OAUTH_KEYCLOAK_REALM": "my-realm", "OAUTH_KEYCLOAK_BASE_URL": "https://keycloak.example.com", }, ): provider = KeycloakOAuthProvider() mock_response = Mock() mock_response.json.return_value = { "access_token": "keycloak_access_token", "refresh_token": "keycloak_refresh_token", } mock_response.raise_for_status = Mock() with patch("httpx.AsyncClient") as mock_client: mock_client.return_value.__aenter__.return_value.post = AsyncMock( return_value=mock_response ) token = await provider.get_token( "auth_code", "http://localhost/callback" ) assert token == "keycloak_access_token" assert provider.refresh_token == "keycloak_refresh_token" @pytest.mark.asyncio async def test_keycloak_get_user_info_success(self): """Test Keycloak get_user_info with successful response.""" with patch.dict( os.environ, { "OAUTH_KEYCLOAK_CLIENT_ID": "keycloak_id", "OAUTH_KEYCLOAK_CLIENT_SECRET": "keycloak_secret", "OAUTH_KEYCLOAK_REALM": "my-realm", "OAUTH_KEYCLOAK_BASE_URL": "https://keycloak.example.com", }, ): provider = KeycloakOAuthProvider() mock_response = Mock() mock_response.json.return_value = { "email": "user@keycloak.com", "name": "Keycloak User", } mock_response.raise_for_status = Mock() with patch("httpx.AsyncClient") as mock_client: mock_client.return_value.__aenter__.return_value.get = AsyncMock( return_value=mock_response ) keycloak_user, user = await provider.get_user_info("test_token") assert keycloak_user["email"] == "user@keycloak.com" assert isinstance(user, User) assert user.identifier == "user@keycloak.com" assert user.metadata["provider"] == "keycloak" class TestHelperFunctions: """Test suite for helper functions.""" def test_get_oauth_provider_returns_correct_provider(self): """Test get_oauth_provider returns the correct provider.""" provider = get_oauth_provider("github") assert provider is not None assert provider.id == "github" def test_get_oauth_provider_returns_none_for_unknown(self): """Test get_oauth_provider returns None for unknown provider.""" provider = get_oauth_provider("unknown_provider") assert provider is None def test_get_configured_oauth_providers_empty_when_none_configured(self): """Test get_configured_oauth_providers returns empty list when none configured.""" # Clear all OAuth environment variables with patch.dict(os.environ, {}, clear=True): configured = get_configured_oauth_providers() assert configured == [] def test_get_configured_oauth_providers_returns_configured(self): """Test get_configured_oauth_providers returns configured providers.""" with patch.dict( os.environ, { "OAUTH_GITHUB_CLIENT_ID": "github_id", "OAUTH_GITHUB_CLIENT_SECRET": "github_secret", "OAUTH_GOOGLE_CLIENT_ID": "google_id", "OAUTH_GOOGLE_CLIENT_SECRET": "google_secret", }, ): configured = get_configured_oauth_providers() assert "github" in configured assert "google" in configured class TestOAuthProviderEdgeCases: """Test suite for OAuth provider edge cases.""" @pytest.mark.asyncio async def test_provider_handles_http_error(self): """Test provider handles HTTP errors gracefully.""" with patch.dict( os.environ, { "OAUTH_GITHUB_CLIENT_ID": "test_id", "OAUTH_GITHUB_CLIENT_SECRET": "test_secret", }, ): provider = GithubOAuthProvider() mock_response = Mock() mock_response.raise_for_status.side_effect = httpx.HTTPStatusError( "Error", request=Mock(), response=Mock() ) with patch("httpx.AsyncClient") as mock_client: mock_client.return_value.__aenter__.return_value.post = AsyncMock( return_value=mock_response ) with pytest.raises(httpx.HTTPStatusError): await provider.get_raw_token_response("code", "url") def test_provider_strips_trailing_slash_from_domain(self): """Test providers strip trailing slashes from domains.""" with patch.dict( os.environ, { "OAUTH_AUTH0_CLIENT_ID": "auth0_id", "OAUTH_AUTH0_CLIENT_SECRET": "auth0_secret", "OAUTH_AUTH0_DOMAIN": "dev-12345.auth0.com/", }, ): provider = Auth0OAuthProvider() assert not provider.domain.endswith("/") def test_provider_with_prompt_parameter(self): """Test provider includes prompt parameter when configured.""" with patch.dict( os.environ, { "OAUTH_GOOGLE_CLIENT_ID": "google_id", "OAUTH_GOOGLE_CLIENT_SECRET": "google_secret", "OAUTH_GOOGLE_PROMPT": "consent", }, ): provider = GoogleOAuthProvider() assert "prompt" in provider.authorize_params assert provider.authorize_params["prompt"] == "consent" ================================================ FILE: backend/tests/test_server.py ================================================ import datetime import os import pathlib from pathlib import Path from typing import Callable from unittest.mock import AsyncMock, Mock, create_autospec, mock_open import pytest from fastapi.testclient import TestClient from chainlit.auth import get_current_user from chainlit.config import ( APP_ROOT, ChainlitConfig, SpontaneousFileUploadFeature, ) from chainlit.server import app from chainlit.types import AskFileSpec from chainlit.user import PersistedUser @pytest.fixture def test_client(): return TestClient(app) @pytest.fixture def mock_load_translation(test_config: ChainlitConfig, monkeypatch: pytest.MonkeyPatch): mock_method = Mock(return_value={"key": "value"}) monkeypatch.setattr("chainlit.config.ChainlitConfig.load_translation", mock_method) return mock_method def test_project_translations_default_language( test_client: TestClient, mock_load_translation: Mock ): """Test with default language.""" response = test_client.get("/project/translations") assert response.status_code == 200 assert "translation" in response.json() mock_load_translation.assert_called_once_with("en-US") mock_load_translation.reset_mock() def test_project_translations_specific_language( test_client: TestClient, mock_load_translation: Mock ): """Test with a specific language.""" response = test_client.get("/project/translations?language=fr-FR") assert response.status_code == 200 assert "translation" in response.json() mock_load_translation.assert_called_once_with("fr-FR") mock_load_translation.reset_mock() def test_project_translations_invalid_language( test_client: TestClient, mock_load_translation: Mock ): """Test with an invalid language.""" response = test_client.get("/project/translations?language=invalid") assert response.status_code == 422 assert ( "translation" not in response.json() ) # It should fall back to default translation assert not mock_load_translation.called def test_project_translations_bcp47_language( test_client: TestClient, mock_load_translation: Mock ): """Regression test for https://github.com/Chainlit/chainlit/issues/1352.""" response = test_client.get("/project/translations?language=es-419") assert response.status_code == 200 assert "translation" in response.json() mock_load_translation.assert_called_once_with("es-419") mock_load_translation.reset_mock() @pytest.fixture def mock_get_current_user(): """Override get_current_user() dependency.""" # Programming sucks! # Ref: https://github.com/fastapi/fastapi/issues/3331#issuecomment-1182452859 app.dependency_overrides[get_current_user] = create_autospec(lambda: None) yield app.dependency_overrides[get_current_user] del app.dependency_overrides[get_current_user] async def test_project_settings(test_client: TestClient, mock_get_current_user: Mock): """Burn test for project settings.""" response = test_client.get( "/project/settings", ) mock_get_current_user.assert_called_once() assert response.status_code == 200, response.json() data = response.json() assert "ui" in data assert "features" in data assert "userEnv" in data assert "dataPersistence" in data assert "threadResumable" in data assert "markdown" in data assert "debugUrl" in data assert data["chatProfiles"] == [] assert data["starters"] == [] def test_project_settings_path_traversal( test_client: TestClient, mock_get_current_user: Mock, tmp_path: Path, test_config: ChainlitConfig, ): """Test to prevent path traversal in project settings.""" # Create a mock chainlit directory structure app_dir = tmp_path / "app" app_dir.mkdir() (tmp_path / "README.md").write_text("This is a secret README") # This is required for the exploit to occur. chainlit_dir = app_dir / "chainlit_stuff" chainlit_dir.mkdir() # Mock the config root test_config.root = str(app_dir) # Attempt to access the file using path traversal response = test_client.get( "/project/settings", params={"language": "stuff/../../README"} ) # Should not be able to read the file assert "This is a secret README" not in response.text assert response.status_code == 422 # The response should not contain the normally expected keys data = response.json() assert "ui" not in data assert "features" not in data assert "userEnv" not in data assert "dataPersistence" not in data assert "threadResumable" not in data assert "markdown" not in data assert "chatProfiles" not in data assert "starters" not in data assert "debugUrl" not in data def test_get_avatar_default(test_client: TestClient, monkeypatch: pytest.MonkeyPatch): """Test with default avatar.""" response = test_client.get("/avatars/default") assert response.status_code == 200 assert response.headers["content-type"].startswith("image/") def test_get_avatar_custom(test_client: TestClient, monkeypatch: pytest.MonkeyPatch): """Test with custom avatar.""" custom_avatar_path = os.path.join( APP_ROOT, "public", "avatars", "custom_avatar.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/custom_avatar") 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_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 ): """Test with non-existent avatar (should return favicon).""" favicon_response = test_client.get("/favicon") assert favicon_response.status_code == 200 response = test_client.get("/avatars/non_existent") assert response.status_code == 200 assert response.headers["content-type"].startswith("image/") assert response.content == favicon_response.content def test_avatar_path_traversal( test_client: TestClient, monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path ): """Test to prevent potential path traversal in avatar route on Windows.""" # Create a Mock object for the glob function mock_glob = Mock(return_value=[]) monkeypatch.setattr("chainlit.server.glob.glob", mock_glob) mock_open_inst = mock_open(read_data=b'{"should_not": "Be readable."}') monkeypatch.setattr("builtins.open", mock_open_inst) # Attempt to access a file using path traversal response = test_client.get("/avatars/..%5C..%5Capp") # No glob should ever be called assert not mock_glob.called # Should return an error status assert response.status_code == 400 @pytest.fixture def mock_session_get_by_id_patched(mock_session: Mock, monkeypatch: pytest.MonkeyPatch): test_session_id = "test_session_id" # Mock the WebsocketSession.get_by_id method to return the mock session monkeypatch.setattr( "chainlit.session.WebsocketSession.get_by_id", lambda session_id: mock_session if session_id == test_session_id else None, ) return mock_session def test_get_file_success( test_client: TestClient, mock_session_get_by_id_patched: Mock, tmp_path: pathlib.Path, mock_get_current_user: Mock, ): """ Test successful retrieval of a file from a session. """ # Set current_user to match session.user mock_get_current_user.return_value = mock_session_get_by_id_patched.user # Create test data test_content = b"Test file content" test_file_id = "test_file_id" # Create a temporary file with the test content test_file = tmp_path / "test_file" test_file.write_bytes(test_content) mock_session_get_by_id_patched.files = { test_file_id: { "id": test_file_id, "path": test_file, "name": "test.txt", "type": "text/plain", "size": len(test_content), } } # Make the GET request to retrieve the file response = test_client.get( f"/project/file/{test_file_id}?session_id={mock_session_get_by_id_patched.id}" ) # Verify the response assert response.status_code == 200 assert response.content == test_content assert response.headers["content-type"].startswith("text/plain") def test_get_file_not_existent_file( test_client: TestClient, mock_session_get_by_id_patched: Mock, mock_get_current_user: Mock, ): """ Test retrieval of a non-existing file from a session. """ # Set current_user to match session.user mock_get_current_user.return_value = mock_session_get_by_id_patched.user # Make the GET request to retrieve the file response = test_client.get("/project/file/test_file_id?session_id=test_session_id") # Verify the response assert response.status_code == 404 def test_get_file_non_existing_session( test_client: TestClient, tmp_path: pathlib.Path, mock_session_get_by_id_patched: Mock, mock_session: Mock, monkeypatch: pytest.MonkeyPatch, ): """ Test that an unauthenticated user cannot retrieve a file uploaded by an authenticated user. """ # Attempt to access the file without authentication by providing an invalid session_id response = test_client.get( "/project/file/nonexistent?session_id=unauthenticated_session_id" ) # Verify the response assert response.status_code == 401 # Unauthorized def test_upload_file_success( test_client: TestClient, test_config: ChainlitConfig, mock_session_get_by_id_patched: Mock, ): """Test successful file upload.""" # Prepare the files to upload file_content = b"Sample file content" files = { "file": ("test_upload.txt", file_content, "text/plain"), } # Mock the persist_file method to return a known value expected_file_id = "mocked_file_id" mock_session_get_by_id_patched.persist_file = AsyncMock( return_value={ "id": expected_file_id, "name": "test_upload.txt", "type": "text/plain", "size": len(file_content), } ) # Make the POST request to upload the file response = test_client.post( "/project/file", files=files, params={"session_id": mock_session_get_by_id_patched.id}, ) # Verify the response assert response.status_code == 200 response_data = response.json() assert "id" in response_data assert response_data["id"] == expected_file_id assert response_data["name"] == "test_upload.txt" assert response_data["type"] == "text/plain" assert response_data["size"] == len(file_content) # Verify that persist_file was called with the correct arguments mock_session_get_by_id_patched.persist_file.assert_called_once_with( name="test_upload.txt", content=file_content, mime="text/plain" ) def test_file_access_by_different_user( test_client: TestClient, mock_session_get_by_id_patched: Mock, persisted_test_user: PersistedUser, tmp_path: pathlib.Path, mock_session_factory: Callable[..., Mock], ): """Test that a file uploaded by one user cannot be accessed by another user.""" # Prepare the files to upload file_content = b"Sample file content" files = { "file": ("test_upload.txt", file_content, "text/plain"), } # Mock the persist_file method to return a known value expected_file_id = "mocked_file_id" mock_session_get_by_id_patched.persist_file = AsyncMock( return_value={ "id": expected_file_id, "name": "test_upload.txt", "type": "text/plain", "size": len(file_content), } ) # Make the POST request to upload the file response = test_client.post( "/project/file", files=files, params={"session_id": mock_session_get_by_id_patched.id}, ) # Verify the response assert response.status_code == 200 response_data = response.json() assert "id" in response_data file_id = response_data["id"] # Create a second session with a different user second_session = mock_session_factory( id="another_session_id", user=PersistedUser( id="another_user_id", createdAt=datetime.datetime.now().isoformat(), identifier="another_user_identifier", ), ) # Attempt to access the uploaded file using the second user's session response = test_client.get( f"/project/file/{file_id}?session_id={second_session.id}" ) # Verify that the access attempt fails assert response.status_code == 401 # Unauthorized def test_upload_file_missing_file( test_client: TestClient, mock_session: Mock, ): """Test file upload with missing file in the request.""" # Make the POST request without a file response = test_client.post( "/project/file", data={"session_id": mock_session.id}, ) # Verify the response assert response.status_code == 422 # Unprocessable Entity assert "detail" in response.json() def test_upload_file_invalid_session( test_client: TestClient, ): """Test file upload with an invalid session.""" # Prepare the files to upload file_content = b"Sample file content" files = { "file": ("test_upload.txt", file_content, "text/plain"), } # Make the POST request with an invalid session_id response = test_client.post( "/project/file", files=files, data={"session_id": "invalid_session_id"}, ) # Verify the response assert response.status_code == 422 def test_upload_file_unauthorized( test_client: TestClient, test_config: ChainlitConfig, mock_session_get_by_id_patched: Mock, ): """Test file upload without proper authorization.""" # Mock the upload_file_session to have no user mock_session_get_by_id_patched.user = None # Prepare the files to upload file_content = b"Sample file content" files = { "file": ("test_upload.txt", file_content, "text/plain"), } # Make the POST request to upload the file response = test_client.post( "/project/file", files=files, data={"session_id": mock_session_get_by_id_patched.id}, ) assert response.status_code == 422 def test_upload_file_disabled( test_client: TestClient, test_config: ChainlitConfig, mock_session_get_by_id_patched: Mock, monkeypatch: pytest.MonkeyPatch, ): """Test file upload being disabled by config.""" # Set accept in config monkeypatch.setattr( test_config.features, "spontaneous_file_upload", SpontaneousFileUploadFeature(enabled=False), ) # Prepare the files to upload file_content = b"Sample file content" files = { "file": ("test_upload.txt", file_content, "text/plain"), } # Make the POST request to upload the file response = test_client.post( "/project/file", files=files, params={"session_id": mock_session_get_by_id_patched.id}, ) # Verify the response assert response.status_code == 400 @pytest.mark.parametrize( ("accept_pattern", "mime_type", "expected_status"), [ ({"image/*": [".png", ".gif", ".jpeg", ".jpg"]}, "image/jpeg", 400), (["image/*"], "text/plain", 400), (["image/*", "application/*"], "text/plain", 400), (["image/png", "application/pdf"], "image/jpeg", 400), (["text/*"], "text/plain", 200), (["application/*"], "application/pdf", 200), (["image/*"], "image/jpeg", 200), (["image/*", "text/*"], "text/plain", 200), (["*/*"], "text/plain", 200), (["*/*"], "image/jpeg", 200), (["*/*"], "application/pdf", 200), (["image/*", "application/*"], "application/pdf", 200), (["image/*", "application/*"], "image/jpeg", 200), (["image/png", "application/pdf"], "image/png", 200), (["image/png", "application/pdf"], "application/pdf", 200), ({"image/*": []}, "image/jpeg", 200), ( {"image/*": [".png", ".gif", ".jpeg", ".jpg"]}, "text/plain", 400, ), # mime type not allowed ( {"*/*": [".txt", ".gif", ".jpeg", ".jpg"]}, "text/plain", 200, ), # extension allowed ( {"*/*": [".gif", ".jpeg", ".jpg"]}, "text/plain", 400, ), # extension not allowed ], ) def test_upload_file_mime_type_check( test_client: TestClient, test_config: ChainlitConfig, mock_session_get_by_id_patched: Mock, monkeypatch: pytest.MonkeyPatch, accept_pattern: list[str], mime_type: str, expected_status: int, ): """Test check of mime_type.""" # Set accept in config monkeypatch.setattr( test_config.features, "spontaneous_file_upload", SpontaneousFileUploadFeature(enabled=True, accept=accept_pattern), ) # Prepare the files to upload file_content = b"Sample file content" files = { "file": ("test_upload.txt", file_content, mime_type), } # Mock the persist_file method to return a known value expected_file_id = "mocked_file_id" mock_session_get_by_id_patched.persist_file = AsyncMock( return_value={ "id": expected_file_id, "name": "test_upload.txt", "type": "text/plain", "size": len(file_content), } ) # Make the POST request to upload the file response = test_client.post( "/project/file", files=files, params={"session_id": mock_session_get_by_id_patched.id}, ) # Verify the response assert response.status_code == expected_status @pytest.mark.parametrize( ("file_content", "content_multiplier", "max_size_mb", "expected_status"), [ (b"1", 1, 1, 200), (b"11", 1024 * 1024, 1, 400), ], ) def test_upload_file_size_check( test_client: TestClient, test_config: ChainlitConfig, mock_session_get_by_id_patched: Mock, monkeypatch: pytest.MonkeyPatch, file_content: bytes, content_multiplier: int, max_size_mb: int, expected_status: int, ): """Test check of max_size_mb.""" file_content = file_content * content_multiplier # Set accept in config monkeypatch.setattr( test_config.features, "spontaneous_file_upload", SpontaneousFileUploadFeature(max_size_mb=max_size_mb, enabled=True), ) # Prepare the files to upload files = { "file": ("test_upload.txt", file_content, "text/plain"), } # Mock the persist_file method to return a known value expected_file_id = "mocked_file_id" mock_session_get_by_id_patched.persist_file = AsyncMock( return_value={ "id": expected_file_id, "name": "test_upload.txt", "type": "text/plain", "size": len(file_content), } ) # Make the POST request to upload the file response = test_client.post( "/project/file", files=files, params={"session_id": mock_session_get_by_id_patched.id}, ) # Verify the response assert response.status_code == expected_status @pytest.mark.parametrize( ( "file_content", "content_multiplier", "max_size_mb", "parent_id", "expected_status", "accept", ), [ (b"1", 1, 1, "mocked_parent_id", 200, ["text/plain"]), (b"11", 1024 * 1024, 1, "mocked_parent_id", 400, ["text/plain"]), (b"11", 1, 1, "invalid_parent_id", 404, ["text/plain"]), (b"11", 1, 1, "mocked_parent_id", 400, ["image/gif"]), ], ) def test_ask_file_with_spontaneous_upload_disabled( test_client: TestClient, test_config: ChainlitConfig, mock_session_get_by_id_patched: Mock, monkeypatch: pytest.MonkeyPatch, file_content: bytes, content_multiplier: int, max_size_mb: int, parent_id: str, expected_status: int, accept: list[str], ): """Test file upload being disabled by config.""" # Set accept in config monkeypatch.setattr( test_config.features, "spontaneous_file_upload", SpontaneousFileUploadFeature(enabled=False), ) # Prepare the files to upload file_content = file_content * content_multiplier files = { "file": ("test_upload.txt", file_content, "text/plain"), } expected_file_id = "mocked_file_id" mock_session_get_by_id_patched.persist_file = AsyncMock( return_value={ "id": expected_file_id, "name": "test_upload.txt", "type": "text/plain", "size": len(file_content), } ) mock_session_get_by_id_patched.files_spec = { "mocked_parent_id": AskFileSpec( step_id="mocked_file_spec", timeout=1, type="file", accept=accept, max_files=1, max_size_mb=max_size_mb, ) } # Make the POST request to upload the file response = test_client.post( "/project/file", files=files, params={ "session_id": mock_session_get_by_id_patched.id, "ask_parent_id": parent_id, }, ) # Verify the response assert response.status_code == expected_status def test_project_translations_file_path_traversal( test_client: TestClient, monkeypatch: pytest.MonkeyPatch ): """Test to prevent file path traversal in project translations.""" mock_open_inst = mock_open(read_data='{"should_not": "Be readable."}') monkeypatch.setattr("builtins.open", mock_open_inst) # Attempt to access the file using path traversal response = test_client.get( "/project/translations", params={"language": "/app/unreadable"} ) # File should never be opened assert not mock_open_inst.called # Should give error status assert response.status_code == 422 def test_project_settings_with_chat_profile_config_overrides( test_client: TestClient, test_config: ChainlitConfig, monkeypatch: pytest.MonkeyPatch, ): """Test that /project/settings endpoint returns merged configuration when chat_profile is specified.""" from chainlit.config import ( ChainlitConfigOverrides, FeaturesSettings, McpFeature, UISettings, ) from chainlit.types import ChatProfile # Mock chat profiles with different config overrides mock_profiles = [ ChatProfile( name="basic", markdown_description="Basic profile without overrides", default=True, ), ChatProfile( name="mcp-enabled", markdown_description="Profile with MCP enabled", config_overrides=ChainlitConfigOverrides( features=FeaturesSettings(mcp=McpFeature(enabled=True)), ui=UISettings(name="MCP Assistant", default_theme="dark"), ), ), ChatProfile( name="light-theme", markdown_description="Profile with light theme", config_overrides=ChainlitConfigOverrides( ui=UISettings( name="Light Theme App", default_theme="light", description="Light theme app", ) ), ), ] # Mock the chat profiles callback async def mock_get_chat_profiles(user, language): # Use asyncio.sleep to make this truly async import asyncio await asyncio.sleep(0) return mock_profiles test_config.code.set_chat_profiles = mock_get_chat_profiles # Test 1: Default profile (no overrides) response = test_client.get("/project/settings", params={"chat_profile": "basic"}) assert response.status_code == 200 config_data = response.json() # Should return base configuration without overrides assert config_data["ui"]["name"] == test_config.ui.name # Original name assert ( config_data["features"]["mcp"]["enabled"] == test_config.features.mcp.enabled ) # Original MCP setting # Test 2: MCP-enabled profile response = test_client.get( "/project/settings", params={"chat_profile": "mcp-enabled"} ) assert response.status_code == 200 config_data = response.json() # Should return merged configuration with MCP enabled and custom UI assert config_data["features"]["mcp"]["enabled"] is True # Overridden assert config_data["ui"]["name"] == "MCP Assistant" # Overridden assert config_data["ui"]["default_theme"] == "dark" # Overridden # Test 3: Light theme profile response = test_client.get( "/project/settings", params={"chat_profile": "light-theme"} ) assert response.status_code == 200 config_data = response.json() # Should return merged configuration with light theme assert config_data["ui"]["default_theme"] == "light" # Overridden assert config_data["ui"]["description"] == "Light theme app" # Overridden assert ( config_data["features"]["mcp"]["enabled"] == test_config.features.mcp.enabled ) # Not overridden # Test 4: Non-existent profile (should return base config) response = test_client.get( "/project/settings", params={"chat_profile": "non-existent"} ) assert response.status_code == 200 config_data = response.json() # Should return base configuration assert config_data["ui"]["name"] == test_config.ui.name assert config_data["features"]["mcp"]["enabled"] == test_config.features.mcp.enabled # Test 5: No profile specified (should return base config) response = test_client.get("/project/settings") assert response.status_code == 200 config_data = response.json() # Should return base configuration assert config_data["ui"]["name"] == test_config.ui.name assert config_data["features"]["mcp"]["enabled"] == test_config.features.mcp.enabled def test_project_settings_config_overrides_serialization( test_client: TestClient, test_config: ChainlitConfig, monkeypatch: pytest.MonkeyPatch, ): """Test that config_overrides field is not included in serialized chat profiles.""" from chainlit.config import ChainlitConfigOverrides, FeaturesSettings, McpFeature from chainlit.types import ChatProfile # Mock chat profile with config overrides mock_profile = ChatProfile( name="test-profile", markdown_description="Test profile", config_overrides=ChainlitConfigOverrides( features=FeaturesSettings(mcp=McpFeature(enabled=True)) ), ) async def mock_get_chat_profiles(user, language): # Use asyncio.sleep to make this truly async import asyncio await asyncio.sleep(0) return [mock_profile] test_config.code.set_chat_profiles = mock_get_chat_profiles # Get the project settings response = test_client.get( "/project/settings", params={"chat_profile": "test-profile"} ) assert response.status_code == 200 config_data = response.json() # Check that chatProfiles are included in the response assert "chatProfiles" in config_data assert len(config_data["chatProfiles"]) == 1 # Check that config_overrides is NOT included in the serialized profile profile_data = config_data["chatProfiles"][0] assert "config_overrides" not in profile_data assert profile_data["name"] == "test-profile" assert profile_data["markdown_description"] == "Test profile" def test_project_settings_config_overrides_language( test_client: TestClient, test_config: ChainlitConfig, monkeypatch: pytest.MonkeyPatch, ): """Test that localized chat profiles use the right config overrides.""" from chainlit.config import ChainlitConfigOverrides, FeaturesSettings, McpFeature from chainlit.types import ChatProfile # Mock chat profile with config overrides mock_profile_fr = ChatProfile( name="test-profile-fr", markdown_description="Test profil", config_overrides=ChainlitConfigOverrides( features=FeaturesSettings(mcp=McpFeature(enabled=True)) ), ) mock_profile_en = ChatProfile( name="test-profile", markdown_description="Test profile", config_overrides=ChainlitConfigOverrides( features=FeaturesSettings(mcp=McpFeature(enabled=False)) ), ) async def mock_get_chat_profiles(user, language): # Use asyncio.sleep to make this truly async import asyncio await asyncio.sleep(0) if language == "fr-CA": return [mock_profile_fr] return [mock_profile_en] test_config.code.set_chat_profiles = mock_get_chat_profiles # Get the project settings in French response = test_client.get( "/project/settings", params={"language": "fr-CA", "chat_profile": "test-profile-fr"}, ) assert response.status_code == 200 config_data = response.json() # Check that chatProfiles are included in the response assert "chatProfiles" in config_data assert len(config_data["chatProfiles"]) == 1 # Check that config_overrides is NOT included in the serialized profile assert config_data["features"]["mcp"]["enabled"] is True # Overridden # Check that the profile_data matches the selected profile. profile_data = config_data["chatProfiles"][0] assert "config_overrides" not in profile_data assert profile_data["name"] == "test-profile-fr" assert profile_data["markdown_description"] == "Test profil" # Get the project settings in English response = test_client.get( "/project/settings", params={"language": "en-US", "chat_profile": "test-profile"}, ) assert response.status_code == 200 config_data = response.json() # Check that chatProfiles are included in the response assert "chatProfiles" in config_data assert len(config_data["chatProfiles"]) == 1 # Check that config_overrides is NOT included in the serialized profile assert config_data["features"]["mcp"]["enabled"] is False # Overridden # Check that the profile_data matches the selected profile. profile_data = config_data["chatProfiles"][0] assert "config_overrides" not in profile_data assert profile_data["name"] == "test-profile" assert profile_data["markdown_description"] == "Test profile" def test_project_settings_thread_sharing_flag( test_client: TestClient, test_config: ChainlitConfig, monkeypatch: pytest.MonkeyPatch, ): # Start with both disabled test_config.features.allow_thread_sharing = False test_config.code.on_shared_thread_view = None resp = test_client.get("/project/settings") assert resp.status_code == 200 assert resp.json().get("threadSharing") is False # Enable flag only -> still False (callback missing) test_config.features.allow_thread_sharing = True test_config.code.on_shared_thread_view = None resp = test_client.get("/project/settings") assert resp.status_code == 200 assert resp.json().get("threadSharing") is False # Enable callback only -> still False (flag disabled) test_config.features.allow_thread_sharing = False def dummy_cb(*args, **kwargs): return True test_config.code.on_shared_thread_view = dummy_cb resp = test_client.get("/project/settings") assert resp.status_code == 200 assert resp.json().get("threadSharing") is False # Enable both -> True test_config.features.allow_thread_sharing = True test_config.code.on_shared_thread_view = dummy_cb resp = test_client.get("/project/settings") assert resp.status_code == 200 assert resp.json().get("threadSharing") is True def test_share_thread_endpoint_sets_flags( test_client: TestClient, monkeypatch: pytest.MonkeyPatch, ): # Override current user to match thread author from chainlit.server import app as _app, get_current_user as _get_current_user author = PersistedUser( id="u1", createdAt=datetime.datetime.now().isoformat(), identifier="author", ) _app.dependency_overrides[_get_current_user] = lambda: author # Mock data layer from unittest.mock import AsyncMock dl = AsyncMock() dl.get_thread.return_value = { "id": "t1", "name": "Thread 1", "userIdentifier": "author", "metadata": {"other": True}, } dl.get_thread_author.return_value = "author" dl.build_debug_url.return_value = "" # Ensure data layer is initialized for both server routes and ACL checks import chainlit.data as data_mod data_mod._data_layer = dl data_mod._data_layer_initialized = True # Share r = test_client.put( "/project/thread/share", json={"threadId": "t1", "isShared": True} ) assert r.status_code == 200 # Validate metadata passed to update_thread includes is_shared and shared_at assert dl.update_thread.await_count >= 1 _, kwargs = dl.update_thread.await_args assert kwargs.get("thread_id") == "t1" meta = kwargs.get("metadata") or {} assert meta.get("is_shared") is True assert isinstance(meta.get("shared_at"), str) # Unshare r = test_client.put( "/project/thread/share", json={"threadId": "t1", "isShared": False} ) assert r.status_code == 200 _, kwargs = dl.update_thread.await_args meta = kwargs.get("metadata") or {} assert meta.get("is_shared") is False assert "shared_at" not in meta # Cleanup override and data layer del _app.dependency_overrides[_get_current_user] data_mod._data_layer = None data_mod._data_layer_initialized = False def test_health_check(test_client: TestClient): response = test_client.get("/health") assert response.status_code == 200 assert response.json() == {"status": "ok"} ================================================ FILE: backend/tests/test_session.py ================================================ import json import tempfile import uuid from pathlib import Path from unittest.mock import AsyncMock, Mock, patch import pytest from chainlit.session import ( BaseSession, HTTPSession, JSONEncoderIgnoreNonSerializable, WebsocketSession, clean_metadata, ) class TestJSONEncoderIgnoreNonSerializable: """Test suite for JSONEncoderIgnoreNonSerializable.""" def test_encoder_handles_serializable_objects(self): """Test that encoder handles normal serializable objects.""" data = { "string": "value", "number": 42, "list": [1, 2, 3], "dict": {"key": "value"}, } result = json.dumps(data, cls=JSONEncoderIgnoreNonSerializable) assert json.loads(result) == data def test_encoder_ignores_non_serializable_objects(self): """Test that encoder returns None for non-serializable objects.""" class NonSerializable: pass data = {"normal": "value", "non_serializable": NonSerializable()} result = json.dumps(data, cls=JSONEncoderIgnoreNonSerializable) parsed = json.loads(result) assert parsed["normal"] == "value" assert parsed["non_serializable"] is None def test_encoder_with_nested_non_serializable(self): """Test encoder with nested non-serializable objects.""" class NonSerializable: pass data = { "level1": { "level2": { "serializable": "value", "non_serializable": NonSerializable(), } } } result = json.dumps(data, cls=JSONEncoderIgnoreNonSerializable) parsed = json.loads(result) assert parsed["level1"]["level2"]["serializable"] == "value" assert parsed["level1"]["level2"]["non_serializable"] is None class TestCleanMetadata: """Test suite for clean_metadata function.""" def test_clean_metadata_with_normal_data(self): """Test clean_metadata with normal serializable data.""" metadata = {"key": "value", "number": 42, "list": [1, 2, 3]} result = clean_metadata(metadata) assert result == metadata def test_clean_metadata_removes_non_serializable(self): """Test that clean_metadata removes non-serializable objects.""" class NonSerializable: pass metadata = {"normal": "value", "non_serializable": NonSerializable()} result = clean_metadata(metadata) assert result["normal"] == "value" assert result["non_serializable"] is None def test_clean_metadata_redacts_large_data(self): """Test that clean_metadata redacts data exceeding max size.""" # Create large metadata large_data = {"data": "x" * 2000000} # > 1MB result = clean_metadata(large_data, max_size=1048576) assert "message" in result assert "exceeds the limit" in result["message"] def test_clean_metadata_with_custom_max_size(self): """Test clean_metadata with custom max size.""" small_data = {"data": "x" * 100} result = clean_metadata(small_data, max_size=50) # Should be redacted because it exceeds 50 bytes assert "message" in result assert "exceeds the limit" in result["message"] def test_clean_metadata_preserves_unicode(self): """Test that clean_metadata preserves Unicode characters.""" metadata = {"chinese": "你好", "emoji": "🎉", "japanese": "こんにちは"} result = clean_metadata(metadata) assert result["chinese"] == "你好" assert result["emoji"] == "🎉" assert result["japanese"] == "こんにちは" class TestBaseSession: """Test suite for BaseSession class.""" def test_base_session_initialization(self): """Test BaseSession initialization with required parameters.""" session = BaseSession( id="test_id", client_type="webapp", thread_id=None, user=None, token=None, user_env=None, ) assert session.id == "test_id" assert session.client_type == "webapp" assert session.thread_id is not None # Auto-generated UUID assert session.user is None assert session.token is None assert session.user_env == {} assert session.chat_settings == {} def test_base_session_with_thread_id(self): """Test BaseSession with provided thread_id.""" thread_id = str(uuid.uuid4()) session = BaseSession( id="test_id", client_type="webapp", thread_id=thread_id, user=None, token=None, user_env=None, ) assert session.thread_id == thread_id assert session.thread_id_to_resume == thread_id def test_base_session_with_user_env(self): """Test BaseSession with user environment variables.""" user_env = {"API_KEY": "secret", "ENV_VAR": "value"} session = BaseSession( id="test_id", client_type="webapp", thread_id=None, user=None, token=None, user_env=user_env, ) assert session.user_env == user_env def test_base_session_with_chat_profile(self): """Test BaseSession with chat profile.""" session = BaseSession( id="test_id", client_type="webapp", thread_id=None, user=None, token=None, user_env=None, chat_profile="gpt-4", ) assert session.chat_profile == "gpt-4" def test_base_session_files_dir(self): """Test BaseSession files_dir property.""" with patch("chainlit.config.FILES_DIRECTORY", Path("/tmp/files")): session = BaseSession( id="test_id", client_type="webapp", thread_id=None, user=None, token=None, user_env=None, ) assert session.files_dir == Path("/tmp/files/test_id") @pytest.mark.asyncio async def test_base_session_persist_file_with_content(self): """Test persisting a file with content.""" with tempfile.TemporaryDirectory() as tmpdir: with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)): session = BaseSession( id="test_id", client_type="webapp", thread_id=None, user=None, token=None, user_env=None, ) content = b"test file content" result = await session.persist_file( name="test.txt", mime="text/plain", content=content, ) assert "id" in result assert result["id"] in session.files assert session.files[result["id"]]["name"] == "test.txt" assert session.files[result["id"]]["type"] == "text/plain" @pytest.mark.asyncio async def test_base_session_persist_file_with_string_content(self): """Test persisting a file with string content.""" with tempfile.TemporaryDirectory() as tmpdir: with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)): session = BaseSession( id="test_id", client_type="webapp", thread_id=None, user=None, token=None, user_env=None, ) content = "test string content" result = await session.persist_file( name="test.txt", mime="text/plain", content=content, ) assert "id" in result file_id = result["id"] assert session.files[file_id]["size"] > 0 @pytest.mark.asyncio async def test_base_session_persist_file_without_path_or_content(self): """Test that persist_file raises error without path or content.""" session = BaseSession( id="test_id", client_type="webapp", thread_id=None, user=None, token=None, user_env=None, ) with pytest.raises(ValueError, match="Either path or content must be provided"): await session.persist_file(name="test.txt", mime="text/plain") def test_base_session_to_persistable(self): """Test BaseSession to_persistable method.""" from chainlit.user_session import user_sessions original_sessions = user_sessions.copy() user_sessions.update({"test_id": {"key": "value"}}) try: with patch("chainlit.config.config") as mock_config: mock_config.project.persist_user_env = True session = BaseSession( id="test_id", client_type="webapp", thread_id=None, user=None, token=None, user_env={"API_KEY": "secret"}, chat_profile="gpt-4", ) session.chat_settings = {"temperature": 0.7} result = session.to_persistable() assert result["chat_settings"] == {"temperature": 0.7} assert result["chat_profile"] == "gpt-4" assert result["client_type"] == "webapp" finally: user_sessions.clear() user_sessions.update(original_sessions) def test_base_session_to_persistable_without_persist_user_env(self): """Test to_persistable removes user_env when persist_user_env is False.""" from chainlit.user_session import user_sessions original_sessions = user_sessions.copy() user_sessions.update({"test_id": {"env": {"KEY": "value"}}}) try: with patch("chainlit.config.config") as mock_config: mock_config.project.persist_user_env = False session = BaseSession( id="test_id", client_type="webapp", thread_id=None, user=None, token=None, user_env={"API_KEY": "secret"}, ) result = session.to_persistable() assert result["env"] == {} finally: user_sessions.clear() user_sessions.update(original_sessions) class TestHTTPSession: """Test suite for HTTPSession class.""" def test_http_session_initialization(self): """Test HTTPSession initialization.""" session = HTTPSession( id="http_id", client_type="copilot", thread_id=None, user=None, token=None, user_env=None, ) assert session.id == "http_id" assert session.client_type == "copilot" assert isinstance(session, BaseSession) @pytest.mark.asyncio async def test_http_session_delete(self): """Test HTTPSession delete method.""" with tempfile.TemporaryDirectory() as tmpdir: with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)): session = HTTPSession( id="http_id", client_type="copilot", ) # Create files directory session.files_dir.mkdir(exist_ok=True) test_file = session.files_dir / "test.txt" test_file.write_text("test") assert session.files_dir.exists() await session.delete() assert not session.files_dir.exists() class TestWebsocketSession: """Test suite for WebsocketSession class.""" def test_websocket_session_initialization(self): """Test WebsocketSession initialization.""" emit_mock = Mock() emit_call_mock = Mock() session = WebsocketSession( id="ws_id", socket_id="socket_123", emit=emit_mock, emit_call=emit_call_mock, user_env={}, client_type="webapp", ) assert session.id == "ws_id" assert session.socket_id == "socket_123" assert session.emit == emit_mock assert session.emit_call == emit_call_mock assert session.restored is False assert session.mcp_sessions == {} def test_websocket_session_language_detection(self): """Test WebsocketSession language detection from HTTP headers.""" session = WebsocketSession( id="ws_id", socket_id="socket_123", emit=Mock(), emit_call=Mock(), user_env={}, client_type="webapp", environ={"HTTP_ACCEPT_LANGUAGE": "fr-FR,fr;q=0.9,en;q=0.8"}, ) assert session.language == "fr-FR" def test_websocket_session_default_language(self): """Test WebsocketSession defaults to en-US without language header.""" session = WebsocketSession( id="ws_id", socket_id="socket_123", emit=Mock(), emit_call=Mock(), user_env={}, client_type="webapp", environ={}, ) assert session.language == "en-US" def test_websocket_session_restore(self): """Test WebsocketSession restore method.""" from chainlit.session import ws_sessions_sid session = WebsocketSession( id="ws_id", socket_id="old_socket", emit=Mock(), emit_call=Mock(), user_env={}, client_type="webapp", ) assert ws_sessions_sid.get("old_socket") == session session.restore("new_socket") assert session.socket_id == "new_socket" assert session.restored is True assert ws_sessions_sid.get("old_socket") is None assert ws_sessions_sid.get("new_socket") == session @pytest.mark.asyncio async def test_websocket_session_delete(self): """Test WebsocketSession delete method.""" from chainlit.session import ws_sessions_id, ws_sessions_sid with tempfile.TemporaryDirectory() as tmpdir: with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)): session = WebsocketSession( id="ws_id", socket_id="socket_123", emit=Mock(), emit_call=Mock(), user_env={}, client_type="webapp", ) # Create files directory session.files_dir.mkdir(exist_ok=True) assert ws_sessions_sid.get("socket_123") == session assert ws_sessions_id.get("ws_id") == session await session.delete() assert not session.files_dir.exists() assert ws_sessions_sid.get("socket_123") is None assert ws_sessions_id.get("ws_id") is None def test_websocket_session_get(self): """Test WebsocketSession.get class method.""" session = WebsocketSession( id="ws_id", socket_id="socket_123", emit=Mock(), emit_call=Mock(), user_env={}, client_type="webapp", ) retrieved = WebsocketSession.get("socket_123") assert retrieved == session def test_websocket_session_get_by_id(self): """Test WebsocketSession.get_by_id class method.""" session = WebsocketSession( id="ws_id", socket_id="socket_123", emit=Mock(), emit_call=Mock(), user_env={}, client_type="webapp", ) retrieved = WebsocketSession.get_by_id("ws_id") assert retrieved == session def test_websocket_session_require_success(self): """Test WebsocketSession.require with existing session.""" session = WebsocketSession( id="ws_id", socket_id="socket_123", emit=Mock(), emit_call=Mock(), user_env={}, client_type="webapp", ) retrieved = WebsocketSession.require("socket_123") assert retrieved == session def test_websocket_session_require_failure(self): """Test WebsocketSession.require raises error for missing session.""" with pytest.raises(ValueError, match="Session not found"): WebsocketSession.require("nonexistent_socket") @pytest.mark.asyncio async def test_websocket_session_flush_method_queue(self): """Test WebsocketSession flush_method_queue.""" from collections import deque session = WebsocketSession( id="ws_id", socket_id="socket_123", emit=Mock(), emit_call=Mock(), user_env={}, client_type="webapp", ) # Create a mock async method mock_method = AsyncMock() # Add items to queue session.thread_queues["test_method"] = deque( [ (mock_method, session, ("arg1",), {"kwarg1": "value1"}), (mock_method, session, ("arg2",), {"kwarg2": "value2"}), ] ) await session.flush_method_queue() assert mock_method.call_count == 2 assert len(session.thread_queues["test_method"]) == 0 class TestSessionEdgeCases: """Test suite for session edge cases.""" def test_base_session_with_all_client_types(self): """Test BaseSession with different client types.""" client_types = ["webapp", "copilot", "teams", "slack", "discord"] for client_type in client_types: session = BaseSession( id=f"test_{client_type}", client_type=client_type, thread_id=None, user=None, token=None, user_env=None, ) assert session.client_type == client_type @pytest.mark.asyncio async def test_persist_file_with_mime_extension(self): """Test that persist_file adds correct file extension based on MIME type.""" with tempfile.TemporaryDirectory() as tmpdir: with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)): session = BaseSession( id="test_id", client_type="webapp", thread_id=None, user=None, token=None, user_env=None, ) # Test with image MIME type result = await session.persist_file( name="image.png", mime="image/png", content=b"fake image data", ) file_id = result["id"] file_path = session.files[file_id]["path"] assert file_path.suffix == ".png" def test_clean_metadata_with_empty_dict(self): """Test clean_metadata with empty dictionary.""" result = clean_metadata({}) assert result == {} def test_websocket_session_with_chat_profile(self): """Test WebsocketSession with chat profile.""" session = WebsocketSession( id="ws_id", socket_id="socket_123", emit=Mock(), emit_call=Mock(), user_env={}, client_type="webapp", chat_profile="gpt-4", ) assert session.chat_profile == "gpt-4" @pytest.mark.asyncio async def test_websocket_session_delete_with_mcp_sessions(self): """Test WebsocketSession delete with MCP sessions.""" with tempfile.TemporaryDirectory() as tmpdir: with patch("chainlit.config.FILES_DIRECTORY", Path(tmpdir)): session = WebsocketSession( id="ws_id", socket_id="socket_123", emit=Mock(), emit_call=Mock(), user_env={}, client_type="webapp", ) # Mock MCP session with exit stack mock_exit_stack = AsyncMock() session.mcp_sessions["mcp1"] = (Mock(), mock_exit_stack) await session.delete() mock_exit_stack.aclose.assert_called_once() ================================================ FILE: backend/tests/test_sidebar.py ================================================ import pytest from chainlit.element import File, Image, Text from chainlit.sidebar import ElementSidebar @pytest.mark.asyncio class TestElementSidebar: """Test suite for ElementSidebar class.""" async def test_set_title(self, mock_chainlit_context): """Test ElementSidebar.set_title() method.""" async with mock_chainlit_context as ctx: await ElementSidebar.set_title("My Sidebar Title") ctx.emitter.emit.assert_called_once_with( "set_sidebar_title", "My Sidebar Title" ) async def test_set_title_with_empty_string(self, mock_chainlit_context): """Test ElementSidebar.set_title() with empty string.""" async with mock_chainlit_context as ctx: await ElementSidebar.set_title("") ctx.emitter.emit.assert_called_once_with("set_sidebar_title", "") async def test_set_title_with_special_characters(self, mock_chainlit_context): """Test ElementSidebar.set_title() with special characters.""" async with mock_chainlit_context as ctx: title = "Title with 特殊字符 & symbols! 🎉" await ElementSidebar.set_title(title) ctx.emitter.emit.assert_called_once_with("set_sidebar_title", title) async def test_set_elements_with_single_element(self, mock_chainlit_context): """Test ElementSidebar.set_elements() with a single element.""" async with mock_chainlit_context as ctx: element = File(name="test.txt", url="https://example.com/test.txt") await ElementSidebar.set_elements([element]) # Verify element.send() was called ctx.emitter.send_element.assert_called_once() # Verify emit was called with correct structure ctx.emitter.emit.assert_called_once() call_args = ctx.emitter.emit.call_args assert call_args[0][0] == "set_sidebar_elements" assert "elements" in call_args[0][1] assert "key" in call_args[0][1] assert len(call_args[0][1]["elements"]) == 1 assert call_args[0][1]["key"] is None async def test_set_elements_with_multiple_elements(self, mock_chainlit_context): """Test ElementSidebar.set_elements() with multiple elements.""" async with mock_chainlit_context as ctx: elements = [ File(name="file1.txt", url="https://example.com/file1.txt"), Image(name="image1.png", url="https://example.com/image1.png"), Text(name="text1", content="Some text content"), ] await ElementSidebar.set_elements(elements) # Verify all elements were sent (3 send_element calls) assert ctx.emitter.send_element.call_count == 3 # Verify emit was called with all elements ctx.emitter.emit.assert_called_once() call_args = ctx.emitter.emit.call_args assert call_args[0][0] == "set_sidebar_elements" assert len(call_args[0][1]["elements"]) == 3 async def test_set_elements_with_empty_list(self, mock_chainlit_context): """Test ElementSidebar.set_elements() with empty list (closes sidebar).""" async with mock_chainlit_context as ctx: await ElementSidebar.set_elements([]) # No elements to send ctx.emitter.send_element.assert_not_called() # Emit should still be called with empty elements ctx.emitter.emit.assert_called_once() call_args = ctx.emitter.emit.call_args assert call_args[0][0] == "set_sidebar_elements" assert call_args[0][1]["elements"] == [] assert call_args[0][1]["key"] is None async def test_set_elements_with_key(self, mock_chainlit_context): """Test ElementSidebar.set_elements() with a key.""" async with mock_chainlit_context as ctx: element = File(name="test.txt", url="https://example.com/test.txt") key = "my_sidebar_key" await ElementSidebar.set_elements([element], key=key) # Verify emit was called with the key ctx.emitter.emit.assert_called_once() call_args = ctx.emitter.emit.call_args assert call_args[0][1]["key"] == key async def test_set_elements_with_for_id(self, mock_chainlit_context): """Test ElementSidebar.set_elements() with elements that have for_id.""" async with mock_chainlit_context as ctx: element = File( name="test.txt", url="https://example.com/test.txt", for_id="message_123", ) await ElementSidebar.set_elements([element]) # Element should be sent with its for_id ctx.emitter.send_element.assert_called_once() # Verify emit was called ctx.emitter.emit.assert_called_once() async def test_set_elements_without_for_id(self, mock_chainlit_context): """Test ElementSidebar.set_elements() with elements without for_id.""" async with mock_chainlit_context as ctx: element = File(name="test.txt", url="https://example.com/test.txt") await ElementSidebar.set_elements([element]) # Element should be sent with empty string for_id ctx.emitter.send_element.assert_called_once() # Verify emit was called ctx.emitter.emit.assert_called_once() async def test_set_elements_persist_false(self, mock_chainlit_context): """Test that set_elements() sends elements with persist=False.""" async with mock_chainlit_context as ctx: # Mock persist_file to provide chainlit_key ctx.session.persist_file.return_value = {"id": "test_key"} element = File(name="test.txt", content=b"test content") await ElementSidebar.set_elements([element]) # persist_file is still called to get chainlit_key, even with persist=False # The persist=False affects data layer persistence, not file upload ctx.session.persist_file.assert_called_once() # Verify element was sent ctx.emitter.send_element.assert_called_once() async def test_set_elements_serialization(self, mock_chainlit_context): """Test that elements are properly serialized in set_elements().""" async with mock_chainlit_context as ctx: file_elem = File(name="file.txt", url="https://example.com/file.txt") image_elem = Image( name="image.png", url="https://example.com/image.png", size="large" ) await ElementSidebar.set_elements([file_elem, image_elem]) # Verify emit was called with serialized elements call_args = ctx.emitter.emit.call_args elements_data = call_args[0][1]["elements"] assert len(elements_data) == 2 assert elements_data[0]["name"] == "file.txt" assert elements_data[0]["type"] == "file" assert elements_data[1]["name"] == "image.png" assert elements_data[1]["type"] == "image" assert elements_data[1]["size"] == "large" @pytest.mark.asyncio class TestElementSidebarEdgeCases: """Test suite for ElementSidebar edge cases.""" async def test_set_title_multiple_times(self, mock_chainlit_context): """Test calling set_title() multiple times.""" async with mock_chainlit_context as ctx: await ElementSidebar.set_title("First Title") await ElementSidebar.set_title("Second Title") await ElementSidebar.set_title("Third Title") assert ctx.emitter.emit.call_count == 3 # Verify last call had the third title last_call = ctx.emitter.emit.call_args assert last_call[0][1] == "Third Title" async def test_set_elements_multiple_times(self, mock_chainlit_context): """Test calling set_elements() multiple times.""" async with mock_chainlit_context as ctx: element1 = File(name="file1.txt", url="https://example.com/file1.txt") element2 = File(name="file2.txt", url="https://example.com/file2.txt") await ElementSidebar.set_elements([element1]) await ElementSidebar.set_elements([element2]) # Should have sent both elements assert ctx.emitter.send_element.call_count == 2 # Should have emitted twice assert ctx.emitter.emit.call_count == 2 async def test_set_elements_with_same_key_twice(self, mock_chainlit_context): """Test calling set_elements() with the same key twice.""" async with mock_chainlit_context as ctx: element1 = File(name="file1.txt", url="https://example.com/file1.txt") element2 = File(name="file2.txt", url="https://example.com/file2.txt") await ElementSidebar.set_elements([element1], key="same_key") await ElementSidebar.set_elements([element2], key="same_key") # Both should be sent (server doesn't prevent this) assert ctx.emitter.send_element.call_count == 2 assert ctx.emitter.emit.call_count == 2 async def test_set_elements_with_different_keys(self, mock_chainlit_context): """Test calling set_elements() with different keys.""" async with mock_chainlit_context as ctx: element1 = File(name="file1.txt", url="https://example.com/file1.txt") element2 = File(name="file2.txt", url="https://example.com/file2.txt") await ElementSidebar.set_elements([element1], key="key1") await ElementSidebar.set_elements([element2], key="key2") assert ctx.emitter.emit.call_count == 2 # Verify different keys were used calls = ctx.emitter.emit.call_args_list assert calls[0][0][1]["key"] == "key1" assert calls[1][0][1]["key"] == "key2" async def test_set_elements_with_large_number_of_elements( self, mock_chainlit_context ): """Test set_elements() with many elements.""" async with mock_chainlit_context as ctx: # Create 50 elements elements = [ File(name=f"file{i}.txt", url=f"https://example.com/file{i}.txt") for i in range(50) ] await ElementSidebar.set_elements(elements) # All 50 elements should be sent assert ctx.emitter.send_element.call_count == 50 # Verify emit was called with all 50 elements call_args = ctx.emitter.emit.call_args assert len(call_args[0][1]["elements"]) == 50 async def test_set_title_and_set_elements_together(self, mock_chainlit_context): """Test using set_title() and set_elements() together.""" async with mock_chainlit_context as ctx: await ElementSidebar.set_title("My Documents") elements = [ File(name="doc1.pdf", url="https://example.com/doc1.pdf"), File(name="doc2.pdf", url="https://example.com/doc2.pdf"), ] await ElementSidebar.set_elements(elements) # Verify both methods were called assert ctx.emitter.emit.call_count == 2 # Verify the calls were correct calls = ctx.emitter.emit.call_args_list assert calls[0][0][0] == "set_sidebar_title" assert calls[0][0][1] == "My Documents" assert calls[1][0][0] == "set_sidebar_elements" async def test_set_elements_with_mixed_element_types(self, mock_chainlit_context): """Test set_elements() with various element types.""" async with mock_chainlit_context as ctx: elements = [ File(name="document.pdf", url="https://example.com/doc.pdf"), Image( name="photo.jpg", url="https://example.com/photo.jpg", size="medium" ), Text(name="notes", content="Some important notes"), ] await ElementSidebar.set_elements(elements) # Verify all different types were sent assert ctx.emitter.send_element.call_count == 3 # Verify serialization includes type information call_args = ctx.emitter.emit.call_args elements_data = call_args[0][1]["elements"] assert elements_data[0]["type"] == "file" assert elements_data[1]["type"] == "image" assert elements_data[2]["type"] == "text" async def test_set_title_with_long_string(self, mock_chainlit_context): """Test set_title() with a very long title.""" async with mock_chainlit_context as ctx: long_title = "A" * 1000 # 1000 character title await ElementSidebar.set_title(long_title) ctx.emitter.emit.assert_called_once_with("set_sidebar_title", long_title) ================================================ FILE: backend/tests/test_slack_socket_mode.py ================================================ # tests/test_slack_socket_mode.py import importlib from unittest.mock import AsyncMock, patch import pytest @pytest.mark.asyncio async def test_start_socket_mode_starts_handler(monkeypatch): """ The function should: • build an AsyncSocketModeHandler with the global slack_app • use the token found in SLACK_WEBSOCKET_TOKEN • await the handler.start_async() coroutine exactly once """ token = "xapp-fake-token" # minimal env required for the Slack module to initialise monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-fake-bot") monkeypatch.setenv("SLACK_WEBSOCKET_TOKEN", token) # Import the module first to avoid lazy import registry issues slack_app_mod = importlib.import_module("chainlit.slack.app") # Patch the object directly instead of using string path with patch.object( slack_app_mod, "AsyncSocketModeHandler", autospec=True ) as handler_cls: handler_instance = AsyncMock() handler_cls.return_value = handler_instance # Run: should build handler + await start_async await slack_app_mod.start_socket_mode() handler_cls.assert_called_once_with(slack_app_mod.slack_app, token) handler_instance.start_async.assert_awaited_once() def test_slack_http_route_registered(monkeypatch): """ When only the classic HTTP tokens are set (no websocket token), the FastAPI app should expose POST /slack/events. """ # HTTP-only environment monkeypatch.setenv("SLACK_BOT_TOKEN", "xoxb-fake-bot") monkeypatch.setenv("SLACK_SIGNING_SECRET", "shhh-fake-secret") monkeypatch.delenv("SLACK_WEBSOCKET_TOKEN", raising=False) # Re-import server with the fresh env so the route table is built correctly server = importlib.reload(importlib.import_module("chainlit.server")) assert any( route.path == "/slack/events" and "POST" in route.methods for route in server.router.routes ), "Slack HTTP handler route was not registered" ================================================ FILE: backend/tests/test_socket.py ================================================ import json from unittest.mock import AsyncMock, Mock, patch import pytest from chainlit.session import WebsocketSession from chainlit.socket import ( _authenticate_connection, _get_token, _get_token_from_cookie, clean_session, load_user_env, persist_user_session, restore_existing_session, resume_thread, ) class TestGetTokenFromCookie: """Test suite for _get_token_from_cookie function.""" def test_get_token_from_cookie_with_valid_cookie(self): """Test extracting token from valid cookie header.""" with patch("chainlit.socket.get_token_from_cookies") as mock_get_token: mock_get_token.return_value = "test_token" environ = {"HTTP_COOKIE": "session=abc123; token=test_token"} result = _get_token_from_cookie(environ) assert result == "test_token" mock_get_token.assert_called_once() def test_get_token_from_cookie_without_cookie(self): """Test when no cookie header is present.""" environ = {} result = _get_token_from_cookie(environ) assert result is None def test_get_token_from_cookie_with_empty_cookie(self): """Test with empty cookie header.""" with patch("chainlit.socket.get_token_from_cookies") as mock_get_token: mock_get_token.return_value = None environ = {"HTTP_COOKIE": ""} result = _get_token_from_cookie(environ) assert result is None class TestGetToken: """Test suite for _get_token function.""" def test_get_token_calls_get_token_from_cookie(self): """Test that _get_token delegates to _get_token_from_cookie.""" with patch("chainlit.socket._get_token_from_cookie") as mock_get_cookie: mock_get_cookie.return_value = "token_value" environ = {"HTTP_COOKIE": "token=token_value"} result = _get_token(environ) assert result == "token_value" mock_get_cookie.assert_called_once_with(environ) class TestAuthenticateConnection: """Test suite for _authenticate_connection function.""" @pytest.mark.asyncio async def test_authenticate_connection_with_valid_token(self): """Test authentication with valid token.""" mock_user = Mock() mock_user.identifier = "user123" with patch("chainlit.socket._get_token") as mock_get_token: with patch("chainlit.socket.get_current_user") as mock_get_user: mock_get_token.return_value = "valid_token" mock_get_user.return_value = mock_user environ = {"HTTP_COOKIE": "token=valid_token"} user, token = await _authenticate_connection(environ) assert user == mock_user assert token == "valid_token" mock_get_user.assert_called_once_with(token="valid_token") @pytest.mark.asyncio async def test_authenticate_connection_without_token(self): """Test authentication without token.""" with patch("chainlit.socket._get_token") as mock_get_token: mock_get_token.return_value = None environ = {} user, token = await _authenticate_connection(environ) assert user is None assert token is None @pytest.mark.asyncio async def test_authenticate_connection_with_invalid_token(self): """Test authentication with invalid token.""" with patch("chainlit.socket._get_token") as mock_get_token: with patch("chainlit.socket.get_current_user") as mock_get_user: mock_get_token.return_value = "invalid_token" mock_get_user.return_value = None environ = {"HTTP_COOKIE": "token=invalid_token"} user, token = await _authenticate_connection(environ) assert user is None assert token is None class TestRestoreExistingSession: """Test suite for restore_existing_session function.""" def test_restore_existing_session_success(self): """Test restoring an existing session.""" mock_session = Mock(spec=WebsocketSession) emit_fn = Mock() emit_call_fn = Mock() environ = {"HTTP_COOKIE": "token=token"} with patch.object(WebsocketSession, "get_by_id") as mock_get: mock_get.return_value = mock_session result = restore_existing_session( "new_sid", "session_123", emit_fn, emit_call_fn, environ ) assert result is True mock_session.restore.assert_called_once_with(new_socket_id="new_sid") assert mock_session.emit == emit_fn assert mock_session.emit_call == emit_call_fn assert mock_session.environ == environ def test_restore_existing_session_not_found(self): """Test when session is not found.""" with patch.object(WebsocketSession, "get_by_id") as mock_get: mock_get.return_value = None result = restore_existing_session( "new_sid", "session_123", Mock(), Mock(), {"HTTP_COOKIE": "token=token"} ) assert result is False class TestPersistUserSession: """Test suite for persist_user_session function.""" @pytest.mark.asyncio async def test_persist_user_session_with_data_layer(self): """Test persisting user session with data layer.""" mock_data_layer = AsyncMock() with patch("chainlit.socket.get_data_layer") as mock_get_dl: mock_get_dl.return_value = mock_data_layer metadata = {"key": "value"} await persist_user_session("thread_123", metadata) mock_data_layer.update_thread.assert_called_once_with( thread_id="thread_123", metadata=metadata ) @pytest.mark.asyncio async def test_persist_user_session_without_data_layer(self): """Test persisting when no data layer is available.""" with patch("chainlit.socket.get_data_layer") as mock_get_dl: mock_get_dl.return_value = None # Should not raise an error await persist_user_session("thread_123", {"key": "value"}) class TestResumeThread: """Test suite for resume_thread function.""" @pytest.mark.asyncio async def test_resume_thread_without_data_layer(self): """Test resume thread when no data layer exists.""" mock_session = Mock(spec=WebsocketSession) mock_session.user = Mock() mock_session.thread_id_to_resume = "thread_123" with patch("chainlit.socket.get_data_layer") as mock_get_dl: mock_get_dl.return_value = None result = await resume_thread(mock_session) assert result is None @pytest.mark.asyncio async def test_resume_thread_without_user(self): """Test resume thread when session has no user.""" mock_session = Mock(spec=WebsocketSession) mock_session.user = None mock_session.thread_id_to_resume = "thread_123" result = await resume_thread(mock_session) assert result is None @pytest.mark.asyncio async def test_resume_thread_without_thread_id(self): """Test resume thread when no thread_id_to_resume.""" mock_session = Mock(spec=WebsocketSession) mock_session.user = Mock() mock_session.thread_id_to_resume = None result = await resume_thread(mock_session) assert result is None @pytest.mark.asyncio async def test_resume_thread_thread_not_found(self): """Test resume thread when thread doesn't exist.""" mock_session = Mock(spec=WebsocketSession) mock_session.user = Mock(identifier="user123") mock_session.thread_id_to_resume = "thread_123" mock_session.id = "session_123" mock_data_layer = AsyncMock() mock_data_layer.get_thread.return_value = None with patch("chainlit.socket.get_data_layer") as mock_get_dl: mock_get_dl.return_value = mock_data_layer result = await resume_thread(mock_session) assert result is None mock_data_layer.get_thread.assert_called_once_with(thread_id="thread_123") @pytest.mark.asyncio async def test_resume_thread_user_not_author(self): """Test resume thread when user is not the thread author.""" mock_session = Mock(spec=WebsocketSession) mock_session.user = Mock(identifier="user123") mock_session.thread_id_to_resume = "thread_123" mock_session.id = "session_123" thread = {"userIdentifier": "different_user", "metadata": {}} mock_data_layer = AsyncMock() mock_data_layer.get_thread.return_value = thread with patch("chainlit.socket.get_data_layer") as mock_get_dl: mock_get_dl.return_value = mock_data_layer result = await resume_thread(mock_session) assert result is None @pytest.mark.asyncio async def test_resume_thread_success(self): """Test successful thread resumption.""" from chainlit.user_session import user_sessions mock_session = Mock(spec=WebsocketSession) mock_session.user = Mock(identifier="user123") mock_session.thread_id_to_resume = "thread_123" mock_session.id = "session_123" metadata = { "chat_profile": "gpt-4", "chat_settings": {"temperature": 0.7}, } thread = {"userIdentifier": "user123", "metadata": metadata} mock_data_layer = AsyncMock() mock_data_layer.get_thread.return_value = thread original_sessions = user_sessions.copy() try: with patch("chainlit.socket.get_data_layer") as mock_get_dl: mock_get_dl.return_value = mock_data_layer result = await resume_thread(mock_session) assert result == thread assert mock_session.chat_profile == "gpt-4" assert mock_session.chat_settings == {"temperature": 0.7} assert user_sessions.get("session_123") == metadata finally: user_sessions.clear() user_sessions.update(original_sessions) @pytest.mark.asyncio async def test_resume_thread_with_string_metadata(self): """Test thread resumption with JSON string metadata.""" from chainlit.user_session import user_sessions mock_session = Mock(spec=WebsocketSession) mock_session.user = Mock(identifier="user123") mock_session.thread_id_to_resume = "thread_123" mock_session.id = "session_123" metadata_dict = {"chat_profile": "gpt-4"} thread = { "userIdentifier": "user123", "metadata": json.dumps(metadata_dict), } mock_data_layer = AsyncMock() mock_data_layer.get_thread.return_value = thread original_sessions = user_sessions.copy() try: with patch("chainlit.socket.get_data_layer") as mock_get_dl: mock_get_dl.return_value = mock_data_layer result = await resume_thread(mock_session) assert result == thread assert mock_session.chat_profile == "gpt-4" finally: user_sessions.clear() user_sessions.update(original_sessions) class TestLoadUserEnv: """Test suite for load_user_env function.""" def test_load_user_env_with_valid_json(self): """Test loading valid user environment JSON.""" user_env = '{"API_KEY": "secret", "ENV_VAR": "value"}' with patch("chainlit.socket.config") as mock_config: mock_config.project.user_env = [] result = load_user_env(user_env) assert result == {"API_KEY": "secret", "ENV_VAR": "value"} def test_load_user_env_with_required_keys(self): """Test loading user env with required keys.""" user_env = '{"API_KEY": "secret", "OTHER_KEY": "value"}' with patch("chainlit.socket.config") as mock_config: mock_config.project.user_env = ["API_KEY", "OTHER_KEY"] result = load_user_env(user_env) assert result == {"API_KEY": "secret", "OTHER_KEY": "value"} def test_load_user_env_missing_required_key(self): """Test error when required key is missing.""" user_env = '{"API_KEY": "secret"}' with patch("chainlit.socket.config") as mock_config: mock_config.project.user_env = ["API_KEY", "MISSING_KEY"] with pytest.raises( ConnectionRefusedError, match="Missing user environment variable" ): load_user_env(user_env) def test_load_user_env_none_with_required_keys(self): """Test error when user_env is None but keys are required.""" with patch("chainlit.socket.config") as mock_config: mock_config.project.user_env = ["API_KEY"] # The function has a bug - it raises UnboundLocalError instead of ConnectionRefusedError # Python 3.10: "referenced before assignment" # Python 3.11+: "cannot access local variable" with pytest.raises(UnboundLocalError, match="user_env_dict"): load_user_env(None) def test_load_user_env_none_without_required_keys(self): """Test when user_env is None and no keys are required.""" with patch("chainlit.socket.config") as mock_config: mock_config.project.user_env = [] # The function has a bug - it raises NameError when user_env is None # even when no required keys are configured with pytest.raises(NameError, match="user_env_dict"): load_user_env(None) class TestCleanSession: """Test suite for clean_session function.""" @pytest.mark.asyncio async def test_clean_session_with_existing_session(self): """Test marking session for cleanup.""" mock_session = Mock(spec=WebsocketSession) mock_session.to_clear = False with patch.object(WebsocketSession, "get") as mock_get: mock_get.return_value = mock_session await clean_session("socket_123") assert mock_session.to_clear is True mock_get.assert_called_once_with("socket_123") @pytest.mark.asyncio async def test_clean_session_without_session(self): """Test clean_session when session doesn't exist.""" with patch.object(WebsocketSession, "get") as mock_get: mock_get.return_value = None # Should not raise an error await clean_session("socket_123") class TestSocketEdgeCases: """Test suite for socket edge cases.""" def test_restore_existing_session_with_none_session_id(self): """Test restore with None session_id.""" with patch.object(WebsocketSession, "get_by_id") as mock_get: mock_get.return_value = None result = restore_existing_session(None, None, Mock(), Mock(), None) assert result is False @pytest.mark.asyncio async def test_persist_user_session_with_empty_metadata(self): """Test persisting empty metadata.""" mock_data_layer = AsyncMock() with patch("chainlit.socket.get_data_layer") as mock_get_dl: mock_get_dl.return_value = mock_data_layer await persist_user_session("thread_123", {}) mock_data_layer.update_thread.assert_called_once_with( thread_id="thread_123", metadata={} ) def test_load_user_env_with_empty_json(self): """Test loading empty user environment.""" user_env = "{}" with patch("chainlit.socket.config") as mock_config: mock_config.project.user_env = [] result = load_user_env(user_env) assert result == {} @pytest.mark.asyncio async def test_resume_thread_with_empty_metadata(self): """Test resuming thread with empty metadata.""" from chainlit.user_session import user_sessions mock_session = Mock(spec=WebsocketSession) mock_session.user = Mock(identifier="user123") mock_session.thread_id_to_resume = "thread_123" mock_session.id = "session_123" thread = {"userIdentifier": "user123", "metadata": {}} mock_data_layer = AsyncMock() mock_data_layer.get_thread.return_value = thread original_sessions = user_sessions.copy() try: with patch("chainlit.socket.get_data_layer") as mock_get_dl: mock_get_dl.return_value = mock_data_layer result = await resume_thread(mock_session) assert result == thread assert user_sessions.get("session_123") == {} finally: user_sessions.clear() user_sessions.update(original_sessions) @pytest.mark.asyncio async def test_authenticate_connection_with_exception(self): """Test authentication when get_current_user raises exception.""" with patch("chainlit.socket._get_token") as mock_get_token: with patch("chainlit.socket.get_current_user") as mock_get_user: mock_get_token.return_value = "token" mock_get_user.side_effect = Exception("Auth error") environ = {"HTTP_COOKIE": "token=token"} # Should propagate the exception with pytest.raises(Exception, match="Auth error"): await _authenticate_connection(environ) ================================================ FILE: backend/tests/test_step.py ================================================ import sys import uuid from unittest.mock import AsyncMock, Mock, patch import pytest from chainlit.context import local_steps from chainlit.element import Element from chainlit.step import ( Step, check_add_step_in_cot, flatten_args_kwargs, step, stub_step, ) @pytest.mark.asyncio class TestStepClass: """Test suite for the Step class.""" async def test_step_initialization_with_defaults(self, mock_chainlit_context): """Test Step initialization with default values.""" async with mock_chainlit_context: test_step = Step(name="test_step") assert test_step.name == "test_step" assert test_step.type == "undefined" assert isinstance(test_step.id, str) uuid.UUID(test_step.id) # Verify valid UUID assert test_step.parent_id is None assert test_step.metadata == {} assert test_step.tags is None assert test_step.is_error is False assert test_step.show_input == "json" assert test_step.language is None assert test_step.default_open is False assert test_step.elements == [] assert test_step.streaming is False assert test_step.persisted is False assert test_step.fail_on_persist_error is False assert test_step.input == "" assert test_step.output == "" assert test_step.created_at is not None assert test_step.start is None assert test_step.end is None async def test_step_initialization_with_all_fields(self, mock_chainlit_context): """Test Step initialization with all fields provided.""" async with mock_chainlit_context: test_id = str(uuid.uuid4()) parent_id = str(uuid.uuid4()) metadata = {"key": "value"} tags = ["tag1", "tag2"] test_step = Step( name="custom_step", type="tool", id=test_id, parent_id=parent_id, metadata=metadata, tags=tags, language="python", default_open=True, show_input=False, ) assert test_step.name == "custom_step" assert test_step.type == "tool" assert test_step.id == test_id assert test_step.parent_id == parent_id assert test_step.metadata == metadata assert test_step.tags == tags assert test_step.language == "python" assert test_step.default_open is True assert test_step.show_input is False async def test_step_input_setter_with_string(self, mock_chainlit_context): """Test Step input setter with string content.""" async with mock_chainlit_context: test_step = Step(name="test") test_step.input = "This is input text" assert test_step.input == "This is input text" async def test_step_input_setter_with_dict(self, mock_chainlit_context): """Test Step input setter with dictionary content.""" async with mock_chainlit_context: test_step = Step(name="test") input_dict = {"param1": "value1", "param2": 42} test_step.input = input_dict # Should be JSON formatted assert "param1" in test_step.input assert "value1" in test_step.input assert isinstance(test_step.input, str) async def test_step_output_setter_with_string(self, mock_chainlit_context): """Test Step output setter with string content.""" async with mock_chainlit_context: test_step = Step(name="test") test_step.output = "This is output text" assert test_step.output == "This is output text" async def test_step_output_setter_with_dict(self, mock_chainlit_context): """Test Step output setter with dictionary content and language detection.""" async with mock_chainlit_context: test_step = Step(name="test") output_dict = {"result": "success", "data": [1, 2, 3]} test_step.output = output_dict # Should be JSON formatted and language set to json assert "result" in test_step.output assert "success" in test_step.output assert test_step.language == "json" async def test_step_clean_content_with_bytes(self, mock_chainlit_context): """Test that bytes in content are stripped.""" async with mock_chainlit_context: test_step = Step(name="test") content_with_bytes = { "text": "hello", "binary": b"binary_data", "nested": {"data": b"more_binary"}, } test_step.output = content_with_bytes assert "STRIPPED_BINARY_DATA" in test_step.output assert b"binary_data" not in test_step.output.encode() async def test_step_to_dict(self, mock_chainlit_context): """Test Step serialization to dictionary.""" async with mock_chainlit_context as ctx: test_step = Step( name="test_step", type="tool", metadata={"key": "value"}, tags=["tag1"], ) test_step.input = "test input" test_step.output = "test output" step_dict = test_step.to_dict() assert step_dict["name"] == "test_step" assert step_dict["type"] == "tool" assert step_dict["id"] == test_step.id assert step_dict["threadId"] == ctx.session.thread_id assert step_dict["parentId"] is None assert step_dict["streaming"] is False assert step_dict["metadata"] == {"key": "value"} assert step_dict["tags"] == ["tag1"] assert step_dict["input"] == "test input" assert step_dict["output"] == "test output" assert step_dict["isError"] is False assert step_dict["createdAt"] is not None assert step_dict["start"] is None assert step_dict["end"] is None async def test_step_send(self, mock_chainlit_context): """Test Step.send() method.""" async with mock_chainlit_context as ctx: test_step = Step(name="test_step") result = await test_step.send() assert result == test_step assert test_step.persisted is False # No data layer configured ctx.emitter.send_step.assert_called_once() async def test_step_send_with_elements(self, mock_chainlit_context): """Test Step.send() with elements.""" async with mock_chainlit_context: mock_element = Mock(spec=Element) mock_element.send = AsyncMock() test_step = Step(name="test_step", elements=[mock_element]) await test_step.send() mock_element.send.assert_called_once_with(for_id=test_step.id) async def test_step_send_already_persisted(self, mock_chainlit_context): """Test that send() returns early if already persisted.""" async with mock_chainlit_context as ctx: test_step = Step(name="test_step") test_step.persisted = True result = await test_step.send() assert result == test_step ctx.emitter.send_step.assert_not_called() async def test_step_update(self, mock_chainlit_context): """Test Step.update() method.""" async with mock_chainlit_context as ctx: test_step = Step(name="test_step") test_step.streaming = True result = await test_step.update() assert result is True assert test_step.streaming is False ctx.emitter.update_step.assert_called_once() async def test_step_remove(self, mock_chainlit_context): """Test Step.remove() method.""" async with mock_chainlit_context as ctx: test_step = Step(name="test_step") result = await test_step.remove() assert result is True ctx.emitter.delete_step.assert_called_once() async def test_step_stream_token_output(self, mock_chainlit_context): """Test streaming tokens to output.""" async with mock_chainlit_context: test_step = Step(name="test_step") await test_step.stream_token("Hello") await test_step.stream_token(" ") await test_step.stream_token("World") assert test_step.output == "Hello World" assert test_step.streaming is True async def test_step_stream_token_input(self, mock_chainlit_context): """Test streaming tokens to input.""" async with mock_chainlit_context: test_step = Step(name="test_step") await test_step.stream_token("Input", is_input=True) await test_step.stream_token(" text", is_input=True) assert test_step.input == "Input text" async def test_step_stream_token_sequence(self, mock_chainlit_context): """Test streaming tokens with is_sequence flag.""" async with mock_chainlit_context: test_step = Step(name="test_step") await test_step.stream_token("First", is_sequence=True) await test_step.stream_token("Second", is_sequence=True) # With is_sequence, it replaces instead of appending assert test_step.output == "Second" async def test_step_stream_token_empty(self, mock_chainlit_context): """Test that empty tokens are ignored.""" async with mock_chainlit_context as ctx: test_step = Step(name="test_step") await test_step.stream_token("") assert test_step.output == "" ctx.emitter.stream_start.assert_not_called() async def test_step_context_manager_async(self, mock_chainlit_context): """Test Step as async context manager.""" async with mock_chainlit_context as ctx: async with Step(name="context_step") as test_step: assert test_step.start is not None assert test_step.end is None # After exiting context assert test_step.end is not None assert ctx.emitter.send_step.call_count == 1 assert ctx.emitter.update_step.call_count == 1 async def test_step_context_manager_with_exception(self, mock_chainlit_context): """Test Step context manager handles exceptions.""" async with mock_chainlit_context: try: async with Step(name="error_step") as test_step: raise ValueError("Test error") except ValueError: pass assert test_step.is_error is True assert "Test error" in test_step.output async def test_step_parent_id_from_context(self, mock_chainlit_context): """Test that parent_id is set from context when nesting steps.""" async with mock_chainlit_context: async with Step(name="parent_step") as parent: async with Step(name="child_step") as child: assert child.parent_id == parent.id async def test_step_local_steps_tracking(self, mock_chainlit_context): """Test that local_steps tracks step hierarchy.""" async with mock_chainlit_context: async with Step(name="step1") as step1: steps = local_steps.get() assert step1 in steps async with Step(name="step2") as step2: steps = local_steps.get() assert step1 in steps assert step2 in steps # After step2 exits steps = local_steps.get() assert step1 in steps assert step2 not in steps async def test_step_with_none_input(self, mock_chainlit_context): """Test Step handles None input correctly.""" async with mock_chainlit_context: test_step = Step(name="test") test_step.input = None assert test_step.input == "" async def test_step_with_none_output(self, mock_chainlit_context): """Test Step handles None output correctly.""" async with mock_chainlit_context: test_step = Step(name="test") test_step.output = None assert test_step.output == "" async def test_step_with_list_content(self, mock_chainlit_context): """Test Step handles list content.""" async with mock_chainlit_context: test_step = Step(name="test") test_step.output = [1, 2, 3, "four"] assert "[" in test_step.output assert "1" in test_step.output assert "four" in test_step.output assert test_step.language == "json" async def test_step_with_tuple_content(self, mock_chainlit_context): """Test Step handles tuple content.""" async with mock_chainlit_context: test_step = Step(name="test") test_step.output = ("a", "b", "c") assert test_step.output != "" assert test_step.language == "json" @pytest.mark.asyncio class TestStepDecorator: """Test suite for the @step decorator.""" async def test_step_decorator_async_function(self, mock_chainlit_context): """Test @step decorator on async function.""" async with mock_chainlit_context as ctx: @step(name="async_step", type="tool") async def async_function(x: int, y: int): return x + y result = await async_function(2, 3) assert result == 5 ctx.emitter.send_step.assert_called() async def test_step_decorator_sync_function(self, mock_chainlit_context): """Test @step decorator on sync function.""" async with mock_chainlit_context: @step(name="sync_step", type="tool") def sync_function(x: int, y: int): return x + y result = sync_function(2, 3) assert result == 5 async def test_step_decorator_uses_function_name(self, mock_chainlit_context): """Test that decorator uses function name when name not provided.""" async with mock_chainlit_context as ctx: @step(type="tool") async def my_custom_function(): return "result" await my_custom_function() # Check that step was created with function name call_args = ctx.emitter.send_step.call_args step_dict = call_args[0][0] assert step_dict["name"] == "my_custom_function" async def test_step_decorator_captures_input(self, mock_chainlit_context): """Test that decorator captures function arguments as input.""" async with mock_chainlit_context as ctx: @step(name="test_step") async def function_with_args(a: str, b: int, c: bool = True): return "done" await function_with_args("hello", 42, c=False) # Verify send_step was called (input is set during step execution) ctx.emitter.send_step.assert_called() async def test_step_decorator_captures_output(self, mock_chainlit_context): """Test that decorator captures function return value as output.""" async with mock_chainlit_context as ctx: @step(name="test_step") async def function_with_return(): return {"status": "success", "value": 123} await function_with_return() call_args = ctx.emitter.update_step.call_args step_dict = call_args[0][0] assert "status" in step_dict["output"] assert "success" in step_dict["output"] async def test_step_decorator_handles_exception(self, mock_chainlit_context): """Test that decorator handles exceptions in wrapped function.""" async with mock_chainlit_context as ctx: @step(name="error_step") async def function_with_error(): raise ValueError("Something went wrong") try: await function_with_error() except ValueError: pass call_args = ctx.emitter.update_step.call_args step_dict = call_args[0][0] assert step_dict["isError"] is True assert "Something went wrong" in step_dict["output"] async def test_step_decorator_with_metadata(self, mock_chainlit_context): """Test decorator with metadata parameter.""" async with mock_chainlit_context as ctx: metadata = {"version": "1.0", "author": "test"} @step(name="test_step", metadata=metadata) async def function_with_metadata(): return "result" await function_with_metadata() call_args = ctx.emitter.send_step.call_args step_dict = call_args[0][0] assert step_dict["metadata"] == metadata async def test_step_decorator_with_tags(self, mock_chainlit_context): """Test decorator with tags parameter.""" async with mock_chainlit_context as ctx: tags = ["important", "production"] @step(name="test_step", tags=tags) async def function_with_tags(): return "result" await function_with_tags() call_args = ctx.emitter.send_step.call_args step_dict = call_args[0][0] assert step_dict["tags"] == tags async def test_step_decorator_without_parentheses(self, mock_chainlit_context): """Test @step decorator without parentheses.""" async with mock_chainlit_context as ctx: @step async def simple_function(): return "result" result = await simple_function() assert result == "result" ctx.emitter.send_step.assert_called() @pytest.mark.asyncio class TestStepHelperFunctions: """Test suite for Step helper functions.""" def test_flatten_args_kwargs(self): """Test flatten_args_kwargs function.""" def sample_func(a, b, c=10, d=20): pass result = flatten_args_kwargs(sample_func, (1, 2), {"d": 30}) assert result["a"] == 1 assert result["b"] == 2 assert result["c"] == 10 # default value assert result["d"] == 30 def test_flatten_args_kwargs_with_all_kwargs(self): """Test flatten_args_kwargs with all keyword arguments.""" def sample_func(x, y, z): pass result = flatten_args_kwargs(sample_func, (), {"x": 1, "y": 2, "z": 3}) assert result == {"x": 1, "y": 2, "z": 3} async def test_stub_step(self, mock_chainlit_context): """Test stub_step function creates minimal step dict.""" async with mock_chainlit_context: test_step = Step(name="test_step", type="tool") test_step.parent_id = "parent_123" test_step.input = "full input" test_step.output = "full output" stub = stub_step(test_step) assert stub["name"] == "test_step" assert stub["type"] == "tool" assert stub["id"] == test_step.id assert stub["parentId"] == "parent_123" assert stub["threadId"] == test_step.thread_id assert stub["input"] == "" # Stubbed assert stub["output"] == "" # Stubbed async def test_check_add_step_in_cot_hidden(self, mock_chainlit_context): """Test check_add_step_in_cot with hidden COT.""" async with mock_chainlit_context: step_module = sys.modules["chainlit.step"] with patch.object(step_module, "config") as mock_config: mock_config.ui.cot = "hidden" # Message types should be added message_step = Step(name="test", type="assistant_message") assert check_add_step_in_cot(message_step) is True # Non-message types should not be added tool_step = Step(name="test", type="tool") assert check_add_step_in_cot(tool_step) is False async def test_check_add_step_in_cot_visible(self, mock_chainlit_context): """Test check_add_step_in_cot with visible COT.""" async with mock_chainlit_context: step_module = sys.modules["chainlit.step"] with patch.object(step_module, "config") as mock_config: mock_config.ui.cot = "visible" # All steps should be added tool_step = Step(name="test", type="tool") assert check_add_step_in_cot(tool_step) is True @pytest.mark.asyncio class TestStepEdgeCases: """Test suite for Step edge cases and error handling.""" async def test_step_with_non_serializable_content(self, mock_chainlit_context): """Test Step handles non-JSON-serializable content.""" async with mock_chainlit_context: test_step = Step(name="test") class NonSerializable: pass test_step.output = NonSerializable() # Should convert to string assert isinstance(test_step.output, str) assert test_step.language == "text" async def test_step_with_very_long_content(self, mock_chainlit_context): """Test Step handles very long content.""" async with mock_chainlit_context: test_step = Step(name="test") long_text = "x" * 10000 test_step.output = long_text assert len(test_step.output) == 10000 async def test_step_multiple_updates(self, mock_chainlit_context): """Test calling update() multiple times.""" async with mock_chainlit_context as ctx: test_step = Step(name="test") await test_step.update() await test_step.update() await test_step.update() assert ctx.emitter.update_step.call_count == 3 async def test_step_id_uniqueness(self, mock_chainlit_context): """Test that each Step gets a unique ID.""" async with mock_chainlit_context: step1 = Step(name="step1") step2 = Step(name="step2") step3 = Step(name="step3") ids = {step1.id, step2.id, step3.id} assert len(ids) == 3 # All unique async def test_step_with_custom_thread_id(self, mock_chainlit_context): """Test Step with custom thread_id.""" async with mock_chainlit_context: custom_thread_id = "custom_thread_123" test_step = Step(name="test", thread_id=custom_thread_id) assert test_step.thread_id == custom_thread_id async def test_step_fail_on_persist_error_flag(self, mock_chainlit_context): """Test fail_on_persist_error flag behavior.""" async with mock_chainlit_context: test_step = Step(name="test") assert test_step.fail_on_persist_error is False test_step.fail_on_persist_error = True assert test_step.fail_on_persist_error is True ================================================ FILE: backend/tests/test_translations.py ================================================ from io import StringIO from unittest.mock import patch import pytest from chainlit.translations import compare_json_structures, lint_translation_json class TestCompareJsonStructures: """Test suite for compare_json_structures function.""" def test_compare_identical_structures(self): """Test comparing identical JSON structures.""" truth = {"key1": "value1", "key2": "value2"} to_compare = {"key1": "value1", "key2": "value2"} errors = compare_json_structures(truth, to_compare) assert errors == [] def test_compare_with_missing_keys(self): """Test when to_compare is missing keys.""" truth = {"key1": "value1", "key2": "value2", "key3": "value3"} to_compare = {"key1": "value1"} errors = compare_json_structures(truth, to_compare) assert len(errors) == 2 assert "❌ Missing key: 'key2'" in errors assert "❌ Missing key: 'key3'" in errors def test_compare_with_extra_keys(self): """Test when to_compare has extra keys.""" truth = {"key1": "value1"} to_compare = {"key1": "value1", "key2": "value2", "key3": "value3"} errors = compare_json_structures(truth, to_compare) assert len(errors) == 2 assert "⚠️ Extra key: 'key2'" in errors assert "⚠️ Extra key: 'key3'" in errors def test_compare_with_both_missing_and_extra_keys(self): """Test when there are both missing and extra keys.""" truth = {"key1": "value1", "key2": "value2"} to_compare = {"key1": "value1", "key3": "value3"} errors = compare_json_structures(truth, to_compare) assert len(errors) == 2 assert any("Extra key: 'key3'" in e for e in errors) assert any("Missing key: 'key2'" in e for e in errors) def test_compare_nested_structures(self): """Test comparing nested JSON structures.""" truth = {"level1": {"level2": {"key": "value"}}} to_compare = {"level1": {"level2": {"key": "value"}}} errors = compare_json_structures(truth, to_compare) assert errors == [] def test_compare_nested_with_missing_keys(self): """Test nested structures with missing keys.""" truth = {"level1": {"key1": "value1", "key2": "value2"}} to_compare = {"level1": {"key1": "value1"}} errors = compare_json_structures(truth, to_compare) assert len(errors) == 1 assert "❌ Missing key: 'level1.key2'" in errors def test_compare_nested_with_extra_keys(self): """Test nested structures with extra keys.""" truth = {"level1": {"key1": "value1"}} to_compare = {"level1": {"key1": "value1", "key2": "value2"}} errors = compare_json_structures(truth, to_compare) assert len(errors) == 1 assert "⚠️ Extra key: 'level1.key2'" in errors def test_compare_deeply_nested_structures(self): """Test deeply nested structures.""" truth = {"a": {"b": {"c": {"d": "value"}}}} to_compare = {"a": {"b": {"c": {}}}} errors = compare_json_structures(truth, to_compare) assert len(errors) == 1 assert "❌ Missing key: 'a.b.c.d'" in errors def test_compare_structure_mismatch_dict_vs_value(self): """Test when one is dict and other is value.""" truth = {"key": {"nested": "value"}} to_compare = {"key": "not_a_dict"} errors = compare_json_structures(truth, to_compare) assert len(errors) == 1 assert "❌ Structure mismatch at: 'key'" in errors def test_compare_structure_mismatch_value_vs_dict(self): """Test when truth is value and to_compare is dict.""" truth = {"key": "value"} to_compare = {"key": {"nested": "value"}} errors = compare_json_structures(truth, to_compare) assert len(errors) == 1 assert "❌ Structure mismatch at: 'key'" in errors def test_compare_with_non_dict_input_truth(self): """Test error when truth is not a dict.""" with pytest.raises(ValueError, match="Both inputs must be dictionaries"): compare_json_structures("not_a_dict", {}) def test_compare_with_non_dict_input_to_compare(self): """Test error when to_compare is not a dict.""" with pytest.raises(ValueError, match="Both inputs must be dictionaries"): compare_json_structures({}, "not_a_dict") def test_compare_with_both_non_dict_inputs(self): """Test error when both inputs are not dicts.""" with pytest.raises(ValueError, match="Both inputs must be dictionaries"): compare_json_structures("not_a_dict", "also_not_a_dict") def test_compare_empty_dicts(self): """Test comparing empty dictionaries.""" truth = {} to_compare = {} errors = compare_json_structures(truth, to_compare) assert errors == [] def test_compare_empty_truth_with_data(self): """Test when truth is empty but to_compare has data.""" truth = {} to_compare = {"key1": "value1", "key2": "value2"} errors = compare_json_structures(truth, to_compare) assert len(errors) == 2 assert all("Extra key" in e for e in errors) def test_compare_empty_to_compare_with_data(self): """Test when to_compare is empty but truth has data.""" truth = {"key1": "value1", "key2": "value2"} to_compare = {} errors = compare_json_structures(truth, to_compare) assert len(errors) == 2 assert all("Missing key" in e for e in errors) def test_compare_with_different_value_types(self): """Test that different value types at leaf nodes don't cause errors.""" truth = {"key1": "string", "key2": 123, "key3": True} to_compare = {"key1": "different", "key2": 456, "key3": False} errors = compare_json_structures(truth, to_compare) # Structure matches, so no errors (values are not compared) assert errors == [] def test_compare_complex_nested_structure(self): """Test complex nested structure with multiple levels.""" truth = { "app": { "title": "My App", "settings": {"theme": "dark", "language": "en"}, }, "user": {"name": "John", "preferences": {"notifications": True}}, } to_compare = { "app": { "title": "My App", "settings": {"theme": "light"}, # Missing 'language' }, "user": { "name": "Jane", "preferences": {"notifications": False, "extra": "value"}, # Extra key }, } errors = compare_json_structures(truth, to_compare) assert len(errors) == 2 assert any("Missing key: 'app.settings.language'" in e for e in errors) assert any("Extra key: 'user.preferences.extra'" in e for e in errors) def test_compare_with_null_values(self): """Test structures with None/null values.""" truth = {"key1": None, "key2": "value"} to_compare = {"key1": None, "key2": "value"} errors = compare_json_structures(truth, to_compare) assert errors == [] def test_compare_with_list_values(self): """Test structures with list values (treated as leaf nodes).""" truth = {"key1": ["a", "b", "c"], "key2": "value"} to_compare = {"key1": ["x", "y"], "key2": "value"} errors = compare_json_structures(truth, to_compare) # Lists are leaf nodes, structure matches assert errors == [] def test_compare_path_formatting(self): """Test that error paths are formatted correctly.""" truth = {"a": {"b": {"c": "value"}}} to_compare = {"a": {"b": {}}} errors = compare_json_structures(truth, to_compare) assert len(errors) == 1 assert "a.b.c" in errors[0] assert not errors[0].startswith(".") class TestLintTranslationJson: """Test suite for lint_translation_json function.""" def test_lint_with_no_errors(self): """Test linting when there are no errors.""" truth = {"key1": "value1", "key2": "value2"} to_compare = {"key1": "value1", "key2": "value2"} with patch("sys.stdout", new=StringIO()) as fake_out: lint_translation_json("test.json", truth, to_compare) output = fake_out.getvalue() assert "Linting test.json..." in output assert "✅ No errors found in test.json" in output def test_lint_with_errors(self): """Test linting when there are errors.""" truth = {"key1": "value1", "key2": "value2"} to_compare = {"key1": "value1", "key3": "value3"} with patch("sys.stdout", new=StringIO()) as fake_out: lint_translation_json("test.json", truth, to_compare) output = fake_out.getvalue() assert "Linting test.json..." in output assert "Missing key: 'key2'" in output assert "Extra key: 'key3'" in output assert "✅ No errors found" not in output def test_lint_with_nested_errors(self): """Test linting with nested structure errors.""" truth = {"level1": {"key1": "value1", "key2": "value2"}} to_compare = {"level1": {"key1": "value1"}} with patch("sys.stdout", new=StringIO()) as fake_out: lint_translation_json("nested.json", truth, to_compare) output = fake_out.getvalue() assert "Linting nested.json..." in output assert "Missing key: 'level1.key2'" in output def test_lint_with_structure_mismatch(self): """Test linting with structure mismatch.""" truth = {"key": {"nested": "value"}} to_compare = {"key": "not_nested"} with patch("sys.stdout", new=StringIO()) as fake_out: lint_translation_json("mismatch.json", truth, to_compare) output = fake_out.getvalue() assert "Linting mismatch.json..." in output assert "Structure mismatch at: 'key'" in output def test_lint_with_multiple_errors(self): """Test linting with multiple types of errors.""" truth = { "key1": "value1", "key2": {"nested": "value"}, "key3": "value3", } to_compare = { "key1": "value1", "key2": "not_nested", "key4": "extra", } with patch("sys.stdout", new=StringIO()) as fake_out: lint_translation_json("multi.json", truth, to_compare) output = fake_out.getvalue() assert "Linting multi.json..." in output assert "Structure mismatch" in output assert "Missing key: 'key3'" in output assert "Extra key: 'key4'" in output def test_lint_output_format(self): """Test that lint output is properly formatted.""" truth = {"key1": "value1"} to_compare = {"key2": "value2"} with patch("sys.stdout", new=StringIO()) as fake_out: lint_translation_json("format.json", truth, to_compare) output = fake_out.getvalue() # Check that output starts with newline and linting message lines = output.strip().split("\n") assert "Linting format.json..." in lines[0] assert len(lines) >= 2 # At least linting message + errors class TestTranslationsEdgeCases: """Test suite for edge cases in translations module.""" def test_compare_with_numeric_keys(self): """Test structures with numeric keys (as strings).""" truth = {"1": "value1", "2": "value2"} to_compare = {"1": "value1", "2": "value2"} errors = compare_json_structures(truth, to_compare) assert errors == [] def test_compare_with_special_characters_in_keys(self): """Test keys with special characters.""" truth = {"key-1": "value", "key_2": "value", "key.3": "value"} to_compare = {"key-1": "value", "key_2": "value", "key.3": "value"} errors = compare_json_structures(truth, to_compare) assert errors == [] def test_compare_with_unicode_keys(self): """Test keys with unicode characters.""" truth = {"键": "value", "clé": "value", "مفتاح": "value"} to_compare = {"键": "value", "clé": "value", "مفتاح": "value"} errors = compare_json_structures(truth, to_compare) assert errors == [] def test_compare_very_deeply_nested(self): """Test very deeply nested structures.""" truth = {"a": {"b": {"c": {"d": {"e": {"f": "value"}}}}}} to_compare = {"a": {"b": {"c": {"d": {"e": {}}}}}} errors = compare_json_structures(truth, to_compare) assert len(errors) == 1 assert "a.b.c.d.e.f" in errors[0] def test_compare_with_empty_string_values(self): """Test structures with empty string values.""" truth = {"key1": "", "key2": "value"} to_compare = {"key1": "", "key2": "value"} errors = compare_json_structures(truth, to_compare) assert errors == [] def test_lint_with_empty_filename(self): """Test lint with empty filename.""" truth = {"key": "value"} to_compare = {"key": "value"} with patch("sys.stdout", new=StringIO()) as fake_out: lint_translation_json("", truth, to_compare) output = fake_out.getvalue() assert "Linting ..." in output def test_compare_preserves_error_order(self): """Test that errors are reported in a consistent order.""" truth = {"a": "1", "b": "2", "c": "3"} to_compare = {"d": "4", "e": "5"} errors = compare_json_structures(truth, to_compare) # Should have 2 extra keys and 3 missing keys assert len(errors) == 5 extra_errors = [e for e in errors if "Extra" in e] missing_errors = [e for e in errors if "Missing" in e] assert len(extra_errors) == 2 assert len(missing_errors) == 3 ================================================ FILE: backend/tests/test_user_session.py ================================================ async def test_user_session_set_get(mock_chainlit_context, user_session): async with mock_chainlit_context as context: # Test setting a value user_session.set("test_key", "test_value") # Test getting the value assert user_session.get("test_key") == "test_value" # Test getting a default value for a non-existent key assert user_session.get("non_existent_key", "default") == "default" # Test getting session-related values assert user_session.get("id") == context.session.id assert user_session.get("env") == context.session.user_env ================================================ FILE: backend/tests/test_utils.py ================================================ import os import tempfile from datetime import datetime, timezone from unittest.mock import AsyncMock, patch import click import pytest from chainlit.utils import ( check_file, check_module_version, make_module_getattr, timestamp_utc, utc_now, wrap_user_function, ) class TestUtcNow: """Test suite for utc_now function.""" def test_utc_now_returns_string(self): """Test that utc_now returns a string.""" result = utc_now() assert isinstance(result, str) def test_utc_now_ends_with_z(self): """Test that utc_now returns ISO format with Z suffix.""" result = utc_now() assert result.endswith("Z") def test_utc_now_is_iso_format(self): """Test that utc_now returns valid ISO format.""" result = utc_now() # Remove the Z and parse dt_str = result[:-1] # Should be parseable as ISO format datetime.fromisoformat(dt_str) def test_utc_now_is_current_time(self): """Test that utc_now returns approximately current time.""" before = datetime.now(timezone.utc).replace(tzinfo=None) result = utc_now() after = datetime.now(timezone.utc).replace(tzinfo=None) # Parse the result (naive datetime) result_dt = datetime.fromisoformat(result[:-1]) # Should be between before and after (with some tolerance for microseconds) assert ( before.replace(microsecond=0) <= result_dt <= after.replace(microsecond=0) or before <= result_dt <= after ) def test_utc_now_multiple_calls(self): """Test that multiple calls to utc_now return different values.""" result1 = utc_now() result2 = utc_now() # Results should be very close but might differ assert isinstance(result1, str) assert isinstance(result2, str) class TestTimestampUtc: """Test suite for timestamp_utc function.""" def test_timestamp_utc_returns_string(self): """Test that timestamp_utc returns a string.""" result = timestamp_utc(1234567890.0) assert isinstance(result, str) def test_timestamp_utc_ends_with_z(self): """Test that timestamp_utc returns ISO format with Z suffix.""" result = timestamp_utc(1234567890.0) assert result.endswith("Z") def test_timestamp_utc_converts_correctly(self): """Test that timestamp_utc converts timestamp correctly.""" # Known timestamp: 2009-02-13 23:31:30 UTC timestamp = 1234567890.0 result = timestamp_utc(timestamp) # Parse and verify dt = datetime.fromisoformat(result[:-1]) assert dt.year == 2009 assert dt.month == 2 assert dt.day == 13 def test_timestamp_utc_with_zero(self): """Test timestamp_utc with epoch (0).""" result = timestamp_utc(0.0) dt = datetime.fromisoformat(result[:-1]) assert dt.year == 1970 assert dt.month == 1 assert dt.day == 1 def test_timestamp_utc_with_fractional_seconds(self): """Test timestamp_utc with fractional seconds.""" timestamp = 1234567890.123456 result = timestamp_utc(timestamp) # Should be valid ISO format dt = datetime.fromisoformat(result[:-1]) assert isinstance(dt, datetime) def test_timestamp_utc_with_negative_timestamp(self): """Test timestamp_utc with negative timestamp (before epoch).""" # 1969-12-31 23:00:00 UTC timestamp = -3600.0 result = timestamp_utc(timestamp) dt = datetime.fromisoformat(result[:-1]) assert dt.year == 1969 @pytest.mark.asyncio class TestWrapUserFunction: """Test suite for wrap_user_function.""" async def test_wrap_user_function_with_sync_function(self, mock_chainlit_context): """Test wrapping a synchronous function.""" async with mock_chainlit_context: def user_func(a, b): return a + b wrapped = wrap_user_function(user_func) result = await wrapped(5, 3) assert result == 8 async def test_wrap_user_function_with_async_function(self, mock_chainlit_context): """Test wrapping an asynchronous function.""" async with mock_chainlit_context: async def user_func(x, y): return x * y wrapped = wrap_user_function(user_func) result = await wrapped(4, 7) assert result == 28 async def test_wrap_user_function_with_no_args(self, mock_chainlit_context): """Test wrapping a function with no arguments.""" async with mock_chainlit_context: def user_func(): return "hello" wrapped = wrap_user_function(user_func) result = await wrapped() assert result == "hello" async def test_wrap_user_function_with_task(self, mock_chainlit_context): """Test wrapping a function with task management.""" async with mock_chainlit_context as ctx: ctx.emitter.task_start = AsyncMock() ctx.emitter.task_end = AsyncMock() def user_func(value): return value * 2 wrapped = wrap_user_function(user_func, with_task=True) result = await wrapped(10) assert result == 20 ctx.emitter.task_start.assert_called_once() ctx.emitter.task_end.assert_called_once() async def test_wrap_user_function_handles_exception(self, mock_chainlit_context): """Test that wrapped function handles exceptions.""" async with mock_chainlit_context: def user_func(): raise ValueError("Test error") wrapped = wrap_user_function(user_func) result = await wrapped() # Should return None when exception occurs assert result is None async def test_wrap_user_function_with_task_handles_exception( self, mock_chainlit_context ): """Test that wrapped function with task handles exceptions.""" async with mock_chainlit_context as ctx: ctx.emitter.task_start = AsyncMock() ctx.emitter.task_end = AsyncMock() def user_func(): raise ValueError("Test error") with patch("chainlit.utils.logger") as mock_logger: wrapped = wrap_user_function(user_func, with_task=True) result = await wrapped() assert result is None ctx.emitter.task_start.assert_called_once() ctx.emitter.task_end.assert_called_once() mock_logger.exception.assert_called_once() async def test_wrap_user_function_preserves_function_metadata( self, mock_chainlit_context ): """Test that wrapping preserves function metadata.""" async with mock_chainlit_context: def user_func(a, b): """Test function docstring.""" return a + b wrapped = wrap_user_function(user_func) assert wrapped.__name__ == "user_func" assert wrapped.__doc__ == "Test function docstring." async def test_wrap_user_function_with_kwargs(self, mock_chainlit_context): """Test wrapping a function and calling with positional args.""" async with mock_chainlit_context: def user_func(x, y, z): return x + y + z wrapped = wrap_user_function(user_func) result = await wrapped(1, 2, 3) assert result == 6 class TestMakeModuleGetattr: """Test suite for make_module_getattr.""" def test_make_module_getattr_creates_function(self): """Test that make_module_getattr creates a function.""" registry = {"SomeClass": "some.module"} getattr_func = make_module_getattr(registry) assert callable(getattr_func) def test_make_module_getattr_imports_module(self): """Test that the created function imports modules.""" # Use a real module for testing registry = {"datetime": "datetime"} getattr_func = make_module_getattr(registry) result = getattr_func("datetime") assert result is datetime def test_make_module_getattr_with_nested_module(self): """Test with nested module path.""" registry = {"timezone": "datetime"} getattr_func = make_module_getattr(registry) result = getattr_func("timezone") assert result is timezone class TestCheckModuleVersion: """Test suite for check_module_version.""" def test_check_module_version_with_installed_module(self): """Test checking version of an installed module.""" # pytest should be installed result = check_module_version("pytest", "1.0.0") assert result is True def test_check_module_version_with_higher_required_version(self): """Test with a required version higher than installed.""" # Require an impossibly high version result = check_module_version("pytest", "999.0.0") assert result is False def test_check_module_version_with_nonexistent_module(self): """Test with a module that doesn't exist.""" result = check_module_version("nonexistent_module_xyz", "1.0.0") assert result is False def test_check_module_version_exact_match(self): """Test with exact version match.""" # Get actual pytest version result = check_module_version("pytest", pytest.__version__) assert result is True def test_check_module_version_with_builtin_module(self): """Test with a builtin module that has no __version__.""" # os module doesn't have __version__ with pytest.raises(AttributeError): check_module_version("os", "1.0.0") class TestCheckFile: """Test suite for check_file function.""" def test_check_file_with_valid_py_file(self): """Test check_file with a valid .py file.""" with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as f: temp_file = f.name try: # Should not raise any exception check_file(temp_file) finally: os.unlink(temp_file) def test_check_file_with_valid_py3_file(self): """Test check_file with a valid .py3 file.""" with tempfile.NamedTemporaryFile(suffix=".py3", delete=False) as f: temp_file = f.name try: # Should not raise any exception check_file(temp_file) finally: os.unlink(temp_file) def test_check_file_with_invalid_extension(self): """Test check_file with invalid file extension.""" with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as f: temp_file = f.name try: with pytest.raises(click.BadArgumentUsage) as exc_info: check_file(temp_file) assert ".txt" in str(exc_info.value) finally: os.unlink(temp_file) def test_check_file_with_no_extension(self): """Test check_file with file that has no extension.""" with tempfile.NamedTemporaryFile(suffix="", delete=False) as f: temp_file = f.name try: with pytest.raises(click.BadArgumentUsage) as exc_info: check_file(temp_file) assert "no extension" in str(exc_info.value) finally: os.unlink(temp_file) def test_check_file_with_nonexistent_file(self): """Test check_file with a file that doesn't exist.""" nonexistent_file = "/path/to/nonexistent/file.py" with pytest.raises(click.BadParameter) as exc_info: check_file(nonexistent_file) assert "does not exist" in str(exc_info.value) def test_check_file_with_json_extension(self): """Test check_file with .json extension.""" with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f: temp_file = f.name try: with pytest.raises(click.BadArgumentUsage) as exc_info: check_file(temp_file) assert ".json" in str(exc_info.value) finally: os.unlink(temp_file) class TestUtilsEdgeCases: """Test suite for utils edge cases.""" def test_utc_now_format_consistency(self): """Test that utc_now format is consistent across calls.""" results = [utc_now() for _ in range(5)] for result in results: # All should have same format assert result.endswith("Z") assert "T" in result # Should be parseable datetime.fromisoformat(result[:-1]) def test_timestamp_utc_with_large_timestamp(self): """Test timestamp_utc with very large timestamp (far future).""" # Year 2100 timestamp = 4102444800.0 result = timestamp_utc(timestamp) dt = datetime.fromisoformat(result[:-1]) assert dt.year == 2100 @pytest.mark.asyncio async def test_wrap_user_function_with_multiple_exceptions( self, mock_chainlit_context ): """Test wrapped function handles different exception types.""" async with mock_chainlit_context: exceptions = [ValueError("error1"), TypeError("error2"), KeyError("error3")] for exc in exceptions: def user_func(): raise exc with patch("chainlit.utils.logger"): wrapped = wrap_user_function(user_func) result = await wrapped() assert result is None def test_check_file_with_relative_path(self): """Test check_file with relative path.""" # Create a temp file in current directory with tempfile.NamedTemporaryFile(suffix=".py", delete=False, dir=".") as f: temp_file = os.path.basename(f.name) try: # Should work with relative path check_file(temp_file) finally: os.unlink(temp_file) def test_check_file_with_absolute_path(self): """Test check_file with absolute path.""" with tempfile.NamedTemporaryFile(suffix=".py", delete=False) as f: temp_file = os.path.abspath(f.name) try: # Should work with absolute path check_file(temp_file) finally: os.unlink(temp_file) def test_make_module_getattr_with_empty_registry(self): """Test make_module_getattr with empty registry.""" registry = {} getattr_func = make_module_getattr(registry) with pytest.raises(KeyError): getattr_func("nonexistent") @pytest.mark.asyncio async def test_wrap_user_function_with_default_args(self, mock_chainlit_context): """Test wrapping function with default arguments.""" async with mock_chainlit_context: def user_func(a, b=10): return a + b wrapped = wrap_user_function(user_func) # Call with only required arg result = await wrapped(5) assert result == 15 ================================================ FILE: cypress/e2e/action/main.py ================================================ import chainlit as cl @cl.action_callback("test action") async def on_test_action(): await cl.Message(content="Executed test action!").send() @cl.action_callback("removable action") async def on_removable_action(action: cl.Action): await cl.Message(content="Executed removable action!").send() await action.remove() @cl.action_callback("multiple actions") async def on_multiple_actions(action: cl.Action): await cl.Message(content=f"Action(id={action.id}) has been removed!").send() await action.remove() @cl.action_callback("all actions removed") async def on_all_actions_removed(_: cl.Action): await cl.Message(content="All actions have been removed!").send() to_remove = cl.user_session.get("to_remove") # type: cl.Message await to_remove.remove_actions() @cl.on_chat_start async def main(): actions = [ cl.Action(id="test-action", name="test action", payload={"value": "test"}), cl.Action( id="removable-action", name="removable action", payload={"value": "test"} ), cl.Action( id="label-action", name="label action", payload={"value": "test"}, label="Test Label", ), cl.Action( id="multiple-action-one", name="multiple actions", payload={"value": "multiple action one"}, label="multiple action one", ), cl.Action( id="multiple-action-two", name="multiple actions", payload={"value": "multiple action two"}, label="multiple action two", ), cl.Action( id="all-actions-removed", name="all actions removed", payload={"value": "test"}, ), ] message = cl.Message("Hello, this is a test message!", actions=actions) cl.user_session.set("to_remove", message) await message.send() result = await cl.AskActionMessage( content="Please, pick an action!", actions=[ cl.Action( id="first-action", name="first_action", payload={"value": "first-action"}, label="First action", ), cl.Action( id="second-action", name="second_action", payload={"value": "second-action"}, label="Second action", ), ], ).send() if result is not None: await cl.Message(f"Thanks for pressing: {result['payload']['value']}").send() ================================================ FILE: cypress/e2e/action/spec.cy.ts ================================================ describe('Action', () => { it('should correctly execute and display actions', () => { // Click on "first action" cy.get('#first-action').should('exist'); cy.get('#first-action').click(); cy.get('.step').should('have.length', 3); cy.get('.step') .eq(2) .should('contain', 'Thanks for pressing: first-action'); // Click on "test action" cy.get("[id='test-action']").should('exist'); cy.get("[id='test-action']").click(); cy.get('.step').should('have.length', 4); cy.get('.step').eq(3).should('contain', 'Executed test action!'); cy.get("[id='test-action']").should('exist'); // Click on "removable action" cy.get("[id='removable-action']").should('exist'); cy.get("[id='removable-action']").click(); cy.get('.step').should('have.length', 5); cy.get('.step').eq(4).should('contain', 'Executed removable action!'); cy.get("[id='removable-action']").should('not.exist'); cy.get('.step').should('have.length', 5); cy.get("[id='multiple-action-one']").should('exist'); cy.get("[id='multiple-action-one']").click(); cy.get('.step') .eq(5) .should('contain', 'Action(id=multiple-action-one) has been removed!'); cy.get("[id='multiple-action-one']").should('not.exist'); // Click on "multiple action two", should remove the correct action button cy.get('.step').should('have.length', 6); cy.get("[id='multiple-action-two']").click(); cy.get('.step') .eq(6) .should('contain', 'Action(id=multiple-action-two) has been removed!'); cy.get("[id='multiple-action-two']").should('not.exist'); // Click on "all actions removed", should remove all buttons cy.get("[id='all-actions-removed']").should('exist'); cy.get("[id='all-actions-removed']").click(); cy.get('.step').eq(7).should('contain', 'All actions have been removed!'); cy.get("[id='all-actions-removed']").should('not.exist'); cy.get("[id='test-action']").should('not.exist'); }); }); ================================================ FILE: cypress/e2e/ask_custom_element/main.py ================================================ import chainlit as cl @cl.on_chat_start async def on_start(): element = cl.CustomElement( name="JiraTicket", props={ "timeout": 20, "fields": [ {"id": "summary", "label": "Summary", "type": "text", "required": True}, {"id": "description", "label": "Description", "type": "textarea"}, { "id": "due", "label": "Due Date", "type": "date", }, { "id": "priority", "label": "Priority", "type": "select", "options": ["Low", "Medium", "High"], "value": "Medium", "required": True, }, ], }, ) res = await cl.AskElementMessage( content="Create a new Jira ticket:", element=element, timeout=10 ).send() if res and res.get("submitted"): await cl.Message( content=f"Ticket '{res['summary']}' with priority {res['priority']} submitted" ).send() ================================================ FILE: cypress/e2e/ask_custom_element/public/elements/JiraTicket.jsx ================================================ import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Textarea } from "@/components/ui/textarea"; import React, { useEffect, useMemo, useState } from 'react'; export default function JiraTicket() { const [timeLeft, setTimeLeft] = useState(props.timeout || 30); const [values, setValues] = useState(() => { const init = {}; (props.fields || []).forEach((f) => { init[f.id] = f.value || ''; }); return init; }); const allValid = useMemo(() => { if (!props.fields) return true; return props.fields.every((f) => { if (!f.required) return true; const val = values[f.id]; return val !== undefined && val !== ''; }); }, [props.fields, values]); useEffect(() => { const interval = setInterval(() => { setTimeLeft((t) => (t > 0 ? t - 1 : 0)); }, 1000); return () => clearInterval(interval); }, []); const handleChange = (id, val) => { setValues((v) => ({ ...v, [id]: val })); }; const renderField = (field) => { const value = values[field.id]; switch (field.type) { case 'textarea': return