@jskit-ai/assistant-runtime 0.2.29 → 0.2.30
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/CHANGELOG.md +7 -0
- package/README.md +6 -3
- package/package.json +16 -10
- package/patterns/assistant-surface/PATTERN.md +5 -2
- package/src/client/components/AssistantSettingsClientElement.vue +5 -3
- package/src/server/AssistantProvider.js +8 -13
- package/src/server/AssistantStoreFeature.js +59 -0
- package/src/server/actions.js +5 -5
- package/src/server/createAssistantRuntime.js +2 -1
- package/src/server/createAssistantSettingsActions.js +17 -9
- package/src/server/index.js +1 -0
- package/src/server/registerConversationSubscriptions.js +5 -2
- package/src/server/registerRoutes.js +11 -8
- package/src/server/registerSettingsRoutes.js +4 -4
- package/src/server/repositories/assistantConfigRepository.js +52 -67
- package/src/server/repositories/assistantResources.js +110 -0
- package/src/server/repositories/conversationsRepository.js +164 -147
- package/src/server/repositories/messagesRepository.js +136 -87
- package/src/server/repositories/repositoryPersistenceUtils.js +11 -22
- package/src/server/repositories/turnRequestsRepository.js +50 -16
- package/src/server/services/assistantConfigService.js +22 -0
- package/src/server/services/chatService.js +15 -7
- package/src/server/services/transcriptService.js +3 -3
- package/src/server/sqlConversationStorage.js +23 -19
- package/src/server/types.js +1 -0
- package/test/assistantIntegrationBoundary.test.js +2 -0
- package/test/assistantSettings.browser.test.js +23 -2
- package/test/assistantSettingsAccess.test.js +46 -1
- package/test/chatServiceLifecycle.test.js +32 -1
- package/test/featureRuntime.test.js +17 -3
- package/test/repositories.mysql.test.js +474 -0
- package/test/repositoryContracts.test.js +13 -13
- package/test/sqlConversationStorage.mysql.test.js +9 -6
- package/test/sqlConversationStorage.test.js +27 -20
- package/test/support/assistantStore.js +39 -0
- package/test/support/memoryAssistantRows.js +11 -4
- package/test/turnRequests.test.js +37 -14
|
@@ -1,16 +1,15 @@
|
|
|
1
|
-
import { applyForUpdate, createWithTransaction, normalizeDbRecordId, runInTransaction } from "@jskit-ai/database-runtime/shared/repositoryOptions";
|
|
2
1
|
import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
|
|
3
|
-
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
|
+
import { normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
|
+
import { passedOn } from "@jskit-ai/resource-core/shared/callOptions";
|
|
4
4
|
import {
|
|
5
|
-
applyWorkspaceScope,
|
|
6
|
-
normalizeDbWorkspaceId,
|
|
7
5
|
normalizeInputRecordId,
|
|
8
6
|
parseJsonObject,
|
|
9
|
-
resolveInsertedId,
|
|
10
7
|
stringifyJsonObject,
|
|
11
|
-
toIso
|
|
8
|
+
toIso,
|
|
9
|
+
toWrittenDateTime
|
|
12
10
|
} from "./repositoryPersistenceUtils.js";
|
|
13
|
-
import { DEFAULT_CONVERSATION_TITLE
|
|
11
|
+
import { DEFAULT_CONVERSATION_TITLE } from "../../shared/assistantRuntimeConfig.js";
|
|
12
|
+
|
|
14
13
|
|
|
15
14
|
function normalizeRequiredSurfaceId(value) {
|
|
16
15
|
const normalizedSurfaceId = normalizeSurfaceId(value);
|
|
@@ -21,22 +20,22 @@ function normalizeRequiredSurfaceId(value) {
|
|
|
21
20
|
return normalizedSurfaceId;
|
|
22
21
|
}
|
|
23
22
|
|
|
24
|
-
function
|
|
23
|
+
function mapConversationRecord(record = {}) {
|
|
25
24
|
return {
|
|
26
|
-
id:
|
|
27
|
-
workspaceId:
|
|
28
|
-
title: String(
|
|
29
|
-
createdByUserId:
|
|
30
|
-
status: String(
|
|
31
|
-
provider: String(
|
|
32
|
-
model: String(
|
|
33
|
-
surfaceId: String(
|
|
34
|
-
startedAt: toIso(
|
|
35
|
-
endedAt:
|
|
36
|
-
messageCount: Number(
|
|
37
|
-
metadata: parseJsonObject(
|
|
38
|
-
createdAt: toIso(
|
|
39
|
-
updatedAt: toIso(
|
|
25
|
+
id: normalizeRecordId(record.id, { fallback: "" }),
|
|
26
|
+
workspaceId: normalizeInputRecordId(record.workspaceId),
|
|
27
|
+
title: String(record.title || DEFAULT_CONVERSATION_TITLE),
|
|
28
|
+
createdByUserId: record.userId == null ? null : normalizeInputRecordId(record.userId),
|
|
29
|
+
status: String(record.status || "active"),
|
|
30
|
+
provider: String(record.provider || ""),
|
|
31
|
+
model: String(record.model || ""),
|
|
32
|
+
surfaceId: String(record.surfaceId || ""),
|
|
33
|
+
startedAt: toIso(record.startedAt),
|
|
34
|
+
endedAt: record.endedAt ? toIso(record.endedAt) : null,
|
|
35
|
+
messageCount: Number(record.messageCount || 0),
|
|
36
|
+
metadata: parseJsonObject(record.metadataJson),
|
|
37
|
+
createdAt: toIso(record.createdAt),
|
|
38
|
+
updatedAt: toIso(record.updatedAt)
|
|
40
39
|
};
|
|
41
40
|
}
|
|
42
41
|
|
|
@@ -53,13 +52,77 @@ function normalizeCursorPagination(pagination = {}, { defaultLimit = 20, maxLimi
|
|
|
53
52
|
};
|
|
54
53
|
}
|
|
55
54
|
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
// json-rest-api refuses a row lock outside a transaction; callers have always seen this TypeError.
|
|
56
|
+
function rethrowLockError(error) {
|
|
57
|
+
if (error?.type === "rest_api_lock_outside_transaction") {
|
|
58
|
+
throw new TypeError("Locking a conversation requires its database transaction.");
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// A record patch as stored fields, with updatedAt stamped when anything changes.
|
|
64
|
+
function conversationChanges(patch) {
|
|
65
|
+
const changes = {};
|
|
66
|
+
|
|
67
|
+
if (Object.hasOwn(patch, "title")) {
|
|
68
|
+
changes.title = normalizeText(patch.title) || DEFAULT_CONVERSATION_TITLE;
|
|
69
|
+
}
|
|
70
|
+
if (Object.hasOwn(patch, "status")) {
|
|
71
|
+
changes.status = normalizeText(patch.status).toLowerCase() || "active";
|
|
72
|
+
}
|
|
73
|
+
if (Object.hasOwn(patch, "provider")) {
|
|
74
|
+
changes.provider = normalizeText(patch.provider);
|
|
75
|
+
}
|
|
76
|
+
if (Object.hasOwn(patch, "model")) {
|
|
77
|
+
changes.model = normalizeText(patch.model);
|
|
78
|
+
}
|
|
79
|
+
if (Object.hasOwn(patch, "surfaceId")) {
|
|
80
|
+
changes.surfaceId = normalizeRequiredSurfaceId(patch.surfaceId);
|
|
81
|
+
}
|
|
82
|
+
if (Object.hasOwn(patch, "messageCount")) {
|
|
83
|
+
changes.messageCount = Math.max(0, Number(patch.messageCount || 0));
|
|
84
|
+
}
|
|
85
|
+
if (Object.hasOwn(patch, "endedAt")) {
|
|
86
|
+
changes.endedAt = patch.endedAt ? toWrittenDateTime(patch.endedAt) : null;
|
|
87
|
+
}
|
|
88
|
+
if (Object.hasOwn(patch, "metadata")) {
|
|
89
|
+
changes.metadataJson = stringifyJsonObject(patch.metadata);
|
|
90
|
+
}
|
|
91
|
+
if (Object.keys(changes).length > 0) {
|
|
92
|
+
changes.updatedAt = toWrittenDateTime();
|
|
93
|
+
}
|
|
94
|
+
return changes;
|
|
58
95
|
}
|
|
59
96
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
97
|
+
/**
|
|
98
|
+
* The assistant's conversations, on the assistantConversations table. A
|
|
99
|
+
* conversation's actor reads it in that actor's user context; the transcript
|
|
100
|
+
* storage, which reaches a conversation by id after its caller's actor check,
|
|
101
|
+
* and creates without a valid creator use the system context.
|
|
102
|
+
* @param {object} dependencies
|
|
103
|
+
* @param {import("@jskit-ai/resource-runtime/server/types").ResourceHandle} dependencies.conversations
|
|
104
|
+
* @param {(reason: string) => import("@jskit-ai/resource-runtime/server/types").SystemContext} dependencies.systemContext
|
|
105
|
+
*/
|
|
106
|
+
function createRepository({ conversations, systemContext }) {
|
|
107
|
+
const conversationContext = systemContext("a conversation reached by id after its caller checked the actor scope, or created without a valid creator");
|
|
108
|
+
|
|
109
|
+
function withTransaction(options, work) {
|
|
110
|
+
return conversations.withTransaction(options, work);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function transaction(work, { retries } = {}) {
|
|
114
|
+
return conversations.withTransaction({ retry: retries !== 0 }, work);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The record as `context` sees it, under a row lock when asked.
|
|
118
|
+
async function readRecord(id, { forUpdate = false, ...options }, context) {
|
|
119
|
+
const scoped = { ...passedOn(options), context };
|
|
120
|
+
if (forUpdate !== true) {
|
|
121
|
+
return conversations.get(id, {}, { ...scoped, missing: "skip" });
|
|
122
|
+
}
|
|
123
|
+
const [locked] = await conversations.lockMany([id], { ...scoped, missing: "skip" }).catch(rethrowLockError);
|
|
124
|
+
return locked || null;
|
|
125
|
+
}
|
|
63
126
|
|
|
64
127
|
async function findById(conversationId, options = {}) {
|
|
65
128
|
const normalizedConversationId = normalizeInputRecordId(conversationId);
|
|
@@ -67,18 +130,8 @@ function createRepository(knex) {
|
|
|
67
130
|
return null;
|
|
68
131
|
}
|
|
69
132
|
|
|
70
|
-
const
|
|
71
|
-
|
|
72
|
-
if (!options.trx) throw new TypeError("Locking a conversation requires its database transaction.");
|
|
73
|
-
// Acquire the write reservation before reading, including SQLite where
|
|
74
|
-
// SELECT FOR UPDATE is not supported. This does not change row contents.
|
|
75
|
-
await client(assistantRuntimeConfig.conversationsTable).where({ id: normalizedConversationId })
|
|
76
|
-
.update({ id: normalizedConversationId });
|
|
77
|
-
}
|
|
78
|
-
const query = createConversationBaseQuery(client).where("c.id", normalizedConversationId);
|
|
79
|
-
const row = await applyForUpdate(query, options).first();
|
|
80
|
-
|
|
81
|
-
return row ? mapConversationRow(row) : null;
|
|
133
|
+
const record = await readRecord(normalizedConversationId, options, conversationContext);
|
|
134
|
+
return record ? mapConversationRecord(record) : null;
|
|
82
135
|
}
|
|
83
136
|
|
|
84
137
|
async function findByIdForActorScope(
|
|
@@ -93,45 +146,39 @@ function createRepository(knex) {
|
|
|
93
146
|
return null;
|
|
94
147
|
}
|
|
95
148
|
|
|
96
|
-
const
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.where("c.surface_id", normalizedSurfaceId);
|
|
102
|
-
applyWorkspaceScope(query, "c.workspace_id", workspaceId);
|
|
103
|
-
const row = await applyForUpdate(query, options).first();
|
|
149
|
+
const record = await readRecord(normalizedConversationId, options, conversationContext.forUser(normalizedActorUserId));
|
|
150
|
+
if (!record || record.surfaceId !== normalizedSurfaceId ||
|
|
151
|
+
normalizeInputRecordId(record.workspaceId) !== normalizeInputRecordId(workspaceId)) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
104
154
|
|
|
105
|
-
return
|
|
155
|
+
return mapConversationRecord(record);
|
|
106
156
|
}
|
|
107
157
|
|
|
108
158
|
async function create(payload = {}, options = {}) {
|
|
109
|
-
const
|
|
110
|
-
const now = new Date();
|
|
159
|
+
const now = toWrittenDateTime();
|
|
111
160
|
const surfaceId = normalizeRequiredSurfaceId(payload.surfaceId);
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
161
|
+
const createdByUserId = normalizeInputRecordId(payload.createdByUserId);
|
|
162
|
+
const attributes = {
|
|
163
|
+
workspaceId: normalizeInputRecordId(payload.workspaceId),
|
|
115
164
|
title: normalizeText(payload.title) || DEFAULT_CONVERSATION_TITLE,
|
|
116
165
|
status: normalizeText(payload.status).toLowerCase() || "active",
|
|
117
166
|
provider: normalizeText(payload.provider),
|
|
118
167
|
model: normalizeText(payload.model),
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
168
|
+
surfaceId,
|
|
169
|
+
messageCount: Math.max(0, Number(payload.messageCount || 0)),
|
|
170
|
+
metadataJson: stringifyJsonObject(payload.metadata),
|
|
171
|
+
startedAt: payload.startedAt ? toWrittenDateTime(payload.startedAt) : now,
|
|
172
|
+
endedAt: payload.endedAt ? toWrittenDateTime(payload.endedAt) : null,
|
|
173
|
+
createdAt: now,
|
|
174
|
+
updatedAt: now
|
|
175
|
+
};
|
|
176
|
+
// A conversation without a valid creator is stored with none, so it is created in the system context.
|
|
177
|
+
const created = createdByUserId
|
|
178
|
+
? await conversations.create(attributes, { ...passedOn(options), context: conversationContext.forUser(createdByUserId) })
|
|
179
|
+
: await conversations.create({ ...attributes, userId: null }, { ...passedOn(options), context: conversationContext });
|
|
131
180
|
|
|
132
|
-
return
|
|
133
|
-
trx: client
|
|
134
|
-
});
|
|
181
|
+
return mapConversationRecord(created);
|
|
135
182
|
}
|
|
136
183
|
|
|
137
184
|
async function updateById(conversationId, patch = {}, options = {}) {
|
|
@@ -140,70 +187,54 @@ function createRepository(knex) {
|
|
|
140
187
|
return null;
|
|
141
188
|
}
|
|
142
189
|
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
}
|
|
152
|
-
if (Object.hasOwn(patch, "provider")) {
|
|
153
|
-
updatePatch.provider = normalizeText(patch.provider);
|
|
154
|
-
}
|
|
155
|
-
if (Object.hasOwn(patch, "model")) {
|
|
156
|
-
updatePatch.model = normalizeText(patch.model);
|
|
157
|
-
}
|
|
158
|
-
if (Object.hasOwn(patch, "surfaceId")) {
|
|
159
|
-
updatePatch.surface_id = normalizeRequiredSurfaceId(patch.surfaceId);
|
|
160
|
-
}
|
|
161
|
-
if (Object.hasOwn(patch, "messageCount")) {
|
|
162
|
-
updatePatch.message_count = Math.max(0, Number(patch.messageCount || 0));
|
|
163
|
-
}
|
|
164
|
-
if (Object.hasOwn(patch, "endedAt")) {
|
|
165
|
-
updatePatch.ended_at = patch.endedAt ? new Date(patch.endedAt) : null;
|
|
166
|
-
}
|
|
167
|
-
if (Object.hasOwn(patch, "metadata")) {
|
|
168
|
-
updatePatch.metadata_json = stringifyJsonObject(patch.metadata);
|
|
190
|
+
const changes = conversationChanges(patch);
|
|
191
|
+
if (Object.keys(changes).length > 0) {
|
|
192
|
+
await conversations.patch(normalizedConversationId, changes, {
|
|
193
|
+
...passedOn(options),
|
|
194
|
+
context: conversationContext,
|
|
195
|
+
missing: "skip",
|
|
196
|
+
returning: "none"
|
|
197
|
+
});
|
|
169
198
|
}
|
|
170
199
|
|
|
171
|
-
|
|
172
|
-
updatePatch.updated_at = new Date();
|
|
173
|
-
await client(assistantRuntimeConfig.conversationsTable)
|
|
174
|
-
.where({ id: normalizedConversationId })
|
|
175
|
-
.update(updatePatch);
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
return findById(normalizedConversationId, {
|
|
179
|
-
trx: client
|
|
180
|
-
});
|
|
200
|
+
return findById(normalizedConversationId, passedOn(options));
|
|
181
201
|
}
|
|
182
202
|
|
|
183
|
-
|
|
184
|
-
|
|
203
|
+
/**
|
|
204
|
+
* The conversation and its transcript's own state, its revision and summary, from one read (under a row lock with
|
|
205
|
+
* `forUpdate`), or null. The transcript state stays out of the record, which reaches the browser.
|
|
206
|
+
* @param {unknown} conversationId
|
|
207
|
+
* @param {{ forUpdate?: boolean, context?: object | null, trx?: object | null }} [options]
|
|
208
|
+
*/
|
|
209
|
+
async function findWithTranscriptState(conversationId, options = {}) {
|
|
185
210
|
const id = normalizeInputRecordId(conversationId);
|
|
186
211
|
if (!id) return null;
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
.first("summary_text", "summary_through_turn_id", "transcript_revision");
|
|
190
|
-
if (!row) return null;
|
|
212
|
+
const record = await readRecord(id, options, conversationContext);
|
|
213
|
+
if (!record) return null;
|
|
191
214
|
return {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
215
|
+
conversation: mapConversationRecord(record),
|
|
216
|
+
revision: record.transcriptRevision || "",
|
|
217
|
+
summary: record.summaryText == null ? null
|
|
218
|
+
: { text: String(record.summaryText), throughTurnId: String(record.summaryThroughTurnId || "") }
|
|
195
219
|
};
|
|
196
220
|
}
|
|
197
221
|
|
|
198
|
-
|
|
222
|
+
/**
|
|
223
|
+
* Writes the transcript's revision and summary and, in the same statement, the record changes of `changes` (as
|
|
224
|
+
* updateById takes them), so a save that appends messages updates its conversation once. An unknown id writes nothing.
|
|
225
|
+
* @param {unknown} conversationId
|
|
226
|
+
* @param {{ revision: string, summary: { text: string, throughTurnId: string } | null, changes?: Record<string, unknown> }} state
|
|
227
|
+
* @param {{ context?: object | null, trx?: object | null }} [options]
|
|
228
|
+
*/
|
|
229
|
+
async function writeTranscriptState(conversationId, { revision, summary, changes = {} }, options = {}) {
|
|
199
230
|
const id = normalizeInputRecordId(conversationId);
|
|
200
231
|
if (!id) throw new TypeError("writeTranscriptState requires conversationId.");
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
});
|
|
232
|
+
await conversations.patch(id, {
|
|
233
|
+
...conversationChanges(changes),
|
|
234
|
+
transcriptRevision: revision,
|
|
235
|
+
summaryText: summary ? summary.text : null,
|
|
236
|
+
summaryThroughTurnId: summary ? summary.throughTurnId : null
|
|
237
|
+
}, { ...passedOn(options), context: conversationContext, missing: "skip", returning: "none" });
|
|
207
238
|
}
|
|
208
239
|
|
|
209
240
|
async function listForActorScope(
|
|
@@ -219,31 +250,21 @@ function createRepository(knex) {
|
|
|
219
250
|
};
|
|
220
251
|
}
|
|
221
252
|
|
|
222
|
-
const client = options.trx || knex;
|
|
223
253
|
const { cursor, limit } = normalizeCursorPagination(pagination);
|
|
224
|
-
let query = createConversationBaseQuery(client)
|
|
225
|
-
.where("c.created_by_user_id", normalizedActorUserId)
|
|
226
|
-
.where("c.surface_id", normalizedSurfaceId);
|
|
227
|
-
query = applyWorkspaceScope(query, "c.workspace_id", workspaceId);
|
|
228
|
-
|
|
229
254
|
const normalizedStatus = normalizeText(filters.status).toLowerCase();
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
255
|
+
const { records, page } = await conversations.query({
|
|
256
|
+
filters: {
|
|
257
|
+
surfaceId: normalizedSurfaceId,
|
|
258
|
+
workspaceId: normalizeInputRecordId(workspaceId),
|
|
259
|
+
...(normalizedStatus ? { status: normalizedStatus } : {}),
|
|
260
|
+
...(cursor ? { beforeId: cursor } : {})
|
|
261
|
+
},
|
|
262
|
+
sort: ["-id"],
|
|
263
|
+
page: { size: limit }
|
|
264
|
+
}, { ...passedOn(options), context: conversationContext.forUser(normalizedActorUserId) });
|
|
240
265
|
|
|
241
|
-
const
|
|
242
|
-
const
|
|
243
|
-
const items = pageRows.map(mapConversationRow);
|
|
244
|
-
const nextCursor = hasMore && pageRows.length > 0
|
|
245
|
-
? normalizeDbRecordId(pageRows[pageRows.length - 1].id, { fallback: null })
|
|
246
|
-
: null;
|
|
266
|
+
const items = records.map(mapConversationRecord);
|
|
267
|
+
const nextCursor = page.hasMore && items.length > 0 ? items[items.length - 1].id : null;
|
|
247
268
|
|
|
248
269
|
return {
|
|
249
270
|
items,
|
|
@@ -251,10 +272,6 @@ function createRepository(knex) {
|
|
|
251
272
|
};
|
|
252
273
|
}
|
|
253
274
|
|
|
254
|
-
async function transaction(callback, options) {
|
|
255
|
-
return runInTransaction(knex, callback, options);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
275
|
return Object.freeze({
|
|
259
276
|
withTransaction,
|
|
260
277
|
findById,
|
|
@@ -262,7 +279,7 @@ function createRepository(knex) {
|
|
|
262
279
|
create,
|
|
263
280
|
updateById,
|
|
264
281
|
listForActorScope,
|
|
265
|
-
|
|
282
|
+
findWithTranscriptState,
|
|
266
283
|
writeTranscriptState,
|
|
267
284
|
transaction
|
|
268
285
|
});
|