@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.
Files changed (69) hide show
  1. package/README.md +1 -1
  2. package/fixtures/canonical-conversation/main.js +2 -1
  3. package/fixtures/canonical-conversation/vite.config.mjs +11 -0
  4. package/fixtures/conversation/main.js +2 -1
  5. package/fixtures/conversation/vite.config.mjs +4 -3
  6. package/locales/en.json +1 -0
  7. package/migrations/assistant_config_initial.cjs +1 -5
  8. package/migrations/assistant_transcripts_initial.cjs +3 -9
  9. package/migrations/assistant_transcripts_summary.cjs +28 -0
  10. package/migrations/assistant_turn_requests.cjs +2 -1
  11. package/migrations/constraints/assistant_users_foreign_keys.cjs +22 -0
  12. package/migrations/constraints/assistant_workspaces_foreign_keys.cjs +26 -0
  13. package/package.json +17 -10
  14. package/patterns/assistant-surface/example/src/ApplicationAiFeature.js +4 -2
  15. package/src/client/components/AssistantConversationClientElement.vue +10 -4
  16. package/src/client/components/AssistantSurfaceClientElement.vue +8 -4
  17. package/src/client/composables/conversationGoal.js +266 -0
  18. package/src/client/composables/conversationHistory.js +215 -0
  19. package/src/client/composables/conversationRuntime.js +624 -0
  20. package/src/client/composables/useAssistantConversation.js +208 -840
  21. package/src/client/composables/useAssistantRuntime.js +60 -45
  22. package/src/client/providers/AssistantClientProvider.js +1 -0
  23. package/src/client/support/assistantRuntimeState.js +6 -34
  24. package/src/client/support/conversationFailures.js +30 -0
  25. package/src/client/support/conversationRestoreSupport.js +13 -5
  26. package/src/client/support/subscribeAssistantConversation.js +32 -9
  27. package/src/client/support/workspaceScopeSupport.js +13 -22
  28. package/src/client/types.js +46 -0
  29. package/src/server/AssistantProvider.js +45 -17
  30. package/src/server/actions.js +162 -201
  31. package/src/server/conversationOperations.js +141 -0
  32. package/src/server/createAssistantRuntime.js +83 -44
  33. package/src/server/createAssistantSettingsActions.js +69 -47
  34. package/src/server/hostedAssistant.js +21 -0
  35. package/src/server/index.js +1 -0
  36. package/src/server/registerConversationSubscriptions.js +34 -11
  37. package/src/server/registerFastifyConversations.js +18 -6
  38. package/src/server/registerRoutes.js +5 -2
  39. package/src/server/registerSettingsRoutes.js +1 -1
  40. package/src/server/repositories/assistantConfigRepository.js +10 -20
  41. package/src/server/repositories/conversationsRepository.js +35 -10
  42. package/src/server/repositories/messagesRepository.js +14 -10
  43. package/src/server/repositories/repositoryPersistenceUtils.js +1 -6
  44. package/src/server/repositories/turnRequestsRepository.js +6 -1
  45. package/src/server/services/assistantConfigService.js +25 -23
  46. package/src/server/services/chatService.js +57 -61
  47. package/src/server/services/transcriptService.js +15 -7
  48. package/src/server/sqlConversationStorage.js +90 -18
  49. package/src/server/support/assistantServerConfig.js +1 -1
  50. package/src/server/support/assistantStreamWriter.js +7 -1
  51. package/src/server/support/createSurfaceAwareToolCatalog.js +35 -17
  52. package/src/server/types.js +38 -0
  53. package/src/shared/assistantSurfaces.js +2 -2
  54. package/test/assistantConversationBinding.browser.test.js +115 -1
  55. package/test/assistantConversationOperations.test.js +5 -5
  56. package/test/assistantRuntimeState.test.js +1 -16
  57. package/test/chatServiceLifecycle.test.js +62 -154
  58. package/test/createSurfaceAwareToolCatalog.test.js +5 -0
  59. package/test/featureRuntime.test.js +12 -6
  60. package/test/lazyAppConfig.test.js +3 -5
  61. package/test/packageMetadata.test.js +7 -1
  62. package/test/sqlConversationStorage.mysql.test.js +89 -0
  63. package/test/sqlConversationStorage.test.js +161 -16
  64. package/test/support/memoryAssistantRows.js +6 -1
  65. package/test/support/scriptedModel.js +32 -0
  66. package/test/turnRequests.test.js +4 -2
  67. package/test/usersForeignKeysMigration.test.js +46 -0
  68. package/test/workspacesForeignKeysMigration.test.js +66 -0
  69. 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
- function toNonNegativeInteger(value, fallback = 0) {
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
- function buildScopeStorageKey(scope = {}) {
52
- const runtimeSurfaceId = normalizeText(scope?.targetSurfaceId).toLowerCase() || "assistant";
53
- const workspaceSlug = normalizeText(scope?.workspaceSlug).toLowerCase() || "global";
54
- const userId = normalizeRecordId(scope?.userId, { fallback: "anonymous" });
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
- function readStoredActiveConversationId(scope = {}) {
59
- if (typeof window === "undefined" || !window.sessionStorage) {
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(window.sessionStorage.getItem(buildScopeStorageKey(scope)), { fallback: "" });
80
+ return normalizeRecordId(storage.getItem(buildScopeStorageKey(scope)), { fallback: "" });
65
81
  } catch {
66
82
  return "";
67
83
  }
68
84
  }
69
85
 
70
- function writeStoredActiveConversationId(scope = {}, conversationId) {
71
- if (typeof window === "undefined" || !window.sessionStorage) {
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
- window.sessionStorage.setItem(storageKey, normalizedConversationId);
101
+ storage.setItem(storageKey, normalizedConversationId);
80
102
  return;
81
103
  }
82
104
 
83
- window.sessionStorage.removeItem(storageKey);
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
- function createRuntimeApi({ overrideApi = null, resolveBasePath, resolveSurfaceId = null } = {}) {
125
- if (overrideApi && typeof overrideApi.streamChat === "function") {
126
- return overrideApi;
127
- }
128
-
129
- return createAssistantApi({
130
- request: assistantHttpClient.request,
131
- requestStream: assistantHttpClient.requestStream,
132
- resolveBasePath,
133
- resolveSurfaceId
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 = createRuntimeApi({
174
- overrideApi: api,
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(failureText(loadError) || t("jskit.assistant.runtime.history.conversationLoadFailed"));
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 = failureText(event) || t("jskit.assistant.runtime.request.failed");
572
+ admissionError = normalizeText(failureMessage(event)) || t("jskit.assistant.runtime.request.failed");
558
573
  return;
559
574
  }
560
575
  setRuntimeError(
561
- failureText(event) || t("jskit.assistant.runtime.request.failed"),
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(failureText(streamError) || t("jskit.assistant.runtime.request.failed"));
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 Object.freeze({
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
- function buildId(prefix = "id") {
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 "./accessDenied.js";
7
+ import { isAccessDenied } from "./conversationFailures.js";
8
8
 
9
- // Private to the original client history/subscription coordination. Symbols
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
- function patchLoadedHistoryPages(pages = [], patch) {
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 = () => {}, onError = () => {}
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
- // Ported from the original history reader: retain only delivered updates
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
- const EMPTY_WORKSPACE_WEB_SCOPE_SUPPORT = Object.freeze({
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
- function isWorkspaceWebScopeSupport(value) {
17
- return Boolean(value && typeof value.readRouteScope === "function");
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
- function useWorkspaceWebScopeSupport({ required = false } = {}) {
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
- http: "runtime.http"
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
- if (!conversationRuntime || typeof conversationAccess.router?.register !== "function" ||
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 = Object.freeze({ conversationRuntime,
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)?.release?.();
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
  });