From 8da9ad2503f174e6a36631141a2c596a55fd6c4c Mon Sep 17 00:00:00 2001 From: Mervin Praison Date: Tue, 27 Aug 2024 11:32:00 +0100 Subject: [PATCH 01/45] Adding CHAINLIT_APP_ROOT to modify APP_ROOT (#1259) --- CHANGELOG.md | 1 + backend/chainlit/config.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9095758d7..913eeeeec5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ### Changed +- Adding `CHAINLIT_APP_ROOT` Environment Variable to modify `APP_ROOT`, enabling the ability to set the location of config.toml and other setting files. - changing the default host from 0.0.0.0 to 127.0.0.1 ## [1.1.403rc0] - 2024-08-13 diff --git a/backend/chainlit/config.py b/backend/chainlit/config.py index 5159263f96..e2523a9781 100644 --- a/backend/chainlit/config.py +++ b/backend/chainlit/config.py @@ -29,7 +29,7 @@ # Get the directory the script is running from -APP_ROOT = os.getcwd() +APP_ROOT = os.getenv("CHAINLIT_APP_ROOT", os.getcwd()) # Create the directory to store the uploaded files FILES_DIRECTORY = Path(APP_ROOT) / ".files" From 86798bca7332710b2aefb95be40ba4d8848917be Mon Sep 17 00:00:00 2001 From: Quy Tang <3761730+qtangs@users.noreply.github.com> Date: Wed, 28 Aug 2024 17:16:44 +0800 Subject: [PATCH 02/45] Resolve #828: frontend connection resume after connection loss Update websocket's thread id header with currentThreadId to ensure session continuation after backend restart. --- cypress/e2e/data_layer/main.py | 32 ++++++++++-- cypress/e2e/data_layer/spec.cy.ts | 67 ++++++++++++++++++++++++- libs/react-client/src/useChatSession.ts | 14 +++++- 3 files changed, 104 insertions(+), 9 deletions(-) diff --git a/cypress/e2e/data_layer/main.py b/cypress/e2e/data_layer/main.py index 21c682e836..e1752215fc 100644 --- a/cypress/e2e/data_layer/main.py +++ b/cypress/e2e/data_layer/main.py @@ -1,6 +1,9 @@ +import os.path +import pickle from typing import Dict, List, Optional import chainlit.data as cl_data +from chainlit.socket import persist_user_session from chainlit.step import StepDict from literalai.helper import utc_now @@ -8,9 +11,6 @@ now = utc_now() -create_step_counter = 0 - - thread_history = [ { "id": "test1", @@ -61,6 +61,22 @@ ] # type: List[cl_data.ThreadDict] deleted_thread_ids = [] # type: List[str] +THREAD_HISTORY_PICKLE_PATH = os.getenv("THREAD_HISTORY_PICKLE_PATH") +if THREAD_HISTORY_PICKLE_PATH and os.path.exists(THREAD_HISTORY_PICKLE_PATH): + with open(THREAD_HISTORY_PICKLE_PATH, "rb") as f: + thread_history = pickle.load(f) + + +async def save_thread_history(): + if THREAD_HISTORY_PICKLE_PATH: + # Force saving of thread history for reload when server restarts + await persist_user_session( + cl.context.session.thread_id, cl.context.session.to_persistable() + ) + + with open(THREAD_HISTORY_PICKLE_PATH, "wb") as out_file: + pickle.dump(thread_history, out_file) + class TestDataLayer(cl_data.BaseDataLayer): async def get_user(self, identifier: str): @@ -101,8 +117,9 @@ async def update_thread( @cl_data.queue_until_user_message() async def create_step(self, step_dict: StepDict): - global create_step_counter - create_step_counter += 1 + cl.user_session.set( + "create_step_counter", cl.user_session.get("create_step_counter") + 1 + ) thread = next( (t for t in thread_history if t["id"] == step_dict.get("threadId")), None @@ -138,11 +155,14 @@ async def delete_thread(self, thread_id: str): async def send_count(): + create_step_counter = cl.user_session.get("create_step_counter") await cl.Message(f"Create step counter: {create_step_counter}").send() @cl.on_chat_start async def main(): + # Add step counter to session so that it is saved in thread metadata + cl.user_session.set("create_step_counter", 0) await cl.Message("Hello, send me a message!").send() await send_count() @@ -157,6 +177,8 @@ async def handle_message(): await cl.Message("Ok!").send() await send_count() + await save_thread_history() + @cl.password_auth_callback def auth_callback(username: str, password: str) -> Optional[cl.User]: diff --git a/cypress/e2e/data_layer/spec.cy.ts b/cypress/e2e/data_layer/spec.cy.ts index 4f16e8dc8b..0320e89e98 100644 --- a/cypress/e2e/data_layer/spec.cy.ts +++ b/cypress/e2e/data_layer/spec.cy.ts @@ -1,4 +1,7 @@ +import { sep } from 'path'; + import { runTestServer, submitMessage } from '../../support/testUtils'; +import { ExecutionMode } from '../../support/utils'; function login() { cy.get("[id='email']").type('admin'); @@ -71,9 +74,50 @@ function resumeThread() { cy.get('.step').eq(8).should('contain', 'chat_profile'); } +function restartServer( + mode: ExecutionMode = undefined, + env?: Record +) { + const pathItems = Cypress.spec.absolute.split(sep); + const testName = pathItems[pathItems.length - 2]; + cy.exec(`pnpm exec ts-node ./cypress/support/run.ts ${testName} ${mode}`, { + env + }); +} + +function continueThread() { + cy.get('.step').eq(7).should('contain', 'Welcome back to Hello'); + + submitMessage('Hello after restart'); + + // Verify that new step counter messages have been added + cy.get('.step').eq(11).should('contain', 'Create step counter: 14'); + cy.get('.step').eq(14).should('contain', 'Create step counter: 17'); +} + +function newThread() { + cy.get('#new-chat-button').click(); + cy.get('#confirm').click(); +} + describe('Data Layer', () => { - before(() => { - runTestServer(); + beforeEach(() => { + // Set up the thread history file + const pathItems = Cypress.spec.absolute.split(sep); + pathItems[pathItems.length - 1] = 'thread_history.pickle'; + const threadHistoryFile = pathItems.join(sep); + cy.wrap(threadHistoryFile).as('threadHistoryFile'); + + runTestServer(undefined, { + THREAD_HISTORY_PICKLE_PATH: threadHistoryFile + }); + }); + + afterEach(() => { + cy.get('@threadHistoryFile').then((threadHistoryFile) => { + // Clean up the thread history file + cy.exec(`rm ${threadHistoryFile}`); + }); }); describe('Data Features with persistence', () => { @@ -84,5 +128,24 @@ describe('Data Layer', () => { threadList(); resumeThread(); }); + + it('should continue the thread after backend restarts and work with new thread as usual', () => { + login(); + feedback(); + threadQueue(); + + cy.get('@threadHistoryFile').then((threadHistoryFile) => { + restartServer(undefined, { + THREAD_HISTORY_PICKLE_PATH: `${threadHistoryFile}` + }); + }); + // Continue the thread and verify that the step counter is not reset + continueThread(); + + // Create a new thread and verify that the step counter is reset + newThread(); + feedback(); + threadQueue(); + }); }); }); diff --git a/libs/react-client/src/useChatSession.ts b/libs/react-client/src/useChatSession.ts index 6ff4e77bb8..ffa817c586 100644 --- a/libs/react-client/src/useChatSession.ts +++ b/libs/react-client/src/useChatSession.ts @@ -1,5 +1,5 @@ import { debounce } from 'lodash'; -import { useCallback, useContext } from 'react'; +import { useCallback, useContext, useEffect } from 'react'; import { useRecoilState, useRecoilValue, @@ -63,7 +63,17 @@ const useChatSession = () => { const setTokenCount = useSetRecoilState(tokenCountState); const [chatProfile, setChatProfile] = useRecoilState(chatProfileState); const idToResume = useRecoilValue(threadIdToResumeState); - const setCurrentThreadId = useSetRecoilState(currentThreadIdState); + const [currentThreadId, setCurrentThreadId] = + useRecoilState(currentThreadIdState); + + // Use currentThreadId as thread id in websocket header + useEffect(() => { + if (session?.socket) { + session.socket.io.opts.extraHeaders!['X-Chainlit-Thread-Id'] = + currentThreadId || ''; + } + }, [currentThreadId]); + const _connect = useCallback( ({ userEnv, From 48502a51204106b1ef0ea1b55c40357377ee2989 Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Mon, 2 Sep 2024 19:38:12 +0100 Subject: [PATCH 03/45] Various improvements in tests/CI (#1271) * Run unit tests with all supported Python versions. * Caching of Python deps in CI. Special care taken of https://github.com/python-poetry/poetry/issues/2117 * Local action for DRY Python/Poetry install with caching. --- .../actions/poetry-python-install/action.yaml | 46 +++++++++++++++++++ .github/workflows/e2e-tests.yaml | 18 ++++---- .github/workflows/mypy.yaml | 20 ++++---- .github/workflows/pytest.yaml | 25 +++++----- 4 files changed, 77 insertions(+), 32 deletions(-) create mode 100644 .github/actions/poetry-python-install/action.yaml diff --git a/.github/actions/poetry-python-install/action.yaml b/.github/actions/poetry-python-install/action.yaml new file mode 100644 index 0000000000..c65fb3f791 --- /dev/null +++ b/.github/actions/poetry-python-install/action.yaml @@ -0,0 +1,46 @@ +name: 'Install Python, poetry and dependencies.' +description: 'Install Python, Poetry and poetry dependencies using cache' + +inputs: + python-version: + description: 'Python version' + required: true + poetry-version: + description: 'Poetry version' + required: true + poetry-working-directory: + description: 'Working directory for poetry command.' + required: false + default: '.' + poetry-install-args: + description: 'Extra arguments for poetry install, e.g. --with tests.' + required: false + +defaults: + run: + shell: bash + +runs: + using: 'composite' + steps: + - name: Cache poetry install + uses: actions/cache@v2 + with: + path: ~/.local + key: poetry-${{ runner.os }}-${{ inputs.poetry-version }}-0 + - name: Install Poetry + run: pipx install 'poetry==${{ inputs.poetry-version }}' + shell: bash + - name: Set up Python ${{ inputs.python-version }} + id: setup_python + uses: actions/setup-python@v4 + with: + python-version: ${{ inputs.python-version }} + cache: poetry + cache-dependency-path: ${{ inputs.poetry-working-directory }}/poetry.lock + - name: Set Poetry environment + run: poetry -C '${{ inputs.poetry-working-directory }}' env use '${{ steps.setup_python.outputs.python-path }}' + shell: bash + - name: Install Python dependencies + run: poetry -C '${{ inputs.poetry-working-directory }}' install ${{ inputs.poetry-install-args }} + shell: bash diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index f6d26de166..ad83e94f54 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -8,31 +8,31 @@ jobs: strategy: matrix: os: [ubuntu-latest, windows-latest] + env: + BACKEND_DIR: ./backend steps: - uses: actions/checkout@v3 - uses: pnpm/action-setup@v2 with: version: 8.6.9 + - uses: ./.github/actions/poetry-python-install + name: Install Python, poetry and Python dependencies + with: + python-version: 3.9 + poetry-version: 1.8.3 + poetry-working-directory: ${{ env.BACKEND_DIR }} + poetry-install-args: --with tests - name: Use Node.js 16.15.0 uses: actions/setup-node@v3 with: node-version: '16.15.0' cache: 'pnpm' - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.9' - cache: 'pip' - - name: Install Poetry - run: pip install poetry - name: Install JS dependencies run: pnpm install --no-frozen-lockfile - name: Build UI run: pnpm run buildUi - name: Lint UI run: pnpm run lintUi - - name: Install Python dependencies - run: poetry install -C ./backend --with tests - name: Run tests env: CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }} diff --git a/.github/workflows/mypy.yaml b/.github/workflows/mypy.yaml index a0cd334b27..a33b2f4bd8 100644 --- a/.github/workflows/mypy.yaml +++ b/.github/workflows/mypy.yaml @@ -5,19 +5,17 @@ on: [workflow_call] jobs: mypy: runs-on: ubuntu-latest - defaults: - run: - working-directory: ./backend + env: + BACKEND_DIR: ./backend steps: - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 + - uses: ./.github/actions/poetry-python-install + name: Install Python, poetry and Python dependencies with: - python-version: '3.9' - cache: 'pip' - - name: Install Poetry - run: pip install poetry - - name: Install dependencies - run: poetry install --with tests --with mypy --with custom-data + python-version: 3.9 + poetry-version: 1.8.3 + poetry-install-args: --with tests --with mypy --with custom-data + poetry-working-directory: ${{ env.BACKEND_DIR }} - name: Run Mypy run: poetry run mypy chainlit/ + working-directory: ${{ env.BACKEND_DIR }} diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index d8b68c7f05..3b9851a279 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -3,21 +3,22 @@ name: Pytest on: [workflow_call] jobs: - mypy: + pytest: runs-on: ubuntu-latest - defaults: - run: - working-directory: ./backend + strategy: + matrix: + python-version: ['3.9', '3.10', '3.11', '3.12'] + env: + BACKEND_DIR: ./backend steps: - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 + - uses: ./.github/actions/poetry-python-install + name: Install Python, poetry and Python dependencies with: - python-version: '3.9' - cache: 'pip' - - name: Install Poetry - run: pip install poetry - - name: Install dependencies - run: poetry install --with tests --with mypy --with custom-data + python-version: ${{ matrix.python-version }} + poetry-version: 1.8.3 + poetry-install-args: --with tests --with mypy --with custom-data + poetry-working-directory: ${{ env.BACKEND_DIR }} - name: Run Pytest run: poetry run pytest --cov=chainlit/ + working-directory: ${{ env.BACKEND_DIR }} From 88f767972283b48ebfe365e3bb0bca94bac6a29b Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Mon, 2 Sep 2024 19:49:20 +0100 Subject: [PATCH 04/45] Data layer refactor/cleanup (#1277) * Factor out data layer. * Make BaseDataLayer an Abstract Base Class, remove unused delete_user_session(). * Change BaseStorageClient from Protocol into Abstract Base Class. * Reduce imports in base class. * f-string without placeholders * Fix typing thread_queues. * Rename ChainlitDataLayer to LiteralDataLayer for consistency. * Various fixups in data layer and associated e2e tests. --- backend/chainlit/data/__init__.py | 527 +---------------------- backend/chainlit/data/base.py | 121 ++++++ backend/chainlit/data/dynamodb.py | 7 +- backend/chainlit/data/literalai.py | 395 +++++++++++++++++ backend/chainlit/data/sql_alchemy.py | 14 +- backend/chainlit/data/storage_clients.py | 84 +++- backend/chainlit/data/utils.py | 29 ++ backend/chainlit/session.py | 5 +- cypress/e2e/data_layer/main.py | 61 ++- 9 files changed, 686 insertions(+), 557 deletions(-) create mode 100644 backend/chainlit/data/base.py create mode 100644 backend/chainlit/data/literalai.py create mode 100644 backend/chainlit/data/utils.py diff --git a/backend/chainlit/data/__init__.py b/backend/chainlit/data/__init__.py index 21512f71c5..c059ff40e4 100644 --- a/backend/chainlit/data/__init__.py +++ b/backend/chainlit/data/__init__.py @@ -1,534 +1,19 @@ -import functools -import json import os -from collections import deque -from typing import ( - TYPE_CHECKING, - Any, - Dict, - List, - Literal, - Optional, - Protocol, - Union, - cast, -) +from typing import Optional -import aiofiles -from chainlit.context import context -from chainlit.logger import logger -from chainlit.session import WebsocketSession -from chainlit.types import ( - Feedback, - PageInfo, - PaginatedResponse, - Pagination, - ThreadDict, - ThreadFilter, +from .base import BaseDataLayer +from .literalai import LiteralDataLayer +from .utils import ( + queue_until_user_message as queue_until_user_message, # TODO: Consider deprecating re-export.; Redundant alias tells type checkers to STFU. ) -from chainlit.user import PersistedUser, User -from httpx import HTTPStatusError, RequestError -from literalai import Attachment -from literalai import Score as LiteralScore -from literalai import Step as LiteralStep -from literalai.filter import threads_filters as LiteralThreadsFilters -from literalai.step import StepDict as LiteralStepDict - -if TYPE_CHECKING: - from chainlit.element import Element, ElementDict - from chainlit.step import FeedbackDict, StepDict - - -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 - - -class BaseDataLayer: - """Base class for data persistence.""" - - async def get_user(self, identifier: str) -> Optional["PersistedUser"]: - return None - - async def create_user(self, user: "User") -> Optional["PersistedUser"]: - pass - - async def delete_feedback( - self, - feedback_id: str, - ) -> bool: - return True - - async def upsert_feedback( - self, - feedback: Feedback, - ) -> str: - return "" - - @queue_until_user_message() - async def create_element(self, element: "Element"): - pass - - async def get_element( - self, thread_id: str, element_id: str - ) -> Optional["ElementDict"]: - pass - - @queue_until_user_message() - async def delete_element(self, element_id: str, thread_id: Optional[str] = None): - pass - - @queue_until_user_message() - async def create_step(self, step_dict: "StepDict"): - pass - - @queue_until_user_message() - async def update_step(self, step_dict: "StepDict"): - pass - - @queue_until_user_message() - async def delete_step(self, step_id: str): - pass - - async def get_thread_author(self, thread_id: str) -> str: - return "" - - async def delete_thread(self, thread_id: str): - pass - - async def list_threads( - self, pagination: "Pagination", filters: "ThreadFilter" - ) -> "PaginatedResponse[ThreadDict]": - return PaginatedResponse( - data=[], - pageInfo=PageInfo(hasNextPage=False, startCursor=None, endCursor=None), - ) - - async def get_thread(self, thread_id: str) -> "Optional[ThreadDict]": - 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, - ): - pass - - async def delete_user_session(self, id: str) -> bool: - return True - - async def build_debug_url(self) -> str: - return "" - _data_layer: Optional[BaseDataLayer] = None -class ChainlitDataLayer(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") - - def attachment_to_element_dict(self, attachment: Attachment) -> "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"), - "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, - } - - def score_to_feedback_dict( - self, 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, - } - - def step_to_step_dict(self, 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": self.score_to_feedback_dict(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), - "indent": metadata.get("indent"), - "language": metadata.get("language"), - "isError": bool(step.error), - "waitForAnswer": metadata.get("waitForAnswer", False), - } - - 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), - } - - 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 = await f.read() # type: Union[bytes, str] - 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 self.attachment_to_element_dict(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"}, - ) - return PaginatedResponse( - pageInfo=PageInfo( - hasNextPage=literal_response.pageInfo.hasNextPage, - startCursor=literal_response.pageInfo.startCursor, - endCursor=literal_response.pageInfo.endCursor, - ), - data=literal_response.data, - ) - - async def get_thread(self, thread_id: str) -> "Optional[ThreadDict]": - from chainlit.step import check_add_step_in_cot, stub_step - - 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(self.attachment_to_element_dict(attachment)) - - if check_add_step_in_cot(step): - steps.append(self.step_to_step_dict(step)) - else: - steps.append(stub_step(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, - ) - - -class BaseStorageClient(Protocol): - """Base class for non-text data persistence like Azure Data Lake, S3, Google Storage, etc.""" - - async def upload_file( - self, - object_key: str, - data: Union[bytes, str], - mime: str = "application/octet-stream", - overwrite: bool = True, - ) -> Dict[str, Any]: - pass - - if api_key := os.environ.get("LITERAL_API_KEY"): # support legacy LITERAL_SERVER variable as fallback server = os.environ.get("LITERAL_API_URL") or os.environ.get("LITERAL_SERVER") - _data_layer = ChainlitDataLayer(api_key=api_key, server=server) + _data_layer = LiteralDataLayer(api_key=api_key, server=server) def get_data_layer(): diff --git a/backend/chainlit/data/base.py b/backend/chainlit/data/base.py new file mode 100644 index 0000000000..d34eaaa899 --- /dev/null +++ b/backend/chainlit/data/base.py @@ -0,0 +1,121 @@ +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union + +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 + + +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, + ) -> Dict[str, Any]: + pass diff --git a/backend/chainlit/data/dynamodb.py b/backend/chainlit/data/dynamodb.py index 0b63614318..ec0f1418fa 100644 --- a/backend/chainlit/data/dynamodb.py +++ b/backend/chainlit/data/dynamodb.py @@ -12,7 +12,8 @@ import boto3 # type: ignore from boto3.dynamodb.types import TypeDeserializer, TypeSerializer from chainlit.context import context -from chainlit.data import BaseDataLayer, BaseStorageClient, queue_until_user_message +from chainlit.data.base import BaseDataLayer, 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 @@ -36,7 +37,6 @@ class DynamoDBDataLayer(BaseDataLayer): - def __init__( self, table_name: str, @@ -579,8 +579,5 @@ async def update_thread( updates=item, ) - async def delete_user_session(self, id: str) -> bool: - return True # Not sure why documentation wants this - async def build_debug_url(self) -> str: return "" diff --git a/backend/chainlit/data/literalai.py b/backend/chainlit/data/literalai.py new file mode 100644 index 0000000000..5572dbf4a9 --- /dev/null +++ b/backend/chainlit/data/literalai.py @@ -0,0 +1,395 @@ +import json +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union, cast + +import aiofiles +from chainlit.data.base import BaseDataLayer +from chainlit.data.utils import queue_until_user_message +from chainlit.logger import logger +from chainlit.types import ( + Feedback, + PageInfo, + PaginatedResponse, + Pagination, + ThreadDict, + ThreadFilter, +) +from chainlit.user import PersistedUser, User +from httpx import HTTPStatusError, RequestError +from literalai import Attachment +from literalai import Score as LiteralScore +from literalai import Step as LiteralStep +from literalai.filter import threads_filters as LiteralThreadsFilters +from literalai.step import StepDict as LiteralStepDict + +if TYPE_CHECKING: + from chainlit.element import Element, ElementDict + from chainlit.step import FeedbackDict, StepDict + + +_data_layer: Optional[BaseDataLayer] = None + + +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") + + def attachment_to_element_dict(self, attachment: Attachment) -> "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"), + "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, + } + + def score_to_feedback_dict( + self, 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, + } + + def step_to_step_dict(self, 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": self.score_to_feedback_dict(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), + "indent": metadata.get("indent"), + "language": metadata.get("language"), + "isError": bool(step.error), + "waitForAnswer": metadata.get("waitForAnswer", False), + } + + 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), + } + + 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 self.attachment_to_element_dict(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"}, + ) + return PaginatedResponse( + pageInfo=PageInfo( + hasNextPage=literal_response.pageInfo.hasNextPage, + startCursor=literal_response.pageInfo.startCursor, + endCursor=literal_response.pageInfo.endCursor, + ), + data=literal_response.data, + ) + + async def get_thread(self, thread_id: str) -> "Optional[ThreadDict]": + from chainlit.step import check_add_step_in_cot, stub_step + + 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(self.attachment_to_element_dict(attachment)) + + if check_add_step_in_cot(step): + steps.append(self.step_to_step_dict(step)) + else: + steps.append(stub_step(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, + ) diff --git a/backend/chainlit/data/sql_alchemy.py b/backend/chainlit/data/sql_alchemy.py index 024eed6141..d72ad985df 100644 --- a/backend/chainlit/data/sql_alchemy.py +++ b/backend/chainlit/data/sql_alchemy.py @@ -8,7 +8,8 @@ import aiofiles import aiohttp from chainlit.context import context -from chainlit.data import BaseDataLayer, BaseStorageClient, queue_until_user_message +from chainlit.data.base import BaseDataLayer, 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 @@ -54,7 +55,9 @@ def __init__( self.engine: AsyncEngine = create_async_engine( self._conninfo, connect_args=ssl_args ) - self.async_session = sessionmaker(bind=self.engine, expire_on_commit=False, class_=AsyncSession) # type: ignore + 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: @@ -378,7 +381,7 @@ async def create_element(self, element: "Element"): raise ValueError("No authenticated user in context") if not self.storage_provider: logger.warn( - f"SQLAlchemy: create_element error. No blob_storage_client is configured!" + "SQLAlchemy: create_element error. No blob_storage_client is configured!" ) return if not element.for_id: @@ -440,15 +443,12 @@ async def delete_element(self, element_id: str, thread_id: Optional[str] = None) parameters = {"id": element_id} await self.execute_sql(query=query, parameters=parameters) - async def delete_user_session(self, id: str) -> bool: - return False # Not sure why documentation wants this - 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(f"SQLAlchemy: get_all_user_threads") + logger.info("SQLAlchemy: get_all_user_threads") user_threads_query = """ SELECT "id" AS thread_id, diff --git a/backend/chainlit/data/storage_clients.py b/backend/chainlit/data/storage_clients.py index 7242b76e00..42d5f5e40a 100644 --- a/backend/chainlit/data/storage_clients.py +++ b/backend/chainlit/data/storage_clients.py @@ -1,11 +1,22 @@ -from chainlit.data import BaseStorageClient +from typing import TYPE_CHECKING, Any, Dict, Optional, Union + +import boto3 # type: ignore +from azure.storage.filedatalake import ( + ContentSettings, + DataLakeFileClient, + DataLakeServiceClient, + FileSystemClient, +) +from chainlit.data.base import BaseStorageClient from chainlit.logger import logger -from typing import TYPE_CHECKING, Optional, Dict, Union, Any -from azure.storage.filedatalake import DataLakeServiceClient, FileSystemClient, DataLakeFileClient, ContentSettings -import boto3 # type: ignore if TYPE_CHECKING: - from azure.core.credentials import AzureNamedKeyCredential, AzureSasCredential, TokenCredential + from azure.core.credentials import ( + AzureNamedKeyCredential, + AzureSasCredential, + TokenCredential, + ) + class AzureStorageClient(BaseStorageClient): """ @@ -16,30 +27,65 @@ class AzureStorageClient(BaseStorageClient): 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): + + 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.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.warn(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) -> Dict[str, Any]: + + async def upload_file( + self, + object_key: str, + data: Union[bytes, str], + mime: str = "application/octet-stream", + overwrite: bool = True, + ) -> Dict[str, Any]: try: - file_client: DataLakeFileClient = self.container_client.get_file_client(object_key) + file_client: DataLakeFileClient = self.container_client.get_file_client( + object_key + ) content_settings = ContentSettings(content_type=mime) - 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 + 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.warn(f"AzureStorageClient, upload_file error: {e}") return {} + class S3StorageClient(BaseStorageClient): """ Class to enable Amazon S3 storage provider """ + def __init__(self, bucket: str): try: self.bucket = bucket @@ -48,9 +94,17 @@ def __init__(self, bucket: str): except Exception as e: logger.warn(f"S3StorageClient initialization error: {e}") - async def upload_file(self, object_key: str, data: Union[bytes, str], mime: str = 'application/octet-stream', overwrite: bool = True) -> Dict[str, Any]: + async def upload_file( + self, + object_key: str, + data: Union[bytes, str], + mime: str = "application/octet-stream", + overwrite: bool = True, + ) -> Dict[str, Any]: try: - self.client.put_object(Bucket=self.bucket, Key=object_key, Body=data, ContentType=mime) + self.client.put_object( + Bucket=self.bucket, Key=object_key, Body=data, ContentType=mime + ) url = f"https://{self.bucket}.s3.amazonaws.com/{object_key}" return {"object_key": object_key, "url": url} except Exception as e: diff --git a/backend/chainlit/data/utils.py b/backend/chainlit/data/utils.py new file mode 100644 index 0000000000..23dc329b5d --- /dev/null +++ b/backend/chainlit/data/utils.py @@ -0,0 +1,29 @@ +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 diff --git a/backend/chainlit/session.py b/backend/chainlit/session.py index f23e39ca40..f73a635558 100644 --- a/backend/chainlit/session.py +++ b/backend/chainlit/session.py @@ -193,6 +193,9 @@ def delete(self): shutil.rmtree(self.files_dir) +ThreadQueue = Deque[tuple[Callable, object, tuple, Dict]] + + class WebsocketSession(BaseSession): """Internal web socket session object. @@ -250,7 +253,7 @@ def __init__( self.restored = False - self.thread_queues = {} # type: Dict[str, Deque[Callable]] + self.thread_queues: Dict[str, ThreadQueue] = {} ws_sessions_id[self.id] = self ws_sessions_sid[socket_id] = self diff --git a/cypress/e2e/data_layer/main.py b/cypress/e2e/data_layer/main.py index e1752215fc..6fa6dc7f07 100644 --- a/cypress/e2e/data_layer/main.py +++ b/cypress/e2e/data_layer/main.py @@ -3,8 +3,19 @@ from typing import Dict, List, Optional import chainlit.data as cl_data +from chainlit.data.utils import queue_until_user_message +from chainlit.element import Element, ElementDict from chainlit.socket import persist_user_session from chainlit.step import StepDict +from chainlit.types import ( + Feedback, + PageInfo, + PaginatedResponse, + Pagination, + ThreadDict, + ThreadFilter, +) +from chainlit.user import PersistedUser, User from literalai.helper import utc_now import chainlit as cl @@ -58,7 +69,7 @@ }, ], }, -] # type: List[cl_data.ThreadDict] +] # type: List[ThreadDict] deleted_thread_ids = [] # type: List[str] THREAD_HISTORY_PICKLE_PATH = os.getenv("THREAD_HISTORY_PICKLE_PATH") @@ -131,13 +142,11 @@ async def get_thread_author(self, thread_id: str): return "admin" async def list_threads( - self, pagination: cl_data.Pagination, filters: cl_data.ThreadFilter - ) -> cl_data.PaginatedResponse[cl_data.ThreadDict]: - return cl_data.PaginatedResponse( + self, pagination: Pagination, filters: ThreadFilter + ) -> PaginatedResponse[ThreadDict]: + return PaginatedResponse( data=[t for t in thread_history if t["id"] not in deleted_thread_ids], - pageInfo=cl_data.PageInfo( - hasNextPage=False, startCursor=None, endCursor=None - ), + pageInfo=PageInfo(hasNextPage=False, startCursor=None, endCursor=None), ) async def get_thread(self, thread_id: str): @@ -150,6 +159,42 @@ async def get_thread(self, thread_id: str): async def delete_thread(self, thread_id: str): deleted_thread_ids.append(thread_id) + async def delete_feedback( + self, + feedback_id: str, + ) -> bool: + return True + + async def upsert_feedback( + self, + feedback: Feedback, + ) -> str: + return "" + + @queue_until_user_message() + async def create_element(self, element: "Element"): + pass + + async def get_element( + self, thread_id: str, element_id: str + ) -> Optional["ElementDict"]: + pass + + @queue_until_user_message() + async def delete_element(self, element_id: str, thread_id: Optional[str] = None): + pass + + @queue_until_user_message() + async def update_step(self, step_dict: "StepDict"): + pass + + @queue_until_user_message() + async def delete_step(self, step_id: str): + pass + + async def build_debug_url(self) -> str: + return "" + cl_data._data_layer = TestDataLayer() @@ -189,7 +234,7 @@ def auth_callback(username: str, password: str) -> Optional[cl.User]: @cl.on_chat_resume -async def on_chat_resume(thread: cl_data.ThreadDict): +async def on_chat_resume(thread: ThreadDict): await cl.Message(f"Welcome back to {thread['name']}").send() if "metadata" in thread: await cl.Message(thread["metadata"], author="metadata", language="json").send() From 37148c283c0a988b12a030fbcf23f5000dab6929 Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Tue, 3 Sep 2024 10:11:00 +0100 Subject: [PATCH 05/45] Revert "Data layer refactor/cleanup (#1277)" (#1287) This reverts commit 88f767972283b48ebfe365e3bb0bca94bac6a29b. --- backend/chainlit/data/__init__.py | 527 ++++++++++++++++++++++- backend/chainlit/data/base.py | 121 ------ backend/chainlit/data/dynamodb.py | 7 +- backend/chainlit/data/literalai.py | 395 ----------------- backend/chainlit/data/sql_alchemy.py | 14 +- backend/chainlit/data/storage_clients.py | 84 +--- backend/chainlit/data/utils.py | 29 -- backend/chainlit/session.py | 5 +- cypress/e2e/data_layer/main.py | 61 +-- 9 files changed, 557 insertions(+), 686 deletions(-) delete mode 100644 backend/chainlit/data/base.py delete mode 100644 backend/chainlit/data/literalai.py delete mode 100644 backend/chainlit/data/utils.py diff --git a/backend/chainlit/data/__init__.py b/backend/chainlit/data/__init__.py index c059ff40e4..21512f71c5 100644 --- a/backend/chainlit/data/__init__.py +++ b/backend/chainlit/data/__init__.py @@ -1,19 +1,534 @@ +import functools +import json import os -from typing import Optional +from collections import deque +from typing import ( + TYPE_CHECKING, + Any, + Dict, + List, + Literal, + Optional, + Protocol, + Union, + cast, +) -from .base import BaseDataLayer -from .literalai import LiteralDataLayer -from .utils import ( - queue_until_user_message as queue_until_user_message, # TODO: Consider deprecating re-export.; Redundant alias tells type checkers to STFU. +import aiofiles +from chainlit.context import context +from chainlit.logger import logger +from chainlit.session import WebsocketSession +from chainlit.types import ( + Feedback, + PageInfo, + PaginatedResponse, + Pagination, + ThreadDict, + ThreadFilter, ) +from chainlit.user import PersistedUser, User +from httpx import HTTPStatusError, RequestError +from literalai import Attachment +from literalai import Score as LiteralScore +from literalai import Step as LiteralStep +from literalai.filter import threads_filters as LiteralThreadsFilters +from literalai.step import StepDict as LiteralStepDict + +if TYPE_CHECKING: + from chainlit.element import Element, ElementDict + from chainlit.step import FeedbackDict, StepDict + + +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 + + +class BaseDataLayer: + """Base class for data persistence.""" + + async def get_user(self, identifier: str) -> Optional["PersistedUser"]: + return None + + async def create_user(self, user: "User") -> Optional["PersistedUser"]: + pass + + async def delete_feedback( + self, + feedback_id: str, + ) -> bool: + return True + + async def upsert_feedback( + self, + feedback: Feedback, + ) -> str: + return "" + + @queue_until_user_message() + async def create_element(self, element: "Element"): + pass + + async def get_element( + self, thread_id: str, element_id: str + ) -> Optional["ElementDict"]: + pass + + @queue_until_user_message() + async def delete_element(self, element_id: str, thread_id: Optional[str] = None): + pass + + @queue_until_user_message() + async def create_step(self, step_dict: "StepDict"): + pass + + @queue_until_user_message() + async def update_step(self, step_dict: "StepDict"): + pass + + @queue_until_user_message() + async def delete_step(self, step_id: str): + pass + + async def get_thread_author(self, thread_id: str) -> str: + return "" + + async def delete_thread(self, thread_id: str): + pass + + async def list_threads( + self, pagination: "Pagination", filters: "ThreadFilter" + ) -> "PaginatedResponse[ThreadDict]": + return PaginatedResponse( + data=[], + pageInfo=PageInfo(hasNextPage=False, startCursor=None, endCursor=None), + ) + + async def get_thread(self, thread_id: str) -> "Optional[ThreadDict]": + 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, + ): + pass + + async def delete_user_session(self, id: str) -> bool: + return True + + async def build_debug_url(self) -> str: + return "" + _data_layer: Optional[BaseDataLayer] = None +class ChainlitDataLayer(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") + + def attachment_to_element_dict(self, attachment: Attachment) -> "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"), + "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, + } + + def score_to_feedback_dict( + self, 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, + } + + def step_to_step_dict(self, 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": self.score_to_feedback_dict(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), + "indent": metadata.get("indent"), + "language": metadata.get("language"), + "isError": bool(step.error), + "waitForAnswer": metadata.get("waitForAnswer", False), + } + + 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), + } + + 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 = await f.read() # type: Union[bytes, str] + 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 self.attachment_to_element_dict(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"}, + ) + return PaginatedResponse( + pageInfo=PageInfo( + hasNextPage=literal_response.pageInfo.hasNextPage, + startCursor=literal_response.pageInfo.startCursor, + endCursor=literal_response.pageInfo.endCursor, + ), + data=literal_response.data, + ) + + async def get_thread(self, thread_id: str) -> "Optional[ThreadDict]": + from chainlit.step import check_add_step_in_cot, stub_step + + 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(self.attachment_to_element_dict(attachment)) + + if check_add_step_in_cot(step): + steps.append(self.step_to_step_dict(step)) + else: + steps.append(stub_step(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, + ) + + +class BaseStorageClient(Protocol): + """Base class for non-text data persistence like Azure Data Lake, S3, Google Storage, etc.""" + + async def upload_file( + self, + object_key: str, + data: Union[bytes, str], + mime: str = "application/octet-stream", + overwrite: bool = True, + ) -> Dict[str, Any]: + pass + + if api_key := os.environ.get("LITERAL_API_KEY"): # 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 = ChainlitDataLayer(api_key=api_key, server=server) def get_data_layer(): diff --git a/backend/chainlit/data/base.py b/backend/chainlit/data/base.py deleted file mode 100644 index d34eaaa899..0000000000 --- a/backend/chainlit/data/base.py +++ /dev/null @@ -1,121 +0,0 @@ -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union - -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 - - -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, - ) -> Dict[str, Any]: - pass diff --git a/backend/chainlit/data/dynamodb.py b/backend/chainlit/data/dynamodb.py index ec0f1418fa..0b63614318 100644 --- a/backend/chainlit/data/dynamodb.py +++ b/backend/chainlit/data/dynamodb.py @@ -12,8 +12,7 @@ import boto3 # type: ignore from boto3.dynamodb.types import TypeDeserializer, TypeSerializer from chainlit.context import context -from chainlit.data.base import BaseDataLayer, BaseStorageClient -from chainlit.data.utils import queue_until_user_message +from chainlit.data import BaseDataLayer, BaseStorageClient, queue_until_user_message from chainlit.element import ElementDict from chainlit.logger import logger from chainlit.step import StepDict @@ -37,6 +36,7 @@ class DynamoDBDataLayer(BaseDataLayer): + def __init__( self, table_name: str, @@ -579,5 +579,8 @@ async def update_thread( updates=item, ) + async def delete_user_session(self, id: str) -> bool: + return True # Not sure why documentation wants this + async def build_debug_url(self) -> str: return "" diff --git a/backend/chainlit/data/literalai.py b/backend/chainlit/data/literalai.py deleted file mode 100644 index 5572dbf4a9..0000000000 --- a/backend/chainlit/data/literalai.py +++ /dev/null @@ -1,395 +0,0 @@ -import json -from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union, cast - -import aiofiles -from chainlit.data.base import BaseDataLayer -from chainlit.data.utils import queue_until_user_message -from chainlit.logger import logger -from chainlit.types import ( - Feedback, - PageInfo, - PaginatedResponse, - Pagination, - ThreadDict, - ThreadFilter, -) -from chainlit.user import PersistedUser, User -from httpx import HTTPStatusError, RequestError -from literalai import Attachment -from literalai import Score as LiteralScore -from literalai import Step as LiteralStep -from literalai.filter import threads_filters as LiteralThreadsFilters -from literalai.step import StepDict as LiteralStepDict - -if TYPE_CHECKING: - from chainlit.element import Element, ElementDict - from chainlit.step import FeedbackDict, StepDict - - -_data_layer: Optional[BaseDataLayer] = None - - -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") - - def attachment_to_element_dict(self, attachment: Attachment) -> "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"), - "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, - } - - def score_to_feedback_dict( - self, 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, - } - - def step_to_step_dict(self, 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": self.score_to_feedback_dict(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), - "indent": metadata.get("indent"), - "language": metadata.get("language"), - "isError": bool(step.error), - "waitForAnswer": metadata.get("waitForAnswer", False), - } - - 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), - } - - 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 self.attachment_to_element_dict(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"}, - ) - return PaginatedResponse( - pageInfo=PageInfo( - hasNextPage=literal_response.pageInfo.hasNextPage, - startCursor=literal_response.pageInfo.startCursor, - endCursor=literal_response.pageInfo.endCursor, - ), - data=literal_response.data, - ) - - async def get_thread(self, thread_id: str) -> "Optional[ThreadDict]": - from chainlit.step import check_add_step_in_cot, stub_step - - 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(self.attachment_to_element_dict(attachment)) - - if check_add_step_in_cot(step): - steps.append(self.step_to_step_dict(step)) - else: - steps.append(stub_step(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, - ) diff --git a/backend/chainlit/data/sql_alchemy.py b/backend/chainlit/data/sql_alchemy.py index d72ad985df..024eed6141 100644 --- a/backend/chainlit/data/sql_alchemy.py +++ b/backend/chainlit/data/sql_alchemy.py @@ -8,8 +8,7 @@ import aiofiles import aiohttp from chainlit.context import context -from chainlit.data.base import BaseDataLayer, BaseStorageClient -from chainlit.data.utils import queue_until_user_message +from chainlit.data import BaseDataLayer, BaseStorageClient, queue_until_user_message from chainlit.element import ElementDict from chainlit.logger import logger from chainlit.step import StepDict @@ -55,9 +54,7 @@ def __init__( self.engine: AsyncEngine = create_async_engine( self._conninfo, connect_args=ssl_args ) - self.async_session = sessionmaker( - bind=self.engine, expire_on_commit=False, class_=AsyncSession - ) # type: ignore + 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: @@ -381,7 +378,7 @@ async def create_element(self, element: "Element"): raise ValueError("No authenticated user in context") if not self.storage_provider: logger.warn( - "SQLAlchemy: create_element error. No blob_storage_client is configured!" + f"SQLAlchemy: create_element error. No blob_storage_client is configured!" ) return if not element.for_id: @@ -443,12 +440,15 @@ async def delete_element(self, element_id: str, thread_id: Optional[str] = None) parameters = {"id": element_id} await self.execute_sql(query=query, parameters=parameters) + async def delete_user_session(self, id: str) -> bool: + return False # Not sure why documentation wants this + 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") + logger.info(f"SQLAlchemy: get_all_user_threads") user_threads_query = """ SELECT "id" AS thread_id, diff --git a/backend/chainlit/data/storage_clients.py b/backend/chainlit/data/storage_clients.py index 42d5f5e40a..7242b76e00 100644 --- a/backend/chainlit/data/storage_clients.py +++ b/backend/chainlit/data/storage_clients.py @@ -1,22 +1,11 @@ -from typing import TYPE_CHECKING, Any, Dict, Optional, Union - -import boto3 # type: ignore -from azure.storage.filedatalake import ( - ContentSettings, - DataLakeFileClient, - DataLakeServiceClient, - FileSystemClient, -) -from chainlit.data.base import BaseStorageClient +from chainlit.data import BaseStorageClient from chainlit.logger import logger +from typing import TYPE_CHECKING, Optional, Dict, Union, Any +from azure.storage.filedatalake import DataLakeServiceClient, FileSystemClient, DataLakeFileClient, ContentSettings +import boto3 # type: ignore if TYPE_CHECKING: - from azure.core.credentials import ( - AzureNamedKeyCredential, - AzureSasCredential, - TokenCredential, - ) - + from azure.core.credentials import AzureNamedKeyCredential, AzureSasCredential, TokenCredential class AzureStorageClient(BaseStorageClient): """ @@ -27,65 +16,30 @@ class AzureStorageClient(BaseStorageClient): 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, - ): + 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.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.warn(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, - ) -> Dict[str, Any]: + + async def upload_file(self, object_key: str, data: Union[bytes, str], mime: str = 'application/octet-stream', overwrite: bool = True) -> Dict[str, Any]: try: - file_client: DataLakeFileClient = self.container_client.get_file_client( - object_key - ) + file_client: DataLakeFileClient = self.container_client.get_file_client(object_key) content_settings = ContentSettings(content_type=mime) - 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 - ) + 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.warn(f"AzureStorageClient, upload_file error: {e}") return {} - class S3StorageClient(BaseStorageClient): """ Class to enable Amazon S3 storage provider """ - def __init__(self, bucket: str): try: self.bucket = bucket @@ -94,17 +48,9 @@ def __init__(self, bucket: str): except Exception as e: logger.warn(f"S3StorageClient initialization error: {e}") - async def upload_file( - self, - object_key: str, - data: Union[bytes, str], - mime: str = "application/octet-stream", - overwrite: bool = True, - ) -> Dict[str, Any]: + async def upload_file(self, object_key: str, data: Union[bytes, str], mime: str = 'application/octet-stream', overwrite: bool = True) -> Dict[str, Any]: try: - self.client.put_object( - Bucket=self.bucket, Key=object_key, Body=data, ContentType=mime - ) + self.client.put_object(Bucket=self.bucket, Key=object_key, Body=data, ContentType=mime) url = f"https://{self.bucket}.s3.amazonaws.com/{object_key}" return {"object_key": object_key, "url": url} except Exception as e: diff --git a/backend/chainlit/data/utils.py b/backend/chainlit/data/utils.py deleted file mode 100644 index 23dc329b5d..0000000000 --- a/backend/chainlit/data/utils.py +++ /dev/null @@ -1,29 +0,0 @@ -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 diff --git a/backend/chainlit/session.py b/backend/chainlit/session.py index f73a635558..f23e39ca40 100644 --- a/backend/chainlit/session.py +++ b/backend/chainlit/session.py @@ -193,9 +193,6 @@ def delete(self): shutil.rmtree(self.files_dir) -ThreadQueue = Deque[tuple[Callable, object, tuple, Dict]] - - class WebsocketSession(BaseSession): """Internal web socket session object. @@ -253,7 +250,7 @@ def __init__( self.restored = False - self.thread_queues: Dict[str, ThreadQueue] = {} + self.thread_queues = {} # type: Dict[str, Deque[Callable]] ws_sessions_id[self.id] = self ws_sessions_sid[socket_id] = self diff --git a/cypress/e2e/data_layer/main.py b/cypress/e2e/data_layer/main.py index 6fa6dc7f07..e1752215fc 100644 --- a/cypress/e2e/data_layer/main.py +++ b/cypress/e2e/data_layer/main.py @@ -3,19 +3,8 @@ from typing import Dict, List, Optional import chainlit.data as cl_data -from chainlit.data.utils import queue_until_user_message -from chainlit.element import Element, ElementDict from chainlit.socket import persist_user_session from chainlit.step import StepDict -from chainlit.types import ( - Feedback, - PageInfo, - PaginatedResponse, - Pagination, - ThreadDict, - ThreadFilter, -) -from chainlit.user import PersistedUser, User from literalai.helper import utc_now import chainlit as cl @@ -69,7 +58,7 @@ }, ], }, -] # type: List[ThreadDict] +] # type: List[cl_data.ThreadDict] deleted_thread_ids = [] # type: List[str] THREAD_HISTORY_PICKLE_PATH = os.getenv("THREAD_HISTORY_PICKLE_PATH") @@ -142,11 +131,13 @@ async def get_thread_author(self, thread_id: str): return "admin" async def list_threads( - self, pagination: Pagination, filters: ThreadFilter - ) -> PaginatedResponse[ThreadDict]: - return PaginatedResponse( + self, pagination: cl_data.Pagination, filters: cl_data.ThreadFilter + ) -> cl_data.PaginatedResponse[cl_data.ThreadDict]: + return cl_data.PaginatedResponse( data=[t for t in thread_history if t["id"] not in deleted_thread_ids], - pageInfo=PageInfo(hasNextPage=False, startCursor=None, endCursor=None), + pageInfo=cl_data.PageInfo( + hasNextPage=False, startCursor=None, endCursor=None + ), ) async def get_thread(self, thread_id: str): @@ -159,42 +150,6 @@ async def get_thread(self, thread_id: str): async def delete_thread(self, thread_id: str): deleted_thread_ids.append(thread_id) - async def delete_feedback( - self, - feedback_id: str, - ) -> bool: - return True - - async def upsert_feedback( - self, - feedback: Feedback, - ) -> str: - return "" - - @queue_until_user_message() - async def create_element(self, element: "Element"): - pass - - async def get_element( - self, thread_id: str, element_id: str - ) -> Optional["ElementDict"]: - pass - - @queue_until_user_message() - async def delete_element(self, element_id: str, thread_id: Optional[str] = None): - pass - - @queue_until_user_message() - async def update_step(self, step_dict: "StepDict"): - pass - - @queue_until_user_message() - async def delete_step(self, step_id: str): - pass - - async def build_debug_url(self) -> str: - return "" - cl_data._data_layer = TestDataLayer() @@ -234,7 +189,7 @@ def auth_callback(username: str, password: str) -> Optional[cl.User]: @cl.on_chat_resume -async def on_chat_resume(thread: ThreadDict): +async def on_chat_resume(thread: cl_data.ThreadDict): await cl.Message(f"Welcome back to {thread['name']}").send() if "metadata" in thread: await cl.Message(thread["metadata"], author="metadata", language="json").send() From 7c5e69c3b13fd12f32877f61254eb486a74cf90c Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Tue, 3 Sep 2024 11:13:32 +0100 Subject: [PATCH 06/45] Changelog for version 1.2.0. --- CHANGELOG.md | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 913eeeeec5..4f69be8fcd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,27 @@ 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/). -## [Unreleased] +## [1.2.0] - 2024-09-03 -### Changed +### 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). -- Adding `CHAINLIT_APP_ROOT` Environment Variable to modify `APP_ROOT`, enabling the ability to set the location of config.toml and other setting files. -- changing the default host from 0.0.0.0 to 127.0.0.1 - ## [1.1.403rc0] - 2024-08-13 ### Fixed From 6a6d9dd21fe2cbbd854ee50d3a33402c8dfee0c5 Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Wed, 4 Sep 2024 10:23:48 +0100 Subject: [PATCH 07/45] Fix publish workflow. (#1299) --- .github/workflows/publish.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 6b68ef69c9..f5059c40b9 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -6,13 +6,13 @@ on: types: [published] jobs: - tests: - uses: ./.github/workflows/tests.yaml + ci: + uses: ./.github/workflows/ci.yaml secrets: inherit build-n-publish: name: Upload release to PyPI runs-on: ubuntu-latest - needs: [tests] + needs: [ci] env: name: pypi url: https://pypi.org/p/chainlit From 53b8ae0ace3a8423c2090d066459562e3da33dad Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Wed, 4 Sep 2024 10:08:05 +0100 Subject: [PATCH 08/45] Update CHANGELOG.md Bump 1.2.0 release date. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f69be8fcd..554fdfd19d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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/). -## [1.2.0] - 2024-09-03 +## [1.2.0] - 2024-09-04 ### Security From e91cdb1e39febad1f5ed25b88154e96156c50791 Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Wed, 4 Sep 2024 10:23:21 +0100 Subject: [PATCH 09/45] Use the same (caching) poetry action everywhere. --- .github/workflows/publish.yaml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index f5059c40b9..d91294f2c4 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -16,6 +16,7 @@ jobs: env: name: pypi url: https://pypi.org/p/chainlit + BACKEND_DIR: ./backend permissions: contents: read id-token: write # IMPORTANT: this permission is mandatory for trusted publishing @@ -31,12 +32,12 @@ jobs: with: node-version: '16.15.0' cache: 'pnpm' - - name: Set up Python - uses: actions/setup-python@v4 + - uses: ./.github/actions/poetry-python-install + name: Install Python, poetry and Python dependencies with: - python-version: '3.9' - - name: Install Poetry - uses: snok/install-poetry@v1 + python-version: 3.9 + poetry-version: 1.8.3 + poetry-working-directory: ${{ env.BACKEND_DIR }} - name: Copy readme to backend run: cp README.md backend/ - name: Install JS dependencies From 613bf6e6b7122c42aca1572d944ef2de2cf855f0 Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Wed, 4 Sep 2024 10:27:37 +0100 Subject: [PATCH 10/45] Allow calling ci workflow from publish. --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ef730685e3..381aa47d55 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,6 +1,7 @@ name: CI on: + workflow_call: workflow_dispatch: pull_request: branches: [main, dev] From 21c607a2dc912ded7b1baeeb99bb138ba0355038 Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Wed, 4 Sep 2024 11:01:02 +0100 Subject: [PATCH 11/45] LOL must update package version. (#1302) --- backend/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 2cc4700ea5..d1f3696533 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chainlit" -version = "1.1.403rc0" +version = "1.1.404" keywords = [ 'LLM', 'Agents', From ca12964be4874aebd9e3e0a207157f7d5cbf5aa7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Sep 2024 14:57:16 +0100 Subject: [PATCH 12/45] chore(deps-dev): bump cryptography from 43.0.0 to 43.0.1 in /backend (#1298) Bumps [cryptography](https://github.com/pyca/cryptography) from 43.0.0 to 43.0.1. - [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pyca/cryptography/compare/43.0.0...43.0.1) --- updated-dependencies: - dependency-name: cryptography dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- backend/poetry.lock | 60 ++++++++++++++++++++++----------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/backend/poetry.lock b/backend/poetry.lock index a542ed3e34..09c9b21845 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -963,38 +963,38 @@ toml = ["tomli"] [[package]] name = "cryptography" -version = "43.0.0" +version = "43.0.1" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." optional = false python-versions = ">=3.7" files = [ - {file = "cryptography-43.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:64c3f16e2a4fc51c0d06af28441881f98c5d91009b8caaff40cf3548089e9c74"}, - {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3dcdedae5c7710b9f97ac6bba7e1052b95c7083c9d0e9df96e02a1932e777895"}, - {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d9a1eca329405219b605fac09ecfc09ac09e595d6def650a437523fcd08dd22"}, - {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ea9e57f8ea880eeea38ab5abf9fbe39f923544d7884228ec67d666abd60f5a47"}, - {file = "cryptography-43.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:9a8d6802e0825767476f62aafed40532bd435e8a5f7d23bd8b4f5fd04cc80ecf"}, - {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cc70b4b581f28d0a254d006f26949245e3657d40d8857066c2ae22a61222ef55"}, - {file = "cryptography-43.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4a997df8c1c2aae1e1e5ac49c2e4f610ad037fc5a3aadc7b64e39dea42249431"}, - {file = "cryptography-43.0.0-cp37-abi3-win32.whl", hash = "sha256:6e2b11c55d260d03a8cf29ac9b5e0608d35f08077d8c087be96287f43af3ccdc"}, - {file = "cryptography-43.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:31e44a986ceccec3d0498e16f3d27b2ee5fdf69ce2ab89b52eaad1d2f33d8778"}, - {file = "cryptography-43.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:7b3f5fe74a5ca32d4d0f302ffe6680fcc5c28f8ef0dc0ae8f40c0f3a1b4fca66"}, - {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac1955ce000cb29ab40def14fd1bbfa7af2017cca696ee696925615cafd0dce5"}, - {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:299d3da8e00b7e2b54bb02ef58d73cd5f55fb31f33ebbf33bd00d9aa6807df7e"}, - {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ee0c405832ade84d4de74b9029bedb7b31200600fa524d218fc29bfa371e97f5"}, - {file = "cryptography-43.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb013933d4c127349b3948aa8aaf2f12c0353ad0eccd715ca789c8a0f671646f"}, - {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fdcb265de28585de5b859ae13e3846a8e805268a823a12a4da2597f1f5afc9f0"}, - {file = "cryptography-43.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2905ccf93a8a2a416f3ec01b1a7911c3fe4073ef35640e7ee5296754e30b762b"}, - {file = "cryptography-43.0.0-cp39-abi3-win32.whl", hash = "sha256:47ca71115e545954e6c1d207dd13461ab81f4eccfcb1345eac874828b5e3eaaf"}, - {file = "cryptography-43.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:0663585d02f76929792470451a5ba64424acc3cd5227b03921dab0e2f27b1709"}, - {file = "cryptography-43.0.0-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:2c6d112bf61c5ef44042c253e4859b3cbbb50df2f78fa8fae6747a7814484a70"}, - {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:844b6d608374e7d08f4f6e6f9f7b951f9256db41421917dfb2d003dde4cd6b66"}, - {file = "cryptography-43.0.0-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:51956cf8730665e2bdf8ddb8da0056f699c1a5715648c1b0144670c1ba00b48f"}, - {file = "cryptography-43.0.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:aae4d918f6b180a8ab8bf6511a419473d107df4dbb4225c7b48c5c9602c38c7f"}, - {file = "cryptography-43.0.0-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:232ce02943a579095a339ac4b390fbbe97f5b5d5d107f8a08260ea2768be8cc2"}, - {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5bcb8a5620008a8034d39bce21dc3e23735dfdb6a33a06974739bfa04f853947"}, - {file = "cryptography-43.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:08a24a7070b2b6804c1940ff0f910ff728932a9d0e80e7814234269f9d46d069"}, - {file = "cryptography-43.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e9c5266c432a1e23738d178e51c2c7a5e2ddf790f248be939448c0ba2021f9d1"}, - {file = "cryptography-43.0.0.tar.gz", hash = "sha256:b88075ada2d51aa9f18283532c9f60e72170041bba88d7f37e49cbb10275299e"}, + {file = "cryptography-43.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:8385d98f6a3bf8bb2d65a73e17ed87a3ba84f6991c155691c51112075f9ffc5d"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27e613d7077ac613e399270253259d9d53872aaf657471473ebfc9a52935c062"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68aaecc4178e90719e95298515979814bda0cbada1256a4485414860bd7ab962"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:de41fd81a41e53267cb020bb3a7212861da53a7d39f863585d13ea11049cf277"}, + {file = "cryptography-43.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f98bf604c82c416bc829e490c700ca1553eafdf2912a91e23a79d97d9801372a"}, + {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:61ec41068b7b74268fa86e3e9e12b9f0c21fcf65434571dbb13d954bceb08042"}, + {file = "cryptography-43.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:014f58110f53237ace6a408b5beb6c427b64e084eb451ef25a28308270086494"}, + {file = "cryptography-43.0.1-cp37-abi3-win32.whl", hash = "sha256:2bd51274dcd59f09dd952afb696bf9c61a7a49dfc764c04dd33ef7a6b502a1e2"}, + {file = "cryptography-43.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:666ae11966643886c2987b3b721899d250855718d6d9ce41b521252a17985f4d"}, + {file = "cryptography-43.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:ac119bb76b9faa00f48128b7f5679e1d8d437365c5d26f1c2c3f0da4ce1b553d"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bbcce1a551e262dfbafb6e6252f1ae36a248e615ca44ba302df077a846a8806"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58d4e9129985185a06d849aa6df265bdd5a74ca6e1b736a77959b498e0505b85"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d03a475165f3134f773d1388aeb19c2d25ba88b6a9733c5c590b9ff7bbfa2e0c"}, + {file = "cryptography-43.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:511f4273808ab590912a93ddb4e3914dfd8a388fed883361b02dea3791f292e1"}, + {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:80eda8b3e173f0f247f711eef62be51b599b5d425c429b5d4ca6a05e9e856baa"}, + {file = "cryptography-43.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38926c50cff6f533f8a2dae3d7f19541432610d114a70808f0926d5aaa7121e4"}, + {file = "cryptography-43.0.1-cp39-abi3-win32.whl", hash = "sha256:a575913fb06e05e6b4b814d7f7468c2c660e8bb16d8d5a1faf9b33ccc569dd47"}, + {file = "cryptography-43.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:d75601ad10b059ec832e78823b348bfa1a59f6b8d545db3a24fd44362a1564cb"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ea25acb556320250756e53f9e20a4177515f012c9eaea17eb7587a8c4d8ae034"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c1332724be35d23a854994ff0b66530119500b6053d0bd3363265f7e5e77288d"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fba1007b3ef89946dbbb515aeeb41e30203b004f0b4b00e5e16078b518563289"}, + {file = "cryptography-43.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:5b43d1ea6b378b54a1dc99dd8a2b5be47658fe9a7ce0a58ff0b55f4b43ef2b84"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:88cce104c36870d70c49c7c8fd22885875d950d9ee6ab54df2745f83ba0dc365"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9d3cdb25fa98afdd3d0892d132b8d7139e2c087da1712041f6b762e4f807cc96"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e710bf40870f4db63c3d7d929aa9e09e4e7ee219e703f949ec4073b4294f6172"}, + {file = "cryptography-43.0.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7c05650fe8023c5ed0d46793d4b7d7e6cd9c04e68eabe5b0aeea836e37bdcec2"}, + {file = "cryptography-43.0.1.tar.gz", hash = "sha256:203e92a75716d8cfb491dc47c79e17d0d9207ccffcbcb35f598fbe463ae3444d"}, ] [package.dependencies] @@ -1007,7 +1007,7 @@ nox = ["nox"] pep8test = ["check-sdist", "click", "mypy", "ruff"] sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["certifi", "cryptography-vectors (==43.0.0)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test = ["certifi", "cryptography-vectors (==43.0.1)", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] test-randomorder = ["pytest-randomly"] [[package]] @@ -3311,8 +3311,8 @@ files = [ [package.dependencies] numpy = [ {version = ">=1.22.4", markers = "python_version < \"3.11\""}, - {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, {version = ">=1.23.2", markers = "python_version == \"3.11\""}, + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, ] python-dateutil = ">=2.8.2" pytz = ">=2020.1" From 80c3e44324322f323fcfd3d3bf2129e6b9d56efe Mon Sep 17 00:00:00 2001 From: Josh Hayes <35790761+hayescode@users.noreply.github.com> Date: Thu, 15 Aug 2024 21:15:34 -0500 Subject: [PATCH 13/45] fix show_input --- backend/chainlit/data/sql_alchemy.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/chainlit/data/sql_alchemy.py b/backend/chainlit/data/sql_alchemy.py index 024eed6141..d066b044ad 100644 --- a/backend/chainlit/data/sql_alchemy.py +++ b/backend/chainlit/data/sql_alchemy.py @@ -578,7 +578,7 @@ async def get_all_user_threads( tags=step_feedback.get("step_tags"), input=( step_feedback.get("step_input", "") - if step_feedback["step_showinput"] == "true" + if step_feedback.get("step_showinput") not in [None, "false"] else None ), output=step_feedback.get("step_output", ""), From 122369a64ebdc3d34f6f88cbe24ec612d209c20b Mon Sep 17 00:00:00 2001 From: Ming Date: Wed, 4 Sep 2024 08:29:15 -0700 Subject: [PATCH 14/45] [fix] `LlamaIndexCallbackHandler` to support displaying function calls as tools in CoTs (#1285) --- backend/chainlit/llama_index/callbacks.py | 24 +++- backend/tests/llama_index/test_callbacks.py | 128 ++++++++++++++++++++ 2 files changed, 148 insertions(+), 4 deletions(-) create mode 100644 backend/tests/llama_index/test_callbacks.py diff --git a/backend/chainlit/llama_index/callbacks.py b/backend/chainlit/llama_index/callbacks.py index 9f2a85df8e..88c8170962 100644 --- a/backend/chainlit/llama_index/callbacks.py +++ b/backend/chainlit/llama_index/callbacks.py @@ -8,6 +8,7 @@ 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 DEFAULT_IGNORE = [ CBEventType.CHUNKING, @@ -54,7 +55,16 @@ def on_event_start( ) -> str: """Run when an event starts and return id of event.""" step_type: StepType = "undefined" - if event_type == CBEventType.RETRIEVE: + 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" @@ -64,7 +74,7 @@ def on_event_start( return event_id step = Step( - name=event_type.value, + name=step_name, type=step_type, parent_id=self._get_parent_id(parent_id), id=event_id, @@ -72,7 +82,7 @@ def on_event_start( self.steps[event_id] = step step.start = utc_now() - step.input = payload or {} + step.input = step_input or {} context_var.get().loop.create_task(step.send()) return event_id @@ -91,7 +101,13 @@ def on_event_end( step.end = utc_now() - if event_type == CBEventType.QUERY: + 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: diff --git a/backend/tests/llama_index/test_callbacks.py b/backend/tests/llama_index/test_callbacks.py new file mode 100644 index 0000000000..66a591f93b --- /dev/null +++ b/backend/tests/llama_index/test_callbacks.py @@ -0,0 +1,128 @@ +from contextlib import asynccontextmanager +from unittest.mock import Mock, patch + +import pytest_asyncio +from chainlit.context import ChainlitContext, context_var + +# Import the class we're testing +from chainlit.llama_index.callbacks import LlamaIndexCallbackHandler +from chainlit.session import WebsocketSession +from chainlit.step import Step +from llama_index.core.callbacks.schema import CBEventType, EventPayload +from llama_index.core.tools.types import ToolMetadata + + +@asynccontextmanager +async def create_chainlit_context(): + mock_session = Mock(spec=WebsocketSession) + mock_session.id = "test_session_id" + mock_session.thread_id = "test_session_thread_id" + mock_session.user_env = {"test_env": "value"} + mock_session.chat_settings = {} + mock_session.user = None + mock_session.chat_profile = None + mock_session.http_referer = None + mock_session.client_type = "webapp" + mock_session.languages = ["en"] + + context = ChainlitContext(mock_session) + token = context_var.set(context) + try: + yield context + finally: + context_var.reset(token) + + +@pytest_asyncio.fixture +async def mock_chainlit_context(): + return create_chainlit_context() + + +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() From d927b6c0b8054aa198bbaff3b4af927bc91a877f Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Wed, 4 Sep 2024 12:27:32 +0100 Subject: [PATCH 15/45] Correct release version in changelog. --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 554fdfd19d..bac8bb41b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ 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/). -## [1.2.0] - 2024-09-04 +## [1.1.404] - 2024-09-04 ### Security From 131531cab75546ae229285972cf71ae1563d14cb Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Wed, 4 Sep 2024 18:11:23 +0100 Subject: [PATCH 16/45] Support for fastapi 0.110-0.112 (#1306) * Support broader range of fastapi versions. * Run unit tests with all supported fastapi versions. --- .github/workflows/pytest.yaml | 4 ++++ backend/poetry.lock | 13 +++++++------ backend/pyproject.toml | 3 ++- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 3b9851a279..ef2a99b646 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -8,6 +8,7 @@ jobs: strategy: matrix: python-version: ['3.9', '3.10', '3.11', '3.12'] + fastapi-version: ['0.110', '0.111', '0.112'] env: BACKEND_DIR: ./backend steps: @@ -19,6 +20,9 @@ jobs: poetry-version: 1.8.3 poetry-install-args: --with tests --with mypy --with custom-data poetry-working-directory: ${{ env.BACKEND_DIR }} + - name: Install fastapi ${{ matrix.fastapi-version }} + run: poetry add fastapi@^${{ matrix.fastapi-version}} + working-directory: ${{ env.BACKEND_DIR }} - name: Run Pytest run: poetry run pytest --cov=chainlit/ working-directory: ${{ env.BACKEND_DIR }} diff --git a/backend/poetry.lock b/backend/poetry.lock index 09c9b21845..77888a77a7 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1222,22 +1222,23 @@ weaviate = ["weaviate-client (>2)"] [[package]] name = "fastapi" -version = "0.110.3" +version = "0.112.2" description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production" optional = false python-versions = ">=3.8" files = [ - {file = "fastapi-0.110.3-py3-none-any.whl", hash = "sha256:fd7600612f755e4050beb74001310b5a7e1796d149c2ee363124abdfa0289d32"}, - {file = "fastapi-0.110.3.tar.gz", hash = "sha256:555700b0159379e94fdbfc6bb66a0f1c43f4cf7060f25239af3d84b63a656626"}, + {file = "fastapi-0.112.2-py3-none-any.whl", hash = "sha256:db84b470bd0e2b1075942231e90e3577e12a903c4dc8696f0d206a7904a7af1c"}, + {file = "fastapi-0.112.2.tar.gz", hash = "sha256:3d4729c038414d5193840706907a41839d839523da6ed0c2811f1168cac1798c"}, ] [package.dependencies] pydantic = ">=1.7.4,<1.8 || >1.8,<1.8.1 || >1.8.1,<2.0.0 || >2.0.0,<2.0.1 || >2.0.1,<2.1.0 || >2.1.0,<3.0.0" -starlette = ">=0.37.2,<0.38.0" +starlette = ">=0.37.2,<0.39.0" typing-extensions = ">=4.8.0" [package.extras] -all = ["email_validator (>=2.0.0)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +all = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "itsdangerous (>=1.1.0)", "jinja2 (>=2.11.2)", "orjson (>=3.2.1)", "pydantic-extra-types (>=2.0.0)", "pydantic-settings (>=2.0.0)", "python-multipart (>=0.0.7)", "pyyaml (>=5.3.1)", "ujson (>=4.0.1,!=4.0.2,!=4.1.0,!=4.2.0,!=4.3.0,!=5.0.0,!=5.1.0)", "uvicorn[standard] (>=0.12.0)"] +standard = ["email-validator (>=2.0.0)", "fastapi-cli[standard] (>=0.0.5)", "httpx (>=0.23.0)", "jinja2 (>=2.11.2)", "python-multipart (>=0.0.7)", "uvicorn[standard] (>=0.12.0)"] [[package]] name = "filelock" @@ -5430,4 +5431,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0.0" -content-hash = "a1bb09a81b5c755a5140f0aa519a3d7fc1b009dec00b52cbf2c875c2427e1372" +content-hash = "2c7c18baeef86be74a58fed3fe79c46539fa2972c6b845c089cad09524b1c100" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index d1f3696533..e170c239c5 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -29,7 +29,7 @@ python = ">=3.9,<4.0.0" httpx = ">=0.23.0" literalai = "0.0.607" dataclasses_json = "^0.5.7" -fastapi = "^0.110.1" +fastapi = ">=0.110.1,<0.113" starlette = "^0.37.2" uvicorn = "^0.25.0" python-socketio = "^5.11.0" @@ -104,6 +104,7 @@ module = [ ignore_missing_imports = true + [tool.poetry.group.custom-data] optional = true From c372f8adedcd0d27a8f94988bce2546eb9764122 Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Wed, 4 Sep 2024 18:35:40 +0100 Subject: [PATCH 17/45] Remove most wait statements from E2E tests (#1270) Works towards speeding up tests (#1215). --- cypress/e2e/action/spec.cy.ts | 10 ---------- cypress/e2e/ask_user/spec.cy.ts | 2 +- cypress/e2e/stop_task/spec.cy.ts | 1 - cypress/support/testUtils.ts | 3 --- 4 files changed, 1 insertion(+), 15 deletions(-) diff --git a/cypress/e2e/action/spec.cy.ts b/cypress/e2e/action/spec.cy.ts index e10d4a7afb..9a257ffc30 100644 --- a/cypress/e2e/action/spec.cy.ts +++ b/cypress/e2e/action/spec.cy.ts @@ -21,8 +21,6 @@ describe('Action', () => { cy.get('.step').eq(3).should('contain', 'Executed test action!'); cy.get("[id='test-action']").should('exist'); - cy.wait(100); - // Click on "removable action" cy.get("[id='removable-action']").should('exist'); cy.get("[id='removable-action']").click(); @@ -30,15 +28,11 @@ describe('Action', () => { cy.get('.step').eq(4).should('contain', 'Executed removable action!'); cy.get("[id='removable-action']").should('not.exist'); - cy.wait(100); - // Click on "multiple action one" in the action drawer, should remove the correct action button cy.get("[id='actions-drawer-button']").should('exist'); cy.get("[id='actions-drawer-button']").click(); cy.get('.step').should('have.length', 5); - cy.wait(100); - cy.get("[id='multiple-action-one']").should('exist'); cy.get("[id='multiple-action-one']").click(); cy.get('.step') @@ -46,8 +40,6 @@ describe('Action', () => { .should('contain', 'Action(id=multiple-action-one) has been removed!'); cy.get("[id='multiple-action-one']").should('not.exist'); - cy.wait(100); - // Click on "multiple action two", should remove the correct action button cy.get('.step').should('have.length', 6); cy.get("[id='actions-drawer-button']").click(); @@ -58,8 +50,6 @@ describe('Action', () => { .should('contain', 'Action(id=multiple-action-two) has been removed!'); cy.get("[id='multiple-action-two']").should('not.exist'); - cy.wait(100); - // Click on "all actions removed", should remove all buttons cy.get("[id='all-actions-removed']").should('exist'); cy.get("[id='all-actions-removed']").click(); diff --git a/cypress/e2e/ask_user/spec.cy.ts b/cypress/e2e/ask_user/spec.cy.ts index 2e3fba3d48..c4dbae69fd 100644 --- a/cypress/e2e/ask_user/spec.cy.ts +++ b/cypress/e2e/ask_user/spec.cy.ts @@ -8,7 +8,7 @@ describe('Ask User', () => { it('should send a new message containing the user input', () => { cy.get('.step').should('have.length', 1); submitMessage('Jeeves'); - cy.wait(2000); + cy.get('.step').should('have.length', 3); cy.get('.step').eq(2).should('contain', 'Jeeves'); diff --git a/cypress/e2e/stop_task/spec.cy.ts b/cypress/e2e/stop_task/spec.cy.ts index 3bc0d71efc..b825309fcd 100644 --- a/cypress/e2e/stop_task/spec.cy.ts +++ b/cypress/e2e/stop_task/spec.cy.ts @@ -14,7 +14,6 @@ describeSyncAsync('Stop task', (mode) => { cy.get('#stop-button').should('exist').click(); cy.get('#stop-button').should('not.exist'); - cy.wait(1000); cy.get('.step').should('have.length', 3); cy.get('.step').last().should('contain.text', 'Task manually stopped.'); }); diff --git a/cypress/support/testUtils.ts b/cypress/support/testUtils.ts index 458a300477..68c1435f43 100644 --- a/cypress/support/testUtils.ts +++ b/cypress/support/testUtils.ts @@ -3,13 +3,11 @@ import { sep } from 'path'; import { ExecutionMode } from './utils'; export function submitMessage(message: string) { - cy.wait(1000); cy.get(`#chat-input`).should('not.be.disabled'); cy.get(`#chat-input`).type(`${message}{enter}`); } export function submitMessageCopilot(message: string) { - cy.wait(1000); cy.get(`#copilot-chat-input`, { includeShadowDom: true }) .should('not.be.disabled') .type(`${message}{enter}`, { @@ -18,7 +16,6 @@ export function submitMessageCopilot(message: string) { } export function openHistory() { - cy.wait(1000); cy.get(`#chat-input`).should('not.be.disabled'); cy.get(`#chat-input`).type(`{upArrow}`); } From 5091c811824b57cbea3cb22820774ca0467289e8 Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Tue, 3 Sep 2024 12:28:22 +0100 Subject: [PATCH 18/45] Starting point documenting release engineering. --- RELENG.md | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 RELENG.md diff --git a/RELENG.md b/RELENG.md new file mode 100644 index 0000000000..d2b6bdb7b8 --- /dev/null +++ b/RELENG.md @@ -0,0 +1,42 @@ +# 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. **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. + +2. **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. + +3. **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. + +4. **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! From b4b6289ece5b9d3029695a01b157d78018c41c90 Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Wed, 4 Sep 2024 10:37:54 +0100 Subject: [PATCH 19/45] Upgrade actions/cache to v4, solves Node 12 not supported warning. --- .github/actions/poetry-python-install/action.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/poetry-python-install/action.yaml b/.github/actions/poetry-python-install/action.yaml index c65fb3f791..bf978481d5 100644 --- a/.github/actions/poetry-python-install/action.yaml +++ b/.github/actions/poetry-python-install/action.yaml @@ -24,7 +24,7 @@ runs: using: 'composite' steps: - name: Cache poetry install - uses: actions/cache@v2 + uses: actions/cache@v4 with: path: ~/.local key: poetry-${{ runner.os }}-${{ inputs.poetry-version }}-0 From 3184a69ba5667f37af1f394e1140e85fbbff9f81 Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Wed, 4 Sep 2024 10:45:14 +0100 Subject: [PATCH 20/45] Bump python setup action. --- .github/actions/poetry-python-install/action.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/poetry-python-install/action.yaml b/.github/actions/poetry-python-install/action.yaml index bf978481d5..8447c053dc 100644 --- a/.github/actions/poetry-python-install/action.yaml +++ b/.github/actions/poetry-python-install/action.yaml @@ -33,7 +33,7 @@ runs: shell: bash - name: Set up Python ${{ inputs.python-version }} id: setup_python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ inputs.python-version }} cache: poetry From 2d1e4b2b92cc6ef9a0555a6e51fad14ed6b77590 Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Wed, 4 Sep 2024 10:52:22 +0100 Subject: [PATCH 21/45] Bump pnpm version in workflow. Lockfile format outdated, currently ignoring. --- .github/workflows/e2e-tests.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index ad83e94f54..bb3f1f65fe 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -14,7 +14,7 @@ jobs: - uses: actions/checkout@v3 - uses: pnpm/action-setup@v2 with: - version: 8.6.9 + version: 9.7.0 - uses: ./.github/actions/poetry-python-install name: Install Python, poetry and Python dependencies with: From 5294f065f3b505549933d1c7645f2f76f71a5d13 Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Wed, 4 Sep 2024 11:03:57 +0100 Subject: [PATCH 22/45] Bump pnpm and node setup actions. --- .github/workflows/e2e-tests.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index bb3f1f65fe..17e641d6c5 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -12,9 +12,6 @@ jobs: BACKEND_DIR: ./backend steps: - uses: actions/checkout@v3 - - uses: pnpm/action-setup@v2 - with: - version: 9.7.0 - uses: ./.github/actions/poetry-python-install name: Install Python, poetry and Python dependencies with: @@ -22,8 +19,11 @@ jobs: poetry-version: 1.8.3 poetry-working-directory: ${{ env.BACKEND_DIR }} poetry-install-args: --with tests + - uses: pnpm/action-setup@v4 + with: + version: 9.7.0 - name: Use Node.js 16.15.0 - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: '16.15.0' cache: 'pnpm' From 86647649bb5474a1ef19ef95b0182c8f5856e26c Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Wed, 4 Sep 2024 11:07:06 +0100 Subject: [PATCH 23/45] Bump to latest Node LTS. --- .github/workflows/e2e-tests.yaml | 4 ++-- .github/workflows/publish.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index 17e641d6c5..eb2b3a559a 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -22,10 +22,10 @@ jobs: - uses: pnpm/action-setup@v4 with: version: 9.7.0 - - name: Use Node.js 16.15.0 + - name: Use Node.js 22.7.0 uses: actions/setup-node@v4 with: - node-version: '16.15.0' + node-version: '22.7.0' cache: 'pnpm' - name: Install JS dependencies run: pnpm install --no-frozen-lockfile diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index d91294f2c4..5589487ff5 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -27,10 +27,10 @@ jobs: - uses: pnpm/action-setup@v2 with: version: 8.6.9 - - name: Use Node.js 16.15.0 + - name: Use Node.js 22.7.0 uses: actions/setup-node@v3 with: - node-version: '16.15.0' + node-version: '22.7.0' cache: 'pnpm' - uses: ./.github/actions/poetry-python-install name: Install Python, poetry and Python dependencies From b8b0724372daf6e1c1b229d08f39381303133c89 Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Wed, 4 Sep 2024 13:03:53 +0100 Subject: [PATCH 24/45] DRY node/pnpm action. --- .github/actions/pnpm-node-install/action.yaml | 27 +++++++++++++++++++ .../actions/poetry-python-install/action.yaml | 4 --- .github/workflows/e2e-tests.yaml | 14 ++++------ .github/workflows/publish.yaml | 14 ++++------ 4 files changed, 37 insertions(+), 22 deletions(-) create mode 100644 .github/actions/pnpm-node-install/action.yaml diff --git a/.github/actions/pnpm-node-install/action.yaml b/.github/actions/pnpm-node-install/action.yaml new file mode 100644 index 0000000000..0e21dbe864 --- /dev/null +++ b/.github/actions/pnpm-node-install/action.yaml @@ -0,0 +1,27 @@ +name: 'Install Node, pnpm and dependencies.' +description: 'Install Node, pnpm and dependencies using cache.' + +inputs: + node-version: + description: 'Node.js version' + required: true + pnpm-version: + description: 'pnpm version' + required: true + pnpm-install-args: + description: 'Extra arguments for pnpm install, e.g. --no-frozen-lockfile.' + +runs: + using: 'composite' + steps: + - uses: pnpm/action-setup@v4 + with: + version: ${{ inputs.pnpm-version }} + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ inputs.node-version }} + cache: 'pnpm' + - name: Install JS dependencies + run: pnpm install ${{ inputs.pnpm-install-args }} + shell: bash diff --git a/.github/actions/poetry-python-install/action.yaml b/.github/actions/poetry-python-install/action.yaml index 8447c053dc..fe26355f64 100644 --- a/.github/actions/poetry-python-install/action.yaml +++ b/.github/actions/poetry-python-install/action.yaml @@ -16,10 +16,6 @@ inputs: description: 'Extra arguments for poetry install, e.g. --with tests.' required: false -defaults: - run: - shell: bash - runs: using: 'composite' steps: diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index eb2b3a559a..cf745c10e1 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -19,16 +19,12 @@ jobs: poetry-version: 1.8.3 poetry-working-directory: ${{ env.BACKEND_DIR }} poetry-install-args: --with tests - - uses: pnpm/action-setup@v4 + - uses: ./.github/actions/pnpm-node-install + name: Install Node, pnpm and dependencies. with: - version: 9.7.0 - - name: Use Node.js 22.7.0 - uses: actions/setup-node@v4 - with: - node-version: '22.7.0' - cache: 'pnpm' - - name: Install JS dependencies - run: pnpm install --no-frozen-lockfile + node-version: 22.7.0 + pnpm-version: 9.7.0 + pnpm-install-args: --no-frozen-lockfile - name: Build UI run: pnpm run buildUi - name: Lint UI diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 5589487ff5..4a0c44081f 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -24,14 +24,12 @@ jobs: - uses: actions/checkout@v3 with: ref: main - - uses: pnpm/action-setup@v2 + - uses: ./.github/actions/pnpm-node-install + name: Install Node, pnpm and dependencies. with: - version: 8.6.9 - - name: Use Node.js 22.7.0 - uses: actions/setup-node@v3 - with: - node-version: '22.7.0' - cache: 'pnpm' + node-version: 22.7.0 + pnpm-version: 9.7.0 + pnpm-install-args: --no-frozen-lockfile - uses: ./.github/actions/poetry-python-install name: Install Python, poetry and Python dependencies with: @@ -40,8 +38,6 @@ jobs: poetry-working-directory: ${{ env.BACKEND_DIR }} - name: Copy readme to backend run: cp README.md backend/ - - name: Install JS dependencies - run: pnpm install --no-frozen-lockfile - name: Build chainlit run: pnpm run build - name: Publish package distributions to PyPI From 0491d016e0dae5e9f755e15bf4e315f8b67442c1 Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Wed, 4 Sep 2024 13:12:25 +0100 Subject: [PATCH 25/45] prepublishOnly; don't pnpm build twice. --- frontend/package.json | 2 +- libs/react-client/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index 3a66753b03..14fcd8bb75 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,7 +11,7 @@ "lint": "eslint ./src --ext .ts,.tsx && tsc --noemit", "format": "prettier src/**/*.{ts,tsx} --write --loglevel error", "test": "vitest run", - "prepublish": "pnpm run build && pnpm test" + "prepublishOnly": "pnpm run build && pnpm test" }, "dependencies": { "@chainlit/react-client": "workspace:^", diff --git a/libs/react-client/package.json b/libs/react-client/package.json index 539cab287d..cc9a635fb1 100644 --- a/libs/react-client/package.json +++ b/libs/react-client/package.json @@ -8,7 +8,7 @@ "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "format": "prettier **/*.{ts,tsx} --write --loglevel error", "test": "echo no tests yet", - "prepublish": "pnpm run build" + "prepublishOnly": "pnpm run build" }, "repository": { "type": "git", From bbc5e6827e15079d114a584594bd7c00efba3d20 Mon Sep 17 00:00:00 2001 From: pioman22 <53273757+pioman22@users.noreply.github.com> Date: Thu, 5 Sep 2024 12:14:03 +0200 Subject: [PATCH 26/45] Added missing feedback_id. (#1144) Co-authored-by: Mathijs de Bruin --- backend/chainlit/data/sql_alchemy.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/chainlit/data/sql_alchemy.py b/backend/chainlit/data/sql_alchemy.py index d066b044ad..acde0ea7ba 100644 --- a/backend/chainlit/data/sql_alchemy.py +++ b/backend/chainlit/data/sql_alchemy.py @@ -504,7 +504,8 @@ async def get_all_user_threads( s."language" AS step_language, s."indent" AS step_indent, f."value" AS feedback_value, - f."comment" AS feedback_comment + 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 From 466491214e180713601903a57901f624f7e0a85f Mon Sep 17 00:00:00 2001 From: duckboy81 Date: Thu, 5 Sep 2024 06:15:03 -0400 Subject: [PATCH 27/45] Removed redundant key/value pair from dictionary creator (#1311) --- backend/chainlit/message.py | 1 - 1 file changed, 1 deletion(-) diff --git a/backend/chainlit/message.py b/backend/chainlit/message.py index d7b6c99740..637fa44957 100644 --- a/backend/chainlit/message.py +++ b/backend/chainlit/message.py @@ -82,7 +82,6 @@ def to_dict(self) -> StepDict: "output": self.content, "name": self.author, "type": self.type, - "createdAt": self.created_at, "language": self.language, "streaming": self.streaming, "isError": self.is_error, From d4eeeb8f8055e1d5f90607f8cfcbf28b89618952 Mon Sep 17 00:00:00 2001 From: AidanShipperley <70974869+AidanShipperley@users.noreply.github.com> Date: Sat, 7 Sep 2024 06:33:08 -0400 Subject: [PATCH 28/45] Allow clicking links in chat profile description (#1276), resolves #1256 This pull request resolves #1256, where users are unable to interact with chat profile descriptions, such as scrolling through long descriptions or clicking links within them. The modifications ensure that the description popover remains open and interactable when the mouse hovers over it, and closes appropriately when the mouse leaves the popover area or a selection is made. --- cypress/e2e/chat_profiles/main.py | 2 +- cypress/e2e/chat_profiles/spec.cy.ts | 56 +++++++++++++++++++ .../src/components/molecules/chatProfiles.tsx | 25 +++++++-- 3 files changed, 77 insertions(+), 6 deletions(-) diff --git a/cypress/e2e/chat_profiles/main.py b/cypress/e2e/chat_profiles/main.py index 04446a42c0..bc38e29c86 100644 --- a/cypress/e2e/chat_profiles/main.py +++ b/cypress/e2e/chat_profiles/main.py @@ -30,7 +30,7 @@ async def chat_profile(current_user: cl.User): ), cl.ChatProfile( name="GPT-4", - markdown_description="The underlying LLM model is **GPT-4**, a *1.5T parameter model* trained on 3.5TB of text data.", + markdown_description="The underlying LLM model is **GPT-4**, a *1.5T parameter model* trained on 3.5TB of text data. [Learn more](https://example.com/gpt4)", icon="https://picsum.photos/250", starters=starters, ), diff --git a/cypress/e2e/chat_profiles/spec.cy.ts b/cypress/e2e/chat_profiles/spec.cy.ts index f80cb5f0da..0558691fe9 100644 --- a/cypress/e2e/chat_profiles/spec.cy.ts +++ b/cypress/e2e/chat_profiles/spec.cy.ts @@ -67,4 +67,60 @@ describe('Chat profiles', () => { cy.get('#starter-ask-for-help').should('exist'); }); + + it('should keep chat profile description visible when hovering over a link', () => { + cy.visit('/'); + cy.get("input[name='email']").type('admin'); + cy.get("input[name='password']").type('admin'); + cy.get("button[type='submit']").click(); + cy.get('#chat-input').should('exist'); + + cy.get('#chat-profile-selector').parent().click(); + + // Force hover over GPT-4 profile to show description + cy.get('[data-test="select-item:GPT-4"]').trigger('mouseover', { force: true }); + + // Wait for the popover to appear and check its content + cy.get('#chat-profile-description').within(() => { + cy.contains('Learn more').should('be.visible'); + }); + + // Check if the link is present in the description and has correct attributes + const linkSelector = '#chat-profile-description a:contains("Learn more")'; + cy.get(linkSelector) + .should('have.attr', 'href', 'https://example.com/gpt4') + .and('have.attr', 'target', '_blank'); + + // Move mouse to the link + cy.get(linkSelector).trigger('mouseover', { force: true }); + + // Verify that the description is still visible after + cy.get('#chat-profile-description').within(() => { + cy.contains('Learn more').should('be.visible'); + }); + + // Verify that the link is still present and clickable + cy.get(linkSelector) + .should('exist') + .and('be.visible') + .and('not.have.css', 'pointer-events', 'none') + .and('not.have.attr', 'disabled'); + + // Ensure the chat profile selector is still open + cy.get('[data-test="select-item:GPT-4"]').should('be.visible'); + + // Select GPT-4 profile + cy.get('[data-test="select-item:GPT-4"]').click(); + + // Verify the profile has been changed + submitMessage('hello'); + cy.get('.step') + .should('have.length', 2) + .last() + .should( + 'contain', + 'starting chat with admin using the GPT-4 chat profile' + ); + + }); }); diff --git a/frontend/src/components/molecules/chatProfiles.tsx b/frontend/src/components/molecules/chatProfiles.tsx index 06f4f30029..08f2df111c 100644 --- a/frontend/src/components/molecules/chatProfiles.tsx +++ b/frontend/src/components/molecules/chatProfiles.tsx @@ -27,6 +27,7 @@ export default function ChatProfiles() { const { clear } = useChatInteract(); const [newChatProfile, setNewChatProfile] = useState(null); const [openDialog, setOpenDialog] = useState(false); + const [popoverOpen, setPopoverOpen] = useState(false); const navigate = useNavigate(); const handleClose = () => { @@ -58,8 +59,6 @@ export default function ChatProfiles() { const allowHtml = config?.features?.unsafe_allow_html; const latex = config?.features?.latex; - const popoverOpen = Boolean(anchorEl); - const items = config.chatProfiles.map((item) => { const icon = item.icon?.includes('/public') ? apiClient.buildEndpoint(item.icon) @@ -94,7 +93,8 @@ export default function ChatProfiles() { theme.palette.mode === 'light' ? '0px 2px 4px 0px #0000000D' : '0px 10px 10px 0px #0000000D', - ml: 2 + ml: 2, + pointerEvents: 'auto' // Allow mouse interaction with the chat profile description } }} sx={{ @@ -110,6 +110,11 @@ export default function ChatProfiles() { horizontal: 'left' }} disableRestoreFocus + onMouseEnter={() => setPopoverOpen(true)} + onMouseLeave={() => { + setPopoverOpen(false); + setAnchorEl(null); + }} > setAnchorEl(null)} + onItemMouseLeave={() => setPopoverOpen(false)} onChange={(e) => { const newValue = e.target.value; + + // Close the chat profile description when any selection is made + setPopoverOpen(false); + setAnchorEl(null); + + // Handle user selection setNewChatProfile(newValue); if (firstInteraction) { setOpenDialog(true); @@ -145,7 +157,10 @@ export default function ChatProfiles() { handleConfirm(newValue); } }} - onClose={() => setAnchorEl(null)} + onClose={() => { + setPopoverOpen(false); + setAnchorEl(null); + }} /> Date: Mon, 9 Sep 2024 11:49:59 +0100 Subject: [PATCH 29/45] Data layer cleanup (#1288) * Factor out data layer from `__init__.py`. * Make BaseDataLayer an Abstract Base Class, remove unused delete_user_session(). * Change BaseStorageClient from Protocol into Abstract Base Class. * Reduce imports in base class. * f-string without placeholders * Fix typing thread_queues. * Rename ChainlitDataLayer to LiteralDataLayer for consistency. * Fixups in e2e test for data_layer. * Various other small fixups. --- backend/chainlit/data/__init__.py | 527 +---------------------- backend/chainlit/data/base.py | 121 ++++++ backend/chainlit/data/dynamodb.py | 7 +- backend/chainlit/data/literalai.py | 395 +++++++++++++++++ backend/chainlit/data/sql_alchemy.py | 14 +- backend/chainlit/data/storage_clients.py | 84 +++- backend/chainlit/data/utils.py | 29 ++ backend/chainlit/session.py | 5 +- cypress/e2e/data_layer/main.py | 61 ++- 9 files changed, 686 insertions(+), 557 deletions(-) create mode 100644 backend/chainlit/data/base.py create mode 100644 backend/chainlit/data/literalai.py create mode 100644 backend/chainlit/data/utils.py diff --git a/backend/chainlit/data/__init__.py b/backend/chainlit/data/__init__.py index 21512f71c5..c059ff40e4 100644 --- a/backend/chainlit/data/__init__.py +++ b/backend/chainlit/data/__init__.py @@ -1,534 +1,19 @@ -import functools -import json import os -from collections import deque -from typing import ( - TYPE_CHECKING, - Any, - Dict, - List, - Literal, - Optional, - Protocol, - Union, - cast, -) +from typing import Optional -import aiofiles -from chainlit.context import context -from chainlit.logger import logger -from chainlit.session import WebsocketSession -from chainlit.types import ( - Feedback, - PageInfo, - PaginatedResponse, - Pagination, - ThreadDict, - ThreadFilter, +from .base import BaseDataLayer +from .literalai import LiteralDataLayer +from .utils import ( + queue_until_user_message as queue_until_user_message, # TODO: Consider deprecating re-export.; Redundant alias tells type checkers to STFU. ) -from chainlit.user import PersistedUser, User -from httpx import HTTPStatusError, RequestError -from literalai import Attachment -from literalai import Score as LiteralScore -from literalai import Step as LiteralStep -from literalai.filter import threads_filters as LiteralThreadsFilters -from literalai.step import StepDict as LiteralStepDict - -if TYPE_CHECKING: - from chainlit.element import Element, ElementDict - from chainlit.step import FeedbackDict, StepDict - - -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 - - -class BaseDataLayer: - """Base class for data persistence.""" - - async def get_user(self, identifier: str) -> Optional["PersistedUser"]: - return None - - async def create_user(self, user: "User") -> Optional["PersistedUser"]: - pass - - async def delete_feedback( - self, - feedback_id: str, - ) -> bool: - return True - - async def upsert_feedback( - self, - feedback: Feedback, - ) -> str: - return "" - - @queue_until_user_message() - async def create_element(self, element: "Element"): - pass - - async def get_element( - self, thread_id: str, element_id: str - ) -> Optional["ElementDict"]: - pass - - @queue_until_user_message() - async def delete_element(self, element_id: str, thread_id: Optional[str] = None): - pass - - @queue_until_user_message() - async def create_step(self, step_dict: "StepDict"): - pass - - @queue_until_user_message() - async def update_step(self, step_dict: "StepDict"): - pass - - @queue_until_user_message() - async def delete_step(self, step_id: str): - pass - - async def get_thread_author(self, thread_id: str) -> str: - return "" - - async def delete_thread(self, thread_id: str): - pass - - async def list_threads( - self, pagination: "Pagination", filters: "ThreadFilter" - ) -> "PaginatedResponse[ThreadDict]": - return PaginatedResponse( - data=[], - pageInfo=PageInfo(hasNextPage=False, startCursor=None, endCursor=None), - ) - - async def get_thread(self, thread_id: str) -> "Optional[ThreadDict]": - 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, - ): - pass - - async def delete_user_session(self, id: str) -> bool: - return True - - async def build_debug_url(self) -> str: - return "" - _data_layer: Optional[BaseDataLayer] = None -class ChainlitDataLayer(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") - - def attachment_to_element_dict(self, attachment: Attachment) -> "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"), - "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, - } - - def score_to_feedback_dict( - self, 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, - } - - def step_to_step_dict(self, 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": self.score_to_feedback_dict(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), - "indent": metadata.get("indent"), - "language": metadata.get("language"), - "isError": bool(step.error), - "waitForAnswer": metadata.get("waitForAnswer", False), - } - - 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), - } - - 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 = await f.read() # type: Union[bytes, str] - 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 self.attachment_to_element_dict(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"}, - ) - return PaginatedResponse( - pageInfo=PageInfo( - hasNextPage=literal_response.pageInfo.hasNextPage, - startCursor=literal_response.pageInfo.startCursor, - endCursor=literal_response.pageInfo.endCursor, - ), - data=literal_response.data, - ) - - async def get_thread(self, thread_id: str) -> "Optional[ThreadDict]": - from chainlit.step import check_add_step_in_cot, stub_step - - 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(self.attachment_to_element_dict(attachment)) - - if check_add_step_in_cot(step): - steps.append(self.step_to_step_dict(step)) - else: - steps.append(stub_step(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, - ) - - -class BaseStorageClient(Protocol): - """Base class for non-text data persistence like Azure Data Lake, S3, Google Storage, etc.""" - - async def upload_file( - self, - object_key: str, - data: Union[bytes, str], - mime: str = "application/octet-stream", - overwrite: bool = True, - ) -> Dict[str, Any]: - pass - - if api_key := os.environ.get("LITERAL_API_KEY"): # support legacy LITERAL_SERVER variable as fallback server = os.environ.get("LITERAL_API_URL") or os.environ.get("LITERAL_SERVER") - _data_layer = ChainlitDataLayer(api_key=api_key, server=server) + _data_layer = LiteralDataLayer(api_key=api_key, server=server) def get_data_layer(): diff --git a/backend/chainlit/data/base.py b/backend/chainlit/data/base.py new file mode 100644 index 0000000000..d34eaaa899 --- /dev/null +++ b/backend/chainlit/data/base.py @@ -0,0 +1,121 @@ +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union + +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 + + +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, + ) -> Dict[str, Any]: + pass diff --git a/backend/chainlit/data/dynamodb.py b/backend/chainlit/data/dynamodb.py index 0b63614318..ec0f1418fa 100644 --- a/backend/chainlit/data/dynamodb.py +++ b/backend/chainlit/data/dynamodb.py @@ -12,7 +12,8 @@ import boto3 # type: ignore from boto3.dynamodb.types import TypeDeserializer, TypeSerializer from chainlit.context import context -from chainlit.data import BaseDataLayer, BaseStorageClient, queue_until_user_message +from chainlit.data.base import BaseDataLayer, 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 @@ -36,7 +37,6 @@ class DynamoDBDataLayer(BaseDataLayer): - def __init__( self, table_name: str, @@ -579,8 +579,5 @@ async def update_thread( updates=item, ) - async def delete_user_session(self, id: str) -> bool: - return True # Not sure why documentation wants this - async def build_debug_url(self) -> str: return "" diff --git a/backend/chainlit/data/literalai.py b/backend/chainlit/data/literalai.py new file mode 100644 index 0000000000..5572dbf4a9 --- /dev/null +++ b/backend/chainlit/data/literalai.py @@ -0,0 +1,395 @@ +import json +from typing import TYPE_CHECKING, Dict, List, Literal, Optional, Union, cast + +import aiofiles +from chainlit.data.base import BaseDataLayer +from chainlit.data.utils import queue_until_user_message +from chainlit.logger import logger +from chainlit.types import ( + Feedback, + PageInfo, + PaginatedResponse, + Pagination, + ThreadDict, + ThreadFilter, +) +from chainlit.user import PersistedUser, User +from httpx import HTTPStatusError, RequestError +from literalai import Attachment +from literalai import Score as LiteralScore +from literalai import Step as LiteralStep +from literalai.filter import threads_filters as LiteralThreadsFilters +from literalai.step import StepDict as LiteralStepDict + +if TYPE_CHECKING: + from chainlit.element import Element, ElementDict + from chainlit.step import FeedbackDict, StepDict + + +_data_layer: Optional[BaseDataLayer] = None + + +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") + + def attachment_to_element_dict(self, attachment: Attachment) -> "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"), + "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, + } + + def score_to_feedback_dict( + self, 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, + } + + def step_to_step_dict(self, 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": self.score_to_feedback_dict(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), + "indent": metadata.get("indent"), + "language": metadata.get("language"), + "isError": bool(step.error), + "waitForAnswer": metadata.get("waitForAnswer", False), + } + + 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), + } + + 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 self.attachment_to_element_dict(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"}, + ) + return PaginatedResponse( + pageInfo=PageInfo( + hasNextPage=literal_response.pageInfo.hasNextPage, + startCursor=literal_response.pageInfo.startCursor, + endCursor=literal_response.pageInfo.endCursor, + ), + data=literal_response.data, + ) + + async def get_thread(self, thread_id: str) -> "Optional[ThreadDict]": + from chainlit.step import check_add_step_in_cot, stub_step + + 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(self.attachment_to_element_dict(attachment)) + + if check_add_step_in_cot(step): + steps.append(self.step_to_step_dict(step)) + else: + steps.append(stub_step(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, + ) diff --git a/backend/chainlit/data/sql_alchemy.py b/backend/chainlit/data/sql_alchemy.py index acde0ea7ba..98068baa20 100644 --- a/backend/chainlit/data/sql_alchemy.py +++ b/backend/chainlit/data/sql_alchemy.py @@ -8,7 +8,8 @@ import aiofiles import aiohttp from chainlit.context import context -from chainlit.data import BaseDataLayer, BaseStorageClient, queue_until_user_message +from chainlit.data.base import BaseDataLayer, 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 @@ -54,7 +55,9 @@ def __init__( self.engine: AsyncEngine = create_async_engine( self._conninfo, connect_args=ssl_args ) - self.async_session = sessionmaker(bind=self.engine, expire_on_commit=False, class_=AsyncSession) # type: ignore + 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: @@ -378,7 +381,7 @@ async def create_element(self, element: "Element"): raise ValueError("No authenticated user in context") if not self.storage_provider: logger.warn( - f"SQLAlchemy: create_element error. No blob_storage_client is configured!" + "SQLAlchemy: create_element error. No blob_storage_client is configured!" ) return if not element.for_id: @@ -440,15 +443,12 @@ async def delete_element(self, element_id: str, thread_id: Optional[str] = None) parameters = {"id": element_id} await self.execute_sql(query=query, parameters=parameters) - async def delete_user_session(self, id: str) -> bool: - return False # Not sure why documentation wants this - 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(f"SQLAlchemy: get_all_user_threads") + logger.info("SQLAlchemy: get_all_user_threads") user_threads_query = """ SELECT "id" AS thread_id, diff --git a/backend/chainlit/data/storage_clients.py b/backend/chainlit/data/storage_clients.py index 7242b76e00..42d5f5e40a 100644 --- a/backend/chainlit/data/storage_clients.py +++ b/backend/chainlit/data/storage_clients.py @@ -1,11 +1,22 @@ -from chainlit.data import BaseStorageClient +from typing import TYPE_CHECKING, Any, Dict, Optional, Union + +import boto3 # type: ignore +from azure.storage.filedatalake import ( + ContentSettings, + DataLakeFileClient, + DataLakeServiceClient, + FileSystemClient, +) +from chainlit.data.base import BaseStorageClient from chainlit.logger import logger -from typing import TYPE_CHECKING, Optional, Dict, Union, Any -from azure.storage.filedatalake import DataLakeServiceClient, FileSystemClient, DataLakeFileClient, ContentSettings -import boto3 # type: ignore if TYPE_CHECKING: - from azure.core.credentials import AzureNamedKeyCredential, AzureSasCredential, TokenCredential + from azure.core.credentials import ( + AzureNamedKeyCredential, + AzureSasCredential, + TokenCredential, + ) + class AzureStorageClient(BaseStorageClient): """ @@ -16,30 +27,65 @@ class AzureStorageClient(BaseStorageClient): 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): + + 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.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.warn(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) -> Dict[str, Any]: + + async def upload_file( + self, + object_key: str, + data: Union[bytes, str], + mime: str = "application/octet-stream", + overwrite: bool = True, + ) -> Dict[str, Any]: try: - file_client: DataLakeFileClient = self.container_client.get_file_client(object_key) + file_client: DataLakeFileClient = self.container_client.get_file_client( + object_key + ) content_settings = ContentSettings(content_type=mime) - 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 + 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.warn(f"AzureStorageClient, upload_file error: {e}") return {} + class S3StorageClient(BaseStorageClient): """ Class to enable Amazon S3 storage provider """ + def __init__(self, bucket: str): try: self.bucket = bucket @@ -48,9 +94,17 @@ def __init__(self, bucket: str): except Exception as e: logger.warn(f"S3StorageClient initialization error: {e}") - async def upload_file(self, object_key: str, data: Union[bytes, str], mime: str = 'application/octet-stream', overwrite: bool = True) -> Dict[str, Any]: + async def upload_file( + self, + object_key: str, + data: Union[bytes, str], + mime: str = "application/octet-stream", + overwrite: bool = True, + ) -> Dict[str, Any]: try: - self.client.put_object(Bucket=self.bucket, Key=object_key, Body=data, ContentType=mime) + self.client.put_object( + Bucket=self.bucket, Key=object_key, Body=data, ContentType=mime + ) url = f"https://{self.bucket}.s3.amazonaws.com/{object_key}" return {"object_key": object_key, "url": url} except Exception as e: diff --git a/backend/chainlit/data/utils.py b/backend/chainlit/data/utils.py new file mode 100644 index 0000000000..23dc329b5d --- /dev/null +++ b/backend/chainlit/data/utils.py @@ -0,0 +1,29 @@ +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 diff --git a/backend/chainlit/session.py b/backend/chainlit/session.py index f23e39ca40..f73a635558 100644 --- a/backend/chainlit/session.py +++ b/backend/chainlit/session.py @@ -193,6 +193,9 @@ def delete(self): shutil.rmtree(self.files_dir) +ThreadQueue = Deque[tuple[Callable, object, tuple, Dict]] + + class WebsocketSession(BaseSession): """Internal web socket session object. @@ -250,7 +253,7 @@ def __init__( self.restored = False - self.thread_queues = {} # type: Dict[str, Deque[Callable]] + self.thread_queues: Dict[str, ThreadQueue] = {} ws_sessions_id[self.id] = self ws_sessions_sid[socket_id] = self diff --git a/cypress/e2e/data_layer/main.py b/cypress/e2e/data_layer/main.py index e1752215fc..6fa6dc7f07 100644 --- a/cypress/e2e/data_layer/main.py +++ b/cypress/e2e/data_layer/main.py @@ -3,8 +3,19 @@ from typing import Dict, List, Optional import chainlit.data as cl_data +from chainlit.data.utils import queue_until_user_message +from chainlit.element import Element, ElementDict from chainlit.socket import persist_user_session from chainlit.step import StepDict +from chainlit.types import ( + Feedback, + PageInfo, + PaginatedResponse, + Pagination, + ThreadDict, + ThreadFilter, +) +from chainlit.user import PersistedUser, User from literalai.helper import utc_now import chainlit as cl @@ -58,7 +69,7 @@ }, ], }, -] # type: List[cl_data.ThreadDict] +] # type: List[ThreadDict] deleted_thread_ids = [] # type: List[str] THREAD_HISTORY_PICKLE_PATH = os.getenv("THREAD_HISTORY_PICKLE_PATH") @@ -131,13 +142,11 @@ async def get_thread_author(self, thread_id: str): return "admin" async def list_threads( - self, pagination: cl_data.Pagination, filters: cl_data.ThreadFilter - ) -> cl_data.PaginatedResponse[cl_data.ThreadDict]: - return cl_data.PaginatedResponse( + self, pagination: Pagination, filters: ThreadFilter + ) -> PaginatedResponse[ThreadDict]: + return PaginatedResponse( data=[t for t in thread_history if t["id"] not in deleted_thread_ids], - pageInfo=cl_data.PageInfo( - hasNextPage=False, startCursor=None, endCursor=None - ), + pageInfo=PageInfo(hasNextPage=False, startCursor=None, endCursor=None), ) async def get_thread(self, thread_id: str): @@ -150,6 +159,42 @@ async def get_thread(self, thread_id: str): async def delete_thread(self, thread_id: str): deleted_thread_ids.append(thread_id) + async def delete_feedback( + self, + feedback_id: str, + ) -> bool: + return True + + async def upsert_feedback( + self, + feedback: Feedback, + ) -> str: + return "" + + @queue_until_user_message() + async def create_element(self, element: "Element"): + pass + + async def get_element( + self, thread_id: str, element_id: str + ) -> Optional["ElementDict"]: + pass + + @queue_until_user_message() + async def delete_element(self, element_id: str, thread_id: Optional[str] = None): + pass + + @queue_until_user_message() + async def update_step(self, step_dict: "StepDict"): + pass + + @queue_until_user_message() + async def delete_step(self, step_id: str): + pass + + async def build_debug_url(self) -> str: + return "" + cl_data._data_layer = TestDataLayer() @@ -189,7 +234,7 @@ def auth_callback(username: str, password: str) -> Optional[cl.User]: @cl.on_chat_resume -async def on_chat_resume(thread: cl_data.ThreadDict): +async def on_chat_resume(thread: ThreadDict): await cl.Message(f"Welcome back to {thread['name']}").send() if "metadata" in thread: await cl.Message(thread["metadata"], author="metadata", language="json").send() From 15170cf3713a3f82f0cba69d0f43867a4f71408b Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Mon, 9 Sep 2024 11:50:15 +0100 Subject: [PATCH 30/45] Factor out callbacks and extensive test coverage. (#1292) * Factor out callbacks from __init__.py. * Cleanup/factor out common components in tests. * Unit test coverage for callbacks. * Adjust callback types to match reality. * Remove fixture already present in conftest.py --- backend/chainlit/__init__.py | 360 +++---------------- backend/chainlit/callbacks.py | 308 ++++++++++++++++ backend/chainlit/config.py | 30 +- backend/tests/conftest.py | 53 +++ backend/tests/llama_index/test_callbacks.py | 34 +- backend/tests/test_callbacks.py | 373 ++++++++++++++++++++ backend/tests/test_context.py | 12 +- backend/tests/test_emitter.py | 80 +++-- backend/tests/test_user_session.py | 39 -- 9 files changed, 854 insertions(+), 435 deletions(-) create mode 100644 backend/chainlit/callbacks.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_callbacks.py diff --git a/backend/chainlit/__init__.py b/backend/chainlit/__init__.py index 00b969994b..2d3adc94a0 100644 --- a/backend/chainlit/__init__.py +++ b/backend/chainlit/__init__.py @@ -1,33 +1,12 @@ -import inspect -import os - -from dotenv import load_dotenv - -env_found = load_dotenv(dotenv_path=os.path.join(os.getcwd(), ".env")) - import asyncio -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional - -from fastapi import Request, Response -from pydantic.dataclasses import dataclass -from starlette.datastructures import Headers - -if TYPE_CHECKING: - from chainlit.haystack.callbacks import HaystackAgentCallbackHandler - from chainlit.langchain.callbacks import ( - LangchainCallbackHandler, - AsyncLangchainCallbackHandler, - ) - from chainlit.llama_index.callbacks import LlamaIndexCallbackHandler - from chainlit.openai import instrument_openai - from chainlit.mistralai import instrument_mistralai +import os +from typing import TYPE_CHECKING, Any, Dict 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.config import config from chainlit.context import context from chainlit.element import ( Audio, @@ -51,303 +30,51 @@ ErrorMessage, Message, ) -from chainlit.oauth_providers import get_configured_oauth_providers from chainlit.step import Step, step from chainlit.sync import make_async, run_sync -from chainlit.telemetry import trace -from chainlit.types import AudioChunk, ChatProfile, Starter, ThreadDict +from chainlit.types import AudioChunk, ChatProfile, Starter from chainlit.user import PersistedUser, User from chainlit.user_session import user_session -from chainlit.utils import make_module_getattr, wrap_user_function +from chainlit.utils import make_module_getattr from chainlit.version import __version__ +from dotenv import load_dotenv from literalai import ChatGeneration, CompletionGeneration, GenerationMessage +from pydantic.dataclasses import dataclass -if env_found: - logger.info("Loaded .env file") - - -@trace -def password_auth_callback(func: Callable[[str, str], Optional[User]]) -> Callable: - """ - Framework agnostic decorator to authenticate the user. - - Args: - func (Callable[[str, str], 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], Optional[User]]: The decorated authentication callback. - """ - - config.code.password_auth_callback = wrap_user_function(func) - return func - - -@trace -def header_auth_callback(func: Callable[[Headers], Optional[User]]) -> Callable: - """ - Framework agnostic decorator to authenticate the user via a header - - Args: - func (Callable[[Headers], Optional[User]]): The authentication callback to execute. - - Example: - @cl.header_auth_callback - async def header_auth_callback(headers: Headers) -> Optional[User]: - - Returns: - Callable[[Headers], Optional[User]]: The decorated authentication callback. - """ - - config.code.header_auth_callback = wrap_user_function(func) - return func - - -@trace -def oauth_callback( - func: Callable[[str, str, Dict[str, str], User], Optional[User]] -) -> Callable: - """ - Framework agnostic decorator to authenticate the user via oauth - - Args: - func (Callable[[str, str, Dict[str, str], User], 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[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 - - -@trace -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 - - -@trace -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 - - -@trace -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 - - -@trace -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 - - -@trace -def set_chat_profiles( - func: Callable[[Optional["User"]], List["ChatProfile"]] -) -> Callable: - """ - Programmatic declaration of the available chat profiles (can depend on the User from the session if authentication is setup). - - Args: - func (Callable[[Optional["User"]], List["ChatProfile"]]): The function declaring the chat profiles. - - Returns: - Callable[[Optional["User"]], List["ChatProfile"]]: The decorated function. - """ - - config.code.set_chat_profiles = wrap_user_function(func) - return func - - -@trace -def set_starters(func: Callable[[Optional["User"]], List["Starter"]]) -> Callable: - """ - Programmatic declaration of the available starter (can depend on the User from the session if authentication is setup). - - Args: - func (Callable[[Optional["User"]], List["Starter"]]): The function declaring the starters. - - Returns: - Callable[[Optional["User"]], List["Starter"]]: The decorated function. - """ - - config.code.set_starters = wrap_user_function(func) - return func - - -@trace -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 - - -@trace -def on_audio_chunk(func: Callable) -> Callable: - """ - Hook to react to the audio chunks being sent. - - Args: - chunk (AudioChunk): 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 - - -@trace -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. - - Args: - elements ([List[Element]): The files that were uploaded before starting the audio stream (if any). - - Returns: - Callable[], Any]: The decorated hook. - """ +from .callbacks import ( + action_callback, + author_rename, + header_auth_callback, + oauth_callback, + on_audio_chunk, + on_audio_end, + on_chat_end, + on_chat_resume, + on_chat_start, + on_logout, + on_message, + on_settings_update, + on_stop, + password_auth_callback, + set_chat_profiles, + set_starters, +) - config.code.on_audio_end = wrap_user_function( - step(func, name="on_audio_end", type="run"), with_task=True +if TYPE_CHECKING: + from chainlit.haystack.callbacks import HaystackAgentCallbackHandler + from chainlit.langchain.callbacks import ( + AsyncLangchainCallbackHandler, + LangchainCallbackHandler, ) - return func - - -@trace -def author_rename(func: Callable[[str], str]) -> Callable[[str], str]: - """ - Useful to rename the author of message to display more friendly author names in the UI. - Args: - func (Callable[[str], str]): The function to be called to rename an author. Takes the original author name as parameter. - - Returns: - Callable[[Any, str], Any]: The decorated function. - """ - - config.code.author_rename = wrap_user_function(func) - return func - - -@trace -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=True) - return func - - return decorator - + from chainlit.llama_index.callbacks import LlamaIndexCallbackHandler + from chainlit.mistralai import instrument_mistralai + from chainlit.openai import instrument_openai -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. - """ +env_found = load_dotenv(dotenv_path=os.path.join(os.getcwd(), ".env")) - config.code.on_settings_update = wrap_user_function(func, with_task=True) - return func +if env_found: + logger.info("Loaded .env file") def sleep(duration: int): @@ -380,6 +107,7 @@ def acall(self): ) __all__ = [ + "__version__", "ChatProfile", "Starter", "user_session", @@ -434,6 +162,22 @@ def acall(self): "HaystackAgentCallbackHandler", "instrument_openai", "instrument_mistralai", + "password_auth_callback", + "header_auth_callback", + "oauth_callback", + "on_logout", + "on_message", + "on_chat_start", + "on_chat_resume", + "set_chat_profiles", + "set_starters", + "on_chat_end", + "on_audio_chunk", + "on_audio_end", + "author_rename", + "on_stop", + "action_callback", + "on_settings_update", ] diff --git a/backend/chainlit/callbacks.py b/backend/chainlit/callbacks.py new file mode 100644 index 0000000000..b559049d7b --- /dev/null +++ b/backend/chainlit/callbacks.py @@ -0,0 +1,308 @@ +import inspect +from typing import Any, Awaitable, Callable, Dict, List, Optional + +from chainlit.action import Action +from chainlit.config import config +from chainlit.message import Message +from chainlit.oauth_providers import get_configured_oauth_providers +from chainlit.step import Step, step +from chainlit.telemetry import trace +from chainlit.types import ChatProfile, Starter, ThreadDict +from chainlit.user import User +from chainlit.utils import wrap_user_function +from fastapi import Request, Response +from starlette.datastructures import Headers + + +@trace +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 + + +@trace +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 + + +@trace +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 + + +@trace +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 + + +@trace +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 + + +@trace +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 + + +@trace +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 + + +@trace +def set_chat_profiles( + func: Callable[[Optional["User"]], Awaitable[List["ChatProfile"]]], +) -> Callable: + """ + 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 + + +@trace +def set_starters( + func: Callable[[Optional["User"]], Awaitable[List["Starter"]]] +) -> Callable: + """ + Programmatic declaration of the available starter (can depend on the User from the session if authentication is setup). + + Args: + func (Callable[[Optional["User"]], Awaitable[List["Starter"]]]): The function declaring the starters. + + Returns: + Callable[[Optional["User"]], Awaitable[List["Starter"]]]: The decorated function. + """ + + config.code.set_starters = wrap_user_function(func) + return func + + +@trace +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 + + +@trace +def on_audio_chunk(func: Callable) -> Callable: + """ + Hook to react to the audio chunks being sent. + + Args: + chunk (AudioChunk): 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 + + +@trace +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. + + Args: + elements ([List[Element]): The files that were uploaded before starting the audio stream (if any). + + 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 + + +@trace +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 + + +@trace +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=True) + 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 diff --git a/backend/chainlit/config.py b/backend/chainlit/config.py index e2523a9781..a558269206 100644 --- a/backend/chainlit/config.py +++ b/backend/chainlit/config.py @@ -4,7 +4,17 @@ import sys from importlib import util from pathlib import Path -from typing import TYPE_CHECKING, Any, Callable, Dict, List, Literal, Optional, Union +from typing import ( + TYPE_CHECKING, + Any, + Awaitable, + Callable, + Dict, + List, + Literal, + Optional, + Union, +) import tomli from chainlit.logger import logger @@ -270,10 +280,14 @@ class CodeSettings: # Module object loaded from the module_name module: Any = None # Bunch of callbacks defined by the developer - password_auth_callback: Optional[Callable[[str, str], Optional["User"]]] = None - header_auth_callback: Optional[Callable[[Headers], Optional["User"]]] = None + 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"], Optional["User"]] + Callable[[str, str, Dict[str, str], "User"], Awaitable[Optional["User"]]] ] = None on_logout: Optional[Callable[["Request", "Response"], Any]] = None on_stop: Optional[Callable[[], Any]] = None @@ -284,12 +298,14 @@ class CodeSettings: on_audio_chunk: Optional[Callable[["AudioChunk"], Any]] = None on_audio_end: Optional[Callable[[List["ElementBased"]], Any]] = None - author_rename: Optional[Callable[[str], str]] = None + author_rename: Optional[Callable[[str], Awaitable[str]]] = None on_settings_update: Optional[Callable[[Dict[str, Any]], Any]] = None - set_chat_profiles: Optional[Callable[[Optional["User"]], List["ChatProfile"]]] = ( + set_chat_profiles: Optional[ + Callable[[Optional["User"]], Awaitable[List["ChatProfile"]]] + ] = None + set_starters: Optional[Callable[[Optional["User"]], Awaitable[List["Starter"]]]] = ( None ) - set_starters: Optional[Callable[[Optional["User"]], List["Starter"]]] = None @dataclass() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000000..1a0e900c2c --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,53 @@ +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, Mock + +import pytest +import pytest_asyncio +from chainlit.context import ChainlitContext, context_var +from chainlit.session import HTTPSession, WebsocketSession +from chainlit.user_session import UserSession + + +@asynccontextmanager +async def create_chainlit_context(): + mock_session = Mock(spec=WebsocketSession) + mock_session.id = "test_session_id" + mock_session.user_env = {"test_env": "value"} + mock_session.chat_settings = {} + mock_session.user = None + mock_session.chat_profile = None + mock_session.http_referer = None + mock_session.client_type = "webapp" + mock_session.languages = ["en"] + mock_session.thread_id = "test_thread_id" + mock_session.emit = AsyncMock() + + context = ChainlitContext(mock_session) + token = context_var.set(context) + try: + yield context + finally: + context_var.reset(token) + + +@pytest_asyncio.fixture +async def mock_chainlit_context(): + return create_chainlit_context() + + +@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) diff --git a/backend/tests/llama_index/test_callbacks.py b/backend/tests/llama_index/test_callbacks.py index 66a591f93b..edf8ec4cf4 100644 --- a/backend/tests/llama_index/test_callbacks.py +++ b/backend/tests/llama_index/test_callbacks.py @@ -1,43 +1,11 @@ -from contextlib import asynccontextmanager -from unittest.mock import Mock, patch +from unittest.mock import patch -import pytest_asyncio -from chainlit.context import ChainlitContext, context_var - -# Import the class we're testing from chainlit.llama_index.callbacks import LlamaIndexCallbackHandler -from chainlit.session import WebsocketSession from chainlit.step import Step from llama_index.core.callbacks.schema import CBEventType, EventPayload from llama_index.core.tools.types import ToolMetadata -@asynccontextmanager -async def create_chainlit_context(): - mock_session = Mock(spec=WebsocketSession) - mock_session.id = "test_session_id" - mock_session.thread_id = "test_session_thread_id" - mock_session.user_env = {"test_env": "value"} - mock_session.chat_settings = {} - mock_session.user = None - mock_session.chat_profile = None - mock_session.http_referer = None - mock_session.client_type = "webapp" - mock_session.languages = ["en"] - - context = ChainlitContext(mock_session) - token = context_var.set(context) - try: - yield context - finally: - context_var.reset(token) - - -@pytest_asyncio.fixture -async def mock_chainlit_context(): - return create_chainlit_context() - - async def test_on_event_start_for_function_calls(mock_chainlit_context): TEST_EVENT_ID = "test_event_id" async with mock_chainlit_context: diff --git a/backend/tests/test_callbacks.py b/backend/tests/test_callbacks.py new file mode 100644 index 0000000000..86178cc313 --- /dev/null +++ b/backend/tests/test_callbacks.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +from chainlit.callbacks import password_auth_callback +from chainlit.config import config +from chainlit.user import User + + +async def test_password_auth_callback(): + @password_auth_callback + async def auth_func(username: str, password: str) -> User | None: + if username == "testuser" and password == "testpass": + return User(identifier="testuser") + return None + + # Test that the callback is properly registered + assert config.code.password_auth_callback is not None + + # Test the wrapped function + result = await config.code.password_auth_callback("testuser", "testpass") + assert isinstance(result, User) + assert result.identifier == "testuser" + + # Test with incorrect credentials + result = await config.code.password_auth_callback("wronguser", "wrongpass") + assert result is None + + +async def test_header_auth_callback(): + from chainlit.callbacks import header_auth_callback + from starlette.datastructures import Headers + + @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 config.code.header_auth_callback is not None + + # Test the wrapped function with valid header + valid_headers = Headers({"Authorization": "Bearer valid_token"}) + result = await 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 config.code.header_auth_callback(invalid_headers) + assert result is None + + # Test with missing header + missing_headers = Headers({}) + result = await config.code.header_auth_callback(missing_headers) + assert result is None + + +async def test_oauth_callback(): + from unittest.mock import patch + + from chainlit.callbacks import oauth_callback + from chainlit.config import config + 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": + return User(identifier="oauth_user") + return None + + # Test that the callback is properly registered + assert config.code.oauth_callback is not None + + # Test the wrapped function with valid data + result = await 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 config.code.oauth_callback( + "facebook", "invalid_token", {}, User(identifier="default_user") + ) + assert result is None + + +async def test_on_message(mock_chainlit_context): + from chainlit.callbacks import on_message + from chainlit.config import config + from chainlit.message import Message + + async with mock_chainlit_context as 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 config.code.on_message is not None + + # Create a test message + test_message = Message(content="Test message", author="User") + + # Call the registered callback + await 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" + + # Check that the emit method was called + context.session.emit.assert_called() + + +async def test_on_stop(mock_chainlit_context): + from chainlit.callbacks import on_stop + from chainlit.config import config + + 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 config.code.on_stop is not None + + # Call the registered callback + await config.code.on_stop() + + # Check that the stop_called flag was set + assert stop_called + + +async def test_action_callback(mock_chainlit_context): + from chainlit.action import Action + from chainlit.callbacks import action_callback + from chainlit.config import config + + 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 config.code.action_callbacks + + # Call the registered callback + test_action = Action(name="test_action", value="test_value") + await 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): + from chainlit.callbacks import on_settings_update + from chainlit.config import config + + 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 config.code.on_settings_update is not None + + # Call the registered callback + await 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(): + from chainlit.callbacks import author_rename + from chainlit.config import config + + @author_rename + async def rename_author(author: str) -> str: + if author == "AI": + return "Assistant" + return author + + # Test that the callback is properly registered + assert config.code.author_rename is not None + + # Call the registered callback + result = await config.code.author_rename("AI") + assert result == "Assistant" + + result = await config.code.author_rename("Human") + assert result == "Human" + + # Test that the callback is properly registered + assert config.code.author_rename is not None + + # Call the registered callback + result = await config.code.author_rename("AI") + assert result == "Assistant" + + result = await config.code.author_rename("Human") + assert result == "Human" + + +async def test_on_chat_start(mock_chainlit_context): + from chainlit.callbacks import on_chat_start + from chainlit.config import config + + async with mock_chainlit_context as 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 config.code.on_chat_start is not None + + # Call the registered callback + await config.code.on_chat_start() + + # Check that the chat_started flag was set + assert chat_started + + # Check that the emit method was called + context.session.emit.assert_called() + + +async def test_on_chat_resume(mock_chainlit_context): + from chainlit.callbacks import on_chat_resume + from chainlit.config import config + from chainlit.types import ThreadDict + + 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 config.code.on_chat_resume is not None + + # Call the registered callback + await 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): + from chainlit.callbacks import set_chat_profiles + from chainlit.config import config + from chainlit.types import ChatProfile + + async with mock_chainlit_context: + + @set_chat_profiles + async def get_chat_profiles(user): + return [ + ChatProfile(name="Test Profile", markdown_description="A test profile") + ] + + # Test that the callback is properly registered + assert config.code.set_chat_profiles is not None + + # Call the registered callback + result = await config.code.set_chat_profiles(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_starters(mock_chainlit_context): + from chainlit.callbacks import set_starters + from chainlit.config import config + 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 config.code.set_starters is not None + + # Call the registered callback + result = await config.code.set_starters(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_on_chat_end(mock_chainlit_context): + from chainlit.callbacks import on_chat_end + from chainlit.config import config + + async with mock_chainlit_context as 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 config.code.on_chat_end is not None + + # Call the registered callback + await config.code.on_chat_end() + + # Check that the chat_ended flag was set + assert chat_ended + + # Check that the emit method was called + context.session.emit.assert_called() diff --git a/backend/tests/test_context.py b/backend/tests/test_context.py index 9a64667f7c..3e722ef7b8 100644 --- a/backend/tests/test_context.py +++ b/backend/tests/test_context.py @@ -9,17 +9,7 @@ init_ws_context, ) from chainlit.emitter import BaseChainlitEmitter, ChainlitEmitter -from chainlit.session import HTTPSession, WebsocketSession - - -@pytest.fixture -def mock_websocket_session(): - return Mock(spec=WebsocketSession) - - -@pytest.fixture -def mock_http_session(): - return Mock(spec=HTTPSession) +from chainlit.session import HTTPSession @pytest.fixture diff --git a/backend/tests/test_emitter.py b/backend/tests/test_emitter.py index 242acf8808..48c6df017d 100644 --- a/backend/tests/test_emitter.py +++ b/backend/tests/test_emitter.py @@ -1,23 +1,19 @@ +from unittest.mock import MagicMock + import pytest -from unittest.mock import AsyncMock, MagicMock -from chainlit.emitter import ChainlitEmitter from chainlit.element import ElementDict +from chainlit.emitter import ChainlitEmitter from chainlit.step import StepDict @pytest.fixture -def mock_session(): - session = MagicMock() - session.emit = AsyncMock() - return session - - -@pytest.fixture -def emitter(mock_session): - return ChainlitEmitter(mock_session) +def emitter(mock_websocket_session): + return ChainlitEmitter(mock_websocket_session) -async def test_send_element(emitter: ChainlitEmitter, mock_session: MagicMock) -> None: +async def test_send_element( + emitter: ChainlitEmitter, mock_websocket_session: MagicMock +) -> None: element_dict: ElementDict = { "id": "test_element", "threadId": None, @@ -33,15 +29,17 @@ async def test_send_element(emitter: ChainlitEmitter, mock_session: MagicMock) - "autoPlay": None, "playerConfig": None, "forId": None, - "mime": None + "mime": None, } await emitter.send_element(element_dict) - mock_session.emit.assert_called_once_with("element", element_dict) + mock_websocket_session.emit.assert_called_once_with("element", element_dict) -async def test_send_step(emitter: ChainlitEmitter, mock_session: MagicMock) -> None: +async def test_send_step( + emitter: ChainlitEmitter, mock_websocket_session: MagicMock +) -> None: step_dict: StepDict = { "id": "test_step", "type": "user_message", @@ -51,10 +49,12 @@ async def test_send_step(emitter: ChainlitEmitter, mock_session: MagicMock) -> N await emitter.send_step(step_dict) - mock_session.emit.assert_called_once_with("new_message", step_dict) + mock_websocket_session.emit.assert_called_once_with("new_message", step_dict) -async def test_update_step(emitter: ChainlitEmitter, mock_session: MagicMock) -> None: +async def test_update_step( + emitter: ChainlitEmitter, mock_websocket_session: MagicMock +) -> None: step_dict: StepDict = { "id": "test_step", "type": "assistant_message", @@ -64,10 +64,12 @@ async def test_update_step(emitter: ChainlitEmitter, mock_session: MagicMock) -> await emitter.update_step(step_dict) - mock_session.emit.assert_called_once_with("update_message", step_dict) + mock_websocket_session.emit.assert_called_once_with("update_message", step_dict) -async def test_delete_step(emitter: ChainlitEmitter, mock_session: MagicMock) -> None: +async def test_delete_step( + emitter: ChainlitEmitter, mock_websocket_session: MagicMock +) -> None: step_dict: StepDict = { "id": "test_step", "type": "system_message", @@ -77,57 +79,61 @@ async def test_delete_step(emitter: ChainlitEmitter, mock_session: MagicMock) -> await emitter.delete_step(step_dict) - mock_session.emit.assert_called_once_with("delete_message", step_dict) + mock_websocket_session.emit.assert_called_once_with("delete_message", step_dict) -async def test_send_timeout(emitter, mock_session): +async def test_send_timeout(emitter, mock_websocket_session): await emitter.send_timeout("ask_timeout") - mock_session.emit.assert_called_once_with("ask_timeout", {}) + mock_websocket_session.emit.assert_called_once_with("ask_timeout", {}) -async def test_clear(emitter, mock_session): +async def test_clear(emitter, mock_websocket_session): await emitter.clear("clear_ask") - mock_session.emit.assert_called_once_with("clear_ask", {}) + mock_websocket_session.emit.assert_called_once_with("clear_ask", {}) -async def test_send_token(emitter: ChainlitEmitter, mock_session: MagicMock) -> None: +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_session.emit.assert_called_once_with( + 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_session): +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_send_action_response(emitter, mock_session): +async def test_send_action_response(emitter, mock_websocket_session): await emitter.send_action_response("test_id", True, "Success") - mock_session.emit.assert_called_once_with( + mock_websocket_session.emit.assert_called_once_with( "action_response", {"id": "test_id", "status": True, "response": "Success"} ) -async def test_update_token_count(emitter, mock_session): +async def test_update_token_count(emitter, mock_websocket_session): count = 100 await emitter.update_token_count(count) - mock_session.emit.assert_called_once_with("token_usage", count) + mock_websocket_session.emit.assert_called_once_with("token_usage", count) -async def test_task_start(emitter, mock_session): +async def test_task_start(emitter, mock_websocket_session): await emitter.task_start() - mock_session.emit.assert_called_once_with("task_start", {}) + mock_websocket_session.emit.assert_called_once_with("task_start", {}) -async def test_task_end(emitter, mock_session): +async def test_task_end(emitter, mock_websocket_session): await emitter.task_end() - mock_session.emit.assert_called_once_with("task_end", {}) + mock_websocket_session.emit.assert_called_once_with("task_end", {}) -async def test_stream_start(emitter: ChainlitEmitter, mock_session: MagicMock) -> None: +async def test_stream_start( + emitter: ChainlitEmitter, mock_websocket_session: MagicMock +) -> None: step_dict: StepDict = { "id": "test_stream", "type": "run", @@ -135,4 +141,4 @@ async def test_stream_start(emitter: ChainlitEmitter, mock_session: MagicMock) - "output": "This is a test stream", } await emitter.stream_start(step_dict) - mock_session.emit.assert_called_once_with("stream_start", step_dict) + mock_websocket_session.emit.assert_called_once_with("stream_start", step_dict) diff --git a/backend/tests/test_user_session.py b/backend/tests/test_user_session.py index b3c082006d..c2c0d16b15 100644 --- a/backend/tests/test_user_session.py +++ b/backend/tests/test_user_session.py @@ -1,42 +1,3 @@ -import pytest -import pytest_asyncio -from unittest.mock import Mock -from contextlib import asynccontextmanager -from chainlit.user_session import UserSession -from chainlit.context import ChainlitContext, context_var -from chainlit.session import WebsocketSession - - -@asynccontextmanager -async def create_chainlit_context(): - mock_session = Mock(spec=WebsocketSession) - mock_session.id = "test_session_id" - mock_session.user_env = {"test_env": "value"} - mock_session.chat_settings = {} - mock_session.user = None - mock_session.chat_profile = None - mock_session.http_referer = None - mock_session.client_type = "webapp" - mock_session.languages = ["en"] - - context = ChainlitContext(mock_session) - token = context_var.set(context) - try: - yield context - finally: - context_var.reset(token) - - -@pytest_asyncio.fixture -async def mock_chainlit_context(): - return create_chainlit_context() - - -@pytest.fixture -def user_session(): - return UserSession() - - async def test_user_session_set_get(mock_chainlit_context, user_session): async with mock_chainlit_context as context: # Test setting a value From 48fae262426f13b3a7f992c1dd1ad1fbfcfc7c90 Mon Sep 17 00:00:00 2001 From: Willy Douhard Date: Tue, 10 Sep 2024 00:08:27 +0200 Subject: [PATCH 31/45] fix: step should be centered when contained in an assistant message (#1324) --- frontend/src/components/molecules/messages/Message.tsx | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/molecules/messages/Message.tsx b/frontend/src/components/molecules/messages/Message.tsx index 732d68a9c5..f85d49cb95 100644 --- a/frontend/src/components/molecules/messages/Message.tsx +++ b/frontend/src/components/molecules/messages/Message.tsx @@ -23,7 +23,6 @@ import UserMessage from './UserMessage'; interface Props { message: IStep; - showAvatar?: boolean; elements: IMessageElement[]; actions: IAction[]; indent: number; @@ -35,7 +34,6 @@ interface Props { const Message = memo( ({ message, - showAvatar = true, elements, actions, isRunning, @@ -55,7 +53,6 @@ const Message = memo( const isAsk = message.waitForAnswer; const isUserMessage = message.type === 'user_message'; const isStep = !message.type.includes('message'); - // Only keep tool calls if Chain of Thought is tool_call const toolCallSkip = isStep && config?.ui.cot === 'tool_call' && message.type !== 'tool'; @@ -136,7 +133,7 @@ const Message = memo( className="ai-message" > {!isStep || !indent ? ( - + ) : null} {/* Display the step and its children */} {isStep ? ( @@ -216,7 +213,7 @@ const Message = memo( messages={message.steps} elements={elements} actions={actions} - indent={isUserMessage ? indent : indent + 1} + indent={indent} isRunning={isRunning} /> ) : null} From f9dafa331f9cfc82369fbbfcc42b31e90766443d Mon Sep 17 00:00:00 2001 From: Daniel Avdar <66269169+DanielAvdar@users.noreply.github.com> Date: Tue, 10 Sep 2024 01:09:46 +0300 Subject: [PATCH 32/45] Add Hebrew translation JSON (#1322) Introduce a new JSON file containing Hebrew translations for various UI elements. This includes translations for buttons, error messages, settings, and other interface components. Signed-off-by: DanielAvdar <66269169+DanielAvdar@users.noreply.github.com> --- backend/chainlit/translations/he-IL.json | 231 +++++++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 backend/chainlit/translations/he-IL.json diff --git a/backend/chainlit/translations/he-IL.json b/backend/chainlit/translations/he-IL.json new file mode 100644 index 0000000000..236e489bae --- /dev/null +++ b/backend/chainlit/translations/he-IL.json @@ -0,0 +1,231 @@ +{ + "components": { + "atoms": { + "buttons": { + "userButton": { + "menu": { + "settings": "הגדרות", + "settingsKey": "S", + "APIKeys": "API Keys", + "logout": "התנתק" + } + } + } + }, + "molecules": { + "newChatButton": { + "newChat": "צ'אט חדש" + }, + "tasklist": { + "TaskList": { + "title": "\ud83d\uddd2\ufe0f Task List", + "loading": "טוען...", + "error": "שגיאה" + } + }, + "attachments": { + "cancelUpload": "בטל העלאה", + "removeAttachment": "הסר קובץ מצורף" + }, + "newChatDialog": { + "createNewChat": "צור צ'אט חדש?", + "clearChat": "פעולה זו תנקה את ההודעות הנוכחיות ותתחיל צ'אט חדש.", + "cancel": "בטל", + "confirm": "אשר" + }, + "settingsModal": { + "settings": "הגדרות", + "expandMessages": "הרחב הודעות", + "hideChainOfThought": "הסתר שרשרת מחשבות", + "darkMode": "מצב כהה" + }, + "detailsButton": { + "using": "משתמש ב-", + "running": "רץ", + "took_one": "לקח צעד {{count}}", + "took_other": "לקח צעדים {{count}}" + }, + "auth": { + "authLogin": { + "title": "התחבר כדי לגשת לאפליקציה.", + "form": { + "email": "כתובת אימייל", + "password": "סיסמא", + "noAccount": "אין לך חשבון?", + "alreadyHaveAccount": "כבר יש לך חשבון?", + "signup": "הירשם", + "signin": "היכנס", + "or": "או", + "continue": "המשך", + "forgotPassword": "שכחת סיסמה?", + "passwordMustContain": "הסיסמה שלך חייבת להכיל:", + "emailRequired": "אימייל הוא שדה חובה", + "passwordRequired": "סיסמה היא שדה חובה" + }, + "error": { + "default": "לא ניתן להיכנס.", + "signin": "נסה להיכנס עם חשבון אחר.", + "oauthsignin": "נסה להיכנס עם חשבון אחר.", + "redirect_uri_mismatch": "כתובת ה-URI להפניה אינה תואמת לתצורת האפליקציה של oauth.", + "oauthcallbackerror": "נסה להיכנס עם חשבון אחר.", + "oauthcreateaccount": "נסה להיכנס עם חשבון אחר.", + "emailcreateaccount": "נסה להיכנס עם חשבון אחר.", + "callback": "נסה להיכנס עם חשבון אחר.", + "oauthaccountnotlinked": "כדי לאשר את זהותך, היכנס עם אותו חשבון שבו השתמשת במקור.", + "emailsignin": "לא ניתן לשלוח את האימייל.", + "emailverify": "אנא אשר את האימייל שלך, אימייל חדש נשלח.", + "credentialssignin": "הכניסה נכשלה. בדוק שהפרטים שסיפקת נכונים.", + "sessionrequired": "אנא היכנס כדי לגשת לדף זה." + } + }, + "authVerifyEmail": { + "almostThere": "אתה כמעט שם! שלחנו אימייל אל ", + "verifyEmailLink": "אנא לחץ על הקישור באימייל זה כדי להשלים את ההרשמה שלך.", + "didNotReceive": "לא מוצא את האימייל?", + "resendEmail": "שלח שוב אימייל", + "goBack": "חזור אחורה", + "emailSent": "האימייל נשלח בהצלחה.", + "verifyEmail": "אמת את כתובת האימייל שלך" + }, + "providerButton": { + "continue": "המשך עם {{provider}}", + "signup": "הירשם עם {{provider}}" + }, + "authResetPassword": { + "newPasswordRequired": "סיסמה חדשה היא שדה חובה", + "passwordsMustMatch": "הסיסמאות חייבות להתאים", + "confirmPasswordRequired": "אישור סיסמה הוא שדה חובה", + "newPassword": "סיסמא חדשה", + "confirmPassword": "אשר סיסמא", + "resetPassword": "אפס סיסמה" + }, + "authForgotPassword": { + "email": "כתובת אימייל", + "emailRequired": "אימייל הוא שדה חובה", + "emailSent": "אנא בדוק את כתובת האימייל {{email}} לקבלת הוראות לאיפוס הסיסמה שלך.", + "enterEmail": "הזן את כתובת האימייל שלך ואנו נשלח לך הוראות לאיפוס הסיסמה שלך.", + "resendEmail": "שלח שוב אימייל", + "continue": "המשך", + "goBack": "חזור אחורה" + } + } + }, + "organisms": { + "chat": { + "history": { + "index": { + "showHistory": "הצג היסטוריה", + "lastInputs": "קלט אחרון", + "noInputs": "ריק...", + "loading": "טוען..." + } + }, + "inputBox": { + "input": { + "placeholder": "כתוב הודעה כאן..." + }, + "speechButton": { + "start": "התחל הקלטה", + "stop": "עצור הקלטה" + }, + "SubmitButton": { + "sendMessage": "שלח הודעה", + "stopTask": "עצור משימה" + }, + "UploadButton": { + "attachFiles": "צרף קבצים" + }, + "waterMark": { + "text": "נבנה עם" + } + }, + "Messages": { + "index": { + "running": "רץ", + "executedSuccessfully": "בוצע בהצלחה", + "failed": "נכשל", + "feedbackUpdated": "משוב עודכן", + "updating": "מעדכן" + } + }, + "dropScreen": { + "dropYourFilesHere": "שחרר את הקבצים שלך כאן" + }, + "index": { + "failedToUpload": "העלאה נכשלה", + "cancelledUploadOf": "העלאה של בוטלה", + "couldNotReachServer": "לא ניתן היה להגיע לשרת", + "continuingChat": "ממשיך בצ'אט הקודם" + }, + "settings": { + "settingsPanel": "לוח הגדרות", + "reset": "אפס", + "cancel": "בטל", + "confirm": "אשר" + } + }, + "threadHistory": { + "sidebar": { + "filters": { + "FeedbackSelect": { + "feedbackAll": "משוב: הכל", + "feedbackPositive": "משוב: חיובי", + "feedbackNegative": "משוב: שלילי" + }, + "SearchBar": { + "search": "חיפוש" + } + }, + "DeleteThreadButton": { + "confirmMessage": "פעולה זו תמחק את השרשור וכן את ההודעות והרכיבים שלו.", + "cancel": "בטל", + "confirm": "אשר", + "deletingChat": "מוחק צ'אט", + "chatDeleted": "הצ'אט נמחק" + }, + "index": { + "pastChats": "צ'אטים קודמים" + }, + "ThreadList": { + "empty": "ריק...", + "today": "היום", + "yesterday": "אתמול", + "previous7days": "7 ימים קודמים", + "previous30days": "30 ימים קודמים" + }, + "TriggerButton": { + "closeSidebar": "סגור סרגל צד", + "openSidebar": "פתח סרגל צד" + } + }, + "Thread": { + "backToChat": "חזור לצ'אט", + "chatCreatedOn": "הצ'אט הזה נוצר בתאריך" + } + }, + "header": { + "chat": "צ'אט", + "readme": "אודות" + } + } + }, + "hooks": { + "useLLMProviders": { + "failedToFetchProviders": "נכשלה הבאת ספקים:" + } + }, + "pages": { + "Design": {}, + "Env": { + "savedSuccessfully": "נשמר בהצלחה", + "requiredApiKeys": "מפתחות API נדרשים", + "requiredApiKeysInfo": "כדי להשתמש באפליקציה זו, נדרשים מפתחות ה-API הבאים. המפתחות מאוחסנים באחסון המקומי של המכשיר שלך." + }, + "Page": { + "notPartOfProject": "אתה לא חלק מהפרויקט הזה." + }, + "ResumeButton": { + "resumeChat": "המשך צ'אט" + } + } +} From 23a5eb5c5c1a684aad4d071316cac4b93fed5218 Mon Sep 17 00:00:00 2001 From: Naga Budigam Date: Tue, 10 Sep 2024 15:07:11 +0530 Subject: [PATCH 33/45] Translations files for Indian languages (#1321) Bengali, Gujarati, Hindi, Kannada, Malayalam, Marathi, Tamil, Telugu. --- backend/chainlit/translations/bn.json | 231 ++++++++++++++++++++++++++ backend/chainlit/translations/gu.json | 231 ++++++++++++++++++++++++++ backend/chainlit/translations/hi.json | 231 ++++++++++++++++++++++++++ backend/chainlit/translations/kn.json | 231 ++++++++++++++++++++++++++ backend/chainlit/translations/ml.json | 231 ++++++++++++++++++++++++++ backend/chainlit/translations/mr.json | 231 ++++++++++++++++++++++++++ backend/chainlit/translations/ta.json | 231 ++++++++++++++++++++++++++ backend/chainlit/translations/te.json | 231 ++++++++++++++++++++++++++ 8 files changed, 1848 insertions(+) create mode 100644 backend/chainlit/translations/bn.json create mode 100644 backend/chainlit/translations/gu.json create mode 100644 backend/chainlit/translations/hi.json create mode 100644 backend/chainlit/translations/kn.json create mode 100644 backend/chainlit/translations/ml.json create mode 100644 backend/chainlit/translations/mr.json create mode 100644 backend/chainlit/translations/ta.json create mode 100644 backend/chainlit/translations/te.json diff --git a/backend/chainlit/translations/bn.json b/backend/chainlit/translations/bn.json new file mode 100644 index 0000000000..d6c9659ce5 --- /dev/null +++ b/backend/chainlit/translations/bn.json @@ -0,0 +1,231 @@ +{ + "components": { + "atoms": { + "buttons": { + "userButton": { + "menu": { + "settings": "সেটিংস", + "settingsKey": "S", + "APIKeys": "এপিআই কী", + "logout": "লগআউট" + } + } + } + }, + "molecules": { + "newChatButton": { + "newChat": "নতুন আড্ডা" + }, + "tasklist": { + "TaskList": { + "title": "🗒️ কার্য তালিকা", + "loading": "লোড।।।", + "error": "একটি ত্রুটি সংঘটিত হয়েছে" + } + }, + "attachments": { + "cancelUpload": "আপলোড বাতিল করুন", + "removeAttachment": "সংযুক্তি সরান" + }, + "newChatDialog": { + "createNewChat": "নতুন চ্যাট তৈরি করবেন?", + "clearChat": "এটি বর্তমান বার্তাগুলি সাফ করবে এবং একটি নতুন চ্যাট শুরু করবে।", + "cancel": "বাতিল", + "confirm": "নিশ্চিত" + }, + "settingsModal": { + "settings": "সেটিংস", + "expandMessages": "বার্তাগুলি প্রসারিত করুন", + "hideChainOfThought": "চিন্তার শৃঙ্খল লুকান", + "darkMode": "ডার্ক মোড" + }, + "detailsButton": { + "using": "ব্যবহার", + "running": "চলমান", + "took_one": "{{count}} পদক্ষেপ নিয়েছে", + "took_other": "{{count}}টি পদক্ষেপ নিয়েছে" + }, + "auth": { + "authLogin": { + "title": "অ্যাপটি অ্যাক্সেস করতে লগইন করুন।", + "form": { + "email": "ই-মেইল ঠিকানা", + "password": "পাসওয়ার্ড", + "noAccount": "কোনও অ্যাকাউন্ট নেই?", + "alreadyHaveAccount": "ইতিমধ্যে একটি অ্যাকাউন্ট আছে?", + "signup": "সাইন আপ করো", + "signin": "সাইন ইন করো", + "or": "বা", + "continue": "অবিরত", + "forgotPassword": "পাসওয়ার্ড ভুলে গেছেন?", + "passwordMustContain": "আপনার পাসওয়ার্ডে অবশ্যই থাকতে হবে:", + "emailRequired": "ইমেল একটি প্রয়োজনীয় ক্ষেত্র", + "passwordRequired": "পাসওয়ার্ড একটি আবশ্যক ক্ষেত্র" + }, + "error": { + "default": "সাইন ইন করতে অক্ষম।", + "signin": "একটি ভিন্ন অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন।", + "oauthsignin": "একটি ভিন্ন অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন।", + "redirect_uri_mismatch": "পুনঃনির্দেশিত URI OAUTH অ্যাপ কনফিগারেশনের সাথে মিলছে না।", + "oauthcallbackerror": "একটি ভিন্ন অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন।", + "oauthcreateaccount": "একটি ভিন্ন অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন।", + "emailcreateaccount": "একটি ভিন্ন অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন।", + "callback": "একটি ভিন্ন অ্যাকাউন্ট দিয়ে সাইন ইন করার চেষ্টা করুন।", + "oauthaccountnotlinked": "আপনার পরিচয় নিশ্চিত করতে, আপনি মূলত যে অ্যাকাউন্টটি ব্যবহার করেছেন সেই একই অ্যাকাউন্ট দিয়ে সাইন ইন করুন।", + "emailsignin": "ই-মেইলটি প্রেরণ করা যায়নি।", + "emailverify": "অনুগ্রহ করে আপনার ইমেলটি যাচাই করুন, একটি নতুন ইমেল প্রেরণ করা হয়েছে।", + "credentialssignin": "সাইন ইন ব্যর্থ হয়েছে। আপনার প্রদত্ত বিবরণগুলি সঠিক কিনা তা পরীক্ষা করুন।", + "sessionrequired": "এই পৃষ্ঠাটি অ্যাক্সেস করতে দয়া করে সাইন ইন করুন।" + } + }, + "authVerifyEmail": { + "almostThere": "আপনি প্রায় সেখানে পৌঁছেছেন! আমরা একটি ইমেইল পাঠিয়েছি ", + "verifyEmailLink": "আপনার সাইনআপ সম্পূর্ণ করতে দয়া করে সেই ইমেলের লিঙ্কটিতে ক্লিক করুন।", + "didNotReceive": "ইমেইল খুঁজে পাচ্ছেন না?", + "resendEmail": "ইমেইল পুনরায় পাঠান", + "goBack": "ফিরে যাও", + "emailSent": "ইমেল সফলভাবে পাঠানো হয়েছে।", + "verifyEmail": "আপনার ইমেল ঠিকানা যাচাই করুন" + }, + "providerButton": { + "continue": "{{provider}} দিয়ে চালিয়ে যান", + "signup": "{{provider}} দিয়ে সাইন আপ করুন" + }, + "authResetPassword": { + "newPasswordRequired": "নতুন পাসওয়ার্ড একটি আবশ্যক ক্ষেত্র", + "passwordsMustMatch": "পাসওয়ার্ড অবশ্যই মিলতে হবে", + "confirmPasswordRequired": "পাসওয়ার্ড নিশ্চিত করা একটি আবশ্যক ক্ষেত্র", + "newPassword": "নতুন পাসওয়ার্ড", + "confirmPassword": "পাসওয়ার্ড নিশ্চিত করুন", + "resetPassword": "পাসওয়ার্ড রিসেট করুন" + }, + "authForgotPassword": { + "email": "ই-মেইল ঠিকানা", + "emailRequired": "ইমেল একটি প্রয়োজনীয় ক্ষেত্র", + "emailSent": "আপনার পাসওয়ার্ডটি পুনরায় সেট করার নির্দেশাবলীর জন্য দয়া করে ইমেল ঠিকানা {{email}} পরীক্ষা করুন।", + "enterEmail": "আপনার ইমেল ঠিকানা লিখুন এবং আমরা আপনাকে আপনার পাসওয়ার্ড পুনরায় সেট করতে নির্দেশাবলী পাঠাব।", + "resendEmail": "ইমেইল পুনরায় পাঠান", + "continue": "অবিরত", + "goBack": "ফিরে যাও" + } + } + }, + "organisms": { + "chat": { + "history": { + "index": { + "showHistory": "ইতিহাস দেখান", + "lastInputs": "সর্বশেষ ইনপুট", + "noInputs": "এত ফাঁকা...", + "loading": "লোড।।।" + } + }, + "inputBox": { + "input": { + "placeholder": "এখানে আপনার বার্তা টাইপ করুন..." + }, + "speechButton": { + "start": "রেকর্ডিং শুরু করুন", + "stop": "রেকর্ডিং বন্ধ করুন" + }, + "SubmitButton": { + "sendMessage": "বার্তা প্রেরণ করুন", + "stopTask": "স্টপ টাস্ক" + }, + "UploadButton": { + "attachFiles": "ফাইল সংযুক্ত করুন" + }, + "waterMark": { + "text": "সঙ্গে নির্মিত" + } + }, + "Messages": { + "index": { + "running": "চলমান", + "executedSuccessfully": "সফলভাবে সম্পাদিত হয়েছে", + "failed": "ব্যর্থ", + "feedbackUpdated": "ফিডব্যাক আপডেট হয়েছে", + "updating": "আধুনিকীকরণ" + } + }, + "dropScreen": { + "dropYourFilesHere": "আপনার ফাইলগুলি এখানে ফেলে দিন" + }, + "index": { + "failedToUpload": "আপলোড করতে ব্যর্থ হয়েছে", + "cancelledUploadOf": "এর আপলোড বাতিল", + "couldNotReachServer": "সার্ভারে পৌঁছানো যায়নি", + "continuingChat": "পূর্ববর্তী চ্যাট অবিরত রাখা" + }, + "settings": { + "settingsPanel": "সেটিংস প্যানেল", + "reset": "রিসেট", + "cancel": "বাতিল", + "confirm": "নিশ্চিত" + } + }, + "threadHistory": { + "sidebar": { + "filters": { + "FeedbackSelect": { + "feedbackAll": "প্রতিক্রিয়া: সব", + "feedbackPositive": "প্রতিক্রিয়া: ইতিবাচক", + "feedbackNegative": "প্রতিক্রিয়া: নেতিবাচক" + }, + "SearchBar": { + "search": "সন্ধান" + } + }, + "DeleteThreadButton": { + "confirmMessage": "এটি থ্রেডের পাশাপাশি এর বার্তা এবং উপাদানগুলিও মুছে ফেলবে।", + "cancel": "বাতিল", + "confirm": "নিশ্চিত", + "deletingChat": "চ্যাট মোছা হচ্ছে", + "chatDeleted": "চ্যাট মোছা হয়েছে" + }, + "index": { + "pastChats": "অতীত চ্যাট" + }, + "ThreadList": { + "empty": "খালি।।।", + "today": "আজ", + "yesterday": "গতকাল", + "previous7days": "Previous 7 দিন", + "previous30days": "পূর্ববর্তী 30 দিন" + }, + "TriggerButton": { + "closeSidebar": "সাইডবার বন্ধ করুন", + "openSidebar": "সাইডবার খুলুন" + } + }, + "Thread": { + "backToChat": "চ্যাটে ফিরে যান", + "chatCreatedOn": "এই চ্যাটটি তৈরি করা হয়েছিল" + } + }, + "header": { + "chat": "আলাপ", + "readme": "রিডমি" + } + } + }, + "hooks": { + "useLLMProviders": { + "failedToFetchProviders": "সরবরাহকারীদের আনতে ব্যর্থ:" + } + }, + "pages": { + "Design": {}, + "Env": { + "savedSuccessfully": "সফলভাবে সংরক্ষণ করা হয়েছে", + "requiredApiKeys": "আবশ্যক API কী", + "requiredApiKeysInfo": "এই অ্যাপটি ব্যবহার করতে, নিম্নলিখিত API কীগুলির প্রয়োজন। কীগুলি আপনার ডিভাইসের স্থানীয় স্টোরেজে সঞ্চিত রয়েছে।" + }, + "Page": { + "notPartOfProject": "আপনি এই প্রকল্পের অংশ নন।" + }, + "ResumeButton": { + "resumeChat": "চ্যাট পুনরায় শুরু করুন" + } + } +} \ No newline at end of file diff --git a/backend/chainlit/translations/gu.json b/backend/chainlit/translations/gu.json new file mode 100644 index 0000000000..d87726efec --- /dev/null +++ b/backend/chainlit/translations/gu.json @@ -0,0 +1,231 @@ +{ + "components": { + "atoms": { + "buttons": { + "userButton": { + "menu": { + "settings": "સુયોજનો", + "settingsKey": "S", + "APIKeys": "API કીઓ", + "logout": "બહાર નીકળો" + } + } + } + }, + "molecules": { + "newChatButton": { + "newChat": "નવો સંવાદ" + }, + "tasklist": { + "TaskList": { + "title": "🗒️ કાર્ય યાદી", + "loading": "લોડ કરી રહ્યા છે...", + "error": "ભૂલ ઉદ્ભવી" + } + }, + "attachments": { + "cancelUpload": "અપલોડ કરવાનું રદ કરો", + "removeAttachment": "જોડાણને દૂર કરો" + }, + "newChatDialog": { + "createNewChat": "શું નવું સંવાદ બનાવવું છે?", + "clearChat": "આ વર્તમાન સંદેશાઓને સાફ કરશે અને નવી વાતચીત શરૂ કરશે.", + "cancel": "રદ્દ", + "confirm": "ખાતરી કરો" + }, + "settingsModal": { + "settings": "સુયોજનો", + "expandMessages": "સંદેશાઓ વિસ્તૃત કરો", + "hideChainOfThought": "વિચારની સાંકળ છુપાવો", + "darkMode": "ઘાટી સ્થિતિ" + }, + "detailsButton": { + "using": "વાપરી રહ્યા છીએ", + "running": "ચાલી રહ્યુ છે", + "took_one": "{{count}} પગલું ભર્યું", + "took_other": "{{count}} પગલાંઓ લીધા" + }, + "auth": { + "authLogin": { + "title": "એપ્લિકેશનને ઍક્સેસ કરવા માટે લોગિન કરો.", + "form": { + "email": "ઈ-મેઈલ સરનામું", + "password": "પાસવર્ડ", + "noAccount": "ખાતું નથી?", + "alreadyHaveAccount": "પહેલેથી જ ખાતું છે?", + "signup": "સાઇન અપ કરો", + "signin": "સાઇન ઇન કરો", + "or": "અથવા", + "continue": "ચાલુ રાખો", + "forgotPassword": "પાસવર્ડ ભૂલી ગયા?", + "passwordMustContain": "તમારો પાસવર્ડ સમાવતો જ હોવો જોઇએ:", + "emailRequired": "ઈ-મેઈલ એ જરૂરી ક્ષેત્ર છે", + "passwordRequired": "પાસવર્ડ એ જરૂરી ક્ષેત્ર છે" + }, + "error": { + "default": "પ્રવેશ કરવામાં અસમર્થ.", + "signin": "અલગ ખાતા સાથે સાઇન ઇન કરવાનો પ્રયત્ન કરો.", + "oauthsignin": "અલગ ખાતા સાથે સાઇન ઇન કરવાનો પ્રયત્ન કરો.", + "redirect_uri_mismatch": "રીડાયરેક્ટ URI એ oauth એપ્લિકેશન રૂપરેખાંકન સાથે બંધબેસતી નથી.", + "oauthcallbackerror": "અલગ ખાતા સાથે સાઇન ઇન કરવાનો પ્રયત્ન કરો.", + "oauthcreateaccount": "અલગ ખાતા સાથે સાઇન ઇન કરવાનો પ્રયત્ન કરો.", + "emailcreateaccount": "અલગ ખાતા સાથે સાઇન ઇન કરવાનો પ્રયત્ન કરો.", + "callback": "અલગ ખાતા સાથે સાઇન ઇન કરવાનો પ્રયત્ન કરો.", + "oauthaccountnotlinked": "તમારી ઓળખની પુષ્ટિ કરવા માટે, તમે જે મૂળભૂત રીતે ઉપયોગ કર્યો હતો તે જ એકાઉન્ટ સાથે સાઇન ઇન કરો.", + "emailsignin": "ઈ-મેઈલ મોકલી શકાયો નહિ.", + "emailverify": "કૃપા કરીને તમારા ઇમેઇલની ખાત્રી કરો, એક નવું ઇમેઇલ મોકલવામાં આવ્યું છે.", + "credentialssignin": "સાઇન ઇન નિષ્ફળ. તમે પૂરી પાડેલી વિગતો સાચી છે તે ચકાસો.", + "sessionrequired": "કૃપા કરીને આ પૃષ્ઠને ઍક્સેસ કરવા માટે સાઇન ઇન કરો." + } + }, + "authVerifyEmail": { + "almostThere": "તમે તો લગભગ ત્યાં જ છો! અમે આના પર ઇમેઇલ મોકલ્યો છે ", + "verifyEmailLink": "તમારું સાઇનઅપ પૂર્ણ કરવા માટે કૃપા કરીને તે ઇમેઇલની લિંક પર ક્લિક કરો.", + "didNotReceive": "ઈ-મેઈલ શોધી શકતા નથી?", + "resendEmail": "ઇમેઇલ ફરી મોકલો", + "goBack": "પાછા જાઓ", + "emailSent": "ઈ-મેઈલ સફળતાપૂર્વક મોકલાઈ ગયો.", + "verifyEmail": "તમારા ઇમેઇલ એડ્રેસની ખાત્રી કરો" + }, + "providerButton": { + "continue": "{{provider}} સાથે ચાલુ રાખો", + "signup": "{{provider}} સાથે સાઇન અપ કરો" + }, + "authResetPassword": { + "newPasswordRequired": "નવો પાસવર્ડ એ જરૂરી ક્ષેત્ર છે", + "passwordsMustMatch": "પાસવર્ડો બંધબેસતા જ હોવા જોઈએ", + "confirmPasswordRequired": "ખાતરી કરો પાસવર્ડ એ જરૂરી ક્ષેત્ર છે", + "newPassword": "નવો પાસવર્ડ", + "confirmPassword": "ખાતરી પાસવર્ડ", + "resetPassword": "પાસવર્ડને પુન:સુયોજિત કરો" + }, + "authForgotPassword": { + "email": "ઈ-મેઈલ સરનામું", + "emailRequired": "ઈ-મેઈલ એ જરૂરી ક્ષેત્ર છે", + "emailSent": "સૂચનાઓ માટે કૃપા કરીને તમારું પાસવર્ડ રિસૅટ કરવા માટે ઇમેઇલ એડ્રેસ {{email}} ચકાસો.", + "enterEmail": "તમારું ઇમેઇલ એડ્રેસ દાખલ કરો અને અમે તમારો પાસવર્ડ રીસેટ કરવા માટે તમને સૂચનાઓ મોકલીશું.", + "resendEmail": "ઇમેઇલ ફરી મોકલો", + "continue": "ચાલુ રાખો", + "goBack": "પાછા જાઓ" + } + } + }, + "organisms": { + "chat": { + "history": { + "index": { + "showHistory": "ઇતિહાસ બતાવો", + "lastInputs": "છેલ્લા ઇનપુટ્સ", + "noInputs": "આવા ખાલી...", + "loading": "લોડ કરી રહ્યા છે..." + } + }, + "inputBox": { + "input": { + "placeholder": "તમારો સંદેશો અહીં ટાઇપ કરો..." + }, + "speechButton": { + "start": "રેકોર્ડ કરવાનું શરૂ કરો", + "stop": "રેકોર્ડ કરવાનું બંધ કરો" + }, + "SubmitButton": { + "sendMessage": "સંદેશો મોકલો", + "stopTask": "કાર્યને અટકાવો" + }, + "UploadButton": { + "attachFiles": "ફાઇલોને જોડો" + }, + "waterMark": { + "text": "ની સાથે બિલ્ટ થયેલ" + } + }, + "Messages": { + "index": { + "running": "ચાલી રહ્યુ છે", + "executedSuccessfully": "સફળતાપૂર્વક ચલાવ્યેલ છે", + "failed": "નિષ્ફળ", + "feedbackUpdated": "પ્રતિસાદ સુધારેલ છે", + "updating": "સુધારી રહ્યા છીએ" + } + }, + "dropScreen": { + "dropYourFilesHere": "તમારી ફાઇલોને અંહિ મૂકો" + }, + "index": { + "failedToUpload": "અપલોડ કરવામાં નિષ્ફળ", + "cancelledUploadOf": "નું અપલોડ રદ થયેલ છે", + "couldNotReachServer": "સર્વર સુધી પહોંચી શક્યા નહિં", + "continuingChat": "પહેલાની વાતચીતને ચાલુ રાખી રહ્યા છે" + }, + "settings": { + "settingsPanel": "પેનલ સુયોજનો", + "reset": "પુન:સુયોજિત કરો", + "cancel": "રદ્દ", + "confirm": "ખાતરી કરો" + } + }, + "threadHistory": { + "sidebar": { + "filters": { + "FeedbackSelect": { + "feedbackAll": "પ્રતિસાદ: બધા", + "feedbackPositive": "પ્રતિસાદ: હકારાત્મક", + "feedbackNegative": "પ્રતિસાદ: નકારાત્મક" + }, + "SearchBar": { + "search": "શોધવું" + } + }, + "DeleteThreadButton": { + "confirmMessage": "આ થ્રેડની સાથે સાથે તેના સંદેશા અને તત્વોને પણ કાઢી નાખશે.", + "cancel": "રદ્દ", + "confirm": "ખાતરી કરો", + "deletingChat": "ચૅટને કાઢી રહ્યા છીએ", + "chatDeleted": "ચૅટ ડિલીટ થઈ ગઈ" + }, + "index": { + "pastChats": "ભૂતકાળની વાતચીતો" + }, + "ThreadList": { + "empty": "ખાલી...", + "today": "આજે", + "yesterday": "ગઇકાલે", + "previous7days": "પહેલાના ૭ દિવસો", + "previous30days": "પહેલાના ૩૦ દિવસો" + }, + "TriggerButton": { + "closeSidebar": "બાજુપટ્ટીને બંધ કરો", + "openSidebar": "બાજુપટ્ટી ખોલો" + } + }, + "Thread": { + "backToChat": "સંવાદમાં પાછા જાઓ", + "chatCreatedOn": "આ વાતચીત તેની પર બનાવેલ હતી" + } + }, + "header": { + "chat": "સંવાદ", + "readme": "રીડમે" + } + } + }, + "hooks": { + "useLLMProviders": { + "failedToFetchProviders": "પ્રદાતાઓને લાવવામાં નિષ્ફળતા:" + } + }, + "pages": { + "Design": {}, + "Env": { + "savedSuccessfully": "સફળતાપૂર્વક સંગ્રહાયેલ", + "requiredApiKeys": "જરૂરી API કીઓ", + "requiredApiKeysInfo": "આ એપ્લિકેશનનો ઉપયોગ કરવા માટે, નીચેની API કીઓ જરૂરી છે. કીઓ તમારા ડિવાઇસના સ્થાનિક સ્ટોરેજ પર સંગ્રહિત થાય છે." + }, + "Page": { + "notPartOfProject": "તમે આ પ્રોજેક્ટનો ભાગ નથી." + }, + "ResumeButton": { + "resumeChat": "ફરી શરૂ કરો સંવાદ" + } + } +} \ No newline at end of file diff --git a/backend/chainlit/translations/hi.json b/backend/chainlit/translations/hi.json new file mode 100644 index 0000000000..f813d95f06 --- /dev/null +++ b/backend/chainlit/translations/hi.json @@ -0,0 +1,231 @@ +{ + "components": { + "atoms": { + "buttons": { + "userButton": { + "menu": { + "settings": "सेटिंग्स", + "settingsKey": "दक्षिणी", + "APIKeys": "एपीआई कुंजी", + "logout": "लॉगआउट" + } + } + } + }, + "molecules": { + "newChatButton": { + "newChat": "नई चैट" + }, + "tasklist": { + "TaskList": { + "title": "🗒️ कार्य सूची", + "loading": "लोड।।।", + "error": "कोई त्रुटि उत्पन्न हुई" + } + }, + "attachments": { + "cancelUpload": "अपलोड रद्द करें", + "removeAttachment": "अनुलग्नक निकालें" + }, + "newChatDialog": { + "createNewChat": "नई चैट बनाएँ?", + "clearChat": "यह वर्तमान संदेशों को साफ़ करेगा और एक नई चैट शुरू करेगा।", + "cancel": "रद्द करना", + "confirm": "सुदृढ़ करना" + }, + "settingsModal": { + "settings": "सेटिंग्स", + "expandMessages": "संदेशों का विस्तार करें", + "hideChainOfThought": "विचार की श्रृंखला छिपाएं", + "darkMode": "डार्क मोड" + }, + "detailsButton": { + "using": "का उपयोग करके", + "running": "भागना", + "took_one": "{{count}} कदम उठाया", + "took_other": "{{count}} कदम उठाए" + }, + "auth": { + "authLogin": { + "title": "ऐप तक पहुंचने के लिए लॉगिन करें।", + "form": { + "email": "ईमेल पता", + "password": "पासवर्ड", + "noAccount": "क्या आपके पास खाता नहीं है?", + "alreadyHaveAccount": "पहले से ही एक खाता है?", + "signup": "नाम लिखो", + "signin": "साइन इन करें", + "or": "नहीं तो", + "continue": "जारी रखना", + "forgotPassword": "पासवर्ड भूल गए?", + "passwordMustContain": "आपके पासवर्ड में होना चाहिए:", + "emailRequired": "ईमेल एक आवश्यक फ़ील्ड है", + "passwordRequired": "पासवर्ड एक आवश्यक फ़ील्ड है" + }, + "error": { + "default": "साइन इन करने में असमर्थ.", + "signin": "किसी दूसरे खाते से साइन इन करके देखें.", + "oauthsignin": "किसी दूसरे खाते से साइन इन करके देखें.", + "redirect_uri_mismatch": "रीडायरेक्ट यूआरआई ओथ ऐप कॉन्फ़िगरेशन से मेल नहीं खा रहा है।", + "oauthcallbackerror": "किसी दूसरे खाते से साइन इन करके देखें.", + "oauthcreateaccount": "किसी दूसरे खाते से साइन इन करके देखें.", + "emailcreateaccount": "किसी दूसरे खाते से साइन इन करके देखें.", + "callback": "किसी दूसरे खाते से साइन इन करके देखें.", + "oauthaccountnotlinked": "अपनी पहचान कन्फ़र्म करने के लिए, उसी खाते से साइन इन करें जिसका इस्तेमाल आपने पहले किया था.", + "emailsignin": "ई-मेल नहीं भेजी जा सकी.", + "emailverify": "कृपया अपना ईमेल सत्यापित करें, एक नया ईमेल भेजा गया है।", + "credentialssignin": "साइन इन विफल रहा. जांचें कि आपके द्वारा प्रदान किए गए विवरण सही हैं।", + "sessionrequired": "कृपया इस पृष्ठ तक पहुंचने के लिए साइन इन करें।" + } + }, + "authVerifyEmail": { + "almostThere": "आप लगभग वहाँ हैं! हमने एक ईमेल भेजा है ", + "verifyEmailLink": "कृपया अपना साइनअप पूरा करने के लिए उस ईमेल में दिए गए लिंक पर क्लिक करें।", + "didNotReceive": "ईमेल नहीं मिल रहा है?", + "resendEmail": "ईमेल पुनः भेजें", + "goBack": "पस जाओ", + "emailSent": "ईमेल सफलतापूर्वक भेजा गया।", + "verifyEmail": "अपना ईमेल पता सत्यापित करें" + }, + "providerButton": { + "continue": "{{provider}} के साथ जारी रखें", + "signup": "{{provider}} के साथ साइन अप करें" + }, + "authResetPassword": { + "newPasswordRequired": "नया पासवर्ड एक आवश्यक फ़ील्ड है", + "passwordsMustMatch": "पासवर्ड मेल खाना चाहिए", + "confirmPasswordRequired": "पुष्टि करें कि पासवर्ड एक आवश्यक फ़ील्ड है", + "newPassword": "नया पासवर्ड", + "confirmPassword": "पासवर्ड की पुष्टि करें", + "resetPassword": "पासवर्ड रीसेट करें" + }, + "authForgotPassword": { + "email": "ईमेल पता", + "emailRequired": "ईमेल एक आवश्यक फ़ील्ड है", + "emailSent": "अपना पासवर्ड रीसेट करने के निर्देशों के लिए कृपया ईमेल पता {{email}} देखें।", + "enterEmail": "अपना ईमेल पता दर्ज करें और हम आपको अपना पासवर्ड रीसेट करने के लिए निर्देश भेजेंगे।", + "resendEmail": "ईमेल पुनः भेजें", + "continue": "जारी रखना", + "goBack": "पस जाओ" + } + } + }, + "organisms": { + "chat": { + "history": { + "index": { + "showHistory": "इतिहास दिखाएं", + "lastInputs": "अंतिम इनपुट", + "noInputs": "ऐसे खाली...", + "loading": "लोड।।।" + } + }, + "inputBox": { + "input": { + "placeholder": "अपना संदेश यहाँ टाइप करें..." + }, + "speechButton": { + "start": "रिकॉर्डिंग शुरू करें", + "stop": "रिकॉर्डिंग बंद करो" + }, + "SubmitButton": { + "sendMessage": "संदेश भेजें", + "stopTask": "कार्य बंद करो" + }, + "UploadButton": { + "attachFiles": "फ़ाइलें अनुलग्न करें" + }, + "waterMark": { + "text": "के साथ निर्मित" + } + }, + "Messages": { + "index": { + "running": "भागना", + "executedSuccessfully": "सफलतापूर्वक निष्पादित", + "failed": "असफल", + "feedbackUpdated": "प्रतिक्रिया अपडेट की गई", + "updating": "अद्यतन" + } + }, + "dropScreen": { + "dropYourFilesHere": "अपनी फ़ाइलें यहाँ ड्रॉप करें" + }, + "index": { + "failedToUpload": "अपलोड करने में विफल", + "cancelledUploadOf": "का अपलोड रद्द किया गया", + "couldNotReachServer": "सर्वर तक नहीं पहुँच सका", + "continuingChat": "पिछली चैट जारी रखना" + }, + "settings": { + "settingsPanel": "सेटिंग्स पैनल", + "reset": "रीसेट", + "cancel": "रद्द करना", + "confirm": "सुदृढ़ करना" + } + }, + "threadHistory": { + "sidebar": { + "filters": { + "FeedbackSelect": { + "feedbackAll": "प्रतिपुष्टि: सभी", + "feedbackPositive": "प्रतिपुष्टि: सकारात्मक", + "feedbackNegative": "प्रतिपुष्टि: नकारात्मक" + }, + "SearchBar": { + "search": "ढूँढ" + } + }, + "DeleteThreadButton": { + "confirmMessage": "यह थ्रेड के साथ-साथ इसके संदेशों और तत्वों को भी हटा देगा।", + "cancel": "रद्द करना", + "confirm": "सुदृढ़ करना", + "deletingChat": "चैट हटाना", + "chatDeleted": "चैट हटाई गई" + }, + "index": { + "pastChats": "पिछली चैट" + }, + "ThreadList": { + "empty": "खाली।।।", + "today": "आज", + "yesterday": "बीता हुआ कल", + "previous7days": "पिछले 7 दिन", + "previous30days": "पिछले 30 दिन" + }, + "TriggerButton": { + "closeSidebar": "साइडबार बंद करें", + "openSidebar": "साइडबार खोलें" + } + }, + "Thread": { + "backToChat": "चैट पर वापस जाएं", + "chatCreatedOn": "यह चैट इस पर बनाई गई थी" + } + }, + "header": { + "chat": "चैट", + "readme": "रीडमी" + } + } + }, + "hooks": { + "useLLMProviders": { + "failedToFetchProviders": "प्रदाताओं को लाने में विफल:" + } + }, + "pages": { + "Design": {}, + "Env": { + "savedSuccessfully": "सफलतापूर्वक सहेजा गया", + "requiredApiKeys": "आवश्यक एपीआई कुंजी", + "requiredApiKeysInfo": "इस ऐप का उपयोग करने के लिए, निम्नलिखित एपीआई कुंजियों की आवश्यकता होती है। चाबियाँ आपके डिवाइस के स्थानीय संग्रहण पर संग्रहीत की जाती हैं।" + }, + "Page": { + "notPartOfProject": "आप इस परियोजना का हिस्सा नहीं हैं।" + }, + "ResumeButton": { + "resumeChat": "चैट फिर से शुरू करें" + } + } +} \ No newline at end of file diff --git a/backend/chainlit/translations/kn.json b/backend/chainlit/translations/kn.json new file mode 100644 index 0000000000..89f082d044 --- /dev/null +++ b/backend/chainlit/translations/kn.json @@ -0,0 +1,231 @@ +{ + "components": { + "atoms": { + "buttons": { + "userButton": { + "menu": { + "settings": "ಸೆಟ್ಟಿಂಗ್ ಗಳು", + "settingsKey": "S", + "APIKeys": "API ಕೀಲಿಗಳು", + "logout": "ಲಾಗ್ ಔಟ್" + } + } + } + }, + "molecules": { + "newChatButton": { + "newChat": "ಹೊಸ ಚಾಟ್" + }, + "tasklist": { + "TaskList": { + "title": "🗒️ ಕಾರ್ಯ ಪಟ್ಟಿ", + "loading": "ಲೋಡ್ ಆಗುತ್ತಿದೆ...", + "error": "ದೋಷ ಸಂಭವಿಸಿದೆ" + } + }, + "attachments": { + "cancelUpload": "ಅಪ್ ಲೋಡ್ ರದ್ದು ಮಾಡಿ", + "removeAttachment": "ಲಗತ್ತು ತೆಗೆದುಹಾಕಿ" + }, + "newChatDialog": { + "createNewChat": "ಹೊಸ ಚಾಟ್ ರಚಿಸಬೇಕೆ?", + "clearChat": "ಇದು ಪ್ರಸ್ತುತ ಸಂದೇಶಗಳನ್ನು ತೆರವುಗೊಳಿಸುತ್ತದೆ ಮತ್ತು ಹೊಸ ಚಾಟ್ ಅನ್ನು ಪ್ರಾರಂಭಿಸುತ್ತದೆ.", + "cancel": "ರದ್ದುಮಾಡಿ", + "confirm": "ದೃಢಪಡಿಸಿ" + }, + "settingsModal": { + "settings": "ಸೆಟ್ಟಿಂಗ್ ಗಳು", + "expandMessages": "ಸಂದೇಶಗಳನ್ನು ವಿಸ್ತರಿಸಿ", + "hideChainOfThought": "ಚಿಂತನೆಯ ಸರಪಳಿಯನ್ನು ಮರೆಮಾಡು", + "darkMode": "ಡಾರ್ಕ್ ಮೋಡ್" + }, + "detailsButton": { + "using": "ಬಳಸಲಾಗುತ್ತಿದೆ", + "running": "ಚಲಿಸುತ್ತಿದೆ", + "took_one": "{{count}} ಹೆಜ್ಜೆ ಇಟ್ಟಿದೆ", + "took_other": "{{count}} ಹೆಜ್ಜೆಗಳನ್ನು ತೆಗೆದುಕೊಂಡರು" + }, + "auth": { + "authLogin": { + "title": "ಅಪ್ಲಿಕೇಶನ್ ಪ್ರವೇಶಿಸಲು ಲಾಗಿನ್ ಮಾಡಿ.", + "form": { + "email": "ಇಮೇಲ್ ವಿಳಾಸ", + "password": "ಪಾಸ್ ವರ್ಡ್", + "noAccount": "ಖಾತೆ ಇಲ್ಲವೇ?", + "alreadyHaveAccount": "ಈಗಾಗಲೇ ಖಾತೆಯನ್ನು ಹೊಂದಿದ್ದೀರಾ?", + "signup": "ಸೈನ್ ಅಪ್ ಮಾಡಿ", + "signin": "ಸೈನ್ ಇನ್ ಮಾಡಿ", + "or": "ಅಥವಾ", + "continue": "ಮುಂದುವರಿಸಿ", + "forgotPassword": "ಪಾಸ್ ವರ್ಡ್ ಮರೆತಿದ್ದೀರಾ?", + "passwordMustContain": "ನಿಮ್ಮ ಪಾಸ್ ವರ್ಡ್ ಇವುಗಳನ್ನು ಒಳಗೊಂಡಿರಬೇಕು:", + "emailRequired": "ಇಮೇಲ್ ಅವಶ್ಯಕ ಫೀಲ್ಡ್ ಆಗಿದೆ", + "passwordRequired": "ಪಾಸ್ ವರ್ಡ್ ಅವಶ್ಯಕ ಫೀಲ್ಡ್ ಆಗಿದೆ" + }, + "error": { + "default": "ಸೈನ್ ಇನ್ ಮಾಡಲು ಅಸಮರ್ಥವಾಗಿದೆ.", + "signin": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ.", + "oauthsignin": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ.", + "redirect_uri_mismatch": "ಮರುನಿರ್ದೇಶನದ URI ಆ್ಯಪ್ ಕಾನ್ಫಿಗರೇಶನ್ ಗೆ ಹೋಲಿಕೆಯಾಗುತ್ತಿಲ್ಲ.", + "oauthcallbackerror": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ.", + "oauthcreateaccount": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ.", + "emailcreateaccount": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ.", + "callback": "ಬೇರೆ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಲು ಪ್ರಯತ್ನಿಸಿ.", + "oauthaccountnotlinked": "ನಿಮ್ಮ ಗುರುತನ್ನು ದೃಢೀಕರಿಸಲು, ನೀವು ಮೂಲತಃ ಬಳಸಿದ ಅದೇ ಖಾತೆಯೊಂದಿಗೆ ಸೈನ್ ಇನ್ ಮಾಡಿ.", + "emailsignin": "ಇ-ಮೇಲ್ ಕಳುಹಿಸಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ.", + "emailverify": "ದಯವಿಟ್ಟು ನಿಮ್ಮ ಇಮೇಲ್ ಪರಿಶೀಲಿಸಿ, ಹೊಸ ಇಮೇಲ್ ಕಳುಹಿಸಲಾಗಿದೆ.", + "credentialssignin": "ಸೈನ್ ಇನ್ ವಿಫಲವಾಗಿದೆ. ನೀವು ಒದಗಿಸಿದ ವಿವರಗಳು ಸರಿಯಾಗಿವೆಯೇ ಎಂದು ಪರಿಶೀಲಿಸಿ.", + "sessionrequired": "ಈ ಪುಟವನ್ನು ಪ್ರವೇಶಿಸಲು ದಯವಿಟ್ಟು ಸೈನ್ ಇನ್ ಮಾಡಿ." + } + }, + "authVerifyEmail": { + "almostThere": "ನೀವು ಬಹುತೇಕ ಅಲ್ಲಿದ್ದೀರಿ! ನಾವು ಗೆ ಇಮೇಲ್ ಕಳುಹಿಸಿದ್ದೇವೆ ", + "verifyEmailLink": "ನಿಮ್ಮ ಸೈನ್ ಅಪ್ ಪೂರ್ಣಗೊಳಿಸಲು ದಯವಿಟ್ಟು ಆ ಇಮೇಲ್ ನಲ್ಲಿರುವ ಲಿಂಕ್ ಕ್ಲಿಕ್ ಮಾಡಿ.", + "didNotReceive": "ಇಮೇಲ್ ಹುಡುಕಲು ಸಾಧ್ಯವಿಲ್ಲವೇ?", + "resendEmail": "ಇಮೇಲ್ ಅನ್ನು ಮತ್ತೆ ಕಳಿಸಿ", + "goBack": "ಹಿಂದೆ ಹೋಗು", + "emailSent": "ಇಮೇಲ್ ಯಶಸ್ವಿಯಾಗಿ ಕಳುಹಿಸಲಾಗಿದೆ.", + "verifyEmail": "ನಿಮ್ಮ ಇಮೇಲ್ ವಿಳಾಸವನ್ನು ಪರಿಶೀಲಿಸಿ" + }, + "providerButton": { + "continue": "{{provider}} ನೊಂದಿಗೆ ಮುಂದುವರಿಸಿ", + "signup": "{{provider}} ನೊಂದಿಗೆ ಸೈನ್ ಅಪ್ ಮಾಡಿ" + }, + "authResetPassword": { + "newPasswordRequired": "ಹೊಸ ಪಾಸ್ ವರ್ಡ್ ಅವಶ್ಯಕ ಫೀಲ್ಡ್ ಆಗಿದೆ", + "passwordsMustMatch": "ಪಾಸ್ ವರ್ಡ್ ಗಳು ಹೊಂದಿಕೆಯಾಗಬೇಕು", + "confirmPasswordRequired": "ಪಾಸ್ ವರ್ಡ್ ಅವಶ್ಯಕ ಫೀಲ್ಡ್ ಎಂದು ದೃಢಪಡಿಸಿ", + "newPassword": "ಹೊಸ ಪಾಸ್ ವರ್ಡ್", + "confirmPassword": "ಪಾಸ್ ವರ್ಡ್ ದೃಢಪಡಿಸಿ", + "resetPassword": "ಪಾಸ್ ವರ್ಡ್ ಮರುಹೊಂದಿಸಿ" + }, + "authForgotPassword": { + "email": "ಇಮೇಲ್ ವಿಳಾಸ", + "emailRequired": "ಇಮೇಲ್ ಅವಶ್ಯಕ ಫೀಲ್ಡ್ ಆಗಿದೆ", + "emailSent": "ನಿಮ್ಮ ಪಾಸ್ ವರ್ಡ್ ಮರುಹೊಂದಿಸಲು ಸೂಚನೆಗಳಿಗಾಗಿ ದಯವಿಟ್ಟು ಇಮೇಲ್ ವಿಳಾಸ {{email}} ಪರಿಶೀಲಿಸಿ.", + "enterEmail": "ನಿಮ್ಮ ಇಮೇಲ್ ವಿಳಾಸವನ್ನು ನಮೂದಿಸಿ ಮತ್ತು ನಿಮ್ಮ ಪಾಸ್ ವರ್ಡ್ ಮರುಹೊಂದಿಸಲು ನಾವು ನಿಮಗೆ ಸೂಚನೆಗಳನ್ನು ಕಳುಹಿಸುತ್ತೇವೆ.", + "resendEmail": "ಇಮೇಲ್ ಅನ್ನು ಮತ್ತೆ ಕಳಿಸಿ", + "continue": "ಮುಂದುವರಿಸಿ", + "goBack": "ಹಿಂದೆ ಹೋಗು" + } + } + }, + "organisms": { + "chat": { + "history": { + "index": { + "showHistory": "ಇತಿಹಾಸ ತೋರಿಸು", + "lastInputs": "ಕೊನೆಯ ಇನ್ ಪುಟ್ ಗಳು", + "noInputs": "ಎಂತಹ ಖಾಲಿ...", + "loading": "ಲೋಡ್ ಆಗುತ್ತಿದೆ..." + } + }, + "inputBox": { + "input": { + "placeholder": "ನಿಮ್ಮ ಸಂದೇಶವನ್ನು ಇಲ್ಲಿ ಬೆರಳಚ್ಚಿಸಿ..." + }, + "speechButton": { + "start": "ರೆಕಾರ್ಡಿಂಗ್ ಪ್ರಾರಂಭಿಸಿ", + "stop": "ರೆಕಾರ್ಡಿಂಗ್ ನಿಲ್ಲಿಸು" + }, + "SubmitButton": { + "sendMessage": "ಸಂದೇಶ ಕಳಿಸಿ", + "stopTask": "ನಿಲ್ಲಿಸು ಕಾರ್ಯ" + }, + "UploadButton": { + "attachFiles": "ಫೈಲ್ ಗಳನ್ನು ಲಗತ್ತಿಸಿ" + }, + "waterMark": { + "text": "ಇದರೊಂದಿಗೆ ನಿರ್ಮಿಸಲಾಗಿದೆ" + } + }, + "Messages": { + "index": { + "running": "ಚಲಿಸುತ್ತಿದೆ", + "executedSuccessfully": "ಯಶಸ್ವಿಯಾಗಿ ಕಾರ್ಯಗತಗೊಳಿಸಲಾಗಿದೆ", + "failed": "ವಿಫಲವಾಗಿದೆ", + "feedbackUpdated": "ಪ್ರತಿಕ್ರಿಯೆ ನವೀಕರಿಸಲಾಗಿದೆ", + "updating": "ನವೀಕರಿಸಲಾಗುತ್ತಿದೆ" + } + }, + "dropScreen": { + "dropYourFilesHere": "ನಿಮ್ಮ ಫೈಲ್ ಗಳನ್ನು ಇಲ್ಲಿ ಬಿಡಿ" + }, + "index": { + "failedToUpload": "ಅಪ್ ಲೋಡ್ ಮಾಡಲು ವಿಫಲವಾಗಿದೆ", + "cancelledUploadOf": "ಅಪ್ ಲೋಡ್ ರದ್ದುಗೊಂಡಿದೆ", + "couldNotReachServer": "ಸರ್ವರ್ ತಲುಪಲು ಸಾಧ್ಯವಾಗಲಿಲ್ಲ", + "continuingChat": "ಹಿಂದಿನ ಚಾಟ್ ಮುಂದುವರಿಯುತ್ತಿದೆ" + }, + "settings": { + "settingsPanel": "ಸೆಟ್ಟಿಂಗ್ ಗಳ ಫಲಕ", + "reset": "ಮರುಹೊಂದಿಸಿ", + "cancel": "ರದ್ದುಮಾಡಿ", + "confirm": "ದೃಢಪಡಿಸಿ" + } + }, + "threadHistory": { + "sidebar": { + "filters": { + "FeedbackSelect": { + "feedbackAll": "ಪ್ರತಿಕ್ರಿಯೆ: ಎಲ್ಲವೂ", + "feedbackPositive": "ಪ್ರತಿಕ್ರಿಯೆ: ಧನಾತ್ಮಕ", + "feedbackNegative": "ಪ್ರತಿಕ್ರಿಯೆ: ನಕಾರಾತ್ಮಕ" + }, + "SearchBar": { + "search": "ಹುಡುಕು" + } + }, + "DeleteThreadButton": { + "confirmMessage": "ಇದು ಥ್ರೆಡ್ ಮತ್ತು ಅದರ ಸಂದೇಶಗಳು ಮತ್ತು ಅಂಶಗಳನ್ನು ಅಳಿಸುತ್ತದೆ.", + "cancel": "ರದ್ದುಮಾಡಿ", + "confirm": "ದೃಢಪಡಿಸಿ", + "deletingChat": "ಚಾಟ್ ಅಳಿಸಲಾಗುತ್ತಿದೆ", + "chatDeleted": "ಚಾಟ್ ಅಳಿಸಲಾಗಿದೆ" + }, + "index": { + "pastChats": "ಹಿಂದಿನ ಚಾಟ್ ಗಳು" + }, + "ThreadList": { + "empty": "ಖಾಲಿ...", + "today": "ಇಂದು", + "yesterday": "ನಿನ್ನೆ", + "previous7days": "ಹಿಂದಿನ 7 ದಿನಗಳು", + "previous30days": "ಹಿಂದಿನ 30 ದಿನಗಳು" + }, + "TriggerButton": { + "closeSidebar": "ಸೈಡ್ ಬಾರ್ ಮುಚ್ಚು", + "openSidebar": "ಸೈಡ್ ಬಾರ್ ತೆರೆಯಿರಿ" + } + }, + "Thread": { + "backToChat": "ಚಾಟ್ ಗೆ ಹಿಂತಿರುಗಿ", + "chatCreatedOn": "ಈ ಚಾಟ್ ಅನ್ನು ಈ ನಲ್ಲಿ ರಚಿಸಲಾಗಿದೆ" + } + }, + "header": { + "chat": "ಚಾಟ್", + "readme": "Readme" + } + } + }, + "hooks": { + "useLLMProviders": { + "failedToFetchProviders": "ಪೂರೈಕೆದಾರರನ್ನು ಕರೆತರಲು ವಿಫಲವಾಗಿದೆ:" + } + }, + "pages": { + "Design": {}, + "Env": { + "savedSuccessfully": "ಯಶಸ್ವಿಯಾಗಿ ಉಳಿಸಲಾಗಿದೆ", + "requiredApiKeys": "ಅವಶ್ಯಕ API ಕೀಲಿಗಳು", + "requiredApiKeysInfo": "ಈ ಅಪ್ಲಿಕೇಶನ್ ಅನ್ನು ಬಳಸಲು, ಈ ಕೆಳಗಿನ ಎಪಿಐ ಕೀಲಿಗಳು ಬೇಕಾಗುತ್ತವೆ. ಕೀಲಿಗಳನ್ನು ನಿಮ್ಮ ಸಾಧನದ ಸ್ಥಳೀಯ ಸಂಗ್ರಹಣೆಯಲ್ಲಿ ಸಂಗ್ರಹಿಸಲಾಗುತ್ತದೆ." + }, + "Page": { + "notPartOfProject": "ನೀವು ಈ ಯೋಜನೆಯ ಭಾಗವಾಗಿಲ್ಲ." + }, + "ResumeButton": { + "resumeChat": "ಚಾಟ್ ಪುನರಾರಂಭಿಸಿ" + } + } +} \ No newline at end of file diff --git a/backend/chainlit/translations/ml.json b/backend/chainlit/translations/ml.json new file mode 100644 index 0000000000..6576624aa3 --- /dev/null +++ b/backend/chainlit/translations/ml.json @@ -0,0 +1,231 @@ +{ + "components": { + "atoms": { + "buttons": { + "userButton": { + "menu": { + "settings": "ക്രമീകരണങ്ങൾ", + "settingsKey": "S", + "APIKeys": "API കീകൾ", + "logout": "ലോഗോട്ട്" + } + } + } + }, + "molecules": { + "newChatButton": { + "newChat": "പുതിയ ചാറ്റ്" + }, + "tasklist": { + "TaskList": { + "title": "🗒️ ടാസ്ക് ലിസ്റ്റ്", + "loading": "ലോഡിംഗ്...", + "error": "ഒരു പിശക് സംഭവിച്ചു" + } + }, + "attachments": { + "cancelUpload": "അപ്ലോഡ് റദ്ദാക്കുക", + "removeAttachment": "അറ്റാച്ച് മെന്റ് നീക്കംചെയ്യുക" + }, + "newChatDialog": { + "createNewChat": "പുതിയ ചാറ്റ് ഉണ്ടാക്കണോ?", + "clearChat": "ഇത് നിലവിലെ സന്ദേശങ്ങൾ ക്ലിയർ ചെയ്യുകയും പുതിയ ചാറ്റ് ആരംഭിക്കുകയും ചെയ്യും.", + "cancel": "ക്യാൻസൽ ചെയ്യ്", + "confirm": "സ്ഥിരീകരിക്കുക" + }, + "settingsModal": { + "settings": "ക്രമീകരണങ്ങൾ", + "expandMessages": "സന്ദേശങ്ങൾ വികസിപ്പിക്കുക", + "hideChainOfThought": "ചിന്തയുടെ ശൃംഖല മറയ്ക്കുക", + "darkMode": "ഡാർക്ക് മോഡ്" + }, + "detailsButton": { + "using": "ഉപയോഗം", + "running": "ഓടുന്നു", + "took_one": "{{count}} സ്റ്റെപ്പ് എടുത്തു", + "took_other": "{{count}} സ്റ്റെപ്പുകൾ എടുത്തു" + }, + "auth": { + "authLogin": { + "title": "അപ്ലിക്കേഷൻ ആക്സസ് ചെയ്യാൻ ലോഗിൻ ചെയ്യുക.", + "form": { + "email": "ഇമെയിൽ വിലാസം", + "password": "Password", + "noAccount": "അക്കൗണ്ട് ഇല്ലേ?", + "alreadyHaveAccount": "ഒരു അക്കൗണ്ട് ഉണ്ടോ?", + "signup": "സൈൻ അപ്പ് ചെയ്യുക", + "signin": "സൈൻ ഇൻ ചെയ്യുക", + "or": "അല്ലെങ്കിൽ", + "continue": "തുടരുക", + "forgotPassword": "പാസ് വേഡ് മറന്നോ?", + "passwordMustContain": "നിങ്ങളുടെ പാസ് വേഡിൽ ഇനിപ്പറയുന്നവ അടങ്ങിയിരിക്കണം:", + "emailRequired": "ഇമെയിൽ ആവശ്യമായ ഒരു ഫീൽഡാണ്", + "passwordRequired": "Password ആവശ്യമുള്ള ഒരു ഫീൽഡാണ്" + }, + "error": { + "default": "സൈന് ഇന് ചെയ്യാന് കഴിയുന്നില്ല.", + "signin": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈനിൻ ചെയ്യാൻ ശ്രമിക്കുക.", + "oauthsignin": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈനിൻ ചെയ്യാൻ ശ്രമിക്കുക.", + "redirect_uri_mismatch": "റീഡയറക്ട് യുആർഐ ഓത്ത് ആപ്പ് കോൺഫിഗറേഷനുമായി പൊരുത്തപ്പെടുന്നില്ല.", + "oauthcallbackerror": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈനിൻ ചെയ്യാൻ ശ്രമിക്കുക.", + "oauthcreateaccount": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈനിൻ ചെയ്യാൻ ശ്രമിക്കുക.", + "emailcreateaccount": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈനിൻ ചെയ്യാൻ ശ്രമിക്കുക.", + "callback": "മറ്റൊരു അക്കൗണ്ട് ഉപയോഗിച്ച് സൈനിൻ ചെയ്യാൻ ശ്രമിക്കുക.", + "oauthaccountnotlinked": "നിങ്ങളുടെ ഐഡന്റിറ്റി സ്ഥിരീകരിക്കുന്നതിന്, നിങ്ങൾ ആദ്യം ഉപയോഗിച്ച അതേ അക്കൗണ്ട് ഉപയോഗിച്ച് സൈനിൻ ചെയ്യുക.", + "emailsignin": "ഇ-മെയിൽ അയയ്ക്കാൻ കഴിഞ്ഞില്ല.", + "emailverify": "നിങ്ങളുടെ ഇമെയിൽ പരിശോധിക്കുക, ഒരു പുതിയ ഇമെയിൽ അയച്ചിട്ടുണ്ട്.", + "credentialssignin": "സൈൻ ഇൻ പരാജയപ്പെട്ടു. നിങ്ങൾ നൽകിയ വിശദാംശങ്ങൾ ശരിയാണോ എന്ന് പരിശോധിക്കുക.", + "sessionrequired": "ഈ പേജ് ആക്സസ് ചെയ്യുന്നതിന് ദയവായി സൈനിൻ ചെയ്യുക." + } + }, + "authVerifyEmail": { + "almostThere": "നിങ്ങളിവിടെ എത്താറായി! ഞങ്ങൾ ഒരു ഇമെയിൽ അയച്ചിട്ടുണ്ട് ", + "verifyEmailLink": "നിങ്ങളുടെ സൈനപ്പ് പൂർത്തിയാക്കാൻ ആ ഇമെയിലിലെ ലിങ്കിൽ ക്ലിക്കുചെയ്യുക.", + "didNotReceive": "ഇമെയിൽ കണ്ടെത്താൻ കഴിയുന്നില്ലേ?", + "resendEmail": "Email വീണ്ടും അയയ്ക്കുക", + "goBack": "തിരിച്ച് പോകൂ", + "emailSent": "ഇമെയിൽ വിജയകരമായി അയച്ചു.", + "verifyEmail": "നിങ്ങളുടെ ഇമെയിൽ വിലാസം പരിശോധിക്കുക" + }, + "providerButton": { + "continue": "{{provider}} ഉപയോഗിച്ച് തുടരുക", + "signup": "{{provider}} ഉപയോഗിച്ച് സൈൻ അപ്പ് ചെയ്യുക" + }, + "authResetPassword": { + "newPasswordRequired": "പുതിയ പാസ് വേഡ് ആവശ്യമുള്ള ഫീൽഡാണ്", + "passwordsMustMatch": "പാസ് വേഡുകൾ പൊരുത്തപ്പെടണം", + "confirmPasswordRequired": "പാസ് വേഡ് സ്ഥിരീകരിക്കേണ്ടത് ആവശ്യമാണ്", + "newPassword": "പുതിയ പാസ് വേഡ്", + "confirmPassword": "പാസ് വേഡ് സ്ഥിരീകരിക്കുക", + "resetPassword": "പാസ് വേഡ് പുനഃക്രമീകരിക്കുക" + }, + "authForgotPassword": { + "email": "ഇമെയിൽ വിലാസം", + "emailRequired": "ഇമെയിൽ ആവശ്യമായ ഒരു ഫീൽഡാണ്", + "emailSent": "നിങ്ങളുടെ പാസ് വേഡ് പുനഃക്രമീകരിക്കുന്നതിനുള്ള നിർദ്ദേശങ്ങൾക്കായി {{email}} ഇമെയിൽ വിലാസം പരിശോധിക്കുക.", + "enterEmail": "നിങ്ങളുടെ ഇമെയിൽ വിലാസം നൽകുക, നിങ്ങളുടെ പാസ് വേഡ് പുനഃക്രമീകരിക്കുന്നതിനുള്ള നിർദ്ദേശങ്ങൾ ഞങ്ങൾ നിങ്ങൾക്ക് അയയ്ക്കും.", + "resendEmail": "Email വീണ്ടും അയയ്ക്കുക", + "continue": "തുടരുക", + "goBack": "തിരിച്ച് പോകൂ" + } + } + }, + "organisms": { + "chat": { + "history": { + "index": { + "showHistory": "ചരിത്രം കാണിക്കുക", + "lastInputs": "അവസാന ഇൻപുട്ടുകൾ", + "noInputs": "ശൂന്യമായ...", + "loading": "ലോഡിംഗ്..." + } + }, + "inputBox": { + "input": { + "placeholder": "നിങ്ങളുടെ സന്ദേശം ഇവിടെ ടൈപ്പുചെയ്യുക..." + }, + "speechButton": { + "start": "റെക്കോർഡിംഗ് ആരംഭിക്കുക", + "stop": "റെക്കോർഡിംഗ് നിർത്തുക" + }, + "SubmitButton": { + "sendMessage": "സന്ദേശം അയയ്ക്കുക", + "stopTask": "ജോലി നിർത്തുക" + }, + "UploadButton": { + "attachFiles": "ഫയലുകൾ അറ്റാച്ച് ചെയ്യുക" + }, + "waterMark": { + "text": "നിർമ്മിച്ചത്" + } + }, + "Messages": { + "index": { + "running": "ഓടുന്നു", + "executedSuccessfully": "വിജയകരമായി നടപ്പിലാക്കി", + "failed": "പരാജയപ്പെട്ടു", + "feedbackUpdated": "ഫീഡ്ബാക്ക് അപ് ഡേറ്റുചെയ് തു", + "updating": "അപ് ഡേറ്റ്" + } + }, + "dropScreen": { + "dropYourFilesHere": "നിങ്ങളുടെ ഫയലുകൾ ഇവിടെ ഇടുക" + }, + "index": { + "failedToUpload": "അപ് ലോഡ് ചെയ്യുന്നതിൽ പരാജയപ്പെട്ടു", + "cancelledUploadOf": "അപ് ലോഡ് റദ്ദാക്കി", + "couldNotReachServer": "സെർവറിൽ എത്താൻ കഴിഞ്ഞില്ല", + "continuingChat": "മുമ്പത്തെ ചാറ്റ് തുടരുന്നു" + }, + "settings": { + "settingsPanel": "ക്രമീകരണ പാനൽ", + "reset": "പുനഃക്രമീകരിക്കുക", + "cancel": "ക്യാൻസൽ ചെയ്യ്", + "confirm": "സ്ഥിരീകരിക്കുക" + } + }, + "threadHistory": { + "sidebar": { + "filters": { + "FeedbackSelect": { + "feedbackAll": "Feedback: എല്ലാം", + "feedbackPositive": "Feedback: പോസിറ്റീവ്", + "feedbackNegative": "Feedback: നെഗറ്റീവ്" + }, + "SearchBar": { + "search": "തിരയുക" + } + }, + "DeleteThreadButton": { + "confirmMessage": "ഇത് ത്രെഡും അതിന്റെ സന്ദേശങ്ങളും ഘടകങ്ങളും ഇല്ലാതാക്കും.", + "cancel": "ക്യാൻസൽ ചെയ്യ്", + "confirm": "സ്ഥിരീകരിക്കുക", + "deletingChat": "ചാറ്റ് ഇല്ലാതാക്കൽ", + "chatDeleted": "ചാറ്റ് നീക്കം ചെയ്തു" + }, + "index": { + "pastChats": "Past Chats" + }, + "ThreadList": { + "empty": "ശൂന്യം...", + "today": "ഇന്ന്", + "yesterday": "ഇന്നലെ", + "previous7days": "Previous 7 ദിവസം", + "previous30days": "Previous 30 ദിവസം" + }, + "TriggerButton": { + "closeSidebar": "സൈഡ് ബാർ അടയ്ക്കുക", + "openSidebar": "സൈഡ് ബാർ തുറക്കുക" + } + }, + "Thread": { + "backToChat": "ചാറ്റിലേക്ക് മടങ്ങുക", + "chatCreatedOn": "ഈ ചാറ്റ് ഇവിടെ സൃഷ്ടിച്ചു" + } + }, + "header": { + "chat": "സംഭാഷണം", + "readme": "Readme" + } + } + }, + "hooks": { + "useLLMProviders": { + "failedToFetchProviders": "ദാതാക്കളെ കൊണ്ടുവരുന്നതിൽ പരാജയപ്പെട്ടു:" + } + }, + "pages": { + "Design": {}, + "Env": { + "savedSuccessfully": "വിജയകരമായി സംരക്ഷിച്ചു", + "requiredApiKeys": "ആവശ്യമുള്ള API കീകൾ", + "requiredApiKeysInfo": "ഈ അപ്ലിക്കേഷൻ ഉപയോഗിക്കുന്നതിന്, ഇനിപ്പറയുന്ന എപിഐ കീകൾ ആവശ്യമാണ്. നിങ്ങളുടെ ഉപകരണത്തിന്റെ പ്രാദേശിക സംഭരണത്തിലാണ് കീകൾ സംഭരിച്ചിരിക്കുന്നത്." + }, + "Page": { + "notPartOfProject": "നിങ്ങൾ ഈ പദ്ധതിയുടെ ഭാഗമല്ല." + }, + "ResumeButton": { + "resumeChat": "സംഭാഷണം പുനരാരംഭിക്കുക" + } + } +} \ No newline at end of file diff --git a/backend/chainlit/translations/mr.json b/backend/chainlit/translations/mr.json new file mode 100644 index 0000000000..95e3b3a000 --- /dev/null +++ b/backend/chainlit/translations/mr.json @@ -0,0 +1,231 @@ +{ + "components": { + "atoms": { + "buttons": { + "userButton": { + "menu": { + "settings": "सेटिंग्स", + "settingsKey": "S", + "APIKeys": "एपीआय कीज", + "logout": "Logout" + } + } + } + }, + "molecules": { + "newChatButton": { + "newChat": "नवीन गप्पा" + }, + "tasklist": { + "TaskList": { + "title": "🗒️ कार्य सूची", + "loading": "लोडिंग...", + "error": "एक त्रुटी झाली" + } + }, + "attachments": { + "cancelUpload": "अपलोड रद्द करा", + "removeAttachment": "संलग्नता काढून टाका" + }, + "newChatDialog": { + "createNewChat": "नवीन चॅट तयार करा?", + "clearChat": "यामुळे सध्याचे मेसेज क्लिअर होतील आणि नवीन चॅट सुरू होईल.", + "cancel": "रद्द करा", + "confirm": "पुष्टी करा" + }, + "settingsModal": { + "settings": "सेटिंग्स", + "expandMessages": "संदेश ांचा विस्तार करा", + "hideChainOfThought": "विचारांची साखळी लपवा", + "darkMode": "डार्क मोड" + }, + "detailsButton": { + "using": "वापरत", + "running": "धावत आहे.", + "took_one": "{{count}} पाऊल उचलले", + "took_other": "{{count}} पावले उचलली" + }, + "auth": { + "authLogin": { + "title": "अ ॅपमध्ये प्रवेश करण्यासाठी लॉगिन करा.", + "form": { + "email": "ईमेल पत्ता", + "password": "पासवर्ड", + "noAccount": "खाते नाही का?", + "alreadyHaveAccount": "आधीच खाते आहे का?", + "signup": "साइन अप करा", + "signin": "साइन इन", + "or": "किंवा", + "continue": "चालू ठेवा", + "forgotPassword": "पासवर्ड विसरला?", + "passwordMustContain": "आपल्या पासवर्डमध्ये हे असणे आवश्यक आहे:", + "emailRequired": "ईमेल हे एक आवश्यक क्षेत्र आहे", + "passwordRequired": "पासवर्ड हे एक आवश्यक क्षेत्र आहे" + }, + "error": { + "default": "साइन इन करण्यास अक्षम.", + "signin": "वेगळ्या खात्यासह साइन इन करण्याचा प्रयत्न करा.", + "oauthsignin": "वेगळ्या खात्यासह साइन इन करण्याचा प्रयत्न करा.", + "redirect_uri_mismatch": "रिडायरेक्ट यूआरआय ऑथ अॅप कॉन्फिगरेशनशी जुळत नाही.", + "oauthcallbackerror": "वेगळ्या खात्यासह साइन इन करण्याचा प्रयत्न करा.", + "oauthcreateaccount": "वेगळ्या खात्यासह साइन इन करण्याचा प्रयत्न करा.", + "emailcreateaccount": "वेगळ्या खात्यासह साइन इन करण्याचा प्रयत्न करा.", + "callback": "वेगळ्या खात्यासह साइन इन करण्याचा प्रयत्न करा.", + "oauthaccountnotlinked": "आपली ओळख निश्चित करण्यासाठी, आपण मूळवापरलेल्या खात्यासह साइन इन करा.", + "emailsignin": "ई-मेल पाठवता आला नाही.", + "emailverify": "कृपया आपल्या ईमेलची पडताळणी करा, एक नवीन ईमेल पाठविला गेला आहे.", + "credentialssignin": "साइन इन अयशस्वी झाले. आपण दिलेला तपशील योग्य आहे हे तपासा.", + "sessionrequired": "कृपया या पृष्ठावर प्रवेश करण्यासाठी साइन इन करा." + } + }, + "authVerifyEmail": { + "almostThere": "तू जवळजवळ तिथेच आहेस! आम्ही एक ईमेल पाठवला आहे. ", + "verifyEmailLink": "आपले साइनअप पूर्ण करण्यासाठी कृपया त्या ईमेलमधील लिंकवर क्लिक करा.", + "didNotReceive": "ईमेल सापडत नाही का?", + "resendEmail": "ईमेल पुन्हा पाठवा", + "goBack": "परत जा", + "emailSent": "ईमेल यशस्वीरित्या पाठविला.", + "verifyEmail": "आपला ईमेल पत्ता पडताळून पहा" + }, + "providerButton": { + "continue": "{{provider}} चालू ठेवा", + "signup": "{{provider}} सह साइन अप करा" + }, + "authResetPassword": { + "newPasswordRequired": "नवीन पासवर्ड हे आवश्यक क्षेत्र आहे", + "passwordsMustMatch": "पासवर्ड जुळणे आवश्यक आहे", + "confirmPasswordRequired": "पासवर्ड आवश्यक क्षेत्र आहे याची पुष्टी करा", + "newPassword": "नवीन पासवर्ड", + "confirmPassword": "पासवर्ड ची पुष्टी करा", + "resetPassword": "पासवर्ड रीसेट करा" + }, + "authForgotPassword": { + "email": "ईमेल पत्ता", + "emailRequired": "ईमेल हे एक आवश्यक क्षेत्र आहे", + "emailSent": "आपला पासवर्ड रीसेट करण्याच्या सूचनांसाठी कृपया ईमेल पत्ता {{email}} तपासा.", + "enterEmail": "आपला ईमेल पत्ता प्रविष्ट करा आणि आम्ही आपल्याला आपला पासवर्ड रीसेट करण्याच्या सूचना पाठवू.", + "resendEmail": "ईमेल पुन्हा पाठवा", + "continue": "चालू ठेवा", + "goBack": "परत जा" + } + } + }, + "organisms": { + "chat": { + "history": { + "index": { + "showHistory": "इतिहास दाखवा", + "lastInputs": "शेवटची माहिती", + "noInputs": "इतकी रिकामी...", + "loading": "लोडिंग..." + } + }, + "inputBox": { + "input": { + "placeholder": "तुमचा मेसेज इथे टाईप करा..." + }, + "speechButton": { + "start": "रेकॉर्डिंग सुरू करा", + "stop": "रेकॉर्डिंग थांबवा" + }, + "SubmitButton": { + "sendMessage": "संदेश पाठवा", + "stopTask": "कार्य थांबवा" + }, + "UploadButton": { + "attachFiles": "फाईल्स संलग्न करा" + }, + "waterMark": { + "text": "यासह बांधले आहे" + } + }, + "Messages": { + "index": { + "running": "धावत आहे.", + "executedSuccessfully": "यशस्वीरित्या राबविली", + "failed": "अपयशी ठरले", + "feedbackUpdated": "अभिप्राय अद्ययावत", + "updating": "अद्ययावत करणे" + } + }, + "dropScreen": { + "dropYourFilesHere": "आपल्या फायली येथे टाका" + }, + "index": { + "failedToUpload": "अपलोड करण्यात अपयश आले", + "cancelledUploadOf": "रद्द केलेले अपलोड", + "couldNotReachServer": "सर्व्हरपर्यंत पोहोचू शकले नाही", + "continuingChat": "मागील गप्पा चालू ठेवा" + }, + "settings": { + "settingsPanel": "सेटिंग्स पॅनेल", + "reset": "रीसेट करा", + "cancel": "रद्द करा", + "confirm": "पुष्टी करा" + } + }, + "threadHistory": { + "sidebar": { + "filters": { + "FeedbackSelect": { + "feedbackAll": "अभिप्राय: सर्व", + "feedbackPositive": "अभिप्राय: सकारात्मक", + "feedbackNegative": "अभिप्राय: नकारात्मक" + }, + "SearchBar": { + "search": "शोधणे" + } + }, + "DeleteThreadButton": { + "confirmMessage": "हे धागा तसेच त्यातील संदेश आणि घटक डिलीट करेल.", + "cancel": "रद्द करा", + "confirm": "पुष्टी करा", + "deletingChat": "चॅट डिलीट करणे", + "chatDeleted": "चॅट डिलीट" + }, + "index": { + "pastChats": "मागील गप्पा" + }, + "ThreadList": { + "empty": "रिक्त।।।", + "today": "आज", + "yesterday": "काल", + "previous7days": "मागील 7 दिवस", + "previous30days": "मागील ३० दिवस" + }, + "TriggerButton": { + "closeSidebar": "साइडबार बंद करा", + "openSidebar": "ओपन साइडबार" + } + }, + "Thread": { + "backToChat": "परत गप्पा मारायला जा", + "chatCreatedOn": "हे चॅट तयार करण्यात आले होते." + } + }, + "header": { + "chat": "बकवाद करणें", + "readme": "वाचा" + } + } + }, + "hooks": { + "useLLMProviders": { + "failedToFetchProviders": "प्रदात्यांना आणण्यात अपयशी:" + } + }, + "pages": { + "Design": {}, + "Env": { + "savedSuccessfully": "यशस्वीरित्या वाचवले", + "requiredApiKeys": "आवश्यक एपीआय चाव्या", + "requiredApiKeysInfo": "हे अॅप वापरण्यासाठी खालील एपीआय चाव्या आवश्यक आहेत. चाव्या आपल्या डिव्हाइसच्या स्थानिक स्टोरेजवर संग्रहित केल्या जातात." + }, + "Page": { + "notPartOfProject": "तुम्ही या प्रकल्पाचा भाग नाही." + }, + "ResumeButton": { + "resumeChat": "चॅट पुन्हा सुरू करा" + } + } +} \ No newline at end of file diff --git a/backend/chainlit/translations/ta.json b/backend/chainlit/translations/ta.json new file mode 100644 index 0000000000..2681bd81a0 --- /dev/null +++ b/backend/chainlit/translations/ta.json @@ -0,0 +1,231 @@ +{ + "components": { + "atoms": { + "buttons": { + "userButton": { + "menu": { + "settings": "அமைப்புகள்", + "settingsKey": "S", + "APIKeys": "API விசைகள்", + "logout": "வெளியேறு" + } + } + } + }, + "molecules": { + "newChatButton": { + "newChat": "புதிய அரட்டை" + }, + "tasklist": { + "TaskList": { + "title": "🗒️ பணி பட்டியல்", + "loading": "ஏற்றுகிறது...", + "error": "ஒரு பிழை ஏற்பட்டது" + } + }, + "attachments": { + "cancelUpload": "பதிவேற்றத்தை ரத்துசெய்", + "removeAttachment": "இணைப்பை அகற்று" + }, + "newChatDialog": { + "createNewChat": "புதிய அரட்டையை உருவாக்கவா?", + "clearChat": "இது தற்போதைய செய்திகளை அழித்து புதிய அரட்டையைத் தொடங்கும்.", + "cancel": "ரத்து", + "confirm": "உறுதிசெய்" + }, + "settingsModal": { + "settings": "அமைப்புகள்", + "expandMessages": "செய்திகளை விரிவாக்கு", + "hideChainOfThought": "சிந்தனைச் சங்கிலியை மறைத்து", + "darkMode": "இருண்ட பயன்முறை" + }, + "detailsButton": { + "using": "பயன்படுத்தி", + "running": "ஓடுதல்", + "took_one": "{{count}} அடி எடுத்து வைத்தார்", + "took_other": "{{count}} படிகளை எடுத்தார்" + }, + "auth": { + "authLogin": { + "title": "பயன்பாட்டை அணுக உள்நுழைக.", + "form": { + "email": "மின்னஞ்சல் முகவரி", + "password": "கடவுச்சொல்", + "noAccount": "கணக்கு இல்லையா?", + "alreadyHaveAccount": "ஏற்கனவே ஒரு கணக்கு உள்ளதா?", + "signup": "பதிவுபெறு", + "signin": "உள்நுழைக", + "or": "அல்லது", + "continue": "தொடர்", + "forgotPassword": "கடவுச்சொல்லை மறந்துவிட்டீர்களா?", + "passwordMustContain": "உங்கள் கடவுச்சொல்லில் இவை இருக்க வேண்டும்:", + "emailRequired": "மின்னஞ்சல் ஒரு தேவையான புலம்", + "passwordRequired": "கடவுச்சொல் தேவையான புலம்" + }, + "error": { + "default": "உள்நுழைய இயலவில்லை.", + "signin": "வேறொரு கணக்குடன் உள்நுழைய முயற்சிக்கவும்.", + "oauthsignin": "வேறொரு கணக்குடன் உள்நுழைய முயற்சிக்கவும்.", + "redirect_uri_mismatch": "வழிமாற்று URI oauth பயன்பாட்டு உள்ளமைவுடன் பொருந்தவில்லை.", + "oauthcallbackerror": "வேறொரு கணக்குடன் உள்நுழைய முயற்சிக்கவும்.", + "oauthcreateaccount": "வேறொரு கணக்குடன் உள்நுழைய முயற்சிக்கவும்.", + "emailcreateaccount": "வேறொரு கணக்குடன் உள்நுழைய முயற்சிக்கவும்.", + "callback": "வேறொரு கணக்குடன் உள்நுழைய முயற்சிக்கவும்.", + "oauthaccountnotlinked": "உங்கள் அடையாளத்தை உறுதிப்படுத்த, நீங்கள் முதலில் பயன்படுத்திய அதே கணக்குடன் உள்நுழையவும்.", + "emailsignin": "மின்னஞ்சலை அனுப்ப இயலவில்லை.", + "emailverify": "உங்கள் மின்னஞ்சலை உறுதிப்படுத்துங்கள், ஒரு புதிய மின்னஞ்சல் அனுப்பப்பட்டது.", + "credentialssignin": "உள்நுழைவு தோல்வியுற்றது. நீங்கள் வழங்கிய விவரங்கள் சரியானதா என்று சரிபார்க்கவும்.", + "sessionrequired": "இந்தப் பக்கத்தை அணுக உள்நுழையவும்." + } + }, + "authVerifyEmail": { + "almostThere": "நீங்கள் கிட்டத்தட்ட வந்துவிட்டீர்கள்! -க்கு ஒரு மின்னஞ்சல் அனுப்பியுள்ளோம் ", + "verifyEmailLink": "உங்கள் பதிவுசெய்தலை நிறைவுசெய்ய அந்த மின்னஞ்சலில் உள்ள இணைப்பைக் கிளிக் செய்யவும்.", + "didNotReceive": "மின்னஞ்சலைக் கண்டுபிடிக்க முடியவில்லையா?", + "resendEmail": "மின்னஞ்சலை மீண்டும் அனுப்பவும்", + "goBack": "பின் செல்", + "emailSent": "மின்னஞ்சல் வெற்றிகரமாக அனுப்பப்பட்டது.", + "verifyEmail": "உங்கள் ஈமெயில் முகவரியைச் சரிபார்க்கவும்" + }, + "providerButton": { + "continue": "{{provider}} உடன் தொடரவும்", + "signup": "{{provider}} உடன் பதிவு செய்க" + }, + "authResetPassword": { + "newPasswordRequired": "புதிய கடவுச்சொல் தேவையான புலம்", + "passwordsMustMatch": "கடவுச்சொற்கள் பொருந்த வேண்டும்", + "confirmPasswordRequired": "கடவுச்சொல்லை உறுதிப்படுத்தவும் தேவையான புலம்", + "newPassword": "புதிய கடவுச்சொல்", + "confirmPassword": "கடவுச்சொல்லை உறுதிப்படுத்தவும்", + "resetPassword": "கடவுச்சொல்லை மீட்டமை" + }, + "authForgotPassword": { + "email": "மின்னஞ்சல் முகவரி", + "emailRequired": "மின்னஞ்சல் ஒரு தேவையான புலம்", + "emailSent": "உங்கள் கடவுச்சொல்லை மீட்டமைப்பதற்கான வழிமுறைகளுக்கு {{email}} மின்னஞ்சல் முகவரியை சரிபார்க்கவும்.", + "enterEmail": "உங்கள் மின்னஞ்சல் முகவரியை உள்ளிடவும், உங்கள் கடவுச்சொல்லை மீட்டமைக்க நாங்கள் உங்களுக்கு வழிமுறைகளை அனுப்புவோம்.", + "resendEmail": "மின்னஞ்சலை மீண்டும் அனுப்பவும்", + "continue": "தொடர்", + "goBack": "பின் செல்" + } + } + }, + "organisms": { + "chat": { + "history": { + "index": { + "showHistory": "வரலாற்றைக் காட்டுக", + "lastInputs": "கடைசி உள்ளீடுகள்", + "noInputs": "அவ்வளவு வெறுமை...", + "loading": "ஏற்றுகிறது..." + } + }, + "inputBox": { + "input": { + "placeholder": "உங்கள் செய்தியை இங்கே தட்டச்சு செய்க..." + }, + "speechButton": { + "start": "பதிவு செய்யத் தொடங்கு", + "stop": "பதிவு செய்வதை நிறுத்து" + }, + "SubmitButton": { + "sendMessage": "செய்தி அனுப்பு", + "stopTask": "பணியை நிறுத்து" + }, + "UploadButton": { + "attachFiles": "கோப்புகளை இணைக்கவும்" + }, + "waterMark": { + "text": "உடன் கட்டப்பட்டது" + } + }, + "Messages": { + "index": { + "running": "ஓடுதல்", + "executedSuccessfully": "வெற்றிகரமாக செயல்படுத்தப்பட்டது", + "failed": "தோல்வியுற்றது", + "feedbackUpdated": "கருத்து புதுப்பிக்கப்பட்டது", + "updating": "புதுப்பிக்கிறது" + } + }, + "dropScreen": { + "dropYourFilesHere": "உங்கள் கோப்புகளை இங்கே விடுங்கள்:" + }, + "index": { + "failedToUpload": "பதிவேற்றுவதில் தோல்வி", + "cancelledUploadOf": "ரத்து செய்யப்பட்ட பதிவேற்றம்", + "couldNotReachServer": "சேவையகத்தை அடைய முடியவில்லை", + "continuingChat": "தொடரும் முந்தைய அரட்டை" + }, + "settings": { + "settingsPanel": "அமைப்புகள் குழு", + "reset": "மீட்டமை", + "cancel": "ரத்து", + "confirm": "உறுதிசெய்" + } + }, + "threadHistory": { + "sidebar": { + "filters": { + "FeedbackSelect": { + "feedbackAll": "பின்னூட்டம்: அனைத்தும்", + "feedbackPositive": "பின்னூட்டம்: நேர்மறை", + "feedbackNegative": "பின்னூட்டம்: எதிர்மறை" + }, + "SearchBar": { + "search": "தேடு" + } + }, + "DeleteThreadButton": { + "confirmMessage": "இது நூல் மற்றும் அதன் செய்திகள் மற்றும் கூறுகளை நீக்கும்.", + "cancel": "ரத்து", + "confirm": "உறுதிசெய்", + "deletingChat": "அரட்டையை நீக்குகிறது", + "chatDeleted": "அரட்டை நீக்கப்பட்டது" + }, + "index": { + "pastChats": "கடந்த அரட்டைகள்" + }, + "ThreadList": { + "empty": "காலியான...", + "today": "இன்று", + "yesterday": "நேற்று", + "previous7days": "முந்தைய 7 நாட்கள்", + "previous30days": "முந்தைய 30 நாட்கள்" + }, + "TriggerButton": { + "closeSidebar": "பக்கப்பட்டியை மூடு", + "openSidebar": "பக்கப்பட்டியைத் திறக்கவும்" + } + }, + "Thread": { + "backToChat": "அரட்டைக்கு மீண்டும் செல்லவும்", + "chatCreatedOn": "இந்த அரட்டை உருவாக்கப்பட்ட தேதி" + } + }, + "header": { + "chat": "அரட்டை", + "readme": "ரீட்மீ" + } + } + }, + "hooks": { + "useLLMProviders": { + "failedToFetchProviders": "வழங்குநர்களைப் பெறுவதில் தோல்வி:" + } + }, + "pages": { + "Design": {}, + "Env": { + "savedSuccessfully": "வெற்றிகரமாக சேமிக்கப்பட்டது", + "requiredApiKeys": "தேவையான API விசைகள்", + "requiredApiKeysInfo": "இந்த பயன்பாட்டைப் பயன்படுத்த, பின்வரும் API விசைகள் தேவை. விசைகள் உங்கள் சாதனத்தின் உள்ளூர் சேமிப்பகத்தில் சேமிக்கப்படும்." + }, + "Page": { + "notPartOfProject": "நீங்கள் இந்தத் திட்டத்தின் ஒரு பகுதியாக இல்லை." + }, + "ResumeButton": { + "resumeChat": "அரட்டையை மீண்டும் தொடங்கவும்" + } + } +} \ No newline at end of file diff --git a/backend/chainlit/translations/te.json b/backend/chainlit/translations/te.json new file mode 100644 index 0000000000..64f649be4a --- /dev/null +++ b/backend/chainlit/translations/te.json @@ -0,0 +1,231 @@ +{ + "components": { + "atoms": { + "buttons": { + "userButton": { + "menu": { + "settings": "సెట్టింగ్ లు", + "settingsKey": "S", + "APIKeys": "API Keys", + "logout": "Logout" + } + } + } + }, + "molecules": { + "newChatButton": { + "newChat": "కొత్త చాట్" + }, + "tasklist": { + "TaskList": { + "title": "🗒️ టాస్క్ లిస్ట్", + "loading": "లోడింగ్...", + "error": "ఒక దోషం సంభవించింది" + } + }, + "attachments": { + "cancelUpload": "అప్ లోడ్ రద్దు చేయండి", + "removeAttachment": "అటాచ్ మెంట్ తొలగించు" + }, + "newChatDialog": { + "createNewChat": "కొత్త చాట్ సృష్టించాలా?", + "clearChat": "ఇది ప్రస్తుత సందేశాలను క్లియర్ చేస్తుంది మరియు కొత్త చాట్ను ప్రారంభిస్తుంది.", + "cancel": "రద్దు", + "confirm": "ధ్రువపరచు" + }, + "settingsModal": { + "settings": "సెట్టింగ్ లు", + "expandMessages": "సందేశాలను విస్తరించండి", + "hideChainOfThought": "ఆలోచనా గొలుసును దాచండి", + "darkMode": "డార్క్ మోడ్" + }, + "detailsButton": { + "using": "ఉపయోగించడం", + "running": "రన్నింగ్", + "took_one": "{{count}} అడుగు వేసింది", + "took_other": "{{count}} అడుగులు వేసింది" + }, + "auth": { + "authLogin": { + "title": "యాప్ యాక్సెస్ చేసుకోవడానికి లాగిన్ అవ్వండి.", + "form": { + "email": "ఇమెయిల్ చిరునామా", + "password": "పాస్ వర్డ్", + "noAccount": "మీకు అకౌంట్ లేదా?", + "alreadyHaveAccount": "ఇప్పటికే ఖాతా ఉందా?", + "signup": "సైన్ అప్", + "signin": "సైన్ ఇన్", + "or": "లేదా", + "continue": "కొనసాగు", + "forgotPassword": "పాస్ వర్డ్ మర్చిపోయారా?", + "passwordMustContain": "మీ పాస్ వర్డ్ లో ఇవి ఉండాలి:", + "emailRequired": "ఇమెయిల్ అనేది అవసరమైన ఫీల్డ్", + "passwordRequired": "పాస్ వర్డ్ అనేది అవసరమైన ఫీల్డ్" + }, + "error": { + "default": "సైన్ ఇన్ చేయడం సాధ్యం కాదు.", + "signin": "వేరొక ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి.", + "oauthsignin": "వేరొక ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి.", + "redirect_uri_mismatch": "రీడైరెక్ట్ URI ఓయూత్ యాప్ కాన్ఫిగరేషన్ కు సరిపోలడం లేదు.", + "oauthcallbackerror": "వేరొక ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి.", + "oauthcreateaccount": "వేరొక ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి.", + "emailcreateaccount": "వేరొక ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి.", + "callback": "వేరొక ఖాతాతో సైన్ ఇన్ చేయడానికి ప్రయత్నించండి.", + "oauthaccountnotlinked": "మీ గుర్తింపును ధృవీకరించడానికి, మీరు మొదట ఉపయోగించిన అదే ఖాతాతో సైన్ ఇన్ చేయండి.", + "emailsignin": "ఇ-మెయిల్ పంపడం సాధ్యం కాదు.", + "emailverify": "దయచేసి మీ ఇమెయిల్ ని ధృవీకరించండి, కొత్త ఇమెయిల్ పంపబడింది.", + "credentialssignin": "సైన్ ఇన్ విఫలమైంది. మీరు అందించిన వివరాలు సరిగ్గా ఉన్నాయో లేదో చెక్ చేసుకోండి.", + "sessionrequired": "ఈ పేజీని యాక్సెస్ చేయడం కొరకు దయచేసి సైన్ ఇన్ చేయండి." + } + }, + "authVerifyEmail": { + "almostThere": "మీరు దాదాపు అక్కడే ఉన్నారు! మేము దీనికి ఒక ఇమెయిల్ పంపాము ", + "verifyEmailLink": "మీ సైన్ అప్ పూర్తి చేయడానికి దయచేసి ఆ ఇమెయిల్ లోని లింక్ పై క్లిక్ చేయండి.", + "didNotReceive": "ఇమెయిల్ ని కనుగొనలేకపోయారా?", + "resendEmail": "ఇమెయిల్ ని తిరిగి పంపండి", + "goBack": "వెనక్కి వెళ్ళు", + "emailSent": "ఇమెయిల్ విజయవంతంగా పంపబడింది.", + "verifyEmail": "మీ ఇమెయిల్ చిరునామాను ధృవీకరించండి" + }, + "providerButton": { + "continue": "{{provider}} తో కొనసాగించండి", + "signup": "{{provider}} తో సైన్ అప్ చేయండి" + }, + "authResetPassword": { + "newPasswordRequired": "కొత్త పాస్ వర్డ్ అనేది అవసరమైన ఫీల్డ్", + "passwordsMustMatch": "పాస్ వర్డ్ లు తప్పనిసరిగా సరిపోలాలి", + "confirmPasswordRequired": "పాస్ వర్డ్ అనేది అవసరమైన ఫీల్డ్ అని ధృవీకరించండి", + "newPassword": "కొత్త పాస్ వర్డ్", + "confirmPassword": "పాస్ వర్డ్ ను ధృవీకరించండి", + "resetPassword": "రీసెట్ పాస్ వర్డ్" + }, + "authForgotPassword": { + "email": "ఇమెయిల్ చిరునామా", + "emailRequired": "ఇమెయిల్ అనేది అవసరమైన ఫీల్డ్", + "emailSent": "మీ పాస్ వర్డ్ రీసెట్ చేయడానికి సూచనల కొరకు దయచేసి {{email}} ఇమెయిల్ చిరునామాను తనిఖీ చేయండి.", + "enterEmail": "మీ ఇమెయిల్ చిరునామాను నమోదు చేయండి మరియు మీ పాస్ వర్డ్ ను రీసెట్ చేయడానికి మేము మీకు సూచనలు పంపుతాము.", + "resendEmail": "ఇమెయిల్ ని తిరిగి పంపండి", + "continue": "కొనసాగు", + "goBack": "వెనక్కి వెళ్ళు" + } + } + }, + "organisms": { + "chat": { + "history": { + "index": { + "showHistory": "చరిత్రను చూపించు", + "lastInputs": "చివరి ఇన్ పుట్ లు", + "noInputs": "అంత ఖాళీగా...", + "loading": "లోడింగ్..." + } + }, + "inputBox": { + "input": { + "placeholder": "మీ సందేశాన్ని ఇక్కడ టైప్ చేయండి..." + }, + "speechButton": { + "start": "రికార్డింగ్ ప్రారంభించండి", + "stop": "రికార్డింగ్ ఆపండి" + }, + "SubmitButton": { + "sendMessage": "సందేశం పంపు", + "stopTask": "స్టాప్ టాస్క్" + }, + "UploadButton": { + "attachFiles": "ఫైళ్లను జోడించు" + }, + "waterMark": { + "text": "దీనితో నిర్మించబడింది" + } + }, + "Messages": { + "index": { + "running": "రన్నింగ్", + "executedSuccessfully": "విజయవంతంగా అమలు చేయబడింది", + "failed": "విఫలమైంది", + "feedbackUpdated": "ఫీడ్ బ్యాక్ అప్ డేట్ చేయబడింది", + "updating": "అప్ డేట్ చేయడం" + } + }, + "dropScreen": { + "dropYourFilesHere": "మీ ఫైళ్లను ఇక్కడ డ్రాప్ చేయండి" + }, + "index": { + "failedToUpload": "అప్ లోడ్ చేయడం విఫలమైంది", + "cancelledUploadOf": "రద్దు చేసిన అప్ లోడ్", + "couldNotReachServer": "సర్వర్ కు చేరుకోవడం సాధ్యం కాలేదు", + "continuingChat": "మునుపటి చాట్ ను కొనసాగించడం" + }, + "settings": { + "settingsPanel": "సెట్టింగ్స్ ప్యానెల్", + "reset": "రీసెట్", + "cancel": "రద్దు", + "confirm": "ధ్రువపరచు" + } + }, + "threadHistory": { + "sidebar": { + "filters": { + "FeedbackSelect": { + "feedbackAll": "ఫీడ్ బ్యాక్: అన్నీ", + "feedbackPositive": "ఫీడ్ బ్యాక్: పాజిటివ్", + "feedbackNegative": "ఫీడ్ బ్యాక్: నెగెటివ్" + }, + "SearchBar": { + "search": "వెతుకు" + } + }, + "DeleteThreadButton": { + "confirmMessage": "ఇది థ్రెడ్తో పాటు దాని సందేశాలు మరియు ఎలిమెంట్లను తొలగిస్తుంది.", + "cancel": "రద్దు", + "confirm": "ధ్రువపరచు", + "deletingChat": "చాట్ ను డిలీట్ చేయడం", + "chatDeleted": "చాట్ డిలీట్ చేయబడింది" + }, + "index": { + "pastChats": "గత చాట్ లు" + }, + "ThreadList": { + "empty": "ఖాళీ...", + "today": "ఈ రోజు", + "yesterday": "నిన్న", + "previous7days": "మునుపటి 7 రోజులు", + "previous30days": "మునుపటి 30 రోజులు" + }, + "TriggerButton": { + "closeSidebar": "క్లోజ్ సైడ్ బార్", + "openSidebar": "ఓపెన్ సైడ్ బార్" + } + }, + "Thread": { + "backToChat": "చాట్ చేయడానికి తిరిగి వెళ్లండి", + "chatCreatedOn": "ఈ చాట్ దీనిలో సృష్టించబడింది" + } + }, + "header": { + "chat": "ముచ్చటించు", + "readme": "Readme" + } + } + }, + "hooks": { + "useLLMProviders": { + "failedToFetchProviders": "ప్రొవైడర్లను పొందడంలో విఫలమైంది:" + } + }, + "pages": { + "Design": {}, + "Env": { + "savedSuccessfully": "విజయవంతంగా సేవ్ చేయబడింది", + "requiredApiKeys": "అవసరమైన API కీలు", + "requiredApiKeysInfo": "ఈ యాప్ ఉపయోగించడానికి, ఈ క్రింది API కీలు అవసరం అవుతాయి. కీలు మీ పరికరం యొక్క స్థానిక స్టోరేజీలో నిల్వ చేయబడతాయి." + }, + "Page": { + "notPartOfProject": "మీరు ఈ ప్రాజెక్టులో భాగం కాదు." + }, + "ResumeButton": { + "resumeChat": "రెజ్యూమ్ చాట్" + } + } +} \ No newline at end of file From ff7fbe0fd74e93e9f1c9d5eb35d4935b849ade7c Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Tue, 10 Sep 2024 11:09:23 +0100 Subject: [PATCH 34/45] Bump dataclasses to latest version. (#1291) --- backend/poetry.lock | 34 ++++++++-------------------------- backend/pyproject.toml | 3 ++- 2 files changed, 10 insertions(+), 27 deletions(-) diff --git a/backend/poetry.lock b/backend/poetry.lock index 77888a77a7..aefff1d7e1 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -1027,22 +1027,18 @@ tests = ["pytest", "pytest-cov", "pytest-xdist"] [[package]] name = "dataclasses-json" -version = "0.5.9" -description = "Easily serialize dataclasses to and from JSON" +version = "0.6.7" +description = "Easily serialize dataclasses to and from JSON." optional = false -python-versions = ">=3.6" +python-versions = "<4.0,>=3.7" files = [ - {file = "dataclasses-json-0.5.9.tar.gz", hash = "sha256:e9ac87b73edc0141aafbce02b44e93553c3123ad574958f0fe52a534b6707e8e"}, - {file = "dataclasses_json-0.5.9-py3-none-any.whl", hash = "sha256:1280542631df1c375b7bc92e5b86d39e06c44760d7e3571a537b3b8acabf2f0c"}, + {file = "dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a"}, + {file = "dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0"}, ] [package.dependencies] -marshmallow = ">=3.3.0,<4.0.0" -marshmallow-enum = ">=1.5.1,<2.0.0" -typing-inspect = ">=0.4.0" - -[package.extras] -dev = ["flake8", "hypothesis", "ipython", "mypy (>=0.710)", "portray", "pytest (>=7.2.0)", "setuptools", "simplejson", "twine", "types-dataclasses", "wheel"] +marshmallow = ">=3.18.0,<4.0.0" +typing-inspect = ">=0.4.0,<1" [[package]] name = "deprecated" @@ -2570,20 +2566,6 @@ dev = ["marshmallow[tests]", "pre-commit (>=3.5,<4.0)", "tox"] docs = ["alabaster (==1.0.0)", "autodocsumm (==0.2.13)", "sphinx (==8.0.2)", "sphinx-issues (==4.1.0)", "sphinx-version-warning (==1.1.2)"] tests = ["pytest", "pytz", "simplejson"] -[[package]] -name = "marshmallow-enum" -version = "1.5.1" -description = "Enum field for Marshmallow" -optional = false -python-versions = "*" -files = [ - {file = "marshmallow-enum-1.5.1.tar.gz", hash = "sha256:38e697e11f45a8e64b4a1e664000897c659b60aa57bfa18d44e226a9920b6e58"}, - {file = "marshmallow_enum-1.5.1-py2.py3-none-any.whl", hash = "sha256:57161ab3dbfde4f57adeb12090f39592e992b9c86d206d02f6bd03ebec60f072"}, -] - -[package.dependencies] -marshmallow = ">=2.0.0" - [[package]] name = "matplotlib" version = "3.9.2" @@ -5431,4 +5413,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0.0" -content-hash = "2c7c18baeef86be74a58fed3fe79c46539fa2972c6b845c089cad09524b1c100" +content-hash = "0c20ee17e5e449204c15b44de7ba5597863a97cc2195d77365d42091b09af2ff" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e170c239c5..7cb2086180 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -28,7 +28,7 @@ chainlit = 'chainlit.cli:cli' python = ">=3.9,<4.0.0" httpx = ">=0.23.0" literalai = "0.0.607" -dataclasses_json = "^0.5.7" +dataclasses_json = "^0.6.7" fastapi = ">=0.110.1,<0.113" starlette = "^0.37.2" uvicorn = "^0.25.0" @@ -105,6 +105,7 @@ ignore_missing_imports = true + [tool.poetry.group.custom-data] optional = true From 74636a990eb989068bfcb7a5b03122cc356cb10a Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Tue, 10 Sep 2024 15:58:49 +0100 Subject: [PATCH 35/45] Load env before other imports. (#1328) --- backend/chainlit/__init__.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/backend/chainlit/__init__.py b/backend/chainlit/__init__.py index 2d3adc94a0..0506ef38f3 100644 --- a/backend/chainlit/__init__.py +++ b/backend/chainlit/__init__.py @@ -1,5 +1,17 @@ -import asyncio import os + +from dotenv import load_dotenv + +# ruff: noqa: E402 +# Keep this here to ensure imports have environment available. +env_found = load_dotenv(dotenv_path=os.path.join(os.getcwd(), ".env")) + +from chainlit.logger import logger + +if env_found: + logger.info("Loaded .env file") + +import asyncio from typing import TYPE_CHECKING, Any, Dict import chainlit.input_widget as input_widget @@ -22,7 +34,6 @@ Text, Video, ) -from chainlit.logger import logger from chainlit.message import ( AskActionMessage, AskFileMessage, @@ -37,7 +48,6 @@ from chainlit.user_session import user_session from chainlit.utils import make_module_getattr from chainlit.version import __version__ -from dotenv import load_dotenv from literalai import ChatGeneration, CompletionGeneration, GenerationMessage from pydantic.dataclasses import dataclass @@ -71,12 +81,6 @@ from chainlit.openai import instrument_openai -env_found = load_dotenv(dotenv_path=os.path.join(os.getcwd(), ".env")) - -if env_found: - logger.info("Loaded .env file") - - def sleep(duration: int): """ Sleep for a given duration. From 0f7aad597dee5c464aa4e2f4db3da122a8f1b70f Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Fri, 13 Sep 2024 12:22:52 +0100 Subject: [PATCH 36/45] Security: various path traversal issues (#1326) * Regression tests and fixes for path traversal issues in `project_translations`, `project_settings` and `get_avatar` endpoints. * Resolve minor linter issues, formatting. * Start of tests for FastAPI endpoints. * Docstrings to most server.py methods. * Build frontend for backend tests to make tests work. * Follow ISO spec on Klingon matters in E2E tests. --- .github/workflows/pytest.yaml | 8 ++ backend/chainlit/_utils.py | 8 ++ backend/chainlit/config.py | 50 ++++--- backend/chainlit/markdown.py | 24 ++-- backend/chainlit/server.py | 110 +++++++++++---- backend/tests/conftest.py | 1 + backend/tests/test_callbacks.py | 107 ++++++++------- backend/tests/test_server.py | 230 ++++++++++++++++++++++++++++++++ cypress/e2e/readme/spec.cy.ts | 2 +- 9 files changed, 434 insertions(+), 106 deletions(-) create mode 100644 backend/chainlit/_utils.py create mode 100644 backend/tests/test_server.py diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index ef2a99b646..7a335d99d6 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -23,6 +23,14 @@ jobs: - name: Install fastapi ${{ matrix.fastapi-version }} run: poetry add fastapi@^${{ matrix.fastapi-version}} working-directory: ${{ env.BACKEND_DIR }} + - uses: ./.github/actions/pnpm-node-install + name: Install Node, pnpm and dependencies. + with: + node-version: 22.7.0 + pnpm-version: 9.7.0 + pnpm-install-args: --no-frozen-lockfile + - name: Build UI + run: pnpm run buildUi - name: Run Pytest run: poetry run pytest --cov=chainlit/ working-directory: ${{ env.BACKEND_DIR }} diff --git a/backend/chainlit/_utils.py b/backend/chainlit/_utils.py new file mode 100644 index 0000000000..7a373017a3 --- /dev/null +++ b/backend/chainlit/_utils.py @@ -0,0 +1,8 @@ +"""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 diff --git a/backend/chainlit/config.py b/backend/chainlit/config.py index a558269206..7cf3aea131 100644 --- a/backend/chainlit/config.py +++ b/backend/chainlit/config.py @@ -24,6 +24,8 @@ from pydantic.dataclasses import Field, dataclass from starlette.datastructures import Headers +from ._utils import is_path_inside + if TYPE_CHECKING: from chainlit.action import Action from chainlit.element import ElementBased @@ -343,33 +345,41 @@ def load_translation(self, language: str): # fallback to root language (ex: `de` when `de-DE` is not found) parent_language = language.split("-")[0] - translation_lib_file_path = os.path.join( - config_translation_dir, f"{language}.json" - ) - translation_lib_parent_language_file_path = os.path.join( - config_translation_dir, f"{parent_language}.json" - ) - default_translation_lib_file_path = os.path.join( - config_translation_dir, f"{default_language}.json" - ) + translation_dir = Path(config_translation_dir) - if os.path.exists(translation_lib_file_path): - with open(translation_lib_file_path, "r", encoding="utf-8") as f: - translation = json.load(f) - elif os.path.exists(translation_lib_parent_language_file_path): + 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}." ) - with open( - translation_lib_parent_language_file_path, "r", encoding="utf-8" - ) as f: - translation = json.load(f) - elif os.path.exists(default_translation_lib_file_path): + 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}." ) - with open(default_translation_lib_file_path, "r", encoding="utf-8") as f: - translation = json.load(f) + translation = json.loads( + default_translation_lib_file_path.read_text(encoding="utf-8") + ) return translation diff --git a/backend/chainlit/markdown.py b/backend/chainlit/markdown.py index 224a1373fd..42642c0b53 100644 --- a/backend/chainlit/markdown.py +++ b/backend/chainlit/markdown.py @@ -1,7 +1,11 @@ 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! 🚀🤖 @@ -30,12 +34,16 @@ def init_markdown(root: str): logger.info(f"Created default chainlit markdown file at {chainlit_md_file}") -def get_markdown_str(root: str, language: str): +def get_markdown_str(root: str, language: str) -> Optional[str]: """Get the chainlit.md file as a string.""" - translated_chainlit_md_path = os.path.join(root, f"chainlit_{language}.md") - default_chainlit_md_path = os.path.join(root, "chainlit.md") - - if os.path.exists(translated_chainlit_md_path): + 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 @@ -43,9 +51,7 @@ def get_markdown_str(root: str, language: str): f"Translated markdown file for {language} not found. Defaulting to chainlit.md." ) - if os.path.exists(chainlit_md_path): - with open(chainlit_md_path, "r", encoding="utf-8") as f: - chainlit_md = f.read() - return chainlit_md + if chainlit_md_path.is_file(): + return chainlit_md_path.read_text(encoding="utf-8") else: return None diff --git a/backend/chainlit/server.py b/backend/chainlit/server.py index e28414d576..ab3e6a7e45 100644 --- a/backend/chainlit/server.py +++ b/backend/chainlit/server.py @@ -1,22 +1,15 @@ +import asyncio import glob import json import mimetypes +import os import re import shutil import urllib.parse -from typing import Any, Optional, Union - -from chainlit.oauth_providers import get_oauth_provider -from chainlit.secret import random_secret - -mimetypes.add_type("application/javascript", ".js") -mimetypes.add_type("text/css", ".css") - -import asyncio -import os import webbrowser from contextlib import asynccontextmanager from pathlib import Path +from typing import Any, Optional, Union import socketio from chainlit.auth import create_jwt, get_configuration, get_current_user @@ -34,6 +27,8 @@ 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 ( DeleteFeedbackRequest, DeleteThreadRequest, @@ -62,12 +57,18 @@ from typing_extensions import Annotated from watchfiles import awatch +from ._utils import is_path_inside + +mimetypes.add_type("application/javascript", ".js") +mimetypes.add_type("text/css", ".css") + ROOT_PATH = os.environ.get("CHAINLIT_ROOT_PATH", "") IS_SUBMOUNT = os.environ.get("CHAINLIT_SUBMOUNT", "") == "true" @asynccontextmanager async def lifespan(app: FastAPI): + """Context manager to handle app start and shutdown.""" host = config.run.host port = config.run.port @@ -150,7 +151,18 @@ async def watch_files_for_changes(): os._exit(0) -def get_build_dir(local_target: str, packaged_target: str): +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") @@ -171,9 +183,7 @@ def get_build_dir(local_target: str, packaged_target: str): app = FastAPI(lifespan=lifespan) -sio = socketio.AsyncServer( - cors_allowed_origins=[], async_mode="asgi" -) +sio = socketio.AsyncServer(cors_allowed_origins=[], async_mode="asgi") sio_mount_location = f"{ROOT_PATH}/ws" if ROOT_PATH else "ws" @@ -253,12 +263,19 @@ async def teams_endpoint(req: Request): # ------------------------------------------------------------------------------- -def replace_between_tags(text: str, start_tag: str, end_tag: str, replacement: str): +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(): + """ + Get HTML template for the index view. + """ PLACEHOLDER = "" JS_PLACEHOLDER = "" CSS_PLACEHOLDER = "" @@ -345,6 +362,9 @@ async def auth(request: Request): @router.post("/login") async def login(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" @@ -374,6 +394,7 @@ async def login(form_data: OAuth2PasswordRequestForm = Depends()): @router.post("/logout") async def logout(request: Request, response: Response): + """Logout the user by calling the on_logout callback.""" if config.code.on_logout: return await config.code.on_logout(request, response) return {"success": True} @@ -381,6 +402,7 @@ async def logout(request: Request, response: Response): @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, @@ -410,6 +432,7 @@ async def header_auth(request: Request): @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, @@ -436,7 +459,7 @@ async def oauth_login(provider_id: str, request: Request): response = RedirectResponse( url=f"{provider.authorize_url}?{params}", ) - samesite = os.environ.get("CHAINLIT_COOKIE_SAMESITE", "lax") # type: Any + samesite: Any = os.environ.get("CHAINLIT_COOKIE_SAMESITE", "lax") secure = samesite.lower() == "none" response.set_cookie( "oauth_state", @@ -457,6 +480,8 @@ async def oauth_callback( 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, @@ -544,6 +569,8 @@ async def oauth_azure_hf_callback( 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( @@ -617,9 +644,16 @@ async def oauth_azure_hf_callback( return response +_language_pattern = ( + "^[a-zA-Z]{2,3}(-[a-zA-Z]{2,3})?(-[a-zA-Z]{2,8})?(-x-[a-zA-Z0-9]{1,8})?$" +) + + @router.get("/project/translations") async def project_translations( - language: str = Query(default="en-US", description="Language code"), + language: str = Query( + default="en-US", description="Language code", pattern=_language_pattern + ), ): """Return project translations.""" @@ -636,11 +670,14 @@ async def project_translations( @router.get("/project/settings") async def project_settings( current_user: Annotated[Union[User, PersistedUser], Depends(get_current_user)], - language: str = Query(default="en-US", description="Language code"), + language: str = Query( + default="en-US", description="Language code", pattern=_language_pattern + ), ): """Return project settings. This is called by the UI before the establishing the websocket connection.""" # Load the markdown file based on the provided language + markdown = get_markdown_str(config.root, language) profiles = [] @@ -808,6 +845,8 @@ async def upload_file( Union[None, User, PersistedUser], Depends(get_current_user) ], ): + """Upload a file to the session files directory.""" + from chainlit.session import WebsocketSession session = WebsocketSession.get_by_id(session_id) @@ -841,6 +880,8 @@ async def get_file( file_id: str, session_id: Optional[str] = None, ): + """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 @@ -863,11 +904,12 @@ async def serve_file( filename: str, current_user: Annotated[Union[User, PersistedUser], Depends(get_current_user)], ): + """Serve a file from the local filesystem.""" + base_path = Path(config.project.local_fs_path).resolve() file_path = (base_path / filename).resolve() - # Check if the base path is a parent of the file path - if base_path not in file_path.parents: + if not is_path_inside(file_path, base_path): raise HTTPException(status_code=400, detail="Invalid filename") if file_path.is_file(): @@ -878,6 +920,7 @@ async def serve_file( @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) @@ -893,6 +936,7 @@ async def get_favicon(): @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 @@ -908,32 +952,42 @@ async def get_logo(theme: Optional[Theme] = Query(Theme.light)): if not logo_path: raise HTTPException(status_code=404, detail="Missing default logo") + media_type, _ = mimetypes.guess_type(logo_path) return FileResponse(logo_path, media_type=media_type) -@router.get("/avatars/{avatar_id}") +@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(" ", "_") - avatar_path = os.path.join(APP_ROOT, "public", "avatars", f"{avatar_id}.*") + base_path = Path(APP_ROOT) / "public" / "avatars" + avatar_pattern = f"{avatar_id}.*" - files = glob.glob(avatar_path) + 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)) - if files: - avatar_path = files[0] - media_type, _ = mimetypes.guess_type(avatar_path) return FileResponse(avatar_path, media_type=media_type) - else: - return await get_favicon() + + return await get_favicon() @router.head("/") def status_check(): + """Check if the site is operational.""" return {"message": "Site is operational"} diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1a0e900c2c..b517fdf609 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -3,6 +3,7 @@ import pytest import pytest_asyncio + from chainlit.context import ChainlitContext, context_var from chainlit.session import HTTPSession, WebsocketSession from chainlit.user_session import UserSession diff --git a/backend/tests/test_callbacks.py b/backend/tests/test_callbacks.py index 86178cc313..523e313d5f 100644 --- a/backend/tests/test_callbacks.py +++ b/backend/tests/test_callbacks.py @@ -1,11 +1,22 @@ from __future__ import annotations +import pytest + from chainlit.callbacks import password_auth_callback -from chainlit.config import config +from chainlit import config from chainlit.user import User -async def test_password_auth_callback(): +@pytest.fixture +def test_config(monkeypatch: pytest.MonkeyPatch): + test_config = config.load_config() + + monkeypatch.setattr("chainlit.callbacks.config", test_config) + + return test_config + + +async def test_password_auth_callback(test_config): @password_auth_callback async def auth_func(username: str, password: str) -> User | None: if username == "testuser" and password == "testpass": @@ -13,19 +24,19 @@ async def auth_func(username: str, password: str) -> User | None: return None # Test that the callback is properly registered - assert config.code.password_auth_callback is not None + assert test_config.code.password_auth_callback is not None # Test the wrapped function - result = await config.code.password_auth_callback("testuser", "testpass") + result = await test_config.code.password_auth_callback("testuser", "testpass") assert isinstance(result, User) assert result.identifier == "testuser" # Test with incorrect credentials - result = await config.code.password_auth_callback("wronguser", "wrongpass") + result = await test_config.code.password_auth_callback("wronguser", "wrongpass") assert result is None -async def test_header_auth_callback(): +async def test_header_auth_callback(test_config): from chainlit.callbacks import header_auth_callback from starlette.datastructures import Headers @@ -36,26 +47,26 @@ async def auth_func(headers: Headers) -> User | None: return None # Test that the callback is properly registered - assert config.code.header_auth_callback is not None + 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 config.code.header_auth_callback(valid_headers) + 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 config.code.header_auth_callback(invalid_headers) + result = await test_config.code.header_auth_callback(invalid_headers) assert result is None # Test with missing header missing_headers = Headers({}) - result = await config.code.header_auth_callback(missing_headers) + result = await test_config.code.header_auth_callback(missing_headers) assert result is None -async def test_oauth_callback(): +async def test_oauth_callback(test_config): from unittest.mock import patch from chainlit.callbacks import oauth_callback @@ -80,23 +91,23 @@ async def auth_func( return None # Test that the callback is properly registered - assert config.code.oauth_callback is not None + assert test_config.code.oauth_callback is not None # Test the wrapped function with valid data - result = await config.code.oauth_callback( + 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 config.code.oauth_callback( + 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): +async def test_on_message(mock_chainlit_context, test_config): from chainlit.callbacks import on_message from chainlit.config import config from chainlit.message import Message @@ -110,13 +121,13 @@ async def handle_message(message: Message): message_received = message # Test that the callback is properly registered - assert config.code.on_message is not None + 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 config.code.on_message(test_message) + await test_config.code.on_message(test_message) # Check that the message was received by our handler assert message_received is not None @@ -127,7 +138,7 @@ async def handle_message(message: Message): context.session.emit.assert_called() -async def test_on_stop(mock_chainlit_context): +async def test_on_stop(mock_chainlit_context, test_config): from chainlit.callbacks import on_stop from chainlit.config import config @@ -140,16 +151,16 @@ async def handle_stop(): stop_called = True # Test that the callback is properly registered - assert config.code.on_stop is not None + assert test_config.code.on_stop is not None # Call the registered callback - await config.code.on_stop() + 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): +async def test_action_callback(mock_chainlit_context, test_config): from chainlit.action import Action from chainlit.callbacks import action_callback from chainlit.config import config @@ -164,17 +175,17 @@ async def handle_action(action: Action): assert action.name == "test_action" # Test that the callback is properly registered - assert "test_action" in config.code.action_callbacks + assert "test_action" in test_config.code.action_callbacks # Call the registered callback test_action = Action(name="test_action", value="test_value") - await config.code.action_callbacks["test_action"](test_action) + 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): +async def test_on_settings_update(mock_chainlit_context, test_config): from chainlit.callbacks import on_settings_update from chainlit.config import config @@ -188,16 +199,16 @@ async def handle_settings_update(settings: dict): assert settings == {"test_setting": "test_value"} # Test that the callback is properly registered - assert config.code.on_settings_update is not None + assert test_config.code.on_settings_update is not None # Call the registered callback - await config.code.on_settings_update({"test_setting": "test_value"}) + 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(): +async def test_author_rename(test_config): from chainlit.callbacks import author_rename from chainlit.config import config @@ -208,27 +219,27 @@ async def rename_author(author: str) -> str: return author # Test that the callback is properly registered - assert config.code.author_rename is not None + assert test_config.code.author_rename is not None # Call the registered callback - result = await config.code.author_rename("AI") + result = await test_config.code.author_rename("AI") assert result == "Assistant" - result = await config.code.author_rename("Human") + result = await test_config.code.author_rename("Human") assert result == "Human" # Test that the callback is properly registered - assert config.code.author_rename is not None + assert test_config.code.author_rename is not None # Call the registered callback - result = await config.code.author_rename("AI") + result = await test_config.code.author_rename("AI") assert result == "Assistant" - result = await config.code.author_rename("Human") + result = await test_config.code.author_rename("Human") assert result == "Human" -async def test_on_chat_start(mock_chainlit_context): +async def test_on_chat_start(mock_chainlit_context, test_config): from chainlit.callbacks import on_chat_start from chainlit.config import config @@ -241,10 +252,10 @@ async def handle_chat_start(): chat_started = True # Test that the callback is properly registered - assert config.code.on_chat_start is not None + assert test_config.code.on_chat_start is not None # Call the registered callback - await config.code.on_chat_start() + await test_config.code.on_chat_start() # Check that the chat_started flag was set assert chat_started @@ -253,7 +264,7 @@ async def handle_chat_start(): context.session.emit.assert_called() -async def test_on_chat_resume(mock_chainlit_context): +async def test_on_chat_resume(mock_chainlit_context, test_config): from chainlit.callbacks import on_chat_resume from chainlit.config import config from chainlit.types import ThreadDict @@ -268,10 +279,10 @@ async def handle_chat_resume(thread: ThreadDict): assert thread["id"] == "test_thread_id" # Test that the callback is properly registered - assert config.code.on_chat_resume is not None + assert test_config.code.on_chat_resume is not None # Call the registered callback - await config.code.on_chat_resume( + await test_config.code.on_chat_resume( { "id": "test_thread_id", "createdAt": "2023-01-01T00:00:00Z", @@ -289,7 +300,7 @@ async def handle_chat_resume(thread: ThreadDict): assert chat_resumed -async def test_set_chat_profiles(mock_chainlit_context): +async def test_set_chat_profiles(mock_chainlit_context, test_config): from chainlit.callbacks import set_chat_profiles from chainlit.config import config from chainlit.types import ChatProfile @@ -303,10 +314,10 @@ async def get_chat_profiles(user): ] # Test that the callback is properly registered - assert config.code.set_chat_profiles is not None + assert test_config.code.set_chat_profiles is not None # Call the registered callback - result = await config.code.set_chat_profiles(None) + result = await test_config.code.set_chat_profiles(None) # Check the result assert result is not None @@ -317,7 +328,7 @@ async def get_chat_profiles(user): assert result[0].markdown_description == "A test profile" -async def test_set_starters(mock_chainlit_context): +async def test_set_starters(mock_chainlit_context, test_config): from chainlit.callbacks import set_starters from chainlit.config import config from chainlit.types import Starter @@ -334,10 +345,10 @@ async def get_starters(user): ] # Test that the callback is properly registered - assert config.code.set_starters is not None + assert test_config.code.set_starters is not None # Call the registered callback - result = await config.code.set_starters(None) + result = await test_config.code.set_starters(None) # Check the result assert result is not None @@ -348,7 +359,7 @@ async def get_starters(user): assert result[0].message == "Test Message" -async def test_on_chat_end(mock_chainlit_context): +async def test_on_chat_end(mock_chainlit_context, test_config): from chainlit.callbacks import on_chat_end from chainlit.config import config @@ -361,10 +372,10 @@ async def handle_chat_end(): chat_ended = True # Test that the callback is properly registered - assert config.code.on_chat_end is not None + assert test_config.code.on_chat_end is not None # Call the registered callback - await config.code.on_chat_end() + await test_config.code.on_chat_end() # Check that the chat_ended flag was set assert chat_ended diff --git a/backend/tests/test_server.py b/backend/tests/test_server.py new file mode 100644 index 0000000000..6e98bca782 --- /dev/null +++ b/backend/tests/test_server.py @@ -0,0 +1,230 @@ +import os +from pathlib import Path +from unittest.mock import Mock, create_autospec, mock_open + +import pytest +from chainlit.auth import get_current_user +from chainlit.config import APP_ROOT, ChainlitConfig, load_config +from chainlit.server import app +from fastapi.testclient import TestClient + + +@pytest.fixture +def test_client(): + return TestClient(app) + + +@pytest.fixture +def test_config(monkeypatch: pytest.MonkeyPatch, tmp_path: Path): + monkeypatch.setenv("CHAINLIT_ROOT_PATH", str(tmp_path)) + + config = load_config() + + monkeypatch.setattr("chainlit.server.config", config) + + return config + + +@pytest.fixture +def mock_load_translation(test_config: ChainlitConfig, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + test_config, "load_translation", Mock(return_value={"key": "value"}) + ) + + return test_config.load_translation + + +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 + + +@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_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 +): + """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 + + +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 diff --git a/cypress/e2e/readme/spec.cy.ts b/cypress/e2e/readme/spec.cy.ts index ad8c2fac6b..1dda4512a4 100644 --- a/cypress/e2e/readme/spec.cy.ts +++ b/cypress/e2e/readme/spec.cy.ts @@ -31,7 +31,7 @@ describe('readme_language', () => { cy.visit('/', { onBeforeLoad(win) { Object.defineProperty(win.navigator, 'language', { - value: 'Klingon' + value: 'tlh' }); } }); From 7de6081ef6c12fec709fb2a0f0cb585b6bbbdf15 Mon Sep 17 00:00:00 2001 From: Willy Douhard Date: Fri, 13 Sep 2024 13:46:39 +0200 Subject: [PATCH 37/45] Refine feedback UI and improve type handling (#1325) * Enhance type checking for output logging in callbacks * Display feedback buttons at end of run regardless of last step type * Prevent feedback UI when no data layer is configured * Update imports to include useConfig from chainlit/react-client --- backend/chainlit/langchain/callbacks.py | 5 +++-- .../src/components/molecules/messages/Message.tsx | 9 ++++++--- .../src/components/molecules/messages/Messages.tsx | 10 +++++++++- .../molecules/messages/components/FeedbackButtons.tsx | 11 ++++++++++- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/backend/chainlit/langchain/callbacks.py b/backend/chainlit/langchain/callbacks.py index 8243018e89..b7e29105cc 100644 --- a/backend/chainlit/langchain/callbacks.py +++ b/backend/chainlit/langchain/callbacks.py @@ -587,12 +587,13 @@ def _on_run_update(self, run: Run) -> None: outputs = run.outputs or {} output_keys = list(outputs.keys()) output = outputs + if output_keys: output = outputs.get(output_keys[0], outputs) - + if current_step: current_step.output = ( - output[0] if isinstance(output, Sequence) and len(output) else output + output[0] if isinstance(output, Sequence) and not isinstance(output, str) and len(output) else output ) current_step.end = utc_now() self._run_sync(current_step.update()) diff --git a/frontend/src/components/molecules/messages/Message.tsx b/frontend/src/components/molecules/messages/Message.tsx index f85d49cb95..ac20f42676 100644 --- a/frontend/src/components/molecules/messages/Message.tsx +++ b/frontend/src/components/molecules/messages/Message.tsx @@ -186,9 +186,12 @@ const Message = memo( {actions?.length ? ( ) : null} - {scorableRun && isScorable ? ( - - ) : null} + )} diff --git a/frontend/src/components/molecules/messages/Messages.tsx b/frontend/src/components/molecules/messages/Messages.tsx index b1923ed9c9..024874299d 100644 --- a/frontend/src/components/molecules/messages/Messages.tsx +++ b/frontend/src/components/molecules/messages/Messages.tsx @@ -79,11 +79,19 @@ const Messages = memo( // Score the current run const _scorableRun = m.type === 'run' ? m : scorableRun; // The message is scorable if it is the last assistant message of the run - const isScorable = + + const isRunLastAssistantMessage = m === _scorableRun?.steps?.findLast( (_m) => _m.type === 'assistant_message' ); + + const isLastAssistantMessage = + messages.findLast((_m) => _m.type === 'assistant_message') === m; + + const isScorable = + isRunLastAssistantMessage || isLastAssistantMessage; + return ( { + const config = useConfig(); const { onFeedbackUpdated, onFeedbackDeleted } = useContext(MessageContext); const [showFeedbackDialog, setShowFeedbackDialog] = useState(); const [commentInput, setCommentInput] = useState(); @@ -39,6 +44,10 @@ const FeedbackButtons = ({ message }: Props) => { const [feedback, setFeedback] = useState(message.feedback?.value); const [comment, setComment] = useState(message.feedback?.comment); + if (!config.config?.dataPersistence) { + return null; + } + const DownIcon = feedback === 0 ? ThumbDownFilledIcon : ThumbDownIcon; const UpIcon = feedback === 1 ? ThumbUpFilledIcon : ThumbUpIcon; From 5d2380afa16fe8ab7a1027f5f5f072b3eeeaaa33 Mon Sep 17 00:00:00 2001 From: duckboy81 Date: Mon, 16 Sep 2024 02:31:10 -0600 Subject: [PATCH 38/45] Minor spelling error fix (#1341) Corrected spelling error. `occured` to `occurred` --- frontend/src/components/atoms/elements/Text.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/atoms/elements/Text.tsx b/frontend/src/components/atoms/elements/Text.tsx index b4444d5899..e2d36775f2 100644 --- a/frontend/src/components/atoms/elements/Text.tsx +++ b/frontend/src/components/atoms/elements/Text.tsx @@ -21,7 +21,7 @@ const TextElement = ({ element }: Props) => { if (isLoading) { content = 'Loading...'; } else if (error) { - content = 'An error occured'; + content = 'An error occurred'; } else if (data) { content = data; } From 1bf45a69297340564fe9bad04bdfc75887f1e3bd Mon Sep 17 00:00:00 2001 From: San Nguyen <22189661+sandangel@users.noreply.github.com> Date: Mon, 16 Sep 2024 17:31:58 +0900 Subject: [PATCH 39/45] fix: negative feedback class incorrect (#1332) Signed-off-by: San Nguyen --- .../molecules/messages/components/FeedbackButtons.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/molecules/messages/components/FeedbackButtons.tsx b/frontend/src/components/molecules/messages/components/FeedbackButtons.tsx index f6d0dd374f..8d92e6f5fd 100644 --- a/frontend/src/components/molecules/messages/components/FeedbackButtons.tsx +++ b/frontend/src/components/molecules/messages/components/FeedbackButtons.tsx @@ -123,7 +123,7 @@ const FeedbackButtons = ({ message }: Props) => { { handleFeedbackClick(0); }} From e235a34a5598fbe40f7460b05eb7580369645b55 Mon Sep 17 00:00:00 2001 From: Willy Douhard Date: Mon, 16 Sep 2024 16:02:30 +0200 Subject: [PATCH 40/45] fix: websocket connection when submounting chainlit (#1337) * fix: websocket connection when submounting chainlit * fix: starting chainlit with --root-path --- backend/chainlit/config.py | 12 ++++++------ backend/chainlit/langchain/callbacks.py | 8 ++++++-- backend/chainlit/server.py | 16 ++++++++-------- backend/chainlit/utils.py | 2 +- backend/tests/conftest.py | 1 - backend/tests/test_callbacks.py | 4 ++-- 6 files changed, 23 insertions(+), 20 deletions(-) diff --git a/backend/chainlit/config.py b/backend/chainlit/config.py index 7cf3aea131..5700479677 100644 --- a/backend/chainlit/config.py +++ b/backend/chainlit/config.py @@ -285,9 +285,9 @@ class CodeSettings: password_auth_callback: Optional[ Callable[[str, str], Awaitable[Optional["User"]]] ] = None - header_auth_callback: Optional[Callable[[Headers], 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 @@ -305,9 +305,9 @@ class CodeSettings: set_chat_profiles: Optional[ Callable[[Optional["User"]], Awaitable[List["ChatProfile"]]] ] = None - set_starters: Optional[Callable[[Optional["User"]], Awaitable[List["Starter"]]]] = ( - None - ) + set_starters: Optional[ + Callable[[Optional["User"]], Awaitable[List["Starter"]]] + ] = None @dataclass() diff --git a/backend/chainlit/langchain/callbacks.py b/backend/chainlit/langchain/callbacks.py index b7e29105cc..59ad8b68ee 100644 --- a/backend/chainlit/langchain/callbacks.py +++ b/backend/chainlit/langchain/callbacks.py @@ -590,10 +590,14 @@ def _on_run_update(self, run: Run) -> None: if output_keys: output = outputs.get(output_keys[0], outputs) - + if current_step: current_step.output = ( - output[0] if isinstance(output, Sequence) and not isinstance(output, str) and len(output) else output + output[0] + if isinstance(output, Sequence) + and not isinstance(output, str) + and len(output) + else output ) current_step.end = utc_now() self._run_sync(current_step.update()) diff --git a/backend/chainlit/server.py b/backend/chainlit/server.py index ab3e6a7e45..597830ee43 100644 --- a/backend/chainlit/server.py +++ b/backend/chainlit/server.py @@ -64,6 +64,8 @@ ROOT_PATH = os.environ.get("CHAINLIT_ROOT_PATH", "") IS_SUBMOUNT = os.environ.get("CHAINLIT_SUBMOUNT", "") == "true" +# If the app is a submount, no need to set the prefix +PREFIX = ROOT_PATH if ROOT_PATH and not IS_SUBMOUNT else "" @asynccontextmanager @@ -185,14 +187,12 @@ def get_build_dir(local_target: str, packaged_target: str) -> str: sio = socketio.AsyncServer(cors_allowed_origins=[], async_mode="asgi") -sio_mount_location = f"{ROOT_PATH}/ws" if ROOT_PATH else "ws" - asgi_app = socketio.ASGIApp( socketio_server=sio, - socketio_path=f"{sio_mount_location}/socket.io", + socketio_path="", ) -app.mount(f"/{sio_mount_location}", asgi_app) +app.mount(f"{PREFIX}/ws/socket.io", asgi_app) app.add_middleware( CORSMiddleware, @@ -202,16 +202,16 @@ def get_build_dir(local_target: str, packaged_target: str) -> str: allow_headers=["*"], ) -router = APIRouter(prefix=ROOT_PATH) +router = APIRouter(prefix=PREFIX) app.mount( - f"{ROOT_PATH}/public", + f"{PREFIX}/public", StaticFiles(directory="public", check_dir=False), name="public", ) app.mount( - f"{ROOT_PATH}/assets", + f"{PREFIX}/assets", StaticFiles( packages=[("chainlit", os.path.join(build_dir, "assets"))], follow_symlink=config.project.follow_symlink, @@ -220,7 +220,7 @@ def get_build_dir(local_target: str, packaged_target: str) -> str: ) app.mount( - f"{ROOT_PATH}/copilot", + f"{PREFIX}/copilot", StaticFiles( packages=[("chainlit", copilot_build_dir)], follow_symlink=config.project.follow_symlink, diff --git a/backend/chainlit/utils.py b/backend/chainlit/utils.py index 4fad5878e9..79049c4308 100644 --- a/backend/chainlit/utils.py +++ b/backend/chainlit/utils.py @@ -130,4 +130,4 @@ def mount_chainlit(app: FastAPI, target: str, path="/chainlit"): ensure_jwt_secret() - app.mount("/", chainlit_app) + app.mount(path, chainlit_app) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index b517fdf609..1a0e900c2c 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -3,7 +3,6 @@ import pytest import pytest_asyncio - from chainlit.context import ChainlitContext, context_var from chainlit.session import HTTPSession, WebsocketSession from chainlit.user_session import UserSession diff --git a/backend/tests/test_callbacks.py b/backend/tests/test_callbacks.py index 523e313d5f..982cd4ba6b 100644 --- a/backend/tests/test_callbacks.py +++ b/backend/tests/test_callbacks.py @@ -1,11 +1,11 @@ from __future__ import annotations import pytest - from chainlit.callbacks import password_auth_callback -from chainlit import config from chainlit.user import User +from chainlit import config + @pytest.fixture def test_config(monkeypatch: pytest.MonkeyPatch): From 052e8d813504a197bc2e3a0426d64535e47675e1 Mon Sep 17 00:00:00 2001 From: Mathijs de Bruin Date: Mon, 16 Sep 2024 17:18:28 +0100 Subject: [PATCH 41/45] Release prep 1.2.0 (#1344) * Changelog for 1.2.0 * Amend release engineering instructions. * Bump package version number. --- CHANGELOG.md | 39 +++++++++++++++++++++++++++++++++++++++ RELENG.md | 22 +++++++++++++--------- backend/pyproject.toml | 2 +- 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bac8bb41b3..55633368ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ 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/). +## [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 diff --git a/RELENG.md b/RELENG.md index d2b6bdb7b8..2e45bc1d81 100644 --- a/RELENG.md +++ b/RELENG.md @@ -8,19 +8,23 @@ This document outlines the steps for maintainers to create a new release of the ## Steps -1. **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. - -2. **Determine the new version number**: +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. -3. **Create a new release**: +2. **Bump the package version**: + + - Update `version` in `[tool.poetry]` of `backend/pyproject.toml`. + +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). @@ -29,7 +33,7 @@ This document outlines the steps for maintainers to create a new release of the - If this is a full release after an RC, remove any "-rc" suffix from the version number. - Publish the release. -4. **Update any associated documentation and examples**: +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. diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 7cb2086180..90ec5dc0ed 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chainlit" -version = "1.1.404" +version = "1.2.0" keywords = [ 'LLM', 'Agents', From bfc6ae973cafc859083f78a24dcc1e1b24d5007f Mon Sep 17 00:00:00 2001 From: EWouters <6179932+EWouters@users.noreply.github.com> Date: Tue, 17 Sep 2024 10:15:02 +0200 Subject: [PATCH 42/45] Small text fixes (#1347) --- README.md | 2 +- backend/chainlit/cli/__init__.py | 2 +- backend/chainlit/data/dynamodb.py | 6 +++--- backend/chainlit/element.py | 2 +- backend/chainlit/session.py | 6 +++--- backend/chainlit/socket.py | 2 +- frontend/src/components/atoms/elements/Plotly.tsx | 2 +- 7 files changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index acf0a0a27f..4c271bce7e 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ $ chainlit run demo.py -w Full documentation is available [here](https://docs.chainlit.io). Key features: - [💬 Multi Modal chats](https://docs.chainlit.io/advanced-features/multi-modal) -- [💭 Chain of Thought visualisation](https://docs.chainlit.io/concepts/step) +- [💭 Chain of Thought visualization](https://docs.chainlit.io/concepts/step) - [💾 Data persistence + human feedback](https://docs.chainlit.io/data-persistence/overview) - [🐛 Debug Mode](https://docs.chainlit.io/data-persistence/enterprise#debug-mode) - [👤 Authentication](https://docs.chainlit.io/authentication/overview) diff --git a/backend/chainlit/cli/__init__.py b/backend/chainlit/cli/__init__.py index 0985622a29..234b7befb6 100644 --- a/backend/chainlit/cli/__init__.py +++ b/backend/chainlit/cli/__init__.py @@ -183,7 +183,7 @@ def chainlit_run( 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" - # This is required for authenticationt tests + # This is required for authentication tests os.environ["CHAINLIT_AUTH_SECRET"] = "SUPER_SECRET" else: trace_event("chainlit run") diff --git a/backend/chainlit/data/dynamodb.py b/backend/chainlit/data/dynamodb.py index ec0f1418fa..dbca89cff5 100644 --- a/backend/chainlit/data/dynamodb.py +++ b/backend/chainlit/data/dynamodb.py @@ -173,11 +173,11 @@ async def upsert_feedback(self, feedback: Feedback) -> str: if not feedback.forId: raise ValueError( - "DynamoDB datalayer expects value for feedback.threadId got None" + "DynamoDB data layer expects value for feedback.threadId got None" ) feedback.id = f"THREAD#{feedback.threadId}::STEP#{feedback.forId}" - searialized_feedback = self._type_serializer.serialize(asdict(feedback)) + serialized_feedback = self._type_serializer.serialize(asdict(feedback)) self.client.update_item( TableName=self.table_name, @@ -187,7 +187,7 @@ async def upsert_feedback(self, feedback: Feedback) -> str: }, UpdateExpression="SET #feedback = :feedback", ExpressionAttributeNames={"#feedback": "feedback"}, - ExpressionAttributeValues={":feedback": searialized_feedback}, + ExpressionAttributeValues={":feedback": serialized_feedback}, ) return feedback.id diff --git a/backend/chainlit/element.py b/backend/chainlit/element.py index 3ecf8bb932..3b58b38714 100644 --- a/backend/chainlit/element.py +++ b/backend/chainlit/element.py @@ -65,7 +65,7 @@ class Element: 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 somehwere else. + # The URL of the element if already hosted somewhere else. url: Optional[str] = None # The S3 object key. object_key: Optional[str] = None diff --git a/backend/chainlit/session.py b/backend/chainlit/session.py index f73a635558..038b1bf4b9 100644 --- a/backend/chainlit/session.py +++ b/backend/chainlit/session.py @@ -64,7 +64,7 @@ def __init__( client_type: ClientType, # Thread id thread_id: Optional[str], - # Logged-in user informations + # Logged-in user information user: Optional[Union["User", "PersistedUser"]], # Logged-in user token token: Optional[str], @@ -169,7 +169,7 @@ def __init__( client_type: ClientType, # Thread id thread_id: Optional[str] = None, - # Logged-in user informations + # Logged-in user information user: Optional[Union["User", "PersistedUser"]] = None, # Logged-in user token token: Optional[str] = None, @@ -225,7 +225,7 @@ def __init__( client_type: ClientType, # Thread id thread_id: Optional[str] = None, - # Logged-in user informations + # Logged-in user information user: Optional[Union["User", "PersistedUser"]] = None, # Logged-in user token token: Optional[str] = None, diff --git a/backend/chainlit/socket.py b/backend/chainlit/socket.py index f2979e9e21..0fa936c042 100644 --- a/backend/chainlit/socket.py +++ b/backend/chainlit/socket.py @@ -392,7 +392,7 @@ async def call_action(sid, action): except Exception as e: logger.exception(e) await context.emitter.send_action_response( - id=action.id, status=False, response="An error occured" + id=action.id, status=False, response="An error occurred" ) diff --git a/frontend/src/components/atoms/elements/Plotly.tsx b/frontend/src/components/atoms/elements/Plotly.tsx index 8a206a1c62..e0a1054110 100644 --- a/frontend/src/components/atoms/elements/Plotly.tsx +++ b/frontend/src/components/atoms/elements/Plotly.tsx @@ -18,7 +18,7 @@ const _PlotlyElement = ({ element }: Props) => { if (isLoading) { return
Loading...
; } else if (error) { - return
An error occured
; + return
An error occurred
; } let state; From 41cf43d3a636eca791ee1e62078ee4e759ae995e Mon Sep 17 00:00:00 2001 From: EWouters <6179932+EWouters@users.noreply.github.com> Date: Tue, 17 Sep 2024 10:19:02 +0200 Subject: [PATCH 43/45] Markdownlint fixes (#1348) --- .github/CONTRIBUTING.md | 10 +++++----- README.md | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index b4ebbb953a..75fbdb08b7 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -6,7 +6,7 @@ To contribute to Chainlit, you first need to setup the project on your local mac - [Contribute to Chainlit](#contribute-to-chainlit) @@ -37,11 +37,11 @@ I've copy/pasted the whole document there, without the previous two headings. 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 to fork the chainlit code into your own 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 +git clone https://github.com/YOUR_USERNAME/YOUR_FORK.git ``` 3. Go into your fork and list the current configured remote repository. @@ -55,7 +55,7 @@ $ git remote -v 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 +git remote add upstream https://github.com/Chainlit/chainlit.git ``` 5. Verify the new upstream repository you've specified for your fork. @@ -103,7 +103,7 @@ If you've made it this far, you can now replace `chainlit/hello.py` by your own ## 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. +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. diff --git a/README.md b/README.md index 4c271bce7e..1aa56515e0 100644 --- a/README.md +++ b/README.md @@ -23,16 +23,16 @@ Full documentation is available [here](https://docs.chainlit.io). You can ask Ch > Check out [Literal AI](https://literalai.com), our product to monitor and evaluate LLM applications! It works with any Python or TypeScript applications and [seamlessly](https://docs.chainlit.io/data-persistence/overview) with Chainlit by adding a `LITERAL_API_KEY` in your project.

- + Chainlit user interface

## Installation Open a terminal and run: -```bash -$ pip install chainlit -$ chainlit hello +```sh +pip install chainlit +chainlit hello ``` If this opens the `hello app` in your browser, you're all set! @@ -77,8 +77,8 @@ async def main(message: cl.Message): Now run it! -``` -$ chainlit run demo.py -w +```sh +chainlit run demo.py -w ``` Quick Start From b86fa055655659446ccb79a2f50cf2db649a1ade Mon Sep 17 00:00:00 2001 From: EWouters <6179932+EWouters@users.noreply.github.com> Date: Wed, 18 Sep 2024 12:52:09 +0200 Subject: [PATCH 44/45] YAML fixes, restrict GH Actions perms (#1349) * Yaml: Remove redundant quotes * GitHub Actions: Set top-level permissions to read-all --- .github/actions/pnpm-node-install/action.yaml | 14 +++++++------- .../actions/poetry-python-install/action.yaml | 16 ++++++++-------- .github/workflows/ci.yaml | 4 +++- .github/workflows/e2e-tests.yaml | 2 ++ .github/workflows/mypy.yaml | 2 ++ .github/workflows/publish.yaml | 2 ++ .github/workflows/pytest.yaml | 2 ++ pnpm-workspace.yaml | 8 ++++---- 8 files changed, 30 insertions(+), 20 deletions(-) diff --git a/.github/actions/pnpm-node-install/action.yaml b/.github/actions/pnpm-node-install/action.yaml index 0e21dbe864..d3bd27338d 100644 --- a/.github/actions/pnpm-node-install/action.yaml +++ b/.github/actions/pnpm-node-install/action.yaml @@ -1,18 +1,18 @@ -name: 'Install Node, pnpm and dependencies.' -description: 'Install Node, pnpm and dependencies using cache.' +name: Install Node, pnpm and dependencies. +description: Install Node, pnpm and dependencies using cache. inputs: node-version: - description: 'Node.js version' + description: Node.js version required: true pnpm-version: - description: 'pnpm version' + description: pnpm version required: true pnpm-install-args: - description: 'Extra arguments for pnpm install, e.g. --no-frozen-lockfile.' + description: Extra arguments for pnpm install, e.g. --no-frozen-lockfile. runs: - using: 'composite' + using: composite steps: - uses: pnpm/action-setup@v4 with: @@ -21,7 +21,7 @@ runs: uses: actions/setup-node@v4 with: node-version: ${{ inputs.node-version }} - cache: 'pnpm' + cache: pnpm - name: Install JS dependencies run: pnpm install ${{ inputs.pnpm-install-args }} shell: bash diff --git a/.github/actions/poetry-python-install/action.yaml b/.github/actions/poetry-python-install/action.yaml index fe26355f64..f423e8be1f 100644 --- a/.github/actions/poetry-python-install/action.yaml +++ b/.github/actions/poetry-python-install/action.yaml @@ -1,23 +1,23 @@ -name: 'Install Python, poetry and dependencies.' -description: 'Install Python, Poetry and poetry dependencies using cache' +name: Install Python, poetry and dependencies. +description: Install Python, Poetry and poetry dependencies using cache inputs: python-version: - description: 'Python version' + description: Python version required: true poetry-version: - description: 'Poetry version' + description: Poetry version required: true poetry-working-directory: - description: 'Working directory for poetry command.' + description: Working directory for poetry command. required: false - default: '.' + default: . poetry-install-args: - description: 'Extra arguments for poetry install, e.g. --with tests.' + description: Extra arguments for poetry install, e.g. --with tests. required: false runs: - using: 'composite' + using: composite steps: - name: Cache poetry install uses: actions/cache@v4 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 381aa47d55..6304522dd6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,6 +8,8 @@ on: push: branches: [main, dev] +permissions: read-all + jobs: pytest: uses: ./.github/workflows/pytest.yaml @@ -23,5 +25,5 @@ jobs: name: Run CI needs: [mypy, pytest, e2e-tests] steps: - - name: 'Done' + - name: Done run: echo "Done" diff --git a/.github/workflows/e2e-tests.yaml b/.github/workflows/e2e-tests.yaml index cf745c10e1..36f6ab6303 100644 --- a/.github/workflows/e2e-tests.yaml +++ b/.github/workflows/e2e-tests.yaml @@ -2,6 +2,8 @@ name: E2ETests on: [workflow_call] +permissions: read-all + jobs: ci: runs-on: ${{ matrix.os }} diff --git a/.github/workflows/mypy.yaml b/.github/workflows/mypy.yaml index a33b2f4bd8..48ba387784 100644 --- a/.github/workflows/mypy.yaml +++ b/.github/workflows/mypy.yaml @@ -2,6 +2,8 @@ name: Mypy on: [workflow_call] +permissions: read-all + jobs: mypy: runs-on: ubuntu-latest diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 4a0c44081f..8dc7708b4a 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -5,6 +5,8 @@ on: release: types: [published] +permissions: read-all + jobs: ci: uses: ./.github/workflows/ci.yaml diff --git a/.github/workflows/pytest.yaml b/.github/workflows/pytest.yaml index 7a335d99d6..71c72a07f7 100644 --- a/.github/workflows/pytest.yaml +++ b/.github/workflows/pytest.yaml @@ -2,6 +2,8 @@ name: Pytest on: [workflow_call] +permissions: read-all + jobs: pytest: runs-on: ubuntu-latest diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 4328b76f3a..1e796f8066 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,5 +1,5 @@ packages: - - 'frontend/' - - 'libs/react-components/' - - 'libs/react-client/' - - 'libs/copilot/' + - frontend/ + - libs/react-components/ + - libs/react-client/ + - libs/copilot/ From 2bdd5410822ffc1377718eb37b7953815b2df4a5 Mon Sep 17 00:00:00 2001 From: Josh Hayes <35790761+hayescode@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:50:24 -0500 Subject: [PATCH 45/45] Add get_element() and test infra for sql_alchemy.py (#1346) * Add get_element() to sql_alchemy.py * Add test for create_element and get_element in SQLAlchemyDataLayer. * Add aiosqlite test dep. * Add missing attribute to mocked WebsocketSession object * Add mocked user to ChainlitContext in test --------- Co-authored-by: Mathijs de Bruin (aider) --- backend/chainlit/data/sql_alchemy.py | 28 +++++ backend/poetry.lock | 20 +++- backend/pyproject.toml | 2 + backend/tests/conftest.py | 6 +- backend/tests/data/__init__.py | 0 backend/tests/data/conftest.py | 15 +++ backend/tests/data/test_sql_alchemy.py | 138 +++++++++++++++++++++++++ 7 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 backend/tests/data/__init__.py create mode 100644 backend/tests/data/conftest.py create mode 100644 backend/tests/data/test_sql_alchemy.py diff --git a/backend/chainlit/data/sql_alchemy.py b/backend/chainlit/data/sql_alchemy.py index 98068baa20..9a4f65b411 100644 --- a/backend/chainlit/data/sql_alchemy.py +++ b/backend/chainlit/data/sql_alchemy.py @@ -373,6 +373,34 @@ async def delete_feedback(self, feedback_id: str) -> bool: 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"], + 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: diff --git a/backend/poetry.lock b/backend/poetry.lock index aefff1d7e1..eec42e14f5 100644 --- a/backend/poetry.lock +++ b/backend/poetry.lock @@ -148,6 +148,24 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "aiosqlite" +version = "0.20.0" +description = "asyncio bridge to the standard sqlite3 module" +optional = false +python-versions = ">=3.8" +files = [ + {file = "aiosqlite-0.20.0-py3-none-any.whl", hash = "sha256:36a1deaca0cac40ebe32aac9977a6e2bbc7f5189f23f4a54d5908986729e5bd6"}, + {file = "aiosqlite-0.20.0.tar.gz", hash = "sha256:6d35c8c256637f4672f843c31021464090805bf925385ac39473fb16eaaca3d7"}, +] + +[package.dependencies] +typing_extensions = ">=4.0" + +[package.extras] +dev = ["attribution (==1.7.0)", "black (==24.2.0)", "coverage[toml] (==7.4.1)", "flake8 (==7.0.0)", "flake8-bugbear (==24.2.6)", "flit (==3.9.0)", "mypy (==1.8.0)", "ufmt (==2.3.0)", "usort (==1.0.8.post1)"] +docs = ["sphinx (==7.2.6)", "sphinx-mdinclude (==0.5.3)"] + [[package]] name = "anyio" version = "4.4.0" @@ -5413,4 +5431,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0.0" -content-hash = "0c20ee17e5e449204c15b44de7ba5597863a97cc2195d77365d42091b09af2ff" +content-hash = "abd6fcef6a72a72b26f8c5842a877c57fd32414a729f8c6b1ec37a488b217b09" diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 90ec5dc0ed..7b02912a16 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -68,6 +68,7 @@ plotly = "^5.18.0" slack_bolt = "^1.18.1" discord = "^2.3.2" botbuilder-core = "^4.15.0" +aiosqlite = "^0.20.0" [tool.poetry.group.dev.dependencies] black = "^24.8.0" @@ -106,6 +107,7 @@ ignore_missing_imports = true + [tool.poetry.group.custom-data] optional = true diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 1a0e900c2c..8d40de5b0f 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -5,6 +5,7 @@ import pytest_asyncio from chainlit.context import ChainlitContext, context_var from chainlit.session import HTTPSession, WebsocketSession +from chainlit.user import PersistedUser from chainlit.user_session import UserSession @@ -14,13 +15,16 @@ async def create_chainlit_context(): mock_session.id = "test_session_id" mock_session.user_env = {"test_env": "value"} mock_session.chat_settings = {} - mock_session.user = None + mock_user = Mock(spec=PersistedUser) + mock_user.id = "test_user_id" + mock_session.user = mock_user mock_session.chat_profile = None mock_session.http_referer = None mock_session.client_type = "webapp" mock_session.languages = ["en"] mock_session.thread_id = "test_thread_id" mock_session.emit = AsyncMock() + mock_session.has_first_interaction = True context = ChainlitContext(mock_session) token = context_var.set(context) diff --git a/backend/tests/data/__init__.py b/backend/tests/data/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/data/conftest.py b/backend/tests/data/conftest.py new file mode 100644 index 0000000000..0e03190426 --- /dev/null +++ b/backend/tests/data/conftest.py @@ -0,0 +1,15 @@ +import pytest + +from unittest.mock import AsyncMock + +from chainlit.data.base import BaseStorageClient + + +@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 diff --git a/backend/tests/data/test_sql_alchemy.py b/backend/tests/data/test_sql_alchemy.py new file mode 100644 index 0000000000..1cff9dd6ae --- /dev/null +++ b/backend/tests/data/test_sql_alchemy.py @@ -0,0 +1,138 @@ +import uuid +from pathlib import Path + +import pytest + +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy import text + +from chainlit.data.base import BaseStorageClient +from chainlit.data.sql_alchemy import SQLAlchemyDataLayer +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) + + yield data_layer + + +@pytest.mark.asyncio +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", + ) + + 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