skrypt-ai 0.7.0 → 0.8.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.js +3 -3
- package/dist/cli.js +1 -1
- package/dist/commands/cron.js +0 -4
- package/dist/commands/generate/index.d.ts +3 -0
- package/dist/commands/generate/index.js +393 -0
- package/dist/commands/generate/scan.d.ts +41 -0
- package/dist/commands/generate/scan.js +256 -0
- package/dist/commands/generate/verify.d.ts +14 -0
- package/dist/commands/generate/verify.js +122 -0
- package/dist/commands/generate/write.d.ts +25 -0
- package/dist/commands/generate/write.js +120 -0
- package/dist/commands/import.js +4 -1
- package/dist/commands/llms-txt.js +6 -4
- package/dist/config/loader.d.ts +0 -1
- package/dist/config/loader.js +1 -1
- package/dist/generator/agents-md.d.ts +25 -0
- package/dist/generator/agents-md.js +122 -0
- package/dist/generator/index.d.ts +2 -0
- package/dist/generator/index.js +2 -0
- package/dist/generator/mdx-serializer.d.ts +11 -0
- package/dist/generator/mdx-serializer.js +135 -0
- package/dist/generator/organizer.d.ts +1 -16
- package/dist/generator/organizer.js +0 -38
- package/dist/generator/writer.js +5 -4
- package/dist/llm/proxy-client.d.ts +32 -0
- package/dist/llm/proxy-client.js +103 -0
- package/dist/scanner/csharp.d.ts +0 -4
- package/dist/scanner/csharp.js +9 -49
- package/dist/scanner/go.d.ts +0 -3
- package/dist/scanner/go.js +8 -35
- package/dist/scanner/java.d.ts +0 -4
- package/dist/scanner/java.js +9 -49
- package/dist/scanner/kotlin.d.ts +0 -3
- package/dist/scanner/kotlin.js +6 -33
- package/dist/scanner/php.d.ts +0 -10
- package/dist/scanner/php.js +11 -55
- package/dist/scanner/ruby.d.ts +0 -3
- package/dist/scanner/ruby.js +8 -38
- package/dist/scanner/rust.d.ts +0 -3
- package/dist/scanner/rust.js +10 -37
- package/dist/scanner/swift.d.ts +0 -3
- package/dist/scanner/swift.js +8 -35
- package/dist/scanner/utils.d.ts +41 -0
- package/dist/scanner/utils.js +97 -0
- package/dist/template/docs.json +5 -2
- package/dist/template/next.config.mjs +31 -0
- package/dist/template/package.json +5 -3
- package/dist/template/src/app/layout.tsx +13 -13
- package/dist/template/src/app/llms-full.md/route.ts +29 -0
- package/dist/template/src/app/llms.txt/route.ts +29 -0
- package/dist/template/src/app/md/[...slug]/route.ts +174 -0
- package/dist/template/src/app/reference/route.ts +22 -18
- package/dist/template/src/app/sitemap.ts +1 -1
- package/dist/template/src/components/ai-chat-impl.tsx +206 -0
- package/dist/template/src/components/ai-chat.tsx +20 -193
- package/dist/template/src/components/mdx/index.tsx +27 -4
- package/dist/template/src/lib/fonts.ts +135 -0
- package/dist/template/src/middleware.ts +101 -0
- package/dist/template/src/styles/globals.css +28 -20
- package/dist/utils/files.d.ts +0 -8
- package/dist/utils/files.js +0 -33
- package/package.json +1 -1
- package/dist/autofix/autofix.test.d.ts +0 -1
- package/dist/autofix/autofix.test.js +0 -487
- package/dist/commands/generate.d.ts +0 -9
- package/dist/commands/generate.js +0 -739
- package/dist/generator/generator.test.d.ts +0 -1
- package/dist/generator/generator.test.js +0 -259
- package/dist/generator/writer.test.d.ts +0 -1
- package/dist/generator/writer.test.js +0 -411
- package/dist/llm/llm.manual-test.d.ts +0 -1
- package/dist/llm/llm.manual-test.js +0 -112
- package/dist/llm/llm.mock-test.d.ts +0 -4
- package/dist/llm/llm.mock-test.js +0 -79
- package/dist/plugins/index.d.ts +0 -47
- package/dist/plugins/index.js +0 -181
- package/dist/scanner/content-type.test.d.ts +0 -1
- package/dist/scanner/content-type.test.js +0 -231
- package/dist/scanner/integration.test.d.ts +0 -4
- package/dist/scanner/integration.test.js +0 -180
- package/dist/scanner/scanner.test.d.ts +0 -1
- package/dist/scanner/scanner.test.js +0 -210
- package/dist/scanner/typescript.manual-test.d.ts +0 -1
- package/dist/scanner/typescript.manual-test.js +0 -112
- package/dist/template/src/app/docs/auth/page.mdx +0 -589
- package/dist/template/src/app/docs/autofix/page.mdx +0 -624
- package/dist/template/src/app/docs/cli/page.mdx +0 -217
- package/dist/template/src/app/docs/config/page.mdx +0 -428
- package/dist/template/src/app/docs/configuration/page.mdx +0 -86
- package/dist/template/src/app/docs/deployment/page.mdx +0 -112
- package/dist/template/src/app/docs/generator/generator.md +0 -504
- package/dist/template/src/app/docs/generator/organizer.md +0 -779
- package/dist/template/src/app/docs/generator/page.mdx +0 -613
- package/dist/template/src/app/docs/github/page.mdx +0 -502
- package/dist/template/src/app/docs/llm/anthropic-client.md +0 -549
- package/dist/template/src/app/docs/llm/index.md +0 -471
- package/dist/template/src/app/docs/llm/page.mdx +0 -428
- package/dist/template/src/app/docs/plugins/page.mdx +0 -1793
- package/dist/template/src/app/docs/pro/page.mdx +0 -121
- package/dist/template/src/app/docs/quickstart/page.mdx +0 -93
- package/dist/template/src/app/docs/scanner/content-type.md +0 -599
- package/dist/template/src/app/docs/scanner/index.md +0 -212
- package/dist/template/src/app/docs/scanner/page.mdx +0 -307
- package/dist/template/src/app/docs/scanner/python.md +0 -469
- package/dist/template/src/app/docs/scanner/python_parser.md +0 -1056
- package/dist/template/src/app/docs/scanner/rust.md +0 -325
- package/dist/template/src/app/docs/scanner/typescript.md +0 -201
- package/dist/template/src/app/icon.tsx +0 -29
- package/dist/utils/validation.d.ts +0 -1
- package/dist/utils/validation.js +0 -12
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { NextResponse } from 'next/server'
|
|
2
|
+
import { readFile } from 'fs/promises'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
|
|
5
|
+
export async function GET() {
|
|
6
|
+
const paths = [
|
|
7
|
+
join(process.cwd(), 'content', 'docs', 'llms.txt'),
|
|
8
|
+
join(process.cwd(), 'public', 'llms.txt'),
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
for (const path of paths) {
|
|
12
|
+
try {
|
|
13
|
+
const content = await readFile(path, 'utf-8')
|
|
14
|
+
return new NextResponse(content, {
|
|
15
|
+
headers: {
|
|
16
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
17
|
+
'Cache-Control': 'public, max-age=3600, s-maxage=86400',
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
} catch {
|
|
21
|
+
continue
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return new NextResponse('# No llms.txt found\n\nRun `skrypt generate` to create one.\n', {
|
|
26
|
+
status: 404,
|
|
27
|
+
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
|
28
|
+
})
|
|
29
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { NextRequest, NextResponse } from 'next/server'
|
|
2
|
+
import { readFile } from 'fs/promises'
|
|
3
|
+
import { join, resolve } from 'path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Markdown route handler for content negotiation.
|
|
7
|
+
*
|
|
8
|
+
* Serves raw MDX/Markdown content with JSX components stripped,
|
|
9
|
+
* returning clean markdown that AI agents can consume directly.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface RouteParams {
|
|
13
|
+
params: Promise<{ slug: string[] }>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function GET(request: NextRequest, { params }: RouteParams) {
|
|
17
|
+
const { slug } = await params
|
|
18
|
+
const contentDir = resolve(process.cwd(), 'content', 'docs')
|
|
19
|
+
|
|
20
|
+
// Reject path traversal: slug segments must not contain .. or absolute paths
|
|
21
|
+
if (slug.some(s => s === '..' || s === '.' || s.includes('/') || s.includes('\\'))) {
|
|
22
|
+
return new NextResponse('# 400 Bad Request\n', {
|
|
23
|
+
status: 400,
|
|
24
|
+
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Try to find the MDX/MD source file
|
|
29
|
+
const filePath = join(contentDir, ...slug)
|
|
30
|
+
|
|
31
|
+
// Double-check resolved path stays within content directory
|
|
32
|
+
const resolved = resolve(filePath)
|
|
33
|
+
if (!resolved.startsWith(contentDir)) {
|
|
34
|
+
return new NextResponse('# 400 Bad Request\n', {
|
|
35
|
+
status: 400,
|
|
36
|
+
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
|
|
37
|
+
})
|
|
38
|
+
}
|
|
39
|
+
let content: string | null = null
|
|
40
|
+
let resolvedPath = ''
|
|
41
|
+
|
|
42
|
+
for (const ext of ['.mdx', '.md', '/index.mdx', '/index.md']) {
|
|
43
|
+
try {
|
|
44
|
+
resolvedPath = filePath + ext
|
|
45
|
+
content = await readFile(resolvedPath, 'utf-8')
|
|
46
|
+
break
|
|
47
|
+
} catch {
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!content) {
|
|
53
|
+
return new NextResponse('# 404 Not Found\n\nThis documentation page does not exist.\n', {
|
|
54
|
+
status: 404,
|
|
55
|
+
headers: { 'Content-Type': 'text/markdown; charset=utf-8' },
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Strip JSX components for clean markdown output
|
|
60
|
+
const markdown = stripMdxToMarkdown(content)
|
|
61
|
+
|
|
62
|
+
return new NextResponse(markdown, {
|
|
63
|
+
status: 200,
|
|
64
|
+
headers: {
|
|
65
|
+
'Content-Type': 'text/markdown; charset=utf-8',
|
|
66
|
+
'Cache-Control': 'public, max-age=3600, s-maxage=86400',
|
|
67
|
+
'X-Content-Source': 'mdx',
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Lightweight MDX-to-markdown stripping for the route handler.
|
|
74
|
+
* Runs at request time so must be fast — uses regex, not a full parser.
|
|
75
|
+
*/
|
|
76
|
+
function stripMdxToMarkdown(mdx: string): string {
|
|
77
|
+
let out = mdx
|
|
78
|
+
|
|
79
|
+
// Remove import/export statements
|
|
80
|
+
out = out.replace(/^import\s+.*$/gm, '')
|
|
81
|
+
out = out.replace(/^export\s+(?!default\s).*$/gm, '')
|
|
82
|
+
|
|
83
|
+
// CodeGroup → flatten (keep code blocks)
|
|
84
|
+
out = out.replace(/<CodeGroup>\s*([\s\S]*?)\s*<\/CodeGroup>/g, (_m, inner: string) => inner.trim())
|
|
85
|
+
|
|
86
|
+
// Callout variants → blockquotes
|
|
87
|
+
for (const tag of ['Callout', 'Info', 'Warning', 'Success', 'Error', 'Tip', 'Note']) {
|
|
88
|
+
out = out.replace(
|
|
89
|
+
new RegExp(`<${tag}\\s+type=["']([^"']+)["'][^>]*>\\s*([\\s\\S]*?)\\s*<\\/${tag}>`, 'g'),
|
|
90
|
+
(_m, type: string, content: string) => {
|
|
91
|
+
const label = type.charAt(0).toUpperCase() + type.slice(1)
|
|
92
|
+
return `> **${label}:** ${content.trim()}\n`
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
out = out.replace(
|
|
96
|
+
new RegExp(`<${tag}(?:\\s[^>]*)?>\\s*([\\s\\S]*?)\\s*<\\/${tag}>`, 'g'),
|
|
97
|
+
(_m, content: string) => {
|
|
98
|
+
const label = tag === 'Callout' ? 'Note' : tag
|
|
99
|
+
return `> **${label}:** ${content.trim()}\n`
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// CardGroup → unwrap
|
|
105
|
+
out = out.replace(/<CardGroup[^>]*>\s*/g, '')
|
|
106
|
+
out = out.replace(/\s*<\/CardGroup>/g, '')
|
|
107
|
+
|
|
108
|
+
// Card with href
|
|
109
|
+
out = out.replace(
|
|
110
|
+
/<Card\s+title=["']([^"']+)["']\s+(?:[^>]*?href=["']([^"']+)["'][^>]*?)>\s*([\s\S]*?)\s*<\/Card>/g,
|
|
111
|
+
(_m, title: string, href: string, desc: string) => {
|
|
112
|
+
const d = desc.trim().replace(/<[^>]+>/g, '')
|
|
113
|
+
return d ? `- **[${title}](${href})** — ${d}` : `- **[${title}](${href})**`
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
// Card without href
|
|
118
|
+
out = out.replace(
|
|
119
|
+
/<Card\s+title=["']([^"']+)["'][^>]*>\s*([\s\S]*?)\s*<\/Card>/g,
|
|
120
|
+
(_m, title: string, desc: string) => {
|
|
121
|
+
const d = desc.trim().replace(/<[^>]+>/g, '')
|
|
122
|
+
return d ? `- **${title}** — ${d}` : `- **${title}**`
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
// Tabs
|
|
127
|
+
out = out.replace(/<TabList>\s*([\s\S]*?)\s*<\/TabList>/g, '')
|
|
128
|
+
out = out.replace(/<Tab\s+label=["']([^"']+)["'][^>]*>\s*([\s\S]*?)\s*<\/Tab>/g,
|
|
129
|
+
(_m, label: string, content: string) => `#### ${label}\n\n${content.trim()}\n`
|
|
130
|
+
)
|
|
131
|
+
out = out.replace(/<TabPanel[^>]*>\s*([\s\S]*?)\s*<\/TabPanel>/g, (_m, c: string) => c.trim())
|
|
132
|
+
out = out.replace(/<Tabs[^>]*>\s*/g, '')
|
|
133
|
+
out = out.replace(/\s*<\/Tabs>/g, '')
|
|
134
|
+
|
|
135
|
+
// Steps
|
|
136
|
+
out = out.replace(/<Steps>\s*([\s\S]*?)\s*<\/Steps>/g, (_match, inner: string) => {
|
|
137
|
+
let n = 0
|
|
138
|
+
return inner.replace(
|
|
139
|
+
/<Step\s*(?:title=["']([^"']+)["'][^>]*)?>?\s*([\s\S]*?)\s*<\/Step>/g,
|
|
140
|
+
(_m, title: string | undefined, content: string) => {
|
|
141
|
+
n++
|
|
142
|
+
return `${n}. ${title ? `**${title}** ` : ''}${content.trim()}\n`
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// Accordion
|
|
148
|
+
out = out.replace(/<AccordionGroup[^>]*>\s*/g, '')
|
|
149
|
+
out = out.replace(/\s*<\/AccordionGroup>/g, '')
|
|
150
|
+
out = out.replace(/<Accordion\s+title=["']([^"']+)["'][^>]*>\s*([\s\S]*?)\s*<\/Accordion>/g,
|
|
151
|
+
(_m, title: string, content: string) => `**${title}**\n\n${content.trim()}\n`
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
// Screenshot → image
|
|
155
|
+
out = out.replace(/<Screenshot\s+(?:[^>]*?src=["']([^"']+)["'][^>]*?)(?:\s+alt=["']([^"']+)["'])?[^>]*\s*\/?>/g,
|
|
156
|
+
(_m, src: string, alt?: string) => ``
|
|
157
|
+
)
|
|
158
|
+
out = out.replace(/<Screenshot\s+(?:[^>]*?src=["']([^"']+)["'][^>]*?)>[\s\S]*?<\/Screenshot>/g,
|
|
159
|
+
(_m, src: string) => ``
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
// Frame → unwrap
|
|
163
|
+
out = out.replace(/<Frame[^>]*>\s*/g, '')
|
|
164
|
+
out = out.replace(/\s*<\/Frame>/g, '')
|
|
165
|
+
|
|
166
|
+
// Catch-all: remove remaining JSX
|
|
167
|
+
out = out.replace(/<[A-Z][A-Za-z]*\s[^>]*\/>/g, '')
|
|
168
|
+
out = out.replace(/<[A-Z][A-Za-z]*(?:\s[^>]*)?>/g, '')
|
|
169
|
+
out = out.replace(/<\/[A-Z][A-Za-z]*>/g, '')
|
|
170
|
+
|
|
171
|
+
// Clean up
|
|
172
|
+
out = out.replace(/\n{4,}/g, '\n\n\n')
|
|
173
|
+
return out.trim() + '\n'
|
|
174
|
+
}
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { ApiReference } from '@scalar/nextjs-api-reference'
|
|
2
|
-
|
|
3
1
|
const MOCK_SERVER_ENABLED = process.env.NEXT_PUBLIC_MOCK_SERVER === 'true'
|
|
4
2
|
|
|
5
3
|
// Build servers list
|
|
@@ -18,19 +16,25 @@ const servers = [
|
|
|
18
16
|
}] : []),
|
|
19
17
|
]
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
19
|
+
// Dynamic import — @scalar/nextjs-api-reference is only loaded when
|
|
20
|
+
// this route is actually hit (not bundled into the main docs pages)
|
|
21
|
+
export const GET = async () => {
|
|
22
|
+
const { ApiReference } = await import('@scalar/nextjs-api-reference')
|
|
23
|
+
const handler = ApiReference({
|
|
24
|
+
spec: {
|
|
25
|
+
url: '/api/openapi',
|
|
26
|
+
},
|
|
27
|
+
// Scalar configuration options
|
|
28
|
+
theme: 'default',
|
|
29
|
+
hideModels: false,
|
|
30
|
+
darkMode: true,
|
|
31
|
+
servers,
|
|
32
|
+
// Enable request sharing (#76)
|
|
33
|
+
showSidebar: true,
|
|
34
|
+
searchHotKey: 'k',
|
|
35
|
+
metaData: {
|
|
36
|
+
title: 'API Reference',
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
return handler()
|
|
40
|
+
}
|
|
@@ -52,7 +52,7 @@ async function scanDir(dir: string, prefix: string): Promise<string[]> {
|
|
|
52
52
|
|
|
53
53
|
if (entry.isDirectory()) {
|
|
54
54
|
files.push(...await scanDir(join(dir, entry.name), path))
|
|
55
|
-
} else if (entry.name.endsWith('.mdx') || entry.name.endsWith('.md')) {
|
|
55
|
+
} else if ((entry.name.endsWith('.mdx') || entry.name.endsWith('.md')) && !entry.name.startsWith('llms') && !entry.name.startsWith('_') && entry.name !== 'README.md') {
|
|
56
56
|
files.push(path)
|
|
57
57
|
}
|
|
58
58
|
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useEffect } from 'react'
|
|
4
|
+
import { useChat } from '@ai-sdk/react'
|
|
5
|
+
import { Streamdown } from 'streamdown'
|
|
6
|
+
import { MessageSquare, X, Send, ExternalLink } from 'lucide-react'
|
|
7
|
+
|
|
8
|
+
interface Citation {
|
|
9
|
+
title: string
|
|
10
|
+
path: string
|
|
11
|
+
snippet: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface AIChatProps {
|
|
15
|
+
projectName?: string
|
|
16
|
+
apiEndpoint?: string
|
|
17
|
+
placeholder?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function AIChat({
|
|
21
|
+
projectName = 'this documentation',
|
|
22
|
+
apiEndpoint = '/api/chat',
|
|
23
|
+
placeholder = 'Ask a question...',
|
|
24
|
+
}: AIChatProps) {
|
|
25
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
26
|
+
const [citations, setCitations] = useState<Map<string, Citation[]>>(new Map())
|
|
27
|
+
const messagesEndRef = useRef<HTMLDivElement>(null)
|
|
28
|
+
|
|
29
|
+
const { messages, input, handleInputChange, handleSubmit, status, error } = useChat({
|
|
30
|
+
api: apiEndpoint,
|
|
31
|
+
onResponse(response: Response) {
|
|
32
|
+
// Extract citations from response header
|
|
33
|
+
const citationsHeader = response.headers.get('X-Citations')
|
|
34
|
+
if (citationsHeader) {
|
|
35
|
+
try {
|
|
36
|
+
const parsed = JSON.parse(atob(citationsHeader)) as Citation[]
|
|
37
|
+
// Associate citations with the next assistant message
|
|
38
|
+
setCitations(prev => {
|
|
39
|
+
const next = new Map(prev)
|
|
40
|
+
// Use a temporary key that will be replaced when the message arrives
|
|
41
|
+
next.set('_pending', parsed)
|
|
42
|
+
return next
|
|
43
|
+
})
|
|
44
|
+
} catch {
|
|
45
|
+
// Skip invalid citations
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
onFinish(message: { id: string }) {
|
|
50
|
+
// Move pending citations to the actual message ID
|
|
51
|
+
setCitations(prev => {
|
|
52
|
+
const next = new Map(prev)
|
|
53
|
+
const pending = next.get('_pending')
|
|
54
|
+
if (pending) {
|
|
55
|
+
next.delete('_pending')
|
|
56
|
+
next.set(message.id, pending)
|
|
57
|
+
}
|
|
58
|
+
return next
|
|
59
|
+
})
|
|
60
|
+
},
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const isStreaming = status === 'streaming'
|
|
64
|
+
const isLoading = status === 'submitted'
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
68
|
+
}, [messages, isStreaming])
|
|
69
|
+
|
|
70
|
+
if (!isOpen) {
|
|
71
|
+
return (
|
|
72
|
+
<button
|
|
73
|
+
onClick={() => setIsOpen(true)}
|
|
74
|
+
className="fixed bottom-6 right-6 z-50 flex h-14 w-14 items-center justify-center rounded-full bg-[var(--color-text)] text-[var(--color-bg)] shadow-lg transition-transform hover:scale-105"
|
|
75
|
+
aria-label="Open AI chat"
|
|
76
|
+
>
|
|
77
|
+
<MessageSquare className="h-6 w-6" />
|
|
78
|
+
</button>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="fixed bottom-6 right-6 z-50 flex h-[500px] w-[380px] flex-col overflow-hidden rounded-2xl border border-[var(--color-border)] bg-[var(--color-bg)] shadow-2xl">
|
|
84
|
+
{/* Header */}
|
|
85
|
+
<div className="flex items-center justify-between border-b border-[var(--color-border)] px-4 py-3">
|
|
86
|
+
<div className="flex items-center gap-2">
|
|
87
|
+
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--color-text)]">
|
|
88
|
+
<MessageSquare className="h-4 w-4 text-[var(--color-bg)]" />
|
|
89
|
+
</div>
|
|
90
|
+
<div>
|
|
91
|
+
<p className="text-sm font-medium text-[var(--color-text)]">Ask AI</p>
|
|
92
|
+
<p className="text-xs text-[var(--color-text-tertiary)]">About {projectName}</p>
|
|
93
|
+
</div>
|
|
94
|
+
</div>
|
|
95
|
+
<button
|
|
96
|
+
onClick={() => setIsOpen(false)}
|
|
97
|
+
className="rounded-lg p-1 text-[var(--color-text-tertiary)] transition-colors hover:bg-[var(--color-bg-secondary)] hover:text-[var(--color-text-secondary)]"
|
|
98
|
+
aria-label="Close chat"
|
|
99
|
+
>
|
|
100
|
+
<X className="h-5 w-5" />
|
|
101
|
+
</button>
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
{/* Messages */}
|
|
105
|
+
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
106
|
+
{messages.length === 0 && (
|
|
107
|
+
<div className="text-center text-sm text-[var(--color-text-tertiary)] py-8">
|
|
108
|
+
<p>Ask me anything about {projectName}.</p>
|
|
109
|
+
<p className="mt-2 text-xs">I'll search the docs and give you an answer with sources.</p>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
|
|
113
|
+
{messages.map((message) => (
|
|
114
|
+
<div
|
|
115
|
+
key={message.id}
|
|
116
|
+
className={`flex ${message.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
|
117
|
+
>
|
|
118
|
+
<div
|
|
119
|
+
className={`max-w-[85%] rounded-2xl px-4 py-2 text-sm ${
|
|
120
|
+
message.role === 'user'
|
|
121
|
+
? 'bg-[var(--color-text)] text-[var(--color-bg)]'
|
|
122
|
+
: 'bg-[var(--color-bg-tertiary)] text-[var(--color-text)]'
|
|
123
|
+
}`}
|
|
124
|
+
>
|
|
125
|
+
{message.role === 'assistant' ? (
|
|
126
|
+
<div className="chat-markdown prose prose-sm max-w-none">
|
|
127
|
+
<Streamdown
|
|
128
|
+
shikiTheme={['github-light', 'github-dark']}
|
|
129
|
+
isAnimating={isStreaming && message.id === messages[messages.length - 1]?.id}
|
|
130
|
+
>
|
|
131
|
+
{message.content}
|
|
132
|
+
</Streamdown>
|
|
133
|
+
</div>
|
|
134
|
+
) : (
|
|
135
|
+
<p className="whitespace-pre-wrap">{message.content}</p>
|
|
136
|
+
)}
|
|
137
|
+
|
|
138
|
+
{/* Citations */}
|
|
139
|
+
{citations.get(message.id) && (
|
|
140
|
+
<div className="mt-3 border-t border-[var(--color-border)] pt-2">
|
|
141
|
+
<p className="text-xs font-medium text-[var(--color-text-tertiary)] mb-1">Sources:</p>
|
|
142
|
+
<div className="space-y-1">
|
|
143
|
+
{citations.get(message.id)!.map((citation, j) => (
|
|
144
|
+
<a
|
|
145
|
+
key={j}
|
|
146
|
+
href={citation.path}
|
|
147
|
+
className="flex items-center gap-1 text-xs text-[var(--color-primary)] hover:underline"
|
|
148
|
+
>
|
|
149
|
+
<ExternalLink className="h-3 w-3" />
|
|
150
|
+
{citation.title}
|
|
151
|
+
</a>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
))}
|
|
159
|
+
|
|
160
|
+
{isLoading && (
|
|
161
|
+
<div className="flex justify-start">
|
|
162
|
+
<div className="flex items-center gap-2 rounded-2xl bg-[var(--color-bg-tertiary)] px-4 py-2 text-sm text-[var(--color-text-tertiary)]">
|
|
163
|
+
<span className="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
|
164
|
+
Searching docs...
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
{error && (
|
|
170
|
+
<div className="flex justify-start">
|
|
171
|
+
<div className="rounded-2xl bg-red-50 px-4 py-2 text-sm text-red-600">
|
|
172
|
+
Something went wrong. Please try again.
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
|
|
177
|
+
<div ref={messagesEndRef} />
|
|
178
|
+
</div>
|
|
179
|
+
|
|
180
|
+
{/* Input */}
|
|
181
|
+
<form
|
|
182
|
+
onSubmit={handleSubmit}
|
|
183
|
+
className="border-t border-[var(--color-border)] p-3"
|
|
184
|
+
>
|
|
185
|
+
<div className="flex items-center gap-2">
|
|
186
|
+
<input
|
|
187
|
+
type="text"
|
|
188
|
+
value={input}
|
|
189
|
+
onChange={handleInputChange}
|
|
190
|
+
placeholder={placeholder}
|
|
191
|
+
disabled={isStreaming || isLoading}
|
|
192
|
+
className="flex-1 rounded-xl border border-[var(--color-border)] bg-[var(--color-bg-secondary)] px-4 py-2 text-sm text-[var(--color-text)] outline-none transition-colors placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-strong)]"
|
|
193
|
+
/>
|
|
194
|
+
<button
|
|
195
|
+
type="submit"
|
|
196
|
+
disabled={!input.trim() || isStreaming || isLoading}
|
|
197
|
+
className="flex h-10 w-10 items-center justify-center rounded-xl bg-[var(--color-text)] text-[var(--color-bg)] transition-colors hover:opacity-90 disabled:opacity-50"
|
|
198
|
+
aria-label="Send message"
|
|
199
|
+
>
|
|
200
|
+
<Send className="h-4 w-4" />
|
|
201
|
+
</button>
|
|
202
|
+
</div>
|
|
203
|
+
</form>
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
}
|