"use node"; import { httpAction, action } from "./_generated/server"; import { internal } from "./_generated/api"; import { components } from "./_generated/api"; import { PersistentTextStreaming, StreamId } from "@convex-dev/persistent-text-streaming"; import Anthropic from "@anthropic-ai/sdk"; import OpenAI from "openai"; import { v } from "convex/values"; // Initialize Persistent Text Streaming component const streaming = new PersistentTextStreaming(components.persistentTextStreaming); // System prompt for RAG-based Q&A const RAG_SYSTEM_PROMPT = `You are a helpful assistant that answers questions about this website's content. Guidelines: - Answer questions based ONLY on the provided context - If the context doesn't contain relevant information, say so honestly - Cite sources by mentioning the page/post title when referencing specific content - Be concise but thorough - Format responses in markdown when appropriate - Do not make up information not present in the context`; // CORS headers for all responses const corsHeaders = { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", }; // HTTP action for streaming AI responses export const streamResponse = httpAction(async (ctx, request) => { let body: { streamId?: string }; try { body = await request.json(); } catch { return new Response(JSON.stringify({ error: "Invalid JSON body" }), { status: 400, headers: { "Content-Type": "application/json", ...corsHeaders }, }); } const { streamId } = body; // Validate streamId if (!streamId) { return new Response(JSON.stringify({ error: "Missing streamId" }), { status: 400, headers: { "Content-Type": "application/json", ...corsHeaders }, }); } // Get the question and model from the database const session = await ctx.runQuery(internal.askAI.getSessionByStreamId, { streamId }); if (!session) { return new Response(JSON.stringify({ error: "Session not found" }), { status: 404, headers: { "Content-Type": "application/json", ...corsHeaders }, }); } const { question, model } = session; console.log("Ask AI received:", { streamId: streamId.slice(0, 20), question: question.slice(0, 50), model }); // Pre-fetch search results before starting the stream let searchResults: Array<{ title: string; slug: string; type: string; content: string }> = []; let searchError: string | null = null; try { const apiKey = process.env.OPENAI_API_KEY; if (!apiKey) { searchError = "OPENAI_API_KEY not configured. Please add it to your Convex dashboard environment variables."; } else { const openai = new OpenAI({ apiKey }); console.log("Generating embedding for query:", question.trim().slice(0, 50)); const embeddingResponse = await openai.embeddings.create({ model: "text-embedding-ada-002", input: question.trim(), }); const queryEmbedding = embeddingResponse.data[0].embedding; console.log("Embedding generated, searching..."); // Search posts const postResults = await ctx.vectorSearch("posts", "by_embedding", { vector: queryEmbedding, limit: 5, filter: (q) => q.eq("published", true), }); // Search pages const pageResults = await ctx.vectorSearch("pages", "by_embedding", { vector: queryEmbedding, limit: 5, filter: (q) => q.eq("published", true), }); console.log("Found:", postResults.length, "posts,", pageResults.length, "pages"); // Fetch full documents const posts = await ctx.runQuery(internal.semanticSearchQueries.fetchPostsByIds, { ids: postResults.map((r) => r._id), }); const pages = await ctx.runQuery(internal.semanticSearchQueries.fetchPagesByIds, { ids: pageResults.map((r) => r._id), }); // Build results const results: Array<{ title: string; slug: string; type: string; content: string; score: number }> = []; for (const result of postResults) { const post = posts.find((p) => p._id === result._id); if (post) { results.push({ title: post.title, slug: post.slug, type: "post", content: post.content, score: result._score, }); } } for (const result of pageResults) { const page = pages.find((p) => p._id === result._id); if (page) { results.push({ title: page.title, slug: page.slug, type: "page", content: page.content, score: result._score, }); } } results.sort((a, b) => b.score - a.score); searchResults = results.slice(0, 5); console.log("Search completed, found", searchResults.length, "relevant results"); } } catch (error) { console.error("Search error:", error); searchError = error instanceof Error ? error.message : "Search failed"; } // Now start the streaming with pre-fetched results const generateAnswer = async ( _ctx: unknown, _request: unknown, _streamId: unknown, appendChunk: (chunk: string) => Promise ) => { try { // Handle search errors if (searchError) { await appendChunk(`**Error:** ${searchError}`); return; } if (searchResults.length === 0) { await appendChunk("I couldn't find any relevant content to answer your question. Please make sure:\n\n1. Semantic search is enabled in siteConfig.ts\n2. Content has been synced with `npm run sync`\n3. OPENAI_API_KEY is configured in Convex dashboard"); return; } // Build context from search results const contextParts = searchResults.map( (r) => `## ${r.title}\nURL: /${r.slug}\n\n${r.content.slice(0, 2000)}` ); const context = contextParts.join("\n\n---\n\n"); const fullPrompt = `Based on the following content from the website, answer this question: "${question}" CONTEXT: ${context} Please provide a helpful answer based on the context above.`; // Generate response with selected model if (model === "gpt-4o") { const openaiApiKey = process.env.OPENAI_API_KEY; if (!openaiApiKey) { await appendChunk("**Error:** OPENAI_API_KEY not configured."); return; } const openai = new OpenAI({ apiKey: openaiApiKey }); const stream = await openai.chat.completions.create({ model: "gpt-4o", messages: [ { role: "system", content: RAG_SYSTEM_PROMPT }, { role: "user", content: fullPrompt }, ], stream: true, }); for await (const chunk of stream) { const content = chunk.choices[0]?.delta?.content; if (content) { await appendChunk(content); } } } else { // Use Anthropic (default) const anthropicApiKey = process.env.ANTHROPIC_API_KEY; if (!anthropicApiKey) { await appendChunk("**Error:** ANTHROPIC_API_KEY not configured in Convex dashboard."); return; } const anthropic = new Anthropic({ apiKey: anthropicApiKey }); // Use non-streaming for more reliable error handling const response = await anthropic.messages.create({ model: "claude-sonnet-4-20250514", max_tokens: 2048, system: RAG_SYSTEM_PROMPT, messages: [{ role: "user", content: fullPrompt }], }); // Extract text from response for (const block of response.content) { if (block.type === "text") { // Stream word by word for better UX const words = block.text.split(/(\s+)/); for (const word of words) { await appendChunk(word); } } } } // Add source citations await appendChunk("\n\n---\n\n**Sources:**\n"); for (const source of searchResults) { await appendChunk(`- [${source.title}](/${source.slug})\n`); } } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; console.error("Generation error:", error); try { await appendChunk(`\n\n**Error:** ${errorMessage}`); } catch { // Stream may already be closed, ignore } } }; const response = await streaming.stream( ctx, request, streamId as StreamId, generateAnswer ); // Set CORS headers response.headers.set("Access-Control-Allow-Origin", "*"); response.headers.set("Access-Control-Allow-Methods", "POST, OPTIONS"); response.headers.set("Access-Control-Allow-Headers", "Content-Type"); response.headers.set("Vary", "Origin"); return response; }); // CORS preflight handler export const streamResponseOptions = httpAction(async () => { return new Response(null, { status: 204, headers: { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", "Access-Control-Allow-Headers": "Content-Type", "Access-Control-Max-Age": "86400", }, }); }); // Check if Ask AI is properly configured (environment variables set) export const checkConfiguration = action({ args: {}, returns: v.object({ configured: v.boolean(), hasOpenAI: v.boolean(), hasAnthropic: v.boolean(), missingKeys: v.array(v.string()), }), handler: async () => { const hasOpenAI = !!process.env.OPENAI_API_KEY; const hasAnthropic = !!process.env.ANTHROPIC_API_KEY; const missingKeys: string[] = []; if (!hasOpenAI) missingKeys.push("OPENAI_API_KEY"); if (!hasAnthropic) missingKeys.push("ANTHROPIC_API_KEY"); // Ask AI requires at least OPENAI_API_KEY for embeddings // and either ANTHROPIC_API_KEY or OPENAI_API_KEY for LLM const configured = hasOpenAI && (hasAnthropic || hasOpenAI); return { configured, hasOpenAI, hasAnthropic, missingKeys, }; }, });