ReactNextCentral
Published on
예제 Next.js AI Chatbot

4. OpenAI Assistants 기반으로 클라이언트에서 날씨 컴포넌트 렌더링

Authors
Table of Contents

학습 목표 및 소개

이번 페이지에서는 OpenAI Assistants API를 사용하여 클라이언트 측에서 날씨 정보를 렌더링하는 방법을 배웁니다. 이 실습을 통해 OpenAI Assistants의 기본 개념과 사용법을 익히고, 이를 통해 실시간 데이터를 기반으로 한 AI 어시스턴트를 구축할 수 있습니다.

배울 내용

이번 실습을 통해 배울 수 있는 내용은 다음과 같습니다.

  • OpenAI Assistant 개념 및 설정 방법
  • 버셀 AI SDK에서 useAssistant 훅과 AssistantResponse()를 활용하여 Assistant 사용하는 방법
  • OpenAI Assistant의 도구(함수 호출) 결과를 버셀 AI SDK에서 받아 실시간 날씨 정보를 연동하고 UI 컴포넌트로 렌더링하는 방법

실행 화면

아래 실습한 파일을 저장하고 npm run dev 명령어를 실행하고 브라우저에서 http://localhost:3000/assistants을 입력하여 접속하면, 다음과 같은 실행 결과를 확인할 수 있습니다. 이전 실습에서 했던 기능들과 동일합니다.

레벨업 날씨 AI 챗봇 v2의 제주도 날씨 문의 시작레벨업 날씨 AI 챗봇 v2의 제주도 날씨 문의 완료레벨업 날씨 AI 챗봇 v2의 제주도 날씨 문의 완료

OpenAI Assistant 소개

OpenAI Assistants API는 개발자가 AI 어시스턴트를 애플리케이션에 통합할 수 있도록 지원하는 도구입니다. 이 API를 통해 어시스턴트는 코드 해석, 파일 검색, 함수 호출 등의 도구를 사용해 다양한 작업을 수행할 수 있습니다. 어시스턴트는 OpenAI 모델에 특정 지시사항을 전달해 성격과 기능을 조정할 수 있으며, 여러 도구를 병행하여 활용할 수 있습니다.

어시스턴트는 대화 세션(Threads) 기능을 통해 메시지 히스토리를 관리하고, 사용자가 주고받는 메시지를 저장합니다. 이를 통해 대화가 길어져도 효율적으로 맥락을 유지할 수 있습니다. 또한, 어시스턴트는 파일을 생성하거나 참조할 수 있으며, 생성된 파일은 메시지와 함께 관리됩니다.


OpenAI에서 Assistant 설정

Assistant 사용을 위해 OpenAI 사이트에서 아래와 같이 설정해야 합니다.

  1. 웹에서 OpenAI 사이트 내 Assistant 설정 페이지로 이동합니다.
  2. Create 버튼을 눌러 새로운 Assistant를 생성합니다.
  3. Name, Instrunctions, Functions를 아래와 같이 설정합니다.
레벨업 날씨 AI 챗봇 v2의 제주도 날씨 문의 시작레벨업 날씨 AI 챗봇 v2의 제주도 날씨 문의 완료
Name
Weather Assistant
Instrunctions
You are an assistant with access to real-time weather information. You can provide the current weather details for any specific city or region. Users can provide the name of the city and nation, and optionally specify the temperature unit (Celsius or Fahrenheit) and the language for the response.

If the user does not specify the temperature unit, use Celsius by default. If the user requests Fahrenheit, you should convert the temperature from Celsius to Fahrenheit.

Additionally, provide any other relevant weather details such as humidity, wind speed, and weather conditions (e.g., sunny, cloudy, rainy).
Functions:get_current_weather
{
  "name": "get_current_weather",
  "description": "Get the current weather",
  "parameters": {
    "type": "object",
    "properties": {
      "location": {
        "type": "string",
        "description": "The city and state in English, even if the input is in Korean, e.g., Seoul, Jeju. 도시와 주의 이름은 영어로 입력해야 합니다. 입력이 한글일지라도 영어 도시 이름으로 변환되어야 합니다. 예: 서울 -> Seoul, 제주 -> Jeju"
      },
      "nation": {
        "type": "string",
        "description": "The country or nation of the location, e.g., S.Korea"
      },
      "unit": {
        "type": "string",
        "enum": [
          "celsius",
          "fahrenheit"
        ],
        "description": "The temperature unit to use. Infer this from the user's location."
      },
      "language": {
        "type": "string",
        "description": "The language of the nation, e.g., 한국어"
      }
    },
    "required": [
      "location",
      "nation",
      "unit",
      "language"
    ]
  }
}
  1. Next.js 웹 프로젝트에서 사용하기 위해 Assistant ID를 복사해 둡니다.
  2. .env.local 파일을 열어서 아래와 같이 Assistant ID를 설정합니다.
ASSISTANT_ID="asst_your_assistant_id"

추가 패키지 설치

이 실습을 위해서는 openai 패키지에 대해 추가적으로 설치가 필요합니다.

npm install openai

서버 측 라우트 핸들러 구현

먼저 app/api/ 폴더 안에 assistant 폴더를 생성하고 route.ts 파일을 만들어서 아래와 같이 구현합니다.

app/api/route.ts
import { AssistantResponse } from 'ai';
import OpenAI from 'openai';        // npm install openai
import { fetchWeatherData, WeatherParams } from '@/base/weather';

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY || '',
});

export const maxDuration = 30;
export const dynamic = 'force-dynamic';

export async function POST(req: Request) {
  const input: {
    threadId: string | null;
    message: string;
  } = await req.json();

  const threadId = input.threadId ?? (await openai.beta.threads.create({})).id;

  const createdMessage = await openai.beta.threads.messages.create(threadId, {
    role: 'user',
    content: input.message,
  });

  return AssistantResponse(
    { threadId, messageId: createdMessage.id },
    async ({ forwardStream, sendDataMessage }) => {
      const runStream = openai.beta.threads.runs.stream(threadId, {
        assistant_id:
          process.env.ASSISTANT_ID ??
          (() => {
            throw new Error('ASSISTANT_ID is not set');
          })(),
      });

      let runResult = await forwardStream(runStream);

      while (
        runResult?.status === 'requires_action' &&
        runResult.required_action?.type === 'submit_tool_outputs'
      ) {
        const tool_outputs =
          await Promise.all(runResult.required_action.submit_tool_outputs.tool_calls.map(
            async (toolCall: any) => {
              const parameters = JSON.parse(toolCall.function.arguments);

              switch (toolCall.function.name) {
                case 'get_current_weather': {
                  const weatherParams: WeatherParams = {
                    location: parameters.location,
                    nation: parameters.nation,
                    unit: parameters.unit,
                    language: parameters.language,
                  };
                  console.info(weatherParams)
                  const weatherData = await fetchWeatherData(weatherParams);

                  sendDataMessage({
                    role: 'data',
                    data: weatherData
                  });

                  return {
                    tool_call_id: toolCall.id,
                    output: JSON.stringify(weatherData),
                  };
                }
                default:
                  throw new Error(
                    `Unknown tool call function: ${toolCall.function.name}`,
                  );
              }
            },
          ));

        runResult = await forwardStream(
          openai.beta.threads.runs.submitToolOutputsStream(
            threadId,
            runResult.id,
            { tool_outputs },
          ),
        );
      }
    },
  );
}

이 소스 코드는 OpenAI의 Assistant API와 연동하여 사용자의 요청에 따라 실시간으로 데이터를 처리하고 응답을 반환하는 서버 측 라우트 핸들러를 구현합니다. 코드를 기능별로 나누어 설명하겠습니다.

1. 기본 설정 및 모듈 임포트

import { AssistantResponse } from 'ai';
import OpenAI from 'openai';        // npm install openai
import { fetchWeatherData, WeatherParams } from '@/base/weather';
  • AssistantResponse: AI Assistant의 응답을 처리하기 위한 함수입니다.
  • OpenAI: OpenAI API를 사용하기 위한 클라이언트 객체를 생성합니다.
  • fetchWeatherDataWeatherParams: 날씨 데이터를 가져오는 함수와 관련된 타입을 임포트하여 이후 도구 호출 시 사용할 준비를 합니다.

2. OpenAI 클라이언트 초기화 및 환경 설정

const openai = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY || '',
});

export const maxDuration = 30;
export const dynamic = 'force-dynamic';
  • openai: OpenAI API를 초기화하는 객체로, API 키를 설정합니다.
  • maxDurationdynamic: 서버 측 라우트의 설정으로, 최대 실행 시간과 동적 페이지 로딩을 강제합니다.

3. POST 요청 처리 및 스레드 생성

export async function POST(req: Request) {
  const input: {
    threadId: string | null;
    message: string;
  } = await req.json();

  const threadId = input.threadId ?? (await openai.beta.threads.create({})).id;
  • 사용자가 보낸 JSON 데이터를 파싱하여 threadIdmessage를 추출합니다.
  • threadId가 없으면 새로운 스레드를 생성하여 ID를 반환합니다.

4. 메시지 생성 및 Assistant 응답 처리

  const createdMessage = await openai.beta.threads.messages.create(threadId, {
    role: 'user',
    content: input.message,
  });

  return AssistantResponse(
    { threadId, messageId: createdMessage.id },
    async ({ forwardStream, sendDataMessage }) => {
      const runStream = openai.beta.threads.runs.stream(threadId, {
        assistant_id:
          process.env.ASSISTANT_ID ??
          (() => {
            throw new Error('ASSISTANT_ID is not set');
          })(),
      });

      let runResult = await forwardStream(runStream);
  • createdMessage: 사용자의 메시지를 스레드에 추가합니다.
  • AssistantResponse: Assistant의 응답을 처리하기 위한 함수입니다. 스레드 ID와 메시지 ID를 기반으로 응답을 생성합니다.
  • forwardStream: OpenAI의 응답 스트림을 전달하며, Assistant가 처리한 결과를 받아옵니다.

5. 도구 호출 및 결과 처리

      while (
        runResult?.status === 'requires_action' &&
        runResult.required_action?.type === 'submit_tool_outputs'
      ) {
        const tool_outputs =
          await Promise.all(runResult.required_action.submit_tool_outputs.tool_calls.map(
            async (toolCall: any) => {
              const parameters = JSON.parse(toolCall.function.arguments);

              switch (toolCall.function.name) {
                case 'get_current_weather': {
                  const weatherParams: WeatherParams = {
                    location: parameters.location,
                    nation: parameters.nation,
                    unit: parameters.unit,
                    language: parameters.language,
                  };
                  console.info(weatherParams)
                  const weatherData = await fetchWeatherData(weatherParams);

                  sendDataMessage({
                    role: 'data',
                    data: weatherData
                  });

                  return {
                    tool_call_id: toolCall.id,
                    output: JSON.stringify(weatherData),
                  };
                }
                default:
                  throw new Error(
                    `Unknown tool call function: ${toolCall.function.name}`,
                  );
              }
            },
          ));

        runResult = await forwardStream(
          openai.beta.threads.runs.submitToolOutputsStream(
            threadId,
            runResult.id,
            { tool_outputs },
          ),
        );
      }
  • runResult의 상태 확인: Assistant의 실행 결과를 확인하고, 도구 호출이 필요한 경우(예: 날씨 정보 조회) 이를 처리합니다.
  • toolCall.function.name: 도구 호출의 이름을 확인하여, 그에 맞는 로직을 실행합니다.
    • get_current_weather: 날씨 정보를 가져오기 위한 도구 호출입니다. fetchWeatherData 함수를 사용해 OpenWeatherMap API에서 실시간 날씨 데이터를 가져옵니다.
    • sendDataMessage: 데이터를 전달하여 실시간 날씨 정보를 클라이언트로 전송합니다.
  • 도구 호출 결과 반환: 도구 호출 결과를 JSON 형식으로 반환하여, 다음 단계에서 사용됩니다.

6. 최종 도구 출력 제출

        runResult = await forwardStream(
          openai.beta.threads.runs.submitToolOutputsStream(
            threadId,
            runResult.id,
            { tool_outputs },
          ),
        );
      }
    },
  );
}
  • submitToolOutputsStream: 도구 호출의 결과를 제출하여, 다음 Assistant의 작업을 이어갈 수 있도록 합니다.
  • forwardStream: 계속해서 Assistant의 응답 스트림을 처리합니다.

이 구현은 사용자의 입력에 따라 실시간 데이터를 처리하고, OpenAI의 Assistant를 통해 적절한 응답을 생성해 클라이언트로 반환하는 과정을 관리합니다.


클라이언트 측 구현

이제 클라이언트에서 동작되는 프론트엔드 코드를 구현하기 위해 app/ 폴더 내에 assistants/ 폴더를 만들고, page.tsx 파일을 생성해 아래와 같이 구현합니다.

app/assistants/page.tsx
'use client';

import { Message, useAssistant as useAssistant } from 'ai/react';
import { useState, useEffect, useRef } from 'react';

import SubmitButton from '@/components/submit-button';
import WeatherCard from '@/components/weather-card';
import LoadingIndicator from '@/components/loading-indicator';

import { remark } from 'remark';
import html from 'remark-html';
import remarkGfm from 'remark-gfm';

interface ExtendedMessage extends Message {
  htmlContent?: string;
}

export default function Chat() {
  const {
    status,
    messages,
    input,
    submitMessage,
    handleInputChange,
    error,
    stop,
  } = useAssistant({ api: '/api/assistant' });

  const inputRef = useRef<HTMLInputElement>(null);
  useEffect(() => {
    if (status === 'awaiting_message') {
      inputRef.current?.focus();
    }
  }, [status]);

  const [renderedMessages, setRenderedMessages] = useState<ExtendedMessage[]>([]);

  useEffect(() => {
    const processMessages = async () => {
      const updatedMessages = await Promise.all(
        messages.map(async (m) => {
          const processedContent = await remark()
            .use(remarkGfm)
            .use(html, { sanitize: false })
            .process(m.content);
          return { ...m, htmlContent: processedContent.toString().replace(/\n/g, '') };
        })
      );
      setRenderedMessages(updatedMessages);
    };
    processMessages();
  }, [messages]);

  return (
    <div className="flex flex-col items-center justify-between w-full h-screen">
      <div className="w-full h-full max-w-md p-6 bg-white rounded-lg shadow-md">
        <div className="flex flex-col justify-between h-full">
          <div className="overflow-y-auto">
            {error != null && (
              <div className="relative px-6 py-4 text-white bg-red-500 rounded-md">
                <span className="block sm:inline">
                  Error: {(error as any).toString()}
                </span>
              </div>
            )}

            {renderedMessages.map((m: ExtendedMessage) => (
              <div
                key={m.id}
                className="whitespace-pre-wrap"
              >
                <strong>{`${m.role}: `}</strong>
                <div
                  className="prose"
                  dangerouslySetInnerHTML={{ __html: m.htmlContent || m.content }}
                />
                {m.role === 'data' && (
                  <>
                    <pre className={'bg-gray-200'}>
                      <WeatherCard data={JSON.stringify(m.data)} />
                    </pre>
                  </>
                )}
              </div>
            ))}
            {status === 'in_progress' && (
              <LoadingIndicator />
            )}
          </div>

          <form onSubmit={submitMessage} className="flex w-full mt-4">
            <input
              value={input}
              onChange={handleInputChange}
              className="w-full p-3 border border-gray-300 rounded-l-lg focus:outline-none focus:ring focus:border-blue-300"
              disabled={status !== 'awaiting_message'}
              placeholder="현재 제주도 날씨 어때?"
            />
            <SubmitButton isLoading={status !== 'awaiting_message'} isDisabled={!input.trim()} onStop={stop} />
          </form>
        </div>
      </div>
    </div>
  );
}

이 소스 코드는 클라이언트 측에서 OpenAI Assistant와 상호작용하여 사용자의 메시지를 처리하고, 실시간 날씨 정보를 렌더링하는 기능을 제공합니다. 아래에서 기능별로 나누어 설명하겠습니다.

1. 기본 설정 및 모듈 임포트

'use client';

import { Message, useAssistant } from 'ai/react';
import { useState, useEffect, useRef } from 'react';

import SubmitButton from '@/components/submit-button';
import WeatherCard from '@/components/weather-card';
import LoadingIndicator from '@/components/loading-indicator';

import { remark } from 'remark';
import html from 'remark-html';
import remarkGfm from 'remark-gfm';
  • useAssistant: OpenAI Assistant API와 상호작용하기 위한 훅입니다.
  • useState, useEffect, useRef: 리액트의 상태 관리와 사이드 이펙트 관리를 위한 훅입니다.
  • SubmitButton, WeatherCard, LoadingIndicator: UI 구성 요소를 임포트합니다.
  • remark, html, remarkGfm: 마크다운 형식의 텍스트를 HTML로 변환하기 위한 라이브러리입니다.

2. 상태 및 초기화

interface ExtendedMessage extends Message {
  htmlContent?: string;
}

export default function Chat() {
  const {
    status,
    messages,
    input,
    submitMessage,
    handleInputChange,
    error,
    stop,
  } = useAssistant({ api: '/api/assistant' });
  • ExtendedMessage: 기본 메시지 구조에 htmlContent 속성을 추가하여 HTML 형식의 콘텐츠를 저장할 수 있도록 확장합니다.
  • useAssistant: OpenAI Assistant와 상호작용하여 메시지를 관리하는 데 필요한 여러 가지 상태(status, messages, input)와 함수(submitMessage, handleInputChange, stop)를 반환합니다.

3. 입력 필드 포커스 관리

  const inputRef = useRef<HTMLInputElement>(null);
  useEffect(() => {
    if (status === 'awaiting_message') {
      inputRef.current?.focus();
    }
  }, [status]);
  • inputRef: 입력 필드를 참조하기 위한 Ref를 생성합니다.
  • useEffect: 메시지를 기다리고 있는 상태(awaiting_message)가 되면 입력 필드에 자동으로 포커스가 가도록 설정합니다.

4. 메시지 처리 및 HTML 변환

  const [renderedMessages, setRenderedMessages] = useState<ExtendedMessage[]>([]);

  useEffect(() => {
    const processMessages = async () => {
      const updatedMessages = await Promise.all(
        messages.map(async (m) => {
          const processedContent = await remark()
            .use(remarkGfm)
            .use(html, { sanitize: false })
            .process(m.content);
          return { ...m, htmlContent: processedContent.toString().replace(/\n/g, '') };
        })
      );
      setRenderedMessages(updatedMessages);
    };
    processMessages();
  }, [messages]);
  • renderedMessages: 변환된 메시지들을 저장하는 상태입니다.
  • useEffect: 메시지가 업데이트될 때마다 remark를 사용해 마크다운을 HTML로 변환하고, 이를 renderedMessages에 저장합니다.

5. UI 렌더링

  return (
    <div className="flex flex-col items-center justify-between w-full h-screen">
      <div className="w-full h-full max-w-md p-6 bg-white rounded-lg shadow-md">
        <div className="flex flex-col justify-between h-full">
          <div className="overflow-y-auto">
            {error != null && (
              <div className="relative px-6 py-4 text-white bg-red-500 rounded-md">
                <span className="block sm:inline">
                  Error: {(error as any).toString()}
                </span>
              </div>
            )}
  • 에러 처리: error 상태가 존재할 경우, 에러 메시지를 화면에 표시합니다.
            {renderedMessages.map((m: ExtendedMessage) => (
              <div
                key={m.id}
                className="whitespace-pre-wrap"
              >
                <strong>{`${m.role}: `}</strong>
                <div
                  className="prose"
                  dangerouslySetInnerHTML={{ __html: m.htmlContent || m.content }}
                />
                {m.role === 'data' && (
                  <>
                    <pre className={'bg-gray-200'}>
                      <WeatherCard data={JSON.stringify(m.data)} />
                    </pre>
                  </>
                )}
              </div>
            ))}
            {status === 'in_progress' && (
              <LoadingIndicator />
            )}
  • 메시지 렌더링: renderedMessages 배열을 순회하며 각 메시지를 UI에 표시합니다. m.role'data'인 경우, WeatherCard 컴포넌트를 사용해 날씨 정보를 렌더링합니다.
  • 로딩 인디케이터: status'in_progress'일 때 로딩 인디케이터를 표시합니다.

6. 메시지 입력 및 제출 폼

          <form onSubmit={submitMessage} className="flex w-full mt-4">
            <input
              value={input}
              onChange={handleInputChange}
              className="w-full p-3 border border-gray-300 rounded-l-lg focus:outline-none focus:ring focus:border-blue-300"
              disabled={status !== 'awaiting_message'}
              placeholder="현재 제주도 날씨 어때?"
            />
            <SubmitButton isLoading={status !== 'awaiting_message'} isDisabled={!input.trim()} onStop={stop} />
          </form>
  • 입력 필드: 사용자가 입력한 텍스트를 관리하며, 상태에 따라 비활성화될 수 있습니다.
  • 제출 버튼: 메시지를 전송할 수 있는 버튼으로, 로딩 상태일 때 비활성화됩니다.

이 코드는 클라이언트 측에서 OpenAI Assistant와 상호작용하여 실시간 데이터를 처리하고, 이를 UI에 표시하는 기능을 제공합니다. 각 기능은 상태 관리, API 호출, UI 렌더링 등으로 나뉘어져 있어 이해하고 확장하기 쉽게 구성되어 있습니다.


다음 과정 안내

다음 과정에서는 마지막으로 위 OpenAI Assistant를 이용한 구현을 라우트 핸들러가 아닌 RSC를 이용하여 서버에서 날씨 컴포넌트 렌더링하여 스트리밍하는 Assistants를 구현하는 방법을 실습하겠습니다.