@alia.onl/sdk 2.0.0 → 3.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.
@@ -1,8 +1,10 @@
1
- import React, { useRef, useEffect } from 'react';
1
+ import React, { useRef, useEffect, useState, useCallback } from 'react';
2
2
  import {
3
3
  View,
4
4
  Text,
5
+ Pressable,
5
6
  StyleSheet,
7
+ Platform,
6
8
  } from 'react-native';
7
9
  import { BlurView } from 'expo-blur';
8
10
  import Animated, {
@@ -25,10 +27,15 @@ import type { ChatMessage, ToolInvocation } from '../types';
25
27
 
26
28
  const ENTER_ANIMATION = FadeInUp.duration(200);
27
29
 
28
- interface AliaChatMessageListProps {
30
+ type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
31
+
32
+ export interface AliaChatMessageListProps {
29
33
  messages: ChatMessage[];
30
34
  isStreaming: boolean;
31
35
  scrollOffsetY: SharedValue<number>;
36
+ onReadAloud?: (messageId: string, text: string) => void;
37
+ ttsActiveMessageId?: string | null;
38
+ ttsPlaybackState?: PlaybackState;
32
39
  }
33
40
 
34
41
  // ── Tool Bullet ──
@@ -36,7 +43,6 @@ interface AliaChatMessageListProps {
36
43
  function ToolBullet({ invocation }: { invocation: ToolInvocation }) {
37
44
  const colors = useAliaColors();
38
45
 
39
- // Clean up tool name for display: "oxy_inbox__searchEmails" → "Searching emails"
40
46
  const displayName = invocation.toolName
41
47
  .replace(/^oxy_\w+__/, '')
42
48
  .replace(/([A-Z])/g, ' $1')
@@ -99,14 +105,107 @@ function UserBubble({ content }: { content: string }) {
99
105
  );
100
106
  }
101
107
 
108
+ // ── Message Actions ──
109
+
110
+ function MessageActions({
111
+ message,
112
+ onReadAloud,
113
+ ttsActiveMessageId,
114
+ ttsPlaybackState,
115
+ }: {
116
+ message: ChatMessage;
117
+ onReadAloud?: (messageId: string, text: string) => void;
118
+ ttsActiveMessageId?: string | null;
119
+ ttsPlaybackState?: PlaybackState;
120
+ }) {
121
+ const colors = useAliaColors();
122
+ const [copied, setCopied] = useState(false);
123
+
124
+ const handleCopy = useCallback(async () => {
125
+ try {
126
+ if (Platform.OS === 'web') {
127
+ await navigator.clipboard.writeText(message.content);
128
+ } else {
129
+ const Clipboard = await import('expo-clipboard');
130
+ await Clipboard.setStringAsync(message.content);
131
+ }
132
+ setCopied(true);
133
+ setTimeout(() => setCopied(false), 2000);
134
+ } catch {}
135
+ }, [message.content]);
136
+
137
+ const isThisPlaying = ttsActiveMessageId === message.id && ttsPlaybackState === 'playing';
138
+ const isThisLoading = ttsActiveMessageId === message.id && ttsPlaybackState === 'loading';
139
+
140
+ if (!message.content) return null;
141
+
142
+ return (
143
+ <View style={styles.actionsRow}>
144
+ {/* Copy */}
145
+ <Pressable onPress={handleCopy} style={styles.actionBtn} hitSlop={8}>
146
+ {copied ? (
147
+ <Text style={[styles.actionIcon, { color: colors.tint }]}>{'\u2713'}</Text>
148
+ ) : (
149
+ <CopyIcon color={colors.secondaryText} />
150
+ )}
151
+ </Pressable>
152
+
153
+ {/* Read aloud */}
154
+ {onReadAloud && (
155
+ <Pressable
156
+ onPress={() => onReadAloud(message.id, message.content)}
157
+ style={styles.actionBtn}
158
+ hitSlop={8}
159
+ >
160
+ {isThisLoading ? (
161
+ <Text style={[styles.actionIcon, { color: colors.secondaryText }]}>{'\u2026'}</Text>
162
+ ) : (
163
+ <VolumeIcon color={isThisPlaying ? colors.tint : colors.secondaryText} />
164
+ )}
165
+ </Pressable>
166
+ )}
167
+ </View>
168
+ );
169
+ }
170
+
171
+ function CopyIcon({ color }: { color: string }) {
172
+ if (Platform.OS === 'web') {
173
+ return (
174
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
175
+ <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
176
+ <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
177
+ </svg>
178
+ );
179
+ }
180
+ return <Text style={[styles.actionIcon, { color }]}>{'\u2398'}</Text>;
181
+ }
182
+
183
+ function VolumeIcon({ color }: { color: string }) {
184
+ if (Platform.OS === 'web') {
185
+ return (
186
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
187
+ <path d="M11 5L6 9H2v6h4l5 4V5z" />
188
+ <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
189
+ </svg>
190
+ );
191
+ }
192
+ return <Text style={[styles.actionIcon, { color }]}>{'\u{1F50A}'}</Text>;
193
+ }
194
+
102
195
  // ── Assistant Message ──
103
196
 
104
197
  function AssistantMessage({
105
198
  message,
106
199
  isStreamingThis,
200
+ onReadAloud,
201
+ ttsActiveMessageId,
202
+ ttsPlaybackState,
107
203
  }: {
108
204
  message: ChatMessage;
109
205
  isStreamingThis: boolean;
206
+ onReadAloud?: (messageId: string, text: string) => void;
207
+ ttsActiveMessageId?: string | null;
208
+ ttsPlaybackState?: PlaybackState;
110
209
  }) {
111
210
  const colors = useAliaColors();
112
211
  const hasContent = !!message.content;
@@ -143,6 +242,16 @@ function AssistantMessage({
143
242
  {isStreamingThis && hasContent && (
144
243
  <Text style={[styles.cursor, { color: colors.secondaryText }]}>{'\u2758'}</Text>
145
244
  )}
245
+
246
+ {/* Message actions (copy, read aloud) — shown when not streaming */}
247
+ {!isStreamingThis && hasContent && (
248
+ <MessageActions
249
+ message={message}
250
+ onReadAloud={onReadAloud}
251
+ ttsActiveMessageId={ttsActiveMessageId}
252
+ ttsPlaybackState={ttsPlaybackState}
253
+ />
254
+ )}
146
255
  </Animated.View>
147
256
  );
148
257
  }
@@ -153,6 +262,9 @@ export function AliaChatMessageList({
153
262
  messages,
154
263
  isStreaming,
155
264
  scrollOffsetY,
265
+ onReadAloud,
266
+ ttsActiveMessageId,
267
+ ttsPlaybackState,
156
268
  }: AliaChatMessageListProps) {
157
269
  const scrollRef = useRef<Animated.ScrollView>(null);
158
270
 
@@ -196,6 +308,9 @@ export function AliaChatMessageList({
196
308
  key={msg.id}
197
309
  message={msg}
198
310
  isStreamingThis={isStreamingThis}
311
+ onReadAloud={onReadAloud}
312
+ ttsActiveMessageId={ttsActiveMessageId}
313
+ ttsPlaybackState={ttsPlaybackState}
199
314
  />
200
315
  );
201
316
  })}
@@ -261,4 +376,22 @@ const styles = StyleSheet.create({
261
376
  toolLabel: {
262
377
  fontSize: 13,
263
378
  },
379
+
380
+ // Message actions
381
+ actionsRow: {
382
+ flexDirection: 'row',
383
+ alignItems: 'center',
384
+ gap: 12,
385
+ marginTop: 6,
386
+ opacity: 0.7,
387
+ },
388
+ actionBtn: {
389
+ width: 28,
390
+ height: 28,
391
+ alignItems: 'center',
392
+ justifyContent: 'center',
393
+ },
394
+ actionIcon: {
395
+ fontSize: 14,
396
+ },
264
397
  });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Full-screen Alia chat component for use as a route/screen in Oxy ecosystem apps.
3
+ *
4
+ * Usage:
5
+ * ```tsx
6
+ * import { AliaChatScreen } from '@alia.onl/sdk';
7
+ *
8
+ * <AliaChatScreen
9
+ * clientContext="Notes app — user is viewing note #42"
10
+ * suggestions={[{ label: 'Summarize', prompt: 'Summarize this note' }]}
11
+ * headerLeft={<BackButton />}
12
+ * />
13
+ * ```
14
+ */
15
+
16
+ import React, { useRef, useState, useEffect, useCallback } from 'react';
17
+ import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
18
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
19
+ import { AliaChatContent, type AliaChatContentRef } from './AliaChatContent';
20
+ import { AliaFace, type AliaExpression } from './AliaFace';
21
+ import { useAliaColors } from '../theme';
22
+ import type { AliaChatSuggestion } from '../types';
23
+
24
+ export interface AliaChatScreenProps {
25
+ /** App context injected into system prompt */
26
+ clientContext?: string;
27
+ /** Quick action suggestions shown when chat is empty */
28
+ suggestions?: AliaChatSuggestion[];
29
+ /** Alia model (default: 'alia-v1') */
30
+ model?: string;
31
+ /** API URL override */
32
+ apiUrl?: string;
33
+ /** Optional header left action (e.g., back button) */
34
+ headerLeft?: React.ReactNode;
35
+ /** Optional header right action (e.g., settings button) */
36
+ headerRight?: React.ReactNode;
37
+ }
38
+
39
+ export function AliaChatScreen({
40
+ clientContext,
41
+ suggestions = [],
42
+ model,
43
+ apiUrl,
44
+ headerLeft,
45
+ headerRight,
46
+ }: AliaChatScreenProps) {
47
+ const colors = useAliaColors();
48
+ const insets = useSafeAreaInsets();
49
+ const contentRef = useRef<AliaChatContentRef>(null);
50
+
51
+ const [faceExpression, setFaceExpression] = useState<AliaExpression>('Idle A');
52
+ const [hasMessages, setHasMessages] = useState(false);
53
+
54
+ // Poll content ref for header state
55
+ useEffect(() => {
56
+ const interval = setInterval(() => {
57
+ if (contentRef.current) {
58
+ setFaceExpression(contentRef.current.faceExpression);
59
+ setHasMessages(contentRef.current.messages.length > 0);
60
+ }
61
+ }, 200);
62
+ return () => clearInterval(interval);
63
+ }, []);
64
+
65
+ const handleClear = useCallback(() => {
66
+ contentRef.current?.clear();
67
+ }, []);
68
+
69
+ return (
70
+ <View style={[styles.container, { backgroundColor: colors.background, paddingTop: insets.top }]}>
71
+ {/* Header */}
72
+ <View style={styles.header}>
73
+ <View style={styles.headerLeft}>
74
+ {headerLeft}
75
+ <AliaFace size={28} expression={faceExpression} />
76
+ <Text style={[styles.headerTitle, { color: colors.text }]}>Alia</Text>
77
+ </View>
78
+ <View style={styles.headerRight}>
79
+ {hasMessages && (
80
+ <TouchableOpacity onPress={handleClear} style={styles.clearButton}>
81
+ <Text style={[styles.clearText, { color: colors.secondaryText }]}>Clear</Text>
82
+ </TouchableOpacity>
83
+ )}
84
+ {headerRight}
85
+ </View>
86
+ </View>
87
+
88
+ {/* Chat content */}
89
+ <AliaChatContent
90
+ ref={contentRef}
91
+ clientContext={clientContext}
92
+ suggestions={suggestions}
93
+ model={model}
94
+ apiUrl={apiUrl}
95
+ />
96
+ </View>
97
+ );
98
+ }
99
+
100
+ const styles = StyleSheet.create({
101
+ container: {
102
+ flex: 1,
103
+ },
104
+ header: {
105
+ flexDirection: 'row',
106
+ alignItems: 'center',
107
+ justifyContent: 'space-between',
108
+ paddingHorizontal: 16,
109
+ paddingVertical: 12,
110
+ },
111
+ headerLeft: {
112
+ flexDirection: 'row',
113
+ alignItems: 'center',
114
+ gap: 10,
115
+ },
116
+ headerTitle: {
117
+ fontSize: 18,
118
+ fontWeight: '600',
119
+ },
120
+ headerRight: {
121
+ flexDirection: 'row',
122
+ alignItems: 'center',
123
+ gap: 8,
124
+ },
125
+ clearButton: {
126
+ paddingHorizontal: 10,
127
+ paddingVertical: 6,
128
+ },
129
+ clearText: {
130
+ fontSize: 14,
131
+ },
132
+ });
@@ -27,10 +27,7 @@ import Animated, {
27
27
  withTiming,
28
28
  } from 'react-native-reanimated';
29
29
  import { useSafeAreaInsets } from 'react-native-safe-area-context';
30
- import { useAliaChat, type UseAliaChatOptions } from '../hooks/useAliaChat';
31
- import { AliaChatMessageList } from './AliaChatMessageList';
32
- import { AliaChatInput } from './AliaChatInput';
33
- import { AliaChatSuggestions } from './AliaChatSuggestions';
30
+ import { AliaChatContent, type AliaChatContentRef } from './AliaChatContent';
34
31
  import { AliaFace, type AliaExpression } from './AliaFace';
35
32
  import { useAliaColors } from '../theme';
36
33
  import type { AliaChatSuggestion } from '../types';
@@ -65,15 +62,28 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
65
62
  const isDark = colors.isDark;
66
63
  const insets = useSafeAreaInsets();
67
64
 
68
- // Chat
69
- const chatOptions: UseAliaChatOptions = { apiUrl, model, clientContext };
70
- const { messages, send, isStreaming, clear } = useAliaChat(chatOptions);
65
+ // Content ref for reading state
66
+ const contentRef = useRef<AliaChatContentRef>(null);
67
+ const [faceExpression, setFaceExpression] = useState<AliaExpression>('Idle A');
68
+ const [hasMessages, setHasMessages] = useState(false);
71
69
 
72
70
  // Sheet visibility
73
71
  const [rendered, setRendered] = useState(false);
74
72
  const hasClosedRef = useRef(false);
75
73
  const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
76
74
 
75
+ // Poll content ref for header state (lightweight, only when rendered)
76
+ useEffect(() => {
77
+ if (!rendered) return;
78
+ const interval = setInterval(() => {
79
+ if (contentRef.current) {
80
+ setFaceExpression(contentRef.current.faceExpression);
81
+ setHasMessages(contentRef.current.messages.length > 0);
82
+ }
83
+ }, 200);
84
+ return () => clearInterval(interval);
85
+ }, [rendered]);
86
+
77
87
  // Reanimated shared values
78
88
  const translateY = useSharedValue(SCREEN_HEIGHT);
79
89
  const backdropOpacity = useSharedValue(0);
@@ -200,16 +210,9 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
200
210
  [insets.top],
201
211
  );
202
212
 
203
- // Derive AliaFace expression from chat state
204
- const faceExpression = useMemo<AliaExpression>(() => {
205
- if (!isStreaming) return 'Idle A';
206
- const lastMsg = messages[messages.length - 1];
207
- if (lastMsg?.role === 'assistant' && lastMsg.toolInvocations?.some(t => t.state === 'call')) return 'Searching A';
208
- if (lastMsg?.thinking) return 'Thinking';
209
- return 'Writing E';
210
- }, [messages, isStreaming]);
211
-
212
- const showSuggestions = messages.length === 0 && suggestions.length > 0;
213
+ const handleClear = useCallback(() => {
214
+ contentRef.current?.clear();
215
+ }, []);
213
216
 
214
217
  if (!rendered) return null;
215
218
 
@@ -256,8 +259,8 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
256
259
  </Text>
257
260
  </View>
258
261
  <View style={styles.headerRight}>
259
- {messages.length > 0 && (
260
- <TouchableOpacity onPress={clear} style={styles.clearButton}>
262
+ {hasMessages && (
263
+ <TouchableOpacity onPress={handleClear} style={styles.clearButton}>
261
264
  <Text
262
265
  style={[
263
266
  styles.clearText,
@@ -286,26 +289,17 @@ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
286
289
  </View>
287
290
  </View>
288
291
 
289
- {/* Suggestions or Messages */}
292
+ {/* Chat content */}
290
293
  <GestureDetector gesture={nativeGesture}>
291
- <View style={styles.chatArea}>
292
- {showSuggestions ? (
293
- <AliaChatSuggestions
294
- suggestions={suggestions}
295
- onSelect={send}
296
- />
297
- ) : (
298
- <AliaChatMessageList
299
- messages={messages}
300
- isStreaming={isStreaming}
301
- scrollOffsetY={scrollOffsetY}
302
- />
303
- )}
304
- </View>
294
+ <AliaChatContent
295
+ ref={contentRef}
296
+ clientContext={clientContext}
297
+ suggestions={suggestions}
298
+ model={model}
299
+ apiUrl={apiUrl}
300
+ scrollOffsetY={scrollOffsetY}
301
+ />
305
302
  </GestureDetector>
306
-
307
- {/* Input */}
308
- <AliaChatInput onSend={send} isStreaming={isStreaming} />
309
303
  </Animated.View>
310
304
  </GestureDetector>
311
305
  </GestureHandlerRootView>
@@ -384,7 +378,4 @@ const styles = StyleSheet.create({
384
378
  justifyContent: 'center',
385
379
  borderRadius: 16,
386
380
  },
387
- chatArea: {
388
- flex: 1,
389
- },
390
381
  });
@@ -0,0 +1,104 @@
1
+ import { cva, type VariantProps } from "class-variance-authority";
2
+ import * as React from "react";
3
+ import { Pressable, ActivityIndicator } from "react-native";
4
+ import { TextClassContext } from "./text";
5
+ import { cn } from "../../lib/utils";
6
+
7
+ const buttonVariants = cva(
8
+ "group flex items-center justify-center rounded-xl web:ring-offset-background web:transition-colors web:focus-visible:outline-none web:focus-visible:ring-2 web:focus-visible:ring-ring web:focus-visible:ring-offset-2",
9
+ {
10
+ variants: {
11
+ variant: {
12
+ default: "bg-primary web:hover:opacity-90 active:opacity-90",
13
+ destructive: "bg-destructive web:hover:opacity-90 active:opacity-90",
14
+ outline:
15
+ "border border-input bg-background web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent",
16
+ secondary: "bg-secondary web:hover:opacity-80 active:opacity-80",
17
+ ghost:
18
+ "web:hover:bg-accent web:hover:text-accent-foreground active:bg-accent",
19
+ link: "web:underline-offset-4 web:hover:underline web:focus:underline ",
20
+ pill: "text-primary-foreground rounded-full bg-black web:hover:opacity-90 active:opacity-90",
21
+ },
22
+ size: {
23
+ default: "h-9 px-3.5 py-2 native:h-11 native:px-4",
24
+ sm: "h-8 rounded-lg px-2.5",
25
+ lg: "h-10 rounded-xl px-6 native:h-12",
26
+ icon: "h-9 w-9",
27
+ },
28
+ },
29
+ defaultVariants: {
30
+ variant: "default",
31
+ size: "default",
32
+ },
33
+ },
34
+ );
35
+
36
+ const buttonTextVariants = cva(
37
+ "web:whitespace-nowrap text-sm native:text-base font-medium text-foreground web:transition-colors",
38
+ {
39
+ variants: {
40
+ variant: {
41
+ default: "text-primary-foreground",
42
+ destructive: "text-destructive-foreground",
43
+ outline: "group-active:text-accent-foreground",
44
+ secondary:
45
+ "text-secondary-foreground group-active:text-secondary-foreground",
46
+ ghost: "group-active:text-accent-foreground",
47
+ link: "text-primary group-active:underline",
48
+ pill: "text-primary-foreground",
49
+ },
50
+ size: {
51
+ default: "",
52
+ sm: "",
53
+ lg: "native:text-lg",
54
+ icon: "",
55
+ },
56
+ },
57
+ defaultVariants: {
58
+ variant: "default",
59
+ size: "default",
60
+ },
61
+ },
62
+ );
63
+
64
+ type ButtonProps = React.ComponentPropsWithoutRef<typeof Pressable> &
65
+ VariantProps<typeof buttonVariants> & {
66
+ isLoading?: boolean;
67
+ };
68
+
69
+ const Button = React.forwardRef<
70
+ React.ElementRef<typeof Pressable>,
71
+ ButtonProps
72
+ >(({ className, variant, size, isLoading, children, ...props }, ref) => {
73
+ return (
74
+ <TextClassContext.Provider
75
+ value={cn(
76
+ props.disabled && "web:pointer-events-none",
77
+ buttonTextVariants({ variant, size }),
78
+ )}
79
+ >
80
+ <Pressable
81
+ className={cn(
82
+ props.disabled && "opacity-50 web:pointer-events-none",
83
+ isLoading && "web:pointer-events-none",
84
+ buttonVariants({ variant, size, className }),
85
+ )}
86
+ ref={ref}
87
+ role="button"
88
+ {...props}
89
+ >
90
+ {isLoading ? (
91
+ <ActivityIndicator
92
+ color={variant === "outline" ? "black" : "white"}
93
+ />
94
+ ) : (
95
+ children
96
+ )}
97
+ </Pressable>
98
+ </TextClassContext.Provider>
99
+ );
100
+ });
101
+ Button.displayName = "Button";
102
+
103
+ export { Button, buttonTextVariants, buttonVariants };
104
+ export type { ButtonProps };