├── .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 | 
11 |
12 |
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"
"
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"""
"""
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: 
244 | Unique visitors: 
245 | GitHub
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
--------------------------------------------------------------------------------