@jskit-ai/assistant-core 0.1.171 → 0.1.173

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 (31) 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/fixtures/responsive-assistant/ProgressFixture.vue +61 -0
  9. package/fixtures/responsive-assistant/main.js +3 -1
  10. package/package.json +21 -7
  11. package/src/client/conversation/AssistantAttachmentPreview.vue +38 -0
  12. package/src/client/conversation/AssistantAttachmentQueue.vue +563 -0
  13. package/src/client/conversation/AssistantComposerSupport.vue +235 -0
  14. package/src/client/conversation/AssistantConversationElement.vue +103 -9
  15. package/src/client/conversation/AssistantGoalControl.vue +81 -0
  16. package/src/client/conversation/AssistantMessageAttachments.vue +114 -0
  17. package/src/client/conversation/AssistantModelControl.vue +334 -0
  18. package/src/client/conversation/AssistantQuestionInputs.vue +124 -0
  19. package/src/client/conversation/AssistantTranscript.vue +139 -174
  20. package/src/client/conversation/index.js +9 -0
  21. package/src/client/conversation/useAssistantAttachments.js +991 -0
  22. package/src/client/conversation/useAssistantSuggestions.js +99 -0
  23. package/src/server/index.js +1 -0
  24. package/src/server/lib/aiConnectionClient.js +90 -0
  25. package/src/server/lib/providers/anthropicClient.js +6 -21
  26. package/src/shared/assistantResource.js +7 -0
  27. package/src/shared/conversation/attachments.js +13 -0
  28. package/src/shared/conversation/index.js +1 -0
  29. package/test/aiConnectionClient.test.js +159 -0
  30. package/test/assistantScroll.browser.test.js +217 -0
  31. package/test/suggestions.test.js +114 -0
@@ -98,12 +98,12 @@
98
98
  </div>
99
99
 
100
100
  <article
101
- v-for="turn in displayTurns"
102
- :key="turn.turnId"
101
+ v-for="entry in displayEntries"
102
+ :key="entry.key"
103
103
  class="assistant-transcript__turn"
104
104
  >
105
105
  <div
106
- v-if="turn.system"
106
+ v-if="entry.role === 'system'"
107
107
  class="assistant-transcript__system"
108
108
  >
109
109
  <v-icon
@@ -114,55 +114,55 @@
114
114
  <div class="assistant-transcript__system-body">
115
115
  <div class="assistant-transcript__system-meta">
116
116
  <span>{{ systemLabel }}</span>
117
- <time v-if="turn.system.displayAt">{{ turn.system.displayAt }}</time>
117
+ <time v-if="entry.message.displayAt">{{ entry.message.displayAt }}</time>
118
118
  </div>
119
- <slot name="system-message" :message="turn.system"><LongTextPreviewBlocks compact :blocks="turn.system.blocks" @link-click="handleLongTextLinkClick" /></slot>
119
+ <slot name="system-message" :message="entry.message"><LongTextPreviewBlocks compact :blocks="entry.message.blocks" @link-click="handleLongTextLinkClick" /></slot>
120
120
  </div>
121
121
  </div>
122
122
 
123
123
  <div
124
- v-if="turn.user"
124
+ v-else-if="entry.role === 'user'"
125
125
  class="assistant-transcript__message-row assistant-transcript__message-row--user"
126
126
  >
127
127
  <div class="assistant-transcript__message assistant-transcript__message--user">
128
128
  <div
129
129
  class="assistant-transcript__user-content"
130
130
  >
131
- <p v-if="userMessageFormat === 'plain'" class="assistant-transcript__plain-user">{{ userMessageExpanded(turn) ? turn.user.text : userMessagePreviewText(turn.user.text) || turn.user.text }}</p>
131
+ <p v-if="userMessageFormat === 'plain'" class="assistant-transcript__plain-user">{{ userMessageExpanded(entry.turn) ? entry.message.text : userMessagePreviewText(entry.message.text) || entry.message.text }}</p>
132
132
  <LongTextPreviewBlocks
133
133
  v-else
134
- :blocks="userMessageExpanded(turn) ? turn.user.blocks : (turn.user.previewBlocks || turn.user.blocks)"
134
+ :blocks="userMessageExpanded(entry.turn) ? entry.message.blocks : (entry.message.previewBlocks || entry.message.blocks)"
135
135
  @link-click="handleLongTextLinkClick"
136
136
  />
137
137
  </div>
138
138
  <button
139
- v-if="userMessageCollapsible(turn.user)"
140
- :aria-expanded="userMessageExpanded(turn)"
139
+ v-if="userMessageCollapsible(entry.message)"
140
+ :aria-expanded="userMessageExpanded(entry.turn)"
141
141
  class="assistant-transcript__user-content-toggle"
142
142
  type="button"
143
- @click="toggleUserMessage(turn)"
143
+ @click="toggleUserMessage(entry.turn)"
144
144
  >
145
- {{ userMessageExpanded(turn) ? "Show less" : "Read more" }}
145
+ {{ userMessageExpanded(entry.turn) ? "Show less" : "Read more" }}
146
146
  </button>
147
- <slot name="attachments" :items="turn.user.attachments" :message="turn.user" />
147
+ <slot name="attachments" :items="entry.message.attachments" :message="entry.message"><AssistantMessageAttachments :attachments="entry.message.attachments || []" /></slot>
148
148
  <div
149
- v-if="turn.user.displayAt"
149
+ v-if="entry.message.displayAt"
150
150
  class="assistant-transcript__message-footer assistant-transcript__message-footer--user"
151
151
  >
152
- <time v-if="turn.user.displayAt">{{ turn.user.displayAt }}</time>
152
+ <time v-if="entry.message.displayAt">{{ entry.message.displayAt }}</time>
153
153
  </div>
154
154
  <div
155
- v-if="turn.optimistic?.status === 'failed'"
155
+ v-if="entry.turn.optimistic?.status === 'failed'"
156
156
  class="assistant-transcript__optimistic-failure"
157
157
  >
158
- <span>{{ turn.optimistic.error || "Message could not be sent." }}</span>
158
+ <span>{{ entry.turn.optimistic.error || "Message could not be sent." }}</span>
159
159
  <div class="assistant-transcript__optimistic-actions">
160
160
  <v-btn
161
161
  color="primary"
162
162
  size="x-small"
163
163
  type="button"
164
164
  variant="tonal"
165
- @click="emit('resend-turn', turn.optimistic.id)"
165
+ @click="emit('resend-turn', entry.turn.optimistic.id)"
166
166
  >
167
167
  Resend
168
168
  </v-btn>
@@ -170,7 +170,7 @@
170
170
  size="x-small"
171
171
  type="button"
172
172
  variant="text"
173
- @click="emit('cancel-turn', turn.optimistic.id)"
173
+ @click="emit('cancel-turn', entry.turn.optimistic.id)"
174
174
  >
175
175
  Cancel
176
176
  </v-btn>
@@ -178,7 +178,7 @@
178
178
  size="x-small"
179
179
  type="button"
180
180
  variant="text"
181
- @click="emit('edit-turn', turn.optimistic.id)"
181
+ @click="emit('edit-turn', entry.turn.optimistic.id)"
182
182
  >
183
183
  Edit
184
184
  </v-btn>
@@ -190,84 +190,79 @@
190
190
  </span>
191
191
  </div>
192
192
 
193
- <template
194
- v-for="entry in turn.agentTimeline"
195
- :key="entry.key"
193
+ <AssistantProgress
194
+ v-else-if="entry.role === 'thinking'"
195
+ :key="`${scrollKey}:${entry.key}`"
196
+ class="assistant-transcript__thinking"
197
+ :messages="entry.messages"
198
+ :pending="isWorking && entry === displayEntries.at(-1)"
199
+ :preview-limit="progressPreviewLimit"
200
+ :aria-label="`${assistantLabel} progress`"
201
+ />
202
+ <div
203
+ v-else
204
+ class="assistant-transcript__message-row assistant-transcript__message-row--assistant"
205
+ :data-message-role="entry.role"
196
206
  >
197
- <AssistantProgress
198
- v-if="entry.role === 'thinking'"
199
- :key="`${turn.turnId}:${turn.pending ? 'active' : 'completed'}:${entry.key}`"
200
- class="assistant-transcript__thinking"
201
- :messages="entry.messages"
202
- :pending="turn.pending"
203
- :preview-limit="progressPreviewLimit"
204
- :aria-label="`${assistantLabel} progress`"
205
- />
206
- <div
207
- v-else
208
- class="assistant-transcript__message-row assistant-transcript__message-row--assistant"
209
- :data-message-role="entry.role"
210
- >
211
- <div class="assistant-transcript__assistant-header">
212
- <span class="assistant-transcript__avatar assistant-transcript__avatar--assistant">
213
- <v-icon :icon="mdiRobotOutline" size="16" />
214
- </span>
215
- <div class="assistant-transcript__message-header">
216
- <span>{{ assistantLabel }}</span>
217
- </div>
207
+ <div class="assistant-transcript__assistant-header">
208
+ <span class="assistant-transcript__avatar assistant-transcript__avatar--assistant">
209
+ <v-icon :icon="mdiRobotOutline" size="16" />
210
+ </span>
211
+ <div class="assistant-transcript__message-header">
212
+ <span>{{ assistantLabel }}</span>
218
213
  </div>
219
- <div class="assistant-transcript__message assistant-transcript__message--assistant">
220
- <LongTextPreviewBlocks
221
- v-if="entry.message.blocks.length"
222
- :blocks="entry.message.blocks"
223
- @link-click="handleLongTextLinkClick"
224
- />
225
- <ol
226
- v-if="entry.message.questions.length"
227
- class="assistant-transcript__questions"
214
+ </div>
215
+ <div class="assistant-transcript__message assistant-transcript__message--assistant">
216
+ <LongTextPreviewBlocks
217
+ v-if="entry.message.blocks.length"
218
+ :blocks="entry.message.blocks"
219
+ @link-click="handleLongTextLinkClick"
220
+ />
221
+ <ol
222
+ v-if="entry.message.questions.length"
223
+ class="assistant-transcript__questions"
224
+ >
225
+ <li
226
+ v-for="question in entry.message.questions"
227
+ :key="question.name"
228
+ class="assistant-transcript__question"
228
229
  >
229
- <li
230
- v-for="question in entry.message.questions"
231
- :key="question.name"
232
- class="assistant-transcript__question"
233
- >
234
- <span class="assistant-transcript__question-number">{{ question.number }}</span>
235
- <div class="assistant-transcript__question-content">
236
- <span class="assistant-transcript__question-text">
230
+ <span class="assistant-transcript__question-number">{{ question.number }}</span>
231
+ <div class="assistant-transcript__question-content">
232
+ <span class="assistant-transcript__question-text">
233
+ <LongTextInlineParts
234
+ :text="question.label"
235
+ @link-click="handleLongTextLinkClick"
236
+ />
237
+ </span>
238
+ <ul
239
+ v-if="question.choices.length"
240
+ class="assistant-transcript__question-choices"
241
+ >
242
+ <li v-for="choice in question.choices" :key="choice.value">
237
243
  <LongTextInlineParts
238
- :text="question.label"
244
+ :text="choice.label"
239
245
  @link-click="handleLongTextLinkClick"
240
- />
241
- </span>
242
- <ul
243
- v-if="question.choices.length"
244
- class="assistant-transcript__question-choices"
245
- >
246
- <li v-for="choice in question.choices" :key="choice.value">
247
- <LongTextInlineParts
248
- :text="choice.label"
249
- @link-click="handleLongTextLinkClick"
250
- /><span v-if="choice.recommended"> · Recommended</span>
251
- </li>
252
- </ul>
253
- </div>
254
- </li>
255
- </ol>
256
- <LongTextPreviewBlocks
257
- v-if="entry.message.outroBlocks.length"
258
- :blocks="entry.message.outroBlocks"
259
- @link-click="handleLongTextLinkClick"
260
- />
261
- <slot name="message-actions" :message="entry.message" :turn="turn" />
262
- </div>
263
- <div
264
- v-if="entry.message.displayAt"
265
- class="assistant-transcript__message-footer assistant-transcript__message-footer--assistant"
266
- >
267
- <time>{{ entry.message.displayAt }}</time>
268
- </div>
246
+ /><span v-if="choice.recommended"> · Recommended</span>
247
+ </li>
248
+ </ul>
249
+ </div>
250
+ </li>
251
+ </ol>
252
+ <LongTextPreviewBlocks
253
+ v-if="entry.message.outroBlocks.length"
254
+ :blocks="entry.message.outroBlocks"
255
+ @link-click="handleLongTextLinkClick"
256
+ />
257
+ <slot name="message-actions" :message="entry.message" :turn="entry.turn" />
258
+ </div>
259
+ <div
260
+ v-if="entry.message.displayAt"
261
+ class="assistant-transcript__message-footer assistant-transcript__message-footer--assistant"
262
+ >
263
+ <time>{{ entry.message.displayAt }}</time>
269
264
  </div>
270
- </template>
265
+ </div>
271
266
  </article>
272
267
 
273
268
  <div
@@ -280,6 +275,7 @@
280
275
  </template>
281
276
 
282
277
  <script setup>
278
+ import AssistantMessageAttachments from "./AssistantMessageAttachments.vue";
283
279
  import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
284
280
  import {
285
281
  mdiAccountOutline,
@@ -302,6 +298,7 @@ import {
302
298
 
303
299
  const props = defineProps({
304
300
  systemLabel: { type: String, default: "Status" },
301
+ working: { type: Boolean, default: undefined },
305
302
  progressPreviewLimit: { type: Number, default: 2 },
306
303
  userMessageFormat: { type: String, default: "formatted", validator: (value) => ["plain", "formatted"].includes(value) },
307
304
  assistantLabel: {
@@ -372,6 +369,7 @@ const DISPLAY_MESSAGE_CACHE_LIMIT = 500;
372
369
  const bodyElement = ref(null);
373
370
  const bottomElement = ref(null);
374
371
  const expandedUserMessages = ref(new Set());
372
+ const isWorking = computed(() => props.working ?? props.turns.some(turn => turn.pending));
375
373
  const followingLatest = ref(true);
376
374
  const initialScrollSettled = ref(false);
377
375
  const userScrollIntent = ref(false);
@@ -529,66 +527,49 @@ function conversationMessageKey(message = {}, index = 0) {
529
527
  ].join(":");
530
528
  }
531
529
 
532
- function displayAgentTimeline(turn = {}, turnKey = "") {
533
- const timeline = [];
534
- for (const [index, message] of conversationAgentMessages(turn).entries()) {
535
- const role = String(message?.role || "").trim();
536
- if (role === "thinking") {
537
- const displayed = displayThinkingMessage(message);
538
- if (!displayed) {
539
- continue;
540
- }
541
- const keyedMessage = {
542
- ...displayed,
543
- key: conversationMessageKey(message, index)
544
- };
545
- const previous = timeline.at(-1);
546
- if (previous?.role === "thinking") {
547
- previous.messages.push(keyedMessage);
548
- continue;
530
+ // Storage rows are not visual boundaries. Only an intervening message ends a
531
+ // reasoning group, including when history is prepended or execution continues.
532
+ const displayEntries = computed(() => {
533
+ const entries = [];
534
+ for (const [index, turn] of props.turns.entries()) {
535
+ const turnId = String(turn.turnId || index + 1);
536
+ const messages = [
537
+ turn.system && { ...turn.system, role: "system" },
538
+ turn.user && { ...turn.user, role: "user" },
539
+ ...conversationAgentMessages(turn)
540
+ ].filter(Boolean);
541
+ for (const [messageIndex, message] of messages.entries()) {
542
+ const role = String(message.role || "").trim();
543
+ const key = `${turnId}:${conversationMessageKey(message, messageIndex)}`;
544
+ const displayed = role === "thinking" ? displayThinkingMessage(message) : displayMessage(message, {
545
+ allowNumberedQuestions: role !== "user" && role !== "system",
546
+ preserveParagraphLineBreaks: role === "user",
547
+ previewUserMessage: role === "user"
548
+ }, key);
549
+ if (!displayed) continue;
550
+ const previous = entries.at(-1);
551
+ if (role === "thinking" && previous?.role === "thinking") {
552
+ previous.messages.push({ ...displayed, key });
553
+ } else {
554
+ entries.push({
555
+ key, role, turn: { ...turn, turnId }, message: displayed,
556
+ ...(role === "thinking" ? { messages: [{ ...displayed, key }] } : {})
557
+ });
549
558
  }
550
- timeline.push({
551
- key: `thinking:${keyedMessage.key}`,
552
- messages: [keyedMessage],
553
- role
554
- });
555
- continue;
556
559
  }
557
- timeline.push({
558
- key: conversationMessageKey(message, index),
559
- message: displayMessage(message, {
560
- allowNumberedQuestions: true
561
- }, `${turnKey}:agent:${conversationMessageKey(message, index)}`),
562
- role
563
- });
564
560
  }
565
- return timeline;
566
- }
561
+ // Anchor a progress group to the following message, or to the transcript end.
562
+ // Appending progress or prepending history then preserves its expansion state.
563
+ for (const [index, entry] of entries.entries()) {
564
+ if (entry.role === "thinking") entry.key = `progress:${entries[index + 1]?.key || "tail"}`;
565
+ }
566
+ return entries;
567
+ });
567
568
 
568
569
  function handleLongTextLinkClick(payload) {
569
570
  emit("link-click", payload);
570
571
  }
571
572
 
572
- const displayTurns = computed(() => (Array.isArray(props.turns) ? props.turns : [])
573
- .map((turn, index) => {
574
- const turnId = String(turn.turnId || index + 1);
575
- return {
576
- ...turn,
577
- agentTimeline: displayAgentTimeline(turn, turnId),
578
- optimistic: turn.optimistic && typeof turn.optimistic === "object" && !Array.isArray(turn.optimistic)
579
- ? turn.optimistic
580
- : null,
581
- pending: turn.pending === true,
582
- system: displayMessage(turn.system, {}, `${turnId}:system`),
583
- turnId,
584
- user: displayMessage(turn.user, {
585
- preserveParagraphLineBreaks: true,
586
- previewUserMessage: true
587
- }, `${turnId}:user`)
588
- };
589
- })
590
- .filter((turn) => turn.system || turn.user || turn.agentTimeline.length));
591
-
592
573
  function userMessageCollapsible(message = null) {
593
574
  return Array.isArray(message?.previewBlocks);
594
575
  }
@@ -617,11 +598,11 @@ function toggleUserMessage(turn = {}) {
617
598
 
618
599
  const loadingIndicatorVisible = computed(() => Boolean(
619
600
  props.loading &&
620
- !displayTurns.value.length
601
+ !displayEntries.value.length
621
602
  ));
622
603
  const initialScrollPending = computed(() => Boolean(
623
604
  props.visible &&
624
- displayTurns.value.length &&
605
+ displayEntries.value.length &&
625
606
  !initialScrollSettled.value
626
607
  ));
627
608
 
@@ -637,40 +618,24 @@ function messageScrollKey(message = null) {
637
618
  ].join("/");
638
619
  }
639
620
 
640
- function agentTimelineScrollKey(timeline = []) {
641
- return (Array.isArray(timeline) ? timeline : []).map((entry) => (
642
- entry?.role === "thinking"
643
- ? (entry.messages || []).map(messageScrollKey).join("+")
644
- : messageScrollKey(entry?.message)
645
- )).join("|");
646
- }
647
-
648
- function latestRenderedTailScrollKey(turns = []) {
649
- const turn = Array.isArray(turns) ? turns.at(-1) : null;
650
- if (!turn) {
651
- return "";
652
- }
653
- const timeline = Array.isArray(turn.agentTimeline) ? turn.agentTimeline : [];
654
- return [
655
- turn.turnId,
656
- messageScrollKey(turn.system),
657
- messageScrollKey(turn.user),
658
- turn.optimistic?.id || "",
659
- turn.optimistic?.status || "",
660
- turn.optimistic?.error || "",
661
- turn.pending ? "pending" : "settled",
662
- agentTimelineScrollKey(timeline)
663
- ].join(":");
664
- }
665
-
666
621
  const timelineScrollTrigger = computed(() => [
667
622
  props.visible ? "visible" : "hidden",
668
623
  props.error ? "error" : "body",
669
624
  loadingIndicatorVisible.value ? "loading" : "ready",
670
- displayTurns.value.length ? "has-turns" : "empty",
625
+ displayEntries.value.length ? "has-messages" : "empty",
671
626
  props.scrollKey
672
627
  ].join(":"));
673
- const latestRenderedTailKey = computed(() => latestRenderedTailScrollKey(displayTurns.value));
628
+ const latestRenderedTailKey = computed(() => {
629
+ const entry = displayEntries.value.at(-1);
630
+ if (!entry) return "";
631
+ return [
632
+ entry.key,
633
+ isWorking.value,
634
+ entry.role === "thinking" ? entry.messages.map(messageScrollKey).join("+") : messageScrollKey(entry.message),
635
+ entry.turn.optimistic?.status || "",
636
+ entry.turn.optimistic?.error || ""
637
+ ].join(":");
638
+ });
674
639
  const autoScrollEnabled = computed(() => Boolean(
675
640
  props.visible &&
676
641
  followingLatest.value
@@ -3,7 +3,16 @@ export { default as AssistantTranscript } from "./AssistantTranscript.vue";
3
3
  export { default as AssistantPromptInput } from "./AssistantPromptInput.vue";
4
4
  export { default as AssistantComposerActions } from "./AssistantComposerActions.vue";
5
5
  export { default as AssistantProgress } from "./AssistantProgress.vue";
6
+ export { default as AssistantComposerSupport } from "./AssistantComposerSupport.vue";
7
+ export { default as AssistantGoalControl } from "./AssistantGoalControl.vue";
8
+ export { useAssistantSuggestions } from "./useAssistantSuggestions.js";
6
9
  export { default as LongTextPreviewBlocks } from "./LongTextPreviewBlocks.vue";
7
10
  export { default as LongTextInlineParts } from "./LongTextInlineParts.vue";
8
11
  export { useScrollToBottom } from "./useScrollToBottom.js";
9
12
  export { createAssistantTextSubmission } from "./submitText.js";
13
+ export { useAssistantAttachments } from "./useAssistantAttachments.js";
14
+ export { default as AssistantAttachmentQueue } from "./AssistantAttachmentQueue.vue";
15
+ export { default as AssistantModelControl } from "./AssistantModelControl.vue";
16
+ export { default as AssistantMessageAttachments } from "./AssistantMessageAttachments.vue";
17
+ export { default as AssistantAttachmentPreview } from "./AssistantAttachmentPreview.vue";
18
+ export { default as AssistantQuestionInputs } from "./AssistantQuestionInputs.vue";