docus 5.11.0 → 5.12.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 (30) hide show
  1. package/app/app.config.ts +3 -0
  2. package/app/app.vue +22 -27
  3. package/app/components/app/AppSearch.vue +54 -0
  4. package/app/components/docs/DocsAsideRight.vue +1 -2
  5. package/app/components/docs/DocsAsideRightBottom.vue +2 -2
  6. package/app/components/docs/DocsPageHeaderLinks.vue +4 -4
  7. package/app/composables/useDocusShortcuts.ts +24 -0
  8. package/app/composables/useUIConfig.ts +1 -1
  9. package/app/error.vue +1 -10
  10. package/app/pages/[[lang]]/[...slug].vue +6 -5
  11. package/app/types/index.d.ts +22 -0
  12. package/i18n/locales/pl.json +2 -2
  13. package/modules/assistant/index.ts +9 -3
  14. package/modules/assistant/runtime/components/AssistantChat.vue +5 -2
  15. package/modules/assistant/runtime/components/AssistantComark.ts +9 -0
  16. package/modules/assistant/runtime/components/AssistantFloatingInput.vue +9 -10
  17. package/modules/assistant/runtime/components/AssistantIndicator.vue +116 -0
  18. package/modules/assistant/runtime/components/AssistantPanel.vue +268 -258
  19. package/modules/assistant/runtime/components/AssistantPreStream.vue +1 -1
  20. package/modules/assistant/runtime/composables/useAssistant.ts +34 -38
  21. package/modules/assistant/runtime/server/api/search.ts +22 -42
  22. package/modules/css.ts +8 -0
  23. package/nuxt.schema.ts +28 -0
  24. package/package.json +46 -28
  25. package/server/mcp/tools/get-page.ts +11 -5
  26. package/server/mcp/tools/list-pages.ts +5 -4
  27. package/server/routes/sitemap.xml.ts +7 -4
  28. package/server/utils/content.ts +4 -0
  29. package/modules/assistant/runtime/components/AssistantLoading.vue +0 -164
  30. package/modules/assistant/runtime/components/AssistantMatrix.vue +0 -92
package/app/app.config.ts CHANGED
@@ -3,6 +3,9 @@ export default defineAppConfig({
3
3
  locale: 'en',
4
4
  colorMode: '',
5
5
  },
6
+ search: {
7
+ fts: false,
8
+ },
6
9
  ui: {
7
10
  colors: {
8
11
  primary: 'emerald',
package/app/app.vue CHANGED
@@ -2,15 +2,15 @@
2
2
  import type { ContentNavigationItem, PageCollections } from '@nuxt/content'
3
3
  import * as nuxtUiLocales from '@nuxt/ui/locale'
4
4
  import { transformNavigation } from './utils/navigation'
5
- import { useDocusColorMode } from './composables/useDocusColorMode'
5
+ import { useDocusShortcuts } from './composables/useDocusShortcuts'
6
6
  import { useSubNavigation } from './composables/useSubNavigation'
7
7
 
8
8
  const appConfig = useAppConfig()
9
9
  const { seo } = appConfig
10
- const { forced: forcedColorMode } = useDocusColorMode()
10
+ useDocusShortcuts()
11
11
  const site = useSiteConfig()
12
12
  const { locale, locales, isEnabled, switchLocalePath } = useDocusI18n()
13
- const { isEnabled: isAssistantEnabled, panelWidth: assistantPanelWidth, shouldPushContent } = useAssistant()
13
+ const { isEnabled: isAssistantEnabled } = useAssistant()
14
14
 
15
15
  const nuxtUiLocale = computed(() => nuxtUiLocales[locale.value as keyof typeof nuxtUiLocales] || nuxtUiLocales.en)
16
16
  const lang = computed(() => nuxtUiLocale.value.code)
@@ -53,10 +53,6 @@ const { data: navigation } = await useAsyncData(() => `navigation_${collectionNa
53
53
  transform: (data: ContentNavigationItem[]) => transformNavigation(data, isEnabled.value, locale.value),
54
54
  watch: [locale],
55
55
  })
56
- const { data: files } = useLazyAsyncData(`search_${collectionName.value}`, () => queryCollectionSearchSections(collectionName.value as keyof PageCollections), {
57
- server: false,
58
- watch: [locale],
59
- })
60
56
 
61
57
  provide('navigation', navigation)
62
58
 
@@ -67,28 +63,27 @@ const { subNavigationMode } = useSubNavigation(navigation)
67
63
  <UApp :locale="nuxtUiLocale">
68
64
  <NuxtLoadingIndicator color="var(--ui-primary)" />
69
65
 
70
- <div
71
- :class="['transition-[margin-right] duration-200 ease-linear will-change-[margin-right]', { 'docus-sub-header': subNavigationMode === 'header' }]"
72
- :style="{ marginRight: shouldPushContent ? `${assistantPanelWidth}px` : '0' }"
73
- >
74
- <AppHeader v-if="$route.meta.header !== false" />
75
- <NuxtLayout>
76
- <NuxtPage />
77
- </NuxtLayout>
78
- <AppFooter v-if="$route.meta.footer !== false" />
79
- </div>
66
+ <div class="flex">
67
+ <div
68
+ class="flex-1 min-w-0"
69
+ :class="{ 'docus-sub-header': subNavigationMode === 'header' }"
70
+ >
71
+ <AppHeader v-if="$route.meta.header !== false" />
72
+ <NuxtLayout>
73
+ <NuxtPage />
74
+ </NuxtLayout>
75
+ <AppFooter v-if="$route.meta.footer !== false" />
80
76
 
81
- <ClientOnly>
82
- <LazyUContentSearch
83
- :files="files"
84
- :navigation="navigation"
85
- :color-mode="!forcedColorMode"
86
- />
87
- <template v-if="isAssistantEnabled">
77
+ <ClientOnly>
78
+ <AppSearch :navigation="navigation" />
79
+ <LazyAssistantFloatingInput v-if="isAssistantEnabled" />
80
+ </ClientOnly>
81
+ </div>
82
+
83
+ <ClientOnly v-if="isAssistantEnabled">
88
84
  <LazyAssistantPanel />
89
- <LazyAssistantFloatingInput />
90
- </template>
91
- </ClientOnly>
85
+ </ClientOnly>
86
+ </div>
92
87
  </UApp>
93
88
  </template>
94
89
 
@@ -0,0 +1,54 @@
1
+ <script setup lang="ts">
2
+ import type { ContentNavigationItem, PageCollections } from '@nuxt/content'
3
+
4
+ const props = defineProps<{
5
+ navigation?: ContentNavigationItem[]
6
+ }>()
7
+
8
+ const appConfig = useAppConfig()
9
+ const { forced: forcedColorMode } = useDocusColorMode()
10
+ const { locale, isEnabled } = useDocusI18n()
11
+
12
+ const collectionName = computed(() => (isEnabled.value ? `docs_${locale.value}` : 'docs') as keyof PageCollections)
13
+ const useFts = appConfig.search.fts
14
+
15
+ const { data: files } = useFts
16
+ ? { data: ref(null) }
17
+ : useLazyAsyncData(`search_${collectionName.value}`, () => queryCollectionSearchSections(collectionName.value), {
18
+ server: false,
19
+ watch: [locale],
20
+ })
21
+
22
+ const { search, status: searchStatus, init } = useFts
23
+ ? useSearchCollection(collectionName, { immediate: false, ignoredTags: ['style'] })
24
+ : { search: undefined, status: ref(undefined), init: () => {} }
25
+
26
+ if (useFts) {
27
+ const { open } = useContentSearch()
28
+ watch(open, (value) => {
29
+ if (value && searchStatus.value === 'idle') {
30
+ init()
31
+ }
32
+ })
33
+ }
34
+
35
+ const links = computed(() => useFts
36
+ ? props.navigation?.filter(item => item.children?.length).map(item => ({
37
+ label: item.title,
38
+ icon: item.icon,
39
+ to: item.children![0]!.path,
40
+ }))
41
+ : undefined,
42
+ )
43
+ </script>
44
+
45
+ <template>
46
+ <LazyUContentSearch
47
+ :files="files"
48
+ :search="search"
49
+ :search-status="searchStatus"
50
+ :links="links"
51
+ :navigation="navigation"
52
+ :color-mode="!forcedColorMode"
53
+ />
54
+ </template>
@@ -8,7 +8,6 @@ const props = defineProps<{
8
8
 
9
9
  const links = computed(() => props.page?.body?.toc?.links || [])
10
10
 
11
- const { shouldPushContent: shouldHideToc } = useAssistant()
12
11
  const { subNavigationMode } = useSubNavigation()
13
12
  const appConfig = useAppConfig()
14
13
  const { t } = useDocusI18n()
@@ -19,7 +18,7 @@ const contentTocVariants = useUIConfig('contentToc')
19
18
  <template>
20
19
  <div>
21
20
  <UContentToc
22
- v-if="links.length && !shouldHideToc"
21
+ v-if="links.length"
23
22
  :highlight="contentTocVariants.highlight ?? true"
24
23
  :highlight-color="contentTocVariants.highlightColor"
25
24
  :highlight-variant="contentTocVariants.highlightVariant"
@@ -4,10 +4,10 @@ const route = useRoute()
4
4
  const pageUrl = route.path
5
5
  const appConfig = useAppConfig()
6
6
  const { t } = useDocusI18n()
7
- const { isEnabled, open } = useAssistant()
7
+ const { isEnabled, isStudioExpanded, open } = useAssistant()
8
8
 
9
9
  const showExplainWithAi = computed(() => {
10
- return isEnabled.value && appConfig.assistant?.explainWithAi !== false
10
+ return isEnabled.value && appConfig.assistant?.explainWithAi !== false && !isStudioExpanded.value
11
11
  })
12
12
 
13
13
  const explainIcon = computed(() => appConfig.assistant?.icons?.explain || 'i-lucide-brain')
@@ -14,8 +14,8 @@ const { t } = useDocusI18n()
14
14
 
15
15
  const markdownLink = computed(() => `${window?.location?.origin}${withTrailingSlash(appBaseURL)}raw${route.path}.md`)
16
16
  const mcpServerUrl = computed(() => `${window?.location?.origin}${joinURL(appBaseURL, mcpRoute)}`)
17
- const mcpDeeplink = joinURL(mcpRoute, 'deeplink')
18
- const items = [
17
+ const mcpDeeplink = computed(() => `${window?.location?.origin}${joinURL(appBaseURL, mcpRoute, 'deeplink')}`)
18
+ const items = computed(() => [
19
19
  [{
20
20
  label: t('docs.copy.link'),
21
21
  icon: 'i-lucide-link',
@@ -57,10 +57,10 @@ const items = [
57
57
  label: 'Add MCP Server',
58
58
  icon: 'i-simple-icons:cursor',
59
59
  target: '_blank',
60
- to: mcpDeeplink,
60
+ to: mcpDeeplink.value,
61
61
  },
62
62
  ],
63
- ]
63
+ ])
64
64
 
65
65
  async function copyPage() {
66
66
  const page = await $fetch<string>(`/raw${route.path}.md`)
@@ -0,0 +1,24 @@
1
+ import { useDocusColorMode } from './useDocusColorMode'
2
+
3
+ export function useDocusShortcuts() {
4
+ const appConfig = useAppConfig()
5
+ const { forced: forcedColorMode } = useDocusColorMode()
6
+ const colorMode = useColorMode()
7
+ const toggleColorModeShortcut = computed(() => appConfig.docus?.shortcuts?.toggleColorMode ?? 'd')
8
+
9
+ const shortcuts = computed(() => {
10
+ const key = toggleColorModeShortcut.value
11
+ if (!key) return {}
12
+
13
+ return {
14
+ [key]: {
15
+ handler: () => {
16
+ if (forcedColorMode) return
17
+ colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
18
+ },
19
+ },
20
+ }
21
+ })
22
+
23
+ defineShortcuts(shortcuts)
24
+ }
@@ -24,7 +24,7 @@ interface UIConfigMap {
24
24
  export function useUIConfig<K extends keyof UIConfigMap>(componentName: K): ComputedRef<UIConfigMap[K]> {
25
25
  const appConfig = useAppConfig()
26
26
  return computed(() => {
27
- const ui = appConfig.ui as Record<string, Record<string, Record<string, unknown>>>
27
+ const ui = appConfig.ui
28
28
  return (ui?.[componentName]?.defaultVariants || {}) as UIConfigMap[K]
29
29
  })
30
30
  }
package/app/error.vue CHANGED
@@ -3,13 +3,11 @@ import type { NuxtError } from '#app'
3
3
  import type { ContentNavigationItem, PageCollections } from '@nuxt/content'
4
4
  import * as nuxtUiLocales from '@nuxt/ui/locale'
5
5
  import { transformNavigation } from './utils/navigation'
6
- import { useDocusColorMode } from './composables/useDocusColorMode'
7
6
 
8
7
  const props = defineProps<{
9
8
  error: NuxtError
10
9
  }>()
11
10
 
12
- const { forced: forcedColorMode } = useDocusColorMode()
13
11
  const { locale, locales, isEnabled, t, switchLocalePath } = useDocusI18n()
14
12
 
15
13
  const nuxtUiLocale = computed(() => nuxtUiLocales[locale.value as keyof typeof nuxtUiLocales] || nuxtUiLocales.en)
@@ -53,9 +51,6 @@ const { data: navigation } = await useAsyncData(`navigation_${collectionName.val
53
51
  transform: (data: ContentNavigationItem[]) => transformNavigation(data, isEnabled.value, locale.value),
54
52
  watch: [locale],
55
53
  })
56
- const { data: files } = useLazyAsyncData(`search_${collectionName.value}`, () => queryCollectionSearchSections(collectionName.value as keyof PageCollections), {
57
- server: false,
58
- })
59
54
 
60
55
  provide('navigation', navigation)
61
56
  </script>
@@ -69,11 +64,7 @@ provide('navigation', navigation)
69
64
  <AppFooter />
70
65
 
71
66
  <ClientOnly>
72
- <LazyUContentSearch
73
- :files="files"
74
- :navigation="navigation"
75
- :color-mode="!forcedColorMode"
76
- />
67
+ <AppSearch :navigation="navigation" />
77
68
  </ClientOnly>
78
69
  </UApp>
79
70
  </template>
@@ -9,10 +9,9 @@ definePageMeta({
9
9
 
10
10
  const route = useRoute()
11
11
  const { locale, isEnabled, t } = useDocusI18n()
12
+ const { isOpen } = useAssistant()
12
13
  const appConfig = useAppConfig()
13
14
  const navigation = inject<Ref<ContentNavigationItem[]>>('navigation')
14
- const { shouldPushContent: shouldHideToc } = useAssistant()
15
-
16
15
  const collectionName = computed(() => isEnabled.value ? `docs_${locale.value}` : 'docs')
17
16
 
18
17
  const [{ data: page }, { data: surround }] = await Promise.all([
@@ -75,8 +74,7 @@ addPrerenderPath(`/raw${route.path}.md`)
75
74
  <template>
76
75
  <UPage
77
76
  v-if="page"
78
- :key="`page-${shouldHideToc}`"
79
- :ui="{ root: 'lg:grid-cols-12', center: 'lg:col-span-9', right: 'lg:col-span-3' }"
77
+ :ui="isOpen ? { center: 'lg:col-span-10' } : undefined"
80
78
  >
81
79
  <UPageHeader
82
80
  :title="page.title"
@@ -136,7 +134,10 @@ addPrerenderPath(`/raw${route.path}.md`)
136
134
  <UContentSurround :surround="surround" />
137
135
  </UPageBody>
138
136
 
139
- <template #right>
137
+ <template
138
+ v-if="!isOpen"
139
+ #right
140
+ >
140
141
  <DocsAsideRight
141
142
  :page="page"
142
143
  />
@@ -6,6 +6,20 @@ declare module 'nuxt/schema' {
6
6
  interface AppConfig {
7
7
  docus: {
8
8
  locale: string
9
+ /**
10
+ * Force a specific color mode. Leave empty for system preference with toggle.
11
+ */
12
+ colorMode?: '' | 'light' | 'dark'
13
+ /**
14
+ * Keyboard shortcuts configuration.
15
+ */
16
+ shortcuts?: {
17
+ /**
18
+ * Shortcut to toggle light and dark mode.
19
+ * @default 'd'
20
+ */
21
+ toggleColorMode?: string
22
+ }
9
23
  }
10
24
  seo: {
11
25
  titleTemplate: string
@@ -48,6 +62,14 @@ declare module 'nuxt/schema' {
48
62
  branch: string
49
63
  rootDir?: string
50
64
  } | false
65
+ search: {
66
+ /**
67
+ * Use SQLite FTS5 full-text search instead of Fuse.js.
68
+ * Requires @nuxt/content v3.14+.
69
+ * @default false
70
+ */
71
+ fts: boolean
72
+ }
51
73
  assistant?: {
52
74
  /**
53
75
  * Show the floating input at the bottom of documentation pages.
@@ -34,7 +34,7 @@
34
34
  "copyWordmarkFailed": "Nie udało się skopiować wordmarku"
35
35
  },
36
36
  "assistant": {
37
- "title": "Zapytaj SI",
37
+ "title": "Zapytaj AI",
38
38
  "placeholder": "Zadaj pytanie...",
39
39
  "tooltip": "Zadaj AI pytanie",
40
40
  "tryAsking": "Spróbuj zadać pytanie",
@@ -49,7 +49,7 @@
49
49
  "faq": "Często zadawane pytania",
50
50
  "chatCleared": "Czat został wyczyszczony po odświeżeniu",
51
51
  "lineBreak": "Podział wiersza",
52
- "explainWithAi": "Wyjaśnij za pomocą sztucznej inteligencji",
52
+ "explainWithAi": "Wyjaśnij z AI",
53
53
  "toolListPages": "Wymienione strony dokumentacji",
54
54
  "toolReadPage": "Czytaj",
55
55
  "loading": {
@@ -33,6 +33,9 @@ export default defineNuxtModule<AssistantModuleOptions>({
33
33
  meta: {
34
34
  name: 'assistant',
35
35
  },
36
+ moduleDependencies: {
37
+ '@comark/nuxt': {},
38
+ },
36
39
  setup(_options, nuxt) {
37
40
  const legacyOptions = nuxt.options.assistant
38
41
  if (legacyOptions && Object.keys(legacyOptions).length > 0) {
@@ -63,8 +66,6 @@ export default defineNuxtModule<AssistantModuleOptions>({
63
66
  'AssistantChat',
64
67
  'AssistantPanel',
65
68
  'AssistantFloatingInput',
66
- 'AssistantLoading',
67
- 'AssistantMatrix',
68
69
  ]
69
70
 
70
71
  components.forEach(name =>
@@ -88,7 +89,12 @@ export default defineNuxtModule<AssistantModuleOptions>({
88
89
  model: options.model,
89
90
  }
90
91
 
91
- const routePath = options.apiPath.replace(/^\//, '')
92
+ addComponent({
93
+ name: 'AssistantComark',
94
+ filePath: resolve('./runtime/components/AssistantComark'),
95
+ })
96
+
97
+ const routePath = options.apiPath!.replace(/^\//, '')
92
98
  addServerHandler({
93
99
  route: `/${routePath}`,
94
100
  handler: resolve('./runtime/server/api/search'),
@@ -2,7 +2,7 @@
2
2
  import { useDocusI18n } from '../../../../app/composables/useDocusI18n'
3
3
 
4
4
  const appConfig = useAppConfig()
5
- const { toggle } = useAssistant()
5
+ const { toggle, isStudioExpanded } = useAssistant()
6
6
  const { t } = useDocusI18n()
7
7
 
8
8
  const tooltipText = computed(() => t('assistant.tooltip'))
@@ -10,7 +10,10 @@ const triggerIcon = computed(() => appConfig.assistant?.icons?.trigger || 'i-cus
10
10
  </script>
11
11
 
12
12
  <template>
13
- <UTooltip :text="tooltipText">
13
+ <UTooltip
14
+ v-if="!isStudioExpanded"
15
+ :text="tooltipText"
16
+ >
14
17
  <UButton
15
18
  :icon="triggerIcon"
16
19
  color="neutral"
@@ -0,0 +1,9 @@
1
+ import highlight from '@comark/nuxt/plugins/highlight'
2
+
3
+ export default defineComarkComponent({
4
+ name: 'AssistantComark',
5
+ plugins: [
6
+ highlight(),
7
+ ],
8
+ class: '*:first:mt-0 *:last:mb-0',
9
+ })
@@ -4,7 +4,7 @@ import { useDocusI18n } from '../../../../app/composables/useDocusI18n'
4
4
 
5
5
  const route = useRoute()
6
6
  const appConfig = useAppConfig()
7
- const { open, isOpen } = useAssistant()
7
+ const { open, isOpen, isStudioExpanded } = useAssistant()
8
8
  const { t } = useDocusI18n()
9
9
  const input = ref('')
10
10
  const isVisible = ref(true)
@@ -42,31 +42,29 @@ const shortcuts = computed(() => ({
42
42
  inputRef.value?.inputRef?.focus()
43
43
  },
44
44
  },
45
- escape: {
46
- usingInput: true,
47
- handler: () => {
48
- inputRef.value?.inputRef?.blur()
49
- },
50
- },
51
45
  }))
52
46
 
53
47
  defineShortcuts(shortcuts)
48
+
49
+ function onEscape() {
50
+ inputRef.value?.inputRef?.blur()
51
+ }
54
52
  </script>
55
53
 
56
54
  <template>
57
55
  <AnimatePresence>
58
56
  <motion.div
59
- v-if="isFloatingInputEnabled && isDocsRoute && isVisible && !isOpen"
57
+ v-if="isFloatingInputEnabled && isDocsRoute && isVisible && !isOpen && !isStudioExpanded"
60
58
  key="floating-input"
61
59
  :initial="{ y: 20, opacity: 0 }"
62
60
  :animate="{ y: 0, opacity: 1 }"
63
61
  :exit="{ y: 100, opacity: 0 }"
64
62
  :transition="{ duration: 0.2, ease: 'easeOut' }"
65
- class="pointer-events-none fixed inset-x-0 z-10 bottom-[max(1.5rem,env(safe-area-inset-bottom))] px-4 sm:px-80"
63
+ class="pointer-events-none fixed inset-x-0 z-10 bottom-[max(1.5rem,env(safe-area-inset-bottom))] px-4 sm:px-24"
66
64
  style="will-change: transform"
67
65
  >
68
66
  <form
69
- class="pointer-events-none flex w-full justify-center"
67
+ class="pointer-events-none flex w-full min-w-3xs justify-center"
70
68
  @submit.prevent="handleSubmit"
71
69
  >
72
70
  <div class="pointer-events-auto w-full max-w-96">
@@ -82,6 +80,7 @@ defineShortcuts(shortcuts)
82
80
  trailing: 'pe-2',
83
81
  }"
84
82
  @keydown.enter.exact.prevent="handleSubmit"
83
+ @keydown.escape="onEscape"
85
84
  >
86
85
  <template #trailing>
87
86
  <div class="flex items-center gap-2">
@@ -0,0 +1,116 @@
1
+ <script setup lang="ts">
2
+ const size = 4
3
+ const dotSize = 2
4
+ const gap = 2
5
+ const totalDots = size * size
6
+
7
+ const patterns = [
8
+ [[0], [1], [2], [3], [7], [11], [15], [14], [13], [12], [8], [4], [5], [6], [10], [9]],
9
+ [[0, 4, 8, 12], [1, 5, 9, 13], [2, 6, 10, 14], [3, 7, 11, 15]],
10
+ [[5, 6, 9, 10], [1, 4, 7, 8, 11, 14], [0, 3, 12, 15], [1, 4, 7, 8, 11, 14], [5, 6, 9, 10]],
11
+ [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11], [12, 13, 14, 15]],
12
+ [[0], [3], [15], [12]],
13
+ [[5, 6, 9, 10], [1, 2, 4, 7, 8, 11, 13, 14], [0, 3, 12, 15]],
14
+ [[0], [1], [2], [3], [7], [6], [5], [4], [8], [9], [10], [11], [15], [14], [13], [12]],
15
+ [[0], [1, 4], [2, 5, 8], [3, 6, 9, 12], [7, 10, 13], [11, 14], [15]],
16
+ ]
17
+
18
+ const activeDots = ref<Set<number>>(new Set())
19
+ let patternIndex = 0
20
+ let stepIndex = 0
21
+
22
+ function nextStep() {
23
+ const pattern = patterns[patternIndex]
24
+ if (!pattern) return
25
+
26
+ activeDots.value = new Set(pattern[stepIndex])
27
+ stepIndex++
28
+
29
+ if (stepIndex >= pattern.length) {
30
+ stepIndex = 0
31
+ patternIndex = (patternIndex + 1) % patterns.length
32
+ }
33
+ }
34
+
35
+ const statusMessages = ['Thinking...', 'Searching...', 'Reading...', 'Analyzing...']
36
+ const currentIndex = ref(0)
37
+ const displayedText = ref(statusMessages[0]!)
38
+ const chars = 'abcdefghijklmnopqrstuvwxyz'
39
+
40
+ function scramble(from: string, to: string) {
41
+ const maxLength = Math.max(from.length, to.length)
42
+ let frame = 0
43
+ const totalFrames = 15
44
+
45
+ const step = () => {
46
+ frame++
47
+ let result = ''
48
+ const progress = (frame / totalFrames) * maxLength
49
+
50
+ for (let i = 0; i < maxLength; i++) {
51
+ if (i < progress - 2) {
52
+ result += to[i] || ''
53
+ }
54
+ else if (i < progress) {
55
+ result += chars[Math.floor(Math.random() * chars.length)]
56
+ }
57
+ else {
58
+ result += from[i] || ''
59
+ }
60
+ }
61
+
62
+ displayedText.value = result
63
+
64
+ if (frame < totalFrames) {
65
+ requestAnimationFrame(step)
66
+ }
67
+ else {
68
+ displayedText.value = to
69
+ }
70
+ }
71
+
72
+ requestAnimationFrame(step)
73
+ }
74
+
75
+ let matrixInterval: ReturnType<typeof setInterval> | undefined
76
+ let textInterval: ReturnType<typeof setInterval> | undefined
77
+
78
+ onMounted(() => {
79
+ nextStep()
80
+ matrixInterval = setInterval(nextStep, 120)
81
+ textInterval = setInterval(() => {
82
+ const prev = displayedText.value
83
+ currentIndex.value = (currentIndex.value + 1) % statusMessages.length
84
+ scramble(prev, statusMessages[currentIndex.value]!)
85
+ }, 3500)
86
+ })
87
+
88
+ onUnmounted(() => {
89
+ clearInterval(matrixInterval)
90
+ clearInterval(textInterval)
91
+ })
92
+ </script>
93
+
94
+ <template>
95
+ <div class="flex items-center text-xs text-muted overflow-hidden">
96
+ <div
97
+ class="shrink-0 mr-2 grid"
98
+ :style="{
99
+ gridTemplateColumns: `repeat(${size}, 1fr)`,
100
+ gap: `${gap}px`,
101
+ width: `${size * dotSize + (size - 1) * gap}px`,
102
+ height: `${size * dotSize + (size - 1) * gap}px`,
103
+ }"
104
+ >
105
+ <span
106
+ v-for="i in totalDots"
107
+ :key="i"
108
+ class="rounded-[0.5px] bg-current transition-opacity duration-100"
109
+ :class="activeDots.has(i - 1) ? 'opacity-100' : 'opacity-20'"
110
+ :style="{ width: `${dotSize}px`, height: `${dotSize}px` }"
111
+ />
112
+ </div>
113
+
114
+ <UChatShimmer :text="displayedText" class="font-mono tracking-tight" />
115
+ </div>
116
+ </template>