Files
wiki/convex/askAI.node.ts

318 lines
9.9 KiB
TypeScript
Raw Normal View History

"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<void>
) => {
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,
};
},
});