@jskit-ai/assistant 0.1.4

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 (57) hide show
  1. package/package.descriptor.mjs +284 -0
  2. package/package.json +31 -0
  3. package/src/client/components/AssistantClientElement.vue +1316 -0
  4. package/src/client/components/AssistantConsoleSettingsClientElement.vue +71 -0
  5. package/src/client/components/AssistantSettingsFormCard.vue +76 -0
  6. package/src/client/components/AssistantWorkspaceClientElement.vue +15 -0
  7. package/src/client/components/AssistantWorkspaceSettingsClientElement.vue +73 -0
  8. package/src/client/composables/useAssistantWorkspaceRuntime.js +789 -0
  9. package/src/client/index.js +12 -0
  10. package/src/client/lib/assistantApi.js +137 -0
  11. package/src/client/lib/assistantHttpClient.js +10 -0
  12. package/src/client/lib/markdownRenderer.js +31 -0
  13. package/src/client/providers/AssistantWebClientProvider.js +25 -0
  14. package/src/server/AssistantServiceProvider.js +179 -0
  15. package/src/server/actionIds.js +11 -0
  16. package/src/server/actions.js +191 -0
  17. package/src/server/diTokens.js +19 -0
  18. package/src/server/lib/aiClient.js +43 -0
  19. package/src/server/lib/ndjson.js +47 -0
  20. package/src/server/lib/providers/anthropicClient.js +375 -0
  21. package/src/server/lib/providers/common.js +158 -0
  22. package/src/server/lib/providers/deepSeekClient.js +22 -0
  23. package/src/server/lib/providers/openAiClient.js +13 -0
  24. package/src/server/lib/providers/openAiCompatibleClient.js +69 -0
  25. package/src/server/lib/resolveWorkspaceSlug.js +24 -0
  26. package/src/server/lib/serviceToolCatalog.js +459 -0
  27. package/src/server/registerRoutes.js +384 -0
  28. package/src/server/repositories/assistantSettingsRepository.js +100 -0
  29. package/src/server/repositories/conversationsRepository.js +244 -0
  30. package/src/server/repositories/messagesRepository.js +154 -0
  31. package/src/server/repositories/repositoryPersistenceUtils.js +63 -0
  32. package/src/server/services/assistantSettingsService.js +153 -0
  33. package/src/server/services/chatService.js +987 -0
  34. package/src/server/services/transcriptService.js +334 -0
  35. package/src/shared/assistantPaths.js +50 -0
  36. package/src/shared/assistantResource.js +323 -0
  37. package/src/shared/assistantSettingsResource.js +214 -0
  38. package/src/shared/index.js +39 -0
  39. package/src/shared/queryKeys.js +69 -0
  40. package/src/shared/settingsEvents.js +7 -0
  41. package/src/shared/streamEvents.js +31 -0
  42. package/src/shared/support/positiveInteger.js +9 -0
  43. package/templates/migrations/assistant_settings_initial.cjs +39 -0
  44. package/templates/migrations/assistant_transcripts_initial.cjs +51 -0
  45. package/templates/src/pages/admin/workspace/assistant/index.vue +7 -0
  46. package/test/aiConfigValidation.test.js +15 -0
  47. package/test/assistantApiSurfaceHeader.test.js +64 -0
  48. package/test/assistantResource.test.js +53 -0
  49. package/test/assistantSettingsResource.test.js +48 -0
  50. package/test/assistantSettingsService.test.js +133 -0
  51. package/test/chatService.test.js +841 -0
  52. package/test/descriptorSurfaceOption.test.js +35 -0
  53. package/test/queryKeys.test.js +41 -0
  54. package/test/resolveWorkspaceSlug.test.js +83 -0
  55. package/test/routeInputContracts.test.js +287 -0
  56. package/test/serviceToolCatalog.test.js +1235 -0
  57. package/test/transcriptService.test.js +175 -0
@@ -0,0 +1,789 @@
1
+ import { computed, ref, watch } from "vue";
2
+ import { useQueryClient } from "@tanstack/vue-query";
3
+ import { getClientAppConfig } from "@jskit-ai/kernel/client";
4
+ import { normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
5
+ import { useRealtimeEvent } from "@jskit-ai/realtime/client/composables/useRealtimeEvent";
6
+ import { useShellWebErrorRuntime } from "@jskit-ai/shell-web/client/error";
7
+ import { useWorkspaceRouteContext } from "@jskit-ai/users-web/client/composables/useWorkspaceRouteContext";
8
+ import { usePagedCollection } from "@jskit-ai/users-web/client/composables/usePagedCollection";
9
+ import {
10
+ MAX_INPUT_CHARS,
11
+ ASSISTANT_STREAM_EVENT_TYPES,
12
+ ASSISTANT_TRANSCRIPT_CHANGED_EVENT,
13
+ MAX_HISTORY_MESSAGES,
14
+ assistantConversationMessagesQueryKey,
15
+ assistantConversationsListQueryKey,
16
+ assistantWorkspaceScopeQueryKey,
17
+ normalizeAssistantStreamEventType,
18
+ toPositiveInteger
19
+ } from "../../shared/index.js";
20
+ import { assistantHttpClient } from "../lib/assistantHttpClient.js";
21
+ import { createAssistantWorkspaceApi } from "../lib/assistantApi.js";
22
+
23
+ const DEFAULT_STREAM_TIMEOUT_MS = 120_000;
24
+ const DEFAULT_HISTORY_PAGE_SIZE = 20;
25
+ const DEFAULT_MESSAGES_PAGE_SIZE = 200;
26
+ const DEFAULT_HISTORY_STALE_TIME_MS = 60_000;
27
+ const RESTORE_MESSAGES_PAGE = 1;
28
+ const ACTIVE_CONVERSATION_STORAGE_PREFIX = "assistant.activeConversationId";
29
+
30
+ function toNonNegativeInteger(value, fallback = 0) {
31
+ const parsed = Number(value);
32
+ if (!Number.isInteger(parsed) || parsed < 0) {
33
+ return fallback;
34
+ }
35
+
36
+ return parsed;
37
+ }
38
+
39
+ function buildActiveConversationStorageKey(workspaceSlug) {
40
+ const normalizedWorkspaceSlug = normalizeText(workspaceSlug);
41
+ if (!normalizedWorkspaceSlug) {
42
+ return "";
43
+ }
44
+
45
+ return `${ACTIVE_CONVERSATION_STORAGE_PREFIX}:${normalizedWorkspaceSlug}`;
46
+ }
47
+
48
+ function readStoredActiveConversationId(workspaceSlug) {
49
+ if (typeof window === "undefined" || !window.sessionStorage) {
50
+ return 0;
51
+ }
52
+
53
+ const storageKey = buildActiveConversationStorageKey(workspaceSlug);
54
+ if (!storageKey) {
55
+ return 0;
56
+ }
57
+
58
+ try {
59
+ return toPositiveInteger(window.sessionStorage.getItem(storageKey), 0);
60
+ } catch {
61
+ return 0;
62
+ }
63
+ }
64
+
65
+ function writeStoredActiveConversationId(workspaceSlug, conversationId) {
66
+ if (typeof window === "undefined" || !window.sessionStorage) {
67
+ return;
68
+ }
69
+
70
+ const storageKey = buildActiveConversationStorageKey(workspaceSlug);
71
+ if (!storageKey) {
72
+ return;
73
+ }
74
+
75
+ const normalizedConversationId = toPositiveInteger(conversationId, 0);
76
+ try {
77
+ if (normalizedConversationId > 0) {
78
+ window.sessionStorage.setItem(storageKey, String(normalizedConversationId));
79
+ return;
80
+ }
81
+
82
+ window.sessionStorage.removeItem(storageKey);
83
+ } catch {
84
+ return;
85
+ }
86
+ }
87
+
88
+ function buildId(prefix = "id") {
89
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
90
+ return `${prefix}_${crypto.randomUUID()}`;
91
+ }
92
+
93
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
94
+ }
95
+
96
+ function normalizeToolName(value) {
97
+ return normalizeText(value) || "tool";
98
+ }
99
+
100
+ function normalizeConversationStatus(value) {
101
+ const status = normalizeText(value).toLowerCase();
102
+ return status || "unknown";
103
+ }
104
+
105
+ function formatConversationStartedAt(value) {
106
+ const source = normalizeText(value);
107
+ if (!source) {
108
+ return "unknown";
109
+ }
110
+
111
+ const date = new Date(source);
112
+ if (Number.isNaN(date.getTime())) {
113
+ return "unknown";
114
+ }
115
+
116
+ return date.toLocaleString();
117
+ }
118
+
119
+ function parseToolResultPayload(value) {
120
+ if (!value) {
121
+ return {};
122
+ }
123
+
124
+ try {
125
+ const parsed = typeof value === "string" ? JSON.parse(value) : value;
126
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
127
+ return {};
128
+ }
129
+
130
+ return parsed;
131
+ } catch {
132
+ return {};
133
+ }
134
+ }
135
+
136
+ function buildHistory(messages) {
137
+ const normalizedHistory = (Array.isArray(messages) ? messages : [])
138
+ .filter((message) => {
139
+ if (!message || typeof message !== "object") {
140
+ return false;
141
+ }
142
+ if (message.kind !== "chat") {
143
+ return false;
144
+ }
145
+ if (message.role !== "user" && message.role !== "assistant") {
146
+ return false;
147
+ }
148
+ if (normalizeText(message.status).toLowerCase() !== "done") {
149
+ return false;
150
+ }
151
+ return Boolean(normalizeText(message.text));
152
+ })
153
+ .map((message) => ({
154
+ role: message.role,
155
+ content: String(message.text || "").slice(0, MAX_INPUT_CHARS)
156
+ }));
157
+
158
+ return normalizedHistory.slice(-MAX_HISTORY_MESSAGES);
159
+ }
160
+
161
+ function mapTranscriptEntriesToAssistantState(entries) {
162
+ const sourceEntries = Array.isArray(entries) ? entries : [];
163
+ const messages = [];
164
+ const toolEventsById = new Map();
165
+
166
+ function ensureToolEvent(toolCallId, toolName) {
167
+ const key = normalizeText(toolCallId) || buildId("tool_call");
168
+ if (toolEventsById.has(key)) {
169
+ return toolEventsById.get(key);
170
+ }
171
+
172
+ const next = {
173
+ id: key,
174
+ name: normalizeToolName(toolName),
175
+ arguments: "",
176
+ status: "pending",
177
+ result: null,
178
+ error: null
179
+ };
180
+ toolEventsById.set(key, next);
181
+ return next;
182
+ }
183
+
184
+ for (const entry of sourceEntries) {
185
+ const role = normalizeText(entry?.role).toLowerCase();
186
+ const kind = normalizeText(entry?.kind).toLowerCase();
187
+ const metadata = normalizeObject(entry?.metadata);
188
+ const messageId = Number(entry?.id) > 0 ? `transcript_${entry.id}` : buildId("transcript");
189
+
190
+ if (kind === "chat" && (role === "user" || role === "assistant")) {
191
+ messages.push({
192
+ id: messageId,
193
+ role,
194
+ kind: "chat",
195
+ text: entry?.contentText == null ? "" : String(entry.contentText),
196
+ status: "done"
197
+ });
198
+ continue;
199
+ }
200
+
201
+ if (kind === "tool_call") {
202
+ const toolCallId = normalizeText(metadata.toolCallId) || `tool_call_${messageId}`;
203
+ const toolName = normalizeToolName(metadata.tool);
204
+ const toolEvent = ensureToolEvent(toolCallId, toolName);
205
+ toolEvent.arguments = String(entry?.contentText || "");
206
+ toolEvent.status = "pending";
207
+ continue;
208
+ }
209
+
210
+ if (kind === "tool_result") {
211
+ const parsedResult = parseToolResultPayload(entry?.contentText);
212
+ const toolCallId = normalizeText(metadata.toolCallId || parsedResult.toolCallId) || `tool_result_${messageId}`;
213
+ const toolName = normalizeToolName(metadata.tool || parsedResult.tool);
214
+ const toolEvent = ensureToolEvent(toolCallId, toolName);
215
+ const failed = parsedResult.ok === false || metadata.ok === false;
216
+ toolEvent.status = failed ? "failed" : "done";
217
+ toolEvent.result = failed ? null : parsedResult.result;
218
+ toolEvent.error = failed ? parsedResult.error || metadata.error || null : null;
219
+ }
220
+ }
221
+
222
+ return {
223
+ messages,
224
+ pendingToolEvents: [...toolEventsById.values()]
225
+ };
226
+ }
227
+
228
+ function resolveWorkspaceScope(bootstrapData = {}, workspaceSlug = "") {
229
+ const activeWorkspace = bootstrapData?.workspace && typeof bootstrapData.workspace === "object"
230
+ ? bootstrapData.workspace
231
+ : bootstrapData?.activeWorkspace && typeof bootstrapData.activeWorkspace === "object"
232
+ ? bootstrapData.activeWorkspace
233
+ : null;
234
+
235
+ return {
236
+ workspaceSlug: normalizeText(workspaceSlug),
237
+ workspaceId: toPositiveInteger(activeWorkspace?.id, 0)
238
+ };
239
+ }
240
+
241
+ function resolveRuntimePolicy() {
242
+ const appConfig = getClientAppConfig();
243
+ const assistantConfig = normalizeObject(appConfig?.assistant);
244
+
245
+ return Object.freeze({
246
+ timeoutMs: toPositiveInteger(assistantConfig.timeoutMs, DEFAULT_STREAM_TIMEOUT_MS),
247
+ historyPageSize: toPositiveInteger(assistantConfig.historyPageSize, DEFAULT_HISTORY_PAGE_SIZE),
248
+ restoreMessagesPageSize: toPositiveInteger(assistantConfig.restoreMessagesPageSize, DEFAULT_MESSAGES_PAGE_SIZE),
249
+ historyStaleTimeMs: toNonNegativeInteger(assistantConfig.historyStaleTimeMs, DEFAULT_HISTORY_STALE_TIME_MS)
250
+ });
251
+ }
252
+
253
+ function createRuntimeApi({ overrideApi = null, resolveSurfaceId = null } = {}) {
254
+ if (overrideApi && typeof overrideApi.streamChat === "function") {
255
+ return overrideApi;
256
+ }
257
+
258
+ return createAssistantWorkspaceApi({
259
+ request: assistantHttpClient.request,
260
+ requestStream: assistantHttpClient.requestStream,
261
+ resolveSurfaceId
262
+ });
263
+ }
264
+
265
+ function useAssistantWorkspaceRuntime({ api = null } = {}) {
266
+ const runtimePolicy = resolveRuntimePolicy();
267
+ const queryClient = useQueryClient();
268
+ const errorRuntime = useShellWebErrorRuntime();
269
+ const { workspaceSlugFromRoute, currentSurfaceId, placementContext } = useWorkspaceRouteContext();
270
+ const runtimeApi = createRuntimeApi({
271
+ overrideApi: api,
272
+ resolveSurfaceId: () => normalizeText(currentSurfaceId.value).toLowerCase()
273
+ });
274
+
275
+ const messages = ref([]);
276
+ const input = ref("");
277
+ const isStreaming = ref(false);
278
+ const isRestoringConversation = ref(false);
279
+ const error = ref("");
280
+ const pendingToolEvents = ref([]);
281
+ const conversationId = ref(null);
282
+ const abortController = ref(null);
283
+
284
+ const placementSnapshot = computed(() => normalizeObject(placementContext.value));
285
+ const workspaceScope = computed(() => resolveWorkspaceScope(placementSnapshot.value, workspaceSlugFromRoute.value));
286
+ const hasWorkspaceScope = computed(() => Boolean(workspaceScope.value.workspaceSlug));
287
+ const activeConversationId = computed(() => normalizeText(conversationId.value));
288
+ const isAdminSurface = computed(() => currentSurfaceId.value === "admin");
289
+ const canSend = computed(() => !isStreaming.value && !isRestoringConversation.value && Boolean(normalizeText(input.value)));
290
+ const canStartNewConversation = computed(() => !isStreaming.value);
291
+
292
+ function setRuntimeError(message, dedupeKey = "") {
293
+ const normalizedMessage = normalizeText(message);
294
+ error.value = normalizedMessage;
295
+ if (!normalizedMessage) {
296
+ return;
297
+ }
298
+
299
+ errorRuntime.report({
300
+ source: "assistant.workspace-runtime",
301
+ message: normalizedMessage,
302
+ severity: "error",
303
+ channel: "banner",
304
+ dedupeKey: dedupeKey || `assistant.workspace-runtime:error:${normalizedMessage}`,
305
+ dedupeWindowMs: 3000
306
+ });
307
+ }
308
+
309
+ const conversationHistoryCollection = usePagedCollection({
310
+ queryKey: computed(() =>
311
+ assistantConversationsListQueryKey(workspaceScope.value, {
312
+ limit: runtimePolicy.historyPageSize
313
+ })
314
+ ),
315
+ queryFn: ({ pageParam = null }) =>
316
+ runtimeApi.listConversations(workspaceScope.value.workspaceSlug, {
317
+ cursor: pageParam,
318
+ limit: runtimePolicy.historyPageSize
319
+ }),
320
+ initialPageParam: null,
321
+ dedupeBy(entry) {
322
+ const conversationNumericId = toPositiveInteger(entry?.id, 0);
323
+ return conversationNumericId > 0 ? String(conversationNumericId) : normalizeText(entry?.id);
324
+ },
325
+ enabled: computed(() => hasWorkspaceScope.value),
326
+ queryOptions: {
327
+ staleTime: runtimePolicy.historyStaleTimeMs,
328
+ refetchOnMount: false,
329
+ refetchOnWindowFocus: false
330
+ },
331
+ fallbackLoadError: "Unable to load conversation history."
332
+ });
333
+
334
+ const conversationHistory = conversationHistoryCollection.items;
335
+ const conversationHistoryLoading = computed(
336
+ () => Boolean(conversationHistoryCollection.isLoading.value && !conversationHistoryCollection.isLoadingMore.value)
337
+ );
338
+ const conversationHistoryLoadingMore = conversationHistoryCollection.isLoadingMore;
339
+ const conversationHistoryHasMore = conversationHistoryCollection.hasMore;
340
+ const conversationHistoryError = conversationHistoryCollection.loadError;
341
+
342
+ watch(conversationId, (nextConversationId, previousConversationId) => {
343
+ const workspaceSlug = workspaceScope.value.workspaceSlug;
344
+ if (!workspaceSlug) {
345
+ return;
346
+ }
347
+
348
+ const nextConversationNumericId = toPositiveInteger(nextConversationId, 0);
349
+ if (nextConversationNumericId > 0) {
350
+ writeStoredActiveConversationId(workspaceSlug, nextConversationNumericId);
351
+ return;
352
+ }
353
+
354
+ const previousConversationNumericId = toPositiveInteger(previousConversationId, 0);
355
+ if (previousConversationNumericId > 0) {
356
+ writeStoredActiveConversationId(workspaceSlug, 0);
357
+ }
358
+ });
359
+
360
+ watch(
361
+ [
362
+ hasWorkspaceScope,
363
+ conversationHistoryLoading,
364
+ workspaceScope,
365
+ conversationId,
366
+ conversationHistory,
367
+ isRestoringConversation
368
+ ],
369
+ async ([
370
+ nextHasWorkspaceScope,
371
+ nextConversationHistoryLoading,
372
+ nextWorkspaceScope,
373
+ nextConversationId,
374
+ nextConversationHistory,
375
+ nextIsRestoringConversation
376
+ ]) => {
377
+ if (!nextHasWorkspaceScope || nextConversationHistoryLoading || nextIsRestoringConversation) {
378
+ return;
379
+ }
380
+
381
+ const activeConversationId = toPositiveInteger(nextConversationId, 0);
382
+ if (activeConversationId > 0) {
383
+ return;
384
+ }
385
+
386
+ const sourceEntries = Array.isArray(nextConversationHistory) ? nextConversationHistory : [];
387
+ if (sourceEntries.length < 1) {
388
+ return;
389
+ }
390
+
391
+ const storedConversationId = readStoredActiveConversationId(nextWorkspaceScope?.workspaceSlug);
392
+ if (!storedConversationId) {
393
+ return;
394
+ }
395
+
396
+ const hasStoredConversation = sourceEntries.some(
397
+ (entry) => toPositiveInteger(entry?.id, 0) === storedConversationId
398
+ );
399
+ if (!hasStoredConversation) {
400
+ writeStoredActiveConversationId(nextWorkspaceScope?.workspaceSlug, 0);
401
+ return;
402
+ }
403
+
404
+ await selectConversationById(storedConversationId);
405
+ },
406
+ {
407
+ immediate: true
408
+ }
409
+ );
410
+
411
+ function appendMessage(payload) {
412
+ messages.value = [...messages.value, payload];
413
+ }
414
+
415
+ function updateMessage(messageId, updater) {
416
+ messages.value = messages.value.map((message) => {
417
+ if (message.id !== messageId) {
418
+ return message;
419
+ }
420
+
421
+ const patch = typeof updater === "function" ? updater(message) : updater;
422
+ return {
423
+ ...message,
424
+ ...(patch && typeof patch === "object" ? patch : {})
425
+ };
426
+ });
427
+ }
428
+
429
+ function findMessage(messageId) {
430
+ return messages.value.find((entry) => entry.id === messageId) || null;
431
+ }
432
+
433
+ async function invalidateConversationScope() {
434
+ if (!hasWorkspaceScope.value) {
435
+ return;
436
+ }
437
+
438
+ await queryClient.invalidateQueries({
439
+ queryKey: assistantWorkspaceScopeQueryKey(workspaceScope.value)
440
+ });
441
+ }
442
+
443
+ async function refreshConversationHistory() {
444
+ if (!hasWorkspaceScope.value) {
445
+ return;
446
+ }
447
+
448
+ await conversationHistoryCollection.reload();
449
+ }
450
+
451
+ async function loadMoreConversationHistory() {
452
+ await conversationHistoryCollection.loadMore();
453
+ }
454
+
455
+ async function selectConversationById(nextConversationId) {
456
+ const normalizedConversationId = normalizeText(nextConversationId);
457
+ if (!normalizedConversationId || isStreaming.value || isRestoringConversation.value || !hasWorkspaceScope.value) {
458
+ return;
459
+ }
460
+
461
+ const parsedConversationId = toPositiveInteger(normalizedConversationId, 0);
462
+ if (!parsedConversationId) {
463
+ return;
464
+ }
465
+
466
+ const previousConversationId = conversationId.value;
467
+ conversationId.value = String(parsedConversationId);
468
+ isRestoringConversation.value = true;
469
+ setRuntimeError("");
470
+
471
+ try {
472
+ const response = await queryClient.fetchQuery({
473
+ queryKey: assistantConversationMessagesQueryKey(workspaceScope.value, parsedConversationId, {
474
+ page: RESTORE_MESSAGES_PAGE,
475
+ pageSize: runtimePolicy.restoreMessagesPageSize
476
+ }),
477
+ queryFn: () =>
478
+ runtimeApi.getConversationMessages(workspaceScope.value.workspaceSlug, parsedConversationId, {
479
+ page: RESTORE_MESSAGES_PAGE,
480
+ pageSize: runtimePolicy.restoreMessagesPageSize
481
+ })
482
+ });
483
+
484
+ const restored = mapTranscriptEntriesToAssistantState(response?.entries);
485
+ messages.value = restored.messages;
486
+ pendingToolEvents.value = restored.pendingToolEvents;
487
+ input.value = "";
488
+ } catch (loadError) {
489
+ conversationId.value = previousConversationId;
490
+ setRuntimeError(normalizeText(loadError?.message) || "Unable to load conversation.");
491
+ } finally {
492
+ isRestoringConversation.value = false;
493
+ }
494
+ }
495
+
496
+ async function selectConversation(conversation) {
497
+ await selectConversationById(conversation?.id);
498
+ }
499
+
500
+ function startNewConversation() {
501
+ if (abortController.value) {
502
+ abortController.value.abort();
503
+ }
504
+
505
+ messages.value = [];
506
+ pendingToolEvents.value = [];
507
+ input.value = "";
508
+ setRuntimeError("");
509
+ conversationId.value = null;
510
+ writeStoredActiveConversationId(workspaceScope.value.workspaceSlug, 0);
511
+ isStreaming.value = false;
512
+ isRestoringConversation.value = false;
513
+ abortController.value = null;
514
+ }
515
+
516
+ function handleInputKeydown(event) {
517
+ if (event?.key === "Enter" && isStreaming.value) {
518
+ event.preventDefault();
519
+ return;
520
+ }
521
+
522
+ if (
523
+ event?.key === "Enter" &&
524
+ event?.shiftKey !== true &&
525
+ event?.ctrlKey !== true &&
526
+ event?.metaKey !== true &&
527
+ event?.altKey !== true
528
+ ) {
529
+ event.preventDefault();
530
+ void sendMessage();
531
+ }
532
+ }
533
+
534
+ function cancelStream() {
535
+ if (abortController.value) {
536
+ abortController.value.abort();
537
+ }
538
+ }
539
+
540
+ async function sendMessage() {
541
+ const normalizedInput = normalizeText(input.value).slice(0, MAX_INPUT_CHARS);
542
+ if (!normalizedInput || isStreaming.value || isRestoringConversation.value || !hasWorkspaceScope.value) {
543
+ return;
544
+ }
545
+
546
+ const messageId = buildId("message");
547
+ const assistantMessageId = buildId("assistant");
548
+ const history = buildHistory(messages.value);
549
+ const parsedConversationId = toPositiveInteger(conversationId.value, 0);
550
+
551
+ appendMessage({
552
+ id: buildId("user"),
553
+ role: "user",
554
+ kind: "chat",
555
+ text: normalizedInput,
556
+ status: "done"
557
+ });
558
+
559
+ appendMessage({
560
+ id: assistantMessageId,
561
+ role: "assistant",
562
+ kind: "chat",
563
+ text: "",
564
+ status: "streaming"
565
+ });
566
+
567
+ input.value = "";
568
+ setRuntimeError("");
569
+ isStreaming.value = true;
570
+
571
+ const streamAbortController = new AbortController();
572
+ abortController.value = streamAbortController;
573
+
574
+ const streamTimeout = setTimeout(() => {
575
+ streamAbortController.abort();
576
+ }, runtimePolicy.timeoutMs);
577
+
578
+ let streamDoneStatus = "";
579
+
580
+ try {
581
+ await runtimeApi.streamChat(
582
+ workspaceScope.value.workspaceSlug,
583
+ {
584
+ messageId,
585
+ ...(parsedConversationId > 0 ? { conversationId: parsedConversationId } : {}),
586
+ input: normalizedInput,
587
+ history
588
+ },
589
+ {
590
+ signal: streamAbortController.signal,
591
+ onEvent(event) {
592
+ const eventType = normalizeAssistantStreamEventType(event?.type, "");
593
+
594
+ if (eventType === ASSISTANT_STREAM_EVENT_TYPES.META && Object.hasOwn(event || {}, "conversationId")) {
595
+ conversationId.value = event?.conversationId ? String(event.conversationId) : null;
596
+ return;
597
+ }
598
+
599
+ if (eventType === ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA) {
600
+ const delta = String(event?.delta || "");
601
+ if (!delta) {
602
+ return;
603
+ }
604
+
605
+ updateMessage(assistantMessageId, (message) => ({
606
+ text: `${String(message?.text || "")}${delta}`,
607
+ status: "streaming"
608
+ }));
609
+ return;
610
+ }
611
+
612
+ if (eventType === ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE) {
613
+ const text = String(event?.text || "");
614
+ updateMessage(assistantMessageId, {
615
+ text,
616
+ status: "done"
617
+ });
618
+ return;
619
+ }
620
+
621
+ if (eventType === ASSISTANT_STREAM_EVENT_TYPES.TOOL_CALL) {
622
+ const toolCallId = normalizeText(event?.toolCallId) || buildId("tool_call");
623
+ pendingToolEvents.value = [
624
+ ...pendingToolEvents.value,
625
+ {
626
+ id: toolCallId,
627
+ name: normalizeToolName(event?.name),
628
+ arguments: String(event?.arguments || ""),
629
+ status: "pending",
630
+ result: null,
631
+ error: null
632
+ }
633
+ ];
634
+ return;
635
+ }
636
+
637
+ if (eventType === ASSISTANT_STREAM_EVENT_TYPES.TOOL_RESULT) {
638
+ const toolCallId = normalizeText(event?.toolCallId);
639
+ if (toolCallId) {
640
+ pendingToolEvents.value = pendingToolEvents.value.map((toolEvent) => {
641
+ if (toolEvent.id !== toolCallId) {
642
+ return toolEvent;
643
+ }
644
+
645
+ const failed = event?.ok === false;
646
+ return {
647
+ ...toolEvent,
648
+ status: failed ? "failed" : "done",
649
+ result: failed ? null : event?.result,
650
+ error: failed ? event?.error || null : null
651
+ };
652
+ });
653
+ }
654
+ return;
655
+ }
656
+
657
+ if (eventType === ASSISTANT_STREAM_EVENT_TYPES.ERROR) {
658
+ setRuntimeError(
659
+ normalizeText(event?.message) || "Assistant request failed.",
660
+ "assistant.workspace-runtime:stream-event-error"
661
+ );
662
+ updateMessage(assistantMessageId, {
663
+ status: "error"
664
+ });
665
+ return;
666
+ }
667
+
668
+ if (eventType === ASSISTANT_STREAM_EVENT_TYPES.DONE) {
669
+ streamDoneStatus = normalizeText(event?.status).toLowerCase();
670
+ }
671
+ }
672
+ }
673
+ );
674
+
675
+ const assistantMessage = findMessage(assistantMessageId);
676
+ const assistantMessageText = normalizeText(assistantMessage?.text);
677
+ if (!assistantMessageText && streamDoneStatus !== "aborted") {
678
+ if (!error.value) {
679
+ setRuntimeError("Assistant returned no output.", "assistant.workspace-runtime:empty-output");
680
+ }
681
+ updateMessage(assistantMessageId, {
682
+ status: "error"
683
+ });
684
+ } else if (streamDoneStatus === "aborted") {
685
+ updateMessage(assistantMessageId, {
686
+ status: "canceled"
687
+ });
688
+ } else {
689
+ updateMessage(assistantMessageId, (message) => ({
690
+ status: message.status === "streaming" ? "done" : message.status
691
+ }));
692
+ }
693
+ } catch (streamError) {
694
+ if (String(streamError?.name || "") === "AbortError") {
695
+ updateMessage(assistantMessageId, {
696
+ status: "canceled"
697
+ });
698
+ } else {
699
+ setRuntimeError(normalizeText(streamError?.message) || "Assistant request failed.");
700
+ updateMessage(assistantMessageId, {
701
+ status: "error"
702
+ });
703
+ }
704
+ } finally {
705
+ clearTimeout(streamTimeout);
706
+ abortController.value = null;
707
+ isStreaming.value = false;
708
+ await invalidateConversationScope();
709
+ await refreshConversationHistory();
710
+ }
711
+ }
712
+
713
+ useRealtimeEvent({
714
+ event: ASSISTANT_TRANSCRIPT_CHANGED_EVENT,
715
+ enabled: computed(() => hasWorkspaceScope.value),
716
+ matches({ payload }) {
717
+ if (!payload || typeof payload !== "object") {
718
+ return true;
719
+ }
720
+
721
+ const scope = payload.scope && typeof payload.scope === "object" ? payload.scope : {};
722
+ const workspaceIdFromEvent = toPositiveInteger(scope.id || scope.workspaceId, 0);
723
+ const workspaceId = toPositiveInteger(workspaceScope.value.workspaceId, 0);
724
+ if (workspaceIdFromEvent > 0 && workspaceId > 0) {
725
+ return workspaceIdFromEvent === workspaceId;
726
+ }
727
+
728
+ return true;
729
+ },
730
+ async onEvent({ payload }) {
731
+ await invalidateConversationScope();
732
+ const incomingConversationId = toPositiveInteger(payload?.conversationId, 0);
733
+ const activeId = toPositiveInteger(conversationId.value, 0);
734
+ if (!incomingConversationId || incomingConversationId !== activeId || !activeId) {
735
+ return;
736
+ }
737
+
738
+ await selectConversationById(activeId);
739
+ }
740
+ });
741
+
742
+ const viewer = computed(() => {
743
+ const user = normalizeObject(placementSnapshot.value.user);
744
+
745
+ return {
746
+ displayName: normalizeText(user.displayName || user.name) || "You",
747
+ avatarUrl: normalizeText(user.avatarUrl)
748
+ };
749
+ });
750
+
751
+ return Object.freeze({
752
+ meta: {
753
+ normalizeConversationStatus,
754
+ formatConversationStartedAt
755
+ },
756
+ state: {
757
+ messages,
758
+ input,
759
+ isStreaming,
760
+ isRestoringConversation,
761
+ error,
762
+ pendingToolEvents,
763
+ conversationId,
764
+ activeConversationId,
765
+ conversationHistory,
766
+ conversationHistoryLoading,
767
+ conversationHistoryLoadingMore,
768
+ conversationHistoryHasMore,
769
+ conversationHistoryError,
770
+ isAdminSurface,
771
+ canSend,
772
+ canStartNewConversation
773
+ },
774
+ actions: {
775
+ sendMessage,
776
+ handleInputKeydown,
777
+ cancelStream,
778
+ startNewConversation,
779
+ clearConversation: startNewConversation,
780
+ selectConversation,
781
+ selectConversationById,
782
+ refreshConversationHistory,
783
+ loadMoreConversationHistory
784
+ },
785
+ viewer
786
+ });
787
+ }
788
+
789
+ export { useAssistantWorkspaceRuntime };