@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,29 +1,26 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
|
+
import { passedOn } from "@jskit-ai/resource-core/shared/callOptions";
|
|
3
3
|
import {
|
|
4
|
-
applyWorkspaceScope,
|
|
5
|
-
normalizeDbWorkspaceId,
|
|
6
4
|
normalizeInputRecordId,
|
|
7
5
|
parseJsonObject,
|
|
8
|
-
resolveInsertedId,
|
|
9
6
|
stringifyJsonObject,
|
|
10
|
-
toIso
|
|
7
|
+
toIso,
|
|
8
|
+
toWrittenDateTime
|
|
11
9
|
} from "./repositoryPersistenceUtils.js";
|
|
12
|
-
import { assistantRuntimeConfig } from "../../shared/assistantRuntimeConfig.js";
|
|
13
10
|
|
|
14
|
-
function
|
|
11
|
+
function mapMessageRecord(record = {}) {
|
|
15
12
|
return {
|
|
16
|
-
id:
|
|
17
|
-
conversationId:
|
|
18
|
-
workspaceId:
|
|
19
|
-
seq: Number(
|
|
20
|
-
role: String(
|
|
21
|
-
kind: String(
|
|
22
|
-
clientMessageSid: String(
|
|
23
|
-
actorUserId:
|
|
24
|
-
contentText:
|
|
25
|
-
metadata: parseJsonObject(
|
|
26
|
-
createdAt: toIso(
|
|
13
|
+
id: normalizeRecordId(record.id, { fallback: "" }),
|
|
14
|
+
conversationId: normalizeRecordId(record.conversationId, { fallback: "" }),
|
|
15
|
+
workspaceId: normalizeInputRecordId(record.workspaceId),
|
|
16
|
+
seq: Number(record.seq),
|
|
17
|
+
role: String(record.role || ""),
|
|
18
|
+
kind: String(record.kind || "chat"),
|
|
19
|
+
clientMessageSid: String(record.clientMessageSid || ""),
|
|
20
|
+
actorUserId: record.actorUserId == null ? null : normalizeInputRecordId(record.actorUserId),
|
|
21
|
+
contentText: record.contentText == null ? null : String(record.contentText),
|
|
22
|
+
metadata: parseJsonObject(record.metadataJson),
|
|
23
|
+
createdAt: toIso(record.createdAt)
|
|
27
24
|
};
|
|
28
25
|
}
|
|
29
26
|
|
|
@@ -41,22 +38,43 @@ function normalizePagination(pagination = {}, { defaultPage = 1, defaultPageSize
|
|
|
41
38
|
};
|
|
42
39
|
}
|
|
43
40
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
.where({ conversation_id: conversationId })
|
|
47
|
-
.max({ maxSeq: "seq" })
|
|
48
|
-
.first();
|
|
49
|
-
const maxSeq = Number(row?.maxSeq || 0);
|
|
50
|
-
if (!Number.isInteger(maxSeq) || maxSeq < 0) {
|
|
51
|
-
return 1;
|
|
52
|
-
}
|
|
41
|
+
// A conversation id list per query, so its "in" list stays well inside every database's limits.
|
|
42
|
+
const CONVERSATION_IDS_PER_QUERY = 1000;
|
|
53
43
|
|
|
54
|
-
|
|
44
|
+
// Whether message a was written after b: the later createdAt, then the higher id.
|
|
45
|
+
function isNewer(a, b) {
|
|
46
|
+
const byTime = Date.parse(toIso(a.createdAt)) - Date.parse(toIso(b.createdAt));
|
|
47
|
+
return byTime !== 0 ? byTime > 0 : BigInt(a.id) > BigInt(b.id);
|
|
55
48
|
}
|
|
56
49
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
/**
|
|
51
|
+
* A conversation's messages, on the assistantMessages table. Callers reach
|
|
52
|
+
* them by conversation id after checking that conversation's actor scope; a
|
|
53
|
+
* workspace's messages are read in that workspace, the rest in the system context.
|
|
54
|
+
* @param {object} dependencies
|
|
55
|
+
* @param {import("@jskit-ai/resource-runtime/server/types").ResourceHandle} dependencies.messages
|
|
56
|
+
* @param {import("@jskit-ai/resource-runtime/server/types").ResourceHandle} dependencies.conversations The
|
|
57
|
+
* assistantConversations table's handle, for the conversations of one user.
|
|
58
|
+
* @param {(reason: string) => import("@jskit-ai/resource-runtime/server/types").SystemContext} dependencies.systemContext
|
|
59
|
+
*/
|
|
60
|
+
function createRepository({ messages, conversations, systemContext }) {
|
|
61
|
+
const messageContext = systemContext("messages reached by conversation id after the caller checked the conversation's actor scope");
|
|
62
|
+
const userContext = systemContext("the newest assistant message among one user's conversations, which the caller is entitled to read");
|
|
63
|
+
|
|
64
|
+
// A workspace's messages in that workspace; messages without one in the system context, filtered to none.
|
|
65
|
+
function scoped(workspaceId, options) {
|
|
66
|
+
return workspaceId
|
|
67
|
+
? { filters: {}, options: { ...passedOn(options), context: messageContext.forWorkspace(workspaceId) } }
|
|
68
|
+
: { filters: { workspaceId: null }, options: { ...passedOn(options), context: messageContext } };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function withTransaction(options, work) {
|
|
72
|
+
return messages.withTransaction(options, work);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function transaction(work) {
|
|
76
|
+
return messages.withTransaction({}, work);
|
|
77
|
+
}
|
|
60
78
|
|
|
61
79
|
async function findById(messageId, options = {}) {
|
|
62
80
|
const normalizedMessageId = normalizeInputRecordId(messageId);
|
|
@@ -64,43 +82,43 @@ function createRepository(knex) {
|
|
|
64
82
|
return null;
|
|
65
83
|
}
|
|
66
84
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
.first();
|
|
85
|
+
const record = await messages.get(normalizedMessageId, {}, { ...passedOn(options), context: messageContext, missing: "skip" });
|
|
86
|
+
return record ? mapMessageRecord(record) : null;
|
|
87
|
+
}
|
|
71
88
|
|
|
72
|
-
|
|
89
|
+
// The conversation's highest seq, across its workspaces, or 0.
|
|
90
|
+
async function highestSequence(conversationId, options) {
|
|
91
|
+
const [row] = await messages.aggregate({
|
|
92
|
+
measures: { maxSeq: { max: "seq" } },
|
|
93
|
+
filters: { conversationId }
|
|
94
|
+
}, { ...passedOn(options), context: messageContext });
|
|
95
|
+
const maxSeq = Number(row?.maxSeq || 0);
|
|
96
|
+
return Number.isInteger(maxSeq) && maxSeq > 0 ? maxSeq : 0;
|
|
73
97
|
}
|
|
74
98
|
|
|
75
99
|
async function create(payload = {}, options = {}) {
|
|
76
|
-
const client = options.trx || knex;
|
|
77
100
|
const conversationId = normalizeInputRecordId(payload.conversationId);
|
|
78
101
|
if (!conversationId) {
|
|
79
102
|
throw new TypeError("messagesRepository.create requires conversationId.");
|
|
80
103
|
}
|
|
81
104
|
|
|
82
105
|
const providedSeq = Number(payload.seq);
|
|
83
|
-
const seq = Number.isInteger(providedSeq) && providedSeq > 0 ? providedSeq : (await
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
106
|
+
const seq = Number.isInteger(providedSeq) && providedSeq > 0 ? providedSeq : (await highestSequence(conversationId, options)) + 1;
|
|
107
|
+
const workspaceId = normalizeInputRecordId(payload.workspaceId);
|
|
108
|
+
const created = await messages.create({
|
|
109
|
+
conversationId,
|
|
110
|
+
workspaceId,
|
|
87
111
|
seq,
|
|
88
112
|
role: normalizeText(payload.role).toLowerCase(),
|
|
89
113
|
kind: normalizeText(payload.kind).toLowerCase() || "chat",
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
throw new Error("messagesRepository.create could not resolve inserted id.");
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return findById(id, {
|
|
102
|
-
trx: client
|
|
103
|
-
});
|
|
114
|
+
clientMessageSid: normalizeText(payload.clientMessageSid),
|
|
115
|
+
actorUserId: normalizeInputRecordId(payload.actorUserId),
|
|
116
|
+
contentText: payload.contentText == null ? null : String(payload.contentText),
|
|
117
|
+
metadataJson: stringifyJsonObject(payload.metadata),
|
|
118
|
+
createdAt: toWrittenDateTime(payload.createdAt)
|
|
119
|
+
}, scoped(workspaceId, options).options);
|
|
120
|
+
|
|
121
|
+
return mapMessageRecord(created);
|
|
104
122
|
}
|
|
105
123
|
|
|
106
124
|
async function countByConversationScope(conversationId, { workspaceId = null } = {}, options = {}) {
|
|
@@ -109,12 +127,11 @@ function createRepository(knex) {
|
|
|
109
127
|
return 0;
|
|
110
128
|
}
|
|
111
129
|
|
|
112
|
-
const
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
const row = await query.count({ total: "*" }).first();
|
|
130
|
+
const scope = scoped(normalizeInputRecordId(workspaceId), options);
|
|
131
|
+
const [row] = await messages.aggregate({
|
|
132
|
+
measures: { total: { count: "*" } },
|
|
133
|
+
filters: { ...scope.filters, conversationId: normalizedConversationId }
|
|
134
|
+
}, scope.options);
|
|
118
135
|
|
|
119
136
|
const total = Number(row?.total || 0);
|
|
120
137
|
return Number.isFinite(total) && total > 0 ? total : 0;
|
|
@@ -123,45 +140,76 @@ function createRepository(knex) {
|
|
|
123
140
|
async function updateById(messageId, patch = {}, options = {}) {
|
|
124
141
|
const id = normalizeInputRecordId(messageId);
|
|
125
142
|
if (!id) return null;
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
if (Object.hasOwn(patch, "
|
|
129
|
-
if (Object.
|
|
130
|
-
|
|
131
|
-
|
|
143
|
+
const changes = {};
|
|
144
|
+
if (Object.hasOwn(patch, "contentText")) changes.contentText = patch.contentText == null ? null : String(patch.contentText);
|
|
145
|
+
if (Object.hasOwn(patch, "metadata")) changes.metadataJson = stringifyJsonObject(patch.metadata);
|
|
146
|
+
if (Object.keys(changes).length) {
|
|
147
|
+
await messages.patch(id, changes, { ...passedOn(options), context: messageContext, missing: "skip", returning: "none" });
|
|
148
|
+
}
|
|
149
|
+
return findById(id, options);
|
|
132
150
|
}
|
|
133
151
|
|
|
152
|
+
// One numbered page of the conversation's messages in seq order, in one statement.
|
|
134
153
|
async function listByConversationScope(conversationId, { workspaceId = null } = {}, pagination = {}, options = {}) {
|
|
135
154
|
const normalizedConversationId = normalizeInputRecordId(conversationId);
|
|
136
155
|
if (!normalizedConversationId) {
|
|
137
156
|
return [];
|
|
138
157
|
}
|
|
139
158
|
|
|
140
|
-
const client = options.trx || knex;
|
|
141
159
|
const { page, pageSize } = normalizePagination(pagination);
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
160
|
+
const scope = scoped(normalizeInputRecordId(workspaceId), options);
|
|
161
|
+
const { records } = await messages.query({
|
|
162
|
+
filters: { ...scope.filters, conversationId: normalizedConversationId },
|
|
163
|
+
sort: ["seq", "id"],
|
|
164
|
+
page: { size: pageSize, number: page }
|
|
165
|
+
}, scope.options);
|
|
166
|
+
return records.map(mapMessageRecord);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The newest assistant message (the latest createdAt, then the highest id)
|
|
171
|
+
* in any of one user's conversations, in every workspace and surface, or
|
|
172
|
+
* null; with `kind`, the newest of that kind, such as "chat" for a reply.
|
|
173
|
+
* Two reads for a user with up to 1,000 conversations: their ids, then the
|
|
174
|
+
* newest message among them.
|
|
175
|
+
* @param {unknown} userId
|
|
176
|
+
* @param {{ kind?: string }} [criteria]
|
|
177
|
+
* @param {{ context?: object | null, trx?: object | null }} [options]
|
|
178
|
+
*/
|
|
179
|
+
async function findNewestAssistantByUserId(userId, { kind = "" } = {}, options = {}) {
|
|
180
|
+
const normalizedUserId = normalizeInputRecordId(userId);
|
|
181
|
+
if (!normalizedUserId) {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const owned = await conversations.queryAll(
|
|
186
|
+
{ filters: { userId: normalizedUserId }, fields: ["userId"] },
|
|
187
|
+
{ ...passedOn(options), context: userContext.forUser(normalizedUserId) }
|
|
188
|
+
);
|
|
189
|
+
const conversationIds = owned.map((conversation) => normalizeRecordId(conversation.id, { fallback: "" })).filter(Boolean);
|
|
190
|
+
const normalizedKind = normalizeText(kind).toLowerCase();
|
|
191
|
+
let newest = null;
|
|
192
|
+
for (let start = 0; start < conversationIds.length; start += CONVERSATION_IDS_PER_QUERY) {
|
|
193
|
+
const { records: [candidate] } = await messages.query({
|
|
194
|
+
filters: {
|
|
195
|
+
conversationIds: conversationIds.slice(start, start + CONVERSATION_IDS_PER_QUERY),
|
|
196
|
+
role: "assistant",
|
|
197
|
+
...(normalizedKind ? { kind: normalizedKind } : {})
|
|
198
|
+
},
|
|
199
|
+
sort: ["-createdAt", "-id"],
|
|
200
|
+
page: { size: 1 }
|
|
201
|
+
}, { ...passedOn(options), context: messageContext });
|
|
202
|
+
if (candidate && (!newest || isNewer(candidate, newest))) {
|
|
203
|
+
newest = candidate;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return newest ? mapMessageRecord(newest) : null;
|
|
155
207
|
}
|
|
156
208
|
|
|
157
209
|
async function lastSequence(conversationId, options = {}) {
|
|
158
210
|
const normalizedConversationId = normalizeInputRecordId(conversationId);
|
|
159
211
|
if (!normalizedConversationId) return 0;
|
|
160
|
-
return (
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
async function transaction(callback) {
|
|
164
|
-
return runInTransaction(knex, callback);
|
|
212
|
+
return highestSequence(normalizedConversationId, options);
|
|
165
213
|
}
|
|
166
214
|
|
|
167
215
|
return Object.freeze({
|
|
@@ -171,6 +219,7 @@ function createRepository(knex) {
|
|
|
171
219
|
updateById,
|
|
172
220
|
countByConversationScope,
|
|
173
221
|
listByConversationScope,
|
|
222
|
+
findNewestAssistantByUserId,
|
|
174
223
|
lastSequence,
|
|
175
224
|
transaction
|
|
176
225
|
});
|
|
@@ -1,26 +1,12 @@
|
|
|
1
|
-
import { resolveInsertedRecordId } from "@jskit-ai/database-runtime/shared";
|
|
2
|
-
import { normalizeDbRecordId } from "@jskit-ai/database-runtime/shared/repositoryOptions";
|
|
3
1
|
import { normalizeRecordId } from "@jskit-ai/kernel/shared/support/normalize";
|
|
4
2
|
import { parseJsonObject } from "@jskit-ai/assistant-core/shared";
|
|
5
3
|
|
|
6
|
-
|
|
7
|
-
return normalizeDbRecordId(value, { fallback: null });
|
|
8
|
-
}
|
|
9
|
-
|
|
4
|
+
/** @param {unknown} value */
|
|
10
5
|
function normalizeInputRecordId(value) {
|
|
11
6
|
return normalizeRecordId(value, { fallback: null });
|
|
12
7
|
}
|
|
13
8
|
|
|
14
|
-
|
|
15
|
-
const normalizedWorkspaceId = normalizeInputRecordId(workspaceId);
|
|
16
|
-
if (normalizedWorkspaceId) {
|
|
17
|
-
return query.where(columnName, normalizedWorkspaceId);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
return query.whereNull(columnName);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
|
|
9
|
+
/** @param {unknown} value */
|
|
24
10
|
function stringifyJsonObject(value) {
|
|
25
11
|
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
26
12
|
return null;
|
|
@@ -29,12 +15,13 @@ function stringifyJsonObject(value) {
|
|
|
29
15
|
return JSON.stringify(value);
|
|
30
16
|
}
|
|
31
17
|
|
|
18
|
+
/** @param {unknown} value */
|
|
32
19
|
function toIso(value) {
|
|
33
20
|
if (!value) {
|
|
34
21
|
return "";
|
|
35
22
|
}
|
|
36
23
|
|
|
37
|
-
const date = value instanceof Date ? value : new Date(value);
|
|
24
|
+
const date = value instanceof Date ? value : new Date(/** @type {string | number} */ (value));
|
|
38
25
|
if (Number.isNaN(date.getTime())) {
|
|
39
26
|
return "";
|
|
40
27
|
}
|
|
@@ -42,16 +29,18 @@ function toIso(value) {
|
|
|
42
29
|
return date.toISOString();
|
|
43
30
|
}
|
|
44
31
|
|
|
45
|
-
|
|
46
|
-
|
|
32
|
+
/**
|
|
33
|
+
* A date-time a repository writes: the caller's value as ISO text, or now.
|
|
34
|
+
* @param {unknown} [value]
|
|
35
|
+
*/
|
|
36
|
+
function toWrittenDateTime(value = null) {
|
|
37
|
+
return (value ? new Date(/** @type {string | number | Date} */ (value)) : new Date()).toISOString();
|
|
47
38
|
}
|
|
48
39
|
|
|
49
40
|
export {
|
|
50
|
-
applyWorkspaceScope,
|
|
51
|
-
normalizeDbWorkspaceId,
|
|
52
41
|
normalizeInputRecordId,
|
|
53
42
|
parseJsonObject,
|
|
54
43
|
stringifyJsonObject,
|
|
55
44
|
toIso,
|
|
56
|
-
|
|
45
|
+
toWrittenDateTime
|
|
57
46
|
};
|
|
@@ -1,33 +1,67 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { toWrittenDateTime } from "./repositoryPersistenceUtils.js";
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
function scopeKey(scope) {
|
|
5
|
+
return JSON.stringify([scope.surfaceId, scope.workspaceId]);
|
|
6
|
+
}
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
function mapTurnRequestRecord(record) {
|
|
9
|
+
return {
|
|
10
|
+
id: record.id,
|
|
11
|
+
token: record.claimToken,
|
|
12
|
+
request: JSON.parse(record.requestJson),
|
|
13
|
+
response: record.responseJson ? JSON.parse(record.responseJson) : null,
|
|
14
|
+
status: record.status
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The receipt of each chat request, on the assistantTurnRequests table: its
|
|
20
|
+
* actor claims and reads it in that actor's user context; the claim that
|
|
21
|
+
* recorded it settles it.
|
|
22
|
+
* @param {object} dependencies
|
|
23
|
+
* @param {import("@jskit-ai/resource-runtime/server/types").ResourceHandle} dependencies.turnRequests
|
|
24
|
+
* @param {(reason: string) => import("@jskit-ai/resource-runtime/server/types").SystemContext} dependencies.systemContext
|
|
25
|
+
*/
|
|
26
|
+
function createRepository({ turnRequests, systemContext }) {
|
|
27
|
+
const receiptContext = systemContext("a request receipt is settled by the claim that recorded it, after its actor's request was checked");
|
|
11
28
|
|
|
12
29
|
async function find(scope, messageId) {
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
30
|
+
const { records } = await turnRequests.query({
|
|
31
|
+
filters: { scopeKey: scopeKey(scope), messageSid: messageId },
|
|
32
|
+
page: { size: 1 }
|
|
33
|
+
}, { context: receiptContext.forUser(scope.actorUserId) });
|
|
34
|
+
return records[0] ? mapTurnRequestRecord(records[0]) : null;
|
|
16
35
|
}
|
|
17
36
|
|
|
18
37
|
async function claim(scope, request) {
|
|
19
38
|
const token = randomUUID();
|
|
20
|
-
await
|
|
21
|
-
|
|
39
|
+
await turnRequests.createMany([{
|
|
40
|
+
scopeKey: scopeKey(scope),
|
|
41
|
+
messageSid: request.messageId,
|
|
42
|
+
claimToken: token,
|
|
43
|
+
requestJson: JSON.stringify(request)
|
|
44
|
+
}], {
|
|
45
|
+
context: receiptContext.forUser(scope.actorUserId),
|
|
46
|
+
onConflict: { keys: ["userId", "scopeKey", "messageSid"], action: "ignore" }
|
|
47
|
+
});
|
|
22
48
|
const record = await find(scope, request.messageId);
|
|
23
49
|
if (!record) throw new Error("Assistant request could not be recorded.");
|
|
24
50
|
return { ...record, acquired: record.token === token };
|
|
25
51
|
}
|
|
26
52
|
|
|
27
|
-
async function update(
|
|
28
|
-
const changed = await
|
|
29
|
-
|
|
30
|
-
|
|
53
|
+
async function update(claimed, response, status = "running") {
|
|
54
|
+
const changed = await turnRequests.patch(claimed.id, {
|
|
55
|
+
responseJson: JSON.stringify(response),
|
|
56
|
+
status,
|
|
57
|
+
updatedAt: toWrittenDateTime()
|
|
58
|
+
}, {
|
|
59
|
+
context: receiptContext,
|
|
60
|
+
onlyIf: { claimToken: claimed.token, status: "running" },
|
|
61
|
+
missing: "skip",
|
|
62
|
+
returning: "id"
|
|
63
|
+
});
|
|
64
|
+
if (changed === null) throw new Error("Assistant request ownership was lost.");
|
|
31
65
|
}
|
|
32
66
|
|
|
33
67
|
return Object.freeze({
|
|
@@ -8,6 +8,8 @@ import { resolveAssistantServerConfig, resolveAssistantServerConfigMap } from ".
|
|
|
8
8
|
|
|
9
9
|
const WORKSPACE_SETTINGS_UPDATE_POLICY = Object.freeze({ require: "all", permissions: Object.freeze(["workspace.settings.update"]) });
|
|
10
10
|
const ASSISTANT_SETTINGS_UPDATE_POLICY = Object.freeze({ require: "all", permissions: Object.freeze(["assistant.settings.update"]) });
|
|
11
|
+
// The statuses of an access check's refusal (not signed in, not allowed), as against a failure.
|
|
12
|
+
const ACCESS_REFUSED_STATUSES = Object.freeze([401, 403]);
|
|
11
13
|
|
|
12
14
|
/**
|
|
13
15
|
* An assistant surface's saved settings: who may read and change them, and the
|
|
@@ -111,6 +113,25 @@ function createService({
|
|
|
111
113
|
});
|
|
112
114
|
}
|
|
113
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Whether the context may change a surface's settings: the check updateSettings makes, answered instead of thrown,
|
|
118
|
+
* so the settings screen offers Save only to the people the server lets save.
|
|
119
|
+
*/
|
|
120
|
+
async function canUpdateSettings(input = {}, options = {}) {
|
|
121
|
+
const assistantSurface = requireAssistantSurface(appConfig, input.targetSurfaceId);
|
|
122
|
+
try {
|
|
123
|
+
await requireSettingsAccess(assistantSurface, options.context, {
|
|
124
|
+
mode: "write"
|
|
125
|
+
});
|
|
126
|
+
return true;
|
|
127
|
+
} catch (error) {
|
|
128
|
+
if (ACCESS_REFUSED_STATUSES.includes(Number(error?.status))) {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
114
135
|
async function updateSettings(input = {}, patch = {}, options = {}) {
|
|
115
136
|
const assistantSurface = requireAssistantSurface(appConfig, input.targetSurfaceId);
|
|
116
137
|
await requireSettingsAccess(assistantSurface, options.context, {
|
|
@@ -145,6 +166,7 @@ function createService({
|
|
|
145
166
|
|
|
146
167
|
return Object.freeze({
|
|
147
168
|
getSettings,
|
|
169
|
+
canUpdateSettings,
|
|
148
170
|
updateSettings,
|
|
149
171
|
resolveSystemPrompt
|
|
150
172
|
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isDeepStrictEqual } from "node:util";
|
|
2
2
|
import { resolveActor } from "@jskit-ai/kernel/server/actions/actionContributorHelpers";
|
|
3
|
-
import { AppError, createValidationError } from "@jskit-ai/kernel/server/runtime";
|
|
3
|
+
import { AppError, createValidationError, hideDatabaseError } from "@jskit-ai/kernel/server/runtime";
|
|
4
|
+
import { silentLogger } from "@jskit-ai/kernel/server/runtime/logger";
|
|
4
5
|
import { normalizeObject, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
5
6
|
import { resolveWorkspaceSlug } from "@jskit-ai/assistant-core/server";
|
|
6
7
|
import { conversationAttachmentIds } from "@jskit-ai/assistant-core/server/conversation";
|
|
@@ -156,6 +157,7 @@ function buildAssistantActionContext(context = {}, assistantSurface = {}) {
|
|
|
156
157
|
* @param {PersonContextPrompt} dependencies.personContextPrompt
|
|
157
158
|
* @param {Record<string, any>} dependencies.appConfig
|
|
158
159
|
* @param {WorkspaceScopeSupport | null} [dependencies.workspaceScopeSupport]
|
|
160
|
+
* @param {import("@jskit-ai/kernel/server/types").RuntimeLogger} [dependencies.logger]
|
|
159
161
|
*/
|
|
160
162
|
function createChatService({
|
|
161
163
|
conversationRuntime,
|
|
@@ -167,7 +169,8 @@ function createChatService({
|
|
|
167
169
|
assistantConfigService,
|
|
168
170
|
personContextPrompt,
|
|
169
171
|
appConfig,
|
|
170
|
-
workspaceScopeSupport
|
|
172
|
+
workspaceScopeSupport,
|
|
173
|
+
logger = silentLogger
|
|
171
174
|
}) {
|
|
172
175
|
function resolveRuntimeWorkspace(assistantSurface, context = {}, input = {}) {
|
|
173
176
|
if (assistantSurface?.runtimeSurfaceRequiresWorkspace !== true) {
|
|
@@ -279,14 +282,16 @@ function createChatService({
|
|
|
279
282
|
let result;
|
|
280
283
|
try {
|
|
281
284
|
result = await executeTurn();
|
|
282
|
-
} catch (
|
|
285
|
+
} catch (thrown) {
|
|
286
|
+
// A database error is saved, so replayed, only as its stand-in; the route answers and logs the error itself.
|
|
287
|
+
const error = hideDatabaseError(thrown);
|
|
283
288
|
response.failure = { message: String(error?.message || "Assistant request failed."),
|
|
284
289
|
status: errorStatus(error),
|
|
285
290
|
...(error instanceof AppError ? { code: error.code,
|
|
286
291
|
...(exposedAppErrorDetails(error) ? { details: error.details } : {}) } : {}),
|
|
287
292
|
...savedMessageFields(error) };
|
|
288
293
|
await turnRequests.update(claim, response, "failed");
|
|
289
|
-
throw
|
|
294
|
+
throw thrown;
|
|
290
295
|
}
|
|
291
296
|
await turnRequests.update(claim, response, result.status);
|
|
292
297
|
if (response.done) output.sendDone(response.done);
|
|
@@ -413,10 +418,13 @@ function createChatService({
|
|
|
413
418
|
streamWriter.sendAssistantMessage({ type: ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE, text });
|
|
414
419
|
streamWriter.sendDone({ type: ASSISTANT_STREAM_EVENT_TYPES.DONE, messageId: source.messageId, status: "completed" });
|
|
415
420
|
return { conversationId, messageId: source.messageId, status: "completed" };
|
|
416
|
-
} catch (
|
|
417
|
-
const aborted = isAbortError(
|
|
421
|
+
} catch (thrown) {
|
|
422
|
+
const aborted = isAbortError(thrown) || options.abortSignal?.aborted;
|
|
418
423
|
const status = aborted ? "aborted" : "failed";
|
|
419
|
-
if (!streamed) throw
|
|
424
|
+
if (!streamed) throw thrown;
|
|
425
|
+
// A database error streams only as its stand-in (a generic 409 or 500), and its detail goes to the log.
|
|
426
|
+
const error = hideDatabaseError(thrown);
|
|
427
|
+
if (error !== thrown) logger.error({ err: thrown }, "Assistant turn failed on a database error.");
|
|
420
428
|
await transcriptService.completeConversation(assistantSurface, conversationId, { status }, { context: assistantContext, workspace });
|
|
421
429
|
streamWriter.sendError({ type: ASSISTANT_STREAM_EVENT_TYPES.ERROR, messageId: source.messageId,
|
|
422
430
|
code: aborted ? "assistant_stream_aborted" : String(error?.code || "assistant_stream_failed"),
|
|
@@ -162,12 +162,12 @@ function createTranscriptService({ conversationsRepository, messagesRepository }
|
|
|
162
162
|
required: true
|
|
163
163
|
});
|
|
164
164
|
const workspaceId = resolveExpectedWorkspaceId(resolvedAssistantSurface, options.workspace || context.workspace);
|
|
165
|
-
return conversationsRepository.transaction(async
|
|
165
|
+
return conversationsRepository.transaction(async txOptions => {
|
|
166
166
|
const existing = await conversationsRepository.findByIdForActorScope(normalizedConversationId, {
|
|
167
167
|
workspaceId,
|
|
168
168
|
actorUserId,
|
|
169
169
|
surfaceId: resolvedAssistantSurface.targetSurfaceId
|
|
170
|
-
}, {
|
|
170
|
+
}, { ...txOptions, forUpdate: true });
|
|
171
171
|
if (!existing) {
|
|
172
172
|
throw new AppError(404, "Conversation not found.", { code: "assistant.runtime.conversation.notFound" });
|
|
173
173
|
}
|
|
@@ -181,7 +181,7 @@ function createTranscriptService({ conversationsRepository, messagesRepository }
|
|
|
181
181
|
...normalizeObject(existing.metadata),
|
|
182
182
|
...normalizeObject(source.metadata)
|
|
183
183
|
}
|
|
184
|
-
},
|
|
184
|
+
}, txOptions);
|
|
185
185
|
});
|
|
186
186
|
}
|
|
187
187
|
|