diff --git a/.github/workflows/sync-docs.yml b/.github/workflows/sync-docs.yml index 110fff02..87241c38 100644 --- a/.github/workflows/sync-docs.yml +++ b/.github/workflows/sync-docs.yml @@ -6,6 +6,16 @@ on: - main paths: - 'content/**' + workflow_dispatch: + inputs: + before_commit: + description: 'Before commit SHA (leave empty for automatic detection)' + required: false + type: string + after_commit: + description: 'After commit SHA (leave empty for HEAD)' + required: false + type: string jobs: sync: @@ -15,11 +25,37 @@ jobs: with: fetch-depth: 0 + - name: Set commit variables + id: commits + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + # Manual run + BEFORE_COMMIT="${{ github.event.inputs.before_commit }}" + AFTER_COMMIT="${{ github.event.inputs.after_commit }}" + + # Use defaults if not provided + if [ -z "$BEFORE_COMMIT" ]; then + BEFORE_COMMIT="$(git rev-parse HEAD~1)" + fi + if [ -z "$AFTER_COMMIT" ]; then + AFTER_COMMIT="$(git rev-parse HEAD)" + fi + + echo "before_commit=$BEFORE_COMMIT" >> $GITHUB_OUTPUT + echo "after_commit=$AFTER_COMMIT" >> $GITHUB_OUTPUT + echo "Manual run: $BEFORE_COMMIT -> $AFTER_COMMIT" + else + # Automatic push run + echo "before_commit=${{ github.event.before }}" >> $GITHUB_OUTPUT + echo "after_commit=${{ github.sha }}" >> $GITHUB_OUTPUT + echo "Push run: ${{ github.event.before }} -> ${{ github.sha }}" + fi + - name: Collect and validate files run: | set -euo pipefail - git fetch origin ${{ github.event.before }} - ./bin/collect-changed-files.sh "${{ github.event.before }}" "${{ github.sha }}" > changed-files.txt + git fetch origin "${{ steps.commits.outputs.before_commit }}" + ./bin/collect-changed-files.sh "${{ steps.commits.outputs.before_commit }}" "${{ steps.commits.outputs.after_commit }}" > changed-files.txt echo "Files to sync:" cat changed-files.txt diff --git a/app/(docs)/layout.tsx b/app/(docs)/layout.tsx index 7dee80ce..b23e6d19 100644 --- a/app/(docs)/layout.tsx +++ b/app/(docs)/layout.tsx @@ -1,12 +1,26 @@ import { baseOptions } from "@/app/layout.config"; import { source } from "@/lib/source"; import { DocsLayout } from "fumadocs-ui/layouts/notebook"; - +import { LargeSearchToggle } from 'fumadocs-ui/components/layout/search-toggle'; +import AISearchToggle from "../../components/AISearchToggle"; import type { ReactNode } from "react"; export default function Layout({ children }: { children: ReactNode }) { return ( - + + + + + ), + }, + }} + > {children} ); diff --git a/app/api/rag-search/route.ts b/app/api/rag-search/route.ts new file mode 100644 index 00000000..be9ec324 --- /dev/null +++ b/app/api/rag-search/route.ts @@ -0,0 +1,165 @@ +import { source } from '@/lib/source'; +import { NextRequest } from 'next/server'; +import { getAgentConfig } from '@/lib/env'; + +function documentPathToUrl(docPath: string): string { + // Remove the .md or .mdx extension before any # symbol + const path = docPath.replace(/\.mdx?(?=#|$)/, ''); + + // Split path and hash (if any) + const [basePath, hash] = path.split('#'); + + // Split the base path into segments + const segments = basePath.split('/').filter(Boolean); + + // If the last segment is 'index', remove it + if (segments.length > 0 && segments[segments.length - 1].toLowerCase() === 'index') { + segments.pop(); + } + + // Reconstruct the path + let url = '/' + segments.join('/'); + if (url === '/') { + url = '/'; + } + if (hash) { + url += '#' + hash; + } + return url; +} + +// Helper function to get document title and description from source +function getDocumentMetadata(docPath: string): { title: string; description?: string } { + try { + const urlPath = documentPathToUrl(docPath).substring(1).split('/'); + const page = source.getPage(urlPath); + + 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 { + return `Documentation for ${formatPathAsTitle(docPath)}`; + } +} + +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([]); + } + + try { + 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, + body: JSON.stringify({ message: query }), + }); + + if (!response.ok) { + throw new Error(`Agent API error: ${response.status} ${response.statusText}`); + } + + const data = await response.json(); + const results = []; + + if (data?.answer?.trim()) { + results.push({ + id: `ai-answer-${Date.now()}`, + url: '#ai-answer', + title: 'AI Answer', + content: data.answer.trim(), + type: 'ai-answer' + }); + } + + // 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, + title: metadata.title, + content: snippet, + type: 'document' + }); + } catch (error) { + console.warn(`Failed to process document ${docPath}:`, error); + } + }); + } + + console.log('Returning RAG results:', results.length, 'items'); + return Response.json(results); + + } catch (error) { + console.error('Error calling AI agent:', error); + + // Return error message as AI answer + return Response.json([ + { + id: 'error-notice', + url: '#error', + title: '❌ Search Error', + content: 'AI search is temporarily unavailable. Please try again later or use the regular search.', + type: 'ai-answer' + } + ]); + } +} \ No newline at end of file diff --git a/app/api/search/route.ts b/app/api/search/route.ts index 3bfd8aa3..cb4a0a13 100644 --- a/app/api/search/route.ts +++ b/app/api/search/route.ts @@ -1,198 +1,5 @@ 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); - -function documentPathToUrl(docPath: string): string { - // Remove the .md or .mdx extension before any # symbol - const path = docPath.replace(/\.mdx?(?=#|$)/, ''); - - // Split path and hash (if any) - const [basePath, hash] = path.split('#'); - - // Split the base path into segments - const segments = basePath.split('/').filter(Boolean); - - // If the last segment is 'index', remove it - if (segments.length > 0 && segments[segments.length - 1].toLowerCase() === 'index') { - segments.pop(); - } - - // Reconstruct the path - let url = '/' + segments.join('/'); - if (url === '/') { - url = '/'; - } - if (hash) { - url += '#' + hash; - } - return url; -} - -// Helper function to get document title and description from source -function getDocumentMetadata(docPath: string): { title: string; description?: string } { - try { - const urlPath = documentPathToUrl(docPath).substring(1).split('/'); - const page = source.getPage(urlPath); - - 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 { - return `Documentation for ${formatPathAsTitle(docPath)}`; - } -} - -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([]); - } - - try { - 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, - body: JSON.stringify({ message: query }), - }); - - if (!response.ok) { - throw new Error(`Agent API error: ${response.status} ${response.statusText}`); - } - - const data = await response.json(); - const results = []; - - if (data?.answer?.trim()) { - results.push({ - id: `ai-answer-${Date.now()}`, - 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.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 - 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' - } - ]); - } - } -} +// Export the default fumadocs search handler +export const { GET } = createFromSource(source); diff --git a/app/global.css b/app/global.css index 0d0d3fe1..507d580f 100644 --- a/app/global.css +++ b/app/global.css @@ -112,6 +112,26 @@ code span.line > span { background-color: #000; } +figure { + background-color: var(--color-cyan-100); +} + +.dark figure { + background-color: var(--color-cyan-900); +} + +figure > div:nth-child(1) { + border: 0; + background: transparent; +} + +figure > div:nth-child(2) { + background: transparent; + border: 0; + padding-top: 10px; + padding-bottom: 10px; +} + .mermaid .cluster rect { fill: #FFF !important; stroke: #099 !important; diff --git a/app/layout.config.tsx b/app/layout.config.tsx index a420f73d..fb50b565 100644 --- a/app/layout.config.tsx +++ b/app/layout.config.tsx @@ -3,7 +3,6 @@ import { Github } from "lucide-react"; import { CommunityButton } from "../components/Community"; import { NavButton } from "../components/NavButton"; import { XButton } from "../components/XButton"; - /** * Shared layout configurations */ diff --git a/app/layout.tsx b/app/layout.tsx index 3dc1264c..e5f79926 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,7 +2,6 @@ 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 { validateEnv } from "@/lib/env"; import "./global.css"; @@ -91,9 +90,6 @@ export default function Layout({ children }: { children: ReactNode }) { {children} diff --git a/bin/collect-changed-files.sh b/bin/collect-changed-files.sh index 73cd12f3..306e4abe 100755 --- a/bin/collect-changed-files.sh +++ b/bin/collect-changed-files.sh @@ -33,7 +33,7 @@ echo "Collecting changed files between $BEFORE_COMMIT and $AFTER_COMMIT" >&2 # Get changed files (excluding deleted) echo "Changed files:" >&2 git diff --name-only "$BEFORE_COMMIT" "$AFTER_COMMIT" -- 'content/**/*.mdx' | \ - grep '^content/' | \ + (grep '^content/' || true) | \ sed 's|^content/||' | \ while read -r file; do if [ -n "$file" ] && [ -f "content/$file" ]; then @@ -45,7 +45,7 @@ git diff --name-only "$BEFORE_COMMIT" "$AFTER_COMMIT" -- 'content/**/*.mdx' | \ # Get removed files echo "Removed files:" >&2 git diff --name-only --diff-filter=D "$BEFORE_COMMIT" "$AFTER_COMMIT" -- 'content/**/*.mdx' | \ - grep '^content/' | \ + (grep '^content/' || true) | \ sed 's|^content/||' | \ while read -r file; do if [ -n "$file" ]; then diff --git a/components/AISearchToggle.tsx b/components/AISearchToggle.tsx new file mode 100644 index 00000000..6cc35d0d --- /dev/null +++ b/components/AISearchToggle.tsx @@ -0,0 +1,27 @@ +'use client'; + +import { useState } from 'react'; +import { Sparkles } from 'lucide-react'; +import CustomSearchDialog from './CustomSearchDialog'; + +export default function AISearchToggle() { + const [isOpen, setIsOpen] = useState(false); + + const handleToggle = (e: React.MouseEvent) => { + e.preventDefault(); + setIsOpen(true); + }; + + return ( + <> + + + + ); +} \ No newline at end of file diff --git a/components/CLICommand.tsx b/components/CLICommand.tsx index 6dffaf04..9ca0f64d 100644 --- a/components/CLICommand.tsx +++ b/components/CLICommand.tsx @@ -7,16 +7,18 @@ export interface CLICommandProps { export function CLICommand({ command, children }: CLICommandProps) { return ( - - - ${" "} -
{command}
-
- {children && ( -
- {children} -
- )} + +
+ + ${" "} +
{command}
+
+ {children && ( +
+ {children} +
+ )} +
); } diff --git a/components/CustomSearchDialog/SearchInput.tsx b/components/CustomSearchDialog/SearchInput.tsx index 197dd768..dd65d8d2 100644 --- a/components/CustomSearchDialog/SearchInput.tsx +++ b/components/CustomSearchDialog/SearchInput.tsx @@ -21,6 +21,13 @@ export function SearchInput({ currentInput, setCurrentInput, loading, sendMessag textareaRef.current?.focus(); }, []); + // Refocus when loading completes + useEffect(() => { + if (!loading) { + textareaRef.current?.focus(); + } + }, [loading]); + const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Enter' && (!e.shiftKey || e.ctrlKey || e.metaKey)) { e.preventDefault(); @@ -37,40 +44,34 @@ export function SearchInput({ currentInput, setCurrentInput, loading, sendMessag }; return ( -
-
- {/* Textarea Container */} -
-