mirror of
https://github.com/waynesutton/markdown-site.git
synced 2026-01-12 04:09:14 +00:00
Ask AI header button with RAG-based Q&A with semeantic-search added, config in siteconfig
This commit is contained in:
37
convex/_generated/api.d.ts
vendored
37
convex/_generated/api.d.ts
vendored
@@ -11,6 +11,7 @@
|
||||
import type * as aiChatActions from "../aiChatActions.js";
|
||||
import type * as aiChats from "../aiChats.js";
|
||||
import type * as aiImageGeneration from "../aiImageGeneration.js";
|
||||
import type * as askAI from "../askAI.js";
|
||||
import type * as cms from "../cms.js";
|
||||
import type * as contact from "../contact.js";
|
||||
import type * as contactActions from "../contactActions.js";
|
||||
@@ -39,6 +40,7 @@ declare const fullApi: ApiFromModules<{
|
||||
aiChatActions: typeof aiChatActions;
|
||||
aiChats: typeof aiChats;
|
||||
aiImageGeneration: typeof aiImageGeneration;
|
||||
askAI: typeof askAI;
|
||||
cms: typeof cms;
|
||||
contact: typeof contact;
|
||||
contactActions: typeof contactActions;
|
||||
@@ -643,4 +645,39 @@ export declare const components: {
|
||||
>;
|
||||
};
|
||||
};
|
||||
persistentTextStreaming: {
|
||||
lib: {
|
||||
addChunk: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{ final: boolean; streamId: string; text: string },
|
||||
any
|
||||
>;
|
||||
createStream: FunctionReference<"mutation", "internal", {}, any>;
|
||||
getStreamStatus: FunctionReference<
|
||||
"query",
|
||||
"internal",
|
||||
{ streamId: string },
|
||||
"pending" | "streaming" | "done" | "error" | "timeout"
|
||||
>;
|
||||
getStreamText: FunctionReference<
|
||||
"query",
|
||||
"internal",
|
||||
{ streamId: string },
|
||||
{
|
||||
status: "pending" | "streaming" | "done" | "error" | "timeout";
|
||||
text: string;
|
||||
}
|
||||
>;
|
||||
setStreamStatus: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{
|
||||
status: "pending" | "streaming" | "done" | "error" | "timeout";
|
||||
streamId: string;
|
||||
},
|
||||
any
|
||||
>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
317
convex/askAI.node.ts
Normal file
317
convex/askAI.node.ts
Normal file
@@ -0,0 +1,317 @@
|
||||
"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,
|
||||
};
|
||||
},
|
||||
});
|
||||
61
convex/askAI.ts
Normal file
61
convex/askAI.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { v } from "convex/values";
|
||||
import { mutation, query, internalQuery } from "./_generated/server";
|
||||
import { components } from "./_generated/api";
|
||||
import { PersistentTextStreaming, StreamIdValidator, StreamId } from "@convex-dev/persistent-text-streaming";
|
||||
|
||||
// Initialize Persistent Text Streaming component (works in Convex runtime)
|
||||
const streaming = new PersistentTextStreaming(components.persistentTextStreaming);
|
||||
|
||||
// Create a new Ask AI session with streaming
|
||||
export const createSession = mutation({
|
||||
args: {
|
||||
question: v.string(),
|
||||
model: v.optional(v.string()),
|
||||
},
|
||||
returns: v.object({
|
||||
sessionId: v.id("askAISessions"),
|
||||
streamId: v.string(),
|
||||
}),
|
||||
handler: async (ctx, { question, model }) => {
|
||||
const streamId = await streaming.createStream(ctx);
|
||||
const sessionId = await ctx.db.insert("askAISessions", {
|
||||
question,
|
||||
streamId,
|
||||
model: model || "claude-sonnet-4-20250514",
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
return { sessionId, streamId };
|
||||
},
|
||||
});
|
||||
|
||||
// Get stream body for database fallback (used by useStream hook)
|
||||
export const getStreamBody = query({
|
||||
args: {
|
||||
streamId: StreamIdValidator,
|
||||
},
|
||||
handler: async (ctx, { streamId }) => {
|
||||
return await streaming.getStreamBody(ctx, streamId as StreamId);
|
||||
},
|
||||
});
|
||||
|
||||
// Internal query to get session by streamId (used by HTTP action)
|
||||
export const getSessionByStreamId = internalQuery({
|
||||
args: {
|
||||
streamId: v.string(),
|
||||
},
|
||||
returns: v.union(
|
||||
v.object({
|
||||
question: v.string(),
|
||||
model: v.optional(v.string()),
|
||||
}),
|
||||
v.null()
|
||||
),
|
||||
handler: async (ctx, { streamId }) => {
|
||||
const session = await ctx.db
|
||||
.query("askAISessions")
|
||||
.withIndex("by_stream", (q) => q.eq("streamId", streamId))
|
||||
.first();
|
||||
if (!session) return null;
|
||||
return { question: session.question, model: session.model };
|
||||
},
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineApp } from "convex/server";
|
||||
import aggregate from "@convex-dev/aggregate/convex.config.js";
|
||||
import persistentTextStreaming from "@convex-dev/persistent-text-streaming/convex.config";
|
||||
|
||||
const app = defineApp();
|
||||
|
||||
@@ -12,5 +13,8 @@ app.use(aggregate, { name: "totalPageViews" });
|
||||
// Aggregate component for unique visitors count
|
||||
app.use(aggregate, { name: "uniqueVisitors" });
|
||||
|
||||
// Persistent text streaming for real-time AI responses in Ask AI feature
|
||||
app.use(persistentTextStreaming);
|
||||
|
||||
export default app;
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { httpRouter } from "convex/server";
|
||||
import { httpAction } from "./_generated/server";
|
||||
import { api } from "./_generated/api";
|
||||
import { rssFeed, rssFullFeed } from "./rss";
|
||||
import { streamResponse, streamResponseOptions } from "./askAI.node";
|
||||
|
||||
const http = httpRouter();
|
||||
|
||||
@@ -399,4 +400,18 @@ http.route({
|
||||
}),
|
||||
});
|
||||
|
||||
// Ask AI streaming endpoint for RAG-based Q&A
|
||||
http.route({
|
||||
path: "/ask-ai-stream",
|
||||
method: "POST",
|
||||
handler: streamResponse,
|
||||
});
|
||||
|
||||
// CORS preflight for Ask AI endpoint
|
||||
http.route({
|
||||
path: "/ask-ai-stream",
|
||||
method: "OPTIONS",
|
||||
handler: streamResponseOptions,
|
||||
});
|
||||
|
||||
export default http;
|
||||
|
||||
@@ -226,4 +226,22 @@ export default defineSchema({
|
||||
createdAt: v.number(), // Timestamp when submitted
|
||||
emailSentAt: v.optional(v.number()), // Timestamp when email was sent (if applicable)
|
||||
}).index("by_createdAt", ["createdAt"]),
|
||||
|
||||
// Ask AI sessions for header AI chat feature
|
||||
// Stores questions and stream IDs for RAG-based Q&A
|
||||
askAISessions: defineTable({
|
||||
question: v.string(), // User's question
|
||||
streamId: v.string(), // Persistent text streaming ID
|
||||
model: v.optional(v.string()), // Selected AI model
|
||||
createdAt: v.number(), // Timestamp when session was created
|
||||
sources: v.optional(
|
||||
v.array(
|
||||
v.object({
|
||||
title: v.string(),
|
||||
slug: v.string(),
|
||||
type: v.string(),
|
||||
})
|
||||
)
|
||||
), // Optional sources cited in the response
|
||||
}).index("by_stream", ["streamId"]),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user