@docsector/docsector-reader 4.5.5 β†’ 4.6.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.
package/README.md CHANGED
@@ -34,6 +34,7 @@ Transform Markdown content into beautiful, navigable documentation sites β€” wit
34
34
  - πŸͺͺ **MCP Server Card** β€” Optional `/.well-known/mcp/server-card.json` for MCP server discovery before connection
35
35
  - 🌐 **WebMCP Browser Tools** β€” Optional registration of in-page tools via `navigator.modelContext` for browser agents
36
36
  - πŸ€– **AI Assistant Panel** β€” Optional documentation assistant drawer backed by Cloudflare AI Search through an internal same-origin endpoint
37
+ - πŸ—‚οΈ **API Catalog Well-Known** β€” Auto-generates `/.well-known/api-catalog` as Linkset JSON for machine-readable API discovery
37
38
  - πŸ”— **Homepage Link Headers** β€” Auto-generated `Link` response headers for agent discovery (`api-catalog`, `service-doc`, `service-desc`, `describedby`) per RFC 8288 / RFC 9727
38
39
  - πŸ”Œ **MCP Server** β€” Auto-generated [MCP](https://modelcontextprotocol.io) server at `/mcp` for AI assistant integration (Claude Desktop, VS Code, etc.)
39
40
  - πŸ“„ **llms.txt / llms-full.txt** β€” Auto-generated [llms.txt](https://llmstxt.org) index and full-content file for LLM discovery (requires `siteUrl` in config)
@@ -58,7 +59,7 @@ Transform Markdown content into beautiful, navigable documentation sites β€” wit
58
59
  - πŸ”— **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
59
60
  - πŸ–±οΈ **Active Menu Item UX** β€” Active menu entries keep pointer cursor, clear URL hash without redundant navigation, and prevent accidental label text selection
60
61
  - πŸ”Ž **Search** β€” Menu search across all documentation content and tags
61
- - πŸ’¬ **Assistant Chat UX Enhancements** β€” Long conversations keep focus on recent messages, load earlier history progressively, deduplicate repeated sources, preserve the assistant panel open state across reloads, include per-message copy actions, and show a floating quick return to the bottom
62
+ - πŸ’¬ **Assistant Chat UX Enhancements** β€” Long conversations keep focus on recent messages, load earlier history progressively, deduplicate repeated sources, preserve the assistant panel open state across reloads, include per-message copy actions and hover-revealed message times, and show a floating quick return to the bottom
62
63
  - πŸ“± **Responsive** β€” Mobile-friendly with collapsible sidebar and drawers
63
64
  - 🏷️ **Clickable Header Branding** β€” The configured `branding.logo` and `branding.name` render as a home link in the global header, aligned left on desktop with a compact mobile treatment
64
65
  - πŸ“š **Book Tabs with Per-State Colors** β€” Define `*.book.js` tabs with icons, order, and `color.active` / `color.inactive`
@@ -77,9 +78,6 @@ Transform Markdown content into beautiful, navigable documentation sites β€” wit
77
78
  - 🧱 **Configurable Homepage Layout** β€” Set `homePage.layout` to `default` or `fullwidth`; fullwidth keeps the header and book tabs while removing the sidebar, subpage toolbar, Table of Contents, and homepage footer
78
79
  - 🌍 **Remote README as Home** β€” Optional build-time remote README source for homepage with automatic local fallback and automatic primary-title handoff when the remote README already provides the project heading
79
80
  - πŸ”— **GitHub-Compatible Heading Anchors** β€” Markdown headings use GitHub-style slugs so standard README Table of Contents links work inside Docsector
80
- - 🧬 **Scaffolded Homepage Override Wiring** β€” New consumer projects automatically wire `virtual:docsector-homepage-override` into i18n message building
81
- - πŸ€– **Scaffolded AI Assistant Config** β€” New consumer projects include a ready-to-enable `aiAssistant` example in `docsector.config.js` so the built-in assistant is visible in the default scaffold
82
- - 🧰 **Scaffolded Dev Reliability** β€” New consumer projects protect Docsector virtual registries and Markdown CommonJS plugins from Vite optimizer edge cases during dev and build
83
81
  - πŸ“– **Expandable Markdown Sections** β€” Use `<d-block-expandable title="...">...</d-block-expandable>` to collapse secondary content while keeping rich Markdown support inside the body
84
82
  - 1️⃣ **Stepper Guides** β€” Use `<d-block-stepper>` with nested `<d-block-step title="...">...</d-block-step>` items to render native Quasar vertical steppers with rich Markdown and optional per-step icon overrides
85
83
  - πŸ•’ **Timeline Updates** β€” Use `<d-block-timeline>` with nested `<d-block-timeline-item date="...">...</d-block-timeline-item>` entries and optional `<d-block-timeline-tag>` labels to publish GitBook-inspired changelog items with direct-link anchors, tag icons/colors, and rich Markdown bodies
@@ -88,13 +86,11 @@ Transform Markdown content into beautiful, navigable documentation sites β€” wit
88
86
  - 🧭 **Quick Links Custom Element** β€” Use `<d-block-quick-links>` and `<d-block-quick-link>` in Markdown to render rich home navigation cards
89
87
  - πŸ—‚οΈ **Cards Custom Element** β€” Use `<d-block-cards>` and `<d-block-card>` in Markdown to render linked content cards with optional cover images
90
88
  - 🧾 **API JSON Reference Block** β€” Use `<d-block-api src="..." />` in Markdown to render Quasar-compatible API reference UIs from public JSON assets without inventing a new schema
91
- - πŸ—‚οΈ **API Catalog Well-Known** β€” Auto-generates `/.well-known/api-catalog` as Linkset JSON for machine-readable API discovery
92
89
  - 🧠 **Docsector Authoring Skill Docs** β€” Documents the built-in `SKILL.md` and reference files so agents can learn Docsector blocks, page patterns, MCP lookup, and WebMCP tools from a public manual page
93
90
  - πŸ—ƒοΈ **Multi-Version History** β€” Archive older major versions under `src/pages/.old/<version>/` and expose them at prefixed routes (e.g. `/v0.x/guide/...`) while keeping the current docs at unprefixed routes
94
91
  - 🏷️ **Version Selector Badges** β€” Every version in the sidebar selector displays a color-coded badge: green for released, orange for draft, red for deprecated; fully customizable via `badge: { label, color, textColor }`
95
92
  - πŸ“‚ **Tabbed Code Blocks** β€” Group consecutive fenced code blocks into tabs using the `group` and `tab` attributes in the fence info line
96
93
  - πŸ§ͺ **Live Code Example Blocks** β€” Use `<d-block-code-example src="..." />` to render bundled Vue SFC examples with a live preview, GitHub source link, source toggle, and CodePen export for compatible examples
97
- - πŸ“ **Accurate Source Code Line Counts** β€” Code example headers count visible lines correctly across LF, CRLF, and terminal newlines without inflating the total
98
94
  - 🍞 **Breadcrumb Path Display** β€” Show a file path breadcrumb above code blocks with the `breadcrumb` attribute; renders as clickable path segments
99
95
  - 🎨 **File Type Icons** β€” Automatically resolves file extension or filename to a Material Icon Theme SVG icon, shown inline in tabs and beside the last breadcrumb segment
100
96
  - βš™οΈ **Single Config File** β€” Customize branding, links, and languages via `docsector.config.js`
@@ -305,7 +301,7 @@ Check `checks.discovery.webMcp.status` equals `"pass"`.
305
301
 
306
302
  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.
307
303
 
308
- 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, preserve the panel open state across page reloads, and provide per-message copy actions.
304
+ 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, preserve the panel open state across page reloads, and provide per-message copy actions with hover-revealed message times.
309
305
 
310
306
  ### Configure
311
307
 
@@ -336,8 +332,8 @@ export default {
336
332
  accountIdEnv: 'CLOUDFLARE_ACCOUNT_ID',
337
333
  apiTokenEnv: 'CLOUDFLARE_API_TOKEN',
338
334
  model: '@cf/meta/llama-3.3-70b-instruct-fp8-fast',
339
- retrievalType: 'hybrid',
340
- maxResults: 6,
335
+ retrievalType: 'vector',
336
+ maxResults: 10,
341
337
  matchThreshold: 0.4,
342
338
  contextExpansion: 1,
343
339
  queryRewrite: { enabled: true },
@@ -890,8 +886,8 @@ export default {
890
886
  instanceNameEnv: 'AI_SEARCH_INSTANCE_NAME',
891
887
  accountIdEnv: 'CLOUDFLARE_ACCOUNT_ID',
892
888
  apiTokenEnv: 'CLOUDFLARE_API_TOKEN',
893
- retrievalType: 'hybrid',
894
- maxResults: 6,
889
+ retrievalType: 'vector',
890
+ maxResults: 10,
895
891
  stream: true
896
892
  }
897
893
  },
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.5.5'
27
+ const VERSION = '4.6.0'
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`
@@ -553,15 +553,15 @@ const TEMPLATE_HOMEPAGE_MD = `\
553
553
 
554
554
  Docsector Reader is a markdown-first documentation engine.
555
555
 
556
+ Give us a star on GitHub: [docsector/docsector-reader](https://github.com/docsector/docsector-reader)
557
+
556
558
  ## Quick Links
557
559
 
558
560
  - [Getting Started](/guide/getting-started/overview/)
559
- - [Configuration](/guide/configuration/overview/)
560
- - [Pages and Routing](/guide/pages-and-routing/overview/)
561
561
 
562
- ## About
562
+ ## Demo
563
563
 
564
- - Repository: [docsector/docsector-reader](https://github.com/docsector/docsector-reader)
564
+ Check out the [docsector.com](https://docsector.com) to see all the features in action!
565
565
  `
566
566
 
567
567
  const TEMPLATE_404_PAGE = `\
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@docsector/docsector-reader",
3
- "version": "4.5.5",
3
+ "version": "4.6.0",
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",
@@ -57,7 +57,8 @@
57
57
  "url": "https://github.com/docsector/docsector-reader/issues"
58
58
  },
59
59
  "scripts": {
60
- "dev": "npx wrangler pages dev dist/spa",
60
+ "dev": "quasar dev",
61
+ "dev:pages": "npx wrangler pages dev dist/spa",
61
62
  "build": "quasar build",
62
63
  "lint": "eslint --ext .js,.vue ./",
63
64
  "format": "prettier --write \"**/*.{js,vue,scss,html,md,json}\" --ignore-path .gitignore",
@@ -2,6 +2,39 @@ export function hasAssistantMessageContent(message) {
2
2
  return String(message?.content || '').trim().length > 0
3
3
  }
4
4
 
5
+ export function getAssistantMessageTimestamp(message) {
6
+ const timestamp = Number(message?.timestamp)
7
+ if (Number.isFinite(timestamp) && timestamp > 0) {
8
+ return timestamp
9
+ }
10
+
11
+ const id = String(message?.id || '')
12
+ const match = id.match(/^(?:user|assistant)-(\d{12,})-/)
13
+ if (!match) {
14
+ return null
15
+ }
16
+
17
+ const legacyTimestamp = Number(match[1])
18
+ return Number.isFinite(legacyTimestamp) && legacyTimestamp > 0 ? legacyTimestamp : null
19
+ }
20
+
21
+ export function formatAssistantMessageTime(message, locale = 'en-US') {
22
+ const timestamp = getAssistantMessageTimestamp(message)
23
+ if (!timestamp) {
24
+ return ''
25
+ }
26
+
27
+ const date = new Date(timestamp)
28
+ if (Number.isNaN(date.getTime())) {
29
+ return ''
30
+ }
31
+
32
+ return new Intl.DateTimeFormat(locale || 'en-US', {
33
+ hour: '2-digit',
34
+ minute: '2-digit'
35
+ }).format(date)
36
+ }
37
+
5
38
  export function listVisibleAssistantMessages(messages = []) {
6
39
  return messages.filter((message) => {
7
40
  if (message?.role !== 'assistant') {
@@ -12,6 +45,26 @@ export function listVisibleAssistantMessages(messages = []) {
12
45
  })
13
46
  }
14
47
 
48
+ export function hasVisibleAssistantHistoryAfter(messages = [], messageId = '') {
49
+ const targetId = String(messageId || '')
50
+ if (!targetId) {
51
+ return false
52
+ }
53
+
54
+ const targetIndex = messages.findIndex(message => String(message?.id || '') === targetId)
55
+ if (targetIndex === -1) {
56
+ return false
57
+ }
58
+
59
+ return messages.slice(targetIndex + 1).some((message) => {
60
+ if (message?.role === 'assistant') {
61
+ return hasAssistantMessageContent(message)
62
+ }
63
+
64
+ return hasAssistantMessageContent(message)
65
+ })
66
+ }
67
+
15
68
  export const ASSISTANT_MESSAGE_WINDOW_SIZE = 60
16
69
  export const ASSISTANT_MESSAGE_WINDOW_STEP = 40
17
70
 
@@ -1,4 +1,5 @@
1
1
  import { dedupeAssistantSources } from './stream'
2
+ import { getAssistantMessageTimestamp } from './panel'
2
3
 
3
4
  export const ASSISTANT_SESSION_STORAGE_KEY = 'docsector.assistant.session.v1'
4
5
 
@@ -17,6 +18,11 @@ function cleanString (value = '', maxLength = Number.MAX_SAFE_INTEGER) {
17
18
  return String(value || '').slice(0, maxLength)
18
19
  }
19
20
 
21
+ function normalizeMessageTimestamp (message) {
22
+ const timestamp = getAssistantMessageTimestamp(message)
23
+ return timestamp ? { timestamp } : {}
24
+ }
25
+
20
26
  export function normalizeAssistantSession (session = {}) {
21
27
  const messages = (Array.isArray(session?.messages) ? session.messages : [])
22
28
  .map((message, index) => {
@@ -24,11 +30,16 @@ export function normalizeAssistantSession (session = {}) {
24
30
  const content = cleanString(message?.content, MAX_MESSAGE_CONTENT_LENGTH)
25
31
  if (!VALID_ROLES.has(role) || !content.trim()) return null
26
32
 
27
- return {
33
+ const normalizedMessage = {
28
34
  id: cleanString(message?.id || `${role}-${index + 1}`, 160),
29
35
  role,
30
36
  content
31
37
  }
38
+
39
+ return {
40
+ ...normalizedMessage,
41
+ ...normalizeMessageTimestamp(message)
42
+ }
32
43
  })
33
44
  .filter(Boolean)
34
45
  .slice(-MAX_PERSISTED_MESSAGES)
@@ -90,4 +101,4 @@ export function clearAssistantSession ({ storage = null, key = ASSISTANT_SESSION
90
101
  } catch {
91
102
  // Ignore storage failures.
92
103
  }
93
- }
104
+ }
@@ -8,7 +8,9 @@ import useAssistant from '../composables/useAssistant'
8
8
  import {
9
9
  ASSISTANT_MESSAGE_WINDOW_SIZE,
10
10
  ASSISTANT_MESSAGE_WINDOW_STEP,
11
+ formatAssistantMessageTime,
11
12
  getAssistantMessageWindow,
13
+ hasVisibleAssistantHistoryAfter,
12
14
  isAssistantThinkingState
13
15
  } from '../ai-assistant/panel'
14
16
  import DPageTokens from './DPageTokens.vue'
@@ -57,6 +59,8 @@ const scrollArea = ref(null)
57
59
  const visibleMessageLimit = ref(ASSISTANT_MESSAGE_WINDOW_SIZE)
58
60
  const copiedMessageId = ref('')
59
61
  const showScrollToBottom = ref(false)
62
+ const retryHistoryDialogOpen = ref(false)
63
+ const pendingRetryMessageId = ref('')
60
64
  let scrollFrame = 0
61
65
  let copiedMessageTimer = null
62
66
  let revealingOlderMessages = false
@@ -131,6 +135,10 @@ const hasMessageContent = (message) => {
131
135
  return String(message?.content || '').trim().length > 0
132
136
  }
133
137
 
138
+ const messageTime = (message) => {
139
+ return formatAssistantMessageTime(message, locale.value)
140
+ }
141
+
134
142
  const messageHasSources = (message) => {
135
143
  return hasSources.value && String(message?.id || '') === latestAssistantMessageId.value
136
144
  }
@@ -281,6 +289,41 @@ const messageCopyIcon = (message) => {
281
289
  return copiedMessageId.value === String(message?.id || '') ? 'check' : 'content_copy'
282
290
  }
283
291
 
292
+ const runRetryMessage = async (messageId) => {
293
+ const id = String(messageId || '')
294
+ if (!id) return
295
+ await assistant.retryFromUserMessage(id)
296
+ }
297
+
298
+ const retryMessage = async (message) => {
299
+ const id = String(message?.id || '')
300
+ if (!id || assistant.loading.value) return
301
+
302
+ if (hasVisibleAssistantHistoryAfter(assistant.messages.value, id)) {
303
+ pendingRetryMessageId.value = id
304
+ retryHistoryDialogOpen.value = true
305
+ return
306
+ }
307
+
308
+ await runRetryMessage(id)
309
+ }
310
+
311
+ const clearPendingRetryMessage = () => {
312
+ pendingRetryMessageId.value = ''
313
+ }
314
+
315
+ const cancelRetryMessage = () => {
316
+ retryHistoryDialogOpen.value = false
317
+ clearPendingRetryMessage()
318
+ }
319
+
320
+ const confirmRetryMessage = async () => {
321
+ const id = pendingRetryMessageId.value
322
+ retryHistoryDialogOpen.value = false
323
+ clearPendingRetryMessage()
324
+ await runRetryMessage(id)
325
+ }
326
+
284
327
  const submit = async (value = input.value) => {
285
328
  const prompt = String(value || '').trim()
286
329
  if (!prompt) return
@@ -419,7 +462,7 @@ onBeforeUnmount(() => {
419
462
  <q-btn
420
463
  flat dense
421
464
  text-color="primary"
422
- class="d-assistant-message__copy d-assistant-message__copy--assistant"
465
+ class="d-assistant-message__action d-assistant-message__copy d-assistant-message__copy--assistant"
423
466
  :icon="messageCopyIcon(message)"
424
467
  :aria-label="t('assistant.copyMessage')"
425
468
  @click="copyMessage(message)"
@@ -483,6 +526,11 @@ onBeforeUnmount(() => {
483
526
  </q-list>
484
527
  </q-menu>
485
528
  </q-chip>
529
+
530
+ <span
531
+ v-if="messageTime(message)"
532
+ class="d-assistant-message__timestamp"
533
+ >{{ messageTime(message) }}</span>
486
534
  </div>
487
535
 
488
536
  <div
@@ -491,15 +539,30 @@ onBeforeUnmount(() => {
491
539
  >
492
540
  <div class="d-assistant-message__hoverlayer">
493
541
  <q-btn
542
+ v-if="!assistant.loading.value"
494
543
  flat dense
495
544
  text-color="primary"
496
- class="d-assistant-message__copy d-assistant-message__copy--user"
545
+ class="d-assistant-message__action d-assistant-message__retry"
546
+ icon="refresh"
547
+ :aria-label="t('assistant.retryMessage')"
548
+ @click="retryMessage(message)"
549
+ >
550
+ <q-tooltip>{{ t('assistant.retryMessage') }}</q-tooltip>
551
+ </q-btn>
552
+ <q-btn
553
+ flat dense
554
+ text-color="primary"
555
+ class="d-assistant-message__action d-assistant-message__copy d-assistant-message__copy--user"
497
556
  :icon="messageCopyIcon(message)"
498
557
  :aria-label="t('assistant.copyMessage')"
499
558
  @click="copyMessage(message)"
500
559
  >
501
560
  <q-tooltip>{{ t('assistant.copyMessage') }}</q-tooltip>
502
561
  </q-btn>
562
+ <span
563
+ v-if="messageTime(message)"
564
+ class="d-assistant-message__timestamp"
565
+ >{{ messageTime(message) }}</span>
503
566
  </div>
504
567
  </div>
505
568
  </div>
@@ -572,6 +635,33 @@ onBeforeUnmount(() => {
572
635
  </div>
573
636
  </div>
574
637
  </footer>
638
+
639
+ <q-dialog v-model="retryHistoryDialogOpen" @hide="clearPendingRetryMessage">
640
+ <q-card class="d-assistant-retry-dialog">
641
+ <q-card-section class="d-assistant-retry-dialog__header">
642
+ <q-icon name="warning_amber" size="24px" />
643
+ <div>
644
+ <h3>{{ t('assistant.retryHistoryTitle') }}</h3>
645
+ <p>{{ t('assistant.retryHistoryMessage') }}</p>
646
+ </div>
647
+ </q-card-section>
648
+ <q-card-actions align="right" class="d-assistant-retry-dialog__actions">
649
+ <q-btn
650
+ unelevated no-caps
651
+ color="grey-7"
652
+ text-color="white"
653
+ :label="t('assistant.retryHistoryCancel')"
654
+ @click="cancelRetryMessage"
655
+ />
656
+ <q-btn
657
+ unelevated no-caps
658
+ color="primary"
659
+ :label="t('assistant.retryHistoryConfirm')"
660
+ @click="confirmRetryMessage"
661
+ />
662
+ </q-card-actions>
663
+ </q-card>
664
+ </q-dialog>
575
665
  </aside>
576
666
  </template>
577
667
 
@@ -636,7 +726,7 @@ onBeforeUnmount(() => {
636
726
  color: rgba(255, 255, 255, 0.86)
637
727
  border-color: rgba(255, 255, 255, 0.12)
638
728
 
639
- .d-assistant-message__copy
729
+ .d-assistant-message__action
640
730
  color: rgba(255, 255, 255, 0.84)
641
731
 
642
732
  .d-assistant-sources-chip__avatar
@@ -862,10 +952,12 @@ onBeforeUnmount(() => {
862
952
  color: white
863
953
  background: var(--q-primary)
864
954
 
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
955
+ &:hover .d-assistant-message__hoverlayer .d-assistant-message__action,
956
+ &:focus-within .d-assistant-message__hoverlayer .d-assistant-message__action,
957
+ .d-assistant-message__hoverlayer:hover .d-assistant-message__action,
958
+ .d-assistant-message__hoverlayer:focus-within .d-assistant-message__action,
959
+ &:hover .d-assistant-message__timestamp,
960
+ &:focus-within .d-assistant-message__timestamp
869
961
  opacity: 1
870
962
  pointer-events: auto
871
963
 
@@ -881,6 +973,10 @@ onBeforeUnmount(() => {
881
973
  .d-assistant-message__footer
882
974
  justify-content: flex-start
883
975
 
976
+ &:hover .d-assistant-message__timestamp,
977
+ &:focus-within .d-assistant-message__timestamp
978
+ opacity: 1
979
+
884
980
  &__content
885
981
  max-width: 88%
886
982
  min-width: 0
@@ -935,7 +1031,7 @@ onBeforeUnmount(() => {
935
1031
 
936
1032
  .d-assistant-sources-chip
937
1033
  flex: 0 1 auto
938
- max-width: calc(100% - 38px)
1034
+ max-width: calc(100% - 92px)
939
1035
 
940
1036
  &--user
941
1037
  width: auto
@@ -946,7 +1042,7 @@ onBeforeUnmount(() => {
946
1042
  align-items: center
947
1043
  justify-content: center
948
1044
 
949
- &__copy
1045
+ &__action
950
1046
  flex: 0 0 auto
951
1047
  width: 30px
952
1048
  height: 30px
@@ -960,25 +1056,45 @@ onBeforeUnmount(() => {
960
1056
 
961
1057
  i
962
1058
  font-size: 17px !important
963
- margin-left: 3px
1059
+ margin: 0
964
1060
 
1061
+ &__copy
965
1062
  &--assistant
966
1063
  margin-left: -2px
967
1064
 
1065
+ &__retry
1066
+ color: currentColor
1067
+
968
1068
  &__hoverlayer
969
1069
  display: flex
970
1070
  align-items: center
971
- justify-content: center
972
- width: 30px
1071
+ justify-content: flex-end
1072
+ gap: 6px
1073
+ width: auto
973
1074
  height: 30px
974
1075
  min-width: 30px
975
1076
  min-height: 30px
976
1077
 
977
- .d-assistant-message__copy
1078
+ .d-assistant-message__action
978
1079
  opacity: 0
979
1080
  pointer-events: none
980
1081
  transition: opacity 0.14s ease
981
1082
 
1083
+ &__timestamp
1084
+ flex: 0 0 auto
1085
+ margin-left: auto
1086
+ color: currentColor
1087
+ font-size: 0.72rem
1088
+ font-weight: 700
1089
+ font-variant-numeric: tabular-nums
1090
+ line-height: 30px
1091
+ opacity: 0
1092
+ pointer-events: none
1093
+ transition: opacity 0.14s ease
1094
+
1095
+ &__hoverlayer &__timestamp
1096
+ margin-left: 0
1097
+
982
1098
  &__thinking
983
1099
  display: flex
984
1100
  align-items: center
@@ -987,6 +1103,31 @@ onBeforeUnmount(() => {
987
1103
  opacity: 0.78
988
1104
  font-weight: 600
989
1105
 
1106
+ .d-assistant-retry-dialog
1107
+ width: min(360px, calc(100vw - 32px))
1108
+ border-radius: 8px
1109
+
1110
+ &__header
1111
+ display: flex
1112
+ align-items: flex-start
1113
+ gap: 12px
1114
+ padding: 18px 18px 10px
1115
+
1116
+ h3
1117
+ margin: 0 0 6px
1118
+ font-size: 1rem
1119
+ line-height: 1.3
1120
+ font-weight: 800
1121
+
1122
+ p
1123
+ margin: 0
1124
+ color: currentColor
1125
+ opacity: 0.72
1126
+ line-height: 1.45
1127
+
1128
+ &__actions
1129
+ padding: 8px 12px 14px
1130
+
990
1131
  .d-assistant-sources-chip
991
1132
  max-width: 100%
992
1133
  min-width: 0
@@ -17,10 +17,13 @@ let assistantSessionPersistencePaused = false
17
17
  const ASSISTANT_SESSION_PERSIST_DEBOUNCE = 180
18
18
 
19
19
  function createMessage (role, content = '') {
20
+ const timestamp = Date.now()
21
+
20
22
  return {
21
- id: `${role}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
23
+ id: `${role}-${timestamp}-${Math.random().toString(16).slice(2)}`,
22
24
  role,
23
- content
25
+ content,
26
+ timestamp
24
27
  }
25
28
  }
26
29
 
@@ -121,6 +124,11 @@ export default function useAssistant ({ route, locale, getContext } = {}) {
121
124
  message.content += content
122
125
  }
123
126
 
127
+ const appendAssistantPlaceholder = () => {
128
+ messages.value.push(createMessage('assistant'))
129
+ return messages.value[messages.value.length - 1]
130
+ }
131
+
124
132
  const consumeStream = async (response, assistantMessage) => {
125
133
  const reader = response.body.getReader()
126
134
  const decoder = new TextDecoder()
@@ -175,22 +183,14 @@ export default function useAssistant ({ route, locale, getContext } = {}) {
175
183
  }
176
184
  }
177
185
 
178
- const send = async (content) => {
179
- const prompt = String(content || '').trim()
180
- if (!prompt || loading.value) return
181
-
186
+ const prepareRequest = () => {
182
187
  assistantSessionPersistencePaused = true
183
188
  cancelPersistAssistantSession()
184
189
  error.value = ''
185
190
  sources.value = []
191
+ }
186
192
 
187
- const userMessage = createMessage('user', prompt)
188
- const assistantMessage = createMessage('assistant')
189
- messages.value.push(userMessage, assistantMessage)
190
- // Use the reactive proxy from the array so streamed mutations trigger
191
- // live re-renders (raw object mutations bypass Vue reactivity).
192
- const liveAssistantMessage = messages.value[messages.value.length - 1]
193
-
193
+ const requestAssistantResponse = async (liveAssistantMessage) => {
194
194
  abortController.value = new AbortController()
195
195
  loading.value = true
196
196
 
@@ -249,6 +249,36 @@ export default function useAssistant ({ route, locale, getContext } = {}) {
249
249
  }
250
250
  }
251
251
 
252
+ const send = async (content) => {
253
+ const prompt = String(content || '').trim()
254
+ if (!prompt || loading.value) return
255
+
256
+ prepareRequest()
257
+
258
+ messages.value.push(createMessage('user', prompt))
259
+ const liveAssistantMessage = appendAssistantPlaceholder()
260
+
261
+ await requestAssistantResponse(liveAssistantMessage)
262
+ }
263
+
264
+ const retryFromUserMessage = async (messageId) => {
265
+ const targetId = String(messageId || '')
266
+ if (!targetId || loading.value) return
267
+
268
+ const targetIndex = messages.value.findIndex(message => String(message?.id || '') === targetId)
269
+ const targetMessage = messages.value[targetIndex]
270
+ if (targetIndex === -1 || targetMessage?.role !== 'user' || !String(targetMessage?.content || '').trim()) {
271
+ return
272
+ }
273
+
274
+ prepareRequest()
275
+
276
+ messages.value = messages.value.slice(0, targetIndex + 1)
277
+ const liveAssistantMessage = appendAssistantPlaceholder()
278
+
279
+ await requestAssistantResponse(liveAssistantMessage)
280
+ }
281
+
252
282
  return {
253
283
  config: assistantConfig,
254
284
  messages,
@@ -257,6 +287,7 @@ export default function useAssistant ({ route, locale, getContext } = {}) {
257
287
  error,
258
288
  hasMessages,
259
289
  send,
290
+ retryFromUserMessage,
260
291
  stop,
261
292
  clear
262
293
  }
@@ -84,6 +84,11 @@ const engineDefaults = {
84
84
  sources: 'Sources',
85
85
  sourcesCount: '{count} sources',
86
86
  copyMessage: 'Copy message',
87
+ retryMessage: 'Reload message',
88
+ retryHistoryTitle: 'Reload from this message?',
89
+ retryHistoryMessage: 'Messages after this question will be removed from the conversation.',
90
+ retryHistoryConfirm: 'Reload',
91
+ retryHistoryCancel: 'Cancel',
87
92
  copied: 'Message copied',
88
93
  loadEarlier: 'Load earlier messages',
89
94
  thinking: 'Searching the docs…',
@@ -157,6 +162,11 @@ const engineDefaults = {
157
162
  sources: 'Fontes',
158
163
  sourcesCount: '{count} fontes',
159
164
  copyMessage: 'Copiar mensagem',
165
+ retryMessage: 'Reenviar mensagem',
166
+ retryHistoryTitle: 'Reenviar desta mensagem?',
167
+ retryHistoryMessage: 'As mensagens depois desta pergunta serΓ£o removidas da conversa.',
168
+ retryHistoryConfirm: 'Reenviar',
169
+ retryHistoryCancel: 'Cancelar',
160
170
  copied: 'Mensagem copiada',
161
171
  loadEarlier: 'Carregar mensagens anteriores',
162
172
  thinking: 'Consultando a documentaΓ§Γ£o…',
@@ -119,6 +119,11 @@
119
119
  sources: 'Sources',
120
120
  sourcesCount: '{count} sources',
121
121
  copyMessage: 'Copy message',
122
+ retryMessage: 'Reload message',
123
+ retryHistoryTitle: 'Reload from this message?',
124
+ retryHistoryMessage: 'Messages after this question will be removed from the conversation.',
125
+ retryHistoryConfirm: 'Reload',
126
+ retryHistoryCancel: 'Cancel',
122
127
  copied: 'Message copied',
123
128
  loadEarlier: 'Load earlier messages',
124
129
  thinking: 'Searching the docs…',
@@ -118,6 +118,11 @@
118
118
  sources: 'Fontes',
119
119
  sourcesCount: '{count} fontes',
120
120
  copyMessage: 'Copiar mensagem',
121
+ retryMessage: 'Reenviar mensagem',
122
+ retryHistoryTitle: 'Reenviar desta mensagem?',
123
+ retryHistoryMessage: 'As mensagens depois desta pergunta serΓ£o removidas da conversa.',
124
+ retryHistoryConfirm: 'Reenviar',
125
+ retryHistoryCancel: 'Cancelar',
121
126
  copied: 'Mensagem copiada',
122
127
  loadEarlier: 'Carregar mensagens anteriores',
123
128
  thinking: 'Consultando a documentaΓ§Γ£o…',