mesop / fancy_chat.py
wwwillchen's picture
Commit
8e04495
import random
import time
from dataclasses import asdict, dataclass
from typing import Callable, Literal
import mesop as me
Role = Literal["user", "bot"]
_APP_TITLE = "Fancy Mesop Chat"
_BOT_AVATAR_LETTER = "M"
_EMPTY_CHAT_MESSAGE = "Get started with an example"
_EXAMPLE_USER_QUERIES = (
"What is Mesop?",
"Make me a chat app.",
"How do I make a web component?",
)
_CHAT_MAX_WIDTH = "800px"
_MOBILE_BREAKPOINT = 640
@dataclass(kw_only=True)
class ChatMessage:
"""Chat message metadata."""
role: Role = "user"
content: str = ""
edited: bool = False
# 1 is positive
# -1 is negative
# 0 is no rating
rating: int = 0
@me.stateclass
class State:
input: str
output: list[ChatMessage]
in_progress: bool
sidebar_expanded: bool = False
# Need to use dict instead of ChatMessage due to serialization bug.
# See: https://github.com/google/mesop/issues/659
history: list[list[dict]]
def respond_to_chat(input: str, history: list[ChatMessage]):
"""Displays random canned text.
Edit this function to process messages with a real chatbot/LLM.
"""
lines = [
"Mesop is a Python-based UI framework designed to simplify web UI development for engineers without frontend experience.",
"It leverages the power of the Angular web framework and Angular Material components, allowing rapid construction of web demos and internal tools.",
"With Mesop, developers can enjoy a fast build-edit-refresh loop thanks to its hot reload feature, making UI tweaks and component integration seamless.",
"Deployment is straightforward, utilizing standard HTTP technologies.",
"Mesop's component library aims for comprehensive Angular Material component coverage, enhancing UI flexibility and composability.",
"It supports custom components for specific use cases, ensuring developers can extend its capabilities to fit their unique requirements.",
"Mesop's roadmap includes expanding its component library and simplifying the onboarding processs.",
]
for line in random.sample(lines, random.randint(3, len(lines) - 1)):
time.sleep(0.3)
yield line + " "
def on_load(e: me.LoadEvent):
me.set_theme_mode("system")
@me.page(
security_policy=me.SecurityPolicy(
allowed_iframe_parents=["https://google.github.io", "https://huggingface.co."]
),
title="Fancy Mesop Demo Chat",
path="/fancy_chat",
on_load=on_load,
)
def page():
state = me.state(State)
with me.box(
style=me.Style(
background=me.theme_var("surface-container-lowest"),
display="flex",
flex_direction="column",
height="100%",
)
):
with me.box(
style=me.Style(
display="flex", flex_direction="row", flex_grow=1, overflow="hidden"
)
):
with me.box(
style=me.Style(
background=me.theme_var("surface-container-low"),
display="flex",
flex_direction="column",
flex_shrink=0,
position="absolute"
if state.sidebar_expanded and _is_mobile()
else None,
height="100%" if state.sidebar_expanded and _is_mobile() else None,
width=300 if state.sidebar_expanded else None,
z_index=2000,
)
):
sidebar()
with me.box(
style=me.Style(
display="flex",
flex_direction="column",
flex_grow=1,
padding=me.Padding(left=60)
if state.sidebar_expanded and _is_mobile()
else None,
)
):
header()
with me.box(style=me.Style(flex_grow=1, overflow_y="scroll")):
if state.output:
chat_pane()
else:
examples_pane()
chat_input()
def sidebar():
state = me.state(State)
with me.box(
style=me.Style(
display="flex",
flex_direction="column",
flex_grow=1,
)
):
with me.box(style=me.Style(display="flex", gap=20)):
menu_icon(icon="menu", tooltip="Menu", on_click=on_click_menu_icon)
if state.sidebar_expanded:
me.text(
_APP_TITLE,
style=me.Style(margin=me.Margin(bottom=0, top=14)),
type="headline-6",
)
if state.sidebar_expanded:
menu_item(icon="add", label="New chat", on_click=on_click_new_chat)
else:
menu_icon(icon="add", tooltip="New chat", on_click=on_click_new_chat)
if state.sidebar_expanded:
history_pane()
def history_pane():
state = me.state(State)
for index, chat in enumerate(state.history):
with me.box(
key=f"chat-{index}",
on_click=on_click_history,
style=me.Style(
background=me.theme_var("surface-container"),
border=me.Border.all(
me.BorderSide(
width=1, color=me.theme_var("outline-variant"), style="solid"
)
),
border_radius=5,
cursor="pointer",
margin=me.Margin.symmetric(horizontal=10, vertical=10),
padding=me.Padding.all(10),
text_overflow="ellipsis",
),
):
me.text(_truncate_text(chat[0]["content"]))
def header():
state = me.state(State)
with me.box(
style=me.Style(
align_items="center",
background=me.theme_var("surface-container-lowest"),
display="flex",
gap=5,
justify_content="space-between",
padding=me.Padding.symmetric(horizontal=20, vertical=10),
)
):
with me.box(style=me.Style(display="flex", gap=5)):
if not state.sidebar_expanded:
me.text(
_APP_TITLE,
style=me.Style(margin=me.Margin(bottom=0)),
type="headline-6",
)
with me.box(style=me.Style(display="flex", gap=5)):
icon_button(
key="",
icon="dark_mode" if me.theme_brightness() == "light" else "light_mode",
tooltip="Dark mode"
if me.theme_brightness() == "light"
else "Light mode",
on_click=on_click_theme_brightness,
)
def examples_pane():
with me.box(
style=me.Style(
margin=me.Margin.symmetric(horizontal="auto"),
padding=me.Padding.all(15),
width=f"min({_CHAT_MAX_WIDTH}, 100%)",
)
):
with me.box(style=me.Style(margin=me.Margin(top=25), font_size=24)):
me.text(_EMPTY_CHAT_MESSAGE)
with me.box(
style=me.Style(
display="flex",
flex_direction="column" if _is_mobile() else "row",
gap=20,
margin=me.Margin(top=25),
)
):
for index, query in enumerate(_EXAMPLE_USER_QUERIES):
with me.box(
key=f"query-{index}",
on_click=on_click_example_user_query,
style=me.Style(
background=me.theme_var("surface-container-highest"),
border_radius=15,
padding=me.Padding.all(20),
cursor="pointer",
),
):
me.text(query)
def chat_pane():
state = me.state(State)
with me.box(
style=me.Style(
background=me.theme_var("surface-container-lowest"),
color=me.theme_var("on-surface"),
display="flex",
flex_direction="column",
margin=me.Margin.symmetric(horizontal="auto"),
padding=me.Padding.all(15),
width=f"min({_CHAT_MAX_WIDTH}, 100%)",
)
):
for index, msg in enumerate(state.output):
if msg.role == "user":
user_message(message=msg)
else:
bot_message(message_index=index, message=msg)
if state.in_progress:
with me.box(key="scroll-to", style=me.Style(height=250)):
pass
def user_message(*, message: ChatMessage):
with me.box(
style=me.Style(
display="flex",
gap=15,
justify_content="end",
margin=me.Margin.all(20),
)
):
with me.box(
style=me.Style(
background=me.theme_var("surface-container-low"),
border_radius=10,
color=me.theme_var("on-surface-variant"),
padding=me.Padding.symmetric(vertical=0, horizontal=10),
width="66%",
)
):
me.markdown(message.content)
def bot_message(*, message_index: int, message: ChatMessage):
with me.box(style=me.Style(display="flex", gap=15, margin=me.Margin.all(20))):
text_avatar(
background=me.theme_var("primary"),
color=me.theme_var("on-primary"),
label=_BOT_AVATAR_LETTER,
)
# Bot message response
with me.box(style=me.Style(display="flex", flex_direction="column")):
me.markdown(
message.content,
style=me.Style(color=me.theme_var("on-surface")),
)
# Actions panel
with me.box():
icon_button(
key=f"thumb_up-{message_index}",
icon="thumb_up",
is_selected=message.rating == 1,
tooltip="Good response",
on_click=on_click_thumb_up,
)
icon_button(
key=f"thumb_down-{message_index}",
icon="thumb_down",
is_selected=message.rating == -1,
tooltip="Bad response",
on_click=on_click_thumb_down,
)
icon_button(
key=f"restart-{message_index}",
icon="restart_alt",
tooltip="Regenerate answer",
on_click=on_click_regenerate,
)
def chat_input():
state = me.state(State)
with me.box(
style=me.Style(
background=me.theme_var("surface-container")
if _is_mobile()
else me.theme_var("surface-container"),
border_radius=16,
display="flex",
margin=me.Margin.symmetric(horizontal="auto", vertical=15),
padding=me.Padding.all(8),
width=f"min({_CHAT_MAX_WIDTH}, 90%)",
)
):
with me.box(
style=me.Style(
flex_grow=1,
)
):
me.native_textarea(
autosize=True,
key="chat_input",
min_rows=4,
on_blur=on_chat_input,
shortcuts={
me.Shortcut(shift=True, key="Enter"): on_submit_chat_msg,
},
placeholder="Enter your prompt",
style=me.Style(
background=me.theme_var("surface-container")
if _is_mobile()
else me.theme_var("surface-container"),
border=me.Border.all(
me.BorderSide(style="none"),
),
color=me.theme_var("on-surface-variant"),
outline="none",
overflow_y="auto",
padding=me.Padding(top=16, left=16),
width="100%",
),
value=state.input,
)
with me.content_button(
disabled=state.in_progress,
on_click=on_click_submit_chat_msg,
type="icon",
):
me.icon("send")
@me.component
def text_avatar(*, label: str, background: str, color: str):
me.text(
label,
style=me.Style(
background=background,
border_radius="50%",
color=color,
font_size=20,
height=40,
line_height="1",
margin=me.Margin(top=16),
padding=me.Padding(top=10),
text_align="center",
width="40px",
),
)
@me.component
def icon_button(
*,
icon: str,
tooltip: str,
key: str = "",
is_selected: bool = False,
on_click: Callable | None = None,
):
selected_style = me.Style(
background=me.theme_var("surface-container-low"),
color=me.theme_var("on-surface-variant"),
)
with me.tooltip(message=tooltip):
with me.content_button(
type="icon",
key=key,
on_click=on_click,
style=selected_style if is_selected else None,
):
me.icon(icon)
@me.component
def menu_icon(
*, icon: str, tooltip: str, key: str = "", on_click: Callable | None = None
):
with me.tooltip(message=tooltip):
with me.content_button(
key=key,
on_click=on_click,
style=me.Style(margin=me.Margin.all(10)),
type="icon",
):
me.icon(icon)
@me.component
def menu_item(
*, icon: str, label: str, key: str = "", on_click: Callable | None = None
):
with me.box(on_click=on_click):
with me.box(
style=me.Style(
background=me.theme_var("surface-container-high"),
border_radius=20,
cursor="pointer",
display="inline-flex",
gap=10,
line_height=1,
margin=me.Margin.all(10),
padding=me.Padding(top=10, left=10, right=20, bottom=10),
),
):
me.icon(icon)
me.text(label, style=me.Style(height=24, line_height="24px"))
# Event Handlers
def on_click_example_user_query(e: me.ClickEvent):
"""Populates the user input with the example query"""
state = me.state(State)
_, example_index = e.key.split("-")
state.input = _EXAMPLE_USER_QUERIES[int(example_index)]
me.focus_component(key="chat_input")
def on_click_thumb_up(e: me.ClickEvent):
"""Gives the message a positive rating"""
state = me.state(State)
_, msg_index = e.key.split("-")
msg_index = int(msg_index)
state.output[msg_index].rating = 1
def on_click_thumb_down(e: me.ClickEvent):
"""Gives the message a negative rating"""
state = me.state(State)
_, msg_index = e.key.split("-")
msg_index = int(msg_index)
state.output[msg_index].rating = -1
def on_click_new_chat(e: me.ClickEvent):
"""Resets messages."""
state = me.state(State)
if state.output:
state.history.insert(0, [asdict(messages) for messages in state.output])
state.output = []
me.focus_component(key="chat_input")
def on_click_history(e: me.ClickEvent):
"""Loads existing chat from history and saves current chat"""
state = me.state(State)
_, chat_index = e.key.split("-")
chat_messages = [
ChatMessage(**chat) for chat in state.history.pop(int(chat_index))
]
if state.output:
state.history.insert(0, [asdict(messages) for messages in state.output])
state.output = chat_messages
me.focus_component(key="chat_input")
def on_click_theme_brightness(e: me.ClickEvent):
"""Toggles dark mode."""
if me.theme_brightness() == "light":
me.set_theme_mode("dark")
else:
me.set_theme_mode("light")
def on_click_menu_icon(e: me.ClickEvent):
"""Expands and collapses sidebar menu."""
state = me.state(State)
state.sidebar_expanded = not state.sidebar_expanded
def on_chat_input(e: me.InputBlurEvent):
"""Capture chat text input on blur."""
state = me.state(State)
state.input = e.value
def on_click_regenerate(e: me.ClickEvent):
"""Regenerates response from an existing message"""
state = me.state(State)
_, msg_index = e.key.split("-")
msg_index = int(msg_index)
# Get the user message which is the previous message
user_message = state.output[msg_index - 1]
# Get bot message to be regenerated
assistant_message = state.output[msg_index]
assistant_message.content = ""
state.in_progress = True
yield
start_time = time.time()
# Send in the old user input and chat history to get the bot response.
# We make sure to only pass in the chat history up to this message.
output_message = respond_to_chat(
user_message.content, state.output[:msg_index]
)
for content in output_message:
assistant_message.content += content
# TODO: 0.25 is an abitrary choice. In the future, consider making this adjustable.
if (time.time() - start_time) >= 0.25:
start_time = time.time()
yield
state.in_progress = False
me.focus_component(key="chat_input")
yield
def on_submit_chat_msg(e: me.TextareaShortcutEvent):
state = me.state(State)
state.input = e.value
yield
yield from _submit_chat_msg()
def on_click_submit_chat_msg(e: me.ClickEvent):
yield from _submit_chat_msg()
def _submit_chat_msg():
"""Handles submitting a chat message."""
state = me.state(State)
if state.in_progress or not state.input:
return
input = state.input
# Clear the text input.
state.input = ""
yield
output = state.output
if output is None:
output = []
output.append(ChatMessage(role="user", content=input))
state.in_progress = True
me.scroll_into_view(key="scroll-to")
yield
start_time = time.time()
# Send user input and chat history to get the bot response.
output_message = respond_to_chat(input, state.output)
assistant_message = ChatMessage(role="bot")
output.append(assistant_message)
state.output = output
for content in output_message:
assistant_message.content += content
# TODO: 0.25 is an abitrary choice. In the future, consider making this adjustable.
if (time.time() - start_time) >= 0.25:
start_time = time.time()
yield
state.in_progress = False
me.focus_component(key="chat_input")
yield
# Helpers
def _is_mobile():
return me.viewport_size().width < _MOBILE_BREAKPOINT
def _truncate_text(text, char_limit=100):
"""Truncates text that is too long."""
if len(text) <= char_limit:
return text
truncated_text = text[:char_limit].rsplit(" ", 1)[0]
return truncated_text.rstrip(".,!?;:") + "..."