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]
This commit is contained in:
@@ -74,7 +74,8 @@ export default function FlowScreen() {
|
||||
const [newContent, setNewContent] = useState('');
|
||||
const [isSending, setIsSending] = 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
|
||||
const [selectedRole, setSelectedRole] = useState<AIRole | null>(aiRoles[0] || null);
|
||||
@@ -253,10 +254,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 () => {
|
||||
if (!newContent.trim() || isSending || !selectedRole) return;
|
||||
const hasText = !!newContent.trim();
|
||||
const hasImage = !!attachedImage;
|
||||
if ((!hasText && !hasImage) || isSending || !selectedRole) return;
|
||||
|
||||
// Check authentication
|
||||
if (!token) {
|
||||
@@ -268,11 +271,64 @@ export default function FlowScreen() {
|
||||
return;
|
||||
}
|
||||
|
||||
const userMessage = newContent.trim();
|
||||
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('');
|
||||
|
||||
// Add user message immediately
|
||||
const userMsg: ChatMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
@@ -282,25 +338,15 @@ export default function FlowScreen() {
|
||||
setMessages(prev => [...prev, userMsg]);
|
||||
|
||||
try {
|
||||
// 1. Convert current messages history to LangChain format
|
||||
const history: (HumanMessage | LangChainAIMessage | SystemMessage)[] = messages.map(msg => {
|
||||
if (msg.role === 'user') return new HumanMessage(msg.content);
|
||||
return new LangChainAIMessage(msg.content);
|
||||
});
|
||||
|
||||
// 2. Add system prompt
|
||||
const systemPrompt = new SystemMessage(selectedRole?.systemPrompt || '');
|
||||
|
||||
// 3. Add current new message
|
||||
const currentMsg = new HumanMessage(userMessage);
|
||||
|
||||
// 4. Combine all messages for LangGraph processing
|
||||
const fullMessages = [systemPrompt, ...history, currentMsg];
|
||||
|
||||
// 5. Execute via LangGraph service (handles token limits and context)
|
||||
const aiResponse = await langGraphService.execute(fullMessages, token);
|
||||
|
||||
// Add AI response
|
||||
const aiMsg: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
@@ -308,20 +354,15 @@ export default function FlowScreen() {
|
||||
createdAt: new Date(),
|
||||
};
|
||||
setMessages(prev => [...prev, aiMsg]);
|
||||
|
||||
} catch (error) {
|
||||
console.error('AI request failed:', error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
|
||||
// Handle authentication errors (401, credentials, unauthorized)
|
||||
const isAuthError =
|
||||
errorMessage.includes('401') ||
|
||||
errorMessage.includes('credentials') ||
|
||||
errorMessage.includes('Unauthorized') ||
|
||||
errorMessage.includes('Not authenticated') ||
|
||||
errorMessage.includes('validate');
|
||||
|
||||
if (isAuthError) {
|
||||
signOut();
|
||||
Alert.alert(
|
||||
@@ -331,8 +372,6 @@ export default function FlowScreen() {
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show error as AI message
|
||||
const errorMsg: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
@@ -354,17 +393,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 () => {
|
||||
// Request permission
|
||||
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('Permission Required', 'Please grant permission to access photos');
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick image
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
@@ -373,78 +410,11 @@ export default function FlowScreen() {
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const imageAsset = result.assets[0];
|
||||
setSelectedImage(imageAsset.uri);
|
||||
|
||||
// Check authentication
|
||||
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);
|
||||
}
|
||||
const asset = result.assets[0];
|
||||
setAttachedImage({
|
||||
uri: asset.uri,
|
||||
base64: asset.base64 || '',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -817,21 +787,35 @@ export default function FlowScreen() {
|
||||
|
||||
{/* Bottom Input Bar */}
|
||||
<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}>
|
||||
{/* Image attachment button */}
|
||||
<TouchableOpacity
|
||||
style={styles.inputBarButton}
|
||||
style={[styles.inputBarButton, attachedImage && styles.inputBarButtonActive]}
|
||||
onPress={handleAddImage}
|
||||
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>
|
||||
|
||||
{/* Text Input */}
|
||||
<View style={styles.inputWrapper}>
|
||||
<TextInput
|
||||
style={styles.inputBarText}
|
||||
placeholder="Message..."
|
||||
placeholder={attachedImage ? '输入对图片的说明(可选)...' : 'Message...'}
|
||||
placeholderTextColor={colors.flow.textSecondary}
|
||||
value={newContent}
|
||||
onChangeText={setNewContent}
|
||||
@@ -840,8 +824,8 @@ export default function FlowScreen() {
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Send or Voice button */}
|
||||
{newContent.trim() || isSending ? (
|
||||
{/* Send or Voice button: show send when has text or attached image */}
|
||||
{newContent.trim() || attachedImage || isSending ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButton, isSending && styles.sendButtonDisabled]}
|
||||
onPress={handleSendMessage}
|
||||
@@ -1514,6 +1498,33 @@ const styles = StyleSheet.create({
|
||||
paddingTop: spacing.sm,
|
||||
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: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
|
||||
@@ -221,10 +221,13 @@ export const aiService = {
|
||||
const errorText = await response.text();
|
||||
logApiDebug('AI Image Error Response', errorText);
|
||||
|
||||
let errorDetail = 'AI image request failed';
|
||||
let errorDetail: string = 'AI image request failed';
|
||||
try {
|
||||
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 {
|
||||
errorDetail = errorText || errorDetail;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user