@docsector/docsector-reader 4.3.3 → 4.4.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.
@@ -0,0 +1,714 @@
1
+ <script setup>
2
+ import { computed, nextTick, ref, watch } from 'vue'
3
+ import { useI18n } from 'vue-i18n'
4
+ import { useRoute } from 'vue-router'
5
+ import { useQuasar } from 'quasar'
6
+
7
+ import useAssistant from '../composables/useAssistant'
8
+ import { isAssistantThinkingState, listVisibleAssistantMessages } from '../ai-assistant/panel'
9
+ import DPageTokens from './DPageTokens.vue'
10
+ import { tokenizePageSectionSource } from './page-section-tokens'
11
+
12
+ const emit = defineEmits(['close', 'resize'])
13
+
14
+ const props = defineProps({
15
+ contextTitle: {
16
+ type: String,
17
+ default: ''
18
+ },
19
+ markdownUrl: {
20
+ type: String,
21
+ default: ''
22
+ },
23
+ width: {
24
+ type: Number,
25
+ default: 0
26
+ },
27
+ resizable: {
28
+ type: Boolean,
29
+ default: false
30
+ }
31
+ })
32
+
33
+ const siteFavicon = '/favicon.ico'
34
+
35
+ const resolveAssetUrl = (src = '') => {
36
+ const raw = String(src || '').trim()
37
+ if (!raw) return ''
38
+ if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith('data:')) return raw
39
+
40
+ try {
41
+ return new URL(raw, typeof window !== 'undefined' ? window.location.origin : 'https://localhost').href
42
+ } catch {
43
+ return raw
44
+ }
45
+ }
46
+
47
+ const route = useRoute()
48
+ const $q = useQuasar()
49
+ const { t, locale } = useI18n()
50
+ const input = ref('')
51
+ const scrollArea = ref(null)
52
+
53
+ const assistant = useAssistant({
54
+ route,
55
+ locale,
56
+ getContext: () => ({
57
+ title: props.contextTitle,
58
+ markdownUrl: props.markdownUrl,
59
+ selectedText: typeof window !== 'undefined' ? String(window.getSelection?.() || '') : ''
60
+ })
61
+ })
62
+
63
+ const prompts = computed(() => assistant.config.ui.suggestedPrompts)
64
+ const title = computed(() => assistant.config.ui.title || t('assistant.title'))
65
+ const subtitle = computed(() => assistant.config.ui.subtitle || t('assistant.subtitle'))
66
+ const greeting = computed(() => {
67
+ const hour = new Date().getHours()
68
+ if (hour < 12) return t('assistant.greeting.morning')
69
+ if (hour < 18) return t('assistant.greeting.afternoon')
70
+ return t('assistant.greeting.evening')
71
+ })
72
+
73
+ const panelTone = computed(() => $q.dark.isActive ? 'dark' : 'light')
74
+
75
+ const sourceHref = (source) => {
76
+ const key = String(source?.key || '').trim()
77
+ if (!key) return '#'
78
+ if (/^https?:\/\//i.test(key) || key.startsWith('/')) return key
79
+ return `/${key}`
80
+ }
81
+
82
+ const faviconFor = () => resolveAssetUrl(siteFavicon)
83
+
84
+ const sources = computed(() => assistant.sources.value)
85
+ const hasSources = computed(() => sources.value.length > 0 && assistant.config.ui.showCitations)
86
+ const sourceAvatars = computed(() => sources.value.slice(0, 4))
87
+ const sourcesLabel = computed(() => t('assistant.sourcesCount', { count: sources.value.length }))
88
+ const visibleMessages = computed(() => listVisibleAssistantMessages(assistant.messages.value))
89
+
90
+ const isThinking = computed(() => isAssistantThinkingState({
91
+ loading: assistant.loading.value,
92
+ messages: assistant.messages.value
93
+ }))
94
+
95
+ const renderMessageTokens = (message) => {
96
+ if (message?.role !== 'assistant') {
97
+ return []
98
+ }
99
+
100
+ return tokenizePageSectionSource(message?.content || '', { allowHeadingTokens: false })
101
+ }
102
+
103
+ const startResize = (event) => {
104
+ if (!props.resizable) return
105
+ event.preventDefault()
106
+ const startX = event.clientX
107
+ const startWidth = props.width || 380
108
+
109
+ const onMove = (moveEvent) => {
110
+ const delta = startX - moveEvent.clientX
111
+ emit('resize', startWidth + delta)
112
+ }
113
+
114
+ const onUp = () => {
115
+ window.removeEventListener('pointermove', onMove)
116
+ window.removeEventListener('pointerup', onUp)
117
+ document.body.style.userSelect = ''
118
+ document.body.style.cursor = ''
119
+ }
120
+
121
+ document.body.style.userSelect = 'none'
122
+ document.body.style.cursor = 'col-resize'
123
+ window.addEventListener('pointermove', onMove)
124
+ window.addEventListener('pointerup', onUp)
125
+ }
126
+
127
+ const scrollToBottom = () => {
128
+ nextTick(() => {
129
+ const target = scrollArea.value?.$el?.querySelector('.q-scrollarea__container')
130
+ if (target) {
131
+ target.scrollTop = target.scrollHeight
132
+ }
133
+ })
134
+ }
135
+
136
+ const submit = async (value = input.value) => {
137
+ const prompt = String(value || '').trim()
138
+ if (!prompt) return
139
+ input.value = ''
140
+ await assistant.send(prompt)
141
+ }
142
+
143
+ const handleKeydown = (event) => {
144
+ if (event.key !== 'Enter' || event.shiftKey) return
145
+ event.preventDefault()
146
+ submit()
147
+ }
148
+
149
+ watch(assistant.messages, scrollToBottom, { deep: true })
150
+ watch(assistant.sources, scrollToBottom, { deep: true })
151
+ </script>
152
+
153
+ <template>
154
+ <aside class="d-assistant-panel" :class="`d-assistant-panel--${panelTone}`">
155
+ <div
156
+ v-if="resizable"
157
+ class="d-assistant-panel__resizer"
158
+ role="separator"
159
+ aria-orientation="vertical"
160
+ :aria-label="t('assistant.resize')"
161
+ @pointerdown="startResize"
162
+ />
163
+ <header class="d-assistant-panel__header">
164
+ <div class="d-assistant-panel__brand">
165
+ <q-icon name="auto_awesome" size="22px" />
166
+ <strong>{{ title }}</strong>
167
+ </div>
168
+ <div class="d-assistant-panel__header-actions">
169
+ <q-btn v-if="assistant.hasMessages.value"
170
+ dense round
171
+ color="white"
172
+ class="d-assistant-panel__header-action d-assistant-panel__header-action--clear"
173
+ icon="delete_outline"
174
+ text-color="negative"
175
+ :aria-label="t('assistant.clear')"
176
+ @click="assistant.clear"
177
+ >
178
+ <q-tooltip>{{ t('assistant.clear') }}</q-tooltip>
179
+ </q-btn>
180
+ <q-btn
181
+ dense round
182
+ color="white"
183
+ text-color="black"
184
+ class="d-assistant-panel__header-action d-assistant-panel__header-action--close"
185
+ icon="close"
186
+ :aria-label="t('assistant.close')"
187
+ @click="emit('close')"
188
+ >
189
+ <q-tooltip>{{ t('assistant.close') }}</q-tooltip>
190
+ </q-btn>
191
+ </div>
192
+ </header>
193
+
194
+ <q-scroll-area ref="scrollArea" class="d-assistant-panel__body">
195
+ <div v-if="!assistant.hasMessages.value" class="d-assistant-panel__empty">
196
+ <div class="d-assistant-panel__mark">
197
+ <q-icon name="auto_awesome" size="52px" />
198
+ </div>
199
+ <h2>{{ greeting }}</h2>
200
+ <p>{{ subtitle }}</p>
201
+ </div>
202
+
203
+ <div v-else class="d-assistant-panel__messages">
204
+ <div
205
+ v-for="(message, index) in visibleMessages"
206
+ :key="message.id"
207
+ class="d-assistant-message"
208
+ :class="`d-assistant-message--${message.role}`"
209
+ >
210
+ <div
211
+ v-if="message.role === 'assistant'"
212
+ class="content no-padding d-assistant-message__content d-assistant-message__content--markdown"
213
+ >
214
+ <d-page-tokens :id="(index + 1) * 1000" :tokens="renderMessageTokens(message)" />
215
+ </div>
216
+ <div v-else class="d-assistant-message__content">{{ message.content }}</div>
217
+ </div>
218
+
219
+ <div v-if="isThinking" class="d-assistant-message d-assistant-message--assistant">
220
+ <div class="d-assistant-message__content d-assistant-message__thinking">
221
+ <q-spinner-dots size="22px" />
222
+ <span>{{ t('assistant.thinking') }}</span>
223
+ </div>
224
+ </div>
225
+ </div>
226
+
227
+ <div v-if="assistant.error.value" class="d-assistant-panel__error">
228
+ <q-icon name="error_outline" />
229
+ <span>{{ assistant.error.value }}</span>
230
+ </div>
231
+
232
+ <div v-if="hasSources" class="d-assistant-panel__sources">
233
+ <q-chip
234
+ clickable
235
+ class="d-assistant-sources-chip"
236
+ :ripple="false"
237
+ >
238
+ <span class="d-assistant-sources-chip__avatars">
239
+ <q-avatar
240
+ v-for="(source, index) in sourceAvatars"
241
+ :key="source.id"
242
+ class="d-assistant-sources-chip__avatar"
243
+ :style="{ zIndex: sourceAvatars.length - index }"
244
+ size="24px"
245
+ >
246
+ <img :src="faviconFor(source)" :alt="source.title" loading="lazy">
247
+ </q-avatar>
248
+ </span>
249
+ <span class="d-assistant-sources-chip__label">{{ sourcesLabel }}</span>
250
+ <q-icon name="expand_more" size="16px" />
251
+
252
+ <q-menu
253
+ anchor="top left"
254
+ self="bottom left"
255
+ :offset="[0, 8]"
256
+ class="d-assistant-sources-menu"
257
+ >
258
+ <q-list separator class="d-assistant-sources-menu__list">
259
+ <q-item-label header class="d-assistant-sources-menu__header">
260
+ {{ t('assistant.sources') }}
261
+ </q-item-label>
262
+ <q-item
263
+ v-for="source in assistant.sources.value"
264
+ :key="source.id"
265
+ v-close-popup
266
+ clickable
267
+ tag="a"
268
+ :href="sourceHref(source)"
269
+ target="_blank"
270
+ rel="noopener noreferrer"
271
+ >
272
+ <q-item-section avatar>
273
+ <q-avatar size="28px" class="d-assistant-sources-menu__avatar">
274
+ <img :src="faviconFor(source)" :alt="source.title" loading="lazy">
275
+ </q-avatar>
276
+ </q-item-section>
277
+ <q-item-section>
278
+ <q-item-label lines="1">{{ source.title }}</q-item-label>
279
+ <q-item-label v-if="source.meta" caption lines="1">{{ source.meta }}</q-item-label>
280
+ </q-item-section>
281
+ <q-item-section side>
282
+ <q-icon name="open_in_new" size="16px" />
283
+ </q-item-section>
284
+ </q-item>
285
+ </q-list>
286
+ </q-menu>
287
+ </q-chip>
288
+ </div>
289
+ </q-scroll-area>
290
+
291
+ <footer class="d-assistant-panel__composer">
292
+ <div v-if="!assistant.hasMessages.value" class="d-assistant-panel__prompts">
293
+ <q-btn
294
+ v-for="prompt in prompts"
295
+ :key="prompt"
296
+ dense no-caps unelevated
297
+ class="d-assistant-panel__prompt"
298
+ @click="submit(prompt)"
299
+ >
300
+ {{ prompt }}
301
+ </q-btn>
302
+ </div>
303
+
304
+ <div class="d-assistant-panel__composer-box">
305
+ <q-input
306
+ v-model="input"
307
+ class="d-assistant-panel__input"
308
+ borderless
309
+ autogrow
310
+ dense
311
+ :placeholder="t('assistant.placeholder')"
312
+ :disable="assistant.loading.value"
313
+ @keydown="handleKeydown"
314
+ />
315
+ <div class="d-assistant-panel__composer-row">
316
+ <span class="d-assistant-panel__context">
317
+ <q-icon name="smart_toy" size="16px" />
318
+ {{ t('assistant.context') }}
319
+ </span>
320
+ <q-btn
321
+ no-caps flat dense
322
+ class="d-assistant-panel__send"
323
+ :icon="assistant.loading.value ? 'stop' : 'send'"
324
+ :label="assistant.loading.value ? t('assistant.stop') : t('assistant.send')"
325
+ @click="assistant.loading.value ? assistant.stop() : submit()"
326
+ />
327
+ </div>
328
+ </div>
329
+ </footer>
330
+ </aside>
331
+ </template>
332
+
333
+ <style lang="sass">
334
+ .d-assistant-panel
335
+ height: 100%
336
+ display: flex
337
+ flex-direction: column
338
+ background: #f8fafc
339
+ color: #111827
340
+ overflow: hidden
341
+ position: relative
342
+
343
+ &.d-mobile-assistant-panel
344
+ width: 100vw
345
+ max-width: 100vw
346
+ height: 100dvh
347
+ max-height: 100dvh
348
+
349
+ .d-assistant-panel__header
350
+ padding-top: env(safe-area-inset-top, 0px)
351
+ min-height: calc(54px + env(safe-area-inset-top, 0px))
352
+
353
+ .d-assistant-panel__composer
354
+ padding-bottom: calc(16px + env(safe-area-inset-bottom, 0px))
355
+
356
+ &__resizer
357
+ position: absolute
358
+ top: 0
359
+ left: 0
360
+ width: 7px
361
+ height: 100%
362
+ cursor: col-resize
363
+ z-index: 5
364
+ touch-action: none
365
+
366
+ &::after
367
+ content: ''
368
+ position: absolute
369
+ top: 0
370
+ left: 2px
371
+ width: 2px
372
+ height: 100%
373
+ background: transparent
374
+ transition: background 0.15s ease
375
+
376
+ &:hover::after
377
+ background: var(--q-primary)
378
+
379
+ &--dark
380
+ background: #1b1b1c
381
+ color: rgba(255, 255, 255, 0.86)
382
+
383
+ .d-assistant-panel__composer
384
+ background: linear-gradient(180deg, rgba(27,27,28,0), rgba(41, 24, 20, 0.72) 34%, #1b1b1c 100%)
385
+
386
+ .d-assistant-message--assistant .d-assistant-message__content,
387
+ .d-assistant-panel__prompt,
388
+ .d-assistant-sources-chip
389
+ background: rgba(255, 255, 255, 0.045)
390
+ color: rgba(255, 255, 255, 0.86)
391
+ border-color: rgba(255, 255, 255, 0.12)
392
+
393
+ .d-assistant-sources-chip__avatar
394
+ border-color: #1b1b1c
395
+
396
+ .d-assistant-panel__mark
397
+ color: #ffad98
398
+ background: rgba(76, 35, 28, 0.35)
399
+
400
+ .d-assistant-panel__composer-box
401
+ background: rgba(24, 24, 24, 0.86)
402
+ border-color: rgba(255, 142, 111, 0.74)
403
+ box-shadow: 0 0 0 1px rgba(255, 142, 111, 0.3), 0 14px 36px rgba(0, 0, 0, 0.32)
404
+
405
+ .d-assistant-panel__input
406
+ .q-field__native,
407
+ .q-field__input
408
+ color: rgba(255, 255, 255, 0.88)
409
+
410
+ .q-field__native::placeholder,
411
+ .q-field__input::placeholder
412
+ color: rgba(255, 255, 255, 0.48)
413
+
414
+ &__header
415
+ height: 54px
416
+ flex: 0 0 54px
417
+ display: flex
418
+ align-items: center
419
+ justify-content: space-between
420
+ padding: 0 14px
421
+ border-bottom: 1px solid rgba(125, 125, 125, 0.18)
422
+
423
+ &__brand
424
+ display: flex
425
+ align-items: center
426
+ gap: 8px
427
+ min-width: 0
428
+
429
+ strong
430
+ font-size: 0.94rem
431
+ white-space: nowrap
432
+ overflow: hidden
433
+ text-overflow: ellipsis
434
+
435
+ &__header-actions
436
+ flex: 0 0 auto
437
+ display: flex
438
+ align-items: center
439
+ gap: 2px
440
+
441
+ &__header-action
442
+ background: rgba(15, 23, 42, 0.08)
443
+ color: currentColor
444
+
445
+ &:hover
446
+ background: rgba(15, 23, 42, 0.14)
447
+
448
+ &--clear
449
+ margin-right: 8px
450
+
451
+ &__body
452
+ flex: 1 1 auto
453
+ min-height: 0
454
+ overflow-x: hidden
455
+
456
+ .q-scrollarea__container,
457
+ .q-scrollarea__content
458
+ overflow-x: hidden
459
+ max-width: 100%
460
+
461
+ &__empty
462
+ min-height: 100%
463
+ display: flex
464
+ flex-direction: column
465
+ align-items: center
466
+ justify-content: center
467
+ text-align: center
468
+ padding: 32px 28px 170px
469
+
470
+ h2
471
+ font-size: 1.18rem
472
+ line-height: 1.35rem
473
+ font-weight: 700
474
+ margin: 18px 0 4px
475
+
476
+ p
477
+ margin: 0
478
+ opacity: 0.62
479
+ font-weight: 600
480
+
481
+ &__mark
482
+ width: 120px
483
+ height: 120px
484
+ border-radius: 50%
485
+ display: flex
486
+ align-items: center
487
+ justify-content: center
488
+ color: #0284c7
489
+ background: rgba(14, 165, 233, 0.12)
490
+
491
+ &__messages
492
+ padding: 16px 14px 14px
493
+ overflow-x: hidden
494
+
495
+ &__error
496
+ margin: 0 14px 96px
497
+ padding: 10px 12px
498
+ display: flex
499
+ align-items: flex-start
500
+ gap: 8px
501
+ border: 1px solid rgba(194, 65, 12, 0.25)
502
+ color: #b45309
503
+ background: rgba(251, 191, 36, 0.12)
504
+ border-radius: 8px
505
+
506
+ &__sources
507
+ margin: 0 14px 0
508
+ display: flex
509
+ flex-direction: column
510
+ align-items: flex-start
511
+ gap: 6px
512
+ overflow-x: hidden
513
+
514
+ &__sources-title
515
+ font-size: 0.76rem
516
+ opacity: 0.7
517
+ font-weight: 600
518
+
519
+ &__composer
520
+ flex: 0 0 auto
521
+ padding: 18px 14px 16px
522
+ background: linear-gradient(180deg, rgba(248,250,252,0), rgba(248,250,252,0.94) 28%, #f8fafc 100%)
523
+
524
+ &__prompts
525
+ display: flex
526
+ flex-direction: column
527
+ gap: 9px
528
+ margin-bottom: 10px
529
+
530
+ &__prompt
531
+ justify-content: flex-start
532
+ min-height: 38px
533
+ padding: 0 13px
534
+ border: 1px solid rgba(15, 23, 42, 0.1)
535
+ background: rgba(255, 255, 255, 0.68)
536
+ color: inherit
537
+ border-radius: 10px
538
+ font-weight: 600
539
+
540
+ &__composer-box
541
+ border: 1px solid rgba(15, 23, 42, 0.16)
542
+ border-radius: 18px
543
+ padding: 10px 12px 8px
544
+ background: rgba(255, 255, 255, 0.78)
545
+ box-shadow: 0 12px 28px rgba(15, 23, 42, 0.12)
546
+
547
+ &__input
548
+ .q-field__control
549
+ min-height: 36px
550
+ background: transparent
551
+ padding: 0
552
+
553
+ .q-field__native,
554
+ .q-field__input
555
+ font-weight: 600
556
+
557
+ &__composer-row
558
+ display: flex
559
+ align-items: center
560
+ justify-content: space-between
561
+ gap: 8px
562
+ margin-top: 2px
563
+
564
+ &__context
565
+ display: inline-flex
566
+ align-items: center
567
+ gap: 5px
568
+ min-width: 0
569
+ font-size: 0.76rem
570
+ font-weight: 600
571
+ opacity: 0.7
572
+
573
+ &__send
574
+ flex: 0 0 auto
575
+ color: var(--q-primary)
576
+ font-weight: 700
577
+
578
+ .d-assistant-message
579
+ display: flex
580
+ margin-bottom: 10px
581
+ min-width: 0
582
+ overflow-x: hidden
583
+
584
+ &--user
585
+ justify-content: flex-end
586
+
587
+ .d-assistant-message__content
588
+ color: white
589
+ background: var(--q-primary)
590
+
591
+ &--assistant
592
+ justify-content: flex-start
593
+
594
+ .d-assistant-message__content
595
+ background: rgba(255, 255, 255, 0.78)
596
+ border: 1px solid rgba(15, 23, 42, 0.1)
597
+ padding: 12px 14px !important
598
+
599
+ &__content
600
+ max-width: 88%
601
+ min-width: 0
602
+ padding: 10px 12px
603
+ border-radius: 8px
604
+ white-space: pre-wrap
605
+ overflow-wrap: anywhere
606
+ line-height: 1.45
607
+
608
+ &--markdown
609
+ min-height: 0 !important
610
+ white-space: normal
611
+
612
+ // Visuals come from Docsector's own page token components so the chat
613
+ // stays identical to pages/subpages; only enforce bubble-safe spacing
614
+ // and overflow containment here.
615
+ p
616
+ line-height: 1.6em
617
+
618
+ p,
619
+ ul,
620
+ ol,
621
+ blockquote,
622
+ .d-table-wrapper
623
+ margin-top: 0
624
+ margin-bottom: 0.7em
625
+
626
+ > :first-child
627
+ margin-top: 0
628
+
629
+ > :last-child
630
+ margin-bottom: 0
631
+
632
+ pre,
633
+ table,
634
+ .d-table-wrapper
635
+ max-width: 100%
636
+ overflow-x: auto
637
+
638
+ img
639
+ max-width: 100%
640
+ height: auto
641
+
642
+ &__thinking
643
+ display: flex
644
+ align-items: center
645
+ gap: 9px
646
+ color: inherit
647
+ opacity: 0.78
648
+ font-weight: 600
649
+
650
+ .d-assistant-sources-chip
651
+ max-width: 100%
652
+ height: auto
653
+ min-height: 34px
654
+ padding: 4px 10px 4px 6px
655
+ margin: 0
656
+ border-radius: 999px
657
+ background: rgba(255, 255, 255, 0.78)
658
+ border: 1px solid rgba(15, 23, 42, 0.12)
659
+ font-weight: 600
660
+
661
+ &__avatars
662
+ display: inline-flex
663
+ align-items: center
664
+ padding-left: 6px
665
+
666
+ &__avatar
667
+ display: inline-flex
668
+ align-items: center
669
+ justify-content: center
670
+ flex: 0 0 24px
671
+ position: relative
672
+ background: var(--q-primary)
673
+ border: 2px solid #f8fafc
674
+ box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18)
675
+
676
+ & + &
677
+ margin-left: -11px
678
+
679
+ img
680
+ width: 100%
681
+ height: 100%
682
+ box-sizing: border-box
683
+ object-fit: contain
684
+ padding: 4px
685
+ background: transparent
686
+
687
+ &__label
688
+ margin: 0 2px 0 8px
689
+ font-size: 0.8rem
690
+ white-space: nowrap
691
+
692
+ .d-assistant-sources-menu
693
+ max-width: min(360px, 90vw)
694
+
695
+ &__header
696
+ padding: 8px 16px 6px
697
+ font-weight: 700
698
+ opacity: 0.7
699
+
700
+ &__avatar
701
+ background: var(--q-primary)
702
+
703
+ &__list
704
+ padding-bottom: 4px
705
+
706
+ .q-item__section--avatar
707
+ padding-left: 6px
708
+
709
+ img
710
+ box-sizing: border-box
711
+ object-fit: contain
712
+ padding: 5px
713
+ background: transparent
714
+ </style>