@alia.onl/sdk 21.5.0 → 21.5.2

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 CHANGED
@@ -1,5 +1,34 @@
1
1
  # @alia.onl/sdk
2
2
 
3
+ ## 21.5.2
4
+
5
+ ### Fixed
6
+
7
+ - Welcome and conversation sheets grow and shrink with their content up to Bloom’s
8
+ available-height limit, instead of filling the entire frame. Bloom owns the Screen
9
+ sizing, scrolling, keyboard clearance and safe areas.
10
+ - The empty thread no longer adds composer clearance a second time. Growing and
11
+ shrinking a draft now changes the sheet height by the composer’s actual change.
12
+
13
+ ### Changed
14
+
15
+ - The Bloom peer is `>=15.10.1 <16.0.0`, which provides intrinsic Screen sizing
16
+ inside content-sized sheets and clips decorative chat backgrounds.
17
+
18
+ ## 21.5.1
19
+
20
+ ### Fixed
21
+
22
+ - Embedded welcome and conversation sheets now request Bloom's bounded `sizing="fill"`
23
+ presentation. After the chat moved to Screen composition, a content-sized sheet could
24
+ mount at zero height even though the conversation and composer had loaded. Bloom owns
25
+ the available height, keyboard clearance and safe areas; hosts need no viewport workaround.
26
+
27
+ ### Changed
28
+
29
+ - The Bloom peer is `>=15.10.0 <16.0.0`, the first release with the bounded sheet contract.
30
+ The independent thought sheet keeps its existing content sizing.
31
+
3
32
  ## 21.5.0
4
33
 
5
34
  ### Fixed
package/README.md CHANGED
@@ -27,6 +27,10 @@ the default in Expo SDK 53+), because every icon import is a subpath.
27
27
 
28
28
  The chat is built from Bloom (`@oxy.so/bloom`, a peer dependency): mount Bloom's
29
29
  `BloomProvider` above it, with the one `KeyboardProvider` your app already has.
30
+ The main and conversation sheets use Bloom's default content sizing: they grow and
31
+ shrink with the chat up to the shared viewport limit. Bloom coordinates the Screen
32
+ content, safe areas and keyboard clearance. Hosts should not add window-height calculations or a second
33
+ scroll container to make an embedded chat visible.
30
34
 
31
35
  Alia's avatar is Bloom's 3D character (`AgentAvatar`). It draws through a runtime of
32
36
  static files (about 214 MB), which the chat loads from Alia's own origin
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "21.5.0",
3
+ "version": "21.5.2",
4
4
  "description": "Alia AI chat SDK — the conversation runtime, chat UI, voice calls and dictation for Oxy apps",
5
5
  "main": "src/index.ts",
6
6
  "react-native": "src/index.ts",
@@ -54,7 +54,7 @@
54
54
  "react": "^19.2.3",
55
55
  "react-native": "^0.86.0 || ^0.88.0",
56
56
  "@expo/vector-icons": "^15.1.1",
57
- "@oxy.so/bloom": ">=10.21.0",
57
+ "@oxy.so/bloom": ">=15.10.1 <16.0.0",
58
58
  "@oxy.so/core": ">=3.0.0",
59
59
  "@oxy.so/services": ">=12.1.0",
60
60
  "@tanstack/react-query": "^5.101",
@@ -1,9 +1,5 @@
1
1
  import { AgentThinking } from '@oxy.so/bloom/agent-thinking';
2
- import {
3
- AiChatThread,
4
- type AiChatThreadHandle,
5
- useAiChatChromeInsets,
6
- } from '@oxy.so/bloom/ai-chat';
2
+ import { AiChatThread, type AiChatThreadHandle } from '@oxy.so/bloom/ai-chat';
7
3
  import { ChatDateHeader } from '@oxy.so/bloom/chat-screen';
8
4
  import { Divider } from '@oxy.so/bloom/divider';
9
5
  import { Loading } from '@oxy.so/bloom/loading';
@@ -263,16 +259,12 @@ export const AliaThread = memo(function AliaThread({
263
259
  onScroll,
264
260
  });
265
261
 
266
- const chromeInsets = useAiChatChromeInsets();
267
262
  const nothingToShow =
268
263
  rows.length === 0 && !conversationLoading && !isLoading && !loadingHistory && !voiceThinking;
269
264
  if (nothingToShow) {
270
265
  return (
271
- // Above the floating composer: the container measures it for us.
272
- <View
273
- className="flex-1 justify-center px-4"
274
- style={{ paddingBottom: chromeInsets?.bottom ?? 0 }}
275
- >
266
+ // Screen reserves the composer; the empty state only owns its alignment.
267
+ <View className="flex-1 justify-center px-4">
276
268
  <View className="w-full max-w-[768px] self-center">{empty}</View>
277
269
  </View>
278
270
  );
@@ -102,3 +102,43 @@ export function errorName(err: unknown): string | undefined {
102
102
  export function errorResponseData(err: unknown): ErrorResponseData | undefined {
103
103
  return asObject(err).response?.data;
104
104
  }
105
+
106
+ /** A refusal body is a small JSON envelope; more than this is not one. */
107
+ const MAX_ERROR_BODY_BYTES = 64 * 1024;
108
+
109
+ /**
110
+ * The whole body of a non-OK streaming response, parsed as JSON, or `null`.
111
+ *
112
+ * A streaming fetch is body-oriented (`.json()` is not reliable on it), so the
113
+ * body is read through its reader. ONE `read()` is not the body: a network that
114
+ * splits the envelope across chunks handed `JSON.parse` half of it, which threw,
115
+ * and the typed `code` and `reference` were lost for a generic "Server error".
116
+ * It reads to the end, bounded, and always releases the reader.
117
+ */
118
+ export async function readErrorResponseBody(response: {
119
+ body?: ReadableStream<Uint8Array> | null;
120
+ }): Promise<unknown> {
121
+ const reader = response.body?.getReader();
122
+ if (!reader) return null;
123
+ const decoder = new TextDecoder();
124
+ let text = '';
125
+ let bytes = 0;
126
+ try {
127
+ while (bytes < MAX_ERROR_BODY_BYTES) {
128
+ const { done, value } = await reader.read();
129
+ if (done) break;
130
+ bytes += value.byteLength;
131
+ text += decoder.decode(value, { stream: true });
132
+ }
133
+ text += decoder.decode();
134
+ return JSON.parse(text);
135
+ } catch {
136
+ return null;
137
+ } finally {
138
+ try {
139
+ await reader.cancel();
140
+ } catch {
141
+ // Already closed: nothing left to release.
142
+ }
143
+ }
144
+ }
@@ -5,7 +5,13 @@ import { useCallback, useEffect, useRef, useState } from 'react';
5
5
  import { useLabels } from '../lib/labels';
6
6
  import { useAppAssertionHeaders } from './app-assertion';
7
7
  import { type AliaArtifact, useAliaRuntime } from './config';
8
- import { errorCode, errorMessage as getErrorMessage, errorName, errorStatus } from './error-utils';
8
+ import {
9
+ errorCode,
10
+ errorMessage as getErrorMessage,
11
+ errorName,
12
+ errorStatus,
13
+ readErrorResponseBody,
14
+ } from './error-utils';
9
15
  import { agentMessageId, buildOutboundMessages, isAgentMessageOf } from './message-history';
10
16
  import { aliaQueryKeys } from './query-keys';
11
17
  import { createRandomUuid } from './random-uuid';
@@ -314,8 +320,16 @@ export function useConversationStream({
314
320
  // which is the same conversation; any other change is a different one.
315
321
  const was = conversationRef.current;
316
322
  conversationRef.current = conversationId;
317
- if (was !== undefined && was !== conversationId) hostOutcomesRef.current.clear();
318
- }, [conversationId]);
323
+ if (was !== undefined && was !== conversationId) {
324
+ hostOutcomesRef.current.clear();
325
+ // The reply still streaming belongs to the thread that was left: cut it, drop what it had
326
+ // batched (it would land on this thread's last message) and let the screen be idle.
327
+ abortControllerRef.current?.abort();
328
+ abortControllerRef.current = null;
329
+ dropPendingUpdates();
330
+ setIsLoading(false);
331
+ }
332
+ }, [conversationId, dropPendingUpdates]);
319
333
 
320
334
  /**
321
335
  * A different account is a different person: the thread on screen is not
@@ -601,15 +615,22 @@ export function useConversationStream({
601
615
  /** The request's own controller: `finally` asks it whether the person stopped the turn. */
602
616
  let controller: AbortController | null = null;
603
617
  let linked: ReturnType<typeof oxyServices.createLinkedClient> | null = null;
618
+ /** Cut THIS turn's stream. Never the ref's: by now it may hold a later turn's controller. */
619
+ const abortThisTurn = (): void => {
620
+ controller?.abort();
621
+ if (abortControllerRef.current === controller) abortControllerRef.current = null;
622
+ };
604
623
 
605
624
  try {
606
- // Collect device info (will be available to AI via tool if needed)
607
- const deviceInfo = host.deviceInfo ? await host.deviceInfo() : undefined;
608
-
609
- // Create abort controller for this request
625
+ // Create the abort controller before the first await, so `stop()` and an account or
626
+ // conversation change reach a turn that is still collecting device info.
610
627
  controller = new AbortController();
611
628
  abortControllerRef.current = controller;
612
629
 
630
+ // Collect device info (will be available to AI via tool if needed)
631
+ const deviceInfo = host.deviceInfo ? await host.deviceInfo() : undefined;
632
+ if (controller.signal.aborted) return 'aborted';
633
+
613
634
  /**
614
635
  * Read at send time rather than closed over: the capability switches
615
636
  * live in a menu that stays open across a send, and a value captured
@@ -660,21 +681,7 @@ export function useConversationStream({
660
681
  });
661
682
 
662
683
  if (!response.ok) {
663
- let errorData: StreamErrorResponse | null = null;
664
- try {
665
- // A streaming fetch is body-oriented; .json() may not work for error responses.
666
- // Read the body manually via the ReadableStream reader.
667
- if (response.body) {
668
- const errReader = response.body.getReader();
669
- const { value } = await errReader.read();
670
- if (value) {
671
- errorData = JSON.parse(new TextDecoder().decode(value));
672
- }
673
- }
674
- } catch {
675
- // Best-effort: if the error body isn't readable/JSON, fall through to
676
- // the generic status-based error message below.
677
- }
684
+ const errorData = (await readErrorResponseBody(response)) as StreamErrorResponse | null;
678
685
 
679
686
  // An agent's feature gate refused the turn: the host shows its own upsell, and the
680
687
  // person's words go back to the composer for after.
@@ -734,14 +741,18 @@ export function useConversationStream({
734
741
  // own line, with code and reference — never the server's English.
735
742
  const failed =
736
743
  errorData?.error && typeof errorData.error === 'object' ? errorData.error : null;
737
- if (failed && typeof failed.reference === 'string') {
744
+ if (
745
+ failed &&
746
+ (typeof failed.reference === 'string' ||
747
+ (response.status >= 500 && typeof failed.code === 'string'))
748
+ ) {
738
749
  setError(new Error(getErrorMessage(failed) || `Server error (${response.status})`));
739
750
  setIsLoading(false);
740
751
  return keepFailedTurn(
741
752
  failed.retryable !== false,
742
753
  failureDetail({
743
754
  code: typeof failed.code === 'string' ? failed.code : undefined,
744
- reference: failed.reference,
755
+ reference: typeof failed.reference === 'string' ? failed.reference : undefined,
745
756
  }),
746
757
  );
747
758
  }
@@ -790,6 +801,8 @@ export function useConversationStream({
790
801
  */
791
802
  const sse = createSseFrameReader();
792
803
  let lastOutputAt = 0;
804
+ /** The server closed the turn: `[DONE]`, or a chunk that carries a finish reason. */
805
+ let sawTerminal = false;
793
806
 
794
807
  while (true) {
795
808
  const { done, value } = await reader.read();
@@ -808,6 +821,13 @@ export function useConversationStream({
808
821
  setError(new Error('No response received from AI'));
809
822
  return keepFailedTurn(true);
810
823
  }
824
+ // The connection closed before the server said the turn was over:
825
+ // what is on screen is a cut-off answer, not a finished one. Kept
826
+ // as an interrupted tail with Retry, never settled as complete.
827
+ if (!sawTerminal) {
828
+ setError(new Error('The response ended before it was complete'));
829
+ return keepFailedTurn(true);
830
+ }
811
831
  break;
812
832
  }
813
833
 
@@ -821,6 +841,7 @@ export function useConversationStream({
821
841
 
822
842
  // Skip [DONE] marker
823
843
  if (data === '[DONE]') {
844
+ sawTerminal = true;
824
845
  continue;
825
846
  }
826
847
 
@@ -1021,9 +1042,8 @@ export function useConversationStream({
1021
1042
  const lockedFeature = lockedFeatureOf(err);
1022
1043
  if (lockedFeature !== null) {
1023
1044
  setIsLoading(false);
1024
- abortControllerRef.current?.abort();
1025
- abortControllerRef.current = null;
1026
- reader.cancel();
1045
+ abortThisTurn();
1046
+ reader.cancel().catch(() => {});
1027
1047
  host.onFeatureLocked?.(lockedFeature);
1028
1048
  return settleError();
1029
1049
  }
@@ -1068,11 +1088,8 @@ export function useConversationStream({
1068
1088
  const msg = getErrorMessage(err) || 'Something went wrong. Please try again.';
1069
1089
  setError(new Error(msg));
1070
1090
  setIsLoading(false);
1071
- if (abortControllerRef.current) {
1072
- abortControllerRef.current.abort();
1073
- abortControllerRef.current = null;
1074
- }
1075
- reader.cancel();
1091
+ abortThisTurn();
1092
+ reader.cancel().catch(() => {});
1076
1093
  // A turn that failed after it started carries a run reference:
1077
1094
  // its card says code and reference, never the server's English
1078
1095
  // prose. A refusal without one (a gate, a bad request) keeps its
@@ -1102,6 +1119,7 @@ export function useConversationStream({
1102
1119
  // Handle OpenAI-compatible format
1103
1120
  const choice = parsed.choices?.[0];
1104
1121
  if (!choice) continue;
1122
+ if (choice.finish_reason) sawTerminal = true;
1105
1123
 
1106
1124
  const delta = choice.delta;
1107
1125
  if (!delta) continue;
@@ -1239,13 +1257,10 @@ export function useConversationStream({
1239
1257
  setIsLoading(false);
1240
1258
 
1241
1259
  // Abort the stream
1242
- if (abortControllerRef.current) {
1243
- abortControllerRef.current.abort();
1244
- abortControllerRef.current = null;
1245
- }
1260
+ abortThisTurn();
1246
1261
 
1247
1262
  // Break out of the streaming loop
1248
- reader.cancel();
1263
+ reader.cancel().catch(() => {});
1249
1264
  return keepFailedTurn(true, errMsg);
1250
1265
  }
1251
1266
  } catch (frameError: unknown) {
@@ -1342,13 +1357,17 @@ export function useConversationStream({
1342
1357
  clearTimeout(flushTimerRef.current);
1343
1358
  flushTimerRef.current = null;
1344
1359
  }
1345
- abortControllerRef.current = null;
1360
+ // Only the turn that still owns the ref clears it and the loading flag: after Stop and a new
1361
+ // send, this `finally` belongs to the old turn and must not idle the new one.
1362
+ const ownsTurn =
1363
+ abortControllerRef.current === null || abortControllerRef.current === controller;
1364
+ if (abortControllerRef.current === controller) abortControllerRef.current = null;
1346
1365
  linked?.dispose();
1347
1366
  // A turn that is still streaming here ended without an error path
1348
1367
  // settling it: on its own, or because `stop()` aborted the controller.
1349
1368
  // (The error paths abort it too, but they have settled it first.)
1350
1369
  settleAssistant(controller?.signal.aborted === true ? 'cancelled' : 'completed');
1351
- setIsLoading(false);
1370
+ if (ownsTurn) setIsLoading(false);
1352
1371
  /*
1353
1372
  * The answer replaces your own line in the sidebar — once per turn rather
1354
1373
  * than once per token. Read from what this turn received, not from the
@@ -375,6 +375,9 @@ export function useConversationThread({
375
375
  const draft = useComposerDraftStore.getState().address(conversationId ?? null);
376
376
  const original = messages.find((msg) => msg.id === messageId);
377
377
  if (original === undefined) return false;
378
+ // Like a send: while a reply streams, truncating the thread under it would let the old
379
+ // stream keep writing into the new turn. The composer gets the words back.
380
+ if (isLoading) return false;
378
381
  // A string edit keeps the original turn's attachments; only a full parts
379
382
  // array replaces them. See `EditedContent`.
380
383
  let content = mergeEditedContent(original.content, newContent);
@@ -423,7 +426,7 @@ export function useConversationThread({
423
426
  releaseAttachments(attachments ?? []);
424
427
  return true;
425
428
  },
426
- [setMessages, append, messages, conversationId, host, labels, upload],
429
+ [setMessages, append, messages, conversationId, host, labels, upload, isLoading],
427
430
  );
428
431
 
429
432
  /** The user turn a reply answers: the nearest one before it, or `undefined`. */