@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
|
@@ -22,7 +22,6 @@ import { usePagedCollection } from "@jskit-ai/http-web/client/composables/usePag
|
|
|
22
22
|
import { useSurfaceRouteContext } from "@jskit-ai/shell-web/client/navigation/useSurfaceRouteContext";
|
|
23
23
|
import { resolveAssistantRuntimeRoute, resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
|
|
24
24
|
import {
|
|
25
|
-
buildHistory,
|
|
26
25
|
buildId,
|
|
27
26
|
interruptPendingToolEvents,
|
|
28
27
|
mapTranscriptEntriesToAssistantState,
|
|
@@ -34,12 +33,15 @@ import {
|
|
|
34
33
|
} from "../support/conversationRestoreSupport.js";
|
|
35
34
|
import { useWorkspaceWebScopeSupport } from "../support/workspaceScopeSupport.js";
|
|
36
35
|
import { useJskitText } from "../i18n.js";
|
|
36
|
+
import { failureMessage } from "../support/conversationFailures.js";
|
|
37
37
|
|
|
38
38
|
const DEFAULT_STREAM_TIMEOUT_MS = 120_000;
|
|
39
39
|
const DEFAULT_HISTORY_PAGE_SIZE = 20;
|
|
40
40
|
const DEFAULT_HISTORY_STALE_TIME_MS = 60_000;
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
/** @typedef {{ targetSurfaceId: string, userId: string | null, workspaceSlug: string }} RuntimeScope */
|
|
43
|
+
|
|
44
|
+
function toNonNegativeInteger(value, fallback) {
|
|
43
45
|
const parsed = Number(value);
|
|
44
46
|
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
45
47
|
return fallback;
|
|
@@ -48,27 +50,47 @@ function toNonNegativeInteger(value, fallback = 0) {
|
|
|
48
50
|
return parsed;
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
53
|
+
/**
|
|
54
|
+
* The session storage key of a runtime scope's active conversation.
|
|
55
|
+
* @param {RuntimeScope} scope
|
|
56
|
+
*/
|
|
57
|
+
function buildScopeStorageKey(scope) {
|
|
58
|
+
const runtimeSurfaceId = normalizeText(scope.targetSurfaceId).toLowerCase() || "assistant";
|
|
59
|
+
const workspaceSlug = normalizeText(scope.workspaceSlug).toLowerCase() || "global";
|
|
60
|
+
const userId = normalizeRecordId(scope.userId, { fallback: "anonymous" });
|
|
55
61
|
return `assistant.activeConversationId:${runtimeSurfaceId}:${workspaceSlug}:${userId}`;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
/** The browser's session storage, or none outside a browser or where the browser has none. */
|
|
65
|
+
function browserSessionStorage() {
|
|
66
|
+
return typeof window === "undefined" ? null : window.sessionStorage || null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The conversation this scope had open in this browser tab, or "".
|
|
71
|
+
* @param {RuntimeScope} scope
|
|
72
|
+
*/
|
|
73
|
+
function readStoredActiveConversationId(scope) {
|
|
74
|
+
const storage = browserSessionStorage();
|
|
75
|
+
if (!storage) {
|
|
60
76
|
return "";
|
|
61
77
|
}
|
|
62
78
|
|
|
63
79
|
try {
|
|
64
|
-
return normalizeRecordId(
|
|
80
|
+
return normalizeRecordId(storage.getItem(buildScopeStorageKey(scope)), { fallback: "" });
|
|
65
81
|
} catch {
|
|
66
82
|
return "";
|
|
67
83
|
}
|
|
68
84
|
}
|
|
69
85
|
|
|
70
|
-
|
|
71
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Remembers this scope's open conversation in this browser tab; an empty id forgets it.
|
|
88
|
+
* @param {RuntimeScope} scope
|
|
89
|
+
* @param {unknown} conversationId
|
|
90
|
+
*/
|
|
91
|
+
function writeStoredActiveConversationId(scope, conversationId) {
|
|
92
|
+
const storage = browserSessionStorage();
|
|
93
|
+
if (!storage) {
|
|
72
94
|
return;
|
|
73
95
|
}
|
|
74
96
|
|
|
@@ -76,11 +98,11 @@ function writeStoredActiveConversationId(scope = {}, conversationId) {
|
|
|
76
98
|
const storageKey = buildScopeStorageKey(scope);
|
|
77
99
|
try {
|
|
78
100
|
if (normalizedConversationId) {
|
|
79
|
-
|
|
101
|
+
storage.setItem(storageKey, normalizedConversationId);
|
|
80
102
|
return;
|
|
81
103
|
}
|
|
82
104
|
|
|
83
|
-
|
|
105
|
+
storage.removeItem(storageKey);
|
|
84
106
|
} catch {
|
|
85
107
|
return;
|
|
86
108
|
}
|
|
@@ -98,12 +120,6 @@ function formatConversationStartedAt(value) {
|
|
|
98
120
|
return (source && formatDateTime(source)) || t("jskit.assistant.runtime.conversation.startedAtUnknown");
|
|
99
121
|
}
|
|
100
122
|
|
|
101
|
-
// A failure or stream error event in the active language when its code is JSKIT's;
|
|
102
|
-
// otherwise its message as sent.
|
|
103
|
-
function failureText(failure) {
|
|
104
|
-
return failure?.message ? normalizeText(useJskitText().serverMessage(failure)) : "";
|
|
105
|
-
}
|
|
106
|
-
|
|
107
123
|
function resolveRuntimePolicy() {
|
|
108
124
|
const appConfig = getClientAppConfig();
|
|
109
125
|
const assistantConfig = normalizeObject(appConfig?.assistant);
|
|
@@ -121,20 +137,19 @@ function resolveRuntimePolicy() {
|
|
|
121
137
|
});
|
|
122
138
|
}
|
|
123
139
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" } = {}) {
|
|
140
|
+
/** A send whose acceptance needs no further cleanup. */
|
|
141
|
+
function ignoreAcceptance() {}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The runtime of the ready-made assistant surface: one streamed conversation at a time, its saved history and the
|
|
145
|
+
* stored active conversation.
|
|
146
|
+
* @param {{
|
|
147
|
+
* api?: import("@jskit-ai/assistant-core/client/types").AssistantApi,
|
|
148
|
+
* surfaceId?: import("vue").MaybeRefOrGetter<string>,
|
|
149
|
+
* integrationId?: import("vue").MaybeRefOrGetter<string>
|
|
150
|
+
* }} options `api` replaces the surface's standard assistant API.
|
|
151
|
+
*/
|
|
152
|
+
function useAssistantRuntime({ api, surfaceId = "", integrationId = "" }) {
|
|
138
153
|
const runtimePolicy = resolveRuntimePolicy();
|
|
139
154
|
const queryClient = useQueryClient();
|
|
140
155
|
const errorRuntime = useShellWebErrorRuntime();
|
|
@@ -170,8 +185,9 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
|
|
|
170
185
|
}));
|
|
171
186
|
const hasRuntimeScope = computed(() => runtimeRoute.value.configured && Boolean(runtimeScope.value.userId));
|
|
172
187
|
|
|
173
|
-
const runtimeApi =
|
|
174
|
-
|
|
188
|
+
const runtimeApi = api ?? createAssistantApi({
|
|
189
|
+
request: assistantHttpClient.request,
|
|
190
|
+
requestStream: assistantHttpClient.requestStream,
|
|
175
191
|
resolveBasePath: () => runtimeRoute.value.endpoint,
|
|
176
192
|
resolveSurfaceId: () => normalizeText(currentSurfaceId.value).toLowerCase()
|
|
177
193
|
});
|
|
@@ -376,7 +392,7 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
|
|
|
376
392
|
writeStoredActiveConversationId(scope, "");
|
|
377
393
|
}
|
|
378
394
|
if (!input.value) input.value = previousDraft;
|
|
379
|
-
setRuntimeError(
|
|
395
|
+
setRuntimeError(normalizeText(failureMessage(loadError)) || t("jskit.assistant.runtime.history.conversationLoadFailed"));
|
|
380
396
|
} finally {
|
|
381
397
|
if (version === restoreVersion) isRestoringConversation.value = false;
|
|
382
398
|
}
|
|
@@ -416,7 +432,7 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
|
|
|
416
432
|
abortController.value.abort();
|
|
417
433
|
}
|
|
418
434
|
|
|
419
|
-
async function sendMessage({ attachments = [], onAccepted, retryMessageId = "" } = {}) {
|
|
435
|
+
async function sendMessage({ attachments = [], onAccepted = ignoreAcceptance, retryMessageId = "" } = {}) {
|
|
420
436
|
if (isStreaming.value || isRestoringConversation.value || !hasRuntimeScope.value) return false;
|
|
421
437
|
const retry = retryMessageId ? delivery.find(retryMessageId) : null;
|
|
422
438
|
if (retryMessageId && retry?.status !== "failed") return false;
|
|
@@ -429,8 +445,7 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
|
|
|
429
445
|
input: message,
|
|
430
446
|
...(parsedConversationId ? { conversationId: parsedConversationId } : {}),
|
|
431
447
|
...(attachments.length ? { attachmentIds: attachments.map(file => file.attachmentId) } : {}),
|
|
432
|
-
...(toValue(integrationId) ? { integrationId: toValue(integrationId) } : {})
|
|
433
|
-
history: buildHistory(messages.value)
|
|
448
|
+
...(toValue(integrationId) ? { integrationId: toValue(integrationId) } : {})
|
|
434
449
|
}
|
|
435
450
|
};
|
|
436
451
|
if (!payload.message) return false;
|
|
@@ -485,7 +500,7 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
|
|
|
485
500
|
appendMessage({ id: messageId, role: "user", kind: "chat", text: normalizedInput, attachments, status: "done" });
|
|
486
501
|
appendMessage({ id: assistantMessageId, role: "assistant", kind: "chat", text: "", status: "streaming" });
|
|
487
502
|
delivery.reconcile([{ user: { messageId } }]);
|
|
488
|
-
onAccepted
|
|
503
|
+
onAccepted();
|
|
489
504
|
admission.resolve(true);
|
|
490
505
|
}
|
|
491
506
|
conversationId.value = normalizeRecordId(event?.conversationId, { fallback: null });
|
|
@@ -554,11 +569,11 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
|
|
|
554
569
|
|
|
555
570
|
if (eventType === ASSISTANT_STREAM_EVENT_TYPES.ERROR) {
|
|
556
571
|
if (!accepted) {
|
|
557
|
-
admissionError =
|
|
572
|
+
admissionError = normalizeText(failureMessage(event)) || t("jskit.assistant.runtime.request.failed");
|
|
558
573
|
return;
|
|
559
574
|
}
|
|
560
575
|
setRuntimeError(
|
|
561
|
-
|
|
576
|
+
normalizeText(failureMessage(event)) || t("jskit.assistant.runtime.request.failed"),
|
|
562
577
|
"assistant.runtime:stream-event-error"
|
|
563
578
|
);
|
|
564
579
|
updateMessage(assistantMessageId, {
|
|
@@ -603,7 +618,7 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
|
|
|
603
618
|
status: "canceled"
|
|
604
619
|
});
|
|
605
620
|
} else {
|
|
606
|
-
setRuntimeError(
|
|
621
|
+
setRuntimeError(normalizeText(failureMessage(streamError)) || t("jskit.assistant.runtime.request.failed"));
|
|
607
622
|
updateMessage(assistantMessageId, {
|
|
608
623
|
status: "error"
|
|
609
624
|
});
|
|
@@ -636,7 +651,7 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
|
|
|
636
651
|
};
|
|
637
652
|
});
|
|
638
653
|
|
|
639
|
-
return
|
|
654
|
+
return {
|
|
640
655
|
meta: {
|
|
641
656
|
normalizeConversationStatus,
|
|
642
657
|
formatConversationStartedAt
|
|
@@ -673,7 +688,7 @@ function useAssistantRuntime({ api = null, surfaceId = "", integrationId = "" }
|
|
|
673
688
|
loadMoreConversationHistory
|
|
674
689
|
},
|
|
675
690
|
viewer
|
|
676
|
-
}
|
|
691
|
+
};
|
|
677
692
|
}
|
|
678
693
|
|
|
679
694
|
export { useAssistantRuntime };
|
|
@@ -6,6 +6,7 @@ const AssistantClientProvider = defineProvider({
|
|
|
6
6
|
requires: {
|
|
7
7
|
components: "client.components"
|
|
8
8
|
},
|
|
9
|
+
/** @param {{ components: import("@jskit-ai/kernel/client/types").ClientComponents }} capabilities */
|
|
9
10
|
setup({ components }) {
|
|
10
11
|
components.register("assistant.web.settings.element", AssistantSettingsClientElement);
|
|
11
12
|
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { normalizeObject, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
2
|
import {
|
|
3
|
-
MAX_HISTORY_MESSAGES,
|
|
4
|
-
MAX_INPUT_CHARS,
|
|
5
3
|
isAssistantProgressOnlyText,
|
|
6
4
|
parseJsonObject
|
|
7
5
|
} from "@jskit-ai/assistant-core/shared";
|
|
@@ -37,7 +35,12 @@ function mapAssistantConversationTurns(messages, toolEvents = [], { showToolActi
|
|
|
37
35
|
})));
|
|
38
36
|
}
|
|
39
37
|
|
|
40
|
-
|
|
38
|
+
/**
|
|
39
|
+
* A new local id with a prefix that says what it names.
|
|
40
|
+
* @param {string} prefix
|
|
41
|
+
*/
|
|
42
|
+
function buildId(prefix) {
|
|
43
|
+
// readable-check: allow method-probe - platform object: crypto.randomUUID exists only in a secure browser context
|
|
41
44
|
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
42
45
|
return `${prefix}_${crypto.randomUUID()}`;
|
|
43
46
|
}
|
|
@@ -49,36 +52,6 @@ function normalizeToolName(value) {
|
|
|
49
52
|
return normalizeText(value) || "tool";
|
|
50
53
|
}
|
|
51
54
|
|
|
52
|
-
function buildHistory(messages) {
|
|
53
|
-
const normalizedHistory = (Array.isArray(messages) ? messages : [])
|
|
54
|
-
.filter((message) => {
|
|
55
|
-
if (!message || typeof message !== "object") {
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
if (message.kind !== "chat") {
|
|
59
|
-
return false;
|
|
60
|
-
}
|
|
61
|
-
if (message.role !== "user" && message.role !== "assistant") {
|
|
62
|
-
return false;
|
|
63
|
-
}
|
|
64
|
-
if (normalizeText(message.status).toLowerCase() !== "done") {
|
|
65
|
-
return false;
|
|
66
|
-
}
|
|
67
|
-
const text = normalizeText(message.text);
|
|
68
|
-
if (!text) {
|
|
69
|
-
return false;
|
|
70
|
-
}
|
|
71
|
-
return message.role !== "assistant" || !isAssistantProgressOnlyText(text);
|
|
72
|
-
})
|
|
73
|
-
.map((message) => ({
|
|
74
|
-
role: message.role,
|
|
75
|
-
content: String(message.text || "").slice(0, MAX_INPUT_CHARS),
|
|
76
|
-
...(message.role === "user" && message.attachments?.length ? { attachmentIds: message.attachments.map(file => file.attachmentId) } : {})
|
|
77
|
-
}));
|
|
78
|
-
|
|
79
|
-
return normalizedHistory.slice(-MAX_HISTORY_MESSAGES);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
55
|
function interruptPendingToolEvents(toolEvents) {
|
|
83
56
|
return (Array.isArray(toolEvents) ? toolEvents : []).map((toolEvent) => {
|
|
84
57
|
if (normalizeText(toolEvent?.status).toLowerCase() !== "pending") {
|
|
@@ -179,7 +152,6 @@ function mapTranscriptEntriesToAssistantState(entries) {
|
|
|
179
152
|
}
|
|
180
153
|
|
|
181
154
|
export {
|
|
182
|
-
buildHistory,
|
|
183
155
|
buildId,
|
|
184
156
|
interruptPendingToolEvents,
|
|
185
157
|
mapTranscriptEntriesToAssistantState,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { useJskitText } from "../i18n.js";
|
|
2
|
+
|
|
3
|
+
const { serverMessage } = useJskitText();
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Whether a conversation failure means the viewer lost access to it.
|
|
7
|
+
* @param {{ status?: number, statusCode?: number }} error
|
|
8
|
+
*/
|
|
9
|
+
function isAccessDenied(error) {
|
|
10
|
+
return [401, 403].includes(Number(error.status || error.statusCode));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A failure in the active language when its code or messageCode is JSKIT's English; otherwise its message as sent.
|
|
15
|
+
* @param {any} failure
|
|
16
|
+
* @param {string} [fallback] The text for a failure without a message.
|
|
17
|
+
*/
|
|
18
|
+
function failureMessage(failure, fallback = "") {
|
|
19
|
+
return failure?.message ? serverMessage(failure) : fallback;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A refused result's error text, read like a failure's message.
|
|
24
|
+
* @param {any} result
|
|
25
|
+
*/
|
|
26
|
+
function resultError(result) {
|
|
27
|
+
return failureMessage({ ...result, message: result?.error });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export { failureMessage, isAccessDenied, resultError };
|
|
@@ -18,6 +18,10 @@ function normalizeTotalPages(value) {
|
|
|
18
18
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : 1;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The page size and entry bound a conversation restore uses, from the application's configuration.
|
|
23
|
+
* @param {{ pageSize?: unknown, maxEntries?: unknown }} [configured]
|
|
24
|
+
*/
|
|
21
25
|
function resolveConversationRestorePolicy({ pageSize, maxEntries } = {}) {
|
|
22
26
|
return Object.freeze({
|
|
23
27
|
pageSize: normalizeBoundedPositiveInteger(
|
|
@@ -33,15 +37,19 @@ function resolveConversationRestorePolicy({ pageSize, maxEntries } = {}) {
|
|
|
33
37
|
});
|
|
34
38
|
}
|
|
35
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Loads the newest transcript pages of a conversation, up to the restore policy's bound.
|
|
42
|
+
* @param {{
|
|
43
|
+
* fetchPage: (page: number, pageSize: number) => Promise<{ totalPages?: number, entries?: object[] }>,
|
|
44
|
+
* pageSize?: number,
|
|
45
|
+
* maxEntries?: number
|
|
46
|
+
* }} options
|
|
47
|
+
*/
|
|
36
48
|
async function loadConversationTranscript({
|
|
37
49
|
fetchPage,
|
|
38
50
|
pageSize = DEFAULT_RESTORE_MESSAGES_PAGE_SIZE,
|
|
39
51
|
maxEntries = DEFAULT_RESTORE_MESSAGES_MAX_ENTRIES
|
|
40
|
-
}
|
|
41
|
-
if (typeof fetchPage !== "function") {
|
|
42
|
-
throw new TypeError("loadConversationTranscript requires fetchPage().");
|
|
43
|
-
}
|
|
44
|
-
|
|
52
|
+
}) {
|
|
45
53
|
const restorePolicy = resolveConversationRestorePolicy({ pageSize, maxEntries });
|
|
46
54
|
const normalizedPageSize = restorePolicy.pageSize;
|
|
47
55
|
const normalizedMaxEntries = restorePolicy.maxEntries;
|
|
@@ -4,13 +4,18 @@ import {
|
|
|
4
4
|
ASSISTANT_CONVERSATION_SUBSCRIBE,
|
|
5
5
|
ASSISTANT_CONVERSATION_UNSUBSCRIBE
|
|
6
6
|
} from "../../shared/conversationRealtime.js";
|
|
7
|
-
import { isAccessDenied } from "./
|
|
7
|
+
import { isAccessDenied } from "./conversationFailures.js";
|
|
8
8
|
|
|
9
|
-
// Private to the
|
|
9
|
+
// Private to the conversation history and its subscription. Symbols
|
|
10
10
|
// survive local snapshot spreads but never enter the conversation wire format.
|
|
11
11
|
const refreshedHistoryPages = Symbol("assistant.refreshedHistoryPages");
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Applies a transcript patch to the loaded older page that holds its turn.
|
|
15
|
+
* @param {any[]} pages
|
|
16
|
+
* @param {any} patch
|
|
17
|
+
*/
|
|
18
|
+
function patchLoadedHistoryPages(pages, patch) {
|
|
14
19
|
return pages.map(page => page.conversationLog.some(turn => turn.turnId === patch?.turn?.turnId)
|
|
15
20
|
? applyConversationLogPatch(page, patch, { limit: page.pagination?.limit }) || page : page);
|
|
16
21
|
}
|
|
@@ -23,16 +28,34 @@ const SNAPSHOT_EVENTS = new Set(["accepted", "settled", "configuration", "phase"
|
|
|
23
28
|
const SUBSCRIPTION_NOTICE_MS = 10_000;
|
|
24
29
|
const SUBSCRIPTION_LOST_MS = 120_000;
|
|
25
30
|
|
|
31
|
+
/** A subscriber that leaves conversation events to its state listener. */
|
|
32
|
+
function ignoreConversationEvent() {}
|
|
33
|
+
/** A subscriber that shows no subscription failure. */
|
|
34
|
+
function ignoreSubscriptionError() {}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Subscribes to one conversation over the shared realtime socket: a canonical read, then live events applied in
|
|
38
|
+
* stream order. Returns the release, which also carries `reload`.
|
|
39
|
+
* @param {{
|
|
40
|
+
* socket: import("../types.js").RealtimeSocket,
|
|
41
|
+
* conversationId: string,
|
|
42
|
+
* targetSurfaceId: string,
|
|
43
|
+
* hostSurfaceId: string,
|
|
44
|
+
* workspaceSlug?: string,
|
|
45
|
+
* read: (read: { current: () => boolean }) => Promise<any>,
|
|
46
|
+
* onState: (state: any, options: { initial: boolean, canonical: boolean }) => void,
|
|
47
|
+
* onEvent?: (event: any) => void,
|
|
48
|
+
* onError?: (failure: Error) => void
|
|
49
|
+
* }} subscription
|
|
50
|
+
*/
|
|
26
51
|
function subscribeAssistantConversation({
|
|
27
52
|
socket, conversationId, targetSurfaceId, hostSurfaceId, workspaceSlug,
|
|
28
|
-
read, onState, onEvent =
|
|
29
|
-
}
|
|
53
|
+
read, onState, onEvent = ignoreConversationEvent, onError = ignoreSubscriptionError
|
|
54
|
+
}) {
|
|
55
|
+
// readable-check: allow method-probe - input validation: an application may hand the conversation binding its own socket
|
|
30
56
|
if (typeof socket?.on !== "function" || typeof socket?.off !== "function" || typeof socket?.timeout !== "function") {
|
|
31
57
|
throw new TypeError("Conversation subscriptions require the application's shared realtime socket.");
|
|
32
58
|
}
|
|
33
|
-
if (typeof read !== "function" || typeof onState !== "function" || typeof onEvent !== "function") {
|
|
34
|
-
throw new TypeError("Conversation subscriptions require a canonical read operation and state listener.");
|
|
35
|
-
}
|
|
36
59
|
const subscriptionId = crypto.randomUUID();
|
|
37
60
|
const input = { subscriptionId, conversationId, targetSurfaceId, hostSurfaceId,
|
|
38
61
|
...(workspaceSlug === undefined ? {} : { workspaceSlug }) };
|
|
@@ -95,7 +118,7 @@ function subscribeAssistantConversation({
|
|
|
95
118
|
return read({ current });
|
|
96
119
|
}).then(snapshot => {
|
|
97
120
|
if (!current()) return;
|
|
98
|
-
//
|
|
121
|
+
// Retain only delivered updates
|
|
99
122
|
// received while this request was in flight before replacing its cache.
|
|
100
123
|
for (const patch of pendingRead.patches) {
|
|
101
124
|
snapshot = applyConversationLogPatch(snapshot, patch, { limit: snapshot.pagination?.limit });
|
|
@@ -1,38 +1,29 @@
|
|
|
1
1
|
import { inject } from "vue";
|
|
2
2
|
|
|
3
|
+
/** workspaces-web provides its route scope under this key in an application with workspaces. */
|
|
3
4
|
const WORKSPACES_WEB_SCOPE_SUPPORT_INJECTION_KEY = "jskit.workspaces.web.scope-support";
|
|
4
5
|
|
|
5
6
|
const EMPTY_ROUTE_SCOPE = Object.freeze({
|
|
6
7
|
workspaceSlug: ""
|
|
7
8
|
});
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
/**
|
|
11
|
+
* An application without workspaces: no route names a workspace.
|
|
12
|
+
* @type {import("../types.js").WorkspaceScopeSupport}
|
|
13
|
+
*/
|
|
14
|
+
const NO_WORKSPACE_WEB_SCOPE_SUPPORT = Object.freeze({
|
|
10
15
|
available: false,
|
|
11
16
|
readRouteScope() {
|
|
12
17
|
return EMPTY_ROUTE_SCOPE;
|
|
13
18
|
}
|
|
14
19
|
});
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
|
|
21
|
+
/**
|
|
22
|
+
* The application's workspace route scope, or none when it has no workspaces.
|
|
23
|
+
* @returns {import("../types.js").WorkspaceScopeSupport}
|
|
24
|
+
*/
|
|
25
|
+
function useWorkspaceWebScopeSupport() {
|
|
26
|
+
return inject(WORKSPACES_WEB_SCOPE_SUPPORT_INJECTION_KEY, NO_WORKSPACE_WEB_SCOPE_SUPPORT);
|
|
18
27
|
}
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
const support = inject(WORKSPACES_WEB_SCOPE_SUPPORT_INJECTION_KEY, null);
|
|
22
|
-
if (isWorkspaceWebScopeSupport(support)) {
|
|
23
|
-
return support;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
if (required) {
|
|
27
|
-
throw new Error("Workspace web scope support is not available in Vue injection context.");
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
return EMPTY_WORKSPACE_WEB_SCOPE_SUPPORT;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export {
|
|
34
|
-
EMPTY_WORKSPACE_WEB_SCOPE_SUPPORT,
|
|
35
|
-
WORKSPACES_WEB_SCOPE_SUPPORT_INJECTION_KEY,
|
|
36
|
-
isWorkspaceWebScopeSupport,
|
|
37
|
-
useWorkspaceWebScopeSupport
|
|
38
|
-
};
|
|
29
|
+
export { useWorkspaceWebScopeSupport };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The types the assistant runtime's client hands out and receives across its files. Types assistant-core owns are
|
|
2
|
+
// imported from `@jskit-ai/assistant-core/client/types` where they are used.
|
|
3
|
+
|
|
4
|
+
/** @typedef {ReturnType<typeof import("@jskit-ai/realtime/client/composables/useRealtimeEvent").useRealtimeSocket>} RealtimeSocket The application's shared realtime socket. */
|
|
5
|
+
/** @typedef {ReturnType<typeof import("@jskit-ai/shell-web/client/navigation/useSurfaceRouteContext").useSurfaceRouteContext>} SurfaceRouteContext The current route's surface and placement context. */
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The shell's web placement runtime (`jskit.shell-web.runtime.web-placement.client`): the members a conversation reads.
|
|
9
|
+
* @typedef {object} PlacementRuntime
|
|
10
|
+
* @property {() => Record<string, any>} getContext The placement context, including the signed-in user.
|
|
11
|
+
* @property {(listener: () => void) => () => void} subscribe Calls the listener when the context changes; returns the unsubscribe.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The workspace route scope workspaces-web provides (`jskit.workspaces.web.scope-support`).
|
|
16
|
+
* @typedef {object} WorkspaceScopeSupport
|
|
17
|
+
* @property {boolean} available Whether the application has workspaces.
|
|
18
|
+
* @property {(routeContext: SurfaceRouteContext) => { workspaceSlug: string }} readRouteScope The workspace a route names.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The conversation one binding targets, resolved from its options and the route.
|
|
23
|
+
* @typedef {object} ConversationIdentity
|
|
24
|
+
* @property {string} conversationId
|
|
25
|
+
* @property {string} targetSurfaceId
|
|
26
|
+
* @property {string} hostSurfaceId
|
|
27
|
+
* @property {string} workspaceSlug
|
|
28
|
+
* @property {boolean} configured Whether the target surface has an assistant.
|
|
29
|
+
* @property {string} actorKey
|
|
30
|
+
* @property {string} endpoint
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* One view that keeps a retained conversation open.
|
|
35
|
+
* @typedef {object} ConversationReader
|
|
36
|
+
* @property {import("vue").MaybeRefOrGetter<boolean>} active Whether the view is shown; `false` stops it reading.
|
|
37
|
+
* @property {import("vue").MaybeRefOrGetter<boolean | object | null | undefined>} [questions] The view's question options.
|
|
38
|
+
* @property {import("vue").MaybeRefOrGetter<boolean | object | null | undefined>} [goal] `true` when the view reads the goal.
|
|
39
|
+
* @property {(event: object) => unknown} [onEvent] The view's presentation hook. A reader acquired through
|
|
40
|
+
* `runtime.retain()` has none.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
/** @typedef {ReturnType<typeof import("./support/subscribeAssistantConversation.js").subscribeAssistantConversation>} ConversationSubscription */
|
|
44
|
+
/** @typedef {ReturnType<typeof import("./composables/conversationRuntime.js").createConversationRuntime>} ConversationRuntime */
|
|
45
|
+
|
|
46
|
+
export {};
|
|
@@ -1,27 +1,57 @@
|
|
|
1
1
|
import { defineFeature } from "@jskit-ai/kernel/server/features";
|
|
2
2
|
import { actionIds } from "./actionIds.js";
|
|
3
3
|
import { createAssistantActions } from "./actions.js";
|
|
4
|
+
import { createHostedAssistant } from "./hostedAssistant.js";
|
|
4
5
|
import { registerRoutes } from "./registerRoutes.js";
|
|
5
6
|
import { registerConversationSubscriptions } from "./registerConversationSubscriptions.js";
|
|
6
7
|
|
|
8
|
+
/** @typedef {import("./types.js").AssistantRuntime} AssistantRuntime */
|
|
9
|
+
/** @typedef {import("./types.js").ConversationAccess} ConversationAccess */
|
|
10
|
+
/** @typedef {import("./types.js").ConversationRuntime} ConversationRuntime */
|
|
11
|
+
/** @typedef {import("@jskit-ai/kernel/server/types").ActionCatalogue} ActionCatalogue */
|
|
12
|
+
|
|
13
|
+
/** Before boot registers the conversation subscriptions, there are none to release. */
|
|
14
|
+
function releaseNoSubscriptions() {}
|
|
15
|
+
|
|
16
|
+
/** @type {WeakMap<AssistantRuntime, { actions: ActionCatalogue, release: () => void }>} */
|
|
7
17
|
const subscriptionLifecycle = new WeakMap();
|
|
8
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Refuses a host's conversation access that cannot own the conversation routes,
|
|
21
|
+
* actions and subscriptions.
|
|
22
|
+
* @param {ConversationAccess} conversationAccess
|
|
23
|
+
* @param {ConversationRuntime | null} conversationRuntime
|
|
24
|
+
*/
|
|
25
|
+
function assertConversationAccess(conversationAccess, conversationRuntime) {
|
|
26
|
+
// readable-check: allow method-probe - input validation: the host supplies its conversation access owner
|
|
27
|
+
if (!conversationRuntime || typeof conversationAccess.router?.register !== "function" ||
|
|
28
|
+
// readable-check: allow method-probe - input validation: the host supplies its conversation access owner
|
|
29
|
+
typeof conversationAccess.wrapAction !== "function") {
|
|
30
|
+
throw new TypeError("assistant.conversation.access requires assistant.conversations, router.register() and wrapAction().");
|
|
31
|
+
}
|
|
32
|
+
if (!["authenticated", "host"].includes(conversationAccess.requestPolicy) ||
|
|
33
|
+
typeof conversationAccess.subscribeActionId !== "string" || !conversationAccess.subscribeActionId.trim() ||
|
|
34
|
+
(conversationAccess.requestPolicy === "host" && conversationAccess.subscribeActionId === actionIds.conversationSubscribe)) {
|
|
35
|
+
throw new TypeError("assistant.conversation.access requires a fixed subscribeActionId and authenticated or host requestPolicy.");
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
9
39
|
const AssistantFeature = defineFeature({
|
|
10
40
|
id: "assistant.runtime",
|
|
11
41
|
domain: "assistant",
|
|
12
42
|
requires: {
|
|
13
43
|
config: "runtime.config",
|
|
14
44
|
env: "runtime.env",
|
|
15
|
-
|
|
45
|
+
events: "runtime.events",
|
|
46
|
+
http: "runtime.http",
|
|
47
|
+
logger: "runtime.logger"
|
|
16
48
|
},
|
|
17
49
|
optional: {
|
|
18
|
-
logger: "runtime.logger",
|
|
19
50
|
conversationRuntime: "assistant.conversations",
|
|
20
51
|
conversationAccess: "assistant.conversation.access",
|
|
21
52
|
assistantConfigService: "assistant.settings",
|
|
22
53
|
database: "runtime.database",
|
|
23
54
|
realtime: "runtime.realtime",
|
|
24
|
-
events: "runtime.events",
|
|
25
55
|
aiConnections: "integrations.ai",
|
|
26
56
|
attachments: "assistant.attachments",
|
|
27
57
|
consoleRuntime: "console.core",
|
|
@@ -33,23 +63,15 @@ const AssistantFeature = defineFeature({
|
|
|
33
63
|
async setup(dependencies, { actionCatalogue }) {
|
|
34
64
|
const { conversationRuntime, conversationAccess, assistantConfigService } = dependencies;
|
|
35
65
|
if (conversationAccess != null) {
|
|
36
|
-
|
|
37
|
-
typeof conversationAccess.wrapAction !== "function") {
|
|
38
|
-
throw new TypeError("assistant.conversation.access requires assistant.conversations, router.register() and wrapAction().");
|
|
39
|
-
}
|
|
40
|
-
if (!["authenticated", "host"].includes(conversationAccess.requestPolicy) ||
|
|
41
|
-
typeof conversationAccess.subscribeActionId !== "string" || !conversationAccess.subscribeActionId.trim() ||
|
|
42
|
-
(conversationAccess.requestPolicy === "host" && conversationAccess.subscribeActionId === actionIds.conversationSubscribe)) {
|
|
43
|
-
throw new TypeError("assistant.conversation.access requires a fixed subscribeActionId and authenticated or host requestPolicy.");
|
|
44
|
-
}
|
|
66
|
+
assertConversationAccess(conversationAccess, conversationRuntime);
|
|
45
67
|
}
|
|
46
68
|
let assistant;
|
|
47
69
|
if (conversationRuntime) {
|
|
70
|
+
// readable-check: allow method-probe - input validation: the host supplies its conversation runtime
|
|
48
71
|
if (typeof conversationRuntime.open !== "function") {
|
|
49
72
|
throw new TypeError("assistant.conversations requires createConversationRuntime().");
|
|
50
73
|
}
|
|
51
|
-
assistant =
|
|
52
|
-
services: Object.freeze({ conversations: conversationRuntime, config: assistantConfigService || null }) });
|
|
74
|
+
assistant = createHostedAssistant({ conversationRuntime, assistantConfigService: assistantConfigService || null });
|
|
53
75
|
} else {
|
|
54
76
|
if (!dependencies.database?.knex) {
|
|
55
77
|
throw new Error("AssistantFeature requires assistant.conversations, or runtime.database for database integration.");
|
|
@@ -60,8 +82,13 @@ const AssistantFeature = defineFeature({
|
|
|
60
82
|
const { createRepository: createMessagesRepository } = await import("./repositories/messagesRepository.js");
|
|
61
83
|
const { createRepository: createTurnRequestsRepository } = await import("./repositories/turnRequestsRepository.js");
|
|
62
84
|
assistant = createAssistantRuntime({
|
|
63
|
-
...dependencies,
|
|
64
85
|
actionCatalogue,
|
|
86
|
+
config: dependencies.config,
|
|
87
|
+
consoleRuntime: dependencies.consoleRuntime || null,
|
|
88
|
+
env: dependencies.env,
|
|
89
|
+
aiConnections: dependencies.aiConnections || null,
|
|
90
|
+
attachments: dependencies.attachments || null,
|
|
91
|
+
workspaces: dependencies.workspaces || null,
|
|
65
92
|
repositories: {
|
|
66
93
|
config: createAssistantConfigRepository(dependencies.database.knex),
|
|
67
94
|
conversations: createConversationsRepository(dependencies.database.knex),
|
|
@@ -81,7 +108,7 @@ const AssistantFeature = defineFeature({
|
|
|
81
108
|
assistantConfigService,
|
|
82
109
|
workspaceScopeSupport: dependencies.workspaces?.scope || null
|
|
83
110
|
});
|
|
84
|
-
subscriptionLifecycle.set(assistant, { actions: actionCatalogue });
|
|
111
|
+
subscriptionLifecycle.set(assistant, { actions: actionCatalogue, release: releaseNoSubscriptions });
|
|
85
112
|
return { assistant };
|
|
86
113
|
},
|
|
87
114
|
actions({ assistant, conversationAccess, config, workspaces }) {
|
|
@@ -111,8 +138,9 @@ const AssistantFeature = defineFeature({
|
|
|
111
138
|
});
|
|
112
139
|
},
|
|
113
140
|
async shutdown(_dependencies, { outputs }) {
|
|
114
|
-
subscriptionLifecycle.get(outputs.assistant)
|
|
141
|
+
const state = subscriptionLifecycle.get(outputs.assistant);
|
|
115
142
|
subscriptionLifecycle.delete(outputs.assistant);
|
|
143
|
+
state?.release();
|
|
116
144
|
await outputs.assistant.services.chat?.close();
|
|
117
145
|
}
|
|
118
146
|
});
|