mirror of
https://github.com/fosrl/docs-v2.git
synced 2026-09-27 08:19:08 +02:00
port mintlify to fumadocs
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import 'server-only';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { createGoogleGenerativeAI } from '@ai-sdk/google';
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
|
||||
/**
|
||||
* Bring-your-own-key model configuration for the docs assistant. Everything comes from
|
||||
* environment variables on the server; keys are never sent to the browser.
|
||||
*
|
||||
* AI_PROVIDER anthropic | openai | google | openai-compatible
|
||||
* (optional: inferred from whichever key is set)
|
||||
* AI_MODEL model id for that provider (optional: sensible default per provider)
|
||||
*
|
||||
* ANTHROPIC_API_KEY for `anthropic`
|
||||
* OPENAI_API_KEY for `openai`
|
||||
* GOOGLE_GENERATIVE_AI_API_KEY for `google`
|
||||
* AI_BASE_URL + AI_API_KEY for `openai-compatible` (OpenRouter, Ollama, vLLM,
|
||||
* LiteLLM, a Pangolin AI Gateway resource, ...)
|
||||
*/
|
||||
export type Provider = 'anthropic' | 'openai' | 'google' | 'openai-compatible';
|
||||
|
||||
const defaultModels: Record<Provider, string | undefined> = {
|
||||
anthropic: 'claude-opus-5',
|
||||
openai: 'gpt-5.6-luna',
|
||||
google: 'gemini-2.5-pro',
|
||||
'openai-compatible': undefined,
|
||||
};
|
||||
|
||||
function detectProvider(): Provider | undefined {
|
||||
const explicit = process.env.AI_PROVIDER?.trim().toLowerCase();
|
||||
if (explicit) {
|
||||
if (explicit in defaultModels) return explicit as Provider;
|
||||
throw new Error(`Unknown AI_PROVIDER "${explicit}"`);
|
||||
}
|
||||
if (process.env.AI_BASE_URL) return 'openai-compatible';
|
||||
if (process.env.ANTHROPIC_API_KEY) return 'anthropic';
|
||||
if (process.env.OPENAI_API_KEY) return 'openai';
|
||||
if (process.env.GOOGLE_GENERATIVE_AI_API_KEY) return 'google';
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export interface ModelConfig {
|
||||
provider: Provider;
|
||||
modelId: string;
|
||||
model: LanguageModel;
|
||||
}
|
||||
|
||||
export class AIConfigError extends Error {}
|
||||
|
||||
export function getModel(): ModelConfig {
|
||||
const provider = detectProvider();
|
||||
if (!provider) {
|
||||
throw new AIConfigError(
|
||||
'The docs assistant is not configured. Set ANTHROPIC_API_KEY, OPENAI_API_KEY, GOOGLE_GENERATIVE_AI_API_KEY, or AI_BASE_URL + AI_API_KEY on the server.',
|
||||
);
|
||||
}
|
||||
|
||||
const modelId = process.env.AI_MODEL?.trim() || defaultModels[provider];
|
||||
if (!modelId) throw new AIConfigError(`AI_MODEL is required for provider "${provider}".`);
|
||||
|
||||
switch (provider) {
|
||||
case 'anthropic':
|
||||
return { provider, modelId, model: createAnthropic()(modelId) };
|
||||
case 'openai':
|
||||
return { provider, modelId, model: createOpenAI()(modelId) };
|
||||
case 'google':
|
||||
return { provider, modelId, model: createGoogleGenerativeAI()(modelId) };
|
||||
case 'openai-compatible': {
|
||||
const baseURL = process.env.AI_BASE_URL;
|
||||
if (!baseURL) throw new AIConfigError('AI_BASE_URL is required for openai-compatible.');
|
||||
const compatible = createOpenAICompatible({
|
||||
name: 'custom',
|
||||
baseURL,
|
||||
apiKey: process.env.AI_API_KEY,
|
||||
});
|
||||
return { provider, modelId, model: compatible.chatModel(modelId) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isAIConfigured() {
|
||||
try {
|
||||
getModel();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'server-only';
|
||||
import { source } from '@/lib/source';
|
||||
import { siteUrl } from '@/lib/shared';
|
||||
|
||||
let pageIndex: string | undefined;
|
||||
|
||||
/** compact list of every page, so the model knows what exists before searching */
|
||||
function getPageIndex() {
|
||||
pageIndex ??= source
|
||||
.getPages()
|
||||
.map((page) => `- ${page.url} | ${page.data.title}${page.data.description ? ` — ${page.data.description}` : ''}`)
|
||||
.sort()
|
||||
.join('\n');
|
||||
return pageIndex;
|
||||
}
|
||||
|
||||
export function getSystemPrompt() {
|
||||
return `You are the documentation assistant for Pangolin, an open-source networking and security platform (identity-aware access to apps, infrastructure, and AI workloads). You answer questions from people reading the docs at ${siteUrl}.
|
||||
|
||||
How to work:
|
||||
- Ground every answer in the documentation. Before answering anything specific (config keys, commands, versions, UI steps, feature availability), use search_docs and read_page to look it up. Read the full page when the answer depends on details.
|
||||
- The page index below lists every page. If a page's title clearly matches the question, you can read it directly.
|
||||
- Cite the pages you used as Markdown links with site-relative URLs, e.g. [Install a Site](/manage/sites/install-site). Link to sections with #anchors when you have them.
|
||||
- If the docs don't cover something, say so plainly and point to the closest relevant page or to the community (Discord: https://pangolin.net/discord, GitHub discussions: https://github.com/fosrl/pangolin/discussions). Do not invent configuration options, flags, or behavior.
|
||||
- Page sources contain MDX components such as <Note>, <Steps>, <Card> or <ResponseField>. Never output those tags; rewrite their content as plain Markdown (blockquotes, lists, headings, tables).
|
||||
- Be concise and practical. Prefer short steps and code blocks that the user can copy. Use the same terminology as the docs (sites, resources, clients, Newt, Olm, Gerbil, Badger, remote nodes).
|
||||
- Pangolin has a hosted Cloud edition and a self-hosted edition (Community and Enterprise). When the answer differs between them, say which one you are describing.
|
||||
- Each user message may include "[Client Context: ...]" with the page the user is currently viewing. Use it to resolve questions like "this page" or "how do I do this".
|
||||
|
||||
Page index (URL | title — description):
|
||||
${getPageIndex()}`;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'server-only';
|
||||
|
||||
/**
|
||||
* Minimal in-memory sliding-window limiter so a public docs site can't be used to burn
|
||||
* through your API credits. It is per server instance; put a shared limiter (or your
|
||||
* reverse proxy's) in front if you run several replicas.
|
||||
*/
|
||||
const windowMs = 60_000;
|
||||
const limit = Number(process.env.AI_RATE_LIMIT_PER_MINUTE ?? 10);
|
||||
const hits = new Map<string, number[]>();
|
||||
|
||||
export function checkRateLimit(key: string): { ok: boolean; retryAfter: number } {
|
||||
if (!Number.isFinite(limit) || limit <= 0) return { ok: true, retryAfter: 0 };
|
||||
|
||||
const now = Date.now();
|
||||
const recent = (hits.get(key) ?? []).filter((t) => now - t < windowMs);
|
||||
if (recent.length >= limit) {
|
||||
hits.set(key, recent);
|
||||
return { ok: false, retryAfter: Math.ceil((windowMs - (now - recent[0])) / 1000) };
|
||||
}
|
||||
|
||||
recent.push(now);
|
||||
hits.set(key, recent);
|
||||
|
||||
// keep the map from growing forever
|
||||
if (hits.size > 10_000) {
|
||||
for (const [k, v] of hits) if (v.every((t) => now - t >= windowMs)) hits.delete(k);
|
||||
}
|
||||
return { ok: true, retryAfter: 0 };
|
||||
}
|
||||
|
||||
export function clientKey(req: Request) {
|
||||
return (
|
||||
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
||||
req.headers.get('x-real-ip') ||
|
||||
'anonymous'
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'server-only';
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { getPageText, source } from '@/lib/source';
|
||||
import { searchServer } from '@/lib/search';
|
||||
|
||||
const MAX_PAGE_CHARS = 60_000;
|
||||
|
||||
function findPage(pathOrUrl: string) {
|
||||
let path = pathOrUrl.trim();
|
||||
try {
|
||||
path = new URL(path, 'http://x').pathname;
|
||||
} catch {
|
||||
// not a URL, use as is
|
||||
}
|
||||
path = path.replace(/\.mdx?$/, '').replace(/^\/+|\/+$/g, '');
|
||||
const slugs = path === '' || path === 'index' ? [] : path.split('/');
|
||||
return source.getPage(slugs);
|
||||
}
|
||||
|
||||
export const searchDocs = tool({
|
||||
description:
|
||||
'Full-text search over the Pangolin documentation. Returns matching pages and sections with their URLs. Use several short keyword queries rather than one long question.',
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('keywords, e.g. "newt docker compose" or "OIDC auto provisioning"'),
|
||||
limit: z.number().int().min(1).max(25).default(10),
|
||||
}),
|
||||
async execute({ query, limit }) {
|
||||
const results = await searchServer.search(query, { limit: limit * 3 });
|
||||
const pages = new Map<string, { url: string; title: string; matches: string[] }>();
|
||||
|
||||
for (const result of results) {
|
||||
const url = result.url.split('#')[0];
|
||||
const page = pages.get(url) ?? {
|
||||
url,
|
||||
title: findPage(url)?.data.title ?? url,
|
||||
matches: [],
|
||||
};
|
||||
if (result.type !== 'page' && page.matches.length < 3) {
|
||||
const text = result.content.replace(/<\/?mark>/g, '');
|
||||
page.matches.push(result.type === 'heading' ? `${text} (${result.url})` : text);
|
||||
}
|
||||
pages.set(url, page);
|
||||
if (pages.size >= limit) break;
|
||||
}
|
||||
return [...pages.values()];
|
||||
},
|
||||
});
|
||||
|
||||
export const readPage = tool({
|
||||
description:
|
||||
'Read the full Markdown content of one documentation page. Pass the page URL path from search results or the page index, e.g. "/manage/sites/install-site".',
|
||||
inputSchema: z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
async execute({ path }) {
|
||||
const page = findPage(path);
|
||||
if (!page) return { error: `No page found at "${path}". Use search_docs to find the right URL.` };
|
||||
|
||||
const text = await getPageText(page);
|
||||
if (text.length <= MAX_PAGE_CHARS) return { url: page.url, content: text };
|
||||
return {
|
||||
url: page.url,
|
||||
truncated: true,
|
||||
note: `Page is ${text.length} characters; only the first ${MAX_PAGE_CHARS} are included. Search for specific sections if the answer is not here.`,
|
||||
content: text.slice(0, MAX_PAGE_CHARS),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const tools = {
|
||||
search_docs: searchDocs,
|
||||
read_page: readPage,
|
||||
};
|
||||
Reference in New Issue
Block a user