@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.
Files changed (66) hide show
  1. package/README.md +1 -1
  2. package/fixtures/canonical-conversation/main.js +2 -1
  3. package/fixtures/conversation/main.js +2 -1
  4. package/fixtures/conversation/vite.config.mjs +4 -3
  5. package/migrations/assistant_config_initial.cjs +1 -5
  6. package/migrations/assistant_transcripts_initial.cjs +3 -9
  7. package/migrations/assistant_transcripts_summary.cjs +28 -0
  8. package/migrations/assistant_turn_requests.cjs +2 -1
  9. package/migrations/constraints/assistant_users_foreign_keys.cjs +22 -0
  10. package/migrations/constraints/assistant_workspaces_foreign_keys.cjs +26 -0
  11. package/package.json +17 -10
  12. package/patterns/assistant-surface/example/src/ApplicationAiFeature.js +4 -2
  13. package/src/client/components/AssistantConversationClientElement.vue +10 -4
  14. package/src/client/components/AssistantSurfaceClientElement.vue +8 -4
  15. package/src/client/composables/conversationGoal.js +266 -0
  16. package/src/client/composables/conversationHistory.js +215 -0
  17. package/src/client/composables/conversationRuntime.js +624 -0
  18. package/src/client/composables/useAssistantConversation.js +208 -884
  19. package/src/client/composables/useAssistantRuntime.js +60 -45
  20. package/src/client/providers/AssistantClientProvider.js +1 -0
  21. package/src/client/support/assistantRuntimeState.js +6 -34
  22. package/src/client/support/conversationFailures.js +30 -0
  23. package/src/client/support/conversationRestoreSupport.js +13 -5
  24. package/src/client/support/subscribeAssistantConversation.js +32 -9
  25. package/src/client/support/workspaceScopeSupport.js +13 -22
  26. package/src/client/types.js +46 -0
  27. package/src/server/AssistantProvider.js +45 -17
  28. package/src/server/actions.js +162 -201
  29. package/src/server/conversationOperations.js +141 -0
  30. package/src/server/createAssistantRuntime.js +83 -44
  31. package/src/server/createAssistantSettingsActions.js +69 -47
  32. package/src/server/hostedAssistant.js +21 -0
  33. package/src/server/index.js +1 -0
  34. package/src/server/registerConversationSubscriptions.js +34 -11
  35. package/src/server/registerFastifyConversations.js +18 -6
  36. package/src/server/registerRoutes.js +5 -2
  37. package/src/server/registerSettingsRoutes.js +1 -1
  38. package/src/server/repositories/assistantConfigRepository.js +10 -20
  39. package/src/server/repositories/conversationsRepository.js +35 -10
  40. package/src/server/repositories/messagesRepository.js +14 -10
  41. package/src/server/repositories/repositoryPersistenceUtils.js +1 -6
  42. package/src/server/repositories/turnRequestsRepository.js +6 -1
  43. package/src/server/services/assistantConfigService.js +25 -23
  44. package/src/server/services/chatService.js +57 -61
  45. package/src/server/services/transcriptService.js +15 -7
  46. package/src/server/sqlConversationStorage.js +90 -18
  47. package/src/server/support/assistantServerConfig.js +1 -1
  48. package/src/server/support/assistantStreamWriter.js +7 -1
  49. package/src/server/support/createSurfaceAwareToolCatalog.js +35 -17
  50. package/src/server/types.js +38 -0
  51. package/src/shared/assistantSurfaces.js +2 -2
  52. package/test/assistantConversationOperations.test.js +5 -5
  53. package/test/assistantRuntimeState.test.js +1 -16
  54. package/test/chatServiceLifecycle.test.js +62 -154
  55. package/test/createSurfaceAwareToolCatalog.test.js +5 -0
  56. package/test/featureRuntime.test.js +12 -6
  57. package/test/lazyAppConfig.test.js +3 -5
  58. package/test/packageMetadata.test.js +7 -1
  59. package/test/sqlConversationStorage.mysql.test.js +89 -0
  60. package/test/sqlConversationStorage.test.js +161 -16
  61. package/test/support/memoryAssistantRows.js +6 -1
  62. package/test/support/scriptedModel.js +32 -0
  63. package/test/turnRequests.test.js +4 -2
  64. package/test/usersForeignKeysMigration.test.js +46 -0
  65. package/test/workspacesForeignKeysMigration.test.js +66 -0
  66. package/src/client/support/accessDenied.js +0 -6
@@ -0,0 +1,215 @@
1
+ import { computed, ref } from "vue";
2
+ import {
3
+ conversationTurnMessages,
4
+ mergeConversationLogPages,
5
+ mergeConversationStream,
6
+ normalizeConversationLogPage,
7
+ normalizeConversationLogPagination
8
+ } from "@jskit-ai/assistant-core/shared/conversation";
9
+ import { failureMessage, isAccessDenied } from "../support/conversationFailures.js";
10
+ import { patchLoadedHistoryPages, refreshedHistoryPages } from "../support/subscribeAssistantConversation.js";
11
+ import { useJskitText } from "../i18n.js";
12
+
13
+ const { t } = useJskitText();
14
+ const compareTurnIds = (left, right) => String(left).localeCompare(String(right), undefined, { numeric: true });
15
+
16
+ /** A Load older request whose view does not follow the result. */
17
+ function ignoreLoadResult() {}
18
+
19
+ /**
20
+ * The saved history a conversation's views have loaded: the live page from its subscription plus the older pages a
21
+ * reader asked for, kept apart so a canonical read can refresh both.
22
+ * @param {{
23
+ * api: import("@jskit-ai/assistant-core/client/types").AssistantApi,
24
+ * identity: import("../types.js").ConversationIdentity,
25
+ * snapshot: import("vue").ShallowRef<any>,
26
+ * current: import("vue").ComputedRef<boolean>,
27
+ * listening: import("vue").ComputedRef<boolean>,
28
+ * available: import("vue").ComputedRef<boolean>,
29
+ * receiveError: (failure: any) => void
30
+ * }} conversation
31
+ */
32
+ function createConversationHistory({ api, identity, snapshot, current, listening, available, receiveError }) {
33
+ const olderPages = ref([]);
34
+ let historyRevision = 0;
35
+ let deniedHistoryRevision = 0;
36
+ const loadingMore = ref(false);
37
+ const loadMoreError = ref("");
38
+ const turns = computed(() => {
39
+ const saved = mergeConversationLogPages([
40
+ ...olderPages.value, { conversationLog: snapshot.value?.conversationLog || [] }
41
+ ]).conversationLog;
42
+ // Use the original overlay against the full loaded saved span. A synthetic
43
+ // live older turn must not replace its authored request or saved progress.
44
+ const messages = (snapshot.value?.turns || []).flatMap(conversationTurnMessages);
45
+ return mergeConversationStream(saved, { messages });
46
+ });
47
+ const oldestLoadedPage = computed(() => olderPages.value[0] || snapshot.value);
48
+ const hasMoreBefore = computed(() => normalizeConversationLogPagination(oldestLoadedPage.value?.pagination).hasMoreBefore);
49
+
50
+ /**
51
+ * Takes the older pages a new state brings, before the live page is replaced by it.
52
+ * @param {any} state
53
+ * @param {any} previous The live page the state replaces.
54
+ * @param {{ initial: boolean, canonical: boolean }} options
55
+ */
56
+ function receiveState(state, previous, { initial, canonical }) {
57
+ if (canonical) {
58
+ historyRevision += 1;
59
+ olderPages.value = initial ? [] : state[refreshedHistoryPages] || [];
60
+ loadMoreError.value = "";
61
+ } else if (olderPages.value.length) {
62
+ // Transcript patches may evict the head of the bounded live page before
63
+ // its canonical read completes. Retain saved rows, never display overlays.
64
+ const first = state.conversationLog?.[0]?.turnId;
65
+ const evicted = first ? (previous?.conversationLog || []).filter(turn => compareTurnIds(turn.turnId, first) < 0) : [];
66
+ if (evicted.length) {
67
+ historyRevision += 1;
68
+ olderPages.value = [...olderPages.value, normalizeConversationLogPage({ conversationLog: evicted })];
69
+ }
70
+ }
71
+ }
72
+ /**
73
+ * Applies a transcript event to the older page that holds its turn.
74
+ * @param {any} event
75
+ */
76
+ function receivePatch(event) {
77
+ if (event.type === "transcript" && olderPages.value.some(page =>
78
+ page.conversationLog.some(turn => turn.turnId === event.patch?.turn?.turnId))) {
79
+ historyRevision += 1;
80
+ olderPages.value = patchLoadedHistoryPages(olderPages.value, event.patch);
81
+ }
82
+ }
83
+ /** Drops the older pages, as when the view is cleared. */
84
+ function clear() {
85
+ historyRevision += 1;
86
+ olderPages.value = [];
87
+ }
88
+ /** Makes a read in flight stale, as when the subscription restarts. */
89
+ function invalidate() {
90
+ historyRevision += 1;
91
+ }
92
+ /** Makes a read in flight fail as denied, after the viewer lost access. */
93
+ function deny() {
94
+ deniedHistoryRevision += 1;
95
+ }
96
+
97
+ /**
98
+ * Rereads the live page and every older page already loaded, for the subscription's canonical read.
99
+ * @param {{ current: () => boolean }} read Whether the subscription still wants this read.
100
+ */
101
+ async function readLoadedHistory({ current: subscriptionCurrent }) {
102
+ const denial = deniedHistoryRevision;
103
+ const valid = () => {
104
+ if (denial !== deniedHistoryRevision) {
105
+ throw Object.assign(new Error("Access denied."), { status: 403, messageCode: "assistant.runtime.accessDenied" });
106
+ }
107
+ return current.value && listening.value && subscriptionCurrent();
108
+ };
109
+ // A concurrent Load older operation extends the desired span. Retry one
110
+ // refresh in this same read, rather than dropping that newly loaded page.
111
+ while (valid()) {
112
+ const revision = historyRevision;
113
+ const oldestTurn = olderPages.value[0]?.conversationLog?.[0];
114
+ const oldest = oldestTurn?.turnId;
115
+ const oldestCursor = olderPages.value[0]?.pagination?.nextBeforeTurnId || oldest;
116
+ const latest = await api.readConversation(identity.conversationId);
117
+ if (!valid()) return latest;
118
+ const pages = [];
119
+ let page = normalizeConversationLogPage(latest);
120
+ const cursors = new Set();
121
+ while (oldest && page.pagination.hasMoreBefore) {
122
+ const cursor = page.pagination.nextBeforeTurnId || page.pagination.oldestTurnId;
123
+ const reachedFullOldestTurn = page.pagination.oldestTurnId === oldest && !page.conversationLog[0]?.paginationPartial;
124
+ const passedOldestTurn = compareTurnIds(page.pagination.oldestTurnId, oldest) < 0;
125
+ if (cursor && (cursor === oldestCursor || passedOldestTurn || reachedFullOldestTurn)) break;
126
+ if (!cursor || cursors.has(cursor)) {
127
+ throw Object.assign(new Error("Older conversation history could not be refreshed. Reload chat to try again."), {
128
+ messageCode: "assistant.runtime.history.refreshFailed"
129
+ });
130
+ }
131
+ cursors.add(cursor);
132
+ page = normalizeConversationLogPage(await api.readConversation(identity.conversationId, {
133
+ beforeTurnId: cursor, limit: latest.pagination.limit
134
+ }));
135
+ if (!valid()) return latest;
136
+ // A removed oldest row can make this page reach farther back than the
137
+ // reader loaded. Keep the original visible boundary and page metadata.
138
+ const saved = page.conversationLog.filter(turn => compareTurnIds(turn.turnId, oldest) >= 0).map(turn => {
139
+ if (turn.turnId !== oldest || !oldestTurn.paginationPartial) return turn;
140
+ const roles = ["system", "user", "assistant"];
141
+ const excluded = roles.slice(0, roles.findIndex(role => oldestTurn[role]));
142
+ return { ...turn, paginationPartial: true,
143
+ ...Object.fromEntries(excluded.map(role => [role, null])),
144
+ messages: turn.messages?.filter(message => !excluded.includes(message.role)) };
145
+ });
146
+ pages.unshift(normalizeConversationLogPage({ ...page, conversationLog: saved, pagination: {
147
+ ...page.pagination, count: saved.length, oldestTurnId: saved[0]?.turnId || "",
148
+ hasMoreBefore: page.pagination.hasMoreBefore || saved.length < page.conversationLog.length
149
+ } }));
150
+ }
151
+ if (revision !== historyRevision) continue;
152
+ return { ...latest, [refreshedHistoryPages]: pages.filter(page => page.conversationLog.length) };
153
+ }
154
+ return null;
155
+ }
156
+
157
+ /**
158
+ * Loads the page before the oldest loaded turn, kept separate from the live page.
159
+ * @param {{ complete?: (result: { changed: boolean, loaded: boolean }) => void }} [options]
160
+ */
161
+ async function loadMore({ complete = ignoreLoadResult } = {}) {
162
+ const pagination = normalizeConversationLogPagination(oldestLoadedPage.value?.pagination);
163
+ const beforeTurnId = pagination.nextBeforeTurnId || pagination.oldestTurnId ||
164
+ String(turns.value[0]?.turnId || "").trim();
165
+ if (!available.value || !hasMoreBefore.value || loadingMore.value || !beforeTurnId) {
166
+ complete({ changed: false, loaded: false });
167
+ return false;
168
+ }
169
+ const revision = historyRevision;
170
+ loadingMore.value = true;
171
+ loadMoreError.value = "";
172
+ try {
173
+ const page = await api.readConversation(identity.conversationId, {
174
+ beforeTurnId, limit: snapshot.value.pagination.limit
175
+ });
176
+ if (!available.value || revision !== historyRevision) {
177
+ complete({ changed: false, loaded: false });
178
+ return false;
179
+ }
180
+ const previousOldestTurnId = String(turns.value[0]?.turnId || "").trim();
181
+ const previousMessageCount = turns.value.reduce((count, turn) => count + (turn.messages?.length || 0), 0);
182
+ historyRevision += 1;
183
+ olderPages.value = [normalizeConversationLogPage(page), ...olderPages.value];
184
+ const nextOldestTurnId = String(turns.value[0]?.turnId || "").trim();
185
+ const nextMessageCount = turns.value.reduce((count, turn) => count + (turn.messages?.length || 0), 0);
186
+ const changed = Boolean(nextOldestTurnId && (nextOldestTurnId !== previousOldestTurnId ||
187
+ nextMessageCount !== previousMessageCount));
188
+ complete({ changed, loaded: true });
189
+ return true;
190
+ } catch (failure) {
191
+ if (current.value) {
192
+ loadMoreError.value = String(failureMessage(failure) || failure || t("jskit.assistant.runtime.history.loadFailed"));
193
+ if (isAccessDenied(failure)) receiveError(failure);
194
+ }
195
+ complete({ changed: false, loaded: false });
196
+ return false;
197
+ } finally { loadingMore.value = false; }
198
+ }
199
+
200
+ return {
201
+ turns,
202
+ hasMoreBefore,
203
+ loadingMore,
204
+ loadMoreError,
205
+ receiveState,
206
+ receivePatch,
207
+ clear,
208
+ invalidate,
209
+ deny,
210
+ readLoadedHistory,
211
+ loadMore
212
+ };
213
+ }
214
+
215
+ export { createConversationHistory };