Skip to content

Commit e9e192b

Browse files
committed
feat(ai): integrate OpenRouter with rich YouTube and episode cards in Agent AJ
1 parent 58ecd18 commit e9e192b

5 files changed

Lines changed: 194 additions & 23 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,14 @@ jobs:
4545
- name: Configure Cloudflare Worker Secrets (dev)
4646
run: |
4747
echo "$SANITY_API_READ_TOKEN" | pnpm --filter @codingcatdev/site exec wrangler secret put SANITY_API_READ_TOKEN
48+
if [ -n "$OPENROUTER_API_KEY" ]; then
49+
echo "$OPENROUTER_API_KEY" | pnpm --filter @codingcatdev/site exec wrangler secret put OPENROUTER_API_KEY
50+
fi
4851
env:
4952
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
5053
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
5154
SANITY_API_READ_TOKEN: ${{ secrets.SANITY_API_READ_TOKEN }}
55+
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
5256

5357
- name: Deploy to Cloudflare Workers (dev.codingcat.dev)
5458
run: pnpm --filter @codingcatdev/site exec wrangler deploy
@@ -87,10 +91,14 @@ jobs:
8791
- name: Configure Cloudflare Worker Secrets (production)
8892
run: |
8993
echo "$SANITY_API_READ_TOKEN" | pnpm --filter @codingcatdev/site exec wrangler secret put SANITY_API_READ_TOKEN --env production
94+
if [ -n "$OPENROUTER_API_KEY" ]; then
95+
echo "$OPENROUTER_API_KEY" | pnpm --filter @codingcatdev/site exec wrangler secret put OPENROUTER_API_KEY --env production
96+
fi
9097
env:
9198
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
9299
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
93100
SANITY_API_READ_TOKEN: ${{ secrets.SANITY_API_READ_TOKEN }}
101+
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
94102

95103
- name: Deploy to Cloudflare Workers (codingcat.dev)
96104
run: pnpm --filter @codingcatdev/site exec wrangler deploy --env production

‎apps/site/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"@ai-sdk/mcp": "^2.0.72",
2222
"@astrojs/cloudflare": "^14.2.1",
2323
"@astrojs/react": "^6.0.2",
24+
"@openrouter/ai-sdk-provider": "^3.1.0",
2425
"@portabletext/to-html": "^6.0.0",
2526
"@portabletext/types": "^4.0.2",
2627
"@sanity/client": "^7.26.2",

‎apps/site/src/components/ChatModal.astro‎

Lines changed: 137 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,134 @@
212212
});
213213
});
214214

215+
function escapeHtml(text: string): string {
216+
return text
217+
.replace(/&/g, "&")
218+
.replace(/</g, "&lt;")
219+
.replace(/>/g, "&gt;")
220+
.replace(/"/g, "&quot;")
221+
.replace(/'/g, "&#039;");
222+
}
223+
224+
function renderRichAssistantContent(container: HTMLElement, rawText: string) {
225+
// Clean up any localhost or malformed url artifacts
226+
let cleanedText = rawText
227+
.replace(/http:\/\/localhost:\d+/g, "")
228+
.replace(/\[\s*["']?(\/[a-zA-Z0-9_\-\/]+)["']?\s*\]\((?:http:\/\/localhost:\d+)?(\/[a-zA-Z0-9_\-\/]+)\)/g, "[$1]($2)")
229+
.replace(/\[\s*["']?(\/[a-zA-Z0-9_\-\/]+)["']?\s*\]/g, "[$1]($1)");
230+
231+
// Extract YouTube links
232+
const ytRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/|live\/))([\w-]{11})/gi;
233+
const youtubeIds = new Set<string>();
234+
let ytMatch: RegExpExecArray | null;
235+
while ((ytMatch = ytRegex.exec(cleanedText)) !== null) {
236+
if (ytMatch[1]) {
237+
youtubeIds.add(ytMatch[1]);
238+
}
239+
}
240+
241+
// Extract Internal Episode / Post Links
242+
const internalLinkRegex = /\[([^\]]+)\]\(((\/(?:podcast|post)\/[a-zA-Z0-9_\-]+))\)/gi;
243+
const internalLinks: Array<{ title: string; url: string; type: "podcast" | "post" }> = [];
244+
let intMatch: RegExpExecArray | null;
245+
while ((intMatch = internalLinkRegex.exec(cleanedText)) !== null) {
246+
const linkUrl = intMatch[2];
247+
const linkTitle = intMatch[1];
248+
const type = linkUrl.startsWith("/podcast") ? "podcast" : "post";
249+
if (!internalLinks.some((l) => l.url === linkUrl)) {
250+
internalLinks.push({ title: linkTitle, url: linkUrl, type });
251+
}
252+
}
253+
254+
// Convert markdown formatting to safe HTML
255+
let html = escapeHtml(cleanedText);
256+
257+
// Bold: **text**
258+
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
259+
// Bullet list: - text
260+
html = html.replace(/^[ \t]*-[ \t]+(.*)$/gm, "<li class='ml-4 list-disc'>$1</li>");
261+
// Markdown links: [Title](url)
262+
html = html.replace(
263+
/\[([^\]]+)\]\(([^)]+)\)/g,
264+
"<a href='$2' class='text-primary font-medium hover:underline inline-flex items-center gap-0.5' target='_blank' rel='noopener noreferrer'>$1<svg class='size-3 inline opacity-70' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='2'><path d='M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6'></path><polyline points='15 3 21 3 21 9'></polyline><line x1='10' y1='14' x2='21' y2='3'></line></svg></a>",
265+
);
266+
267+
// Naked YouTube URLs
268+
html = html.replace(
269+
/(https?:\/\/(?:www\.)?(?:youtu\.be\/|youtube\.com\/(?:watch\?v=|embed\/|live\/))[\w-]+[^\s<]*)/gi,
270+
"<a href='$1' class='text-primary font-medium hover:underline' target='_blank' rel='noopener noreferrer'>$1</a>",
271+
);
272+
273+
// Newlines to <br> or <p>
274+
html = html.replace(/\n\n/g, "<div class='h-2'></div>");
275+
html = html.replace(/\n/g, "<br />");
276+
277+
// Build Cards Section
278+
let cardsHtml = "";
279+
280+
// 1. YouTube Preview Cards
281+
if (youtubeIds.size > 0) {
282+
cardsHtml += `<div class="mt-3 flex flex-col gap-2.5">`;
283+
for (const ytId of youtubeIds) {
284+
const thumbUrl = `https://img.youtube.com/vi/${ytId}/mqdefault.jpg`;
285+
const ytUrl = `https://www.youtube.com/watch?v=${ytId}`;
286+
cardsHtml += `
287+
<a href="${ytUrl}" target="_blank" rel="noopener noreferrer" class="group relative flex flex-col sm:flex-row items-center gap-3 p-2.5 rounded-xl border border-border bg-card/80 hover:bg-accent/60 transition-all hover:scale-[1.01] hover:shadow-md overflow-hidden text-left no-underline">
288+
<div class="relative shrink-0 w-full sm:w-36 h-24 sm:h-20 rounded-lg overflow-hidden bg-black/20 flex items-center justify-center">
289+
<img src="${thumbUrl}" alt="YouTube Preview" class="w-full h-full object-cover group-hover:opacity-90 transition-opacity" loading="lazy" />
290+
<div class="absolute inset-0 flex items-center justify-center bg-black/25 group-hover:bg-black/10 transition-colors">
291+
<div class="size-8 rounded-full bg-red-600/90 text-white flex items-center justify-center shadow-lg group-hover:scale-110 transition-transform">
292+
<svg class="size-4 ml-0.5 fill-current" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
293+
</div>
294+
</div>
295+
</div>
296+
<div class="flex-1 min-w-0 py-0.5">
297+
<div class="inline-flex items-center gap-1.5 text-[10px] uppercase font-bold tracking-wider text-red-500 mb-0.5">
298+
<svg class="size-3.5 fill-current" viewBox="0 0 24 24"><path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/></svg>
299+
YouTube Video
300+
</div>
301+
<div class="text-xs font-semibold text-foreground truncate group-hover:text-primary transition-colors">Watch on YouTube</div>
302+
<div class="text-[11px] text-muted-foreground truncate">youtube.com/watch?v=${ytId}</div>
303+
</div>
304+
</a>
305+
`;
306+
}
307+
cardsHtml += `</div>`;
308+
}
309+
310+
// 2. Clickable Episode / Post Cards
311+
if (internalLinks.length > 0) {
312+
cardsHtml += `<div class="mt-2.5 flex flex-col gap-2">`;
313+
for (const link of internalLinks) {
314+
const isPodcast = link.type === "podcast";
315+
cardsHtml += `
316+
<a href="${link.url}" class="group flex items-center justify-between gap-3 p-3 rounded-xl border border-border bg-card/90 hover:bg-accent hover:border-primary/40 transition-all hover:scale-[1.01] hover:shadow-xs text-left no-underline">
317+
<div class="flex items-center gap-2.5 min-w-0">
318+
<div class="size-8 rounded-lg ${isPodcast ? "bg-purple-500/10 text-purple-600 dark:text-purple-400" : "bg-blue-500/10 text-blue-600 dark:text-blue-400"} flex items-center justify-center shrink-0">
319+
${
320+
isPodcast
321+
? '<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"></path><path d="M19 10v2a7 7 0 0 1-14 0v-2"></path><line x1="12" x2="12" y1="19" y2="22"></line></svg>'
322+
: '<svg class="size-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg>'
323+
}
324+
</div>
325+
<div class="min-w-0">
326+
<div class="text-xs font-semibold text-foreground group-hover:text-primary transition-colors truncate">${escapeHtml(link.title)}</div>
327+
<div class="text-[10px] text-muted-foreground uppercase tracking-wider">${isPodcast ? "Podcast Episode" : "Blog Post"} • CodingCat.dev</div>
328+
</div>
329+
</div>
330+
<div class="shrink-0 text-xs font-medium text-primary flex items-center gap-1 group-hover:translate-x-0.5 transition-transform">
331+
<span>View</span>
332+
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"></polyline></svg>
333+
</div>
334+
</a>
335+
`;
336+
}
337+
cardsHtml += `</div>`;
338+
}
339+
340+
container.innerHTML = html + cardsHtml;
341+
}
342+
215343
function appendMessage(role: "user" | "assistant", initialText = ""): HTMLElement {
216344
if (welcomeContainer) {
217345
welcomeContainer.classList.add("hidden");
@@ -232,7 +360,7 @@
232360
}
233361

234362
const contentDiv = document.createElement("div");
235-
contentDiv.className = "whitespace-pre-wrap leading-relaxed";
363+
contentDiv.className = "leading-relaxed text-sm";
236364
contentDiv.textContent = initialText;
237365

238366
msgDiv.appendChild(contentDiv);
@@ -275,17 +403,23 @@
275403
return;
276404
}
277405

278-
assistantContent.textContent = "";
406+
let accumulatedText = "";
279407
const reader = res.body.getReader();
280408
const decoder = new TextDecoder();
281409

282410
while (true) {
283411
const { done, value } = await reader.read();
284412
if (done) break;
285413
const chunk = decoder.decode(value, { stream: true });
286-
assistantContent.textContent += chunk;
414+
accumulatedText += chunk;
415+
// Render progressive text during streaming
416+
assistantContent.textContent = accumulatedText;
287417
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: "smooth" });
288418
}
419+
420+
// On complete stream finish: render full rich content with cards & links
421+
renderRichAssistantContent(assistantContent, accumulatedText);
422+
messagesContainer?.scrollTo({ top: messagesContainer.scrollHeight, behavior: "smooth" });
289423
} catch (err) {
290424
assistantContent.textContent = `Connection error: ${err instanceof Error ? err.message : String(err)}`;
291425
} finally {

‎apps/site/src/pages/api/chat.ts‎

Lines changed: 33 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { createMCPClient } from "@ai-sdk/mcp";
33
import { createClient } from "@sanity/client";
44
import { isStepCount, jsonSchema, streamText, tool } from "ai";
55
import type { APIRoute } from "astro";
6+
import { createOpenRouter } from "@openrouter/ai-sdk-provider";
67
import { createWorkersAI } from "workers-ai-provider";
78
import {
89
semanticSearchQuery,
@@ -338,28 +339,40 @@ export const POST: APIRoute = async ({ request }) => {
338339
},
339340
};
340341

341-
// 2. Setup Cloudflare Workers AI model
342-
const aiBinding = cfEnv?.AI;
343-
if (!aiBinding) {
344-
await mcpClient.close();
345-
return new Response(
346-
JSON.stringify({
347-
error:
348-
"Cloudflare Workers AI binding is not available in the current environment.",
349-
}),
350-
{
351-
status: 500,
352-
headers: {
353-
"content-type": "application/json; charset=utf-8",
354-
"access-control-allow-origin": "*",
342+
// 2. Setup AI Model (prefer OpenRouter when configured, fallback to Workers AI)
343+
const openRouterApiKey =
344+
(cfEnv?.OPENROUTER_API_KEY as string | undefined) ||
345+
process.env.OPENROUTER_API_KEY;
346+
347+
let model: any;
348+
if (openRouterApiKey) {
349+
const openrouter = createOpenRouter({
350+
apiKey: openRouterApiKey,
351+
});
352+
// Use meta-llama/llama-3.3-70b-instruct or deepseek/deepseek-chat via OpenRouter
353+
model = openrouter("meta-llama/llama-3.3-70b-instruct");
354+
} else {
355+
const aiBinding = cfEnv?.AI;
356+
if (!aiBinding) {
357+
await mcpClient.close();
358+
return new Response(
359+
JSON.stringify({
360+
error:
361+
"Neither OPENROUTER_API_KEY nor Cloudflare Workers AI binding is configured.",
362+
}),
363+
{
364+
status: 500,
365+
headers: {
366+
"content-type": "application/json; charset=utf-8",
367+
"access-control-allow-origin": "*",
368+
},
355369
},
356-
},
357-
);
370+
);
371+
}
372+
const workersai = createWorkersAI({ binding: aiBinding as any });
373+
model = workersai("@cf/meta/llama-3.1-8b-instruct");
358374
}
359375

360-
const workersai = createWorkersAI({ binding: aiBinding as any });
361-
const model = workersai("@cf/meta/llama-3.3-70b-instruct-fp8-fast");
362-
363376
// 3. Stream model response with multi-step tool calling
364377
const result = streamText({
365378
model,
@@ -371,7 +384,7 @@ export const POST: APIRoute = async ({ request }) => {
371384
"- When asked for 'top videos' or popular video podcasts, search for high-profile episodes with video recordings (e.g. episodes featuring Guillermo Rauch, Rich Harris, Lee Robinson, or recent GenAI/MCP video podcasts) and provide their YouTube links alongside CodingCat.dev episode links.\n" +
372385
"- Use `groq_query` for precise lookups when searching for specific author profiles (e.g. *[_type == 'author' && title match '*Alex*']).\n" +
373386
"- CRITICAL: ALWAYS execute tools by invoking them via function calls. NEVER output raw JSON or code blocks in your text describing tool calls instead of executing them.\n" +
374-
"- Once you receive results from your tool calls, synthesize them into an engaging, helpful response. List the relevant episode or article titles, brief descriptions, and markdown links using relative paths (e.g. [Title](/podcast/slug) or [Title](/post/slug)). If YouTube links are available, include them. Never use localhost or absolute domain URLs.",
387+
"- Once you receive results from your tool calls, synthesize them into an engaging, helpful response. List the relevant episode or article titles, brief descriptions, and markdown links using relative paths (e.g. [Episode Title](/podcast/slug) or [Post Title](/post/slug)). When YouTube links are available, always include them directly on their own line (e.g. https://www.youtube.com/watch?v=VIDEO_ID or https://youtu.be/VIDEO_ID). NEVER output localhost URLs, internal IP addresses, or malformed URL brackets like [\"/slug\"](http://localhost:3000/slug).",
375388
messages: coreMessages,
376389
tools,
377390
stopWhen: isStepCount(5),

‎pnpm-lock.yaml‎

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

0 commit comments

Comments
 (0)