@jskit-ai/assistant-runtime 0.1.192 → 0.1.194
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/fixtures/canonical-conversation/App.vue +188 -0
- package/fixtures/canonical-conversation/DraftConversation.vue +32 -0
- package/fixtures/canonical-conversation/index.html +1 -0
- package/fixtures/canonical-conversation/main.js +61 -0
- package/fixtures/canonical-conversation/socket.js +50 -0
- package/fixtures/canonical-conversation/vite.config.mjs +178 -0
- package/fixtures/conversation/vite.config.mjs +12 -2
- package/package.json +23 -10
- package/src/client/components/AssistantConversationClientElement.vue +57 -0
- package/src/client/composables/useAssistantConversation.js +874 -0
- package/src/client/index.js +2 -0
- package/src/client/support/assistantRuntimeState.js +1 -1
- package/src/client/support/subscribeAssistantConversation.js +205 -0
- package/src/server/AssistantProvider.js +82 -106
- package/src/server/actionIds.js +10 -0
- package/src/server/actions.js +287 -79
- package/src/server/createAssistantRuntime.js +138 -0
- package/src/server/createAssistantSettingsActions.js +89 -0
- package/src/server/index.js +6 -0
- package/src/server/inputSchemas.js +90 -0
- package/src/server/registerConversationSubscriptions.js +163 -0
- package/src/server/registerFastifyConversations.js +76 -0
- package/src/server/registerRoutes.js +220 -198
- package/src/server/registerSettingsRoutes.js +113 -0
- package/src/server/repositories/conversationsRepository.js +12 -5
- package/src/server/repositories/messagesRepository.js +12 -0
- package/src/server/services/chatService.js +88 -694
- package/src/server/services/transcriptService.js +56 -52
- package/src/server/sqlConversationStorage.js +192 -0
- package/src/server/support/assistantRouteContext.js +91 -0
- package/src/server/support/createSurfaceAwareToolCatalog.js +6 -2
- package/src/shared/assistantSurfaces.js +14 -0
- package/src/shared/conversationRealtime.js +9 -0
- package/src/shared/index.js +1 -0
- package/test/actionDefinitions.test.js +44 -0
- package/test/aiConnections.test.js +1 -1
- package/test/assistantConversationBinding.browser.test.js +1868 -0
- package/test/assistantConversationOperations.test.js +459 -0
- package/test/assistantConversationSubscriptions.test.js +692 -0
- package/test/assistantIntegrationBoundary.test.js +103 -0
- package/test/assistantRuntimeComposition.test.js +68 -0
- package/test/assistantSurfacePattern.test.js +1 -1
- package/test/assistantSurfaces.test.js +23 -0
- package/test/chatServiceLifecycle.test.js +12 -1
- package/test/featureRuntime.test.js +238 -0
- package/test/registerFastifyConversations.test.js +169 -0
- package/test/sqlConversationStorage.test.js +205 -0
- package/test/support/memoryAssistantRows.js +51 -0
- package/test/turnRequests.test.js +11 -2
- package/src/shared/assistantResponseText.js +0 -29
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
import { computed, effectScope, getCurrentInstance, getCurrentScope, inject, onScopeDispose, proxyRefs, ref, shallowRef, toValue, watch } from "vue";
|
|
2
|
+
import { getClientAppConfig } from "@jskit-ai/kernel/client";
|
|
3
|
+
import { createAssistantMessageDelivery, retainAssistantConversation, unmatchedOptimisticMessages } from "@jskit-ai/assistant-core/client/conversation-delivery";
|
|
4
|
+
import { createAssistantTextSubmission } from "@jskit-ai/assistant-core/client/conversation";
|
|
5
|
+
import { useAssistantQuestions } from "@jskit-ai/assistant-core/client/conversation-questions";
|
|
6
|
+
import { assistantHttpClient, createAssistantApi } from "@jskit-ai/assistant-core/client";
|
|
7
|
+
import { buildAssistantApiPath } from "@jskit-ai/assistant-core/shared";
|
|
8
|
+
import {
|
|
9
|
+
conversationTurnsFromMessages, latestAssistantMessageAwaitingUserReply, mergeConversationLogPages,
|
|
10
|
+
normalizeConversationLogPage, normalizeConversationLogPagination
|
|
11
|
+
} from "@jskit-ai/assistant-core/shared/conversation";
|
|
12
|
+
import { useRealtimeSocket } from "@jskit-ai/realtime/client/composables/useRealtimeEvent";
|
|
13
|
+
import { useSurfaceRouteContext } from "@jskit-ai/shell-web/client/navigation/useSurfaceRouteContext";
|
|
14
|
+
import { resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
|
|
15
|
+
import { useWorkspaceWebScopeSupport } from "../support/workspaceScopeSupport.js";
|
|
16
|
+
import { subscribeAssistantConversation } from "../support/subscribeAssistantConversation.js";
|
|
17
|
+
|
|
18
|
+
const text = value => String(toValue(value) ?? "").trim();
|
|
19
|
+
// Keep the injection key stable across optimized package and raw Vue imports.
|
|
20
|
+
const conversationDefaultsKey = Symbol.for("jskit.assistant-runtime.conversation.defaults");
|
|
21
|
+
|
|
22
|
+
/** Configure this Vue application's existing conversation binding before mounting. */
|
|
23
|
+
function configureAssistantConversations(app, { actorKey, api = null, request, clearDraftOn = "dispatch" } = {}) {
|
|
24
|
+
if (typeof app?.provide !== "function") throw new TypeError("Conversation defaults require a Vue application.");
|
|
25
|
+
if (request !== undefined && typeof request !== "function") throw new TypeError("Conversation request must be a function.");
|
|
26
|
+
if (!["dispatch", "accepted"].includes(clearDraftOn)) throw new TypeError("clearDraftOn must be dispatch or accepted.");
|
|
27
|
+
app.provide(conversationDefaultsKey, Object.freeze({ actorKey, api, request, clearDraftOn }));
|
|
28
|
+
}
|
|
29
|
+
// These errors are raised before send admission by the action pipeline or
|
|
30
|
+
// admitSend. Provider, storage, attachment-resolution and transport errors can
|
|
31
|
+
// occur after dispatch and must retain an uncertain receipt.
|
|
32
|
+
const SEND_REJECTIONS = new Set([
|
|
33
|
+
"ACTION_VALIDATION_FAILED", "ACTION_PERMISSION_DENIED", "ACTION_SURFACE_FORBIDDEN",
|
|
34
|
+
"conversation_invalid_message", "conversation_busy", "conversation_invalid_goal", "conversation_goal_changed"
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
function isQuestionConfiguration(value) {
|
|
38
|
+
return value === true || Boolean(value && typeof value === "object" &&
|
|
39
|
+
!["questions", "choices", "capture"].some(key => key in value));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function draftAfterAcceptedSubmission(currentDraft = "", submittedDraft = "") {
|
|
43
|
+
const current = String(currentDraft || "");
|
|
44
|
+
const submitted = String(submittedDraft || "");
|
|
45
|
+
if (current === submitted) return "";
|
|
46
|
+
return submitted && current.startsWith(submitted) ? current.slice(submitted.length) : current;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function createConversation(identity, { api, socket, actorKey, placement, readers, queueWhileSending, draftStorage, application, goalReadEnabled }) {
|
|
50
|
+
const disposed = ref(false);
|
|
51
|
+
const placementRevision = ref(0);
|
|
52
|
+
const stopPlacement = placement.subscribe(() => { placementRevision.value += 1; });
|
|
53
|
+
const current = computed(() => {
|
|
54
|
+
placementRevision.value;
|
|
55
|
+
const actor = toValue(actorKey);
|
|
56
|
+
return !disposed.value && (actor === undefined ? String(placement.getContext()?.user?.id || "") : text(actor)) === identity.actorKey;
|
|
57
|
+
});
|
|
58
|
+
const active = computed(() => current.value && [...readers.values()].some(reader => toValue(reader.active) !== false));
|
|
59
|
+
const snapshot = shallowRef(null);
|
|
60
|
+
const draft = ref("");
|
|
61
|
+
const draftMessageId = ref("");
|
|
62
|
+
const draftAttachments = ref([]);
|
|
63
|
+
const draftRetry = ref(null);
|
|
64
|
+
const draftRetryMatches = computed(() => {
|
|
65
|
+
const retry = draftRetry.value;
|
|
66
|
+
const current = String(draft.value || "");
|
|
67
|
+
const submitted = String(retry?.draftSnapshot || "");
|
|
68
|
+
return Boolean(retry && submitted && (current === submitted || current.startsWith(submitted)));
|
|
69
|
+
});
|
|
70
|
+
let draftSubmissionSequence = 0;
|
|
71
|
+
const error = ref("");
|
|
72
|
+
const loading = ref(true);
|
|
73
|
+
const stopping = ref(false);
|
|
74
|
+
const goalPending = ref(false);
|
|
75
|
+
const goalLoading = ref(false);
|
|
76
|
+
const goalError = ref("");
|
|
77
|
+
const goalView = shallowRef(null);
|
|
78
|
+
const goalLoadError = ref("");
|
|
79
|
+
const accessDenied = ref(false);
|
|
80
|
+
const delivery = createAssistantMessageDelivery();
|
|
81
|
+
const pendingMessages = new Map();
|
|
82
|
+
const olderPages = ref([]);
|
|
83
|
+
const loadingMore = ref(false);
|
|
84
|
+
const loadMoreError = ref("");
|
|
85
|
+
const turns = computed(() => mergeConversationLogPages([
|
|
86
|
+
...olderPages.value, { conversationLog: snapshot.value?.turns || [] }
|
|
87
|
+
]).conversationLog);
|
|
88
|
+
const oldestLoadedPage = computed(() => olderPages.value[0] || snapshot.value);
|
|
89
|
+
const hasMoreBefore = computed(() => normalizeConversationLogPagination(oldestLoadedPage.value?.pagination).hasMoreBefore);
|
|
90
|
+
const editable = computed(() => active.value && !accessDenied.value);
|
|
91
|
+
const available = computed(() => active.value && !loading.value && !accessDenied.value && Boolean(snapshot.value));
|
|
92
|
+
const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
|
|
93
|
+
const queueing = computed(() => queueWhileSending !== false && steerable.value);
|
|
94
|
+
const canSubmit = computed(() => available.value && (snapshot.value.status === "ready" || steerable.value) &&
|
|
95
|
+
(queueWhileSending !== false || !delivery.state.sending) &&
|
|
96
|
+
!delivery.state.messages.some(message => message.status === "uncertain"));
|
|
97
|
+
const savedDraft = toValue(draftStorage);
|
|
98
|
+
if (savedDraft && (typeof savedDraft.key !== "string" || !savedDraft.key ||
|
|
99
|
+
!["getItem", "setItem", "removeItem"].every(name => typeof savedDraft.storage?.[name] === "function"))) {
|
|
100
|
+
throw new TypeError("draftStorage requires an explicit key and storage with getItem, setItem and removeItem.");
|
|
101
|
+
}
|
|
102
|
+
// Original composer state is restored verbatim. A read never reconstructs or
|
|
103
|
+
// sends an old request; the explicit retry keeps its original payload and ID.
|
|
104
|
+
try {
|
|
105
|
+
const saved = savedDraft ? JSON.parse(savedDraft.storage.getItem(savedDraft.key) || "null") : null;
|
|
106
|
+
draft.value = typeof saved?.draft === "string" ? saved.draft : "";
|
|
107
|
+
draftAttachments.value = Array.isArray(saved?.attachments)
|
|
108
|
+
? saved.attachments.filter(attachment => typeof attachment?.attachmentId === "string") : [];
|
|
109
|
+
const messages = Array.isArray(saved?.messages) ? saved.messages.filter(message =>
|
|
110
|
+
typeof message?.id === "string" && typeof message.text === "string" &&
|
|
111
|
+
typeof message.payload?.message === "string") : [];
|
|
112
|
+
const restoredMessages = messages.filter(message => !delivery.find(message.id))
|
|
113
|
+
.map(message => ({ ...message, status: "failed", error: message.error ||
|
|
114
|
+
"Delivery was not confirmed before this page closed. Check the conversation before retrying." }));
|
|
115
|
+
delivery.state.messages = unmatchedOptimisticMessages(turns.value, [...delivery.state.messages, ...restoredMessages], { receiptsOnly: true });
|
|
116
|
+
} catch {
|
|
117
|
+
draft.value = "";
|
|
118
|
+
draftAttachments.value = [];
|
|
119
|
+
}
|
|
120
|
+
function persistDraft() {
|
|
121
|
+
if (!savedDraft || !current.value || accessDenied.value) return;
|
|
122
|
+
try {
|
|
123
|
+
const saved = { draft: draft.value,
|
|
124
|
+
attachments: draftAttachments.value.filter(attachment => attachment?.attachmentId),
|
|
125
|
+
messages: delivery.state.messages };
|
|
126
|
+
if (saved.draft || saved.attachments.length || saved.messages.length) {
|
|
127
|
+
savedDraft.storage.setItem(savedDraft.key, JSON.stringify(saved));
|
|
128
|
+
} else savedDraft.storage.removeItem(savedDraft.key);
|
|
129
|
+
} catch {
|
|
130
|
+
// The in-memory delivery UI remains usable if storage is full or blocked.
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
persistDraft();
|
|
134
|
+
watch([draft, draftAttachments, () => delivery.state.messages], persistDraft, { deep: true, flush: "sync" });
|
|
135
|
+
function settleDraftRetry(retry = draftRetry.value) {
|
|
136
|
+
if (!retry || retry.messageId !== draftRetry.value?.messageId) return false;
|
|
137
|
+
draft.value = draftAfterAcceptedSubmission(draft.value, retry.draftSnapshot);
|
|
138
|
+
draftRetry.value = null;
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
watch(turns, value => {
|
|
142
|
+
const retry = draftRetry.value;
|
|
143
|
+
if (retry && !unmatchedOptimisticMessages(value, [retry.optimistic], { receiptsOnly: true }).length) settleDraftRetry(retry);
|
|
144
|
+
});
|
|
145
|
+
const questionConfiguration = computed(() => {
|
|
146
|
+
for (const reader of readers.values()) {
|
|
147
|
+
const option = toValue(reader.questions);
|
|
148
|
+
if (toValue(reader.active) !== false && isQuestionConfiguration(option)) return option;
|
|
149
|
+
}
|
|
150
|
+
return null;
|
|
151
|
+
});
|
|
152
|
+
// The form belongs to the conversation, including while only voice retains it.
|
|
153
|
+
// Reader options control presentation; they do not retire its answer state.
|
|
154
|
+
const questions = useAssistantQuestions({
|
|
155
|
+
message: () => available.value ? latestAssistantMessageAwaitingUserReply(turns.value) : "",
|
|
156
|
+
extraChoice: () => questionConfiguration.value?.extraChoice || null
|
|
157
|
+
});
|
|
158
|
+
let subscription;
|
|
159
|
+
let goalRead = null;
|
|
160
|
+
let goalSegment = null;
|
|
161
|
+
let goalReadController = null;
|
|
162
|
+
let goalReadQueued = false;
|
|
163
|
+
const readsGoals = computed(() => goalReadEnabled || snapshot.value?.capabilities?.goals === true ||
|
|
164
|
+
[...readers.values()].some(reader => toValue(reader.goal) === true));
|
|
165
|
+
watch(readsGoals, enabled => { if (enabled) void refreshGoal(); });
|
|
166
|
+
|
|
167
|
+
function receiveState(state, { initial = false, canonical = false } = {}) {
|
|
168
|
+
if (!current.value) return;
|
|
169
|
+
const previous = snapshot.value;
|
|
170
|
+
const goalChanged = JSON.stringify([previous?.goal, previous?.capabilities?.goals,
|
|
171
|
+
previous?.capabilities?.goalCommands, previous?.capabilities?.goalBudgets]) !==
|
|
172
|
+
JSON.stringify([state.goal, state.capabilities?.goals, state.capabilities?.goalCommands, state.capabilities?.goalBudgets]);
|
|
173
|
+
if (canonical) {
|
|
174
|
+
olderPages.value = [];
|
|
175
|
+
loadMoreError.value = "";
|
|
176
|
+
}
|
|
177
|
+
snapshot.value = state;
|
|
178
|
+
loading.value = false;
|
|
179
|
+
error.value = "";
|
|
180
|
+
accessDenied.value = false;
|
|
181
|
+
delivery.reconcile(state.turns);
|
|
182
|
+
if (state.status === "unconfirmed" && state.pendingRequest) delivery.restoreUncertain(state.pendingRequest, state.turns);
|
|
183
|
+
if (state.segmentId !== goalSegment) {
|
|
184
|
+
// Keep the open goal menu while its first native identity arrives. The
|
|
185
|
+
// pending read disables commands until it supplies the new exact target.
|
|
186
|
+
goalReadController?.abort();
|
|
187
|
+
}
|
|
188
|
+
if (readsGoals.value && (initial || goalChanged || state.segmentId !== goalSegment)) void refreshGoal();
|
|
189
|
+
goalSegment = state.segmentId;
|
|
190
|
+
}
|
|
191
|
+
function receiveError(failure) {
|
|
192
|
+
if (!current.value) return;
|
|
193
|
+
loading.value = false;
|
|
194
|
+
error.value = failure.message;
|
|
195
|
+
accessDenied.value = [401, 403].includes(Number(failure.status || failure.statusCode));
|
|
196
|
+
if (accessDenied.value) {
|
|
197
|
+
snapshot.value = null;
|
|
198
|
+
olderPages.value = [];
|
|
199
|
+
draft.value = "";
|
|
200
|
+
draftMessageId.value = "";
|
|
201
|
+
draftAttachments.value = [];
|
|
202
|
+
draftRetry.value = null;
|
|
203
|
+
delivery.reset();
|
|
204
|
+
goalError.value = "";
|
|
205
|
+
goalView.value = null;
|
|
206
|
+
goalLoadError.value = "";
|
|
207
|
+
goalReadController?.abort();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function receiveEvent(event) {
|
|
211
|
+
if (event.type === "goal") void refreshGoal();
|
|
212
|
+
for (const [token, reader] of [...readers]) {
|
|
213
|
+
if (!current.value) return;
|
|
214
|
+
if (!readers.has(token) || toValue(reader.active) === false || typeof reader.onEvent !== "function") continue;
|
|
215
|
+
// Presentation hooks own their errors; one reader must not interrupt
|
|
216
|
+
// another reader or the subscription's canonical state and delivery.
|
|
217
|
+
try { void Promise.resolve(reader.onEvent(event)).catch(() => {}); }
|
|
218
|
+
catch {}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
watch(active, enabled => {
|
|
222
|
+
subscription?.();
|
|
223
|
+
subscription = null;
|
|
224
|
+
if (!enabled) return;
|
|
225
|
+
loading.value = true;
|
|
226
|
+
subscription = subscribeAssistantConversation({ socket, conversationId: identity.conversationId,
|
|
227
|
+
targetSurfaceId: identity.targetSurfaceId, hostSurfaceId: identity.hostSurfaceId,
|
|
228
|
+
...(identity.workspaceSlug ? { workspaceSlug: identity.workspaceSlug } : {}),
|
|
229
|
+
read: () => api.readConversation(identity.conversationId), onState: receiveState,
|
|
230
|
+
onError: receiveError, onEvent: receiveEvent
|
|
231
|
+
});
|
|
232
|
+
}, { immediate: true, flush: "sync" });
|
|
233
|
+
watch(current, value => {
|
|
234
|
+
if (value) return;
|
|
235
|
+
delivery.reset();
|
|
236
|
+
snapshot.value = null;
|
|
237
|
+
olderPages.value = [];
|
|
238
|
+
draft.value = "";
|
|
239
|
+
draftMessageId.value = "";
|
|
240
|
+
draftAttachments.value = [];
|
|
241
|
+
draftRetry.value = null;
|
|
242
|
+
goalError.value = "";
|
|
243
|
+
goalPending.value = false;
|
|
244
|
+
goalView.value = null;
|
|
245
|
+
goalLoadError.value = "";
|
|
246
|
+
goalReadQueued = false;
|
|
247
|
+
goalReadController?.abort();
|
|
248
|
+
questions.reset();
|
|
249
|
+
}, { flush: "sync" });
|
|
250
|
+
onScopeDispose(() => {
|
|
251
|
+
disposed.value = true;
|
|
252
|
+
subscription?.();
|
|
253
|
+
stopPlacement();
|
|
254
|
+
for (const controller of pendingMessages.values()) controller.abort();
|
|
255
|
+
pendingMessages.clear();
|
|
256
|
+
goalReadController?.abort();
|
|
257
|
+
globalThis.removeEventListener?.("focus", refreshVisibleGoal);
|
|
258
|
+
globalThis.document?.removeEventListener("visibilitychange", refreshVisibleGoal);
|
|
259
|
+
delivery.reset();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
async function send(payload, { messageId = crypto.randomUUID(), onAccepted } = {}) {
|
|
263
|
+
const retry = delivery.find(messageId);
|
|
264
|
+
if (retry?.status === "failed" && retry.payload.goalRequest) return changeGoal(retry.payload.goalRequest.action, {}, { messageId });
|
|
265
|
+
if (!canSubmit.value) return false;
|
|
266
|
+
if (retry && retry.status !== "failed") return false;
|
|
267
|
+
const request = retry?.payload?.request || payload.request;
|
|
268
|
+
const requestedSteering = request?.steer === true;
|
|
269
|
+
if (requestedSteering && snapshot.value.capabilities?.steering !== true) return false;
|
|
270
|
+
const steering = requestedSteering || steerable.value;
|
|
271
|
+
if ((snapshot.value.status === "working" || delivery.state.sending) && !steering) return false;
|
|
272
|
+
if ((retry?.payload || payload).displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) return false;
|
|
273
|
+
const captured = retry?.payload?.request ? retry.payload : { ...(retry?.payload || payload), request: payload.request || {
|
|
274
|
+
text: String(payload.message || ""),
|
|
275
|
+
...(payload.data !== undefined ? { data: payload.data } : {}),
|
|
276
|
+
...(payload.displayAttachments?.length ? { attachmentIds: payload.displayAttachments.map(file => file.attachmentId) } : {}),
|
|
277
|
+
...(steerable.value ? { steer: true } : {})
|
|
278
|
+
} };
|
|
279
|
+
return submitRequest(captured, { messageId, onAccepted, queue: queueWhileSending !== false && steering });
|
|
280
|
+
}
|
|
281
|
+
function submitPrepared(payload, { messageId = crypto.randomUUID(), onAccepted, prepare } = {}) {
|
|
282
|
+
if (typeof prepare !== "function") throw new TypeError("Prepared submission requires application preparation.");
|
|
283
|
+
const retry = delivery.find(messageId);
|
|
284
|
+
const captured = retry?.payload || payload;
|
|
285
|
+
if (!captured?.request || typeof captured.request !== "object" || Array.isArray(captured.request) || captured.goalRequest) {
|
|
286
|
+
throw new TypeError("Prepared submission requires an authored conversation request.");
|
|
287
|
+
}
|
|
288
|
+
if (!current.value || accessDenied.value || retry && retry.status !== "failed") return Promise.resolve(false);
|
|
289
|
+
return submitRequest(captured, { messageId, onAccepted, prepare });
|
|
290
|
+
}
|
|
291
|
+
async function submitDraft(payload, { messageId = crypto.randomUUID(), submissionKind = steerable.value ? "steer" : "send",
|
|
292
|
+
questionText = "", onAccepted } = {}) {
|
|
293
|
+
if (!canSubmit.value) return false;
|
|
294
|
+
const retry = draftRetryMatches.value ? draftRetry.value : null;
|
|
295
|
+
if (!retry) draftRetry.value = null;
|
|
296
|
+
const draftSnapshot = retry?.draftSnapshot || draft.value;
|
|
297
|
+
const captured = retry?.payload?.request ? retry.payload : payload;
|
|
298
|
+
const kind = retry?.submissionKind || submissionKind;
|
|
299
|
+
const questionTextSnapshot = retry?.questionTextSnapshot || questionText;
|
|
300
|
+
const id = retry?.messageId || messageId;
|
|
301
|
+
const sequence = ++draftSubmissionSequence;
|
|
302
|
+
draftRetry.value = null;
|
|
303
|
+
draft.value = retry ? draftAfterAcceptedSubmission(draft.value, draftSnapshot) : "";
|
|
304
|
+
const result = await send(captured, { messageId: id, onAccepted });
|
|
305
|
+
if (!current.value) return false;
|
|
306
|
+
const accepted = result !== false && result?.ok !== false;
|
|
307
|
+
if (!accepted && kind === "steer" && sequence === draftSubmissionSequence && !draft.value) {
|
|
308
|
+
draft.value = draftSnapshot;
|
|
309
|
+
const optimistic = delivery.find(id) || {
|
|
310
|
+
createdAtMs: Date.now(), id, text: String(captured.displayMessage || captured.message || "").trim()
|
|
311
|
+
};
|
|
312
|
+
draftRetry.value = { draftSnapshot, messageId: id, optimistic, payload: optimistic.payload || captured,
|
|
313
|
+
questionTextSnapshot, submissionKind: kind };
|
|
314
|
+
if (!unmatchedOptimisticMessages(turns.value, [optimistic], { receiptsOnly: true }).length) settleDraftRetry(draftRetry.value);
|
|
315
|
+
}
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
async function submitRequest(payload, { messageId, onAccepted, queue = false, prepare }) {
|
|
319
|
+
try {
|
|
320
|
+
const result = await delivery.send(payload, {
|
|
321
|
+
messageId, queue, isCurrent: () => current.value, onAccepted,
|
|
322
|
+
receiptTurns: turns, uncertainOnError: true,
|
|
323
|
+
async deliver(submission) {
|
|
324
|
+
const controller = new AbortController();
|
|
325
|
+
pendingMessages.set(messageId, controller);
|
|
326
|
+
let dispatched = false;
|
|
327
|
+
try {
|
|
328
|
+
const request = prepare ? await prepare(submission, { signal: controller.signal }) : submission.request;
|
|
329
|
+
if (prepare) {
|
|
330
|
+
if (!current.value || accessDenied.value || controller.signal.aborted || request === false) return false;
|
|
331
|
+
if (!request || typeof request !== "object" || Array.isArray(request)) {
|
|
332
|
+
throw new TypeError("Application preparation must return its conversation request or false.");
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
dispatched = true;
|
|
336
|
+
return await (submission.goalRequest
|
|
337
|
+
? api.updateConversationGoal(identity.conversationId, { ...submission.goalRequest, messageId }, { signal: controller.signal })
|
|
338
|
+
: api.sendConversationMessage(identity.conversationId, { ...request, messageId }, { signal: controller.signal }));
|
|
339
|
+
}
|
|
340
|
+
catch (failure) {
|
|
341
|
+
if (controller.signal.aborted) return false;
|
|
342
|
+
if (prepare && !dispatched) return { ok: false, error: failure.message,
|
|
343
|
+
...(failure.code ? { code: failure.code } : {}) };
|
|
344
|
+
if (SEND_REJECTIONS.has(failure.code)) return { ok: false, error: failure.message,
|
|
345
|
+
...(prepare && failure.code ? { code: failure.code } : {}) };
|
|
346
|
+
throw failure;
|
|
347
|
+
} finally {
|
|
348
|
+
if (pendingMessages.get(messageId) === controller) pendingMessages.delete(messageId);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
});
|
|
352
|
+
return result;
|
|
353
|
+
} catch (failure) {
|
|
354
|
+
// Delivery keeps its exact authored payload until an explicit receipt check.
|
|
355
|
+
if (prepare) return { ok: false, status: "uncertain", error: failure.message,
|
|
356
|
+
...(failure.code ? { code: failure.code } : {}) };
|
|
357
|
+
return false;
|
|
358
|
+
} finally { if (current.value) subscription?.reload(); }
|
|
359
|
+
}
|
|
360
|
+
function cancelMessage(messageId) {
|
|
361
|
+
const controller = pendingMessages.get(messageId);
|
|
362
|
+
if (!controller) return false;
|
|
363
|
+
controller.abort();
|
|
364
|
+
pendingMessages.delete(messageId);
|
|
365
|
+
return true;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// The original history control keeps older pages separate from the live page.
|
|
369
|
+
async function loadMore({ complete } = {}) {
|
|
370
|
+
const finish = (result) => {
|
|
371
|
+
if (typeof complete === "function") complete(result);
|
|
372
|
+
};
|
|
373
|
+
const beforeTurnId = normalizeConversationLogPagination(oldestLoadedPage.value?.pagination).oldestTurnId ||
|
|
374
|
+
String(turns.value[0]?.turnId || "").trim();
|
|
375
|
+
if (!available.value || !hasMoreBefore.value || loadingMore.value || !beforeTurnId) {
|
|
376
|
+
finish({ changed: false, loaded: false });
|
|
377
|
+
return false;
|
|
378
|
+
}
|
|
379
|
+
loadingMore.value = true;
|
|
380
|
+
loadMoreError.value = "";
|
|
381
|
+
try {
|
|
382
|
+
const page = await api.readConversation(identity.conversationId, {
|
|
383
|
+
beforeTurnId, limit: snapshot.value.pagination.limit
|
|
384
|
+
});
|
|
385
|
+
if (!available.value) {
|
|
386
|
+
finish({ changed: false, loaded: false });
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
const previousOldestTurnId = String(turns.value[0]?.turnId || "").trim();
|
|
390
|
+
olderPages.value = [normalizeConversationLogPage(page), ...olderPages.value];
|
|
391
|
+
const nextOldestTurnId = String(turns.value[0]?.turnId || "").trim();
|
|
392
|
+
const changed = Boolean(nextOldestTurnId && nextOldestTurnId !== previousOldestTurnId);
|
|
393
|
+
finish({ changed, loaded: true });
|
|
394
|
+
return true;
|
|
395
|
+
} catch (failure) {
|
|
396
|
+
if (current.value) {
|
|
397
|
+
loadMoreError.value = String(failure?.message || failure || "Older conversation history could not be loaded.");
|
|
398
|
+
if ([401, 403].includes(Number(failure.status || failure.statusCode))) receiveError(failure);
|
|
399
|
+
}
|
|
400
|
+
finish({ changed: false, loaded: false });
|
|
401
|
+
return false;
|
|
402
|
+
} finally { loadingMore.value = false; }
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function refreshGoal() {
|
|
406
|
+
if (!active.value || !snapshot.value || !readsGoals.value || accessDenied.value) return Promise.resolve(false);
|
|
407
|
+
if (goalRead) { goalReadQueued = true; return goalRead; }
|
|
408
|
+
const segment = snapshot.value.segmentId;
|
|
409
|
+
const capabilities = snapshot.value.capabilities;
|
|
410
|
+
const controller = new AbortController();
|
|
411
|
+
goalReadController = controller;
|
|
412
|
+
goalLoading.value = true;
|
|
413
|
+
const job = Promise.resolve().then(() => api.readConversationGoal(identity.conversationId, {
|
|
414
|
+
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
|
415
|
+
})).then(result => {
|
|
416
|
+
if (!current.value || controller.signal.aborted || snapshot.value?.segmentId !== segment) return false;
|
|
417
|
+
// Hosts may supply a same-read target and product view. Standalone reads
|
|
418
|
+
// retain their existing goal/null contract and captured conversation target.
|
|
419
|
+
goalView.value = result && Object.hasOwn(result, "target") ? result : {
|
|
420
|
+
status: "available", goal: result, target: { segmentId: segment, capabilities }
|
|
421
|
+
};
|
|
422
|
+
goalLoadError.value = "";
|
|
423
|
+
return true;
|
|
424
|
+
}).catch(failure => {
|
|
425
|
+
if (current.value && !controller.signal.aborted && snapshot.value?.segmentId === segment) {
|
|
426
|
+
goalLoadError.value = failure.message || "Goal status is unavailable.";
|
|
427
|
+
}
|
|
428
|
+
return false;
|
|
429
|
+
}).finally(() => {
|
|
430
|
+
if (goalRead !== job) return;
|
|
431
|
+
goalRead = null;
|
|
432
|
+
goalReadController = null;
|
|
433
|
+
goalLoading.value = false;
|
|
434
|
+
if (goalReadQueued) {
|
|
435
|
+
goalReadQueued = false;
|
|
436
|
+
void refreshGoal();
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
goalRead = job;
|
|
440
|
+
return job;
|
|
441
|
+
}
|
|
442
|
+
function refreshVisibleGoal() {
|
|
443
|
+
if (!globalThis.document?.hidden) void refreshGoal();
|
|
444
|
+
}
|
|
445
|
+
globalThis.addEventListener?.("focus", refreshVisibleGoal);
|
|
446
|
+
globalThis.document?.addEventListener("visibilitychange", refreshVisibleGoal);
|
|
447
|
+
async function changeGoal(action, input = {}, { messageId = crypto.randomUUID() } = {}) {
|
|
448
|
+
const view = goalView.value;
|
|
449
|
+
const target = view?.target;
|
|
450
|
+
const command = target?.capabilities?.goalCommands?.[action];
|
|
451
|
+
if (!available.value || view?.status !== "available" || goalLoadError.value || goalPending.value || goalLoading.value ||
|
|
452
|
+
!["message", "control"].includes(command?.delivery) ||
|
|
453
|
+
delivery.state.messages.some(message => message.status === "uncertain")) return false;
|
|
454
|
+
if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
|
|
455
|
+
goalError.value = "This conversation does not support goal token budgets.";
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
const retry = delivery.find(messageId);
|
|
459
|
+
if (retry && retry.status !== "failed") return false;
|
|
460
|
+
const request = retry?.payload.goalRequest || { action, expectedSegmentId: target.segmentId,
|
|
461
|
+
expectedGoalId: view.goal?.id || null,
|
|
462
|
+
...(action === "set" ? { objective: String(input.objective || "").trim(),
|
|
463
|
+
...(input.tokenBudget == null ? {} : { tokenBudget: input.tokenBudget }) } : {}) };
|
|
464
|
+
goalPending.value = true;
|
|
465
|
+
goalError.value = "";
|
|
466
|
+
try {
|
|
467
|
+
if (retry || command.delivery === "message") {
|
|
468
|
+
const result = await submitRequest(retry?.payload || {
|
|
469
|
+
message: action === "set" ? request.objective : action === "resume" ? "Resume the goal." : "Cancel the goal.",
|
|
470
|
+
goalRequest: request
|
|
471
|
+
}, { messageId });
|
|
472
|
+
if (result === false || result?.ok === false) {
|
|
473
|
+
if (current.value) goalError.value = result?.error || delivery.find(messageId)?.error || "Could not change the goal.";
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
return result;
|
|
477
|
+
}
|
|
478
|
+
const result = await api.updateConversationGoal(identity.conversationId, request);
|
|
479
|
+
if (result?.ok === false) throw new Error(result.error || "Could not change the goal.");
|
|
480
|
+
return result;
|
|
481
|
+
} catch (failure) {
|
|
482
|
+
if (current.value) {
|
|
483
|
+
goalError.value = failure.message;
|
|
484
|
+
if ([401, 403].includes(Number(failure.status || failure.statusCode))) receiveError(failure);
|
|
485
|
+
}
|
|
486
|
+
return false;
|
|
487
|
+
} finally {
|
|
488
|
+
if (current.value) {
|
|
489
|
+
goalPending.value = false;
|
|
490
|
+
await goalRead;
|
|
491
|
+
await refreshGoal();
|
|
492
|
+
subscription?.reload();
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
const goalState = computed(() => {
|
|
497
|
+
const view = goalView.value;
|
|
498
|
+
if (!readsGoals.value) return null;
|
|
499
|
+
const capabilities = view?.target?.capabilities || {};
|
|
500
|
+
const commands = capabilities.goalCommands || {};
|
|
501
|
+
return {
|
|
502
|
+
enabled: active.value && Boolean((view?.status === "available" && !goalLoadError.value) || goalError.value),
|
|
503
|
+
tokenBudgetSupported: capabilities.goalBudgets === true,
|
|
504
|
+
goal: view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
|
|
505
|
+
sampledAt: Date.parse(view.goal.updatedAt) } : null,
|
|
506
|
+
pending: goalPending.value || goalLoading.value || !available.value || delivery.state.sending ||
|
|
507
|
+
delivery.state.messages.some(message => message.status === "uncertain"),
|
|
508
|
+
error: goalError.value,
|
|
509
|
+
pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
|
|
510
|
+
cancelInterruptsTurn: commands.cancel?.interruptsTurn === true,
|
|
511
|
+
...Object.fromEntries(["set", "resume", "pause", "cancel"].filter(action =>
|
|
512
|
+
["message", "control"].includes(commands[action]?.delivery)).map(action => [action, input => changeGoal(action, input)]))
|
|
513
|
+
};
|
|
514
|
+
});
|
|
515
|
+
async function cancel() {
|
|
516
|
+
if (!editable.value || stopping.value) return false;
|
|
517
|
+
stopping.value = true;
|
|
518
|
+
try { return await api.cancelConversation(identity.conversationId); }
|
|
519
|
+
catch (failure) { receiveError(failure); return false; }
|
|
520
|
+
finally { stopping.value = false; if (current.value) subscription?.reload(); }
|
|
521
|
+
}
|
|
522
|
+
async function inspectDelivery(messageId) {
|
|
523
|
+
if (!available.value) return false;
|
|
524
|
+
const message = delivery.find(messageId);
|
|
525
|
+
if (message?.checking) return false;
|
|
526
|
+
if (message) message.checking = true;
|
|
527
|
+
try {
|
|
528
|
+
const receipt = await api.inspectConversationDelivery(identity.conversationId, messageId);
|
|
529
|
+
if (!current.value) return false;
|
|
530
|
+
if (receipt.status === "accepted") delivery.accept(messageId);
|
|
531
|
+
subscription?.reload();
|
|
532
|
+
return receipt;
|
|
533
|
+
} catch (failure) { receiveError(failure); return false; }
|
|
534
|
+
finally { if (message) message.checking = false; }
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const runtime = { identity, current, active, editable, available, canSubmit, steerable, queueWhileSending: queueing,
|
|
538
|
+
snapshot, draft, draftMessageId, draftAttachments, draftRetry, draftRetryMatches, settleDraftRetry,
|
|
539
|
+
error, loading, stopping, delivery, turns,
|
|
540
|
+
hasMoreBefore, loadingMore, loadMoreError, loadMore,
|
|
541
|
+
send, submitPrepared, submitDraft, cancel, cancelMessage, inspectDelivery, changeGoal, refreshGoal, goalState, goalView, goalLoadError, questions,
|
|
542
|
+
reload() { const job = subscription?.reload(); void refreshGoal(); return job; } };
|
|
543
|
+
runtime.application = application ? application(runtime) : null;
|
|
544
|
+
return runtime;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Original bounded-command history belongs to this mounted view. It has no
|
|
548
|
+
// canonical delivery receipt, retained server conversation or live subscription.
|
|
549
|
+
function useBoundedTask({ command, endpoint, scope, input, result, onResult }, { active, data, presentation }) {
|
|
550
|
+
if (typeof command?.run !== "function" || !("isRunning" in command) ||
|
|
551
|
+
typeof input !== "function" || typeof result !== "function") {
|
|
552
|
+
throw new TypeError("boundedTask requires its command and input/result mappings.");
|
|
553
|
+
}
|
|
554
|
+
if (endpoint === undefined || scope === undefined) throw new TypeError("boundedTask requires its endpoint and application scope.");
|
|
555
|
+
if (onResult !== undefined && typeof onResult !== "function") throw new TypeError("boundedTask.onResult must be a function.");
|
|
556
|
+
const draft = ref("");
|
|
557
|
+
const messages = ref([]);
|
|
558
|
+
const busy = computed(() => toValue(command.isRunning) === true);
|
|
559
|
+
let disposed = false;
|
|
560
|
+
onScopeDispose(() => { disposed = true; });
|
|
561
|
+
|
|
562
|
+
async function submit() {
|
|
563
|
+
const content = draft.value.trim();
|
|
564
|
+
if (!content || busy.value || toValue(active) === false) return;
|
|
565
|
+
const capturedScope = toValue(scope);
|
|
566
|
+
const message = { ...toValue(data), content, role: "user" };
|
|
567
|
+
messages.value.push(message);
|
|
568
|
+
draft.value = "";
|
|
569
|
+
const response = await command.run({ path: toValue(endpoint), payload: input(messages.value) });
|
|
570
|
+
if (!response || disposed || toValue(scope) !== capturedScope) return;
|
|
571
|
+
messages.value.push(result(response));
|
|
572
|
+
onResult?.(response, { message, scope: capturedScope });
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const runtime = shallowRef(Object.freeze({ draft, messages, submit,
|
|
576
|
+
clearHistory() { messages.value = []; }
|
|
577
|
+
}));
|
|
578
|
+
const adapter = computed(() => {
|
|
579
|
+
const display = toValue(presentation) || {};
|
|
580
|
+
return {
|
|
581
|
+
conversation: {
|
|
582
|
+
turns: conversationTurnsFromMessages(messages.value.map(message => ({ ...message, text: message.content }))),
|
|
583
|
+
assistantLabel: display.assistantLabel || "Assistant", welcomeMessage: display.welcomeMessage,
|
|
584
|
+
visible: display.visible, variant: display.variant, scrollKey: toValue(scope)
|
|
585
|
+
},
|
|
586
|
+
composer: {
|
|
587
|
+
draft: draft.value, disabled: busy.value, pending: busy.value,
|
|
588
|
+
canSend: toValue(active) !== false && !busy.value && Boolean(draft.value.trim()),
|
|
589
|
+
submitOnEnter: display.submitOnEnter ?? false, submitOnModifierEnter: display.submitOnModifierEnter ?? true,
|
|
590
|
+
placeholder: display.placeholder, ariaLabel: display.ariaLabel, submitAriaLabel: display.submitAriaLabel
|
|
591
|
+
},
|
|
592
|
+
actions: { setDraft(value) { draft.value = value; }, submit }
|
|
593
|
+
};
|
|
594
|
+
});
|
|
595
|
+
return { runtime, adapter, error: computed(() => "") };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function conversationBindingSetup({ socket, boundedTask = null } = {}) {
|
|
599
|
+
const app = getCurrentInstance()?.appContext.app;
|
|
600
|
+
if (!app) throw new Error("Conversation binding must be acquired from the application setup.");
|
|
601
|
+
if (boundedTask !== null) return { app };
|
|
602
|
+
const routeContext = useSurfaceRouteContext();
|
|
603
|
+
const placement = inject("jskit.shell-web.runtime.web-placement.client", null);
|
|
604
|
+
if (typeof placement?.getContext !== "function" || typeof placement?.subscribe !== "function") {
|
|
605
|
+
throw new Error("Conversation binding requires the application's placement runtime.");
|
|
606
|
+
}
|
|
607
|
+
return { app, routeContext, placement, defaults: inject(conversationDefaultsKey, {}), workspaceScope: useWorkspaceWebScopeSupport(),
|
|
608
|
+
socket: socket || useRealtimeSocket({ required: true }), config: getClientAppConfig() };
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function createConversationBinding({ conversationId, endpoint = "", surfaceId = "", hostSurfaceId = "", workspaceSlug,
|
|
612
|
+
actorKey: suppliedActorKey, api: suppliedApi = null, socket: suppliedSocket = null, active = true, onEvent,
|
|
613
|
+
clearDraftOn: suppliedClearDraftOn, queueWhileSending, draftWhileLoading = false,
|
|
614
|
+
draftStorage = null, application = null, boundedTask = null,
|
|
615
|
+
data, attachments = null, suggestions = null, models = null, questions = null, goal = null, presentation = {} } = {}, setup) {
|
|
616
|
+
const { app, routeContext, placement, workspaceScope, socket: setupSocket, config, defaults = {} } = setup ||
|
|
617
|
+
conversationBindingSetup({ socket: suppliedSocket, boundedTask });
|
|
618
|
+
const clearDraftOn = suppliedClearDraftOn ?? defaults.clearDraftOn ?? "dispatch";
|
|
619
|
+
const actorKey = computed(() => {
|
|
620
|
+
const actor = toValue(suppliedActorKey);
|
|
621
|
+
return actor === undefined ? toValue(defaults.actorKey) : actor;
|
|
622
|
+
});
|
|
623
|
+
if (!["dispatch", "accepted"].includes(clearDraftOn)) throw new TypeError("clearDraftOn must be dispatch or accepted.");
|
|
624
|
+
if (queueWhileSending !== undefined && typeof queueWhileSending !== "boolean") throw new TypeError("queueWhileSending must be a boolean.");
|
|
625
|
+
if (typeof draftWhileLoading !== "boolean") throw new TypeError("draftWhileLoading must be a boolean.");
|
|
626
|
+
if (application !== null && typeof application !== "function") throw new TypeError("application must be a factory.");
|
|
627
|
+
if (boundedTask !== null) return useBoundedTask(boundedTask, { active, data, presentation });
|
|
628
|
+
const socket = suppliedSocket || setupSocket;
|
|
629
|
+
const runtime = shallowRef(null);
|
|
630
|
+
const initialDraft = ref("");
|
|
631
|
+
const attachmentOwner = computed(() => {
|
|
632
|
+
const owner = toValue(attachments);
|
|
633
|
+
return owner ? proxyRefs(owner) : null;
|
|
634
|
+
});
|
|
635
|
+
const suggestionOwner = computed(() => {
|
|
636
|
+
const owner = toValue(suggestions);
|
|
637
|
+
return owner ? proxyRefs(owner) : null;
|
|
638
|
+
});
|
|
639
|
+
const modelOwner = computed(() => {
|
|
640
|
+
const owner = toValue(models);
|
|
641
|
+
return owner ? proxyRefs(owner) : null;
|
|
642
|
+
});
|
|
643
|
+
const questionOwner = computed(() => {
|
|
644
|
+
const option = toValue(questions);
|
|
645
|
+
const owner = isQuestionConfiguration(option) ? runtime.value?.questions : option;
|
|
646
|
+
return owner ? proxyRefs(owner) : null;
|
|
647
|
+
});
|
|
648
|
+
const identity = computed(() => {
|
|
649
|
+
const host = (text(hostSurfaceId) || text(routeContext.currentSurfaceId)).toLowerCase();
|
|
650
|
+
const target = (text(surfaceId) || host).toLowerCase();
|
|
651
|
+
const surface = resolveAssistantSurfaceConfig(config, target);
|
|
652
|
+
const workspace = toValue(workspaceSlug) === undefined
|
|
653
|
+
? surface?.runtimeSurfaceRequiresWorkspace ? workspaceScope.readRouteScope(routeContext).workspaceSlug : ""
|
|
654
|
+
: text(workspaceSlug);
|
|
655
|
+
const actor = toValue(actorKey);
|
|
656
|
+
return Object.freeze({ conversationId: text(conversationId), targetSurfaceId: target, hostSurfaceId: host || target,
|
|
657
|
+
workspaceSlug: workspace || "", configured: Boolean(surface) && (!surface.runtimeSurfaceRequiresWorkspace || Boolean(workspace)),
|
|
658
|
+
actorKey: actor === undefined ? String(routeContext.placementContext.value?.user?.id || "") : text(actor),
|
|
659
|
+
endpoint: text(endpoint) || buildAssistantApiPath({ requiresWorkspace: Boolean(workspace), workspaceSlug: workspace,
|
|
660
|
+
suffix: `/${target}` }) });
|
|
661
|
+
});
|
|
662
|
+
// The original composer owns input before a conversation ID is available.
|
|
663
|
+
// It is scoped to the real actor/surface/workspace, never a placeholder ID.
|
|
664
|
+
const draftScope = computed(() => {
|
|
665
|
+
const scope = identity.value;
|
|
666
|
+
return JSON.stringify([scope.actorKey, scope.endpoint, scope.targetSurfaceId,
|
|
667
|
+
scope.hostSurfaceId, scope.workspaceSlug, scope.configured]);
|
|
668
|
+
});
|
|
669
|
+
const canDraftWhileLoading = computed(() => draftWhileLoading && identity.value.configured &&
|
|
670
|
+
Boolean(identity.value.actorKey && identity.value.targetSurfaceId) && toValue(active) !== false);
|
|
671
|
+
watch(draftScope, () => { initialDraft.value = ""; }, { flush: "sync" });
|
|
672
|
+
let retained;
|
|
673
|
+
watch(() => JSON.stringify(identity.value), () => {
|
|
674
|
+
retained?.release();
|
|
675
|
+
retained = null;
|
|
676
|
+
runtime.value = null;
|
|
677
|
+
const target = identity.value;
|
|
678
|
+
if (!target.configured || !target.conversationId || !target.actorKey || !target.targetSurfaceId) return;
|
|
679
|
+
const key = JSON.stringify(["assistant", target.actorKey, target.endpoint, target.targetSurfaceId,
|
|
680
|
+
target.hostSurfaceId, target.workspaceSlug, target.conversationId]);
|
|
681
|
+
retained = retainAssistantConversation(app, key, readers => createConversation(target, {
|
|
682
|
+
readers, socket, actorKey, placement, queueWhileSending, draftStorage, application, goalReadEnabled: toValue(goal) === true,
|
|
683
|
+
api: suppliedApi || defaults.api || createAssistantApi({ request: defaults.request || assistantHttpClient.request,
|
|
684
|
+
resolveBasePath: () => target.endpoint, resolveSurfaceId: () => target.hostSurfaceId })
|
|
685
|
+
}), { active, questions, goal, onEvent });
|
|
686
|
+
runtime.value = retained.runtime;
|
|
687
|
+
}, { immediate: true, flush: "sync" });
|
|
688
|
+
watch(() => runtime.value?.editable.value && runtime.value, current => {
|
|
689
|
+
if (!draftWhileLoading || !current || !initialDraft.value) return;
|
|
690
|
+
const value = initialDraft.value;
|
|
691
|
+
if (!current.draft.value.trim()) current.draft.value = value;
|
|
692
|
+
else if (value.trim() && text(current.draft) !== value.trim()) {
|
|
693
|
+
// Both inputs are already editable and the helper writes synchronously.
|
|
694
|
+
// Reuse its existing append rule without invoking Send.
|
|
695
|
+
void createAssistantTextSubmission({
|
|
696
|
+
getState: () => ({ active: current.editable.value, draft: current.draft.value }),
|
|
697
|
+
setDraft: value => { current.draft.value = value; },
|
|
698
|
+
submit: () => false
|
|
699
|
+
})(value, { sendImmediately: false });
|
|
700
|
+
}
|
|
701
|
+
initialDraft.value = "";
|
|
702
|
+
}, { flush: "sync" });
|
|
703
|
+
onScopeDispose(() => { initialDraft.value = ""; retained?.release(); });
|
|
704
|
+
|
|
705
|
+
function submit({ attachments: files = [], retryMessageId = "" } = {}) {
|
|
706
|
+
const current = runtime.value;
|
|
707
|
+
if (!current?.canSubmit.value) return false;
|
|
708
|
+
const owner = attachmentOwner.value;
|
|
709
|
+
const waitForAcceptance = clearDraftOn === "accepted";
|
|
710
|
+
const pending = waitForAcceptance ? current.delivery.find(current.draftMessageId.value) : null;
|
|
711
|
+
const previousId = retryMessageId || (pending && text(pending.payload.draft) === text(current.draft) ? pending.id : "");
|
|
712
|
+
const retry = previousId ? current.delivery.find(previousId) : null;
|
|
713
|
+
if (retry && retry.status !== "failed") return false;
|
|
714
|
+
if (!retry && owner?.canSubmit === false) return false;
|
|
715
|
+
const prompts = questionOwner.value;
|
|
716
|
+
const formatsQuestions = typeof prompts?.capture === "function";
|
|
717
|
+
if (!retry && formatsQuestions && prompts.questions?.length &&
|
|
718
|
+
!prompts.questions.every(question => String(prompts.answers[question.name] || "").trim())) return false;
|
|
719
|
+
const answer = !retry && formatsQuestions ? prompts.capture(current.draft.value) : null;
|
|
720
|
+
const captured = retry?.payload || { message: answer?.message ?? current.draft.value, displayAttachments: files,
|
|
721
|
+
...(waitForAcceptance ? { draft: current.draft.value } : {}),
|
|
722
|
+
...(data === undefined ? {} : { data: toValue(data) }) };
|
|
723
|
+
if (!String(captured.message || "").trim() && !captured.displayAttachments?.length) return false;
|
|
724
|
+
if (captured.displayAttachments?.length && current.snapshot.value?.capabilities?.attachments !== true) return false;
|
|
725
|
+
const messageId = previousId || crypto.randomUUID();
|
|
726
|
+
if (!retry && answer) prompts.markSubmitted(answer.questionText);
|
|
727
|
+
return submitCaptured(current, captured, { messageId, retry, attachments: owner });
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// Prepared application tasks use the same composer/receipt handling as the
|
|
731
|
+
// standard element. Creation, routing and access policy remain preparation.
|
|
732
|
+
function submitPrepared(payload, { messageId = crypto.randomUUID(), retryMessageId = "", retry,
|
|
733
|
+
working = false, clearDraft = true, attachments = attachmentOwner.value,
|
|
734
|
+
isCurrent = () => true, onSubmit, onAccepted, prepare } = {}) {
|
|
735
|
+
const current = runtime.value;
|
|
736
|
+
if (typeof prepare !== "function") throw new TypeError("Prepared submission requires application preparation.");
|
|
737
|
+
if (!current?.current.value || !isCurrent()) return false;
|
|
738
|
+
const id = retryMessageId || messageId;
|
|
739
|
+
const previous = retry || current.delivery.find(id);
|
|
740
|
+
if ((retryMessageId && !previous) || (previous && (!["failed", "uncertain"].includes(previous.status) || previous.checking))) {
|
|
741
|
+
return false;
|
|
742
|
+
}
|
|
743
|
+
const captured = previous?.payload?.request ? previous.payload : payload;
|
|
744
|
+
const authored = captured.request ? captured : { ...captured, request: {
|
|
745
|
+
text: String(captured.message || ""),
|
|
746
|
+
...(captured.data !== undefined ? { data: captured.data } : {}),
|
|
747
|
+
...(captured.attachmentIds?.length ? { attachmentIds: captured.attachmentIds }
|
|
748
|
+
: captured.displayAttachments?.length ? { attachmentIds: captured.displayAttachments.map(file => file.attachmentId) } : {}),
|
|
749
|
+
...((previous ? captured.submissionKind === "steer" : working) ? { steer: true } : {})
|
|
750
|
+
} };
|
|
751
|
+
return submitCaptured(current, authored, { messageId: id, retry: previous, clearDraft,
|
|
752
|
+
attachments, isCurrent, onSubmit, onAccepted, prepare });
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function submitCaptured(current, captured, { messageId, retry, clearDraft = true, attachments,
|
|
756
|
+
isCurrent = () => true, onSubmit, onAccepted, prepare } = {}) {
|
|
757
|
+
const waitForAcceptance = clearDraftOn === "accepted";
|
|
758
|
+
const attachmentIds = [...(captured.request?.attachmentIds || captured.attachmentIds ||
|
|
759
|
+
(captured.displayAttachments || []).map(file => file.attachmentId))];
|
|
760
|
+
let accepted = false;
|
|
761
|
+
function acknowledge() {
|
|
762
|
+
if (accepted || !current.current.value || !isCurrent()) return;
|
|
763
|
+
accepted = true;
|
|
764
|
+
if (waitForAcceptance && current.draftMessageId.value === messageId) {
|
|
765
|
+
const receipt = current.delivery.find(messageId);
|
|
766
|
+
current.draftMessageId.value = "";
|
|
767
|
+
if (receipt && text(current.draft) === text(receipt.payload.draft)) current.draft.value = "";
|
|
768
|
+
}
|
|
769
|
+
if (attachmentIds.length) {
|
|
770
|
+
current.draftAttachments.value = current.draftAttachments.value.filter(file => !attachmentIds.includes(file.attachmentId));
|
|
771
|
+
if (runtime.value === current) {
|
|
772
|
+
try { attachments?.clearAttachments({ accepted: true, attachmentIds }); }
|
|
773
|
+
catch (failure) { current.error.value = failure?.message || "Accepted attachments could not be cleared from the composer."; }
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
onAccepted?.();
|
|
777
|
+
}
|
|
778
|
+
if (!retry && clearDraft) {
|
|
779
|
+
if (waitForAcceptance) {
|
|
780
|
+
current.draftMessageId.value = messageId;
|
|
781
|
+
captured = { ...captured, draft: current.draft.value };
|
|
782
|
+
} else current.draft.value = "";
|
|
783
|
+
}
|
|
784
|
+
onSubmit?.();
|
|
785
|
+
// A restored uncertain receipt may have no retained acceptance callback.
|
|
786
|
+
// Inspection can acknowledge it, but must never run preparation or Send.
|
|
787
|
+
if (retry?.status === "uncertain") return current.inspectDelivery(messageId).then(receipt => {
|
|
788
|
+
if (!current.current.value || !isCurrent()) return false;
|
|
789
|
+
if (receipt?.status !== "accepted") {
|
|
790
|
+
const error = current.error.value || "Message delivery is not confirmed.";
|
|
791
|
+
const pending = current.delivery.find(messageId);
|
|
792
|
+
if (pending) pending.error = error;
|
|
793
|
+
return { ok: false, status: "uncertain", error };
|
|
794
|
+
}
|
|
795
|
+
acknowledge();
|
|
796
|
+
return { ...receipt, ok: true };
|
|
797
|
+
});
|
|
798
|
+
return prepare ? current.submitPrepared(captured, { messageId, onAccepted: acknowledge, prepare })
|
|
799
|
+
: current.send(captured, { messageId, onAccepted: acknowledge });
|
|
800
|
+
}
|
|
801
|
+
const adapter = computed(() => {
|
|
802
|
+
const current = runtime.value;
|
|
803
|
+
const display = toValue(presentation) || {};
|
|
804
|
+
const state = current?.snapshot.value;
|
|
805
|
+
const files = state?.capabilities?.attachments ? attachmentOwner.value : null;
|
|
806
|
+
const prompts = current?.available.value ? questionOwner.value : null;
|
|
807
|
+
const formatsQuestions = typeof prompts?.capture === "function";
|
|
808
|
+
const draft = current?.draft.value ?? initialDraft.value;
|
|
809
|
+
const editable = canDraftWhileLoading.value ? !current || current.editable.value : current?.available.value;
|
|
810
|
+
const message = formatsQuestions ? prompts.capture(draft).message : draft;
|
|
811
|
+
const answered = !formatsQuestions || !prompts.questions?.length ||
|
|
812
|
+
prompts.questions.every(question => String(prompts.answers[question.name] || "").trim());
|
|
813
|
+
const canSend = current?.canSubmit.value || false;
|
|
814
|
+
return {
|
|
815
|
+
delivery: current?.delivery,
|
|
816
|
+
attachments: files,
|
|
817
|
+
suggestions: current?.available.value ? suggestionOwner.value : null,
|
|
818
|
+
models: current?.available.value ? modelOwner.value : null, questions: prompts,
|
|
819
|
+
goal: (toValue(goal) === true ? null : toValue(goal)) || current?.goalState.value || null,
|
|
820
|
+
conversation: { turns: current?.turns.value || [], interimReply: state?.interimReply || null,
|
|
821
|
+
scrollKey: JSON.stringify(identity.value),
|
|
822
|
+
hasMoreBefore: current?.hasMoreBefore.value || false,
|
|
823
|
+
loadingMore: current?.loadingMore.value || false, loadMoreError: current?.loadMoreError.value || "",
|
|
824
|
+
loading: current?.loading.value || false, working: state?.status === "working" && !current.delivery.state.sending,
|
|
825
|
+
assistantLabel: display.assistantLabel || "Assistant", welcomeMessage: display.welcomeMessage ?? "What would you like to do?",
|
|
826
|
+
systemLabel: display.systemLabel, variant: display.variant, visible: display.visible,
|
|
827
|
+
userMessageFormat: display.userMessageFormat, progressPreviewLimit: display.progressPreviewLimit,
|
|
828
|
+
previewMessage: current?.available.value ? display.previewMessage : null },
|
|
829
|
+
composer: { draft, disabled: !editable ||
|
|
830
|
+
(queueWhileSending === false && current?.delivery.state.sending),
|
|
831
|
+
canSend: canSend && answered && Boolean(message.trim() || files?.attachments?.length),
|
|
832
|
+
canStop: state?.status === "working" || state?.status === "unavailable", stopPending: current?.stopping.value || false,
|
|
833
|
+
queueWhileSending: current?.queueWhileSending.value || false,
|
|
834
|
+
ariaLabel: display.ariaLabel, submitAriaLabel: display.submitAriaLabel, submitLabel: display.submitLabel,
|
|
835
|
+
placeholder: formatsQuestions && prompts.questions?.length ? "Optional additional context…"
|
|
836
|
+
: formatsQuestions && prompts.choices?.length ? "Choose an answer above, or type another answer…"
|
|
837
|
+
: display.placeholder || "Message the assistant…",
|
|
838
|
+
hint: formatsQuestions && prompts.active ? "Answer the assistant, then send one combined reply." : "",
|
|
839
|
+
rows: display.rows ?? 2, density: display.layout === "compact" ? "compact" : "default", submitOnEnter: true, submitOnModifierEnter: true },
|
|
840
|
+
actions: { setDraft(value) {
|
|
841
|
+
if (current) current.draft.value = value;
|
|
842
|
+
else if (canDraftWhileLoading.value) initialDraft.value = value;
|
|
843
|
+
}, submit,
|
|
844
|
+
resend: messageId => submit({ retryMessageId: messageId }), stop: () => current?.cancel(),
|
|
845
|
+
checkDelivery: messageId => current?.inspectDelivery(messageId), loadMore: input => current?.loadMore(input), reload: () => current?.reload() }
|
|
846
|
+
};
|
|
847
|
+
});
|
|
848
|
+
return { runtime, adapter, submitPrepared, error: computed(() => runtime.value?.error.value || runtime.value?.snapshot.value?.error || "") };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/** The standard element and custom text/voice presentation share the supplied binding. */
|
|
852
|
+
function useAssistantConversation(options) {
|
|
853
|
+
return createConversationBinding(options);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/** Dynamic application tasks acquire the same binding without mounting a hidden view. */
|
|
857
|
+
function useAssistantConversationFactory(options = {}) {
|
|
858
|
+
const setup = conversationBindingSetup(options);
|
|
859
|
+
const owner = getCurrentScope();
|
|
860
|
+
if (!owner) throw new Error("Conversation factories require an application setup scope.");
|
|
861
|
+
return (input = {}) => {
|
|
862
|
+
if (!owner.active) throw new Error("The conversation factory's application scope has ended.");
|
|
863
|
+
const scope = owner.run(() => effectScope());
|
|
864
|
+
try {
|
|
865
|
+
const binding = scope.run(() => createConversationBinding({ ...options, ...input }, setup));
|
|
866
|
+
return { ...binding, release: () => scope.stop() };
|
|
867
|
+
} catch (failure) {
|
|
868
|
+
scope.stop();
|
|
869
|
+
throw failure;
|
|
870
|
+
}
|
|
871
|
+
};
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
export { configureAssistantConversations, useAssistantConversation, useAssistantConversationFactory };
|