Skip to content

Instantly share code, notes, and snippets.

@nirkaufman
Created January 6, 2025 15:48
Show Gist options
  • Select an option

  • Save nirkaufman/aceafcc3d0c9a31eb2f47655ab3a8c55 to your computer and use it in GitHub Desktop.

Select an option

Save nirkaufman/aceafcc3d0c9a31eb2f47655ab3a8c55 to your computer and use it in GitHub Desktop.

Generative UI

Step 1 - New Project Setup

  1. craete new nextjs project with tailwind as your CSS framework
    npx create-next-app@latest
  1. create openAI API Key at: https://platform.openai.com/
  2. carete an .env file at the root of your project with OpenAI key
    OPENAI_API_KEY=***********
  1. install vercel AI SDK
   npm install ai @ai-sdk/openai zod
  1. run your project
   npm run dev
  1. cleanup your components

Step 2 - Implement simple chat

  1. create an api route:

app/api/chat/route.ts

import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';

// Allow streaming responses up to 30 seconds
export const maxDuration = 30;

export async function POST(req: Request) {
  const { messages } = await req.json();

  const result = streamText({
    model: openai('gpt-4-turbo'),
    system: 'You are a helpful assistant.',
    messages,
  });

  return result.toDataStreamResponse();
}
  1. Rafctor the code on your page and use useChat hook

app/page.tsx

'use client';

import { useChat } from 'ai/react';

export default function Page() {
  const { messages, input, handleInputChange, handleSubmit } = useChat({});

  return (
    <>
      {messages.map(message => (
        <div key={message.id}>
          {message.role === 'user' ? 'User: ' : 'AI: '}
          {message.content}
        </div>
      ))}

      <form onSubmit={handleSubmit}>
        <input name="prompt" value={input} onChange={handleInputChange} />
        <button type="submit">Submit</button>
      </form>
    </>
  );
}
  1. test your code: customize system message, style with tailwind (get help from LLM)

Step 2 - Add funcualiity

  1. use isLoading from useChat to handle loading state
function Page() {
    const { isLoading} = useChat({});
    
    return (
         {isLoading && (
            <div>
              <Spinner />
              <button type="button" onClick={() => stop()}>
                Stop
              </button>
            </div>
          )}
    )
}
  1. use error and reload from useChat to handle error state
function Page() {
    const { error, reload } = useChat({});
    
    return (
        {error && (
        <>
          <div>An error occurred.</div>
          <button type="button" onClick={() => reload()}>
            Retry
          </button>
        </>
      )}
    )
}
  1. use 'stop' for caceling genration
function Page() {
    const { stop } = useChat({});
    
    return (
        <button onClick={stop} disabled={!isLoading}>Stop</button>
    )
}

Step 3 - Generative UI: CSR

  1. create one or more custom tools of your choice.
  2. Think about the description
  3. you can communicate with external API
  4. as an example, here is a whteher tool with hard codded data:

tools.ts

import { tool as createTool } from 'ai';
import { z } from 'zod';

export const weatherTool = createTool({
  description: 'Display the weather for a location',
  parameters: z.object({
    location: z.string().describe('The location to get the weather for'),
  }),
  execute: async function ({ location }) {
    await new Promise(resolve => setTimeout(resolve, 2000));
    return { weather: 'Sunny', temperature: 75, location };
  },
});

export const tools = {
  displayWeather: weatherTool,
};
  1. update the chat API route to include your tools
  2. add maxSteps to enable
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
import { tools } from '@/ai/tools';

export async function POST(request: Request) {
  const { messages } = await request.json();

  const result = streamText({
    model: openai('gpt-4o'),
    system: 'You are a friendly assistant!',
    messages,
    maxSteps: 5,
    tools,
  });

  return result.toDataStreamResponse();
}
  1. create a UI component to render the result for your custom tool
  2. in the following example, we identify a tool call, and render the desired component
'use client';

import { useChat } from 'ai/react';
import { Weather } from '@/components/weather';

export default function Page() {
  const { messages, input, handleInputChange, handleSubmit } = useChat();

  return (
    <div>
      {messages.map(message => (
        <div key={message.id}>
          <div>{message.role === 'user' ? 'User: ' : 'AI: '}</div>
          <div>{message.content}</div>

          <div>
            {message.toolInvocations?.map(toolInvocation => {
              const { toolName, toolCallId, state } = toolInvocation;

              if (state === 'result') {
                if (toolName === 'displayWeather') {
                  const { result } = toolInvocation;
                  return (
                    <div key={toolCallId}>
                      <Weather {...result} />
                    </div>
                  );
                }
              } else {
                return (
                  <div key={toolCallId}>
                    {toolName === 'displayWeather' ? (
                      <div>Loading weather...</div>
                    ) : null}
                  </div>
                );
              }
            })}
          </div>
        </div>
      ))}

      <form onSubmit={handleSubmit}>
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Type a message..."
        />
        <button type="submit">Send</button>
      </form>
    </div>
  );
}
  1. The code flow is as follows:
  • Check if the message has toolInvocations.
  • Check if the tool invocation state is 'result'.
  • If it's a result and the tool name is 'displayWeather', render the Weather component.
  • If the tool invocation state is not 'result', show a loading message.
  1. add more tools and components for your choice

Step 4 - Generative UI - SSR

  1. this time we will use streamUI function as an action
  2. start by creating mock function that communicate with the outside world

flight-actions.tsx

// simulate lataency in response
async function wait(ms: number): Promise<void> {
  return new Promise(resolve => setTimeout(resolve, ms));
}


const searchFlights = async (
  source: string,
  destination: string,
  date: string,
) => {
  await wait(2000) // wait for 2 seconds
  return [
    {
      id: '1',
      flightNumber: 'AA123',
    },
    {
      id: '2',
      flightNumber: 'AA456',
    },
  ];
};

const lookupFlight = async (flightNumber: string) => {
  await wait(2000) // wait for 2 seconds
  return {
    flightNumber: flightNumber,
    departureTime: '10:00 AM',
    arrivalTime: '12:00 PM',
  };
};
  1. our custom tools will return JSX instead of plain objects

flight-actions.tsx

export async function submitUserMessage(input: string) {
  'use server';

  const ui = await streamUI({
    model: openai('gpt-4o'),
    system: 'you are a flight booking assistant',
    prompt: input,
    text: async ({ content }) => <div>{content}</div>,
    tools: {
      searchFlights: {
        description: 'search for flights',
        parameters: z.object({
          source: z.string().describe('The origin of the flight'),
          destination: z.string().describe('The destination of the flight'),
          date: z.string().describe('The date of the flight'),
        }),
        generate: async function* ({ source, destination, date }) {
          yield `Searching for flights from ${source} to ${destination} on ${date}...`;
          const results = await searchFlights(source, destination, date);

          return (
              <div>
                {results.map(result => (
                    <div key={result.id}>
                      <div>{result.flightNumber}</div>
                    </div>
                ))}
              </div>
          );
        },
      },
      lookupFlight: {
        description: 'lookup details for a flight',
        parameters: z.object({
          flightNumber: z.string().describe('The flight number'),
        }),
        generate: async function* ({ flightNumber }) {
          yield `Looking up details for flight ${flightNumber}...`;
          const details = await lookupFlight(flightNumber);

          return (
              <div>
                <div>Flight Number: {details.flightNumber}</div>
                <div>Departure Time: {details.departureTime}</div>
                <div>Arrival Time: {details.arrivalTime}</div>
              </div>
          );
        },
      },
    },
  });

  return ui.value;
}
    
  1. use the createAI function to create shard context

ai.ts

import { createAI } from 'ai/rsc';
import { submitUserMessage } from './actions';

export const AI = createAI<any[], React.ReactNode[]>({
  initialUIState: [],
  initialAIState: [],
  actions: {
    submitUserMessage,
  },
});
  1. wrap the entire application with this context

layout.tsx

import { type ReactNode } from 'react';
import { AI } from './ai';

export default function RootLayout({
  children,
}: Readonly<{ children: ReactNode }>) {
  return (
    <AI>
      <html lang="en">
        <body>{children}</body>
      </html>
    </AI>
  );
}
  1. use the useUIState and useActions hooks

page.tsx

'use client';

import { useState } from 'react';
import { AI } from './ai';
import { useActions, useUIState } from 'ai/rsc';

export default function Page() {
  const [input, setInput] = useState<string>('');
  const [conversation, setConversation] = useUIState<typeof AI>();
  const { submitUserMessage } = useActions();

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setInput('');
    setConversation(currentConversation => [
      ...currentConversation,
      <div>{input}</div>,
    ]);
    const message = await submitUserMessage(input);
    setConversation(currentConversation => [...currentConversation, message]);
  };

  return (
    <div>
      <div>
        {conversation.map((message, i) => (
          <div key={i}>{message}</div>
        ))}
      </div>
      <div>
        <form onSubmit={handleSubmit}>
          <input
            type="text"
            value={input}
            onChange={e => setInput(e.target.value)}
          />
          <button>Send Message</button>
        </form>
      </div>
    </div>
  );
}

Create interactive components

  1. to add interactivity we need to build custom react client components
  2. use useUIState to push new message back to the feed

Flights.tsx

'use client';

import { useActions, useUIState } from 'ai/rsc';
import { ReactNode } from 'react';

interface FlightsProps {
  flights: { id: string; flightNumber: string }[];
}

export const Flights = ({ flights }: FlightsProps) => {
  const { submitUserMessage } = useActions();
  const [_, setMessages] = useUIState();

  return (
    <div>
      {flights.map(result => (
        <div key={result.id}>
          <div
            onClick={async () => {
              const display = await submitUserMessage(
                `lookupFlight ${result.flightNumber}`,
              );

              setMessages((messages: ReactNode[]) => [...messages, display]);
            }}
          >
            {result.flightNumber}
          </div>
        </div>
      ))}
    </div>
  );
};
  1. update the tool to return the custom interactive component

flight-actions.tsx

searchFlights: {
  description: 'search for flights',
  parameters: z.object({
    source: z.string().describe('The origin of the flight'),
    destination: z.string().describe('The destination of the flight'),
    date: z.string().describe('The date of the flight'),
  }),
  generate: async function* ({ source, destination, date }) {
    yield `Searching for flights from ${source} to ${destination} on ${date}...`;
    const results = await searchFlights(source, destination, date);
    return (<Flights flights={results} />);
  },
}
  1. build a skeleton component (get help from ChatGPT)
  2. use it to enhance UX

flight-actions.tsx

searchFlights: {
  description: 'search for flights',
  parameters: z.object({
    source: z.string().describe('The origin of the flight'),
    destination: z.string().describe('The destination of the flight'),
    date: z.string().describe('The date of the flight'),
  }),
  generate: async function* ({ source, destination, date }) {
    yield <FlightsLoader />
    const results = await searchFlights(source, destination, date);
    return <Flights flights={results} />;
  },
}
  1. add more components, fine tune system prompts and tool description
@notflip
Copy link

notflip commented Jun 9, 2025

Thanks for this, I wonder how you handle tool call errors, let's say a tool throws an error, this would keep showing "Loading weather" (in the weather case), since there's no error state for tools. How would you handle this?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment