@jskit-ai/assistant-runtime 0.1.1

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 (30) hide show
  1. package/package.descriptor.mjs +136 -0
  2. package/package.json +21 -0
  3. package/src/client/components/AssistantSettingsClientElement.vue +204 -0
  4. package/src/client/components/AssistantSurfaceClientElement.vue +19 -0
  5. package/src/client/composables/useAssistantRuntime.js +759 -0
  6. package/src/client/index.js +4 -0
  7. package/src/client/providers/AssistantClientProvider.js +16 -0
  8. package/src/server/AssistantProvider.js +152 -0
  9. package/src/server/actionIds.js +9 -0
  10. package/src/server/actions.js +151 -0
  11. package/src/server/inputValidators.js +41 -0
  12. package/src/server/registerRoutes.js +450 -0
  13. package/src/server/repositories/assistantConfigRepository.js +148 -0
  14. package/src/server/repositories/conversationsRepository.js +263 -0
  15. package/src/server/repositories/messagesRepository.js +166 -0
  16. package/src/server/services/assistantConfigService.js +132 -0
  17. package/src/server/services/chatService.js +1048 -0
  18. package/src/server/services/transcriptService.js +331 -0
  19. package/src/server/support/assistantServerConfig.js +106 -0
  20. package/src/server/support/createSurfaceAwareToolCatalog.js +64 -0
  21. package/src/shared/assistantRuntimeConfig.js +7 -0
  22. package/src/shared/assistantSurfaces.js +97 -0
  23. package/src/shared/index.js +7 -0
  24. package/templates/migrations/assistant_config_initial.cjs +27 -0
  25. package/templates/migrations/assistant_transcripts_initial.cjs +58 -0
  26. package/test/assistantServerConfig.test.js +72 -0
  27. package/test/assistantSurfaces.test.js +50 -0
  28. package/test/createSurfaceAwareToolCatalog.test.js +77 -0
  29. package/test/lazyAppConfig.test.js +248 -0
  30. package/test/packageDescriptor.test.js +34 -0
@@ -0,0 +1,450 @@
1
+ import { AppError } from "@jskit-ai/kernel/server/runtime";
2
+ import { resolveAppConfig } from "@jskit-ai/kernel/server/support";
3
+ import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
4
+ import { withStandardErrorResponses } from "@jskit-ai/http-runtime/shared/validators/errorResponses";
5
+ import { buildWorkspaceInputFromRouteParams } from "@jskit-ai/users-core/server/support/workspaceRouteInput";
6
+ import { workspaceSlugParamsValidator } from "@jskit-ai/users-core/server/validators/routeParamsValidator";
7
+ import {
8
+ assistantConfigResource,
9
+ assistantResource,
10
+ resolveAssistantApiBasePath
11
+ } from "@jskit-ai/assistant-core/shared";
12
+ import {
13
+ endNdjson,
14
+ mapStreamError,
15
+ setNdjsonHeaders,
16
+ writeNdjson
17
+ } from "@jskit-ai/assistant-core/server";
18
+ import { resolveAssistantSurfaceConfig } from "../shared/assistantSurfaces.js";
19
+ import { actionIds } from "./actionIds.js";
20
+ import { assistantSurfaceRouteParamsValidator } from "./inputValidators.js";
21
+
22
+ function buildRouteParamsValidator(requiresWorkspace) {
23
+ if (requiresWorkspace === true) {
24
+ return [workspaceSlugParamsValidator, assistantSurfaceRouteParamsValidator];
25
+ }
26
+
27
+ return assistantSurfaceRouteParamsValidator;
28
+ }
29
+
30
+ function buildConversationMessagesRouteParamsValidator(requiresWorkspace) {
31
+ const validators = buildRouteParamsValidator(requiresWorkspace);
32
+ if (Array.isArray(validators)) {
33
+ return validators.concat(assistantResource.operations.conversationMessagesList.paramsValidator);
34
+ }
35
+
36
+ return [validators, assistantResource.operations.conversationMessagesList.paramsValidator];
37
+ }
38
+
39
+ function readWorkspaceInput(request, requiresWorkspace) {
40
+ if (requiresWorkspace !== true) {
41
+ return {};
42
+ }
43
+
44
+ return buildWorkspaceInputFromRouteParams(request?.input?.params);
45
+ }
46
+
47
+ function requireAssistantSurface(appConfig = {}, targetSurfaceId = "") {
48
+ const assistantSurface = resolveAssistantSurfaceConfig(appConfig, targetSurfaceId);
49
+ if (assistantSurface) {
50
+ return assistantSurface;
51
+ }
52
+
53
+ throw new AppError(404, "Assistant not found.");
54
+ }
55
+
56
+ function requireHostSurfaceId(request) {
57
+ const headerValue = Array.isArray(request?.headers?.["x-jskit-surface"])
58
+ ? request.headers["x-jskit-surface"][0]
59
+ : request?.headers?.["x-jskit-surface"];
60
+ const hostSurfaceId = normalizeSurfaceId(headerValue);
61
+ if (hostSurfaceId) {
62
+ return hostSurfaceId;
63
+ }
64
+
65
+ throw new AppError(400, "Assistant surface header x-jskit-surface is required.");
66
+ }
67
+
68
+ function shouldExposeAppErrorDetails(errorCode = "") {
69
+ return String(errorCode || "").trim() !== "ACTION_PERMISSION_DENIED";
70
+ }
71
+
72
+ function sendPreStreamErrorResponse(reply, error) {
73
+ const statusCode = Number(error?.status || error?.statusCode || 500);
74
+ const safeStatusCode = Number.isInteger(statusCode) && statusCode >= 400 && statusCode <= 599 ? statusCode : 500;
75
+
76
+ if (error instanceof AppError) {
77
+ const appErrorCode = String(error?.code || "app_error").trim() || "app_error";
78
+ const payload = {
79
+ error: error.message,
80
+ code: appErrorCode
81
+ };
82
+
83
+ if (error.details && shouldExposeAppErrorDetails(appErrorCode)) {
84
+ payload.details = error.details;
85
+ if (error.details.fieldErrors) {
86
+ payload.fieldErrors = error.details.fieldErrors;
87
+ }
88
+ }
89
+
90
+ if (error.headers && typeof error.headers === "object") {
91
+ Object.entries(error.headers).forEach(([name, value]) => {
92
+ reply.header(name, value);
93
+ });
94
+ }
95
+
96
+ reply.code(safeStatusCode).send(payload);
97
+ return;
98
+ }
99
+
100
+ reply.code(safeStatusCode).send({
101
+ error: safeStatusCode >= 500 ? "Internal server error." : String(error?.message || "Request failed.")
102
+ });
103
+ }
104
+
105
+ function resolveRouteRequestState(request, { resolveCurrentAppConfig = () => ({}), kind = "runtime", requiresWorkspace = false } = {}) {
106
+ const appConfig = resolveCurrentAppConfig();
107
+ const targetSurfaceId = normalizeSurfaceId(request?.input?.params?.surfaceId);
108
+ const assistantSurface = requireAssistantSurface(appConfig, targetSurfaceId);
109
+ const hostSurfaceId = requireHostSurfaceId(request);
110
+ const expectsWorkspace = kind === "settings"
111
+ ? assistantSurface.settingsSurfaceRequiresWorkspace
112
+ : assistantSurface.runtimeSurfaceRequiresWorkspace;
113
+ const expectedHostSurfaceId = kind === "settings"
114
+ ? assistantSurface.settingsSurfaceId
115
+ : assistantSurface.targetSurfaceId;
116
+
117
+ if (expectsWorkspace !== (requiresWorkspace === true)) {
118
+ throw new AppError(404, "Assistant route not found.");
119
+ }
120
+ if (hostSurfaceId !== expectedHostSurfaceId) {
121
+ throw new AppError(403, "Assistant route is not available on this surface.");
122
+ }
123
+
124
+ return Object.freeze({
125
+ assistantSurface,
126
+ hostSurfaceId,
127
+ actionInput: Object.freeze({
128
+ targetSurfaceId: assistantSurface.targetSurfaceId,
129
+ ...readWorkspaceInput(request, requiresWorkspace)
130
+ })
131
+ });
132
+ }
133
+
134
+ function registerSettingsRoutes(router, resolveCurrentAppConfig, { requiresWorkspace = false } = {}) {
135
+ const routeBase = resolveAssistantApiBasePath({
136
+ requiresWorkspace
137
+ });
138
+ const visibility = requiresWorkspace ? "workspace" : "public";
139
+ const routePath = `${routeBase}/:surfaceId/settings`;
140
+ const paramsValidator = buildRouteParamsValidator(requiresWorkspace);
141
+
142
+ router.register(
143
+ "GET",
144
+ routePath,
145
+ {
146
+ auth: "required",
147
+ visibility,
148
+ paramsValidator,
149
+ meta: {
150
+ tags: ["assistant", "settings"],
151
+ summary: "Get assistant settings."
152
+ },
153
+ responseValidators: withStandardErrorResponses({
154
+ 200: assistantConfigResource.operations.view.outputValidator
155
+ })
156
+ },
157
+ async function assistantSettingsReadRoute(request, reply) {
158
+ const routeState = resolveRouteRequestState(request, {
159
+ resolveCurrentAppConfig,
160
+ kind: "settings",
161
+ requiresWorkspace
162
+ });
163
+
164
+ const response = await request.executeAction({
165
+ actionId: actionIds.settingsRead,
166
+ context: {
167
+ surface: routeState.hostSurfaceId
168
+ },
169
+ input: routeState.actionInput
170
+ });
171
+
172
+ reply.code(200).send(response);
173
+ }
174
+ );
175
+
176
+ router.register(
177
+ "PATCH",
178
+ routePath,
179
+ {
180
+ auth: "required",
181
+ visibility,
182
+ paramsValidator,
183
+ meta: {
184
+ tags: ["assistant", "settings"],
185
+ summary: "Update assistant settings."
186
+ },
187
+ bodyValidator: assistantConfigResource.operations.patch.bodyValidator,
188
+ responseValidators: withStandardErrorResponses(
189
+ {
190
+ 200: assistantConfigResource.operations.patch.outputValidator
191
+ },
192
+ {
193
+ includeValidation400: true
194
+ }
195
+ )
196
+ },
197
+ async function assistantSettingsPatchRoute(request, reply) {
198
+ const routeState = resolveRouteRequestState(request, {
199
+ resolveCurrentAppConfig,
200
+ kind: "settings",
201
+ requiresWorkspace
202
+ });
203
+
204
+ const response = await request.executeAction({
205
+ actionId: actionIds.settingsUpdate,
206
+ context: {
207
+ surface: routeState.hostSurfaceId
208
+ },
209
+ input: {
210
+ ...routeState.actionInput,
211
+ patch: request.input.body
212
+ }
213
+ });
214
+
215
+ reply.code(200).send(response);
216
+ }
217
+ );
218
+ }
219
+
220
+ function registerRuntimeRoutes(router, resolveCurrentAppConfig, { requiresWorkspace = false } = {}) {
221
+ const routeBase = resolveAssistantApiBasePath({
222
+ requiresWorkspace
223
+ });
224
+ const visibility = requiresWorkspace ? "workspace" : "public";
225
+ const surfaceRouteBase = `${routeBase}/:surfaceId`;
226
+ const paramsValidator = buildRouteParamsValidator(requiresWorkspace);
227
+
228
+ router.register(
229
+ "POST",
230
+ `${surfaceRouteBase}/chat/stream`,
231
+ {
232
+ auth: "required",
233
+ visibility,
234
+ paramsValidator,
235
+ meta: {
236
+ tags: ["assistant"],
237
+ summary: "Stream assistant response."
238
+ },
239
+ bodyValidator: assistantResource.operations.chatStream.bodyValidator
240
+ },
241
+ async function assistantChatStreamRoute(request, reply) {
242
+ const routeState = resolveRouteRequestState(request, {
243
+ resolveCurrentAppConfig,
244
+ kind: "runtime",
245
+ requiresWorkspace
246
+ });
247
+ const abortController = new AbortController();
248
+ const requestBody = request?.input?.body && typeof request.input.body === "object" ? request.input.body : {};
249
+ const closeListener = () => {
250
+ abortController.abort();
251
+ };
252
+
253
+ let streamStarted = false;
254
+
255
+ function ensureStreamStarted() {
256
+ if (streamStarted) {
257
+ return;
258
+ }
259
+
260
+ setNdjsonHeaders(reply);
261
+ reply.code(200);
262
+ reply.hijack();
263
+ if (typeof reply.raw.flushHeaders === "function") {
264
+ reply.raw.flushHeaders();
265
+ }
266
+ streamStarted = true;
267
+ }
268
+
269
+ const streamWriter = Object.freeze({
270
+ sendMeta(payload = {}) {
271
+ ensureStreamStarted();
272
+ writeNdjson(reply, payload);
273
+ },
274
+ sendAssistantDelta(payload = {}) {
275
+ ensureStreamStarted();
276
+ writeNdjson(reply, payload);
277
+ },
278
+ sendAssistantMessage(payload = {}) {
279
+ ensureStreamStarted();
280
+ writeNdjson(reply, payload);
281
+ },
282
+ sendToolCall(payload = {}) {
283
+ ensureStreamStarted();
284
+ writeNdjson(reply, payload);
285
+ },
286
+ sendToolResult(payload = {}) {
287
+ ensureStreamStarted();
288
+ writeNdjson(reply, payload);
289
+ },
290
+ sendError(payload = {}) {
291
+ ensureStreamStarted();
292
+ writeNdjson(reply, payload);
293
+ },
294
+ sendDone(payload = {}) {
295
+ ensureStreamStarted();
296
+ writeNdjson(reply, payload);
297
+ }
298
+ });
299
+
300
+ try {
301
+ request.raw.on("close", closeListener);
302
+
303
+ await request.executeAction({
304
+ actionId: actionIds.chatStream,
305
+ context: {
306
+ surface: routeState.hostSurfaceId
307
+ },
308
+ input: {
309
+ ...routeState.actionInput,
310
+ messageId: requestBody.messageId,
311
+ conversationId: requestBody.conversationId,
312
+ input: requestBody.input,
313
+ history: requestBody.history,
314
+ clientContext: requestBody.clientContext
315
+ },
316
+ deps: {
317
+ streamWriter,
318
+ abortSignal: abortController.signal
319
+ }
320
+ });
321
+
322
+ if (streamStarted) {
323
+ endNdjson(reply);
324
+ return;
325
+ }
326
+
327
+ reply.code(204).send();
328
+ } catch (error) {
329
+ if (!streamStarted) {
330
+ sendPreStreamErrorResponse(reply, error);
331
+ return;
332
+ }
333
+
334
+ const streamError = mapStreamError(error);
335
+ writeNdjson(reply, {
336
+ type: "error",
337
+ ...streamError
338
+ });
339
+ writeNdjson(reply, {
340
+ type: "done",
341
+ status: "failed"
342
+ });
343
+ endNdjson(reply);
344
+ } finally {
345
+ request.raw.off("close", closeListener);
346
+ }
347
+ }
348
+ );
349
+
350
+ router.register(
351
+ "GET",
352
+ `${surfaceRouteBase}/conversations`,
353
+ {
354
+ auth: "required",
355
+ visibility,
356
+ paramsValidator,
357
+ meta: {
358
+ tags: ["assistant"],
359
+ summary: "List assistant conversations."
360
+ },
361
+ queryValidator: assistantResource.operations.conversationsList.queryValidator,
362
+ responseValidators: withStandardErrorResponses({
363
+ 200: assistantResource.operations.conversationsList.outputValidator
364
+ })
365
+ },
366
+ async function assistantConversationsRoute(request, reply) {
367
+ const routeState = resolveRouteRequestState(request, {
368
+ resolveCurrentAppConfig,
369
+ kind: "runtime",
370
+ requiresWorkspace
371
+ });
372
+
373
+ const response = await request.executeAction({
374
+ actionId: actionIds.conversationsList,
375
+ context: {
376
+ surface: routeState.hostSurfaceId
377
+ },
378
+ input: {
379
+ ...routeState.actionInput,
380
+ query: request.input.query
381
+ }
382
+ });
383
+
384
+ reply.code(200).send(response);
385
+ }
386
+ );
387
+
388
+ router.register(
389
+ "GET",
390
+ `${surfaceRouteBase}/conversations/:conversationId/messages`,
391
+ {
392
+ auth: "required",
393
+ visibility,
394
+ paramsValidator: buildConversationMessagesRouteParamsValidator(requiresWorkspace),
395
+ meta: {
396
+ tags: ["assistant"],
397
+ summary: "List assistant conversation messages."
398
+ },
399
+ queryValidator: assistantResource.operations.conversationMessagesList.queryValidator,
400
+ responseValidators: withStandardErrorResponses({
401
+ 200: assistantResource.operations.conversationMessagesList.outputValidator
402
+ })
403
+ },
404
+ async function assistantConversationMessagesRoute(request, reply) {
405
+ const routeState = resolveRouteRequestState(request, {
406
+ resolveCurrentAppConfig,
407
+ kind: "runtime",
408
+ requiresWorkspace
409
+ });
410
+
411
+ const response = await request.executeAction({
412
+ actionId: actionIds.conversationMessagesList,
413
+ context: {
414
+ surface: routeState.hostSurfaceId
415
+ },
416
+ input: {
417
+ ...routeState.actionInput,
418
+ conversationId: request.input.params.conversationId,
419
+ query: request.input.query
420
+ }
421
+ });
422
+
423
+ reply.code(200).send(response);
424
+ }
425
+ );
426
+ }
427
+
428
+ function registerRoutes(app) {
429
+ if (!app || typeof app.make !== "function") {
430
+ throw new Error("registerRoutes requires application make().");
431
+ }
432
+
433
+ const router = app.make("jskit.http.router");
434
+ const resolveCurrentAppConfig = () => resolveAppConfig(app);
435
+
436
+ registerSettingsRoutes(router, resolveCurrentAppConfig, {
437
+ requiresWorkspace: false
438
+ });
439
+ registerSettingsRoutes(router, resolveCurrentAppConfig, {
440
+ requiresWorkspace: true
441
+ });
442
+ registerRuntimeRoutes(router, resolveCurrentAppConfig, {
443
+ requiresWorkspace: false
444
+ });
445
+ registerRuntimeRoutes(router, resolveCurrentAppConfig, {
446
+ requiresWorkspace: true
447
+ });
448
+ }
449
+
450
+ export { registerRoutes };
@@ -0,0 +1,148 @@
1
+ import { parsePositiveInteger } from "@jskit-ai/kernel/server/runtime";
2
+ import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
3
+ import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
4
+ import { resolveInsertedId } from "@jskit-ai/assistant-core/server";
5
+ import { assistantRuntimeConfig } from "../../shared/assistantRuntimeConfig.js";
6
+
7
+ function normalizeTargetSurfaceId(value = "") {
8
+ return normalizeSurfaceId(value);
9
+ }
10
+
11
+ function normalizeWorkspaceId(value) {
12
+ return parsePositiveInteger(value) || null;
13
+ }
14
+
15
+ function buildScopeKey(targetSurfaceId, workspaceId = null) {
16
+ const normalizedTargetSurfaceId = normalizeTargetSurfaceId(targetSurfaceId);
17
+ if (!normalizedTargetSurfaceId) {
18
+ throw new TypeError("assistantConfigRepository requires targetSurfaceId.");
19
+ }
20
+ const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId);
21
+ if (normalizedWorkspaceId) {
22
+ return `${normalizedTargetSurfaceId}:workspace:${normalizedWorkspaceId}`;
23
+ }
24
+
25
+ return `${normalizedTargetSurfaceId}:global`;
26
+ }
27
+
28
+ function mapConfigRow(row = {}) {
29
+ return {
30
+ targetSurfaceId: normalizeTargetSurfaceId(row.target_surface_id),
31
+ scopeKey: normalizeText(row.scope_key),
32
+ workspaceId: normalizeWorkspaceId(row.workspace_id),
33
+ settings: {
34
+ systemPrompt: String(row.system_prompt || "")
35
+ }
36
+ };
37
+ }
38
+
39
+ function createDefaultRecord({ targetSurfaceId = "", workspaceId = null } = {}) {
40
+ const normalizedTargetSurfaceId = normalizeTargetSurfaceId(targetSurfaceId);
41
+ if (!normalizedTargetSurfaceId) {
42
+ throw new TypeError("assistantConfigRepository requires targetSurfaceId.");
43
+ }
44
+ const normalizedWorkspaceId = normalizeWorkspaceId(workspaceId);
45
+
46
+ return {
47
+ targetSurfaceId: normalizedTargetSurfaceId,
48
+ scopeKey: buildScopeKey(normalizedTargetSurfaceId, normalizedWorkspaceId),
49
+ workspaceId: normalizedWorkspaceId,
50
+ settings: {
51
+ systemPrompt: ""
52
+ }
53
+ };
54
+ }
55
+
56
+ function createRepository(knex) {
57
+ if (!knex || typeof knex !== "function") {
58
+ throw new Error("createAssistantConfigRepository requires knex client.");
59
+ }
60
+
61
+ async function findByScope({ targetSurfaceId = "", workspaceId = null } = {}, options = {}) {
62
+ const client = options?.trx || knex;
63
+ const defaultRecord = createDefaultRecord({
64
+ targetSurfaceId,
65
+ workspaceId
66
+ });
67
+ const row = await client(assistantRuntimeConfig.configTable)
68
+ .where({
69
+ target_surface_id: defaultRecord.targetSurfaceId,
70
+ scope_key: defaultRecord.scopeKey
71
+ })
72
+ .first();
73
+
74
+ return row ? mapConfigRow(row) : null;
75
+ }
76
+
77
+ async function upsertByScope({ targetSurfaceId = "", workspaceId = null, patch = {} } = {}, options = {}) {
78
+ const client = options?.trx || knex;
79
+ const defaultRecord = createDefaultRecord({
80
+ targetSurfaceId,
81
+ workspaceId
82
+ });
83
+ const existing = await findByScope(defaultRecord, {
84
+ trx: client
85
+ });
86
+ const nextSystemPrompt = Object.hasOwn(patch || {}, "systemPrompt")
87
+ ? String(patch.systemPrompt || "")
88
+ : String(existing?.settings?.systemPrompt || defaultRecord.settings.systemPrompt);
89
+ const now = new Date();
90
+
91
+ if (existing) {
92
+ await client(assistantRuntimeConfig.configTable)
93
+ .where({
94
+ target_surface_id: defaultRecord.targetSurfaceId,
95
+ scope_key: defaultRecord.scopeKey
96
+ })
97
+ .update({
98
+ workspace_id: defaultRecord.workspaceId,
99
+ system_prompt: nextSystemPrompt,
100
+ updated_at: now
101
+ });
102
+
103
+ return {
104
+ ...defaultRecord,
105
+ settings: {
106
+ systemPrompt: nextSystemPrompt
107
+ }
108
+ };
109
+ }
110
+
111
+ const insertResult = await client(assistantRuntimeConfig.configTable).insert({
112
+ target_surface_id: defaultRecord.targetSurfaceId,
113
+ scope_key: defaultRecord.scopeKey,
114
+ workspace_id: defaultRecord.workspaceId,
115
+ system_prompt: nextSystemPrompt,
116
+ created_at: now,
117
+ updated_at: now
118
+ });
119
+ const insertedId = resolveInsertedId(insertResult);
120
+ if (!insertedId) {
121
+ return {
122
+ ...defaultRecord,
123
+ settings: {
124
+ systemPrompt: nextSystemPrompt
125
+ }
126
+ };
127
+ }
128
+
129
+ const insertedRow = await client(assistantRuntimeConfig.configTable)
130
+ .where({ id: insertedId })
131
+ .first();
132
+
133
+ return insertedRow ? mapConfigRow(insertedRow) : {
134
+ ...defaultRecord,
135
+ settings: {
136
+ systemPrompt: nextSystemPrompt
137
+ }
138
+ };
139
+ }
140
+
141
+ return Object.freeze({
142
+ createDefaultRecord,
143
+ findByScope,
144
+ upsertByScope
145
+ });
146
+ }
147
+
148
+ export { createRepository };