@jskit-ai/assistant-core 0.1.175 → 0.1.177
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.
- package/examples/conversation/App.vue +46 -28
- package/examples/conversation/README.md +4 -1
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/DeliveryFixture.vue +61 -0
- package/fixtures/responsive-assistant/main.js +2 -1
- package/package.json +11 -6
- package/src/client/conversation/AssistantConversationElement.vue +71 -16
- package/src/client/conversation/index.js +1 -0
- package/src/client/conversation/messageDelivery.js +138 -0
- package/test/messageDelivery.browser.test.js +64 -0
- package/test/messageDelivery.test.js +72 -0
|
@@ -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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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({
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
}
|
|
112
|
-
} catch (failure) {
|
|
113
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
12
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
13
|
-
"@jskit-ai/kernel": "0.1.
|
|
11
|
+
"@jskit-ai/assistant-core": "0.1.177",
|
|
12
|
+
"@jskit-ai/http-runtime": "0.1.197",
|
|
13
|
+
"@jskit-ai/kernel": "0.1.199",
|
|
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.
|
|
18
|
-
"@jskit-ai/connectors-catalog": "0.1.
|
|
17
|
+
"@jskit-ai/connectors-core": "0.1.16",
|
|
18
|
+
"@jskit-ai/connectors-catalog": "0.1.16"
|
|
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.
|
|
3
|
+
"version": "0.1.177",
|
|
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.
|
|
37
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
37
|
+
"@jskit-ai/resource-core": "0.1.141",
|
|
38
|
+
"@jskit-ai/resource-crud-core": "0.1.141",
|
|
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.
|
|
47
|
-
"@jskit-ai/kernel": "0.1.
|
|
47
|
+
"@jskit-ai/http-runtime": "0.1.197",
|
|
48
|
+
"@jskit-ai/kernel": "0.1.199",
|
|
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="
|
|
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="
|
|
7
|
-
@edit-turn="
|
|
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' ||
|
|
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' ||
|
|
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
|
|
119
|
-
|
|
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
|
|
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
|
-
|
|
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" ||
|
|
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
|
+
});
|