jamdesk 1.0.13 → 1.0.14
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/README.md +89 -4
- package/dist/__tests__/unit/auth.test.d.ts +2 -0
- package/dist/__tests__/unit/auth.test.d.ts.map +1 -0
- package/dist/__tests__/unit/auth.test.js +169 -0
- package/dist/__tests__/unit/auth.test.js.map +1 -0
- package/dist/__tests__/unit/config.test.d.ts +2 -0
- package/dist/__tests__/unit/config.test.d.ts.map +1 -0
- package/dist/__tests__/unit/config.test.js +76 -0
- package/dist/__tests__/unit/config.test.js.map +1 -0
- package/dist/__tests__/unit/deploy.test.d.ts +2 -0
- package/dist/__tests__/unit/deploy.test.d.ts.map +1 -0
- package/dist/__tests__/unit/deploy.test.js +273 -0
- package/dist/__tests__/unit/deploy.test.js.map +1 -0
- package/dist/__tests__/unit/deps-sync.test.js +3 -1
- package/dist/__tests__/unit/deps-sync.test.js.map +1 -1
- package/dist/__tests__/unit/dev-loading-server.test.d.ts +2 -0
- package/dist/__tests__/unit/dev-loading-server.test.d.ts.map +1 -0
- package/dist/__tests__/unit/dev-loading-server.test.js +141 -0
- package/dist/__tests__/unit/dev-loading-server.test.js.map +1 -0
- package/dist/__tests__/unit/docs-json-writer.test.d.ts +2 -0
- package/dist/__tests__/unit/docs-json-writer.test.d.ts.map +1 -0
- package/dist/__tests__/unit/docs-json-writer.test.js +71 -0
- package/dist/__tests__/unit/docs-json-writer.test.js.map +1 -0
- package/dist/__tests__/unit/loading-page.test.d.ts +2 -0
- package/dist/__tests__/unit/loading-page.test.d.ts.map +1 -0
- package/dist/__tests__/unit/loading-page.test.js +73 -0
- package/dist/__tests__/unit/loading-page.test.js.map +1 -0
- package/dist/__tests__/unit/login.test.d.ts +2 -0
- package/dist/__tests__/unit/login.test.d.ts.map +1 -0
- package/dist/__tests__/unit/login.test.js +100 -0
- package/dist/__tests__/unit/login.test.js.map +1 -0
- package/dist/__tests__/unit/logout.test.d.ts +2 -0
- package/dist/__tests__/unit/logout.test.d.ts.map +1 -0
- package/dist/__tests__/unit/logout.test.js +39 -0
- package/dist/__tests__/unit/logout.test.js.map +1 -0
- package/dist/__tests__/unit/tarball.test.d.ts +2 -0
- package/dist/__tests__/unit/tarball.test.d.ts.map +1 -0
- package/dist/__tests__/unit/tarball.test.js +126 -0
- package/dist/__tests__/unit/tarball.test.js.map +1 -0
- package/dist/__tests__/unit/whoami.test.d.ts +2 -0
- package/dist/__tests__/unit/whoami.test.d.ts.map +1 -0
- package/dist/__tests__/unit/whoami.test.js +47 -0
- package/dist/__tests__/unit/whoami.test.js.map +1 -0
- package/dist/commands/deploy.d.ts +13 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +265 -0
- package/dist/commands/deploy.js.map +1 -0
- package/dist/commands/dev.d.ts.map +1 -1
- package/dist/commands/dev.js +48 -25
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/login.d.ts +8 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/login.js +135 -0
- package/dist/commands/login.js.map +1 -0
- package/dist/commands/logout.d.ts +5 -0
- package/dist/commands/logout.d.ts.map +1 -0
- package/dist/commands/logout.js +17 -0
- package/dist/commands/logout.js.map +1 -0
- package/dist/commands/whoami.d.ts +5 -0
- package/dist/commands/whoami.d.ts.map +1 -0
- package/dist/commands/whoami.js +24 -0
- package/dist/commands/whoami.js.map +1 -0
- package/dist/index.js +50 -7
- package/dist/index.js.map +1 -1
- package/dist/lib/auth.d.ts +34 -0
- package/dist/lib/auth.d.ts.map +1 -0
- package/dist/lib/auth.js +105 -0
- package/dist/lib/auth.js.map +1 -0
- package/dist/lib/config.d.ts +9 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +7 -1
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/dev-loading-server.d.ts +22 -0
- package/dist/lib/dev-loading-server.d.ts.map +1 -0
- package/dist/lib/dev-loading-server.js +117 -0
- package/dist/lib/dev-loading-server.js.map +1 -0
- package/dist/lib/docs-config.d.ts +1 -0
- package/dist/lib/docs-config.d.ts.map +1 -1
- package/dist/lib/docs-config.js.map +1 -1
- package/dist/lib/docs-json-writer.d.ts +2 -0
- package/dist/lib/docs-json-writer.d.ts.map +1 -0
- package/dist/lib/docs-json-writer.js +35 -0
- package/dist/lib/docs-json-writer.js.map +1 -0
- package/dist/lib/loading-page.d.ts +11 -0
- package/dist/lib/loading-page.d.ts.map +1 -0
- package/dist/lib/loading-page.js +222 -0
- package/dist/lib/loading-page.js.map +1 -0
- package/dist/lib/output.d.ts +13 -5
- package/dist/lib/output.d.ts.map +1 -1
- package/dist/lib/output.js +22 -5
- package/dist/lib/output.js.map +1 -1
- package/dist/lib/tarball.d.ts +28 -0
- package/dist/lib/tarball.d.ts.map +1 -0
- package/dist/lib/tarball.js +117 -0
- package/dist/lib/tarball.js.map +1 -0
- package/package.json +5 -2
- package/vendored/app/[[...slug]]/page.tsx +6 -20
- package/vendored/app/api/chat/[project]/route.ts +323 -0
- package/vendored/app/api/mcp/[project]/route.ts +2 -63
- package/vendored/components/chat/ChatCodeBlock.tsx +63 -0
- package/vendored/components/chat/ChatEmptyState.tsx +79 -0
- package/vendored/components/chat/ChatFAB.tsx +36 -0
- package/vendored/components/chat/ChatInput.tsx +106 -0
- package/vendored/components/chat/ChatMessage.tsx +176 -0
- package/vendored/components/chat/ChatPanel.tsx +206 -0
- package/vendored/components/chat/ChatResizeHandle.tsx +108 -0
- package/vendored/components/chat/LazyChatPanel.tsx +19 -0
- package/vendored/components/layout/LayoutWrapper.tsx +134 -44
- package/vendored/components/layout/PageColumns.tsx +40 -0
- package/vendored/components/navigation/Header.tsx +74 -29
- package/vendored/components/navigation/Sidebar.tsx +17 -2
- package/vendored/hooks/useChat.ts +335 -0
- package/vendored/hooks/useChatPanel.tsx +101 -0
- package/vendored/lib/anthropic-client.ts +19 -0
- package/vendored/lib/build/extract-tarball.ts +150 -0
- package/vendored/lib/chat-prompt.ts +56 -0
- package/vendored/lib/docs-types.ts +14 -0
- package/vendored/lib/docs.ts +22 -4
- package/vendored/lib/embedding-chunker.ts +173 -0
- package/vendored/lib/generate-starter-questions.ts +98 -0
- package/vendored/lib/isr-build-executor.ts +2 -1
- package/vendored/lib/middleware-helpers.ts +21 -0
- package/vendored/lib/route-helpers.ts +96 -0
- package/vendored/lib/snippet-loader-isr.ts +107 -1
- package/vendored/lib/static-artifacts.ts +3 -2
- package/vendored/lib/validate-config.ts +1 -0
- package/vendored/lib/vector-store.ts +213 -0
- package/vendored/schema/docs-schema.json +33 -0
- package/vendored/scripts/dev-project.cjs +6 -0
- package/vendored/shared/types.ts +6 -5
- package/vendored/tailwind.config.ts +9 -0
- package/vendored/themes/jam/variables.css +2 -2
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tarball utility — package docs directory for CLI deploy.
|
|
3
|
+
*
|
|
4
|
+
* Creates a gzipped tarball of the docs directory, respecting .gitignore
|
|
5
|
+
* and excluding sensitive files. Used by the deploy command.
|
|
6
|
+
*/
|
|
7
|
+
import { create } from 'tar';
|
|
8
|
+
import ignore from 'ignore';
|
|
9
|
+
import fs from 'fs/promises';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { CLIError } from './errors.js';
|
|
12
|
+
const MAX_SIZE_BYTES = 100 * 1024 * 1024; // 100 MB
|
|
13
|
+
/** Patterns always excluded from the tarball, regardless of .gitignore. */
|
|
14
|
+
const ALWAYS_EXCLUDED = [
|
|
15
|
+
'.git',
|
|
16
|
+
'node_modules',
|
|
17
|
+
'.next',
|
|
18
|
+
'.env',
|
|
19
|
+
'.env.*',
|
|
20
|
+
'*.pem',
|
|
21
|
+
'*.key',
|
|
22
|
+
'credentials.json',
|
|
23
|
+
'.gcloud',
|
|
24
|
+
'.DS_Store',
|
|
25
|
+
'Thumbs.db',
|
|
26
|
+
];
|
|
27
|
+
/** Filenames that suggest secrets — warn user but don't block. */
|
|
28
|
+
const SECRET_PATTERNS = [
|
|
29
|
+
/^\.env$/,
|
|
30
|
+
/^\.env\..+$/,
|
|
31
|
+
/\.pem$/,
|
|
32
|
+
/\.key$/,
|
|
33
|
+
/^credentials\.json$/,
|
|
34
|
+
/^service[_-]?account.*\.json$/i,
|
|
35
|
+
/^secret/i,
|
|
36
|
+
];
|
|
37
|
+
export function getExcludedPatterns() {
|
|
38
|
+
return [...ALWAYS_EXCLUDED];
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Scan file list for suspicious filenames that may contain secrets.
|
|
42
|
+
* Returns filenames that match secret patterns (for user warning).
|
|
43
|
+
*/
|
|
44
|
+
export function checkForSecrets(files) {
|
|
45
|
+
return files.filter((file) => {
|
|
46
|
+
const basename = path.basename(file);
|
|
47
|
+
return SECRET_PATTERNS.some((p) => p.test(basename));
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Collect files to include in the tarball.
|
|
52
|
+
* Applies .gitignore rules and always-excluded patterns.
|
|
53
|
+
*/
|
|
54
|
+
export async function collectFiles(dir) {
|
|
55
|
+
const ig = ignore().add(ALWAYS_EXCLUDED);
|
|
56
|
+
// Parse .gitignore if present
|
|
57
|
+
try {
|
|
58
|
+
const gitignoreContent = await fs.readFile(path.join(dir, '.gitignore'), 'utf-8');
|
|
59
|
+
ig.add(gitignoreContent);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// No .gitignore — that's fine
|
|
63
|
+
}
|
|
64
|
+
const files = [];
|
|
65
|
+
async function walk(currentDir) {
|
|
66
|
+
const entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
69
|
+
const relativePath = path.relative(dir, fullPath);
|
|
70
|
+
// Use forward slashes for ignore matching (cross-platform)
|
|
71
|
+
const posixPath = relativePath.split(path.sep).join('/');
|
|
72
|
+
if (ig.ignores(posixPath))
|
|
73
|
+
continue;
|
|
74
|
+
if (entry.isDirectory()) {
|
|
75
|
+
// Check directory itself before recursing
|
|
76
|
+
if (ig.ignores(posixPath + '/'))
|
|
77
|
+
continue;
|
|
78
|
+
await walk(fullPath);
|
|
79
|
+
}
|
|
80
|
+
else if (entry.isFile()) {
|
|
81
|
+
files.push(relativePath);
|
|
82
|
+
}
|
|
83
|
+
// Skip symlinks and other special entries
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
await walk(dir);
|
|
87
|
+
return files.sort();
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Create a gzipped tarball of the docs directory.
|
|
91
|
+
* Respects .gitignore and excludes sensitive files.
|
|
92
|
+
*/
|
|
93
|
+
export async function createTarball(dir) {
|
|
94
|
+
const files = await collectFiles(dir);
|
|
95
|
+
if (files.length === 0) {
|
|
96
|
+
throw new CLIError('No files to deploy.', 'EMPTY_PROJECT', 1, 'Make sure you are in a Jamdesk project directory with docs.json and MDX files.');
|
|
97
|
+
}
|
|
98
|
+
const chunks = [];
|
|
99
|
+
let totalSize = 0;
|
|
100
|
+
const stream = create({ gzip: true, cwd: dir }, files);
|
|
101
|
+
for await (const chunk of stream) {
|
|
102
|
+
const buf = Buffer.from(chunk);
|
|
103
|
+
totalSize += buf.length;
|
|
104
|
+
if (totalSize > MAX_SIZE_BYTES) {
|
|
105
|
+
const sizeMB = (totalSize / (1024 * 1024)).toFixed(1);
|
|
106
|
+
throw new CLIError(`Project too large (>${sizeMB} MB). Maximum upload size is 100 MB.`, 'PROJECT_TOO_LARGE', 1, 'Exclude large files via .gitignore or remove unnecessary assets.');
|
|
107
|
+
}
|
|
108
|
+
chunks.push(buf);
|
|
109
|
+
}
|
|
110
|
+
const buffer = Buffer.concat(chunks);
|
|
111
|
+
return {
|
|
112
|
+
buffer,
|
|
113
|
+
fileCount: files.length,
|
|
114
|
+
size: buffer.length,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=tarball.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tarball.js","sourceRoot":"","sources":["../../src/lib/tarball.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jamdesk",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.14",
|
|
4
4
|
"description": "CLI for Jamdesk — build, preview, and deploy documentation sites from MDX. Dev server with hot reload, 50+ components, OpenAPI support, AI search, and Mintlify migration",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"jamdesk",
|
|
@@ -101,8 +101,11 @@
|
|
|
101
101
|
"fs-extra": "^11.2.0",
|
|
102
102
|
"glob": "^13.0.6",
|
|
103
103
|
"gray-matter": "^4.0.3",
|
|
104
|
+
"ignore": "^7.0.5",
|
|
104
105
|
"json5": "^2.2.3",
|
|
105
|
-
"
|
|
106
|
+
"open": "^11.0.0",
|
|
107
|
+
"ora": "^9.3.0",
|
|
108
|
+
"tar": "^7.5.9"
|
|
106
109
|
},
|
|
107
110
|
"devDependencies": {
|
|
108
111
|
"@mdx-js/mdx": "^3.1.1",
|
|
@@ -22,6 +22,7 @@ export const dynamicParams = true; // Allow paths not in generateStaticParams
|
|
|
22
22
|
import { MDXComponents } from '@/components/mdx/MDXComponents';
|
|
23
23
|
import { Breadcrumb } from '@/components/navigation/Breadcrumb';
|
|
24
24
|
import { TableOfContents } from '@/components/navigation/TableOfContents';
|
|
25
|
+
import { PageColumns } from '@/components/layout/PageColumns';
|
|
25
26
|
import { PageNavigation } from '@/components/navigation/PageNavigation';
|
|
26
27
|
import { SocialFooter } from '@/components/navigation/SocialFooter';
|
|
27
28
|
import { ApiPageWrapper } from '@/components/mdx/ApiPage';
|
|
@@ -690,26 +691,11 @@ export default async function DocPage({ params }: PageProps) {
|
|
|
690
691
|
return <PanelWrapper>{articleContent}</PanelWrapper>;
|
|
691
692
|
}
|
|
692
693
|
|
|
693
|
-
// Non-API pages: standard layout with Table of Contents
|
|
694
|
-
//
|
|
694
|
+
// Non-API pages: standard layout with Table of Contents (or inline chat)
|
|
695
|
+
// PageColumns handles TOC ↔ chat column switching on xl+ screens
|
|
695
696
|
return (
|
|
696
|
-
<
|
|
697
|
-
{
|
|
698
|
-
|
|
699
|
-
id="content-scroll-container"
|
|
700
|
-
className="flex-1 min-w-0 lg:overflow-y-auto lg:h-full content-scroll"
|
|
701
|
-
>
|
|
702
|
-
{articleContent}
|
|
703
|
-
</div>
|
|
704
|
-
|
|
705
|
-
{/* Table of Contents - hidden when mode: "wide" is set in frontmatter */}
|
|
706
|
-
{!isWideMode && (
|
|
707
|
-
<aside className="hidden xl:block w-72 flex-shrink-0 xl:h-full xl:overflow-y-auto toc-scroll xl:ml-0.5 pr-2">
|
|
708
|
-
<div className="py-6 sm:py-10 pr-4">
|
|
709
|
-
<TableOfContents content={content} />
|
|
710
|
-
</div>
|
|
711
|
-
</aside>
|
|
712
|
-
)}
|
|
713
|
-
</div>
|
|
697
|
+
<PageColumns toc={<TableOfContents content={content} />} isWideMode={isWideMode}>
|
|
698
|
+
{articleContent}
|
|
699
|
+
</PageColumns>
|
|
714
700
|
);
|
|
715
701
|
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat API Route for AI Documentation Q&A
|
|
3
|
+
*
|
|
4
|
+
* Handles POST requests for AI-powered documentation chat. Uses Upstash Vector
|
|
5
|
+
* for RAG context retrieval and Claude Haiku 4.5 for streaming responses.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - SSE streaming (text chunks → clarification options → filtered citations → done)
|
|
9
|
+
* - Post-response citation filtering: only includes pages Claude actually referenced
|
|
10
|
+
* - Redis rate limiting (persistent across cold starts)
|
|
11
|
+
* - Conversation history support (capped at 10 messages)
|
|
12
|
+
*
|
|
13
|
+
* Security: No CORS headers — this endpoint is same-origin only.
|
|
14
|
+
* The docs site calls /_chat which middleware rewrites to /api/chat/{project}
|
|
15
|
+
* on the same origin. Cross-origin requests from other sites are blocked
|
|
16
|
+
* by the browser's same-origin policy.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* POST https://acme.jamdesk.app/_chat
|
|
20
|
+
* Content-Type: application/json
|
|
21
|
+
* {"message": "How do I authenticate?", "history": []}
|
|
22
|
+
*/
|
|
23
|
+
import { NextRequest } from 'next/server';
|
|
24
|
+
import { querySimilarChunks } from '@/lib/vector-store';
|
|
25
|
+
import { buildSystemPrompt } from '@/lib/chat-prompt';
|
|
26
|
+
import { getDocsPath, getBaseUrl, trackChatAnalytics } from '@/lib/route-helpers';
|
|
27
|
+
import { getAnthropicClient } from '@/lib/anthropic-client';
|
|
28
|
+
import { redis } from '@/lib/redis';
|
|
29
|
+
|
|
30
|
+
export const runtime = 'nodejs';
|
|
31
|
+
export const maxDuration = 30;
|
|
32
|
+
|
|
33
|
+
const CHAT_MODEL = 'claude-haiku-4-5-20251001';
|
|
34
|
+
const RATE_LIMIT = 10;
|
|
35
|
+
const RATE_WINDOW_SECONDS = 60;
|
|
36
|
+
const MAX_HISTORY = 10;
|
|
37
|
+
const VALID_ROLES = new Set<string>(['user', 'assistant']);
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Extract citations from Claude's response by matching [Title] references
|
|
41
|
+
* against the vector search chunks. Deduplicates by page slug.
|
|
42
|
+
* Falls back to top 2 unique pages by score if Claude didn't cite anything.
|
|
43
|
+
*/
|
|
44
|
+
type Citation = { title: string; slug: string; section?: string };
|
|
45
|
+
type ScoredChunk = { pageSlug: string; pageTitle: string; sectionHeading: string; score: number };
|
|
46
|
+
|
|
47
|
+
function extractCitations(
|
|
48
|
+
responseText: string,
|
|
49
|
+
chunks: ScoredChunk[],
|
|
50
|
+
): { sources: Citation[]; hadExplicitCitations: boolean } {
|
|
51
|
+
// Match [Title] but not markdown links [text](url)
|
|
52
|
+
const referencedTitles = new Set(
|
|
53
|
+
Array.from(responseText.matchAll(/\[([^\]]+)\](?!\()/g), (m) => m[1]),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
const seen = new Set<string>();
|
|
57
|
+
const sources: Citation[] = [];
|
|
58
|
+
|
|
59
|
+
function addUniqueChunk(c: (typeof chunks)[number]): void {
|
|
60
|
+
if (seen.has(c.pageSlug)) return;
|
|
61
|
+
seen.add(c.pageSlug);
|
|
62
|
+
sources.push({ title: c.pageTitle, slug: c.pageSlug, section: c.sectionHeading });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Filter chunks to only those Claude referenced, deduplicate by slug
|
|
66
|
+
for (const c of chunks) {
|
|
67
|
+
if (referencedTitles.has(c.pageTitle)) addUniqueChunk(c);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Record whether Claude explicitly cited sources before the fallback
|
|
71
|
+
const hadExplicitCitations = sources.length > 0;
|
|
72
|
+
|
|
73
|
+
// Fallback: if Claude didn't cite anything explicitly, show top 2 unique pages by score
|
|
74
|
+
if (sources.length === 0) {
|
|
75
|
+
for (const c of chunks) {
|
|
76
|
+
addUniqueChunk(c);
|
|
77
|
+
if (sources.length >= 2) break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { sources, hadExplicitCitations };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Detect option lists in Claude's response that indicate a clarification question.
|
|
86
|
+
* Returns extracted option strings, or null if the response is a normal answer.
|
|
87
|
+
*
|
|
88
|
+
* Heuristic guards to avoid false positives on instructional lists:
|
|
89
|
+
* - Response must contain a question mark or colon (clarification Qs ask something)
|
|
90
|
+
* - Response must be short (< 500 chars — clarification Qs are brief)
|
|
91
|
+
* - Must be 2-3 items (instructional lists tend to be longer)
|
|
92
|
+
* - List must be at the END of the response
|
|
93
|
+
*
|
|
94
|
+
* Supports numbered (1. 2. 3.) and unnumbered (plain lines) formats.
|
|
95
|
+
* Strips markdown formatting (bold, backticks) from option text.
|
|
96
|
+
*/
|
|
97
|
+
export function extractClarificationOptions(responseText: string): string[] | null {
|
|
98
|
+
// Trim trailing whitespace/newlines so the $ anchor in option-list regexes
|
|
99
|
+
// matches reliably — SSE text deltas can include trailing newlines.
|
|
100
|
+
const text = responseText.trimEnd();
|
|
101
|
+
|
|
102
|
+
// Must indicate a question. A `?` always qualifies. A `:` only qualifies when
|
|
103
|
+
// the preamble contains clarification words (to avoid false positives on
|
|
104
|
+
// instructional lists like "To enable dark mode:\n\n1. Open Settings").
|
|
105
|
+
const hasQuestion = text.includes('?');
|
|
106
|
+
const hasClarificationColon = !hasQuestion
|
|
107
|
+
&& text.includes(':')
|
|
108
|
+
&& /\b(which|what|clarify|interested|looking for|asking|type of|kind of)\b/i.test(text);
|
|
109
|
+
if (!hasQuestion && !hasClarificationColon) return null;
|
|
110
|
+
|
|
111
|
+
// Only short responses are likely clarification questions
|
|
112
|
+
if (text.length > 500) return null;
|
|
113
|
+
|
|
114
|
+
// Strip markdown formatting (bold, italic, backticks) from option labels
|
|
115
|
+
function cleanOption(text: string): string {
|
|
116
|
+
return text.trim().replace(/[*`]/g, '');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Primary: numbered list (1. 2. 3.) at the end
|
|
120
|
+
const numbered = text.match(
|
|
121
|
+
/\n\n1\.\s+(.+)\n2\.\s+(.+)(?:\n3\.\s+(.+))?$/,
|
|
122
|
+
);
|
|
123
|
+
if (numbered) {
|
|
124
|
+
const options = [cleanOption(numbered[1]), cleanOption(numbered[2])];
|
|
125
|
+
if (numbered[3]) options.push(cleanOption(numbered[3]));
|
|
126
|
+
return options;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Fallback: unnumbered list (2-3 short lines at the end, after a blank line)
|
|
130
|
+
// Each line must be short (< 80 chars) and non-empty to distinguish from paragraphs
|
|
131
|
+
const unnumbered = text.match(
|
|
132
|
+
/\n\n([^\n]{2,80})\n([^\n]{2,80})(?:\n([^\n]{2,80}))?$/,
|
|
133
|
+
);
|
|
134
|
+
if (unnumbered) {
|
|
135
|
+
// Extra guard: lines must NOT look like sentences (no periods at end)
|
|
136
|
+
const lines = [unnumbered[1], unnumbered[2], unnumbered[3]].filter(Boolean) as string[];
|
|
137
|
+
const looksLikeOptions = lines.every(l => !l.trim().endsWith('.') && l.trim().length < 80);
|
|
138
|
+
if (looksLikeOptions && lines.length >= 2) {
|
|
139
|
+
return lines.map(cleanOption);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function POST(
|
|
147
|
+
request: NextRequest,
|
|
148
|
+
context: { params: Promise<{ project: string }> },
|
|
149
|
+
): Promise<Response> {
|
|
150
|
+
const startTime = Date.now();
|
|
151
|
+
const { project } = await context.params;
|
|
152
|
+
// Use first IP from x-forwarded-for (set by Vercel/Cloud Run infrastructure).
|
|
153
|
+
// Fallback to user-agent hash to avoid a shared 'unknown' bucket that would
|
|
154
|
+
// rate-limit all unidentified clients together.
|
|
155
|
+
const forwarded = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim();
|
|
156
|
+
const ip = forwarded || `ua:${request.headers.get('user-agent')?.slice(0, 64) || 'none'}`;
|
|
157
|
+
|
|
158
|
+
// Rate limit via Upstash Redis (persistent across Vercel cold starts)
|
|
159
|
+
// Key is scoped per-project so each docs site has its own rate limit.
|
|
160
|
+
// If Redis is down, skip rate limiting rather than failing the request.
|
|
161
|
+
if (redis) {
|
|
162
|
+
try {
|
|
163
|
+
const key = `chat_rl:${project}:${ip}`;
|
|
164
|
+
const count = await redis.incr(key);
|
|
165
|
+
if (count === 1) await redis.expire(key, RATE_WINDOW_SECONDS);
|
|
166
|
+
if (count > RATE_LIMIT) {
|
|
167
|
+
return Response.json(
|
|
168
|
+
{ error: 'Rate limit exceeded. Try again in a minute.' },
|
|
169
|
+
{ status: 429 },
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
// Redis unavailable — allow request through without rate limiting
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
let body: { message: unknown; history?: unknown[] };
|
|
178
|
+
try {
|
|
179
|
+
body = await request.json();
|
|
180
|
+
} catch {
|
|
181
|
+
return Response.json({ error: 'Invalid request body' }, { status: 400 });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const { message, history: rawHistory = [] } = body;
|
|
185
|
+
|
|
186
|
+
if (!message || typeof message !== 'string' || message.length > 2000) {
|
|
187
|
+
return Response.json({ error: 'Invalid message' }, { status: 400 });
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Sanitize history: only allow valid roles, string content, capped length
|
|
191
|
+
const history = (Array.isArray(rawHistory) ? rawHistory : [])
|
|
192
|
+
.filter((h): h is { role: 'user' | 'assistant'; content: string } => {
|
|
193
|
+
if (!h || typeof h !== 'object') return false;
|
|
194
|
+
const entry = h as Record<string, unknown>;
|
|
195
|
+
return VALID_ROLES.has(entry.role as string) && typeof entry.content === 'string';
|
|
196
|
+
})
|
|
197
|
+
.map((h) => ({ role: h.role, content: h.content.slice(0, 4000) }));
|
|
198
|
+
|
|
199
|
+
// Enrich short messages with conversation context for better vector search.
|
|
200
|
+
// When user picks a clarification option (e.g., "Post Analytics"), the message
|
|
201
|
+
// alone is too short for good retrieval. Combine with the preceding question.
|
|
202
|
+
let searchQuery = message;
|
|
203
|
+
if (message.length < 60 && history.length > 0) {
|
|
204
|
+
const prevUserMsg = [...history].reverse().find(
|
|
205
|
+
h => h.role === 'user' && h.content !== message,
|
|
206
|
+
);
|
|
207
|
+
if (prevUserMsg) {
|
|
208
|
+
searchQuery = `${prevUserMsg.content} — ${message}`;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Query Upstash Vector for relevant chunks
|
|
213
|
+
let chunks: Awaited<ReturnType<typeof querySimilarChunks>>;
|
|
214
|
+
try {
|
|
215
|
+
chunks = await querySimilarChunks(project, searchQuery, 8);
|
|
216
|
+
} catch {
|
|
217
|
+
return Response.json(
|
|
218
|
+
{ error: 'AI chat is not available for this project.' },
|
|
219
|
+
{ status: 503 },
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (chunks.length === 0) {
|
|
224
|
+
return Response.json(
|
|
225
|
+
{ error: 'No documentation content found for this project.' },
|
|
226
|
+
{ status: 404 },
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const anthropic = getAnthropicClient();
|
|
231
|
+
if (!anthropic) {
|
|
232
|
+
return Response.json(
|
|
233
|
+
{ error: 'AI chat is not configured.' },
|
|
234
|
+
{ status: 503 },
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Resolve project URLs — falls back to defaults if R2/config unavailable
|
|
239
|
+
const originalHost = request.headers.get('x-jamdesk-forwarded-host')
|
|
240
|
+
|| request.headers.get('x-original-host') || '';
|
|
241
|
+
const baseUrl = getBaseUrl(project, originalHost);
|
|
242
|
+
let docsPath: string;
|
|
243
|
+
try {
|
|
244
|
+
docsPath = await getDocsPath(project, originalHost);
|
|
245
|
+
} catch {
|
|
246
|
+
docsPath = '';
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Build system prompt with retrieved context
|
|
250
|
+
const systemPrompt = buildSystemPrompt(project, chunks, baseUrl, docsPath);
|
|
251
|
+
|
|
252
|
+
const stream = anthropic.messages.stream({
|
|
253
|
+
model: CHAT_MODEL,
|
|
254
|
+
max_tokens: 2048,
|
|
255
|
+
temperature: 0.3,
|
|
256
|
+
system: systemPrompt,
|
|
257
|
+
messages: [
|
|
258
|
+
...history.slice(-MAX_HISTORY),
|
|
259
|
+
{ role: 'user', content: message },
|
|
260
|
+
],
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const encoder = new TextEncoder();
|
|
264
|
+
function sendEvent(data: Record<string, unknown>): Uint8Array {
|
|
265
|
+
return encoder.encode(`data: ${JSON.stringify(data)}\n\n`);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const readable = new ReadableStream({
|
|
269
|
+
async start(controller) {
|
|
270
|
+
let fullText = '';
|
|
271
|
+
|
|
272
|
+
try {
|
|
273
|
+
for await (const event of stream) {
|
|
274
|
+
if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') {
|
|
275
|
+
fullText += event.delta.text;
|
|
276
|
+
controller.enqueue(sendEvent({ type: 'text', content: event.delta.text }));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// After streaming completes, get token usage
|
|
281
|
+
const finalMessage = await stream.finalMessage();
|
|
282
|
+
const inputTokens = finalMessage.usage?.input_tokens ?? 0;
|
|
283
|
+
const outputTokens = finalMessage.usage?.output_tokens ?? 0;
|
|
284
|
+
|
|
285
|
+
// Extract clarification options (if Claude asked a disambiguation question)
|
|
286
|
+
const options = extractClarificationOptions(fullText);
|
|
287
|
+
if (options) {
|
|
288
|
+
controller.enqueue(sendEvent({ type: 'clarification', options }));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Extract [Title] references from Claude's response (skip markdown links [text](url))
|
|
292
|
+
const { sources, hadExplicitCitations } = extractCitations(fullText, chunks);
|
|
293
|
+
controller.enqueue(sendEvent({ type: 'citations', sources }));
|
|
294
|
+
controller.enqueue(sendEvent({ type: 'done' }));
|
|
295
|
+
|
|
296
|
+
// Fire-and-forget analytics — only on success (finalMessage unreliable on error)
|
|
297
|
+
trackChatAnalytics({
|
|
298
|
+
projectSlug: project,
|
|
299
|
+
query: message,
|
|
300
|
+
resultsCount: chunks.length,
|
|
301
|
+
inputTokens,
|
|
302
|
+
outputTokens,
|
|
303
|
+
model: CHAT_MODEL,
|
|
304
|
+
hadExplicitCitations,
|
|
305
|
+
hasClarification: options !== null,
|
|
306
|
+
durationMs: Date.now() - startTime,
|
|
307
|
+
userAgent: request.headers.get('user-agent') || undefined,
|
|
308
|
+
}).catch(() => {});
|
|
309
|
+
} catch {
|
|
310
|
+
controller.enqueue(sendEvent({ type: 'error', message: 'The response was interrupted. Please try again.' }));
|
|
311
|
+
}
|
|
312
|
+
controller.close();
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
return new Response(readable, {
|
|
317
|
+
headers: {
|
|
318
|
+
'Content-Type': 'text/event-stream',
|
|
319
|
+
'Cache-Control': 'no-cache',
|
|
320
|
+
'Connection': 'keep-alive',
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
}
|
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
* {"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}
|
|
11
11
|
*/
|
|
12
12
|
import { NextRequest, NextResponse } from 'next/server';
|
|
13
|
-
import { redis } from '@/lib/redis';
|
|
14
|
-
import { isCustomDomain, parseRedisConfig } from '@/lib/domain-helpers';
|
|
15
13
|
import { searchProject, getPageContent } from '@/lib/mcp-search';
|
|
14
|
+
import { getDocsPath, getBaseUrl, trackServerAnalytics } from '@/lib/route-helpers';
|
|
16
15
|
|
|
17
16
|
export const runtime = 'nodejs';
|
|
18
17
|
export const maxDuration = 30;
|
|
@@ -58,66 +57,6 @@ globalThis.__resetMcpRateLimitForTesting = function(): void {
|
|
|
58
57
|
lastCleanup = Date.now();
|
|
59
58
|
};
|
|
60
59
|
|
|
61
|
-
/**
|
|
62
|
-
* Track MCP search analytics (fire-and-forget).
|
|
63
|
-
*/
|
|
64
|
-
async function trackMcpSearch(
|
|
65
|
-
projectSlug: string,
|
|
66
|
-
query: string,
|
|
67
|
-
resultsCount: number
|
|
68
|
-
): Promise<void> {
|
|
69
|
-
const trackingUrl = process.env.TRACKING_URL || 'https://us-central1-jamdesk-prod.cloudfunctions.net/trackSearchAnalytics';
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
await fetch(trackingUrl, {
|
|
73
|
-
method: 'POST',
|
|
74
|
-
headers: { 'Content-Type': 'application/json' },
|
|
75
|
-
body: JSON.stringify({
|
|
76
|
-
projectSlug,
|
|
77
|
-
type: 'search_query',
|
|
78
|
-
query,
|
|
79
|
-
resultsCount,
|
|
80
|
-
sessionId: `mcp-${Date.now()}`,
|
|
81
|
-
source: 'mcp',
|
|
82
|
-
}),
|
|
83
|
-
});
|
|
84
|
-
} catch {
|
|
85
|
-
// Silent failure - don't break search if analytics fails
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* Get docsPath for a project based on hostAtDocs config.
|
|
91
|
-
* Handles both subdomains (projectCfg) and custom domains (domainCfg).
|
|
92
|
-
* Defaults to '/docs' (hostAtDocs=true) for safety - most Jamdesk sites use this.
|
|
93
|
-
*/
|
|
94
|
-
async function getDocsPath(project: string, originalHost: string): Promise<string> {
|
|
95
|
-
if (!redis) return '/docs';
|
|
96
|
-
|
|
97
|
-
try {
|
|
98
|
-
const key = isCustomDomain(originalHost) ? `domainCfg:${originalHost}` : `projectCfg:${project}`;
|
|
99
|
-
const cfg = parseRedisConfig(await redis.get(key));
|
|
100
|
-
if (cfg) {
|
|
101
|
-
return cfg.hostAtDocs !== false ? '/docs' : '';
|
|
102
|
-
}
|
|
103
|
-
} catch {
|
|
104
|
-
// Fall through to default
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
return '/docs';
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Get the base URL for a project.
|
|
112
|
-
* Uses the resolved host for custom/forwarded domains, falls back to project.jamdesk.app.
|
|
113
|
-
*/
|
|
114
|
-
function getBaseUrl(project: string, resolvedHost: string): string {
|
|
115
|
-
const hostname = resolvedHost.split(':')[0];
|
|
116
|
-
return isCustomDomain(hostname)
|
|
117
|
-
? `https://${hostname}`
|
|
118
|
-
: `https://${project}.jamdesk.app`;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
60
|
// CORS headers for cross-origin MCP clients
|
|
122
61
|
const CORS_HEADERS: HeadersInit = {
|
|
123
62
|
'Access-Control-Allow-Origin': '*',
|
|
@@ -324,7 +263,7 @@ export async function POST(
|
|
|
324
263
|
const results = await searchProject(project, query, limit, type, docsPath);
|
|
325
264
|
|
|
326
265
|
// Track analytics (fire-and-forget, don't await)
|
|
327
|
-
|
|
266
|
+
trackServerAnalytics({ projectSlug: project, type: 'search_query', query, resultsCount: results.length, source: 'mcp' }).catch(() => {});
|
|
328
267
|
|
|
329
268
|
return jsonResponse({
|
|
330
269
|
jsonrpc: '2.0',
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { memo, useState } from 'react';
|
|
4
|
+
import { useShikiHighlight } from '@/hooks/useShikiHighlight';
|
|
5
|
+
|
|
6
|
+
interface ChatCodeBlockProps {
|
|
7
|
+
className?: string;
|
|
8
|
+
children: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Syntax-highlighted code block for chat messages with a copy button.
|
|
13
|
+
* Uses the existing Shiki highlighter with a plain code fallback while loading.
|
|
14
|
+
*
|
|
15
|
+
* Security note: dangerouslySetInnerHTML is safe here because the HTML comes from
|
|
16
|
+
* Shiki's highlightCode(), which generates markup from source code tokens — not from
|
|
17
|
+
* user-supplied HTML. This is the same trusted pattern used throughout the codebase
|
|
18
|
+
* (see CodePanel, OpenApiEndpoint, etc.).
|
|
19
|
+
*
|
|
20
|
+
* Memoized so Shiki doesn't re-highlight unchanged code blocks during streaming.
|
|
21
|
+
* When the assistant streams text after a code block, ChatMessage re-renders but
|
|
22
|
+
* the code block's children are the same — memo prevents a redundant Shiki call.
|
|
23
|
+
*/
|
|
24
|
+
export const ChatCodeBlock = memo(function ChatCodeBlock({ className, children }: ChatCodeBlockProps) {
|
|
25
|
+
// Extract language from className (e.g., "language-javascript" -> "javascript")
|
|
26
|
+
const language = className?.replace('language-', '') || 'text';
|
|
27
|
+
const code = children.replace(/\n$/, '');
|
|
28
|
+
|
|
29
|
+
const { html, isLoading } = useShikiHighlight(code, language);
|
|
30
|
+
const [copied, setCopied] = useState(false);
|
|
31
|
+
|
|
32
|
+
function handleCopy(): void {
|
|
33
|
+
navigator.clipboard.writeText(code).then(() => {
|
|
34
|
+
setCopied(true);
|
|
35
|
+
setTimeout(() => setCopied(false), 2000);
|
|
36
|
+
}).catch(() => {
|
|
37
|
+
// clipboard API unavailable (non-HTTPS context)
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div className="relative group my-2 border border-[var(--color-border)]/60 rounded-lg">
|
|
43
|
+
<button
|
|
44
|
+
onClick={handleCopy}
|
|
45
|
+
className="absolute top-2 right-2 p-1.5 rounded-md opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity bg-white/10 text-[var(--color-text-muted)] hover:text-[var(--color-text-primary)] text-xs z-10 cursor-pointer"
|
|
46
|
+
aria-label="Copy code"
|
|
47
|
+
title={copied ? 'Copied!' : 'Copy code'}
|
|
48
|
+
>
|
|
49
|
+
<i className={`fa-solid ${copied ? 'fa-check text-green-400' : 'fa-copy'}`} aria-hidden="true" />
|
|
50
|
+
</button>
|
|
51
|
+
{isLoading ? (
|
|
52
|
+
<pre className="rounded-lg bg-[var(--code-panel-bg,#0d1117)] p-3 overflow-x-auto text-sm">
|
|
53
|
+
<code>{code}</code>
|
|
54
|
+
</pre>
|
|
55
|
+
) : (
|
|
56
|
+
<div
|
|
57
|
+
className="rounded-lg overflow-hidden text-sm [&_pre]:!m-0 [&_pre]:!p-3 [&_pre]:!rounded-lg [&_.shiki]:!rounded-lg"
|
|
58
|
+
dangerouslySetInnerHTML={{ __html: html }}
|
|
59
|
+
/>
|
|
60
|
+
)}
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
});
|