docus 5.4.4 → 5.5.1
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/app/app.vue +16 -6
- package/app/components/app/AppHeader.vue +5 -0
- package/app/components/docs/DocsAsideRightBottom.vue +28 -1
- package/app/composables/useSeo.ts +207 -0
- package/app/pages/[[lang]]/[...slug].vue +17 -9
- package/app/plugins/i18n.ts +28 -11
- package/app/templates/landing.vue +4 -10
- package/app/types/index.d.ts +49 -0
- package/app/utils/navigation.ts +31 -0
- package/i18n/locales/en.json +27 -0
- package/i18n/locales/fr.json +28 -1
- package/modules/assistant/README.md +213 -0
- package/modules/assistant/index.ts +100 -0
- package/modules/assistant/runtime/components/AssistantChat.vue +21 -0
- package/modules/assistant/runtime/components/AssistantChatDisabled.vue +3 -0
- package/modules/assistant/runtime/components/AssistantFloatingInput.vue +110 -0
- package/modules/assistant/runtime/components/AssistantLoading.vue +164 -0
- package/modules/assistant/runtime/components/AssistantMatrix.vue +92 -0
- package/modules/assistant/runtime/components/AssistantPanel.vue +329 -0
- package/modules/assistant/runtime/components/AssistantPreStream.vue +46 -0
- package/modules/assistant/runtime/composables/useAssistant.ts +107 -0
- package/modules/assistant/runtime/composables/useHighlighter.ts +34 -0
- package/modules/assistant/runtime/server/api/search.ts +111 -0
- package/modules/assistant/runtime/types.ts +7 -0
- package/modules/config.ts +6 -4
- package/modules/css.ts +6 -2
- package/modules/markdown-rewrite.ts +130 -0
- package/nuxt.config.ts +22 -1
- package/nuxt.schema.ts +63 -0
- package/package.json +24 -15
- package/server/routes/sitemap.xml.ts +93 -0
- package/utils/meta.ts +9 -3
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { defineAsyncComponent } from 'vue'
|
|
3
|
+
import type { UIMessage } from 'ai'
|
|
4
|
+
import { Chat } from '@ai-sdk/vue'
|
|
5
|
+
import { DefaultChatTransport } from 'ai'
|
|
6
|
+
import { createReusableTemplate } from '@vueuse/core'
|
|
7
|
+
import { useDocusI18n } from '../../../../app/composables/useDocusI18n'
|
|
8
|
+
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
|
+
const components: Record<string, any> = {
|
|
11
|
+
pre: defineAsyncComponent(() => import('./AssistantPreStream.vue')),
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const [DefineChatContent, ReuseChatContent] = createReusableTemplate<{ showExpandButton?: boolean }>()
|
|
15
|
+
|
|
16
|
+
const { isOpen, isExpanded, isMobile, panelWidth, toggleExpanded, messages, pendingMessage, clearPending, faqQuestions } = useAssistant()
|
|
17
|
+
const config = useRuntimeConfig()
|
|
18
|
+
const toast = useToast()
|
|
19
|
+
const { t } = useDocusI18n()
|
|
20
|
+
const input = ref('')
|
|
21
|
+
|
|
22
|
+
const displayTitle = computed(() => t('assistant.title'))
|
|
23
|
+
const displayPlaceholder = computed(() => t('assistant.placeholder'))
|
|
24
|
+
|
|
25
|
+
const chat = new Chat({
|
|
26
|
+
messages: messages.value,
|
|
27
|
+
transport: new DefaultChatTransport({
|
|
28
|
+
api: config.public.assistant.apiPath,
|
|
29
|
+
}),
|
|
30
|
+
onError: (error: Error) => {
|
|
31
|
+
const message = (() => {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(error.message)
|
|
34
|
+
return parsed?.message || error.message
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return error.message
|
|
38
|
+
}
|
|
39
|
+
})()
|
|
40
|
+
|
|
41
|
+
toast.add({
|
|
42
|
+
description: message,
|
|
43
|
+
icon: 'i-lucide-alert-circle',
|
|
44
|
+
color: 'error',
|
|
45
|
+
duration: 0,
|
|
46
|
+
})
|
|
47
|
+
},
|
|
48
|
+
onFinish: () => {
|
|
49
|
+
messages.value = chat.messages
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
watch(pendingMessage, (message: string | undefined) => {
|
|
54
|
+
if (message) {
|
|
55
|
+
if (messages.value.length === 0 && chat.messages.length > 0) {
|
|
56
|
+
chat.messages.length = 0
|
|
57
|
+
}
|
|
58
|
+
chat.sendMessage({
|
|
59
|
+
text: message,
|
|
60
|
+
})
|
|
61
|
+
clearPending()
|
|
62
|
+
}
|
|
63
|
+
}, { immediate: true })
|
|
64
|
+
|
|
65
|
+
watch(messages, (newMessages: UIMessage[]) => {
|
|
66
|
+
if (newMessages.length === 0 && chat.messages.length > 0) {
|
|
67
|
+
chat.messages.length = 0
|
|
68
|
+
}
|
|
69
|
+
}, { deep: true })
|
|
70
|
+
|
|
71
|
+
const lastMessage = computed(() => chat.messages.at(-1))
|
|
72
|
+
const showThinking = computed(() =>
|
|
73
|
+
chat.status === 'streaming'
|
|
74
|
+
&& lastMessage.value?.role === 'assistant'
|
|
75
|
+
&& !lastMessage.value?.parts?.some((p: { type: string }) => p.type === 'text'),
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
|
+
function getMessageToolCalls(message: any) {
|
|
80
|
+
if (!message?.parts) return []
|
|
81
|
+
return message.parts
|
|
82
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
83
|
+
.filter((p: any) => p.type === 'data-tool-calls')
|
|
84
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
85
|
+
.flatMap((p: any) => p.data?.tools || [])
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function handleSubmit(event?: Event) {
|
|
89
|
+
event?.preventDefault()
|
|
90
|
+
|
|
91
|
+
if (!input.value.trim()) {
|
|
92
|
+
return
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
chat.sendMessage({
|
|
96
|
+
text: input.value,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
input.value = ''
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function askQuestion(question: string) {
|
|
103
|
+
chat.sendMessage({
|
|
104
|
+
text: question,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function resetChat() {
|
|
109
|
+
chat.stop()
|
|
110
|
+
messages.value = []
|
|
111
|
+
chat.messages.length = 0
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
onMounted(() => {
|
|
115
|
+
if (pendingMessage.value) {
|
|
116
|
+
chat.sendMessage({
|
|
117
|
+
text: pendingMessage.value,
|
|
118
|
+
})
|
|
119
|
+
clearPending()
|
|
120
|
+
}
|
|
121
|
+
else if (chat.lastMessage?.role === 'user') {
|
|
122
|
+
chat.regenerate()
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
</script>
|
|
126
|
+
|
|
127
|
+
<template>
|
|
128
|
+
<DefineChatContent v-slot="{ showExpandButton = true }">
|
|
129
|
+
<div class="flex h-full flex-col">
|
|
130
|
+
<div class="flex h-16 shrink-0 items-center justify-between border-b border-default px-4">
|
|
131
|
+
<span class="font-medium text-highlighted">{{ displayTitle }}</span>
|
|
132
|
+
<div class="flex items-center gap-1">
|
|
133
|
+
<UTooltip
|
|
134
|
+
v-if="showExpandButton"
|
|
135
|
+
:text="isExpanded ? t('assistant.collapse') : t('assistant.expand')"
|
|
136
|
+
>
|
|
137
|
+
<UButton
|
|
138
|
+
:icon="isExpanded ? 'i-lucide-minimize-2' : 'i-lucide-maximize-2'"
|
|
139
|
+
color="neutral"
|
|
140
|
+
variant="ghost"
|
|
141
|
+
size="sm"
|
|
142
|
+
class="text-muted hover:text-highlighted"
|
|
143
|
+
@click="toggleExpanded"
|
|
144
|
+
/>
|
|
145
|
+
</UTooltip>
|
|
146
|
+
<UTooltip
|
|
147
|
+
v-if="chat.messages.length > 0"
|
|
148
|
+
:text="t('assistant.clearChat')"
|
|
149
|
+
>
|
|
150
|
+
<UButton
|
|
151
|
+
icon="i-lucide-trash-2"
|
|
152
|
+
color="neutral"
|
|
153
|
+
variant="ghost"
|
|
154
|
+
size="sm"
|
|
155
|
+
class="text-muted hover:text-highlighted"
|
|
156
|
+
@click="resetChat"
|
|
157
|
+
/>
|
|
158
|
+
</UTooltip>
|
|
159
|
+
<UTooltip :text="t('assistant.close')">
|
|
160
|
+
<UButton
|
|
161
|
+
icon="i-lucide-x"
|
|
162
|
+
color="neutral"
|
|
163
|
+
variant="ghost"
|
|
164
|
+
size="sm"
|
|
165
|
+
class="text-muted hover:text-highlighted"
|
|
166
|
+
@click="isOpen = false"
|
|
167
|
+
/>
|
|
168
|
+
</UTooltip>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div class="min-h-0 flex-1 overflow-y-auto">
|
|
173
|
+
<UChatMessages
|
|
174
|
+
v-if="chat.messages.length > 0"
|
|
175
|
+
:messages="chat.messages"
|
|
176
|
+
compact
|
|
177
|
+
:status="chat.status"
|
|
178
|
+
:user="{ ui: { content: 'text-sm' } }"
|
|
179
|
+
:ui="{ indicator: '*:bg-accented', root: 'h-auto!' }"
|
|
180
|
+
class="px-4 py-4"
|
|
181
|
+
>
|
|
182
|
+
<template #content="{ message }">
|
|
183
|
+
<div class="flex flex-col gap-2">
|
|
184
|
+
<AssistantLoading
|
|
185
|
+
v-if="message.role === 'assistant' && (getMessageToolCalls(message).length > 0 || (showThinking && message.id === lastMessage?.id))"
|
|
186
|
+
:tool-calls="getMessageToolCalls(message)"
|
|
187
|
+
:is-loading="showThinking && message.id === lastMessage?.id"
|
|
188
|
+
/>
|
|
189
|
+
<template
|
|
190
|
+
v-for="(part, index) in message.parts"
|
|
191
|
+
:key="`${message.id}-${part.type}-${index}${'state' in part ? `-${part.state}` : ''}`"
|
|
192
|
+
>
|
|
193
|
+
<MDCCached
|
|
194
|
+
v-if="part.type === 'text' && part.text"
|
|
195
|
+
:value="part.text"
|
|
196
|
+
:cache-key="`${message.id}-${index}`"
|
|
197
|
+
:components="components"
|
|
198
|
+
:parser-options="{ highlight: false }"
|
|
199
|
+
class="*:first:mt-0 *:last:mb-0"
|
|
200
|
+
/>
|
|
201
|
+
</template>
|
|
202
|
+
</div>
|
|
203
|
+
</template>
|
|
204
|
+
</UChatMessages>
|
|
205
|
+
|
|
206
|
+
<div
|
|
207
|
+
v-else
|
|
208
|
+
class="p-4"
|
|
209
|
+
>
|
|
210
|
+
<div
|
|
211
|
+
v-if="!faqQuestions?.length"
|
|
212
|
+
class="flex h-full flex-col items-center justify-center py-12 text-center"
|
|
213
|
+
>
|
|
214
|
+
<div class="mb-4 flex size-12 items-center justify-center rounded-full bg-primary/10">
|
|
215
|
+
<UIcon
|
|
216
|
+
name="i-lucide-message-circle-question"
|
|
217
|
+
class="size-6 text-primary"
|
|
218
|
+
/>
|
|
219
|
+
</div>
|
|
220
|
+
<h3 class="mb-2 text-base font-medium text-highlighted">
|
|
221
|
+
{{ t('assistant.askMeAnything') }}
|
|
222
|
+
</h3>
|
|
223
|
+
<p class="max-w-xs text-sm text-muted">
|
|
224
|
+
{{ t('assistant.askMeAnythingDescription') }}
|
|
225
|
+
</p>
|
|
226
|
+
</div>
|
|
227
|
+
|
|
228
|
+
<template v-else>
|
|
229
|
+
<p class="mb-4 text-sm font-medium text-muted">
|
|
230
|
+
{{ t('assistant.faq') }}
|
|
231
|
+
</p>
|
|
232
|
+
|
|
233
|
+
<div class="flex flex-col gap-5">
|
|
234
|
+
<div
|
|
235
|
+
v-for="category in faqQuestions"
|
|
236
|
+
:key="category.category"
|
|
237
|
+
class="flex flex-col gap-1.5"
|
|
238
|
+
>
|
|
239
|
+
<h4 class="text-xs font-medium uppercase tracking-wide text-dimmed">
|
|
240
|
+
{{ category.category }}
|
|
241
|
+
</h4>
|
|
242
|
+
<div class="flex flex-col">
|
|
243
|
+
<button
|
|
244
|
+
v-for="question in category.items"
|
|
245
|
+
:key="question"
|
|
246
|
+
class="py-1.5 text-left text-sm text-muted transition-colors hover:text-highlighted"
|
|
247
|
+
@click="askQuestion(question)"
|
|
248
|
+
>
|
|
249
|
+
{{ question }}
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
</div>
|
|
253
|
+
</div>
|
|
254
|
+
</template>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
|
|
258
|
+
<div class="w-full shrink-0 p-3">
|
|
259
|
+
<UChatPrompt
|
|
260
|
+
v-model="input"
|
|
261
|
+
:rows="2"
|
|
262
|
+
:placeholder="displayPlaceholder"
|
|
263
|
+
maxlength="1000"
|
|
264
|
+
:ui="{
|
|
265
|
+
root: 'shadow-none!',
|
|
266
|
+
body: '*:p-0! *:rounded-none! *:text-base!',
|
|
267
|
+
}"
|
|
268
|
+
@submit="handleSubmit"
|
|
269
|
+
>
|
|
270
|
+
<template #footer>
|
|
271
|
+
<div class="flex items-center gap-1 text-xs text-muted">
|
|
272
|
+
<span>{{ t('assistant.lineBreak') }}</span>
|
|
273
|
+
<UKbd
|
|
274
|
+
size="sm"
|
|
275
|
+
value="shift"
|
|
276
|
+
/>
|
|
277
|
+
<UKbd
|
|
278
|
+
size="sm"
|
|
279
|
+
value="enter"
|
|
280
|
+
/>
|
|
281
|
+
</div>
|
|
282
|
+
<UChatPromptSubmit
|
|
283
|
+
class="ml-auto"
|
|
284
|
+
size="xs"
|
|
285
|
+
:status="chat.status"
|
|
286
|
+
@stop="chat.stop()"
|
|
287
|
+
@reload="chat.regenerate()"
|
|
288
|
+
/>
|
|
289
|
+
</template>
|
|
290
|
+
</UChatPrompt>
|
|
291
|
+
<div class="mt-1 flex text-xs text-dimmed items-center justify-between">
|
|
292
|
+
<span>{{ t('assistant.chatCleared') }}</span>
|
|
293
|
+
<span>
|
|
294
|
+
{{ input.length }}/1000
|
|
295
|
+
</span>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
</div>
|
|
299
|
+
</DefineChatContent>
|
|
300
|
+
|
|
301
|
+
<aside
|
|
302
|
+
v-if="!isMobile"
|
|
303
|
+
class="left-auto! fixed top-0 z-50 h-dvh overflow-hidden border-l border-default bg-default/95 backdrop-blur-xl transition-[right,width] duration-200 ease-linear will-change-[right,width]"
|
|
304
|
+
:style="{
|
|
305
|
+
width: `${panelWidth}px`,
|
|
306
|
+
right: isOpen ? '0' : `-${panelWidth}px`,
|
|
307
|
+
}"
|
|
308
|
+
>
|
|
309
|
+
<div
|
|
310
|
+
class="h-full transition-[width] duration-200 ease-linear"
|
|
311
|
+
:style="{ width: `${panelWidth}px` }"
|
|
312
|
+
>
|
|
313
|
+
<ReuseChatContent :show-expand-button="true" />
|
|
314
|
+
</div>
|
|
315
|
+
</aside>
|
|
316
|
+
|
|
317
|
+
<USlideover
|
|
318
|
+
v-else
|
|
319
|
+
v-model:open="isOpen"
|
|
320
|
+
side="right"
|
|
321
|
+
:ui="{
|
|
322
|
+
content: 'ring-0 bg-default',
|
|
323
|
+
}"
|
|
324
|
+
>
|
|
325
|
+
<template #content>
|
|
326
|
+
<ReuseChatContent :show-expand-button="false" />
|
|
327
|
+
</template>
|
|
328
|
+
</USlideover>
|
|
329
|
+
</template>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ShikiCachedRenderer } from 'shiki-stream/vue'
|
|
3
|
+
import { useColorMode } from '#imports'
|
|
4
|
+
import { useHighlighter } from '../composables/useHighlighter'
|
|
5
|
+
|
|
6
|
+
const colorMode = useColorMode()
|
|
7
|
+
const highlighter = await useHighlighter()
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
code: string
|
|
10
|
+
language: string
|
|
11
|
+
class?: string
|
|
12
|
+
meta?: string
|
|
13
|
+
}>()
|
|
14
|
+
const trimmedCode = computed(() => {
|
|
15
|
+
return props.code.trim().replace(/`+$/, '')
|
|
16
|
+
})
|
|
17
|
+
const lang = computed(() => {
|
|
18
|
+
switch (props.language) {
|
|
19
|
+
case 'vue':
|
|
20
|
+
return 'vue'
|
|
21
|
+
case 'javascript':
|
|
22
|
+
return 'js'
|
|
23
|
+
case 'typescript':
|
|
24
|
+
return 'ts'
|
|
25
|
+
case 'css':
|
|
26
|
+
return 'css'
|
|
27
|
+
default:
|
|
28
|
+
return props.language
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
const key = computed(() => {
|
|
32
|
+
return `${lang.value}-${colorMode.value}`
|
|
33
|
+
})
|
|
34
|
+
</script>
|
|
35
|
+
|
|
36
|
+
<template>
|
|
37
|
+
<ProsePre v-bind="props">
|
|
38
|
+
<ShikiCachedRenderer
|
|
39
|
+
:key="key"
|
|
40
|
+
:highlighter="highlighter"
|
|
41
|
+
:code="trimmedCode"
|
|
42
|
+
:lang="lang"
|
|
43
|
+
:theme="colorMode.value === 'dark' ? 'material-theme-palenight' : 'material-theme-lighter'"
|
|
44
|
+
/>
|
|
45
|
+
</ProsePre>
|
|
46
|
+
</template>
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import type { UIMessage } from 'ai'
|
|
2
|
+
import { useMediaQuery } from '@vueuse/core'
|
|
3
|
+
import type { FaqCategory, FaqQuestions, LocalizedFaqQuestions } from '../types'
|
|
4
|
+
|
|
5
|
+
function normalizeFaqQuestions(questions: FaqQuestions): FaqCategory[] {
|
|
6
|
+
if (!questions || (Array.isArray(questions) && questions.length === 0)) {
|
|
7
|
+
return []
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
if (typeof questions[0] === 'string') {
|
|
11
|
+
return [{
|
|
12
|
+
category: 'Questions',
|
|
13
|
+
items: questions as string[],
|
|
14
|
+
}]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return questions as FaqCategory[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const PANEL_WIDTH_COMPACT = 360
|
|
21
|
+
const PANEL_WIDTH_EXPANDED = 520
|
|
22
|
+
|
|
23
|
+
export function useAssistant() {
|
|
24
|
+
const config = useRuntimeConfig()
|
|
25
|
+
const appConfig = useAppConfig()
|
|
26
|
+
const isEnabled = computed(() => config.public.assistant?.enabled ?? false)
|
|
27
|
+
|
|
28
|
+
const isOpen = useState('assistant-open', () => false)
|
|
29
|
+
const isExpanded = useState('assistant-expanded', () => false)
|
|
30
|
+
const messages = useState<UIMessage[]>('assistant-messages', () => [])
|
|
31
|
+
const pendingMessage = useState<string | undefined>('assistant-pending', () => undefined)
|
|
32
|
+
|
|
33
|
+
const isMobile = useMediaQuery('(max-width: 767px)')
|
|
34
|
+
const panelWidth = computed(() => isExpanded.value ? PANEL_WIDTH_EXPANDED : PANEL_WIDTH_COMPACT)
|
|
35
|
+
const shouldPushContent = computed(() => !isMobile.value && isOpen.value)
|
|
36
|
+
|
|
37
|
+
const faqQuestions = computed<FaqCategory[]>(() => {
|
|
38
|
+
const assistantConfig = appConfig.assistant
|
|
39
|
+
const faqConfig = assistantConfig?.faqQuestions
|
|
40
|
+
if (!faqConfig) return []
|
|
41
|
+
|
|
42
|
+
// Check if it's a localized object (has locale keys like 'en', 'fr')
|
|
43
|
+
if (!Array.isArray(faqConfig)) {
|
|
44
|
+
const localizedConfig = faqConfig as LocalizedFaqQuestions
|
|
45
|
+
const currentLocale = appConfig.docus?.locale || 'en'
|
|
46
|
+
const defaultLocale = config.public.i18n?.defaultLocale || 'en'
|
|
47
|
+
|
|
48
|
+
// Try current locale, then default locale, then first available
|
|
49
|
+
const questions = localizedConfig[currentLocale]
|
|
50
|
+
|| localizedConfig[defaultLocale]
|
|
51
|
+
|| Object.values(localizedConfig)[0]
|
|
52
|
+
|
|
53
|
+
return normalizeFaqQuestions(questions || [])
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return normalizeFaqQuestions(faqConfig)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
function open(initialMessage?: string, clearPrevious = false) {
|
|
60
|
+
if (clearPrevious) {
|
|
61
|
+
messages.value = []
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (initialMessage) {
|
|
65
|
+
pendingMessage.value = initialMessage
|
|
66
|
+
}
|
|
67
|
+
isOpen.value = true
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function clearPending() {
|
|
71
|
+
pendingMessage.value = undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function close() {
|
|
75
|
+
isOpen.value = false
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function toggle() {
|
|
79
|
+
isOpen.value = !isOpen.value
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function clearMessages() {
|
|
83
|
+
messages.value = []
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function toggleExpanded() {
|
|
87
|
+
isExpanded.value = !isExpanded.value
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
isEnabled,
|
|
92
|
+
isOpen,
|
|
93
|
+
isExpanded,
|
|
94
|
+
isMobile,
|
|
95
|
+
panelWidth,
|
|
96
|
+
shouldPushContent,
|
|
97
|
+
messages,
|
|
98
|
+
pendingMessage,
|
|
99
|
+
faqQuestions,
|
|
100
|
+
open,
|
|
101
|
+
clearPending,
|
|
102
|
+
close,
|
|
103
|
+
toggle,
|
|
104
|
+
toggleExpanded,
|
|
105
|
+
clearMessages,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createHighlighterCore } from '@shikijs/core'
|
|
2
|
+
import type { HighlighterCore } from '@shikijs/core'
|
|
3
|
+
import { createJavaScriptRegexEngine } from '@shikijs/engine-javascript'
|
|
4
|
+
|
|
5
|
+
let highlighter: HighlighterCore | null = null
|
|
6
|
+
let promise: Promise<HighlighterCore> | null = null
|
|
7
|
+
|
|
8
|
+
export const useHighlighter = async () => {
|
|
9
|
+
if (!promise) {
|
|
10
|
+
promise = createHighlighterCore({
|
|
11
|
+
langs: [
|
|
12
|
+
import('@shikijs/langs/vue'),
|
|
13
|
+
import('@shikijs/langs/javascript'),
|
|
14
|
+
import('@shikijs/langs/typescript'),
|
|
15
|
+
import('@shikijs/langs/css'),
|
|
16
|
+
import('@shikijs/langs/html'),
|
|
17
|
+
import('@shikijs/langs/json'),
|
|
18
|
+
import('@shikijs/langs/yaml'),
|
|
19
|
+
import('@shikijs/langs/markdown'),
|
|
20
|
+
import('@shikijs/langs/bash'),
|
|
21
|
+
],
|
|
22
|
+
themes: [
|
|
23
|
+
import('@shikijs/themes/material-theme-palenight'),
|
|
24
|
+
import('@shikijs/themes/material-theme-lighter'),
|
|
25
|
+
],
|
|
26
|
+
engine: createJavaScriptRegexEngine(),
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
if (!highlighter) {
|
|
30
|
+
highlighter = await promise
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return highlighter
|
|
34
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse } from 'ai'
|
|
2
|
+
import type { UIMessageStreamWriter, ToolCallPart, ToolSet } from 'ai'
|
|
3
|
+
import { createMCPClient } from '@ai-sdk/mcp'
|
|
4
|
+
|
|
5
|
+
const MAX_STEPS = 10
|
|
6
|
+
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
8
|
+
function stopWhenResponseComplete({ steps }: { steps: any[] }): boolean {
|
|
9
|
+
const lastStep = steps.at(-1)
|
|
10
|
+
if (!lastStep) return false
|
|
11
|
+
|
|
12
|
+
// Primary condition: stop when model gives a text response without tool calls
|
|
13
|
+
const hasText = Boolean(lastStep.text && lastStep.text.trim().length > 0)
|
|
14
|
+
const hasNoToolCalls = !lastStep.toolCalls || lastStep.toolCalls.length === 0
|
|
15
|
+
|
|
16
|
+
if (hasText && hasNoToolCalls) return true
|
|
17
|
+
|
|
18
|
+
return steps.length >= MAX_STEPS
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function getSystemPrompt(siteName: string) {
|
|
22
|
+
return `You are the documentation assistant for ${siteName}. Help users navigate and understand the project documentation.
|
|
23
|
+
|
|
24
|
+
**Your identity:**
|
|
25
|
+
- You are an assistant helping users with ${siteName} documentation
|
|
26
|
+
- NEVER use first person ("I", "me", "my") - always refer to the project by name: "${siteName} provides...", "${siteName} supports...", "The project offers..."
|
|
27
|
+
- Be confident and knowledgeable about the project
|
|
28
|
+
- Speak as a helpful guide, not as the documentation itself
|
|
29
|
+
|
|
30
|
+
**Tool usage (CRITICAL):**
|
|
31
|
+
- You have tools: list-pages (discover pages) and get-page (read a page)
|
|
32
|
+
- If a page title clearly matches the question, read it directly without listing first
|
|
33
|
+
- ALWAYS respond with text after using tools - never end with just tool calls
|
|
34
|
+
|
|
35
|
+
**Guidelines:**
|
|
36
|
+
- If you can't find something, say "There is no documentation on that yet" or "${siteName} doesn't cover that topic yet"
|
|
37
|
+
- Be concise, helpful, and direct
|
|
38
|
+
- Guide users like a friendly expert would
|
|
39
|
+
|
|
40
|
+
**FORMATTING RULES (CRITICAL):**
|
|
41
|
+
- NEVER use markdown headings (#, ##, ###, etc.)
|
|
42
|
+
- Use **bold text** for emphasis and section labels
|
|
43
|
+
- Start responses with content directly, never with a heading
|
|
44
|
+
- Use bullet points for lists
|
|
45
|
+
- Keep code examples focused and minimal
|
|
46
|
+
|
|
47
|
+
**Response style:**
|
|
48
|
+
- Conversational but professional
|
|
49
|
+
- "Here's how you can do that:" instead of "The documentation shows:"
|
|
50
|
+
- "${siteName} supports TypeScript out of the box" instead of "I support TypeScript"
|
|
51
|
+
- Provide actionable guidance, not just information dumps`
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default defineEventHandler(async (event) => {
|
|
55
|
+
const { messages } = await readBody(event)
|
|
56
|
+
const config = useRuntimeConfig()
|
|
57
|
+
const siteConfig = getSiteConfig(event)
|
|
58
|
+
|
|
59
|
+
const siteName = siteConfig.name || 'Documentation'
|
|
60
|
+
|
|
61
|
+
const mcpServer = config.assistant.mcpServer
|
|
62
|
+
const isExternalUrl = mcpServer.startsWith('http://') || mcpServer.startsWith('https://')
|
|
63
|
+
const mcpUrl = isExternalUrl
|
|
64
|
+
? mcpServer
|
|
65
|
+
: import.meta.dev
|
|
66
|
+
? `http://localhost:3000${mcpServer}`
|
|
67
|
+
: `${getRequestURL(event).origin}${mcpServer}`
|
|
68
|
+
|
|
69
|
+
const httpClient = await createMCPClient({
|
|
70
|
+
transport: { type: 'http', url: mcpUrl },
|
|
71
|
+
})
|
|
72
|
+
const mcpTools = await httpClient.tools()
|
|
73
|
+
|
|
74
|
+
const stream = createUIMessageStream({
|
|
75
|
+
execute: async ({ writer }: { writer: UIMessageStreamWriter }) => {
|
|
76
|
+
const modelMessages = await convertToModelMessages(messages)
|
|
77
|
+
const result = streamText({
|
|
78
|
+
model: config.assistant.model,
|
|
79
|
+
maxOutputTokens: 4000,
|
|
80
|
+
maxRetries: 2,
|
|
81
|
+
stopWhen: stopWhenResponseComplete,
|
|
82
|
+
system: getSystemPrompt(siteName),
|
|
83
|
+
messages: modelMessages,
|
|
84
|
+
tools: mcpTools as ToolSet,
|
|
85
|
+
onStepFinish: ({ toolCalls }: { toolCalls: ToolCallPart[] }) => {
|
|
86
|
+
if (toolCalls.length === 0) return
|
|
87
|
+
writer.write({
|
|
88
|
+
id: toolCalls[0]?.toolCallId,
|
|
89
|
+
type: 'data-tool-calls',
|
|
90
|
+
data: {
|
|
91
|
+
tools: toolCalls.map((tc: ToolCallPart) => {
|
|
92
|
+
const args = 'args' in tc ? tc.args : 'input' in tc ? tc.input : {}
|
|
93
|
+
return {
|
|
94
|
+
toolName: tc.toolName,
|
|
95
|
+
toolCallId: tc.toolCallId,
|
|
96
|
+
args,
|
|
97
|
+
}
|
|
98
|
+
}),
|
|
99
|
+
},
|
|
100
|
+
})
|
|
101
|
+
},
|
|
102
|
+
})
|
|
103
|
+
writer.merge(result.toUIMessageStream())
|
|
104
|
+
},
|
|
105
|
+
onFinish: async () => {
|
|
106
|
+
await httpClient.close()
|
|
107
|
+
},
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
return createUIMessageStreamResponse({ stream })
|
|
111
|
+
})
|
package/modules/config.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { createResolver, defineNuxtModule } from '@nuxt/kit'
|
|
1
|
+
import { createResolver, defineNuxtModule, logger } from '@nuxt/kit'
|
|
2
2
|
import { defu } from 'defu'
|
|
3
3
|
import { existsSync } from 'node:fs'
|
|
4
4
|
import { join } from 'node:path'
|
|
5
5
|
import { inferSiteURL, getPackageJsonMetadata } from '../utils/meta'
|
|
6
6
|
import { getGitBranch, getGitEnv, getLocalGitInfo } from '../utils/git'
|
|
7
7
|
|
|
8
|
+
const log = logger.withTag('Docus')
|
|
9
|
+
|
|
8
10
|
export default defineNuxtModule({
|
|
9
11
|
meta: {
|
|
10
12
|
name: 'config',
|
|
@@ -14,7 +16,7 @@ export default defineNuxtModule({
|
|
|
14
16
|
const url = inferSiteURL()
|
|
15
17
|
const meta = await getPackageJsonMetadata(dir)
|
|
16
18
|
const gitInfo = await getLocalGitInfo(dir) || getGitEnv()
|
|
17
|
-
const siteName = nuxt.options
|
|
19
|
+
const siteName = (typeof nuxt.options.site === 'object' && nuxt.options.site?.name) || meta.name || gitInfo?.name || ''
|
|
18
20
|
|
|
19
21
|
nuxt.options.llms = defu(nuxt.options.llms, {
|
|
20
22
|
domain: url,
|
|
@@ -68,11 +70,11 @@ export default defineNuxtModule({
|
|
|
68
70
|
const hasContentFolder = existsSync(contentPath)
|
|
69
71
|
|
|
70
72
|
if (!hasLocaleFile) {
|
|
71
|
-
|
|
73
|
+
log.warn(`Locale file not found: ${localeCode}.json - skipping locale "${localeCode}"`)
|
|
72
74
|
}
|
|
73
75
|
|
|
74
76
|
if (!hasContentFolder) {
|
|
75
|
-
|
|
77
|
+
log.warn(`Content folder not found: content/${localeCode}/ - skipping locale "${localeCode}"`)
|
|
76
78
|
}
|
|
77
79
|
|
|
78
80
|
return hasLocaleFile && hasContentFolder
|
package/modules/css.ts
CHANGED
|
@@ -14,6 +14,7 @@ export default defineNuxtModule({
|
|
|
14
14
|
const uiPath = resolveModulePath('@nuxt/ui', { from: import.meta.url, conditions: ['style'] })
|
|
15
15
|
const tailwindPath = resolveModulePath('tailwindcss', { from: import.meta.url, conditions: ['style'] })
|
|
16
16
|
const layerDir = resolver.resolve('../app')
|
|
17
|
+
const assistantDir = resolver.resolve('../modules/assistant')
|
|
17
18
|
|
|
18
19
|
const cssTemplate = addTemplate({
|
|
19
20
|
filename: 'docus.css',
|
|
@@ -23,10 +24,13 @@ export default defineNuxtModule({
|
|
|
23
24
|
|
|
24
25
|
@source "${contentDir.replace(/\\/g, '/')}/**/*";
|
|
25
26
|
@source "${layerDir.replace(/\\/g, '/')}/**/*";
|
|
26
|
-
@source "../../app.config.ts"
|
|
27
|
+
@source "../../app.config.ts";
|
|
28
|
+
@source "${assistantDir.replace(/\\/g, '/')}/**/*";`
|
|
27
29
|
},
|
|
28
30
|
})
|
|
29
31
|
|
|
30
|
-
nuxt.options.css
|
|
32
|
+
if (Array.isArray(nuxt.options.css)) {
|
|
33
|
+
nuxt.options.css.unshift(cssTemplate.dst)
|
|
34
|
+
}
|
|
31
35
|
},
|
|
32
36
|
})
|