Skip to content

Commit 2fe3b0e

Browse files
authored
Merge pull request #346 from wpengine/feat-rag-chat
feat: AI Chat
2 parents bbb8f7e + b6d147e commit 2fe3b0e

23 files changed

Lines changed: 941 additions & 38 deletions

.env.local.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,9 @@ NEXT_SEARCH_ACCESS_TOKEN=search-access-token
1313

1414
# Google Analytics key
1515
NEXT_PUBLIC_GOOGLE_ANALYTICS_KEY=ga-key
16+
17+
# Google Vertex AI API Key
18+
GOOGLE_VERTEX_CLIENT_EMAIL=example-project-website@example-org.iam.gserviceaccount.com
19+
GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----Your private key here-----END PRIVATE KEY-----"
20+
GOOGLE_VERTEX_LOCATION=us-west1
21+
GOOGLE_VERTEX_PROJECT=example-project

package.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
"prepare": "husky"
2121
},
2222
"dependencies": {
23+
"@ai-sdk/google-vertex": "^2.2.27",
2324
"@apollo/client": "^3.13.8",
2425
"@faustwp/blocks": "^6.1.2",
2526
"@faustwp/cli": "^3.2.3",
@@ -31,6 +32,7 @@
3132
"@shikijs/transformers": "^3.7.0",
3233
"@sindresorhus/slugify": "^2.2.1",
3334
"@wpengine/atlas-next": "^3.0.0",
35+
"ai": "^4.3.16",
3436
"date-fns": "^4.1.0",
3537
"date-fns-tz": "^3.2.0",
3638
"feed": "^5.1.0",
@@ -44,6 +46,7 @@
4446
"react-dom": "^19.1.0",
4547
"react-icons": "^5.5.0",
4648
"react-intersection-observer": "^9.16.0",
49+
"react-markdown": "^10.1.0",
4750
"rehype-callouts": "^2.1.1",
4851
"rehype-external-links": "^3.0.0",
4952
"rehype-pretty-code": "^0.14.1",
@@ -57,7 +60,8 @@
5760
"shiki": "^3.7.0",
5861
"strip-markdown": "^6.0.0",
5962
"unified": "^11.0.5",
60-
"vfile-matter": "^5.0.1"
63+
"vfile-matter": "^5.0.1",
64+
"zod": "^3.25.76"
6165
},
6266
"devDependencies": {
6367
"@tailwindcss/postcss": "^4.1.11",

pnpm-lock.yaml

Lines changed: 372 additions & 18 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/app/api/chat/route.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { env } from "node:process";
2+
import { createVertex } from "@ai-sdk/google-vertex";
3+
import { streamText, convertToCoreMessages } from "ai";
4+
import { StatusCodes, ReasonPhrases } from "http-status-codes";
5+
import { smartSearchTool } from "@/lib/rag.mjs";
6+
7+
// Ensure all required environment variables are set
8+
if (!env.GOOGLE_VERTEX_PROJECT) {
9+
throw new Error("GOOGLE_VERTEX_PROJECT is not set");
10+
}
11+
12+
if (!env.GOOGLE_VERTEX_LOCATION) {
13+
throw new Error("GOOGLE_VERTEX_LOCATION is not set");
14+
}
15+
16+
if (!env.GOOGLE_VERTEX_CLIENT_EMAIL) {
17+
throw new Error("GOOGLE_VERTEX_CLIENT_EMAIL is not set");
18+
}
19+
20+
if (!env.GOOGLE_VERTEX_PRIVATE_KEY) {
21+
throw new Error("GOOGLE_VERTEX_PRIVATE_KEY is not set");
22+
}
23+
24+
if (!env.GOOGLE_VERTEX_PRIVATE_KEY.includes("-----BEGIN PRIVATE KEY-----")) {
25+
throw new Error("GOOGLE_VERTEX_PRIVATE_KEY is not formatted correctly");
26+
}
27+
28+
const vertex = createVertex({
29+
project: env.GOOGLE_VERTEX_PROJECT,
30+
location: env.GOOGLE_VERTEX_LOCATION,
31+
googleAuthOptions: {
32+
credentials: {
33+
client_email: env.GOOGLE_VERTEX_CLIENT_EMAIL,
34+
private_key: env.GOOGLE_VERTEX_PRIVATE_KEY.replaceAll(
35+
String.raw`\n`,
36+
"\n",
37+
), // Ensure newlines are correctly formatted
38+
},
39+
},
40+
});
41+
42+
const smartSearchPrompt = `
43+
- You can use the 'smartSearchTool' to find information relating to Faust.
44+
- WP Engine Smart Search is a powerful tool for finding information about Faust.
45+
- After the 'smartSearchTool' provides results (even if it's an error or no information found)
46+
- 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.
47+
- If search results are found, summarize them for the user.
48+
- If no information is found or an error occurs, inform the user clearly.
49+
- 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.
50+
`;
51+
52+
const systemPromptContent = `
53+
- 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.
54+
- Format your responses using Github Flavored Markdown.
55+
- Make sure to format links as [link text](path).
56+
- Make sure to link out to the source of the information you provide.
57+
- Prefer new information over old information.
58+
- Do not invent information. Stick to the data provided by the tool.
59+
`;
60+
61+
export async function POST(req) {
62+
try {
63+
const { messages } = await req.json();
64+
65+
if (!messages || !Array.isArray(messages) || messages.length === 0) {
66+
return new Response(ReasonPhrases.BAD_REQUEST, {
67+
status: StatusCodes.BAD_REQUEST,
68+
});
69+
}
70+
71+
const coreMessages = convertToCoreMessages(messages);
72+
73+
const response = await streamText({
74+
model: vertex("gemini-2.5-flash"),
75+
system: [systemPromptContent, smartSearchPrompt].join("\n"),
76+
messages: coreMessages,
77+
tools: {
78+
smartSearchTool,
79+
},
80+
onError: (error) => {
81+
console.error("Error during streaming:", error);
82+
return new Response(ReasonPhrases.INTERNAL_SERVER_ERROR, {
83+
status: StatusCodes.INTERNAL_SERVER_ERROR,
84+
});
85+
},
86+
onToolCall: async (toolCall) => {
87+
console.log("Tool call initiated:", toolCall);
88+
},
89+
onStepFinish: async (result) => {
90+
if (result.usage) {
91+
console.log(
92+
`[Token Usage] Prompt tokens: ${result.usage.promptTokens}, Completion tokens: ${result.usage.completionTokens}, Total tokens: ${result.usage.totalTokens}`,
93+
);
94+
}
95+
},
96+
maxSteps: 5,
97+
});
98+
99+
return response.toDataStreamResponse();
100+
} catch (error) {
101+
console.error("Error in chat API:", error);
102+
return new Response(ReasonPhrases.INTERNAL_SERVER_ERROR, {
103+
status: StatusCodes.INTERNAL_SERVER_ERROR,
104+
});
105+
}
106+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { useEffect, useState } from "react";
2+
import {
3+
HiOutlineChatBubbleLeftRight,
4+
HiOutlineXCircle,
5+
} from "react-icons/hi2";
6+
import { useChatDialog } from "./state";
7+
import { sendChatToggleEvent } from "@/lib/analytics.mjs";
8+
import { classNames } from "@/utils/strings";
9+
10+
export default function ChatButton() {
11+
const { dialog } = useChatDialog();
12+
const [isOpen, setIsOpen] = useState(false);
13+
const [wasEverOpen, setWasEverOpen] = useState(false);
14+
15+
useEffect(() => {
16+
const dialogElement = dialog.current;
17+
18+
const handleDialogToggle = () => {
19+
setIsOpen(!isOpen);
20+
setWasEverOpen(true);
21+
22+
sendChatToggleEvent({
23+
is_open: !isOpen,
24+
});
25+
};
26+
27+
dialogElement?.addEventListener("toggle", handleDialogToggle);
28+
29+
return () => {
30+
dialogElement?.removeEventListener("toggle", handleDialogToggle);
31+
};
32+
}, [dialog, isOpen, setIsOpen]);
33+
34+
return (
35+
<div className="fixed right-6 bottom-6 z-50 overflow-visible">
36+
<div
37+
id="ping"
38+
aria-hidden="true"
39+
className={classNames(
40+
{ "motion-safe:animate-ping": !isOpen && !wasEverOpen },
41+
"pointer-events-none absolute inset-0 -z-10 h-full w-full rounded-full bg-gray-200",
42+
)}
43+
/>
44+
<button
45+
id="chat-button"
46+
type="button"
47+
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"
48+
aria-label={isOpen ? "Close chat" : "Open chat"}
49+
onClick={() => {
50+
return isOpen ? dialog.current?.close() : dialog.current?.show();
51+
}}
52+
>
53+
<span className="sr-only">{isOpen ? "Close chat" : "Open chat"}</span>
54+
{isOpen ? (
55+
<HiOutlineXCircle className="h-6 w-6" />
56+
) : (
57+
<HiOutlineChatBubbleLeftRight className="h-6 w-6" />
58+
)}
59+
</button>
60+
</div>
61+
);
62+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { useChat } from "ai/react";
2+
import { useEffect } from "react";
3+
import { HiXCircle } from "react-icons/hi2";
4+
import { useChatDialog } from "./state";
5+
import Chat from "@/components/chat/chat";
6+
import "./chat.css";
7+
8+
export default function ChatDialog() {
9+
const { dialog } = useChatDialog();
10+
const {
11+
messages,
12+
input,
13+
handleInputChange,
14+
handleSubmit,
15+
setMessages,
16+
status,
17+
} = useChat();
18+
19+
useEffect(() => {
20+
if (messages.length === 0) {
21+
setMessages([
22+
{
23+
role: "assistant",
24+
content:
25+
"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?",
26+
id: "welcome-intro",
27+
},
28+
]);
29+
}
30+
}, [messages, setMessages]);
31+
32+
return (
33+
<dialog
34+
ref={dialog}
35+
id="chat-dialog"
36+
role="application"
37+
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"
38+
// eslint-disable-next-line react/no-unknown-property
39+
closedby="any"
40+
>
41+
<button
42+
formMethod="dialog"
43+
type="button"
44+
form="chat-form"
45+
aria-label="Close chat"
46+
className="absolute -top-2 -right-2 text-gray-400 hover:text-gray-300"
47+
onClick={() => {
48+
dialog.current?.close();
49+
}}
50+
>
51+
<span className="sr-only">Close chat</span>
52+
<HiXCircle className="h-6 w-6 cursor-pointer text-gray-200 hover:text-red-500" />
53+
</button>
54+
<section>
55+
<Chat
56+
input={input}
57+
handleInputChange={handleInputChange}
58+
handleMessageSubmit={handleSubmit}
59+
messages={messages}
60+
status={status}
61+
/>
62+
</section>
63+
</dialog>
64+
);
65+
}

src/components/chat/chat-input.jsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { HiOutlinePaperAirplane, HiOutlineArrowPath } from "react-icons/hi2";
2+
3+
export default function Input({ input, handleInputChange, status }) {
4+
const isReady = status === "ready";
5+
const isSubmitted = status === "submitted";
6+
7+
return (
8+
<div className="flex w-full items-end justify-between gap-2">
9+
<input
10+
id="chat-input"
11+
type="text"
12+
wrap="soft"
13+
value={input}
14+
onChange={handleInputChange}
15+
autoFocus
16+
placeholder="Ask about Faust..."
17+
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"
18+
/>
19+
20+
<button
21+
type="submit"
22+
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"
23+
aria-label="Send message"
24+
disabled={!input.trim() || !isReady}
25+
>
26+
{isSubmitted ? (
27+
<HiOutlineArrowPath className="b-white mx-auto h-6 w-6 animate-spin text-gray-200" />
28+
) : (
29+
<HiOutlinePaperAirplane className="h-6 w-6" />
30+
)}
31+
</button>
32+
</div>
33+
);
34+
}

src/components/chat/chat-link.jsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import Link from "@/components/link";
2+
import { sendSelectItemEvent } from "@/lib/analytics.mjs";
3+
4+
export default function ChatLink(props) {
5+
return (
6+
<Link
7+
{...props}
8+
onClick={() => {
9+
sendSelectItemEvent({
10+
list: { name: "Chat Messages", id: "chat-messages" },
11+
item: {
12+
item_id: props.href,
13+
item_name: props.children,
14+
},
15+
});
16+
}}
17+
/>
18+
);
19+
}

src/components/chat/chat.css

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
.gemini-text {
2+
font-family:
3+
Google Sans,
4+
Helvetica Neue,
5+
sans-serif;
6+
/* Choose a font that matches, Arial is a common sans-serif */
7+
/* font-size: 100px; */
8+
/* Adjust size as needed */
9+
/* Gradient: Adjust colors and angles as needed */
10+
background: linear-gradient(to right, #6b50d2, #c25cb6, #ea668a);
11+
/*
12+
Color Hex codes approximated from the image:
13+
Left (Blue-Purple): #6B50D2
14+
Middle (Pink-Purple): #C25CB6
15+
Right (Reddish-Pink): #EA668A
16+
*/
17+
18+
/* Crucial properties for text gradient */
19+
-webkit-background-clip: text;
20+
/* For Safari/Chrome */
21+
background-clip: text;
22+
color: transparent;
23+
/* Makes the original text color transparent */
24+
25+
/* Optional: A slight text shadow can make it pop on some backgrounds */
26+
/* text-shadow: 2px 2px 4px rgba(0,0,0,0.1); */
27+
}

src/components/chat/chat.jsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import ChatInput from "./chat-input";
2+
import Messages from "./messages";
3+
import { sendChatMessageEvent } from "@/lib/analytics.mjs";
4+
5+
export default function Chat({
6+
input,
7+
handleInputChange,
8+
handleMessageSubmit,
9+
status,
10+
messages,
11+
}) {
12+
return (
13+
<div id="chat" className="flex h-full w-full flex-col gap-4">
14+
<Messages messages={messages} className="-mr-2 pr-4 pb-12 md:-mr-4" />
15+
<form
16+
id="chat-form"
17+
onSubmit={(event) => {
18+
sendChatMessageEvent({
19+
message: input,
20+
});
21+
22+
return handleMessageSubmit(event);
23+
}}
24+
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"
25+
>
26+
<ChatInput
27+
input={input}
28+
handleInputChange={handleInputChange}
29+
status={status}
30+
/>
31+
</form>
32+
</div>
33+
);
34+
}

0 commit comments

Comments
 (0)