Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
720bed9
wip
moonmeister Jul 2, 2025
d5802da
Merge branch 'main' into feat-rag-chat
moonmeister Jul 8, 2025
235e9b4
wip
moonmeister Jul 8, 2025
5d307ed
Merge branch 'feat-recomendations' into feat-rag-chat
moonmeister Jul 8, 2025
99dd8ed
feat: initial working implementation
moonmeister Jul 9, 2025
b11cc8b
Merge branch 'main' into feat-rag-chat
moonmeister Jul 10, 2025
916c197
Merge branch 'main' into feat-rag-chat
moonmeister Jul 10, 2025
70416d1
Merge branch 'main' into feat-rag-chat
moonmeister Jul 10, 2025
891a615
fix: import path missed durring merge
moonmeister Jul 10, 2025
7b9fc85
refactor: cleanup console logs
moonmeister Jul 14, 2025
34e4887
lots of css an ui
moonmeister Jul 14, 2025
f292ffc
more css
moonmeister Jul 14, 2025
9b25261
chore: update env example
moonmeister Jul 15, 2025
1c3c728
Prompt for more detailed links and information
moonmeister Jul 15, 2025
9630bcb
feat: update markdown parsing and add custom link component
moonmeister Jul 16, 2025
fcdd8ac
feat: loading dots
moonmeister Jul 16, 2025
5ca3233
fix: prompt refinement
moonmeister Jul 16, 2025
12de04f
refactor: welcome message
moonmeister Jul 16, 2025
6f49c32
fix/feat: add ping animation for un-opened AI chat, fix incorrect SR…
moonmeister Jul 16, 2025
f190cff
refactor: loading icon to share with submit button and correctly disa…
moonmeister Jul 16, 2025
c422632
fix: remove console log
moonmeister Jul 16, 2025
800db47
feat: add event tracking to chat
moonmeister Jul 16, 2025
3353dea
refactor: Add intro to welcome
moonmeister Jul 16, 2025
9665730
fix: function name
moonmeister Jul 16, 2025
7a59076
fix: footer spaces
moonmeister Jul 17, 2025
5f54ef7
revert: heading id change
moonmeister Jul 17, 2025
3f80458
fix: formmethod => formMethod
moonmeister Jul 17, 2025
ccad92c
refactor: remove custom headings component from md in chat
moonmeister Jul 17, 2025
290c8d5
fix: chat close on redirect and reset app
moonmeister Jul 17, 2025
c457ddf
fix: missing search input id
moonmeister Jul 17, 2025
b6d147e
fix: remove explicit prevent default now that I'm passing the event t…
moonmeister Jul 17, 2025
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
6 changes: 6 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,9 @@ NEXT_SEARCH_ACCESS_TOKEN=search-access-token

# Google Analytics key
NEXT_PUBLIC_GOOGLE_ANALYTICS_KEY=ga-key

# Google Vertex AI API Key
GOOGLE_VERTEX_CLIENT_EMAIL=example-project-website@example-org.iam.gserviceaccount.com
GOOGLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----Your private key here-----END PRIVATE KEY-----"
GOOGLE_VERTEX_LOCATION=us-west1
GOOGLE_VERTEX_PROJECT=example-project
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"prepare": "husky"
},
"dependencies": {
"@ai-sdk/google-vertex": "^2.2.27",
"@apollo/client": "^3.13.8",
"@faustwp/blocks": "^6.1.2",
"@faustwp/cli": "^3.2.3",
Expand All @@ -31,6 +32,7 @@
"@shikijs/transformers": "^3.7.0",
"@sindresorhus/slugify": "^2.2.1",
"@wpengine/atlas-next": "^3.0.0",
"ai": "^4.3.16",
"date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"feed": "^5.1.0",
Expand All @@ -44,6 +46,7 @@
"react-dom": "^19.1.0",
"react-icons": "^5.5.0",
"react-intersection-observer": "^9.16.0",
"react-markdown": "^10.1.0",
"rehype-callouts": "^2.1.1",
"rehype-external-links": "^3.0.0",
"rehype-pretty-code": "^0.14.1",
Expand All @@ -57,7 +60,8 @@
"shiki": "^3.7.0",
"strip-markdown": "^6.0.0",
"unified": "^11.0.5",
"vfile-matter": "^5.0.1"
"vfile-matter": "^5.0.1",
"zod": "^3.25.76"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.11",
Expand Down
390 changes: 372 additions & 18 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

106 changes: 106 additions & 0 deletions src/app/api/chat/route.js
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.
Copy link
Contributor

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 :)

- 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Developers" -> "developers"

- Format your responses using Github Flavored Markdown.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Github Flavored Markdown" -> "GitHub-flavored Markdown"
(notice the capital "H" in GitHub)

- 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.
Copy link
Contributor

Choose a reason for hiding this comment

The 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(
Copy link
Contributor

Choose a reason for hiding this comment

The 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 console.info(). So then you can search the codebase for "console.info" to find the "permanent" log statements and "console.log" to find the temporary ones left over from debugging. So you could use that convention, if you like.

`[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,
});
}
}
62 changes: 62 additions & 0 deletions src/components/chat/chat-button.jsx
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>
);
}
65 changes: 65 additions & 0 deletions src/components/chat/chat-dialog.jsx
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(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The 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 initialMessages prop we can use instead, like this:

const {
		messages,
		input,
		handleInputChange,
		handleSubmit,
		setMessages,
		status } = useChat({
  initialMessages: [
    {
      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",
    },
  ],
});

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>
);
}
34 changes: 34 additions & 0 deletions src/components/chat/chat-input.jsx
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>
);
}
19 changes: 19 additions & 0 deletions src/components/chat/chat-link.jsx
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,
},
});
}}
/>
);
}
27 changes: 27 additions & 0 deletions src/components/chat/chat.css
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 */
Copy link
Contributor

Choose a reason for hiding this comment

The 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); */
}
34 changes: 34 additions & 0 deletions src/components/chat/chat.jsx
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>
);
}
Loading