-
Notifications
You must be signed in to change notification settings - Fork 4
feat: AI Chat #346
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: AI Chat #346
Changes from all commits
720bed9
d5802da
235e9b4
5d307ed
99dd8ed
b11cc8b
916c197
70416d1
891a615
7b9fc85
34e4887
f292ffc
9b25261
1c3c728
9630bcb
fcdd8ac
5ca3233
12de04f
6f49c32
f190cff
c422632
800db47
3353dea
9665730
7a59076
5f54ef7
3f80458
ccad92c
290c8d5
c457ddf
b6d147e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| import { env } from "node:process"; | ||
| import { createVertex } from "@ai-sdk/google-vertex"; | ||
| import { streamText, convertToCoreMessages } from "ai"; | ||
| import { StatusCodes, ReasonPhrases } from "http-status-codes"; | ||
| import { smartSearchTool } from "@/lib/rag.mjs"; | ||
|
|
||
| // Ensure all required environment variables are set | ||
| if (!env.GOOGLE_VERTEX_PROJECT) { | ||
| throw new Error("GOOGLE_VERTEX_PROJECT is not set"); | ||
| } | ||
|
|
||
| if (!env.GOOGLE_VERTEX_LOCATION) { | ||
| throw new Error("GOOGLE_VERTEX_LOCATION is not set"); | ||
| } | ||
|
|
||
| if (!env.GOOGLE_VERTEX_CLIENT_EMAIL) { | ||
| throw new Error("GOOGLE_VERTEX_CLIENT_EMAIL is not set"); | ||
| } | ||
|
|
||
| if (!env.GOOGLE_VERTEX_PRIVATE_KEY) { | ||
| throw new Error("GOOGLE_VERTEX_PRIVATE_KEY is not set"); | ||
| } | ||
|
|
||
| if (!env.GOOGLE_VERTEX_PRIVATE_KEY.includes("-----BEGIN PRIVATE KEY-----")) { | ||
| throw new Error("GOOGLE_VERTEX_PRIVATE_KEY is not formatted correctly"); | ||
| } | ||
|
|
||
| const vertex = createVertex({ | ||
| project: env.GOOGLE_VERTEX_PROJECT, | ||
| location: env.GOOGLE_VERTEX_LOCATION, | ||
| googleAuthOptions: { | ||
| credentials: { | ||
| client_email: env.GOOGLE_VERTEX_CLIENT_EMAIL, | ||
| private_key: env.GOOGLE_VERTEX_PRIVATE_KEY.replaceAll( | ||
| String.raw`\n`, | ||
| "\n", | ||
| ), // Ensure newlines are correctly formatted | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| const smartSearchPrompt = ` | ||
| - You can use the 'smartSearchTool' to find information relating to Faust. | ||
| - WP Engine Smart Search is a powerful tool for finding information about Faust. | ||
| - After the 'smartSearchTool' provides results (even if it's an error or no information found) | ||
| - You MUST then formulate a conversational response to the user based on those results but also use the tool if the users query is deemed plausible. | ||
| - If search results are found, summarize them for the user. | ||
| - If no information is found or an error occurs, inform the user clearly. | ||
| - IMPORTANT: Don't prefix root-relative links in post_url so client-side routing works. If you find links other places that are at the "faustjs.org" domain, you can make them root-relative. | ||
| `; | ||
|
|
||
| const systemPromptContent = ` | ||
| - You are a friendly and helpful AI assistant that provides Developers help with their coding tasks and learning, as relevant to Faust.js, WPGraphQL, and headless WordPress. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "Developers" -> "developers" |
||
| - Format your responses using Github Flavored Markdown. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. "Github Flavored Markdown" -> "GitHub-flavored Markdown" |
||
| - Make sure to format links as [link text](path). | ||
| - Make sure to link out to the source of the information you provide. | ||
| - Prefer new information over old information. | ||
| - Do not invent information. Stick to the data provided by the tool. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instructions: don't hallucinate ever. k thanks, bye! :D |
||
| `; | ||
|
|
||
| export async function POST(req) { | ||
| try { | ||
| const { messages } = await req.json(); | ||
|
|
||
| if (!messages || !Array.isArray(messages) || messages.length === 0) { | ||
| return new Response(ReasonPhrases.BAD_REQUEST, { | ||
| status: StatusCodes.BAD_REQUEST, | ||
| }); | ||
| } | ||
|
|
||
| const coreMessages = convertToCoreMessages(messages); | ||
|
|
||
| const response = await streamText({ | ||
| model: vertex("gemini-2.5-flash"), | ||
| system: [systemPromptContent, smartSearchPrompt].join("\n"), | ||
| messages: coreMessages, | ||
| tools: { | ||
| smartSearchTool, | ||
| }, | ||
| onError: (error) => { | ||
| console.error("Error during streaming:", error); | ||
| return new Response(ReasonPhrases.INTERNAL_SERVER_ERROR, { | ||
| status: StatusCodes.INTERNAL_SERVER_ERROR, | ||
| }); | ||
| }, | ||
| onToolCall: async (toolCall) => { | ||
| console.log("Tool call initiated:", toolCall); | ||
| }, | ||
| onStepFinish: async (result) => { | ||
| if (result.usage) { | ||
| console.log( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be removed before going to prod? Otherwise, if you want it to be a "permanent" console.log statement- what I like to do in my apps is use |
||
| `[Token Usage] Prompt tokens: ${result.usage.promptTokens}, Completion tokens: ${result.usage.completionTokens}, Total tokens: ${result.usage.totalTokens}`, | ||
| ); | ||
| } | ||
| }, | ||
| maxSteps: 5, | ||
| }); | ||
|
|
||
| return response.toDataStreamResponse(); | ||
| } catch (error) { | ||
| console.error("Error in chat API:", error); | ||
| return new Response(ReasonPhrases.INTERNAL_SERVER_ERROR, { | ||
| status: StatusCodes.INTERNAL_SERVER_ERROR, | ||
| }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| import { useEffect, useState } from "react"; | ||
| import { | ||
| HiOutlineChatBubbleLeftRight, | ||
| HiOutlineXCircle, | ||
| } from "react-icons/hi2"; | ||
| import { useChatDialog } from "./state"; | ||
| import { sendChatToggleEvent } from "@/lib/analytics.mjs"; | ||
| import { classNames } from "@/utils/strings"; | ||
|
|
||
| export default function ChatButton() { | ||
| const { dialog } = useChatDialog(); | ||
| const [isOpen, setIsOpen] = useState(false); | ||
| const [wasEverOpen, setWasEverOpen] = useState(false); | ||
|
|
||
| useEffect(() => { | ||
| const dialogElement = dialog.current; | ||
|
|
||
| const handleDialogToggle = () => { | ||
| setIsOpen(!isOpen); | ||
| setWasEverOpen(true); | ||
|
|
||
| sendChatToggleEvent({ | ||
| is_open: !isOpen, | ||
| }); | ||
| }; | ||
|
|
||
| dialogElement?.addEventListener("toggle", handleDialogToggle); | ||
|
|
||
| return () => { | ||
| dialogElement?.removeEventListener("toggle", handleDialogToggle); | ||
| }; | ||
| }, [dialog, isOpen, setIsOpen]); | ||
|
|
||
| return ( | ||
| <div className="fixed right-6 bottom-6 z-50 overflow-visible"> | ||
| <div | ||
| id="ping" | ||
| aria-hidden="true" | ||
| className={classNames( | ||
| { "motion-safe:animate-ping": !isOpen && !wasEverOpen }, | ||
| "pointer-events-none absolute inset-0 -z-10 h-full w-full rounded-full bg-gray-200", | ||
| )} | ||
| /> | ||
| <button | ||
| id="chat-button" | ||
| type="button" | ||
| className="flex h-12 w-12 cursor-pointer items-center justify-center rounded-full bg-blue-800 text-white shadow-xl transition-transform hover:scale-105 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 md:right-8 md:bottom-12" | ||
| aria-label={isOpen ? "Close chat" : "Open chat"} | ||
| onClick={() => { | ||
| return isOpen ? dialog.current?.close() : dialog.current?.show(); | ||
| }} | ||
| > | ||
| <span className="sr-only">{isOpen ? "Close chat" : "Open chat"}</span> | ||
| {isOpen ? ( | ||
| <HiOutlineXCircle className="h-6 w-6" /> | ||
| ) : ( | ||
| <HiOutlineChatBubbleLeftRight className="h-6 w-6" /> | ||
| )} | ||
| </button> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| import { useChat } from "ai/react"; | ||
| import { useEffect } from "react"; | ||
| import { HiXCircle } from "react-icons/hi2"; | ||
| import { useChatDialog } from "./state"; | ||
| import Chat from "@/components/chat/chat"; | ||
| import "./chat.css"; | ||
|
|
||
| export default function ChatDialog() { | ||
| const { dialog } = useChatDialog(); | ||
| const { | ||
| messages, | ||
| input, | ||
| handleInputChange, | ||
| handleSubmit, | ||
| setMessages, | ||
| status, | ||
| } = useChat(); | ||
|
|
||
| useEffect(() => { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using useEffect to initialize data like this after the component has already rendered once isn't ideal. I think useChat provides an |
||
| if (messages.length === 0) { | ||
| setMessages([ | ||
| { | ||
| role: "assistant", | ||
| content: | ||
| "Hey there! I'm an AI driven chat assistant here to help you with Faust.js! I'm trained on the documentation and can help you with coding tasks, learning, and more. What can I assist you with today?", | ||
| id: "welcome-intro", | ||
| }, | ||
| ]); | ||
| } | ||
| }, [messages, setMessages]); | ||
|
|
||
| return ( | ||
| <dialog | ||
| ref={dialog} | ||
| id="chat-dialog" | ||
| role="application" | ||
| className="fixed right-4 bottom-18 left-auto z-20 w-[92dvw] max-w-xl overflow-visible rounded-lg bg-gray-800 p-4 md:right-8 md:bottom-32 md:p-6" | ||
| // eslint-disable-next-line react/no-unknown-property | ||
| closedby="any" | ||
| > | ||
| <button | ||
| formMethod="dialog" | ||
| type="button" | ||
| form="chat-form" | ||
| aria-label="Close chat" | ||
| className="absolute -top-2 -right-2 text-gray-400 hover:text-gray-300" | ||
| onClick={() => { | ||
| dialog.current?.close(); | ||
| }} | ||
| > | ||
| <span className="sr-only">Close chat</span> | ||
| <HiXCircle className="h-6 w-6 cursor-pointer text-gray-200 hover:text-red-500" /> | ||
| </button> | ||
| <section> | ||
| <Chat | ||
| input={input} | ||
| handleInputChange={handleInputChange} | ||
| handleMessageSubmit={handleSubmit} | ||
| messages={messages} | ||
| status={status} | ||
| /> | ||
| </section> | ||
| </dialog> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import { HiOutlinePaperAirplane, HiOutlineArrowPath } from "react-icons/hi2"; | ||
|
|
||
| export default function Input({ input, handleInputChange, status }) { | ||
| const isReady = status === "ready"; | ||
| const isSubmitted = status === "submitted"; | ||
|
|
||
| return ( | ||
| <div className="flex w-full items-end justify-between gap-2"> | ||
| <input | ||
| id="chat-input" | ||
| type="text" | ||
| wrap="soft" | ||
| value={input} | ||
| onChange={handleInputChange} | ||
| autoFocus | ||
| placeholder="Ask about Faust..." | ||
| className="no-scrollbar text-md w-full max-w-full rounded-xl bg-gray-700 p-2 text-wrap text-gray-200 placeholder-gray-400 shadow-lg transition-colors focus:ring-2 focus:ring-teal-500 focus:outline-none" | ||
| /> | ||
|
|
||
| <button | ||
| type="submit" | ||
| className="enabled:bg-hero-gradient ml-auto cursor-pointer rounded-xl bg-gray-700 p-2 text-gray-400 shadow-lg enabled:text-gray-200" | ||
| aria-label="Send message" | ||
| disabled={!input.trim() || !isReady} | ||
| > | ||
| {isSubmitted ? ( | ||
| <HiOutlineArrowPath className="b-white mx-auto h-6 w-6 animate-spin text-gray-200" /> | ||
| ) : ( | ||
| <HiOutlinePaperAirplane className="h-6 w-6" /> | ||
| )} | ||
| </button> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import Link from "@/components/link"; | ||
| import { sendSelectItemEvent } from "@/lib/analytics.mjs"; | ||
|
|
||
| export default function ChatLink(props) { | ||
| return ( | ||
| <Link | ||
| {...props} | ||
| onClick={() => { | ||
| sendSelectItemEvent({ | ||
| list: { name: "Chat Messages", id: "chat-messages" }, | ||
| item: { | ||
| item_id: props.href, | ||
| item_name: props.children, | ||
| }, | ||
| }); | ||
| }} | ||
| /> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| .gemini-text { | ||
| font-family: | ||
| Google Sans, | ||
| Helvetica Neue, | ||
| sans-serif; | ||
| /* Choose a font that matches, Arial is a common sans-serif */ | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These code comments could be removed |
||
| /* font-size: 100px; */ | ||
| /* Adjust size as needed */ | ||
| /* Gradient: Adjust colors and angles as needed */ | ||
| background: linear-gradient(to right, #6b50d2, #c25cb6, #ea668a); | ||
| /* | ||
| Color Hex codes approximated from the image: | ||
| Left (Blue-Purple): #6B50D2 | ||
| Middle (Pink-Purple): #C25CB6 | ||
| Right (Reddish-Pink): #EA668A | ||
| */ | ||
|
|
||
| /* Crucial properties for text gradient */ | ||
| -webkit-background-clip: text; | ||
| /* For Safari/Chrome */ | ||
| background-clip: text; | ||
| color: transparent; | ||
| /* Makes the original text color transparent */ | ||
|
|
||
| /* Optional: A slight text shadow can make it pop on some backgrounds */ | ||
| /* text-shadow: 2px 2px 4px rgba(0,0,0,0.1); */ | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| import ChatInput from "./chat-input"; | ||
| import Messages from "./messages"; | ||
| import { sendChatMessageEvent } from "@/lib/analytics.mjs"; | ||
|
|
||
| export default function Chat({ | ||
| input, | ||
| handleInputChange, | ||
| handleMessageSubmit, | ||
| status, | ||
| messages, | ||
| }) { | ||
| return ( | ||
| <div id="chat" className="flex h-full w-full flex-col gap-4"> | ||
| <Messages messages={messages} className="-mr-2 pr-4 pb-12 md:-mr-4" /> | ||
| <form | ||
| id="chat-form" | ||
| onSubmit={(event) => { | ||
| sendChatMessageEvent({ | ||
| message: input, | ||
| }); | ||
|
|
||
| return handleMessageSubmit(event); | ||
| }} | ||
| className="absolute bottom-0 left-0 w-[calc(100%-theme(spacing.[1.5]))] bg-gradient-to-b from-transparent via-gray-800 to-gray-800 p-4 md:p-6" | ||
| > | ||
| <ChatInput | ||
| input={input} | ||
| handleInputChange={handleInputChange} | ||
| status={status} | ||
| /> | ||
| </form> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This line seems like it should be combined with the one before it. And "users query" should be "user's query". Not that that matters too much- I'm sure the LLM would understand it just fine either way :)