OpenAI 채팅 어시스턴트
SaaS 지원, 온보딩, 복사용 AI 채팅 기능을 붙이는 설치형 확장팩
OpenAI 채팅 어시스턴트
AI 상담, 온보딩, 복사용 채팅 기능을 빠르게 붙이는 확장팩입니다.
설치
pnpm dlx shadcn@latest add https://n-exit.io/r/openai-chat-assistant.jsonnpx 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를 따로 연결하면 됩니다.