diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d9ac1ac --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: ci +env: + OPENAI_API_KEY: "dummy" +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + check-pyright: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install UV + uses: astral-sh/setup-uv@v6 + with: + python-version: "3.10" + enable-cache: true + activate-environment: true + - name: Install Reflex + run: uv pip install -r requirements.txt pyright + - name: Run Pyright + run: pyright . + + check-ruff: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install UV + uses: astral-sh/setup-uv@v6 + with: + python-version: "3.10" + enable-cache: true + activate-environment: true + - name: Install Reflex + run: uv pip install -r requirements.txt + - name: Run Ruff + uses: astral-sh/ruff-action@v3 + + check-export: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Install UV + uses: astral-sh/setup-uv@v6 + with: + python-version: "3.10" + enable-cache: true + activate-environment: true + - name: Install Reflex + run: uv pip install -r requirements.txt + + - name: Initialize Reflex + run: reflex init + + - name: Build frontend + run: reflex export diff --git a/.github/workflows/repository_dispatch.yml b/.github/workflows/repository_dispatch.yml deleted file mode 100644 index 58f531d..0000000 --- a/.github/workflows/repository_dispatch.yml +++ /dev/null @@ -1,16 +0,0 @@ -name: reflex-chat-repository-dispatch -on: - push: - branches: ['main'] -jobs: - test: - name: reflex-chat-repository-dispatch - runs-on: ubuntu-latest - steps: - - name: Dispatch - uses: peter-evans/repository-dispatch@v3 - with: - token: ${{ secrets.HOSTING_REPOSITORY_DISPATCH }} - repository: reflex-dev/reflex-hosting - event-type: push - client-payload: '{"repo": "${{ github.repository }}", "sha": "${{ github.sha }}", "deployment-key": "chat"}' diff --git a/.gitignore b/.gitignore index cc2e773..3d7fa7e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.states assets/external/ **/*.db **/*.ipynb @@ -19,3 +20,4 @@ dist/* frontend.zip poetry.lock venv/ +*.env \ No newline at end of file diff --git a/README.md b/README.md index c368efe..4f77749 100644 --- a/README.md +++ b/README.md @@ -24,8 +24,7 @@ git clone https://github.com/reflex-dev/reflex-chat.git To get started with Reflex, you'll need: -- Python 3.7+ -- Node.js 12.22.0+ \(No JavaScript knowledge required!\) +- Python 3.10+ - Pip dependencies: `reflex`, `openai` Install `pip` dependencies with the provided `requirements.txt`: @@ -48,15 +47,16 @@ reflex run - 100% Python-based, including the UI, using Reflex - Create and delete chat sessions - The application is fully customizable and no knowledge of web dev is required to use it. - - See https://reflex.dev/docs/styling/overview for more details + - See https://reflex.dev/docs/styling/overview for more details - Easily swap out any LLM - Responsive design for various devices # Contributing -We welcome contributions to improve and extend the LLM Web UI. +We welcome contributions to improve and extend the LLM Web UI. If you'd like to contribute, please do the following: -- Fork the repository and make your changes. + +- Fork the repository and make your changes. - Once you're ready, submit a pull request for review. # License diff --git a/chat/chat.py b/chat/chat.py index 43d7711..d7214c3 100644 --- a/chat/chat.py +++ b/chat/chat.py @@ -1,20 +1,19 @@ """The main Chat app.""" import reflex as rx -import reflex_chakra as rc from chat.components import chat, navbar def index() -> rx.Component: """The main app.""" - return rc.vstack( + return rx.vstack( navbar(), chat.chat(), chat.action_bar(), background_color=rx.color("mauve", 1), color=rx.color("mauve", 12), - min_height="100vh", + height="100dvh", align_items="stretch", spacing="0", ) @@ -24,7 +23,7 @@ def index() -> rx.Component: app = rx.App( theme=rx.theme( appearance="dark", - accent_color="violet", + accent_color="purple", ), ) app.add_page(index) diff --git a/chat/components/__init__.py b/chat/components/__init__.py index b153f28..0c74373 100644 --- a/chat/components/__init__.py +++ b/chat/components/__init__.py @@ -1,2 +1 @@ -from .loading_icon import loading_icon -from .navbar import navbar +from .navbar import navbar as navbar diff --git a/chat/components/chat.py b/chat/components/chat.py index df1faf9..f3367cb 100644 --- a/chat/components/chat.py +++ b/chat/components/chat.py @@ -1,11 +1,27 @@ import reflex as rx -import reflex_chakra as rc -from chat.components import loading_icon from chat.state import QA, State +from reflex.constants.colors import ColorType -message_style = dict(display="inline-block", padding="1em", border_radius="8px", max_width=["30em", "30em", "50em", "50em", "50em", "50em"]) +def message_content(text: str, color: ColorType) -> rx.Component: + """Create a message content component. + + Args: + text: The text to display. + color: The color of the message. + + Returns: + A component displaying the message. + """ + return rx.markdown( + text, + background_color=rx.color(color, 4), + color=rx.color(color, 12), + display="inline-block", + padding_inline="1em", + border_radius="8px", + ) def message(qa: QA) -> rx.Component: @@ -19,41 +35,29 @@ def message(qa: QA) -> rx.Component: """ return rx.box( rx.box( - rx.markdown( - qa.question, - background_color=rx.color("mauve", 4), - color=rx.color("mauve", 12), - **message_style, - ), + message_content(qa["question"], "mauve"), text_align="right", - margin_top="1em", + margin_bottom="8px", ), rx.box( - rx.markdown( - qa.answer, - background_color=rx.color("accent", 4), - color=rx.color("accent", 12), - **message_style, - ), + message_content(qa["answer"], "accent"), text_align="left", - padding_top="1em", + margin_bottom="8px", ), - width="100%", + max_width="50em", + margin_inline="auto", ) def chat() -> rx.Component: """List all the messages in a single conversation.""" - return rx.vstack( - rx.box(rx.foreach(State.chats[State.current_chat], message), width="100%"), - py="8", + return rx.auto_scroll( + rx.foreach( + State.chats[State.current_chat], + message, + ), flex="1", - width="100%", - max_width="50em", - padding_x="4px", - align_self="center", - overflow="hidden", - padding_bottom="5em", + padding="8px", ) @@ -61,34 +65,31 @@ def action_bar() -> rx.Component: """The action bar to send a new message.""" return rx.center( rx.vstack( - rc.form( - rc.form_control( - rx.hstack( - rx.input( - rx.input.slot( - rx.tooltip( - rx.icon("info", size=18), - content="Enter a question to get a response.", - ) - ), - placeholder="Type something...", - id="question", - width=["15em", "20em", "45em", "50em", "50em", "50em"], - ), - rx.button( - rx.cond( - State.processing, - loading_icon(height="1em"), - rx.text("Send"), - ), - type="submit", + rx.form( + rx.hstack( + rx.input( + rx.input.slot( + rx.tooltip( + rx.icon("info", size=18), + content="Enter a question to get a response.", + ) ), - align_items="center", + placeholder="Type something...", + id="question", + disabled=State.processing, + flex="1", + ), + rx.button( + "Send", + loading=State.processing, + disabled=State.processing, + type="submit", ), - is_disabled=State.processing, + max_width="50em", + margin="0 auto", + align_items="center", ), - on_submit=State.process_question, - reset_on_submit=True, + on_submit=[State.process_question, rx.set_value("question", "")], ), rx.text( "ReflexGPT may return factually incorrect or misleading responses. Use discretion.", @@ -96,8 +97,10 @@ def action_bar() -> rx.Component: font_size=".75em", color=rx.color("mauve", 10), ), - rx.logo(margin_top="-1em", margin_bottom="-1em"), - align_items="center", + rx.logo(margin_block="-1em"), + width="100%", + padding_x="16px", + align="stretch", ), position="sticky", bottom="0", @@ -107,6 +110,6 @@ def action_bar() -> rx.Component: backdrop_blur="lg", border_top=f"1px solid {rx.color('mauve', 3)}", background_color=rx.color("mauve", 2), - align_items="stretch", + align="stretch", width="100%", ) diff --git a/chat/components/loading_icon.py b/chat/components/loading_icon.py deleted file mode 100644 index 512422e..0000000 --- a/chat/components/loading_icon.py +++ /dev/null @@ -1,21 +0,0 @@ -import reflex as rx - - -class LoadingIcon(rx.Component): - """A custom loading icon component.""" - - library = "react-loading-icons" - tag = "SpinningCircles" - stroke: rx.Var[str] - stroke_opacity: rx.Var[str] - fill: rx.Var[str] - fill_opacity: rx.Var[str] - stroke_width: rx.Var[str] - speed: rx.Var[str] - height: rx.Var[str] - - def get_event_triggers(self) -> dict: - return {"on_change": lambda status: [status]} - - -loading_icon = LoadingIcon.create diff --git a/chat/components/modal.py b/chat/components/modal.py deleted file mode 100644 index bca8123..0000000 --- a/chat/components/modal.py +++ /dev/null @@ -1,53 +0,0 @@ -import reflex as rx -import reflex_chakra as rc - -from chat.state import State - - -def modal() -> rx.Component: - """A modal to create a new chat.""" - return rc.modal( - rc.modal_overlay( - rc.modal_content( - rc.modal_header( - rc.hstack( - rc.text("Create new chat"), - rc.icon( - tag="close", - font_size="sm", - on_click=State.toggle_modal, - color="#fff8", - _hover={"color": "#fff"}, - cursor="pointer", - ), - align_items="center", - justify_content="space-between", - ) - ), - rc.modal_body( - rc.input( - placeholder="Type something...", - on_blur=State.set_new_chat_name, - bg="#222", - border_color="#fff3", - _placeholder={"color": "#fffa"}, - ), - ), - rc.modal_footer( - rc.button( - "Create", - bg="#5535d4", - box_shadow="md", - px="4", - py="2", - h="auto", - _hover={"bg": "#4c2db3"}, - on_click=State.create_chat, - ), - ), - bg="#222", - color="#fff", - ), - ), - is_open=State.modal_open, - ) diff --git a/chat/components/navbar.py b/chat/components/navbar.py index 4cb8d4a..af7e439 100644 --- a/chat/components/navbar.py +++ b/chat/components/navbar.py @@ -1,28 +1,34 @@ import reflex as rx from chat.state import State + def sidebar_chat(chat: str) -> rx.Component: """A sidebar chat item. Args: chat: The chat item. """ - return rx.drawer.close(rx.hstack( - rx.button( - chat, on_click=lambda: State.set_chat(chat), width="80%", variant="surface" - ), - rx.button( - rx.icon( - tag="trash", - on_click=State.delete_chat, - stroke_width=1, + return rx.drawer.close( + rx.hstack( + rx.button( + chat, + on_click=lambda: State.set_chat(chat), + width="80%", + variant="surface", ), - width="20%", - variant="surface", - color_scheme="red", - ), - width="100%", - )) + rx.button( + rx.icon( + tag="trash", + on_click=State.delete_chat, + stroke_width=1, + ), + width="20%", + variant="surface", + color_scheme="red", + ), + width="100%", + ) + ) def sidebar(trigger) -> rx.Component: @@ -59,9 +65,10 @@ def modal(trigger) -> rx.Component: rx.dialog.content( rx.hstack( rx.input( - placeholder="Type something...", + placeholder="Chat name", on_blur=State.set_new_chat_name, - width=["15em", "20em", "30em", "30em", "30em", "30em"], + flex="1", + min_width="20ch", ), rx.dialog.close( rx.button( @@ -69,61 +76,39 @@ def modal(trigger) -> rx.Component: on_click=State.create_chat, ), ), - background_color=rx.color("mauve", 1), spacing="2", + wrap="wrap", width="100%", ), + background_color=rx.color("mauve", 1), ), ) def navbar(): - return rx.box( - rx.hstack( - rx.hstack( - rx.avatar(fallback="RC", variant="solid"), - rx.heading("Reflex Chat"), - rx.desktop_only( - rx.badge( - State.current_chat, - rx.tooltip(rx.icon("info", size=14), content="The current selected chat."), - variant="soft" - ) - ), - align_items="center", + return rx.hstack( + rx.badge( + State.current_chat, + rx.tooltip( + rx.icon("info", size=14), + content="The current selected chat.", ), - rx.hstack( - modal(rx.button("+ New chat")), - sidebar( - rx.button( - rx.icon( - tag="messages-square", - color=rx.color("mauve", 12), - ), - background_color=rx.color("mauve", 6), - ) - ), - rx.desktop_only( - rx.button( - rx.icon( - tag="sliders-horizontal", - color=rx.color("mauve", 12), - ), - background_color=rx.color("mauve", 6), - ) - ), - align_items="center", - ), - justify_content="space-between", - align_items="center", + size="3", + variant="soft", + margin_inline_end="auto", ), - backdrop_filter="auto", - backdrop_blur="lg", + modal( + rx.icon_button("message-square-plus"), + ), + sidebar( + rx.icon_button( + "messages-square", + background_color=rx.color("mauve", 6), + ) + ), + justify_content="space-between", + align_items="center", padding="12px", border_bottom=f"1px solid {rx.color('mauve', 3)}", background_color=rx.color("mauve", 2), - position="sticky", - top="0", - z_index="100", - align_items="center", ) diff --git a/chat/state.py b/chat/state.py index fb1c427..c6f09aa 100644 --- a/chat/state.py +++ b/chat/state.py @@ -1,14 +1,15 @@ import os +from typing import Any, TypedDict import reflex as rx from openai import OpenAI - +from openai.types.chat import ChatCompletionMessageParam # Checking if the API key is set properly if not os.getenv("OPENAI_API_KEY"): raise Exception("Please set OPENAI_API_KEY environment variable.") -class QA(rx.Base): +class QA(TypedDict): """A question and answer pair.""" question: str @@ -29,21 +30,20 @@ class State(rx.State): # The current chat name. current_chat = "Intros" - # The current question. - question: str - # Whether we are processing the question. processing: bool = False # The name of the new chat. new_chat_name: str = "" + @rx.event def create_chat(self): """Create a new chat.""" # Add the new chat to the list of chats. self.current_chat = self.new_chat_name self.chats[self.new_chat_name] = [] + @rx.event def delete_chat(self): """Delete the current chat.""" del self.chats[self.current_chat] @@ -51,6 +51,7 @@ def delete_chat(self): self.chats = DEFAULT_CHATS self.current_chat = list(self.chats.keys())[0] + @rx.event def set_chat(self, chat_name: str): """Set the name of the current chat. @@ -59,7 +60,16 @@ def set_chat(self, chat_name: str): """ self.current_chat = chat_name - @rx.var(cache=True) + @rx.event + def set_new_chat_name(self, new_chat_name: str): + """Set the name of the new chat. + + Args: + new_chat_name: The name of the new chat. + """ + self.new_chat_name = new_chat_name + + @rx.var def chat_titles(self) -> list[str]: """Get the list of chat titles. @@ -68,7 +78,8 @@ def chat_titles(self) -> list[str]: """ return list(self.chats.keys()) - async def process_question(self, form_data: dict[str, str]): + @rx.event + async def process_question(self, form_data: dict[str, Any]): # Get the question from the form question = form_data["question"] @@ -76,11 +87,10 @@ async def process_question(self, form_data: dict[str, str]): if question == "": return - model = self.openai_process_question - - async for value in model(question): + async for value in self.openai_process_question(question): yield value + @rx.event async def openai_process_question(self, question: str): """Get the response from the API. @@ -97,15 +107,15 @@ async def openai_process_question(self, question: str): yield # Build the messages. - messages = [ + messages: list[ChatCompletionMessageParam] = [ { "role": "system", "content": "You are a friendly chatbot named Reflex. Respond in markdown.", } ] for qa in self.chats[self.current_chat]: - messages.append({"role": "user", "content": qa.question}) - messages.append({"role": "assistant", "content": qa.answer}) + messages.append({"role": "user", "content": qa["question"]}) + messages.append({"role": "assistant", "content": qa["answer"]}) # Remove the last mock answer. messages = messages[:-1] @@ -123,12 +133,12 @@ async def openai_process_question(self, question: str): answer_text = item.choices[0].delta.content # Ensure answer_text is not None before concatenation if answer_text is not None: - self.chats[self.current_chat][-1].answer += answer_text + self.chats[self.current_chat][-1]["answer"] += answer_text else: # Handle the case where answer_text is None, perhaps log it or assign a default value # For example, assigning an empty string if answer_text is None answer_text = "" - self.chats[self.current_chat][-1].answer += answer_text + self.chats[self.current_chat][-1]["answer"] += answer_text self.chats = self.chats yield diff --git a/requirements.txt b/requirements.txt index 826e86a..d228dd6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ -reflex>=0.4.7 -openai>=1.14.0 -reflex-chakra>=0.6.0 +reflex>=0.7.11 +openai>=1.78.1