@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 +2 -1
- package/bin/docsector.js +1 -1
- package/package.json +1 -1
- package/src/ai-assistant/panel.js +15 -0
- package/src/ai-assistant/server.js +143 -0
- package/src/ai-assistant/session.js +4 -2
- package/src/ai-assistant/stream.js +64 -1
- package/src/components/DAssistantPanel.vue +420 -77
- package/src/components/DPage.vue +42 -0
- package/src/composables/useAssistant.js +68 -6
- package/src/i18n/helpers.js +6 -0
- package/src/i18n/languages/en-US.hjson +3 -0
- package/src/i18n/languages/pt-BR.hjson +3 -0
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.
|
|
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.
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
873
|
+
align-items: flex-start
|
|
593
874
|
|
|
594
875
|
.d-assistant-message__content
|
|
595
|
-
background:
|
|
596
|
-
border:
|
|
597
|
-
padding:
|
|
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)
|
package/src/components/DPage.vue
CHANGED
|
@@ -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
|
|
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],
|
|
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 (
|
|
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 (
|
|
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')
|
|
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
|
|
package/src/i18n/helpers.js
CHANGED
|
@@ -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: {
|