@jskit-ai/assistant-runtime 0.2.9 → 0.2.10
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 +1 -1
- package/fixtures/canonical-conversation/main.js +2 -1
- package/fixtures/conversation/main.js +2 -1
- package/fixtures/conversation/vite.config.mjs +4 -3
- package/migrations/assistant_config_initial.cjs +1 -5
- package/migrations/assistant_transcripts_initial.cjs +3 -9
- package/migrations/assistant_transcripts_summary.cjs +28 -0
- package/migrations/assistant_turn_requests.cjs +2 -1
- package/migrations/constraints/assistant_users_foreign_keys.cjs +22 -0
- package/migrations/constraints/assistant_workspaces_foreign_keys.cjs +26 -0
- package/package.json +17 -10
- package/patterns/assistant-surface/example/src/ApplicationAiFeature.js +4 -2
- package/src/client/components/AssistantConversationClientElement.vue +10 -4
- package/src/client/components/AssistantSurfaceClientElement.vue +8 -4
- package/src/client/composables/conversationGoal.js +266 -0
- package/src/client/composables/conversationHistory.js +215 -0
- package/src/client/composables/conversationRuntime.js +624 -0
- package/src/client/composables/useAssistantConversation.js +208 -884
- package/src/client/composables/useAssistantRuntime.js +60 -45
- package/src/client/providers/AssistantClientProvider.js +1 -0
- package/src/client/support/assistantRuntimeState.js +6 -34
- package/src/client/support/conversationFailures.js +30 -0
- package/src/client/support/conversationRestoreSupport.js +13 -5
- package/src/client/support/subscribeAssistantConversation.js +32 -9
- package/src/client/support/workspaceScopeSupport.js +13 -22
- package/src/client/types.js +46 -0
- package/src/server/AssistantProvider.js +45 -17
- package/src/server/actions.js +162 -201
- package/src/server/conversationOperations.js +141 -0
- package/src/server/createAssistantRuntime.js +83 -44
- package/src/server/createAssistantSettingsActions.js +69 -47
- package/src/server/hostedAssistant.js +21 -0
- package/src/server/index.js +1 -0
- package/src/server/registerConversationSubscriptions.js +34 -11
- package/src/server/registerFastifyConversations.js +18 -6
- package/src/server/registerRoutes.js +5 -2
- package/src/server/registerSettingsRoutes.js +1 -1
- package/src/server/repositories/assistantConfigRepository.js +10 -20
- package/src/server/repositories/conversationsRepository.js +35 -10
- package/src/server/repositories/messagesRepository.js +14 -10
- package/src/server/repositories/repositoryPersistenceUtils.js +1 -6
- package/src/server/repositories/turnRequestsRepository.js +6 -1
- package/src/server/services/assistantConfigService.js +25 -23
- package/src/server/services/chatService.js +57 -61
- package/src/server/services/transcriptService.js +15 -7
- package/src/server/sqlConversationStorage.js +90 -18
- package/src/server/support/assistantServerConfig.js +1 -1
- package/src/server/support/assistantStreamWriter.js +7 -1
- package/src/server/support/createSurfaceAwareToolCatalog.js +35 -17
- package/src/server/types.js +38 -0
- package/src/shared/assistantSurfaces.js +2 -2
- package/test/assistantConversationOperations.test.js +5 -5
- package/test/assistantRuntimeState.test.js +1 -16
- package/test/chatServiceLifecycle.test.js +62 -154
- package/test/createSurfaceAwareToolCatalog.test.js +5 -0
- package/test/featureRuntime.test.js +12 -6
- package/test/lazyAppConfig.test.js +3 -5
- package/test/packageMetadata.test.js +7 -1
- package/test/sqlConversationStorage.mysql.test.js +89 -0
- package/test/sqlConversationStorage.test.js +161 -16
- package/test/support/memoryAssistantRows.js +6 -1
- package/test/support/scriptedModel.js +32 -0
- package/test/turnRequests.test.js +4 -2
- package/test/usersForeignKeysMigration.test.js +46 -0
- package/test/workspacesForeignKeysMigration.test.js +66 -0
- package/src/client/support/accessDenied.js +0 -6
|
@@ -1,883 +1,166 @@
|
|
|
1
1
|
import { computed, effectScope, getCurrentInstance, getCurrentScope, inject, onScopeDispose, proxyRefs, ref, shallowRef, toValue, watch } from "vue";
|
|
2
2
|
import { getClientAppConfig } from "@jskit-ai/kernel/client";
|
|
3
|
-
import {
|
|
3
|
+
import { retainAssistantConversation } from "@jskit-ai/assistant-core/client/conversation-delivery";
|
|
4
4
|
import { createAssistantTextSubmission } from "@jskit-ai/assistant-core/client/conversation";
|
|
5
|
-
import { useAssistantQuestions } from "@jskit-ai/assistant-core/client/conversation-questions";
|
|
6
5
|
import { assistantHttpClient, createAssistantApi } from "@jskit-ai/assistant-core/client";
|
|
7
|
-
import {
|
|
8
|
-
conversationTurnMessages, conversationTurnsFromMessages, latestAssistantMessageAwaitingUserReply, mergeConversationLogPages, mergeConversationStream,
|
|
9
|
-
normalizeConversationLogPage, normalizeConversationLogPagination
|
|
10
|
-
} from "@jskit-ai/assistant-core/shared/conversation";
|
|
6
|
+
import { conversationTurnsFromMessages } from "@jskit-ai/assistant-core/shared/conversation";
|
|
11
7
|
import { useRealtimeSocket } from "@jskit-ai/realtime/client/composables/useRealtimeEvent";
|
|
12
8
|
import { SHELL_WEB_PLACEMENT_RUNTIME_KEY } from "@jskit-ai/shell-web/client/placement";
|
|
13
9
|
import { useSurfaceRouteContext } from "@jskit-ai/shell-web/client/navigation/useSurfaceRouteContext";
|
|
14
10
|
import { resolveAssistantRuntimeRoute, resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
|
|
15
11
|
import { useWorkspaceWebScopeSupport } from "../support/workspaceScopeSupport.js";
|
|
16
|
-
import {
|
|
17
|
-
import { subscribeAssistantConversation, refreshedHistoryPages, patchLoadedHistoryPages } from "../support/subscribeAssistantConversation.js";
|
|
12
|
+
import { failureMessage } from "../support/conversationFailures.js";
|
|
18
13
|
import { useJskitText } from "../i18n.js";
|
|
14
|
+
import { authoredRequest, createConversationRuntime, isQuestionConfiguration, resolveWorkingPolicy, text } from "./conversationRuntime.js";
|
|
19
15
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
16
|
+
/** @typedef {import("@jskit-ai/assistant-core/client/types").AssistantApi} AssistantApi */
|
|
17
|
+
/** @typedef {import("vue").MaybeRefOrGetter<string>} TextOption */
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The application defaults `configureAssistantConversations` provides to every binding.
|
|
21
|
+
* @typedef {object} ConversationDefaults
|
|
22
|
+
* @property {import("vue").MaybeRefOrGetter<string | undefined>} actorKey
|
|
23
|
+
* @property {AssistantApi | null | undefined} api
|
|
24
|
+
* @property {(path: string, options: object) => Promise<any>} request
|
|
25
|
+
* @property {"dispatch" | "accepted"} clearDraftOn
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* An application command whose assistant returns one final result: the bounded task mode of a binding.
|
|
30
|
+
* @typedef {object} BoundedTask
|
|
31
|
+
* @property {{ run: (request: { path: string, payload: object }) => Promise<any>, isRunning: import("vue").MaybeRefOrGetter<boolean> }} command
|
|
32
|
+
* @property {TextOption} endpoint
|
|
33
|
+
* @property {import("vue").MaybeRefOrGetter<unknown>} scope The application scope a result belongs to.
|
|
34
|
+
* @property {(messages: object[]) => object} input
|
|
35
|
+
* @property {(response: any) => object} result
|
|
36
|
+
* @property {(response: any, submitted: { message: object, scope: unknown }) => void} [onResult]
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* What `useAssistantConversation` and `useAssistantConversationFactory` accept; the assistant conversation guide
|
|
41
|
+
* describes each option.
|
|
42
|
+
* @typedef {object} ConversationBindingOptions
|
|
43
|
+
* @property {TextOption} [conversationId]
|
|
44
|
+
* @property {TextOption} [endpoint]
|
|
45
|
+
* @property {TextOption} [surfaceId]
|
|
46
|
+
* @property {TextOption} [hostSurfaceId]
|
|
47
|
+
* @property {import("vue").MaybeRefOrGetter<string | undefined>} [workspaceSlug]
|
|
48
|
+
* @property {import("vue").MaybeRefOrGetter<string | undefined>} [actorKey]
|
|
49
|
+
* @property {AssistantApi | null} [api]
|
|
50
|
+
* @property {import("../types.js").RealtimeSocket} [socket]
|
|
51
|
+
* @property {import("vue").MaybeRefOrGetter<boolean>} [active]
|
|
52
|
+
* @property {(event: object) => unknown} [onEvent]
|
|
53
|
+
* @property {"dispatch" | "accepted"} [clearDraftOn]
|
|
54
|
+
* @property {boolean} [queueWhileSending]
|
|
55
|
+
* @property {boolean | ((snapshot: any) => boolean)} [deferWhileWorking]
|
|
56
|
+
* @property {boolean | ((snapshot: any) => boolean)} [admitWhileWorking]
|
|
57
|
+
* @property {boolean} [draftWhileLoading]
|
|
58
|
+
* @property {import("vue").MaybeRefOrGetter<{ storage: Storage, key: string } | null | undefined>} [draftStorage]
|
|
59
|
+
* @property {((runtime: import("../types.js").ConversationRuntime) => unknown) | null} [application]
|
|
60
|
+
* @property {BoundedTask | null} [boundedTask]
|
|
61
|
+
* @property {import("vue").MaybeRefOrGetter<unknown>} [data]
|
|
62
|
+
* @property {import("vue").MaybeRefOrGetter<import("@jskit-ai/assistant-core/client/types").AssistantAttachments | null | undefined>} [attachments]
|
|
63
|
+
* @property {import("vue").MaybeRefOrGetter<import("@jskit-ai/assistant-core/client/types").AssistantSuggestions | null | undefined>} [suggestions]
|
|
64
|
+
* @property {import("vue").MaybeRefOrGetter<import("@jskit-ai/assistant-core/client/types").AssistantModelChoices | null | undefined>} [models]
|
|
65
|
+
* @property {import("vue").MaybeRefOrGetter<boolean | object | null | undefined>} [questions]
|
|
66
|
+
* @property {import("vue").MaybeRefOrGetter<boolean | object | null | undefined>} [goal]
|
|
67
|
+
* @property {import("vue").MaybeRefOrGetter<Record<string, any> | undefined>} [presentation]
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What a binding takes from the application's setup. A bounded task takes only the application.
|
|
72
|
+
* @typedef {object} ConversationBindingSetup
|
|
73
|
+
* @property {import("vue").App} app
|
|
74
|
+
* @property {ConversationDefaults} defaults
|
|
75
|
+
* @property {import("../types.js").SurfaceRouteContext} [routeContext]
|
|
76
|
+
* @property {import("../types.js").PlacementRuntime} [placement]
|
|
77
|
+
* @property {import("../types.js").WorkspaceScopeSupport} [workspaceScope]
|
|
78
|
+
* @property {import("../types.js").RealtimeSocket} [socket]
|
|
79
|
+
* @property {Record<string, any>} [config]
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
const { t } = useJskitText();
|
|
83
|
+
/**
|
|
84
|
+
* A conversation snapshot's error, which names its runtime sentence with errorMessageCode.
|
|
85
|
+
* @param {any} state
|
|
86
|
+
*/
|
|
27
87
|
const stateError = state => failureMessage({ message: state?.error,
|
|
28
88
|
messageCode: state?.errorMessageCode, messageParams: state?.errorMessageParams });
|
|
29
89
|
// Keep the injection key stable across optimized package and raw Vue imports.
|
|
30
90
|
const conversationDefaultsKey = Symbol.for("jskit.assistant-runtime.conversation.defaults");
|
|
31
|
-
const compareTurnIds = (left, right) => String(left).localeCompare(String(right), undefined, { numeric: true });
|
|
32
91
|
|
|
33
|
-
/**
|
|
34
|
-
|
|
92
|
+
/**
|
|
93
|
+
* An application that configures nothing: the placement user, the standard API, and Send clears the draft.
|
|
94
|
+
* @type {ConversationDefaults}
|
|
95
|
+
*/
|
|
96
|
+
const NO_CONVERSATION_DEFAULTS = Object.freeze({
|
|
97
|
+
actorKey: undefined,
|
|
98
|
+
api: undefined,
|
|
99
|
+
request: assistantHttpClient.request,
|
|
100
|
+
clearDraftOn: "dispatch"
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
/** A submission with no further lifetime guard of its own. */
|
|
104
|
+
function alwaysCurrent() {
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
/** A submission that shows no pending state of its own. */
|
|
108
|
+
function ignoreSubmit() {}
|
|
109
|
+
/** A submission whose acceptance needs no further cleanup. */
|
|
110
|
+
function ignoreAcceptance() {}
|
|
111
|
+
/** A bounded task result the application does not present itself. */
|
|
112
|
+
function ignoreResult() {}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Sets this Vue application's conversation binding defaults before mounting.
|
|
116
|
+
* @param {import("vue").App} app
|
|
117
|
+
* @param {{ actorKey?: import("vue").MaybeRefOrGetter<string | undefined>, api?: AssistantApi,
|
|
118
|
+
* request?: (path: string, options: object) => Promise<any>, clearDraftOn?: "dispatch" | "accepted" }} [defaults]
|
|
119
|
+
*/
|
|
120
|
+
function configureAssistantConversations(app, { actorKey, api, request = assistantHttpClient.request, clearDraftOn = "dispatch" } = {}) {
|
|
121
|
+
// readable-check: allow method-probe - input validation: an application hands configureAssistantConversations its Vue application
|
|
35
122
|
if (typeof app?.provide !== "function") throw new TypeError("Conversation defaults require a Vue application.");
|
|
36
|
-
|
|
123
|
+
// readable-check: allow method-probe - input validation: an application may hand configureAssistantConversations its own request
|
|
124
|
+
if (typeof request !== "function") throw new TypeError("Conversation request must be a function.");
|
|
37
125
|
if (!["dispatch", "accepted"].includes(clearDraftOn)) throw new TypeError("clearDraftOn must be dispatch or accepted.");
|
|
38
126
|
app.provide(conversationDefaultsKey, Object.freeze({ actorKey, api, request, clearDraftOn }));
|
|
39
127
|
}
|
|
40
|
-
// These errors are raised before send admission by the action pipeline or
|
|
41
|
-
// admitSend. Provider, storage, attachment-resolution and transport errors can
|
|
42
|
-
// occur after dispatch and must retain an uncertain receipt.
|
|
43
|
-
const SEND_REJECTIONS = new Set([
|
|
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",
|
|
46
|
-
"conversation_message_cancelled"
|
|
47
|
-
]);
|
|
48
|
-
|
|
49
|
-
function isQuestionConfiguration(value) {
|
|
50
|
-
return value === true || Boolean(value && typeof value === "object" &&
|
|
51
|
-
!["questions", "choices", "capture"].some(key => key in value));
|
|
52
|
-
}
|
|
53
128
|
|
|
54
129
|
function questionsAnswered(prompts) {
|
|
55
130
|
return prompts.questions.every(question => String(prompts.answers[question.name] || "").trim());
|
|
56
131
|
}
|
|
57
132
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function draftAfterAcceptedSubmission(currentDraft = "", submittedDraft = "") {
|
|
69
|
-
const current = String(currentDraft || "");
|
|
70
|
-
const submitted = String(submittedDraft || "");
|
|
71
|
-
if (current === submitted) return "";
|
|
72
|
-
return submitted && current.startsWith(submitted) ? current.slice(submitted.length) : current;
|
|
133
|
+
/**
|
|
134
|
+
* Whether a question owner formats the draft as its answers.
|
|
135
|
+
* @param {any} prompts The conversation's own question form, an application's question owner, or none.
|
|
136
|
+
*/
|
|
137
|
+
function formatsAnswers(prompts) {
|
|
138
|
+
// readable-check: allow method-probe - optional operation: an application's own question owner may have no capture, and then the draft is sent as typed
|
|
139
|
+
return typeof prompts?.capture === "function";
|
|
73
140
|
}
|
|
74
141
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const admitted = resolve("admitWhileWorking", admitWhileWorking);
|
|
83
|
-
if (deferred && admitted) throw new TypeError("Choose either admitWhileWorking or deferWhileWorking.");
|
|
84
|
-
return { deferWhileWorking: deferred, admitWhileWorking: admitted };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function createConversation(identity, { api, socket, actorKey, placement, readers, queueWhileSending, deferWhileWorking, admitWhileWorking, draftStorage, application, goalReadEnabled }) {
|
|
88
|
-
const disposed = ref(false);
|
|
89
|
-
const placementRevision = ref(0);
|
|
90
|
-
const stopPlacement = placement.subscribe(() => { placementRevision.value += 1; });
|
|
91
|
-
const current = computed(() => {
|
|
92
|
-
placementRevision.value;
|
|
93
|
-
const actor = toValue(actorKey);
|
|
94
|
-
return !disposed.value && (actor === undefined ? String(placement.getContext()?.user?.id || "") : text(actor)) === identity.actorKey;
|
|
95
|
-
});
|
|
96
|
-
const active = computed(() => current.value && [...readers.values()].some(reader => toValue(reader.active) !== false));
|
|
97
|
-
const snapshot = shallowRef(null);
|
|
98
|
-
const draft = ref("");
|
|
99
|
-
const draftMessageId = ref("");
|
|
100
|
-
const draftAttachments = ref([]);
|
|
101
|
-
const draftRetry = ref(null);
|
|
102
|
-
const draftRetryMatches = computed(() => {
|
|
103
|
-
const retry = draftRetry.value;
|
|
104
|
-
const current = String(draft.value || "");
|
|
105
|
-
const submitted = String(retry?.draftSnapshot || "");
|
|
106
|
-
return Boolean(retry && submitted && (current === submitted || current.startsWith(submitted)));
|
|
107
|
-
});
|
|
108
|
-
let draftSubmissionSequence = 0;
|
|
109
|
-
const error = ref("");
|
|
110
|
-
const loading = ref(true);
|
|
111
|
-
const stopping = ref(false);
|
|
112
|
-
const goalAction = ref("");
|
|
113
|
-
// An accepted Set or Resume waiting for its goal keeps Pause and Cancel usable.
|
|
114
|
-
const goalSettling = ref(false);
|
|
115
|
-
const goalTargetCurrent = ref(false);
|
|
116
|
-
const goalError = ref("");
|
|
117
|
-
const goalView = shallowRef(null);
|
|
118
|
-
const goalLoadError = ref("");
|
|
119
|
-
const accessDenied = ref(false);
|
|
120
|
-
const delivery = createAssistantMessageDelivery();
|
|
121
|
-
const pendingMessages = new Map();
|
|
122
|
-
const deliveryChecks = new Map();
|
|
123
|
-
const olderPages = ref([]);
|
|
124
|
-
let historyRevision = 0;
|
|
125
|
-
let deniedHistoryRevision = 0;
|
|
126
|
-
const loadingMore = ref(false);
|
|
127
|
-
const loadMoreError = ref("");
|
|
128
|
-
const turns = computed(() => {
|
|
129
|
-
const saved = mergeConversationLogPages([
|
|
130
|
-
...olderPages.value, { conversationLog: snapshot.value?.conversationLog || [] }
|
|
131
|
-
]).conversationLog;
|
|
132
|
-
// Use the original overlay against the full loaded saved span. A synthetic
|
|
133
|
-
// live older turn must not replace its authored request or saved progress.
|
|
134
|
-
const messages = (snapshot.value?.turns || []).flatMap(conversationTurnMessages);
|
|
135
|
-
return mergeConversationStream(saved, { messages });
|
|
136
|
-
});
|
|
137
|
-
const oldestLoadedPage = computed(() => olderPages.value[0] || snapshot.value);
|
|
138
|
-
const hasMoreBefore = computed(() => normalizeConversationLogPagination(oldestLoadedPage.value?.pagination).hasMoreBefore);
|
|
139
|
-
const editable = computed(() => active.value && !accessDenied.value);
|
|
140
|
-
const available = computed(() => active.value && !loading.value && !accessDenied.value && Boolean(snapshot.value));
|
|
141
|
-
// A deferred message keeps the subscription while its view is hidden, so it
|
|
142
|
-
// is admitted against live state. A failed read leaves that state stale.
|
|
143
|
-
const deferredWaits = ref(0);
|
|
144
|
-
const listening = computed(() => active.value || current.value && deferredWaits.value > 0);
|
|
145
|
-
const admissible = computed(() => listening.value && !loading.value && !accessDenied.value && !error.value && Boolean(snapshot.value));
|
|
146
|
-
const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
|
|
147
|
-
const workingPolicy = computed(() => resolveWorkingPolicy(snapshot.value, { deferWhileWorking, admitWhileWorking }));
|
|
148
|
-
const queueing = computed(() => queueWhileSending !== false && (steerable.value || workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking));
|
|
149
|
-
const canSubmit = computed(() => available.value && !stopping.value && (snapshot.value.status === "ready" || steerable.value || (workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking) && snapshot.value.status === "working") &&
|
|
150
|
-
(queueWhileSending !== false || !delivery.state.sending) &&
|
|
151
|
-
!delivery.state.messages.some(message => message.status === "uncertain" &&
|
|
152
|
-
(snapshot.value.status !== "ready" || !message.payload || text(message.payload.draft ?? message.payload.message) === text(draft))));
|
|
153
|
-
const savedDraft = toValue(draftStorage);
|
|
154
|
-
if (savedDraft && (typeof savedDraft.key !== "string" || !savedDraft.key ||
|
|
155
|
-
!["getItem", "setItem", "removeItem"].every(name => typeof savedDraft.storage?.[name] === "function"))) {
|
|
156
|
-
throw new TypeError("draftStorage requires an explicit key and storage with getItem, setItem and removeItem.");
|
|
157
|
-
}
|
|
158
|
-
// Original composer state is restored verbatim. A read never reconstructs or
|
|
159
|
-
// sends an old request; the explicit retry keeps its original payload and ID.
|
|
160
|
-
try {
|
|
161
|
-
const saved = savedDraft ? JSON.parse(savedDraft.storage.getItem(savedDraft.key) || "null") : null;
|
|
162
|
-
draft.value = typeof saved?.draft === "string" ? saved.draft : "";
|
|
163
|
-
draftAttachments.value = Array.isArray(saved?.attachments)
|
|
164
|
-
? saved.attachments.filter(attachment => typeof attachment?.attachmentId === "string") : [];
|
|
165
|
-
const messages = Array.isArray(saved?.messages) ? saved.messages.filter(message =>
|
|
166
|
-
typeof message?.id === "string" && typeof message.text === "string" &&
|
|
167
|
-
typeof message.payload?.message === "string") : [];
|
|
168
|
-
const restoredMessages = messages.filter(message => !delivery.find(message.id)).map(message => {
|
|
169
|
-
const status = ["failed", "accepted"].includes(message.status) ? message.status : "uncertain";
|
|
170
|
-
return { ...message, status, checking: false, cancelling: false, error: message.error || (status === "accepted" ? "" :
|
|
171
|
-
t("jskit.assistant.runtime.delivery.unconfirmedBeforeClose")) };
|
|
172
|
-
});
|
|
173
|
-
delivery.state.messages = unmatchedOptimisticMessages(turns.value, [...delivery.state.messages, ...restoredMessages], { receiptsOnly: true });
|
|
174
|
-
// A cancel the server had not agreed to before this page closed is retried.
|
|
175
|
-
for (const message of messages) {
|
|
176
|
-
if (message.cancelling || message.cancelError) delivery.cancelFailed(message.id, message.cancelError);
|
|
177
|
-
}
|
|
178
|
-
} catch {
|
|
179
|
-
draft.value = "";
|
|
180
|
-
draftAttachments.value = [];
|
|
181
|
-
}
|
|
182
|
-
function persistDraft() {
|
|
183
|
-
if (!savedDraft || !current.value || accessDenied.value) return;
|
|
184
|
-
try {
|
|
185
|
-
const saved = { draft: draft.value,
|
|
186
|
-
attachments: draftAttachments.value.filter(attachment => attachment?.attachmentId),
|
|
187
|
-
messages: delivery.state.messages };
|
|
188
|
-
if (saved.draft || saved.attachments.length || saved.messages.length) {
|
|
189
|
-
savedDraft.storage.setItem(savedDraft.key, JSON.stringify(saved));
|
|
190
|
-
} else savedDraft.storage.removeItem(savedDraft.key);
|
|
191
|
-
} catch {
|
|
192
|
-
// The in-memory delivery UI remains usable if storage is full or blocked.
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
persistDraft();
|
|
196
|
-
watch([draft, draftAttachments, () => delivery.state.messages], persistDraft, { deep: true, flush: "sync" });
|
|
197
|
-
function settleDraftRetry(retry = draftRetry.value) {
|
|
198
|
-
if (!retry || retry.messageId !== draftRetry.value?.messageId) return false;
|
|
199
|
-
draft.value = draftAfterAcceptedSubmission(draft.value, retry.draftSnapshot);
|
|
200
|
-
draftRetry.value = null;
|
|
201
|
-
return true;
|
|
202
|
-
}
|
|
203
|
-
watch(turns, value => {
|
|
204
|
-
const retry = draftRetry.value;
|
|
205
|
-
if (retry && !unmatchedOptimisticMessages(value, [retry.optimistic], { receiptsOnly: true }).length) settleDraftRetry(retry);
|
|
206
|
-
});
|
|
207
|
-
const questionConfiguration = computed(() => {
|
|
208
|
-
for (const reader of readers.values()) {
|
|
209
|
-
const option = toValue(reader.questions);
|
|
210
|
-
if (toValue(reader.active) !== false && isQuestionConfiguration(option)) return option;
|
|
211
|
-
}
|
|
212
|
-
return null;
|
|
213
|
-
});
|
|
214
|
-
// The form belongs to the conversation, including while only voice retains it.
|
|
215
|
-
// Reader options control presentation; they do not retire its answer state.
|
|
216
|
-
const questions = useAssistantQuestions({
|
|
217
|
-
message: () => available.value ? latestAssistantMessageAwaitingUserReply(turns.value) : "",
|
|
218
|
-
extraChoice: () => questionConfiguration.value?.extraChoice || null
|
|
219
|
-
});
|
|
220
|
-
let subscription;
|
|
221
|
-
let goalRead = null;
|
|
222
|
-
let goalSegment = null;
|
|
223
|
-
let goalReadController = null;
|
|
224
|
-
let goalReadQueued = false;
|
|
225
|
-
let goalCommandSequence = 0;
|
|
226
|
-
const readsGoals = computed(() => goalReadEnabled || snapshot.value?.capabilities?.goals === true ||
|
|
227
|
-
[...readers.values()].some(reader => toValue(reader.goal) === true));
|
|
228
|
-
watch(readsGoals, enabled => { if (enabled) void refreshGoal(); });
|
|
229
|
-
|
|
230
|
-
function receiveState(state, { initial = false, canonical = false } = {}) {
|
|
231
|
-
if (!current.value) return;
|
|
232
|
-
const previous = snapshot.value;
|
|
233
|
-
const goalChanged = JSON.stringify([previous?.goal, previous?.capabilities?.goals,
|
|
234
|
-
previous?.capabilities?.goalCommands, previous?.capabilities?.goalBudgets]) !==
|
|
235
|
-
JSON.stringify([state.goal, state.capabilities?.goals, state.capabilities?.goalCommands, state.capabilities?.goalBudgets]);
|
|
236
|
-
if (canonical) {
|
|
237
|
-
historyRevision += 1;
|
|
238
|
-
olderPages.value = initial ? [] : state[refreshedHistoryPages] || [];
|
|
239
|
-
loadMoreError.value = "";
|
|
240
|
-
} else if (olderPages.value.length) {
|
|
241
|
-
// Transcript patches may evict the head of the bounded live page before
|
|
242
|
-
// its canonical read completes. Retain saved rows, never display overlays.
|
|
243
|
-
const first = state.conversationLog?.[0]?.turnId;
|
|
244
|
-
const evicted = first ? (previous?.conversationLog || []).filter(turn => compareTurnIds(turn.turnId, first) < 0) : [];
|
|
245
|
-
if (evicted.length) {
|
|
246
|
-
historyRevision += 1;
|
|
247
|
-
olderPages.value = [...olderPages.value, normalizeConversationLogPage({ conversationLog: evicted })];
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
|
-
snapshot.value = state;
|
|
251
|
-
loading.value = false;
|
|
252
|
-
error.value = "";
|
|
253
|
-
accessDenied.value = false;
|
|
254
|
-
delivery.reconcile(state.turns);
|
|
255
|
-
if (state.status === "unconfirmed" && state.pendingRequest) delivery.restoreUncertain(state.pendingRequest, state.turns);
|
|
256
|
-
retryCancels();
|
|
257
|
-
if (state.segmentId !== goalSegment) {
|
|
258
|
-
// Keep the open goal menu while its first native identity arrives. The
|
|
259
|
-
// pending read disables commands until it supplies the new exact target.
|
|
260
|
-
goalTargetCurrent.value = false;
|
|
261
|
-
goalLoadError.value = "";
|
|
262
|
-
goalReadController?.abort();
|
|
263
|
-
}
|
|
264
|
-
if (readsGoals.value && (initial || goalChanged || state.segmentId !== goalSegment)) void refreshGoal();
|
|
265
|
-
goalSegment = state.segmentId;
|
|
266
|
-
}
|
|
267
|
-
function receiveError(failure) {
|
|
268
|
-
if (!current.value) return;
|
|
269
|
-
loading.value = false;
|
|
270
|
-
error.value = failureMessage(failure);
|
|
271
|
-
accessDenied.value = isAccessDenied(failure);
|
|
272
|
-
if (accessDenied.value) {
|
|
273
|
-
deniedHistoryRevision += 1;
|
|
274
|
-
clearConversationView();
|
|
275
|
-
goalTargetCurrent.value = false;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
// Shared by access denial and actor change. Access denial keeps the open goal
|
|
279
|
-
// command, queued goal read and question form; an actor change clears them.
|
|
280
|
-
function clearConversationView() {
|
|
281
|
-
delivery.reset();
|
|
282
|
-
snapshot.value = null;
|
|
283
|
-
historyRevision += 1;
|
|
284
|
-
olderPages.value = [];
|
|
285
|
-
draft.value = "";
|
|
286
|
-
draftMessageId.value = "";
|
|
287
|
-
draftAttachments.value = [];
|
|
288
|
-
draftRetry.value = null;
|
|
289
|
-
goalError.value = "";
|
|
290
|
-
goalView.value = null;
|
|
291
|
-
goalLoadError.value = "";
|
|
292
|
-
goalReadController?.abort();
|
|
293
|
-
}
|
|
294
|
-
function receiveEvent(event) {
|
|
295
|
-
if (event.type === "transcript" && olderPages.value.some(page =>
|
|
296
|
-
page.conversationLog.some(turn => turn.turnId === event.patch?.turn?.turnId))) {
|
|
297
|
-
historyRevision += 1;
|
|
298
|
-
olderPages.value = patchLoadedHistoryPages(olderPages.value, event.patch);
|
|
299
|
-
}
|
|
300
|
-
if (event.type === "goal") void refreshGoal();
|
|
301
|
-
for (const [token, reader] of [...readers]) {
|
|
302
|
-
if (!current.value) return;
|
|
303
|
-
if (!readers.has(token) || toValue(reader.active) === false || typeof reader.onEvent !== "function") continue;
|
|
304
|
-
// Presentation hooks own their errors; one reader must not interrupt
|
|
305
|
-
// another reader or the subscription's canonical state and delivery.
|
|
306
|
-
try { void Promise.resolve(reader.onEvent(event)).catch(() => {}); }
|
|
307
|
-
catch {}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
async function readLoadedHistory({ current: subscriptionCurrent }) {
|
|
311
|
-
const denial = deniedHistoryRevision;
|
|
312
|
-
const valid = () => {
|
|
313
|
-
if (denial !== deniedHistoryRevision) {
|
|
314
|
-
throw Object.assign(new Error("Access denied."), { status: 403, messageCode: "assistant.runtime.accessDenied" });
|
|
315
|
-
}
|
|
316
|
-
return current.value && listening.value && subscriptionCurrent();
|
|
317
|
-
};
|
|
318
|
-
// A concurrent Load older operation extends the desired span. Retry one
|
|
319
|
-
// refresh in this same read, rather than dropping that newly loaded page.
|
|
320
|
-
while (valid()) {
|
|
321
|
-
const revision = historyRevision;
|
|
322
|
-
const oldestTurn = olderPages.value[0]?.conversationLog?.[0];
|
|
323
|
-
const oldest = oldestTurn?.turnId;
|
|
324
|
-
const oldestCursor = olderPages.value[0]?.pagination?.nextBeforeTurnId || oldest;
|
|
325
|
-
const latest = await api.readConversation(identity.conversationId);
|
|
326
|
-
if (!valid()) return latest;
|
|
327
|
-
const pages = [];
|
|
328
|
-
let page = normalizeConversationLogPage(latest);
|
|
329
|
-
const cursors = new Set();
|
|
330
|
-
while (oldest && page.pagination.hasMoreBefore) {
|
|
331
|
-
const cursor = page.pagination.nextBeforeTurnId || page.pagination.oldestTurnId;
|
|
332
|
-
const reachedFullOldestTurn = page.pagination.oldestTurnId === oldest && !page.conversationLog[0]?.paginationPartial;
|
|
333
|
-
const passedOldestTurn = compareTurnIds(page.pagination.oldestTurnId, oldest) < 0;
|
|
334
|
-
if (cursor && (cursor === oldestCursor || passedOldestTurn || reachedFullOldestTurn)) break;
|
|
335
|
-
if (!cursor || cursors.has(cursor)) {
|
|
336
|
-
throw Object.assign(new Error("Older conversation history could not be refreshed. Reload chat to try again."), {
|
|
337
|
-
messageCode: "assistant.runtime.history.refreshFailed"
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
cursors.add(cursor);
|
|
341
|
-
page = normalizeConversationLogPage(await api.readConversation(identity.conversationId, {
|
|
342
|
-
beforeTurnId: cursor, limit: latest.pagination.limit
|
|
343
|
-
}));
|
|
344
|
-
if (!valid()) return latest;
|
|
345
|
-
// A removed oldest row can make this page reach farther back than the
|
|
346
|
-
// reader loaded. Keep the original visible boundary and page metadata.
|
|
347
|
-
const saved = page.conversationLog.filter(turn => compareTurnIds(turn.turnId, oldest) >= 0).map(turn => {
|
|
348
|
-
if (turn.turnId !== oldest || !oldestTurn.paginationPartial) return turn;
|
|
349
|
-
const roles = ["system", "user", "assistant"];
|
|
350
|
-
const excluded = roles.slice(0, roles.findIndex(role => oldestTurn[role]));
|
|
351
|
-
return { ...turn, paginationPartial: true,
|
|
352
|
-
...Object.fromEntries(excluded.map(role => [role, null])),
|
|
353
|
-
messages: turn.messages?.filter(message => !excluded.includes(message.role)) };
|
|
354
|
-
});
|
|
355
|
-
pages.unshift(normalizeConversationLogPage({ ...page, conversationLog: saved, pagination: {
|
|
356
|
-
...page.pagination, count: saved.length, oldestTurnId: saved[0]?.turnId || "",
|
|
357
|
-
hasMoreBefore: page.pagination.hasMoreBefore || saved.length < page.conversationLog.length
|
|
358
|
-
} }));
|
|
359
|
-
}
|
|
360
|
-
if (revision !== historyRevision) continue;
|
|
361
|
-
return { ...latest, [refreshedHistoryPages]: pages.filter(page => page.conversationLog.length) };
|
|
362
|
-
}
|
|
363
|
-
return null;
|
|
364
|
-
}
|
|
365
|
-
watch(listening, enabled => {
|
|
366
|
-
subscription?.();
|
|
367
|
-
subscription = null;
|
|
368
|
-
historyRevision += 1;
|
|
369
|
-
if (!enabled) return;
|
|
370
|
-
loading.value = true;
|
|
371
|
-
subscription = subscribeAssistantConversation({ socket, conversationId: identity.conversationId,
|
|
372
|
-
targetSurfaceId: identity.targetSurfaceId, hostSurfaceId: identity.hostSurfaceId,
|
|
373
|
-
...(identity.workspaceSlug ? { workspaceSlug: identity.workspaceSlug } : {}),
|
|
374
|
-
read: readLoadedHistory, onState: receiveState,
|
|
375
|
-
onError: receiveError, onEvent: receiveEvent
|
|
376
|
-
});
|
|
377
|
-
}, { immediate: true, flush: "sync" });
|
|
378
|
-
watch(current, value => {
|
|
379
|
-
if (value) return;
|
|
380
|
-
for (const pending of pendingMessages.values()) if (!pending.dispatched) pending.controller.abort();
|
|
381
|
-
clearConversationView();
|
|
382
|
-
goalAction.value = "";
|
|
383
|
-
goalSettling.value = false;
|
|
384
|
-
goalReadQueued = false;
|
|
385
|
-
questions.reset();
|
|
386
|
-
}, { flush: "sync" });
|
|
387
|
-
onScopeDispose(() => {
|
|
388
|
-
disposed.value = true;
|
|
389
|
-
subscription?.();
|
|
390
|
-
stopPlacement();
|
|
391
|
-
for (const pending of pendingMessages.values()) pending.controller.abort();
|
|
392
|
-
pendingMessages.clear();
|
|
393
|
-
for (const check of deliveryChecks.values()) check.abort();
|
|
394
|
-
goalReadController?.abort();
|
|
395
|
-
delivery.reset();
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
async function send(payload, { messageId = crypto.randomUUID(), onAccepted } = {}) {
|
|
399
|
-
const retry = delivery.find(messageId);
|
|
400
|
-
if (retry?.status === "failed" && retry.payload.goalRequest) return changeGoal(retry.payload.goalRequest.action, {}, { messageId });
|
|
401
|
-
if (!canSubmit.value) return false;
|
|
402
|
-
if (retry && retry.status !== "failed") return false;
|
|
403
|
-
if (delivery.state.messages.some(message => message.status === "uncertain" &&
|
|
404
|
-
text(message.payload?.message) === text((retry?.payload || payload).message))) return false;
|
|
405
|
-
const request = retry?.payload?.request || payload.request;
|
|
406
|
-
const requestedSteering = request?.steer === true;
|
|
407
|
-
if (requestedSteering && snapshot.value.capabilities?.steering !== true) return false;
|
|
408
|
-
const steering = requestedSteering || steerable.value;
|
|
409
|
-
const policy = workingPolicy.value;
|
|
410
|
-
const deferred = policy.deferWhileWorking && !retry && !steering;
|
|
411
|
-
// An application queue can admit ordinary messages without native steering
|
|
412
|
-
// or waiting for ready. It still owns admission, permissions and receipts.
|
|
413
|
-
const appAdmission = policy.admitWhileWorking && !steering;
|
|
414
|
-
if ((snapshot.value.status === "working" || delivery.state.sending) && !steering && !deferred && !appAdmission) return false;
|
|
415
|
-
if ((retry?.payload || payload).displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) return false;
|
|
416
|
-
const captured = retry?.payload?.request ? retry.payload : { ...(retry?.payload || payload), request: payload.request ||
|
|
417
|
-
authoredRequest(payload, {
|
|
418
|
-
attachmentIds: payload.displayAttachments?.map(file => file.attachmentId),
|
|
419
|
-
steer: steerable.value
|
|
420
|
-
}) };
|
|
421
|
-
return submitRequest(captured, { messageId, onAccepted, queue: queueWhileSending !== false && (steering || deferred || appAdmission), deferred, appAdmission });
|
|
422
|
-
}
|
|
423
|
-
function submitPrepared(payload, { messageId = crypto.randomUUID(), onAccepted, prepare } = {}) {
|
|
424
|
-
if (typeof prepare !== "function") throw new TypeError("Prepared submission requires application preparation.");
|
|
425
|
-
const retry = delivery.find(messageId);
|
|
426
|
-
const captured = retry?.payload || payload;
|
|
427
|
-
if (!captured?.request || typeof captured.request !== "object" || Array.isArray(captured.request) || captured.goalRequest) {
|
|
428
|
-
throw new TypeError("Prepared submission requires an authored conversation request.");
|
|
429
|
-
}
|
|
430
|
-
if (!current.value || accessDenied.value || retry && retry.status !== "failed") return Promise.resolve(false);
|
|
431
|
-
return submitRequest(captured, { messageId, onAccepted, prepare, queue: queueWhileSending === true });
|
|
432
|
-
}
|
|
433
|
-
async function submitDraft(payload, { messageId = crypto.randomUUID(), submissionKind = steerable.value ? "steer" : "send",
|
|
434
|
-
questionText = "", onAccepted } = {}) {
|
|
435
|
-
if (!canSubmit.value) return false;
|
|
436
|
-
const retry = draftRetryMatches.value ? draftRetry.value : null;
|
|
437
|
-
if (!retry) draftRetry.value = null;
|
|
438
|
-
const draftSnapshot = retry?.draftSnapshot || draft.value;
|
|
439
|
-
const captured = retry?.payload?.request ? retry.payload : payload;
|
|
440
|
-
const kind = retry?.submissionKind || submissionKind;
|
|
441
|
-
const questionTextSnapshot = retry?.questionTextSnapshot || questionText;
|
|
442
|
-
const id = retry?.messageId || messageId;
|
|
443
|
-
const sequence = ++draftSubmissionSequence;
|
|
444
|
-
draftRetry.value = null;
|
|
445
|
-
draft.value = retry ? draftAfterAcceptedSubmission(draft.value, draftSnapshot) : "";
|
|
446
|
-
const result = await send(captured, { messageId: id, onAccepted });
|
|
447
|
-
if (!current.value) return false;
|
|
448
|
-
const accepted = result !== false && result?.ok !== false;
|
|
449
|
-
if (!accepted && kind === "steer" && sequence === draftSubmissionSequence && !draft.value) {
|
|
450
|
-
draft.value = draftSnapshot;
|
|
451
|
-
const optimistic = delivery.find(id) || {
|
|
452
|
-
createdAtMs: Date.now(), id, text: String(captured.displayMessage || captured.message || "").trim()
|
|
453
|
-
};
|
|
454
|
-
draftRetry.value = { draftSnapshot, messageId: id, optimistic, payload: optimistic.payload || captured,
|
|
455
|
-
questionTextSnapshot, submissionKind: kind };
|
|
456
|
-
if (!unmatchedOptimisticMessages(turns.value, [optimistic], { receiptsOnly: true }).length) settleDraftRetry(draftRetry.value);
|
|
457
|
-
}
|
|
458
|
-
return result;
|
|
459
|
-
}
|
|
460
|
-
// A deferred message waits only while the working turn cannot take it. It
|
|
461
|
-
// steers as soon as that turn accepts steering, or sends once it is ready.
|
|
462
|
-
// Its view may be hidden. While loading or after a failed read there is no
|
|
463
|
-
// current state to admit it against, so the unsent message keeps waiting;
|
|
464
|
-
// only Stop, Cancel, retirement or lost access end it.
|
|
465
|
-
function waitForAdmission({ controller }) {
|
|
466
|
-
return new Promise(resolve => {
|
|
467
|
-
let stop;
|
|
468
|
-
const finish = admission => {
|
|
469
|
-
stop?.();
|
|
470
|
-
controller.signal.removeEventListener("abort", check);
|
|
471
|
-
resolve(admission);
|
|
472
|
-
};
|
|
473
|
-
const check = () => {
|
|
474
|
-
if (controller.signal.aborted || accessDenied.value) finish(false);
|
|
475
|
-
else if (!admissible.value) return;
|
|
476
|
-
else if (snapshot.value.status === "ready") finish("send");
|
|
477
|
-
else if (steerable.value) finish("steer");
|
|
478
|
-
else if (snapshot.value.status !== "working") finish(false);
|
|
479
|
-
};
|
|
480
|
-
stop = watch([admissible, snapshot, steerable], check, { flush: "sync" });
|
|
481
|
-
controller.signal.addEventListener("abort", check, { once: true });
|
|
482
|
-
check();
|
|
483
|
-
});
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
async function submitRequest(payload, { messageId, onAccepted, queue = false, prepare, deferred = false, appAdmission = false }) {
|
|
487
|
-
if (pendingMessages.has(messageId)) return false;
|
|
488
|
-
// Register before the original serial tail so Stop/retirement also reaches
|
|
489
|
-
// a local follower whose deliver callback has not started yet.
|
|
490
|
-
const pending = { controller: new AbortController(), started: false, dispatched: false };
|
|
491
|
-
const { controller } = pending;
|
|
492
|
-
pendingMessages.set(messageId, pending);
|
|
493
|
-
if (deferred) deferredWaits.value += 1;
|
|
494
|
-
try {
|
|
495
|
-
const result = await delivery.send(payload, {
|
|
496
|
-
// A goal command settles on the server's goal answer, not on its
|
|
497
|
-
// transcript turn, which can arrive while its goal is still changing.
|
|
498
|
-
messageId, queue, isCurrent: () => current.value, onAccepted,
|
|
499
|
-
receiptTurns: payload.goalRequest ? null : turns, uncertainOnError: true,
|
|
500
|
-
async deliver(submission) {
|
|
501
|
-
pending.started = true;
|
|
502
|
-
let admission = null;
|
|
503
|
-
try {
|
|
504
|
-
if (controller.signal.aborted || appAdmission && (!available.value || stopping.value || !workingPolicy.value.admitWhileWorking)) return false;
|
|
505
|
-
if (deferred) {
|
|
506
|
-
// Refresh through the same subscription after a queued predecessor;
|
|
507
|
-
// its receipt may have arrived before the working snapshot.
|
|
508
|
-
await subscription?.reload();
|
|
509
|
-
if (snapshot.value?.status === "working" && !steerable.value) {
|
|
510
|
-
delivery.wait(messageId, t("jskit.assistant.runtime.delivery.waitingForStep"));
|
|
511
|
-
}
|
|
512
|
-
admission = await waitForAdmission(pending);
|
|
513
|
-
delivery.wait(messageId, "");
|
|
514
|
-
if (!admission || controller.signal.aborted || !admissible.value ||
|
|
515
|
-
snapshot.value.status !== (admission === "steer" ? "working" : "ready")) return false;
|
|
516
|
-
if (submission.displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) {
|
|
517
|
-
return { ok: false, error: t("jskit.assistant.runtime.attachments.noLongerAccepted") };
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
let request = prepare ? await prepare(submission, { signal: controller.signal }) : submission.request;
|
|
521
|
-
if (admission === "steer") {
|
|
522
|
-
request = { ...request, steer: true };
|
|
523
|
-
const retained = delivery.find(messageId);
|
|
524
|
-
if (retained) retained.payload.request = request;
|
|
525
|
-
}
|
|
526
|
-
if (prepare) {
|
|
527
|
-
if (!current.value || accessDenied.value || controller.signal.aborted || request === false) return false;
|
|
528
|
-
if (!request || typeof request !== "object" || Array.isArray(request)) {
|
|
529
|
-
throw new TypeError("Application preparation must return its conversation request or false.");
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
pending.dispatched = true;
|
|
533
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
534
|
-
try {
|
|
535
|
-
return await (submission.goalRequest
|
|
536
|
-
? api.updateConversationGoal(identity.conversationId, { ...submission.goalRequest, messageId }, { signal: controller.signal })
|
|
537
|
-
: api.sendConversationMessage(identity.conversationId, { ...request, messageId }, { signal: controller.signal }));
|
|
538
|
-
} catch (failure) {
|
|
539
|
-
// The server identifies this exact pre-admission race. Keep the
|
|
540
|
-
// same queued words/attachments/identity and try ordinary Send
|
|
541
|
-
// once. A timeout, generic rejection or uncertain send never
|
|
542
|
-
// grants another dispatch.
|
|
543
|
-
if (attempt !== 0 || submission.goalRequest || request.steer !== true ||
|
|
544
|
-
failure.code !== "conversation_steer_turn_finished" || failure.status !== 409 ||
|
|
545
|
-
failure.details?.delivery && (failure.details.delivery.status !== "not-sent" ||
|
|
546
|
-
failure.details.delivery.messageId !== messageId)) throw failure;
|
|
547
|
-
const retained = delivery.find(messageId);
|
|
548
|
-
if (retained?.status === "accepted" || turns.value.some(turn => turn.user?.messageId === messageId && turn.user.receipt !== false)) return { ok: true };
|
|
549
|
-
if (!current.value || accessDenied.value || controller.signal.aborted || !retained || retained.status !== "pending") return false;
|
|
550
|
-
const { steer: _finishedTurn, ...nextRequest } = request;
|
|
551
|
-
request = nextRequest;
|
|
552
|
-
// Retain the new dispatch intent if Send itself fails; Retry
|
|
553
|
-
// must not loop back through the obsolete steering request.
|
|
554
|
-
retained.payload.request = nextRequest;
|
|
555
|
-
retained.payload.submissionKind = "send";
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
catch (failure) {
|
|
560
|
-
if (controller.signal.aborted) return false;
|
|
561
|
-
if (prepare && !pending.dispatched) return { ok: false, error: failureMessage(failure),
|
|
562
|
-
...(failure.code ? { code: failure.code } : {}) };
|
|
563
|
-
const rejection = failure.details?.delivery;
|
|
564
|
-
const notSent = Number.isInteger(failure.status) && failure.status >= 400 && failure.status < 500 &&
|
|
565
|
-
rejection && !Array.isArray(rejection) && rejection.status === "not-sent" && rejection.messageId === messageId;
|
|
566
|
-
// A definite rejection keeps its code so the application can offer
|
|
567
|
-
// the matching recovery, such as freeing assistant context.
|
|
568
|
-
if (notSent || SEND_REJECTIONS.has(failure.code) || failure.code === "conversation_steer_turn_finished" && failure.status === 409 && !failure.details?.delivery) return { ok: false, error: failureMessage(failure),
|
|
569
|
-
...(failure.code ? { code: failure.code } : {}) };
|
|
570
|
-
throw failure;
|
|
571
|
-
} finally {
|
|
572
|
-
if (pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
});
|
|
576
|
-
return result;
|
|
577
|
-
} catch (failure) {
|
|
578
|
-
// Delivery keeps its exact authored payload until an explicit receipt check.
|
|
579
|
-
if (prepare) return { ok: false, status: "uncertain", error: failureMessage(failure),
|
|
580
|
-
...(failure.code ? { code: failure.code } : {}) };
|
|
581
|
-
return false;
|
|
582
|
-
} finally {
|
|
583
|
-
if (!pending.started && pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
|
|
584
|
-
if (deferred) deferredWaits.value -= 1;
|
|
585
|
-
if (current.value) subscription?.reload();
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
// Cancel means cancel, and the server decides. Cancel ends the message's
|
|
589
|
-
// unsent request and running delivery check, then asks the server, which
|
|
590
|
-
// always records the cancel. The message stays, shown as cancelling, until
|
|
591
|
-
// the server agrees; when the server cannot be reached it stays with Cancel
|
|
592
|
-
// again, and the next snapshot after reconnecting retries it.
|
|
593
|
-
async function cancelMessage(messageId) {
|
|
594
|
-
pendingMessages.get(messageId)?.controller.abort();
|
|
595
|
-
deliveryChecks.get(messageId)?.abort();
|
|
596
|
-
if (!available.value || !delivery.cancel(messageId)) return false;
|
|
597
|
-
try {
|
|
598
|
-
const result = await api.cancelConversationDelivery(identity.conversationId, messageId);
|
|
599
|
-
if (result?.ok === false) throw Object.assign(new Error(resultError(result)), { status: 409 });
|
|
600
|
-
if (!current.value) return false;
|
|
601
|
-
delivery.remove(messageId);
|
|
602
|
-
subscription?.reload();
|
|
603
|
-
return true;
|
|
604
|
-
} catch (failure) {
|
|
605
|
-
// The server's own refusal names itself; anything else did not reach it.
|
|
606
|
-
if (current.value) delivery.cancelFailed(messageId, Number.isInteger(failure?.status) && failure.status > 0 ? failureMessage(failure) : "");
|
|
607
|
-
return false;
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
function retryCancels() {
|
|
611
|
-
for (const message of delivery.state.messages) {
|
|
612
|
-
if (message.cancelError && !message.cancelling) void cancelMessage(message.id);
|
|
613
|
-
}
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
// The original history control keeps older pages separate from the live page.
|
|
617
|
-
async function loadMore({ complete } = {}) {
|
|
618
|
-
const finish = (result) => {
|
|
619
|
-
if (typeof complete === "function") complete(result);
|
|
620
|
-
};
|
|
621
|
-
const pagination = normalizeConversationLogPagination(oldestLoadedPage.value?.pagination);
|
|
622
|
-
const beforeTurnId = pagination.nextBeforeTurnId || pagination.oldestTurnId ||
|
|
623
|
-
String(turns.value[0]?.turnId || "").trim();
|
|
624
|
-
if (!available.value || !hasMoreBefore.value || loadingMore.value || !beforeTurnId) {
|
|
625
|
-
finish({ changed: false, loaded: false });
|
|
626
|
-
return false;
|
|
627
|
-
}
|
|
628
|
-
const revision = historyRevision;
|
|
629
|
-
loadingMore.value = true;
|
|
630
|
-
loadMoreError.value = "";
|
|
631
|
-
try {
|
|
632
|
-
const page = await api.readConversation(identity.conversationId, {
|
|
633
|
-
beforeTurnId, limit: snapshot.value.pagination.limit
|
|
634
|
-
});
|
|
635
|
-
if (!available.value || revision !== historyRevision) {
|
|
636
|
-
finish({ changed: false, loaded: false });
|
|
637
|
-
return false;
|
|
638
|
-
}
|
|
639
|
-
const previousOldestTurnId = String(turns.value[0]?.turnId || "").trim();
|
|
640
|
-
const previousMessageCount = turns.value.reduce((count, turn) => count + (turn.messages?.length || 0), 0);
|
|
641
|
-
historyRevision += 1;
|
|
642
|
-
olderPages.value = [normalizeConversationLogPage(page), ...olderPages.value];
|
|
643
|
-
const nextOldestTurnId = String(turns.value[0]?.turnId || "").trim();
|
|
644
|
-
const nextMessageCount = turns.value.reduce((count, turn) => count + (turn.messages?.length || 0), 0);
|
|
645
|
-
const changed = Boolean(nextOldestTurnId && (nextOldestTurnId !== previousOldestTurnId ||
|
|
646
|
-
nextMessageCount !== previousMessageCount));
|
|
647
|
-
finish({ changed, loaded: true });
|
|
648
|
-
return true;
|
|
649
|
-
} catch (failure) {
|
|
650
|
-
if (current.value) {
|
|
651
|
-
loadMoreError.value = String(failureMessage(failure) || failure || t("jskit.assistant.runtime.history.loadFailed"));
|
|
652
|
-
if (isAccessDenied(failure)) receiveError(failure);
|
|
653
|
-
}
|
|
654
|
-
finish({ changed: false, loaded: false });
|
|
655
|
-
return false;
|
|
656
|
-
} finally { loadingMore.value = false; }
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
function refreshGoal() {
|
|
660
|
-
if (!active.value || !snapshot.value || !readsGoals.value || accessDenied.value) return Promise.resolve(false);
|
|
661
|
-
if (goalRead) { goalReadQueued = true; return goalRead; }
|
|
662
|
-
const segment = snapshot.value.segmentId;
|
|
663
|
-
const capabilities = snapshot.value.capabilities;
|
|
664
|
-
const controller = new AbortController();
|
|
665
|
-
goalReadController = controller;
|
|
666
|
-
const job = Promise.resolve().then(() => api.readConversationGoal(identity.conversationId, {
|
|
667
|
-
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
|
668
|
-
})).then(result => {
|
|
669
|
-
if (!current.value || controller.signal.aborted || snapshot.value?.segmentId !== segment) return false;
|
|
670
|
-
// Hosts may supply a same-read target and product view. Standalone reads
|
|
671
|
-
// retain their existing goal/null contract and captured conversation target.
|
|
672
|
-
goalView.value = result && Object.hasOwn(result, "target") ? result : {
|
|
673
|
-
status: "available", goal: result, target: { segmentId: segment, capabilities }
|
|
674
|
-
};
|
|
675
|
-
// A background read does not invalidate this observation. Its supplied
|
|
676
|
-
// goal target may intentionally differ from the visible chat segment.
|
|
677
|
-
goalTargetCurrent.value = true;
|
|
678
|
-
goalLoadError.value = "";
|
|
679
|
-
return true;
|
|
680
|
-
}).catch(failure => {
|
|
681
|
-
if (current.value && !controller.signal.aborted && snapshot.value?.segmentId === segment) {
|
|
682
|
-
goalLoadError.value = failureMessage(failure, t("jskit.assistant.runtime.goal.unavailable"));
|
|
683
|
-
}
|
|
684
|
-
return false;
|
|
685
|
-
}).finally(() => {
|
|
686
|
-
if (goalRead !== job) return;
|
|
687
|
-
goalRead = null;
|
|
688
|
-
goalReadController = null;
|
|
689
|
-
if (goalReadQueued) {
|
|
690
|
-
goalReadQueued = false;
|
|
691
|
-
void refreshGoal();
|
|
692
|
-
}
|
|
693
|
-
});
|
|
694
|
-
goalRead = job;
|
|
695
|
-
return job;
|
|
696
|
-
}
|
|
697
|
-
async function changeGoal(action, input = {}, { messageId = crypto.randomUUID() } = {}) {
|
|
698
|
-
const view = goalView.value;
|
|
699
|
-
const target = view?.target;
|
|
700
|
-
const command = target?.capabilities?.goalCommands?.[action];
|
|
701
|
-
// Pause and Cancel never depend on chat delivery: each is pinned to the goal
|
|
702
|
-
// it stops and goes straight to the goal operation, even while a message is
|
|
703
|
-
// sending or its receipt is unknown. Set and Resume wait for those.
|
|
704
|
-
const stopping = action === "pause" || action === "cancel";
|
|
705
|
-
if (!available.value || view?.status !== "available" || goalLoadError.value ||
|
|
706
|
-
goalAction.value && !(stopping && goalSettling.value) || !goalTargetCurrent.value ||
|
|
707
|
-
!["message", "control"].includes(command?.delivery) ||
|
|
708
|
-
!stopping && delivery.state.messages.some(message => message.status === "uncertain")) return false;
|
|
709
|
-
if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
|
|
710
|
-
goalError.value = t("jskit.assistant.runtime.goal.budgetsUnsupported");
|
|
711
|
-
return false;
|
|
712
|
-
}
|
|
713
|
-
const retry = stopping ? null : delivery.find(messageId);
|
|
714
|
-
if (retry && retry.status !== "failed") return false;
|
|
715
|
-
const request = retry?.payload.goalRequest || { action, expectedSegmentId: target.segmentId,
|
|
716
|
-
expectedGoalId: view.goal?.id || null,
|
|
717
|
-
...(action === "set" ? { objective: String(input.objective || "").trim(),
|
|
718
|
-
...(input.tokenBudget == null ? {} : { tokenBudget: input.tokenBudget }) } : {}) };
|
|
719
|
-
const before = view.goal ? { id: view.goal.id, status: view.goal.status } : null;
|
|
720
|
-
const sequence = ++goalCommandSequence;
|
|
721
|
-
let accepted = false;
|
|
722
|
-
goalAction.value = action;
|
|
723
|
-
goalSettling.value = false;
|
|
724
|
-
goalError.value = "";
|
|
725
|
-
try {
|
|
726
|
-
let result;
|
|
727
|
-
if (retry || !stopping && command.delivery === "message") {
|
|
728
|
-
result = await submitRequest(retry?.payload || {
|
|
729
|
-
message: action === "set" ? request.objective
|
|
730
|
-
: t(action === "resume" ? "jskit.assistant.runtime.goal.resumeMessage" : "jskit.assistant.runtime.goal.cancelMessage"),
|
|
731
|
-
goalRequest: request
|
|
732
|
-
}, { messageId });
|
|
733
|
-
if (result === false || result?.ok === false) {
|
|
734
|
-
if (current.value) {
|
|
735
|
-
goalError.value = result?.error || delivery.find(messageId)?.error || t("jskit.assistant.runtime.goal.changeFailed");
|
|
736
|
-
}
|
|
737
|
-
return false;
|
|
738
|
-
}
|
|
739
|
-
} else {
|
|
740
|
-
result = await api.updateConversationGoal(identity.conversationId,
|
|
741
|
-
command.delivery === "message" ? { ...request, messageId } : request);
|
|
742
|
-
if (result?.ok === false) throw new Error(resultError(result) || t("jskit.assistant.runtime.goal.changeFailed"));
|
|
743
|
-
}
|
|
744
|
-
accepted = true;
|
|
745
|
-
return result;
|
|
746
|
-
} catch (failure) {
|
|
747
|
-
if (current.value) {
|
|
748
|
-
goalError.value = failureMessage(failure);
|
|
749
|
-
if (isAccessDenied(failure)) receiveError(failure);
|
|
750
|
-
}
|
|
751
|
-
return false;
|
|
752
|
-
} finally {
|
|
753
|
-
// The command stays Starting, Resuming, Pausing or Cancelling until a goal
|
|
754
|
-
// read made after the server's answer arrives; until then the control
|
|
755
|
-
// shows no stale goal or enabled form. An accepted Set or Resume also
|
|
756
|
-
// waits while its goal waits behind the running turn.
|
|
757
|
-
if (current.value && goalCommandSequence === sequence) {
|
|
758
|
-
await goalRead;
|
|
759
|
-
await refreshGoal();
|
|
760
|
-
if (accepted && !stopping) await awaitGoalChange(before, sequence);
|
|
761
|
-
subscription?.reload();
|
|
762
|
-
}
|
|
763
|
-
if (goalCommandSequence === sequence) {
|
|
764
|
-
goalAction.value = "";
|
|
765
|
-
goalSettling.value = false;
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
// An accepted Set or Resume changes the server's goal, now or once the turn
|
|
770
|
-
// it waits behind has ended. When the conversation stops working without
|
|
771
|
-
// that change, a fresh read decides and the reason is shown. A later Pause
|
|
772
|
-
// or Cancel ends the wait.
|
|
773
|
-
const goalChangedFrom = before => {
|
|
774
|
-
const goal = goalView.value?.goal;
|
|
775
|
-
return Boolean(goal) && (goal.id !== before?.id || goal.status !== before?.status);
|
|
776
|
-
};
|
|
777
|
-
async function awaitGoalChange(before, sequence) {
|
|
778
|
-
const settled = () => !current.value || goalCommandSequence !== sequence || goalChangedFrom(before);
|
|
779
|
-
await subscription?.reload();
|
|
780
|
-
if (!settled()) goalSettling.value = true;
|
|
781
|
-
while (!settled() && snapshot.value?.status === "working") {
|
|
782
|
-
await new Promise(resolve => {
|
|
783
|
-
const stop = watch([goalView, () => snapshot.value?.status, current, goalAction], () => { stop(); resolve(); });
|
|
784
|
-
});
|
|
785
|
-
}
|
|
786
|
-
if (settled()) return;
|
|
787
|
-
await goalRead;
|
|
788
|
-
await refreshGoal();
|
|
789
|
-
if (!settled()) goalError.value = t("jskit.assistant.runtime.goal.notStarted");
|
|
790
|
-
}
|
|
791
|
-
const goalState = computed(() => {
|
|
792
|
-
const view = goalView.value;
|
|
793
|
-
if (!readsGoals.value) return null;
|
|
794
|
-
const capabilities = view?.target?.capabilities || {};
|
|
795
|
-
const commands = capabilities.goalCommands || {};
|
|
796
|
-
const loading = !goalTargetCurrent.value && !goalLoadError.value;
|
|
797
|
-
const goalAvailable = goalTargetCurrent.value && view?.status === "available" && !goalLoadError.value;
|
|
798
|
-
const stopPending = Boolean(goalAction.value) && !goalSettling.value || !goalAvailable || !available.value;
|
|
799
|
-
return {
|
|
800
|
-
enabled: active.value && Boolean(loading || (view?.status === "available" && !goalLoadError.value) || goalError.value || goalLoadError.value),
|
|
801
|
-
loading,
|
|
802
|
-
available: Boolean(goalAvailable),
|
|
803
|
-
action: goalAction.value,
|
|
804
|
-
working: snapshot.value?.status === "working",
|
|
805
|
-
tokenBudgetSupported: capabilities.goalBudgets === true,
|
|
806
|
-
goal: goalAvailable && view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
|
|
807
|
-
sampledAt: Date.parse(view.goal.updatedAt) } : null,
|
|
808
|
-
pending: Boolean(goalAction.value) || stopPending || delivery.state.sending ||
|
|
809
|
-
delivery.state.messages.some(message => message.status === "uncertain"),
|
|
810
|
-
stopPending,
|
|
811
|
-
error: goalError.value || goalLoadError.value || view?.error || "",
|
|
812
|
-
pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
|
|
813
|
-
cancelInterruptsTurn: commands.cancel?.interruptsTurn === true,
|
|
814
|
-
...Object.fromEntries(["set", "resume", "pause", "cancel"].filter(action =>
|
|
815
|
-
["message", "control"].includes(commands[action]?.delivery)).map(action => [action, input => changeGoal(action, input)]))
|
|
816
|
-
};
|
|
817
|
-
});
|
|
818
|
-
async function cancel() {
|
|
819
|
-
if (!editable.value || stopping.value) return false;
|
|
820
|
-
stopping.value = true;
|
|
821
|
-
for (const pending of pendingMessages.values()) {
|
|
822
|
-
if (!pending.dispatched) pending.controller.abort();
|
|
823
|
-
}
|
|
824
|
-
try { return await api.cancelConversation(identity.conversationId); }
|
|
825
|
-
catch (failure) { receiveError(failure); return false; }
|
|
826
|
-
finally { stopping.value = false; if (current.value) subscription?.reload(); }
|
|
827
|
-
}
|
|
828
|
-
async function inspectDelivery(messageId) {
|
|
829
|
-
if (!available.value) return false;
|
|
830
|
-
const message = delivery.find(messageId);
|
|
831
|
-
if (message?.checking) return false;
|
|
832
|
-
const check = new AbortController();
|
|
833
|
-
deliveryChecks.set(messageId, check);
|
|
834
|
-
if (message) message.checking = true;
|
|
835
|
-
try {
|
|
836
|
-
const receipt = await api.inspectConversationDelivery(identity.conversationId, messageId, { signal: check.signal });
|
|
837
|
-
if (!current.value || check.signal.aborted) return false;
|
|
838
|
-
if (receipt.status === "accepted") {
|
|
839
|
-
delivery.accept(messageId);
|
|
840
|
-
if (receipt.messageId === messageId && message?.payload.goalRequest) goalError.value = "";
|
|
841
|
-
}
|
|
842
|
-
// The server recorded this message as cancelled, perhaps from another tab.
|
|
843
|
-
else if (receipt.status === "cancelled" && receipt.messageId === messageId) delivery.remove(messageId);
|
|
844
|
-
else if (receipt.status === "not-sent" && receipt.messageId === messageId) delivery.rejectUnsent(messageId, resultError(receipt));
|
|
845
|
-
else if (receipt.status === "unknown" && message?.status === "uncertain") {
|
|
846
|
-
message.error = t("jskit.assistant.runtime.delivery.stillUnknown");
|
|
847
|
-
}
|
|
848
|
-
subscription?.reload();
|
|
849
|
-
return receipt;
|
|
850
|
-
} catch (failure) {
|
|
851
|
-
if (check.signal.aborted) return false;
|
|
852
|
-
if (current.value && message?.status === "uncertain") message.error = failureMessage(failure, t("jskit.assistant.runtime.delivery.checkFailed"));
|
|
853
|
-
receiveError(failure);
|
|
854
|
-
return false;
|
|
855
|
-
}
|
|
856
|
-
finally {
|
|
857
|
-
if (deliveryChecks.get(messageId) === check) deliveryChecks.delete(messageId);
|
|
858
|
-
if (message) message.checking = false;
|
|
859
|
-
}
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
const runtime = { identity, current, active, editable, available, canSubmit, steerable, queueWhileSending: queueing,
|
|
863
|
-
snapshot, draft, draftMessageId, draftAttachments, draftRetry, draftRetryMatches, settleDraftRetry,
|
|
864
|
-
error, accessDenied, loading, stopping, delivery, turns,
|
|
865
|
-
hasMoreBefore, loadingMore, loadMoreError, loadMore,
|
|
866
|
-
send, submitPrepared, submitDraft, cancel, cancelMessage, inspectDelivery, changeGoal, refreshGoal, goalState, goalView, goalLoadError, questions,
|
|
867
|
-
reload(options) { const job = subscription?.reload(options); void refreshGoal(); return job; } };
|
|
868
|
-
runtime.application = application ? application(runtime) : null;
|
|
869
|
-
return runtime;
|
|
870
|
-
}
|
|
871
|
-
|
|
872
|
-
// Original bounded-command history belongs to this mounted view. It has no
|
|
873
|
-
// canonical delivery receipt, retained server conversation or live subscription.
|
|
874
|
-
function useBoundedTask({ command, endpoint, scope, input, result, onResult }, { active, data, presentation }) {
|
|
875
|
-
if (typeof command?.run !== "function" || !("isRunning" in command) ||
|
|
876
|
-
typeof input !== "function" || typeof result !== "function") {
|
|
142
|
+
/**
|
|
143
|
+
* Checks the bounded task an application hands a binding.
|
|
144
|
+
* @param {BoundedTask} boundedTask
|
|
145
|
+
*/
|
|
146
|
+
// readable-check: allow method-probe - input validation: an application hands the binding its bounded task
|
|
147
|
+
function assertBoundedTask({ command, endpoint, scope, input, result, onResult }) {
|
|
148
|
+
if (typeof command?.run !== "function" || !("isRunning" in command) || typeof input !== "function" || typeof result !== "function") {
|
|
877
149
|
throw new TypeError("boundedTask requires its command and input/result mappings.");
|
|
878
150
|
}
|
|
879
151
|
if (endpoint === undefined || scope === undefined) throw new TypeError("boundedTask requires its endpoint and application scope.");
|
|
880
|
-
if (
|
|
152
|
+
if (typeof onResult !== "function") throw new TypeError("boundedTask.onResult must be a function.");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* A bounded-command history belongs to this mounted view. It has no canonical delivery receipt, retained server
|
|
157
|
+
* conversation or live subscription.
|
|
158
|
+
* @param {BoundedTask} boundedTask
|
|
159
|
+
* @param {{ active: import("vue").MaybeRefOrGetter<boolean>, data: import("vue").MaybeRefOrGetter<unknown>,
|
|
160
|
+
* presentation: import("vue").MaybeRefOrGetter<Record<string, any> | undefined> }} view
|
|
161
|
+
*/
|
|
162
|
+
function useBoundedTask({ command, endpoint, scope, input, result, onResult = ignoreResult }, { active, data, presentation }) {
|
|
163
|
+
assertBoundedTask({ command, endpoint, scope, input, result, onResult });
|
|
881
164
|
const draft = ref("");
|
|
882
165
|
const messages = ref([]);
|
|
883
166
|
const busy = computed(() => toValue(command.isRunning) === true);
|
|
@@ -894,12 +177,15 @@ function useBoundedTask({ command, endpoint, scope, input, result, onResult }, {
|
|
|
894
177
|
const response = await command.run({ path: toValue(endpoint), payload: input(messages.value) });
|
|
895
178
|
if (!response || disposed || toValue(scope) !== capturedScope) return;
|
|
896
179
|
messages.value.push(result(response));
|
|
897
|
-
onResult
|
|
180
|
+
onResult(response, { message, scope: capturedScope });
|
|
898
181
|
}
|
|
899
182
|
|
|
900
|
-
const runtime = shallowRef(
|
|
183
|
+
const runtime = shallowRef({
|
|
184
|
+
draft,
|
|
185
|
+
messages,
|
|
186
|
+
submit,
|
|
901
187
|
clearHistory() { messages.value = []; }
|
|
902
|
-
})
|
|
188
|
+
});
|
|
903
189
|
const adapter = computed(() => {
|
|
904
190
|
const display = toValue(presentation) || {};
|
|
905
191
|
return {
|
|
@@ -920,27 +206,45 @@ function useBoundedTask({ command, endpoint, scope, input, result, onResult }, {
|
|
|
920
206
|
return { runtime, adapter, error: computed(() => "") };
|
|
921
207
|
}
|
|
922
208
|
|
|
923
|
-
|
|
209
|
+
/**
|
|
210
|
+
* What a binding takes from the application's setup: the shell's route, placement and socket, and the application's
|
|
211
|
+
* conversation defaults.
|
|
212
|
+
* @param {{ socket?: import("../types.js").RealtimeSocket, boundedTask?: BoundedTask | null }} options
|
|
213
|
+
* @returns {ConversationBindingSetup}
|
|
214
|
+
*/
|
|
215
|
+
function conversationBindingSetup({ socket, boundedTask }) {
|
|
924
216
|
const app = getCurrentInstance()?.appContext.app;
|
|
925
217
|
if (!app) throw new Error("Conversation binding must be acquired from the application setup.");
|
|
926
|
-
if (boundedTask
|
|
218
|
+
if (boundedTask != null) return { app, defaults: NO_CONVERSATION_DEFAULTS };
|
|
927
219
|
const routeContext = useSurfaceRouteContext();
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
return {
|
|
933
|
-
|
|
220
|
+
/** @type {import("../types.js").PlacementRuntime} */
|
|
221
|
+
const placement = inject(SHELL_WEB_PLACEMENT_RUNTIME_KEY);
|
|
222
|
+
// readable-check: allow required-collaborators - safety invariant: a binding outside the shell would otherwise fail only once a conversation is targeted
|
|
223
|
+
if (!placement) throw new Error("Conversation binding requires the application's placement runtime.");
|
|
224
|
+
return {
|
|
225
|
+
app,
|
|
226
|
+
defaults: inject(conversationDefaultsKey, NO_CONVERSATION_DEFAULTS),
|
|
227
|
+
routeContext,
|
|
228
|
+
placement,
|
|
229
|
+
workspaceScope: useWorkspaceWebScopeSupport(),
|
|
230
|
+
socket: socket || useRealtimeSocket({ required: true }),
|
|
231
|
+
config: getClientAppConfig()
|
|
232
|
+
};
|
|
934
233
|
}
|
|
935
234
|
|
|
235
|
+
/**
|
|
236
|
+
* One view's binding to a retained conversation, or to a bounded task.
|
|
237
|
+
* @param {ConversationBindingOptions} options
|
|
238
|
+
* @param {ConversationBindingSetup} [setup] The factory's setup, captured once in the application's setup.
|
|
239
|
+
*/
|
|
936
240
|
function createConversationBinding({ conversationId, endpoint = "", surfaceId = "", hostSurfaceId = "", workspaceSlug,
|
|
937
|
-
actorKey: suppliedActorKey, api: suppliedApi
|
|
241
|
+
actorKey: suppliedActorKey, api: suppliedApi, socket: suppliedSocket, active = true, onEvent,
|
|
938
242
|
clearDraftOn: suppliedClearDraftOn, queueWhileSending, deferWhileWorking = false, admitWhileWorking = false, draftWhileLoading = false,
|
|
939
|
-
draftStorage
|
|
940
|
-
data, attachments
|
|
941
|
-
const { app, routeContext, placement, workspaceScope, socket: setupSocket, config, defaults
|
|
243
|
+
draftStorage, application, boundedTask,
|
|
244
|
+
data, attachments, suggestions, models, questions, goal, presentation }, setup) {
|
|
245
|
+
const { app, routeContext, placement, workspaceScope, socket: setupSocket, config, defaults } = setup ||
|
|
942
246
|
conversationBindingSetup({ socket: suppliedSocket, boundedTask });
|
|
943
|
-
const clearDraftOn = suppliedClearDraftOn ?? defaults.clearDraftOn
|
|
247
|
+
const clearDraftOn = suppliedClearDraftOn ?? defaults.clearDraftOn;
|
|
944
248
|
const actorKey = computed(() => {
|
|
945
249
|
const actor = toValue(suppliedActorKey);
|
|
946
250
|
return actor === undefined ? toValue(defaults.actorKey) : actor;
|
|
@@ -951,8 +255,9 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
951
255
|
if (!["boolean", "function"].includes(typeof admitWhileWorking)) throw new TypeError("admitWhileWorking must be a boolean.");
|
|
952
256
|
resolveWorkingPolicy(null, { deferWhileWorking, admitWhileWorking });
|
|
953
257
|
if (typeof draftWhileLoading !== "boolean") throw new TypeError("draftWhileLoading must be a boolean.");
|
|
954
|
-
|
|
955
|
-
if (
|
|
258
|
+
// readable-check: allow method-probe - input validation: an application may hand the binding a factory for its own runtime extension
|
|
259
|
+
if (application != null && typeof application !== "function") throw new TypeError("application must be a factory.");
|
|
260
|
+
if (boundedTask != null) return useBoundedTask(boundedTask, { active, data, presentation });
|
|
956
261
|
const socket = suppliedSocket || setupSocket;
|
|
957
262
|
const runtime = shallowRef(null);
|
|
958
263
|
const initialDraft = ref("");
|
|
@@ -980,12 +285,17 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
980
285
|
const route = resolveAssistantRuntimeRoute(surface, toValue(workspaceSlug) === undefined
|
|
981
286
|
? workspaceScope.readRouteScope(routeContext).workspaceSlug : text(workspaceSlug));
|
|
982
287
|
const actor = toValue(actorKey);
|
|
983
|
-
return Object.freeze({
|
|
984
|
-
|
|
288
|
+
return Object.freeze({
|
|
289
|
+
conversationId: text(conversationId),
|
|
290
|
+
targetSurfaceId: target,
|
|
291
|
+
hostSurfaceId: host || target,
|
|
292
|
+
workspaceSlug: route.workspaceSlug,
|
|
293
|
+
configured: route.configured,
|
|
985
294
|
actorKey: actor === undefined ? String(routeContext.placementContext.value?.user?.id || "") : text(actor),
|
|
986
|
-
endpoint: text(endpoint) || route.endpoint
|
|
295
|
+
endpoint: text(endpoint) || route.endpoint
|
|
296
|
+
});
|
|
987
297
|
});
|
|
988
|
-
// The
|
|
298
|
+
// The binding's own composer owns input before a conversation ID is available.
|
|
989
299
|
// It is scoped to the real actor/surface/workspace, never a placeholder ID.
|
|
990
300
|
const draftScope = computed(() => {
|
|
991
301
|
const scope = identity.value;
|
|
@@ -1004,9 +314,10 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1004
314
|
if (!target.configured || !target.conversationId || !target.actorKey || !target.targetSurfaceId) return;
|
|
1005
315
|
const key = JSON.stringify(["assistant", target.actorKey, target.endpoint, target.targetSurfaceId,
|
|
1006
316
|
target.hostSurfaceId, target.workspaceSlug, target.conversationId]);
|
|
1007
|
-
retained = retainAssistantConversation(app, key, readers =>
|
|
1008
|
-
readers, socket, actorKey, placement, queueWhileSending, deferWhileWorking, admitWhileWorking,
|
|
1009
|
-
|
|
317
|
+
retained = retainAssistantConversation(app, key, (readers, retain) => createConversationRuntime({
|
|
318
|
+
identity: target, readers, retain, socket, actorKey, placement, queueWhileSending, deferWhileWorking, admitWhileWorking,
|
|
319
|
+
draftStorage, application, goalReadEnabled: toValue(goal) === true,
|
|
320
|
+
api: suppliedApi ?? defaults.api ?? createAssistantApi({ request: defaults.request,
|
|
1010
321
|
resolveBasePath: () => target.endpoint, resolveSurfaceId: () => target.hostSurfaceId })
|
|
1011
322
|
}), { active, questions, goal, onEvent });
|
|
1012
323
|
runtime.value = retained.runtime;
|
|
@@ -1039,7 +350,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1039
350
|
if (retry && retry.status !== "failed") return false;
|
|
1040
351
|
if (!retry && owner?.canSubmit === false) return false;
|
|
1041
352
|
const prompts = questionOwner.value;
|
|
1042
|
-
const formatsQuestions =
|
|
353
|
+
const formatsQuestions = formatsAnswers(prompts);
|
|
1043
354
|
if (!retry && formatsQuestions && prompts.questions?.length &&
|
|
1044
355
|
!questionsAnswered(prompts)) return false;
|
|
1045
356
|
const answer = !retry && formatsQuestions ? prompts.capture(current.draft.value) : null;
|
|
@@ -1057,8 +368,9 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1057
368
|
// standard element. Creation, routing and access policy remain preparation.
|
|
1058
369
|
function submitPrepared(payload, { messageId = crypto.randomUUID(), retryMessageId = "", retry,
|
|
1059
370
|
working = false, clearDraft = true, attachments = attachmentOwner.value,
|
|
1060
|
-
isCurrent =
|
|
371
|
+
isCurrent = alwaysCurrent, onSubmit, onAccepted, prepare } = {}) {
|
|
1061
372
|
const current = runtime.value;
|
|
373
|
+
// readable-check: allow method-probe - input validation: an application hands a prepared submission its own preparation
|
|
1062
374
|
if (typeof prepare !== "function") throw new TypeError("Prepared submission requires application preparation.");
|
|
1063
375
|
if (!current?.current.value || !isCurrent()) return false;
|
|
1064
376
|
const id = retryMessageId || messageId;
|
|
@@ -1077,7 +389,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1077
389
|
}
|
|
1078
390
|
|
|
1079
391
|
function submitCaptured(current, captured, { messageId, retry, clearDraft = true, attachments,
|
|
1080
|
-
isCurrent =
|
|
392
|
+
isCurrent = alwaysCurrent, onSubmit = ignoreSubmit, onAccepted = ignoreAcceptance, prepare }) {
|
|
1081
393
|
const waitForAcceptance = clearDraftOn === "accepted";
|
|
1082
394
|
const attachmentIds = [...(captured.request?.attachmentIds || captured.attachmentIds ||
|
|
1083
395
|
(captured.displayAttachments || []).map(file => file.attachmentId))];
|
|
@@ -1097,7 +409,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1097
409
|
catch (failure) { current.error.value = failureMessage(failure, t("jskit.assistant.runtime.attachments.clearFailed")); }
|
|
1098
410
|
}
|
|
1099
411
|
}
|
|
1100
|
-
onAccepted
|
|
412
|
+
onAccepted();
|
|
1101
413
|
}
|
|
1102
414
|
if (!retry && clearDraft) {
|
|
1103
415
|
if (waitForAcceptance) {
|
|
@@ -1105,7 +417,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1105
417
|
captured = { ...captured, draft: current.draft.value };
|
|
1106
418
|
} else current.draft.value = "";
|
|
1107
419
|
}
|
|
1108
|
-
onSubmit
|
|
420
|
+
onSubmit();
|
|
1109
421
|
const dispatch = () => prepare ? current.submitPrepared(captured, { messageId, onAccepted: acknowledge, prepare })
|
|
1110
422
|
: current.send(captured, { messageId, onAccepted: acknowledge });
|
|
1111
423
|
// A restored uncertain receipt may have no retained acceptance callback.
|
|
@@ -1132,7 +444,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1132
444
|
const state = current?.snapshot.value;
|
|
1133
445
|
const files = state?.capabilities?.attachments ? attachmentOwner.value : null;
|
|
1134
446
|
const prompts = current?.available.value ? questionOwner.value : null;
|
|
1135
|
-
const formatsQuestions =
|
|
447
|
+
const formatsQuestions = formatsAnswers(prompts);
|
|
1136
448
|
const draft = current?.draft.value ?? initialDraft.value;
|
|
1137
449
|
const editable = canDraftWhileLoading.value ? !current || current.editable.value : current?.available.value;
|
|
1138
450
|
const message = formatsQuestions ? prompts.capture(draft).message : draft;
|
|
@@ -1181,12 +493,18 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1181
493
|
return { runtime, adapter, submitPrepared, error: computed(() => runtime.value?.error.value || stateError(runtime.value?.snapshot.value)) };
|
|
1182
494
|
}
|
|
1183
495
|
|
|
1184
|
-
/**
|
|
496
|
+
/**
|
|
497
|
+
* The standard element and custom text/voice presentation share the supplied binding.
|
|
498
|
+
* @param {ConversationBindingOptions} options
|
|
499
|
+
*/
|
|
1185
500
|
function useAssistantConversation(options) {
|
|
1186
501
|
return createConversationBinding(options);
|
|
1187
502
|
}
|
|
1188
503
|
|
|
1189
|
-
/**
|
|
504
|
+
/**
|
|
505
|
+
* Dynamic application tasks acquire the same binding without mounting a hidden view.
|
|
506
|
+
* @param {ConversationBindingOptions} [options] Shared by every binding the factory acquires.
|
|
507
|
+
*/
|
|
1190
508
|
function useAssistantConversationFactory(options = {}) {
|
|
1191
509
|
const setup = conversationBindingSetup(options);
|
|
1192
510
|
const owner = getCurrentScope();
|
|
@@ -1196,7 +514,13 @@ function useAssistantConversationFactory(options = {}) {
|
|
|
1196
514
|
const scope = owner.run(() => effectScope());
|
|
1197
515
|
try {
|
|
1198
516
|
const binding = scope.run(() => createConversationBinding({ ...options, ...input }, setup));
|
|
1199
|
-
return {
|
|
517
|
+
return {
|
|
518
|
+
runtime: binding.runtime,
|
|
519
|
+
adapter: binding.adapter,
|
|
520
|
+
submitPrepared: binding.submitPrepared,
|
|
521
|
+
error: binding.error,
|
|
522
|
+
release: () => scope.stop()
|
|
523
|
+
};
|
|
1200
524
|
} catch (failure) {
|
|
1201
525
|
scope.stop();
|
|
1202
526
|
throw failure;
|