@jskit-ai/assistant-runtime 0.2.8 → 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/canonical-conversation/vite.config.mjs +11 -0
- package/fixtures/conversation/main.js +2 -1
- package/fixtures/conversation/vite.config.mjs +4 -3
- package/locales/en.json +1 -0
- 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 -840
- 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/assistantConversationBinding.browser.test.js +115 -1
- 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,839 +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
|
-
const goalTargetCurrent = ref(false);
|
|
114
|
-
const goalError = ref("");
|
|
115
|
-
const goalView = shallowRef(null);
|
|
116
|
-
const goalLoadError = ref("");
|
|
117
|
-
const accessDenied = ref(false);
|
|
118
|
-
const delivery = createAssistantMessageDelivery();
|
|
119
|
-
const pendingMessages = new Map();
|
|
120
|
-
const deliveryChecks = new Map();
|
|
121
|
-
const olderPages = ref([]);
|
|
122
|
-
let historyRevision = 0;
|
|
123
|
-
let deniedHistoryRevision = 0;
|
|
124
|
-
const loadingMore = ref(false);
|
|
125
|
-
const loadMoreError = ref("");
|
|
126
|
-
const turns = computed(() => {
|
|
127
|
-
const saved = mergeConversationLogPages([
|
|
128
|
-
...olderPages.value, { conversationLog: snapshot.value?.conversationLog || [] }
|
|
129
|
-
]).conversationLog;
|
|
130
|
-
// Use the original overlay against the full loaded saved span. A synthetic
|
|
131
|
-
// live older turn must not replace its authored request or saved progress.
|
|
132
|
-
const messages = (snapshot.value?.turns || []).flatMap(conversationTurnMessages);
|
|
133
|
-
return mergeConversationStream(saved, { messages });
|
|
134
|
-
});
|
|
135
|
-
const oldestLoadedPage = computed(() => olderPages.value[0] || snapshot.value);
|
|
136
|
-
const hasMoreBefore = computed(() => normalizeConversationLogPagination(oldestLoadedPage.value?.pagination).hasMoreBefore);
|
|
137
|
-
const editable = computed(() => active.value && !accessDenied.value);
|
|
138
|
-
const available = computed(() => active.value && !loading.value && !accessDenied.value && Boolean(snapshot.value));
|
|
139
|
-
// A deferred message keeps the subscription while its view is hidden, so it
|
|
140
|
-
// is admitted against live state. A failed read leaves that state stale.
|
|
141
|
-
const deferredWaits = ref(0);
|
|
142
|
-
const listening = computed(() => active.value || current.value && deferredWaits.value > 0);
|
|
143
|
-
const admissible = computed(() => listening.value && !loading.value && !accessDenied.value && !error.value && Boolean(snapshot.value));
|
|
144
|
-
const steerable = computed(() => snapshot.value?.status === "working" && snapshot.value?.capabilities?.steering === true);
|
|
145
|
-
const workingPolicy = computed(() => resolveWorkingPolicy(snapshot.value, { deferWhileWorking, admitWhileWorking }));
|
|
146
|
-
const queueing = computed(() => queueWhileSending !== false && (steerable.value || workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking));
|
|
147
|
-
const canSubmit = computed(() => available.value && !stopping.value && (snapshot.value.status === "ready" || steerable.value || (workingPolicy.value.deferWhileWorking || workingPolicy.value.admitWhileWorking) && snapshot.value.status === "working") &&
|
|
148
|
-
(queueWhileSending !== false || !delivery.state.sending) &&
|
|
149
|
-
!delivery.state.messages.some(message => message.status === "uncertain" &&
|
|
150
|
-
(snapshot.value.status !== "ready" || !message.payload || text(message.payload.draft ?? message.payload.message) === text(draft))));
|
|
151
|
-
const savedDraft = toValue(draftStorage);
|
|
152
|
-
if (savedDraft && (typeof savedDraft.key !== "string" || !savedDraft.key ||
|
|
153
|
-
!["getItem", "setItem", "removeItem"].every(name => typeof savedDraft.storage?.[name] === "function"))) {
|
|
154
|
-
throw new TypeError("draftStorage requires an explicit key and storage with getItem, setItem and removeItem.");
|
|
155
|
-
}
|
|
156
|
-
// Original composer state is restored verbatim. A read never reconstructs or
|
|
157
|
-
// sends an old request; the explicit retry keeps its original payload and ID.
|
|
158
|
-
try {
|
|
159
|
-
const saved = savedDraft ? JSON.parse(savedDraft.storage.getItem(savedDraft.key) || "null") : null;
|
|
160
|
-
draft.value = typeof saved?.draft === "string" ? saved.draft : "";
|
|
161
|
-
draftAttachments.value = Array.isArray(saved?.attachments)
|
|
162
|
-
? saved.attachments.filter(attachment => typeof attachment?.attachmentId === "string") : [];
|
|
163
|
-
const messages = Array.isArray(saved?.messages) ? saved.messages.filter(message =>
|
|
164
|
-
typeof message?.id === "string" && typeof message.text === "string" &&
|
|
165
|
-
typeof message.payload?.message === "string") : [];
|
|
166
|
-
const restoredMessages = messages.filter(message => !delivery.find(message.id)).map(message => {
|
|
167
|
-
const status = ["failed", "accepted"].includes(message.status) ? message.status : "uncertain";
|
|
168
|
-
return { ...message, status, checking: false, cancelling: false, error: message.error || (status === "accepted" ? "" :
|
|
169
|
-
t("jskit.assistant.runtime.delivery.unconfirmedBeforeClose")) };
|
|
170
|
-
});
|
|
171
|
-
delivery.state.messages = unmatchedOptimisticMessages(turns.value, [...delivery.state.messages, ...restoredMessages], { receiptsOnly: true });
|
|
172
|
-
// A cancel the server had not agreed to before this page closed is retried.
|
|
173
|
-
for (const message of messages) {
|
|
174
|
-
if (message.cancelling || message.cancelError) delivery.cancelFailed(message.id, message.cancelError);
|
|
175
|
-
}
|
|
176
|
-
} catch {
|
|
177
|
-
draft.value = "";
|
|
178
|
-
draftAttachments.value = [];
|
|
179
|
-
}
|
|
180
|
-
function persistDraft() {
|
|
181
|
-
if (!savedDraft || !current.value || accessDenied.value) return;
|
|
182
|
-
try {
|
|
183
|
-
const saved = { draft: draft.value,
|
|
184
|
-
attachments: draftAttachments.value.filter(attachment => attachment?.attachmentId),
|
|
185
|
-
messages: delivery.state.messages };
|
|
186
|
-
if (saved.draft || saved.attachments.length || saved.messages.length) {
|
|
187
|
-
savedDraft.storage.setItem(savedDraft.key, JSON.stringify(saved));
|
|
188
|
-
} else savedDraft.storage.removeItem(savedDraft.key);
|
|
189
|
-
} catch {
|
|
190
|
-
// The in-memory delivery UI remains usable if storage is full or blocked.
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
persistDraft();
|
|
194
|
-
watch([draft, draftAttachments, () => delivery.state.messages], persistDraft, { deep: true, flush: "sync" });
|
|
195
|
-
function settleDraftRetry(retry = draftRetry.value) {
|
|
196
|
-
if (!retry || retry.messageId !== draftRetry.value?.messageId) return false;
|
|
197
|
-
draft.value = draftAfterAcceptedSubmission(draft.value, retry.draftSnapshot);
|
|
198
|
-
draftRetry.value = null;
|
|
199
|
-
return true;
|
|
200
|
-
}
|
|
201
|
-
watch(turns, value => {
|
|
202
|
-
const retry = draftRetry.value;
|
|
203
|
-
if (retry && !unmatchedOptimisticMessages(value, [retry.optimistic], { receiptsOnly: true }).length) settleDraftRetry(retry);
|
|
204
|
-
});
|
|
205
|
-
const questionConfiguration = computed(() => {
|
|
206
|
-
for (const reader of readers.values()) {
|
|
207
|
-
const option = toValue(reader.questions);
|
|
208
|
-
if (toValue(reader.active) !== false && isQuestionConfiguration(option)) return option;
|
|
209
|
-
}
|
|
210
|
-
return null;
|
|
211
|
-
});
|
|
212
|
-
// The form belongs to the conversation, including while only voice retains it.
|
|
213
|
-
// Reader options control presentation; they do not retire its answer state.
|
|
214
|
-
const questions = useAssistantQuestions({
|
|
215
|
-
message: () => available.value ? latestAssistantMessageAwaitingUserReply(turns.value) : "",
|
|
216
|
-
extraChoice: () => questionConfiguration.value?.extraChoice || null
|
|
217
|
-
});
|
|
218
|
-
let subscription;
|
|
219
|
-
let goalRead = null;
|
|
220
|
-
let goalSegment = null;
|
|
221
|
-
let goalReadController = null;
|
|
222
|
-
let goalReadQueued = false;
|
|
223
|
-
const readsGoals = computed(() => goalReadEnabled || snapshot.value?.capabilities?.goals === true ||
|
|
224
|
-
[...readers.values()].some(reader => toValue(reader.goal) === true));
|
|
225
|
-
watch(readsGoals, enabled => { if (enabled) void refreshGoal(); });
|
|
226
|
-
|
|
227
|
-
function receiveState(state, { initial = false, canonical = false } = {}) {
|
|
228
|
-
if (!current.value) return;
|
|
229
|
-
const previous = snapshot.value;
|
|
230
|
-
const goalChanged = JSON.stringify([previous?.goal, previous?.capabilities?.goals,
|
|
231
|
-
previous?.capabilities?.goalCommands, previous?.capabilities?.goalBudgets]) !==
|
|
232
|
-
JSON.stringify([state.goal, state.capabilities?.goals, state.capabilities?.goalCommands, state.capabilities?.goalBudgets]);
|
|
233
|
-
if (canonical) {
|
|
234
|
-
historyRevision += 1;
|
|
235
|
-
olderPages.value = initial ? [] : state[refreshedHistoryPages] || [];
|
|
236
|
-
loadMoreError.value = "";
|
|
237
|
-
} else if (olderPages.value.length) {
|
|
238
|
-
// Transcript patches may evict the head of the bounded live page before
|
|
239
|
-
// its canonical read completes. Retain saved rows, never display overlays.
|
|
240
|
-
const first = state.conversationLog?.[0]?.turnId;
|
|
241
|
-
const evicted = first ? (previous?.conversationLog || []).filter(turn => compareTurnIds(turn.turnId, first) < 0) : [];
|
|
242
|
-
if (evicted.length) {
|
|
243
|
-
historyRevision += 1;
|
|
244
|
-
olderPages.value = [...olderPages.value, normalizeConversationLogPage({ conversationLog: evicted })];
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
snapshot.value = state;
|
|
248
|
-
loading.value = false;
|
|
249
|
-
error.value = "";
|
|
250
|
-
accessDenied.value = false;
|
|
251
|
-
delivery.reconcile(state.turns);
|
|
252
|
-
if (state.status === "unconfirmed" && state.pendingRequest) delivery.restoreUncertain(state.pendingRequest, state.turns);
|
|
253
|
-
retryCancels();
|
|
254
|
-
if (state.segmentId !== goalSegment) {
|
|
255
|
-
// Keep the open goal menu while its first native identity arrives. The
|
|
256
|
-
// pending read disables commands until it supplies the new exact target.
|
|
257
|
-
goalTargetCurrent.value = false;
|
|
258
|
-
goalLoadError.value = "";
|
|
259
|
-
goalReadController?.abort();
|
|
260
|
-
}
|
|
261
|
-
if (readsGoals.value && (initial || goalChanged || state.segmentId !== goalSegment)) void refreshGoal();
|
|
262
|
-
goalSegment = state.segmentId;
|
|
263
|
-
}
|
|
264
|
-
function receiveError(failure) {
|
|
265
|
-
if (!current.value) return;
|
|
266
|
-
loading.value = false;
|
|
267
|
-
error.value = failureMessage(failure);
|
|
268
|
-
accessDenied.value = isAccessDenied(failure);
|
|
269
|
-
if (accessDenied.value) {
|
|
270
|
-
deniedHistoryRevision += 1;
|
|
271
|
-
clearConversationView();
|
|
272
|
-
goalTargetCurrent.value = false;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
// Shared by access denial and actor change. Access denial keeps the open goal
|
|
276
|
-
// command, queued goal read and question form; an actor change clears them.
|
|
277
|
-
function clearConversationView() {
|
|
278
|
-
delivery.reset();
|
|
279
|
-
snapshot.value = null;
|
|
280
|
-
historyRevision += 1;
|
|
281
|
-
olderPages.value = [];
|
|
282
|
-
draft.value = "";
|
|
283
|
-
draftMessageId.value = "";
|
|
284
|
-
draftAttachments.value = [];
|
|
285
|
-
draftRetry.value = null;
|
|
286
|
-
goalError.value = "";
|
|
287
|
-
goalView.value = null;
|
|
288
|
-
goalLoadError.value = "";
|
|
289
|
-
goalReadController?.abort();
|
|
290
|
-
}
|
|
291
|
-
function receiveEvent(event) {
|
|
292
|
-
if (event.type === "transcript" && olderPages.value.some(page =>
|
|
293
|
-
page.conversationLog.some(turn => turn.turnId === event.patch?.turn?.turnId))) {
|
|
294
|
-
historyRevision += 1;
|
|
295
|
-
olderPages.value = patchLoadedHistoryPages(olderPages.value, event.patch);
|
|
296
|
-
}
|
|
297
|
-
if (event.type === "goal") void refreshGoal();
|
|
298
|
-
for (const [token, reader] of [...readers]) {
|
|
299
|
-
if (!current.value) return;
|
|
300
|
-
if (!readers.has(token) || toValue(reader.active) === false || typeof reader.onEvent !== "function") continue;
|
|
301
|
-
// Presentation hooks own their errors; one reader must not interrupt
|
|
302
|
-
// another reader or the subscription's canonical state and delivery.
|
|
303
|
-
try { void Promise.resolve(reader.onEvent(event)).catch(() => {}); }
|
|
304
|
-
catch {}
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
async function readLoadedHistory({ current: subscriptionCurrent }) {
|
|
308
|
-
const denial = deniedHistoryRevision;
|
|
309
|
-
const valid = () => {
|
|
310
|
-
if (denial !== deniedHistoryRevision) {
|
|
311
|
-
throw Object.assign(new Error("Access denied."), { status: 403, messageCode: "assistant.runtime.accessDenied" });
|
|
312
|
-
}
|
|
313
|
-
return current.value && listening.value && subscriptionCurrent();
|
|
314
|
-
};
|
|
315
|
-
// A concurrent Load older operation extends the desired span. Retry one
|
|
316
|
-
// refresh in this same read, rather than dropping that newly loaded page.
|
|
317
|
-
while (valid()) {
|
|
318
|
-
const revision = historyRevision;
|
|
319
|
-
const oldestTurn = olderPages.value[0]?.conversationLog?.[0];
|
|
320
|
-
const oldest = oldestTurn?.turnId;
|
|
321
|
-
const oldestCursor = olderPages.value[0]?.pagination?.nextBeforeTurnId || oldest;
|
|
322
|
-
const latest = await api.readConversation(identity.conversationId);
|
|
323
|
-
if (!valid()) return latest;
|
|
324
|
-
const pages = [];
|
|
325
|
-
let page = normalizeConversationLogPage(latest);
|
|
326
|
-
const cursors = new Set();
|
|
327
|
-
while (oldest && page.pagination.hasMoreBefore) {
|
|
328
|
-
const cursor = page.pagination.nextBeforeTurnId || page.pagination.oldestTurnId;
|
|
329
|
-
const reachedFullOldestTurn = page.pagination.oldestTurnId === oldest && !page.conversationLog[0]?.paginationPartial;
|
|
330
|
-
const passedOldestTurn = compareTurnIds(page.pagination.oldestTurnId, oldest) < 0;
|
|
331
|
-
if (cursor && (cursor === oldestCursor || passedOldestTurn || reachedFullOldestTurn)) break;
|
|
332
|
-
if (!cursor || cursors.has(cursor)) {
|
|
333
|
-
throw Object.assign(new Error("Older conversation history could not be refreshed. Reload chat to try again."), {
|
|
334
|
-
messageCode: "assistant.runtime.history.refreshFailed"
|
|
335
|
-
});
|
|
336
|
-
}
|
|
337
|
-
cursors.add(cursor);
|
|
338
|
-
page = normalizeConversationLogPage(await api.readConversation(identity.conversationId, {
|
|
339
|
-
beforeTurnId: cursor, limit: latest.pagination.limit
|
|
340
|
-
}));
|
|
341
|
-
if (!valid()) return latest;
|
|
342
|
-
// A removed oldest row can make this page reach farther back than the
|
|
343
|
-
// reader loaded. Keep the original visible boundary and page metadata.
|
|
344
|
-
const saved = page.conversationLog.filter(turn => compareTurnIds(turn.turnId, oldest) >= 0).map(turn => {
|
|
345
|
-
if (turn.turnId !== oldest || !oldestTurn.paginationPartial) return turn;
|
|
346
|
-
const roles = ["system", "user", "assistant"];
|
|
347
|
-
const excluded = roles.slice(0, roles.findIndex(role => oldestTurn[role]));
|
|
348
|
-
return { ...turn, paginationPartial: true,
|
|
349
|
-
...Object.fromEntries(excluded.map(role => [role, null])),
|
|
350
|
-
messages: turn.messages?.filter(message => !excluded.includes(message.role)) };
|
|
351
|
-
});
|
|
352
|
-
pages.unshift(normalizeConversationLogPage({ ...page, conversationLog: saved, pagination: {
|
|
353
|
-
...page.pagination, count: saved.length, oldestTurnId: saved[0]?.turnId || "",
|
|
354
|
-
hasMoreBefore: page.pagination.hasMoreBefore || saved.length < page.conversationLog.length
|
|
355
|
-
} }));
|
|
356
|
-
}
|
|
357
|
-
if (revision !== historyRevision) continue;
|
|
358
|
-
return { ...latest, [refreshedHistoryPages]: pages.filter(page => page.conversationLog.length) };
|
|
359
|
-
}
|
|
360
|
-
return null;
|
|
361
|
-
}
|
|
362
|
-
watch(listening, enabled => {
|
|
363
|
-
subscription?.();
|
|
364
|
-
subscription = null;
|
|
365
|
-
historyRevision += 1;
|
|
366
|
-
if (!enabled) return;
|
|
367
|
-
loading.value = true;
|
|
368
|
-
subscription = subscribeAssistantConversation({ socket, conversationId: identity.conversationId,
|
|
369
|
-
targetSurfaceId: identity.targetSurfaceId, hostSurfaceId: identity.hostSurfaceId,
|
|
370
|
-
...(identity.workspaceSlug ? { workspaceSlug: identity.workspaceSlug } : {}),
|
|
371
|
-
read: readLoadedHistory, onState: receiveState,
|
|
372
|
-
onError: receiveError, onEvent: receiveEvent
|
|
373
|
-
});
|
|
374
|
-
}, { immediate: true, flush: "sync" });
|
|
375
|
-
watch(current, value => {
|
|
376
|
-
if (value) return;
|
|
377
|
-
for (const pending of pendingMessages.values()) if (!pending.dispatched) pending.controller.abort();
|
|
378
|
-
clearConversationView();
|
|
379
|
-
goalAction.value = "";
|
|
380
|
-
goalReadQueued = false;
|
|
381
|
-
questions.reset();
|
|
382
|
-
}, { flush: "sync" });
|
|
383
|
-
onScopeDispose(() => {
|
|
384
|
-
disposed.value = true;
|
|
385
|
-
subscription?.();
|
|
386
|
-
stopPlacement();
|
|
387
|
-
for (const pending of pendingMessages.values()) pending.controller.abort();
|
|
388
|
-
pendingMessages.clear();
|
|
389
|
-
for (const check of deliveryChecks.values()) check.abort();
|
|
390
|
-
goalReadController?.abort();
|
|
391
|
-
delivery.reset();
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
async function send(payload, { messageId = crypto.randomUUID(), onAccepted } = {}) {
|
|
395
|
-
const retry = delivery.find(messageId);
|
|
396
|
-
if (retry?.status === "failed" && retry.payload.goalRequest) return changeGoal(retry.payload.goalRequest.action, {}, { messageId });
|
|
397
|
-
if (!canSubmit.value) return false;
|
|
398
|
-
if (retry && retry.status !== "failed") return false;
|
|
399
|
-
if (delivery.state.messages.some(message => message.status === "uncertain" &&
|
|
400
|
-
text(message.payload?.message) === text((retry?.payload || payload).message))) return false;
|
|
401
|
-
const request = retry?.payload?.request || payload.request;
|
|
402
|
-
const requestedSteering = request?.steer === true;
|
|
403
|
-
if (requestedSteering && snapshot.value.capabilities?.steering !== true) return false;
|
|
404
|
-
const steering = requestedSteering || steerable.value;
|
|
405
|
-
const policy = workingPolicy.value;
|
|
406
|
-
const deferred = policy.deferWhileWorking && !retry && !steering;
|
|
407
|
-
// An application queue can admit ordinary messages without native steering
|
|
408
|
-
// or waiting for ready. It still owns admission, permissions and receipts.
|
|
409
|
-
const appAdmission = policy.admitWhileWorking && !steering;
|
|
410
|
-
if ((snapshot.value.status === "working" || delivery.state.sending) && !steering && !deferred && !appAdmission) return false;
|
|
411
|
-
if ((retry?.payload || payload).displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) return false;
|
|
412
|
-
const captured = retry?.payload?.request ? retry.payload : { ...(retry?.payload || payload), request: payload.request ||
|
|
413
|
-
authoredRequest(payload, {
|
|
414
|
-
attachmentIds: payload.displayAttachments?.map(file => file.attachmentId),
|
|
415
|
-
steer: steerable.value
|
|
416
|
-
}) };
|
|
417
|
-
return submitRequest(captured, { messageId, onAccepted, queue: queueWhileSending !== false && (steering || deferred || appAdmission), deferred, appAdmission });
|
|
418
|
-
}
|
|
419
|
-
function submitPrepared(payload, { messageId = crypto.randomUUID(), onAccepted, prepare } = {}) {
|
|
420
|
-
if (typeof prepare !== "function") throw new TypeError("Prepared submission requires application preparation.");
|
|
421
|
-
const retry = delivery.find(messageId);
|
|
422
|
-
const captured = retry?.payload || payload;
|
|
423
|
-
if (!captured?.request || typeof captured.request !== "object" || Array.isArray(captured.request) || captured.goalRequest) {
|
|
424
|
-
throw new TypeError("Prepared submission requires an authored conversation request.");
|
|
425
|
-
}
|
|
426
|
-
if (!current.value || accessDenied.value || retry && retry.status !== "failed") return Promise.resolve(false);
|
|
427
|
-
return submitRequest(captured, { messageId, onAccepted, prepare, queue: queueWhileSending === true });
|
|
428
|
-
}
|
|
429
|
-
async function submitDraft(payload, { messageId = crypto.randomUUID(), submissionKind = steerable.value ? "steer" : "send",
|
|
430
|
-
questionText = "", onAccepted } = {}) {
|
|
431
|
-
if (!canSubmit.value) return false;
|
|
432
|
-
const retry = draftRetryMatches.value ? draftRetry.value : null;
|
|
433
|
-
if (!retry) draftRetry.value = null;
|
|
434
|
-
const draftSnapshot = retry?.draftSnapshot || draft.value;
|
|
435
|
-
const captured = retry?.payload?.request ? retry.payload : payload;
|
|
436
|
-
const kind = retry?.submissionKind || submissionKind;
|
|
437
|
-
const questionTextSnapshot = retry?.questionTextSnapshot || questionText;
|
|
438
|
-
const id = retry?.messageId || messageId;
|
|
439
|
-
const sequence = ++draftSubmissionSequence;
|
|
440
|
-
draftRetry.value = null;
|
|
441
|
-
draft.value = retry ? draftAfterAcceptedSubmission(draft.value, draftSnapshot) : "";
|
|
442
|
-
const result = await send(captured, { messageId: id, onAccepted });
|
|
443
|
-
if (!current.value) return false;
|
|
444
|
-
const accepted = result !== false && result?.ok !== false;
|
|
445
|
-
if (!accepted && kind === "steer" && sequence === draftSubmissionSequence && !draft.value) {
|
|
446
|
-
draft.value = draftSnapshot;
|
|
447
|
-
const optimistic = delivery.find(id) || {
|
|
448
|
-
createdAtMs: Date.now(), id, text: String(captured.displayMessage || captured.message || "").trim()
|
|
449
|
-
};
|
|
450
|
-
draftRetry.value = { draftSnapshot, messageId: id, optimistic, payload: optimistic.payload || captured,
|
|
451
|
-
questionTextSnapshot, submissionKind: kind };
|
|
452
|
-
if (!unmatchedOptimisticMessages(turns.value, [optimistic], { receiptsOnly: true }).length) settleDraftRetry(draftRetry.value);
|
|
453
|
-
}
|
|
454
|
-
return result;
|
|
455
|
-
}
|
|
456
|
-
// A deferred message waits only while the working turn cannot take it. It
|
|
457
|
-
// steers as soon as that turn accepts steering, or sends once it is ready.
|
|
458
|
-
// Its view may be hidden. While loading or after a failed read there is no
|
|
459
|
-
// current state to admit it against, so the unsent message keeps waiting;
|
|
460
|
-
// only Stop, Cancel, retirement or lost access end it.
|
|
461
|
-
function waitForAdmission({ controller }) {
|
|
462
|
-
return new Promise(resolve => {
|
|
463
|
-
let stop;
|
|
464
|
-
const finish = admission => {
|
|
465
|
-
stop?.();
|
|
466
|
-
controller.signal.removeEventListener("abort", check);
|
|
467
|
-
resolve(admission);
|
|
468
|
-
};
|
|
469
|
-
const check = () => {
|
|
470
|
-
if (controller.signal.aborted || accessDenied.value) finish(false);
|
|
471
|
-
else if (!admissible.value) return;
|
|
472
|
-
else if (snapshot.value.status === "ready") finish("send");
|
|
473
|
-
else if (steerable.value) finish("steer");
|
|
474
|
-
else if (snapshot.value.status !== "working") finish(false);
|
|
475
|
-
};
|
|
476
|
-
stop = watch([admissible, snapshot, steerable], check, { flush: "sync" });
|
|
477
|
-
controller.signal.addEventListener("abort", check, { once: true });
|
|
478
|
-
check();
|
|
479
|
-
});
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
async function submitRequest(payload, { messageId, onAccepted, queue = false, prepare, deferred = false, appAdmission = false }) {
|
|
483
|
-
if (pendingMessages.has(messageId)) return false;
|
|
484
|
-
// Register before the original serial tail so Stop/retirement also reaches
|
|
485
|
-
// a local follower whose deliver callback has not started yet.
|
|
486
|
-
const pending = { controller: new AbortController(), started: false, dispatched: false };
|
|
487
|
-
const { controller } = pending;
|
|
488
|
-
pendingMessages.set(messageId, pending);
|
|
489
|
-
if (deferred) deferredWaits.value += 1;
|
|
490
|
-
try {
|
|
491
|
-
const result = await delivery.send(payload, {
|
|
492
|
-
messageId, queue, isCurrent: () => current.value, onAccepted,
|
|
493
|
-
receiptTurns: turns, uncertainOnError: true,
|
|
494
|
-
async deliver(submission) {
|
|
495
|
-
pending.started = true;
|
|
496
|
-
let admission = null;
|
|
497
|
-
try {
|
|
498
|
-
if (controller.signal.aborted || appAdmission && (!available.value || stopping.value || !workingPolicy.value.admitWhileWorking)) return false;
|
|
499
|
-
if (deferred) {
|
|
500
|
-
// Refresh through the same subscription after a queued predecessor;
|
|
501
|
-
// its receipt may have arrived before the working snapshot.
|
|
502
|
-
await subscription?.reload();
|
|
503
|
-
if (snapshot.value?.status === "working" && !steerable.value) {
|
|
504
|
-
delivery.wait(messageId, t("jskit.assistant.runtime.delivery.waitingForStep"));
|
|
505
|
-
}
|
|
506
|
-
admission = await waitForAdmission(pending);
|
|
507
|
-
delivery.wait(messageId, "");
|
|
508
|
-
if (!admission || controller.signal.aborted || !admissible.value ||
|
|
509
|
-
snapshot.value.status !== (admission === "steer" ? "working" : "ready")) return false;
|
|
510
|
-
if (submission.displayAttachments?.length && snapshot.value.capabilities?.attachments !== true) {
|
|
511
|
-
return { ok: false, error: t("jskit.assistant.runtime.attachments.noLongerAccepted") };
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
let request = prepare ? await prepare(submission, { signal: controller.signal }) : submission.request;
|
|
515
|
-
if (admission === "steer") {
|
|
516
|
-
request = { ...request, steer: true };
|
|
517
|
-
const retained = delivery.find(messageId);
|
|
518
|
-
if (retained) retained.payload.request = request;
|
|
519
|
-
}
|
|
520
|
-
if (prepare) {
|
|
521
|
-
if (!current.value || accessDenied.value || controller.signal.aborted || request === false) return false;
|
|
522
|
-
if (!request || typeof request !== "object" || Array.isArray(request)) {
|
|
523
|
-
throw new TypeError("Application preparation must return its conversation request or false.");
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
pending.dispatched = true;
|
|
527
|
-
for (let attempt = 0; ; attempt += 1) {
|
|
528
|
-
try {
|
|
529
|
-
return await (submission.goalRequest
|
|
530
|
-
? api.updateConversationGoal(identity.conversationId, { ...submission.goalRequest, messageId }, { signal: controller.signal })
|
|
531
|
-
: api.sendConversationMessage(identity.conversationId, { ...request, messageId }, { signal: controller.signal }));
|
|
532
|
-
} catch (failure) {
|
|
533
|
-
// The server identifies this exact pre-admission race. Keep the
|
|
534
|
-
// same queued words/attachments/identity and try ordinary Send
|
|
535
|
-
// once. A timeout, generic rejection or uncertain send never
|
|
536
|
-
// grants another dispatch.
|
|
537
|
-
if (attempt !== 0 || submission.goalRequest || request.steer !== true ||
|
|
538
|
-
failure.code !== "conversation_steer_turn_finished" || failure.status !== 409 ||
|
|
539
|
-
failure.details?.delivery && (failure.details.delivery.status !== "not-sent" ||
|
|
540
|
-
failure.details.delivery.messageId !== messageId)) throw failure;
|
|
541
|
-
const retained = delivery.find(messageId);
|
|
542
|
-
if (retained?.status === "accepted" || turns.value.some(turn => turn.user?.messageId === messageId && turn.user.receipt !== false)) return { ok: true };
|
|
543
|
-
if (!current.value || accessDenied.value || controller.signal.aborted || !retained || retained.status !== "pending") return false;
|
|
544
|
-
const { steer: _finishedTurn, ...nextRequest } = request;
|
|
545
|
-
request = nextRequest;
|
|
546
|
-
// Retain the new dispatch intent if Send itself fails; Retry
|
|
547
|
-
// must not loop back through the obsolete steering request.
|
|
548
|
-
retained.payload.request = nextRequest;
|
|
549
|
-
retained.payload.submissionKind = "send";
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
catch (failure) {
|
|
554
|
-
if (controller.signal.aborted) return false;
|
|
555
|
-
if (prepare && !pending.dispatched) return { ok: false, error: failureMessage(failure),
|
|
556
|
-
...(failure.code ? { code: failure.code } : {}) };
|
|
557
|
-
const rejection = failure.details?.delivery;
|
|
558
|
-
const notSent = Number.isInteger(failure.status) && failure.status >= 400 && failure.status < 500 &&
|
|
559
|
-
rejection && !Array.isArray(rejection) && rejection.status === "not-sent" && rejection.messageId === messageId;
|
|
560
|
-
// A definite rejection keeps its code so the application can offer
|
|
561
|
-
// the matching recovery, such as freeing assistant context.
|
|
562
|
-
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),
|
|
563
|
-
...(failure.code ? { code: failure.code } : {}) };
|
|
564
|
-
throw failure;
|
|
565
|
-
} finally {
|
|
566
|
-
if (pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
});
|
|
570
|
-
return result;
|
|
571
|
-
} catch (failure) {
|
|
572
|
-
// Delivery keeps its exact authored payload until an explicit receipt check.
|
|
573
|
-
if (prepare) return { ok: false, status: "uncertain", error: failureMessage(failure),
|
|
574
|
-
...(failure.code ? { code: failure.code } : {}) };
|
|
575
|
-
return false;
|
|
576
|
-
} finally {
|
|
577
|
-
if (!pending.started && pendingMessages.get(messageId) === pending) pendingMessages.delete(messageId);
|
|
578
|
-
if (deferred) deferredWaits.value -= 1;
|
|
579
|
-
if (current.value) subscription?.reload();
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
// Cancel means cancel, and the server decides. Cancel ends the message's
|
|
583
|
-
// unsent request and running delivery check, then asks the server, which
|
|
584
|
-
// always records the cancel. The message stays, shown as cancelling, until
|
|
585
|
-
// the server agrees; when the server cannot be reached it stays with Cancel
|
|
586
|
-
// again, and the next snapshot after reconnecting retries it.
|
|
587
|
-
async function cancelMessage(messageId) {
|
|
588
|
-
pendingMessages.get(messageId)?.controller.abort();
|
|
589
|
-
deliveryChecks.get(messageId)?.abort();
|
|
590
|
-
if (!available.value || !delivery.cancel(messageId)) return false;
|
|
591
|
-
try {
|
|
592
|
-
const result = await api.cancelConversationDelivery(identity.conversationId, messageId);
|
|
593
|
-
if (result?.ok === false) throw Object.assign(new Error(resultError(result)), { status: 409 });
|
|
594
|
-
if (!current.value) return false;
|
|
595
|
-
delivery.remove(messageId);
|
|
596
|
-
subscription?.reload();
|
|
597
|
-
return true;
|
|
598
|
-
} catch (failure) {
|
|
599
|
-
// The server's own refusal names itself; anything else did not reach it.
|
|
600
|
-
if (current.value) delivery.cancelFailed(messageId, Number.isInteger(failure?.status) && failure.status > 0 ? failureMessage(failure) : "");
|
|
601
|
-
return false;
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
function retryCancels() {
|
|
605
|
-
for (const message of delivery.state.messages) {
|
|
606
|
-
if (message.cancelError && !message.cancelling) void cancelMessage(message.id);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
|
|
610
|
-
// The original history control keeps older pages separate from the live page.
|
|
611
|
-
async function loadMore({ complete } = {}) {
|
|
612
|
-
const finish = (result) => {
|
|
613
|
-
if (typeof complete === "function") complete(result);
|
|
614
|
-
};
|
|
615
|
-
const pagination = normalizeConversationLogPagination(oldestLoadedPage.value?.pagination);
|
|
616
|
-
const beforeTurnId = pagination.nextBeforeTurnId || pagination.oldestTurnId ||
|
|
617
|
-
String(turns.value[0]?.turnId || "").trim();
|
|
618
|
-
if (!available.value || !hasMoreBefore.value || loadingMore.value || !beforeTurnId) {
|
|
619
|
-
finish({ changed: false, loaded: false });
|
|
620
|
-
return false;
|
|
621
|
-
}
|
|
622
|
-
const revision = historyRevision;
|
|
623
|
-
loadingMore.value = true;
|
|
624
|
-
loadMoreError.value = "";
|
|
625
|
-
try {
|
|
626
|
-
const page = await api.readConversation(identity.conversationId, {
|
|
627
|
-
beforeTurnId, limit: snapshot.value.pagination.limit
|
|
628
|
-
});
|
|
629
|
-
if (!available.value || revision !== historyRevision) {
|
|
630
|
-
finish({ changed: false, loaded: false });
|
|
631
|
-
return false;
|
|
632
|
-
}
|
|
633
|
-
const previousOldestTurnId = String(turns.value[0]?.turnId || "").trim();
|
|
634
|
-
const previousMessageCount = turns.value.reduce((count, turn) => count + (turn.messages?.length || 0), 0);
|
|
635
|
-
historyRevision += 1;
|
|
636
|
-
olderPages.value = [normalizeConversationLogPage(page), ...olderPages.value];
|
|
637
|
-
const nextOldestTurnId = String(turns.value[0]?.turnId || "").trim();
|
|
638
|
-
const nextMessageCount = turns.value.reduce((count, turn) => count + (turn.messages?.length || 0), 0);
|
|
639
|
-
const changed = Boolean(nextOldestTurnId && (nextOldestTurnId !== previousOldestTurnId ||
|
|
640
|
-
nextMessageCount !== previousMessageCount));
|
|
641
|
-
finish({ changed, loaded: true });
|
|
642
|
-
return true;
|
|
643
|
-
} catch (failure) {
|
|
644
|
-
if (current.value) {
|
|
645
|
-
loadMoreError.value = String(failureMessage(failure) || failure || t("jskit.assistant.runtime.history.loadFailed"));
|
|
646
|
-
if (isAccessDenied(failure)) receiveError(failure);
|
|
647
|
-
}
|
|
648
|
-
finish({ changed: false, loaded: false });
|
|
649
|
-
return false;
|
|
650
|
-
} finally { loadingMore.value = false; }
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
function refreshGoal() {
|
|
654
|
-
if (!active.value || !snapshot.value || !readsGoals.value || accessDenied.value) return Promise.resolve(false);
|
|
655
|
-
if (goalRead) { goalReadQueued = true; return goalRead; }
|
|
656
|
-
const segment = snapshot.value.segmentId;
|
|
657
|
-
const capabilities = snapshot.value.capabilities;
|
|
658
|
-
const controller = new AbortController();
|
|
659
|
-
goalReadController = controller;
|
|
660
|
-
const job = Promise.resolve().then(() => api.readConversationGoal(identity.conversationId, {
|
|
661
|
-
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(30_000)])
|
|
662
|
-
})).then(result => {
|
|
663
|
-
if (!current.value || controller.signal.aborted || snapshot.value?.segmentId !== segment) return false;
|
|
664
|
-
// Hosts may supply a same-read target and product view. Standalone reads
|
|
665
|
-
// retain their existing goal/null contract and captured conversation target.
|
|
666
|
-
goalView.value = result && Object.hasOwn(result, "target") ? result : {
|
|
667
|
-
status: "available", goal: result, target: { segmentId: segment, capabilities }
|
|
668
|
-
};
|
|
669
|
-
// A background read does not invalidate this observation. Its supplied
|
|
670
|
-
// goal target may intentionally differ from the visible chat segment.
|
|
671
|
-
goalTargetCurrent.value = true;
|
|
672
|
-
goalLoadError.value = "";
|
|
673
|
-
return true;
|
|
674
|
-
}).catch(failure => {
|
|
675
|
-
if (current.value && !controller.signal.aborted && snapshot.value?.segmentId === segment) {
|
|
676
|
-
goalLoadError.value = failureMessage(failure, t("jskit.assistant.runtime.goal.unavailable"));
|
|
677
|
-
}
|
|
678
|
-
return false;
|
|
679
|
-
}).finally(() => {
|
|
680
|
-
if (goalRead !== job) return;
|
|
681
|
-
goalRead = null;
|
|
682
|
-
goalReadController = null;
|
|
683
|
-
if (goalReadQueued) {
|
|
684
|
-
goalReadQueued = false;
|
|
685
|
-
void refreshGoal();
|
|
686
|
-
}
|
|
687
|
-
});
|
|
688
|
-
goalRead = job;
|
|
689
|
-
return job;
|
|
690
|
-
}
|
|
691
|
-
async function changeGoal(action, input = {}, { messageId = crypto.randomUUID() } = {}) {
|
|
692
|
-
const view = goalView.value;
|
|
693
|
-
const target = view?.target;
|
|
694
|
-
const command = target?.capabilities?.goalCommands?.[action];
|
|
695
|
-
// Pause and Cancel never depend on chat delivery: each is pinned to the goal
|
|
696
|
-
// it stops and goes straight to the goal operation, even while a message is
|
|
697
|
-
// sending or its receipt is unknown. Set and Resume wait for those.
|
|
698
|
-
const stopping = action === "pause" || action === "cancel";
|
|
699
|
-
if (!available.value || view?.status !== "available" || goalLoadError.value || goalAction.value || !goalTargetCurrent.value ||
|
|
700
|
-
!["message", "control"].includes(command?.delivery) ||
|
|
701
|
-
!stopping && delivery.state.messages.some(message => message.status === "uncertain")) return false;
|
|
702
|
-
if (input.tokenBudget != null && !target.capabilities.goalBudgets) {
|
|
703
|
-
goalError.value = t("jskit.assistant.runtime.goal.budgetsUnsupported");
|
|
704
|
-
return false;
|
|
705
|
-
}
|
|
706
|
-
const retry = stopping ? null : delivery.find(messageId);
|
|
707
|
-
if (retry && retry.status !== "failed") return false;
|
|
708
|
-
const request = retry?.payload.goalRequest || { action, expectedSegmentId: target.segmentId,
|
|
709
|
-
expectedGoalId: view.goal?.id || null,
|
|
710
|
-
...(action === "set" ? { objective: String(input.objective || "").trim(),
|
|
711
|
-
...(input.tokenBudget == null ? {} : { tokenBudget: input.tokenBudget }) } : {}) };
|
|
712
|
-
goalAction.value = action;
|
|
713
|
-
goalError.value = "";
|
|
714
|
-
try {
|
|
715
|
-
if (retry || !stopping && command.delivery === "message") {
|
|
716
|
-
const result = await submitRequest(retry?.payload || {
|
|
717
|
-
message: action === "set" ? request.objective
|
|
718
|
-
: t(action === "resume" ? "jskit.assistant.runtime.goal.resumeMessage" : "jskit.assistant.runtime.goal.cancelMessage"),
|
|
719
|
-
goalRequest: request
|
|
720
|
-
}, { messageId });
|
|
721
|
-
if (result === false || result?.ok === false) {
|
|
722
|
-
if (current.value) {
|
|
723
|
-
goalError.value = result?.error || delivery.find(messageId)?.error || t("jskit.assistant.runtime.goal.changeFailed");
|
|
724
|
-
}
|
|
725
|
-
return false;
|
|
726
|
-
}
|
|
727
|
-
return result;
|
|
728
|
-
}
|
|
729
|
-
const result = await api.updateConversationGoal(identity.conversationId,
|
|
730
|
-
command.delivery === "message" ? { ...request, messageId } : request);
|
|
731
|
-
if (result?.ok === false) throw new Error(resultError(result) || t("jskit.assistant.runtime.goal.changeFailed"));
|
|
732
|
-
return result;
|
|
733
|
-
} catch (failure) {
|
|
734
|
-
if (current.value) {
|
|
735
|
-
goalError.value = failureMessage(failure);
|
|
736
|
-
if (isAccessDenied(failure)) receiveError(failure);
|
|
737
|
-
}
|
|
738
|
-
return false;
|
|
739
|
-
} finally {
|
|
740
|
-
if (current.value) {
|
|
741
|
-
goalAction.value = "";
|
|
742
|
-
await goalRead;
|
|
743
|
-
await refreshGoal();
|
|
744
|
-
subscription?.reload();
|
|
745
|
-
}
|
|
746
|
-
}
|
|
747
|
-
}
|
|
748
|
-
const goalState = computed(() => {
|
|
749
|
-
const view = goalView.value;
|
|
750
|
-
if (!readsGoals.value) return null;
|
|
751
|
-
const capabilities = view?.target?.capabilities || {};
|
|
752
|
-
const commands = capabilities.goalCommands || {};
|
|
753
|
-
const loading = !goalTargetCurrent.value && !goalLoadError.value;
|
|
754
|
-
const goalAvailable = goalTargetCurrent.value && view?.status === "available" && !goalLoadError.value;
|
|
755
|
-
const stopPending = Boolean(goalAction.value) || !goalAvailable || !available.value;
|
|
756
|
-
return {
|
|
757
|
-
enabled: active.value && Boolean(loading || (view?.status === "available" && !goalLoadError.value) || goalError.value || goalLoadError.value),
|
|
758
|
-
loading,
|
|
759
|
-
available: Boolean(goalAvailable),
|
|
760
|
-
action: goalAction.value,
|
|
761
|
-
working: snapshot.value?.status === "working",
|
|
762
|
-
tokenBudgetSupported: capabilities.goalBudgets === true,
|
|
763
|
-
goal: goalAvailable && view?.goal ? { ...view.goal, elapsedSeconds: view.goal.timeUsedSeconds,
|
|
764
|
-
sampledAt: Date.parse(view.goal.updatedAt) } : null,
|
|
765
|
-
pending: stopPending || delivery.state.sending || delivery.state.messages.some(message => message.status === "uncertain"),
|
|
766
|
-
stopPending,
|
|
767
|
-
error: goalError.value || goalLoadError.value || view?.error || "",
|
|
768
|
-
pauseInterruptsTurn: commands.pause?.interruptsTurn === true,
|
|
769
|
-
cancelInterruptsTurn: commands.cancel?.interruptsTurn === true,
|
|
770
|
-
...Object.fromEntries(["set", "resume", "pause", "cancel"].filter(action =>
|
|
771
|
-
["message", "control"].includes(commands[action]?.delivery)).map(action => [action, input => changeGoal(action, input)]))
|
|
772
|
-
};
|
|
773
|
-
});
|
|
774
|
-
async function cancel() {
|
|
775
|
-
if (!editable.value || stopping.value) return false;
|
|
776
|
-
stopping.value = true;
|
|
777
|
-
for (const pending of pendingMessages.values()) {
|
|
778
|
-
if (!pending.dispatched) pending.controller.abort();
|
|
779
|
-
}
|
|
780
|
-
try { return await api.cancelConversation(identity.conversationId); }
|
|
781
|
-
catch (failure) { receiveError(failure); return false; }
|
|
782
|
-
finally { stopping.value = false; if (current.value) subscription?.reload(); }
|
|
783
|
-
}
|
|
784
|
-
async function inspectDelivery(messageId) {
|
|
785
|
-
if (!available.value) return false;
|
|
786
|
-
const message = delivery.find(messageId);
|
|
787
|
-
if (message?.checking) return false;
|
|
788
|
-
const check = new AbortController();
|
|
789
|
-
deliveryChecks.set(messageId, check);
|
|
790
|
-
if (message) message.checking = true;
|
|
791
|
-
try {
|
|
792
|
-
const receipt = await api.inspectConversationDelivery(identity.conversationId, messageId, { signal: check.signal });
|
|
793
|
-
if (!current.value || check.signal.aborted) return false;
|
|
794
|
-
if (receipt.status === "accepted") {
|
|
795
|
-
delivery.accept(messageId);
|
|
796
|
-
if (receipt.messageId === messageId && message?.payload.goalRequest) goalError.value = "";
|
|
797
|
-
}
|
|
798
|
-
// The server recorded this message as cancelled, perhaps from another tab.
|
|
799
|
-
else if (receipt.status === "cancelled" && receipt.messageId === messageId) delivery.remove(messageId);
|
|
800
|
-
else if (receipt.status === "not-sent" && receipt.messageId === messageId) delivery.rejectUnsent(messageId, resultError(receipt));
|
|
801
|
-
else if (receipt.status === "unknown" && message?.status === "uncertain") {
|
|
802
|
-
message.error = t("jskit.assistant.runtime.delivery.stillUnknown");
|
|
803
|
-
}
|
|
804
|
-
subscription?.reload();
|
|
805
|
-
return receipt;
|
|
806
|
-
} catch (failure) {
|
|
807
|
-
if (check.signal.aborted) return false;
|
|
808
|
-
if (current.value && message?.status === "uncertain") message.error = failureMessage(failure, t("jskit.assistant.runtime.delivery.checkFailed"));
|
|
809
|
-
receiveError(failure);
|
|
810
|
-
return false;
|
|
811
|
-
}
|
|
812
|
-
finally {
|
|
813
|
-
if (deliveryChecks.get(messageId) === check) deliveryChecks.delete(messageId);
|
|
814
|
-
if (message) message.checking = false;
|
|
815
|
-
}
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
const runtime = { identity, current, active, editable, available, canSubmit, steerable, queueWhileSending: queueing,
|
|
819
|
-
snapshot, draft, draftMessageId, draftAttachments, draftRetry, draftRetryMatches, settleDraftRetry,
|
|
820
|
-
error, accessDenied, loading, stopping, delivery, turns,
|
|
821
|
-
hasMoreBefore, loadingMore, loadMoreError, loadMore,
|
|
822
|
-
send, submitPrepared, submitDraft, cancel, cancelMessage, inspectDelivery, changeGoal, refreshGoal, goalState, goalView, goalLoadError, questions,
|
|
823
|
-
reload(options) { const job = subscription?.reload(options); void refreshGoal(); return job; } };
|
|
824
|
-
runtime.application = application ? application(runtime) : null;
|
|
825
|
-
return runtime;
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
// Original bounded-command history belongs to this mounted view. It has no
|
|
829
|
-
// canonical delivery receipt, retained server conversation or live subscription.
|
|
830
|
-
function useBoundedTask({ command, endpoint, scope, input, result, onResult }, { active, data, presentation }) {
|
|
831
|
-
if (typeof command?.run !== "function" || !("isRunning" in command) ||
|
|
832
|
-
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") {
|
|
833
149
|
throw new TypeError("boundedTask requires its command and input/result mappings.");
|
|
834
150
|
}
|
|
835
151
|
if (endpoint === undefined || scope === undefined) throw new TypeError("boundedTask requires its endpoint and application scope.");
|
|
836
|
-
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 });
|
|
837
164
|
const draft = ref("");
|
|
838
165
|
const messages = ref([]);
|
|
839
166
|
const busy = computed(() => toValue(command.isRunning) === true);
|
|
@@ -850,12 +177,15 @@ function useBoundedTask({ command, endpoint, scope, input, result, onResult }, {
|
|
|
850
177
|
const response = await command.run({ path: toValue(endpoint), payload: input(messages.value) });
|
|
851
178
|
if (!response || disposed || toValue(scope) !== capturedScope) return;
|
|
852
179
|
messages.value.push(result(response));
|
|
853
|
-
onResult
|
|
180
|
+
onResult(response, { message, scope: capturedScope });
|
|
854
181
|
}
|
|
855
182
|
|
|
856
|
-
const runtime = shallowRef(
|
|
183
|
+
const runtime = shallowRef({
|
|
184
|
+
draft,
|
|
185
|
+
messages,
|
|
186
|
+
submit,
|
|
857
187
|
clearHistory() { messages.value = []; }
|
|
858
|
-
})
|
|
188
|
+
});
|
|
859
189
|
const adapter = computed(() => {
|
|
860
190
|
const display = toValue(presentation) || {};
|
|
861
191
|
return {
|
|
@@ -876,27 +206,45 @@ function useBoundedTask({ command, endpoint, scope, input, result, onResult }, {
|
|
|
876
206
|
return { runtime, adapter, error: computed(() => "") };
|
|
877
207
|
}
|
|
878
208
|
|
|
879
|
-
|
|
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 }) {
|
|
880
216
|
const app = getCurrentInstance()?.appContext.app;
|
|
881
217
|
if (!app) throw new Error("Conversation binding must be acquired from the application setup.");
|
|
882
|
-
if (boundedTask
|
|
218
|
+
if (boundedTask != null) return { app, defaults: NO_CONVERSATION_DEFAULTS };
|
|
883
219
|
const routeContext = useSurfaceRouteContext();
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
return {
|
|
889
|
-
|
|
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
|
+
};
|
|
890
233
|
}
|
|
891
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
|
+
*/
|
|
892
240
|
function createConversationBinding({ conversationId, endpoint = "", surfaceId = "", hostSurfaceId = "", workspaceSlug,
|
|
893
|
-
actorKey: suppliedActorKey, api: suppliedApi
|
|
241
|
+
actorKey: suppliedActorKey, api: suppliedApi, socket: suppliedSocket, active = true, onEvent,
|
|
894
242
|
clearDraftOn: suppliedClearDraftOn, queueWhileSending, deferWhileWorking = false, admitWhileWorking = false, draftWhileLoading = false,
|
|
895
|
-
draftStorage
|
|
896
|
-
data, attachments
|
|
897
|
-
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 ||
|
|
898
246
|
conversationBindingSetup({ socket: suppliedSocket, boundedTask });
|
|
899
|
-
const clearDraftOn = suppliedClearDraftOn ?? defaults.clearDraftOn
|
|
247
|
+
const clearDraftOn = suppliedClearDraftOn ?? defaults.clearDraftOn;
|
|
900
248
|
const actorKey = computed(() => {
|
|
901
249
|
const actor = toValue(suppliedActorKey);
|
|
902
250
|
return actor === undefined ? toValue(defaults.actorKey) : actor;
|
|
@@ -907,8 +255,9 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
907
255
|
if (!["boolean", "function"].includes(typeof admitWhileWorking)) throw new TypeError("admitWhileWorking must be a boolean.");
|
|
908
256
|
resolveWorkingPolicy(null, { deferWhileWorking, admitWhileWorking });
|
|
909
257
|
if (typeof draftWhileLoading !== "boolean") throw new TypeError("draftWhileLoading must be a boolean.");
|
|
910
|
-
|
|
911
|
-
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 });
|
|
912
261
|
const socket = suppliedSocket || setupSocket;
|
|
913
262
|
const runtime = shallowRef(null);
|
|
914
263
|
const initialDraft = ref("");
|
|
@@ -936,12 +285,17 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
936
285
|
const route = resolveAssistantRuntimeRoute(surface, toValue(workspaceSlug) === undefined
|
|
937
286
|
? workspaceScope.readRouteScope(routeContext).workspaceSlug : text(workspaceSlug));
|
|
938
287
|
const actor = toValue(actorKey);
|
|
939
|
-
return Object.freeze({
|
|
940
|
-
|
|
288
|
+
return Object.freeze({
|
|
289
|
+
conversationId: text(conversationId),
|
|
290
|
+
targetSurfaceId: target,
|
|
291
|
+
hostSurfaceId: host || target,
|
|
292
|
+
workspaceSlug: route.workspaceSlug,
|
|
293
|
+
configured: route.configured,
|
|
941
294
|
actorKey: actor === undefined ? String(routeContext.placementContext.value?.user?.id || "") : text(actor),
|
|
942
|
-
endpoint: text(endpoint) || route.endpoint
|
|
295
|
+
endpoint: text(endpoint) || route.endpoint
|
|
296
|
+
});
|
|
943
297
|
});
|
|
944
|
-
// The
|
|
298
|
+
// The binding's own composer owns input before a conversation ID is available.
|
|
945
299
|
// It is scoped to the real actor/surface/workspace, never a placeholder ID.
|
|
946
300
|
const draftScope = computed(() => {
|
|
947
301
|
const scope = identity.value;
|
|
@@ -960,9 +314,10 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
960
314
|
if (!target.configured || !target.conversationId || !target.actorKey || !target.targetSurfaceId) return;
|
|
961
315
|
const key = JSON.stringify(["assistant", target.actorKey, target.endpoint, target.targetSurfaceId,
|
|
962
316
|
target.hostSurfaceId, target.workspaceSlug, target.conversationId]);
|
|
963
|
-
retained = retainAssistantConversation(app, key, readers =>
|
|
964
|
-
readers, socket, actorKey, placement, queueWhileSending, deferWhileWorking, admitWhileWorking,
|
|
965
|
-
|
|
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,
|
|
966
321
|
resolveBasePath: () => target.endpoint, resolveSurfaceId: () => target.hostSurfaceId })
|
|
967
322
|
}), { active, questions, goal, onEvent });
|
|
968
323
|
runtime.value = retained.runtime;
|
|
@@ -995,7 +350,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
995
350
|
if (retry && retry.status !== "failed") return false;
|
|
996
351
|
if (!retry && owner?.canSubmit === false) return false;
|
|
997
352
|
const prompts = questionOwner.value;
|
|
998
|
-
const formatsQuestions =
|
|
353
|
+
const formatsQuestions = formatsAnswers(prompts);
|
|
999
354
|
if (!retry && formatsQuestions && prompts.questions?.length &&
|
|
1000
355
|
!questionsAnswered(prompts)) return false;
|
|
1001
356
|
const answer = !retry && formatsQuestions ? prompts.capture(current.draft.value) : null;
|
|
@@ -1013,8 +368,9 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1013
368
|
// standard element. Creation, routing and access policy remain preparation.
|
|
1014
369
|
function submitPrepared(payload, { messageId = crypto.randomUUID(), retryMessageId = "", retry,
|
|
1015
370
|
working = false, clearDraft = true, attachments = attachmentOwner.value,
|
|
1016
|
-
isCurrent =
|
|
371
|
+
isCurrent = alwaysCurrent, onSubmit, onAccepted, prepare } = {}) {
|
|
1017
372
|
const current = runtime.value;
|
|
373
|
+
// readable-check: allow method-probe - input validation: an application hands a prepared submission its own preparation
|
|
1018
374
|
if (typeof prepare !== "function") throw new TypeError("Prepared submission requires application preparation.");
|
|
1019
375
|
if (!current?.current.value || !isCurrent()) return false;
|
|
1020
376
|
const id = retryMessageId || messageId;
|
|
@@ -1033,7 +389,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1033
389
|
}
|
|
1034
390
|
|
|
1035
391
|
function submitCaptured(current, captured, { messageId, retry, clearDraft = true, attachments,
|
|
1036
|
-
isCurrent =
|
|
392
|
+
isCurrent = alwaysCurrent, onSubmit = ignoreSubmit, onAccepted = ignoreAcceptance, prepare }) {
|
|
1037
393
|
const waitForAcceptance = clearDraftOn === "accepted";
|
|
1038
394
|
const attachmentIds = [...(captured.request?.attachmentIds || captured.attachmentIds ||
|
|
1039
395
|
(captured.displayAttachments || []).map(file => file.attachmentId))];
|
|
@@ -1053,7 +409,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1053
409
|
catch (failure) { current.error.value = failureMessage(failure, t("jskit.assistant.runtime.attachments.clearFailed")); }
|
|
1054
410
|
}
|
|
1055
411
|
}
|
|
1056
|
-
onAccepted
|
|
412
|
+
onAccepted();
|
|
1057
413
|
}
|
|
1058
414
|
if (!retry && clearDraft) {
|
|
1059
415
|
if (waitForAcceptance) {
|
|
@@ -1061,7 +417,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1061
417
|
captured = { ...captured, draft: current.draft.value };
|
|
1062
418
|
} else current.draft.value = "";
|
|
1063
419
|
}
|
|
1064
|
-
onSubmit
|
|
420
|
+
onSubmit();
|
|
1065
421
|
const dispatch = () => prepare ? current.submitPrepared(captured, { messageId, onAccepted: acknowledge, prepare })
|
|
1066
422
|
: current.send(captured, { messageId, onAccepted: acknowledge });
|
|
1067
423
|
// A restored uncertain receipt may have no retained acceptance callback.
|
|
@@ -1088,7 +444,7 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1088
444
|
const state = current?.snapshot.value;
|
|
1089
445
|
const files = state?.capabilities?.attachments ? attachmentOwner.value : null;
|
|
1090
446
|
const prompts = current?.available.value ? questionOwner.value : null;
|
|
1091
|
-
const formatsQuestions =
|
|
447
|
+
const formatsQuestions = formatsAnswers(prompts);
|
|
1092
448
|
const draft = current?.draft.value ?? initialDraft.value;
|
|
1093
449
|
const editable = canDraftWhileLoading.value ? !current || current.editable.value : current?.available.value;
|
|
1094
450
|
const message = formatsQuestions ? prompts.capture(draft).message : draft;
|
|
@@ -1137,12 +493,18 @@ function createConversationBinding({ conversationId, endpoint = "", surfaceId =
|
|
|
1137
493
|
return { runtime, adapter, submitPrepared, error: computed(() => runtime.value?.error.value || stateError(runtime.value?.snapshot.value)) };
|
|
1138
494
|
}
|
|
1139
495
|
|
|
1140
|
-
/**
|
|
496
|
+
/**
|
|
497
|
+
* The standard element and custom text/voice presentation share the supplied binding.
|
|
498
|
+
* @param {ConversationBindingOptions} options
|
|
499
|
+
*/
|
|
1141
500
|
function useAssistantConversation(options) {
|
|
1142
501
|
return createConversationBinding(options);
|
|
1143
502
|
}
|
|
1144
503
|
|
|
1145
|
-
/**
|
|
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
|
+
*/
|
|
1146
508
|
function useAssistantConversationFactory(options = {}) {
|
|
1147
509
|
const setup = conversationBindingSetup(options);
|
|
1148
510
|
const owner = getCurrentScope();
|
|
@@ -1152,7 +514,13 @@ function useAssistantConversationFactory(options = {}) {
|
|
|
1152
514
|
const scope = owner.run(() => effectScope());
|
|
1153
515
|
try {
|
|
1154
516
|
const binding = scope.run(() => createConversationBinding({ ...options, ...input }, setup));
|
|
1155
|
-
return {
|
|
517
|
+
return {
|
|
518
|
+
runtime: binding.runtime,
|
|
519
|
+
adapter: binding.adapter,
|
|
520
|
+
submitPrepared: binding.submitPrepared,
|
|
521
|
+
error: binding.error,
|
|
522
|
+
release: () => scope.stop()
|
|
523
|
+
};
|
|
1156
524
|
} catch (failure) {
|
|
1157
525
|
scope.stop();
|
|
1158
526
|
throw failure;
|