@alia.onl/sdk 2.0.0 → 3.1.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 +25 -3
- package/src/components/AliaChatContent.tsx +356 -0
- package/src/components/AliaChatInput.tsx +173 -26
- package/src/components/AliaChatMessageList.tsx +459 -168
- package/src/components/AliaChatScreen.tsx +140 -0
- package/src/components/AliaChatSheet.tsx +41 -44
- package/src/components/AliaWelcomeMessage.tsx +75 -0
- package/src/components/Markdown.tsx +58 -114
- package/src/components/PlanPreviewCard.tsx +76 -0
- package/src/components/ResearchProgressCard.tsx +159 -0
- package/src/components/ui/button.tsx +104 -0
- package/src/components/ui/chat-text-input.tsx +164 -0
- package/src/components/ui/dropdown-menu.tsx +2 -0
- package/src/components/ui/dropdown-menu.web.tsx +355 -0
- package/src/components/ui/prompt-input/actions.tsx +28 -0
- package/src/components/ui/prompt-input/add-menu.tsx +83 -0
- package/src/components/ui/prompt-input/attachments.tsx +183 -0
- package/src/components/ui/prompt-input/autocomplete.tsx +168 -0
- package/src/components/ui/prompt-input/context.tsx +71 -0
- package/src/components/ui/prompt-input/mic-button.tsx +53 -0
- package/src/components/ui/prompt-input/prompt-input.tsx +276 -0
- package/src/components/ui/prompt-input/submit-button.tsx +49 -0
- package/src/components/ui/prompt-input/textarea.tsx +64 -0
- package/src/components/ui/text.tsx +29 -0
- package/src/hooks/useAliaChat.ts +18 -8
- package/src/hooks/useDocumentPicker.ts +37 -0
- package/src/hooks/useImagePicker.ts +38 -0
- package/src/hooks/useSpeechToText.ts +25 -9
- package/src/index.ts +41 -3
- package/src/lib/content-utils.ts +23 -0
- package/src/lib/keyboard.native.tsx +6 -0
- package/src/lib/keyboard.tsx +25 -0
- package/src/lib/tool-registry.ts +55 -0
- package/src/lib/utils.ts +14 -0
- package/src/optional-deps.d.ts +17 -0
- package/src/types.ts +27 -10
- package/src/components/AliaChatSuggestions.tsx +0 -70
|
@@ -1,9 +1,5 @@
|
|
|
1
|
-
import React, { useRef, useEffect } from 'react';
|
|
2
|
-
import {
|
|
3
|
-
View,
|
|
4
|
-
Text,
|
|
5
|
-
StyleSheet,
|
|
6
|
-
} from 'react-native';
|
|
1
|
+
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
|
2
|
+
import { View, Pressable, TextInput, StyleSheet, Platform } from 'react-native';
|
|
7
3
|
import { BlurView } from 'expo-blur';
|
|
8
4
|
import Animated, {
|
|
9
5
|
useAnimatedScrollHandler,
|
|
@@ -13,88 +9,190 @@ import Animated, {
|
|
|
13
9
|
withTiming,
|
|
14
10
|
withSequence,
|
|
15
11
|
cancelAnimation,
|
|
16
|
-
Easing,
|
|
17
12
|
FadeInUp,
|
|
18
13
|
type SharedValue,
|
|
19
14
|
} from 'react-native-reanimated';
|
|
15
|
+
import {
|
|
16
|
+
Copy,
|
|
17
|
+
Check,
|
|
18
|
+
Volume2,
|
|
19
|
+
Square,
|
|
20
|
+
ThumbsUp,
|
|
21
|
+
ThumbsDown,
|
|
22
|
+
Pencil,
|
|
23
|
+
} from 'lucide-react-native';
|
|
20
24
|
import { useAliaColors } from '../theme';
|
|
25
|
+
import { Text } from './ui/text';
|
|
21
26
|
import { AliaMarkdown } from './Markdown';
|
|
22
27
|
import { ThinkingIndicator } from './ThinkingIndicator';
|
|
23
28
|
import { Reasoning, ReasoningTrigger, ReasoningContent } from './Reasoning';
|
|
29
|
+
import { ResearchProgressCard } from './ResearchProgressCard';
|
|
30
|
+
import { PlanPreviewCard } from './PlanPreviewCard';
|
|
31
|
+
import { getToolLabel, getToolActiveLabel, getResearchActiveLabel } from '../lib/tool-registry';
|
|
32
|
+
import { getTextFromContent, getImagesFromContent } from '../lib/content-utils';
|
|
24
33
|
import type { ChatMessage, ToolInvocation } from '../types';
|
|
25
34
|
|
|
26
|
-
|
|
35
|
+
// Resolve expo-image once at module load (optional peer dep)
|
|
36
|
+
let ExpoImage: any = null;
|
|
37
|
+
try { ExpoImage = require('expo-image').Image; } catch {}
|
|
27
38
|
|
|
28
|
-
|
|
39
|
+
type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
|
|
40
|
+
|
|
41
|
+
export interface AliaChatMessageListProps {
|
|
29
42
|
messages: ChatMessage[];
|
|
30
43
|
isStreaming: boolean;
|
|
31
44
|
scrollOffsetY: SharedValue<number>;
|
|
45
|
+
onReadAloud?: (messageId: string, text: string) => void;
|
|
46
|
+
ttsActiveMessageId?: string | null;
|
|
47
|
+
ttsPlaybackState?: PlaybackState;
|
|
48
|
+
// Injectable callbacks
|
|
49
|
+
onEditMessage?: (messageId: string, newContent: string) => void;
|
|
50
|
+
onThumbsUp?: (messageId: string) => void;
|
|
51
|
+
onThumbsDown?: (messageId: string) => void;
|
|
52
|
+
onApprovePlan?: (planId: string) => void;
|
|
53
|
+
onRejectPlan?: (planId: string) => void;
|
|
54
|
+
onToolResultPress?: (messageId: string) => void;
|
|
55
|
+
// Welcome
|
|
56
|
+
welcomeComponent?: React.ReactNode;
|
|
57
|
+
conversationLoading?: boolean;
|
|
58
|
+
// Markdown renderer override (app passes CustomMarkdown, SDK uses AliaMarkdown)
|
|
59
|
+
renderMarkdown?: (content: string) => React.ReactNode;
|
|
32
60
|
}
|
|
33
61
|
|
|
34
62
|
// ── Tool Bullet ──
|
|
35
63
|
|
|
36
|
-
function ToolBullet({
|
|
37
|
-
const colors = useAliaColors();
|
|
38
|
-
|
|
39
|
-
// Clean up tool name for display: "oxy_inbox__searchEmails" → "Searching emails"
|
|
40
|
-
const displayName = invocation.toolName
|
|
41
|
-
.replace(/^oxy_\w+__/, '')
|
|
42
|
-
.replace(/([A-Z])/g, ' $1')
|
|
43
|
-
.replace(/^./, (s) => s.toUpperCase())
|
|
44
|
-
.trim();
|
|
45
|
-
|
|
46
|
-
const isActive = invocation.state === 'call' || invocation.state === 'partial-call';
|
|
47
|
-
const label = isActive ? `${displayName}...` : displayName;
|
|
48
|
-
|
|
49
|
-
return (
|
|
50
|
-
<View style={styles.toolStatus}>
|
|
51
|
-
{isActive ? (
|
|
52
|
-
<PulsingDot color={colors.tint} />
|
|
53
|
-
) : (
|
|
54
|
-
<Text style={[styles.toolCheckmark, { color: colors.tint }]}>{'\u2713'}</Text>
|
|
55
|
-
)}
|
|
56
|
-
<Text style={[styles.toolLabel, { color: colors.secondaryText }]}>{label}</Text>
|
|
57
|
-
</View>
|
|
58
|
-
);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function PulsingDot({ color }: { color: string }) {
|
|
64
|
+
function ToolBullet({ isRunning }: { isRunning: boolean }) {
|
|
62
65
|
const opacity = useSharedValue(1);
|
|
63
|
-
|
|
64
66
|
useEffect(() => {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
67
|
+
if (isRunning) {
|
|
68
|
+
opacity.value = withRepeat(
|
|
69
|
+
withSequence(
|
|
70
|
+
withTiming(0.3, { duration: 500 }),
|
|
71
|
+
withTiming(1, { duration: 500 }),
|
|
72
|
+
),
|
|
73
|
+
-1,
|
|
74
|
+
);
|
|
75
|
+
} else {
|
|
76
|
+
opacity.value = 1;
|
|
77
|
+
}
|
|
72
78
|
return () => cancelAnimation(opacity);
|
|
73
|
-
}, []);
|
|
74
|
-
|
|
75
|
-
const animStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
|
|
76
|
-
|
|
79
|
+
}, [isRunning]);
|
|
80
|
+
const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
|
|
77
81
|
return (
|
|
78
|
-
<Animated.View style={
|
|
82
|
+
<Animated.View style={style}>
|
|
83
|
+
<Text style={{ color: isRunning ? '#eab308' : '#22c55e', fontSize: 10 }}>
|
|
84
|
+
{'\u25CF'}
|
|
85
|
+
</Text>
|
|
86
|
+
</Animated.View>
|
|
79
87
|
);
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
// ── User Bubble ──
|
|
83
91
|
|
|
84
|
-
function UserBubble({
|
|
85
|
-
|
|
86
|
-
|
|
92
|
+
function UserBubble({
|
|
93
|
+
message,
|
|
94
|
+
text,
|
|
95
|
+
isEditing,
|
|
96
|
+
editedContent,
|
|
97
|
+
onEditedContentChange,
|
|
98
|
+
onSaveEdit,
|
|
99
|
+
onCancelEdit,
|
|
100
|
+
onStartEdit,
|
|
101
|
+
onCopy,
|
|
102
|
+
copiedMessageId,
|
|
103
|
+
showEditButton,
|
|
104
|
+
}: {
|
|
105
|
+
message: ChatMessage;
|
|
106
|
+
text: string;
|
|
107
|
+
isEditing: boolean;
|
|
108
|
+
editedContent: string;
|
|
109
|
+
onEditedContentChange: (text: string) => void;
|
|
110
|
+
onSaveEdit: () => void;
|
|
111
|
+
onCancelEdit: () => void;
|
|
112
|
+
onStartEdit: () => void;
|
|
113
|
+
onCopy: () => void;
|
|
114
|
+
copiedMessageId: string | null;
|
|
115
|
+
showEditButton: boolean;
|
|
116
|
+
}) {
|
|
117
|
+
const images = getImagesFromContent(message.content);
|
|
87
118
|
|
|
88
119
|
return (
|
|
89
|
-
<View
|
|
90
|
-
|
|
91
|
-
<
|
|
92
|
-
intensity={60}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
120
|
+
<View className="flex-col items-end gap-0.5">
|
|
121
|
+
{isEditing ? (
|
|
122
|
+
<View className="max-w-[85%] sm:max-w-[75%] rounded-[24px] overflow-hidden border border-border">
|
|
123
|
+
<BlurView intensity={60} tint="default" style={StyleSheet.absoluteFill} />
|
|
124
|
+
<View className="px-4 py-2">
|
|
125
|
+
<TextInput
|
|
126
|
+
value={editedContent}
|
|
127
|
+
onChangeText={onEditedContentChange}
|
|
128
|
+
multiline
|
|
129
|
+
className="text-base text-foreground leading-7"
|
|
130
|
+
autoFocus
|
|
131
|
+
/>
|
|
132
|
+
<View className="flex-row gap-2 mt-2">
|
|
133
|
+
<Pressable
|
|
134
|
+
className="px-3 py-1.5 rounded-lg bg-primary"
|
|
135
|
+
onPress={onSaveEdit}
|
|
136
|
+
>
|
|
137
|
+
<Text className="text-xs text-primary-foreground">Save</Text>
|
|
138
|
+
</Pressable>
|
|
139
|
+
<Pressable
|
|
140
|
+
className="px-3 py-1.5 rounded-lg bg-muted-foreground"
|
|
141
|
+
onPress={onCancelEdit}
|
|
142
|
+
>
|
|
143
|
+
<Text className="text-xs text-background">Cancel</Text>
|
|
144
|
+
</Pressable>
|
|
145
|
+
</View>
|
|
146
|
+
</View>
|
|
147
|
+
</View>
|
|
148
|
+
) : (
|
|
149
|
+
<View className="max-w-[85%] sm:max-w-[75%] rounded-[24px] overflow-hidden border border-border">
|
|
150
|
+
<BlurView intensity={60} tint="default" style={StyleSheet.absoluteFill} />
|
|
151
|
+
<View className="px-4 py-2">
|
|
152
|
+
{/* Inline images from multi-part content */}
|
|
153
|
+
{images.length > 0 && (
|
|
154
|
+
<View className="flex-row flex-wrap gap-2 mb-2">
|
|
155
|
+
{images.map((imgUrl, imgIdx) => {
|
|
156
|
+
if (!ExpoImage) return null;
|
|
157
|
+
return (
|
|
158
|
+
<View key={`img-${imgIdx}`} className="rounded-xl overflow-hidden" style={{ width: 120, height: 120 }}>
|
|
159
|
+
<ExpoImage
|
|
160
|
+
source={{ uri: imgUrl }}
|
|
161
|
+
className="w-full h-full"
|
|
162
|
+
contentFit="cover"
|
|
163
|
+
/>
|
|
164
|
+
</View>
|
|
165
|
+
);
|
|
166
|
+
})}
|
|
167
|
+
</View>
|
|
168
|
+
)}
|
|
169
|
+
<Text className="text-base text-foreground leading-7">{text}</Text>
|
|
170
|
+
</View>
|
|
171
|
+
</View>
|
|
172
|
+
)}
|
|
173
|
+
{/* User message actions */}
|
|
174
|
+
{!isEditing && (
|
|
175
|
+
<View className="flex-row gap-1">
|
|
176
|
+
<Pressable
|
|
177
|
+
className="p-1.5 rounded-lg active:bg-muted"
|
|
178
|
+
onPress={onCopy}
|
|
179
|
+
>
|
|
180
|
+
{copiedMessageId === message.id ? (
|
|
181
|
+
<Check size={14} className="text-green-500" />
|
|
182
|
+
) : (
|
|
183
|
+
<Copy size={14} className="text-muted-foreground" />
|
|
184
|
+
)}
|
|
185
|
+
</Pressable>
|
|
186
|
+
{showEditButton && (
|
|
187
|
+
<Pressable
|
|
188
|
+
className="p-1.5 rounded-lg active:bg-muted"
|
|
189
|
+
onPress={onStartEdit}
|
|
190
|
+
>
|
|
191
|
+
<Pencil size={14} className="text-muted-foreground" />
|
|
192
|
+
</Pressable>
|
|
193
|
+
)}
|
|
194
|
+
</View>
|
|
195
|
+
)}
|
|
98
196
|
</View>
|
|
99
197
|
);
|
|
100
198
|
}
|
|
@@ -103,47 +201,203 @@ function UserBubble({ content }: { content: string }) {
|
|
|
103
201
|
|
|
104
202
|
function AssistantMessage({
|
|
105
203
|
message,
|
|
204
|
+
messageText,
|
|
106
205
|
isStreamingThis,
|
|
206
|
+
isLastMessage,
|
|
207
|
+
isLoading,
|
|
208
|
+
onReadAloud,
|
|
209
|
+
ttsActiveMessageId,
|
|
210
|
+
ttsPlaybackState,
|
|
211
|
+
onCopy,
|
|
212
|
+
copiedMessageId,
|
|
213
|
+
onThumbsUp,
|
|
214
|
+
onThumbsDown,
|
|
215
|
+
onApprovePlan,
|
|
216
|
+
onRejectPlan,
|
|
217
|
+
onToolResultPress,
|
|
218
|
+
renderMarkdown,
|
|
107
219
|
}: {
|
|
108
220
|
message: ChatMessage;
|
|
221
|
+
messageText: string;
|
|
109
222
|
isStreamingThis: boolean;
|
|
223
|
+
isLastMessage: boolean;
|
|
224
|
+
isLoading: boolean;
|
|
225
|
+
onReadAloud?: (messageId: string, text: string) => void;
|
|
226
|
+
ttsActiveMessageId?: string | null;
|
|
227
|
+
ttsPlaybackState?: PlaybackState;
|
|
228
|
+
onCopy: () => void;
|
|
229
|
+
copiedMessageId: string | null;
|
|
230
|
+
onThumbsUp?: (messageId: string) => void;
|
|
231
|
+
onThumbsDown?: (messageId: string) => void;
|
|
232
|
+
onApprovePlan?: (planId: string) => void;
|
|
233
|
+
onRejectPlan?: (planId: string) => void;
|
|
234
|
+
onToolResultPress?: (messageId: string) => void;
|
|
235
|
+
renderMarkdown?: (content: string) => React.ReactNode;
|
|
110
236
|
}) {
|
|
111
|
-
const
|
|
112
|
-
const hasContent = !!message.content;
|
|
237
|
+
const hasContent = messageText.length > 0;
|
|
113
238
|
const hasThinking = !!message.thinking;
|
|
114
|
-
|
|
239
|
+
|
|
240
|
+
const isThisPlaying = ttsActiveMessageId === message.id && ttsPlaybackState === 'playing';
|
|
241
|
+
const isThisPaused = ttsActiveMessageId === message.id && ttsPlaybackState === 'paused';
|
|
242
|
+
const isThisLoading = ttsActiveMessageId === message.id && ttsPlaybackState === 'loading';
|
|
243
|
+
|
|
244
|
+
// ThinkingIndicator — context-aware status
|
|
245
|
+
let thinkingIndicator: React.ReactNode = null;
|
|
246
|
+
if (isLoading && isLastMessage && !hasContent) {
|
|
247
|
+
const activeTool = message.toolInvocations?.find(
|
|
248
|
+
t => t.state === 'call' || t.state === 'partial-call',
|
|
249
|
+
);
|
|
250
|
+
const rp = message.researchProgress;
|
|
251
|
+
let activeStatus: string | undefined;
|
|
252
|
+
if (activeTool) {
|
|
253
|
+
activeStatus = getToolActiveLabel(activeTool.toolName);
|
|
254
|
+
} else if (rp?.phase && rp.phase !== 'complete') {
|
|
255
|
+
activeStatus = getResearchActiveLabel(rp.phase);
|
|
256
|
+
} else if (message.thinking) {
|
|
257
|
+
activeStatus = 'Reasoning...';
|
|
258
|
+
}
|
|
259
|
+
thinkingIndicator = (
|
|
260
|
+
<ThinkingIndicator
|
|
261
|
+
isWorking={(message.toolInvocations?.length ?? 0) > 0}
|
|
262
|
+
statusText={activeStatus}
|
|
263
|
+
/>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
115
266
|
|
|
116
267
|
return (
|
|
117
|
-
<
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
268
|
+
<View className="w-full">
|
|
269
|
+
{/* Plan Preview */}
|
|
270
|
+
{message.pendingPlan && onApprovePlan && onRejectPlan && (
|
|
271
|
+
<PlanPreviewCard
|
|
272
|
+
steps={message.pendingPlan.steps}
|
|
273
|
+
approved={message.pendingPlan.approved}
|
|
274
|
+
rejected={message.pendingPlan.rejected}
|
|
275
|
+
onApprove={() => onApprovePlan(message.pendingPlan!.planId)}
|
|
276
|
+
onReject={() => onRejectPlan(message.pendingPlan!.planId)}
|
|
277
|
+
/>
|
|
127
278
|
)}
|
|
128
279
|
|
|
129
|
-
{/* Tool
|
|
130
|
-
{message.toolInvocations?.map((
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
280
|
+
{/* Tool Invocations */}
|
|
281
|
+
{message.toolInvocations?.map((t, ti) => {
|
|
282
|
+
const key = t.toolCallId || `tool-${message.id}-${ti}`;
|
|
283
|
+
const toolLabel = getToolLabel(t.toolName);
|
|
284
|
+
const isRunning = t.state === 'call' || t.state === 'partial-call';
|
|
285
|
+
const isDone = t.state === 'result';
|
|
286
|
+
|
|
287
|
+
let description = '';
|
|
288
|
+
if (t.args?.url) {
|
|
289
|
+
const url = String(t.args.url);
|
|
290
|
+
description = url.length > 40 ? url.substring(0, 40) + '...' : url;
|
|
291
|
+
} else if (t.args?.query) {
|
|
292
|
+
const q = String(t.args.query);
|
|
293
|
+
description = `"${q.length > 30 ? q.substring(0, 30) + '...' : q}"`;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<Pressable
|
|
298
|
+
key={key}
|
|
299
|
+
className="flex-row items-center gap-2 py-1 active:opacity-70"
|
|
300
|
+
onPress={isDone && onToolResultPress ? () => onToolResultPress(message.id) : undefined}
|
|
301
|
+
disabled={!isDone || !onToolResultPress}
|
|
302
|
+
>
|
|
303
|
+
<ToolBullet isRunning={isRunning} />
|
|
304
|
+
<Text className="text-sm text-foreground flex-1 flex-shrink">
|
|
305
|
+
<Text className="font-bold">{toolLabel}</Text>
|
|
306
|
+
{description ? (
|
|
307
|
+
<Text className="text-muted-foreground"> {description}</Text>
|
|
308
|
+
) : null}
|
|
309
|
+
</Text>
|
|
310
|
+
</Pressable>
|
|
311
|
+
);
|
|
312
|
+
})}
|
|
313
|
+
|
|
314
|
+
{/* Research Progress */}
|
|
315
|
+
{message.researchProgress && (
|
|
316
|
+
<ResearchProgressCard progress={message.researchProgress} />
|
|
317
|
+
)}
|
|
136
318
|
|
|
137
|
-
{/*
|
|
138
|
-
{
|
|
139
|
-
<
|
|
319
|
+
{/* Extended Thinking */}
|
|
320
|
+
{hasThinking && (
|
|
321
|
+
<View className="mb-3 w-full">
|
|
322
|
+
<Reasoning isStreaming={isStreamingThis && !hasContent}>
|
|
323
|
+
<ReasoningTrigger />
|
|
324
|
+
<ReasoningContent>{message.thinking!}</ReasoningContent>
|
|
325
|
+
</Reasoning>
|
|
326
|
+
</View>
|
|
140
327
|
)}
|
|
141
328
|
|
|
142
|
-
{/*
|
|
143
|
-
{
|
|
144
|
-
<
|
|
329
|
+
{/* Message Content */}
|
|
330
|
+
{(hasContent || message.isStreaming) && (
|
|
331
|
+
<View className="flex-col items-start gap-0.5">
|
|
332
|
+
{/* Voice cohost label */}
|
|
333
|
+
{message.source === 'voice' && message.speaker === 'cohost' && (
|
|
334
|
+
<Text className="text-xs text-indigo-400 mb-0.5">Cohost</Text>
|
|
335
|
+
)}
|
|
336
|
+
{/* Agent identity */}
|
|
337
|
+
{message.agentInfo && (
|
|
338
|
+
<View className="flex-row items-center gap-2 mb-0.5">
|
|
339
|
+
<View className="w-5 h-5 rounded-full bg-orange-500/20 items-center justify-center">
|
|
340
|
+
<Text style={{ fontSize: 8, color: '#f97316' }}>{'\u25CF'}</Text>
|
|
341
|
+
</View>
|
|
342
|
+
<Text className="text-xs font-semibold" style={{ color: '#f97316' }}>
|
|
343
|
+
{message.agentInfo.name}
|
|
344
|
+
</Text>
|
|
345
|
+
</View>
|
|
346
|
+
)}
|
|
347
|
+
<View className="w-full">
|
|
348
|
+
{message.source === 'voice' ? (
|
|
349
|
+
<Text className="text-base text-foreground leading-7">
|
|
350
|
+
{messageText}
|
|
351
|
+
{message.isStreaming ? '\u258C' : ''}
|
|
352
|
+
</Text>
|
|
353
|
+
) : renderMarkdown ? (
|
|
354
|
+
renderMarkdown(messageText)
|
|
355
|
+
) : (
|
|
356
|
+
<AliaMarkdown content={messageText} />
|
|
357
|
+
)}
|
|
358
|
+
</View>
|
|
359
|
+
{/* Action buttons */}
|
|
360
|
+
{!isStreamingThis && hasContent && (
|
|
361
|
+
<View className="flex-row gap-1">
|
|
362
|
+
{onReadAloud && (
|
|
363
|
+
<Pressable
|
|
364
|
+
className="p-1.5 rounded-lg active:bg-muted"
|
|
365
|
+
onPress={() => onReadAloud(message.id, messageText)}
|
|
366
|
+
>
|
|
367
|
+
{isThisPlaying || isThisPaused ? (
|
|
368
|
+
<Square size={14} className={isThisPlaying ? 'text-primary' : 'text-muted-foreground'} />
|
|
369
|
+
) : (
|
|
370
|
+
<Volume2 size={14} className={isThisLoading ? 'text-primary opacity-50' : 'text-muted-foreground'} />
|
|
371
|
+
)}
|
|
372
|
+
</Pressable>
|
|
373
|
+
)}
|
|
374
|
+
<Pressable
|
|
375
|
+
className="p-1.5 rounded-lg active:bg-muted"
|
|
376
|
+
onPress={onCopy}
|
|
377
|
+
>
|
|
378
|
+
{copiedMessageId === message.id ? (
|
|
379
|
+
<Check size={14} className="text-green-500" />
|
|
380
|
+
) : (
|
|
381
|
+
<Copy size={14} className="text-muted-foreground" />
|
|
382
|
+
)}
|
|
383
|
+
</Pressable>
|
|
384
|
+
{onThumbsUp && (
|
|
385
|
+
<Pressable className="p-1.5 rounded-lg active:bg-muted" onPress={() => onThumbsUp(message.id)}>
|
|
386
|
+
<ThumbsUp size={14} className="text-muted-foreground" />
|
|
387
|
+
</Pressable>
|
|
388
|
+
)}
|
|
389
|
+
{onThumbsDown && (
|
|
390
|
+
<Pressable className="p-1.5 rounded-lg active:bg-muted" onPress={() => onThumbsDown(message.id)}>
|
|
391
|
+
<ThumbsDown size={14} className="text-muted-foreground" />
|
|
392
|
+
</Pressable>
|
|
393
|
+
)}
|
|
394
|
+
</View>
|
|
395
|
+
)}
|
|
396
|
+
</View>
|
|
145
397
|
)}
|
|
146
|
-
|
|
398
|
+
|
|
399
|
+
{thinkingIndicator}
|
|
400
|
+
</View>
|
|
147
401
|
);
|
|
148
402
|
}
|
|
149
403
|
|
|
@@ -153,10 +407,30 @@ export function AliaChatMessageList({
|
|
|
153
407
|
messages,
|
|
154
408
|
isStreaming,
|
|
155
409
|
scrollOffsetY,
|
|
410
|
+
onReadAloud,
|
|
411
|
+
ttsActiveMessageId,
|
|
412
|
+
ttsPlaybackState,
|
|
413
|
+
onEditMessage,
|
|
414
|
+
onThumbsUp,
|
|
415
|
+
onThumbsDown,
|
|
416
|
+
onApprovePlan,
|
|
417
|
+
onRejectPlan,
|
|
418
|
+
onToolResultPress,
|
|
419
|
+
welcomeComponent,
|
|
420
|
+
conversationLoading,
|
|
421
|
+
renderMarkdown,
|
|
156
422
|
}: AliaChatMessageListProps) {
|
|
157
423
|
const scrollRef = useRef<Animated.ScrollView>(null);
|
|
424
|
+
const [editingMessageId, setEditingMessageId] = useState<string | null>(null);
|
|
425
|
+
const [editedContent, setEditedContent] = useState('');
|
|
426
|
+
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
|
|
427
|
+
const prevMessageCountRef = useRef(messages.length);
|
|
158
428
|
|
|
159
|
-
|
|
429
|
+
useEffect(() => {
|
|
430
|
+
prevMessageCountRef.current = messages.length;
|
|
431
|
+
}, [messages.length]);
|
|
432
|
+
|
|
433
|
+
// Auto-scroll
|
|
160
434
|
useEffect(() => {
|
|
161
435
|
if (messages.length > 0) {
|
|
162
436
|
const timer = setTimeout(() => {
|
|
@@ -164,7 +438,7 @@ export function AliaChatMessageList({
|
|
|
164
438
|
}, 100);
|
|
165
439
|
return () => clearTimeout(timer);
|
|
166
440
|
}
|
|
167
|
-
}, [messages]);
|
|
441
|
+
}, [messages.length, isStreaming]);
|
|
168
442
|
|
|
169
443
|
const scrollHandler = useAnimatedScrollHandler({
|
|
170
444
|
onScroll: (event) => {
|
|
@@ -172,93 +446,110 @@ export function AliaChatMessageList({
|
|
|
172
446
|
},
|
|
173
447
|
});
|
|
174
448
|
|
|
449
|
+
const handleCopy = useCallback(async (content: string, messageId: string) => {
|
|
450
|
+
try {
|
|
451
|
+
if (Platform.OS === 'web') {
|
|
452
|
+
await navigator.clipboard.writeText(content);
|
|
453
|
+
} else {
|
|
454
|
+
const Clipboard = await import('expo-clipboard');
|
|
455
|
+
await Clipboard.setStringAsync(content);
|
|
456
|
+
}
|
|
457
|
+
setCopiedMessageId(messageId);
|
|
458
|
+
setTimeout(() => setCopiedMessageId(null), 2000);
|
|
459
|
+
} catch {}
|
|
460
|
+
}, []);
|
|
461
|
+
|
|
462
|
+
const handleStartEdit = useCallback((messageId: string, content: string) => {
|
|
463
|
+
setEditingMessageId(messageId);
|
|
464
|
+
setEditedContent(content);
|
|
465
|
+
}, []);
|
|
466
|
+
|
|
467
|
+
const editedContentRef = useRef(editedContent);
|
|
468
|
+
editedContentRef.current = editedContent;
|
|
469
|
+
|
|
470
|
+
const handleSaveEdit = useCallback((messageId: string) => {
|
|
471
|
+
if (onEditMessage && editedContentRef.current.trim()) {
|
|
472
|
+
onEditMessage(messageId, editedContentRef.current);
|
|
473
|
+
}
|
|
474
|
+
setEditingMessageId(null);
|
|
475
|
+
setEditedContent('');
|
|
476
|
+
}, [onEditMessage]);
|
|
477
|
+
|
|
478
|
+
const handleCancelEdit = useCallback(() => {
|
|
479
|
+
setEditingMessageId(null);
|
|
480
|
+
setEditedContent('');
|
|
481
|
+
}, []);
|
|
482
|
+
|
|
483
|
+
const filteredMessages = useMemo(() => messages.filter(m => m.role !== 'system'), [messages]);
|
|
484
|
+
|
|
175
485
|
return (
|
|
176
486
|
<Animated.ScrollView
|
|
177
487
|
ref={scrollRef}
|
|
178
|
-
|
|
179
|
-
contentContainerStyle={
|
|
488
|
+
className="flex-1 bg-background px-4 py-4"
|
|
489
|
+
contentContainerStyle={{ flexGrow: 1, paddingTop: 4, paddingBottom: 16 }}
|
|
180
490
|
showsVerticalScrollIndicator={false}
|
|
181
491
|
keyboardShouldPersistTaps="handled"
|
|
182
492
|
onScroll={scrollHandler}
|
|
183
493
|
scrollEventThrottle={16}
|
|
184
494
|
>
|
|
185
|
-
{
|
|
186
|
-
|
|
187
|
-
.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
495
|
+
<View className="max-w-3xl mx-auto w-full" style={!filteredMessages.length ? { flex: 1, justifyContent: 'center' } : undefined}>
|
|
496
|
+
{/* Welcome or loading */}
|
|
497
|
+
{!filteredMessages.length && (
|
|
498
|
+
welcomeComponent || null
|
|
499
|
+
)}
|
|
500
|
+
|
|
501
|
+
{/* Messages */}
|
|
502
|
+
<View className="gap-2">
|
|
503
|
+
{filteredMessages.map((msg, i) => {
|
|
504
|
+
const isNewMessage = i >= prevMessageCountRef.current;
|
|
505
|
+
const isLast = i === filteredMessages.length - 1;
|
|
506
|
+
const isStreamingThis = isStreaming && isLast;
|
|
507
|
+
const messageText = getTextFromContent(msg.content);
|
|
508
|
+
|
|
509
|
+
return (
|
|
510
|
+
<Animated.View
|
|
511
|
+
key={msg.id}
|
|
512
|
+
entering={isNewMessage ? FadeInUp.springify() : undefined}
|
|
513
|
+
>
|
|
514
|
+
{msg.role === 'user' ? (
|
|
515
|
+
<UserBubble
|
|
516
|
+
message={msg}
|
|
517
|
+
text={messageText}
|
|
518
|
+
isEditing={editingMessageId === msg.id}
|
|
519
|
+
editedContent={editedContent}
|
|
520
|
+
onEditedContentChange={setEditedContent}
|
|
521
|
+
onSaveEdit={() => handleSaveEdit(msg.id)}
|
|
522
|
+
onCancelEdit={handleCancelEdit}
|
|
523
|
+
onStartEdit={() => handleStartEdit(msg.id, messageText)}
|
|
524
|
+
onCopy={() => handleCopy(messageText, msg.id)}
|
|
525
|
+
copiedMessageId={copiedMessageId}
|
|
526
|
+
showEditButton={!!onEditMessage}
|
|
527
|
+
/>
|
|
528
|
+
) : (
|
|
529
|
+
<AssistantMessage
|
|
530
|
+
message={msg}
|
|
531
|
+
messageText={messageText}
|
|
532
|
+
isStreamingThis={isStreamingThis}
|
|
533
|
+
isLastMessage={isLast}
|
|
534
|
+
isLoading={isStreaming}
|
|
535
|
+
onReadAloud={onReadAloud}
|
|
536
|
+
ttsActiveMessageId={ttsActiveMessageId}
|
|
537
|
+
ttsPlaybackState={ttsPlaybackState}
|
|
538
|
+
onCopy={() => handleCopy(messageText, msg.id)}
|
|
539
|
+
copiedMessageId={copiedMessageId}
|
|
540
|
+
onThumbsUp={onThumbsUp}
|
|
541
|
+
onThumbsDown={onThumbsDown}
|
|
542
|
+
onApprovePlan={onApprovePlan}
|
|
543
|
+
onRejectPlan={onRejectPlan}
|
|
544
|
+
onToolResultPress={onToolResultPress}
|
|
545
|
+
renderMarkdown={renderMarkdown}
|
|
546
|
+
/>
|
|
547
|
+
)}
|
|
548
|
+
</Animated.View>
|
|
549
|
+
);
|
|
550
|
+
})}
|
|
551
|
+
</View>
|
|
552
|
+
</View>
|
|
202
553
|
</Animated.ScrollView>
|
|
203
554
|
);
|
|
204
555
|
}
|
|
205
|
-
|
|
206
|
-
const styles = StyleSheet.create({
|
|
207
|
-
scrollView: {
|
|
208
|
-
flex: 1,
|
|
209
|
-
},
|
|
210
|
-
content: {
|
|
211
|
-
padding: 16,
|
|
212
|
-
paddingTop: 4,
|
|
213
|
-
flexGrow: 1,
|
|
214
|
-
},
|
|
215
|
-
|
|
216
|
-
// User bubble — frosted glass
|
|
217
|
-
userMessage: {
|
|
218
|
-
alignItems: 'flex-end',
|
|
219
|
-
marginBottom: 12,
|
|
220
|
-
},
|
|
221
|
-
userBubble: {
|
|
222
|
-
maxWidth: '85%',
|
|
223
|
-
paddingHorizontal: 16,
|
|
224
|
-
paddingVertical: 10,
|
|
225
|
-
borderRadius: 24,
|
|
226
|
-
borderWidth: StyleSheet.hairlineWidth,
|
|
227
|
-
overflow: 'hidden',
|
|
228
|
-
},
|
|
229
|
-
userText: {
|
|
230
|
-
fontSize: 15,
|
|
231
|
-
lineHeight: 22,
|
|
232
|
-
},
|
|
233
|
-
|
|
234
|
-
// Assistant message — no bubble
|
|
235
|
-
assistantMessage: {
|
|
236
|
-
alignItems: 'flex-start',
|
|
237
|
-
marginBottom: 12,
|
|
238
|
-
},
|
|
239
|
-
cursor: {
|
|
240
|
-
fontSize: 15,
|
|
241
|
-
marginTop: -4,
|
|
242
|
-
},
|
|
243
|
-
|
|
244
|
-
// Tool status
|
|
245
|
-
toolStatus: {
|
|
246
|
-
flexDirection: 'row',
|
|
247
|
-
alignItems: 'center',
|
|
248
|
-
gap: 6,
|
|
249
|
-
marginBottom: 6,
|
|
250
|
-
paddingVertical: 2,
|
|
251
|
-
},
|
|
252
|
-
toolDot: {
|
|
253
|
-
width: 8,
|
|
254
|
-
height: 8,
|
|
255
|
-
borderRadius: 4,
|
|
256
|
-
},
|
|
257
|
-
toolCheckmark: {
|
|
258
|
-
fontSize: 14,
|
|
259
|
-
fontWeight: '600',
|
|
260
|
-
},
|
|
261
|
-
toolLabel: {
|
|
262
|
-
fontSize: 13,
|
|
263
|
-
},
|
|
264
|
-
});
|