@alia.onl/sdk 22.0.1 → 22.1.0
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 +10 -0
- package/package.json +1 -1
- package/src/components/AliaChatContent.tsx +8 -0
- package/src/components/AliaChatSheet.tsx +15 -15
- package/src/runtime/chat-turn-control.ts +74 -0
- package/src/runtime/index.ts +2 -0
- package/src/runtime/turn-preparation.ts +21 -0
- package/src/runtime/use-conversation-stream.ts +45 -7
- package/src/runtime/use-conversation-thread.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# @alia.onl/sdk
|
|
2
2
|
|
|
3
|
+
## 22.1.0
|
|
4
|
+
|
|
5
|
+
- Stop explicitly cancels the authenticated foreground turn across API replicas;
|
|
6
|
+
closing a sheet or losing its transport still allows background completion.
|
|
7
|
+
- Export `createChatTurnControl` / `AliaChatTurnControl` from `@alia.onl/sdk/runtime`
|
|
8
|
+
for custom Stop controls, with account-change fences and early-turn ids.
|
|
9
|
+
- Requires the explicit Stop API receiver to be deployed before adoption; see
|
|
10
|
+
`docs/explicit-chat-stop.md` in the repository.
|
|
11
|
+
|
|
12
|
+
|
|
3
13
|
## 22.0.1
|
|
4
14
|
|
|
5
15
|
Remove the Bloom peer upper bound to follow the first-party package policy.
|
package/package.json
CHANGED
|
@@ -265,6 +265,8 @@ export interface AliaChatContentProps {
|
|
|
265
265
|
presentedProgress?: SharedValue<number>;
|
|
266
266
|
/** Internal sheet lifecycle bridge: receives the live request canceller. */
|
|
267
267
|
onStopReady?: (stop: (() => void) | null) => void;
|
|
268
|
+
/** Detach a closed view while its durable answer continues. Never invokes explicit Stop. */
|
|
269
|
+
onDetachReady?: (detach: (() => void) | null) => void;
|
|
268
270
|
}
|
|
269
271
|
|
|
270
272
|
/** No live conversation while the thread looks at a moment of its past, as ONE array. */
|
|
@@ -319,6 +321,7 @@ function AliaConversationView({
|
|
|
319
321
|
initialTurn,
|
|
320
322
|
presentedProgress,
|
|
321
323
|
onStopReady,
|
|
324
|
+
onDetachReady,
|
|
322
325
|
}: ConversationViewProps) {
|
|
323
326
|
const ownFlights = useAvatarFlights();
|
|
324
327
|
const flights = sharedFlights ?? ownFlights;
|
|
@@ -359,6 +362,7 @@ function AliaConversationView({
|
|
|
359
362
|
error,
|
|
360
363
|
clearError,
|
|
361
364
|
stopGeneration: stop,
|
|
365
|
+
detachGeneration: detach,
|
|
362
366
|
startOver,
|
|
363
367
|
clearConversation,
|
|
364
368
|
suggestedNewConversation,
|
|
@@ -384,6 +388,10 @@ function AliaConversationView({
|
|
|
384
388
|
onStopReady?.(stop);
|
|
385
389
|
return () => onStopReady?.(null);
|
|
386
390
|
}, [onStopReady, stop]);
|
|
391
|
+
useEffect(() => {
|
|
392
|
+
onDetachReady?.(detach);
|
|
393
|
+
return () => onDetachReady?.(null);
|
|
394
|
+
}, [onDetachReady, detach]);
|
|
387
395
|
|
|
388
396
|
// ── What the person does: write, send, rewrite, call ──
|
|
389
397
|
// An agent's starters are its welcome suggestions, unless the host brought its own.
|
|
@@ -151,9 +151,9 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
151
151
|
const conversationProgress = useSharedValue(0);
|
|
152
152
|
const mainRef = useRef<BottomSheetRef>(null);
|
|
153
153
|
const conversationRef = useRef<BottomSheetRef>(null);
|
|
154
|
-
//
|
|
155
|
-
const
|
|
156
|
-
const
|
|
154
|
+
// Closing detaches this sheet's reader; only its explicit Stop cancels the generation.
|
|
155
|
+
const mainDetachRef = useRef<(() => void) | null>(null);
|
|
156
|
+
const conversationDetachRef = useRef<(() => void) | null>(null);
|
|
157
157
|
|
|
158
158
|
// The conversation sheet, stacked over the main one while a chat is open: begun by a first
|
|
159
159
|
// turn, or a stored one picked up again. The key makes every opening a fresh chat.
|
|
@@ -163,11 +163,11 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
163
163
|
conversationId?: string;
|
|
164
164
|
} | null>(null);
|
|
165
165
|
|
|
166
|
-
const
|
|
167
|
-
|
|
166
|
+
const registerMainDetach = useCallback((stop: (() => void) | null) => {
|
|
167
|
+
mainDetachRef.current = stop;
|
|
168
168
|
}, []);
|
|
169
|
-
const
|
|
170
|
-
|
|
169
|
+
const registerConversationDetach = useCallback((stop: (() => void) | null) => {
|
|
170
|
+
conversationDetachRef.current = stop;
|
|
171
171
|
}, []);
|
|
172
172
|
|
|
173
173
|
const startConversation = useCallback((turn: AliaTurn) => {
|
|
@@ -186,11 +186,11 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
186
186
|
|
|
187
187
|
// Back, and the menu's Clear: close the conversation, back to the main screen underneath.
|
|
188
188
|
const closeConversation = useCallback(() => {
|
|
189
|
-
|
|
189
|
+
conversationDetachRef.current?.();
|
|
190
190
|
conversationRef.current?.dismiss();
|
|
191
191
|
}, []);
|
|
192
192
|
const onConversationDismissAttempt = useCallback(() => {
|
|
193
|
-
|
|
193
|
+
conversationDetachRef.current?.();
|
|
194
194
|
return true;
|
|
195
195
|
}, []);
|
|
196
196
|
|
|
@@ -214,8 +214,8 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
214
214
|
);
|
|
215
215
|
|
|
216
216
|
const handleDismiss = useCallback(() => {
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
conversationDetachRef.current?.();
|
|
218
|
+
mainDetachRef.current?.();
|
|
219
219
|
conversationRef.current?.dismiss();
|
|
220
220
|
mainRef.current?.dismiss();
|
|
221
221
|
closeThought();
|
|
@@ -239,8 +239,8 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
239
239
|
|
|
240
240
|
// Closing the main sheet (a drag, backdrop press, Back or Escape) closes what is stacked on it.
|
|
241
241
|
const onMainDismissAttempt = useCallback(() => {
|
|
242
|
-
|
|
243
|
-
|
|
242
|
+
mainDetachRef.current?.();
|
|
243
|
+
conversationDetachRef.current?.();
|
|
244
244
|
conversationRef.current?.dismiss();
|
|
245
245
|
closeThought();
|
|
246
246
|
return true;
|
|
@@ -324,7 +324,7 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
324
324
|
) : undefined
|
|
325
325
|
}
|
|
326
326
|
presentedProgress={mainProgress}
|
|
327
|
-
|
|
327
|
+
onDetachReady={registerMainDetach}
|
|
328
328
|
onStart={startConversation}
|
|
329
329
|
/>
|
|
330
330
|
</BottomSheet>
|
|
@@ -347,7 +347,7 @@ const AliaChatSheetBody = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
|
|
|
347
347
|
conversationId={conversation.conversationId}
|
|
348
348
|
onConversationChange={followConversation}
|
|
349
349
|
presentedProgress={conversationProgress}
|
|
350
|
-
|
|
350
|
+
onDetachReady={registerConversationDetach}
|
|
351
351
|
chrome={conversationChrome}
|
|
352
352
|
/>
|
|
353
353
|
</BottomSheet>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { OxyServices } from '@oxy.so/core';
|
|
2
|
+
import { createRandomUuid } from './random-uuid';
|
|
3
|
+
import { awaitTurnPreparation } from './turn-preparation';
|
|
4
|
+
|
|
5
|
+
type LinkedClient = ReturnType<OxyServices['createLinkedClient']>;
|
|
6
|
+
|
|
7
|
+
/** One client turn, created before any asynchronous preparation or metadata. */
|
|
8
|
+
export function createChatTurnControl(
|
|
9
|
+
linked: LinkedClient,
|
|
10
|
+
headers: Promise<Record<string, string>>,
|
|
11
|
+
conversationId: string | undefined,
|
|
12
|
+
controller: AbortController,
|
|
13
|
+
) {
|
|
14
|
+
// A host can reject before Stop is pressed. Observe that rejection immediately;
|
|
15
|
+
// awaiting the original promise below still reports the failure to Stop.
|
|
16
|
+
void headers.catch(() => undefined);
|
|
17
|
+
const id = createRandomUuid();
|
|
18
|
+
let stopping: Promise<void> | undefined;
|
|
19
|
+
let ended = false;
|
|
20
|
+
const lifetime = new AbortController();
|
|
21
|
+
const epoch = linked.client.getSessionEpoch();
|
|
22
|
+
const current = () => linked.client.getSessionEpoch() === epoch;
|
|
23
|
+
const unsubscribe = linked.client.addTokenChangeListener(() => {
|
|
24
|
+
if (!current()) {
|
|
25
|
+
const reason = new Error('The conversation account changed');
|
|
26
|
+
lifetime.abort(reason);
|
|
27
|
+
controller.abort(reason);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
return {
|
|
31
|
+
id,
|
|
32
|
+
/** Only a deliberate Stop invokes this. View teardown merely aborts its transport. */
|
|
33
|
+
stop(): Promise<void> {
|
|
34
|
+
if (stopping) return stopping;
|
|
35
|
+
if (ended || controller.signal.aborted) return Promise.resolve();
|
|
36
|
+
stopping = (async () => {
|
|
37
|
+
const signal = AbortSignal.any([lifetime.signal, AbortSignal.timeout(10_000)]);
|
|
38
|
+
const assertionHeaders = await awaitTurnPreparation(headers, signal);
|
|
39
|
+
signal.throwIfAborted();
|
|
40
|
+
controller.signal.throwIfAborted();
|
|
41
|
+
if (!current()) throw new Error('The conversation account changed');
|
|
42
|
+
const response = await linked.client.requestAuthenticatedResponse({
|
|
43
|
+
url: `/v1/chat/turns/${id}/cancel`,
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { ...assertionHeaders, 'Content-Type': 'application/json' },
|
|
46
|
+
body: JSON.stringify({ ...(conversationId ? { conversationId } : {}) }),
|
|
47
|
+
// The cancelled chat signal must NOT cancel its own control request.
|
|
48
|
+
signal,
|
|
49
|
+
});
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
await response.body?.cancel();
|
|
52
|
+
throw new Error('Generation could not be stopped. Try again.');
|
|
53
|
+
}
|
|
54
|
+
await response.body?.cancel();
|
|
55
|
+
controller.abort();
|
|
56
|
+
})().catch((error) => {
|
|
57
|
+
stopping = undefined;
|
|
58
|
+
throw error;
|
|
59
|
+
});
|
|
60
|
+
return stopping;
|
|
61
|
+
},
|
|
62
|
+
async finish() {
|
|
63
|
+
ended = true;
|
|
64
|
+
// The stream can close before the replica answering Stop sends its acknowledgement.
|
|
65
|
+
await stopping?.catch(() => undefined);
|
|
66
|
+
lifetime.abort();
|
|
67
|
+
unsubscribe();
|
|
68
|
+
linked.dispose();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Public lifecycle handle for hosts that render their own Stop control. */
|
|
74
|
+
export type AliaChatTurnControl = ReturnType<typeof createChatTurnControl>;
|
package/src/runtime/index.ts
CHANGED
|
@@ -112,3 +112,5 @@ export type { BuiltMessageContent, DroppedAttachment, UploadDocument } from './a
|
|
|
112
112
|
|
|
113
113
|
// The conversation as data (also the leaf entry `@alia.onl/sdk/model`).
|
|
114
114
|
export * from './model';
|
|
115
|
+
|
|
116
|
+
export { createChatTurnControl, type AliaChatTurnControl } from './chat-turn-control';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Fence asynchronous host preparation without waiting for an uncooperative host. */
|
|
2
|
+
export function awaitTurnPreparation<T>(pending: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
3
|
+
return new Promise<T>((resolve, reject) => {
|
|
4
|
+
const aborted = () => {
|
|
5
|
+
signal.removeEventListener('abort', aborted);
|
|
6
|
+
reject(signal.reason);
|
|
7
|
+
};
|
|
8
|
+
pending.then(
|
|
9
|
+
(value) => {
|
|
10
|
+
signal.removeEventListener('abort', aborted);
|
|
11
|
+
resolve(value);
|
|
12
|
+
},
|
|
13
|
+
(error) => {
|
|
14
|
+
signal.removeEventListener('abort', aborted);
|
|
15
|
+
reject(error);
|
|
16
|
+
},
|
|
17
|
+
);
|
|
18
|
+
if (signal.aborted) aborted();
|
|
19
|
+
else signal.addEventListener('abort', aborted, { once: true });
|
|
20
|
+
});
|
|
21
|
+
}
|
|
@@ -15,6 +15,8 @@ import {
|
|
|
15
15
|
import { agentMessageId, buildOutboundMessages, isAgentMessageOf } from './message-history';
|
|
16
16
|
import { aliaQueryKeys } from './query-keys';
|
|
17
17
|
import { createRandomUuid } from './random-uuid';
|
|
18
|
+
import { createChatTurnControl } from './chat-turn-control';
|
|
19
|
+
import { awaitTurnPreparation } from './turn-preparation';
|
|
18
20
|
import { type AliaHostTools, HostOutcomeQueue, hostRequest } from './host-tools';
|
|
19
21
|
import { API_ROUTES } from './routes';
|
|
20
22
|
import { createSseFrameReader } from './sse-frame-reader';
|
|
@@ -213,6 +215,7 @@ export function useConversationStream({
|
|
|
213
215
|
const appAssertionHeaders = useAppAssertionHeaders();
|
|
214
216
|
const queryClient = useQueryClient();
|
|
215
217
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
218
|
+
const turnControlRef = useRef<ReturnType<typeof createChatTurnControl> | null>(null);
|
|
216
219
|
|
|
217
220
|
// Batching refs: accumulate streaming text and flush at ~20fps instead of per-chunk
|
|
218
221
|
const pendingContentRef = useRef('');
|
|
@@ -615,6 +618,7 @@ export function useConversationStream({
|
|
|
615
618
|
/** The request's own controller: `finally` asks it whether the person stopped the turn. */
|
|
616
619
|
let controller: AbortController | null = null;
|
|
617
620
|
let linked: ReturnType<typeof oxyServices.createLinkedClient> | null = null;
|
|
621
|
+
let turnControl: ReturnType<typeof createChatTurnControl> | null = null;
|
|
618
622
|
/** Cut THIS turn's stream. Never the ref's: by now it may hold a later turn's controller. */
|
|
619
623
|
const abortThisTurn = (): void => {
|
|
620
624
|
controller?.abort();
|
|
@@ -626,9 +630,17 @@ export function useConversationStream({
|
|
|
626
630
|
// conversation change reach a turn that is still collecting device info.
|
|
627
631
|
controller = new AbortController();
|
|
628
632
|
abortControllerRef.current = controller;
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
633
|
+
linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
|
|
634
|
+
const assertion = appAssertionHeaders();
|
|
635
|
+
turnControl = createChatTurnControl(linked, assertion, conversationId, controller);
|
|
636
|
+
turnControlRef.current = turnControl;
|
|
637
|
+
|
|
638
|
+
// Attach both rejection handlers immediately; a slow host must not leave
|
|
639
|
+
// an already rejected surface assertion unobserved or defeat detachment.
|
|
640
|
+
const [appAssertion, deviceInfo] = await awaitTurnPreparation(
|
|
641
|
+
Promise.all([assertion, host.deviceInfo ? host.deviceInfo() : undefined]),
|
|
642
|
+
controller.signal,
|
|
643
|
+
);
|
|
632
644
|
if (controller.signal.aborted) return 'aborted';
|
|
633
645
|
|
|
634
646
|
/**
|
|
@@ -646,8 +658,7 @@ export function useConversationStream({
|
|
|
646
658
|
// Authentication, the preflight refresh and the single 401 replay belong
|
|
647
659
|
// to the linked Oxy client; this layer never reads or writes a bearer.
|
|
648
660
|
// Which Oxy app this turn is in (ADR 0021): an agent Oxy confines to an app opens only there.
|
|
649
|
-
|
|
650
|
-
linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
|
|
661
|
+
if (controller.signal.aborted) return 'aborted';
|
|
651
662
|
const response = await linked.client.requestAuthenticatedResponse({
|
|
652
663
|
method: 'POST',
|
|
653
664
|
url: API_ROUTES.chat,
|
|
@@ -658,6 +669,7 @@ export function useConversationStream({
|
|
|
658
669
|
...(deviceInfo === undefined ? {} : { 'X-Device-Info': JSON.stringify(deviceInfo) }),
|
|
659
670
|
},
|
|
660
671
|
body: JSON.stringify({
|
|
672
|
+
turnId: turnControl.id,
|
|
661
673
|
messages: messagesToSend,
|
|
662
674
|
// The reply is stored under the id it is drawn under here, so a vote
|
|
663
675
|
// or a read-aloud on it needs no reload to find it.
|
|
@@ -688,6 +700,12 @@ export function useConversationStream({
|
|
|
688
700
|
const lockedFeature = lockedFeatureOf(
|
|
689
701
|
errorData?.error && typeof errorData.error === 'object' ? errorData.error : null,
|
|
690
702
|
);
|
|
703
|
+
if (errorCode(errorData?.error) === 'turn_cancelled') {
|
|
704
|
+
flushPendingUpdates();
|
|
705
|
+
settleAssistant('cancelled');
|
|
706
|
+
abortThisTurn();
|
|
707
|
+
return 'aborted';
|
|
708
|
+
}
|
|
691
709
|
if (lockedFeature !== null) {
|
|
692
710
|
host.onFeatureLocked?.(lockedFeature);
|
|
693
711
|
return rollback();
|
|
@@ -1037,6 +1055,13 @@ export function useConversationStream({
|
|
|
1037
1055
|
// Handle structured error events sent via SSE
|
|
1038
1056
|
if (parsed.error) {
|
|
1039
1057
|
const err = parsed.error;
|
|
1058
|
+
if (errorCode(err) === 'turn_cancelled') {
|
|
1059
|
+
flushPendingUpdates();
|
|
1060
|
+
settleAssistant('cancelled');
|
|
1061
|
+
abortThisTurn();
|
|
1062
|
+
reader.cancel().catch(() => {});
|
|
1063
|
+
return 'aborted';
|
|
1064
|
+
}
|
|
1040
1065
|
// An agent's feature gate refused the turn before anything was spent: the host
|
|
1041
1066
|
// shows its own upsell, and the person's words go back to the composer.
|
|
1042
1067
|
const lockedFeature = lockedFeatureOf(err);
|
|
@@ -1362,7 +1387,9 @@ export function useConversationStream({
|
|
|
1362
1387
|
const ownsTurn =
|
|
1363
1388
|
abortControllerRef.current === null || abortControllerRef.current === controller;
|
|
1364
1389
|
if (abortControllerRef.current === controller) abortControllerRef.current = null;
|
|
1365
|
-
|
|
1390
|
+
if (turnControlRef.current === turnControl) turnControlRef.current = null;
|
|
1391
|
+
if (turnControl) await turnControl.finish();
|
|
1392
|
+
else linked?.dispose();
|
|
1366
1393
|
// A turn that is still streaming here ended without an error path
|
|
1367
1394
|
// settling it: on its own, or because `stop()` aborted the controller.
|
|
1368
1395
|
// (The error paths abort it too, but they have settled it first.)
|
|
@@ -1397,7 +1424,7 @@ export function useConversationStream({
|
|
|
1397
1424
|
],
|
|
1398
1425
|
);
|
|
1399
1426
|
|
|
1400
|
-
const
|
|
1427
|
+
const detach = useCallback(() => {
|
|
1401
1428
|
if (abortControllerRef.current) {
|
|
1402
1429
|
abortControllerRef.current.abort();
|
|
1403
1430
|
abortControllerRef.current = null;
|
|
@@ -1405,6 +1432,16 @@ export function useConversationStream({
|
|
|
1405
1432
|
setIsLoading(false);
|
|
1406
1433
|
}, []);
|
|
1407
1434
|
|
|
1435
|
+
const stop = useCallback(() => {
|
|
1436
|
+
const turn = turnControlRef.current;
|
|
1437
|
+
if (!turn) return;
|
|
1438
|
+
void turn.stop().catch((error: unknown) => {
|
|
1439
|
+
if (turnControlRef.current === turn && mountedRef.current) {
|
|
1440
|
+
setError(error instanceof Error ? error : new Error('Generation could not be stopped.'));
|
|
1441
|
+
}
|
|
1442
|
+
});
|
|
1443
|
+
}, []);
|
|
1444
|
+
|
|
1408
1445
|
const clearError = useCallback(() => setError(null), []);
|
|
1409
1446
|
|
|
1410
1447
|
/**
|
|
@@ -1451,6 +1488,7 @@ export function useConversationStream({
|
|
|
1451
1488
|
error,
|
|
1452
1489
|
append,
|
|
1453
1490
|
stop,
|
|
1491
|
+
detach,
|
|
1454
1492
|
setMessages: setMessagesAndRef,
|
|
1455
1493
|
conversationTitle,
|
|
1456
1494
|
clearError,
|
|
@@ -134,6 +134,7 @@ export function useConversationThread({
|
|
|
134
134
|
clearError,
|
|
135
135
|
setMessages,
|
|
136
136
|
stop,
|
|
137
|
+
detach,
|
|
137
138
|
suggestedNewConversation,
|
|
138
139
|
dismissSuggestedNewConversation,
|
|
139
140
|
agentRun,
|
|
@@ -547,6 +548,7 @@ export function useConversationThread({
|
|
|
547
548
|
promptOf,
|
|
548
549
|
turnOptionsOf,
|
|
549
550
|
stopGeneration,
|
|
551
|
+
detachGeneration: detach,
|
|
550
552
|
clearConversation,
|
|
551
553
|
clearError,
|
|
552
554
|
setMessages,
|