doccupine 0.0.39 → 0.0.41
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/templates/app/api/rag/route.d.ts +1 -1
- package/dist/templates/app/api/rag/route.js +1 -1
- package/dist/templates/components/layout/DocsNavigation.d.ts +1 -1
- package/dist/templates/components/layout/DocsNavigation.js +16 -0
- package/dist/templates/env.example.d.ts +1 -1
- package/dist/templates/env.example.js +3 -3
- package/dist/templates/mdx/ai-assistant.mdx.d.ts +1 -1
- package/dist/templates/mdx/ai-assistant.mdx.js +3 -3
- package/dist/templates/package.js +2 -2
- package/dist/templates/services/llm/config.d.ts +1 -1
- package/dist/templates/services/llm/config.js +2 -2
- package/dist/templates/services/mcp/server.d.ts +1 -1
- package/dist/templates/services/mcp/server.js +11 -1
- package/dist/templates/services/mcp/tools.d.ts +1 -1
- package/dist/templates/services/mcp/tools.js +2 -2
- package/package.json +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const ragRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport path from \"node:path\";\nimport { getLLMConfig, createChatModel } from \"@/services/llm\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n} from \"@/services/mcp/server\";\n\nconst PROJECT_ROOT = process.cwd();\n\nexport async function POST(req: Request) {\n try {\n const { question, refresh } = await req.json();\n\n let config;\n try {\n config = 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 // Use MCP service to ensure docs are indexed\n await ensureDocsIndex(Boolean(refresh));\n\n // Use MCP search_docs tool to find relevant documentation\n const searchResults = await searchDocs(String(question || \"\"), 6);\n\n // Build context from search results\n const context = searchResults\n .map(\n ({ chunk, score }) =>\n `File: ${path.relative(PROJECT_ROOT, chunk.path)}\\nScore: ${score.toFixed(3)}\\n----\\n${chunk.text}`,\n )\n .join(\"\\n\\n================\\n\\n\");\n\n // Create chat model and stream response\n const llm = createChatModel(config);\n const prompt = [\n {\n role: \"system\" as const,\n content:\n \"You are a helpful documentation assistant. Answer strictly from the provided context. If the answer is not in the context, say you don't know and suggest where to look. Make sure the mdx can be nested properly if you show code components within nested
|
|
1
|
+
export declare const ragRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport path from \"node:path\";\nimport { getLLMConfig, createChatModel } from \"@/services/llm\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n} from \"@/services/mcp/server\";\n\nconst PROJECT_ROOT = process.cwd();\n\nexport async function POST(req: Request) {\n try {\n const { question, refresh } = await req.json();\n\n let config;\n try {\n config = 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 // Use MCP service to ensure docs are indexed\n await ensureDocsIndex(Boolean(refresh));\n\n // Use MCP search_docs tool to find relevant documentation\n const searchResults = await searchDocs(String(question || \"\"), 6);\n\n // Build context from search results\n const context = searchResults\n .map(\n ({ chunk, score }) =>\n `File: ${path.relative(PROJECT_ROOT, chunk.path)}\\nScore: ${score.toFixed(3)}\\n----\\n${chunk.text}`,\n )\n .join(\"\\n\\n================\\n\\n\");\n\n // Create chat model and stream response\n const llm = createChatModel(config);\n const prompt = [\n {\n role: \"system\" as const,\n content:\n \"You are a helpful documentation assistant. Answer strictly from the provided context. If the answer is not in the context, say you don't know and suggest where to look. Make sure the mdx can be nested properly if you show code components within nested ``` it has to be valid md/mdx output.\",\n },\n {\n role: \"user\" as const,\n content: `Question: ${question}\\n\\nContext:\\n${context}`,\n },\n ];\n\n const stream = await llm.stream(prompt);\n\n // Build metadata from MCP search results\n const indexStatus = getIndexStatus();\n const metadata = {\n sources: searchResults.map(({ chunk, score }) => ({\n id: chunk.id,\n path: path.relative(PROJECT_ROOT, chunk.path),\n uri: chunk.uri,\n score,\n })),\n chunkCount: indexStatus.chunkCount,\n };\n\n const encoder = new TextEncoder();\n const readableStream = new ReadableStream({\n async start(controller) {\n try {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"metadata\", data: metadata })}\\n\\n`,\n ),\n );\n\n for await (const chunk of stream) {\n const content = chunk?.content || \"\";\n if (content) {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"content\", data: content })}\\n\\n`,\n ),\n );\n }\n }\n\n controller.enqueue(\n encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`),\n );\n controller.close();\n } catch (error: unknown) {\n const message =\n error instanceof Error ? error.message : \"Stream error\";\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", data: message })}\\n\\n`,\n ),\n );\n controller.close();\n }\n },\n });\n\n return new Response(readableStream, {\n headers: {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache\",\n Connection: \"keep-alive\",\n },\n });\n } catch (e: unknown) {\n const message = e instanceof Error ? e.message : \"Unknown error\";\n return NextResponse.json({ error: message }, { status: 500 });\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";
|
|
@@ -42,7 +42,7 @@ export async function POST(req: Request) {
|
|
|
42
42
|
{
|
|
43
43
|
role: "system" as const,
|
|
44
44
|
content:
|
|
45
|
-
"You are a helpful documentation assistant. Answer strictly from the provided context. If the answer is not in the context, say you don't know and suggest where to look. Make sure the mdx can be nested properly if you show code components within nested \`\`\`",
|
|
45
|
+
"You are a helpful documentation assistant. Answer strictly from the provided context. If the answer is not in the context, say you don't know and suggest where to look. Make sure the mdx can be nested properly if you show code components within nested \`\`\` it has to be valid md/mdx output.",
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
role: "user" as const,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const docsNavigationTemplate = "\"use client\";\nimport { useContext } from \"react\";\nimport { usePathname } from \"next/navigation\";\nimport Link from \"next/link\";\nimport styled, { css } from \"styled-components\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { interactiveStyles } from \"@/components/layout/SharedStyled\";\nimport { ChatContext } from \"@/components/Chat\";\nconst StyledNavigationWrapper = styled.div<{\n $isChatOpen?: boolean;\n}>`\n transition: all 0.3s ease;\n padding: 0 20px 100px 20px;\n ${mq(\"lg\")} {\n padding: 0 340px 80px 340px;\n ${({ $isChatOpen }) =>\n $isChatOpen &&\n css`\n padding: 0 440px 80px 340px;\n `}\n }\n`;\nconst StyledNavigationInner = styled.div`\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 20px;\n max-width: 640px;\n margin: auto;\n`;\nconst StyledNavButton = styled(Link)<{ theme: Theme }>`\n ${interactiveStyles};\n display: flex;\n flex-direction: column;\n text-decoration: none;\n padding: 20px;\n flex: 50%;\n max-width: 50%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.dark};\n &:hover {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n &[data-direction=\"prev\"] {\n align-items: flex-start;\n }\n &[data-direction=\"next\"] {\n align-items: flex-end;\n margin-left: auto;\n text-align: right;\n }\n`;\nconst StyledNavLabel = styled.span<{ theme: Theme }>`\n color: ${({ theme }) => theme.colors.gray};\n display: flex;\n flex-direction: row;\n gap: 4px;\n & svg {\n margin: auto 0;\n }\n`;\nconst StyledNavTitle = styled.span<{ theme: Theme }>`\n color: ${({ theme }) => theme.colors.dark};\n font-weight: 600;\n margin: 0 0 4px 0;\n white-space: nowrap;\n text-overflow: ellipsis;\n overflow: hidden;\n`;\nconst StyledSpacer = styled.div`\n flex: 1;\n`;\ninterface Page {\n slug: string;\n title: string;\n category?: string;\n [key: string]: any;\n}\ninterface NavigationItem {\n category?: string;\n slug?: string;\n title?: string;\n links?: Page[];\n items?: Page[];\n [key: string]: any;\n}\ninterface DocsNavigationProps {\n result: NavigationItem[];\n}\nfunction DocsNavigation({ result }: DocsNavigationProps) {\n const { isOpen } = useContext(ChatContext);\n const pathname = usePathname();\n const allPages: Page[] = result.flatMap((item) => {\n if (item.links && Array.isArray(item.links)) {\n return item.links;\n }\n if (item.items && Array.isArray(item.items)) {\n return item.items;\n }\n if (item.slug !== undefined) {\n return [item as Page];\n }\n return [];\n });\n const currentSlug = pathname.replace(/^\\//, \"\").replace(/\\/$/, \"\");\n const currentIndex = allPages.findIndex((page) => page.slug === currentSlug);\n const prevPage = currentIndex > 0 ? allPages[currentIndex - 1] : null;\n const nextPage =\n currentIndex < allPages.length - 1 ? allPages[currentIndex + 1] : null;\n if (currentIndex === -1 || allPages.length === 0) {\n return null;\n }\n if (!prevPage && !nextPage) {\n return null;\n }\n return (\n <StyledNavigationWrapper $isChatOpen={isOpen}>\n <StyledNavigationInner>\n {prevPage ? (\n <StyledNavButton href={`/${prevPage.slug}`} data-direction=\"prev\">\n <StyledNavTitle>{prevPage.title}</StyledNavTitle>\n <StyledNavLabel>\n <Icon name=\"arrow-left\" size={16} /> Previous\n </StyledNavLabel>\n </StyledNavButton>\n ) : (\n <StyledSpacer />\n )}\n {nextPage && (\n <StyledNavButton href={`/${nextPage.slug}`} data-direction=\"next\">\n <StyledNavTitle>{nextPage.title}</StyledNavTitle>\n <StyledNavLabel>\n Next <Icon name=\"arrow-right\" size={16} />\n </StyledNavLabel>\n </StyledNavButton>\n )}\n </StyledNavigationInner>\n </StyledNavigationWrapper>\n );\n}\nexport { DocsNavigation };\n";
|
|
1
|
+
export declare const docsNavigationTemplate = "\"use client\";\nimport { useContext } from \"react\";\nimport { usePathname } from \"next/navigation\";\nimport Link from \"next/link\";\nimport styled, { css } from \"styled-components\";\nimport { Icon } from \"@/components/layout/Icon\";\nimport { mq, Theme } from \"@/app/theme\";\nimport { interactiveStyles } from \"@/components/layout/SharedStyled\";\nimport { ChatContext } from \"@/components/Chat\";\n\nconst StyledNavigationWrapper = styled.div<{\n $isChatOpen?: boolean;\n}>`\n transition: all 0.3s ease;\n padding: 0 20px 100px 20px;\n ${mq(\"lg\")} {\n padding: 0 340px 80px 340px;\n ${({ $isChatOpen }) =>\n $isChatOpen &&\n css`\n padding: 0 440px 80px 340px;\n `}\n }\n`;\n\nconst StyledNavigationInner = styled.div`\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 20px;\n max-width: 640px;\n margin: auto;\n`;\n\nconst StyledNavButton = styled(Link)<{ theme: Theme }>`\n ${interactiveStyles};\n display: flex;\n flex-direction: column;\n text-decoration: none;\n padding: 20px;\n flex: 50%;\n max-width: 50%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.dark};\n \n &:hover {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n\n &[data-direction=\"prev\"] {\n align-items: flex-start;\n }\n\n &[data-direction=\"next\"] {\n align-items: flex-end;\n margin-left: auto;\n text-align: right;\n }\n`;\n\nconst StyledNavLabel = styled.span<{ theme: Theme }>`\n color: ${({ theme }) => theme.colors.gray};\n display: flex;\n flex-direction: row;\n gap: 4px;\n\n & svg {\n margin: auto 0;\n }\n`;\n\nconst StyledNavTitle = styled.span<{ theme: Theme }>`\n color: ${({ theme }) => theme.colors.dark};\n font-weight: 600;\n margin: 0 0 4px 0;\n white-space: nowrap;\n text-overflow: ellipsis;\n overflow: hidden;\n max-width: 100%;\n`;\n\nconst StyledSpacer = styled.div`\n flex: 1;\n`;\n\ninterface Page {\n slug: string;\n title: string;\n category?: string;\n [key: string]: any;\n}\n\ninterface NavigationItem {\n category?: string;\n slug?: string;\n title?: string;\n links?: Page[];\n items?: Page[];\n [key: string]: any;\n}\n\ninterface DocsNavigationProps {\n result: NavigationItem[];\n}\n\nfunction DocsNavigation({ result }: DocsNavigationProps) {\n const { isOpen } = useContext(ChatContext);\n const pathname = usePathname();\n const allPages: Page[] = result.flatMap((item) => {\n if (item.links && Array.isArray(item.links)) {\n return item.links;\n }\n if (item.items && Array.isArray(item.items)) {\n return item.items;\n }\n if (item.slug !== undefined) {\n return [item as Page];\n }\n return [];\n });\n const currentSlug = pathname.replace(/^\\//, \"\").replace(/\\/$/, \"\");\n const currentIndex = allPages.findIndex((page) => page.slug === currentSlug);\n const prevPage = currentIndex > 0 ? allPages[currentIndex - 1] : null;\n const nextPage =\n currentIndex < allPages.length - 1 ? allPages[currentIndex + 1] : null;\n if (currentIndex === -1 || allPages.length === 0) {\n return null;\n }\n if (!prevPage && !nextPage) {\n return null;\n }\n return (\n <StyledNavigationWrapper $isChatOpen={isOpen}>\n <StyledNavigationInner>\n {prevPage ? (\n <StyledNavButton href={`/${prevPage.slug}`} data-direction=\"prev\">\n <StyledNavTitle>{prevPage.title}</StyledNavTitle>\n <StyledNavLabel>\n <Icon name=\"arrow-left\" size={16} /> Previous\n </StyledNavLabel>\n </StyledNavButton>\n ) : (\n <StyledSpacer />\n )}\n {nextPage && (\n <StyledNavButton href={`/${nextPage.slug}`} data-direction=\"next\">\n <StyledNavTitle>{nextPage.title}</StyledNavTitle>\n <StyledNavLabel>\n Next <Icon name=\"arrow-right\" size={16} />\n </StyledNavLabel>\n </StyledNavButton>\n )}\n </StyledNavigationInner>\n </StyledNavigationWrapper>\n );\n}\n\nexport { DocsNavigation };\n";
|
|
@@ -7,6 +7,7 @@ import { Icon } from "@/components/layout/Icon";
|
|
|
7
7
|
import { mq, Theme } from "@/app/theme";
|
|
8
8
|
import { interactiveStyles } from "@/components/layout/SharedStyled";
|
|
9
9
|
import { ChatContext } from "@/components/Chat";
|
|
10
|
+
|
|
10
11
|
const StyledNavigationWrapper = styled.div<{
|
|
11
12
|
$isChatOpen?: boolean;
|
|
12
13
|
}>\`
|
|
@@ -21,6 +22,7 @@ const StyledNavigationWrapper = styled.div<{
|
|
|
21
22
|
\`}
|
|
22
23
|
}
|
|
23
24
|
\`;
|
|
25
|
+
|
|
24
26
|
const StyledNavigationInner = styled.div\`
|
|
25
27
|
display: flex;
|
|
26
28
|
justify-content: space-between;
|
|
@@ -29,6 +31,7 @@ const StyledNavigationInner = styled.div\`
|
|
|
29
31
|
max-width: 640px;
|
|
30
32
|
margin: auto;
|
|
31
33
|
\`;
|
|
34
|
+
|
|
32
35
|
const StyledNavButton = styled(Link)<{ theme: Theme }>\`
|
|
33
36
|
\${interactiveStyles};
|
|
34
37
|
display: flex;
|
|
@@ -40,27 +43,33 @@ const StyledNavButton = styled(Link)<{ theme: Theme }>\`
|
|
|
40
43
|
border-radius: \${({ theme }) => theme.spacing.radius.lg};
|
|
41
44
|
border: solid 1px \${({ theme }) => theme.colors.grayLight};
|
|
42
45
|
color: \${({ theme }) => theme.colors.dark};
|
|
46
|
+
|
|
43
47
|
&:hover {
|
|
44
48
|
border-color: \${({ theme }) => theme.colors.primary};
|
|
45
49
|
}
|
|
50
|
+
|
|
46
51
|
&[data-direction="prev"] {
|
|
47
52
|
align-items: flex-start;
|
|
48
53
|
}
|
|
54
|
+
|
|
49
55
|
&[data-direction="next"] {
|
|
50
56
|
align-items: flex-end;
|
|
51
57
|
margin-left: auto;
|
|
52
58
|
text-align: right;
|
|
53
59
|
}
|
|
54
60
|
\`;
|
|
61
|
+
|
|
55
62
|
const StyledNavLabel = styled.span<{ theme: Theme }>\`
|
|
56
63
|
color: \${({ theme }) => theme.colors.gray};
|
|
57
64
|
display: flex;
|
|
58
65
|
flex-direction: row;
|
|
59
66
|
gap: 4px;
|
|
67
|
+
|
|
60
68
|
& svg {
|
|
61
69
|
margin: auto 0;
|
|
62
70
|
}
|
|
63
71
|
\`;
|
|
72
|
+
|
|
64
73
|
const StyledNavTitle = styled.span<{ theme: Theme }>\`
|
|
65
74
|
color: \${({ theme }) => theme.colors.dark};
|
|
66
75
|
font-weight: 600;
|
|
@@ -68,16 +77,20 @@ const StyledNavTitle = styled.span<{ theme: Theme }>\`
|
|
|
68
77
|
white-space: nowrap;
|
|
69
78
|
text-overflow: ellipsis;
|
|
70
79
|
overflow: hidden;
|
|
80
|
+
max-width: 100%;
|
|
71
81
|
\`;
|
|
82
|
+
|
|
72
83
|
const StyledSpacer = styled.div\`
|
|
73
84
|
flex: 1;
|
|
74
85
|
\`;
|
|
86
|
+
|
|
75
87
|
interface Page {
|
|
76
88
|
slug: string;
|
|
77
89
|
title: string;
|
|
78
90
|
category?: string;
|
|
79
91
|
[key: string]: any;
|
|
80
92
|
}
|
|
93
|
+
|
|
81
94
|
interface NavigationItem {
|
|
82
95
|
category?: string;
|
|
83
96
|
slug?: string;
|
|
@@ -86,9 +99,11 @@ interface NavigationItem {
|
|
|
86
99
|
items?: Page[];
|
|
87
100
|
[key: string]: any;
|
|
88
101
|
}
|
|
102
|
+
|
|
89
103
|
interface DocsNavigationProps {
|
|
90
104
|
result: NavigationItem[];
|
|
91
105
|
}
|
|
106
|
+
|
|
92
107
|
function DocsNavigation({ result }: DocsNavigationProps) {
|
|
93
108
|
const { isOpen } = useContext(ChatContext);
|
|
94
109
|
const pathname = usePathname();
|
|
@@ -140,5 +155,6 @@ function DocsNavigation({ result }: DocsNavigationProps) {
|
|
|
140
155
|
</StyledNavigationWrapper>
|
|
141
156
|
);
|
|
142
157
|
}
|
|
158
|
+
|
|
143
159
|
export { DocsNavigation };
|
|
144
160
|
`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const envExampleTemplate = "# LLM Provider Configuration\n# Choose your preferred LLM provider: openai, anthropic, or google\nLLM_PROVIDER=openai\n\n# API Keys (set the one matching your provider)\nOPENAI_API_KEY=your_openai_api_key_here\nANTHROPIC_API_KEY=your_anthropic_api_key_here\nGOOGLE_API_KEY=your_google_api_key_here\n\n# Optional: Override default models\n# OpenAI models: gpt-
|
|
1
|
+
export declare const envExampleTemplate = "# LLM Provider Configuration\n# Choose your preferred LLM provider: openai, anthropic, or google\nLLM_PROVIDER=openai\n\n# API Keys (set the one matching your provider)\nOPENAI_API_KEY=your_openai_api_key_here\nANTHROPIC_API_KEY=your_anthropic_api_key_here\nGOOGLE_API_KEY=your_google_api_key_here\n\n# Optional: Override default models\n# OpenAI models: gpt-5-mini, gpt-5-nano, gpt-5\n# Anthropic models: claude-sonnet-4-5-20250929, claude-haiku-4-5-20251001, claude-opus-4-5-20251101\n# Google models: gemini-2.5-flash-lite, gemini-2.5-pro, gemini-2.5-flash\n# LLM_CHAT_MODEL=gpt-4.1-nano\n\n# Optional: Override default embedding model\n# OpenAI: text-embedding-3-small, text-embedding-3-large\n# Google: text-embedding-004\n# Note: Anthropic doesn't provide embeddings, will fallback to OpenAI\n# LLM_EMBEDDING_MODEL=text-embedding-3-small\n\n# Optional: Set temperature (0-1, default: 1)\n# LLM_TEMPERATURE=1\n";
|
|
@@ -8,7 +8,7 @@ ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
|
|
8
8
|
GOOGLE_API_KEY=your_google_api_key_here
|
|
9
9
|
|
|
10
10
|
# Optional: Override default models
|
|
11
|
-
# OpenAI models: gpt-
|
|
11
|
+
# OpenAI models: gpt-5-mini, gpt-5-nano, gpt-5
|
|
12
12
|
# Anthropic models: claude-sonnet-4-5-20250929, claude-haiku-4-5-20251001, claude-opus-4-5-20251101
|
|
13
13
|
# Google models: gemini-2.5-flash-lite, gemini-2.5-pro, gemini-2.5-flash
|
|
14
14
|
# LLM_CHAT_MODEL=gpt-4.1-nano
|
|
@@ -19,6 +19,6 @@ GOOGLE_API_KEY=your_google_api_key_here
|
|
|
19
19
|
# Note: Anthropic doesn't provide embeddings, will fallback to OpenAI
|
|
20
20
|
# LLM_EMBEDDING_MODEL=text-embedding-3-small
|
|
21
21
|
|
|
22
|
-
# Optional: Set temperature (0-1, default:
|
|
23
|
-
# LLM_TEMPERATURE=
|
|
22
|
+
# Optional: Set temperature (0-1, default: 1)
|
|
23
|
+
# LLM_TEMPERATURE=1
|
|
24
24
|
`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const aiAssistantMdxTemplate = "---\ntitle: \"AI Assistant\"\ndescription: \"Integrate AI capabilities into your Doccupine documentation using OpenAI, Anthropic, or Google Gemini.\"\ndate: \"2025-01-24\"\ncategory: \"Configuration\"\ncategoryOrder: 3\norder: 7\n---\n# AI Assistant\nDoccupine supports AI integration to enhance your documentation experience. You can use OpenAI, Anthropic, or Google Gemini to power AI features in your documentation site. The AI assistant uses your documentation content as context, allowing users to ask questions about your docs and receive accurate answers based on the documentation.\n\n## Setup\nTo enable AI features, create an `.env` file in the directory where your website is generated. By default, this is the `nextjs-app/` directory.\n\n## Configuration\nCreate an `.env` file with the following configuration options:\n\n```env\n# LLM Provider Configuration\n# Choose your preferred LLM provider: openai, anthropic, or google\nLLM_PROVIDER=openai\n\n# API Keys (set the one matching your provider)\nOPENAI_API_KEY=your_openai_api_key_here\nANTHROPIC_API_KEY=your_anthropic_api_key_here\nGOOGLE_API_KEY=your_google_api_key_here\n\n# Optional: Override default models\n# OpenAI models: gpt-
|
|
1
|
+
export declare const aiAssistantMdxTemplate = "---\ntitle: \"AI Assistant\"\ndescription: \"Integrate AI capabilities into your Doccupine documentation using OpenAI, Anthropic, or Google Gemini.\"\ndate: \"2025-01-24\"\ncategory: \"Configuration\"\ncategoryOrder: 3\norder: 7\n---\n# AI Assistant\nDoccupine supports AI integration to enhance your documentation experience. You can use OpenAI, Anthropic, or Google Gemini to power AI features in your documentation site. The AI assistant uses your documentation content as context, allowing users to ask questions about your docs and receive accurate answers based on the documentation.\n\n## Setup\nTo enable AI features, create an `.env` file in the directory where your website is generated. By default, this is the `nextjs-app/` directory.\n\n## Configuration\nCreate an `.env` file with the following configuration options:\n\n```env\n# LLM Provider Configuration\n# Choose your preferred LLM provider: openai, anthropic, or google\nLLM_PROVIDER=openai\n\n# API Keys (set the one matching your provider)\nOPENAI_API_KEY=your_openai_api_key_here\nANTHROPIC_API_KEY=your_anthropic_api_key_here\nGOOGLE_API_KEY=your_google_api_key_here\n\n# Optional: Override default models\n# OpenAI models: gpt-5-mini, gpt-5-nano, gpt-5\n# Anthropic models: claude-sonnet-4-5-20250929, claude-haiku-4-5-20251001, claude-opus-4-5-20251101\n# Google models: gemini-2.5-flash-lite, gemini-2.5-pro, gemini-2.5-flash\n# LLM_CHAT_MODEL=gpt-4.1-nano\n\n# Optional: Override default embedding model\n# OpenAI: text-embedding-3-small, text-embedding-3-large\n# Google: text-embedding-004\n# Note: Anthropic doesn't provide embeddings, will fallback to OpenAI\n# LLM_EMBEDDING_MODEL=text-embedding-3-small\n\n# Optional: Set temperature (0-1, default: 1)\n# LLM_TEMPERATURE=1\n```\n\n## Provider Selection\nSet `LLM_PROVIDER` to one of the following values:\n- `openai` - Use OpenAI's models (GPT-4.1, GPT-4.1-mini, GPT-4.1-nano)\n- `anthropic` - Use Anthropic's models (Claude Sonnet 4.5, Claude Haiku 4.5, Claude Opus 4.5)\n- `google` - Use Google's models (Gemini 2.5 Pro, Gemini 2.5 Flash, Gemini 2.5 Flash-Lite)\n\n## API Keys\nYou need to set the API key that matches your chosen provider:\n- For OpenAI: Set `OPENAI_API_KEY`\n- For Anthropic: Set `ANTHROPIC_API_KEY`\n- For Google: Set `GOOGLE_API_KEY`\n\n<Callout type=\"warning\">\n Keep your API keys secure. Never commit your `.env` file to version control.\n</Callout>\n\n<Callout type=\"note\">\n Doccupine automatically adds `.env` to your `.gitignore` file.\n</Callout>\n\n## Using Anthropic with OpenAI\nIf you want to use Anthropic as your LLM provider, you must also have an OpenAI API key set. Here's why:\n\n### The Situation\nAnthropic (Claude) does not provide an embeddings API. They only offer chat/completion models, not text embeddings.\n\nYour RAG (Retrieval-Augmented Generation) system has two components:\n- **Chat/Completion** - Generates answers, works with Anthropic.\n- **Embeddings** - Creates vector representations of text for search, Anthropic doesn't provide this.\n\nWhen using Anthropic as your `LLM_PROVIDER`, Doccupine will use Anthropic for chat/completion tasks, but will automatically fallback to OpenAI for embeddings. This means you need both API keys configured:\n\n```env\nLLM_PROVIDER=anthropic\nANTHROPIC_API_KEY=your_anthropic_api_key_here\nOPENAI_API_KEY=your_openai_api_key_here\n```\n\nThis hybrid approach allows you to leverage Anthropic's powerful chat models while still having access to embeddings functionality through OpenAI.\n\n## Optional Settings\n\n### Chat Model\nOverride the default chat model by uncommenting and setting `LLM_CHAT_MODEL`. You can use any available model from your chosen provider. Example models include:\n- **OpenAI**: `gpt-4.1-nano`, `gpt-4.1-mini`, `gpt-4.1`\n- **Anthropic**: `claude-sonnet-4-5-20250929`, `claude-haiku-4-5-20251001`, `claude-opus-4-5-20251101`\n- **Google**: `gemini-2.5-flash-lite`, `gemini-2.5-pro`, `gemini-2.5-flash`\n\nFor a complete list of available models, refer to the official documentation:\n- [OpenAI Models](https://platform.openai.com/docs/models)\n- [Anthropic Models](https://docs.anthropic.com/claude/docs/models-overview)\n- [Google Gemini Models](https://ai.google.dev/models/gemini)\n\n### Embedding Model\nOverride the default embedding model by uncommenting and setting `LLM_EMBEDDING_MODEL`:\n- **OpenAI**: `text-embedding-3-small`, `text-embedding-3-large`\n- **Google**: `text-embedding-004`\n- **Anthropic**: Anthropic doesn't provide embeddings. If you use Anthropic as your provider, Doccupine will fallback to OpenAI for embeddings.\n\n### Temperature\nControl the randomness of AI responses by setting `LLM_TEMPERATURE` to a value between 0 and 1:\n- `0` - More deterministic and focused responses (default)\n- `1` - More creative and varied responses";
|
|
@@ -26,7 +26,7 @@ ANTHROPIC_API_KEY=your_anthropic_api_key_here
|
|
|
26
26
|
GOOGLE_API_KEY=your_google_api_key_here
|
|
27
27
|
|
|
28
28
|
# Optional: Override default models
|
|
29
|
-
# OpenAI models: gpt-
|
|
29
|
+
# OpenAI models: gpt-5-mini, gpt-5-nano, gpt-5
|
|
30
30
|
# Anthropic models: claude-sonnet-4-5-20250929, claude-haiku-4-5-20251001, claude-opus-4-5-20251101
|
|
31
31
|
# Google models: gemini-2.5-flash-lite, gemini-2.5-pro, gemini-2.5-flash
|
|
32
32
|
# LLM_CHAT_MODEL=gpt-4.1-nano
|
|
@@ -37,8 +37,8 @@ GOOGLE_API_KEY=your_google_api_key_here
|
|
|
37
37
|
# Note: Anthropic doesn't provide embeddings, will fallback to OpenAI
|
|
38
38
|
# LLM_EMBEDDING_MODEL=text-embedding-3-small
|
|
39
39
|
|
|
40
|
-
# Optional: Set temperature (0-1, default:
|
|
41
|
-
# LLM_TEMPERATURE=
|
|
40
|
+
# Optional: Set temperature (0-1, default: 1)
|
|
41
|
+
# LLM_TEMPERATURE=1
|
|
42
42
|
\`\`\`
|
|
43
43
|
|
|
44
44
|
## Provider Selection
|
|
@@ -10,10 +10,10 @@ export const packageJsonTemplate = JSON.stringify({
|
|
|
10
10
|
},
|
|
11
11
|
dependencies: {
|
|
12
12
|
"@langchain/anthropic": "^1.3.17",
|
|
13
|
-
"@langchain/google-genai": "^2.1.
|
|
13
|
+
"@langchain/google-genai": "^2.1.18",
|
|
14
14
|
"@langchain/openai": "^1.2.7",
|
|
15
15
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
16
|
-
langchain: "^1.2.
|
|
16
|
+
langchain: "^1.2.23",
|
|
17
17
|
next: "16.1.6",
|
|
18
18
|
react: "19.2.4",
|
|
19
19
|
"react-dom": "19.2.4",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const llmConfigTemplate = "import type {\n LLMConfig,\n LLMProvider,\n ProviderDefaults,\n} from \"@/services/llm/types\";\nconst PROVIDER_DEFAULTS: ProviderDefaults = {\n openai: {\n chat: \"gpt-
|
|
1
|
+
export declare const llmConfigTemplate = "import type {\n LLMConfig,\n LLMProvider,\n ProviderDefaults,\n} from \"@/services/llm/types\";\nconst PROVIDER_DEFAULTS: ProviderDefaults = {\n openai: {\n chat: \"gpt-5-nano\",\n embedding: \"text-embedding-3-small\",\n },\n anthropic: {\n chat: \"claude-sonnet-4-5-20250929\",\n embedding: \"text-embedding-3-small\", // Fallback to OpenAI\n },\n google: {\n chat: \"gemini-2.5-flash-lite\",\n embedding: \"text-embedding-004\",\n },\n};\nfunction validateAPIKeys(provider: LLMProvider): void {\n const requiredKeys: Record<LLMProvider, string> = {\n openai: \"OPENAI_API_KEY\",\n anthropic: \"ANTHROPIC_API_KEY\",\n google: \"GOOGLE_API_KEY\",\n };\n const keyName = requiredKeys[provider];\n const keyValue = process.env[keyName];\n if (!keyValue) {\n throw new Error(\n `Missing API key for ${provider}. Please set ${keyName} in your environment variables.`,\n );\n }\n if (provider === \"anthropic\" && !process.env.OPENAI_API_KEY) {\n console.warn(\n \"Anthropic provider requires OPENAI_API_KEY for embeddings. Please set OPENAI_API_KEY in your environment variables.\",\n );\n }\n}\nexport function getLLMConfig(): LLMConfig {\n const provider = (process.env.LLM_PROVIDER || \"openai\") as LLMProvider;\n if (![\"openai\", \"anthropic\", \"google\"].includes(provider)) {\n throw new Error(\n `Invalid LLM_PROVIDER: ${provider}. Must be one of: openai, anthropic, google`,\n );\n }\n validateAPIKeys(provider);\n const defaults = PROVIDER_DEFAULTS[provider];\n return {\n provider,\n chatModel: process.env.LLM_CHAT_MODEL || defaults.chat,\n embeddingModel: process.env.LLM_EMBEDDING_MODEL || defaults.embedding,\n temperature: parseFloat(process.env.LLM_TEMPERATURE || \"1\"),\n };\n}\nexport function getLLMDisplayName(config?: LLMConfig): string {\n const c = config || getLLMConfig();\n const providerNames: Record<LLMProvider, string> = {\n openai: \"OpenAI\",\n anthropic: \"Anthropic\",\n google: \"Google\",\n };\n return `${providerNames[c.provider]} (${c.chatModel})`;\n}\nexport function isLLMConfigured(): boolean {\n try {\n getLLMConfig();\n return true;\n } catch {\n return false;\n }\n}\n";
|
|
@@ -5,7 +5,7 @@ export const llmConfigTemplate = `import type {
|
|
|
5
5
|
} from "@/services/llm/types";
|
|
6
6
|
const PROVIDER_DEFAULTS: ProviderDefaults = {
|
|
7
7
|
openai: {
|
|
8
|
-
chat: "gpt-
|
|
8
|
+
chat: "gpt-5-nano",
|
|
9
9
|
embedding: "text-embedding-3-small",
|
|
10
10
|
},
|
|
11
11
|
anthropic: {
|
|
@@ -49,7 +49,7 @@ export function getLLMConfig(): LLMConfig {
|
|
|
49
49
|
provider,
|
|
50
50
|
chatModel: process.env.LLM_CHAT_MODEL || defaults.chat,
|
|
51
51
|
embeddingModel: process.env.LLM_EMBEDDING_MODEL || defaults.embedding,
|
|
52
|
-
temperature: parseFloat(process.env.LLM_TEMPERATURE || "
|
|
52
|
+
temperature: parseFloat(process.env.LLM_TEMPERATURE || "1"),
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
55
|
export function getLLMDisplayName(config?: LLMConfig): string {
|
|
@@ -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, createEmbeddings } from \"@/services/llm\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/**\n * In-memory cache for document embeddings\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/**\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 */\nexport async 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 const
|
|
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, createEmbeddings } from \"@/services/llm\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/**\n * In-memory cache for document embeddings\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/**\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 */\nexport async 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 } finally {\n docsIndex.building = false;\n }\n}\n\n/**\n * Ensure the docs index is ready\n */\nexport async function ensureDocsIndex(force = false): Promise<void> {\n if (force) {\n docsIndex.ready = false;\n docsIndex.chunks = [];\n }\n if (!docsIndex.ready) {\n await buildDocsIndex();\n }\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 config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n const queryVector = await embeddings.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";
|
|
@@ -59,7 +59,17 @@ export async function buildDocsIndex(force = false): Promise<void> {
|
|
|
59
59
|
|
|
60
60
|
const config = getLLMConfig();
|
|
61
61
|
const embeddings = createEmbeddings(config);
|
|
62
|
-
|
|
62
|
+
|
|
63
|
+
// Process embeddings in small batches to avoid exceeding token limits
|
|
64
|
+
const BATCH_SIZE = 10;
|
|
65
|
+
const texts = chunks.map((c) => c.text);
|
|
66
|
+
const vectors: number[][] = [];
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
|
|
69
|
+
const batch = texts.slice(i, i + BATCH_SIZE);
|
|
70
|
+
const batchVectors = await embeddings.embedDocuments(batch);
|
|
71
|
+
vectors.push(...batchVectors);
|
|
72
|
+
}
|
|
63
73
|
|
|
64
74
|
docsIndex.chunks = chunks.map((c, i) => ({
|
|
65
75
|
...c,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const mcpToolsTemplate = "import path from \"node:path\";\nimport fs from \"node:fs/promises\";\nimport type {\n MCPToolDefinition,\n DocsResource,\n DocsChunk,\n GetDocParams,\n ListDocsParams,\n} from \"@/services/mcp/types\";\n\nconst PROJECT_ROOT = process.cwd();\nconst APP_DIR = path.join(PROJECT_ROOT, \"app\");\nconst VALID_EXT = new Set([\".ts\", \".tsx\", \".js\", \".jsx\"]);\n\n/**\n * Tool definitions for MCP - these describe the available tools\n */\nexport const DOCS_TOOLS: MCPToolDefinition[] = [\n {\n name: \"search_docs\",\n description:\n \"Search through the documentation content using semantic search. Returns relevant chunks of documentation based on the query.\",\n inputSchema: {\n type: \"object\",\n properties: {\n query: {\n type: \"string\",\n description: \"The search query to find relevant documentation\",\n },\n limit: {\n type: \"number\",\n description: \"Maximum number of results to return (default: 6)\",\n },\n },\n required: [\"query\"],\n },\n },\n {\n name: \"get_doc\",\n description:\n \"Get the full content of a specific documentation page by its path.\",\n inputSchema: {\n type: \"object\",\n properties: {\n path: {\n type: \"string\",\n description:\n \"The file path to the documentation page (e.g., 'app/getting-started/page.tsx')\",\n },\n },\n required: [\"path\"],\n },\n },\n {\n name: \"list_docs\",\n description:\n \"List all available documentation pages, optionally filtered by directory.\",\n inputSchema: {\n type: \"object\",\n properties: {\n directory: {\n type: \"string\",\n description:\n \"Optional directory to filter results (e.g., 'components')\",\n },\n },\n },\n },\n];\n\n/**\n * Recursively walk directory to find documentation files\n */\nasync function* walkDocs(dir: string): AsyncGenerator<string> {\n const entries = await fs.readdir(dir, { withFileTypes: true });\n for (const entry of entries) {\n const fullPath = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n if ([\"node_modules\", \".next\", \".git\", \"api\"].includes(entry.name)) {\n continue;\n }\n yield* walkDocs(fullPath);\n } else {\n const ext = path.extname(entry.name).toLowerCase();\n if (VALID_EXT.has(ext) && entry.name.startsWith(\"page.\")) {\n yield fullPath;\n }\n }\n }\n}\n\n/**\n * Extract content blocks from a file\n */\nfunction extractContentBlocks(fileText: string): string[] {\n const results: string[] = [];\n\n const tplRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*`((?:\\\\`|[^`])*)`\\s*;/g;\n let m: RegExpExecArray | null;\n while ((m = tplRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n const sglRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*'([^']*)'\\s*;/g;\n while ((m = sglRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n const dblRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*\"([^\"]*)\"\\s*;/g;\n while ((m = dblRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n return results;\n}\n\n/**\n * Get the title from markdown content\n */\nfunction extractTitle(content: string): string {\n const match = content.match(/^#\\s+(.+)$/m);\n return match ? match[1].trim() : \"Untitled\";\n}\n\n/**\n * List all documentation resources\n */\nexport async function listDocs(\n params?: ListDocsParams,\n): Promise<DocsResource[]> {\n const resources: DocsResource[] = [];\n const filterDir = params?.directory;\n\n for await (const filePath of walkDocs(APP_DIR)) {\n const relativePath = path.relative(PROJECT_ROOT, filePath);\n\n if (filterDir && !relativePath.includes(filterDir)) {\n continue;\n }\n\n try {\n const fileContent = await fs.readFile(filePath, \"utf8\");\n const blocks = extractContentBlocks(fileContent);\n const content = blocks.join(\"\\n\\n\");\n const title = extractTitle(content);\n const docPath = path.dirname(relativePath).replace(/^app\\/?/, \"\") || \"/\";\n\n resources.push({\n uri: `docs://${docPath}`,\n name: title,\n path: relativePath,\n content,\n });\n } catch {\n // Skip files that can't be read\n }\n }\n\n return resources;\n}\n\n/**\n * Get a specific documentation page\n */\nexport async function getDoc(\n params: GetDocParams,\n): Promise<DocsResource | null> {\n let targetPath = params.path;\n\n // Normalize path\n if (!targetPath.startsWith(\"app/\")) {\n targetPath = `app/${targetPath}`;\n }\n if (!targetPath.includes(\"page.\")) {\n targetPath = path.join(targetPath, \"page.tsx\");\n }\n\n const fullPath = path.join(PROJECT_ROOT, targetPath);\n\n try {\n const fileContent = await fs.readFile(fullPath, \"utf8\");\n const blocks = extractContentBlocks(fileContent);\n const content = blocks.join(\"\\n\\n\");\n const title = extractTitle(content);\n const docPath = path.dirname(targetPath).replace(/^app\\/?/, \"\") || \"/\";\n\n return {\n uri: `docs://${docPath}`,\n name: title,\n path: targetPath,\n content,\n };\n } catch {\n return null;\n }\n}\n\n/**\n * Chunk text for embeddings\n */\nexport function chunkText(\n text: string,\n chunkSize =
|
|
1
|
+
export declare const mcpToolsTemplate = "import path from \"node:path\";\nimport fs from \"node:fs/promises\";\nimport type {\n MCPToolDefinition,\n DocsResource,\n DocsChunk,\n GetDocParams,\n ListDocsParams,\n} from \"@/services/mcp/types\";\n\nconst PROJECT_ROOT = process.cwd();\nconst APP_DIR = path.join(PROJECT_ROOT, \"app\");\nconst VALID_EXT = new Set([\".ts\", \".tsx\", \".js\", \".jsx\"]);\n\n/**\n * Tool definitions for MCP - these describe the available tools\n */\nexport const DOCS_TOOLS: MCPToolDefinition[] = [\n {\n name: \"search_docs\",\n description:\n \"Search through the documentation content using semantic search. Returns relevant chunks of documentation based on the query.\",\n inputSchema: {\n type: \"object\",\n properties: {\n query: {\n type: \"string\",\n description: \"The search query to find relevant documentation\",\n },\n limit: {\n type: \"number\",\n description: \"Maximum number of results to return (default: 6)\",\n },\n },\n required: [\"query\"],\n },\n },\n {\n name: \"get_doc\",\n description:\n \"Get the full content of a specific documentation page by its path.\",\n inputSchema: {\n type: \"object\",\n properties: {\n path: {\n type: \"string\",\n description:\n \"The file path to the documentation page (e.g., 'app/getting-started/page.tsx')\",\n },\n },\n required: [\"path\"],\n },\n },\n {\n name: \"list_docs\",\n description:\n \"List all available documentation pages, optionally filtered by directory.\",\n inputSchema: {\n type: \"object\",\n properties: {\n directory: {\n type: \"string\",\n description:\n \"Optional directory to filter results (e.g., 'components')\",\n },\n },\n },\n },\n];\n\n/**\n * Recursively walk directory to find documentation files\n */\nasync function* walkDocs(dir: string): AsyncGenerator<string> {\n const entries = await fs.readdir(dir, { withFileTypes: true });\n for (const entry of entries) {\n const fullPath = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n if ([\"node_modules\", \".next\", \".git\", \"api\"].includes(entry.name)) {\n continue;\n }\n yield* walkDocs(fullPath);\n } else {\n const ext = path.extname(entry.name).toLowerCase();\n if (VALID_EXT.has(ext) && entry.name.startsWith(\"page.\")) {\n yield fullPath;\n }\n }\n }\n}\n\n/**\n * Extract content blocks from a file\n */\nfunction extractContentBlocks(fileText: string): string[] {\n const results: string[] = [];\n\n const tplRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*`((?:\\\\`|[^`])*)`\\s*;/g;\n let m: RegExpExecArray | null;\n while ((m = tplRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n const sglRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*'([^']*)'\\s*;/g;\n while ((m = sglRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n const dblRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*\"([^\"]*)\"\\s*;/g;\n while ((m = dblRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n return results;\n}\n\n/**\n * Get the title from markdown content\n */\nfunction extractTitle(content: string): string {\n const match = content.match(/^#\\s+(.+)$/m);\n return match ? match[1].trim() : \"Untitled\";\n}\n\n/**\n * List all documentation resources\n */\nexport async function listDocs(\n params?: ListDocsParams,\n): Promise<DocsResource[]> {\n const resources: DocsResource[] = [];\n const filterDir = params?.directory;\n\n for await (const filePath of walkDocs(APP_DIR)) {\n const relativePath = path.relative(PROJECT_ROOT, filePath);\n\n if (filterDir && !relativePath.includes(filterDir)) {\n continue;\n }\n\n try {\n const fileContent = await fs.readFile(filePath, \"utf8\");\n const blocks = extractContentBlocks(fileContent);\n const content = blocks.join(\"\\n\\n\");\n const title = extractTitle(content);\n const docPath = path.dirname(relativePath).replace(/^app\\/?/, \"\") || \"/\";\n\n resources.push({\n uri: `docs://${docPath}`,\n name: title,\n path: relativePath,\n content,\n });\n } catch {\n // Skip files that can't be read\n }\n }\n\n return resources;\n}\n\n/**\n * Get a specific documentation page\n */\nexport async function getDoc(\n params: GetDocParams,\n): Promise<DocsResource | null> {\n let targetPath = params.path;\n\n // Normalize path\n if (!targetPath.startsWith(\"app/\")) {\n targetPath = `app/${targetPath}`;\n }\n if (!targetPath.includes(\"page.\")) {\n targetPath = path.join(targetPath, \"page.tsx\");\n }\n\n const fullPath = path.join(PROJECT_ROOT, targetPath);\n\n try {\n const fileContent = await fs.readFile(fullPath, \"utf8\");\n const blocks = extractContentBlocks(fileContent);\n const content = blocks.join(\"\\n\\n\");\n const title = extractTitle(content);\n const docPath = path.dirname(targetPath).replace(/^app\\/?/, \"\") || \"/\";\n\n return {\n uri: `docs://${docPath}`,\n name: title,\n path: targetPath,\n content,\n };\n } catch {\n return null;\n }\n}\n\n/**\n * Chunk text for embeddings\n */\nexport function chunkText(\n text: string,\n chunkSize = 800,\n overlap = 100,\n): string[] {\n const chunks: string[] = [];\n let i = 0;\n while (i < text.length) {\n const end = Math.min(i + chunkSize, text.length);\n chunks.push(text.slice(i, end));\n if (end === text.length) break;\n i = end - overlap;\n if (i < 0) i = 0;\n }\n return chunks;\n}\n\n/**\n * Get all documentation chunks for indexing\n */\nexport async function getAllDocsChunks(): Promise<DocsChunk[]> {\n const allChunks: DocsChunk[] = [];\n const docs = await listDocs();\n\n for (const doc of docs) {\n const cleanContent = doc.content\n .replace(/\\r\\n/g, \"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .slice(0, 200_000);\n\n const textChunks = chunkText(cleanContent);\n for (let i = 0; i < textChunks.length; i++) {\n allChunks.push({\n id: `${doc.path}:${i}`,\n text: textChunks[i],\n path: doc.path,\n uri: doc.uri,\n });\n }\n }\n\n return allChunks;\n}\n";
|
package/package.json
CHANGED