Skip to content

Commit 9c6e824

Browse files
committed
feat(mcp): proxy Sanity Context MCP, add Workers AI chat agent & interactive chat modal
1 parent a2cc1a6 commit 9c6e824

10 files changed

Lines changed: 648 additions & 314 deletions

File tree

‎.github/workflows/deploy-workers.yml‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,14 @@ jobs:
4242
- name: Build site (dev)
4343
run: pnpm --filter @codingcatdev/site build
4444

45+
- name: Configure Cloudflare Worker Secrets (dev)
46+
run: |
47+
echo "$SANITY_API_READ_TOKEN" | pnpm --filter @codingcatdev/site exec wrangler secret put SANITY_API_READ_TOKEN
48+
env:
49+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
50+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
51+
SANITY_API_READ_TOKEN: ${{ secrets.SANITY_API_READ_TOKEN }}
52+
4553
- name: Deploy to Cloudflare Workers (dev.codingcat.dev)
4654
run: pnpm --filter @codingcatdev/site exec wrangler deploy
4755
env:
@@ -76,6 +84,14 @@ jobs:
7684
- name: Build site (production)
7785
run: pnpm --filter @codingcatdev/site build:production
7886

87+
- name: Configure Cloudflare Worker Secrets (production)
88+
run: |
89+
echo "$SANITY_API_READ_TOKEN" | pnpm --filter @codingcatdev/site exec wrangler secret put SANITY_API_READ_TOKEN --env production
90+
env:
91+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
92+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
93+
SANITY_API_READ_TOKEN: ${{ secrets.SANITY_API_READ_TOKEN }}
94+
7995
- name: Deploy to Cloudflare Workers (codingcat.dev)
8096
run: pnpm --filter @codingcatdev/site exec wrangler deploy --env production
8197
env:

‎apps/site/package.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"test:e2e": "node tests/run-e2e.mjs"
1919
},
2020
"dependencies": {
21+
"@ai-sdk/mcp": "^2.0.72",
2122
"@astrojs/cloudflare": "^14.2.1",
2223
"@astrojs/react": "^6.0.2",
2324
"@portabletext/to-html": "^6.0.0",
@@ -26,12 +27,14 @@
2627
"@sanity/image-url": "^2.1.1",
2728
"@sanity/visual-editing-standalone": "^2.0.1",
2829
"@tailwindcss/vite": "^4.2.1",
30+
"ai": "^7.0.135",
2931
"astro": "^7.2.2",
3032
"astro-portabletext": "^0.13.0",
3133
"groq": "^6.9.2",
3234
"react": "^19.2.6",
3335
"react-dom": "^19.2.6",
3436
"tailwindcss": "^4.2.1",
37+
"workers-ai-provider": "^4.0.0",
3538
"workers-og": "^0.0.27"
3639
},
3740
"devDependencies": {
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
---
2+
---
3+
4+
<div id="ai-chat-widget">
5+
<!-- Floating Trigger Button -->
6+
<button
7+
type="button"
8+
id="ai-chat-open-btn"
9+
class="fixed bottom-6 right-6 z-50 flex items-center gap-2 rounded-full bg-primary px-4 py-3 text-sm font-semibold text-primary-foreground shadow-lg hover:opacity-90 transition-all hover:scale-105 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
10+
aria-label="Open AI Assistant"
11+
>
12+
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
13+
<path d="M12 2a10 10 0 0 1 10 10c0 5.523-4.477 10-10 10a9.96 9.96 0 0 1-4.708-1.175L2 22l1.175-5.292A9.96 9.96 0 0 1 2 12 10 10 0 0 1 12 2z"></path>
14+
<path d="m9 12 2 2 4-4"></path>
15+
</svg>
16+
<span>Ask AI</span>
17+
</button>
18+
19+
<!-- Modal Backdrop & Window -->
20+
<div
21+
id="ai-chat-modal"
22+
class="hidden fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs transition-opacity"
23+
role="dialog"
24+
aria-modal="true"
25+
aria-labelledby="ai-chat-title"
26+
>
27+
<div class="relative flex flex-col w-full max-w-2xl h-[640px] max-h-[90vh] rounded-2xl border border-border bg-card shadow-2xl overflow-hidden">
28+
<!-- Header -->
29+
<header class="flex items-center justify-between border-b border-border px-5 py-3.5 bg-muted/40">
30+
<div class="flex items-center gap-2.5">
31+
<div class="flex size-8 items-center justify-center rounded-lg bg-primary/10 text-primary">
32+
<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
33+
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path>
34+
</svg>
35+
</div>
36+
<div>
37+
<h2 id="ai-chat-title" class="text-sm font-bold tracking-tight">CodingCat AI Assistant</h2>
38+
<p class="text-[11px] text-muted-foreground">Grounded in Sanity Content Lake & Workers AI</p>
39+
</div>
40+
</div>
41+
<button
42+
type="button"
43+
id="ai-chat-close-btn"
44+
class="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors cursor-pointer"
45+
aria-label="Close dialog"
46+
>
47+
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
48+
<line x1="18" y1="6" x2="6" y2="18"></line>
49+
<line x1="6" y1="6" x2="18" y2="18"></line>
50+
</svg>
51+
</button>
52+
</header>
53+
54+
<!-- Messages Container -->
55+
<div id="ai-chat-messages" class="flex-1 overflow-y-auto p-4 sm:p-5 flex flex-col gap-4 text-sm">
56+
<!-- Welcome / Prompt chips -->
57+
<div id="ai-chat-welcome" class="flex flex-col gap-3 my-auto text-center items-center py-6">
58+
<div class="size-10 rounded-full bg-primary/10 text-primary flex items-center justify-center">
59+
<svg class="size-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
60+
<circle cx="12" cy="12" r="10"></circle>
61+
<path d="m9 12 2 2 4-4"></path>
62+
</svg>
63+
</div>
64+
<h3 class="font-semibold text-base">How can I help you learn today?</h3>
65+
<p class="text-xs text-muted-foreground max-w-sm">
66+
I query the live CodingCat.dev content catalog, podcasts, video transcripts, and author notes.
67+
</p>
68+
<div class="flex flex-wrap gap-2 justify-center max-w-md pt-2">
69+
<button type="button" class="ai-prompt-chip text-xs px-3 py-1.5 rounded-full border border-border bg-card hover:bg-accent cursor-pointer transition-colors" data-prompt="What tutorials are available for Astro?">
70+
Tutorials for Astro
71+
</button>
72+
<button type="button" class="ai-prompt-chip text-xs px-3 py-1.5 rounded-full border border-border bg-card hover:bg-accent cursor-pointer transition-colors" data-prompt="Who is Raul Melo and what did he write?">
73+
Who is Raul Melo?
74+
</button>
75+
<button type="button" class="ai-prompt-chip text-xs px-3 py-1.5 rounded-full border border-border bg-card hover:bg-accent cursor-pointer transition-colors" data-prompt="What podcast episodes cover Sanity?">
76+
Podcasts about Sanity
77+
</button>
78+
</div>
79+
</div>
80+
</div>
81+
82+
<!-- Input Form -->
83+
<form id="ai-chat-form" class="border-t border-border p-3 sm:p-4 bg-card flex items-center gap-2">
84+
<input
85+
type="text"
86+
id="ai-chat-input"
87+
placeholder="Ask anything about CodingCat.dev..."
88+
class="flex-1 rounded-xl border border-input bg-background px-4 py-2.5 text-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary transition-all"
89+
autocomplete="off"
90+
/>
91+
<button
92+
type="submit"
93+
id="ai-chat-send-btn"
94+
class="rounded-xl bg-primary px-4 py-2.5 text-sm font-semibold text-primary-foreground hover:opacity-90 disabled:opacity-50 transition-all cursor-pointer"
95+
>
96+
Send
97+
</button>
98+
</form>
99+
</div>
100+
</div>
101+
</div>
102+
103+
<script>
104+
const openBtn = document.getElementById("ai-chat-open-btn");
105+
const closeBtn = document.getElementById("ai-chat-close-btn");
106+
const modal = document.getElementById("ai-chat-modal");
107+
const form = document.getElementById("ai-chat-form") as HTMLFormElement | null;
108+
const input = document.getElementById("ai-chat-input") as HTMLInputElement | null;
109+
const sendBtn = document.getElementById("ai-chat-send-btn") as HTMLButtonElement | null;
110+
const messagesContainer = document.getElementById("ai-chat-messages");
111+
const welcomeContainer = document.getElementById("ai-chat-welcome");
112+
113+
let isStreaming = false;
114+
115+
function openModal() {
116+
if (modal) {
117+
modal.classList.remove("hidden");
118+
input?.focus();
119+
}
120+
}
121+
122+
function closeModal() {
123+
if (modal) {
124+
modal.classList.add("hidden");
125+
}
126+
}
127+
128+
openBtn?.addEventListener("click", openModal);
129+
closeBtn?.addEventListener("click", closeModal);
130+
131+
modal?.addEventListener("click", (e) => {
132+
if (e.target === modal) {
133+
closeModal();
134+
}
135+
});
136+
137+
window.addEventListener("keydown", (e) => {
138+
if (e.key === "Escape" && modal && !modal.classList.contains("hidden")) {
139+
closeModal();
140+
}
141+
});
142+
143+
document.querySelectorAll(".ai-prompt-chip").forEach((chip) => {
144+
chip.addEventListener("click", () => {
145+
const prompt = chip.getAttribute("data-prompt");
146+
if (prompt && input) {
147+
input.value = prompt;
148+
form?.dispatchEvent(new Event("submit", { cancelable: true }));
149+
}
150+
});
151+
});
152+
153+
function appendMessage(role: "user" | "assistant", initialText = ""): HTMLElement {
154+
if (welcomeContainer) {
155+
welcomeContainer.classList.add("hidden");
156+
}
157+
158+
const msgDiv = document.createElement("div");
159+
msgDiv.className = `flex flex-col gap-1 max-w-[85%] ${
160+
role === "user"
161+
? "ml-auto bg-primary text-primary-foreground rounded-2xl rounded-tr-xs px-4 py-2.5"
162+
: "mr-auto bg-muted/60 text-foreground rounded-2xl rounded-tl-xs px-4 py-2.5 border border-border"
163+
}`;
164+
165+
const contentDiv = document.createElement("div");
166+
contentDiv.className = "whitespace-pre-wrap leading-relaxed";
167+
contentDiv.textContent = initialText;
168+
169+
msgDiv.appendChild(contentDiv);
170+
messagesContainer?.appendChild(msgDiv);
171+
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: "smooth" });
172+
173+
return contentDiv;
174+
}
175+
176+
form?.addEventListener("submit", async (e) => {
177+
e.preventDefault();
178+
if (!input) return;
179+
const prompt = input.value.trim();
180+
if (!prompt || isStreaming) return;
181+
182+
input.value = "";
183+
isStreaming = true;
184+
if (sendBtn) sendBtn.disabled = true;
185+
186+
appendMessage("user", prompt);
187+
const assistantContent = appendMessage("assistant", "Thinking...");
188+
189+
try {
190+
const res = await fetch("/api/chat", {
191+
method: "POST",
192+
headers: { "Content-Type": "application/json" },
193+
body: JSON.stringify({ prompt }),
194+
});
195+
196+
if (!res.ok) {
197+
const errorJson = (await res.json().catch(() => ({}))) as {
198+
error?: string;
199+
};
200+
assistantContent.textContent = `Error: ${errorJson.error || "Failed to generate answer"}`;
201+
return;
202+
}
203+
204+
if (!res.body) {
205+
assistantContent.textContent = "No response stream received.";
206+
return;
207+
}
208+
209+
assistantContent.textContent = "";
210+
const reader = res.body.getReader();
211+
const decoder = new TextDecoder();
212+
213+
while (true) {
214+
const { done, value } = await reader.read();
215+
if (done) break;
216+
const chunk = decoder.decode(value, { stream: true });
217+
assistantContent.textContent += chunk;
218+
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: "smooth" });
219+
}
220+
} catch (err) {
221+
assistantContent.textContent = `Connection error: ${err instanceof Error ? err.message : String(err)}`;
222+
} finally {
223+
isStreaming = false;
224+
if (sendBtn) sendBtn.disabled = false;
225+
input?.focus();
226+
}
227+
});
228+
</script>

‎apps/site/src/layouts/BaseLayout.astro‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { Font } from "astro:assets";
33
import "@/styles/global.css";
44
5+
import ChatModal from "@/components/ChatModal.astro";
56
import DraftModeToggle from "@/components/DraftModeToggle.astro";
67
import Footer from "@/components/Footer.astro";
78
import Header from "@/components/Header.astro";
@@ -190,5 +191,6 @@ const graph = buildGraph([
190191
<Footer />
191192
</div>
192193
{preview && <DraftModeToggle />}
194+
<ChatModal />
193195
</body>
194196
</html>

‎apps/site/src/lib/sanity/queries/search.ts‎

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ import { defineQuery } from "groq";
88
export const semanticSearchQuery = defineQuery(`
99
*[_type in ["post", "podcast", "author", "guest"] && defined(slug.current) && ($type == null || $type == "all" || _type == $type)]
1010
| score(
11-
boost(lower(coalesce(title, name)) == lower($searchTerm), 100),
12-
boost(coalesce(title, name) match $searchTerm + "*", 50),
13-
boost(coalesce(title, name) match $searchTerm, 30),
14-
boost(_type in ["author", "guest"] && coalesce(name, title) match $searchTerm + "*", 20),
11+
boost(title == $searchTerm, 100),
12+
boost(name == $searchTerm, 100),
13+
boost(title match $searchTerm + "*", 50),
14+
boost(name match $searchTerm + "*", 50),
15+
boost(title match $searchTerm, 30),
16+
boost(name match $searchTerm, 30),
1517
boost(excerpt match $searchTerm + "*", 8),
16-
boost(transcript->summary match $searchTerm + "*", 3),
17-
boost(transcript->fullText match $searchTerm + "*", 1),
1818
text::semanticSimilarity($searchTerm)
1919
)
2020
| order(_score desc)[0...24] {
@@ -35,20 +35,20 @@ export const semanticSearchQuery = defineQuery(`
3535
*/
3636
export const textSearchFallbackQuery = defineQuery(`
3737
*[_type in ["post", "podcast", "author", "guest"] && defined(slug.current) && ($type == null || $type == "all" || _type == $type) && (
38-
coalesce(title, name) match $searchTerm + "*" ||
39-
coalesce(title, name) match $searchTerm ||
38+
title match $searchTerm + "*" ||
39+
name match $searchTerm + "*" ||
4040
excerpt match $searchTerm + "*" ||
4141
transcript->summary match $searchTerm + "*" ||
4242
transcript->fullText match $searchTerm + "*"
4343
)]
4444
| score(
45-
boost(lower(coalesce(title, name)) == lower($searchTerm), 100),
46-
boost(coalesce(title, name) match $searchTerm + "*", 50),
47-
boost(coalesce(title, name) match $searchTerm, 30),
48-
boost(_type in ["author", "guest"] && coalesce(name, title) match $searchTerm + "*", 20),
49-
boost(excerpt match $searchTerm + "*", 8),
50-
boost(transcript->summary match $searchTerm + "*", 3),
51-
boost(transcript->fullText match $searchTerm + "*", 1)
45+
boost(title == $searchTerm, 100),
46+
boost(name == $searchTerm, 100),
47+
boost(title match $searchTerm + "*", 50),
48+
boost(name match $searchTerm + "*", 50),
49+
boost(title match $searchTerm, 30),
50+
boost(name match $searchTerm, 30),
51+
boost(excerpt match $searchTerm + "*", 8)
5252
)
5353
| order(_score desc)[0...24] {
5454
_id,

0 commit comments

Comments
 (0)