@alia.onl/sdk 15.7.0 → 19.0.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.
Files changed (150) hide show
  1. package/CHANGELOG.md +255 -12
  2. package/README.md +241 -155
  3. package/package.json +17 -5
  4. package/src/animations.ts +0 -1
  5. package/src/components/AliaAskProvider.tsx +127 -0
  6. package/src/components/AliaChatContent.tsx +604 -392
  7. package/src/components/AliaChatHeader.tsx +14 -38
  8. package/src/components/AliaChatScreen.tsx +56 -31
  9. package/src/components/AliaChatSheet.tsx +110 -36
  10. package/src/components/AliaComposer.tsx +1 -1
  11. package/src/components/AliaParticipants.tsx +202 -0
  12. package/src/components/AliaParticipantsHeader.tsx +60 -0
  13. package/src/components/AliaSharedConversation.tsx +86 -0
  14. package/src/components/AliaWelcomeMessage.tsx +18 -5
  15. package/src/components/Image.tsx +12 -0
  16. package/src/components/Markdown.tsx +498 -14
  17. package/src/components/PlanPreviewCard.tsx +7 -49
  18. package/src/components/ReplyMarkdown.tsx +359 -0
  19. package/src/components/RichBlocks.tsx +195 -0
  20. package/src/components/canvas/CanvasComponent.tsx +59 -0
  21. package/src/components/canvas/ChartRenderer.tsx +73 -0
  22. package/src/components/canvas/CodeRenderer.tsx +24 -0
  23. package/src/components/canvas/FormRenderer.tsx +101 -0
  24. package/src/components/canvas/MarkdownRenderer.tsx +14 -0
  25. package/src/components/canvas/TableRenderer.tsx +47 -0
  26. package/src/components/thought/FilesAndSources.tsx +120 -0
  27. package/src/components/thought/ThoughtPanel.tsx +417 -0
  28. package/src/components/thought/ToolStep.tsx +141 -0
  29. package/src/components/thread/AgentResultCard.tsx +125 -0
  30. package/src/components/thread/AgentTaskCard.tsx +127 -0
  31. package/src/components/thread/AliaThread.tsx +401 -0
  32. package/src/components/thread/MessageBlockBoundary.tsx +78 -0
  33. package/src/components/thread/NewConversationOffer.tsx +54 -0
  34. package/src/components/thread/ThreadRow.tsx +458 -0
  35. package/src/components/thread/ThreadSearch.tsx +129 -0
  36. package/src/components/thread/ToolResultCard.tsx +254 -0
  37. package/src/components/thread/TurnMenu.tsx +54 -0
  38. package/src/components/thread/TurnStatusLine.tsx +34 -0
  39. package/src/components/voice/AliaVoiceCall.tsx +21 -0
  40. package/src/components/voice/VoiceControls.tsx +2 -28
  41. package/src/components/voice/VoiceSession.tsx +39 -11
  42. package/src/csp.ts +50 -0
  43. package/src/hooks/useAliaActivity.ts +3 -3
  44. package/src/hooks/useAtBottom.ts +51 -0
  45. package/src/hooks/useAttachMenu.ts +94 -0
  46. package/src/hooks/useChatComposer.ts +299 -0
  47. package/src/hooks/useChatVoice.ts +129 -0
  48. package/src/hooks/useDictation.ts +2 -3
  49. package/src/hooks/useDocumentPicker.ts +3 -29
  50. package/src/hooks/useFailedTurnToast.ts +43 -0
  51. package/src/hooks/useMessageVote.ts +50 -0
  52. package/src/hooks/useOpenThought.ts +79 -0
  53. package/src/hooks/usePendingQuestions.ts +2 -2
  54. package/src/hooks/useReadAloud.ts +92 -0
  55. package/src/hooks/useShareHandler.ts +24 -0
  56. package/src/hooks/useSoundEffects.ts +1 -1
  57. package/src/hooks/useTTS.ts +2 -3
  58. package/src/hooks/useThoughtScope.ts +59 -0
  59. package/src/hooks/useThreadRows.ts +145 -0
  60. package/src/hooks/useThreadView.ts +68 -0
  61. package/src/hooks/useTimelineWindow.ts +256 -0
  62. package/src/hooks/useVoiceLauncher.ts +180 -0
  63. package/src/hooks/useVoiceRoom.ts +2 -6
  64. package/src/index.ts +43 -29
  65. package/src/lib/activity-store.ts +1 -1
  66. package/src/lib/add-menu.ts +7 -0
  67. package/src/lib/call-thread.ts +33 -0
  68. package/src/lib/character-runtime.ts +6 -4
  69. package/src/lib/citations.ts +79 -2
  70. package/src/lib/clipboard.ts +23 -0
  71. package/src/lib/content-utils.ts +10 -0
  72. package/src/lib/copy-en.ts +284 -0
  73. package/src/lib/copy.ts +32 -0
  74. package/src/lib/document-picker.ts +31 -0
  75. package/src/lib/flights.ts +22 -13
  76. package/src/lib/focus-return.ts +66 -0
  77. package/src/lib/keyboard.native.tsx +19 -0
  78. package/src/lib/keyboard.tsx +9 -0
  79. package/src/lib/labels.tsx +69 -17
  80. package/src/lib/origins.ts +15 -0
  81. package/src/lib/participants.ts +86 -0
  82. package/src/lib/power-level-options.ts +66 -0
  83. package/src/lib/questions.ts +2 -2
  84. package/src/lib/task-labels.ts +4 -4
  85. package/src/lib/tool-call-status.ts +1 -1
  86. package/src/lib/tool-icons.ts +36 -0
  87. package/src/lib/tool-renderers.tsx +48 -0
  88. package/src/lib/turn-state.ts +2 -2
  89. package/src/lib/voice-audio-engine.ts +14 -158
  90. package/src/lib/voice-call-request.ts +49 -0
  91. package/src/lib/voice-call.ts +76 -34
  92. package/src/lib/voice-controls-labels.ts +31 -0
  93. package/src/lib/voice-protocol.ts +32 -14
  94. package/src/lib/voice-worklets.ts +19 -0
  95. package/src/lib/work-log.ts +6 -6
  96. package/src/runtime/agents.ts +151 -0
  97. package/src/runtime/api.ts +123 -0
  98. package/src/runtime/attachment-utils.ts +96 -0
  99. package/src/runtime/composer-draft-store.ts +270 -0
  100. package/src/runtime/config.tsx +186 -0
  101. package/src/runtime/conversation-store.ts +49 -0
  102. package/src/runtime/conversations.ts +557 -0
  103. package/src/runtime/error-utils.ts +104 -0
  104. package/src/runtime/host-tools.ts +140 -0
  105. package/src/runtime/index.ts +113 -0
  106. package/src/runtime/message-history.ts +124 -0
  107. package/src/runtime/model.ts +39 -0
  108. package/src/runtime/oxy-files.ts +37 -0
  109. package/src/runtime/power-levels.ts +66 -0
  110. package/src/runtime/query-keys.ts +34 -0
  111. package/src/runtime/random-uuid.ts +25 -0
  112. package/src/runtime/routes.ts +51 -0
  113. package/src/runtime/sharing.ts +165 -0
  114. package/src/runtime/sse-frame-reader.ts +88 -0
  115. package/src/runtime/stream-events.ts +146 -0
  116. package/src/runtime/stream-outcome.ts +21 -0
  117. package/src/runtime/thought-store.ts +103 -0
  118. package/src/runtime/thread/agent-activity.ts +261 -0
  119. package/src/runtime/thread/automation-labels.ts +79 -0
  120. package/src/runtime/thread/conversation-export.ts +135 -0
  121. package/src/runtime/thread/message-days.ts +120 -0
  122. package/src/runtime/thread/message-processor.ts +93 -0
  123. package/src/runtime/thread/task-utils.ts +11 -0
  124. package/src/runtime/thread/thought-utils.ts +752 -0
  125. package/src/runtime/thread/thread-history.ts +139 -0
  126. package/src/runtime/thread/timeline.ts +202 -0
  127. package/src/runtime/thread/title-tags.ts +8 -0
  128. package/src/runtime/thread/tool-cards.ts +383 -0
  129. package/src/runtime/turn-failure.ts +108 -0
  130. package/src/runtime/types.ts +148 -0
  131. package/src/runtime/usage-limit-error.ts +44 -0
  132. package/src/runtime/use-agent-activity.ts +80 -0
  133. package/src/runtime/use-conversation-stream.ts +1427 -0
  134. package/src/runtime/use-conversation-thread.ts +556 -0
  135. package/src/runtime/use-start-conversation.ts +107 -0
  136. package/src/runtime/use-thread-history.ts +64 -0
  137. package/src/runtime/use-thread-search.ts +95 -0
  138. package/src/runtime/use-turn-edit.ts +99 -0
  139. package/src/tools.ts +2 -2
  140. package/src/types.ts +18 -47
  141. package/worklets/voice-processors.js +152 -0
  142. package/src/components/AliaChatMessageList.tsx +0 -532
  143. package/src/components/ThinkingBlock.tsx +0 -43
  144. package/src/hooks/useAliaChat.ts +0 -573
  145. package/src/lib/api-url.ts +0 -2
  146. package/src/lib/catalogue.ts +0 -236
  147. package/src/lib/chat-stream.ts +0 -655
  148. package/src/lib/chat-transport.ts +0 -98
  149. package/src/lib/delegation.ts +0 -34
  150. package/src/lib/work-log-copy.ts +0 -61
@@ -0,0 +1,256 @@
1
+ import {
2
+ type Message,
3
+ TAIL,
4
+ TOP,
5
+ type TimelineAnchor,
6
+ type TimelinePosition,
7
+ collapseToTail,
8
+ pinAnchor,
9
+ resolveStart,
10
+ revealAbove,
11
+ sameTimelineShape,
12
+ timelinePositions,
13
+ } from '../runtime/model';
14
+ import type { AiChatThreadHandle } from '@oxy.so/bloom/ai-chat';
15
+ import { useCallback, useLayoutEffect, useMemo, useRef, useState, type RefObject } from 'react';
16
+ import type { LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent } from 'react-native';
17
+
18
+ /**
19
+ * The list, handed back as the SAME array for as long as only what its
20
+ * messages say has changed (`sameTimelineShape`).
21
+ *
22
+ * The array it returns may hold an older copy of the message being streamed
23
+ * into, so it is for deriving structure — ids, roles, stamps, speakers — and
24
+ * never for drawing text.
25
+ */
26
+ export function useTimelineShape<
27
+ T extends Pick<Message, 'id' | 'role' | 'createdAt' | 'agentInfo'>,
28
+ >(list: readonly T[]): readonly T[] {
29
+ const shape = useRef(list);
30
+ if (!sameTimelineShape(shape.current, list)) shape.current = list;
31
+ return shape.current;
32
+ }
33
+
34
+ interface Options {
35
+ /** Every row's id, in reading order. Keep its identity stable across tokens. */
36
+ ids: readonly string[];
37
+ /**
38
+ * What a saved position belongs to — the conversation — or `null` for a view
39
+ * that is not remembered (a cursor jump's window of the past).
40
+ */
41
+ scrollKey: string | null;
42
+ /** The conversation's messages have loaded; a restore waits for it. */
43
+ ready: boolean;
44
+ /** Index of a cursor jump's target row, or -1. It is always mounted. */
45
+ focusIndex: number;
46
+ /** A turn is streaming; the window is never folded under it. */
47
+ turnInFlight: boolean;
48
+ atBottomThreshold: number;
49
+ threadRef: RefObject<AiChatThreadHandle | null>;
50
+ /** Ask for the page of history above the first loaded row. */
51
+ onLoadHistory?: () => void;
52
+ onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
53
+ }
54
+
55
+ /**
56
+ * What is still to be done to the scroll once the rows it needs are laid out.
57
+ * A restore remembers the list it was set against: that list may still be the
58
+ * previous conversation's, and only a list that changed after it can say the
59
+ * saved row is really gone.
60
+ */
61
+ type PendingScroll =
62
+ | { kind: 'end' }
63
+ | { kind: 'offset'; position: TimelinePosition; setAgainst: readonly string[] };
64
+
65
+ /**
66
+ * A restore is waiting for its conversation's rows: the row it was saved
67
+ * against is not on screen, and the list has not changed since it was set. A
68
+ * list that did change without bringing that row back means the row is gone
69
+ * (edited away, regenerated), and the restore falls back to the newest turn.
70
+ */
71
+ function awaitingRows(pending: { current: PendingScroll | null }, ids: readonly string[]): boolean {
72
+ const todo = pending.current;
73
+ if (todo?.kind !== 'offset' || ids.includes(todo.position.probeId)) return false;
74
+ if (ids === todo.setAgainst) return true;
75
+ pending.current = { kind: 'end' };
76
+ return false;
77
+ }
78
+
79
+ /** The state before the first key: nothing mounted yet, so nothing to remember. */
80
+ const MOUNTING = Symbol('mounting');
81
+
82
+ const nextFrame = (run: () => void): void => {
83
+ if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
84
+ else setTimeout(run, 0);
85
+ };
86
+
87
+ /**
88
+ * Which rows the thread mounts, and the scroll that goes with them: the window
89
+ * over a long history, and the position a conversation is returned to.
90
+ *
91
+ * ## The window
92
+ *
93
+ * The newest `INITIAL_ROWS` rows mount first. Near the top, Bloom's
94
+ * `onStartReached` reveals `PAGE_ROWS` more — or, with none left, asks for the
95
+ * thread's history — and `maintainStartPosition` holds the reader on the row
96
+ * they were reading while either lands above. The window folds back to its
97
+ * opening size only when the reader is at the bottom and nothing is streaming.
98
+ * See `runtime/thread/timeline.ts` for why it is this and not virtualization.
99
+ *
100
+ * ## The position
101
+ *
102
+ * Leaving a conversation — by navigating away or by switching the screen to
103
+ * another — saves the offset, whether the reader was at the bottom, and the
104
+ * window's anchor. Coming back restores the anchor first, so the same rows are
105
+ * mounted above the reader, and the offset once they have laid out. Coming
106
+ * back from a cursor jump does not restore: "back to latest" means the latest.
107
+ */
108
+ export function useTimelineWindow({
109
+ ids,
110
+ scrollKey,
111
+ ready,
112
+ focusIndex,
113
+ turnInFlight,
114
+ atBottomThreshold,
115
+ threadRef,
116
+ onLoadHistory,
117
+ onScroll,
118
+ }: Options) {
119
+ const [state, setState] = useState<{ key: string | null; anchor: TimelineAnchor }>({
120
+ key: scrollKey,
121
+ anchor: TAIL,
122
+ });
123
+ // The render after a key change and before its effect: the new conversation
124
+ // starts from the newest rows until the effect says otherwise.
125
+ const anchor = state.key === scrollKey ? state.anchor : TAIL;
126
+ const start = useMemo(() => resolveStart(anchor, ids, focusIndex), [anchor, ids, focusIndex]);
127
+
128
+ const setAnchor = useCallback(
129
+ (next: TimelineAnchor) => setState((current) => ({ key: current.key, anchor: next })),
130
+ [],
131
+ );
132
+
133
+ /** This render's facts, for callbacks and cleanups that outlive it. */
134
+ const latest = useRef({ ids, start, anchor, ready, turnInFlight, onLoadHistory });
135
+ useLayoutEffect(() => {
136
+ latest.current = { ids, start, anchor, ready, turnInFlight, onLoadHistory };
137
+ });
138
+
139
+ /** The last position the thread reported for the current key. */
140
+ const lastOffset = useRef<number | null>(null);
141
+ const atBottom = useRef(true);
142
+ const pending = useRef<PendingScroll | null>(null);
143
+ const previousKey = useRef<string | null | typeof MOUNTING>(MOUNTING);
144
+
145
+ useLayoutEffect(() => {
146
+ const cameFrom = previousKey.current;
147
+ previousKey.current = scrollKey;
148
+ lastOffset.current = null;
149
+ atBottom.current = true;
150
+ // Returning from a jump is a request for the present, not for the past.
151
+ // A reader who left from the bottom comes back to the newest page, which
152
+ // is where they were; only a position mid-thread brings its rows back.
153
+ const read =
154
+ scrollKey !== null && cameFrom !== null ? timelinePositions.read(scrollKey) : undefined;
155
+ const saved = read?.atBottom === false ? read : undefined;
156
+ pending.current =
157
+ scrollKey === null
158
+ ? null
159
+ : saved === undefined
160
+ ? { kind: 'end' }
161
+ : { kind: 'offset', position: saved, setAgainst: ids };
162
+ setState({ key: scrollKey, anchor: saved?.anchor ?? TAIL });
163
+
164
+ return () => {
165
+ if (scrollKey === null) return;
166
+ const { ids: rows, start: first, anchor: kept } = latest.current;
167
+ if (rows.length === 0) return;
168
+ timelinePositions.save(scrollKey, {
169
+ anchor: kept,
170
+ offset: lastOffset.current ?? 0,
171
+ atBottom: atBottom.current,
172
+ probeId: rows[first],
173
+ });
174
+ };
175
+ }, [scrollKey]);
176
+
177
+ /**
178
+ * Pin the opening window to its first row once the rows exist, so a turn
179
+ * appended at the bottom grows the window rather than sliding it — a slide
180
+ * would unmount the top row under a reader who is mid-thread. Re-pinned the
181
+ * same way when the pinned row has gone (the list was replaced) — except
182
+ * while a restore waits for its conversation's rows: the rows on screen are
183
+ * still the last conversation's, and the anchor being restored is not in
184
+ * them yet.
185
+ */
186
+ useLayoutEffect(() => {
187
+ if (ids.length === 0 || awaitingRows(pending, ids)) return;
188
+ const stale = anchor.kind === 'tail' || (anchor.kind === 'row' && !ids.includes(anchor.id));
189
+ if (!stale) return;
190
+ setState((current) =>
191
+ current.key === scrollKey && current.anchor === anchor
192
+ ? { key: current.key, anchor: pinAnchor(ids, start) }
193
+ : current,
194
+ );
195
+ }, [ids, anchor, start, scrollKey]);
196
+
197
+ const handleStartReached = useCallback(() => {
198
+ const { ids: rows, start: first, onLoadHistory: loadHistory } = latest.current;
199
+ const next = revealAbove(rows, first);
200
+ if (next !== null) {
201
+ setAnchor(next);
202
+ return;
203
+ }
204
+ setAnchor(TOP);
205
+ loadHistory?.();
206
+ }, [setAnchor]);
207
+
208
+ const handleScroll = useCallback(
209
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
210
+ const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
211
+ lastOffset.current = contentOffset.y;
212
+ const wasAtBottom = atBottom.current;
213
+ atBottom.current =
214
+ contentSize.height - contentOffset.y - layoutMeasurement.height <= atBottomThreshold;
215
+ if (atBottom.current && !wasAtBottom && !latest.current.turnInFlight) {
216
+ const { ids: rows, start: first } = latest.current;
217
+ const folded = collapseToTail(rows, first);
218
+ if (folded !== null) {
219
+ pending.current = { kind: 'end' };
220
+ setAnchor(folded);
221
+ }
222
+ }
223
+ onScroll?.(event);
224
+ },
225
+ [atBottomThreshold, onScroll, setAnchor],
226
+ );
227
+
228
+ /**
229
+ * The rows have laid out: carry out whatever scroll was waiting for them.
230
+ * A frame later, so it lands after the thread's own reaction to the same
231
+ * layout (on web it follows to the end on its first one).
232
+ */
233
+ const handleListLayout = useCallback(
234
+ (_event: LayoutChangeEvent) => {
235
+ const { ids: rows, ready: loaded } = latest.current;
236
+ if (pending.current === null || !loaded || rows.length === 0) return;
237
+ if (awaitingRows(pending, rows)) return;
238
+ const todo = pending.current;
239
+ pending.current = null;
240
+ nextFrame(() => {
241
+ if (todo.kind === 'end') threadRef.current?.scrollToEnd({ animated: false });
242
+ else threadRef.current?.scrollToOffset({ offset: todo.position.offset });
243
+ });
244
+ },
245
+ [threadRef],
246
+ );
247
+
248
+ return {
249
+ /** Index of the first mounted row. */
250
+ start,
251
+ /** For `AiChatThread`: absent when there is nothing above to reveal or load. */
252
+ onStartReached: start > 0 || onLoadHistory !== undefined ? handleStartReached : undefined,
253
+ onScroll: handleScroll,
254
+ onListLayout: handleListLayout,
255
+ };
256
+ }
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The host side of a voice call, shared by every surface that offers one.
3
+ *
4
+ * It owns whether a call is open, the state it reports back (for the ambient
5
+ * wave, the sound effects and the transcript), the gate before one opens (plan,
6
+ * credits, sign-in: the host answers, the SDK tells the person), and the end of
7
+ * a call that is no longer the screen's — another screen on show, another
8
+ * conversation, another account. `sessionProps` is everything `VoiceSession`
9
+ * needs; the sheet, `AliaChatScreen` and the Alia app all spread it.
10
+ *
11
+ * Nothing here imports the call or its audio engine.
12
+ */
13
+
14
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
15
+ import { toast } from '@oxy.so/bloom/toast';
16
+ import { useLabels, type VoiceBlockReason } from '../lib/labels';
17
+ import { isRequestFor, useVoiceCallRequests } from '../lib/voice-call-request';
18
+ import type { VoiceNotice, VoiceSessionProps, VoiceSessionState } from '../types';
19
+
20
+ export interface UseVoiceLauncherOptions {
21
+ /** Alia API base URL. */
22
+ apiUrl?: string;
23
+ /** The conversation open on this screen, which is where the call lives. */
24
+ conversationId?: string;
25
+ /**
26
+ * Called after the gate, when no conversation is open: creates (and shows) the one the call
27
+ * will live in and returns its id, or `undefined` when it could not be made (the call then
28
+ * does not open). Every call has a conversation, so its transcript has a home. The call opens
29
+ * on the screen that shows that conversation: this one, or the one the host navigates to.
30
+ */
31
+ resolveConversation?: () => Promise<string | undefined>;
32
+ /** Whose call it would be. A live call ends when the account, or the open conversation, changes. */
33
+ accountId?: string | null;
34
+ /** The agent the call talks to; omitted, Alia. */
35
+ agentId?: string;
36
+ /** Language spoken (BCP-47); omitted, the platform's. */
37
+ locale?: string;
38
+ /** Which of the product's two voices answers. */
39
+ voice?: 'male' | 'female';
40
+ /** Alia stored messages of the call in the conversation: refresh the thread. */
41
+ onMessagesSaved?: VoiceSessionProps['onMessagesSaved'];
42
+ /**
43
+ * The gate before a call opens: why it may not (no account, no plan, no credits), or `null`.
44
+ * The person is told (`labels.voiceBlocked`); `onVoiceBlocked` adds what only the host can do.
45
+ */
46
+ canStartVoice?: () => VoiceBlockReason | null;
47
+ /** The call was not opened for `reason`: sign the person in, open the plans… */
48
+ onVoiceBlocked?: (reason: VoiceBlockReason) => void;
49
+ /** Where the call's notices go; omitted, Bloom's toast. */
50
+ onNotice?: (notice: VoiceNotice) => void;
51
+ /** A call is over, however it ended: refresh credits, the thread… */
52
+ onCallEnded?: () => void;
53
+ /** Whether the screen holding the call is the one on show. A live call ends when it is not. */
54
+ isFocused?: boolean;
55
+ }
56
+
57
+ export interface VoiceLauncher {
58
+ /** A call is open (or being opened). */
59
+ isActive: boolean;
60
+ /** What the call reports back; `null` until it is under way. */
61
+ state: VoiceSessionState | null;
62
+ /** Open a call, after the gate. Does nothing while one is open or the screen is not on show. */
63
+ start: () => Promise<void>;
64
+ /** End the call. */
65
+ end: () => void;
66
+ /** Spread onto `VoiceSession` (or `AliaVoiceCall`). */
67
+ sessionProps: VoiceSessionProps;
68
+ }
69
+
70
+ /** Bloom's toast, by kind: the default home of a call's notices. */
71
+ export function toastNotice({ kind, text }: VoiceNotice): void {
72
+ if (kind === 'error') toast.error(text);
73
+ else if (kind === 'warning') toast.warning(text);
74
+ else toast.info(text);
75
+ }
76
+
77
+ export function useVoiceLauncher(options: UseVoiceLauncherOptions = {}): VoiceLauncher {
78
+ const labels = useLabels();
79
+ const {
80
+ apiUrl,
81
+ conversationId,
82
+ resolveConversation,
83
+ accountId = null,
84
+ agentId,
85
+ locale,
86
+ voice,
87
+ onMessagesSaved,
88
+ canStartVoice,
89
+ onVoiceBlocked,
90
+ onNotice,
91
+ onCallEnded,
92
+ isFocused = true,
93
+ } = options;
94
+ const [isActive, setIsActive] = useState(false);
95
+ const [state, setState] = useState<VoiceSessionState | null>(null);
96
+ const latest = useRef({
97
+ canStartVoice,
98
+ onVoiceBlocked,
99
+ onNotice,
100
+ onCallEnded,
101
+ resolveConversation,
102
+ labels,
103
+ });
104
+ latest.current = {
105
+ canStartVoice,
106
+ onVoiceBlocked,
107
+ onNotice,
108
+ onCallEnded,
109
+ resolveConversation,
110
+ labels,
111
+ };
112
+
113
+ const notify = useCallback((notice: VoiceNotice) => {
114
+ (latest.current.onNotice ?? toastNotice)(notice);
115
+ }, []);
116
+
117
+ const end = useCallback(() => {
118
+ setIsActive(false);
119
+ setState(null);
120
+ latest.current.onCallEnded?.();
121
+ }, []);
122
+
123
+ const start = useCallback(async () => {
124
+ if (isActive || !isFocused) return;
125
+ const reason = latest.current.canStartVoice?.() ?? null;
126
+ if (reason !== null) {
127
+ notify({
128
+ kind: reason === 'credits-exhausted' ? 'error' : 'info',
129
+ text: latest.current.labels.voiceBlocked(reason),
130
+ });
131
+ latest.current.onVoiceBlocked?.(reason);
132
+ return;
133
+ }
134
+ // The gate first: a call that may not open must not leave an empty conversation behind.
135
+ const home = conversationId ?? (await latest.current.resolveConversation?.());
136
+ if (home === undefined) return;
137
+ // The call opens where its conversation is shown (see `useVoiceCallRequests`): here, at once,
138
+ // when it already is; wherever the host takes the new conversation, when it was just made.
139
+ useVoiceCallRequests.getState().ask(home, accountId);
140
+ }, [isActive, isFocused, conversationId, accountId, notify]);
141
+
142
+ const request = useVoiceCallRequests((store) => store.request);
143
+ useEffect(() => {
144
+ if (isActive || !isFocused || !isRequestFor(request, conversationId, accountId)) return;
145
+ useVoiceCallRequests.getState().clear();
146
+ setIsActive(true);
147
+ }, [request, isActive, isFocused, conversationId, accountId]);
148
+
149
+ // A call belongs to one account and one conversation, on the screen that shows it: it ends when
150
+ // the screen goes out of focus, the account changes, or the screen moves to another conversation.
151
+ const openedIn = useRef<{ conversationId: string | undefined; accountId: string | null }>(
152
+ undefined,
153
+ );
154
+ useEffect(() => {
155
+ if (!isActive) {
156
+ openedIn.current = undefined;
157
+ return;
158
+ }
159
+ openedIn.current ??= { conversationId, accountId };
160
+ const call = openedIn.current;
161
+ if (!isFocused || call.accountId !== accountId || call.conversationId !== conversationId) end();
162
+ }, [isActive, isFocused, accountId, conversationId, end]);
163
+
164
+ const sessionProps = useMemo<VoiceSessionProps>(
165
+ () => ({
166
+ ...(apiUrl === undefined ? {} : { apiUrl }),
167
+ ...(conversationId === undefined ? {} : { conversationId }),
168
+ ...(agentId === undefined ? {} : { agentId }),
169
+ ...(locale === undefined ? {} : { locale }),
170
+ ...(voice === undefined ? {} : { voice }),
171
+ ...(onMessagesSaved === undefined ? {} : { onMessagesSaved }),
172
+ onNotice: notify,
173
+ onStateChange: setState,
174
+ onEnd: end,
175
+ }),
176
+ [apiUrl, conversationId, agentId, locale, voice, onMessagesSaved, notify, end],
177
+ );
178
+
179
+ return { isActive, state, start, end, sessionProps };
180
+ }
@@ -24,8 +24,7 @@ import type { VoiceAudioEngine } from '../lib/voice-audio-engine-types';
24
24
  import { VoiceCall, type VoiceCallSnapshot, type VoiceSocketFactory } from '../lib/voice-call';
25
25
  import { VoiceLevelChannel, type VoiceLevelSource } from '../lib/voice-levels';
26
26
  import type { ProductVoice, VoiceCloseReason } from '../lib/voice-protocol';
27
-
28
- const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
27
+ import { ALIA_API_URL } from '../lib/origins';
29
28
 
30
29
  export interface UseVoiceRoomOptions {
31
30
  apiUrl?: string;
@@ -44,8 +43,6 @@ export interface UseVoiceRoomOptions {
44
43
  lang?: string;
45
44
  /** Alia stored messages of the call: refresh the thread. */
46
45
  onMessagesSaved?: (conversationId: string, messageIds: readonly string[]) => void;
47
- /** A tool filed an action for the person's OK; its card is in the conversation. */
48
- onApprovalRequested?: (toolName: string) => void;
49
46
  /** Test seams: the device's audio and the socket. */
50
47
  createAudioEngine?: () => VoiceAudioEngine;
51
48
  openSocket?: VoiceSocketFactory;
@@ -120,7 +117,7 @@ export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
120
117
  if (live !== undefined && live !== 'ended' && live !== 'error') return;
121
118
  const current = optionsRef.current;
122
119
  const call = new VoiceCall({
123
- apiUrl: current.apiUrl || API_URL,
120
+ apiUrl: current.apiUrl || ALIA_API_URL,
124
121
  getToken,
125
122
  conversationId: current.conversationId ?? newConversationId(),
126
123
  ...(current.agentId === undefined ? {} : { agentId: current.agentId }),
@@ -134,7 +131,6 @@ export function useVoiceRoom(options: UseVoiceRoomOptions = {}) {
134
131
  },
135
132
  onMessagesSaved: (conversationId, ids) =>
136
133
  optionsRef.current.onMessagesSaved?.(conversationId, ids),
137
- onApprovalRequested: (toolName) => optionsRef.current.onApprovalRequested?.(toolName),
138
134
  });
139
135
  callRef.current = call;
140
136
  await call.start();
package/src/index.ts CHANGED
@@ -3,17 +3,39 @@
3
3
  // Reusable chat UI, voice, and streaming components for Oxy apps
4
4
 
5
5
  // ── Main components ──
6
- export type { AliaChatAgent, AliaChatState } from './components/AliaChatContent';
6
+ export { AliaChatContent } from './components/AliaChatContent';
7
+ export type {
8
+ AliaChatAgentRef,
9
+ AliaChatChrome,
10
+ AliaChatContentProps,
11
+ AliaChatState,
12
+ } from './components/AliaChatContent';
13
+ export { AliaParticipants, joinFlights, useParticipants } from './components/AliaParticipants';
14
+ export type { AliaParticipantsProps, UseParticipantsOptions } from './components/AliaParticipants';
15
+ export { AliaParticipantsHeader } from './components/AliaParticipantsHeader';
16
+ export type { AliaParticipantsHeaderProps } from './components/AliaParticipantsHeader';
17
+ export { ALIA_LEAD, leadOf } from './lib/participants';
18
+ export type { AliaChatParticipants, AliaLead, AliaParticipant } from './lib/participants';
19
+ export { ANCHORS, leadToProfile } from './lib/flights';
20
+ export { parseCatalogueTool, toolRendererKey } from './lib/tool-renderers';
21
+ export type { AliaToolRenderers, AliaToolResult } from './lib/tool-renderers';
22
+ export { AliaAskProvider, useAskAgent } from './components/AliaAskProvider';
23
+ export type { AliaAsk, AliaAskAgents, AliaAskProviderProps } from './components/AliaAskProvider';
24
+ export { AliaSharedConversation } from './components/AliaSharedConversation';
25
+ export type { AliaSharedConversationProps } from './components/AliaSharedConversation';
26
+ export type { AliaAddMenu } from './lib/add-menu';
27
+ export type { AliaVoiceOptions } from './hooks/useChatVoice';
7
28
  export { AliaChatSheet } from './components/AliaChatSheet';
8
29
  export type { AliaChatSheetProps, AliaChatSheetRef } from './components/AliaChatSheet';
9
- export type { AliaTurn } from './components/AliaChatContent';
30
+ export type { AliaTurn } from './hooks/useChatComposer';
10
31
  export { AliaChatScreen } from './components/AliaChatScreen';
11
32
  export type { AliaChatScreenProps } from './components/AliaChatScreen';
12
33
 
13
34
  // ── Chat UI components ──
14
35
  export { ALIA_CHARACTER_RUNTIME_URL } from './lib/character-runtime';
15
36
  export { DEFAULT_LABELS } from './lib/labels';
16
- export type { AliaChatLabels } from './lib/labels';
37
+ export { AliaChatLabelsProvider, useLabels } from './lib/labels';
38
+ export type { AliaChatLabels, VoiceBlockReason } from './lib/labels';
17
39
  export { VoiceModeIcon } from './components/VoiceModeIcon';
18
40
  export { AmbientField, PARALLAX_DURATION, PARALLAX_EASE } from './components/AmbientField';
19
41
  export type { AmbientFieldProps } from './components/AmbientField';
@@ -27,6 +49,16 @@ export { IdentityMark } from './components/IdentityMark';
27
49
  export type { IdentityMarkProps, IdentityMarkState } from './components/IdentityMark';
28
50
  export { AliaMarkdown } from './components/Markdown';
29
51
  export type { AliaMarkdownProps, RenderCodeBlock } from './components/Markdown';
52
+ export { ReplyMarkdown } from './components/ReplyMarkdown';
53
+ export { AliaThread } from './components/thread/AliaThread';
54
+ export type { AliaThreadProps, NewConversationOfferProps } from './components/thread/AliaThread';
55
+ export { ThoughtPanel } from './components/thought/ThoughtPanel';
56
+ export type { ThoughtPanelProps } from './components/thought/ThoughtPanel';
57
+ export type { OutputActions } from './components/thought/FilesAndSources';
58
+ export { CanvasComponent } from './components/canvas/CanvasComponent';
59
+ export { restoreOpenerFocus } from './lib/focus-return';
60
+ export { buildPowerLevelOptions, POWER_LEVEL_ICONS } from './lib/power-level-options';
61
+ export type { DeviceModel } from './lib/power-level-options';
30
62
  export { AliaWelcomeMessage } from './components/AliaWelcomeMessage';
31
63
  export { PlanPreviewCard } from './components/PlanPreviewCard';
32
64
  export { AskedQuestions, QuestionCard } from './components/QuestionCard';
@@ -49,11 +81,8 @@ export {
49
81
  } from './lib/questions';
50
82
  export type { AskedOption, AskedQuestion, QuestionAnswer } from './lib/questions';
51
83
 
52
- // ── Chat hook ──
53
- export { useAliaChat } from './hooks/useAliaChat';
54
- export type { UseAliaChatOptions, UseAliaChatReturn } from './hooks/useAliaChat';
55
- export { AliaChatStreamError } from './lib/chat-stream';
56
- export type { AliaChatFailure } from './lib/chat-stream';
84
+ // ── Conversation runtime: the one data layer under the sheet, the screen and the Alia app ──
85
+ export * from './runtime';
57
86
 
58
87
  // ── Read aloud and dictation (no call, no audio engine) ──
59
88
  export { useTTS } from './hooks/useTTS';
@@ -67,36 +96,21 @@ export type { VoiceErrorCode } from './lib/speech-messages';
67
96
  export { SpeechClipError } from './lib/speech-synthesis';
68
97
  export type { SpeechClipErrorCode } from './lib/speech-synthesis';
69
98
 
70
- // ── Model catalogue (GET /catalogue) ──
71
- // Exported so a consumer building its own picker reads the real models the
72
- // server offers; the SDK itself ships no model identifier.
73
- export {
74
- fetchCatalogue,
75
- parseCatalogue,
76
- resolveSelection,
77
- resolveModelId,
78
- clearCatalogueCache,
79
- } from './lib/catalogue';
80
- export type { Catalogue, CatalogueEntry, ModelSelection, ReasoningEffort } from './lib/catalogue';
81
-
82
99
  // ── Types ──
83
100
  export type {
84
- ChatMessage,
85
- ToolInvocation,
86
101
  AliaAttachment,
87
102
  AliaSuggestion,
88
103
  ResearchProgress,
89
104
  ResearchSource,
90
- PendingPlan,
105
+ MessagePlan,
91
106
  PlanStep,
92
107
  } from './types';
93
108
 
94
- // ── Voice capability contract (the implementation lives in `./voice`) ──
95
- export type {
96
- VoiceSessionComponent,
97
- VoiceSessionProps,
98
- VoiceSessionState,
99
- } from './types';
109
+ // ── Voice: part of the conversation screen; the host brings callbacks (see `AliaVoiceOptions`) ──
110
+ export type { VoiceNotice, VoiceSessionProps, VoiceSessionState } from './types';
111
+ export { AliaVoiceCall } from './components/voice/AliaVoiceCall';
112
+ export { useVoiceLauncher, toastNotice } from './hooks/useVoiceLauncher';
113
+ export type { UseVoiceLauncherOptions, VoiceLauncher } from './hooks/useVoiceLauncher';
100
114
 
101
115
  // ── UI Primitives (NativeWind) ──
102
116
  export { cn, formatFileSize } from './lib/utils';
@@ -6,7 +6,7 @@ type Sources = Readonly<Record<string, AliaAvatarState>>;
6
6
  /**
7
7
  * What Alia is doing, from every place that can know. Two sources feed it and neither owns it:
8
8
  *
9
- * - `local`: the turns running in THIS app, one entry per `useAliaChat` instance (the sheet);
9
+ * - `local`: the turns running in THIS app, one entry per `useConversationThread` instance;
10
10
  * - `remote`: runs the server reports for the person, one entry per agent session, so a button
11
11
  * reacts to work that is not in any open chat.
12
12
  *
@@ -0,0 +1,7 @@
1
+ import type { ComposerPanelAddMenuGroup } from '@oxy.so/bloom/composer-panel';
2
+
3
+ /** The rows of the composer's add menu, and what pressing one does. */
4
+ export interface AliaAddMenu {
5
+ groups: readonly ComposerPanelAddMenuGroup[];
6
+ onSelect: (rowId: string) => void;
7
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * What the thread shows while a call is open: the text messages it held when
3
+ * the call started, then the call's messages.
4
+ *
5
+ * A call message the server has stored is shown AS STORED — the copy a reload
6
+ * or a resume shows, with its tools' arguments and results, which the call's
7
+ * socket never carries. Same id either way (the call names every message by the
8
+ * id it is stored under), so swapping one for the other never adds a message.
9
+ */
10
+
11
+ import type { Message } from '../runtime/types';
12
+ import type { VoiceMessage } from '../types';
13
+
14
+ export function callThread(
15
+ before: readonly Message[],
16
+ call: readonly VoiceMessage[],
17
+ stored: readonly Message[] | undefined,
18
+ ): Message[] {
19
+ const storedById = new Map((stored ?? []).map((message) => [message.id, message]));
20
+ return [...before, ...call.map((message) => storedById.get(message.id) ?? fromCall(message))];
21
+ }
22
+
23
+ function fromCall(message: VoiceMessage): Message {
24
+ return {
25
+ id: message.id,
26
+ role: message.role,
27
+ content: message.content,
28
+ toolInvocations: message.toolInvocations,
29
+ createdAt: new Date(message.timestamp).toISOString(),
30
+ source: 'voice' as const,
31
+ speaker: message.speaker,
32
+ };
33
+ }
@@ -1,10 +1,12 @@
1
+ import { ALIA_WEB_ORIGIN } from './origins';
2
+
1
3
  /**
2
4
  * Where the 3D character runtime (Alia's avatar) is served from when the host does not say.
3
5
  *
4
6
  * The runtime is ~214 MB of engine and data, so the chat loads it from Alia's own web origin
5
7
  * instead of asking every app to host a copy. That origin serves it with CORS open
6
- * (`packages/app/worker/index.js`), which a cross-origin module import needs; an app with a
7
- * strict CSP allows it in `script-src` and `connect-src`. A host that serves its own copy
8
- * passes `characterRuntimeUrl`.
8
+ * (`packages/app/worker/index.js`), which a cross-origin module import needs; a host with a
9
+ * CSP allows it with `@alia.onl/sdk/csp`. A host that serves its own copy passes
10
+ * `characterRuntimeUrl`.
9
11
  */
10
- export const ALIA_CHARACTER_RUNTIME_URL = 'https://alia.onl/bloom-character/runtime.mjs';
12
+ export const ALIA_CHARACTER_RUNTIME_URL = `${ALIA_WEB_ORIGIN}/bloom-character/runtime.mjs`;