diff --git a/apps/site/src/pages/api/chat.ts b/apps/site/src/pages/api/chat.ts index 0793cb77..3c953ddc 100644 --- a/apps/site/src/pages/api/chat.ts +++ b/apps/site/src/pages/api/chat.ts @@ -50,21 +50,6 @@ export const POST: APIRoute = async ({ request }) => { (cfEnv?.SANITY_API_READ_TOKEN as string | undefined) || process.env.SANITY_API_READ_TOKEN; - if (!sanityToken) { - return new Response( - JSON.stringify({ - error: "SANITY_API_READ_TOKEN is not configured on the server.", - }), - { - status: 500, - headers: { - "content-type": "application/json; charset=utf-8", - "access-control-allow-origin": "*", - }, - }, - ); - } - let body: { messages?: Array<{ role: string; content: string }>; prompt?: string; @@ -112,38 +97,101 @@ export const POST: APIRoute = async ({ request }) => { let mcpClient: Awaited> | undefined; try { - // 1. Connect MCP client to Sanity Context MCP endpoint - mcpClient = await createMCPClient({ - transport: { - type: "http", - url: SANITY_CONTEXT_MCP_URL, - headers: { - Authorization: `Bearer ${sanityToken}`, - }, - redirect: "follow", - fetch: (input, init) => { - const sanitizedInit = init ? { ...init } : {}; - if (sanitizedInit.redirect === "error") { - sanitizedInit.redirect = "follow"; - } - return globalThis.fetch(input, sanitizedInit); - }, - }, - }); + let mcpGroqTool: any; + if (sanityToken) { + try { + // 1. Optionally connect MCP client to Sanity Context MCP endpoint + mcpClient = await createMCPClient({ + transport: { + type: "http", + url: SANITY_CONTEXT_MCP_URL, + headers: { + Authorization: `Bearer ${sanityToken}`, + }, + redirect: "follow", + fetch: (input, init) => { + const sanitizedInit = init ? { ...init } : {}; + if (sanitizedInit.redirect === "error") { + sanitizedInit.redirect = "follow"; + } + return globalThis.fetch(input, sanitizedInit); + }, + }, + }); - const mcpTools = await mcpClient.tools(); + const mcpTools = await mcpClient.tools(); + mcpGroqTool = mcpTools.groq_query; + } catch (mcpErr) { + console.warn( + "[Chat API MCP connection warning, using direct Sanity client]", + mcpErr, + ); + } + } - // Directly configure Sanity client for fallback per prompt instructions (Step 3) + // Configure Sanity client (fall back to public 'production' dataset when no read token is present) + const configuredDataset = + (cfEnv?.PUBLIC_SANITY_DATASET as string) || "production"; const sanityClient = createClient({ projectId: (cfEnv?.PUBLIC_SANITY_PROJECT_ID as string) || "hfh83o0w", - dataset: (cfEnv?.PUBLIC_SANITY_DATASET as string) || "production", + dataset: sanityToken ? configuredDataset : "production", apiVersion: (cfEnv?.PUBLIC_SANITY_API_VERSION as string) || "2025-09-30", - useCdn: false, - token: sanityToken, + useCdn: !sanityToken, + ...(sanityToken ? { token: sanityToken } : {}), }); + const collectedToolItems: Array<{ + title: string; + type?: string; + slug?: string; + url?: string; + date?: string; + excerpt?: string; + youtube?: string | null; + }> = []; + + const normalizeAndCollectItems = (rawItems: any[]) => { + const normalized = rawItems + .filter((item) => item && typeof item === "object") + .map((item: any) => { + const slugStr = + typeof item.slug === "string" + ? item.slug + : typeof item.slug?.current === "string" + ? item.slug.current + : undefined; + const itemType = item.type || item._type || "podcast"; + const url = + item.url || + (slugStr + ? `/${itemType === "podcast" ? "podcast" : itemType === "post" ? "post" : itemType}/${slugStr}` + : undefined); + return { + title: item.title || item.name || "Untitled", + type: itemType, + slug: slugStr, + url, + date: item.date || item.publishedAt, + excerpt: item.excerpt || item.transcriptSummary || "", + youtube: item.youtube || undefined, + }; + }); + for (const entry of normalized) { + if ( + entry.title && + !collectedToolItems.some( + (existing) => + (entry.url && existing.url === entry.url) || + existing.title === entry.title, + ) + ) { + collectedToolItems.push(entry); + } + } + return normalized; + }; + const tools = { - ...mcpTools, search_content: tool({ description: "Search CodingCat.dev articles, podcast episodes, authors, and transcripts using hybrid semantic search and keyword ranking. ALWAYS use this tool first when asked to find, recommend, or query podcasts, articles, or topics (e.g. 'Sanity', 'Firebase', 'Next.js', 'AI', 'web dev').", @@ -202,14 +250,7 @@ export const POST: APIRoute = async ({ request }) => { } } - return results.slice(0, 10).map((item: any) => ({ - title: item.title, - type: item._type, - slug: item.slug, - url: `/${item._type === "podcast" ? "podcast" : item._type === "post" ? "post" : item._type}/${item.slug}`, - excerpt: item.excerpt || item.transcriptSummary || "", - youtube: item.youtube || undefined, - })); + return normalizeAndCollectItems(results.slice(0, 10)); }, }), get_recent_content: tool({ @@ -236,9 +277,11 @@ export const POST: APIRoute = async ({ request }) => { execute: async ({ type, limit }) => { const rawType = type ? String(type).toLowerCase() : "all"; const types = - rawType === "podcast" + rawType.includes("podcast") || rawType.includes("episode") ? ["podcast"] - : rawType === "post" + : rawType.includes("post") || + rawType.includes("article") || + rawType.includes("blog") ? ["post"] : ["podcast", "post"]; const count = Math.min(Math.max(Number(limit) || 5, 1), 10); @@ -254,70 +297,131 @@ export const POST: APIRoute = async ({ request }) => { }`, { types, limit: count }, ); - return (recent || []).map((item: any) => ({ - title: item.title, - type: item._type, - slug: item.slug, - url: `/${item._type === "podcast" ? "podcast" : "post"}/${item.slug}`, - date: item.date, - excerpt: item.excerpt || "", - youtube: item.youtube || null, - })); + return normalizeAndCollectItems(recent || []); } catch (err) { console.error("[get_recent_content error]", err); return []; } }, }), - groq_query: { - ...mcpTools.groq_query, + groq_query: tool({ + description: + "Execute a read-only GROQ query against the CodingCat.dev Sanity dataset. Document types are 'podcast', 'post', 'author', 'guest', and 'sponsor'. Content documents use 'date' (NOT 'publishedAt'), 'title', '\"slug\": slug.current', 'excerpt', and 'youtube'.", + inputSchema: jsonSchema<{ query: string }>({ + type: "object", + properties: { + query: { + type: "string", + description: + "Valid GROQ query (e.g. *[_type == 'podcast' && defined(slug.current)] | order(date desc)[0...5]{title, _type, 'slug': slug.current, date, excerpt, youtube}).", + }, + }, + required: ["query"], + }), execute: async ({ query }: { query: string }) => { - try { - const mcpResult = await (mcpTools.groq_query as any).execute( - { query }, - {} as any, + let cleanQuery = query.trim(); + if (!cleanQuery.startsWith("*") && !cleanQuery.startsWith("count(")) { + cleanQuery = `*[${cleanQuery}]`; + } + // Heal common LLM schema/syntax mismatches: + // 1. Heal _type == "episode" / "podcastEpisode" -> "podcast", "article" / "blog" -> "post" + cleanQuery = cleanQuery + .replace( + /(_type\s*==\s*["'])(?:episode|episodes|podcastEpisode|podcast_episode)(["'])/gi, + "$1podcast$2", + ) + .replace( + /(_type\s*==\s*["'])(?:article|articles|blog|blogPost|posts)(["'])/gi, + "$1post$2", ); - const parsed = - typeof mcpResult === "string" ? JSON.parse(mcpResult) : mcpResult; - const results = - parsed?.result || - (Array.isArray(parsed?.content) && parsed.content[0]?.text - ? JSON.parse(parsed.content[0].text)?.result - : null); - if (Array.isArray(results) && results.length > 0) { - return results; - } - } catch {} + // 2. Heal publishedAt -> date (CodingCat.dev schema uses `date`) + cleanQuery = cleanQuery.replace(/\bpublishedAt\b/g, "date"); + // 3. Heal [a, b] match "..." -> (a match "..." || b match "...") + cleanQuery = cleanQuery.replace( + /\[([a-zA-Z0-9_]+),\s*([a-zA-Z0-9_]+)\]\s+match\s+([^)]+\))/g, + "($1 match $3 || $2 match $3)", + ); + // 4. Heal text::query("...") -> "*...*" + cleanQuery = cleanQuery.replace( + /text::query\((["'])(.*?)\1\)/g, + "$1*$2*$1", + ); + // 5. Remove filter [_score > 0] + cleanQuery = cleanQuery.replace(/\[_score\s*>\s*0\]/g, ""); - // Fallback: direct Sanity fetch with query healing - try { - let cleanQuery = query.startsWith("*") ? query : `*[${query}]`; - // Heal common LLM query syntax issues: - // Heal [a, b] match "..." -> (a match "..." || b match "...") - cleanQuery = cleanQuery.replace( - /\[([a-zA-Z0-9_]+),\s*([a-zA-Z0-9_]+)\]\s+match\s+([^)]+\))/g, - "($1 match $3 || $2 match $3)", - ); - // Heal text::query("...") -> "*...*" - cleanQuery = cleanQuery.replace( - /text::query\((["'])(.*?)\1\)/g, - "$1*$2*$1", - ); - // Remove filter [_score > 0] - cleanQuery = cleanQuery.replace(/\[_score\s*>\s*0\]/g, ""); + if (mcpGroqTool?.execute) { + try { + const mcpResult = await mcpGroqTool.execute( + { query: cleanQuery }, + {} as any, + ); + const parsed = + typeof mcpResult === "string" + ? JSON.parse(mcpResult) + : mcpResult; + const results = + parsed?.result || + (Array.isArray(parsed?.content) && parsed.content[0]?.text + ? JSON.parse(parsed.content[0].text)?.result + : null); + const arr = Array.isArray(results) + ? results + : results && typeof results === "object" + ? [results] + : []; + if (arr.length > 0) { + return normalizeAndCollectItems(arr); + } + } catch {} + } + // Direct Sanity fetch with healed query + try { const directResult = await sanityClient.fetch(cleanQuery); - if (Array.isArray(directResult) && directResult.length > 0) { - return directResult; + const normalizedDirect = Array.isArray(directResult) + ? directResult + : directResult !== null && typeof directResult === "object" + ? [directResult] + : []; + + if (normalizedDirect.length > 0) { + return normalizeAndCollectItems(normalizedDirect); + } + + // If query ordered by date desc (e.g. asking for latest podcast/post) and returned empty, fallback to recent content query + if (/order\s*\(\s*date\s+desc/i.test(cleanQuery)) { + const typeMatch = cleanQuery.match( + /_type\s*==\s*["']([a-zA-Z0-9_-]+)["']/, + ); + const targetTypes = + typeMatch?.[1] === "post" + ? ["post"] + : typeMatch?.[1] === "podcast" + ? ["podcast"] + : ["podcast", "post"]; + const recentFallback = await sanityClient.fetch( + `*[_type in $types && defined(slug.current)] | order(date desc, _createdAt desc)[0...5]{ + title, + _type, + "slug": slug.current, + date, + excerpt, + youtube + }`, + { types: targetTypes }, + ); + if (Array.isArray(recentFallback) && recentFallback.length > 0) { + return normalizeAndCollectItems(recentFallback); + } } // If still empty and query was a search for a keyword, extract keyword and run semantic search - const matchTerm = query.match( + const matchTerm = cleanQuery.match( /match\s+["']\*?([a-zA-Z0-9_-]+)\*?["']/, ); if (matchTerm?.[1]) { const keyword = matchTerm[1]; - const typeMatch = query.match( + const typeMatch = cleanQuery.match( /_type\s*==\s*["']([a-zA-Z0-9_-]+)["']/, ); const filterType = typeMatch?.[1] || null; @@ -332,17 +436,13 @@ export const POST: APIRoute = async ({ request }) => { Array.isArray(semanticResults) && semanticResults.length > 0 ) { - return semanticResults.slice(0, 10).map((item: any) => ({ - title: item.title, - type: item._type, - slug: item.slug, - excerpt: item.excerpt || "", - })); + return normalizeAndCollectItems(semanticResults.slice(0, 10)); } } - return directResult || []; + return []; } catch (directErr) { + console.error("[groq_query direct error]", directErr); return { error: directErr instanceof Error @@ -351,7 +451,7 @@ export const POST: APIRoute = async ({ request }) => { }; } }, - }, + }), }; // 2. Setup AI Model (prefer OpenRouter when configured, fallback to Workers AI) @@ -369,7 +469,9 @@ export const POST: APIRoute = async ({ request }) => { } else { const aiBinding = cfEnv?.AI; if (!aiBinding) { - await mcpClient.close(); + if (mcpClient) { + await mcpClient.close(); + } return new Response( JSON.stringify({ error: @@ -397,9 +499,9 @@ export const POST: APIRoute = async ({ request }) => { "- ALWAYS use the `get_recent_content` tool first when asked for 'latest podcast', 'recent episodes', 'latest blog post', or 'new articles'.\n" + "- ALWAYS use the `search_content` tool when asked to find, recommend, or query podcasts, posts, or articles on any topic, technology, or question (e.g. 'Sanity', 'Firebase', 'Next.js', 'AI', 'web dev'). It performs hybrid semantic and keyword search across all episodes, posts, and transcripts.\n" + "- 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" + - "- Use `groq_query` for precise lookups when searching for specific author profiles (e.g. *[_type == 'author' && title match '*Alex*']).\n" + + "- Use `groq_query` only for precise lookups when searching for specific author profiles (e.g. *[_type == 'author' && title match '*Alex*']).\n" + "- 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" + - '- 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 an exact YouTube URL is present in the tool results (e.g. item.youtube), include that exact URL on its own line (e.g. https://www.youtube.com/watch?v=VIDEO_ID or https://youtu.be/VIDEO_ID). NEVER invent placeholder strings like "https://www.youtube.com/watch?v=VIDEO_ID", localhost URLs, or malformed URL brackets like ["/slug"](http://localhost:3000/slug).', + '- Once you receive results from your tool calls, immediately synthesize them into an engaging, helpful response without calling more tools. 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 an exact YouTube URL is present in the tool results (e.g. item.youtube), include that exact URL on its own line (e.g. https://www.youtube.com/watch?v=VIDEO_ID or https://youtu.be/VIDEO_ID). NEVER invent placeholder strings like "https://www.youtube.com/watch?v=VIDEO_ID", localhost URLs, or malformed URL brackets like ["/slug"](http://localhost:3000/slug).', messages: coreMessages, tools, stopWhen: isStepCount(5), @@ -446,7 +548,32 @@ export const POST: APIRoute = async ({ request }) => { .join("\n\n"); if (allText) { await writer.write(encoder.encode(allText)); + } else if (collectedToolItems.length > 0) { + console.warn("[Chat API Synthesizing Fallback from Tool Results]", { + stepCount: steps.length, + itemCount: collectedToolItems.length, + }); + const formatted = collectedToolItems + .slice(0, 5) + .map((item) => { + const link = item.url + ? `[${item.title}](${item.url})` + : `**${item.title}**`; + const desc = item.excerpt ? `\n${item.excerpt.trim()}` : ""; + const yt = item.youtube ? `\n${item.youtube}` : ""; + return `- ${link}${desc}${yt}`; + }) + .join("\n\n"); + await writer.write( + encoder.encode( + `Here is what I found on CodingCat.dev:\n\n${formatted}`, + ), + ); } else { + console.warn("[Chat API Empty Text Stream]", { + stepCount: steps.length, + finishReasons: steps.map((s) => s.finishReason), + }); await writer.write( encoder.encode( "I searched the CodingCat.dev Sanity content lake for your query, but could not find relevant content.",