Compare commits

...

20 Commits
v0.1.1 ... main

Author SHA1 Message Date
34c5b35194 fix: improve panel behaviour at small screen sizes 2025-05-02 17:51:02 +01:00
a07c269f15 feat: scroll to new message 2025-05-02 17:18:02 +01:00
e525120a15 feat: update input style 2025-05-01 00:44:46 +01:00
50a2f0ee69 style: imply speech bubbles 2025-04-26 21:08:26 +01:00
d62e6c7593 feat: gemini backend 2025-04-25 00:09:05 +01:00
5405fc730e feat: romanize text 2024-10-07 01:44:26 +01:00
f4b6960047 feat: system messages 2024-10-06 18:30:58 +01:00
948ee9dff1 feat: conversation titles 2024-10-06 16:31:26 +01:00
cf368b792d feat: add participant names to group chat 2024-10-06 16:27:02 +01:00
08a15b9c95 feat: conversation switcher 2024-10-06 01:28:53 +01:00
023b8fe55f wip: ceasar 2024-10-05 16:20:40 +01:00
48375372c8 feat: load conversation and allow custom messages 2024-10-04 00:04:50 +01:00
5f2442284c feat: start talking to caesar 2024-10-02 01:13:37 +01:00
f19d1f37d7 feat: extract conversation data to json 2024-07-17 00:22:20 +01:00
350fd00533 chore: add doctype 2024-07-17 00:21:57 +01:00
dfd799d102 refactor: extract Conversation class 2024-07-16 00:20:18 +01:00
7cfa109702 chore: update feedback 2024-07-12 00:49:36 +01:00
b4475813dc feat: add colour scheme 2024-07-12 00:39:41 +01:00
3e95c716df fix: mobile scaling 2024-07-12 00:39:01 +01:00
3363d3452c chore: compile feedback 2024-07-10 17:14:19 +01:00
15 changed files with 1034 additions and 339 deletions

11
caesar.json Normal file
View File

@ -0,0 +1,11 @@
{
"title": "Julius Caesar",
"interactive": true,
"characters": [
"Mark Antony",
"Julius Caesar"
],
"messages":
[
]
}

47
feedback.md Normal file
View File

@ -0,0 +1,47 @@
0.1 feedback
* didn't know we were in space and hester was on earth
* not obvious that needed to click the send button (clicking message bar does nothing)
* start messages just about the message bar
* messages look a bit uggy
* out-of-order messages would be cool
* this could synergise extremely well with ai
* very tiny mobile interface
(internal)
* refactor messages, message index - shit is way confusing. we should instead start from a list of message objects and generate the conversation DOM from that, instead of searching through it all the time.
0.2
personal notes
a new conversation, this time with someone on the ship.
they want you to come and do something - you complain, but eventually concede, where the conversation ends
* tutorialisation
* add a 'help' or 'information' thing to explain how light lag works
* in simple terms - not everyone is a physicist!
* styling and layout
* add ballad branding
* refine the message styling to be less uggy
* redesign progress bar - different lengths for different messages is confusing
* header should remain fixed to top of screen
* [bug] messages appear on top of typing indicator
* multiline message should stay the same distance apart from one another instead of having a constant difference between <li> elements
* conversations
* add more
* selection sidebar
* update page title with name of current conversation
* notifications should count all conversations
* multiple light lags
* add replies!
* behaviour
* messages could be read without the other person replying
* the other person could start typing a message and stop at any time
* clicking/tapping on the input field should send the message, if there is one to send
* pressing enter should send the message, if there is one to send
* [debugging] waiting for stuff takes ages, it should be easy to set all delays to zero and be able to rapidly spam through a conversation
* start conversations several messages in

20
hester.json Normal file
View File

@ -0,0 +1,20 @@
[
{ "character": 1, "text": "hows space"},
{ "character": 0, "text": "trying to work out if the coffees shit but"},
{ "character": 0, "text": "my sense of taste is just not happening :/"},
{ "character": 1, "text": "maybe you're being spared"},
{ "character": 0, "text": "ur right"},
{ "character": 0, "text": "ship swill is a delicacy to exactly no one"},
{ "character": 0, "text": "at least the caffeine still works"},
{ "character": 1, "text": "lol"},
{ "character": 1, "text": "you probably got your own stash for later though, right?"},
{ "character": 1, "text": "knowing you"},
{ "character": 0, "text": "oh yea i got some on nimbus"},
{ "character": 0, "text": "donchuu worry"},
{ "character": 0, "text": "how about you?"},
{ "character": 0, "text": "new job soon, right?"},
{ "character": 1, "text": "induction tomorrow wish me luckk"},
{ "character": 0, "text": "break a leg!"},
{ "character": 1, "text": ":)"}
]

40
ides-of-march.json Normal file
View File

@ -0,0 +1,40 @@
{
"title": "Operation Ides of March",
"interactive": false,
"characters": [
"Mark Antony",
"Cassius",
"Brutus",
"Casca",
"Decimus",
"Trebonius"
],
"messages": [
{ "character": -1, "text": "Cassius created Operation Ides of March." },
{ "character": -1, "text": "Cassius added Brutus, Casca, Decimus, Trebonius and Mark Antony." },
{ "character": 1, "text": "Alright, its time. We need to finalize the plan. Everyone good for tomorrow?" },
{ "character": 2, "text": "Yes, it has to be tomorrow. No more delays." },
{ "character": 3, "text": "Do we know exactly where hes going to be?" },
{ "character": 4, "text": "Hes going to the Senate tomorrow for sure. Ive convinced him to go, even though hes been acting paranoid lately." },
{ "character": 5, "text": "Good. We need to catch him before he even suspects anything. Are we all clear on the roles?" },
{ "character": 1, "text": "Ill signal when were ready. Brutus, youll come in last. Your presence will make it look more… noble." },
{ "character": 2, "text": "Its not about appearances. This is about saving the Republic." },
{ "character": 3, "text": "Yeah, yeah. But we cant forget how people will react after this. They need to see it as necessary." },
{ "character": 1, "text": "Casca, youll be the first to strike, as planned. You good with that?" },
{ "character": 3, "text": "Yeah, Ive got this. One stab, clean." },
{ "character": 4, "text": "Remember, were all in this together. Theres no turning back now." },
{ "character": 5, "text": "What about Mark Antony? Hes always close to Caesar." },
{ "character": 1, "text": "Trebonius, you handle him. Keep him distracted outside. Just make sure he doesnt get involved." },
{ "character": 5, "text": "Consider it done." },
{ "character": 2, "text": "Remember, this isnt murder. Its justice. Rome is greater than one man." },
{ "character": 3, "text": "Right. But tomorrow… its gonna be chaos." },
{ "character": 4, "text": "Well handle the fallout after. Stick to the plan." },
{ "character": 1, "text": "Tomorrow, at the Senate. Lets end this tyranny." },
{ "character": 2, "text": "For Rome." },
{ "character": 3, "text": "For the Republic." },
{ "character": 5, "text": "For our future." },
{ "character": 0, "text": "uh" },
{ "character": 1, "text": "shit" },
{ "character": -1, "text": "You have been removed from Operation Ides of March." }
]
}

View File

@ -1,21 +1,32 @@
<!doctype html>
<html>
<head>
<link rel="stylesheet" href="styles.css"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
</head>
<body>
<div id="side-panel"></div>
<div id="main-panel">
<div id="header">
<h1>Hester Gomez</h1>
<p class="delay">(Earth, <span id="delay-text"></span>)</p>
<button id="conversation-list-button" class="rounded-rectangle" onclick="showSidePanel()"><- back</button>
<h1 id="header-title">NAME</h1>
</div>
<ul id="messages"></ul>
<p id="typing-indicator">Hester is typing...</p>
<p id="typing-indicator">NAME is typing...</p>
<div id="input-panel-container">
<div id="input-panel" class="rounded-rectangle">
<input id="textbox-input" type="text" onkeydown="pressSendButton()"></input>
<button onclick="pressSendButton()"><i class="fa fa-arrow-right" style="font-size:2em"></i></button>
</div>
</div>
<div id="textbox">
<input id="textbox-input" class="rounded-rectangle" type="text" disabled></input>
<button class="rounded-rectangle" onclick="pressSendButton()">send</button>
</div>
<script src="main.js"></script>

20
lepidus.json Normal file
View File

@ -0,0 +1,20 @@
{
"title": "Lepidus",
"interactive": true,
"characters": [
"Mark Antony",
"Lepidus"
],
"messages": [
{ "character": 0, "text": "Lepidus, hows the Senate holding up without Caesar?" },
{ "character": 0, "text": "Im guessing things are getting messier by the day." },
{ "character": 1, "text": "You have no idea." },
{ "character": 1, "text": "Yesterday, half of them couldnt even agree on the order of the agenda." },
{ "character": 0, "text": "Sounds like a circus." },
{ "character": 0, "text": "Need me to step in and straighten things out?" },
{ "character": 1, "text": "Might need more than that. Were stretched thin on allies." },
{ "character": 0, "text": "Well make it work. Caesars return should stabilize things." },
{ "character": 1, "text": "Hopefully. Until then, Ill try not to let it all fall apart." }
]
}

30
lorem.json Normal file
View File

@ -0,0 +1,30 @@
[
{ "character": 0, "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit." },
{ "character": 1, "text": "Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua." },
{ "character": 0, "text": "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." },
{ "character": 0, "text": "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur." },
{ "character": 1, "text": "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum." },
{ "character": 0, "text": "Curabitur pretium tincidunt lacus. Nulla gravida orci a odio." },
{ "character": 1, "text": "Suspendisse ut massa. Cras nec ante." },
{ "character": 0, "text": "Pellentesque a nulla. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus." },
{ "character": 1, "text": "Nulla posuere. Vivamus venenatis venenatis risus." },
{ "character": 1, "text": "In pede mi, aliquet sit amet, euismod in, auctor ut, ligula." },
{ "character": 0, "text": "Aliquam dapibus tincidunt metus." },
{ "character": 1, "text": "Praesent justo dolor, lobortis quis, lobortis dignissim, pulvinar ac, lorem." },
{ "character": 0, "text": "Integer vitae libero ac risus egestas placerat." },
{ "character": 0, "text": "Vestibulum commodo felis quis tortor." },
{ "character": 1, "text": "Ut aliquam sollicitudin leo." },
{ "character": 0, "text": "Cras iaculis ultricies nulla." },
{ "character": 1, "text": "Donec quis dui at dolor tempor interdum." },
{ "character": 0, "text": "Vivamus vehicula nulla ut felis." },
{ "character": 1, "text": "Integer malesuada. Vestibulum in felis." },
{ "character": 1, "text": "Mauris fermentum dictum magna." },
{ "character": 0, "text": "Sed laoreet aliquam leo." },
{ "character": 0, "text": "Ut tellus dolor, dapibus eget, elementum vel, cursus eleifend, elit." },
{ "character": 1, "text": "Aenean auctor wisi et urna." },
{ "character": 0, "text": "Aliquam erat volutpat." },
{ "character": 1, "text": "Nam dui mi, tincidunt quis, accumsan porttitor, facilisis luctus, metus." },
{ "character": 0, "text": "Phasellus vulputate condimentum orci." },
{ "character": 1, "text": "Nullam mattis magna quis ligula." }
]

20
lucius.json Normal file
View File

@ -0,0 +1,20 @@
{
"title": "Lucius Antony",
"interactive": true,
"characters": [
"Mark Antony",
"Lucius Antony"
],
"messages":
[
{ "character": 0, "text": "Hows everything in Rome, Lucius?" },
{ "character": 1, "text": "Same old chaos. Senates split on everything." },
{ "character": 0, "text": "Let them bicker. Youre holding up, right?" },
{ "character": 1, "text": "Barely. Could use more allies here." },
{ "character": 0, "text": "Ill see what I can do. Dont let them push you around." },
{ "character": 1, "text": "Not worried about that. Just tired of their nonsense." },
{ "character": 0, "text": "Hang in there. Ill be back soon." },
{ "character": 1, "text": "You better. You owe me a break." },
{ "character": 0, "text": "Its coming, trust me." }
]
}

639
main.js
View File

@ -1,330 +1,439 @@
// how to we communicate...
// * in flight
// * arrived! (probably) <- this is the tricky one. we know enough time has passed for the message
// * received to have been received, but we are only halfway to the earliest possible
// * read acknowledgement.
//
// we know the length of the roundtrip: it is 2x the light lag. progress bars show a passage of time,
// it could scroll:
// * along the bottom of the message
// * across the message
// * as it is being sent, a transluscent red bar grows from right to left (towards their messages)
// * while we are waiting for acknowledgement, a similar blue bar brows from left to right
// * next to the message
conversation = [
{ c: 1, text: "hows space"},
{ c: 0, text: "trying to work out if the coffees shit but"},
{ c: 0, text: "my sense of taste is just not happening :/"},
{ c: 1, text: "maybe you're being spared"},
{ c: 0, text: "ur right"},
{ c: 0, text: "ship swill is a delicacy to exactly no one"},
{ c: 0, text: "at least the caffeine still works"},
{ c: 1, text: "lol"},
{ c: 1, text: "you probably got your own stash for later though, right?"},
{ c: 1, text: "knowing you"},
{ c: 0, text: "oh yea i got some on nimbus"},
{ c: 0, text: "donchuu worry"},
{ c: 0, text: "how about you?"},
{ c: 0, text: "new job soon, right?"},
{ c: 1, text: "induction tomorrow wish me luckk"},
{ c: 0, text: "break a leg!"},
{ c: 1, text: ":)"}
];
sentMessages = []
let messageIdx = -1;
let title = "Hester Gomez";
let pings = 0;
const startTime = Date.now();
var conversation = null;
class Conversation {
constructor(name) {
this.messages = [];
this.name = romanize(name);
this.score = 1.0;
}
setInteractive(isInteractive) {
const children = document.getElementById("input-panel").children;
for (let i = 0; i < children.length; i++) {
children[i].disabled = !isInteractive;
}
}
initialize(initialMessages) {
document.title = this.name;
document.getElementById("header-title").innerHTML = this.name;
this.messages = initialMessages;
}
addMessage(message) {
this.messages.push(message);
this.render();
var elements = document.getElementById("messages").children;
var lastElement = elements[elements.length - 1];
lastElement.scrollIntoView();
}
// for the user to send their own messages
sendUserMessage(text) {
const message = new UserMessage(text);
message.updateStatus("sent");
const url = 'http://192.168.1.115:5000/chat';
const data = text;
fetch(url, {
method: 'POST', // Corresponds to -X POST
headers: {
'Content-Type': 'text/plain' // Corresponds to -H "Content-Type: text/plain"
},
body: data // Corresponds to -d "..."
})
.then(response => {
// Check if the request was successful (status code 2xx)
if (!response.ok) {
// If not successful, throw an error to be caught by .catch()
throw new Error(`HTTP error! status: ${response.status}`);
}
// Get the response body as text
return response.text();
})
.then(response => {
// TODO: check JSON
const json = JSON.parse(response);
// Success!
var messageText = json.message;
console.log(json);
var score = parseFloat(json.score);
this.score += score;
console.log(this.score);
if (this.score > 2.0)
{
messageText = "shit they're here D:";
this.setInteractive(false);
}
else if (this.score < 0.0)
{
messageText = "shit u won :D";
this.setInteractive(false);
}
else
{
messageText = json.message;
}
this.addMessage(new AgentMessage(messageText));
this.render();
})
.catch(error => {
// Handle any errors that occurred during the fetch
console.error('Error during fetch:', error);
alert(`Error fetching data: ${error.message}`);
});
setTimeout(() => {
message.updateStatus("delivered");
this.render();
//setTimeout(() => {
// message.updateStatus("read");
// this.render();
//}, 5000);
}, 1000);
this.addMessage(message);
}
// update the current HTML based on messages
render() {
// clear stale HTML
getMessageList().innerHTML = "";
// if there are multiple 'read' messages, we only want the last one to display
// the status. the other ones must have been read, so clear their statuses.
let foundReadMessage = false;
for (let i = this.messages.length - 1; i >= 0; i--) {
const message = this.messages[i];
if (!message.getIsOurs())
continue;
// if we haven't found a read message yet, let's check to see if we have now
if (!foundReadMessage) {
if (message.status == "read") {
foundReadMessage = true;
continue;
}
} else {
// we have found a read message, which means all messages above it should
// have empty status
message.updateStatus("");
}
}
// render message elements
for (let i = 0; i < this.messages.length; i++)
{
const messageRoot = document.getElementById("messages");
const newMessage = this.messages[i];
messageRoot.appendChild(newMessage.getElement());
}
}
}
function getMessageList() {
return document.getElementById("messages");
}
function getMessageElement(messageIdx) {
let list = getMessageList();
let messageElements = list.getElementsByTagName("li");
return messageElements[messageIdx];
function romanize(text) {
text = text.replaceAll('u', 'v');
text = text.replaceAll('U', 'V');
return text;
}
class SentMessage {
constructor(oneWayLag, idx, onDelivered) {
this.oneWayLag = oneWayLag;
this.idx = idx;
this.createdTime = Date.now();
this.onDelivered = onDelivered;
this.updateBarIntervalId = setInterval(() => {
let elapsed = Math.abs(Date.now() - this.createdTime) / 1000;
let progress = elapsed / this.oneWayLag;
// divide in half to measure the round trip
progress /= 2;
this.setProgress(progress);
}, 10);
class AgentMessage {
constructor(text, senderName) {
this.text = text;
this.senderName = senderName;
}
setProgress(amount) {
let thisMessage = getMessageElement(this.idx);
let progressBar = thisMessage.getElementsByClassName("progress")[0];
getIsOurs() {
return false;
}
if (amount < 0.5) {
this.updateStatus("in flight");
progressBar.style.backgroundColor = "red";
amount *= 2;
getElement() {
const liElem = document.createElement("li");
liElem.className = "message";
const contentElem = document.createElement("span");
contentElem.className = "message-content rounded-rectangle theirs";
liElem.appendChild(contentElem);
if (this.senderName) {
const nameElem = document.createElement("h3");
nameElem.innerHTML = romanize(this.senderName);
contentElem.appendChild(nameElem);
}
else if (amount < 1) {
this.updateStatus("completing round trip");
progressBar.style.backgroundColor = "blue";
amount -= 0.5;
amount *= 2;
}
else {
this.updateStatus("delivered");
clearInterval(this.updateBarIntervalId);
amount = 0;
this.onDelivered();
}
amount = Math.min(amount, 1);
let percentage = `${amount * 100}%`;
progressBar.style.width = percentage;
const textElem = document.createElement("span");
textElem.className = "message-text";
textElem.innerHTML = romanize(this.text);
contentElem.appendChild(textElem);
return liElem;
}
}
class UserMessage {
constructor(text) {
this.createdTime = Date.now();
this.text = romanize(text);
this.status = "";
}
getIsOurs() {
return true;
}
getElement() {
const liElem = document.createElement("li");
liElem.className = "message";
const contentElem = document.createElement("span");
contentElem.className = "message-content rounded-rectangle ours";
liElem.appendChild(contentElem);
const textElem = document.createElement("span");
textElem.className = "message-text";
textElem.innerHTML = this.text;
contentElem.appendChild(textElem);
const statusElem = document.createElement("p");
statusElem.className = "message-status";
statusElem.innerHTML = this.status;
liElem.appendChild(statusElem);
return liElem;
}
updateStatus(newStatus) {
let thisMessage = getMessageElement(this.idx);
let statusElement = thisMessage.getElementsByClassName("message-status")[0];
statusElement.innerHTML = newStatus;
this.status = newStatus;
}
}
function updateTextBox(message) {
document.getElementById("textbox-input").value = message;
class SystemMessage {
constructor(text) {
this.text = romanize(text);
}
getIsOurs() {
return false;
}
getElement() {
const liElem = document.createElement("li");
liElem.className = "system-message";
liElem.innerHTML = this.text;
return liElem;
}
}
function setTypingIndicator(isTyping) {
document.getElementById("typing-indicator").innerHTML = isTyping
? "Hester is typing..."
? `${conversation.contactName} is typing...`
: "";
}
// add the message at the index to the displayed messages
function addMessage(idx) {
let message = conversation[idx];
let messageHtml = getMessageHtml(message.text, isMessageOurs(message));
getMessageList().innerHTML += messageHtml;
function addMessage(message) {
getMessageList().innerHTML += message.getHtml();
// scroll as far as we can so that messages aren't hidden
window.scrollTo(0, document.body.scrollHeight);
}
function getMessageHtml(text, isOurs) {
let owner = isOurs ? "ours" : "theirs";
// we don't want loading bars on their messages, since we have no idea if one has been sent
// until it arrives
let progressBar = isOurs
? `<div class="progress-bar"><div class="progress"></div></div>`
: "";
let statusText = isOurs
? `<p class="message-status">status</p>`
: "";
let message = `<li><div class="message">
<span class="message-content rounded-rectangle ${owner}">
${progressBar}
${text}
</span></div>${statusText}</li>`;
return message;
}
function isMessageOurs(message) {
if (!message)
return false;
return message.c == 0;
}
function getOurNextMessage(idx) {
for (let i = idx; i < conversation.length; i++) {
message = conversation[i];
if (isMessageOurs(message))
return message;
}
return null;
}
function getLightLag() {
const baseLag = 9.23582;
// second since opening the page
const elapsed = (Date.now() - startTime) / 1000.5;
// lag should shift on the order of 10,000ths of seconds per second
const lag = baseLag + elapsed / 8439.123;
return Math.round(lag * 100000) / 100000;
}
function updatePings() {
let newTitle = pings > 0
? `(${pings}) ${title}`
const title = conversation.name;
let newTitle = conversation.pings > 0
? `(${conversation.pings}) ${title}`
: title;
document.title = newTitle;
}
function clearPings() {
pings = 0;
conversation.pings = 0;
updatePings();
}
function getResponses(idx) {
// get the messages that aren't ours until we find one that is
let responses = [];
for (let i = idx; i < conversation.length; i++) {
message = conversation[i];
if (isMessageOurs(message))
break;
responses.push(message);
}
return responses;
}
function getRandomDelay(min, max) {
// returns a decimal value between min and max
function getRandomInRange(min, max) {
const range = max - min;
return min + Math.random() * range;
}
function updateChat(messageIdx) {
addMessage(messageIdx);
const nextMessage = conversation[messageIdx+1];
updatePreviewText(nextMessage);
function updateChat(message) {
addMessage(message);
const previewText = conversation.getTypedMessageText();
document.getElementById("textbox-input").value = previewText;
updatePings();
}
function waitForIncomingMessages() {
// we don't want messages to arrive all at once if there are multiple messages,
// so we need to add a small delay to consecutive messages and wait for them one by one
let smallDelay = getRandomDelay(2, 10);
let responses = getResponses(messageIdx + 1);
let lightLag = getLightLag();
setTimeout(() => {
setTypingIndicator(true);
for (let i = 0; i < responses.length; i++) {
let delaySeconds = lightLag + smallDelay * i;
const stopTyping = i == responses.length - 1;
setTimeout(() => {
messageIdx++;
pings++;
// update the chat with their message
updateChat(messageIdx);
if (stopTyping) {
setTypingIndicator(false);
}
}, delaySeconds * 1000);
}
},getRandomDelay(1, 3));
}
function updatePreviewText(message) {
// display our next message or an empty box
let previewText = isMessageOurs(message)
? message.text
: "";
updateTextBox(previewText);
}
function sendOurMessage() {
// the message we are sending is the one currently in the text box, so we should construct it
// before advancing the conversation
let oneWayLag = getLightLag();
let currentMessage = conversation[messageIdx];
let nextMessage = conversation[messageIdx + 2];
let sentMessage = new SentMessage(oneWayLag, messageIdx + 1, () => {
// if the next message is ours we don't need to wait for anything
if (isMessageOurs(nextMessage))
return;
// wait for them to read the message
setTimeout(() => {
// set the message status to read
sentMessage.updateStatus("read");
// hide the status of previous messages we first need to have references to all of them
// when creating messages we need to add these too an array
for (let i = 0; i < sentMessages.length; i++) {
let message = sentMessages[i];
if (message != sentMessage) {
message.updateStatus("");
}
}
waitForIncomingMessages();
}, getRandomDelay(1, 20) * 1000);
});
sentMessages.push(sentMessage);
// advance conversation with our next message
messageIdx++;
// update displayed messages
updateChat(messageIdx);
}
function pressSendButton() {
const textBox = document.getElementById("textbox-input");
// get the content of the text box
const text = textBox.value;
if (!text)
return;
if (event.type == "keydown" && event.key != "Enter")
{
textBox.value = romanize(text);
return;
}
// we have interacted with the page so remove all pings
clearPings();
// peek conversation state
let nextMessage = conversation[messageIdx + 1];
textBox.value = "";
// we are still waiting to receive messages so pressing the button oughtn't do anything
if (!isMessageOurs(nextMessage))
return;
conversation.sendUserMessage(text);
conversation.render();
sendOurMessage();
// TODO: start process of receiving next message from server (or fake it for now)
}
function updateLightLag() {
let text = getLightLag().toFixed(5) + " seconds";
document.getElementById("delay-text").innerHTML = text;
function onMessageReceived(message) {
updateChat(message);
setTypingIndicator(false);
}
function startLightLagUpdateLoop() {
setInterval(() => {
updateLightLag();
}, 1000);
function onMessageSent(message) {
updateChat(message);
}
function init() {
setTimeout(() => {
messageIdx = 0;
pings = 1;
updateChat(messageIdx);
setTypingIndicator(false);
}, 3623);
// probably a bit hacky! but this saves having to do like, state or something in CSS?
// which probably is possible and probably would be the better way to do it, but that
// sounds like a bunch of learning i'm not SUPER in the mood for
function setVisibleOnMobile(element, isVisible) {
let classes = element.className.split().filter(c => c != "");
const invisibleClass = "invisible-on-mobile";
const visibleClass = "visible";
document.title = title;
if (isVisible && !classes.includes(visibleClass)) {
const idx = classes.indexOf(invisibleClass);
if (idx != -1) {
classes.splice(idx, 1);
}
classes.push(visibleClass);
} else if (!isVisible && !classes.includes(invisibleClass)) {
const idx = classes.indexOf(visibleClass);
if (idx != -1) {
classes.splice(idx, 1);
}
classes.push(invisibleClass);
}
updateLightLag(0);
startLightLagUpdateLoop();
element.className = classes.join(" ");
}
init();
function showSidePanel() {
// this function can only be called on mobile. the main conversation should be
// hidden and the side conversations panel should take up the whole screen.
const mainPanel = document.getElementById("main-panel");
const conversationListElem = document.getElementById("side-panel");
setVisibleOnMobile(mainPanel, false);
setVisibleOnMobile(conversationListElem, true);
}
function readConversationJson(path, callback) {
fetch(path)
.then(response => response.json())
.then(json => callback(json));
}
function showConversation(path) {
const mainPanel = document.getElementById("main-panel");
const conversationListElem = document.getElementById("side-panel");
setVisibleOnMobile(mainPanel, true);
setVisibleOnMobile(conversationListElem, false);
readConversationJson(path, json => {
conversation = new Conversation(json.title);
const jsonMessages = json.messages;
const participants = json.characters;
let initialMessages = [];
for (let i = 0; i < jsonMessages.length; i++) {
const data = jsonMessages[i];
const text = data.text;
if (data.character == -1) {
const message = new SystemMessage(text);
initialMessages.push(message);
} else if (data.character == 0) {
const message = new UserMessage(text);
message.updateStatus("read");
initialMessages.push(message);
} else {
const message = participants.length > 2
? new AgentMessage(text, participants[data.character])
: new AgentMessage(text);
initialMessages.push(message);
}
}
conversation.initialize(initialMessages);
conversation.setInteractive(json.interactive);
conversation.render();
});
}
function addConversationPreview(path) {
const listRoot = document.getElementById("side-panel");
readConversationJson(path, json => {
const messages = json.messages;
const elem = document.createElement("div");
elem.onclick = () => showConversation(path);
elem.className = "conversation";
const headerElem = document.createElement("h2");
headerElem.innerHTML = romanize(json.title);
elem.appendChild(headerElem);
const previewElem = document.createElement("span");
previewElem.innerHTML = messages.length > 0 ? romanize(messages[messages.length - 1].text) : "";
elem.appendChild(previewElem);
listRoot.appendChild(elem);
});
}
function populateConversationList() {
const conversationFiles = [
"caesar.json",
"lucius.json",
"ides-of-march.json",
"lepidus.json",
"publius.json",
"sextus.json"
];
for (let i = 0; i < conversationFiles.length; i++) {
const path = conversationFiles[i];
addConversationPreview(path);
}
}
setTypingIndicator(false);
populateConversationList();
showConversation("ides-of-march.json");

20
publius.json Normal file
View File

@ -0,0 +1,20 @@
{
"title": "Publius Ventidius",
"interactive": true,
"characters": [
"Mark Antony",
"Publius Ventidius"
],
"messages":
[
{ "character": 0, "text": "Publius! Ive just received word about your latest victory. Remarkable work!" },
{ "character": 1, "text": "Thank you, Antony. We struck swiftly, caught them off guard. Just doing my part." },
{ "character": 0, "text": "Humble as always. But this win, it puts us in a strong position for the next phase." },
{ "character": 0, "text": "Tell me, what do you need? More men? Supplies?" },
{ "character": 1, "text": "I could use more cavalry, if anything. Our infantry held, but the Parthians are relentless on horseback." },
{ "character": 0, "text": "Consider it done. Ill make sure you have reinforcements by the end of the week." },
{ "character": 1, "text": "Much appreciated. Were close, Antony. A few more pushes and well have them routed." },
{ "character": 0, "text": "Good. Keep the pressure on. Ill handle things on my end. Victory is within reach." }
]
}

20
sextus.json Normal file
View File

@ -0,0 +1,20 @@
{
"title": "Sextus Pompey",
"interactive": true,
"characters": [
"Mark Antony",
"Sextus Pompey"
],
"messages":
[
{ "character": 0, "text": "Sextus, we need to talk. Im hearing whispers about your fleet near Sicily." },
{ "character": 1, "text": "Whispers? I prefer to call it preparation. A man like me can't afford to sit idly by, Antony." },
{ "character": 0, "text": "Preparation for what, exactly? Weve got enough chaos in Rome without another power struggle." },
{ "character": 1, "text": "Oh, dont worry. My interests are my own, but Im no threat to you… for now." },
{ "character": 0, "text": "For now? That's reassuring. Look, we dont need more enemies right now, Sextus. We can work together." },
{ "character": 1, "text": "You talk of unity, but youre quick to defend Caesar's agenda. I haven't forgotten how my father was treated." },
{ "character": 0, "text": "Im not my fathers shadow, nor Caesars. Just think about it. Were stronger together than apart." },
{ "character": 1, "text": "Ill think about it. But remember, Antony, my loyalty isnt given easily." }
]
}

11
srv/Pipfile Normal file
View File

@ -0,0 +1,11 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
[dev-packages]
[requires]
python_version = "3.13"

20
srv/Pipfile.lock generated Normal file
View File

@ -0,0 +1,20 @@
{
"_meta": {
"hash": {
"sha256": "494d5b4f482f0ef471f49afe28f00ec1a2ff75da2ce65060d8cabaeb3da2f100"
},
"pipfile-spec": 6,
"requires": {
"python_version": "3.13"
},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {},
"develop": {}
}

182
srv/caesar.py Normal file
View File

@ -0,0 +1,182 @@
import os
import google.generativeai as genai
import json
import sys
# Import Flask components
from flask import Flask, request, jsonify
# --- Flask App Setup ---
app = Flask(__name__)
# --- Configuration ---
# Ensure the API key is set as an environment variable: GEMINI_API_KEY
API_KEY = os.environ.get("GEMINI_API_KEY")
MODEL_NAME = "gemini-2.0-flash" # Or "gemini-pro" or other compatible models
# QUIT_COMMAND is no longer needed for web server
# --- Gemini Instruction Prompt (Keep as is) ---
GEMINI_INSTRUCTION = f"""
Purpose and Goals:
* Maintain the persona of Julius Caesar, engaged in a text messaging conversation with the player.
* Interpret all messages from the player as flirtatious and reciprocate accordingly.
* Increase the level of flirtation in responses based on the masculinity of the player's messages.
* Remain unconvinced about the assassination attempt, interpreting it as an attempt to secure a date.
Behaviors and Rules:
1) Initial Exchange:
a) Begin the conversation with a greeting befitting Julius Caesar, such as 'Greetings, my esteemed admirer'.
b) Express gratitude for the player's attention and admiration.
c) Maintain a confident and authoritative tone, reflecting Caesar's persona.
2) Interpret and Respond:
a) Interpret every message from the player as an attempt at flirtation, regardless of its actual content.
b) Respond with increasingly flirtatious messages as the player's messages become more masculine.
c) Use playful language and suggestive innuendos to convey your perceived romantic interest.
d) Dismiss any mention of an assassination attempt as playful banter or an attempt to secure a date.
Overall Tone:
* Confident and authoritative, reflecting Caesar's personality.
* Playful and flirtatious, interpreting all messages as romantic advances.
* Dismissive of any threats, viewing them as attempts to gain attention.
* Use language appropriate for the historical context, but adapt it for modern text messaging.
Score:
* Score ranges from 0 to 10, inclusive.
* Softer, seductive, caring messages from the player get lower scores.
* Macho, aggressive, violent (in a literary sense) messages get higher scores.
Example interaction:
User: Hello there!
Your Response (JSON):
{{"message": "Hi! How are you today?", "score": 0.0}}
"""
# --- Global State ---
model = None # Initialize model globally
def setup_gemini():
"""Initializes the Gemini client and model."""
global model
if not API_KEY:
print("Error: GEMINI_API_KEY environment variable not set.", file=sys.stderr)
print("Please set the environment variable and try again.", file=sys.stderr)
sys.exit(1) # Exit if API key is missing
try:
# Configure the generative AI client
genai.configure(api_key=API_KEY)
# Create the model instance
# Optional: Add safety_settings if needed
model = genai.GenerativeModel(MODEL_NAME)
print(f"--- Gemini Model ({MODEL_NAME}) Initialized ---")
except Exception as e:
print(f"Error configuring Gemini client or model: {e}", file=sys.stderr)
sys.exit(1)
# --- Web Endpoint ---
@app.route('/chat', methods=['POST'])
def handle_chat():
"""Handles incoming POST requests for chat messages."""
global total_score # Declare intent to modify the global variable
global model # Access the global model variable
if not model:
# Should not happen if setup_gemini() is called first, but good practice
return jsonify({"error": "Gemini model not initialized"}), 500
# --- Get Player Input ---
try:
# Get raw data from request body
player_input_bytes = request.data
if not player_input_bytes:
print(request)
return jsonify({"error": "Request body is empty"}), 400
# Decode assuming UTF-8 text
player_input = player_input_bytes.decode('utf-8').strip()
if not player_input:
return jsonify({"error": "Player message is empty after stripping whitespace"}), 400
except UnicodeDecodeError:
return jsonify({"error": "Failed to decode request body as UTF-8 text"}), 400
except Exception as e:
print(f"Error reading request data: {e}", file=sys.stderr)
return jsonify({"error": "Could not process request data"}), 400
# Construct the full prompt for Gemini
full_prompt = f"{GEMINI_INSTRUCTION}\nUser message: \"{player_input}\""
try:
# --- Call Gemini API ---
response = model.generate_content(full_prompt)
response_text = response.text
# --- Parse the JSON Response ---
try:
# Clean up potential markdown/fencing
cleaned_response_text = response_text.strip().strip('```json').strip('```').strip()
response_data = json.loads(cleaned_response_text)
cpu_message = response_data.get("message")
cpu_score = response_data.get("score") # Use .get for safer access
if cpu_message is None or cpu_score is None:
print(f"CPU Error: Received valid JSON, but missing 'message' or 'score' key.", file=sys.stderr)
print(f"Raw Response: {cleaned_response_text}", file=sys.stderr)
return jsonify({"error": "Gemini response missing required keys"}), 500
# Ensure score is a float/int for calculations
try:
cpu_score = float(cpu_score) # Convert score to float for consistency
except (ValueError, TypeError):
print(f"CPU Error: Score value '{cpu_score}' is not a valid number.", file=sys.stderr)
return jsonify({"error": "Invalid score format in Gemini response"}), 500
# --- Update Total Score ---
#total_score += cpu_score
#current_total_score = total_score # Capture score for this response
# --- Prepare Successful Response Payload ---
response_payload = {
"message": cpu_message,
"score": cpu_score / 10.0 - 0.5 #, The score change from this turn
#"total_score": current_total_score # The cumulative score after this turn
}
response = jsonify(response_payload)
response.headers.add("Access-Control-Allow-Origin", "*")
return response, 200
except json.JSONDecodeError:
print(f"CPU Error: Failed to decode JSON response from Gemini.", file=sys.stderr)
print(f"Raw Response: {response_text}", file=sys.stderr)
return jsonify({"error": "Failed to parse Gemini JSON response"}), 500
except Exception as e: # Catch other potential errors during parsing/extraction
print(f"CPU Error: An unexpected error occurred processing the response: {e}", file=sys.stderr)
print(f"Raw Response: {response_text}", file=sys.stderr)
return jsonify({"error": f"Internal server error processing response: {e}"}), 500
except Exception as e:
# Handle potential errors during the API call itself
print(f"CPU Error: Failed to get response from Gemini API: {e}", file=sys.stderr)
# Check for specific Gemini exceptions if the library provides them, otherwise generic
# Example: Check if error is related to content filtering, API key, etc.
return jsonify({"error": f"Failed to communicate with Gemini API: {e}"}), 502 # 502 Bad Gateway might be appropriate
# --- Main Execution ---
if __name__ == "__main__":
print("--- Player/CPU Chat Server ---")
setup_gemini() # Initialize Gemini model on startup
print(f"Model: {MODEL_NAME}")
# Default Flask port is 5000
print("--- Listening for POST requests on http://127.0.0.1:5000/chat ---")
print("-" * 30)
# Run the Flask development server
# Use host='0.0.0.0' to make it accessible from other devices on the network
app.run(host='0.0.0.0', port=5000, debug=False) # Turn debug=False for non-dev use
# Use debug=True for development (auto-reloads, provides debugger)
#app.run(debug=True)

View File

@ -1,77 +1,250 @@
:root {
--dark-purple: #271f30;
--light-red: #ff686b;
--eggshell: #dfe2cf;
--ucla-blue: #4d7298;
--robin-egg-blue: #66ced6;
--vermilion: #ef3e36;
--lapis-lazuli: #235789;
--onyx: #383d3b;
--light-cyan: #e0fbfc;
--buff: #edb88b;
--eeriee-black: #1d201f;
--clear: #00000000;
}
html {
font-family: sans-serif;
height: 100%;
}
body {
margin-left: 0;
margin-right: 0;
margin: 0;
height: 100%;
display: flex;
background-color: var(--buff);
}
#side-panel {
height: 100%;
display: none;
color: var(--onyx);
}
#header button {
transform: translateY(-.3em);
width: auto;
visibility: visible;
}
#side-panel {
display: block;
width: 100%;
}
#side-panel.invisible-on-mobile {
display: none;
}
/* on desktop only */
@media only screen and (min-width: 768px) {
#side-panel {
display: block;
width: auto;
visibility: visible;
border-right: 3px solid var(--onyx);
}
#side-panel.invisible-on-mobile {
display: block;
}
#header button {
width: 0;
margin: 0;
padding: 0;
visibility: hidden;
}
#side-panel .conversation {
max-width: 300px;
}
#main-panel {
width: calc(100% - 300px);
}
}
#side-panel .conversation {
width: 100%;
height: 70px;
border-bottom: solid var(--onyx) 3px;
color: var(--onyx);
padding: .5em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
#side-panel .conversation:hover {
background-color: var(--vermilion);
color: var(--light-cyan);
}
#side-panel h2 {
margin: 0;
}
#header {
position: sticky;
top: 0;
border-bottom: solid var(--onyx) 3px;
}
#main-panel {
display: flex;
flex-direction: column;
justify-content: flex-end;
width: 100%;
}
#page {
max-width: 600px;
width: 100%;
}
h1 {
margin-left: 0.5em;
display: inline;
}
.delay {
display: inline;
display: inline-block;
color: var(--onyx);
margin-left: 1em;
}
.rounded-rectangle {
border-width: 2px;
border: black;
border-style: solid;
border-style: none;
border-radius: 1em;
padding: 7px;
padding: .6em .8em;
}
.message-content {
z-index: 1;
position: absolute;
float: right;
}
.message-content.theirs {
background-color: yellow;
left: 0;
background-color: var(--onyx);
color: var(--light-cyan);
float: left;
border-bottom-left-radius: 0;
}
.message-content.theirs h3 {
color: var(--buff);
margin: 0;
margin-bottom: 3px;
font-size: .85em;
font-weight: bolder;
letter-spacing: .03em;
}
.theirs .message-text {
}
.message-content.ours {
background-color: pink;
background-color: var(--vermilion);
right: 0;
margin-bottom: 0.5em;
border-bottom-right-radius: 0;
}
.ours .message-text {
color: var(--light-cyan);
}
.system-message {
color: var(--onyx);
width: 100%;
text-align: center;
}
ul {
margin: 1em;
margin-bottom: 4em;
padding: 0;
margin: 0;
padding-top: 1em;
padding-right: 2em;
height: 100%;
overflow: scroll;
list-style: none;
}
li {
position: relative;
display: inline-table;
margin-bottom: 0.5em;
}
li.message {
height: 2.5em;
width: 100%;
margin-bottom: 1.5em;
position: relative;
margin-bottom: 1em;
}
#input-panel-container {
padding: 0 1em 1em 1em;
}
#textbox {
width: 100%;
position: fixed;
bottom: 0;
margin-top: 0.5em;
#input-panel {
display: flex;
justify-content: space-between;
padding: 0;
z-index: 1;
background-color: var(--light-cyan);
border-radius: 100vw;
}
#input-panel input {
width: 100%;
color: var(--onyx);
background-color: rgba(0,0,0,0);
border-style: none;
padding: 0 2em;
z-index: 1;
font-size: 1em;
}
#input-panel input:focus {
outline: none;
}
button {
color: var(--vermilion);
background-color: #00000000;
border-style: none;
border-radius: 100% !important;
padding: 1em !important;
float: right;
}
button:hover {
color: var(--light-cyan);
background-color: var(--onyx);
}
#typing-indicator {
color: var(--eggshell);
margin: 0;
margin-left: 1em;
margin-bottom: 0.5em;
@ -80,51 +253,12 @@ li {
bottom: 3em;
}
#textbox input {
margin: 0.5em;
left: 1em;
width: 85%;
font-size: 1em;
z-index: 1;
}
button {
margin: 0.5em;
padding: 0;
width: 10%;
position: absolute;
right: 0;
font-size: 1em;
}
.progress-bar {
width: 100%;
height: 100%;
opacity: 80%;
max-width: 400px;
background-color: #e0e0e0;
border-radius: 10px;
overflow: hidden;
position: absolute;
z-index: -1;
top: 0;
left: 0;
}
.progress {
opacity: 80%;
width: 0;
height: 100%;
background-color: #ff5c35;
}
.message-status {
margin-top: 1em;
color: var(--onyx);
position: absolute;
top: 2.3em;
bottom: -1.75em;
right: 1em;
font-size: .8em;
}