diff --git a/apps/easypid/README.md b/apps/easypid/README.md index 39401d04..89d0a779 100644 --- a/apps/easypid/README.md +++ b/apps/easypid/README.md @@ -388,6 +388,9 @@ The following standards and specifications were implemented. - Fixed an issue where the PIN screen would get stuck in a loading state when an incorrect PIN was entered [commit](https://github.com/animo/paradym-wallet/commit/0f65ef98f5f26c3afc0968e4f848bf538a86cfd7) - Fixed an issue with redirect based auth flow if the authorization flow left the in-app browser (e.g. when requiring authentication using the native AusweisApp with the eID card) [commit](https://github.com/animo/paradym-wallet/commit/eb333b81fe5662cc2f010e1ee9bbdc83a7e19aa3) - Fixed an issue where the PID setup would get stuck if you skipped it during onboarding [commit](https://github.com/animo/openid4vc-playground-funke/commit/65178e776bc421b9ca413542ea0e86db4ad1ead4) +- Added support for on-device local AI model for oversharing detection on higher-end devices (can be enabled in the settings) [commit](https://github.com/animo/paradym-wallet/commit) + + #### 28-11-2024 diff --git a/apps/easypid/app.config.js b/apps/easypid/app.config.js index 056588e4..fea3fb7d 100644 --- a/apps/easypid/app.config.js +++ b/apps/easypid/app.config.js @@ -117,6 +117,9 @@ const config = { ], }, associatedDomains: associatedDomains.map((host) => `applinks:${host}`), + entitlements: { + 'com.apple.developer.kernel.increased-memory-limit': true, + }, }, android: { adaptiveIcon: { @@ -145,6 +148,9 @@ const config = { })) ), ], + config: { + largeHeap: true, + }, }, experiments: { tsconfigPaths: true, diff --git a/apps/easypid/package.json b/apps/easypid/package.json index 7b80551b..355fbefb 100644 --- a/apps/easypid/package.json +++ b/apps/easypid/package.json @@ -38,6 +38,7 @@ "expo-blur": "^13.0.2", "expo-constants": "~16.0.2", "expo-dev-client": "~4.0.16", + "expo-device": "~6.0.2", "expo-font": "~12.0.7", "expo-haptics": "~13.0.1", "expo-image": "~1.13.0", @@ -55,6 +56,7 @@ "react": "catalog:", "react-native": "catalog:", "react-native-argon2": "^2.0.1", + "react-native-executorch": "^0.1.2", "react-native-fs": "^2.20.0", "react-native-gesture-handler": "~2.16.2", "react-native-get-random-values": "~1.11.0", diff --git a/apps/easypid/src/app/_layout.tsx b/apps/easypid/src/app/_layout.tsx index 0fcb2642..da05c8dc 100644 --- a/apps/easypid/src/app/_layout.tsx +++ b/apps/easypid/src/app/_layout.tsx @@ -6,6 +6,7 @@ import { DefaultTheme, ThemeProvider } from '@react-navigation/native' import { Slot } from 'expo-router' import * as SplashScreen from 'expo-splash-screen' +import { useCheckIncompleteDownload } from '@easypid/llm' import tamaguiConfig from '../../tamagui.config' void SplashScreen.preventAutoHideAsync() @@ -17,6 +18,7 @@ export const unstable_settings = { export default function RootLayout() { useTransparentNavigationBar() + useCheckIncompleteDownload() return ( diff --git a/apps/easypid/src/features/menu/FunkeSettingsScreen.tsx b/apps/easypid/src/features/menu/FunkeSettingsScreen.tsx index 09b1ae7e..ee8210dc 100644 --- a/apps/easypid/src/features/menu/FunkeSettingsScreen.tsx +++ b/apps/easypid/src/features/menu/FunkeSettingsScreen.tsx @@ -1,15 +1,15 @@ -import { Button, FlexPage, Heading, HeroIcons, Paragraph, ScrollView, Stack, XStack, YStack } from '@package/ui' +import { FlexPage, Heading, HeroIcons, ScrollView, Stack, Switch, YStack } from '@package/ui' import React from 'react' -import { useRouter } from 'solito/router' -import { Label, Switch } from 'tamagui' + +import { TextBackButton } from 'packages/app/src' +import { LocalAiContainer } from './components/LocalAiContainer' import { useScrollViewPosition } from '@package/app/src/hooks' import { useDevelopmentMode } from '../../hooks/useDevelopmentMode' export function FunkeSettingsScreen() { const { handleScroll, isScrolledByOffset, scrollEventThrottle } = useScrollViewPosition() - const router = useRouter() - const [isDevelpomentModeEnabled, setIsDevelopmentModeEnabled] = useDevelopmentMode() + const [isDevelopmentModeEnabled, setIsDevelopmentModeEnabled] = useDevelopmentMode() return ( @@ -27,26 +27,19 @@ export function FunkeSettingsScreen() { contentContainerStyle={{ minHeight: '85%' }} > - - - This page is under construction. More options will be added. - - - - - - - + + } + value={isDevelopmentModeEnabled ?? false} + onChange={setIsDevelopmentModeEnabled} + /> + + + + - router.back()}> - Back - diff --git a/apps/easypid/src/features/menu/components/LocalAiContainer.tsx b/apps/easypid/src/features/menu/components/LocalAiContainer.tsx new file mode 100644 index 00000000..0eac525b --- /dev/null +++ b/apps/easypid/src/features/menu/components/LocalAiContainer.tsx @@ -0,0 +1,99 @@ +import { HeroIcons } from '@package/ui/src/content/Icon' + +import { Switch } from '@package/ui/src/base/Switch' + +import { useIsDeviceCapable, useLLM } from '@easypid/llm' +import { ConfirmationSheet } from '@package/app/src/components/ConfirmationSheet' +import { useHasInternetConnection, useIsConnectedToWifi } from 'packages/app/src/hooks' +import { useToastController } from 'packages/ui/src' +import React, { useState } from 'react' + +export function LocalAiContainer() { + const toast = useToastController() + const isConnectedToWifi = useIsConnectedToWifi() + const hasInternetConnection = useHasInternetConnection() + const isDeviceCapable = useIsDeviceCapable() + + const [isAiModelConfirmationOpen, setIsAiModelConfirmationOpen] = useState(false) + const { loadModel, isModelReady, downloadProgress, removeModel, isModelActivated, isModelDownloading } = useLLM() + + const onActivateModel = () => { + if (!isDeviceCapable) { + toast.show('Device not supported', { + message: 'This device is not powerful enough to run local AI models', + customData: { + preset: 'warning', + }, + }) + setIsAiModelConfirmationOpen(false) + return + } + if (!isConnectedToWifi && !hasInternetConnection) { + toast.show('WiFi connection required', { + message: 'Please connect to WiFi to activate and download the model', + customData: { + preset: 'warning', + }, + }) + setIsAiModelConfirmationOpen(false) + return + } + + setIsAiModelConfirmationOpen(false) + loadModel() + } + + const handleAiModelChange = (value: boolean) => { + if (isModelDownloading) { + toast.show('Model download in progress', { + message: 'Force close the app to cancel the download', + customData: { + preset: 'warning', + }, + }) + return + } + + if (value) { + setIsAiModelConfirmationOpen(true) + } else { + removeModel() + } + } + + return ( + <> + } + value={isModelActivated} + description={ + isModelActivated + ? isModelReady + ? 'Model active and ready to use' + : downloadProgress + ? `Downloading model ${(downloadProgress * 100).toFixed(2)}%` + : 'Getting ready...' + : '' + } + onChange={handleAiModelChange} + beta + /> + + + ) +} diff --git a/apps/easypid/src/features/receive/FunkeCredentialNotificationScreen.tsx b/apps/easypid/src/features/receive/FunkeCredentialNotificationScreen.tsx index 068dec6c..590d583a 100644 --- a/apps/easypid/src/features/receive/FunkeCredentialNotificationScreen.tsx +++ b/apps/easypid/src/features/receive/FunkeCredentialNotificationScreen.tsx @@ -448,6 +448,8 @@ export function FunkeCredentialNotificationScreen() { logo={credentialsForRequest.verifier.logo} submission={credentialsForRequest.formattedSubmission} isAccepting={isSharingPresentation} + // Not supported for this flow atm + overAskingResponse={{ validRequest: 'could_not_determine', reason: '' }} /> ), } diff --git a/apps/easypid/src/features/receive/slides/CredentialErrorSlide.tsx b/apps/easypid/src/features/receive/slides/CredentialErrorSlide.tsx index ad6df95d..6b1c88b1 100644 --- a/apps/easypid/src/features/receive/slides/CredentialErrorSlide.tsx +++ b/apps/easypid/src/features/receive/slides/CredentialErrorSlide.tsx @@ -1,4 +1,5 @@ -import { Button, Heading, HeroIcons, Paragraph, Stack, XStack, YStack } from '@package/ui' +import { Button, Heading, HeroIcons, Paragraph, ScrollView, Stack, XStack, YStack } from '@package/ui' +import { useState } from 'react' interface CredentialErrorSlideProps { reason?: string @@ -6,10 +7,12 @@ interface CredentialErrorSlideProps { } export const CredentialErrorSlide = ({ reason, onCancel }: CredentialErrorSlideProps) => { + const [scrollViewHeight, setScrollViewHeight] = useState(0) + return ( - - + setScrollViewHeight(event.nativeEvent.layout.height)}> + Something went wrong @@ -22,13 +25,15 @@ export const CredentialErrorSlide = ({ reason, onCancel }: CredentialErrorSlideP again later. - {reason && ( - - Reason: - {reason} - + {reason && scrollViewHeight !== 0 && ( + + + Reason: + {reason} + + )} - + diff --git a/apps/easypid/src/features/share/FunkeOpenIdPresentationNotificationScreen.tsx b/apps/easypid/src/features/share/FunkeOpenIdPresentationNotificationScreen.tsx index 553cd1e3..23f26989 100644 --- a/apps/easypid/src/features/share/FunkeOpenIdPresentationNotificationScreen.tsx +++ b/apps/easypid/src/features/share/FunkeOpenIdPresentationNotificationScreen.tsx @@ -12,9 +12,8 @@ import React, { useEffect, useState, useCallback } from 'react' import { useAppAgent } from '@easypid/agent' import { InvalidPinError } from '@easypid/crypto/error' +import { useOverAskingAi } from '@easypid/hooks' import { useDevelopmentMode } from '@easypid/hooks' -import { analyzeVerification } from '@easypid/use-cases/ValidateVerification' -import type { VerificationAnalysisResponse } from '@easypid/use-cases/ValidateVerification' import { usePushToWallet } from '@package/app/src/hooks/usePushToWallet' import { setWalletServiceProviderPin } from '../../crypto/WalletServiceProviderClient' import { useShouldUsePinForSubmission } from '../../hooks/useShouldUsePinForPresentation' @@ -62,26 +61,24 @@ export function FunkeOpenIdPresentationNotificationScreen() { }) }, [credentialsForRequest, params.data, params.uri, toast.show, agent, pushToWallet, toast, isDevelopmentModeEnabled]) - const [verificationAnalysis, setVerificationAnalysis] = useState<{ - isLoading: boolean - result: VerificationAnalysisResponse | undefined - }>({ - isLoading: false, - result: undefined, - }) + const { checkForOverAsking, isProcessingOverAsking, overAskingResponse, stopOverAsking } = useOverAskingAi() useEffect(() => { if (!credentialsForRequest?.formattedSubmission || !credentialsForRequest?.formattedSubmission.areAllSatisfied) { return } - setVerificationAnalysis((prev) => ({ ...prev, isLoading: true })) + + if (isProcessingOverAsking || overAskingResponse) { + // Already generating or already has result + return + } const submission = credentialsForRequest.formattedSubmission const requestedCards = submission.entries .filter((entry): entry is FormattedSubmissionEntrySatisfied => entry.isSatisfied) .flatMap((entry) => entry.credentials) - analyzeVerification({ + void checkForOverAsking({ verifier: { name: credentialsForRequest.verifier.name ?? 'No name provided', domain: credentialsForRequest.verifier.hostName ?? 'No domain provided', @@ -93,8 +90,8 @@ export function FunkeOpenIdPresentationNotificationScreen() { subtitle: credential.credential.display.description ?? 'Card description', requestedAttributes: getDisclosedAttributeNamesForDisplay(credential), })), - }).then((result) => setVerificationAnalysis((prev) => ({ ...prev, isLoading: false, result }))) - }, [credentialsForRequest]) + }) + }, [credentialsForRequest, checkForOverAsking, isProcessingOverAsking, overAskingResponse]) const onProofAccept = useCallback( async (pin?: string): Promise => { @@ -106,6 +103,7 @@ export function FunkeOpenIdPresentationNotificationScreen() { }, } + stopOverAsking() setIsSharing(true) if (shouldUsePin) { @@ -190,10 +188,11 @@ export function FunkeOpenIdPresentationNotificationScreen() { } } }, - [credentialsForRequest, agent, shouldUsePin, isDevelopmentModeEnabled] + [credentialsForRequest, agent, shouldUsePin, stopOverAsking, isDevelopmentModeEnabled] ) const onProofDecline = async () => { + stopOverAsking() if (credentialsForRequest) { await addSharedActivityForCredentialsForRequest( agent, @@ -219,7 +218,7 @@ export function FunkeOpenIdPresentationNotificationScreen() { trustedEntities={credentialsForRequest?.verifier.trustedEntities} lastInteractionDate={lastInteractionDate} onComplete={() => pushToWallet('replace')} - verificationAnalysis={verificationAnalysis} + overAskingResponse={overAskingResponse} /> ) } diff --git a/apps/easypid/src/features/share/FunkePresentationNotificationScreen.tsx b/apps/easypid/src/features/share/FunkePresentationNotificationScreen.tsx index c41b773e..ba3aec92 100644 --- a/apps/easypid/src/features/share/FunkePresentationNotificationScreen.tsx +++ b/apps/easypid/src/features/share/FunkePresentationNotificationScreen.tsx @@ -1,6 +1,6 @@ import type { DisplayImage, FormattedSubmission, TrustedEntity } from '@package/agent' -import type { VerificationAnalysisResult } from '@easypid/use-cases/ValidateVerification' +import type { OverAskingResponse } from '@easypid/use-cases/OverAskingApi' import { type SlideStep, SlideWizard } from '@package/app' import { LoadingRequestSlide } from '../receive/slides/LoadingRequestSlide' import { VerifyPartySlide } from '../receive/slides/VerifyPartySlide' @@ -14,7 +14,7 @@ interface FunkePresentationNotificationScreenProps { verifierName?: string logo?: DisplayImage lastInteractionDate?: string - verificationAnalysis: VerificationAnalysisResult + overAskingResponse?: OverAskingResponse trustedEntities?: Array submission?: FormattedSubmission usePin: boolean @@ -35,7 +35,7 @@ export function FunkePresentationNotificationScreen({ isAccepting, submission, onComplete, - verificationAnalysis, + overAskingResponse, trustedEntities, }: FunkePresentationNotificationScreenProps) { return ( @@ -74,7 +74,7 @@ export function FunkePresentationNotificationScreen({ logo={logo} submission={submission} isAccepting={isAccepting} - verificationAnalysis={verificationAnalysis} + overAskingResponse={overAskingResponse} /> ), }, diff --git a/apps/easypid/src/features/share/components/RequestPurposeSection.tsx b/apps/easypid/src/features/share/components/RequestPurposeSection.tsx index 3c06ff88..4c8fce8b 100644 --- a/apps/easypid/src/features/share/components/RequestPurposeSection.tsx +++ b/apps/easypid/src/features/share/components/RequestPurposeSection.tsx @@ -1,4 +1,4 @@ -import type { VerificationAnalysisResult } from '@easypid/use-cases/ValidateVerification' +import type { OverAskingResponse } from '@easypid/use-cases/OverAskingApi' import { AnimatedStack, Circle, @@ -7,24 +7,25 @@ import { Image, InfoSheet, MessageBox, + Spinner, Stack, XStack, YStack, useScaleAnimation, } from '@package/ui' import type { DisplayImage } from 'packages/agent/src' +import { isAndroid } from 'packages/app/src' import { useState } from 'react' import React from 'react' import { FadeIn, ZoomIn } from 'react-native-reanimated' -import { VerificationAnalysisIcon } from './VerificationAnalysisIcon' interface RequestPurposeSectionProps { purpose: string logo?: DisplayImage - verificationAnalysis?: VerificationAnalysisResult + overAskingResponse?: OverAskingResponse } -export function RequestPurposeSection({ purpose, logo, verificationAnalysis }: RequestPurposeSectionProps) { +export function RequestPurposeSection({ purpose, logo, overAskingResponse }: RequestPurposeSectionProps) { const [isAnalysisModalOpen, setIsAnalysisModalOpen] = useState(false) const { handlePressIn, handlePressOut, pressStyle } = useScaleAnimation() @@ -34,14 +35,14 @@ export function RequestPurposeSection({ purpose, logo, verificationAnalysis }: R return ( <> - {verificationAnalysis?.result?.validRequest === 'no' && ( + {overAskingResponse?.validRequest === 'no' && ( PURPOSE - {verificationAnalysis && ( - - - - )} + + {!overAskingResponse ? ( + + ) : overAskingResponse.validRequest === 'yes' ? ( + + ) : overAskingResponse.validRequest === 'no' ? ( + + ) : null} + - - if (!verificationAnalysis.result || verificationAnalysis.result.validRequest === 'could_not_determine') { - // AI doesn't know or an error was thrown - return null - } - - if (verificationAnalysis.result.validRequest === 'yes') { - return - } - - return -} diff --git a/apps/easypid/src/features/share/slides/ShareCredentialsSlide.tsx b/apps/easypid/src/features/share/slides/ShareCredentialsSlide.tsx index 4278469c..e053f9a6 100644 --- a/apps/easypid/src/features/share/slides/ShareCredentialsSlide.tsx +++ b/apps/easypid/src/features/share/slides/ShareCredentialsSlide.tsx @@ -1,4 +1,4 @@ -import type { VerificationAnalysisResult } from '@easypid/use-cases/ValidateVerification' +import type { OverAskingResponse } from '@easypid/use-cases/OverAskingApi' import type { DisplayImage, FormattedSubmission } from '@package/agent' import { DualResponseButtons, usePushToWallet, useScrollViewPosition } from '@package/app' import { useWizard } from '@package/app' @@ -17,7 +17,7 @@ interface ShareCredentialsSlideProps { isAccepting: boolean isOffline?: boolean - verificationAnalysis?: VerificationAnalysisResult + overAskingResponse?: OverAskingResponse } export const ShareCredentialsSlide = ({ @@ -27,7 +27,7 @@ export const ShareCredentialsSlide = ({ onDecline, isAccepting, isOffline, - verificationAnalysis, + overAskingResponse, }: ShareCredentialsSlideProps) => { const { onNext, onCancel } = useWizard() const [scrollViewHeight, setScrollViewHeight] = useState(0) @@ -88,7 +88,9 @@ export const ShareCredentialsSlide = ({ purpose={ submission.purpose ?? 'No information was provided on the purpose of the data request. Be cautious' } - verificationAnalysis={verificationAnalysis} + overAskingResponse={ + submission.areAllSatisfied ? overAskingResponse : { validRequest: 'could_not_determine', reason: '' } + } logo={logo} /> )} diff --git a/apps/easypid/src/hooks/index.ts b/apps/easypid/src/hooks/index.ts index f45292cb..f1fe8429 100644 --- a/apps/easypid/src/hooks/index.ts +++ b/apps/easypid/src/hooks/index.ts @@ -1,3 +1,4 @@ export * from './usePidCredential' export * from './useWalletReset' +export * from './useOverAskingAi' export * from './useDevelopmentMode' diff --git a/apps/easypid/src/hooks/useOverAskingAi.tsx b/apps/easypid/src/hooks/useOverAskingAi.tsx new file mode 100644 index 00000000..bd73732c --- /dev/null +++ b/apps/easypid/src/hooks/useOverAskingAi.tsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from 'react' + +import { useLLM } from '@easypid/llm' +import type { OverAskingInput, OverAskingResponse } from '@easypid/use-cases/OverAskingApi' +import { EXCLUDED_ATTRIBUTES_FOR_ANALYSIS, checkForOverAskingApi } from '@easypid/use-cases/OverAskingApi' + +const fallbackResponse: OverAskingResponse = { + validRequest: 'could_not_determine', + reason: 'Error determining if the request is valid.', +} + +export function useOverAskingAi() { + const [isProcessingOverAsking, setIsProcessingOverAsking] = useState(false) + const [overAskingResponse, setOverAskingResponse] = useState() + + const { generate, response, error, isModelReady, isModelGenerating, interrupt } = useLLM() + + useEffect(() => { + if (error) { + setIsProcessingOverAsking(false) + setOverAskingResponse(fallbackResponse) + return + } + + if (!response || isModelGenerating) return + + try { + const result = formatLocalResult(response) + setOverAskingResponse(result) + } catch (e) { + console.error('Error parsing AI response:', e) + setOverAskingResponse(fallbackResponse) + setIsProcessingOverAsking(false) + } + }, [response, isModelGenerating, error]) + + const checkForOverAsking = async (input: OverAskingInput) => { + setIsProcessingOverAsking(true) + if (isModelReady) { + console.debug('Local LLM ready, using local LLM') + const prompt = formatLocalPrompt(input) + await generate(prompt) + } else { + console.debug('Local LLM not ready, using API') + await checkForOverAskingApi(input) + .then(setOverAskingResponse) + .catch((e) => { + console.error('Error analyzing verification using API:', e) + setOverAskingResponse(fallbackResponse) + }) + .finally(() => setIsProcessingOverAsking(false)) + } + } + + const stopOverAsking = () => { + if (isModelReady) interrupt() + if (!overAskingResponse) setOverAskingResponse(fallbackResponse) + setIsProcessingOverAsking(false) + } + + return { + isProcessingOverAsking, + checkForOverAsking, + overAskingResponse, + stopOverAsking, + } +} + +const formatLocalResult = (response: string) => { + const match = response.match(/([\s\S]*?)<\/response>/) + if (!match) return fallbackResponse + + const responseContent = match[1] + + if (responseContent.includes('') && responseContent.includes('')) { + return { + validRequest: responseContent.split('')[1].split('')[0] as + | 'yes' + | 'no' + | 'could_not_determine', + reason: responseContent.split('')[1].split('')[0], + } + } + + return fallbackResponse +} + +const formatLocalPrompt = (input: OverAskingInput) => { + const cards = input.cards + .map( + (credential) => ` + + ${credential.name} + + ${credential.requestedAttributes + .filter((attr) => !EXCLUDED_ATTRIBUTES_FOR_ANALYSIS.includes(attr)) + .map((attr) => `${attr}`) + .join('\n ')} + + ` + ) + .join('\n') + + return ` + + ${input.verifier.name} + ${input.verifier.domain} + ${input.purpose} + + ${cards} + + +` +} diff --git a/apps/easypid/src/llm/RnExecutorchModule.ts b/apps/easypid/src/llm/RnExecutorchModule.ts new file mode 100644 index 00000000..3cce9190 --- /dev/null +++ b/apps/easypid/src/llm/RnExecutorchModule.ts @@ -0,0 +1,28 @@ +import { NativeEventEmitter, NativeModules, Platform } from 'react-native' + +const LINKING_ERROR = `The package 'react-native-executorch' doesn't seem to be linked. Make sure: \n\n${Platform.select({ ios: "- You have run 'pod install'\n", default: '' })}- You rebuilt the app after installing the package\n- You are not using Expo Go\n` + +const RnExecutorch = NativeModules.RnExecutorch + ? NativeModules.RnExecutorch + : new Proxy( + {}, + { + get() { + throw new Error(LINKING_ERROR) + }, + } + ) + +const eventEmitter = new NativeEventEmitter(RnExecutorch) + +export const subscribeToTokenGenerated = (callback: (data?: string) => void) => { + const subscription = eventEmitter.addListener('onToken', callback) + return () => subscription.remove() +} + +export const subscribeToDownloadProgress = (callback: (data?: number) => void) => { + const subscription = eventEmitter.addListener('onDownloadProgress', callback) + return () => subscription.remove() +} + +export default RnExecutorch diff --git a/apps/easypid/src/llm/constants.ts b/apps/easypid/src/llm/constants.ts new file mode 100644 index 00000000..86bab8a3 --- /dev/null +++ b/apps/easypid/src/llm/constants.ts @@ -0,0 +1 @@ +export const EOT_TOKEN = '<|eot_id|>' diff --git a/apps/easypid/src/llm/index.ts b/apps/easypid/src/llm/index.ts new file mode 100644 index 00000000..6684c967 --- /dev/null +++ b/apps/easypid/src/llm/index.ts @@ -0,0 +1,3 @@ +export * from './useLLM' +export * from './state' +export * from './types' diff --git a/apps/easypid/src/llm/prompt.ts b/apps/easypid/src/llm/prompt.ts new file mode 100644 index 00000000..f5f585ac --- /dev/null +++ b/apps/easypid/src/llm/prompt.ts @@ -0,0 +1,87 @@ +export const OVERASKING_PROMPT = ` +You are an AI assistant specializing in data privacy analysis. Your task is to evaluate data verification requests and determine if they are asking for an appropriate amount of information or if they are overasking. + +=== INFORMATION AVAILABLE === + +You will be provided with the following information: + +- Verifier name: the name of the requesting party +- Verifier domain: the domain of the requesting party +- Request purpose: the purpose of the verification request; why is the verifier requesting this information? +- Cards and requested attributes: the specific cards and requested attributes per card that the verifier is requesting. + +Based on this information, you should determine if the request matches the verifier and the purpose, or if the verifier is overasking for information. Focus only on personal information that could be sensitive. Overasking of metadata related to the card is not a reason to reject the request. + +=== OUTPUT === + +Your output should consist of two parts: + +1. Reason: Use 10-20 words describing why the request is overasking or not. Use specifics from the request to justify your answer. +2. Valid: Your final verdict which can be 'yes', 'no' or 'could_not_determine'. + +Your response should be formatted in XML, as shown below: + + +Your concise reason for the assessment +yes + + +This output structure is VERY important and should be followed exactly. It will be parsed by the app, so make sure it's correct. DO NOT include any other text than the XML tags and content specified above. + + +=== EXAMPLES === + +=== EXAMPLE 1 === + + + HealthCare Plus + healthcareplus.med + Medical appointment scheduling and insurance verification + + + Insurance Card + + policy_number + expiration_date + + + + + + + Request aligns with medical purpose, asking only for relevant insurance information needed for appointment scheduling. + yes + + + +=== EXAMPLE 2 === + + + OnlineShop + onlineshop.com + Shipping a purchased item + + + Personalausweis + + full_name + address + date_of_birth + portrait + + + + + + + Online shop requesting a portrait photo for simple shipping is excessive and unnecessary for stated purpose. + no + + + + +====== GUIDELINES ====== + +Return ONLY the XML ... tags and content specified above. DO NOT repeat the input or any other text. + +` diff --git a/apps/easypid/src/llm/state.ts b/apps/easypid/src/llm/state.ts new file mode 100644 index 00000000..39ef5cf2 --- /dev/null +++ b/apps/easypid/src/llm/state.ts @@ -0,0 +1,27 @@ +import { useMMKVBoolean } from 'react-native-mmkv' + +import { mmkv } from '@easypid/storage/mmkv' + +export function useIsModelReady() { + return useMMKVBoolean('isModelReady', mmkv) +} + +export function removeIsModelReady() { + mmkv.delete('isModelReady') +} + +export function useIsModelActivated() { + return useMMKVBoolean('isModelActivated', mmkv) +} + +export function removeIsModelActivated() { + mmkv.delete('isModelActivated') +} + +export function useIsModelDownloading() { + return useMMKVBoolean('isModelDownloading', mmkv) +} + +export function removeIsModelDownloading() { + mmkv.delete('isModelDownloading') +} diff --git a/apps/easypid/src/llm/types.ts b/apps/easypid/src/llm/types.ts new file mode 100644 index 00000000..ef945600 --- /dev/null +++ b/apps/easypid/src/llm/types.ts @@ -0,0 +1,15 @@ +export type ResourceSource = string | number + +export interface Model { + generate: (input: string) => Promise + response: string + downloadProgress: number + error: string | null + isModelGenerating: boolean + isModelReady: boolean + isModelActivated: boolean + isModelDownloading: boolean + interrupt: () => void + loadModel: () => Promise + removeModel: () => void +} diff --git a/apps/easypid/src/llm/useLLM.tsx b/apps/easypid/src/llm/useLLM.tsx new file mode 100644 index 00000000..74445074 --- /dev/null +++ b/apps/easypid/src/llm/useLLM.tsx @@ -0,0 +1,180 @@ +import * as Device from 'expo-device' +import { useCallback, useEffect, useRef, useState } from 'react' +import { Platform } from 'react-native' +import { LLAMA3_2_1B_QLORA_URL, LLAMA3_2_1B_TOKENIZER } from 'react-native-executorch' +import RnExecutorch, { subscribeToDownloadProgress, subscribeToTokenGenerated } from './RnExecutorchModule' +import { EOT_TOKEN } from './constants' +import { OVERASKING_PROMPT } from './prompt' +import { + removeIsModelActivated, + removeIsModelDownloading, + removeIsModelReady, + useIsModelActivated, + useIsModelDownloading, + useIsModelReady, +} from './state' +import type { Model } from './types' + +const interrupt = () => { + RnExecutorch.interrupt() +} + +// FIXME: The model expects a system prompt on initializing, but this blocks it from being used for different tasks. +export const useLLM = (): Model => { + const [error, setError] = useState(null) + const [isModelActivated, setIsModelActivated] = useIsModelActivated() + const [isModelDownloading, setIsModelDownloading] = useIsModelDownloading() + const [isModelReady, setIsModelReady] = useIsModelReady() + const [isModelGenerating, setIsModelGenerating] = useState(false) + const [response, setResponse] = useState('') + const [downloadProgress, setDownloadProgress] = useState(0) + const initialized = useRef(false) + + useEffect(() => { + if (!response) return + console.debug('Local LLM response', response) + }, [response]) + + useEffect(() => { + if (!error) return + console.debug('Local LLM error', error) + }, [error]) + + useEffect(() => { + const unsubscribeDownloadProgress = subscribeToDownloadProgress((data) => { + if (data) { + setDownloadProgress(data) + } + }) + + return () => { + if (unsubscribeDownloadProgress) unsubscribeDownloadProgress() + } + }, []) + + const loadModel = useCallback(async () => { + if (initialized.current) { + return + } + + setIsModelActivated(true) + initialized.current = true + try { + try { + setIsModelDownloading(true) + await RnExecutorch.loadLLM(LLAMA3_2_1B_QLORA_URL, LLAMA3_2_1B_TOKENIZER, OVERASKING_PROMPT, 2) + await RnExecutorch + } catch (error) { + console.log('ERROR LOADING MODEL', error) + } + setIsModelDownloading(false) + setIsModelActivated(true) + setIsModelReady(true) + } catch (err) { + const message = (err as Error).message + setIsModelReady(false) + setIsModelDownloading(false) + setError(message) + initialized.current = false + } + }, [setIsModelReady, setIsModelActivated, setIsModelDownloading]) + + const generate = useCallback( + async (input: string): Promise => { + if (!isModelReady || !isModelActivated) { + throw new Error('Model not active or still loading') + } + if (error) { + throw new Error(error) + } + + try { + setResponse('') + setIsModelGenerating(true) + await RnExecutorch.runInference(input) + } catch (err) { + setIsModelGenerating(false) + throw new Error((err as Error).message) + } + }, + [isModelReady, error, isModelActivated] + ) + + // Move the token subscription to useEffect to ensure it persists + useEffect(() => { + const unsubscribeTokenGenerated = subscribeToTokenGenerated((data) => { + if (!data) return + + if (data !== EOT_TOKEN) { + setResponse((prevResponse) => prevResponse + data) + } else { + setIsModelGenerating(false) + } + }) + + return () => { + if (unsubscribeTokenGenerated) unsubscribeTokenGenerated() + } + }, []) + + // Doesn't actually remove the model, just the state + const removeModel = useCallback(() => { + removeIsModelReady() + removeIsModelActivated() + removeIsModelDownloading() + initialized.current = false + }, []) + + return { + generate, + error, + isModelActivated: isModelActivated ?? false, + isModelReady: isModelReady ?? false, + isModelDownloading: isModelDownloading ?? false, + isModelGenerating, + response, + downloadProgress, + interrupt, + loadModel, + removeModel, + } +} + +// Check for incomplete model download from previous session +// Used when the app is opened +export function useCheckIncompleteDownload() { + const [isModelActivated] = useIsModelActivated() + const [isModelDownloading] = useIsModelDownloading() + const [isModelReady] = useIsModelReady() + + // biome-ignore lint/correctness/useExhaustiveDependencies: should only run once + useEffect(() => { + if (isModelActivated && isModelDownloading && !isModelReady) { + console.log('Cleaning up incomplete model download from previous session') + removeIsModelReady() + removeIsModelActivated() + removeIsModelDownloading() + } + }, []) +} + +export function useIsDeviceCapable(): boolean { + // For iOS, check if device is at least iPhone 12 or newer and iOS 17.0 or newer + if (Platform.OS === 'ios') { + const modelId = Device.modelId ?? '' + const systemVersion = Number.parseFloat(Device.osVersion ?? '0') + // iPhone 12 series and newer start with iPhone13 (iPhone12 = iPhone13,2) + const isSupportedModel = /iPhone1[3-9]/.test(modelId) || /iPhone[2-9][0-9]/.test(modelId) + return isSupportedModel && systemVersion >= 17.0 + } + + // For Android, check for minimum 4GB RAM and Android 13 or newer + if (Platform.OS === 'android') { + const totalMemory = Device.totalMemory ?? 0 + const MIN_REQUIRED_RAM = 4 * 1024 * 1024 * 1024 + const systemVersion = Number.parseInt(Device.osVersion ?? '0', 10) + return totalMemory >= MIN_REQUIRED_RAM && systemVersion >= 13 + } + + return false +} diff --git a/apps/easypid/src/use-cases/ValidateVerification.ts b/apps/easypid/src/use-cases/OverAskingApi.ts similarity index 82% rename from apps/easypid/src/use-cases/ValidateVerification.ts rename to apps/easypid/src/use-cases/OverAskingApi.ts index ca018295..0b1f07a7 100644 --- a/apps/easypid/src/use-cases/ValidateVerification.ts +++ b/apps/easypid/src/use-cases/OverAskingApi.ts @@ -2,7 +2,7 @@ const PLAYGROUND_URL = 'https://funke.animo.id' export const EXCLUDED_ATTRIBUTES_FOR_ANALYSIS = ['Issuing authority', 'Issuing country', 'Issued at', 'Expires at'] -export type VerificationAnalysisInput = { +export type OverAskingInput = { verifier: { name: string domain: string @@ -16,22 +16,17 @@ export type VerificationAnalysisInput = { }> } -export type VerificationAnalysisResponse = { +export type OverAskingResponse = { validRequest: 'yes' | 'no' | 'could_not_determine' reason: string } -export type VerificationAnalysisResult = { - isLoading: boolean - result: VerificationAnalysisResponse | undefined -} - -export const analyzeVerification = async ({ +export const checkForOverAskingApi = async ({ verifier, name, purpose, cards, -}: VerificationAnalysisInput): Promise => { +}: OverAskingInput): Promise => { try { const cardsWithoutExcludedAttributes = cards.map((card) => ({ ...card, diff --git a/packages/app/src/components/ConfirmationSheet.tsx b/packages/app/src/components/ConfirmationSheet.tsx index bca8adf8..ce8b3abf 100644 --- a/packages/app/src/components/ConfirmationSheet.tsx +++ b/packages/app/src/components/ConfirmationSheet.tsx @@ -8,8 +8,10 @@ const DEFAULT_CONFIRM_TEXT = 'Yes, stop' interface ConfirmationSheetProps { type?: 'regular' | 'floating' + variant?: 'confirmation' | 'regular' title?: string - description?: string + description?: string | string[] + cancelText?: string confirmText?: string isOpen: boolean setIsOpen: (open: boolean) => void @@ -19,9 +21,11 @@ interface ConfirmationSheetProps { export function ConfirmationSheet({ type = 'regular', + variant = 'confirmation', title, description, confirmText, + cancelText, isOpen, setIsOpen, onConfirm, @@ -42,12 +46,16 @@ export function ConfirmationSheet({ - {description || DEFAULT_DESCRIPTION} + {Array.isArray(description) ? ( + description.map((desc) => {desc}) + ) : ( + {description || DEFAULT_DESCRIPTION} + )} setIsOpen(false))} /> @@ -61,14 +69,18 @@ export function ConfirmationSheet({ {title || DEFAULT_TITLE} - {description || DEFAULT_DESCRIPTION} + {Array.isArray(description) ? ( + description.map((desc) => {desc}) + ) : ( + {description || DEFAULT_DESCRIPTION} + )} setIsOpen(false))} /> diff --git a/packages/app/src/hooks/index.ts b/packages/app/src/hooks/index.ts index cac7af42..715d91ce 100644 --- a/packages/app/src/hooks/index.ts +++ b/packages/app/src/hooks/index.ts @@ -8,3 +8,4 @@ export * from './usePushToWallet' export * from './useHeaderRightAction' export * from './useHaptics' export * from './useImageScaler' +export * from './useHasInternetConnection' diff --git a/packages/app/src/hooks/useHasInternetConnection.tsx b/packages/app/src/hooks/useHasInternetConnection.tsx index b2befde9..1b71e199 100644 --- a/packages/app/src/hooks/useHasInternetConnection.tsx +++ b/packages/app/src/hooks/useHasInternetConnection.tsx @@ -7,3 +7,8 @@ export const useHasInternetConnection = () => { return (isConnected && isInternetReachable) ?? false } + +export const useIsConnectedToWifi = () => { + const { type } = useNetInfo() + return type === 'wifi' +} diff --git a/packages/ui/src/base/Switch.tsx b/packages/ui/src/base/Switch.tsx new file mode 100644 index 00000000..9d678614 --- /dev/null +++ b/packages/ui/src/base/Switch.tsx @@ -0,0 +1,73 @@ +import { cloneElement } from 'react' +import Animated from 'react-native-reanimated' +import { Label, Switch as TamaguiSwitch } from 'tamagui' +import { useScaleAnimation } from '../hooks' +import { Paragraph } from './Paragraph' +import { XStack, YStack } from './Stacks' + +type SettingsSwitchProps = { + id: string + label: string + description?: string + value: boolean + icon?: React.ReactElement + disabled?: boolean + onChange: (value: boolean) => void + beta?: boolean +} + +const AnimatedSwitch = Animated.createAnimatedComponent(TamaguiSwitch) + +export function Switch({ id, label, value, disabled, onChange, icon, description, beta }: SettingsSwitchProps) { + const { handlePressIn, handlePressOut, pressStyle } = useScaleAnimation({ scaleInValue: 0.95 }) + + return ( + + + + {icon && ( + + {cloneElement(icon, { size: 20, color: value ? '$primary-500' : '$grey-500' })} + + )} + + + {beta && ( + + + BETA + + + )} + + + + + + + {description && {description}} + + ) +} diff --git a/packages/ui/src/base/index.ts b/packages/ui/src/base/index.ts index 172f5d8c..85bd2d70 100644 --- a/packages/ui/src/base/index.ts +++ b/packages/ui/src/base/index.ts @@ -5,3 +5,4 @@ export * from './Stacks' export * from './Page' export * from './ScrollView' export * from './Separator' +export * from './Switch' diff --git a/packages/ui/src/content/Icon.tsx b/packages/ui/src/content/Icon.tsx index 1ecb2764..f8358a1b 100644 --- a/packages/ui/src/content/Icon.tsx +++ b/packages/ui/src/content/Icon.tsx @@ -64,6 +64,8 @@ import { CircleStackIcon as CircleStackFilledIcon, ClockIcon as ClockFilledIcon, Cog8ToothIcon as Cog8ToothFilledIcon, + CommandLineIcon, + CpuChipIcon as CpuChipFilledIcon, CreditCardIcon as CreditCardFilledIcon, ExclamationCircleIcon as ExclamationCircleFilledIcon, ExclamationTriangleIcon as ExclamationTriangleFilledIcon, @@ -175,6 +177,8 @@ export const HeroIcons = { MagnifyingGlass: wrapHeroIcon(MagnifyingGlassIcon), Link: wrapHeroIcon(LinkIcon), Cloud: wrapHeroIcon(CloudIcon), + CpuChipFilled: wrapHeroIcon(CpuChipFilledIcon), + CommandLineFilled: wrapHeroIcon(CommandLineIcon), } as const export const CustomIcons = { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index af1d2d57..0d4e7667 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,6 +1,14 @@ export { tokens, config, absoluteFill } from './config/tamagui.config' export * from './constants' -export { TamaguiProviderProps, TamaguiProvider, Spacer, Input, AnimatePresence, Circle, VisuallyHidden } from 'tamagui' +export { + TamaguiProviderProps, + TamaguiProvider, + Spacer, + Input, + AnimatePresence, + Circle, + VisuallyHidden, +} from 'tamagui' export { ToastProvider, useToastController, ToastViewport, useToastState } from '@tamagui/toast' export * from './panels' export * from './base' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 053b832e..c8439302 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -174,6 +174,9 @@ importers: expo-dev-client: specifier: ~4.0.16 version: 4.0.28(expo@51.0.39(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))) + expo-device: + specifier: ~6.0.2 + version: 6.0.2(expo@51.0.39(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))) expo-font: specifier: ~12.0.7 version: 12.0.10(expo@51.0.39(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))) @@ -225,6 +228,9 @@ importers: react-native-argon2: specifier: ^2.0.1 version: 2.0.1 + react-native-executorch: + specifier: ^0.1.2 + version: 0.1.2(react-native@0.74.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1) react-native-fs: specifier: ^2.20.0 version: 2.20.0(react-native@0.74.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.2.79)(react@18.3.1)) @@ -6014,6 +6020,11 @@ packages: peerDependencies: expo: '*' + expo-device@6.0.2: + resolution: {integrity: sha512-sCt91CuTmAuMXX4SlFOn4lIos2UIr8vb0jDstDDZXys6kErcj0uynC7bQAMreU5uRUTKMAl4MAMpKt9ufCXPBw==} + peerDependencies: + expo: '*' + expo-eas-client@0.12.0: resolution: {integrity: sha512-Jkww9Cwpv0z7DdLYiRX0r4fqBEcI9cKqTn7cHx63S09JaZ2rcwEE4zYHgrXwjahO+tU2VW8zqH+AJl6RhhW4zA==} @@ -8368,6 +8379,12 @@ packages: react-native-argon2@2.0.1: resolution: {integrity: sha512-/iOi0S+VVgS1gQGtQgL4ZxUVS4gz6Lav3bgIbtNmr9KbOunnBYzP6/yBe/XxkbpXvasHDwdQnuppOH/nuOBn7w==} + react-native-executorch@0.1.2: + resolution: {integrity: sha512-iexg2NDKO/ADVZtDYT99kap7kVJPtmTd8HbtLJRmVBT/tYhoXG9o5+vcHl5xIo9qlyZW8YqG047z4NtFAGDP1w==} + peerDependencies: + react: '*' + react-native: '*' + react-native-fit-image@1.5.5: resolution: {integrity: sha512-Wl3Vq2DQzxgsWKuW4USfck9zS7YzhvLNPpkwUUCF90bL32e1a0zOVQ3WsJILJOwzmPdHfzZmWasiiAUNBkhNkg==} @@ -9386,6 +9403,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + ua-parser-js@0.7.39: + resolution: {integrity: sha512-IZ6acm6RhQHNibSt7+c09hhvsKy9WUr4DVbeq9U8o71qxyYtJpQeDxQnMrVqnIFMLcQjHO0I9wgfO2vIahht4w==} + hasBin: true + ua-parser-js@1.0.39: resolution: {integrity: sha512-k24RCVWlEcjkdOxYmVJgeD/0a1TiSpqLg+ZalVGV9lsnr4yqu0w7tX/x2xX6G4zpkgQnRf89lxuZ1wsbjXM8lw==} hasBin: true @@ -17596,6 +17617,11 @@ snapshots: expo-dev-menu-interface: 1.8.3(expo@51.0.39(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))) semver: 7.6.3 + expo-device@6.0.2(expo@51.0.39(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))): + dependencies: + expo: 51.0.39(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0)) + ua-parser-js: 0.7.39 + expo-eas-client@0.12.0: {} expo-file-system@17.0.1(expo@51.0.39(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))): @@ -20407,6 +20433,11 @@ snapshots: react-native-argon2@2.0.1: {} + react-native-executorch@0.1.2(react-native@0.74.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.2.79)(react@18.3.1))(react@18.3.1): + dependencies: + react: 18.3.1 + react-native: 0.74.5(@babel/core@7.26.0)(@babel/preset-env@7.26.0(@babel/core@7.26.0))(@types/react@18.2.79)(react@18.3.1) + react-native-fit-image@1.5.5: dependencies: prop-types: 15.8.1 @@ -21709,6 +21740,8 @@ snapshots: typescript@5.3.3: {} + ua-parser-js@0.7.39: {} + ua-parser-js@1.0.39: {} uc.micro@1.0.6: {}