Create a simple fitness planner app with JamAI and Next.js with streaming text.

What are we going to build?

This project utilizes Next.js 14 and JamAI to create a personalized daily workout and meal plan based on user profiles (age, height, weight, sex, and desired body type). It leverages streaming for a better user experience.

The full source code of this project can be found on JamAIBase cookbook.

Fitness Planner Web Application.

Project setup

Create the Action Table

First we will create the fitness_planner Action table with the following input columns:

  • age: int

  • height: float

  • weight: float

  • sex: sting

  • preferred_body_type: string

creating action table

Add workout output column with the following settings.

Prompt:

Suggest daily workout plan for a ${sex} of ${age} years old, height of ${height} centimeters and have a weight of ${weight} KG. The preferred body type is ${preferred_body_type}.

System Prompt:

You are a certified virtual gym trainer with a passion for helping people achieve their fitness goals. Your primary function is to design safe and effective personalized workout plans based on user input. Your response should consist only workout plan. The output should be in the Markdown format.

The model settings are left as default, but you can customize the parameters according to your prefference.

Add meal output column

Prompt:

suggest daily meal plan for a ${sex} of ${age} years old, height of ${height} centimeters and have a weight of ${weight} KG. The preferred body type is ${preferred_body_type}.

System Prompt:

You are an expert virtual nutritionist, you help people to plan their diet based on their age, height, weight, sex, preferred body type and so on. Your response should consist of only diet plan. The output should be in the Markdown format.

Try your prompts

Once you're happy with the results, use the JamAi JS/TS SDK in your Next.js app.

Create your Next.js app

The app will have a single page with a form in the left side and 2 output box to display the output of JamAI in markdown format.

Fitness Planner app.

Initialize the project

Create new next.js project and move to that directory.

npx create-next-app@latest
cd (path/to/your/app)

Install JamAI SDK

npm install jamaibase

Add the secrets to .env.local file at the root of your project

NEXT_PUBLIC_JAMAI_BASEURL=http://api.jamaibase.com/
JAMAI_API_KEY=<your_jamai_sk_api_key>
JAMAI_PROJECT_ID=<your_proj_id>

Generate API Key -> Go to cloud.jamaibase.com/organization/secrets and collect API Key

Collect Project ID -> Go to cloud.jamaibase.com/project and copy the project id.

Install necessary libraries

Install vercel AI SDK library to stream the output from route handler and react markdown to display markdown output.

npm i react-markdown ai

Create Route Handler

As the SDK/API requires API Key, we will use it on the server side for secure interaction. So, we will write a route handler that will allow us to create custom request handlers for a given route using the Web Request and Response APIs.

Create a route handler with the following code:

// src/app/api/get-fitness-suggestion/route/ts

import JamAI from "jamaibase";
import { NextRequest, NextResponse } from "next/server";
import { StreamingTextResponse } from "ai";
import { GenTableStreamChatCompletionChunk } from "jamaibase/resources/gen_tables/chat";

const jamai = new JamAI({
    baseURL: process.env.NEXT_PUBLIC_JAMAI_BASEURL!,
    apiKey: process.env.JAMAI_API_KEY,
    projectId: process.env.JAMAI_PROJECT_ID,
});

export async function POST(request: NextRequest) {
    const body = await request.json();
    try {
        let stream = await jamai.addRowStream({
            table_type: "action",
            data: [
                {
                    age: body.age,
                    height: body.height,
                    weight: body.weight,
                    sex: body.sex,
                    preferred_body_type: body.preferredBodyType,
                },
            ],
            table_id: "fitness_planner",
            reindex: null,
            concurrent: true,
        });

        return new StreamingTextResponse(streamText(stream));
    } catch (error: any) {
        console.error("Error fetching tables:", error.response);
        return NextResponse.json(
            { message: "Internal server error" },
            { status: 500 }
        );
    }
}

The above code first create a JamAI client and then use the addRowStream funtion to add a new row to the action table and get the output as stream.

Concurrent is set to true, which means the output of the both columns will be generated concurrently.

Add custom streamText function:

Unfortunately, the StreamingTextResponse method of vercel AI sdk only accepts texts but the chunk returned by the sdk is of GenTableStreamChatCompletionChunk type. Therefore, in order to stringify the chunk, use the following function.

function streamText(
    inputStream: ReadableStream<GenTableStreamChatCompletionChunk>
): ReadableStream<string> {
    const reader = inputStream.getReader();

    return new ReadableStream<string>({
        async start(controller) {
            while (true) {
                const { done, value } = await reader.read();
                if (done) {
                    controller.close();
                    break;
                }
                // Extract the text content from the chunk
                const text = JSON.stringify(value);

                if (text) {
                    // Enqueue the text content as a chunk to the new stream
                    controller.enqueue(text);
                }
            }
        },
        cancel() {
            reader.releaseLock();
        },
    });
}

Build the UI and use the route handler

Add the following code to to page.tsx file:

// src/app/page.tsx

"use client";

import ReactMarkdown from "react-markdown";
import { ChangeEvent, useState } from "react";
import { processJsonStream } from "./utils";
import { GenTableStreamChatCompletionChunk } from "jamaibase/resources/gen_tables/chat";

export default function HomePage() {
    const [age, setAge] = useState<number | null>(null);
    const [weight, setWeight] = useState<number | null>(null);
    const [height, setHeight] = useState<number | null>(null);
    const [preferredBodyType, setPreferredBodyType] = useState<string>("");
    const [workoutPlan, setWorkoutPlan] = useState<string>("");
    const [mealPlan, setMealPlan] = useState<string>("");
    const [isLoading, setIsLoading] = useState<boolean>(false);

    const [sex, setSex] = useState("male");

    const handleSexChange = (e: ChangeEvent<HTMLSelectElement>) => {
        setSex(e.target.value);
    };

    const handleSubmit = async () => {
        setIsLoading(true);
        setWorkoutPlan("");
        setMealPlan("");
        if (age && weight && height && preferredBodyType && sex) {
            const response = await fetch(`/api/get-fitness-suggestion`, {
                method: "POST",

                body: JSON.stringify({
                    age,
                    weight,
                    height,
                    sex,
                    preferredBodyType,
                }),
            });
            if (response.ok && response.body) {
                const reader = response.body.getReader();
                processJsonStream<GenTableStreamChatCompletionChunk>(
                    reader,
                    (content) => {
                        if (content.output_column_name == "workout") {
                            setWorkoutPlan(
                                (prev) =>
                                    prev + content.choices[0]?.message.content
                            );
                        } else {
                            setMealPlan(
                                (prev) =>
                                    prev + content.choices[0]?.message.content
                            );
                        }
                    },
                    () => setIsLoading(false)
                );
            } else {
                alert("Something went wrong");
            }
        } else {
            alert("Please fill in all fields.");
            setIsLoading(false);
        }
    };

    return (
        <main className="p-12 ">
            <h1 className="text-3xl font-bold text-center mb-8 underline">
                My Fitness Planner
            </h1>
            <div className="grid grid-cols-3 mx-6 gap-x-6">
                <div className=" p-6 shadow-md rounded-md">
                    <h2 className="text-2xl font-bold mb-6">
                        Suggested Daily Workout Plan :
                    </h2>
                    <div className="mb-4">
                        <label className="block text-sm font-medium text-gray-700">
                            Age
                        </label>
                        <input
                            type="number"
                            required
                            className="mt-1 p-2 block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
                            value={age || ""}
                            onChange={(e) => setAge(Number(e.target.value))}
                        />
                    </div>
                    <div className="mb-4">
                        <label className="block text-sm font-medium text-gray-700">
                            Weight (kg)
                        </label>
                        <input
                            type="number"
                            step="0.1"
                            required
                            className="mt-1 p-2 block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
                            value={weight || ""}
                            onChange={(e) => setWeight(Number(e.target.value))}
                        />
                    </div>
                    <div className="mb-4">
                        <label className="block text-sm font-medium text-gray-700">
                            Height (cm)
                        </label>
                        <input
                            type="number"
                            step="0.1"
                            required
                            className="mt-1 p-2 block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
                            value={height || ""}
                            onChange={(e) => setHeight(Number(e.target.value))}
                        />
                    </div>
                    <div className="mb-6">
                        <label
                            htmlFor="movieGenre"
                            className="block text-sm font-medium text-gray-700 mb-2"
                        >
                            Sex
                        </label>
                        <select
                            value={sex}
                            onChange={handleSexChange}
                            required
                            className="block w-full py-3 px-3 border border-gray-300 bg-white rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm text-black"
                        >
                            <option value="male">Male</option>
                            <option value="female">Female</option>
                        </select>
                    </div>

                    <div className="mb-4">
                        <label className="block text-sm font-medium text-gray-700">
                            Preferred Body Type
                            <span className="text-gray-500">
                                (eg: lean, athletic, bulky)
                            </span>
                        </label>
                        <input
                            type="text"
                            required
                            className="mt-1 p-2 block w-full border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500"
                            value={preferredBodyType}
                            onChange={(e) =>
                                setPreferredBodyType(e.target.value)
                            }
                        />
                    </div>
                    <button
                        disabled={isLoading}
                        onClick={handleSubmit}
                        className={`w-full  text-white py-2 px-4 rounded-md  ${
                            !isLoading
                                ? "bg-indigo-500 hover:bg-indigo-600"
                                : "bg-gray-500 hover:bg-gray-500 cursor-default"
                        }`}
                    >
                        Get Workout Plan
                    </button>
                </div>

                <div className="p-4 border  border-gray-300 rounded-md">
                    <h2 className="text-2xl font-bold mb-6">
                        Suggested Daily Workout Plan :
                    </h2>
                    <ReactMarkdown>{workoutPlan}</ReactMarkdown>
                </div>
                <div className="p-4 border  border-gray-300 rounded-md">
                    <h2 className="text-2xl font-bold mb-6">
                        Suggested Daily Meal Plan :
                    </h2>
                    <ReactMarkdown>{mealPlan}</ReactMarkdown>
                </div>
            </div>
        </main>
    );
}

Process JSON Stream

When sending streams, it's common for the chunks to get concentrated. So, if we want to parse them, they will fail. So, we will write a utility function to process the JSON stream on the client side.

// src/lib/utils.ts

export async function processJsonStream<T>(
    reader: ReadableStreamDefaultReader<Uint8Array>,
    processContent: (content: T) => void,
    handleComplete?: () => void,
    handleError?: (error: any) => void
) {
    const decoder = new TextDecoder("utf-8");
    let buffer = "";

    while (true) {
        const { done, value } = await reader.read();
        if (done) break;
        const chunk = decoder.decode(value, { stream: true });
        buffer += chunk;

        // Split by the delimiter "}{" }
        let boundary = buffer.indexOf("}{");

        while (boundary !== -1) {
            const jsonString = buffer.substring(0, boundary + 1);
            buffer = buffer.substring(boundary + 1);

            try {
                const parsedChunk = JSON.parse(jsonString);
                processContent(parsedChunk);
            } catch (error) {
                console.error("Error parsing JSON chunk:", jsonString);
                handleError && handleError(error);
            }

            boundary = buffer.indexOf("}{");
        }

        // Attempt to parse whatever is left in the buffer
        try {
            const parsedChunk = JSON.parse(buffer);
            processContent(parsedChunk);
            buffer = "";
        } catch (error) {
            // Continue buffering since we might not have a complete JSON object yet
        }
    }

    if (buffer.trim()) {
        try {
            const parsedChunk = JSON.parse(buffer.trim());
            processContent(parsedChunk);
        } catch (error) {
            console.error("Error parsing remaining buffer:", buffer.trim());
            handleError && handleError(error);
        }
    }

    handleComplete && handleComplete();
}

Run the App

npm run dev

Conclusion

Once you get your app running, you can look at your Jamai Action table rows to review the data that you can access with your app.

Next Steps

We have demonstrated how easy it is to store data and use LLM's to update your cell values with prompts directly. This simple example demonstrates the simple use of an Action Table here in your app. If you have related data you want to leverage and make your app smarter, you can implement the Knowledge Table to make your Action Tables smarter with Retrieval Augmented Generation (RAG).

For example, you can add your menu catalog and let meal columns recommend meals based on your menu.

Last updated