EXITN

OpenAI 채팅 어시스턴트

SaaS 지원, 온보딩, 복사용 AI 채팅 기능을 붙이는 설치형 확장팩

OpenAI 채팅 어시스턴트

AI 상담, 온보딩, 복사용 채팅 기능을 빠르게 붙이는 확장팩입니다.

설치

pnpm dlx shadcn@latest add https://n-exit.io/r/openai-chat-assistant.json
npx shadcn@latest add https://n-exit.io/r/openai-chat-assistant.json

추가 파일

openai-chat-assistant.tsx
use-openai-chat.ts
route.ts
"use client";

import { useOpenAIChat } from "@/hooks/use-openai-chat";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";

export default function OpenAIChatAssistant() {
  const {
    input,
    setInput,
    messages,
    isSubmitting,
    error,
    submit,
    clear,
  } = useOpenAIChat();

  return (
    <Card className="border-border/70 bg-background/95">
      <CardHeader>
        <div className="flex items-center gap-2">
          <CardTitle>OpenAI Chat Assistant</CardTitle>
          <Badge variant="secondary">No persistence</Badge>
        </div>
        <CardDescription>
          Drop in a usable SaaS support or workspace assistant with a server-side
          OpenAI chat route and a simple client hook.
        </CardDescription>
      </CardHeader>
      <CardContent className="space-y-4">
        <div className="space-y-3 rounded-2xl border p-4">
          <div className="max-h-[420px] space-y-3 overflow-y-auto">
            {messages.length === 0 ? (
              <div className="text-muted-foreground text-sm">
                Start a conversation. This extension keeps chat state in memory
                only. Add your own database if you need history, search, or team
                handoff.
              </div>
            ) : null}

            {messages.map((message) => (
              <div
                key={message.id}
                className={`rounded-xl border p-3 ${
                  message.role === "assistant"
                    ? "bg-muted/40"
                    : "bg-background"
                }`}
              >
                <div className="mb-2">
                  <Badge variant={message.role === "assistant" ? "default" : "outline"}>
                    {message.role === "assistant" ? "Assistant" : "You"}
                  </Badge>
                </div>
                <p className="text-sm leading-6 whitespace-pre-wrap">{message.text}</p>
              </div>
            ))}
          </div>
        </div>

        <div className="space-y-3">
          <Textarea
            value={input}
            onChange={(event) => setInput(event.target.value)}
            placeholder="Ask a question, draft copy, summarize tickets, or create support replies."
            className="min-h-28"
          />
          <div className="flex flex-wrap gap-2">
            <Button type="button" onClick={submit} disabled={isSubmitting || !input.trim()}>
              {isSubmitting ? "Thinking..." : "Send message"}
            </Button>
            <Button type="button" variant="outline" onClick={clear}>
              Clear history
            </Button>
          </div>
          {error ? <p className="text-sm text-red-500">{error}</p> : null}
        </div>
      </CardContent>
    </Card>
  );
}
"use client";

import { useState } from "react";

type ChatMessage = {
  id: string;
  role: "user" | "assistant";
  text: string;
};

function createMessageId() {
  return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}

export function useOpenAIChat() {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [input, setInput] = useState("");
  const [isSubmitting, setIsSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const submit = async () => {
    const text = input.trim();
    if (!text || isSubmitting) return;

    const nextMessages = [
      ...messages,
      {
        id: createMessageId(),
        role: "user" as const,
        text,
      },
    ];

    setMessages(nextMessages);
    setInput("");
    setIsSubmitting(true);
    setError(null);

    try {
      const response = await fetch("/api/ai/chat", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          messages: nextMessages.map((message) => ({
            role: message.role,
            content: message.text,
          })),
        }),
      });

      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || "Chat request failed.");
      }

      setMessages((current) => [
        ...current,
        {
          id: createMessageId(),
          role: "assistant",
          text: data.message || "No response returned.",
        },
      ]);
    } catch (submitError) {
      setError(
        submitError instanceof Error
          ? submitError.message
          : "Chat request failed."
      );
    } finally {
      setIsSubmitting(false);
    }
  };

  const clear = () => {
    setMessages([]);
    setInput("");
    setError(null);
  };

  return {
    input,
    setInput,
    messages,
    isSubmitting,
    error,
    submit,
    clear,
  };
}
import { NextResponse } from "next/server";

type ChatMessage = {
  role?: "user" | "assistant" | "system";
  content?: string;
};

export async function POST(request: Request) {
  if (!process.env.OPENAI_API_KEY) {
    return NextResponse.json(
      { error: "OPENAI_API_KEY is not configured." },
      { status: 500 }
    );
  }

  const body = (await request.json()) as { messages?: ChatMessage[] };
  const incomingMessages = Array.isArray(body.messages) ? body.messages : [];

  if (incomingMessages.length === 0) {
    return NextResponse.json(
      { error: "At least one message is required." },
      { status: 400 }
    );
  }

  try {
    const response = await fetch("https://api.openai.com/v1/chat/completions", {
      method: "POST",
      headers: {
        Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        model: process.env.OPENAI_CHAT_MODEL || "gpt-5-mini",
        messages: [
          {
            role: "system",
            content:
              "You are a concise SaaS copilot. Help with support replies, onboarding help, marketing copy, and product explanations. If you are missing context, say so clearly.",
          },
          ...incomingMessages
            .filter(
              (message) =>
                message.role &&
                typeof message.content === "string" &&
                message.content.trim().length > 0
            )
            .map((message) => ({
              role: message.role,
              content: message.content,
            })),
        ],
      }),
    });

    const result = await response.json();

    if (!response.ok) {
      return NextResponse.json(
        { error: result.error?.message || "OpenAI request failed." },
        { status: response.status }
      );
    }

    const message = result.choices?.[0]?.message?.content;

    return NextResponse.json({
      message:
        typeof message === "string" ? message : "No response returned.",
      storage: {
        persisted: false,
        note: "This extension does not save chat history. Add your own database or object storage if you need history, analytics, or team handoff.",
      },
    });
  } catch (error) {
    return NextResponse.json(
      {
        error:
          error instanceof Error ? error.message : "Unexpected server error.",
      },
      { status: 500 }
    );
  }
}

환경변수

OPENAI_API_KEY=your_openai_api_key
OPENAI_CHAT_MODEL=gpt-5-mini

저장

기본 구성에는 채팅 저장이 없습니다.

  • Supabase / Postgres
  • Redis

SQL은 포함하지 않았습니다. 히스토리가 필요하면 DB를 따로 연결하면 됩니다.