Tutorials

Building a Generative Story Book App using GPTScript, Nuxt 3 and GPT-4 – Part 2

May 2, 2024 by tyler-slaton

Introduction

Welcome back to the second part of our Story Book tutorial! In this part, we will dive deeper into how to expand the features of our story book generator and discuss a bit more of what you can do with GPTScript. We’ll also make it a fully deployable application. You can read the first part here.

You can find the open-source repo for the code at https://github.com/gptscript-ai/story-book and the hosted version of it at https://story-book.gptscript-demos.ai/.

Prerequisites

The same prerequisites from last time apply here, but I also highly recommend reading through the first part in this series before proceeding. We’ll be building on that application directly so that context will be very important as we move forward.

Quick recap

Last time we got an application that:

  • Use GPTScript to generate a story.
  • Streams updates from GPTScript from the backend to the front-end using SSE.
  • Learned some basics about how to use Nuxt as a full stack framework.

We’re missing a few things from this and have various improvements to make. So, to enumerate those, by the end of this blog post we’ll:

  • Improve the SSE implementation to stream updates in a more reliable and scalable way.
  • Go through the motions of some prompt engineering to better prompt the AI for stories.
  • Fully package the application as a container image that can be deployed anywhere that prefer using Github Actions.
  • Some UI improvements to pad out the user experience for our application.

We’ve got a lot to cover so let’s jump right into it!

Overhauling our approach to storing stories

Last time, we stored our stories in the public directory. This was nice as it, when run locally, we were able to serve those files using Nuxt. However, when we take this application into an actual environment the public directory does not dynamically update. With the current state of our application, this means that the stories that the AI generates will not be served properly. Let’s get to fixing this!

First, let’s create an environment variable that we can set that will change the path that stories are stored. In the nuxt.config.ts file, let’s update it to look like the following:

// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  devtools: { enabled: true },
  modules: [
    '@nuxt/ui',
    '@nuxtjs/tailwindcss',
  ],
  runtimeConfig: {
    storiesVolumePath: 'stories', // NUXT_STORIES_VOLUME_PATH
  }
})

Now this environment variable will be loaded into the application wherever the application is deployed. We need to tell the AI that it should store the files here so we’ll need to do two things:

  1. Update the GPTScript to accept and act on this NUXT_STORIES_VOLUME_PATH as an input
  2. Update the call execution to pass along NUXT_STORIES_VOLUME_PATH

Let’s start by updating our GPTScript’s base tool:

tools: story-writer, story-illustrator, mkdir, sys.write, sys.read, sys.download
description: Writes a children's book and generates illustrations for it.
args: story: The story to write and illustrate. Can be a prompt or a complete story.
args: pages: The number of pages to generate
args: path: The path that the story should be written to

You are a story-writer. Do the following steps sequentially:

1. Come up with an appropriate title for the story based on the ${prompt}
2. Create the `${path}/${story-title}` directory if it does not already exist.
3. If ${story} is a prompt and not a complete children's story, call story-writer
   to write a story based on the prompt.
4. Take ${story} and break it up into ${pages} logical "pages" of text.
5. For each page:
   - For the content of the page, write it to `${path}/${story-title}/page<page-number>.txt and add appropriate newline
     characters.
   - Call story-illustrator to illustrate it. Be sure to include any relevant characters to include when
     asking it to illustrate the page.
   - Download the illustration to a file at `${path}/${story-title}/page<page_number>.png`.

With this change, you’ll see we added the path arg and utilize it in steps 2 and 5 appropriately. Finally let’s update the GPTScript execution to pass this. In server/api/story/index.post.ts, let’s update the call to GPTScript to pass the environment variable.

import gptscript from '@gptscript-ai/gptscript'
import { Readable } from 'stream'

type Request = {
    prompt: string;
    pages: number;
}

export type RunningScript = {
    stdout: Readable;
    stderr: Readable;
    promise: Promise<void>;
}

export const runningScripts: Record<string, RunningScript>= {}

export default defineEventHandler(async (event) => {
    const request = await readBody(event) as Request

    if (!request.prompt) {
        throw createError({
            statusCode: 400,
            statusMessage: 'prompt is required'
        });
    }

    if (!request.pages) {
        throw createError({
            statusCode: 400,
            statusMessage: 'pages are required'
        });
    }

		// Grab the value of the environment variable and pass it to the GPTScript.
    const {storiesVolumePath } = useRuntimeConfig()
    const {stdout, stderr, promise} = await gptscript.streamExecFile(
        'story-book.gpt', `--story ${request.prompt} --pages ${request.pages} --path ${storiesVolumePath}`, {})

    setResponseStatus(event, 202)

    runningScripts[request.prompt] = {
        stdout: stdout,
        stderr: stderr,
        promise: promise
    }
})

Now we can set where we want stories to be written by setting NUXT_STORIES_VOLUME_PATH. When we build our image in a later step this will be extremely useful as we will want to store these stories in some sort of persistent disk (hence the VOLUME_PATH).

Stamping out the remaining routes we need

In our last tutorial we cut out a couple of routes in the actual https://story-book.gptscript-demos.ai/ application in order to be brief with the implementation. Since we’re coming back to this to get everything fully ready, let’s go ahead and stamp out the remaining basic routes used in the story book today.

touch "server/api/story/index.get.ts" "server/api/story/[title].get.ts"

This creates two files for us that we’ll want to fill out so let’s tackle them one at a time.

server/api/story/index.get.ts

In the index.get.ts file go ahead and add the following:

import fs from 'fs'

export default defineEventHandler(async (event) => {
    try {
		    // Read all of the files in the storiesVolumePath.
        return await fs.promises.readdir(useRuntimeConfig().storiesVolumePath)
    } catch (error) {
        // If the error is a 404 error, we can throw it directly.
        if ((error as any).code === 'ENOENT') {
            throw createError({
                statusCode:    404,
                statusMessage: 'no stories found',
            })
        }
        // If any other error occurs, throw it.
        throw createError({
            statusCode:    500,
            statusMessage: `error fetching stories: ${error}`,
        })
    }
})

This code dynamically lists all generated stories. It checks the storage path for story files and displays them. If an error, such as a non-existent directory, occurs, it returns a 404 error, ensuring a robust user experience.

server/api/story/[title].get.ts

import fs from 'fs'
import path from 'path';
import type { Pages } from '@/lib/types'

export default defineEventHandler(async (event) => {
    try {
		    // The [title] in the name of this file is a router param. This
		    // is how we grab that value.
        let title = getRouterParam(event, 'title');
        if (!title) {
            throw createError({
                statusCode: 400,
                statusMessage: 'title is required'
            });
        }

				// The name of the story could have values that need to be decoded
        title = decodeURIComponent(title);

				// Grab the files in the storiesVolumePath
        const storiesVolumePath = useRuntimeConfig().storiesVolumePath
        const files = await fs.promises.readdir(path.join(storiesVolumePath, title));
        
        // For each file in the title's directory, parse the txt files and image files.
        // Combine them all together into a singular Pages const.
        const pages: Pages = {};
        for (const file of files) {
            if (!file.endsWith('.txt')) continue
            const page = await fs.promises.readFile(path.join(storiesVolumePath, title, file), 'utf-8');
            pages[file.replace('.txt', '').replace('page', '')] = {
                image_path: `/api/image/${path.join(name, file.replace('.txt', ''))}`,
                content: page
            }
        }

        return pages
    } catch (error) {
        // if the error is a 404 error, we can throw it directly
        if ((error as any).code === 'ENOENT') {
            throw createError({
                statusCode:    404,
                statusMessage: 'story found',
            })
        }
        throw createError({
            statusCode:    500,
            statusMessage: `error fetching story: ${error}`,
        })
    }
})

This API fetches specific story content by using the title from the URL. It decodes the title, reads the story’s content, and combines text and images into a structured format. This ensures a smooth user experience, even when errors like a missing story occur.

You’ll notice that we are importing a Pages type that we have yet to create, so let’s make that now.

mkdir lib
touch lib/types.ts

And then we can add the type in there.

export type Pages = Record<string, Page>;
export type Page = {
    image_path: string;
    content: string;
}

With that the basic functionality for the API is complete. However, you may have also noticed in the parsing of files that we’re referencing a route we have yet to implement – /api/image.

server/api/image/[…path].get.ts

Since we moved our storage of stories out of the public directory, we no longer have anything serving images. To fix this, we can implement a very simple image serving route that will grab it from the NUXT_STORIES_VOLUME_PATH and return them over HTTP.

First create the file for Nuxt. Let’s also install sharp which is an image conversion and compression library for JavaScript. We’ll be using it to optimize our image serving.

touch "server/api/image/[...path].get.ts"

Then we can go ahead and implement image serving logic.

import { existsSync } from 'fs';
import sharp from 'sharp';
import path from 'path';

export default defineEventHandler(async (event) => {
    // Build the image path out of the [...path] router param. The ... in the file
    // name means that any subroutes under image fall under this route as well.
    const imagePath = path.join(useRuntimeConfig().storiesVolumePath, `${getRouterParam(event, 'path')}.png`);    
    if (!existsSync(imagePath)) {
        throw createError({
            statusCode: 404,
            statusMessage: 'file not found'
        });
    }    

		// We'll be returning a JPEG, so set the header.
    event.node.res.setHeader('Content-Type', 'image/jpeg'); 

    // Compress the image using sharp.
    const compressedImage = await sharp(imagePath)
        .jpeg()
        .toBuffer();

		// Tell Nuxt that we'll be manually handling the response and then we
		// use the underlying node.res.end call to terminate with the image.
		event._handled = true;
    event.node.res.end(compressedImage); 
    return event;
});

The code serves story illustrations effectively by storing images outside the public directory and using the sharp library to optimize images before delivery. It maintains a persistent server connection, ensuring real-time updates and a smooth user experience.

Now we can request and serve any images in the NUXT_STORIES_VOLUME_PATH.

Adding the ability to render Stories in-app

Now that we have an API that can serve stories from the API, let’s render this on the front-end so users can actually view the stories that they’ve created. First, let’s create the page that we’ll need.

mkdir pages/stories
touch "pages/stories/[name].vue"

Then let’s go ahead and implement the story rendering logic.

<script setup lang="ts">
    import type { Pages } from '@/lib/types'
    import unmangleStoryName from '@/lib/unmangle';

    const route = useRoute()

    const name = route.params.name as string
    const pages = ref<Pages>({})
    const currentPage = ref(1)
    const pdfCreating = ref(false)

    onMounted(async () => pages.value = await $fetch(`/api/story/${name}`) as Pages )
    const nextPage = () => currentPage.value++
    const prevPage = () => currentPage.value--
</script>

<template>
    <div class="page text-2xl w-full">
		    <!-- Header w/ title and page number -->
        <div class="flex justify-center">
            <div class="w-full pt-20 md:pt-10 p-2">
                <div class="text-center">
                    <h1 class="text-4xl font-semibold mb-6">
                        {{ unmangleStoryName(name) }}
                    </h1>
                </div>
                <UDivider class="mt-10 text-xl">Page {{ currentPage }}</UDivider>
            </div>
        </div>
        
	      <!-- Buttons for changing the page -->
        <div class="h-full flex flex-col items-center justify-center">
            <UButton v-if="currentPage > 1" @click="prevPage"
                class="absolute left-[2vw] top-1/2"
                :ui="{ rounded: 'rounded-full' }" 
                color="gray"
                icon="i-heroicons-arrow-left" 
            />
            <UButton v-if="currentPage < Object.keys(pages).length" @click="nextPage"
                class="absolute right-[2vw] top-1/2"
                :ui="{ rounded: 'rounded-full' }"
                color="gray"
                icon="i-heroicons-arrow-right"
            />
        </div>
        
        <!-- Page text and image rendering -->
        <div v-if="Object.keys(pages).length > 0" class="flex-col xl:flex-row flex content-center justify-center space-y-20 xl:space-x-20 xl:space-y-0 items-center m-6 lg:m-16">
            <div class="xl:w-1/2 max-w-1/2 text-2xl">
                <p v-html="pages[currentPage].content" style="white-space: pre-line;"></p>
            </div>
            <img class="xl:w-2/5" :src="pages[currentPage].image_path" alt="page image" />
        </div>
    </div>
</template>

This code allows for the display of individual stories on a webpage. It fetches the story details from an API, allows users to navigate through story pages, and renders the story text and accompanying images. It uses Vue’s useRoute() function to extract the story name from the URL and displays the title and page number. It also handles navigation with nextPage and prevPage functions, and uses the v-html directive to render page content as HTML.

In that we reference an unmangle import we have yet to implement. This is a function that takes the folder name of stories and turns them into the title format a user would expect. First make the file.

touch lib/unmangle.ts

Then we can implement the unmangle function.

const unmangleStoryName = (storyName: string): string => {
		// Replace the -'s with spaces and capitalize the first letter of each word
    return storyName.replaceAll('-', ' ').replace(/(?<=s)bw/g, c => c.toUpperCase()).replace(/^w/, c => c.toUpperCase());
}
export default unmangleStoryName;

After that, rendering the story should look something like this.

story.png

With that complete, we’re able to go to localhost:3000/story/[title] to render stories in NUXT_STORIES_VOLUME_PATH. Next up we’ll start improving how we stream updates to the UI through SSE.

Improving our use of SSE

When we last touched the SSE implementation, we had a post route that we would submit a prompt and page count and a separate get route to see updates based on the prompt. This is fine at a smaller scale but once we start to take things into a system where more than one user can create stories at a time scaling starts to fall apart. For example, there are a limited number of streams that we can connect to on the front-end.

This is where we’re at now.

sse-before.png

A more scalable approach to this is to instead have a single get route where all updates for stories get pushed to. Each message that gets pushed through the stream will have an ID that we generate for that story so we know which message is for which story. This means that we only ever have one stream open on the front-end instead of one per generating story. That will look something like this.

sse-after.png

Let’s get to implementing it!

Updating the API

The complexity of this section relative to the previous ones will increase here. Let’s take it one step at a time so that we understand what we’re doing and why.

First, let’s start by updating the POST route to:

  1. Have a single in-memory event stream
  2. Simplify how we associate what scripts are running
  3. Create an ID for each running script instead of using their prompt
  4. Add a .on() function for the stdout and stderr that the node module returns.
  5. Add handling directly to the promises instead of writing them to a store and watching.
import gptscript from '@gptscript-ai/gptscript'
import { Readable } from 'stream'
import type { StreamEvent } from '@/lib/types'

type Request = {
    prompt: string;
    pages: number;
}

// This is more simple in-memory store that will tell us which prompts are pending
// based on their ID.
export const runningScripts: Record<string, string>= {}

// This is a single in-memory stream that the front-end will connect to. It is 
// in memory for the app right now but you could make this instead stored in a
// Reddis cache of some sort to scale even better.
export const eventStream = new Readable({read(){}});

export default defineEventHandler(async (event) => {
    const request = await readBody(event) as Request

    // Ensure that the request has the required fields
    if (!request.prompt) { throw createError({
        statusCode: 400,
        statusMessage: 'prompt is required'
    })}
    if (!request.pages) { throw createError({
        statusCode: 400,
        statusMessage: 'pages are required'
    })}

    // Run the script with the given prompt and number of pages
    const {storiesVolumePath } = useRuntimeConfig()
    const opts: { cacheDir?: string } = gptscriptCachePath ? { cacheDir: gptscriptCachePath } : {}
    const {stdout, stderr, promise} = await gptscript.streamExecFile(
        `story-book.gpt`, `--story ${request.prompt} --pages ${request.pages}`, opts)

    // Generate an ID and add it to the runningScripts object
    const id = Math.random().toString(36).substring(2, 14);
    runningScripts[id] = request.prompt

    // Setup listening for data that is received from the script. stdout is always the final message.
    stdout.on('data', (data) => {
        const outboundEvent: StreamEvent = {id, message: data.toString(), final: true}
        eventStream.push(`data: ${JSON.stringify(outboundEvent)}nn`)
    })
    stderr.on('data', (data) => { 
        const outboundEvent: StreamEvent = {id, message: data.toString()}
        eventStream.push(`data: ${JSON.stringify(outboundEvent)}nn`)
    })

    // Handle the promise by deleting the running script from the runningScripts object
    promise
        .catch((err) => { 
            const outboundEvent: StreamEvent = {id, message: err.message, final: true, error: true}
            eventStream.push(`data: ${JSON.stringify(outboundEvent)}nn`)
        })
        .finally(() => { delete runningScripts[id] })
    setResponseStatus(event, 202)
})

This new implementation frees us from having to watch the promise, stdout, and stderr through an in-memory struct. Instead, their updates will be automatically written to the singular in-memory event stream we created. You’ll remember that we want to associate each message with an ID so we know what message is meant for which prompt. To do this, this code uses a StreamEvent type that we have yet to write so let’s implement that now.

We can simply add its type to lib/types.ts to accomplish this.

export type Pages = Record<string, Page>;

export type Page = {
    image_path: string;
    content: string;
}

export type StreamEvent = {
    id: string; // This will be how we associate a message with its prompt
    message: string; // The message from GPTScript
    final?: boolean; // Indicates if we have any more messages
    error?: boolean; // Indicates if this message is an error
}

With all of this implemented, our SSE route can get much simpler.

import { eventStream } from '@/server/api/story/index.post';

export default defineEventHandler(async (event) => {
    setResponseStatus(event, 200);
    setHeaders(event, {
        'Access-Control-Allow-Origin': '*',
        'Access-Control-Allow-Credentials': 'true',
        'Connection': 'keep-alive',
        'Content-Type': 'text/event-stream',
    });
    
    event._handled = true;
    eventStream.on('data', (data) => event.node.res.write(data));
});

Finally, let’s add a route that allows us to get all of the pending jobs out of the runningScripts store.

touch server/api/story/pending.get.ts
import { runningScripts } from '@/server/api/story/index.post';
export default defineEventHandler(async () => { return runningScripts });

With this implement the new intended flow of receiving updates is for the front-end to:

  1. On load, connect to the SSE route to start listening for events
  2. On post, the backend will read the stream updates and place them in the appropriate locations

Let’s wire up our front-end to do just that now.

Updating the UI

The actual story-book application has a lot of UI features that are relatively trivial to add. Things like the navigation bar, theme toggle, etc. If you’d like to see the code for these things you can see them in the source code of the story-book. However, for the sake of brevity, we’re going to be only updating the essential pieces for getting our application functional. As such, we’ll be adding a section for:

  1. Pending stories
  2. Stories

We’ll tackle each of these one at a time, starting with…

Pending stories

Last time we had a very simple interface where the form to create a story was on the left of the page and its progress was on the right. We’re going to be overhauling that approach to instead have a few sections. The first of these sections will be for “pending stories” where we’ll have a button to create new stories and hover-able pending stories with their progress streamed from the backend.

We’re going to be creating components for this. Components are reusable chunks of the UI that contain both mark-up and the required logic for their rendering and we’ll be making components for all of the new UI updates. In Nuxt components are automatically imported into every file on our behalf so long as they’re in the components directory.

Let’s create it along with the file for the PendingStories component.

mkdir components
touch components/PendingStories.vue

In this component we’re going to be rendering the pending stories that are stored in the backend along with a hover-able section for each of them that displays progress for that particular story. This will be done by two get calls:

  1. Getting the pending stories
  2. Opening an EventSource to our /api/story/sse endpoint

With that understood, let’s write the code for our PendingStories component!

<script setup lang="ts">
import { useMainStore } from '@/store'
import type { StreamEvent } from '@/lib/types'

// We'll be talking about what a store is along with how to use it next
const store = useMainStore()

// Toast's are @nuxt/ui's notification framework. Since its a composable,
// we call useToast() here. 
const toast = useToast()

// Store the pending stories
const pendingStories = computed(() => store.pendingStories)

const previousMessages = ref<Record<string, string>>({})
const messages = ref<Record<string, string>>({})

// An event source is a JS DOM object for connecting to compatible SSE
// endpoints.
const es = ref<EventSource>()

// addMessage will take an id and message and add it to the appropriate
// key in messages.
const addMessage = (id:string, message: string) => {
    if (!messages.value[id]) messages.value[id] = ''
    if (!message) return
    messages.value[id] += message
}

// When we mount the component, fetch any pending stories
onMounted(async () => store.fetchPendingStories() )

// Close the stream when the component is unmounted
onBeforeUnmount(() => { es.value?.close() })

// Before we mount the component, open an event stream and setup the 
// event handlers for it.
onBeforeMount(() => { 
    es.value = new EventSource('/api/story/sse')
    
    // Setup the EventSource event handler
    es.value.onmessage = (event) => {
		    // Parse the event as StreamEvent
        const e = JSON.parse(event.data) as StreamEvent
        addMessage(e.id, e.message)

				// If this is the final message, determine if it is a 
				// success or failure
        if (e.final) {
		        // In either case, fetch the stories
            store.fetchStories()
            
            
            if (e.error) {
	            // If this was an error message, then truncate the prompt and notify
	            // the user that its creation failed. The previous message from our
	            // error message will be the failure reason.
                const truncatedPrompt = pendingStories.value[e.id].length > 50 ? pendingStories.value[e.id].substring(0, 50) + '...' : pendingStories.value[e.id]
                toast.add({
                    id: 'story-generating-failed',
                    title: `"${truncatedPrompt}" Generation Failed`,
                    description: `${previousMessages.value[e.id]}.`,
                    icon: 'i-heroicons-x-mark',
                    timeout: 30000,
                })
            } else {
								// Successful story creation
                toast.add({
                    id: 'story-created',
                    title: 'Story Created',
                    description: `A story you requested has been created.`,
                    icon: 'i-heroicons-check-circle-solid',
                })
            }
            
            // Delete all of the messages from the variable for this pending story
            delete messages.value[e.id]
            
            // Update the pending stories
            store.fetchPendingStories()
        }

        // Previous message is stored for error handling. When an error occurs, the previous message is the error message.
        if (messages.value[e.id]) {
            previousMessages.value[e.id] = e.message
        }
    }}
)
</script>

<template>
    <div class="flex flex-col space-y-2">
			  <!-- We'll create the component soon-->
        <New class="w-full"/>
        
        <!--
	        UPopover is an element that appears when, in this case, you hover
	        an element. We create one with a loading button for every pending
	        story that will recieve the appropriate messages.
	      -->
        <UPopover mode="hover" v-for="(prompt, id) in pendingStories" >
            <UButton truncate loading :key="id" size="lg" class="w-full text-xl" color="white" :label="prompt" icon="i-heroicons-book-open"/>

            <template #panel>
                <UCard class="p-4 w-[80vw] xl:w-[40vw]">
                    <h1 class="text-xl">Writing the perfect story...</h1>
                    <h2 class="text-zinc-400 mb-4">GPTScript is currently writing a story. Curious? You can see what it is thinking below!</h2>
                    <pre class="h-[26vh] bg-zinc-950 px-6 text-white overflow-scroll rounded shadow flex flex-col-reverse" style="white-space: pre-line;">
                        {{ messages[id] ? messages[id] : 'Waiting for response...'}}
                    </pre>
                </UCard>
            </template>
        </UPopover>
    </div>
</template>

This Vue component, created with the Composition API, is designed to render a list of pending stories. Let’s break down its key features:

  • pendingStories is a computed property that fetches the list of pending stories from the store.
  • previousMessages and messages are used to store the most recent message and the entire message history for each story ID, respectively.
  • es is a reference to an EventSource object, utilized for Server-Sent Events (SSE).

Three lifecycle hooks are employed:

  • onMounted is activated when the component first loads, triggering a fetch of the pending stories.
  • onBeforeUnmount ensures the EventSource is closed when the component is unmounted, preventing potential memory leaks.
  • onBeforeMount sets up the EventSource and its event handlers prior to the component rendering.

In the onBeforeMount hook, a new EventSource is created to listen to the /api/story/sse endpoint. An onmessage event handler is set up to manage incoming events from the server. Each event is parsed as a StreamEvent, the message is added to the appropriate message history, and a check is performed to determine if the message is the final one for a given story. If it is, a fetch of the stories from the server is triggered, the success or failure of the story generation is determined, and a notification is sent to the user accordingly. The message history for that story ID is then cleaned up and the pending stories are fetched again.

In the template section, a New component (to be created) is rendered, followed by a list of UPopover components for each pending story. Each UPopover contains a UButton that displays a loading animation and the story prompt, and a UCard that shows the current progress of the story generation. The UCard includes a pre element that renders the message history for the given story ID, enabling the user to receive real-time updates on the story generation process.

There are a few things here that we have yet to talk about or implement.

  1. Our store
  2. The New component

Let’s start with our store.

Creating our store

Stores in component-based frameworks like Vue and React allow you to have state that persists across components and pages. In our case, it allows for our components to more easily react to new stories and pending stories. For this tutorial, we’ll use pinia.

npm i pinia @pinia/nuxt

Pinia comes with a Nuxt module as well so we need to add it to our config.

// https://nuxt.com/docs/api/configuration/nuxt-config
export default defineNuxtConfig({
  devtools: { enabled: true },
  modules: [
    '@nuxt/ui',
    '@nuxtjs/tailwindcss',
    '@pinia/nuxt',
  ],
  runtimeConfig: {
    storiesVolumePath: 'stories', // NUXT_STORIES_VOLUME_PATH
  },
})

With that done, we need to create our store files.

mkdir store
touch store/index.ts

And finally, we can implement the store.

// store/index.ts
import { defineStore } from 'pinia'

export const useMainStore = defineStore({
    id: 'main',
    // This is the state that we're keeping track of
    state: () => ({
        pendingStories: {} as Record<string, string>,
        stories: [] as string[],
    }),
    // These are the actions that we can perform on that state. They're 
    // all relatively simple data retrieval actions.
    actions: {
        addStory(name: string) {
            this.stories.push(name)
        },
        addStories(names: string[]) {
            names.forEach(name => {
                if (!this.stories.includes(name)) {
                    this.stories.push(name)
                }
            })
        },
        removeStory(name: string) {
            this.stories = this.stories.filter(s => s !== name)
        },
        async fetchStories() {
            this.addStories(await $fetch('/api/story') as string[])
        },
        async fetchPendingStories() {
            this.pendingStories = await $fetch('/api/story/pending') as Record<string, string>
        },
    }
})

Here, we’re defining a store using pinia’s defineStore() function. This store will be used to manage the state of our application. The state we’re tracking includes pendingStories, which is an object with keys as story IDs and values as prompts, and stories, which is an array of story names. The actions section specifies the different operations we can perform on our state. We have addStory() to add a single story, addStories() for adding multiple stories, and removeStory() to delete a story from our state. fetchStories() and fetchPendingStories() are asynchronous actions that fetch the current stories and pending stories respectively from the backend using the $fetch() function. The fetched data is then added to our state.

Let’s move on to update how we create stories now.

New form

Last time, we had our form appear on the screen at all times. This is fine but since we’re moving toward a full application we want to instead have a button that opens a modal to fill out the form.

<script setup lang="ts">
import { useMainStore } from '@/store'

// Declare necessary values and utilize the store
const store = useMainStore()
const open = ref(false)
const pageOptions = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
const toast = useToast()

// Variable to contain the values of the form
const formValues = reactive({
    prompt: '',
    pages: 0,
})

// onSubmit will submit the values of the form to our API
async function onSubmit () {
		// Create the story using fetch with our formValues
    const response = await fetch('/api/story', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(formValues)
    })

    if (response.ok) {
		    // If the response succeeded, notify the user and fetch
		    // the pending stories
        toast.add({
            id: 'story-generating',
            title: 'Story Generating',
            description: 'Your story is being generated. Depending on the length, this may take a few minutes.',
            icon: 'i-heroicons-pencil-square-solid',
        })
        store.fetchPendingStories()
    } else {
			  // If the response failed, notify the user of the failure reason
        toast.add({
            id: 'story-generating-failed',
            title: 'Story Generation Failed',
            description: `Your story could not be generated due to an error: ${response.statusText}.`,
            icon: 'i-heroicons-x-mark',
        })
    }
    // Finally, close the modal 
    open.value = false
}
</script>

<template>
    <UButton size="lg" color="gray" class="w-full text-xl" icon="i-heroicons-plus" @click="open = true">New Story</UButton>
    <!-- When the above UButton is clicked, UModal will open -->
    <UModal fullscreen v-model="open" :ui="{width: 'sm:max-w-3/4 w-4/5 md:w-3/4 lg:w-1/2', }">
        <UCard class="h-full">
            <template #header>
                <div class="flex items-center justify-between">
                    <h1 class="text-2xl">Create a new story</h1>
                    <UButton color="white" icon="i-heroicons-x-mark-20-solid" class="-my-1" @click="open = false" />
                </div>
            </template>
            <div >
                <UForm class="h-full" :state=state @submit="onSubmit">
                    <UFormGroup size="lg" class="mb-6" label="Pages" name="pages">
                        <USelectMenu class="w-1/4 md:w-1/6"v-model="formValues.pages" :options="pageOptions"/>
                    </UFormGroup>
                    <UFormGroup class="my-6" label="Story" name="prompt">
                            <UTextarea :ui="{base: 'h-[50vh]'}" size="xl" class="" v-model="formValues.prompt" label="Prompt" placeholder="Put your full story here or prompt for a new one"/>
                    </UFormGroup>
                    <UButton size="xl" color="white" icon="i-heroicons-book-open" type="submit">Create Story</UButton>
                </UForm>
            </div>
        </UCard>    
    </UModal>
</template>

This component allows users to create a new story through a form in a modal. It enhances user experience by keeping the main page uncluttered. Upon form submission, an API call initiates the story generation on the server, and users receive notifications about the process. It’ll look like this:

create.png

Updating the homepage

Finally, let’s update our homepage to use all of these new components.

<script setup lang="ts">
    import { useMainStore } from '@/store'
    const store = useMainStore()
    onMounted(async () => store.fetchStories() )
</script>

<template>
    <UContainer class="w-full h-full flex items-center justify-center">
        <div class="w-full h-full md:h-1/2 p-2 mt-32">
            <h1 class="text-4xl text-center">Welcome to the Story Book!</h1>
            <h2 class="mt-6 text-xl text-center text-gray-500">
                Select one of today's story below or generate a new one. 
            </h2>
            <div class="flex mt-10 flex-col space-y-10 lg:flex-row lg:space-x-10 lg:space-y-0">
                <UCard class="w-full lg:w-1/2">
                    <template #header>
                        <h1>Generate a new story</h1>
                    </template>
                    <PendingStories />
                </UCard>
                <UCard class="w-full lg:w-1/2">
                    <template #header>
                        <h1>Completed Stories</h1>
                    </template>
                    <Stories />
                </UCard>
            </div>
        </div>
    </UContainer>
</template>

Our application should now look a little something like this now:

home.png

We’re in the home stretch! Let’s get this application packed and wrap up.

Bundling this into a container

If we want to actually deploy this thing, we’ll likely want it to be bundled into a container. To do this, we’ll need a Dockerfile.

touch Dockerfile

And then we can fill it out.

FROM node:20 as dev
WORKDIR /app
COPY . .
RUN npm install
RUN ./node_modules/.bin/gptscript --assemble story-book.gpt > story-book-assembled.gpt
EXPOSE 3000
CMD [ "npm", "run", "dev" ]

FROM node:20 as prod
WORKDIR /app
COPY --from=dev /app .
RUN npm run build
CMD [ "npm", "run", "start" ]

When you go to deploy this, you’ll need to make sure you do so we your OPENAI_API_KEY variable set. Most if not all deployment services available today allow this.

Finally, we’ll want to setup our CI/CD to build and deploy this image in Github. I’ll be using Github actions since its easy to integrate with any repo but feel free to choose your CI/CD platform of choice.

First, create the relevant files.

mkdir -p .github/workflows
touch release.yaml

Then write the workflow.

name: release
on:
  push:
    branches:
    - main
    tags:
    - v*

jobs:
  build_push:
    runs-on: ubuntu-latest
    steps:
    - name: Check out the repo
      uses: actions/checkout@v3

    - name: Login to ghcr.io
      uses: docker/login-action@v1
      with:
        registry: ghcr.io
        username: ${{ github.actor }}
        password: ${{ secrets.GITHUB_TOKEN }}

    - name: Build and push the image
      uses: docker/build-push-action@v2
      with:
        context: .
        push: true
        tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}

This is a drop-in action and will work in any repo it is put into. It uses Github’s authentication key to push the image to GHCR.

With that, we’re done! You can take this image and deploy it anywhere you please.

Releated Articles