/** * GET /api/v1/context * * Fetch documentation snippets for a library. Compatible with context7's * GET /api/v2/context interface. * * Query parameters: * libraryId (required) — e.g. "/facebook/react" or "/facebook/react/v18.3.0" * query (required) — specific question about the library * type (optional) — "json" (default) or "txt" * tokens (optional) — approximate max token count (default 10000) */ import type { RequestHandler } from './$types'; import { getClient } from '$lib/server/db/client'; import { dtoJsonResponse } from '$lib/server/api/dto-response'; import { SearchService } from '$lib/server/search/search.service'; import { HybridSearchService } from '$lib/server/search/hybrid.search.service'; import { createProviderFromProfile } from '$lib/server/embeddings/registry'; import { EmbeddingProfileEntity, type EmbeddingProfileEntityProps } from '$lib/server/models/embedding-profile'; import { EmbeddingProfileMapper } from '$lib/server/mappers/embedding-profile.mapper'; import { parseLibraryId } from '$lib/server/api/library-id'; import { selectSnippetsWithinBudget, DEFAULT_TOKEN_BUDGET } from '$lib/server/api/token-budget'; import { formatContextJson, formatContextTxt, CORS_HEADERS } from '$lib/server/api/formatters'; import type { ContextResponseMetadata } from '$lib/server/mappers/context-response.mapper'; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function getServices(db: ReturnType) { const searchService = new SearchService(db); // Load the active embedding profile from the database const profileRow = db .prepare<[], EmbeddingProfileEntityProps>( 'SELECT * FROM embedding_profiles WHERE is_default = 1 AND enabled = 1 LIMIT 1' ) .get(); const profile = profileRow ? EmbeddingProfileMapper.fromEntity(new EmbeddingProfileEntity(profileRow)) : null; const provider = profile ? createProviderFromProfile(profile) : null; const hybridService = new HybridSearchService(db, searchService, provider); return { db, searchService, hybridService, profileId: profile?.id }; } interface RawRepoConfig { rules: string | null; } function getRules(db: ReturnType, repositoryId: string): string[] { const row = db .prepare< [string], RawRepoConfig >(`SELECT rules FROM repository_configs WHERE repository_id = ?`) .get(repositoryId); if (!row?.rules) return []; try { const parsed = JSON.parse(row.rules); return Array.isArray(parsed) ? (parsed as string[]) : []; } catch { return []; } } interface RawRepoState { state: 'pending' | 'indexing' | 'indexed' | 'error'; id: string; title: string; source: 'github' | 'local'; source_url: string; branch: string | null; } interface RawVersionRow { id: string; tag: string; } function getSnippetVersionTags( db: ReturnType, versionIds: string[] ): Record { if (versionIds.length === 0) return {}; const placeholders = versionIds.map(() => '?').join(', '); const rows = db .prepare< string[], RawVersionRow >(`SELECT id, tag FROM repository_versions WHERE id IN (${placeholders})`) .all(...versionIds); return Object.fromEntries(rows.map((row) => [row.id, row.tag])); } // --------------------------------------------------------------------------- // Route handler // --------------------------------------------------------------------------- export const GET: RequestHandler = async ({ url }) => { const libraryId = url.searchParams.get('libraryId'); if (!libraryId || !libraryId.trim()) { return new Response( JSON.stringify({ error: 'libraryId is required', code: 'MISSING_PARAMETER' }), { status: 400, headers: { 'Content-Type': 'application/json', ...CORS_HEADERS } } ); } const query = url.searchParams.get('query'); if (!query || !query.trim()) { return new Response(JSON.stringify({ error: 'query is required', code: 'MISSING_PARAMETER' }), { status: 400, headers: { 'Content-Type': 'application/json', ...CORS_HEADERS } }); } const responseType = url.searchParams.get('type') ?? 'json'; const applyTokenBudget = responseType === 'txt' || url.searchParams.has('tokens'); const tokensRaw = parseInt(url.searchParams.get('tokens') ?? String(DEFAULT_TOKEN_BUDGET), 10); const maxTokens = isNaN(tokensRaw) || tokensRaw < 1 ? DEFAULT_TOKEN_BUDGET : tokensRaw; // Parse searchMode and alpha const rawMode = url.searchParams.get('searchMode') ?? 'auto'; const searchMode = ['auto', 'keyword', 'semantic', 'hybrid'].includes(rawMode) ? (rawMode as 'auto' | 'keyword' | 'semantic' | 'hybrid') : 'auto'; const alphaRaw = parseFloat(url.searchParams.get('alpha') ?? '0.5'); const alpha = isNaN(alphaRaw) ? 0.5 : Math.max(0, Math.min(1, alphaRaw)); // Parse the libraryId let parsed: ReturnType; try { parsed = parseLibraryId(libraryId); } catch { return new Response( JSON.stringify({ error: `Invalid libraryId: ${libraryId}`, code: 'MISSING_PARAMETER' }), { status: 400, headers: { 'Content-Type': 'application/json', ...CORS_HEADERS } } ); } try { const db = getClient(); const { hybridService, profileId } = getServices(db); // Verify the repository exists and check its state. const repo = db .prepare< [string], RawRepoState >(`SELECT id, state, title, source, source_url, branch FROM repositories WHERE id = ?`) .get(parsed.repositoryId); if (!repo) { return new Response( JSON.stringify({ error: `Library ${parsed.repositoryId} not found or not yet indexed`, code: 'LIBRARY_NOT_FOUND' }), { status: 404, headers: { 'Content-Type': 'application/json', ...CORS_HEADERS } } ); } if (repo.state === 'indexing' || repo.state === 'pending') { return new Response( JSON.stringify({ error: 'Library is currently being indexed, please try again shortly', code: 'INDEXING_IN_PROGRESS' }), { status: 503, headers: { 'Content-Type': 'application/json', ...CORS_HEADERS } } ); } // Resolve version ID if a specific version was requested. let versionId: string | undefined; let resolvedVersion: RawVersionRow | undefined; if (parsed.version) { resolvedVersion = db .prepare< [string, string], RawVersionRow >(`SELECT id, tag FROM repository_versions WHERE repository_id = ? AND tag = ?`) .get(parsed.repositoryId, parsed.version); // Version not found is not fatal — fall back to default branch. versionId = resolvedVersion?.id; } // Execute hybrid search (falls back to FTS5 when no embedding provider is set). const searchResults = await hybridService.search(query, { repositoryId: parsed.repositoryId, versionId, limit: 50, // fetch more than needed; token budget will trim searchMode, alpha, profileId }); const selectedResults = applyTokenBudget ? (() => { const snippets = searchResults.map((r) => r.snippet); const selected = selectSnippetsWithinBudget(snippets, maxTokens); return selected.map((snippet) => { const found = searchResults.find((r) => r.snippet.id === snippet.id)!; return found; }); })() : searchResults; const snippetVersionIds = Array.from( new Set( selectedResults .map((result) => result.snippet.versionId) .filter((value): value is string => Boolean(value)) ) ); const snippetVersions = getSnippetVersionTags(db, snippetVersionIds); const metadata: ContextResponseMetadata = { localSource: repo.source === 'local', resultCount: selectedResults.length, repository: { id: repo.id, title: repo.title, source: repo.source, sourceUrl: repo.source_url, branch: repo.branch }, version: parsed.version || resolvedVersion ? { requested: parsed.version ?? null, resolved: resolvedVersion?.tag ?? null, id: resolvedVersion?.id ?? null } : null, snippetVersions }; // Load rules from repository_configs. const rules = getRules(db, parsed.repositoryId); if (responseType === 'txt') { const text = formatContextTxt(selectedResults, rules, metadata); return new Response(text, { status: 200, headers: { 'Content-Type': 'text/plain; charset=utf-8', ...CORS_HEADERS } }); } // Default: JSON const body = formatContextJson(selectedResults, rules, metadata); return dtoJsonResponse(body, { status: 200, headers: CORS_HEADERS }); } catch (err) { const message = err instanceof Error ? err.message : 'Internal server error'; return new Response(JSON.stringify({ error: message, code: 'INTERNAL_ERROR' }), { status: 500, headers: { 'Content-Type': 'application/json', ...CORS_HEADERS } }); } }; export const OPTIONS: RequestHandler = () => { return new Response(null, { status: 204, headers: CORS_HEADERS }); };