Code viewer for World: Infinite story book

// Use JS to write whatever HTML and data you want to the page 

// One way of using JS to write HTML and data is with a multi-line string
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Template_literals

// Tweaker's Box
// =============================================================================

// https://platform.openai.com/docs/guides/images/introduction?context=node
// Options for Models
// "dall-e-2" - DALL-E 2 Model
// "dall-e-3" - DALL-E 3 Model
const MODEL = "dall-e-3";

// Set the resolution of the image returned
// Dalle 2 -> "256x256", "512x512", "1024x1024"
// Dalle 3 -> "1024x1024", "1024x1792", "1792x1024"
const RESOLUTION = MODEL === "dall-e-3" ? "1024x1024" : "256x256";

// Set image quality
// Options
// "standard"
// "hd" (Will fucntion for both models but only provides meaning for DALLE-3)
const QUALITY = "standard";

//Options for mode
// "prompt" - Uses the user supplied prompt to create both the image and text
// "image" - Only supported by DALL-E 3. Use the enhanced prompt used to create the image to create the accompanying story
// "story" - Uses the story created by the langauge model as the input to the image model
// WARNING - Only "prompt" can make requests in sync. This leads to much longer load times for "image" and "story" 
const MODE = "story";

// The word limit given to the llm i.e. "generate me a story about a duck who get lost in no more than <WORD_LIMIT> words"
const WORD_LIMIT = 180;

// https://platform.openai.com/docs/guides/text-generation/completions-api
// https://ancientbrain.com/world.php?world=2850716357

// Select the LLM Model
const LLM_MODEL = "gpt-3.5-turbo";

// Control LLM Creativity 0-2
// 0 is the least creative
// 2 is the most creative
const TEMPERATURE = 1;

const OPENAI_GENERATION_URL = "https://api.openai.com/v1/images/generations";
const OPENAI_CHAT_URL = "https://api.openai.com/v1/chat/completions";

// =============================================================================

// Inital page load with cover/apikey form
document.write ( `
<div class="content-container">
    <div class="title-block">
        <h1>Generative AI - Infinite Story Book</h1>
        <p>Welcome to the “Infinite Story Book”. To start your journey, enter a valid OpenAI API key and click the arrow  (Or press Enter) to proceed.
    Enter your story idea in the input on the left page. Once your happy, click the "Generate" and watch as your story comes to life. Flip to the next page and repeat as long as your heart desires.
    </p>
        <p>This page uses OpenAI’s Image Generation and Text Generation APIs.</p>
    </div>
    
    
    <div id="book-block"  class="book-block">
        <div class="cover-background">
            <div class="cover-background-pages">
            </div>
            <div class="cover-accent-line">
            </div>
            <div class="cover">
                <div class="cover-content">
                    <div class="cover-title">Infinite Stories</div>
                    <div class="apikey-form">
                        <input id="apikey-input" class="apikey-input" placeholder="Enter Your API Key..."/>
                        <button id="apikey-submit" class="apikey-submit">></button>
                    </div>
                </div>
            </div>
        </div>
    </div>
    
    <div class="details-block">
        <h4>Details:</h4>
        <p>
            Model: ${MODEL}
            <br />
            Mode: ${MODE}
        </p>
    </div>
</div>



` );

// HTML template for the open book layout
const openBookTemplate = `
    <div class="book-background">
        <div id="text-page" class="text-page">
            <h2 id="page-title"></h2>
            <div id="page-content"></div>
        </div>
        <div class="image-page">
            <div id="image-content"></div>
        </div>
        <button id="page-back" class="page-btn" disabled><</button>
        <button id="page-forward" class="page-btn" disabled>></button>
        <div id="left-page-number">1</div>
        <div id="right-page-number">2</div>
        <svg class="bookmark">
            <rect x="0" width="35" height="40" fill="#BFBB4E"/>
            <polygon points="0 40, 35 40, 0 75" fill="#BFBB4E"/>
        </svg>
    </div>
`;

// Form template for user prompt input
const promptForm = `
<div>
    <input id="prompt-field" name="prompt-field" type="text" placeholder="Enter Prompt"/>
    <button id="prompt-submit" disabled>Generate</button>
</div>
`;

class Page{
    constructor(title, text, imageURL){
        this.title = title;
        this.text = text;
        this.imageURL = imageURL;
    }
}

//Pagination logic varibales
const pages = [];
let pageCount = 0;
let backBtn;
let forwardBtn;

let apiKey = "";
let loading = false;

//API Form inputs
const keySubmitBtn = document.getElementById("apikey-submit");
const keyField = document.getElementById("apikey-input");

// Create HTTP Header in the format required for both OpenAI API's
const getOpenAIHeader = () => {
    return {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": "application/json"
    };
};

// Build and Fetch request to Image Generation API: https://api.openai.com/v1/images/generations
async function makeDALLERequest(prompt){
    const headers = getOpenAIHeader();
    const requestBody = {
        	"model": MODEL,
	        "prompt": prompt,
	        "n": 1,
	        "size": RESOLUTION,
	        "quality": QUALITY
    };
    
    console.log("DALLE Request Payload: ", requestBody);
    
    const response = await fetch(OPENAI_GENERATION_URL, {
        method: "POST",
        mode: "cors",
        cache: "no-cache",
        headers: headers,
        body: JSON.stringify(requestBody)
    });
    
    json_res = await response.json();
    
    console.log("DALLE Reponse:", json_res);
    // API returns array of images (to handle n>1) In this app n=1 so just return the first
    return json_res.data[0];
}

// Build and Fetch request to Text Generation API: https://api.openai.com/v1/chat/completions
async function makeLLMRequest(prompt){
    const headers = getOpenAIHeader();
    const requestBody = {
	    "model": LLM_MODEL,
	    "messages": [{
			"role": "user",
			"content": prompt
		}],
	    "temperature": TEMPERATURE
    };
    
    console.log("LLM Request Payload: ", requestBody);
    
    const response = await fetch(OPENAI_CHAT_URL, {
        method: "POST",
        mode: "cors",
        cache: "no-cache",
        headers: headers,
        body: JSON.stringify(requestBody)
    });
    
    json_res = await response.json();
    
    console.log("LLM Reponse:", json_res);
    
    return json_res.choices[0];
}

// Make the requests to the LLM and Image generation with irder and prompt depending on which mode is selected
async function makeOpenAIRequest(prompt){
    
    // image mode
    if (MODE === "image" && MODEL == "dall-e-3"){
        const image_response = await makeDALLERequest(`Create an image of ${prompt}`);
        const llm_response = await makeLLMRequest(`Write a short story to accompany an image created using the prompt "${image_response.revised_prompt}" in no more than ${WORD_LIMIT} words`);
        
        return {
            url: image_response.url,
            text: llm_response.message.content,
            title: prompt
        };
    }
    // story mode
    else if (MODE === "story" && MODEL == "dall-e-3"){
        const llm_response = await makeLLMRequest(`Write a short story about ${prompt} in no more than ${WORD_LIMIT} words`);
        const image_response = await makeDALLERequest(`Create an image to accompany the story "${llm_response.message.content}"`);
        
        return {
            url: image_response.url,
            text: llm_response.message.content,
            title: prompt
        };
    }
    // prompt mode
    else{
         const [image_response, llm_response] = await Promise.all([makeDALLERequest(`Create an image to accompany a story about ${prompt}`), makeLLMRequest(`Write a short story about ${prompt} in no more than ${WORD_LIMIT} words`)]);
          
        return {
            url: image_response.url,
            text: llm_response.message.content,
            title: prompt
        };
    }
}

// Build the template for image page with image src included
const createImagePage = (url) => {
    return `
        <img src=${url} height="375px", width="375px"/>
        <div class="tape left-tape"></div>
        <div class="tape right-tape"></div>
    `;
} ;

// Create a page
const createNewPage = (prompt) => {
    console.log("Creating New Page with Prompt: ", prompt);
    
    loading=true;
    document.getElementById("page-content").innerHTML = "Generating Story. This may take a few minutes.";
    
    // Get title, text and image url from OpenAI APIs
    makeOpenAIRequest(prompt)
        .then((res) => {
            console.log("Creating New Page: ", res);
            // Add page to pagination list
            pages.push(new Page(res.title, res.text, res.url));
            loading=(false);
            // Redraw current page
            changePage(0);
        })
        .catch((err) => {
            console.log("Request Error: ", err);
            // Refraw page and Notify user of error
            loading=(false);
            changePage(0)
            document.getElementById("page-content").innerHTML += "<p>An error occured in one or more of the APIs. See console for details.</p>";
        });
};

// Render Page to screen based of pageCount varible
const drawPage = () => {
    // Get the data from the paginiation list at index pageCount
    const pageData = pages[pageCount];
    
    // Get page numbers
    document.getElementById("left-page-number").innerHTML = pageCount*2+1;
    document.getElementById("right-page-number").innerHTML = pageCount*2+2;
    
    //If there is existing data about the page display it
    if (pageData){
        document.getElementById("page-title").innerHTML = pageData.title;
        document.getElementById("page-content").innerHTML = pageData.text;
        document.getElementById("image-content").innerHTML = createImagePage(pageData.imageURL);
        
    }
    // If the page is currently loading display laoding message instead of input prompt
    else if (loading){
        document.getElementById("page-title").innerHTML = "Tell Me A Story About...";
        document.getElementById("page-content").innerHTML = "Generating Story. This may take a few minutes.";
        document.getElementById("image-content").innerHTML = "";
    }
    // If no data exisits about the current page display prompt form
    else{
        document.getElementById("page-title").innerHTML = "Tell Me A Story About...";
        document.getElementById("page-content").innerHTML = promptForm;
        document.getElementById("image-content").innerHTML = "";
        
        const promptField = document.getElementById("prompt-field");
        const promptSubmitBtn = document.getElementById("prompt-submit");
        
        // Only enable "Generate" button after user has types
        promptField.addEventListener("input", (e) => {
            if (e.target.value) promptSubmitBtn.disabled = false;
            else promptSubmitBtn.disabled = true;
        });
        
        // https://stackoverflow.com/questions/155188/trigger-a-button-click-with-javascript-on-the-enter-key-in-a-text-box
        // Enable the use of Enter key to sumbit the prompt
        promptField.addEventListener("keyup", (e) => {
            event.preventDefault();
            if (event.keyCode === 13 && !promptSubmitBtn.disabled) {
               promptSubmitBtn.click();
            }
        });
        
        promptSubmitBtn.addEventListener("click", () => {
            createNewPage(promptField.value);
        });
    }
};

// Handles pagination increase/decrease
const changePage = (n) => {
    // If not on the page after the last story allow for increase in page count
    if (pageCount != pages.length || n < 0){
            pageCount += n;
    }
    // If on the page after the last story (prompt page) disable the forward page button
    if (pageCount == pages.length) forwardBtn.disabled = true;
    else forwardBtn.disabled = false;
    // If on the fist page siable the back page button
    if (pageCount <= 0) backBtn.disabled = true;
    else backBtn.disabled = false;
    
    //Render the page
    drawPage();
};

// Swap from cover templae to openBook templae
const openBook = () => {
    console.log("Opening Book");
    document.getElementById("book-block").innerHTML = openBookTemplate;
    
    //Add pagination button functionality
    backBtn = document.getElementById("page-back");
    forwardBtn = document.getElementById("page-forward");
    
    backBtn.addEventListener("click", () => {changePage(-1)});
    forwardBtn.addEventListener("click", () => {changePage(1)});
    
    //https://stackoverflow.com/questions/41303787/use-arrow-keys-as-event-listener
    //Allow for pagination using the left and right arrow keys
    document.addEventListener('keyup', function(e) {
        var code = e.which || e.keyCode;
        if (code == '37' && !backBtn.disabled) {
           backBtn.click();
        }
        else if (code == '39' && !forwardBtn.disabled) {
           forwardBtn.click();
        }
    });
    
    // Render page
    drawPage();
};

// Api Key submit button event listener
keySubmitBtn.addEventListener("click", () => {
    if (keyField.value){
        console.log("Setting API Key: ", keyField.value);
        apiKey = keyField.value;
        openBook();
    }
});

// https://stackoverflow.com/questions/155188/trigger-a-button-click-with-javascript-on-the-enter-key-in-a-text-box
//Allow for the api key submit using Enter key
keyField.addEventListener("keyup", function(event) {
    event.preventDefault();
    if (event.keyCode === 13) {
       keySubmitBtn.click();
    }
});



// CSS
// Add fonts
$('head').append(`<link href='https://fonts.googleapis.com/css?family=Inter' rel='stylesheet'>`);
$('head').append(`<link href='https://fonts.googleapis.com/css?family=Inknut Antiqua' rel='stylesheet'>`);
// Style page
$('head').append(`
<style> 
    .content-container{
    	padding: 0px 20px;
        width: calc(100vw - 40px);
        height: calc(100vh - 40px);
        display: flex;
        flex-direction: column;
        overflow: auto;
    }
    
    .title-block{
        max-width: 800px;
        font-family: Inter;
    }
    
    .book-block{
    	flex-grow: 1;
        display: flex;
        align-items: center;
        justify-content: center;
    }
    
    .details-block{
    	font-family: Inter;
    }
    
    .cover-background{
    	height: 610px;
        width: 460px;
        background-color: #5B0808;
        position: relative;
        z-index: 1;
        border-radius: 0px 10px 10px 0px;
    }
    
    .cover-background-pages{
    	height: calc(100% - 12px);
        width: calc(100% - 12px);
        background-color: #F6F1C3;
        position: absolute;
        left: 6;
        top: 6;
        z-index: 2;
        border-radius: 0px 10px 10px 0px;
    }
    
    .cover-accent-line{
    	height: 600px;
        width: 6px;
        background-color: #BFBB4E;
        position: absolute;
        left: 25;
        z-index: 4;
    }
    
    .cover{
    	 height: 600px;
        width: 460px;
        background-color: #901414;
        position: absolute;
        z-index: 3;
        border-radius: 0px 10px 10px 0px;
        display: flex;
        font-family: Inknut Antiqua;
        color: #BFBB4E;
        justify-content: center;
    }
    
    .cover-content{
    	height: 300px;
        display: flex;
        flex-direction: column;
        margin-top: 100px;
        justify-content: space-between;
    }
    
    .cover-title{
    	font-size: 40px;
    }
    
    .apikey-form{
    	display: flex;
        justify-content: center;
    }
    
    .apikey-input{
    	outline: 0;
        border-width: 0 0 3px;
        border-color: #BFBB4E;
        background-color: #901414;
        color: #BFBB4E;
    }
    
    .apikey-submit{
    	outline: 0;
        border: none;
        background-color: #901414;
        color: #BFBB4E;
        font-size: 20;
        font-weight: 900;
        cursor: pointer;
    }
    
    .book-background{
    	height: 600px;
        width: 920px;
        background-color: #5B0808;
        position: relative;
        z-index: 1;
        border-radius: 10px;
    }
    
    .text-page{
    	height: 580px;
        width: 450px;
        background-color: #F6F1C3;
        position: absolute;
        z-index: 1;
        border-radius: 10px 0px 0px 10px;
        top: 10;
        left: 10;
        box-shadow: -30px 0px 20px 0px rgba(0, 0, 0, 0.25) inset;
        padding: 0px 10px;
    }
    
    #page-title{
    	white-space: nowrap;
        overflow: hidden;
        text-overflow: ellipsis;
        text-transform: capitalize;
    }
    
    #page-content{
    	height: 470px;
    	max-width: 430px;
        overflow-x: auto;
    }
    
    .image-page{
    	height: 580px;
        width: 450px;
        background-color: #F6F1C3;
        position: absolute;
        z-index: 1;
        border-radius: 0px 10px 10px 0px;
        top: 10;
        left: 460;
        box-shadow: 10px 0px 20px 0px rgba(0, 0, 0, 0.25) inset;
        display: flex;
        align-items: center;
        justify-content: center;
    }
    
    #image-content{
    	height: 450px;
        width: 450px;
        position: relative;
        z-index: 5;
        display: flex;
        justify-content: center;
        align-items: center;
    }
    
    .image-page{
    	height: 580px;
        width: 450px;
        background-color: #F6F1C3;
        position: absolute;
        z-index: 1;
        border-radius: 0px 10px 10px 0px;
        top: 10;
        left: 460;
        box-shadow: 10px 0px 20px 0px rgba(0, 0, 0, 0.25) inset;
    }
    
    .page-btn{
        background-color: rgba(0,0,0,0);
        outline: 0;
        border: none;
        position: absolute;
        z-index: 2;
        bottom: 20;
        font-size: 20;
        cursor: pointer;
    }
    
    #page-back{
        left: 20;
    }
    
    #page-forward{
        right: 20;
    }
    
    #left-page-number{
        position: absolute;
        bottom: 20;
        left: 225;
        z-index: 10;
    }
    
    #right-page-number{
    	position: absolute;
        bottom: 20;
        right: 225;
        z-index: 10;
    }
    
    .bookmark{
        position: absolute;
        top: 0;
        right: 150;
        z-index: 10;
    }
    
    .tape{
        height: 25px;
        width: 80px;
        background-color: rgba(217,217,217,0.7);
        z-index: 10;
        position: absolute;
        transform: rotate(-45deg);
    }
    
    .left-tape{
    	top: 45;
        left: 15;
    }
    
    .right-tape{
    	bottom: 45;
        right: 15;
    }
</style>`
);