skrypt-ai 0.4.2 → 0.6.0
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/auth/index.d.ts +13 -3
- package/dist/auth/index.js +101 -9
- package/dist/auth/keychain.d.ts +5 -0
- package/dist/auth/keychain.js +82 -0
- package/dist/auth/notices.d.ts +3 -0
- package/dist/auth/notices.js +42 -0
- package/dist/autofix/index.d.ts +0 -4
- package/dist/autofix/index.js +10 -24
- package/dist/capture/browser.d.ts +11 -0
- package/dist/capture/browser.js +173 -0
- package/dist/capture/diff.d.ts +23 -0
- package/dist/capture/diff.js +52 -0
- package/dist/capture/index.d.ts +23 -0
- package/dist/capture/index.js +210 -0
- package/dist/capture/naming.d.ts +17 -0
- package/dist/capture/naming.js +45 -0
- package/dist/capture/parser.d.ts +15 -0
- package/dist/capture/parser.js +80 -0
- package/dist/capture/types.d.ts +57 -0
- package/dist/capture/types.js +1 -0
- package/dist/cli.js +20 -3
- package/dist/commands/autofix.js +136 -120
- package/dist/commands/cron.js +58 -47
- package/dist/commands/deploy.js +123 -102
- package/dist/commands/generate.js +125 -7
- package/dist/commands/heal.d.ts +10 -0
- package/dist/commands/heal.js +201 -0
- package/dist/commands/i18n.js +146 -111
- package/dist/commands/import.d.ts +2 -0
- package/dist/commands/import.js +157 -0
- package/dist/commands/init.js +19 -7
- package/dist/commands/lint.js +50 -44
- package/dist/commands/llms-txt.js +59 -49
- package/dist/commands/login.js +63 -34
- package/dist/commands/mcp.js +6 -0
- package/dist/commands/monitor.js +13 -8
- package/dist/commands/qa.d.ts +2 -0
- package/dist/commands/qa.js +43 -0
- package/dist/commands/review-pr.js +108 -92
- package/dist/commands/sdk.js +128 -122
- package/dist/commands/security.d.ts +2 -0
- package/dist/commands/security.js +109 -0
- package/dist/commands/test.js +91 -92
- package/dist/commands/version.js +104 -75
- package/dist/commands/watch.js +130 -114
- package/dist/config/types.js +2 -2
- package/dist/context-hub/index.d.ts +23 -0
- package/dist/context-hub/index.js +179 -0
- package/dist/context-hub/mappings.d.ts +8 -0
- package/dist/context-hub/mappings.js +55 -0
- package/dist/context-hub/types.d.ts +33 -0
- package/dist/context-hub/types.js +1 -0
- package/dist/generator/generator.js +39 -6
- package/dist/generator/types.d.ts +7 -0
- package/dist/generator/writer.d.ts +3 -1
- package/dist/generator/writer.js +36 -7
- package/dist/importers/confluence.d.ts +5 -0
- package/dist/importers/confluence.js +137 -0
- package/dist/importers/detect.d.ts +20 -0
- package/dist/importers/detect.js +121 -0
- package/dist/importers/docusaurus.d.ts +5 -0
- package/dist/importers/docusaurus.js +279 -0
- package/dist/importers/gitbook.d.ts +5 -0
- package/dist/importers/gitbook.js +189 -0
- package/dist/importers/github.d.ts +8 -0
- package/dist/importers/github.js +99 -0
- package/dist/importers/index.d.ts +15 -0
- package/dist/importers/index.js +30 -0
- package/dist/importers/markdown.d.ts +6 -0
- package/dist/importers/markdown.js +105 -0
- package/dist/importers/mintlify.d.ts +5 -0
- package/dist/importers/mintlify.js +172 -0
- package/dist/importers/notion.d.ts +5 -0
- package/dist/importers/notion.js +174 -0
- package/dist/importers/readme.d.ts +5 -0
- package/dist/importers/readme.js +184 -0
- package/dist/importers/transform.d.ts +90 -0
- package/dist/importers/transform.js +457 -0
- package/dist/importers/types.d.ts +37 -0
- package/dist/importers/types.js +1 -0
- package/dist/llm/anthropic-client.d.ts +1 -0
- package/dist/llm/anthropic-client.js +3 -1
- package/dist/llm/index.d.ts +6 -4
- package/dist/llm/index.js +76 -261
- package/dist/llm/openai-client.d.ts +1 -0
- package/dist/llm/openai-client.js +7 -2
- package/dist/plugins/index.js +7 -0
- package/dist/qa/checks.d.ts +10 -0
- package/dist/qa/checks.js +492 -0
- package/dist/qa/fixes.d.ts +30 -0
- package/dist/qa/fixes.js +277 -0
- package/dist/qa/index.d.ts +29 -0
- package/dist/qa/index.js +187 -0
- package/dist/qa/types.d.ts +24 -0
- package/dist/qa/types.js +1 -0
- package/dist/scanner/csharp.d.ts +23 -0
- package/dist/scanner/csharp.js +421 -0
- package/dist/scanner/index.js +53 -26
- package/dist/scanner/java.d.ts +39 -0
- package/dist/scanner/java.js +318 -0
- package/dist/scanner/kotlin.d.ts +23 -0
- package/dist/scanner/kotlin.js +389 -0
- package/dist/scanner/php.d.ts +57 -0
- package/dist/scanner/php.js +351 -0
- package/dist/scanner/python.js +17 -0
- package/dist/scanner/ruby.d.ts +36 -0
- package/dist/scanner/ruby.js +431 -0
- package/dist/scanner/swift.d.ts +25 -0
- package/dist/scanner/swift.js +392 -0
- package/dist/scanner/types.d.ts +1 -1
- package/dist/template/content/docs/_navigation.json +46 -0
- package/dist/template/content/docs/_sidebars.json +684 -0
- package/dist/template/content/docs/core.md +4544 -0
- package/dist/template/content/docs/index.mdx +89 -0
- package/dist/template/content/docs/integrations.md +1158 -0
- package/dist/template/content/docs/llms-full.md +403 -0
- package/dist/template/content/docs/llms.txt +4588 -0
- package/dist/template/content/docs/other.md +10379 -0
- package/dist/template/content/docs/tools.md +746 -0
- package/dist/template/content/docs/types.md +531 -0
- package/dist/template/docs.json +13 -11
- package/dist/template/mdx-components.tsx +27 -2
- package/dist/template/package.json +6 -0
- package/dist/template/public/search-index.json +1 -1
- package/dist/template/scripts/build-search-index.mjs +149 -13
- package/dist/template/src/app/api/chat/route.ts +83 -128
- package/dist/template/src/app/docs/[...slug]/page.tsx +75 -20
- package/dist/template/src/app/docs/llms-full.md +151 -4
- package/dist/template/src/app/docs/llms.txt +2464 -847
- package/dist/template/src/app/docs/page.mdx +48 -38
- package/dist/template/src/app/layout.tsx +3 -1
- package/dist/template/src/app/page.tsx +22 -8
- package/dist/template/src/components/ai-chat.tsx +73 -64
- package/dist/template/src/components/breadcrumbs.tsx +21 -23
- package/dist/template/src/components/copy-button.tsx +13 -9
- package/dist/template/src/components/copy-page-button.tsx +54 -0
- package/dist/template/src/components/docs-layout.tsx +37 -25
- package/dist/template/src/components/header.tsx +51 -10
- package/dist/template/src/components/mdx/card.tsx +17 -3
- package/dist/template/src/components/mdx/code-block.tsx +13 -9
- package/dist/template/src/components/mdx/code-group.tsx +13 -8
- package/dist/template/src/components/mdx/heading.tsx +15 -2
- package/dist/template/src/components/mdx/highlighted-code.tsx +13 -8
- package/dist/template/src/components/mdx/index.tsx +2 -0
- package/dist/template/src/components/mdx/mermaid.tsx +110 -0
- package/dist/template/src/components/mdx/screenshot.tsx +150 -0
- package/dist/template/src/components/scroll-to-hash.tsx +48 -0
- package/dist/template/src/components/sidebar.tsx +12 -18
- package/dist/template/src/components/table-of-contents.tsx +9 -0
- package/dist/template/src/lib/highlight.ts +3 -88
- package/dist/template/src/lib/navigation.ts +159 -0
- package/dist/template/src/lib/search-types.ts +4 -1
- package/dist/template/src/lib/search.ts +30 -7
- package/dist/template/src/styles/globals.css +17 -6
- package/dist/utils/files.d.ts +9 -1
- package/dist/utils/files.js +59 -10
- package/dist/utils/validation.d.ts +0 -3
- package/dist/utils/validation.js +0 -26
- package/package.json +5 -1
|
@@ -19,13 +19,15 @@ async function dirExists(dir) {
|
|
|
19
19
|
async function getAllMDXFiles(dir) {
|
|
20
20
|
const files = []
|
|
21
21
|
|
|
22
|
-
async function walk(currentDir) {
|
|
22
|
+
async function walk(currentDir, depth = 0) {
|
|
23
|
+
if (depth > 20) return
|
|
23
24
|
try {
|
|
24
25
|
const entries = await readdir(currentDir, { withFileTypes: true })
|
|
25
26
|
for (const entry of entries) {
|
|
26
27
|
const fullPath = join(currentDir, entry.name)
|
|
28
|
+
if (entry.isSymbolicLink?.()) continue
|
|
27
29
|
if (entry.isDirectory()) {
|
|
28
|
-
await walk(fullPath)
|
|
30
|
+
await walk(fullPath, depth + 1)
|
|
29
31
|
} else if (entry.name.endsWith('.md') || entry.name.endsWith('.mdx')) {
|
|
30
32
|
files.push(fullPath)
|
|
31
33
|
}
|
|
@@ -39,14 +41,30 @@ async function getAllMDXFiles(dir) {
|
|
|
39
41
|
return files
|
|
40
42
|
}
|
|
41
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Extract headings (h2, h3) as a separate searchable field.
|
|
46
|
+
* This lets users find pages by section names.
|
|
47
|
+
*/
|
|
48
|
+
function extractHeadings(content) {
|
|
49
|
+
const headings = []
|
|
50
|
+
const regex = /^#{2,3}\s+(.+)$/gm
|
|
51
|
+
let match
|
|
52
|
+
while ((match = regex.exec(content)) !== null) {
|
|
53
|
+
// Strip any inline formatting
|
|
54
|
+
const clean = match[1].replace(/[*_`[\]]/g, '').trim()
|
|
55
|
+
if (clean) headings.push(clean)
|
|
56
|
+
}
|
|
57
|
+
return headings.join(' | ')
|
|
58
|
+
}
|
|
59
|
+
|
|
42
60
|
function extractPlainText(content) {
|
|
43
61
|
return content
|
|
44
62
|
// Remove import statements
|
|
45
63
|
.replace(/^import\s+.*$/gm, '')
|
|
46
64
|
// Remove export statements
|
|
47
65
|
.replace(/^export\s+.*$/gm, '')
|
|
48
|
-
// Remove MDX/JSX components
|
|
49
|
-
.replace(/<[^>]+>/g, '')
|
|
66
|
+
// Remove MDX/JSX components (but keep text content inside)
|
|
67
|
+
.replace(/<[^>]+>/g, ' ')
|
|
50
68
|
// Remove code blocks
|
|
51
69
|
.replace(/```[\s\S]*?```/g, '')
|
|
52
70
|
.replace(/`[^`]+`/g, '')
|
|
@@ -68,19 +86,95 @@ function extractPlainText(content) {
|
|
|
68
86
|
.trim()
|
|
69
87
|
}
|
|
70
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Slugify a heading for use as an anchor hash.
|
|
91
|
+
* Matches the logic in heading components (H2, H3).
|
|
92
|
+
*/
|
|
93
|
+
function slugifyHeading(text) {
|
|
94
|
+
return text
|
|
95
|
+
.replace(/`/g, '')
|
|
96
|
+
.toLowerCase()
|
|
97
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
98
|
+
.replace(/(^-|-$)/g, '')
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Split content into sections by h2 headings.
|
|
103
|
+
* Returns an array of { heading, slug, content } objects.
|
|
104
|
+
*/
|
|
105
|
+
function splitBySections(content) {
|
|
106
|
+
const sections = []
|
|
107
|
+
const h2Regex = /^## (.+)$/gm
|
|
108
|
+
let lastIndex = 0
|
|
109
|
+
let lastHeading = null
|
|
110
|
+
let lastSlug = null
|
|
111
|
+
let match
|
|
112
|
+
|
|
113
|
+
while ((match = h2Regex.exec(content)) !== null) {
|
|
114
|
+
if (lastHeading !== null) {
|
|
115
|
+
sections.push({
|
|
116
|
+
heading: lastHeading,
|
|
117
|
+
slug: lastSlug,
|
|
118
|
+
content: content.slice(lastIndex, match.index),
|
|
119
|
+
})
|
|
120
|
+
}
|
|
121
|
+
lastHeading = match[1].replace(/[`*_[\]]/g, '').trim()
|
|
122
|
+
lastSlug = slugifyHeading(lastHeading)
|
|
123
|
+
lastIndex = match.index
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Last section
|
|
127
|
+
if (lastHeading !== null) {
|
|
128
|
+
sections.push({
|
|
129
|
+
heading: lastHeading,
|
|
130
|
+
slug: lastSlug,
|
|
131
|
+
content: content.slice(lastIndex),
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return sections
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Extract keywords from content — important terms that appear frequently
|
|
140
|
+
* or in headings, used to boost search relevance.
|
|
141
|
+
*/
|
|
142
|
+
function extractKeywords(content, title) {
|
|
143
|
+
const words = new Map()
|
|
144
|
+
|
|
145
|
+
// Title words get high weight
|
|
146
|
+
for (const word of title.toLowerCase().split(/\s+/)) {
|
|
147
|
+
if (word.length > 2) words.set(word, (words.get(word) || 0) + 3)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Heading words get medium weight
|
|
151
|
+
const headingRegex = /^#{1,3}\s+(.+)$/gm
|
|
152
|
+
let match
|
|
153
|
+
while ((match = headingRegex.exec(content)) !== null) {
|
|
154
|
+
for (const word of match[1].toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/)) {
|
|
155
|
+
if (word.length > 2) words.set(word, (words.get(word) || 0) + 2)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Sort by weight and return top keywords
|
|
160
|
+
return [...words.entries()]
|
|
161
|
+
.sort((a, b) => b[1] - a[1])
|
|
162
|
+
.slice(0, 20)
|
|
163
|
+
.map(([word]) => word)
|
|
164
|
+
.join(' ')
|
|
165
|
+
}
|
|
166
|
+
|
|
71
167
|
function getSlugFromContentPath(filePath) {
|
|
72
168
|
const rel = relative(CONTENT_DIR, filePath)
|
|
73
169
|
const slug = rel
|
|
74
170
|
.replace(/\.(mdx?|md)$/, '')
|
|
75
|
-
.replace(
|
|
171
|
+
.replace(/(^|\/)index$/, '')
|
|
76
172
|
.replace(/\\/g, '/')
|
|
77
173
|
|
|
78
174
|
return slug ? `/docs/${slug}` : '/docs'
|
|
79
175
|
}
|
|
80
176
|
|
|
81
177
|
function getSlugFromAppPath(filePath) {
|
|
82
|
-
// For App Router: src/app/docs/quickstart/page.mdx -> /docs/quickstart
|
|
83
|
-
// src/app/docs/page.mdx -> /docs
|
|
84
178
|
const rel = relative(APP_DOCS_DIR, filePath)
|
|
85
179
|
const dir = dirname(rel)
|
|
86
180
|
const name = basename(rel)
|
|
@@ -99,11 +193,9 @@ function getSlugFromAppPath(filePath) {
|
|
|
99
193
|
}
|
|
100
194
|
|
|
101
195
|
function extractTitle(content, filePath) {
|
|
102
|
-
// Try to get title from first heading
|
|
103
196
|
const h1Match = content.match(/^#\s+(.+)$/m)
|
|
104
197
|
if (h1Match) return h1Match[1].trim()
|
|
105
198
|
|
|
106
|
-
// Derive from file path
|
|
107
199
|
const dir = dirname(filePath)
|
|
108
200
|
const folderName = basename(dir)
|
|
109
201
|
if (folderName && folderName !== 'docs' && folderName !== '.') {
|
|
@@ -122,10 +214,18 @@ async function buildSearchIndex() {
|
|
|
122
214
|
schema: {
|
|
123
215
|
id: 'string',
|
|
124
216
|
title: 'string',
|
|
217
|
+
headings: 'string',
|
|
218
|
+
keywords: 'string',
|
|
125
219
|
content: 'string',
|
|
126
220
|
href: 'string',
|
|
127
221
|
section: 'string',
|
|
128
222
|
},
|
|
223
|
+
components: {
|
|
224
|
+
tokenizer: {
|
|
225
|
+
stemming: true,
|
|
226
|
+
language: 'english',
|
|
227
|
+
},
|
|
228
|
+
},
|
|
129
229
|
})
|
|
130
230
|
|
|
131
231
|
const documents = []
|
|
@@ -135,26 +235,57 @@ async function buildSearchIndex() {
|
|
|
135
235
|
if (await dirExists(CONTENT_DIR)) {
|
|
136
236
|
const contentFiles = await getAllMDXFiles(CONTENT_DIR)
|
|
137
237
|
for (const file of contentFiles) {
|
|
238
|
+
// Skip metadata files
|
|
239
|
+
const fileName = basename(file).replace(/\.(mdx?|md)$/, '')
|
|
240
|
+
if (fileName.startsWith('_') || fileName.startsWith('llms') || fileName === 'README') continue
|
|
241
|
+
|
|
138
242
|
try {
|
|
139
243
|
const raw = await readFile(file, 'utf-8')
|
|
140
244
|
const { data, content } = matter(raw)
|
|
141
245
|
|
|
142
246
|
const title = data.title || extractTitle(content, file)
|
|
143
|
-
const plainContent = extractPlainText(content)
|
|
144
247
|
const href = getSlugFromContentPath(file)
|
|
145
248
|
|
|
146
249
|
if (seen.has(href)) continue
|
|
147
250
|
seen.add(href)
|
|
148
251
|
|
|
252
|
+
// Index the page itself
|
|
253
|
+
const plainContent = extractPlainText(content)
|
|
254
|
+
const headings = extractHeadings(content)
|
|
255
|
+
const keywords = extractKeywords(content, title)
|
|
256
|
+
const sectionLabel = data.section || data.category || title
|
|
257
|
+
|
|
149
258
|
documents.push({
|
|
150
259
|
id: href,
|
|
151
260
|
title,
|
|
261
|
+
headings,
|
|
262
|
+
keywords,
|
|
152
263
|
content: plainContent.slice(0, 5000),
|
|
153
264
|
href,
|
|
154
|
-
section:
|
|
265
|
+
section: sectionLabel,
|
|
155
266
|
})
|
|
156
267
|
|
|
157
|
-
|
|
268
|
+
// Also index individual h2 sections as separate search documents
|
|
269
|
+
// This enables deep-linking search results (e.g. /docs/core#autofixbatch)
|
|
270
|
+
const sections = splitBySections(content)
|
|
271
|
+
for (const sec of sections) {
|
|
272
|
+
const sectionHref = `${href}#${sec.slug}`
|
|
273
|
+
if (seen.has(sectionHref)) continue
|
|
274
|
+
seen.add(sectionHref)
|
|
275
|
+
|
|
276
|
+
const sectionContent = extractPlainText(sec.content).slice(0, 2000)
|
|
277
|
+
documents.push({
|
|
278
|
+
id: sectionHref,
|
|
279
|
+
title: sec.heading,
|
|
280
|
+
headings: '',
|
|
281
|
+
keywords: sec.heading.toLowerCase(),
|
|
282
|
+
content: sectionContent,
|
|
283
|
+
href: sectionHref,
|
|
284
|
+
section: title, // parent page title as section label
|
|
285
|
+
})
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
console.log(` Indexed: ${href} (content/) + ${sections.length} sections`)
|
|
158
289
|
} catch (err) {
|
|
159
290
|
console.error(` Error indexing ${file}:`, err.message)
|
|
160
291
|
}
|
|
@@ -165,8 +296,9 @@ async function buildSearchIndex() {
|
|
|
165
296
|
if (await dirExists(APP_DOCS_DIR)) {
|
|
166
297
|
const appFiles = await getAllMDXFiles(APP_DOCS_DIR)
|
|
167
298
|
for (const file of appFiles) {
|
|
168
|
-
// Skip catch-all route files and layout files
|
|
169
299
|
if (file.includes('[...slug]') || file.includes('layout.')) continue
|
|
300
|
+
const appFileName = basename(file).replace(/\.(mdx?|md)$/, '')
|
|
301
|
+
if (appFileName.startsWith('_') || appFileName.startsWith('llms') || appFileName === 'README') continue
|
|
170
302
|
|
|
171
303
|
try {
|
|
172
304
|
const raw = await readFile(file, 'utf-8')
|
|
@@ -174,6 +306,8 @@ async function buildSearchIndex() {
|
|
|
174
306
|
|
|
175
307
|
const title = data.title || extractTitle(content, file)
|
|
176
308
|
const plainContent = extractPlainText(content)
|
|
309
|
+
const headings = extractHeadings(content)
|
|
310
|
+
const keywords = extractKeywords(content, title)
|
|
177
311
|
const href = getSlugFromAppPath(file)
|
|
178
312
|
|
|
179
313
|
if (seen.has(href)) continue
|
|
@@ -182,6 +316,8 @@ async function buildSearchIndex() {
|
|
|
182
316
|
documents.push({
|
|
183
317
|
id: href,
|
|
184
318
|
title,
|
|
319
|
+
headings,
|
|
320
|
+
keywords,
|
|
185
321
|
content: plainContent.slice(0, 5000),
|
|
186
322
|
href,
|
|
187
323
|
section: data.section || data.category || '',
|
|
@@ -1,10 +1,24 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { streamText, type CoreMessage } from 'ai'
|
|
2
|
+
import { createOpenAI } from '@ai-sdk/openai'
|
|
3
|
+
import { createAnthropic } from '@ai-sdk/anthropic'
|
|
2
4
|
import { readFileSync, readdirSync, statSync, existsSync } from 'fs'
|
|
3
5
|
import { join, extname } from 'path'
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
7
|
+
// In-memory rate limiter
|
|
8
|
+
const rateLimitMap = new Map<string, { count: number; resetAt: number }>()
|
|
9
|
+
const RATE_LIMIT = 20
|
|
10
|
+
const RATE_WINDOW = 60_000
|
|
11
|
+
|
|
12
|
+
function checkRateLimit(ip: string): boolean {
|
|
13
|
+
const now = Date.now()
|
|
14
|
+
const entry = rateLimitMap.get(ip)
|
|
15
|
+
if (!entry || now > entry.resetAt) {
|
|
16
|
+
rateLimitMap.set(ip, { count: 1, resetAt: now + RATE_WINDOW })
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
if (entry.count >= RATE_LIMIT) return false
|
|
20
|
+
entry.count++
|
|
21
|
+
return true
|
|
8
22
|
}
|
|
9
23
|
|
|
10
24
|
interface DocChunk {
|
|
@@ -19,13 +33,12 @@ let docChunks: DocChunk[] | null = null
|
|
|
19
33
|
let lastIndexTime = 0
|
|
20
34
|
|
|
21
35
|
function indexDocs(): DocChunk[] {
|
|
22
|
-
// Return cached if recent (5 min)
|
|
23
36
|
if (docChunks && Date.now() - lastIndexTime < 5 * 60 * 1000) {
|
|
24
37
|
return docChunks
|
|
25
38
|
}
|
|
26
39
|
|
|
27
40
|
const chunks: DocChunk[] = []
|
|
28
|
-
const docsDir = join(process.cwd(), '
|
|
41
|
+
const docsDir = join(process.cwd(), 'content/docs')
|
|
29
42
|
|
|
30
43
|
if (!existsSync(docsDir)) {
|
|
31
44
|
return chunks
|
|
@@ -45,7 +58,6 @@ function indexDocs(): DocChunk[] {
|
|
|
45
58
|
const content = readFileSync(fullPath, 'utf-8')
|
|
46
59
|
const docPath = `${basePath}/${entry.replace(/\.(mdx?|md)$/, '')}`.replace('/page', '')
|
|
47
60
|
|
|
48
|
-
// Extract title from frontmatter or first heading
|
|
49
61
|
let title = entry.replace(/\.(mdx?|md)$/, '')
|
|
50
62
|
const titleMatch = content.match(/title:\s*["']?([^"'\n]+)["']?/m)
|
|
51
63
|
if (titleMatch) {
|
|
@@ -55,16 +67,14 @@ function indexDocs(): DocChunk[] {
|
|
|
55
67
|
if (h1Match) title = h1Match[1]
|
|
56
68
|
}
|
|
57
69
|
|
|
58
|
-
// Extract headings
|
|
59
70
|
const headings = [...content.matchAll(/^#{1,3}\s+(.+)$/gm)].map(m => m[1])
|
|
60
71
|
|
|
61
|
-
// Clean content (remove frontmatter, code blocks for search)
|
|
62
72
|
let cleanContent = content
|
|
63
|
-
.replace(/^---[\s\S]*?---/m, '')
|
|
64
|
-
.replace(/```[\s\S]*?```/g, '')
|
|
65
|
-
.replace(/<[^>]+>/g, '')
|
|
66
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
67
|
-
.slice(0, 2000)
|
|
73
|
+
.replace(/^---[\s\S]*?---/m, '')
|
|
74
|
+
.replace(/```[\s\S]*?```/g, '')
|
|
75
|
+
.replace(/<[^>]+>/g, '')
|
|
76
|
+
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
|
77
|
+
.slice(0, 2000)
|
|
68
78
|
|
|
69
79
|
chunks.push({
|
|
70
80
|
path: docPath || '/',
|
|
@@ -100,13 +110,11 @@ function searchDocs(query: string, chunks: DocChunk[]): DocChunk[] {
|
|
|
100
110
|
const contentLower = chunk.content.toLowerCase()
|
|
101
111
|
const titleLower = chunk.title.toLowerCase()
|
|
102
112
|
|
|
103
|
-
// Title matches (high weight)
|
|
104
113
|
if (titleLower.includes(queryLower)) score += 20
|
|
105
114
|
queryWords.forEach(word => {
|
|
106
115
|
if (titleLower.includes(word)) score += 5
|
|
107
116
|
})
|
|
108
117
|
|
|
109
|
-
// Heading matches (medium weight)
|
|
110
118
|
chunk.headings.forEach(h => {
|
|
111
119
|
const hLower = h.toLowerCase()
|
|
112
120
|
if (hLower.includes(queryLower)) score += 10
|
|
@@ -115,10 +123,9 @@ function searchDocs(query: string, chunks: DocChunk[]): DocChunk[] {
|
|
|
115
123
|
})
|
|
116
124
|
})
|
|
117
125
|
|
|
118
|
-
// Content matches (lower weight)
|
|
119
126
|
queryWords.forEach(word => {
|
|
120
127
|
const matches = (contentLower.match(new RegExp(escapeRegex(word), 'g')) || []).length
|
|
121
|
-
score += Math.min(matches, 5)
|
|
128
|
+
score += Math.min(matches, 5)
|
|
122
129
|
})
|
|
123
130
|
|
|
124
131
|
return { chunk, score }
|
|
@@ -129,137 +136,85 @@ function searchDocs(query: string, chunks: DocChunk[]): DocChunk[] {
|
|
|
129
136
|
.map(r => r.chunk)
|
|
130
137
|
}
|
|
131
138
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
): Promise<{ content: string; citations: Array<{ title: string; path: string; snippet: string }> }> {
|
|
137
|
-
// Check for API key
|
|
138
|
-
const apiKey = process.env.OPENAI_API_KEY || process.env.ANTHROPIC_API_KEY
|
|
139
|
-
|
|
140
|
-
if (!apiKey) {
|
|
141
|
-
// Fallback: return a simple search result without AI
|
|
142
|
-
if (context.length === 0) {
|
|
143
|
-
return {
|
|
144
|
-
content: "I couldn't find any relevant documentation for your question. Try rephrasing or browsing the docs directly.",
|
|
145
|
-
citations: [],
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
return {
|
|
150
|
-
content: `Based on the documentation, here's what I found:\n\n${context[0].content.slice(0, 500)}...\n\nSee the linked sources for more details.`,
|
|
151
|
-
citations: context.slice(0, 3).map(c => ({
|
|
152
|
-
title: c.title,
|
|
153
|
-
path: `/docs${c.path}`,
|
|
154
|
-
snippet: c.content.slice(0, 100),
|
|
155
|
-
})),
|
|
156
|
-
}
|
|
139
|
+
function getModel() {
|
|
140
|
+
if (process.env.OPENAI_API_KEY) {
|
|
141
|
+
const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY })
|
|
142
|
+
return openai('gpt-4o-mini')
|
|
157
143
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
.map(c => `## ${c.title}\nPath: ${c.path}\n\n${c.content}`)
|
|
162
|
-
.join('\n\n---\n\n')
|
|
163
|
-
|
|
164
|
-
const systemPrompt = `You are a helpful documentation assistant. Answer questions based ONLY on the provided documentation context. If the answer isn't in the context, say so.
|
|
165
|
-
|
|
166
|
-
Always cite your sources by mentioning which document you're referencing. Be concise and helpful.
|
|
167
|
-
|
|
168
|
-
Documentation context:
|
|
169
|
-
${contextText}`
|
|
170
|
-
|
|
171
|
-
const messages = [
|
|
172
|
-
{ role: 'system' as const, content: systemPrompt },
|
|
173
|
-
...history.slice(-4), // Last 4 messages for context
|
|
174
|
-
{ role: 'user' as const, content: query },
|
|
175
|
-
]
|
|
176
|
-
|
|
177
|
-
try {
|
|
178
|
-
let answer: string
|
|
179
|
-
|
|
180
|
-
if (process.env.OPENAI_API_KEY) {
|
|
181
|
-
const response = await fetch('https://api.openai.com/v1/chat/completions', {
|
|
182
|
-
method: 'POST',
|
|
183
|
-
headers: {
|
|
184
|
-
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
|
|
185
|
-
'Content-Type': 'application/json',
|
|
186
|
-
},
|
|
187
|
-
body: JSON.stringify({
|
|
188
|
-
model: 'gpt-4o-mini',
|
|
189
|
-
messages,
|
|
190
|
-
max_tokens: 500,
|
|
191
|
-
}),
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
const data = await response.json()
|
|
195
|
-
answer = data.choices?.[0]?.message?.content || 'Sorry, I could not generate an answer.'
|
|
196
|
-
} else {
|
|
197
|
-
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
|
198
|
-
method: 'POST',
|
|
199
|
-
headers: {
|
|
200
|
-
'x-api-key': process.env.ANTHROPIC_API_KEY!,
|
|
201
|
-
'anthropic-version': '2023-06-01',
|
|
202
|
-
'Content-Type': 'application/json',
|
|
203
|
-
},
|
|
204
|
-
body: JSON.stringify({
|
|
205
|
-
model: 'claude-sonnet-4-20250514',
|
|
206
|
-
max_tokens: 500,
|
|
207
|
-
system: systemPrompt,
|
|
208
|
-
messages: messages.filter(m => m.role !== 'system'),
|
|
209
|
-
}),
|
|
210
|
-
})
|
|
211
|
-
|
|
212
|
-
const data = await response.json()
|
|
213
|
-
answer = data.content?.[0]?.text || 'Sorry, I could not generate an answer.'
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
return {
|
|
217
|
-
content: answer,
|
|
218
|
-
citations: context.slice(0, 3).map(c => ({
|
|
219
|
-
title: c.title,
|
|
220
|
-
path: `/docs${c.path}`,
|
|
221
|
-
snippet: c.content.slice(0, 100),
|
|
222
|
-
})),
|
|
223
|
-
}
|
|
224
|
-
} catch (err) {
|
|
225
|
-
console.error('AI generation error:', err)
|
|
226
|
-
return {
|
|
227
|
-
content: 'Sorry, I encountered an error generating an answer. Please try again.',
|
|
228
|
-
citations: [],
|
|
229
|
-
}
|
|
144
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
145
|
+
const anthropic = createAnthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
|
|
146
|
+
return anthropic('claude-sonnet-4-20250514')
|
|
230
147
|
}
|
|
148
|
+
return null
|
|
231
149
|
}
|
|
232
150
|
|
|
233
151
|
export async function POST(request: Request) {
|
|
152
|
+
const ip = request.headers.get('x-forwarded-for') ?? request.headers.get('x-real-ip') ?? 'unknown'
|
|
153
|
+
if (!checkRateLimit(ip)) {
|
|
154
|
+
return new Response(JSON.stringify({ error: 'Rate limit exceeded' }), { status: 429 })
|
|
155
|
+
}
|
|
156
|
+
|
|
234
157
|
try {
|
|
235
158
|
const body = await request.json()
|
|
236
|
-
const { messages } = body as { messages:
|
|
159
|
+
const { messages } = body as { messages: CoreMessage[] }
|
|
237
160
|
|
|
238
161
|
if (!Array.isArray(messages) || messages.length === 0 || messages.length > 50) {
|
|
239
|
-
return
|
|
162
|
+
return new Response(JSON.stringify({ error: 'Invalid request' }), { status: 400 })
|
|
240
163
|
}
|
|
241
164
|
|
|
242
165
|
const lastMessage = messages[messages.length - 1]
|
|
243
166
|
if (typeof lastMessage?.content !== 'string' || lastMessage.content.length > 10000) {
|
|
244
|
-
return
|
|
167
|
+
return new Response(JSON.stringify({ error: 'Message too long' }), { status: 400 })
|
|
245
168
|
}
|
|
246
169
|
|
|
247
|
-
|
|
248
|
-
|
|
170
|
+
// Index and search docs
|
|
171
|
+
const chunks = indexDocs()
|
|
172
|
+
const relevantDocs = searchDocs(lastMessage.content as string, chunks)
|
|
173
|
+
|
|
174
|
+
const contextText = relevantDocs
|
|
175
|
+
.map(c => `## ${c.title}\nPath: ${c.path}\n\n${c.content}`)
|
|
176
|
+
.join('\n\n---\n\n')
|
|
177
|
+
|
|
178
|
+
const citations = relevantDocs.slice(0, 3).map(c => ({
|
|
179
|
+
title: c.title,
|
|
180
|
+
path: `/docs${c.path}`,
|
|
181
|
+
snippet: c.content.slice(0, 100),
|
|
182
|
+
}))
|
|
183
|
+
|
|
184
|
+
const model = getModel()
|
|
185
|
+
|
|
186
|
+
// No API key — return plain search results as a non-streaming response
|
|
187
|
+
if (!model) {
|
|
188
|
+
const fallback = relevantDocs.length > 0
|
|
189
|
+
? `Based on the docs:\n\n${relevantDocs[0].content.slice(0, 500)}...\n\nSee the linked sources for more.`
|
|
190
|
+
: "I couldn't find relevant documentation. Try rephrasing or browse the docs directly."
|
|
191
|
+
|
|
192
|
+
return new Response(JSON.stringify({ content: fallback, citations }), {
|
|
193
|
+
headers: { 'Content-Type': 'application/json' },
|
|
194
|
+
})
|
|
249
195
|
}
|
|
250
196
|
|
|
251
|
-
//
|
|
252
|
-
const
|
|
197
|
+
// Stream the response using Vercel AI SDK
|
|
198
|
+
const result = streamText({
|
|
199
|
+
model,
|
|
200
|
+
system: `You are a helpful documentation assistant. Answer questions based ONLY on the provided documentation context. If the answer isn't in the context, say so. Be concise. Use markdown formatting (bold, code blocks, lists) to make answers scannable.
|
|
253
201
|
|
|
254
|
-
|
|
255
|
-
|
|
202
|
+
Documentation context:
|
|
203
|
+
${contextText}
|
|
256
204
|
|
|
257
|
-
|
|
258
|
-
|
|
205
|
+
Sources available (reference these when relevant):
|
|
206
|
+
${citations.map(c => `- [${c.title}](${c.path})`).join('\n')}`,
|
|
207
|
+
messages: messages.slice(-5) as CoreMessage[],
|
|
208
|
+
maxTokens: 800,
|
|
209
|
+
})
|
|
259
210
|
|
|
260
|
-
return
|
|
211
|
+
return result.toDataStreamResponse({
|
|
212
|
+
headers: {
|
|
213
|
+
'X-Citations': Buffer.from(JSON.stringify(citations)).toString('base64'),
|
|
214
|
+
},
|
|
215
|
+
})
|
|
261
216
|
} catch (err) {
|
|
262
217
|
console.error('Chat error:', err)
|
|
263
|
-
return
|
|
218
|
+
return new Response(JSON.stringify({ error: 'Internal server error' }), { status: 500 })
|
|
264
219
|
}
|
|
265
220
|
}
|