@windward/integrations 0.15.0 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/components/Integration/AiAgentIntegration/ChatWindow.vue +916 -0
- package/components/LLM/BloomTaxonomySelector.vue +120 -0
- package/components/LLM/ContentSelector.vue +66 -0
- package/components/LLM/GenerateContent/AssessmentQuestionGenerateButton.vue +285 -0
- package/components/LLM/GenerateContent/BlockQuestionGenerateButton.vue +514 -0
- package/components/LLM/GenerateContent/FakeTextStream.vue +67 -0
- package/i18n/en-US/components/ai_agent/chat.ts +20 -0
- package/i18n/en-US/components/ai_agent/index.ts +5 -0
- package/i18n/en-US/components/index.ts +4 -0
- package/i18n/en-US/components/llm/blooms.ts +15 -0
- package/i18n/en-US/components/llm/content_selector.ts +3 -0
- package/i18n/en-US/components/llm/generate_content/fake_text_stream.ts +62 -0
- package/i18n/en-US/components/llm/generate_content/generate_questions.ts +81 -0
- package/i18n/en-US/components/llm/generate_content/index.ts +7 -0
- package/i18n/en-US/components/llm/index.ts +10 -0
- package/i18n/en-US/shared/permission.ts +10 -0
- package/i18n/en-US/shared/settings.ts +2 -1
- package/i18n/es-ES/components/ai_agent/chat.ts +20 -0
- package/i18n/es-ES/components/ai_agent/index.ts +5 -0
- package/i18n/es-ES/components/index.ts +4 -0
- package/i18n/es-ES/components/llm/blooms.ts +15 -0
- package/i18n/es-ES/components/llm/content_selector.ts +3 -0
- package/i18n/es-ES/components/llm/generate_content/fake_text_stream.ts +62 -0
- package/i18n/es-ES/components/llm/generate_content/generate_questions.ts +85 -0
- package/i18n/es-ES/components/llm/generate_content/index.ts +7 -0
- package/i18n/es-ES/components/llm/index.ts +10 -0
- package/i18n/es-ES/shared/permission.ts +10 -0
- package/i18n/es-ES/shared/settings.ts +2 -1
- package/i18n/sv-SE/components/ai_agent/chat.ts +19 -0
- package/i18n/sv-SE/components/ai_agent/index.ts +5 -0
- package/i18n/sv-SE/components/index.ts +4 -0
- package/i18n/sv-SE/components/llm/blooms.ts +15 -0
- package/i18n/sv-SE/components/llm/content_selector.ts +3 -0
- package/i18n/sv-SE/components/llm/generate_content/fake_text_stream.ts +62 -0
- package/i18n/sv-SE/components/llm/generate_content/generate_questions.ts +82 -0
- package/i18n/sv-SE/components/llm/generate_content/index.ts +7 -0
- package/i18n/sv-SE/components/llm/index.ts +10 -0
- package/i18n/sv-SE/shared/permission.ts +10 -0
- package/i18n/sv-SE/shared/settings.ts +1 -0
- package/models/Activity.ts +8 -0
- package/models/AgentChat.ts +12 -0
- package/models/AgentChatMessage.ts +12 -0
- package/package.json +2 -1
- package/plugin.js +34 -1
|
@@ -0,0 +1,916 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="chat-window">
|
|
3
|
+
<!-- Header -->
|
|
4
|
+
<div class="chat-header d-flex align-center justify-space-between">
|
|
5
|
+
<div class="d-flex align-center">
|
|
6
|
+
<span class="header-title">
|
|
7
|
+
{{
|
|
8
|
+
$t(
|
|
9
|
+
'windward.integrations.components.ai_agent.chat.new_chat'
|
|
10
|
+
)
|
|
11
|
+
}}
|
|
12
|
+
</span>
|
|
13
|
+
<v-menu offset-y>
|
|
14
|
+
<template #activator="{ on, attrs }">
|
|
15
|
+
<v-btn
|
|
16
|
+
icon
|
|
17
|
+
x-small
|
|
18
|
+
v-bind="attrs"
|
|
19
|
+
:aria-label="
|
|
20
|
+
$t(
|
|
21
|
+
'windward.integrations.components.ai_agent.chat.aria_open_history'
|
|
22
|
+
)
|
|
23
|
+
"
|
|
24
|
+
v-on="on"
|
|
25
|
+
>
|
|
26
|
+
<v-icon small>mdi-chevron-down</v-icon>
|
|
27
|
+
</v-btn>
|
|
28
|
+
</template>
|
|
29
|
+
<v-list dense>
|
|
30
|
+
<v-subheader>
|
|
31
|
+
{{
|
|
32
|
+
$t(
|
|
33
|
+
'windward.integrations.components.ai_agent.chat.recent_chats'
|
|
34
|
+
)
|
|
35
|
+
}}
|
|
36
|
+
</v-subheader>
|
|
37
|
+
<template v-if="history.length > 0">
|
|
38
|
+
<v-list-item
|
|
39
|
+
v-for="item in history"
|
|
40
|
+
:key="item.id"
|
|
41
|
+
:class="{
|
|
42
|
+
'active-chat': item.id === currentSessionId,
|
|
43
|
+
}"
|
|
44
|
+
@click="loadSession(item.id)"
|
|
45
|
+
>
|
|
46
|
+
<v-list-item-content>
|
|
47
|
+
<v-list-item-title class="text-truncate">
|
|
48
|
+
{{
|
|
49
|
+
item.title ||
|
|
50
|
+
$t(
|
|
51
|
+
'windward.integrations.components.ai_agent.chat.untitled_chat'
|
|
52
|
+
)
|
|
53
|
+
}}
|
|
54
|
+
</v-list-item-title>
|
|
55
|
+
<v-list-item-subtitle>
|
|
56
|
+
{{ formatDate(item.updated_at) }}
|
|
57
|
+
</v-list-item-subtitle>
|
|
58
|
+
</v-list-item-content>
|
|
59
|
+
</v-list-item>
|
|
60
|
+
<v-divider class="my-1"></v-divider>
|
|
61
|
+
</template>
|
|
62
|
+
<template v-else>
|
|
63
|
+
<v-list-item>
|
|
64
|
+
<v-list-item-title>
|
|
65
|
+
{{
|
|
66
|
+
$t(
|
|
67
|
+
'windward.integrations.components.ai_agent.chat.no_history'
|
|
68
|
+
)
|
|
69
|
+
}}
|
|
70
|
+
</v-list-item-title>
|
|
71
|
+
</v-list-item>
|
|
72
|
+
<v-divider class="my-1"></v-divider>
|
|
73
|
+
</template>
|
|
74
|
+
<v-list-item @click="startNewChat">
|
|
75
|
+
<v-list-item-icon
|
|
76
|
+
><v-icon small
|
|
77
|
+
>mdi-plus</v-icon
|
|
78
|
+
></v-list-item-icon
|
|
79
|
+
>
|
|
80
|
+
<v-list-item-title>
|
|
81
|
+
{{
|
|
82
|
+
$t(
|
|
83
|
+
'windward.integrations.components.ai_agent.chat.new_chat'
|
|
84
|
+
)
|
|
85
|
+
}}
|
|
86
|
+
</v-list-item-title>
|
|
87
|
+
</v-list-item>
|
|
88
|
+
</v-list>
|
|
89
|
+
</v-menu>
|
|
90
|
+
</div>
|
|
91
|
+
<v-btn
|
|
92
|
+
small
|
|
93
|
+
color="primary"
|
|
94
|
+
class="font-weight-medium"
|
|
95
|
+
text
|
|
96
|
+
@click="startNewChat"
|
|
97
|
+
>
|
|
98
|
+
<v-icon left small>mdi-plus</v-icon>
|
|
99
|
+
{{
|
|
100
|
+
$t(
|
|
101
|
+
'windward.integrations.components.ai_agent.chat.new_chat'
|
|
102
|
+
)
|
|
103
|
+
}}
|
|
104
|
+
</v-btn>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<!-- Messages Area -->
|
|
108
|
+
<div ref="messagesScroll" class="messages" @scroll="onMessagesScroll">
|
|
109
|
+
<div
|
|
110
|
+
v-if="loadingOlder"
|
|
111
|
+
class="top-loader d-flex justify-center mb-2"
|
|
112
|
+
>
|
|
113
|
+
<v-progress-circular
|
|
114
|
+
indeterminate
|
|
115
|
+
size="18"
|
|
116
|
+
width="2"
|
|
117
|
+
color="primary"
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
120
|
+
<div v-if="messages.length === 0" class="system-tip">
|
|
121
|
+
{{
|
|
122
|
+
$t(
|
|
123
|
+
'windward.integrations.components.ai_agent.chat.system_tip'
|
|
124
|
+
)
|
|
125
|
+
}}
|
|
126
|
+
</div>
|
|
127
|
+
<div v-else>
|
|
128
|
+
<div
|
|
129
|
+
v-for="(msg, i) in messages"
|
|
130
|
+
:key="i"
|
|
131
|
+
class="message"
|
|
132
|
+
:class="msg.role"
|
|
133
|
+
>
|
|
134
|
+
<div class="bubble">
|
|
135
|
+
<div class="bubble-meta">
|
|
136
|
+
<span class="sender">{{ senderLabel(msg) }}</span>
|
|
137
|
+
<span class="time">{{
|
|
138
|
+
formatDate(msg.created_at || msg.createdAt)
|
|
139
|
+
}}</span>
|
|
140
|
+
<span v-if="msg.role === 'user'" class="checkmarks">
|
|
141
|
+
<v-icon
|
|
142
|
+
v-if="messageStatus(i) === 'single'"
|
|
143
|
+
small
|
|
144
|
+
color="text-primary"
|
|
145
|
+
>mdi-check</v-icon
|
|
146
|
+
>
|
|
147
|
+
<v-icon
|
|
148
|
+
v-else-if="messageStatus(i) === 'double'"
|
|
149
|
+
small
|
|
150
|
+
color="text-primary"
|
|
151
|
+
>mdi-check-all</v-icon
|
|
152
|
+
>
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
<div class="bubble-text">
|
|
156
|
+
<text-viewer v-model="msg.text"></text-viewer>
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
<!-- Awaiting response indicator/messages -->
|
|
161
|
+
<div
|
|
162
|
+
v-if="
|
|
163
|
+
awaitingReply &&
|
|
164
|
+
(showTypingDots || showProcessingMessage)
|
|
165
|
+
"
|
|
166
|
+
class="message assistant"
|
|
167
|
+
>
|
|
168
|
+
<div class="bubble" aria-live="polite">
|
|
169
|
+
<span class="sr-only">Agent is typing</span>
|
|
170
|
+
<div class="awaiting-indicator">
|
|
171
|
+
<span
|
|
172
|
+
v-if="showTypingDots"
|
|
173
|
+
class="typing-dots"
|
|
174
|
+
aria-hidden="true"
|
|
175
|
+
>
|
|
176
|
+
<span class="dot" />
|
|
177
|
+
<span class="dot" />
|
|
178
|
+
<span class="dot" />
|
|
179
|
+
</span>
|
|
180
|
+
<span
|
|
181
|
+
v-if="showProcessingMessage || showTypingDots"
|
|
182
|
+
class="processing-note"
|
|
183
|
+
>
|
|
184
|
+
{{ feedbackMessages[currentFeedbackIndex] }}
|
|
185
|
+
</span>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<!-- Composer -->
|
|
193
|
+
<div class="composer">
|
|
194
|
+
<div v-if="sendError" class="composer-error mb-2">
|
|
195
|
+
<v-alert type="error" dense text>
|
|
196
|
+
<div class="d-flex align-center justify-space-between">
|
|
197
|
+
<span>{{ sendError }}</span>
|
|
198
|
+
<div class="ml-2">
|
|
199
|
+
<template v-if="isLimitReached">
|
|
200
|
+
<v-btn
|
|
201
|
+
x-small
|
|
202
|
+
color="primary"
|
|
203
|
+
text
|
|
204
|
+
@click="startNewChat"
|
|
205
|
+
>
|
|
206
|
+
{{
|
|
207
|
+
$t(
|
|
208
|
+
'windward.integrations.components.ai_agent.chat.new_chat'
|
|
209
|
+
)
|
|
210
|
+
}}
|
|
211
|
+
</v-btn>
|
|
212
|
+
</template>
|
|
213
|
+
<template v-else>
|
|
214
|
+
<v-btn
|
|
215
|
+
x-small
|
|
216
|
+
color="error"
|
|
217
|
+
text
|
|
218
|
+
@click="retryLastSend"
|
|
219
|
+
>
|
|
220
|
+
{{
|
|
221
|
+
$t(
|
|
222
|
+
'windward.integrations.components.ai_agent.chat.action_try_again'
|
|
223
|
+
)
|
|
224
|
+
}}
|
|
225
|
+
</v-btn>
|
|
226
|
+
<v-btn
|
|
227
|
+
color="error"
|
|
228
|
+
x-small
|
|
229
|
+
text
|
|
230
|
+
@click="clearError"
|
|
231
|
+
>
|
|
232
|
+
{{
|
|
233
|
+
$t(
|
|
234
|
+
'windward.integrations.components.ai_agent.chat.action_dismiss'
|
|
235
|
+
)
|
|
236
|
+
}}
|
|
237
|
+
</v-btn>
|
|
238
|
+
</template>
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
</v-alert>
|
|
242
|
+
</div>
|
|
243
|
+
|
|
244
|
+
<v-textarea
|
|
245
|
+
ref="composer"
|
|
246
|
+
v-model="draft"
|
|
247
|
+
:placeholder="placeholder"
|
|
248
|
+
:disabled="awaitingReply || isLimitReached"
|
|
249
|
+
auto-grow
|
|
250
|
+
dense
|
|
251
|
+
outlined
|
|
252
|
+
hide-details
|
|
253
|
+
rows="1"
|
|
254
|
+
class="composer-input"
|
|
255
|
+
:append-icon="canSend ? ' mdi-send' : 'mdi-send-lock'"
|
|
256
|
+
@click:append="send"
|
|
257
|
+
@keydown.enter.exact.prevent="onEnterKey"
|
|
258
|
+
/>
|
|
259
|
+
|
|
260
|
+
<div class="composer-hint mt-3">
|
|
261
|
+
{{ $t('windward.integrations.components.ai_agent.chat.hint') }}
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
</div>
|
|
265
|
+
</template>
|
|
266
|
+
|
|
267
|
+
<script>
|
|
268
|
+
import _ from 'lodash'
|
|
269
|
+
import { mapGetters } from 'vuex'
|
|
270
|
+
|
|
271
|
+
import AgentChat from '../../../models/AgentChat'
|
|
272
|
+
import AgentChatMessage from '../../../models/AgentChatMessage'
|
|
273
|
+
import Organization from '~/models/Organization'
|
|
274
|
+
import Course from '~/models/Course'
|
|
275
|
+
import TextViewer from '~/components/Text/TextViewer.vue'
|
|
276
|
+
|
|
277
|
+
export default {
|
|
278
|
+
name: 'ChatWindow',
|
|
279
|
+
components: { TextViewer },
|
|
280
|
+
data() {
|
|
281
|
+
return {
|
|
282
|
+
messages: [],
|
|
283
|
+
draft: '',
|
|
284
|
+
history: [],
|
|
285
|
+
sessionsById: {},
|
|
286
|
+
currentSessionId: null,
|
|
287
|
+
awaitingReply: false,
|
|
288
|
+
showTypingDots: false,
|
|
289
|
+
showProcessingMessage: false,
|
|
290
|
+
typingDotsTimeoutId: null,
|
|
291
|
+
longWaitTimeoutId: null,
|
|
292
|
+
currentFeedbackIndex: 0,
|
|
293
|
+
feedbackIntervalId: null,
|
|
294
|
+
// error management state
|
|
295
|
+
sendError: null,
|
|
296
|
+
lastFailedPrompt: '',
|
|
297
|
+
// pagination state for infinite scroll
|
|
298
|
+
perPage: 20,
|
|
299
|
+
nextPage: 1, // next page to load when scrolling up (ordered desc)
|
|
300
|
+
loadingOlder: false,
|
|
301
|
+
noMoreOlder: false,
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
computed: {
|
|
305
|
+
...mapGetters({
|
|
306
|
+
organization: 'organization/get',
|
|
307
|
+
course: 'course/get',
|
|
308
|
+
}),
|
|
309
|
+
feedbackMessages() {
|
|
310
|
+
const localizedQuotes = this.$t(
|
|
311
|
+
'windward.integrations.components.llm.generate_content.fake_text_stream.options'
|
|
312
|
+
)
|
|
313
|
+
const quotes = []
|
|
314
|
+
|
|
315
|
+
for (const [_key, quote] of Object.entries(localizedQuotes)) {
|
|
316
|
+
quotes.push(quote)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return _.shuffle(quotes)
|
|
320
|
+
},
|
|
321
|
+
canSend() {
|
|
322
|
+
return (
|
|
323
|
+
this.draft.trim().length > 0 &&
|
|
324
|
+
!this.awaitingReply &&
|
|
325
|
+
!this.isLimitReached
|
|
326
|
+
)
|
|
327
|
+
},
|
|
328
|
+
userMessageCount() {
|
|
329
|
+
return Array.isArray(this.messages)
|
|
330
|
+
? this.messages.filter((m) => m && m.role === 'user').length
|
|
331
|
+
: 0
|
|
332
|
+
},
|
|
333
|
+
isLimitReached() {
|
|
334
|
+
// Chat limit removed: always allow sending
|
|
335
|
+
return false
|
|
336
|
+
},
|
|
337
|
+
placeholder() {
|
|
338
|
+
return this.$t(
|
|
339
|
+
'windward.integrations.components.ai_agent.chat.placeholder'
|
|
340
|
+
)
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
watch: {
|
|
344
|
+
isLimitReached(newVal) {
|
|
345
|
+
if (newVal) {
|
|
346
|
+
this.sendError = this.$t(
|
|
347
|
+
'windward.integrations.components.ai_agent.chat.limit_reached'
|
|
348
|
+
)
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
},
|
|
352
|
+
mounted() {
|
|
353
|
+
this.loadHistory().then(() => {
|
|
354
|
+
// If there is history, load the most recent chat; otherwise start a new one
|
|
355
|
+
if (
|
|
356
|
+
this.history &&
|
|
357
|
+
this.history.length > 0 &&
|
|
358
|
+
this.history[0]?.id
|
|
359
|
+
) {
|
|
360
|
+
this.loadSession(this.history[0].id)
|
|
361
|
+
} else {
|
|
362
|
+
this.startNewChat()
|
|
363
|
+
}
|
|
364
|
+
this.focusComposer()
|
|
365
|
+
})
|
|
366
|
+
},
|
|
367
|
+
methods: {
|
|
368
|
+
clearError() {
|
|
369
|
+
this.sendError = null
|
|
370
|
+
this.lastFailedPrompt = ''
|
|
371
|
+
},
|
|
372
|
+
retryLastSend() {
|
|
373
|
+
if (this.awaitingReply) return
|
|
374
|
+
if (!this.lastFailedPrompt) return this.clearError()
|
|
375
|
+
// ensure draft contains the failed prompt, then send
|
|
376
|
+
this.draft = this.lastFailedPrompt
|
|
377
|
+
this.sendError = null
|
|
378
|
+
this.lastFailedPrompt = ''
|
|
379
|
+
this.$nextTick(() => this.send())
|
|
380
|
+
},
|
|
381
|
+
onEnterKey(e) {
|
|
382
|
+
// If Shift is held, let it create a newline
|
|
383
|
+
if (e.shiftKey) return
|
|
384
|
+
this.send()
|
|
385
|
+
},
|
|
386
|
+
async send() {
|
|
387
|
+
// Enforce per-chat user message limit (40)
|
|
388
|
+
if (this.isLimitReached) {
|
|
389
|
+
this.sendError = this.$t(
|
|
390
|
+
'windward.integrations.components.ai_agent.chat.limit_reached'
|
|
391
|
+
)
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
if (!this.canSend) return
|
|
395
|
+
const userText = _.cloneDeep(this.draft.trim())
|
|
396
|
+
this.draft = ''
|
|
397
|
+
// clear any previous error
|
|
398
|
+
this.sendError = null
|
|
399
|
+
this.lastFailedPrompt = ''
|
|
400
|
+
// Show the user's message optimistically
|
|
401
|
+
this.messages.push({
|
|
402
|
+
role: 'user',
|
|
403
|
+
text: userText,
|
|
404
|
+
created_at: Date.now(),
|
|
405
|
+
})
|
|
406
|
+
let reply = {}
|
|
407
|
+
// set awaiting state and indicators
|
|
408
|
+
this.awaitingReply = true
|
|
409
|
+
this.showTypingDots = true
|
|
410
|
+
this.showProcessingMessage = true
|
|
411
|
+
this.currentFeedbackIndex = 0
|
|
412
|
+
if (this.typingDotsTimeoutId) clearTimeout(this.typingDotsTimeoutId)
|
|
413
|
+
if (this.longWaitTimeoutId) clearTimeout(this.longWaitTimeoutId)
|
|
414
|
+
if (this.feedbackIntervalId) clearInterval(this.feedbackIntervalId)
|
|
415
|
+
// Rotate the processing message every 5 seconds while awaiting
|
|
416
|
+
this.feedbackIntervalId = setInterval(() => {
|
|
417
|
+
if (!this.awaitingReply) return
|
|
418
|
+
this.currentFeedbackIndex =
|
|
419
|
+
(this.currentFeedbackIndex + 1) %
|
|
420
|
+
this.feedbackMessages.length
|
|
421
|
+
}, 5000)
|
|
422
|
+
this.$nextTick(this.scrollToBottom)
|
|
423
|
+
try {
|
|
424
|
+
if (this.currentSessionId !== null) {
|
|
425
|
+
reply = await new AgentChat({
|
|
426
|
+
id: this.currentSessionId,
|
|
427
|
+
prompt: btoa(userText),
|
|
428
|
+
})
|
|
429
|
+
.for(
|
|
430
|
+
new Organization({ id: this.organization.id }),
|
|
431
|
+
new Course({ id: this.course.id })
|
|
432
|
+
)
|
|
433
|
+
.save()
|
|
434
|
+
} else {
|
|
435
|
+
reply = await new AgentChat({
|
|
436
|
+
id: '',
|
|
437
|
+
prompt: btoa(userText),
|
|
438
|
+
})
|
|
439
|
+
.for(
|
|
440
|
+
new Organization({ id: this.organization.id }),
|
|
441
|
+
new Course({ id: this.course.id })
|
|
442
|
+
)
|
|
443
|
+
.save()
|
|
444
|
+
}
|
|
445
|
+
// success path
|
|
446
|
+
this.awaitingReply = false
|
|
447
|
+
this.showTypingDots = false
|
|
448
|
+
this.showProcessingMessage = false
|
|
449
|
+
if (this.typingDotsTimeoutId)
|
|
450
|
+
clearTimeout(this.typingDotsTimeoutId)
|
|
451
|
+
if (this.longWaitTimeoutId) clearTimeout(this.longWaitTimeoutId)
|
|
452
|
+
if (this.feedbackIntervalId)
|
|
453
|
+
clearInterval(this.feedbackIntervalId)
|
|
454
|
+
// set current session and refresh messages (latest page)
|
|
455
|
+
this.currentSessionId = reply.id
|
|
456
|
+
await this.reloadLatestMessages()
|
|
457
|
+
this.$nextTick(this.scrollToBottom)
|
|
458
|
+
// persist current session on each send
|
|
459
|
+
this.persistCurrentSession(reply.id)
|
|
460
|
+
await this.loadHistory()
|
|
461
|
+
} catch (err) {
|
|
462
|
+
// error path: clear awaiting flags & show error with retry option
|
|
463
|
+
this.awaitingReply = false
|
|
464
|
+
this.showTypingDots = false
|
|
465
|
+
this.showProcessingMessage = false
|
|
466
|
+
if (this.typingDotsTimeoutId)
|
|
467
|
+
clearTimeout(this.typingDotsTimeoutId)
|
|
468
|
+
if (this.longWaitTimeoutId) clearTimeout(this.longWaitTimeoutId)
|
|
469
|
+
if (this.feedbackIntervalId)
|
|
470
|
+
clearInterval(this.feedbackIntervalId)
|
|
471
|
+
|
|
472
|
+
// Remove the last optimistic user message to avoid duplicates on retry
|
|
473
|
+
const last = this.messages[this.messages.length - 1]
|
|
474
|
+
if (last && last.role === 'user' && last.text === userText) {
|
|
475
|
+
this.messages.pop()
|
|
476
|
+
}
|
|
477
|
+
// Restore draft so the user can edit/try again
|
|
478
|
+
this.draft = userText
|
|
479
|
+
|
|
480
|
+
// Determine a user-friendly message
|
|
481
|
+
let message = this.$t(
|
|
482
|
+
'windward.integrations.components.ai_agent.chat.error_generic'
|
|
483
|
+
)
|
|
484
|
+
try {
|
|
485
|
+
const status = err && (err.status || err.response?.status)
|
|
486
|
+
if (status)
|
|
487
|
+
message = this.$t(
|
|
488
|
+
'windward.integrations.components.ai_agent.chat.error_generic'
|
|
489
|
+
)
|
|
490
|
+
} catch (e) {}
|
|
491
|
+
|
|
492
|
+
this.sendError = message
|
|
493
|
+
this.lastFailedPrompt = userText
|
|
494
|
+
// keep focus for immediate retry
|
|
495
|
+
this.$nextTick(this.focusComposer)
|
|
496
|
+
// Optionally log
|
|
497
|
+
// eslint-disable-next-line no-console
|
|
498
|
+
console.error('AgentChat send failed', err)
|
|
499
|
+
}
|
|
500
|
+
},
|
|
501
|
+
async startNewChat() {
|
|
502
|
+
// if current session has some content, ensure it is saved before starting new one
|
|
503
|
+
if (this.currentSessionId && this.messages.length) {
|
|
504
|
+
const data = this.history.find(
|
|
505
|
+
(item) => item.id === this.currentSessionId
|
|
506
|
+
)
|
|
507
|
+
this.persistCurrentSession(data)
|
|
508
|
+
await this.loadHistory()
|
|
509
|
+
}
|
|
510
|
+
// reset awaiting flags
|
|
511
|
+
this.awaitingReply = false
|
|
512
|
+
this.showTypingDots = false
|
|
513
|
+
this.showProcessingMessage = false
|
|
514
|
+
if (this.typingDotsTimeoutId) clearTimeout(this.typingDotsTimeoutId)
|
|
515
|
+
if (this.longWaitTimeoutId) clearTimeout(this.longWaitTimeoutId)
|
|
516
|
+
if (this.feedbackIntervalId) clearInterval(this.feedbackIntervalId)
|
|
517
|
+
this.currentSessionId = null
|
|
518
|
+
this.messages = []
|
|
519
|
+
this.draft = ''
|
|
520
|
+
// clear any error state
|
|
521
|
+
this.sendError = null
|
|
522
|
+
this.lastFailedPrompt = ''
|
|
523
|
+
this.upsertHistoryEntry({
|
|
524
|
+
id: this.currentSessionId,
|
|
525
|
+
title: '',
|
|
526
|
+
updatedAt: Date.now(),
|
|
527
|
+
})
|
|
528
|
+
this.$nextTick(this.focusComposer)
|
|
529
|
+
},
|
|
530
|
+
async loadSession(id) {
|
|
531
|
+
const data = this.history.find((item) => item.id === id)
|
|
532
|
+
if (!data) return
|
|
533
|
+
try {
|
|
534
|
+
// reset awaiting flags when switching sessions
|
|
535
|
+
this.awaitingReply = false
|
|
536
|
+
this.showTypingDots = false
|
|
537
|
+
this.showProcessingMessage = false
|
|
538
|
+
if (this.typingDotsTimeoutId)
|
|
539
|
+
clearTimeout(this.typingDotsTimeoutId)
|
|
540
|
+
if (this.longWaitTimeoutId) clearTimeout(this.longWaitTimeoutId)
|
|
541
|
+
if (this.feedbackIntervalId)
|
|
542
|
+
clearInterval(this.feedbackIntervalId)
|
|
543
|
+
this.currentSessionId = id
|
|
544
|
+
await this.reloadLatestMessages()
|
|
545
|
+
this.$nextTick(() => {
|
|
546
|
+
this.scrollToBottom()
|
|
547
|
+
this.focusComposer()
|
|
548
|
+
})
|
|
549
|
+
} catch (e) {
|
|
550
|
+
// ignore malformed
|
|
551
|
+
}
|
|
552
|
+
},
|
|
553
|
+
async loadHistory() {
|
|
554
|
+
// Fetch history and ensure it is sorted by most recent timestamp (desc)
|
|
555
|
+
const fetched = await new AgentChat()
|
|
556
|
+
.for(
|
|
557
|
+
new Organization({ id: this.organization.id }),
|
|
558
|
+
new Course({ id: this.course.id })
|
|
559
|
+
)
|
|
560
|
+
.get()
|
|
561
|
+
// Normalize and sort: prefer updated_at/updatedAt, fallback to created_at/createdAt
|
|
562
|
+
this.history = Array.isArray(fetched) ? fetched.slice() : []
|
|
563
|
+
this.history.sort((a, b) => {
|
|
564
|
+
const ta = this.getEntryTimestamp(a)
|
|
565
|
+
const tb = this.getEntryTimestamp(b)
|
|
566
|
+
return tb - ta
|
|
567
|
+
})
|
|
568
|
+
},
|
|
569
|
+
persistCurrentSession(data) {
|
|
570
|
+
if (!this.currentSessionId) return
|
|
571
|
+
const title = data.title
|
|
572
|
+
const payload = {
|
|
573
|
+
id: this.currentSessionId,
|
|
574
|
+
messages: this.messages,
|
|
575
|
+
updatedAt: Date.now(),
|
|
576
|
+
title,
|
|
577
|
+
}
|
|
578
|
+
// Mocked storage: keep sessions in-memory only for current runtime
|
|
579
|
+
this.upsertHistoryEntry({
|
|
580
|
+
id: this.currentSessionId,
|
|
581
|
+
title,
|
|
582
|
+
updatedAt: payload.updatedAt,
|
|
583
|
+
})
|
|
584
|
+
},
|
|
585
|
+
upsertHistoryEntry(entry) {
|
|
586
|
+
// Mirror updatedAt to updated_at so date display works consistently
|
|
587
|
+
const normalized = {
|
|
588
|
+
...entry,
|
|
589
|
+
updated_at:
|
|
590
|
+
entry.updated_at != null
|
|
591
|
+
? entry.updated_at
|
|
592
|
+
: entry.updatedAt != null
|
|
593
|
+
? entry.updatedAt
|
|
594
|
+
: Date.now(),
|
|
595
|
+
}
|
|
596
|
+
const idx = this.history.findIndex((h) => h.id === normalized.id)
|
|
597
|
+
if (idx >= 0) {
|
|
598
|
+
this.$set(this.history, idx, {
|
|
599
|
+
...this.history[idx],
|
|
600
|
+
...normalized,
|
|
601
|
+
})
|
|
602
|
+
} else {
|
|
603
|
+
this.history.unshift(normalized)
|
|
604
|
+
}
|
|
605
|
+
// keep only last 10 overall (runtime only)
|
|
606
|
+
if (this.history.length > 10)
|
|
607
|
+
this.history = this.history.slice(0, 10)
|
|
608
|
+
// Always keep most recent first
|
|
609
|
+
this.history.sort((a, b) => {
|
|
610
|
+
const ta = this.getEntryTimestamp(a)
|
|
611
|
+
const tb = this.getEntryTimestamp(b)
|
|
612
|
+
return tb - ta
|
|
613
|
+
})
|
|
614
|
+
},
|
|
615
|
+
|
|
616
|
+
formatDate(ts) {
|
|
617
|
+
try {
|
|
618
|
+
const d = new Date(ts)
|
|
619
|
+
return d.toLocaleString()
|
|
620
|
+
} catch (e) {
|
|
621
|
+
return ''
|
|
622
|
+
}
|
|
623
|
+
},
|
|
624
|
+
|
|
625
|
+
getEntryTimestamp(entry) {
|
|
626
|
+
const entryDate = Date.parse(entry.updated_at)
|
|
627
|
+
if (!isNaN(entryDate)) return entryDate
|
|
628
|
+
return 0
|
|
629
|
+
},
|
|
630
|
+
|
|
631
|
+
getMessageTimestamp(msg) {
|
|
632
|
+
// Determine timestamp
|
|
633
|
+
const eventDate = Date.parse(msg.created_at)
|
|
634
|
+
if (!isNaN(eventDate)) return eventDate
|
|
635
|
+
return Date.now()
|
|
636
|
+
},
|
|
637
|
+
|
|
638
|
+
normalizeMessages(list) {
|
|
639
|
+
if (!Array.isArray(list)) return []
|
|
640
|
+
return list
|
|
641
|
+
.map((m) => {
|
|
642
|
+
const role =
|
|
643
|
+
m.role || (m.sender === 'user' ? 'user' : 'assistant')
|
|
644
|
+
const createdAtTs = this.getMessageTimestamp(m)
|
|
645
|
+
return { ...m, role, created_at: createdAtTs }
|
|
646
|
+
})
|
|
647
|
+
.sort((a, b) => a.created_at - b.created_at)
|
|
648
|
+
},
|
|
649
|
+
|
|
650
|
+
senderLabel(msg) {
|
|
651
|
+
return msg.role === 'user'
|
|
652
|
+
? this.$t(
|
|
653
|
+
'windward.integrations.components.ai_agent.chat.sender_you'
|
|
654
|
+
)
|
|
655
|
+
: this.$t(
|
|
656
|
+
'windward.integrations.components.ai_agent.chat.sender_agent'
|
|
657
|
+
)
|
|
658
|
+
},
|
|
659
|
+
|
|
660
|
+
messageStatus(index) {
|
|
661
|
+
const msg = this.messages[index]
|
|
662
|
+
if (!msg || msg.role !== 'user') return null
|
|
663
|
+
// If there is an assistant message after this one, consider it delivered (double check)
|
|
664
|
+
for (let j = index + 1; j < this.messages.length; j++) {
|
|
665
|
+
const m = this.messages[j]
|
|
666
|
+
if (!m) continue
|
|
667
|
+
if (m.role === 'assistant') return 'double'
|
|
668
|
+
}
|
|
669
|
+
// If it's the latest message and we're awaiting a reply, show a single check
|
|
670
|
+
if (index === this.messages.length - 1 && this.awaitingReply)
|
|
671
|
+
return 'single'
|
|
672
|
+
return null
|
|
673
|
+
},
|
|
674
|
+
|
|
675
|
+
scrollToBottom() {
|
|
676
|
+
const el = this.$refs.messagesScroll
|
|
677
|
+
if (el && el.scrollHeight != null) {
|
|
678
|
+
try {
|
|
679
|
+
el.scrollTop = el.scrollHeight
|
|
680
|
+
} catch (e) {
|
|
681
|
+
if (typeof el.scrollTo === 'function') {
|
|
682
|
+
el.scrollTo({ top: el.scrollHeight, behavior: 'auto' })
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
},
|
|
687
|
+
focusComposer() {
|
|
688
|
+
const ta = this.$refs.composer
|
|
689
|
+
if (ta && typeof ta.focus === 'function') ta.focus()
|
|
690
|
+
},
|
|
691
|
+
|
|
692
|
+
// ===== Infinite Scroll Helpers =====
|
|
693
|
+
resetPaginationState() {
|
|
694
|
+
this.perPage = 20
|
|
695
|
+
this.nextPage = 1
|
|
696
|
+
this.loadingOlder = false
|
|
697
|
+
this.noMoreOlder = false
|
|
698
|
+
},
|
|
699
|
+
buildMessageQuery(sessionId) {
|
|
700
|
+
return new AgentChatMessage().for(
|
|
701
|
+
new Organization({ id: this.organization.id }),
|
|
702
|
+
new Course({ id: this.course.id }),
|
|
703
|
+
new AgentChat({ id: sessionId })
|
|
704
|
+
)
|
|
705
|
+
},
|
|
706
|
+
async fetchMessagesPage(page = 1) {
|
|
707
|
+
const q = this.buildMessageQuery(this.currentSessionId)
|
|
708
|
+
// Try using page/limit if available; fallback to params
|
|
709
|
+
let results = []
|
|
710
|
+
try {
|
|
711
|
+
if (typeof q.page === 'function') q.page(page)
|
|
712
|
+
if (typeof q.limit === 'function') q.limit(this.perPage)
|
|
713
|
+
else if (typeof q.perPage === 'function')
|
|
714
|
+
q.perPage(this.perPage)
|
|
715
|
+
else if (typeof q.params === 'function')
|
|
716
|
+
q.params({ page, per_page: this.perPage })
|
|
717
|
+
results = await q.get()
|
|
718
|
+
} catch (e) {
|
|
719
|
+
// fallback minimal get without pagination
|
|
720
|
+
results = await this.buildMessageQuery(
|
|
721
|
+
this.currentSessionId
|
|
722
|
+
).get()
|
|
723
|
+
}
|
|
724
|
+
return Array.isArray(results) ? results : []
|
|
725
|
+
},
|
|
726
|
+
async reloadLatestMessages() {
|
|
727
|
+
// Start from newest page (desc page 1)
|
|
728
|
+
this.resetPaginationState()
|
|
729
|
+
const data = await this.fetchMessagesPage(1)
|
|
730
|
+
// We receive newest first; reverse to show ascending in UI
|
|
731
|
+
const normalized = this.normalizeMessages(data)
|
|
732
|
+
this.messages = normalized
|
|
733
|
+
this.nextPage = 2
|
|
734
|
+
// If fewer than perPage, no more older
|
|
735
|
+
if (!data || data.length < this.perPage) this.noMoreOlder = true
|
|
736
|
+
},
|
|
737
|
+
async loadOlder() {
|
|
738
|
+
if (this.loadingOlder || this.noMoreOlder) return
|
|
739
|
+
const el = this.$refs.messagesScroll
|
|
740
|
+
const prevHeight = el ? el.scrollHeight : 0
|
|
741
|
+
this.loadingOlder = true
|
|
742
|
+
try {
|
|
743
|
+
const page = this.nextPage
|
|
744
|
+
const data = await this.fetchMessagesPage(page)
|
|
745
|
+
const olderAsc = this.normalizeMessages(data)
|
|
746
|
+
// prepend
|
|
747
|
+
this.messages = olderAsc.concat(this.messages)
|
|
748
|
+
this.nextPage = page + 1
|
|
749
|
+
if (!data || data.length < this.perPage) this.noMoreOlder = true
|
|
750
|
+
this.$nextTick(() => {
|
|
751
|
+
if (el) {
|
|
752
|
+
const newHeight = el.scrollHeight
|
|
753
|
+
el.scrollTop = newHeight - prevHeight + el.scrollTop
|
|
754
|
+
}
|
|
755
|
+
})
|
|
756
|
+
} catch (e) {
|
|
757
|
+
// eslint-disable-next-line no-console
|
|
758
|
+
console.warn('Failed to load older messages', e)
|
|
759
|
+
} finally {
|
|
760
|
+
this.loadingOlder = false
|
|
761
|
+
}
|
|
762
|
+
},
|
|
763
|
+
onMessagesScroll() {
|
|
764
|
+
const el = this.$refs.messagesScroll
|
|
765
|
+
if (!el) return
|
|
766
|
+
if (el.scrollTop <= 40) {
|
|
767
|
+
this.loadOlder()
|
|
768
|
+
}
|
|
769
|
+
},
|
|
770
|
+
},
|
|
771
|
+
}
|
|
772
|
+
</script>
|
|
773
|
+
|
|
774
|
+
<style scoped>
|
|
775
|
+
.chat-window {
|
|
776
|
+
display: flex;
|
|
777
|
+
flex-direction: column;
|
|
778
|
+
height: 86vh;
|
|
779
|
+
max-height: 100vh;
|
|
780
|
+
overflow: hidden; /* ensure only messages area scrolls */
|
|
781
|
+
}
|
|
782
|
+
.chat-header {
|
|
783
|
+
padding: 12px 16px;
|
|
784
|
+
border-bottom: 1px solid #e6e6e6;
|
|
785
|
+
}
|
|
786
|
+
.header-title {
|
|
787
|
+
font-weight: 600;
|
|
788
|
+
font-size: 16px;
|
|
789
|
+
}
|
|
790
|
+
.messages {
|
|
791
|
+
flex: 1;
|
|
792
|
+
overflow-y: auto;
|
|
793
|
+
padding: 12px 16px;
|
|
794
|
+
background: var(--v-primary-lighten3);
|
|
795
|
+
}
|
|
796
|
+
.system-tip {
|
|
797
|
+
display: inline-block;
|
|
798
|
+
max-width: 560px;
|
|
799
|
+
background: #ffffff;
|
|
800
|
+
color: #333;
|
|
801
|
+
padding: 12px 14px;
|
|
802
|
+
border-radius: 8px;
|
|
803
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
804
|
+
}
|
|
805
|
+
.message {
|
|
806
|
+
display: flex;
|
|
807
|
+
margin: 8px 0;
|
|
808
|
+
}
|
|
809
|
+
.message.user {
|
|
810
|
+
justify-content: flex-end;
|
|
811
|
+
color: var(--v-text-primary-base);
|
|
812
|
+
}
|
|
813
|
+
.message.assistant {
|
|
814
|
+
justify-content: flex-start;
|
|
815
|
+
color: var(--v-text-primary-base);
|
|
816
|
+
}
|
|
817
|
+
.bubble {
|
|
818
|
+
max-width: 85%;
|
|
819
|
+
padding: 10px 12px;
|
|
820
|
+
border-radius: 12px;
|
|
821
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
822
|
+
}
|
|
823
|
+
.message.user .bubble {
|
|
824
|
+
background: var(--v-secondary-base);
|
|
825
|
+
}
|
|
826
|
+
.message.assistant .bubble {
|
|
827
|
+
background: var(--v-primary-base);
|
|
828
|
+
word-break: break-word;
|
|
829
|
+
}
|
|
830
|
+
.bubble-meta {
|
|
831
|
+
display: flex;
|
|
832
|
+
justify-content: space-between;
|
|
833
|
+
align-items: center;
|
|
834
|
+
gap: 12px;
|
|
835
|
+
font-size: 11px;
|
|
836
|
+
opacity: 0.85;
|
|
837
|
+
margin-bottom: 4px;
|
|
838
|
+
}
|
|
839
|
+
.bubble-text {
|
|
840
|
+
white-space: pre-wrap;
|
|
841
|
+
}
|
|
842
|
+
.checkmarks {
|
|
843
|
+
display: inline-flex;
|
|
844
|
+
align-items: center;
|
|
845
|
+
gap: 2px;
|
|
846
|
+
opacity: 0.85;
|
|
847
|
+
}
|
|
848
|
+
.composer {
|
|
849
|
+
position: relative;
|
|
850
|
+
border-top: 1px solid #e6e6e6;
|
|
851
|
+
padding: 10px 12px 18px 12px; /* extra for hint */
|
|
852
|
+
}
|
|
853
|
+
.composer-input >>> textarea {
|
|
854
|
+
/* deep selector for Vuetify textarea */
|
|
855
|
+
resize: none;
|
|
856
|
+
}
|
|
857
|
+
.composer-hint {
|
|
858
|
+
font-size: 12px;
|
|
859
|
+
}
|
|
860
|
+
.sr-only {
|
|
861
|
+
position: absolute;
|
|
862
|
+
width: 1px;
|
|
863
|
+
height: 1px;
|
|
864
|
+
padding: 0;
|
|
865
|
+
margin: -1px;
|
|
866
|
+
overflow: hidden;
|
|
867
|
+
clip: rect(0, 0, 1px, 1px);
|
|
868
|
+
border: 0;
|
|
869
|
+
}
|
|
870
|
+
/* typing dots indicator */
|
|
871
|
+
.typing-dots {
|
|
872
|
+
display: inline-flex;
|
|
873
|
+
align-items: center;
|
|
874
|
+
gap: 4px;
|
|
875
|
+
}
|
|
876
|
+
.typing-dots .dot {
|
|
877
|
+
width: 6px;
|
|
878
|
+
height: 6px;
|
|
879
|
+
background: var(--v-text-primary-base);
|
|
880
|
+
border-radius: 50%;
|
|
881
|
+
display: inline-block;
|
|
882
|
+
animation: bounce 1.3s infinite ease-in-out;
|
|
883
|
+
}
|
|
884
|
+
.typing-dots .dot:nth-child(2) {
|
|
885
|
+
animation-delay: 0.15s;
|
|
886
|
+
}
|
|
887
|
+
.typing-dots .dot:nth-child(3) {
|
|
888
|
+
animation-delay: 0.3s;
|
|
889
|
+
}
|
|
890
|
+
@keyframes bounce {
|
|
891
|
+
0%,
|
|
892
|
+
80%,
|
|
893
|
+
100% {
|
|
894
|
+
transform: translateY(0);
|
|
895
|
+
opacity: 0.6;
|
|
896
|
+
}
|
|
897
|
+
40% {
|
|
898
|
+
transform: translateY(-4px);
|
|
899
|
+
opacity: 1;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
/* awaiting indicator layout */
|
|
903
|
+
.awaiting-indicator {
|
|
904
|
+
display: inline-flex;
|
|
905
|
+
align-items: center;
|
|
906
|
+
gap: 8px;
|
|
907
|
+
}
|
|
908
|
+
.processing-note {
|
|
909
|
+
font-size: 12px;
|
|
910
|
+
color: var(--v-text-primary-base);
|
|
911
|
+
}
|
|
912
|
+
/* highlight active chat in history dropdown */
|
|
913
|
+
.active-chat {
|
|
914
|
+
background-color: rgba(0, 0, 0, 0.06) !important;
|
|
915
|
+
}
|
|
916
|
+
</style>
|