doccupine 0.0.52 → 0.0.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +4 -20
- package/dist/templates/app/api/mcp/route.d.ts +1 -1
- package/dist/templates/app/api/mcp/route.js +11 -0
- package/dist/templates/app/api/rag/route.d.ts +1 -1
- package/dist/templates/app/api/rag/route.js +8 -20
- package/dist/templates/app/layout.js +10 -7
- package/dist/templates/components/Chat.d.ts +1 -1
- package/dist/templates/components/Chat.js +33 -10
- package/dist/templates/components/ClickOutside.d.ts +1 -1
- package/dist/templates/components/ClickOutside.js +14 -12
- package/dist/templates/components/DocsSideBar.d.ts +1 -1
- package/dist/templates/components/DocsSideBar.js +1 -1
- package/dist/templates/components/layout/Accordion.d.ts +1 -1
- package/dist/templates/components/layout/Accordion.js +6 -1
- package/dist/templates/components/layout/Code.d.ts +1 -1
- package/dist/templates/components/layout/Code.js +11 -4
- package/dist/templates/components/layout/SharedStyles.d.ts +1 -1
- package/dist/templates/components/layout/SharedStyles.js +1 -1
- package/dist/templates/components/layout/ThemeToggle.d.ts +1 -1
- package/dist/templates/components/layout/ThemeToggle.js +1 -1
- package/dist/templates/next.config.d.ts +1 -1
- package/dist/templates/next.config.js +1 -1
- package/dist/templates/package.js +16 -15
- package/dist/templates/services/llm/config.d.ts +1 -1
- package/dist/templates/services/llm/config.js +0 -17
- package/dist/templates/services/llm/types.d.ts +1 -1
- package/dist/templates/services/llm/types.js +0 -10
- package/dist/templates/services/mcp/server.d.ts +1 -1
- package/dist/templates/services/mcp/server.js +34 -7
- package/dist/templates/services/mcp/tools.d.ts +1 -1
- package/dist/templates/services/mcp/tools.js +3 -1
- package/dist/templates/services/mcp/types.d.ts +1 -1
- package/dist/templates/services/mcp/types.js +1 -6
- package/dist/templates/tsconfig.js +2 -2
- package/dist/templates/utils/config.d.ts +1 -0
- package/dist/templates/utils/config.js +14 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -62,6 +62,7 @@ import { llmTypesTemplate } from "./templates/services/llm/types.js";
|
|
|
62
62
|
import { styledDTemplate } from "./templates/types/styled.js";
|
|
63
63
|
import { orderNavItemsTemplate } from "./templates/utils/orderNavItems.js";
|
|
64
64
|
import { rateLimitTemplate } from "./templates/utils/rateLimit.js";
|
|
65
|
+
import { configTemplate } from "./templates/utils/config.js";
|
|
65
66
|
import { accordionMdxTemplate } from "./templates/mdx/accordion.mdx.js";
|
|
66
67
|
import { aiAssistantMdxTemplate } from "./templates/mdx/ai-assistant.mdx.js";
|
|
67
68
|
import { buttonsMdxTemplate } from "./templates/mdx/buttons.mdx.js";
|
|
@@ -255,6 +256,7 @@ class MDXToNextJSGenerator {
|
|
|
255
256
|
"types/styled.d.ts": styledDTemplate,
|
|
256
257
|
"utils/orderNavItems.ts": orderNavItemsTemplate,
|
|
257
258
|
"utils/rateLimit.ts": rateLimitTemplate,
|
|
259
|
+
"utils/config.ts": configTemplate,
|
|
258
260
|
"components/Chat.tsx": chatTemplate,
|
|
259
261
|
"components/ClickOutside.ts": clickOutsideTemplate,
|
|
260
262
|
"components/Docs.tsx": docsTemplate,
|
|
@@ -666,16 +668,7 @@ class MDXToNextJSGenerator {
|
|
|
666
668
|
async generatePageFromMDX(mdxFile) {
|
|
667
669
|
const pageContent = `import { Metadata } from "next";
|
|
668
670
|
import { Docs } from "@/components/Docs";
|
|
669
|
-
import
|
|
670
|
-
|
|
671
|
-
interface Config {
|
|
672
|
-
name?: string;
|
|
673
|
-
description?: string;
|
|
674
|
-
icon?: string;
|
|
675
|
-
preview?: string;
|
|
676
|
-
}
|
|
677
|
-
|
|
678
|
-
const config = configData as Config;
|
|
671
|
+
import { config } from "@/utils/config";
|
|
679
672
|
|
|
680
673
|
const content = \`${escapeTemplateContent(mdxFile.content)}\`;
|
|
681
674
|
|
|
@@ -718,16 +711,7 @@ export default function Page() {
|
|
|
718
711
|
}
|
|
719
712
|
const indexContent = `import { Metadata } from "next";
|
|
720
713
|
import { Docs } from "@/components/Docs";
|
|
721
|
-
import
|
|
722
|
-
|
|
723
|
-
interface Config {
|
|
724
|
-
name?: string;
|
|
725
|
-
description?: string;
|
|
726
|
-
icon?: string;
|
|
727
|
-
preview?: string;
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
const config = configData as Config;
|
|
714
|
+
import { config } from "@/utils/config";
|
|
731
715
|
|
|
732
716
|
${indexMDX ? `const content = \`${escapeTemplateContent(indexMDX.content)}\`;` : `const content = null;`}
|
|
733
717
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const mcpRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { WebStandardStreamableHTTPServerTransport } from \"@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js\";\nimport { createMCPServer } from \"@/services/mcp/server\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n DOCS_TOOLS,\n listDocs,\n getDoc,\n} from \"@/services/mcp\";\nimport type { MCPToolName } from \"@/services/mcp\";\n\nconst searchDocsSchema = z.object({\n query: z.string().min(1).max(2000),\n limit: z.number().int().min(1).max(50).optional(),\n});\n\nconst getDocSchema = z.object({\n path: z.string().min(1).max(500),\n});\n\nconst listDocsSchema = z.object({\n directory: z.string().max(500).optional(),\n});\n\n// Create a stateless transport for serverless environment\nfunction createTransport() {\n return new WebStandardStreamableHTTPServerTransport({\n sessionIdGenerator: undefined, // Stateless mode for serverless\n enableJsonResponse: true,\n });\n}\n\n// Handle MCP protocol requests\nasync function handleMCPRequest(req: Request) {\n const transport = createTransport();\n const server = createMCPServer();\n\n try {\n await server.connect(transport);\n const response = await transport.handleRequest(req);\n\n // Clean up after response is done\n response\n .clone()\n .body?.pipeTo(\n new WritableStream({\n close() {\n transport.close();\n server.close();\n },\n }),\n )\n .catch(() => {});\n\n return response;\n } catch (error) {\n console.error(\"MCP request error:\", error);\n return new Response(\n JSON.stringify({\n jsonrpc: \"2.0\",\n error: {\n code: -32603,\n message: \"Internal server error\",\n },\n id: null,\n }),\n {\n status: 500,\n headers: { \"Content-Type\": \"application/json\" },\n },\n );\n }\n}\n\n// Handle REST API requests (original JSON format)\ninterface ToolCallRequest {\n tool: MCPToolName;\n params: Record<string, unknown>;\n}\n\nasync function handleRESTRequest(body: ToolCallRequest) {\n try {\n const { tool, params } = body;\n\n if (!tool) {\n return NextResponse.json(\n { error: \"Missing 'tool' parameter\" },\n { status: 400 },\n );\n }\n\n switch (tool) {\n case \"search_docs\": {\n const parsed = searchDocsSchema.safeParse(params);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid params\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n await ensureDocsIndex();\n const results = await searchDocs(\n parsed.data.query,\n parsed.data.limit ?? 6,\n );\n return NextResponse.json({\n content: results.map(({ chunk, score }) => ({\n path: chunk.path,\n uri: chunk.uri,\n score: score.toFixed(3),\n text: chunk.text,\n })),\n });\n }\n\n case \"get_doc\": {\n const parsed = getDocSchema.safeParse(params);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid params\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n const doc = await getDoc({ path: parsed.data.path });\n if (!doc) {\n return NextResponse.json(\n { error: \"Document not found\" },\n { status: 404 },\n );\n }\n return NextResponse.json({ content: doc });\n }\n\n case \"list_docs\": {\n const parsed = listDocsSchema.safeParse(params);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid params\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n const docs = await listDocs({\n directory: parsed.data.directory,\n });\n return NextResponse.json({\n content: docs.map((d) => ({\n name: d.name,\n path: d.path,\n uri: d.uri,\n })),\n });\n }\n\n default:\n return NextResponse.json(\n { error: `Unknown tool: ${tool}` },\n { status: 400 },\n );\n }\n } catch (e: unknown) {\n const message = e instanceof Error ? e.message : \"Unknown error\";\n return NextResponse.json({ error: message }, { status: 500 });\n }\n}\n\nexport async function POST(req: Request) {\n // Clone the request to read body twice if needed\n const clonedReq = req.clone();\n\n try {\n const body = await clonedReq.json();\n\n // Check if this is an MCP protocol request (has jsonrpc field)\n // or a REST API request (has tool field)\n if (\"jsonrpc\" in body) {\n // MCP protocol request - use the original request\n return handleMCPRequest(req);\n } else if (\"tool\" in body) {\n // REST API request\n return handleRESTRequest(body as ToolCallRequest);\n } else {\n return NextResponse.json(\n {\n error:\n \"Invalid request format. Expected 'jsonrpc' (MCP) or 'tool' (REST) field.\",\n },\n { status: 400 },\n );\n }\n } catch {\n return NextResponse.json({ error: \"Invalid JSON body\" }, { status: 400 });\n }\n}\n\nexport async function GET() {\n // GET always returns REST API format (tools list and index status)\n const status = getIndexStatus();\n return NextResponse.json({\n tools: DOCS_TOOLS,\n index: {\n ready: status.ready,\n chunkCount: status.chunkCount,\n },\n });\n}\n\nexport async function DELETE(req: Request) {\n // DELETE is only used by MCP protocol\n return handleMCPRequest(req);\n}\n";
|
|
1
|
+
export declare const mcpRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { WebStandardStreamableHTTPServerTransport } from \"@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js\";\nimport { createMCPServer } from \"@/services/mcp/server\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n DOCS_TOOLS,\n listDocs,\n getDoc,\n} from \"@/services/mcp\";\nimport type { MCPToolName } from \"@/services/mcp\";\nimport { rateLimit } from \"@/utils/rateLimit\";\n\nconst searchDocsSchema = z.object({\n query: z.string().min(1).max(2000),\n limit: z.number().int().min(1).max(50).optional(),\n});\n\nconst getDocSchema = z.object({\n path: z.string().min(1).max(500),\n});\n\nconst listDocsSchema = z.object({\n directory: z.string().max(500).optional(),\n});\n\n// Create a stateless transport for serverless environment\nfunction createTransport() {\n return new WebStandardStreamableHTTPServerTransport({\n sessionIdGenerator: undefined, // Stateless mode for serverless\n enableJsonResponse: true,\n });\n}\n\n// Handle MCP protocol requests\nasync function handleMCPRequest(req: Request) {\n const transport = createTransport();\n const server = createMCPServer();\n\n try {\n await server.connect(transport);\n const response = await transport.handleRequest(req);\n\n // Clean up after response is done\n response\n .clone()\n .body?.pipeTo(\n new WritableStream({\n close() {\n transport.close();\n server.close();\n },\n }),\n )\n .catch(() => {});\n\n return response;\n } catch (error) {\n console.error(\"MCP request error:\", error);\n return new Response(\n JSON.stringify({\n jsonrpc: \"2.0\",\n error: {\n code: -32603,\n message: \"Internal server error\",\n },\n id: null,\n }),\n {\n status: 500,\n headers: { \"Content-Type\": \"application/json\" },\n },\n );\n }\n}\n\n// Handle REST API requests (original JSON format)\ninterface ToolCallRequest {\n tool: MCPToolName;\n params: Record<string, unknown>;\n}\n\nasync function handleRESTRequest(body: ToolCallRequest) {\n try {\n const { tool, params } = body;\n\n if (!tool) {\n return NextResponse.json(\n { error: \"Missing 'tool' parameter\" },\n { status: 400 },\n );\n }\n\n switch (tool) {\n case \"search_docs\": {\n const parsed = searchDocsSchema.safeParse(params);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid params\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n await ensureDocsIndex();\n const results = await searchDocs(\n parsed.data.query,\n parsed.data.limit ?? 6,\n );\n return NextResponse.json({\n content: results.map(({ chunk, score }) => ({\n path: chunk.path,\n uri: chunk.uri,\n score: score.toFixed(3),\n text: chunk.text,\n })),\n });\n }\n\n case \"get_doc\": {\n const parsed = getDocSchema.safeParse(params);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid params\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n const doc = await getDoc({ path: parsed.data.path });\n if (!doc) {\n return NextResponse.json(\n { error: \"Document not found\" },\n { status: 404 },\n );\n }\n return NextResponse.json({ content: doc });\n }\n\n case \"list_docs\": {\n const parsed = listDocsSchema.safeParse(params);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid params\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n const docs = await listDocs({\n directory: parsed.data.directory,\n });\n return NextResponse.json({\n content: docs.map((d) => ({\n name: d.name,\n path: d.path,\n uri: d.uri,\n })),\n });\n }\n\n default:\n return NextResponse.json(\n { error: `Unknown tool: ${tool}` },\n { status: 400 },\n );\n }\n } catch (e: unknown) {\n const message = e instanceof Error ? e.message : \"Unknown error\";\n return NextResponse.json({ error: message }, { status: 500 });\n }\n}\n\nexport async function POST(req: Request) {\n const ip =\n req.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ?? \"unknown\";\n const { allowed, retryAfter } = rateLimit(ip);\n if (!allowed) {\n return NextResponse.json(\n { error: \"Too many requests\" },\n { status: 429, headers: { \"Retry-After\": String(retryAfter) } },\n );\n }\n\n // Clone the request to read body twice if needed\n const clonedReq = req.clone();\n\n try {\n const body = await clonedReq.json();\n\n // Check if this is an MCP protocol request (has jsonrpc field)\n // or a REST API request (has tool field)\n if (\"jsonrpc\" in body) {\n // MCP protocol request - use the original request\n return handleMCPRequest(req);\n } else if (\"tool\" in body) {\n // REST API request\n return handleRESTRequest(body as ToolCallRequest);\n } else {\n return NextResponse.json(\n {\n error:\n \"Invalid request format. Expected 'jsonrpc' (MCP) or 'tool' (REST) field.\",\n },\n { status: 400 },\n );\n }\n } catch {\n return NextResponse.json({ error: \"Invalid JSON body\" }, { status: 400 });\n }\n}\n\nexport async function GET() {\n // GET always returns REST API format (tools list and index status)\n const status = getIndexStatus();\n return NextResponse.json({\n tools: DOCS_TOOLS,\n index: {\n ready: status.ready,\n chunkCount: status.chunkCount,\n },\n });\n}\n\nexport async function DELETE(req: Request) {\n // DELETE is only used by MCP protocol\n return handleMCPRequest(req);\n}\n";
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
getDoc,
|
|
12
12
|
} from "@/services/mcp";
|
|
13
13
|
import type { MCPToolName } from "@/services/mcp";
|
|
14
|
+
import { rateLimit } from "@/utils/rateLimit";
|
|
14
15
|
|
|
15
16
|
const searchDocsSchema = z.object({
|
|
16
17
|
query: z.string().min(1).max(2000),
|
|
@@ -167,6 +168,16 @@ async function handleRESTRequest(body: ToolCallRequest) {
|
|
|
167
168
|
}
|
|
168
169
|
|
|
169
170
|
export async function POST(req: Request) {
|
|
171
|
+
const ip =
|
|
172
|
+
req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
|
173
|
+
const { allowed, retryAfter } = rateLimit(ip);
|
|
174
|
+
if (!allowed) {
|
|
175
|
+
return NextResponse.json(
|
|
176
|
+
{ error: "Too many requests" },
|
|
177
|
+
{ status: 429, headers: { "Retry-After": String(retryAfter) } },
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
170
181
|
// Clone the request to read body twice if needed
|
|
171
182
|
const clonedReq = req.clone();
|
|
172
183
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const ragRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport
|
|
1
|
+
export declare const ragRoutesTemplate = "import { NextResponse } from \"next/server\";\nimport { z } from \"zod\";\nimport { getLLMConfig, createChatModel } from \"@/services/llm\";\nimport {\n searchDocs,\n ensureDocsIndex,\n getIndexStatus,\n} from \"@/services/mcp/server\";\nimport { rateLimit } from \"@/utils/rateLimit\";\nimport { config } from \"@/utils/config\";\n\nconst ragSchema = z.object({\n question: z.string().min(1).max(2000),\n refresh: z.boolean().optional(),\n});\n\nconst projectName = config.name || \"Doccupine\";\n\nconst systemContext = `You are AI Assistant, a documentation assistant for ${projectName}, Your name is ${projectName} AI Assistant.\n\n## Core Rules\n1. Answer ONLY from the provided context. Never fabricate information.\n2. If the answer isn't in the context, say so clearly and suggest relevant sections or pages the user might check.\n3. If the question is ambiguous, ask a brief clarifying question before answering.\n\n## Response Style\n- Be concise and direct. Lead with the answer, then provide details if needed.\n- Use code examples from the context when relevant.\n- Match the technical level of the user's question.\n\n## MDX/Code Formatting\nWhen including code blocks in your response:\n- Never nest fenced code blocks (triple backticks) inside other fenced code blocks.\n- If you need to show MDX source that itself contains code blocks, use indented code blocks or escape the inner backticks.\n- All output must be valid MDX that renders correctly.\n\n## Greetings & Small Talk\nIf the user sends a greeting or non-documentation question, respond briefly and ask how you can help with the documentation.`;\n\nexport async function POST(req: Request) {\n // Rate limit by IP\n const ip =\n req.headers.get(\"x-forwarded-for\")?.split(\",\")[0]?.trim() ?? \"unknown\";\n const { allowed, retryAfter } = rateLimit(ip);\n if (!allowed) {\n return NextResponse.json(\n { error: \"Too many requests\" },\n { status: 429, headers: { \"Retry-After\": String(retryAfter) } },\n );\n }\n\n try {\n const body = await req.json();\n const parsed = ragSchema.safeParse(body);\n if (!parsed.success) {\n return NextResponse.json(\n { error: \"Invalid input\", details: parsed.error.issues },\n { status: 400 },\n );\n }\n const { question, refresh } = parsed.data;\n\n let llmConfig;\n try {\n llmConfig = getLLMConfig();\n } catch (error: unknown) {\n const message =\n error instanceof Error ? error.message : \"LLM configuration error\";\n return NextResponse.json({ error: message }, { status: 500 });\n }\n\n // Use MCP service to ensure docs are indexed\n await ensureDocsIndex(Boolean(refresh));\n\n // Use MCP search_docs tool to find relevant documentation\n const searchResults = await searchDocs(question, 6);\n\n // Build context from search results\n const context = searchResults\n .map(\n ({ chunk, score }) =>\n `File: ${chunk.path}\\nScore: ${score.toFixed(3)}\\n----\\n${chunk.text}`,\n )\n .join(\"\\n\\n================\\n\\n\");\n\n // Create chat model and stream response\n const llm = createChatModel(llmConfig);\n const prompt = [\n {\n role: \"system\" as const,\n content: systemContext,\n },\n {\n role: \"user\" as const,\n content: `Question: ${question}\\n\\nContext:\\n${context}`,\n },\n ];\n\n const stream = await llm.stream(prompt);\n\n // Build metadata from MCP search results\n const indexStatus = getIndexStatus();\n const metadata = {\n sources: searchResults.map(({ chunk, score }) => ({\n id: chunk.id,\n path: chunk.path,\n uri: chunk.uri,\n score,\n })),\n chunkCount: indexStatus.chunkCount,\n };\n\n const encoder = new TextEncoder();\n const readableStream = new ReadableStream({\n async start(controller) {\n try {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"metadata\", data: metadata })}\\n\\n`,\n ),\n );\n\n for await (const chunk of stream) {\n const content = chunk?.content || \"\";\n if (content) {\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"content\", data: content })}\\n\\n`,\n ),\n );\n }\n }\n\n controller.enqueue(\n encoder.encode(`data: ${JSON.stringify({ type: \"done\" })}\\n\\n`),\n );\n controller.close();\n } catch (error: unknown) {\n const message =\n error instanceof Error ? error.message : \"Stream error\";\n controller.enqueue(\n encoder.encode(\n `data: ${JSON.stringify({ type: \"error\", data: message })}\\n\\n`,\n ),\n );\n controller.close();\n }\n },\n });\n\n return new Response(readableStream, {\n headers: {\n \"Content-Type\": \"text/event-stream\",\n \"Cache-Control\": \"no-cache\",\n Connection: \"keep-alive\",\n },\n });\n } catch (e: unknown) {\n const message = e instanceof Error ? e.message : \"Unknown error\";\n return NextResponse.json({ error: message }, { status: 500 });\n }\n}\n\nexport async function GET() {\n const status = getIndexStatus();\n return NextResponse.json({\n ready: status.ready,\n chunks: status.chunkCount,\n });\n}\n";
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
export const ragRoutesTemplate = `import { NextResponse } from "next/server";
|
|
2
|
-
import path from "node:path";
|
|
3
2
|
import { z } from "zod";
|
|
4
3
|
import { getLLMConfig, createChatModel } from "@/services/llm";
|
|
5
4
|
import {
|
|
@@ -8,18 +7,7 @@ import {
|
|
|
8
7
|
getIndexStatus,
|
|
9
8
|
} from "@/services/mcp/server";
|
|
10
9
|
import { rateLimit } from "@/utils/rateLimit";
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
const config = configData as Config;
|
|
14
|
-
|
|
15
|
-
interface Config {
|
|
16
|
-
name?: string;
|
|
17
|
-
description?: string;
|
|
18
|
-
icon?: string;
|
|
19
|
-
preview?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
const PROJECT_ROOT = process.cwd();
|
|
10
|
+
import { config } from "@/utils/config";
|
|
23
11
|
|
|
24
12
|
const ragSchema = z.object({
|
|
25
13
|
question: z.string().min(1).max(2000),
|
|
@@ -72,9 +60,9 @@ export async function POST(req: Request) {
|
|
|
72
60
|
}
|
|
73
61
|
const { question, refresh } = parsed.data;
|
|
74
62
|
|
|
75
|
-
let
|
|
63
|
+
let llmConfig;
|
|
76
64
|
try {
|
|
77
|
-
|
|
65
|
+
llmConfig = getLLMConfig();
|
|
78
66
|
} catch (error: unknown) {
|
|
79
67
|
const message =
|
|
80
68
|
error instanceof Error ? error.message : "LLM configuration error";
|
|
@@ -85,22 +73,22 @@ export async function POST(req: Request) {
|
|
|
85
73
|
await ensureDocsIndex(Boolean(refresh));
|
|
86
74
|
|
|
87
75
|
// Use MCP search_docs tool to find relevant documentation
|
|
88
|
-
const searchResults = await searchDocs(
|
|
76
|
+
const searchResults = await searchDocs(question, 6);
|
|
89
77
|
|
|
90
78
|
// Build context from search results
|
|
91
79
|
const context = searchResults
|
|
92
80
|
.map(
|
|
93
81
|
({ chunk, score }) =>
|
|
94
|
-
\`File: \${
|
|
82
|
+
\`File: \${chunk.path}\\nScore: \${score.toFixed(3)}\\n----\\n\${chunk.text}\`,
|
|
95
83
|
)
|
|
96
84
|
.join("\\n\\n================\\n\\n");
|
|
97
85
|
|
|
98
86
|
// Create chat model and stream response
|
|
99
|
-
const llm = createChatModel(
|
|
87
|
+
const llm = createChatModel(llmConfig);
|
|
100
88
|
const prompt = [
|
|
101
89
|
{
|
|
102
90
|
role: "system" as const,
|
|
103
|
-
content: systemContext
|
|
91
|
+
content: systemContext,
|
|
104
92
|
},
|
|
105
93
|
{
|
|
106
94
|
role: "user" as const,
|
|
@@ -115,7 +103,7 @@ export async function POST(req: Request) {
|
|
|
115
103
|
const metadata = {
|
|
116
104
|
sources: searchResults.map(({ chunk, score }) => ({
|
|
117
105
|
id: chunk.id,
|
|
118
|
-
path:
|
|
106
|
+
path: chunk.path,
|
|
119
107
|
uri: chunk.uri,
|
|
120
108
|
score,
|
|
121
109
|
})),
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export const layoutTemplate = (pages, fontConfig) => `import type { Metadata } from "next";
|
|
2
2
|
${fontConfig?.googleFont?.fontName?.length ? `import { ${fontConfig.googleFont.fontName} } from "next/font/google";` : fontConfig?.localFonts?.length || fontConfig?.localFonts?.src?.length ? 'import localFont from "next/font/local";' : 'import { Inter } from "next/font/google";'}
|
|
3
|
+
import dynamic from "next/dynamic";
|
|
3
4
|
import { StyledComponentsRegistry } from "cherry-styled-components";
|
|
4
5
|
import { theme, themeDark } from "@/app/theme";
|
|
5
6
|
import { CherryThemeProvider } from "@/components/layout/CherryThemeProvider";
|
|
6
|
-
import {
|
|
7
|
+
import { ChtProvider } from "@/components/Chat";
|
|
7
8
|
import { Footer } from "@/components/layout/Footer";
|
|
8
9
|
import { Header } from "@/components/layout/Header";
|
|
9
10
|
import { DocsWrapper } from "@/components/layout/DocsComponents";
|
|
@@ -14,7 +15,9 @@ import {
|
|
|
14
15
|
type PagesProps,
|
|
15
16
|
} from "@/utils/orderNavItems";
|
|
16
17
|
import { StaticLinks } from "@/components/layout/StaticLinks";
|
|
18
|
+
import { config } from "@/utils/config";
|
|
17
19
|
import navigation from "@/navigation.json";
|
|
20
|
+
const Chat = dynamic(() => import("@/components/Chat").then((mod) => mod.Chat));
|
|
18
21
|
|
|
19
22
|
${fontConfig?.googleFont?.fontName?.length
|
|
20
23
|
? `const font = ${fontConfig.googleFont.fontName}({ subsets: ${fontConfig?.googleFont?.subsets?.length ? JSON.stringify(fontConfig?.googleFont?.subsets, null, 2) : '["latin"]'}, ${fontConfig.googleFont?.weight.length ? `weight: "${fontConfig.googleFont.weight}"` : ""} });`
|
|
@@ -25,13 +28,13 @@ ${fontConfig?.googleFont?.fontName?.length
|
|
|
25
28
|
: 'const font = Inter({ subsets: ["latin"] });'}
|
|
26
29
|
|
|
27
30
|
export const metadata: Metadata = {
|
|
28
|
-
title: "Doccupine",
|
|
29
|
-
description:
|
|
30
|
-
|
|
31
|
+
title: config.name || "Doccupine",
|
|
32
|
+
description: config.description || "Doccupine is a free and open-source document management system that allows you to store, organize, and share your documentation with ease. AI-ready.",
|
|
33
|
+
icons: config.icon || "https://doccupine.com/favicon.ico",
|
|
31
34
|
openGraph: {
|
|
32
|
-
title: "Doccupine",
|
|
33
|
-
description:
|
|
34
|
-
|
|
35
|
+
title: config.name || "Doccupine",
|
|
36
|
+
description: config.description || "Doccupine is a free and open-source document management system that allows you to store, organize, and share your documentation with ease. AI-ready.",
|
|
37
|
+
images: config.preview || "https://doccupine.com/preview.png",
|
|
35
38
|
},
|
|
36
39
|
};
|
|
37
40
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const chatTemplate = "\"use client\";\nimport React, {\n createContext,\n useContext,\n useEffect,\n useRef,\n useState,\n} from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { rgba } from \"polished\";\nimport { Button } from \"cherry-styled-components\";\nimport { ArrowUp, LoaderPinwheel, 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 { mq, Theme } from \"@/app/theme\";\nimport { useMDXComponents } from \"@/components/MDXComponents\";\nimport { styledTable, stylesLists } from \"@/components/layout/SharedStyled\";\nimport links from \"@/links.json\";\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(100vh - 90px);\n overflow-y: scroll;\n overflow-x: hidden;\n z-index: 1000;\n width: 100%;\n padding: 0 20px;\n transition: all 0.3s ease;\n transform: translateX(0);\n background: ${({ theme }) => theme.colors.light};\n\n ${({ $isVisible }) =>\n !$isVisible &&\n css`\n transform: translateX(100%);\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 ${rgba(\"#ff6b6b\", 0.4)},\n ${rgba(\"#feca57\", 0.4)},\n ${rgba(\"#48dbfb\", 0.4)},\n ${rgba(\"#ff9ff3\", 0.4)},\n ${rgba(\"#54a0ff\", 0.4)},\n ${rgba(\"#5f27cd\", 0.4)},\n ${rgba(\"#ff6b6b\", 0.4)}\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: 14px 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 }) => rgba(theme.colors.dark, 0.4)};\n transition: color 0.3s ease;\n }\n\n &:focus::placeholder {\n color: ${({ theme }) => rgba(theme.colors.dark, 0.6)};\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 }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\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\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n\n ${({ $isVisible }) =>\n $isVisible &&\n css`\n transform: translateX(0);\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledChatFixedForm = styled.form<{\n theme: Theme;\n $hide: boolean;\n $hasLinks: boolean;\n}>`\n transition: all 0.3s ease;\n position: fixed;\n bottom: 20px;\n left: 20px;\n width: calc(100% - 115px);\n z-index: 998;\n\n ${mq(\"lg\")} {\n left: 50%;\n transform: translateX(-50%) translateY(0);\n bottom: initial;\n position: absolute;\n top: 90px;\n width: calc(100% - 320px * 2 - 40px);\n opacity: 1;\n\n ${({ $hasLinks }) =>\n $hasLinks &&\n css`\n top: 164px;\n `}\n }\n\n ${({ $hide }) =>\n $hide &&\n css`\n transform: translateX(-100px);\n\n ${mq(\"lg\")} {\n opacity: 0;\n transform: translateX(-50%) translateY(-20px);\n }\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledChatFixedInner = styled.div`\n margin: auto;\n display: flex;\n gap: 10px;\n justify-content: center;\n align-items: center;\n\n ${mq(\"lg\")} {\n max-width: 640px;\n }\n`;\n\nconst StyledError = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n background: ${({ theme }) => theme.colors.error};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\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 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 background: ${({ theme }) => theme.colors.primary};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\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 `}\n\n & code:not([class]) {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n color: ${({ theme }) => theme.colors.dark};\n padding: 2px 4px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};A\n white-space: pre;\n }\n\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 & ol {\n & > li {\n padding-left: 20px;\n\n &::before {\n position: absolute;\n top: 0;\n left: 0;\n }\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 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: 25px 20px;\n height: 73px;\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: 5px;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\nconst StyledChatCloseButton = styled.button<{ theme: Theme }>`\n background: transparent;\n border: none;\n cursor: pointer;\n padding: 0;\n margin: 0;\n color: ${({ theme }) => theme.colors.primary};\n\n &:hover {\n color: ${({ theme }) => theme.colors.primaryDark};\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n`;\n\ntype Answer = {\n text: string;\n answer?: boolean;\n mdx?: MDXRemoteSerializeResult;\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}\n\nfunction RainbowInput({\n id,\n value,\n onChange,\n placeholder,\n autoComplete,\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 id={id}\n value={value}\n onChange={onChange}\n placeholder={placeholder}\n autoComplete={autoComplete}\n onFocus={() => setIsFocused(true)}\n onBlur={() => setIsFocused(false)}\n />\n </StyledRainbowInputWrapper>\n );\n}\n\nfunction Chat() {\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 endRef = useRef<HTMLDivElement | null>(null);\n const mdxComponents = useMDXComponents({});\n const { isOpen, setIsOpen } = useContext(ChatContext);\n\n useEffect(() => {\n endRef.current?.scrollIntoView({ behavior: \"smooth\", block: \"end\" });\n }, [answer]);\n\n useEffect(() => {\n if (answer?.length > 0) {\n const el = document.getElementById(\n \"chat-bottom-input\",\n ) as HTMLInputElement | null;\n el?.focus();\n }\n }, [answer]);\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 try {\n const res = await fetch(\"/api/rag\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ question: currentQuestion }),\n });\n\n if (!res.ok) {\n const errorData = await res.json();\n throw new Error(errorData.error || \"Request failed\");\n }\n\n const reader = res.body?.getReader();\n const decoder = new TextDecoder();\n let streamedContent = \"\";\n if (!reader) {\n throw new Error(\"Failed to get response reader\");\n }\n\n // Add a placeholder for the streaming answer\n const streamingAnswerIndex = mergedQuestions.length;\n setAnswer([...mergedQuestions, { text: \"\", answer: true }]);\n\n while (true) {\n const { done, value } = await reader.read();\n if (done) break;\n\n const chunk = decoder.decode(value);\n const lines = chunk.split(\"\\n\");\n\n for (const line of lines) {\n if (line.startsWith(\"data: \")) {\n try {\n const data = JSON.parse(line.slice(6));\n\n if (data.type === \"content\") {\n streamedContent += data.data;\n\n setAnswer((prev) => {\n const newAnswers = [...prev];\n newAnswers[streamingAnswerIndex] = {\n text: streamedContent,\n answer: true,\n };\n return newAnswers;\n });\n } else if (data.type === \"error\") {\n throw new Error(data.data);\n } else if (data.type === \"done\") {\n // Finalize with MDX serialization\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 };\n return newAnswers;\n });\n }\n } catch (parseError) {\n console.error(\"Failed to parse SSE data:\", parseError);\n }\n }\n }\n }\n } catch (err: unknown) {\n setError(err instanceof Error ? err.message : \"Unknown error\");\n } finally {\n setLoading(false);\n }\n }\n\n return (\n <>\n <StyledChatFixedForm\n onSubmit={ask}\n $hide={answer?.length > 0}\n $hasLinks={links.length > 0}\n >\n <StyledChatFixedInner>\n <RainbowInput\n value={question}\n onChange={(e) => setQuestion(e.target.value)}\n placeholder=\"Ask AI Assistant...\"\n autoComplete=\"off\"\n />\n <StyledRainbowButton\n type=\"submit\"\n disabled={loading}\n $hasContent={question.trim().length > 0}\n >\n {loading ? <LoaderPinwheel className=\"loading\" /> : <ArrowUp />}\n </StyledRainbowButton>\n </StyledChatFixedInner>\n </StyledChatFixedForm>\n\n <StyledChat $isVisible={isOpen}>\n <StyledChatTitle>\n <StyledChatTitleIconWrapper>\n <Sparkles />\n <h3>AI Assistant</h3>\n </StyledChatTitleIconWrapper>\n <StyledChatCloseButton\n onClick={() => {\n setAnswer([]);\n setIsOpen(false);\n }}\n >\n <X />\n </StyledChatCloseButton>\n </StyledChatTitle>\n {answer &&\n answer.map((a, i) => (\n <StyledAnswer key={i} $isAnswer={a.answer ?? false}>\n {a.answer && a.mdx ? (\n <MDXRemote {...a.mdx} components={mdxComponents} />\n ) : (\n a.text\n )}\n </StyledAnswer>\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 value={question}\n onChange={(e) => setQuestion(e.target.value)}\n placeholder=\"Ask AI Assistant...\"\n autoComplete=\"off\"\n />\n <StyledRainbowButton\n type=\"submit\"\n disabled={loading || question.trim() === \"\"}\n $hasContent={question.trim().length > 0}\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 isChatActive: boolean;\n}>({\n isOpen: false,\n setIsOpen: () => {},\n isChatActive: false,\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\n return (\n <ChatContext.Provider\n value={{\n isOpen,\n setIsOpen,\n isChatActive,\n }}\n >\n {children}\n </ChatContext.Provider>\n );\n};\n\nexport { Chat, ChtProvider, ChatContext };\n";
|
|
1
|
+
export declare const chatTemplate = "\"use client\";\nimport React, {\n createContext,\n useContext,\n useEffect,\n useRef,\n useState,\n} from \"react\";\nimport styled, { css, keyframes } from \"styled-components\";\nimport { rgba } from \"polished\";\nimport { Button } from \"cherry-styled-components\";\nimport { ArrowUp, LoaderPinwheel, 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 { mq, Theme } from \"@/app/theme\";\nimport { useMDXComponents as getMDXComponents } from \"@/components/MDXComponents\";\nimport { styledTable, stylesLists } from \"@/components/layout/SharedStyled\";\nimport links from \"@/links.json\";\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(100vh - 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\n ${({ $isVisible }) =>\n !$isVisible &&\n css`\n transform: translateX(100%);\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 ${rgba(\"#ff6b6b\", 0.4)},\n ${rgba(\"#feca57\", 0.4)},\n ${rgba(\"#48dbfb\", 0.4)},\n ${rgba(\"#ff9ff3\", 0.4)},\n ${rgba(\"#54a0ff\", 0.4)},\n ${rgba(\"#5f27cd\", 0.4)},\n ${rgba(\"#ff6b6b\", 0.4)}\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: 14px 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 }) => rgba(theme.colors.dark, 0.4)};\n transition: color 0.3s ease;\n }\n\n &:focus::placeholder {\n color: ${({ theme }) => rgba(theme.colors.dark, 0.6)};\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 }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\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\n ${mq(\"lg\")} {\n width: 420px;\n border-left: solid 1px ${({ theme }) => theme.colors.grayLight};\n }\n\n ${({ $isVisible }) =>\n $isVisible &&\n css`\n transform: translateX(0);\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledChatFixedForm = styled.form<{\n theme: Theme;\n $hide: boolean;\n $hasLinks: boolean;\n}>`\n transition: all 0.3s ease;\n position: fixed;\n bottom: 20px;\n left: 20px;\n width: calc(100% - 115px);\n z-index: 998;\n\n ${mq(\"lg\")} {\n left: 50%;\n transform: translateX(-50%) translateY(0);\n bottom: initial;\n position: absolute;\n top: 90px;\n width: calc(100% - 320px * 2 - 40px);\n opacity: 1;\n\n ${({ $hasLinks }) =>\n $hasLinks &&\n css`\n top: 164px;\n `}\n }\n\n ${({ $hide }) =>\n $hide &&\n css`\n transform: translateX(-100px);\n\n ${mq(\"lg\")} {\n opacity: 0;\n transform: translateX(-50%) translateY(-20px);\n }\n `}\n\n & .loading {\n animation: ${loadingAnimation} 1s linear infinite;\n }\n`;\n\nconst StyledChatFixedInner = styled.div`\n margin: auto;\n display: flex;\n gap: 10px;\n justify-content: center;\n align-items: center;\n\n ${mq(\"lg\")} {\n max-width: 640px;\n }\n`;\n\nconst StyledError = styled.div<{ theme: Theme }>`\n overflow-x: auto;\n background: ${({ theme }) => theme.colors.error};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\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 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 background: ${({ theme }) => theme.colors.primary};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n padding: 10px;\n border-radius: 8px;\n margin: 20px 0;\n width: 100%;\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 `}\n\n & code:not([class]) {\n background: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\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 ${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 & ol {\n & > li {\n padding-left: 20px;\n\n &::before {\n position: absolute;\n top: 0;\n left: 0;\n }\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 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: 25px 20px;\n height: 73px;\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: 5px;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\nconst StyledChatCloseButton = styled.button<{ theme: Theme }>`\n background: transparent;\n border: none;\n cursor: pointer;\n padding: 0;\n margin: 0;\n color: ${({ theme }) => theme.colors.primary};\n\n &:hover {\n color: ${({ theme }) => theme.colors.primaryDark};\n transform: scale(1.05);\n }\n\n &:active {\n transform: scale(0.95);\n }\n`;\n\ntype Answer = {\n text: string;\n answer?: boolean;\n mdx?: MDXRemoteSerializeResult;\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}\n\nfunction RainbowInput({\n id,\n value,\n onChange,\n placeholder,\n autoComplete,\n \"aria-label\": ariaLabel,\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 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 Chat() {\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 endRef = useRef<HTMLDivElement | null>(null);\n const abortRef = useRef<AbortController | null>(null);\n const { isOpen, setIsOpen } = useContext(ChatContext);\n\n useEffect(() => {\n endRef.current?.scrollIntoView({ behavior: \"smooth\", block: \"end\" });\n }, [answer]);\n\n useEffect(() => {\n if (answer?.length > 0) {\n const el = document.getElementById(\n \"chat-bottom-input\",\n ) as HTMLInputElement | null;\n el?.focus();\n }\n }, [answer]);\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 res = await fetch(\"/api/rag\", {\n method: \"POST\",\n headers: { \"Content-Type\": \"application/json\" },\n body: JSON.stringify({ question: currentQuestion }),\n signal: controller.signal,\n });\n\n if (!res.ok) {\n const errorData = await res.json();\n throw new Error(errorData.error || \"Request failed\");\n }\n\n const reader = res.body?.getReader();\n const decoder = new TextDecoder();\n const contentParts: string[] = [];\n if (!reader) {\n throw new Error(\"Failed to get response reader\");\n }\n\n // Add a placeholder for the streaming answer\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 // Keep the last (potentially incomplete) part in the buffer\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 === \"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 };\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 // Finalize with MDX serialization\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 };\n return newAnswers;\n });\n }\n } catch (parseError) {\n if (parseError instanceof Error && parseError.message !== \"Unknown error\") {\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 return (\n <>\n <StyledChatFixedForm\n onSubmit={ask}\n $hide={answer?.length > 0}\n $hasLinks={links.length > 0}\n >\n <StyledChatFixedInner>\n <RainbowInput\n value={question}\n onChange={(e) => setQuestion(e.target.value)}\n placeholder=\"Ask AI Assistant...\"\n autoComplete=\"off\"\n aria-label=\"Ask a question about the documentation\"\n />\n <StyledRainbowButton\n type=\"submit\"\n disabled={loading}\n $hasContent={question.trim().length > 0}\n aria-label={loading ? \"Loading response\" : \"Submit question\"}\n >\n {loading ? <LoaderPinwheel className=\"loading\" /> : <ArrowUp />}\n </StyledRainbowButton>\n </StyledChatFixedInner>\n </StyledChatFixedForm>\n\n <StyledChat $isVisible={isOpen}>\n <StyledChatTitle>\n <StyledChatTitleIconWrapper>\n <Sparkles />\n <h3>AI Assistant</h3>\n </StyledChatTitleIconWrapper>\n <StyledChatCloseButton\n onClick={() => {\n abortRef.current?.abort();\n setAnswer([]);\n setIsOpen(false);\n }}\n aria-label=\"Close chat\"\n >\n <X />\n </StyledChatCloseButton>\n </StyledChatTitle>\n {answer &&\n answer.map((a, i) => (\n <StyledAnswer key={i} $isAnswer={a.answer ?? false}>\n {a.answer && a.mdx ? (\n <MDXRemote {...a.mdx} components={mdxComponents} />\n ) : (\n a.text\n )}\n </StyledAnswer>\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 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 isChatActive: boolean;\n}>({\n isOpen: false,\n setIsOpen: () => {},\n isChatActive: false,\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\n return (\n <ChatContext.Provider\n value={{\n isOpen,\n setIsOpen,\n isChatActive,\n }}\n >\n {children}\n </ChatContext.Provider>\n );\n};\n\nexport { Chat, ChtProvider, ChatContext };\n";
|
|
@@ -15,10 +15,12 @@ import rehypeHighlight from "rehype-highlight";
|
|
|
15
15
|
import { MDXRemote, MDXRemoteSerializeResult } from "next-mdx-remote";
|
|
16
16
|
import { serialize } from "next-mdx-remote/serialize";
|
|
17
17
|
import { mq, Theme } from "@/app/theme";
|
|
18
|
-
import { useMDXComponents } from "@/components/MDXComponents";
|
|
18
|
+
import { useMDXComponents as getMDXComponents } from "@/components/MDXComponents";
|
|
19
19
|
import { styledTable, stylesLists } from "@/components/layout/SharedStyled";
|
|
20
20
|
import links from "@/links.json";
|
|
21
21
|
|
|
22
|
+
const mdxComponents = getMDXComponents({});
|
|
23
|
+
|
|
22
24
|
const styledText = css<{ theme: Theme }>\`
|
|
23
25
|
font-size: \${({ theme }) => theme.fontSizes.text.xs};
|
|
24
26
|
line-height: \${({ theme }) => theme.lineHeights.text.xs};
|
|
@@ -39,7 +41,6 @@ const StyledChat = styled.div<{ theme: Theme; $isVisible: boolean }>\`
|
|
|
39
41
|
overflow-y: scroll;
|
|
40
42
|
overflow-x: hidden;
|
|
41
43
|
z-index: 1000;
|
|
42
|
-
width: 100%;
|
|
43
44
|
padding: 0 20px;
|
|
44
45
|
transition: all 0.3s ease;
|
|
45
46
|
transform: translateX(0);
|
|
@@ -490,7 +491,7 @@ const StyledAnswer = styled.div<{ theme: Theme; $isAnswer: boolean }>\`
|
|
|
490
491
|
background: \${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};
|
|
491
492
|
color: \${({ theme }) => theme.colors.dark};
|
|
492
493
|
padding: 2px 4px;
|
|
493
|
-
border-radius: \${({ theme }) => theme.spacing.radius.xs};
|
|
494
|
+
border-radius: \${({ theme }) => theme.spacing.radius.xs};
|
|
494
495
|
white-space: pre;
|
|
495
496
|
}
|
|
496
497
|
|
|
@@ -632,6 +633,7 @@ interface RainbowInputProps {
|
|
|
632
633
|
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
|
633
634
|
placeholder?: string;
|
|
634
635
|
autoComplete?: string;
|
|
636
|
+
"aria-label"?: string;
|
|
635
637
|
}
|
|
636
638
|
|
|
637
639
|
function RainbowInput({
|
|
@@ -640,6 +642,7 @@ function RainbowInput({
|
|
|
640
642
|
onChange,
|
|
641
643
|
placeholder,
|
|
642
644
|
autoComplete,
|
|
645
|
+
"aria-label": ariaLabel,
|
|
643
646
|
}: RainbowInputProps) {
|
|
644
647
|
const [isFocused, setIsFocused] = useState(false);
|
|
645
648
|
const [isHovered, setIsHovered] = useState(false);
|
|
@@ -675,6 +678,7 @@ function RainbowInput({
|
|
|
675
678
|
onChange={onChange}
|
|
676
679
|
placeholder={placeholder}
|
|
677
680
|
autoComplete={autoComplete}
|
|
681
|
+
aria-label={ariaLabel}
|
|
678
682
|
onFocus={() => setIsFocused(true)}
|
|
679
683
|
onBlur={() => setIsFocused(false)}
|
|
680
684
|
/>
|
|
@@ -688,7 +692,7 @@ function Chat() {
|
|
|
688
692
|
const [error, setError] = useState<string | null>(null);
|
|
689
693
|
const [answer, setAnswer] = useState<Answer[]>([]);
|
|
690
694
|
const endRef = useRef<HTMLDivElement | null>(null);
|
|
691
|
-
const
|
|
695
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
692
696
|
const { isOpen, setIsOpen } = useContext(ChatContext);
|
|
693
697
|
|
|
694
698
|
useEffect(() => {
|
|
@@ -720,11 +724,15 @@ function Chat() {
|
|
|
720
724
|
|
|
721
725
|
setAnswer(mergedQuestions);
|
|
722
726
|
|
|
727
|
+
const controller = new AbortController();
|
|
728
|
+
abortRef.current = controller;
|
|
729
|
+
|
|
723
730
|
try {
|
|
724
731
|
const res = await fetch("/api/rag", {
|
|
725
732
|
method: "POST",
|
|
726
733
|
headers: { "Content-Type": "application/json" },
|
|
727
734
|
body: JSON.stringify({ question: currentQuestion }),
|
|
735
|
+
signal: controller.signal,
|
|
728
736
|
});
|
|
729
737
|
|
|
730
738
|
if (!res.ok) {
|
|
@@ -734,7 +742,7 @@ function Chat() {
|
|
|
734
742
|
|
|
735
743
|
const reader = res.body?.getReader();
|
|
736
744
|
const decoder = new TextDecoder();
|
|
737
|
-
|
|
745
|
+
const contentParts: string[] = [];
|
|
738
746
|
if (!reader) {
|
|
739
747
|
throw new Error("Failed to get response reader");
|
|
740
748
|
}
|
|
@@ -743,20 +751,24 @@ function Chat() {
|
|
|
743
751
|
const streamingAnswerIndex = mergedQuestions.length;
|
|
744
752
|
setAnswer([...mergedQuestions, { text: "", answer: true }]);
|
|
745
753
|
|
|
754
|
+
let buffer = "";
|
|
746
755
|
while (true) {
|
|
747
756
|
const { done, value } = await reader.read();
|
|
748
757
|
if (done) break;
|
|
749
758
|
|
|
750
|
-
|
|
751
|
-
const
|
|
759
|
+
buffer += decoder.decode(value, { stream: true });
|
|
760
|
+
const parts = buffer.split("\\n");
|
|
761
|
+
// Keep the last (potentially incomplete) part in the buffer
|
|
762
|
+
buffer = parts.pop() ?? "";
|
|
752
763
|
|
|
753
|
-
for (const line of
|
|
764
|
+
for (const line of parts) {
|
|
754
765
|
if (line.startsWith("data: ")) {
|
|
755
766
|
try {
|
|
756
767
|
const data = JSON.parse(line.slice(6));
|
|
757
768
|
|
|
758
769
|
if (data.type === "content") {
|
|
759
|
-
|
|
770
|
+
contentParts.push(data.data);
|
|
771
|
+
const streamedContent = contentParts.join("");
|
|
760
772
|
|
|
761
773
|
setAnswer((prev) => {
|
|
762
774
|
const newAnswers = [...prev];
|
|
@@ -769,6 +781,7 @@ function Chat() {
|
|
|
769
781
|
} else if (data.type === "error") {
|
|
770
782
|
throw new Error(data.data);
|
|
771
783
|
} else if (data.type === "done") {
|
|
784
|
+
const streamedContent = contentParts.join("");
|
|
772
785
|
// Finalize with MDX serialization
|
|
773
786
|
let mdxSource: MDXRemoteSerializeResult | null = null;
|
|
774
787
|
try {
|
|
@@ -796,14 +809,18 @@ function Chat() {
|
|
|
796
809
|
});
|
|
797
810
|
}
|
|
798
811
|
} catch (parseError) {
|
|
799
|
-
|
|
812
|
+
if (parseError instanceof Error && parseError.message !== "Unknown error") {
|
|
813
|
+
console.error("Failed to parse SSE data:", parseError);
|
|
814
|
+
}
|
|
800
815
|
}
|
|
801
816
|
}
|
|
802
817
|
}
|
|
803
818
|
}
|
|
804
819
|
} catch (err: unknown) {
|
|
820
|
+
if (err instanceof DOMException && err.name === "AbortError") return;
|
|
805
821
|
setError(err instanceof Error ? err.message : "Unknown error");
|
|
806
822
|
} finally {
|
|
823
|
+
abortRef.current = null;
|
|
807
824
|
setLoading(false);
|
|
808
825
|
}
|
|
809
826
|
}
|
|
@@ -821,11 +838,13 @@ function Chat() {
|
|
|
821
838
|
onChange={(e) => setQuestion(e.target.value)}
|
|
822
839
|
placeholder="Ask AI Assistant..."
|
|
823
840
|
autoComplete="off"
|
|
841
|
+
aria-label="Ask a question about the documentation"
|
|
824
842
|
/>
|
|
825
843
|
<StyledRainbowButton
|
|
826
844
|
type="submit"
|
|
827
845
|
disabled={loading}
|
|
828
846
|
$hasContent={question.trim().length > 0}
|
|
847
|
+
aria-label={loading ? "Loading response" : "Submit question"}
|
|
829
848
|
>
|
|
830
849
|
{loading ? <LoaderPinwheel className="loading" /> : <ArrowUp />}
|
|
831
850
|
</StyledRainbowButton>
|
|
@@ -840,9 +859,11 @@ function Chat() {
|
|
|
840
859
|
</StyledChatTitleIconWrapper>
|
|
841
860
|
<StyledChatCloseButton
|
|
842
861
|
onClick={() => {
|
|
862
|
+
abortRef.current?.abort();
|
|
843
863
|
setAnswer([]);
|
|
844
864
|
setIsOpen(false);
|
|
845
865
|
}}
|
|
866
|
+
aria-label="Close chat"
|
|
846
867
|
>
|
|
847
868
|
<X />
|
|
848
869
|
</StyledChatCloseButton>
|
|
@@ -879,11 +900,13 @@ function Chat() {
|
|
|
879
900
|
onChange={(e) => setQuestion(e.target.value)}
|
|
880
901
|
placeholder="Ask AI Assistant..."
|
|
881
902
|
autoComplete="off"
|
|
903
|
+
aria-label="Ask a follow-up question"
|
|
882
904
|
/>
|
|
883
905
|
<StyledRainbowButton
|
|
884
906
|
type="submit"
|
|
885
907
|
disabled={loading || question.trim() === ""}
|
|
886
908
|
$hasContent={question.trim().length > 0}
|
|
909
|
+
aria-label={loading ? "Loading response" : "Submit question"}
|
|
887
910
|
>
|
|
888
911
|
{loading ? <LoaderPinwheel className="loading" /> : <ArrowUp />}
|
|
889
912
|
</StyledRainbowButton>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const clickOutsideTemplate = "import { RefObject, useEffect } from \"react\";\n\nexport function useOnClickOutside(\n refs: RefObject<HTMLElement | null>[],\n cb: () => void,\n) {\n
|
|
1
|
+
export declare const clickOutsideTemplate = "import { RefObject, useCallback, useEffect } from \"react\";\n\nexport function useOnClickOutside(\n refs: RefObject<HTMLElement | null>[],\n cb: () => void,\n) {\n // Stable callback ref to avoid re-subscribing on every render\n const handleClickOutside = useCallback(\n (event: MouseEvent) => {\n if (\n refs.every(\n (ref) => !ref.current || !ref.current.contains(event.target as Node),\n )\n ) {\n cb();\n }\n },\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [...refs, cb],\n );\n\n useEffect(() => {\n document.addEventListener(\"mousedown\", handleClickOutside);\n return () => {\n document.removeEventListener(\"mousedown\", handleClickOutside);\n };\n }, [handleClickOutside]);\n}\n";
|
|
@@ -1,27 +1,29 @@
|
|
|
1
|
-
export const clickOutsideTemplate = `import { RefObject, useEffect } from "react";
|
|
1
|
+
export const clickOutsideTemplate = `import { RefObject, useCallback, useEffect } from "react";
|
|
2
2
|
|
|
3
3
|
export function useOnClickOutside(
|
|
4
4
|
refs: RefObject<HTMLElement | null>[],
|
|
5
5
|
cb: () => void,
|
|
6
6
|
) {
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
// Stable callback ref to avoid re-subscribing on every render
|
|
8
|
+
const handleClickOutside = useCallback(
|
|
9
|
+
(event: MouseEvent) => {
|
|
9
10
|
if (
|
|
10
|
-
refs
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
(ref) =>
|
|
14
|
-
ref && ref.current && ref.current.contains(event.target as Node),
|
|
15
|
-
)
|
|
16
|
-
.every((i) => i === false)
|
|
11
|
+
refs.every(
|
|
12
|
+
(ref) => !ref.current || !ref.current.contains(event.target as Node),
|
|
13
|
+
)
|
|
17
14
|
) {
|
|
18
15
|
cb();
|
|
19
16
|
}
|
|
20
|
-
}
|
|
17
|
+
},
|
|
18
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
19
|
+
[...refs, cb],
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
21
23
|
document.addEventListener("mousedown", handleClickOutside);
|
|
22
24
|
return () => {
|
|
23
25
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
24
26
|
};
|
|
25
|
-
}, [
|
|
27
|
+
}, [handleClickOutside]);
|
|
26
28
|
}
|
|
27
29
|
`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const docsSideBarTemplate = "\"use client\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { Space } from \"cherry-styled-components\";\nimport {\n StyledIndexSidebar,\n StyledIndexSidebarLink,\n StyledIndexSidebarLabel,\n} from \"@/components/layout/DocsComponents\";\n\nexport interface Heading {\n id: string;\n text: string;\n level: number;\n}\n\nexport function DocsSideBar({ headings }: { headings: Heading[] }) {\n const [activeId, setActiveId] = useState<string>(\"\");\n\n const getScrollOffset = useCallback(() => {\n return document.getElementById(\"static-links\") ? 90 : 18;\n }, []);\n\n const handleScroll = useCallback(() => {\n if (headings.length === 0) return;\n\n const offset = getScrollOffset();\n\n const headingElements = headings\n .map((heading) => document.getElementById(heading.id))\n .filter(
|
|
1
|
+
export declare const docsSideBarTemplate = "\"use client\";\nimport { useCallback, useEffect, useState } from \"react\";\nimport { Space } from \"cherry-styled-components\";\nimport {\n StyledIndexSidebar,\n StyledIndexSidebarLink,\n StyledIndexSidebarLabel,\n} from \"@/components/layout/DocsComponents\";\n\nexport interface Heading {\n id: string;\n text: string;\n level: number;\n}\n\nexport function DocsSideBar({ headings }: { headings: Heading[] }) {\n const [activeId, setActiveId] = useState<string>(\"\");\n\n const getScrollOffset = useCallback(() => {\n return document.getElementById(\"static-links\") ? 90 : 18;\n }, []);\n\n const handleScroll = useCallback(() => {\n if (headings.length === 0) return;\n\n const offset = getScrollOffset();\n\n const headingElements = headings\n .map((heading) => document.getElementById(heading.id))\n .filter((el): el is HTMLElement => el !== null);\n\n if (headingElements.length === 0) return;\n\n const windowHeight = window.innerHeight;\n\n const visibleHeadings = headingElements.filter((element) => {\n const rect = element.getBoundingClientRect();\n const elementTop = rect.top;\n const elementBottom = rect.bottom;\n return elementTop < windowHeight && elementBottom > -50;\n });\n\n if (visibleHeadings.length > 0) {\n let closestHeading = visibleHeadings[0];\n let closestDistance = Math.abs(\n closestHeading.getBoundingClientRect().top - offset,\n );\n for (const heading of visibleHeadings) {\n const distance = Math.abs(heading.getBoundingClientRect().top - offset);\n if (\n distance < closestDistance &&\n heading.getBoundingClientRect().top <= windowHeight * 0.3\n ) {\n closestDistance = distance;\n closestHeading = heading;\n }\n }\n setActiveId(closestHeading.id);\n return;\n }\n\n let currentActiveId = headings[0].id;\n for (const element of headingElements) {\n const rect = element.getBoundingClientRect();\n if (rect.top <= offset) {\n currentActiveId = element.id;\n } else {\n break;\n }\n }\n setActiveId(currentActiveId);\n }, [headings, getScrollOffset]);\n\n useEffect(() => {\n if (headings.length === 0) return;\n // Run initial scroll check on next frame to avoid synchronous setState in effect\n const rafId = requestAnimationFrame(handleScroll);\n let timeoutId: NodeJS.Timeout;\n const throttledHandleScroll = () => {\n clearTimeout(timeoutId);\n timeoutId = setTimeout(handleScroll, 50);\n };\n window.addEventListener(\"scroll\", throttledHandleScroll);\n window.addEventListener(\"resize\", handleScroll);\n return () => {\n window.removeEventListener(\"scroll\", throttledHandleScroll);\n window.removeEventListener(\"resize\", handleScroll);\n cancelAnimationFrame(rafId);\n clearTimeout(timeoutId);\n };\n }, [handleScroll, headings]);\n\n const handleHeadingClick = (headingId: string) => {\n const element = document.getElementById(headingId);\n if (element) {\n const offset = getScrollOffset();\n const elementPosition =\n element.getBoundingClientRect().top + window.scrollY;\n window.scrollTo({ top: elementPosition - offset, behavior: \"smooth\" });\n }\n };\n\n return (\n <StyledIndexSidebar>\n {headings?.length > 0 && (\n <>\n <StyledIndexSidebarLabel>On this page</StyledIndexSidebarLabel>\n <Space $size={20} />\n </>\n )}\n {headings.map((heading, index) => (\n <li\n key={index}\n style={{ paddingLeft: `${(heading.level - 1) * 16}px` }}\n >\n <StyledIndexSidebarLink\n href={`#${heading.id}`}\n onClick={(e) => {\n e.preventDefault();\n handleHeadingClick(heading.id);\n }}\n $isActive={activeId === heading.id}\n >\n {heading.text}\n </StyledIndexSidebarLink>\n </li>\n ))}\n </StyledIndexSidebar>\n );\n}\n";
|
|
@@ -27,7 +27,7 @@ export function DocsSideBar({ headings }: { headings: Heading[] }) {
|
|
|
27
27
|
|
|
28
28
|
const headingElements = headings
|
|
29
29
|
.map((heading) => document.getElementById(heading.id))
|
|
30
|
-
.filter(
|
|
30
|
+
.filter((el): el is HTMLElement => el !== null);
|
|
31
31
|
|
|
32
32
|
if (headingElements.length === 0) return;
|
|
33
33
|
|