@jskit-ai/assistant-core 0.1.187 → 0.1.189

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.
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.187",
12
- "@jskit-ai/http-runtime": "0.1.207",
13
- "@jskit-ai/kernel": "0.1.209",
11
+ "@jskit-ai/assistant-core": "0.1.189",
12
+ "@jskit-ai/http-runtime": "0.1.209",
13
+ "@jskit-ai/kernel": "0.1.211",
14
14
  "@mdi/js": "^7.4.47",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0",
17
- "@jskit-ai/connectors-core": "0.1.26",
18
- "@jskit-ai/connectors-catalog": "0.1.26"
17
+ "@jskit-ai/connectors-core": "0.1.28",
18
+ "@jskit-ai/connectors-catalog": "0.1.28"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
@@ -8,8 +8,9 @@ const turns = ref([]);
8
8
  const requests = ref([]);
9
9
  const configuration = ref({ tone: "concise" });
10
10
  const clearedFiles = ref([]);
11
+ const queueWhileSending = new URLSearchParams(location.search).has("queue");
11
12
  const attachments = reactive({
12
- attachments: [{ attachmentId: "original-file", fileName: "original.txt" }],
13
+ attachments: queueWhileSending ? [] : [{ attachmentId: "original-file", fileName: "original.txt" }],
13
14
  queueItems: [], canSubmit: true, canAddFiles: false,
14
15
  clearAttachments({ attachmentIds }) {
15
16
  clearedFiles.value.push(...attachmentIds);
@@ -24,14 +25,14 @@ const delivery = createAssistantMessageDelivery({ deliver(payload) {
24
25
  } });
25
26
  function accept() {
26
27
  const payload = requests.value.at(-1);
27
- turns.value = [{ turnId: "accepted", user: { messageId: payload.messageId, text: payload.message },
28
+ turns.value = [...turns.value, { turnId: payload.messageId, user: { messageId: payload.messageId, text: payload.message },
28
29
  assistant: { text: "Accepted once." } }];
29
30
  pendingRequest.resolve({ ok: true });
30
31
  }
31
32
  const adapter = reactive({
32
33
  delivery, attachments,
33
34
  conversation: { turns, visible: true, scrollKey: "delivery", welcomeMessage: "Start a conversation." },
34
- composer: { draft, canSend: true, submitOnEnter: true },
35
+ composer: { draft, canSend: true, submitOnEnter: true, queueWhileSending },
35
36
  actions: {
36
37
  setDraft(value) { draft.value = value; }
37
38
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.187",
3
+ "version": "0.1.189",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -34,8 +34,8 @@
34
34
  "@ai-sdk/perplexity": "^4.0.43",
35
35
  "@ai-sdk/togetherai": "^3.0.49",
36
36
  "@ai-sdk/xai": "^4.0.58",
37
- "@jskit-ai/resource-core": "0.1.151",
38
- "@jskit-ai/resource-crud-core": "0.1.151",
37
+ "@jskit-ai/resource-core": "0.1.153",
38
+ "@jskit-ai/resource-crud-core": "0.1.153",
39
39
  "@mdi/js": "^7.4.47",
40
40
  "@openrouter/ai-sdk-provider": "^3.0.0",
41
41
  "ai": "^7.0.101",
@@ -44,8 +44,8 @@
44
44
  "ws": "^8.20.1"
45
45
  },
46
46
  "peerDependencies": {
47
- "@jskit-ai/http-runtime": "0.1.207",
48
- "@jskit-ai/kernel": "0.1.209",
47
+ "@jskit-ai/http-runtime": "0.1.209",
48
+ "@jskit-ai/kernel": "0.1.211",
49
49
  "vue": "^3.5.13",
50
50
  "vuetify": "^4.0.0"
51
51
  },
@@ -11,11 +11,11 @@
11
11
  </v-btn>
12
12
  <v-btn
13
13
  ref="sendButton" class="assistant-composer-actions__send" color="primary" :disabled="!state.canSend"
14
- :aria-busy="state.pending ? 'true' : undefined" :aria-label="state.submitAriaLabel || state.submitLabel || 'Send message'"
14
+ :aria-busy="state.pending && !state.canSend ? 'true' : undefined" :aria-label="state.submitAriaLabel || state.submitLabel || 'Send message'"
15
15
  :title="state.submitTitle" :prepend-icon="state.submitIcon || mdiSend" size="small" variant="flat"
16
16
  @click="emit('submit')"
17
17
  >
18
- {{ state.submitLabel || (state.pending ? 'Sending…' : 'Send') }}
18
+ {{ state.submitLabel || (state.pending && !state.canSend ? 'Sending…' : 'Send') }}
19
19
  </v-btn>
20
20
  </div>
21
21
  <slot name="feedback" />
@@ -125,6 +125,7 @@ watch(() => [props.adapter.delivery, props.adapter.conversation.turns], ([contro
125
125
  controller?.reconcile(turns || []);
126
126
  }, { immediate: true });
127
127
  const pending = computed(() => Boolean(props.adapter.delivery?.state.sending || props.adapter.composer?.pending));
128
+ const queueWhileSending = computed(() => props.adapter.composer?.queueWhileSending === true);
128
129
  const working = computed(() => conversation.value.working ?? (!pending.value && Boolean(
129
130
  props.adapter.composer?.canStop || conversation.value.turns?.some(turn => turn.pending)
130
131
  )));
@@ -139,14 +140,17 @@ const attachmentState = computed(() => ({
139
140
  count: attachmentsEnabled.value ? props.adapter.attachments.queueItems?.length || 0 : 0,
140
141
  canSubmit: !attachmentsEnabled.value || props.adapter.attachments.canSubmit !== false
141
142
  }));
142
- const canSend = computed(() => !pending.value && props.adapter.composer?.canSend && attachmentState.value.canSubmit);
143
+ const canSend = computed(() => (
144
+ (!pending.value || (queueWhileSending.value && attachmentState.value.count === 0)) &&
145
+ props.adapter.composer?.canSend && attachmentState.value.canSubmit
146
+ ));
143
147
  async function resend(id) {
144
148
  if (props.adapter.actions?.resend) return props.adapter.actions.resend(id);
145
149
  const controller = props.adapter.delivery;
146
150
  const payload = controller?.find(id)?.payload;
147
151
  const attachments = props.adapter.attachments;
148
152
  try {
149
- const response = await controller?.resend(id);
153
+ const response = await controller?.resend(id, { queue: queueWhileSending.value });
150
154
  clearAcceptedAttachments(response, payload, attachments);
151
155
  return response;
152
156
  }
@@ -202,7 +206,7 @@ async function submit() {
202
206
  };
203
207
  props.adapter.actions.setDraft("");
204
208
  try {
205
- const response = await controller.send(payload);
209
+ const response = await controller.send(payload, { queue: queueWhileSending.value });
206
210
  clearAcceptedAttachments(response, payload, attachments);
207
211
  return response;
208
212
  } catch { return false; /* Delivery retains the failed message and its actions. */ }
@@ -124,7 +124,13 @@
124
124
  v-else-if="entry.role === 'user'"
125
125
  class="assistant-transcript__message-row assistant-transcript__message-row--user"
126
126
  >
127
- <div class="assistant-transcript__message assistant-transcript__message--user">
127
+ <div
128
+ class="assistant-transcript__message assistant-transcript__message--user"
129
+ :class="{
130
+ 'assistant-transcript__message--pending': entry.turn.optimistic?.status === 'pending',
131
+ 'assistant-transcript__message--failed': entry.turn.optimistic?.status === 'failed'
132
+ }"
133
+ >
128
134
  <div
129
135
  class="assistant-transcript__user-content"
130
136
  >
@@ -146,27 +152,30 @@
146
152
  </button>
147
153
  <slot name="attachments" :items="entry.message.attachments" :message="entry.message"><AssistantMessageAttachments :attachments="entry.message.attachments || []" /></slot>
148
154
  <div
149
- v-if="entry.message.displayAt"
155
+ v-if="entry.message.displayAt || entry.turn.optimistic?.status === 'pending'"
150
156
  class="assistant-transcript__message-footer assistant-transcript__message-footer--user"
151
157
  >
152
158
  <time v-if="entry.message.displayAt">{{ entry.message.displayAt }}</time>
159
+ <span v-if="entry.turn.optimistic?.status === 'pending'" role="status">Pending</span>
153
160
  </div>
154
161
  <div
155
162
  v-if="entry.turn.optimistic?.status === 'failed'"
156
163
  class="assistant-transcript__optimistic-failure"
157
164
  >
158
- <span>{{ entry.turn.optimistic.error || "Message could not be sent." }}</span>
165
+ <span role="status">Failed: {{ entry.turn.optimistic.error || "Message could not be sent." }}</span>
159
166
  <div class="assistant-transcript__optimistic-actions">
160
167
  <v-btn
168
+ class="assistant-transcript__delivery-action"
161
169
  color="primary"
162
170
  size="x-small"
163
171
  type="button"
164
172
  variant="tonal"
165
173
  @click="emit('resend-turn', entry.turn.optimistic.id)"
166
174
  >
167
- Resend
175
+ Retry
168
176
  </v-btn>
169
177
  <v-btn
178
+ class="assistant-transcript__delivery-action"
170
179
  size="x-small"
171
180
  type="button"
172
181
  variant="text"
@@ -175,6 +184,7 @@
175
184
  Cancel
176
185
  </v-btn>
177
186
  <v-btn
187
+ class="assistant-transcript__delivery-action"
178
188
  size="x-small"
179
189
  type="button"
180
190
  variant="text"
@@ -1101,6 +1111,21 @@ watch(timelineScrollTrigger, () => {
1101
1111
  width: fit-content;
1102
1112
  }
1103
1113
 
1114
+ .assistant-transcript__message--user.assistant-transcript__message--pending {
1115
+ background: rgba(var(--v-theme-on-surface), 0.04);
1116
+ outline: 1px dashed rgba(var(--v-theme-on-surface), 0.4);
1117
+ outline-offset: -1px;
1118
+ }
1119
+
1120
+ .assistant-transcript__message--pending .assistant-transcript__message-footer {
1121
+ color: rgba(var(--v-theme-on-surface), 0.7);
1122
+ }
1123
+
1124
+ .assistant-transcript__message--user.assistant-transcript__message--failed {
1125
+ outline: 1px solid rgb(var(--v-theme-error));
1126
+ outline-offset: -1px;
1127
+ }
1128
+
1104
1129
  .assistant-transcript__message--assistant {
1105
1130
  background: rgb(var(--v-theme-surface));
1106
1131
  color: rgb(var(--v-theme-on-surface));
@@ -1232,6 +1257,13 @@ watch(timelineScrollTrigger, () => {
1232
1257
  gap: 0.25rem;
1233
1258
  }
1234
1259
 
1260
+ @media (max-width: 600px), (pointer: coarse) {
1261
+ .assistant-transcript__delivery-action {
1262
+ min-height: 3rem;
1263
+ min-width: 3rem;
1264
+ }
1265
+ }
1266
+
1235
1267
  .assistant-transcript__message--assistant :deep(.studio-long-text-review__blocks),
1236
1268
  .assistant-transcript__message--user :deep(.studio-long-text-review__blocks) {
1237
1269
  color: inherit;
@@ -34,7 +34,8 @@ function unmatchedOptimisticMessages(turns = [], optimisticMessages = []) {
34
34
  // the shared assistant element renders these pending and failed turns.
35
35
  function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
36
36
  const state = reactive({ messages: [], sending: false });
37
- let generation = 0;
37
+ let deliveryTail = null;
38
+ let pendingSends = new Map();
38
39
 
39
40
  function find(messageId) {
40
41
  return state.messages.find((message) => message.id === messageId) || null;
@@ -65,12 +66,21 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
65
66
  ];
66
67
  }
67
68
 
68
- async function send(payload, { messageId = crypto.randomUUID(), deliver = defaultDeliver, isCurrent = () => true } = {}) {
69
- if (state.sending || !String(payload?.message || "").trim()) return false;
69
+ async function send(payload, {
70
+ messageId = crypto.randomUUID(),
71
+ deliver = defaultDeliver,
72
+ isCurrent = () => true,
73
+ queue = false
74
+ } = {}) {
75
+ if ((state.sending && !queue) || pendingSends.has(messageId) || !messageText(payload?.message)) return false;
70
76
  if (typeof deliver !== "function") throw new TypeError("Message delivery requires a deliver function.");
71
77
  const snapshot = JSON.parse(JSON.stringify(payload));
72
- const ownerGeneration = generation;
73
- const current = () => generation === ownerGeneration && isCurrent();
78
+ const ownerPendingSends = pendingSends;
79
+ const current = () => pendingSends === ownerPendingSends && isCurrent();
80
+ const predecessor = deliveryTail;
81
+ const settled = Promise.withResolvers();
82
+ deliveryTail = settled.promise;
83
+ ownerPendingSends.set(messageId, settled);
74
84
  const now = new Date();
75
85
  const message = {
76
86
  attachments: snapshot.displayAttachments || [],
@@ -85,10 +95,15 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
85
95
  state.messages = [...state.messages.filter((entry) => entry.id !== messageId), message];
86
96
  state.sending = true;
87
97
  try {
98
+ if (predecessor) await predecessor;
99
+ if (!current()) return false;
88
100
  const response = await deliver({ ...snapshot, messageId });
89
101
  if (!current()) return false;
90
102
  if (response === false || response?.ok === false) {
91
103
  fail(messageId, response?.error || "Message could not be sent.");
104
+ } else {
105
+ const acceptedMessage = find(messageId);
106
+ if (acceptedMessage) acceptedMessage.status = "accepted";
92
107
  }
93
108
  return response;
94
109
  } catch (error) {
@@ -96,7 +111,12 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
96
111
  fail(messageId, error);
97
112
  throw error;
98
113
  } finally {
99
- if (generation === ownerGeneration) state.sending = false;
114
+ ownerPendingSends.delete(messageId);
115
+ if (pendingSends === ownerPendingSends) {
116
+ state.sending = ownerPendingSends.size > 0;
117
+ if (!state.sending) deliveryTail = null;
118
+ }
119
+ settled.resolve();
100
120
  }
101
121
  }
102
122
 
@@ -109,7 +129,7 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
109
129
  }
110
130
 
111
131
  function cancel(messageId) {
112
- if (state.sending || find(messageId)?.status !== "failed") return false;
132
+ if (find(messageId)?.status !== "failed") return false;
113
133
  remove(messageId);
114
134
  return true;
115
135
  }
@@ -120,14 +140,16 @@ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
120
140
  return !draft || draft.startsWith(message.text) ? draft || message.text : `${message.text}\n\n${draft}`;
121
141
  }
122
142
 
123
- function resend(messageId) {
143
+ function resend(messageId, { queue = false } = {}) {
124
144
  const message = find(messageId);
125
145
  if (message?.status !== "failed") return false;
126
- return send(message.payload, { messageId });
146
+ return send(message.payload, { messageId, queue });
127
147
  }
128
148
 
129
149
  function reset() {
130
- generation += 1;
150
+ deliveryTail = null;
151
+ for (const pending of pendingSends.values()) pending.resolve();
152
+ pendingSends = new Map();
131
153
  state.messages = [];
132
154
  state.sending = false;
133
155
  }
@@ -26,10 +26,10 @@ test("the shared element renders delayed sends and supplies retry, edit and canc
26
26
  await expect(page.getByText("Start a conversation.", { exact: true })).toHaveCount(0);
27
27
  await expect(page.locator(".assistant-composer-support__assistant-status")).toHaveText("Sending to assistant…");
28
28
  await fail();
29
- await expect(page.getByText("Delivery unavailable.", { exact: true })).toHaveCount(1);
29
+ await expect(page.getByText("Failed: Delivery unavailable.", { exact: true })).toHaveCount(1);
30
30
  await input.fill("Keep my newer draft.");
31
31
  await page.getByRole("button", { name: "Prepare newer input" }).click();
32
- await page.getByRole("button", { name: "Resend", exact: true }).click();
32
+ await page.getByRole("button", { name: "Retry", exact: true }).click();
33
33
  await expect(input).toHaveValue("Keep my newer draft.");
34
34
  await page.getByRole("button", { name: "Accept request" }).click();
35
35
  await expect(page.getByText("Accepted once.", { exact: true })).toBeVisible();
@@ -53,6 +53,34 @@ test("the shared element renders delayed sends and supplies retry, edit and canc
53
53
  await page.getByRole("button", { name: "Cancel", exact: true }).click();
54
54
  await expect(input).toHaveValue("Retain this.");
55
55
  await expect(page.getByText("Cancel this question.", { exact: true })).toHaveCount(0);
56
+ await page.goto(`${vite.baseURL}/?delivery=1&queue=1`);
57
+ await send("First steer.");
58
+ await send("Second steer.");
59
+ await send("Third steer.");
60
+ await expect(input).toHaveValue("");
61
+ await expect(page.getByText("Pending", { exact: true })).toHaveCount(3);
62
+ await expect(page.locator(".assistant-transcript__message--pending")).toHaveCount(3);
63
+ assert.equal(JSON.parse(await page.locator("[data-requests]").textContent()).length, 1);
64
+ await input.fill("Keep this newer draft.");
65
+ await fail();
66
+ await expect(page.locator(".assistant-transcript__message--failed")).toHaveCount(1);
67
+ await expect(page.getByRole("button", { name: "Retry", exact: true })).toBeVisible();
68
+ if (width === 390) {
69
+ const retryBox = await page.getByRole("button", { name: "Retry", exact: true }).boundingBox();
70
+ assert.ok(retryBox.width >= 48 && retryBox.height >= 48);
71
+ }
72
+ await page.getByRole("button", { name: "Retry", exact: true }).click();
73
+ await expect(page.getByText("Pending", { exact: true })).toHaveCount(3);
74
+ for (let count = 2; count <= 4; count += 1) {
75
+ await expect.poll(async () => JSON.parse(await page.locator("[data-requests]").textContent()).length).toBe(count);
76
+ await page.getByRole("button", { name: "Accept request" }).click();
77
+ }
78
+ await expect(page.getByText("Pending", { exact: true })).toHaveCount(0);
79
+ await expect(page.locator(".assistant-transcript__message--pending")).toHaveCount(0);
80
+ await expect(input).toHaveValue("Keep this newer draft.");
81
+ const queuedRequests = JSON.parse(await page.locator("[data-requests]").textContent());
82
+ assert.deepEqual(queuedRequests.map(item => item.message), ["First steer.", "Second steer.", "Third steer.", "First steer."]);
83
+ assert.deepEqual(queuedRequests[0], queuedRequests[3]);
56
84
  assert.deepEqual(errors, []);
57
85
  assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
58
86
  await page.close();
@@ -41,7 +41,7 @@ test("failed delivery retains the original payload and identity for an explicit
41
41
  assert.deepEqual(sent[0], sent[1]);
42
42
  assert.equal(sent[1].configuration.model, "one");
43
43
  assert.equal(delivery.state.messages.length, 1);
44
- assert.equal(delivery.state.messages[0].status, "pending");
44
+ assert.equal(delivery.state.messages[0].status, "accepted");
45
45
  });
46
46
 
47
47
  test("cancel and edit affect failed messages and preserve a newer draft", async () => {
@@ -70,3 +70,79 @@ test("retiring a conversation isolates late responses from the next conversation
70
70
  await newSend;
71
71
  assert.equal(delivery.state.sending, false);
72
72
  });
73
+
74
+ test("queued messages appear immediately and deliver in order with captured payloads", async () => {
75
+ const requests = [Promise.withResolvers(), Promise.withResolvers(), Promise.withResolvers()];
76
+ const sent = [];
77
+ const delivery = createAssistantMessageDelivery({ deliver: payload => {
78
+ sent.push(payload);
79
+ return requests[sent.length - 1].promise;
80
+ } });
81
+ const first = delivery.send({ message: "First" }, { messageId: "one" });
82
+ const payload = { message: "Second", configuration: { model: "original" } };
83
+ const second = delivery.send(payload, { messageId: "two", queue: true });
84
+ const third = delivery.send({ message: "Third" }, { messageId: "three", queue: true });
85
+ payload.configuration.model = "changed";
86
+ assert.deepEqual(delivery.turns().map(turn => [turn.user.text, turn.optimistic.status]),
87
+ [["First", "pending"], ["Second", "pending"], ["Third", "pending"]]);
88
+ assert.equal(await delivery.send({ message: "Duplicate" }, { messageId: "two", queue: true }), false);
89
+ assert.deepEqual(sent.map(message => message.messageId), ["one"]);
90
+ requests[0].resolve({ ok: true });
91
+ await first;
92
+ assert.equal(delivery.state.sending, true);
93
+ assert.equal(delivery.find("one").status, "accepted");
94
+ assert.deepEqual(sent.map(message => message.messageId), ["one", "two"]);
95
+ assert.equal(sent[1].configuration.model, "original");
96
+ requests[1].resolve({ ok: true });
97
+ await second;
98
+ assert.deepEqual(sent.map(message => message.messageId), ["one", "two", "three"]);
99
+ requests[2].resolve({ ok: true });
100
+ await third;
101
+ assert.equal(delivery.state.sending, false);
102
+ });
103
+
104
+ test("a failed message retains its retry identity while later guidance is pending", async () => {
105
+ const firstRequest = Promise.withResolvers();
106
+ const secondRequest = Promise.withResolvers();
107
+ const sent = [];
108
+ const delivery = createAssistantMessageDelivery({ deliver: payload => {
109
+ sent.push(payload);
110
+ return [firstRequest.promise, secondRequest.promise, { ok: true }][sent.length - 1];
111
+ } });
112
+ const first = delivery.send({ message: "First", configuration: { model: "one" } }, { messageId: "one" });
113
+ const second = delivery.send({ message: "Second" }, { messageId: "two", queue: true });
114
+ const rejected = assert.rejects(first, /Offline/);
115
+ firstRequest.reject(new Error("Offline"));
116
+ await rejected;
117
+ assert.equal(delivery.find("one").status, "failed");
118
+ assert.equal(delivery.find("one").error, "Offline");
119
+ const retry = delivery.resend("one", { queue: true });
120
+ assert.equal(delivery.find("one").status, "pending");
121
+ assert.equal(sent.length, 2);
122
+ secondRequest.resolve({ ok: true });
123
+ await second;
124
+ await retry;
125
+ assert.deepEqual(sent[2], sent[0]);
126
+ assert.equal(delivery.state.sending, false);
127
+ });
128
+
129
+ test("reset discards queued delivery without waiting for the old transport", async () => {
130
+ const request = Promise.withResolvers();
131
+ const sent = [];
132
+ const delivery = createAssistantMessageDelivery({ deliver: payload => {
133
+ sent.push(payload.message);
134
+ return request.promise;
135
+ } });
136
+ const first = delivery.send({ message: "Old" });
137
+ const queued = delivery.send({ message: "Queued" }, { queue: true });
138
+ delivery.reset();
139
+ assert.equal(await queued, false);
140
+ assert.deepEqual(sent, ["Old"]);
141
+ const next = delivery.send({ message: "New" });
142
+ assert.deepEqual(sent, ["Old", "New"]);
143
+ assert.equal(delivery.state.sending, true);
144
+ request.resolve({ ok: true });
145
+ assert.equal(await first, false);
146
+ assert.deepEqual(await next, { ok: true });
147
+ assert.equal(delivery.state.sending, false);
148
+ });