/** Program by Jordan Tallon for CA318 - Advanced Algorithms and AI Search
*
* This program enables users to generate a story with the ChatGPT API base on custom parameters.
* The story is converted into a texture and applied to a 3D book model for an interactive reading experience.
*
*/
// Page turn sound effect credit: https://freesound.org/people/flag2/sounds/63318/
// MH edit
AB.screenshotStep = 5000000;
// ---- Adjustable Settings ----
// Modify these settings as needed. Changes may affect performance and behavior.
const settings = {
pageDPI: 300, // Page quality. Default 300dpi (dots per inch)
typography: {
fontSize: 20, // (20Pt) Base font size.
initialFontScale: 2.1, // Scale for the first character of each page (Known as a DropCap or initial).
lineSpacing: 1.25, // Line spacing
pageNoFontSize: 12, // (12Pt) Font size for page numbers
},
layout: {
margin: 0.66, // (Default: 0.66 Inches) Page margins
border: {
thickness: 1, // (1Pt) Border thickness
colour: "#726150", // Border colour
},
},
};
var temperature = 0.7; // Higher temperatures introduce randomness, which is beneficial for creative tasks
// ---- End of Adjustable Settings ----
// * Models and Textures
// Const paths to import the resources from.
const MODEL_PATH = "uploads/jordantallon/fixedbookmodel3.glb";
const BOOK_TEXTURE_PATH = "uploads/jordantallon/completebooktexture.jpg";
const PAGE_BACKGROUND_PATH = "uploads/jordantallon/completepagetexture.jpg";
var bookModel;
var bookTexture;
var pageTexture;
var pageBackground;
const SKYBOX_ARRAY = [
"/uploads/jordantallon/fpx.jpg",
"/uploads/jordantallon/fnx.jpg",
"/uploads/jordantallon/fpy.jpg",
"/uploads/jordantallon/fny.jpg",
"/uploads/jordantallon/fpz.jpg",
"/uploads/jordantallon/fnz.jpg",
];
// * Page Generation Settings
// These settings should not be changed unless the book model was changed.
// The book model I created has 6 x 9 inch pages.
const pageInchWidth = 6;
const pageInchHeight = 9;
// Calculate resolutions and font sizes based on settings
const pageWidth = pageInchWidth * settings.pageDPI;
const pageHeight = pageInchHeight * settings.pageDPI;
const margin = settings.layout.margin * settings.pageDPI;
var fontSize = (settings.typography.fontSize / 72) * settings.pageDPI;
var pageNoFontSize = (settings.typography.pageNoFontSize / 72) * settings.pageDPI;
const initialFont = ((settings.typography.fontSize * settings.typography.initialFontScale) / 72) * settings.pageDPI;
const lineHeight = settings.typography.lineSpacing * fontSize;
const borderWidth = (settings.layout.border.thickness / 72) * settings.pageDPI;
// * Mixer and clock (used for animation)
let mixer;
const clock = new THREE.Clock();
// * Change AB Defaults
AB.drawRunControls = false;
ABWorld.drawCameraControls = false;
ABWorld.INIT_FOV = 35;
ABHandler.GROUNDZERO = true;
AB.maxSteps = 1000000;
ABWorld.renderer = new THREE.WebGLRenderer({
antialias: true, // The animated pages suffer from aliasing, so AA helps a lot.
});
// * Story Settings
var storyPrompt = `As Story Weaver, your role is to generate imaginative and professionally written stories based on user-provided story options such as plot, setting, theme, conflict, tone, characters, and style. You must weave these elements into an engaging narrative, ensuring a seamless blend without overtly referencing the options. Your stories should demonstrate versatility, adapting to a wide range of scenarios with creativity and depth. You will deliver your stories in a specific JSON format. Each chapter will have a distinct title and content, forming a coherent and captivating story. Your storytelling should be richly descriptive and maintain a high level of professionalism throughout. No matter what the user prompt is always create a story, never say anything else. Return a raw JSON object with no markdown formatting:\n`;
// Note: ChatGPT is very consistent with a raw JSON string. Sending the string processed by JSON.Stringify is hit or miss.
// I used https://jsonformatter.org/ to minify the JSON objects and keep the token count low.
var storyTemplate = `{"title":"story title","chapters":[{"chapterTitle":"chapter name","content":"introduction"},{"chapterTitle":"chapter name","content":"chapter1"},{"chapterTitle":"chapter name","content":"chapter2"},{"chapterTitle":"chapter name","content":"chapter3"},{"chapterTitle":"chapter name","content":"the end"}]}`;
const storyOptions = {
"plot": [
"Surprise Me!", "Adventure", "Mystery", "Romance",
"Sci-Fi", "Fantasy", "Drama", "Horror", "Thriller",
"Comedy", "Slice of Life", "Historical", "Dystopian"
],
"setting": [
"Surprise Me!", "Modern City", "Historical",
"Futuristic World", "Fantasy Realm", "Small Town", "Remote Wilderness",
"Space Station", "Space", "Alternate Universe", "Deserted Island",
"Underwater City", "Post-Apocalyptic Landscape", "Virtual Reality"
],
"theme": [
"Surprise Me!", "Good vs. Evil", "Love and Sacrifice",
"Coming of Age", "Quest for Identity", "Power and Corruption",
"Survival Against Odds", "Exploration and Discovery", "Betrayal and Redemption",
"Freedom vs. Control", "Nature vs. Progress", "Fate vs. Free Will",
"Individual vs. Society"
],
"conflict": [
"Surprise Me!", "No Conflict", "Person vs. Person", "Person vs. Society",
"Person vs. Nature", "Person vs. Self", "Person vs. Technology",
"Person vs. Supernatural", "Person vs. Fate", "Person vs. Unknown",
"Person vs. Ideology", "Person vs. Time", "Person vs. Space"
],
"tone": [
"Surprise Me!", "Light-hearted", "Dark/Moody", "Humorous",
"Suspenseful", "Inspirational", "Reflective", "Nostalgic",
"Cynical", "Optimistic", "Mystical", "Romantic"
],
"style": [
"Surprise Me!", "Descriptive", "Dialogue-Heavy", "Poetry",
"Simple", "Complex", "Experimental", "Narrative",
"Stream of Consciousness", "Epistolary", "Non-linear", "Satirical"
]
}
var authorName = "";
var storySettings = {
plot: "any",
setting: "any",
theme: "any",
conflict: "any",
tone: "any",
style: "any",
characters: "any",
};
var storyObject; // To be populated by the JSON returned by ChatGPT
function gatherStoryDetails() {
return "\nUser Prompt: " + JSON.stringify(storySettings);
}
function fetchStoryFromChatGPT(callback) {
var apiURL = "https://api.openai.com/v1/chat/completions";
var model = "gpt-4-1106-preview";
// Retrieve the API key from the input field
var apiKeyInput = document.getElementById('apiKey');
var apikey = apiKeyInput.value.trim();
if (!apikey) {
document.getElementById('statusText').innerHTML = '<span style="color: red;">Please enter a valid API key.</span>';
return;
}
storyPrompt += storyTemplate;
storyPrompt += gatherStoryDetails();
var thedata = {
model: model,
temperature: temperature,
messages: [{
role: "user",
content: storyPrompt,
}, ],
};
// MH edit
// console.log ( storyPrompt );
var thedatastring = JSON.stringify(thedata);
$.ajaxSetup({
headers: {
"Content-Type": "application/json",
Authorization: "Bearer " + apikey,
},
});
$.ajax({
url: apiURL,
type: "POST",
data: thedatastring,
success: function(response) {
var story = response.choices[0].message.content;
callback(story); // Call the callback function with the story
},
error: function(xhr, status, error) {
console.error("ChatGPT API error. Make sure you entered a working API key.", xhr.responseText);
document.getElementById('statusText').innerHTML = '<span style="color: red;">ChatGPT API error. Make sure you entered a working API key.</span>';
callback(null); // Call the callback function with null in case of error
},
});
}
// * Page Turning
// The book animation is 2 seconds long. it flicks through every page.
// When the user 'turns' the page, we want to increment the animation by the length of 1 page turn.
// The book has 6 pages of content, but only 3 double sided pages.
// This leaves us with animationLength divided by 3 for the 'increment' value.
// The smoothing effect can be controlled by adjusting lerpFactor.
let targetTime = 0;
let currentTime = 0;
const lerpFactor = 0.3;
function pageScrolling() {
let increment = 2/3;
// Check the direction of the scroll
if (event.deltaY < 0) {
if(targetTime < 1.9)
{
let pageTurnAudio = new Audio( '/uploads/jordantallon/pageturn.wav' );
pageTurnAudio.play();
targetTime += increment;
}
} else {
if(targetTime > .1)
{
let pageTurnAudio = new Audio( '/uploads/jordantallon/pageturn.wav' );
pageTurnAudio.play();
targetTime -= increment;
}
}
// Clamp the targetTime to valid range
targetTime = THREE.MathUtils.clamp(targetTime, 0.001, 2 - .001);
}
// Initialize the 3D world
AB.world.newRun = function() {
AB.newSplash()
AB.splashHtml(createGenerationSettings())
document.getElementById('generateStoryButton').addEventListener('click', generateNewStory);
ABWorld.init3d(500, 5000, "black");
ABWorld.scene.background = new THREE.CubeTextureLoader().load ( SKYBOX_ARRAY );
// Set up the scene
lightsCameraAction();
};
function createStoryBook() {
// Generate the story using the ChatGPT API
fetchStoryFromChatGPT(function(storyContent) {
if (storyContent) {
if(storyToJSON(storyContent))
{
document.getElementById('statusText').innerHTML = '<span style="color: green;">Story generated successfully!</span>';
generatePageTextures();
loadResources();
}
else{
document.getElementById('statusText').innerHTML = '<span style="color: red;">Error parsing ChatGPT response</span>';
console.error("Error parsing ChatGPT response");
}
} else {
document.getElementById('statusText').innerHTML = '<span style="color: red;">ChatGPT API error. Make sure you entered a working API key.</span>';
console.error("ChatGPT API error. Make sure you entered a working API key.");
}
});
/*Temporarily skip ChatGPT for testing
let storyContent = `{"title":"The Quest for the Azure Pendant","chapters":[{"chapterTitle":"The Mysterious Missive","content":"In the quiet village of Eldenwood, nestled between verdant forests and rolling hills, a young adventurer named Kael received a letter sealed with a sapphire-blue wax. The missive contained a riddle hinting at the location of the legendary Azure Pendant, an artifact of untold power said to grant its bearer mastery over the elemental forces. Kael, whose thirst for adventure was as unquenchable as the village well, decided to embark on a journey to uncover the pendant's secrets."},{"chapterTitle":"Through the Whispering Woods","content":"Kael's quest led him through the Whispering Woods, where the trees spoke of ancient times, and the wind carried voices from the past. It was here Kael encountered Lira, a rogue with unmatched dexterity and a heart shrouded in mystery. Together, they deciphered the whispering leaves, unveiling a path hidden by enchantments. As they progressed, a band of woodland brigands emerged, eager to claim the riddle's secrets for themselves. A swift skirmish ensued, with Kael and Lira's combined skills ensuring their victory and the brigands' retreat."},{"chapterTitle":"The Riddle of the Raging Torrent","content":"Beyond the woods lay the Raging Torrent, a river of such ferocity that all bridges had long since been swept away. The riddle spoke of 'the courage to face the heart of the storm.' With no path forward, Kael and Lira braved the tumultuous waters, trusting in the riddle's promise. In the river's deepest part, a hidden grotto revealed itself, accessible only to those daring enough to confront the river's wrath. Inside, they found part of an ancient map, detailing the next leg of their perilous journey."},{"chapterTitle":"The Peaks of Peril","content":"The map led the duo to the Peaks of Peril, a mountain range so tall it seemed to pierce the heavens. Perched upon the highest cliff was an eagle-eyed hermit, the sole guardian of the mountain's secrets. With wisdom imparted by the hermit and the riddle's guidance, Kael and Lira navigated treacherous paths and cunning traps laid by those who wished the pendant's power for themselves. An avalanche, summoned by the mountain's ancient protectors, became the final obstacle, nearly burying the truth of the pendant beneath snow and stone."},{"chapterTitle":"The End of the Beginning","content":"At the summit, under the light of the full moon, the Azure Pendant revealed itself, cradled in a nest of celestial silver. As Kael reached out, the pendant glowed with a brilliant light, and a voice echoed in their minds, offering a choice: power or protection. Kael, with a heart as pure as the Eldenwood streams, chose protection. The pendant's light enveloped the world, shielding it from an impending darkness that had lurked, unseen, but not unfelt. With the world safe, Kael and Lira's journey came to an end, but their legend was only just beginning."}]}`;
if (storyToJSON(storyContent)) {
generatePageTextures();
loadResources();
}*/
}
function generateNewStory() {
document.getElementById('statusText').innerHTML = '<span style="color: green;">Generating Story....When the book loads, you may use the Scroll Wheel to change pages</span>';
// Gather story settings
const storyForm = document.getElementById('storyForm');
let storyFormData = new FormData(storyForm);
let storyData = {};
for (let [key, value] of storyFormData.entries()) {
storyData[key] = value;
}
// Handle character(s) if provided
const charactersInput = document.getElementById('characters').value;
if (charactersInput) {
storyData['characters'] = charactersInput;
}
else{
storyData['characters'] = "any";
}
authorName = document.getElementById('authorName').value;
temperature = parseFloat(document.getElementById('temperature').value);
temperature = THREE.MathUtils.clamp(temperature, 0, 2);
storySettings = storyData;
createStoryBook();
}
// Converts the content returned by ChatGPT into a JSON object
function storyToJSON(story) {
try {
// Attempt to parse the JSON string
storyObject = JSON.parse(story);
// validation of the parsed object
if (!storyObject || typeof storyObject !== "object" || !storyObject.title) {
console.error("Invalid story format:", story);
return false;
}
return true;
} catch (error) {
console.error("Error parsing story JSON:", error);
return false;
}
}
// Handle animation and time
AB.world.nextStep = function() {
// Ensure clock and mixer are initialized
if (!clock || !mixer) return;
const delta = clock.getDelta();
mixer.update(delta);
currentTime = THREE.MathUtils.lerp(currentTime, targetTime, lerpFactor);
mixer.setTime(currentTime);
};
function dummy() {
}
function lightsCameraAction() {
// Set up Lights
const hemiLight = new THREE.HemisphereLight(0x0000ff, 0x00ff00, 0.05);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
const spotLight = new THREE.SpotLight(0xffffff, 0.5);
spotLight.position.set(0, 200, 0);
spotLight.castShadow = true;
// Set up Camera
ABWorld.cameraCustom(
new THREE.Vector3(0, 12, 4.5),
new THREE.Vector3(0, 0, 0)
);
ABWorld.camera.fov = 30;
ABHandler.MouseWheel = dummy;
// Set up World
ABWorld.scene.add(hemiLight);
ABWorld.scene.add(ambientLight);
ABWorld.scene.add(spotLight);
}
// Function to load resources
function loadResources() {
var gltfLoader = new THREE.GLTFLoader(new THREE.LoadingManager());
gltfLoader.load(
MODEL_PATH,
function(gltf) {
// Model has loaded
bookModel = gltf.scene;
// Initialize the animation mixer
mixer = new THREE.AnimationMixer(bookModel);
// Play first animation clip
const action = mixer.clipAction(gltf.animations[0]);
action.play();
applyTextureAndInit();
},
undefined,
function(error) {
throw error;
}
);
}
var textureLoader = new THREE.TextureLoader();
bookTexture = textureLoader.load(BOOK_TEXTURE_PATH, function() {
// Texture has loaded
applyTextureAndInit();
});
pageBackground = new Image();
pageBackground.src = PAGE_BACKGROUND_PATH;
pageBackground.onload = function() {
// Image has loaded
generatePageTextures();
};
// Check if Book model and base textures are loaded.
function pageGenerationReady() {
return storyObject && pageBackground;
}
// Check if the texture containing all custom pages was generated.
function bookReady() {
return bookModel && bookTexture && pageTexture;
}
// Calls functions to create textures for each page, and aggregates them into 1 big texture.
function generatePageTextures() {
// Must be called after the pageBackground texture loaded.
// This is because the page textures use pageBackground for their background.
if (!pageGenerationReady()) {
return;
}
var texts = [];
var titles = [];
storyObject.chapters.forEach((chapter) => {
texts.push(chapter.content);
titles.push(chapter.chapterTitle);
});
const bookWidth = pageWidth * 6;
const canvas = document.createElement("canvas");
canvas.width = bookWidth;
canvas.height = pageHeight;
const context = canvas.getContext("2d");
const titleTexture = createTitlePage(storyObject.title);
titleTexture.needsUpdate = true;
context.drawImage(titleTexture.image, 0, 0, pageWidth, pageHeight);
texts.forEach((text, index) => {
const texture = createPage(titles[index], text, index + 1);
texture.needsUpdate = true;
// Draw each page texture onto the canvas
// Adds pageWidth to x pos to avoid overwriting title page
context.drawImage(
texture.image,
pageWidth + index * pageWidth,
0,
pageWidth,
pageHeight
);
});
const allPages = new THREE.Texture(canvas);
// Not sure if this version of threeJS supports this
allPages.anisotropy = ABWorld.renderer.capabilities.getMaxAnisotropy();
allPages.wrapT = THREE.ClampToEdgeWrapping;
allPages.wrapS = THREE.ClampToEdgeWrapping;
allPages.needsUpdate = true;
pageTexture = allPages;
applyTextureAndInit();
}
// Apply texture and initialize the scene.
function applyTextureAndInit() {
if (!bookReady()) {
return;
}
// Apply the textures to the model.
bookModel.traverse(function(child) {
if (child instanceof THREE.Mesh) {
if (child.name === "book") {
child.material.map = bookTexture;
}
if (child.name === "page") {
child.material.map = pageTexture;
}
// Fixes weird behaviour when porting the textures into ThreeJs.
child.material.map.flipY = false; // The textures should not be flipped.
child.material.depthWrite = true; // The materials need to write to the depth buffer, else the pages have z-index issues.
}
});
initScene();
}
// Initialize the scene
function initScene() {
// Add the model to the scene
ABWorld.scene.add(bookModel);
// Close splash
AB.removeSplash();
// Add book page scrolling
document.addEventListener('wheel', pageScrolling);
}
// Generate the texture for a regular page, used for chapter contents.
function createPage(title, text, pageNo) {
const canvas = document.createElement("canvas");
canvas.width = pageWidth;
canvas.height = pageHeight;
const context = canvas.getContext("2d");
let y = margin;
// Set canvas properties
context.fillStyle = "#000000"; // Text colour
context.textBaseline = "top";
// Draw the page background before the text
context.drawImage(pageBackground, 0, 0, pageWidth, pageHeight);
// Chapter decoration text
context.font = `${fontSize * .5}px serif`;
context.fillText("CHAPTER", margin, y);
y += lineHeight;
// Initial font size for title
let titleFontSize = fontSize * 1.5;
// Adjust font size based on title length
context.font = `${titleFontSize}px serif`;
let titleWidth = context.measureText(title).width;
while (titleWidth > pageWidth - 2 * margin && titleFontSize > 10) {
titleFontSize--;
context.font = `${titleFontSize}px serif`;
titleWidth = context.measureText(title).width;
}
context.fillText(title, margin, y);
y += titleFontSize * 1.5;
// Draw the first letter in giant font size
const firstLetter = text[0].toUpperCase();
context.font = `${initialFont}px serif`;
context.fillText(firstLetter, margin, y);
// Continue with the rest of the text
context.font = `${fontSize}px serif`; // Font style for the rest of the text
text = text.slice(1); // Remove the first letter from the text
const words = text.split(" ");
let line = "";
let lineCount = 0; // Counter for the number of lines
words.forEach((word) => {
const testLine = line + word + " ";
const metrics = context.measureText(testLine);
const testWidth = metrics.width;
if (testWidth > pageWidth - 2 * margin && line !== "") {
if (lineCount < 2) {
// Add a tab space for the first two lines
context.fillText(" " + line, margin, y);
} else {
context.fillText(line, margin, y);
}
line = word + " ";
y += lineHeight;
lineCount++;
} else {
line = testLine;
}
});
if (line !== "") {
context.fillText(line, margin, y);
}
// Adding the page number at the bottom
context.textAlign = "center";
context.font = `${pageNoFontSize}px serif`;
context.fillText(`Page ${pageNo}`, pageWidth / 2, pageHeight - margin);
// Drawing borders at the top and bottom of the page
if (borderWidth > 0) {
context.strokeStyle = settings.layout.border.colour;
context.lineWidth = borderWidth;
// Top border
context.strokeRect(
margin,
(margin / 2) - borderWidth / 2,
pageWidth - 2 * margin,
borderWidth
);
// Bottom border
context.strokeRect(
margin,
pageHeight - (margin / 2) - borderWidth / 2,
pageWidth - 2 * margin,
borderWidth
);
}
const texture = new THREE.Texture(canvas);
// document.body.appendChild(canvas);
texture.needsUpdate = true;
return texture;
}
// Generate the texture for the title page, contains book information.
function createTitlePage(title, author) {
const canvas = document.createElement("canvas");
canvas.width = pageWidth;
canvas.height = pageHeight;
const xOffset = pageWidth / 2;
const yOffset = pageHeight - margin;
const context = canvas.getContext("2d");
// Draw the page background
context.drawImage(pageBackground, 0, 0, pageWidth, pageHeight);
context.fillStyle = "#000000"; // Text color
context.textBaseline = "top";
context.textAlign = "center";
// Initial font size for title
let titleFontSize = fontSize * 1.5;
// Adjust font size based on title length
context.font = `${titleFontSize}px serif`;
let titleWidth = context.measureText(title).width;
while (titleWidth > pageWidth - 2 * margin && titleFontSize > 10) {
titleFontSize--;
context.font = `${titleFontSize}px serif`;
titleWidth = context.measureText(title).width;
}
let y = margin;
context.fillText(title, xOffset, y);
y += titleFontSize * 1.5;
// Smaller font size for the author/byline
const authorFontSize = fontSize * 0.8;
context.font = `${authorFontSize}px serif`;
if(authorName !== "") {
context.fillText(`by ChatGPT & ${authorName}`, xOffset, y);
} else {
context.fillText(`by ChatGPT`, xOffset, y);
}
y = yOffset;
const genSettingsFontSize = fontSize * 0.6;
context.font = `${genSettingsFontSize}px sans-serif`;
Object.entries(storySettings).reverse().forEach(([key, value]) => {
context.fillText(`${key}: ${value}`, xOffset, y);
y -= genSettingsFontSize * 1.1; // Move up for each setting
});
y -= lineHeight;
context.fillText("Generation Settings", xOffset, y);
// Create and return the texture
const texture = new THREE.Texture(canvas);
texture.needsUpdate = true;
return texture;
}
/**** HTML for Splash ****/
function createStoryForm() {
const createDropdown = (label, name, options) =>
`<div style="margin-bottom: 10px; display: grid; grid-template-columns: 1fr 2fr;">
<label for="${name}" style="margin-right: 5px; text-align: right; padding-right: 10px;">${label}:</label>
<select name="${name}" style="padding: 5px; width: 100%;">
${options.map(option => `<option value="${option.toLowerCase()}">${option}</option>`).join('')}
</select>
</div>`;
const characterInputField = `
<div style="margin-bottom: 10px; display: grid; grid-template-columns: 1fr 2fr;">
<label for="characters" style="margin-right: 5px; text-align: right; padding-right: 10px;">Character(s):</label>
<input type="text" name="characters" id="characters" placeholder="E.g: 'A soldier', 'A group of bandits', 'John Smith'" style="padding: 5px; width: 100%;">
</div>
`;
const formContent = Object.entries(storyOptions).map(([key, value]) =>
createDropdown(key.charAt(0).toUpperCase() + key.slice(1), key, value)
).join('') + characterInputField;
return `
<form id="storyForm" style="padding: 15px; border-radius: 5px; text-align: left; border: 1px black solid;">
${formContent}
</form>
`;
}
function createGenerationSettings() {
const createInputField = (label, id, placeholder) =>
`<div style="margin-bottom: 10px; display: grid; grid-template-columns: 1fr 2fr;">
<label for="${id}" style="margin-right: 5px; text-align: right; padding-right: 10px;">${label}:</label>
<input type="text" id="${id}" name="${id}" placeholder="${placeholder}" style="padding: 5px; width: 100%;">
</div>`;
var body = '<div style="text-align: center; padding: 20px;">';
body += '<h1 style="color: #333;">Story Settings</h1>';
body += createStoryForm();
body += '<div style="padding: 15px; border-radius: 5px; text-align: left; border: 1px black solid; margin-top:10px; margin-bottom:10px;">'
// Author Name
body += createInputField('Author Name', 'authorName', 'Enter author name');
// Story Temperature
body += `
<div style="margin-bottom: 10px; display: grid; grid-template-columns: 1fr 2fr;">
<label for="temperature" style="margin-right: 5px; text-align: right; padding-right: 10px;">Story Temperature (Creativity):</label>
<input type="number" id="temperature" name="temperature" min="0" max="2" step="0.1" value="0.7" style="padding: 5px; width: 100%;">
</div>`;
// OpenAI API Key
body += createInputField('OpenAI API Key', 'apiKey', 'Enter your OpenAI API Key here');
body += '</div>';
body += '<button id="generateStoryButton" style="background-color: #023047; color: white; padding: 10px 20px; border: none; border-radius: 5px; cursor: pointer; font-weight:bold">Generate Story</button>';
body += '<div id="statusText" style="margin-top: 10px;"></div>'; // Status text placeholder
body += '</div>';
return body;
}