@docsector/docsector-reader 4.4.2 → 4.4.4

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/README.md CHANGED
@@ -57,6 +57,7 @@ Transform Markdown content into beautiful, navigable documentation sites — wit
57
57
  - 🔗 **Anchor Navigation** — Right-side source-ordered Table of Contents tree with stable scroll tracking, resize-safe drawer state, auto-scroll to the active section, and active-heading resolution based on the last heading that crossed the content threshold
58
58
  - 🖱️ **Active Menu Item UX** — Active menu entries keep pointer cursor, clear URL hash without redundant navigation, and prevent accidental label text selection
59
59
  - 🔎 **Search** — Menu search across all documentation content and tags
60
+ - 💬 **Assistant Chat UX Enhancements** — Long conversations keep focus on recent messages, load earlier history progressively, deduplicate repeated sources, include per-message copy actions, and show a floating quick return to the bottom
60
61
  - 📱 **Responsive** — Mobile-friendly with collapsible sidebar and drawers
61
62
  - 📚 **Book Tabs with Per-State Colors** — Define `*.book.js` tabs with icons, order, and `color.active` / `color.inactive`
62
63
  - 🔀 **Internal Shortcut Pages** — Route entries can redirect with `config.link.to`, keeping localized titles while inheriting icon/status from the destination page
@@ -297,7 +298,7 @@ Check `checks.discovery.webMcp.status` equals `"pass"`.
297
298
 
298
299
  Docsector Reader can add an opt-in assistant panel for documentation Q&A. Users open it from the global header while reading pages and subpages; it is not a dedicated documentation route. The drawer posts to a same-origin Cloudflare Pages Function, and that function calls Cloudflare AI Search so secrets, rate-limit strategy, provider errors, and future auth stay server-side.
299
300
 
300
- The panel is disabled by default. When enabled, desktop pages get a dedicated right-side assistant rail that can sit beside the table of contents on wide screens. Mobile uses a fullscreen dialog.
301
+ The panel is disabled by default. When enabled, desktop pages get a dedicated right-side assistant rail that can sit beside the table of contents on wide screens. Mobile uses a fullscreen dialog. Conversations restore at the latest message, reveal earlier history progressively in long chats, deduplicate repeated source links, and provide per-message copy actions.
301
302
 
302
303
  ### Configure
303
304
 
package/bin/docsector.js CHANGED
@@ -24,7 +24,7 @@ const packageRoot = resolve(__dirname, '..')
24
24
  const args = process.argv.slice(2)
25
25
  const command = args[0]
26
26
 
27
- const VERSION = '4.4.2'
27
+ const VERSION = '4.4.4'
28
28
  const AUTHORING_SKILL_NAME = 'docsector-documentation-authoring'
29
29
  const AUTHORING_SKILL_DESCRIPTION = 'Author Docsector documentation with Markdown, custom blocks, MCP, and WebMCP.'
30
30
  const AUTHORING_SKILL_PUBLIC_PATH = `/.well-known/agent-skills/${AUTHORING_SKILL_NAME}/SKILL.md`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docsector/docsector-reader",
3
- "version": "4.4.2",
3
+ "version": "4.4.4",
4
4
  "description": "A documentation rendering engine built with Vue 3, Quasar v2 and Vite. Transform Markdown into beautiful, navigable documentation sites.",
5
5
  "productName": "Docsector Reader",
6
6
  "author": "Rodrigo de Araujo Vieira",
@@ -12,6 +12,21 @@ export function listVisibleAssistantMessages(messages = []) {
12
12
  })
13
13
  }
14
14
 
15
+ export const ASSISTANT_MESSAGE_WINDOW_SIZE = 60
16
+ export const ASSISTANT_MESSAGE_WINDOW_STEP = 40
17
+
18
+ export function getAssistantMessageWindow(messages = [], limit = ASSISTANT_MESSAGE_WINDOW_SIZE) {
19
+ const visibleMessages = listVisibleAssistantMessages(messages)
20
+ const safeLimit = Math.max(1, Number(limit) || ASSISTANT_MESSAGE_WINDOW_SIZE)
21
+ const hiddenCount = Math.max(0, visibleMessages.length - safeLimit)
22
+
23
+ return {
24
+ messages: hiddenCount > 0 ? visibleMessages.slice(hiddenCount) : visibleMessages,
25
+ hiddenCount,
26
+ total: visibleMessages.length
27
+ }
28
+ }
29
+
15
30
  export function isAssistantThinkingState({ loading = false, messages = [] } = {}) {
16
31
  if (!loading) {
17
32
  return false
@@ -16,10 +16,12 @@ const JSON_HEADERS = {
16
16
  const SSE_HEADERS = {
17
17
  'Content-Type': 'text/event-stream; charset=utf-8',
18
18
  'Cache-Control': 'no-cache, no-store',
19
+ 'X-Accel-Buffering': 'no',
19
20
  Connection: 'keep-alive'
20
21
  }
21
22
 
22
23
  const CURRENT_PAGE_MARKDOWN_MAX_LENGTH = 7000
24
+ const STREAM_PACE_DELAY_MS = 24
23
25
 
24
26
  function jsonResponse (body, status = 200) {
25
27
  return new Response(JSON.stringify(body), {
@@ -32,6 +34,119 @@ function errorResponse (code, message, status = 500) {
32
34
  return jsonResponse({ error: { code, message } }, status)
33
35
  }
34
36
 
37
+ function delay (milliseconds) {
38
+ return new Promise(resolve => setTimeout(resolve, milliseconds))
39
+ }
40
+
41
+ function collectSseData (block = '') {
42
+ const data = []
43
+
44
+ for (const line of String(block || '').split(/\r?\n/)) {
45
+ if (line.startsWith('data:')) {
46
+ data.push(line.slice(5).trimStart())
47
+ }
48
+ }
49
+
50
+ return data.join('\n')
51
+ }
52
+
53
+ function sseBlockHasAssistantContent (block = '') {
54
+ const data = collectSseData(block)
55
+ if (!data || data === '[DONE]') {
56
+ return false
57
+ }
58
+
59
+ try {
60
+ const payload = JSON.parse(data)
61
+ const content = payload?.choices?.[0]?.delta?.content
62
+ || payload?.choices?.[0]?.message?.content
63
+ || payload?.response
64
+ || ''
65
+
66
+ return typeof content === 'string' && content.length > 0
67
+ } catch {
68
+ return data.length > 0
69
+ }
70
+ }
71
+
72
+ function resultBodyStream (result) {
73
+ if (result instanceof Response) {
74
+ return result.body
75
+ }
76
+
77
+ if (result instanceof ReadableStream) {
78
+ return result
79
+ }
80
+
81
+ if (result?.body instanceof ReadableStream) {
82
+ return result.body
83
+ }
84
+
85
+ return null
86
+ }
87
+
88
+ function encodeSseData (payload) {
89
+ return `data: ${JSON.stringify(payload)}\n\n`
90
+ }
91
+
92
+ function encodeSseDone () {
93
+ return 'data: [DONE]\n\n'
94
+ }
95
+
96
+ async function writePacedSseStream (source, controller) {
97
+ const reader = source.getReader()
98
+ const decoder = new TextDecoder()
99
+ const encoder = new TextEncoder()
100
+ let buffer = ''
101
+ let reading = true
102
+
103
+ while (reading) {
104
+ const { done, value } = await reader.read()
105
+ if (done) {
106
+ reading = false
107
+ break
108
+ }
109
+
110
+ buffer += decoder.decode(value, { stream: true })
111
+ const parts = buffer.split(/\r?\n\r?\n/)
112
+ buffer = parts.pop() || ''
113
+
114
+ for (const part of parts) {
115
+ if (!part.trim()) continue
116
+
117
+ controller.enqueue(encoder.encode(`${part}\n\n`))
118
+ if (sseBlockHasAssistantContent(part)) {
119
+ await delay(STREAM_PACE_DELAY_MS)
120
+ }
121
+ }
122
+ }
123
+
124
+ buffer += decoder.decode()
125
+ if (buffer.trim()) {
126
+ controller.enqueue(encoder.encode(`${buffer}\n\n`))
127
+ }
128
+ }
129
+
130
+ async function writeAssistantStreamResult (result, controller) {
131
+ const encoder = new TextEncoder()
132
+ const stream = resultBodyStream(result)
133
+
134
+ if (stream) {
135
+ await writePacedSseStream(stream, controller)
136
+ return
137
+ }
138
+
139
+ controller.enqueue(encoder.encode(encodeSseData(result)))
140
+ controller.enqueue(encoder.encode(encodeSseDone()))
141
+ }
142
+
143
+ function streamErrorPayload (error) {
144
+ const message = error?.message || 'Assistant request failed.'
145
+ return {
146
+ response: message
147
+ }
148
+ }
149
+
35
150
  function normalizeMessages (messages) {
36
151
  const validRoles = new Set(['system', 'developer', 'user', 'assistant', 'tool'])
37
152
 
@@ -313,6 +428,27 @@ async function createAssistantResponse (payload, result) {
313
428
  return jsonResponse(result)
314
429
  }
315
430
 
431
+ function createAssistantStreamResponse (env, body) {
432
+ const encoder = new TextEncoder()
433
+
434
+ const stream = new ReadableStream({
435
+ async start (controller) {
436
+ try {
437
+ controller.enqueue(encoder.encode(': connected\n\n'))
438
+ const { result } = await runAssistant(env, body)
439
+ await writeAssistantStreamResult(result, controller)
440
+ } catch (error) {
441
+ controller.enqueue(encoder.encode(encodeSseData(streamErrorPayload(error))))
442
+ controller.enqueue(encoder.encode(encodeSseDone()))
443
+ } finally {
444
+ controller.close()
445
+ }
446
+ }
447
+ })
448
+
449
+ return new Response(stream, { headers: SSE_HEADERS })
450
+ }
451
+
316
452
  export async function onRequestOptions () {
317
453
  return new Response(null, {
318
454
  status: 204,
@@ -335,6 +471,13 @@ export async function onRequestPost (context) {
335
471
  }
336
472
 
337
473
  try {
474
+ if (ASSISTANT_CONFIG.aiSearch?.stream !== false) {
475
+ return createAssistantStreamResponse(context.env, {
476
+ ...body,
477
+ request: context.request
478
+ })
479
+ }
480
+
338
481
  const { payload, result } = await runAssistant(context.env, {
339
482
  ...body,
340
483
  request: context.request
@@ -1,3 +1,5 @@
1
+ import { dedupeAssistantSources } from './stream'
2
+
1
3
  export const ASSISTANT_SESSION_STORAGE_KEY = 'docsector.assistant.session.v1'
2
4
 
3
5
  const MAX_PERSISTED_MESSAGES = 40
@@ -31,7 +33,7 @@ export function normalizeAssistantSession (session = {}) {
31
33
  .filter(Boolean)
32
34
  .slice(-MAX_PERSISTED_MESSAGES)
33
35
 
34
- const sources = (Array.isArray(session?.sources) ? session.sources : [])
36
+ const sources = dedupeAssistantSources((Array.isArray(session?.sources) ? session.sources : [])
35
37
  .map((source, index) => {
36
38
  const key = cleanString(source?.key || '')
37
39
  const text = cleanString(source?.text || '')
@@ -48,7 +50,7 @@ export function normalizeAssistantSession (session = {}) {
48
50
  score: Number.isFinite(score) ? score : 0
49
51
  }
50
52
  })
51
- .filter(Boolean)
53
+ .filter(Boolean))
52
54
  .slice(0, MAX_PERSISTED_SOURCES)
53
55
 
54
56
  return { messages, sources }
@@ -71,6 +71,67 @@ function buildAssistantSourceDisplay (key = '') {
71
71
  }
72
72
  }
73
73
 
74
+ function normalizeAssistantSourcePath (path = '') {
75
+ const normalized = String(path || '')
76
+ .replace(/\\/g, '/')
77
+ .replace(/\/+/g, '/')
78
+ .replace(/\/+$/g, '')
79
+ .replace(/\.md$/i, '')
80
+
81
+ return normalized || '/'
82
+ }
83
+
84
+ export function normalizeAssistantSourceLinkKey (key = '') {
85
+ const rawKey = String(key || '').trim()
86
+ if (!rawKey) return ''
87
+
88
+ try {
89
+ if (/^(?:[a-z][a-z0-9+.-]*:)?\/\//i.test(rawKey)) {
90
+ const url = new URL(rawKey, 'https://docsector.local')
91
+ return [
92
+ url.protocol.toLowerCase(),
93
+ '//',
94
+ url.host.toLowerCase(),
95
+ normalizeAssistantSourcePath(decodeURIComponent(url.pathname || '/')),
96
+ url.search
97
+ ].join('')
98
+ }
99
+ } catch {
100
+ // Fall through to relative-path normalization for malformed URLs.
101
+ }
102
+
103
+ const hashless = rawKey.split('#')[0]
104
+ const queryIndex = hashless.indexOf('?')
105
+ const rawPath = queryIndex === -1 ? hashless : hashless.slice(0, queryIndex)
106
+ const search = queryIndex === -1 ? '' : hashless.slice(queryIndex)
107
+ const normalizedPath = normalizeAssistantSourcePath(rawPath).replace(/^\/+/, '') || '/'
108
+
109
+ return `${normalizedPath}${search}`
110
+ }
111
+
112
+ export function dedupeAssistantSources (sources = []) {
113
+ const byLink = new Map()
114
+
115
+ for (const source of Array.isArray(sources) ? sources : []) {
116
+ const dedupeKey = normalizeAssistantSourceLinkKey(source?.key) || `text:${String(source?.text || '').trim()}`
117
+ if (!dedupeKey || dedupeKey === 'text:') continue
118
+
119
+ const previous = byLink.get(dedupeKey)
120
+ if (!previous) {
121
+ byLink.set(dedupeKey, source)
122
+ continue
123
+ }
124
+
125
+ byLink.set(dedupeKey, {
126
+ ...previous,
127
+ text: previous.text || source.text || '',
128
+ score: Math.max(Number(previous.score || 0), Number(source.score || 0))
129
+ })
130
+ }
131
+
132
+ return Array.from(byLink.values())
133
+ }
134
+
74
135
  export function extractAssistantStreamDelta (event) {
75
136
  if (!event || event.data === '[DONE]') {
76
137
  return { done: true, content: '', chunks: [] }
@@ -104,7 +165,7 @@ export function extractAssistantStreamDelta (event) {
104
165
  }
105
166
 
106
167
  export function normalizeAssistantSourceChunks (chunks = []) {
107
- return (Array.isArray(chunks) ? chunks : [])
168
+ const sources = (Array.isArray(chunks) ? chunks : [])
108
169
  .map((chunk, index) => {
109
170
  const key = chunk?.item?.key || chunk?.key || chunk?.url || ''
110
171
  const score = Number(chunk?.score ?? chunk?.scoring_details?.vector_score ?? 0)
@@ -122,4 +183,6 @@ export function normalizeAssistantSourceChunks (chunks = []) {
122
183
  }
123
184
  })
124
185
  .filter(chunk => chunk.key || chunk.text)
186
+
187
+ return dedupeAssistantSources(sources)
125
188
  }
@@ -1,11 +1,16 @@
1
1
  <script setup>
2
- import { computed, nextTick, ref, watch } from 'vue'
2
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
3
3
  import { useI18n } from 'vue-i18n'
4
4
  import { useRoute } from 'vue-router'
5
- import { useQuasar } from 'quasar'
5
+ import { copyToClipboard, useQuasar } from 'quasar'
6
6
 
7
7
  import useAssistant from '../composables/useAssistant'
8
- import { isAssistantThinkingState, listVisibleAssistantMessages } from '../ai-assistant/panel'
8
+ import {
9
+ ASSISTANT_MESSAGE_WINDOW_SIZE,
10
+ ASSISTANT_MESSAGE_WINDOW_STEP,
11
+ getAssistantMessageWindow,
12
+ isAssistantThinkingState
13
+ } from '../ai-assistant/panel'
9
14
  import DPageTokens from './DPageTokens.vue'
10
15
  import { tokenizePageSectionSource } from './page-section-tokens'
11
16
 
@@ -49,6 +54,12 @@ const $q = useQuasar()
49
54
  const { t, locale } = useI18n()
50
55
  const input = ref('')
51
56
  const scrollArea = ref(null)
57
+ const visibleMessageLimit = ref(ASSISTANT_MESSAGE_WINDOW_SIZE)
58
+ const copiedMessageId = ref('')
59
+ const showScrollToBottom = ref(false)
60
+ let scrollFrame = 0
61
+ let copiedMessageTimer = null
62
+ let revealingOlderMessages = false
52
63
 
53
64
  const assistant = useAssistant({
54
65
  route,
@@ -85,13 +96,45 @@ const sources = computed(() => assistant.sources.value)
85
96
  const hasSources = computed(() => sources.value.length > 0 && assistant.config.ui.showCitations)
86
97
  const sourceAvatars = computed(() => sources.value.slice(0, 4))
87
98
  const sourcesLabel = computed(() => t('assistant.sourcesCount', { count: sources.value.length }))
88
- const visibleMessages = computed(() => listVisibleAssistantMessages(assistant.messages.value))
99
+ const messageWindow = computed(() => getAssistantMessageWindow(assistant.messages.value, visibleMessageLimit.value))
100
+ const visibleMessages = computed(() => messageWindow.value.messages)
101
+ const hasOlderMessages = computed(() => messageWindow.value.hiddenCount > 0)
102
+ const streamingAssistantMessageId = computed(() => {
103
+ if (!assistant.loading.value) {
104
+ return ''
105
+ }
106
+
107
+ const lastMessage = assistant.messages.value[assistant.messages.value.length - 1]
108
+ return lastMessage?.role === 'assistant' ? String(lastMessage.id || '') : ''
109
+ })
110
+ const latestAssistantMessageId = computed(() => {
111
+ for (let index = assistant.messages.value.length - 1; index >= 0; index -= 1) {
112
+ const message = assistant.messages.value[index]
113
+ if (message?.role === 'assistant' && String(message?.content || '').trim()) {
114
+ return String(message.id || '')
115
+ }
116
+ }
117
+
118
+ return ''
119
+ })
89
120
 
90
121
  const isThinking = computed(() => isAssistantThinkingState({
91
122
  loading: assistant.loading.value,
92
123
  messages: assistant.messages.value
93
124
  }))
94
125
 
126
+ const isStreamingAssistantMessage = (message) => {
127
+ return String(message?.id || '') !== '' && String(message?.id || '') === streamingAssistantMessageId.value
128
+ }
129
+
130
+ const hasMessageContent = (message) => {
131
+ return String(message?.content || '').trim().length > 0
132
+ }
133
+
134
+ const messageHasSources = (message) => {
135
+ return hasSources.value && String(message?.id || '') === latestAssistantMessageId.value
136
+ }
137
+
95
138
  const renderMessageTokens = (message) => {
96
139
  if (message?.role !== 'assistant') {
97
140
  return []
@@ -124,15 +167,120 @@ const startResize = (event) => {
124
167
  window.addEventListener('pointerup', onUp)
125
168
  }
126
169
 
127
- const scrollToBottom = () => {
128
- nextTick(() => {
129
- const target = scrollArea.value?.$el?.querySelector('.q-scrollarea__container')
130
- if (target) {
131
- target.scrollTop = target.scrollHeight
132
- }
170
+ const getScrollTarget = () => {
171
+ return scrollArea.value?.getScrollTarget?.()
172
+ || scrollArea.value?.$el?.querySelector('.q-scrollarea__container')
173
+ }
174
+
175
+ const applyBottomScroll = () => {
176
+ const target = getScrollTarget()
177
+ scrollArea.value?.setScrollPosition?.('vertical', Number.MAX_SAFE_INTEGER, 0)
178
+ if (target) {
179
+ target.scrollTop = target.scrollHeight
180
+ }
181
+ }
182
+
183
+ const scrollToBottom = ({ attempts = 6 } = {}) => {
184
+ if (typeof window === 'undefined') {
185
+ return
186
+ }
187
+
188
+ if (scrollFrame !== 0) {
189
+ window.cancelAnimationFrame(scrollFrame)
190
+ }
191
+
192
+ const run = (remainingAttempts) => {
193
+ scrollFrame = window.requestAnimationFrame(() => {
194
+ scrollFrame = 0
195
+
196
+ nextTick(() => {
197
+ applyBottomScroll()
198
+ if (remainingAttempts > 0) {
199
+ run(remainingAttempts - 1)
200
+ }
201
+ })
202
+ })
203
+ }
204
+
205
+ run(attempts)
206
+ }
207
+
208
+ const loadOlderMessages = async () => {
209
+ if (!hasOlderMessages.value || revealingOlderMessages) return
210
+
211
+ revealingOlderMessages = true
212
+ const target = getScrollTarget()
213
+ const previousHeight = target?.scrollHeight || 0
214
+ visibleMessageLimit.value += ASSISTANT_MESSAGE_WINDOW_STEP
215
+
216
+ await nextTick()
217
+
218
+ const nextTarget = getScrollTarget()
219
+ if (nextTarget) {
220
+ nextTarget.scrollTop += Math.max(0, nextTarget.scrollHeight - previousHeight)
221
+ }
222
+
223
+ revealingOlderMessages = false
224
+ }
225
+
226
+ const syncScrollToBottomVisibility = () => {
227
+ const target = getScrollTarget()
228
+ if (!target || !assistant.hasMessages.value) {
229
+ showScrollToBottom.value = false
230
+ return
231
+ }
232
+
233
+ const maxTop = Math.max(0, target.scrollHeight - target.clientHeight)
234
+ const distanceToBottom = maxTop - target.scrollTop
235
+ showScrollToBottom.value = distanceToBottom > 120
236
+ }
237
+
238
+ const handleScroll = (info = {}) => {
239
+ syncScrollToBottomVisibility()
240
+
241
+ const position = Number(info.verticalPosition ?? info.position?.top ?? 0)
242
+ if (position <= 80) {
243
+ loadOlderMessages()
244
+ }
245
+ }
246
+
247
+ const notifyMessageCopied = () => {
248
+ $q.notify({
249
+ message: t('assistant.copied'),
250
+ color: 'positive',
251
+ textColor: 'white',
252
+ icon: 'check',
253
+ position: 'top',
254
+ timeout: 1200
133
255
  })
134
256
  }
135
257
 
258
+ const copyMessage = (message) => {
259
+ const content = String(message?.content || '').trim()
260
+ const id = String(message?.id || '')
261
+ if (!content || !id) return
262
+
263
+ copyToClipboard(content)
264
+ .then(() => {
265
+ copiedMessageId.value = id
266
+ notifyMessageCopied()
267
+
268
+ if (copiedMessageTimer !== null) {
269
+ window.clearTimeout(copiedMessageTimer)
270
+ }
271
+
272
+ copiedMessageTimer = window.setTimeout(() => {
273
+ copiedMessageId.value = ''
274
+ copiedMessageTimer = null
275
+ }, 1600)
276
+ })
277
+ .catch(() => {})
278
+ }
279
+
280
+ const messageCopyIcon = (message) => {
281
+ return copiedMessageId.value === String(message?.id || '') ? 'check' : 'content_copy'
282
+ }
283
+
136
284
  const submit = async (value = input.value) => {
137
285
  const prompt = String(value || '').trim()
138
286
  if (!prompt) return
@@ -140,14 +288,45 @@ const submit = async (value = input.value) => {
140
288
  await assistant.send(prompt)
141
289
  }
142
290
 
291
+ const scrollToBottomAction = () => {
292
+ showScrollToBottom.value = false
293
+ scrollToBottom({ attempts: 10 })
294
+ }
295
+
143
296
  const handleKeydown = (event) => {
144
297
  if (event.key !== 'Enter' || event.shiftKey) return
145
298
  event.preventDefault()
146
299
  submit()
147
300
  }
148
301
 
149
- watch(assistant.messages, scrollToBottom, { deep: true })
150
- watch(assistant.sources, scrollToBottom, { deep: true })
302
+ watch(assistant.messages, () => scrollToBottom({ attempts: 6 }), { deep: true })
303
+ watch(assistant.sources, () => scrollToBottom({ attempts: 4 }), { deep: true })
304
+ watch(() => assistant.messages.value.length, (length, previousLength) => {
305
+ if (length < previousLength) {
306
+ visibleMessageLimit.value = ASSISTANT_MESSAGE_WINDOW_SIZE
307
+ }
308
+
309
+ if (length === 0) {
310
+ showScrollToBottom.value = false
311
+ }
312
+ })
313
+
314
+ onMounted(() => {
315
+ scrollToBottom({ attempts: 14 })
316
+ nextTick(syncScrollToBottomVisibility)
317
+ })
318
+
319
+ onBeforeUnmount(() => {
320
+ if (scrollFrame !== 0 && typeof window !== 'undefined') {
321
+ window.cancelAnimationFrame(scrollFrame)
322
+ scrollFrame = 0
323
+ }
324
+
325
+ if (copiedMessageTimer !== null && typeof window !== 'undefined') {
326
+ window.clearTimeout(copiedMessageTimer)
327
+ copiedMessageTimer = null
328
+ }
329
+ })
151
330
  </script>
152
331
 
153
332
  <template>
@@ -191,7 +370,7 @@ watch(assistant.sources, scrollToBottom, { deep: true })
191
370
  </div>
192
371
  </header>
193
372
 
194
- <q-scroll-area ref="scrollArea" class="d-assistant-panel__body">
373
+ <q-scroll-area ref="scrollArea" class="d-assistant-panel__body" @scroll="handleScroll">
195
374
  <div v-if="!assistant.hasMessages.value" class="d-assistant-panel__empty">
196
375
  <div class="d-assistant-panel__mark">
197
376
  <q-icon name="auto_awesome" size="52px" />
@@ -201,6 +380,18 @@ watch(assistant.sources, scrollToBottom, { deep: true })
201
380
  </div>
202
381
 
203
382
  <div v-else class="d-assistant-panel__messages">
383
+ <div v-if="hasOlderMessages" class="d-assistant-panel__older">
384
+ <q-btn
385
+ dense no-caps unelevated
386
+ icon="expand_less"
387
+ color="white"
388
+ text-color="primary"
389
+ class="d-assistant-panel__older-action"
390
+ :label="t('assistant.loadEarlier')"
391
+ @click="loadOlderMessages"
392
+ />
393
+ </div>
394
+
204
395
  <div
205
396
  v-for="(message, index) in visibleMessages"
206
397
  :key="message.id"
@@ -208,12 +399,109 @@ watch(assistant.sources, scrollToBottom, { deep: true })
208
399
  :class="`d-assistant-message--${message.role}`"
209
400
  >
210
401
  <div
211
- v-if="message.role === 'assistant'"
402
+ v-if="message.role === 'assistant' && isStreamingAssistantMessage(message)"
403
+ class="d-assistant-message__content d-assistant-message__content--streaming"
404
+ >
405
+ {{ message.content }}
406
+ </div>
407
+ <div
408
+ v-else-if="message.role === 'assistant'"
212
409
  class="content no-padding d-assistant-message__content d-assistant-message__content--markdown"
213
410
  >
214
411
  <d-page-tokens :id="(index + 1) * 1000" :tokens="renderMessageTokens(message)" />
215
412
  </div>
216
413
  <div v-else class="d-assistant-message__content">{{ message.content }}</div>
414
+
415
+ <div
416
+ v-if="message.role === 'assistant' && hasMessageContent(message)"
417
+ class="d-assistant-message__footer"
418
+ >
419
+ <q-btn
420
+ flat dense
421
+ text-color="primary"
422
+ class="d-assistant-message__copy d-assistant-message__copy--assistant"
423
+ :icon="messageCopyIcon(message)"
424
+ :aria-label="t('assistant.copyMessage')"
425
+ @click="copyMessage(message)"
426
+ >
427
+ <q-tooltip>{{ t('assistant.copyMessage') }}</q-tooltip>
428
+ </q-btn>
429
+
430
+ <q-chip
431
+ v-if="messageHasSources(message)"
432
+ clickable
433
+ class="d-assistant-sources-chip"
434
+ :ripple="false"
435
+ >
436
+ <span class="d-assistant-sources-chip__avatars">
437
+ <q-avatar
438
+ v-for="(source, sourceIndex) in sourceAvatars"
439
+ :key="source.id"
440
+ class="d-assistant-sources-chip__avatar"
441
+ :style="{ zIndex: sourceAvatars.length - sourceIndex }"
442
+ size="24px"
443
+ >
444
+ <img :src="faviconFor(source)" :alt="source.title" loading="lazy">
445
+ </q-avatar>
446
+ </span>
447
+ <span class="d-assistant-sources-chip__label">{{ sourcesLabel }}</span>
448
+ <q-icon name="expand_more" size="16px" />
449
+
450
+ <q-menu
451
+ anchor="top left"
452
+ self="bottom left"
453
+ :offset="[0, 8]"
454
+ class="d-assistant-sources-menu"
455
+ >
456
+ <q-list separator class="d-assistant-sources-menu__list">
457
+ <q-item-label header class="d-assistant-sources-menu__header">
458
+ {{ t('assistant.sources') }}
459
+ </q-item-label>
460
+ <q-item
461
+ v-for="source in sources"
462
+ :key="source.id"
463
+ v-close-popup
464
+ clickable
465
+ tag="a"
466
+ :href="sourceHref(source)"
467
+ target="_blank"
468
+ rel="noopener noreferrer"
469
+ >
470
+ <q-item-section avatar>
471
+ <q-avatar size="28px" class="d-assistant-sources-menu__avatar">
472
+ <img :src="faviconFor(source)" :alt="source.title" loading="lazy">
473
+ </q-avatar>
474
+ </q-item-section>
475
+ <q-item-section>
476
+ <q-item-label lines="1">{{ source.title }}</q-item-label>
477
+ <q-item-label v-if="source.meta" caption lines="1">{{ source.meta }}</q-item-label>
478
+ </q-item-section>
479
+ <q-item-section side>
480
+ <q-icon name="open_in_new" size="16px" />
481
+ </q-item-section>
482
+ </q-item>
483
+ </q-list>
484
+ </q-menu>
485
+ </q-chip>
486
+ </div>
487
+
488
+ <div
489
+ v-else-if="message.role === 'user' && hasMessageContent(message)"
490
+ class="d-assistant-message__footer d-assistant-message__footer--user"
491
+ >
492
+ <div class="d-assistant-message__hoverlayer">
493
+ <q-btn
494
+ flat dense
495
+ text-color="primary"
496
+ class="d-assistant-message__copy d-assistant-message__copy--user"
497
+ :icon="messageCopyIcon(message)"
498
+ :aria-label="t('assistant.copyMessage')"
499
+ @click="copyMessage(message)"
500
+ >
501
+ <q-tooltip>{{ t('assistant.copyMessage') }}</q-tooltip>
502
+ </q-btn>
503
+ </div>
504
+ </div>
217
505
  </div>
218
506
 
219
507
  <div v-if="isThinking" class="d-assistant-message d-assistant-message--assistant">
@@ -229,65 +517,22 @@ watch(assistant.sources, scrollToBottom, { deep: true })
229
517
  <span>{{ assistant.error.value }}</span>
230
518
  </div>
231
519
 
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
520
  </q-scroll-area>
290
521
 
522
+ <q-btn
523
+ v-if="showScrollToBottom && assistant.hasMessages.value"
524
+ round
525
+ unelevated
526
+ color="primary"
527
+ text-color="white"
528
+ class="d-assistant-panel__scroll-bottom"
529
+ icon="keyboard_arrow_down"
530
+ aria-label="Scroll to bottom"
531
+ @click="scrollToBottomAction"
532
+ >
533
+ <q-tooltip>Scroll to bottom</q-tooltip>
534
+ </q-btn>
535
+
291
536
  <footer class="d-assistant-panel__composer">
292
537
  <div v-if="!assistant.hasMessages.value" class="d-assistant-panel__prompts">
293
538
  <q-btn
@@ -384,12 +629,16 @@ watch(assistant.sources, scrollToBottom, { deep: true })
384
629
  background: linear-gradient(180deg, rgba(27,27,28,0), rgba(41, 24, 20, 0.72) 34%, #1b1b1c 100%)
385
630
 
386
631
  .d-assistant-message--assistant .d-assistant-message__content,
632
+ .d-assistant-panel__older-action,
387
633
  .d-assistant-panel__prompt,
388
634
  .d-assistant-sources-chip
389
635
  background: rgba(255, 255, 255, 0.045)
390
636
  color: rgba(255, 255, 255, 0.86)
391
637
  border-color: rgba(255, 255, 255, 0.12)
392
638
 
639
+ .d-assistant-message__copy
640
+ color: rgba(255, 255, 255, 0.84)
641
+
393
642
  .d-assistant-sources-chip__avatar
394
643
  border-color: #1b1b1c
395
644
 
@@ -489,9 +738,22 @@ watch(assistant.sources, scrollToBottom, { deep: true })
489
738
  background: rgba(14, 165, 233, 0.12)
490
739
 
491
740
  &__messages
492
- padding: 16px 14px 14px
741
+ padding: 16px 14px 8px
493
742
  overflow-x: hidden
494
743
 
744
+ &__older
745
+ display: flex
746
+ justify-content: center
747
+ margin: 0 0 12px
748
+
749
+ &__older-action
750
+ min-height: 32px
751
+ padding: 0 12px
752
+ border: 1px solid rgba(15, 23, 42, 0.12)
753
+ background: rgba(255, 255, 255, 0.82)
754
+ border-radius: 8px
755
+ font-weight: 700
756
+
495
757
  &__error
496
758
  margin: 0 14px 96px
497
759
  padding: 10px 12px
@@ -503,6 +765,14 @@ watch(assistant.sources, scrollToBottom, { deep: true })
503
765
  background: rgba(251, 191, 36, 0.12)
504
766
  border-radius: 8px
505
767
 
768
+ &__scroll-bottom
769
+ position: absolute
770
+ left: 50%
771
+ bottom: calc(90px + env(safe-area-inset-bottom, 0px))
772
+ transform: translateX(-50%)
773
+ z-index: 4
774
+ box-shadow: 0 10px 22px rgba(15, 23, 42, 0.28)
775
+
506
776
  &__sources
507
777
  margin: 0 14px 0
508
778
  display: flex
@@ -577,24 +847,39 @@ watch(assistant.sources, scrollToBottom, { deep: true })
577
847
 
578
848
  .d-assistant-message
579
849
  display: flex
850
+ flex-direction: column
851
+ align-items: flex-start
580
852
  margin-bottom: 10px
581
853
  min-width: 0
582
854
  overflow-x: hidden
855
+ overflow-y: hidden
856
+ position: relative
583
857
 
584
858
  &--user
585
- justify-content: flex-end
859
+ align-items: flex-end
586
860
 
587
861
  .d-assistant-message__content
588
862
  color: white
589
863
  background: var(--q-primary)
590
864
 
865
+ &:hover .d-assistant-message__hoverlayer .d-assistant-message__copy,
866
+ &:focus-within .d-assistant-message__hoverlayer .d-assistant-message__copy,
867
+ .d-assistant-message__hoverlayer:hover .d-assistant-message__copy,
868
+ .d-assistant-message__hoverlayer:focus-within .d-assistant-message__copy
869
+ opacity: 1
870
+ pointer-events: auto
871
+
591
872
  &--assistant
592
- justify-content: flex-start
873
+ align-items: flex-start
593
874
 
594
875
  .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
876
+ background: none !important
877
+ border: none
878
+ padding: 0 !important
879
+ max-width: 100%
880
+
881
+ .d-assistant-message__footer
882
+ justify-content: flex-start
598
883
 
599
884
  &__content
600
885
  max-width: 88%
@@ -639,6 +924,61 @@ watch(assistant.sources, scrollToBottom, { deep: true })
639
924
  max-width: 100%
640
925
  height: auto
641
926
 
927
+ &__footer
928
+ width: 88%
929
+ max-width: 88%
930
+ min-width: 0
931
+ display: flex
932
+ align-items: center
933
+ gap: 6px
934
+ margin-top: 5px
935
+
936
+ .d-assistant-sources-chip
937
+ flex: 0 1 auto
938
+ max-width: calc(100% - 38px)
939
+
940
+ &--user
941
+ width: auto
942
+ max-width: none
943
+ min-width: 30px
944
+ height: 30px
945
+ align-self: flex-end
946
+ align-items: center
947
+ justify-content: center
948
+
949
+ &__copy
950
+ flex: 0 0 auto
951
+ width: 30px
952
+ height: 30px
953
+ min-height: 30px
954
+ min-width: 30px
955
+ padding: 0
956
+ border-radius: 6px
957
+
958
+ .q-btn__content
959
+ line-height: 1
960
+
961
+ i
962
+ font-size: 17px !important
963
+ margin-left: 3px
964
+
965
+ &--assistant
966
+ margin-left: -2px
967
+
968
+ &__hoverlayer
969
+ display: flex
970
+ align-items: center
971
+ justify-content: center
972
+ width: 30px
973
+ height: 30px
974
+ min-width: 30px
975
+ min-height: 30px
976
+
977
+ .d-assistant-message__copy
978
+ opacity: 0
979
+ pointer-events: none
980
+ transition: opacity 0.14s ease
981
+
642
982
  &__thinking
643
983
  display: flex
644
984
  align-items: center
@@ -649,6 +989,7 @@ watch(assistant.sources, scrollToBottom, { deep: true })
649
989
 
650
990
  .d-assistant-sources-chip
651
991
  max-width: 100%
992
+ min-width: 0
652
993
  height: auto
653
994
  min-height: 34px
654
995
  padding: 4px 10px 4px 6px
@@ -688,6 +1029,8 @@ watch(assistant.sources, scrollToBottom, { deep: true })
688
1029
  margin: 0 2px 0 8px
689
1030
  font-size: 0.8rem
690
1031
  white-space: nowrap
1032
+ overflow: hidden
1033
+ text-overflow: ellipsis
691
1034
 
692
1035
  .d-assistant-sources-menu
693
1036
  max-width: min(360px, 90vw)
@@ -136,6 +136,10 @@ const mobileAssistantOpen = computed({
136
136
  get: () => assistantEnabled && rightRailState.value.isMobile && layoutAssistant.value,
137
137
  set: (value) => { layoutAssistant.value = value }
138
138
  })
139
+ const mobileTocOpen = computed({
140
+ get: () => rightRailState.value.isMobile && layoutMeta.value,
141
+ set: (value) => { layoutMeta.value = value }
142
+ })
139
143
  const backToTopRightOffset = computed(() => {
140
144
  return rightRailState.value.backToTopRightOffset
141
145
  })
@@ -470,6 +474,18 @@ watch(() => route.fullPath, () => {
470
474
  </div>
471
475
  </q-drawer>
472
476
 
477
+ <q-dialog
478
+ v-model="mobileTocOpen"
479
+ position="right"
480
+ square
481
+ full-height
482
+ class="d-mobile-anchor-dialog"
483
+ >
484
+ <div id="anchor" class="d-mobile-anchor-dialog__panel">
485
+ <d-page-anchor />
486
+ </div>
487
+ </q-dialog>
488
+
473
489
  <q-dialog
474
490
  v-if="assistantEnabled"
475
491
  v-model="mobileAssistantOpen"
@@ -551,6 +567,32 @@ watch(() => route.fullPath, () => {
551
567
  max-height: 100dvh
552
568
  margin: 0
553
569
 
570
+ .d-mobile-anchor-dialog
571
+ .q-dialog__inner
572
+ padding: 0
573
+ align-items: stretch
574
+ justify-content: flex-end
575
+
576
+ .q-dialog__backdrop
577
+ background: rgba(15, 23, 42, 0.24)
578
+
579
+ &__panel
580
+ width: min(100vw, 340px)
581
+ max-width: 100vw
582
+ height: 100%
583
+ max-height: 100dvh
584
+ padding-top: env(safe-area-inset-top, 0px)
585
+ padding-bottom: env(safe-area-inset-bottom, 0px)
586
+ background: rgba(248, 250, 252, 0.88)
587
+ backdrop-filter: blur(18px)
588
+ -webkit-backdrop-filter: blur(18px)
589
+ overflow: auto
590
+ box-shadow: -16px 0 40px rgba(15, 23, 42, 0.22)
591
+
592
+ body.body--dark
593
+ .d-mobile-anchor-dialog__panel
594
+ background: rgba(15, 23, 42, 0.9)
595
+
554
596
  #scroll-container
555
597
  width: 100%
556
598
  max-width: 1200px
@@ -1,4 +1,4 @@
1
- import { computed, ref, watch } from 'vue'
1
+ import { computed, nextTick, ref, watch } from 'vue'
2
2
 
3
3
  import docsectorConfig from 'docsector.config.js'
4
4
  import { normalizeAiAssistantConfig } from '../ai-assistant/config'
@@ -11,6 +11,10 @@ const assistantMessages = ref([])
11
11
  const assistantSources = ref([])
12
12
  let assistantSessionReady = false
13
13
  let assistantSessionWatcherReady = false
14
+ let assistantSessionPersistTimer = null
15
+ let assistantSessionPersistencePaused = false
16
+
17
+ const ASSISTANT_SESSION_PERSIST_DEBOUNCE = 180
14
18
 
15
19
  function createMessage (role, content = '') {
16
20
  return {
@@ -27,13 +31,50 @@ function getCompletionText (payload) {
27
31
  || ''
28
32
  }
29
33
 
30
- function persistAssistantSession () {
34
+ function persistAssistantSessionNow () {
31
35
  saveAssistantSession({
32
36
  messages: assistantMessages.value,
33
37
  sources: assistantSources.value
34
38
  })
35
39
  }
36
40
 
41
+ function cancelPersistAssistantSession () {
42
+ if (assistantSessionPersistTimer !== null) {
43
+ clearTimeout(assistantSessionPersistTimer)
44
+ assistantSessionPersistTimer = null
45
+ }
46
+ }
47
+
48
+ function schedulePersistAssistantSession ({ immediate = false } = {}) {
49
+ if (assistantSessionPersistencePaused) {
50
+ return
51
+ }
52
+
53
+ cancelPersistAssistantSession()
54
+
55
+ if (immediate || typeof window === 'undefined') {
56
+ persistAssistantSessionNow()
57
+ return
58
+ }
59
+
60
+ assistantSessionPersistTimer = window.setTimeout(() => {
61
+ assistantSessionPersistTimer = null
62
+ persistAssistantSessionNow()
63
+ }, ASSISTANT_SESSION_PERSIST_DEBOUNCE)
64
+ }
65
+
66
+ async function yieldAssistantStreamPaint () {
67
+ await nextTick()
68
+
69
+ if (typeof window === 'undefined') {
70
+ return
71
+ }
72
+
73
+ await new Promise(resolve => {
74
+ window.setTimeout(resolve, 0)
75
+ })
76
+ }
77
+
37
78
  function ensureAssistantSession () {
38
79
  if (!assistantSessionReady) {
39
80
  const session = loadAssistantSession()
@@ -43,7 +84,9 @@ function ensureAssistantSession () {
43
84
  }
44
85
 
45
86
  if (!assistantSessionWatcherReady) {
46
- watch([assistantMessages, assistantSources], persistAssistantSession, { deep: true })
87
+ watch([assistantMessages, assistantSources], () => {
88
+ schedulePersistAssistantSession()
89
+ }, { deep: true })
47
90
  assistantSessionWatcherReady = true
48
91
  }
49
92
  }
@@ -63,6 +106,7 @@ export default function useAssistant ({ route, locale, getContext } = {}) {
63
106
  messages.value = []
64
107
  sources.value = []
65
108
  error.value = ''
109
+ cancelPersistAssistantSession()
66
110
  clearAssistantSession()
67
111
  }
68
112
 
@@ -99,10 +143,16 @@ export default function useAssistant ({ route, locale, getContext } = {}) {
99
143
  for (const event of events) {
100
144
  const delta = extractAssistantStreamDelta(event)
101
145
  if (delta.done) return
146
+ const hadContent = delta.content.length > 0
147
+ const hadChunks = delta.chunks.length > 0
102
148
  appendAssistantContent(assistantMessage, delta.content)
103
- if (delta.chunks.length > 0) {
149
+ if (hadChunks) {
104
150
  sources.value = delta.chunks
105
151
  }
152
+
153
+ if (hadContent || hadChunks) {
154
+ await yieldAssistantStreamPaint()
155
+ }
106
156
  }
107
157
  }
108
158
  }
@@ -111,10 +161,16 @@ export default function useAssistant ({ route, locale, getContext } = {}) {
111
161
  const events = parseServerSentEvents(`${buffer}\n\n`)
112
162
  for (const event of events) {
113
163
  const delta = extractAssistantStreamDelta(event)
164
+ const hadContent = delta.content.length > 0
165
+ const hadChunks = delta.chunks.length > 0
114
166
  appendAssistantContent(assistantMessage, delta.content)
115
- if (delta.chunks.length > 0) {
167
+ if (hadChunks) {
116
168
  sources.value = delta.chunks
117
169
  }
170
+
171
+ if (hadContent || hadChunks) {
172
+ await yieldAssistantStreamPaint()
173
+ }
118
174
  }
119
175
  }
120
176
  }
@@ -123,6 +179,8 @@ export default function useAssistant ({ route, locale, getContext } = {}) {
123
179
  const prompt = String(content || '').trim()
124
180
  if (!prompt || loading.value) return
125
181
 
182
+ assistantSessionPersistencePaused = true
183
+ cancelPersistAssistantSession()
126
184
  error.value = ''
127
185
  sources.value = []
128
186
 
@@ -165,7 +223,7 @@ export default function useAssistant ({ route, locale, getContext } = {}) {
165
223
  }
166
224
 
167
225
  const contentType = response.headers.get('content-type') || ''
168
- if (contentType.includes('text/event-stream') && response.body) {
226
+ if (response.body && (contentType.includes('text/event-stream') || contentType.trim() === '')) {
169
227
  await consumeStream(response, liveAssistantMessage)
170
228
  } else {
171
229
  const payload = await response.json()
@@ -184,6 +242,10 @@ export default function useAssistant ({ route, locale, getContext } = {}) {
184
242
  } finally {
185
243
  loading.value = false
186
244
  abortController.value = null
245
+ assistantSessionPersistencePaused = false
246
+ // Persist once after the streamed response settles; writing the whole
247
+ // session on every chunk can block paints and make streaming appear buffered.
248
+ schedulePersistAssistantSession({ immediate: true })
187
249
  }
188
250
  }
189
251
 
@@ -83,6 +83,9 @@ const engineDefaults = {
83
83
  context: 'Based on your context',
84
84
  sources: 'Sources',
85
85
  sourcesCount: '{count} sources',
86
+ copyMessage: 'Copy message',
87
+ copied: 'Message copied',
88
+ loadEarlier: 'Load earlier messages',
86
89
  thinking: 'Searching the docs…',
87
90
  resize: 'Resize assistant',
88
91
  greeting: {
@@ -153,6 +156,9 @@ const engineDefaults = {
153
156
  context: 'Com base no seu contexto',
154
157
  sources: 'Fontes',
155
158
  sourcesCount: '{count} fontes',
159
+ copyMessage: 'Copiar mensagem',
160
+ copied: 'Mensagem copiada',
161
+ loadEarlier: 'Carregar mensagens anteriores',
156
162
  thinking: 'Consultando a documentação…',
157
163
  resize: 'Redimensionar assistente',
158
164
  greeting: {
@@ -118,6 +118,9 @@
118
118
  context: 'Based on your context',
119
119
  sources: 'Sources',
120
120
  sourcesCount: '{count} sources',
121
+ copyMessage: 'Copy message',
122
+ copied: 'Message copied',
123
+ loadEarlier: 'Load earlier messages',
121
124
  thinking: 'Searching the docs…',
122
125
  resize: 'Resize assistant',
123
126
  greeting: {
@@ -117,6 +117,9 @@
117
117
  context: 'Com base no seu contexto',
118
118
  sources: 'Fontes',
119
119
  sourcesCount: '{count} fontes',
120
+ copyMessage: 'Copiar mensagem',
121
+ copied: 'Mensagem copiada',
122
+ loadEarlier: 'Carregar mensagens anteriores',
120
123
  thinking: 'Consultando a documentação…',
121
124
  resize: 'Redimensionar assistente',
122
125
  greeting: {