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,213 @@
|
|
|
1
|
+
# Assistant Module
|
|
2
|
+
|
|
3
|
+
A Nuxt module that provides an AI-powered chat interface using MCP (Model Context Protocol) tools.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- AI chat slideover component with streaming responses
|
|
8
|
+
- Floating input component for quick questions
|
|
9
|
+
- MCP tools integration for documentation search
|
|
10
|
+
- Syntax highlighting for code blocks
|
|
11
|
+
- FAQ suggestions
|
|
12
|
+
- Persistent chat state
|
|
13
|
+
- Keyboard shortcuts support
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
1. Copy the `modules/assistant` folder to your Nuxt project
|
|
18
|
+
2. Install the required dependencies:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pnpm add @ai-sdk/mcp @ai-sdk/vue @ai-sdk/gateway ai motion-v shiki shiki-stream
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
3. Add the module to your `nuxt.config.ts`:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
export default defineNuxtConfig({
|
|
28
|
+
modules: ['./modules/assistant'],
|
|
29
|
+
|
|
30
|
+
assistant: {
|
|
31
|
+
apiPath: '/__docus__/assistant',
|
|
32
|
+
mcpServer: '/mcp',
|
|
33
|
+
model: 'google/gemini-3-flash',
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
4. Set up your API key as an environment variable:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
AI_GATEWAY_API_KEY=your-gateway-key
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
> **Note:** The module will only be enabled if `AI_GATEWAY_API_KEY` is detected. If no key is found, the module is disabled and a message is logged to the console.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
Add the components to your app:
|
|
49
|
+
|
|
50
|
+
```vue
|
|
51
|
+
<template>
|
|
52
|
+
<div>
|
|
53
|
+
<!-- Button to open the chat -->
|
|
54
|
+
<AssistantChat />
|
|
55
|
+
|
|
56
|
+
<!-- Chat panel (place once in your app/layout) -->
|
|
57
|
+
<AssistantPanel />
|
|
58
|
+
</div>
|
|
59
|
+
</template>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### FAQ Questions
|
|
63
|
+
|
|
64
|
+
Configure FAQ questions in your `app.config.ts`:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
export default defineAppConfig({
|
|
68
|
+
assistant: {
|
|
69
|
+
faqQuestions: [
|
|
70
|
+
{
|
|
71
|
+
category: 'Getting Started',
|
|
72
|
+
items: ['How do I install?', 'How do I configure?'],
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
category: 'Advanced',
|
|
76
|
+
items: ['How do I customize?'],
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
},
|
|
80
|
+
})
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
You can also use localized FAQ questions:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
export default defineAppConfig({
|
|
87
|
+
assistant: {
|
|
88
|
+
faqQuestions: {
|
|
89
|
+
en: ['How do I install?', 'How do I configure?'],
|
|
90
|
+
fr: ['Comment installer ?', 'Comment configurer ?'],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
})
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### Floating Input
|
|
97
|
+
|
|
98
|
+
Use `AssistantFloatingInput` for a floating input at the bottom of the page.
|
|
99
|
+
|
|
100
|
+
**Recommended:** Use `Teleport` to render the floating input at the body level, ensuring it stays fixed at the bottom regardless of your component hierarchy:
|
|
101
|
+
|
|
102
|
+
```vue
|
|
103
|
+
<template>
|
|
104
|
+
<div>
|
|
105
|
+
<!-- Teleport to body for proper fixed positioning -->
|
|
106
|
+
<Teleport to="body">
|
|
107
|
+
<ClientOnly>
|
|
108
|
+
<LazyAssistantFloatingInput />
|
|
109
|
+
</ClientOnly>
|
|
110
|
+
</Teleport>
|
|
111
|
+
|
|
112
|
+
<!-- Chat panel (required to display responses) -->
|
|
113
|
+
<AssistantPanel />
|
|
114
|
+
</div>
|
|
115
|
+
</template>
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The floating input:
|
|
119
|
+
- Appears at the bottom center of the viewport
|
|
120
|
+
- Automatically hides when the chat slideover is open
|
|
121
|
+
- Expands on focus for better typing experience
|
|
122
|
+
- Supports keyboard shortcuts: `⌘I` to focus, `Escape` to blur
|
|
123
|
+
|
|
124
|
+
### Programmatic Control
|
|
125
|
+
|
|
126
|
+
Use the `useAssistant` composable to control the chat:
|
|
127
|
+
|
|
128
|
+
```vue
|
|
129
|
+
<script setup>
|
|
130
|
+
const { open, close, toggle, isOpen, messages, clearMessages } = useAssistant()
|
|
131
|
+
|
|
132
|
+
// Open chat with an initial message
|
|
133
|
+
open('How do I install the module?')
|
|
134
|
+
|
|
135
|
+
// Open and clear previous messages
|
|
136
|
+
open('New question', true)
|
|
137
|
+
|
|
138
|
+
// Toggle chat visibility
|
|
139
|
+
toggle()
|
|
140
|
+
|
|
141
|
+
// Clear all messages
|
|
142
|
+
clearMessages()
|
|
143
|
+
</script>
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
## Module Options
|
|
147
|
+
|
|
148
|
+
| Option | Type | Default | Description |
|
|
149
|
+
|--------|------|---------|-------------|
|
|
150
|
+
| `apiPath` | `string` | `/__docus__/assistant` | API endpoint path for the chat |
|
|
151
|
+
| `mcpServer` | `string` | `/mcp` | MCP server path or full URL (e.g., `https://docs.example.com/mcp` for external servers) |
|
|
152
|
+
| `model` | `string` | `google/gemini-3-flash` | AI model identifier for AI SDK Gateway |
|
|
153
|
+
|
|
154
|
+
## Components
|
|
155
|
+
|
|
156
|
+
### `<AssistantChat>`
|
|
157
|
+
|
|
158
|
+
Button to toggle the chat panel. The tooltip text is automatically translated using i18n (`assistant.tooltip`).
|
|
159
|
+
|
|
160
|
+
### `<AssistantPanel>`
|
|
161
|
+
|
|
162
|
+
Main chat interface displayed as a side panel. Configuration is done via `app.config.ts` (see FAQ Questions section above).
|
|
163
|
+
|
|
164
|
+
### `<AssistantFloatingInput>`
|
|
165
|
+
|
|
166
|
+
Floating input field positioned at the bottom of the viewport. No props required.
|
|
167
|
+
|
|
168
|
+
**Keyboard shortcuts:**
|
|
169
|
+
- `⌘I` / `Ctrl+I` - Focus the input
|
|
170
|
+
- `Escape` - Blur the input
|
|
171
|
+
- `Enter` - Submit the question
|
|
172
|
+
|
|
173
|
+
## Composables
|
|
174
|
+
|
|
175
|
+
### `useAssistant`
|
|
176
|
+
|
|
177
|
+
Main composable for controlling the chat state.
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
const {
|
|
181
|
+
isOpen, // Ref<boolean> - Whether the chat is open
|
|
182
|
+
messages, // Ref<UIMessage[]> - Chat messages
|
|
183
|
+
pendingMessage, // Ref<string | undefined> - Pending message to send
|
|
184
|
+
faqQuestions, // ComputedRef<FaqCategory[]> - FAQ questions from config
|
|
185
|
+
open, // (message?: string, clearPrevious?: boolean) => void
|
|
186
|
+
close, // () => void
|
|
187
|
+
toggle, // () => void
|
|
188
|
+
clearMessages, // () => void
|
|
189
|
+
clearPending, // () => void
|
|
190
|
+
} = useAssistant()
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### `useHighlighter`
|
|
194
|
+
|
|
195
|
+
Composable for syntax highlighting code blocks with Shiki.
|
|
196
|
+
|
|
197
|
+
## Requirements
|
|
198
|
+
|
|
199
|
+
- Nuxt 4.x
|
|
200
|
+
- Nuxt UI 3.x (for `USlideover`, `UButton`, `UTextarea`, `UChatMessages`, etc.)
|
|
201
|
+
- An MCP server running (path configurable via `mcpServer`)
|
|
202
|
+
- `AI_GATEWAY_API_KEY` environment variable
|
|
203
|
+
|
|
204
|
+
## Customization
|
|
205
|
+
|
|
206
|
+
### System Prompt
|
|
207
|
+
|
|
208
|
+
To customize the AI's behavior, edit the system prompt in:
|
|
209
|
+
`runtime/server/api/search.ts`
|
|
210
|
+
|
|
211
|
+
### Styling
|
|
212
|
+
|
|
213
|
+
The components use Nuxt UI and Tailwind CSS design tokens. Customize the appearance by modifying the component files or overriding the UI props.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { addComponent, addImports, addServerHandler, createResolver, defineNuxtModule, logger } from '@nuxt/kit'
|
|
2
|
+
|
|
3
|
+
export interface AssistantModuleOptions {
|
|
4
|
+
/**
|
|
5
|
+
* API endpoint path for the assistant
|
|
6
|
+
* @default '/__docus__/assistant'
|
|
7
|
+
*/
|
|
8
|
+
apiPath?: string
|
|
9
|
+
/**
|
|
10
|
+
* MCP server URL or path.
|
|
11
|
+
* - Use a path like '/mcp' to use the built-in Docus MCP server
|
|
12
|
+
* - Use a full URL like 'https://docs.example.com/mcp' for external MCP servers
|
|
13
|
+
* @default '/mcp'
|
|
14
|
+
*/
|
|
15
|
+
mcpServer?: string
|
|
16
|
+
/**
|
|
17
|
+
* AI model to use via AI SDK Gateway
|
|
18
|
+
* @default 'google/gemini-3-flash'
|
|
19
|
+
*/
|
|
20
|
+
model?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const log = logger.withTag('Docus')
|
|
24
|
+
|
|
25
|
+
export default defineNuxtModule<AssistantModuleOptions>({
|
|
26
|
+
meta: {
|
|
27
|
+
name: 'assistant',
|
|
28
|
+
configKey: 'assistant',
|
|
29
|
+
},
|
|
30
|
+
defaults: {
|
|
31
|
+
apiPath: '/__docus__/assistant',
|
|
32
|
+
mcpServer: '/mcp',
|
|
33
|
+
model: 'google/gemini-3-flash',
|
|
34
|
+
},
|
|
35
|
+
setup(options, nuxt) {
|
|
36
|
+
const hasApiKey = !!process.env.AI_GATEWAY_API_KEY
|
|
37
|
+
|
|
38
|
+
const { resolve } = createResolver(import.meta.url)
|
|
39
|
+
|
|
40
|
+
nuxt.options.runtimeConfig.public.assistant = {
|
|
41
|
+
enabled: hasApiKey,
|
|
42
|
+
apiPath: options.apiPath!,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
addImports([
|
|
46
|
+
{
|
|
47
|
+
name: 'useAssistant',
|
|
48
|
+
from: resolve('./runtime/composables/useAssistant'),
|
|
49
|
+
},
|
|
50
|
+
])
|
|
51
|
+
|
|
52
|
+
const components = [
|
|
53
|
+
'AssistantChat',
|
|
54
|
+
'AssistantPanel',
|
|
55
|
+
'AssistantFloatingInput',
|
|
56
|
+
'AssistantLoading',
|
|
57
|
+
'AssistantMatrix',
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
components.forEach(name =>
|
|
61
|
+
addComponent({
|
|
62
|
+
name,
|
|
63
|
+
filePath: hasApiKey
|
|
64
|
+
? resolve(`./runtime/components/${name}.vue`)
|
|
65
|
+
: resolve('./runtime/components/AssistantChatDisabled.vue'),
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
if (!hasApiKey) {
|
|
70
|
+
log.warn('AI assistant disabled: AI_GATEWAY_API_KEY not found')
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
nuxt.options.runtimeConfig.assistant = {
|
|
75
|
+
mcpServer: options.mcpServer!,
|
|
76
|
+
model: options.model!,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const routePath = options.apiPath!.replace(/^\//, '')
|
|
80
|
+
addServerHandler({
|
|
81
|
+
route: `/${routePath}`,
|
|
82
|
+
handler: resolve('./runtime/server/api/search'),
|
|
83
|
+
})
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
declare module 'nuxt/schema' {
|
|
88
|
+
interface PublicRuntimeConfig {
|
|
89
|
+
assistant: {
|
|
90
|
+
enabled: boolean
|
|
91
|
+
apiPath: string
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
interface RuntimeConfig {
|
|
95
|
+
assistant: {
|
|
96
|
+
mcpServer: string
|
|
97
|
+
model: string
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useDocusI18n } from '../../../../app/composables/useDocusI18n'
|
|
3
|
+
|
|
4
|
+
const appConfig = useAppConfig()
|
|
5
|
+
const { toggle } = useAssistant()
|
|
6
|
+
const { t } = useDocusI18n()
|
|
7
|
+
|
|
8
|
+
const tooltipText = computed(() => t('assistant.tooltip'))
|
|
9
|
+
const triggerIcon = computed(() => appConfig.assistant?.icons?.trigger || 'i-lucide-sparkles')
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<UTooltip :text="tooltipText">
|
|
14
|
+
<UButton
|
|
15
|
+
:icon="triggerIcon"
|
|
16
|
+
variant="ghost"
|
|
17
|
+
class="rounded-full"
|
|
18
|
+
@click="toggle"
|
|
19
|
+
/>
|
|
20
|
+
</UTooltip>
|
|
21
|
+
</template>
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { AnimatePresence, motion } from 'motion-v'
|
|
3
|
+
import { useDocusI18n } from '../../../../app/composables/useDocusI18n'
|
|
4
|
+
|
|
5
|
+
const route = useRoute()
|
|
6
|
+
const appConfig = useAppConfig()
|
|
7
|
+
const { open, isOpen } = useAssistant()
|
|
8
|
+
const { t } = useDocusI18n()
|
|
9
|
+
const input = ref('')
|
|
10
|
+
const isVisible = ref(true)
|
|
11
|
+
const inputRef = ref<{ inputRef: HTMLInputElement } | null>(null)
|
|
12
|
+
|
|
13
|
+
const isDocsRoute = computed(() => route.meta.layout === 'docs')
|
|
14
|
+
const isFloatingInputEnabled = computed(() => appConfig.assistant?.floatingInput !== false)
|
|
15
|
+
const focusInputShortcut = computed(() => appConfig.assistant?.shortcuts?.focusInput || 'meta_i')
|
|
16
|
+
const placeholder = computed(() => t('assistant.placeholder'))
|
|
17
|
+
|
|
18
|
+
const shortcutDisplayKeys = computed(() => {
|
|
19
|
+
const shortcut = focusInputShortcut.value
|
|
20
|
+
const parts = shortcut.split('_')
|
|
21
|
+
return parts.map(part => part === 'meta' ? 'meta' : part.toUpperCase())
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
function handleSubmit() {
|
|
25
|
+
if (!input.value.trim()) return
|
|
26
|
+
|
|
27
|
+
const message = input.value
|
|
28
|
+
isVisible.value = false
|
|
29
|
+
|
|
30
|
+
setTimeout(() => {
|
|
31
|
+
open(message, true)
|
|
32
|
+
input.value = ''
|
|
33
|
+
isVisible.value = true
|
|
34
|
+
}, 200)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const shortcuts = computed(() => ({
|
|
38
|
+
[focusInputShortcut.value]: {
|
|
39
|
+
usingInput: true,
|
|
40
|
+
handler: () => {
|
|
41
|
+
if (!isDocsRoute.value || !isFloatingInputEnabled.value) return
|
|
42
|
+
inputRef.value?.inputRef?.focus()
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
escape: {
|
|
46
|
+
usingInput: true,
|
|
47
|
+
handler: () => {
|
|
48
|
+
inputRef.value?.inputRef?.blur()
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
}))
|
|
52
|
+
|
|
53
|
+
defineShortcuts(shortcuts)
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<template>
|
|
57
|
+
<AnimatePresence>
|
|
58
|
+
<motion.div
|
|
59
|
+
v-if="isFloatingInputEnabled && isDocsRoute && isVisible && !isOpen"
|
|
60
|
+
key="floating-input"
|
|
61
|
+
:initial="{ y: 20, opacity: 0 }"
|
|
62
|
+
:animate="{ y: 0, opacity: 1 }"
|
|
63
|
+
:exit="{ y: 100, opacity: 0 }"
|
|
64
|
+
:transition="{ duration: 0.2, ease: 'easeOut' }"
|
|
65
|
+
class="fixed inset-x-0 z-10 px-4 sm:px-80 bottom-[max(1.5rem,env(safe-area-inset-bottom))]"
|
|
66
|
+
style="will-change: transform"
|
|
67
|
+
>
|
|
68
|
+
<form
|
|
69
|
+
class="flex w-full justify-center"
|
|
70
|
+
@submit.prevent="handleSubmit"
|
|
71
|
+
>
|
|
72
|
+
<div class="w-full max-w-96">
|
|
73
|
+
<UInput
|
|
74
|
+
ref="inputRef"
|
|
75
|
+
v-model="input"
|
|
76
|
+
:placeholder="placeholder"
|
|
77
|
+
size="lg"
|
|
78
|
+
maxlength="1000"
|
|
79
|
+
:ui="{
|
|
80
|
+
root: 'group w-full! min-w-0 sm:max-w-96 transition-all duration-300 ease-out [@media(hover:hover)]:hover:scale-105 [@media(hover:hover)]:focus-within:scale-105',
|
|
81
|
+
base: 'bg-default shadow-lg rounded-xl text-base',
|
|
82
|
+
trailing: 'pe-2',
|
|
83
|
+
}"
|
|
84
|
+
@keydown.enter.exact.prevent="handleSubmit"
|
|
85
|
+
>
|
|
86
|
+
<template #trailing>
|
|
87
|
+
<div class="flex items-center gap-2">
|
|
88
|
+
<div class="hidden sm:flex group-focus-within:hidden items-center gap-1">
|
|
89
|
+
<UKbd
|
|
90
|
+
v-for="key in shortcutDisplayKeys"
|
|
91
|
+
:key="key"
|
|
92
|
+
:value="key"
|
|
93
|
+
/>
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<UButton
|
|
97
|
+
type="submit"
|
|
98
|
+
icon="i-lucide-arrow-up"
|
|
99
|
+
color="primary"
|
|
100
|
+
size="xs"
|
|
101
|
+
:disabled="!input.trim()"
|
|
102
|
+
/>
|
|
103
|
+
</div>
|
|
104
|
+
</template>
|
|
105
|
+
</UInput>
|
|
106
|
+
</div>
|
|
107
|
+
</form>
|
|
108
|
+
</motion.div>
|
|
109
|
+
</AnimatePresence>
|
|
110
|
+
</template>
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { motion } from 'motion-v'
|
|
3
|
+
import { useDocusI18n } from '../../../../app/composables/useDocusI18n'
|
|
4
|
+
|
|
5
|
+
interface ToolCall {
|
|
6
|
+
toolCallId: string
|
|
7
|
+
toolName: string
|
|
8
|
+
args: Record<string, unknown>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
text?: string
|
|
13
|
+
toolCalls?: ToolCall[]
|
|
14
|
+
isLoading?: boolean
|
|
15
|
+
}>()
|
|
16
|
+
|
|
17
|
+
const { t } = useDocusI18n()
|
|
18
|
+
|
|
19
|
+
const messages = computed(() => [
|
|
20
|
+
t('assistant.loading.searching'),
|
|
21
|
+
t('assistant.loading.reading'),
|
|
22
|
+
t('assistant.loading.analyzing'),
|
|
23
|
+
t('assistant.loading.finding'),
|
|
24
|
+
])
|
|
25
|
+
|
|
26
|
+
const finishedMessage = computed(() => t('assistant.loading.finished'))
|
|
27
|
+
|
|
28
|
+
const currentIndex = ref(0)
|
|
29
|
+
const targetText = computed(() => {
|
|
30
|
+
if (!props.isLoading) {
|
|
31
|
+
return finishedMessage.value
|
|
32
|
+
}
|
|
33
|
+
return props.text || messages.value[currentIndex.value]
|
|
34
|
+
})
|
|
35
|
+
const displayedText = ref(targetText.value)
|
|
36
|
+
|
|
37
|
+
const chars = 'abcdefghijklmnopqrstuvwxyz'
|
|
38
|
+
|
|
39
|
+
function scrambleText(from: string, to: string) {
|
|
40
|
+
const maxLength = Math.max(from.length, to.length)
|
|
41
|
+
let frame = 0
|
|
42
|
+
const totalFrames = 15
|
|
43
|
+
|
|
44
|
+
const animate = () => {
|
|
45
|
+
frame++
|
|
46
|
+
let result = ''
|
|
47
|
+
|
|
48
|
+
for (let i = 0; i < maxLength; i++) {
|
|
49
|
+
const progress = frame / totalFrames
|
|
50
|
+
const charProgress = progress * maxLength
|
|
51
|
+
|
|
52
|
+
if (i < charProgress - 2) {
|
|
53
|
+
result += to[i] || ''
|
|
54
|
+
}
|
|
55
|
+
else if (i < charProgress) {
|
|
56
|
+
result += chars[Math.floor(Math.random() * chars.length)]
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
result += from[i] || ''
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
displayedText.value = result
|
|
64
|
+
|
|
65
|
+
if (frame < totalFrames) {
|
|
66
|
+
requestAnimationFrame(animate)
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
displayedText.value = to
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
requestAnimationFrame(animate)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let textInterval: ReturnType<typeof setInterval> | null = null
|
|
77
|
+
|
|
78
|
+
watch(targetText, (newText, oldText) => {
|
|
79
|
+
if (newText !== oldText && newText && oldText) {
|
|
80
|
+
scrambleText(oldText, newText)
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// Stop text rotation when loading finishes
|
|
85
|
+
watch(() => props.isLoading, (isLoading) => {
|
|
86
|
+
if (!isLoading && textInterval) {
|
|
87
|
+
clearInterval(textInterval)
|
|
88
|
+
textInterval = null
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
93
|
+
function getToolLabel(toolName: string, args: any) {
|
|
94
|
+
const path = args?.path || ''
|
|
95
|
+
|
|
96
|
+
if (toolName === 'list-pages') {
|
|
97
|
+
return t('assistant.toolListPages')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (toolName === 'get-page') {
|
|
101
|
+
return `${t('assistant.toolReadPage')} ${path || '...'}`
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return toolName
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
onMounted(() => {
|
|
108
|
+
// Text rotation only when loading
|
|
109
|
+
if (!props.text && props.isLoading) {
|
|
110
|
+
textInterval = setInterval(() => {
|
|
111
|
+
currentIndex.value = (currentIndex.value + 1) % messages.value.length
|
|
112
|
+
}, 3500)
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
onUnmounted(() => {
|
|
117
|
+
if (textInterval) clearInterval(textInterval)
|
|
118
|
+
})
|
|
119
|
+
</script>
|
|
120
|
+
|
|
121
|
+
<template>
|
|
122
|
+
<div class="flex flex-col gap-2">
|
|
123
|
+
<!-- Main loader with matrix and text -->
|
|
124
|
+
<div class="flex items-center text-xs text-muted overflow-hidden">
|
|
125
|
+
<motion.div
|
|
126
|
+
v-if="isLoading"
|
|
127
|
+
:initial="{ opacity: 1, width: 'auto' }"
|
|
128
|
+
:exit="{ opacity: 0, width: 0 }"
|
|
129
|
+
:transition="{ duration: 0.2 }"
|
|
130
|
+
class="shrink-0 mr-2"
|
|
131
|
+
>
|
|
132
|
+
<AssistantMatrix />
|
|
133
|
+
</motion.div>
|
|
134
|
+
<motion.span
|
|
135
|
+
:animate="{ x: 0 }"
|
|
136
|
+
:transition="{ duration: 0.2 }"
|
|
137
|
+
class="font-mono tracking-tight"
|
|
138
|
+
>
|
|
139
|
+
{{ displayedText }}
|
|
140
|
+
</motion.span>
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<!-- Tool calls displayed below -->
|
|
144
|
+
<div
|
|
145
|
+
v-if="toolCalls?.length"
|
|
146
|
+
class="flex flex-col gap-1"
|
|
147
|
+
:class="isLoading ? 'pl-[22px]' : 'pl-0'"
|
|
148
|
+
>
|
|
149
|
+
<motion.div
|
|
150
|
+
v-for="tool in toolCalls"
|
|
151
|
+
:key="`${tool.toolCallId}-${JSON.stringify(tool.args)}`"
|
|
152
|
+
:initial="{ opacity: 0, x: -4 }"
|
|
153
|
+
:animate="{ opacity: 1, x: 0 }"
|
|
154
|
+
:transition="{ duration: 0.15 }"
|
|
155
|
+
class="flex items-center gap-1.5"
|
|
156
|
+
>
|
|
157
|
+
<span class="size-1 rounded-full bg-current opacity-40" />
|
|
158
|
+
<span class="text-[11px] text-dimmed truncate max-w-[200px]">
|
|
159
|
+
{{ getToolLabel(tool.toolName, tool.args) }}
|
|
160
|
+
</span>
|
|
161
|
+
</motion.div>
|
|
162
|
+
</div>
|
|
163
|
+
</div>
|
|
164
|
+
</template>
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
size?: number
|
|
4
|
+
dotSize?: number
|
|
5
|
+
gap?: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const props = withDefaults(defineProps<Props>(), {
|
|
9
|
+
size: 4,
|
|
10
|
+
dotSize: 2,
|
|
11
|
+
gap: 2,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
const totalDots = computed(() => props.size * props.size)
|
|
15
|
+
const activeDots = ref<Set<number>>(new Set())
|
|
16
|
+
|
|
17
|
+
// Patterns for 4x4 grid (indices 0-15)
|
|
18
|
+
// Grid layout:
|
|
19
|
+
// 0 1 2 3
|
|
20
|
+
// 4 5 6 7
|
|
21
|
+
// 8 9 10 11
|
|
22
|
+
// 12 13 14 15
|
|
23
|
+
const patterns = [
|
|
24
|
+
// Spiral inward
|
|
25
|
+
[[0], [1], [2], [3], [7], [11], [15], [14], [13], [12], [8], [4], [5], [6], [10], [9]],
|
|
26
|
+
// Wave horizontal
|
|
27
|
+
[[0, 4, 8, 12], [1, 5, 9, 13], [2, 6, 10, 14], [3, 7, 11, 15]],
|
|
28
|
+
// Diamond pulse
|
|
29
|
+
[[5, 6, 9, 10], [1, 4, 7, 8, 11, 14], [0, 3, 12, 15], [1, 4, 7, 8, 11, 14], [5, 6, 9, 10]],
|
|
30
|
+
// Loading bar
|
|
31
|
+
[[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11], [12, 13, 14, 15]],
|
|
32
|
+
// Corners rotate
|
|
33
|
+
[[0], [3], [15], [12]],
|
|
34
|
+
// Cross pulse
|
|
35
|
+
[[5, 6, 9, 10], [1, 2, 4, 7, 8, 11, 13, 14], [0, 3, 12, 15]],
|
|
36
|
+
// Snake
|
|
37
|
+
[[0], [1], [2], [3], [7], [6], [5], [4], [8], [9], [10], [11], [15], [14], [13], [12]],
|
|
38
|
+
// Diagonal wave
|
|
39
|
+
[[0], [1, 4], [2, 5, 8], [3, 6, 9, 12], [7, 10, 13], [11, 14], [15]],
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
let patternIndex = 0
|
|
43
|
+
let stepIndex = 0
|
|
44
|
+
let interval: ReturnType<typeof setInterval> | null = null
|
|
45
|
+
|
|
46
|
+
function nextStep() {
|
|
47
|
+
const pattern = patterns[patternIndex]
|
|
48
|
+
if (!pattern) return
|
|
49
|
+
|
|
50
|
+
activeDots.value = new Set(pattern[stepIndex])
|
|
51
|
+
stepIndex++
|
|
52
|
+
|
|
53
|
+
if (stepIndex >= pattern.length) {
|
|
54
|
+
stepIndex = 0
|
|
55
|
+
patternIndex = (patternIndex + 1) % patterns.length
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const gridStyle = computed(() => ({
|
|
60
|
+
display: 'grid',
|
|
61
|
+
gridTemplateColumns: `repeat(${props.size}, 1fr)`,
|
|
62
|
+
gap: `${props.gap}px`,
|
|
63
|
+
width: `${props.size * props.dotSize + (props.size - 1) * props.gap}px`,
|
|
64
|
+
height: `${props.size * props.dotSize + (props.size - 1) * props.gap}px`,
|
|
65
|
+
}))
|
|
66
|
+
|
|
67
|
+
const dotStyle = computed(() => ({
|
|
68
|
+
width: `${props.dotSize}px`,
|
|
69
|
+
height: `${props.dotSize}px`,
|
|
70
|
+
}))
|
|
71
|
+
|
|
72
|
+
onMounted(() => {
|
|
73
|
+
interval = setInterval(nextStep, 120)
|
|
74
|
+
nextStep()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
onUnmounted(() => {
|
|
78
|
+
if (interval) clearInterval(interval)
|
|
79
|
+
})
|
|
80
|
+
</script>
|
|
81
|
+
|
|
82
|
+
<template>
|
|
83
|
+
<div :style="gridStyle">
|
|
84
|
+
<span
|
|
85
|
+
v-for="i in totalDots"
|
|
86
|
+
:key="i"
|
|
87
|
+
class="rounded-[0.5px] bg-current transition-opacity duration-100"
|
|
88
|
+
:class="activeDots.has(i - 1) ? 'opacity-100' : 'opacity-20'"
|
|
89
|
+
:style="dotStyle"
|
|
90
|
+
/>
|
|
91
|
+
</div>
|
|
92
|
+
</template>
|