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.
Files changed (110) hide show
  1. package/dist/auth/index.js +3 -3
  2. package/dist/cli.js +1 -1
  3. package/dist/commands/cron.js +0 -4
  4. package/dist/commands/generate/index.d.ts +3 -0
  5. package/dist/commands/generate/index.js +393 -0
  6. package/dist/commands/generate/scan.d.ts +41 -0
  7. package/dist/commands/generate/scan.js +256 -0
  8. package/dist/commands/generate/verify.d.ts +14 -0
  9. package/dist/commands/generate/verify.js +122 -0
  10. package/dist/commands/generate/write.d.ts +25 -0
  11. package/dist/commands/generate/write.js +120 -0
  12. package/dist/commands/import.js +4 -1
  13. package/dist/commands/llms-txt.js +6 -4
  14. package/dist/config/loader.d.ts +0 -1
  15. package/dist/config/loader.js +1 -1
  16. package/dist/generator/agents-md.d.ts +25 -0
  17. package/dist/generator/agents-md.js +122 -0
  18. package/dist/generator/index.d.ts +2 -0
  19. package/dist/generator/index.js +2 -0
  20. package/dist/generator/mdx-serializer.d.ts +11 -0
  21. package/dist/generator/mdx-serializer.js +135 -0
  22. package/dist/generator/organizer.d.ts +1 -16
  23. package/dist/generator/organizer.js +0 -38
  24. package/dist/generator/writer.js +5 -4
  25. package/dist/llm/proxy-client.d.ts +32 -0
  26. package/dist/llm/proxy-client.js +103 -0
  27. package/dist/scanner/csharp.d.ts +0 -4
  28. package/dist/scanner/csharp.js +9 -49
  29. package/dist/scanner/go.d.ts +0 -3
  30. package/dist/scanner/go.js +8 -35
  31. package/dist/scanner/java.d.ts +0 -4
  32. package/dist/scanner/java.js +9 -49
  33. package/dist/scanner/kotlin.d.ts +0 -3
  34. package/dist/scanner/kotlin.js +6 -33
  35. package/dist/scanner/php.d.ts +0 -10
  36. package/dist/scanner/php.js +11 -55
  37. package/dist/scanner/ruby.d.ts +0 -3
  38. package/dist/scanner/ruby.js +8 -38
  39. package/dist/scanner/rust.d.ts +0 -3
  40. package/dist/scanner/rust.js +10 -37
  41. package/dist/scanner/swift.d.ts +0 -3
  42. package/dist/scanner/swift.js +8 -35
  43. package/dist/scanner/utils.d.ts +41 -0
  44. package/dist/scanner/utils.js +97 -0
  45. package/dist/template/docs.json +5 -2
  46. package/dist/template/next.config.mjs +31 -0
  47. package/dist/template/package.json +5 -3
  48. package/dist/template/src/app/layout.tsx +13 -13
  49. package/dist/template/src/app/llms-full.md/route.ts +29 -0
  50. package/dist/template/src/app/llms.txt/route.ts +29 -0
  51. package/dist/template/src/app/md/[...slug]/route.ts +174 -0
  52. package/dist/template/src/app/reference/route.ts +22 -18
  53. package/dist/template/src/app/sitemap.ts +1 -1
  54. package/dist/template/src/components/ai-chat-impl.tsx +206 -0
  55. package/dist/template/src/components/ai-chat.tsx +20 -193
  56. package/dist/template/src/components/mdx/index.tsx +27 -4
  57. package/dist/template/src/lib/fonts.ts +135 -0
  58. package/dist/template/src/middleware.ts +101 -0
  59. package/dist/template/src/styles/globals.css +28 -20
  60. package/dist/utils/files.d.ts +0 -8
  61. package/dist/utils/files.js +0 -33
  62. package/package.json +1 -1
  63. package/dist/autofix/autofix.test.d.ts +0 -1
  64. package/dist/autofix/autofix.test.js +0 -487
  65. package/dist/commands/generate.d.ts +0 -9
  66. package/dist/commands/generate.js +0 -739
  67. package/dist/generator/generator.test.d.ts +0 -1
  68. package/dist/generator/generator.test.js +0 -259
  69. package/dist/generator/writer.test.d.ts +0 -1
  70. package/dist/generator/writer.test.js +0 -411
  71. package/dist/llm/llm.manual-test.d.ts +0 -1
  72. package/dist/llm/llm.manual-test.js +0 -112
  73. package/dist/llm/llm.mock-test.d.ts +0 -4
  74. package/dist/llm/llm.mock-test.js +0 -79
  75. package/dist/plugins/index.d.ts +0 -47
  76. package/dist/plugins/index.js +0 -181
  77. package/dist/scanner/content-type.test.d.ts +0 -1
  78. package/dist/scanner/content-type.test.js +0 -231
  79. package/dist/scanner/integration.test.d.ts +0 -4
  80. package/dist/scanner/integration.test.js +0 -180
  81. package/dist/scanner/scanner.test.d.ts +0 -1
  82. package/dist/scanner/scanner.test.js +0 -210
  83. package/dist/scanner/typescript.manual-test.d.ts +0 -1
  84. package/dist/scanner/typescript.manual-test.js +0 -112
  85. package/dist/template/src/app/docs/auth/page.mdx +0 -589
  86. package/dist/template/src/app/docs/autofix/page.mdx +0 -624
  87. package/dist/template/src/app/docs/cli/page.mdx +0 -217
  88. package/dist/template/src/app/docs/config/page.mdx +0 -428
  89. package/dist/template/src/app/docs/configuration/page.mdx +0 -86
  90. package/dist/template/src/app/docs/deployment/page.mdx +0 -112
  91. package/dist/template/src/app/docs/generator/generator.md +0 -504
  92. package/dist/template/src/app/docs/generator/organizer.md +0 -779
  93. package/dist/template/src/app/docs/generator/page.mdx +0 -613
  94. package/dist/template/src/app/docs/github/page.mdx +0 -502
  95. package/dist/template/src/app/docs/llm/anthropic-client.md +0 -549
  96. package/dist/template/src/app/docs/llm/index.md +0 -471
  97. package/dist/template/src/app/docs/llm/page.mdx +0 -428
  98. package/dist/template/src/app/docs/plugins/page.mdx +0 -1793
  99. package/dist/template/src/app/docs/pro/page.mdx +0 -121
  100. package/dist/template/src/app/docs/quickstart/page.mdx +0 -93
  101. package/dist/template/src/app/docs/scanner/content-type.md +0 -599
  102. package/dist/template/src/app/docs/scanner/index.md +0 -212
  103. package/dist/template/src/app/docs/scanner/page.mdx +0 -307
  104. package/dist/template/src/app/docs/scanner/python.md +0 -469
  105. package/dist/template/src/app/docs/scanner/python_parser.md +0 -1056
  106. package/dist/template/src/app/docs/scanner/rust.md +0 -325
  107. package/dist/template/src/app/docs/scanner/typescript.md +0 -201
  108. package/dist/template/src/app/icon.tsx +0 -29
  109. package/dist/utils/validation.d.ts +0 -1
  110. 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) => `![${alt || 'Screenshot'}](${src})`
157
+ )
158
+ out = out.replace(/<Screenshot\s+(?:[^>]*?src=["']([^"']+)["'][^>]*?)>[\s\S]*?<\/Screenshot>/g,
159
+ (_m, src: string) => `![Screenshot](${src})`
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
- export const GET = ApiReference({
22
- spec: {
23
- url: '/api/openapi',
24
- },
25
- // Scalar configuration options
26
- theme: 'default',
27
- hideModels: false,
28
- darkMode: true,
29
- servers,
30
- // Enable request sharing (#76)
31
- showSidebar: true,
32
- searchHotKey: 'k',
33
- metaData: {
34
- title: 'API Reference',
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&apos;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
+ }