From 92e452d33765ef87e01c9c12ddc5dd7331ed7805 Mon Sep 17 00:00:00 2001 From: afterrburn Date: Sun, 22 Jun 2025 10:24:07 -0600 Subject: [PATCH 01/33] Remove unnecessary validate files system --- .github/workflows/sync-docs-full.yml | 3 +- .github/workflows/sync-docs.yml | 3 +- bin/validate-files.sh | 53 ---------------------------- 3 files changed, 2 insertions(+), 57 deletions(-) delete mode 100755 bin/validate-files.sh diff --git a/.github/workflows/sync-docs-full.yml b/.github/workflows/sync-docs-full.yml index 59f719d8..30c0f083 100644 --- a/.github/workflows/sync-docs-full.yml +++ b/.github/workflows/sync-docs-full.yml @@ -12,8 +12,7 @@ jobs: - name: Collect and validate files run: | set -euo pipefail - ./bin/collect-all-files.sh | \ - ./bin/validate-files.sh > all-files.txt + ./bin/collect-all-files.sh > all-files.txt echo "Files to sync:" cat all-files.txt diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index 9e28e839..110fff02 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -19,8 +19,7 @@ jobs: run: | set -euo pipefail git fetch origin ${{ github.event.before }} - ./bin/collect-changed-files.sh "${{ github.event.before }}" "${{ github.sha }}" | \ - ./bin/validate-files.sh > changed-files.txt + ./bin/collect-changed-files.sh "${{ github.event.before }}" "${{ github.sha }}" > changed-files.txt echo "Files to sync:" cat changed-files.txt diff --git a/bin/validate-files.sh b/bin/validate-files.sh deleted file mode 100755 index 6752d38d..00000000 --- a/bin/validate-files.sh +++ /dev/null @@ -1,53 +0,0 @@ -#!/bin/bash -set -euo pipefail - -# validate-files.sh -# Reads file paths from stdin, validates they exist and are safe -# Outputs only valid file paths - -echo "Validating file paths" >&2 - -valid_count=0 -invalid_count=0 - -# Read all input into an array first -mapfile -t files - -for file in "${files[@]}"; do - # Skip empty lines - if [ -z "$file" ]; then - continue - fi - - # Handle REMOVED: prefix - if [[ "$file" == REMOVED:* ]]; then - echo "$file" - ((valid_count++)) - continue - fi - - # Security check: prevent path traversal - if [[ "$file" == *".."* ]] || [[ "$file" == "/"* ]]; then - echo "Warning: Unsafe path detected, skipping: $file" >&2 - ((invalid_count++)) - continue - fi - - # Check if file exists - if [ -f "content/$file" ]; then - echo "$file" - echo " ✓ $file" >&2 - ((valid_count++)) - else - echo "Warning: File not found, skipping: $file" >&2 - ((invalid_count++)) - fi -done - -echo "Validation complete: $valid_count valid, $invalid_count invalid" >&2 - -# Exit with error if no valid files -if [ "$valid_count" -eq 0 ]; then - echo "Error: No valid files found" >&2 - exit 1 -fi \ No newline at end of file From dc2d58de7d989b5e38e0a42c16ee9310c916a62f Mon Sep 17 00:00:00 2001 From: afterrburn Date: Sun, 22 Jun 2025 11:27:16 -0600 Subject: [PATCH 02/33] move config to agent-dcs dir --- agent-docs/config.ts | 2 ++ agent-docs/src/agents/doc-processing/docs-orchestrator.ts | 2 +- agent-docs/src/agents/doc-qa/index.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) create mode 100644 agent-docs/config.ts diff --git a/agent-docs/config.ts b/agent-docs/config.ts new file mode 100644 index 00000000..3088c1f8 --- /dev/null +++ b/agent-docs/config.ts @@ -0,0 +1,2 @@ +export const VECTOR_STORE_NAME = process.env.VECTOR_STORE_NAME || 'docs'; +export const vectorSearchNumber = 20; \ No newline at end of file diff --git a/agent-docs/src/agents/doc-processing/docs-orchestrator.ts b/agent-docs/src/agents/doc-processing/docs-orchestrator.ts index 3cdefbb7..c78d8682 100644 --- a/agent-docs/src/agents/doc-processing/docs-orchestrator.ts +++ b/agent-docs/src/agents/doc-processing/docs-orchestrator.ts @@ -1,6 +1,6 @@ import type { AgentContext } from '@agentuity/sdk'; import { processDoc } from './docs-processor'; -import { VECTOR_STORE_NAME } from '../../../../config'; +import { VECTOR_STORE_NAME } from '../../../config'; import type { SyncPayload, SyncStats } from './types'; /** diff --git a/agent-docs/src/agents/doc-qa/index.ts b/agent-docs/src/agents/doc-qa/index.ts index 54351f89..af936596 100644 --- a/agent-docs/src/agents/doc-qa/index.ts +++ b/agent-docs/src/agents/doc-qa/index.ts @@ -3,7 +3,7 @@ import { streamText } from 'ai'; import { openai } from '@ai-sdk/openai'; import type { ChunkMetadata } from '../doc-processing/types'; -import { VECTOR_STORE_NAME, vectorSearchNumber } from '../../../../config'; +import { VECTOR_STORE_NAME, vectorSearchNumber } from '../../../config'; import type { RelevantDoc } from './types'; export default async function Agent( From e033b8f9e15d0d4443866d9fc55cbc0071856a27 Mon Sep 17 00:00:00 2001 From: afterrburn Date: Sun, 22 Jun 2025 11:27:31 -0600 Subject: [PATCH 03/33] remove config from root dir --- config.ts | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 config.ts diff --git a/config.ts b/config.ts deleted file mode 100644 index 3088c1f8..00000000 --- a/config.ts +++ /dev/null @@ -1,2 +0,0 @@ -export const VECTOR_STORE_NAME = process.env.VECTOR_STORE_NAME || 'docs'; -export const vectorSearchNumber = 20; \ No newline at end of file From 7fe0a04c8d4f166a8607d971b7e82a768931fb69 Mon Sep 17 00:00:00 2001 From: afterrburn Date: Mon, 23 Jun 2025 07:58:38 -0600 Subject: [PATCH 04/33] Add prompt classifier --- agent-docs/src/agents/doc-qa/index.ts | 6 +-- agent-docs/src/agents/doc-qa/prompt.ts | 66 ++++++++++++++++++++++++++ agent-docs/src/agents/doc-qa/types.ts | 14 +++++- 3 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 agent-docs/src/agents/doc-qa/prompt.ts diff --git a/agent-docs/src/agents/doc-qa/index.ts b/agent-docs/src/agents/doc-qa/index.ts index af936596..5cf4cdcc 100644 --- a/agent-docs/src/agents/doc-qa/index.ts +++ b/agent-docs/src/agents/doc-qa/index.ts @@ -14,6 +14,8 @@ export default async function Agent( const prompt = await req.data.text(); const relevantDocs = await retrieveRelevantDocs(ctx, prompt); + + const systemPrompt = ` You are a developer documentation assistant. Your job is to answer user questions about the Agentuity platform as effectively and concisely as possible, adapting your style to the user's request. If the user asks for a direct answer, provide it without extra explanation. If they want an explanation, provide a clear and concise one. Use only the provided relevant documents to answer. @@ -55,8 +57,6 @@ async function retrieveRelevantDocs(ctx: AgentContext, prompt: string): Promise< limit: vectorSearchNumber } try { - - const vectors = await ctx.vector.search(VECTOR_STORE_NAME, dbQuery); const uniquePaths = new Set(); @@ -80,7 +80,7 @@ async function retrieveRelevantDocs(ctx: AgentContext, prompt: string): Promise< content: await retrieveDocumentBasedOnPath(ctx, path) })) ); - + return docs; } catch (err) { ctx.logger.error('Error retrieving relevant docs: %o', err); diff --git a/agent-docs/src/agents/doc-qa/prompt.ts b/agent-docs/src/agents/doc-qa/prompt.ts new file mode 100644 index 00000000..5b5f7f39 --- /dev/null +++ b/agent-docs/src/agents/doc-qa/prompt.ts @@ -0,0 +1,66 @@ +import type { AgentContext } from '@agentuity/sdk'; +import { generateObject } from 'ai'; +import { openai } from '@ai-sdk/openai'; +import { z } from 'zod'; +import { PromptType } from './types'; + +const PromptClassificationSchema = z.object({ + type: z.enum(['Normal', 'Thinking']), + confidence: z.number().min(0).max(1), + reasoning: z.string() +}); + +/** + * Determines the prompt type based on the input string using LLM classification. + * Uses specific, measurable criteria to decide between Normal and Agentic RAG. + * @param ctx - Agent Context for logging and LLM access + * @param input - The input string to analyze + * @returns {Promise} - The determined PromptType + */ +export async function getPromptType(ctx: AgentContext, input: string): Promise { + const systemPrompt = ` +You are a query classifier that determines whether a user question requires simple retrieval (Normal) or complex reasoning (Thinking). + +Use these SPECIFIC criteria for classification: + +**THINKING (Agentic RAG) indicators:** +- Multi-step reasoning required (e.g., "compare and contrast", "analyze pros/cons") +- Synthesis across multiple concepts (e.g., "how does X relate to Y") +- Scenario analysis (e.g., "what would happen if...", "when should I use...") +- Troubleshooting/debugging questions requiring logical deduction +- Questions with explicit reasoning requests ("explain why", "walk me through") +- Comparative analysis ("which is better for...", "what are the trade-offs") + +**NORMAL (Simple RAG) indicators:** +- Direct factual lookups (e.g., "what is...", "how do I install...") +- Simple how-to questions with clear answers +- API reference queries +- Configuration/syntax questions +- Single-concept definitions + +Respond with a JSON object containing: +- type: "Normal" or "Thinking" +- confidence: 0.0-1.0 (how certain you are) +- reasoning: brief explanation of your classification + +Be conservative - when in doubt, default to "Normal" for better performance.`; + + try { + const result = await generateObject({ + model: openai('gpt-4o-mini'), // Use faster model for classification + system: systemPrompt, + prompt: `Classify this user query: "${input}"`, + schema: PromptClassificationSchema, + maxTokens: 200, + }); + + ctx.logger.info('Prompt classified as %s (confidence: %f): %s', + result.object.type, result.object.confidence, result.object.reasoning); + + return result.object.type === 'Thinking' ? PromptType.Thinking : PromptType.Normal; + + } catch (error) { + ctx.logger.error('Error classifying prompt, defaulting to Normal: %o', error); + return PromptType.Normal; // Fail-safe default + } +} diff --git a/agent-docs/src/agents/doc-qa/types.ts b/agent-docs/src/agents/doc-qa/types.ts index 9fa227ff..d198defd 100644 --- a/agent-docs/src/agents/doc-qa/types.ts +++ b/agent-docs/src/agents/doc-qa/types.ts @@ -1,5 +1,15 @@ export interface RelevantDoc { path: string; content: string; - } - \ No newline at end of file +} + +export enum PromptType { + Normal = 'Normal', + Thinking = 'Thinking' +} + +export interface PromptClassification { + type: PromptType; + confidence: number; + reasoning: string; +} \ No newline at end of file From 73ca415d25ec47e24288a8623b0a0b0711c04142 Mon Sep 17 00:00:00 2001 From: afterrburn Date: Wed, 25 Jun 2025 07:52:34 -0600 Subject: [PATCH 05/33] enhance RAG prompt --- agent-docs/src/agents/doc-qa/index.ts | 119 ++-------------------- agent-docs/src/agents/doc-qa/prompt.ts | 14 +-- agent-docs/src/agents/doc-qa/rag.ts | 68 +++++++++++++ agent-docs/src/agents/doc-qa/retriever.ts | 75 ++++++++++++++ agent-docs/src/agents/doc-qa/types.ts | 40 +++++--- 5 files changed, 178 insertions(+), 138 deletions(-) create mode 100644 agent-docs/src/agents/doc-qa/rag.ts create mode 100644 agent-docs/src/agents/doc-qa/retriever.ts diff --git a/agent-docs/src/agents/doc-qa/index.ts b/agent-docs/src/agents/doc-qa/index.ts index 5cf4cdcc..a590b1ad 100644 --- a/agent-docs/src/agents/doc-qa/index.ts +++ b/agent-docs/src/agents/doc-qa/index.ts @@ -1,10 +1,6 @@ import type { AgentContext, AgentRequest, AgentResponse } from '@agentuity/sdk'; -import { streamText } from 'ai'; -import { openai } from '@ai-sdk/openai'; - -import type { ChunkMetadata } from '../doc-processing/types'; -import { VECTOR_STORE_NAME, vectorSearchNumber } from '../../../config'; -import type { RelevantDoc } from './types'; +import { getPromptType } from './prompt'; +import answerQuestion from './rag'; export default async function Agent( req: AgentRequest, @@ -12,111 +8,8 @@ export default async function Agent( ctx: AgentContext ) { const prompt = await req.data.text(); - const relevantDocs = await retrieveRelevantDocs(ctx, prompt); - - - - const systemPrompt = ` -You are a developer documentation assistant. Your job is to answer user questions about the Agentuity platform as effectively and concisely as possible, adapting your style to the user's request. If the user asks for a direct answer, provide it without extra explanation. If they want an explanation, provide a clear and concise one. Use only the provided relevant documents to answer. - -You must not make up answers if the provided documents don't exist. You can be direct to the user that the documentations -don't seem to include what they are looking for. Lying to the user is prohibited as it only slows them down. Feel free to -suggest follow up questions if what they're asking for don't seem to have an answer in the document. You can provide them -a few related things that the documents contain that may interest them. - -For every answer, return a valid JSON object with: - 1. "answer": your answer to the user's question. - 2. "documents": an array of strings, representing the path of the documents you used to answer. - -If you use information from a document, include it in the "documents" array. If you do not use any documents, return an empty array for "documents". - -User question: -\`\`\` -${prompt} -\`\`\` - -Relevant documents: -${JSON.stringify(relevantDocs, null, 2)} - -Respond ONLY with a valid JSON object as described above. In your answer, you should format code blocks properly in Markdown style if the user needs answer in code block. -`.trim(); - - const llmResponse = await streamText({ - model: openai('gpt-4o'), - system: systemPrompt, - prompt: prompt, - maxTokens: 2048, - }); - - return resp.stream(llmResponse.textStream); -} - -async function retrieveRelevantDocs(ctx: AgentContext, prompt: string): Promise { - const dbQuery = { - query: prompt, - limit: vectorSearchNumber - } - try { - const vectors = await ctx.vector.search(VECTOR_STORE_NAME, dbQuery); - - const uniquePaths = new Set(); - - vectors.forEach(vec => { - if (!vec.metadata) { - ctx.logger.warn('Vector missing metadata'); - return; - } - const path = typeof vec.metadata.path === 'string' ? vec.metadata.path : undefined; - if (!path) { - ctx.logger.warn('Vector metadata path is not a string'); - return; - } - uniquePaths.add(path); - }); - - const docs = await Promise.all( - Array.from(uniquePaths).map(async path => ({ - path, - content: await retrieveDocumentBasedOnPath(ctx, path) - })) - ); - - return docs; - } catch (err) { - ctx.logger.error('Error retrieving relevant docs: %o', err); - return []; - } -} - -async function retrieveDocumentBasedOnPath(ctx: AgentContext, path: string): Promise { - const dbQuery = { - query: ' ', - limit: 10000, - metadata: { - path: path - } - } - try { - const vectors = await ctx.vector.search(VECTOR_STORE_NAME, dbQuery); - - // Sort vectors by chunk index and concatenate text - const sortedVectors = vectors - .map(vec => { - const metadata = vec.metadata as ChunkMetadata; - return { - metadata, - index: metadata.chunkIndex - }; - }) - .sort((a, b) => a.index - b.index); - - const fullText = sortedVectors - .map(vec => vec.metadata.text) - .join('\n\n'); - - return fullText; - } catch (err) { - ctx.logger.error('Error retrieving document by path %s: %o', path, err); - return ''; - } + const promptType = await getPromptType(ctx, prompt); + ctx.logger.info(`Receive user prompt with type: ${promptType}`); + const answer = await answerQuestion(ctx, prompt); + return resp.json(answer); } \ No newline at end of file diff --git a/agent-docs/src/agents/doc-qa/prompt.ts b/agent-docs/src/agents/doc-qa/prompt.ts index 5b5f7f39..178fea60 100644 --- a/agent-docs/src/agents/doc-qa/prompt.ts +++ b/agent-docs/src/agents/doc-qa/prompt.ts @@ -1,14 +1,8 @@ import type { AgentContext } from '@agentuity/sdk'; import { generateObject } from 'ai'; import { openai } from '@ai-sdk/openai'; -import { z } from 'zod'; -import { PromptType } from './types'; - -const PromptClassificationSchema = z.object({ - type: z.enum(['Normal', 'Thinking']), - confidence: z.number().min(0).max(1), - reasoning: z.string() -}); +import type { PromptType } from './types'; +import { PromptClassificationSchema } from './types'; /** * Determines the prompt type based on the input string using LLM classification. @@ -57,10 +51,10 @@ Be conservative - when in doubt, default to "Normal" for better performance.`; ctx.logger.info('Prompt classified as %s (confidence: %f): %s', result.object.type, result.object.confidence, result.object.reasoning); - return result.object.type === 'Thinking' ? PromptType.Thinking : PromptType.Normal; + return result.object.type as PromptType; } catch (error) { ctx.logger.error('Error classifying prompt, defaulting to Normal: %o', error); - return PromptType.Normal; // Fail-safe default + return 'Normal' as PromptType; } } diff --git a/agent-docs/src/agents/doc-qa/rag.ts b/agent-docs/src/agents/doc-qa/rag.ts new file mode 100644 index 00000000..a8f33109 --- /dev/null +++ b/agent-docs/src/agents/doc-qa/rag.ts @@ -0,0 +1,68 @@ +import type { AgentContext } from '@agentuity/sdk'; +import { generateObject } from 'ai'; +import { openai } from '@ai-sdk/openai'; + +import { retrieveRelevantDocs } from './retriever'; +import { AnswerSchema } from './types'; +import type { Answer } from './types'; + +export default async function answerQuestion(ctx: AgentContext, prompt: string) { + const relevantDocs = await retrieveRelevantDocs(ctx, prompt); + + const systemPrompt = ` +You are Agentuity's developer-documentation assistant. + +=== RULES === +1. Use ONLY the content inside tags to craft your reply. If the required information is missing, state that the docs do not cover it. +2. Never fabricate or guess undocumented details. +3. Ambiguity handling: + • When contains more than one distinct workflow or context that could satisfy the question, do **not** choose for the user. + • Briefly (≤ 2 sentences each) summarise each plausible interpretation and ask **one** clarifying question so the user can pick a path. + • Provide a definitive answer only after the ambiguity is resolved. +4. Answer style: + • If the question can be answered unambiguously from a single workflow, give a short, direct answer. + • Add an explanation only when the user explicitly asks for one. + • Show any code or CLI snippets in fenced Markdown blocks. +5. You may suggest concise follow-up questions or related topics that are present in . +6. Keep a neutral, factual tone. + +=== OUTPUT FORMAT === +Return **valid JSON only** matching this TypeScript type: + +type LlmAnswer = { + answer: string; // The reply or the clarifying question + documents: string[]; // Paths of documents actually cited +} + +If you cited no documents, return an empty array. Do NOT wrap the JSON in Markdown or add any extra keys. + + +${prompt} + + + +${JSON.stringify(relevantDocs, null, 2)} + +`; + + try { + const result = await generateObject({ + model: openai('gpt-4o'), + system: systemPrompt, + prompt: prompt, + schema: AnswerSchema, + maxTokens: 2048, + }); + return result.object; + } catch (error) { + ctx.logger.error('Error generating answer: %o', error); + + // Fallback response + const fallbackAnswer: Answer = { + answer: "I apologize, but I encountered an error while processing your question. Please try again or rephrase your question.", + documents: [] + }; + + return fallbackAnswer; + } +} \ No newline at end of file diff --git a/agent-docs/src/agents/doc-qa/retriever.ts b/agent-docs/src/agents/doc-qa/retriever.ts new file mode 100644 index 00000000..d393e3b8 --- /dev/null +++ b/agent-docs/src/agents/doc-qa/retriever.ts @@ -0,0 +1,75 @@ +import type { AgentContext } from '@agentuity/sdk'; + +import type { ChunkMetadata } from '../doc-processing/types'; +import { VECTOR_STORE_NAME, vectorSearchNumber } from '../../../config'; +import type { RelevantDoc } from './types'; + +export async function retrieveRelevantDocs(ctx: AgentContext, prompt: string): Promise { + const dbQuery = { + query: prompt, + limit: vectorSearchNumber + } + try { + const vectors = await ctx.vector.search(VECTOR_STORE_NAME, dbQuery); + + const uniquePaths = new Set(); + + vectors.forEach(vec => { + if (!vec.metadata) { + ctx.logger.warn('Vector missing metadata'); + return; + } + const path = typeof vec.metadata.path === 'string' ? vec.metadata.path : undefined; + if (!path) { + ctx.logger.warn('Vector metadata path is not a string'); + return; + } + uniquePaths.add(path); + }); + + const docs = await Promise.all( + Array.from(uniquePaths).map(async path => ({ + path, + content: await retrieveDocumentBasedOnPath(ctx, path) + })) + ); + + return docs; + } catch (err) { + ctx.logger.error('Error retrieving relevant docs: %o', err); + return []; + } + } + + async function retrieveDocumentBasedOnPath(ctx: AgentContext, path: string): Promise { + const dbQuery = { + query: ' ', + limit: 10000, + metadata: { + path: path + } + } + try { + const vectors = await ctx.vector.search(VECTOR_STORE_NAME, dbQuery); + + // Sort vectors by chunk index and concatenate text + const sortedVectors = vectors + .map(vec => { + const metadata = vec.metadata as ChunkMetadata; + return { + metadata, + index: metadata.chunkIndex + }; + }) + .sort((a, b) => a.index - b.index); + + const fullText = sortedVectors + .map(vec => vec.metadata.text) + .join('\n\n'); + + return fullText; + } catch (err) { + ctx.logger.error('Error retrieving document by path %s: %o', path, err); + return ''; + } + } \ No newline at end of file diff --git a/agent-docs/src/agents/doc-qa/types.ts b/agent-docs/src/agents/doc-qa/types.ts index d198defd..e1d989d4 100644 --- a/agent-docs/src/agents/doc-qa/types.ts +++ b/agent-docs/src/agents/doc-qa/types.ts @@ -1,15 +1,25 @@ -export interface RelevantDoc { - path: string; - content: string; -} - -export enum PromptType { - Normal = 'Normal', - Thinking = 'Thinking' -} - -export interface PromptClassification { - type: PromptType; - confidence: number; - reasoning: string; -} \ No newline at end of file +import { z } from 'zod'; + +export const RelevantDocSchema = z.object({ + path: z.string(), + content: z.string() +}); + +export const AnswerSchema = z.object({ + answer: z.string(), + documents: z.array(z.string()) +}); + +export const PromptTypeSchema = z.enum(['Normal', 'Thinking']); + +export const PromptClassificationSchema = z.object({ + type: PromptTypeSchema, + confidence: z.number().min(0).max(1), + reasoning: z.string() +}); + +// Generated TypeScript types +export type RelevantDoc = z.infer; +export type Answer = z.infer; +export type PromptType = z.infer; +export type PromptClassification = z.infer; \ No newline at end of file From 077c70cbb376e571d75ee4e000018aa157bb5dc8 Mon Sep 17 00:00:00 2001 From: afterrburn Date: Thu, 26 Jun 2025 08:23:07 -0600 Subject: [PATCH 06/33] Add simple search dialog --- app/api/search/route.ts | 91 +++++++++++++++- app/layout.tsx | 8 +- components/CustomSearchDialog.tsx | 169 ++++++++++++++++++++++++++++++ 3 files changed, 266 insertions(+), 2 deletions(-) create mode 100644 components/CustomSearchDialog.tsx diff --git a/app/api/search/route.ts b/app/api/search/route.ts index df889626..c78e1d30 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -1,4 +1,93 @@ import { source } from '@/lib/source'; import { createFromSource } from 'fumadocs-core/search/server'; +import { NextRequest } from 'next/server'; -export const { GET } = createFromSource(source); +// Create the default search handler +const { GET: defaultSearchHandler } = createFromSource(source); + +// Helper function to convert document path to URL +function documentPathToUrl(docPath: string): string { + // Remove .mdx extension and convert to URL format + // "CLI/agent.mdx" -> "/CLI/agent" + return '/' + docPath.replace(/\.mdx?$/, ''); +} + +// Helper function to get document title from source +function getDocumentTitle(docPath: string): string { + try { + // Convert path to URL format for source lookup + const urlPath = documentPathToUrl(docPath).substring(1).split('/'); // Remove leading slash and split + const page = source.getPage(urlPath); + return page?.data.title || docPath.replace(/\.mdx?$/, '').replace(/\//g, ' > '); + } catch { + // Fallback to formatted path if lookup fails + return docPath.replace(/\.mdx?$/, '').replace(/\//g, ' > '); + } +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + console.log(request.url); + const query = searchParams.get('query'); + console.log("query: " + query); + + // If no query, return empty results + if (!query || query.trim().length === 0) { + return Response.json([]); + } + + try { + // Call your AI agent API + const response = await fetch('https://agentuity.ai/api/9ccc5545e93644bd9d7954e632a55a61', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer wht_843942568308430586ec1bc460245a8f', + }, + body: JSON.stringify({ message: query }), + }); + + if (!response.ok) { + throw new Error(`Agent API error: ${response.status}`); + } + + const data = await response.json(); + + const results = []; + + // 1. Add the AI answer as the first result (most prominent) + if (data.answer) { + results.push({ + id: `ai-answer-${Date.now()}`, + url: '#ai-answer', // Special marker for AI answers + title: `🤖 AI Answer`, + content: data.answer, + type: 'ai-answer' // Custom type for styling + }); + } + + // 2. Add related documents as clickable results + if (data.documents && Array.isArray(data.documents)) { + data.documents.forEach((docPath: string, index: number) => { + const url = documentPathToUrl(docPath); + const title = getDocumentTitle(docPath); + + results.push({ + id: `doc-${Date.now()}-${index}`, + url: url, + title: `📄 ${title}`, + content: `Related documentation: ${title}`, + type: 'document' // Custom type for styling + }); + }); + } + + return Response.json(results); + + } catch (error) { + console.error('Error calling AI agent:', error); + + // Fallback to original Fumadocs search behavior if AI fails + return defaultSearchHandler(request); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 3fea5030..b77f4892 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,6 +2,7 @@ import { RootProvider } from "fumadocs-ui/provider"; import { GeistSans } from "geist/font/sans"; import type { ReactNode } from "react"; import type { Metadata } from 'next'; +import CustomSearchDialog from "@/components/CustomSearchDialog"; import "./global.css"; export const metadata: Metadata = { @@ -82,7 +83,12 @@ export default function Layout({ children }: { children: ReactNode }) { return ( - + {children} diff --git a/components/CustomSearchDialog.tsx b/components/CustomSearchDialog.tsx new file mode 100644 index 00000000..e89fe775 --- /dev/null +++ b/components/CustomSearchDialog.tsx @@ -0,0 +1,169 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import type { SharedProps } from 'fumadocs-ui/components/dialog/search'; + +interface SearchResult { + id: string; + title: string; + content: string; + url?: string; + type?: 'ai-answer' | 'document' | 'default'; +} + +export default function CustomSearchDialog(props: SharedProps) { + const { open, onOpenChange } = props; + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const [hasSearched, setHasSearched] = useState(false); + + // Perform search function + const performSearch = useCallback(async (searchQuery: string) => { + if (!searchQuery.trim()) { + setResults([]); + setHasSearched(false); + return; + } + + setLoading(true); + setHasSearched(true); + const searchParams = new URLSearchParams({ query: searchQuery }); + + try { + const response = await fetch(`/api/search?${searchParams}`); + const data: SearchResult[] = await response.json(); + setResults(data || []); + } catch (error) { + console.error('Search error:', error); + setResults([]); + } finally { + setLoading(false); + } + }, []); + + const handleInputChange = useCallback((e: React.ChangeEvent) => { + setQuery(e.target.value); + // Reset search state when user starts typing again + if (hasSearched) { + setHasSearched(false); + setResults([]); + } + }, [hasSearched]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + e.preventDefault(); + performSearch(query); + } + if (e.key === 'Escape') { + onOpenChange(false); + } + }, [query, performSearch, onOpenChange]); + + const handleResultClick = useCallback((result: SearchResult) => { + if (result.type === 'ai-answer') { + // For AI answers, keep dialog open for now (could copy to clipboard or show full answer) + return; + } + + if (result.url && result.url !== '#ai-answer') { + window.location.href = result.url; + onOpenChange(false); + } + }, [onOpenChange]); + + const getResultStyles = (result: SearchResult) => { + const baseStyles = "p-3 rounded border-b border-gray-200 dark:border-gray-600 last:border-b-0"; + + if (result.type === 'ai-answer') { + return `${baseStyles} bg-blue-50 dark:bg-blue-900/20 border-l-4 border-l-blue-500`; + } + + if (result.type === 'document') { + return `${baseStyles} hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer`; + } + + return `${baseStyles} hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer`; + }; + + if (!open) return null; + + return ( +
onOpenChange(false)}> +
e.stopPropagation()} + > + + + {loading && ( +
+
+ Searching... +
+ )} + + {!loading && hasSearched && ( +
+ {results.length > 0 ? ( +
+ {results.map((result) => ( +
handleResultClick(result)} + > +
+ {result.title} +
+
+ {result.content} +
+ {result.type === 'document' && ( +
+ Click to open documentation → +
+ )} + {result.type === 'ai-answer' && ( +
+ 💡 AI-generated answer based on documentation +
+ )} +
+ ))} +
+ ) : ( +
+
🔍
+ No results found +
+ )} +
+ )} + +
+ {!hasSearched && query ? 'Press Enter to search' : + hasSearched ? `${results.length} result(s) for "${query}"` : + 'Type your search query and press Enter'} +
+ +
+ Press Enter to search • Esc to close +
+
+
+ ); +} \ No newline at end of file From b3f39c95fc4da987bc2fbf83e7501b1e59d16bb7 Mon Sep 17 00:00:00 2001 From: afterrburn Date: Fri, 27 Jun 2025 07:21:47 -0600 Subject: [PATCH 07/33] remove prompt type parsing --- agent-docs/src/agents/doc-qa/index.ts | 20 ++++++++-- agent-docs/src/agents/doc-qa/rag.ts | 55 +++++++++++++++++++++++++-- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/agent-docs/src/agents/doc-qa/index.ts b/agent-docs/src/agents/doc-qa/index.ts index a590b1ad..85d5665f 100644 --- a/agent-docs/src/agents/doc-qa/index.ts +++ b/agent-docs/src/agents/doc-qa/index.ts @@ -1,5 +1,4 @@ import type { AgentContext, AgentRequest, AgentResponse } from '@agentuity/sdk'; -import { getPromptType } from './prompt'; import answerQuestion from './rag'; export default async function Agent( @@ -7,9 +6,22 @@ export default async function Agent( resp: AgentResponse, ctx: AgentContext ) { - const prompt = await req.data.text(); - const promptType = await getPromptType(ctx, prompt); - ctx.logger.info(`Receive user prompt with type: ${promptType}`); + let jsonRequest: any = null; + let prompt: string; + + try { + jsonRequest = await req.data.json(); + prompt = typeof jsonRequest === 'object' && jsonRequest !== null && 'message' in jsonRequest + ? jsonRequest.message + : JSON.stringify(jsonRequest); + } catch { + prompt = await req.data.text(); + } + + if (prompt === undefined || prompt === null) { + return resp.text("How can I help you?"); + } + const answer = await answerQuestion(ctx, prompt); return resp.json(answer); } \ No newline at end of file diff --git a/agent-docs/src/agents/doc-qa/rag.ts b/agent-docs/src/agents/doc-qa/rag.ts index a8f33109..0157fa10 100644 --- a/agent-docs/src/agents/doc-qa/rag.ts +++ b/agent-docs/src/agents/doc-qa/rag.ts @@ -22,7 +22,12 @@ You are Agentuity's developer-documentation assistant. 4. Answer style: • If the question can be answered unambiguously from a single workflow, give a short, direct answer. • Add an explanation only when the user explicitly asks for one. - • Show any code or CLI snippets in fenced Markdown blocks. + • Format your response in **MDX (Markdown Extended)** format with proper syntax highlighting for code blocks. + • Use appropriate headings (##, ###) to structure longer responses. + • Wrap CLI commands in \`\`\`bash code blocks for proper syntax highlighting. + • Wrap code snippets in appropriate language blocks (e.g., \`\`\`typescript, \`\`\`json, \`\`\`javascript). + • Use **bold** for important terms and *italic* for emphasis when appropriate. + • Use > blockquotes for important notes or warnings. 5. You may suggest concise follow-up questions or related topics that are present in . 6. Keep a neutral, factual tone. @@ -30,12 +35,45 @@ You are Agentuity's developer-documentation assistant. Return **valid JSON only** matching this TypeScript type: type LlmAnswer = { - answer: string; // The reply or the clarifying question + answer: string; // The reply in MDX format or the clarifying question documents: string[]; // Paths of documents actually cited } +The "answer" field should contain properly formatted MDX content that will render beautifully in a documentation site. + If you cited no documents, return an empty array. Do NOT wrap the JSON in Markdown or add any extra keys. +=== MDX FORMATTING EXAMPLES === +For CLI commands: +\`\`\`bash +agentuity agent create my-agent "My agent description" bearer +\`\`\` + +For code examples: +\`\`\`typescript +import type { AgentRequest, AgentResponse, AgentContext } from "@agentuity/sdk"; + +export default async function Agent(req: AgentRequest, resp: AgentResponse, ctx: AgentContext) { + return resp.json({hello: 'world'}); +} +\`\`\` + +For structured responses: +## Creating a New Agent + +To create a new agent, use the CLI command: + +\`\`\`bash +agentuity agent create [name] [description] [auth_type] +\`\`\` + +**Parameters:** +- \`name\`: The agent name +- \`description\`: Agent description +- \`auth_type\`: Either \`bearer\` or \`none\` + +> **Note**: This command will create the agent in the Agentuity Cloud and set up local files. + ${prompt} @@ -57,9 +95,18 @@ ${JSON.stringify(relevantDocs, null, 2)} } catch (error) { ctx.logger.error('Error generating answer: %o', error); - // Fallback response + // Fallback response with MDX formatting const fallbackAnswer: Answer = { - answer: "I apologize, but I encountered an error while processing your question. Please try again or rephrase your question.", + answer: `## Error + +I apologize, but I encountered an error while processing your question. + +**Please try:** +- Rephrasing your question +- Being more specific about what you're looking for +- Checking if your question relates to Agentuity's documented features + +> If the problem persists, please contact support.`, documents: [] }; From 6a34d9878b063a905da575a28fb9f383a7b0559d Mon Sep 17 00:00:00 2001 From: afterrburn Date: Fri, 27 Jun 2025 08:11:59 -0600 Subject: [PATCH 08/33] properly rendering Markdown and retrieved documents --- app/api/search/route.ts | 148 ++++++++++++++++++++-------- components/CustomSearchDialog.tsx | 156 +++++++++++++----------------- package-lock.json | 56 ++++++++++- package.json | 3 + 4 files changed, 233 insertions(+), 130 deletions(-) diff --git a/app/api/search/route.ts b/app/api/search/route.ts index c78e1d30..e398d730 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -5,89 +5,161 @@ import { NextRequest } from 'next/server'; // Create the default search handler const { GET: defaultSearchHandler } = createFromSource(source); -// Helper function to convert document path to URL function documentPathToUrl(docPath: string): string { - // Remove .mdx extension and convert to URL format - // "CLI/agent.mdx" -> "/CLI/agent" return '/' + docPath.replace(/\.mdx?$/, ''); } -// Helper function to get document title from source -function getDocumentTitle(docPath: string): string { +// Helper function to get document title and description from source +function getDocumentMetadata(docPath: string): { title: string; description?: string } { try { - // Convert path to URL format for source lookup - const urlPath = documentPathToUrl(docPath).substring(1).split('/'); // Remove leading slash and split + const urlPath = documentPathToUrl(docPath).substring(1).split('/'); const page = source.getPage(urlPath); - return page?.data.title || docPath.replace(/\.mdx?$/, '').replace(/\//g, ' > '); + + if (page?.data) { + return { + title: page.data.title || formatPathAsTitle(docPath), + description: page.data.description + }; + } + } catch (error) { + console.warn(`Failed to get metadata for ${docPath}:`, error); + } + + return { title: formatPathAsTitle(docPath) }; +} + +function formatPathAsTitle(docPath: string): string { + return docPath + .replace(/\.mdx?$/, '') + .split('/') + .map(segment => segment.charAt(0).toUpperCase() + segment.slice(1)) + .join(' > '); +} + +function getDocumentSnippet(docPath: string, maxLength: number = 150): string { + try { + const urlPath = documentPathToUrl(docPath).substring(1).split('/'); + const page = source.getPage(urlPath); + + if (page?.data.description) { + return page.data.description.length > maxLength + ? page.data.description.substring(0, maxLength) + '...' + : page.data.description; + } + + // Fallback description based on path + const pathParts = docPath.replace(/\.mdx?$/, '').split('/'); + const section = pathParts[0]; + const topic = pathParts[pathParts.length - 1]; + + return `Learn about ${topic} in the ${section} section of our documentation.`; } catch { - // Fallback to formatted path if lookup fails - return docPath.replace(/\.mdx?$/, '').replace(/\//g, ' > '); + return `Documentation for ${formatPathAsTitle(docPath)}`; } } export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); - console.log(request.url); const query = searchParams.get('query'); - console.log("query: " + query); - + // If no query, return empty results if (!query || query.trim().length === 0) { return Response.json([]); } try { - // Call your AI agent API - const response = await fetch('https://agentuity.ai/api/9ccc5545e93644bd9d7954e632a55a61', { + const response = await fetch('http://127.0.0.1:3500/agent_9ccc5545e93644bd9d7954e632a55a61', { method: 'POST', headers: { - 'Content-Type': 'application/json', - 'Authorization': 'Bearer wht_843942568308430586ec1bc460245a8f', + 'Content-Type': 'application/json' }, body: JSON.stringify({ message: query }), }); if (!response.ok) { - throw new Error(`Agent API error: ${response.status}`); + throw new Error(`Agent API error: ${response.status} ${response.statusText}`); } const data = await response.json(); - const results = []; - // 1. Add the AI answer as the first result (most prominent) - if (data.answer) { + if (data.answer && data.answer.trim()) { results.push({ id: `ai-answer-${Date.now()}`, - url: '#ai-answer', // Special marker for AI answers - title: `🤖 AI Answer`, - content: data.answer, - type: 'ai-answer' // Custom type for styling + url: '#ai-answer', + title: 'AI Answer', + content: data.answer.trim(), + type: 'ai-answer' }); } // 2. Add related documents as clickable results - if (data.documents && Array.isArray(data.documents)) { - data.documents.forEach((docPath: string, index: number) => { - const url = documentPathToUrl(docPath); - const title = getDocumentTitle(docPath); - - results.push({ - id: `doc-${Date.now()}-${index}`, - url: url, - title: `📄 ${title}`, - content: `Related documentation: ${title}`, - type: 'document' // Custom type for styling - }); + if (data.documents && Array.isArray(data.documents) && data.documents.length > 0) { + const uniqueDocuments = [...new Set(data.documents as string[])]; + + uniqueDocuments.forEach((docPath: string, index: number) => { + try { + const url = documentPathToUrl(docPath); + const metadata = getDocumentMetadata(docPath); + const snippet = getDocumentSnippet(docPath); + + results.push({ + id: `doc-${Date.now()}-${index}`, + url: url, + title: metadata.title, + content: snippet, + type: 'document' + }); + } catch (error) { + console.warn(`Failed to process document ${docPath}:`, error); + } }); } + console.log('Returning results:', results.length, 'items'); return Response.json(results); } catch (error) { console.error('Error calling AI agent:', error); // Fallback to original Fumadocs search behavior if AI fails - return defaultSearchHandler(request); + console.log('Falling back to default search'); + try { + const fallbackResponse = await defaultSearchHandler(request); + const fallbackData = await fallbackResponse.json(); + + // Add a note that this is fallback search + if (Array.isArray(fallbackData) && fallbackData.length > 0) { + return Response.json([ + { + id: 'fallback-notice', + url: '#fallback', + title: '⚠️ AI Search Unavailable', + content: 'AI search is temporarily unavailable. Showing traditional search results below.', + type: 'ai-answer' + }, + ...fallbackData.map((item: Record, index: number) => ({ + ...item, + id: `fallback-${index}`, + type: 'document' + })) + ]); + } + + return fallbackResponse; + } catch (fallbackError) { + console.error('Fallback search also failed:', fallbackError); + + // Return error message as AI answer + return Response.json([ + { + id: 'error-notice', + url: '#error', + title: '❌ Search Error', + content: 'Search is temporarily unavailable. Please try again later or check our documentation directly.', + type: 'ai-answer' + } + ]); + } } } diff --git a/components/CustomSearchDialog.tsx b/components/CustomSearchDialog.tsx index e89fe775..a1ded471 100644 --- a/components/CustomSearchDialog.tsx +++ b/components/CustomSearchDialog.tsx @@ -1,6 +1,9 @@ 'use client'; import { useState, useCallback } from 'react'; +import { Command } from 'cmdk'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; import type { SharedProps } from 'fumadocs-ui/components/dialog/search'; interface SearchResult { @@ -16,18 +19,15 @@ export default function CustomSearchDialog(props: SharedProps) { const [query, setQuery] = useState(''); const [results, setResults] = useState([]); const [loading, setLoading] = useState(false); - const [hasSearched, setHasSearched] = useState(false); // Perform search function const performSearch = useCallback(async (searchQuery: string) => { if (!searchQuery.trim()) { setResults([]); - setHasSearched(false); return; } setLoading(true); - setHasSearched(true); const searchParams = new URLSearchParams({ query: searchQuery }); try { @@ -42,98 +42,81 @@ export default function CustomSearchDialog(props: SharedProps) { } }, []); - const handleInputChange = useCallback((e: React.ChangeEvent) => { - setQuery(e.target.value); - // Reset search state when user starts typing again - if (hasSearched) { - setHasSearched(false); - setResults([]); - } - }, [hasSearched]); - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (e.key === 'Enter') { e.preventDefault(); performSearch(query); } - if (e.key === 'Escape') { - onOpenChange(false); - } - }, [query, performSearch, onOpenChange]); + }, [query, performSearch]); + // Handle clicking on document results const handleResultClick = useCallback((result: SearchResult) => { - if (result.type === 'ai-answer') { - // For AI answers, keep dialog open for now (could copy to clipboard or show full answer) - return; - } - - if (result.url && result.url !== '#ai-answer') { + if (result.type === 'document' && result.url && result.url !== '#ai-answer') { + // Navigate to the document and close the dialog window.location.href = result.url; onOpenChange(false); } - }, [onOpenChange]); - const getResultStyles = (result: SearchResult) => { - const baseStyles = "p-3 rounded border-b border-gray-200 dark:border-gray-600 last:border-b-0"; - - if (result.type === 'ai-answer') { - return `${baseStyles} bg-blue-50 dark:bg-blue-900/20 border-l-4 border-l-blue-500`; - } - - if (result.type === 'document') { - return `${baseStyles} hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer`; - } - - return `${baseStyles} hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer`; - }; + }, [onOpenChange]); if (!open) return null; return ( -
onOpenChange(false)}> -
e.stopPropagation()} - > - - - {loading && ( -
-
- Searching... -
- )} - - {!loading && hasSearched && ( -
- {results.length > 0 ? ( + +
+
+

Search Documentation

+ + + + {loading && ( +
+
+ Searching... +
+ )} + + {!loading && results.length > 0 && ( +
{results.map((result) => (
handleResultClick(result)} > -
- {result.title} -
-
- {result.content} +
+ {result.title} + {result.type === 'document' && ( + + + + )}
+ {result.type === 'ai-answer' ? ( +
+ + {result.content} + +
+ ) : ( +
+ {result.content} +
+ )} {result.type === 'document' && ( -
+
Click to open documentation →
)} @@ -145,25 +128,20 @@ export default function CustomSearchDialog(props: SharedProps) {
))}
- ) : ( -
-
🔍
- No results found -
- )} +
+ )} + + {!loading && results.length === 0 && query && ( +
+ No results found for "{query}" +
+ )} + +
+ {query ? 'Press Enter to search' : 'Type your search query and press Enter'}
- )} - -
- {!hasSearched && query ? 'Press Enter to search' : - hasSearched ? `${results.length} result(s) for "${query}"` : - 'Type your search query and press Enter'} -
- -
- Press Enter to search • Esc to close
-
+ ); } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9d915505..f4eb3eee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "hasInstallScript": true, "dependencies": { + "cmdk": "^1.1.1", "fumadocs-core": "15.2.6", "fumadocs-mdx": "11.5.8", "fumadocs-twoslash": "^3.1.0", @@ -19,6 +20,8 @@ "next": "15.3.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "twoslash": "^0.3.1" }, "devDependencies": { @@ -12858,7 +12861,6 @@ "version": "19.0.10", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.10.tgz", "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", - "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -13858,6 +13860,21 @@ "node": ">=6" } }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, "node_modules/collapse-white-space": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", @@ -14034,7 +14051,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "devOptional": true, "license": "MIT" }, "node_modules/cytoscape": { @@ -16837,6 +16853,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-void-elements": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", @@ -20672,6 +20697,32 @@ "dev": true, "license": "MIT" }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-medium-image-zoom": { "version": "5.2.14", "resolved": "https://registry.npmjs.org/react-medium-image-zoom/-/react-medium-image-zoom-5.2.14.tgz", @@ -20937,7 +20988,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", - "license": "MIT", "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", diff --git a/package.json b/package.json index cd150234..40ceaba8 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts" }, "dependencies": { + "cmdk": "^1.1.1", "fumadocs-core": "15.2.6", "fumadocs-mdx": "11.5.8", "fumadocs-twoslash": "^3.1.0", @@ -24,6 +25,8 @@ "next": "15.3.0", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "twoslash": "^0.3.1" }, "devDependencies": { From 557fee02c9c27d54bd8785c690f2c64794b6152c Mon Sep 17 00:00:00 2001 From: afterrburn Date: Fri, 27 Jun 2025 08:54:46 -0600 Subject: [PATCH 09/33] Facelift for AI search dialog --- components/CustomSearchDialog.tsx | 383 ++++++++++++++++++++++------- components/icons/AgentuityLogo.tsx | 30 +++ 2 files changed, 325 insertions(+), 88 deletions(-) create mode 100644 components/icons/AgentuityLogo.tsx diff --git a/components/CustomSearchDialog.tsx b/components/CustomSearchDialog.tsx index a1ded471..f337485b 100644 --- a/components/CustomSearchDialog.tsx +++ b/components/CustomSearchDialog.tsx @@ -1,11 +1,34 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import { Command } from 'cmdk'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import { + X, + Send, + RotateCcw, + Trash2, + User, + HelpCircle, + Loader2 +} from 'lucide-react'; +import { AgentuityLogo } from './icons/AgentuityLogo'; import type { SharedProps } from 'fumadocs-ui/components/dialog/search'; +interface Message { + id: string; + type: 'user' | 'ai'; + content: string; + timestamp: Date; + sources?: Array<{ + id: string; + title: string; + url: string; + content: string; + }>; +} + interface SearchResult { id: string; title: string; @@ -14,131 +37,315 @@ interface SearchResult { type?: 'ai-answer' | 'document' | 'default'; } +const STORAGE_KEY = 'agentuity-search-history'; + export default function CustomSearchDialog(props: SharedProps) { const { open, onOpenChange } = props; - const [query, setQuery] = useState(''); - const [results, setResults] = useState([]); + const [messages, setMessages] = useState([]); + const [currentInput, setCurrentInput] = useState(''); const [loading, setLoading] = useState(false); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + + // Load messages from localStorage on mount + useEffect(() => { + try { + const saved = localStorage.getItem(STORAGE_KEY); + if (saved) { + const parsed = JSON.parse(saved); + // Convert timestamp strings back to Date objects + const messagesWithDates = parsed.map((msg: any) => ({ + ...msg, + timestamp: new Date(msg.timestamp) + })); + setMessages(messagesWithDates); + } + } catch (error) { + console.warn('Failed to load chat history:', error); + } + }, []); + + // Save messages to localStorage whenever messages change + useEffect(() => { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); + } catch (error) { + console.warn('Failed to save chat history:', error); + } + }, [messages]); - // Perform search function - const performSearch = useCallback(async (searchQuery: string) => { - if (!searchQuery.trim()) { - setResults([]); - return; + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + // Focus input when dialog opens + useEffect(() => { + if (open) { + setTimeout(() => inputRef.current?.focus(), 100); } + }, [open]); + + // Send message function + const sendMessage = useCallback(async (query: string) => { + if (!query.trim()) return; + // Add user message + const userMessage: Message = { + id: `user-${Date.now()}`, + type: 'user', + content: query.trim(), + timestamp: new Date() + }; + + setMessages(prev => [...prev, userMessage]); + setCurrentInput(''); setLoading(true); - const searchParams = new URLSearchParams({ query: searchQuery }); - + try { + const searchParams = new URLSearchParams({ query: query.trim() }); const response = await fetch(`/api/search?${searchParams}`); const data: SearchResult[] = await response.json(); - setResults(data || []); + + // Find AI answer and documents + const aiAnswer = data.find(result => result.type === 'ai-answer'); + const documents = data.filter(result => result.type === 'document'); + + if (aiAnswer) { + const aiMessage: Message = { + id: `ai-${Date.now()}`, + type: 'ai', + content: aiAnswer.content, + timestamp: new Date(), + sources: documents.length > 0 ? documents.map(doc => ({ + id: doc.id, + title: doc.title, + url: doc.url || '#', + content: doc.content + })) : undefined + }; + + setMessages(prev => [...prev, aiMessage]); + } else { + // Fallback if no AI answer + const fallbackMessage: Message = { + id: `ai-${Date.now()}`, + type: 'ai', + content: 'I couldn\'t find a relevant answer to your question. Please try rephrasing or check our documentation directly.', + timestamp: new Date() + }; + setMessages(prev => [...prev, fallbackMessage]); + } } catch (error) { console.error('Search error:', error); - setResults([]); + const errorMessage: Message = { + id: `ai-${Date.now()}`, + type: 'ai', + content: 'Sorry, I encountered an error while searching. Please try again.', + timestamp: new Date() + }; + setMessages(prev => [...prev, errorMessage]); } finally { setLoading(false); } }, []); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter') { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - performSearch(query); + sendMessage(currentInput); } - }, [query, performSearch]); - - // Handle clicking on document results - const handleResultClick = useCallback((result: SearchResult) => { - if (result.type === 'document' && result.url && result.url !== '#ai-answer') { - // Navigate to the document and close the dialog - window.location.href = result.url; + if (e.key === 'Escape') { onOpenChange(false); } + }, [currentInput, sendMessage, onOpenChange]); + + const handleRetry = useCallback(() => { + const lastUserMessage = [...messages].reverse().find(m => m.type === 'user'); + if (lastUserMessage) { + sendMessage(lastUserMessage.content); + } + }, [messages, sendMessage]); - }, [onOpenChange]); + const handleClear = useCallback(() => { + setMessages([]); + setCurrentInput(''); + // Also clear from localStorage + try { + localStorage.removeItem(STORAGE_KEY); + } catch (error) { + console.warn('Failed to clear chat history:', error); + } + }, []); + + const handleSourceClick = useCallback((url: string) => { + if (url && url !== '#') { + window.open(url, '_blank'); + } + }, []); if (!open) return null; return ( - -
-
-

Search Documentation

+ +
+
- - - {loading && ( -
-
- Searching... + {/* Header */} +
+
+
+ +
+
+

Search Documentation

+

Ask questions about Agentuity

+
- )} - - {!loading && results.length > 0 && ( -
-
- {results.map((result) => ( -
handleResultClick(result)} - > -
- {result.title} - {result.type === 'document' && ( - - - - )} -
- {result.type === 'ai-answer' ? ( -
+ +
+ + {/* Messages Area */} +
+ {messages.length === 0 && ( +
+
+ +
+

Ask a question

+

+ Search our documentation or ask about Agentuity features +

+
+ )} + + {messages.map((message) => ( +
+ {message.type === 'ai' && ( +
+ +
+ )} + +
+
+ {message.type === 'ai' ? ( +
- {result.content} + {message.content}
) : ( -
- {result.content} -
- )} - {result.type === 'document' && ( -
- Click to open documentation → -
- )} - {result.type === 'ai-answer' && ( -
- 💡 AI-generated answer based on documentation -
+

{message.content}

)}
- ))} + + {/* Sources for AI messages */} + {message.type === 'ai' && message.sources && message.sources.length > 0 && ( +
+

Related:

+ {message.sources.map((source) => ( + + ))} +
+ )} + +
+ {message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
+
+ + {message.type === 'user' && ( +
+ +
+ )} +
+ ))} + + {loading && ( +
+
+ +
+
+
+ + Searching... +
+
+ )} + +
+
+ + {/* Input Area */} +
+
+ setCurrentInput(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Ask about Agentuity..." + className="flex-1 px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-1 focus:ring-gray-300 dark:focus:ring-gray-600 focus:border-transparent bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 transition-all" + disabled={loading} + /> +
- )} - - {!loading && results.length === 0 && query && ( -
- No results found for "{query}" + + {/* Action Buttons */} +
+
+ {messages.length > 0 && ( + <> + + + + )} +
+ + Powered by Agentuity +
- )} - -
- {query ? 'Press Enter to search' : 'Type your search query and press Enter'}
diff --git a/components/icons/AgentuityLogo.tsx b/components/icons/AgentuityLogo.tsx new file mode 100644 index 00000000..31b157ed --- /dev/null +++ b/components/icons/AgentuityLogo.tsx @@ -0,0 +1,30 @@ +interface AgentuityLogoProps { + className?: string; + size?: number; +} + +export function AgentuityLogo({ className = "w-5 h-5", size }: AgentuityLogoProps) { + return ( + + Agentuity + + + + ); +} \ No newline at end of file From 4c015b7eb3b7e22bd7a33daf20bc44399dd96ea9 Mon Sep 17 00:00:00 2001 From: afterrburn Date: Sat, 28 Jun 2025 10:33:55 -0600 Subject: [PATCH 10/33] Coderabbit suggested cleanups --- agent-docs/src/agents/doc-qa/index.ts | 8 +++-- agent-docs/src/agents/doc-qa/retriever.ts | 13 +++++--- app/api/search/route.ts | 28 +++++++++--------- components/CustomSearchDialog.tsx | 36 ++++++++++++++--------- 4 files changed, 50 insertions(+), 35 deletions(-) diff --git a/agent-docs/src/agents/doc-qa/index.ts b/agent-docs/src/agents/doc-qa/index.ts index 85d5665f..be3288ee 100644 --- a/agent-docs/src/agents/doc-qa/index.ts +++ b/agent-docs/src/agents/doc-qa/index.ts @@ -11,9 +11,11 @@ export default async function Agent( try { jsonRequest = await req.data.json(); - prompt = typeof jsonRequest === 'object' && jsonRequest !== null && 'message' in jsonRequest - ? jsonRequest.message - : JSON.stringify(jsonRequest); + if (typeof jsonRequest === 'object' && jsonRequest !== null && 'message' in jsonRequest) { + prompt = String(jsonRequest.message || ''); + } else { + prompt = JSON.stringify(jsonRequest); + } } catch { prompt = await req.data.text(); } diff --git a/agent-docs/src/agents/doc-qa/retriever.ts b/agent-docs/src/agents/doc-qa/retriever.ts index d393e3b8..3c2b210d 100644 --- a/agent-docs/src/agents/doc-qa/retriever.ts +++ b/agent-docs/src/agents/doc-qa/retriever.ts @@ -44,23 +44,28 @@ export async function retrieveRelevantDocs(ctx: AgentContext, prompt: string): P async function retrieveDocumentBasedOnPath(ctx: AgentContext, path: string): Promise { const dbQuery = { query: ' ', - limit: 10000, + limit: 1000, metadata: { path: path } } try { const vectors = await ctx.vector.search(VECTOR_STORE_NAME, dbQuery); - + // Sort vectors by chunk index and concatenate text const sortedVectors = vectors .map(vec => { - const metadata = vec.metadata as ChunkMetadata; + const metadata = vec.metadata; + if (!metadata || typeof metadata.chunkIndex !== 'number' || typeof metadata.text !== 'string') { + ctx.logger.warn('Invalid chunk metadata structure for path %s', path); + return null; + } return { metadata, - index: metadata.chunkIndex + index: metadata.chunkIndex as number }; }) + .filter(item => item !== null) .sort((a, b) => a.index - b.index); const fullText = sortedVectors diff --git a/app/api/search/route.ts b/app/api/search/route.ts index e398d730..1421347e 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -14,7 +14,7 @@ function getDocumentMetadata(docPath: string): { title: string; description?: st try { const urlPath = documentPathToUrl(docPath).substring(1).split('/'); const page = source.getPage(urlPath); - + if (page?.data) { return { title: page.data.title || formatPathAsTitle(docPath), @@ -24,7 +24,7 @@ function getDocumentMetadata(docPath: string): { title: string; description?: st } catch (error) { console.warn(`Failed to get metadata for ${docPath}:`, error); } - + return { title: formatPathAsTitle(docPath) }; } @@ -40,18 +40,18 @@ function getDocumentSnippet(docPath: string, maxLength: number = 150): string { try { const urlPath = documentPathToUrl(docPath).substring(1).split('/'); const page = source.getPage(urlPath); - + if (page?.data.description) { - return page.data.description.length > maxLength + return page.data.description.length > maxLength ? page.data.description.substring(0, maxLength) + '...' : page.data.description; } - + // Fallback description based on path const pathParts = docPath.replace(/\.mdx?$/, '').split('/'); const section = pathParts[0]; const topic = pathParts[pathParts.length - 1]; - + return `Learn about ${topic} in the ${section} section of our documentation.`; } catch { return `Documentation for ${formatPathAsTitle(docPath)}`; @@ -61,7 +61,7 @@ function getDocumentSnippet(docPath: string, maxLength: number = 150): string { export async function GET(request: NextRequest) { const { searchParams } = new URL(request.url); const query = searchParams.get('query'); - + // If no query, return empty results if (!query || query.trim().length === 0) { return Response.json([]); @@ -83,7 +83,7 @@ export async function GET(request: NextRequest) { const data = await response.json(); const results = []; - if (data.answer && data.answer.trim()) { + if (data?.answer?.trim()) { results.push({ id: `ai-answer-${Date.now()}`, url: '#ai-answer', @@ -96,13 +96,13 @@ export async function GET(request: NextRequest) { // 2. Add related documents as clickable results if (data.documents && Array.isArray(data.documents) && data.documents.length > 0) { const uniqueDocuments = [...new Set(data.documents as string[])]; - + uniqueDocuments.forEach((docPath: string, index: number) => { try { const url = documentPathToUrl(docPath); const metadata = getDocumentMetadata(docPath); const snippet = getDocumentSnippet(docPath); - + results.push({ id: `doc-${Date.now()}-${index}`, url: url, @@ -121,13 +121,13 @@ export async function GET(request: NextRequest) { } catch (error) { console.error('Error calling AI agent:', error); - + // Fallback to original Fumadocs search behavior if AI fails console.log('Falling back to default search'); try { const fallbackResponse = await defaultSearchHandler(request); const fallbackData = await fallbackResponse.json(); - + // Add a note that this is fallback search if (Array.isArray(fallbackData) && fallbackData.length > 0) { return Response.json([ @@ -145,11 +145,11 @@ export async function GET(request: NextRequest) { })) ]); } - + return fallbackResponse; } catch (fallbackError) { console.error('Fallback search also failed:', fallbackError); - + // Return error message as AI answer return Response.json([ { diff --git a/components/CustomSearchDialog.tsx b/components/CustomSearchDialog.tsx index f337485b..732b6925 100644 --- a/components/CustomSearchDialog.tsx +++ b/components/CustomSearchDialog.tsx @@ -4,12 +4,12 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { Command } from 'cmdk'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; -import { - X, - Send, - RotateCcw, - Trash2, - User, +import { + X, + Send, + RotateCcw, + Trash2, + User, HelpCircle, Loader2 } from 'lucide-react'; @@ -104,7 +104,13 @@ export default function CustomSearchDialog(props: SharedProps) { try { const searchParams = new URLSearchParams({ query: query.trim() }); - const response = await fetch(`/api/search?${searchParams}`); + + const controller = new AbortController(); + setTimeout(() => controller.abort(), 45000); + + const response = await fetch(`/api/search?${searchParams}`, { + signal: controller.signal + }); const data: SearchResult[] = await response.json(); // Find AI answer and documents @@ -179,9 +185,12 @@ export default function CustomSearchDialog(props: SharedProps) { }, []); const handleSourceClick = useCallback((url: string) => { - if (url && url !== '#') { + if (url && url !== '#' && (url.startsWith('/') || url.startsWith('http'))) { window.open(url, '_blank'); } + else { + console.warn('Invalid or potentially unsafe URL:', url); + } }, []); if (!open) return null; @@ -190,7 +199,7 @@ export default function CustomSearchDialog(props: SharedProps) {
- + {/* Header */}
@@ -233,11 +242,10 @@ export default function CustomSearchDialog(props: SharedProps) { )}
-
+
{message.type === 'ai' ? (
From 4d769269a773691c94d70a27ddab6a9f81c65c04 Mon Sep 17 00:00:00 2001 From: afterrburn Date: Sat, 28 Jun 2025 11:14:47 -0600 Subject: [PATCH 11/33] fix breaking build --- components/CustomSearchDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/CustomSearchDialog.tsx b/components/CustomSearchDialog.tsx index 732b6925..cd38c571 100644 --- a/components/CustomSearchDialog.tsx +++ b/components/CustomSearchDialog.tsx @@ -54,7 +54,7 @@ export default function CustomSearchDialog(props: SharedProps) { if (saved) { const parsed = JSON.parse(saved); // Convert timestamp strings back to Date objects - const messagesWithDates = parsed.map((msg: any) => ({ + const messagesWithDates: Message[] = (parsed as Message[]).map((msg) => ({ ...msg, timestamp: new Date(msg.timestamp) })); From c3b2a411b2b0be02d8b0607058f8e7da97a5bf66 Mon Sep 17 00:00:00 2001 From: afterrburn Date: Sat, 28 Jun 2025 11:51:31 -0600 Subject: [PATCH 12/33] move config to env variable --- .env.example | 10 ++++++ .gitignore | 2 ++ app/api/search/route.ts | 19 +++++++++--- app/layout.tsx | 6 ++++ lib/env.ts | 68 +++++++++++++++++++++++++++++++++++++++++ package.json | 4 ++- wrangler.jsonc | 5 ++- 7 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 .env.example create mode 100644 lib/env.ts diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..acc73b85 --- /dev/null +++ b/.env.example @@ -0,0 +1,10 @@ +# Agent Configuration +AGENT_BASE_URL=http://127.0.0.1:3500 +AGENT_ID=agent_your_agent_id +# AGENT_BEARER_TOKEN=your-bearer-token-here + +# Alternative: You can also set the full URL instead of BASE_URL + ID +# AGENT_FULL_URL=http://127.0.0.1:3500/agent_your_agent_id + +# Next.js Environment +NEXTJS_ENV=development \ No newline at end of file diff --git a/.gitignore b/.gitignore index fa8246e7..d984131e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ yarn-error.log* # others .env*.local +.env.local +.env.production .vercel next-env.d.ts .open-next diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 1421347e..7db1b3d8 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -1,6 +1,7 @@ import { source } from '@/lib/source'; import { createFromSource } from 'fumadocs-core/search/server'; import { NextRequest } from 'next/server'; +import { getAgentConfig } from '@/lib/env'; // Create the default search handler const { GET: defaultSearchHandler } = createFromSource(source); @@ -68,11 +69,21 @@ export async function GET(request: NextRequest) { } try { - const response = await fetch('http://127.0.0.1:3500/agent_9ccc5545e93644bd9d7954e632a55a61', { + const agentConfig = getAgentConfig(); + + // Prepare headers + const headers: Record = { + 'Content-Type': 'application/json', + }; + + // Add bearer token if provided + if (agentConfig.bearerToken) { + headers['Authorization'] = `Bearer ${agentConfig.bearerToken}`; + } + + const response = await fetch(agentConfig.url, { method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, + headers, body: JSON.stringify({ message: query }), }); diff --git a/app/layout.tsx b/app/layout.tsx index b77f4892..3dc1264c 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,8 +3,14 @@ import { GeistSans } from "geist/font/sans"; import type { ReactNode } from "react"; import type { Metadata } from 'next'; import CustomSearchDialog from "@/components/CustomSearchDialog"; +import { validateEnv } from "@/lib/env"; import "./global.css"; +// Validate environment variables at startup (server-side only) +if (typeof window === 'undefined') { + validateEnv(); +} + export const metadata: Metadata = { metadataBase: new URL('https://www.agentuity.dev'), title: 'Agentuity Docs', diff --git a/lib/env.ts b/lib/env.ts new file mode 100644 index 00000000..480834a6 --- /dev/null +++ b/lib/env.ts @@ -0,0 +1,68 @@ +/** + * Environment variable validation and configuration utility + */ + +export interface AgentConfig { + url: string; + bearerToken?: string; +} + +/** + * Validates and returns agent configuration from environment variables + */ +export const getAgentConfig = (): AgentConfig => { + const baseUrl = process.env.AGENT_BASE_URL; + const agentId = process.env.AGENT_ID; + const bearerToken = process.env.AGENT_BEARER_TOKEN; + const fullUrl = process.env.AGENT_FULL_URL; + + // Validate required environment variables + if (!fullUrl && (!baseUrl || !agentId)) { + throw new Error( + 'Missing required environment variables. Either set AGENT_FULL_URL or both AGENT_BASE_URL and AGENT_ID' + ); + } + + const url = fullUrl || `${baseUrl}/${agentId}`; + + return { + url, + bearerToken: bearerToken || undefined, + }; +}; + +/** + * Validates environment variables at startup + */ +export const validateEnv = (): boolean => { + try { + const config = getAgentConfig(); + console.log('✓ Environment variables validated'); + console.log('✓ Agent URL:', config.url); + console.log('✓ Bearer token:', config.bearerToken ? 'configured' : 'not set'); + return true; + } catch (error) { + console.error('❌ Environment validation failed:', error); + console.error('💡 Make sure to set either:'); + console.error(' - AGENT_FULL_URL, or'); + console.error(' - Both AGENT_BASE_URL and AGENT_ID'); + console.error('💡 Optionally set AGENT_BEARER_TOKEN for authentication'); + return false; + } +}; + +/** + * Environment variable types + * Use .env.local for development and .env.production for production + */ +declare global { + namespace NodeJS { + interface ProcessEnv { + AGENT_BASE_URL?: string; + AGENT_ID?: string; + AGENT_BEARER_TOKEN?: string; + AGENT_FULL_URL?: string; + NEXTJS_ENV?: string; + } + } +} \ No newline at end of file diff --git a/package.json b/package.json index 40ceaba8..3d3c3b19 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,14 @@ "prebuild": "node scripts/generate-docs-json.js", "build": "next build", "build:worker": "opennextjs-cloudflare build", + "build:production": "NODE_ENV=production next build", "dev": "PORT=3201 next dev", "start": "next start", "postinstall": "fumadocs-mdx", "preview": "opennextjs-cloudflare && wrangler dev", "deploy": "opennextjs-cloudflare && wrangler deploy --keep-vars", - "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts" + "cf-typegen": "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts", + "validate-env": "node -e \"require('./lib/env').validateEnv()\"" }, "dependencies": { "cmdk": "^1.1.1", diff --git a/wrangler.jsonc b/wrangler.jsonc index 9d6aa327..cbdb160e 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -20,5 +20,8 @@ "routes": [ { "pattern": "agentuity.dev", "custom_domain": true }, { "pattern": "www.agentuity.dev", "custom_domain": true } - ] + ], + "vars": { + "NEXTJS_ENV": "production" + } } From 1d0473402101ab1aa90f4eae20fe8986a89e1286 Mon Sep 17 00:00:00 2001 From: afterrburn Date: Sat, 28 Jun 2025 12:03:38 -0600 Subject: [PATCH 13/33] small clean up --- agent-docs/src/agents/doc-qa/index.ts | 2 +- lib/env.ts | 20 +++++++++----------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/agent-docs/src/agents/doc-qa/index.ts b/agent-docs/src/agents/doc-qa/index.ts index be3288ee..d49cef91 100644 --- a/agent-docs/src/agents/doc-qa/index.ts +++ b/agent-docs/src/agents/doc-qa/index.ts @@ -20,7 +20,7 @@ export default async function Agent( prompt = await req.data.text(); } - if (prompt === undefined || prompt === null) { + if (!prompt.trim()) { return resp.text("How can I help you?"); } diff --git a/lib/env.ts b/lib/env.ts index 480834a6..07598e16 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -1,7 +1,6 @@ /** * Environment variable validation and configuration utility */ - export interface AgentConfig { url: string; bearerToken?: string; @@ -39,7 +38,7 @@ export const validateEnv = (): boolean => { const config = getAgentConfig(); console.log('✓ Environment variables validated'); console.log('✓ Agent URL:', config.url); - console.log('✓ Bearer token:', config.bearerToken ? 'configured' : 'not set'); + console.log('✓ Bearer token:', config.bearerToken ? 'configured' : 'Not Set'); return true; } catch (error) { console.error('❌ Environment validation failed:', error); @@ -56,13 +55,12 @@ export const validateEnv = (): boolean => { * Use .env.local for development and .env.production for production */ declare global { - namespace NodeJS { - interface ProcessEnv { - AGENT_BASE_URL?: string; - AGENT_ID?: string; - AGENT_BEARER_TOKEN?: string; - AGENT_FULL_URL?: string; - NEXTJS_ENV?: string; - } + interface ProcessEnv { + AGENT_BASE_URL?: string; + AGENT_ID?: string; + AGENT_BEARER_TOKEN?: string; + AGENT_FULL_URL?: string; + NEXTJS_ENV?: string; } -} \ No newline at end of file +} +export {}; \ No newline at end of file From a0a26916de74eb55cb9a25f2c9f4324cf04b918a Mon Sep 17 00:00:00 2001 From: afterrburn Date: Sat, 28 Jun 2025 12:49:03 -0600 Subject: [PATCH 14/33] abort controller on component dismount --- components/CustomSearchDialog.tsx | 22 +++++++++++++++++++++- lib/env.ts | 3 +-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/components/CustomSearchDialog.tsx b/components/CustomSearchDialog.tsx index cd38c571..48ef8226 100644 --- a/components/CustomSearchDialog.tsx +++ b/components/CustomSearchDialog.tsx @@ -46,6 +46,15 @@ export default function CustomSearchDialog(props: SharedProps) { const [loading, setLoading] = useState(false); const messagesEndRef = useRef(null); const inputRef = useRef(null); + const abortControllerRef = useRef(null); + + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); // Load messages from localStorage on mount useEffect(() => { @@ -105,12 +114,23 @@ export default function CustomSearchDialog(props: SharedProps) { try { const searchParams = new URLSearchParams({ query: query.trim() }); + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + const controller = new AbortController(); - setTimeout(() => controller.abort(), 45000); + abortControllerRef.current = controller; + const timeoutId = setTimeout(() => controller.abort(), 30000); const response = await fetch(`/api/search?${searchParams}`, { signal: controller.signal }); + + clearTimeout(timeoutId); + if (!response.ok) { + throw new Error(`Search failed: ${response.status}`); + } + const data: SearchResult[] = await response.json(); // Find AI answer and documents diff --git a/lib/env.ts b/lib/env.ts index 07598e16..96d6b6bc 100644 --- a/lib/env.ts +++ b/lib/env.ts @@ -62,5 +62,4 @@ declare global { AGENT_FULL_URL?: string; NEXTJS_ENV?: string; } -} -export {}; \ No newline at end of file +} \ No newline at end of file From 29e0598e047f5d835caf4b7c9b6b856fb812eeb9 Mon Sep 17 00:00:00 2001 From: afterrburn Date: Sat, 28 Jun 2025 15:19:46 -0600 Subject: [PATCH 15/33] break custom search dialog into multiple modules --- components/CustomSearchDialog.tsx | 382 +--------------- components/CustomSearchDialog/MessageList.tsx | 128 ++++++ components/CustomSearchDialog/SearchInput.tsx | 74 ++++ .../CustomSearchDialog/hooks/useMessages.tsx | 157 +++++++ components/CustomSearchDialog/index.tsx | 121 ++++++ components/CustomSearchDialog/types.ts | 37 ++ package-lock.json | 410 +++++++++++++++++- package.json | 2 + 8 files changed, 910 insertions(+), 401 deletions(-) create mode 100644 components/CustomSearchDialog/MessageList.tsx create mode 100644 components/CustomSearchDialog/SearchInput.tsx create mode 100644 components/CustomSearchDialog/hooks/useMessages.tsx create mode 100644 components/CustomSearchDialog/index.tsx create mode 100644 components/CustomSearchDialog/types.ts diff --git a/components/CustomSearchDialog.tsx b/components/CustomSearchDialog.tsx index 48ef8226..cb7443e5 100644 --- a/components/CustomSearchDialog.tsx +++ b/components/CustomSearchDialog.tsx @@ -1,382 +1,4 @@ 'use client'; -import { useState, useCallback, useRef, useEffect } from 'react'; -import { Command } from 'cmdk'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import { - X, - Send, - RotateCcw, - Trash2, - User, - HelpCircle, - Loader2 -} from 'lucide-react'; -import { AgentuityLogo } from './icons/AgentuityLogo'; -import type { SharedProps } from 'fumadocs-ui/components/dialog/search'; - -interface Message { - id: string; - type: 'user' | 'ai'; - content: string; - timestamp: Date; - sources?: Array<{ - id: string; - title: string; - url: string; - content: string; - }>; -} - -interface SearchResult { - id: string; - title: string; - content: string; - url?: string; - type?: 'ai-answer' | 'document' | 'default'; -} - -const STORAGE_KEY = 'agentuity-search-history'; - -export default function CustomSearchDialog(props: SharedProps) { - const { open, onOpenChange } = props; - const [messages, setMessages] = useState([]); - const [currentInput, setCurrentInput] = useState(''); - const [loading, setLoading] = useState(false); - const messagesEndRef = useRef(null); - const inputRef = useRef(null); - const abortControllerRef = useRef(null); - - useEffect(() => { - return () => { - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - }; - }, []); - - // Load messages from localStorage on mount - useEffect(() => { - try { - const saved = localStorage.getItem(STORAGE_KEY); - if (saved) { - const parsed = JSON.parse(saved); - // Convert timestamp strings back to Date objects - const messagesWithDates: Message[] = (parsed as Message[]).map((msg) => ({ - ...msg, - timestamp: new Date(msg.timestamp) - })); - setMessages(messagesWithDates); - } - } catch (error) { - console.warn('Failed to load chat history:', error); - } - }, []); - - // Save messages to localStorage whenever messages change - useEffect(() => { - try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(messages)); - } catch (error) { - console.warn('Failed to save chat history:', error); - } - }, [messages]); - - // Auto-scroll to bottom when new messages arrive - useEffect(() => { - messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); - }, [messages]); - - // Focus input when dialog opens - useEffect(() => { - if (open) { - setTimeout(() => inputRef.current?.focus(), 100); - } - }, [open]); - - // Send message function - const sendMessage = useCallback(async (query: string) => { - if (!query.trim()) return; - - // Add user message - const userMessage: Message = { - id: `user-${Date.now()}`, - type: 'user', - content: query.trim(), - timestamp: new Date() - }; - - setMessages(prev => [...prev, userMessage]); - setCurrentInput(''); - setLoading(true); - - try { - const searchParams = new URLSearchParams({ query: query.trim() }); - - if (abortControllerRef.current) { - abortControllerRef.current.abort(); - } - - const controller = new AbortController(); - abortControllerRef.current = controller; - const timeoutId = setTimeout(() => controller.abort(), 30000); - - const response = await fetch(`/api/search?${searchParams}`, { - signal: controller.signal - }); - - clearTimeout(timeoutId); - if (!response.ok) { - throw new Error(`Search failed: ${response.status}`); - } - - const data: SearchResult[] = await response.json(); - - // Find AI answer and documents - const aiAnswer = data.find(result => result.type === 'ai-answer'); - const documents = data.filter(result => result.type === 'document'); - - if (aiAnswer) { - const aiMessage: Message = { - id: `ai-${Date.now()}`, - type: 'ai', - content: aiAnswer.content, - timestamp: new Date(), - sources: documents.length > 0 ? documents.map(doc => ({ - id: doc.id, - title: doc.title, - url: doc.url || '#', - content: doc.content - })) : undefined - }; - - setMessages(prev => [...prev, aiMessage]); - } else { - // Fallback if no AI answer - const fallbackMessage: Message = { - id: `ai-${Date.now()}`, - type: 'ai', - content: 'I couldn\'t find a relevant answer to your question. Please try rephrasing or check our documentation directly.', - timestamp: new Date() - }; - setMessages(prev => [...prev, fallbackMessage]); - } - } catch (error) { - console.error('Search error:', error); - const errorMessage: Message = { - id: `ai-${Date.now()}`, - type: 'ai', - content: 'Sorry, I encountered an error while searching. Please try again.', - timestamp: new Date() - }; - setMessages(prev => [...prev, errorMessage]); - } finally { - setLoading(false); - } - }, []); - - const handleKeyDown = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendMessage(currentInput); - } - if (e.key === 'Escape') { - onOpenChange(false); - } - }, [currentInput, sendMessage, onOpenChange]); - - const handleRetry = useCallback(() => { - const lastUserMessage = [...messages].reverse().find(m => m.type === 'user'); - if (lastUserMessage) { - sendMessage(lastUserMessage.content); - } - }, [messages, sendMessage]); - - const handleClear = useCallback(() => { - setMessages([]); - setCurrentInput(''); - // Also clear from localStorage - try { - localStorage.removeItem(STORAGE_KEY); - } catch (error) { - console.warn('Failed to clear chat history:', error); - } - }, []); - - const handleSourceClick = useCallback((url: string) => { - if (url && url !== '#' && (url.startsWith('/') || url.startsWith('http'))) { - window.open(url, '_blank'); - } - else { - console.warn('Invalid or potentially unsafe URL:', url); - } - }, []); - - if (!open) return null; - - return ( - -
-
- - {/* Header */} -
-
-
- -
-
-

Search Documentation

-

Ask questions about Agentuity

-
-
- -
- - {/* Messages Area */} -
- {messages.length === 0 && ( -
-
- -
-

Ask a question

-

- Search our documentation or ask about Agentuity features -

-
- )} - - {messages.map((message) => ( -
- {message.type === 'ai' && ( -
- -
- )} - -
-
- {message.type === 'ai' ? ( -
- - {message.content} - -
- ) : ( -

{message.content}

- )} -
- - {/* Sources for AI messages */} - {message.type === 'ai' && message.sources && message.sources.length > 0 && ( -
-

Related:

- {message.sources.map((source) => ( - - ))} -
- )} - -
- {message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -
-
- - {message.type === 'user' && ( -
- -
- )} -
- ))} - - {loading && ( -
-
- -
-
-
- - Searching... -
-
-
- )} - -
-
- - {/* Input Area */} -
-
- setCurrentInput(e.target.value)} - onKeyDown={handleKeyDown} - placeholder="Ask about Agentuity..." - className="flex-1 px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg focus:outline-none focus:ring-1 focus:ring-gray-300 dark:focus:ring-gray-600 focus:border-transparent bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 transition-all" - disabled={loading} - /> - -
- - {/* Action Buttons */} -
-
- {messages.length > 0 && ( - <> - - - - )} -
- - Powered by Agentuity - -
-
-
-
- - ); -} \ No newline at end of file +import { default as CustomSearchDialogImpl } from './CustomSearchDialog/index'; +export default CustomSearchDialogImpl; \ No newline at end of file diff --git a/components/CustomSearchDialog/MessageList.tsx b/components/CustomSearchDialog/MessageList.tsx new file mode 100644 index 00000000..a6bbfd8e --- /dev/null +++ b/components/CustomSearchDialog/MessageList.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useRef, useEffect } from 'react'; +import ReactMarkdown from 'react-markdown'; +import remarkGfm from 'remark-gfm'; +import { User, HelpCircle, Loader2 } from 'lucide-react'; +import { AgentuityLogo } from '../icons/AgentuityLogo'; +import { MessageListProps, Message } from './types'; + +export function MessageList({ messages, loading, handleSourceClick }: MessageListProps) { + const messagesEndRef = useRef(null); + + // Auto-scroll to bottom when new messages arrive + useEffect(() => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [messages]); + + return ( +
+ {messages.length === 0 && } + + {messages.map((message) => ( + + ))} + + {loading && } + +
+
+ ); +} + +function EmptyState() { + return ( +
+
+ +
+

+ Ask a question +

+

+ Search our documentation or ask about Agentuity features +

+
+ ); +} + +function LoadingIndicator() { + return ( +
+
+ +
+
+
+ + Searching... +
+
+
+ ); +} + +interface MessageItemProps { + message: Message; + handleSourceClick: (url: string) => void; +} + +function MessageItem({ message, handleSourceClick }: MessageItemProps) { + return ( +
+ {message.type === 'ai' && ( +
+ +
+ )} + +
+
+ {message.type === 'ai' ? ( +
+ + {message.content} + +
+ ) : ( +

{message.content}

+ )} +
+ + {/* Sources for AI messages */} + {message.type === 'ai' && message.sources && message.sources.length > 0 && ( +
+

Related:

+ {message.sources.map((source) => ( + + ))} +
+ )} + +
+ {message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +
+
+ + {message.type === 'user' && ( +
+ +
+ )} +
+ ); +} \ No newline at end of file diff --git a/components/CustomSearchDialog/SearchInput.tsx b/components/CustomSearchDialog/SearchInput.tsx new file mode 100644 index 00000000..9cb1b440 --- /dev/null +++ b/components/CustomSearchDialog/SearchInput.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { useRef, useEffect, KeyboardEvent } from 'react'; +import { Send } from 'lucide-react'; +import { SearchInputProps } from './types'; + +export function SearchInput({ currentInput, setCurrentInput, loading, sendMessage }: SearchInputProps) { + const textareaRef = useRef(null); + + // Auto-resize textarea based on content + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + // Reset height to auto to get the correct scrollHeight + textarea.style.height = 'auto'; + + // Set the height to scrollHeight to fit the content (up to max-height set in CSS) + const newHeight = Math.min(textarea.scrollHeight, 150); // Max height of 150px + textarea.style.height = `${newHeight}px`; + }, [currentInput]); + + // Focus textarea when component mounts + useEffect(() => { + textareaRef.current?.focus(); + }, []); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + if (currentInput.trim()) { + sendMessage(currentInput); + } + } + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + if (currentInput.trim()) { + sendMessage(currentInput); + } + } + }; + + return ( +
+
+
+