@docsector/docsector-reader 4.3.2 → 4.4.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 (37) hide show
  1. package/.env.example +18 -0
  2. package/README.md +136 -5
  3. package/bin/docsector.js +36 -1
  4. package/docsector.config.js +44 -0
  5. package/package.json +3 -2
  6. package/public/robots.txt +4 -0
  7. package/src/ai-assistant/config.js +91 -0
  8. package/src/ai-assistant/indexing.js +50 -0
  9. package/src/ai-assistant/layout.js +56 -0
  10. package/src/ai-assistant/messages.js +41 -0
  11. package/src/ai-assistant/panel.js +22 -0
  12. package/src/ai-assistant/server.js +348 -0
  13. package/src/ai-assistant/session.js +91 -0
  14. package/src/ai-assistant/stream.js +125 -0
  15. package/src/components/DAssistantPanel.vue +701 -0
  16. package/src/components/DPage.vue +114 -4
  17. package/src/components/DPageAnchor.vue +11 -7
  18. package/src/components/DPageRichContent.vue +105 -0
  19. package/src/components/DPageTokens.vue +27 -16
  20. package/src/components/api-block-model.js +77 -1
  21. package/src/components/inline-code-copy.js +58 -0
  22. package/src/components/page-section-tokens.js +6 -4
  23. package/src/components/quasar-api-extends.json +235 -0
  24. package/src/composables/useAssistant.js +201 -0
  25. package/src/i18n/helpers.js +2 -0
  26. package/src/i18n/languages/en-US.hjson +22 -0
  27. package/src/i18n/languages/pt-BR.hjson +22 -0
  28. package/src/layouts/DefaultLayout.vue +22 -0
  29. package/src/markdown-agent.js +32 -0
  30. package/src/pages/manual/basic/ai-assistant.overview.en-US.md +69 -0
  31. package/src/pages/manual/basic/ai-assistant.overview.pt-BR.md +69 -0
  32. package/src/pages/manual/basic/d-page-anchor.overview.en-US.md +1 -1
  33. package/src/pages/manual/basic/d-page-anchor.overview.pt-BR.md +1 -1
  34. package/src/pages/manual.index.js +29 -0
  35. package/src/quasar.factory.js +166 -33
  36. package/src/sitemap.js +103 -0
  37. package/src/store/Layout.js +9 -1
@@ -0,0 +1,235 @@
1
+ {
2
+ "props": {
3
+ "readonly": {
4
+ "type": "Boolean",
5
+ "desc": "Put component in readonly mode",
6
+ "category": "state"
7
+ },
8
+ "disable": {
9
+ "type": "Boolean",
10
+ "desc": "Put component in disabled mode",
11
+ "category": "state"
12
+ },
13
+ "color": {
14
+ "type": "String",
15
+ "tsType": "NamedColor",
16
+ "desc": "Color name for component from the Quasar Color Palette",
17
+ "examples": ["'primary'", "'teal'", "'teal-10'"],
18
+ "category": "style"
19
+ },
20
+ "text-color": {
21
+ "type": "String",
22
+ "tsType": "NamedColor",
23
+ "desc": "Overrides text color (if needed); Color name from the Quasar Color Palette",
24
+ "examples": ["'primary'", "'teal'", "'teal-10'"],
25
+ "category": "style"
26
+ },
27
+ "dense": {
28
+ "type": "Boolean",
29
+ "desc": "Dense mode; occupies less space",
30
+ "category": "style"
31
+ },
32
+ "size": {
33
+ "type": "String",
34
+ "desc": "Size in CSS units, including unit name",
35
+ "examples": ["'16px'", "'2rem'"],
36
+ "category": "style"
37
+ },
38
+ "dark": {
39
+ "type": ["Boolean", "null"],
40
+ "default": "null",
41
+ "desc": "Notify the component that the background is a dark color",
42
+ "category": "style"
43
+ },
44
+ "icon": {
45
+ "type": "String",
46
+ "desc": "Icon name following Quasar convention; Make sure you have the icon library installed unless you are using 'img:' prefix; If 'none' (String) is used as value then no icon is rendered (but screen real estate will still be used for it)",
47
+ "examples": [
48
+ "'map'",
49
+ "'ion-add'",
50
+ "'img:https://cdn.quasar.dev/logo-v2/svg/logo.svg'",
51
+ "'img:path/to/some_image.png'"
52
+ ],
53
+ "category": "content"
54
+ },
55
+ "flat": {
56
+ "type": "Boolean",
57
+ "desc": "Applies a 'flat' design (no default shadow)",
58
+ "category": "style"
59
+ },
60
+ "bordered": {
61
+ "type": "Boolean",
62
+ "desc": "Applies a default border to the component",
63
+ "category": "style"
64
+ },
65
+ "square": {
66
+ "type": "Boolean",
67
+ "desc": "Removes border-radius so borders are squared",
68
+ "category": "style"
69
+ },
70
+ "rounded": {
71
+ "type": "Boolean",
72
+ "desc": "Applies a small standard border-radius for a squared shape of the component",
73
+ "category": "style"
74
+ },
75
+ "tabindex": {
76
+ "type": ["Number", "String"],
77
+ "desc": "Tabindex HTML attribute value",
78
+ "examples": ["100", "'0'"],
79
+ "category": "general"
80
+ },
81
+ "transition": {
82
+ "type": "String",
83
+ "desc": "One of Quasar's embedded transitions",
84
+ "examples": ["'fade'", "'slide-down'"],
85
+ "category": "transition"
86
+ },
87
+ "animation-speed": {
88
+ "type": ["String", "Number"],
89
+ "desc": "Animation speed (in milliseconds, without unit)",
90
+ "examples": ["500", "'1200'"],
91
+ "category": "style"
92
+ },
93
+ "model-value": {
94
+ "desc": "Model of the component; Either use this property (along with a listener for 'update:model-value' event) OR use v-model directive",
95
+ "required": true,
96
+ "syncable": true,
97
+ "category": "model"
98
+ },
99
+ "html": {
100
+ "type": "Boolean",
101
+ "desc": "Force use of textContent instead of innerHTML to render text; Use it when the text might be unsafe (from user input)",
102
+ "category": "behavior"
103
+ },
104
+ "tag": {
105
+ "type": "String",
106
+ "desc": "HTML tag to use",
107
+ "category": "content"
108
+ },
109
+ "scroll-target": {
110
+ "type": ["Element", "String"],
111
+ "desc": "CSS selector or DOM element to be used as a custom scroll container instead of the auto detected one",
112
+ "examples": [
113
+ ".scroll-target-class",
114
+ "#scroll-target-id",
115
+ "$refs.scrollTarget",
116
+ "document.body"
117
+ ],
118
+ "category": "behavior"
119
+ },
120
+ "ripple": {
121
+ "type": ["Boolean", "Object"],
122
+ "desc": "Configure material ripple (disable it by setting it to 'false' or supply a config object)",
123
+ "default": "true",
124
+ "examples": [
125
+ "false",
126
+ "{ early: true, center: true, color: 'teal', keyCodes: [] }"
127
+ ],
128
+ "category": "style"
129
+ },
130
+ "evt": {
131
+ "type": "Event",
132
+ "desc": "JS event object"
133
+ }
134
+ },
135
+ "slots": {
136
+ "default": {
137
+ "desc": "Default slot in the devland unslotted content of the component"
138
+ }
139
+ },
140
+ "events": {
141
+ "update:model-value": {
142
+ "desc": "Emitted when the component needs to change the model; Is also used by v-model",
143
+ "params": {
144
+ "value": {
145
+ "type": "Any",
146
+ "desc": "New model value",
147
+ "required": true
148
+ }
149
+ }
150
+ },
151
+ "show": {
152
+ "desc": "Emitted after component has triggered show()",
153
+ "params": {
154
+ "evt": {
155
+ "extends": "evt",
156
+ "required": true
157
+ }
158
+ }
159
+ },
160
+ "before-show": {
161
+ "desc": "Emitted when component triggers show() but before it finishes doing it",
162
+ "params": {
163
+ "evt": {
164
+ "extends": "evt",
165
+ "required": true
166
+ }
167
+ }
168
+ },
169
+ "after-show": {
170
+ "desc": "Emitted when component show animation is finished"
171
+ },
172
+ "hide": {
173
+ "desc": "Emitted after component has triggered hide()",
174
+ "params": {
175
+ "evt": {
176
+ "extends": "evt",
177
+ "required": true
178
+ }
179
+ }
180
+ },
181
+ "before-hide": {
182
+ "desc": "Emitted when component triggers hide() but before it finishes doing it",
183
+ "params": {
184
+ "evt": {
185
+ "extends": "evt",
186
+ "required": true
187
+ }
188
+ }
189
+ },
190
+ "after-hide": {
191
+ "desc": "Emitted when component hide animation is finished"
192
+ },
193
+ "click": {
194
+ "desc": "Emitted when user clicks/taps on the component",
195
+ "params": {
196
+ "evt": {
197
+ "extends": "evt",
198
+ "required": true
199
+ }
200
+ }
201
+ }
202
+ },
203
+ "methods": {
204
+ "show": {
205
+ "desc": "Triggers component to show",
206
+ "params": {
207
+ "evt": {
208
+ "extends": "evt",
209
+ "required": false
210
+ }
211
+ },
212
+ "returns": null
213
+ },
214
+ "hide": {
215
+ "desc": "Triggers component to hide",
216
+ "params": {
217
+ "evt": {
218
+ "extends": "evt",
219
+ "required": false
220
+ }
221
+ },
222
+ "returns": null
223
+ },
224
+ "toggle": {
225
+ "desc": "Triggers component to toggle between show/hide",
226
+ "params": {
227
+ "evt": {
228
+ "extends": "evt",
229
+ "required": false
230
+ }
231
+ },
232
+ "returns": null
233
+ }
234
+ }
235
+ }
@@ -0,0 +1,201 @@
1
+ import { computed, ref, watch } from 'vue'
2
+
3
+ import docsectorConfig from 'docsector.config.js'
4
+ import { normalizeAiAssistantConfig } from '../ai-assistant/config'
5
+ import { createAssistantRequestPayload } from '../ai-assistant/messages'
6
+ import { clearAssistantSession, loadAssistantSession, saveAssistantSession } from '../ai-assistant/session'
7
+ import { extractAssistantStreamDelta, normalizeAssistantSourceChunks, parseServerSentEvents } from '../ai-assistant/stream'
8
+
9
+ const assistantConfig = normalizeAiAssistantConfig(docsectorConfig)
10
+ const assistantMessages = ref([])
11
+ const assistantSources = ref([])
12
+ let assistantSessionReady = false
13
+ let assistantSessionWatcherReady = false
14
+
15
+ function createMessage (role, content = '') {
16
+ return {
17
+ id: `${role}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
18
+ role,
19
+ content
20
+ }
21
+ }
22
+
23
+ function getCompletionText (payload) {
24
+ return payload?.choices?.[0]?.message?.content
25
+ || payload?.response
26
+ || payload?.content
27
+ || ''
28
+ }
29
+
30
+ function persistAssistantSession () {
31
+ saveAssistantSession({
32
+ messages: assistantMessages.value,
33
+ sources: assistantSources.value
34
+ })
35
+ }
36
+
37
+ function ensureAssistantSession () {
38
+ if (!assistantSessionReady) {
39
+ const session = loadAssistantSession()
40
+ assistantMessages.value = session.messages
41
+ assistantSources.value = session.sources
42
+ assistantSessionReady = true
43
+ }
44
+
45
+ if (!assistantSessionWatcherReady) {
46
+ watch([assistantMessages, assistantSources], persistAssistantSession, { deep: true })
47
+ assistantSessionWatcherReady = true
48
+ }
49
+ }
50
+
51
+ export default function useAssistant ({ route, locale, getContext } = {}) {
52
+ ensureAssistantSession()
53
+
54
+ const messages = assistantMessages
55
+ const sources = assistantSources
56
+ const loading = ref(false)
57
+ const error = ref('')
58
+ const abortController = ref(null)
59
+
60
+ const hasMessages = computed(() => messages.value.length > 0)
61
+
62
+ const clear = () => {
63
+ messages.value = []
64
+ sources.value = []
65
+ error.value = ''
66
+ clearAssistantSession()
67
+ }
68
+
69
+ const stop = () => {
70
+ abortController.value?.abort()
71
+ abortController.value = null
72
+ loading.value = false
73
+ }
74
+
75
+ const appendAssistantContent = (message, content) => {
76
+ if (!content) return
77
+ message.content += content
78
+ }
79
+
80
+ const consumeStream = async (response, assistantMessage) => {
81
+ const reader = response.body.getReader()
82
+ const decoder = new TextDecoder()
83
+ let buffer = ''
84
+ let reading = true
85
+
86
+ while (reading) {
87
+ const { done, value } = await reader.read()
88
+ if (done) {
89
+ reading = false
90
+ break
91
+ }
92
+
93
+ buffer += decoder.decode(value, { stream: true })
94
+ const parts = buffer.split(/\r?\n\r?\n/)
95
+ buffer = parts.pop() || ''
96
+
97
+ for (const part of parts) {
98
+ const events = parseServerSentEvents(`${part}\n\n`)
99
+ for (const event of events) {
100
+ const delta = extractAssistantStreamDelta(event)
101
+ if (delta.done) return
102
+ appendAssistantContent(assistantMessage, delta.content)
103
+ if (delta.chunks.length > 0) {
104
+ sources.value = delta.chunks
105
+ }
106
+ }
107
+ }
108
+ }
109
+
110
+ if (buffer.trim()) {
111
+ const events = parseServerSentEvents(`${buffer}\n\n`)
112
+ for (const event of events) {
113
+ const delta = extractAssistantStreamDelta(event)
114
+ appendAssistantContent(assistantMessage, delta.content)
115
+ if (delta.chunks.length > 0) {
116
+ sources.value = delta.chunks
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ const send = async (content) => {
123
+ const prompt = String(content || '').trim()
124
+ if (!prompt || loading.value) return
125
+
126
+ error.value = ''
127
+ sources.value = []
128
+
129
+ const userMessage = createMessage('user', prompt)
130
+ const assistantMessage = createMessage('assistant')
131
+ messages.value.push(userMessage, assistantMessage)
132
+ // Use the reactive proxy from the array so streamed mutations trigger
133
+ // live re-renders (raw object mutations bypass Vue reactivity).
134
+ const liveAssistantMessage = messages.value[messages.value.length - 1]
135
+
136
+ abortController.value = new AbortController()
137
+ loading.value = true
138
+
139
+ try {
140
+ const payload = createAssistantRequestPayload({
141
+ messages: messages.value
142
+ .filter(message => message.content.trim())
143
+ .map(({ role, content }) => ({ role, content })),
144
+ route: route?.value || route,
145
+ locale: locale?.value || locale,
146
+ context: typeof getContext === 'function' ? getContext() : {}
147
+ })
148
+
149
+ const response = await fetch(assistantConfig.endpoint, {
150
+ method: 'POST',
151
+ headers: { 'Content-Type': 'application/json' },
152
+ body: JSON.stringify(payload),
153
+ signal: abortController.value.signal
154
+ })
155
+
156
+ if (!response.ok) {
157
+ let message = `Assistant request failed with ${response.status}`
158
+ try {
159
+ const payload = await response.json()
160
+ message = payload?.error?.message || message
161
+ } catch {
162
+ // keep status message
163
+ }
164
+ throw new Error(message)
165
+ }
166
+
167
+ const contentType = response.headers.get('content-type') || ''
168
+ if (contentType.includes('text/event-stream') && response.body) {
169
+ await consumeStream(response, liveAssistantMessage)
170
+ } else {
171
+ const payload = await response.json()
172
+ liveAssistantMessage.content = getCompletionText(payload)
173
+ sources.value = normalizeAssistantSourceChunks(payload?.chunks)
174
+ }
175
+
176
+ if (!liveAssistantMessage.content.trim()) {
177
+ liveAssistantMessage.content = 'No answer was returned for this request.'
178
+ }
179
+ } catch (err) {
180
+ if (err?.name !== 'AbortError') {
181
+ error.value = err?.message || 'Assistant request failed.'
182
+ liveAssistantMessage.content = ''
183
+ }
184
+ } finally {
185
+ loading.value = false
186
+ abortController.value = null
187
+ }
188
+ }
189
+
190
+ return {
191
+ config: assistantConfig,
192
+ messages,
193
+ sources,
194
+ loading,
195
+ error,
196
+ hasMessages,
197
+ send,
198
+ stop,
199
+ clear
200
+ }
201
+ }
@@ -28,6 +28,7 @@ const engineDefaults = {
28
28
  newVersion: 'New in',
29
29
  copyPage: 'Copy page',
30
30
  copyPageCaption: 'Copy page as Markdown for LLMs',
31
+ copyInlineCode: 'Click to copy inline code',
31
32
  copied: 'Copied!',
32
33
  viewAsMarkdown: 'View as Markdown',
33
34
  viewAsMarkdownCaption: 'View this page as plain text',
@@ -77,6 +78,7 @@ const engineDefaults = {
77
78
  newVersion: 'Novo em',
78
79
  copyPage: 'Copiar página',
79
80
  copyPageCaption: 'Copiar página como Markdown para LLMs',
81
+ copyInlineCode: 'Clique para copiar o código inline',
80
82
  copied: 'Copiado!',
81
83
  viewAsMarkdown: 'Ver como Markdown',
82
84
  viewAsMarkdownCaption: 'Ver esta página como texto simples',
@@ -35,6 +35,7 @@
35
35
  newVersion: 'New in',
36
36
  copyPage: 'Copy page',
37
37
  copyPageCaption: 'Copy page as Markdown for LLMs',
38
+ copyInlineCode: 'Click to copy inline code',
38
39
  copied: 'Copied!',
39
40
  viewAsMarkdown: 'View as Markdown',
40
41
  viewAsMarkdownCaption: 'View this page as plain text',
@@ -105,6 +106,27 @@
105
106
  changelog: 'Changelog'
106
107
  },
107
108
 
109
+ assistant: {
110
+ title: 'Docsector Assistant',
111
+ subtitle: "I'm here to help you with the docs.",
112
+ open: 'Open assistant',
113
+ close: 'Close assistant',
114
+ clear: 'Clear conversation',
115
+ placeholder: 'Ask, search, or explain...',
116
+ send: 'Send',
117
+ stop: 'Stop',
118
+ context: 'Based on your context',
119
+ sources: 'Sources',
120
+ sourcesCount: '{count} sources',
121
+ thinking: 'Searching the docs…',
122
+ resize: 'Resize assistant',
123
+ greeting: {
124
+ morning: 'Good morning',
125
+ afternoon: 'Good afternoon',
126
+ evening: 'Good evening'
127
+ }
128
+ },
129
+
108
130
  // @
109
131
  system: {
110
132
  documentation: 'Documentation',
@@ -34,6 +34,7 @@
34
34
  newVersion: 'Novo em',
35
35
  copyPage: 'Copiar página',
36
36
  copyPageCaption: 'Copiar página como Markdown para LLMs',
37
+ copyInlineCode: 'Clique para copiar o código inline',
37
38
  copied: 'Copiado!',
38
39
  viewAsMarkdown: 'Ver como Markdown',
39
40
  viewAsMarkdownCaption: 'Ver esta página como texto simples',
@@ -104,6 +105,27 @@
104
105
  changelog: 'Changelog'
105
106
  },
106
107
 
108
+ assistant: {
109
+ title: 'Assistente Docsector',
110
+ subtitle: 'Estou aqui para ajudar com a documentação.',
111
+ open: 'Abrir assistente',
112
+ close: 'Fechar assistente',
113
+ clear: 'Limpar conversa',
114
+ placeholder: 'Pergunte, pesquise ou explique...',
115
+ send: 'Enviar',
116
+ stop: 'Parar',
117
+ context: 'Com base no seu contexto',
118
+ sources: 'Fontes',
119
+ sourcesCount: '{count} fontes',
120
+ thinking: 'Consultando a documentação…',
121
+ resize: 'Redimensionar assistente',
122
+ greeting: {
123
+ morning: 'Bom dia',
124
+ afternoon: 'Boa tarde',
125
+ evening: 'Boa noite'
126
+ }
127
+ },
128
+
107
129
  // @
108
130
  system: {
109
131
  documentation: 'Documentação',
@@ -15,6 +15,17 @@
15
15
  <q-icon class="q-mb-xs q-mr-sm" :name="headerTitleIcon" />
16
16
  {{ headerTitleText }}
17
17
  </q-toolbar-title>
18
+ <q-btn
19
+ v-if="assistantEnabled"
20
+ class="filled d-header__assistant-toggle"
21
+ :class="assistantOpen ? 'active' : null"
22
+ square
23
+ icon="auto_awesome"
24
+ :aria-label="t('assistant.open')"
25
+ @click="toggleAssistant"
26
+ >
27
+ <q-tooltip>{{ t('assistant.open') }}</q-tooltip>
28
+ </q-btn>
18
29
  <q-btn class="filled" square icon="settings" aria-label="Configuration" @click="openSettingsDialog" />
19
30
  </q-toolbar>
20
31
 
@@ -59,12 +70,15 @@ import { useMeta, colors } from 'quasar'
59
70
 
60
71
  import DMenu from '../components/DMenu.vue'
61
72
  import docsectorConfig from 'docsector.config.js'
73
+ import { normalizeAiAssistantConfig } from '../ai-assistant/config'
62
74
  import { allBooks, booksByVersion } from 'virtual:docsector-books'
63
75
  import { pageTitleI18nPath } from '../i18n/path'
64
76
 
65
77
  defineOptions({ name: 'LayoutDefault' })
66
78
 
67
79
  const branding = docsectorConfig.branding || {}
80
+ const assistantConfig = normalizeAiAssistantConfig(docsectorConfig)
81
+ const assistantEnabled = assistantConfig.enabled === true
68
82
 
69
83
  const route = useRoute()
70
84
  const router = useRouter()
@@ -75,6 +89,8 @@ const layout = ref({
75
89
  menu: false
76
90
  })
77
91
 
92
+ const assistantOpen = computed(() => store.state.layout.assistant)
93
+
78
94
  const headerTitleIcon = computed(() => {
79
95
  return route.matched[0].meta.icon ?? route.meta.icon
80
96
  })
@@ -271,6 +287,10 @@ function openSettingsDialog () {
271
287
  store.commit('settings/dialog', true)
272
288
  }
273
289
 
290
+ function toggleAssistant () {
291
+ store.commit('layout/setAssistant', !store.state.layout.assistant)
292
+ }
293
+
274
294
  function getFirstRoutePathByBook (bookId) {
275
295
  const routes = router.options?.routes || []
276
296
  const versionId = activeVersionId.value
@@ -356,6 +376,8 @@ store.commit('page/resetAnchors')
356
376
  color: var(--d-book-tab-active-color, #ffffff)
357
377
  .q-btn
358
378
  border-radius: 0
379
+ .d-header__assistant-toggle.active
380
+ background: rgba(255, 255, 255, 0.16)
359
381
  .q-btn:before
360
382
  box-shadow: 0 0 5px rgba(0, 0, 0, 0.2), 0 0 2px rgba(0, 0, 0, 0.14), 0 0 1px -2px rgba(0, 0, 0, 0.12)
361
383
  border-radius: 0
@@ -0,0 +1,32 @@
1
+ export const MARKDOWN_AGENT_USER_AGENT_SOURCE = [
2
+ 'GPTBot',
3
+ 'ChatGPT-User',
4
+ 'OAI-SearchBot',
5
+ 'ClaudeBot',
6
+ 'Claude-User',
7
+ 'Claude-SearchBot',
8
+ 'anthropic-ai',
9
+ 'Google-Extended',
10
+ 'Gemini-Deep-Research',
11
+ 'PerplexityBot',
12
+ 'Perplexity-User',
13
+ 'Bytespider',
14
+ 'CCBot',
15
+ 'Meta-ExternalAgent',
16
+ 'FacebookBot',
17
+ 'Amazonbot',
18
+ 'Applebot-Extended',
19
+ 'cohere-ai',
20
+ 'DuckAssistBot',
21
+ 'GrokBot',
22
+ 'AI2Bot',
23
+ 'YouBot',
24
+ 'PetalBot',
25
+ 'Cloudflare-AI-Search'
26
+ ].join('|')
27
+
28
+ export const MARKDOWN_AGENT_USER_AGENT_PATTERN = new RegExp(MARKDOWN_AGENT_USER_AGENT_SOURCE, 'i')
29
+
30
+ export function matchesMarkdownAgentUserAgent (userAgent = '') {
31
+ return MARKDOWN_AGENT_USER_AGENT_PATTERN.test(String(userAgent || ''))
32
+ }
@@ -0,0 +1,69 @@
1
+ ## Overview
2
+
3
+ The AI Assistant adds an optional documentation chat panel to Docsector pages. It is designed for semantic RAG workflows on Cloudflare while keeping the browser integration simple: users open a drawer from the global header, and the Cloudflare Pages Function talks to the configured AI provider.
4
+
5
+ The first provider is Cloudflare AI Search. It can crawl Docsector's generated Markdown sitemap, retrieve relevant chunks with hybrid search, and stream an answer with source chunks that the panel can show as citations.
6
+
7
+ ## What It Adds
8
+
9
+ - A right-side assistant drawer on desktop.
10
+ - A fullscreen assistant dialog on mobile.
11
+ - Suggested prompts, current-page context, streaming responses, and source links.
12
+ - Build-time AI Search artifacts when `siteUrl` is configured.
13
+ - A same-origin internal endpoint so credentials stay server-side.
14
+
15
+ ## Enable It
16
+
17
+ ```javascript
18
+ export default {
19
+ siteUrl: 'https://docs.example.com',
20
+
21
+ aiAssistant: {
22
+ enabled: true,
23
+ provider: 'aiSearch',
24
+ endpoint: '/assistant',
25
+ aiSearch: {
26
+ binding: 'AI_SEARCH',
27
+ instanceNameEnv: 'AI_SEARCH_INSTANCE_NAME',
28
+ accountIdEnv: 'CLOUDFLARE_ACCOUNT_ID',
29
+ apiTokenEnv: 'CLOUDFLARE_API_TOKEN',
30
+ retrievalType: 'hybrid',
31
+ maxResults: 6,
32
+ stream: true
33
+ }
34
+ }
35
+ }
36
+ ```
37
+
38
+ Set `AI_SEARCH_INSTANCE_NAME` in Cloudflare Pages environment variables for deployed environments, or in `.dev.vars` when running `wrangler pages dev` locally.
39
+
40
+ ## Cloudflare AI Search
41
+
42
+ Create an AI Search instance and configure a Website data source. Docsector always publishes `/sitemap.xml` during build and advertises it from `robots.txt`, so Cloudflare's crawler can discover the site automatically.
43
+
44
+ For cleaner retrieval, point the specific sitemap setting to:
45
+
46
+ ```text
47
+ https://docs.example.com/ai-search-sitemap.xml
48
+ ```
49
+
50
+ The AI Search sitemap points to Markdown URLs, which are cleaner for retrieval than rendered SPA HTML. The manifest at `/.well-known/ai-search/manifest.json` lists titles, routes, locales, books, versions, and subpages for the same source set.
51
+
52
+ ## Runtime Endpoint
53
+
54
+ The generated Pages Function accepts chat messages, current route metadata, locale, and optional selected page text. It forwards the request to AI Search by binding when available, or by REST using encrypted Cloudflare environment variables. The endpoint is an internal API for the drawer, not a page users navigate to.
55
+
56
+ The browser never needs a Cloudflare API token.
57
+
58
+ ## Validate
59
+
60
+ ```bash
61
+ npx docsector build
62
+ cat dist/spa/sitemap.xml
63
+ cat dist/spa/robots.txt
64
+ cat dist/spa/ai-search-sitemap.xml
65
+ cat dist/spa/.well-known/ai-search/manifest.json
66
+ npx wrangler pages dev dist/spa
67
+ ```
68
+
69
+ Remote AI Search and Workers AI bindings can incur Cloudflare usage during local development.