Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions apps/web/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { getRelevantRulings } from "@/lib/ai-tools";
import getOpenAIClient from "@repo/embeddings/openai";
import { streamText } from "ai";
import { streamText, tool } from "ai";
import { z } from "zod";
import { env } from "@/app/env";

export const maxDuration = 60;

Expand All @@ -9,8 +12,27 @@ export async function POST(req: Request) {
const openai = getOpenAIClient();

const result = streamText({
model: openai.responses("gpt-4o-mini"),
model: openai.responses(env.OPENAI_MODEL),
messages,
system: `
You are a Yu-Gi-Oh! Goat Format ruling expert.
You only answer questions about Yu-Gi-Oh! Goat Format rulings.
The getInformation tool is mandatory for providing ruling context.
The getInformation tool returns an array of objects containing the keys "title" and "url".
Always link the URL as the source (with the title as the hyperlink text) for any ruling context you use.
If the tool's results do not completely answer the question, respond with "Sorry, I don't know."
Check your knowledge base before answering any questions.
`,
tools: {
getInformation: tool({
description:
"Get information from your knowledge base to answer Yu-Gi-Oh! Goat Format rulings questions.",
parameters: z.object({
question: z.string().describe("the user's question"),
}),
execute: async ({ question }) => getRelevantRulings(question),
}),
},
});

return result.toDataStreamResponse();
Expand All @@ -21,7 +43,7 @@ export async function POST(req: Request) {
{
status: 500,
headers: { "Content-Type": "application/json" },
},
}
);
}
}
2 changes: 2 additions & 0 deletions apps/web/app/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const env = createEnv({
*/
server: {
OPENAI_API_KEY: z.string(),
OPENAI_MODEL: z.string().default("o4-mini-2025-04-16"),
PINECONE_API_KEY: z.string(),
PINECONE_INDEX_NAME: z.string(),
},
Expand All @@ -24,6 +25,7 @@ export const env = createEnv({
* 💡 You'll get type errors if not all variables from `server` & `client` are included here.
*/
runtimeEnv: {
OPENAI_MODEL: process.env.OPENAI_MODEL,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
PINECONE_API_KEY: process.env.PINECONE_API_KEY,
PINECONE_INDEX_NAME: process.env.PINECONE_INDEX_NAME,
Expand Down
48 changes: 48 additions & 0 deletions apps/web/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,54 @@
@import "@repo/tailwind-config";
@import "@repo/ui/styles.css";

@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}

@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

/* Custom scrollbar styles */
::-webkit-scrollbar {
width: 8px;
Expand Down
5 changes: 3 additions & 2 deletions apps/web/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import Navbar from "@/components/navbar";
const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
title: "Create Turborepo",
description: "Generated by create turbo",
title: "Yu-Gi-Oh! Ruling Bot",
description:
"Get AI-generated information on Yu-Gi-Oh! Goat Format rulings using Retrieval Augmented Generation (RAG). A helpful tool for exploring card interactions and rules.",
};

export default function RootLayout({
Expand Down
5 changes: 2 additions & 3 deletions apps/web/components/chat-input-area.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,21 @@ const ChatInputArea = ({
const { handleSendMessage } = useSendMessage({
chatHelpers,
isGeneratingResponse,
input,
setInput,
});

const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSendMessage();
handleSendMessage(input);
}
};

return (
<Textarea
value={input}
placeholder="How can I help with Yu-Gi-Oh! rulings?"
className="pr-12 py-3 min-h-[60px] max-h-[120px] focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none border-0 resize-none"
className="pr-12 p-5 min-h-[60px] max-h-[120px] focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:outline-none border-0 resize-none"
disabled={isGeneratingResponse}
autoFocus
onChange={(event) => {
Expand Down
3 changes: 1 addition & 2 deletions apps/web/components/chat-input-controls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ const ChatInputControls = ({
const { handleSendMessage } = useSendMessage({
chatHelpers,
isGeneratingResponse,
input,
setInput,
});

Expand All @@ -31,7 +30,7 @@ const ChatInputControls = ({
<Button
size="icon"
className="transform rounded-[10px] h-8 w-8 focus-visible:ring-1 focus-visible:ring-offset-1"
onClick={handleSendMessage}
onClick={() => handleSendMessage(input)}
aria-label={isGeneratingResponse ? "Stop generating" : "Send message"}
>
{isGeneratingResponse ? <StopCircleIcon /> : <ArrowUpIcon />}
Expand Down
13 changes: 5 additions & 8 deletions apps/web/components/chat-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@
import { UseChatHelpers } from "@ai-sdk/react";
import ChatInputControls from "@/components/chat-input-controls";
import ChatInputArea from "@/components/chat-input-area";
import { useState } from "react";
import Footnote from "./footnote";

interface ChatInputProps {
chatHelpers: UseChatHelpers;
input: string;
setInput: (value: string) => void;
}

const ChatInput = ({ chatHelpers }: ChatInputProps) => {
const [input, setInput] = useState<string>("");

const ChatInput = ({ chatHelpers, input, setInput }: ChatInputProps) => {
return (
<div className="fixed bottom-0 left-0 right-0 bg-background py-4 z-10">
<div className="max-w-3xl mx-auto w-full px-4">
Expand All @@ -27,10 +27,7 @@ const ChatInput = ({ chatHelpers }: ChatInputProps) => {
setInput={setInput}
/>
</div>
<div className="text-muted-foreground text-xs mt-2">
This is an AI chatbot using RAG. It is trained on GOAT Format
Yu-Gi-Oh! rulings only.
</div>
<Footnote />
</div>
</div>
);
Expand Down
19 changes: 9 additions & 10 deletions apps/web/components/chat-messages.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { UIMessage } from "ai";
import { useEffect, useRef } from "react";
import Markdown from "react-markdown";
import { markdownComponents } from "@repo/ui/components/markdown-components";
import { cn } from "@repo/ui/lib/utils";
import StartMessages from "./start-messages";
import { UseChatHelpers } from "@ai-sdk/react";

type ChatMessagesProps = {
messages: Array<UIMessage>;
chatHelpers: UseChatHelpers;
setInput: (value: string) => void;
};

const ChatMessages = ({ messages }: ChatMessagesProps) => {
const ChatMessages = ({ chatHelpers, setInput }: ChatMessagesProps) => {
const messagesRef = useRef<HTMLDivElement>(null);
const { messages } = chatHelpers;

useEffect(() => {
if (messagesRef.current) {
Expand All @@ -24,27 +27,23 @@ const ChatMessages = ({ messages }: ChatMessagesProps) => {
<div className="max-w-3xl mx-auto w-full px-4 pt-20">
<div className="py-4">
{messages.length === 0 ? (
<div className="flex items-center justify-center min-h-[70vh]">
<p className="text-foreground text-lg">
{"Hey there! What's on your mind?"}
</p>
</div>
<StartMessages chatHelpers={chatHelpers} setInput={setInput} />
) : (
<div className="space-y-6">
{messages.map((message) => (
<div
key={message.id}
className={cn(
"flex",
message.role === "user" ? "justify-end" : "justify-start",
message.role === "user" ? "justify-end" : "justify-start"
)}
>
<div
className={cn(
"rounded-lg px-4 py-2 max-w-[80%]",
message.role === "user"
? "bg-primary text-primary-foreground"
: "bg-secondary text-foreground system-message-styles",
: "bg-secondary text-foreground system-message-styles"
)}
>
<Markdown components={markdownComponents}>
Expand Down
10 changes: 6 additions & 4 deletions apps/web/components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,23 @@ import ChatMessages from "@/components/chat-messages";
import { useChat } from "@ai-sdk/react";
import ChatInput from "@/components/chat-input";
import { toast } from "sonner";
import { useState } from "react";

const Chat = () => {
const [input, setInput] = useState<string>("");

const chatHelpers = useChat({
id: "primary",
maxSteps: 3,
onError: () => {
toast.error("An error occurred, please try again!");
},
});

const { messages } = chatHelpers;

return (
<div className="min-h-screen bg-background pb-24">
<ChatMessages messages={messages}></ChatMessages>
<ChatInput chatHelpers={chatHelpers} />
<ChatMessages chatHelpers={chatHelpers} setInput={setInput} />
<ChatInput chatHelpers={chatHelpers} input={input} setInput={setInput} />
</div>
);
};
Expand Down
27 changes: 27 additions & 0 deletions apps/web/components/footnote.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const Footnote = () => {
return (
<div className="text-muted-foreground text-xs mt-2">
This is an AI chatbot using{" "}
<a
className="underline text-primary"
href="https://www.pinecone.io/learn/retrieval-augmented-generation/"
target="_blank"
rel="noreferrer"
>
RAG
</a>
. It is trained on{" "}
<a
className="underline text-primary"
href="https://www.goatformat.com/whatisgoat.html"
target="_blank"
rel="noreferrer"
>
GOAT Format Yu-Gi-Oh!
</a>{" "}
rulings only.
</div>
);
};

export default Footnote;
3 changes: 2 additions & 1 deletion apps/web/components/navbar.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { ModeToggle } from "@repo/ui/components/mode-toggle";
import { BotIcon } from "lucide-react";

export default function Navbar() {
return (
<header className="fixed top-0 left-0 right-0 border-b w-full bg-background z-10">
<div className="flex h-16 items-center px-4 w-full">
<div className="flex items-center gap-2 font-bold text-xl">
<span>Yu-Gi-Oh! Ruling Chatbot</span>
<BotIcon />
</div>
<div className="ml-auto flex items-center gap-2">
<ModeToggle />
Expand Down
53 changes: 53 additions & 0 deletions apps/web/components/start-messages.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"use client";

import { useSendMessage } from "@/hooks/use-send-message";
import { UseChatHelpers } from "@ai-sdk/react";
import { Card } from "@repo/ui/components/ui/card";

const STARTER_PROMPTS = [
`Can Exiled Force be the target of its own effect?`,
`If Spirit Reaper is targeted by Snatch Steal, and Mystical Space Typhoon is chained, will Spirit Reaper still destroy itself?`,
`Does the effect of Dark Balter the Terrible or Dark Ruler Ha Des negate the effect of Injection Fairy Lily?`,
`If my opponent already has Set the card declared with Prohibition, can they activate the card?`,
];

interface StartMessagesProps {
chatHelpers: UseChatHelpers;
setInput: (value: string) => void;
}

const StartMessages = ({ chatHelpers, setInput }: StartMessagesProps) => {
const { status } = chatHelpers;
const isGeneratingResponse = ["streaming", "submitted"].includes(status);

const { handleSendMessage } = useSendMessage({
chatHelpers,
isGeneratingResponse,
setInput,
});

return (
<div className="flex flex-col items-center justify-center min-h-[70vh]">
<h1 className="w-full text-2xl mb-9 tracking-tight sm:text-3xl text-primary-100 flex flex-col items-center justify-center text-center">
{"Welcome to Yu-Gi-Oh! Ruling Bot"}
<span className="text-gray-400">
{"Ask me anything about Goat Format rulings and interactions."}
</span>
</h1>

<div className="grid grid-cols-1 md:grid-cols-2 gap-4 w-full max-w-2xl">
{STARTER_PROMPTS.map((question, index) => (
<Card
key={index}
className="p-4 cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => handleSendMessage(question)}
>
<p className="text-foreground">{question}</p>
</Card>
))}
</div>
</div>
);
};

export default StartMessages;
Loading