doccupine 0.0.121 → 0.0.122

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.
@@ -3,3 +3,4 @@ export declare const DEFAULT_SITE_NAME = "Doccupine";
3
3
  export declare const DEFAULT_META_DESCRIPTION = "Generated with Doccupine";
4
4
  export declare const DEFAULT_FAVICON = "https://docs.doccupine.com/favicon.ico";
5
5
  export declare const DEFAULT_OG_IMAGE = "https://docs.doccupine.com/preview.png";
6
+ export declare const DEFAULT_URL = "https://docs.doccupine.com";
@@ -3,3 +3,4 @@ export const DEFAULT_SITE_NAME = "Doccupine";
3
3
  export const DEFAULT_META_DESCRIPTION = "Generated with Doccupine";
4
4
  export const DEFAULT_FAVICON = "https://docs.doccupine.com/favicon.ico";
5
5
  export const DEFAULT_OG_IMAGE = "https://docs.doccupine.com/preview.png";
6
+ export const DEFAULT_URL = "https://docs.doccupine.com";
@@ -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 // 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";
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 && !answer[answer.length - 1]?.answer && (\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 // Don't insert the answer bubble yet - an empty bubble renders a blank\n // gap above the \"Answering...\" loader. The bubble is created on the first\n // streamed token (or the done event) instead, so the loader stays put\n // until real text appears.\n const streamingAnswerIndex = mergedQuestions.length;\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";
@@ -873,7 +873,7 @@ function Chat() {
873
873
  )}
874
874
  </React.Fragment>
875
875
  ))}
876
- {loading && (
876
+ {loading && !answer[answer.length - 1]?.answer && (
877
877
  <StyledLoading>
878
878
  Answering<span>.</span>
879
879
  <span>.</span>
@@ -1057,8 +1057,11 @@ const ChtProvider = ({ children, isChatActive }: ChatContextProviderProps) => {
1057
1057
  throw new Error("Failed to get response reader");
1058
1058
  }
1059
1059
 
1060
+ // Don't insert the answer bubble yet - an empty bubble renders a blank
1061
+ // gap above the "Answering..." loader. The bubble is created on the first
1062
+ // streamed token (or the done event) instead, so the loader stays put
1063
+ // until real text appears.
1060
1064
  const streamingAnswerIndex = mergedQuestions.length;
1061
- setAnswer([...mergedQuestions, { text: "", answer: true }]);
1062
1065
 
1063
1066
  let buffer = "";
1064
1067
  while (true) {
@@ -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: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 3\norder: 8\n---\n\n# AI Assistant\n\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## Opening the Assistant\n\nOnce a provider is configured, an assistant button appears in the header. You can open and close the assistant from anywhere on the site by pressing <kbd>Command</kbd> + <kbd>I</kbd> (<kbd>Ctrl</kbd> + <kbd>I</kbd> on Windows). Press the same shortcut again to dismiss it.\n\n<Callout type=\"note\">\n The keyboard shortcut is only active once you have configured an LLM provider, as described below.\n</Callout>\n\n## Setup\n\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\n\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 chat model (see your provider's docs for available models)\n# LLM_CHAT_MODEL=your-model-id\n\n# Optional: Override default embedding model (see your provider's docs for available models)\n# Note: Anthropic doesn't provide embeddings, will fallback to OpenAI\n# LLM_EMBEDDING_MODEL=your-embedding-model-id\n\n# Optional: Set temperature (0-1, default: 0)\n# LLM_TEMPERATURE=0\n```\n\n## Provider Selection\n\nSet `LLM_PROVIDER` to one of the following values:\n\n- `openai` - Use OpenAI's models\n- `anthropic` - Use Anthropic's models\n- `google` - Use Google's models\n\n## API Keys\n\nYou need to set the API key that matches your chosen provider:\n\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\n\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\n\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\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## Default models\n\n| Provider | Chat model | Embedding model |\n| --------- | ---------------------------- | ------------------------ |\n| OpenAI | `gpt-4.1-nano` | `text-embedding-3-small` |\n| Anthropic | `claude-sonnet-4-5-20250929` | OpenAI fallback |\n| Google | `gemini-2.5-flash-lite` | `gemini-embedding-001` |\n\n## Optional Settings\n\n### Chat Model\n\nOverride the default chat model by uncommenting and setting `LLM_CHAT_MODEL`. You can use any available model from your chosen provider. For a complete list of available models, refer to the official documentation:\n\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\n\nOverride the default embedding model by uncommenting and setting `LLM_EMBEDDING_MODEL`. For a complete list of available embedding models, refer to the official documentation:\n\n- [OpenAI Embeddings](https://platform.openai.com/docs/guides/embeddings)\n- [Google Gemini Embeddings](https://ai.google.dev/gemini-api/docs/embeddings)\n- **Anthropic**: Anthropic doesn't provide embeddings. If you use Anthropic as your provider, Doccupine will fallback to OpenAI for embeddings.\n\n### Temperature\n\nControl the randomness of AI responses by setting `LLM_TEMPERATURE` to a value between 0 and 1:\n\n- `0` - More deterministic and focused responses (default)\n- `1` - More creative and varied responses";
1
+ export declare const aiAssistantMdxTemplate = "---\ntitle: \"AI Assistant\"\ndescription: \"Integrate AI capabilities into your Doccupine documentation using OpenAI, Anthropic, or Google Gemini.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 3\norder: 8\n---\n\n# AI Assistant\n\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## Opening the Assistant\n\nOnce a provider is configured, an assistant button appears in the header. You can open and close the assistant from anywhere on the site by pressing <kbd>Command</kbd> + <kbd>I</kbd> (<kbd>Ctrl</kbd> + <kbd>I</kbd> on Windows). Press the same shortcut again to dismiss it.\n\n<Callout type=\"note\">\n The keyboard shortcut is only active once you have configured an LLM provider, as described below.\n</Callout>\n\n## Setup\n\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\n\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 chat model (see your provider's docs for available models)\n# LLM_CHAT_MODEL=your-model-id\n\n# Optional: Override default embedding model (see your provider's docs for available models)\n# Note: Anthropic doesn't provide embeddings, will fallback to OpenAI\n# LLM_EMBEDDING_MODEL=your-embedding-model-id\n\n# Optional: Set temperature (0-1, default: 0)\n# LLM_TEMPERATURE=0\n\n# Optional: Embedding dimensions for the prebuilt search index (default: 512)\n# Doc vectors are truncated to this many dimensions and stored as int8 to keep\n# the search index small. Lower = smaller index, slightly lower recall.\n# Rebuild after changing this.\n# LLM_EMBEDDING_DIMS=512\n```\n\n## Provider Selection\n\nSet `LLM_PROVIDER` to one of the following values:\n\n- `openai` - Use OpenAI's models\n- `anthropic` - Use Anthropic's models\n- `google` - Use Google's models\n\n## API Keys\n\nYou need to set the API key that matches your chosen provider:\n\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\n\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\n\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\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## Default models\n\n| Provider | Chat model | Embedding model |\n| --------- | ---------------------------- | ------------------------ |\n| OpenAI | `gpt-4.1-nano` | `text-embedding-3-small` |\n| Anthropic | `claude-sonnet-4-5-20250929` | OpenAI fallback |\n| Google | `gemini-2.5-flash-lite` | `gemini-embedding-001` |\n\n## Optional Settings\n\n### Chat Model\n\nOverride the default chat model by uncommenting and setting `LLM_CHAT_MODEL`. You can use any available model from your chosen provider. For a complete list of available models, refer to the official documentation:\n\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\n\nOverride the default embedding model by uncommenting and setting `LLM_EMBEDDING_MODEL`. For a complete list of available embedding models, refer to the official documentation:\n\n- [OpenAI Embeddings](https://platform.openai.com/docs/guides/embeddings)\n- [Google Gemini Embeddings](https://ai.google.dev/gemini-api/docs/embeddings)\n- **Anthropic**: Anthropic doesn't provide embeddings. If you use Anthropic as your provider, Doccupine will fallback to OpenAI for embeddings.\n\n### Temperature\n\nControl the randomness of AI responses by setting `LLM_TEMPERATURE` to a value between 0 and 1:\n\n- `0` - More deterministic and focused responses (default)\n- `1` - More creative and varied responses\n\n### Embedding Dimensions\n\nControl the size of the prebuilt search index by setting `LLM_EMBEDDING_DIMS` (default: 512). Doc vectors are truncated to this many dimensions and stored as int8, keeping `services/mcp/docs-index.json` small so large doc sets don't stall the AI chat on serverless cold starts.\n\n- Lower values produce a smaller index with slightly lower search recall.\n- Values greater than or equal to the model's native dimension keep full precision.\n\nThis value must match between build time and runtime. A mismatch forces Doccupine to re-embed your docs at runtime, so rebuild or redeploy your site after changing it.";
@@ -46,6 +46,12 @@ GOOGLE_API_KEY=your_google_api_key_here
46
46
 
47
47
  # Optional: Set temperature (0-1, default: 0)
48
48
  # LLM_TEMPERATURE=0
49
+
50
+ # Optional: Embedding dimensions for the prebuilt search index (default: 512)
51
+ # Doc vectors are truncated to this many dimensions and stored as int8 to keep
52
+ # the search index small. Lower = smaller index, slightly lower recall.
53
+ # Rebuild after changing this.
54
+ # LLM_EMBEDDING_DIMS=512
49
55
  \`\`\`
50
56
 
51
57
  ## Provider Selection
@@ -126,4 +132,13 @@ Override the default embedding model by uncommenting and setting \`LLM_EMBEDDING
126
132
  Control the randomness of AI responses by setting \`LLM_TEMPERATURE\` to a value between 0 and 1:
127
133
 
128
134
  - \`0\` - More deterministic and focused responses (default)
129
- - \`1\` - More creative and varied responses`;
135
+ - \`1\` - More creative and varied responses
136
+
137
+ ### Embedding Dimensions
138
+
139
+ Control the size of the prebuilt search index by setting \`LLM_EMBEDDING_DIMS\` (default: 512). Doc vectors are truncated to this many dimensions and stored as int8, keeping \`services/mcp/docs-index.json\` small so large doc sets don't stall the AI chat on serverless cold starts.
140
+
141
+ - Lower values produce a smaller index with slightly lower search recall.
142
+ - Values greater than or equal to the model's native dimension keep full precision.
143
+
144
+ This value must match between build time and runtime. A mismatch forces Doccupine to re-embed your docs at runtime, so rebuild or redeploy your site after changing it.`;
@@ -1 +1 @@
1
- export declare const globalsMdxTemplate = "---\ntitle: \"Globals\"\ndescription: \"Configure global settings for your documentation.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 3\ncategoryIcon: \"settings\"\norder: 1\n---\n\n# Global Configuration\n\nUse a `config.json` file to define project\u2011wide metadata for your documentation site. These values are applied to every generated page unless a page overrides them in its own frontmatter.\n\n## config.json\n\nPlace a `config.json` at your project root (the same directory where you execute `npx doccupine`) to define global metadata for your documentation site.\n\n```json\n{\n \"name\": \"Doccupine\",\n \"description\": \"Doccupine is a 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.\",\n \"icon\": \"https://docs.doccupine.com/favicon.ico\",\n \"image\": \"https://docs.doccupine.com/preview.png\",\n \"url\": \"https://docs.example.com\"\n}\n```\n\n## Fields\n\nAll fields are optional. Doccupine uses sensible defaults when a field is not set.\n\n- **name**: The primary name of your documentation website. Displayed in the site title and used in various UI elements.\n- **description**: A concise summary of your project, used in site metadata (e.g., HTML meta description) and social previews when not overridden.\n- **icon**: The favicon for your site. You can provide a full URL or a relative path to an asset in your project.\n- **image**: The Open Graph image used when links to your docs are shared on social platforms. Accepts a full URL or a relative path.\n- **url**: The public URL of your deployed site. Used as the base URL for `sitemap.xml` and `robots.txt`. When omitted, no sitemap is generated. Can be overridden at deploy time with the `NEXT_PUBLIC_SITE_URL` environment variable.\n\n## Per-page overrides\n\nAny page can override global values by defining the matching key in its frontmatter. When present, the page's value takes precedence over `config.json` for that page only.\n\n| Frontmatter field | Overrides | Effect |\n| ----------------- | ------------- | ----------------------------------------------------------- |\n| **title** | - | Page title in metadata and Open Graph |\n| **description** | `description` | Meta description and Open Graph description |\n| **name** | `name` | Site name shown in the title suffix (e.g. \"Page - My Docs\") |\n| **icon** | `icon` | Favicon for this page |\n| **image** | `image` | Open Graph preview image |\n| **section** | - | Assigns the page to a [section](/sections) |\n| **sectionOrder** | - | Controls section position in the tab bar |\n| **sectionLabel** | - | Renames the default \"Docs\" tab (use on `index.mdx`) |\n\n<Callout type=\"note\">\n If a key is not specified in a page's frontmatter, Doccupine falls back to the corresponding value in `config.json`.\n</Callout>\n\nExample frontmatter in an `.mdx` file:\n\n```text\n---\ntitle: \"My Feature\"\ndescription: \"A focused description just for this page.\"\nname: \"My Product Docs\"\nicon: \"/custom-favicon.ico\"\nimage: \"/custom-preview.png\"\ndate: \"2026-02-19\"\ncategory: \"Guides\"\n---\n```";
1
+ export declare const globalsMdxTemplate = "---\ntitle: \"Globals\"\ndescription: \"Configure global settings for your documentation.\"\ndate: \"2026-02-19\"\ncategory: \"Configuration\"\ncategoryOrder: 3\ncategoryIcon: \"settings\"\norder: 1\n---\n\n# Global Configuration\n\nUse a `config.json` file to define project\u2011wide metadata for your documentation site. These values are applied to every generated page unless a page overrides them in its own frontmatter.\n\n## config.json\n\nPlace a `config.json` at your project root (the same directory where you execute `npx doccupine`) to define global metadata for your documentation site.\n\n```json\n{\n \"name\": \"Doccupine\",\n \"description\": \"Doccupine is a 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.\",\n \"icon\": \"https://docs.doccupine.com/favicon.ico\",\n \"image\": \"https://docs.doccupine.com/preview.png\",\n \"url\": \"https://docs.doccupine.com\"\n}\n```\n\n## Fields\n\nAll fields are optional. Doccupine uses sensible defaults when a field is not set.\n\n- **name**: The primary name of your documentation website. Displayed in the site title and used in various UI elements.\n- **description**: A concise summary of your project, used in site metadata (e.g., HTML meta description) and social previews when not overridden.\n- **icon**: The favicon for your site. You can provide a full URL or a relative path to an asset in your project.\n- **image**: The Open Graph image used when links to your docs are shared on social platforms. Accepts a full URL or a relative path.\n- **url**: The public URL of your deployed site. Used as the base URL for `sitemap.xml` and `robots.txt`. When omitted, no sitemap is generated. Can be overridden at deploy time with the `NEXT_PUBLIC_SITE_URL` environment variable.\n\n## Per-page overrides\n\nAny page can override global values by defining the matching key in its frontmatter. When present, the page's value takes precedence over `config.json` for that page only.\n\n| Frontmatter field | Overrides | Effect |\n| ----------------- | ------------- | ----------------------------------------------------------- |\n| **title** | - | Page title in metadata and Open Graph |\n| **description** | `description` | Meta description and Open Graph description |\n| **name** | `name` | Site name shown in the title suffix (e.g. \"Page - My Docs\") |\n| **icon** | `icon` | Favicon for this page |\n| **image** | `image` | Open Graph preview image |\n| **section** | - | Assigns the page to a [section](/sections) |\n| **sectionOrder** | - | Controls section position in the tab bar |\n| **sectionLabel** | - | Renames the default \"Docs\" tab (use on `index.mdx`) |\n\n<Callout type=\"note\">\n If a key is not specified in a page's frontmatter, Doccupine falls back to the corresponding value in `config.json`.\n</Callout>\n\nExample frontmatter in an `.mdx` file:\n\n```text\n---\ntitle: \"My Feature\"\ndescription: \"A focused description just for this page.\"\nname: \"My Product Docs\"\nicon: \"/custom-favicon.ico\"\nimage: \"/custom-preview.png\"\ndate: \"2026-02-19\"\ncategory: \"Guides\"\n---\n```";
@@ -1,4 +1,4 @@
1
- import { DEFAULT_DESCRIPTION, DEFAULT_FAVICON, DEFAULT_OG_IMAGE, } from "../../lib/constants.js";
1
+ import { DEFAULT_DESCRIPTION, DEFAULT_FAVICON, DEFAULT_OG_IMAGE, DEFAULT_URL, } from "../../lib/constants.js";
2
2
  export const globalsMdxTemplate = `---
3
3
  title: "Globals"
4
4
  description: "Configure global settings for your documentation."
@@ -23,7 +23,7 @@ Place a \`config.json\` at your project root (the same directory where you execu
23
23
  "description": "${DEFAULT_DESCRIPTION}",
24
24
  "icon": "${DEFAULT_FAVICON}",
25
25
  "image": "${DEFAULT_OG_IMAGE}",
26
- "url": "https://docs.example.com"
26
+ "url": "${DEFAULT_URL}"
27
27
  }
28
28
  \`\`\`
29
29
 
@@ -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\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. **Compaction**: Each vector is truncated to `LLM_EMBEDDING_DIMS` dimensions (default 512) and quantized to int8, keeping the index roughly 20x smaller than raw floats so large doc sets don't stall the chat on cold start.\n6. **Index Building**: The compact 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.";
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 The `DOCS_API_KEY` check only applies to the `/api/mcp` endpoint. It does not affect the `/api/rag` endpoint used by the built-in AI Assistant chat. To gate the entire site (including the chat and search APIs) behind a shared password instead, see the [Authentication](/authentication) documentation.\n</Callout>\n\n### Rate limiting\n\nTool calls to `POST /api/mcp` are rate limited per client IP address. When a client exceeds the limit, the server responds with `429 Too Many Requests` and a `Retry-After` header indicating how many seconds to wait before retrying.\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. **Compaction**: Each vector is truncated to `LLM_EMBEDDING_DIMS` dimensions (default 512) and quantized to int8, keeping the index roughly 20x smaller than raw floats so large doc sets don't stall the chat on cold start.\n6. **Index Building**: The compact 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.";
@@ -63,9 +63,13 @@ Authorization: Bearer your-secret-key
63
63
  Requests 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.
64
64
 
65
65
  <Callout type="note">
66
- 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.
66
+ The \`DOCS_API_KEY\` check only applies to the \`/api/mcp\` endpoint. It does not affect the \`/api/rag\` endpoint used by the built-in AI Assistant chat. To gate the entire site (including the chat and search APIs) behind a shared password instead, see the [Authentication](/authentication) documentation.
67
67
  </Callout>
68
68
 
69
+ ### Rate limiting
70
+
71
+ Tool calls to \`POST /api/mcp\` are rate limited per client IP address. When a client exceeds the limit, the server responds with \`429 Too Many Requests\` and a \`Retry-After\` header indicating how many seconds to wait before retrying.
72
+
69
73
  ### API Endpoints
70
74
 
71
75
  #### GET /api/mcp
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "doccupine",
3
- "version": "0.0.121",
3
+ "version": "0.0.122",
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": {