doccupine 0.0.117 → 0.0.119
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/lib/structures.js +4 -0
- package/dist/templates/app/api/rag/route.d.ts +1 -1
- package/dist/templates/app/api/rag/route.js +146 -116
- package/dist/templates/components/Chat.d.ts +1 -1
- package/dist/templates/components/Chat.js +15 -2
- package/dist/templates/components/layout/DocsComponents.d.ts +1 -1
- package/dist/templates/components/layout/DocsComponents.js +1 -0
- package/dist/templates/gitignore.d.ts +1 -1
- package/dist/templates/gitignore.js +3 -0
- package/dist/templates/mdx/model-context-protocol.mdx.d.ts +1 -1
- package/dist/templates/mdx/model-context-protocol.mdx.js +8 -5
- package/dist/templates/next.config.js +12 -0
- package/dist/templates/package.js +3 -1
- package/dist/templates/pnpmWorkspace.d.ts +1 -1
- package/dist/templates/pnpmWorkspace.js +1 -0
- package/dist/templates/prettierignore.d.ts +1 -1
- package/dist/templates/prettierignore.js +1 -0
- package/dist/templates/scripts/build-docs-index.d.ts +1 -0
- package/dist/templates/scripts/build-docs-index.js +81 -0
- package/dist/templates/services/mcp/docsIndexStub.d.ts +1 -0
- package/dist/templates/services/mcp/docsIndexStub.js +9 -0
- package/dist/templates/services/mcp/server.d.ts +1 -1
- package/dist/templates/services/mcp/server.js +53 -1
- package/package.json +1 -1
package/dist/lib/structures.js
CHANGED
|
@@ -58,10 +58,12 @@ import { mcpIndexTemplate } from "../templates/services/mcp/index.js";
|
|
|
58
58
|
import { mcpServerTemplate } from "../templates/services/mcp/server.js";
|
|
59
59
|
import { mcpToolsTemplate } from "../templates/services/mcp/tools.js";
|
|
60
60
|
import { mcpTypesTemplate } from "../templates/services/mcp/types.js";
|
|
61
|
+
import { docsIndexStubTemplate } from "../templates/services/mcp/docsIndexStub.js";
|
|
61
62
|
import { llmConfigTemplate } from "../templates/services/llm/config.js";
|
|
62
63
|
import { llmFactoryTemplate } from "../templates/services/llm/factory.js";
|
|
63
64
|
import { llmIndexTemplate } from "../templates/services/llm/index.js";
|
|
64
65
|
import { llmTypesTemplate } from "../templates/services/llm/types.js";
|
|
66
|
+
import { buildDocsIndexScriptTemplate } from "../templates/scripts/build-docs-index.js";
|
|
65
67
|
import { posthogServerTemplate } from "../templates/lib/posthog.js";
|
|
66
68
|
import { siteGateTemplate } from "../templates/lib/siteGate.js";
|
|
67
69
|
import { styledDTemplate } from "../templates/types/styled.js";
|
|
@@ -136,10 +138,12 @@ export const appStructure = {
|
|
|
136
138
|
"services/mcp/server.ts": mcpServerTemplate,
|
|
137
139
|
"services/mcp/tools.ts": mcpToolsTemplate,
|
|
138
140
|
"services/mcp/types.ts": mcpTypesTemplate,
|
|
141
|
+
"services/mcp/docs-index.json": docsIndexStubTemplate,
|
|
139
142
|
"services/llm/config.ts": llmConfigTemplate,
|
|
140
143
|
"services/llm/factory.ts": llmFactoryTemplate,
|
|
141
144
|
"services/llm/index.ts": llmIndexTemplate,
|
|
142
145
|
"services/llm/types.ts": llmTypesTemplate,
|
|
146
|
+
"scripts/build-docs-index.mts": buildDocsIndexScriptTemplate,
|
|
143
147
|
"types/styled.d.ts": styledDTemplate,
|
|
144
148
|
"lib/posthog.ts": posthogServerTemplate,
|
|
145
149
|
"lib/siteGate.ts": siteGateTemplate,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const ragRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { getLLMConfig, createChatModel } from \"@/services/llm\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n} from \"@/services/mcp/server\";\nimport { rateLimit } from \"@/utils/rateLimit\";\nimport { config } from \"@/utils/config\";\n\nconst messageSchema = z.object({\n role: z.enum([\"user\", \"assistant\"]),\n content: z.string().max(4000),\n});\n\nconst ragSchema = z.object({\n question: z.string().min(1).max(2000),\n history: z.array(messageSchema).max(20).optional(),\n refresh: z.boolean().optional(),\n});\n\nconst projectName = config.name || \"Doccupine\";\n\nconst systemContext = `You are AI Assistant, a documentation assistant for ${projectName}, Your name is ${projectName} AI Assistant.\n\n## Core Rules\n1. Answer ONLY from the provided context. Never fabricate information.\n2. If the answer isn't in the context, say so clearly and suggest relevant sections or pages the user might check.\n3. If the question is ambiguous, ask a brief clarifying question before answering.\n\n## Response Style\n- Be concise and direct. Lead with the answer, then provide details if needed.\n- Use code examples from the context when relevant.\n- Match the technical level of the user's question.\n\n## MDX/Code Formatting\nWhen including code blocks in your response:\n- Never nest fenced code blocks (triple backticks) inside other fenced code blocks.\n- If you need to show MDX source that itself contains code blocks, use indented code blocks or escape the inner backticks.\n- All output must be valid MDX that renders correctly.\n\n## Internal Links\nEach context chunk includes a \"URL:\" line with the pre-computed page URL. Use it directly when linking:\n- Format links as markdown: [Page Title](/slug/).\n- Never expose raw file paths like \"/app/.../page.tsx\" to the user.\n- Never include route group segments in parentheses, like \"(site)\", in any link - they are internal folder names and do not exist in real URLs. Use \"/code/\" not \"/(site)/code/\".\n- Do NOT add a \"Related Pages\" section at the end - sources are shown separately by the UI.\n\n## Greetings & Small Talk\nIf the user sends a greeting or non-documentation question, respond briefly and ask how you can help with the documentation.`;\n\nexport async function POST(req: Request) {\n // Rate limit by IP\n const ip =\n req.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ?? \"unknown\";\n const { allowed, retryAfter } = rateLimit(ip);\n if (!allowed) {\n return NextResponse.json(\n { error: \"Too many requests\" },\n { status: 429, headers: { \"Retry-After\": String(retryAfter) } },\n );\n }\n\n try {\n
|
|
1
|
+
export declare const ragRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { getLLMConfig, createChatModel } from \"@/services/llm\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n} from \"@/services/mcp/server\";\nimport { rateLimit } from \"@/utils/rateLimit\";\nimport { config } from \"@/utils/config\";\n\nconst messageSchema = z.object({\n role: z.enum([\"user\", \"assistant\"]),\n content: z.string().max(4000),\n});\n\nconst ragSchema = z.object({\n question: z.string().min(1).max(2000),\n history: z.array(messageSchema).max(20).optional(),\n refresh: z.boolean().optional(),\n});\n\nconst projectName = config.name || \"Doccupine\";\n\nconst systemContext = `You are AI Assistant, a documentation assistant for ${projectName}, Your name is ${projectName} AI Assistant.\n\n## Core Rules\n1. Answer ONLY from the provided context. Never fabricate information.\n2. If the answer isn't in the context, say so clearly and suggest relevant sections or pages the user might check.\n3. If the question is ambiguous, ask a brief clarifying question before answering.\n\n## Response Style\n- Be concise and direct. Lead with the answer, then provide details if needed.\n- Use code examples from the context when relevant.\n- Match the technical level of the user's question.\n\n## MDX/Code Formatting\nWhen including code blocks in your response:\n- Never nest fenced code blocks (triple backticks) inside other fenced code blocks.\n- If you need to show MDX source that itself contains code blocks, use indented code blocks or escape the inner backticks.\n- All output must be valid MDX that renders correctly.\n\n## Internal Links\nEach context chunk includes a \"URL:\" line with the pre-computed page URL. Use it directly when linking:\n- Format links as markdown: [Page Title](/slug/).\n- Never expose raw file paths like \"/app/.../page.tsx\" to the user.\n- Never include route group segments in parentheses, like \"(site)\", in any link - they are internal folder names and do not exist in real URLs. Use \"/code/\" not \"/(site)/code/\".\n- Do NOT add a \"Related Pages\" section at the end - sources are shown separately by the UI.\n\n## Greetings & Small Talk\nIf the user sends a greeting or non-documentation question, respond briefly and ask how you can help with the documentation.`;\n\n// LangChain + the MCP SDK require the Node.js runtime (not edge).\nexport const runtime = \"nodejs\";\n// Safety net for the streaming function (Vercel-only; ignored elsewhere).\n// The heartbeat below, not this value, is what prevents proxy 524 timeouts.\nexport const maxDuration = 60;\n\nexport async function POST(req: Request) {\n // Rate limit by IP\n const ip =\n req.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ?? \"unknown\";\n const { allowed, retryAfter } = rateLimit(ip);\n if (!allowed) {\n return NextResponse.json(\n { error: \"Too many requests\" },\n { status: 429, headers: { \"Retry-After\": String(retryAfter) } },\n );\n }\n\n // Validate the request up front so genuine client/config errors still return\n // real status codes before we commit to a 200 streaming response.\n let body: unknown;\n try {\n body = await req.json();\n } catch {\n return NextResponse.json({ error: \"Invalid JSON body\" }, { status: 400 });\n }\n\n const parsed = ragSchema.safeParse(body);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid input\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n const { question, history, refresh } = parsed.data;\n\n let llmConfig;\n try {\n llmConfig = getLLMConfig();\n } catch (error: unknown) {\n const message =\n error instanceof Error ? error.message : \"LLM configuration error\";\n return NextResponse.json({ error: message }, { status: 500 });\n }\n\n const encoder = new TextEncoder();\n let heartbeat: ReturnType<typeof setInterval> | null = null;\n let streamClosed = false;\n\n // Return the streaming response immediately and do ALL slow work (indexing,\n // search, model streaming) inside start(). This flushes headers + a first\n // byte right away so edge proxies never hit their time-to-first-byte timeout.\n const readableStream = new ReadableStream({\n async start(controller) {\n const safeEnqueue = (payload: string) => {\n if (streamClosed) return;\n try {\n controller.enqueue(encoder.encode(payload));\n } catch {\n // Stream already closed or cancelled - stop emitting.\n streamClosed = true;\n if (heartbeat) clearInterval(heartbeat);\n }\n };\n\n // Keep the connection alive while the index and model warm up.\n heartbeat = setInterval(() => safeEnqueue(`: keep-alive\\n\\n`), 15000);\n\n try {\n // First byte, before any slow work - satisfies the proxy TTFB window.\n safeEnqueue(`: connected\\n\\n`);\n\n // Ensure docs are indexed (loads precomputed embeddings when present).\n await ensureDocsIndex(Boolean(refresh));\n\n // Use MCP search_docs tool to find relevant documentation\n const searchResults = await searchDocs(question, 6);\n\n // Build context from search results\n const context = searchResults\n .map(({ chunk, score }) => {\n const slug = chunk.uri.replace(\"docs://\", \"\").replace(/^\\/+/, \"\");\n const url = slug ? `/${slug}/` : \"/\";\n return `File: ${chunk.path}\\nURL: ${url}\\nScore: ${score.toFixed(3)}\\n----\\n${chunk.text}`;\n })\n .join(\"\\n\\n================\\n\\n\");\n\n // Build metadata from MCP search results and send it before the answer.\n const indexStatus = getIndexStatus();\n const metadata = {\n sources: searchResults.map(({ chunk, score }) => ({\n id: chunk.id,\n path: chunk.path,\n uri: chunk.uri,\n score,\n })),\n chunkCount: indexStatus.chunkCount,\n };\n safeEnqueue(\n `data: ${JSON.stringify({ type: \"metadata\", data: metadata })}\\n\\n`,\n );\n\n // Assemble the prompt, including conversation history for multi-turn.\n const prompt: {\n role: \"system\" | \"user\" | \"assistant\";\n content: string;\n }[] = [\n {\n role: \"system\" as const,\n content: systemContext,\n },\n ];\n if (history && history.length > 0) {\n for (const msg of history) {\n prompt.push({\n role: msg.role,\n content: msg.content,\n });\n }\n }\n prompt.push({\n role: \"user\" as const,\n content: `Question: ${question}\\n\\nContext:\\n${context}`,\n });\n\n // Create chat model and stream the response.\n const llm = createChatModel(llmConfig);\n const stream = await llm.stream(prompt);\n for await (const chunk of stream) {\n const content = chunk?.content || \"\";\n if (content) {\n safeEnqueue(\n `data: ${JSON.stringify({ type: \"content\", data: content })}\\n\\n`,\n );\n }\n }\n\n safeEnqueue(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`);\n } catch (error: unknown) {\n const message = error instanceof Error ? error.message : \"Stream error\";\n safeEnqueue(\n `data: ${JSON.stringify({ type: \"error\", data: message })}\\n\\n`,\n );\n } finally {\n if (heartbeat) clearInterval(heartbeat);\n streamClosed = true;\n try {\n controller.close();\n } catch {\n // Already closed.\n }\n }\n },\n cancel() {\n streamClosed = true;\n if (heartbeat) clearInterval(heartbeat);\n },\n });\n\n return new Response(readableStream, {\n headers: {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache, no-transform\",\n },\n });\n}\n\nexport async function GET() {\n const status = getIndexStatus();\n return NextResponse.json({\n ready: status.ready,\n chunks: status.chunkCount,\n });\n}\n";
|
|
@@ -50,6 +50,12 @@ Each context chunk includes a "URL:" line with the pre-computed page URL. Use it
|
|
|
50
50
|
## Greetings & Small Talk
|
|
51
51
|
If the user sends a greeting or non-documentation question, respond briefly and ask how you can help with the documentation.\`;
|
|
52
52
|
|
|
53
|
+
// LangChain + the MCP SDK require the Node.js runtime (not edge).
|
|
54
|
+
export const runtime = "nodejs";
|
|
55
|
+
// Safety net for the streaming function (Vercel-only; ignored elsewhere).
|
|
56
|
+
// The heartbeat below, not this value, is what prevents proxy 524 timeouts.
|
|
57
|
+
export const maxDuration = 60;
|
|
58
|
+
|
|
53
59
|
export async function POST(req: Request) {
|
|
54
60
|
// Rate limit by IP
|
|
55
61
|
const ip =
|
|
@@ -62,129 +68,153 @@ export async function POST(req: Request) {
|
|
|
62
68
|
);
|
|
63
69
|
}
|
|
64
70
|
|
|
71
|
+
// Validate the request up front so genuine client/config errors still return
|
|
72
|
+
// real status codes before we commit to a 200 streaming response.
|
|
73
|
+
let body: unknown;
|
|
74
|
+
try {
|
|
75
|
+
body = await req.json();
|
|
76
|
+
} catch {
|
|
77
|
+
return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const parsed = ragSchema.safeParse(body);
|
|
81
|
+
if (!parsed.success) {
|
|
82
|
+
return NextResponse.json(
|
|
83
|
+
{ error: "Invalid input", details: parsed.error.issues },
|
|
84
|
+
{ status: 400 },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const { question, history, refresh } = parsed.data;
|
|
88
|
+
|
|
89
|
+
let llmConfig;
|
|
65
90
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
91
|
+
llmConfig = getLLMConfig();
|
|
92
|
+
} catch (error: unknown) {
|
|
93
|
+
const message =
|
|
94
|
+
error instanceof Error ? error.message : "LLM configuration error";
|
|
95
|
+
return NextResponse.json({ error: message }, { status: 500 });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const encoder = new TextEncoder();
|
|
99
|
+
let heartbeat: ReturnType<typeof setInterval> | null = null;
|
|
100
|
+
let streamClosed = false;
|
|
101
|
+
|
|
102
|
+
// Return the streaming response immediately and do ALL slow work (indexing,
|
|
103
|
+
// search, model streaming) inside start(). This flushes headers + a first
|
|
104
|
+
// byte right away so edge proxies never hit their time-to-first-byte timeout.
|
|
105
|
+
const readableStream = new ReadableStream({
|
|
106
|
+
async start(controller) {
|
|
107
|
+
const safeEnqueue = (payload: string) => {
|
|
108
|
+
if (streamClosed) return;
|
|
109
|
+
try {
|
|
110
|
+
controller.enqueue(encoder.encode(payload));
|
|
111
|
+
} catch {
|
|
112
|
+
// Stream already closed or cancelled - stop emitting.
|
|
113
|
+
streamClosed = true;
|
|
114
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
// Keep the connection alive while the index and model warm up.
|
|
119
|
+
heartbeat = setInterval(() => safeEnqueue(\`: keep-alive\\n\\n\`), 15000);
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
// First byte, before any slow work - satisfies the proxy TTFB window.
|
|
123
|
+
safeEnqueue(\`: connected\\n\\n\`);
|
|
124
|
+
|
|
125
|
+
// Ensure docs are indexed (loads precomputed embeddings when present).
|
|
126
|
+
await ensureDocsIndex(Boolean(refresh));
|
|
127
|
+
|
|
128
|
+
// Use MCP search_docs tool to find relevant documentation
|
|
129
|
+
const searchResults = await searchDocs(question, 6);
|
|
130
|
+
|
|
131
|
+
// Build context from search results
|
|
132
|
+
const context = searchResults
|
|
133
|
+
.map(({ chunk, score }) => {
|
|
134
|
+
const slug = chunk.uri.replace("docs://", "").replace(/^\\/+/, "");
|
|
135
|
+
const url = slug ? \`/\${slug}/\` : "/";
|
|
136
|
+
return \`File: \${chunk.path}\\nURL: \${url}\\nScore: \${score.toFixed(3)}\\n----\\n\${chunk.text}\`;
|
|
137
|
+
})
|
|
138
|
+
.join("\\n\\n================\\n\\n");
|
|
139
|
+
|
|
140
|
+
// Build metadata from MCP search results and send it before the answer.
|
|
141
|
+
const indexStatus = getIndexStatus();
|
|
142
|
+
const metadata = {
|
|
143
|
+
sources: searchResults.map(({ chunk, score }) => ({
|
|
144
|
+
id: chunk.id,
|
|
145
|
+
path: chunk.path,
|
|
146
|
+
uri: chunk.uri,
|
|
147
|
+
score,
|
|
148
|
+
})),
|
|
149
|
+
chunkCount: indexStatus.chunkCount,
|
|
150
|
+
};
|
|
151
|
+
safeEnqueue(
|
|
152
|
+
\`data: \${JSON.stringify({ type: "metadata", data: metadata })}\\n\\n\`,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Assemble the prompt, including conversation history for multi-turn.
|
|
156
|
+
const prompt: {
|
|
157
|
+
role: "system" | "user" | "assistant";
|
|
158
|
+
content: string;
|
|
159
|
+
}[] = [
|
|
160
|
+
{
|
|
161
|
+
role: "system" as const,
|
|
162
|
+
content: systemContext,
|
|
163
|
+
},
|
|
164
|
+
];
|
|
165
|
+
if (history && history.length > 0) {
|
|
166
|
+
for (const msg of history) {
|
|
167
|
+
prompt.push({
|
|
168
|
+
role: msg.role,
|
|
169
|
+
content: msg.content,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
113
173
|
prompt.push({
|
|
114
|
-
role:
|
|
115
|
-
content:
|
|
174
|
+
role: "user" as const,
|
|
175
|
+
content: \`Question: \${question}\\n\\nContext:\\n\${context}\`,
|
|
116
176
|
});
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
// Build metadata from MCP search results
|
|
128
|
-
const indexStatus = getIndexStatus();
|
|
129
|
-
const metadata = {
|
|
130
|
-
sources: searchResults.map(({ chunk, score }) => ({
|
|
131
|
-
id: chunk.id,
|
|
132
|
-
path: chunk.path,
|
|
133
|
-
uri: chunk.uri,
|
|
134
|
-
score,
|
|
135
|
-
})),
|
|
136
|
-
chunkCount: indexStatus.chunkCount,
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
const encoder = new TextEncoder();
|
|
140
|
-
const readableStream = new ReadableStream({
|
|
141
|
-
async start(controller) {
|
|
142
|
-
try {
|
|
143
|
-
controller.enqueue(
|
|
144
|
-
encoder.encode(
|
|
145
|
-
\`data: \${JSON.stringify({ type: "metadata", data: metadata })}\\n\\n\`,
|
|
146
|
-
),
|
|
147
|
-
);
|
|
148
|
-
|
|
149
|
-
for await (const chunk of stream) {
|
|
150
|
-
const content = chunk?.content || "";
|
|
151
|
-
if (content) {
|
|
152
|
-
controller.enqueue(
|
|
153
|
-
encoder.encode(
|
|
154
|
-
\`data: \${JSON.stringify({ type: "content", data: content })}\\n\\n\`,
|
|
155
|
-
),
|
|
156
|
-
);
|
|
157
|
-
}
|
|
177
|
+
|
|
178
|
+
// Create chat model and stream the response.
|
|
179
|
+
const llm = createChatModel(llmConfig);
|
|
180
|
+
const stream = await llm.stream(prompt);
|
|
181
|
+
for await (const chunk of stream) {
|
|
182
|
+
const content = chunk?.content || "";
|
|
183
|
+
if (content) {
|
|
184
|
+
safeEnqueue(
|
|
185
|
+
\`data: \${JSON.stringify({ type: "content", data: content })}\\n\\n\`,
|
|
186
|
+
);
|
|
158
187
|
}
|
|
188
|
+
}
|
|
159
189
|
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
),
|
|
171
|
-
);
|
|
190
|
+
safeEnqueue(\`data: \${JSON.stringify({ type: "done" })}\\n\\n\`);
|
|
191
|
+
} catch (error: unknown) {
|
|
192
|
+
const message = error instanceof Error ? error.message : "Stream error";
|
|
193
|
+
safeEnqueue(
|
|
194
|
+
\`data: \${JSON.stringify({ type: "error", data: message })}\\n\\n\`,
|
|
195
|
+
);
|
|
196
|
+
} finally {
|
|
197
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
198
|
+
streamClosed = true;
|
|
199
|
+
try {
|
|
172
200
|
controller.close();
|
|
201
|
+
} catch {
|
|
202
|
+
// Already closed.
|
|
173
203
|
}
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
cancel() {
|
|
207
|
+
streamClosed = true;
|
|
208
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
return new Response(readableStream, {
|
|
213
|
+
headers: {
|
|
214
|
+
"Content-Type": "text/event-stream",
|
|
215
|
+
"Cache-Control": "no-cache, no-transform",
|
|
216
|
+
},
|
|
217
|
+
});
|
|
188
218
|
}
|
|
189
219
|
|
|
190
220
|
export async function GET() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const chatTemplate = "\"use client\";\nimport React, {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n} from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { Button, IconButton } from \"cherry-styled-components\";\nimport { ArrowUp, LoaderPinwheel, RotateCcw, Sparkles, X } from \"lucide-react\";\nimport remarkGfm from \"remark-gfm\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport { MDXRemote, MDXRemoteSerializeResult } from \"next-mdx-remote\";\nimport { serialize } from \"next-mdx-remote/serialize\";\nimport Link from \"next/link\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { useLockBodyScroll } from \"@/components/LockBodyScroll\";\nimport { useMDXComponents as getMDXComponents } from \"@/components/MDXComponents\";\nimport {\n styledAnchor,\n styledTable,\n stylesLists,\n StyledSmallButton,\n interactiveStyles,\n thinScrollbar,\n} from \"@/components/layout/SharedStyled\";\n\nconst mdxComponents = getMDXComponents({});\n\nconst styledText = css<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: ${({ theme }) => theme.lineHeights.small.lg};\n }\n`;\n\nconst StyledChat = styled.div<{ theme: Theme; $isVisible: boolean }>`\n margin: 0;\n position: fixed;\n top: 0;\n right: 0;\n width: 100%;\n height: calc(100dvh - 90px);\n overflow-y: scroll;\n overflow-x: hidden;\n z-index: 1000;\n padding: 0 20px;\n transition: all 0.3s ease;\n transform: translateX(0);\n background: ${({ theme }) => theme.colors.light};\n -webkit-overflow-scrolling: touch;\n opacity: 1;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${({ $isVisible }) =>\n !$isVisible &&\n css`\n transform: translateX(100%);\n opacity: 0;\n `}\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n`;\n\nconst loadingAnimation = keyframes`\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n`;\n\nconst rotateGradient = keyframes`\n 0% {\n --gradient-angle: 0deg;\n }\n 100% {\n --gradient-angle: 360deg;\n }\n`;\n\nconst pulseGlow = keyframes`\n 0%, 100% {\n opacity: 0.5;\n filter: blur(16px);\n }\n 50% {\n opacity: 1;\n filter: blur(22px);\n }\n`;\n\nconst sparkleFloat = keyframes`\n 0%, 100% {\n opacity: 0;\n transform: translateY(0) scale(0);\n }\n 50% {\n opacity: 0.9;\n transform: translateY(-20px) scale(1);\n }\n`;\n\nconst shimmer = keyframes`\n 0% {\n background-position: 0% center;\n }\n 50% {\n background-position: 100% center;\n }\n 100% {\n background-position: 0% center;\n }\n`;\n\nconst StyledRainbowInputWrapper = styled.div<{\n theme: Theme;\n $isActive: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n flex: 1;\n\n &::before {\n content: \"\";\n position: absolute;\n inset: -2px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: 0;\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -10px;\n border-radius: 20px;\n background: conic-gradient(\n from var(--gradient-angle),\n #ff6b6b66,\n #feca5766,\n #48dbfb66,\n #ff9ff366,\n #54a0ff66,\n #5f27cd66,\n #ff6b6b66\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -1;\n pointer-events: none;\n }\n\n &:hover::before,\n &:focus-within::before {\n opacity: 1;\n }\n\n &:hover::after,\n &:focus-within::after {\n opacity: 1;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledSparkleContainer = styled.div<{ $isActive: boolean }>`\n position: absolute;\n inset: -30px;\n pointer-events: none;\n overflow: hidden;\n border-radius: 30px;\n z-index: -2;\n opacity: 0;\n transition: opacity 0.4s ease;\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n opacity: 1;\n `}\n`;\n\nconst StyledSparkle = styled.div<{\n $color: string;\n $left: number;\n $top: number;\n $delay: number;\n}>`\n position: absolute;\n width: 4px;\n height: 4px;\n border-radius: 50%;\n background: ${({ $color }) => $color};\n box-shadow: 0 0 6px ${({ $color }) => $color};\n left: ${({ $left }) => $left}%;\n top: ${({ $top }) => $top}%;\n animation: ${sparkleFloat} 2s ease-in-out infinite;\n animation-delay: ${({ $delay }) => $delay}s;\n`;\n\nconst StyledRainbowInput = styled.input<{ theme: Theme }>`\n position: relative;\n z-index: 1;\n width: 100%;\n background: ${({ theme }) => theme.colors.light};\n border: 1px solid ${({ theme }) => theme.colors.grayLight};\n border-radius: 12px;\n padding: 12px 18px;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-family: inherit;\n color: ${({ theme }) => theme.colors.dark};\n outline: none;\n transition:\n border-color 0.3s ease,\n box-shadow 0.3s ease;\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n }\n\n &::placeholder {\n color: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.dark} 40%, transparent)`};\n transition: color 0.3s ease;\n }\n\n &:focus::placeholder {\n color: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.dark} 60%, transparent)`};\n }\n\n &:focus {\n border-color: transparent;\n }\n`;\n\nconst StyledRainbowButton = styled(Button)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n padding-top: 10px;\n padding-bottom: 10px;\n position: relative;\n overflow: hidden;\n transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;\n color: ${({ theme }) => theme.colors.surface};\n\n &::before {\n content: \"\";\n position: absolute;\n inset: 0;\n background: linear-gradient(\n 135deg,\n #ff6b6b,\n #feca57,\n #48dbfb,\n #ff9ff3,\n #54a0ff\n );\n background-size: 300% 300%;\n opacity: 0;\n transition: opacity 0.3s ease;\n z-index: 0;\n animation: ${shimmer} 3s linear infinite;\n width: 200%;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n `}\n\n &:hover::before {\n opacity: 1;\n }\n\n &:hover {\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n\n & svg {\n position: relative;\n z-index: 1;\n transition: transform 0.3s ease;\n }\n\n &:disabled,\n &:disabled:hover {\n background: ${({ theme }) => theme.colors.primaryDark};\n transform: none;\n box-shadow: none;\n\n &::before {\n opacity: 0;\n }\n }\n`;\n\nconst StyledChatForm = styled.form<{ theme: Theme; $isVisible: boolean }>`\n display: flex;\n gap: 10px;\n justify-content: center;\n align-items: center;\n background: ${({ theme }) => theme.colors.light};\n padding: 20px;\n position: fixed;\n bottom: 0;\n right: 0;\n z-index: 1000;\n width: 100%;\n border-top: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: all 0.3s ease;\n transform: translateX(100%);\n opacity: 0;\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n\n ${({ $isVisible }) =>\n $isVisible &&\n css`\n opacity: 1;\n transform: translateX(0);\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledGlowSmallButton = styled(StyledSmallButton)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n isolation: isolate;\n margin-right: 0;\n background: ${({ theme }) => theme.colors.light};\n padding: 0;\n\n &::before {\n content: \"\";\n inset: -2px;\n border-radius: 8px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: -1;\n position: absolute;\n top: -2px;\n left: -2px;\n width: calc(100% + 4px);\n height: calc(100% + 4px);\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -8px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n #ff6b6b66,\n #feca5766,\n #48dbfb66,\n #ff9ff366,\n #54a0ff66,\n #5f27cd66,\n #ff6b6b66\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -2;\n pointer-events: none;\n }\n\n &:hover::before,\n &:hover::after {\n opacity: 1;\n }\n\n & span {\n padding: 6px 8px;\n display: flex;\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n gap: 6px;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledError = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n ${thinScrollbar};\n background: ${({ theme }) => theme.colors.error};\n color: ${({ theme }) => theme.colors.surface};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\n ${styledText};\n`;\n\nconst loadingDotAnimation = keyframes`\n 0% {\n opacity: 0;\n }\n 50% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n`;\n\nconst StyledLoading = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n ${thinScrollbar};\n margin: 20px 0;\n width: 100%;\n font-weight: 600;\n ${styledText};\n color: ${({ theme }) => theme.colors.dark};\n\n & span {\n &:nth-child(1) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n }\n &:nth-child(2) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.2s;\n }\n &:nth-child(3) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.4s;\n }\n }\n`;\n\nconst StyledAnswer = styled.div<{ theme: Theme; $isAnswer: boolean }>`\n overflow-x: auto;\n ${thinScrollbar};\n background: ${({ theme }) => theme.colors.primary};\n color: ${({ theme }) => theme.colors.surface};\n padding: 10px 15px;\n border-radius: 18px;\n margin: 20px 0 20px auto;\n width: fit-content;\n ${styledText};\n\n & p {\n ${styledText};\n }\n\n ${({ $isAnswer }) =>\n $isAnswer &&\n css`\n background: transparent;\n color: ${({ theme }) => theme.colors.dark};\n padding: 0;\n width: 100%;\n max-width: 100%;\n margin: 20px 0;\n border-radius: 0;\n `}\n\n & code:not([class]) {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 20%, transparent)`};\n color: ${({ theme }) => theme.colors.dark};\n padding: 2px 4px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n white-space: pre;\n }\n\n ${styledAnchor};\n ${stylesLists};\n ${styledTable};\n\n & pre,\n & .hljs {\n margin: 10px 0;\n }\n\n & .code-wrapper pre {\n margin: 0;\n ${styledText};\n }\n\n & > *:first-child {\n margin-top: 0;\n }\n\n & > *:last-child {\n margin-bottom: 0;\n\n & > *:last-child {\n margin-bottom: 0;\n }\n }\n\n & ul,\n & ol {\n & li {\n ${styledText};\n }\n }\n\n & img,\n & video,\n & iframe {\n max-width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n margin: 10px 0;\n display: block;\n }\n\n & h1,\n & h2,\n & h3,\n & h4,\n & h5,\n & h6 {\n margin: 10px 0;\n padding: 0;\n }\n`;\n\nconst StyledSources = styled.div`\n display: flex;\n gap: 16px;\n flex-wrap: wrap;\n margin: -5px 0 20px;\n`;\n\nconst StyledSourceLink = styled(Link)<{ theme: Theme }>`\n position: relative;\n text-decoration: none;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: 1;\n color: ${({ theme }) => theme.colors.primary};\n display: flex;\n gap: 6px;\n transition: all 0.3s ease;\n font-weight: 600;\n white-space: nowrap;\n min-width: fit-content;\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 10%, transparent)`};\n padding: 6px 8px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n ${interactiveStyles};\n\n & * {\n margin: auto 0;\n }\n\n &:hover {\n color: ${({ theme }) => theme.colors.accent};\n }\n`;\n\nconst StyledChatTitle = styled.div<{ theme: Theme }>`\n display: flex;\n flex-wrap: nowrap;\n justify-content: space-between;\n position: sticky;\n margin: 0 -20px;\n padding: 16px 20px;\n height: 62px;\n top: 0;\n background: ${({ theme }) => theme.colors.light};\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n z-index: 1000;\n`;\n\nconst StyledChatTitleIconWrapper = styled.span<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\ntype Source = {\n id: string;\n path: string;\n uri: string;\n score: number;\n};\n\ntype Answer = {\n text: string;\n answer?: boolean;\n mdx?: MDXRemoteSerializeResult;\n sources?: Source[];\n};\n\nconst SPARKLE_COLORS = [\n \"#ff6b6b\",\n \"#feca57\",\n \"#48dbfb\",\n \"#ff9ff3\",\n \"#54a0ff\",\n \"#5f27cd\",\n];\n\n// Deterministic sparkle positions to avoid hydration mismatch\nconst SPARKLE_POSITIONS = [\n { left: 8, top: 35 },\n { left: 17, top: 55 },\n { left: 26, top: 28 },\n { left: 35, top: 68 },\n { left: 44, top: 42 },\n { left: 53, top: 75 },\n { left: 62, top: 32 },\n { left: 71, top: 58 },\n { left: 80, top: 45 },\n { left: 89, top: 65 },\n];\n\ninterface RainbowInputProps {\n id?: string;\n value: string;\n onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;\n placeholder?: string;\n autoComplete?: string;\n \"aria-label\"?: string;\n inputRef?: React.Ref<HTMLInputElement>;\n}\n\nfunction RainbowInput({\n id,\n value,\n onChange,\n placeholder,\n autoComplete,\n \"aria-label\": ariaLabel,\n inputRef,\n}: RainbowInputProps) {\n const [isFocused, setIsFocused] = useState(false);\n const [isHovered, setIsHovered] = useState(false);\n const isActive = isFocused || isHovered;\n\n const sparkles = SPARKLE_POSITIONS.map((pos, i) => ({\n color: SPARKLE_COLORS[i % SPARKLE_COLORS.length],\n left: pos.left,\n top: pos.top,\n delay: i * 0.12,\n }));\n\n return (\n <StyledRainbowInputWrapper\n $isActive={isActive}\n onMouseEnter={() => setIsHovered(true)}\n onMouseLeave={() => setIsHovered(false)}\n >\n <StyledSparkleContainer $isActive={isActive}>\n {sparkles.map((sparkle, i) => (\n <StyledSparkle\n key={i}\n $color={sparkle.color}\n $left={sparkle.left}\n $top={sparkle.top}\n $delay={sparkle.delay}\n />\n ))}\n </StyledSparkleContainer>\n <StyledRainbowInput\n ref={inputRef}\n id={id}\n value={value}\n onChange={onChange}\n placeholder={placeholder}\n autoComplete={autoComplete}\n aria-label={ariaLabel}\n onFocus={() => setIsFocused(true)}\n onBlur={() => setIsFocused(false)}\n />\n </StyledRainbowInputWrapper>\n );\n}\n\nfunction ChatButtonCTA() {\n const { toggleChat, isOpen } = useContext(ChatContext);\n\n return (\n <StyledGlowSmallButton\n onClick={toggleChat}\n aria-label=\"Ask AI Assistant\"\n $hasContent={isOpen}\n type=\"button\"\n >\n <span>\n <Sparkles size={16} />\n Ask AI\n </span>\n </StyledGlowSmallButton>\n );\n}\n\nfunction Chat() {\n const {\n isOpen,\n question,\n setQuestion,\n loading,\n error,\n answer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n } = useContext(ChatContext);\n const endRef = useRef<HTMLDivElement | null>(null);\n\n useLockBodyScroll(isOpen);\n\n useEffect(() => {\n endRef.current?.scrollIntoView({ behavior: \"smooth\", block: \"end\" });\n }, [answer]);\n\n useEffect(() => {\n if (answer?.length > 0) {\n chatInputRef.current?.focus();\n }\n }, [answer, chatInputRef]);\n\n return (\n <>\n <StyledChat $isVisible={isOpen}>\n <StyledChatTitle>\n <StyledChatTitleIconWrapper>\n <Sparkles />\n <h3>AI Assistant</h3>\n </StyledChatTitleIconWrapper>\n <StyledChatTitleIconWrapper>\n <IconButton\n onClick={resetChat}\n aria-label=\"Reset chat history\"\n title=\"Reset chat history\"\n >\n <RotateCcw />\n </IconButton>\n <IconButton\n onClick={closeChat}\n aria-label=\"Close chat\"\n title=\"Close chat\"\n >\n <X />\n </IconButton>\n </StyledChatTitleIconWrapper>\n </StyledChatTitle>\n {answer &&\n answer.map((a, i) => (\n <React.Fragment key={i}>\n <StyledAnswer $isAnswer={a.answer ?? false}>\n {a.answer && a.mdx ? (\n <MDXRemote {...a.mdx} components={mdxComponents} />\n ) : (\n a.text\n )}\n </StyledAnswer>\n {a.answer && a.sources && a.sources.length > 0 && (\n <StyledSources>\n {a.sources.map((src) => {\n const slug = src.uri\n .replace(\"docs://\", \"\")\n .replace(/^\\/+/, \"\");\n const href = slug ? `/${slug}/` : \"/\";\n const label = slug\n ? slug\n .split(\"/\")\n .pop()!\n .replace(/-/g, \" \")\n .replace(/\\b\\w/g, (c: string) => c.toUpperCase())\n : \"Home\";\n return (\n <StyledSourceLink\n key={src.id}\n href={href}\n onClick={() => {\n if (window.innerWidth <= 992) {\n closeChat();\n }\n }}\n >\n {label}\n </StyledSourceLink>\n );\n })}\n </StyledSources>\n )}\n </React.Fragment>\n ))}\n {loading && (\n <StyledLoading>\n Answering<span>.</span>\n <span>.</span>\n <span>.</span>\n </StyledLoading>\n )}\n {error && (\n <StyledError>\n <strong>Error:</strong> {error}\n </StyledError>\n )}\n <div ref={endRef} />\n </StyledChat>\n\n <StyledChatForm onSubmit={ask} $isVisible={isOpen}>\n <RainbowInput\n id=\"chat-bottom-input\"\n inputRef={chatInputRef}\n value={question}\n onChange={(e) => setQuestion(e.target.value)}\n placeholder=\"Ask AI Assistant...\"\n autoComplete=\"off\"\n aria-label=\"Ask a follow-up question\"\n />\n <StyledRainbowButton\n type=\"submit\"\n disabled={loading || question.trim() === \"\"}\n $hasContent={question.trim().length > 0}\n aria-label={loading ? \"Loading response\" : \"Submit question\"}\n >\n {loading ? <LoaderPinwheel className=\"loading\" /> : <ArrowUp />}\n </StyledRainbowButton>\n </StyledChatForm>\n </>\n );\n}\n\nconst ChatContext = createContext<{\n isOpen: boolean;\n setIsOpen: (isOpen: boolean) => void;\n toggleChat: () => void;\n isChatActive: boolean;\n question: string;\n setQuestion: (q: string) => void;\n loading: boolean;\n error: string | null;\n answer: Answer[];\n setAnswer: (answers: Answer[]) => void;\n ask: (e: React.FormEvent) => void;\n closeChat: () => void;\n resetChat: () => void;\n chatInputRef: React.RefObject<HTMLInputElement | null>;\n}>({\n isOpen: false,\n setIsOpen: () => {},\n toggleChat: () => {},\n isChatActive: false,\n question: \"\",\n setQuestion: () => {},\n loading: false,\n error: null,\n answer: [],\n setAnswer: () => {},\n ask: () => {},\n closeChat: () => {},\n resetChat: () => {},\n chatInputRef: { current: null },\n});\n\ninterface ChatContextProviderProps {\n children: React.ReactNode;\n isChatActive: boolean;\n}\n\nconst ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {\n const [isOpen, setIsOpen] = useState(false);\n const [question, setQuestion] = useState(\"\");\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [answer, setAnswer] = useState<Answer[]>([]);\n const abortRef = useRef<AbortController | null>(null);\n const chatInputRef = useRef<HTMLInputElement | null>(null);\n const isOpenRef = useRef(isOpen);\n\n useEffect(() => {\n isOpenRef.current = isOpen;\n }, [isOpen]);\n\n // Open the assistant, seeding a greeting the first time and focusing the\n // input once the panel has slid in (matches the 0.3s panel transition).\n const openChat = useCallback(() => {\n setIsOpen(true);\n setAnswer((prev) =>\n prev.length === 0\n ? [{ text: \"Hey there, how can I assist you?\", answer: true }]\n : prev,\n );\n setTimeout(() => {\n chatInputRef.current?.focus();\n }, 350);\n }, []);\n\n const toggleChat = useCallback(() => {\n if (isOpenRef.current) {\n setIsOpen(false);\n } else {\n openChat();\n }\n }, [openChat]);\n\n // Global Cmd/Ctrl+I toggles the assistant, but only when chat is enabled.\n useEffect(() => {\n if (!isChatActive) return;\n function handleKeyDown(e: KeyboardEvent) {\n if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"i\") {\n e.preventDefault();\n toggleChat();\n }\n }\n document.addEventListener(\"keydown\", handleKeyDown);\n return () => document.removeEventListener(\"keydown\", handleKeyDown);\n }, [isChatActive, toggleChat]);\n\n async function ask(e: React.FormEvent) {\n e.preventDefault();\n if (loading || question.trim() === \"\") return;\n const currentQuestion = question;\n setQuestion(\"\");\n setIsOpen(true);\n setLoading(true);\n setError(null);\n\n const mergedQuestions =\n answer.length > 0\n ? [...answer, { text: currentQuestion, answer: false }]\n : [{ text: currentQuestion, answer: false }];\n\n setAnswer(mergedQuestions);\n\n const controller = new AbortController();\n abortRef.current = controller;\n\n try {\n const history = answer\n .filter((a) => a.text.trim() !== \"\")\n .map((a) => ({\n role: a.answer ? (\"assistant\" as const) : (\"user\" as const),\n content: a.text,\n }));\n\n const res = await fetch(\"/api/rag\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ question: currentQuestion, history }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n const errorData = await res.json();\n throw new Error(errorData.error || \"Request failed\");\n }\n\n const reader = res.body?.getReader();\n const decoder = new TextDecoder();\n const contentParts: string[] = [];\n let sources: Source[] = [];\n if (!reader) {\n throw new Error(\"Failed to get response reader\");\n }\n\n const streamingAnswerIndex = mergedQuestions.length;\n setAnswer([...mergedQuestions, { text: \"\", answer: true }]);\n\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n const parts = buffer.split(\"\\n\");\n buffer = parts.pop() ?? \"\";\n\n for (const line of parts) {\n if (line.startsWith(\"data: \")) {\n try {\n const data = JSON.parse(line.slice(6));\n\n if (data.type === \"metadata\") {\n const allSources: Source[] = data.data?.sources ?? [];\n const seen = new Set<string>();\n sources = allSources.filter((s: Source) => {\n if (s.score < 0.4 || seen.has(s.uri)) return false;\n seen.add(s.uri);\n return true;\n });\n } else if (data.type === \"content\") {\n contentParts.push(data.data);\n const streamedContent = contentParts.join(\"\");\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n sources,\n };\n return newAnswers;\n });\n } else if (data.type === \"error\") {\n throw new Error(data.data);\n } else if (data.type === \"done\") {\n const streamedContent = contentParts.join(\"\");\n let mdxSource: MDXRemoteSerializeResult | null = null;\n try {\n mdxSource = await serialize(streamedContent, {\n parseFrontmatter: false,\n mdxOptions: {\n remarkPlugins: [remarkGfm],\n rehypePlugins: [rehypeHighlight],\n format: \"md\",\n development: false,\n },\n });\n } catch (mdxError: unknown) {\n console.error(\"MDX serialization error:\", mdxError);\n }\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n mdx: mdxSource || undefined,\n sources,\n };\n return newAnswers;\n });\n }\n } catch (parseError) {\n if (\n parseError instanceof Error &&\n parseError.message !== \"Unknown error\"\n ) {\n console.error(\"Failed to parse SSE data:\", parseError);\n }\n }\n }\n }\n }\n } catch (err: unknown) {\n if (err instanceof DOMException && err.name === \"AbortError\") return;\n setError(err instanceof Error ? err.message : \"Unknown error\");\n } finally {\n abortRef.current = null;\n setLoading(false);\n }\n }\n\n function closeChat() {\n setIsOpen(false);\n }\n\n function resetChat() {\n abortRef.current?.abort();\n setLoading(false);\n setError(null);\n setAnswer([{ text: \"Hey there, how can I assist you?\", answer: true }]);\n }\n\n return (\n <ChatContext.Provider\n value={{\n isOpen,\n setIsOpen,\n toggleChat,\n isChatActive,\n question,\n setQuestion,\n loading,\n error,\n answer,\n setAnswer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n }}\n >\n {children}\n </ChatContext.Provider>\n );\n};\n\nexport { Chat, ChtProvider, ChatContext, ChatButtonCTA };\n";
|
|
1
|
+
export declare const chatTemplate = "\"use client\";\nimport React, {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useRef,\n useState,\n} from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { Button, IconButton } from \"cherry-styled-components\";\nimport { ArrowUp, LoaderPinwheel, RotateCcw, Sparkles, X } from \"lucide-react\";\nimport remarkGfm from \"remark-gfm\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport { MDXRemote, MDXRemoteSerializeResult } from \"next-mdx-remote\";\nimport { serialize } from \"next-mdx-remote/serialize\";\nimport Link from \"next/link\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { useLockBodyScroll } from \"@/components/LockBodyScroll\";\nimport { useMDXComponents as getMDXComponents } from \"@/components/MDXComponents\";\nimport {\n styledAnchor,\n styledTable,\n stylesLists,\n StyledSmallButton,\n interactiveStyles,\n thinScrollbar,\n} from \"@/components/layout/SharedStyled\";\n\nconst mdxComponents = getMDXComponents({});\n\nconst styledText = css<{ theme: Theme }>`\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: ${({ theme }) => theme.lineHeights.small.lg};\n }\n`;\n\nconst StyledChat = styled.div<{ theme: Theme; $isVisible: boolean }>`\n margin: 0;\n position: fixed;\n top: 0;\n right: 0;\n width: 100%;\n height: calc(100dvh - 90px);\n overflow-y: scroll;\n overflow-x: hidden;\n z-index: 1000;\n padding: 0 20px;\n transition: all 0.3s ease;\n transform: translateX(0);\n background: ${({ theme }) => theme.colors.light};\n -webkit-overflow-scrolling: touch;\n opacity: 1;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${({ $isVisible }) =>\n !$isVisible &&\n css`\n transform: translateX(100%);\n opacity: 0;\n `}\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n`;\n\nconst loadingAnimation = keyframes`\n 0% {\n transform: rotate(0deg);\n }\n 100% {\n transform: rotate(360deg);\n }\n`;\n\nconst rotateGradient = keyframes`\n 0% {\n --gradient-angle: 0deg;\n }\n 100% {\n --gradient-angle: 360deg;\n }\n`;\n\nconst pulseGlow = keyframes`\n 0%, 100% {\n opacity: 0.5;\n filter: blur(16px);\n }\n 50% {\n opacity: 1;\n filter: blur(22px);\n }\n`;\n\nconst sparkleFloat = keyframes`\n 0%, 100% {\n opacity: 0;\n transform: translateY(0) scale(0);\n }\n 50% {\n opacity: 0.9;\n transform: translateY(-20px) scale(1);\n }\n`;\n\nconst shimmer = keyframes`\n 0% {\n background-position: 0% center;\n }\n 50% {\n background-position: 100% center;\n }\n 100% {\n background-position: 0% center;\n }\n`;\n\nconst StyledRainbowInputWrapper = styled.div<{\n theme: Theme;\n $isActive: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n flex: 1;\n\n &::before {\n content: \"\";\n position: absolute;\n inset: -2px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: 0;\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -10px;\n border-radius: 20px;\n background: conic-gradient(\n from var(--gradient-angle),\n #ff6b6b66,\n #feca5766,\n #48dbfb66,\n #ff9ff366,\n #54a0ff66,\n #5f27cd66,\n #ff6b6b66\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -1;\n pointer-events: none;\n }\n\n &:hover::before,\n &:focus-within::before {\n opacity: 1;\n }\n\n &:hover::after,\n &:focus-within::after {\n opacity: 1;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledSparkleContainer = styled.div<{ $isActive: boolean }>`\n position: absolute;\n inset: -30px;\n pointer-events: none;\n overflow: hidden;\n border-radius: 30px;\n z-index: -2;\n opacity: 0;\n transition: opacity 0.4s ease;\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n opacity: 1;\n `}\n`;\n\nconst StyledSparkle = styled.div<{\n $color: string;\n $left: number;\n $top: number;\n $delay: number;\n}>`\n position: absolute;\n width: 4px;\n height: 4px;\n border-radius: 50%;\n background: ${({ $color }) => $color};\n box-shadow: 0 0 6px ${({ $color }) => $color};\n left: ${({ $left }) => $left}%;\n top: ${({ $top }) => $top}%;\n animation: ${sparkleFloat} 2s ease-in-out infinite;\n animation-delay: ${({ $delay }) => $delay}s;\n`;\n\nconst StyledRainbowInput = styled.input<{ theme: Theme }>`\n position: relative;\n z-index: 1;\n width: 100%;\n background: ${({ theme }) => theme.colors.light};\n border: 1px solid ${({ theme }) => theme.colors.grayLight};\n border-radius: 12px;\n padding: 12px 18px;\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n font-family: inherit;\n color: ${({ theme }) => theme.colors.dark};\n outline: none;\n transition:\n border-color 0.3s ease,\n box-shadow 0.3s ease;\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n }\n\n &::placeholder {\n color: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.dark} 40%, transparent)`};\n transition: color 0.3s ease;\n }\n\n &:focus::placeholder {\n color: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.dark} 60%, transparent)`};\n }\n\n &:focus {\n border-color: transparent;\n }\n`;\n\nconst StyledRainbowButton = styled(Button)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n padding-top: 10px;\n padding-bottom: 10px;\n position: relative;\n overflow: hidden;\n transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1) !important;\n color: ${({ theme }) => theme.colors.surface};\n\n &::before {\n content: \"\";\n position: absolute;\n inset: 0;\n background: linear-gradient(\n 135deg,\n #ff6b6b,\n #feca57,\n #48dbfb,\n #ff9ff3,\n #54a0ff\n );\n background-size: 300% 300%;\n opacity: 0;\n transition: opacity 0.3s ease;\n z-index: 0;\n animation: ${shimmer} 3s linear infinite;\n width: 200%;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n `}\n\n &:hover::before {\n opacity: 1;\n }\n\n &:hover {\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n\n & svg {\n position: relative;\n z-index: 1;\n transition: transform 0.3s ease;\n }\n\n &:disabled,\n &:disabled:hover {\n background: ${({ theme }) => theme.colors.primaryDark};\n transform: none;\n box-shadow: none;\n\n &::before {\n opacity: 0;\n }\n }\n`;\n\nconst StyledChatForm = styled.form<{ theme: Theme; $isVisible: boolean }>`\n display: flex;\n gap: 10px;\n justify-content: center;\n align-items: center;\n background: ${({ theme }) => theme.colors.light};\n padding: 20px;\n position: fixed;\n bottom: 0;\n right: 0;\n z-index: 1000;\n width: 100%;\n border-top: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: all 0.3s ease;\n transform: translateX(100%);\n opacity: 0;\n\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n\n ${({ $isVisible }) =>\n $isVisible &&\n css`\n opacity: 1;\n transform: translateX(0);\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledGlowSmallButton = styled(StyledSmallButton)<{\n theme: Theme;\n $hasContent: boolean;\n}>`\n @property --gradient-angle {\n syntax: \"<angle>\";\n initial-value: 0deg;\n inherits: false;\n }\n\n position: relative;\n isolation: isolate;\n margin-right: 0;\n background: ${({ theme }) => theme.colors.light};\n padding: 0;\n\n &::before {\n content: \"\";\n inset: -2px;\n border-radius: 8px;\n background: conic-gradient(\n from var(--gradient-angle),\n #cc5555,\n #d9a745,\n #3ab0cc,\n #cc7fc2,\n #4380cc,\n #4c1fa3,\n #cc5555\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation: ${rotateGradient} 3s linear infinite;\n z-index: -1;\n position: absolute;\n top: -2px;\n left: -2px;\n width: calc(100% + 4px);\n height: calc(100% + 4px);\n }\n\n &::after {\n content: \"\";\n position: absolute;\n inset: -8px;\n border-radius: 14px;\n background: conic-gradient(\n from var(--gradient-angle),\n #ff6b6b66,\n #feca5766,\n #48dbfb66,\n #ff9ff366,\n #54a0ff66,\n #5f27cd66,\n #ff6b6b66\n );\n opacity: 0;\n transition: opacity 0.4s cubic-bezier(0.4, 0, 0.2, 1);\n animation:\n ${rotateGradient} 3s linear infinite,\n ${pulseGlow} 2s ease-in-out infinite;\n z-index: -2;\n pointer-events: none;\n }\n\n &:hover::before,\n &:hover::after {\n opacity: 1;\n }\n\n & span {\n padding: 6px 8px;\n display: flex;\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n gap: 6px;\n }\n\n ${({ $hasContent }) =>\n $hasContent &&\n css`\n &::before {\n opacity: 1;\n }\n &::after {\n opacity: 1;\n }\n `}\n`;\n\nconst StyledError = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n ${thinScrollbar};\n background: ${({ theme }) => theme.colors.error};\n color: ${({ theme }) => theme.colors.surface};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\n ${styledText};\n`;\n\nconst loadingDotAnimation = keyframes`\n 0% {\n opacity: 0;\n }\n 50% {\n opacity: 1;\n }\n 100% {\n opacity: 0;\n }\n`;\n\nconst StyledLoading = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n ${thinScrollbar};\n margin: 20px 0;\n width: 100%;\n font-weight: 600;\n ${styledText};\n color: ${({ theme }) => theme.colors.dark};\n\n & span {\n &:nth-child(1) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n }\n &:nth-child(2) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.2s;\n }\n &:nth-child(3) {\n animation: ${loadingDotAnimation} 1s ease infinite;\n animation-delay: 0.4s;\n }\n }\n`;\n\nconst StyledAnswer = styled.div<{ theme: Theme; $isAnswer: boolean }>`\n overflow-x: auto;\n ${thinScrollbar};\n background: ${({ theme }) => theme.colors.primary};\n color: ${({ theme }) => theme.colors.surface};\n padding: 10px 15px;\n border-radius: 18px;\n margin: 20px 0 20px auto;\n width: fit-content;\n ${styledText};\n\n & p {\n ${styledText};\n }\n\n ${({ $isAnswer }) =>\n $isAnswer &&\n css`\n background: transparent;\n color: ${({ theme }) => theme.colors.dark};\n padding: 0;\n width: 100%;\n max-width: 100%;\n margin: 20px 0;\n border-radius: 0;\n `}\n\n & code:not([class]) {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 20%, transparent)`};\n color: ${({ theme }) => theme.colors.dark};\n padding: 2px 4px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n white-space: pre;\n }\n\n ${styledAnchor};\n ${stylesLists};\n ${styledTable};\n\n & pre,\n & .hljs {\n margin: 10px 0;\n }\n\n & .code-wrapper pre {\n margin: 0;\n ${styledText};\n }\n\n & > *:first-child {\n margin-top: 0;\n }\n\n & > *:last-child {\n margin-bottom: 0;\n\n & > *:last-child {\n margin-bottom: 0;\n }\n }\n\n & ul,\n & ol {\n & li {\n ${styledText};\n }\n }\n\n & img,\n & video,\n & iframe {\n max-width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n margin: 10px 0;\n display: block;\n }\n\n & h1,\n & h2,\n & h3,\n & h4,\n & h5,\n & h6 {\n margin: 10px 0;\n padding: 0;\n }\n`;\n\nconst StyledSources = styled.div`\n display: flex;\n gap: 16px;\n flex-wrap: wrap;\n margin: -5px 0 20px;\n`;\n\nconst StyledSourceLink = styled(Link)<{ theme: Theme }>`\n position: relative;\n text-decoration: none;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: 1;\n color: ${({ theme }) => theme.colors.primary};\n display: flex;\n gap: 6px;\n transition: all 0.3s ease;\n font-weight: 600;\n white-space: nowrap;\n min-width: fit-content;\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 10%, transparent)`};\n padding: 6px 8px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n ${interactiveStyles};\n\n & * {\n margin: auto 0;\n }\n\n &:hover {\n color: ${({ theme }) => theme.colors.accent};\n }\n`;\n\nconst StyledChatTitle = styled.div<{ theme: Theme }>`\n display: flex;\n flex-wrap: nowrap;\n justify-content: space-between;\n position: sticky;\n margin: 0 -20px;\n padding: 16px 20px;\n height: 62px;\n top: 0;\n background: ${({ theme }) => theme.colors.light};\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n z-index: 1000;\n`;\n\nconst StyledChatTitleIconWrapper = styled.span<{ theme: Theme }>`\n display: flex;\n align-items: center;\n gap: 12px;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\ntype Source = {\n id: string;\n path: string;\n uri: string;\n score: number;\n};\n\ntype Answer = {\n text: string;\n answer?: boolean;\n mdx?: MDXRemoteSerializeResult;\n sources?: Source[];\n};\n\nconst SPARKLE_COLORS = [\n \"#ff6b6b\",\n \"#feca57\",\n \"#48dbfb\",\n \"#ff9ff3\",\n \"#54a0ff\",\n \"#5f27cd\",\n];\n\n// Deterministic sparkle positions to avoid hydration mismatch\nconst SPARKLE_POSITIONS = [\n { left: 8, top: 35 },\n { left: 17, top: 55 },\n { left: 26, top: 28 },\n { left: 35, top: 68 },\n { left: 44, top: 42 },\n { left: 53, top: 75 },\n { left: 62, top: 32 },\n { left: 71, top: 58 },\n { left: 80, top: 45 },\n { left: 89, top: 65 },\n];\n\ninterface RainbowInputProps {\n id?: string;\n value: string;\n onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;\n placeholder?: string;\n autoComplete?: string;\n \"aria-label\"?: string;\n inputRef?: React.Ref<HTMLInputElement>;\n}\n\nfunction RainbowInput({\n id,\n value,\n onChange,\n placeholder,\n autoComplete,\n \"aria-label\": ariaLabel,\n inputRef,\n}: RainbowInputProps) {\n const [isFocused, setIsFocused] = useState(false);\n const [isHovered, setIsHovered] = useState(false);\n const isActive = isFocused || isHovered;\n\n const sparkles = SPARKLE_POSITIONS.map((pos, i) => ({\n color: SPARKLE_COLORS[i % SPARKLE_COLORS.length],\n left: pos.left,\n top: pos.top,\n delay: i * 0.12,\n }));\n\n return (\n <StyledRainbowInputWrapper\n $isActive={isActive}\n onMouseEnter={() => setIsHovered(true)}\n onMouseLeave={() => setIsHovered(false)}\n >\n <StyledSparkleContainer $isActive={isActive}>\n {sparkles.map((sparkle, i) => (\n <StyledSparkle\n key={i}\n $color={sparkle.color}\n $left={sparkle.left}\n $top={sparkle.top}\n $delay={sparkle.delay}\n />\n ))}\n </StyledSparkleContainer>\n <StyledRainbowInput\n ref={inputRef}\n id={id}\n value={value}\n onChange={onChange}\n placeholder={placeholder}\n autoComplete={autoComplete}\n aria-label={ariaLabel}\n onFocus={() => setIsFocused(true)}\n onBlur={() => setIsFocused(false)}\n />\n </StyledRainbowInputWrapper>\n );\n}\n\nfunction ChatButtonCTA() {\n const { toggleChat, isOpen } = useContext(ChatContext);\n\n return (\n <StyledGlowSmallButton\n onClick={toggleChat}\n aria-label=\"Ask AI Assistant\"\n $hasContent={isOpen}\n type=\"button\"\n >\n <span>\n <Sparkles size={16} />\n Ask AI\n </span>\n </StyledGlowSmallButton>\n );\n}\n\nfunction Chat() {\n const {\n isOpen,\n question,\n setQuestion,\n loading,\n error,\n answer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n } = useContext(ChatContext);\n const endRef = useRef<HTMLDivElement | null>(null);\n\n useLockBodyScroll(isOpen);\n\n useEffect(() => {\n endRef.current?.scrollIntoView({ behavior: \"smooth\", block: \"end\" });\n }, [answer]);\n\n useEffect(() => {\n if (answer?.length > 0) {\n chatInputRef.current?.focus();\n }\n }, [answer, chatInputRef]);\n\n return (\n <>\n <StyledChat $isVisible={isOpen}>\n <StyledChatTitle>\n <StyledChatTitleIconWrapper>\n <Sparkles />\n <h3>AI Assistant</h3>\n </StyledChatTitleIconWrapper>\n <StyledChatTitleIconWrapper>\n <IconButton\n onClick={resetChat}\n aria-label=\"Reset chat history\"\n title=\"Reset chat history\"\n >\n <RotateCcw />\n </IconButton>\n <IconButton\n onClick={closeChat}\n aria-label=\"Close chat\"\n title=\"Close chat\"\n >\n <X />\n </IconButton>\n </StyledChatTitleIconWrapper>\n </StyledChatTitle>\n {answer &&\n answer.map((a, i) => (\n <React.Fragment key={i}>\n <StyledAnswer $isAnswer={a.answer ?? false}>\n {a.answer && a.mdx ? (\n <MDXRemote {...a.mdx} components={mdxComponents} />\n ) : (\n a.text\n )}\n </StyledAnswer>\n {a.answer && a.sources && a.sources.length > 0 && (\n <StyledSources>\n {a.sources.map((src) => {\n const slug = src.uri\n .replace(\"docs://\", \"\")\n .replace(/^\\/+/, \"\");\n const href = slug ? `/${slug}/` : \"/\";\n const label = slug\n ? slug\n .split(\"/\")\n .pop()!\n .replace(/-/g, \" \")\n .replace(/\\b\\w/g, (c: string) => c.toUpperCase())\n : \"Home\";\n return (\n <StyledSourceLink\n key={src.id}\n href={href}\n onClick={() => {\n if (window.innerWidth <= 992) {\n closeChat();\n }\n }}\n >\n {label}\n </StyledSourceLink>\n );\n })}\n </StyledSources>\n )}\n </React.Fragment>\n ))}\n {loading && (\n <StyledLoading>\n Answering<span>.</span>\n <span>.</span>\n <span>.</span>\n </StyledLoading>\n )}\n {error && (\n <StyledError>\n <strong>Error:</strong> {error}\n </StyledError>\n )}\n <div ref={endRef} />\n </StyledChat>\n\n <StyledChatForm onSubmit={ask} $isVisible={isOpen}>\n <RainbowInput\n id=\"chat-bottom-input\"\n inputRef={chatInputRef}\n value={question}\n onChange={(e) => setQuestion(e.target.value)}\n placeholder=\"Ask AI Assistant...\"\n autoComplete=\"off\"\n aria-label=\"Ask a follow-up question\"\n />\n <StyledRainbowButton\n type=\"submit\"\n disabled={loading || question.trim() === \"\"}\n $hasContent={question.trim().length > 0}\n aria-label={loading ? \"Loading response\" : \"Submit question\"}\n >\n {loading ? <LoaderPinwheel className=\"loading\" /> : <ArrowUp />}\n </StyledRainbowButton>\n </StyledChatForm>\n </>\n );\n}\n\nconst ChatContext = createContext<{\n isOpen: boolean;\n setIsOpen: (isOpen: boolean) => void;\n toggleChat: () => void;\n isChatActive: boolean;\n question: string;\n setQuestion: (q: string) => void;\n loading: boolean;\n error: string | null;\n answer: Answer[];\n setAnswer: (answers: Answer[]) => void;\n ask: (e: React.FormEvent) => void;\n closeChat: () => void;\n resetChat: () => void;\n chatInputRef: React.RefObject<HTMLInputElement | null>;\n}>({\n isOpen: false,\n setIsOpen: () => {},\n toggleChat: () => {},\n isChatActive: false,\n question: \"\",\n setQuestion: () => {},\n loading: false,\n error: null,\n answer: [],\n setAnswer: () => {},\n ask: () => {},\n closeChat: () => {},\n resetChat: () => {},\n chatInputRef: { current: null },\n});\n\ninterface ChatContextProviderProps {\n children: React.ReactNode;\n isChatActive: boolean;\n}\n\nconst ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {\n const [isOpen, setIsOpen] = useState(false);\n const [question, setQuestion] = useState(\"\");\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<string | null>(null);\n const [answer, setAnswer] = useState<Answer[]>([]);\n const abortRef = useRef<AbortController | null>(null);\n const chatInputRef = useRef<HTMLInputElement | null>(null);\n const isOpenRef = useRef(isOpen);\n\n useEffect(() => {\n isOpenRef.current = isOpen;\n }, [isOpen]);\n\n // Open the assistant, seeding a greeting the first time and focusing the\n // input once the panel has slid in (matches the 0.3s panel transition).\n const openChat = useCallback(() => {\n setIsOpen(true);\n setAnswer((prev) =>\n prev.length === 0\n ? [{ text: \"Hey there, how can I assist you?\", answer: true }]\n : prev,\n );\n setTimeout(() => {\n chatInputRef.current?.focus();\n }, 350);\n }, []);\n\n const toggleChat = useCallback(() => {\n if (isOpenRef.current) {\n setIsOpen(false);\n } else {\n openChat();\n }\n }, [openChat]);\n\n // Global Cmd/Ctrl+I toggles the assistant, but only when chat is enabled.\n useEffect(() => {\n if (!isChatActive) return;\n function handleKeyDown(e: KeyboardEvent) {\n if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === \"i\") {\n e.preventDefault();\n toggleChat();\n }\n }\n document.addEventListener(\"keydown\", handleKeyDown);\n return () => document.removeEventListener(\"keydown\", handleKeyDown);\n }, [isChatActive, toggleChat]);\n\n async function ask(e: React.FormEvent) {\n e.preventDefault();\n if (loading || question.trim() === \"\") return;\n const currentQuestion = question;\n setQuestion(\"\");\n setIsOpen(true);\n setLoading(true);\n setError(null);\n\n const mergedQuestions =\n answer.length > 0\n ? [...answer, { text: currentQuestion, answer: false }]\n : [{ text: currentQuestion, answer: false }];\n\n setAnswer(mergedQuestions);\n\n const controller = new AbortController();\n abortRef.current = controller;\n\n try {\n const history = answer\n .filter((a) => a.text.trim() !== \"\")\n .map((a) => ({\n role: a.answer ? (\"assistant\" as const) : (\"user\" as const),\n content: a.text,\n }));\n\n const res = await fetch(\"/api/rag\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ question: currentQuestion, history }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n // The body may be a proxy/gateway HTML error page (e.g. Cloudflare\n // 524), not our JSON - never blindly JSON.parse it.\n const contentType = res.headers.get(\"content-type\") || \"\";\n let message = \"Request failed\";\n if (contentType.includes(\"application/json\")) {\n try {\n const errorData = await res.json();\n message = errorData.error || message;\n } catch {\n // Non-JSON despite the header - fall through to the default.\n }\n } else if ([502, 503, 504, 524].includes(res.status)) {\n message = \"The assistant took too long to respond. Please try again.\";\n }\n throw new Error(message);\n }\n\n const reader = res.body?.getReader();\n const decoder = new TextDecoder();\n const contentParts: string[] = [];\n let sources: Source[] = [];\n if (!reader) {\n throw new Error(\"Failed to get response reader\");\n }\n\n const streamingAnswerIndex = mergedQuestions.length;\n setAnswer([...mergedQuestions, { text: \"\", answer: true }]);\n\n let buffer = \"\";\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n buffer += decoder.decode(value, { stream: true });\n const parts = buffer.split(\"\\n\");\n buffer = parts.pop() ?? \"\";\n\n for (const line of parts) {\n if (line.startsWith(\"data: \")) {\n try {\n const data = JSON.parse(line.slice(6));\n\n if (data.type === \"metadata\") {\n const allSources: Source[] = data.data?.sources ?? [];\n const seen = new Set<string>();\n sources = allSources.filter((s: Source) => {\n if (s.score < 0.4 || seen.has(s.uri)) return false;\n seen.add(s.uri);\n return true;\n });\n } else if (data.type === \"content\") {\n contentParts.push(data.data);\n const streamedContent = contentParts.join(\"\");\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n sources,\n };\n return newAnswers;\n });\n } else if (data.type === \"error\") {\n throw new Error(data.data);\n } else if (data.type === \"done\") {\n const streamedContent = contentParts.join(\"\");\n let mdxSource: MDXRemoteSerializeResult | null = null;\n try {\n mdxSource = await serialize(streamedContent, {\n parseFrontmatter: false,\n mdxOptions: {\n remarkPlugins: [remarkGfm],\n rehypePlugins: [rehypeHighlight],\n format: \"md\",\n development: false,\n },\n });\n } catch (mdxError: unknown) {\n console.error(\"MDX serialization error:\", mdxError);\n }\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n mdx: mdxSource || undefined,\n sources,\n };\n return newAnswers;\n });\n }\n } catch (parseError) {\n if (\n parseError instanceof Error &&\n parseError.message !== \"Unknown error\"\n ) {\n console.error(\"Failed to parse SSE data:\", parseError);\n }\n }\n }\n }\n }\n } catch (err: unknown) {\n if (err instanceof DOMException && err.name === \"AbortError\") return;\n setError(err instanceof Error ? err.message : \"Unknown error\");\n } finally {\n abortRef.current = null;\n setLoading(false);\n }\n }\n\n function closeChat() {\n setIsOpen(false);\n }\n\n function resetChat() {\n abortRef.current?.abort();\n setLoading(false);\n setError(null);\n setAnswer([{ text: \"Hey there, how can I assist you?\", answer: true }]);\n }\n\n return (\n <ChatContext.Provider\n value={{\n isOpen,\n setIsOpen,\n toggleChat,\n isChatActive,\n question,\n setQuestion,\n loading,\n error,\n answer,\n setAnswer,\n ask,\n closeChat,\n resetChat,\n chatInputRef,\n }}\n >\n {children}\n </ChatContext.Provider>\n );\n};\n\nexport { Chat, ChtProvider, ChatContext, ChatButtonCTA };\n";
|
|
@@ -1032,8 +1032,21 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
|
|
|
1032
1032
|
});
|
|
1033
1033
|
|
|
1034
1034
|
if (!res.ok) {
|
|
1035
|
-
|
|
1036
|
-
|
|
1035
|
+
// The body may be a proxy/gateway HTML error page (e.g. Cloudflare
|
|
1036
|
+
// 524), not our JSON - never blindly JSON.parse it.
|
|
1037
|
+
const contentType = res.headers.get("content-type") || "";
|
|
1038
|
+
let message = "Request failed";
|
|
1039
|
+
if (contentType.includes("application/json")) {
|
|
1040
|
+
try {
|
|
1041
|
+
const errorData = await res.json();
|
|
1042
|
+
message = errorData.error || message;
|
|
1043
|
+
} catch {
|
|
1044
|
+
// Non-JSON despite the header - fall through to the default.
|
|
1045
|
+
}
|
|
1046
|
+
} else if ([502, 503, 504, 524].includes(res.status)) {
|
|
1047
|
+
message = "The assistant took too long to respond. Please try again.";
|
|
1048
|
+
}
|
|
1049
|
+
throw new Error(message);
|
|
1037
1050
|
}
|
|
1038
1051
|
|
|
1039
1052
|
const reader = res.body?.getReader();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const docsComponentsTemplate = "\"use client\";\nimport React, { createContext, useContext } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport {\n resetButton,\n styledSmall,\n styledStrong,\n styledText,\n} from \"cherry-styled-components\";\nimport Link from \"next/link\";\nimport { mq, Theme } from \"@/app/theme\";\nimport {\n styledAnchor,\n styledTable,\n stylesLists,\n} from \"@/components/layout/SharedStyled\";\nimport { ChatContext } from \"@/components/Chat\";\n\nconst SectionBarContext = createContext(false);\n\nfunction SectionBarProvider({\n hasSectionBar,\n children,\n}: {\n hasSectionBar: boolean;\n children: React.ReactNode;\n}) {\n return (\n <SectionBarContext.Provider value={hasSectionBar}>\n {children}\n </SectionBarContext.Provider>\n );\n}\n\ninterface DocsProps {\n children: React.ReactNode;\n}\n\nconst StyledDocsWrapper = styled.main<{ theme: Theme }>`\n position: relative;\n`;\n\nconst StyledDocsSidebar = styled.div<{ theme: Theme }>`\n clear: both;\n`;\n\nconst StyledDocsContainer = styled.div<{ theme: Theme; $isChatOpen?: boolean }>`\n position: relative;\n padding: 0 20px 100px 20px;\n width: 100%;\n ${({ theme }) => styledText(theme)};\n transition: all 0.3s ease;\n\n ${mq(\"lg\")} {\n padding: 0 300px 80px 300px;\n\n ${({ $isChatOpen }) =>\n $isChatOpen &&\n css`\n padding: 0 440px 80px 300px;\n `}\n }\n\n & p {\n color: ${({ theme }) => theme.colors.grayDark};\n hyphens: auto;\n }\n\n & pre {\n max-width: 100%;\n }\n\n ${styledAnchor};\n ${stylesLists};\n ${styledTable};\n\n & img,\n & video,\n & iframe {\n max-width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n }\n\n & code:not([class]),\n & kbd {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 20%, transparent)`};\n color: ${({ theme }) => theme.colors.dark};\n padding: 2px 4px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n }\n\n & .lucide {\n color: ${({ theme }) => theme.colors.primary};\n }\n\n & .aspect-video {\n aspect-ratio: 16 / 9;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n }\n`;\n\nexport const StyledMarkdownContainer = styled.div`\n display: flex;\n flex-direction: column;\n gap: 20px;\n flex-wrap: wrap;\n flex: 1;\n max-width: 640px;\n margin: auto;\n`;\n\ninterface Props {\n theme?: Theme;\n $isActive?: boolean;\n $isOpen?: boolean;\n $hasSectionBar?: boolean;\n}\n\nexport const StyledSidebar = styled.nav<Props>`\n position: fixed;\n overflow-y: auto;\n max-height: calc(\n 100dvh - ${({ $hasSectionBar }) => ($hasSectionBar ? 104 : 62)}px\n );\n width: 100%;\n z-index: 99;\n top: ${({ $hasSectionBar }) => ($hasSectionBar ? 104 : 62)}px;\n height: 100%;\n padding: 20px;\n opacity: 0;\n pointer-events: none;\n transition: all 0.3s ease;\n transform: translateY(30px);\n left: 0;\n background: ${({ theme }) => theme.colors.light};\n -webkit-overflow-scrolling: touch;\n display: flex;\n flex-direction: column;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${mq(\"lg\")} {\n border-right: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: none;\n max-height: 100dvh;\n width: 220px;\n background: transparent;\n padding: 82px 20px 20px 20px;\n opacity: 1;\n pointer-events: all;\n transform: translateY(0);\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 5%, transparent)`};\n top: 0;\n width: 280px;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n transform: translateY(0);\n opacity: 1;\n pointer-events: all;\n `}\n`;\n\nexport const StyledSidebarFooter = styled.div`\n padding: 22px 20px;\n position: sticky;\n border-top: 1px solid ${({ theme }) => theme.colors.grayLight};\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 5%, transparent)`};\n margin: 0 -20px -20px;\n bottom: -20px;\n backdrop-filter: blur(10px);\n\n ${mq(\"lg\")} {\n padding: 16px 20px;\n }\n`;\n\nexport const StyledIndexSidebar = styled.ul<{ theme: Theme }>`\n display: none;\n list-style: none;\n margin: 0;\n padding: 0;\n position: fixed;\n top: 0;\n right: 0;\n width: 280px;\n height: 100dvh;\n overflow-y: auto;\n z-index: 1;\n padding: 82px 20px 20px 20px;\n background: ${({ theme }) => theme.colors.light};\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n -webkit-overflow-scrolling: touch;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${mq(\"lg\")} {\n display: block;\n }\n\n & li {\n padding: 5px 0;\n }\n`;\n\nexport const StyledIndexSidebarLabel = styled.span<{ theme: Theme }>`\n ${({ theme }) => styledSmall(theme)};\n color: ${({ theme }) => theme.colors.grayDark};\n`;\n\nexport const StyledIndexSidebarLi = styled.li<{\n theme: Theme;\n $isActive: boolean;\n}>`\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n left: 0;\n height: 20px;\n width: 1px;\n background: transparent;\n transition: all 0.3s ease;\n }\n\n ${({ $isActive, theme }) =>\n $isActive &&\n css`\n &::before {\n background: ${theme.colors.primary};\n }\n `}\n`;\n\nexport const StyledIndexSidebarLink = styled.a<{\n theme: Theme;\n $isActive: boolean;\n}>`\n ${({ theme }) => styledSmall(theme)};\n color: ${({ theme, $isActive }) =>\n $isActive ? theme.colors.primary : theme.colors.dark};\n font-weight: ${({ $isActive }) => ($isActive ? \"600\" : \"400\")};\n text-decoration: none;\n transition: all 0.3s ease;\n\n &:hover {\n color: ${({ theme }) => theme.colors.primary};\n }\n`;\n\nexport const StyledSidebarList = styled.ul`\n list-style: none;\n margin: 0;\n padding: 0;\n\n &:last-of-type {\n margin-bottom: auto;\n }\n`;\n\nexport const StyledStrong = styled.strong<{ theme: Theme }>`\n font-weight: 600;\n ${({ theme }) => styledStrong(theme)};\n color: ${({ theme }) => theme.colors.accentStrong};\n display: inline-flex;\n align-items: center;\n gap: 8px;\n\n & svg {\n flex-shrink: 0;\n }\n`;\n\nexport const StyledSidebarListItem = styled.li`\n display: flex;\n gap: 10px;\n clear: both;\n`;\n\n// Shared appearance for every sidebar row - leaf links AND nested group\n// headers - so hover, active state, and the left rail read identically.\nconst sidebarRowStyles = css<Props>`\n text-decoration: none;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: 1.6;\n color: ${({ theme }) => theme.colors.accentMuted};\n padding: 5px 0 5px 20px;\n display: flex;\n align-items: center;\n gap: 8px;\n flex: 1;\n min-width: 0;\n transition: all 0.3s ease;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n & svg {\n flex-shrink: 0;\n }\n\n &:hover {\n color: ${({ theme }) => theme.colors.accent};\n border-color: ${({ theme }) => theme.colors.primary};\n }\n\n ${({ $isActive, theme }) =>\n $isActive &&\n css`\n color: ${theme.colors.accentStrong};\n border-color: ${theme.colors.primary};\n font-weight: 600;\n `};\n`;\n\n// The collapse chevron points right when closed and rotates to point down\n// when the group is open.\nconst sidebarChevron = css<Props>`\n & .lucide-chevron-right {\n margin-left: auto;\n transition: transform 0.3s ease;\n\n ${({ $isOpen }) =>\n $isOpen &&\n css`\n transform: rotate(90deg);\n `}\n }\n`;\n\nexport const StyledSidebarListItemLink = styled(Link)<Props>`\n ${sidebarRowStyles};\n`;\n\n// Nested navigation group. The header shares the link appearance/hover; a\n// non-navigable group is a single toggle button, while a group that is also a\n// page pairs a link with a chevron toggle. Children live in\n// StyledSidebarGroupContent, which animates open/closed via height 0 <-> auto\n// (enabled by interpolate-size in GlobalStyles, same as the Accordion).\nexport const StyledSidebarGroupButton = styled.button<Props>`\n ${resetButton};\n ${sidebarRowStyles};\n ${sidebarChevron};\n width: 100%;\n text-align: left;\n\n /* Buttons aren't covered by the global a:focus-visible ring, so this\n nav-row toggle carries its own copy of it. */\n &:focus-visible {\n outline: none;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n`;\n\nexport const StyledSidebarGroupRow = styled.div<Props>`\n ${sidebarRowStyles};\n ${sidebarChevron};\n\n /* The row is a plain div, so it never matches :focus-visible itself. Ring the\n whole row when its inner link or chevron takes keyboard focus instead, to\n match the leaf-row focus treatment. */\n &:has(:focus-visible) {\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n`;\n\nexport const StyledSidebarGroupLink = styled(Link)`\n display: flex;\n align-items: center;\n gap: 8px;\n flex: 1;\n min-width: 0;\n color: inherit;\n text-decoration: none;\n\n & svg {\n flex-shrink: 0;\n }\n\n /* Ring is drawn on the parent row via :has(:focus-visible); suppress the\n global a:focus-visible glow so it isn't drawn twice. */\n &:focus-visible {\n outline: none;\n box-shadow: none;\n }\n`;\n\nexport const StyledSidebarGroupChevron = styled.button`\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n padding: 0;\n border: 0;\n background: none;\n color: inherit;\n cursor: pointer;\n\n /* Ring is drawn on the parent row via :has(:focus-visible). */\n &:focus-visible {\n outline: none;\n }\n`;\n\nexport const StyledSidebarGroupContent = styled.ul<Props>`\n list-style: none;\n margin: 0;\n padding: 0;\n height: 0;\n overflow: clip;\n transition: all 0.3s ease;\n\n ${({ $isOpen }) =>\n $isOpen &&\n css`\n height: auto;\n `}\n`;\n\nexport const StyleMobileBar = styled.button<Props>`\n ${resetButton};\n position: fixed;\n z-index: 999;\n bottom: 0;\n right: 20px;\n font-size: ${({ theme }) => theme.fontSizes.strong.lg};\n line-height: ${({ theme }) => theme.fontSizes.strong.lg};\n box-shadow: ${({ theme }) => theme.shadows.sm};\n background: ${({ theme }) => theme.colors.primary};\n color: ${({ theme }) => theme.colors.surface};\n backdrop-filter: blur(10px);\n -webkit-backdrop-filter: blur(10px);\n padding: 10px;\n border-radius: 100px;\n margin: 0 0 20px 0;\n font-weight: 600;\n display: flex;\n justify-content: flex-start;\n width: auto;\n\n ${mq(\"lg\")} {\n display: none;\n }\n\n ${({ $isActive }) => $isActive && `position: fixed;`};\n`;\n\nexport const StyledMobileBurger = styled.span<Props>`\n display: block;\n margin: auto 0;\n width: 18px;\n height: 18px;\n position: relative;\n overflow: hidden;\n background: transparent;\n position: relative;\n transform: scale(0.8);\n\n &::before,\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 18px;\n height: 3px;\n border-radius: 3px;\n background: ${({ theme }) => theme.colors.surface};\n transition: all 0.3s ease;\n }\n\n &::before {\n top: 3px;\n }\n\n &::after {\n bottom: 3px;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n &::before {\n transform: translateY(5px) rotate(45deg);\n }\n\n &::after {\n transform: translateY(-4px) rotate(-45deg);\n }\n `};\n`;\n\nexport const StyledMissingComponent = styled.div`\n background: ${({ theme }) => theme.colors.error};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n padding: 20px;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.surface};\n font-weight: 600;\n display: flex;\n gap: 10px;\n align-items: center;\n`;\n\ninterface DocsWrapperProps {\n children: React.ReactNode;\n}\n\nfunction DocsWrapper({ children }: DocsWrapperProps) {\n return <StyledDocsWrapper>{children}</StyledDocsWrapper>;\n}\n\nfunction DocsSidebar({ children }: DocsProps) {\n return <StyledDocsSidebar>{children}</StyledDocsSidebar>;\n}\n\nfunction DocsContainer({ children }: DocsProps) {\n const { isOpen } = useContext(ChatContext);\n\n return (\n <StyledDocsContainer $isChatOpen={isOpen}>{children}</StyledDocsContainer>\n );\n}\n\nexport {\n DocsWrapper,\n DocsSidebar,\n DocsContainer,\n SectionBarContext,\n SectionBarProvider,\n};\n";
|
|
1
|
+
export declare const docsComponentsTemplate = "\"use client\";\nimport React, { createContext, useContext } from \"react\";\nimport styled, { css } from \"styled-components\";\nimport {\n resetButton,\n styledSmall,\n styledStrong,\n styledText,\n} from \"cherry-styled-components\";\nimport Link from \"next/link\";\nimport { mq, Theme } from \"@/app/theme\";\nimport {\n styledAnchor,\n styledTable,\n stylesLists,\n} from \"@/components/layout/SharedStyled\";\nimport { ChatContext } from \"@/components/Chat\";\n\nconst SectionBarContext = createContext(false);\n\nfunction SectionBarProvider({\n hasSectionBar,\n children,\n}: {\n hasSectionBar: boolean;\n children: React.ReactNode;\n}) {\n return (\n <SectionBarContext.Provider value={hasSectionBar}>\n {children}\n </SectionBarContext.Provider>\n );\n}\n\ninterface DocsProps {\n children: React.ReactNode;\n}\n\nconst StyledDocsWrapper = styled.main<{ theme: Theme }>`\n position: relative;\n`;\n\nconst StyledDocsSidebar = styled.div<{ theme: Theme }>`\n clear: both;\n`;\n\nconst StyledDocsContainer = styled.div<{ theme: Theme; $isChatOpen?: boolean }>`\n position: relative;\n padding: 0 20px 100px 20px;\n width: 100%;\n ${({ theme }) => styledText(theme)};\n transition: all 0.3s ease;\n\n ${mq(\"lg\")} {\n padding: 0 300px 80px 300px;\n\n ${({ $isChatOpen }) =>\n $isChatOpen &&\n css`\n padding: 0 440px 80px 300px;\n `}\n }\n\n & p {\n color: ${({ theme }) => theme.colors.grayDark};\n hyphens: auto;\n }\n\n & pre {\n max-width: 100%;\n }\n\n ${styledAnchor};\n ${stylesLists};\n ${styledTable};\n\n & img,\n & video,\n & iframe {\n max-width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n }\n\n & code:not([class]),\n & kbd {\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 20%, transparent)`};\n color: ${({ theme }) => theme.colors.dark};\n padding: 2px 4px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n overflow-wrap: anywhere;\n }\n\n & .lucide {\n color: ${({ theme }) => theme.colors.primary};\n }\n\n & .aspect-video {\n aspect-ratio: 16 / 9;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n }\n`;\n\nexport const StyledMarkdownContainer = styled.div`\n display: flex;\n flex-direction: column;\n gap: 20px;\n flex-wrap: wrap;\n flex: 1;\n max-width: 640px;\n margin: auto;\n`;\n\ninterface Props {\n theme?: Theme;\n $isActive?: boolean;\n $isOpen?: boolean;\n $hasSectionBar?: boolean;\n}\n\nexport const StyledSidebar = styled.nav<Props>`\n position: fixed;\n overflow-y: auto;\n max-height: calc(\n 100dvh - ${({ $hasSectionBar }) => ($hasSectionBar ? 104 : 62)}px\n );\n width: 100%;\n z-index: 99;\n top: ${({ $hasSectionBar }) => ($hasSectionBar ? 104 : 62)}px;\n height: 100%;\n padding: 20px;\n opacity: 0;\n pointer-events: none;\n transition: all 0.3s ease;\n transform: translateY(30px);\n left: 0;\n background: ${({ theme }) => theme.colors.light};\n -webkit-overflow-scrolling: touch;\n display: flex;\n flex-direction: column;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${mq(\"lg\")} {\n border-right: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: none;\n max-height: 100dvh;\n width: 220px;\n background: transparent;\n padding: 82px 20px 20px 20px;\n opacity: 1;\n pointer-events: all;\n transform: translateY(0);\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 5%, transparent)`};\n top: 0;\n width: 280px;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n transform: translateY(0);\n opacity: 1;\n pointer-events: all;\n `}\n`;\n\nexport const StyledSidebarFooter = styled.div`\n padding: 22px 20px;\n position: sticky;\n border-top: 1px solid ${({ theme }) => theme.colors.grayLight};\n background: ${({ theme }) =>\n `color-mix(in srgb, ${theme.colors.primaryLight} 5%, transparent)`};\n margin: 0 -20px -20px;\n bottom: -20px;\n backdrop-filter: blur(10px);\n\n ${mq(\"lg\")} {\n padding: 16px 20px;\n }\n`;\n\nexport const StyledIndexSidebar = styled.ul<{ theme: Theme }>`\n display: none;\n list-style: none;\n margin: 0;\n padding: 0;\n position: fixed;\n top: 0;\n right: 0;\n width: 280px;\n height: 100dvh;\n overflow-y: auto;\n z-index: 1;\n padding: 82px 20px 20px 20px;\n background: ${({ theme }) => theme.colors.light};\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n -webkit-overflow-scrolling: touch;\n\n &::-webkit-scrollbar {\n display: none;\n }\n\n ${mq(\"lg\")} {\n display: block;\n }\n\n & li {\n padding: 5px 0;\n }\n`;\n\nexport const StyledIndexSidebarLabel = styled.span<{ theme: Theme }>`\n ${({ theme }) => styledSmall(theme)};\n color: ${({ theme }) => theme.colors.grayDark};\n`;\n\nexport const StyledIndexSidebarLi = styled.li<{\n theme: Theme;\n $isActive: boolean;\n}>`\n &::before {\n content: \"\";\n display: block;\n position: absolute;\n left: 0;\n height: 20px;\n width: 1px;\n background: transparent;\n transition: all 0.3s ease;\n }\n\n ${({ $isActive, theme }) =>\n $isActive &&\n css`\n &::before {\n background: ${theme.colors.primary};\n }\n `}\n`;\n\nexport const StyledIndexSidebarLink = styled.a<{\n theme: Theme;\n $isActive: boolean;\n}>`\n ${({ theme }) => styledSmall(theme)};\n color: ${({ theme, $isActive }) =>\n $isActive ? theme.colors.primary : theme.colors.dark};\n font-weight: ${({ $isActive }) => ($isActive ? \"600\" : \"400\")};\n text-decoration: none;\n transition: all 0.3s ease;\n\n &:hover {\n color: ${({ theme }) => theme.colors.primary};\n }\n`;\n\nexport const StyledSidebarList = styled.ul`\n list-style: none;\n margin: 0;\n padding: 0;\n\n &:last-of-type {\n margin-bottom: auto;\n }\n`;\n\nexport const StyledStrong = styled.strong<{ theme: Theme }>`\n font-weight: 600;\n ${({ theme }) => styledStrong(theme)};\n color: ${({ theme }) => theme.colors.accentStrong};\n display: inline-flex;\n align-items: center;\n gap: 8px;\n\n & svg {\n flex-shrink: 0;\n }\n`;\n\nexport const StyledSidebarListItem = styled.li`\n display: flex;\n gap: 10px;\n clear: both;\n`;\n\n// Shared appearance for every sidebar row - leaf links AND nested group\n// headers - so hover, active state, and the left rail read identically.\nconst sidebarRowStyles = css<Props>`\n text-decoration: none;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n line-height: 1.6;\n color: ${({ theme }) => theme.colors.accentMuted};\n padding: 5px 0 5px 20px;\n display: flex;\n align-items: center;\n gap: 8px;\n flex: 1;\n min-width: 0;\n transition: all 0.3s ease;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n & svg {\n flex-shrink: 0;\n }\n\n &:hover {\n color: ${({ theme }) => theme.colors.accent};\n border-color: ${({ theme }) => theme.colors.primary};\n }\n\n ${({ $isActive, theme }) =>\n $isActive &&\n css`\n color: ${theme.colors.accentStrong};\n border-color: ${theme.colors.primary};\n font-weight: 600;\n `};\n`;\n\n// The collapse chevron points right when closed and rotates to point down\n// when the group is open.\nconst sidebarChevron = css<Props>`\n & .lucide-chevron-right {\n margin-left: auto;\n transition: transform 0.3s ease;\n\n ${({ $isOpen }) =>\n $isOpen &&\n css`\n transform: rotate(90deg);\n `}\n }\n`;\n\nexport const StyledSidebarListItemLink = styled(Link)<Props>`\n ${sidebarRowStyles};\n`;\n\n// Nested navigation group. The header shares the link appearance/hover; a\n// non-navigable group is a single toggle button, while a group that is also a\n// page pairs a link with a chevron toggle. Children live in\n// StyledSidebarGroupContent, which animates open/closed via height 0 <-> auto\n// (enabled by interpolate-size in GlobalStyles, same as the Accordion).\nexport const StyledSidebarGroupButton = styled.button<Props>`\n ${resetButton};\n ${sidebarRowStyles};\n ${sidebarChevron};\n width: 100%;\n text-align: left;\n\n /* Buttons aren't covered by the global a:focus-visible ring, so this\n nav-row toggle carries its own copy of it. */\n &:focus-visible {\n outline: none;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n`;\n\nexport const StyledSidebarGroupRow = styled.div<Props>`\n ${sidebarRowStyles};\n ${sidebarChevron};\n\n /* The row is a plain div, so it never matches :focus-visible itself. Ring the\n whole row when its inner link or chevron takes keyboard focus instead, to\n match the leaf-row focus treatment. */\n &:has(:focus-visible) {\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n`;\n\nexport const StyledSidebarGroupLink = styled(Link)`\n display: flex;\n align-items: center;\n gap: 8px;\n flex: 1;\n min-width: 0;\n color: inherit;\n text-decoration: none;\n\n & svg {\n flex-shrink: 0;\n }\n\n /* Ring is drawn on the parent row via :has(:focus-visible); suppress the\n global a:focus-visible glow so it isn't drawn twice. */\n &:focus-visible {\n outline: none;\n box-shadow: none;\n }\n`;\n\nexport const StyledSidebarGroupChevron = styled.button`\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n padding: 0;\n border: 0;\n background: none;\n color: inherit;\n cursor: pointer;\n\n /* Ring is drawn on the parent row via :has(:focus-visible). */\n &:focus-visible {\n outline: none;\n }\n`;\n\nexport const StyledSidebarGroupContent = styled.ul<Props>`\n list-style: none;\n margin: 0;\n padding: 0;\n height: 0;\n overflow: clip;\n transition: all 0.3s ease;\n\n ${({ $isOpen }) =>\n $isOpen &&\n css`\n height: auto;\n `}\n`;\n\nexport const StyleMobileBar = styled.button<Props>`\n ${resetButton};\n position: fixed;\n z-index: 999;\n bottom: 0;\n right: 20px;\n font-size: ${({ theme }) => theme.fontSizes.strong.lg};\n line-height: ${({ theme }) => theme.fontSizes.strong.lg};\n box-shadow: ${({ theme }) => theme.shadows.sm};\n background: ${({ theme }) => theme.colors.primary};\n color: ${({ theme }) => theme.colors.surface};\n backdrop-filter: blur(10px);\n -webkit-backdrop-filter: blur(10px);\n padding: 10px;\n border-radius: 100px;\n margin: 0 0 20px 0;\n font-weight: 600;\n display: flex;\n justify-content: flex-start;\n width: auto;\n\n ${mq(\"lg\")} {\n display: none;\n }\n\n ${({ $isActive }) => $isActive && `position: fixed;`};\n`;\n\nexport const StyledMobileBurger = styled.span<Props>`\n display: block;\n margin: auto 0;\n width: 18px;\n height: 18px;\n position: relative;\n overflow: hidden;\n background: transparent;\n position: relative;\n transform: scale(0.8);\n\n &::before,\n &::after {\n content: \"\";\n display: block;\n position: absolute;\n width: 18px;\n height: 3px;\n border-radius: 3px;\n background: ${({ theme }) => theme.colors.surface};\n transition: all 0.3s ease;\n }\n\n &::before {\n top: 3px;\n }\n\n &::after {\n bottom: 3px;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n &::before {\n transform: translateY(5px) rotate(45deg);\n }\n\n &::after {\n transform: translateY(-4px) rotate(-45deg);\n }\n `};\n`;\n\nexport const StyledMissingComponent = styled.div`\n background: ${({ theme }) => theme.colors.error};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n padding: 20px;\n font-size: ${({ theme }) => theme.fontSizes.small.lg};\n color: ${({ theme }) => theme.colors.surface};\n font-weight: 600;\n display: flex;\n gap: 10px;\n align-items: center;\n`;\n\ninterface DocsWrapperProps {\n children: React.ReactNode;\n}\n\nfunction DocsWrapper({ children }: DocsWrapperProps) {\n return <StyledDocsWrapper>{children}</StyledDocsWrapper>;\n}\n\nfunction DocsSidebar({ children }: DocsProps) {\n return <StyledDocsSidebar>{children}</StyledDocsSidebar>;\n}\n\nfunction DocsContainer({ children }: DocsProps) {\n const { isOpen } = useContext(ChatContext);\n\n return (\n <StyledDocsContainer $isChatOpen={isOpen}>{children}</StyledDocsContainer>\n );\n}\n\nexport {\n DocsWrapper,\n DocsSidebar,\n DocsContainer,\n SectionBarContext,\n SectionBarProvider,\n};\n";
|
|
@@ -89,6 +89,7 @@ const StyledDocsContainer = styled.div<{ theme: Theme; $isChatOpen?: boolean }>\
|
|
|
89
89
|
color: \${({ theme }) => theme.colors.dark};
|
|
90
90
|
padding: 2px 4px;
|
|
91
91
|
border-radius: \${({ theme }) => theme.spacing.radius.xs};
|
|
92
|
+
overflow-wrap: anywhere;
|
|
92
93
|
}
|
|
93
94
|
|
|
94
95
|
& .lucide {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const gitignoreTemplate = "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n.yarn/install-state.gz\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n.next\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# local env files\n.env*.local\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n\n.env\n.vscode\n";
|
|
1
|
+
export declare const gitignoreTemplate = "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pnp\n.pnp.js\n.yarn/install-state.gz\n\n# testing\n/coverage\n\n# next.js\n/.next/\n/out/\n.next\n\n# production\n/build\n\n# misc\n.DS_Store\n*.pem\n\n# debug\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# local env files\n.env*.local\n\n# vercel\n.vercel\n\n# typescript\n*.tsbuildinfo\nnext-env.d.ts\n\n.env\n.vscode\n\n# generated build artifact (precomputed embeddings)\nservices/mcp/docs-index.json\n";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const mcpMdxTemplate = "---\ntitle: \"Model Context Protocol\"\ndescription: \"Connect your Doccupine documentation to AI tools with an MCP server for enhanced AI-powered documentation search.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 3\norder: 9\n---\n\n# Model Context Protocol\n\nConnect your documentation to AI tools with a hosted MCP server.\n\nDoccupine automatically generates a Model Context Protocol (MCP) server from your documentation, making your content accessible to AI applications like Claude, Cursor, VS Code, and other MCP-compatible tools. Your MCP server exposes semantic search capabilities, allowing AI tools to query your documentation directly and provide accurate, context-aware answers.\n\n<Callout type=\"warning\">\n The MCP server requires the [AI Assistant](/ai-assistant) to be configured. Make sure you have set up your LLM provider and API keys before using the MCP server.\n</Callout>\n\n## About MCP servers\n\nThe Model Context Protocol (MCP) is an open protocol that creates standardized connections between AI applications and external services, like documentation. Doccupine generates an MCP server from your documentation, preparing your content for the broader AI ecosystem where any MCP client can connect to your documentation.\n\nYour MCP server exposes search and retrieval tools for AI applications to query your documentation. Your users must connect your MCP server to their preferred AI tools to access your documentation.\n\n### How MCP servers work\n\nWhen an AI tool has your documentation MCP server connected, the AI tool can search your documentation directly instead of making a generic web search in response to a user's prompt. Your MCP server provides access to all indexed content from your documentation site.\n\n- The LLM can proactively search your documentation while generating a response, not just when explicitly asked.\n- The LLM determines when to use the search tool based on the context of the conversation and the relevance of your documentation.\n- Each tool call happens during the generation process, so the LLM searches up-to-date information from your documentation to generate its response.\n\n### MCP compared to web search\n\nAI tools can search the web, but MCP provides distinct advantages for documentation.\n\n- **Direct source access**: Web search depends on what search engines have indexed, which may be stale or incomplete. MCP searches your current indexed documentation directly.\n- **Integrated workflow**: MCP allows the AI to search during response generation rather than performing a separate web search.\n- **Semantic search**: MCP uses vector embeddings for semantic similarity search, providing more relevant results than keyword-based web search.\n- **No search noise**: SEO and ranking algorithms influence web search results. MCP goes straight to your documentation content.\n\n## Access your MCP server\n\nDoccupine automatically generates an MCP server for your documentation and hosts it at your documentation URL with the `/api/mcp` path. For example, if your documentation is hosted at `https://example.com`, your MCP server is available at `https://example.com/api/mcp`.\n\nThe MCP server provides both a GET endpoint to discover available tools and a POST endpoint to execute tool calls.\n\n### Authentication\n\nYou can optionally protect your MCP server with an API key by setting the `DOCS_API_KEY` environment variable in your `.env` file:\n\n```bash\nDOCS_API_KEY=your-secret-key\n```\n\nWhen `DOCS_API_KEY` is set, all requests to `/api/mcp` must include an `Authorization` header with a Bearer token:\n\n```text\nAuthorization: Bearer your-secret-key\n```\n\nRequests without a valid token receive a `401 Unauthorized` response. When `DOCS_API_KEY` is not set, the MCP server is publicly accessible with no authentication required.\n\n<Callout type=\"note\">\n Authentication only applies to the `/api/mcp` endpoint. The `/api/rag` endpoint used by the built-in AI Assistant chat is not affected by this setting.\n</Callout>\n\n### API Endpoints\n\n#### GET /api/mcp\n\nReturns information about available tools and the current index status.\n\n**Response:**\n\n```json\n{\n \"tools\": [\n {\n \"name\": \"search_docs\",\n \"description\": \"Search through the documentation content using semantic search...\",\n \"inputSchema\": { ... }\n },\n ...\n ],\n \"index\": {\n \"ready\": true,\n \"chunkCount\": 150\n }\n}\n```\n\n#### POST /api/mcp\n\nExecutes an MCP tool call.\n\n**Request Body:**\n\n```json\n{\n \"tool\": \"search_docs\",\n \"params\": {\n \"query\": \"how to deploy\",\n \"limit\": 6\n }\n}\n```\n\n**Response:**\n\n```json\n{\n \"content\": [\n {\n \"path\": \"app/deployment-and-hosting/page.tsx\",\n \"uri\": \"docs://deployment-and-hosting\",\n \"score\": \"0.892\",\n \"text\": \"Deploy your Doccupine site as a Next.js application...\"\n },\n ...\n ]\n}\n```\n\n## Available Tools\n\nYour MCP server exposes three tools for interacting with your documentation:\n\n### search_docs\n\nSearch through the documentation content using semantic search. Returns relevant chunks of documentation based on the query using vector embeddings and cosine similarity.\n\n**Parameters:**\n\n- `query` (required): The search query to find relevant documentation\n- `limit` (optional): Maximum number of results to return (default: 6)\n\n**Example:**\n\n```json\n{\n \"tool\": \"search_docs\",\n \"params\": {\n \"query\": \"how to configure AI assistant\",\n \"limit\": 5\n }\n}\n```\n\n### get_doc\n\nGet the full content of a specific documentation page by its path.\n\n**Parameters:**\n\n- `path` (required): The file path to the documentation page (e.g., `app/getting-started/page.tsx`)\n\n**Example:**\n\n```json\n{\n \"tool\": \"get_doc\",\n \"params\": {\n \"path\": \"app/deployment-and-hosting/page.tsx\"\n }\n}\n```\n\n### list_docs\n\nList all available documentation pages, optionally filtered by directory.\n\n**Parameters:**\n\n- `directory` (optional): Optional directory to filter results (e.g., `components`)\n\n**Example:**\n\n```json\n{\n \"tool\": \"list_docs\",\n \"params\": {\n \"directory\": \"configuration\"\n }\n}\n```\n\n## How it works\n\nDoccupine's MCP server uses semantic search powered by vector embeddings to provide accurate, context-aware search results.\n\n### Indexing Process\n\n1. **Document Discovery**: The server scans your `app/` directory for all `page.tsx`, `page.ts`, `page.jsx`, and `page.js` files.\n2. **Content Extraction**: It extracts content from `const content =` declarations in your page files.\n3. **Chunking**: Large documents are split into chunks of approximately 800 characters with 100 character overlap for better context preservation.\n4. **Embedding Generation**: Each chunk is converted to a vector embedding using your configured LLM provider's embedding model.\n5. **Index Building**: All embeddings are stored in memory for fast similarity search.\n\n### Search Process\n\n1. **Query Embedding**: The search query is converted to a vector embedding using the same embedding model.\n2. **Similarity Calculation**: Cosine similarity is calculated between the query embedding and all document chunk embeddings.\n3. **Ranking**: Results are sorted by similarity score (highest first).\n4. **Response**: The top N results (based on the limit parameter) are returned with their paths, URIs, scores, and text content.\n\n<Callout type=\"note\">\n The index is built automatically when the server starts. It is stored in memory and persists for the lifetime of the server process. If you update your documentation, restart the server to rebuild the index.\n</Callout>\n\n## Use your MCP server\n\nYour users must connect your MCP server to their preferred AI tools.\n\n1. Make your MCP server URL publicly available.\n2. Users copy your MCP server URL and add it to their tools.\n3. Users access your documentation through their tools.\n\nThese are some of the ways you can help your users connect to your MCP server:\n\n<Tabs>\n <TabContent title=\"Claude\">\n <Steps>\n <Step title=\"Get your MCP server URL.\">\n Your MCP server URL is available at `https://your-domain.com/api/mcp`.\n </Step>\n <Step title=\"Publish your MCP server URL for your users.\">\n Create a guide for your users that includes your MCP server URL and the steps to connect it to Claude.\n 1. Navigate to the [Connectors](https://claude.ai/settings/connectors) page in the Claude settings.\n 2. Select **Add custom connector**.\n 3. Add your MCP server name and URL.\n 4. Select **Add**.\n 5. When using Claude, select the attachments button (the plus icon).\n 6. Select your MCP server.\n </Step>\n </Steps>\n See the [Model Context Protocol documentation](https://modelcontextprotocol.io/docs/tutorials/use-remote-mcp-server#connecting-to-a-remote-mcp-server) for more details.\n </TabContent>\n <TabContent title=\"Claude Code\">\n <Steps>\n <Step title=\"Get your MCP server URL.\">\n Your MCP server URL is available at `https://your-domain.com/api/mcp`.\n </Step>\n <Step title=\"Publish your MCP server URL for your users.\">\n Create a guide for your users that includes your MCP server URL and the command to connect it to Claude Code.\n ```bash\n claude mcp add --transport http <name> <url>\n ```\n </Step>\n </Steps>\n See the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code/mcp#installing-mcp-servers) for more details.\n </TabContent>\n <TabContent title=\"Cursor\">\n <Steps>\n <Step title=\"Get your MCP server URL.\">\n Your MCP server URL is available at `https://your-domain.com/api/mcp`.\n </Step>\n <Step title=\"Publish your MCP server URL for your users.\">\n Create a guide for your users that includes your MCP server URL and the steps to connect it to Cursor.\n 1. Use <kbd>Command</kbd> + <kbd>Shift</kbd> + <kbd>P</kbd> (<kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>P</kbd> on Windows) to open the command palette.\n 2. Search for \"Open MCP settings\".\n 3. Select **Add custom MCP**. This opens the `mcp.json` file.\n 4. In `mcp.json`, configure your server:\n ```json\n {\n \"mcpServers\": {\n \"<your-mcp-server-name>\": {\n \"url\": \"<your-mcp-server-url>\"\n }\n }\n }\n ```\n </Step>\n </Steps>\n See the [Cursor documentation](https://docs.cursor.com/en/context/mcp#installing-mcp-servers) for more details.\n </TabContent>\n <TabContent title=\"VS Code\">\n <Steps>\n <Step title=\"Get your MCP server URL.\">\n Your MCP server URL is available at `https://your-domain.com/api/mcp`.\n </Step>\n <Step title=\"Publish your MCP server URL for your users.\">\n Create a guide for your users that includes your MCP server URL and the steps to connect it to VS Code.\n 1. Create a `.vscode/mcp.json` file.\n 2. In `mcp.json`, configure your server:\n ```json\n {\n \"servers\": {\n \"<your-mcp-server-name>\": {\n \"type\": \"http\",\n \"url\": \"<your-mcp-server-url>\"\n }\n }\n }\n ```\n </Step>\n </Steps>\n See the [VS Code documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more details.\n </TabContent>\n</Tabs>\n\n## Requirements\n\nTo use the MCP server, you need to have the AI Assistant configured. The MCP server uses the same LLM configuration for generating embeddings.\n\n| Variable | Required | Description |\n| -------------- | -------- | ------------------------------------------------------------ |\n| `LLM_PROVIDER` | Yes | Your LLM provider (`openai`, `anthropic`, or `google`) |\n| `DOCS_API_KEY` | No | When set, requires Bearer token authentication on `/api/mcp` |\n\n<Callout type=\"warning\">\n The MCP server requires an LLM provider to be configured for generating embeddings. Make sure you have set up your AI Assistant with a valid API key before using the MCP server.\n</Callout>\n\nSee the [AI Assistant documentation](/ai-assistant) for configuration details.\n\n## Content indexing\n\nYour MCP server searches content extracted from your page files. The server automatically discovers and indexes all `page.tsx`, `page.ts`, `page.jsx`, and `page.js` files in your `app/` directory.\n\n### Content extraction\n\nThe server extracts content from `const content =` declarations in your page files. Make sure your documentation pages export a `content` constant with your markdown or MDX content.\n\n**Example:**\n\n```tsx\nexport const content = `\n# Getting Started\n\nWelcome to the documentation...\n`;\n```\n\n### Excluded directories\n\nThe following directories are automatically excluded from indexing:\n\n- `node_modules`\n- `.next`\n- `.git`\n- `api`\n\n## Troubleshooting\n\n### Index not building\n\nIf the index is not building, check:\n\n- Your LLM provider is configured correctly in your `.env` file\n- You have a valid API key set\n- Your documentation pages export a `content` constant\n\n### No search results\n\nIf searches return no results:\n\n- Verify that your documentation pages are in the `app/` directory\n- Check that your pages export a `content` constant\n- Ensure the index has been built (check the `index.ready` status via GET `/api/mcp`)\n\n### Slow search performance\n\nThe first search may be slower as it builds the index. Subsequent searches are fast as they use the in-memory index. If performance is consistently slow:\n\n- Check your embedding API response times\n- Consider reducing the number of documentation pages\n- Verify your server has sufficient memory\n\n### Cloudflare blocking MCP requests\n\nIf you use Cloudflare as a proxy in front of your documentation site, Cloudflare's bot protection may block server-to-server requests from AI tools like Claude.ai. This can cause MCP connections to fail silently or return errors.\n\nThere are two ways to resolve this:\n\n**Option 1: Disable the Cloudflare proxy (simplest)**\n\nIn your Cloudflare DNS settings, click the orange cloud icon next to your domain record to switch it to \"DNS only\" (grey cloud). This disables Cloudflare's proxy and bot protection for your domain, allowing MCP requests to reach your server directly.\n\n**Option 2: Add a Cloudflare WAF exception (keeps your custom domain proxied)**\n\nIn Cloudflare dashboard:\n\n1. Go to **Security > WAF**.\n2. Click **Create rule**.\n3. Set it up as:\n - **Rule name**: Allow MCP API\n - **Field**: URI Path\n - **Operator**: starts with\n - **Value**: `/api/mcp`\n - **Action**: Skip -- then check all remaining custom rules, Rate limiting rules, and Bot Fight Mode / Super Bot Fight Mode.\n4. Deploy the rule and make sure it is ordered first (above other rules).\n\n<Callout type=\"warning\">\n Also check **Security > Bots** in your Cloudflare dashboard. If \"Bot Fight Mode\" or \"Super Bot Fight Mode\" is enabled, that is very likely what is blocking server-to-server requests from AI tools.\n</Callout>\n\n## Best practices\n\n- **Keep content up-to-date**: Restart your server after updating documentation to rebuild the index with fresh content.\n- **Use descriptive queries**: More specific queries yield better semantic search results.\n- **Monitor index status**: Use the GET endpoint to check if your index is ready before performing searches.\n- **Optimize content structure**: Well-structured markdown with clear headings improves search relevance.";
|
|
1
|
+
export declare const mcpMdxTemplate = "---\ntitle: \"Model Context Protocol\"\ndescription: \"Connect your Doccupine documentation to AI tools with an MCP server for enhanced AI-powered documentation search.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 3\norder: 9\n---\n\n# Model Context Protocol\n\nConnect your documentation to AI tools with a hosted MCP server.\n\nDoccupine automatically generates a Model Context Protocol (MCP) server from your documentation, making your content accessible to AI applications like Claude, Cursor, VS Code, and other MCP-compatible tools. Your MCP server exposes semantic search capabilities, allowing AI tools to query your documentation directly and provide accurate, context-aware answers.\n\n<Callout type=\"warning\">\n The MCP server requires the [AI Assistant](/ai-assistant) to be configured. Make sure you have set up your LLM provider and API keys before using the MCP server.\n</Callout>\n\n## About MCP servers\n\nThe Model Context Protocol (MCP) is an open protocol that creates standardized connections between AI applications and external services, like documentation. Doccupine generates an MCP server from your documentation, preparing your content for the broader AI ecosystem where any MCP client can connect to your documentation.\n\nYour MCP server exposes search and retrieval tools for AI applications to query your documentation. Your users must connect your MCP server to their preferred AI tools to access your documentation.\n\n### How MCP servers work\n\nWhen an AI tool has your documentation MCP server connected, the AI tool can search your documentation directly instead of making a generic web search in response to a user's prompt. Your MCP server provides access to all indexed content from your documentation site.\n\n- The LLM can proactively search your documentation while generating a response, not just when explicitly asked.\n- The LLM determines when to use the search tool based on the context of the conversation and the relevance of your documentation.\n- Each tool call happens during the generation process, so the LLM searches up-to-date information from your documentation to generate its response.\n\n### MCP compared to web search\n\nAI tools can search the web, but MCP provides distinct advantages for documentation.\n\n- **Direct source access**: Web search depends on what search engines have indexed, which may be stale or incomplete. MCP searches your current indexed documentation directly.\n- **Integrated workflow**: MCP allows the AI to search during response generation rather than performing a separate web search.\n- **Semantic search**: MCP uses vector embeddings for semantic similarity search, providing more relevant results than keyword-based web search.\n- **No search noise**: SEO and ranking algorithms influence web search results. MCP goes straight to your documentation content.\n\n## Access your MCP server\n\nDoccupine automatically generates an MCP server for your documentation and hosts it at your documentation URL with the `/api/mcp` path. For example, if your documentation is hosted at `https://example.com`, your MCP server is available at `https://example.com/api/mcp`.\n\nThe MCP server provides both a GET endpoint to discover available tools and a POST endpoint to execute tool calls.\n\n### Authentication\n\nYou can optionally protect your MCP server with an API key by setting the `DOCS_API_KEY` environment variable in your `.env` file:\n\n```bash\nDOCS_API_KEY=your-secret-key\n```\n\nWhen `DOCS_API_KEY` is set, all requests to `/api/mcp` must include an `Authorization` header with a Bearer token:\n\n```text\nAuthorization: Bearer your-secret-key\n```\n\nRequests without a valid token receive a `401 Unauthorized` response. When `DOCS_API_KEY` is not set, the MCP server is publicly accessible with no authentication required.\n\n<Callout type=\"note\">\n Authentication only applies to the `/api/mcp` endpoint. The `/api/rag` endpoint used by the built-in AI Assistant chat is not affected by this setting.\n</Callout>\n\n### API Endpoints\n\n#### GET /api/mcp\n\nReturns information about available tools and the current index status.\n\n**Response:**\n\n```json\n{\n \"tools\": [\n {\n \"name\": \"search_docs\",\n \"description\": \"Search through the documentation content using semantic search...\",\n \"inputSchema\": { ... }\n },\n ...\n ],\n \"index\": {\n \"ready\": true,\n \"chunkCount\": 150\n }\n}\n```\n\n#### POST /api/mcp\n\nExecutes an MCP tool call.\n\n**Request Body:**\n\n```json\n{\n \"tool\": \"search_docs\",\n \"params\": {\n \"query\": \"how to deploy\",\n \"limit\": 6\n }\n}\n```\n\n**Response:**\n\n```json\n{\n \"content\": [\n {\n \"path\": \"app/deployment-and-hosting/page.tsx\",\n \"uri\": \"docs://deployment-and-hosting\",\n \"score\": \"0.892\",\n \"text\": \"Deploy your Doccupine site as a Next.js application...\"\n },\n ...\n ]\n}\n```\n\n## Available Tools\n\nYour MCP server exposes three tools for interacting with your documentation:\n\n### search_docs\n\nSearch through the documentation content using semantic search. Returns relevant chunks of documentation based on the query using vector embeddings and cosine similarity.\n\n**Parameters:**\n\n- `query` (required): The search query to find relevant documentation\n- `limit` (optional): Maximum number of results to return (default: 6)\n\n**Example:**\n\n```json\n{\n \"tool\": \"search_docs\",\n \"params\": {\n \"query\": \"how to configure AI assistant\",\n \"limit\": 5\n }\n}\n```\n\n### get_doc\n\nGet the full content of a specific documentation page by its path.\n\n**Parameters:**\n\n- `path` (required): The file path to the documentation page (e.g., `app/getting-started/page.tsx`)\n\n**Example:**\n\n```json\n{\n \"tool\": \"get_doc\",\n \"params\": {\n \"path\": \"app/deployment-and-hosting/page.tsx\"\n }\n}\n```\n\n### list_docs\n\nList all available documentation pages, optionally filtered by directory.\n\n**Parameters:**\n\n- `directory` (optional): Optional directory to filter results (e.g., `components`)\n\n**Example:**\n\n```json\n{\n \"tool\": \"list_docs\",\n \"params\": {\n \"directory\": \"configuration\"\n }\n}\n```\n\n## How it works\n\nDoccupine's MCP server uses semantic search powered by vector embeddings to provide accurate, context-aware search results.\n\n### Indexing Process\n\nDoccupine runs this pipeline at build time and ships the resulting vectors with your site, so the server loads a ready-made index instead of re-embedding your docs on every cold start.\n\n1. **Document Discovery**: Doccupine scans your `app/` directory for all `page.tsx`, `page.ts`, `page.jsx`, and `page.js` files.\n2. **Content Extraction**: It extracts content from `const content =` declarations in your page files.\n3. **Chunking**: Large documents are split into chunks of approximately 800 characters with 100 character overlap for better context preservation.\n4. **Embedding Generation**: Each chunk is converted to a vector embedding using your configured LLM provider's embedding model.\n5. **Index Building**: The embeddings are written to `services/mcp/docs-index.json`, bundled into your serverless functions, and loaded into memory at runtime for fast similarity search.\n\n### Search Process\n\n1. **Query Embedding**: The search query is converted to a vector embedding using the same embedding model.\n2. **Similarity Calculation**: Cosine similarity is calculated between the query embedding and all document chunk embeddings.\n3. **Ranking**: Results are sorted by similarity score (highest first).\n4. **Response**: The top N results (based on the limit parameter) are returned with their paths, URIs, scores, and text content.\n\n<Callout type=\"note\">\n Embeddings are precomputed at build time and bundled with your site, so the server loads the ready-made index into memory on startup instead of re-embedding your docs on every cold start. It only embeds at runtime when the precomputed index is missing or was built with a different provider or model. When you update your documentation, rebuild or redeploy your site to regenerate the index with fresh content.\n</Callout>\n\n## Use your MCP server\n\nYour users must connect your MCP server to their preferred AI tools.\n\n1. Make your MCP server URL publicly available.\n2. Users copy your MCP server URL and add it to their tools.\n3. Users access your documentation through their tools.\n\nThese are some of the ways you can help your users connect to your MCP server:\n\n<Tabs>\n <TabContent title=\"Claude\">\n <Steps>\n <Step title=\"Get your MCP server URL.\">\n Your MCP server URL is available at `https://your-domain.com/api/mcp`.\n </Step>\n <Step title=\"Publish your MCP server URL for your users.\">\n Create a guide for your users that includes your MCP server URL and the steps to connect it to Claude.\n 1. Navigate to the [Connectors](https://claude.ai/settings/connectors) page in the Claude settings.\n 2. Select **Add custom connector**.\n 3. Add your MCP server name and URL.\n 4. Select **Add**.\n 5. When using Claude, select the attachments button (the plus icon).\n 6. Select your MCP server.\n </Step>\n </Steps>\n See the [Model Context Protocol documentation](https://modelcontextprotocol.io/docs/tutorials/use-remote-mcp-server#connecting-to-a-remote-mcp-server) for more details.\n </TabContent>\n <TabContent title=\"Claude Code\">\n <Steps>\n <Step title=\"Get your MCP server URL.\">\n Your MCP server URL is available at `https://your-domain.com/api/mcp`.\n </Step>\n <Step title=\"Publish your MCP server URL for your users.\">\n Create a guide for your users that includes your MCP server URL and the command to connect it to Claude Code.\n ```bash\n claude mcp add --transport http <name> <url>\n ```\n </Step>\n </Steps>\n See the [Claude Code documentation](https://docs.anthropic.com/en/docs/claude-code/mcp#installing-mcp-servers) for more details.\n </TabContent>\n <TabContent title=\"Cursor\">\n <Steps>\n <Step title=\"Get your MCP server URL.\">\n Your MCP server URL is available at `https://your-domain.com/api/mcp`.\n </Step>\n <Step title=\"Publish your MCP server URL for your users.\">\n Create a guide for your users that includes your MCP server URL and the steps to connect it to Cursor.\n 1. Use <kbd>Command</kbd> + <kbd>Shift</kbd> + <kbd>P</kbd> (<kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>P</kbd> on Windows) to open the command palette.\n 2. Search for \"Open MCP settings\".\n 3. Select **Add custom MCP**. This opens the `mcp.json` file.\n 4. In `mcp.json`, configure your server:\n ```json\n {\n \"mcpServers\": {\n \"<your-mcp-server-name>\": {\n \"url\": \"<your-mcp-server-url>\"\n }\n }\n }\n ```\n </Step>\n </Steps>\n See the [Cursor documentation](https://docs.cursor.com/en/context/mcp#installing-mcp-servers) for more details.\n </TabContent>\n <TabContent title=\"VS Code\">\n <Steps>\n <Step title=\"Get your MCP server URL.\">\n Your MCP server URL is available at `https://your-domain.com/api/mcp`.\n </Step>\n <Step title=\"Publish your MCP server URL for your users.\">\n Create a guide for your users that includes your MCP server URL and the steps to connect it to VS Code.\n 1. Create a `.vscode/mcp.json` file.\n 2. In `mcp.json`, configure your server:\n ```json\n {\n \"servers\": {\n \"<your-mcp-server-name>\": {\n \"type\": \"http\",\n \"url\": \"<your-mcp-server-url>\"\n }\n }\n }\n ```\n </Step>\n </Steps>\n See the [VS Code documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers) for more details.\n </TabContent>\n</Tabs>\n\n## Requirements\n\nTo use the MCP server, you need to have the AI Assistant configured. The MCP server uses the same LLM configuration for generating embeddings.\n\n| Variable | Required | Description |\n| -------------- | -------- | ------------------------------------------------------------ |\n| `LLM_PROVIDER` | Yes | Your LLM provider (`openai`, `anthropic`, or `google`) |\n| `DOCS_API_KEY` | No | When set, requires Bearer token authentication on `/api/mcp` |\n\n<Callout type=\"warning\">\n The MCP server requires an LLM provider to be configured for generating embeddings. Make sure you have set up your AI Assistant with a valid API key before using the MCP server.\n</Callout>\n\nSee the [AI Assistant documentation](/ai-assistant) for configuration details.\n\n## Content indexing\n\nYour MCP server searches content extracted from your page files. The server automatically discovers and indexes all `page.tsx`, `page.ts`, `page.jsx`, and `page.js` files in your `app/` directory.\n\n### Content extraction\n\nThe server extracts content from `const content =` declarations in your page files. Make sure your documentation pages export a `content` constant with your markdown or MDX content.\n\n**Example:**\n\n```tsx\nexport const content = `\n# Getting Started\n\nWelcome to the documentation...\n`;\n```\n\n### Excluded directories\n\nThe following directories are automatically excluded from indexing:\n\n- `node_modules`\n- `.next`\n- `.git`\n- `api`\n\n## Troubleshooting\n\n### Index not building\n\nIf the index is not building, check:\n\n- Your LLM provider is configured correctly in your `.env` file\n- You have a valid API key set\n- Your documentation pages export a `content` constant\n\n### No search results\n\nIf searches return no results:\n\n- Verify that your documentation pages are in the `app/` directory\n- Check that your pages export a `content` constant\n- Ensure the index has been built (check the `index.ready` status via GET `/api/mcp`)\n\n### Slow search performance\n\nSearches use the precomputed in-memory index and are fast. The first search is only slow when the app has to embed your docs at runtime, which happens when the precomputed index is missing or was built with a different provider or model. If performance is consistently slow:\n\n- Confirm the build ran the doc-index precompute step (the `build` script runs it before `next build`) with a valid API key\n- Check your embedding API response times\n- Consider reducing the number of documentation pages\n- Verify your server has sufficient memory\n\n### Cloudflare blocking MCP requests\n\nIf you use Cloudflare as a proxy in front of your documentation site, Cloudflare's bot protection may block server-to-server requests from AI tools like Claude.ai. This can cause MCP connections to fail silently or return errors.\n\nThere are two ways to resolve this:\n\n**Option 1: Disable the Cloudflare proxy (simplest)**\n\nIn your Cloudflare DNS settings, click the orange cloud icon next to your domain record to switch it to \"DNS only\" (grey cloud). This disables Cloudflare's proxy and bot protection for your domain, allowing MCP requests to reach your server directly.\n\n**Option 2: Add a Cloudflare WAF exception (keeps your custom domain proxied)**\n\nIn Cloudflare dashboard:\n\n1. Go to **Security > WAF**.\n2. Click **Create rule**.\n3. Set it up as:\n - **Rule name**: Allow MCP API\n - **Field**: URI Path\n - **Operator**: starts with\n - **Value**: `/api/mcp`\n - **Action**: Skip -- then check all remaining custom rules, Rate limiting rules, and Bot Fight Mode / Super Bot Fight Mode.\n4. Deploy the rule and make sure it is ordered first (above other rules).\n\n<Callout type=\"warning\">\n Also check **Security > Bots** in your Cloudflare dashboard. If \"Bot Fight Mode\" or \"Super Bot Fight Mode\" is enabled, that is very likely what is blocking server-to-server requests from AI tools.\n</Callout>\n\n## Best practices\n\n- **Keep content up-to-date**: Rebuild or redeploy your site after updating documentation to regenerate the index with fresh content.\n- **Use descriptive queries**: More specific queries yield better semantic search results.\n- **Monitor index status**: Use the GET endpoint to check if your index is ready before performing searches.\n- **Optimize content structure**: Well-structured markdown with clear headings improves search relevance.";
|
|
@@ -192,11 +192,13 @@ Doccupine's MCP server uses semantic search powered by vector embeddings to prov
|
|
|
192
192
|
|
|
193
193
|
### Indexing Process
|
|
194
194
|
|
|
195
|
-
|
|
195
|
+
Doccupine runs this pipeline at build time and ships the resulting vectors with your site, so the server loads a ready-made index instead of re-embedding your docs on every cold start.
|
|
196
|
+
|
|
197
|
+
1. **Document Discovery**: Doccupine scans your \`app/\` directory for all \`page.tsx\`, \`page.ts\`, \`page.jsx\`, and \`page.js\` files.
|
|
196
198
|
2. **Content Extraction**: It extracts content from \`const content =\` declarations in your page files.
|
|
197
199
|
3. **Chunking**: Large documents are split into chunks of approximately 800 characters with 100 character overlap for better context preservation.
|
|
198
200
|
4. **Embedding Generation**: Each chunk is converted to a vector embedding using your configured LLM provider's embedding model.
|
|
199
|
-
5. **Index Building**:
|
|
201
|
+
5. **Index Building**: The embeddings are written to \`services/mcp/docs-index.json\`, bundled into your serverless functions, and loaded into memory at runtime for fast similarity search.
|
|
200
202
|
|
|
201
203
|
### Search Process
|
|
202
204
|
|
|
@@ -206,7 +208,7 @@ Doccupine's MCP server uses semantic search powered by vector embeddings to prov
|
|
|
206
208
|
4. **Response**: The top N results (based on the limit parameter) are returned with their paths, URIs, scores, and text content.
|
|
207
209
|
|
|
208
210
|
<Callout type="note">
|
|
209
|
-
|
|
211
|
+
Embeddings are precomputed at build time and bundled with your site, so the server loads the ready-made index into memory on startup instead of re-embedding your docs on every cold start. It only embeds at runtime when the precomputed index is missing or was built with a different provider or model. When you update your documentation, rebuild or redeploy your site to regenerate the index with fresh content.
|
|
210
212
|
</Callout>
|
|
211
213
|
|
|
212
214
|
## Use your MCP server
|
|
@@ -362,8 +364,9 @@ If searches return no results:
|
|
|
362
364
|
|
|
363
365
|
### Slow search performance
|
|
364
366
|
|
|
365
|
-
The first search
|
|
367
|
+
Searches use the precomputed in-memory index and are fast. The first search is only slow when the app has to embed your docs at runtime, which happens when the precomputed index is missing or was built with a different provider or model. If performance is consistently slow:
|
|
366
368
|
|
|
369
|
+
- Confirm the build ran the doc-index precompute step (the \`build\` script runs it before \`next build\`) with a valid API key
|
|
367
370
|
- Check your embedding API response times
|
|
368
371
|
- Consider reducing the number of documentation pages
|
|
369
372
|
- Verify your server has sufficient memory
|
|
@@ -398,7 +401,7 @@ In Cloudflare dashboard:
|
|
|
398
401
|
|
|
399
402
|
## Best practices
|
|
400
403
|
|
|
401
|
-
- **Keep content up-to-date**:
|
|
404
|
+
- **Keep content up-to-date**: Rebuild or redeploy your site after updating documentation to regenerate the index with fresh content.
|
|
402
405
|
- **Use descriptive queries**: More specific queries yield better semantic search results.
|
|
403
406
|
- **Monitor index status**: Use the GET endpoint to check if your index is ready before performing searches.
|
|
404
407
|
- **Optimize content structure**: Well-structured markdown with clear headings improves search relevance.`;
|
|
@@ -7,6 +7,12 @@ const nextConfig: NextConfig = {
|
|
|
7
7
|
compiler: {
|
|
8
8
|
styledComponents: true,
|
|
9
9
|
},
|
|
10
|
+
// Bundle the precomputed embeddings index into the serverless functions that
|
|
11
|
+
// read it (a dynamic fs read is invisible to Next's file tracing).
|
|
12
|
+
outputFileTracingIncludes: {
|
|
13
|
+
"/api/rag": ["./services/mcp/docs-index.json"],
|
|
14
|
+
"/api/mcp": ["./services/mcp/docs-index.json"],
|
|
15
|
+
},
|
|
10
16
|
};
|
|
11
17
|
|
|
12
18
|
export default nextConfig;
|
|
@@ -23,6 +29,12 @@ const nextConfig: NextConfig = {
|
|
|
23
29
|
compiler: {
|
|
24
30
|
styledComponents: true,
|
|
25
31
|
},
|
|
32
|
+
// Bundle the precomputed embeddings index into the serverless functions that
|
|
33
|
+
// read it (a dynamic fs read is invisible to Next's file tracing).
|
|
34
|
+
outputFileTracingIncludes: {
|
|
35
|
+
"/api/rag": ["./services/mcp/docs-index.json"],
|
|
36
|
+
"/api/mcp": ["./services/mcp/docs-index.json"],
|
|
37
|
+
},
|
|
26
38
|
async rewrites() {
|
|
27
39
|
return [
|
|
28
40
|
{
|
|
@@ -4,7 +4,7 @@ export const packageJsonTemplate = JSON.stringify({
|
|
|
4
4
|
private: true,
|
|
5
5
|
scripts: {
|
|
6
6
|
dev: "next dev",
|
|
7
|
-
build: "next build",
|
|
7
|
+
build: "tsx scripts/build-docs-index.mts && next build",
|
|
8
8
|
start: "next start",
|
|
9
9
|
lint: "eslint .",
|
|
10
10
|
format: "prettier --write .",
|
|
@@ -37,6 +37,7 @@ export const packageJsonTemplate = JSON.stringify({
|
|
|
37
37
|
zod: "^4.4.3",
|
|
38
38
|
},
|
|
39
39
|
devDependencies: {
|
|
40
|
+
"@next/env": "16.2.10",
|
|
40
41
|
"@next/eslint-plugin-next": "16.2.10",
|
|
41
42
|
"@types/node": "^26",
|
|
42
43
|
"@types/react": "^19",
|
|
@@ -51,6 +52,7 @@ export const packageJsonTemplate = JSON.stringify({
|
|
|
51
52
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
52
53
|
globals: "^17.7.0",
|
|
53
54
|
prettier: "^3.9.5",
|
|
55
|
+
tsx: "^4.23.0",
|
|
54
56
|
typescript: "npm:@typescript/typescript6@^6.0.2",
|
|
55
57
|
},
|
|
56
58
|
}, null, 2) + "\n";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const pnpmWorkspaceTemplate = "allowBuilds:\n core-js: false\n protobufjs: false\n sharp: false\n unrs-resolver: false\n# Disable pnpm's minimum-release-age supply-chain gate for the generated app.\n# The gate blocks freshly published versions, which trips installs whenever the\n# pinned dependencies here are newer than the cutoff. Set to 0 to install any\n# resolved version without waiting out the release-age window.\nminimumReleaseAge: 0\n";
|
|
1
|
+
export declare const pnpmWorkspaceTemplate = "allowBuilds:\n core-js: false\n esbuild: false\n protobufjs: false\n sharp: false\n unrs-resolver: false\n# Disable pnpm's minimum-release-age supply-chain gate for the generated app.\n# The gate blocks freshly published versions, which trips installs whenever the\n# pinned dependencies here are newer than the cutoff. Set to 0 to install any\n# resolved version without waiting out the release-age window.\nminimumReleaseAge: 0\n";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const prettierignoreTemplate = "node_modules\nconvex/_generated\npublic\npackage.json\npackage-lock.json\npnpm-lock.yaml\npnpm-workspace.yaml\n";
|
|
1
|
+
export declare const prettierignoreTemplate = "node_modules\nconvex/_generated\npublic\npackage.json\npackage-lock.json\npnpm-lock.yaml\npnpm-workspace.yaml\nservices/mcp/docs-index.json\n";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const buildDocsIndexScriptTemplate = "/**\n * Precompute document embeddings at build time.\n *\n * Runs before `next build` (wired into the \"build\" script in package.json).\n * Embeds every docs chunk once and writes services/mcp/docs-index.json, so the\n * running app loads vectors instead of re-embedding the whole doc set on every\n * serverless cold start (the main cause of slow first chats / proxy timeouts).\n *\n * Fails soft: without an API key, or on any embedding error, it leaves the\n * existing (possibly empty) index in place and exits 0 so the build proceeds.\n * The app then falls back to embedding on demand at runtime.\n */\nimport path from \"node:path\";\nimport { writeFileSync } from \"node:fs\";\n// @next/env is CommonJS; use a default import so the named export resolves\n// under tsx's ESM loader (a named import is not statically detected).\nimport nextEnv from \"@next/env\";\nimport { getAllDocsChunks } from \"../services/mcp/tools\";\nimport { getLLMConfig, isLLMAvailable } from \"../services/llm/config\";\nimport { createEmbeddings } from \"../services/llm/factory\";\n\n// This runs as a standalone process before `next build`, so it must load\n// .env / .env.local / .env.production itself (the same way Next does). Real\n// environment variables still take precedence over .env files.\nnextEnv.loadEnvConfig(process.cwd());\n\nconst OUTPUT = path.join(process.cwd(), \"services\", \"mcp\", \"docs-index.json\");\nconst BATCH_SIZE = 10;\n\nasync function main() {\n if (!isLLMAvailable()) {\n console.warn(\n \"[doccupine] No LLM API key set - skipping embedding precompute. \" +\n \"The chat will embed docs on demand at runtime.\",\n );\n return;\n }\n\n const chunks = await getAllDocsChunks();\n if (chunks.length === 0) {\n console.warn(\"[doccupine] No docs found to embed - skipping precompute.\");\n return;\n }\n\n const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n\n // Embed in small batches to stay within provider token limits.\n const texts = chunks.map((c) => c.text);\n const vectors: number[][] = [];\n for (let i = 0; i < texts.length; i += BATCH_SIZE) {\n const batch = await embeddings.embedDocuments(\n texts.slice(i, i + BATCH_SIZE),\n );\n vectors.push(...batch);\n }\n\n const data = {\n provider: config.provider,\n embeddingModel: config.embeddingModel,\n chunks: chunks.map((c, i) => ({ ...c, embedding: vectors[i] })),\n };\n\n writeFileSync(OUTPUT, JSON.stringify(data));\n console.log(\n `[doccupine] Precomputed ${data.chunks.length} doc embeddings -> ${OUTPUT}`,\n );\n}\n\nmain()\n .then(() => process.exit(0))\n .catch((error) => {\n // Never fail the build on an embedding error - fall back to runtime embedding.\n console.warn(\n \"[doccupine] Embedding precompute failed; continuing build. \" +\n \"The chat will embed docs at runtime.\",\n error instanceof Error ? error.message : error,\n );\n process.exit(0);\n });\n";
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export const buildDocsIndexScriptTemplate = `/**
|
|
2
|
+
* Precompute document embeddings at build time.
|
|
3
|
+
*
|
|
4
|
+
* Runs before \`next build\` (wired into the "build" script in package.json).
|
|
5
|
+
* Embeds every docs chunk once and writes services/mcp/docs-index.json, so the
|
|
6
|
+
* running app loads vectors instead of re-embedding the whole doc set on every
|
|
7
|
+
* serverless cold start (the main cause of slow first chats / proxy timeouts).
|
|
8
|
+
*
|
|
9
|
+
* Fails soft: without an API key, or on any embedding error, it leaves the
|
|
10
|
+
* existing (possibly empty) index in place and exits 0 so the build proceeds.
|
|
11
|
+
* The app then falls back to embedding on demand at runtime.
|
|
12
|
+
*/
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { writeFileSync } from "node:fs";
|
|
15
|
+
// @next/env is CommonJS; use a default import so the named export resolves
|
|
16
|
+
// under tsx's ESM loader (a named import is not statically detected).
|
|
17
|
+
import nextEnv from "@next/env";
|
|
18
|
+
import { getAllDocsChunks } from "../services/mcp/tools";
|
|
19
|
+
import { getLLMConfig, isLLMAvailable } from "../services/llm/config";
|
|
20
|
+
import { createEmbeddings } from "../services/llm/factory";
|
|
21
|
+
|
|
22
|
+
// This runs as a standalone process before \`next build\`, so it must load
|
|
23
|
+
// .env / .env.local / .env.production itself (the same way Next does). Real
|
|
24
|
+
// environment variables still take precedence over .env files.
|
|
25
|
+
nextEnv.loadEnvConfig(process.cwd());
|
|
26
|
+
|
|
27
|
+
const OUTPUT = path.join(process.cwd(), "services", "mcp", "docs-index.json");
|
|
28
|
+
const BATCH_SIZE = 10;
|
|
29
|
+
|
|
30
|
+
async function main() {
|
|
31
|
+
if (!isLLMAvailable()) {
|
|
32
|
+
console.warn(
|
|
33
|
+
"[doccupine] No LLM API key set - skipping embedding precompute. " +
|
|
34
|
+
"The chat will embed docs on demand at runtime.",
|
|
35
|
+
);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const chunks = await getAllDocsChunks();
|
|
40
|
+
if (chunks.length === 0) {
|
|
41
|
+
console.warn("[doccupine] No docs found to embed - skipping precompute.");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const config = getLLMConfig();
|
|
46
|
+
const embeddings = createEmbeddings(config);
|
|
47
|
+
|
|
48
|
+
// Embed in small batches to stay within provider token limits.
|
|
49
|
+
const texts = chunks.map((c) => c.text);
|
|
50
|
+
const vectors: number[][] = [];
|
|
51
|
+
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
|
|
52
|
+
const batch = await embeddings.embedDocuments(
|
|
53
|
+
texts.slice(i, i + BATCH_SIZE),
|
|
54
|
+
);
|
|
55
|
+
vectors.push(...batch);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const data = {
|
|
59
|
+
provider: config.provider,
|
|
60
|
+
embeddingModel: config.embeddingModel,
|
|
61
|
+
chunks: chunks.map((c, i) => ({ ...c, embedding: vectors[i] })),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
writeFileSync(OUTPUT, JSON.stringify(data));
|
|
65
|
+
console.log(
|
|
66
|
+
\`[doccupine] Precomputed \${data.chunks.length} doc embeddings -> \${OUTPUT}\`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
main()
|
|
71
|
+
.then(() => process.exit(0))
|
|
72
|
+
.catch((error) => {
|
|
73
|
+
// Never fail the build on an embedding error - fall back to runtime embedding.
|
|
74
|
+
console.warn(
|
|
75
|
+
"[doccupine] Embedding precompute failed; continuing build. " +
|
|
76
|
+
"The chat will embed docs at runtime.",
|
|
77
|
+
error instanceof Error ? error.message : error,
|
|
78
|
+
);
|
|
79
|
+
process.exit(0);
|
|
80
|
+
});
|
|
81
|
+
`;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const docsIndexStubTemplate = "{\n \"provider\": null,\n \"embeddingModel\": null,\n \"chunks\": []\n}\n";
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Placeholder embeddings index. Populated at build time by
|
|
2
|
+
// scripts/build-docs-index.mts; the stub keeps the path present for dev and
|
|
3
|
+
// for outputFileTracingIncludes. Empty chunks -> the app embeds at runtime.
|
|
4
|
+
export const docsIndexStubTemplate = `{
|
|
5
|
+
"provider": null,
|
|
6
|
+
"embeddingModel": null,
|
|
7
|
+
"chunks": []
|
|
8
|
+
}
|
|
9
|
+
`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const mcpServerTemplate = "import { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport {\n listDocs,\n getDoc,\n getAllDocsChunks,\n DOCS_TOOLS,\n} from \"@/services/mcp/tools\";\nimport { getLLMConfig, isLLMAvailable, createEmbeddings } from \"@/services/llm\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/**\n * In-memory cache for document embeddings.\n * Built once at server startup since docs are static.\n */\nlet docsIndex: {\n ready: boolean;\n building: boolean;\n chunks: (DocsChunk & { embedding: number[] })[];\n} = {\n ready: false,\n building: false,\n chunks: [],\n};\n\n/** Resolves when the initial index build completes */\nlet indexReady: Promise<void> | null = null;\n\n/**\n * Cosine similarity between two vectors\n */\nfunction cosineSim(a: number[], b: number[]): number {\n let dot = 0,\n na = 0,\n nb = 0;\n for (let i = 0; i < a.length; i++) {\n const x = a[i];\n const y = b[i];\n dot += x * y;\n na += x * x;\n nb += y * y;\n }\n if (na === 0 || nb === 0) return 0;\n return dot / (Math.sqrt(na) * Math.sqrt(nb));\n}\n\n/**\n * Build or rebuild the documentation index\n */\nasync function buildDocsIndex(force = false): Promise<void> {\n if (docsIndex.building) return;\n if (docsIndex.ready && !force) return;\n\n docsIndex.building = true;\n try {\n const chunks = await getAllDocsChunks();\n\n if (chunks.length === 0) {\n docsIndex.chunks = [];\n docsIndex.ready = true;\n return;\n }\n\n const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n\n // Process embeddings in small batches to avoid exceeding token limits\n const BATCH_SIZE = 10;\n const texts = chunks.map((c) => c.text);\n const vectors: number[][] = [];\n\n for (let i = 0; i < texts.length; i += BATCH_SIZE) {\n const batch = texts.slice(i, i + BATCH_SIZE);\n const batchVectors = await embeddings.embedDocuments(batch);\n vectors.push(...batchVectors);\n }\n\n docsIndex.chunks = chunks.map((c, i) => ({\n ...c,\n embedding: vectors[i],\n }));\n docsIndex.ready = true;\n } catch (error) {\n // Reset so the next call to ensureDocsIndex retries\n indexReady = null;\n throw error;\n } finally {\n docsIndex.building = false;\n }\n}\n\n/**\n * Ensure the docs index is ready.\n * On first call, triggers the build; subsequent calls wait for the same promise.\n */\nexport async function ensureDocsIndex(force = false): Promise<void> {\n if (force) {\n // Wait for any in-flight build before starting a forced rebuild\n if (docsIndex.building && indexReady) {\n await indexReady.catch(() => {});\n }\n docsIndex.ready = false;\n docsIndex.chunks = [];\n indexReady = buildDocsIndex(true);\n return indexReady;\n }\n if (!indexReady) {\n indexReady = buildDocsIndex();\n }\n return indexReady;\n}\n\n// Eagerly start building the index on server startup if LLM is configured\nif (isLLMAvailable()) {\n indexReady = buildDocsIndex();\n}\n\n/** Cached embeddings instance for search queries */\nlet cachedEmbeddings: ReturnType<typeof createEmbeddings> | null = null;\n\nfunction getEmbeddings() {\n if (!cachedEmbeddings) {\n cachedEmbeddings = createEmbeddings(getLLMConfig());\n }\n return cachedEmbeddings;\n}\n\n/**\n * Search documents using semantic similarity\n */\nexport async function searchDocs(\n query: string,\n limit = 6,\n): Promise<{ chunk: DocsChunk; score: number }[]> {\n await ensureDocsIndex();\n\n const queryVector = await getEmbeddings().embedQuery(query);\n\n const scored = docsIndex.chunks\n .map((c) => ({\n chunk: { id: c.id, text: c.text, path: c.path, uri: c.uri },\n score: cosineSim(queryVector, c.embedding),\n }))\n .sort((a, b) => b.score - a.score)\n .slice(0, limit);\n\n return scored;\n}\n\n/**\n * Get the current index status\n */\nexport function getIndexStatus(): { ready: boolean; chunkCount: number } {\n return {\n ready: docsIndex.ready,\n chunkCount: docsIndex.chunks.length,\n };\n}\n\n/**\n * Create and configure the MCP server with documentation tools\n */\nexport function createMCPServer(): McpServer {\n const server = new McpServer({\n name: \"docs-server\",\n version: \"1.0.0\",\n });\n\n // Register the search_docs tool\n server.tool(\n \"search_docs\",\n DOCS_TOOLS[0].description,\n {\n query: z\n .string()\n .describe(\"The search query to find relevant documentation\"),\n limit: z\n .number()\n .optional()\n .describe(\"Maximum number of results to return (default: 6)\"),\n },\n async ({ query, limit }) => {\n const results = await searchDocs(query, limit ?? 6);\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n results.map(({ chunk, score }) => ({\n path: chunk.path,\n uri: chunk.uri,\n score: score.toFixed(3),\n text: chunk.text,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register the get_doc tool\n server.tool(\n \"get_doc\",\n DOCS_TOOLS[1].description,\n {\n path: z.string().describe(\"The file path to the documentation page\"),\n },\n async ({ path }) => {\n const doc = await getDoc({ path });\n if (!doc) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify({ error: \"Document not found\" }),\n },\n ],\n isError: true,\n };\n }\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(doc, null, 2),\n },\n ],\n };\n },\n );\n\n // Register the list_docs tool\n server.tool(\n \"list_docs\",\n DOCS_TOOLS[2].description,\n {\n directory: z\n .string()\n .optional()\n .describe(\"Optional directory to filter results\"),\n },\n async ({ directory }) => {\n const docs = await listDocs({ directory });\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n docs.map((d) => ({\n name: d.name,\n path: d.path,\n uri: d.uri,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register documentation as resources\n server.resource(\"docs://list\", \"docs://list\", async () => {\n const docs = await listDocs();\n return {\n contents: [\n {\n uri: \"docs://list\",\n text: JSON.stringify(\n docs.map((d) => ({ name: d.name, path: d.path, uri: d.uri })),\n null,\n 2,\n ),\n },\n ],\n };\n });\n\n return server;\n}\n";
|
|
1
|
+
export declare const mcpServerTemplate = "import path from \"node:path\";\nimport fs from \"node:fs\";\nimport { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport {\n listDocs,\n getDoc,\n getAllDocsChunks,\n DOCS_TOOLS,\n} from \"@/services/mcp/tools\";\nimport { getLLMConfig, isLLMAvailable, createEmbeddings } from \"@/services/llm\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/**\n * In-memory cache for document embeddings.\n * Built once at server startup since docs are static.\n */\nlet docsIndex: {\n ready: boolean;\n building: boolean;\n chunks: (DocsChunk & { embedding: number[] })[];\n} = {\n ready: false,\n building: false,\n chunks: [],\n};\n\n/** Resolves when the initial index build completes */\nlet indexReady: Promise<void> | null = null;\n\n/**\n * Cosine similarity between two vectors\n */\nfunction cosineSim(a: number[], b: number[]): number {\n let dot = 0,\n na = 0,\n nb = 0;\n for (let i = 0; i < a.length; i++) {\n const x = a[i];\n const y = b[i];\n dot += x * y;\n na += x * x;\n nb += y * y;\n }\n if (na === 0 || nb === 0) return 0;\n return dot / (Math.sqrt(na) * Math.sqrt(nb));\n}\n\n/**\n * Absolute path to the embeddings index precomputed at build time by\n * scripts/build-docs-index.mts. Bundled into serverless functions via\n * outputFileTracingIncludes in next.config.ts.\n */\nconst INDEX_FILE = path.join(\n process.cwd(),\n \"services\",\n \"mcp\",\n \"docs-index.json\",\n);\n\n/**\n * Load embeddings precomputed at build time. Returns null when the file is\n * missing/empty or was built with a different provider/model than the current\n * config - query and document vectors must come from the same embedding model.\n */\nfunction loadPrecomputedIndex():\n (DocsChunk & { embedding: number[] })[] | null {\n try {\n const parsed = JSON.parse(fs.readFileSync(INDEX_FILE, \"utf8\")) as {\n provider?: string;\n embeddingModel?: string;\n chunks?: (DocsChunk & { embedding: number[] })[];\n };\n if (!parsed.chunks || parsed.chunks.length === 0) return null;\n const config = getLLMConfig();\n if (\n parsed.provider !== config.provider ||\n parsed.embeddingModel !== config.embeddingModel\n ) {\n return null;\n }\n return parsed.chunks;\n } catch {\n return null;\n }\n}\n\n/**\n * Build or rebuild the documentation index\n */\nasync function buildDocsIndex(force = false): Promise<void> {\n if (docsIndex.building) return;\n if (docsIndex.ready && !force) return;\n\n docsIndex.building = true;\n try {\n // Prefer embeddings precomputed at build time - avoids re-embedding the\n // entire doc set on every cold start (the main cause of slow first chats).\n if (!force) {\n const precomputed = loadPrecomputedIndex();\n if (precomputed) {\n docsIndex.chunks = precomputed;\n docsIndex.ready = true;\n return;\n }\n }\n\n const chunks = await getAllDocsChunks();\n\n if (chunks.length === 0) {\n docsIndex.chunks = [];\n docsIndex.ready = true;\n return;\n }\n\n const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n\n // Process embeddings in small batches to avoid exceeding token limits\n const BATCH_SIZE = 10;\n const texts = chunks.map((c) => c.text);\n const vectors: number[][] = [];\n\n for (let i = 0; i < texts.length; i += BATCH_SIZE) {\n const batch = texts.slice(i, i + BATCH_SIZE);\n const batchVectors = await embeddings.embedDocuments(batch);\n vectors.push(...batchVectors);\n }\n\n docsIndex.chunks = chunks.map((c, i) => ({\n ...c,\n embedding: vectors[i],\n }));\n docsIndex.ready = true;\n } catch (error) {\n // Reset so the next call to ensureDocsIndex retries\n indexReady = null;\n throw error;\n } finally {\n docsIndex.building = false;\n }\n}\n\n/**\n * Ensure the docs index is ready.\n * On first call, triggers the build; subsequent calls wait for the same promise.\n */\nexport async function ensureDocsIndex(force = false): Promise<void> {\n if (force) {\n // Wait for any in-flight build before starting a forced rebuild\n if (docsIndex.building && indexReady) {\n await indexReady.catch(() => {});\n }\n docsIndex.ready = false;\n docsIndex.chunks = [];\n indexReady = buildDocsIndex(true);\n return indexReady;\n }\n if (!indexReady) {\n indexReady = buildDocsIndex();\n }\n return indexReady;\n}\n\n// Eagerly start building the index on server startup if LLM is configured\nif (isLLMAvailable()) {\n indexReady = buildDocsIndex();\n}\n\n/** Cached embeddings instance for search queries */\nlet cachedEmbeddings: ReturnType<typeof createEmbeddings> | null = null;\n\nfunction getEmbeddings() {\n if (!cachedEmbeddings) {\n cachedEmbeddings = createEmbeddings(getLLMConfig());\n }\n return cachedEmbeddings;\n}\n\n/**\n * Search documents using semantic similarity\n */\nexport async function searchDocs(\n query: string,\n limit = 6,\n): Promise<{ chunk: DocsChunk; score: number }[]> {\n await ensureDocsIndex();\n\n const queryVector = await getEmbeddings().embedQuery(query);\n\n const scored = docsIndex.chunks\n .map((c) => ({\n chunk: { id: c.id, text: c.text, path: c.path, uri: c.uri },\n score: cosineSim(queryVector, c.embedding),\n }))\n .sort((a, b) => b.score - a.score)\n .slice(0, limit);\n\n return scored;\n}\n\n/**\n * Get the current index status\n */\nexport function getIndexStatus(): { ready: boolean; chunkCount: number } {\n return {\n ready: docsIndex.ready,\n chunkCount: docsIndex.chunks.length,\n };\n}\n\n/**\n * Create and configure the MCP server with documentation tools\n */\nexport function createMCPServer(): McpServer {\n const server = new McpServer({\n name: \"docs-server\",\n version: \"1.0.0\",\n });\n\n // Register the search_docs tool\n server.tool(\n \"search_docs\",\n DOCS_TOOLS[0].description,\n {\n query: z\n .string()\n .describe(\"The search query to find relevant documentation\"),\n limit: z\n .number()\n .optional()\n .describe(\"Maximum number of results to return (default: 6)\"),\n },\n async ({ query, limit }) => {\n const results = await searchDocs(query, limit ?? 6);\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n results.map(({ chunk, score }) => ({\n path: chunk.path,\n uri: chunk.uri,\n score: score.toFixed(3),\n text: chunk.text,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register the get_doc tool\n server.tool(\n \"get_doc\",\n DOCS_TOOLS[1].description,\n {\n path: z.string().describe(\"The file path to the documentation page\"),\n },\n async ({ path }) => {\n const doc = await getDoc({ path });\n if (!doc) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify({ error: \"Document not found\" }),\n },\n ],\n isError: true,\n };\n }\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(doc, null, 2),\n },\n ],\n };\n },\n );\n\n // Register the list_docs tool\n server.tool(\n \"list_docs\",\n DOCS_TOOLS[2].description,\n {\n directory: z\n .string()\n .optional()\n .describe(\"Optional directory to filter results\"),\n },\n async ({ directory }) => {\n const docs = await listDocs({ directory });\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n docs.map((d) => ({\n name: d.name,\n path: d.path,\n uri: d.uri,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register documentation as resources\n server.resource(\"docs://list\", \"docs://list\", async () => {\n const docs = await listDocs();\n return {\n contents: [\n {\n uri: \"docs://list\",\n text: JSON.stringify(\n docs.map((d) => ({ name: d.name, path: d.path, uri: d.uri })),\n null,\n 2,\n ),\n },\n ],\n };\n });\n\n return server;\n}\n";
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
export const mcpServerTemplate = `import
|
|
1
|
+
export const mcpServerTemplate = `import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
4
|
import { z } from "zod";
|
|
3
5
|
import {
|
|
4
6
|
listDocs,
|
|
@@ -44,6 +46,45 @@ function cosineSim(a: number[], b: number[]): number {
|
|
|
44
46
|
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
|
45
47
|
}
|
|
46
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Absolute path to the embeddings index precomputed at build time by
|
|
51
|
+
* scripts/build-docs-index.mts. Bundled into serverless functions via
|
|
52
|
+
* outputFileTracingIncludes in next.config.ts.
|
|
53
|
+
*/
|
|
54
|
+
const INDEX_FILE = path.join(
|
|
55
|
+
process.cwd(),
|
|
56
|
+
"services",
|
|
57
|
+
"mcp",
|
|
58
|
+
"docs-index.json",
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Load embeddings precomputed at build time. Returns null when the file is
|
|
63
|
+
* missing/empty or was built with a different provider/model than the current
|
|
64
|
+
* config - query and document vectors must come from the same embedding model.
|
|
65
|
+
*/
|
|
66
|
+
function loadPrecomputedIndex():
|
|
67
|
+
(DocsChunk & { embedding: number[] })[] | null {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(fs.readFileSync(INDEX_FILE, "utf8")) as {
|
|
70
|
+
provider?: string;
|
|
71
|
+
embeddingModel?: string;
|
|
72
|
+
chunks?: (DocsChunk & { embedding: number[] })[];
|
|
73
|
+
};
|
|
74
|
+
if (!parsed.chunks || parsed.chunks.length === 0) return null;
|
|
75
|
+
const config = getLLMConfig();
|
|
76
|
+
if (
|
|
77
|
+
parsed.provider !== config.provider ||
|
|
78
|
+
parsed.embeddingModel !== config.embeddingModel
|
|
79
|
+
) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return parsed.chunks;
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
47
88
|
/**
|
|
48
89
|
* Build or rebuild the documentation index
|
|
49
90
|
*/
|
|
@@ -53,6 +94,17 @@ async function buildDocsIndex(force = false): Promise<void> {
|
|
|
53
94
|
|
|
54
95
|
docsIndex.building = true;
|
|
55
96
|
try {
|
|
97
|
+
// Prefer embeddings precomputed at build time - avoids re-embedding the
|
|
98
|
+
// entire doc set on every cold start (the main cause of slow first chats).
|
|
99
|
+
if (!force) {
|
|
100
|
+
const precomputed = loadPrecomputedIndex();
|
|
101
|
+
if (precomputed) {
|
|
102
|
+
docsIndex.chunks = precomputed;
|
|
103
|
+
docsIndex.ready = true;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
56
108
|
const chunks = await getAllDocsChunks();
|
|
57
109
|
|
|
58
110
|
if (chunks.length === 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "doccupine",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.119",
|
|
4
4
|
"description": "Free and open-source documentation platform. Write MDX, get a production-ready site with AI chat, built-in components, and an MCP server - in one command.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|