@alia.onl/sdk 3.1.7 → 4.0.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/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "@alia.onl/sdk",
3
- "version": "3.1.7",
3
+ "version": "4.0.1",
4
4
  "description": "Alia AI chat SDK — reusable chat UI, voice, and streaming components for Oxy apps",
5
5
  "main": "src/index.ts",
6
6
  "react-native": "src/index.ts",
7
7
  "source": "src/index.ts",
8
8
  "types": "src/index.ts",
9
+ "exports": {
10
+ ".": "./src/index.ts",
11
+ "./voice": "./src/voice.ts",
12
+ "./package.json": "./package.json"
13
+ },
9
14
  "files": [
10
15
  "src",
11
16
  "tsconfig.json"
@@ -37,8 +37,14 @@ type ExpoImageProps = {
37
37
  className?: string;
38
38
  contentFit?: string;
39
39
  };
40
+ // expo-image is an optional peer dependency: SDK-only consumers that never render
41
+ // inline images may not install it. Degrade to skipping images when it can't be required.
40
42
  let ExpoImage: React.ComponentType<ExpoImageProps> | null = null;
41
- try { ExpoImage = require('expo-image').Image; } catch {}
43
+ try {
44
+ ExpoImage = require('expo-image').Image;
45
+ } catch {
46
+ // Optional peer dependency absent — inline images are simply not rendered.
47
+ }
42
48
 
43
49
  type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
44
50
 
@@ -93,7 +99,7 @@ function ToolBullet({ isRunning }: { isRunning: boolean }) {
93
99
 
94
100
  // ── User Bubble ──
95
101
 
96
- function UserBubble({
102
+ const UserBubble = React.memo(function UserBubble({
97
103
  message,
98
104
  text,
99
105
  isEditing,
@@ -111,10 +117,10 @@ function UserBubble({
111
117
  isEditing: boolean;
112
118
  editedContent: string;
113
119
  onEditedContentChange: (text: string) => void;
114
- onSaveEdit: () => void;
120
+ onSaveEdit: (messageId: string) => void;
115
121
  onCancelEdit: () => void;
116
- onStartEdit: () => void;
117
- onCopy: () => void;
122
+ onStartEdit: (messageId: string, content: string) => void;
123
+ onCopy: (text: string, messageId: string) => void;
118
124
  copiedMessageId: string | null;
119
125
  showEditButton: boolean;
120
126
  }) {
@@ -136,7 +142,7 @@ function UserBubble({
136
142
  <View className="flex-row gap-2 mt-2">
137
143
  <Pressable
138
144
  className="px-3 py-1.5 rounded-lg bg-primary"
139
- onPress={onSaveEdit}
145
+ onPress={() => onSaveEdit(message.id)}
140
146
  >
141
147
  <Text className="text-xs text-primary-foreground">Save</Text>
142
148
  </Pressable>
@@ -179,7 +185,7 @@ function UserBubble({
179
185
  <View className="flex-row gap-1">
180
186
  <Pressable
181
187
  className="p-1.5 rounded-lg active:bg-muted"
182
- onPress={onCopy}
188
+ onPress={() => onCopy(text, message.id)}
183
189
  >
184
190
  {copiedMessageId === message.id ? (
185
191
  <Check size={14} className="text-green-500" />
@@ -190,7 +196,7 @@ function UserBubble({
190
196
  {showEditButton && (
191
197
  <Pressable
192
198
  className="p-1.5 rounded-lg active:bg-muted"
193
- onPress={onStartEdit}
199
+ onPress={() => onStartEdit(message.id, text)}
194
200
  >
195
201
  <Pencil size={14} className="text-muted-foreground" />
196
202
  </Pressable>
@@ -199,11 +205,11 @@ function UserBubble({
199
205
  )}
200
206
  </View>
201
207
  );
202
- }
208
+ });
203
209
 
204
210
  // ── Assistant Message ──
205
211
 
206
- function AssistantMessage({
212
+ const AssistantMessage = React.memo(function AssistantMessage({
207
213
  message,
208
214
  messageText,
209
215
  isStreamingThis,
@@ -229,7 +235,7 @@ function AssistantMessage({
229
235
  onReadAloud?: (messageId: string, text: string) => void;
230
236
  ttsActiveMessageId?: string | null;
231
237
  ttsPlaybackState?: PlaybackState;
232
- onCopy: () => void;
238
+ onCopy: (text: string, messageId: string) => void;
233
239
  copiedMessageId: string | null;
234
240
  onThumbsUp?: (messageId: string) => void;
235
241
  onThumbsDown?: (messageId: string) => void;
@@ -377,7 +383,7 @@ function AssistantMessage({
377
383
  )}
378
384
  <Pressable
379
385
  className="p-1.5 rounded-lg active:bg-muted"
380
- onPress={onCopy}
386
+ onPress={() => onCopy(messageText, message.id)}
381
387
  >
382
388
  {copiedMessageId === message.id ? (
383
389
  <Check size={14} className="text-green-500" />
@@ -403,7 +409,7 @@ function AssistantMessage({
403
409
  {thinkingIndicator}
404
410
  </View>
405
411
  );
406
- }
412
+ });
407
413
 
408
414
  // ── List ──
409
415
 
@@ -460,7 +466,9 @@ export function AliaChatMessageList({
460
466
  }
461
467
  setCopiedMessageId(messageId);
462
468
  setTimeout(() => setCopiedMessageId(null), 2000);
463
- } catch {}
469
+ } catch (err) {
470
+ console.warn('[AliaChat] Failed to copy message to clipboard:', err);
471
+ }
464
472
  }, []);
465
473
 
466
474
  const handleStartEdit = useCallback((messageId: string, content: string) => {
@@ -520,12 +528,12 @@ export function AliaChatMessageList({
520
528
  message={msg}
521
529
  text={messageText}
522
530
  isEditing={editingMessageId === msg.id}
523
- editedContent={editedContent}
531
+ editedContent={editingMessageId === msg.id ? editedContent : ''}
524
532
  onEditedContentChange={setEditedContent}
525
- onSaveEdit={() => handleSaveEdit(msg.id)}
533
+ onSaveEdit={handleSaveEdit}
526
534
  onCancelEdit={handleCancelEdit}
527
- onStartEdit={() => handleStartEdit(msg.id, messageText)}
528
- onCopy={() => handleCopy(messageText, msg.id)}
535
+ onStartEdit={handleStartEdit}
536
+ onCopy={handleCopy}
529
537
  copiedMessageId={copiedMessageId}
530
538
  showEditButton={!!onEditMessage}
531
539
  />
@@ -539,7 +547,7 @@ export function AliaChatMessageList({
539
547
  onReadAloud={onReadAloud}
540
548
  ttsActiveMessageId={ttsActiveMessageId}
541
549
  ttsPlaybackState={ttsPlaybackState}
542
- onCopy={() => handleCopy(messageText, msg.id)}
550
+ onCopy={handleCopy}
543
551
  copiedMessageId={copiedMessageId}
544
552
  onThumbsUp={onThumbsUp}
545
553
  onThumbsDown={onThumbsDown}
@@ -240,6 +240,72 @@ export interface AliaMarkdownProps {
240
240
  colors?: Partial<AliaColors>;
241
241
  }
242
242
 
243
+ const FENCE_RE = /^\s*(?:```|~~~)/;
244
+ const LIST_OR_TABLE_RE = /^\s*(?:[-*+]\s|\d+[.)]\s|\|)/;
245
+ // Link reference definitions ([1]: https://…) resolve per-parse, so a document
246
+ // using them cannot be split into independently parsed blocks.
247
+ const LINK_REF_DEF_RE = /^\s*\[[^\]]+\]:\s/m;
248
+
249
+ /**
250
+ * Split markdown into top-level blocks at blank-line boundaries, keeping
251
+ * fenced code, list runs, and tables intact. During streaming only the last
252
+ * block's text changes, so every completed block hits React.memo and skips
253
+ * its markdown-it re-parse — the per-flush cost drops from O(document) to
254
+ * O(trailing block).
255
+ */
256
+ function splitMarkdownBlocks(content: string): string[] {
257
+ if (LINK_REF_DEF_RE.test(content)) return [content];
258
+
259
+ const lines = content.split('\n');
260
+ const blocks: string[] = [];
261
+ let current: string[] = [];
262
+ let lastNonBlank = '';
263
+ let inFence = false;
264
+ let i = 0;
265
+
266
+ while (i < lines.length) {
267
+ const line = lines[i];
268
+ if (FENCE_RE.test(line)) {
269
+ inFence = !inFence;
270
+ current.push(line);
271
+ lastNonBlank = line;
272
+ i += 1;
273
+ continue;
274
+ }
275
+ if (!inFence && line.trim() === '') {
276
+ let j = i;
277
+ while (j < lines.length && lines[j].trim() === '') j += 1;
278
+ const next = j < lines.length ? lines[j] : '';
279
+ // Splitting a list or table at a blank line would restart ordered-list
280
+ // numbering and break table parsing — keep those runs in one block.
281
+ const keepTogether = LIST_OR_TABLE_RE.test(lastNonBlank) || LIST_OR_TABLE_RE.test(next);
282
+ if (keepTogether || current.length === 0) {
283
+ for (; i < j; i += 1) current.push(lines[i]);
284
+ } else {
285
+ blocks.push(current.join('\n'));
286
+ current = [];
287
+ i = j;
288
+ }
289
+ continue;
290
+ }
291
+ current.push(line);
292
+ lastNonBlank = line;
293
+ i += 1;
294
+ }
295
+ if (current.length > 0) blocks.push(current.join('\n'));
296
+ return blocks.length > 0 ? blocks : [content];
297
+ }
298
+
299
+ type MarkdownBlockProps = {
300
+ content: string;
301
+ rules: ReturnType<typeof createRules>;
302
+ styles: ReturnType<typeof createStyles>;
303
+ };
304
+
305
+ const MarkdownBlock = React.memo(function MarkdownBlock({ content, rules, styles }: MarkdownBlockProps) {
306
+ return <Markdown rules={rules} style={styles}>{content}</Markdown>;
307
+ });
308
+
243
309
  export function AliaMarkdown({ content, colors: colorOverrides }: AliaMarkdownProps) {
244
310
  const scheme = useColorScheme();
245
311
  const fallback = scheme === 'dark' ? FALLBACK_DARK : FALLBACK_LIGHT;
@@ -247,6 +313,20 @@ export function AliaMarkdown({ content, colors: colorOverrides }: AliaMarkdownPr
247
313
 
248
314
  const customRules = useMemo(() => createRules(colors), [colors.text, colors.muted, colors.border, colors.primary, colors.mutedForeground]);
249
315
  const markdownStyles = useMemo(() => createStyles(colors), [colors.text, colors.muted, colors.border, colors.primary, colors.mutedForeground]);
316
+ const blocks = useMemo(() => splitMarkdownBlocks(content), [content]);
317
+
318
+ if (blocks.length === 1) {
319
+ return <Markdown rules={customRules} style={markdownStyles}>{blocks[0]}</Markdown>;
320
+ }
250
321
 
251
- return <Markdown rules={customRules} style={markdownStyles}>{content}</Markdown>;
322
+ // Each block's `body` rule cancels its own trailing paragraph margin
323
+ // (marginBottom: -8), so rowGap restores the exact inter-paragraph spacing
324
+ // a single continuous parse would produce.
325
+ return (
326
+ <View style={{ rowGap: 8 }}>
327
+ {blocks.map((block, i) => (
328
+ <MarkdownBlock key={i} content={block} rules={customRules} styles={markdownStyles} />
329
+ ))}
330
+ </View>
331
+ );
252
332
  }
@@ -11,6 +11,15 @@ import {
11
11
  } from "react-native";
12
12
  import { cn } from "../../lib/utils";
13
13
 
14
+ // react-native-web forwards the DOM KeyboardEvent modifier flags on the key-press
15
+ // nativeEvent, but React Native's `TextInputKeyPressEventData` only declares `key`.
16
+ // Augment the type (web-only field, hence optional) so `shiftKey` is readable without a cast.
17
+ declare module "react-native" {
18
+ interface TextInputKeyPressEventData {
19
+ shiftKey?: boolean;
20
+ }
21
+ }
22
+
14
23
  type ChatTextInputProps = React.ComponentPropsWithoutRef<typeof TextInput> & {
15
24
  noFocus?: boolean;
16
25
  onEnterPress?: () => void;
@@ -54,13 +63,11 @@ const ChatTextInput = React.forwardRef<TextInput, ChatTextInputProps>(
54
63
  const handlePaste = (e: Event) => {
55
64
  const clipboardEvent = e as ClipboardEvent;
56
65
 
57
- // @ts-ignore - web-specific API
58
66
  const activeElement = document.activeElement;
59
- // @ts-ignore - web-specific API
60
67
  const wrapper = wrapperRef.current;
61
-
62
- // @ts-ignore - web-specific API
63
- const isContained = wrapper && wrapper.contains(activeElement);
68
+ // On web the wrapper ref resolves to its underlying DOM node — only react to
69
+ // pastes whose focused element lives inside this input.
70
+ const isContained = wrapper instanceof HTMLElement && wrapper.contains(activeElement);
64
71
 
65
72
  if (!isContained) return;
66
73
 
@@ -84,11 +91,9 @@ const ChatTextInput = React.forwardRef<TextInput, ChatTextInputProps>(
84
91
  }
85
92
  };
86
93
 
87
- // @ts-ignore - web-specific API
88
94
  document.addEventListener('paste', handlePaste);
89
95
 
90
96
  return () => {
91
- // @ts-ignore - web-specific API
92
97
  document.removeEventListener('paste', handlePaste);
93
98
  };
94
99
  }, [onImagePaste]);
@@ -108,7 +113,6 @@ const ChatTextInput = React.forwardRef<TextInput, ChatTextInputProps>(
108
113
  }
109
114
 
110
115
  if (key === "Enter" && !disableEnterToSubmit) {
111
- // @ts-ignore - shiftKey exists on web
112
116
  if (Platform.OS !== 'web' || !e.nativeEvent.shiftKey) {
113
117
  e.preventDefault();
114
118
  onEnterPress?.();
@@ -133,6 +133,9 @@ export function PromptInput({
133
133
 
134
134
  const handleSubmit = () => {
135
135
  onSubmit?.();
136
+ // Clicking the send button (as opposed to pressing Enter) shifts DOM focus
137
+ // to the button on web — restore it so the user can keep typing.
138
+ textareaRef.current?.focus();
136
139
  if (showFullscreen) setShowFullscreen(false);
137
140
  };
138
141
 
@@ -59,7 +59,11 @@ export function useSoundEffects(enabled: boolean = false, sounds: SoundSources =
59
59
  return () => {
60
60
  mounted = false;
61
61
  for (const player of playersRef.current.values()) {
62
- try { player.release(); } catch {}
62
+ try {
63
+ player.release();
64
+ } catch {
65
+ // Best-effort cleanup — a player already released/unloaded is safe to ignore.
66
+ }
63
67
  }
64
68
  playersRef.current.clear();
65
69
  loadedRef.current = false;
@@ -55,15 +55,16 @@ export function useTTS(options: UseTTSOptions = {}) {
55
55
  const tonePref = options.tone ?? 'default';
56
56
 
57
57
  const { oxyServices } = useOxy();
58
- const {
59
- activeMessageId,
60
- playbackState,
61
- error,
62
- setActiveMessage,
63
- setPlaybackState,
64
- setError,
65
- reset,
66
- } = useTTSStore();
58
+ // Per-field selectors: subscribing to the whole store re-renders every
59
+ // consumer on any playback transition. Actions are static store refs, so
60
+ // selecting them individually never triggers a re-render.
61
+ const activeMessageId = useTTSStore((s) => s.activeMessageId);
62
+ const playbackState = useTTSStore((s) => s.playbackState);
63
+ const error = useTTSStore((s) => s.error);
64
+ const setActiveMessage = useTTSStore((s) => s.setActiveMessage);
65
+ const setPlaybackState = useTTSStore((s) => s.setPlaybackState);
66
+ const setError = useTTSStore((s) => s.setError);
67
+ const reset = useTTSStore((s) => s.reset);
67
68
 
68
69
  const playerRef = useRef<AudioPlayer | null>(null);
69
70
 
@@ -108,7 +109,9 @@ export function useTTS(options: UseTTSOptions = {}) {
108
109
  const releasePlayer = useCallback(() => {
109
110
  try {
110
111
  playerRef.current?.remove();
111
- } catch {}
112
+ } catch {
113
+ // Best-effort teardown — the player may already be removed.
114
+ }
112
115
  playerRef.current = null;
113
116
  }, []);
114
117
 
@@ -127,6 +130,11 @@ export function useTTS(options: UseTTSOptions = {}) {
127
130
  playerRef.current = player;
128
131
 
129
132
  player.addListener('playbackStatusUpdate', (status: AudioStatus) => {
133
+ if (status.error) {
134
+ releasePlayer();
135
+ setError(status.error);
136
+ return;
137
+ }
130
138
  if (status.didJustFinish) {
131
139
  releasePlayer();
132
140
  reset();
@@ -147,6 +155,11 @@ export function useTTS(options: UseTTSOptions = {}) {
147
155
  conversationId?: string,
148
156
  audioUrl?: string,
149
157
  ) => {
158
+ // Read volatile playback state at call time (not via closure) so this
159
+ // callback's identity survives every playback transition. It is passed to
160
+ // memoized message rows — an unstable identity re-renders all of them.
161
+ const { activeMessageId, playbackState } = useTTSStore.getState();
162
+
150
163
  // If same message is playing, toggle pause/play
151
164
  if (activeMessageId === messageId) {
152
165
  if (playbackState === 'playing') {
@@ -212,7 +225,7 @@ export function useTTS(options: UseTTSOptions = {}) {
212
225
  console.error('[TTS] Error:', e);
213
226
  setError(errorMessage(e, 'Failed to read aloud'));
214
227
  }
215
- }, [activeMessageId, playbackState, getToken, apiUrl, getTTSVoice, getTTSSpeed, stop, playFromUrl, setActiveMessage, setPlaybackState, setError]);
228
+ }, [getToken, apiUrl, getTTSVoice, getTTSSpeed, stop, playFromUrl, setActiveMessage, setPlaybackState, setError]);
216
229
 
217
230
  // Cleanup on unmount
218
231
  useEffect(() => {
package/src/index.ts CHANGED
@@ -19,27 +19,15 @@ export { AliaWelcomeMessage } from './components/AliaWelcomeMessage';
19
19
  export { ResearchProgressCard } from './components/ResearchProgressCard';
20
20
  export { PlanPreviewCard } from './components/PlanPreviewCard';
21
21
 
22
- // ── Voice components ──
23
- export { AudioWaveVisualizer } from './components/voice/AudioWaveVisualizer';
24
- export { VoiceOverlay } from './components/voice/VoiceOverlay';
25
- export { VoiceControls } from './components/voice/VoiceControls';
26
-
27
22
  // ── Chat hook ──
28
23
  export { useAliaChat } from './hooks/useAliaChat';
29
24
  export type { UseAliaChatOptions, UseAliaChatReturn } from './hooks/useAliaChat';
30
25
 
31
- // ── Voice hooks ──
32
- export { useVoiceRoom } from './hooks/useVoiceRoom';
33
- export { useAudioLevelMonitor } from './hooks/useAudioLevelMonitor';
34
- export { useAudioLevels } from './hooks/useAudioLevels';
26
+ // ── Text-to-speech / speech-to-text (no livekit) ──
35
27
  export { useTTS } from './hooks/useTTS';
36
28
  export type { UseTTSOptions } from './hooks/useTTS';
37
29
  export { useSpeechToText, useSTTStore } from './hooks/useSpeechToText';
38
30
  export type { UseSTTOptions } from './hooks/useSpeechToText';
39
- export { useAmbientWave } from './hooks/useAmbientWave';
40
- export type { UseAmbientWaveOptions, UseAmbientWaveResult, AmbientWaveVoice, AmbientWaveMode } from './hooks/useAmbientWave';
41
- export { useSoundEffects, useVoiceSoundEffects } from './hooks/useSoundEffects';
42
- export type { SoundName, SoundSources } from './hooks/useSoundEffects';
43
31
 
44
32
  // ── Types ──
45
33
  export type {
@@ -50,10 +38,6 @@ export type {
50
38
  ResearchSource,
51
39
  PendingPlan,
52
40
  PlanStep,
53
- RoomState,
54
- AgentState,
55
- VoiceMessage,
56
- VoiceToolInvocation,
57
41
  } from './types';
58
42
 
59
43
  // ── UI Primitives (NativeWind) ──
package/src/voice.ts ADDED
@@ -0,0 +1,26 @@
1
+ // @alia.onl/sdk/voice — Alia voice surface
2
+ // LiveKit-backed voice room, audio visualisation, ambient wave, and sound effects.
3
+ // Split out of the root entry so text-chat consumers never pull livekit-client
4
+ // into their module graph.
5
+
6
+ // ── Voice components ──
7
+ export { AudioWaveVisualizer } from './components/voice/AudioWaveVisualizer';
8
+ export { VoiceOverlay } from './components/voice/VoiceOverlay';
9
+ export { VoiceControls } from './components/voice/VoiceControls';
10
+
11
+ // ── Voice hooks ──
12
+ export { useVoiceRoom } from './hooks/useVoiceRoom';
13
+ export { useAudioLevelMonitor } from './hooks/useAudioLevelMonitor';
14
+ export { useAudioLevels } from './hooks/useAudioLevels';
15
+ export { useAmbientWave } from './hooks/useAmbientWave';
16
+ export type { UseAmbientWaveOptions, UseAmbientWaveResult, AmbientWaveVoice, AmbientWaveMode } from './hooks/useAmbientWave';
17
+ export { useSoundEffects, useVoiceSoundEffects } from './hooks/useSoundEffects';
18
+ export type { SoundName, SoundSources } from './hooks/useSoundEffects';
19
+
20
+ // ── Voice types ──
21
+ export type {
22
+ RoomState,
23
+ AgentState,
24
+ VoiceMessage,
25
+ VoiceToolInvocation,
26
+ } from './types';