2 Commits

Author SHA1 Message Date
Ada
1e6c06bfef Merge branch 'main' into mobile-demo 2026-02-04 17:23:25 -08:00
Ada
8994a3e045 feat(flow): image attachment and 422 error message display
- Attach image then send with optional text or image-only (default prompt)
- Attached image preview above input with remove
- AI image API error detail no longer shows [object Object]
2026-02-04 17:19:51 -08:00
2 changed files with 118 additions and 104 deletions

View File

@@ -76,7 +76,8 @@ export default function FlowScreen() {
const [newContent, setNewContent] = useState(''); const [newContent, setNewContent] = useState('');
const [isSending, setIsSending] = useState(false); const [isSending, setIsSending] = useState(false);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [selectedImage, setSelectedImage] = useState<string | null>(null); /** Attached image for next send (uri + base64); user can add optional text then send together */
const [attachedImage, setAttachedImage] = useState<{ uri: string; base64: string } | null>(null);
// AI Role state - start with null to detect first load // AI Role state - start with null to detect first load
const [selectedRole, setSelectedRole] = useState<AIRole | null>(aiRoles[0] || null); const [selectedRole, setSelectedRole] = useState<AIRole | null>(aiRoles[0] || null);
@@ -264,10 +265,12 @@ export default function FlowScreen() {
// ============================================================================= // =============================================================================
/** /**
* Handle sending a message to AI * Handle sending a message to AI (text-only via LangGraph, or image + optional text via vision API)
*/ */
const handleSendMessage = async () => { const handleSendMessage = async () => {
if (!newContent.trim() || isSending || !selectedRole) return; const hasText = !!newContent.trim();
const hasImage = !!attachedImage;
if ((!hasText && !hasImage) || isSending || !selectedRole) return;
// Check authentication // Check authentication
if (!token) { if (!token) {
@@ -279,11 +282,64 @@ export default function FlowScreen() {
return; return;
} }
const userMessage = newContent.trim();
setIsSending(true); setIsSending(true);
// --- Path: send with image (optional text) ---
if (hasImage && attachedImage) {
const imageUri = attachedImage.uri;
const imageBase64 = attachedImage.base64;
const userText = newContent.trim() || '请描述或分析这张图片';
setAttachedImage(null);
setNewContent('');
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: 'user',
content: userText,
imageUri,
createdAt: new Date(),
};
setMessages(prev => [...prev, userMsg]);
try {
const aiResponse = await aiService.sendMessageWithImage(userText, imageBase64, token);
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: aiResponse,
createdAt: new Date(),
};
setMessages(prev => [...prev, aiMsg]);
} catch (error) {
console.error('AI image request failed:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
const isAuthError =
errorMessage.includes('401') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('credentials') ||
errorMessage.includes('validate');
if (isAuthError) {
signOut();
Alert.alert('Session Expired', 'Your login session has expired. Please login again.', [{ text: 'OK' }]);
return;
}
const errorMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `⚠️ Error: ${errorMessage}`,
createdAt: new Date(),
};
setMessages(prev => [...prev, errorMsg]);
} finally {
setIsSending(false);
}
return;
}
// --- Path: text-only via LangGraph (unchanged) ---
const userMessage = newContent.trim();
setNewContent(''); setNewContent('');
// Add user message immediately
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: Date.now().toString(), id: Date.now().toString(),
role: 'user', role: 'user',
@@ -293,25 +349,15 @@ export default function FlowScreen() {
setMessages(prev => [...prev, userMsg]); setMessages(prev => [...prev, userMsg]);
try { try {
// 1. Convert current messages history to LangChain format
const history: (HumanMessage | LangChainAIMessage | SystemMessage)[] = messages.map(msg => { const history: (HumanMessage | LangChainAIMessage | SystemMessage)[] = messages.map(msg => {
if (msg.role === 'user') return new HumanMessage(msg.content); if (msg.role === 'user') return new HumanMessage(msg.content);
return new LangChainAIMessage(msg.content); return new LangChainAIMessage(msg.content);
}); });
// 2. Add system prompt
const systemPrompt = new SystemMessage(selectedRole?.systemPrompt || ''); const systemPrompt = new SystemMessage(selectedRole?.systemPrompt || '');
// 3. Add current new message
const currentMsg = new HumanMessage(userMessage); const currentMsg = new HumanMessage(userMessage);
// 4. Combine all messages for LangGraph processing
const fullMessages = [systemPrompt, ...history, currentMsg]; const fullMessages = [systemPrompt, ...history, currentMsg];
// 5. Execute via LangGraph service (handles token limits and context)
const aiResponse = await langGraphService.execute(fullMessages, token); const aiResponse = await langGraphService.execute(fullMessages, token);
// Add AI response
const aiMsg: ChatMessage = { const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(), id: (Date.now() + 1).toString(),
role: 'assistant', role: 'assistant',
@@ -319,20 +365,15 @@ export default function FlowScreen() {
createdAt: new Date(), createdAt: new Date(),
}; };
setMessages(prev => [...prev, aiMsg]); setMessages(prev => [...prev, aiMsg]);
} catch (error) { } catch (error) {
console.error('AI request failed:', error); console.error('AI request failed:', error);
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
// Handle authentication errors (401, credentials, unauthorized)
const isAuthError = const isAuthError =
errorMessage.includes('401') || errorMessage.includes('401') ||
errorMessage.includes('credentials') || errorMessage.includes('credentials') ||
errorMessage.includes('Unauthorized') || errorMessage.includes('Unauthorized') ||
errorMessage.includes('Not authenticated') || errorMessage.includes('Not authenticated') ||
errorMessage.includes('validate'); errorMessage.includes('validate');
if (isAuthError) { if (isAuthError) {
signOut(); signOut();
Alert.alert( Alert.alert(
@@ -342,8 +383,6 @@ export default function FlowScreen() {
); );
return; return;
} }
// Show error as AI message
const errorMsg: ChatMessage = { const errorMsg: ChatMessage = {
id: (Date.now() + 1).toString(), id: (Date.now() + 1).toString(),
role: 'assistant', role: 'assistant',
@@ -365,17 +404,15 @@ export default function FlowScreen() {
}; };
/** /**
* Handle image attachment - pick image and analyze with AI * Handle image attachment - pick image and attach to next message (user can add text then send)
*/ */
const handleAddImage = async () => { const handleAddImage = async () => {
// Request permission
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') { if (status !== 'granted') {
Alert.alert('Permission Required', 'Please grant permission to access photos'); Alert.alert('Permission Required', 'Please grant permission to access photos');
return; return;
} }
// Pick image
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images, mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true, allowsEditing: true,
@@ -384,78 +421,11 @@ export default function FlowScreen() {
}); });
if (!result.canceled && result.assets[0]) { if (!result.canceled && result.assets[0]) {
const imageAsset = result.assets[0]; const asset = result.assets[0];
setSelectedImage(imageAsset.uri); setAttachedImage({
uri: asset.uri,
// Check authentication base64: asset.base64 || '',
if (!token) { });
Alert.alert(
'Login Required',
'Please login to analyze images',
[{ text: 'OK', onPress: () => signOut() }]
);
return;
}
setIsSending(true);
// Add user message with image
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: 'user',
content: 'Analyze this image',
imageUri: imageAsset.uri,
createdAt: new Date(),
};
setMessages(prev => [...prev, userMsg]);
try {
// Call AI with image (using base64)
const aiResponse = await aiService.sendMessageWithImage(
'Please describe and analyze this image in detail.',
imageAsset.base64 || '',
token
);
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: aiResponse,
createdAt: new Date(),
};
setMessages(prev => [...prev, aiMsg]);
} catch (error) {
console.error('AI image analysis failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Handle authentication errors
const isAuthError =
errorMessage.includes('401') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('credentials') ||
errorMessage.includes('validate');
if (isAuthError) {
signOut();
Alert.alert(
'Session Expired',
'Your login session has expired. Please login again.',
[{ text: 'OK' }]
);
return;
}
const errorMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `⚠️ Error analyzing image: ${errorMessage}`,
createdAt: new Date(),
};
setMessages(prev => [...prev, errorMsg]);
} finally {
setIsSending(false);
setSelectedImage(null);
}
} }
}; };
@@ -836,21 +806,35 @@ export default function FlowScreen() {
{/* Bottom Input Bar */} {/* Bottom Input Bar */}
<View style={styles.inputBarContainer}> <View style={styles.inputBarContainer}>
{/* Attached image preview (optional text then send) */}
{attachedImage && (
<View style={styles.attachedImageRow}>
<Image source={{ uri: attachedImage.uri }} style={styles.attachedImageThumb} resizeMode="cover" />
<Text style={styles.attachedImageHint} numberOfLines={1}></Text>
<TouchableOpacity
style={styles.attachedImageRemove}
onPress={() => setAttachedImage(null)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Ionicons name="close-circle" size={24} color={colors.flow.textSecondary} />
</TouchableOpacity>
</View>
)}
<View style={styles.inputBar}> <View style={styles.inputBar}>
{/* Image attachment button */} {/* Image attachment button */}
<TouchableOpacity <TouchableOpacity
style={styles.inputBarButton} style={[styles.inputBarButton, attachedImage && styles.inputBarButtonActive]}
onPress={handleAddImage} onPress={handleAddImage}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Feather name="image" size={22} color={colors.flow.textSecondary} /> <Feather name="image" size={22} color={attachedImage ? colors.nautical.teal : colors.flow.textSecondary} />
</TouchableOpacity> </TouchableOpacity>
{/* Text Input */} {/* Text Input */}
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
<TextInput <TextInput
style={styles.inputBarText} style={styles.inputBarText}
placeholder="Message..." placeholder={attachedImage ? '输入对图片的说明(可选)...' : 'Message...'}
placeholderTextColor={colors.flow.textSecondary} placeholderTextColor={colors.flow.textSecondary}
value={newContent} value={newContent}
onChangeText={setNewContent} onChangeText={setNewContent}
@@ -859,8 +843,8 @@ export default function FlowScreen() {
/> />
</View> </View>
{/* Send or Voice button */} {/* Send or Voice button: show send when has text or attached image */}
{newContent.trim() || isSending ? ( {newContent.trim() || attachedImage || isSending ? (
<TouchableOpacity <TouchableOpacity
style={[styles.sendButton, isSending && styles.sendButtonDisabled]} style={[styles.sendButton, isSending && styles.sendButtonDisabled]}
onPress={handleSendMessage} onPress={handleSendMessage}
@@ -1533,6 +1517,33 @@ const styles = StyleSheet.create({
paddingTop: spacing.sm, paddingTop: spacing.sm,
backgroundColor: 'transparent', backgroundColor: 'transparent',
}, },
attachedImageRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.flow.cardBackground,
borderRadius: borderRadius.lg,
padding: spacing.sm,
marginBottom: spacing.sm,
borderWidth: 1,
borderColor: colors.flow.cardBorder,
gap: spacing.sm,
},
attachedImageThumb: {
width: 48,
height: 48,
borderRadius: borderRadius.md,
},
attachedImageHint: {
flex: 1,
fontSize: typography.fontSize.sm,
color: colors.flow.textSecondary,
},
attachedImageRemove: {
padding: spacing.xs,
},
inputBarButtonActive: {
backgroundColor: colors.nautical.paleAqua,
},
inputBar: { inputBar: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-end', alignItems: 'flex-end',

View File

@@ -221,10 +221,13 @@ export const aiService = {
const errorText = await response.text(); const errorText = await response.text();
logApiDebug('AI Image Error Response', errorText); logApiDebug('AI Image Error Response', errorText);
let errorDetail = 'AI image request failed'; let errorDetail: string = 'AI image request failed';
try { try {
const errorData = JSON.parse(errorText); const errorData = JSON.parse(errorText);
errorDetail = errorData.detail || errorDetail; const d = errorData.detail;
if (typeof d === 'string') errorDetail = d;
else if (Array.isArray(d) && d[0]?.msg) errorDetail = d.map((e: { msg?: string }) => e.msg).join('; ');
else if (d && typeof d === 'object') errorDetail = JSON.stringify(d);
} catch { } catch {
errorDetail = errorText || errorDetail; errorDetail = errorText || errorDetail;
} }