docus 5.11.0 → 5.12.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.
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 +6 -3
  14. package/modules/assistant/runtime/components/AssistantChat.vue +5 -2
  15. package/modules/assistant/runtime/components/AssistantComark.ts +10 -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 +29 -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
@@ -1,7 +1,7 @@
1
1
  import type { UIMessage } from 'ai'
2
- import { useAppConfig, useRuntimeConfig, useState } from '#imports'
3
- import { useMediaQuery } from '@vueuse/core'
4
- import { computed } from 'vue'
2
+ import { useAppConfig, useRuntimeConfig } from '#imports'
3
+ import { createSharedComposable, useLocalStorage } from '@vueuse/core'
4
+ import { computed, ref, watch } from 'vue'
5
5
  import type { FaqCategory, FaqQuestions, LocalizedFaqQuestions } from '../types'
6
6
 
7
7
  function normalizeFaqQuestions(questions: FaqQuestions): FaqCategory[] {
@@ -19,10 +19,7 @@ function normalizeFaqQuestions(questions: FaqQuestions): FaqCategory[] {
19
19
  return questions as FaqCategory[]
20
20
  }
21
21
 
22
- const PANEL_WIDTH_COMPACT = 360
23
- const PANEL_WIDTH_EXPANDED = 520
24
-
25
- export function useAssistant() {
22
+ export const useAssistant = createSharedComposable(() => {
26
23
  const config = useRuntimeConfig()
27
24
  const appConfig = useAppConfig()
28
25
  const assistantRuntimeConfig = config.public.assistant as { enabled?: boolean } | undefined
@@ -30,26 +27,37 @@ export function useAssistant() {
30
27
  const docusRuntimeConfig = appConfig.docus as { locale?: string } | undefined
31
28
  const isEnabled = computed(() => assistantRuntimeConfig?.enabled ?? false)
32
29
 
33
- const isOpen = useState('assistant-open', () => false)
34
- const isExpanded = useState('assistant-expanded', () => false)
35
- const messages = useState<UIMessage[]>('assistant-messages', () => [])
36
- const pendingMessage = useState<string | undefined>('assistant-pending', () => undefined)
30
+ const storageOpen = useLocalStorage('assistant-open', false)
31
+ const messages = useLocalStorage<UIMessage[]>('assistant-messages', [])
32
+
33
+ const isOpen = ref(false)
34
+ const isStudioExpanded = ref(false)
37
35
 
38
- const isMobile = useMediaQuery('(max-width: 767px)')
39
- const panelWidth = computed(() => isExpanded.value ? PANEL_WIDTH_EXPANDED : PANEL_WIDTH_COMPACT)
40
- const shouldPushContent = computed(() => !isMobile.value && isOpen.value)
36
+ onNuxtReady(() => {
37
+ nextTick(() => {
38
+ isOpen.value = storageOpen.value
39
+ })
40
+
41
+ isStudioExpanded.value = document.body.hasAttribute('data-expand-sidebar')
42
+ const observer = new MutationObserver(() => {
43
+ isStudioExpanded.value = document.body.hasAttribute('data-expand-sidebar')
44
+ })
45
+ observer.observe(document.body, { attributes: true, attributeFilter: ['data-expand-sidebar'] })
46
+ })
47
+
48
+ watch(isOpen, (value) => {
49
+ storageOpen.value = value
50
+ })
41
51
 
42
52
  const faqQuestions = computed<FaqCategory[]>(() => {
43
53
  const faqConfig = assistantConfig?.faqQuestions
44
54
  if (!faqConfig) return []
45
55
 
46
- // Check if it's a localized object (has locale keys like 'en', 'fr')
47
56
  if (!Array.isArray(faqConfig)) {
48
57
  const localizedConfig = faqConfig as LocalizedFaqQuestions
49
58
  const currentLocale = docusRuntimeConfig?.locale || 'en'
50
59
  const defaultLocale = config.public.i18n?.defaultLocale || 'en'
51
60
 
52
- // Try current locale, then default locale, then first available
53
61
  const questions = localizedConfig[currentLocale]
54
62
  || localizedConfig[defaultLocale]
55
63
  || Object.values(localizedConfig)[0]
@@ -61,51 +69,39 @@ export function useAssistant() {
61
69
  })
62
70
 
63
71
  function open(initialMessage?: string, clearPrevious = false) {
72
+ if (isStudioExpanded.value) return
73
+
64
74
  if (clearPrevious) {
65
75
  messages.value = []
66
76
  }
67
77
 
68
78
  if (initialMessage) {
69
- pendingMessage.value = initialMessage
79
+ messages.value = [...messages.value, {
80
+ id: String(Date.now()),
81
+ role: 'user' as const,
82
+ parts: [{ type: 'text' as const, text: initialMessage }],
83
+ }]
70
84
  }
71
85
  isOpen.value = true
72
86
  }
73
87
 
74
- function clearPending() {
75
- pendingMessage.value = undefined
76
- }
77
-
78
88
  function close() {
79
89
  isOpen.value = false
80
90
  }
81
91
 
82
92
  function toggle() {
93
+ if (isStudioExpanded.value) return
83
94
  isOpen.value = !isOpen.value
84
95
  }
85
96
 
86
- function clearMessages() {
87
- messages.value = []
88
- }
89
-
90
- function toggleExpanded() {
91
- isExpanded.value = !isExpanded.value
92
- }
93
-
94
97
  return {
95
98
  isEnabled,
96
99
  isOpen,
97
- isExpanded,
98
- isMobile,
99
- panelWidth,
100
- shouldPushContent,
100
+ isStudioExpanded,
101
101
  messages,
102
- pendingMessage,
103
102
  faqQuestions,
104
103
  open,
105
- clearPending,
106
104
  close,
107
105
  toggle,
108
- toggleExpanded,
109
- clearMessages,
110
106
  }
111
- }
107
+ })
@@ -1,5 +1,5 @@
1
- import { streamText, convertToModelMessages, createUIMessageStream, createUIMessageStreamResponse } from 'ai'
2
- import type { UIMessageStreamWriter, ToolCallPart, ToolSet } from 'ai'
1
+ import { streamText, convertToModelMessages } from 'ai'
2
+ import type { ToolSet } from 'ai'
3
3
  import { createMCPClient } from '@ai-sdk/mcp'
4
4
  import type { H3Event } from 'h3'
5
5
 
@@ -23,11 +23,10 @@ function createLocalFetch(event: H3Event): typeof fetch {
23
23
  }
24
24
 
25
25
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
26
- function stopWhenResponseComplete({ steps }: { steps: any[] }): boolean {
26
+ function stopWhenResponseComplete({ steps }: { steps: { text?: string, toolCalls?: unknown[] }[] }): boolean {
27
27
  const lastStep = steps.at(-1)
28
28
  if (!lastStep) return false
29
29
 
30
- // Primary condition: stop when model gives a text response without tool calls
31
30
  const hasText = Boolean(lastStep.text && lastStep.text.trim().length > 0)
32
31
  const hasNoToolCalls = !lastStep.toolCalls || lastStep.toolCalls.length === 0
33
32
 
@@ -57,7 +56,7 @@ function getSystemPrompt(siteName: string) {
57
56
 
58
57
  **Links and exploration:**
59
58
  - Tool results include a \`url\` for each page — prefer markdown links \`[label](url)\` so users can open the doc in one click
60
- - When it helps, add extra links (related pages, read more”, side topics) — make the answer easy to dig into, not a wall of text
59
+ - When it helps, add extra links (related pages, "read more", side topics) — make the answer easy to dig into, not a wall of text
61
60
  - Stick to URLs from tool results (\`url\` / \`path\`) so links stay valid
62
61
 
63
62
  **FORMATTING RULES (CRITICAL):**
@@ -85,6 +84,9 @@ export default defineEventHandler(async (event) => {
85
84
  const isExternalUrl = mcpServer.startsWith('http://') || mcpServer.startsWith('https://')
86
85
  const baseURL = config.app?.baseURL?.replace(/\/$/, '') || ''
87
86
 
87
+ const abortController = new AbortController()
88
+ event.node.req.on('close', () => abortController.abort())
89
+
88
90
  let transport: Parameters<typeof createMCPClient>[0]['transport']
89
91
  if (isExternalUrl) {
90
92
  transport = {
@@ -109,41 +111,19 @@ export default defineEventHandler(async (event) => {
109
111
  const httpClient = await createMCPClient({ transport })
110
112
  const mcpTools = await httpClient.tools()
111
113
 
112
- const stream = createUIMessageStream({
113
- execute: async ({ writer }: { writer: UIMessageStreamWriter }) => {
114
- const modelMessages = await convertToModelMessages(messages)
115
- const result = streamText({
116
- model: config.assistant.model,
117
- maxOutputTokens: 4000,
118
- maxRetries: 2,
119
- stopWhen: stopWhenResponseComplete,
120
- system: getSystemPrompt(siteName),
121
- messages: modelMessages,
122
- tools: mcpTools as ToolSet,
123
- onStepFinish: ({ toolCalls }: { toolCalls: ToolCallPart[] }) => {
124
- if (toolCalls.length === 0) return
125
- writer.write({
126
- id: toolCalls[0]?.toolCallId,
127
- type: 'data-tool-calls',
128
- data: {
129
- tools: toolCalls.map((tc: ToolCallPart) => {
130
- const args = 'args' in tc ? tc.args : 'input' in tc ? tc.input : {}
131
- return {
132
- toolName: tc.toolName,
133
- toolCallId: tc.toolCallId,
134
- args,
135
- }
136
- }),
137
- },
138
- })
139
- },
140
- })
141
- writer.merge(result.toUIMessageStream())
142
- },
143
- onFinish: async () => {
144
- await httpClient.close()
145
- },
146
- })
147
-
148
- return createUIMessageStreamResponse({ stream })
114
+ const closeMcp = () => event.waitUntil(httpClient.close())
115
+
116
+ return streamText({
117
+ model: config.assistant.model,
118
+ maxOutputTokens: 4000,
119
+ maxRetries: 2,
120
+ abortSignal: abortController.signal,
121
+ stopWhen: stopWhenResponseComplete,
122
+ system: getSystemPrompt(siteName),
123
+ messages: await convertToModelMessages(messages),
124
+ tools: mcpTools as ToolSet,
125
+ onFinish: closeMcp,
126
+ onAbort: closeMcp,
127
+ onError: closeMcp,
128
+ }).toUIMessageStreamResponse()
149
129
  })
package/modules/css.ts CHANGED
@@ -43,6 +43,14 @@ export default defineNuxtModule({
43
43
  @source "../../app.config.ts";
44
44
  @source "${assistantDir.replace(/\\/g, '/')}/**/*";
45
45
 
46
+ html.dark .shiki span {
47
+ color: var(--shiki-dark) !important;
48
+ background-color: var(--shiki-dark-bg) !important;
49
+ font-style: var(--shiki-dark-font-style) !important;
50
+ font-weight: var(--shiki-dark-font-weight) !important;
51
+ text-decoration: var(--shiki-dark-text-decoration) !important;
52
+ }
53
+
46
54
  :root {
47
55
  --ui-container: 90rem;
48
56
  }
package/nuxt.schema.ts CHANGED
@@ -297,6 +297,34 @@ export default defineNuxtSchema({
297
297
  default: '',
298
298
  required: ['', 'light', 'dark'],
299
299
  }),
300
+ shortcuts: group({
301
+ title: 'Shortcuts',
302
+ description: 'Keyboard shortcuts configuration.',
303
+ icon: 'i-lucide-keyboard',
304
+ fields: {
305
+ toggleColorMode: field({
306
+ type: 'string',
307
+ title: 'Toggle Color Mode',
308
+ description: 'Shortcut to toggle light and dark mode (e.g., d, meta_d). Leave empty to disable.',
309
+ icon: 'i-lucide-keyboard',
310
+ default: 'd',
311
+ }),
312
+ },
313
+ }),
314
+ },
315
+ }),
316
+ search: group({
317
+ title: 'Search',
318
+ description: 'Search configuration.',
319
+ icon: 'i-lucide-search',
320
+ fields: {
321
+ fts: field({
322
+ type: 'boolean',
323
+ title: 'Full-Text Search',
324
+ description: 'Use SQLite FTS5 full-text search instead of Fuse.js. Requires @nuxt/content v3.14+.',
325
+ icon: 'i-lucide-database',
326
+ default: false,
327
+ }),
300
328
  },
301
329
  }),
302
330
  assistant: group({
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "docus",
3
3
  "description": "Nuxt layer for Docus documentation theme",
4
- "version": "5.11.0",
4
+ "version": "5.12.1",
5
5
  "type": "module",
6
6
  "main": "./nuxt.config.ts",
7
7
  "repository": {
@@ -23,41 +23,42 @@
23
23
  "README.md"
24
24
  ],
25
25
  "dependencies": {
26
- "@ai-sdk/gateway": "^3.0.104",
27
- "@ai-sdk/mcp": "^1.0.36",
28
- "@ai-sdk/vue": "3.0.168",
29
- "@iconify-json/lucide": "^1.2.102",
30
- "@iconify-json/simple-icons": "^1.2.79",
31
- "@iconify-json/vscode-icons": "^1.2.45",
32
- "@nuxt/content": "^3.13.0",
26
+ "@ai-sdk/gateway": "^3.0.120",
27
+ "@ai-sdk/mcp": "^1.0.43",
28
+ "@ai-sdk/vue": "^3.0.191",
29
+ "@comark/vue": "^0.4.0",
30
+ "@iconify-json/lucide": "^1.2.111",
31
+ "@iconify-json/simple-icons": "^1.2.85",
32
+ "@iconify-json/vscode-icons": "^1.2.53",
33
+ "@nuxt/content": "^3.14.0",
33
34
  "@nuxt/image": "^2.0.0",
34
- "@nuxt/kit": "^4.4.2",
35
- "@nuxt/ui": "^4.7.1",
36
- "@nuxtjs/i18n": "^10.2.4",
37
- "@nuxtjs/mcp-toolkit": "^0.13.4",
38
- "@nuxtjs/mdc": "^0.21.1",
39
- "@nuxtjs/robots": "^6.0.7",
40
- "@shikijs/core": "^4.0.2",
41
- "@shikijs/engine-javascript": "^4.0.2",
42
- "@shikijs/langs": "^4.0.2",
43
- "@shikijs/themes": "^4.0.2",
44
- "@takumi-rs/core": "^1.0.15",
45
- "@vueuse/core": "^14.2.1",
46
- "ai": "6.0.168",
35
+ "@nuxt/kit": "^4.4.7",
36
+ "@nuxt/ui": "^4.8.1",
37
+ "@nuxtjs/i18n": "^10.4.0",
38
+ "@nuxtjs/mcp-toolkit": "^0.17.2",
39
+ "@nuxtjs/mdc": "^0.22.0",
40
+ "@nuxtjs/robots": "^6.0.9",
41
+ "@shikijs/core": "^4.2.0",
42
+ "@shikijs/engine-javascript": "^4.2.0",
43
+ "@shikijs/langs": "^4.2.0",
44
+ "@shikijs/stream": "^4.2.0",
45
+ "@shikijs/themes": "^4.2.0",
46
+ "@takumi-rs/core": "^1.6.0",
47
+ "@vueuse/core": "^14.3.0",
48
+ "ai": "^6.0.191",
47
49
  "defu": "^6.1.7",
48
50
  "exsolve": "^1.0.8",
49
51
  "git-url-parse": "^16.1.0",
50
52
  "motion-v": "^2.2.1",
51
53
  "nuxt-llms": "^0.2.0",
52
- "nuxt-og-image": "^6.4.5",
54
+ "nuxt-og-image": "^6.5.1",
53
55
  "pathe": "^2.0.3",
54
- "pkg-types": "^2.3.0",
56
+ "pkg-types": "^2.3.1",
55
57
  "scule": "^1.3.0",
56
- "shiki-stream": "^0.1.4",
57
- "tailwindcss": "^4.2.3",
58
- "ufo": "^1.6.3",
59
- "yaml": "^2.8.3",
60
- "zod": "^4.3.6",
58
+ "tailwindcss": "^4.3.0",
59
+ "ufo": "^1.6.4",
60
+ "yaml": "^2.9.0",
61
+ "zod": "^4.4.3",
61
62
  "zod-to-json-schema": "^3.25.2"
62
63
  },
63
64
  "peerDependencies": {
@@ -1,9 +1,9 @@
1
- import { z } from 'zod'
2
- import { joinURL } from 'ufo'
3
- import { queryCollection } from '@nuxt/content/server'
4
1
  import type { Collections } from '@nuxt/content'
5
- import { getAvailableLocales, getCollectionFromPath } from '../../utils/content'
2
+ import { queryCollection } from '@nuxt/content/server'
3
+ import { joinURL } from 'ufo'
4
+ import { z } from 'zod'
6
5
  import { inferSiteURL } from '../../../utils/meta'
6
+ import { getAvailableLocales, getCollectionFromPath, isNavigationPath } from '../../utils/content'
7
7
 
8
8
  export default defineMcpTool({
9
9
  description: `Retrieves the full content and details of a specific documentation page.
@@ -25,7 +25,9 @@ WORKFLOW: This tool returns the complete page content including title, descripti
25
25
  openWorldHint: false,
26
26
  },
27
27
  inputSchema: {
28
- path: z.string().describe('The page path from list-pages or provided by the user (e.g., /en/getting-started/installation)'),
28
+ path: z.string().describe(
29
+ 'The page path from list-pages or provided by the user (e.g., /en/getting-started/installation)',
30
+ ),
29
31
  },
30
32
  inputExamples: [
31
33
  { path: '/en/getting-started/installation' },
@@ -33,6 +35,10 @@ WORKFLOW: This tool returns the complete page content including title, descripti
33
35
  ],
34
36
  cache: '1h',
35
37
  handler: async ({ path }) => {
38
+ if (isNavigationPath(path)) {
39
+ throw createError({ statusCode: 404, message: 'Page not found' })
40
+ }
41
+
36
42
  const event = useEvent()
37
43
  const config = useRuntimeConfig(event)
38
44
  const publicConfig = config.public
@@ -1,9 +1,9 @@
1
- import { z } from 'zod'
2
- import { joinURL } from 'ufo'
3
- import { queryCollection } from '@nuxt/content/server'
4
1
  import type { Collections } from '@nuxt/content'
5
- import { getCollectionsToQuery, getAvailableLocales } from '../../utils/content'
2
+ import { queryCollection } from '@nuxt/content/server'
3
+ import { joinURL } from 'ufo'
4
+ import { z } from 'zod'
6
5
  import { inferSiteURL } from '../../../utils/meta'
6
+ import { getAvailableLocales, getCollectionsToQuery } from '../../utils/content'
7
7
 
8
8
  export default defineMcpTool({
9
9
  description: `Lists all available documentation pages with their categories and basic information.
@@ -52,6 +52,7 @@ OUTPUT: Returns a structured list with:
52
52
  const allPages = await Promise.all(
53
53
  collections.map(async (collectionName) => {
54
54
  const pages = await queryCollection(event, collectionName as keyof Collections)
55
+ .where('path', 'NOT LIKE', '%.navigation')
55
56
  .select('title', 'path', 'description')
56
57
  .all()
57
58
 
@@ -1,6 +1,6 @@
1
1
  import { queryCollection } from '@nuxt/content/server'
2
- import { getAvailableLocales, getCollectionsToQuery } from '../utils/content'
3
2
  import { inferSiteURL } from '../../utils/meta'
3
+ import { getAvailableLocales, getCollectionsToQuery, isNavigationPath } from '../utils/content'
4
4
 
5
5
  interface SitemapUrl {
6
6
  loc: string
@@ -27,17 +27,20 @@ export default defineEventHandler(async (event) => {
27
27
 
28
28
  for (const collection of collections) {
29
29
  try {
30
- const pages = await (queryCollection as unknown as (event: unknown, collection: string) => { all: () => Promise<Array<Record<string, unknown> & { path?: string }>> })(event, collection).all()
30
+ const pages = await (queryCollection as unknown as (
31
+ event: unknown,
32
+ collection: string,
33
+ ) => { all: () => Promise<Array<Record<string, unknown> & { path?: string }>> })(event, collection).all()
31
34
 
32
35
  for (const page of pages) {
33
- const meta = page as Record<string, unknown>
36
+ const meta = page.meta as Record<string, unknown>
34
37
  const pagePath = page.path || '/'
35
38
 
36
39
  // Skip pages with sitemap: false in frontmatter
37
40
  if (meta.sitemap === false) continue
38
41
 
39
42
  // Skip .navigation files (used for navigation configuration)
40
- if (pagePath.endsWith('.navigation') || pagePath.includes('/.navigation')) continue
43
+ if (isNavigationPath(pagePath)) continue
41
44
 
42
45
  const urlEntry: SitemapUrl = {
43
46
  loc: pagePath,
@@ -25,6 +25,10 @@ export function getCollectionsToQuery(locale: string | undefined, availableLocal
25
25
  : ['docs']
26
26
  }
27
27
 
28
+ export function isNavigationPath(path: string): boolean {
29
+ return path.endsWith('.navigation') || path.includes('/.navigation/')
30
+ }
31
+
28
32
  export function getCollectionFromPath(path: string, availableLocales: string[]): string {
29
33
  const pathSegments = path.split('/').filter(Boolean)
30
34
  const firstSegment = pathSegments[0]
@@ -1,164 +0,0 @@
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>