15 Commits

Author SHA1 Message Date
Ada
465881c0e4 Update README.md 2026-02-09 13:06:50 -08:00
Ada
8994a3e045 feat(flow): image attachment and 422 error message display
- Attach image then send with optional text or image-only (default prompt)
- Attached image preview above input with remove
- AI image API error detail no longer shows [object Object]
2026-02-04 17:19:51 -08:00
Ada
e33ea62e35 fix(mobile): polyfill
- polyfill crypto.getRandomValues (uuid/LangChain  not supported))
- polyfill AbortSignal.prototype.throwIfAborted (throwIfAborted is not a function)")
2026-02-04 16:48:00 -08:00
Ada
96d95a50fc fix: resolve ReadableStream in simulator so LangGraph runs on RN
- Add src/polyfills.ts as the first executed module; inject ReadableStream/WritableStream/TransformStream via web-streams-polyfill and ponyfill fallback
- Import polyfills at the top of App.tsx so globals are set before any LangChain/LangGraph code loads
- Add assets/images/icon.png to fix Metro “Asset not found” for icon
2026-02-04 15:24:14 -08:00
lusixing
c1ce804d14 langgraph_used 2026-02-03 21:37:41 -08:00
lusixing
0aab9a838b ai_role_update 2026-02-02 22:20:24 -08:00
lusixing
6822638d47 complete_heir_functions 2026-02-02 19:40:49 -08:00
lusixing
5c1172a912 added_reveal_secret_and_delete_treasure 2026-02-02 17:34:03 -08:00
lusixing
b5373c2d9a update_260201-3 2026-02-01 21:13:15 -08:00
Ada
3ffcc60ee8 feat(vault): vault storage (user-isolated, multi-account) 2026-02-01 15:22:32 -08:00
Ada
50e78c84c9 feat(vault): vault storage (user-isolated, multi-account) 2026-02-01 11:57:16 -08:00
Ada
8e6c621f7b feat(vault): show mnemonic flow will show at first time ; or user reset vault state; 2026-02-01 11:02:14 -08:00
Ada
7b8511f080 fix(bug): Could not find MIME for Buffer <null> 2026-02-01 10:36:21 -08:00
Ada
f6fa19d0b2 feat(vault): add get/create assets API in workflow
TODO: update vault.service.ts. Use MNEMONIC workflow to create real private_key_shard and content_inner_encrypted
2026-02-01 09:19:45 -08:00
Ada
536513ab3f fix(bug): Could not find MIME for Buffer <null> 2026-02-01 09:12:29 -08:00
36 changed files with 4252 additions and 870 deletions

View File

@@ -4,6 +4,7 @@
* 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 { Buffer } from 'buffer';

798
README.md
View File

@@ -1,287 +1,595 @@
# Sentinel App # Sentinel Frontend
[中文版](#中文版) A React Native mobile application for secure digital legacy management, built with Expo and TypeScript. Sentinel enables users to preserve, encrypt, and conditionally transfer their digital assets to designated heirs through a sophisticated cryptographic architecture.
## Digital Legacy Management ## Table of Contents
Sentinel is a mobile application that helps users securely manage their digital legacy. Built with React Native (Expo) and TypeScript. - [Overview](#overview)
- [Business Features](#business-features)
- [Technical Architecture](#technical-architecture)
- [Security & Cryptography](#security--cryptography)
- [Getting Started](#getting-started)
- [Project Structure](#project-structure)
- [Configuration](#configuration)
- [Development](#development)
## Features ## Overview
Sentinel is a comprehensive digital inheritance platform that addresses the critical challenge of preserving and transferring digital assets securely. The application combines advanced cryptographic techniques with an intuitive user interface, ensuring that sensitive information remains protected while enabling controlled access for designated beneficiaries.
### Core Value Proposition
- **Zero-Knowledge Architecture**: The platform cannot access user data without cryptographic keys
- **Conditional Release**: Assets are released only when specific conditions are met (e.g., subscription expiration, inactivity)
- **Multi-Layer Encryption**: Combines symmetric and asymmetric encryption for maximum security
- **Distributed Trust**: Uses secret sharing to prevent single points of failure
## Business Features
### 🗞️ Flow - Captain's Journal ### 🗞️ Flow - Captain's Journal
- Record daily thoughts, emotions, and reflections
An AI-powered journaling interface for daily thoughts, emotions, and reflections.
**Capabilities:**
- Multi-modal entry support: text, voice, and image inputs
- AI-inferred emotional state tracking - AI-inferred emotional state tracking
- Archive entries to the encrypted Vault - Conversational AI with multiple role configurations:
- Support for text, voice, and image entries - Reflective Assistant: Deep introspection and emotional exploration
- Creative Spark: Brainstorming and creative writing
- Action Planner: Goal setting and productivity coaching
- Empathetic Guide: Emotional support and non-judgmental listening
- Archive entries to encrypted Vault for long-term preservation
- Conversation summarization for efficient review
**Use Cases:**
- Daily emotional tracking and mental health monitoring
- Creative writing and idea generation
- Goal planning and accountability
- Therapeutic journaling with AI support
### 📦 Vault - The Deep Vault ### 📦 Vault - The Deep Vault
- End-to-end encrypted asset storage
- Support for game accounts, private keys, documents, photos, wills End-to-end encrypted storage for sensitive digital assets.
- Biometric authentication required for access
- Zero-knowledge architecture **Supported Asset Types:**
- **Game Accounts**: Credentials for gaming platforms (Steam, etc.)
- **Private Keys**: Cryptographic keys and wallet seeds
- **Documents**: Legal documents, contracts, and important files
- **Photos**: Personal memories and sensitive images
- **Wills**: Testamentary documents and final wishes
- **Custom**: User-defined asset categories
**Security Features:**
- Biometric authentication required for access (Face ID / Touch ID / Fingerprint)
- Shamir's Secret Sharing (3-of-2 threshold) for key management
- AES-256-GCM encryption for content protection
- User-isolated storage (multi-account support)
- Zero-knowledge architecture (server cannot decrypt without user shares)
**Workflow:**
1. User creates asset with plaintext content
2. System generates mnemonic phrase and splits into 3 shares
3. Content encrypted with derived AES key
4. One share stored on device, one on server, one for heir
5. Any 2 shares required to recover decryption key
### ⚓ Sentinel - Lighthouse Watch ### ⚓ Sentinel - Lighthouse Watch
- Dead Man's Switch monitoring system
- Heartbeat confirmation mechanism Dead Man's Switch monitoring system for conditional asset release.
- Subscription and activity tracking
- Configurable grace periods **Mechanism:**
- Continuous heartbeat monitoring of user activity
- Subscription status tracking
- Configurable grace periods before triggering release
- Activity logging and audit trail
- Status indicators: Normal, Warning, Releasing
**Trigger Conditions:**
- Subscription expiration without renewal
- Extended inactivity period
- Manual activation by user
- Administrative declaration (for testing/emergencies)
**Release Process:**
1. System detects trigger condition
2. Outer encryption layer removed (RSA gateway unlocked)
3. Heirs notified of available assets
4. Heirs can claim assets with their share + server share
### 🧭 Heritage - Fleet Legacy ### 🧭 Heritage - Fleet Legacy
- Heir management with release levels
- Customizable release order and timing Heir management and asset distribution system.
- Payment strategy configuration
- Legal document-style interface **Features:**
- **Heir Management**: Add, edit, and remove designated beneficiaries
- **Release Levels**: Configure priority tiers (1-3) for asset access
- **Release Order**: Define sequence for multi-heir scenarios
- **Payment Strategies**:
- Prepaid: Heir pays upfront for access
- Pay on Access: Payment required when claiming assets
- **Legal Document Interface**: Formal, testamentary-style presentation
- **Assignment Workflow**: Assign specific assets to specific heirs
**Heir Status:**
- **Invited**: Heir has been designated but not yet confirmed
- **Confirmed**: Heir has accepted invitation and verified identity
### ⛵ Me - Captain's Quarters ### ⛵ Me - Captain's Quarters
- Subscription and protocol status
- Sentinel configuration
- Security center
- Data export and backup
- Social responsibility program
## Tech Stack User account management and system configuration.
- **Framework**: React Native (Expo SDK 52) **Sections:**
- **Language**: TypeScript - **Subscription Status**: Current tier, expiration date, features enabled
- **Navigation**: React Navigation (Bottom Tabs) - **Protocol Information**: Version tracking and update status
- **Icons**: @expo/vector-icons (Feather, Ionicons, FontAwesome5) - **Sentinel Configuration**: Heartbeat intervals, grace periods, monitoring settings
- **Styling**: Custom nautical theme with gradients - **Security Center**:
- **State Management**: React Context (AuthContext) - Biometric settings
- **Storage**: AsyncStorage for auth persistence - Vault state management
- Key recovery options
- **Data Export**: Backup encrypted vault data
- **Social Responsibility**: Program information and participation
## Configuration ## Technical Architecture
The application uses a centralized configuration file located at `src/config/index.ts`. ### Technology Stack
### Key Configuration Options | Category | Technology | Purpose |
|----------|-----------|---------|
| **Framework** | React Native (Expo SDK 52) | Cross-platform mobile development |
| **Language** | TypeScript 5.3+ | Type-safe development |
| **Navigation** | React Navigation 6 | Bottom tabs + stack navigation |
| **State Management** | React Context API | Authentication and app state |
| **Storage** | AsyncStorage | Local persistence (auth tokens, vault state) |
| **Icons** | @expo/vector-icons | Feather, Ionicons, FontAwesome5, Material |
| **Styling** | StyleSheet + LinearGradient | Custom nautical-themed UI |
| **Crypto** | Web Crypto API + Polyfills | Cryptographic operations |
| **AI Integration** | LangChain + LangGraph | AI conversation management |
| **Build System** | Metro Bundler | JavaScript bundling |
| Option | Description | Default | ### Architecture Patterns
|--------|-------------|---------|
| `NO_BACKEND_MODE` | Use mock data instead of real backend | `false` |
| `DEBUG_MODE` | Enable API debug logging | `true` |
| `API_BASE_URL` | Backend API server URL | `http://localhost:8000` |
| `API_TIMEOUT` | Request timeout (ms) | `30000` |
### API Endpoints #### Service Layer Architecture
All backend API routes are defined in `API_ENDPOINTS`:
- **AUTH**: `/login`, `/register`
- **ASSETS**: `/assets/get`, `/assets/create`, `/assets/claim`, `/assets/assign`
- **AI**: `/ai/proxy`
- **ADMIN**: `/admin/declare-guale`
### Environment Setup
For development, you may need to modify `API_BASE_URL` in the config file to match your backend server address.
## Project Structure
```
src/
├── components/
│ └── common/
│ ├── BiometricModal.tsx
│ ├── Icons.tsx
│ └── VaultDoorAnimation.tsx
├── config/
│ └── index.ts # Centralized configuration
├── context/
│ └── AuthContext.tsx # Authentication state management
├── navigation/
│ ├── AuthNavigator.tsx # Login/Register navigation
│ └── TabNavigator.tsx # Main app navigation
├── screens/
│ ├── FlowScreen.tsx # AI chat interface
│ ├── VaultScreen.tsx
│ ├── SentinelScreen.tsx
│ ├── HeritageScreen.tsx
│ ├── MeScreen.tsx
│ ├── LoginScreen.tsx
│ └── RegisterScreen.tsx
├── services/
│ ├── index.ts # Service exports
│ ├── ai.service.ts # AI API integration
│ ├── auth.service.ts # Authentication API
│ ├── assets.service.ts # Asset management API
│ └── admin.service.ts # Admin operations API
├── theme/
│ └── colors.ts
└── types/
└── index.ts
assets/
├── icon.png # App icon (1024x1024)
├── adaptive-icon.png # Android adaptive icon
├── splash.png # Splash screen
├── favicon.png # Web favicon (32x32)
├── favicon.svg # SVG favicon for web
├── logo.svg # Vector logo (512x512)
└── images/
└── captain-avatar.svg # Avatar placeholder
```
## Services
The application uses a modular service architecture for API communication: The application uses a modular service architecture for API communication:
- **AuthService**: User authentication (login, register) ```
- **AIService**: AI conversation proxy with support for text and image input src/services/
- **AssetsService**: Digital asset management ├── auth.service.ts # Authentication (login, register)
- **AdminService**: Administrative operations ├── assets.service.ts # Asset CRUD and inheritance
├── vault.service.ts # Vault encryption/decryption
## Icons & Branding ├── ai.service.ts # AI conversation proxy
├── admin.service.ts # Administrative operations
The Sentinel brand uses a nautical anchor-and-star logo on a teal (#459E9E) background. ├── langgraph.service.ts # LangGraph workflow integration
└── storage.service.ts # AsyncStorage abstraction
### Logo Elements
- **Anchor**: Symbolizes stability and anchoring your digital legacy
- **Star/Compass**: Represents guidance and direction for heirs
- **Teal Color**: Evokes ocean depth and calm security
### Generating Icons
```bash
# View icon specifications
node scripts/generate-icons.js
``` ```
Use the `assets/logo.svg` as the source and export to required sizes. **Service Pattern:**
- Centralized API configuration
- Mock mode support for development
- Consistent error handling
- Debug logging integration
- Type-safe request/response interfaces
#### Context-Based State Management
**AuthContext** (`src/context/AuthContext.tsx`):
- Manages authentication state
- Handles token persistence
- Provides user information throughout app
- Handles initialization and loading states
**Usage Pattern:**
```typescript
const { user, token, login, logout, isInitializing } = useAuth();
```
#### Navigation Structure
```
App (Root)
├── AuthNavigator (if not authenticated)
│ ├── LoginScreen
│ └── RegisterScreen
└── TabNavigator (if authenticated)
├── FlowScreen (🗞️)
├── VaultScreen (📦)
├── SentinelScreen (⚓)
├── HeritageScreen (🧭)
└── MeScreen (⛵)
```
### Key Dependencies
**Core:**
- `react`: 18.3.1
- `react-native`: 0.76.9
- `expo`: ~52.0.0
**Navigation:**
- `@react-navigation/native`: ^6.1.18
- `@react-navigation/bottom-tabs`: ^6.6.1
- `@react-navigation/native-stack`: ^6.11.0
**Cryptography:**
- `@noble/ciphers`: ^1.3.0 (AES encryption)
- `@noble/hashes`: ^1.8.0 (Hash functions)
- `bip39`: ^3.1.0 (Mnemonic generation)
- `expo-crypto`: ~14.0.2 (Crypto polyfills)
**AI & Language Models:**
- `@langchain/core`: ^1.1.18
- `@langchain/langgraph`: ^1.1.3
**Storage & Utilities:**
- `@react-native-async-storage/async-storage`: ^2.2.0
- `buffer`: ^6.0.3 (Node.js Buffer polyfill)
- `readable-stream`: ^4.7.0
## Security & Cryptography
### Encryption Architecture
Sentinel implements a multi-layer encryption system:
#### Layer 1: Vault Encryption (User-Controlled)
**Process:**
1. Generate 12-word BIP-39 mnemonic phrase
2. Derive AES-256 key using PBKDF2:
- Input: Mnemonic phrase
- Salt: `Sentinel_Salt_2026`
- Iterations: 100,000
- Hash: SHA-256
- Output: 32-byte AES key
3. Encrypt plaintext with AES-256-GCM:
- Mode: Galois/Counter Mode (authenticated encryption)
- IV: 16-byte random nonce
- Tag: 16-byte authentication tag
- Output: `IV + Ciphertext + Tag` (hex encoded)
**Implementation:** `src/utils/vaultCrypto.ts`
#### Layer 2: Secret Sharing (Distributed Trust)
**Shamir's Secret Sharing (3-of-2 Threshold):**
1. Convert mnemonic to entropy (BigInt representation)
2. Split entropy into 3 shares using linear polynomial:
```
f(x) = secret + a*x (mod P)
```
Where:
- `secret`: Mnemonic entropy
- `a`: Random coefficient
- `P`: Prime modulus (2^127 - 1)
- Shares at x = 1, 2, 3
3. Share Distribution:
- **Device Share (S0)**: Stored locally on user's device
- **Cloud Share (S1)**: Stored on Sentinel server
- **Heir Share (S2)**: Provided to designated heir
4. Recovery: Any 2 shares can recover original entropy via Lagrange interpolation
**Implementation:** `src/utils/sss.ts`
#### Layer 3: Gateway Encryption (Server-Controlled)
**RSA Outer Encryption:**
- Server generates RSA-4096 key pair per asset
- Inner encrypted content encrypted again with RSA public key
- Private key held by server, released only on trigger conditions
- Prevents unauthorized access even if inner encryption is compromised
**Note:** Gateway encryption is handled by backend; frontend sends `content_inner_encrypted` which backend wraps with RSA.
### Key Management
**Storage Strategy:**
- User-isolated keys: `getVaultStorageKeys(userId)` generates per-user storage keys
- Device share (S0): Stored in AsyncStorage with user-scoped key
- Mnemonic backup: Optional local backup of mnemonic portion (encrypted)
- Multi-account support: Each user has independent vault state
**Storage Keys:**
```typescript
{
INITIALIZED: `sentinel_vault_initialized_u{userId}`,
SHARE_DEVICE: `sentinel_vault_s0_u{userId}`,
MNEMONIC_PART_LOCAL: `sentinel_mnemonic_part_local_u{userId}`,
AES_KEY: `sentinel_aes_key_u{userId}`,
SHARE_SERVER: `sentinel_share_server_u{userId}`,
SHARE_HEIR: `sentinel_share_heir_u{userId}`
}
```
### Security Properties
1. **Zero-Knowledge**: Server cannot decrypt user data without user's share
2. **Forward Secrecy**: Compromising one share reveals nothing about the secret
3. **Authenticated Encryption**: GCM mode ensures data integrity
4. **Key Derivation**: PBKDF2 with high iteration count prevents brute force
5. **Distributed Trust**: No single point of failure for key recovery
## Getting Started ## Getting Started
### Prerequisites
- Node.js 18+ and npm
- Expo CLI (installed globally or via npx)
- iOS Simulator (macOS) or Android Emulator / physical device
- Backend API server running (or use `NO_BACKEND_MODE`)
### Installation
```bash ```bash
# Install dependencies # Install dependencies
npm install npm install
# Start the development server # Start Expo development server
npx expo start npx expo start
``` ```
### Platform-Specific Commands
```bash
# iOS Simulator (macOS only)
npm run ios
# Android Emulator / Device
npm run android
# Web Browser
npm run web
```
### Development Modes
**With Backend:**
1. Ensure backend server is running (default: `http://localhost:8000`)
2. Update `API_BASE_URL` in `src/config/index.ts` if needed
3. Start Expo: `npx expo start`
**Without Backend (Mock Mode):**
1. Set `NO_BACKEND_MODE = true` in `src/config/index.ts`
2. All API calls return mock data
3. Useful for UI development and testing
## Project Structure
```
frontend/
├── App.tsx # Root component with auth routing
├── app.json # Expo configuration
├── package.json # Dependencies and scripts
├── tsconfig.json # TypeScript configuration
├── assets/ # Static assets
│ ├── icon.png # App icon (1024x1024)
│ ├── adaptive-icon.png # Android adaptive icon
│ ├── splash.png # Splash screen
│ ├── favicon.png # Web favicon
│ ├── logo.svg # Vector logo
│ └── images/ # Additional images
├── scripts/ # Build scripts
│ └── generate-icons.js # Icon generation utility
└── src/
├── components/ # Reusable UI components
│ └── common/
│ ├── BiometricModal.tsx # Biometric auth modal
│ ├── Icons.tsx # Icon helper component
│ └── VaultDoorAnimation.tsx # Vault unlock animation
├── config/ # Configuration
│ └── index.ts # Centralized config (API, endpoints, etc.)
├── context/ # React Context providers
│ └── AuthContext.tsx # Authentication state management
├── hooks/ # Custom React hooks
│ ├── index.ts
│ └── useVaultAssets.ts # Vault asset management hook
├── navigation/ # Navigation configuration
│ ├── AuthNavigator.tsx # Login/Register navigation
│ └── TabNavigator.tsx # Main app tab navigation
├── screens/ # Screen components
│ ├── FlowScreen.tsx # AI journaling interface
│ ├── VaultScreen.tsx # Encrypted asset management
│ ├── SentinelScreen.tsx # Dead Man's Switch monitoring
│ ├── HeritageScreen.tsx # Heir management
│ ├── MeScreen.tsx # User settings and account
│ ├── LoginScreen.tsx # Authentication
│ └── RegisterScreen.tsx
├── services/ # API service layer
│ ├── index.ts # Service exports
│ ├── auth.service.ts # Authentication API
│ ├── assets.service.ts # Asset CRUD API
│ ├── vault.service.ts # Vault encryption utilities
│ ├── ai.service.ts # AI conversation API
│ ├── admin.service.ts # Admin operations
│ ├── langgraph.service.ts # LangGraph integration
│ └── storage.service.ts # AsyncStorage abstraction
├── theme/ # Design system
│ └── colors.ts # Color palette and typography
├── types/ # TypeScript type definitions
│ └── index.ts # Shared types and interfaces
├── utils/ # Utility functions
│ ├── index.ts
│ ├── crypto_core.ts # Crypto utilities (if needed)
│ ├── crypto_polyfill.ts # Crypto API polyfills
│ ├── sss.ts # Shamir's Secret Sharing
│ ├── vaultCrypto.ts # AES encryption/decryption
│ ├── vaultAssets.ts # Asset management utilities
│ ├── token_utils.ts # Token management
│ └── async_hooks_mock.ts # Async hooks polyfill
└── polyfills.ts # Global polyfills (Buffer, etc.)
```
## Configuration
### Environment Configuration
All configuration is centralized in `src/config/index.ts`:
```typescript
// Development mode
export const NO_BACKEND_MODE = false; // Use mock data
export const DEBUG_MODE = true; // API debug logging
// API Configuration
export const API_BASE_URL = 'http://localhost:8000';
export const API_TIMEOUT = 30000; // 30 seconds
```
### API Endpoints
Defined in `API_ENDPOINTS` constant:
| Category | Endpoint | Method | Purpose |
|----------|----------|--------|---------|
| **Auth** | `/login` | POST | User authentication |
| | `/register` | POST | User registration |
| **Assets** | `/assets/get` | GET | Fetch user's assets |
| | `/assets/create` | POST | Create new asset |
| | `/assets/claim` | POST | Claim inherited asset |
| | `/assets/assign` | POST | Assign asset to heir |
| | `/assets/delete` | POST | Delete asset |
| **AI** | `/ai/proxy` | POST | AI conversation proxy |
| | `/get_ai_roles` | GET | Fetch available AI roles |
| **Admin** | `/admin/declare-guale` | POST | Admin: Declare user deceased |
### Vault Storage Configuration
Vault storage uses user-scoped keys for multi-account support:
```typescript
// Get storage keys for a user
const keys = getVaultStorageKeys(userId);
// Keys are prefixed with user ID to prevent cross-user access
// Format: sentinel_vault_{key}_{suffix}
// Suffix: _u{userId} for authenticated users, _guest for guests
```
### AI Configuration
AI roles and system prompts are configurable:
```typescript
export const AI_CONFIG = {
DEFAULT_SYSTEM_PROMPT: '...',
MOCK_RESPONSE_DELAY: 500,
ROLES: [
{ id: 'reflective', name: 'Reflective Assistant', ... },
{ id: 'creative', name: 'Creative Spark', ... },
{ id: 'planner', name: 'Action Planner', ... },
{ id: 'empathetic', name: 'Empathetic Guide', ... },
]
};
```
## Development
### Code Style
- **TypeScript**: Strict mode enabled, all files typed
- **Components**: Functional components with hooks
- **Naming**: PascalCase for components, camelCase for functions/variables
- **Imports**: Absolute imports preferred (configured in tsconfig.json)
### Testing
**Manual Testing:**
- Use Expo Go app on physical device for real-world testing
- Test biometric authentication on actual devices
- Verify encryption/decryption flows with real backend
**Mock Mode Testing:**
- Enable `NO_BACKEND_MODE` for UI/UX testing
- Mock responses simulate real API behavior
- Useful for rapid iteration without backend dependency
### Debugging
**API Debug Logging:**
- Enabled when `DEBUG_MODE = true`
- Logs all API requests/responses to console
- Includes request URLs, headers, and response data
**React Native Debugger:**
- Use React Native Debugger or Chrome DevTools
- Inspect component state and props
- Monitor network requests
### Building for Production
```bash
# Build for iOS
eas build --platform ios
# Build for Android
eas build --platform android
# Build for Web
npx expo export:web
```
**Note:** Requires Expo Application Services (EAS) account for native builds.
### Common Issues
**Crypto API Not Available:**
- Ensure polyfills are loaded (`src/polyfills.ts`)
- Check that `crypto.subtle` is available in environment
- React Native requires polyfills for Web Crypto API
**AsyncStorage Errors:**
- Ensure `@react-native-async-storage/async-storage` is properly linked
- Check storage permissions on device
- Clear storage if corrupted: `AsyncStorage.clear()`
**Navigation Issues:**
- Ensure `NavigationContainer` wraps navigators
- Check that screens are properly registered
- Verify tab bar configuration matches screen names
## Design Philosophy ## Design Philosophy
- **Nautical Theme**: Captain's sanctum aesthetic with anchors, ship wheels, and ocean colors ### Nautical Theme
- **Emotional Balance**: Warm and secure feeling across different tabs
- **Privacy First**: Zero-knowledge architecture, local encryption The application uses a consistent nautical/maritime aesthetic:
- **Elegant UI**: Mint gradients, serif typography, subtle shadows
- **Color Palette**: Teal (#459E9E) primary, ocean blues, mint gradients
- **Iconography**: Anchors, ship wheels, compasses, lighthouses
- **Terminology**: Captain, Fleet, Vault, Sentinel, Heritage
- **Typography**: Serif fonts for formal sections, sans-serif for UI
### User Experience Principles
1. **Privacy First**: Encryption happens locally, user controls keys
2. **Transparency**: Clear explanation of security mechanisms
3. **Accessibility**: Biometric auth for convenience, fallback options available
4. **Elegance**: Clean, modern UI with subtle animations
5. **Trust**: Visual indicators for security status and system health
## License
Private - All rights reserved.
## Support
For issues, questions, or contributions, please contact the development team.
--- ---
# 中文版 **Version**: 2.0.0
**Last Updated**: February 2026
[English Version](#sentinel-app)
## 数字遗产管理
Sentinel 是一款帮助用户安全管理数字遗产的移动应用程序。使用 React Native (Expo) 和 TypeScript 构建。
## 功能特性
### 🗞️ Flow - 船长日志
- 记录日常想法、情感和反思
- AI 推断情感状态追踪
- 将条目归档到加密保险库
- 支持文本、语音和图像条目
### 📦 Vault - 深海宝库
- 端到端加密资产存储
- 支持游戏账号、私钥、文档、照片、遗嘱
- 需要生物识别认证才能访问
- 零知识架构
### ⚓ Sentinel - 灯塔守望
- 死人开关监控系统
- 心跳确认机制
- 订阅和活动追踪
- 可配置的冷静期
### 🧭 Heritage - 舰队遗产
- 继承人管理与释放等级
- 可自定义释放顺序和时间
- 付款策略配置
- 法律文书风格界面
### ⛵ Me - 船长室
- 订阅和协议状态
- 哨兵配置
- 安全中心
- 数据导出和备份
- 社会责任计划
## 技术栈
- **框架**: React Native (Expo SDK 52)
- **语言**: TypeScript
- **导航**: React Navigation (底部标签)
- **图标**: @expo/vector-icons (Feather, Ionicons, FontAwesome5)
- **样式**: 自定义航海主题配渐变
- **状态管理**: React Context (AuthContext)
- **存储**: AsyncStorage 用于认证持久化
## 配置说明
应用使用位于 `src/config/index.ts` 的集中配置文件。
### 主要配置项
| 选项 | 说明 | 默认值 |
|------|------|--------|
| `NO_BACKEND_MODE` | 使用模拟数据而非真实后端 | `false` |
| `DEBUG_MODE` | 启用 API 调试日志 | `true` |
| `API_BASE_URL` | 后端 API 服务器地址 | `http://localhost:8000` |
| `API_TIMEOUT` | 请求超时时间(毫秒) | `30000` |
### API 端点
所有后端 API 路由定义在 `API_ENDPOINTS` 中:
- **AUTH**: `/login`, `/register`
- **ASSETS**: `/assets/get`, `/assets/create`, `/assets/claim`, `/assets/assign`
- **AI**: `/ai/proxy`
- **ADMIN**: `/admin/declare-guale`
### 环境配置
开发时,您可能需要修改配置文件中的 `API_BASE_URL` 以匹配您的后端服务器地址。
## 服务层
应用使用模块化的服务架构进行 API 通信:
- **AuthService**: 用户认证(登录、注册)
- **AIService**: AI 对话代理,支持文本和图片输入
- **AssetsService**: 数字资产管理
- **AdminService**: 管理员操作
## 运行项目
```bash
# 安装依赖
npm install
# 启动开发服务器
npx expo start
```
## 图标与品牌
Sentinel 品牌使用青色(#459E9E)背景上的航海锚与星星标志。
### 标志元素
- **锚**: 象征稳定性和锚定你的数字遗产
- **星星/指南针**: 代表对继承人的指引和方向
- **青色**: 唤起海洋深度和平静的安全感
### 生成图标
```bash
# 查看图标规格
node scripts/generate-icons.js
```
使用 `assets/logo.svg` 作为源文件并导出所需尺寸。
## 设计理念
- **航海主题**: 船长圣殿美学,配以锚、船舵和海洋色彩
- **情感平衡**: 不同标签页带来温暖而安全的感觉
- **隐私优先**: 零知识架构,本地加密
- **优雅界面**: 薄荷渐变、衬线字体、柔和阴影

View File

@@ -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"
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 B

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 B

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 B

After

Width:  |  Height:  |  Size: 70 B

BIN
assets/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 B

After

Width:  |  Height:  |  Size: 70 B

15
metro.config.js Normal file
View 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;

471
package-lock.json generated
View File

@@ -10,6 +10,10 @@
"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",
@@ -19,6 +23,7 @@
"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",
@@ -32,7 +37,9 @@
"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-view-shot": "^3.8.0", "react-native-view-shot": "^3.8.0",
"react-native-web": "~0.19.13" "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",
@@ -2198,6 +2205,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@cfworker/json-schema": {
"version": "4.1.1",
"resolved": "https://registry.npmmirror.com/@cfworker/json-schema/-/json-schema-4.1.1.tgz",
"integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==",
"license": "MIT"
},
"node_modules/@egjs/hammerjs": { "node_modules/@egjs/hammerjs": {
"version": "2.0.17", "version": "2.0.17",
"resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz", "resolved": "https://registry.npmjs.org/@egjs/hammerjs/-/hammerjs-2.0.17.tgz",
@@ -3211,9 +3224,219 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@langchain/core": {
"version": "1.1.18",
"resolved": "https://registry.npmmirror.com/@langchain/core/-/core-1.1.18.tgz",
"integrity": "sha512-vwzbtHUSZaJONBA1n9uQedZPfyFFZ6XzTggTpR28n8tiIg7e1NC/5dvGW/lGtR1Du1VwV9DvDHA5/bOrLe6cVg==",
"license": "MIT",
"dependencies": {
"@cfworker/json-schema": "^4.0.2",
"ansi-styles": "^5.0.0",
"camelcase": "6",
"decamelize": "1.2.0",
"js-tiktoken": "^1.0.12",
"langsmith": ">=0.4.0 <1.0.0",
"mustache": "^4.2.0",
"p-queue": "^6.6.2",
"uuid": "^10.0.0",
"zod": "^3.25.76 || ^4"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@langchain/core/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@langchain/core/node_modules/camelcase": {
"version": "6.3.0",
"resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-6.3.0.tgz",
"integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@langchain/core/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@langchain/langgraph": {
"version": "1.1.3",
"resolved": "https://registry.npmmirror.com/@langchain/langgraph/-/langgraph-1.1.3.tgz",
"integrity": "sha512-o/cEWeocDDSpyBI2MfX07LkNG4LzdRKxwcgUcbR4PyRzhxxCkeIZRCCYkXVQoDbdKqAczJa0D7+yjU9rmA5iHQ==",
"license": "MIT",
"dependencies": {
"@langchain/langgraph-checkpoint": "^1.0.0",
"@langchain/langgraph-sdk": "~1.5.5",
"@standard-schema/spec": "1.1.0",
"uuid": "^10.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@langchain/core": "^1.0.1",
"zod": "^3.25.32 || ^4.2.0",
"zod-to-json-schema": "^3.x"
},
"peerDependenciesMeta": {
"zod-to-json-schema": {
"optional": true
}
}
},
"node_modules/@langchain/langgraph-checkpoint": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.0.tgz",
"integrity": "sha512-xrclBGvNCXDmi0Nz28t3vjpxSH6UYx6w5XAXSiiB1WEdc2xD2iY/a913I3x3a31XpInUW/GGfXXfePfaghV54A==",
"license": "MIT",
"dependencies": {
"uuid": "^10.0.0"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@langchain/core": "^1.0.1"
}
},
"node_modules/@langchain/langgraph-checkpoint/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@langchain/langgraph-sdk": {
"version": "1.5.5",
"resolved": "https://registry.npmmirror.com/@langchain/langgraph-sdk/-/langgraph-sdk-1.5.5.tgz",
"integrity": "sha512-SyiAs6TVXPWlt/8cI9pj/43nbIvclY3ytKqUFbL5MplCUnItetEyqvH87EncxyVF5D7iJKRZRfSVYBMmOZbjbQ==",
"license": "MIT",
"dependencies": {
"p-queue": "^9.0.1",
"p-retry": "^7.1.1",
"uuid": "^13.0.0"
},
"peerDependencies": {
"@langchain/core": "^1.1.15",
"react": "^18 || ^19",
"react-dom": "^18 || ^19"
},
"peerDependenciesMeta": {
"@langchain/core": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/@langchain/langgraph-sdk/node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/@langchain/langgraph-sdk/node_modules/p-queue": {
"version": "9.1.0",
"resolved": "https://registry.npmmirror.com/p-queue/-/p-queue-9.1.0.tgz",
"integrity": "sha512-O/ZPaXuQV29uSLbxWBGGZO1mCQXV2BLIwUr59JUU9SoH76mnYvtms7aafH/isNSNGwuEfP6W/4xD0/TJXxrizw==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^5.0.1",
"p-timeout": "^7.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@langchain/langgraph-sdk/node_modules/p-timeout": {
"version": "7.0.1",
"resolved": "https://registry.npmmirror.com/p-timeout/-/p-timeout-7.0.1.tgz",
"integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@langchain/langgraph-sdk/node_modules/uuid": {
"version": "13.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-13.0.0.tgz",
"integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist-node/bin/uuid"
}
},
"node_modules/@langchain/langgraph/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/@noble/ciphers": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/@noble/ciphers/-/ciphers-1.3.0.tgz",
"integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": { "node_modules/@noble/hashes": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", "resolved": "https://registry.npmmirror.com/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3801,6 +4024,12 @@
"@sinonjs/commons": "^3.0.0" "@sinonjs/commons": "^3.0.0"
} }
}, },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmmirror.com/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3923,6 +4152,12 @@
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"license": "MIT"
},
"node_modules/@types/yargs": { "node_modules/@types/yargs": {
"version": "17.0.35", "version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -5082,6 +5317,15 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/console-table-printer": {
"version": "2.15.0",
"resolved": "https://registry.npmmirror.com/console-table-printer/-/console-table-printer-2.15.0.tgz",
"integrity": "sha512-SrhBq4hYVjLCkBVOWaTzceJalvn5K1Zq5aQA6wXC/cYjI3frKWNPEMK3sZsJfNNQApvCQmgBcc13ZKmFj8qExw==",
"license": "MIT",
"dependencies": {
"simple-wcswidth": "^1.1.2"
}
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -5221,6 +5465,15 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decode-uri-component": { "node_modules/decode-uri-component": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz", "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
@@ -5579,6 +5832,21 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmmirror.com/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/exec-async": { "node_modules/exec-async": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz", "resolved": "https://registry.npmjs.org/exec-async/-/exec-async-2.2.0.tgz",
@@ -5756,6 +6024,18 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-crypto": {
"version": "14.0.2",
"resolved": "https://registry.npmmirror.com/expo-crypto/-/expo-crypto-14.0.2.tgz",
"integrity": "sha512-WRc9PBpJraJN29VD5Ef7nCecxJmZNyRKcGkNiDQC1nhY5agppzwhqh7zEzNFarE/GqDgSiaDHS8yd5EgFhP9AQ==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-font": { "node_modules/expo-font": {
"version": "13.0.4", "version": "13.0.4",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-13.0.4.tgz", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-13.0.4.tgz",
@@ -6775,6 +7055,18 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-network-error": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/is-network-error/-/is-network-error-1.3.0.tgz",
"integrity": "sha512-6oIwpsgRfnDiyEDLMay/GqCl3HoAtH5+RUKW29gYkL0QA+ipzpDLA16yQs7/RHCSu+BwgbJaOUqa4A99qNVQVw==",
"license": "MIT",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-number": { "node_modules/is-number": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@@ -7093,6 +7385,15 @@
"integrity": "sha512-bF7vcQxbODoGK1imE2P9GS9aw4zD0Sd+Hni68IMZLj7zRnquH7dXUmMw9hDI5S/Jzt7q+IyTXN0rSg2GI0IKhQ==", "integrity": "sha512-bF7vcQxbODoGK1imE2P9GS9aw4zD0Sd+Hni68IMZLj7zRnquH7dXUmMw9hDI5S/Jzt7q+IyTXN0rSg2GI0IKhQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-tiktoken": {
"version": "1.0.21",
"resolved": "https://registry.npmmirror.com/js-tiktoken/-/js-tiktoken-1.0.21.tgz",
"integrity": "sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.5.1"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -7213,6 +7514,65 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/langsmith": {
"version": "0.4.12",
"resolved": "https://registry.npmmirror.com/langsmith/-/langsmith-0.4.12.tgz",
"integrity": "sha512-YWt0jcGvKqjUgIvd78rd4QcdMss0lUkeUaqp0UpVRq7H2yNDx8H5jOUO/laWUmaPtWGgcip0qturykXe1g9Gqw==",
"license": "MIT",
"dependencies": {
"@types/uuid": "^10.0.0",
"chalk": "^4.1.2",
"console-table-printer": "^2.12.1",
"p-queue": "^6.6.2",
"semver": "^7.6.3",
"uuid": "^10.0.0"
},
"peerDependencies": {
"@opentelemetry/api": "*",
"@opentelemetry/exporter-trace-otlp-proto": "*",
"@opentelemetry/sdk-trace-base": "*",
"openai": "*"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@opentelemetry/exporter-trace-otlp-proto": {
"optional": true
},
"@opentelemetry/sdk-trace-base": {
"optional": true
},
"openai": {
"optional": true
}
}
},
"node_modules/langsmith/node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/langsmith/node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/leven": { "node_modules/leven": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -8299,6 +8659,15 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/mustache": {
"version": "4.2.0",
"resolved": "https://registry.npmmirror.com/mustache/-/mustache-4.2.0.tgz",
"integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==",
"license": "MIT",
"bin": {
"mustache": "bin/mustache"
}
},
"node_modules/mz": { "node_modules/mz": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -8713,6 +9082,49 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-queue": {
"version": "6.6.2",
"resolved": "https://registry.npmmirror.com/p-queue/-/p-queue-6.6.2.tgz",
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.4",
"p-timeout": "^3.2.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-retry": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/p-retry/-/p-retry-7.1.1.tgz",
"integrity": "sha512-J5ApzjyRkkf601HpEeykoiCvzHQjWxPAHhyjFcEUP2SWq0+35NKh8TLhpLw+Dkq5TZBFvUM6UigdE9hIVYTl5w==",
"license": "MIT",
"dependencies": {
"is-network-error": "^1.1.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"license": "MIT",
"dependencies": {
"p-finally": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": { "node_modules/p-try": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@@ -9063,6 +9475,15 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0" "node": "^14.17.0 || ^16.13.0 || >=18.0.0"
} }
}, },
"node_modules/process": {
"version": "0.11.10",
"resolved": "https://registry.npmmirror.com/process/-/process-0.11.10.tgz",
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
"license": "MIT",
"engines": {
"node": ">= 0.6.0"
}
},
"node_modules/progress": { "node_modules/progress": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -9534,6 +9955,22 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/readable-stream": {
"version": "4.7.0",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-4.7.0.tgz",
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
"license": "MIT",
"dependencies": {
"abort-controller": "^3.0.0",
"buffer": "^6.0.3",
"events": "^3.3.0",
"process": "^0.11.10",
"string_decoder": "^1.3.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
}
},
"node_modules/readline": { "node_modules/readline": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/readline/-/readline-1.3.0.tgz", "resolved": "https://registry.npmjs.org/readline/-/readline-1.3.0.tgz",
@@ -10080,6 +10517,12 @@
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==", "integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/simple-wcswidth": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz",
"integrity": "sha512-j7piyCjAeTDSjzTSQ7DokZtMNwNlEAyxqSZeCS+CXH7fJ4jx3FuJ/mTW3mE+6JLs4VJBbcll0Kjn+KXI5t21Iw==",
"license": "MIT"
},
"node_modules/sisteransi": { "node_modules/sisteransi": {
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",
@@ -10234,6 +10677,15 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "5.1.2", "version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
@@ -11011,6 +11463,12 @@
"integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==", "integrity": "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/vm-browserify": {
"version": "1.1.2",
"resolved": "https://registry.npmmirror.com/vm-browserify/-/vm-browserify-1.1.2.tgz",
"integrity": "sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==",
"license": "MIT"
},
"node_modules/walker": { "node_modules/walker": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz",
@@ -11415,6 +11873,15 @@
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
} }
} }
} }

View File

@@ -11,6 +11,10 @@
"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",
@@ -20,6 +24,7 @@
"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",
@@ -29,11 +34,13 @@
"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-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",

View File

@@ -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);
}); });
}; };

View File

@@ -27,7 +27,7 @@ export const DEBUG_MODE = true;
/** /**
* Base URL for the backend API server * Base URL for the backend API server
*/ */
export const API_BASE_URL = 'http://192.168.56.103:8000'; export const API_BASE_URL = 'http://localhost:8000';
/** /**
* API request timeout in milliseconds * API request timeout in milliseconds
@@ -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
// ============================================================================= // =============================================================================

View File

@@ -7,8 +7,9 @@
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'; import { storageService } from '../services/storage.service';
// ============================================================================= // =============================================================================
@@ -18,11 +19,13 @@ import { storageService } from '../services/storage.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
@@ -44,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);
@@ -66,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);
@@ -74,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
*/ */
@@ -114,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 {
@@ -143,7 +174,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const signOut = () => { const signOut = () => {
setUser(null); setUser(null);
setToken(null); setToken(null);
setAIRoles([]);
clearAuth(); clearAuth();
//storageService.clearAllData(); //storageService.clearAllData();
}; };
@@ -152,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
View 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
View 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
View 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;
}
};
}

View File

@@ -28,11 +28,18 @@ 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 } from '../config'; import { AI_CONFIG, getVaultStorageKeys } from '../config';
import { storageService } from '../services/storage.service'; 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';
// ============================================================================= // =============================================================================
// Type Definitions // Type Definitions
@@ -59,7 +66,7 @@ interface ChatSession {
// ============================================================================= // =============================================================================
export default function FlowScreen() { export default function FlowScreen() {
const { token, user, 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 +74,11 @@ export default function FlowScreen() {
const [newContent, setNewContent] = useState(''); const [newContent, setNewContent] = useState('');
const [isSending, setIsSending] = useState(false); const [isSending, setIsSending] = useState(false);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [selectedImage, setSelectedImage] = useState<string | null>(null); /** Attached image for next send (uri + base64); user can add optional text then send together */
const [attachedImage, setAttachedImage] = useState<{ uri: string; base64: string } | null>(null);
// AI Role state // AI Role state - start with null to detect first load
const [selectedRole, setSelectedRole] = useState(AI_CONFIG.ROLES[0]); const [selectedRole, setSelectedRole] = useState<AIRole | null>(aiRoles[0] || null);
const [showRoleModal, setShowRoleModal] = useState(false); const [showRoleModal, setShowRoleModal] = useState(false);
const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null); const [expandedRoleId, setExpandedRoleId] = useState<string | null>(null);
@@ -78,6 +86,18 @@ export default function FlowScreen() {
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);
const [chatHistory, setChatHistory] = useState<ChatSession[]>([ const [chatHistory, setChatHistory] = useState<ChatSession[]>([
// Sample history data // Sample history data
{ {
@@ -143,9 +163,9 @@ export default function FlowScreen() {
// Load messages whenever role changes // Load messages whenever role changes
useEffect(() => { useEffect(() => {
const loadRoleMessages = async () => { const loadRoleMessages = async () => {
if (!user) return; if (!user || !selectedRole) return;
try { try {
const savedMessages = await storageService.getCurrentChat(selectedRole.id, user.id); const savedMessages = await storageService.getCurrentChat(selectedRole?.id || '', user.id);
if (savedMessages) { if (savedMessages) {
const formattedMessages = savedMessages.map((msg: any) => ({ const formattedMessages = savedMessages.map((msg: any) => ({
...msg, ...msg,
@@ -156,18 +176,42 @@ export default function FlowScreen() {
setMessages([]); setMessages([]);
} }
} catch (error) { } catch (error) {
console.error(`Failed to load messages for role ${selectedRole.id}:`, error); if (selectedRole) {
console.error(`Failed to load messages for role ${selectedRole?.id}:`, error);
}
setMessages([]); setMessages([]);
} }
}; };
loadRoleMessages(); loadRoleMessages();
}, [selectedRole.id, user]); }, [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]);
// Save current messages for the active role when they change // Save current messages for the active role when they change
useEffect(() => { useEffect(() => {
if (user && messages.length >= 0) { // Save even if empty to allow clearing if (user && selectedRole && messages.length >= 0) { // Save even if empty to allow clearing
storageService.saveCurrentChat(selectedRole.id, messages, user.id); storageService.saveCurrentChat(selectedRole?.id || '', messages, user.id);
} }
if (messages.length > 0) { if (messages.length > 0) {
@@ -175,7 +219,7 @@ export default function FlowScreen() {
scrollViewRef.current?.scrollToEnd({ animated: true }); scrollViewRef.current?.scrollToEnd({ animated: true });
}, 100); }, 100);
} }
}, [messages, selectedRole.id, user]); }, [messages, selectedRole?.id, user]);
// Save history when it changes // Save history when it changes
useEffect(() => { useEffect(() => {
@@ -210,10 +254,12 @@ export default function FlowScreen() {
// ============================================================================= // =============================================================================
/** /**
* Handle sending a message to AI * Handle sending a message to AI (text-only via LangGraph, or image + optional text via vision API)
*/ */
const handleSendMessage = async () => { const handleSendMessage = async () => {
if (!newContent.trim() || isSending) return; const hasText = !!newContent.trim();
const hasImage = !!attachedImage;
if ((!hasText && !hasImage) || isSending || !selectedRole) return;
// Check authentication // Check authentication
if (!token) { if (!token) {
@@ -225,11 +271,64 @@ export default function FlowScreen() {
return; return;
} }
const userMessage = newContent.trim();
setIsSending(true); setIsSending(true);
// --- Path: send with image (optional text) ---
if (hasImage && attachedImage) {
const imageUri = attachedImage.uri;
const imageBase64 = attachedImage.base64;
const userText = newContent.trim() || '请描述或分析这张图片';
setAttachedImage(null);
setNewContent('');
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: 'user',
content: userText,
imageUri,
createdAt: new Date(),
};
setMessages(prev => [...prev, userMsg]);
try {
const aiResponse = await aiService.sendMessageWithImage(userText, imageBase64, token);
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: aiResponse,
createdAt: new Date(),
};
setMessages(prev => [...prev, aiMsg]);
} catch (error) {
console.error('AI image request failed:', error);
const errorMessage = error instanceof Error ? error.message : String(error);
const isAuthError =
errorMessage.includes('401') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('credentials') ||
errorMessage.includes('validate');
if (isAuthError) {
signOut();
Alert.alert('Session Expired', 'Your login session has expired. Please login again.', [{ text: 'OK' }]);
return;
}
const errorMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `⚠️ Error: ${errorMessage}`,
createdAt: new Date(),
};
setMessages(prev => [...prev, errorMsg]);
} finally {
setIsSending(false);
}
return;
}
// --- Path: text-only via LangGraph (unchanged) ---
const userMessage = newContent.trim();
setNewContent(''); setNewContent('');
// Add user message immediately
const userMsg: ChatMessage = { const userMsg: ChatMessage = {
id: Date.now().toString(), id: Date.now().toString(),
role: 'user', role: 'user',
@@ -239,10 +338,15 @@ export default function FlowScreen() {
setMessages(prev => [...prev, userMsg]); setMessages(prev => [...prev, userMsg]);
try { try {
// Call AI proxy with selected role's system prompt const history: (HumanMessage | LangChainAIMessage | SystemMessage)[] = messages.map(msg => {
const aiResponse = await aiService.sendMessage(userMessage, token, selectedRole.systemPrompt); if (msg.role === 'user') return new HumanMessage(msg.content);
return new LangChainAIMessage(msg.content);
});
const systemPrompt = new SystemMessage(selectedRole?.systemPrompt || '');
const currentMsg = new HumanMessage(userMessage);
const fullMessages = [systemPrompt, ...history, currentMsg];
const aiResponse = await langGraphService.execute(fullMessages, token);
// Add AI response
const aiMsg: ChatMessage = { const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(), id: (Date.now() + 1).toString(),
role: 'assistant', role: 'assistant',
@@ -250,20 +354,15 @@ export default function FlowScreen() {
createdAt: new Date(), createdAt: new Date(),
}; };
setMessages(prev => [...prev, aiMsg]); setMessages(prev => [...prev, aiMsg]);
} catch (error) { } catch (error) {
console.error('AI request failed:', error); console.error('AI request failed:', error);
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
// Handle authentication errors (401, credentials, unauthorized)
const isAuthError = const isAuthError =
errorMessage.includes('401') || errorMessage.includes('401') ||
errorMessage.includes('credentials') || errorMessage.includes('credentials') ||
errorMessage.includes('Unauthorized') || errorMessage.includes('Unauthorized') ||
errorMessage.includes('Not authenticated') || errorMessage.includes('Not authenticated') ||
errorMessage.includes('validate'); errorMessage.includes('validate');
if (isAuthError) { if (isAuthError) {
signOut(); signOut();
Alert.alert( Alert.alert(
@@ -273,8 +372,6 @@ export default function FlowScreen() {
); );
return; return;
} }
// Show error as AI message
const errorMsg: ChatMessage = { const errorMsg: ChatMessage = {
id: (Date.now() + 1).toString(), id: (Date.now() + 1).toString(),
role: 'assistant', role: 'assistant',
@@ -296,17 +393,15 @@ export default function FlowScreen() {
}; };
/** /**
* Handle image attachment - pick image and analyze with AI * Handle image attachment - pick image and attach to next message (user can add text then send)
*/ */
const handleAddImage = async () => { const handleAddImage = async () => {
// Request permission
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') { if (status !== 'granted') {
Alert.alert('Permission Required', 'Please grant permission to access photos'); Alert.alert('Permission Required', 'Please grant permission to access photos');
return; return;
} }
// Pick image
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images, mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true, allowsEditing: true,
@@ -315,78 +410,11 @@ export default function FlowScreen() {
}); });
if (!result.canceled && result.assets[0]) { if (!result.canceled && result.assets[0]) {
const imageAsset = result.assets[0]; const asset = result.assets[0];
setSelectedImage(imageAsset.uri); setAttachedImage({
uri: asset.uri,
// Check authentication base64: asset.base64 || '',
if (!token) { });
Alert.alert(
'Login Required',
'Please login to analyze images',
[{ text: 'OK', onPress: () => signOut() }]
);
return;
}
setIsSending(true);
// Add user message with image
const userMsg: ChatMessage = {
id: Date.now().toString(),
role: 'user',
content: 'Analyze this image',
imageUri: imageAsset.uri,
createdAt: new Date(),
};
setMessages(prev => [...prev, userMsg]);
try {
// Call AI with image (using base64)
const aiResponse = await aiService.sendMessageWithImage(
'Please describe and analyze this image in detail.',
imageAsset.base64 || '',
token
);
const aiMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: aiResponse,
createdAt: new Date(),
};
setMessages(prev => [...prev, aiMsg]);
} catch (error) {
console.error('AI image analysis failed:', error);
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
// Handle authentication errors
const isAuthError =
errorMessage.includes('401') ||
errorMessage.includes('Unauthorized') ||
errorMessage.includes('credentials') ||
errorMessage.includes('validate');
if (isAuthError) {
signOut();
Alert.alert(
'Session Expired',
'Your login session has expired. Please login again.',
[{ text: 'OK' }]
);
return;
}
const errorMsg: ChatMessage = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: `⚠️ Error analyzing image: ${errorMessage}`,
createdAt: new Date(),
};
setMessages(prev => [...prev, errorMsg]);
} finally {
setIsSending(false);
setSelectedImage(null);
}
} }
}; };
@@ -408,8 +436,8 @@ export default function FlowScreen() {
// Clear current messages and storage for this role // Clear current messages and storage for this role
setMessages([]); setMessages([]);
if (user) { if (user && selectedRole) {
storageService.saveCurrentChat(selectedRole.id, [], user.id); storageService.saveCurrentChat(selectedRole?.id || '', [], user.id);
} }
closeHistoryModal(); closeHistoryModal();
}; };
@@ -453,6 +481,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
// ============================================================================= // =============================================================================
@@ -525,9 +659,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}>Chatting with {selectedRole.name}</Text> <Text style={styles.emptyTitle}>Chatting with {selectedRole?.name || 'AI'}</Text>
<Text style={styles.emptySubtitle}> <Text style={styles.emptySubtitle}>
{selectedRole.description} {selectedRole?.description || 'Loading AI Assistant...'}
</Text> </Text>
</View> </View>
); );
@@ -585,17 +719,32 @@ export default function FlowScreen() {
onPress={() => setShowRoleModal(true)} onPress={() => setShowRoleModal(true)}
activeOpacity={0.7} activeOpacity={0.7}
> >
{selectedRole && (
<Ionicons <Ionicons
name={selectedRole.icon as any} name={(selectedRole?.icon || 'help-outline') as any}
size={16} size={16}
color={colors.nautical.teal} color={colors.nautical.teal}
/> />
)}
<Text style={styles.headerRoleText} numberOfLines={1}> <Text style={styles.headerRoleText} numberOfLines={1}>
{selectedRole.name} {selectedRole?.name || 'Loading...'}
</Text> </Text>
<Ionicons name="chevron-down" size={14} color={colors.flow.textSecondary} /> <Ionicons name="chevron-down" size={14} color={colors.flow.textSecondary} />
</TouchableOpacity> </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}
@@ -638,21 +787,35 @@ export default function FlowScreen() {
{/* Bottom Input Bar */} {/* Bottom Input Bar */}
<View style={styles.inputBarContainer}> <View style={styles.inputBarContainer}>
{/* Attached image preview (optional text then send) */}
{attachedImage && (
<View style={styles.attachedImageRow}>
<Image source={{ uri: attachedImage.uri }} style={styles.attachedImageThumb} resizeMode="cover" />
<Text style={styles.attachedImageHint} numberOfLines={1}></Text>
<TouchableOpacity
style={styles.attachedImageRemove}
onPress={() => setAttachedImage(null)}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
>
<Ionicons name="close-circle" size={24} color={colors.flow.textSecondary} />
</TouchableOpacity>
</View>
)}
<View style={styles.inputBar}> <View style={styles.inputBar}>
{/* Image attachment button */} {/* Image attachment button */}
<TouchableOpacity <TouchableOpacity
style={styles.inputBarButton} style={[styles.inputBarButton, attachedImage && styles.inputBarButtonActive]}
onPress={handleAddImage} onPress={handleAddImage}
activeOpacity={0.7} activeOpacity={0.7}
> >
<Feather name="image" size={22} color={colors.flow.textSecondary} /> <Feather name="image" size={22} color={attachedImage ? colors.nautical.teal : colors.flow.textSecondary} />
</TouchableOpacity> </TouchableOpacity>
{/* Text Input */} {/* Text Input */}
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
<TextInput <TextInput
style={styles.inputBarText} style={styles.inputBarText}
placeholder="Message..." placeholder={attachedImage ? '输入对图片的说明(可选)...' : 'Message...'}
placeholderTextColor={colors.flow.textSecondary} placeholderTextColor={colors.flow.textSecondary}
value={newContent} value={newContent}
onChangeText={setNewContent} onChangeText={setNewContent}
@@ -661,8 +824,8 @@ export default function FlowScreen() {
/> />
</View> </View>
{/* Send or Voice button */} {/* Send or Voice button: show send when has text or attached image */}
{newContent.trim() || isSending ? ( {newContent.trim() || attachedImage || isSending ? (
<TouchableOpacity <TouchableOpacity
style={[styles.sendButton, isSending && styles.sendButtonDisabled]} style={[styles.sendButton, isSending && styles.sendButtonDisabled]}
onPress={handleSendMessage} onPress={handleSendMessage}
@@ -776,34 +939,34 @@ export default function FlowScreen() {
<Text style={styles.modalTitle}>Choose AI Assistant</Text> <Text style={styles.modalTitle}>Choose AI Assistant</Text>
<ScrollView style={styles.roleList} showsVerticalScrollIndicator={false}> <ScrollView style={styles.roleList} showsVerticalScrollIndicator={false}>
{AI_CONFIG.ROLES.map((role) => ( {aiRoles.map((role) => (
<View key={role.id} style={styles.roleItemContainer}> <View key={role.id} style={styles.roleItemContainer}>
<View <View
style={[ style={[
styles.roleItem, styles.roleItem,
selectedRole.id === role.id && styles.roleItemActive selectedRole?.id === role.id && styles.roleItemActive
]} ]}
> >
<TouchableOpacity <TouchableOpacity
style={styles.roleSelectionArea} style={styles.roleSelectionArea}
onPress={() => { onPress={() => {
setSelectedRole(role as any); setSelectedRole(role);
setShowRoleModal(false); setShowRoleModal(false);
}} }}
> >
<View style={[ <View style={[
styles.roleItemIcon, styles.roleItemIcon,
selectedRole.id === role.id && styles.roleItemIconActive selectedRole?.id === role.id && styles.roleItemIconActive
]}> ]}>
<Ionicons <Ionicons
name={role.icon as any} name={role.icon as any}
size={20} size={20}
color={selectedRole.id === role.id ? '#fff' : colors.nautical.teal} color={selectedRole?.id === role.id ? '#fff' : colors.nautical.teal}
/> />
</View> </View>
<Text style={[ <Text style={[
styles.roleItemName, styles.roleItemName,
selectedRole.id === role.id && styles.roleItemNameActive selectedRole?.id === role.id && styles.roleItemNameActive
]}> ]}>
{role.name} {role.name}
</Text> </Text>
@@ -843,6 +1006,212 @@ export default function FlowScreen() {
</View> </View>
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
</Modal> </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>
); );
} }
@@ -1129,6 +1498,33 @@ const styles = StyleSheet.create({
paddingTop: spacing.sm, paddingTop: spacing.sm,
backgroundColor: 'transparent', backgroundColor: 'transparent',
}, },
attachedImageRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.flow.cardBackground,
borderRadius: borderRadius.lg,
padding: spacing.sm,
marginBottom: spacing.sm,
borderWidth: 1,
borderColor: colors.flow.cardBorder,
gap: spacing.sm,
},
attachedImageThumb: {
width: 48,
height: 48,
borderRadius: borderRadius.md,
},
attachedImageHint: {
flex: 1,
fontSize: typography.fontSize.sm,
color: colors.flow.textSecondary,
},
attachedImageRemove: {
padding: spacing.xs,
},
inputBarButtonActive: {
backgroundColor: colors.nautical.paleAqua,
},
inputBar: { inputBar: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-end', alignItems: 'flex-end',
@@ -1281,4 +1677,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',
},
}); });

View File

@@ -18,7 +18,7 @@ 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 { VAULT_STORAGE_KEYS } from './SentinelScreen'; import { getVaultStorageKeys } from '../config';
// Mock heirs data // Mock heirs data
const initialHeirs: Heir[] = [ const initialHeirs: Heir[] = [
@@ -248,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');
@@ -308,17 +309,31 @@ export default function MeScreen() {
}; };
const handleResetVault = async () => { const handleResetVault = async () => {
setResetVaultFeedback({ status: 'idle', message: '' });
const vaultKeys = getVaultStorageKeys(user?.id ?? null);
try { try {
await AsyncStorage.multiRemove([ await AsyncStorage.multiRemove([
VAULT_STORAGE_KEYS.INITIALIZED, vaultKeys.INITIALIZED,
VAULT_STORAGE_KEYS.SHARE_DEVICE, vaultKeys.SHARE_DEVICE,
vaultKeys.MNEMONIC_PART_LOCAL,
]); ]);
Alert.alert('Done', 'Vault state reset. Go to Sentinel → Open Shadow Vault to see first-time flow.'); setResetVaultFeedback({
status: 'success',
message: 'Vault state has been reset. Next time you open Shadow Vault you will see the mnemonic flow again.',
});
} catch (e) { } catch (e) {
Alert.alert('Error', 'Failed to reset vault state.'); setResetVaultFeedback({
status: 'error',
message: 'Failed to reset vault state. Please try again.',
});
} }
}; };
const handleCloseSanctumModal = () => {
setResetVaultFeedback({ status: 'idle', message: '' });
setShowSanctumModal(false);
};
return ( return (
<View style={styles.container}> <View style={styles.container}>
<LinearGradient <LinearGradient
@@ -760,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}>
@@ -908,7 +923,31 @@ export default function MeScreen() {
<Ionicons name="refresh" size={16} color={colors.nautical.coral} /> <Ionicons name="refresh" size={16} color={colors.nautical.coral} />
<Text style={styles.devResetText}>Reset Vault State</Text> <Text style={styles.devResetText}>Reset Vault State</Text>
</TouchableOpacity> </TouchableOpacity>
<Text style={styles.sanctumHint}>Clear hasVaultInitialized & Share A. Test first-open flow.</Text> <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> </View>
)} )}
</ScrollView> </ScrollView>
@@ -916,7 +955,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="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>
@@ -924,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>
@@ -1910,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',

View File

@@ -65,6 +65,8 @@ const initialLogs: KillSwitchLog[] = [
{ id: '4', action: 'HEARTBEAT_CONFIRMED', timestamp: new Date('2024-01-15T11:20: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'));

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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;
}
},
}; };

View File

@@ -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';

View 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();
}
};

View File

@@ -14,6 +14,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEYS = { const STORAGE_KEYS = {
CHAT_HISTORY: '@sentinel:chat_history', CHAT_HISTORY: '@sentinel:chat_history',
CURRENT_MESSAGES: '@sentinel:current_messages', CURRENT_MESSAGES: '@sentinel:current_messages',
ASSET_BACKUP: '@sentinel:asset_backup',
} as const; } as const;
// ============================================================================= // =============================================================================
@@ -115,6 +116,32 @@ export const storageService = {
} catch (e) { } catch (e) {
console.error('Error clearing storage data:', 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;
}
} }
}; };

View 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_encryptedhex 字符串)
*
* 使用方式:在任意页面调用 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_encryptedAES-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,
};
}

View File

@@ -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
@@ -102,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;
}

View 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
View 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 "【解密失败】:密钥错误或数据被篡改";
}
}
}

View 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);
}

View File

@@ -3,3 +3,4 @@
*/ */
export * from './sss'; export * from './sss';
export * from './vaultAssets';

76
src/utils/token_utils.ts Normal file
View 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
View 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
View 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;
}