@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 +26 -0
- package/src/components/AliaChatInput.tsx +147 -0
- package/src/components/AliaChatMessageList.tsx +211 -0
- package/src/components/AliaChatSheet.tsx +394 -0
- package/src/components/AliaChatSuggestions.tsx +70 -0
- package/src/hooks/useAliaChat.ts +270 -0
- package/src/index.ts +17 -0
- package/src/theme.ts +32 -0
- package/src/types.ts +20 -0
- package/tsconfig.json +14 -0
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
|
+
}
|