@jskit-ai/assistant-core 0.1.170 → 0.1.172

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 (29) hide show
  1. package/examples/conversation/App.vue +62 -10
  2. package/examples/conversation/README.md +29 -4
  3. package/examples/conversation/attachments.js +45 -0
  4. package/examples/conversation/integrations.json +28 -0
  5. package/examples/conversation/package.json +6 -4
  6. package/examples/conversation/server.js +61 -10
  7. package/fixtures/responsive-assistant/ConversationFixture.vue +54 -1
  8. package/package.json +21 -7
  9. package/src/client/conversation/AssistantAttachmentPreview.vue +38 -0
  10. package/src/client/conversation/AssistantAttachmentQueue.vue +563 -0
  11. package/src/client/conversation/AssistantComposerSupport.vue +235 -0
  12. package/src/client/conversation/AssistantConversationElement.vue +99 -8
  13. package/src/client/conversation/AssistantGoalControl.vue +81 -0
  14. package/src/client/conversation/AssistantMessageAttachments.vue +114 -0
  15. package/src/client/conversation/AssistantModelControl.vue +334 -0
  16. package/src/client/conversation/AssistantQuestionInputs.vue +124 -0
  17. package/src/client/conversation/AssistantTranscript.vue +2 -1
  18. package/src/client/conversation/index.js +9 -0
  19. package/src/client/conversation/useAssistantAttachments.js +991 -0
  20. package/src/client/conversation/useAssistantSuggestions.js +99 -0
  21. package/src/server/index.js +1 -0
  22. package/src/server/lib/aiConnectionClient.js +90 -0
  23. package/src/server/lib/providers/anthropicClient.js +6 -21
  24. package/src/shared/assistantResource.js +7 -0
  25. package/src/shared/conversation/attachments.js +13 -0
  26. package/src/shared/conversation/index.js +1 -0
  27. package/test/aiConnectionClient.test.js +159 -0
  28. package/test/assistantScroll.browser.test.js +144 -0
  29. package/test/suggestions.test.js +114 -0
@@ -0,0 +1,563 @@
1
+ <template>
2
+ <section
3
+ v-if="items.length"
4
+ ref="queueRoot"
5
+ class="assistant-attachment-queue"
6
+ :class="{ 'assistant-attachment-queue--standalone': !joined }"
7
+ :aria-busy="hasUnresolved ? 'true' : undefined"
8
+ aria-label="Message attachments"
9
+ >
10
+ <header class="assistant-attachment-queue__summary">
11
+ <span>{{ aggregateLabel }}</span>
12
+ <span class="assistant-attachment-queue__limit">{{ retainedItems.length }} / {{ maximumFiles }}</span>
13
+ </header>
14
+
15
+ <ol class="assistant-attachment-queue__items">
16
+ <li
17
+ v-for="(item, index) in items"
18
+ :key="itemKey(item)"
19
+ class="assistant-attachment-queue__item"
20
+ :class="`assistant-attachment-queue__item--${itemPhase(item)}`"
21
+ >
22
+ <v-icon
23
+ class="assistant-attachment-queue__icon"
24
+ :color="itemColor(item)"
25
+ :icon="itemIcon(item)"
26
+ size="18"
27
+ aria-hidden="true"
28
+ />
29
+
30
+ <div class="assistant-attachment-queue__content">
31
+ <div class="assistant-attachment-queue__primary">
32
+ <component
33
+ :is="item.attachmentId && previewEnabled ? 'button' : 'span'"
34
+ :type="item.attachmentId && previewEnabled ? 'button' : undefined"
35
+ class="assistant-attachment-queue__name"
36
+ :title="itemName(item)"
37
+ @click="item.attachmentId && previewEnabled && emit('preview', item.receipt || item)"
38
+ >
39
+ {{ item.receipt?.reference }} {{ itemName(item) }}
40
+ </component>
41
+ <span class="assistant-attachment-queue__size">{{ attachmentSizeLabel(item.size) }}</span>
42
+ </div>
43
+ <div class="assistant-attachment-queue__secondary">
44
+ <span>{{ itemStatusLabel(item) }}</span>
45
+ <span v-if="itemProgressVisible(item)">{{ itemBytesLabel(item) }}</span>
46
+ </div>
47
+ <div
48
+ v-if="itemProgressStationary(item)"
49
+ aria-hidden="true"
50
+ class="assistant-attachment-queue__progress assistant-attachment-queue__progress--stationary rounded-pill"
51
+ />
52
+ <v-progress-linear
53
+ v-else-if="itemProgressVisible(item)"
54
+ :aria-label="`Upload progress for ${itemName(item)}`"
55
+ class="assistant-attachment-queue__progress"
56
+ color="primary"
57
+ height="3"
58
+ :indeterminate="itemProgressIndeterminate(item)"
59
+ :model-value="itemProgressValue(item)"
60
+ rounded
61
+ />
62
+ <span
63
+ v-if="item.error"
64
+ class="assistant-attachment-queue__error"
65
+ role="alert"
66
+ >
67
+ {{ item.error }}
68
+ </span>
69
+ </div>
70
+
71
+ <div class="assistant-attachment-queue__actions">
72
+ <v-btn
73
+ v-if="itemCancelable(item)"
74
+ :ref="(element) => setActionElement(item, 'cancel', element)"
75
+ :aria-label="`Cancel ${itemName(item)}`"
76
+ class="assistant-attachment-queue__action"
77
+ :icon="mdiCloseCircleOutline"
78
+ size="small"
79
+ :title="`Cancel ${itemName(item)}`"
80
+ type="button"
81
+ variant="text"
82
+ @click="handleAction('cancel', item, index)"
83
+ />
84
+ <v-btn
85
+ v-if="itemPhase(item) === 'failed' || itemPhase(item) === 'cancelled'"
86
+ :ref="(element) => setActionElement(item, 'retry', element)"
87
+ :aria-label="`Retry ${itemName(item)}`"
88
+ class="assistant-attachment-queue__action"
89
+ :icon="mdiRefresh"
90
+ size="small"
91
+ :title="`Retry ${itemName(item)}`"
92
+ type="button"
93
+ variant="text"
94
+ @click="handleAction('retry', item, index)"
95
+ />
96
+ <v-btn
97
+ v-if="itemPhase(item) === 'ready' || itemPhase(item) === 'failed' || itemPhase(item) === 'cancelled'"
98
+ :ref="(element) => setActionElement(item, 'remove', element)"
99
+ :aria-label="`Remove ${itemName(item)}`"
100
+ class="assistant-attachment-queue__action"
101
+ :icon="mdiClose"
102
+ size="small"
103
+ :title="`Remove ${itemName(item)}`"
104
+ type="button"
105
+ variant="text"
106
+ @click="handleAction('remove', item, index)"
107
+ />
108
+ </div>
109
+ </li>
110
+ </ol>
111
+
112
+ <span
113
+ class="assistant-attachment-queue__announcement"
114
+ aria-atomic="true"
115
+ aria-live="polite"
116
+ role="status"
117
+ >
118
+ {{ announcement }}
119
+ </span>
120
+ </section>
121
+ </template>
122
+
123
+ <script setup>
124
+ import { computed, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
125
+ import {
126
+ mdiAlertCircleOutline,
127
+ mdiCheckCircleOutline,
128
+ mdiClose,
129
+ mdiCloseCircleOutline,
130
+ mdiFileOutline,
131
+ mdiRefresh
132
+ } from "@mdi/js";
133
+
134
+ import { attachmentSizeLabel } from "../../shared/conversation/attachments.js";
135
+
136
+ const emit = defineEmits(["cancel", "remove", "retry", "preview", "focus-input"]);
137
+
138
+ const props = defineProps({
139
+ previewEnabled: Boolean,
140
+ items: {
141
+ default: () => [],
142
+ type: Array
143
+ },
144
+ joined: {
145
+ default: true,
146
+ type: Boolean
147
+ },
148
+ maximumFiles: {
149
+ default: 10,
150
+ type: Number
151
+ }
152
+ });
153
+
154
+ const activePhases = new Set(["queued", "preparing", "uploading", "delivering"]);
155
+ const queueRoot = ref(null);
156
+ const reducedMotion = ref(false);
157
+ const actionElements = new Map();
158
+ let reducedMotionMediaQuery = null;
159
+ const retainedItems = computed(() => props.items.filter((item) => itemPhase(item) !== "cancelled"));
160
+ const completedCount = computed(() => props.items.filter((item) => itemPhase(item) === "ready").length);
161
+ const hasUnresolved = computed(() => props.items.some((item) => activePhases.has(itemPhase(item))));
162
+ const aggregatePercent = computed(() => {
163
+ if (!retainedItems.value.length) {
164
+ return 0;
165
+ }
166
+ const totalSize = retainedItems.value.reduce((sum, item) => sum + Math.max(0, Number(item.size) || 0), 0);
167
+ if (retainedItems.value.some((item) => (
168
+ activePhases.has(itemPhase(item)) && itemPercent(item) === null
169
+ ))) {
170
+ return null;
171
+ }
172
+ if (totalSize < 1) {
173
+ return Math.round((completedCount.value / retainedItems.value.length) * 100);
174
+ }
175
+ const sent = retainedItems.value.reduce((sum, item) => {
176
+ if (itemPhase(item) === "ready") {
177
+ return sum + Math.max(0, Number(item.size) || 0);
178
+ }
179
+ return sum + Math.min(Math.max(0, Number(item.bytesSent) || 0), Math.max(0, Number(item.size) || 0));
180
+ }, 0);
181
+ return Math.min(100, Math.max(0, Math.round((sent / totalSize) * 100)));
182
+ });
183
+ const aggregateLabel = computed(() => {
184
+ const total = retainedItems.value.length;
185
+ const active = props.items.filter((item) => activePhases.has(itemPhase(item))).length;
186
+ const failed = props.items.filter((item) => itemPhase(item) === "failed").length;
187
+ const cancelled = props.items.filter((item) => itemPhase(item) === "cancelled").length;
188
+ if (active) {
189
+ return aggregatePercent.value === null
190
+ ? `${completedCount.value} of ${total} ready · in progress`
191
+ : `${completedCount.value} of ${total} ready · ${aggregatePercent.value}%`;
192
+ }
193
+ if (failed) {
194
+ return `${completedCount.value} of ${total} ready · ${failed} ${failed === 1 ? "needs" : "need"} attention`;
195
+ }
196
+ if (cancelled) {
197
+ return `${completedCount.value} ready · ${cancelled} cancelled`;
198
+ }
199
+ return `${completedCount.value} of ${total} ready`;
200
+ });
201
+ const announcement = computed(() => {
202
+ const messages = [];
203
+ const failed = props.items.filter((item) => itemPhase(item) === "failed").length;
204
+ if (failed) {
205
+ messages.push(`${attachmentCountLabel(failed)} ${failed === 1 ? "needs" : "need"} attention.`);
206
+ }
207
+ const active = props.items.filter((item) => activePhases.has(itemPhase(item))).length;
208
+ if (active) {
209
+ messages.push(`${attachmentCountLabel(active)} ${active === 1 ? "is" : "are"} in progress.`);
210
+ }
211
+ const cancelled = props.items.filter((item) => itemPhase(item) === "cancelled").length;
212
+ if (cancelled) {
213
+ messages.push(`${attachmentCountLabel(cancelled)} cancelled.`);
214
+ }
215
+ if (completedCount.value) {
216
+ messages.push(`${attachmentCountLabel(completedCount.value)} ready.`);
217
+ }
218
+ return messages.join(" ");
219
+ });
220
+
221
+ function attachmentCountLabel(count = 0) {
222
+ return `${count} attachment${count === 1 ? "" : "s"}`;
223
+ }
224
+
225
+ function itemKey(item = {}) {
226
+ return String(item.clientId || item.id || item.attachmentId || item.fileName || "attachment");
227
+ }
228
+
229
+ function itemName(item = {}) {
230
+ return String(item.fileName || item.file?.name || "attachment");
231
+ }
232
+
233
+ function itemPhase(item = {}) {
234
+ return String(item.phase || item.status || "queued");
235
+ }
236
+
237
+ function itemPercent(item = {}) {
238
+ if (itemPhase(item) === "ready") {
239
+ return 100;
240
+ }
241
+ if (itemPhase(item) === "delivering") {
242
+ return 100;
243
+ }
244
+ if (itemPhase(item) === "queued") {
245
+ return 0;
246
+ }
247
+ const progressValue = item.percent ?? item.progress;
248
+ const directPercent = progressValue === null || progressValue === undefined || progressValue === ""
249
+ ? null
250
+ : Number(progressValue);
251
+ if (directPercent !== null && Number.isFinite(directPercent)) {
252
+ return Math.min(100, Math.max(0, Math.round(directPercent)));
253
+ }
254
+ const size = Number(item.size) || 0;
255
+ return size > 0 && Number(item.bytesSent) > 0
256
+ ? Math.min(100, Math.max(0, Math.round(((Number(item.bytesSent) || 0) / size) * 100)))
257
+ : null;
258
+ }
259
+
260
+ function itemProgressVisible(item = {}) {
261
+ return activePhases.has(itemPhase(item));
262
+ }
263
+
264
+ function itemProgressIndeterminate(item = {}) {
265
+ return itemProgressVisible(item) && itemPercent(item) === null;
266
+ }
267
+
268
+ function itemProgressValue(item = {}) {
269
+ return itemPercent(item) ?? 0;
270
+ }
271
+
272
+ function itemProgressStationary(item = {}) {
273
+ return reducedMotion.value && itemProgressVisible(item) && itemPercent(item) === null;
274
+ }
275
+
276
+ function itemBytesLabel(item = {}) {
277
+ const size = Math.max(0, Number(item.size) || 0);
278
+ const sent = Math.min(Math.max(0, Number(item.bytesSent) || 0), size);
279
+ const percent = itemPercent(item);
280
+ return percent === null
281
+ ? `${attachmentSizeLabel(sent)} / ${attachmentSizeLabel(size)}`
282
+ : `${attachmentSizeLabel(sent)} / ${attachmentSizeLabel(size)} · ${percent}%`;
283
+ }
284
+
285
+ function itemStatusLabel(item = {}) {
286
+ const labels = {
287
+ cancelled: "Cancelled",
288
+ delivering: "Sending to Codex",
289
+ failed: item.failureStage === "preparing"
290
+ ? "Preparation failed"
291
+ : item.failureStage === "handoff"
292
+ ? "Sending failed"
293
+ : "Upload failed",
294
+ preparing: "Preparing",
295
+ queued: "Queued",
296
+ ready: "Ready",
297
+ uploading: "Uploading"
298
+ };
299
+ return labels[itemPhase(item)] || "Queued";
300
+ }
301
+
302
+ function itemCancelable(item = {}) {
303
+ return ["preparing", "queued", "uploading"].includes(itemPhase(item));
304
+ }
305
+
306
+ function actionKey(item = {}, action = "") {
307
+ return `${itemKey(item)}:${action}`;
308
+ }
309
+
310
+ function actionElement(element = null) {
311
+ return element?.$el || element || null;
312
+ }
313
+
314
+ function setActionElement(item = {}, action = "", element = null) {
315
+ const key = actionKey(item, action);
316
+ if (element) {
317
+ actionElements.set(key, element);
318
+ return;
319
+ }
320
+ actionElements.delete(key);
321
+ }
322
+
323
+ function focusItemAction(item = {}) {
324
+ for (const action of ["cancel", "retry", "remove"]) {
325
+ const target = actionElement(actionElements.get(actionKey(item, action)));
326
+ if (target && !target.disabled) {
327
+ target.focus?.();
328
+ return true;
329
+ }
330
+ }
331
+ return false;
332
+ }
333
+
334
+ async function handleAction(action, item, index) {
335
+ emit(action, item);
336
+ await nextTick();
337
+ const retainedItem = props.items.find((candidate) => itemKey(candidate) === itemKey(item));
338
+ if (retainedItem && focusItemAction(retainedItem)) {
339
+ return;
340
+ }
341
+ const neighbour = props.items[index] || props.items[index - 1];
342
+ if (neighbour && focusItemAction(neighbour)) {
343
+ return;
344
+ }
345
+ emit("focus-input");
346
+ }
347
+
348
+ function itemIcon(item = {}) {
349
+ if (itemPhase(item) === "ready") {
350
+ return mdiCheckCircleOutline;
351
+ }
352
+ if (itemPhase(item) === "failed") {
353
+ return mdiAlertCircleOutline;
354
+ }
355
+ return mdiFileOutline;
356
+ }
357
+
358
+ function itemColor(item = {}) {
359
+ if (itemPhase(item) === "ready") {
360
+ return "success";
361
+ }
362
+ if (itemPhase(item) === "failed") {
363
+ return "error";
364
+ }
365
+ return "primary";
366
+ }
367
+
368
+ function updateReducedMotionPreference() {
369
+ reducedMotion.value = reducedMotionMediaQuery?.matches === true;
370
+ }
371
+
372
+ if (typeof window !== "undefined" && typeof window.matchMedia === "function") {
373
+ reducedMotionMediaQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
374
+ updateReducedMotionPreference();
375
+ }
376
+
377
+ onMounted(() => {
378
+ if (!reducedMotionMediaQuery) {
379
+ return;
380
+ }
381
+ if (typeof reducedMotionMediaQuery.addEventListener === "function") {
382
+ reducedMotionMediaQuery.addEventListener("change", updateReducedMotionPreference);
383
+ } else {
384
+ reducedMotionMediaQuery.addListener?.(updateReducedMotionPreference);
385
+ }
386
+ });
387
+
388
+ onBeforeUnmount(() => {
389
+ if (typeof reducedMotionMediaQuery?.removeEventListener === "function") {
390
+ reducedMotionMediaQuery.removeEventListener("change", updateReducedMotionPreference);
391
+ } else {
392
+ reducedMotionMediaQuery?.removeListener?.(updateReducedMotionPreference);
393
+ }
394
+ reducedMotionMediaQuery = null;
395
+ });
396
+ </script>
397
+
398
+ <style scoped>
399
+ .assistant-attachment-queue {
400
+ background: rgba(var(--v-theme-on-surface), 0.035);
401
+ border: 1px solid rgba(var(--v-theme-outline), 0.2);
402
+ border-bottom: 0;
403
+ border-radius: 14px 14px 0 0;
404
+ color: rgb(var(--v-theme-on-surface));
405
+ display: grid;
406
+ gap: 0.2rem;
407
+ max-height: min(15rem, 32dvh);
408
+ min-width: 0;
409
+ overflow: hidden;
410
+ padding: 0.35rem 0.4rem;
411
+ }
412
+
413
+ .assistant-attachment-queue__summary,
414
+ .assistant-attachment-queue__primary,
415
+ .assistant-attachment-queue__secondary {
416
+ align-items: baseline;
417
+ display: flex;
418
+ gap: 0.45rem;
419
+ justify-content: space-between;
420
+ min-width: 0;
421
+ }
422
+
423
+ .assistant-attachment-queue--standalone {
424
+ border-bottom: 1px solid rgba(var(--v-theme-outline), 0.2);
425
+ border-radius: 10px;
426
+ }
427
+
428
+ .assistant-attachment-queue__summary {
429
+ color: rgba(var(--v-theme-on-surface), 0.72);
430
+ font-size: 0.72rem;
431
+ font-weight: 650;
432
+ line-height: 1.25;
433
+ padding: 0 0.28rem 0.15rem;
434
+ }
435
+
436
+ .assistant-attachment-queue__limit {
437
+ flex: 0 0 auto;
438
+ font-variant-numeric: tabular-nums;
439
+ }
440
+
441
+ .assistant-attachment-queue__items {
442
+ display: grid;
443
+ gap: 0.25rem;
444
+ list-style: none;
445
+ margin: 0;
446
+ min-width: 0;
447
+ overflow-y: auto;
448
+ padding: 0;
449
+ }
450
+
451
+ .assistant-attachment-queue__item {
452
+ align-items: center;
453
+ background: rgb(var(--v-theme-surface));
454
+ border-radius: 10px;
455
+ display: grid;
456
+ gap: 0.4rem;
457
+ grid-template-columns: auto minmax(0, 1fr) auto;
458
+ min-height: 3rem;
459
+ padding: 0.28rem 0.2rem 0.28rem 0.5rem;
460
+ }
461
+
462
+ .assistant-attachment-queue__item--failed {
463
+ background: rgba(var(--v-theme-error), 0.07);
464
+ }
465
+
466
+ .assistant-attachment-queue__icon {
467
+ align-self: start;
468
+ margin-top: 0.3rem;
469
+ }
470
+
471
+ .assistant-attachment-queue__content {
472
+ display: grid;
473
+ gap: 0.1rem;
474
+ min-width: 0;
475
+ }
476
+
477
+ .assistant-attachment-queue__name {
478
+ background: transparent;
479
+ border: 0;
480
+ color: inherit;
481
+ padding: 0;
482
+ text-align: left;
483
+ font-size: 0.78rem;
484
+ font-weight: 650;
485
+ min-width: 0;
486
+ overflow: hidden;
487
+ text-overflow: ellipsis;
488
+ white-space: nowrap;
489
+ }
490
+
491
+ button.assistant-attachment-queue__name {
492
+ cursor: pointer;
493
+ }
494
+
495
+ .assistant-attachment-queue__size,
496
+ .assistant-attachment-queue__secondary,
497
+ .assistant-attachment-queue__error {
498
+ color: rgba(var(--v-theme-on-surface), 0.68);
499
+ font-size: 0.68rem;
500
+ line-height: 1.25;
501
+ }
502
+
503
+ .assistant-attachment-queue__size {
504
+ flex: 0 0 auto;
505
+ }
506
+
507
+ .assistant-attachment-queue__secondary {
508
+ font-variant-numeric: tabular-nums;
509
+ }
510
+
511
+ .assistant-attachment-queue__error {
512
+ color: rgb(var(--v-theme-error));
513
+ overflow-wrap: anywhere;
514
+ }
515
+
516
+ .assistant-attachment-queue__progress {
517
+ margin-top: 0.08rem;
518
+ }
519
+
520
+ .assistant-attachment-queue__progress--stationary {
521
+ background: rgba(var(--v-theme-primary), 0.18);
522
+ height: 3px;
523
+ }
524
+
525
+ .assistant-attachment-queue__actions {
526
+ align-items: center;
527
+ display: flex;
528
+ flex: 0 0 auto;
529
+ }
530
+
531
+ .assistant-attachment-queue__action {
532
+ min-height: 2.5rem;
533
+ min-width: 2.5rem;
534
+ }
535
+
536
+ .assistant-attachment-queue__announcement {
537
+ height: 1px;
538
+ margin: -1px;
539
+ overflow: hidden;
540
+ padding: 0;
541
+ position: absolute;
542
+ width: 1px;
543
+ clip: rect(0 0 0 0);
544
+ }
545
+
546
+ @media (pointer: coarse) {
547
+ .assistant-attachment-queue__action {
548
+ min-height: 3rem;
549
+ min-width: 3rem;
550
+ }
551
+ }
552
+
553
+ @media (max-width: 599px) {
554
+ .assistant-attachment-queue {
555
+ max-height: min(13rem, 30dvh);
556
+ }
557
+
558
+ .assistant-attachment-queue__item {
559
+ gap: 0.25rem;
560
+ padding-left: 0.4rem;
561
+ }
562
+ }
563
+ </style>