Merge branch 'main' into mobile-demo
This commit is contained in:
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user