@jskit-ai/assistant 0.1.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/package.descriptor.mjs +284 -0
- package/package.json +31 -0
- package/src/client/components/AssistantClientElement.vue +1316 -0
- package/src/client/components/AssistantConsoleSettingsClientElement.vue +71 -0
- package/src/client/components/AssistantSettingsFormCard.vue +76 -0
- package/src/client/components/AssistantWorkspaceClientElement.vue +15 -0
- package/src/client/components/AssistantWorkspaceSettingsClientElement.vue +73 -0
- package/src/client/composables/useAssistantWorkspaceRuntime.js +789 -0
- package/src/client/index.js +12 -0
- package/src/client/lib/assistantApi.js +137 -0
- package/src/client/lib/assistantHttpClient.js +10 -0
- package/src/client/lib/markdownRenderer.js +31 -0
- package/src/client/providers/AssistantWebClientProvider.js +25 -0
- package/src/server/AssistantServiceProvider.js +179 -0
- package/src/server/actionIds.js +11 -0
- package/src/server/actions.js +191 -0
- package/src/server/diTokens.js +19 -0
- package/src/server/lib/aiClient.js +43 -0
- package/src/server/lib/ndjson.js +47 -0
- package/src/server/lib/providers/anthropicClient.js +375 -0
- package/src/server/lib/providers/common.js +158 -0
- package/src/server/lib/providers/deepSeekClient.js +22 -0
- package/src/server/lib/providers/openAiClient.js +13 -0
- package/src/server/lib/providers/openAiCompatibleClient.js +69 -0
- package/src/server/lib/resolveWorkspaceSlug.js +24 -0
- package/src/server/lib/serviceToolCatalog.js +459 -0
- package/src/server/registerRoutes.js +384 -0
- package/src/server/repositories/assistantSettingsRepository.js +100 -0
- package/src/server/repositories/conversationsRepository.js +244 -0
- package/src/server/repositories/messagesRepository.js +154 -0
- package/src/server/repositories/repositoryPersistenceUtils.js +63 -0
- package/src/server/services/assistantSettingsService.js +153 -0
- package/src/server/services/chatService.js +987 -0
- package/src/server/services/transcriptService.js +334 -0
- package/src/shared/assistantPaths.js +50 -0
- package/src/shared/assistantResource.js +323 -0
- package/src/shared/assistantSettingsResource.js +214 -0
- package/src/shared/index.js +39 -0
- package/src/shared/queryKeys.js +69 -0
- package/src/shared/settingsEvents.js +7 -0
- package/src/shared/streamEvents.js +31 -0
- package/src/shared/support/positiveInteger.js +9 -0
- package/templates/migrations/assistant_settings_initial.cjs +39 -0
- package/templates/migrations/assistant_transcripts_initial.cjs +51 -0
- package/templates/src/pages/admin/workspace/assistant/index.vue +7 -0
- package/test/aiConfigValidation.test.js +15 -0
- package/test/assistantApiSurfaceHeader.test.js +64 -0
- package/test/assistantResource.test.js +53 -0
- package/test/assistantSettingsResource.test.js +48 -0
- package/test/assistantSettingsService.test.js +133 -0
- package/test/chatService.test.js +841 -0
- package/test/descriptorSurfaceOption.test.js +35 -0
- package/test/queryKeys.test.js +41 -0
- package/test/resolveWorkspaceSlug.test.js +83 -0
- package/test/routeInputContracts.test.js +287 -0
- package/test/serviceToolCatalog.test.js +1235 -0
- package/test/transcriptService.test.js +175 -0
|
@@ -0,0 +1,1316 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section
|
|
3
|
+
ref="rootRef"
|
|
4
|
+
:class="rootClasses.concat(['d-flex', 'flex-column', 'h-100', 'ga-1'])"
|
|
5
|
+
:data-testid="uiTestIds.root"
|
|
6
|
+
tabindex="-1"
|
|
7
|
+
@focus="onRootFocus"
|
|
8
|
+
@pointerdown.capture="onRootPointerDown"
|
|
9
|
+
>
|
|
10
|
+
<v-row class="assistant-layout h-100 flex-grow-1 my-0">
|
|
11
|
+
<v-col cols="12" lg="8" class="assistant-main-col d-flex flex-column overflow-hidden">
|
|
12
|
+
<v-card rounded="lg" elevation="1" border class="assistant-main-card d-flex flex-column flex-grow-1">
|
|
13
|
+
<v-card-text class="assistant-main-card-text d-flex flex-column flex-grow-1">
|
|
14
|
+
<div
|
|
15
|
+
ref="messagesPanelRef"
|
|
16
|
+
class="messages-panel mb-3 flex-grow-1"
|
|
17
|
+
:class="[{ 'messages-panel--empty': messages.length < 1 }, uiClasses.messagesPanel]"
|
|
18
|
+
:data-testid="uiTestIds.messagesPanel"
|
|
19
|
+
@scroll.passive="handleMessagesPanelScroll"
|
|
20
|
+
>
|
|
21
|
+
<div v-if="messages.length < 1" class="messages-empty-state">
|
|
22
|
+
<slot name="empty-state" :state="state" :actions="actions">{{ copyText.emptyState }}</slot>
|
|
23
|
+
</div>
|
|
24
|
+
<div
|
|
25
|
+
v-for="message in messages"
|
|
26
|
+
:key="message.id"
|
|
27
|
+
class="message-row d-flex align-end ga-2 mb-3"
|
|
28
|
+
:class="[`message-row--${message.role}`, { 'flex-row-reverse': message.role === 'user' }]"
|
|
29
|
+
>
|
|
30
|
+
<v-avatar v-if="message.role === 'user'" size="36" class="message-avatar message-avatar--user">
|
|
31
|
+
<v-img v-if="currentUserAvatarUrl" :src="currentUserAvatarUrl" cover />
|
|
32
|
+
<span v-else class="message-avatar-initials">{{ currentUserInitials }}</span>
|
|
33
|
+
</v-avatar>
|
|
34
|
+
<v-avatar v-else size="36" class="message-avatar message-avatar--assistant" aria-hidden="true" />
|
|
35
|
+
<div class="message-body d-flex flex-column">
|
|
36
|
+
<div class="message-meta mb-1 text-caption text-medium-emphasis">
|
|
37
|
+
<span class="message-author">{{ messageAuthorLabel(message) }}</span>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="message-bubble">
|
|
40
|
+
<div
|
|
41
|
+
v-if="showAssistantTypingIndicator(message)"
|
|
42
|
+
class="message-typing d-inline-flex align-center ga-1"
|
|
43
|
+
aria-label="Assistant is typing"
|
|
44
|
+
>
|
|
45
|
+
<span class="message-typing-dot" />
|
|
46
|
+
<span class="message-typing-dot" />
|
|
47
|
+
<span class="message-typing-dot" />
|
|
48
|
+
</div>
|
|
49
|
+
<div
|
|
50
|
+
v-else-if="isAssistantChatMessage(message)"
|
|
51
|
+
class="message-text message-text--markdown text-body-2"
|
|
52
|
+
v-html="assistantMessageHtml(message)"
|
|
53
|
+
/>
|
|
54
|
+
<div v-else class="message-text text-body-2">{{ message.text }}</div>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<div class="assistant-composer-shell d-grid" :class="uiClasses.composer">
|
|
61
|
+
<div class="assistant-composer-row d-flex align-end ga-2">
|
|
62
|
+
<v-textarea
|
|
63
|
+
ref="composerRef"
|
|
64
|
+
v-model="input"
|
|
65
|
+
class="assistant-composer-textarea flex-grow-1"
|
|
66
|
+
:placeholder="copyText.messagePlaceholder"
|
|
67
|
+
:aria-label="copyText.messagePlaceholder"
|
|
68
|
+
rows="1"
|
|
69
|
+
max-rows="4"
|
|
70
|
+
variant="solo-filled"
|
|
71
|
+
density="comfortable"
|
|
72
|
+
auto-grow
|
|
73
|
+
hide-details="auto"
|
|
74
|
+
:disabled="isRestoringConversation"
|
|
75
|
+
@keydown="onHandleInputKeydown"
|
|
76
|
+
/>
|
|
77
|
+
|
|
78
|
+
<v-btn
|
|
79
|
+
:color="isStreaming ? 'error' : 'primary'"
|
|
80
|
+
:class="{ 'assistant-stop-button': isStreaming }"
|
|
81
|
+
:disabled="isStreaming ? false : !canSend"
|
|
82
|
+
:data-testid="uiTestIds.sendButton"
|
|
83
|
+
@click="onSendMessage"
|
|
84
|
+
>
|
|
85
|
+
{{ isStreaming ? copyText.stop : copyText.send }}
|
|
86
|
+
</v-btn>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<slot name="composer-extra" :state="state" :actions="actions" />
|
|
90
|
+
|
|
91
|
+
<div v-if="resolvedFeatures.composerActions" class="assistant-actions d-flex ga-2 flex-wrap mt-2">
|
|
92
|
+
<v-btn
|
|
93
|
+
v-if="resolvedFeatures.mobilePicker"
|
|
94
|
+
class="d-lg-none"
|
|
95
|
+
variant="tonal"
|
|
96
|
+
:disabled="isStreaming || isRestoringConversation"
|
|
97
|
+
@click="onConversationPickerOpen"
|
|
98
|
+
>
|
|
99
|
+
{{ copyText.conversations }}
|
|
100
|
+
</v-btn>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
</v-card-text>
|
|
104
|
+
</v-card>
|
|
105
|
+
</v-col>
|
|
106
|
+
|
|
107
|
+
<v-col cols="12" lg="4" class="assistant-side-col d-flex flex-column overflow-hidden">
|
|
108
|
+
<v-card
|
|
109
|
+
v-if="resolvedFeatures.historyPanel"
|
|
110
|
+
rounded="lg"
|
|
111
|
+
elevation="1"
|
|
112
|
+
border
|
|
113
|
+
class="d-none d-lg-flex flex-column mb-3 assistant-history-card overflow-hidden"
|
|
114
|
+
>
|
|
115
|
+
<v-card-item>
|
|
116
|
+
<v-card-title class="text-subtitle-2 font-weight-bold">{{ copyText.conversationHistory }}</v-card-title>
|
|
117
|
+
<template #append>
|
|
118
|
+
<v-btn
|
|
119
|
+
variant="text"
|
|
120
|
+
size="small"
|
|
121
|
+
:disabled="isStreaming || isRestoringConversation || conversationHistoryLoading"
|
|
122
|
+
@click="refreshConversationHistory"
|
|
123
|
+
>
|
|
124
|
+
{{ copyText.refresh }}
|
|
125
|
+
</v-btn>
|
|
126
|
+
</template>
|
|
127
|
+
<slot name="history-header-extra" :state="state" :actions="actions" />
|
|
128
|
+
</v-card-item>
|
|
129
|
+
<v-divider />
|
|
130
|
+
<v-card-text class="pt-2 assistant-history-card-text d-flex flex-column flex-grow-1">
|
|
131
|
+
<v-btn
|
|
132
|
+
block
|
|
133
|
+
variant="outlined"
|
|
134
|
+
color="primary"
|
|
135
|
+
class="mb-2 assistant-history-start-button"
|
|
136
|
+
:disabled="!canStartNewConversation"
|
|
137
|
+
@click="onStartNewConversation"
|
|
138
|
+
>
|
|
139
|
+
{{ copyText.startNewConversation }}
|
|
140
|
+
</v-btn>
|
|
141
|
+
<div v-if="conversationHistoryError" class="text-caption text-error mb-2">
|
|
142
|
+
{{ conversationHistoryError }}
|
|
143
|
+
</div>
|
|
144
|
+
<v-list density="compact" class="assistant-history-list flex-grow-1 overflow-y-auto">
|
|
145
|
+
<v-list-item v-if="conversationHistory.length < 1" :title="copyText.noConversations" />
|
|
146
|
+
<v-list-item
|
|
147
|
+
v-for="conversation in conversationHistory"
|
|
148
|
+
:key="conversation.id"
|
|
149
|
+
:title="conversationDisplayTitle(conversation)"
|
|
150
|
+
:subtitle="conversationSubtitle(conversation)"
|
|
151
|
+
:active="isActiveConversation(conversation)"
|
|
152
|
+
:disabled="isStreaming || isRestoringConversation"
|
|
153
|
+
@click="onSelectConversation(conversation)"
|
|
154
|
+
/>
|
|
155
|
+
</v-list>
|
|
156
|
+
<v-btn
|
|
157
|
+
v-if="conversationHistoryHasMore"
|
|
158
|
+
block
|
|
159
|
+
variant="text"
|
|
160
|
+
size="small"
|
|
161
|
+
class="mt-2"
|
|
162
|
+
:loading="conversationHistoryLoadingMore"
|
|
163
|
+
:disabled="isStreaming || isRestoringConversation || conversationHistoryLoadingMore"
|
|
164
|
+
@click="onLoadMoreConversations"
|
|
165
|
+
>
|
|
166
|
+
{{ copyText.loadOlderConversations }}
|
|
167
|
+
</v-btn>
|
|
168
|
+
</v-card-text>
|
|
169
|
+
</v-card>
|
|
170
|
+
|
|
171
|
+
<v-card
|
|
172
|
+
v-if="resolvedFeatures.toolsPanel"
|
|
173
|
+
rounded="lg"
|
|
174
|
+
elevation="1"
|
|
175
|
+
border
|
|
176
|
+
class="assistant-tools-card d-flex flex-column flex-grow-1 overflow-hidden"
|
|
177
|
+
>
|
|
178
|
+
<v-card-item>
|
|
179
|
+
<v-card-title class="text-subtitle-2 font-weight-bold">{{ copyText.toolTimeline }}</v-card-title>
|
|
180
|
+
<slot name="tools-header-extra" :state="state" :actions="actions" />
|
|
181
|
+
</v-card-item>
|
|
182
|
+
<v-divider />
|
|
183
|
+
<v-list density="compact" class="assistant-tools-list flex-grow-1 overflow-y-auto">
|
|
184
|
+
<v-list-item v-if="pendingToolEvents.length < 1" :title="copyText.noToolEvents" />
|
|
185
|
+
<v-list-item
|
|
186
|
+
v-for="toolEvent in pendingToolEvents"
|
|
187
|
+
:key="toolEvent.id"
|
|
188
|
+
:title="toolEvent.name"
|
|
189
|
+
:subtitle="toolEvent.status"
|
|
190
|
+
/>
|
|
191
|
+
</v-list>
|
|
192
|
+
</v-card>
|
|
193
|
+
</v-col>
|
|
194
|
+
</v-row>
|
|
195
|
+
|
|
196
|
+
<v-bottom-sheet v-if="resolvedFeatures.mobilePicker" v-model="conversationPickerOpen">
|
|
197
|
+
<v-card rounded="t-lg" border>
|
|
198
|
+
<v-card-item>
|
|
199
|
+
<v-card-title class="text-subtitle-1 font-weight-bold">{{ copyText.conversations }}</v-card-title>
|
|
200
|
+
</v-card-item>
|
|
201
|
+
<v-divider />
|
|
202
|
+
<v-card-text class="pt-3">
|
|
203
|
+
<v-btn
|
|
204
|
+
block
|
|
205
|
+
variant="outlined"
|
|
206
|
+
color="primary"
|
|
207
|
+
class="mb-2"
|
|
208
|
+
:disabled="!canStartNewConversation"
|
|
209
|
+
@click="startNewConversationFromPicker"
|
|
210
|
+
>
|
|
211
|
+
{{ copyText.startNewConversation }}
|
|
212
|
+
</v-btn>
|
|
213
|
+
<div v-if="conversationHistoryError" class="text-caption text-error mb-2">{{ conversationHistoryError }}</div>
|
|
214
|
+
<v-list density="compact">
|
|
215
|
+
<v-list-item v-if="conversationHistory.length < 1" :title="copyText.noConversations" />
|
|
216
|
+
<v-list-item
|
|
217
|
+
v-for="conversation in conversationHistory"
|
|
218
|
+
:key="conversation.id"
|
|
219
|
+
:title="conversationDisplayTitle(conversation)"
|
|
220
|
+
:subtitle="conversationSubtitle(conversation)"
|
|
221
|
+
:active="isActiveConversation(conversation)"
|
|
222
|
+
:disabled="isStreaming || isRestoringConversation"
|
|
223
|
+
@click="selectConversationFromPicker(conversation)"
|
|
224
|
+
/>
|
|
225
|
+
</v-list>
|
|
226
|
+
<v-btn
|
|
227
|
+
v-if="conversationHistoryHasMore"
|
|
228
|
+
block
|
|
229
|
+
variant="text"
|
|
230
|
+
size="small"
|
|
231
|
+
class="mt-2"
|
|
232
|
+
:loading="conversationHistoryLoadingMore"
|
|
233
|
+
:disabled="isStreaming || isRestoringConversation || conversationHistoryLoadingMore"
|
|
234
|
+
@click="onLoadMoreConversations"
|
|
235
|
+
>
|
|
236
|
+
{{ copyText.loadOlderConversations }}
|
|
237
|
+
</v-btn>
|
|
238
|
+
</v-card-text>
|
|
239
|
+
</v-card>
|
|
240
|
+
</v-bottom-sheet>
|
|
241
|
+
|
|
242
|
+
<slot name="footer-extra" :state="state" :actions="actions" />
|
|
243
|
+
</section>
|
|
244
|
+
</template>
|
|
245
|
+
|
|
246
|
+
<script setup>
|
|
247
|
+
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch } from "vue";
|
|
248
|
+
import { createComponentInteractionEmitter } from "@jskit-ai/kernel/client";
|
|
249
|
+
import {
|
|
250
|
+
normalizeObject,
|
|
251
|
+
normalizeOneOf
|
|
252
|
+
} from "@jskit-ai/kernel/shared/support/normalize";
|
|
253
|
+
import { renderMarkdownToSafeHtml } from "../lib/markdownRenderer.js";
|
|
254
|
+
|
|
255
|
+
const DEFAULT_COPY = Object.freeze({
|
|
256
|
+
emptyState: "I am here to help",
|
|
257
|
+
assistantLabel: "Assistant",
|
|
258
|
+
systemLabel: "System",
|
|
259
|
+
messagePlaceholder: "Message",
|
|
260
|
+
stop: "STOP",
|
|
261
|
+
send: "Send",
|
|
262
|
+
conversations: "Conversations",
|
|
263
|
+
conversationHistory: "Conversation History",
|
|
264
|
+
refresh: "Refresh",
|
|
265
|
+
startNewConversation: "Start new conversation",
|
|
266
|
+
loadOlderConversations: "Load older conversations",
|
|
267
|
+
noConversations: "No conversations yet.",
|
|
268
|
+
unknownConversationTitle: "New conversation",
|
|
269
|
+
unknownUser: "Unknown user",
|
|
270
|
+
unknownDate: "unknown",
|
|
271
|
+
toolTimeline: "Tool Timeline",
|
|
272
|
+
noToolEvents: "No tool events yet."
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const props = defineProps({
|
|
276
|
+
meta: {
|
|
277
|
+
type: Object,
|
|
278
|
+
required: true
|
|
279
|
+
},
|
|
280
|
+
state: {
|
|
281
|
+
type: Object,
|
|
282
|
+
required: true
|
|
283
|
+
},
|
|
284
|
+
actions: {
|
|
285
|
+
type: Object,
|
|
286
|
+
required: true
|
|
287
|
+
},
|
|
288
|
+
viewer: {
|
|
289
|
+
type: Object,
|
|
290
|
+
default: () => ({})
|
|
291
|
+
},
|
|
292
|
+
copy: {
|
|
293
|
+
type: Object,
|
|
294
|
+
default: () => ({})
|
|
295
|
+
},
|
|
296
|
+
variant: {
|
|
297
|
+
type: Object,
|
|
298
|
+
default: () => ({})
|
|
299
|
+
},
|
|
300
|
+
features: {
|
|
301
|
+
type: Object,
|
|
302
|
+
default: () => ({})
|
|
303
|
+
},
|
|
304
|
+
ui: {
|
|
305
|
+
type: Object,
|
|
306
|
+
default: () => ({})
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const emit = defineEmits([
|
|
311
|
+
"action:started",
|
|
312
|
+
"action:succeeded",
|
|
313
|
+
"action:failed",
|
|
314
|
+
"interaction",
|
|
315
|
+
"conversation:start",
|
|
316
|
+
"conversation:select",
|
|
317
|
+
"message:send",
|
|
318
|
+
"stream:cancel"
|
|
319
|
+
]);
|
|
320
|
+
const {
|
|
321
|
+
emitInteraction,
|
|
322
|
+
invokeAction
|
|
323
|
+
} = createComponentInteractionEmitter(emit);
|
|
324
|
+
|
|
325
|
+
const SCROLL_BOTTOM_THRESHOLD_PX = 30;
|
|
326
|
+
const MIN_VIEWPORT_HEIGHT_PX = 360;
|
|
327
|
+
const VIEWPORT_BOTTOM_GUTTER_PX = 12;
|
|
328
|
+
const ROOT_FOCUS_POINTER_GUARD_MS = 200;
|
|
329
|
+
const MESSAGE_MARKDOWN_RENDER_THROTTLE_MS = 40;
|
|
330
|
+
|
|
331
|
+
const meta = props.meta;
|
|
332
|
+
const state = props.state;
|
|
333
|
+
const actions = props.actions;
|
|
334
|
+
|
|
335
|
+
const messages = state.messages;
|
|
336
|
+
const input = state.input;
|
|
337
|
+
const isStreaming = state.isStreaming;
|
|
338
|
+
const isRestoringConversation = state.isRestoringConversation;
|
|
339
|
+
const pendingToolEvents = state.pendingToolEvents;
|
|
340
|
+
const conversationId = state.conversationId;
|
|
341
|
+
const conversationHistory = state.conversationHistory;
|
|
342
|
+
const conversationHistoryLoading = state.conversationHistoryLoading;
|
|
343
|
+
const conversationHistoryLoadingMore = state.conversationHistoryLoadingMore;
|
|
344
|
+
const conversationHistoryHasMore = state.conversationHistoryHasMore;
|
|
345
|
+
const conversationHistoryError = state.conversationHistoryError;
|
|
346
|
+
const isAdminSurface = state.isAdminSurface;
|
|
347
|
+
const canSend = state.canSend;
|
|
348
|
+
const canStartNewConversation = state.canStartNewConversation;
|
|
349
|
+
|
|
350
|
+
const sendMessage = actions.sendMessage;
|
|
351
|
+
const handleInputKeydown = actions.handleInputKeydown;
|
|
352
|
+
const cancelStream = actions.cancelStream;
|
|
353
|
+
const startNewConversation = actions.startNewConversation;
|
|
354
|
+
const selectConversation = actions.selectConversation;
|
|
355
|
+
const refreshConversationHistory = actions.refreshConversationHistory;
|
|
356
|
+
const loadMoreConversationHistory = actions.loadMoreConversationHistory;
|
|
357
|
+
|
|
358
|
+
const copyText = computed(() => ({
|
|
359
|
+
...DEFAULT_COPY,
|
|
360
|
+
...normalizeObject(props.copy)
|
|
361
|
+
}));
|
|
362
|
+
|
|
363
|
+
const resolvedVariant = computed(() => {
|
|
364
|
+
const variant = normalizeObject(props.variant);
|
|
365
|
+
return {
|
|
366
|
+
layout: normalizeOneOf(variant.layout, ["compact", "comfortable"], "comfortable"),
|
|
367
|
+
surface: normalizeOneOf(variant.surface, ["plain", "carded"], "carded"),
|
|
368
|
+
density: normalizeOneOf(variant.density, ["compact", "comfortable"], "comfortable"),
|
|
369
|
+
tone: normalizeOneOf(variant.tone, ["neutral", "emphasized"], "neutral")
|
|
370
|
+
};
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const resolvedFeatures = computed(() => {
|
|
374
|
+
const features = normalizeObject(props.features);
|
|
375
|
+
return {
|
|
376
|
+
historyPanel: features.historyPanel !== false,
|
|
377
|
+
toolsPanel: features.toolsPanel !== false,
|
|
378
|
+
mobilePicker: features.mobilePicker !== false,
|
|
379
|
+
composerActions: features.composerActions !== false
|
|
380
|
+
};
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
const uiClasses = computed(() => {
|
|
384
|
+
const classes = normalizeObject(normalizeObject(props.ui).classes);
|
|
385
|
+
return {
|
|
386
|
+
root: String(classes.root || "").trim(),
|
|
387
|
+
messagesPanel: String(classes.messagesPanel || "").trim(),
|
|
388
|
+
composer: String(classes.composer || "").trim()
|
|
389
|
+
};
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
const uiTestIds = computed(() => {
|
|
393
|
+
const testIds = normalizeObject(normalizeObject(props.ui).testIds);
|
|
394
|
+
return {
|
|
395
|
+
root: String(testIds.root || "assistant-client-element"),
|
|
396
|
+
messagesPanel: String(testIds.messagesPanel || "assistant-messages-panel"),
|
|
397
|
+
sendButton: String(testIds.sendButton || "assistant-send-button")
|
|
398
|
+
};
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
const rootClasses = computed(() => {
|
|
402
|
+
const classes = [
|
|
403
|
+
"assistant-view",
|
|
404
|
+
"assistant-client-element",
|
|
405
|
+
`assistant-client-element--layout-${resolvedVariant.value.layout}`,
|
|
406
|
+
`assistant-client-element--surface-${resolvedVariant.value.surface}`,
|
|
407
|
+
`assistant-client-element--density-${resolvedVariant.value.density}`,
|
|
408
|
+
`assistant-client-element--tone-${resolvedVariant.value.tone}`
|
|
409
|
+
];
|
|
410
|
+
if (uiClasses.value.root) {
|
|
411
|
+
classes.push(uiClasses.value.root);
|
|
412
|
+
}
|
|
413
|
+
return classes;
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
function normalizeText(value) {
|
|
417
|
+
return String(value || "").trim();
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function formatConversationStartedAt(value) {
|
|
421
|
+
const formatter = meta?.formatConversationStartedAt;
|
|
422
|
+
if (typeof formatter === "function") {
|
|
423
|
+
return formatter(value);
|
|
424
|
+
}
|
|
425
|
+
return copyText.value.unknownDate;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function normalizeConversationStatus(value) {
|
|
429
|
+
const normalizer = meta?.normalizeConversationStatus;
|
|
430
|
+
if (typeof normalizer === "function") {
|
|
431
|
+
return normalizer(value);
|
|
432
|
+
}
|
|
433
|
+
return normalizeText(value).toLowerCase() || "unknown";
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
const viewer = computed(() => {
|
|
437
|
+
const source = normalizeObject(props.viewer);
|
|
438
|
+
return {
|
|
439
|
+
displayName: normalizeText(source.displayName) || "You",
|
|
440
|
+
avatarUrl: normalizeText(source.avatarUrl)
|
|
441
|
+
};
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const conversationPickerOpen = ref(false);
|
|
445
|
+
const rootRef = ref(null);
|
|
446
|
+
const messagesPanelRef = ref(null);
|
|
447
|
+
const composerRef = ref(null);
|
|
448
|
+
const shouldAutoScrollToBottom = ref(true);
|
|
449
|
+
const lastRootPointerDownAt = ref(0);
|
|
450
|
+
const renderedAssistantMessagesById = shallowRef(Object.freeze({}));
|
|
451
|
+
|
|
452
|
+
const assistantMarkdownCacheById = new Map();
|
|
453
|
+
let markdownRenderTimeoutId = null;
|
|
454
|
+
|
|
455
|
+
const currentUserScreenName = computed(() => viewer.value.displayName);
|
|
456
|
+
const currentUserAvatarUrl = computed(() => viewer.value.avatarUrl);
|
|
457
|
+
const currentUserInitials = computed(() => {
|
|
458
|
+
const raw = currentUserScreenName.value || "You";
|
|
459
|
+
const parts = raw
|
|
460
|
+
.split(/\s+/)
|
|
461
|
+
.map((part) => normalizeText(part))
|
|
462
|
+
.filter(Boolean);
|
|
463
|
+
|
|
464
|
+
if (parts.length < 1) {
|
|
465
|
+
return "Y";
|
|
466
|
+
}
|
|
467
|
+
if (parts.length === 1) {
|
|
468
|
+
return parts[0].slice(0, 2).toUpperCase();
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
function messageAuthorLabel(message) {
|
|
475
|
+
const role = normalizeText(message?.role).toLowerCase();
|
|
476
|
+
if (role === "user") {
|
|
477
|
+
return currentUserScreenName.value;
|
|
478
|
+
}
|
|
479
|
+
if (role === "assistant") {
|
|
480
|
+
return copyText.value.assistantLabel;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return copyText.value.systemLabel;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function showAssistantTypingIndicator(message) {
|
|
487
|
+
return (
|
|
488
|
+
normalizeText(message?.role).toLowerCase() === "assistant" &&
|
|
489
|
+
normalizeText(message?.status).toLowerCase() === "streaming" &&
|
|
490
|
+
normalizeText(message?.text).length < 1
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function isAssistantChatMessage(message) {
|
|
495
|
+
return (
|
|
496
|
+
normalizeText(message?.role).toLowerCase() === "assistant" &&
|
|
497
|
+
normalizeText(message?.kind).toLowerCase() === "chat"
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function keepMessagesPanelPinnedToBottom({ behavior = "auto" } = {}) {
|
|
502
|
+
if (!shouldAutoScrollToBottom.value) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
scrollMessagesToBottom({
|
|
507
|
+
behavior
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
void nextTick(() => {
|
|
511
|
+
if (!shouldAutoScrollToBottom.value) {
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
scrollMessagesToBottom({
|
|
515
|
+
behavior
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
if (typeof window === "undefined" || typeof window.requestAnimationFrame !== "function") {
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
window.requestAnimationFrame(() => {
|
|
523
|
+
if (!shouldAutoScrollToBottom.value) {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
scrollMessagesToBottom({
|
|
527
|
+
behavior
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function renderAssistantMarkdownSnapshot() {
|
|
534
|
+
const entries = Array.isArray(messages.value) ? messages.value : [];
|
|
535
|
+
const nextRenderedById = {};
|
|
536
|
+
const activeMessageIds = new Set();
|
|
537
|
+
|
|
538
|
+
for (const message of entries) {
|
|
539
|
+
if (!isAssistantChatMessage(message)) {
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const messageId = String(message?.id || "");
|
|
544
|
+
if (!messageId) {
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
activeMessageIds.add(messageId);
|
|
549
|
+
const text = String(message?.text || "");
|
|
550
|
+
const cached = assistantMarkdownCacheById.get(messageId);
|
|
551
|
+
const cacheKey = text;
|
|
552
|
+
|
|
553
|
+
if (cached && cached.cacheKey === cacheKey) {
|
|
554
|
+
nextRenderedById[messageId] = cached.html;
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const renderedHtml = renderMarkdownToSafeHtml(text);
|
|
559
|
+
assistantMarkdownCacheById.set(messageId, {
|
|
560
|
+
cacheKey,
|
|
561
|
+
html: renderedHtml
|
|
562
|
+
});
|
|
563
|
+
nextRenderedById[messageId] = renderedHtml;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
for (const [messageId] of assistantMarkdownCacheById) {
|
|
567
|
+
if (!activeMessageIds.has(messageId)) {
|
|
568
|
+
assistantMarkdownCacheById.delete(messageId);
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
renderedAssistantMessagesById.value = Object.freeze(nextRenderedById);
|
|
573
|
+
keepMessagesPanelPinnedToBottom({
|
|
574
|
+
behavior: "auto"
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function scheduleAssistantMarkdownRender({ immediate = false } = {}) {
|
|
579
|
+
if (immediate) {
|
|
580
|
+
if (markdownRenderTimeoutId) {
|
|
581
|
+
clearTimeout(markdownRenderTimeoutId);
|
|
582
|
+
markdownRenderTimeoutId = null;
|
|
583
|
+
}
|
|
584
|
+
renderAssistantMarkdownSnapshot();
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (markdownRenderTimeoutId) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
markdownRenderTimeoutId = setTimeout(() => {
|
|
593
|
+
markdownRenderTimeoutId = null;
|
|
594
|
+
renderAssistantMarkdownSnapshot();
|
|
595
|
+
}, MESSAGE_MARKDOWN_RENDER_THROTTLE_MS);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function assistantMessageHtml(message) {
|
|
599
|
+
const messageId = String(message?.id || "");
|
|
600
|
+
if (!messageId) {
|
|
601
|
+
return "";
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
return String(renderedAssistantMessagesById.value[messageId] || "");
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function resolveConversationActorLabel(conversation) {
|
|
608
|
+
const displayName = normalizeText(conversation?.createdByUserDisplayName);
|
|
609
|
+
if (displayName) {
|
|
610
|
+
return displayName;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
const email = normalizeText(conversation?.createdByUserEmail);
|
|
614
|
+
if (email) {
|
|
615
|
+
return email;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const userId = Number(conversation?.createdByUserId);
|
|
619
|
+
if (Number.isInteger(userId) && userId > 0) {
|
|
620
|
+
return `User #${userId}`;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return copyText.value.unknownUser;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function conversationSubtitle(conversation) {
|
|
627
|
+
const id = Number(conversation?.id) || 0;
|
|
628
|
+
const status = normalizeConversationStatus(conversation?.status);
|
|
629
|
+
const startedAt = formatConversationStartedAt(conversation?.startedAt);
|
|
630
|
+
const messageCount = Number(conversation?.messageCount || 0);
|
|
631
|
+
const actorSegment = isAdminSurface.value ? ` • ${resolveConversationActorLabel(conversation)}` : "";
|
|
632
|
+
return `#${id} • ${status} • ${startedAt} • ${messageCount} messages${actorSegment}`;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function conversationDisplayTitle(conversation) {
|
|
636
|
+
const explicitTitle = normalizeText(conversation?.title);
|
|
637
|
+
if (explicitTitle) {
|
|
638
|
+
return explicitTitle;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return copyText.value.unknownConversationTitle;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function isActiveConversation(conversation) {
|
|
645
|
+
return String(conversation?.id || "") === String(conversationId.value || "");
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
async function onSelectConversation(conversation) {
|
|
649
|
+
const payload = {
|
|
650
|
+
conversationId: String(conversation?.id || "")
|
|
651
|
+
};
|
|
652
|
+
emit("conversation:select", payload);
|
|
653
|
+
emitInteraction("conversation:select", payload);
|
|
654
|
+
await invokeAction("selectConversation", payload, () => selectConversation(conversation));
|
|
655
|
+
await requestComposerFocus({
|
|
656
|
+
selectText: true
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function selectConversationFromPicker(conversation) {
|
|
661
|
+
await onSelectConversation(conversation);
|
|
662
|
+
conversationPickerOpen.value = false;
|
|
663
|
+
await requestComposerFocus({
|
|
664
|
+
selectText: true
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
async function onStartNewConversation() {
|
|
669
|
+
emit("conversation:start", {
|
|
670
|
+
source: "history"
|
|
671
|
+
});
|
|
672
|
+
emitInteraction("conversation:start", {
|
|
673
|
+
source: "history"
|
|
674
|
+
});
|
|
675
|
+
await invokeAction("startNewConversation", {}, startNewConversation);
|
|
676
|
+
await requestComposerFocus({
|
|
677
|
+
selectText: true
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async function startNewConversationFromPicker() {
|
|
682
|
+
await onStartNewConversation();
|
|
683
|
+
conversationPickerOpen.value = false;
|
|
684
|
+
await requestComposerFocus({
|
|
685
|
+
selectText: true
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function onLoadMoreConversations() {
|
|
690
|
+
if (typeof loadMoreConversationHistory !== "function") {
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
emitInteraction("conversation:load-more");
|
|
695
|
+
await invokeAction("loadMoreConversationHistory", {}, loadMoreConversationHistory);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async function onSendMessage() {
|
|
699
|
+
if (isStreaming.value) {
|
|
700
|
+
emit("stream:cancel", {
|
|
701
|
+
source: "composer"
|
|
702
|
+
});
|
|
703
|
+
emitInteraction("stream:cancel", {
|
|
704
|
+
source: "composer"
|
|
705
|
+
});
|
|
706
|
+
await invokeAction("cancelStream", {}, cancelStream);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const payload = {
|
|
711
|
+
textLength: String(input.value || "").length
|
|
712
|
+
};
|
|
713
|
+
emit("message:send", payload);
|
|
714
|
+
emitInteraction("message:send", payload);
|
|
715
|
+
await invokeAction("sendMessage", payload, sendMessage);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function onConversationPickerOpen() {
|
|
719
|
+
conversationPickerOpen.value = true;
|
|
720
|
+
emitInteraction("conversation:picker-open");
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function onHandleInputKeydown(event) {
|
|
724
|
+
emitInteraction("composer:keydown", {
|
|
725
|
+
key: String(event?.key || "")
|
|
726
|
+
});
|
|
727
|
+
if (typeof handleInputKeydown === "function") {
|
|
728
|
+
handleInputKeydown(event);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function resolveComposerTextarea() {
|
|
733
|
+
const composer = composerRef.value;
|
|
734
|
+
if (!composer || !(composer.$el instanceof HTMLElement)) {
|
|
735
|
+
return null;
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
const textarea = composer.$el.querySelector("textarea");
|
|
739
|
+
if (!(textarea instanceof HTMLTextAreaElement)) {
|
|
740
|
+
return null;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
return textarea;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function focusComposer(selectText = false) {
|
|
747
|
+
const composer = composerRef.value;
|
|
748
|
+
if (!composer) {
|
|
749
|
+
return false;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (typeof composer.focus === "function") {
|
|
753
|
+
composer.focus();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const textarea = resolveComposerTextarea();
|
|
757
|
+
if (!textarea || textarea.disabled) {
|
|
758
|
+
return false;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
if (selectText && typeof textarea.select === "function") {
|
|
762
|
+
textarea.select();
|
|
763
|
+
} else if (typeof textarea.focus === "function") {
|
|
764
|
+
textarea.focus();
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
return document.activeElement === textarea;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
async function focusComposerWithRetry(selectText = false) {
|
|
771
|
+
if (focusComposer(selectText)) {
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
await nextTick();
|
|
776
|
+
if (focusComposer(selectText)) {
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
await new Promise((resolve) => {
|
|
781
|
+
if (typeof window !== "undefined" && typeof window.requestAnimationFrame === "function") {
|
|
782
|
+
window.requestAnimationFrame(() => resolve());
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
setTimeout(resolve, 0);
|
|
786
|
+
});
|
|
787
|
+
|
|
788
|
+
if (focusComposer(selectText)) {
|
|
789
|
+
return;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
await new Promise((resolve) => {
|
|
793
|
+
setTimeout(resolve, 40);
|
|
794
|
+
});
|
|
795
|
+
focusComposer(selectText);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
async function requestComposerFocus({ selectText = false } = {}) {
|
|
799
|
+
await focusComposerWithRetry(selectText);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function onRootFocus(event) {
|
|
803
|
+
const rootElement = rootRef.value;
|
|
804
|
+
if (!(rootElement instanceof HTMLElement)) {
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
if (event?.target !== rootElement) {
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (Date.now() - Number(lastRootPointerDownAt.value || 0) < ROOT_FOCUS_POINTER_GUARD_MS) {
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
void requestComposerFocus();
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
function onRootPointerDown() {
|
|
818
|
+
lastRootPointerDownAt.value = Date.now();
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function normalizeScrollValue(value) {
|
|
822
|
+
const parsed = Number(value);
|
|
823
|
+
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
824
|
+
return 0;
|
|
825
|
+
}
|
|
826
|
+
return parsed;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function distanceFromBottom(element) {
|
|
830
|
+
if (!element) {
|
|
831
|
+
return Number.POSITIVE_INFINITY;
|
|
832
|
+
}
|
|
833
|
+
const scrollTop = normalizeScrollValue(element.scrollTop);
|
|
834
|
+
const scrollHeight = normalizeScrollValue(element.scrollHeight);
|
|
835
|
+
const clientHeight = normalizeScrollValue(element.clientHeight);
|
|
836
|
+
return Math.max(0, scrollHeight - (scrollTop + clientHeight));
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function isScrolledToBottom(element) {
|
|
840
|
+
return distanceFromBottom(element) <= SCROLL_BOTTOM_THRESHOLD_PX;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function scrollMessagesToBottom({ behavior = "auto" } = {}) {
|
|
844
|
+
const panel = messagesPanelRef.value;
|
|
845
|
+
if (!panel) {
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
if (behavior === "auto") {
|
|
850
|
+
panel.scrollTop = panel.scrollHeight;
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const targetTop = Math.max(0, normalizeScrollValue(panel.scrollHeight) - normalizeScrollValue(panel.clientHeight));
|
|
855
|
+
panel.scrollTo({
|
|
856
|
+
top: targetTop,
|
|
857
|
+
behavior
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
function handleMessagesPanelScroll() {
|
|
862
|
+
const panel = messagesPanelRef.value;
|
|
863
|
+
if (!panel) {
|
|
864
|
+
shouldAutoScrollToBottom.value = true;
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
shouldAutoScrollToBottom.value = isScrolledToBottom(panel);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
function syncViewportHeight() {
|
|
872
|
+
if (typeof window === "undefined") {
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
const rootElement = rootRef.value;
|
|
877
|
+
if (!(rootElement instanceof HTMLElement)) {
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
const viewportHeight = Number(window.innerHeight) || 0;
|
|
882
|
+
const topOffset = Math.max(0, Number(rootElement.getBoundingClientRect().top) || 0);
|
|
883
|
+
const targetHeight = Math.max(MIN_VIEWPORT_HEIGHT_PX, Math.floor(viewportHeight - topOffset - VIEWPORT_BOTTOM_GUTTER_PX));
|
|
884
|
+
|
|
885
|
+
rootElement.style.setProperty("--assistant-viewport-height", `${targetHeight}px`);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const lastMessageSignature = computed(() => {
|
|
889
|
+
const entries = Array.isArray(messages.value) ? messages.value : [];
|
|
890
|
+
const last = entries[entries.length - 1];
|
|
891
|
+
if (!last) {
|
|
892
|
+
return "none";
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
return `${entries.length}|${last.id}|${last.role}|${last.kind}|${String(last.text || "").length}|${last.status}`;
|
|
896
|
+
});
|
|
897
|
+
|
|
898
|
+
const assistantMarkdownSignature = computed(() => {
|
|
899
|
+
const entries = Array.isArray(messages.value) ? messages.value : [];
|
|
900
|
+
return entries
|
|
901
|
+
.filter((message) => isAssistantChatMessage(message))
|
|
902
|
+
.map((message) => `${String(message.id || "")}\u0000${String(message.text || "")}`)
|
|
903
|
+
.join("\u0001");
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
watch(
|
|
907
|
+
() => normalizeText(conversationId.value),
|
|
908
|
+
async (nextConversationId, previousConversationId) => {
|
|
909
|
+
if (!nextConversationId || nextConversationId === previousConversationId) {
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
shouldAutoScrollToBottom.value = true;
|
|
914
|
+
await nextTick();
|
|
915
|
+
scrollMessagesToBottom();
|
|
916
|
+
await requestComposerFocus();
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
immediate: true
|
|
920
|
+
}
|
|
921
|
+
);
|
|
922
|
+
|
|
923
|
+
watch(
|
|
924
|
+
assistantMarkdownSignature,
|
|
925
|
+
() => {
|
|
926
|
+
scheduleAssistantMarkdownRender();
|
|
927
|
+
},
|
|
928
|
+
{
|
|
929
|
+
immediate: true
|
|
930
|
+
}
|
|
931
|
+
);
|
|
932
|
+
|
|
933
|
+
watch(
|
|
934
|
+
lastMessageSignature,
|
|
935
|
+
() => {
|
|
936
|
+
const entries = Array.isArray(messages.value) ? messages.value : [];
|
|
937
|
+
const last = entries[entries.length - 1];
|
|
938
|
+
if (!last) {
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
keepMessagesPanelPinnedToBottom({
|
|
943
|
+
behavior: "auto"
|
|
944
|
+
});
|
|
945
|
+
},
|
|
946
|
+
{
|
|
947
|
+
immediate: true
|
|
948
|
+
}
|
|
949
|
+
);
|
|
950
|
+
|
|
951
|
+
watch(
|
|
952
|
+
() => isStreaming.value,
|
|
953
|
+
async (isNowStreaming, wasStreaming) => {
|
|
954
|
+
if (isNowStreaming || !wasStreaming) {
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
scheduleAssistantMarkdownRender({
|
|
959
|
+
immediate: true
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
await nextTick();
|
|
963
|
+
if (shouldAutoScrollToBottom.value) {
|
|
964
|
+
scrollMessagesToBottom({
|
|
965
|
+
behavior: "auto"
|
|
966
|
+
});
|
|
967
|
+
keepMessagesPanelPinnedToBottom({
|
|
968
|
+
behavior: "auto"
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
);
|
|
973
|
+
|
|
974
|
+
onMounted(async () => {
|
|
975
|
+
scheduleAssistantMarkdownRender({
|
|
976
|
+
immediate: true
|
|
977
|
+
});
|
|
978
|
+
await nextTick();
|
|
979
|
+
syncViewportHeight();
|
|
980
|
+
window.addEventListener("resize", syncViewportHeight, {
|
|
981
|
+
passive: true
|
|
982
|
+
});
|
|
983
|
+
await requestComposerFocus();
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
onActivated(async () => {
|
|
987
|
+
syncViewportHeight();
|
|
988
|
+
await requestComposerFocus();
|
|
989
|
+
});
|
|
990
|
+
|
|
991
|
+
watch(
|
|
992
|
+
() => isRestoringConversation.value,
|
|
993
|
+
async (isNowRestoring, wasRestoring) => {
|
|
994
|
+
if (isNowRestoring || !wasRestoring) {
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
await requestComposerFocus();
|
|
999
|
+
}
|
|
1000
|
+
);
|
|
1001
|
+
|
|
1002
|
+
onBeforeUnmount(() => {
|
|
1003
|
+
if (markdownRenderTimeoutId) {
|
|
1004
|
+
clearTimeout(markdownRenderTimeoutId);
|
|
1005
|
+
markdownRenderTimeoutId = null;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
assistantMarkdownCacheById.clear();
|
|
1009
|
+
if (typeof window === "undefined") {
|
|
1010
|
+
return;
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
window.removeEventListener("resize", syncViewportHeight);
|
|
1014
|
+
});
|
|
1015
|
+
</script>
|
|
1016
|
+
|
|
1017
|
+
<style scoped>
|
|
1018
|
+
.assistant-view {
|
|
1019
|
+
height: var(--assistant-viewport-height, 100dvh);
|
|
1020
|
+
max-height: var(--assistant-viewport-height, 100dvh);
|
|
1021
|
+
min-height: 0;
|
|
1022
|
+
overflow: hidden;
|
|
1023
|
+
padding-block: 0.1rem 0;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
.assistant-layout {
|
|
1027
|
+
min-height: 0;
|
|
1028
|
+
overflow: hidden;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
.assistant-client-element--layout-compact .messages-panel {
|
|
1032
|
+
padding: 0.6rem 0.72rem;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
.assistant-client-element--surface-plain .assistant-main-card,
|
|
1036
|
+
.assistant-client-element--surface-plain .assistant-history-card,
|
|
1037
|
+
.assistant-client-element--surface-plain .assistant-tools-card {
|
|
1038
|
+
box-shadow: none;
|
|
1039
|
+
border-width: 0;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
.assistant-client-element--density-compact :deep(.v-card-item) {
|
|
1043
|
+
padding-block: 0.6rem;
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
.assistant-client-element--tone-emphasized .message-row--user .message-bubble {
|
|
1047
|
+
--bubble-bg: rgba(var(--v-theme-primary), 0.22);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
.assistant-main-col,
|
|
1051
|
+
.assistant-side-col,
|
|
1052
|
+
.assistant-main-card,
|
|
1053
|
+
.assistant-main-card-text,
|
|
1054
|
+
.assistant-history-card,
|
|
1055
|
+
.assistant-tools-card,
|
|
1056
|
+
.assistant-history-card-text,
|
|
1057
|
+
.assistant-history-list,
|
|
1058
|
+
.assistant-tools-list {
|
|
1059
|
+
min-height: 0;
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
.messages-panel {
|
|
1063
|
+
flex: 1 1 auto;
|
|
1064
|
+
border: 1px solid rgba(var(--v-theme-on-surface), 0.14);
|
|
1065
|
+
border-radius: 10px;
|
|
1066
|
+
padding: 12px;
|
|
1067
|
+
min-height: 0;
|
|
1068
|
+
overflow: auto;
|
|
1069
|
+
overflow-anchor: none;
|
|
1070
|
+
background: rgba(var(--v-theme-surface-variant), 0.14);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
.messages-panel :deep(*) {
|
|
1074
|
+
overflow-anchor: none;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
.messages-panel--empty {
|
|
1078
|
+
display: grid;
|
|
1079
|
+
place-items: center;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
.messages-empty-state {
|
|
1083
|
+
min-height: 240px;
|
|
1084
|
+
text-align: center;
|
|
1085
|
+
font-size: clamp(1.25rem, 1.2rem + 1vw, 2rem);
|
|
1086
|
+
font-weight: 600;
|
|
1087
|
+
line-height: 1.2;
|
|
1088
|
+
color: rgba(var(--v-theme-on-surface), 0.68);
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
.message-body {
|
|
1092
|
+
max-width: min(82%, 700px);
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
.message-row--user .message-body {
|
|
1096
|
+
align-items: flex-end;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
.message-avatar {
|
|
1100
|
+
border: 2px solid rgba(var(--v-theme-surface), 0.95);
|
|
1101
|
+
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
|
|
1102
|
+
flex: 0 0 auto;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
.message-avatar--assistant {
|
|
1106
|
+
background: linear-gradient(180deg, #2f9a45, #1f7a35) !important;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
.message-avatar-initials {
|
|
1110
|
+
color: rgba(var(--v-theme-on-primary), 1);
|
|
1111
|
+
font-size: 0.75rem;
|
|
1112
|
+
font-weight: 700;
|
|
1113
|
+
letter-spacing: 0.04em;
|
|
1114
|
+
text-transform: uppercase;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
.message-bubble {
|
|
1118
|
+
--bubble-bg: rgba(var(--v-theme-surface), 0.92);
|
|
1119
|
+
--bubble-border: rgba(var(--v-theme-on-surface), 0.16);
|
|
1120
|
+
background: var(--bubble-bg);
|
|
1121
|
+
border: 1px solid var(--bubble-border);
|
|
1122
|
+
border-radius: 16px;
|
|
1123
|
+
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.08);
|
|
1124
|
+
padding: 10px 12px;
|
|
1125
|
+
position: relative;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
.message-row--assistant .message-bubble::after,
|
|
1129
|
+
.message-row--user .message-bubble::after {
|
|
1130
|
+
background: var(--bubble-bg);
|
|
1131
|
+
bottom: 11px;
|
|
1132
|
+
content: "";
|
|
1133
|
+
height: 12px;
|
|
1134
|
+
position: absolute;
|
|
1135
|
+
width: 12px;
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
.message-row--assistant .message-bubble::after {
|
|
1139
|
+
clip-path: polygon(100% 0, 0 50%, 100% 100%);
|
|
1140
|
+
left: -10px;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
.message-row--user .message-bubble {
|
|
1144
|
+
--bubble-bg: rgba(var(--v-theme-primary), 0.14);
|
|
1145
|
+
--bubble-border: rgba(var(--v-theme-primary), 0.34);
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
.message-row--user .message-bubble::after {
|
|
1149
|
+
clip-path: polygon(0 0, 100% 50%, 0 100%);
|
|
1150
|
+
right: -10px;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
.message-author {
|
|
1154
|
+
color: rgba(var(--v-theme-on-surface), 0.84);
|
|
1155
|
+
font-weight: 600;
|
|
1156
|
+
letter-spacing: 0.01em;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
.message-row--user .message-author {
|
|
1160
|
+
color: rgba(var(--v-theme-primary), 1);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
.message-text {
|
|
1164
|
+
white-space: pre-wrap;
|
|
1165
|
+
word-break: break-word;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
.message-text--markdown {
|
|
1169
|
+
white-space: normal;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
.message-text--markdown :deep(p),
|
|
1173
|
+
.message-text--markdown :deep(ul),
|
|
1174
|
+
.message-text--markdown :deep(ol),
|
|
1175
|
+
.message-text--markdown :deep(pre),
|
|
1176
|
+
.message-text--markdown :deep(blockquote),
|
|
1177
|
+
.message-text--markdown :deep(h1),
|
|
1178
|
+
.message-text--markdown :deep(h2),
|
|
1179
|
+
.message-text--markdown :deep(h3),
|
|
1180
|
+
.message-text--markdown :deep(h4) {
|
|
1181
|
+
margin-block: 0 0.6rem;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
.message-text--markdown :deep(p:last-child),
|
|
1185
|
+
.message-text--markdown :deep(ul:last-child),
|
|
1186
|
+
.message-text--markdown :deep(ol:last-child),
|
|
1187
|
+
.message-text--markdown :deep(pre:last-child),
|
|
1188
|
+
.message-text--markdown :deep(blockquote:last-child),
|
|
1189
|
+
.message-text--markdown :deep(h1:last-child),
|
|
1190
|
+
.message-text--markdown :deep(h2:last-child),
|
|
1191
|
+
.message-text--markdown :deep(h3:last-child),
|
|
1192
|
+
.message-text--markdown :deep(h4:last-child) {
|
|
1193
|
+
margin-bottom: 0;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
.message-text--markdown :deep(code) {
|
|
1197
|
+
background: rgba(var(--v-theme-on-surface), 0.08);
|
|
1198
|
+
border-radius: 4px;
|
|
1199
|
+
padding: 0.1rem 0.28rem;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
.message-text--markdown :deep(pre) {
|
|
1203
|
+
background: rgba(var(--v-theme-on-surface), 0.06);
|
|
1204
|
+
border-radius: 8px;
|
|
1205
|
+
overflow-x: auto;
|
|
1206
|
+
padding: 0.6rem 0.72rem;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
.message-text--markdown :deep(pre code) {
|
|
1210
|
+
background: transparent;
|
|
1211
|
+
padding: 0;
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
.message-typing {
|
|
1215
|
+
min-height: 20px;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
.message-typing-dot {
|
|
1219
|
+
animation: message-typing-blink 1.1s infinite ease-in-out;
|
|
1220
|
+
background: rgba(var(--v-theme-on-surface), 0.62);
|
|
1221
|
+
border-radius: 50%;
|
|
1222
|
+
display: inline-block;
|
|
1223
|
+
height: 7px;
|
|
1224
|
+
width: 7px;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
.message-typing-dot:nth-child(2) {
|
|
1228
|
+
animation-delay: 0.16s;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
.message-typing-dot:nth-child(3) {
|
|
1232
|
+
animation-delay: 0.32s;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
@keyframes message-typing-blink {
|
|
1236
|
+
0%,
|
|
1237
|
+
80%,
|
|
1238
|
+
100% {
|
|
1239
|
+
opacity: 0.3;
|
|
1240
|
+
transform: translateY(0);
|
|
1241
|
+
}
|
|
1242
|
+
40% {
|
|
1243
|
+
opacity: 1;
|
|
1244
|
+
transform: translateY(-2px);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
.assistant-composer-shell {
|
|
1249
|
+
border: 1px solid rgba(var(--v-theme-on-surface), 0.12);
|
|
1250
|
+
background: rgba(var(--v-theme-surface), 0.94);
|
|
1251
|
+
border-radius: 18px;
|
|
1252
|
+
padding: 0.4rem 0.5rem;
|
|
1253
|
+
gap: 0.3rem;
|
|
1254
|
+
box-shadow: 0 8px 20px rgba(17, 26, 42, 0.05);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
.assistant-composer-textarea {
|
|
1258
|
+
min-width: 0;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
.assistant-composer-textarea :deep(.v-field) {
|
|
1262
|
+
border-radius: 14px;
|
|
1263
|
+
background: rgba(var(--v-theme-on-surface), 0.03);
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
.assistant-composer-textarea :deep(.v-field__outline),
|
|
1267
|
+
.assistant-composer-textarea :deep(.v-field::before),
|
|
1268
|
+
.assistant-composer-textarea :deep(.v-field::after) {
|
|
1269
|
+
display: none;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
.assistant-composer-textarea :deep(.v-field__overlay) {
|
|
1273
|
+
display: none;
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
.assistant-composer-textarea :deep(.v-field__input) {
|
|
1277
|
+
padding-block: 0.5rem 0.46rem;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
.assistant-composer-textarea :deep(textarea) {
|
|
1281
|
+
line-height: 1.45;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
.assistant-history-start-button {
|
|
1285
|
+
flex: 0 0 auto;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
.assistant-stop-button {
|
|
1289
|
+
background-color: #c62828 !important;
|
|
1290
|
+
border: 3px solid #ffffff !important;
|
|
1291
|
+
border-radius: 0 !important;
|
|
1292
|
+
clip-path: polygon(30% 0, 70% 0, 100% 30%, 100% 70%, 70% 100%, 30% 100%, 0 70%, 0 30%);
|
|
1293
|
+
color: #ffffff !important;
|
|
1294
|
+
font-weight: 800;
|
|
1295
|
+
letter-spacing: 0.08em;
|
|
1296
|
+
min-height: 46px;
|
|
1297
|
+
min-width: 70px;
|
|
1298
|
+
text-transform: uppercase;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
@media (min-width: 1280px) {
|
|
1302
|
+
.assistant-layout {
|
|
1303
|
+
flex-wrap: nowrap;
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
.assistant-history-card {
|
|
1307
|
+
flex: 1 1 auto;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
.assistant-tools-card {
|
|
1311
|
+
flex: 0 0 var(--assistant-tools-panel-height, 320px);
|
|
1312
|
+
max-height: var(--assistant-tools-panel-height, 320px);
|
|
1313
|
+
min-height: var(--assistant-tools-panel-height, 320px);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
</style>
|