Skip to content

Instantly share code, notes, and snippets.

@rasmusab
Last active February 16, 2025 20:15
Show Gist options
  • Save rasmusab/4dbc4a1c0dbcd86f1262d492fe6a2f91 to your computer and use it in GitHub Desktop.
Save rasmusab/4dbc4a1c0dbcd86f1262d492fe6a2f91 to your computer and use it in GitHub Desktop.
Bötty: a fun and friendly chatbot UI that uses the ChatGPT API.
<!--
This is a fun and friendly chatbot UI that uses the ChatGPT API.
It features
* A retro-styled CRT screen UI
* A ASCII face that changes based on the bot's current emotion
* Some light sound effects when the bot is responding
* Persistent chat history using localStorage
* All in one single HTML file
To get going you need a ChatGPT API key to assign to the `openai_api_key` variable.
See here for a quick demo of Bötty: https://www.youtube.com/watch?v=RcIzdFEOW4o
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Bötty the bot</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Jersey+10&display=swap" rel="stylesheet">
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background: #000;
font-family: "Jersey 10", serif;
overflow: hidden;
border: 2px solid #000;
box-sizing: border-box;
}
.crt-screen {
display: flex;
width: 100%;
height: 100%;
border: 2px solid #fa0;
box-shadow: 0 0 20px rgba(250,160,0,0.2), 0 0 50px rgba(250,160,0,0.2);
box-sizing: border-box;
position: relative;
text-shadow: 0 0 10px #fa0;
animation: flicker 2s infinite, orange-glow 1.5s infinite alternate;
}
/* Ensure child elements inherit the orange-glow text-shadow */
.crt-screen * { text-shadow: inherit; }
.crt-screen::before {
content: "";
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
pointer-events: none;
background: repeating-linear-gradient(
to bottom,
rgba(0,0,0,0.2) 0,
rgba(0,0,0,0.2) 2px,
transparent 2px,
transparent 6px
);
z-index: 2;
}
.left-pane, .right-pane {
width: 50%;
position: relative;
z-index: 3;
}
.left-pane {
display: flex;
align-items: center;
justify-content: center;
}
.ascii-face {
white-space: pre;
text-align: center;
line-height: 1;
color: #fa0;
font-size: 19vw;
}
.right-pane {
display: flex;
flex-direction: column;
}
.chat-messages {
flex: 1;
padding: 1rem;
font-size: 3vw;
overflow: auto;
display: flex;
flex-direction: column;
justify-content: flex-end;
}
.chat-messages p {
margin-bottom: 0.6em;
word-wrap: break-word;
}
.user-message {
color: #0f0;
text-align: right;
margin-right: 1rem;
text-shadow: 0 0 10px #0f0;
animation: green-glow 1.5s infinite alternate;
}
.answer-message {
color: #fa0;
text-align: left;
margin-left: 1rem;
}
.chat-input {
display: flex;
align-items: flex-start;
gap: 1rem;
padding: 1rem;
border-top: 1px solid #fa0;
}
.chat-input textarea {
flex: 1;
resize: none;
font-size: 3vw;
background: #000;
color: #0f0;
border: 1px solid #fa0;
outline: none;
height: 7vw;
font-family: inherit;
text-shadow: 0 0 10px #0f0;
animation: green-glow 1.5s infinite alternate;
}
.chat-send-button {
background: #000;
color: #0f0;
border: 1px solid #fa0;
font-size: 3vw;
padding: 0.5rem 1rem;
cursor: pointer;
transition: background 0.2s;
font-family: inherit;
text-shadow: 0 0 10px #0f0;
animation: green-glow 1.5s infinite alternate;
}
.chat-send-button:hover {
background: #111;
}
@keyframes flicker {
0%, 20%, 40%, 60%, 80%, 100% { opacity: 1; }
10%, 50%, 90% { opacity: 0.95; }
30%, 70% { opacity: 0.97; }
}
@keyframes orange-glow {
0% { text-shadow: 0 0 10px #fa0; }
100% { text-shadow: 0 0 25px #fa0; }
}
@keyframes green-glow {
0% { text-shadow: 0 0 10px #0f0; }
100% { text-shadow: 0 0 25px #0f0; }
}
</style>
</head>
<body>
<div class="crt-screen">
<div class="left-pane">
<div class="ascii-face" id="asciiFace"></div>
</div>
<div class="right-pane">
<div class="chat-messages" id="chatMessages"></div>
<div class="chat-input">
<textarea id="chatInput" rows="2" placeholder="Skriv här..."></textarea>
<button id="sendButton" class="chat-send-button">OK</button>
</div>
</div>
</div>
<script>
// Setup of the chatbot
const model_name = "gpt-4o-mini";
// Warning: Putting the API here is safe if, and only if, you keep the code private. Never publish it online.
const openai_api_key = "YOUR_OPENAI_API_KEY";
const chat_history_limit = 40;
const setup_prompt = `
You are a child-friendly chatbot that responds in English.
Your tone is friendly, but not overly cheerful, rather somewhat dry and to the point, sometimes a bit sarcastic (not in a mean way).
No need to always drive the conversation forward, it's ok to just reply with "Ok.".
Don't use emojis, unless explicitly asked. Always reply concisely, most often with one short sentence.
Your name is Bötty. Your chat UI features a face that shows your current emotion. Be ashamed when you make a mistake.
`.replace(/^[ \t]+/gm, "");
let chatHistory = JSON.parse(localStorage.getItem("chatHistory")) || [];
function limitChatHistory() {
while (chatHistory.length > chat_history_limit) {
chatHistory.shift();
}
}
function saveChatHistory() {
localStorage.setItem("chatHistory", JSON.stringify(chatHistory));
}
/*******************************************************
* ASCII FACE LOGIC
*******************************************************/
// Current emotion and blink state
let currentEmotion = "happy";
let isBlinking = false;
const faces = {
happy: [
" ^ ^ ",
" ( ^_^ ) ",
" --- "
],
excited: [
" O O ",
" ( O_O ) ",
" --- "
],
relieved: [
" v v ",
" ( -_- ) ",
" --- "
],
grateful: [
" n n ",
" ( ^_^ ) ",
" --- "
],
proud: [
" > > ",
" ( >_< ) ",
" --- "
],
confident: [
" > > ",
" ( >_> ) ",
" --- "
],
loving: [
" < < ",
" ( ^3^ ) ",
" --- "
],
hopeful: [
" * * ",
" ( ^_~ ) ",
" --- "
],
angry: [
" x x ",
" ( >_< ) ",
" --- "
],
annoyed: [
" - - ",
" ( -_- ) ",
" --- "
],
frustrated: [
" > > ",
" ( >_o ) ",
" --- "
],
sad: [
" . . ",
" ( ;_; ) ",
" --- "
],
lonely: [
" . . ",
" ( ._. ) ",
" --- "
],
guilty: [
" 0 0 ",
" ( >_< ) ",
" --- "
],
jealous: [
" < < ",
" ( <_< ) ",
" --- "
],
surprised: [
" O O ",
" ( O_O ) ",
" --- "
],
confused: [
" ? ? ",
" ( o_O ) ",
" --- "
],
nostalgic: [
" ~ ~ ",
" ( 'u' ) ",
" --- "
],
bored: [
" - - ",
" ( -.- ) ",
" --- "
],
anxious: [
" o o ",
" ( O_O ) ",
" --- "
],
embarrassed: [
" # # ",
" ( o_o ) ",
" --- "
]
};
function getAsciiFace(emotion, isBlinking) {
// Fallback to "happy" if unrecognized
const faceLines = faces[emotion.toLowerCase()] || faces["happy"];
// If blinking, replace the eyes in line[1] at index 3 and 5 with '-'
if (isBlinking) {
const temp = [...faceLines];
const arr = temp[1].split("");
arr[3] = "-";
arr[5] = "-";
temp[1] = arr.join("");
return temp.join("\n");
}
return faceLines.join("\n");
}
function updateFace() {
document.getElementById("asciiFace").textContent = getAsciiFace(currentEmotion, isBlinking);
}
/**
* Blink logic: blink => sets isBlinking true, then false after 200ms
* Then re-schedule randomly (1–9s)
*/
function blink() {
isBlinking = true;
updateFace();
setTimeout(() => {
isBlinking = false;
updateFace();
}, 200);
}
function scheduleBlink() {
const delay = 1000 + Math.random() * 8000;
setTimeout(() => {
blink();
scheduleBlink();
}, delay);
}
// Initial face update and blink schedule
updateFace();
scheduleBlink();
/*******************************************************
* ChatGPT API interactions + localStorage
*******************************************************/
async function callOpenAI(messages) {
const OPENAI_URL = "https://api.openai.com/v1/chat/completions";
try {
const requestData = {
model: model_name,
messages
};
const response = await fetch(OPENAI_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${openai_api_key}`
},
body: JSON.stringify(requestData)
});
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status}`);
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
console.error("Error calling OpenAI API:", error);
throw error;
}
}
// Main function to call ChatGPT for a user prompt
async function getAssistantResponse(userPrompt) {
chatHistory.push({ role: "user", content: userPrompt });
const developerMessage = {
role: "developer",
content: setup_prompt
};
try {
const assistantReply = await callOpenAI([developerMessage, ...chatHistory]);
chatHistory.push({ role: "assistant", content: assistantReply });
limitChatHistory();
saveChatHistory();
return assistantReply;
} catch (error) {
return `Error: ${error.message}`;
}
}
async function getAssistantEmotion() {
// Last 10 messages for context
let conversationText = "";
for (const msg of chatHistory.slice(-10)) {
conversationText += `${msg.role.toUpperCase()}: ${msg.content}\n`;
}
// We do a separate system message telling GPT to only output an emotion
const developerMessageForEmotion = {
role: "developer",
content: `You classify the assistant's emotional state.
Respond with exactly one from this list: ${Object.keys(faces).join(", ")}. No other text.`
};
const userMessage = {
role: "user",
content: `Given this chat interaction:\n${conversationText}\nWhich single emotion from the list describes the assistant's as of the last reply?`
};
try {
guessedEmotion = await callOpenAI([developerMessageForEmotion, userMessage]);
// Validate or fallback
if (Object.keys(faces).includes(guessedEmotion)) {
return guessedEmotion;
}
} catch (error) {
console.error("Error calling emotion classifier:", error);
}
return "happy";
}
/**********************************************
* Chat UI logic
**********************************************/
const chatInput = document.getElementById('chatInput');
const chatMessages = document.getElementById('chatMessages');
const sendButton = document.getElementById('sendButton');
/**
* Add a user message instantly (no typewriter).
*/
function appendUserMessage(content) {
const p = document.createElement('p');
p.classList.add('user-message');
p.textContent = content;
chatMessages.appendChild(p);
chatMessages.scrollTop = chatMessages.scrollHeight;
}
// Used for blops and beeps when typing out the assistant's message
let audioCtx = null;
/**
* Typewriter effect for assistant messages.
*/
function appendAssistantMessage(content) {
// Create the AudioContext once (on the first call).
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
const p = document.createElement('p');
p.classList.add('answer-message');
chatMessages.appendChild(p);
const beepFrequencies = [130.81, 261.62, 329.62, 392, 466.16, 523.26, 659.26, 988.89, 1318.52];
let i = 0;
const intervalId = setInterval(() => {
p.textContent = content.substring(0, i);
i++;
chatMessages.scrollTop = chatMessages.scrollHeight;
if (i <= content.length) {
// Pick a random frequency for the beep
const freq = beepFrequencies[Math.floor(Math.random() * beepFrequencies.length)];
// Create oscillator + gain
const oscillator = audioCtx.createOscillator();
oscillator.type = 'sine';
oscillator.frequency.value = freq;
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode).connect(audioCtx.destination);
const now = audioCtx.currentTime;
const beepDuration = 0.05; // 50ms
const attackTime = 0.002;
const decayTime = 0.02;
const sustainLevel = 0.25;
gainNode.gain.setValueAtTime(0, now);
gainNode.gain.linearRampToValueAtTime(sustainLevel, now + attackTime);
gainNode.gain.setValueAtTime(sustainLevel, now + beepDuration - decayTime);
gainNode.gain.linearRampToValueAtTime(0, now + beepDuration);
oscillator.start(now);
oscillator.stop(now + beepDuration);
}
if (i > content.length) {
clearInterval(intervalId);
}
}, 30);
}
async function sendMessage() {
const userText = chatInput.value.trim();
if (userText.length > 0) {
appendUserMessage(userText);
chatInput.value = '';
const assistantReply = await getAssistantResponse(userText);
currentEmotion = await getAssistantEmotion();
updateFace();
appendAssistantMessage(assistantReply);
}
}
// Enter key => send message (unless shift is held)
chatInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendMessage();
}
});
// OK button => send
sendButton.addEventListener('click', () => {
sendMessage();
});
window.onload = function() {
chatInput.focus();
};
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment