@alia.onl/sdk 21.4.0 → 21.5.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.
- package/CHANGELOG.md +30 -0
- package/README.md +4 -0
- package/package.json +3 -3
- package/src/components/AliaChatSheet.tsx +2 -0
- package/src/runtime/app-assertion.ts +1 -3
- package/src/runtime/error-utils.ts +40 -0
- package/src/runtime/use-conversation-stream.ts +58 -39
- package/src/runtime/use-conversation-thread.ts +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# @alia.onl/sdk
|
|
2
2
|
|
|
3
|
+
## 21.5.1
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- Embedded welcome and conversation sheets now request Bloom's bounded `sizing="fill"`
|
|
8
|
+
presentation. After the chat moved to Screen composition, a content-sized sheet could
|
|
9
|
+
mount at zero height even though the conversation and composer had loaded. Bloom owns
|
|
10
|
+
the available height, keyboard clearance and safe areas; hosts need no viewport workaround.
|
|
11
|
+
|
|
12
|
+
### Changed
|
|
13
|
+
|
|
14
|
+
- The Bloom peer is `>=15.10.0 <16.0.0`, the first release with the bounded sheet contract.
|
|
15
|
+
The independent thought sheet keeps its existing content sizing.
|
|
16
|
+
|
|
17
|
+
## 21.5.0
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **The SDK no longer ships every Oxy account screen to the app.** The host's app assertion
|
|
22
|
+
(`useAppAssertion`) was read from `@oxy.so/services/ui`, the only entry that exported it, and
|
|
23
|
+
that entry registers every account screen at import (with Bloom's phone flags and the rest), so
|
|
24
|
+
every app using the SDK carried them in its first-paint JS: +0.58 MiB in CrowdSource's reviewer.
|
|
25
|
+
It now comes from the lean `@oxy.so/services/ui/client`, like `useOxy` and `useAuth`.
|
|
26
|
+
`check:entries` refuses `@oxy.so/services/ui` from every entry, as it already refused the root.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- The `@oxy.so/services` peer is `>=12.1.0`, the first release whose `ui/client` exports
|
|
31
|
+
`useAppAssertion`.
|
|
32
|
+
|
|
3
33
|
## 21.4.0
|
|
4
34
|
|
|
5
35
|
### Added
|
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 request Bloom's `sizing="fill"` presentation: the
|
|
31
|
+
shared sheet owns the bounded height, safe areas and keyboard clearance that the
|
|
32
|
+
chat's Screen needs. 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.
|
|
3
|
+
"version": "21.5.1",
|
|
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,9 +54,9 @@
|
|
|
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.
|
|
57
|
+
"@oxy.so/bloom": ">=15.10.0 <16.0.0",
|
|
58
58
|
"@oxy.so/core": ">=3.0.0",
|
|
59
|
-
"@oxy.so/services": ">=
|
|
59
|
+
"@oxy.so/services": ">=12.1.0",
|
|
60
60
|
"@tanstack/react-query": "^5.101",
|
|
61
61
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
|
62
62
|
"expo-blur": "^57.0.0 || ^58.0.0",
|
|
@@ -301,6 +301,7 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
301
301
|
<AgentAvatarProvider runtimeUrl={characterRuntimeUrl}>
|
|
302
302
|
<BottomSheet
|
|
303
303
|
ref={mainRef}
|
|
304
|
+
sizing="fill"
|
|
304
305
|
detached={detached}
|
|
305
306
|
scrollable={false}
|
|
306
307
|
manualActivation
|
|
@@ -333,6 +334,7 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
333
334
|
<BottomSheet
|
|
334
335
|
key={conversation.key}
|
|
335
336
|
ref={conversationRef}
|
|
337
|
+
sizing="fill"
|
|
336
338
|
detached={detached}
|
|
337
339
|
scrollable={false}
|
|
338
340
|
manualActivation
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
// `ui/client` does not export `useAppAssertion`.
|
|
3
|
-
import { useAppAssertion } from '@oxy.so/services/ui';
|
|
1
|
+
import { useAppAssertion } from '@oxy.so/services/ui/client';
|
|
4
2
|
import { useMemo } from 'react';
|
|
5
3
|
|
|
6
4
|
/**
|
|
@@ -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 {
|
|
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)
|
|
318
|
-
|
|
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
|
-
//
|
|
607
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1025
|
-
|
|
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
|
-
|
|
1072
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`. */
|