Compare commits
20 Commits
dev_pr_tes
...
d44ccc3ace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d44ccc3ace | ||
|
|
e33ea62e35 | ||
|
|
96d95a50fc | ||
|
|
c1ce804d14 | ||
|
|
0aab9a838b | ||
|
|
6822638d47 | ||
|
|
5c1172a912 | ||
|
|
b5373c2d9a | ||
|
|
3ffcc60ee8 | ||
|
|
50e78c84c9 | ||
|
|
8e6c621f7b | ||
|
|
7b8511f080 | ||
|
|
f6fa19d0b2 | ||
|
|
536513ab3f | ||
|
|
240a7eea8b | ||
| d64a6557a8 | |||
|
|
22dc3abf65 | ||
|
|
ed1f6fc49d | ||
|
|
218b2e8b29 | ||
| 56bb72aab8 |
6
App.tsx
@@ -4,8 +4,10 @@
|
|||||||
* Main application component with authentication routing.
|
* Main application component with authentication routing.
|
||||||
* Shows loading screen while restoring auth state.
|
* Shows loading screen while restoring auth state.
|
||||||
*/
|
*/
|
||||||
|
import './src/polyfills';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { NavigationContainer } from '@react-navigation/native';
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||||
@@ -15,6 +17,10 @@ import AuthNavigator from './src/navigation/AuthNavigator';
|
|||||||
import { AuthProvider, useAuth } from './src/context/AuthContext';
|
import { AuthProvider, useAuth } from './src/context/AuthContext';
|
||||||
import { colors } from './src/theme/colors';
|
import { colors } from './src/theme/colors';
|
||||||
|
|
||||||
|
if (typeof globalThis !== 'undefined' && !globalThis.Buffer) {
|
||||||
|
globalThis.Buffer = Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Loading screen shown while restoring auth state
|
* Loading screen shown while restoring auth state
|
||||||
*/
|
*/
|
||||||
|
|||||||
6
app.json
@@ -19,14 +19,10 @@
|
|||||||
"bundleIdentifier": "com.sentinel.app"
|
"bundleIdentifier": "com.sentinel.app"
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
|
||||||
"backgroundColor": "#459E9E"
|
|
||||||
},
|
|
||||||
"package": "com.sentinel.app"
|
"package": "com.sentinel.app"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png",
|
"favicon": "./assets/icon.png",
|
||||||
"bundler": "metro"
|
"bundler": "metro"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 86 B After Width: | Height: | Size: 70 B |
|
Before Width: | Height: | Size: 105 B After Width: | Height: | Size: 70 B |
BIN
assets/icon.png
|
Before Width: | Height: | Size: 52 B After Width: | Height: | Size: 70 B |
BIN
assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 70 B |
|
Before Width: | Height: | Size: 108 B After Width: | Height: | Size: 70 B |
15
metro.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
const { getDefaultConfig } = require('expo/metro-config');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const config = getDefaultConfig(__dirname);
|
||||||
|
|
||||||
|
config.resolver.extraNodeModules = {
|
||||||
|
...config.resolver.extraNodeModules,
|
||||||
|
crypto: path.resolve(__dirname, 'src/utils/crypto_polyfill.ts'),
|
||||||
|
stream: require.resolve('readable-stream'),
|
||||||
|
vm: require.resolve('vm-browserify'),
|
||||||
|
async_hooks: path.resolve(__dirname, 'src/utils/async_hooks_mock.ts'),
|
||||||
|
'node:async_hooks': path.resolve(__dirname, 'src/utils/async_hooks_mock.ts'),
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = config;
|
||||||
723
package-lock.json
generated
14
package.json
@@ -11,13 +11,20 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/metro-runtime": "~4.0.1",
|
"@expo/metro-runtime": "~4.0.1",
|
||||||
"@expo/vector-icons": "~14.0.4",
|
"@expo/vector-icons": "~14.0.4",
|
||||||
|
"@langchain/core": "^1.1.18",
|
||||||
|
"@langchain/langgraph": "^1.1.3",
|
||||||
|
"@noble/ciphers": "^1.3.0",
|
||||||
|
"@noble/hashes": "^1.8.0",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
"@react-navigation/bottom-tabs": "^6.6.1",
|
"@react-navigation/bottom-tabs": "^6.6.1",
|
||||||
"@react-navigation/native": "^6.1.18",
|
"@react-navigation/native": "^6.1.18",
|
||||||
"@react-navigation/native-stack": "^6.11.0",
|
"@react-navigation/native-stack": "^6.11.0",
|
||||||
|
"bip39": "^3.1.0",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"expo": "~52.0.0",
|
"expo": "~52.0.0",
|
||||||
"expo-asset": "~11.0.5",
|
"expo-asset": "~11.0.5",
|
||||||
"expo-constants": "~17.0.8",
|
"expo-constants": "~17.0.8",
|
||||||
|
"expo-crypto": "~14.0.2",
|
||||||
"expo-font": "~13.0.4",
|
"expo-font": "~13.0.4",
|
||||||
"expo-haptics": "~14.0.0",
|
"expo-haptics": "~14.0.0",
|
||||||
"expo-image-picker": "^17.0.10",
|
"expo-image-picker": "^17.0.10",
|
||||||
@@ -27,11 +34,14 @@
|
|||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-native": "^0.76.9",
|
"react-native": "^0.76.9",
|
||||||
"react-native-gesture-handler": "~2.20.2",
|
"react-native-gesture-handler": "~2.20.2",
|
||||||
"react-native-view-shot": "^3.8.0",
|
|
||||||
"react-native-reanimated": "~3.16.1",
|
"react-native-reanimated": "~3.16.1",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
"react-native-screens": "~4.4.0",
|
"react-native-screens": "~4.4.0",
|
||||||
"react-native-web": "~0.19.13"
|
"react-native-svg": "^15.15.2",
|
||||||
|
"react-native-view-shot": "^3.8.0",
|
||||||
|
"react-native-web": "~0.19.13",
|
||||||
|
"readable-stream": "^4.7.0",
|
||||||
|
"vm-browserify": "^1.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.25.2",
|
"@babel/core": "^7.25.2",
|
||||||
|
|||||||
@@ -62,20 +62,18 @@ export default function BiometricModal({
|
|||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
Animated.timing(scanAnimation, {
|
Animated.timing(scanAnimation, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
duration: 800,
|
duration: 400,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
Animated.timing(scanAnimation, {
|
Animated.timing(scanAnimation, {
|
||||||
toValue: 0,
|
toValue: 0,
|
||||||
duration: 800,
|
duration: 400,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
]),
|
]),
|
||||||
{ iterations: 2 }
|
{ iterations: 1 }
|
||||||
).start(() => {
|
).start(() => {
|
||||||
setTimeout(() => {
|
onSuccess();
|
||||||
onSuccess();
|
|
||||||
}, 300);
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
85
src/components/puppet/FlowPuppetSlot.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* FlowPuppetSlot - Slot for FlowScreen to show interactive AI puppet.
|
||||||
|
* Composes PuppetView and optional action buttons; does not depend on FlowScreen logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
|
import { PuppetView } from './PuppetView';
|
||||||
|
import type { FlowPuppetSlotProps, PuppetAction } from './types';
|
||||||
|
import { colors } from '../../theme/colors';
|
||||||
|
import { borderRadius, spacing } from '../../theme/colors';
|
||||||
|
|
||||||
|
const ACTIONS: PuppetAction[] = ['smile', 'jump', 'shake'];
|
||||||
|
|
||||||
|
export function FlowPuppetSlot({
|
||||||
|
currentAction,
|
||||||
|
isTalking,
|
||||||
|
onAction,
|
||||||
|
showActionButtons = true,
|
||||||
|
}: FlowPuppetSlotProps) {
|
||||||
|
const [localAction, setLocalAction] = useState<PuppetAction>(currentAction);
|
||||||
|
|
||||||
|
const effectiveAction = currentAction !== 'idle' ? currentAction : localAction;
|
||||||
|
|
||||||
|
const handleAction = useCallback(
|
||||||
|
(action: PuppetAction) => {
|
||||||
|
setLocalAction(action);
|
||||||
|
onAction?.(action);
|
||||||
|
if (['smile', 'wave', 'nod', 'shake', 'jump'].includes(action)) {
|
||||||
|
setTimeout(() => {
|
||||||
|
setLocalAction((prev) => (prev === action ? 'idle' : prev));
|
||||||
|
onAction?.('idle');
|
||||||
|
}, 2600);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onAction]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.wrapper}>
|
||||||
|
<PuppetView action={effectiveAction} isTalking={isTalking} />
|
||||||
|
{showActionButtons && (
|
||||||
|
<View style={styles.actions}>
|
||||||
|
{ACTIONS.map((act) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={act}
|
||||||
|
style={styles.actionBtn}
|
||||||
|
onPress={() => handleAction(act)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.actionLabel}>{act}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
wrapper: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
actionBtn: {
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
backgroundColor: colors.flow.cardBackground,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.flow.cardBorder,
|
||||||
|
},
|
||||||
|
actionLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.flow.primary,
|
||||||
|
textTransform: 'capitalize',
|
||||||
|
},
|
||||||
|
});
|
||||||
340
src/components/puppet/PuppetView.tsx
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
/**
|
||||||
|
* PuppetView - Interactive blue spirit avatar (React Native).
|
||||||
|
* Port of airi---interactive-ai-puppet Puppet with same actions:
|
||||||
|
* idle, wave, nod, shake, jump, think; mouth reflects isTalking.
|
||||||
|
* Code isolated so FlowScreen stays unchanged except composition.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import { View, StyleSheet, Animated, Easing } from 'react-native';
|
||||||
|
import { PuppetViewProps } from './types';
|
||||||
|
|
||||||
|
const PUPPET_SIZE = 160;
|
||||||
|
|
||||||
|
export function PuppetView({ action, isTalking }: PuppetViewProps) {
|
||||||
|
const floatAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const bounceAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const shakeAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const thinkScale = useRef(new Animated.Value(1)).current;
|
||||||
|
const thinkOpacity = useRef(new Animated.Value(1)).current;
|
||||||
|
const smileScale = useRef(new Animated.Value(1)).current;
|
||||||
|
|
||||||
|
// Idle: gentle float
|
||||||
|
useEffect(() => {
|
||||||
|
if (action !== 'idle') return;
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(floatAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 2000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.inOut(Easing.ease),
|
||||||
|
}),
|
||||||
|
Animated.timing(floatAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 2000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.inOut(Easing.ease),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [action, floatAnim]);
|
||||||
|
|
||||||
|
// Smile: exaggerated smile scale pulse
|
||||||
|
useEffect(() => {
|
||||||
|
if (action !== 'smile') {
|
||||||
|
smileScale.setValue(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(smileScale, {
|
||||||
|
toValue: 1.12,
|
||||||
|
duration: 400,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.out(Easing.ease),
|
||||||
|
}),
|
||||||
|
Animated.timing(smileScale, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 400,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.in(Easing.ease),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
{ iterations: 3 }
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [action, smileScale]);
|
||||||
|
|
||||||
|
// Wave / Jump: bounce
|
||||||
|
useEffect(() => {
|
||||||
|
if (action !== 'wave' && action !== 'jump') return;
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(bounceAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 400,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.out(Easing.ease),
|
||||||
|
}),
|
||||||
|
Animated.timing(bounceAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 400,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.in(Easing.ease),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [action, bounceAnim]);
|
||||||
|
|
||||||
|
// Shake: wiggle
|
||||||
|
useEffect(() => {
|
||||||
|
if (action !== 'shake') return;
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(shakeAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 150,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(shakeAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 150,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [action, shakeAnim]);
|
||||||
|
|
||||||
|
// Think: scale + opacity pulse
|
||||||
|
useEffect(() => {
|
||||||
|
if (action !== 'think') {
|
||||||
|
thinkScale.setValue(1);
|
||||||
|
thinkOpacity.setValue(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const loop = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(thinkScale, {
|
||||||
|
toValue: 0.92,
|
||||||
|
duration: 600,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.inOut(Easing.ease),
|
||||||
|
}),
|
||||||
|
Animated.timing(thinkOpacity, {
|
||||||
|
toValue: 0.85,
|
||||||
|
duration: 600,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(thinkScale, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 600,
|
||||||
|
useNativeDriver: true,
|
||||||
|
easing: Easing.inOut(Easing.ease),
|
||||||
|
}),
|
||||||
|
Animated.timing(thinkOpacity, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 600,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
loop.start();
|
||||||
|
return () => loop.stop();
|
||||||
|
}, [action, thinkScale, thinkOpacity]);
|
||||||
|
|
||||||
|
const floatY = floatAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0, -8],
|
||||||
|
});
|
||||||
|
const bounceY = bounceAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [0, -20],
|
||||||
|
});
|
||||||
|
const shakeRotate = shakeAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: ['0deg', '8deg'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const isBounce = action === 'wave' || action === 'jump';
|
||||||
|
const isShake = action === 'shake';
|
||||||
|
const isSmile = action === 'smile';
|
||||||
|
|
||||||
|
const mouthStyle = isTalking
|
||||||
|
? [styles.mouth, styles.mouthOpen]
|
||||||
|
: isSmile
|
||||||
|
? [styles.mouth, styles.mouthBigSmile]
|
||||||
|
: [styles.mouth, styles.mouthSmile];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.container,
|
||||||
|
action === 'idle' && {
|
||||||
|
transform: [{ translateY: floatY }],
|
||||||
|
},
|
||||||
|
isBounce && {
|
||||||
|
transform: [{ translateY: bounceY }],
|
||||||
|
},
|
||||||
|
isShake && {
|
||||||
|
transform: [{ rotate: shakeRotate }],
|
||||||
|
},
|
||||||
|
action === 'think' && {
|
||||||
|
transform: [{ scale: thinkScale }],
|
||||||
|
opacity: thinkOpacity,
|
||||||
|
},
|
||||||
|
isSmile && {
|
||||||
|
transform: [{ scale: smileScale }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* Aura glow */}
|
||||||
|
<View style={styles.aura} />
|
||||||
|
{/* Body (droplet-like rounded rect) */}
|
||||||
|
<View style={styles.body}>
|
||||||
|
{/* Gloss */}
|
||||||
|
<View style={styles.gloss} />
|
||||||
|
{/* Cheeks */}
|
||||||
|
<View style={[styles.cheek, styles.cheekLeft]} />
|
||||||
|
<View style={[styles.cheek, styles.cheekRight]} />
|
||||||
|
{/* Eyes */}
|
||||||
|
<View style={styles.eyes}>
|
||||||
|
<View style={[styles.eye, styles.eyeLeft]}>
|
||||||
|
<View style={styles.eyeSparkle} />
|
||||||
|
</View>
|
||||||
|
<View style={[styles.eye, styles.eyeRight]}>
|
||||||
|
<View style={styles.eyeSparkle} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{/* Mouth - default smile; open when talking; big smile when smile action */}
|
||||||
|
<View style={mouthStyle} />
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BODY_SIZE = PUPPET_SIZE * 0.9;
|
||||||
|
const EYE_SIZE = 10;
|
||||||
|
const EYE_OFFSET_X = 18;
|
||||||
|
const EYE_OFFSET_Y = -8;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
width: PUPPET_SIZE,
|
||||||
|
height: PUPPET_SIZE,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
aura: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: PUPPET_SIZE + 40,
|
||||||
|
height: PUPPET_SIZE + 40,
|
||||||
|
borderRadius: (PUPPET_SIZE + 40) / 2,
|
||||||
|
backgroundColor: 'rgba(14, 165, 233, 0.15)',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
width: BODY_SIZE,
|
||||||
|
height: BODY_SIZE,
|
||||||
|
borderRadius: BODY_SIZE / 2,
|
||||||
|
backgroundColor: '#0ea5e9',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#0c4a6e',
|
||||||
|
shadowOffset: { width: 0, height: 8 },
|
||||||
|
shadowOpacity: 0.35,
|
||||||
|
shadowRadius: 12,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
gloss: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: BODY_SIZE * 0.12,
|
||||||
|
left: BODY_SIZE * 0.2,
|
||||||
|
right: BODY_SIZE * 0.2,
|
||||||
|
height: 4,
|
||||||
|
borderRadius: 2,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.35)',
|
||||||
|
},
|
||||||
|
cheek: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 7,
|
||||||
|
backgroundColor: 'rgba(59, 130, 246, 0.35)',
|
||||||
|
},
|
||||||
|
cheekLeft: {
|
||||||
|
left: BODY_SIZE * 0.15,
|
||||||
|
top: BODY_SIZE * 0.42,
|
||||||
|
},
|
||||||
|
cheekRight: {
|
||||||
|
right: BODY_SIZE * 0.15,
|
||||||
|
top: BODY_SIZE * 0.42,
|
||||||
|
},
|
||||||
|
eyes: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
position: 'absolute',
|
||||||
|
top: BODY_SIZE * 0.34,
|
||||||
|
},
|
||||||
|
eye: {
|
||||||
|
width: EYE_SIZE,
|
||||||
|
height: EYE_SIZE,
|
||||||
|
borderRadius: EYE_SIZE / 2,
|
||||||
|
backgroundColor: '#0c4a6e',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
eyeLeft: { marginRight: EYE_OFFSET_X },
|
||||||
|
eyeRight: { marginLeft: EYE_OFFSET_X },
|
||||||
|
eyeSparkle: {
|
||||||
|
width: 3,
|
||||||
|
height: 3,
|
||||||
|
borderRadius: 1.5,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
position: 'absolute',
|
||||||
|
top: 1,
|
||||||
|
left: 2,
|
||||||
|
},
|
||||||
|
mouth: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: BODY_SIZE * 0.52,
|
||||||
|
backgroundColor: '#0c4a6e',
|
||||||
|
},
|
||||||
|
mouthSmile: {
|
||||||
|
width: 22,
|
||||||
|
height: 6,
|
||||||
|
borderBottomLeftRadius: 11,
|
||||||
|
borderBottomRightRadius: 11,
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
},
|
||||||
|
mouthOpen: {
|
||||||
|
width: 18,
|
||||||
|
height: 6,
|
||||||
|
top: BODY_SIZE * 0.51,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: 'rgba(12, 74, 110, 0.9)',
|
||||||
|
},
|
||||||
|
mouthBigSmile: {
|
||||||
|
width: 32,
|
||||||
|
height: 10,
|
||||||
|
top: BODY_SIZE * 0.51,
|
||||||
|
borderBottomLeftRadius: 16,
|
||||||
|
borderBottomRightRadius: 16,
|
||||||
|
borderTopLeftRadius: 0,
|
||||||
|
borderTopRightRadius: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
3
src/components/puppet/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { PuppetView } from './PuppetView';
|
||||||
|
export { FlowPuppetSlot } from './FlowPuppetSlot';
|
||||||
|
export type { PuppetAction, PuppetState, PuppetViewProps, FlowPuppetSlotProps } from './types';
|
||||||
28
src/components/puppet/types.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Puppet types - compatible with airi interactive AI puppet semantics.
|
||||||
|
* Used for FlowScreen multimodal avatar (action + talking state).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type PuppetAction = 'idle' | 'wave' | 'nod' | 'shake' | 'jump' | 'think' | 'talk' | 'smile';
|
||||||
|
|
||||||
|
export interface PuppetState {
|
||||||
|
currentAction: PuppetAction;
|
||||||
|
isTalking: boolean;
|
||||||
|
isThinking: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PuppetViewProps {
|
||||||
|
action: PuppetAction;
|
||||||
|
isTalking: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowPuppetSlotProps {
|
||||||
|
/** Current action (idle, wave, nod, shake, jump, think). */
|
||||||
|
currentAction: PuppetAction;
|
||||||
|
/** True when AI is "speaking" (e.g. streaming or responding). */
|
||||||
|
isTalking: boolean;
|
||||||
|
/** Optional: allow parent to set action (e.g. from AI tool call). */
|
||||||
|
onAction?: (action: PuppetAction) => void;
|
||||||
|
/** Show quick action buttons (wave, jump, shake) for interactivity. */
|
||||||
|
showActionButtons?: boolean;
|
||||||
|
}
|
||||||
@@ -51,11 +51,13 @@ export const API_ENDPOINTS = {
|
|||||||
CREATE: '/assets/create',
|
CREATE: '/assets/create',
|
||||||
CLAIM: '/assets/claim',
|
CLAIM: '/assets/claim',
|
||||||
ASSIGN: '/assets/assign',
|
ASSIGN: '/assets/assign',
|
||||||
|
DELETE: '/assets/delete',
|
||||||
},
|
},
|
||||||
|
|
||||||
// AI Services
|
// AI Services
|
||||||
AI: {
|
AI: {
|
||||||
PROXY: '/ai/proxy',
|
PROXY: '/ai/proxy',
|
||||||
|
GET_ROLES: '/get_ai_roles',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Admin Operations
|
// Admin Operations
|
||||||
@@ -64,6 +66,48 @@ export const API_ENDPOINTS = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Vault storage (user-isolated, multi-account)
|
||||||
|
// =============================================================================
|
||||||
|
// - AsyncStorage keys for vault state (S0 share, initialized flag, mnemonic part backup).
|
||||||
|
// - User-scoped: each account has its own keys so vault/mnemonic state is isolated.
|
||||||
|
// - Store: use getVaultStorageKeys(userId) and write to INITIALIZED / SHARE_DEVICE / MNEMONIC_PART_LOCAL.
|
||||||
|
// - Clear: use same keys in multiRemove (e.g. MeScreen Reset Vault State).
|
||||||
|
// - Multi-account: same device, multiple users → each has independent vault (no cross-user leakage).
|
||||||
|
|
||||||
|
const VAULT_KEY_PREFIX = 'sentinel_vault';
|
||||||
|
|
||||||
|
/** Base key names (for reference). Prefer getVaultStorageKeys(userId) for all reads/writes. */
|
||||||
|
export const VAULT_STORAGE_KEYS = {
|
||||||
|
INITIALIZED: 'sentinel_vault_initialized',
|
||||||
|
SHARE_DEVICE: 'sentinel_vault_s0',
|
||||||
|
MNEMONIC_PART_LOCAL: 'sentinel_mnemonic_part_local',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns vault storage keys for the given user (user isolation).
|
||||||
|
* - Use for: reading/writing S0, mnemonic part backup, clearing on Reset Vault State.
|
||||||
|
* - userId null → guest namespace (_guest). userId set → per-user namespace (_u{userId}).
|
||||||
|
*/
|
||||||
|
export function getVaultStorageKeys(userId: number | string | null): {
|
||||||
|
INITIALIZED: string;
|
||||||
|
SHARE_DEVICE: string;
|
||||||
|
MNEMONIC_PART_LOCAL: string;
|
||||||
|
AES_KEY: string;
|
||||||
|
SHARE_SERVER: string;
|
||||||
|
SHARE_HEIR: string;
|
||||||
|
} {
|
||||||
|
const suffix = userId != null ? `_u${userId}` : '_guest';
|
||||||
|
return {
|
||||||
|
INITIALIZED: `${VAULT_KEY_PREFIX}_initialized${suffix}`,
|
||||||
|
SHARE_DEVICE: `${VAULT_KEY_PREFIX}_s0${suffix}`,
|
||||||
|
MNEMONIC_PART_LOCAL: `sentinel_mnemonic_part_local${suffix}`,
|
||||||
|
AES_KEY: `sentinel_aes_key${suffix}`,
|
||||||
|
SHARE_SERVER: `sentinel_share_server${suffix}`,
|
||||||
|
SHARE_HEIR: `sentinel_share_heir${suffix}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Helper Functions
|
// Helper Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -112,6 +156,7 @@ export const MOCK_CONFIG = {
|
|||||||
USER: {
|
USER: {
|
||||||
id: 999,
|
id: 999,
|
||||||
username: 'MockCaptain',
|
username: 'MockCaptain',
|
||||||
|
email: 'captain@sentinel.local',
|
||||||
public_key: 'mock_public_key',
|
public_key: 'mock_public_key',
|
||||||
is_admin: true,
|
is_admin: true,
|
||||||
guale: false,
|
guale: false,
|
||||||
@@ -137,6 +182,44 @@ export const AI_CONFIG = {
|
|||||||
* Mock response delay in milliseconds (for NO_BACKEND_MODE)
|
* Mock response delay in milliseconds (for NO_BACKEND_MODE)
|
||||||
*/
|
*/
|
||||||
MOCK_RESPONSE_DELAY: 500,
|
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;
|
} as const;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -7,8 +7,10 @@
|
|||||||
|
|
||||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { User, LoginRequest, RegisterRequest } from '../types';
|
import { User, LoginRequest, RegisterRequest, AIRole } from '../types';
|
||||||
import { authService } from '../services/auth.service';
|
import { authService } from '../services/auth.service';
|
||||||
|
import { aiService } from '../services/ai.service';
|
||||||
|
import { storageService } from '../services/storage.service';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Type Definitions
|
// Type Definitions
|
||||||
@@ -17,11 +19,13 @@ import { authService } from '../services/auth.service';
|
|||||||
interface AuthContextType {
|
interface AuthContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
aiRoles: AIRole[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isInitializing: boolean;
|
isInitializing: boolean;
|
||||||
signIn: (credentials: LoginRequest) => Promise<void>;
|
signIn: (credentials: LoginRequest) => Promise<void>;
|
||||||
signUp: (data: RegisterRequest) => Promise<void>;
|
signUp: (data: RegisterRequest) => Promise<void>;
|
||||||
signOut: () => void;
|
signOut: () => void;
|
||||||
|
refreshAIRoles: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storage keys
|
// Storage keys
|
||||||
@@ -43,6 +47,7 @@ const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
|||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [aiRoles, setAIRoles] = useState<AIRole[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [isInitializing, setIsInitializing] = useState(true);
|
const [isInitializing, setIsInitializing] = useState(true);
|
||||||
|
|
||||||
@@ -65,6 +70,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setToken(storedToken);
|
setToken(storedToken);
|
||||||
setUser(JSON.parse(storedUser));
|
setUser(JSON.parse(storedUser));
|
||||||
console.log('[Auth] Restored session for user:', JSON.parse(storedUser).username);
|
console.log('[Auth] Restored session for user:', JSON.parse(storedUser).username);
|
||||||
|
// Fetch AI roles after restoring session
|
||||||
|
fetchAIRoles(storedToken);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Auth] Failed to load stored auth:', error);
|
console.error('[Auth] Failed to load stored auth:', error);
|
||||||
@@ -73,6 +80,29 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch AI roles from API
|
||||||
|
*/
|
||||||
|
const fetchAIRoles = async (authToken: string) => {
|
||||||
|
console.log('[Auth] Fetching AI roles with token:', authToken ? `${authToken.substring(0, 10)}...` : 'MISSING');
|
||||||
|
try {
|
||||||
|
const roles = await aiService.getAIRoles(authToken);
|
||||||
|
setAIRoles(roles);
|
||||||
|
console.log('[Auth] AI roles fetched successfully:', roles.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Auth] Failed to fetch AI roles:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manual refresh of AI roles
|
||||||
|
*/
|
||||||
|
const refreshAIRoles = async () => {
|
||||||
|
if (token) {
|
||||||
|
await fetchAIRoles(token);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save authentication to AsyncStorage
|
* Save authentication to AsyncStorage
|
||||||
*/
|
*/
|
||||||
@@ -113,6 +143,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
setToken(response.access_token);
|
setToken(response.access_token);
|
||||||
setUser(response.user);
|
setUser(response.user);
|
||||||
await saveAuth(response.access_token, response.user);
|
await saveAuth(response.access_token, response.user);
|
||||||
|
// Fetch AI roles immediately after login
|
||||||
|
await fetchAIRoles(response.access_token);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -137,12 +169,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign out and clear stored auth
|
* Sign out and clear stored auth and session data
|
||||||
*/
|
*/
|
||||||
const signOut = () => {
|
const signOut = () => {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
setToken(null);
|
setToken(null);
|
||||||
|
setAIRoles([]);
|
||||||
clearAuth();
|
clearAuth();
|
||||||
|
|
||||||
|
|
||||||
|
//storageService.clearAllData();
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -150,11 +186,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
value={{
|
value={{
|
||||||
user,
|
user,
|
||||||
token,
|
token,
|
||||||
|
aiRoles,
|
||||||
isLoading,
|
isLoading,
|
||||||
isInitializing,
|
isInitializing,
|
||||||
signIn,
|
signIn,
|
||||||
signUp,
|
signUp,
|
||||||
signOut
|
signOut,
|
||||||
|
refreshAIRoles
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
6
src/hooks/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* React hooks for Sentinel
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { useVaultAssets } from './useVaultAssets';
|
||||||
|
export type { CreateAssetResult, UseVaultAssetsReturn } from './useVaultAssets';
|
||||||
278
src/hooks/useVaultAssets.ts
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/**
|
||||||
|
* useVaultAssets: Encapsulates /assets/get and /assets/create for VaultScreen.
|
||||||
|
* - Fetches assets when vault is unlocked and token exists.
|
||||||
|
* - Exposes createAsset with 401/network error handling and list refresh on success.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import * as bip39 from 'bip39';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { assetsService } from '../services/assets.service';
|
||||||
|
import { getVaultStorageKeys, DEBUG_MODE } from '../config';
|
||||||
|
import { SentinelVault } from '../utils/crypto_core';
|
||||||
|
import { storageService } from '../services/storage.service';
|
||||||
|
import {
|
||||||
|
initialVaultAssets,
|
||||||
|
mapApiAssetsToVaultAssets,
|
||||||
|
type ApiAsset,
|
||||||
|
} from '../utils/vaultAssets';
|
||||||
|
import type { VaultAsset } from '../types';
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface CreateAssetResult {
|
||||||
|
success: boolean;
|
||||||
|
isUnauthorized?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseVaultAssetsReturn {
|
||||||
|
/** Current list (mock until API succeeds) */
|
||||||
|
assets: VaultAsset[];
|
||||||
|
/** Replace list (e.g. after external refresh) */
|
||||||
|
setAssets: React.Dispatch<React.SetStateAction<VaultAsset[]>>;
|
||||||
|
/** Refetch from GET /assets/get */
|
||||||
|
refreshAssets: () => Promise<void>;
|
||||||
|
/** Create asset via POST /assets/create; on success refreshes list */
|
||||||
|
createAsset: (params: { title: string; content: string }) => Promise<CreateAssetResult>;
|
||||||
|
/** Delete asset via POST /assets/delete; on success refreshes list */
|
||||||
|
deleteAsset: (assetId: number) => Promise<CreateAssetResult>;
|
||||||
|
/** Assign asset to heir via POST /assets/assign */
|
||||||
|
assignAsset: (assetId: number, heirEmail: string) => Promise<CreateAssetResult>;
|
||||||
|
/** True while create request is in flight */
|
||||||
|
isSealing: boolean;
|
||||||
|
/** Error message from last create failure (non-401) */
|
||||||
|
createError: string | null;
|
||||||
|
/** Clear createError */
|
||||||
|
clearCreateError: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Hook
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vault assets list + create. Fetches on unlock when token exists; keeps mock on error.
|
||||||
|
*/
|
||||||
|
export function useVaultAssets(isUnlocked: boolean): UseVaultAssetsReturn {
|
||||||
|
const { user, token, signOut } = useAuth();
|
||||||
|
const [assets, setAssets] = useState<VaultAsset[]>(initialVaultAssets);
|
||||||
|
const [isSealing, setIsSealing] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const refreshAssets = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
const list = await assetsService.getMyAssets(token);
|
||||||
|
if (Array.isArray(list)) {
|
||||||
|
setAssets(mapApiAssetsToVaultAssets(list as ApiAsset[]));
|
||||||
|
}
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const rawMessage = err instanceof Error ? err.message : String(err ?? '');
|
||||||
|
if (/Could not validate credentials/i.test(rawMessage)) {
|
||||||
|
signOut();
|
||||||
|
}
|
||||||
|
// Keep current assets (mock or previous fetch)
|
||||||
|
}
|
||||||
|
}, [token, signOut]);
|
||||||
|
|
||||||
|
// Fetch list when unlocked and token exists
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isUnlocked || !token) return;
|
||||||
|
let cancelled = false;
|
||||||
|
assetsService
|
||||||
|
.getMyAssets(token)
|
||||||
|
.then((list) => {
|
||||||
|
if (!cancelled && Array.isArray(list)) {
|
||||||
|
setAssets(mapApiAssetsToVaultAssets(list as ApiAsset[]));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
const rawMessage = err instanceof Error ? err.message : String(err ?? '');
|
||||||
|
if (/Could not validate credentials/i.test(rawMessage)) {
|
||||||
|
signOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Keep initial (mock) assets
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [isUnlocked, token]);
|
||||||
|
|
||||||
|
const createAsset = useCallback(
|
||||||
|
async ({
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
}): Promise<CreateAssetResult> => {
|
||||||
|
if (!token) {
|
||||||
|
return { success: false, error: 'Not logged in.' };
|
||||||
|
}
|
||||||
|
setIsSealing(true);
|
||||||
|
setCreateError(null);
|
||||||
|
try {
|
||||||
|
const vaultKeys = getVaultStorageKeys(user?.id ?? null);
|
||||||
|
const [s1Str, aesKeyHex, s0Str, s2Str] = await Promise.all([
|
||||||
|
AsyncStorage.getItem(vaultKeys.SHARE_SERVER),
|
||||||
|
AsyncStorage.getItem(vaultKeys.AES_KEY),
|
||||||
|
AsyncStorage.getItem(vaultKeys.SHARE_DEVICE),
|
||||||
|
AsyncStorage.getItem(vaultKeys.SHARE_HEIR),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!s1Str || !aesKeyHex) {
|
||||||
|
throw new Error('Vault keys missing. Please re-unlock your vault.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const vault = new SentinelVault();
|
||||||
|
const aesKey = Buffer.from(aesKeyHex, 'hex');
|
||||||
|
const encryptedBuffer = vault.encryptData(aesKey, content.trim());
|
||||||
|
const content_inner_encrypted = encryptedBuffer.toString('hex');
|
||||||
|
|
||||||
|
if (DEBUG_MODE) {
|
||||||
|
console.log('[DEBUG] Crypto Data during Asset Creation:');
|
||||||
|
console.log(' s0 (Device):', s0Str);
|
||||||
|
console.log(' s1 (Server):', s1Str);
|
||||||
|
console.log(' s2 (Heir): ', s2Str);
|
||||||
|
console.log(' AES Key: ', aesKeyHex);
|
||||||
|
console.log(' Encrypted: ', content_inner_encrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAsset = await assetsService.createAsset(
|
||||||
|
{
|
||||||
|
title: title.trim(),
|
||||||
|
private_key_shard: s1Str,
|
||||||
|
content_inner_encrypted,
|
||||||
|
},
|
||||||
|
token
|
||||||
|
);
|
||||||
|
|
||||||
|
// Backup plaintext content locally
|
||||||
|
if (createdAsset && createdAsset.id && user?.id) {
|
||||||
|
await storageService.saveAssetBackup(createdAsset.id, content, user.id);
|
||||||
|
}
|
||||||
|
await refreshAssets();
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status =
|
||||||
|
err && typeof err === 'object' && 'status' in err
|
||||||
|
? (err as { status?: number }).status
|
||||||
|
: undefined;
|
||||||
|
const rawMessage =
|
||||||
|
err instanceof Error ? err.message : String(err ?? 'Failed to create.');
|
||||||
|
const isUnauthorized =
|
||||||
|
status === 401 || /401|Unauthorized/i.test(rawMessage);
|
||||||
|
|
||||||
|
if (isUnauthorized) {
|
||||||
|
signOut();
|
||||||
|
return { success: false, isUnauthorized: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const friendlyMessage = /failed to fetch|network error/i.test(rawMessage)
|
||||||
|
? 'Network error. Please check that the backend is running and reachable (see API_BASE_URL in config).'
|
||||||
|
: rawMessage;
|
||||||
|
setCreateError(friendlyMessage);
|
||||||
|
return { success: false, error: friendlyMessage };
|
||||||
|
} finally {
|
||||||
|
setIsSealing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[token, user, refreshAssets, signOut]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteAsset = useCallback(
|
||||||
|
async (assetId: number): Promise<CreateAssetResult> => {
|
||||||
|
if (!token) {
|
||||||
|
return { success: false, error: 'Not logged in.' };
|
||||||
|
}
|
||||||
|
setIsSealing(true);
|
||||||
|
setCreateError(null);
|
||||||
|
try {
|
||||||
|
await assetsService.deleteAsset(assetId, token);
|
||||||
|
await refreshAssets();
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status =
|
||||||
|
err && typeof err === 'object' && 'status' in err
|
||||||
|
? (err as { status?: number }).status
|
||||||
|
: undefined;
|
||||||
|
const rawMessage =
|
||||||
|
err instanceof Error ? err.message : String(err ?? 'Failed to delete.');
|
||||||
|
const isUnauthorized =
|
||||||
|
status === 401 || /401|Unauthorized/i.test(rawMessage);
|
||||||
|
|
||||||
|
if (isUnauthorized) {
|
||||||
|
signOut();
|
||||||
|
return { success: false, isUnauthorized: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const friendlyMessage = /failed to fetch|network error/i.test(rawMessage)
|
||||||
|
? 'Network error. Please check that the backend is running and reachable.'
|
||||||
|
: rawMessage;
|
||||||
|
setCreateError(friendlyMessage);
|
||||||
|
return { success: false, error: friendlyMessage };
|
||||||
|
} finally {
|
||||||
|
setIsSealing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[token, refreshAssets, signOut]
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignAsset = useCallback(
|
||||||
|
async (assetId: number, heirEmail: string): Promise<CreateAssetResult> => {
|
||||||
|
if (!token) {
|
||||||
|
return { success: false, error: 'Not logged in.' };
|
||||||
|
}
|
||||||
|
setIsSealing(true);
|
||||||
|
setCreateError(null);
|
||||||
|
try {
|
||||||
|
await assetsService.assignAsset({ asset_id: assetId, heir_email: heirEmail }, token);
|
||||||
|
await refreshAssets();
|
||||||
|
return { success: true };
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const status =
|
||||||
|
err && typeof err === 'object' && 'status' in err
|
||||||
|
? (err as { status?: number }).status
|
||||||
|
: undefined;
|
||||||
|
const rawMessage =
|
||||||
|
err instanceof Error ? err.message : String(err ?? 'Failed to assign.');
|
||||||
|
const isUnauthorized =
|
||||||
|
status === 401 || /401|Unauthorized/i.test(rawMessage);
|
||||||
|
|
||||||
|
if (isUnauthorized) {
|
||||||
|
signOut();
|
||||||
|
return { success: false, isUnauthorized: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
const friendlyMessage = /failed to fetch|network error/i.test(rawMessage)
|
||||||
|
? 'Network error. Please check that the backend is running and reachable.'
|
||||||
|
: rawMessage;
|
||||||
|
setCreateError(friendlyMessage);
|
||||||
|
return { success: false, error: friendlyMessage };
|
||||||
|
} finally {
|
||||||
|
setIsSealing(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[token, signOut]
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearCreateError = useCallback(() => setCreateError(null), []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
assets,
|
||||||
|
setAssets,
|
||||||
|
refreshAssets,
|
||||||
|
createAsset,
|
||||||
|
deleteAsset,
|
||||||
|
assignAsset,
|
||||||
|
isSealing,
|
||||||
|
createError,
|
||||||
|
clearCreateError,
|
||||||
|
};
|
||||||
|
}
|
||||||
45
src/polyfills.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
/**
|
||||||
|
* Polyfills that must run before any other app code (including LangChain/LangGraph).
|
||||||
|
* This file is imported as the very first line in App.tsx so that ReadableStream
|
||||||
|
* and crypto.getRandomValues exist before @langchain/core / uuid are loaded.
|
||||||
|
*/
|
||||||
|
import 'web-streams-polyfill';
|
||||||
|
|
||||||
|
// Ensure globalThis has ReadableStream (main polyfill may not patch in RN/Metro)
|
||||||
|
const g = typeof globalThis !== 'undefined' ? globalThis : (typeof global !== 'undefined' ? global : (typeof self !== 'undefined' ? self : {}));
|
||||||
|
if (typeof (g as any).ReadableStream === 'undefined') {
|
||||||
|
const ponyfill = require('web-streams-polyfill/dist/ponyfill.js');
|
||||||
|
(g as any).ReadableStream = ponyfill.ReadableStream;
|
||||||
|
(g as any).WritableStream = ponyfill.WritableStream;
|
||||||
|
(g as any).TransformStream = ponyfill.TransformStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polyfill crypto.getRandomValues for React Native/Expo (required by uuid, LangChain, etc.)
|
||||||
|
if (typeof g !== 'undefined') {
|
||||||
|
const cryptoObj = (g as any).crypto;
|
||||||
|
if (!cryptoObj || typeof (cryptoObj.getRandomValues) !== 'function') {
|
||||||
|
try {
|
||||||
|
const ExpoCrypto = require('expo-crypto');
|
||||||
|
const getRandomValues = (array: ArrayBufferView): ArrayBufferView => {
|
||||||
|
ExpoCrypto.getRandomValues(array);
|
||||||
|
return array;
|
||||||
|
};
|
||||||
|
if (!(g as any).crypto) (g as any).crypto = {};
|
||||||
|
(g as any).crypto.getRandomValues = getRandomValues;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[polyfills] crypto.getRandomValues polyfill failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Polyfill AbortSignal.prototype.throwIfAborted (required by fetch/LangChain in RN; not present in older runtimes)
|
||||||
|
const AbortSignalGlobal = (g as any).AbortSignal;
|
||||||
|
if (typeof AbortSignalGlobal === 'function' && AbortSignalGlobal.prototype && typeof AbortSignalGlobal.prototype.throwIfAborted !== 'function') {
|
||||||
|
AbortSignalGlobal.prototype.throwIfAborted = function (this: AbortSignal) {
|
||||||
|
if (this.aborted) {
|
||||||
|
const e = new Error('Aborted');
|
||||||
|
e.name = 'AbortError';
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -28,9 +28,20 @@ import {
|
|||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { Ionicons, Feather, FontAwesome5 } from '@expo/vector-icons';
|
import { Ionicons, Feather, FontAwesome5 } from '@expo/vector-icons';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import { AIRole } from '../types';
|
||||||
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
|
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
|
||||||
import { aiService } from '../services/ai.service';
|
import { aiService, AIMessage } from '../services/ai.service';
|
||||||
|
import { langGraphService } from '../services/langgraph.service';
|
||||||
|
import { HumanMessage, AIMessage as LangChainAIMessage, SystemMessage } from "@langchain/core/messages";
|
||||||
|
import { assetsService } from '../services/assets.service';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
|
import { AI_CONFIG, getVaultStorageKeys } from '../config';
|
||||||
|
import { storageService } from '../services/storage.service';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { SentinelVault } from '../utils/crypto_core';
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
|
import { FlowPuppetSlot } from '../components/puppet';
|
||||||
|
import type { PuppetAction } from '../components/puppet';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Type Definitions
|
// Type Definitions
|
||||||
@@ -57,7 +68,7 @@ interface ChatSession {
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
export default function FlowScreen() {
|
export default function FlowScreen() {
|
||||||
const { token, signOut } = useAuth();
|
const { token, user, signOut, aiRoles, refreshAIRoles } = useAuth();
|
||||||
const scrollViewRef = useRef<ScrollView>(null);
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
|
|
||||||
// Current conversation state
|
// Current conversation state
|
||||||
@@ -67,10 +78,30 @@ export default function FlowScreen() {
|
|||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
const [selectedImage, setSelectedImage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// AI Role state - start with null to detect first load
|
||||||
|
const [selectedRole, setSelectedRole] = useState<AIRole | null>(aiRoles[0] || null);
|
||||||
|
const [showRoleModal, setShowRoleModal] = useState(false);
|
||||||
|
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
|
||||||
|
|
||||||
// History modal state
|
// History modal state
|
||||||
const [showHistoryModal, setShowHistoryModal] = useState(false);
|
const [showHistoryModal, setShowHistoryModal] = useState(false);
|
||||||
const modalSlideAnim = useRef(new Animated.Value(0)).current;
|
const modalSlideAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
// Summary state
|
||||||
|
const [showSummaryConfirmModal, setShowSummaryConfirmModal] = useState(false);
|
||||||
|
const [showSummaryResultModal, setShowSummaryResultModal] = useState(false);
|
||||||
|
const [isSummarizing, setIsSummarizing] = useState(false);
|
||||||
|
const [generatedSummary, setGeneratedSummary] = useState('');
|
||||||
|
|
||||||
|
// Save to Vault state
|
||||||
|
const [showVaultConfirmModal, setShowVaultConfirmModal] = useState(false);
|
||||||
|
const [showSaveResultModal, setShowSaveResultModal] = useState(false);
|
||||||
|
const [saveResult, setSaveResult] = useState<{ success: boolean; message: string }>({ success: true, message: '' });
|
||||||
|
const [isSavingToVault, setIsSavingToVault] = useState(false);
|
||||||
|
|
||||||
|
// AI multimodal puppet (optional; does not affect existing chat logic)
|
||||||
|
const [puppetAction, setPuppetAction] = useState<PuppetAction>('idle');
|
||||||
|
|
||||||
const [chatHistory, setChatHistory] = useState<ChatSession[]>([
|
const [chatHistory, setChatHistory] = useState<ChatSession[]>([
|
||||||
// Sample history data
|
// Sample history data
|
||||||
{
|
{
|
||||||
@@ -104,14 +135,108 @@ export default function FlowScreen() {
|
|||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-scroll to bottom when new messages arrive
|
// Load history on mount
|
||||||
useEffect(() => {
|
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 || !selectedRole) 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) {
|
||||||
|
if (selectedRole) {
|
||||||
|
console.error(`Failed to load messages for role ${selectedRole?.id}:`, error);
|
||||||
|
}
|
||||||
|
setMessages([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadRoleMessages();
|
||||||
|
}, [selectedRole?.id, user]);
|
||||||
|
|
||||||
|
// Ensure we have a valid selected role from the dynamic list
|
||||||
|
useEffect(() => {
|
||||||
|
if (aiRoles.length > 0) {
|
||||||
|
if (!selectedRole) {
|
||||||
|
// Initial load or first time roles become available
|
||||||
|
setSelectedRole(aiRoles[0]);
|
||||||
|
} else {
|
||||||
|
// If roles refreshed, make sure current selectedRole is still valid or updated
|
||||||
|
const updatedRole = aiRoles.find(r => r.id === selectedRole?.id);
|
||||||
|
if (updatedRole) {
|
||||||
|
setSelectedRole(updatedRole);
|
||||||
|
} else {
|
||||||
|
// Current role no longer exists in dynamic list, fallback to first
|
||||||
|
setSelectedRole(aiRoles[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!selectedRole) {
|
||||||
|
// Fallback if no dynamic roles yet
|
||||||
|
setSelectedRole(AI_CONFIG.ROLES[0]);
|
||||||
|
}
|
||||||
|
}, [aiRoles]);
|
||||||
|
|
||||||
|
// Sync puppet action with sending state (think while AI is responding)
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSending) setPuppetAction('think');
|
||||||
|
else setPuppetAction((prev) => (prev === 'think' ? 'idle' : prev));
|
||||||
|
}, [isSending]);
|
||||||
|
|
||||||
|
// Save current messages for the active role when they change
|
||||||
|
useEffect(() => {
|
||||||
|
if (user && selectedRole && messages.length >= 0) { // Save even if empty to allow clearing
|
||||||
|
storageService.saveCurrentChat(selectedRole?.id || '', messages, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
if (messages.length > 0) {
|
if (messages.length > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
}, [messages]);
|
}, [messages, selectedRole?.id, user]);
|
||||||
|
|
||||||
|
// Save history when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (user) {
|
||||||
|
storageService.saveChatHistory(chatHistory, user.id);
|
||||||
|
}
|
||||||
|
}, [chatHistory, user]);
|
||||||
|
|
||||||
// Modal animation control
|
// Modal animation control
|
||||||
const openHistoryModal = () => {
|
const openHistoryModal = () => {
|
||||||
@@ -142,7 +267,7 @@ export default function FlowScreen() {
|
|||||||
* Handle sending a message to AI
|
* Handle sending a message to AI
|
||||||
*/
|
*/
|
||||||
const handleSendMessage = async () => {
|
const handleSendMessage = async () => {
|
||||||
if (!newContent.trim() || isSending) return;
|
if (!newContent.trim() || isSending || !selectedRole) return;
|
||||||
|
|
||||||
// Check authentication
|
// Check authentication
|
||||||
if (!token) {
|
if (!token) {
|
||||||
@@ -168,8 +293,23 @@ export default function FlowScreen() {
|
|||||||
setMessages(prev => [...prev, userMsg]);
|
setMessages(prev => [...prev, userMsg]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Call AI proxy
|
// 1. Convert current messages history to LangChain format
|
||||||
const aiResponse = await aiService.sendMessage(userMessage, token);
|
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
|
// Add AI response
|
||||||
const aiMsg: ChatMessage = {
|
const aiMsg: ChatMessage = {
|
||||||
@@ -335,8 +475,11 @@ export default function FlowScreen() {
|
|||||||
setChatHistory(prev => [newSession, ...prev]);
|
setChatHistory(prev => [newSession, ...prev]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear current messages
|
// Clear current messages and storage for this role
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
if (user && selectedRole) {
|
||||||
|
storageService.saveCurrentChat(selectedRole?.id || '', [], user.id);
|
||||||
|
}
|
||||||
closeHistoryModal();
|
closeHistoryModal();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -379,6 +522,112 @@ export default function FlowScreen() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle generating summary for current conversation
|
||||||
|
*/
|
||||||
|
const handleGenerateSummary = async () => {
|
||||||
|
if (messages.length === 0) {
|
||||||
|
Alert.alert('No Messages', 'There are no messages to summarize.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
Alert.alert('Login Required', 'Please login to generate a summary.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowSummaryConfirmModal(false);
|
||||||
|
setIsSummarizing(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Convert messages to AIMessage format
|
||||||
|
const aiMessages: AIMessage[] = messages.map(msg => ({
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const summary = await aiService.summarizeChat(aiMessages, token);
|
||||||
|
setGeneratedSummary(summary);
|
||||||
|
setShowSummaryResultModal(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate summary:', error);
|
||||||
|
Alert.alert('Error', 'Failed to generate summary. Please try again later.');
|
||||||
|
} finally {
|
||||||
|
setIsSummarizing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle saving the generated summary to the vault
|
||||||
|
*/
|
||||||
|
const handleSaveToVault = async () => {
|
||||||
|
if (!generatedSummary || isSavingToVault) return;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
Alert.alert('Login Required', 'Please login to save to vault.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowVaultConfirmModal(false);
|
||||||
|
setIsSavingToVault(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Retrieve vault keys
|
||||||
|
if (!user) {
|
||||||
|
Alert.alert('Error', 'User information not found. Please login again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vaultKeys = getVaultStorageKeys(user.id);
|
||||||
|
const shareServer = await AsyncStorage.getItem(vaultKeys.SHARE_SERVER);
|
||||||
|
const aesKeyHex = await AsyncStorage.getItem(vaultKeys.AES_KEY);
|
||||||
|
|
||||||
|
if (!shareServer || !aesKeyHex) {
|
||||||
|
Alert.alert(
|
||||||
|
'Vault Not Initialized',
|
||||||
|
'Your vault is not fully initialized. Please set it up in the Vault tab first.'
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encrypt summary with AES key
|
||||||
|
const vault = new SentinelVault();
|
||||||
|
const aesKey = Buffer.from(aesKeyHex, 'hex');
|
||||||
|
const encryptedSummary = vault.encryptData(aesKey, generatedSummary).toString('hex');
|
||||||
|
|
||||||
|
// Create asset in backend
|
||||||
|
const createdAsset = await assetsService.createAsset({
|
||||||
|
title: `Chat Summary - ${new Date().toLocaleDateString()}`,
|
||||||
|
private_key_shard: shareServer,
|
||||||
|
content_inner_encrypted: encryptedSummary,
|
||||||
|
}, token);
|
||||||
|
|
||||||
|
// Backup plaintext content locally
|
||||||
|
if (createdAsset && createdAsset.id && user?.id) {
|
||||||
|
await storageService.saveAssetBackup(createdAsset.id, generatedSummary, user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaveResult({ success: true, message: 'Summary encrypted and saved to your vault successfully.' });
|
||||||
|
setShowSaveResultModal(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save to vault:', error);
|
||||||
|
setSaveResult({ success: false, message: 'Failed to save summary to vault. Please try again.' });
|
||||||
|
setShowSaveResultModal(true);
|
||||||
|
} finally {
|
||||||
|
setIsSavingToVault(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle closing all summary related modals after successful save or manual close of result
|
||||||
|
*/
|
||||||
|
const handleFinishSaveFlow = () => {
|
||||||
|
setShowSaveResultModal(false);
|
||||||
|
if (saveResult.success) {
|
||||||
|
setShowSummaryResultModal(false);
|
||||||
|
setShowVaultConfirmModal(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Helper Functions
|
// Helper Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -451,9 +700,9 @@ export default function FlowScreen() {
|
|||||||
<View style={styles.emptyIcon}>
|
<View style={styles.emptyIcon}>
|
||||||
<Feather name="feather" size={48} color={colors.nautical.seafoam} />
|
<Feather name="feather" size={48} color={colors.nautical.seafoam} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.emptyTitle}>Start a conversation</Text>
|
<Text style={styles.emptyTitle}>Chatting with {selectedRole?.name || 'AI'}</Text>
|
||||||
<Text style={styles.emptySubtitle}>
|
<Text style={styles.emptySubtitle}>
|
||||||
Ask me anything or share your thoughts
|
{selectedRole?.description || 'Loading AI Assistant...'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -505,6 +754,38 @@ export default function FlowScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* Role Header Dropdown */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.headerRoleButton}
|
||||||
|
onPress={() => setShowRoleModal(true)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{selectedRole && (
|
||||||
|
<Ionicons
|
||||||
|
name={(selectedRole?.icon || 'help-outline') as any}
|
||||||
|
size={16}
|
||||||
|
color={colors.nautical.teal}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Text style={styles.headerRoleText} numberOfLines={1}>
|
||||||
|
{selectedRole?.name || 'Loading...'}
|
||||||
|
</Text>
|
||||||
|
<Ionicons name="chevron-down" size={14} color={colors.flow.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Summary Button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.historyButton, { marginRight: spacing.sm }]}
|
||||||
|
onPress={() => setShowSummaryConfirmModal(true)}
|
||||||
|
disabled={messages.length === 0 || isSummarizing}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name="document-text-outline"
|
||||||
|
size={20}
|
||||||
|
color={messages.length === 0 || isSummarizing ? colors.flow.textSecondary : colors.flow.primary}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* History Button */}
|
{/* History Button */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.historyButton}
|
style={styles.historyButton}
|
||||||
@@ -514,6 +795,14 @@ export default function FlowScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* AI multimodal puppet (optional slot; code in components/puppet) */}
|
||||||
|
<FlowPuppetSlot
|
||||||
|
currentAction={puppetAction}
|
||||||
|
isTalking={isSending}
|
||||||
|
onAction={setPuppetAction}
|
||||||
|
showActionButtons={true}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Chat Messages */}
|
{/* Chat Messages */}
|
||||||
<ScrollView
|
<ScrollView
|
||||||
ref={scrollViewRef}
|
ref={scrollViewRef}
|
||||||
@@ -607,7 +896,6 @@ export default function FlowScreen() {
|
|||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
|
|
||||||
{/* History Modal - Background appears instantly, content slides up */}
|
|
||||||
<Modal
|
<Modal
|
||||||
visible={showHistoryModal}
|
visible={showHistoryModal}
|
||||||
animationType="none"
|
animationType="none"
|
||||||
@@ -630,46 +918,335 @@ export default function FlowScreen() {
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<View style={styles.modalHandle} />
|
<View style={styles.modalHandle} />
|
||||||
|
|
||||||
{/* Modal Header */}
|
{/* Modal Header */}
|
||||||
<View style={styles.modalHeader}>
|
<View style={styles.modalHeader}>
|
||||||
<Text style={styles.modalTitle}>Chat History</Text>
|
<Text style={styles.modalTitle}>Chat History</Text>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.newChatButton}
|
style={styles.newChatButton}
|
||||||
onPress={handleNewChat}
|
onPress={handleNewChat}
|
||||||
>
|
>
|
||||||
<Ionicons name="add" size={20} color="#fff" />
|
<Ionicons name="add" size={20} color="#fff" />
|
||||||
<Text style={styles.newChatText}>New Chat</Text>
|
<Text style={styles.newChatText}>New Chat</Text>
|
||||||
</TouchableOpacity>
|
</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>
|
</View>
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Close Button */}
|
{/* History List */}
|
||||||
<TouchableOpacity
|
<FlatList
|
||||||
style={styles.closeButton}
|
data={chatHistory}
|
||||||
onPress={closeHistoryModal}
|
renderItem={renderHistoryItem}
|
||||||
>
|
keyExtractor={item => item.id}
|
||||||
<Text style={styles.closeButtonText}>Close</Text>
|
style={styles.historyList}
|
||||||
</TouchableOpacity>
|
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>
|
</Animated.View>
|
||||||
</TouchableWithoutFeedback>
|
</TouchableWithoutFeedback>
|
||||||
</View>
|
</View>
|
||||||
</TouchableWithoutFeedback>
|
</TouchableWithoutFeedback>
|
||||||
</Modal>
|
</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}>
|
||||||
|
{aiRoles.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);
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Summary Confirmation Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showSummaryConfirmModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowSummaryConfirmModal(false)}
|
||||||
|
>
|
||||||
|
<TouchableWithoutFeedback onPress={() => setShowSummaryConfirmModal(false)}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
|
||||||
|
<View style={[styles.modalContent, { paddingBottom: spacing.xl }]}>
|
||||||
|
<View style={styles.modalHandle} />
|
||||||
|
<Text style={styles.modalTitle}>Generate Summary</Text>
|
||||||
|
<Text style={[styles.modalSubtitle, { marginVertical: spacing.base }]}>
|
||||||
|
Would you like to generate a summary for the current conversation?
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.cancelButton]}
|
||||||
|
onPress={() => setShowSummaryConfirmModal(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>No</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.confirmButton]}
|
||||||
|
onPress={handleGenerateSummary}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.nautical.teal, colors.nautical.seafoam]}
|
||||||
|
style={styles.actionButtonGradient}
|
||||||
|
>
|
||||||
|
<Text style={styles.confirmButtonText}>Yes, Generate</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Summary Result Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showSummaryResultModal}
|
||||||
|
transparent
|
||||||
|
animationType="slide"
|
||||||
|
onRequestClose={() => setShowSummaryResultModal(false)}
|
||||||
|
>
|
||||||
|
<TouchableWithoutFeedback onPress={() => setShowSummaryResultModal(false)}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
|
||||||
|
<View style={[styles.modalContent, { maxHeight: '70%' }]}>
|
||||||
|
<View style={styles.modalHandle} />
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={styles.modalTitle}>Conversation Summary</Text>
|
||||||
|
<TouchableOpacity onPress={() => setShowSummaryResultModal(false)}>
|
||||||
|
<Ionicons name="close" size={24} color={colors.flow.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.summaryContainer} showsVerticalScrollIndicator={false}>
|
||||||
|
<View style={styles.summaryCard}>
|
||||||
|
<Text style={styles.summaryText}>{generatedSummary}</Text>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<View style={styles.summaryActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.saveToVaultButton]}
|
||||||
|
onPress={() => setShowVaultConfirmModal(true)}
|
||||||
|
disabled={isSavingToVault}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.nautical.teal, colors.nautical.seafoam]}
|
||||||
|
style={styles.actionButtonGradient}
|
||||||
|
>
|
||||||
|
{isSavingToVault ? (
|
||||||
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="shield-checkmark-outline" size={20} color="#fff" />
|
||||||
|
<Text style={styles.confirmButtonText}>Save to Vault</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.closeButton}
|
||||||
|
onPress={() => setShowSummaryResultModal(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.closeButtonText}>Done</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Save to Vault Confirmation Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showVaultConfirmModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowVaultConfirmModal(false)}
|
||||||
|
>
|
||||||
|
<TouchableWithoutFeedback onPress={() => setShowVaultConfirmModal(false)}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
|
||||||
|
<View style={[styles.modalContent, { paddingBottom: spacing.xl }]}>
|
||||||
|
<View style={styles.modalHandle} />
|
||||||
|
<Text style={styles.modalTitle}>Save to Vault</Text>
|
||||||
|
<Text style={[styles.modalSubtitle, { marginVertical: spacing.base }]}>
|
||||||
|
Would you like to securely save this summary to your digital vault?
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.cancelButton]}
|
||||||
|
onPress={() => setShowVaultConfirmModal(false)}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.confirmButton]}
|
||||||
|
onPress={handleSaveToVault}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.nautical.teal, colors.nautical.seafoam]}
|
||||||
|
style={styles.actionButtonGradient}
|
||||||
|
>
|
||||||
|
<Text style={styles.confirmButtonText}>Yes, Save</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Save Result Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showSaveResultModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={handleFinishSaveFlow}
|
||||||
|
>
|
||||||
|
<TouchableWithoutFeedback onPress={handleFinishSaveFlow}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<TouchableWithoutFeedback onPress={e => e.stopPropagation()}>
|
||||||
|
<View style={[styles.modalContent, { paddingBottom: spacing.xl, alignItems: 'center' }]}>
|
||||||
|
<View style={styles.modalHandle} />
|
||||||
|
|
||||||
|
<View style={[
|
||||||
|
styles.resultIconContainer,
|
||||||
|
saveResult.success ? styles.successIconBg : styles.errorIconBg
|
||||||
|
]}>
|
||||||
|
<Ionicons
|
||||||
|
name={saveResult.success ? "checkmark-circle" : "alert-circle"}
|
||||||
|
size={64}
|
||||||
|
color={saveResult.success ? colors.nautical.teal : colors.nautical.coral}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.modalTitle}>
|
||||||
|
{saveResult.success ? 'Success!' : 'Oops!'}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={[styles.modalSubtitle, { marginVertical: spacing.base, textAlign: 'center' }]}>
|
||||||
|
{saveResult.message}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.confirmButton, { width: '100%' }]}
|
||||||
|
onPress={handleFinishSaveFlow}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.nautical.teal, colors.nautical.seafoam]}
|
||||||
|
style={styles.actionButtonGradient}
|
||||||
|
>
|
||||||
|
<Text style={styles.confirmButtonText}>Confirm</Text>
|
||||||
|
</LinearGradient>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</View>
|
||||||
|
</TouchableWithoutFeedback>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Summary Loading Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={isSummarizing}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
>
|
||||||
|
<View style={styles.loadingOverlay}>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.nautical.teal} />
|
||||||
|
<Text style={styles.loadingText}>Generating Summary...</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -697,12 +1274,33 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: spacing.base,
|
paddingHorizontal: spacing.base,
|
||||||
paddingTop: spacing.sm,
|
paddingTop: spacing.sm,
|
||||||
paddingBottom: spacing.md,
|
paddingBottom: spacing.sm,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(0,0,0,0.05)',
|
||||||
},
|
},
|
||||||
headerLeft: {
|
headerLeft: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: spacing.sm,
|
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: {
|
iconCircle: {
|
||||||
width: 44,
|
width: 44,
|
||||||
@@ -773,6 +1371,96 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: 'center',
|
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
|
// Message bubble styles
|
||||||
messageBubble: {
|
messageBubble: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -997,4 +1685,101 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.flow.textSecondary,
|
color: colors.flow.textSecondary,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Summary Modal styles
|
||||||
|
modalSubtitle: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.flow.textSecondary,
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: spacing.md,
|
||||||
|
marginTop: spacing.base,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flex: 1,
|
||||||
|
height: 50,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
actionButtonGradient: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
backgroundColor: colors.nautical.paleAqua,
|
||||||
|
},
|
||||||
|
confirmButton: {
|
||||||
|
// Gradient handled in child
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.flow.textSecondary,
|
||||||
|
},
|
||||||
|
confirmButtonText: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
summaryContainer: {
|
||||||
|
marginVertical: spacing.md,
|
||||||
|
},
|
||||||
|
summaryCard: {
|
||||||
|
backgroundColor: colors.nautical.paleAqua + '40', // 25% opacity
|
||||||
|
padding: spacing.md,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.nautical.lightMint,
|
||||||
|
},
|
||||||
|
summaryText: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.flow.text,
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
|
summaryActions: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
saveToVaultButton: {
|
||||||
|
height: 54,
|
||||||
|
},
|
||||||
|
resultIconContainer: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
successIconBg: {
|
||||||
|
backgroundColor: colors.nautical.paleAqua,
|
||||||
|
},
|
||||||
|
errorIconBg: {
|
||||||
|
backgroundColor: 'rgba(231, 76, 60, 0.1)', // coral at 10%
|
||||||
|
},
|
||||||
|
loadingOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(26, 58, 74, 0.6)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
backgroundColor: colors.flow.cardBackground,
|
||||||
|
padding: spacing.xl,
|
||||||
|
borderRadius: borderRadius.xl,
|
||||||
|
alignItems: 'center',
|
||||||
|
...shadows.soft,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: typography.fontSize.base,
|
||||||
|
color: colors.flow.text,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,9 +14,11 @@ import {
|
|||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons';
|
import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons';
|
||||||
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
|
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { useAuth } from '../context/AuthContext';
|
import { useAuth } from '../context/AuthContext';
|
||||||
import { Heir, HeirStatus, PaymentStrategy } from '../types';
|
import { Heir, HeirStatus, PaymentStrategy } from '../types';
|
||||||
import HeritageScreen from './HeritageScreen';
|
import HeritageScreen from './HeritageScreen';
|
||||||
|
import { getVaultStorageKeys } from '../config';
|
||||||
|
|
||||||
// Mock heirs data
|
// Mock heirs data
|
||||||
const initialHeirs: Heir[] = [
|
const initialHeirs: Heir[] = [
|
||||||
@@ -220,6 +222,7 @@ export default function MeScreen() {
|
|||||||
const [showHeritageModal, setShowHeritageModal] = useState(false);
|
const [showHeritageModal, setShowHeritageModal] = useState(false);
|
||||||
const [showThemeModal, setShowThemeModal] = useState(false);
|
const [showThemeModal, setShowThemeModal] = useState(false);
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
|
const [showSignOutModal, setShowSignOutModal] = useState(false);
|
||||||
|
|
||||||
// Heritage / Fleet Legacy states
|
// Heritage / Fleet Legacy states
|
||||||
const [heirs, setHeirs] = useState<Heir[]>(initialHeirs);
|
const [heirs, setHeirs] = useState<Heir[]>(initialHeirs);
|
||||||
@@ -245,6 +248,7 @@ export default function MeScreen() {
|
|||||||
});
|
});
|
||||||
const [sanctumArchive, setSanctumArchive] = useState<'off' | 'standard' | 'strict'>('standard');
|
const [sanctumArchive, setSanctumArchive] = useState<'off' | 'standard' | 'strict'>('standard');
|
||||||
const [sanctumRehearsal, setSanctumRehearsal] = useState<'monthly' | 'quarterly'>('quarterly');
|
const [sanctumRehearsal, setSanctumRehearsal] = useState<'monthly' | 'quarterly'>('quarterly');
|
||||||
|
const [resetVaultFeedback, setResetVaultFeedback] = useState<{ status: 'idle' | 'success' | 'error'; message: string }>({ status: 'idle', message: '' });
|
||||||
const [triggerDisconnectDays, setTriggerDisconnectDays] = useState(30);
|
const [triggerDisconnectDays, setTriggerDisconnectDays] = useState(30);
|
||||||
const [triggerGraceDays, setTriggerGraceDays] = useState(15);
|
const [triggerGraceDays, setTriggerGraceDays] = useState(15);
|
||||||
const [triggerSource, setTriggerSource] = useState<'dual' | 'subscription' | 'activity'>('dual');
|
const [triggerSource, setTriggerSource] = useState<'dual' | 'subscription' | 'activity'>('dual');
|
||||||
@@ -294,18 +298,40 @@ export default function MeScreen() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleAbandonIsland = () => {
|
const handleAbandonIsland = () => {
|
||||||
Alert.alert(
|
console.log('[MeScreen] Sign out button clicked');
|
||||||
'Sign Out',
|
setShowSignOutModal(true);
|
||||||
'Are you sure you want to sign out?',
|
};
|
||||||
[
|
|
||||||
{ text: 'Cancel', style: 'cancel' },
|
const handleConfirmSignOut = () => {
|
||||||
{
|
console.log('[MeScreen] User confirmed sign out');
|
||||||
text: 'Sign Out',
|
setShowSignOutModal(false);
|
||||||
style: 'destructive',
|
signOut();
|
||||||
onPress: signOut
|
};
|
||||||
},
|
|
||||||
]
|
const handleResetVault = async () => {
|
||||||
);
|
setResetVaultFeedback({ status: 'idle', message: '' });
|
||||||
|
const vaultKeys = getVaultStorageKeys(user?.id ?? null);
|
||||||
|
try {
|
||||||
|
await AsyncStorage.multiRemove([
|
||||||
|
vaultKeys.INITIALIZED,
|
||||||
|
vaultKeys.SHARE_DEVICE,
|
||||||
|
vaultKeys.MNEMONIC_PART_LOCAL,
|
||||||
|
]);
|
||||||
|
setResetVaultFeedback({
|
||||||
|
status: 'success',
|
||||||
|
message: 'Vault state has been reset. Next time you open Shadow Vault you will see the mnemonic flow again.',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setResetVaultFeedback({
|
||||||
|
status: 'error',
|
||||||
|
message: 'Failed to reset vault state. Please try again.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseSanctumModal = () => {
|
||||||
|
setResetVaultFeedback({ status: 'idle', message: '' });
|
||||||
|
setShowSanctumModal(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -749,7 +775,7 @@ export default function MeScreen() {
|
|||||||
visible={showSanctumModal}
|
visible={showSanctumModal}
|
||||||
animationType="fade"
|
animationType="fade"
|
||||||
transparent
|
transparent
|
||||||
onRequestClose={() => setShowSanctumModal(false)}
|
onRequestClose={handleCloseSanctumModal}
|
||||||
>
|
>
|
||||||
<View style={styles.spiritOverlay}>
|
<View style={styles.spiritOverlay}>
|
||||||
<View style={styles.spiritModal}>
|
<View style={styles.spiritModal}>
|
||||||
@@ -885,12 +911,51 @@ export default function MeScreen() {
|
|||||||
<Text style={styles.sanctumValue}>View</Text>
|
<Text style={styles.sanctumValue}>View</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{__DEV__ && (
|
||||||
|
<View style={styles.sanctumSection}>
|
||||||
|
<Text style={styles.tideLabel}>DEV ONLY</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.devResetButton}
|
||||||
|
onPress={handleResetVault}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<Ionicons name="refresh" size={16} color={colors.nautical.coral} />
|
||||||
|
<Text style={styles.devResetText}>Reset Vault State</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.sanctumHint}>Clear S0 (SHARE_DEVICE) from storage. Next vault open uses mnemonic flow.</Text>
|
||||||
|
{resetVaultFeedback.status !== 'idle' && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.resetVaultFeedback,
|
||||||
|
resetVaultFeedback.status === 'success' ? styles.resetVaultFeedbackSuccess : styles.resetVaultFeedbackError,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={resetVaultFeedback.status === 'success' ? 'checkmark-circle' : 'alert-circle'}
|
||||||
|
size={20}
|
||||||
|
color={resetVaultFeedback.status === 'success' ? colors.sentinel?.statusNormal ?? '#6BBF8A' : colors.nautical.coral}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.resetVaultFeedbackText,
|
||||||
|
resetVaultFeedback.status === 'success' ? styles.resetVaultFeedbackTextSuccess : styles.resetVaultFeedbackTextError,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{resetVaultFeedback.status === 'success' ? 'Success' : 'Error'}
|
||||||
|
{' — '}
|
||||||
|
{resetVaultFeedback.message}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<View style={styles.tideModalButtons}>
|
<View style={styles.tideModalButtons}>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.confirmPulseButton}
|
style={styles.confirmPulseButton}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
onPress={() => setShowSanctumModal(false)}
|
onPress={handleCloseSanctumModal}
|
||||||
>
|
>
|
||||||
<Ionicons name="checkmark-circle" size={18} color={colors.nautical.teal} />
|
<Ionicons name="checkmark-circle" size={18} color={colors.nautical.teal} />
|
||||||
<Text style={styles.confirmPulseText}>Save</Text>
|
<Text style={styles.confirmPulseText}>Save</Text>
|
||||||
@@ -898,7 +963,7 @@ export default function MeScreen() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.confirmPulseButton}
|
style={styles.confirmPulseButton}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.85}
|
||||||
onPress={() => setShowSanctumModal(false)}
|
onPress={handleCloseSanctumModal}
|
||||||
>
|
>
|
||||||
<Ionicons name="close-circle" size={18} color={colors.nautical.teal} />
|
<Ionicons name="close-circle" size={18} color={colors.nautical.teal} />
|
||||||
<Text style={styles.confirmPulseText}>Close</Text>
|
<Text style={styles.confirmPulseText}>Close</Text>
|
||||||
@@ -1412,6 +1477,46 @@ export default function MeScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1814,6 +1919,23 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
color: colors.me.textSecondary,
|
color: colors.me.textSecondary,
|
||||||
},
|
},
|
||||||
|
devResetButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
backgroundColor: `${colors.nautical.coral}15`,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: `${colors.nautical.coral}40`,
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
devResetText: {
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
color: colors.nautical.coral,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
sanctumAlert: {
|
sanctumAlert: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -1827,6 +1949,34 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
color: colors.nautical.coral,
|
color: colors.nautical.coral,
|
||||||
},
|
},
|
||||||
|
resetVaultFeedback: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: spacing.base,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
resetVaultFeedbackSuccess: {
|
||||||
|
backgroundColor: 'rgba(107, 191, 138, 0.2)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(107, 191, 138, 0.5)',
|
||||||
|
},
|
||||||
|
resetVaultFeedbackError: {
|
||||||
|
backgroundColor: 'rgba(229, 115, 115, 0.2)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(229, 115, 115, 0.5)',
|
||||||
|
},
|
||||||
|
resetVaultFeedbackText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: typography.fontSize.sm,
|
||||||
|
},
|
||||||
|
resetVaultFeedbackTextSuccess: {
|
||||||
|
color: '#2E7D5E',
|
||||||
|
},
|
||||||
|
resetVaultFeedbackTextError: {
|
||||||
|
color: colors.nautical.coral,
|
||||||
|
},
|
||||||
confirmPulseButton: {
|
confirmPulseButton: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -2333,4 +2483,71 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
zIndex: 10,
|
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',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -8,40 +8,12 @@ import {
|
|||||||
SafeAreaView,
|
SafeAreaView,
|
||||||
Animated,
|
Animated,
|
||||||
Modal,
|
Modal,
|
||||||
TextInput,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
Platform,
|
|
||||||
Share,
|
|
||||||
Alert,
|
|
||||||
Linking,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons';
|
import { Ionicons, Feather, MaterialCommunityIcons, FontAwesome5 } from '@expo/vector-icons';
|
||||||
import { captureRef } from 'react-native-view-shot';
|
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
||||||
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
|
import { colors, typography, spacing, borderRadius, shadows } from '../theme/colors';
|
||||||
import { SystemStatus, KillSwitchLog } from '../types';
|
import { SystemStatus, KillSwitchLog } from '../types';
|
||||||
import VaultScreen from './VaultScreen';
|
import VaultScreen from './VaultScreen';
|
||||||
import {
|
|
||||||
SSSShare,
|
|
||||||
mnemonicToEntropy,
|
|
||||||
splitSecret,
|
|
||||||
formatShareCompact,
|
|
||||||
serializeShare,
|
|
||||||
verifyShares,
|
|
||||||
} from '../utils/sss';
|
|
||||||
|
|
||||||
// Nautical-themed mnemonic word list (unique words only)
|
|
||||||
const MNEMONIC_WORDS = [
|
|
||||||
'anchor', 'harbor', 'compass', 'lighthouse', 'current', 'ocean', 'tide', 'voyage',
|
|
||||||
'keel', 'stern', 'bow', 'mast', 'sail', 'port', 'starboard', 'reef',
|
|
||||||
'signal', 'beacon', 'chart', 'helm', 'gale', 'calm', 'cove', 'isle',
|
|
||||||
'horizon', 'sextant', 'sound', 'drift', 'wake', 'mariner', 'pilot', 'fathom',
|
|
||||||
'buoy', 'lantern', 'harpoon', 'lagoon', 'bay', 'strait', 'riptide', 'foam',
|
|
||||||
'coral', 'pearl', 'trident', 'ebb', 'flow', 'vault', 'cipher', 'shroud',
|
|
||||||
'salt', 'wave', 'grotto', 'storm', 'north', 'south', 'east', 'west',
|
|
||||||
'ember', 'cabin', 'ledger', 'torch', 'sanctum', 'oath', 'depths', 'captain',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// Animation timing constants
|
// Animation timing constants
|
||||||
const ANIMATION_DURATION = {
|
const ANIMATION_DURATION = {
|
||||||
@@ -51,49 +23,6 @@ const ANIMATION_DURATION = {
|
|||||||
heartbeatPress: 150,
|
heartbeatPress: 150,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const generateMnemonic = (wordCount = 12) => {
|
|
||||||
const words: string[] = [];
|
|
||||||
for (let i = 0; i < wordCount; i += 1) {
|
|
||||||
const index = Math.floor(Math.random() * MNEMONIC_WORDS.length);
|
|
||||||
words.push(MNEMONIC_WORDS[index]);
|
|
||||||
}
|
|
||||||
return words;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate SSS shares from mnemonic words
|
|
||||||
* Uses Shamir's Secret Sharing (3,2) threshold scheme
|
|
||||||
*/
|
|
||||||
const generateSSSShares = (words: string[]): SSSShare[] => {
|
|
||||||
try {
|
|
||||||
// Convert mnemonic to entropy (big integer)
|
|
||||||
const entropy = mnemonicToEntropy(words, MNEMONIC_WORDS);
|
|
||||||
|
|
||||||
// Split entropy into 3 shares using SSS
|
|
||||||
const shares = splitSecret(entropy);
|
|
||||||
|
|
||||||
// Verify shares can recover the original (optional, for debugging)
|
|
||||||
if (__DEV__) {
|
|
||||||
const isValid = verifyShares(shares, entropy);
|
|
||||||
if (!isValid) {
|
|
||||||
console.warn('SSS verification failed!');
|
|
||||||
} else {
|
|
||||||
console.log('SSS shares verified successfully');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return shares;
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to generate SSS shares:', error);
|
|
||||||
// Fallback: return empty shares (should not happen in production)
|
|
||||||
return [
|
|
||||||
{ x: 1, y: BigInt(0), label: 'device' },
|
|
||||||
{ x: 2, y: BigInt(0), label: 'cloud' },
|
|
||||||
{ x: 3, y: BigInt(0), label: 'heir' },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Icon names type for type safety
|
// Icon names type for type safety
|
||||||
type StatusIconName = 'checkmark-circle' | 'warning' | 'alert-circle';
|
type StatusIconName = 'checkmark-circle' | 'warning' | 'alert-circle';
|
||||||
|
|
||||||
@@ -130,28 +59,14 @@ const statusConfig: Record<SystemStatus, {
|
|||||||
|
|
||||||
// Mock data
|
// Mock data
|
||||||
const initialLogs: KillSwitchLog[] = [
|
const initialLogs: KillSwitchLog[] = [
|
||||||
{
|
{ id: '1', action: 'HEARTBEAT_CONFIRMED', timestamp: new Date('2024-01-18T09:30:00') },
|
||||||
id: '1',
|
{ id: '2', action: 'SUBSCRIPTION_VERIFIED', timestamp: new Date('2024-01-17T00:00:00') },
|
||||||
action: 'HEARTBEAT_CONFIRMED',
|
{ id: '3', action: 'JOURNAL_ACTIVITY', timestamp: new Date('2024-01-16T15:42:00') },
|
||||||
timestamp: new Date('2024-01-18T09:30:00'),
|
{ id: '4', action: 'HEARTBEAT_CONFIRMED', timestamp: new Date('2024-01-15T11:20:00') },
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
action: 'SUBSCRIPTION_VERIFIED',
|
|
||||||
timestamp: new Date('2024-01-17T00:00:00'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
action: 'JOURNAL_ACTIVITY',
|
|
||||||
timestamp: new Date('2024-01-16T15:42:00'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
action: 'HEARTBEAT_CONFIRMED',
|
|
||||||
timestamp: new Date('2024-01-15T11:20:00'),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export { VAULT_STORAGE_KEYS } from '../config';
|
||||||
|
|
||||||
export default function SentinelScreen() {
|
export default function SentinelScreen() {
|
||||||
const [status, setStatus] = useState<SystemStatus>('normal');
|
const [status, setStatus] = useState<SystemStatus>('normal');
|
||||||
const [lastSubscriptionCheck] = useState(new Date('2024-01-18T00:00:00'));
|
const [lastSubscriptionCheck] = useState(new Date('2024-01-18T00:00:00'));
|
||||||
@@ -161,16 +76,8 @@ export default function SentinelScreen() {
|
|||||||
const [glowAnim] = useState(new Animated.Value(0.5));
|
const [glowAnim] = useState(new Animated.Value(0.5));
|
||||||
const [rotateAnim] = useState(new Animated.Value(0));
|
const [rotateAnim] = useState(new Animated.Value(0));
|
||||||
const [showVault, setShowVault] = useState(false);
|
const [showVault, setShowVault] = useState(false);
|
||||||
const [showMnemonic, setShowMnemonic] = useState(false);
|
|
||||||
const [mnemonicWords, setMnemonicWords] = useState<string[]>([]);
|
|
||||||
const [sssShares, setSssShares] = useState<SSSShare[]>([]);
|
|
||||||
const [showEmailForm, setShowEmailForm] = useState(false);
|
|
||||||
const [emailAddress, setEmailAddress] = useState('');
|
|
||||||
const [isCapturing, setIsCapturing] = useState(false);
|
|
||||||
const mnemonicRef = useRef<View>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Pulse animation
|
|
||||||
const pulseAnimation = Animated.loop(
|
const pulseAnimation = Animated.loop(
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
Animated.timing(pulseAnim, {
|
Animated.timing(pulseAnim, {
|
||||||
@@ -187,7 +94,6 @@ export default function SentinelScreen() {
|
|||||||
);
|
);
|
||||||
pulseAnimation.start();
|
pulseAnimation.start();
|
||||||
|
|
||||||
// Glow animation
|
|
||||||
const glowAnimation = Animated.loop(
|
const glowAnimation = Animated.loop(
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
Animated.timing(glowAnim, {
|
Animated.timing(glowAnim, {
|
||||||
@@ -204,7 +110,6 @@ export default function SentinelScreen() {
|
|||||||
);
|
);
|
||||||
glowAnimation.start();
|
glowAnimation.start();
|
||||||
|
|
||||||
// Slow rotate for ship wheel
|
|
||||||
const rotateAnimation = Animated.loop(
|
const rotateAnimation = Animated.loop(
|
||||||
Animated.timing(rotateAnim, {
|
Animated.timing(rotateAnim, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
@@ -214,7 +119,6 @@ export default function SentinelScreen() {
|
|||||||
);
|
);
|
||||||
rotateAnimation.start();
|
rotateAnimation.start();
|
||||||
|
|
||||||
// Cleanup animations on unmount to prevent memory leaks
|
|
||||||
return () => {
|
return () => {
|
||||||
pulseAnimation.stop();
|
pulseAnimation.stop();
|
||||||
glowAnimation.stop();
|
glowAnimation.stop();
|
||||||
@@ -222,73 +126,9 @@ export default function SentinelScreen() {
|
|||||||
};
|
};
|
||||||
}, [pulseAnim, glowAnim, rotateAnim]);
|
}, [pulseAnim, glowAnim, rotateAnim]);
|
||||||
|
|
||||||
const openVaultWithMnemonic = () => {
|
const openVault = () => setShowVault(true);
|
||||||
const words = generateMnemonic();
|
|
||||||
const shares = generateSSSShares(words);
|
|
||||||
setMnemonicWords(words);
|
|
||||||
setSssShares(shares);
|
|
||||||
setShowMnemonic(true);
|
|
||||||
setShowVault(false);
|
|
||||||
setShowEmailForm(false);
|
|
||||||
setEmailAddress('');
|
|
||||||
|
|
||||||
// Store Share A (device share) locally
|
|
||||||
if (shares[0]) {
|
|
||||||
AsyncStorage.setItem('sentinel_share_device', serializeShare(shares[0])).catch(() => {
|
|
||||||
// Best-effort local store; UI remains available
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleScreenshot = async () => {
|
|
||||||
try {
|
|
||||||
setIsCapturing(true);
|
|
||||||
const uri = await captureRef(mnemonicRef, {
|
|
||||||
format: 'png',
|
|
||||||
quality: 1,
|
|
||||||
result: 'tmpfile',
|
|
||||||
});
|
|
||||||
await Share.share({
|
|
||||||
url: uri,
|
|
||||||
message: 'Sentinel key backup',
|
|
||||||
});
|
|
||||||
setShowMnemonic(false);
|
|
||||||
setShowVault(true);
|
|
||||||
} catch (error) {
|
|
||||||
Alert.alert('Screenshot failed', 'Please try again or use email backup.');
|
|
||||||
} finally {
|
|
||||||
setIsCapturing(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEmailBackup = () => {
|
|
||||||
setShowEmailForm(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendEmail = async () => {
|
|
||||||
const trimmed = emailAddress.trim();
|
|
||||||
if (!trimmed || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
|
|
||||||
Alert.alert('Invalid email', 'Please enter a valid email address.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subject = encodeURIComponent('Sentinel Vault Recovery Key');
|
|
||||||
const body = encodeURIComponent(`Your 12-word mnemonic:\n${mnemonicWords.join(' ')}`);
|
|
||||||
const mailtoUrl = `mailto:${trimmed}?subject=${subject}&body=${body}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await Linking.openURL(mailtoUrl);
|
|
||||||
setShowMnemonic(false);
|
|
||||||
setShowEmailForm(false);
|
|
||||||
setEmailAddress('');
|
|
||||||
setShowVault(true);
|
|
||||||
} catch (error) {
|
|
||||||
Alert.alert('Email failed', 'Unable to open email client.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleHeartbeat = () => {
|
const handleHeartbeat = () => {
|
||||||
// Animate pulse
|
|
||||||
Animated.sequence([
|
Animated.sequence([
|
||||||
Animated.timing(pulseAnim, {
|
Animated.timing(pulseAnim, {
|
||||||
toValue: 1.15,
|
toValue: 1.15,
|
||||||
@@ -302,7 +142,6 @@ export default function SentinelScreen() {
|
|||||||
}),
|
}),
|
||||||
]).start();
|
]).start();
|
||||||
|
|
||||||
// Add new log using functional update to avoid stale closure
|
|
||||||
const newLog: KillSwitchLog = {
|
const newLog: KillSwitchLog = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
action: 'HEARTBEAT_CONFIRMED',
|
action: 'HEARTBEAT_CONFIRMED',
|
||||||
@@ -310,35 +149,27 @@ export default function SentinelScreen() {
|
|||||||
};
|
};
|
||||||
setLogs((prevLogs) => [newLog, ...prevLogs]);
|
setLogs((prevLogs) => [newLog, ...prevLogs]);
|
||||||
|
|
||||||
// Reset status if warning
|
|
||||||
if (status === 'warning') {
|
if (status === 'warning') {
|
||||||
setStatus('normal');
|
setStatus('normal');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDateTime = (date: Date) => {
|
const formatDateTime = (date: Date) =>
|
||||||
return date.toLocaleString('en-US', {
|
date.toLocaleString('en-US', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: '2-digit',
|
month: '2-digit',
|
||||||
day: '2-digit',
|
day: '2-digit',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const formatTimeAgo = (date: Date) => {
|
const formatTimeAgo = (date: Date) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diff = now.getTime() - date.getTime();
|
const diff = now.getTime() - date.getTime();
|
||||||
const hours = Math.floor(diff / (1000 * 60 * 60));
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||||
|
if (hours > 24) return `${Math.floor(hours / 24)} days ago`;
|
||||||
if (hours > 24) {
|
if (hours > 0) return `${hours}h ${minutes}m ago`;
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return `${days} days ago`;
|
|
||||||
}
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}h ${minutes}m ago`;
|
|
||||||
}
|
|
||||||
return `${minutes}m ago`;
|
return `${minutes}m ago`;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -378,7 +209,7 @@ export default function SentinelScreen() {
|
|||||||
transform: [{ scale: pulseAnim }],
|
transform: [{ scale: pulseAnim }],
|
||||||
opacity: glowAnim,
|
opacity: glowAnim,
|
||||||
backgroundColor: `${currentStatus.color}20`,
|
backgroundColor: `${currentStatus.color}20`,
|
||||||
}
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
|
||||||
@@ -416,24 +247,16 @@ export default function SentinelScreen() {
|
|||||||
<FontAwesome5 name="anchor" size={16} color={colors.sentinel.primary} />
|
<FontAwesome5 name="anchor" size={16} color={colors.sentinel.primary} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.metricLabel}>SUBSCRIPTION</Text>
|
<Text style={styles.metricLabel}>SUBSCRIPTION</Text>
|
||||||
<Text style={styles.metricValue}>
|
<Text style={styles.metricValue}>{formatTimeAgo(lastSubscriptionCheck)}</Text>
|
||||||
{formatTimeAgo(lastSubscriptionCheck)}
|
<Text style={styles.metricTime}>{formatDateTime(lastSubscriptionCheck)}</Text>
|
||||||
</Text>
|
|
||||||
<Text style={styles.metricTime}>
|
|
||||||
{formatDateTime(lastSubscriptionCheck)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.metricCard}>
|
<View style={styles.metricCard}>
|
||||||
<View style={styles.metricIconContainer}>
|
<View style={styles.metricIconContainer}>
|
||||||
<Feather name="edit-3" size={16} color={colors.sentinel.primary} />
|
<Feather name="edit-3" size={16} color={colors.sentinel.primary} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.metricLabel}>LAST JOURNAL</Text>
|
<Text style={styles.metricLabel}>LAST JOURNAL</Text>
|
||||||
<Text style={styles.metricValue}>
|
<Text style={styles.metricValue}>{formatTimeAgo(lastFlowActivity)}</Text>
|
||||||
{formatTimeAgo(lastFlowActivity)}
|
<Text style={styles.metricTime}>{formatDateTime(lastFlowActivity)}</Text>
|
||||||
</Text>
|
|
||||||
<Text style={styles.metricTime}>
|
|
||||||
{formatDateTime(lastFlowActivity)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
@@ -450,7 +273,7 @@ export default function SentinelScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.vaultAccessButton}
|
style={styles.vaultAccessButton}
|
||||||
onPress={openVaultWithMnemonic}
|
onPress={openVault}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
accessibilityLabel="Open Shadow Vault"
|
accessibilityLabel="Open Shadow Vault"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
@@ -494,9 +317,7 @@ export default function SentinelScreen() {
|
|||||||
<View style={styles.logDot} />
|
<View style={styles.logDot} />
|
||||||
<View style={styles.logContent}>
|
<View style={styles.logContent}>
|
||||||
<Text style={styles.logAction}>{log.action}</Text>
|
<Text style={styles.logAction}>{log.action}</Text>
|
||||||
<Text style={styles.logTime}>
|
<Text style={styles.logTime}>{formatDateTime(log.timestamp)}</Text>
|
||||||
{formatDateTime(log.timestamp)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -512,7 +333,7 @@ export default function SentinelScreen() {
|
|||||||
onRequestClose={() => setShowVault(false)}
|
onRequestClose={() => setShowVault(false)}
|
||||||
>
|
>
|
||||||
<View style={styles.vaultModalContainer}>
|
<View style={styles.vaultModalContainer}>
|
||||||
<VaultScreen />
|
{showVault ? <VaultScreen /> : null}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.vaultCloseButton}
|
style={styles.vaultCloseButton}
|
||||||
onPress={() => setShowVault(false)}
|
onPress={() => setShowVault(false)}
|
||||||
@@ -524,140 +345,20 @@ export default function SentinelScreen() {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Mnemonic Modal */}
|
|
||||||
<Modal
|
|
||||||
visible={showMnemonic}
|
|
||||||
animationType="fade"
|
|
||||||
transparent
|
|
||||||
onRequestClose={() => setShowMnemonic(false)}
|
|
||||||
>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
style={styles.mnemonicOverlay}
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
||||||
>
|
|
||||||
<View ref={mnemonicRef} collapsable={false}>
|
|
||||||
<LinearGradient
|
|
||||||
colors={[colors.sentinel.cardBackground, colors.sentinel.backgroundGradientEnd]}
|
|
||||||
style={styles.mnemonicCard}
|
|
||||||
>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.mnemonicClose}
|
|
||||||
onPress={() => setShowMnemonic(false)}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
accessibilityLabel="Close mnemonic modal"
|
|
||||||
accessibilityRole="button"
|
|
||||||
>
|
|
||||||
<Ionicons name="close" size={18} color={colors.sentinel.textSecondary} />
|
|
||||||
</TouchableOpacity>
|
|
||||||
<View style={styles.mnemonicHeader}>
|
|
||||||
<MaterialCommunityIcons name="key-variant" size={22} color={colors.sentinel.primary} />
|
|
||||||
<Text style={styles.mnemonicTitle}>12-Word Mnemonic</Text>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.mnemonicSubtitle}>
|
|
||||||
Your seed is protected by SSS (3,2) threshold encryption. Any 2 shares can restore your vault.
|
|
||||||
</Text>
|
|
||||||
<View style={styles.mnemonicBlock}>
|
|
||||||
<Text style={styles.mnemonicBlockText}>
|
|
||||||
{mnemonicWords.join(' ')}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.partGrid}>
|
|
||||||
<View style={[styles.partCard, styles.partCardStored]}>
|
|
||||||
<Text style={styles.partLabel}>SHARE A • DEVICE</Text>
|
|
||||||
<Text style={styles.partValue}>
|
|
||||||
{sssShares[0] ? formatShareCompact(sssShares[0]) : '---'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.partHint}>Stored on this device</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.partCard}>
|
|
||||||
<Text style={styles.partLabel}>SHARE B • CLOUD</Text>
|
|
||||||
<Text style={styles.partValue}>
|
|
||||||
{sssShares[1] ? formatShareCompact(sssShares[1]) : '---'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.partHint}>To be synced to Sentinel</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.partCard}>
|
|
||||||
<Text style={styles.partLabel}>SHARE C • HEIR</Text>
|
|
||||||
<Text style={styles.partValue}>
|
|
||||||
{sssShares[2] ? formatShareCompact(sssShares[2]) : '---'}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.partHint}>For your heir (2-of-3 required)</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.mnemonicPrimaryButton, isCapturing && styles.mnemonicButtonDisabled]}
|
|
||||||
onPress={handleScreenshot}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
disabled={isCapturing}
|
|
||||||
accessibilityLabel="Take screenshot backup of mnemonic"
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityState={{ disabled: isCapturing }}
|
|
||||||
>
|
|
||||||
<Text style={styles.mnemonicPrimaryText}>
|
|
||||||
{isCapturing ? 'CAPTURING...' : 'PHYSICAL BACKUP (SCREENSHOT)'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.mnemonicSecondaryButton}
|
|
||||||
onPress={handleEmailBackup}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
accessibilityLabel="Send mnemonic backup via email"
|
|
||||||
accessibilityRole="button"
|
|
||||||
>
|
|
||||||
<Text style={styles.mnemonicSecondaryText}>EMAIL BACKUP</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
{showEmailForm ? (
|
|
||||||
<View style={styles.emailForm}>
|
|
||||||
<TextInput
|
|
||||||
style={styles.emailInput}
|
|
||||||
value={emailAddress}
|
|
||||||
onChangeText={setEmailAddress}
|
|
||||||
placeholder="you@email.com"
|
|
||||||
placeholderTextColor={colors.sentinel.textSecondary}
|
|
||||||
keyboardType="email-address"
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
/>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.emailSendButton}
|
|
||||||
onPress={handleSendEmail}
|
|
||||||
activeOpacity={0.85}
|
|
||||||
accessibilityLabel="Send backup email"
|
|
||||||
accessibilityRole="button"
|
|
||||||
>
|
|
||||||
<Text style={styles.emailSendText}>SEND EMAIL</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
) : null}
|
|
||||||
</LinearGradient>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</Modal>
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: { flex: 1 },
|
||||||
flex: 1,
|
gradient: { flex: 1 },
|
||||||
},
|
safeArea: { flex: 1 },
|
||||||
gradient: {
|
scrollView: { flex: 1 },
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
safeArea: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
scrollView: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
paddingBottom: 120,
|
paddingBottom: 120,
|
||||||
},
|
},
|
||||||
header: {
|
header: { marginBottom: spacing.xl },
|
||||||
marginBottom: spacing.xl,
|
|
||||||
},
|
|
||||||
headerTitleRow: {
|
headerTitleRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -763,9 +464,7 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: spacing.xl,
|
marginBottom: spacing.xl,
|
||||||
...shadows.medium,
|
...shadows.medium,
|
||||||
},
|
},
|
||||||
heartbeatGradient: {
|
heartbeatGradient: { padding: spacing.lg },
|
||||||
padding: spacing.lg,
|
|
||||||
},
|
|
||||||
heartbeatContent: {
|
heartbeatContent: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -819,9 +518,7 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
marginRight: spacing.md,
|
marginRight: spacing.md,
|
||||||
},
|
},
|
||||||
logContent: {
|
logContent: { flex: 1 },
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
logAction: {
|
logAction: {
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
color: colors.sentinel.text,
|
color: colors.sentinel.text,
|
||||||
@@ -834,7 +531,6 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.sentinel.textSecondary,
|
color: colors.sentinel.textSecondary,
|
||||||
fontFamily: typography.fontFamily.mono,
|
fontFamily: typography.fontFamily.mono,
|
||||||
},
|
},
|
||||||
// Shadow Vault Access Card
|
|
||||||
vaultAccessCard: {
|
vaultAccessCard: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -854,9 +550,7 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginRight: spacing.md,
|
marginRight: spacing.md,
|
||||||
},
|
},
|
||||||
vaultAccessContent: {
|
vaultAccessContent: { flex: 1 },
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
vaultAccessTitle: {
|
vaultAccessTitle: {
|
||||||
fontSize: typography.fontSize.base,
|
fontSize: typography.fontSize.base,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@@ -878,7 +572,6 @@ const styles = StyleSheet.create({
|
|||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
fontSize: typography.fontSize.sm,
|
fontSize: typography.fontSize.sm,
|
||||||
},
|
},
|
||||||
// Vault Modal
|
|
||||||
vaultModalContainer: {
|
vaultModalContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.vault.background,
|
backgroundColor: colors.vault.background,
|
||||||
@@ -894,148 +587,4 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
mnemonicOverlay: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: 'rgba(11, 20, 24, 0.72)',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: spacing.lg,
|
|
||||||
},
|
|
||||||
mnemonicCard: {
|
|
||||||
borderRadius: borderRadius.xl,
|
|
||||||
padding: spacing.lg,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.sentinel.cardBorder,
|
|
||||||
...shadows.glow,
|
|
||||||
},
|
|
||||||
mnemonicHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: spacing.sm,
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
mnemonicClose: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: spacing.sm,
|
|
||||||
right: spacing.sm,
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
borderRadius: 16,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
backgroundColor: 'rgba(26, 58, 74, 0.35)',
|
|
||||||
},
|
|
||||||
mnemonicTitle: {
|
|
||||||
fontSize: typography.fontSize.lg,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: colors.sentinel.text,
|
|
||||||
letterSpacing: typography.letterSpacing.wide,
|
|
||||||
},
|
|
||||||
mnemonicSubtitle: {
|
|
||||||
fontSize: typography.fontSize.sm,
|
|
||||||
color: colors.sentinel.textSecondary,
|
|
||||||
marginBottom: spacing.md,
|
|
||||||
},
|
|
||||||
mnemonicBlock: {
|
|
||||||
backgroundColor: colors.sentinel.cardBackground,
|
|
||||||
borderRadius: borderRadius.lg,
|
|
||||||
paddingVertical: spacing.md,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.sentinel.cardBorder,
|
|
||||||
marginBottom: spacing.lg,
|
|
||||||
},
|
|
||||||
partGrid: {
|
|
||||||
gap: spacing.sm,
|
|
||||||
marginBottom: spacing.lg,
|
|
||||||
},
|
|
||||||
partCard: {
|
|
||||||
backgroundColor: colors.sentinel.cardBackground,
|
|
||||||
borderRadius: borderRadius.lg,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.sentinel.cardBorder,
|
|
||||||
},
|
|
||||||
partCardStored: {
|
|
||||||
borderColor: colors.sentinel.primary,
|
|
||||||
},
|
|
||||||
partLabel: {
|
|
||||||
fontSize: typography.fontSize.xs,
|
|
||||||
color: colors.sentinel.textSecondary,
|
|
||||||
letterSpacing: typography.letterSpacing.wide,
|
|
||||||
marginBottom: 4,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
partValue: {
|
|
||||||
fontSize: typography.fontSize.md,
|
|
||||||
color: colors.sentinel.text,
|
|
||||||
fontFamily: typography.fontFamily.mono,
|
|
||||||
fontWeight: '700',
|
|
||||||
marginBottom: 2,
|
|
||||||
},
|
|
||||||
partHint: {
|
|
||||||
fontSize: typography.fontSize.xs,
|
|
||||||
color: colors.sentinel.textSecondary,
|
|
||||||
},
|
|
||||||
mnemonicBlockText: {
|
|
||||||
fontSize: typography.fontSize.sm,
|
|
||||||
color: colors.sentinel.text,
|
|
||||||
fontFamily: typography.fontFamily.mono,
|
|
||||||
fontWeight: '600',
|
|
||||||
lineHeight: 22,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
mnemonicPrimaryButton: {
|
|
||||||
backgroundColor: colors.sentinel.primary,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: borderRadius.full,
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
mnemonicButtonDisabled: {
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
mnemonicPrimaryText: {
|
|
||||||
color: colors.nautical.cream,
|
|
||||||
fontWeight: '700',
|
|
||||||
letterSpacing: typography.letterSpacing.wide,
|
|
||||||
},
|
|
||||||
mnemonicSecondaryButton: {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: borderRadius.full,
|
|
||||||
alignItems: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.sentinel.cardBorder,
|
|
||||||
},
|
|
||||||
mnemonicSecondaryText: {
|
|
||||||
color: colors.sentinel.text,
|
|
||||||
fontWeight: '700',
|
|
||||||
letterSpacing: typography.letterSpacing.wide,
|
|
||||||
},
|
|
||||||
emailForm: {
|
|
||||||
marginTop: spacing.sm,
|
|
||||||
},
|
|
||||||
emailInput: {
|
|
||||||
height: 44,
|
|
||||||
borderRadius: borderRadius.full,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.sentinel.cardBorder,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
color: colors.sentinel.text,
|
|
||||||
fontSize: typography.fontSize.sm,
|
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.02)',
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
emailSendButton: {
|
|
||||||
backgroundColor: colors.nautical.teal,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
borderRadius: borderRadius.full,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
emailSendText: {
|
|
||||||
color: colors.nautical.cream,
|
|
||||||
fontWeight: '700',
|
|
||||||
letterSpacing: typography.letterSpacing.wide,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
getApiHeaders,
|
getApiHeaders,
|
||||||
logApiDebug,
|
logApiDebug,
|
||||||
} from '../config';
|
} from '../config';
|
||||||
|
import { AIRole } from '../types';
|
||||||
|
import { trimInternalMessages } from '../utils/token_utils';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Type Definitions
|
// Type Definitions
|
||||||
@@ -143,13 +145,14 @@ export const aiService = {
|
|||||||
* Simple helper for single message chat
|
* Simple helper for single message chat
|
||||||
* @param content - User message content
|
* @param content - User message content
|
||||||
* @param token - JWT token for authentication
|
* @param token - JWT token for authentication
|
||||||
|
* @param systemPrompt - Optional custom system prompt
|
||||||
* @returns AI response text
|
* @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[] = [
|
const messages: AIMessage[] = [
|
||||||
{
|
{
|
||||||
role: 'system',
|
role: 'system',
|
||||||
content: AI_CONFIG.DEFAULT_SYSTEM_PROMPT,
|
content: systemPrompt || AI_CONFIG.DEFAULT_SYSTEM_PROMPT,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -240,4 +243,86 @@ export const aiService = {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarize a chat conversation
|
||||||
|
* @param messages - Array of chat messages
|
||||||
|
* @param token - JWT token for authentication
|
||||||
|
* @returns AI summary text
|
||||||
|
*/
|
||||||
|
async summarizeChat(messages: AIMessage[], token?: string): Promise<string> {
|
||||||
|
if (NO_BACKEND_MODE) {
|
||||||
|
logApiDebug('AI Summary', 'Using mock mode');
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve('This is a mock summary of your conversation. You discussed various topics including AI integration and UI design. The main conclusion was to proceed with the proposed implementation plan.');
|
||||||
|
}, AI_CONFIG.MOCK_RESPONSE_DELAY);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce token limit (10,000 tokens)
|
||||||
|
const trimmedMessages = trimInternalMessages(messages);
|
||||||
|
|
||||||
|
const historicalMessages = trimmedMessages.map(msg => ({
|
||||||
|
role: msg.role,
|
||||||
|
content: msg.content,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const summaryPrompt: AIMessage = {
|
||||||
|
role: 'user',
|
||||||
|
content: 'Please provide a concise summary of the conversation above in English. Focus on the main topics discussed and any key conclusions or actions mentioned.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await this.chat([...historicalMessages, summaryPrompt], token);
|
||||||
|
return response.choices[0]?.message?.content || 'No summary generated';
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch available AI roles from backend
|
||||||
|
* @param token - Optional JWT token for authentication
|
||||||
|
* @returns Array of AI roles
|
||||||
|
*/
|
||||||
|
async getAIRoles(token?: string): Promise<AIRole[]> {
|
||||||
|
if (NO_BACKEND_MODE) {
|
||||||
|
logApiDebug('AI Roles', 'Using mock roles');
|
||||||
|
return [...AI_CONFIG.ROLES];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
console.warn('[AI Service] getAIRoles called without token, falling back to static roles');
|
||||||
|
return [...AI_CONFIG.ROLES];
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildApiUrl(API_ENDPOINTS.AI.GET_ROLES);
|
||||||
|
const headers = getApiHeaders(token);
|
||||||
|
|
||||||
|
logApiDebug('AI Roles Request', {
|
||||||
|
url,
|
||||||
|
hasToken: !!token,
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
Authorization: headers.Authorization ? `${headers.Authorization.substring(0, 15)}...` : 'MISSING'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.error(`[AI Service] Failed to fetch AI roles: ${response.status}. Falling back to static roles.`);
|
||||||
|
return [...AI_CONFIG.ROLES];
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
logApiDebug('AI Roles Success', { count: data.length });
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[AI Service] Fetch AI roles error:', error);
|
||||||
|
// Fallback to config roles if API fails for better UX
|
||||||
|
return [...AI_CONFIG.ROLES];
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface Asset {
|
|||||||
author_id: number;
|
author_id: number;
|
||||||
private_key_shard: string;
|
private_key_shard: string;
|
||||||
content_outer_encrypted: string;
|
content_outer_encrypted: string;
|
||||||
|
heir_email?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssetCreate {
|
export interface AssetCreate {
|
||||||
@@ -45,7 +46,7 @@ export interface AssetClaimResponse {
|
|||||||
|
|
||||||
export interface AssetAssign {
|
export interface AssetAssign {
|
||||||
asset_id: number;
|
asset_id: number;
|
||||||
heir_name: string;
|
heir_email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -59,6 +60,7 @@ const MOCK_ASSETS: Asset[] = [
|
|||||||
author_id: MOCK_CONFIG.USER.id,
|
author_id: MOCK_CONFIG.USER.id,
|
||||||
private_key_shard: 'mock_shard_1',
|
private_key_shard: 'mock_shard_1',
|
||||||
content_outer_encrypted: 'mock_encrypted_content_1',
|
content_outer_encrypted: 'mock_encrypted_content_1',
|
||||||
|
heir_email: 'heir@example.com',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -142,11 +144,16 @@ export const assetsService = {
|
|||||||
body: JSON.stringify(asset),
|
body: JSON.stringify(asset),
|
||||||
});
|
});
|
||||||
|
|
||||||
logApiDebug('Create Asset Response Status', response.status);
|
const responseStatus = response.status;
|
||||||
|
logApiDebug('Create Asset Response Status', responseStatus);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
throw new Error(errorData.detail || 'Failed to create asset');
|
const detail = errorData.detail || 'Failed to create asset';
|
||||||
|
const message = responseStatus === 401 ? `Unauthorized (401): ${detail}` : detail;
|
||||||
|
const err = new Error(message) as Error & { status?: number };
|
||||||
|
err.status = responseStatus;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await response.json();
|
return await response.json();
|
||||||
@@ -212,7 +219,7 @@ export const assetsService = {
|
|||||||
logApiDebug('Assign Asset', 'Using mock mode');
|
logApiDebug('Assign Asset', 'Using mock mode');
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
resolve({ message: `Asset assigned to ${assignment.heir_name}` });
|
resolve({ message: `Asset assigned to ${assignment.heir_email}` });
|
||||||
}, MOCK_CONFIG.RESPONSE_DELAY);
|
}, MOCK_CONFIG.RESPONSE_DELAY);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -240,4 +247,44 @@ export const assetsService = {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete an asset
|
||||||
|
* @param assetId - ID of the asset to delete
|
||||||
|
* @param token - JWT token for authentication
|
||||||
|
* @returns Success message
|
||||||
|
*/
|
||||||
|
async deleteAsset(assetId: number, token: string): Promise<{ message: string }> {
|
||||||
|
if (NO_BACKEND_MODE) {
|
||||||
|
logApiDebug('Delete Asset', `Using mock mode for ID: ${assetId}`);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve({ message: 'Asset deleted successfully' });
|
||||||
|
}, MOCK_CONFIG.RESPONSE_DELAY);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = buildApiUrl(API_ENDPOINTS.ASSETS.DELETE);
|
||||||
|
logApiDebug('Delete Asset URL', url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getApiHeaders(token),
|
||||||
|
body: JSON.stringify({ asset_id: assetId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
logApiDebug('Delete Asset Response Status', response.status);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.detail || 'Failed to delete asset');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete asset error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,3 +23,9 @@ export {
|
|||||||
type DeclareGualeRequest,
|
type DeclareGualeRequest,
|
||||||
type DeclareGualeResponse
|
type DeclareGualeResponse
|
||||||
} from './admin.service';
|
} from './admin.service';
|
||||||
|
export {
|
||||||
|
createVaultPayload,
|
||||||
|
createAssetPayload,
|
||||||
|
type CreateVaultPayloadResult,
|
||||||
|
type CreateAssetPayloadResult,
|
||||||
|
} from './vault.service';
|
||||||
|
|||||||
96
src/services/langgraph.service.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* LangGraph Service
|
||||||
|
*
|
||||||
|
* Implements AI chat logic using LangGraph.js for state management
|
||||||
|
* and context handling.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { StateGraph, START, END, Annotation } from "@langchain/langgraph";
|
||||||
|
import { BaseMessage, HumanMessage, AIMessage, SystemMessage } from "@langchain/core/messages";
|
||||||
|
import { aiService } from "./ai.service";
|
||||||
|
import { trimLangChainMessages } from "../utils/token_utils";
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Settings
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the State using Annotation (Standard for latest LangGraph.js)
|
||||||
|
*/
|
||||||
|
const GraphAnnotation = Annotation.Root({
|
||||||
|
messages: Annotation<BaseMessage[]>({
|
||||||
|
reducer: (x, y) => x.concat(y),
|
||||||
|
default: () => [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Graph Definition
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The main node that calls our existing AI API
|
||||||
|
*/
|
||||||
|
async function callModel(state: typeof GraphAnnotation.State, config: any) {
|
||||||
|
const { messages } = state;
|
||||||
|
const { token } = config.configurable || {};
|
||||||
|
|
||||||
|
// 1. Trim messages to stay under token limit
|
||||||
|
const trimmedMessages = trimLangChainMessages(messages);
|
||||||
|
|
||||||
|
// 2. Convert LangChain messages to our internal AIMessage format for the API
|
||||||
|
const apiMessages = trimmedMessages.map(m => {
|
||||||
|
let role: 'system' | 'user' | 'assistant' = 'user';
|
||||||
|
const type = (m as any)._getType?.() || (m instanceof SystemMessage ? 'system' : m instanceof HumanMessage ? 'human' : m instanceof AIMessage ? 'ai' : 'user');
|
||||||
|
|
||||||
|
if (type === 'system') role = 'system';
|
||||||
|
else if (type === 'human') role = 'user';
|
||||||
|
else if (type === 'ai') role = 'assistant';
|
||||||
|
|
||||||
|
return {
|
||||||
|
role,
|
||||||
|
content: m.content.toString()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Call the proxy service
|
||||||
|
const response = await aiService.chat(apiMessages, token);
|
||||||
|
const content = response.choices[0]?.message?.content || "No response generated";
|
||||||
|
|
||||||
|
// 4. Return the new message to satisfy the Graph (it will be appended due to reducer)
|
||||||
|
return {
|
||||||
|
messages: [new AIMessage(content)]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Service Export
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
export const langGraphService = {
|
||||||
|
/**
|
||||||
|
* Run the chat graph with history
|
||||||
|
*/
|
||||||
|
async execute(
|
||||||
|
currentMessages: BaseMessage[],
|
||||||
|
userToken: string,
|
||||||
|
): Promise<string> {
|
||||||
|
// Define the graph
|
||||||
|
const workflow = new StateGraph(GraphAnnotation)
|
||||||
|
.addNode("agent", callModel)
|
||||||
|
.addEdge(START, "agent")
|
||||||
|
.addEdge("agent", END);
|
||||||
|
|
||||||
|
const app = workflow.compile();
|
||||||
|
|
||||||
|
// Execute the graph
|
||||||
|
const result = await app.invoke(
|
||||||
|
{ messages: currentMessages },
|
||||||
|
{ configurable: { token: userToken } }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Return the content of the last message (the AI response)
|
||||||
|
const lastMsg = result.messages[result.messages.length - 1];
|
||||||
|
return lastMsg.content.toString();
|
||||||
|
}
|
||||||
|
};
|
||||||
147
src/services/storage.service.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* 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',
|
||||||
|
ASSET_BACKUP: '@sentinel:asset_backup',
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save the plaintext backup of an asset locally
|
||||||
|
*/
|
||||||
|
async saveAssetBackup(assetId: number, content: string, userId: string | number): Promise<void> {
|
||||||
|
try {
|
||||||
|
const key = `${this.getUserKey(STORAGE_KEYS.ASSET_BACKUP, userId)}:${assetId}`;
|
||||||
|
await AsyncStorage.setItem(key, content);
|
||||||
|
console.log(`[Storage] Saved asset backup for user ${userId}, asset ${assetId}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error saving asset backup for asset ${assetId}:`, e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve the plaintext backup of an asset locally
|
||||||
|
*/
|
||||||
|
async getAssetBackup(assetId: number, userId: string | number): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const key = `${this.getUserKey(STORAGE_KEYS.ASSET_BACKUP, userId)}:${assetId}`;
|
||||||
|
return await AsyncStorage.getItem(key);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`Error getting asset backup for asset ${assetId}:`, e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
81
src/services/vault.service.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Vault Service: 为 /assets/create 生成 private_key_shard 与 content_inner_encrypted
|
||||||
|
*
|
||||||
|
* 流程(与后端 test_scenario / SentinelVault 一致):
|
||||||
|
* 1. 用 SSS 生成助记词并分片 → 选一个分片作为 private_key_shard(存后端,继承时返回)
|
||||||
|
* 2. 用助记词派生 AES 密钥,对明文做 AES-GCM 加密 → content_inner_encrypted(hex 字符串)
|
||||||
|
*
|
||||||
|
* 使用方式:在任意页面调用 createVaultPayload(plaintext, wordList),得到可直接传给 assetsService.createAsset 的字段。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
generateVaultKeys,
|
||||||
|
serializeShare,
|
||||||
|
type SSSShare,
|
||||||
|
type VaultKeyData,
|
||||||
|
} from '../utils/sss';
|
||||||
|
import { deriveKey, encryptDataGCM, bytesToHex } from '../utils/vaultCrypto';
|
||||||
|
|
||||||
|
export interface CreateVaultPayloadResult {
|
||||||
|
/** 传给后端的 private_key_shard(存一个 SSS 分片的序列化字符串,如云端分片) */
|
||||||
|
private_key_shard: string;
|
||||||
|
/** 传给后端的 content_inner_encrypted(AES-GCM 密文的 hex) */
|
||||||
|
content_inner_encrypted: string;
|
||||||
|
/** 本次生成的助记词(用户需妥善保管,恢复时需任意 2 个分片) */
|
||||||
|
mnemonic: string[];
|
||||||
|
/** 三个分片:device / cloud / heir,可与后端返回的 server_shard 组合恢复助记词 */
|
||||||
|
shares: SSSShare[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateAssetPayloadResult {
|
||||||
|
title: string;
|
||||||
|
type: string;
|
||||||
|
private_key_shard: string;
|
||||||
|
content_inner_encrypted: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成金库:助记词 + SSS 分片 + 内层加密内容
|
||||||
|
* @param plaintext 要加密的明文(如遗产说明、账号密码等)
|
||||||
|
* @param wordList 助记词词表(与 sss 使用的词表一致)
|
||||||
|
* @param shareIndexForServer 哪个分片存后端,0=device, 1=cloud, 2=heir,默认 1(云端)
|
||||||
|
*/
|
||||||
|
export async function createVaultPayload(
|
||||||
|
plaintext: string,
|
||||||
|
wordList: readonly string[],
|
||||||
|
shareIndexForServer: 0 | 1 | 2 = 1
|
||||||
|
): Promise<CreateVaultPayloadResult> {
|
||||||
|
const { mnemonic, shares }: VaultKeyData = generateVaultKeys(wordList, 12);
|
||||||
|
const mnemonicPhrase = mnemonic.join(' ');
|
||||||
|
const key = await deriveKey(mnemonicPhrase);
|
||||||
|
const encrypted = await encryptDataGCM(key, plaintext);
|
||||||
|
const content_inner_encrypted = bytesToHex(encrypted);
|
||||||
|
const shareForServer = shares[shareIndexForServer];
|
||||||
|
const private_key_shard = serializeShare(shareForServer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
private_key_shard,
|
||||||
|
content_inner_encrypted,
|
||||||
|
mnemonic,
|
||||||
|
shares,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成可直接用于 POST /assets/create 的请求体(含 title / type)
|
||||||
|
*/
|
||||||
|
export async function createAssetPayload(
|
||||||
|
title: string,
|
||||||
|
plaintext: string,
|
||||||
|
wordList: readonly string[],
|
||||||
|
assetType: string = 'note',
|
||||||
|
shareIndexForServer: 0 | 1 | 2 = 1
|
||||||
|
): Promise<CreateAssetPayloadResult> {
|
||||||
|
const vault = await createVaultPayload(plaintext, wordList, shareIndexForServer);
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
type: assetType,
|
||||||
|
private_key_shard: vault.private_key_shard,
|
||||||
|
content_inner_encrypted: vault.content_inner_encrypted,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -28,6 +28,8 @@ export interface VaultAsset {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
isEncrypted: boolean;
|
isEncrypted: boolean;
|
||||||
|
heirEmail?: string;
|
||||||
|
rawData?: any; // For debug logging
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sentinel Types
|
// Sentinel Types
|
||||||
@@ -77,6 +79,7 @@ export interface ProtocolInfo {
|
|||||||
export interface User {
|
export interface User {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
|
email?: string;
|
||||||
public_key: string;
|
public_key: string;
|
||||||
is_admin: boolean;
|
is_admin: boolean;
|
||||||
guale: boolean;
|
guale: boolean;
|
||||||
@@ -101,3 +104,13 @@ export interface LoginResponse {
|
|||||||
token_type: string;
|
token_type: string;
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI Types
|
||||||
|
export interface AIRole {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
icon: string;
|
||||||
|
iconFamily: string;
|
||||||
|
}
|
||||||
|
|||||||
22
src/utils/async_hooks_mock.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
/**
|
||||||
|
* Mock for Node.js async_hooks
|
||||||
|
* Used to fix LangGraph.js compatibility with React Native
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class AsyncLocalStorage {
|
||||||
|
disable() { }
|
||||||
|
getStore() {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
run(store: any, callback: (...args: any[]) => any, ...args: any[]) {
|
||||||
|
return callback(...args);
|
||||||
|
}
|
||||||
|
exit(callback: (...args: any[]) => any, ...args: any[]) {
|
||||||
|
return callback(...args);
|
||||||
|
}
|
||||||
|
enterWith(store: any) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
AsyncLocalStorage,
|
||||||
|
};
|
||||||
202
src/utils/crypto_core.ts
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import * as bip39 from 'bip39';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
|
|
||||||
|
// 定义分片类型:[x坐标, y坐标]
|
||||||
|
export type Share = [bigint, bigint];
|
||||||
|
|
||||||
|
// 定义生成密钥的返回接口
|
||||||
|
export interface VaultKeys {
|
||||||
|
mnemonic: string;
|
||||||
|
entropyHex: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SentinelKeyEngine {
|
||||||
|
// 使用第 13 个梅森素数 (2^521 - 1)
|
||||||
|
// readonly 确保不会被修改
|
||||||
|
private readonly PRIME: bigint = 2n ** 521n - 1n;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. 生成原始 12 助记词 (Master Key)
|
||||||
|
*/
|
||||||
|
public generateVaultKeys(): VaultKeys {
|
||||||
|
// 生成 128 位强度的助记词 (12 个单词)
|
||||||
|
const mnemonic = bip39.generateMnemonic(128);
|
||||||
|
|
||||||
|
// 将助记词转为 16 进制熵 (Hex String)
|
||||||
|
const entropyHex = bip39.mnemonicToEntropy(mnemonic);
|
||||||
|
|
||||||
|
return { mnemonic, entropyHex };
|
||||||
|
}
|
||||||
|
|
||||||
|
public mnemonicToEntropy(mnemonic: string): string {
|
||||||
|
return bip39.mnemonicToEntropy(mnemonic);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2. SSS (3,2) 门限分片逻辑
|
||||||
|
* @param entropyHex - 16进制字符串 (32字符)
|
||||||
|
*/
|
||||||
|
public splitToShares(entropyHex: string): Share[] {
|
||||||
|
// 将 Hex 熵转换为 BigInt
|
||||||
|
const secretInt = BigInt('0x' + entropyHex);
|
||||||
|
|
||||||
|
// 生成随机系数 a,范围 [0, PRIME-1]
|
||||||
|
const a = this.secureRandomBigInt(this.PRIME);
|
||||||
|
|
||||||
|
// 定义函数 f(x) = (S + a * x) % PRIME
|
||||||
|
const f = (x: number): bigint => {
|
||||||
|
const xBi = BigInt(x);
|
||||||
|
return (secretInt + a * xBi) % this.PRIME;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成 3 个分片: x=1, x=2, x=3
|
||||||
|
const share1: Share = [1n, f(1)]; // 手机分片
|
||||||
|
const share2: Share = [2n, f(2)]; // 云端分片
|
||||||
|
const share3: Share = [3n, f(3)]; // 传承卡分片
|
||||||
|
|
||||||
|
return [share1, share2, share3];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 3. 恢复逻辑:拉格朗日插值还原
|
||||||
|
* @param shareA - 第一个分片
|
||||||
|
* @param shareB - 第二个分片
|
||||||
|
*/
|
||||||
|
public recoverFromShares(shareA: Share, shareB: Share): string {
|
||||||
|
const [x1, y1] = shareA;
|
||||||
|
const [x2, y2] = shareB;
|
||||||
|
|
||||||
|
// 计算分子: (x2 * y1 - x1 * y2) % PRIME
|
||||||
|
// TS/JS 的 % 运算符对负数返回负数,需修正为正余数
|
||||||
|
let numerator = (x2 * y1 - x1 * y2) % this.PRIME;
|
||||||
|
if (numerator < 0n) numerator += this.PRIME;
|
||||||
|
|
||||||
|
// 计算分母: (x2 - x1)
|
||||||
|
let denominator = (x2 - x1) % this.PRIME;
|
||||||
|
if (denominator < 0n) denominator += this.PRIME;
|
||||||
|
|
||||||
|
// 计算分母的模逆: denominator^-1 mod PRIME
|
||||||
|
// 费马小定理: a^(p-2) = a^-1 (mod p)
|
||||||
|
const invDenominator = this.modPow(denominator, this.PRIME - 2n, this.PRIME);
|
||||||
|
|
||||||
|
// 还原常数项 S
|
||||||
|
const secretInt = (numerator * invDenominator) % this.PRIME;
|
||||||
|
|
||||||
|
// 转回 Hex 字符串
|
||||||
|
let recoveredEntropyHex = secretInt.toString(16);
|
||||||
|
|
||||||
|
// 补齐前导零 (Pad Start)
|
||||||
|
// 128 bit 熵 = 16 字节 = 32 个 Hex 字符
|
||||||
|
// 如果你的熵是 256 bit,这里需要改为 64
|
||||||
|
recoveredEntropyHex = recoveredEntropyHex.padStart(32, '0');
|
||||||
|
|
||||||
|
return bip39.entropyToMnemonic(recoveredEntropyHex);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Private Helper Methods ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成小于 limit 的安全随机 BigInt
|
||||||
|
*/
|
||||||
|
private secureRandomBigInt(limit: bigint): bigint {
|
||||||
|
// 计算需要的字节数
|
||||||
|
const bitLength = limit.toString(2).length;
|
||||||
|
const byteLength = Math.ceil(bitLength / 8);
|
||||||
|
|
||||||
|
let randomBi: bigint;
|
||||||
|
do {
|
||||||
|
const buf = crypto.randomBytes(byteLength);
|
||||||
|
randomBi = BigInt('0x' + buf.toString('hex'));
|
||||||
|
// 拒绝采样:确保结果小于 limit
|
||||||
|
} while (randomBi >= limit);
|
||||||
|
|
||||||
|
return randomBi;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模幂运算: (base^exp) % modulus
|
||||||
|
* 用于计算模逆
|
||||||
|
*/
|
||||||
|
private modPow(base: bigint, exp: bigint, modulus: bigint): bigint {
|
||||||
|
let result = 1n;
|
||||||
|
base = base % modulus;
|
||||||
|
while (exp > 0n) {
|
||||||
|
if (exp % 2n === 1n) result = (result * base) % modulus;
|
||||||
|
exp = exp >> 1n; // 相当于除以 2
|
||||||
|
base = (base * base) % modulus;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SentinelVault {
|
||||||
|
private salt: Buffer;
|
||||||
|
|
||||||
|
constructor(salt?: string | Buffer) {
|
||||||
|
// 默认盐值与 Python 版本保持一致
|
||||||
|
this.salt = salt ? Buffer.from(salt) : Buffer.from('Sentinel_Salt_2026');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 PBKDF2 将助记词转换为 AES-256 密钥 (32 bytes)
|
||||||
|
*/
|
||||||
|
public async deriveKey(mnemonicPhrase: string): Promise<Buffer> {
|
||||||
|
// 1. BIP-39 助记词转种子 (遵循 BIP-39 标准)
|
||||||
|
// Python 的 to_seed 默认返回 64 字节种子
|
||||||
|
const seed = await bip39.mnemonicToSeed(mnemonicPhrase);
|
||||||
|
|
||||||
|
// 2. PBKDF2 派生密钥
|
||||||
|
// 注意:PyCryptodome 的 PBKDF2 默认使用 HMAC-SHA1 (如未指定)
|
||||||
|
// 为了确保与 Python 逻辑严格一致,这里使用 'sha1'
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
crypto.pbkdf2(seed, this.salt, 100000, 32, 'sha1', (err, derivedKey) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
resolve(derivedKey);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 AES-256-GCM 模式进行加密
|
||||||
|
*/
|
||||||
|
public encryptData(key: Buffer, plaintext: string): Buffer {
|
||||||
|
// GCM 模式推荐 nonce 长度,Python 默认通常为 16 字节
|
||||||
|
const iv = crypto.randomBytes(16);
|
||||||
|
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||||
|
|
||||||
|
const ciphertext = Buffer.concat([
|
||||||
|
cipher.update(plaintext, 'utf8'),
|
||||||
|
cipher.final()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 获取 GCM 认证标签 (16 bytes)
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// 拼接结果:Nonce + Tag + Ciphertext
|
||||||
|
return Buffer.concat([iv, tag, ciphertext]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AES-256-GCM 解密
|
||||||
|
*/
|
||||||
|
public decryptData(key: Buffer, encryptedBlob: Buffer): string {
|
||||||
|
try {
|
||||||
|
// 切片提取组件
|
||||||
|
const iv = encryptedBlob.subarray(0, 16);
|
||||||
|
const tag = encryptedBlob.subarray(16, 32);
|
||||||
|
const ciphertext = encryptedBlob.subarray(32);
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
|
||||||
|
const decrypted = Buffer.concat([
|
||||||
|
decipher.update(ciphertext),
|
||||||
|
decipher.final()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return decrypted.toString('utf8');
|
||||||
|
} catch (error) {
|
||||||
|
return "【解密失败】:密钥错误或数据被篡改";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/utils/crypto_polyfill.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import * as ExpoCrypto from 'expo-crypto';
|
||||||
|
import { Buffer } from 'buffer';
|
||||||
|
import { pbkdf2 as noblePbkdf2 } from '@noble/hashes/pbkdf2';
|
||||||
|
import { sha1 } from '@noble/hashes/sha1';
|
||||||
|
import { sha256 } from '@noble/hashes/sha256';
|
||||||
|
import { sha512 } from '@noble/hashes/sha512';
|
||||||
|
import { gcm } from '@noble/ciphers/aes';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Node.js Crypto Polyfill for React Native
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function randomBytes(size: number): Buffer {
|
||||||
|
const bytes = new Uint8Array(size);
|
||||||
|
ExpoCrypto.getRandomValues(bytes);
|
||||||
|
return Buffer.from(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const hashMap: Record<string, any> = {
|
||||||
|
sha1,
|
||||||
|
sha256,
|
||||||
|
sha512,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function pbkdf2(
|
||||||
|
password: string | Buffer,
|
||||||
|
salt: string | Buffer,
|
||||||
|
iterations: number,
|
||||||
|
keylen: number,
|
||||||
|
digest: string,
|
||||||
|
callback: (err: Error | null, derivedKey: Buffer) => void
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const passwordBytes = typeof password === 'string' ? Buffer.from(password) : password;
|
||||||
|
const saltBytes = typeof salt === 'string' ? Buffer.from(salt) : salt;
|
||||||
|
const hasher = hashMap[digest.toLowerCase()];
|
||||||
|
|
||||||
|
if (!hasher) {
|
||||||
|
throw new Error(`Unsupported digest: ${digest}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = noblePbkdf2(hasher, passwordBytes, saltBytes, {
|
||||||
|
c: iterations,
|
||||||
|
dkLen: keylen,
|
||||||
|
});
|
||||||
|
|
||||||
|
callback(null, Buffer.from(result));
|
||||||
|
} catch (err) {
|
||||||
|
callback(err as Error, Buffer.alloc(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AES-GCM Implementation
|
||||||
|
class Cipher {
|
||||||
|
private key: Uint8Array;
|
||||||
|
private iv: Uint8Array;
|
||||||
|
private authTag: Buffer | null = null;
|
||||||
|
private aesGcm: any;
|
||||||
|
private buffer: Buffer = Buffer.alloc(0);
|
||||||
|
|
||||||
|
constructor(key: Buffer, iv: Buffer) {
|
||||||
|
this.key = new Uint8Array(key);
|
||||||
|
this.iv = new Uint8Array(iv);
|
||||||
|
// @noble/ciphers/aes gcm takes (key, nonce)
|
||||||
|
this.aesGcm = gcm(this.key, this.iv);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(data: string | Buffer, inputEncoding?: string): Buffer {
|
||||||
|
const input = typeof data === 'string' ? Buffer.from(data, inputEncoding as any) : data;
|
||||||
|
this.buffer = Buffer.concat([this.buffer, input]);
|
||||||
|
return Buffer.alloc(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
final(): Buffer {
|
||||||
|
const result = this.aesGcm.encrypt(this.buffer);
|
||||||
|
// @noble/ciphers returns ciphertext + tag (16 bytes)
|
||||||
|
const tag = result.slice(-16);
|
||||||
|
const ciphertext = result.slice(0, -16);
|
||||||
|
this.authTag = Buffer.from(tag);
|
||||||
|
return Buffer.from(ciphertext);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthTag(): Buffer {
|
||||||
|
if (!this.authTag) throw new Error('Ciphers: TAG not available before final()');
|
||||||
|
return this.authTag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Decipher {
|
||||||
|
private key: Uint8Array;
|
||||||
|
private iv: Uint8Array;
|
||||||
|
private tag: Uint8Array | null = null;
|
||||||
|
private aesGcm: any;
|
||||||
|
private buffer: Buffer = Buffer.alloc(0);
|
||||||
|
|
||||||
|
constructor(key: Buffer, iv: Buffer) {
|
||||||
|
this.key = new Uint8Array(key);
|
||||||
|
this.iv = new Uint8Array(iv);
|
||||||
|
this.aesGcm = gcm(this.key, this.iv);
|
||||||
|
}
|
||||||
|
|
||||||
|
setAuthTag(tag: Buffer): void {
|
||||||
|
this.tag = new Uint8Array(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(data: Buffer): Buffer {
|
||||||
|
this.buffer = Buffer.concat([this.buffer, data]);
|
||||||
|
return Buffer.alloc(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
final(): Buffer {
|
||||||
|
if (!this.tag) throw new Error('Decipher: Auth tag not set');
|
||||||
|
// @noble/ciphers expects ciphertext then tag
|
||||||
|
const full = new Uint8Array(this.buffer.length + this.tag.length);
|
||||||
|
full.set(this.buffer);
|
||||||
|
full.set(this.tag, this.buffer.length);
|
||||||
|
|
||||||
|
const decrypted = this.aesGcm.decrypt(full);
|
||||||
|
return Buffer.from(decrypted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCipheriv(algorithm: string, key: Buffer, iv: Buffer): Cipher {
|
||||||
|
if (algorithm !== 'aes-256-gcm') {
|
||||||
|
throw new Error(`Polyfill only supports aes-256-gcm, got ${algorithm}`);
|
||||||
|
}
|
||||||
|
return new Cipher(key, iv);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDecipheriv(algorithm: string, key: Buffer, iv: Buffer): Decipher {
|
||||||
|
if (algorithm !== 'aes-256-gcm') {
|
||||||
|
throw new Error(`Polyfill only supports aes-256-gcm, got ${algorithm}`);
|
||||||
|
}
|
||||||
|
return new Decipher(key, iv);
|
||||||
|
}
|
||||||
@@ -3,3 +3,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export * from './sss';
|
export * from './sss';
|
||||||
|
export * from './vaultAssets';
|
||||||
|
|||||||
@@ -5,7 +5,12 @@
|
|||||||
* - Secret is split into 3 shares
|
* - Secret is split into 3 shares
|
||||||
* - Any 2 shares can recover the original secret
|
* - Any 2 shares can recover the original secret
|
||||||
*
|
*
|
||||||
* Based on the Sentinel crypto_core_demo Python implementation.
|
* Correspondence with crypto_core_demo (Python):
|
||||||
|
* - sp_trust_sharding.py: split_to_shares(), recover_from_shares()
|
||||||
|
* - Same algorithm: f(x) = secret + a*x (mod P), Lagrange interpolation
|
||||||
|
* - Difference: entropy conversion. Python uses BIP-39 (mnemonic.to_entropy);
|
||||||
|
* we use custom word list index-based encoding for compatibility with
|
||||||
|
* existing MNEMONIC_WORDS. SSS split/recover logic is identical.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Use a large prime for the finite field
|
// Use a large prime for the finite field
|
||||||
|
|||||||
76
src/utils/token_utils.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Token Utilities
|
||||||
|
*
|
||||||
|
* Shared logic for trimming messages to stay within token limits.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BaseMessage, SystemMessage } from "@langchain/core/messages";
|
||||||
|
import { AIMessage as ServiceAIMessage } from "../services/ai.service";
|
||||||
|
|
||||||
|
export const TOKEN_LIMIT = 10000;
|
||||||
|
const CHARS_PER_TOKEN = 3; // Conservative estimate: 1 token ≈ 3 chars
|
||||||
|
export const MAX_CHARS = TOKEN_LIMIT * CHARS_PER_TOKEN;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trims LangChain messages to fit within token limit
|
||||||
|
*/
|
||||||
|
export function trimLangChainMessages(messages: BaseMessage[]): BaseMessage[] {
|
||||||
|
let totalLength = 0;
|
||||||
|
const trimmed: BaseMessage[] = [];
|
||||||
|
|
||||||
|
// Always keep the system message if it's at the start
|
||||||
|
let systemMsg: BaseMessage | null = null;
|
||||||
|
if (messages.length > 0 && (messages[0] instanceof SystemMessage || (messages[0] as any)._getType?.() === 'system')) {
|
||||||
|
systemMsg = messages[0];
|
||||||
|
totalLength += systemMsg.content.toString().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate backwards and add messages until we hit the char limit
|
||||||
|
for (let i = messages.length - 1; i >= (systemMsg ? 1 : 0); i--) {
|
||||||
|
const msg = messages[i];
|
||||||
|
const len = msg.content.toString().length;
|
||||||
|
|
||||||
|
if (totalLength + len > MAX_CHARS) break;
|
||||||
|
|
||||||
|
trimmed.unshift(msg);
|
||||||
|
totalLength += len;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemMsg) {
|
||||||
|
trimmed.unshift(systemMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trims internal AIMessage format messages to fit within token limit
|
||||||
|
*/
|
||||||
|
export function trimInternalMessages(messages: ServiceAIMessage[]): ServiceAIMessage[] {
|
||||||
|
let totalLength = 0;
|
||||||
|
const trimmed: ServiceAIMessage[] = [];
|
||||||
|
|
||||||
|
// Always keep the system message if it's at the start
|
||||||
|
let systemMsg: ServiceAIMessage | null = null;
|
||||||
|
if (messages.length > 0 && messages[0].role === 'system') {
|
||||||
|
systemMsg = messages[0];
|
||||||
|
totalLength += systemMsg.content.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate backwards and add messages until we hit the char limit
|
||||||
|
for (let i = messages.length - 1; i >= (systemMsg ? 1 : 0); i--) {
|
||||||
|
const msg = messages[i];
|
||||||
|
const len = msg.content.length;
|
||||||
|
|
||||||
|
if (totalLength + len > MAX_CHARS) break;
|
||||||
|
|
||||||
|
trimmed.unshift(msg);
|
||||||
|
totalLength += len;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (systemMsg) {
|
||||||
|
trimmed.unshift(systemMsg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
71
src/utils/vaultAssets.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* Vault assets: API ↔ UI mapping and initial mock data.
|
||||||
|
* Used by useVaultAssets and VaultScreen for /assets/get and /assets/create flows.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { VaultAsset, VaultAssetType } from '../types';
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Shape returned by GET /assets/get (backend AssetOut) */
|
||||||
|
export interface ApiAsset {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
type?: string;
|
||||||
|
author_id?: number;
|
||||||
|
private_key_shard?: string;
|
||||||
|
content_outer_encrypted?: string;
|
||||||
|
created_at?: string;
|
||||||
|
updated_at?: string;
|
||||||
|
heir_id?: number;
|
||||||
|
heir_email?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Constants
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const VAULT_ASSET_TYPES: VaultAssetType[] = [
|
||||||
|
'game_account',
|
||||||
|
'private_key',
|
||||||
|
'document',
|
||||||
|
'photo',
|
||||||
|
'will',
|
||||||
|
'custom',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const initialVaultAssets: VaultAsset[] = [];
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Mapping
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map backend API asset to VaultAsset for UI.
|
||||||
|
*/
|
||||||
|
export function mapApiAssetToVaultAsset(api: ApiAsset): VaultAsset {
|
||||||
|
const type: VaultAssetType =
|
||||||
|
api.type && VAULT_ASSET_TYPES.includes(api.type as VaultAssetType)
|
||||||
|
? (api.type as VaultAssetType)
|
||||||
|
: 'custom';
|
||||||
|
return {
|
||||||
|
id: String(api.id),
|
||||||
|
type,
|
||||||
|
label: api.title,
|
||||||
|
createdAt: api.created_at ? new Date(api.created_at) : new Date(),
|
||||||
|
updatedAt: api.updated_at ? new Date(api.updated_at) : new Date(),
|
||||||
|
isEncrypted: true,
|
||||||
|
heirEmail: api.heir_email,
|
||||||
|
rawData: api,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Map array of API assets to VaultAsset[].
|
||||||
|
*/
|
||||||
|
export function mapApiAssetsToVaultAssets(apiList: ApiAsset[]): VaultAsset[] {
|
||||||
|
return apiList.map(mapApiAssetToVaultAsset);
|
||||||
|
}
|
||||||
|
|
||||||
107
src/utils/vaultCrypto.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
/**
|
||||||
|
* Vault crypto: PBKDF2 key derivation + AES-256-GCM encrypt/decrypt.
|
||||||
|
* Matches backend SentinelVault semantics (PBKDF2 from mnemonic, AES-GCM).
|
||||||
|
* Uses Web Crypto API (crypto.subtle). Requires secure context / React Native polyfill if needed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SALT = new TextEncoder().encode('Sentinel_Salt_2026');
|
||||||
|
const PBKDF2_ITERATIONS = 100000;
|
||||||
|
const AES_KEY_LEN = 256;
|
||||||
|
const GCM_IV_LEN = 16;
|
||||||
|
const GCM_TAG_LEN = 16;
|
||||||
|
|
||||||
|
function getCrypto(): Crypto {
|
||||||
|
if (typeof crypto !== 'undefined' && crypto.subtle) return crypto;
|
||||||
|
throw new Error('vaultCrypto: crypto.subtle not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a 32-byte AES key from mnemonic phrase (space-separated words).
|
||||||
|
*/
|
||||||
|
export async function deriveKey(mnemonicPhrase: string, salt: Uint8Array = SALT): Promise<ArrayBuffer> {
|
||||||
|
const crypto = getCrypto();
|
||||||
|
const keyMaterial = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
new TextEncoder().encode(mnemonicPhrase),
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveBits']
|
||||||
|
);
|
||||||
|
const saltBuf = salt.buffer.slice(salt.byteOffset, salt.byteOffset + salt.byteLength) as ArrayBuffer;
|
||||||
|
const bits = await crypto.subtle.deriveBits(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt: saltBuf,
|
||||||
|
iterations: PBKDF2_ITERATIONS,
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
keyMaterial,
|
||||||
|
AES_KEY_LEN
|
||||||
|
);
|
||||||
|
return bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt plaintext with AES-256-GCM. Returns nonce(16) + tag(16) + ciphertext (matches Python SentinelVault).
|
||||||
|
*/
|
||||||
|
export async function encryptDataGCM(key: ArrayBuffer, plaintext: string): Promise<Uint8Array> {
|
||||||
|
const crypto = getCrypto();
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(GCM_IV_LEN));
|
||||||
|
const cryptoKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
key,
|
||||||
|
{ name: 'AES-GCM' },
|
||||||
|
false,
|
||||||
|
['encrypt']
|
||||||
|
);
|
||||||
|
const encoded = new TextEncoder().encode(plaintext);
|
||||||
|
const ciphertextWithTag = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv, tagLength: GCM_TAG_LEN * 8 },
|
||||||
|
cryptoKey,
|
||||||
|
encoded
|
||||||
|
);
|
||||||
|
const out = new Uint8Array(iv.length + ciphertextWithTag.byteLength);
|
||||||
|
out.set(iv, 0);
|
||||||
|
out.set(new Uint8Array(ciphertextWithTag), iv.length);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt blob from encryptDataGCM (nonce(16) + ciphertext+tag).
|
||||||
|
*/
|
||||||
|
export async function decryptDataGCM(key: ArrayBuffer, blob: Uint8Array): Promise<string> {
|
||||||
|
const crypto = getCrypto();
|
||||||
|
const iv = blob.subarray(0, GCM_IV_LEN);
|
||||||
|
const ciphertextWithTag = blob.subarray(GCM_IV_LEN);
|
||||||
|
const ivBuf = iv.buffer.slice(iv.byteOffset, iv.byteOffset + iv.byteLength) as ArrayBuffer;
|
||||||
|
const ctBuf = ciphertextWithTag.buffer.slice(
|
||||||
|
ciphertextWithTag.byteOffset,
|
||||||
|
ciphertextWithTag.byteOffset + ciphertextWithTag.byteLength
|
||||||
|
) as ArrayBuffer;
|
||||||
|
const cryptoKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
key,
|
||||||
|
{ name: 'AES-GCM' },
|
||||||
|
false,
|
||||||
|
['decrypt']
|
||||||
|
);
|
||||||
|
const dec = await crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv: ivBuf, tagLength: GCM_TAG_LEN * 8 },
|
||||||
|
cryptoKey,
|
||||||
|
ctBuf
|
||||||
|
);
|
||||||
|
return new TextDecoder().decode(dec);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bytesToHex(bytes: Uint8Array): string {
|
||||||
|
return Array.from(bytes)
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hexToBytes(hex: string): Uint8Array {
|
||||||
|
const len = hex.length / 2;
|
||||||
|
const out = new Uint8Array(len);
|
||||||
|
for (let i = 0; i < len; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||||
|
return out;
|
||||||
|
}
|
||||||