@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,759 @@
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 { buildAssistantApiPath } from "@jskit-ai/assistant-core/shared";
6
+ import {
7
+ ASSISTANT_STREAM_EVENT_TYPES,
8
+ MAX_HISTORY_MESSAGES,
9
+ MAX_INPUT_CHARS,
10
+ assistantConversationMessagesQueryKey,
11
+ assistantConversationsListQueryKey,
12
+ assistantScopeQueryKey,
13
+ normalizeAssistantStreamEventType,
14
+ normalizeConversationStatus as normalizeAssistantConversationStatus,
15
+ parseJsonObject,
16
+ toPositiveInteger
17
+ } from "@jskit-ai/assistant-core/shared";
18
+ import {
19
+ assistantHttpClient,
20
+ createAssistantApi
21
+ } from "@jskit-ai/assistant-core/client";
22
+ import { useShellWebErrorRuntime } from "@jskit-ai/shell-web/client/error";
23
+ import { usePagedCollection } from "@jskit-ai/users-web/client/composables/usePagedCollection";
24
+ import { useWorkspaceRouteContext } from "@jskit-ai/users-web/client/composables/useWorkspaceRouteContext";
25
+ import { resolveAssistantSurfaceConfig } from "../../shared/assistantSurfaces.js";
26
+
27
+ const DEFAULT_STREAM_TIMEOUT_MS = 120_000;
28
+ const DEFAULT_HISTORY_PAGE_SIZE = 20;
29
+ const DEFAULT_MESSAGES_PAGE_SIZE = 200;
30
+ const DEFAULT_HISTORY_STALE_TIME_MS = 60_000;
31
+ const RESTORE_MESSAGES_PAGE = 1;
32
+
33
+ function toNonNegativeInteger(value, fallback = 0) {
34
+ const parsed = Number(value);
35
+ if (!Number.isInteger(parsed) || parsed < 0) {
36
+ return fallback;
37
+ }
38
+
39
+ return parsed;
40
+ }
41
+
42
+ function buildScopeStorageKey(scope = {}) {
43
+ const runtimeSurfaceId = normalizeText(scope?.targetSurfaceId).toLowerCase() || "assistant";
44
+ const workspaceSlug = normalizeText(scope?.workspaceSlug).toLowerCase() || "global";
45
+ return `assistant.activeConversationId:${runtimeSurfaceId}:${workspaceSlug}`;
46
+ }
47
+
48
+ function readStoredActiveConversationId(scope = {}) {
49
+ if (typeof window === "undefined" || !window.sessionStorage) {
50
+ return 0;
51
+ }
52
+
53
+ try {
54
+ return toPositiveInteger(window.sessionStorage.getItem(buildScopeStorageKey(scope)), 0);
55
+ } catch {
56
+ return 0;
57
+ }
58
+ }
59
+
60
+ function writeStoredActiveConversationId(scope = {}, conversationId) {
61
+ if (typeof window === "undefined" || !window.sessionStorage) {
62
+ return;
63
+ }
64
+
65
+ const normalizedConversationId = toPositiveInteger(conversationId, 0);
66
+ const storageKey = buildScopeStorageKey(scope);
67
+ try {
68
+ if (normalizedConversationId > 0) {
69
+ window.sessionStorage.setItem(storageKey, String(normalizedConversationId));
70
+ return;
71
+ }
72
+
73
+ window.sessionStorage.removeItem(storageKey);
74
+ } catch {
75
+ return;
76
+ }
77
+ }
78
+
79
+ function buildId(prefix = "id") {
80
+ if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
81
+ return `${prefix}_${crypto.randomUUID()}`;
82
+ }
83
+
84
+ return `${prefix}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
85
+ }
86
+
87
+ function normalizeToolName(value) {
88
+ return normalizeText(value) || "tool";
89
+ }
90
+
91
+ function normalizeConversationStatus(value) {
92
+ return normalizeAssistantConversationStatus(value, {
93
+ fallback: "unknown"
94
+ });
95
+ }
96
+
97
+ function formatConversationStartedAt(value) {
98
+ const source = normalizeText(value);
99
+ if (!source) {
100
+ return "unknown";
101
+ }
102
+
103
+ const date = new Date(source);
104
+ if (Number.isNaN(date.getTime())) {
105
+ return "unknown";
106
+ }
107
+
108
+ return date.toLocaleString();
109
+ }
110
+
111
+ function parseToolResultPayload(value) {
112
+ return parseJsonObject(value);
113
+ }
114
+
115
+ function buildHistory(messages) {
116
+ const normalizedHistory = (Array.isArray(messages) ? messages : [])
117
+ .filter((message) => {
118
+ if (!message || typeof message !== "object") {
119
+ return false;
120
+ }
121
+ if (message.kind !== "chat") {
122
+ return false;
123
+ }
124
+ if (message.role !== "user" && message.role !== "assistant") {
125
+ return false;
126
+ }
127
+ if (normalizeText(message.status).toLowerCase() !== "done") {
128
+ return false;
129
+ }
130
+ return Boolean(normalizeText(message.text));
131
+ })
132
+ .map((message) => ({
133
+ role: message.role,
134
+ content: String(message.text || "").slice(0, MAX_INPUT_CHARS)
135
+ }));
136
+
137
+ return normalizedHistory.slice(-MAX_HISTORY_MESSAGES);
138
+ }
139
+
140
+ function mapTranscriptEntriesToAssistantState(entries) {
141
+ const sourceEntries = Array.isArray(entries) ? entries : [];
142
+ const messages = [];
143
+ const toolEventsById = new Map();
144
+
145
+ function ensureToolEvent(toolCallId, toolName) {
146
+ const key = normalizeText(toolCallId) || buildId("tool_call");
147
+ if (toolEventsById.has(key)) {
148
+ return toolEventsById.get(key);
149
+ }
150
+
151
+ const next = {
152
+ id: key,
153
+ name: normalizeToolName(toolName),
154
+ arguments: "",
155
+ status: "pending",
156
+ result: null,
157
+ error: null
158
+ };
159
+ toolEventsById.set(key, next);
160
+ return next;
161
+ }
162
+
163
+ for (const entry of sourceEntries) {
164
+ const role = normalizeText(entry?.role).toLowerCase();
165
+ const kind = normalizeText(entry?.kind).toLowerCase();
166
+ const metadata = normalizeObject(entry?.metadata);
167
+ const messageId = Number(entry?.id) > 0 ? `transcript_${entry.id}` : buildId("transcript");
168
+
169
+ if (kind === "chat" && (role === "user" || role === "assistant")) {
170
+ messages.push({
171
+ id: messageId,
172
+ role,
173
+ kind: "chat",
174
+ text: entry?.contentText == null ? "" : String(entry.contentText),
175
+ status: "done"
176
+ });
177
+ continue;
178
+ }
179
+
180
+ if (kind === "tool_call") {
181
+ const toolCallId = normalizeText(metadata.toolCallId) || `tool_call_${messageId}`;
182
+ const toolName = normalizeToolName(metadata.tool);
183
+ const toolEvent = ensureToolEvent(toolCallId, toolName);
184
+ toolEvent.arguments = String(entry?.contentText || "");
185
+ toolEvent.status = "pending";
186
+ continue;
187
+ }
188
+
189
+ if (kind === "tool_result") {
190
+ const parsedResult = parseToolResultPayload(entry?.contentText);
191
+ const toolCallId = normalizeText(metadata.toolCallId || parsedResult.toolCallId) || `tool_result_${messageId}`;
192
+ const toolName = normalizeToolName(metadata.tool || parsedResult.tool);
193
+ const toolEvent = ensureToolEvent(toolCallId, toolName);
194
+ const failed = parsedResult.ok === false || metadata.ok === false;
195
+ toolEvent.status = failed ? "failed" : "done";
196
+ toolEvent.result = failed ? null : parsedResult.result;
197
+ toolEvent.error = failed ? parsedResult.error || metadata.error || null : null;
198
+ }
199
+ }
200
+
201
+ return {
202
+ messages,
203
+ pendingToolEvents: [...toolEventsById.values()]
204
+ };
205
+ }
206
+
207
+ function resolveRuntimePolicy() {
208
+ const appConfig = getClientAppConfig();
209
+ const assistantConfig = normalizeObject(appConfig?.assistant);
210
+
211
+ return Object.freeze({
212
+ timeoutMs: toPositiveInteger(assistantConfig.timeoutMs, DEFAULT_STREAM_TIMEOUT_MS),
213
+ historyPageSize: toPositiveInteger(assistantConfig.historyPageSize, DEFAULT_HISTORY_PAGE_SIZE),
214
+ restoreMessagesPageSize: toPositiveInteger(assistantConfig.restoreMessagesPageSize, DEFAULT_MESSAGES_PAGE_SIZE),
215
+ historyStaleTimeMs: toNonNegativeInteger(assistantConfig.historyStaleTimeMs, DEFAULT_HISTORY_STALE_TIME_MS)
216
+ });
217
+ }
218
+
219
+ function createRuntimeApi({ overrideApi = null, resolveBasePath, resolveSurfaceId = null } = {}) {
220
+ if (overrideApi && typeof overrideApi.streamChat === "function") {
221
+ return overrideApi;
222
+ }
223
+
224
+ return createAssistantApi({
225
+ request: assistantHttpClient.request,
226
+ requestStream: assistantHttpClient.requestStream,
227
+ resolveBasePath,
228
+ resolveSurfaceId
229
+ });
230
+ }
231
+
232
+ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
233
+ const runtimePolicy = resolveRuntimePolicy();
234
+ const queryClient = useQueryClient();
235
+ const errorRuntime = useShellWebErrorRuntime();
236
+ const { placementContext, currentSurfaceId, workspaceSlugFromRoute } = useWorkspaceRouteContext();
237
+ const appConfig = getClientAppConfig();
238
+
239
+ const messages = ref([]);
240
+ const input = ref("");
241
+ const isStreaming = ref(false);
242
+ const isRestoringConversation = ref(false);
243
+ const error = ref("");
244
+ const pendingToolEvents = ref([]);
245
+ const conversationId = ref(null);
246
+ const abortController = ref(null);
247
+
248
+ const placementSnapshot = computed(() => normalizeObject(placementContext.value));
249
+ const assistantSurface = computed(() =>
250
+ resolveAssistantSurfaceConfig(appConfig, surfaceId)
251
+ );
252
+ const runtimeScope = computed(() => {
253
+ const workspaceSlug = assistantSurface.value?.runtimeSurfaceRequiresWorkspace
254
+ ? normalizeText(workspaceSlugFromRoute.value).toLowerCase()
255
+ : "";
256
+
257
+ return {
258
+ targetSurfaceId: normalizeText(assistantSurface.value?.targetSurfaceId).toLowerCase(),
259
+ workspaceSlug,
260
+ workspaceId: assistantSurface.value?.runtimeSurfaceRequiresWorkspace
261
+ ? toPositiveInteger(placementSnapshot.value?.workspace?.id, 0)
262
+ : 0
263
+ };
264
+ });
265
+ const hasRuntimeScope = computed(() =>
266
+ Boolean(assistantSurface.value) &&
267
+ (assistantSurface.value?.runtimeSurfaceRequiresWorkspace ? Boolean(runtimeScope.value.workspaceSlug) : true)
268
+ );
269
+
270
+ const runtimeApi = createRuntimeApi({
271
+ overrideApi: api,
272
+ resolveBasePath: () =>
273
+ buildAssistantApiPath({
274
+ requiresWorkspace: assistantSurface.value?.runtimeSurfaceRequiresWorkspace === true,
275
+ workspaceSlug: runtimeScope.value.workspaceSlug,
276
+ suffix: `/${runtimeScope.value.targetSurfaceId}`
277
+ }),
278
+ resolveSurfaceId: () => normalizeText(currentSurfaceId.value).toLowerCase()
279
+ });
280
+
281
+ const activeConversationId = computed(() => normalizeText(conversationId.value));
282
+ const isAdminSurface = computed(() => normalizeText(currentSurfaceId.value).toLowerCase() === "admin");
283
+ const canSend = computed(() => {
284
+ return Boolean(
285
+ hasRuntimeScope.value &&
286
+ !isStreaming.value &&
287
+ !isRestoringConversation.value &&
288
+ normalizeText(input.value)
289
+ );
290
+ });
291
+ const canStartNewConversation = computed(() => Boolean(hasRuntimeScope.value && !isStreaming.value));
292
+
293
+ function setRuntimeError(message, dedupeKey = "") {
294
+ const normalizedMessage = normalizeText(message);
295
+ error.value = normalizedMessage;
296
+ if (!normalizedMessage) {
297
+ return;
298
+ }
299
+
300
+ errorRuntime.report({
301
+ source: "assistant.runtime",
302
+ message: normalizedMessage,
303
+ severity: "error",
304
+ channel: "banner",
305
+ dedupeKey: dedupeKey || `assistant.runtime:error:${normalizedMessage}`,
306
+ dedupeWindowMs: 3000
307
+ });
308
+ }
309
+
310
+ const conversationHistoryCollection = usePagedCollection({
311
+ queryKey: computed(() =>
312
+ assistantConversationsListQueryKey(runtimeScope.value, {
313
+ limit: runtimePolicy.historyPageSize
314
+ })
315
+ ),
316
+ queryFn: ({ pageParam = null }) =>
317
+ runtimeApi.listConversations({
318
+ cursor: pageParam,
319
+ limit: runtimePolicy.historyPageSize
320
+ }),
321
+ initialPageParam: null,
322
+ dedupeBy(entry) {
323
+ const conversationNumericId = toPositiveInteger(entry?.id, 0);
324
+ return conversationNumericId > 0 ? String(conversationNumericId) : normalizeText(entry?.id);
325
+ },
326
+ enabled: computed(() => hasRuntimeScope.value),
327
+ queryOptions: {
328
+ staleTime: runtimePolicy.historyStaleTimeMs,
329
+ refetchOnMount: false,
330
+ refetchOnWindowFocus: false
331
+ },
332
+ fallbackLoadError: "Unable to load conversation history."
333
+ });
334
+
335
+ const conversationHistory = conversationHistoryCollection.items;
336
+ const conversationHistoryLoading = computed(
337
+ () => Boolean(conversationHistoryCollection.isLoading.value && !conversationHistoryCollection.isLoadingMore.value)
338
+ );
339
+ const conversationHistoryLoadingMore = conversationHistoryCollection.isLoadingMore;
340
+ const conversationHistoryHasMore = conversationHistoryCollection.hasMore;
341
+ const conversationHistoryError = conversationHistoryCollection.loadError;
342
+
343
+ watch(conversationId, (nextConversationId, previousConversationId) => {
344
+ if (!hasRuntimeScope.value) {
345
+ return;
346
+ }
347
+
348
+ const nextConversationNumericId = toPositiveInteger(nextConversationId, 0);
349
+ if (nextConversationNumericId > 0) {
350
+ writeStoredActiveConversationId(runtimeScope.value, nextConversationNumericId);
351
+ return;
352
+ }
353
+
354
+ const previousConversationNumericId = toPositiveInteger(previousConversationId, 0);
355
+ if (previousConversationNumericId > 0) {
356
+ writeStoredActiveConversationId(runtimeScope.value, 0);
357
+ }
358
+ });
359
+
360
+ watch(
361
+ [
362
+ hasRuntimeScope,
363
+ conversationHistoryLoading,
364
+ runtimeScope,
365
+ conversationId,
366
+ conversationHistory,
367
+ isRestoringConversation
368
+ ],
369
+ async ([
370
+ nextHasRuntimeScope,
371
+ nextConversationHistoryLoading,
372
+ nextRuntimeScope,
373
+ nextConversationId,
374
+ nextConversationHistory,
375
+ nextIsRestoringConversation
376
+ ]) => {
377
+ if (!nextHasRuntimeScope || nextConversationHistoryLoading || nextIsRestoringConversation) {
378
+ return;
379
+ }
380
+
381
+ const activeConversationNumericId = toPositiveInteger(nextConversationId, 0);
382
+ if (activeConversationNumericId > 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(nextRuntimeScope);
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(nextRuntimeScope, 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 (!hasRuntimeScope.value) {
435
+ return;
436
+ }
437
+
438
+ await queryClient.invalidateQueries({
439
+ queryKey: assistantScopeQueryKey(runtimeScope.value)
440
+ });
441
+ }
442
+
443
+ async function refreshConversationHistory() {
444
+ if (!hasRuntimeScope.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 || !hasRuntimeScope.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(runtimeScope.value, parsedConversationId, {
474
+ page: RESTORE_MESSAGES_PAGE,
475
+ pageSize: runtimePolicy.restoreMessagesPageSize
476
+ }),
477
+ queryFn: () =>
478
+ runtimeApi.getConversationMessages(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(runtimeScope.value, 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 || !hasRuntimeScope.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
+ {
583
+ messageId,
584
+ ...(parsedConversationId > 0 ? { conversationId: parsedConversationId } : {}),
585
+ input: normalizedInput,
586
+ history
587
+ },
588
+ {
589
+ signal: streamAbortController.signal,
590
+ onEvent(event) {
591
+ const eventType = normalizeAssistantStreamEventType(event?.type, "");
592
+
593
+ if (eventType === ASSISTANT_STREAM_EVENT_TYPES.META && Object.hasOwn(event || {}, "conversationId")) {
594
+ conversationId.value = event?.conversationId ? String(event.conversationId) : null;
595
+ return;
596
+ }
597
+
598
+ if (eventType === ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_DELTA) {
599
+ const delta = String(event?.delta || "");
600
+ if (!delta) {
601
+ return;
602
+ }
603
+
604
+ updateMessage(assistantMessageId, (message) => ({
605
+ text: `${String(message?.text || "")}${delta}`,
606
+ status: "streaming"
607
+ }));
608
+ return;
609
+ }
610
+
611
+ if (eventType === ASSISTANT_STREAM_EVENT_TYPES.ASSISTANT_MESSAGE) {
612
+ const text = String(event?.text || "");
613
+ updateMessage(assistantMessageId, {
614
+ text,
615
+ status: "done"
616
+ });
617
+ return;
618
+ }
619
+
620
+ if (eventType === ASSISTANT_STREAM_EVENT_TYPES.TOOL_CALL) {
621
+ const toolCallId = normalizeText(event?.toolCallId) || buildId("tool_call");
622
+ pendingToolEvents.value = [
623
+ ...pendingToolEvents.value,
624
+ {
625
+ id: toolCallId,
626
+ name: normalizeToolName(event?.name),
627
+ arguments: String(event?.arguments || ""),
628
+ status: "pending",
629
+ result: null,
630
+ error: null
631
+ }
632
+ ];
633
+ return;
634
+ }
635
+
636
+ if (eventType === ASSISTANT_STREAM_EVENT_TYPES.TOOL_RESULT) {
637
+ const toolCallId = normalizeText(event?.toolCallId);
638
+ if (toolCallId) {
639
+ pendingToolEvents.value = pendingToolEvents.value.map((toolEvent) => {
640
+ if (toolEvent.id !== toolCallId) {
641
+ return toolEvent;
642
+ }
643
+
644
+ const failed = event?.ok === false;
645
+ return {
646
+ ...toolEvent,
647
+ status: failed ? "failed" : "done",
648
+ result: failed ? null : event?.result,
649
+ error: failed ? event?.error || null : null
650
+ };
651
+ });
652
+ }
653
+ return;
654
+ }
655
+
656
+ if (eventType === ASSISTANT_STREAM_EVENT_TYPES.ERROR) {
657
+ setRuntimeError(
658
+ normalizeText(event?.message) || "Assistant request failed.",
659
+ "assistant.runtime:stream-event-error"
660
+ );
661
+ updateMessage(assistantMessageId, {
662
+ status: "error"
663
+ });
664
+ return;
665
+ }
666
+
667
+ if (eventType === ASSISTANT_STREAM_EVENT_TYPES.DONE) {
668
+ streamDoneStatus = normalizeText(event?.status).toLowerCase();
669
+ }
670
+ }
671
+ }
672
+ );
673
+
674
+ const assistantMessage = findMessage(assistantMessageId);
675
+ const assistantMessageText = normalizeText(assistantMessage?.text);
676
+ if (!assistantMessageText && streamDoneStatus !== "aborted") {
677
+ if (!error.value) {
678
+ setRuntimeError("Assistant returned no output.", "assistant.runtime:empty-output");
679
+ }
680
+ updateMessage(assistantMessageId, {
681
+ status: "error"
682
+ });
683
+ } else if (streamDoneStatus === "aborted") {
684
+ updateMessage(assistantMessageId, {
685
+ status: "canceled"
686
+ });
687
+ } else {
688
+ updateMessage(assistantMessageId, (message) => ({
689
+ status: message.status === "streaming" ? "done" : message.status
690
+ }));
691
+ }
692
+ } catch (streamError) {
693
+ if (String(streamError?.name || "") === "AbortError") {
694
+ updateMessage(assistantMessageId, {
695
+ status: "canceled"
696
+ });
697
+ } else {
698
+ setRuntimeError(normalizeText(streamError?.message) || "Assistant request failed.");
699
+ updateMessage(assistantMessageId, {
700
+ status: "error"
701
+ });
702
+ }
703
+ } finally {
704
+ clearTimeout(streamTimeout);
705
+ abortController.value = null;
706
+ isStreaming.value = false;
707
+ await invalidateConversationScope();
708
+ await refreshConversationHistory();
709
+ }
710
+ }
711
+
712
+ const viewer = computed(() => {
713
+ const user = normalizeObject(placementSnapshot.value.user);
714
+
715
+ return {
716
+ displayName: normalizeText(user.displayName || user.name) || "You",
717
+ avatarUrl: normalizeText(user.avatarUrl)
718
+ };
719
+ });
720
+
721
+ return Object.freeze({
722
+ meta: {
723
+ normalizeConversationStatus,
724
+ formatConversationStartedAt
725
+ },
726
+ state: {
727
+ messages,
728
+ input,
729
+ isStreaming,
730
+ isRestoringConversation,
731
+ error,
732
+ pendingToolEvents,
733
+ conversationId,
734
+ activeConversationId,
735
+ conversationHistory,
736
+ conversationHistoryLoading,
737
+ conversationHistoryLoadingMore,
738
+ conversationHistoryHasMore,
739
+ conversationHistoryError,
740
+ isAdminSurface,
741
+ canSend,
742
+ canStartNewConversation
743
+ },
744
+ actions: {
745
+ sendMessage,
746
+ handleInputKeydown,
747
+ cancelStream,
748
+ startNewConversation,
749
+ clearConversation: startNewConversation,
750
+ selectConversation,
751
+ selectConversationById,
752
+ refreshConversationHistory,
753
+ loadMoreConversationHistory
754
+ },
755
+ viewer
756
+ });
757
+ }
758
+
759
+ export { useAssistantRuntime };