@windward/integrations 0.16.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/components/Integration/AiAgentIntegration/ChatWindow.vue +916 -0
  3. package/components/LLM/BloomTaxonomySelector.vue +120 -0
  4. package/components/LLM/ContentSelector.vue +66 -0
  5. package/components/LLM/GenerateContent/AssessmentQuestionGenerateButton.vue +285 -0
  6. package/components/LLM/GenerateContent/BlockQuestionGenerateButton.vue +514 -0
  7. package/components/LLM/GenerateContent/FakeTextStream.vue +67 -0
  8. package/i18n/en-US/components/ai_agent/chat.ts +20 -0
  9. package/i18n/en-US/components/ai_agent/index.ts +5 -0
  10. package/i18n/en-US/components/index.ts +4 -0
  11. package/i18n/en-US/components/llm/blooms.ts +15 -0
  12. package/i18n/en-US/components/llm/content_selector.ts +3 -0
  13. package/i18n/en-US/components/llm/generate_content/fake_text_stream.ts +62 -0
  14. package/i18n/en-US/components/llm/generate_content/generate_questions.ts +81 -0
  15. package/i18n/en-US/components/llm/generate_content/index.ts +7 -0
  16. package/i18n/en-US/components/llm/index.ts +10 -0
  17. package/i18n/en-US/shared/permission.ts +10 -0
  18. package/i18n/en-US/shared/settings.ts +2 -1
  19. package/i18n/es-ES/components/ai_agent/chat.ts +20 -0
  20. package/i18n/es-ES/components/ai_agent/index.ts +5 -0
  21. package/i18n/es-ES/components/index.ts +4 -0
  22. package/i18n/es-ES/components/llm/blooms.ts +15 -0
  23. package/i18n/es-ES/components/llm/content_selector.ts +3 -0
  24. package/i18n/es-ES/components/llm/generate_content/fake_text_stream.ts +62 -0
  25. package/i18n/es-ES/components/llm/generate_content/generate_questions.ts +85 -0
  26. package/i18n/es-ES/components/llm/generate_content/index.ts +7 -0
  27. package/i18n/es-ES/components/llm/index.ts +10 -0
  28. package/i18n/es-ES/shared/permission.ts +10 -0
  29. package/i18n/es-ES/shared/settings.ts +2 -1
  30. package/i18n/sv-SE/components/ai_agent/chat.ts +19 -0
  31. package/i18n/sv-SE/components/ai_agent/index.ts +5 -0
  32. package/i18n/sv-SE/components/index.ts +4 -0
  33. package/i18n/sv-SE/components/llm/blooms.ts +15 -0
  34. package/i18n/sv-SE/components/llm/content_selector.ts +3 -0
  35. package/i18n/sv-SE/components/llm/generate_content/fake_text_stream.ts +62 -0
  36. package/i18n/sv-SE/components/llm/generate_content/generate_questions.ts +82 -0
  37. package/i18n/sv-SE/components/llm/generate_content/index.ts +7 -0
  38. package/i18n/sv-SE/components/llm/index.ts +10 -0
  39. package/i18n/sv-SE/shared/permission.ts +10 -0
  40. package/i18n/sv-SE/shared/settings.ts +1 -0
  41. package/models/Activity.ts +8 -0
  42. package/models/AgentChat.ts +12 -0
  43. package/models/AgentChatMessage.ts +12 -0
  44. package/package.json +2 -1
  45. 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>