├── .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 | fasthtml-modal 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 --------------------------------------------------------------------------------