@jskit-ai/assistant-runtime 0.2.1 → 0.2.3
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/README.md +2 -1
- package/fixtures/canonical-conversation/vite.config.mjs +22 -2
- package/locales/en.json +2 -1
- package/package.json +8 -8
- package/src/client/composables/useAssistantConversation.js +67 -17
- package/src/client/support/subscribeAssistantConversation.js +18 -5
- package/src/server/actionIds.js +1 -0
- package/src/server/actions.js +15 -0
- package/src/server/registerRoutes.js +19 -0
- package/src/server/services/chatService.js +108 -74
- package/test/actionDefinitions.test.js +2 -1
- package/test/assistantConversationBinding.browser.test.js +240 -13
- package/test/assistantConversationOperations.test.js +66 -9
- package/test/assistantConversationSubscriptions.test.js +155 -25
- package/test/chatServiceLifecycle.test.js +48 -2
- package/test/featureRuntime.test.js +3 -3
package/README.md
CHANGED
|
@@ -34,7 +34,8 @@ modes) and `packages/agent-docs/site/guide/app-extras/assistant-conversation.md`
|
|
|
34
34
|
With the existing realtime diagnostic option (`JSKIT_REALTIME_DEBUG=1`, or
|
|
35
35
|
`config.realtime.debug=true`), conversation subscriptions emit one `pending`
|
|
36
36
|
warning if admission/snapshot reading has not returned within the client's
|
|
37
|
-
10-second
|
|
37
|
+
10-second budget, after which the client shows a loading notice and keeps waiting
|
|
38
|
+
(it gives up only after 120 seconds). The warning names the current stage and the
|
|
38
39
|
server-generated epoch; a conversation identity is included only after its
|
|
39
40
|
successful authorized open. Observer release also records the current stage and
|
|
40
41
|
reason. While diagnostics are enabled, every stage transition is recorded at
|
|
@@ -9,7 +9,7 @@ export default defineConfig({
|
|
|
9
9
|
plugins: [vue(), {
|
|
10
10
|
name: "controlled-canonical-assistant-api",
|
|
11
11
|
configureServer(server) {
|
|
12
|
-
let states, requests, held, mode, denied;
|
|
12
|
+
let states, requests, held, mode, denied, holdCancels, heldCancels;
|
|
13
13
|
const pageLimits = new Map();
|
|
14
14
|
// Exercise the existing page reader over this fixture's controlled rows.
|
|
15
15
|
const transcript = createConversationTranscript({ storage: {
|
|
@@ -27,6 +27,7 @@ export default defineConfig({
|
|
|
27
27
|
streaming: { revision: 0, messages: [] }, capabilities: { steering: false, goals: false, attachments: true } }];
|
|
28
28
|
}));
|
|
29
29
|
requests = []; held = []; mode = "accepted"; denied = false; pageLimits.clear();
|
|
30
|
+
holdCancels = false; heldCancels = [];
|
|
30
31
|
}
|
|
31
32
|
reset();
|
|
32
33
|
function accept(state, input) {
|
|
@@ -60,6 +61,8 @@ export default defineConfig({
|
|
|
60
61
|
if (url.pathname === "/fixture/reset") { reset(); return json({ ok: true }); }
|
|
61
62
|
if (url.pathname === "/fixture/state") return json({ requests, states: Object.fromEntries(states), held: held.length });
|
|
62
63
|
if (url.pathname === "/fixture/mode") { mode = input.mode; return json({ ok: true }); }
|
|
64
|
+
if (url.pathname === "/fixture/hold-cancels") { holdCancels = input.hold; return json({ ok: true }); }
|
|
65
|
+
if (url.pathname === "/fixture/answer-cancels") { for (const agree of heldCancels.splice(0)) agree(); return json({ ok: true }); }
|
|
63
66
|
if (url.pathname === "/fixture/deny") { denied = input.denied; return json({ ok: true }); }
|
|
64
67
|
if (url.pathname === "/fixture/error") { states.get(input.id).error = input.error; return json({ ok: true }); }
|
|
65
68
|
if (url.pathname === "/fixture/interim-reply") { states.get(input.id).interimReply = input.reply; return json({ ok: true }); }
|
|
@@ -168,10 +171,27 @@ export default defineConfig({
|
|
|
168
171
|
return json(accept(state, input), 202);
|
|
169
172
|
}
|
|
170
173
|
if (match[2] === "/cancel") { state.status = "ready"; return json({ stopped: true }); }
|
|
174
|
+
// The server always records a cancel and drops the unconfirmed request.
|
|
175
|
+
if (match[2].startsWith("/deliveries/") && match[2].endsWith("/cancel")) {
|
|
176
|
+
const messageId = decodeURIComponent(match[2].split("/")[2]);
|
|
177
|
+
const agree = () => {
|
|
178
|
+
state.cancelled = [...state.cancelled || [], messageId];
|
|
179
|
+
if (state.pendingRequest?.messageId === messageId) { state.pendingRequest = null; state.status = "ready"; }
|
|
180
|
+
json({ ok: true, cancelled: true, messageId });
|
|
181
|
+
};
|
|
182
|
+
if (holdCancels) heldCancels.push(agree);
|
|
183
|
+
else agree();
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
171
186
|
if (match[2].startsWith("/deliveries/") && match[2].endsWith("/inspect")) {
|
|
172
187
|
const messageId = decodeURIComponent(match[2].split("/")[2]);
|
|
188
|
+
if (state.cancelled?.includes(messageId)) return json({ status: "cancelled", messageId });
|
|
173
189
|
const receipt = state.conversationLog.find(turn => turn.user?.messageId === messageId);
|
|
174
|
-
if (!receipt && mode === "not-sent")
|
|
190
|
+
if (!receipt && mode === "not-sent") {
|
|
191
|
+
// Like the runtime, record the proven request as unattempted: reads stop reporting it unconfirmed.
|
|
192
|
+
if (state.pendingRequest?.messageId === messageId) Object.assign(state, { status: "ready", pendingRequest: null });
|
|
193
|
+
return json({ status: "not-sent", messageId, error: "This message was not sent. Review it before sending a new message." });
|
|
194
|
+
}
|
|
175
195
|
return json(receipt ? { status: "accepted", messageId, turnId: receipt.turnId } : { status: "unknown", messageId });
|
|
176
196
|
}
|
|
177
197
|
return json({ error: "Unknown fixture operation" }, 404);
|
package/locales/en.json
CHANGED
|
@@ -130,7 +130,8 @@
|
|
|
130
130
|
},
|
|
131
131
|
"errors": {
|
|
132
132
|
"assistant_history_inspection_required": "This conversation has ambiguous historical SQL rows. Inspect the reported rows offline before using the common runtime; no history was changed.",
|
|
133
|
-
"assistant_subscription_transport_failed": "Chat updates could not reconnect. Retrying automatically; Reload chat to try now."
|
|
133
|
+
"assistant_subscription_transport_failed": "Chat updates could not reconnect. Retrying automatically; Reload chat to try now.",
|
|
134
|
+
"assistant_subscription_waiting": "Chat is still loading. Waiting for the server; Reload chat to try now."
|
|
134
135
|
}
|
|
135
136
|
}
|
|
136
137
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-runtime",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test --test-concurrency=1"
|
|
@@ -13,19 +13,19 @@
|
|
|
13
13
|
"./locales/en.json": "./locales/en.json"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
|
-
"@jskit-ai/assistant-core": "0.2.
|
|
16
|
+
"@jskit-ai/assistant-core": "0.2.3",
|
|
17
17
|
"json-rest-schema": "^1.0.17"
|
|
18
18
|
},
|
|
19
19
|
"peerDependencies": {
|
|
20
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
21
|
-
"@jskit-ai/http-web": "0.2.
|
|
22
|
-
"@jskit-ai/kernel": "0.2.
|
|
23
|
-
"@jskit-ai/realtime": "0.2.
|
|
24
|
-
"@jskit-ai/shell-web": "0.2.
|
|
20
|
+
"@jskit-ai/http-runtime": "0.2.1",
|
|
21
|
+
"@jskit-ai/http-web": "0.2.2",
|
|
22
|
+
"@jskit-ai/kernel": "0.2.1",
|
|
23
|
+
"@jskit-ai/realtime": "0.2.1",
|
|
24
|
+
"@jskit-ai/shell-web": "0.2.1",
|
|
25
25
|
"@tanstack/vue-query": "^5.90.5",
|
|
26
26
|
"vue": "^3.5.13",
|
|
27
27
|
"vuetify": "^4.0.0",
|
|
28
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
28
|
+
"@jskit-ai/database-runtime": "0.1.250"
|
|
29
29
|
},
|
|
30
30
|
"description": "Capability-based assistant runtime with per-surface chat, transcripts, settings, and action tools.",
|
|
31
31
|
"jskit": {
|
|
@@ -42,7 +42,8 @@ function configureAssistantConversations(app, { actorKey, api = null, request, c
|
|
|
42
42
|
// occur after dispatch and must retain an uncertain receipt.
|
|
43
43
|
const SEND_REJECTIONS = new Set([
|
|
44
44
|
"ACTION_VALIDATION_FAILED", "ACTION_PERMISSION_DENIED", "ACTION_SURFACE_FORBIDDEN",
|
|
45
|
-
"conversation_invalid_message", "conversation_busy", "conversation_not_steerable", "conversation_invalid_goal", "conversation_goal_changed"
|
|
45
|
+
"conversation_invalid_message", "conversation_busy", "conversation_not_steerable", "conversation_invalid_goal", "conversation_goal_changed",
|
|
46
|
+
"conversation_message_cancelled"
|
|
46
47
|
]);
|
|
47
48
|
|
|
48
49
|
function isQuestionConfiguration(value) {
|
|
@@ -116,6 +117,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
116
117
|
const accessDenied = ref(false);
|
|
117
118
|
const delivery = createAssistantMessageDelivery();
|
|
118
119
|
const pendingMessages = new Map();
|
|
120
|
+
const deliveryChecks = new Map();
|
|
119
121
|
const olderPages = ref([]);
|
|
120
122
|
let historyRevision = 0;
|
|
121
123
|
let deniedHistoryRevision = 0;
|
|
@@ -158,10 +160,14 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
158
160
|
typeof message.payload?.message === "string") : [];
|
|
159
161
|
const restoredMessages = messages.filter(message => !delivery.find(message.id)).map(message => {
|
|
160
162
|
const status = ["failed", "accepted"].includes(message.status) ? message.status : "uncertain";
|
|
161
|
-
return { ...message, status, checking: false, error: message.error || (status === "accepted" ? "" :
|
|
163
|
+
return { ...message, status, checking: false, cancelling: false, error: message.error || (status === "accepted" ? "" :
|
|
162
164
|
t("jskit.assistant.runtime.delivery.unconfirmedBeforeClose")) };
|
|
163
165
|
});
|
|
164
166
|
delivery.state.messages = unmatchedOptimisticMessages(turns.value, [...delivery.state.messages, ...restoredMessages], { receiptsOnly: true });
|
|
167
|
+
// A cancel the server had not agreed to before this page closed is retried.
|
|
168
|
+
for (const message of messages) {
|
|
169
|
+
if (message.cancelling || message.cancelError) delivery.cancelFailed(message.id, message.cancelError);
|
|
170
|
+
}
|
|
165
171
|
} catch {
|
|
166
172
|
draft.value = "";
|
|
167
173
|
draftAttachments.value = [];
|
|
@@ -239,6 +245,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
239
245
|
accessDenied.value = false;
|
|
240
246
|
delivery.reconcile(state.turns);
|
|
241
247
|
if (state.status === "unconfirmed" && state.pendingRequest) delivery.restoreUncertain(state.pendingRequest, state.turns);
|
|
248
|
+
retryCancels();
|
|
242
249
|
if (state.segmentId !== goalSegment) {
|
|
243
250
|
// Keep the open goal menu while its first native identity arrives. The
|
|
244
251
|
// pending read disables commands until it supplies the new exact target.
|
|
@@ -374,6 +381,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
374
381
|
stopPlacement();
|
|
375
382
|
for (const pending of pendingMessages.values()) pending.controller.abort();
|
|
376
383
|
pendingMessages.clear();
|
|
384
|
+
for (const check of deliveryChecks.values()) check.abort();
|
|
377
385
|
goalReadController?.abort();
|
|
378
386
|
delivery.reset();
|
|
379
387
|
});
|
|
@@ -561,11 +569,32 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
561
569
|
if (current.value) subscription?.reload();
|
|
562
570
|
}
|
|
563
571
|
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
572
|
+
// Cancel means cancel, and the server decides. Cancel ends the message's
|
|
573
|
+
// unsent request and running delivery check, then asks the server, which
|
|
574
|
+
// always records the cancel. The message stays, shown as cancelling, until
|
|
575
|
+
// the server agrees; when the server cannot be reached it stays with Cancel
|
|
576
|
+
// again, and the next snapshot after reconnecting retries it.
|
|
577
|
+
async function cancelMessage(messageId) {
|
|
578
|
+
pendingMessages.get(messageId)?.controller.abort();
|
|
579
|
+
deliveryChecks.get(messageId)?.abort();
|
|
580
|
+
if (!available.value || !delivery.cancel(messageId)) return false;
|
|
581
|
+
try {
|
|
582
|
+
const result = await api.cancelConversationDelivery(identity.conversationId, messageId);
|
|
583
|
+
if (result?.ok === false) throw Object.assign(new Error(resultError(result)), { status: 409 });
|
|
584
|
+
if (!current.value) return false;
|
|
585
|
+
delivery.remove(messageId);
|
|
586
|
+
subscription?.reload();
|
|
587
|
+
return true;
|
|
588
|
+
} catch (failure) {
|
|
589
|
+
// The server's own refusal names itself; anything else did not reach it.
|
|
590
|
+
if (current.value) delivery.cancelFailed(messageId, Number.isInteger(failure?.status) && failure.status > 0 ? failureMessage(failure) : "");
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
function retryCancels() {
|
|
595
|
+
for (const message of delivery.state.messages) {
|
|
596
|
+
if (message.cancelError && !message.cancelling) void cancelMessage(message.id);
|
|
597
|
+
}
|
|
569
598
|
}
|
|
570
599
|
|
|
571
600
|
// The original history control keeps older pages separate from the live page.
|
|
@@ -653,14 +682,18 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
653
682
|
const view = goalView.value;
|
|
654
683
|
const target = view?.target;
|
|
655
684
|
const command = target?.capabilities?.goalCommands?.[action];
|
|
685
|
+
// Pause and Cancel never depend on chat delivery: each is pinned to the goal
|
|
686
|
+
// it stops and goes straight to the goal operation, even while a message is
|
|
687
|
+
// sending or its receipt is unknown. Set and Resume wait for those.
|
|
688
|
+
const stopping = action === "pause" || action === "cancel";
|
|
656
689
|
if (!available.value || view?.status !== "available" || goalLoadError.value || goalAction.value || !goalTargetCurrent.value ||
|
|
657
690
|
!["message", "control"].includes(command?.delivery) ||
|
|
658
|
-
delivery.state.messages.some(message => message.status === "uncertain")) return false;
|
|
691
|
+
!stopping && delivery.state.messages.some(message => message.status === "uncertain")) return false;
|
|
659
692
|
if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
|
|
660
693
|
goalError.value = t("jskit.assistant.runtime.goal.budgetsUnsupported");
|
|
661
694
|
return false;
|
|
662
695
|
}
|
|
663
|
-
const retry = delivery.find(messageId);
|
|
696
|
+
const retry = stopping ? null : delivery.find(messageId);
|
|
664
697
|
if (retry && retry.status !== "failed") return false;
|
|
665
698
|
const request = retry?.payload.goalRequest || { action, expectedSegmentId: target.segmentId,
|
|
666
699
|
expectedGoalId: view.goal?.id || null,
|
|
@@ -669,7 +702,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
669
702
|
goalAction.value = action;
|
|
670
703
|
goalError.value = "";
|
|
671
704
|
try {
|
|
672
|
-
if (retry || command.delivery === "message") {
|
|
705
|
+
if (retry || !stopping && command.delivery === "message") {
|
|
673
706
|
const result = await submitRequest(retry?.payload || {
|
|
674
707
|
message: action === "set" ? request.objective
|
|
675
708
|
: t(action === "resume" ? "jskit.assistant.runtime.goal.resumeMessage" : "jskit.assistant.runtime.goal.cancelMessage"),
|
|
@@ -683,7 +716,8 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
683
716
|
}
|
|
684
717
|
return result;
|
|
685
718
|
}
|
|
686
|
-
const result = await api.updateConversationGoal(identity.conversationId,
|
|
719
|
+
const result = await api.updateConversationGoal(identity.conversationId,
|
|
720
|
+
command.delivery === "message" ? { ...request, messageId } : request);
|
|
687
721
|
if (result?.ok === false) throw new Error(resultError(result) || t("jskit.assistant.runtime.goal.changeFailed"));
|
|
688
722
|
return result;
|
|
689
723
|
} catch (failure) {
|
|
@@ -719,6 +753,7 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
719
753
|
sampledAt: Date.parse(view.goal.updatedAt) } : null,
|
|
720
754
|
pending: Boolean(goalAction.value) || !goalAvailable || !available.value || delivery.state.sending ||
|
|
721
755
|
delivery.state.messages.some(message => message.status === "uncertain"),
|
|
756
|
+
stopPending: Boolean(goalAction.value) || !goalAvailable || !available.value,
|
|
722
757
|
error: goalError.value || goalLoadError.value || view?.error || "",
|
|
723
758
|
pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
|
|
724
759
|
cancelInterruptsTurn: commands.cancel?.interruptsTurn === true,
|
|
@@ -740,14 +775,18 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
740
775
|
if (!available.value) return false;
|
|
741
776
|
const message = delivery.find(messageId);
|
|
742
777
|
if (message?.checking) return false;
|
|
778
|
+
const check = new AbortController();
|
|
779
|
+
deliveryChecks.set(messageId, check);
|
|
743
780
|
if (message) message.checking = true;
|
|
744
781
|
try {
|
|
745
|
-
const receipt = await api.inspectConversationDelivery(identity.conversationId, messageId);
|
|
746
|
-
if (!current.value) return false;
|
|
782
|
+
const receipt = await api.inspectConversationDelivery(identity.conversationId, messageId, { signal: check.signal });
|
|
783
|
+
if (!current.value || check.signal.aborted) return false;
|
|
747
784
|
if (receipt.status === "accepted") {
|
|
748
785
|
delivery.accept(messageId);
|
|
749
786
|
if (receipt.messageId === messageId && message?.payload.goalRequest) goalError.value = "";
|
|
750
787
|
}
|
|
788
|
+
// The server recorded this message as cancelled, perhaps from another tab.
|
|
789
|
+
else if (receipt.status === "cancelled" && receipt.messageId === messageId) delivery.remove(messageId);
|
|
751
790
|
else if (receipt.status === "not-sent" && receipt.messageId === messageId) delivery.rejectUnsent(messageId, resultError(receipt));
|
|
752
791
|
else if (receipt.status === "unknown" && message?.status === "uncertain") {
|
|
753
792
|
message.error = t("jskit.assistant.runtime.delivery.stillUnknown");
|
|
@@ -755,11 +794,15 @@ function createConversation(identity, { api, socket, actorKey, placement, reader
|
|
|
755
794
|
subscription?.reload();
|
|
756
795
|
return receipt;
|
|
757
796
|
} catch (failure) {
|
|
797
|
+
if (check.signal.aborted) return false;
|
|
758
798
|
if (current.value && message?.status === "uncertain") message.error = failureMessage(failure, t("jskit.assistant.runtime.delivery.checkFailed"));
|
|
759
799
|
receiveError(failure);
|
|
760
800
|
return false;
|
|
761
801
|
}
|
|
762
|
-
finally {
|
|
802
|
+
finally {
|
|
803
|
+
if (deliveryChecks.get(messageId) === check) deliveryChecks.delete(messageId);
|
|
804
|
+
if (message) message.checking = false;
|
|
805
|
+
}
|
|
763
806
|
}
|
|
764
807
|
|
|
765
808
|
const runtime = { identity, current, active, editable, available, canSubmit, steerable, queueWhileSending: queueing,
|
|
@@ -1009,10 +1052,15 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1009
1052
|
} else current.draft.value = "";
|
|
1010
1053
|
}
|
|
1011
1054
|
onSubmit?.();
|
|
1055
|
+
const dispatch = () => prepare ? current.submitPrepared(captured, { messageId, onAccepted: acknowledge, prepare })
|
|
1056
|
+
: current.send(captured, { messageId, onAccepted: acknowledge });
|
|
1012
1057
|
// A restored uncertain receipt may have no retained acceptance callback.
|
|
1013
|
-
// Inspection can acknowledge it
|
|
1058
|
+
// Inspection can acknowledge it. Only the server's proof that the message
|
|
1059
|
+
// never reached the AI (which marks it failed) lets Retry send it again.
|
|
1014
1060
|
if (retry?.status === "uncertain") return current.inspectDelivery(messageId).then(receipt => {
|
|
1015
1061
|
if (!current.current.value || !isCurrent()) return false;
|
|
1062
|
+
if (receipt?.status === "not-sent" && receipt.messageId === messageId &&
|
|
1063
|
+
current.delivery.find(messageId)?.status === "failed") return dispatch();
|
|
1016
1064
|
if (receipt?.status !== "accepted") {
|
|
1017
1065
|
const error = current.error.value || t("jskit.assistant.runtime.delivery.notConfirmed");
|
|
1018
1066
|
const pending = current.delivery.find(messageId);
|
|
@@ -1022,8 +1070,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1022
1070
|
acknowledge();
|
|
1023
1071
|
return { ...receipt, ok: true };
|
|
1024
1072
|
});
|
|
1025
|
-
return
|
|
1026
|
-
: current.send(captured, { messageId, onAccepted: acknowledge });
|
|
1073
|
+
return dispatch();
|
|
1027
1074
|
}
|
|
1028
1075
|
const adapter = computed(() => {
|
|
1029
1076
|
const current = runtime.value;
|
|
@@ -1050,6 +1097,8 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1050
1097
|
hasMoreBefore: current?.hasMoreBefore.value || false,
|
|
1051
1098
|
loadingMore: current?.loadingMore.value || false, loadMoreError: current?.loadMoreError.value || "",
|
|
1052
1099
|
loading: current?.loading.value || false, working: !current?.error.value && state?.status === "working" && !current.delivery.state.sending,
|
|
1100
|
+
// The server cancels any unconfirmed message, so its Cancel is offered.
|
|
1101
|
+
cancellableDelivery: true,
|
|
1053
1102
|
assistantLabel: display.assistantLabel || t("jskit.assistant.runtime.conversation.assistantLabel"),
|
|
1054
1103
|
welcomeMessage: display.welcomeMessage ?? t("jskit.assistant.runtime.conversation.welcomeMessage"),
|
|
1055
1104
|
systemLabel: display.systemLabel, variant: display.variant, visible: display.visible, retainWhenHidden: display.retainWhenHidden,
|
|
@@ -1071,6 +1120,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1071
1120
|
else if (canDraftWhileLoading.value) initialDraft.value = value;
|
|
1072
1121
|
}, submit,
|
|
1073
1122
|
resend: messageId => submit({ retryMessageId: messageId }), stop: () => current?.cancel(),
|
|
1123
|
+
cancel: messageId => current?.cancelMessage(messageId),
|
|
1074
1124
|
checkDelivery: messageId => current?.inspectDelivery(messageId), loadMore: input => current?.loadMore(input), reload: () => current?.reload() }
|
|
1075
1125
|
};
|
|
1076
1126
|
});
|
|
@@ -16,6 +16,12 @@ function patchLoadedHistoryPages(pages = [], patch) {
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
const SNAPSHOT_EVENTS = new Set(["accepted", "settled", "configuration", "phase", "error", "replaced", "goal"]);
|
|
19
|
+
// A connected server may need longer than the normal budget to answer (an
|
|
20
|
+
// overloaded host took 15 s). Abandoning it then discards the server's work and
|
|
21
|
+
// repeats it, so a slow answer only shows a loading notice; it is treated as
|
|
22
|
+
// lost, unsubscribed and retried only after the much longer cap.
|
|
23
|
+
const SUBSCRIPTION_NOTICE_MS = 10_000;
|
|
24
|
+
const SUBSCRIPTION_LOST_MS = 120_000;
|
|
19
25
|
|
|
20
26
|
function subscribeAssistantConversation({
|
|
21
27
|
socket, conversationId, targetSurfaceId, hostSurfaceId, workspaceSlug,
|
|
@@ -44,6 +50,7 @@ function subscribeAssistantConversation({
|
|
|
44
50
|
// otherwise only the next connect would recover the chat.
|
|
45
51
|
let retryTimer = null;
|
|
46
52
|
let retryDelay = 1_000;
|
|
53
|
+
let waitingTimer = null;
|
|
47
54
|
const completed = new Map();
|
|
48
55
|
const settledTurns = new Set();
|
|
49
56
|
const pendingReads = new Set();
|
|
@@ -187,12 +194,14 @@ function subscribeAssistantConversation({
|
|
|
187
194
|
if (epoch === null) pending.push(payload);
|
|
188
195
|
else deliver(payload);
|
|
189
196
|
}
|
|
190
|
-
function
|
|
197
|
+
function clearTimers() {
|
|
191
198
|
clearTimeout(retryTimer);
|
|
192
199
|
retryTimer = null;
|
|
200
|
+
clearTimeout(waitingTimer);
|
|
201
|
+
waitingTimer = null;
|
|
193
202
|
}
|
|
194
203
|
function subscribe({ preserveHistory = false } = {}) {
|
|
195
|
-
|
|
204
|
+
clearTimers();
|
|
196
205
|
if (disposed || !socket.connected) return;
|
|
197
206
|
const attempt = ++generation;
|
|
198
207
|
epoch = null;
|
|
@@ -205,8 +214,12 @@ function subscribeAssistantConversation({
|
|
|
205
214
|
settledTurns.clear();
|
|
206
215
|
reloadInFlight = null;
|
|
207
216
|
reloadQueued = false;
|
|
208
|
-
|
|
217
|
+
waitingTimer = setTimeout(() => onError(Object.assign(
|
|
218
|
+
new Error("Chat is still loading. Waiting for the server; Reload chat to try now."),
|
|
219
|
+
{ code: "assistant_subscription_waiting" })), SUBSCRIPTION_NOTICE_MS);
|
|
220
|
+
socket.timeout(SUBSCRIPTION_LOST_MS).emit(ASSISTANT_CONVERSATION_SUBSCRIBE, input, (error, response) => {
|
|
209
221
|
if (disposed || attempt !== generation) return;
|
|
222
|
+
clearTimers();
|
|
210
223
|
if (error || response?.ok !== true) {
|
|
211
224
|
listening = false;
|
|
212
225
|
pending = [];
|
|
@@ -232,7 +245,7 @@ function subscribeAssistantConversation({
|
|
|
232
245
|
});
|
|
233
246
|
}
|
|
234
247
|
function disconnected() {
|
|
235
|
-
|
|
248
|
+
clearTimers();
|
|
236
249
|
generation += 1;
|
|
237
250
|
epoch = null;
|
|
238
251
|
pending = [];
|
|
@@ -248,7 +261,7 @@ function subscribeAssistantConversation({
|
|
|
248
261
|
function release() {
|
|
249
262
|
if (disposed) return;
|
|
250
263
|
disposed = true;
|
|
251
|
-
|
|
264
|
+
clearTimers();
|
|
252
265
|
generation += 1;
|
|
253
266
|
pending = [];
|
|
254
267
|
completed.clear();
|
package/src/server/actionIds.js
CHANGED
|
@@ -5,6 +5,7 @@ const actionIds = Object.freeze({
|
|
|
5
5
|
conversationCancel: "assistant.conversation.cancel",
|
|
6
6
|
conversationSubscribe: "assistant.conversation.subscribe",
|
|
7
7
|
conversationInspectDelivery: "assistant.conversation.delivery.inspect",
|
|
8
|
+
conversationCancelDelivery: "assistant.conversation.delivery.cancel",
|
|
8
9
|
conversationGoalRead: "assistant.conversation.goal.read",
|
|
9
10
|
conversationGoalUpdate: "assistant.conversation.goal.update",
|
|
10
11
|
conversationConfigure: "assistant.conversation.configure",
|
package/src/server/actions.js
CHANGED
|
@@ -225,6 +225,21 @@ function createConversationActions({ conversationRuntime, conversationDataSchema
|
|
|
225
225
|
return conversation.inspectDelivery({ messageId: input.messageId });
|
|
226
226
|
}
|
|
227
227
|
},
|
|
228
|
+
{
|
|
229
|
+
id: actionIds.conversationCancelDelivery,
|
|
230
|
+
version: 1,
|
|
231
|
+
kind: "command",
|
|
232
|
+
channels: ["api", "internal"],
|
|
233
|
+
surfaces: runtimeSurfaces,
|
|
234
|
+
permission: { require: "authenticated" },
|
|
235
|
+
input: conversationInspectInputValidator,
|
|
236
|
+
output: null,
|
|
237
|
+
idempotency: "none",
|
|
238
|
+
async execute(input, context) {
|
|
239
|
+
const conversation = await openConversation(input, context);
|
|
240
|
+
return conversation.cancelDelivery({ messageId: input.messageId });
|
|
241
|
+
}
|
|
242
|
+
},
|
|
228
243
|
{
|
|
229
244
|
id: actionIds.conversationGoalRead,
|
|
230
245
|
version: 1,
|
|
@@ -385,6 +385,25 @@ function registerConversationRoutes(
|
|
|
385
385
|
reply.code(200).send(response);
|
|
386
386
|
});
|
|
387
387
|
|
|
388
|
+
router.register("POST", `${routeBase}/deliveries/:messageId/cancel`, {
|
|
389
|
+
auth: "required", visibility,
|
|
390
|
+
params: composeSchemaDefinitions([params, assistantConversationMessageIdInputValidator], {
|
|
391
|
+
mode: "patch", context: "assistant-runtime conversation delivery cancel route params"
|
|
392
|
+
}),
|
|
393
|
+
meta: { tags: ["assistant"], summary: "Cancel one unsent or unconfirmed assistant message." }
|
|
394
|
+
}, async function assistantConversationCancelDeliveryRoute(request, reply) {
|
|
395
|
+
const routeState = resolveRouteRequestState(request, {
|
|
396
|
+
resolveCurrentAppConfig, requiresWorkspace, workspaceScopeSupport
|
|
397
|
+
});
|
|
398
|
+
const response = await request.executeAction({
|
|
399
|
+
actionId: actionIds.conversationCancelDelivery,
|
|
400
|
+
surface: routeState.hostSurfaceId,
|
|
401
|
+
input: { ...routeState.actionInput, conversationId: request.input.params.conversationId,
|
|
402
|
+
messageId: request.input.params.messageId }
|
|
403
|
+
});
|
|
404
|
+
reply.code(200).send(response);
|
|
405
|
+
});
|
|
406
|
+
|
|
388
407
|
for (const method of ["GET", "POST"]) {
|
|
389
408
|
router.register(method, `${routeBase}/goal`, {
|
|
390
409
|
auth: "required", visibility, params,
|