doccupine 0.0.52 → 0.0.53
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/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/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 +30 -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 codeTemplate = "\"use client\";\nimport { useState, useCallback } from \"react\";\nimport styled from \"styled-components\";\nimport { Theme, styledCode } from \"cherry-styled-components\";\nimport { rgba } from \"polished\";\nimport { unified } from \"unified\";\nimport rehypeParse from \"rehype-parse\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport rehypeStringify from \"rehype-stringify\";\nimport { editableContent } from \"@/components/layout/SharedStyled\";\nimport { Icon } from \"@/components/layout/Icon\";\n\ninterface CodeProps extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n \"theme\"\n> {\n code: string;\n language?: string;\n theme?: Theme;\n}\n\nconst CodeWrapper = styled.span<{ theme: Theme }>`\n position: relative;\n z-index: 2;\n display: block;\n width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px\n ${({ theme }) =>\n theme.isDark\n ? rgba(theme.colors.dark, 0.2)\n : rgba(theme.colors.dark, 0.1)};\n`;\n\nconst TopBar = styled.div<{ theme: Theme }>`\n background: ${({ theme }) => (theme.isDark ? \"#0d1117\" : \"#f6f8fa\")};\n border-top-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-top-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom: solid 1px\n ${({ theme }) =>\n theme.isDark ? rgba(\"#ffffff\", 0.1) : rgba(\"#000000\", 0.1)};\n height: 33px;\n width: 100%;\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 5px;\n padding: 0 10px;\n`;\n\nconst DotsContainer = styled.div`\n display: flex;\n gap: 5px;\n`;\n\nconst Dot = styled.span<{ theme: Theme }>`\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: ${({ theme }) =>\n theme.isDark ? rgba(\"#ffffff\", 0.1) : rgba(\"#000000\", 0.1)};\n`;\n\nconst CopyButton = styled.button<{ theme: Theme; $copied: boolean }>`\n background: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? rgba(\"#7ee787\", 0.2)\n : rgba(\"#2da44e\", 0.1)\n : \"transparent\"};\n border: solid 1px\n ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? \"#7ee787\"\n : \"#2da44e\"\n : theme.isDark\n ? rgba(\"#ffffff\", 0.1)\n : rgba(\"#000000\", 0.1)};\n color: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? \"#7ee787\"\n : \"#2da44e\"\n : theme.isDark\n ? \"#c9d1d9\"\n : \"#57606a\"};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 4px 8px;\n font-size: 12px;\n font-family: ${({ theme }) => theme.fonts.mono};\n cursor: pointer;\n transition: all 0.2s ease;\n display: flex;\n align-items: center;\n gap: 4px;\n margin-right: -6px;\n\n & svg.lucide {\n color: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? \"#7ee787\"\n : \"#2da44e\"\n : theme.isDark\n ? \"#c9d1d9\"\n : \"#57606a\"};\n }\n\n &:hover {\n background: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? rgba(\"#7ee787\", 0.3)\n : rgba(\"#2da44e\", 0.2)\n : theme.isDark\n ? rgba(\"#ffffff\", 0.1)\n : rgba(\"#000000\", 0.05)};\n border-color: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? \"#7ee787\"\n : \"#2da44e\"\n : theme.isDark\n ? rgba(\"#ffffff\", 0.2)\n : rgba(\"#000000\", 0.2)};\n }\n\n &:active {\n transform: scale(0.95);\n }\n`;\n\nconst Body = styled.div<{ theme: Theme }>`\n background: ${({ theme }) => (theme.isDark ? \"#0d1117\" : \"#ffffff\")};\n border-bottom-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n color: ${({ theme }) => (theme.isDark ? \"#ffffff\" : \"#24292f\")};\n padding: 20px;\n font-family: ${({ theme }) => theme.fonts.mono};\n text-align: left;\n overflow-x: auto;\n overflow-y: auto;\n max-height: calc(100svh - 400px);\n ${({ theme }) => styledCode(theme)};\n\n &[contenteditable=\"true\"] {\n ${editableContent};\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n }\n\n /* Dark mode syntax highlighting (GitHub Dark) */\n ${({ theme }) =>\n theme.isDark &&\n `\n & .hljs {\n color: #c9d1d9;\n background: #0d1117;\n }\n\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #ff7b72;\n }\n\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #d2a8ff;\n }\n\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #79c0ff;\n }\n\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #a5d6ff;\n }\n\n & .hljs-built_in,\n & .hljs-symbol {\n color: #ffa657;\n }\n\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #8b949e;\n }\n\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #7ee787;\n }\n\n & .hljs-subst {\n color: #c9d1d9;\n }\n\n & .hljs-section {\n color: #1f6feb;\n font-weight: 700;\n }\n\n & .hljs-bullet {\n color: #f2cc60;\n }\n\n & .hljs-emphasis {\n color: #c9d1d9;\n font-style: italic;\n }\n\n & .hljs-strong {\n color: #c9d1d9;\n font-weight: 700;\n }\n\n & .hljs-addition {\n color: #aff5b4;\n background-color: #033a16;\n }\n\n & .hljs-deletion {\n color: #ffdcd7;\n background-color: #67060c;\n }\n `}\n\n /* Light mode syntax highlighting (GitHub Light) */\n ${({ theme }) =>\n !theme.isDark &&\n `\n & .hljs {\n color: #24292f;\n background: #ffffff;\n }\n\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #cf222e;\n }\n\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #8250df;\n }\n\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #0550ae;\n }\n\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #0a3069;\n }\n\n & .hljs-built_in,\n & .hljs-symbol {\n color: #953800;\n }\n\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #6e7781;\n }\n\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #116329;\n }\n\n & .hljs-subst {\n color: #24292f;\n }\n\n & .hljs-section {\n color: #0550ae;\n font-weight: 700;\n }\n\n & .hljs-bullet {\n color: #953800;\n }\n\n & .hljs-emphasis {\n color: #24292f;\n font-style: italic;\n }\n\n & .hljs-strong {\n color: #24292f;\n font-weight: 700;\n }\n\n & .hljs-addition {\n color: #116329;\n background-color: #dafbe1;\n }\n\n & .hljs-deletion {\n color: #82071e;\n background-color: #ffebe9;\n }\n `}\n`;\n\nconst escapeHtml = (unsafe: string): string => {\n return unsafe\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n};\n\nconst highlightCode = (code: string, language: string): string => {\n const escapedCode = escapeHtml(code);\n const result = unified()\n .use(rehypeParse, { fragment: true })\n .use(rehypeHighlight, {\n detect: true,\n ignoreMissing: true,\n })\n .use(rehypeStringify)\n .processSync(\n `<pre><code class=\"language-${language}\">${escapedCode}</code></pre>`,\n );\n\n return String(result);\n};\n\nfunction Code({ code, language = \"javascript\", theme, className }: CodeProps) {\n const [copied, setCopied] = useState(false);\n const highlightedCode = highlightCode(code, language);\n\n const handleCopy = useCallback(async () => {\n try {\n await navigator.clipboard.writeText(code);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy code:\", err);\n }\n }, [code]);\n\n return (\n <CodeWrapper\n className={className + `${className && \" \"}code-wrapper`}\n theme={theme}\n >\n <TopBar theme={theme}>\n <DotsContainer>\n <Dot theme={theme} />\n <Dot theme={theme} />\n <Dot theme={theme} />\n </DotsContainer>\n <CopyButton onClick={handleCopy} $copied={copied} theme={theme}>\n {copied ? (\n <>\n <Icon name=\"check\" size={12} />\n <span>Copied!</span>\n </>\n ) : (\n <>\n <Icon name=\"copy\" size={12} />\n <span>Copy</span>\n </>\n )}\n </CopyButton>\n </TopBar>\n <Body\n dangerouslySetInnerHTML={{ __html: highlightedCode }}\n theme={theme}\n className=\"code-wrapper-body\"\n />\n </CodeWrapper>\n );\n}\n\nexport { Code };\n";
|
|
1
|
+
export declare const codeTemplate = "\"use client\";\nimport { useState, useCallback, useMemo } from \"react\";\nimport styled from \"styled-components\";\nimport { Theme, styledCode } from \"cherry-styled-components\";\nimport { rgba } from \"polished\";\nimport { unified } from \"unified\";\nimport rehypeParse from \"rehype-parse\";\nimport rehypeHighlight from \"rehype-highlight\";\nimport rehypeStringify from \"rehype-stringify\";\nimport { editableContent } from \"@/components/layout/SharedStyled\";\nimport { Icon } from \"@/components/layout/Icon\";\n\ninterface CodeProps extends Omit<\n React.HTMLAttributes<HTMLDivElement>,\n \"theme\"\n> {\n code: string;\n language?: string;\n theme?: Theme;\n}\n\nconst CodeWrapper = styled.span<{ theme: Theme }>`\n position: relative;\n z-index: 2;\n display: block;\n width: 100%;\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px\n ${({ theme }) =>\n theme.isDark\n ? rgba(theme.colors.dark, 0.2)\n : rgba(theme.colors.dark, 0.1)};\n`;\n\nconst TopBar = styled.div<{ theme: Theme }>`\n background: ${({ theme }) => (theme.isDark ? \"#0d1117\" : \"#f6f8fa\")};\n border-top-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-top-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom: solid 1px\n ${({ theme }) =>\n theme.isDark ? rgba(\"#ffffff\", 0.1) : rgba(\"#000000\", 0.1)};\n height: 33px;\n width: 100%;\n display: flex;\n justify-content: space-between;\n align-items: center;\n gap: 5px;\n padding: 0 10px;\n`;\n\nconst DotsContainer = styled.div`\n display: flex;\n gap: 5px;\n`;\n\nconst Dot = styled.span<{ theme: Theme }>`\n width: 10px;\n height: 10px;\n border-radius: 50%;\n background: ${({ theme }) =>\n theme.isDark ? rgba(\"#ffffff\", 0.1) : rgba(\"#000000\", 0.1)};\n`;\n\nconst CopyButton = styled.button<{ theme: Theme; $copied: boolean }>`\n background: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? rgba(\"#7ee787\", 0.2)\n : rgba(\"#2da44e\", 0.1)\n : \"transparent\"};\n border: solid 1px\n ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? \"#7ee787\"\n : \"#2da44e\"\n : theme.isDark\n ? rgba(\"#ffffff\", 0.1)\n : rgba(\"#000000\", 0.1)};\n color: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? \"#7ee787\"\n : \"#2da44e\"\n : theme.isDark\n ? \"#c9d1d9\"\n : \"#57606a\"};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n padding: 4px 8px;\n font-size: 12px;\n font-family: ${({ theme }) => theme.fonts.mono};\n cursor: pointer;\n transition: all 0.2s ease;\n display: flex;\n align-items: center;\n gap: 4px;\n margin-right: -6px;\n\n & svg.lucide {\n color: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? \"#7ee787\"\n : \"#2da44e\"\n : theme.isDark\n ? \"#c9d1d9\"\n : \"#57606a\"};\n }\n\n &:hover {\n background: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? rgba(\"#7ee787\", 0.3)\n : rgba(\"#2da44e\", 0.2)\n : theme.isDark\n ? rgba(\"#ffffff\", 0.1)\n : rgba(\"#000000\", 0.05)};\n border-color: ${({ theme, $copied }) =>\n $copied\n ? theme.isDark\n ? \"#7ee787\"\n : \"#2da44e\"\n : theme.isDark\n ? rgba(\"#ffffff\", 0.2)\n : rgba(\"#000000\", 0.2)};\n }\n\n &:active {\n transform: scale(0.95);\n }\n`;\n\nconst Body = styled.div<{ theme: Theme }>`\n background: ${({ theme }) => (theme.isDark ? \"#0d1117\" : \"#ffffff\")};\n border-bottom-left-radius: ${({ theme }) => theme.spacing.radius.lg};\n border-bottom-right-radius: ${({ theme }) => theme.spacing.radius.lg};\n color: ${({ theme }) => (theme.isDark ? \"#ffffff\" : \"#24292f\")};\n padding: 20px;\n font-family: ${({ theme }) => theme.fonts.mono};\n text-align: left;\n overflow-x: auto;\n overflow-y: auto;\n max-height: calc(100svh - 400px);\n ${({ theme }) => styledCode(theme)};\n\n &[contenteditable=\"true\"] {\n ${editableContent};\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n }\n\n /* Dark mode syntax highlighting (GitHub Dark) */\n ${({ theme }) =>\n theme.isDark &&\n `\n & .hljs {\n color: #c9d1d9;\n background: #0d1117;\n }\n\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #ff7b72;\n }\n\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #d2a8ff;\n }\n\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #79c0ff;\n }\n\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #a5d6ff;\n }\n\n & .hljs-built_in,\n & .hljs-symbol {\n color: #ffa657;\n }\n\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #8b949e;\n }\n\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #7ee787;\n }\n\n & .hljs-subst {\n color: #c9d1d9;\n }\n\n & .hljs-section {\n color: #1f6feb;\n font-weight: 700;\n }\n\n & .hljs-bullet {\n color: #f2cc60;\n }\n\n & .hljs-emphasis {\n color: #c9d1d9;\n font-style: italic;\n }\n\n & .hljs-strong {\n color: #c9d1d9;\n font-weight: 700;\n }\n\n & .hljs-addition {\n color: #aff5b4;\n background-color: #033a16;\n }\n\n & .hljs-deletion {\n color: #ffdcd7;\n background-color: #67060c;\n }\n `}\n\n /* Light mode syntax highlighting (GitHub Light) */\n ${({ theme }) =>\n !theme.isDark &&\n `\n & .hljs {\n color: #24292f;\n background: #ffffff;\n }\n\n & .hljs-doctag,\n & .hljs-keyword,\n & .hljs-meta .hljs-keyword,\n & .hljs-template-tag,\n & .hljs-template-variable,\n & .hljs-type,\n & .hljs-variable.language_ {\n color: #cf222e;\n }\n\n & .hljs-title,\n & .hljs-title.class_,\n & .hljs-title.class_.inherited__,\n & .hljs-title.function_ {\n color: #8250df;\n }\n\n & .hljs-attr,\n & .hljs-attribute,\n & .hljs-literal,\n & .hljs-meta,\n & .hljs-number,\n & .hljs-operator,\n & .hljs-selector-attr,\n & .hljs-selector-class,\n & .hljs-selector-id,\n & .hljs-variable {\n color: #0550ae;\n }\n\n & .hljs-meta .hljs-string,\n & .hljs-regexp,\n & .hljs-string {\n color: #0a3069;\n }\n\n & .hljs-built_in,\n & .hljs-symbol {\n color: #953800;\n }\n\n & .hljs-code,\n & .hljs-comment,\n & .hljs-formula {\n color: #6e7781;\n }\n\n & .hljs-name,\n & .hljs-quote,\n & .hljs-selector-pseudo,\n & .hljs-selector-tag {\n color: #116329;\n }\n\n & .hljs-subst {\n color: #24292f;\n }\n\n & .hljs-section {\n color: #0550ae;\n font-weight: 700;\n }\n\n & .hljs-bullet {\n color: #953800;\n }\n\n & .hljs-emphasis {\n color: #24292f;\n font-style: italic;\n }\n\n & .hljs-strong {\n color: #24292f;\n font-weight: 700;\n }\n\n & .hljs-addition {\n color: #116329;\n background-color: #dafbe1;\n }\n\n & .hljs-deletion {\n color: #82071e;\n background-color: #ffebe9;\n }\n `}\n`;\n\nconst escapeHtml = (unsafe: string): string => {\n return unsafe\n .replace(/&/g, \"&\")\n .replace(/</g, \"<\")\n .replace(/>/g, \">\")\n .replace(/\"/g, \""\")\n .replace(/'/g, \"'\");\n};\n\nconst sanitizeLanguage = (lang: string): string =>\n lang.replace(/[^a-zA-Z0-9_-]/g, \"\");\n\nconst highlightCode = (code: string, language: string): string => {\n const escapedCode = escapeHtml(code);\n const safeLang = sanitizeLanguage(language);\n const result = unified()\n .use(rehypeParse, { fragment: true })\n .use(rehypeHighlight, {\n detect: true,\n ignoreMissing: true,\n })\n .use(rehypeStringify)\n .processSync(\n `<pre><code class=\"language-${safeLang}\">${escapedCode}</code></pre>`,\n );\n\n return String(result);\n};\n\nfunction Code({ code, language = \"javascript\", theme, className }: CodeProps) {\n const [copied, setCopied] = useState(false);\n const highlightedCode = useMemo(\n () => highlightCode(code, language),\n [code, language],\n );\n\n const handleCopy = useCallback(async () => {\n try {\n await navigator.clipboard.writeText(code);\n setCopied(true);\n setTimeout(() => setCopied(false), 2000);\n } catch (err) {\n console.error(\"Failed to copy code:\", err);\n }\n }, [code]);\n\n return (\n <CodeWrapper\n className={`${className ?? \"\"} code-wrapper`.trim()}\n theme={theme}\n >\n <TopBar theme={theme}>\n <DotsContainer>\n <Dot theme={theme} />\n <Dot theme={theme} />\n <Dot theme={theme} />\n </DotsContainer>\n <CopyButton onClick={handleCopy} $copied={copied} theme={theme}>\n {copied ? (\n <>\n <Icon name=\"check\" size={12} />\n <span>Copied!</span>\n </>\n ) : (\n <>\n <Icon name=\"copy\" size={12} />\n <span>Copy</span>\n </>\n )}\n </CopyButton>\n </TopBar>\n <Body\n dangerouslySetInnerHTML={{ __html: highlightedCode }}\n theme={theme}\n className=\"code-wrapper-body\"\n />\n </CodeWrapper>\n );\n}\n\nexport { Code };\n";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export const codeTemplate = `"use client";
|
|
2
|
-
import { useState, useCallback } from "react";
|
|
2
|
+
import { useState, useCallback, useMemo } from "react";
|
|
3
3
|
import styled from "styled-components";
|
|
4
4
|
import { Theme, styledCode } from "cherry-styled-components";
|
|
5
5
|
import { rgba } from "polished";
|
|
@@ -354,8 +354,12 @@ const escapeHtml = (unsafe: string): string => {
|
|
|
354
354
|
.replace(/'/g, "'");
|
|
355
355
|
};
|
|
356
356
|
|
|
357
|
+
const sanitizeLanguage = (lang: string): string =>
|
|
358
|
+
lang.replace(/[^a-zA-Z0-9_-]/g, "");
|
|
359
|
+
|
|
357
360
|
const highlightCode = (code: string, language: string): string => {
|
|
358
361
|
const escapedCode = escapeHtml(code);
|
|
362
|
+
const safeLang = sanitizeLanguage(language);
|
|
359
363
|
const result = unified()
|
|
360
364
|
.use(rehypeParse, { fragment: true })
|
|
361
365
|
.use(rehypeHighlight, {
|
|
@@ -364,7 +368,7 @@ const highlightCode = (code: string, language: string): string => {
|
|
|
364
368
|
})
|
|
365
369
|
.use(rehypeStringify)
|
|
366
370
|
.processSync(
|
|
367
|
-
\`<pre><code class="language-\${
|
|
371
|
+
\`<pre><code class="language-\${safeLang}">\${escapedCode}</code></pre>\`,
|
|
368
372
|
);
|
|
369
373
|
|
|
370
374
|
return String(result);
|
|
@@ -372,7 +376,10 @@ const highlightCode = (code: string, language: string): string => {
|
|
|
372
376
|
|
|
373
377
|
function Code({ code, language = "javascript", theme, className }: CodeProps) {
|
|
374
378
|
const [copied, setCopied] = useState(false);
|
|
375
|
-
const highlightedCode =
|
|
379
|
+
const highlightedCode = useMemo(
|
|
380
|
+
() => highlightCode(code, language),
|
|
381
|
+
[code, language],
|
|
382
|
+
);
|
|
376
383
|
|
|
377
384
|
const handleCopy = useCallback(async () => {
|
|
378
385
|
try {
|
|
@@ -386,7 +393,7 @@ function Code({ code, language = "javascript", theme, className }: CodeProps) {
|
|
|
386
393
|
|
|
387
394
|
return (
|
|
388
395
|
<CodeWrapper
|
|
389
|
-
className={
|
|
396
|
+
className={\`\${className ?? ""} code-wrapper\`.trim()}
|
|
390
397
|
theme={theme}
|
|
391
398
|
>
|
|
392
399
|
<TopBar theme={theme}>
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const sharedStyledTemplate = "\"use client\";\nimport {\n mq,\n resetButton,\n styledH3,\n styledSmall,\n styledText,\n Theme,\n} from \"cherry-styled-components\";\nimport { rgba } from \"polished\";\nimport Link from \"next/link\";\nimport styled, { css } from \"styled-components\";\nimport { StyledH1 } from \"@/components/layout/Typography\";\n\nexport const interactiveStyles = css<{ theme: Theme }>`\n transition: all 0.3s ease;\n border: solid 1px transparent;\n box-shadow: 0 0 0 0px ${({ theme }) => theme.colors.primary};\n\n &:hover {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n\n &:focus {\n border-color: ${({ theme }) => theme.colors.primary};\n box-shadow: 0 0 0 4px ${({ theme }) => theme.colors.primaryLight};\n }\n\n &:active {\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n`;\n\nexport const StyledPriceHeader = styled.div<{ theme: Theme }>`\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 20px;\n display: flex;\n gap: 20px;\n\n & strong {\n margin: auto 0;\n color: ${({ theme }) => theme.colors.dark};\n }\n`;\n\nexport const StyledPrice = styled.span<{\n theme: Theme;\n $textAlign?: \"left\" | \"center\" | \"right\";\n}>`\n color: ${({ theme }) => theme.colors.dark};\n display: block;\n font-weight: 800;\n ${({ theme }) => styledH3(theme)}\n ${({ $textAlign }) =>\n $textAlign &&\n css`\n text-align: ${$textAlign};\n `}\n`;\n\nexport const StyledSmall = styled.small<{\n theme: Theme;\n $textAlign?: \"left\" | \"center\" | \"right\";\n}>`\n ${({ theme }) => styledSmall(theme)};\n margin: auto 0;\n display: block;\n color: ${({ theme }) => theme.colors.grayDark};\n ${({ $textAlign }) =>\n $textAlign &&\n css`\n text-align: ${$textAlign};\n `}\n`;\n\nexport const StyledMinHeight = styled.div`\n min-height: calc(100svh - 100px);\n\n ${mq(\"lg\")} {\n min-height: calc(100svh - 120px);\n }\n`;\n\nexport const StyledDataUserAvatar = styled.span<{ theme: Theme }>`\n display: inline-flex;\n justify-content: center;\n min-width: 50px;\n min-height: 50px;\n border-radius: 50%;\n border: solid 2px ${({ theme }) => theme.colors.grayLight};\n background: ${({ theme }) => theme.colors.light};\n position: relative;\n overflow: hidden;\n color: ${({ theme }) => theme.colors.primary};\n\n & img {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 100%;\n height: 100%;\n object-fit: cover;\n pointer-events: none;\n }\n\n & svg {\n pointer-events: none;\n margin: auto;\n transition: none;\n }\n`;\n\nexport const StyledDataArrowButton = styled.button<{\n theme: Theme;\n $isActive: boolean;\n}>`\n ${resetButton};\n display: flex;\n gap: 5px;\n min-width: fit-content;\n\n & .avatar,\n & .clickable {\n box-shadow: 0 0 0 0px ${({ theme }) => theme.colors.primary};\n transition: all 0.3s ease;\n }\n\n & svg {\n color: ${({ theme }) => theme.colors.primary};\n }\n\n &:hover {\n & .lucide-chevron-down {\n & path {\n stroke: ${({ theme }) => theme.colors.primaryDark};\n }\n }\n\n & .avatar,\n & .clickable {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n }\n\n &:focus {\n & .avatar,\n & .clickable {\n border-color: ${({ theme }) => theme.colors.primary};\n box-shadow: 0 0 0 4px ${({ theme }) => theme.colors.primaryLight};\n }\n }\n\n &:active {\n & .avatar,\n & .clickable {\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n }\n\n & svg {\n margin: auto 0;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n & .lucide-chevron-down {\n transform: rotate(180deg);\n }\n `}\n`;\n\nexport const StyledDataSpan = styled.span<{ theme: Theme }>`\n display: block;\n font-weight: 500;\n ${({ theme }) => styledText(theme)};\n color: ${({ theme }) => theme.colors.primary};\n font-family: ${({ theme }) => theme.fonts.text};\n`;\n\nexport const StyledDataDropdownHoverWrapper = styled.span<{\n theme: Theme;\n $align?: \"center\";\n}>`\n position: relative;\n display: block;\n padding: 10px 0;\n margin: -10px 0;\n\n &:hover {\n & ul {\n pointer-events: all;\n opacity: 1;\n transform: ${({ $align }) =>\n $align === \"center\"\n ? \"translateX(-50%) translateY(0)\"\n : \"translateY(0)\"};\n }\n\n & .lucide-chevron-down {\n transform: rotate(180deg);\n }\n }\n\n &:active {\n & ul {\n opacity: 0;\n }\n }\n`;\n\nexport const StyledDataDropdownWrapper = styled.span<{\n theme: Theme;\n $isAbsolute?: boolean;\n $marginAuto?: boolean;\n}>`\n position: relative;\n display: flex;\n padding: 10px 0;\n margin: -10px 0;\n\n & span {\n display: flex;\n margin: auto 0;\n }\n\n ${({ $isAbsolute }) =>\n $isAbsolute &&\n css`\n margin: 0;\n padding: 0;\n `}\n\n ${({ $marginAuto }) => $marginAuto && `margin: auto 0;`}\n`;\n\nexport const StyledDataDropdown = styled.ul<{\n theme: Theme;\n $isActive?: boolean;\n $align?: \"left\" | \"center\";\n $isTop?: boolean;\n $isAbsolute?: boolean;\n $minWidth?: string;\n}>`\n position: absolute;\n display: block;\n top: 100%;\n right: 0;\n list-style: none;\n transition: all 0.3s ease;\n min-width: ${({ $minWidth }) => ($minWidth ? $minWidth : \"200px\")};\n text-align: left;\n box-shadow: ${({ theme }) => theme.shadows.sm};\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n overflow: hidden;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: all 0.3s ease;\n opacity: 0;\n pointer-events: none;\n transform: translateY(-10px);\n z-index: 999;\n\n ${({ $isAbsolute }) =>\n $isAbsolute &&\n css`\n top: calc(100% + 56px);\n right: 20px;\n `}\n\n ${({ $isTop }) => $isTop && `top: initial; bottom: 100%;`}\n\n ${({ $align }) => $align === \"left\" && `right: initial; left: 0;`}\n ${({ $align }) =>\n $align === \"center\" &&\n `right: initial; left: 50%; transform: translateX(-50%) translateY(-10px);`}\n\n ${({ $isActive, $align }) =>\n $isActive &&\n css`\n pointer-events: all;\n opacity: 1;\n transform: translateY(0);\n\n ${$align === \"center\" &&\n `right: initial; left: 50%; transform: translateX(-50%) translateY(0);`}\n `}\n`;\n\nexport const StyledDataDropdownItem = styled.li<{ theme: Theme }>`\n display: flex;\n width: 100%;\n text-align: left;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n &:last-of-type,\n &:only-of-type {\n border-bottom: none;\n }\n\n & form {\n width: 100%;\n margin: 0;\n padding: 0;\n }\n\n & a,\n & button,\n & .logout-button {\n ${resetButton};\n width: 100%;\n text-align: left;\n padding: 12px 20px;\n font-weight: 600;\n font-family: inherit;\n color: ${({ theme }) => theme.colors.primary};\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n text-decoration: none;\n transition: all 0.3s ease;\n border-radius: 0;\n background: transparent;\n display: flex;\n gap: 10px;\n height: auto;\n min-height: 45px;\n\n &:hover {\n background-color: ${({ theme }) =>\n rgba(\n theme.isDark ? theme.colors.primaryDark : theme.colors.primaryLight,\n 0.1,\n )};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n }\n\n &:active,\n &:focus {\n box-shadow: none;\n }\n\n * {\n margin: auto 0;\n }\n\n &:focus {\n background-color: ${({ theme }) =>\n rgba(\n theme.isDark ? theme.colors.primaryDark : theme.colors.primaryLight,\n 0.15,\n )};\n }\n\n &:active {\n background-color: ${({ theme }) =>\n rgba(\n theme.isDark ? theme.colors.primaryDark : theme.colors.primaryLight,\n 0.2,\n )};\n }\n }\n\n & button {\n border: none;\n\n & svg {\n margin: -2px -5px;\n }\n }\n`;\n\nexport const StyledDataDropdownButton = styled.button<{ theme: Theme }>`\n ${resetButton};\n`;\n\nexport const StyledDataDropdownSelectWrapper = styled.span<{ theme: Theme }>`\n padding: 5px;\n display: block;\n width: 100%;\n\n & select {\n width: 100%;\n }\n`;\n\nexport const StyledDataTagButton = styled.button<{ theme: Theme }>`\n ${resetButton};\n position: absolute;\n top: 0;\n width: 24px;\n height: 100%;\n right: 0;\n border-left: solid 1px\n ${({ theme }) =>\n rgba(theme.isDark ? theme.colors.dark : theme.colors.light, 0.2)};\n transition: all 0.3s ease;\n\n &:hover {\n background: ${({ theme }) =>\n rgba(theme.isDark ? theme.colors.dark : theme.colors.light, 0.2)};\n }\n\n & svg {\n width: 16px;\n height: 100%;\n vertical-align: middle;\n\n & path {\n stroke: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n }\n }\n`;\n\nexport const StyledDataTag = styled.span<{\n theme: Theme;\n $color?: \"default\" | \"warning\";\n $capitalize?: boolean;\n}>`\n display: inline-block;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n background: ${({ theme, $color }) =>\n $color === \"warning\" ? theme.colors.warning : theme.colors.primary};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n font-size: ${({ theme }) => theme.fontSizes.small.xs};\n position: relative;\n font-weight: 500;\n padding: 5px 10px;\n margin: auto 0;\n overflow: hidden;\n ${({ $capitalize }) =>\n $capitalize &&\n css`\n text-transform: capitalize;\n `}\n\n &:has(button) {\n padding: 5px 35px 5px 10px;\n }\n`;\n\nexport const StyledIllustration = styled.div<{ theme: Theme }>`\n text-align: center;\n\n & svg {\n height: auto;\n width: 250px;\n margin: auto;\n }\n`;\n\nexport const StyledIllustrationText = styled.p<{ theme: Theme }>`\n color: ${({ theme }) => theme.colors.dark};\n ${({ theme }) => styledSmall(theme)};\n`;\n\nexport const StyledDataText = styled.div<{ theme: Theme; $gray?: boolean }>`\n ${({ theme }) => styledText(theme)};\n color: ${({ theme, $gray }) =>\n $gray ? theme.colors.grayDark : theme.colors.dark};\n`;\n\nexport const StyledDataHeader = styled.h1<{ theme: Theme }>`\n ${({ theme }) => styledH3(theme)};\n font-weight: 900;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\nexport const StyledMobileOnly = styled.em<{ theme: Theme }>`\n font-style: normal;\n display: inline;\n\n ${mq(\"lg\")} {\n display: none;\n }\n`;\n\nexport const StyledDesktopOnly = styled.em<{ theme: Theme }>`\n font-style: normal;\n display: none;\n\n ${mq(\"lg\")} {\n display: inline;\n }\n`;\n\nexport const StyledAlignMiddle = styled.div<{ theme: Theme }>`\n margin: auto 0;\n`;\n\nexport const StyledLoadingOverlay = styled.div<{\n theme: Theme;\n $isActive?: boolean;\n}>`\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: ${({ theme }) => theme.colors.light};\n z-index: 1000;\n display: flex;\n transition: all 0.3s ease;\n opacity: 0;\n pointer-events: none;\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n opacity: 1;\n pointer-events: all;\n `}\n\n & > * {\n margin: auto;\n }\n`;\n\nexport const StyledFlex1 = styled.div`\n flex: 1;\n`;\n\nexport const StyledIconCircle = styled.span<{ theme: Theme }>`\n border-radius: 50%;\n width: 40px;\n height: 40px;\n min-width: 40px;\n min-height: 40px;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n display: inline-flex;\n color: ${({ theme }) => theme.colors.primary};\n vertical-align: middle;\n\n & svg {\n margin: auto;\n }\n`;\n\nexport const StyledStrong = styled.span<{ theme: Theme }>`\n display: block;\n margin: auto 0;\n color: ${({ theme }) => theme.colors.primary};\n font-weight: 700;\n`;\n\nexport const StyledTextMiddle = styled.span<{ theme: Theme }>`\n display: block;\n margin: auto 0;\n color: ${({ theme }) => theme.colors.dark};\n text-align: left;\n`;\n\nexport const StyledPanelWrapper = styled.div<{ theme: Theme }>`\n position: relative;\n z-index: 100;\n\n & input {\n position: relative;\n z-index: 10;\n }\n`;\n\nexport const StyledPanel = styled.div<{ theme: Theme; $absolute?: boolean }>`\n background: ${({ theme }) => theme.colors.light};\n border: solid 2px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n margin: ${({ $absolute }) => ($absolute ? \"-10px 0 0 0\" : \"0\")};\n ${({ $absolute }) =>\n $absolute &&\n css`\n position: absolute;\n width: 100%;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n `}\n max-height: calc(63px * 3);\n overflow-y: auto;\n`;\n\nexport const StyledPanelLabel = styled.div<{ theme: Theme; $moveUp?: boolean }>`\n font-size: ${({ theme }) => theme.fontSizes.small.xs};\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.gray};\n padding: ${({ $moveUp }) => ($moveUp ? \"20px 15px 10px\" : \"20px 15px\")};\n`;\n\nexport const StyledPanelContent = styled.div<{ theme: Theme }>`\n display: flex;\n padding: 15px;\n gap: 10px;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n width: 100%;\n text-align: left;\n font-family: ${({ theme }) => theme.fonts.text};\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n font-weight: 500;\n color: ${({ theme }) => theme.colors.primary};\n transition: all 0.3s ease;\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n }\n\n &:last-of-type {\n border-bottom: none;\n }\n`;\n\nexport const StyledPanelButton = styled(Link)<{ theme: Theme }>`\n ${resetButton};\n display: flex;\n padding: 15px;\n gap: 10px;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n width: 100%;\n text-align: left;\n font-family: ${({ theme }) => theme.fonts.text};\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n font-weight: 500;\n color: ${({ theme }) => theme.colors.primary};\n text-decoration: none;\n transition: all 0.3s ease;\n\n &:hover {\n background-color: ${({ theme }) => rgba(theme.colors.primaryLight, 0.1)};\n\n & .link {\n border-color: ${({ theme }) => theme.colors.primaryDark};\n }\n }\n\n &:focus {\n background-color: ${({ theme }) => rgba(theme.colors.primaryLight, 0.15)};\n\n & .link {\n box-shadow: 0 0 0 4px ${({ theme }) => theme.colors.primaryLight};\n border-color: ${({ theme }) => theme.colors.primary};\n }\n }\n\n &:active {\n background-color: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n\n & .link {\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n }\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n }\n\n &:last-of-type {\n border-bottom: none;\n }\n\n & .link {\n transition: all 0.3s ease;\n pointer-events: none;\n }\n`;\n\nexport const StyledPanelSpan = styled.span<{ theme: Theme }>`\n margin: auto 0;\n display: flex;\n flex: 1;\n line-height: 1.5;\n`;\n\nexport const editableContent = css<{ theme: Theme }>`\n border: dotted 1px transparent;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n transition: all 0.3s ease;\n outline: none;\n cursor: text;\n\n &:hover {\n border: dotted 1px ${({ theme }) => theme.colors.gray};\n }\n\n &:focus {\n border: dotted 1px ${({ theme }) => theme.colors.info};\n }\n`;\n\nexport const StyledDataEditableText = styled.div<{\n theme: Theme;\n $gray?: boolean;\n}>`\n ${({ theme }) => styledText(theme)};\n color: ${({ theme, $gray }) =>\n $gray ? theme.colors.grayDark : theme.colors.dark};\n\n &[contenteditable=\"true\"] {\n ${editableContent};\n }\n`;\n\nexport const StyledSmallButtonWrapper = styled.div<{ theme: Theme }>`\n position: relative;\n\n & .hidden-button {\n opacity: 0;\n pointer-events: none;\n transform: translateY(10px);\n }\n\n &:hover {\n & .hidden-button {\n opacity: 1;\n pointer-events: all;\n transform: translateY(0);\n }\n }\n`;\n\nexport const StyledSmallButton = styled.button<{\n theme: Theme;\n}>`\n ${resetButton};\n display: flex;\n gap: 5px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n color: ${({ theme }) => theme.colors.primary};\n background: ${({ theme }) => theme.colors.light};\n margin: auto 0;\n min-width: fit-content;\n ${interactiveStyles}\n\n &.hidden-button {\n position: absolute;\n bottom: 1px;\n right: 1px;\n z-index: 98;\n }\n`;\n\nexport const StyledTitle = styled(StyledH1)<{ theme: Theme }>`\n display: block;\n\n &[contenteditable=\"true\"] {\n ${editableContent};\n }\n`;\n\nexport const StyledImage = styled.img<{ theme: Theme; $maxWidth?: string }>`\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n max-width: ${({ $maxWidth }) => ($maxWidth ? $maxWidth : \"100%\")};\n width: 100%;\n height: auto;\n border: 1px solid ${({ theme }) => theme.colors.grayLight};\n`;\n\nexport const stylesLists = css<{ theme: Theme }>`\n & ul,\n & ol {\n & li {\n & > .code-wrapper {\n margin: 10px 0;\n }\n }\n }\n\n & ul {\n list-style: none;\n padding: 0;\n margin: 0;\n\n & li {\n text-indent: 0;\n display: block;\n position: relative;\n padding: 0 0 0 15px;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n min-height: 23px;\n\n $mq: \"lg\" {\n min-height: 27px;\n }\n\n &::before {\n content: \"\";\n display: block;\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: ${({ theme }) => theme.colors.primary};\n position: absolute;\n top: 8px;\n left: 2px;\n\n ${mq(\"lg\")} {\n top: 10px;\n }\n }\n }\n }\n\n & ol {\n padding: 0;\n margin: 0;\n\n & ul {\n padding-left: 15px;\n }\n\n & > li {\n position: relative;\n padding: 0;\n counter-increment: item;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n\n &::before {\n content: counter(item) \".\";\n display: inline-block;\n margin: 0 4px 0 0;\n font-weight: 700;\n color: ${({ theme }) => theme.colors.primary};\n min-width: max-content;\n }\n }\n }\n`;\n\nexport const styledTable = css<{ theme: Theme }>`\n & table {\n margin: 0;\n padding: 0;\n border-collapse: collapse;\n width: 100%;\n text-align: left;\n\n & tr {\n margin: 0;\n padding: 0;\n }\n\n & th {\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 10px 0;\n ${({ theme }) => styledSmall(theme)};\n font-weight: 600;\n color: ${({ theme }) => theme.colors.dark};\n }\n\n & td {\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 10px 10px 10px 0;\n color: ${({ theme }) => theme.colors.grayDark};\n ${({ theme }) => styledSmall(theme)};\n }\n }\n`;\n";
|
|
1
|
+
export declare const sharedStyledTemplate = "\"use client\";\nimport {\n mq,\n resetButton,\n styledH3,\n styledSmall,\n styledText,\n Theme,\n} from \"cherry-styled-components\";\nimport { rgba } from \"polished\";\nimport Link from \"next/link\";\nimport styled, { css } from \"styled-components\";\nimport { StyledH1 } from \"@/components/layout/Typography\";\n\nexport const interactiveStyles = css<{ theme: Theme }>`\n transition: all 0.3s ease;\n border: solid 1px transparent;\n box-shadow: 0 0 0 0px ${({ theme }) => theme.colors.primary};\n\n &:hover {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n\n &:focus {\n border-color: ${({ theme }) => theme.colors.primary};\n box-shadow: 0 0 0 4px ${({ theme }) => theme.colors.primaryLight};\n }\n\n &:active {\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n`;\n\nexport const StyledPriceHeader = styled.div<{ theme: Theme }>`\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 20px;\n display: flex;\n gap: 20px;\n\n & strong {\n margin: auto 0;\n color: ${({ theme }) => theme.colors.dark};\n }\n`;\n\nexport const StyledPrice = styled.span<{\n theme: Theme;\n $textAlign?: \"left\" | \"center\" | \"right\";\n}>`\n color: ${({ theme }) => theme.colors.dark};\n display: block;\n font-weight: 800;\n ${({ theme }) => styledH3(theme)}\n ${({ $textAlign }) =>\n $textAlign &&\n css`\n text-align: ${$textAlign};\n `}\n`;\n\nexport const StyledSmall = styled.small<{\n theme: Theme;\n $textAlign?: \"left\" | \"center\" | \"right\";\n}>`\n ${({ theme }) => styledSmall(theme)};\n margin: auto 0;\n display: block;\n color: ${({ theme }) => theme.colors.grayDark};\n ${({ $textAlign }) =>\n $textAlign &&\n css`\n text-align: ${$textAlign};\n `}\n`;\n\nexport const StyledMinHeight = styled.div`\n min-height: calc(100svh - 100px);\n\n ${mq(\"lg\")} {\n min-height: calc(100svh - 120px);\n }\n`;\n\nexport const StyledDataUserAvatar = styled.span<{ theme: Theme }>`\n display: inline-flex;\n justify-content: center;\n min-width: 50px;\n min-height: 50px;\n border-radius: 50%;\n border: solid 2px ${({ theme }) => theme.colors.grayLight};\n background: ${({ theme }) => theme.colors.light};\n position: relative;\n overflow: hidden;\n color: ${({ theme }) => theme.colors.primary};\n\n & img {\n position: absolute;\n top: 50%;\n left: 50%;\n transform: translate(-50%, -50%);\n width: 100%;\n height: 100%;\n object-fit: cover;\n pointer-events: none;\n }\n\n & svg {\n pointer-events: none;\n margin: auto;\n transition: none;\n }\n`;\n\nexport const StyledDataArrowButton = styled.button<{\n theme: Theme;\n $isActive: boolean;\n}>`\n ${resetButton};\n display: flex;\n gap: 5px;\n min-width: fit-content;\n\n & .avatar,\n & .clickable {\n box-shadow: 0 0 0 0px ${({ theme }) => theme.colors.primary};\n transition: all 0.3s ease;\n }\n\n & svg {\n color: ${({ theme }) => theme.colors.primary};\n }\n\n &:hover {\n & .lucide-chevron-down {\n & path {\n stroke: ${({ theme }) => theme.colors.primaryDark};\n }\n }\n\n & .avatar,\n & .clickable {\n border-color: ${({ theme }) => theme.colors.primary};\n }\n }\n\n &:focus {\n & .avatar,\n & .clickable {\n border-color: ${({ theme }) => theme.colors.primary};\n box-shadow: 0 0 0 4px ${({ theme }) => theme.colors.primaryLight};\n }\n }\n\n &:active {\n & .avatar,\n & .clickable {\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n }\n\n & svg {\n margin: auto 0;\n }\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n & .lucide-chevron-down {\n transform: rotate(180deg);\n }\n `}\n`;\n\nexport const StyledDataSpan = styled.span<{ theme: Theme }>`\n display: block;\n font-weight: 500;\n ${({ theme }) => styledText(theme)};\n color: ${({ theme }) => theme.colors.primary};\n font-family: ${({ theme }) => theme.fonts.text};\n`;\n\nexport const StyledDataDropdownHoverWrapper = styled.span<{\n theme: Theme;\n $align?: \"center\";\n}>`\n position: relative;\n display: block;\n padding: 10px 0;\n margin: -10px 0;\n\n &:hover {\n & ul {\n pointer-events: all;\n opacity: 1;\n transform: ${({ $align }) =>\n $align === \"center\"\n ? \"translateX(-50%) translateY(0)\"\n : \"translateY(0)\"};\n }\n\n & .lucide-chevron-down {\n transform: rotate(180deg);\n }\n }\n\n &:active {\n & ul {\n opacity: 0;\n }\n }\n`;\n\nexport const StyledDataDropdownWrapper = styled.span<{\n theme: Theme;\n $isAbsolute?: boolean;\n $marginAuto?: boolean;\n}>`\n position: relative;\n display: flex;\n padding: 10px 0;\n margin: -10px 0;\n\n & span {\n display: flex;\n margin: auto 0;\n }\n\n ${({ $isAbsolute }) =>\n $isAbsolute &&\n css`\n margin: 0;\n padding: 0;\n `}\n\n ${({ $marginAuto }) => $marginAuto && `margin: auto 0;`}\n`;\n\nexport const StyledDataDropdown = styled.ul<{\n theme: Theme;\n $isActive?: boolean;\n $align?: \"left\" | \"center\";\n $isTop?: boolean;\n $isAbsolute?: boolean;\n $minWidth?: string;\n}>`\n position: absolute;\n display: block;\n top: 100%;\n right: 0;\n list-style: none;\n transition: all 0.3s ease;\n min-width: ${({ $minWidth }) => ($minWidth ? $minWidth : \"200px\")};\n text-align: left;\n box-shadow: ${({ theme }) => theme.shadows.sm};\n background: ${({ theme }) => theme.colors.light};\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n overflow: hidden;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n transition: all 0.3s ease;\n opacity: 0;\n pointer-events: none;\n transform: translateY(-10px);\n z-index: 999;\n\n ${({ $isAbsolute }) =>\n $isAbsolute &&\n css`\n top: calc(100% + 56px);\n right: 20px;\n `}\n\n ${({ $isTop }) => $isTop && `top: initial; bottom: 100%;`}\n\n ${({ $align }) => $align === \"left\" && `right: initial; left: 0;`}\n ${({ $align }) =>\n $align === \"center\" &&\n `right: initial; left: 50%; transform: translateX(-50%) translateY(-10px);`}\n\n ${({ $isActive, $align }) =>\n $isActive &&\n css`\n pointer-events: all;\n opacity: 1;\n transform: translateY(0);\n\n ${$align === \"center\" &&\n `right: initial; left: 50%; transform: translateX(-50%) translateY(0);`}\n `}\n`;\n\nexport const StyledDataDropdownItem = styled.li<{ theme: Theme }>`\n display: flex;\n width: 100%;\n text-align: left;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n\n &:last-of-type,\n &:only-of-type {\n border-bottom: none;\n }\n\n & form {\n width: 100%;\n margin: 0;\n padding: 0;\n }\n\n & a,\n & button,\n & .logout-button {\n ${resetButton};\n width: 100%;\n text-align: left;\n padding: 12px 20px;\n font-weight: 600;\n font-family: inherit;\n color: ${({ theme }) => theme.colors.primary};\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n text-decoration: none;\n transition: all 0.3s ease;\n border-radius: 0;\n background: transparent;\n display: flex;\n gap: 10px;\n height: auto;\n min-height: 45px;\n\n &:hover {\n background-color: ${({ theme }) =>\n rgba(\n theme.isDark ? theme.colors.primaryDark : theme.colors.primaryLight,\n 0.1,\n )};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.primaryLight : theme.colors.primaryDark};\n }\n\n &:active,\n &:focus {\n box-shadow: none;\n }\n\n * {\n margin: auto 0;\n }\n\n &:focus {\n background-color: ${({ theme }) =>\n rgba(\n theme.isDark ? theme.colors.primaryDark : theme.colors.primaryLight,\n 0.15,\n )};\n }\n\n &:active {\n background-color: ${({ theme }) =>\n rgba(\n theme.isDark ? theme.colors.primaryDark : theme.colors.primaryLight,\n 0.2,\n )};\n }\n }\n\n & button {\n border: none;\n\n & svg {\n margin: -2px -5px;\n }\n }\n`;\n\nexport const StyledDataDropdownButton = styled.button<{ theme: Theme }>`\n ${resetButton};\n`;\n\nexport const StyledDataDropdownSelectWrapper = styled.span<{ theme: Theme }>`\n padding: 5px;\n display: block;\n width: 100%;\n\n & select {\n width: 100%;\n }\n`;\n\nexport const StyledDataTagButton = styled.button<{ theme: Theme }>`\n ${resetButton};\n position: absolute;\n top: 0;\n width: 24px;\n height: 100%;\n right: 0;\n border-left: solid 1px\n ${({ theme }) =>\n rgba(theme.isDark ? theme.colors.dark : theme.colors.light, 0.2)};\n transition: all 0.3s ease;\n\n &:hover {\n background: ${({ theme }) =>\n rgba(theme.isDark ? theme.colors.dark : theme.colors.light, 0.2)};\n }\n\n & svg {\n width: 16px;\n height: 100%;\n vertical-align: middle;\n\n & path {\n stroke: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n }\n }\n`;\n\nexport const StyledDataTag = styled.span<{\n theme: Theme;\n $color?: \"default\" | \"warning\";\n $capitalize?: boolean;\n}>`\n display: inline-block;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n background: ${({ theme, $color }) =>\n $color === \"warning\" ? theme.colors.warning : theme.colors.primary};\n color: ${({ theme }) =>\n theme.isDark ? theme.colors.dark : theme.colors.light};\n font-size: ${({ theme }) => theme.fontSizes.small.xs};\n position: relative;\n font-weight: 500;\n padding: 5px 10px;\n margin: auto 0;\n overflow: hidden;\n ${({ $capitalize }) =>\n $capitalize &&\n css`\n text-transform: capitalize;\n `}\n\n &:has(button) {\n padding: 5px 35px 5px 10px;\n }\n`;\n\nexport const StyledIllustration = styled.div<{ theme: Theme }>`\n text-align: center;\n\n & svg {\n height: auto;\n width: 250px;\n margin: auto;\n }\n`;\n\nexport const StyledIllustrationText = styled.p<{ theme: Theme }>`\n color: ${({ theme }) => theme.colors.dark};\n ${({ theme }) => styledSmall(theme)};\n`;\n\nexport const StyledDataText = styled.div<{ theme: Theme; $gray?: boolean }>`\n ${({ theme }) => styledText(theme)};\n color: ${({ theme, $gray }) =>\n $gray ? theme.colors.grayDark : theme.colors.dark};\n`;\n\nexport const StyledDataHeader = styled.h1<{ theme: Theme }>`\n ${({ theme }) => styledH3(theme)};\n font-weight: 900;\n color: ${({ theme }) => theme.colors.dark};\n`;\n\nexport const StyledMobileOnly = styled.em<{ theme: Theme }>`\n font-style: normal;\n display: inline;\n\n ${mq(\"lg\")} {\n display: none;\n }\n`;\n\nexport const StyledDesktopOnly = styled.em<{ theme: Theme }>`\n font-style: normal;\n display: none;\n\n ${mq(\"lg\")} {\n display: inline;\n }\n`;\n\nexport const StyledAlignMiddle = styled.div<{ theme: Theme }>`\n margin: auto 0;\n`;\n\nexport const StyledLoadingOverlay = styled.div<{\n theme: Theme;\n $isActive?: boolean;\n}>`\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n background-color: ${({ theme }) => theme.colors.light};\n z-index: 1000;\n display: flex;\n transition: all 0.3s ease;\n opacity: 0;\n pointer-events: none;\n\n ${({ $isActive }) =>\n $isActive &&\n css`\n opacity: 1;\n pointer-events: all;\n `}\n\n & > * {\n margin: auto;\n }\n`;\n\nexport const StyledFlex1 = styled.div`\n flex: 1;\n`;\n\nexport const StyledIconCircle = styled.span<{ theme: Theme }>`\n border-radius: 50%;\n width: 40px;\n height: 40px;\n min-width: 40px;\n min-height: 40px;\n border: solid 1px ${({ theme }) => theme.colors.grayLight};\n display: inline-flex;\n color: ${({ theme }) => theme.colors.primary};\n vertical-align: middle;\n\n & svg {\n margin: auto;\n }\n`;\n\nexport const StyledStrong = styled.span<{ theme: Theme }>`\n display: block;\n margin: auto 0;\n color: ${({ theme }) => theme.colors.primary};\n font-weight: 700;\n`;\n\nexport const StyledTextMiddle = styled.span<{ theme: Theme }>`\n display: block;\n margin: auto 0;\n color: ${({ theme }) => theme.colors.dark};\n text-align: left;\n`;\n\nexport const StyledPanelWrapper = styled.div<{ theme: Theme }>`\n position: relative;\n z-index: 100;\n\n & input {\n position: relative;\n z-index: 10;\n }\n`;\n\nexport const StyledPanel = styled.div<{ theme: Theme; $absolute?: boolean }>`\n background: ${({ theme }) => theme.colors.light};\n border: solid 2px ${({ theme }) => theme.colors.grayLight};\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n margin: ${({ $absolute }) => ($absolute ? \"-10px 0 0 0\" : \"0\")};\n ${({ $absolute }) =>\n $absolute &&\n css`\n position: absolute;\n width: 100%;\n border-top-left-radius: 0;\n border-top-right-radius: 0;\n `}\n max-height: calc(63px * 3);\n overflow-y: auto;\n`;\n\nexport const StyledPanelLabel = styled.div<{ theme: Theme; $moveUp?: boolean }>`\n font-size: ${({ theme }) => theme.fontSizes.small.xs};\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n color: ${({ theme }) => theme.colors.gray};\n padding: ${({ $moveUp }) => ($moveUp ? \"20px 15px 10px\" : \"20px 15px\")};\n`;\n\nexport const StyledPanelContent = styled.div<{ theme: Theme }>`\n display: flex;\n padding: 15px;\n gap: 10px;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n width: 100%;\n text-align: left;\n font-family: ${({ theme }) => theme.fonts.text};\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n font-weight: 500;\n color: ${({ theme }) => theme.colors.primary};\n transition: all 0.3s ease;\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n }\n\n &:last-of-type {\n border-bottom: none;\n }\n`;\n\nexport const StyledPanelButton = styled(Link)<{ theme: Theme }>`\n ${resetButton};\n display: flex;\n padding: 15px;\n gap: 10px;\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n width: 100%;\n text-align: left;\n font-family: ${({ theme }) => theme.fonts.text};\n font-size: ${({ theme }) => theme.fontSizes.text.xs};\n line-height: ${({ theme }) => theme.lineHeights.text.xs};\n font-weight: 500;\n color: ${({ theme }) => theme.colors.primary};\n text-decoration: none;\n transition: all 0.3s ease;\n\n &:hover {\n background-color: ${({ theme }) => rgba(theme.colors.primaryLight, 0.1)};\n\n & .link {\n border-color: ${({ theme }) => theme.colors.primaryDark};\n }\n }\n\n &:focus {\n background-color: ${({ theme }) => rgba(theme.colors.primaryLight, 0.15)};\n\n & .link {\n box-shadow: 0 0 0 4px ${({ theme }) => theme.colors.primaryLight};\n border-color: ${({ theme }) => theme.colors.primary};\n }\n }\n\n &:active {\n background-color: ${({ theme }) => rgba(theme.colors.primaryLight, 0.2)};\n\n & .link {\n box-shadow: 0 0 0 2px ${({ theme }) => theme.colors.primaryLight};\n }\n }\n\n ${mq(\"lg\")} {\n font-size: ${({ theme }) => theme.fontSizes.text.lg};\n line-height: ${({ theme }) => theme.lineHeights.text.lg};\n }\n\n &:last-of-type {\n border-bottom: none;\n }\n\n & .link {\n transition: all 0.3s ease;\n pointer-events: none;\n }\n`;\n\nexport const StyledPanelSpan = styled.span<{ theme: Theme }>`\n margin: auto 0;\n display: flex;\n flex: 1;\n line-height: 1.5;\n`;\n\nexport const editableContent = css<{ theme: Theme }>`\n border: dotted 1px transparent;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n transition: all 0.3s ease;\n outline: none;\n cursor: text;\n\n &:hover {\n border: dotted 1px ${({ theme }) => theme.colors.gray};\n }\n\n &:focus {\n border: dotted 1px ${({ theme }) => theme.colors.info};\n }\n`;\n\nexport const StyledDataEditableText = styled.div<{\n theme: Theme;\n $gray?: boolean;\n}>`\n ${({ theme }) => styledText(theme)};\n color: ${({ theme, $gray }) =>\n $gray ? theme.colors.grayDark : theme.colors.dark};\n\n &[contenteditable=\"true\"] {\n ${editableContent};\n }\n`;\n\nexport const StyledSmallButtonWrapper = styled.div<{ theme: Theme }>`\n position: relative;\n\n & .hidden-button {\n opacity: 0;\n pointer-events: none;\n transform: translateY(10px);\n }\n\n &:hover {\n & .hidden-button {\n opacity: 1;\n pointer-events: all;\n transform: translateY(0);\n }\n }\n`;\n\nexport const StyledSmallButton = styled.button<{\n theme: Theme;\n}>`\n ${resetButton};\n display: flex;\n gap: 5px;\n border-radius: ${({ theme }) => theme.spacing.radius.xs};\n color: ${({ theme }) => theme.colors.primary};\n background: ${({ theme }) => theme.colors.light};\n margin: auto 0;\n min-width: fit-content;\n ${interactiveStyles}\n\n &.hidden-button {\n position: absolute;\n bottom: 1px;\n right: 1px;\n z-index: 98;\n }\n`;\n\nexport const StyledTitle = styled(StyledH1)<{ theme: Theme }>`\n display: block;\n\n &[contenteditable=\"true\"] {\n ${editableContent};\n }\n`;\n\nexport const StyledImage = styled.img<{ theme: Theme; $maxWidth?: string }>`\n border-radius: ${({ theme }) => theme.spacing.radius.lg};\n max-width: ${({ $maxWidth }) => ($maxWidth ? $maxWidth : \"100%\")};\n width: 100%;\n height: auto;\n border: 1px solid ${({ theme }) => theme.colors.grayLight};\n`;\n\nexport const stylesLists = css<{ theme: Theme }>`\n & ul,\n & ol {\n & li {\n & > .code-wrapper {\n margin: 10px 0;\n }\n }\n }\n\n & ul {\n list-style: none;\n padding: 0;\n margin: 0;\n\n & li {\n text-indent: 0;\n display: block;\n position: relative;\n padding: 0 0 0 15px;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n min-height: 23px;\n\n ${mq(\"lg\")} {\n min-height: 27px;\n }\n\n &::before {\n content: \"\";\n display: block;\n width: 6px;\n height: 6px;\n border-radius: 50%;\n background: ${({ theme }) => theme.colors.primary};\n position: absolute;\n top: 8px;\n left: 2px;\n\n ${mq(\"lg\")} {\n top: 10px;\n }\n }\n }\n }\n\n & ol {\n padding: 0;\n margin: 0;\n\n & ul {\n padding-left: 15px;\n }\n\n & > li {\n position: relative;\n padding: 0;\n counter-increment: item;\n margin: 0;\n ${({ theme }) => styledText(theme)};\n\n &::before {\n content: counter(item) \".\";\n display: inline-block;\n margin: 0 4px 0 0;\n font-weight: 700;\n color: ${({ theme }) => theme.colors.primary};\n min-width: max-content;\n }\n }\n }\n`;\n\nexport const styledTable = css<{ theme: Theme }>`\n & table {\n margin: 0;\n padding: 0;\n border-collapse: collapse;\n width: 100%;\n text-align: left;\n\n & tr {\n margin: 0;\n padding: 0;\n }\n\n & th {\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 10px 0;\n ${({ theme }) => styledSmall(theme)};\n font-weight: 600;\n color: ${({ theme }) => theme.colors.dark};\n }\n\n & td {\n border-bottom: solid 1px ${({ theme }) => theme.colors.grayLight};\n padding: 10px 10px 10px 0;\n color: ${({ theme }) => theme.colors.grayDark};\n ${({ theme }) => styledSmall(theme)};\n }\n }\n`;\n";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const nextConfigTemplate = "import type { NextConfig } from \"next\";\
|
|
1
|
+
export declare const nextConfigTemplate = "import type { NextConfig } from \"next\";\nimport path from \"node:path\";\n\nconst nextConfig: NextConfig = {\n outputFileTracingRoot: path.join(__dirname, \"./\"),\n compiler: {\n styledComponents: true,\n },\n transpilePackages: [\"lucide-react\", \"cherry-styled-components\"],\n};\n\nexport default nextConfig;\n";
|
|
@@ -9,35 +9,36 @@ export const packageJsonTemplate = JSON.stringify({
|
|
|
9
9
|
lint: "eslint .",
|
|
10
10
|
},
|
|
11
11
|
dependencies: {
|
|
12
|
-
"@langchain/anthropic": "^1.3.
|
|
13
|
-
"@langchain/
|
|
14
|
-
"@langchain/
|
|
12
|
+
"@langchain/anthropic": "^1.3.18",
|
|
13
|
+
"@langchain/core": "^1.1.25",
|
|
14
|
+
"@langchain/google-genai": "^2.1.19",
|
|
15
|
+
"@langchain/openai": "^1.2.8",
|
|
16
|
+
"@mdx-js/react": "^3.1.1",
|
|
15
17
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
18
|
+
"cherry-styled-components": "^0.1.12",
|
|
16
19
|
langchain: "^1.2.24",
|
|
20
|
+
"lucide-react": "^0.574.0",
|
|
17
21
|
next: "16.1.6",
|
|
22
|
+
"next-mdx-remote": "^6.0.0",
|
|
23
|
+
polished: "^4.3.1",
|
|
18
24
|
react: "19.2.4",
|
|
19
25
|
"react-dom": "19.2.4",
|
|
26
|
+
"rehype-highlight": "^7.0.2",
|
|
27
|
+
"rehype-parse": "^9.0.1",
|
|
28
|
+
"rehype-stringify": "^10.0.1",
|
|
29
|
+
"remark-gfm": "^4.0.1",
|
|
30
|
+
"styled-components": "^6.3.9",
|
|
31
|
+
unified: "^11.0.5",
|
|
32
|
+
zod: "^4.3.6",
|
|
20
33
|
},
|
|
21
34
|
devDependencies: {
|
|
22
|
-
"@mdx-js/react": "^3.1.1",
|
|
23
35
|
"@types/node": "^25",
|
|
24
36
|
"@types/react": "^19",
|
|
25
37
|
"@types/react-dom": "^19",
|
|
26
38
|
"baseline-browser-mapping": "^2.9.19",
|
|
27
|
-
"cherry-styled-components": "^0.1.12",
|
|
28
39
|
eslint: "^9",
|
|
29
40
|
"eslint-config-next": "16.1.6",
|
|
30
|
-
"lucide-react": "^0.574.0",
|
|
31
|
-
"next-mdx-remote": "^6.0.0",
|
|
32
|
-
polished: "^4.3.1",
|
|
33
41
|
prettier: "^3.8.1",
|
|
34
|
-
"rehype-highlight": "^7.0.2",
|
|
35
|
-
"rehype-parse": "^9.0.1",
|
|
36
|
-
"rehype-stringify": "^10.0.1",
|
|
37
|
-
"remark-gfm": "^4.0.1",
|
|
38
|
-
"styled-components": "^6.3.9",
|
|
39
42
|
typescript: "^5",
|
|
40
|
-
unified: "^11.0.5",
|
|
41
|
-
zod: "^4.3.6",
|
|
42
43
|
},
|
|
43
44
|
}, null, 2);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const llmConfigTemplate = "import type {\n LLMConfig,\n LLMProvider,\n ProviderDefaults,\n} from \"@/services/llm/types\";\nconst PROVIDER_DEFAULTS: ProviderDefaults = {\n openai: {\n chat: \"gpt-4.1-nano\",\n embedding: \"text-embedding-3-small\",\n },\n anthropic: {\n chat: \"claude-sonnet-4-5-20250929\",\n embedding: \"text-embedding-3-small\", // Fallback to OpenAI\n },\n google: {\n chat: \"gemini-2.5-flash-lite\",\n embedding: \"text-embedding-004\",\n },\n};\nfunction validateAPIKeys(provider: LLMProvider): void {\n const requiredKeys: Record<LLMProvider, string> = {\n openai: \"OPENAI_API_KEY\",\n anthropic: \"ANTHROPIC_API_KEY\",\n google: \"GOOGLE_API_KEY\",\n };\n const keyName = requiredKeys[provider];\n const keyValue = process.env[keyName];\n if (!keyValue) {\n throw new Error(\n `Missing API key for ${provider}. Please set ${keyName} in your environment variables.`,\n );\n }\n if (provider === \"anthropic\" && !process.env.OPENAI_API_KEY) {\n throw new Error(\n \"Anthropic provider requires OPENAI_API_KEY for embeddings. Please set OPENAI_API_KEY in your environment variables.\",\n );\n }\n}\nexport function getLLMConfig(): LLMConfig {\n const provider = (process.env.LLM_PROVIDER || \"openai\") as LLMProvider;\n if (![\"openai\", \"anthropic\", \"google\"].includes(provider)) {\n throw new Error(\n `Invalid LLM_PROVIDER: ${provider}. Must be one of: openai, anthropic, google`,\n );\n }\n validateAPIKeys(provider);\n const defaults = PROVIDER_DEFAULTS[provider];\n return {\n provider,\n chatModel: process.env.LLM_CHAT_MODEL || defaults.chat,\n embeddingModel: process.env.LLM_EMBEDDING_MODEL || defaults.embedding,\n temperature: parseFloat(process.env.LLM_TEMPERATURE || \"0\"),\n };\n}\
|
|
1
|
+
export declare const llmConfigTemplate = "import type {\n LLMConfig,\n LLMProvider,\n ProviderDefaults,\n} from \"@/services/llm/types\";\nconst PROVIDER_DEFAULTS: ProviderDefaults = {\n openai: {\n chat: \"gpt-4.1-nano\",\n embedding: \"text-embedding-3-small\",\n },\n anthropic: {\n chat: \"claude-sonnet-4-5-20250929\",\n embedding: \"text-embedding-3-small\", // Fallback to OpenAI\n },\n google: {\n chat: \"gemini-2.5-flash-lite\",\n embedding: \"text-embedding-004\",\n },\n};\nfunction validateAPIKeys(provider: LLMProvider): void {\n const requiredKeys: Record<LLMProvider, string> = {\n openai: \"OPENAI_API_KEY\",\n anthropic: \"ANTHROPIC_API_KEY\",\n google: \"GOOGLE_API_KEY\",\n };\n const keyName = requiredKeys[provider];\n const keyValue = process.env[keyName];\n if (!keyValue) {\n throw new Error(\n `Missing API key for ${provider}. Please set ${keyName} in your environment variables.`,\n );\n }\n if (provider === \"anthropic\" && !process.env.OPENAI_API_KEY) {\n throw new Error(\n \"Anthropic provider requires OPENAI_API_KEY for embeddings. Please set OPENAI_API_KEY in your environment variables.\",\n );\n }\n}\nexport function getLLMConfig(): LLMConfig {\n const provider = (process.env.LLM_PROVIDER || \"openai\") as LLMProvider;\n if (![\"openai\", \"anthropic\", \"google\"].includes(provider)) {\n throw new Error(\n `Invalid LLM_PROVIDER: ${provider}. Must be one of: openai, anthropic, google`,\n );\n }\n validateAPIKeys(provider);\n const defaults = PROVIDER_DEFAULTS[provider];\n return {\n provider,\n chatModel: process.env.LLM_CHAT_MODEL || defaults.chat,\n embeddingModel: process.env.LLM_EMBEDDING_MODEL || defaults.embedding,\n temperature: parseFloat(process.env.LLM_TEMPERATURE || \"0\"),\n };\n}\n";
|
|
@@ -52,21 +52,4 @@ export function getLLMConfig(): LLMConfig {
|
|
|
52
52
|
temperature: parseFloat(process.env.LLM_TEMPERATURE || "0"),
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
|
-
export function getLLMDisplayName(config?: LLMConfig): string {
|
|
56
|
-
const c = config || getLLMConfig();
|
|
57
|
-
const providerNames: Record<LLMProvider, string> = {
|
|
58
|
-
openai: "OpenAI",
|
|
59
|
-
anthropic: "Anthropic",
|
|
60
|
-
google: "Google",
|
|
61
|
-
};
|
|
62
|
-
return \`\${providerNames[c.provider]} (\${c.chatModel})\`;
|
|
63
|
-
}
|
|
64
|
-
export function isLLMConfigured(): boolean {
|
|
65
|
-
try {
|
|
66
|
-
getLLMConfig();
|
|
67
|
-
return true;
|
|
68
|
-
} catch {
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
55
|
`;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const llmTypesTemplate = "export type LLMProvider = \"openai\" | \"anthropic\" | \"google\";\n\nexport interface LLMConfig {\n provider: LLMProvider;\n chatModel: string;\n embeddingModel: string;\n temperature: number;\n}\n\nexport interface
|
|
1
|
+
export declare const llmTypesTemplate = "export type LLMProvider = \"openai\" | \"anthropic\" | \"google\";\n\nexport interface LLMConfig {\n provider: LLMProvider;\n chatModel: string;\n embeddingModel: string;\n temperature: number;\n}\n\nexport interface ProviderModels {\n chat: string;\n embedding: string;\n}\n\nexport type ProviderDefaults = Record<LLMProvider, ProviderModels>;\n";
|
|
@@ -7,16 +7,6 @@ export interface LLMConfig {
|
|
|
7
7
|
temperature: number;
|
|
8
8
|
}
|
|
9
9
|
|
|
10
|
-
export interface Message {
|
|
11
|
-
role: "system" | "user" | "assistant";
|
|
12
|
-
content: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface LLMResponse {
|
|
16
|
-
content: string;
|
|
17
|
-
metadata?: Record<string, unknown>;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
10
|
export interface ProviderModels {
|
|
21
11
|
chat: string;
|
|
22
12
|
embedding: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const mcpServerTemplate = "import { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport {\n listDocs,\n getDoc,\n getAllDocsChunks,\n DOCS_TOOLS,\n} from \"@/services/mcp/tools\";\nimport { getLLMConfig, createEmbeddings } from \"@/services/llm\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/**\n * In-memory cache for document embeddings
|
|
1
|
+
export declare const mcpServerTemplate = "import { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { z } from \"zod\";\nimport {\n listDocs,\n getDoc,\n getAllDocsChunks,\n DOCS_TOOLS,\n} from \"@/services/mcp/tools\";\nimport { getLLMConfig, createEmbeddings } from \"@/services/llm\";\nimport type { DocsChunk } from \"@/services/mcp/types\";\n\n/**\n * In-memory cache for document embeddings.\n * Built once at server startup since docs are static.\n */\nlet docsIndex: {\n ready: boolean;\n building: boolean;\n chunks: (DocsChunk & { embedding: number[] })[];\n} = {\n ready: false,\n building: false,\n chunks: [],\n};\n\n/** Resolves when the initial index build completes */\nlet indexReady: Promise<void> | null = null;\n\n/**\n * Cosine similarity between two vectors\n */\nfunction cosineSim(a: number[], b: number[]): number {\n let dot = 0,\n na = 0,\n nb = 0;\n for (let i = 0; i < a.length; i++) {\n const x = a[i];\n const y = b[i];\n dot += x * y;\n na += x * x;\n nb += y * y;\n }\n if (na === 0 || nb === 0) return 0;\n return dot / (Math.sqrt(na) * Math.sqrt(nb));\n}\n\n/**\n * Build or rebuild the documentation index\n */\nexport async function buildDocsIndex(force = false): Promise<void> {\n if (docsIndex.building) return;\n if (docsIndex.ready && !force) return;\n\n docsIndex.building = true;\n try {\n const chunks = await getAllDocsChunks();\n\n if (chunks.length === 0) {\n docsIndex.chunks = [];\n docsIndex.ready = true;\n return;\n }\n\n const config = getLLMConfig();\n const embeddings = createEmbeddings(config);\n\n // Process embeddings in small batches to avoid exceeding token limits\n const BATCH_SIZE = 10;\n const texts = chunks.map((c) => c.text);\n const vectors: number[][] = [];\n\n for (let i = 0; i < texts.length; i += BATCH_SIZE) {\n const batch = texts.slice(i, i + BATCH_SIZE);\n const batchVectors = await embeddings.embedDocuments(batch);\n vectors.push(...batchVectors);\n }\n\n docsIndex.chunks = chunks.map((c, i) => ({\n ...c,\n embedding: vectors[i],\n }));\n docsIndex.ready = true;\n } catch (error) {\n // Reset so the next call to ensureDocsIndex retries\n indexReady = null;\n throw error;\n } finally {\n docsIndex.building = false;\n }\n}\n\n/**\n * Ensure the docs index is ready.\n * On first call, triggers the build; subsequent calls wait for the same promise.\n */\nexport async function ensureDocsIndex(force = false): Promise<void> {\n if (force) {\n docsIndex.ready = false;\n docsIndex.chunks = [];\n indexReady = buildDocsIndex();\n return indexReady;\n }\n if (!indexReady) {\n indexReady = buildDocsIndex();\n }\n return indexReady;\n}\n\n// Eagerly start building the index on server startup (docs are static)\nindexReady = buildDocsIndex();\n\n/** Cached embeddings instance for search queries */\nlet cachedEmbeddings: ReturnType<typeof createEmbeddings> | null = null;\n\nfunction getEmbeddings() {\n if (!cachedEmbeddings) {\n cachedEmbeddings = createEmbeddings(getLLMConfig());\n }\n return cachedEmbeddings;\n}\n\n/**\n * Search documents using semantic similarity\n */\nexport async function searchDocs(\n query: string,\n limit = 6,\n): Promise<{ chunk: DocsChunk; score: number }[]> {\n await ensureDocsIndex();\n\n const queryVector = await getEmbeddings().embedQuery(query);\n\n const scored = docsIndex.chunks\n .map((c) => ({\n chunk: { id: c.id, text: c.text, path: c.path, uri: c.uri },\n score: cosineSim(queryVector, c.embedding),\n }))\n .sort((a, b) => b.score - a.score)\n .slice(0, limit);\n\n return scored;\n}\n\n/**\n * Get the current index status\n */\nexport function getIndexStatus(): { ready: boolean; chunkCount: number } {\n return {\n ready: docsIndex.ready,\n chunkCount: docsIndex.chunks.length,\n };\n}\n\n/**\n * Create and configure the MCP server with documentation tools\n */\nexport function createMCPServer(): McpServer {\n const server = new McpServer({\n name: \"docs-server\",\n version: \"1.0.0\",\n });\n\n // Register the search_docs tool\n server.tool(\n \"search_docs\",\n DOCS_TOOLS[0].description,\n {\n query: z\n .string()\n .describe(\"The search query to find relevant documentation\"),\n limit: z\n .number()\n .optional()\n .describe(\"Maximum number of results to return (default: 6)\"),\n },\n async ({ query, limit }) => {\n const results = await searchDocs(query, limit ?? 6);\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n results.map(({ chunk, score }) => ({\n path: chunk.path,\n uri: chunk.uri,\n score: score.toFixed(3),\n text: chunk.text,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register the get_doc tool\n server.tool(\n \"get_doc\",\n DOCS_TOOLS[1].description,\n {\n path: z.string().describe(\"The file path to the documentation page\"),\n },\n async ({ path }) => {\n const doc = await getDoc({ path });\n if (!doc) {\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify({ error: \"Document not found\" }),\n },\n ],\n isError: true,\n };\n }\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(doc, null, 2),\n },\n ],\n };\n },\n );\n\n // Register the list_docs tool\n server.tool(\n \"list_docs\",\n DOCS_TOOLS[2].description,\n {\n directory: z\n .string()\n .optional()\n .describe(\"Optional directory to filter results\"),\n },\n async ({ directory }) => {\n const docs = await listDocs({ directory });\n return {\n content: [\n {\n type: \"text\" as const,\n text: JSON.stringify(\n docs.map((d) => ({\n name: d.name,\n path: d.path,\n uri: d.uri,\n })),\n null,\n 2,\n ),\n },\n ],\n };\n },\n );\n\n // Register documentation as resources\n server.resource(\"docs://list\", \"docs://list\", async () => {\n const docs = await listDocs();\n return {\n contents: [\n {\n uri: \"docs://list\",\n text: JSON.stringify(\n docs.map((d) => ({ name: d.name, path: d.path, uri: d.uri })),\n null,\n 2,\n ),\n },\n ],\n };\n });\n\n return server;\n}\n";
|
|
@@ -10,7 +10,8 @@ import { getLLMConfig, createEmbeddings } from "@/services/llm";
|
|
|
10
10
|
import type { DocsChunk } from "@/services/mcp/types";
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* In-memory cache for document embeddings
|
|
13
|
+
* In-memory cache for document embeddings.
|
|
14
|
+
* Built once at server startup since docs are static.
|
|
14
15
|
*/
|
|
15
16
|
let docsIndex: {
|
|
16
17
|
ready: boolean;
|
|
@@ -22,6 +23,9 @@ let docsIndex: {
|
|
|
22
23
|
chunks: [],
|
|
23
24
|
};
|
|
24
25
|
|
|
26
|
+
/** Resolves when the initial index build completes */
|
|
27
|
+
let indexReady: Promise<void> | null = null;
|
|
28
|
+
|
|
25
29
|
/**
|
|
26
30
|
* Cosine similarity between two vectors
|
|
27
31
|
*/
|
|
@@ -76,22 +80,43 @@ export async function buildDocsIndex(force = false): Promise<void> {
|
|
|
76
80
|
embedding: vectors[i],
|
|
77
81
|
}));
|
|
78
82
|
docsIndex.ready = true;
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// Reset so the next call to ensureDocsIndex retries
|
|
85
|
+
indexReady = null;
|
|
86
|
+
throw error;
|
|
79
87
|
} finally {
|
|
80
88
|
docsIndex.building = false;
|
|
81
89
|
}
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
/**
|
|
85
|
-
* Ensure the docs index is ready
|
|
93
|
+
* Ensure the docs index is ready.
|
|
94
|
+
* On first call, triggers the build; subsequent calls wait for the same promise.
|
|
86
95
|
*/
|
|
87
96
|
export async function ensureDocsIndex(force = false): Promise<void> {
|
|
88
97
|
if (force) {
|
|
89
98
|
docsIndex.ready = false;
|
|
90
99
|
docsIndex.chunks = [];
|
|
100
|
+
indexReady = buildDocsIndex();
|
|
101
|
+
return indexReady;
|
|
102
|
+
}
|
|
103
|
+
if (!indexReady) {
|
|
104
|
+
indexReady = buildDocsIndex();
|
|
91
105
|
}
|
|
92
|
-
|
|
93
|
-
|
|
106
|
+
return indexReady;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Eagerly start building the index on server startup (docs are static)
|
|
110
|
+
indexReady = buildDocsIndex();
|
|
111
|
+
|
|
112
|
+
/** Cached embeddings instance for search queries */
|
|
113
|
+
let cachedEmbeddings: ReturnType<typeof createEmbeddings> | null = null;
|
|
114
|
+
|
|
115
|
+
function getEmbeddings() {
|
|
116
|
+
if (!cachedEmbeddings) {
|
|
117
|
+
cachedEmbeddings = createEmbeddings(getLLMConfig());
|
|
94
118
|
}
|
|
119
|
+
return cachedEmbeddings;
|
|
95
120
|
}
|
|
96
121
|
|
|
97
122
|
/**
|
|
@@ -103,9 +128,7 @@ export async function searchDocs(
|
|
|
103
128
|
): Promise<{ chunk: DocsChunk; score: number }[]> {
|
|
104
129
|
await ensureDocsIndex();
|
|
105
130
|
|
|
106
|
-
const
|
|
107
|
-
const embeddings = createEmbeddings(config);
|
|
108
|
-
const queryVector = await embeddings.embedQuery(query);
|
|
131
|
+
const queryVector = await getEmbeddings().embedQuery(query);
|
|
109
132
|
|
|
110
133
|
const scored = docsIndex.chunks
|
|
111
134
|
.map((c) => ({
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const mcpToolsTemplate = "import path from \"node:path\";\nimport fs from \"node:fs/promises\";\nimport type {\n MCPToolDefinition,\n DocsResource,\n DocsChunk,\n GetDocParams,\n ListDocsParams,\n} from \"@/services/mcp/types\";\n\nconst PROJECT_ROOT = process.cwd();\nconst APP_DIR = path.join(PROJECT_ROOT, \"app\");\nconst VALID_EXT = new Set([\".ts\", \".tsx\", \".js\", \".jsx\"]);\n\n/**\n * Tool definitions for MCP - these describe the available tools\n */\nexport const DOCS_TOOLS: MCPToolDefinition[] = [\n {\n name: \"search_docs\",\n description:\n \"Search through the documentation content using semantic search. Returns relevant chunks of documentation based on the query.\",\n inputSchema: {\n type: \"object\",\n properties: {\n query: {\n type: \"string\",\n description: \"The search query to find relevant documentation\",\n },\n limit: {\n type: \"number\",\n description: \"Maximum number of results to return (default: 6)\",\n },\n },\n required: [\"query\"],\n },\n },\n {\n name: \"get_doc\",\n description:\n \"Get the full content of a specific documentation page by its path.\",\n inputSchema: {\n type: \"object\",\n properties: {\n path: {\n type: \"string\",\n description:\n \"The file path to the documentation page (e.g., 'app/getting-started/page.tsx')\",\n },\n },\n required: [\"path\"],\n },\n },\n {\n name: \"list_docs\",\n description:\n \"List all available documentation pages, optionally filtered by directory.\",\n inputSchema: {\n type: \"object\",\n properties: {\n directory: {\n type: \"string\",\n description:\n \"Optional directory to filter results (e.g., 'components')\",\n },\n },\n },\n },\n];\n\n/**\n * Recursively walk directory to find documentation files\n */\nasync function* walkDocs(dir: string): AsyncGenerator<string> {\n const entries = await fs.readdir(dir, { withFileTypes: true });\n for (const entry of entries) {\n const fullPath = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n if ([\"node_modules\", \".next\", \".git\", \"api\"].includes(entry.name)) {\n continue;\n }\n yield* walkDocs(fullPath);\n } else {\n const ext = path.extname(entry.name).toLowerCase();\n if (VALID_EXT.has(ext) && entry.name.startsWith(\"page.\")) {\n yield fullPath;\n }\n }\n }\n}\n\n/**\n * Extract content blocks from a file\n */\nfunction extractContentBlocks(fileText: string): string[] {\n const results: string[] = [];\n\n const tplRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*`((?:\\\\`|[^`])*)`\\s*;/g;\n let m: RegExpExecArray | null;\n while ((m = tplRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n const sglRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*'([^']*)'\\s*;/g;\n while ((m = sglRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n const dblRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*\"([^\"]*)\"\\s*;/g;\n while ((m = dblRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n return results;\n}\n\n/**\n * Get the title from markdown content\n */\nfunction extractTitle(content: string): string {\n const match = content.match(/^#\\s+(.+)$/m);\n return match ? match[1].trim() : \"Untitled\";\n}\n\n/**\n * List all documentation resources\n */\nexport async function listDocs(\n params?: ListDocsParams,\n): Promise<DocsResource[]> {\n const resources: DocsResource[] = [];\n const filterDir = params?.directory;\n\n for await (const filePath of walkDocs(APP_DIR)) {\n const relativePath = path.relative(PROJECT_ROOT, filePath);\n\n if (filterDir && !relativePath.includes(filterDir)) {\n continue;\n }\n\n try {\n const fileContent = await fs.readFile(filePath, \"utf8\");\n const blocks = extractContentBlocks(fileContent);\n const content = blocks.join(\"\\n\\n\");\n const title = extractTitle(content);\n const docPath = path.dirname(relativePath).replace(/^app\\/?/, \"\") || \"/\";\n\n resources.push({\n uri: `docs://${docPath}`,\n name: title,\n path: relativePath,\n content,\n });\n } catch (error) {\n console.warn(`Failed to read doc file: ${filePath}`, error);\n }\n }\n\n return resources;\n}\n\n/**\n * Get a specific documentation page\n */\nexport async function getDoc(\n params: GetDocParams,\n): Promise<DocsResource | null> {\n let targetPath = params.path;\n\n // Normalize path\n if (!targetPath.startsWith(\"app/\")) {\n targetPath = `app/${targetPath}`;\n }\n if (!targetPath.includes(\"page.\")) {\n targetPath = path.join(targetPath, \"page.tsx\");\n }\n\n const fullPath = path.join(PROJECT_ROOT, targetPath);\n\n // Prevent path traversal\n const resolvedPath = path.resolve(fullPath);\n if (!resolvedPath.startsWith(path.resolve(APP_DIR))) {\n return null;\n }\n\n try {\n const fileContent = await fs.readFile(fullPath, \"utf8\");\n const blocks = extractContentBlocks(fileContent);\n const content = blocks.join(\"\\n\\n\");\n const title = extractTitle(content);\n const docPath = path.dirname(targetPath).replace(/^app\\/?/, \"\") || \"/\";\n\n return {\n uri: `docs://${docPath}`,\n name: title,\n path: targetPath,\n content,\n };\n } catch (error) {\n console.warn(`Failed to read doc: ${targetPath}`, error);\n return null;\n }\n}\n\n/**\n * Chunk text for embeddings\n */\nexport function chunkText(\n text: string,\n chunkSize = 800,\n overlap = 100,\n): string[] {\n const chunks: string[] = [];\n let i = 0;\n while (i < text.length) {\n const end = Math.min(i + chunkSize, text.length);\n chunks.push(text.slice(i, end));\n if (end === text.length) break;\n i = end - overlap;\n if (i < 0) i = 0;\n }\n return chunks;\n}\n\n/**\n * Get all documentation chunks for indexing\n */\nexport async function getAllDocsChunks(): Promise<DocsChunk[]> {\n const allChunks: DocsChunk[] = [];\n const docs = await listDocs();\n\n for (const doc of docs) {\n const cleanContent = doc.content\n .replace(/\\r\\n/g, \"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .slice(0, 200_000);\n\n const textChunks = chunkText(cleanContent);\n for (let i = 0; i < textChunks.length; i++) {\n allChunks.push({\n id: `${doc.path}:${i}`,\n text: textChunks[i],\n path: doc.path,\n uri: doc.uri,\n });\n }\n }\n\n return allChunks;\n}\n";
|
|
1
|
+
export declare const mcpToolsTemplate = "import path from \"node:path\";\nimport fs from \"node:fs/promises\";\nimport type {\n MCPToolDefinition,\n DocsResource,\n DocsChunk,\n GetDocParams,\n ListDocsParams,\n} from \"@/services/mcp/types\";\n\nconst PROJECT_ROOT = process.cwd();\nconst APP_DIR = path.join(PROJECT_ROOT, \"app\");\nconst VALID_EXT = new Set([\".ts\", \".tsx\", \".js\", \".jsx\"]);\n\n/**\n * Tool definitions for MCP - these describe the available tools\n */\nexport const DOCS_TOOLS: MCPToolDefinition[] = [\n {\n name: \"search_docs\",\n description:\n \"Search through the documentation content using semantic search. Returns relevant chunks of documentation based on the query.\",\n inputSchema: {\n type: \"object\",\n properties: {\n query: {\n type: \"string\",\n description: \"The search query to find relevant documentation\",\n },\n limit: {\n type: \"number\",\n description: \"Maximum number of results to return (default: 6)\",\n },\n },\n required: [\"query\"],\n },\n },\n {\n name: \"get_doc\",\n description:\n \"Get the full content of a specific documentation page by its path.\",\n inputSchema: {\n type: \"object\",\n properties: {\n path: {\n type: \"string\",\n description:\n \"The file path to the documentation page (e.g., 'app/getting-started/page.tsx')\",\n },\n },\n required: [\"path\"],\n },\n },\n {\n name: \"list_docs\",\n description:\n \"List all available documentation pages, optionally filtered by directory.\",\n inputSchema: {\n type: \"object\",\n properties: {\n directory: {\n type: \"string\",\n description:\n \"Optional directory to filter results (e.g., 'components')\",\n },\n },\n },\n },\n];\n\n/**\n * Recursively walk directory to find documentation files\n */\nasync function* walkDocs(dir: string): AsyncGenerator<string> {\n const entries = await fs.readdir(dir, { withFileTypes: true });\n for (const entry of entries) {\n const fullPath = path.join(dir, entry.name);\n if (entry.isDirectory()) {\n if ([\"node_modules\", \".next\", \".git\", \"api\"].includes(entry.name)) {\n continue;\n }\n yield* walkDocs(fullPath);\n } else {\n const ext = path.extname(entry.name).toLowerCase();\n if (VALID_EXT.has(ext) && entry.name.startsWith(\"page.\")) {\n yield fullPath;\n }\n }\n }\n}\n\n/**\n * Extract content blocks from a file\n */\nfunction extractContentBlocks(fileText: string): string[] {\n const results: string[] = [];\n\n const tplRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*`((?:\\\\`|[^`])*)`\\s*;/g;\n let m: RegExpExecArray | null;\n while ((m = tplRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n const sglRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*'([^']*)'\\s*;/g;\n while ((m = sglRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n const dblRegex = /(?:export\\s+)?const\\s+content\\s*=\\s*\"([^\"]*)\"\\s*;/g;\n while ((m = dblRegex.exec(fileText)) !== null) {\n results.push(m[1]);\n }\n\n return results;\n}\n\n/**\n * Get the title from markdown content\n */\nfunction extractTitle(content: string): string {\n const match = content.match(/^#\\s+(.+)$/m);\n return match ? match[1].trim() : \"Untitled\";\n}\n\n/**\n * List all documentation resources\n */\nexport async function listDocs(\n params?: ListDocsParams,\n): Promise<DocsResource[]> {\n const resources: DocsResource[] = [];\n const filterDir = params?.directory;\n\n for await (const filePath of walkDocs(APP_DIR)) {\n const relativePath = path.relative(PROJECT_ROOT, filePath);\n\n if (filterDir && !relativePath.includes(filterDir)) {\n continue;\n }\n\n try {\n const fileContent = await fs.readFile(filePath, \"utf8\");\n const blocks = extractContentBlocks(fileContent);\n const content = blocks.join(\"\\n\\n\");\n const title = extractTitle(content);\n const docPath = path.dirname(relativePath).replace(/^app\\/?/, \"\") || \"/\";\n\n resources.push({\n uri: `docs://${docPath}`,\n name: title,\n path: relativePath,\n content,\n });\n } catch (error) {\n console.warn(`Failed to read doc file: ${filePath}`, error);\n }\n }\n\n return resources;\n}\n\n/**\n * Get a specific documentation page\n */\nexport async function getDoc(\n params: GetDocParams,\n): Promise<DocsResource | null> {\n let targetPath = params.path;\n\n // Normalize path\n if (!targetPath.startsWith(\"app/\")) {\n targetPath = `app/${targetPath}`;\n }\n if (!targetPath.includes(\"page.\")) {\n targetPath = path.join(targetPath, \"page.tsx\");\n }\n\n const fullPath = path.join(PROJECT_ROOT, targetPath);\n\n // Prevent path traversal\n const resolvedPath = path.resolve(fullPath);\n if (!resolvedPath.startsWith(path.resolve(APP_DIR))) {\n return null;\n }\n\n try {\n const fileContent = await fs.readFile(fullPath, \"utf8\");\n const blocks = extractContentBlocks(fileContent);\n const content = blocks.join(\"\\n\\n\");\n const title = extractTitle(content);\n const docPath = path.dirname(targetPath).replace(/^app\\/?/, \"\") || \"/\";\n\n return {\n uri: `docs://${docPath}`,\n name: title,\n path: targetPath,\n content,\n };\n } catch (error) {\n console.warn(`Failed to read doc: ${targetPath}`, error);\n return null;\n }\n}\n\n/**\n * Chunk text for embeddings.\n * - chunkSize=800 chars balances granularity with embedding context window limits\n * - overlap=100 chars ensures continuity so searches don't miss content at chunk boundaries\n */\nexport function chunkText(\n text: string,\n chunkSize = 800,\n overlap = 100,\n): string[] {\n const chunks: string[] = [];\n let i = 0;\n while (i < text.length) {\n const end = Math.min(i + chunkSize, text.length);\n chunks.push(text.slice(i, end));\n if (end === text.length) break;\n i = end - overlap;\n if (i < 0) i = 0;\n }\n return chunks;\n}\n\n/**\n * Get all documentation chunks for indexing\n */\nexport async function getAllDocsChunks(): Promise<DocsChunk[]> {\n const allChunks: DocsChunk[] = [];\n const docs = await listDocs();\n\n for (const doc of docs) {\n const cleanContent = doc.content\n .replace(/\\r\\n/g, \"\\n\")\n .replace(/\\n{3,}/g, \"\\n\\n\")\n .slice(0, 200_000);\n\n const textChunks = chunkText(cleanContent);\n for (let i = 0; i < textChunks.length; i++) {\n allChunks.push({\n id: `${doc.path}:${i}`,\n text: textChunks[i],\n path: doc.path,\n uri: doc.uri,\n });\n }\n }\n\n return allChunks;\n}\n";
|
|
@@ -203,7 +203,9 @@ export async function getDoc(
|
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
/**
|
|
206
|
-
* Chunk text for embeddings
|
|
206
|
+
* Chunk text for embeddings.
|
|
207
|
+
* - chunkSize=800 chars balances granularity with embedding context window limits
|
|
208
|
+
* - overlap=100 chars ensures continuity so searches don't miss content at chunk boundaries
|
|
207
209
|
*/
|
|
208
210
|
export function chunkText(
|
|
209
211
|
text: string,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const mcpTypesTemplate = "export interface
|
|
1
|
+
export declare const mcpTypesTemplate = "export interface DocsResource {\n uri: string;\n name: string;\n path: string;\n content: string;\n}\n\nexport interface DocsChunk {\n id: string;\n text: string;\n path: string;\n uri: string;\n}\n\nexport interface GetDocParams {\n path: string;\n}\n\nexport interface ListDocsParams {\n directory?: string;\n}\n\nexport type MCPToolName = \"search_docs\" | \"get_doc\" | \"list_docs\";\n\nexport interface MCPToolDefinition {\n name: MCPToolName;\n description: string;\n inputSchema: {\n type: \"object\";\n properties: Record<string, unknown>;\n required?: string[];\n };\n}\n";
|
|
@@ -1,9 +1,4 @@
|
|
|
1
|
-
export const mcpTypesTemplate = `export interface
|
|
2
|
-
content: string;
|
|
3
|
-
metadata?: Record<string, unknown>;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
export interface DocsResource {
|
|
1
|
+
export const mcpTypesTemplate = `export interface DocsResource {
|
|
7
2
|
uri: string;
|
|
8
3
|
name: string;
|
|
9
4
|
path: string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const configTemplate = "import { z } from \"zod\";\nimport configData from \"@/config.json\";\n\nconst configSchema = z.object({\n name: z.string().optional(),\n description: z.string().optional(),\n icon: z.string().optional(),\n preview: z.string().optional(),\n});\n\nexport type Config = z.infer<typeof configSchema>;\n\nexport const config: Config = configSchema.parse(configData);\n";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export const configTemplate = `import { z } from "zod";
|
|
2
|
+
import configData from "@/config.json";
|
|
3
|
+
|
|
4
|
+
const configSchema = z.object({
|
|
5
|
+
name: z.string().optional(),
|
|
6
|
+
description: z.string().optional(),
|
|
7
|
+
icon: z.string().optional(),
|
|
8
|
+
preview: z.string().optional(),
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export type Config = z.infer<typeof configSchema>;
|
|
12
|
+
|
|
13
|
+
export const config: Config = configSchema.parse(configData);
|
|
14
|
+
`;
|
package/package.json
CHANGED