Merge branch 'multi_prompt_local_storage'

This commit is contained in:
lusixing
2026-01-31 11:13:41 -08:00
7 changed files with 727 additions and 156 deletions

View File

@@ -27,7 +27,7 @@ export const DEBUG_MODE = true;
/**
* Base URL for the backend API server
*/
export const API_BASE_URL = 'http://localhost:8000';
export const API_BASE_URL = 'http://192.168.56.103:8000';
/**
* API request timeout in milliseconds
@@ -137,6 +137,44 @@ export const AI_CONFIG = {
* Mock response delay in milliseconds (for NO_BACKEND_MODE)
*/
MOCK_RESPONSE_DELAY: 500,
/**
* AI Roles configuration
*/
ROLES: [
{
id: 'reflective',
name: 'Reflective Assistant',
description: 'Helps you dive deep into your thoughts and feelings through meaningful reflection.',
systemPrompt: 'You are a helpful journal assistant. Help the user reflect on their thoughts and feelings.',
icon: 'journal-outline',
iconFamily: 'Ionicons',
},
{
id: 'creative',
name: 'Creative Spark',
description: 'A partner for brainstorming, creative writing, and exploring new ideas.',
systemPrompt: 'You are a creative brainstorming partner. Help the user explore new ideas, write stories, or look at things from a fresh perspective.',
icon: 'bulb-outline',
iconFamily: 'Ionicons',
},
{
id: 'planner',
name: 'Action Planner',
description: 'Focused on turning thoughts into actionable plans and organized goals.',
systemPrompt: 'You are a productivity coach. Help the user break down their thoughts into actionable steps and clear goals.',
icon: 'list-outline',
iconFamily: 'Ionicons',
},
{
id: 'empathetic',
name: 'Empathetic Guide',
description: 'Provides a safe, non-judgmental space for emotional support and empathy.',
systemPrompt: 'You are a supportive and empathetic friend. Listen to the user\'s concerns and provide emotional support without judgment.',
icon: 'heart-outline',
iconFamily: 'Ionicons',
},
],
} as const;
// =============================================================================

View File

@@ -9,6 +9,7 @@ import React, { createContext, useContext, useState, useEffect, ReactNode } from
import AsyncStorage from '@react-native-async-storage/async-storage';
import { User, LoginRequest, RegisterRequest } from '../types';
import { authService } from '../services/auth.service';
import { storageService } from '../services/storage.service';
// =============================================================================
// Type Definitions
@@ -137,24 +138,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
};
/**
* Sign out and clear stored auth
* Sign out and clear stored auth and session data
*/
const signOut = () => {
setUser(null);
setToken(null);
clearAuth();
//storageService.clearAllData();
};
return (
<AuthContext.Provider
value={{
user,
token,
isLoading,
<AuthContext.Provider
value={{
user,
token,
isLoading,
isInitializing,
signIn,
signUp,
signOut
signIn,
signUp,
signOut
}}
>
{children}

View File

@@ -31,6 +31,8 @@ import * as ImagePicker from 'expo-image-picker';
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
import { aiService } from '../services/ai.service';
import { useAuth } from '../context/AuthContext';
import { AI_CONFIG } from '../config';
import { storageService } from '../services/storage.service';
// =============================================================================
// Type Definitions
@@ -57,16 +59,21 @@ interface ChatSession {
// =============================================================================
export default function FlowScreen() {
const { token, signOut } = useAuth();
const { token, user, signOut } = useAuth();
const scrollViewRef = useRef<ScrollView>(null);
// Current conversation state
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [newContent, setNewContent] = useState('');
const [isSending, setIsSending] = useState(false);
const [isRecording, setIsRecording] = useState(false);
const [selectedImage, setSelectedImage] = useState<string | null>(null);
// AI Role state
const [selectedRole, setSelectedRole] = useState(AI_CONFIG.ROLES[0]);
const [showRoleModal, setShowRoleModal] = useState(false);
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
// History modal state
const [showHistoryModal, setShowHistoryModal] = useState(false);
const modalSlideAnim = useRef(new Animated.Value(0)).current;
@@ -95,23 +102,87 @@ export default function FlowScreen() {
updatedAt: new Date('2024-01-16T20:30:00'),
},
]);
// Header date display
const today = new Date();
const dateStr = today.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric'
const dateStr = today.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric'
});
// Auto-scroll to bottom when new messages arrive
// Load history on mount
useEffect(() => {
const loadHistory = async () => {
if (!user) return;
try {
console.log('[FlowScreen] Loading chat history...');
const savedHistory = await storageService.getChatHistory(user.id);
if (savedHistory && savedHistory.length > 0) {
const formattedHistory = savedHistory.map((session: any) => ({
...session,
createdAt: new Date(session.createdAt),
updatedAt: new Date(session.updatedAt),
messages: session.messages.map((msg: any) => ({
...msg,
createdAt: new Date(msg.createdAt)
}))
}));
setChatHistory(formattedHistory);
console.log('[FlowScreen] Chat history loaded:', formattedHistory.length, 'sessions');
} else {
console.log('[FlowScreen] No chat history found');
}
} catch (error) {
console.error('Failed to load history:', error);
}
};
loadHistory();
}, [user]);
// Load messages whenever role changes
useEffect(() => {
const loadRoleMessages = async () => {
if (!user) return;
try {
const savedMessages = await storageService.getCurrentChat(selectedRole.id, user.id);
if (savedMessages) {
const formattedMessages = savedMessages.map((msg: any) => ({
...msg,
createdAt: new Date(msg.createdAt)
}));
setMessages(formattedMessages);
} else {
setMessages([]);
}
} catch (error) {
console.error(`Failed to load messages for role ${selectedRole.id}:`, error);
setMessages([]);
}
};
loadRoleMessages();
}, [selectedRole.id, user]);
// Save current messages for the active role when they change
useEffect(() => {
if (user && messages.length >= 0) { // Save even if empty to allow clearing
storageService.saveCurrentChat(selectedRole.id, messages, user.id);
}
if (messages.length > 0) {
setTimeout(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, 100);
}
}, [messages]);
}, [messages, selectedRole.id, user]);
// Save history when it changes
useEffect(() => {
if (user) {
storageService.saveChatHistory(chatHistory, user.id);
}
}, [chatHistory, user]);
// Modal animation control
const openHistoryModal = () => {
@@ -143,7 +214,7 @@ export default function FlowScreen() {
*/
const handleSendMessage = async () => {
if (!newContent.trim() || isSending) return;
// Check authentication
if (!token) {
Alert.alert(
@@ -153,11 +224,11 @@ export default function FlowScreen() {
);
return;
}
const userMessage = newContent.trim();
setIsSending(true);
setNewContent('');
// Add user message immediately
const userMsg: ChatMessage = {
id: Date.now().toString(),
@@ -166,11 +237,11 @@ export default function FlowScreen() {
createdAt: new Date(),
};
setMessages(prev => [...prev, userMsg]);
try {
// Call AI proxy
const aiResponse = await aiService.sendMessage(userMessage, token);
// Call AI proxy with selected role's system prompt
const aiResponse = await aiService.sendMessage(userMessage, token, selectedRole.systemPrompt);
// Add AI response
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
@@ -179,20 +250,20 @@ 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') ||
const isAuthError =
errorMessage.includes('401') ||
errorMessage.includes('credentials') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('Not authenticated') ||
errorMessage.includes('validate');
if (isAuthError) {
signOut();
Alert.alert(
@@ -202,7 +273,7 @@ export default function FlowScreen() {
);
return;
}
// Show error as AI message
const errorMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
@@ -246,7 +317,7 @@ export default function FlowScreen() {
if (!result.canceled && result.assets[0]) {
const imageAsset = result.assets[0];
setSelectedImage(imageAsset.uri);
// Check authentication
if (!token) {
Alert.alert(
@@ -289,9 +360,9 @@ export default function FlowScreen() {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Handle authentication errors
const isAuthError =
errorMessage.includes('401') ||
errorMessage.includes('Unauthorized') ||
const isAuthError =
errorMessage.includes('401') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('credentials') ||
errorMessage.includes('validate');
@@ -334,9 +405,12 @@ export default function FlowScreen() {
};
setChatHistory(prev => [newSession, ...prev]);
}
// Clear current messages
// Clear current messages and storage for this role
setMessages([]);
if (user) {
storageService.saveCurrentChat(selectedRole.id, [], user.id);
}
closeHistoryModal();
};
@@ -355,7 +429,7 @@ export default function FlowScreen() {
};
setChatHistory(prev => [currentSession, ...prev.filter(s => s.id !== session.id)]);
}
// Load selected conversation
setMessages(session.messages);
closeHistoryModal();
@@ -370,8 +444,8 @@ export default function FlowScreen() {
'Are you sure you want to delete this conversation?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
{
text: 'Delete',
style: 'destructive',
onPress: () => setChatHistory(prev => prev.filter(s => s.id !== sessionId))
},
@@ -400,10 +474,10 @@ export default function FlowScreen() {
*/
const renderMessage = (message: ChatMessage, index: number) => {
const isUser = message.role === 'user';
return (
<View
key={message.id}
<View
key={message.id}
style={[
styles.messageBubble,
isUser ? styles.userBubble : styles.aiBubble
@@ -420,8 +494,8 @@ export default function FlowScreen() {
]}>
{/* Show image if present */}
{message.imageUri && (
<Image
source={{ uri: message.imageUri }}
<Image
source={{ uri: message.imageUri }}
style={styles.messageImage}
resizeMode="cover"
/>
@@ -451,9 +525,9 @@ export default function FlowScreen() {
<View style={styles.emptyIcon}>
<Feather name="feather" size={48} color={colors.nautical.seafoam} />
</View>
<Text style={styles.emptyTitle}>Start a conversation</Text>
<Text style={styles.emptyTitle}>Chatting with {selectedRole.name}</Text>
<Text style={styles.emptySubtitle}>
Ask me anything or share your thoughts
{selectedRole.description}
</Text>
</View>
);
@@ -462,7 +536,7 @@ export default function FlowScreen() {
* Render history item in modal
*/
const renderHistoryItem = ({ item }: { item: ChatSession }) => (
<TouchableOpacity
<TouchableOpacity
style={styles.historyItem}
onPress={() => handleLoadHistory(item)}
onLongPress={() => handleDeleteHistory(item.id)}
@@ -504,9 +578,26 @@ export default function FlowScreen() {
<Text style={styles.headerDate}>{dateStr}</Text>
</View>
</View>
{/* Role Header Dropdown */}
<TouchableOpacity
style={styles.headerRoleButton}
onPress={() => setShowRoleModal(true)}
activeOpacity={0.7}
>
<Ionicons
name={selectedRole.icon as any}
size={16}
color={colors.nautical.teal}
/>
<Text style={styles.headerRoleText} numberOfLines={1}>
{selectedRole.name}
</Text>
<Ionicons name="chevron-down" size={14} color={colors.flow.textSecondary} />
</TouchableOpacity>
{/* History Button */}
<TouchableOpacity
<TouchableOpacity
style={styles.historyButton}
onPress={openHistoryModal}
>
@@ -515,7 +606,7 @@ export default function FlowScreen() {
</View>
{/* Chat Messages */}
<ScrollView
<ScrollView
ref={scrollViewRef}
style={styles.messagesContainer}
showsVerticalScrollIndicator={false}
@@ -529,7 +620,7 @@ export default function FlowScreen() {
) : (
messages.map((message, index) => renderMessage(message, index))
)}
{/* Loading indicator when sending */}
{isSending && (
<View style={[styles.messageBubble, styles.aiBubble]}>
@@ -541,7 +632,7 @@ export default function FlowScreen() {
</View>
</View>
)}
<View style={{ height: 20 }} />
</ScrollView>
@@ -549,7 +640,7 @@ export default function FlowScreen() {
<View style={styles.inputBarContainer}>
<View style={styles.inputBar}>
{/* Image attachment button */}
<TouchableOpacity
<TouchableOpacity
style={styles.inputBarButton}
onPress={handleAddImage}
activeOpacity={0.7}
@@ -572,7 +663,7 @@ export default function FlowScreen() {
{/* Send or Voice button */}
{newContent.trim() || isSending ? (
<TouchableOpacity
<TouchableOpacity
style={[styles.sendButton, isSending && styles.sendButtonDisabled]}
onPress={handleSendMessage}
activeOpacity={0.8}
@@ -590,15 +681,15 @@ export default function FlowScreen() {
</LinearGradient>
</TouchableOpacity>
) : (
<TouchableOpacity
<TouchableOpacity
style={[styles.inputBarButton, isRecording && styles.recordingButton]}
onPress={handleVoiceRecord}
activeOpacity={0.7}
>
<Feather
name="mic"
size={22}
color={isRecording ? '#fff' : colors.flow.textSecondary}
<Feather
name="mic"
size={22}
color={isRecording ? '#fff' : colors.flow.textSecondary}
/>
</TouchableOpacity>
)}
@@ -607,17 +698,16 @@ export default function FlowScreen() {
</SafeAreaView>
</LinearGradient>
{/* History Modal - Background appears instantly, content slides up */}
<Modal
visible={showHistoryModal}
<Modal
visible={showHistoryModal}
animationType="none"
transparent
transparent
onRequestClose={closeHistoryModal}
>
<TouchableWithoutFeedback onPress={closeHistoryModal}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={(e) => e.stopPropagation()}>
<Animated.View
<Animated.View
style={[
styles.modalContent,
{
@@ -630,46 +720,129 @@ export default function FlowScreen() {
}
]}
>
<View style={styles.modalHandle} />
{/* Modal Header */}
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Chat History</Text>
<TouchableOpacity
style={styles.newChatButton}
onPress={handleNewChat}
>
<Ionicons name="add" size={20} color="#fff" />
<Text style={styles.newChatText}>New Chat</Text>
</TouchableOpacity>
</View>
{/* History List */}
<FlatList
data={chatHistory}
renderItem={renderHistoryItem}
keyExtractor={item => item.id}
style={styles.historyList}
ListEmptyComponent={
<View style={styles.historyEmpty}>
<Ionicons name="chatbubbles-outline" size={48} color={colors.flow.textSecondary} />
<Text style={styles.historyEmptyText}>No chat history yet</Text>
<View style={styles.modalHandle} />
{/* Modal Header */}
<View style={styles.modalHeader}>
<Text style={styles.modalTitle}>Chat History</Text>
<TouchableOpacity
style={styles.newChatButton}
onPress={handleNewChat}
>
<Ionicons name="add" size={20} color="#fff" />
<Text style={styles.newChatText}>New Chat</Text>
</TouchableOpacity>
</View>
}
/>
{/* Close Button */}
<TouchableOpacity
style={styles.closeButton}
onPress={closeHistoryModal}
>
<Text style={styles.closeButtonText}>Close</Text>
</TouchableOpacity>
{/* History List */}
<FlatList
data={chatHistory}
renderItem={renderHistoryItem}
keyExtractor={item => item.id}
style={styles.historyList}
ListEmptyComponent={
<View style={styles.historyEmpty}>
<Ionicons name="chatbubbles-outline" size={48} color={colors.flow.textSecondary} />
<Text style={styles.historyEmptyText}>No chat history yet</Text>
</View>
}
/>
{/* Close Button */}
<TouchableOpacity
style={styles.closeButton}
onPress={closeHistoryModal}
>
<Text style={styles.closeButtonText}>Close</Text>
</TouchableOpacity>
</Animated.View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
{/* Role Selection Modal */}
<Modal
visible={showRoleModal}
animationType="fade"
transparent
onRequestClose={() => setShowRoleModal(false)}
>
<TouchableWithoutFeedback onPress={() => setShowRoleModal(false)}>
<View style={styles.modalOverlay}>
<TouchableWithoutFeedback onPress={(e) => e.stopPropagation()}>
<View style={[styles.modalContent, styles.roleModalContent]}>
<View style={styles.modalHandle} />
<Text style={styles.modalTitle}>Choose AI Assistant</Text>
<ScrollView style={styles.roleList} showsVerticalScrollIndicator={false}>
{AI_CONFIG.ROLES.map((role) => (
<View key={role.id} style={styles.roleItemContainer}>
<View
style={[
styles.roleItem,
selectedRole.id === role.id && styles.roleItemActive
]}
>
<TouchableOpacity
style={styles.roleSelectionArea}
onPress={() => {
setSelectedRole(role as any);
setShowRoleModal(false);
}}
>
<View style={[
styles.roleItemIcon,
selectedRole.id === role.id && styles.roleItemIconActive
]}>
<Ionicons
name={role.icon as any}
size={20}
color={selectedRole.id === role.id ? '#fff' : colors.nautical.teal}
/>
</View>
<Text style={[
styles.roleItemName,
selectedRole.id === role.id && styles.roleItemNameActive
]}>
{role.name}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.infoButton}
onPress={() => {
setExpandedRoleId(expandedRoleId === role.id ? null : role.id);
}}
>
<Ionicons
name={expandedRoleId === role.id ? "close-circle-outline" : "information-circle-outline"}
size={24}
color={expandedRoleId === role.id ? colors.nautical.coral : colors.flow.textSecondary}
/>
</TouchableOpacity>
</View>
{expandedRoleId === role.id && (
<View style={styles.roleDescription}>
<Text style={styles.roleDescriptionText}>{role.description}</Text>
</View>
)}
</View>
))}
</ScrollView>
<TouchableOpacity
style={styles.closeButton}
onPress={() => setShowRoleModal(false)}
>
<Text style={styles.closeButtonText}>Cancel</Text>
</TouchableOpacity>
</View>
</TouchableWithoutFeedback>
</View>
</TouchableWithoutFeedback>
</Modal>
</View>
);
}
@@ -680,16 +853,16 @@ export default function FlowScreen() {
const styles = StyleSheet.create({
// Container styles
container: {
flex: 1
container: {
flex: 1
},
gradient: {
flex: 1
gradient: {
flex: 1
},
safeArea: {
flex: 1
safeArea: {
flex: 1
},
// Header styles
header: {
flexDirection: 'row',
@@ -697,12 +870,33 @@ const styles = StyleSheet.create({
alignItems: 'center',
paddingHorizontal: spacing.base,
paddingTop: spacing.sm,
paddingBottom: spacing.md,
paddingBottom: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: 'rgba(0,0,0,0.05)',
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
flex: 1,
},
headerRoleButton: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.flow.cardBackground,
paddingHorizontal: spacing.sm,
paddingVertical: 6,
borderRadius: borderRadius.full,
marginHorizontal: spacing.sm,
borderWidth: 1,
borderColor: colors.flow.cardBorder,
maxWidth: '40%',
},
headerRoleText: {
fontSize: typography.fontSize.xs,
fontWeight: '600',
color: colors.flow.text,
marginHorizontal: 4,
},
iconCircle: {
width: 44,
@@ -731,20 +925,20 @@ const styles = StyleSheet.create({
alignItems: 'center',
...shadows.soft,
},
// Messages container styles
messagesContainer: {
flex: 1
messagesContainer: {
flex: 1
},
messagesContent: {
padding: spacing.base,
paddingTop: 0
messagesContent: {
padding: spacing.base,
paddingTop: 0
},
emptyContent: {
flex: 1,
justifyContent: 'center',
},
// Empty state styles
emptyState: {
alignItems: 'center',
@@ -772,7 +966,97 @@ const styles = StyleSheet.create({
color: colors.flow.textSecondary,
textAlign: 'center',
},
// Role selection styles
roleDropdown: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.flow.cardBackground,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: borderRadius.lg,
marginBottom: spacing.md,
...shadows.soft,
borderWidth: 1,
borderColor: colors.flow.cardBorder,
},
roleIcon: {
marginRight: spacing.sm,
},
roleDropdownText: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.flow.text,
marginRight: spacing.xs,
},
roleModalContent: {
paddingBottom: spacing.xl,
},
roleList: {
marginTop: spacing.sm,
maxHeight: 400,
},
roleItemContainer: {
marginBottom: spacing.sm,
},
roleItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderRadius: borderRadius.lg,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: 'transparent',
overflow: 'hidden',
},
roleItemActive: {
backgroundColor: colors.nautical.paleAqua,
borderColor: colors.nautical.lightMint,
},
roleSelectionArea: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
},
roleItemIcon: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.flow.backgroundGradientStart,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
roleItemIconActive: {
backgroundColor: colors.nautical.teal,
},
roleItemName: {
fontSize: typography.fontSize.base,
fontWeight: '500',
color: colors.flow.text,
},
roleItemNameActive: {
fontWeight: '700',
color: colors.nautical.teal,
},
infoButton: {
padding: spacing.md,
justifyContent: 'center',
alignItems: 'center',
},
roleDescription: {
paddingHorizontal: spacing.md + 36 + spacing.md, // icon width + margins
paddingBottom: spacing.sm,
paddingTop: 0,
},
roleDescriptionText: {
fontSize: typography.fontSize.sm,
color: colors.flow.textSecondary,
fontStyle: 'italic',
lineHeight: 18,
},
// Message bubble styles
messageBubble: {
flexDirection: 'row',
@@ -837,7 +1121,7 @@ const styles = StyleSheet.create({
aiMessageTime: {
color: colors.flow.textSecondary,
},
// Input bar styles
inputBarContainer: {
paddingHorizontal: spacing.base,
@@ -893,7 +1177,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
// Modal styles
modalOverlay: {
flex: 1,
@@ -941,7 +1225,7 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: '#fff',
},
// History list styles
historyList: {
flex: 1,

View File

@@ -222,7 +222,8 @@ export default function MeScreen() {
const [showHeritageModal, setShowHeritageModal] = useState(false);
const [showThemeModal, setShowThemeModal] = useState(false);
const [isDarkMode, setIsDarkMode] = useState(false);
const [showSignOutModal, setShowSignOutModal] = useState(false);
// Heritage / Fleet Legacy states
const [heirs, setHeirs] = useState<Heir[]>(initialHeirs);
const [showAddHeirModal, setShowAddHeirModal] = useState(false);
@@ -296,18 +297,14 @@ export default function MeScreen() {
};
const handleAbandonIsland = () => {
Alert.alert(
'Sign Out',
'Are you sure you want to sign out?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Sign Out',
style: 'destructive',
onPress: signOut
},
]
);
console.log('[MeScreen] Sign out button clicked');
setShowSignOutModal(true);
};
const handleConfirmSignOut = () => {
console.log('[MeScreen] User confirmed sign out');
setShowSignOutModal(false);
signOut();
};
const handleResetVault = async () => {
@@ -632,10 +629,10 @@ export default function MeScreen() {
activeOpacity={0.85}
>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: spacing.sm }}>
<Ionicons
name={isDarkMode ? 'moon' : 'sunny'}
size={18}
color={colors.me.primary}
<Ionicons
name={isDarkMode ? 'moon' : 'sunny'}
size={18}
color={colors.me.primary}
/>
<Text style={styles.sanctumText}>Dark Mode</Text>
</View>
@@ -1441,6 +1438,46 @@ export default function MeScreen() {
</View>
</View>
</Modal>
{/* Sign Out Confirmation Modal */}
<Modal
visible={showSignOutModal}
animationType="fade"
transparent
onRequestClose={() => setShowSignOutModal(false)}
>
<View style={styles.spiritOverlay}>
<View style={styles.signOutModal}>
<View style={styles.signOutHeader}>
<View style={styles.signOutIcon}>
<Feather name="log-out" size={32} color={colors.nautical.coral} />
</View>
<Text style={styles.signOutTitle}>Sign Out</Text>
<Text style={styles.signOutMessage}>
Are you sure you want to sign out? You'll need to log in again to access your account.
</Text>
</View>
<View style={styles.signOutButtons}>
<TouchableOpacity
style={styles.signOutCancelButton}
onPress={() => setShowSignOutModal(false)}
activeOpacity={0.85}
>
<Text style={styles.signOutCancelText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.signOutConfirmButton}
onPress={handleConfirmSignOut}
activeOpacity={0.85}
>
<Text style={styles.signOutConfirmText}>Sign Out</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
);
}
@@ -2379,4 +2416,71 @@ const styles = StyleSheet.create({
justifyContent: 'center',
zIndex: 10,
},
// Sign Out Modal Styles
signOutModal: {
backgroundColor: colors.me.cardBackground,
borderRadius: borderRadius.xl,
padding: spacing.xl,
marginHorizontal: spacing.xl,
maxWidth: 400,
width: '100%',
...shadows.medium,
},
signOutHeader: {
alignItems: 'center',
marginBottom: spacing.xl,
},
signOutIcon: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: `${colors.nautical.coral}15`,
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.base,
},
signOutTitle: {
fontSize: typography.fontSize.xl,
fontWeight: '700',
color: colors.me.text,
marginBottom: spacing.sm,
},
signOutMessage: {
fontSize: typography.fontSize.base,
color: colors.me.textSecondary,
textAlign: 'center',
lineHeight: typography.fontSize.base * 1.5,
},
signOutButtons: {
flexDirection: 'row',
gap: spacing.md,
},
signOutCancelButton: {
flex: 1,
paddingVertical: spacing.base,
paddingHorizontal: spacing.lg,
borderRadius: borderRadius.lg,
backgroundColor: colors.me.cardBorder,
alignItems: 'center',
justifyContent: 'center',
},
signOutCancelText: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: colors.me.text,
},
signOutConfirmButton: {
flex: 1,
paddingVertical: spacing.base,
paddingHorizontal: spacing.lg,
borderRadius: borderRadius.lg,
backgroundColor: colors.nautical.coral,
alignItems: 'center',
justifyContent: 'center',
},
signOutConfirmText: {
fontSize: typography.fontSize.base,
fontWeight: '600',
color: '#fff',
},
});

View File

@@ -95,7 +95,7 @@ export const aiService = {
}
const url = buildApiUrl(API_ENDPOINTS.AI.PROXY);
logApiDebug('AI Request', {
url,
hasToken: !!token,
@@ -114,7 +114,7 @@ export const aiService = {
if (!response.ok) {
const errorText = await response.text();
logApiDebug('AI Error Response', errorText);
let errorDetail = 'AI request failed';
try {
const errorData = JSON.parse(errorText);
@@ -131,7 +131,7 @@ export const aiService = {
model: data.model,
choicesCount: data.choices?.length,
});
return data;
} catch (error) {
console.error('AI proxy error:', error);
@@ -143,13 +143,14 @@ export const aiService = {
* Simple helper for single message chat
* @param content - User message content
* @param token - JWT token for authentication
* @param systemPrompt - Optional custom system prompt
* @returns AI response text
*/
async sendMessage(content: string, token?: string): Promise<string> {
async sendMessage(content: string, token?: string, systemPrompt?: string): Promise<string> {
const messages: AIMessage[] = [
{
role: 'system',
content: AI_CONFIG.DEFAULT_SYSTEM_PROMPT,
content: systemPrompt || AI_CONFIG.DEFAULT_SYSTEM_PROMPT,
},
{
role: 'user',
@@ -179,7 +180,7 @@ export const aiService = {
}
const url = buildApiUrl(API_ENDPOINTS.AI.PROXY);
logApiDebug('AI Image Request', {
url,
hasToken: !!token,
@@ -217,7 +218,7 @@ export const aiService = {
if (!response.ok) {
const errorText = await response.text();
logApiDebug('AI Image Error Response', errorText);
let errorDetail = 'AI image request failed';
try {
const errorData = JSON.parse(errorText);
@@ -233,7 +234,7 @@ export const aiService = {
id: data.id,
model: data.model,
});
return data.choices[0]?.message?.content || 'No response';
} catch (error) {
console.error('AI image proxy error:', error);

View File

@@ -0,0 +1,120 @@
/**
* Storage Service
*
* Handles local persistence of chat history and active conversations
* using AsyncStorage with user-specific isolation.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
// =============================================================================
// Constants
// =============================================================================
const STORAGE_KEYS = {
CHAT_HISTORY: '@sentinel:chat_history',
CURRENT_MESSAGES: '@sentinel:current_messages',
} as const;
// =============================================================================
// Service Implementation
// =============================================================================
export const storageService = {
/**
* Get user-specific storage key
*/
getUserKey(baseKey: string, userId: string | number): string {
return `${baseKey}:user_${userId}`;
},
/**
* Save the complete list of chat sessions to local storage for a specific user
*/
async saveChatHistory(history: any[], userId: string | number): Promise<void> {
try {
const jsonValue = JSON.stringify(history);
const key = this.getUserKey(STORAGE_KEYS.CHAT_HISTORY, userId);
await AsyncStorage.setItem(key, jsonValue);
console.log(`[Storage] Saved chat history for user ${userId}:`, history.length, 'sessions');
} catch (e) {
console.error('Error saving chat history:', e);
}
},
/**
* Load the list of chat sessions from local storage for a specific user
*/
async getChatHistory(userId: string | number): Promise<any[]> {
try {
const key = this.getUserKey(STORAGE_KEYS.CHAT_HISTORY, userId);
const jsonValue = await AsyncStorage.getItem(key);
const result = jsonValue != null ? JSON.parse(jsonValue) : [];
console.log(`[Storage] Loaded chat history for user ${userId}:`, result.length, 'sessions');
return result;
} catch (e) {
console.error('Error getting chat history:', e);
return [];
}
},
/**
* Save the current active conversation messages for a specific role and user
*/
async saveCurrentChat(roleId: string, messages: any[], userId: string | number): Promise<void> {
try {
const jsonValue = JSON.stringify(messages);
const key = `${this.getUserKey(STORAGE_KEYS.CURRENT_MESSAGES, userId)}:${roleId}`;
await AsyncStorage.setItem(key, jsonValue);
console.log(`[Storage] Saved current chat for user ${userId}, role ${roleId}:`, messages.length, 'messages');
} catch (e) {
console.error(`Error saving current chat for role ${roleId}:`, e);
}
},
/**
* Load the current active conversation messages for a specific role and user
*/
async getCurrentChat(roleId: string, userId: string | number): Promise<any[]> {
try {
const key = `${this.getUserKey(STORAGE_KEYS.CURRENT_MESSAGES, userId)}:${roleId}`;
const jsonValue = await AsyncStorage.getItem(key);
const result = jsonValue != null ? JSON.parse(jsonValue) : [];
console.log(`[Storage] Loaded current chat for user ${userId}, role ${roleId}:`, result.length, 'messages');
return result;
} catch (e) {
console.error(`Error getting current chat for role ${roleId}:`, e);
return [];
}
},
/**
* Clear all stored chat data for a specific user
*/
async clearUserData(userId: string | number): Promise<void> {
try {
const keys = await AsyncStorage.getAllKeys();
const userPrefix = `:user_${userId}`;
const userKeys = keys.filter(key => key.includes(userPrefix));
await AsyncStorage.multiRemove(userKeys);
console.log(`[Storage] Cleared all data for user ${userId}:`, userKeys.length, 'keys removed');
} catch (e) {
console.error('Error clearing user data:', e);
}
},
/**
* Clear all stored chat data (all users)
*/
async clearAllData(): Promise<void> {
try {
const keys = await AsyncStorage.getAllKeys();
const sentinelKeys = keys.filter(key => key.startsWith('@sentinel:'));
await AsyncStorage.multiRemove(sentinelKeys);
console.log('[Storage] Cleared all data:', sentinelKeys.length, 'keys removed');
} catch (e) {
console.error('Error clearing storage data:', e);
}
}
};