@jskit-ai/assistant-core 0.1.216 → 0.1.218
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/README.md +10 -0
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -1
- package/package.json +5 -5
- package/src/client/conversation/AssistantConversationStatus.vue +7 -6
- package/src/client/conversation/AssistantTranscript.vue +28 -46
- package/test/messageDelivery.browser.test.js +29 -0
|
@@ -75,3 +75,13 @@ database or files; the UI has no storage assumptions.
|
|
|
75
75
|
|
|
76
76
|
See the JSKIT guide **Embeddable assistant conversations** for the complete
|
|
77
77
|
client, storage and provider contracts and reusable storage-adapter checks.
|
|
78
|
+
|
|
79
|
+
An adapter that sets `conversation.cancellableDelivery` and supplies
|
|
80
|
+
`actions.cancel(messageId)` also exposes **Cancel** next to **Retry** for uncertain
|
|
81
|
+
delivery. The application owns that cancellation and preserves accepted history.
|
|
82
|
+
Standalone delivery continues to offer cancellation only for definite failures.
|
|
83
|
+
Direct `AssistantTranscript` consumers opt in with `cancellableDelivery`.
|
|
84
|
+
|
|
85
|
+
The shared transcript interleaves timestamped messages across storage and native
|
|
86
|
+
streaming turns. Equal timestamps keep source order; undated notices keep their
|
|
87
|
+
slots. Reasoning groups form after ordering, preserving original turn identities.
|
|
@@ -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.218",
|
|
12
|
+
"@jskit-ai/http-runtime": "0.1.238",
|
|
13
|
+
"@jskit-ai/kernel": "0.1.240",
|
|
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.57",
|
|
18
|
+
"@jskit-ai/connectors-catalog": "0.1.57"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@vitejs/plugin-vue": "^6.0.7",
|
|
@@ -3,6 +3,7 @@ import { reactive, ref } from "vue";
|
|
|
3
3
|
import AssistantConversationElement from "../../src/client/conversation/AssistantConversationElement.vue";
|
|
4
4
|
import { createAssistantMessageDelivery } from "../../src/client/conversation/messageDelivery.js";
|
|
5
5
|
|
|
6
|
+
const recoveryFixture = new URLSearchParams(location.search).has("recovery");
|
|
6
7
|
const draft = ref("");
|
|
7
8
|
const turns = ref([]);
|
|
8
9
|
const requests = ref([]);
|
|
@@ -29,11 +30,24 @@ function accept() {
|
|
|
29
30
|
assistant: { text: "Accepted once." } }];
|
|
30
31
|
pendingRequest.resolve({ ok: true });
|
|
31
32
|
}
|
|
33
|
+
if (recoveryFixture) {
|
|
34
|
+
const at = minute => `2026-10-13T06:${minute}:00.000Z`;
|
|
35
|
+
turns.value = [
|
|
36
|
+
{ turnId: "one", user: { messageId: "one", text: "Question one", at: at("01") } },
|
|
37
|
+
{ turnId: "two", user: { messageId: "two", text: "Question two", at: at("03") } },
|
|
38
|
+
{ turnId: "native", messages: [
|
|
39
|
+
{ messageId: "reply-one", role: "assistant", text: "Reply one", at: at("02") },
|
|
40
|
+
{ messageId: "reply-two", role: "assistant", text: "Reply two", at: at("04") }
|
|
41
|
+
] }
|
|
42
|
+
];
|
|
43
|
+
delivery.state.messages.push({ id: "uncertain", text: "Discard failed attempt", status: "uncertain", error: "Lost response", createdAt: at("05") });
|
|
44
|
+
}
|
|
32
45
|
const adapter = reactive({
|
|
33
46
|
delivery, attachments,
|
|
34
|
-
conversation: { turns, visible: true, scrollKey: "delivery", welcomeMessage: "Start a conversation." },
|
|
47
|
+
conversation: { cancellableDelivery: recoveryFixture, turns, visible: true, scrollKey: "delivery", welcomeMessage: "Start a conversation." },
|
|
35
48
|
composer: { draft, canSend: true, submitOnEnter: true, queueWhileSending },
|
|
36
49
|
actions: {
|
|
50
|
+
...(recoveryFixture ? { cancel: id => delivery.remove(id) } : {}),
|
|
37
51
|
setDraft(value) { draft.value = value; }
|
|
38
52
|
}
|
|
39
53
|
});
|
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.218",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -47,8 +47,8 @@
|
|
|
47
47
|
"@ai-sdk/perplexity": "^4.0.43",
|
|
48
48
|
"@ai-sdk/togetherai": "^3.0.49",
|
|
49
49
|
"@ai-sdk/xai": "^4.0.58",
|
|
50
|
-
"@jskit-ai/resource-core": "0.1.
|
|
51
|
-
"@jskit-ai/resource-crud-core": "0.1.
|
|
50
|
+
"@jskit-ai/resource-core": "0.1.182",
|
|
51
|
+
"@jskit-ai/resource-crud-core": "0.1.182",
|
|
52
52
|
"@mdi/js": "^7.4.47",
|
|
53
53
|
"@openrouter/ai-sdk-provider": "^3.0.0",
|
|
54
54
|
"ai": "^7.0.101",
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
"ws": "^8.20.1"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
|
-
"@jskit-ai/http-runtime": "0.1.
|
|
61
|
-
"@jskit-ai/kernel": "0.1.
|
|
60
|
+
"@jskit-ai/http-runtime": "0.1.238",
|
|
61
|
+
"@jskit-ai/kernel": "0.1.240",
|
|
62
62
|
"vue": "^3.5.13",
|
|
63
63
|
"vuetify": "^4.0.0"
|
|
64
64
|
},
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div v-if="message.delivery" role="status">
|
|
3
|
-
<
|
|
4
|
-
<p v-if="message.delivery.error" class="text-body-small">{{ message.delivery.error }}</p>
|
|
2
|
+
<div v-if="message.delivery" class="d-flex flex-wrap align-center ga-1 text-caption text-error" role="status">
|
|
3
|
+
<span>{{ message.delivery.error || message.text }}</span>
|
|
5
4
|
<v-btn
|
|
6
|
-
variant="
|
|
7
|
-
|
|
5
|
+
variant="text"
|
|
6
|
+
color="error"
|
|
7
|
+
size="x-small"
|
|
8
|
+
aria-label="Retry delivery check"
|
|
8
9
|
:disabled="message.delivery.checking"
|
|
9
10
|
@click="$emit('check-delivery', message.delivery.messageId)"
|
|
10
11
|
>
|
|
11
|
-
{{ message.delivery.checking ? 'Checking
|
|
12
|
+
{{ message.delivery.checking ? 'Checking…' : 'Retry' }}
|
|
12
13
|
</v-btn>
|
|
13
14
|
</div>
|
|
14
15
|
<slot v-else>
|
|
@@ -139,8 +139,7 @@
|
|
|
139
139
|
class="assistant-transcript__message assistant-transcript__message--user"
|
|
140
140
|
:class="{
|
|
141
141
|
'assistant-transcript__message--pending': entry.turn.optimistic?.status === 'pending',
|
|
142
|
-
'assistant-transcript__message--failed': entry.turn.optimistic?.status === 'failed'
|
|
143
|
-
'assistant-transcript__message--unconfirmed': Boolean(entry.delivery)
|
|
142
|
+
'assistant-transcript__message--failed': entry.turn.optimistic?.status === 'failed'
|
|
144
143
|
}"
|
|
145
144
|
>
|
|
146
145
|
<div
|
|
@@ -176,39 +175,38 @@
|
|
|
176
175
|
<time v-if="entry.message.displayAt">{{ entry.message.displayAt }}</time>
|
|
177
176
|
<span v-if="entry.turn.optimistic?.status === 'pending'" role="status">Pending</span>
|
|
178
177
|
</div>
|
|
179
|
-
<div v-if="entry.delivery" class="assistant-transcript__unconfirmed-delivery">
|
|
180
|
-
<AssistantConversationStatus
|
|
181
|
-
:message="{ ...entry.delivery, text: 'Delivery unconfirmed' }"
|
|
182
|
-
@check-delivery="emit('check-delivery', $event)"
|
|
183
|
-
/>
|
|
184
|
-
</div>
|
|
185
178
|
<slot name="user-message-actions" :message="entry.message" :turn="entry.turn" />
|
|
186
179
|
<div
|
|
187
|
-
v-if="entry.turn.optimistic?.status === 'failed'"
|
|
180
|
+
v-if="entry.delivery || entry.turn.optimistic?.status === 'failed'"
|
|
188
181
|
class="assistant-transcript__optimistic-failure"
|
|
189
182
|
>
|
|
190
|
-
<span role="status">Failed: {
|
|
183
|
+
<span role="status">{{ entry.delivery ? entry.delivery.delivery.error || 'Delivery unconfirmed.' : `Failed: ${entry.turn.optimistic.error || 'Message could not be sent.'}` }}</span>
|
|
191
184
|
<div v-if="!entry.turn.preview" class="assistant-transcript__optimistic-actions">
|
|
192
185
|
<v-btn
|
|
193
186
|
class="assistant-transcript__delivery-action"
|
|
194
|
-
color="
|
|
187
|
+
color="error"
|
|
195
188
|
size="x-small"
|
|
196
189
|
type="button"
|
|
197
|
-
variant="
|
|
198
|
-
|
|
190
|
+
variant="text"
|
|
191
|
+
:disabled="entry.delivery?.delivery.checking"
|
|
192
|
+
:aria-label="entry.delivery ? 'Retry delivery check' : undefined"
|
|
193
|
+
:title="entry.delivery ? entry.delivery.delivery.error || 'Check delivery before resending.' : undefined"
|
|
194
|
+
@click="entry.delivery ? emit('check-delivery', entry.delivery.delivery.messageId) : emit('resend-turn', entry.turn.optimistic.id)"
|
|
199
195
|
>
|
|
200
|
-
Retry
|
|
196
|
+
{{ entry.delivery?.delivery.checking ? 'Checking…' : 'Retry' }}
|
|
201
197
|
</v-btn>
|
|
202
198
|
<v-btn
|
|
199
|
+
v-if="!entry.delivery || cancellableDelivery"
|
|
203
200
|
class="assistant-transcript__delivery-action"
|
|
204
201
|
size="x-small"
|
|
205
202
|
type="button"
|
|
206
203
|
variant="text"
|
|
207
|
-
@click="emit('cancel-turn', entry.turn.optimistic.id)"
|
|
204
|
+
@click="emit('cancel-turn', entry.delivery?.delivery.messageId || entry.turn.optimistic.id)"
|
|
208
205
|
>
|
|
209
206
|
Cancel
|
|
210
207
|
</v-btn>
|
|
211
208
|
<v-btn
|
|
209
|
+
v-if="!entry.delivery"
|
|
212
210
|
class="assistant-transcript__delivery-action"
|
|
213
211
|
size="x-small"
|
|
214
212
|
type="button"
|
|
@@ -340,6 +338,7 @@ function previewForTurn(turn) {
|
|
|
340
338
|
}
|
|
341
339
|
|
|
342
340
|
const props = defineProps({
|
|
341
|
+
cancellableDelivery: { type: Boolean, default: false },
|
|
343
342
|
systemLabel: { type: String, default: "Status" },
|
|
344
343
|
previewMessage: { type: Object, default: null },
|
|
345
344
|
working: { type: Boolean, default: undefined },
|
|
@@ -588,7 +587,7 @@ function conversationMessageKey(message = {}, index = 0) {
|
|
|
588
587
|
// Storage rows are not visual boundaries. Only an intervening message ends a
|
|
589
588
|
// reasoning group, including when history is prepended or execution continues.
|
|
590
589
|
const displayEntries = computed(() => {
|
|
591
|
-
const
|
|
590
|
+
const candidates = [];
|
|
592
591
|
for (const [index, turn] of props.turns.entries()) {
|
|
593
592
|
const turnId = String(turn.turnId || index + 1);
|
|
594
593
|
// A delivery notice belongs to its exact user message, not the previous reply.
|
|
@@ -608,18 +607,21 @@ const displayEntries = computed(() => {
|
|
|
608
607
|
previewUserMessage: role === "user"
|
|
609
608
|
}, key);
|
|
610
609
|
if (!displayed) continue;
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
previous.messages.push({ ...displayed, key });
|
|
614
|
-
} else {
|
|
615
|
-
entries.push({
|
|
616
|
-
key, role, turn: { ...turn, turnId }, message: displayed,
|
|
617
|
-
delivery: role === "user" ? userDelivery : null,
|
|
618
|
-
...(role === "thinking" ? { messages: [{ ...displayed, key }] } : {})
|
|
619
|
-
});
|
|
620
|
-
}
|
|
610
|
+
candidates.push({ key, role, turn: { ...turn, turnId }, message: displayed,
|
|
611
|
+
delivery: role === "user" ? userDelivery : null });
|
|
621
612
|
}
|
|
622
613
|
}
|
|
614
|
+
// Native streaming turns and saved authored turns can arrive independently.
|
|
615
|
+
// Only dated entries participate; undated notices retain their original slot.
|
|
616
|
+
const dated = candidates.filter(entry => Number.isFinite(Date.parse(entry.message.at)))
|
|
617
|
+
.sort((a, b) => Date.parse(a.message.at) - Date.parse(b.message.at))[Symbol.iterator]();
|
|
618
|
+
const entries = [];
|
|
619
|
+
for (const candidate of candidates) {
|
|
620
|
+
const entry = Number.isFinite(Date.parse(candidate.message.at)) ? dated.next().value : candidate;
|
|
621
|
+
const previous = entries.at(-1);
|
|
622
|
+
if (entry.role === "thinking" && previous?.role === "thinking") previous.messages.push({ ...entry.message, key: entry.key });
|
|
623
|
+
else entries.push({ ...entry, ...(entry.role === "thinking" ? { messages: [{ ...entry.message, key: entry.key }] } : {}) });
|
|
624
|
+
}
|
|
623
625
|
// Anchor a progress group to the following message, or to the transcript end.
|
|
624
626
|
// Appending progress or prepending history then preserves its expansion state.
|
|
625
627
|
for (const [index, entry] of entries.entries()) {
|
|
@@ -1442,26 +1444,6 @@ watch(() => props.visible, (visible) => {
|
|
|
1442
1444
|
margin-top: -0.15rem;
|
|
1443
1445
|
}
|
|
1444
1446
|
|
|
1445
|
-
.assistant-transcript__message--user.assistant-transcript__message--unconfirmed {
|
|
1446
|
-
background: rgba(var(--v-theme-warning), 0.08);
|
|
1447
|
-
border: 1px solid rgba(var(--v-theme-warning), 0.6);
|
|
1448
|
-
}
|
|
1449
|
-
|
|
1450
|
-
.assistant-transcript__unconfirmed-delivery {
|
|
1451
|
-
border-top: 1px solid rgba(var(--v-theme-warning), 0.4);
|
|
1452
|
-
font-size: 0.875rem;
|
|
1453
|
-
padding-top: 0.65rem;
|
|
1454
|
-
}
|
|
1455
|
-
|
|
1456
|
-
.assistant-transcript__unconfirmed-delivery :deep(p:first-child) {
|
|
1457
|
-
font-weight: 700;
|
|
1458
|
-
margin-bottom: 0.4rem;
|
|
1459
|
-
}
|
|
1460
|
-
|
|
1461
|
-
.assistant-transcript__unconfirmed-delivery :deep(p) {
|
|
1462
|
-
overflow-wrap: anywhere;
|
|
1463
|
-
}
|
|
1464
|
-
|
|
1465
1447
|
.assistant-transcript__optimistic-failure {
|
|
1466
1448
|
align-items: center;
|
|
1467
1449
|
color: rgba(var(--v-theme-error), 0.92);
|
|
@@ -90,3 +90,32 @@ test("the shared element renders delayed sends and supplies retry, edit and canc
|
|
|
90
90
|
await stopProcess(vite);
|
|
91
91
|
}
|
|
92
92
|
});
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
test("chronological messages and application Cancel work on phone and desktop", {
|
|
96
|
+
skip: process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION !== "1", timeout: 90_000
|
|
97
|
+
}, async () => {
|
|
98
|
+
const vite = await startViteFixture({ fixtureRoot: fileURLToPath(new URL("../fixtures/responsive-assistant", import.meta.url)) });
|
|
99
|
+
const browser = await chromium.launch(createChromiumLaunchOptions());
|
|
100
|
+
try {
|
|
101
|
+
for (const width of [390, 1365]) {
|
|
102
|
+
const page = await browser.newPage({ viewport: { width, height: 900 } });
|
|
103
|
+
const errors = [];
|
|
104
|
+
page.on("pageerror", error => errors.push(error.message));
|
|
105
|
+
await page.goto(`${vite.baseURL}/?delivery=1&recovery=1`);
|
|
106
|
+
const rows = page.locator(".assistant-transcript__turn");
|
|
107
|
+
await expect(rows).toHaveCount(5);
|
|
108
|
+
for (const [index, text] of ["Question one", "Reply one", "Question two", "Reply two", "Discard failed attempt"].entries()) {
|
|
109
|
+
await expect(rows.nth(index)).toContainText(text);
|
|
110
|
+
}
|
|
111
|
+
await expect(page.getByRole("button", { name: "Retry delivery check" })).toBeVisible();
|
|
112
|
+
await page.screenshot({ path: `/tmp/sas-chat-recovery-${width}.png` });
|
|
113
|
+
await page.getByRole("button", { name: "Cancel", exact: true }).click();
|
|
114
|
+
await expect(rows).toHaveCount(4);
|
|
115
|
+
await expect(page.getByText("Discard failed attempt", { exact: true })).toHaveCount(0);
|
|
116
|
+
assert.deepEqual(errors, []);
|
|
117
|
+
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
|
|
118
|
+
await page.close();
|
|
119
|
+
}
|
|
120
|
+
} finally { await browser.close(); await stopProcess(vite); }
|
|
121
|
+
});
|