From b274ddf3c91237b370aa0c4676eb1d7e03869407 Mon Sep 17 00:00:00 2001 From: Wayne Sutton Date: Tue, 6 Jan 2026 21:05:20 -0800 Subject: [PATCH] Ask AI header button with RAG-based Q&A with semeantic-search added, config in siteconfig --- AGENTS.md | 2 +- CLAUDE.md | 2 +- changelog.md | 46 +++ content/pages/about.md | 1 + content/pages/changelog-page.md | 62 ++++ content/pages/docs-ask-ai.md | 171 ++++++++++ convex/_generated/api.d.ts | 37 +++ convex/askAI.node.ts | 317 +++++++++++++++++++ convex/askAI.ts | 61 ++++ convex/convex.config.ts | 4 + convex/http.ts | 15 + convex/schema.ts | 18 ++ files.md | 10 +- package-lock.json | 12 + package.json | 1 + public/images/askai.png | Bin 0 -> 44520 bytes public/llms.txt | 2 +- public/raw/about.md | 3 +- public/raw/changelog.md | 93 +++++- public/raw/contact.md | 2 +- public/raw/docs-ask-ai.md | 159 ++++++++++ public/raw/docs-configuration.md | 2 +- public/raw/docs-content.md | 2 +- public/raw/docs-dashboard.md | 2 +- public/raw/docs-deployment.md | 2 +- public/raw/docs-frontmatter.md | 2 +- public/raw/docs-search.md | 2 +- public/raw/docs-semantic-search.md | 2 +- public/raw/documentation.md | 2 +- public/raw/footer.md | 2 +- public/raw/home-intro.md | 2 +- public/raw/index.md | 5 +- public/raw/newsletter.md | 2 +- public/raw/projects.md | 2 +- src/components/AskAIModal.tsx | 423 +++++++++++++++++++++++++ src/components/Layout.tsx | 50 ++- src/config/siteConfig.ts | 50 ++- src/styles/global.css | 481 +++++++++++++++++++++++++++++ 38 files changed, 2013 insertions(+), 38 deletions(-) create mode 100644 content/pages/docs-ask-ai.md create mode 100644 convex/askAI.node.ts create mode 100644 convex/askAI.ts create mode 100644 public/images/askai.png create mode 100644 public/raw/docs-ask-ai.md create mode 100644 src/components/AskAIModal.tsx diff --git a/AGENTS.md b/AGENTS.md index 0a42143..2c0858d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ Your content is instantly available to browsers, LLMs, and AI agents.. Write mar - **Total Posts**: 17 - **Total Pages**: 4 - **Latest Post**: 2025-12-29 -- **Last Updated**: 2026-01-06T02:32:19.578Z +- **Last Updated**: 2026-01-06T21:21:00.308Z ## Tech stack diff --git a/CLAUDE.md b/CLAUDE.md index 85c7890..a266f67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ Project instructions for Claude Code. ## Project context - + Markdown sync framework. Write markdown in `content/`, run sync commands, content appears instantly via Convex real-time database. Built for developers and AI agents. diff --git a/changelog.md b/changelog.md index c267ccd..b47240e 100644 --- a/changelog.md +++ b/changelog.md @@ -4,6 +4,52 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [2.11.0] - 2026-01-06 + +### Added + +- Ask AI header button with RAG-based Q&A about site content + - Header button with sparkle icon (before search button, after social icons) + - Keyboard shortcuts: Cmd+J or Cmd+/ (Mac), Ctrl+J or Ctrl+/ (Windows/Linux) + - Real-time streaming responses via Convex Persistent Text Streaming + - Model selector: Claude Sonnet 4 (default) or GPT-4o + - Markdown rendering with syntax highlighting in responses + - Internal links use React Router for seamless navigation + - Source citations with links to referenced posts/pages + - Copy response button (hover to reveal) for copying AI answers + - Clear chat button to reset conversation +- AskAIConfig in siteConfig.ts for configuration + - `enabled`: Toggle Ask AI feature + - `defaultModel`: Default model ID + - `models`: Array of available models with id, name, and provider + +### How It Works + +1. User question stored in database with session ID +2. Query converted to embedding using OpenAI text-embedding-ada-002 +3. Vector search finds top 5 relevant posts/pages +4. Content sent to selected AI model with RAG system prompt +5. Response streams in real-time with source citations appended + +### Technical + +- New component: `src/components/AskAIModal.tsx` with StreamingMessage subcomponent +- New file: `convex/askAI.ts` - Session mutations and queries (regular runtime) +- New file: `convex/askAI.node.ts` - HTTP streaming action (Node.js runtime) +- New table: `askAISessions` with question, streamId, model, createdAt, sources fields +- New HTTP endpoint: `/ask-ai-stream` for streaming responses +- Updated `convex/convex.config.ts` with persistentTextStreaming component +- Updated `convex/http.ts` with /ask-ai-stream route and OPTIONS handler +- Updated `src/components/Layout.tsx` with Ask AI button and modal +- Updated `src/styles/global.css` with Ask AI modal styles + +### Requirements + +- `semanticSearch.enabled: true` in siteConfig (for embeddings) +- `OPENAI_API_KEY` in Convex (for embedding generation) +- `ANTHROPIC_API_KEY` in Convex (for Claude models) +- Run `npm run sync` to generate embeddings for content + ## [2.10.2] - 2026-01-06 ### Added diff --git a/content/pages/about.md b/content/pages/about.md index f9a9866..6336121 100644 --- a/content/pages/about.md +++ b/content/pages/about.md @@ -85,6 +85,7 @@ It's a hybrid: developer workflow for publishing + real-time delivery like a dyn - Dual search modes: Keyword (exact match) and Semantic (meaning-based) with Cmd+K toggle - Semantic search uses OpenAI embeddings for finding conceptually similar content +- Ask AI header button (Cmd+J) for RAG-based Q&A about site content with streaming responses - Full text search with Command+K shortcut and result highlighting - Static raw markdown files at `/raw/{slug}.md` - RSS feeds (`/rss.xml` and `/rss-full.xml`) and sitemap for SEO diff --git a/content/pages/changelog-page.md b/content/pages/changelog-page.md index a8391e9..df7afae 100644 --- a/content/pages/changelog-page.md +++ b/content/pages/changelog-page.md @@ -11,6 +11,68 @@ docsSectionOrder: 4 All notable changes to this project. +## v2.11.0 + +Released January 6, 2026 + +**Ask AI header button with RAG-based Q&A** + +New header button that opens a chat modal for asking questions about site content. Uses semantic search to find relevant posts and pages, then generates AI responses with source citations. + +**Features:** + +- Header button with sparkle icon (before search button) +- Keyboard shortcuts: Cmd+J or Cmd+/ (Mac), Ctrl+J or Ctrl+/ (Windows/Linux) +- Real-time streaming responses via Convex Persistent Text Streaming +- Model selector: Claude Sonnet 4 (default) or GPT-4o +- Markdown rendering with syntax highlighting +- Internal links use React Router for seamless navigation +- Source citations with links to referenced content +- Copy response button (hover to reveal) for copying AI answers +- Chat history within session (clears on page refresh) +- Clear chat button to reset conversation + +**How it works:** + +1. User question is stored in database with session ID +2. Query is converted to embedding using OpenAI text-embedding-ada-002 +3. Vector search finds top 5 relevant posts/pages +4. Content is sent to selected AI model with RAG system prompt +5. Response streams in real-time with source citations appended + +**Configuration:** + +Enable in `src/config/siteConfig.ts`: + +```typescript +askAI: { + enabled: true, + defaultModel: "claude-sonnet-4-20250514", + models: [ + { id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4", provider: "anthropic" }, + { id: "gpt-4o", name: "GPT-4o", provider: "openai" }, + ], +}, +``` + +**Requirements:** + +- `semanticSearch.enabled: true` (for embeddings) +- `OPENAI_API_KEY` in Convex (for embeddings) +- `ANTHROPIC_API_KEY` in Convex (for Claude models) +- Run `npm run sync` to generate embeddings + +**Technical details:** + +- New component: `src/components/AskAIModal.tsx` +- New Convex files: `convex/askAI.ts` (mutations/queries), `convex/askAI.node.ts` (HTTP action) +- New table: `askAISessions` with `by_stream` index +- HTTP endpoint: `/ask-ai-stream` for streaming responses +- Uses `@convex-dev/persistent-text-streaming` component +- Separated Node.js runtime (askAI.node.ts) from regular runtime (askAI.ts) + +Updated files: `convex/schema.ts`, `convex/askAI.ts`, `convex/askAI.node.ts`, `convex/http.ts`, `convex/convex.config.ts`, `src/components/AskAIModal.tsx`, `src/components/Layout.tsx`, `src/config/siteConfig.ts`, `src/styles/global.css` + ## v2.10.2 Released January 6, 2026 diff --git a/content/pages/docs-ask-ai.md b/content/pages/docs-ask-ai.md new file mode 100644 index 0000000..139298f --- /dev/null +++ b/content/pages/docs-ask-ai.md @@ -0,0 +1,171 @@ +--- +title: "Ask AI" +slug: "docs-ask-ai" +published: true +order: 2 +showInNav: false +layout: "sidebar" +rightSidebar: true +showImageAtTop: true +authorName: "Markdown" +authorImage: "/images/authors/markdown.png" +image: "/images/askai.png" +showFooter: true +docsSection: true +docsSectionOrder: 5 +docsSectionGroup: "Setup" +docsSectionGroupIcon: "Rocket" +--- + +## Ask AI + +Ask AI is a header button that opens a chat modal for asking questions about your site content. It uses RAG (Retrieval-Augmented Generation) to find relevant content and generate AI responses with source citations. + +Press `Cmd+J` or `Cmd+/` (Mac) or `Ctrl+J` or `Ctrl+/` (Windows/Linux) to open the Ask AI modal. + +--- + +### How Ask AI works + +``` ++------------------+ +-------------------+ +------------------+ +| User question |--->| OpenAI Embedding |--->| Vector Search | +| "How do I..." | | text-embedding- | | Find top 5 | +| | | ada-002 | | relevant pages | ++------------------+ +-------------------+ +--------+---------+ + | + v ++------------------+ +-------------------+ +------------------+ +| Streaming |<---| AI Model |<---| RAG Context | +| Response with | | Claude/GPT-4o | | Build prompt | +| Source Links | | generates answer | | with content | ++------------------+ +-------------------+ +------------------+ +``` + +1. Your question is stored in the database with a session ID +2. Query is converted to a vector embedding using OpenAI +3. Convex vector search finds the 5 most relevant posts and pages +4. Content is combined into a RAG prompt with system instructions +5. AI model generates an answer based only on your site content +6. Response streams in real-time with source citations appended + +### Features + +| Feature | Description | +| ------------------ | ------------------------------------------------------ | +| Streaming | Responses appear word-by-word in real-time | +| Model Selection | Choose between Claude Sonnet 4 or GPT-4o | +| Source Citations | Every response includes links to source content | +| Markdown Rendering | Responses support full markdown formatting | +| Internal Links | Links to your pages use React Router (no page reload) | +| Copy Response | Hover over any response to copy it to clipboard | +| Keyboard Shortcuts | Cmd+J or Cmd+/ to open, Escape to close, Enter to send | + +### Configuration + +Ask AI requires semantic search to be enabled (for embeddings): + +```typescript +// src/config/siteConfig.ts +semanticSearch: { + enabled: true, +}, + +askAI: { + enabled: true, + defaultModel: "claude-sonnet-4-20250514", + models: [ + { id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4", provider: "anthropic" }, + { id: "gpt-4o", name: "GPT-4o", provider: "openai" }, + ], +}, +``` + +### Environment variables + +Set these in your Convex dashboard: + +```bash +# Required for embeddings (vector search) +npx convex env set OPENAI_API_KEY sk-your-key-here + +# Required for Claude models +npx convex env set ANTHROPIC_API_KEY sk-ant-your-key-here +``` + +After setting environment variables, run `npm run sync` to generate embeddings for your content. + +### When to use Ask AI vs Search + +| Use Case | Tool | +| -------------------------------- | ----------------------- | +| Quick navigation to a known page | Keyword Search (Cmd+K) | +| Find exact code or commands | Keyword Search | +| "How do I do X?" questions | Ask AI (Cmd+J or Cmd+/) | +| Understanding a concept | Ask AI | +| Need highlighted matches on page | Keyword Search | +| Want AI-synthesized answers | Ask AI | + +### Technical details + +**Frontend:** + +| File | Purpose | +| ------------------------------- | ------------------------------------ | +| `src/components/AskAIModal.tsx` | Chat modal with streaming messages | +| `src/components/Layout.tsx` | Header button and keyboard shortcuts | +| `src/config/siteConfig.ts` | AskAIConfig interface and settings | + +**Backend (Convex):** + +| File | Purpose | +| ------------------------- | ----------------------------------------------- | +| `convex/askAI.ts` | Session mutations and queries (regular runtime) | +| `convex/askAI.node.ts` | HTTP streaming action (Node.js runtime) | +| `convex/schema.ts` | askAISessions table definition | +| `convex/http.ts` | /ask-ai-stream endpoint registration | +| `convex/convex.config.ts` | persistentTextStreaming component | + +**Database:** + +The `askAISessions` table stores: + +- `question`: The user's question +- `streamId`: Persistent Text Streaming ID +- `model`: Selected AI model ID +- `createdAt`: Timestamp +- `sources`: Optional array of cited sources + +### Limitations + +- **Requires semantic search**: Embeddings must be generated for content +- **API costs**: Each query costs embedding generation (~$0.0001) plus AI model usage +- **Latency**: ~1-3 seconds for initial response (embedding + search + AI) +- **Content scope**: Only searches published posts and pages +- **No conversation history**: Each session starts fresh (no multi-turn context) + +### Troubleshooting + +**"Failed to load response" error:** + +1. Check that `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` is set in Convex +2. Verify the API key is valid and has credits +3. Check browser console for specific error messages + +**Empty or irrelevant responses:** + +1. Run `npm run sync` to ensure embeddings are generated +2. Check that `semanticSearch.enabled: true` in siteConfig +3. Verify content exists in your posts/pages + +**Modal doesn't open:** + +1. Check that `askAI.enabled: true` in siteConfig +2. Check that `semanticSearch.enabled: true` in siteConfig +3. Both conditions must be true for the button to appear + +### Resources + +- [Semantic Search Documentation](/docs-semantic-search) - How embeddings work +- [Convex Persistent Text Streaming](https://github.com/get-convex/persistent-text-streaming) - Streaming component +- [Convex Vector Search](https://docs.convex.dev/search/vector-search) - Vector search documentation diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 53e06e5..5b408d5 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -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 + >; + }; + }; }; diff --git a/convex/askAI.node.ts b/convex/askAI.node.ts new file mode 100644 index 0000000..0ced2f9 --- /dev/null +++ b/convex/askAI.node.ts @@ -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 + ) => { + 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, + }; + }, +}); diff --git a/convex/askAI.ts b/convex/askAI.ts new file mode 100644 index 0000000..de83afa --- /dev/null +++ b/convex/askAI.ts @@ -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 }; + }, +}); diff --git a/convex/convex.config.ts b/convex/convex.config.ts index 53031cb..6370927 100644 --- a/convex/convex.config.ts +++ b/convex/convex.config.ts @@ -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; diff --git a/convex/http.ts b/convex/http.ts index 156044e..e8d71dc 100644 --- a/convex/http.ts +++ b/convex/http.ts @@ -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; diff --git a/convex/schema.ts b/convex/schema.ts index ee3e2c5..1c95585 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -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"]), }); diff --git a/files.md b/files.md index b26df77..e7a9d8a 100644 --- a/files.md +++ b/files.md @@ -35,7 +35,7 @@ A brief description of each file in the codebase. | File | Description | | --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `siteConfig.ts` | Centralized site configuration (name, logo, blog page, posts display with homepage post limit and read more link, featured section with configurable title via featuredTitle, GitHub contributions, nav order, inner page logo settings, hardcoded navigation items for React routes, GitHub repository config for AI service raw URLs, font family configuration, right sidebar configuration, footer configuration with markdown support, social footer configuration, homepage configuration, AI chat configuration, aiDashboard configuration with multi-model support for text chat and image generation, newsletter configuration with admin and notifications, contact form configuration, weekly digest configuration, stats page configuration with public/private toggle, dashboard configuration with optional WorkOS authentication via requireAuth, image lightbox configuration with enabled toggle, semantic search configuration with enabled toggle and disabled by default to avoid blocking forks without OPENAI_API_KEY, twitter configuration for Twitter Cards meta tags) | +| `siteConfig.ts` | Centralized site configuration (name, logo, blog page, posts display with homepage post limit and read more link, featured section with configurable title via featuredTitle, GitHub contributions, nav order, inner page logo settings, hardcoded navigation items for React routes, GitHub repository config for AI service raw URLs, font family configuration, right sidebar configuration, footer configuration with markdown support, social footer configuration, homepage configuration, AI chat configuration, aiDashboard configuration with multi-model support for text chat and image generation, newsletter configuration with admin and notifications, contact form configuration, weekly digest configuration, stats page configuration with public/private toggle, dashboard configuration with optional WorkOS authentication via requireAuth, image lightbox configuration with enabled toggle, semantic search configuration with enabled toggle and disabled by default to avoid blocking forks without OPENAI_API_KEY, twitter configuration for Twitter Cards meta tags, askAI configuration with enabled toggle, default model, and available models for header Ask AI feature) | ### Pages (`src/pages/`) @@ -76,6 +76,7 @@ A brief description of each file in the codebase. | `NewsletterSignup.tsx` | Newsletter signup form component for email-only subscriptions. Displays configurable title/description, validates email, and submits to Convex. Shows on home, blog page, and posts based on siteConfig.newsletter settings. Supports frontmatter override via newsletter: true/false. Includes honeypot field for bot protection. | | `ContactForm.tsx` | Contact form component with name, email, and message fields. Displays when contactForm: true in frontmatter. Submits to Convex which sends email via AgentMail to configured recipient. Requires AGENTMAIL_API_KEY and AGENTMAIL_INBOX environment variables. Includes honeypot field for bot protection. | | `SocialFooter.tsx` | Social footer component with social icons on left (GitHub, Twitter/X, LinkedIn, Instagram, YouTube, TikTok, Discord, Website) and copyright on right. Configurable via siteConfig.socialFooter. Shows below main footer on homepage, blog posts, and pages. Supports frontmatter override via showSocialFooter: true/false. Auto-updates copyright year. Exports `platformIcons` for reuse in header. | +| `AskAIModal.tsx` | Ask AI chat modal for RAG-based Q&A about site content. Opens via header button (Cmd+J) when enabled. Uses Convex Persistent Text Streaming for real-time responses. Supports model selection (Claude, GPT-4o). Features streaming messages with markdown rendering, internal link handling via React Router, and source citations. Requires siteConfig.askAI.enabled and siteConfig.semanticSearch.enabled. | ### Context (`src/context/`) @@ -109,7 +110,7 @@ A brief description of each file in the codebase. | File | Description | | ------------------ | ------------------------------------------------------------------------------------------------------------------ | -| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions, aiChats, newsletterSubscribers, newsletterSentPosts, contactMessages) with indexes for tag queries (by_tags), AI queries, blog featured posts (by_blogFeatured), source tracking (by_source), and vector search (by_embedding). Posts and pages include showSocialFooter, showImageAtTop, blogFeatured, contactForm, source, and embedding fields for frontmatter control, cloud CMS tracking, and semantic search. | +| `schema.ts` | Database schema (posts, pages, viewCounts, pageViews, activeSessions, aiChats, aiGeneratedImages, newsletterSubscribers, newsletterSentPosts, contactMessages, askAISessions) with indexes for tag queries (by_tags), AI queries, blog featured posts (by_blogFeatured), source tracking (by_source), and vector search (by_embedding). Posts and pages include showSocialFooter, showImageAtTop, blogFeatured, contactForm, source, and embedding fields for frontmatter control, cloud CMS tracking, and semantic search. askAISessions stores question, streamId, model, and sources for Ask AI RAG feature. | | `cms.ts` | CRUD mutations for dashboard cloud CMS: createPost, updatePost, deletePost, createPage, updatePage, deletePage, exportPostAsMarkdown, exportPageAsMarkdown. Posts/pages created via dashboard have `source: "dashboard"` (protected from sync overwrites). | | `importAction.ts` | Server-side Convex action for direct URL import via Firecrawl API. Scrapes URL, converts to markdown, saves directly to database with `source: "dashboard"`. Requires FIRECRAWL_API_KEY environment variable. | | `posts.ts` | Queries and mutations for blog posts, view counts, getAllTags, getPostsByTag, getRelatedPosts, and getBlogFeaturedPosts. Includes tag-based queries for tag pages and related posts functionality. | @@ -130,7 +131,9 @@ A brief description of each file in the codebase. | `newsletter.ts` | Newsletter mutations and queries: subscribe, unsubscribe, getSubscriberCount, getActiveSubscribers, getAllSubscribers (admin), deleteSubscriber (admin), getNewsletterStats, getPostsForNewsletter, wasPostSent, recordPostSent, scheduleSendPostNewsletter, scheduleSendCustomNewsletter, scheduleSendStatsSummary, getStatsForSummary. | | `newsletterActions.ts` | Newsletter actions (Node.js runtime): sendPostNewsletter, sendCustomNewsletter, sendWeeklyDigest, notifyNewSubscriber, sendWeeklyStatsSummary. Uses AgentMail SDK for email delivery. Includes markdown-to-HTML conversion for custom emails. | | `contact.ts` | Contact form mutations and actions: submitContact, sendContactEmail (AgentMail API), markEmailSent. | -| `convex.config.ts` | Convex app configuration with aggregate component registrations (pageViewsByPath, totalPageViews, uniqueVisitors) | +| `askAI.ts` | Ask AI session management: createSession mutation (creates streaming session with question/model in DB), getStreamBody query (for database fallback), getSessionByStreamId internal query (retrieves question/model for HTTP action). Uses Persistent Text Streaming component. | +| `askAI.node.ts` | Ask AI HTTP action for streaming responses (Node.js runtime). Retrieves question from database, performs vector search using existing semantic search embeddings, generates AI response via Anthropic Claude or OpenAI GPT-4o, streams via appendChunk. Includes CORS headers and source citations. | +| `convex.config.ts` | Convex app configuration with aggregate component registrations (pageViewsByPath, totalPageViews, uniqueVisitors) and persistentTextStreaming component | | `tsconfig.json` | Convex TypeScript configuration | ### HTTP Endpoints (defined in `http.ts`) @@ -148,6 +151,7 @@ A brief description of each file in the codebase. | `/.well-known/ai-plugin.json` | AI plugin manifest | | `/openapi.yaml` | OpenAPI 3.0 specification | | `/llms.txt` | AI agent discovery | +| `/ask-ai-stream` | Ask AI streaming endpoint for RAG-based Q&A (POST with streamId) | ## Content (`content/blog/`) diff --git a/package-lock.json b/package-lock.json index c916741..1ac1ed2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.71.2", "@convex-dev/aggregate": "^0.2.0", + "@convex-dev/persistent-text-streaming": "^0.3.0", "@convex-dev/workos": "^0.0.1", "@google/genai": "^1.0.1", "@mendable/firecrawl-js": "^1.21.1", @@ -391,6 +392,17 @@ "convex": "^1.24.8" } }, + "node_modules/@convex-dev/persistent-text-streaming": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@convex-dev/persistent-text-streaming/-/persistent-text-streaming-0.3.0.tgz", + "integrity": "sha512-y7CteewFHrBKhVSoLxTMEwWPEmc/3J+BTJ+x+8pvh5DCUlwN80eWmfojpmGOQr7xSc6UC/c7DxlZXQPN7dVlKg==", + "license": "Apache-2.0", + "peerDependencies": { + "convex": "^1.24.8", + "react": "~18.3.1 || ^19.0.0", + "react-dom": "~18.3.1 || ^19.0.0" + } + }, "node_modules/@convex-dev/workos": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/@convex-dev/workos/-/workos-0.0.1.tgz", diff --git a/package.json b/package.json index ec82225..7a9615f 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "dependencies": { "@anthropic-ai/sdk": "^0.71.2", "@convex-dev/aggregate": "^0.2.0", + "@convex-dev/persistent-text-streaming": "^0.3.0", "@convex-dev/workos": "^0.0.1", "@google/genai": "^1.0.1", "@mendable/firecrawl-js": "^1.21.1", diff --git a/public/images/askai.png b/public/images/askai.png new file mode 100644 index 0000000000000000000000000000000000000000..90a6b7304e09c6f04b1328a5977e132dad8896a6 GIT binary patch literal 44520 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 z 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
  • ~5hqplVLH9n8jsObci3Ye+tco4UpC`aX9vUEQA>zP5tbCn2H5fmB?8Kfh={BMZz5!kVm%u#(YD zgKD8+TZlUgz}$Q?Sz}$>C>Ie=MxAw*I?4&TrqG(DO^)o+d)OuovV2le0c{4om*r#= zO6Z0XiBUt<&afS+z)fx|(01ECrQeV&p>y7W*m80YNVLJkajBocZ$f}Sb~B1goHXZ6 z;B^}a_vMtTq*%;?9KP+&8>qnTH`BP0S6aeWEcc*ZO;4imeaelg)(+56A?HH@^F4>Z zBNoGV1`Aqk+mijbgo4GmiDV$o{=rikQj7D?6=rpWo(3n={Wnz%u=dP>hU*kB?Ul0X zch3Y_-XqSF_U??trz2FJ5D0t6n#y(c0WbAS=-GB@H!x1Rbpe>4sfi`C$l@2xbbA;& ze5sMQ(kfbFVzc$Oq1ZTB4cSN)$OVrDMPQqAK+dHC#(_Xrcp6Sm_sIfMzA^PS6S=u| zT|qUA_XlMCdBz}I`{4xxsgF^-hGmF5Orv{pW6c6?6)EMJx??sMH!0;?QVWxGK$Yke zb!=A)HB3&5(gnzeofD=o&;;~myA})TB1@&*L~CoGRdw9Vsh_%95Ng|*+VbcLiAifq z0)fQQUJ1F(-$Y0cKb~2&Z26AQX_A@UTjIBBhvU;Rr%zeF=-2K>`0MW2*2J3%PRGEq zpd9M$i1kP3SBlS1_tZHrRgtJc3X*zuI1)OlF2A$JF0V2+lxB`EyRq!pWmBGC3)v`U z6%jT?tj*Oef=yuu@QPu7fE2iSU3S8;IWjGDsHiP2K!Mi~)g2bA-U z#%>U2&jr`kHWo9#ij7XOG2sXGs#XFI!?ad3dgCtK#SBPDcz6?tT5J{OeZ#L~C?SLZ z(;)qCKWqc>(e<#)A(q59`b$OohS)61jh)-vB2RtpdZ3=2GFZg>3}`pn1?fTNJaX2! zbm>vZ5TL{I@Y6~vx+t)AwLfx??2h3MA;$+7gC4yOpsrd`A0eSR&z;^J_!}refBr-N z&_ReQ;+O)sZ1TbU&@Wzce-jIgWef22A$lq%ddpMuJI{5g0iY`;Xeg=7!*Nr5n-*j_ zF|4H$p_ujE?_j$eL)TG$M5Ly*Bxw3V&>~-oZ0kugBS^|%5G}d<+t%piY*=-ib+j>R z0k4Eizj5~lxd9x3m1yd2J%ExyBr~B)p+F|bH~?7Mz)9l4T6Uq82EHkYzNEQ^S)^5e zbm$6*17Q;t=A`3jYsJim*N&=9oaQ1AA=abNyW73tSr!MA#W`Zz7_`X&K$LejE;wcFW%N$HjHvawu(s+OBW;5cXs_#5GY)_8E629>rD9 zh+Bff@3>FlquB@0imHKC2OXBSX74_D!Sl@U*^NWZ+@4hBd#IG9W@SCd#?Z$FxR`C$ ztqd2~UNqZ`($%>b8fc|T(qa61mAQ)cn+USQsaxaT)3Vx~Qgt+p5w`{x`M#Fi=WPBJ zM9VKc4={LzYfyBH#sEIg1ebPWpaVFb?ZObOX_WA$7{c2vjHE>IdfyG IkyFIK0A!NnxBvhE literal 0 HcmV?d00001 diff --git a/public/llms.txt b/public/llms.txt index fb752da..f1e24e8 100644 --- a/public/llms.txt +++ b/public/llms.txt @@ -1,6 +1,6 @@ # llms.txt - Information for AI assistants and LLMs # Learn more: https://llmstxt.org/ -# Last updated: 2026-01-06T02:32:19.579Z +# Last updated: 2026-01-06T21:21:00.309Z > Your content is instantly available to browsers, LLMs, and AI agents. diff --git a/public/raw/about.md b/public/raw/about.md index 0e77eb9..6381b8a 100644 --- a/public/raw/about.md +++ b/public/raw/about.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-06 +Date: 2026-01-07 --- An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs. Write markdown, sync from the terminal. Your content is instantly available to browsers, LLMs, and AI agents. Built on Convex and Netlify. @@ -84,6 +84,7 @@ It's a hybrid: developer workflow for publishing + real-time delivery like a dyn - Dual search modes: Keyword (exact match) and Semantic (meaning-based) with Cmd+K toggle - Semantic search uses OpenAI embeddings for finding conceptually similar content +- Ask AI header button (Cmd+J) for RAG-based Q&A about site content with streaming responses - Full text search with Command+K shortcut and result highlighting - Static raw markdown files at `/raw/{slug}.md` - RSS feeds (`/rss.xml` and `/rss-full.xml`) and sitemap for SEO diff --git a/public/raw/changelog.md b/public/raw/changelog.md index ac56d5c..9a77950 100644 --- a/public/raw/changelog.md +++ b/public/raw/changelog.md @@ -2,11 +2,102 @@ --- Type: page -Date: 2026-01-06 +Date: 2026-01-07 --- All notable changes to this project. +## v2.11.0 + +Released January 6, 2026 + +**Ask AI header button with RAG-based Q&A** + +New header button that opens a chat modal for asking questions about site content. Uses semantic search to find relevant posts and pages, then generates AI responses with source citations. + +**Features:** + +- Header button with sparkle icon (before search button) +- Keyboard shortcuts: Cmd+J or Cmd+/ (Mac), Ctrl+J or Ctrl+/ (Windows/Linux) +- Real-time streaming responses via Convex Persistent Text Streaming +- Model selector: Claude Sonnet 4 (default) or GPT-4o +- Markdown rendering with syntax highlighting +- Internal links use React Router for seamless navigation +- Source citations with links to referenced content +- Copy response button (hover to reveal) for copying AI answers +- Chat history within session (clears on page refresh) +- Clear chat button to reset conversation + +**How it works:** + +1. User question is stored in database with session ID +2. Query is converted to embedding using OpenAI text-embedding-ada-002 +3. Vector search finds top 5 relevant posts/pages +4. Content is sent to selected AI model with RAG system prompt +5. Response streams in real-time with source citations appended + +**Configuration:** + +Enable in `src/config/siteConfig.ts`: + +```typescript +askAI: { + enabled: true, + defaultModel: "claude-sonnet-4-20250514", + models: [ + { id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4", provider: "anthropic" }, + { id: "gpt-4o", name: "GPT-4o", provider: "openai" }, + ], +}, +``` + +**Requirements:** + +- `semanticSearch.enabled: true` (for embeddings) +- `OPENAI_API_KEY` in Convex (for embeddings) +- `ANTHROPIC_API_KEY` in Convex (for Claude models) +- Run `npm run sync` to generate embeddings + +**Technical details:** + +- New component: `src/components/AskAIModal.tsx` +- New Convex files: `convex/askAI.ts` (mutations/queries), `convex/askAI.node.ts` (HTTP action) +- New table: `askAISessions` with `by_stream` index +- HTTP endpoint: `/ask-ai-stream` for streaming responses +- Uses `@convex-dev/persistent-text-streaming` component +- Separated Node.js runtime (askAI.node.ts) from regular runtime (askAI.ts) + +Updated files: `convex/schema.ts`, `convex/askAI.ts`, `convex/askAI.node.ts`, `convex/http.ts`, `convex/convex.config.ts`, `src/components/AskAIModal.tsx`, `src/components/Layout.tsx`, `src/config/siteConfig.ts`, `src/styles/global.css` + +## v2.10.2 + +Released January 6, 2026 + +**SEO fixes for GitHub Issue #4** + +Seven SEO issues resolved to improve search engine optimization: + +1. **Canonical URL** - Dynamic canonical link tags added client-side for posts and pages +2. **Single H1 per page** - Markdown H1s demoted to H2 elements with `.blog-h1-demoted` class (maintains H1 visual styling) +3. **DOM order fix** - Article now loads before sidebar in DOM for better SEO (CSS `order` property maintains visual layout) +4. **X-Robots-Tag** - HTTP header added via netlify.toml (public routes indexed, dashboard/API routes noindexed) +5. **Hreflang tags** - Self-referencing hreflang (en, x-default) for language targeting +6. **og:url consistency** - Uses same canonicalUrl variable as canonical link tag +7. **twitter:site** - New `TwitterConfig` in siteConfig.ts for Twitter Cards + +**Configuration:** + +Add your Twitter handle in `src/config/siteConfig.ts`: + +```typescript +twitter: { + site: "@yourhandle", + creator: "@yourhandle", +}, +``` + +**Updated files:** `src/config/siteConfig.ts`, `src/pages/Post.tsx`, `src/components/BlogPost.tsx`, `src/styles/global.css`, `convex/http.ts`, `netlify.toml`, `index.html`, `fork-config.json.example` + ## v2.10.1 Released January 5, 2026 diff --git a/public/raw/contact.md b/public/raw/contact.md index 0b6bc53..62ae28f 100644 --- a/public/raw/contact.md +++ b/public/raw/contact.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-06 +Date: 2026-01-07 --- You found the contact page. Nice diff --git a/public/raw/docs-ask-ai.md b/public/raw/docs-ask-ai.md new file mode 100644 index 0000000..9d599ca --- /dev/null +++ b/public/raw/docs-ask-ai.md @@ -0,0 +1,159 @@ +# Ask AI + +--- +Type: page +Date: 2026-01-07 +--- + +## Ask AI + +Ask AI is a header button that opens a chat modal for asking questions about your site content. It uses RAG (Retrieval-Augmented Generation) to find relevant content and generate AI responses with source citations. + +Press `Cmd+J` or `Cmd+/` (Mac) or `Ctrl+J` or `Ctrl+/` (Windows/Linux) to open the Ask AI modal. + +--- + +### How Ask AI works + +``` ++------------------+ +-------------------+ +------------------+ +| User question |--->| OpenAI Embedding |--->| Vector Search | +| "How do I..." | | text-embedding- | | Find top 5 | +| | | ada-002 | | relevant pages | ++------------------+ +-------------------+ +--------+---------+ + | + v ++------------------+ +-------------------+ +------------------+ +| Streaming |<---| AI Model |<---| RAG Context | +| Response with | | Claude/GPT-4o | | Build prompt | +| Source Links | | generates answer | | with content | ++------------------+ +-------------------+ +------------------+ +``` + +1. Your question is stored in the database with a session ID +2. Query is converted to a vector embedding using OpenAI +3. Convex vector search finds the 5 most relevant posts and pages +4. Content is combined into a RAG prompt with system instructions +5. AI model generates an answer based only on your site content +6. Response streams in real-time with source citations appended + +### Features + +| Feature | Description | +| ------------------ | ------------------------------------------------------ | +| Streaming | Responses appear word-by-word in real-time | +| Model Selection | Choose between Claude Sonnet 4 or GPT-4o | +| Source Citations | Every response includes links to source content | +| Markdown Rendering | Responses support full markdown formatting | +| Internal Links | Links to your pages use React Router (no page reload) | +| Copy Response | Hover over any response to copy it to clipboard | +| Keyboard Shortcuts | Cmd+J or Cmd+/ to open, Escape to close, Enter to send | + +### Configuration + +Ask AI requires semantic search to be enabled (for embeddings): + +```typescript +// src/config/siteConfig.ts +semanticSearch: { + enabled: true, +}, + +askAI: { + enabled: true, + defaultModel: "claude-sonnet-4-20250514", + models: [ + { id: "claude-sonnet-4-20250514", name: "Claude Sonnet 4", provider: "anthropic" }, + { id: "gpt-4o", name: "GPT-4o", provider: "openai" }, + ], +}, +``` + +### Environment variables + +Set these in your Convex dashboard: + +```bash +# Required for embeddings (vector search) +npx convex env set OPENAI_API_KEY sk-your-key-here + +# Required for Claude models +npx convex env set ANTHROPIC_API_KEY sk-ant-your-key-here +``` + +After setting environment variables, run `npm run sync` to generate embeddings for your content. + +### When to use Ask AI vs Search + +| Use Case | Tool | +| -------------------------------- | ----------------------- | +| Quick navigation to a known page | Keyword Search (Cmd+K) | +| Find exact code or commands | Keyword Search | +| "How do I do X?" questions | Ask AI (Cmd+J or Cmd+/) | +| Understanding a concept | Ask AI | +| Need highlighted matches on page | Keyword Search | +| Want AI-synthesized answers | Ask AI | + +### Technical details + +**Frontend:** + +| File | Purpose | +| ------------------------------- | ------------------------------------ | +| `src/components/AskAIModal.tsx` | Chat modal with streaming messages | +| `src/components/Layout.tsx` | Header button and keyboard shortcuts | +| `src/config/siteConfig.ts` | AskAIConfig interface and settings | + +**Backend (Convex):** + +| File | Purpose | +| ------------------------- | ----------------------------------------------- | +| `convex/askAI.ts` | Session mutations and queries (regular runtime) | +| `convex/askAI.node.ts` | HTTP streaming action (Node.js runtime) | +| `convex/schema.ts` | askAISessions table definition | +| `convex/http.ts` | /ask-ai-stream endpoint registration | +| `convex/convex.config.ts` | persistentTextStreaming component | + +**Database:** + +The `askAISessions` table stores: + +- `question`: The user's question +- `streamId`: Persistent Text Streaming ID +- `model`: Selected AI model ID +- `createdAt`: Timestamp +- `sources`: Optional array of cited sources + +### Limitations + +- **Requires semantic search**: Embeddings must be generated for content +- **API costs**: Each query costs embedding generation (~$0.0001) plus AI model usage +- **Latency**: ~1-3 seconds for initial response (embedding + search + AI) +- **Content scope**: Only searches published posts and pages +- **No conversation history**: Each session starts fresh (no multi-turn context) + +### Troubleshooting + +**"Failed to load response" error:** + +1. Check that `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` is set in Convex +2. Verify the API key is valid and has credits +3. Check browser console for specific error messages + +**Empty or irrelevant responses:** + +1. Run `npm run sync` to ensure embeddings are generated +2. Check that `semanticSearch.enabled: true` in siteConfig +3. Verify content exists in your posts/pages + +**Modal doesn't open:** + +1. Check that `askAI.enabled: true` in siteConfig +2. Check that `semanticSearch.enabled: true` in siteConfig +3. Both conditions must be true for the button to appear + +### Resources + +- [Semantic Search Documentation](/docs-semantic-search) - How embeddings work +- [Convex Persistent Text Streaming](https://github.com/get-convex/persistent-text-streaming) - Streaming component +- [Convex Vector Search](https://docs.convex.dev/search/vector-search) - Vector search documentation \ No newline at end of file diff --git a/public/raw/docs-configuration.md b/public/raw/docs-configuration.md index cc4c23a..72f7d2b 100644 --- a/public/raw/docs-configuration.md +++ b/public/raw/docs-configuration.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-06 +Date: 2026-01-07 --- ## Configuration diff --git a/public/raw/docs-content.md b/public/raw/docs-content.md index ed94144..1f40316 100644 --- a/public/raw/docs-content.md +++ b/public/raw/docs-content.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-06 +Date: 2026-01-07 --- ## Content diff --git a/public/raw/docs-dashboard.md b/public/raw/docs-dashboard.md index 992cfbe..418b1e5 100644 --- a/public/raw/docs-dashboard.md +++ b/public/raw/docs-dashboard.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-06 +Date: 2026-01-07 --- ## Dashboard diff --git a/public/raw/docs-deployment.md b/public/raw/docs-deployment.md index aa557dd..63bc0c9 100644 --- a/public/raw/docs-deployment.md +++ b/public/raw/docs-deployment.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-06 +Date: 2026-01-07 --- ## Deployment diff --git a/public/raw/docs-frontmatter.md b/public/raw/docs-frontmatter.md index edce2cd..d411afe 100644 --- a/public/raw/docs-frontmatter.md +++ b/public/raw/docs-frontmatter.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-06 +Date: 2026-01-07 --- ## Frontmatter diff --git a/public/raw/docs-search.md b/public/raw/docs-search.md index 57c34b3..88e71a4 100644 --- a/public/raw/docs-search.md +++ b/public/raw/docs-search.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-06 +Date: 2026-01-07 --- ## Keyword Search diff --git a/public/raw/docs-semantic-search.md b/public/raw/docs-semantic-search.md index 42914a9..fa673e4 100644 --- a/public/raw/docs-semantic-search.md +++ b/public/raw/docs-semantic-search.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-06 +Date: 2026-01-07 --- ## Semantic Search diff --git a/public/raw/documentation.md b/public/raw/documentation.md index 235df36..d5faa9e 100644 --- a/public/raw/documentation.md +++ b/public/raw/documentation.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-06 +Date: 2026-01-07 --- ## Getting started diff --git a/public/raw/footer.md b/public/raw/footer.md index 12fd6ca..03eab29 100644 --- a/public/raw/footer.md +++ b/public/raw/footer.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-06 +Date: 2026-01-07 --- Built with [Convex](https://convex.dev) for real-time sync and deployed on [Netlify](https://netlify.com). Read the [project on GitHub](https://github.com/waynesutton/markdown-site) to fork and deploy your own. View [real-time site stats](/stats). diff --git a/public/raw/home-intro.md b/public/raw/home-intro.md index b48fc2a..00ecefa 100644 --- a/public/raw/home-intro.md +++ b/public/raw/home-intro.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-06 +Date: 2026-01-07 --- An open-source publishing framework built for AI agents and developers to ship **[docs](/docs)**, or **[blogs](/blog)** or **[websites](/)**. diff --git a/public/raw/index.md b/public/raw/index.md index 99f8746..b8e7b9f 100644 --- a/public/raw/index.md +++ b/public/raw/index.md @@ -67,12 +67,13 @@ agents. --> - **[Using Images in Blog Posts](/raw/using-images-in-posts.md)** - Learn how to add header images, inline images, and Open Graph images to your markdown posts. - Date: 2025-12-14 | Reading time: 4 min read | Tags: images, tutorial, markdown, open-graph -## Pages (15) +## Pages (16) - **[Footer](/raw/footer.md)** - **[Home Intro](/raw/home-intro.md)** - **[Documentation](/raw/documentation.md)** - **[About](/raw/about.md)** - An open-source publishing framework built for AI agents and developers to ship websites, docs, or blogs. +- **[Ask AI](/raw/docs-ask-ai.md)** - **[Content](/raw/docs-content.md)** - **[Search](/raw/docs-search.md)** - **[Semantic Search](/raw/docs-semantic-search.md)** @@ -87,7 +88,7 @@ agents. --> --- -**Total Content:** 18 posts, 15 pages +**Total Content:** 18 posts, 16 pages All content is available as raw markdown files at `/raw/{slug}.md` diff --git a/public/raw/newsletter.md b/public/raw/newsletter.md index c7a7406..a86256c 100644 --- a/public/raw/newsletter.md +++ b/public/raw/newsletter.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-06 +Date: 2026-01-07 --- # Newsletter Demo Page diff --git a/public/raw/projects.md b/public/raw/projects.md index fbb9f7d..747fcf0 100644 --- a/public/raw/projects.md +++ b/public/raw/projects.md @@ -2,7 +2,7 @@ --- Type: page -Date: 2026-01-06 +Date: 2026-01-07 --- This markdown framework is open source and built to be extended. Here is what ships out of the box. diff --git a/src/components/AskAIModal.tsx b/src/components/AskAIModal.tsx new file mode 100644 index 0000000..ed212eb --- /dev/null +++ b/src/components/AskAIModal.tsx @@ -0,0 +1,423 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { useMutation, useAction } from "convex/react"; +import { useStream } from "@convex-dev/persistent-text-streaming/react"; +import { StreamId } from "@convex-dev/persistent-text-streaming"; +import { api } from "../../convex/_generated/api"; +import { Link } from "react-router-dom"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { + X, + PaperPlaneTilt, + Sparkle, + SpinnerGap, + Trash, + Copy, + Check, + Warning, +} from "@phosphor-icons/react"; +import { siteConfig } from "../config/siteConfig"; + +interface AskAIModalProps { + isOpen: boolean; + onClose: () => void; +} + +interface Message { + id: string; + role: "user" | "assistant"; + content: string; + streamId?: string; + isDriven?: boolean; +} + +// Streaming message component that uses useStream hook +function StreamingMessage({ + streamId, + isDriven, + convexUrl, + onCopy, + isCopied, +}: { + streamId: string; + isDriven: boolean; + convexUrl: string; + onCopy: (text: string) => void; + isCopied: boolean; +}) { + const { text, status } = useStream( + api.askAI.getStreamBody, + new URL(`${convexUrl}/ask-ai-stream`), + isDriven, + streamId as StreamId + ); + + const isLoading = status === "pending" || status === "streaming"; + // Show copy button when not loading and we have text (status could be "complete", "done", etc.) + const showCopyButton = !isLoading && status !== "error" && !!text; + + return ( +
    +
    + {text ? ( + { + // Check if it's an internal link + if (href?.startsWith("/")) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); + }, + }} + > + {text} + + ) : ( +
    + + Searching and thinking... +
    + )} + {isLoading && text && |} +
    + {showCopyButton && ( + + )} + {status === "error" && ( +
    Failed to load response
    + )} +
    + ); +} + +// Configuration status interface +interface ConfigStatus { + configured: boolean; + hasOpenAI: boolean; + hasAnthropic: boolean; + missingKeys: string[]; +} + +export default function AskAIModal({ isOpen, onClose }: AskAIModalProps) { + const [messages, setMessages] = useState([]); + const [inputValue, setInputValue] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); + const [selectedModel, setSelectedModel] = useState( + siteConfig.askAI?.defaultModel || "claude-sonnet-4-20250514" + ); + const [drivenIds, setDrivenIds] = useState>(new Set()); + const [copiedId, setCopiedId] = useState(null); + const [configStatus, setConfigStatus] = useState(null); + const [configChecked, setConfigChecked] = useState(false); + + const inputRef = useRef(null); + const messagesEndRef = useRef(null); + + const createSession = useMutation(api.askAI.createSession); + const checkConfiguration = useAction(api.askAI.checkConfiguration); + + // Check configuration when modal opens + useEffect(() => { + if (isOpen && !configChecked) { + checkConfiguration({}) + .then((status) => { + setConfigStatus(status); + setConfigChecked(true); + }) + .catch((err) => { + console.error("Failed to check Ask AI configuration:", err); + setConfigChecked(true); + }); + } + }, [isOpen, configChecked, checkConfiguration]); + + // Handle copy message + const handleCopy = useCallback(async (content: string, messageId: string) => { + await navigator.clipboard.writeText(content); + setCopiedId(messageId); + setTimeout(() => setCopiedId(null), 2000); + }, []); + + // Get Convex URL from environment and convert to site URL for HTTP routes + // VITE_CONVEX_URL is like https://xxx.convex.cloud + // HTTP routes are served from https://xxx.convex.site + const convexCloudUrl = import.meta.env.VITE_CONVEX_URL as string; + const convexUrl = convexCloudUrl.replace(".convex.cloud", ".convex.site"); + + // Focus input when modal opens + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus(); + } + }, [isOpen]); + + // Auto-scroll to bottom on new messages + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [messages]); + + // Handle send message + const handleSend = useCallback(async () => { + if (!inputValue.trim() || isSubmitting) return; + + const question = inputValue.trim(); + setInputValue(""); + setIsSubmitting(true); + + // Add user message + const userMessageId = `user-${Date.now()}`; + setMessages((prev) => [ + ...prev, + { id: userMessageId, role: "user", content: question }, + ]); + + try { + // Create session with question and model stored in database + // The useStream hook will trigger the HTTP action which retrieves these from DB + const { streamId } = await createSession({ question, model: selectedModel }); + + // Add assistant message with stream + const assistantMessageId = `assistant-${Date.now()}`; + setMessages((prev) => [ + ...prev, + { + id: assistantMessageId, + role: "assistant", + content: "", + streamId, + isDriven: true, + }, + ]); + + // Mark this stream as driven by this client + // The useStream hook will make the HTTP POST request automatically + setDrivenIds((prev) => new Set(prev).add(streamId)); + } catch (error) { + console.error("Failed to create session:", error); + // Add error message + setMessages((prev) => [ + ...prev, + { + id: `error-${Date.now()}`, + role: "assistant", + content: "**Error:** Failed to start conversation. Please try again.", + }, + ]); + } finally { + setIsSubmitting(false); + } + }, [inputValue, isSubmitting, createSession, selectedModel]); + + // Handle keyboard events + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + return; + } + + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + handleSend(); + } + }, + [onClose, handleSend] + ); + + // Clear conversation + const handleClear = () => { + setMessages([]); + setDrivenIds(new Set()); + }; + + // Handle backdrop click + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + if (!isOpen) return null; + + return ( +
    +
    + {/* Header */} +
    +
    + + Ask AI +
    +
    + {/* Model selector */} + {siteConfig.askAI?.models && siteConfig.askAI.models.length > 1 && ( + + )} + + +
    +
    + + {/* Configuration warning banner */} + {configChecked && configStatus && !configStatus.configured && ( +
    + +
    + Ask AI is not fully configured +

    + Missing environment variables in Convex dashboard:{" "} + {configStatus.missingKeys.join(", ")} +

    +

    + Add these keys via: npx convex env set KEY_NAME value +

    +
    +
    + )} + + {/* Messages */} +
    + {messages.length === 0 && configStatus?.configured !== false && ( +
    + +

    Ask a question about this site

    +

    + I'll search the content and provide an answer with sources +

    +
    + )} + + {messages.map((msg) => ( +
    + {msg.role === "user" ? ( +
    +
    +

    {msg.content}

    +
    +
    + ) : msg.streamId ? ( + handleCopy(text, msg.id)} + isCopied={copiedId === msg.id} + /> + ) : ( +
    +
    + + {msg.content} + +
    + {msg.content && ( + + )} +
    + )} +
    + ))} + +
    +
    + + {/* Input */} +
    +