Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 38 additions & 2 deletions .github/workflows/sync-docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
18 changes: 16 additions & 2 deletions app/(docs)/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DocsLayout {...baseOptions} tree={source.pageTree}>
<DocsLayout
{...baseOptions}
tree={source.pageTree}
searchToggle={{
components: {
lg: (
<div className="flex gap-1.5 max-md:hidden">
<LargeSearchToggle className="flex-1" />
<AISearchToggle />
</div>
),
},
}}
>
{children}
</DocsLayout>
);
Expand Down
165 changes: 165 additions & 0 deletions app/api/rag-search/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'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'
}
]);
}
}
Loading