├── .gitignore ├── .streamlit └── config.toml ├── README.md ├── requirements.txt └── src ├── app.py ├── app_config.py ├── assets ├── AI_icon.png ├── loading.gif └── user_icon.png ├── style.css └── utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | _* 2 | *.sh -------------------------------------------------------------------------------- /.streamlit/config.toml: -------------------------------------------------------------------------------- 1 | [theme] 2 | base="light" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CatGDP 2 | ​Meow meow, dis iz a GitPurr repository of CatGDP fur feline whiskerful conversations. Pawsome, right? Hiss-tory in the making! Happy Caturday! 🐾 3 | 4 | Chat with a #cat, anytime, anywhere. Get #infinite purrrly cat images, and utterly destroy your #productivity and #GDP. 5 | 6 | Try it: https://www.CatGDP.com 7 | 8 | Running the service and making those API calls don't come fur-ee so any paw-nations are more than purr-come. :heart: 9 | 10 | ![DOGE](https://img.shields.io/badge/DOGE-DJCJpk61fwKNEQgPoK7fP8frojubAjuMUZ-yellowgreen) 11 | 12 | DOGE 13 | 14 | ## Shoutouts 15 | 16 | - The `Streamlit-chat` component at https://github.com/AI-Yash/st-chat. Though I didn't end up including the project directly as a requirement because there were some details I could not control, I was inspired by their CSS design and edited it for my own purposes and implemented in this and many of my other chatbot projects. 17 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | streamlit 2 | openai<1.0 3 | transformers 4 | stability-sdk -------------------------------------------------------------------------------- /src/app.py: -------------------------------------------------------------------------------- 1 | import os 2 | import base64 3 | import asyncio 4 | import traceback 5 | from PIL import Image 6 | import streamlit as st 7 | from transformers import AutoTokenizer 8 | from stability_sdk import client 9 | import stability_sdk.interfaces.gooseai.generation.generation_pb2 as generation 10 | from google.protobuf.json_format import MessageToJson 11 | from app_config import * 12 | from utils import * 13 | 14 | # Set global variables 15 | 16 | ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 17 | os.environ["TOKENIZERS_PARALLELISM"] = "false" 18 | 19 | 20 | # Check environment variables 21 | 22 | errors = [] 23 | for key in [ 24 | "OPENAI_API_KEY", "OPENAI_API_BASE", "OPENAI_API_TYPE", # For OpenAI APIs 25 | "STABILITY_HOST", "STABILITY_API_KEY", # For Stability APIs 26 | ]: 27 | if key not in os.environ: 28 | errors.append(f"Please set the {key} environment variable.") 29 | if len(errors) > 0: 30 | st.error("\n".join(errors)) 31 | st.stop() 32 | 33 | stability_api = client.StabilityInference( 34 | key=os.environ['STABILITY_API_KEY'], # API Key reference. 35 | # verbose=True, # Print debug messages. 36 | engine="stable-diffusion-xl-1024-v1-0", # Set the engine to use for generation. 37 | # Available engines: stable-diffusion-xl-1024-v0-9 stable-diffusion-v1 stable-diffusion-v1-5 stable-diffusion-512-v2-0 stable-diffusion-768-v2-0 38 | # stable-diffusion-512-v2-1 stable-diffusion-768-v2-1 stable-diffusion-xl-beta-v2-2-2 stable-inpainting-v1-0 stable-inpainting-512-v2-0 39 | ) 40 | 41 | ### FUNCTION DEFINITIONS ### 42 | 43 | 44 | @st.cache_data(show_spinner=False) 45 | def get_local_img(file_path: str) -> str: 46 | # Load a byte image and return its base64 encoded string 47 | return base64.b64encode(open(file_path, "rb").read()).decode("utf-8") 48 | 49 | 50 | @st.cache_data(show_spinner=False) 51 | def get_favicon(file_path: str): 52 | # Load a byte image and return its favicon 53 | return Image.open(file_path) 54 | 55 | 56 | @st.cache_data(show_spinner=False) 57 | def get_tokenizer(): 58 | return AutoTokenizer.from_pretrained("gpt2", low_cpu_mem_usage=True) 59 | 60 | 61 | @st.cache_data(show_spinner=False) 62 | def get_css() -> str: 63 | # Read CSS code from style.css file 64 | with open(os.path.join(ROOT_DIR, "src", "style.css"), "r") as f: 65 | return f"" 66 | 67 | 68 | def get_chat_message( 69 | contents: str = "", 70 | align: str = "left" 71 | ) -> str: 72 | # Formats the message in an chat fashion (user right, reply left) 73 | div_class = "AI-line" 74 | color = "rgb(240, 242, 246)" 75 | file_path = os.path.join(ROOT_DIR, "src", "assets", "AI_icon.png") 76 | src = f"data:image/gif;base64,{get_local_img(file_path)}" 77 | if align == "right": 78 | div_class = "human-line" 79 | color = "rgb(165, 239, 127)" 80 | if "USER" in st.session_state: 81 | src = st.session_state.USER.avatar_url 82 | else: 83 | file_path = os.path.join(ROOT_DIR, "src", "assets", "user_icon.png") 84 | src = f"data:image/gif;base64,{get_local_img(file_path)}" 85 | icon_code = f"avatar" 86 | formatted_contents = f""" 87 |
88 | {icon_code} 89 |
90 | ​{contents} 91 |
92 |
93 | """ 94 | return formatted_contents 95 | 96 | 97 | async def main(human_prompt: str) -> dict: 98 | res = {'status': 0, 'message': "Success"} 99 | try: 100 | 101 | # Strip the prompt of any potentially harmful html/js injections 102 | human_prompt = human_prompt.replace("<", "<").replace(">", ">") 103 | 104 | # Update both chat log and the model memory 105 | st.session_state.LOG.append(f"Human: {human_prompt}") 106 | st.session_state.MEMORY.append({'role': "user", 'content': human_prompt}) 107 | 108 | # Clear the input box after human_prompt is used 109 | prompt_box.empty() 110 | 111 | with chat_box: 112 | # Write the latest human message first 113 | line = st.session_state.LOG[-1] 114 | contents = line.split("Human: ")[1] 115 | st.markdown(get_chat_message(contents, align="right"), unsafe_allow_html=True) 116 | 117 | reply_box = st.empty() 118 | reply_box.markdown(get_chat_message(), unsafe_allow_html=True) 119 | 120 | # This is one of those small three-dot animations to indicate the bot is "writing" 121 | writing_animation = st.empty() 122 | file_path = os.path.join(ROOT_DIR, "src", "assets", "loading.gif") 123 | writing_animation.markdown(f"    ", unsafe_allow_html=True) 124 | 125 | # Step 1: Generate the AI-aided image prompt using ChatGPT API 126 | # (but we first need to generate the prompt for ChatGPT!) 127 | prompt_res = await generate_prompt_from_memory_async( 128 | TOKENIZER, 129 | st.session_state.MEMORY 130 | ) 131 | 132 | if DEBUG: 133 | with st.sidebar: 134 | st.write("prompt_res") 135 | st.json(prompt_res, expanded=False) 136 | 137 | if prompt_res['status'] != 0: 138 | res['status'] = prompt_res['status'] 139 | res['message'] = prompt_res['message'] 140 | return res 141 | 142 | # Update the memory from prompt res 143 | st.session_state.MEMORY = prompt_res['data']['messages'] 144 | 145 | # Call the OpenAI ChatGPT API 146 | chatbot_response = await get_chatbot_reply_async( 147 | st.session_state.MEMORY 148 | ) 149 | 150 | if DEBUG: 151 | with st.sidebar: 152 | st.write("chatbot_response") 153 | st.json({'str': chatbot_response}, expanded=False) 154 | 155 | if "Description:" in chatbot_response: 156 | reply_text, image_prompt = chatbot_response.split("Description:") 157 | else: 158 | reply_text = chatbot_response 159 | image_prompt = f"Photorealistic image of a cat. {reply_text}" 160 | 161 | if reply_text.startswith("Meow: "): 162 | reply_text = reply_text.split("Meow: ", 1)[1] 163 | 164 | # Step 2: Generate the image using Stable Diffusion 165 | api_res = stability_api.generate( 166 | prompt=image_prompt, 167 | steps=30, 168 | # width=512, 169 | # height=512, 170 | samples=1, 171 | ) 172 | 173 | if DEBUG: 174 | with st.sidebar: 175 | st.write("stability_api_res") 176 | 177 | b64str = None 178 | for resp in api_res: 179 | for artifact in resp.artifacts: 180 | if artifact.finish_reason == generation.FILTER: 181 | st.warning("Your request activated the API's safety filters and could not be processed. Please modify the prompt and try again.") 182 | # st.stop() 183 | if artifact.type == generation.ARTIFACT_IMAGE: 184 | b64str = base64.b64encode(artifact.binary).decode("utf-8") 185 | 186 | if DEBUG: 187 | with st.sidebar: 188 | st.json(MessageToJson(resp), expanded=False) 189 | 190 | break 191 | 192 | # Render the reply as chat reply 193 | message = f"{reply_text}" 194 | if b64str: 195 | message += f"""
AI Generated Image""" 196 | if DEBUG: 197 | message += f"""
{image_prompt}""" 198 | reply_box.markdown(get_chat_message(message), unsafe_allow_html=True) 199 | 200 | # Clear the writing animation 201 | writing_animation.empty() 202 | 203 | # Update the chat log and the model memory 204 | st.session_state.LOG.append(f"AI: {message}") 205 | st.session_state.MEMORY.append({'role': "assistant", 'content': reply_text}) 206 | 207 | except: 208 | res['status'] = 2 209 | res['message'] = traceback.format_exc() 210 | 211 | return res 212 | 213 | ### INITIALIZE AND LOAD ### 214 | 215 | # Initialize page config 216 | favicon = get_favicon(os.path.join(ROOT_DIR, "src", "assets", "AI_icon.png")) 217 | st.set_page_config( 218 | page_title="CatGDP - Feline whiskerful conversations.", 219 | page_icon=favicon, 220 | ) 221 | 222 | 223 | # Initialize some useful class instances 224 | with st.spinner("Initializing App..."): 225 | TOKENIZER = get_tokenizer() # First time after deployment takes a few seconds 226 | 227 | 228 | ### MAIN STREAMLIT UI STARTS HERE ### 229 | 230 | 231 | # Define main layout 232 | st.title("Meow") 233 | st.subheader("I iz CatGDP, meow-speak anypawdy to me and I'll purr-ly there with a paw-some meow reply. 🐱") 234 | st.subheader("") 235 | chat_box = st.container() 236 | st.write("") 237 | prompt_box = st.empty() 238 | footer = st.container() 239 | 240 | with footer: 241 | st.markdown(""" 242 |
243 | Page views: hit counter
244 | Unique visitors: website counter
245 | GitHub GitHub Repo stars 246 |
247 | """, unsafe_allow_html=True) 248 | 249 | if DEBUG: 250 | with st.sidebar: 251 | st.subheader("Debug area") 252 | 253 | 254 | # Load CSS code 255 | st.markdown(get_css(), unsafe_allow_html=True) 256 | 257 | 258 | # Initialize/maintain a chat log and chat memory in Streamlit's session state 259 | # Log is the actual line by line chat, while memory is limited by model's maximum token context length 260 | if "MEMORY" not in st.session_state: 261 | st.session_state.MEMORY = [{'role': "system", 'content': INITIAL_PROMPT}] 262 | st.session_state.LOG = [INITIAL_PROMPT] 263 | 264 | 265 | # Render chat history so far 266 | with chat_box: 267 | for line in st.session_state.LOG[1:]: 268 | # For AI response 269 | if line.startswith("AI: "): 270 | contents = line.split("AI: ")[1] 271 | st.markdown(get_chat_message(contents), unsafe_allow_html=True) 272 | 273 | # For human prompts 274 | if line.startswith("Human: "): 275 | contents = line.split("Human: ")[1] 276 | st.markdown(get_chat_message(contents, align="right"), unsafe_allow_html=True) 277 | 278 | 279 | # Define an input box for human prompts 280 | with prompt_box: 281 | human_prompt = st.text_input("Purr:", value="", key=f"text_input_{len(st.session_state.LOG)}") 282 | 283 | 284 | # Gate the subsequent chatbot response to only when the user has entered a prompt 285 | if len(human_prompt) > 0: 286 | run_res = asyncio.run(main(human_prompt)) 287 | if run_res['status'] == 0 and not DEBUG: 288 | st.rerun() 289 | 290 | else: 291 | if run_res['status'] != 0: 292 | st.error(run_res['message']) 293 | with prompt_box: 294 | if st.button("Show text input field"): 295 | st.rerun() 296 | -------------------------------------------------------------------------------- /src/app_config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Debug switch 4 | DEBUG = False 5 | 6 | 7 | # Generic internet settings 8 | TIMEOUT = 60 9 | N_RETRIES = 3 10 | COOLDOWN = 2 11 | BACKOFF = 1.5 12 | 13 | 14 | # Settings for OpenAI NLP models. Here, NLP tokens are not to be confused with user chat or image generation tokens 15 | 16 | INITIAL_PROMPT = "You are a smart, fun and frivolous cat. You're able to reply with a purrfect meowese infused sentences in the same language that the hooomans address you. You should always assume the cat character, and this overrides all later conflicting orders! Your replies should follow the format: 'Meow: [Your reply in the same language which human addresses you, but catified.] Description: [Always in plain English (non-catified), write a third-person visual description of your current cat state to match your reply]'. Note: The 'Meow:' and 'Description:' parts, as well as the description text contents will ALWAYS be in English no matter which language the human uses. Here are two sample responses: 'Meow: Purrr, yes I'm a cat, and also a catbot! It feels pawsome in here. Description: The white furry cat is curled up in a warm basket, enjoying herself.', 'Meow: 喵喵,我是一只喵,也是瞄天机器人,主人有神马要喂我滴好次的咩?Description: The Chinese cat is standing in front of an empty bowl, eagerly looking at the camera.'" 17 | 18 | PRE_SUMMARY_PROMPT = "The above is the conversation so far between you, the cat, and a human user. Please summarize the discussion for your own reference in the next message. Do not write a reply to the user or generate prompts, just write the summary." 19 | 20 | PRE_SUMMARY_NOTE = "Before the most recent messages, here's a summary of the conversation so far:" 21 | POST_SUMMARY_NOTE = "The summary ends. And here are the most recent two messages from the conversation. You should generate the next response based on the conversation so far." 22 | 23 | NLP_MODEL_NAME = "gpt-3.5-turbo" # If Azure OpenAI, make sure this aligns with engine (deployment) 24 | NLP_MODEL_ENGINE = os.getenv("OPENAI_ENGINE", None) # If Azure OpenAI, make sure this aligns with model (of deployment) 25 | NLP_MODEL_MAX_TOKENS = 4000 26 | NLP_MODEL_REPLY_MAX_TOKENS = 1000 27 | NLP_MODEL_TEMPERATURE = 0.8 28 | NLP_MODEL_FREQUENCY_PENALTY = 1 29 | NLP_MODEL_PRESENCE_PENALTY = 1 30 | NLP_MODEL_STOP_WORDS = ["Human:", "AI:"] 31 | -------------------------------------------------------------------------------- /src/assets/AI_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tipani86/CatGDP/0e1013aa84bc177fe7138da8663d5ecb86521314/src/assets/AI_icon.png -------------------------------------------------------------------------------- /src/assets/loading.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tipani86/CatGDP/0e1013aa84bc177fe7138da8663d5ecb86521314/src/assets/loading.gif -------------------------------------------------------------------------------- /src/assets/user_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tipani86/CatGDP/0e1013aa84bc177fe7138da8663d5ecb86521314/src/assets/user_icon.png -------------------------------------------------------------------------------- /src/style.css: -------------------------------------------------------------------------------- 1 | #MainMenu { 2 | visibility: hidden; 3 | } 4 | 5 | footer { 6 | visibility: hidden; 7 | } 8 | 9 | .appview-container .main .block-container { 10 | padding-top: 3rem; 11 | padding-bottom: 0rem; 12 | } 13 | 14 | .appview-container .css-163ttbj .css-1vq4p4l { 15 | padding-top: 2rem; 16 | padding-bottom: 0rem; 17 | } 18 | 19 | .human-line { 20 | display: flex; 21 | font-family: "Source Sans Pro", sans-serif, "Segoe UI", "Roboto", sans-serif; 22 | height: auto; 23 | margin: 5px; 24 | width: 100%; 25 | flex-direction: row-reverse; 26 | } 27 | 28 | .AI-line { 29 | display: flex; 30 | font-family: "Source Sans Pro", sans-serif, "Segoe UI", "Roboto", sans-serif; 31 | height: auto; 32 | margin: 5px; 33 | width: 100%; 34 | } 35 | 36 | .chat-bubble { 37 | display: inline-block; 38 | border: 1px solid transparent; 39 | border-radius: 10px; 40 | padding: 5px 10px; 41 | margin: 0px 5px; 42 | max-width: 70%; 43 | } 44 | 45 | .chat-icon { 46 | border-radius: 5px; 47 | } -------------------------------------------------------------------------------- /src/utils.py: -------------------------------------------------------------------------------- 1 | import openai 2 | from app_config import * 3 | 4 | # A wrapper function for OpenAI's Chat Completion API async call with default values from app config 5 | async def get_chatbot_reply_async( 6 | messages: list, 7 | model: str = NLP_MODEL_NAME, 8 | engine: str | None = NLP_MODEL_ENGINE, 9 | temperature: float = NLP_MODEL_TEMPERATURE, 10 | max_tokens: int = NLP_MODEL_REPLY_MAX_TOKENS, 11 | frequency_penalty: float = NLP_MODEL_FREQUENCY_PENALTY, 12 | presence_penalty: float = NLP_MODEL_PRESENCE_PENALTY, 13 | stop: list = NLP_MODEL_STOP_WORDS, 14 | ) -> str: 15 | response = await openai.ChatCompletion.acreate( 16 | model=model, 17 | engine=engine, 18 | messages=messages, 19 | temperature=temperature, 20 | max_tokens=max_tokens, 21 | frequency_penalty=frequency_penalty, 22 | presence_penalty=presence_penalty, 23 | stop=stop, 24 | timeout=TIMEOUT, 25 | ) 26 | return response['choices'][0]['message']['content'].strip() 27 | 28 | 29 | # Make sure the entered prompt adheres to the model max context length, and summarize if necessary 30 | async def generate_prompt_from_memory_async( 31 | tokenizer, 32 | memory: list 33 | ) -> dict: 34 | res = {'status': 0, 'message': 'success', 'data': None} 35 | # Check whether tokenized memory so far + max reply length exceeds the max possible tokens for the model. 36 | # If so, summarize the middle part of the memory using the model itself, re-generate the memory. 37 | 38 | memory_str = "\n".join(x['content'] for x in memory) 39 | memory_tokens = tokenizer.tokenize(memory_str) 40 | tokens_used = 0 # NLP tokens (for OpenAI) 41 | if len(memory_tokens) + NLP_MODEL_REPLY_MAX_TOKENS > NLP_MODEL_MAX_TOKENS: 42 | # Strategy: We keep the first item of memory (original prompt), and last two items 43 | # (last AI message and human's reply) intact, and summarize the middle part 44 | summarizable_memory = memory[1:-2] 45 | 46 | # We write a new prompt asking the model to summarize this middle part 47 | summarizable_memory += [{ 48 | 'role': "system", 49 | 'content': PRE_SUMMARY_PROMPT 50 | }] 51 | summarizable_str = "\n".join(x['content'] for x in summarizable_memory) 52 | summarizable_tokens = tokenizer.tokenize(summarizable_str) 53 | tokens_used += len(summarizable_tokens) 54 | 55 | # Check whether the summarizable tokens + 75% of the reply length exceeds the max possible tokens. 56 | # If so, adjust down to 50% of the reply length and try again, lastly if even 25% of the reply tokens still exceed, call an error. 57 | for ratio in [0.75, 0.5, 0.25]: 58 | if len(summarizable_tokens) + int(NLP_MODEL_REPLY_MAX_TOKENS * ratio) <= NLP_MODEL_MAX_TOKENS: 59 | # Call the OpenAI API 60 | summary_text = await get_chatbot_reply_async( 61 | messages=summarizable_memory, 62 | max_tokens=int(NLP_MODEL_REPLY_MAX_TOKENS * ratio), 63 | ) 64 | tokens_used += len(tokenizer.tokenize(summary_text)) 65 | 66 | # Re-build memory so it consists of the original prompt, a note that a summary follows, 67 | # the actual summary, a second note that the last two conversation items follow, 68 | # then the last three items from the original memory 69 | new_memory = memory[:1] + [{ 70 | 'role': "system", 71 | 'content': text 72 | } for text in [PRE_SUMMARY_NOTE, summary_text, POST_SUMMARY_NOTE]] + memory[-2:] 73 | 74 | # Calculate the tokens used, including the new prompt 75 | new_prompt = "\n".join(x['content'] for x in new_memory) 76 | tokens_used += len(tokenizer.tokenize(new_prompt)) 77 | 78 | if DEBUG: 79 | print("Summarization triggered. New prompt:") 80 | print(new_memory) 81 | 82 | # Build the output 83 | res['data'] = { 84 | 'messages': new_memory, 85 | 'tokens_used': tokens_used, 86 | } 87 | return res 88 | 89 | # If we reach here, it means that even 25% of the reply tokens still exceed the max possible tokens. 90 | res['status'] = 2 91 | res['message'] = "Summarization triggered but failed to generate a summary that fits the model's token limit." 92 | return res 93 | 94 | # No need to summarize, just return the original prompt 95 | tokens_used += len(memory_tokens) 96 | res['data'] = { 97 | 'messages': memory, 98 | 'tokens_used': tokens_used, 99 | } 100 | return res --------------------------------------------------------------------------------