@alia.onl/sdk 1.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.
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@alia.onl/sdk",
3
+ "version": "1.0.0",
4
+ "description": "Alia AI chat SDK — reusable bottom sheet chat component for Oxy apps",
5
+ "main": "src/index.ts",
6
+ "react-native": "src/index.ts",
7
+ "source": "src/index.ts",
8
+ "types": "src/index.ts",
9
+ "private": false,
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "peerDependencies": {
14
+ "react": ">=18",
15
+ "react-native": ">=0.73",
16
+ "@oxyhq/services": ">=6.0.0",
17
+ "expo-blur": "*",
18
+ "react-native-reanimated": ">=3",
19
+ "react-native-gesture-handler": ">=2",
20
+ "react-native-safe-area-context": ">=4",
21
+ "react-native-keyboard-controller": ">=1"
22
+ },
23
+ "devDependencies": {
24
+ "typescript": "^5.9.3"
25
+ }
26
+ }
@@ -0,0 +1,147 @@
1
+ import React, { useState } from 'react';
2
+ import {
3
+ View,
4
+ TextInput,
5
+ TouchableOpacity,
6
+ StyleSheet,
7
+ ActivityIndicator,
8
+ Platform,
9
+ } from 'react-native';
10
+ import { useSafeAreaInsets } from 'react-native-safe-area-context';
11
+ import { useAliaColors } from '../theme';
12
+
13
+ interface AliaChatInputProps {
14
+ onSend: (text: string) => void;
15
+ isStreaming: boolean;
16
+ }
17
+
18
+ export function AliaChatInput({ onSend, isStreaming }: AliaChatInputProps) {
19
+ const [input, setInput] = useState('');
20
+ const colors = useAliaColors();
21
+ const insets = useSafeAreaInsets();
22
+ const isDark = colors.background === '#000000';
23
+
24
+ const canSend = input.trim().length > 0 && !isStreaming;
25
+
26
+ const handleSend = () => {
27
+ if (!canSend) return;
28
+ onSend(input.trim());
29
+ setInput('');
30
+ };
31
+
32
+ return (
33
+ <View
34
+ style={[
35
+ styles.container,
36
+ {
37
+ borderTopColor: colors.border,
38
+ paddingBottom: Math.max(insets.bottom, 12),
39
+ },
40
+ ]}
41
+ >
42
+ <TextInput
43
+ style={[
44
+ styles.input,
45
+ {
46
+ color: colors.text,
47
+ backgroundColor: isDark ? colors.inputBackground : colors.card,
48
+ },
49
+ ]}
50
+ value={input}
51
+ onChangeText={setInput}
52
+ placeholder="Ask Alia..."
53
+ placeholderTextColor={colors.secondaryText}
54
+ multiline
55
+ maxLength={2000}
56
+ onSubmitEditing={handleSend}
57
+ blurOnSubmit={false}
58
+ editable={!isStreaming}
59
+ />
60
+ <TouchableOpacity
61
+ style={[
62
+ styles.sendButton,
63
+ { backgroundColor: colors.tint },
64
+ !canSend && { opacity: 0.4 },
65
+ ]}
66
+ onPress={handleSend}
67
+ disabled={!canSend}
68
+ activeOpacity={0.7}
69
+ >
70
+ {isStreaming ? (
71
+ <ActivityIndicator size="small" color="#FFFFFF" />
72
+ ) : (
73
+ <ArrowUpIcon />
74
+ )}
75
+ </TouchableOpacity>
76
+ </View>
77
+ );
78
+ }
79
+
80
+ /** Simple arrow-up SVG as a component to avoid icon library dependency */
81
+ function ArrowUpIcon() {
82
+ // Use a unicode character for simplicity — works cross-platform
83
+ return (
84
+ <View style={styles.arrowIcon}>
85
+ {Platform.OS === 'web' ? (
86
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
87
+ <path d="M12 19V5M5 12l7-7 7 7" />
88
+ </svg>
89
+ ) : (
90
+ // React Native fallback — simple Text arrow
91
+ <View style={styles.arrowIconInner}>
92
+ <View style={[styles.arrowStem, { backgroundColor: '#FFFFFF' }]} />
93
+ <View style={[styles.arrowHead, { borderColor: '#FFFFFF' }]} />
94
+ </View>
95
+ )}
96
+ </View>
97
+ );
98
+ }
99
+
100
+ const styles = StyleSheet.create({
101
+ container: {
102
+ flexDirection: 'row',
103
+ alignItems: 'flex-end',
104
+ paddingHorizontal: 12,
105
+ paddingTop: 8,
106
+ gap: 8,
107
+ borderTopWidth: StyleSheet.hairlineWidth,
108
+ },
109
+ input: {
110
+ flex: 1,
111
+ fontSize: 15,
112
+ paddingHorizontal: 14,
113
+ paddingVertical: 10,
114
+ borderRadius: 20,
115
+ maxHeight: 100,
116
+ },
117
+ sendButton: {
118
+ width: 40,
119
+ height: 40,
120
+ borderRadius: 20,
121
+ alignItems: 'center',
122
+ justifyContent: 'center',
123
+ },
124
+ arrowIcon: {
125
+ width: 18,
126
+ height: 18,
127
+ alignItems: 'center',
128
+ justifyContent: 'center',
129
+ },
130
+ arrowIconInner: {
131
+ alignItems: 'center',
132
+ },
133
+ arrowStem: {
134
+ width: 2,
135
+ height: 12,
136
+ borderRadius: 1,
137
+ },
138
+ arrowHead: {
139
+ position: 'absolute',
140
+ top: -1,
141
+ width: 10,
142
+ height: 10,
143
+ borderTopWidth: 2.5,
144
+ borderLeftWidth: 2.5,
145
+ transform: [{ rotate: '45deg' }],
146
+ },
147
+ });
@@ -0,0 +1,211 @@
1
+ import React, { useRef, useEffect } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ StyleSheet,
6
+ Platform,
7
+ } from 'react-native';
8
+ import { BlurView } from 'expo-blur';
9
+ import Animated, { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated';
10
+ import { useAliaColors } from '../theme';
11
+ import type { ChatMessage, ToolInvocation } from '../types';
12
+
13
+ interface AliaChatMessageListProps {
14
+ messages: ChatMessage[];
15
+ isStreaming: boolean;
16
+ scrollOffsetY: Animated.SharedValue<number>;
17
+ }
18
+
19
+ function ToolStatus({ invocation }: { invocation: ToolInvocation }) {
20
+ const colors = useAliaColors();
21
+
22
+ // Clean up tool name for display: "oxy_inbox__searchEmails" → "Searching emails"
23
+ const displayName = invocation.toolName
24
+ .replace(/^oxy_\w+__/, '')
25
+ .replace(/([A-Z])/g, ' $1')
26
+ .replace(/^./, (s) => s.toUpperCase())
27
+ .trim();
28
+
29
+ const label =
30
+ invocation.state === 'call'
31
+ ? `${displayName}...`
32
+ : displayName;
33
+
34
+ return (
35
+ <View style={styles.toolStatus}>
36
+ {invocation.state === 'call' && (
37
+ <View style={[styles.toolDot, { backgroundColor: colors.tint }]} />
38
+ )}
39
+ {invocation.state === 'result' && (
40
+ <Text style={[styles.toolCheckmark, { color: colors.tint }]}>{'✓'}</Text>
41
+ )}
42
+ <Text style={[styles.toolLabel, { color: colors.secondaryText }]}>{label}</Text>
43
+ </View>
44
+ );
45
+ }
46
+
47
+ function UserBubble({ content }: { content: string }) {
48
+ const colors = useAliaColors();
49
+ const isDark = colors.background === '#000000';
50
+
51
+ return (
52
+ <View style={styles.userMessage}>
53
+ <View style={[styles.userBubble, { borderColor: colors.border }]}>
54
+ <BlurView
55
+ intensity={60}
56
+ tint={isDark ? 'dark' : 'light'}
57
+ style={StyleSheet.absoluteFill}
58
+ />
59
+ <Text style={[styles.userText, { color: colors.text }]}>{content}</Text>
60
+ </View>
61
+ </View>
62
+ );
63
+ }
64
+
65
+ function AssistantMessage({
66
+ message,
67
+ isFirst,
68
+ isStreamingThis,
69
+ }: {
70
+ message: ChatMessage;
71
+ isFirst: boolean;
72
+ isStreamingThis: boolean;
73
+ }) {
74
+ const colors = useAliaColors();
75
+
76
+ return (
77
+ <View style={styles.assistantMessage}>
78
+ {/* Tool invocations */}
79
+ {message.toolInvocations?.map((tool, i) => (
80
+ <ToolStatus key={`${tool.toolName}-${i}`} invocation={tool} />
81
+ ))}
82
+
83
+ {/* Text content */}
84
+ {(message.content || isStreamingThis) && (
85
+ <Text style={[styles.assistantText, { color: colors.text }]}>
86
+ {message.content || (isStreamingThis ? '\u2758' : '')}
87
+ </Text>
88
+ )}
89
+ </View>
90
+ );
91
+ }
92
+
93
+ export function AliaChatMessageList({
94
+ messages,
95
+ isStreaming,
96
+ scrollOffsetY,
97
+ }: AliaChatMessageListProps) {
98
+ const scrollRef = useRef<Animated.ScrollView>(null);
99
+
100
+ // Auto-scroll to bottom on new messages
101
+ useEffect(() => {
102
+ if (messages.length > 0) {
103
+ const timer = setTimeout(() => {
104
+ (scrollRef.current as any)?.scrollToEnd?.({ animated: true });
105
+ }, 100);
106
+ return () => clearTimeout(timer);
107
+ }
108
+ }, [messages]);
109
+
110
+ const scrollHandler = useAnimatedScrollHandler({
111
+ onScroll: (event) => {
112
+ scrollOffsetY.value = event.contentOffset.y;
113
+ },
114
+ });
115
+
116
+ return (
117
+ <Animated.ScrollView
118
+ ref={scrollRef}
119
+ style={styles.scrollView}
120
+ contentContainerStyle={styles.content}
121
+ showsVerticalScrollIndicator={false}
122
+ keyboardShouldPersistTaps="handled"
123
+ onScroll={scrollHandler}
124
+ scrollEventThrottle={16}
125
+ >
126
+ {messages
127
+ .filter((m) => m.role !== 'system')
128
+ .map((msg, i, arr) => {
129
+ if (msg.role === 'user') {
130
+ return <UserBubble key={msg.id} content={msg.content} />;
131
+ }
132
+
133
+ const isFirstInGroup = i === 0 || arr[i - 1]?.role === 'user';
134
+ const isStreamingThis = isStreaming && i === arr.length - 1;
135
+
136
+ return (
137
+ <AssistantMessage
138
+ key={msg.id}
139
+ message={msg}
140
+ isFirst={isFirstInGroup}
141
+ isStreamingThis={isStreamingThis}
142
+ />
143
+ );
144
+ })}
145
+ </Animated.ScrollView>
146
+ );
147
+ }
148
+
149
+ const styles = StyleSheet.create({
150
+ scrollView: {
151
+ flex: 1,
152
+ },
153
+ content: {
154
+ padding: 16,
155
+ paddingTop: 4,
156
+ flexGrow: 1,
157
+ },
158
+
159
+ // User bubble — frosted glass
160
+ userMessage: {
161
+ alignItems: 'flex-end',
162
+ marginBottom: 12,
163
+ },
164
+ userBubble: {
165
+ maxWidth: '85%',
166
+ paddingHorizontal: 16,
167
+ paddingVertical: 10,
168
+ borderRadius: 24,
169
+ borderWidth: StyleSheet.hairlineWidth,
170
+ overflow: 'hidden',
171
+ },
172
+ userText: {
173
+ fontSize: 15,
174
+ lineHeight: 22,
175
+ },
176
+
177
+ // Assistant message — no bubble
178
+ assistantMessage: {
179
+ alignItems: 'flex-start',
180
+ marginBottom: 12,
181
+ },
182
+ assistantText: {
183
+ fontSize: 15,
184
+ lineHeight: 24,
185
+ },
186
+
187
+ // Tool status
188
+ toolStatus: {
189
+ flexDirection: 'row',
190
+ alignItems: 'center',
191
+ gap: 6,
192
+ marginBottom: 6,
193
+ paddingVertical: 2,
194
+ },
195
+ toolDot: {
196
+ width: 8,
197
+ height: 8,
198
+ borderRadius: 4,
199
+ ...Platform.select({
200
+ web: {} as any,
201
+ default: {},
202
+ }),
203
+ },
204
+ toolCheckmark: {
205
+ fontSize: 14,
206
+ fontWeight: '600',
207
+ },
208
+ toolLabel: {
209
+ fontSize: 13,
210
+ },
211
+ });
@@ -0,0 +1,394 @@
1
+ import React, {
2
+ forwardRef,
3
+ useImperativeHandle,
4
+ useRef,
5
+ useCallback,
6
+ useState,
7
+ useEffect,
8
+ useMemo,
9
+ } from 'react';
10
+ import {
11
+ View,
12
+ Text,
13
+ TouchableOpacity,
14
+ StyleSheet,
15
+ Modal,
16
+ Pressable,
17
+ Dimensions,
18
+ Platform,
19
+ } from 'react-native';
20
+ import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';
21
+ import { useKeyboardHandler } from 'react-native-keyboard-controller';
22
+ import Animated, {
23
+ runOnJS,
24
+ useAnimatedStyle,
25
+ useSharedValue,
26
+ withSpring,
27
+ withTiming,
28
+ } from 'react-native-reanimated';
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';
34
+ import { useAliaColors } from '../theme';
35
+ import type { AliaChatSuggestion } from '../types';
36
+
37
+ const { height: SCREEN_HEIGHT } = Dimensions.get('window');
38
+
39
+ const SPRING_CONFIG = {
40
+ damping: 25,
41
+ stiffness: 300,
42
+ mass: 0.8,
43
+ };
44
+
45
+ export interface AliaChatSheetProps {
46
+ /** App context injected into system prompt */
47
+ clientContext?: string;
48
+ /** Quick action suggestions shown when chat is empty */
49
+ suggestions?: AliaChatSuggestion[];
50
+ /** Alia model (default: 'alia-v1') */
51
+ model?: string;
52
+ /** API URL override */
53
+ apiUrl?: string;
54
+ }
55
+
56
+ export interface AliaChatSheetRef {
57
+ present: () => void;
58
+ dismiss: () => void;
59
+ }
60
+
61
+ export const AliaChatSheet = forwardRef<AliaChatSheetRef, AliaChatSheetProps>(
62
+ ({ clientContext, suggestions = [], model, apiUrl }, ref) => {
63
+ const colors = useAliaColors();
64
+ const isDark = colors.background === '#000000';
65
+ const insets = useSafeAreaInsets();
66
+
67
+ // Chat
68
+ const chatOptions: UseAliaChatOptions = { apiUrl, model, clientContext };
69
+ const { messages, send, isStreaming, clear } = useAliaChat(chatOptions);
70
+
71
+ // Sheet visibility
72
+ const [rendered, setRendered] = useState(false);
73
+ const hasClosedRef = useRef(false);
74
+ const closeTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
75
+
76
+ // Reanimated shared values
77
+ const translateY = useSharedValue(SCREEN_HEIGHT);
78
+ const backdropOpacity = useSharedValue(0);
79
+ const scrollOffsetY = useSharedValue(0);
80
+ const allowPanClose = useSharedValue(true);
81
+ const keyboardHeight = useSharedValue(0);
82
+ const panContext = useSharedValue({ y: 0 });
83
+
84
+ useKeyboardHandler(
85
+ {
86
+ onMove: (e) => {
87
+ 'worklet';
88
+ keyboardHeight.value = e.height;
89
+ },
90
+ onEnd: (e) => {
91
+ 'worklet';
92
+ keyboardHeight.value = e.height;
93
+ },
94
+ },
95
+ [],
96
+ );
97
+
98
+ // Dismiss helpers
99
+ const finishDismiss = useCallback(() => {
100
+ if (hasClosedRef.current) return;
101
+ hasClosedRef.current = true;
102
+ setRendered(false);
103
+ }, []);
104
+
105
+ const handlePresent = useCallback(() => {
106
+ hasClosedRef.current = false;
107
+ if (closeTimeoutRef.current) {
108
+ clearTimeout(closeTimeoutRef.current);
109
+ closeTimeoutRef.current = null;
110
+ }
111
+ setRendered(true);
112
+ backdropOpacity.value = withTiming(1, { duration: 250 });
113
+ translateY.value = withSpring(0, SPRING_CONFIG);
114
+ }, []);
115
+
116
+ const handleDismiss = useCallback(() => {
117
+ backdropOpacity.value = withTiming(0, { duration: 250 }, (finished) => {
118
+ if (finished) runOnJS(finishDismiss)();
119
+ });
120
+ translateY.value = withSpring(SCREEN_HEIGHT, {
121
+ ...SPRING_CONFIG,
122
+ stiffness: 250,
123
+ });
124
+ if (closeTimeoutRef.current) clearTimeout(closeTimeoutRef.current);
125
+ closeTimeoutRef.current = setTimeout(() => {
126
+ finishDismiss();
127
+ closeTimeoutRef.current = null;
128
+ }, 350);
129
+ }, [finishDismiss]);
130
+
131
+ useEffect(
132
+ () => () => {
133
+ if (closeTimeoutRef.current) {
134
+ clearTimeout(closeTimeoutRef.current);
135
+ closeTimeoutRef.current = null;
136
+ }
137
+ },
138
+ [],
139
+ );
140
+
141
+ useImperativeHandle(
142
+ ref,
143
+ () => ({ present: handlePresent, dismiss: handleDismiss }),
144
+ [handlePresent, handleDismiss],
145
+ );
146
+
147
+ // Pan gesture for swipe-to-dismiss
148
+ const nativeGesture = useMemo(() => Gesture.Native(), []);
149
+
150
+ const panGesture = Gesture.Pan()
151
+ .simultaneousWithExternalGesture(nativeGesture)
152
+ .onStart(() => {
153
+ 'worklet';
154
+ panContext.value = { y: translateY.value };
155
+ allowPanClose.value = scrollOffsetY.value <= 8;
156
+ })
157
+ .onUpdate((event) => {
158
+ 'worklet';
159
+ if (!allowPanClose.value) return;
160
+ if (event.translationY > 0 && scrollOffsetY.value > 8) return;
161
+ const newY = panContext.value.y + event.translationY;
162
+ translateY.value = Math.max(0, newY);
163
+ })
164
+ .onEnd((event) => {
165
+ 'worklet';
166
+ if (!allowPanClose.value) return;
167
+ const velocity = event.velocityY;
168
+ const distance = translateY.value;
169
+ const closeThreshold = Math.max(140, SCREEN_HEIGHT * 0.25);
170
+ const shouldClose =
171
+ velocity > 900 || (distance > closeThreshold && velocity > -300);
172
+
173
+ if (shouldClose) {
174
+ translateY.value = withSpring(SCREEN_HEIGHT, {
175
+ ...SPRING_CONFIG,
176
+ velocity,
177
+ });
178
+ backdropOpacity.value = withTiming(0, { duration: 250 }, (finished) => {
179
+ if (finished) runOnJS(finishDismiss)();
180
+ });
181
+ } else {
182
+ translateY.value = withSpring(0, { ...SPRING_CONFIG, velocity });
183
+ }
184
+ });
185
+
186
+ // Animated styles
187
+ const backdropAnimStyle = useAnimatedStyle(() => ({
188
+ opacity: backdropOpacity.value,
189
+ }));
190
+
191
+ const sheetAnimStyle = useAnimatedStyle(() => ({
192
+ transform: [{ translateY: translateY.value - keyboardHeight.value }],
193
+ }));
194
+
195
+ const sheetMaxHeightStyle = useAnimatedStyle(
196
+ () => ({
197
+ maxHeight: SCREEN_HEIGHT - keyboardHeight.value - insets.top,
198
+ }),
199
+ [insets.top],
200
+ );
201
+
202
+ const showSuggestions = messages.length === 0 && suggestions.length > 0;
203
+
204
+ if (!rendered) return null;
205
+
206
+ return (
207
+ <Modal
208
+ visible={rendered}
209
+ transparent
210
+ animationType="none"
211
+ statusBarTranslucent
212
+ onRequestClose={handleDismiss}
213
+ >
214
+ <GestureHandlerRootView style={StyleSheet.absoluteFill}>
215
+ {/* Backdrop */}
216
+ <Animated.View style={[styles.backdrop, backdropAnimStyle]}>
217
+ <Pressable style={StyleSheet.absoluteFill} onPress={handleDismiss} />
218
+ </Animated.View>
219
+
220
+ {/* Sheet */}
221
+ <GestureDetector gesture={panGesture}>
222
+ <Animated.View
223
+ style={[
224
+ styles.sheet,
225
+ { backgroundColor: colors.background },
226
+ sheetAnimStyle,
227
+ sheetMaxHeightStyle,
228
+ ]}
229
+ >
230
+ {/* Drag handle */}
231
+ <View style={styles.dragHandle}>
232
+ <View
233
+ style={[
234
+ styles.dragHandlePill,
235
+ { backgroundColor: isDark ? '#444' : '#C7C7CC' },
236
+ ]}
237
+ />
238
+ </View>
239
+
240
+ {/* Header */}
241
+ <View style={styles.header}>
242
+ <View style={styles.headerLeft}>
243
+ <View style={[styles.aliaAvatar, { backgroundColor: colors.tint }]}>
244
+ <Text style={styles.aliaAvatarText}>A</Text>
245
+ </View>
246
+ <Text style={[styles.headerTitle, { color: colors.text }]}>
247
+ Alia
248
+ </Text>
249
+ </View>
250
+ <View style={styles.headerRight}>
251
+ {messages.length > 0 && (
252
+ <TouchableOpacity onPress={clear} style={styles.clearButton}>
253
+ <Text
254
+ style={[
255
+ styles.clearText,
256
+ { color: colors.secondaryText },
257
+ ]}
258
+ >
259
+ Clear
260
+ </Text>
261
+ </TouchableOpacity>
262
+ )}
263
+ <TouchableOpacity
264
+ onPress={handleDismiss}
265
+ style={styles.closeButton}
266
+ hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
267
+ >
268
+ <Text
269
+ style={{
270
+ fontSize: 18,
271
+ fontWeight: '600',
272
+ color: colors.icon,
273
+ }}
274
+ >
275
+ {'\u2715'}
276
+ </Text>
277
+ </TouchableOpacity>
278
+ </View>
279
+ </View>
280
+
281
+ {/* Suggestions or Messages */}
282
+ <GestureDetector gesture={nativeGesture}>
283
+ <View style={styles.chatArea}>
284
+ {showSuggestions ? (
285
+ <AliaChatSuggestions
286
+ suggestions={suggestions}
287
+ onSelect={send}
288
+ />
289
+ ) : (
290
+ <AliaChatMessageList
291
+ messages={messages}
292
+ isStreaming={isStreaming}
293
+ scrollOffsetY={scrollOffsetY}
294
+ />
295
+ )}
296
+ </View>
297
+ </GestureDetector>
298
+
299
+ {/* Input */}
300
+ <AliaChatInput onSend={send} isStreaming={isStreaming} />
301
+ </Animated.View>
302
+ </GestureDetector>
303
+ </GestureHandlerRootView>
304
+ </Modal>
305
+ );
306
+ },
307
+ );
308
+
309
+ AliaChatSheet.displayName = 'AliaChatSheet';
310
+
311
+ const styles = StyleSheet.create({
312
+ backdrop: {
313
+ ...StyleSheet.absoluteFillObject,
314
+ backgroundColor: 'rgba(0,0,0,0.5)',
315
+ },
316
+ sheet: {
317
+ position: 'absolute',
318
+ bottom: 0,
319
+ left: 0,
320
+ right: 0,
321
+ borderTopLeftRadius: 24,
322
+ borderTopRightRadius: 24,
323
+ overflow: 'hidden',
324
+ maxWidth: 600,
325
+ alignSelf: 'center',
326
+ ...Platform.select({
327
+ web: {
328
+ marginHorizontal: 'auto',
329
+ boxShadow: '0 -4px 24px rgba(0,0,0,0.15)',
330
+ } as any,
331
+ default: { elevation: 16 },
332
+ }),
333
+ },
334
+ dragHandle: {
335
+ alignItems: 'center',
336
+ paddingTop: 10,
337
+ paddingBottom: 4,
338
+ },
339
+ dragHandlePill: {
340
+ width: 36,
341
+ height: 5,
342
+ borderRadius: 3,
343
+ },
344
+ header: {
345
+ flexDirection: 'row',
346
+ alignItems: 'center',
347
+ justifyContent: 'space-between',
348
+ paddingHorizontal: 16,
349
+ paddingVertical: 8,
350
+ },
351
+ headerLeft: {
352
+ flexDirection: 'row',
353
+ alignItems: 'center',
354
+ gap: 10,
355
+ },
356
+ aliaAvatar: {
357
+ width: 28,
358
+ height: 28,
359
+ borderRadius: 14,
360
+ alignItems: 'center',
361
+ justifyContent: 'center',
362
+ },
363
+ aliaAvatarText: {
364
+ color: '#FFFFFF',
365
+ fontSize: 14,
366
+ fontWeight: '700',
367
+ },
368
+ headerTitle: {
369
+ fontSize: 18,
370
+ fontWeight: '600',
371
+ },
372
+ headerRight: {
373
+ flexDirection: 'row',
374
+ alignItems: 'center',
375
+ gap: 8,
376
+ },
377
+ clearButton: {
378
+ paddingHorizontal: 10,
379
+ paddingVertical: 6,
380
+ },
381
+ clearText: {
382
+ fontSize: 14,
383
+ },
384
+ closeButton: {
385
+ width: 32,
386
+ height: 32,
387
+ alignItems: 'center',
388
+ justifyContent: 'center',
389
+ borderRadius: 16,
390
+ },
391
+ chatArea: {
392
+ flex: 1,
393
+ },
394
+ });
@@ -0,0 +1,70 @@
1
+ import React from 'react';
2
+ import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
3
+ import { useAliaColors } from '../theme';
4
+ import type { AliaChatSuggestion } from '../types';
5
+
6
+ interface AliaChatSuggestionsProps {
7
+ suggestions: AliaChatSuggestion[];
8
+ onSelect: (prompt: string) => void;
9
+ }
10
+
11
+ export function AliaChatSuggestions({ suggestions, onSelect }: AliaChatSuggestionsProps) {
12
+ const colors = useAliaColors();
13
+ const isDark = colors.background === '#000000';
14
+
15
+ if (suggestions.length === 0) return null;
16
+
17
+ return (
18
+ <View style={styles.container}>
19
+ <Text style={[styles.greeting, { color: colors.tint }]}>
20
+ How can I help you today?
21
+ </Text>
22
+ <View style={styles.list}>
23
+ {suggestions.map((suggestion, i) => (
24
+ <TouchableOpacity
25
+ key={i}
26
+ style={[
27
+ styles.chip,
28
+ { backgroundColor: isDark ? colors.card : colors.card },
29
+ ]}
30
+ onPress={() => onSelect(suggestion.prompt)}
31
+ activeOpacity={0.7}
32
+ >
33
+ <Text style={[styles.chipText, { color: colors.text }]}>
34
+ {suggestion.label}
35
+ </Text>
36
+ </TouchableOpacity>
37
+ ))}
38
+ </View>
39
+ </View>
40
+ );
41
+ }
42
+
43
+ const styles = StyleSheet.create({
44
+ container: {
45
+ paddingTop: 16,
46
+ paddingHorizontal: 16,
47
+ },
48
+ greeting: {
49
+ fontSize: 24,
50
+ fontWeight: '600',
51
+ lineHeight: 32,
52
+ marginBottom: 20,
53
+ paddingHorizontal: 4,
54
+ },
55
+ list: {
56
+ gap: 8,
57
+ },
58
+ chip: {
59
+ flexDirection: 'row',
60
+ alignItems: 'center',
61
+ gap: 12,
62
+ paddingHorizontal: 16,
63
+ paddingVertical: 14,
64
+ borderRadius: 16,
65
+ },
66
+ chipText: {
67
+ fontSize: 14,
68
+ flex: 1,
69
+ },
70
+ });
@@ -0,0 +1,270 @@
1
+ import { useState, useRef, useCallback } from 'react';
2
+ import { useOxy } from '@oxyhq/services';
3
+ import type { ChatMessage, ToolInvocation } from '../types';
4
+
5
+ const API_URL = process.env.EXPO_PUBLIC_API_URL;
6
+
7
+ export interface UseAliaChatOptions {
8
+ /** Alia API base URL (default: EXPO_PUBLIC_API_URL) */
9
+ apiUrl?: string;
10
+ /** Alia model to use (default: 'alia-v1') */
11
+ model?: string;
12
+ /** App context injected as system message so Alia knows which app the user is in */
13
+ clientContext?: string;
14
+ /** Access token override — if not provided, fetched from useOxy() */
15
+ accessToken?: string;
16
+ }
17
+
18
+ export interface UseAliaChatReturn {
19
+ messages: ChatMessage[];
20
+ send: (text: string) => void;
21
+ isStreaming: boolean;
22
+ clear: () => void;
23
+ error: string | null;
24
+ }
25
+
26
+ let nextId = 0;
27
+ function generateId(): string {
28
+ return `msg-${Date.now()}-${nextId++}`;
29
+ }
30
+
31
+ /**
32
+ * SSE streaming chat hook for Alia.
33
+ *
34
+ * Sends messages to Alia's /v1/chat/completions endpoint and streams
35
+ * responses back, including tool invocations.
36
+ */
37
+ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn {
38
+ const {
39
+ apiUrl = API_URL,
40
+ model = 'alia-v1',
41
+ clientContext,
42
+ accessToken: accessTokenProp,
43
+ } = options;
44
+
45
+ const { oxyServices } = useOxy();
46
+ const [messages, setMessages] = useState<ChatMessage[]>([]);
47
+ const [isStreaming, setIsStreaming] = useState(false);
48
+ const [error, setError] = useState<string | null>(null);
49
+ const abortRef = useRef<AbortController | null>(null);
50
+
51
+ const getToken = useCallback((): string | null => {
52
+ if (accessTokenProp) return accessTokenProp;
53
+ return oxyServices.httpService.getAccessToken();
54
+ }, [accessTokenProp, oxyServices]);
55
+
56
+ const send = useCallback(
57
+ async (text: string) => {
58
+ const trimmed = text.trim();
59
+ if (!trimmed || isStreaming) return;
60
+
61
+ const token = getToken();
62
+ if (!token) {
63
+ setError('Not authenticated');
64
+ return;
65
+ }
66
+
67
+ setError(null);
68
+
69
+ // Add user message
70
+ const userMsg: ChatMessage = {
71
+ id: generateId(),
72
+ role: 'user',
73
+ content: trimmed,
74
+ createdAt: Date.now(),
75
+ };
76
+
77
+ // Prepare assistant placeholder
78
+ const assistantMsg: ChatMessage = {
79
+ id: generateId(),
80
+ role: 'assistant',
81
+ content: '',
82
+ toolInvocations: [],
83
+ createdAt: Date.now(),
84
+ };
85
+
86
+ setMessages((prev) => [...prev, userMsg, assistantMsg]);
87
+ setIsStreaming(true);
88
+
89
+ // Build messages payload for the API
90
+ const apiMessages: Array<{ role: string; content: string }> = [];
91
+
92
+ // System context (tells Alia which app the user is in)
93
+ if (clientContext) {
94
+ apiMessages.push({ role: 'system', content: clientContext });
95
+ }
96
+
97
+ // Previous conversation history
98
+ for (const msg of messages) {
99
+ if (msg.role === 'system') continue;
100
+ apiMessages.push({ role: msg.role, content: msg.content });
101
+ }
102
+
103
+ // New user message
104
+ apiMessages.push({ role: 'user', content: trimmed });
105
+
106
+ const controller = new AbortController();
107
+ abortRef.current = controller;
108
+
109
+ try {
110
+ const response = await fetch(`${apiUrl}/v1/chat/completions`, {
111
+ method: 'POST',
112
+ headers: {
113
+ 'Content-Type': 'application/json',
114
+ Authorization: `Bearer ${token}`,
115
+ },
116
+ body: JSON.stringify({
117
+ model,
118
+ messages: apiMessages,
119
+ stream: true,
120
+ }),
121
+ signal: controller.signal,
122
+ });
123
+
124
+ if (!response.ok) {
125
+ const body = await response.text().catch(() => '');
126
+ throw new Error(`API error ${response.status}: ${body.slice(0, 200)}`);
127
+ }
128
+
129
+ // Non-streaming fallback
130
+ if (!response.body || typeof response.body.getReader !== 'function') {
131
+ const json = await response.json();
132
+ const content = json.choices?.[0]?.message?.content ?? '';
133
+ setMessages((prev) => {
134
+ const updated = [...prev];
135
+ const last = updated[updated.length - 1];
136
+ if (last?.role === 'assistant') {
137
+ updated[updated.length - 1] = { ...last, content };
138
+ }
139
+ return updated;
140
+ });
141
+ return;
142
+ }
143
+
144
+ // Stream SSE
145
+ const reader = response.body.getReader();
146
+ const decoder = new TextDecoder();
147
+ let buffer = '';
148
+ let accumulatedContent = '';
149
+ const toolInvocations: ToolInvocation[] = [];
150
+
151
+ try {
152
+ while (true) {
153
+ const { done, value } = await reader.read();
154
+ if (done) break;
155
+
156
+ buffer += decoder.decode(value, { stream: true });
157
+ const lines = buffer.split('\n');
158
+ buffer = lines.pop() ?? '';
159
+
160
+ for (const line of lines) {
161
+ const trimmedLine = line.trim();
162
+ if (!trimmedLine || !trimmedLine.startsWith('data: ')) continue;
163
+ const data = trimmedLine.slice(6);
164
+ if (data === '[DONE]') continue;
165
+
166
+ try {
167
+ const parsed = JSON.parse(data);
168
+
169
+ // Standard content delta
170
+ const delta = parsed.choices?.[0]?.delta?.content;
171
+ if (delta) {
172
+ accumulatedContent += delta;
173
+ setMessages((prev) => {
174
+ const updated = [...prev];
175
+ const last = updated[updated.length - 1];
176
+ if (last?.role === 'assistant') {
177
+ updated[updated.length - 1] = {
178
+ ...last,
179
+ content: accumulatedContent,
180
+ toolInvocations: toolInvocations.length > 0 ? [...toolInvocations] : undefined,
181
+ };
182
+ }
183
+ return updated;
184
+ });
185
+ }
186
+
187
+ // Alia tool call event
188
+ if (parsed.type === 'alia.tool_call') {
189
+ toolInvocations.push({
190
+ toolName: parsed.tool || 'unknown',
191
+ state: 'call',
192
+ args: parsed.args,
193
+ });
194
+ setMessages((prev) => {
195
+ const updated = [...prev];
196
+ const last = updated[updated.length - 1];
197
+ if (last?.role === 'assistant') {
198
+ updated[updated.length - 1] = {
199
+ ...last,
200
+ toolInvocations: [...toolInvocations],
201
+ };
202
+ }
203
+ return updated;
204
+ });
205
+ }
206
+
207
+ // Alia tool result event
208
+ if (parsed.type === 'alia.tool_result') {
209
+ const existing = toolInvocations.find(
210
+ (t) => t.toolName === parsed.tool && t.state === 'call',
211
+ );
212
+ if (existing) {
213
+ existing.state = 'result';
214
+ existing.result = parsed.result;
215
+ }
216
+ setMessages((prev) => {
217
+ const updated = [...prev];
218
+ const last = updated[updated.length - 1];
219
+ if (last?.role === 'assistant') {
220
+ updated[updated.length - 1] = {
221
+ ...last,
222
+ toolInvocations: [...toolInvocations],
223
+ };
224
+ }
225
+ return updated;
226
+ });
227
+ }
228
+ } catch {
229
+ // Skip malformed chunks
230
+ }
231
+ }
232
+ }
233
+ } finally {
234
+ reader.releaseLock();
235
+ }
236
+ } catch (err: any) {
237
+ if (err?.name === 'AbortError') return;
238
+ const errorMessage = err?.message || 'Something went wrong';
239
+ setError(errorMessage);
240
+ setMessages((prev) => {
241
+ const updated = [...prev];
242
+ const last = updated[updated.length - 1];
243
+ if (last?.role === 'assistant' && !last.content) {
244
+ updated[updated.length - 1] = {
245
+ ...last,
246
+ content: "I'm having trouble connecting right now. Please try again.",
247
+ };
248
+ }
249
+ return updated;
250
+ });
251
+ } finally {
252
+ setIsStreaming(false);
253
+ abortRef.current = null;
254
+ }
255
+ },
256
+ [isStreaming, messages, getToken, apiUrl, model, clientContext],
257
+ );
258
+
259
+ const clear = useCallback(() => {
260
+ if (abortRef.current) {
261
+ abortRef.current.abort();
262
+ abortRef.current = null;
263
+ }
264
+ setMessages([]);
265
+ setIsStreaming(false);
266
+ setError(null);
267
+ }, []);
268
+
269
+ return { messages, send, isStreaming, clear, error };
270
+ }
package/src/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ // @alia.onl/sdk — Alia AI Chat SDK
2
+ // Reusable bottom sheet chat component for any Oxy ecosystem app
3
+
4
+ // Main component
5
+ export { AliaChatSheet } from './components/AliaChatSheet';
6
+ export type { AliaChatSheetProps, AliaChatSheetRef } from './components/AliaChatSheet';
7
+
8
+ // Hook (for custom UIs)
9
+ export { useAliaChat } from './hooks/useAliaChat';
10
+ export type { UseAliaChatOptions, UseAliaChatReturn } from './hooks/useAliaChat';
11
+
12
+ // Types
13
+ export type { ChatMessage, ToolInvocation, AliaChatSuggestion } from './types';
14
+
15
+ // Theme
16
+ export { useAliaColors } from './theme';
17
+ export type { AliaColors } from './theme';
package/src/theme.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { useMemo } from 'react';
2
+ import { useColorScheme } from 'react-native';
3
+
4
+ const COLORS = {
5
+ light: {
6
+ text: '#11181C',
7
+ background: '#fff',
8
+ card: '#F2F2F7',
9
+ inputBackground: '#F5F5F5',
10
+ border: '#E5E5EA',
11
+ secondaryText: '#8E8E93',
12
+ icon: '#687076',
13
+ tint: '#0a7ea4',
14
+ },
15
+ dark: {
16
+ text: '#ECEDEE',
17
+ background: '#000000',
18
+ card: '#1C1C1E',
19
+ inputBackground: '#333333',
20
+ border: '#2C2C2E',
21
+ secondaryText: '#8E8E93',
22
+ icon: '#9BA1A6',
23
+ tint: '#fff',
24
+ },
25
+ } as const;
26
+
27
+ export type AliaColors = (typeof COLORS)['light'];
28
+
29
+ export function useAliaColors(): AliaColors {
30
+ const scheme = useColorScheme();
31
+ return useMemo(() => COLORS[scheme ?? 'light'], [scheme]);
32
+ }
package/src/types.ts ADDED
@@ -0,0 +1,20 @@
1
+ export interface ChatMessage {
2
+ id: string;
3
+ role: 'user' | 'assistant' | 'system';
4
+ content: string;
5
+ toolInvocations?: ToolInvocation[];
6
+ createdAt: number;
7
+ }
8
+
9
+ export interface ToolInvocation {
10
+ toolName: string;
11
+ state: 'call' | 'result';
12
+ args?: Record<string, any>;
13
+ result?: any;
14
+ }
15
+
16
+ export interface AliaChatSuggestion {
17
+ label: string;
18
+ icon?: string;
19
+ prompt: string;
20
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react-jsx",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "declaration": true,
11
+ "outDir": "lib"
12
+ },
13
+ "include": ["src"]
14
+ }