$/home/knickknack/log_tutorial


Tired of taking long scrolling screenshots? Want to upload all those awesome logs you have onto Neocities? You're in the right place!

HTML

First, create a new HTML file in Neocities and name it 'chats.html'. Here's the basic HTML structure for the chat room:


<!DOCTYPE html>
<html>
  <head>
  </head>
  <body>
    <nav class="navbar">
    ...
    </nav>
    <div class="container" id="content"></div>
    <script src="generateChat.js"></script>
  </body>
</html>
              

This is the base template I use. The '<nav>' section is just a placeholder to indicate your links to navigate around your webpage. The important bits are the bits below, the <div> and the <script>.

The <div> is the 'container' class with id 'content'. That is what will host your chatlog. The <script> is the part that links the Javascript file generateChat.js to your HTML page.

You will need BOTH for this to work!

CSS

Here is the CSS you can use to style your chat room. This is the CSS I have for Ai and Naoki's log, which you can view here. You can copy all this and save it to your Neocities. I like to have different styles per chat, but if you want to use the same styling everywhere, feel free to simply save this as 'chatstyle.css'. Keep in mind that you can feed this into GPT-4 and it can also come up with some neat CSS stylings for your webpage too!


@import url('https://fonts.googleapis.com/css2?family=Merriweather:wght@300;400;700;900&display=swap');

body {
  font-family: 'Merriweather', serif;
  background-color: #1A1A1A;  /* Dark background*/
  color: #CCCCCC;  /* Light grey for main text to ensure readability */
  margin: 0;
  padding: 0;
  line-height: 1.7;
  font-size: 18px;
}

.navbar {
  position: fixed;
  left: 0;
  top: 0;
  width: 100px;
  height: 100vh;
  background-color: rgba(25, 25, 25, 0.95);  /* Slightly lighter dark shade for the navbar */
  color: #FF4500;  /* Orange for text */
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: 20px;
  box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
  z-index: 1000;
}

.menu-container {
  display: flex;
  flex-direction: column;
}

.menu-item {
  background-color: #2C2C2C;
  border: none;
  padding: 10px 15px;
  margin-bottom: 10px;
  border-radius: 5px;
  transition: background-color 0.3s ease;
  color: #FF4500;  /* Orange for menu items */
  width: 100%;
  text-align: left;
  box-sizing: border-box;
}

.menu-item:hover {
  background-color: rgba(255, 69, 0, 0.7);
}

.container {
  max-width: 800px;
  margin: auto;
  margin-left: 220px;  
  padding: 20px;
  background-color: rgba(0, 0, 0, 0.3);  /* Translucent dark background for container */
  border-radius: 10px;
  box-shadow: 5px 5px 15px rgba(0, 0, 0, 0.2);
}

.box {
  background-color: rgba(44, 44, 44, 0.2);
  border-radius: 8px;
  margin-bottom: 20px;
  padding: 20px;
  display: flex;
  align-items: start;
}

.name {
  font-weight: bold;
  margin-bottom: 10px;
  color: #FF6347; 
}

.character-img {
  height: 60px;
  border-radius: 4px;
  margin-right: 20px;
}

.quote {
  color: #FFA07A;
}

.asterisk {
  color: #FF6347; 
  font-style: italic; 
}

.greyed {
  color: #A9A9A9;
  font-size: 80%;   /* Slightly smaller text */
  font-style: italic; 
}

.preformatted {
  background-color: #262626;  /* Dark gray for a distinct look */
  border: 2px solid #FF4500;  /* Orange border */
  padding: 10px;
  color: #E8E8E8;  /* Light gray for preformatted text */
  white-space: pre-wrap;
  white-space: -moz-pre-wrap;
  white-space: -pre-wrap;
  white-space: -o-pre-wrap;
  word-wrap: break-word;
  margin: 10px 0;
  font-family: 'Courier New', monospace;
  box-shadow: 0 0 10px rgba(255, 69, 0, 0.3);  /* Soft orange glow */
}

            

JavaScript

Below is the full Javascript. Copy this entire thing and save it in the same folder as your HTML file as 'generateChat.js'. You'll need to make some changes for this to work, which I'll go over in more detail after this code block.


// Cache DOM elements
const content = document.getElementById('content');

// The dictionary holding chat details
const chatDetails = {
  'drusilla': {
    'characters': ['Drusilla', 'Paul'],
    'stylesheet': 'log_styles/drusilla_style.css',
    'log': 'logs/drusilla.txt'
  },
  'georgia': {
    'characters': ['Georgia', 'Anise'],
    'stylesheet': 'log_styles/georgia_style.css',
    'log': 'logs/georgia.txt'
  },
  'lacarthis': {
    'characters': ['Lacarthis', 'Alec'],
    'stylesheet': 'log_styles/lacarthis_style.css',
    'log': 'logs/lacarthis.txt'
  },
  'shoujorei': {
    'characters': ['ShoujoRei', 'Ana'],
    'stylesheet': 'log_styles/shoujorei_style.css',
    'log': 'logs/shoujorei.txt'
  },
  'hao': {
    'characters': ['Chen', 'Hao'],
    'stylesheet': 'log_styles/hao_style.css',
    'log': 'logs/hao_jsonl.txt'
  }
};


const apiIcons = {
  'openai': 'icons/openai.svg',
  'claude': 'icons/claude.svg',
  'makersuite': 'icons/makersuite.svg'
};

// Compile regular expressions once
const quoteRegex = /["“â€Å"](.*?)["â€Ã¢â‚¬Â]/g;
const asteriskRegex = /\*(.*?)\*/g;
const backtickRegex = /```([^`]*?)```|``([^`]*?)``|`([^`]*?)`/gs;
const greyedRegex = /\[\]\(#'\s*(.*?)\s*'\)/g;
const codeBlockRegex = /```([\s\S]*?)```/g;
// Extract the 'name' parameter from the URL
const urlParams = new URLSearchParams(window.location.search);
const chatName = urlParams.get('name');

// If the chat name exists in the dictionary, generate the dialogue
if (chatName in chatDetails) {
  const details = chatDetails[chatName];
  generateDialogue(details.log, details.characters, details.stylesheet);
} else {
  const errorMsg = document.createElement('p');
  errorMsg.textContent = 'Woah, what are you doing here?';
  content.appendChild(errorMsg);
  
  const link = document.createElement('link');
  link.rel = 'stylesheet';
  link.href = 'log_styles/shoujorei_style.css';
  document.head.appendChild(link);
}

async function generateDialogue(textFile, characters, stylesheet) {
  try {
    const response = await fetch(textFile);
    const data = await response.text();
    
    if (textFile.includes('_jsonl')) {     //Neocities doesn't let you upload jsonl so all jsonl files are uploaded as [name]_jsonl.txt
      processJsonlData(data, characters);   
    } else {
      processTextData(data, characters);
    }

    // Update the stylesheet
    const link = document.createElement('link');
    link.rel = 'stylesheet';
    link.href = stylesheet;
    document.head.appendChild(link);
  } catch (error) {
    console.error('Error fetching or processing data:', error);
  }
}

function processTextData(data, characters) {
  const lines = data.split('\n');
  const stFormatPattern = new RegExp(`^(${characters.join('|')}):`);
  const risuFormatPattern = /^--(\w+)/;
  let dialogue = '';
  let character = '';
  let charSpeaker = '';
  let userSpeaker = 'User';
  let isFirstMessage = true;
  const fragment = document.createDocumentFragment();

  lines.forEach((line, index) => {
    if (stFormatPattern.test(line)) {
      // ST format
      if (index !== 0 && character) {
        addDialogue(character, dialogue, fragment);
      }
      [character, ...dialogue] = line.split(': ');
      dialogue = dialogue.join(': ') + '
'; } else if (risuFormatPattern.test(line)) { // Risu format if (character) { addDialogue(character, dialogue, fragment); isFirstMessage = false; } const match = line.match(risuFormatPattern); character = match[1]; if (!charSpeaker) { charSpeaker = character; // Try to find a second speaker from the characters array if (characters.length === 2) { userSpeaker = characters.find(c => c !== charSpeaker) || 'User'; } } dialogue = ''; } else { //Risu's txt export includes {{char}}/{{user}} in the first message. Replace {{char}} with bot name if (isFirstMessage) { line = line.replace(/{{char}}/g, charSpeaker) .replace(/{{user}}/g, userSpeaker); } dialogue += line + '
'; } }); // Add the last dialogue if (character) { addDialogue(character, dialogue, fragment); } content.appendChild(fragment); } function addDialogue(character, dialogue, fragment) { const div = document.createElement('div'); div.className = 'box'; const dialogueDiv = document.createElement('div'); const img = document.createElement('img'); img.className = 'character-img'; img.src = `../images/${character}.png`; div.appendChild(img); const name = document.createElement('p'); name.className = 'name'; name.textContent = character; dialogueDiv.appendChild(name); const para = document.createElement('p'); para.innerHTML = formatText(dialogue); dialogueDiv.appendChild(para); div.appendChild(dialogueDiv); fragment.appendChild(div); } //NOTE THAT THIS ONLY WORKS FOR ST JSONL EXPORTS function processJsonlData(data, characters) { const lines = data.split('\n'); let currentSwipeId = null; let swipes = []; let apiInfo = null; const fragment = document.createDocumentFragment(); lines.forEach(line => { if (line.trim()) { try { const jsonObj = JSON.parse(line); if (characters.includes(jsonObj.name)) { if (jsonObj.is_user) { addDialogueForJson(jsonObj.name, jsonObj.mes, null, fragment); } else { if (jsonObj.swipe_id !== undefined) { currentSwipeId = jsonObj.swipe_id; swipes = jsonObj.swipes || []; apiInfo = (jsonObj.swipe_info && jsonObj.swipe_info[currentSwipeId]) ? jsonObj.swipe_info[currentSwipeId].extra : null; } const message = (currentSwipeId !== null && currentSwipeId < swipes.length) ? swipes[currentSwipeId] : jsonObj.mes; addDialogueForJson(jsonObj.name, message, apiInfo, fragment); } } } catch (e) { console.error("Error parsing JSON line:", e); } } }); content.appendChild(fragment); } function addDialogueForJson(character, dialogue, apiInfo, fragment) { const div = document.createElement('div'); div.className = 'box'; const messageHeader = document.createElement('div'); messageHeader.className = 'message-header'; //TODO: seperate character name from image source to allow for // multiple profile pics for the same character const img = document.createElement('img'); img.className = 'character-img'; img.src = `../images/${character}.png`; img.alt = character; messageHeader.appendChild(img); const nameApiContainer = document.createElement('div'); nameApiContainer.className = 'name-api-container'; const name = document.createElement('span'); name.className = 'name'; name.textContent = character; nameApiContainer.appendChild(name); if (apiInfo && apiInfo.api && apiInfo.model) { const apiDiv = document.createElement('div'); apiDiv.className = 'api-info'; if (apiIcons[apiInfo.api]) { const apiIcon = document.createElement('img'); apiIcon.src = apiIcons[apiInfo.api]; apiIcon.className = 'api-icon'; apiIcon.alt = apiInfo.api; apiDiv.appendChild(apiIcon); } const modelSpan = document.createElement('span'); modelSpan.className = 'model-info'; modelSpan.title = apiInfo.model; modelSpan.textContent = apiInfo.model.split('-')[0]; apiDiv.appendChild(modelSpan); nameApiContainer.appendChild(apiDiv); } messageHeader.appendChild(nameApiContainer); div.appendChild(messageHeader); const para = document.createElement('div'); para.className = 'message-content'; const segments = splitDialogueIntoSegments(dialogue); segments.forEach((segment, index) => { if (segment.type === 'preformatted') { const trimmedContent = segment.content.trim(); if (trimmedContent) { const pre = document.createElement('pre'); pre.className = 'preformatted'; pre.textContent = trimmedContent; para.appendChild(pre); } } else { const paragraphs = segment.content.split('\n'); paragraphs.forEach((paragraph, pIndex) => { if (paragraph.trim()) { const p = document.createElement('p'); p.innerHTML = formatTextJson(paragraph); para.appendChild(p); } if (pIndex < paragraphs.length - 1) { para.appendChild(document.createElement('br')); } }); } }); div.appendChild(para); fragment.appendChild(div); } function splitDialogueIntoSegments(dialogue) { const segments = []; let currentSegment = { type: 'normal', content: '' }; let preformattedDepth = 0; dialogue.split('\n').forEach(line => { let index = 0; while (index < line.length) { if (preformattedDepth === 0) { const nextBacktick = line.indexOf('`', index); if (nextBacktick === -1) { currentSegment.content += line.slice(index) + '\n'; break; } else { currentSegment.content += line.slice(index, nextBacktick); if (currentSegment.content.trim()) { segments.push(currentSegment); } currentSegment = { type: 'preformatted', content: '' }; preformattedDepth = (line.startsWith('```', nextBacktick)) ? 3 : 1; index = nextBacktick + preformattedDepth; } } else { const nextBacktick = line.indexOf('`'.repeat(preformattedDepth), index); if (nextBacktick === -1) { currentSegment.content += line.slice(index) + '\n'; break; } else { currentSegment.content += line.slice(index, nextBacktick); if (currentSegment.content.trim()) { segments.push(currentSegment); } currentSegment = { type: 'normal', content: '' }; index = nextBacktick + preformattedDepth; preformattedDepth = 0; } } } }); if (currentSegment.content.trim()) { segments.push(currentSegment); } return segments; } function formatText(text) { return text.replace(/“([^”]+)”/g, '“$1”') .replace(/“(.*?)”/, '“$1”') .replace(/“([^â€]+)”/g, '“$1”') .replace(/"(.*?)"/g, '"$1"') // Quotes .replace(/\*(.*?)\*/g, '$1') // Asterisks .replace(/```([^`]*?)```/gs, '
$1
') // Triple backticks .replace(/``([^`]*?)``/gs, '
$1
') // Double backticks .replace(/`([^`]*?)`/gs, '
$1
') // Single backticks .replace(/\[\]\(#'\s*(.*?)\s*'\)/g, '$1'); } function formatTextJson(text) { return text .replace(/“([^”]+)”/g, '“$1”') .replace(/“(.*?)”/, '“$1”') .replace(/“([^â€]+)”/g, '“$1”') .replace(/"(.*?)"/g, '"$1"') // Quotes .replace(asteriskRegex, '$1') .replace(greyedRegex, '$1'); }

Okay! Now let's get down to brass tacks.

Uploading the Files

First, let's take a gander at my neocities setup.

A screenshot of my Neocities setup
My Neocities setup

You can see that chats.html and generateChat.js are in the same directory. I also have three folders: images, log_styles, and logs. Let's take a look at each of them.

Contents of my images folder
images folder

My Images folder hosts the avatars of the characters.

Contents of my log_styles folder
log_styles folder

My log_styles folder contains the CSS files for each of my chats.

Contents of my logs folder
logs folder

My logs folder contains the .txt SillyTavern exports for the logs. My code also works for Risu .txt exports as well.


Let's say you want to upload your very first log. What you're going to do is:

1. Create the folders: NeoCities doesn't come with folders set up beforehand. Click on New Folder in the home directory, and create an images folder, a log_styles folder, and a logs folder.

2. Upload the images to the Images folder: Navigate to the Images folder and upload whatever images you want to use for the characters. They must be in PNG format, and they must match the names of the characters in the 'Characters' argument of the above dictionary. So for instance, if you have a chat between Esther and Anon, you must upload the images as 'Esther.png' and 'Anon.png' respectively.

3. Upload the log to the Logs folder: Navigate to the logs folder and upload the log txt file you've exported. If you are exporting a jsonl file, you MUST update the jsonl file to 'logname_jsonl.txt'. This is because Neocities does not allow JSONL uploads.

4. Upload the CSS to the logs_styles folder (Optional): If you're using a unique CSS theme for your new log, upload your unique CSS file to the logs_styles folder. You can skip this step if you're using the same CSS for everything.

Cool! Now you have everything you need. The next thing you're going to wanna do is go inside generateChat.js. In Neocities, hover and click on 'edit' to dive in. There is only one thing you'll need to change.

Updating The Chat Dictionary

This holds the chat data. As you can see, there are three entries here: Drusilla, ShoujoRei, and Hao.


// The dictionary holding chat details
const chatDetails = {
  'drusilla': {
    'characters': ['Drusilla', 'Paul'],
    'stylesheet': 'log_styles/drusilla_style.css',
    'log': 'logs/drusilla.txt'
  },
  'shoujorei': {
    'characters': ['ShoujoRei', 'Ana'],
    'stylesheet': 'log_styles/shoujorei_style.css',
    'log': 'logs/shoujorei.txt'
  },
  'hao': {
    'characters': ['Chen', 'Hao'],
    'stylesheet': 'log_styles/hao_style.css',
    'log': 'logs/hao_jsonl.txt'
  }
};
            

Let's break down the values.

1. Characters: This denotes the names of the characters involved in the chat. The names here MUST exactly match the names of the characters in the text file. So for instance, if you're playing around with Inquisitor Elyria, and you put in 'Elyria' as the character name in this array instead of 'Inquisitor Elyria', this will not work. You must put in 'Inquisitor Elyria'.

2. Stylesheet: This is the link to whatever CSS stylesheet you want to use for this chat. I like having different stylesheets for each chat, but if you're not into that, feel free to use the same stylesheet everywhere.

3. Log: This is the most important part: the link to the log file itself. Note that you can upload either txt files or JSONL files; if you are uploading a JSONL file, however, it must be saved as [log_name]_jsonl.txt, because Neocities does not allow raw jsonl to be uploaded.

Now, if you happen to also have a Drusilla chat with characters named Drusilla and Paul, a ShoujoRei chat with ShoujoRei and Ana, or a Hao chat with Chen and Hao, you'd be all set! But I suspect you may have different chats than me. So, you're going to have to update this dictionary to fit whatever your chats are. Remove Drusilla, ShoujoRei, and Hao from your version of the file.

Now, remember all the files you uploaded before? Here's where they come into play. Let's continue using the Esther and Anon example before, and let's say you have your Esther log saved as 'vampire_loving.txt', and that you're using the same stylesheet for everything, 'chatstyle.css', which you've saved inside your log_styles folder.

Your new chatDetails dictionary will look like:


// The dictionary holding chat details
const chatDetails = {
  'esther': {
    'characters': ['Esther', 'Anon'],
    'stylesheet': 'log_styles/chatstyle.css',
    'log': 'logs/vampire_loving.txt'
  }
};
            

Awesome! That's all you need to do. Now hit save, and let's move on to the next part...actually getting to your fancy new log page!

Navigating to the Log Page

Remember the first part of this tutorial, with the HTML sample and how we named it chats.html? You may be wondering how you can actually view your log, because all you see when you go to chats.html is an error message saying it can't find any logs. That's because chats.html needs to take in an argument in order to load the log.

Let's take a look at a snippet of HTML I have on my Logs page.

<h3><a href="/chats.html?name=drusilla">Log with Drusilla (heyshitkan)</a></h3>
<div class="desc">
  Paul's the bottom of the vampiric foodchain as a good-for-nothing Caitiff, with a ghoul who's dangerously unstable and addicted to his blood. Not that his sob story's gonna move the rent collectors. With him on the outs with the local Baroness, Paul and Drusilla turn to the next best option: petty cons.
  <br/><br/>
  This is the bot that I developed the character of <a href="bots.html#paul">Paul</a> with. Check out the rest of heyshitkan's bots <a href="https://www.chub.ai/users/heyshitkan">here!</a>
</div>

In particular, note where the href in line 1 is pointing to. It's calling '/chats.html?name=drusilla'.

What this means is that it's passing in an argument 'name' with value 'drusilla' to the chats.html page. So how do you get to your new Esther log? Well, long story short, it corresponds to whatever is the key value you have in your character dictionary. So if the key value is 'esther', then the argument you pass in will be 'esther'. So, you'll have to call '/chats.html?name=esther'.

Voila! That should work!

FAQ

It doesn't work! I see the same 'This log does not exist!' error!

Okay, let's go back to generateChats.js, and let's take a look at the chatDetails dictionary.

const chatDetails = {
  'esther': {
    'characters': ['Esther', 'Anon'],
    'stylesheet': 'log_styles/chatstyle.css',
    'log': 'logs/vampire_loving.txt'
  }
};

The argument that you pass in as the name should EXACTLY match the key value of the log you want to access. What is the key value for 'esther'? In this case, the key value is Line 2 of the code block, 'esther'. Still unclear? Let's take a look at the ShoujoRei and Drusilla dictionary.

const chatDetails = {
  'drusilla': {
    'characters': ['Drusilla', 'Paul'],
    'stylesheet': 'log_styles/drusilla_style.css',
    'log': 'logs/drusilla.txt'
  },
  'shoujorei': {
    'characters': ['ShoujoRei', 'Ana'],
    'stylesheet': 'log_styles/shoujorei_style.css',
    'log': 'logs/shoujorei.txt'
  }

Okay. In this case, the key value of the Drusilla log is on line 2, 'drusilla'. And the key value of the ShoujoRei log is on line 7, 'shoujorei'. Thus, the argument you want to pass in to access the Drusilla log would be '/chats.html?name=drusilla', and the argument for the ShoujoRei log would be ''/chats.html?name=shoujorei'.

If you are still experiencing this issue, please reach out to me at oeufvivant@protonmail.com!

My chat images are broken. Why?

There are a couple of possibilities for this.

Firstly, the Javascript code is hardcoded to assume that your chat images are PNG files. If they're JPGs, then this won't work (however, you can always update the Javascript code to assume JPG instead, or what have you!).

Secondly, the filenames of the image files MUST exactly match the names of the characters in the character array of the dictionary. And yes, it is case-sensitive!

If neither of these seem to be the issue, shoot me an email and I'll try to help out.

Wait, images are tied to character names? That sucks! What if I want to use different avatars for the same character across logs?

Yeah, I know. When making this setup, I wanted to make it as easy and as quick to start up as possible. Later, I may create a more advanced version that takes into account different avatars across logs for the same character.

I'm getting weird errors. Nothing works. My Neocities is blowing up. What's going on?

The changes here should not affect any of your other Neocities pages, and should be contained only to when you access 'chats.html', unless you did something strange. If you think it's something from this tutorial, shoot me an email at oeufvivant@protonmail.com with an attached screenshot and output from Developer Tools (F12 -> Console).