@jskit-ai/assistant-core 0.1.175 → 0.1.176

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.
@@ -20,7 +20,7 @@
20
20
  </template>
21
21
  <script setup>
22
22
  import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
23
- import { AssistantConversationElement, AssistantAttachmentPreview, AssistantMessageAttachments, useAssistantAttachments, useAssistantSuggestions } from "@jskit-ai/assistant-core/client/conversation";
23
+ import { createAssistantMessageDelivery, AssistantConversationElement, AssistantAttachmentPreview, AssistantMessageAttachments, useAssistantAttachments, useAssistantSuggestions } from "@jskit-ai/assistant-core/client/conversation";
24
24
 
25
25
  const turns = ref([]), draft = ref(""), loading = ref(true), pending = ref(false), error = ref("");
26
26
  const accepted = ref(false);
@@ -75,10 +75,9 @@ async function reload() {
75
75
  finally { if (!disposed) loading.value = false; }
76
76
  }
77
77
 
78
- async function submit({ configuration: selectedConfiguration, attachments: selectedFiles }) {
79
- if (pending.value || (!draft.value.trim() && !selectedFiles.length)) return;
80
- const draftSnapshot = draft.value;
81
- const text = draftSnapshot || "Please review the attached files.";
78
+ const delivery = createAssistantMessageDelivery({ deliver });
79
+
80
+ async function deliver(payload) {
82
81
  pending.value = true;
83
82
  accepted.value = false;
84
83
  error.value = "";
@@ -87,30 +86,48 @@ async function submit({ configuration: selectedConfiguration, attachments: selec
87
86
  try {
88
87
  const response = await fetch("/api/messages", {
89
88
  method: "POST", headers: { "Content-Type": "application/json" }, signal: controller.signal,
90
- body: JSON.stringify({ messageId: crypto.randomUUID(), text, configuration: selectedConfiguration, integrationId: integrationId.value, attachmentIds: selectedFiles.map(file => file.attachmentId) })
89
+ body: JSON.stringify({ ...payload, text: payload.message })
91
90
  });
92
91
  if (!response.ok) throw new Error((await response.json()).error || "Could not send.");
93
- if (draft.value === draftSnapshot) draft.value = "";
94
- const reader = response.body.getReader(), decoder = new TextDecoder();
95
- let buffer = "";
96
- try {
97
- while (true) {
98
- const { value, done } = await reader.read();
99
- if (done) break;
100
- buffer += decoder.decode(value, { stream: true });
101
- let newline;
102
- while ((newline = buffer.indexOf("\n")) >= 0) {
103
- const event = JSON.parse(buffer.slice(0, newline));
104
- buffer = buffer.slice(newline + 1);
105
- if (disposed) continue;
106
- if (event.type === "accepted") { accepted.value = true; attachments.clearAttachments({ accepted: true, attachmentIds: selectedFiles.map(file => file.attachmentId) }); }
107
- if (event.type === "snapshot") turns.value = event.turns;
108
- if (event.type === "error") error.value = event.message;
92
+ const admission = Promise.withResolvers();
93
+ void readStream(response, controller, admission);
94
+ return await admission.promise;
95
+ } catch (failure) {
96
+ pending.value = false;
97
+ request = null;
98
+ throw failure;
99
+ }
100
+ }
101
+
102
+ async function readStream(response, controller, admission) {
103
+ const reader = response.body.getReader(), decoder = new TextDecoder();
104
+ let buffer = "";
105
+ try {
106
+ while (true) {
107
+ const { value, done } = await reader.read();
108
+ if (done) break;
109
+ buffer += decoder.decode(value, { stream: true });
110
+ let newline;
111
+ while ((newline = buffer.indexOf("\n")) >= 0) {
112
+ const event = JSON.parse(buffer.slice(0, newline));
113
+ buffer = buffer.slice(newline + 1);
114
+ if (disposed) continue;
115
+ if (event.type === "accepted") { accepted.value = true; admission.resolve({ ok: true }); }
116
+ if (event.type === "snapshot") turns.value = event.turns;
117
+ if (event.type === "error") {
118
+ if (accepted.value) error.value = event.message;
119
+ else admission.reject(new Error(event.message));
109
120
  }
110
121
  }
111
- } finally { reader.releaseLock(); }
112
- } catch (failure) { if (!disposed) error.value = failure.message; }
113
- finally { if (!disposed) pending.value = false; request = null; }
122
+ }
123
+ } catch (failure) {
124
+ if (accepted.value && !disposed) error.value = failure.message;
125
+ admission.reject(failure);
126
+ } finally {
127
+ admission.reject(new Error("The stream ended before the message was accepted."));
128
+ reader.releaseLock();
129
+ if (request === controller) { pending.value = false; request = null; }
130
+ }
114
131
  }
115
132
 
116
133
  async function stop() {
@@ -121,6 +138,7 @@ async function stop() {
121
138
  }
122
139
 
123
140
  const adapter = reactive({
141
+ delivery,
124
142
  attachments,
125
143
  suggestions,
126
144
  models: computed(() => modelChoices.value.length && configurationMode.value !== "hidden" ? {
@@ -134,17 +152,17 @@ const adapter = reactive({
134
152
  } : null),
135
153
  conversation: { turns, loading, visible: true, assistantLabel: "Assistant", scrollKey: "example" },
136
154
  composer: {
137
- draft, pending: computed(() => pending.value && !accepted.value), submitOnEnter: true, submitOnModifierEnter: true,
155
+ draft, payload: computed(() => ({ integrationId: integrationId.value })), pending: computed(() => pending.value && !accepted.value), submitOnEnter: true, submitOnModifierEnter: true,
138
156
  canSend: computed(() => !loading.value && !pending.value && Boolean(draft.value.trim() || attachments.attachments.length)),
139
157
  canStop: pending, placeholder: "Ask a question…"
140
158
  },
141
- actions: { reload, submit, stop,
159
+ actions: { reload, stop,
142
160
  setDraft: (value) => { draft.value = value; },
143
161
  updateConfiguration: (value) => { configuration.value = value; }
144
162
  }
145
163
  });
146
164
  onMounted(reload);
147
- onBeforeUnmount(() => { disposed = true; request?.abort(); });
165
+ onBeforeUnmount(() => { disposed = true; delivery.reset(); request?.abort(); });
148
166
  </script>
149
167
  <style>
150
168
  html, body, #app { margin: 0; height: 100%; }
@@ -42,7 +42,10 @@ offline when `ASSISTANT_INTEGRATIONS` is unset.
42
42
 
43
43
  The shared chooser selects the configured chat integrations; suggestions keep
44
44
  their independent connection. The shared row shows suggestions and working status,
45
- and responses stream incrementally. The example accepts text, JSON and common
45
+ and responses stream incrementally. Messages appear immediately through
46
+ `createAssistantMessageDelivery`; the shared element submits the draft, clears
47
+ accepted files and supplies failed-message Resend, Edit and Cancel actions. Resend preserves the request identity and any
48
+ newer composer draft. The example accepts text, JSON and common
46
49
  web images up to 2 MB each. Uploads use opaque IDs, preview through app URLs,
47
50
  and preserve files accepted by a message. Both uploaded bytes and default history
48
51
  are transient. When replacing history storage, also replace `attachments.js`
@@ -8,14 +8,14 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.175",
12
- "@jskit-ai/http-runtime": "0.1.195",
13
- "@jskit-ai/kernel": "0.1.197",
11
+ "@jskit-ai/assistant-core": "0.1.176",
12
+ "@jskit-ai/http-runtime": "0.1.196",
13
+ "@jskit-ai/kernel": "0.1.198",
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.14",
18
- "@jskit-ai/connectors-catalog": "0.1.14"
17
+ "@jskit-ai/connectors-core": "0.1.15",
18
+ "@jskit-ai/connectors-catalog": "0.1.15"
19
19
  },
20
20
  "devDependencies": {
21
21
  "@vitejs/plugin-vue": "^6.0.7",
@@ -0,0 +1,61 @@
1
+ <script setup>
2
+ import { reactive, ref } from "vue";
3
+ import AssistantConversationElement from "../../src/client/conversation/AssistantConversationElement.vue";
4
+ import { createAssistantMessageDelivery } from "../../src/client/conversation/messageDelivery.js";
5
+
6
+ const draft = ref("");
7
+ const turns = ref([]);
8
+ const requests = ref([]);
9
+ const configuration = ref({ tone: "concise" });
10
+ const clearedFiles = ref([]);
11
+ const attachments = reactive({
12
+ attachments: [{ attachmentId: "original-file", fileName: "original.txt" }],
13
+ queueItems: [], canSubmit: true, canAddFiles: false,
14
+ clearAttachments({ attachmentIds }) {
15
+ clearedFiles.value.push(...attachmentIds);
16
+ this.attachments = this.attachments.filter(file => !attachmentIds.includes(file.attachmentId));
17
+ }
18
+ });
19
+ let pendingRequest;
20
+ const delivery = createAssistantMessageDelivery({ deliver(payload) {
21
+ requests.value.push(payload);
22
+ pendingRequest = Promise.withResolvers();
23
+ return pendingRequest.promise;
24
+ } });
25
+ function accept() {
26
+ const payload = requests.value.at(-1);
27
+ turns.value = [{ turnId: "accepted", user: { messageId: payload.messageId, text: payload.message },
28
+ assistant: { text: "Accepted once." } }];
29
+ pendingRequest.resolve({ ok: true });
30
+ }
31
+ const adapter = reactive({
32
+ delivery, attachments,
33
+ conversation: { turns, visible: true, scrollKey: "delivery", welcomeMessage: "Start a conversation." },
34
+ composer: { draft, canSend: true, submitOnEnter: true },
35
+ actions: {
36
+ setDraft(value) { draft.value = value; }
37
+ }
38
+ });
39
+ </script>
40
+ <template>
41
+ <v-app>
42
+ <main class="fixture">
43
+ <nav>
44
+ <button @click="pendingRequest.reject(new Error('Delivery unavailable.'))">Fail request</button>
45
+ <button @click="accept">Accept request</button>
46
+ <button @click="attachments.attachments.push({ attachmentId: 'new-file', fileName: 'next.txt' }); configuration = { tone: 'detailed' }">Prepare newer input</button>
47
+ <output hidden data-requests>{{ JSON.stringify(requests) }}</output>
48
+ <output data-files>{{ attachments.attachments.map(file => file.attachmentId).join(',') }}</output>
49
+ <output data-cleared>{{ clearedFiles.join(',') }}</output>
50
+ </nav>
51
+ <AssistantConversationElement :adapter="adapter" :configuration="configuration" />
52
+ </main>
53
+ </v-app>
54
+ </template>
55
+ <style scoped>
56
+ .fixture { height: 95dvh; width: min(900px, 100%); margin: auto; display: flex; flex-direction: column; }
57
+ nav { display: flex; gap: 12px; flex-wrap: wrap; padding: 12px; }
58
+ nav button { border: 1px solid; padding: 4px; }
59
+ output { overflow-wrap: anywhere; min-width: 0; }
60
+ .assistant-conversation { flex: 1; }
61
+ </style>
@@ -7,6 +7,7 @@ import { aliases as mdiAliases, mdi } from "vuetify/iconsets/mdi-svg";
7
7
  import App from "./App.vue";
8
8
  import ConversationFixture from "./ConversationFixture.vue";
9
9
  import ProgressFixture from "./ProgressFixture.vue";
10
+ import DeliveryFixture from "./DeliveryFixture.vue";
10
11
 
11
12
  const vuetify = createVuetify({
12
13
  components,
@@ -22,6 +23,6 @@ const vuetify = createVuetify({
22
23
  });
23
24
 
24
25
  const query = new URLSearchParams(location.search);
25
- createApp(query.has("progress") ? ProgressFixture : query.has("conversation") ? ConversationFixture : App)
26
+ createApp(query.has("delivery") ? DeliveryFixture : query.has("progress") ? ProgressFixture : query.has("conversation") ? ConversationFixture : App)
26
27
  .use(vuetify)
27
28
  .mount("#app");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.175",
3
+ "version": "0.1.176",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -20,7 +20,8 @@
20
20
  "./client/conversation-scroll": "./src/client/conversation/useScrollToBottom.js",
21
21
  "./testing/conversation-storage": "./src/testing/conversationStorageContract.js",
22
22
  "./client/conversation-suggestions": "./src/client/conversation/useAssistantSuggestions.js",
23
- "./client/conversation-attachments": "./src/client/conversation/useAssistantAttachments.js"
23
+ "./client/conversation-attachments": "./src/client/conversation/useAssistantAttachments.js",
24
+ "./client/conversation-delivery": "./src/client/conversation/messageDelivery.js"
24
25
  },
25
26
  "dependencies": {
26
27
  "@ai-sdk/anthropic": "^4.0.53",
@@ -33,8 +34,8 @@
33
34
  "@ai-sdk/perplexity": "^4.0.43",
34
35
  "@ai-sdk/togetherai": "^3.0.49",
35
36
  "@ai-sdk/xai": "^4.0.58",
36
- "@jskit-ai/resource-core": "0.1.139",
37
- "@jskit-ai/resource-crud-core": "0.1.139",
37
+ "@jskit-ai/resource-core": "0.1.140",
38
+ "@jskit-ai/resource-crud-core": "0.1.140",
38
39
  "@mdi/js": "^7.4.47",
39
40
  "@openrouter/ai-sdk-provider": "^3.0.0",
40
41
  "ai": "^7.0.101",
@@ -43,8 +44,8 @@
43
44
  "ws": "^8.20.1"
44
45
  },
45
46
  "peerDependencies": {
46
- "@jskit-ai/http-runtime": "0.1.195",
47
- "@jskit-ai/kernel": "0.1.197",
47
+ "@jskit-ai/http-runtime": "0.1.196",
48
+ "@jskit-ai/kernel": "0.1.198",
48
49
  "vue": "^3.5.13",
49
50
  "vuetify": "^4.0.0"
50
51
  },
@@ -119,6 +120,10 @@
119
120
  {
120
121
  "subpath": "./testing/conversation-storage",
121
122
  "summary": "Reusable checks for application conversation storage adapters."
123
+ },
124
+ {
125
+ "subpath": "./client/conversation-delivery",
126
+ "summary": "Optimistic message delivery, immutable retry payloads, failure actions and canonical receipt reconciliation."
122
127
  }
123
128
  ],
124
129
  "capabilities": []
@@ -1,10 +1,10 @@
1
1
  <template>
2
2
  <section class="assistant-conversation" :aria-label="label">
3
3
  <AssistantTranscript
4
- v-bind="adapter.conversation" :working="working" class="assistant-conversation__transcript"
4
+ v-bind="conversation" :working="working" class="assistant-conversation__transcript"
5
5
  @load-more="adapter.actions?.loadMore?.($event)" @reload="adapter.actions?.reload?.()"
6
- @resend-turn="adapter.actions?.resend?.($event)" @cancel-turn="adapter.actions?.cancel?.($event)"
7
- @edit-turn="adapter.actions?.edit?.($event)" @link-click="adapter.actions?.openLink?.($event)"
6
+ @resend-turn="resend($event)" @cancel-turn="cancel($event)"
7
+ @edit-turn="edit($event)" @link-click="adapter.actions?.openLink?.($event)"
8
8
  >
9
9
  <template v-for="name in transcriptSlots" #[name]="scope"><slot :name="name" v-bind="scope" /></template>
10
10
  </AssistantTranscript>
@@ -56,7 +56,7 @@
56
56
  </slot>
57
57
  </template>
58
58
  <template #footer>
59
- <AssistantComposerActions ref="delivery" :state="{ ...adapter.composer, canSend }" @submit="submit" @stop="stop">
59
+ <AssistantComposerActions ref="delivery" :state="{ ...adapter.composer, pending, canSend }" @submit="submit" @stop="stop">
60
60
  <AssistantModelControl
61
61
  v-if="adapter.models && adapter.models.enabled !== false" v-model="modelsOpen" v-bind="adapter.models"
62
62
  @select-provider="adapter.models.selectProvider?.($event)" @select-model="adapter.models.selectModel?.($event)"
@@ -68,13 +68,13 @@
68
68
  />
69
69
  <slot
70
70
  v-if="configurationMode !== 'hidden'" name="configuration"
71
- :configuration="configuration" :disabled="configurationMode !== 'editable' || adapter.composer.pending"
71
+ :configuration="configuration" :disabled="configurationMode !== 'editable' || pending"
72
72
  :update="updateConfiguration"
73
73
  >
74
74
  <v-select
75
75
  v-for="field in configurationFields" :key="field.name"
76
76
  :model-value="configuration[field.name]" :label="field.label" :items="field.items"
77
- :disabled="configurationMode !== 'editable' || adapter.composer.pending"
77
+ :disabled="configurationMode !== 'editable' || pending"
78
78
  density="compact" hide-details variant="outlined" class="assistant-conversation__setting"
79
79
  @update:model-value="updateConfiguration({ ...configuration, [field.name]: $event })"
80
80
  />
@@ -91,7 +91,7 @@
91
91
  </section>
92
92
  </template>
93
93
  <script setup>
94
- import { computed, ref, useId, useSlots } from "vue";
94
+ import { computed, ref, useId, useSlots, watch } from "vue";
95
95
  import { mdiPaperclip } from "@mdi/js";
96
96
  import AssistantTranscript from "./AssistantTranscript.vue";
97
97
  import AssistantPromptInput from "./AssistantPromptInput.vue";
@@ -115,13 +115,22 @@ const fileInput = ref(null);
115
115
  const modelsOpen = ref(false);
116
116
  const delivery = ref(null);
117
117
  const statusId = `assistant-status-${useId()}`;
118
- const working = computed(() => props.adapter.conversation.working ?? Boolean(
119
- props.adapter.composer?.canStop || props.adapter.conversation.turns?.some(turn => turn.pending)
120
- ));
118
+ const conversation = computed(() => {
119
+ const saved = props.adapter.conversation;
120
+ if (!props.adapter.delivery) return saved;
121
+ const turns = props.adapter.delivery.turns(saved.turns || []);
122
+ return { ...saved, turns, welcomeMessage: turns.length ? "" : saved.welcomeMessage };
123
+ });
124
+ watch(() => [props.adapter.delivery, props.adapter.conversation.turns], ([controller, turns]) => {
125
+ controller?.reconcile(turns || []);
126
+ }, { immediate: true });
127
+ const pending = computed(() => Boolean(props.adapter.delivery?.state.sending || props.adapter.composer?.pending));
128
+ const working = computed(() => conversation.value.working ?? (!pending.value && Boolean(
129
+ props.adapter.composer?.canStop || conversation.value.turns?.some(turn => turn.pending)
130
+ )));
121
131
  const activity = computed(() => props.adapter.activity ?? {
122
132
  label: props.adapter.composer?.stopPending ? "Stopping…"
123
- : working.value
124
- ? "Assistant is working…" : props.adapter.composer?.pending ? "Sending to assistant…" : ""
133
+ : pending.value ? "Sending to assistant…" : working.value ? "Assistant is working…" : ""
125
134
  });
126
135
  const suggestionPreview = computed(() => !props.adapter.composer?.draft && props.adapter.suggestions?.visible
127
136
  ? props.adapter.suggestions.preview : "");
@@ -130,7 +139,28 @@ const attachmentState = computed(() => ({
130
139
  count: attachmentsEnabled.value ? props.adapter.attachments.queueItems?.length || 0 : 0,
131
140
  canSubmit: !attachmentsEnabled.value || props.adapter.attachments.canSubmit !== false
132
141
  }));
133
- const canSend = computed(() => props.adapter.composer?.canSend && attachmentState.value.canSubmit);
142
+ const canSend = computed(() => !pending.value && props.adapter.composer?.canSend && attachmentState.value.canSubmit);
143
+ async function resend(id) {
144
+ if (props.adapter.actions?.resend) return props.adapter.actions.resend(id);
145
+ const controller = props.adapter.delivery;
146
+ const payload = controller?.find(id)?.payload;
147
+ const attachments = props.adapter.attachments;
148
+ try {
149
+ const response = await controller?.resend(id);
150
+ clearAcceptedAttachments(response, payload, attachments);
151
+ return response;
152
+ }
153
+ catch { /* The delivery controller retains the error beside the failed message. */ }
154
+ }
155
+ function cancel(id) {
156
+ return props.adapter.actions?.cancel ? props.adapter.actions.cancel(id) : props.adapter.delivery?.cancel(id);
157
+ }
158
+ function edit(id) {
159
+ if (props.adapter.actions?.edit) return props.adapter.actions.edit(id);
160
+ if (!props.adapter.actions?.setDraft) return;
161
+ const draft = props.adapter.delivery?.edit(id, props.adapter.composer?.draft || "");
162
+ if (draft != null) { props.adapter.actions.setDraft(draft); input.value?.focus(); }
163
+ }
134
164
  function attachmentEvent(method, event) {
135
165
  if (attachmentsEnabled.value) return props.adapter.attachments[method]?.(event);
136
166
  }
@@ -149,17 +179,42 @@ function dismissSuggestions() {
149
179
  props.adapter.suggestions?.dismiss();
150
180
  input.value?.focus();
151
181
  }
152
- function submit() {
182
+ function clearAcceptedAttachments(response, payload, attachments) {
183
+ if (response !== false && response?.ok !== false && payload?.attachmentIds?.length) {
184
+ attachments?.clearAttachments({ accepted: true, attachmentIds: payload.attachmentIds });
185
+ }
186
+ }
187
+ async function submit() {
153
188
  if (!canSend.value) return;
154
- return props.adapter.actions.submit({ configuration: props.configuration, attachments: attachmentsEnabled.value ? props.adapter.attachments.attachments.map(item => ({ ...item })) : [] });
189
+ const attachments = props.adapter.attachments;
190
+ const files = attachmentsEnabled.value ? props.adapter.attachments.attachments.map(item => ({ ...item })) : [];
191
+ if (props.adapter.actions?.submit) return props.adapter.actions.submit({ configuration: props.configuration, attachments: files });
192
+ const controller = props.adapter.delivery;
193
+ if (!controller || !props.adapter.actions?.setDraft) return;
194
+ const message = String(props.adapter.composer.draft || "").trim() ||
195
+ (files.length ? props.adapter.composer.attachmentMessage || "Please review the attached files." : "");
196
+ if (!message) return;
197
+ const payload = {
198
+ ...props.adapter.composer.payload,
199
+ configuration: { ...props.configuration },
200
+ message,
201
+ ...(files.length ? { attachmentIds: files.map(file => file.attachmentId), displayAttachments: files } : {})
202
+ };
203
+ props.adapter.actions.setDraft("");
204
+ try {
205
+ const response = await controller.send(payload);
206
+ clearAcceptedAttachments(response, payload, attachments);
207
+ return response;
208
+ } catch { return false; /* Delivery retains the failed message and its actions. */ }
155
209
  }
210
+
156
211
  function stop() {
157
212
  const state = props.adapter.composer;
158
213
  if (!state?.canStop || state.stopDisabled || state.stopPending) return;
159
214
  return props.adapter.actions.stop();
160
215
  }
161
216
  function updateConfiguration(value) {
162
- if (props.configurationMode !== "editable" || props.adapter.composer?.pending) return;
217
+ if (props.configurationMode !== "editable" || pending.value) return;
163
218
  return props.adapter.actions?.updateConfiguration?.(value);
164
219
  }
165
220
  defineExpose({ focus: () => input.value?.focus(), submit, stop });
@@ -16,3 +16,4 @@ export { default as AssistantModelControl } from "./AssistantModelControl.vue";
16
16
  export { default as AssistantMessageAttachments } from "./AssistantMessageAttachments.vue";
17
17
  export { default as AssistantAttachmentPreview } from "./AssistantAttachmentPreview.vue";
18
18
  export { default as AssistantQuestionInputs } from "./AssistantQuestionInputs.vue";
19
+ export { createAssistantMessageDelivery } from "./messageDelivery.js";
@@ -0,0 +1,138 @@
1
+ import { reactive } from "vue";
2
+
3
+ const messageText = value => String(value || "").trim();
4
+
5
+ function turnMatchesOptimisticMessage(turn = {}, optimistic = {}) {
6
+ const canonicalMessageId = messageText(turn?.user?.messageId);
7
+ const optimisticMessageId = messageText(optimistic.id);
8
+ if (canonicalMessageId && optimisticMessageId) {
9
+ return canonicalMessageId === optimisticMessageId;
10
+ }
11
+ if (messageText(turn?.user?.text) !== optimistic.text) {
12
+ return false;
13
+ }
14
+ const userAtMs = Date.parse(String(turn?.user?.at || ""));
15
+ return Number.isFinite(userAtMs) && userAtMs >= optimistic.createdAtMs - 5000;
16
+ }
17
+
18
+ function unmatchedOptimisticMessages(turns = [], optimisticMessages = []) {
19
+ const conversationTurns = Array.isArray(turns) ? turns : [];
20
+ const matchedTurnIndexes = new Set();
21
+ return (Array.isArray(optimisticMessages) ? optimisticMessages : []).filter((message) => {
22
+ const turnIndex = conversationTurns.findIndex((turn, index) => (
23
+ !matchedTurnIndexes.has(index) && turnMatchesOptimisticMessage(turn, message)
24
+ ));
25
+ if (turnIndex < 0) {
26
+ return true;
27
+ }
28
+ matchedTurnIndexes.add(turnIndex);
29
+ return false;
30
+ });
31
+ }
32
+
33
+ // One delivery state per conversation. The caller owns transport and receipts;
34
+ // the shared assistant element renders these pending and failed turns.
35
+ function createAssistantMessageDelivery({ deliver: defaultDeliver } = {}) {
36
+ const state = reactive({ messages: [], sending: false });
37
+ let generation = 0;
38
+
39
+ function find(messageId) {
40
+ return state.messages.find((message) => message.id === messageId) || null;
41
+ }
42
+
43
+ function remove(messageId) {
44
+ state.messages = state.messages.filter((message) => message.id !== messageId);
45
+ }
46
+
47
+ function reconcile(turns) {
48
+ state.messages = unmatchedOptimisticMessages(turns, state.messages);
49
+ }
50
+
51
+ function turns(savedTurns = []) {
52
+ return [
53
+ ...savedTurns,
54
+ ...unmatchedOptimisticMessages(savedTurns, state.messages).map((message) => ({
55
+ optimistic: { error: message.error, id: message.id, status: message.status },
56
+ turnId: message.id,
57
+ user: {
58
+ attachments: message.attachments,
59
+ at: message.createdAt,
60
+ messageId: message.id,
61
+ role: "user",
62
+ text: message.text
63
+ }
64
+ }))
65
+ ];
66
+ }
67
+
68
+ async function send(payload, { messageId = crypto.randomUUID(), deliver = defaultDeliver, isCurrent = () => true } = {}) {
69
+ if (state.sending || !String(payload?.message || "").trim()) return false;
70
+ if (typeof deliver !== "function") throw new TypeError("Message delivery requires a deliver function.");
71
+ const snapshot = JSON.parse(JSON.stringify(payload));
72
+ const ownerGeneration = generation;
73
+ const current = () => generation === ownerGeneration && isCurrent();
74
+ const now = new Date();
75
+ const message = {
76
+ attachments: snapshot.displayAttachments || [],
77
+ createdAt: now.toISOString(),
78
+ createdAtMs: now.getTime(),
79
+ error: "",
80
+ id: messageId,
81
+ payload: snapshot,
82
+ status: "pending",
83
+ text: String(snapshot.displayMessage || snapshot.message).trim()
84
+ };
85
+ state.messages = [...state.messages.filter((entry) => entry.id !== messageId), message];
86
+ state.sending = true;
87
+ try {
88
+ const response = await deliver({ ...snapshot, messageId });
89
+ if (!current()) return false;
90
+ if (response === false || response?.ok === false) {
91
+ fail(messageId, response?.error || "Message could not be sent.");
92
+ }
93
+ return response;
94
+ } catch (error) {
95
+ if (!current()) return false;
96
+ fail(messageId, error);
97
+ throw error;
98
+ } finally {
99
+ if (generation === ownerGeneration) state.sending = false;
100
+ }
101
+ }
102
+
103
+ function fail(messageId, error) {
104
+ const message = find(messageId);
105
+ if (message) {
106
+ message.error = String(error?.message || error || "Message could not be sent.").trim();
107
+ message.status = "failed";
108
+ }
109
+ }
110
+
111
+ function cancel(messageId) {
112
+ if (state.sending || find(messageId)?.status !== "failed") return false;
113
+ remove(messageId);
114
+ return true;
115
+ }
116
+
117
+ function edit(messageId, draft = "") {
118
+ const message = find(messageId);
119
+ if (!message || !cancel(messageId)) return null;
120
+ return !draft || draft.startsWith(message.text) ? draft || message.text : `${message.text}\n\n${draft}`;
121
+ }
122
+
123
+ function resend(messageId) {
124
+ const message = find(messageId);
125
+ if (message?.status !== "failed") return false;
126
+ return send(message.payload, { messageId });
127
+ }
128
+
129
+ function reset() {
130
+ generation += 1;
131
+ state.messages = [];
132
+ state.sending = false;
133
+ }
134
+
135
+ return { cancel, edit, find, reconcile, remove, resend, reset, send, state, turns };
136
+ }
137
+
138
+ export { createAssistantMessageDelivery, unmatchedOptimisticMessages };
@@ -0,0 +1,64 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { fileURLToPath } from "node:url";
4
+ import { chromium, expect } from "@playwright/test";
5
+ import { createChromiumLaunchOptions, startViteFixture, stopProcess } from "../../../tooling/testUtils/browserFixture.mjs";
6
+
7
+ test("the shared element renders delayed sends and supplies retry, edit and cancel without application handlers", {
8
+ skip: process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION !== "1", timeout: 90_000
9
+ }, async () => {
10
+ const vite = await startViteFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
11
+ const browser = await chromium.launch(createChromiumLaunchOptions());
12
+ try {
13
+ for (const width of [390, 800, 1365]) {
14
+ const page = await browser.newPage({ viewport: { width, height: 900 } });
15
+ const errors = [];
16
+ page.on("pageerror", error => errors.push(error.message));
17
+ await page.goto(`${vite.baseURL}/?delivery=1`);
18
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
19
+ const send = async text => {
20
+ await input.fill(text);
21
+ await page.getByRole("button", { name: "Send message", exact: true }).click();
22
+ };
23
+ const fail = () => page.getByRole("button", { name: "Fail request" }).click();
24
+ await send("Explain this work.");
25
+ await expect(page.getByText("Explain this work.", { exact: true })).toHaveCount(1);
26
+ await expect(page.getByText("Start a conversation.", { exact: true })).toHaveCount(0);
27
+ await expect(page.locator(".assistant-composer-support__assistant-status")).toHaveText("Sending to assistant…");
28
+ await fail();
29
+ await expect(page.getByText("Delivery unavailable.", { exact: true })).toHaveCount(1);
30
+ await input.fill("Keep my newer draft.");
31
+ await page.getByRole("button", { name: "Prepare newer input" }).click();
32
+ await page.getByRole("button", { name: "Resend", exact: true }).click();
33
+ await expect(input).toHaveValue("Keep my newer draft.");
34
+ await page.getByRole("button", { name: "Accept request" }).click();
35
+ await expect(page.getByText("Accepted once.", { exact: true })).toBeVisible();
36
+ await expect(page.getByText("Explain this work.", { exact: true })).toHaveCount(1);
37
+ const requests = JSON.parse(await page.locator("[data-requests]").textContent());
38
+ assert.equal(requests.length, 2);
39
+ assert.deepEqual(requests[0], requests[1]);
40
+ assert.deepEqual(requests[0].configuration, { tone: "concise" });
41
+ assert.deepEqual(requests[0].attachmentIds, ["original-file"]);
42
+ await expect(page.locator("[data-files]")).toHaveText("new-file");
43
+ await expect(page.locator("[data-cleared]")).toHaveText("original-file");
44
+ await send("Edit this question.");
45
+ await fail();
46
+ await input.fill("Another draft.");
47
+ await page.getByRole("button", { name: "Edit", exact: true }).click();
48
+ await expect(input).toHaveValue("Edit this question.\n\nAnother draft.");
49
+ await expect(input).toBeFocused();
50
+ await send("Cancel this question.");
51
+ await fail();
52
+ await input.fill("Retain this.");
53
+ await page.getByRole("button", { name: "Cancel", exact: true }).click();
54
+ await expect(input).toHaveValue("Retain this.");
55
+ await expect(page.getByText("Cancel this question.", { exact: true })).toHaveCount(0);
56
+ assert.deepEqual(errors, []);
57
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
58
+ await page.close();
59
+ }
60
+ } finally {
61
+ await browser.close();
62
+ await stopProcess(vite);
63
+ }
64
+ });
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createAssistantMessageDelivery } from "../src/client/conversation/messageDelivery.js";
4
+
5
+ test("sending immediately renders the message and reconciles only its authoritative receipt", async () => {
6
+ const request = Promise.withResolvers();
7
+ const delivery = createAssistantMessageDelivery({ deliver: () => request.promise });
8
+ const sending = delivery.send({ message: "Hello", displayAttachments: [{ attachmentId: "file" }] });
9
+ const [turn] = delivery.turns();
10
+ assert.equal(delivery.state.sending, true);
11
+ assert.equal(turn.user.text, "Hello");
12
+ assert.equal(turn.user.attachments[0].attachmentId, "file");
13
+ assert.equal(await delivery.send({ message: "Duplicate click" }), false);
14
+ const other = { user: { messageId: "different", text: "Hello", at: turn.user.at } };
15
+ delivery.reconcile([other]);
16
+ assert.equal(delivery.state.messages.length, 1);
17
+ const receipt = { user: { ...turn.user } };
18
+ delivery.reconcile([other, receipt]);
19
+ assert.equal(delivery.state.messages.length, 0);
20
+ assert.deepEqual(delivery.turns([other, receipt]), [other, receipt]);
21
+ request.resolve({ ok: true });
22
+ await sending;
23
+ assert.equal(delivery.state.sending, false);
24
+ });
25
+
26
+ test("failed delivery retains the original payload and identity for an explicit retry", async () => {
27
+ const sent = [];
28
+ const delivery = createAssistantMessageDelivery({ deliver: async payload => {
29
+ sent.push(payload);
30
+ if (sent.length === 1) throw new Error("Offline");
31
+ return { ok: true };
32
+ } });
33
+ const payload = { message: "Question", configuration: { model: "one" } };
34
+ await assert.rejects(delivery.send(payload), /Offline/);
35
+ payload.configuration.model = "two";
36
+ const [message] = delivery.state.messages;
37
+ assert.equal(message.status, "failed");
38
+ assert.equal(message.error, "Offline");
39
+ assert.equal(delivery.state.sending, false);
40
+ assert.deepEqual(await delivery.resend(message.id), { ok: true });
41
+ assert.deepEqual(sent[0], sent[1]);
42
+ assert.equal(sent[1].configuration.model, "one");
43
+ assert.equal(delivery.state.messages.length, 1);
44
+ assert.equal(delivery.state.messages[0].status, "pending");
45
+ });
46
+
47
+ test("cancel and edit affect failed messages and preserve a newer draft", async () => {
48
+ const delivery = createAssistantMessageDelivery({ deliver: async () => ({ ok: false, error: "Unavailable" }) });
49
+ await delivery.send({ message: "First" }, { messageId: "one" });
50
+ assert.equal(delivery.state.messages[0].error, "Unavailable");
51
+ assert.equal(delivery.edit("one", "New draft"), "First\n\nNew draft");
52
+ assert.equal(delivery.edit("one", "New draft"), null);
53
+ await delivery.send({ message: "Second" }, { messageId: "two" });
54
+ assert.equal(delivery.cancel("two"), true);
55
+ assert.deepEqual(delivery.state.messages, []);
56
+ });
57
+
58
+ test("retiring a conversation isolates late responses from the next conversation", async () => {
59
+ const oldRequest = Promise.withResolvers();
60
+ const newRequest = Promise.withResolvers();
61
+ const delivery = createAssistantMessageDelivery();
62
+ const oldSend = delivery.send({ message: "Old" }, { deliver: () => oldRequest.promise });
63
+ delivery.reset();
64
+ const newSend = delivery.send({ message: "New" }, { deliver: () => newRequest.promise });
65
+ oldRequest.reject(new Error("Late failure"));
66
+ assert.equal(await oldSend, false);
67
+ assert.equal(delivery.state.sending, true);
68
+ assert.deepEqual(delivery.state.messages.map(message => message.text), ["New"]);
69
+ newRequest.resolve({ ok: true });
70
+ await newSend;
71
+ assert.equal(delivery.state.sending, false);
72
+ });