├── .gitignore
├── LICENSE
├── README.md
├── app.py
├── components
├── assets.py
└── chat.py
├── deploy.py
└── requirements.txt
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | .sesskey
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Arihan Varanasi
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # FastHTML + Modal Template
2 |
3 | Deploy a FastHTML app in just a few lines of simple python code on Modal's serverless infra.
4 |
5 | This template is an implementation of a streaming chat app with auto-scrolling and a simple UI where you can easily swap out the dummy generator with your own LLM.
6 |
7 |
8 |

9 |
10 |
11 | ## Run the App Locally
12 | ```
13 | pip install -r requirements.txt
14 | python app.py
15 | ```
16 |
17 | ## Deploy the App
18 | Visit [modal.com](https://modal.com/) and create a free account. Then follow the instructions to authenticate in your CLI.
19 |
20 | Run the following command in your terminal:
21 | ```
22 | modal deploy deploy.py
23 | ```
24 | That's it!
25 |
--------------------------------------------------------------------------------
/app.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import random
3 |
4 | from fasthtml.common import *
5 |
6 | from components.assets import arrow_circle_icon, github_icon
7 | from components.chat import chat, chat_form, chat_message, chat_messages
8 |
9 | tlink = Script(src="https://cdn.tailwindcss.com")
10 | fasthtml_app, rt = fast_app(ws_hdr=True, hdrs=[tlink])
11 |
12 |
13 | def title():
14 | return Div(
15 | Span("FastHTML + Modal", cls="font-bold text-6xl"),
16 | Span(
17 | Span("Get started by editing ", cls=""),
18 | Span(
19 | "app.py",
20 | cls="font-mono text-green-500 bg-zinc-900 px-1 py-0.5 rounded-md text-sm",
21 | ),
22 | Span(" or deploying to Modal with ", cls=""),
23 | Span(
24 | "deploy.py",
25 | cls="font-mono text-green-500 bg-zinc-900 px-1 py-0.5 rounded-md text-sm",
26 | ),
27 | ),
28 | cls="flex-1 flex flex-col items-center justify-center gap-2",
29 | )
30 |
31 |
32 | def github_link():
33 | return A(
34 | github_icon(),
35 | Span("GitHub", cls="font-mono text-green-500 hover:text-green-400 text-sm"),
36 | href="https://github.com/arihanv/fasthtml-modal",
37 | target="_blank",
38 | cls="font-mono text-green-500 hover:text-green-400 flex items-center gap-1",
39 | )
40 |
41 |
42 | def footer():
43 | return Div(
44 | Div(
45 | Div("Docs", cls="text-zinc-400 text-sm"),
46 | Div(
47 | A(
48 | Span(
49 | "FastHTML",
50 | cls="font-mono text-green-500 group-hover:text-green-400 text-xl",
51 | ),
52 | Div(
53 | arrow_circle_icon(),
54 | cls="flex items-center justify-center text-green-500 group-hover:text-green-400 size-5 -rotate-45",
55 | ),
56 | href="https://fastht.ml/",
57 | target="_blank",
58 | cls="justify-between items-center pl-2 pr-1 flex border border-green-500 w-40 rounded-md group",
59 | ),
60 | Div(cls="w-px bg-zinc-700 h-6"),
61 | A(
62 | Span(
63 | "Modal",
64 | cls="font-mono text-green-500 group-hover:text-green-400 text-xl",
65 | ),
66 | Div(
67 | arrow_circle_icon(),
68 | cls="flex items-center justify-center text-green-500 group-hover:text-green-400 size-5 -rotate-45",
69 | ),
70 | href="https://modal.com/docs",
71 | target="_blank",
72 | cls="justify-between items-center pl-2 pr-1 flex border border-green-500 w-40 rounded-md group",
73 | ),
74 | cls="flex items-center justify-start gap-2",
75 | ),
76 | cls="flex flex-col items-center gap-1",
77 | ),
78 | Div(
79 | Div("Source", cls="text-zinc-400 flex justify-center w-full text-sm"),
80 | Div(
81 | github_link(),
82 | ),
83 | cls="flex flex-col items-center gap-1",
84 | ),
85 | cls="flex-1 flex-col flex items-center justify-center gap-5",
86 | )
87 |
88 |
89 | @rt("/")
90 | async def get():
91 | cts = Div(
92 | title(),
93 | chat(),
94 | footer(),
95 | cls="flex flex-col items-center min-h-screen bg-black",
96 | )
97 | return cts
98 |
99 |
100 | @fasthtml_app.ws("/ws")
101 | async def ws(msg: str, send):
102 | chat_messages.append({"role": "user", "content": msg})
103 |
104 | await send(chat_form(disabled=True))
105 | await send(
106 | Div(
107 | chat_message(len(chat_messages) - 1), id="messages", hx_swap_oob="beforeend"
108 | )
109 | )
110 |
111 | message = "You typed: " + msg
112 |
113 | chunks = []
114 | while message:
115 | chunk_size = random.randint(4, 10)
116 | chunk = message[:chunk_size]
117 | chunks.append(chunk)
118 | message = message[chunk_size:]
119 |
120 | chat_messages.append({"role": "assistant", "content": ""})
121 |
122 | await send(
123 | Div(
124 | chat_message(len(chat_messages) - 1), id="messages", hx_swap_oob="beforeend"
125 | )
126 | )
127 |
128 | stream_response = ""
129 | for chunk in chunks:
130 | stream_response += chunk
131 | chat_messages[-1]["content"] += chunk
132 | await send(
133 | Span(
134 | chunk, id=f"msg-content-{len(chat_messages)-1}", hx_swap_oob="beforeend"
135 | )
136 | )
137 | await asyncio.sleep(0.2)
138 |
139 | await send(chat_form(disabled=False))
140 |
141 |
142 | if __name__ == "__main__":
143 | serve(app="fasthtml_app")
144 |
--------------------------------------------------------------------------------
/components/assets.py:
--------------------------------------------------------------------------------
1 | from fasthtml.common import *
2 |
3 |
4 | def arrow_circle_icon():
5 | return (
6 | Svg(
7 | NotStr(
8 | """"""
9 | ),
10 | width="24",
11 | height="24",
12 | viewBox="0 0 24 24",
13 | fill="none",
14 | stroke="currentColor",
15 | stroke_width="1.5px",
16 | ),
17 | )
18 |
19 |
20 | def send_icon():
21 | return (
22 | Svg(
23 | NotStr(""""""),
24 | width="30",
25 | height="30",
26 | viewBox="0 0 24 24",
27 | fill="none",
28 | stroke="currentColor",
29 | stroke_linecap="round",
30 | stroke_linejoin="round",
31 | stroke_width="1.5px",
32 | ),
33 | )
34 |
35 |
36 | def github_icon():
37 | return (
38 | Svg(
39 | NotStr(
40 | """"""
41 | ),
42 | width="17.5",
43 | height="17.5",
44 | viewBox="0 0 15 15",
45 | fill="none",
46 | cls="bg-zinc-900 rounded-sm p-0.5 border border-green-500",
47 | ),
48 | )
49 |
--------------------------------------------------------------------------------
/components/chat.py:
--------------------------------------------------------------------------------
1 | from fasthtml.common import *
2 | from components.assets import send_icon
3 |
4 | chat_messages = []
5 |
6 |
7 | def chat_input(disabled=False):
8 | return Input(
9 | type="text",
10 | name="msg",
11 | id="msg-input",
12 | required=True,
13 | placeholder="Type a message",
14 | hx_swap_oob="true",
15 | autofocus="true",
16 | disabled=disabled,
17 | cls="!mb-0 bg-zinc-900 border border-zinc-700 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-zinc-500 disabled:bg-zinc-800 disabled:border-zinc-700 disabled:cursor-not-allowed rounded-md",
18 | )
19 |
20 |
21 | def chat_button(disabled=False):
22 | return Button(
23 | send_icon(),
24 | id="send-button",
25 | disabled=disabled,
26 | cls="bg-green-500 hover:bg-green-600 text-white rounded-md p-2.5 flex items-center justify-center border border-zinc-700 focus-visible:outline-none focus-visible:ring-zinc-500 disabled:bg-green-800 disabled:border-green-700 disabled:cursor-not-allowed",
27 | )
28 |
29 |
30 | def chat_form(disabled=False):
31 | return Form(
32 | chat_input(disabled=disabled),
33 | chat_button(disabled=disabled),
34 | id="form",
35 | ws_send=True,
36 | cls="w-full flex gap-2 items-center border-t border-zinc-700 p-2",
37 | )
38 |
39 |
40 | def chat_message(msg_idx):
41 | msg = chat_messages[msg_idx]
42 | content_cls = f"px-2.5 py-1.5 rounded-lg max-w-xs {'rounded-br-none border-green-700 border' if msg['role'] == 'user' else 'rounded-bl-none border-zinc-400 border'}"
43 |
44 | return Div(
45 | Div(msg["role"], cls="text-xs text-zinc-500 mb-1"),
46 | Div(
47 | msg["content"],
48 | cls=f"bg-{'green-600 text-white' if msg['role'] == 'user' else 'zinc-200 text-black'} {content_cls}",
49 | id=f"msg-content-{msg_idx}",
50 | ),
51 | id=f"msg-{msg_idx}",
52 | cls=f"self-{'end' if msg['role'] == 'user' else 'start'}",
53 | )
54 |
55 |
56 | def chat_window():
57 | return Div(
58 | id="messages",
59 | *[chat_message(i) for i in range(len(chat_messages))],
60 | cls="flex flex-col gap-2 p-4 h-[45vh] overflow-y-auto w-full",
61 | )
62 |
63 |
64 | def chat_title():
65 | return Div(
66 | "streaming-chat-example",
67 | cls="text-xs font-mono absolute top-0 left-0 w-fit p-1 bg-zinc-900 border-b border-r border-zinc-700 rounded-tl-md rounded-br-md",
68 | )
69 |
70 |
71 | def chat():
72 | return Div(
73 | chat_title(),
74 | chat_window(),
75 | chat_form(),
76 | Script(
77 | """
78 | function scrollToBottom(smooth) {
79 | var messages = document.getElementById('messages');
80 | messages.scrollTo({
81 | top: messages.scrollHeight,
82 | behavior: smooth ? 'smooth' : 'auto'
83 | });
84 | }
85 | window.onload = function() {
86 | scrollToBottom(true);
87 | };
88 |
89 | const observer = new MutationObserver(function() {
90 | scrollToBottom(false);
91 | });
92 | observer.observe(document.getElementById('messages'), { childList: true, subtree: true });
93 | """
94 | ),
95 | hx_ext="ws",
96 | ws_connect="/ws",
97 | cls="flex flex-col w-full max-w-2xl border border-zinc-700 h-full rounded-md outline-1 outline outline-zinc-700 outline-offset-2 relative",
98 | )
99 |
--------------------------------------------------------------------------------
/deploy.py:
--------------------------------------------------------------------------------
1 | from modal import App, Image, asgi_app
2 | from app import fasthtml_app
3 |
4 | image = Image.debian_slim(python_version="3.11").pip_install("python-fasthtml")
5 |
6 | app = App("fasthtml-modal-template")
7 |
8 |
9 | @app.function(image=image)
10 | @asgi_app()
11 | def get():
12 | return fasthtml_app
13 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | modal==0.64.23
2 | python-fasthtml==0.3.6
--------------------------------------------------------------------------------