diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..a0cff1a4 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,4 @@ +# Generated build artifacts +android/app/build/ +ios/build/ +coverage/ diff --git a/.eslintrc.js b/.eslintrc.js index c6b37197..f10d3ec7 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,7 +1,19 @@ module.exports = { root: true, extends: '@react-native', + plugins: [ + 'react-native', + 'react', + 'react-hooks', + ], + env: { + jest: true, + browser: true, + node: true, + es6: true, + }, rules: { + // TypeScript '@typescript-eslint/no-unused-vars': [ 'error', { @@ -10,5 +22,41 @@ module.exports = { caughtErrorsIgnorePattern: '^_', }, ], + 'no-shadow': 'off', + '@typescript-eslint/no-shadow': 'error', + + // Code quality (built-in) + 'no-empty': 'error', + 'no-else-return': 'error', + 'prefer-template': 'error', + complexity: ['error', 15], + 'max-lines-per-function': ['error', 250], + 'max-lines': ['error', 350], + 'max-params': ['error', 3], + // React hooks + 'react-hooks/rules-of-hooks': 'error', + 'react-hooks/exhaustive-deps': 'warn', + + // React Native + 'react-native/no-unused-styles': 'error', + 'react-native/no-inline-styles': 'error', + 'react-native/no-color-literals': 'error', + 'react-native/no-raw-text': 'error', + 'react-native/no-single-element-style-arrays': 'error', }, + overrides: [ + { + // Relax structural rules in test files — large test suites and helpers are acceptable + files: ['__tests__/**/*', '*.test.ts', '*.test.tsx', 'jest.setup.ts'], + rules: { + 'max-lines': 'off', + 'max-lines-per-function': 'off', + 'max-params': 'off', + complexity: 'off', + 'react-native/no-inline-styles': 'off', + 'react-native/no-raw-text': 'off', + 'react-native/no-color-literals': 'off', + }, + }, + ], }; diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f00c1f99..7648f2b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,3 +72,86 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage/lcov.info fail_ci_if_error: false + + test-android: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Java + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install JS dependencies + run: npm ci + + - name: Run Android unit tests + run: npm run test:android + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-test-results + path: android/app/build/reports/tests/ + if-no-files-found: ignore + + test-ios: + runs-on: macos-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install JS dependencies + run: npm ci + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + + - name: Install CocoaPods + run: | + gem install cocoapods + cd ios && pod install + + - name: Install SwiftLint + run: brew install swiftlint + + - name: Lint Swift + run: swiftlint lint --quiet --reporter github-actions-logging + + - name: Run iOS unit tests + run: | + xcodebuild test \ + -workspace ios/OffgridMobile.xcworkspace \ + -scheme OffgridMobile \ + -destination 'platform=iOS Simulator,name=iPhone 16e' \ + -only-testing:OffgridMobileTests \ + | xcpretty --color && exit "${PIPESTATUS[0]}" + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: ios-test-results + path: ~/Library/Developer/Xcode/DerivedData/**/Logs/Test/*.xcresult + if-no-files-found: ignore diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100755 index 00000000..0c315fa0 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,43 @@ +#!/usr/bin/env sh + +# Detect staged file types +STAGED_JS=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.(ts|tsx|js|jsx)$' || true) +STAGED_SWIFT=$(git diff --cached --name-only --diff-filter=ACMR | grep '\.swift$' | grep -v 'Pods/' | grep -v 'build/' || true) +STAGED_KOTLIN=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.(kt|kts)$' || true) + +# ── JS / TS ──────────────────────────────────────────────────────────────────── +if [ -n "$STAGED_JS" ]; then + echo "▶ JS/TS lint (staged files)..." + npx lint-staged + + echo "▶ TypeScript type check..." + npx tsc --noEmit + + echo "▶ JS/TS tests..." + npm test +fi + +# ── Swift / iOS ──────────────────────────────────────────────────────────────── +if [ -n "$STAGED_SWIFT" ]; then + if command -v swiftlint >/dev/null 2>&1; then + echo "▶ SwiftLint (staged files)..." + echo "$STAGED_SWIFT" | tr '\n' '\0' | xargs -0 swiftlint lint --quiet + else + echo "⚠️ SwiftLint not installed — skipping Swift lint. Install: brew install swiftlint" + fi + + echo "▶ iOS tests..." + npm run test:ios +fi + +# ── Kotlin / Android ─────────────────────────────────────────────────────────── +if [ -n "$STAGED_KOTLIN" ]; then + echo "▶ Kotlin type check (compileStandardDebugKotlin)..." + (cd android && ./gradlew compileStandardDebugKotlin --quiet) + + echo "▶ Android lint..." + (cd android && ./gradlew lintStandardDebug --quiet) + + echo "▶ Android tests..." + npm run test:android +fi diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 00000000..c8e054b3 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,26 @@ +included: + - ios + +excluded: + - ios/Pods + - ios/build + - ios/OffgridMobile.xcodeproj + - ios/OffgridMobileTests + +disabled_rules: + - trailing_whitespace # handled by editor + - line_length # RN bridge code has long lines + +opt_in_rules: + - force_unwrapping + +force_unwrapping: + severity: warning + +function_body_length: + warning: 100 + error: 200 + +type_body_length: + warning: 400 + error: 1000 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..4f1d792e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "sonarlint.connectedMode.project": { + "connectionId": "alichherawalla", + "projectKey": "alichherawalla_off-grid-mobile" + } +} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 3c2c29eb..1f922c4a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,13 +2,19 @@ ## Pre-Commit Quality Gates -Before EVERY commit, you MUST first check if you have added tests for whatever you are trying to commit. If not add tests. Then run all of the following checks and ensure they pass. Do NOT commit until all three are green: +All quality gates run automatically via Husky on every `git commit`, scoped to the file types you staged: -1. **Tests**: `npm test` — if you wrote or modified code, first ensure tests exist for the changes. Write missing tests before running. -2. **Linting**: `npm run lint` -3. **TypeScript**: `npx tsc --noEmit` +| Staged file type | Checks that run automatically | +|---|---| +| `.ts` / `.tsx` / `.js` / `.jsx` | eslint (staged only), `tsc --noEmit`, `npm test` | +| `.swift` | swiftlint (staged only), `npm run test:ios` | +| `.kt` / `.kts` | `compileStandardDebugKotlin` (type check), `lintStandardDebug`, `npm run test:android` | -Run all three in parallel. If any fail, fix the issues and re-run until they all pass. Never skip these checks. +**Requirements:** +- SwiftLint: `brew install swiftlint` (skipped with a warning if not installed) +- Android checks require the Gradle wrapper in `android/` + +Before writing new code, ensure tests exist for your changes. If the hook fails, fix the issue and recommit — never skip with `--no-verify`. ## Push = Create PR + Address Review diff --git a/__tests__/integration/generation/generationFlow.test.ts b/__tests__/integration/generation/generationFlow.test.ts index b9b14fd7..376fc6dd 100644 --- a/__tests__/integration/generation/generationFlow.test.ts +++ b/__tests__/integration/generation/generationFlow.test.ts @@ -74,7 +74,7 @@ describe('Generation Flow Integration', () => { let completeCallback: any = null; mockLlmService.generateResponse.mockImplementation( - async (_messages, onStream, onComplete, _onError, _onThinking) => { + async (_messages, onStream, onComplete) => { streamCallback = onStream!; completeCallback = onComplete!; return 'Hello world!'; @@ -118,7 +118,7 @@ describe('Generation Flow Integration', () => { let completeCallback: any = null; mockLlmService.generateResponse.mockImplementation( - async (_messages, onStream, onComplete, _onError, _onThinking) => { + async (_messages, onStream, onComplete) => { streamCallback = onStream!; completeCallback = onComplete!; return 'Test'; @@ -153,7 +153,7 @@ describe('Generation Flow Integration', () => { let completeCallback: any = null; mockLlmService.generateResponse.mockImplementation( - async (_messages, onStream, onComplete, _onError, _onThinking) => { + async (_messages, onStream, onComplete) => { streamCallback = onStream!; completeCallback = onComplete!; return 'Test'; @@ -186,7 +186,7 @@ describe('Generation Flow Integration', () => { let completeCallback: any = null; mockLlmService.generateResponse.mockImplementation( - async (_messages, _onStream, onComplete, _onError, _onThinking) => { + async (_messages, _onStream, onComplete) => { completeCallback = onComplete!; return 'Test'; } @@ -214,7 +214,7 @@ describe('Generation Flow Integration', () => { let completeCallback: any = null; mockLlmService.generateResponse.mockImplementation( - async (_messages, onStream, onComplete, _onError, _onThinking) => { + async (_messages, onStream, onComplete) => { streamCallback = onStream!; completeCallback = onComplete!; return 'Hello world'; @@ -254,7 +254,7 @@ describe('Generation Flow Integration', () => { let completeCallback: any = null; mockLlmService.generateResponse.mockImplementation( - async (_messages, onStream, onComplete, _onError, _onThinking) => { + async (_messages, onStream, onComplete) => { streamCallback = onStream!; completeCallback = onComplete!; return 'Complete response'; @@ -316,7 +316,7 @@ describe('Generation Flow Integration', () => { let completeCallback: any = null; mockLlmService.generateResponse.mockImplementation( - async (_messages, onStream, onComplete, _onError, _onThinking) => { + async (_messages, onStream, onComplete) => { streamCallback = onStream!; completeCallback = onComplete!; return 'Response'; @@ -347,11 +347,8 @@ describe('Generation Flow Integration', () => { const modelId = setupWithActiveModel(); const conversationId = setupWithConversation({ modelId }); - let _errorCallback: any = null; - mockLlmService.generateResponse.mockImplementation( - async (_messages, _onStream, _onComplete, onError, _onThinking) => { - _errorCallback = onError!; + async (_messages, _onStream, _onComplete) => { throw new Error('Generation failed'); } ); @@ -376,7 +373,7 @@ describe('Generation Flow Integration', () => { const conversationId = setupWithConversation({ modelId }); mockLlmService.generateResponse.mockImplementation( - async (_messages, _onStream, _onComplete, _onError, _onThinking) => { + async (_messages) => { // Never complete automatically - simulates ongoing generation return new Promise(() => {}); } @@ -432,7 +429,7 @@ describe('Generation Flow Integration', () => { let streamCallback: any = null; mockLlmService.generateResponse.mockImplementation( - async (_messages, onStream, _onComplete, _onError, _onThinking) => { + async (_messages, onStream) => { streamCallback = onStream!; // Simulate long running generation by returning a never-resolving promise await new Promise(() => {}); @@ -481,7 +478,7 @@ describe('Generation Flow Integration', () => { let streamCallback: any = null; mockLlmService.generateResponse.mockImplementation( - async (_messages, onStream, _onComplete, _onError, _onThinking) => { + async (_messages, onStream) => { streamCallback = onStream!; return new Promise(() => {}); } @@ -511,7 +508,7 @@ describe('Generation Flow Integration', () => { const conversationId = setupWithConversation({ modelId }); mockLlmService.generateResponse.mockImplementation( - async (_messages, _onStream, _onComplete, _onError, _onThinking) => { + async (_messages) => { return new Promise(() => {}); } ); @@ -540,7 +537,7 @@ describe('Generation Flow Integration', () => { let completeCallback: any = null; mockLlmService.generateResponse.mockImplementation( - async (_messages, onStream, onComplete, _onError, _onThinking) => { + async (_messages, onStream, onComplete) => { streamCallback = onStream!; completeCallback = onComplete!; return 'Test'; diff --git a/__tests__/rntl/components/AnimatedPressable.test.tsx b/__tests__/rntl/components/AnimatedPressable.test.tsx index f0b04bed..b5c7d507 100644 --- a/__tests__/rntl/components/AnimatedPressable.test.tsx +++ b/__tests__/rntl/components/AnimatedPressable.test.tsx @@ -21,9 +21,9 @@ jest.mock('../../../src/utils/haptics', () => ({ triggerHaptic: jest.fn(), })); -// eslint-disable-next-line @typescript-eslint/no-var-requires + const { triggerHaptic: mockTriggerHaptic } = require('../../../src/utils/haptics'); -// eslint-disable-next-line @typescript-eslint/no-var-requires + const Reanimated = require('react-native-reanimated'); describe('AnimatedPressable', () => { diff --git a/__tests__/rntl/screens/ChatScreen.test.tsx b/__tests__/rntl/screens/ChatScreen.test.tsx index aa069905..2a523510 100644 --- a/__tests__/rntl/screens/ChatScreen.test.tsx +++ b/__tests__/rntl/screens/ChatScreen.test.tsx @@ -233,9 +233,9 @@ jest.mock('../../../src/components', () => ({ ); }, ChatInput: ({ onSend, onStop, disabled, placeholder, isGenerating, imageModelLoaded, queueCount, onClearQueue, onOpenSettings }: any) => { - const React = require('react'); + const { useState } = require('react'); const { View, TextInput, TouchableOpacity, Text } = require('react-native'); - const [text, setText] = React.useState(''); + const [text, setText] = useState(''); return ( ({ ModelSelectorModal: ({ visible, onClose, onSelectModel, onUnloadModel }: any) => { const { View, Text, TouchableOpacity } = require('react-native'); if (!visible) return null; - const { useAppStore } = require('../../../src/stores/appStore'); - const models = useAppStore.getState().downloadedModels; + const { useAppStore: useAppStoreMock } = require('../../../src/stores/appStore'); + const models = useAppStoreMock.getState().downloadedModels; return ( Select Model @@ -2219,17 +2219,19 @@ describe('ChatScreen', () => { // Create a second conversation and switch to it const conv2 = createConversation({ modelId, title: 'Second Chat' }); - useChatStore.setState({ - conversations: [ - ...useChatStore.getState().conversations, - conv2, - ], - activeConversationId: conv2.id, + await act(async () => { + useChatStore.setState({ + conversations: [ + ...useChatStore.getState().conversations, + conv2, + ], + activeConversationId: conv2.id, + }); }); + // Wait for the deferred setTimeout(fn, 0) to fire await act(async () => { - // Wait for InteractionManager to run - if (jest.runAllTimers) { jest.runAllTimers(); } else { await new Promise(r => setTimeout(() => r(), 50)); } + await new Promise(r => setTimeout(r, 50)); }); // clearKVCache should have been called @@ -3565,7 +3567,7 @@ describe('ChatScreen', () => { attachments: undefined, messageText: 'test', }); - } catch (_e) {} + } catch (_e) { /* expected: error from send */ } }); await act(async () => { await new Promise(r => setTimeout(() => r(), 500)); }); diff --git a/__tests__/rntl/screens/ChatsListScreen.test.tsx b/__tests__/rntl/screens/ChatsListScreen.test.tsx index d3b7da49..522e3ed5 100644 --- a/__tests__/rntl/screens/ChatsListScreen.test.tsx +++ b/__tests__/rntl/screens/ChatsListScreen.test.tsx @@ -106,7 +106,6 @@ jest.mock('../../../src/services', () => ({ // Override global Swipeable mock to render rightActions for testing jest.mock('react-native-gesture-handler/Swipeable', () => { - const React = require('react'); return ({ children, renderRightActions }: any) => { const { View } = require('react-native'); return ( diff --git a/__tests__/rntl/screens/DownloadManagerScreen.test.tsx b/__tests__/rntl/screens/DownloadManagerScreen.test.tsx index 331b7e64..4f968d17 100644 --- a/__tests__/rntl/screens/DownloadManagerScreen.test.tsx +++ b/__tests__/rntl/screens/DownloadManagerScreen.test.tsx @@ -1287,6 +1287,63 @@ describe('DownloadManagerScreen', () => { expect(queryByText('Unknown')).toBeNull(); }); + // ===== getStatusText HELPER TESTS ===== + + it('shows "Downloading..." for background download with status "running"', async () => { + mockBackgroundDownloadService.isAvailable.mockReturnValue(true); + mockModelManager.getActiveBackgroundDownloads.mockResolvedValue([ + { downloadId: 11, status: 'running', bytesDownloaded: 100, title: 'run.gguf' }, + ]); + const state = createDefaultState({ + activeBackgroundDownloads: { + 11: { modelId: 'a/m', fileName: 'run.gguf', author: 'a', quantization: 'Q4', totalBytes: 1000 }, + }, + }); + mockUseAppStore.mockImplementation((selector?: any) => selector ? selector(state) : state); + + const result = render(); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + + expect(result.getByText('Downloading...')).toBeTruthy(); + }); + + it('shows "Starting..." for background download with status "pending"', async () => { + mockBackgroundDownloadService.isAvailable.mockReturnValue(true); + mockModelManager.getActiveBackgroundDownloads.mockResolvedValue([ + { downloadId: 12, status: 'pending', bytesDownloaded: 0, title: 'pend.gguf' }, + ]); + const state = createDefaultState({ + activeBackgroundDownloads: { + 12: { modelId: 'a/m', fileName: 'pend.gguf', author: 'a', quantization: 'Q4', totalBytes: 1000 }, + }, + }); + mockUseAppStore.mockImplementation((selector?: any) => selector ? selector(state) : state); + + const result = render(); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + + expect(result.getByText('Starting...')).toBeTruthy(); + }); + + it('shows "Paused" for background download with status "paused"', async () => { + mockBackgroundDownloadService.isAvailable.mockReturnValue(true); + mockModelManager.getActiveBackgroundDownloads.mockResolvedValue([ + { downloadId: 13, status: 'paused', bytesDownloaded: 400, title: 'paus.gguf' }, + ]); + const state = createDefaultState({ + activeBackgroundDownloads: { + 13: { modelId: 'a/m', fileName: 'paus.gguf', author: 'a', quantization: 'Q4', totalBytes: 1000 }, + }, + }); + mockUseAppStore.mockImplementation((selector?: any) => selector ? selector(state) : state); + + const result = render(); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + + expect(result.getByText('Paused')).toBeTruthy(); + }); + + it('remove download with downloadId cancels background download', async () => { const setBackgroundDownload = jest.fn(); const setDownloadProgress = jest.fn(); diff --git a/__tests__/rntl/screens/HomeScreen.test.tsx b/__tests__/rntl/screens/HomeScreen.test.tsx index 16f582fd..9be9708e 100644 --- a/__tests__/rntl/screens/HomeScreen.test.tsx +++ b/__tests__/rntl/screens/HomeScreen.test.tsx @@ -179,9 +179,9 @@ jest.mock('../../../src/hooks/useFocusTrigger', () => ({ // Mock Swipeable to render children AND renderRightActions jest.mock('react-native-gesture-handler/Swipeable', () => { - const React = require('react'); + const { forwardRef } = require('react'); const { View } = require('react-native'); - return React.forwardRef(({ children, renderRightActions, containerStyle }: any, _ref: any) => ( + return forwardRef(({ children, renderRightActions, containerStyle }: any, _ref: any) => ( {children} {renderRightActions && {renderRightActions()}} diff --git a/__tests__/rntl/screens/ModelDownloadScreen.test.tsx b/__tests__/rntl/screens/ModelDownloadScreen.test.tsx index 21e9c307..7ba121f6 100644 --- a/__tests__/rntl/screens/ModelDownloadScreen.test.tsx +++ b/__tests__/rntl/screens/ModelDownloadScreen.test.tsx @@ -73,6 +73,7 @@ jest.mock('../../../src/services', () => ({ isBackgroundDownloadSupported: jest.fn(() => false), downloadModel: jest.fn((...args: any[]) => mockDownloadModel(...args)), downloadModelBackground: jest.fn((...args: any[]) => mockDownloadModelBackground(...args)), + watchDownload: jest.fn(), }, })); @@ -458,10 +459,7 @@ describe('ModelDownloadScreen', () => { downloadedAt: new Date().toISOString(), }; - mockDownloadModel.mockImplementation((_modelId: string, _file: any, _onProgress: any, onComplete: any) => { - onComplete(completedModel); - return Promise.resolve(); - }); + mockDownloadModel.mockResolvedValue(completedModel); const result = render(); @@ -504,10 +502,7 @@ describe('ModelDownloadScreen', () => { downloadedAt: new Date().toISOString(), }; - mockDownloadModel.mockImplementation((_modelId: string, _file: any, _onProgress: any, onComplete: any) => { - onComplete(completedModel); - return Promise.resolve(); - }); + mockDownloadModel.mockResolvedValue(completedModel); const result = render(); @@ -536,10 +531,7 @@ describe('ModelDownloadScreen', () => { }; mockGetModelFiles.mockResolvedValue([mockFile]); - mockDownloadModel.mockImplementation((_modelId: string, _file: any, _onProgress: any, _onComplete: any, onError: any) => { - onError(new Error('Download failed')); - return Promise.resolve(); - }); + mockDownloadModel.mockRejectedValue(new Error('Download failed')); const result = render(); diff --git a/__tests__/rntl/screens/ModelsScreen.test.tsx b/__tests__/rntl/screens/ModelsScreen.test.tsx index 59d9b2a4..cb2b3f7f 100644 --- a/__tests__/rntl/screens/ModelsScreen.test.tsx +++ b/__tests__/rntl/screens/ModelsScreen.test.tsx @@ -2520,7 +2520,10 @@ describe('ModelsScreen', () => { it('calls downloadModel when background download not supported', async () => { const { modelManager } = require('../../../src/services/modelManager'); modelManager.isBackgroundDownloadSupported = jest.fn(() => false); - modelManager.downloadModel = jest.fn(() => Promise.resolve()); + modelManager.downloadModel = jest.fn(() => Promise.resolve(createDownloadedModel({ + id: 'test-org/test-model-3B/model-Q4_K_M.gguf', + name: 'Test Model', + }))); const files = [ createModelFile({ name: 'model-Q4_K_M.gguf', size: 2000000000 }), diff --git a/__tests__/rntl/screens/SecuritySettingsScreen.test.tsx b/__tests__/rntl/screens/SecuritySettingsScreen.test.tsx index eab9e879..0cf000f4 100644 --- a/__tests__/rntl/screens/SecuritySettingsScreen.test.tsx +++ b/__tests__/rntl/screens/SecuritySettingsScreen.test.tsx @@ -77,7 +77,6 @@ jest.mock('../../../src/components/Button', () => ({ })); jest.mock('../../../src/components/CustomAlert', () => { - const React = require('react'); const { View, Text, TouchableOpacity } = require('react-native'); return { CustomAlert: ({ visible, title, message, buttons, onClose }: any) => { diff --git a/__tests__/unit/constants/constants.test.ts b/__tests__/unit/constants/constants.test.ts index c662a5f2..3aa1096f 100644 --- a/__tests__/unit/constants/constants.test.ts +++ b/__tests__/unit/constants/constants.test.ts @@ -105,7 +105,7 @@ describe('VERIFIED_QUANTIZERS', () => { }); it('includes bartowski', () => { - expect(VERIFIED_QUANTIZERS['bartowski']).toBeDefined(); + expect(VERIFIED_QUANTIZERS.bartowski).toBeDefined(); }); it('all entries have non-empty display names', () => { @@ -119,9 +119,9 @@ describe('VERIFIED_QUANTIZERS', () => { describe('OFFICIAL_MODEL_AUTHORS', () => { it('includes major model creators', () => { expect(OFFICIAL_MODEL_AUTHORS['meta-llama']).toBe('Meta'); - expect(OFFICIAL_MODEL_AUTHORS['google']).toBe('Google'); - expect(OFFICIAL_MODEL_AUTHORS['microsoft']).toBe('Microsoft'); - expect(OFFICIAL_MODEL_AUTHORS['Qwen']).toBe('Alibaba'); + expect(OFFICIAL_MODEL_AUTHORS.google).toBe('Google'); + expect(OFFICIAL_MODEL_AUTHORS.microsoft).toBe('Microsoft'); + expect(OFFICIAL_MODEL_AUTHORS.Qwen).toBe('Alibaba'); }); it('all entries have non-empty display names', () => { @@ -144,8 +144,8 @@ describe('LMSTUDIO_AUTHORS', () => { describe('QUANTIZATION_INFO', () => { it('has Q4_K_M as recommended', () => { - expect(QUANTIZATION_INFO['Q4_K_M']).toBeDefined(); - expect(QUANTIZATION_INFO['Q4_K_M'].recommended).toBe(true); + expect(QUANTIZATION_INFO.Q4_K_M).toBeDefined(); + expect(QUANTIZATION_INFO.Q4_K_M.recommended).toBe(true); }); it('all entries have required fields', () => { diff --git a/__tests__/unit/services/documentService.test.ts b/__tests__/unit/services/documentService.test.ts index 2ff759c1..4f76feb4 100644 --- a/__tests__/unit/services/documentService.test.ts +++ b/__tests__/unit/services/documentService.test.ts @@ -585,7 +585,7 @@ describe('DocumentService', () => { // DocumentService would truncate this: const maxChars = 50000; const truncated = text.length > maxChars - ? text.substring(0, maxChars) + '\n\n... [Content truncated due to length]' + ? `${text.substring(0, maxChars) }\n\n... [Content truncated due to length]` : text; expect(truncated.length).toBeLessThan(60000); diff --git a/__tests__/unit/services/generationService.test.ts b/__tests__/unit/services/generationService.test.ts index 570f094e..d30a728c 100644 --- a/__tests__/unit/services/generationService.test.ts +++ b/__tests__/unit/services/generationService.test.ts @@ -363,18 +363,13 @@ describe('generationService', () => { const convId = setupWithConversation(); const clearStreamingSpy = jest.spyOn(useChatStore.getState(), 'clearStreamingMessage'); - mockedLlmService.generateResponse.mockImplementation((async ( - _messages: any, - _onStream: any, - _onComplete: any, - onError: any - ) => { - onError?.(new Error('Generation failed')); - }) as any); + mockedLlmService.generateResponse.mockRejectedValue(new Error('Generation failed')); - await generationService.generateResponse(convId, [ - createMessage({ role: 'user', content: 'Hi' }), - ]); + await expect( + generationService.generateResponse(convId, [ + createMessage({ role: 'user', content: 'Hi' }), + ]) + ).rejects.toThrow('Generation failed'); expect(clearStreamingSpy).toHaveBeenCalled(); expect(generationService.getState().isGenerating).toBe(false); diff --git a/__tests__/unit/services/huggingface.test.ts b/__tests__/unit/services/huggingface.test.ts index d4f06224..4b15e44a 100644 --- a/__tests__/unit/services/huggingface.test.ts +++ b/__tests__/unit/services/huggingface.test.ts @@ -1,4 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ + declare const global: any; /** @@ -516,25 +516,6 @@ describe('HuggingFaceService', () => { }); }); - // ============================================================================ - // getKnownMediaPipeModels - // ============================================================================ - describe('getKnownMediaPipeModels', () => { - it('returns curated list', () => { - const models = huggingFaceService.getKnownMediaPipeModels(); - expect(models.length).toBeGreaterThan(0); - expect(models[0]).toHaveProperty('id'); - expect(models[0]).toHaveProperty('name'); - expect(models[0]).toHaveProperty('size'); - }); - - it('has at least one recommended model', () => { - const models = huggingFaceService.getKnownMediaPipeModels(); - const recommended = models.filter(m => m.recommended); - expect(recommended.length).toBeGreaterThan(0); - }); - }); - // ============================================================================ // Additional branch coverage tests // ============================================================================ @@ -569,139 +550,6 @@ describe('HuggingFaceService', () => { }); }); - describe('searchImageGenerationModels', () => { - it('returns image models on success', async () => { - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve([ - { - id: 'stabilityai/sd-turbo', - author: 'stabilityai', - downloads: 10000, - likes: 500, - tags: ['diffusers', 'stable-diffusion'], - siblings: [], - }, - ]), - }); - (global as any).fetch = mockFetch; - - const results = await huggingFaceService.searchImageGenerationModels('sd-turbo'); - - expect(results).toHaveLength(1); - expect(results[0].id).toBe('stabilityai/sd-turbo'); - expect(results[0].modelType).toBe('SD 1.x/2.x'); - }); - - it('uses default search when query is empty', async () => { - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve([]), - }); - (global as any).fetch = mockFetch; - - await huggingFaceService.searchImageGenerationModels(''); - - const url = mockFetch.mock.calls[0][0]; - expect(url).toContain('search=stable-diffusion'); - }); - - it('appends search param when query provided', async () => { - const mockFetch = jest.fn().mockResolvedValue({ - ok: true, - json: () => Promise.resolve([]), - }); - (global as any).fetch = mockFetch; - - await huggingFaceService.searchImageGenerationModels('my-model'); - - const url = mockFetch.mock.calls[0][0]; - expect(url).toContain('search=my-model'); - }); - - it('throws on API error', async () => { - (global as any).fetch = jest.fn().mockResolvedValue({ - ok: false, - status: 500, - }); - - await expect(huggingFaceService.searchImageGenerationModels()).rejects.toThrow('API error: 500'); - }); - }); - - describe('isMediaPipeCompatible', () => { - it('returns true for models with mediapipe tag', () => { - const result = service.isMediaPipeCompatible({ - id: 'test/model', - tags: ['mediapipe'], - siblings: [], - }); - expect(result).toBe(true); - }); - - it('returns true for known compatible models', () => { - const result = service.isMediaPipeCompatible({ - id: 'runwayml/stable-diffusion-v1-5', - tags: [], - siblings: [], - }); - expect(result).toBe(true); - }); - - it('returns false for unknown models', () => { - const result = service.isMediaPipeCompatible({ - id: 'unknown/custom-model', - tags: [], - siblings: [], - }); - expect(result).toBe(false); - }); - }); - - describe('getImageModelType', () => { - it('returns SDXL for stable-diffusion-xl tag', () => { - expect(service.getImageModelType({ tags: ['stable-diffusion-xl'], id: 'test', siblings: [] })).toBe('SDXL'); - }); - - it('returns SD 1.x/2.x for stable-diffusion tag', () => { - expect(service.getImageModelType({ tags: ['stable-diffusion'], id: 'test', siblings: [] })).toBe('SD 1.x/2.x'); - }); - - it('returns Flux for flux tag', () => { - expect(service.getImageModelType({ tags: ['flux'], id: 'test', siblings: [] })).toBe('Flux'); - }); - - it('returns LCM for latent-consistency tag', () => { - expect(service.getImageModelType({ tags: ['latent-consistency'], id: 'test', siblings: [] })).toBe('LCM'); - }); - - it('returns Diffusion for unknown tags', () => { - expect(service.getImageModelType({ tags: ['some-other-tag'], id: 'test', siblings: [] })).toBe('Diffusion'); - }); - }); - - describe('extractImageModelDescription', () => { - it('returns relevant tags as description', () => { - const desc = service.extractImageModelDescription({ - id: 'test/model', - tags: ['stable-diffusion', 'text-to-image', 'license:apache-2.0'], - siblings: [], - }); - expect(desc).toContain('stable-diffusion'); - expect(desc).toContain('text-to-image'); - // license tags should be filtered out - expect(desc).not.toContain('license:'); - }); - - it('returns default description when no relevant tags', () => { - const desc = service.extractImageModelDescription({ - id: 'test/model', - tags: ['license:mit', 'language:en', 'diffusers'], - siblings: [], - }); - expect(desc).toBe('Image generation model'); - }); - }); describe('extractDescription vision detection', () => { it('detects vision model type', () => { diff --git a/__tests__/unit/services/imageGenerator.test.ts b/__tests__/unit/services/imageGenerator.test.ts index b451917f..0aec4ec5 100644 --- a/__tests__/unit/services/imageGenerator.test.ts +++ b/__tests__/unit/services/imageGenerator.test.ts @@ -348,7 +348,7 @@ describe('ImageGeneratorService', () => { }); }); - it('sets up error listener when onError provided', async () => { + it('does not set up error listener (errors propagate via thrown exception)', async () => { jest.isolateModules(async () => { const rn = require('react-native'); rn.Platform.OS = 'android'; @@ -358,10 +358,9 @@ describe('ImageGeneratorService', () => { }); const { imageGeneratorService } = require('../../../src/services/imageGenerator'); - const onError = jest.fn(); - await imageGeneratorService.generateImage({ prompt: 'test' }, undefined, undefined, onError); + await imageGeneratorService.generateImage({ prompt: 'test' }); - expect(mockAddListener).toHaveBeenCalledWith( + expect(mockAddListener).not.toHaveBeenCalledWith( 'ImageGenerationError', expect.any(Function), ); @@ -405,32 +404,15 @@ describe('ImageGeneratorService', () => { }); }); - it('invokes onError callback with Error object when error event fires', async () => { - let errorHandler: any; - mockAddListener.mockImplementation((event: string, handler: any) => { - if (event === 'ImageGenerationError') { - errorHandler = handler; - } - return { remove: jest.fn() }; - }); - + it('propagates native rejection as a rejected promise', async () => { jest.isolateModules(async () => { const rn = require('react-native'); rn.Platform.OS = 'android'; - mockImageGeneratorModule.generateImage.mockImplementation(async () => { - errorHandler?.({ error: 'GPU memory exceeded' }); - return { - id: 'img-1', prompt: 'test', negativePrompt: '', imagePath: '/p.png', - width: 512, height: 512, steps: 20, seed: 1, createdAt: '2026-01-01', - }; - }); + mockImageGeneratorModule.generateImage.mockRejectedValue(new Error('GPU memory exceeded')); const { imageGeneratorService } = require('../../../src/services/imageGenerator'); - const onError = jest.fn(); - await imageGeneratorService.generateImage({ prompt: 'test' }, undefined, undefined, onError); - - expect(onError).toHaveBeenCalledWith(expect.any(Error)); - expect(onError.mock.calls[0][0].message).toBe('GPU memory exceeded'); + await expect(imageGeneratorService.generateImage({ prompt: 'test' })) + .rejects.toThrow('GPU memory exceeded'); }); }); }); diff --git a/__tests__/unit/services/intentClassifier.test.ts b/__tests__/unit/services/intentClassifier.test.ts index 4bf348a7..371edaf6 100644 --- a/__tests__/unit/services/intentClassifier.test.ts +++ b/__tests__/unit/services/intentClassifier.test.ts @@ -815,7 +815,7 @@ describe('IntentClassifier', () => { }); test('should handle very long messages without errors', async () => { - const longMessage = 'draw a ' + 'very '.repeat(100) + 'beautiful landscape'; + const longMessage = `draw a ${ 'very '.repeat(100) }beautiful landscape`; // Should not throw despite long message const result = await intentClassifier.classifyIntent(longMessage, { useLLM: false }); diff --git a/__tests__/unit/services/llm.test.ts b/__tests__/unit/services/llm.test.ts index c7fe4f48..51808728 100644 --- a/__tests__/unit/services/llm.test.ts +++ b/__tests__/unit/services/llm.test.ts @@ -445,15 +445,6 @@ describe('LLMService', () => { await expect(llmService.generateResponse(messages)).rejects.toThrow('Generation already in progress'); }); - it('calls onThinking callback', async () => { - await setupLoadedModel(); - const messages = [createUserMessage('Hello')]; - const onThinking = jest.fn(); - - await llmService.generateResponse(messages, undefined, undefined, undefined, onThinking); - - expect(onThinking).toHaveBeenCalled(); - }); it('streams tokens via onStream callback', async () => { await setupLoadedModel(); @@ -499,21 +490,6 @@ describe('LLMService', () => { expect(llmService.isCurrentlyGenerating()).toBe(false); }); - it('calls onError callback on failure', async () => { - await setupLoadedModel({ - completion: jest.fn(() => Promise.reject(new Error('gen error'))), - tokenize: jest.fn(() => Promise.resolve({ tokens: [1, 2] })), - }); - - const messages = [createUserMessage('Hello')]; - const onError = jest.fn(); - - await expect( - llmService.generateResponse(messages, undefined, undefined, onError) - ).rejects.toThrow(); - - expect(onError).toHaveBeenCalledWith(expect.any(Error)); - }); it('uses messages format for text-only path', async () => { const ctx = await setupLoadedModel(); @@ -1087,7 +1063,8 @@ describe('LLMService', () => { mockedInitLlama .mockResolvedValueOnce(ctx as any) // initial load .mockRejectedValueOnce(new Error('GPU reload failed')) // GPU attempt - .mockRejectedValueOnce(new Error('CPU reload failed')); // CPU fallback + .mockRejectedValueOnce(new Error('CPU reload failed')) // CPU fallback + .mockRejectedValueOnce(new Error('CPU reload failed')); // ctx=2048 fallback // Enable GPU so both attempts happen useAppStore.setState({ diff --git a/__tests__/unit/services/localDreamGenerator.test.ts b/__tests__/unit/services/localDreamGenerator.test.ts index fcf0cd7c..ad538cad 100644 --- a/__tests__/unit/services/localDreamGenerator.test.ts +++ b/__tests__/unit/services/localDreamGenerator.test.ts @@ -596,30 +596,21 @@ describe('LocalDreamGeneratorService', () => { await first; }); - it('calls onError callback on native failure', async () => { + it('rejects with error on native failure', async () => { mockLocalDreamModule.generateImage.mockRejectedValue(new Error('Core ML failed')); - const onError = jest.fn(); - - await service.generateImage({ prompt: 'test' }, undefined, undefined, undefined, onError) - .catch(() => {}); - - expect(onError).toHaveBeenCalledWith(expect.any(Error)); - expect(onError.mock.calls[0][0].message).toBe('Core ML failed'); + await expect(service.generateImage({ prompt: 'test' })) + .rejects.toThrow('Core ML failed'); }); - it('calls onComplete callback on success', async () => { + it('resolves with GeneratedImage on success', async () => { mockLocalDreamModule.generateImage.mockResolvedValue({ id: 'img-ok', imagePath: '/ok.png', width: 512, height: 512, seed: 7, }); - const onComplete = jest.fn(); + const result = await service.generateImage({ prompt: 'test' }); - await service.generateImage({ prompt: 'test' }, undefined, undefined, onComplete); - - expect(onComplete).toHaveBeenCalledWith( - expect.objectContaining({ id: 'img-ok' }), - ); + expect(result).toEqual(expect.objectContaining({ id: 'img-ok' })); }); it('forwards progress events from emitter', async () => { diff --git a/__tests__/unit/services/modelManager.test.ts b/__tests__/unit/services/modelManager.test.ts index 451b1b2f..3b1f08de 100644 --- a/__tests__/unit/services/modelManager.test.ts +++ b/__tests__/unit/services/modelManager.test.ts @@ -207,11 +207,10 @@ describe('ModelManager', () => { // Mock getDownloadedModels for addDownloadedModel mockedAsyncStorage.getItem.mockResolvedValue('[]'); - const onComplete = jest.fn(); - await modelManager.downloadModel('test-author/test-model', file, undefined, onComplete); + const model = await modelManager.downloadModel('test-author/test-model', file); expect(RNFS.downloadFile).not.toHaveBeenCalled(); - expect(onComplete).toHaveBeenCalled(); + expect(model).toBeDefined(); }); it('downloads via RNFS when file does not exist', async () => { @@ -340,11 +339,10 @@ describe('ModelManager', () => { } as any); mockedAsyncStorage.getItem.mockResolvedValue('[]'); - const onComplete = jest.fn(); // Should not throw - mmproj failure is not fatal - await modelManager.downloadModel('test-author/test-model', visionFile, undefined, onComplete); + const model = await modelManager.downloadModel('test-author/test-model', visionFile); - expect(onComplete).toHaveBeenCalled(); + expect(model).toBeDefined(); }); it('calls onComplete with model when done', async () => { @@ -360,15 +358,12 @@ describe('ModelManager', () => { } as any); mockedAsyncStorage.getItem.mockResolvedValue('[]'); - const onComplete = jest.fn(); - await modelManager.downloadModel('test-author/test-model', file, undefined, onComplete); + const model = await modelManager.downloadModel('test-author/test-model', file); - expect(onComplete).toHaveBeenCalledWith( - expect.objectContaining({ - fileName: 'test-model-q4.gguf', - quantization: 'Q4_K_M', - }) - ); + expect(model).toMatchObject({ + fileName: 'test-model-q4.gguf', + quantization: 'Q4_K_M', + }); }); }); @@ -412,14 +407,14 @@ describe('ModelManager', () => { describe('deleteModel', () => { it('deletes file and updates storage', async () => { const storedModels = [ - { id: 'model1', name: 'Model 1', filePath: '/models/m1.gguf', fileSize: 100 }, + { id: 'model1', name: 'Model 1', filePath: '/mock/documents/models/m1.gguf', fileSize: 100 }, ]; mockedAsyncStorage.getItem.mockResolvedValue(JSON.stringify(storedModels)); mockedRNFS.exists.mockResolvedValue(true); await modelManager.deleteModel('model1'); - expect(RNFS.unlink).toHaveBeenCalledWith('/models/m1.gguf'); + expect(RNFS.unlink).toHaveBeenCalledWith('/mock/documents/models/m1.gguf'); // Storage should be updated with empty list expect(AsyncStorage.setItem).toHaveBeenCalledWith( MODELS_STORAGE_KEY, @@ -432,9 +427,9 @@ describe('ModelManager', () => { { id: 'model1', name: 'Model 1', - filePath: '/models/m1.gguf', + filePath: '/mock/documents/models/m1.gguf', fileSize: 100, - mmProjPath: '/models/mmproj.gguf', + mmProjPath: '/mock/documents/models/mmproj.gguf', }, ]; mockedAsyncStorage.getItem.mockResolvedValue(JSON.stringify(storedModels)); @@ -442,8 +437,8 @@ describe('ModelManager', () => { await modelManager.deleteModel('model1'); - expect(RNFS.unlink).toHaveBeenCalledWith('/models/m1.gguf'); - expect(RNFS.unlink).toHaveBeenCalledWith('/models/mmproj.gguf'); + expect(RNFS.unlink).toHaveBeenCalledWith('/mock/documents/models/m1.gguf'); + expect(RNFS.unlink).toHaveBeenCalledWith('/mock/documents/models/mmproj.gguf'); }); it('throws when model not found', async () => { @@ -639,7 +634,8 @@ describe('ModelManager', () => { mockedAsyncStorage.getItem.mockResolvedValue('[]'); const onComplete = jest.fn(); - const result = await modelManager.downloadModelBackground('test/model', file, undefined, onComplete); + const result = await modelManager.downloadModelBackground('test/model', file); + modelManager.watchDownload(result.downloadId, onComplete); expect(result.status).toBe('completed'); expect(onComplete).toHaveBeenCalled(); @@ -670,7 +666,7 @@ describe('ModelManager', () => { expect(result.downloadId).toBe(42); }); - it('sets up progress/complete/error listeners', async () => { + it('sets up progress listener during start and complete/error via watchDownload', async () => { mockedBackgroundDownloadService.isAvailable.mockReturnValue(true); mockedRNFS.exists .mockResolvedValueOnce(true) @@ -688,7 +684,8 @@ describe('ModelManager', () => { startedAt: Date.now(), } as any); - await modelManager.downloadModelBackground('test/model', file); + const info = await modelManager.downloadModelBackground('test/model', file); + modelManager.watchDownload(info.downloadId, jest.fn(), jest.fn()); expect(mockedBackgroundDownloadService.onProgress).toHaveBeenCalledWith(42, expect.any(Function)); expect(mockedBackgroundDownloadService.onComplete).toHaveBeenCalledWith(42, expect.any(Function)); @@ -1442,13 +1439,9 @@ describe('ModelManager', () => { ]; mockedAsyncStorage.getItem.mockResolvedValue(JSON.stringify(storedModels)); mockedRNFS.exists.mockResolvedValue(true); + mockedRNFS.stat.mockResolvedValue({ size: 300000000 } as any); - await modelManager.saveModelWithMmproj( - 'model1', - '/models/mmproj.gguf', - 'mmproj.gguf', - 300000000 - ); + await modelManager.saveModelWithMmproj('model1', '/models/mmproj.gguf'); const savedCall = mockedAsyncStorage.setItem.mock.calls.find( (call) => call[0] === MODELS_STORAGE_KEY @@ -1459,14 +1452,15 @@ describe('ModelManager', () => { expect(savedModels[0].isVisionModel).toBe(true); }); - it('handles string mmProjFileSize', async () => { + it('derives mmProjFileSize from RNFS.stat', async () => { const storedModels = [ { id: 'model1', name: 'Test', filePath: '/models/m1.gguf', fileName: 'm1.gguf', fileSize: 1000 }, ]; mockedAsyncStorage.getItem.mockResolvedValue(JSON.stringify(storedModels)); mockedRNFS.exists.mockResolvedValue(true); + mockedRNFS.stat.mockResolvedValue({ size: 300000000 } as any); - await modelManager.saveModelWithMmproj('model1', '/models/mmproj.gguf', 'mmproj.gguf', '300000000' as any); + await modelManager.saveModelWithMmproj('model1', '/models/mmproj.gguf'); const savedCall = mockedAsyncStorage.setItem.mock.calls.find( (call) => call[0] === MODELS_STORAGE_KEY @@ -1543,9 +1537,9 @@ describe('ModelManager', () => { { id: 'model1', name: 'Model 1', - filePath: '/models/m1.gguf', + filePath: '/mock/documents/models/m1.gguf', fileSize: 100, - mmProjPath: '/models/mmproj.gguf', + mmProjPath: '/mock/documents/models/mmproj.gguf', }, ]; mockedAsyncStorage.getItem.mockResolvedValue(JSON.stringify(storedModels)); @@ -1560,7 +1554,7 @@ describe('ModelManager', () => { await modelManager.deleteModel('model1'); // Main file should have been unlinked - expect(RNFS.unlink).toHaveBeenCalledWith('/models/m1.gguf'); + expect(RNFS.unlink).toHaveBeenCalledWith('/mock/documents/models/m1.gguf'); }); }); @@ -1797,7 +1791,8 @@ describe('ModelManager', () => { }); const onComplete = jest.fn(); - await modelManager.downloadModelBackground('test/model', visionFile, undefined, onComplete); + const info = await modelManager.downloadModelBackground('test/model', visionFile); + modelManager.watchDownload(info.downloadId, onComplete); // Simulate the complete event if (completeCallback) { @@ -1845,7 +1840,8 @@ describe('ModelManager', () => { }); const onError = jest.fn(); - await modelManager.downloadModelBackground('test/model', file, undefined, undefined, onError); + const info = await modelManager.downloadModelBackground('test/model', file); + modelManager.watchDownload(info.downloadId, undefined, onError); // Simulate the error event if (errorCallback) { @@ -1855,8 +1851,8 @@ describe('ModelManager', () => { }); }); - describe('downloadModel onError callback', () => { - it('calls onError when download fails', async () => { + describe('downloadModel error handling', () => { + it('throws when download fails', async () => { const file = createModelFile({ name: 'error-model.gguf', size: 4000000000, @@ -1875,14 +1871,9 @@ describe('ModelManager', () => { promise: Promise.reject(new Error('Network failure')), } as any); - const onError = jest.fn(); - await expect( - modelManager.downloadModel('test/model', file, undefined, undefined, onError) - ).rejects.toThrow(); - - // onError should have been called - expect(onError).toHaveBeenCalled(); + modelManager.downloadModel('test/model', file), + ).rejects.toThrow('Network failure'); }); }); @@ -1970,7 +1961,7 @@ describe('ModelManager', () => { id: 'img-delete', name: 'Delete Me', description: 'Test', - modelPath: '/mock/image_models/delete-model', + modelPath: '/mock/documents/image_models/delete-model', size: 2000000000, downloadedAt: new Date().toISOString(), backend: 'mnn', @@ -1989,7 +1980,7 @@ describe('ModelManager', () => { id: 'img-no-file', name: 'No File', description: 'Test', - modelPath: '/mock/image_models/missing', + modelPath: '/mock/documents/image_models/missing', size: 1000, downloadedAt: new Date().toISOString(), backend: 'mnn', diff --git a/__tests__/unit/stores/appStore.test.ts b/__tests__/unit/stores/appStore.test.ts index 9812b0e4..e66b7c61 100644 --- a/__tests__/unit/stores/appStore.test.ts +++ b/__tests__/unit/stores/appStore.test.ts @@ -1284,9 +1284,9 @@ describe('appStore', () => { setDownloadProgress('m1', { progress: 0.9, bytesDownloaded: 900, totalBytes: 1000 }); const progress = getAppState().downloadProgress; - expect(progress['m1'].progress).toBe(0.9); - expect(progress['m2']).toBeUndefined(); - expect(progress['m3'].progress).toBe(0.3); + expect(progress.m1.progress).toBe(0.9); + expect(progress.m2).toBeUndefined(); + expect(progress.m3.progress).toBe(0.3); }); it('handles model add and remove in sequence', () => { diff --git a/__tests__/unit/stores/chatStore.test.ts b/__tests__/unit/stores/chatStore.test.ts index e115740a..791a6af7 100644 --- a/__tests__/unit/stores/chatStore.test.ts +++ b/__tests__/unit/stores/chatStore.test.ts @@ -279,8 +279,7 @@ describe('chatStore', () => { const attachment = createMediaAttachment({ type: 'image' }); const message = addMessage( convId, - { role: 'user', content: 'Check this image' }, - [attachment] + { role: 'user', content: 'Check this image', attachments: [attachment] }, ); expect(message.attachments).toHaveLength(1); @@ -293,9 +292,7 @@ describe('chatStore', () => { const convId = createConversation('test-model'); const message = addMessage( convId, - { role: 'assistant', content: 'Response' }, - undefined, - 1500 + { role: 'assistant', content: 'Response', generationTimeMs: 1500 }, ); expect(message.generationTimeMs).toBe(1500); @@ -308,10 +305,7 @@ describe('chatStore', () => { const meta = createGenerationMeta({ gpu: true, tokensPerSecond: 25.5 }); const message = addMessage( convId, - { role: 'assistant', content: 'Response' }, - undefined, - 1000, - meta + { role: 'assistant', content: 'Response', generationTimeMs: 1000, generationMeta: meta }, ); expect(message.generationMeta?.gpu).toBe(true); @@ -331,26 +325,26 @@ describe('chatStore', () => { }); }); - describe('updateMessage', () => { + describe('updateMessageContent', () => { it('updates message content', () => { - const { createConversation, addMessage, updateMessage } = useChatStore.getState(); + const { createConversation, addMessage, updateMessageContent } = useChatStore.getState(); const convId = createConversation('test-model'); const message = addMessage(convId, { role: 'user', content: 'Original' }); - updateMessage(convId, message.id, 'Updated'); + updateMessageContent(convId, message.id, 'Updated'); expect(getChatState().conversations[0].messages[0].content).toBe('Updated'); }); it('preserves other message properties', () => { - const { createConversation, addMessage, updateMessage } = useChatStore.getState(); + const { createConversation, addMessage, updateMessageContent } = useChatStore.getState(); const convId = createConversation('test-model'); const message = addMessage(convId, { role: 'user', content: 'Original' }); const originalTimestamp = message.timestamp; - updateMessage(convId, message.id, 'Updated'); + updateMessageContent(convId, message.id, 'Updated'); const updatedMessage = getChatState().conversations[0].messages[0]; expect(updatedMessage.id).toBe(message.id); @@ -737,8 +731,7 @@ describe('chatStore', () => { const message = store.addMessage( convId, - { role: 'user', content: 'Look at these' }, - attachments, + { role: 'user', content: 'Look at these', attachments }, ); expect(message.attachments).toHaveLength(3); @@ -748,29 +741,29 @@ describe('chatStore', () => { }); // ============================================================================ - // updateMessage Edge Cases + // updateMessageThinking Edge Cases // ============================================================================ - describe('updateMessage edge cases', () => { - it('sets isThinking flag when provided', () => { + describe('updateMessageThinking edge cases', () => { + it('sets isThinking flag to true', () => { const store = useChatStore.getState(); const convId = store.createConversation('test-model'); const msg = store.addMessage(convId, { role: 'assistant', content: 'Thinking...' }); - store.updateMessage(convId, msg.id, 'Still thinking...', true); + store.updateMessageThinking(convId, msg.id, true); const updated = getChatState().conversations[0].messages[0]; expect(updated.isThinking).toBe(true); }); - it('does not add isThinking when not provided', () => { + it('sets isThinking flag to false', () => { const store = useChatStore.getState(); const convId = store.createConversation('test-model'); - const msg = store.addMessage(convId, { role: 'assistant', content: 'Original' }); + const msg = store.addMessage(convId, { role: 'assistant', content: 'Original', isThinking: true }); - store.updateMessage(convId, msg.id, 'Updated'); + store.updateMessageThinking(convId, msg.id, false); const updated = getChatState().conversations[0].messages[0]; - expect(updated.isThinking).toBeUndefined(); + expect(updated.isThinking).toBe(false); }); }); diff --git a/__tests__/unit/utils/messageContent.test.ts b/__tests__/unit/utils/messageContent.test.ts index e78c6b41..b336d7cc 100644 --- a/__tests__/unit/utils/messageContent.test.ts +++ b/__tests__/unit/utils/messageContent.test.ts @@ -152,7 +152,7 @@ describe('stripControlTokens', () => { }); it('handles very long content efficiently', () => { - const longContent = 'word '.repeat(10000) + '<|im_end|>'; + const longContent = `${'word '.repeat(10000) }<|im_end|>`; const result = stripControlTokens(longContent); expect(result).not.toContain('<|im_end|>'); expect(result.trim().split(' ')).toHaveLength(10000); @@ -166,13 +166,13 @@ describe('stripControlTokens', () => { it('handles incremental stripping (simulating streaming)', () => { let accumulated = ''; - accumulated = stripControlTokens(accumulated + 'Hello'); + accumulated = stripControlTokens(`${accumulated }Hello`); expect(accumulated).toBe('Hello'); - accumulated = stripControlTokens(accumulated + ' world'); + accumulated = stripControlTokens(`${accumulated } world`); expect(accumulated).toBe('Hello world'); - accumulated = stripControlTokens(accumulated + '<|im_end|>'); + accumulated = stripControlTokens(`${accumulated }<|im_end|>`); expect(accumulated).toBe('Hello world'); }); @@ -180,7 +180,7 @@ describe('stripControlTokens', () => { // In real streaming, a token like <|im_end|> arrives as a single token // but the accumulated string is re-stripped each time let accumulated = 'Response text'; - accumulated = stripControlTokens(accumulated + '<|im_end|>'); + accumulated = stripControlTokens(`${accumulated }<|im_end|>`); expect(accumulated).toBe('Response text'); }); }); diff --git a/__tests__/utils/testHelpers.ts b/__tests__/utils/testHelpers.ts index 8f26e686..2499e845 100644 --- a/__tests__/utils/testHelpers.ts +++ b/__tests__/utils/testHelpers.ts @@ -375,7 +375,7 @@ export const simulateGeneration = async ( const tokens = responseContent.split(' '); for (const token of tokens) { await flushPromises(); - chatStore.appendToStreamingMessage(token + ' '); + chatStore.appendToStreamingMessage(`${token } `); } // Finalize diff --git a/android/app/build.gradle b/android/app/build.gradle index cf1ce07c..fb18808a 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -111,6 +111,15 @@ android { dimension "store" } } + lint { + baseline = file("lint-baseline.xml") + } + testOptions { + unitTests { + includeAndroidResources = true + } + } + buildTypes { debug { signingConfig signingConfigs.debug @@ -148,6 +157,12 @@ dependencies { // PDF text extraction (used by PDFExtractorModule) implementation("io.legere:pdfiumandroid:1.0.35") + + testImplementation("junit:junit:4.13.2") + testImplementation("org.robolectric:robolectric:4.13") + testImplementation("org.mockito:mockito-core:5.11.0") + testImplementation("org.mockito.kotlin:mockito-kotlin:5.4.0") + testImplementation("androidx.test:core:1.6.1") } // Exclude Google Play Install Referrer only for F-Droid builds (non-free, flagged by F-Droid) diff --git a/android/app/lint-baseline.xml b/android/app/lint-baseline.xml new file mode 100644 index 00000000..fdc37fa8 --- /dev/null +++ b/android/app/lint-baseline.xml @@ -0,0 +1,517 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/ai/offgridmobile/download/DownloadCompleteBroadcastReceiver.kt b/android/app/src/main/java/ai/offgridmobile/download/DownloadCompleteBroadcastReceiver.kt index 755b3c39..a027a95f 100644 --- a/android/app/src/main/java/ai/offgridmobile/download/DownloadCompleteBroadcastReceiver.kt +++ b/android/app/src/main/java/ai/offgridmobile/download/DownloadCompleteBroadcastReceiver.kt @@ -18,77 +18,63 @@ import org.json.JSONObject class DownloadCompleteBroadcastReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { - if (intent.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE) { - return - } + if (intent.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE) return val downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) - if (downloadId == -1L) { - return - } + if (downloadId == -1L) return val sharedPrefs = context.getSharedPreferences( DownloadManagerModule.PREFS_NAME, Context.MODE_PRIVATE ) + val downloads = parseDownloads(sharedPrefs.getString(DownloadManagerModule.DOWNLOADS_KEY, "[]")) ?: return + val (downloadInfo, downloadIndex) = findDownload(downloads, downloadId) ?: return - val downloadsJson = sharedPrefs.getString(DownloadManagerModule.DOWNLOADS_KEY, "[]") ?: "[]" - val downloads = try { - JSONArray(downloadsJson) - } catch (e: Exception) { - return - } + val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + applyDownloadResult(downloadManager, downloadId, downloadInfo) ?: return - // Find the download in our tracked list - var downloadInfo: JSONObject? = null - var downloadIndex = -1 + downloads.put(downloadIndex, downloadInfo) + sharedPrefs.edit() + .putString(DownloadManagerModule.DOWNLOADS_KEY, downloads.toString()) + .apply() + } + + private fun parseDownloads(json: String?): JSONArray? = try { + JSONArray(json ?: "[]") + } catch (e: Exception) { + null + } + + private fun findDownload(downloads: JSONArray, downloadId: Long): Pair? { for (i in 0 until downloads.length()) { val download = downloads.getJSONObject(i) - if (download.getLong("downloadId") == downloadId) { - downloadInfo = download - downloadIndex = i - break - } + if (download.getLong("downloadId") == downloadId) return Pair(download, i) } + return null + } - if (downloadInfo == null) { - // Not a download we're tracking - return - } - - // Query the DownloadManager for the result - val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager - val query = DownloadManager.Query().setFilterById(downloadId) - val cursor: Cursor? = downloadManager.query(query) - - cursor?.use { - if (it.moveToFirst()) { - val statusIdx = it.getColumnIndex(DownloadManager.COLUMN_STATUS) - val localUriIdx = it.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI) - val reasonIdx = it.getColumnIndex(DownloadManager.COLUMN_REASON) - - val status = if (statusIdx >= 0) it.getInt(statusIdx) else -1 - val localUri = if (localUriIdx >= 0) it.getString(localUriIdx) else null - val reason = if (reasonIdx >= 0) it.getInt(reasonIdx) else 0 - - when (status) { - DownloadManager.STATUS_SUCCESSFUL -> { - downloadInfo.put("status", "completed") - downloadInfo.put("localUri", localUri ?: "") - downloadInfo.put("completedAt", System.currentTimeMillis()) - } - DownloadManager.STATUS_FAILED -> { - downloadInfo.put("status", "failed") - downloadInfo.put("failureReason", reasonToString(reason)) - downloadInfo.put("completedAt", System.currentTimeMillis()) - } + private fun applyDownloadResult( + downloadManager: DownloadManager, + downloadId: Long, + downloadInfo: JSONObject, + ): Unit? { + val cursor = downloadManager.query(DownloadManager.Query().setFilterById(downloadId)) + return cursor?.use { + if (!it.moveToFirst()) return@use null + val status = it.getColumnIndex(DownloadManager.COLUMN_STATUS).let { idx -> if (idx >= 0) it.getInt(idx) else -1 } + val localUri = it.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI).let { idx -> if (idx >= 0) it.getString(idx) else null } + val reason = it.getColumnIndex(DownloadManager.COLUMN_REASON).let { idx -> if (idx >= 0) it.getInt(idx) else 0 } + when (status) { + DownloadManager.STATUS_SUCCESSFUL -> { + downloadInfo.put("status", "completed") + downloadInfo.put("localUri", localUri ?: "") + downloadInfo.put("completedAt", System.currentTimeMillis()) + } + DownloadManager.STATUS_FAILED -> { + downloadInfo.put("status", "failed") + downloadInfo.put("failureReason", reasonToString(reason)) + downloadInfo.put("completedAt", System.currentTimeMillis()) } - - // Update the download in our list - downloads.put(downloadIndex, downloadInfo) - sharedPrefs.edit() - .putString(DownloadManagerModule.DOWNLOADS_KEY, downloads.toString()) - .apply() } } } diff --git a/android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt b/android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt index 0f7faf70..f07017a1 100644 --- a/android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt +++ b/android/app/src/main/java/ai/offgridmobile/download/DownloadManagerModule.kt @@ -22,6 +22,66 @@ class DownloadManagerModule(reactContext: ReactApplicationContext) : const val PREFS_NAME = "OffgridMobileDownloads" const val DOWNLOADS_KEY = "active_downloads" private const val POLL_INTERVAL_MS = 500L + + internal fun statusToString(status: Int): String = when (status) { + DownloadManager.STATUS_PENDING -> "pending" + DownloadManager.STATUS_RUNNING -> "running" + DownloadManager.STATUS_PAUSED -> "paused" + DownloadManager.STATUS_SUCCESSFUL -> "completed" + DownloadManager.STATUS_FAILED -> "failed" + else -> "unknown" + } + + internal fun reasonToString(status: Int, reason: Int): String { + if (status == DownloadManager.STATUS_PAUSED) { + return when (reason) { + DownloadManager.PAUSED_QUEUED_FOR_WIFI -> "Waiting for WiFi" + DownloadManager.PAUSED_WAITING_FOR_NETWORK -> "Waiting for network" + DownloadManager.PAUSED_WAITING_TO_RETRY -> "Waiting to retry" + else -> "Paused" + } + } + if (status == DownloadManager.STATUS_FAILED) { + return when (reason) { + DownloadManager.ERROR_CANNOT_RESUME -> "Cannot resume" + DownloadManager.ERROR_DEVICE_NOT_FOUND -> "Device not found" + DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "File already exists" + DownloadManager.ERROR_FILE_ERROR -> "File error" + DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP data error" + DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Insufficient space" + DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "Too many redirects" + DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "Unhandled HTTP code" + DownloadManager.ERROR_UNKNOWN -> "Unknown error" + else -> "Error: $reason" + } + } + return "" + } + + /** + * Returns true if the given download entry should be pruned from the persisted list. + * + * A download is removed when: + * - [liveStatus] is "unknown" (DownloadManager no longer tracks it), OR + * - its stored status is "completed", the completion event has been sent, the + * completedAt timestamp is set, and the entry is older than 5 seconds. + * + * The [currentTimeMs] parameter is injectable so tests can control the clock. + */ + internal fun shouldRemoveDownload( + download: JSONObject, + liveStatus: String, + currentTimeMs: Long = System.currentTimeMillis(), + ): Boolean { + if (liveStatus == "unknown") return true + if (download.optString("status", "pending") == "completed") { + val completedAt = download.optLong("completedAt", 0L) + val eventSent = download.optBoolean("completedEventSent", false) + val ageMs = currentTimeMs - completedAt + return completedAt > 0 && eventSent && ageMs > 5_000 + } + return false + } } private val downloadManager: DownloadManager by lazy { @@ -414,41 +474,6 @@ class DownloadManagerModule(reactContext: ReactApplicationContext) : return result } - private fun statusToString(status: Int): String = when (status) { - DownloadManager.STATUS_PENDING -> "pending" - DownloadManager.STATUS_RUNNING -> "running" - DownloadManager.STATUS_PAUSED -> "paused" - DownloadManager.STATUS_SUCCESSFUL -> "completed" - DownloadManager.STATUS_FAILED -> "failed" - else -> "unknown" - } - - private fun reasonToString(status: Int, reason: Int): String { - if (status == DownloadManager.STATUS_PAUSED) { - return when (reason) { - DownloadManager.PAUSED_QUEUED_FOR_WIFI -> "Waiting for WiFi" - DownloadManager.PAUSED_WAITING_FOR_NETWORK -> "Waiting for network" - DownloadManager.PAUSED_WAITING_TO_RETRY -> "Waiting to retry" - else -> "Paused" - } - } - if (status == DownloadManager.STATUS_FAILED) { - return when (reason) { - DownloadManager.ERROR_CANNOT_RESUME -> "Cannot resume" - DownloadManager.ERROR_DEVICE_NOT_FOUND -> "Device not found" - DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "File already exists" - DownloadManager.ERROR_FILE_ERROR -> "File error" - DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP data error" - DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Insufficient space" - DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "Too many redirects" - DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "Unhandled HTTP code" - DownloadManager.ERROR_UNKNOWN -> "Unknown error" - else -> "Error: $reason" - } - } - return "" - } - private fun sendEvent(eventName: String, params: WritableMap) { reactApplicationContext .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) @@ -532,26 +557,14 @@ class DownloadManagerModule(reactContext: ReactApplicationContext) : val status = statusInfo.getString("status") val previousStatus = download.optString("status", "pending") - // Remove if DownloadManager doesn't know about it anymore - if (status == "unknown") { - android.util.Log.d("DownloadManager", "Cleanup: removing unknown download $downloadId") + if (shouldRemoveDownload(download, status ?: "unknown")) { + android.util.Log.d("DownloadManager", "Cleanup: removing download $downloadId (liveStatus=$status, storedStatus=$previousStatus)") removedCount++ continue } - // Remove completed entries that are stale (older than 5s) AND have sent the event - if (previousStatus == "completed") { - val completedAt = download.optLong("completedAt", 0L) - val eventSent = download.optBoolean("completedEventSent", false) - val ageMs = System.currentTimeMillis() - completedAt - // Only remove if event was sent and it's been long enough - if (completedAt > 0 && eventSent && ageMs > 5_000) { - android.util.Log.d("DownloadManager", "Cleanup: removing stale completed download $downloadId (${ageMs/1000}s old)") - removedCount++ - continue - } else if (completedAt > 0 && !eventSent) { - android.util.Log.w("DownloadManager", "Cleanup: found completed download $downloadId without event sent - will retry in polling") - } + if (previousStatus == "completed" && download.optLong("completedAt", 0L) > 0 && !download.optBoolean("completedEventSent", false)) { + android.util.Log.w("DownloadManager", "Cleanup: found completed download $downloadId without event sent - will retry in polling") } cleanedDownloads.put(download) diff --git a/android/app/src/main/java/ai/offgridmobile/localdream/LocalDreamModule.kt b/android/app/src/main/java/ai/offgridmobile/localdream/LocalDreamModule.kt index 2ac693db..6ca3362c 100644 --- a/android/app/src/main/java/ai/offgridmobile/localdream/LocalDreamModule.kt +++ b/android/app/src/main/java/ai/offgridmobile/localdream/LocalDreamModule.kt @@ -41,6 +41,159 @@ class LocalDreamModule(reactContext: ReactApplicationContext) : private const val EVENT_PROGRESS = "LocalDreamProgress" private const val EVENT_ERROR = "LocalDreamError" + + internal fun isNpuSupportedInternal(): Boolean { + val soc = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + Build.SOC_MODEL + } else { + "" + } + return soc.startsWith("SM") || soc.startsWith("QCS") || soc.startsWith("QCM") + } + + internal fun resolveModelDir(dir: File, isCpu: Boolean): File? { + val markerFile = if (isCpu) "unet.mnn" else "unet.bin" + + if (File(dir, markerFile).exists()) return dir + + fun searchDir(current: File, depth: Int): File? { + if (depth > 3) return null + current.listFiles()?.filter { it.isDirectory }?.forEach { subDir -> + if (File(subDir, markerFile).exists()) { + Log.d(TAG, "Found $markerFile in: ${subDir.absolutePath}") + return subDir + } + val deeper = searchDir(subDir, depth + 1) + if (deeper != null) return deeper + } + return null + } + + return searchDir(dir, 0) + } + + internal fun detectTextEmbeddingSize(modelDir: File, isCpu: Boolean): String { + // SD1.5 models always use 768 + return "768" + } + + internal fun buildCommand( + executable: File, + modelDir: File, + runtimeDir: File, + isCpu: Boolean, + ): List { + val embeddingSize = detectTextEmbeddingSize(modelDir, isCpu) + Log.d(TAG, "Detected text_embedding_size: $embeddingSize") + + return if (isCpu) { + // MNN CPU backend + // IMPORTANT: Always pass "clip.mnn" even if only clip_v2.mnn exists. + // The binary auto-detects clip_v2.mnn in the same directory when the + // --clip path ends with "clip.mnn", and loads pos_emb.bin + token_emb.bin. + // Passing clip_v2.mnn directly bypasses this and causes a segfault. + mutableListOf( + executable.absolutePath, + "--clip", File(modelDir, "clip.mnn").absolutePath, + "--unet", File(modelDir, "unet.mnn").absolutePath, + "--vae_decoder", File(modelDir, "vae_decoder.mnn").absolutePath, + "--tokenizer", File(modelDir, "tokenizer.json").absolutePath, + "--port", SERVER_PORT.toString(), + "--text_embedding_size", embeddingSize, + "--cpu", + ).also { cmd -> + val vaeEncoder = File(modelDir, "vae_encoder.mnn") + if (vaeEncoder.exists()) { + cmd.addAll(listOf("--vae_encoder", vaeEncoder.absolutePath)) + } + } + } else { + // QNN NPU backend + // Same clip.mnn rule applies for QNN — binary auto-detects clip_v2 + val hasMnnClip = File(modelDir, "clip.mnn").exists() || File(modelDir, "clip_v2.mnn").exists() + val clipFile = if (hasMnnClip) "clip.mnn" else "clip.bin" + + mutableListOf( + executable.absolutePath, + "--clip", File(modelDir, clipFile).absolutePath, + "--unet", File(modelDir, "unet.bin").absolutePath, + "--vae_decoder", File(modelDir, "vae_decoder.bin").absolutePath, + "--tokenizer", File(modelDir, "tokenizer.json").absolutePath, + "--backend", File(runtimeDir, "libQnnHtp.so").absolutePath, + "--system_library", File(runtimeDir, "libQnnSystem.so").absolutePath, + "--port", SERVER_PORT.toString(), + "--text_embedding_size", embeddingSize, + ).also { cmd -> + if (hasMnnClip) { + cmd.add("--use_cpu_clip") + } + val vaeEncoder = File(modelDir, "vae_encoder.bin") + if (vaeEncoder.exists()) { + cmd.addAll(listOf("--vae_encoder", vaeEncoder.absolutePath)) + } + } + } + } + + internal fun saveRgbToPng(base64Rgb: String, width: Int, height: Int, outputPath: String) { + val rgbBytes = Base64.decode(base64Rgb, Base64.DEFAULT) + val expectedSize = width * height * 3 + if (rgbBytes.size != expectedSize) { + throw IllegalArgumentException( + "RGB data size ${rgbBytes.size} doesn't match expected $expectedSize (${width}x${height}x3)" + ) + } + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + val pixels = IntArray(width * height) + + for (i in 0 until width * height) { + val idx = i * 3 + val r = rgbBytes[idx].toInt() and 0xFF + val g = rgbBytes[idx + 1].toInt() and 0xFF + val b = rgbBytes[idx + 2].toInt() and 0xFF + pixels[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b + } + + bitmap.setPixels(pixels, 0, width, 0, 0, width, height) + + File(outputPath).parentFile?.mkdirs() + FileOutputStream(outputPath).use { out -> + bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) + } + bitmap.recycle() + } + + internal fun buildEnvironment(runtimeDir: File): Map { + val env = mutableMapOf() + + val systemLibPaths = mutableListOf( + runtimeDir.absolutePath, + "/system/lib64", + "/vendor/lib64", + "/vendor/lib64/egl", + ) + + try { + val maliSymlink = File("/system/vendor/lib64/egl/libGLES_mali.so") + if (maliSymlink.exists()) { + val realPath = maliSymlink.canonicalPath + val soc = realPath.split("/").getOrNull(realPath.split("/").size - 2) + if (soc != null) { + listOf("/vendor/lib64/$soc", "/vendor/lib64/egl/$soc").forEach { path -> + if (!systemLibPaths.contains(path)) systemLibPaths.add(path) + } + } + } + } catch (e: Exception) { + Log.w(TAG, "Failed to resolve Mali paths: ${e.message}") + } + + env["LD_LIBRARY_PATH"] = systemLibPaths.joinToString(":") + env["DSP_LIBRARY_PATH"] = runtimeDir.absolutePath + env["ADSP_LIBRARY_PATH"] = runtimeDir.absolutePath + + return env + } } private var serverProcess: Process? = null @@ -127,41 +280,6 @@ class LocalDreamModule(reactContext: ReactApplicationContext) : * This function checks for model files at the root, and if not found, * looks one level deep for a subdirectory that contains them. */ - private fun resolveModelDir(dir: File, isCpu: Boolean): File? { - val markerFile = if (isCpu) "unet.mnn" else "unet.bin" - - // Check root level - if (File(dir, markerFile).exists()) { - return dir - } - - // Recursively search up to 3 levels deep for the marker file - // NPU zips can extract as: model_dir/output_512/qnn_models_min/unet.bin - fun searchDir(current: File, depth: Int): File? { - if (depth > 3) return null - current.listFiles()?.filter { it.isDirectory }?.forEach { subDir -> - if (File(subDir, markerFile).exists()) { - Log.d(TAG, "Found $markerFile in: ${subDir.absolutePath}") - return subDir - } - val deeper = searchDir(subDir, depth + 1) - if (deeper != null) return deeper - } - return null - } - - return searchDir(dir, 0) - } - - private fun isNpuSupportedInternal(): Boolean { - val soc = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - Build.SOC_MODEL - } else { - "" - } - return soc.startsWith("SM") || soc.startsWith("QCS") || soc.startsWith("QCM") - } - // ===================================================================== // Process Lifecycle // ===================================================================== @@ -362,107 +480,6 @@ class LocalDreamModule(reactContext: ReactApplicationContext) : * Note: "clip_v2" refers to MNN model format v2 (separate weight files), * NOT CLIP architecture v2. The embedding dimension is still 768. */ - private fun detectTextEmbeddingSize(modelDir: File, isCpu: Boolean): String { - // SD1.5 models always use 768 - return "768" - } - - private fun buildCommand( - executable: File, - modelDir: File, - runtimeDir: File, - isCpu: Boolean - ): List { - val embeddingSize = detectTextEmbeddingSize(modelDir, isCpu) - Log.d(TAG, "Detected text_embedding_size: $embeddingSize") - - return if (isCpu) { - // MNN CPU backend - // IMPORTANT: Always pass "clip.mnn" even if only clip_v2.mnn exists. - // The binary auto-detects clip_v2.mnn in the same directory when the - // --clip path ends with "clip.mnn", and loads pos_emb.bin + token_emb.bin. - // Passing clip_v2.mnn directly bypasses this and causes a segfault. - mutableListOf( - executable.absolutePath, - "--clip", File(modelDir, "clip.mnn").absolutePath, - "--unet", File(modelDir, "unet.mnn").absolutePath, - "--vae_decoder", File(modelDir, "vae_decoder.mnn").absolutePath, - "--tokenizer", File(modelDir, "tokenizer.json").absolutePath, - "--port", SERVER_PORT.toString(), - "--text_embedding_size", embeddingSize, - "--cpu" - ).also { cmd -> - // Add vae_encoder if present (for img2img) - val vaeEncoder = File(modelDir, "vae_encoder.mnn") - if (vaeEncoder.exists()) { - cmd.addAll(listOf("--vae_encoder", vaeEncoder.absolutePath)) - } - } - } else { - // QNN NPU backend - // Same clip.mnn rule applies for QNN — binary auto-detects clip_v2 - val hasMnnClip = File(modelDir, "clip.mnn").exists() || File(modelDir, "clip_v2.mnn").exists() - val clipFile = if (hasMnnClip) "clip.mnn" else "clip.bin" - - mutableListOf( - executable.absolutePath, - "--clip", File(modelDir, clipFile).absolutePath, - "--unet", File(modelDir, "unet.bin").absolutePath, - "--vae_decoder", File(modelDir, "vae_decoder.bin").absolutePath, - "--tokenizer", File(modelDir, "tokenizer.json").absolutePath, - "--backend", File(runtimeDir, "libQnnHtp.so").absolutePath, - "--system_library", File(runtimeDir, "libQnnSystem.so").absolutePath, - "--port", SERVER_PORT.toString(), - "--text_embedding_size", embeddingSize - ).also { cmd -> - // Use CPU clip if .mnn clip exists (common for NPU models) - if (hasMnnClip) { - cmd.add("--use_cpu_clip") - } - // Add vae_encoder if present - val vaeEncoder = File(modelDir, "vae_encoder.bin") - if (vaeEncoder.exists()) { - cmd.addAll(listOf("--vae_encoder", vaeEncoder.absolutePath)) - } - } - } - } - - private fun buildEnvironment(runtimeDir: File): Map { - val env = mutableMapOf() - - val systemLibPaths = mutableListOf( - runtimeDir.absolutePath, - "/system/lib64", - "/vendor/lib64", - "/vendor/lib64/egl", - ) - - // Detect Mali GPU paths for MNN OpenCL support - try { - val maliSymlink = File("/system/vendor/lib64/egl/libGLES_mali.so") - if (maliSymlink.exists()) { - val realPath = maliSymlink.canonicalPath - val soc = realPath.split("/").getOrNull(realPath.split("/").size - 2) - if (soc != null) { - listOf("/vendor/lib64/$soc", "/vendor/lib64/egl/$soc").forEach { path -> - if (!systemLibPaths.contains(path)) { - systemLibPaths.add(path) - } - } - } - } - } catch (e: Exception) { - Log.w(TAG, "Failed to resolve Mali paths: ${e.message}") - } - - env["LD_LIBRARY_PATH"] = systemLibPaths.joinToString(":") - env["DSP_LIBRARY_PATH"] = runtimeDir.absolutePath - env["ADSP_LIBRARY_PATH"] = runtimeDir.absolutePath - - return env - } - private suspend fun waitForServer(timeoutMs: Long): Boolean { val startTime = System.currentTimeMillis() while (System.currentTimeMillis() - startTime < timeoutMs) { @@ -802,34 +819,6 @@ class LocalDreamModule(reactContext: ReactApplicationContext) : } } - private fun saveRgbToPng(base64Rgb: String, width: Int, height: Int, outputPath: String) { - val rgbBytes = Base64.decode(base64Rgb, Base64.DEFAULT) - val expectedSize = width * height * 3 - if (rgbBytes.size != expectedSize) { - throw IllegalArgumentException( - "RGB data size ${rgbBytes.size} doesn't match expected $expectedSize (${width}x${height}x3)" - ) - } - val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) - val pixels = IntArray(width * height) - - for (i in 0 until width * height) { - val idx = i * 3 - val r = rgbBytes[idx].toInt() and 0xFF - val g = rgbBytes[idx + 1].toInt() and 0xFF - val b = rgbBytes[idx + 2].toInt() and 0xFF - pixels[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b - } - - bitmap.setPixels(pixels, 0, width, 0, 0, width, height) - - File(outputPath).parentFile?.mkdirs() - FileOutputStream(outputPath).use { out -> - bitmap.compress(Bitmap.CompressFormat.PNG, 100, out) - } - bitmap.recycle() - } - // ===================================================================== // Image File Management (RGB → PNG conversion and file operations) // ===================================================================== diff --git a/android/app/src/test/java/ai/offgridmobile/download/DownloadCompleteBroadcastReceiverTest.kt b/android/app/src/test/java/ai/offgridmobile/download/DownloadCompleteBroadcastReceiverTest.kt new file mode 100644 index 00000000..c080a8cf --- /dev/null +++ b/android/app/src/test/java/ai/offgridmobile/download/DownloadCompleteBroadcastReceiverTest.kt @@ -0,0 +1,218 @@ +package ai.offgridmobile.download + +import android.app.Application +import android.app.DownloadManager +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import android.database.Cursor +import androidx.test.core.app.ApplicationProvider +import org.json.JSONArray +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Unit tests for DownloadCompleteBroadcastReceiver. + * + * Strategy: + * - Robolectric: real Intent, SharedPreferences, and DownloadManager.Query construction + * - Mockito: mocked DownloadManager injected via a ContextWrapper so query() results + * are fully controlled without needing a live download in progress + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = Application::class) +class DownloadCompleteBroadcastReceiverTest { + + private lateinit var context: Context + private lateinit var mockDownloadManager: DownloadManager + private val receiver = DownloadCompleteBroadcastReceiver() + + @Before + fun setUp() { + mockDownloadManager = mock() + // Wrap the Robolectric application context so getSystemService(DOWNLOAD_SERVICE) + // returns our Mockito mock while SharedPreferences remain fully functional. + context = object : ContextWrapper(ApplicationProvider.getApplicationContext()) { + override fun getSystemService(name: String): Any? = + if (name == Context.DOWNLOAD_SERVICE) mockDownloadManager + else super.getSystemService(name) + } + } + + // ── Helpers ────────────────────────────────────────────────────────────── + + private fun setTrackedDownloads(vararg downloadIds: Long) { + val array = JSONArray() + downloadIds.forEach { id -> array.put(JSONObject().put("downloadId", id)) } + prefs().edit().putString(DownloadManagerModule.DOWNLOADS_KEY, array.toString()).apply() + } + + private fun getSavedDownload(index: Int = 0): JSONObject { + val json = prefs().getString(DownloadManagerModule.DOWNLOADS_KEY, "[]") ?: "[]" + return JSONArray(json).getJSONObject(index) + } + + private fun prefs() = + context.getSharedPreferences(DownloadManagerModule.PREFS_NAME, Context.MODE_PRIVATE) + + private fun makeIntent( + action: String = DownloadManager.ACTION_DOWNLOAD_COMPLETE, + downloadId: Long = 42L, + ): Intent = Intent(action).apply { + putExtra(DownloadManager.EXTRA_DOWNLOAD_ID, downloadId) + } + + /** + * Returns a mock Cursor that reports a single row with the given status/localUri/reason. + * Column indices 0/1/2 map to STATUS/LOCAL_URI/REASON respectively. + */ + private fun makeCursor( + status: Int, + localUri: String? = "file:///sdcard/test.bin", + reason: Int = 0, + ): Cursor = mock().also { + whenever(it.moveToFirst()).thenReturn(true) + whenever(it.getColumnIndex(DownloadManager.COLUMN_STATUS)).thenReturn(0) + whenever(it.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)).thenReturn(1) + whenever(it.getColumnIndex(DownloadManager.COLUMN_REASON)).thenReturn(2) + whenever(it.getInt(0)).thenReturn(status) + whenever(it.getString(1)).thenReturn(localUri) + whenever(it.getInt(2)).thenReturn(reason) + } + + // ── Guard clause tests ──────────────────────────────────────────────────── + + @Test + fun `ignores intent with wrong action`() { + setTrackedDownloads(42L) + receiver.onReceive(context, makeIntent(action = "wrong.action")) + verifyNoInteractions(mockDownloadManager) + } + + @Test + fun `ignores intent without download id extra`() { + setTrackedDownloads(42L) + receiver.onReceive(context, Intent(DownloadManager.ACTION_DOWNLOAD_COMPLETE)) + verifyNoInteractions(mockDownloadManager) + } + + @Test + fun `ignores download id that is not in tracked list`() { + setTrackedDownloads(42L) + receiver.onReceive(context, makeIntent(downloadId = 99L)) + verifyNoInteractions(mockDownloadManager) + } + + @Test + fun `handles corrupt JSON in SharedPreferences without crashing`() { + prefs().edit().putString(DownloadManagerModule.DOWNLOADS_KEY, "not-json").apply() + receiver.onReceive(context, makeIntent()) + verifyNoInteractions(mockDownloadManager) + } + + // ── Successful download ─────────────────────────────────────────────────── + + @Test + fun `marks successful download as completed and persists to SharedPreferences`() { + setTrackedDownloads(42L) + val cursor = makeCursor(DownloadManager.STATUS_SUCCESSFUL, localUri = "file:///sdcard/model.bin") + whenever(mockDownloadManager.query(any())).thenReturn(cursor) + + receiver.onReceive(context, makeIntent(downloadId = 42L)) + + val saved = getSavedDownload() + assertEquals("completed", saved.getString("status")) + assertEquals("file:///sdcard/model.bin", saved.getString("localUri")) + assertTrue(saved.has("completedAt")) + } + + @Test + fun `uses empty string for localUri when it is null on success`() { + setTrackedDownloads(42L) + val cursor = makeCursor(DownloadManager.STATUS_SUCCESSFUL, localUri = null) + whenever(mockDownloadManager.query(any())).thenReturn(cursor) + + receiver.onReceive(context, makeIntent(downloadId = 42L)) + + assertEquals("", getSavedDownload().getString("localUri")) + } + + // ── Failed download ─────────────────────────────────────────────────────── + + @Test + fun `marks failed download with human-readable reason and persists`() { + setTrackedDownloads(42L) + val cursor = makeCursor(DownloadManager.STATUS_FAILED, reason = DownloadManager.ERROR_INSUFFICIENT_SPACE) + whenever(mockDownloadManager.query(any())).thenReturn(cursor) + + receiver.onReceive(context, makeIntent(downloadId = 42L)) + + val saved = getSavedDownload() + assertEquals("failed", saved.getString("status")) + assertEquals("Insufficient space", saved.getString("failureReason")) + assertTrue(saved.has("completedAt")) + } + + @Test + fun `includes completedAt timestamp for failed download`() { + val before = System.currentTimeMillis() + setTrackedDownloads(42L) + val cursor = makeCursor(DownloadManager.STATUS_FAILED, reason = DownloadManager.ERROR_UNKNOWN) + whenever(mockDownloadManager.query(any())).thenReturn(cursor) + + receiver.onReceive(context, makeIntent(downloadId = 42L)) + + val completedAt = getSavedDownload().getLong("completedAt") + assertTrue(completedAt >= before) + } + + // ── Cursor edge cases ───────────────────────────────────────────────────── + + @Test + fun `does not update SharedPreferences when cursor is null`() { + setTrackedDownloads(42L) + whenever(mockDownloadManager.query(any())).thenReturn(null) + + receiver.onReceive(context, makeIntent(downloadId = 42L)) + + assertFalse(getSavedDownload().has("status")) + } + + @Test + fun `does not update SharedPreferences when cursor has no rows`() { + setTrackedDownloads(42L) + val emptyCursor: Cursor = mock() + whenever(emptyCursor.moveToFirst()).thenReturn(false) + whenever(mockDownloadManager.query(any())).thenReturn(emptyCursor) + + receiver.onReceive(context, makeIntent(downloadId = 42L)) + + assertFalse(getSavedDownload().has("status")) + } + + // ── Multi-download list integrity ───────────────────────────────────────── + + @Test + fun `only updates the matching download and leaves others unchanged`() { + setTrackedDownloads(11L, 42L, 99L) + val cursor = makeCursor(DownloadManager.STATUS_SUCCESSFUL) + whenever(mockDownloadManager.query(any())).thenReturn(cursor) + + receiver.onReceive(context, makeIntent(downloadId = 42L)) + + assertFalse("download at index 0 should be untouched", getSavedDownload(0).has("status")) + assertEquals("completed", getSavedDownload(1).getString("status")) + assertFalse("download at index 2 should be untouched", getSavedDownload(2).has("status")) + } +} diff --git a/android/app/src/test/java/ai/offgridmobile/download/DownloadManagerModuleTest.kt b/android/app/src/test/java/ai/offgridmobile/download/DownloadManagerModuleTest.kt new file mode 100644 index 00000000..2a18ab82 --- /dev/null +++ b/android/app/src/test/java/ai/offgridmobile/download/DownloadManagerModuleTest.kt @@ -0,0 +1,275 @@ +package ai.offgridmobile.download + +import android.app.Application +import android.app.DownloadManager +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Tests for the pure helper functions in DownloadManagerModule. + * These functions contain complex branching logic and all branches must be covered. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = Application::class) +class DownloadManagerModuleTest { + + // ── statusToString ──────────────────────────────────────────────────────── + + @Test + fun `statusToString maps STATUS_PENDING to pending`() { + assertEquals("pending", DownloadManagerModule.statusToString(DownloadManager.STATUS_PENDING)) + } + + @Test + fun `statusToString maps STATUS_RUNNING to running`() { + assertEquals("running", DownloadManagerModule.statusToString(DownloadManager.STATUS_RUNNING)) + } + + @Test + fun `statusToString maps STATUS_PAUSED to paused`() { + assertEquals("paused", DownloadManagerModule.statusToString(DownloadManager.STATUS_PAUSED)) + } + + @Test + fun `statusToString maps STATUS_SUCCESSFUL to completed`() { + assertEquals("completed", DownloadManagerModule.statusToString(DownloadManager.STATUS_SUCCESSFUL)) + } + + @Test + fun `statusToString maps STATUS_FAILED to failed`() { + assertEquals("failed", DownloadManagerModule.statusToString(DownloadManager.STATUS_FAILED)) + } + + @Test + fun `statusToString returns unknown for unrecognized status`() { + assertEquals("unknown", DownloadManagerModule.statusToString(-99)) + assertEquals("unknown", DownloadManagerModule.statusToString(0)) + } + + // ── reasonToString — paused ─────────────────────────────────────────────── + + @Test + fun `reasonToString maps PAUSED_QUEUED_FOR_WIFI when status is paused`() { + assertEquals( + "Waiting for WiFi", + DownloadManagerModule.reasonToString( + DownloadManager.STATUS_PAUSED, + DownloadManager.PAUSED_QUEUED_FOR_WIFI, + ), + ) + } + + @Test + fun `reasonToString maps PAUSED_WAITING_FOR_NETWORK when status is paused`() { + assertEquals( + "Waiting for network", + DownloadManagerModule.reasonToString( + DownloadManager.STATUS_PAUSED, + DownloadManager.PAUSED_WAITING_FOR_NETWORK, + ), + ) + } + + @Test + fun `reasonToString maps PAUSED_WAITING_TO_RETRY when status is paused`() { + assertEquals( + "Waiting to retry", + DownloadManagerModule.reasonToString( + DownloadManager.STATUS_PAUSED, + DownloadManager.PAUSED_WAITING_TO_RETRY, + ), + ) + } + + @Test + fun `reasonToString returns generic Paused for unknown pause reason`() { + assertEquals( + "Paused", + DownloadManagerModule.reasonToString(DownloadManager.STATUS_PAUSED, -99), + ) + } + + // ── reasonToString — failed ─────────────────────────────────────────────── + + @Test + fun `reasonToString maps ERROR_CANNOT_RESUME when status is failed`() { + assertEquals( + "Cannot resume", + DownloadManagerModule.reasonToString( + DownloadManager.STATUS_FAILED, + DownloadManager.ERROR_CANNOT_RESUME, + ), + ) + } + + @Test + fun `reasonToString maps ERROR_DEVICE_NOT_FOUND when status is failed`() { + assertEquals( + "Device not found", + DownloadManagerModule.reasonToString( + DownloadManager.STATUS_FAILED, + DownloadManager.ERROR_DEVICE_NOT_FOUND, + ), + ) + } + + @Test + fun `reasonToString maps ERROR_FILE_ALREADY_EXISTS when status is failed`() { + assertEquals( + "File already exists", + DownloadManagerModule.reasonToString( + DownloadManager.STATUS_FAILED, + DownloadManager.ERROR_FILE_ALREADY_EXISTS, + ), + ) + } + + @Test + fun `reasonToString maps ERROR_FILE_ERROR when status is failed`() { + assertEquals( + "File error", + DownloadManagerModule.reasonToString( + DownloadManager.STATUS_FAILED, + DownloadManager.ERROR_FILE_ERROR, + ), + ) + } + + @Test + fun `reasonToString maps ERROR_HTTP_DATA_ERROR when status is failed`() { + assertEquals( + "HTTP data error", + DownloadManagerModule.reasonToString( + DownloadManager.STATUS_FAILED, + DownloadManager.ERROR_HTTP_DATA_ERROR, + ), + ) + } + + @Test + fun `reasonToString maps ERROR_INSUFFICIENT_SPACE when status is failed`() { + assertEquals( + "Insufficient space", + DownloadManagerModule.reasonToString( + DownloadManager.STATUS_FAILED, + DownloadManager.ERROR_INSUFFICIENT_SPACE, + ), + ) + } + + @Test + fun `reasonToString maps ERROR_TOO_MANY_REDIRECTS when status is failed`() { + assertEquals( + "Too many redirects", + DownloadManagerModule.reasonToString( + DownloadManager.STATUS_FAILED, + DownloadManager.ERROR_TOO_MANY_REDIRECTS, + ), + ) + } + + @Test + fun `reasonToString maps ERROR_UNHANDLED_HTTP_CODE when status is failed`() { + assertEquals( + "Unhandled HTTP code", + DownloadManagerModule.reasonToString( + DownloadManager.STATUS_FAILED, + DownloadManager.ERROR_UNHANDLED_HTTP_CODE, + ), + ) + } + + @Test + fun `reasonToString maps ERROR_UNKNOWN when status is failed`() { + assertEquals( + "Unknown error", + DownloadManagerModule.reasonToString( + DownloadManager.STATUS_FAILED, + DownloadManager.ERROR_UNKNOWN, + ), + ) + } + + @Test + fun `reasonToString includes error code for unrecognized failure reason`() { + assertEquals( + "Error: 999", + DownloadManagerModule.reasonToString(DownloadManager.STATUS_FAILED, 999), + ) + } + + // ── reasonToString — other statuses ────────────────────────────────────── + + @Test + fun `reasonToString returns empty string when status is not paused or failed`() { + assertEquals("", DownloadManagerModule.reasonToString(DownloadManager.STATUS_PENDING, 0)) + assertEquals("", DownloadManagerModule.reasonToString(DownloadManager.STATUS_RUNNING, 0)) + assertEquals("", DownloadManagerModule.reasonToString(DownloadManager.STATUS_SUCCESSFUL, 0)) + } + + // ── shouldRemoveDownload ────────────────────────────────────────────────── + + private fun download( + storedStatus: String = "pending", + completedAt: Long = 0L, + completedEventSent: Boolean = false, + ) = JSONObject() + .put("downloadId", 42L) + .put("status", storedStatus) + .put("completedAt", completedAt) + .put("completedEventSent", completedEventSent) + + @Test + fun `shouldRemoveDownload returns true when live status is unknown`() { + assertTrue(DownloadManagerModule.shouldRemoveDownload(download("running"), liveStatus = "unknown")) + } + + @Test + fun `shouldRemoveDownload returns false for active downloads`() { + assertFalse(DownloadManagerModule.shouldRemoveDownload(download("running"), liveStatus = "running")) + assertFalse(DownloadManagerModule.shouldRemoveDownload(download("pending"), liveStatus = "pending")) + } + + @Test + fun `shouldRemoveDownload removes completed download when event sent and entry is older than 5 seconds`() { + val now = System.currentTimeMillis() + val dl = download("completed", completedAt = now - 6_000L, completedEventSent = true) + assertTrue(DownloadManagerModule.shouldRemoveDownload(dl, liveStatus = "completed", currentTimeMs = now)) + } + + @Test + fun `shouldRemoveDownload keeps completed download when event sent but not yet 5 seconds old`() { + val now = System.currentTimeMillis() + val dl = download("completed", completedAt = now - 1_000L, completedEventSent = true) + assertFalse(DownloadManagerModule.shouldRemoveDownload(dl, liveStatus = "completed", currentTimeMs = now)) + } + + @Test + fun `shouldRemoveDownload keeps completed download when event has not been sent yet`() { + // This is the race-condition guard: even if old enough, don't remove until event is sent + val now = System.currentTimeMillis() + val dl = download("completed", completedAt = now - 10_000L, completedEventSent = false) + assertFalse(DownloadManagerModule.shouldRemoveDownload(dl, liveStatus = "completed", currentTimeMs = now)) + } + + @Test + fun `shouldRemoveDownload keeps completed download when completedAt is zero`() { + val now = System.currentTimeMillis() + val dl = download("completed", completedAt = 0L, completedEventSent = true) + assertFalse(DownloadManagerModule.shouldRemoveDownload(dl, liveStatus = "completed", currentTimeMs = now)) + } + + @Test + fun `shouldRemoveDownload returns false for non-completed stored status regardless of live status`() { + val now = System.currentTimeMillis() + // stored status is "running" — the completed branch never fires + val dl = download("running", completedAt = now - 10_000L, completedEventSent = true) + assertFalse(DownloadManagerModule.shouldRemoveDownload(dl, liveStatus = "running", currentTimeMs = now)) + } +} diff --git a/android/app/src/test/java/ai/offgridmobile/localdream/LocalDreamModuleTest.kt b/android/app/src/test/java/ai/offgridmobile/localdream/LocalDreamModuleTest.kt new file mode 100644 index 00000000..4777e6a8 --- /dev/null +++ b/android/app/src/test/java/ai/offgridmobile/localdream/LocalDreamModuleTest.kt @@ -0,0 +1,392 @@ +package ai.offgridmobile.localdream + +import android.app.Application +import android.graphics.BitmapFactory +import android.os.Build +import android.util.Base64 +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.util.ReflectionHelpers +import java.io.File + +/** + * Tests for pure helper functions in LocalDreamModule. + * All methods under test live in the companion object and have no instance state. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33], application = Application::class) +class LocalDreamModuleTest { + + @get:Rule + val tmp = TemporaryFolder() + + // ── isNpuSupportedInternal ──────────────────────────────────────────────── + + @Test + fun `isNpuSupportedInternal returns true for Snapdragon SM prefix`() { + ReflectionHelpers.setStaticField(Build::class.java, "SOC_MODEL", "SM8650") + assertTrue(LocalDreamModule.isNpuSupportedInternal()) + } + + @Test + fun `isNpuSupportedInternal returns true for QCS prefix`() { + ReflectionHelpers.setStaticField(Build::class.java, "SOC_MODEL", "QCS8550") + assertTrue(LocalDreamModule.isNpuSupportedInternal()) + } + + @Test + fun `isNpuSupportedInternal returns true for QCM prefix`() { + ReflectionHelpers.setStaticField(Build::class.java, "SOC_MODEL", "QCM6490") + assertTrue(LocalDreamModule.isNpuSupportedInternal()) + } + + @Test + fun `isNpuSupportedInternal returns false for non-Qualcomm SoC`() { + ReflectionHelpers.setStaticField(Build::class.java, "SOC_MODEL", "Exynos2400") + assertFalse(LocalDreamModule.isNpuSupportedInternal()) + } + + @Test + fun `isNpuSupportedInternal returns false for empty SoC model`() { + ReflectionHelpers.setStaticField(Build::class.java, "SOC_MODEL", "") + assertFalse(LocalDreamModule.isNpuSupportedInternal()) + } + + // ── resolveModelDir ─────────────────────────────────────────────────────── + + @Test + fun `resolveModelDir returns root dir when marker is at root for CPU`() { + val root = tmp.newFolder("model") + root.resolve("unet.mnn").createNewFile() + + assertEquals(root, LocalDreamModule.resolveModelDir(root, isCpu = true)) + } + + @Test + fun `resolveModelDir returns root dir when marker is at root for QNN`() { + val root = tmp.newFolder("model") + root.resolve("unet.bin").createNewFile() + + assertEquals(root, LocalDreamModule.resolveModelDir(root, isCpu = false)) + } + + @Test + fun `resolveModelDir finds marker one level deep`() { + val root = tmp.newFolder("model") + val sub = root.resolve("inner").also { it.mkdir() } + sub.resolve("unet.mnn").createNewFile() + + assertEquals(sub, LocalDreamModule.resolveModelDir(root, isCpu = true)) + } + + @Test + fun `resolveModelDir finds marker three levels deep`() { + val root = tmp.newFolder("model") + val deep = root.resolve("a/b/c").also { it.mkdirs() } + deep.resolve("unet.bin").createNewFile() + + assertEquals(deep, LocalDreamModule.resolveModelDir(root, isCpu = false)) + } + + @Test + fun `resolveModelDir finds marker four levels deep (boundary of search depth)`() { + // searchDir is called with depth=3 for the 4th level directory — depth > 3 is false, + // so children are still checked. The limit only cuts off at depth=4 (5 levels below root). + val root = tmp.newFolder("model") + val deep = root.resolve("a/b/c/d").also { it.mkdirs() } + deep.resolve("unet.mnn").createNewFile() + + assertEquals(deep, LocalDreamModule.resolveModelDir(root, isCpu = true)) + } + + @Test + fun `resolveModelDir returns null when marker is five levels deep (beyond limit)`() { + val root = tmp.newFolder("model") + root.resolve("a/b/c/d/e").also { it.mkdirs() }.resolve("unet.mnn").createNewFile() + + assertNull(LocalDreamModule.resolveModelDir(root, isCpu = true)) + } + + @Test + fun `resolveModelDir returns null when no marker file exists`() { + val root = tmp.newFolder("model") + root.resolve("some_other_file.bin").createNewFile() + + assertNull(LocalDreamModule.resolveModelDir(root, isCpu = true)) + } + + @Test + fun `resolveModelDir does not confuse CPU and QNN markers`() { + val root = tmp.newFolder("model") + root.resolve("unet.bin").createNewFile() // QNN marker only + + // CPU search should not match unet.bin + assertNull(LocalDreamModule.resolveModelDir(root, isCpu = true)) + // QNN search should match + assertNotNull(LocalDreamModule.resolveModelDir(root, isCpu = false)) + } + + // ── buildCommand — CPU (MNN) backend ───────────────────────────────────── + + private fun makeCpuModelDir(): java.io.File = tmp.newFolder("cpu_model").also { dir -> + dir.resolve("clip.mnn").createNewFile() + dir.resolve("unet.mnn").createNewFile() + dir.resolve("vae_decoder.mnn").createNewFile() + dir.resolve("tokenizer.json").createNewFile() + } + + private fun makeQnnModelDir(withMnnClip: Boolean = false): java.io.File = + tmp.newFolder("qnn_model").also { dir -> + if (withMnnClip) dir.resolve("clip.mnn").createNewFile() + dir.resolve("unet.bin").createNewFile() + dir.resolve("vae_decoder.bin").createNewFile() + dir.resolve("tokenizer.json").createNewFile() + } + + private fun makeExecutable(): java.io.File = tmp.newFile("libstable_diffusion_core.so") + private fun makeRuntimeDir(): java.io.File = tmp.newFolder("runtime") + + @Test + fun `buildCommand CPU includes --cpu flag`() { + val cmd = LocalDreamModule.buildCommand( + makeExecutable(), makeCpuModelDir(), makeRuntimeDir(), isCpu = true, + ) + assertTrue(cmd.contains("--cpu")) + } + + @Test + fun `buildCommand CPU uses clip mnn path`() { + val modelDir = makeCpuModelDir() + val cmd = LocalDreamModule.buildCommand(makeExecutable(), modelDir, makeRuntimeDir(), isCpu = true) + + val clipIdx = cmd.indexOf("--clip") + assertTrue("--clip flag missing", clipIdx >= 0) + assertTrue("clip path should end with clip.mnn", cmd[clipIdx + 1].endsWith("clip.mnn")) + } + + @Test + fun `buildCommand CPU sets correct port`() { + val cmd = LocalDreamModule.buildCommand( + makeExecutable(), makeCpuModelDir(), makeRuntimeDir(), isCpu = true, + ) + val portIdx = cmd.indexOf("--port") + assertTrue(portIdx >= 0) + assertEquals("18081", cmd[portIdx + 1]) + } + + @Test + fun `buildCommand CPU includes vae_encoder when present`() { + val modelDir = makeCpuModelDir() + modelDir.resolve("vae_encoder.mnn").createNewFile() + + val cmd = LocalDreamModule.buildCommand(makeExecutable(), modelDir, makeRuntimeDir(), isCpu = true) + + val encoderIdx = cmd.indexOf("--vae_encoder") + assertTrue("--vae_encoder flag missing", encoderIdx >= 0) + assertTrue(cmd[encoderIdx + 1].endsWith("vae_encoder.mnn")) + } + + @Test + fun `buildCommand CPU omits vae_encoder when absent`() { + val cmd = LocalDreamModule.buildCommand( + makeExecutable(), makeCpuModelDir(), makeRuntimeDir(), isCpu = true, + ) + assertFalse(cmd.contains("--vae_encoder")) + } + + // ── buildCommand — QNN (NPU) backend ───────────────────────────────────── + + @Test + fun `buildCommand QNN does not include --cpu flag`() { + val cmd = LocalDreamModule.buildCommand( + makeExecutable(), makeQnnModelDir(), makeRuntimeDir(), isCpu = false, + ) + assertFalse(cmd.contains("--cpu")) + } + + @Test + fun `buildCommand QNN uses clip bin when no mnn clip present`() { + val modelDir = makeQnnModelDir(withMnnClip = false) + val cmd = LocalDreamModule.buildCommand(makeExecutable(), modelDir, makeRuntimeDir(), isCpu = false) + + val clipIdx = cmd.indexOf("--clip") + assertTrue(clipIdx >= 0) + assertTrue("should use clip.bin", cmd[clipIdx + 1].endsWith("clip.bin")) + assertFalse("should not add --use_cpu_clip", cmd.contains("--use_cpu_clip")) + } + + @Test + fun `buildCommand QNN uses clip mnn and adds use_cpu_clip when mnn clip present`() { + val modelDir = makeQnnModelDir(withMnnClip = true) + val cmd = LocalDreamModule.buildCommand(makeExecutable(), modelDir, makeRuntimeDir(), isCpu = false) + + val clipIdx = cmd.indexOf("--clip") + assertTrue(clipIdx >= 0) + assertTrue("should use clip.mnn", cmd[clipIdx + 1].endsWith("clip.mnn")) + assertTrue("should add --use_cpu_clip", cmd.contains("--use_cpu_clip")) + } + + @Test + fun `buildCommand QNN uses clip mnn when only clip_v2 mnn present`() { + val modelDir = makeQnnModelDir(withMnnClip = false).also { + it.resolve("clip_v2.mnn").createNewFile() + } + val cmd = LocalDreamModule.buildCommand(makeExecutable(), modelDir, makeRuntimeDir(), isCpu = false) + + assertTrue("should add --use_cpu_clip for clip_v2", cmd.contains("--use_cpu_clip")) + } + + @Test + fun `buildCommand QNN includes vae_encoder bin when present`() { + val modelDir = makeQnnModelDir().also { + it.resolve("vae_encoder.bin").createNewFile() + } + val cmd = LocalDreamModule.buildCommand(makeExecutable(), modelDir, makeRuntimeDir(), isCpu = false) + + val encoderIdx = cmd.indexOf("--vae_encoder") + assertTrue(encoderIdx >= 0) + assertTrue(cmd[encoderIdx + 1].endsWith("vae_encoder.bin")) + } + + @Test + fun `buildCommand QNN includes backend and system_library paths`() { + val runtimeDir = makeRuntimeDir() + val cmd = LocalDreamModule.buildCommand( + makeExecutable(), makeQnnModelDir(), runtimeDir, isCpu = false, + ) + + val backendIdx = cmd.indexOf("--backend") + assertTrue(backendIdx >= 0) + assertTrue(cmd[backendIdx + 1].endsWith("libQnnHtp.so")) + + val sysLibIdx = cmd.indexOf("--system_library") + assertTrue(sysLibIdx >= 0) + assertTrue(cmd[sysLibIdx + 1].endsWith("libQnnSystem.so")) + } + + // ── buildEnvironment ────────────────────────────────────────────────────── + + @Test + fun `buildEnvironment always sets all three env vars`() { + val runtimeDir = makeRuntimeDir() + val env = LocalDreamModule.buildEnvironment(runtimeDir) + + assertTrue(env.containsKey("LD_LIBRARY_PATH")) + assertTrue(env.containsKey("DSP_LIBRARY_PATH")) + assertTrue(env.containsKey("ADSP_LIBRARY_PATH")) + } + + @Test + fun `buildEnvironment sets DSP and ADSP paths to runtimeDir`() { + val runtimeDir = makeRuntimeDir() + val env = LocalDreamModule.buildEnvironment(runtimeDir) + + assertEquals(runtimeDir.absolutePath, env["DSP_LIBRARY_PATH"]) + assertEquals(runtimeDir.absolutePath, env["ADSP_LIBRARY_PATH"]) + } + + @Test + fun `buildEnvironment includes runtimeDir as first entry in LD_LIBRARY_PATH`() { + val runtimeDir = makeRuntimeDir() + val env = LocalDreamModule.buildEnvironment(runtimeDir) + + val paths = env["LD_LIBRARY_PATH"]!!.split(":") + assertEquals(runtimeDir.absolutePath, paths.first()) + } + + @Test + fun `buildEnvironment includes standard system library paths`() { + val env = LocalDreamModule.buildEnvironment(makeRuntimeDir()) + val ldPath = env["LD_LIBRARY_PATH"]!! + + assertTrue(ldPath.contains("/system/lib64")) + assertTrue(ldPath.contains("/vendor/lib64")) + assertTrue(ldPath.contains("/vendor/lib64/egl")) + } + + // ── saveRgbToPng ────────────────────────────────────────────────────────── + + private fun rgbBase64(vararg bytes: Int): String = + Base64.encodeToString(ByteArray(bytes.size) { bytes[it].toByte() }, Base64.DEFAULT) + + @Test + fun `saveRgbToPng throws when byte count does not match dimensions`() { + val base64 = Base64.encodeToString(ByteArray(6), Base64.DEFAULT) // 6 bytes but 2x2 needs 12 + try { + LocalDreamModule.saveRgbToPng(base64, 2, 2, tmp.newFile("out.png").absolutePath) + fail("Expected IllegalArgumentException") + } catch (e: IllegalArgumentException) { + assertTrue(e.message!!.contains("doesn't match expected")) + assertTrue(e.message!!.contains("12")) + } + } + + @Test + fun `saveRgbToPng creates a PNG file at the given path`() { + val base64 = rgbBase64(0xFF, 0x00, 0x00) // 1x1 red + val out = tmp.newFile("out.png") + LocalDreamModule.saveRgbToPng(base64, 1, 1, out.absolutePath) + assertTrue(out.exists()) + assertTrue("file should not be empty", out.length() > 0) + } + + @Test + fun `saveRgbToPng creates parent directories when they do not exist`() { + val out = File(tmp.root, "a/b/c/out.png") + assertFalse(out.parentFile!!.exists()) + LocalDreamModule.saveRgbToPng(rgbBase64(0, 0, 0), 1, 1, out.absolutePath) + assertTrue(out.exists()) + } + + @Test + fun `saveRgbToPng encodes red channel correctly`() { + // 1x1 pure red: R=255, G=0, B=0 → ARGB = 0xFFFF0000 + val base64 = rgbBase64(0xFF, 0x00, 0x00) + val out = tmp.newFile("red.png") + LocalDreamModule.saveRgbToPng(base64, 1, 1, out.absolutePath) + val pixel = BitmapFactory.decodeFile(out.absolutePath).getPixel(0, 0) + assertEquals(0xFFFF0000.toInt(), pixel) + } + + @Test + fun `saveRgbToPng encodes blue channel correctly`() { + // 1x1 pure blue: R=0, G=0, B=255 → ARGB = 0xFF0000FF + val base64 = rgbBase64(0x00, 0x00, 0xFF) + val out = tmp.newFile("blue.png") + LocalDreamModule.saveRgbToPng(base64, 1, 1, out.absolutePath) + val pixel = BitmapFactory.decodeFile(out.absolutePath).getPixel(0, 0) + assertEquals(0xFF0000FF.toInt(), pixel) + } + + @Test + fun `saveRgbToPng encodes all pixels for a multi-pixel image`() { + // 2x1 image: [red | blue] + val base64 = rgbBase64(0xFF, 0x00, 0x00, 0x00, 0x00, 0xFF) + val out = tmp.newFile("2x1.png") + LocalDreamModule.saveRgbToPng(base64, 2, 1, out.absolutePath) + val bmp = BitmapFactory.decodeFile(out.absolutePath) + assertEquals(0xFFFF0000.toInt(), bmp.getPixel(0, 0)) + assertEquals(0xFF0000FF.toInt(), bmp.getPixel(1, 0)) + } + + @Test + fun `saveRgbToPng preserves alpha as fully opaque`() { + // Any RGB pixel should decode to alpha=0xFF + val base64 = rgbBase64(0x12, 0x34, 0x56) + val out = tmp.newFile("alpha.png") + LocalDreamModule.saveRgbToPng(base64, 1, 1, out.absolutePath) + val pixel = BitmapFactory.decodeFile(out.absolutePath).getPixel(0, 0) + val alpha = (pixel ushr 24) and 0xFF + assertEquals(0xFF, alpha) + } +} diff --git a/ios/OffgridMobile.xcodeproj/project.pbxproj b/ios/OffgridMobile.xcodeproj/project.pbxproj index f7e394f4..cb0e35aa 100644 --- a/ios/OffgridMobile.xcodeproj/project.pbxproj +++ b/ios/OffgridMobile.xcodeproj/project.pbxproj @@ -16,12 +16,24 @@ 0A7B3D032F3A0B1200CC5FA1 /* PDFExtractorModule.m in Sources */ = {isa = PBXBuildFile; fileRef = 0A7B3D012F3A0B1200CC5FA1 /* PDFExtractorModule.m */; }; 0A7B3D042F3A0B1200CC5FA1 /* PDFExtractorModule.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7B3D022F3A0B1200CC5FA1 /* PDFExtractorModule.swift */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; + 553E18B7CCC207C0885499E4 /* libPods-OffgridMobileTests.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D1B1541769AADA563D6CC44E /* libPods-OffgridMobileTests.a */; }; 761780ED2CA45674006654EE /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 761780EC2CA45674006654EE /* AppDelegate.swift */; }; 80EE15520A374D84DFA0E523 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB81A68108700A75B9A /* PrivacyInfo.xcprivacy */; }; 81AB9BB82411601600AC10FF /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */; }; A084C602C3B4A415DC74D43F /* libPods-OffgridMobile.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 3A3BA1A946E10FA48AA4C0EB /* libPods-OffgridMobile.a */; }; + AABB000100000000000001AA /* OffgridMobileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABB000200000000000002AA /* OffgridMobileTests.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + AABB00010000000000001004 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 13B07F861A680F5B00A75B9A; + remoteInfo = OffgridMobile; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 049521632F390D4500AA4EB4 /* CoreMLDiffusionModule.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = CoreMLDiffusionModule.m; sourceTree = ""; }; 049521642F390D4500AA4EB4 /* CoreMLDiffusionModule.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreMLDiffusionModule.swift; sourceTree = ""; }; @@ -39,6 +51,11 @@ 3A3BA1A946E10FA48AA4C0EB /* libPods-OffgridMobile.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OffgridMobile.a"; sourceTree = BUILT_PRODUCTS_DIR; }; 761780EC2CA45674006654EE /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppDelegate.swift; path = OffgridMobile/AppDelegate.swift; sourceTree = ""; }; 81AB9BB72411601600AC10FF /* LaunchScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = LaunchScreen.storyboard; path = OffgridMobile/LaunchScreen.storyboard; sourceTree = ""; }; + AABB000200000000000002AA /* OffgridMobileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OffgridMobileTests.swift; sourceTree = ""; }; + AABB000400000000000004AA /* OffgridMobileTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OffgridMobileTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B9DE36A1FFE10AF8CD81DBD2 /* Pods-OffgridMobileTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OffgridMobileTests.release.xcconfig"; path = "Target Support Files/Pods-OffgridMobileTests/Pods-OffgridMobileTests.release.xcconfig"; sourceTree = ""; }; + D0917E571600B3FFEDA59EF7 /* Pods-OffgridMobileTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-OffgridMobileTests.debug.xcconfig"; path = "Target Support Files/Pods-OffgridMobileTests/Pods-OffgridMobileTests.debug.xcconfig"; sourceTree = ""; }; + D1B1541769AADA563D6CC44E /* libPods-OffgridMobileTests.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-OffgridMobileTests.a"; sourceTree = BUILT_PRODUCTS_DIR; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; /* End PBXFileReference section */ @@ -52,6 +69,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AABB000800000000000008AA /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 553E18B7CCC207C0885499E4 /* libPods-OffgridMobileTests.a in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -79,6 +104,7 @@ children = ( ED297162215061F000B7C4FE /* JavaScriptCore.framework */, 3A3BA1A946E10FA48AA4C0EB /* libPods-OffgridMobile.a */, + D1B1541769AADA563D6CC44E /* libPods-OffgridMobileTests.a */, ); name = Frameworks; sourceTree = ""; @@ -94,6 +120,7 @@ isa = PBXGroup; children = ( 13B07FAE1A68108700A75B9A /* OffgridMobile */, + AABB000500000000000005AA /* OffgridMobileTests */, 832341AE1AAA6A7D00B99B32 /* Libraries */, 83CBBA001A601CBA00E9B192 /* Products */, 2D16E6871FA4F8E400B85C8A /* Frameworks */, @@ -108,15 +135,26 @@ isa = PBXGroup; children = ( 13B07F961A680F5B00A75B9A /* OffgridMobile.app */, + AABB000400000000000004AA /* OffgridMobileTests.xctest */, ); name = Products; sourceTree = ""; }; + AABB000500000000000005AA /* OffgridMobileTests */ = { + isa = PBXGroup; + children = ( + AABB000200000000000002AA /* OffgridMobileTests.swift */, + ); + path = OffgridMobileTests; + sourceTree = ""; + }; BBD78D7AC51CEA395F1C20DB /* Pods */ = { isa = PBXGroup; children = ( 2BD3167161334CCC189096E3 /* Pods-OffgridMobile.debug.xcconfig */, 37BD4C6C3858A907C678B5B4 /* Pods-OffgridMobile.release.xcconfig */, + D0917E571600B3FFEDA59EF7 /* Pods-OffgridMobileTests.debug.xcconfig */, + B9DE36A1FFE10AF8CD81DBD2 /* Pods-OffgridMobileTests.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -124,6 +162,25 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 00E356ED1AD99517003FC87E /* OffgridMobileTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = AABB00010000000000001003 /* Build configuration list for PBXNativeTarget "OffgridMobileTests" */; + buildPhases = ( + 61A28276E96052FBB39B62C5 /* [CP] Check Pods Manifest.lock */, + AABB000700000000000007AA /* Sources */, + AABB000800000000000008AA /* Frameworks */, + AABB000900000000000009AA /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + AABB00010000000000001005 /* PBXTargetDependency */, + ); + name = OffgridMobileTests; + productName = OffgridMobileTests; + productReference = AABB000400000000000004AA /* OffgridMobileTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 13B07F861A680F5B00A75B9A /* OffgridMobile */ = { isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "OffgridMobile" */; @@ -153,6 +210,9 @@ attributes = { LastUpgradeCheck = 1210; TargetAttributes = { + 00E356ED1AD99517003FC87E = { + TestTargetID = 13B07F861A680F5B00A75B9A; + }; 13B07F861A680F5B00A75B9A = { LastSwiftMigration = 1120; }; @@ -175,6 +235,7 @@ projectRoot = ""; targets = ( 13B07F861A680F5B00A75B9A /* OffgridMobile */, + 00E356ED1AD99517003FC87E /* OffgridMobileTests */, ); }; /* End PBXProject section */ @@ -191,6 +252,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AABB000900000000000009AA /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -249,6 +317,28 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-OffgridMobile/Pods-OffgridMobile-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; + 61A28276E96052FBB39B62C5 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-OffgridMobileTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; DADC570D62064073AFEE927B /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -283,8 +373,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AABB000700000000000007AA /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AABB000100000000000001AA /* OffgridMobileTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + AABB00010000000000001005 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 13B07F861A680F5B00A75B9A /* OffgridMobile */; + targetProxy = AABB00010000000000001004 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; @@ -494,6 +600,34 @@ }; name = Release; }; + AABB00010000000000001001 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = D0917E571600B3FFEDA59EF7 /* Pods-OffgridMobileTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/OffgridMobile.app/OffgridMobile"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = ai.offgridmobile.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUNDLE_LOADER)"; + }; + name = Debug; + }; + AABB00010000000000001002 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = B9DE36A1FFE10AF8CD81DBD2 /* Pods-OffgridMobileTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(BUILT_PRODUCTS_DIR)/OffgridMobile.app/OffgridMobile"; + GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + PRODUCT_BUNDLE_IDENTIFIER = ai.offgridmobile.tests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUNDLE_LOADER)"; + }; + name = Release; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -515,6 +649,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + AABB00010000000000001003 /* Build configuration list for PBXNativeTarget "OffgridMobileTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + AABB00010000000000001001 /* Debug */, + AABB00010000000000001002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/ios/OffgridMobile/Download/DownloadManagerModule.swift b/ios/OffgridMobile/Download/DownloadManagerModule.swift index f5b6069f..0f5b4797 100644 --- a/ios/OffgridMobile/Download/DownloadManagerModule.swift +++ b/ios/OffgridMobile/Download/DownloadManagerModule.swift @@ -150,9 +150,9 @@ class DownloadManagerModule: RCTEventEmitter { let activeDownloads = persisted.filter { $0.status == "running" || $0.status == "pending" } NSLog("[DownloadManager] Restoring %d active downloads from UserDefaults (of %d total persisted)", activeDownloads.count, persisted.count) - for p in activeDownloads { + for persistedDownload in activeDownloads { var fileTasks: [Int: FileTask] = [:] - if p.isMultiFile, let entries = p.fileTaskEntries { + if persistedDownload.isMultiFile, let entries = persistedDownload.fileTaskEntries { // Use negative placeholder keys; will be remapped when reconnecting tasks for (index, entry) in entries.enumerated() { let placeholderKey = -(index + 1) @@ -169,29 +169,29 @@ class DownloadManagerModule: RCTEventEmitter { } let info = DownloadInfo( - downloadId: p.downloadId, - fileName: p.fileName, - modelId: p.modelId, - totalBytes: p.totalBytes, - bytesDownloaded: p.bytesDownloaded, - status: p.status, - startedAt: p.startedAt, + downloadId: persistedDownload.downloadId, + fileName: persistedDownload.fileName, + modelId: persistedDownload.modelId, + totalBytes: persistedDownload.totalBytes, + bytesDownloaded: persistedDownload.bytesDownloaded, + status: persistedDownload.status, + startedAt: persistedDownload.startedAt, task: nil, - localUri: p.localUri, - downloadUrl: p.downloadUrl, + localUri: persistedDownload.localUri, + downloadUrl: persistedDownload.downloadUrl, fileTasks: fileTasks, - multiFileDestDir: p.multiFileDestDir, - isMultiFile: p.isMultiFile + multiFileDestDir: persistedDownload.multiFileDestDir, + isMultiFile: persistedDownload.isMultiFile ) - downloads[p.downloadId] = info + downloads[persistedDownload.downloadId] = info // Ensure nextDownloadId is higher than any restored ID - if p.downloadId >= nextDownloadId { - nextDownloadId = p.downloadId + 1 + if persistedDownload.downloadId >= nextDownloadId { + nextDownloadId = persistedDownload.downloadId + 1 } - NSLog("[DownloadManager] Restored download #%lld: %@ (%@)", p.downloadId, p.fileName, p.status) + NSLog("[DownloadManager] Restored download #%lld: %@ (%@)", persistedDownload.downloadId, persistedDownload.fileName, persistedDownload.status) } // Clean out completed/failed entries from persistence diff --git a/ios/OffgridMobile/PDFExtractor/PDFExtractorModule.swift b/ios/OffgridMobile/PDFExtractor/PDFExtractorModule.swift index 27e0d731..a5e08c22 100644 --- a/ios/OffgridMobile/PDFExtractor/PDFExtractorModule.swift +++ b/ios/OffgridMobile/PDFExtractor/PDFExtractorModule.swift @@ -20,17 +20,17 @@ class PDFExtractorModule: NSObject { let limit = Int(maxChars) var fullText = "" - for i in 0..= limit { fullText = String(fullText.prefix(limit)) - fullText += "\n\n... [Extracted \(i + 1) of \(document.pageCount) pages]" + fullText += "\n\n... [Extracted \(pageIndex + 1) of \(document.pageCount) pages]" break } } diff --git a/ios/OffgridMobileTests/OffgridMobileTests.swift b/ios/OffgridMobileTests/OffgridMobileTests.swift new file mode 100644 index 00000000..76b65323 --- /dev/null +++ b/ios/OffgridMobileTests/OffgridMobileTests.swift @@ -0,0 +1,555 @@ +import XCTest +import PDFKit + +@testable import OffgridMobile + +// MARK: - PDFExtractorModule Tests + +final class PDFExtractorModuleTests: XCTestCase { + + private var module: PDFExtractorModule! + + override func setUp() { + super.setUp() + module = PDFExtractorModule() + } + + /// Creates an n-page PDF and returns its file URL in the temp directory. + private func makeTempPDF(pages: [(text: String, rect: CGRect)] = []) -> URL { + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".pdf") + let pageSize = CGRect(x: 0, y: 0, width: 612, height: 792) + let renderer = UIGraphicsPDFRenderer(bounds: pageSize) + let data = renderer.pdfData { ctx in + let attrs: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: 12)] + for page in pages { + ctx.beginPage() + page.text.draw(in: page.rect, withAttributes: attrs) + } + } + try! data.write(to: url) + return url + } + + private func singlePage(text: String) -> URL { + makeTempPDF(pages: [(text, CGRect(x: 72, y: 72, width: 468, height: 648))]) + } + + // MARK: requiresMainQueueSetup + + func testRequiresMainQueueSetupReturnsFalse() { + XCTAssertFalse(PDFExtractorModule.requiresMainQueueSetup()) + } + + // MARK: extractText — happy path + + func testExtractTextResolvesWithContent() { + let url = singlePage(text: "Hello, PDF World!") + let exp = expectation(description: "resolve") + + module.extractText( + url.absoluteString, + maxChars: 10_000, + resolver: { result in + XCTAssertNotNil(result) + exp.fulfill() + }, + rejecter: { _, _, _ in + XCTFail("extractText should not reject a valid PDF") + exp.fulfill() + } + ) + + waitForExpectations(timeout: 5) + try? FileManager.default.removeItem(at: url) + } + + func testExtractTextFromMultiPagePDF() { + let url = makeTempPDF(pages: [ + ("Page one content", CGRect(x: 72, y: 72, width: 468, height: 648)), + ("Page two content", CGRect(x: 72, y: 72, width: 468, height: 648)), + ]) + let exp = expectation(description: "multi-page resolve") + + module.extractText( + url.absoluteString, + maxChars: 10_000, + resolver: { result in + XCTAssertNotNil(result) + exp.fulfill() + }, + rejecter: { _, _, _ in + XCTFail("multi-page extractText should not reject") + exp.fulfill() + } + ) + + waitForExpectations(timeout: 5) + try? FileManager.default.removeItem(at: url) + } + + func testExtractTextFromEmptyPDF() { + // PDF with a page but no text drawn — should resolve with empty string + let url = makeTempPDF(pages: [("", CGRect(x: 72, y: 72, width: 468, height: 648))]) + let exp = expectation(description: "empty pdf resolve") + + module.extractText( + url.absoluteString, + maxChars: 10_000, + resolver: { result in + XCTAssertNotNil(result) + exp.fulfill() + }, + rejecter: { _, _, _ in + XCTFail("empty-page PDF should not reject") + exp.fulfill() + } + ) + + waitForExpectations(timeout: 5) + try? FileManager.default.removeItem(at: url) + } + + // MARK: extractText — truncation + + func testExtractTextTruncatesAtMaxChars() { + let longText = String(repeating: "A", count: 300) + let url = singlePage(text: longText) + let exp = expectation(description: "truncate") + + module.extractText( + url.absoluteString, + maxChars: 50, + resolver: { result in + let text = (result as? String) ?? "" + XCTAssertTrue( + text.contains("... [Extracted"), + "Truncated result should contain page marker, got: \(text.prefix(120))" + ) + exp.fulfill() + }, + rejecter: { _, _, _ in + XCTFail("extractText should not reject") + exp.fulfill() + } + ) + + waitForExpectations(timeout: 5) + try? FileManager.default.removeItem(at: url) + } + + func testExtractTextDoesNotTruncateWhenUnderLimit() { + let shortText = "Short" + let url = singlePage(text: shortText) + let exp = expectation(description: "no truncate") + + module.extractText( + url.absoluteString, + maxChars: 10_000, + resolver: { result in + let text = (result as? String) ?? "" + XCTAssertFalse( + text.contains("... [Extracted"), + "Short text should not be truncated" + ) + exp.fulfill() + }, + rejecter: { _, _, _ in + XCTFail("should not reject") + exp.fulfill() + } + ) + + waitForExpectations(timeout: 5) + try? FileManager.default.removeItem(at: url) + } + + // MARK: extractText — error cases + + func testExtractTextRejectsInvalidPath() { + let exp = expectation(description: "reject invalid path") + + module.extractText( + "/nonexistent/path/file.pdf", + maxChars: 10_000, + resolver: { _ in + XCTFail("extractText should reject a non-existent file") + exp.fulfill() + }, + rejecter: { code, _, _ in + XCTAssertEqual(code, "PDF_ERROR") + exp.fulfill() + } + ) + + waitForExpectations(timeout: 5) + } + + func testExtractTextRejectsNonPDFFile() { + // Write a plain-text file and pass it as a PDF + let url = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString + ".pdf") + try! "not a pdf".write(to: url, atomically: true, encoding: .utf8) + let exp = expectation(description: "reject non-pdf") + + module.extractText( + url.absoluteString, + maxChars: 10_000, + resolver: { _ in + XCTFail("should reject a non-PDF file") + exp.fulfill() + }, + rejecter: { code, _, _ in + XCTAssertEqual(code, "PDF_ERROR") + exp.fulfill() + } + ) + + waitForExpectations(timeout: 5) + try? FileManager.default.removeItem(at: url) + } +} + +// MARK: - CoreMLDiffusionModule Tests + +final class CoreMLDiffusionModuleTests: XCTestCase { + + private var module: CoreMLDiffusionModule! + + override func setUp() { + super.setUp() + module = CoreMLDiffusionModule() + } + + // MARK: requiresMainQueueSetup + + func testRequiresMainQueueSetupReturnsFalse() { + XCTAssertFalse(CoreMLDiffusionModule.requiresMainQueueSetup()) + } + + // MARK: supportedEvents + + func testSupportedEvents() { + let events = module.supportedEvents()! + XCTAssertTrue(events.contains("LocalDreamProgress")) + XCTAssertTrue(events.contains("LocalDreamError")) + XCTAssertEqual(events.count, 2) + } + + // MARK: initial state queries + + func testIsNpuSupportedReturnsTrue() { + let exp = expectation(description: "isNpuSupported") + module.isNpuSupported( + { value in + XCTAssertEqual(value as? Bool, true) + exp.fulfill() + }, + rejecter: { _, _, _ in XCTFail("unexpected reject"); exp.fulfill() } + ) + waitForExpectations(timeout: 2) + } + + func testIsGeneratingReturnsFalseInitially() { + let exp = expectation(description: "isGenerating") + module.isGenerating( + { value in + XCTAssertEqual(value as? Bool, false) + exp.fulfill() + }, + rejecter: { _, _, _ in XCTFail("unexpected reject"); exp.fulfill() } + ) + waitForExpectations(timeout: 2) + } + + func testIsModelLoadedReturnsFalseInitially() { + let exp = expectation(description: "isModelLoaded") + module.isModelLoaded( + { value in + XCTAssertEqual(value as? Bool, false) + exp.fulfill() + }, + rejecter: { _, _, _ in XCTFail("unexpected reject"); exp.fulfill() } + ) + waitForExpectations(timeout: 2) + } + + func testGetLoadedModelPathReturnsNilInitially() { + let exp = expectation(description: "getLoadedModelPath") + module.getLoadedModelPath( + { value in + // No model loaded — path must be nil or non-String + XCTAssertNil(value as? String) + exp.fulfill() + }, + rejecter: { _, _, _ in XCTFail("unexpected reject"); exp.fulfill() } + ) + waitForExpectations(timeout: 2) + } + + // MARK: cancel / unload + + func testCancelGenerationSucceeds() { + let exp = expectation(description: "cancelGeneration") + module.cancelGeneration( + { value in + XCTAssertEqual(value as? Bool, true) + exp.fulfill() + }, + rejecter: { _, _, _ in XCTFail("unexpected reject"); exp.fulfill() } + ) + waitForExpectations(timeout: 2) + } + + func testCancelGenerationDoesNotAffectGeneratingState() { + // cancelGeneration with no active generation must leave isGenerating = false + let cancelExp = expectation(description: "cancel") + module.cancelGeneration( + { _ in cancelExp.fulfill() }, + rejecter: { _, _, _ in cancelExp.fulfill() } + ) + waitForExpectations(timeout: 2) + + let stateExp = expectation(description: "isGenerating after cancel") + module.isGenerating( + { value in + XCTAssertEqual(value as? Bool, false) + stateExp.fulfill() + }, + rejecter: { _, _, _ in XCTFail(); stateExp.fulfill() } + ) + waitForExpectations(timeout: 2) + } + + func testUnloadModelSucceeds() { + // Unloading when no model is loaded should still resolve true + let exp = expectation(description: "unloadModel") + module.unloadModel( + { value in + XCTAssertEqual(value as? Bool, true) + exp.fulfill() + }, + rejecter: { _, _, _ in XCTFail("unexpected reject"); exp.fulfill() } + ) + waitForExpectations(timeout: 2) + } + + func testUnloadModelKeepsIsModelLoadedFalse() { + let unloadExp = expectation(description: "unload") + module.unloadModel( + { _ in unloadExp.fulfill() }, + rejecter: { _, _, _ in unloadExp.fulfill() } + ) + waitForExpectations(timeout: 2) + + let checkExp = expectation(description: "isModelLoaded after unload") + module.isModelLoaded( + { value in + XCTAssertEqual(value as? Bool, false) + checkExp.fulfill() + }, + rejecter: { _, _, _ in XCTFail(); checkExp.fulfill() } + ) + waitForExpectations(timeout: 2) + } + + // MARK: generateImage guard — no model loaded + + func testGenerateImageWithoutModelRejectsWithNoModel() { + let exp = expectation(description: "generateImage rejects without model") + module.generateImage( + ["prompt": "a cat"], + resolver: { _ in + XCTFail("should reject when no model is loaded") + exp.fulfill() + }, + rejecter: { code, _, _ in + XCTAssertEqual(code, "ERR_NO_MODEL") + exp.fulfill() + } + ) + waitForExpectations(timeout: 2) + } + + // MARK: getGeneratedImages + + func testGetGeneratedImagesReturnsArray() { + let exp = expectation(description: "getGeneratedImages") + module.getGeneratedImages( + { value in + XCTAssertNotNil(value as? [[String: Any]], "Expected an array of image dictionaries") + exp.fulfill() + }, + rejecter: { _, _, _ in XCTFail("unexpected reject"); exp.fulfill() } + ) + waitForExpectations(timeout: 2) + } +} + +// MARK: - DownloadManagerModule Tests + +final class DownloadManagerModuleTests: XCTestCase { + + private var module: DownloadManagerModule! + + override func setUp() { + super.setUp() + // Clear any persisted download state so tests start clean + UserDefaults.standard.removeObject(forKey: "ai.offgridmobile.activeDownloads") + module = DownloadManagerModule() + } + + // MARK: requiresMainQueueSetup + + func testRequiresMainQueueSetupReturnsFalse() { + XCTAssertFalse(DownloadManagerModule.requiresMainQueueSetup()) + } + + // MARK: supportedEvents + + func testSupportedEventsContainsAllExpectedEvents() { + let events = module.supportedEvents()! + XCTAssertTrue(events.contains("DownloadProgress")) + XCTAssertTrue(events.contains("DownloadComplete")) + XCTAssertTrue(events.contains("DownloadError")) + XCTAssertEqual(events.count, 3) + } + + // MARK: getActiveDownloads + + func testGetActiveDownloadsInitiallyEmpty() { + let exp = expectation(description: "getActiveDownloads empty") + module.getActiveDownloads( + { value in + let downloads = value as? [[String: Any]] ?? [] + XCTAssertEqual(downloads.count, 0, "No active downloads expected after fresh init") + exp.fulfill() + }, + rejecter: { _, _, _ in XCTFail("unexpected reject"); exp.fulfill() } + ) + waitForExpectations(timeout: 2) + } + + // MARK: getDownloadProgress — unknown id + + func testGetDownloadProgressRejectsUnknownId() { + let exp = expectation(description: "getDownloadProgress rejects unknown id") + module.getDownloadProgress( + 99_999, + resolver: { _ in + XCTFail("should reject for unknown download id") + exp.fulfill() + }, + rejecter: { code, _, _ in + XCTAssertEqual(code, "NOT_FOUND") + exp.fulfill() + } + ) + waitForExpectations(timeout: 2) + } + + // MARK: cancelDownload — unknown id + + func testCancelDownloadRejectsUnknownId() { + let exp = expectation(description: "cancelDownload rejects unknown id") + module.cancelDownload( + 99_999, + resolver: { _ in + XCTFail("should reject for unknown download id") + exp.fulfill() + }, + rejecter: { code, _, _ in + XCTAssertEqual(code, "NOT_FOUND") + exp.fulfill() + } + ) + waitForExpectations(timeout: 2) + } + + // MARK: moveCompletedDownload — unknown id + + func testMoveCompletedDownloadRejectsUnknownId() { + let exp = expectation(description: "moveCompletedDownload rejects unknown id") + module.moveCompletedDownload( + 99_999, + targetPath: "/tmp/model.bin", + resolver: { _ in + XCTFail("should reject for unknown download id") + exp.fulfill() + }, + rejecter: { code, _, _ in + XCTAssertEqual(code, "NOT_FOUND") + exp.fulfill() + } + ) + waitForExpectations(timeout: 2) + } + + // MARK: startDownload — invalid params + + func testStartDownloadRejectsMissingUrl() { + let exp = expectation(description: "startDownload rejects missing url") + module.startDownload( + ["fileName": "model.bin", "modelId": "m1"], + resolver: { _ in + XCTFail("should reject when url is missing") + exp.fulfill() + }, + rejecter: { code, _, _ in + XCTAssertEqual(code, "INVALID_PARAMS") + exp.fulfill() + } + ) + waitForExpectations(timeout: 2) + } + + func testStartDownloadRejectsMissingFileName() { + let exp = expectation(description: "startDownload rejects missing fileName") + module.startDownload( + ["url": "https://example.com/model.bin", "modelId": "m1"], + resolver: { _ in + XCTFail("should reject when fileName is missing") + exp.fulfill() + }, + rejecter: { code, _, _ in + XCTAssertEqual(code, "INVALID_PARAMS") + exp.fulfill() + } + ) + waitForExpectations(timeout: 2) + } + + func testStartDownloadRejectsMissingModelId() { + let exp = expectation(description: "startDownload rejects missing modelId") + module.startDownload( + ["url": "https://example.com/model.bin", "fileName": "model.bin"], + resolver: { _ in + XCTFail("should reject when modelId is missing") + exp.fulfill() + }, + rejecter: { code, _, _ in + XCTAssertEqual(code, "INVALID_PARAMS") + exp.fulfill() + } + ) + waitForExpectations(timeout: 2) + } + + // MARK: startMultiFileDownload — invalid params + + func testStartMultiFileDownloadRejectsMissingParams() { + let exp = expectation(description: "startMultiFileDownload rejects missing params") + module.startMultiFileDownload( + [:], + resolver: { _ in + XCTFail("should reject when params are missing") + exp.fulfill() + }, + rejecter: { code, _, _ in + XCTAssertEqual(code, "INVALID_PARAMS") + exp.fulfill() + } + ) + waitForExpectations(timeout: 2) + } +} diff --git a/ios/Podfile b/ios/Podfile index 2a9ca2df..9faab368 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -23,6 +23,10 @@ target 'OffgridMobile' do :app_path => "#{Pod::Config.instance.installation_root}/.." ) + target 'OffgridMobileTests' do + inherit! :search_paths + end + post_install do |installer| react_native_post_install( installer, diff --git a/ios/Podfile.lock b/ios/Podfile.lock index faf8f1b9..2e4e142d 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -3608,6 +3608,6 @@ SPEC CHECKSUMS: whisper-rn: 7566faf9b7d78e39ab9fc634cb90fdee81177793 Yoga: 5456bb010373068fc92221140921b09d126b116e -PODFILE CHECKSUM: 2d58f6c10e8da008e32da26f04d8b4cac1768a91 +PODFILE CHECKSUM: 31818a1f7d1207c486dba2e42df373cf65ace073 COCOAPODS: 1.16.2 diff --git a/package-lock.json b/package-lock.json index 39c47621..9de4dbf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,9 @@ "@types/react": "^19.2.0", "@types/react-test-renderer": "^19.1.0", "eslint": "^8.19.0", + "husky": "^9.1.7", "jest": "^29.6.3", + "lint-staged": "^15.5.2", "prettier": "2.8.8", "react-test-renderer": "19.2.0", "typescript": "^5.8.3" @@ -5616,6 +5618,120 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cli-truncate": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", + "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^5.0.0", + "string-width": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", + "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/slice-ansi": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", + "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.0.0", + "is-fullwidth-code-point": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", @@ -6245,6 +6361,19 @@ "node": ">=4" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -6988,6 +7117,13 @@ "node": ">=6" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "dev": true, + "license": "MIT" + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -7412,6 +7548,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -7803,6 +7952,22 @@ "node": ">=10.17.0" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -9387,6 +9552,19 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -9403,6 +9581,311 @@ "uc.micro": "^1.0.1" } }, + "node_modules/lint-staged": { + "version": "15.5.2", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-15.5.2.tgz", + "integrity": "sha512-YUSOLq9VeRNAo/CTaVmhGDKG+LBtA8KF1X4K5+ykMSwWST1vDxJRB2kv2COgLb1fvpCo+A/y9A0G0znNVmdx4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^5.4.1", + "commander": "^13.1.0", + "debug": "^4.4.0", + "execa": "^8.0.1", + "lilconfig": "^3.1.3", + "listr2": "^8.2.5", + "micromatch": "^4.0.8", + "pidtree": "^0.6.0", + "string-argv": "^0.3.2", + "yaml": "^2.7.0" + }, + "bin": { + "lint-staged": "bin/lint-staged.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://opencollective.com/lint-staged" + } + }, + "node_modules/lint-staged/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/commander": { + "version": "13.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-13.1.0.tgz", + "integrity": "sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/lint-staged/node_modules/execa": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-8.0.1.tgz", + "integrity": "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^8.0.1", + "human-signals": "^5.0.0", + "is-stream": "^3.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^5.1.0", + "onetime": "^6.0.0", + "signal-exit": "^4.1.0", + "strip-final-newline": "^3.0.0" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/lint-staged/node_modules/get-stream": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", + "integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/human-signals": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz", + "integrity": "sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.17.0" + } + }, + "node_modules/lint-staged/node_modules/is-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", + "integrity": "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/mimic-fn": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz", + "integrity": "sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/npm-run-path": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-5.3.0.tgz", + "integrity": "sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/onetime": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", + "integrity": "sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/path-key": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-4.0.0.tgz", + "integrity": "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lint-staged/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/lint-staged/node_modules/strip-final-newline": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-3.0.0.tgz", + "integrity": "sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2": { + "version": "8.3.3", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.3.3.tgz", + "integrity": "sha512-LWzX2KsqcB1wqQ4AHgYb4RsDXauQiqhjLk+6hjbaeHG4zpjjVAB6wC/gz6X0l+Du1cN3pUB5ZlrvTbhGSNnUQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "cli-truncate": "^4.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/llama.rn": { "version": "0.11.0-rc.3", "resolved": "https://registry.npmjs.org/llama.rn/-/llama.rn-0.11.0-rc.3.tgz", @@ -9475,6 +9958,222 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/logkitty": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/logkitty/-/logkitty-0.7.1.tgz", @@ -10167,6 +10866,19 @@ "node": ">=6" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -10857,6 +11569,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pidtree": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/pidtree/-/pidtree-0.6.0.tgz", + "integrity": "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g==", + "dev": true, + "license": "MIT", + "bin": { + "pidtree": "bin/pidtree.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/pirates": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", @@ -11942,6 +12667,13 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -12596,6 +13328,16 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-argv": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", + "integrity": "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6.19" + } + }, "node_modules/string-length": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", diff --git a/package.json b/package.json index e3575c40..c22a3790 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,15 @@ "android:fdroid": "react-native run-android --mode=fdroidDebug --appId ai.offgridmobile.dev", "ios": "react-native run-ios", "lint": "eslint .", + "prepare": "husky", "start": "react-native start", "test": "jest", "test:coverage": "jest --coverage --forceExit", "test:e2e": "./scripts/run-tests.sh", "test:e2e:all": "./scripts/run-tests.sh", "test:e2e:single": "maestro test", + "test:android": "cd android && ./gradlew :app:testStandardDebugUnitTest", + "test:ios": "cd ios && xcodebuild test -workspace OffgridMobile.xcworkspace -scheme OffgridMobile -destination 'platform=iOS Simulator,name=iPhone 16e' -only-testing:OffgridMobileTests | xcpretty", "postinstall": "patch-package" }, "dependencies": { @@ -68,11 +71,16 @@ "@types/react": "^19.2.0", "@types/react-test-renderer": "^19.1.0", "eslint": "^8.19.0", + "husky": "^9.1.7", "jest": "^29.6.3", + "lint-staged": "^15.5.2", "prettier": "2.8.8", "react-test-renderer": "19.2.0", "typescript": "^5.8.3" }, + "lint-staged": { + "*.{ts,tsx,js,jsx}": "eslint --max-warnings=999" + }, "engines": { "node": ">=20" } diff --git a/src/components/AnimatedPressable.tsx b/src/components/AnimatedPressable.tsx index d69755b2..efe61998 100644 --- a/src/components/AnimatedPressable.tsx +++ b/src/components/AnimatedPressable.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { TouchableOpacity, type TouchableOpacityProps, type StyleProp, type ViewStyle, type GestureResponderEvent } from 'react-native'; +import { TouchableOpacity, StyleSheet, type TouchableOpacityProps, type StyleProp, type ViewStyle, type GestureResponderEvent } from 'react-native'; import Animated, { useSharedValue, useAnimatedStyle, @@ -87,9 +87,18 @@ export function AnimatedPressable({ hitSlop={hitSlop} accessibilityLabel={accessibilityLabel} accessibilityRole={accessibilityRole} - style={[animatedStyle, { opacity: disabled ? 0.4 : 1, overflow: 'visible' as const }, style]} + style={[animatedStyle, styles.base, disabled && styles.disabled, style]} > {children} ); } + +const styles = StyleSheet.create({ + base: { + overflow: 'visible', + }, + disabled: { + opacity: 0.4, + }, +}); diff --git a/src/components/AppSheet.styles.ts b/src/components/AppSheet.styles.ts new file mode 100644 index 00000000..487dbbc5 --- /dev/null +++ b/src/components/AppSheet.styles.ts @@ -0,0 +1,47 @@ +import type { ThemeColors, ThemeShadows } from '../theme'; +import { TYPOGRAPHY, SPACING } from '../constants'; + +export const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ + container: { + flex: 1, + justifyContent: 'flex-end' as const, + }, + backdrop: { + ...({ + position: 'absolute' as const, + left: 0, + right: 0, + top: 0, + bottom: 0, + }), + backgroundColor: '#000000', + }, + sheet: { + overflow: 'hidden' as const, + ...shadows.large, + }, + handleContainer: { + alignItems: 'center' as const, + paddingVertical: SPACING.sm, + }, + handle: {}, + header: { + flexDirection: 'row' as const, + justifyContent: 'space-between' as const, + alignItems: 'center' as const, + paddingVertical: SPACING.md, + paddingHorizontal: SPACING.lg, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + headerTitle: { + ...TYPOGRAPHY.h3, + color: colors.text, + flex: 1, + marginRight: SPACING.md, + }, + headerClose: { + ...TYPOGRAPHY.body, + color: colors.primary, + }, +}); diff --git a/src/components/AppSheet.tsx b/src/components/AppSheet.tsx index f6a11123..ad1096bf 100644 --- a/src/components/AppSheet.tsx +++ b/src/components/AppSheet.tsx @@ -15,8 +15,7 @@ import { } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useTheme, useThemedStyles } from '../theme'; -import type { ThemeColors, ThemeShadows } from '../theme'; -import { TYPOGRAPHY, SPACING } from '../constants'; +import { createStyles } from './AppSheet.styles'; const { height: SCREEN_HEIGHT } = Dimensions.get('window'); @@ -41,6 +40,54 @@ function resolveSnapPoint(snap: string | number): number { return SCREEN_HEIGHT * 0.5; } +function createSheetPanResponder({ + translateY, + backdropOpacity, + setModalVisible, + onCloseRef, +}: { + translateY: Animated.Value; + backdropOpacity: Animated.Value; + setModalVisible: (v: boolean) => void; + onCloseRef: React.MutableRefObject<() => void>; +}) { + return PanResponder.create({ + onStartShouldSetPanResponder: () => false, + onMoveShouldSetPanResponder: (_, { dy }) => Math.abs(dy) > 8, + onPanResponderMove: (_, { dy }) => { + if (dy > 0) { + translateY.setValue(dy); + } + }, + onPanResponderRelease: (_, { dy, vy }) => { + if (dy > 80 || vy > 0.5) { + Animated.parallel([ + Animated.timing(translateY, { + toValue: SCREEN_HEIGHT, + duration: 150, + useNativeDriver: true, + }), + Animated.timing(backdropOpacity, { + toValue: 0, + duration: 150, + useNativeDriver: true, + }), + ]).start(() => { + setModalVisible(false); + onCloseRef.current(); + }); + } else { + Animated.spring(translateY, { + toValue: 0, + damping: 28, + stiffness: 300, + useNativeDriver: true, + }).start(); + } + }, + }); +} + export const AppSheet: React.FC = ({ visible, onClose, @@ -157,9 +204,9 @@ export const AppSheet: React.FC = ({ clearTimeout(timeout); sub.remove(); }; - } else { + } setModalVisible(true); - } + } else if (modalVisible) { animateOut(() => setModalVisible(false)); } @@ -191,43 +238,7 @@ export const AppSheet: React.FC = ({ // Swipe-to-dismiss on handle const panResponder = useRef( - PanResponder.create({ - onStartShouldSetPanResponder: () => false, - onMoveShouldSetPanResponder: (_, { dy }) => Math.abs(dy) > 8, - onPanResponderMove: (_, { dy }) => { - if (dy > 0) { - translateY.setValue(dy); - } - }, - onPanResponderRelease: (_, { dy, vy }) => { - if (dy > 80 || vy > 0.5) { - // Dismiss - Animated.parallel([ - Animated.timing(translateY, { - toValue: SCREEN_HEIGHT, - duration: 150, - useNativeDriver: true, - }), - Animated.timing(backdropOpacity, { - toValue: 0, - duration: 150, - useNativeDriver: true, - }), - ]).start(() => { - setModalVisible(false); - onCloseRef.current(); - }); - } else { - // Snap back - Animated.spring(translateY, { - toValue: 0, - damping: 28, - stiffness: 300, - useNativeDriver: true, - }).start(); - } - }, - }), + createSheetPanResponder({ translateY, backdropOpacity, setModalVisible, onCloseRef }), ).current; if (!modalVisible && !visible) { @@ -317,48 +328,3 @@ export const AppSheet: React.FC = ({ ); }; - -const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ - container: { - flex: 1, - justifyContent: 'flex-end' as const, - }, - backdrop: { - ...({ - position: 'absolute' as const, - left: 0, - right: 0, - top: 0, - bottom: 0, - }), - backgroundColor: '#000000', - }, - sheet: { - overflow: 'hidden' as const, - ...shadows.large, - }, - handleContainer: { - alignItems: 'center' as const, - paddingVertical: SPACING.sm, - }, - handle: {}, - header: { - flexDirection: 'row' as const, - justifyContent: 'space-between' as const, - alignItems: 'center' as const, - paddingVertical: SPACING.md, - paddingHorizontal: SPACING.lg, - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - headerTitle: { - ...TYPOGRAPHY.h3, - color: colors.text, - flex: 1, - marginRight: SPACING.md, - }, - headerClose: { - ...TYPOGRAPHY.body, - color: colors.primary, - }, -}); diff --git a/src/components/ChatInput.tsx b/src/components/ChatInput.tsx deleted file mode 100644 index da4854a7..00000000 --- a/src/components/ChatInput.tsx +++ /dev/null @@ -1,637 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { - View, - TextInput, - TouchableOpacity, - Image, - ScrollView, - Text, - Platform, -} from 'react-native'; -import { launchImageLibrary, launchCamera, Asset } from 'react-native-image-picker'; -import { pick, types, isErrorWithCode, errorCodes } from '@react-native-documents/picker'; -import Icon from 'react-native-vector-icons/Feather'; -import { useTheme, useThemedStyles } from '../theme'; -import type { ThemeColors, ThemeShadows } from '../theme'; -import { FONTS, SPACING } from '../constants'; -import { MediaAttachment, ImageModeState } from '../types'; -import { documentService } from '../services/documentService'; -import { VoiceRecordButton } from './VoiceRecordButton'; -import { triggerHaptic } from '../utils/haptics'; -import { CustomAlert, showAlert, hideAlert, AlertState, initialAlertState } from './CustomAlert'; -import { useWhisperTranscription } from '../hooks/useWhisperTranscription'; -import { useWhisperStore, useAppStore } from '../stores'; - -interface ChatInputProps { - onSend: (message: string, attachments?: MediaAttachment[], forceImageMode?: boolean) => void; - onStop?: () => void; - disabled?: boolean; - isGenerating?: boolean; - placeholder?: string; - supportsVision?: boolean; - conversationId?: string | null; - imageModelLoaded?: boolean; - onImageModeChange?: (mode: ImageModeState) => void; - onOpenSettings?: () => void; - queueCount?: number; - queuedTexts?: string[]; - onClearQueue?: () => void; -} - -export const ChatInput: React.FC = ({ - onSend, - onStop, - disabled, - isGenerating, - placeholder = 'Type a message...', - supportsVision = false, - conversationId, - imageModelLoaded = false, - onImageModeChange, - onOpenSettings: _onOpenSettings, - queueCount = 0, - queuedTexts = [], - onClearQueue, -}) => { - const { colors } = useTheme(); - const styles = useThemedStyles(createStyles); - const [message, setMessage] = useState(''); - const [attachments, setAttachments] = useState([]); - const [imageMode, setImageMode] = useState('auto'); - const [alertState, setAlertState] = useState(initialAlertState); - - const { settings } = useAppStore(); - - // Track which conversation the recording was started in - const recordingConversationIdRef = useRef(null); - - const { downloadedModelId } = useWhisperStore(); - - const { - isRecording, - isModelLoading, - isTranscribing, - partialResult, - finalResult, - error, - startRecording: startRecordingBase, - stopRecording, - clearResult, - } = useWhisperTranscription(); - - // Voice is available if whisper model is downloaded and loaded - const voiceAvailable = !!downloadedModelId; - - // Wrap startRecording to track conversation ID - const startRecording = async () => { - recordingConversationIdRef.current = conversationId || null; - await startRecordingBase(); - }; - - // Clear pending transcription when conversation changes - useEffect(() => { - // If conversation changed while we had a pending result, clear it - if (recordingConversationIdRef.current && recordingConversationIdRef.current !== conversationId) { - clearResult(); - recordingConversationIdRef.current = null; - } - }, [conversationId, clearResult]); - - // Insert transcribed text into message - only if same conversation - useEffect(() => { - if (finalResult) { - // Only apply if we're still in the same conversation where recording started - if (!recordingConversationIdRef.current || recordingConversationIdRef.current === conversationId) { - setMessage(prev => { - const prefix = prev.trim() ? prev.trim() + ' ' : ''; - return prefix + finalResult; - }); - } - clearResult(); - recordingConversationIdRef.current = null; - } - }, [finalResult, clearResult, conversationId]); - - const inputRef = useRef(null); - - const handleSend = () => { - if ((message.trim() || attachments.length > 0) && !disabled) { - triggerHaptic('impactMedium'); - const forceImage = imageMode === 'force'; - onSend(message.trim(), attachments.length > 0 ? attachments : undefined, forceImage); - setMessage(''); - setAttachments([]); - // Keep keyboard open and refocus input for quick follow-up messages - inputRef.current?.focus(); - // Reset to auto mode after sending with force mode - if (forceImage) { - setImageMode('auto'); - onImageModeChange?.('auto'); - } - } - }; - - const handleImageModeToggle = () => { - if (!imageModelLoaded) { - setAlertState(showAlert( - 'No Image Model', - 'Download an image model from the Models screen to enable image generation.', - [{ text: 'OK' }] - )); - return; - } - - // Toggle between auto and force - const newMode: ImageModeState = imageMode === 'auto' ? 'force' : 'auto'; - setImageMode(newMode); - onImageModeChange?.(newMode); - }; - - const handleStop = () => { - if (onStop && isGenerating) { - triggerHaptic('impactLight'); - onStop(); - } - }; - - const handlePickImage = () => { - setAlertState(showAlert( - 'Add Image', - 'Choose image source', - [ - { - text: 'Camera', - onPress: () => { - setAlertState(hideAlert()); - // Delay picker launch to allow AppSheet modal close animation to finish - setTimeout(pickFromCamera, 300); - }, - }, - { - text: 'Photo Library', - onPress: () => { - setAlertState(hideAlert()); - // Delay picker launch to allow AppSheet modal close animation to finish - setTimeout(pickFromLibrary, 300); - }, - }, - { - text: 'Cancel', - style: 'cancel', - }, - ] - )); - }; - - const pickFromLibrary = async () => { - try { - const result = await launchImageLibrary({ - mediaType: 'photo', - quality: 0.8, - maxWidth: 1024, - maxHeight: 1024, - }); - - if (result.assets && result.assets.length > 0) { - addAttachments(result.assets); - } - } catch (pickError) { - console.error('Error picking image:', pickError); - } - }; - - const pickFromCamera = async () => { - try { - const result = await launchCamera({ - mediaType: 'photo', - quality: 0.8, - maxWidth: 1024, - maxHeight: 1024, - }); - - if (result.assets && result.assets.length > 0) { - addAttachments(result.assets); - } - } catch (cameraError) { - console.error('Error taking photo:', cameraError); - } - }; - - const addAttachments = (assets: Asset[]) => { - const newAttachments: MediaAttachment[] = assets - .filter(asset => asset.uri) - .map(asset => ({ - id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - type: 'image' as const, - uri: asset.uri!, - mimeType: asset.type, - width: asset.width, - height: asset.height, - fileName: asset.fileName, - })); - - setAttachments(prev => [...prev, ...newAttachments]); - }; - - const removeAttachment = (id: string) => { - setAttachments(prev => prev.filter(a => a.id !== id)); - }; - - const handlePickDocument = async () => { - try { - const result = await pick({ - type: [types.allFiles], - allowMultiSelection: false, - }); - - const file = result[0]; - if (!file) return; - - const fileName = file.name || 'document'; - - if (!documentService.isSupported(fileName)) { - setAlertState(showAlert( - 'Unsupported File', - `"${fileName}" is not supported. Supported types: txt, md, csv, json, pdf, and code files.`, - [{ text: 'OK' }] - )); - return; - } - - const attachment = await documentService.processDocumentFromPath(file.uri, fileName); - if (attachment) { - setAttachments(prev => [...prev, attachment]); - } - } catch (pickError: any) { - if (isErrorWithCode(pickError) && pickError.code === errorCodes.OPERATION_CANCELED) return; - console.error('Error picking document:', pickError); - setAlertState(showAlert( - 'Error', - pickError.message || 'Failed to read document', - [{ text: 'OK' }] - )); - } - }; - - const canSend = (message.trim() || attachments.length > 0) && !disabled; - - return ( - - {/* Attachment Preview */} - {attachments.length > 0 && ( - - {attachments.map(attachment => ( - - {attachment.type === 'image' ? ( - - ) : ( - - - - {attachment.fileName || 'Document'} - - - )} - removeAttachment(attachment.id)} - > - × - - - ))} - - )} - {/* Toolbar Row */} - - {/* Left side: Vision, Image Gen (manual only), Status */} - - {/* Document picker button - always visible (works with any text model) */} - - - - - {/* Image picker button - only show if vision is supported */} - {supportsVision && ( - - - - )} - - {/* Vision indicator */} - {supportsVision && ( - - Vision - - )} - - - {/* Image generation mode toggle - only in manual mode (actionable) */} - {settings.imageGenerationMode === 'manual' && imageModelLoaded && ( - - - {imageMode === 'force' && ( - - ON - - )} - - )} - - {/* Queue indicator */} - {queueCount > 0 && ( - - - {queueCount} queued - - {queuedTexts.length > 0 && ( - - {queuedTexts[0].length > 30 - ? queuedTexts[0].substring(0, 30) + '...' - : queuedTexts[0]} - - )} - - - - - )} - - - {/* Text Input Row - Input + Send Button */} - - - {/* Action buttons on the right side of input */} - - {isGenerating && onStop && ( - - - - )} - {canSend ? ( - - - - ) : !isGenerating ? ( - { - stopRecording(); - clearResult(); - }} - asSendButton - /> - ) : null} - - - setAlertState(hideAlert())} - /> - - ); -}; - -const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ - container: { - paddingHorizontal: SPACING.lg, - paddingVertical: SPACING.md, - backgroundColor: colors.background, - borderTopWidth: 1, - borderTopColor: colors.border, - }, - attachmentsContainer: { - marginBottom: 8, - }, - attachmentsContent: { - gap: 8, - }, - attachmentPreview: { - position: 'relative' as const, - width: 60, - height: 60, - borderRadius: 8, - overflow: 'hidden' as const, - }, - attachmentImage: { - width: '100%' as const, - height: '100%' as const, - }, - documentPreview: { - width: '100%' as const, - height: '100%' as const, - backgroundColor: colors.surface, - justifyContent: 'center' as const, - alignItems: 'center' as const, - padding: 4, - }, - documentName: { - fontSize: 10, - fontFamily: FONTS.mono, - color: colors.textMuted, - textAlign: 'center' as const, - marginTop: 4, - }, - removeAttachment: { - position: 'absolute' as const, - top: 2, - right: 2, - width: 20, - height: 20, - borderRadius: 10, - backgroundColor: colors.error, - alignItems: 'center' as const, - justifyContent: 'center' as const, - }, - removeAttachmentText: { - color: colors.text, - fontSize: 14, - fontWeight: 'bold' as const, - marginTop: -2, - }, - inputRow: { - flexDirection: 'row' as const, - alignItems: 'flex-end' as const, - backgroundColor: colors.surface, - borderRadius: 8, - paddingHorizontal: SPACING.md, - paddingVertical: SPACING.sm, - marginTop: SPACING.sm, - gap: SPACING.sm, - }, - input: { - flex: 1, - color: colors.text, - fontSize: 14, - fontFamily: FONTS.mono, - minHeight: 40, - maxHeight: 150, - textAlignVertical: 'top' as const, - paddingTop: Platform.OS === 'ios' ? 8 : 4, - paddingBottom: Platform.OS === 'ios' ? 8 : 4, - }, - inputActions: { - flexDirection: 'row' as const, - alignItems: 'flex-end' as const, - gap: 6, - paddingBottom: 3, - }, - toolbarRow: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - justifyContent: 'space-between' as const, - }, - toolbarLeft: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - gap: 8, - flex: 1, - }, - toolbarRight: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - gap: 8, - }, - toolbarButton: { - width: 32, - height: 32, - borderRadius: 16, - backgroundColor: 'transparent', - alignItems: 'center' as const, - justifyContent: 'center' as const, - }, - sendButton: { - width: 36, - height: 36, - borderRadius: 18, - backgroundColor: colors.surface, - borderWidth: 1, - borderColor: colors.border, - alignItems: 'center' as const, - justifyContent: 'center' as const, - }, - sendButtonDisabled: { - backgroundColor: colors.surface, - borderColor: colors.border, - opacity: 0.5, - }, - stopButton: { - backgroundColor: colors.surface, - borderColor: colors.textMuted, - }, - queueBadge: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - backgroundColor: colors.primary + '20', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 12, - gap: 4, - }, - queueBadgeText: { - fontSize: 11, - fontFamily: FONTS.mono, - fontWeight: '500' as const, - color: colors.primary, - }, - queuePreview: { - fontSize: 11, - fontFamily: FONTS.mono, - fontWeight: '300' as const, - color: colors.textMuted, - maxWidth: 140, - }, - onBadge: { - position: 'absolute' as const, - top: -2, - right: -2, - backgroundColor: colors.primary, - borderRadius: 6, - paddingHorizontal: 3, - paddingVertical: 1, - }, - onBadgeText: { - fontSize: 8, - fontFamily: FONTS.mono, - fontWeight: '700' as const, - color: colors.background, - }, - visionBadge: { - backgroundColor: colors.primary + '20', - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 8, - }, - visionBadgeText: { - fontSize: 10, - fontFamily: FONTS.mono, - fontWeight: '500' as const, - color: colors.primary, - }, -}); diff --git a/src/components/ChatInput/Attachments.tsx b/src/components/ChatInput/Attachments.tsx new file mode 100644 index 00000000..8125305c --- /dev/null +++ b/src/components/ChatInput/Attachments.tsx @@ -0,0 +1,154 @@ +import React, { useState } from 'react'; +import { View, Text, Image, ScrollView, TouchableOpacity } from 'react-native'; +import { launchImageLibrary, launchCamera, Asset } from 'react-native-image-picker'; +import { pick, types, isErrorWithCode, errorCodes } from '@react-native-documents/picker'; +import Icon from 'react-native-vector-icons/Feather'; +import { useTheme, useThemedStyles } from '../../theme'; +import { MediaAttachment } from '../../types'; +import { documentService } from '../../services/documentService'; +import { AlertState, showAlert, hideAlert } from '../CustomAlert'; +import { createStyles } from './styles'; + +// ─── useAttachments hook ────────────────────────────────────────────────────── + +export function useAttachments(setAlertState: (state: AlertState) => void) { + const [attachments, setAttachments] = useState([]); + + const addAttachments = (assets: Asset[]) => { + const newAttachments: MediaAttachment[] = assets + .filter(asset => asset.uri) + .map(asset => ({ + id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + type: 'image' as const, + uri: asset.uri!, + mimeType: asset.type, + width: asset.width, + height: asset.height, + fileName: asset.fileName, + })); + setAttachments(prev => [...prev, ...newAttachments]); + }; + + const removeAttachment = (id: string) => { + setAttachments(prev => prev.filter(a => a.id !== id)); + }; + + const pickFromLibrary = async () => { + try { + const result = await launchImageLibrary({ mediaType: 'photo', quality: 0.8, maxWidth: 1024, maxHeight: 1024 }); + if (result.assets && result.assets.length > 0) addAttachments(result.assets); + } catch (pickError) { + console.error('Error picking image:', pickError); + } + }; + + const pickFromCamera = async () => { + try { + const result = await launchCamera({ mediaType: 'photo', quality: 0.8, maxWidth: 1024, maxHeight: 1024 }); + if (result.assets && result.assets.length > 0) addAttachments(result.assets); + } catch (cameraError) { + console.error('Error taking photo:', cameraError); + } + }; + + const handlePickImage = () => { + setAlertState(showAlert( + 'Add Image', + 'Choose image source', + [ + { + text: 'Camera', + onPress: () => { + setAlertState(hideAlert()); + setTimeout(pickFromCamera, 300); + }, + }, + { + text: 'Photo Library', + onPress: () => { + setAlertState(hideAlert()); + setTimeout(pickFromLibrary, 300); + }, + }, + { text: 'Cancel', style: 'cancel' }, + ], + )); + }; + + const handlePickDocument = async () => { + try { + const result = await pick({ type: [types.allFiles], allowMultiSelection: false }); + const file = result[0]; + if (!file) return; + const fileName = file.name || 'document'; + if (!documentService.isSupported(fileName)) { + setAlertState(showAlert( + 'Unsupported File', + `"${fileName}" is not supported. Supported types: txt, md, csv, json, pdf, and code files.`, + [{ text: 'OK' }], + )); + return; + } + const attachment = await documentService.processDocumentFromPath(file.uri, fileName); + if (attachment) setAttachments(prev => [...prev, attachment]); + } catch (pickError: any) { + if (isErrorWithCode(pickError) && pickError.code === errorCodes.OPERATION_CANCELED) return; + console.error('Error picking document:', pickError); + setAlertState(showAlert('Error', pickError.message || 'Failed to read document', [{ text: 'OK' }])); + } + }; + + const clearAttachments = () => setAttachments([]); + + return { attachments, removeAttachment, clearAttachments, handlePickImage, handlePickDocument }; +} + +// ─── AttachmentPreview component ───────────────────────────────────────────── + +interface AttachmentPreviewProps { + attachments: MediaAttachment[]; + onRemove: (id: string) => void; +} + +export const AttachmentPreview: React.FC = ({ attachments, onRemove }) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + + if (attachments.length === 0) return null; + + return ( + + {attachments.map(attachment => ( + + {attachment.type === 'image' ? ( + + ) : ( + + + + {attachment.fileName || 'Document'} + + + )} + onRemove(attachment.id)} + > + × + + + ))} + + ); +}; diff --git a/src/components/ChatInput/Toolbar.tsx b/src/components/ChatInput/Toolbar.tsx new file mode 100644 index 00000000..bdb14b12 --- /dev/null +++ b/src/components/ChatInput/Toolbar.tsx @@ -0,0 +1,107 @@ +import React from 'react'; +import { View, Text, TouchableOpacity } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { useTheme, useThemedStyles } from '../../theme'; +import { useAppStore } from '../../stores'; +import { ImageModeState } from '../../types'; +import { createStyles } from './styles'; + +interface ChatToolbarProps { + supportsVision: boolean; + imageMode: ImageModeState; + imageModelLoaded: boolean; + disabled?: boolean; + queueCount: number; + queuedTexts: string[]; + onClearQueue?: () => void; + onPickDocument: () => void; + onPickImage: () => void; + onImageModeToggle: () => void; +} + +export const ChatToolbar: React.FC = ({ + supportsVision, + imageMode, + imageModelLoaded, + disabled, + queueCount, + queuedTexts, + onClearQueue, + onPickDocument, + onPickImage, + onImageModeToggle, +}) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const { settings } = useAppStore(); + + return ( + + + + + + + {supportsVision && ( + + + + )} + + {supportsVision && ( + + Vision + + )} + + {settings.imageGenerationMode === 'manual' && imageModelLoaded && ( + + + {imageMode === 'force' && ( + + ON + + )} + + )} + + {queueCount > 0 && ( + + {queueCount} queued + {queuedTexts.length > 0 && ( + + {queuedTexts[0].length > 30 ? `${queuedTexts[0].substring(0, 30)}...` : queuedTexts[0]} + + )} + + + + + )} + + + ); +}; diff --git a/src/components/ChatInput/Voice.ts b/src/components/ChatInput/Voice.ts new file mode 100644 index 00000000..1cc66a19 --- /dev/null +++ b/src/components/ChatInput/Voice.ts @@ -0,0 +1,53 @@ +import { useEffect, useRef } from 'react'; +import { useWhisperTranscription } from '../../hooks/useWhisperTranscription'; +import { useWhisperStore } from '../../stores'; + +interface UseVoiceInputParams { + conversationId?: string | null; + onTranscript: (text: string) => void; +} + +export function useVoiceInput({ conversationId, onTranscript }: UseVoiceInputParams) { + const recordingConversationIdRef = useRef(null); + const onTranscriptRef = useRef(onTranscript); + onTranscriptRef.current = onTranscript; + const { downloadedModelId } = useWhisperStore(); + + const { + isRecording, + isModelLoading, + isTranscribing, + partialResult, + finalResult, + error, + startRecording: startRecordingBase, + stopRecording, + clearResult, + } = useWhisperTranscription(); + + const voiceAvailable = !!downloadedModelId; + + const startRecording = async () => { + recordingConversationIdRef.current = conversationId || null; + await startRecordingBase(); + }; + + useEffect(() => { + if (recordingConversationIdRef.current && recordingConversationIdRef.current !== conversationId) { + clearResult(); + recordingConversationIdRef.current = null; + } + }, [conversationId, clearResult]); + + useEffect(() => { + if (finalResult) { + if (!recordingConversationIdRef.current || recordingConversationIdRef.current === conversationId) { + onTranscriptRef.current(finalResult); + } + clearResult(); + recordingConversationIdRef.current = null; + } + }, [finalResult, clearResult, conversationId]); + + return { isRecording, isModelLoading, isTranscribing, partialResult, error, voiceAvailable, startRecording, stopRecording, clearResult }; +} diff --git a/src/components/ChatInput/index.tsx b/src/components/ChatInput/index.tsx new file mode 100644 index 00000000..6f92eaf9 --- /dev/null +++ b/src/components/ChatInput/index.tsx @@ -0,0 +1,171 @@ +import React, { useState, useRef } from 'react'; +import { View, TextInput, TouchableOpacity } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { useTheme, useThemedStyles } from '../../theme'; +import { ImageModeState, MediaAttachment } from '../../types'; +import { VoiceRecordButton } from '../VoiceRecordButton'; +import { triggerHaptic } from '../../utils/haptics'; +import { CustomAlert, showAlert, hideAlert, AlertState, initialAlertState } from '../CustomAlert'; +import { createStyles } from './styles'; +import { ChatToolbar } from './Toolbar'; +import { AttachmentPreview, useAttachments } from './Attachments'; +import { useVoiceInput } from './Voice'; + +interface ChatInputProps { + onSend: (message: string, attachments?: MediaAttachment[], forceImageMode?: boolean) => void; + onStop?: () => void; + disabled?: boolean; + isGenerating?: boolean; + placeholder?: string; + supportsVision?: boolean; + conversationId?: string | null; + imageModelLoaded?: boolean; + onImageModeChange?: (mode: ImageModeState) => void; + onOpenSettings?: () => void; + queueCount?: number; + queuedTexts?: string[]; + onClearQueue?: () => void; +} + +export const ChatInput: React.FC = ({ + onSend, + onStop, + disabled, + isGenerating, + placeholder = 'Type a message...', + supportsVision = false, + conversationId, + imageModelLoaded = false, + onImageModeChange, + onOpenSettings: _onOpenSettings, + queueCount = 0, + queuedTexts = [], + onClearQueue, +}) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const [message, setMessage] = useState(''); + const [imageMode, setImageMode] = useState('auto'); + const [alertState, setAlertState] = useState(initialAlertState); + const inputRef = useRef(null); + + const { attachments, removeAttachment, clearAttachments, handlePickImage, handlePickDocument } = useAttachments(setAlertState); + + const { isRecording, isModelLoading, isTranscribing, partialResult, error, voiceAvailable, startRecording, stopRecording, clearResult } = useVoiceInput({ + conversationId, + onTranscript: (text) => { + setMessage(prev => { + const prefix = prev.trim() ? `${prev.trim()} ` : ''; + return prefix + text; + }); + }, + }); + + const canSend = (message.trim().length > 0 || attachments.length > 0) && !disabled; + + const handleSend = () => { + if (!canSend) return; + triggerHaptic('impactMedium'); + const forceImage = imageMode === 'force'; + onSend(message.trim(), attachments.length > 0 ? attachments : undefined, forceImage); + setMessage(''); + clearAttachments(); + inputRef.current?.focus(); + if (forceImage) { + setImageMode('auto'); + onImageModeChange?.('auto'); + } + }; + + const handleImageModeToggle = () => { + if (!imageModelLoaded) { + setAlertState(showAlert( + 'No Image Model', + 'Download an image model from the Models screen to enable image generation.', + [{ text: 'OK' }], + )); + return; + } + const newMode: ImageModeState = imageMode === 'auto' ? 'force' : 'auto'; + setImageMode(newMode); + onImageModeChange?.(newMode); + }; + + const handleStop = () => { + if (onStop && isGenerating) { + triggerHaptic('impactLight'); + onStop(); + } + }; + + return ( + + + + + + + {isGenerating && onStop && ( + + + + )} + {canSend ? ( + + + + ) : !isGenerating ? ( + { stopRecording(); clearResult(); }} + asSendButton + /> + ) : null} + + + setAlertState(hideAlert())} + /> + + ); +}; diff --git a/src/components/ChatInput/styles.ts b/src/components/ChatInput/styles.ts new file mode 100644 index 00000000..79fa13fc --- /dev/null +++ b/src/components/ChatInput/styles.ts @@ -0,0 +1,181 @@ +import type { ThemeColors, ThemeShadows } from '../../theme'; +import { FONTS, SPACING } from '../../constants'; +import { Platform } from 'react-native'; + +export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ + container: { + paddingHorizontal: SPACING.lg, + paddingVertical: SPACING.md, + backgroundColor: colors.background, + borderTopWidth: 1, + borderTopColor: colors.border, + }, + attachmentsContainer: { + marginBottom: 8, + }, + attachmentsContent: { + gap: 8, + }, + attachmentPreview: { + position: 'relative' as const, + width: 60, + height: 60, + borderRadius: 8, + overflow: 'hidden' as const, + }, + attachmentImage: { + width: '100%' as const, + height: '100%' as const, + }, + documentPreview: { + width: '100%' as const, + height: '100%' as const, + backgroundColor: colors.surface, + justifyContent: 'center' as const, + alignItems: 'center' as const, + padding: 4, + }, + documentName: { + fontSize: 10, + fontFamily: FONTS.mono, + color: colors.textMuted, + textAlign: 'center' as const, + marginTop: 4, + }, + removeAttachment: { + position: 'absolute' as const, + top: 2, + right: 2, + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: colors.error, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, + removeAttachmentText: { + color: colors.text, + fontSize: 14, + fontWeight: 'bold' as const, + marginTop: -2, + }, + inputRow: { + flexDirection: 'row' as const, + alignItems: 'flex-end' as const, + backgroundColor: colors.surface, + borderRadius: 8, + paddingHorizontal: SPACING.md, + paddingVertical: SPACING.sm, + marginTop: SPACING.sm, + gap: SPACING.sm, + }, + input: { + flex: 1, + color: colors.text, + fontSize: 14, + fontFamily: FONTS.mono, + minHeight: 40, + maxHeight: 150, + textAlignVertical: 'top' as const, + paddingTop: Platform.OS === 'ios' ? 8 : 4, + paddingBottom: Platform.OS === 'ios' ? 8 : 4, + }, + inputActions: { + flexDirection: 'row' as const, + alignItems: 'flex-end' as const, + gap: 6, + paddingBottom: 3, + }, + toolbarRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'space-between' as const, + }, + toolbarLeft: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: 8, + flex: 1, + }, + toolbarRight: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: 8, + }, + toolbarButton: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: 'transparent', + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, + sendButton: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, + sendButtonDisabled: { + backgroundColor: colors.surface, + borderColor: colors.border, + opacity: 0.5, + }, + stopButton: { + backgroundColor: colors.surface, + borderColor: colors.textMuted, + }, + queueBadge: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + backgroundColor: `${colors.primary}20`, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 12, + gap: 4, + }, + queueBadgeText: { + fontSize: 11, + fontFamily: FONTS.mono, + fontWeight: '500' as const, + color: colors.primary, + }, + queuePreview: { + fontSize: 11, + fontFamily: FONTS.mono, + fontWeight: '300' as const, + color: colors.textMuted, + maxWidth: 140, + }, + onBadge: { + position: 'absolute' as const, + top: -2, + right: -2, + backgroundColor: colors.primary, + borderRadius: 6, + paddingHorizontal: 3, + paddingVertical: 1, + }, + onBadgeText: { + fontSize: 8, + fontFamily: FONTS.mono, + fontWeight: '700' as const, + color: colors.background, + }, + visionBadge: { + backgroundColor: `${colors.primary}20`, + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 8, + }, + visionBadgeText: { + fontSize: 10, + fontFamily: FONTS.mono, + fontWeight: '500' as const, + color: colors.primary, + }, +}); diff --git a/src/components/ChatMessage.tsx b/src/components/ChatMessage.tsx deleted file mode 100644 index d990631a..00000000 --- a/src/components/ChatMessage.tsx +++ /dev/null @@ -1,974 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - View, - Text, - StyleSheet, - Image, - TouchableOpacity, - Clipboard, - TextInput, -} from 'react-native'; -import Animated, { - useSharedValue, - useAnimatedStyle, - withRepeat, - withSequence, - withTiming, - useReducedMotion, - FadeIn, -} from 'react-native-reanimated'; -import Icon from 'react-native-vector-icons/Feather'; -import { useTheme, useThemedStyles } from '../theme'; -import type { ThemeColors, ThemeShadows } from '../theme'; -import { TYPOGRAPHY, SPACING, FONTS } from '../constants'; -import { Message } from '../types'; -import { stripControlTokens } from '../utils/messageContent'; -import { CustomAlert, showAlert, hideAlert, AlertState, initialAlertState } from './CustomAlert'; -import { ThinkingIndicator } from './ThinkingIndicator'; -import { MarkdownText } from './MarkdownText'; -import { triggerHaptic } from '../utils/haptics'; -import { AnimatedEntry } from './AnimatedEntry'; -import { AnimatedPressable } from './AnimatedPressable'; -import { AppSheet } from './AppSheet'; -import { viewDocument } from '@react-native-documents/viewer'; - - -// Animated blinking cursor for streaming state -function BlinkingCursor() { - const { colors } = useTheme(); - const reducedMotion = useReducedMotion(); - const opacity = useSharedValue(1); - useEffect(() => { - if (reducedMotion) return; - opacity.value = withRepeat( - withSequence( - withTiming(0, { duration: 400 }), - withTiming(1, { duration: 400 }), - ), - -1, - false, - ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [reducedMotion]); - const style = useAnimatedStyle(() => ({ opacity: opacity.value })); - return ( - - _ - - ); -} - -// Image with fade-in on load -function FadeInImage({ - uri, - imageStyle, - testID, - wrapperTestID, - onPress, -}: { - uri: string; - imageStyle: any; - testID?: string; - wrapperTestID?: string; - onPress?: () => void; -}) { - const opacity = useSharedValue(0); - const fadeStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); - return ( - - - { opacity.value = withTiming(1, { duration: 300 }); }} - /> - - - ); -} - -const fadeInImageStyles = StyleSheet.create({ - wrapper: { - borderRadius: 12, - overflow: 'hidden', - }, -}); - -interface ChatMessageProps { - message: Message; - isStreaming?: boolean; - onImagePress?: (uri: string) => void; - onCopy?: (content: string) => void; - onRetry?: (message: Message) => void; - onEdit?: (message: Message, newContent: string) => void; - onGenerateImage?: (prompt: string) => void; - showActions?: boolean; - canGenerateImage?: boolean; - showGenerationDetails?: boolean; - animateEntry?: boolean; -} - -// Parse message content to extract blocks -interface ParsedContent { - thinking: string | null; - response: string; - isThinkingComplete: boolean; - thinkingLabel?: string; -} - -function parseThinkingContent(content: string): ParsedContent { - // Check for tags - const thinkStartMatch = content.match(//i); - const thinkEndMatch = content.match(/<\/think>/i); - - if (!thinkStartMatch) { - // No thinking block - return { thinking: null, response: content, isThinkingComplete: true }; - } - - const thinkStart = thinkStartMatch.index! + thinkStartMatch[0].length; - - if (!thinkEndMatch) { - // Still thinking (no closing tag yet) - const thinkingContent = content.slice(thinkStart); - return { - thinking: thinkingContent, - response: '', - isThinkingComplete: false - }; - } - - const thinkEnd = thinkEndMatch.index!; - let thinkingContent = content.slice(thinkStart, thinkEnd).trim(); - const responseContent = content.slice(thinkEnd + thinkEndMatch[0].length).trim(); - - // Check for custom label marker: __LABEL:Custom Label__ - let thinkingLabel: string | undefined; - const labelMatch = thinkingContent.match(/^__LABEL:(.+?)__\n*/); - if (labelMatch) { - thinkingLabel = labelMatch[1]; - thinkingContent = thinkingContent.slice(labelMatch[0].length).trim(); - } - - return { - thinking: thinkingContent, - response: responseContent, - isThinkingComplete: true, - thinkingLabel - }; -} - - -export const ChatMessage: React.FC = ({ - message, - isStreaming, - onImagePress, - onCopy, - onRetry, - onEdit, - onGenerateImage, - showActions = true, - canGenerateImage = false, - showGenerationDetails = false, - animateEntry = false, -}) => { - const { colors } = useTheme(); - const styles = useThemedStyles(createStyles); - const [showActionMenu, setShowActionMenu] = useState(false); - const [isEditing, setIsEditing] = useState(false); - const [editedContent, setEditedContent] = useState(message.content); - const [showThinking, setShowThinking] = useState(false); - const [alertState, setAlertState] = useState(initialAlertState); - - const displayContent = message.role === 'assistant' - ? stripControlTokens(message.content) - : message.content; - - // Parse content for blocks (only for assistant messages) - const parsedContent = message.role === 'assistant' - ? parseThinkingContent(displayContent) - : { thinking: null, response: message.content, isThinkingComplete: true }; - - - const isUser = message.role === 'user'; - const hasAttachments = message.attachments && message.attachments.length > 0; - - const handleCopy = () => { - Clipboard.setString(displayContent); - triggerHaptic('notificationSuccess'); - if (onCopy) { - onCopy(displayContent); - } - setShowActionMenu(false); - setAlertState(showAlert('Copied', 'Message copied to clipboard')); - }; - - const handleRetry = () => { - if (onRetry) { - onRetry(message); - } - setShowActionMenu(false); - }; - - const handleEdit = () => { - setEditedContent(message.content); - setShowActionMenu(false); - // Delay opening edit sheet until action menu Modal fully closes - // iOS can't handle two Modal transitions simultaneously - setTimeout(() => setIsEditing(true), 350); - }; - - const handleSaveEdit = () => { - if (onEdit && editedContent.trim() !== message.content) { - onEdit(message, editedContent.trim()); - } - setIsEditing(false); - }; - - const handleCancelEdit = () => { - setEditedContent(message.content); - setIsEditing(false); - }; - - const handleLongPress = () => { - if (showActions && !isStreaming) { - triggerHaptic('impactMedium'); - setShowActionMenu(true); - } - }; - - const handleGenerateImage = () => { - if (onGenerateImage) { - // Extract a prompt from the message - use the main response without thinking blocks - const prompt = message.role === 'assistant' - ? parsedContent.response.trim() - : message.content.trim(); - // Limit prompt length for image generation - const truncatedPrompt = prompt.slice(0, 500); - onGenerateImage(truncatedPrompt); - } - setShowActionMenu(false); - }; - - // Render system info messages (model loaded/unloaded) differently - if (message.isSystemInfo) { - return ( - <> - - {displayContent} - - setAlertState(hideAlert())} - /> - - ); - } - - const messageBody = ( - - - {/* Attachments */} - {hasAttachments && ( - - {message.attachments!.map((attachment, index) => - attachment.type === 'document' ? ( - { - if (!attachment.uri) return; - const ext = (attachment.fileName || '').split('.').pop()?.toLowerCase(); - const mimeMap: Record = { - pdf: 'application/pdf', - txt: 'text/plain', - md: 'text/markdown', - csv: 'text/csv', - json: 'application/json', - xml: 'application/xml', - html: 'text/html', - py: 'text/x-python', - js: 'text/javascript', - ts: 'text/typescript', - }; - const mimeType = ext ? mimeMap[ext] || 'application/octet-stream' : undefined; - // Ensure proper URI format: absolute paths need file:// prefix - let uri = attachment.uri; - if (uri.startsWith('/')) { - uri = `file://${uri}`; - } else if (!uri.includes('://')) { - uri = `file://${uri}`; - } - console.log('[ChatMessage] Opening document:', uri); - viewDocument({ uri, mimeType, grantPermissions: 'read' }).catch((err: any) => { - console.warn('[ChatMessage] Failed to open document:', err?.message || err); - }); - }} - activeOpacity={0.7} - > - - - {attachment.fileName || 'Document'} - - {attachment.fileSize != null && ( - - {formatFileSize(attachment.fileSize)} - - )} - - ) : ( - onImagePress?.(attachment.uri)} - /> - ) - )} - - )} - - {/* Text content */} - {message.isThinking ? ( - - ) : message.content ? ( - - {/* Thinking block for assistant messages */} - {parsedContent.thinking && ( - - setShowThinking(!showThinking)} - > - - - {parsedContent.thinkingLabel?.includes('Enhanced') - ? 'E' - : parsedContent.isThinkingComplete ? 'T' : '...'} - - - - - {parsedContent.thinkingLabel || (parsedContent.isThinkingComplete ? 'Thought process' : 'Thinking...')} - - {!showThinking && parsedContent.thinking && ( - - {parsedContent.thinking.slice(0, 80)} - {parsedContent.thinking.length > 80 ? '...' : ''} - - )} - - - {showThinking ? '▼' : '▶'} - - - {showThinking && ( - - {parsedContent.thinking} - - )} - - )} - - {/* Main response */} - {parsedContent.response ? ( - !isUser ? ( - - {parsedContent.response} - {isStreaming && } - - ) : ( - - {parsedContent.response} - - ) - ) : isStreaming && !parsedContent.isThinkingComplete ? ( - /* Still in thinking phase, show indicator */ - - - - ) : isStreaming ? ( - - - - ) : null} - - ) : isStreaming ? ( - - - - ) : null} - - - - {formatTime(message.timestamp)} - {message.generationTimeMs && message.role === 'assistant' && ( - - {formatDuration(message.generationTimeMs)} - - )} - {showActions && !isStreaming && ( - setShowActionMenu(true)} - > - ••• - - )} - - - {/* Generation details */} - {showGenerationDetails && message.generationMeta && message.role === 'assistant' && ( - - - - {message.generationMeta.gpuBackend || (message.generationMeta.gpu ? 'GPU' : 'CPU')} - {message.generationMeta.gpuLayers != null && message.generationMeta.gpuLayers > 0 - ? ` (${message.generationMeta.gpuLayers}L)` - : ''} - - {message.generationMeta.modelName && ( - <> - · - - {message.generationMeta.modelName} - - - )} - {(message.generationMeta.decodeTokensPerSecond ?? message.generationMeta.tokensPerSecond) != null && - (message.generationMeta.decodeTokensPerSecond ?? message.generationMeta.tokensPerSecond)! > 0 && ( - <> - · - - {(message.generationMeta.decodeTokensPerSecond ?? message.generationMeta.tokensPerSecond)!.toFixed(1)} tok/s - - - )} - {message.generationMeta.timeToFirstToken != null && message.generationMeta.timeToFirstToken > 0 && ( - <> - · - - TTFT {message.generationMeta.timeToFirstToken.toFixed(1)}s - - - )} - {message.generationMeta.tokenCount != null && message.generationMeta.tokenCount > 0 && ( - <> - · - - {message.generationMeta.tokenCount} tokens - - - )} - {message.generationMeta.steps != null && ( - <> - · - - {message.generationMeta.steps} steps - - - )} - {message.generationMeta.guidanceScale != null && ( - <> - · - - cfg {message.generationMeta.guidanceScale} - - - )} - {message.generationMeta.resolution && ( - <> - · - - {message.generationMeta.resolution} - - - )} - - - )} - - ); - - return ( - <> - {animateEntry ? {messageBody} : messageBody} - - {/* Action Sheet */} - setShowActionMenu(false)} - enableDynamicSizing - title="Actions" - > - - - - Copy - - - {isUser && onEdit && ( - - - Edit - - )} - - {onRetry && ( - - - - {isUser ? 'Resend' : 'Regenerate'} - - - )} - - {canGenerateImage && onGenerateImage && ( - - - Generate Image - - )} - - - - {/* Edit Sheet */} - - - - - - CANCEL - - - SAVE & RESEND - - - - - - {/* CustomAlert */} - setAlertState(hideAlert())} - /> - - ); -}; - -function formatTime(timestamp: number): string { - const date = new Date(timestamp); - return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); -} - -function formatFileSize(bytes: number): string { - if (bytes < 1024) return `${bytes}B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)}KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; -} - -function formatDuration(ms: number): string { - if (ms < 1000) { - return `${ms}ms`; - } - const seconds = ms / 1000; - if (seconds < 60) { - return `${seconds.toFixed(1)}s`; - } - const minutes = Math.floor(seconds / 60); - const remainingSeconds = Math.floor(seconds % 60); - return `${minutes}m ${remainingSeconds}s`; -} - -const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ - container: { - marginVertical: 8, - paddingHorizontal: 16, - }, - userContainer: { - alignItems: 'flex-end' as const, - }, - assistantContainer: { - alignItems: 'flex-start' as const, - }, - systemInfoContainer: { - paddingVertical: 8, - paddingHorizontal: 16, - alignItems: 'center' as const, - }, - systemInfoText: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - textAlign: 'center' as const, - }, - bubble: { - maxWidth: '85%' as const, - borderRadius: 8, - paddingHorizontal: SPACING.lg, - paddingVertical: SPACING.md, - }, - bubbleWithAttachments: { - paddingHorizontal: 8, - paddingTop: 8, - paddingBottom: 12, - }, - userBubble: { - backgroundColor: colors.primary, - borderBottomRightRadius: 4, - }, - assistantBubble: { - backgroundColor: colors.surface, - borderBottomLeftRadius: 4, - minWidth: '85%' as const, - }, - attachmentsContainer: { - flexDirection: 'row' as const, - flexWrap: 'wrap' as const, - gap: 4, - marginBottom: 8, - }, - attachmentWrapper: { - borderRadius: 12, - overflow: 'hidden' as const, - }, - documentBadge: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - gap: 6, - paddingHorizontal: 10, - paddingVertical: 6, - borderRadius: 8, - }, - documentBadgeUser: { - backgroundColor: 'rgba(0, 0, 0, 0.15)', - }, - documentBadgeAssistant: { - backgroundColor: colors.surfaceLight, - }, - documentBadgeText: { - fontSize: 12, - fontFamily: FONTS.mono, - fontWeight: '500' as const, - maxWidth: 140, - }, - documentBadgeTextUser: { - color: colors.background, - }, - documentBadgeTextAssistant: { - color: colors.text, - }, - documentBadgeSize: { - fontSize: 10, - fontFamily: FONTS.mono, - }, - documentBadgeSizeUser: { - color: 'rgba(0, 0, 0, 0.4)', - }, - documentBadgeSizeAssistant: { - color: colors.textMuted, - }, - attachmentImage: { - width: 140, - height: 140, - borderRadius: 12, - }, - text: { - ...TYPOGRAPHY.body, - lineHeight: 20, - paddingHorizontal: 0, - }, - userText: { - color: colors.background, - fontWeight: '400' as const, - }, - assistantText: { - color: colors.text, - fontWeight: '400' as const, - }, - cursor: { - color: colors.primary, - fontWeight: '300' as const, - }, - thinkingContainer: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - paddingVertical: 4, - }, - thinkingDots: { - flexDirection: 'row' as const, - marginRight: 8, - }, - thinkingDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: colors.primary, - marginHorizontal: 2, - }, - thinkingText: { - ...TYPOGRAPHY.body, - color: colors.textSecondary, - fontStyle: 'italic' as const, - }, - thinkingBlock: { - backgroundColor: colors.surfaceLight, - borderRadius: 8, - marginBottom: 8, - overflow: 'hidden' as const, - width: '100%' as const, - }, - thinkingHeader: { - flexDirection: 'row' as const, - alignItems: 'flex-start' as const, - padding: 8, - gap: 6, - }, - thinkingHeaderIconBox: { - width: 20, - height: 20, - borderRadius: 4, - backgroundColor: colors.primary + '30', - alignItems: 'center' as const, - justifyContent: 'center' as const, - }, - thinkingHeaderIconText: { - ...TYPOGRAPHY.label, - fontWeight: '600' as const, - color: colors.primary, - }, - thinkingHeaderTextContainer: { - flex: 1, - marginRight: SPACING.xs, - }, - thinkingHeaderText: { - ...TYPOGRAPHY.bodySmall, - color: colors.textMuted, - fontWeight: '500' as const, - }, - thinkingPreview: { - ...TYPOGRAPHY.bodySmall, - color: colors.text, - marginTop: 6, - lineHeight: 18, - opacity: 0.8, - }, - thinkingToggle: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - }, - thinkingBlockText: { - ...TYPOGRAPHY.h3, - color: colors.textSecondary, - lineHeight: 18, - padding: SPACING.sm, - paddingTop: 0, - fontStyle: 'italic' as const, - }, - thinkingBlockContent: { - padding: SPACING.sm, - paddingTop: 0, - }, - streamingThinkingHint: { - marginTop: 8, - }, - metaRow: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - marginTop: 4, - marginHorizontal: 8, - gap: 8, - }, - timestamp: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - }, - generationTime: { - ...TYPOGRAPHY.meta, - fontWeight: '400' as const, - color: colors.primary, - }, - actionHint: { - padding: 4, - }, - actionHintText: { - ...TYPOGRAPHY.bodySmall, - color: colors.textMuted, - letterSpacing: 1, - }, - generationMetaRow: { - flexDirection: 'row' as const, - flexWrap: 'wrap' as const, - alignItems: 'center' as const, - marginTop: 2, - marginHorizontal: 8, - gap: 3, - }, - generationMetaText: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - flexShrink: 1, - }, - generationMetaSep: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - opacity: 0.5, - }, - actionSheetContent: { - paddingHorizontal: SPACING.lg, - paddingBottom: SPACING.xl, - }, - actionSheetItem: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - paddingVertical: SPACING.md, - paddingHorizontal: SPACING.sm, - gap: SPACING.md, - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - actionSheetText: { - ...TYPOGRAPHY.body, - color: colors.text, - }, - editSheetContent: { - paddingHorizontal: SPACING.lg, - paddingBottom: SPACING.xl, - }, - editInput: { - ...TYPOGRAPHY.body, - fontFamily: FONTS.mono, - backgroundColor: colors.surface, - borderRadius: 4, - borderWidth: 1, - borderColor: colors.border, - padding: SPACING.md, - color: colors.text, - minHeight: 100, - maxHeight: 300, - textAlignVertical: 'top' as const, - }, - editActions: { - flexDirection: 'row' as const, - gap: SPACING.sm, - marginTop: SPACING.lg, - }, - editButton: { - flex: 1, - paddingVertical: SPACING.md, - borderRadius: 4, - alignItems: 'center' as const, - borderWidth: 1, - }, - editButtonCancel: { - backgroundColor: colors.surface, - borderColor: colors.border, - }, - editButtonSave: { - backgroundColor: 'transparent' as const, - borderColor: colors.primary, - }, - editButtonText: { - ...TYPOGRAPHY.label, - fontFamily: FONTS.mono, - color: colors.textSecondary, - letterSpacing: 1, - }, - editButtonTextSave: { - color: colors.primary, - fontWeight: '600' as const, - }, -}); diff --git a/src/components/ChatMessage/components/ActionMenuSheet.tsx b/src/components/ChatMessage/components/ActionMenuSheet.tsx new file mode 100644 index 00000000..1f380fe2 --- /dev/null +++ b/src/components/ChatMessage/components/ActionMenuSheet.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { View, Text, TextInput } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { useTheme } from '../../../theme'; +import { AppSheet } from '../../AppSheet'; +import { AnimatedPressable } from '../../AnimatedPressable'; + +interface ActionMenuSheetProps { + visible: boolean; + onClose: () => void; + isUser: boolean; + canEdit: boolean; + canRetry: boolean; + canGenerateImage: boolean; + styles: any; + onCopy: () => void; + onEdit: () => void; + onRetry: () => void; + onGenerateImage: () => void; +} + +export function ActionMenuSheet({ + visible, + onClose, + isUser, + canEdit, + canRetry, + canGenerateImage, + styles, + onCopy, + onEdit, + onRetry, + onGenerateImage, +}: ActionMenuSheetProps) { + const { colors } = useTheme(); + + return ( + + + + + Copy + + + {isUser && canEdit && ( + + + Edit + + )} + + {canRetry && ( + + + + {isUser ? 'Resend' : 'Regenerate'} + + + )} + + {canGenerateImage && ( + + + Generate Image + + )} + + + ); +} + +interface EditSheetProps { + visible: boolean; + onClose: () => void; + defaultValue: string; + onChangeText: (text: string) => void; + onSave: () => void; + onCancel: () => void; + styles: any; + colors: any; +} + +export function EditSheet({ + visible, + onClose, + defaultValue, + onChangeText, + onSave, + onCancel, + styles, + colors, +}: EditSheetProps) { + return ( + + + + + + CANCEL + + + SAVE & RESEND + + + + + ); +} diff --git a/src/components/ChatMessage/components/BlinkingCursor.tsx b/src/components/ChatMessage/components/BlinkingCursor.tsx new file mode 100644 index 00000000..893c91a4 --- /dev/null +++ b/src/components/ChatMessage/components/BlinkingCursor.tsx @@ -0,0 +1,38 @@ +import React, { useEffect } from 'react'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withRepeat, + withSequence, + withTiming, + useReducedMotion, +} from 'react-native-reanimated'; +import { useTheme } from '../../../theme'; +import { FONTS } from '../../../constants'; + +export function BlinkingCursor() { + const { colors } = useTheme(); + const reducedMotion = useReducedMotion(); + const opacity = useSharedValue(1); + useEffect(() => { + if (reducedMotion) { return; } + opacity.value = withRepeat( + withSequence( + withTiming(0, { duration: 400 }), + withTiming(1, { duration: 400 }), + ), + -1, + false, + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reducedMotion]); + const style = useAnimatedStyle(() => ({ opacity: opacity.value })); + return ( + + _ + + ); +} diff --git a/src/components/ChatMessage/components/GenerationMeta.tsx b/src/components/ChatMessage/components/GenerationMeta.tsx new file mode 100644 index 00000000..f8f89a89 --- /dev/null +++ b/src/components/ChatMessage/components/GenerationMeta.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import Animated, { FadeIn } from 'react-native-reanimated'; +import { Message } from '../../../types'; + +interface GenerationMetaProps { + generationMeta: NonNullable; + styles: any; +} + +type MetaItem = { key: string; label: string; maxLines?: number }; + +function buildMetaItems( + generationMeta: NonNullable, + tps: number | null | undefined, +): MetaItem[] { + const items: MetaItem[] = []; + const layers = generationMeta.gpuLayers != null && generationMeta.gpuLayers > 0 + ? ` (${generationMeta.gpuLayers}L)` : ''; + items.push({ key: 'backend', label: `${generationMeta.gpuBackend || (generationMeta.gpu ? 'GPU' : 'CPU')}${layers}` }); + if (generationMeta.modelName) { + items.push({ key: 'model', label: generationMeta.modelName, maxLines: 1 }); + } + if (tps != null && tps > 0) { + items.push({ key: 'tps', label: `${tps.toFixed(1)} tok/s` }); + } + if (generationMeta.timeToFirstToken != null && generationMeta.timeToFirstToken > 0) { + items.push({ key: 'ttft', label: `TTFT ${generationMeta.timeToFirstToken.toFixed(1)}s` }); + } + if (generationMeta.tokenCount != null && generationMeta.tokenCount > 0) { + items.push({ key: 'tokens', label: `${generationMeta.tokenCount} tokens` }); + } + if (generationMeta.steps != null) { + items.push({ key: 'steps', label: `${generationMeta.steps} steps` }); + } + if (generationMeta.guidanceScale != null) { + items.push({ key: 'cfg', label: `cfg ${generationMeta.guidanceScale}` }); + } + if (generationMeta.resolution) { + items.push({ key: 'res', label: generationMeta.resolution }); + } + return items; +} + +export function GenerationMeta({ generationMeta, styles }: GenerationMetaProps) { + const tps = generationMeta.decodeTokensPerSecond ?? generationMeta.tokensPerSecond; + const items = buildMetaItems(generationMeta, tps); + + return ( + + + {items.map((item, index) => ( + + {index > 0 && ·} + + {item.label} + + + ))} + + + ); +} diff --git a/src/components/ChatMessage/components/MessageAttachments.tsx b/src/components/ChatMessage/components/MessageAttachments.tsx new file mode 100644 index 00000000..cbbd507f --- /dev/null +++ b/src/components/ChatMessage/components/MessageAttachments.tsx @@ -0,0 +1,151 @@ +import React from 'react'; +import { + View, + Text, + Image, + TouchableOpacity, + StyleSheet, +} from 'react-native'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withTiming, +} from 'react-native-reanimated'; +import Icon from 'react-native-vector-icons/Feather'; +import { MediaAttachment } from '../../../types'; +import { viewDocument } from '@react-native-documents/viewer'; + +interface FadeInImageProps { + uri: string; + imageStyle: any; + testID?: string; + wrapperTestID?: string; + onPress?: () => void; +} + +function FadeInImage({ uri, imageStyle, testID, wrapperTestID, onPress }: FadeInImageProps) { + const opacity = useSharedValue(0); + const fadeStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); + return ( + + + { opacity.value = withTiming(1, { duration: 300 }); }} + /> + + + ); +} + +const fadeInImageStyles = StyleSheet.create({ + wrapper: { + borderRadius: 12, + overflow: 'hidden', + }, +}); + +function formatFileSize(bytes: number): string { + if (bytes < 1024) { return `${bytes}B`; } + if (bytes < 1024 * 1024) { return `${(bytes / 1024).toFixed(0)}KB`; } + return `${(bytes / (1024 * 1024)).toFixed(1)}MB`; +} + +interface MessageAttachmentsProps { + attachments: MediaAttachment[]; + isUser: boolean; + styles: any; + colors: any; + onImagePress?: (uri: string) => void; +} + +export function MessageAttachments({ + attachments, + isUser, + styles, + colors, + onImagePress, +}: MessageAttachmentsProps) { + return ( + + {attachments.map((attachment, index) => + attachment.type === 'document' ? ( + { + if (!attachment.uri) { return; } + const ext = (attachment.fileName || '').split('.').pop()?.toLowerCase(); + const mimeMap: Record = { + pdf: 'application/pdf', + txt: 'text/plain', + md: 'text/markdown', + csv: 'text/csv', + json: 'application/json', + xml: 'application/xml', + html: 'text/html', + py: 'text/x-python', + js: 'text/javascript', + ts: 'text/typescript', + }; + const mimeType = ext ? mimeMap[ext] || 'application/octet-stream' : undefined; + let uri = attachment.uri; + if (uri.startsWith('/')) { + uri = `file://${uri}`; + } else if (!uri.includes('://')) { + uri = `file://${uri}`; + } + console.log('[ChatMessage] Opening document:', uri); + viewDocument({ uri, mimeType, grantPermissions: 'read' }).catch((err: any) => { + console.warn('[ChatMessage] Failed to open document:', err?.message || err); + }); + }} + activeOpacity={0.7} + > + + + {attachment.fileName || 'Document'} + + {attachment.fileSize != null && ( + + {formatFileSize(attachment.fileSize)} + + )} + + ) : ( + onImagePress?.(attachment.uri)} + /> + ) + )} + + ); +} diff --git a/src/components/ChatMessage/components/MessageContent.tsx b/src/components/ChatMessage/components/MessageContent.tsx new file mode 100644 index 00000000..e98ed7d8 --- /dev/null +++ b/src/components/ChatMessage/components/MessageContent.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import { ThinkingIndicator } from '../../ThinkingIndicator'; +import { MarkdownText } from '../../MarkdownText'; +import { BlinkingCursor } from './BlinkingCursor'; +import { ThinkingBlock } from './ThinkingBlock'; +import type { ParsedContent } from '../types'; + +interface MessageContentProps { + isUser: boolean; + isThinking?: boolean; + content: string; + isStreaming?: boolean; + parsedContent: ParsedContent; + showThinking: boolean; + onToggleThinking: () => void; + styles: any; +} + +export function MessageContent({ + isUser, + isThinking, + content, + isStreaming, + parsedContent, + showThinking, + onToggleThinking, + styles, +}: MessageContentProps) { + if (isThinking) { + return ( + + + + ); + } + + if (!content) { + if (isStreaming) { + return ( + + + + ); + } + return null; + } + + return ( + + {parsedContent.thinking && ( + + )} + + {parsedContent.response ? ( + !isUser ? ( + + {parsedContent.response} + {isStreaming && } + + ) : ( + + {parsedContent.response} + + ) + ) : isStreaming && !parsedContent.isThinkingComplete ? ( + + + + ) : isStreaming ? ( + + + + ) : null} + + ); +} diff --git a/src/components/ChatMessage/components/ThinkingBlock.tsx b/src/components/ChatMessage/components/ThinkingBlock.tsx new file mode 100644 index 00000000..a98b44f7 --- /dev/null +++ b/src/components/ChatMessage/components/ThinkingBlock.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { View, Text, TouchableOpacity } from 'react-native'; +import { MarkdownText } from '../../MarkdownText'; +import type { ParsedContent } from '../types'; + +interface ThinkingBlockProps { + parsedContent: ParsedContent; + showThinking: boolean; + onToggle: () => void; + styles: any; +} + +export function ThinkingBlock({ + parsedContent, + showThinking, + onToggle, + styles, +}: ThinkingBlockProps) { + return ( + + + + + {parsedContent.thinkingLabel?.includes('Enhanced') + ? 'E' + : parsedContent.isThinkingComplete ? 'T' : '...'} + + + + + {parsedContent.thinkingLabel || (parsedContent.isThinkingComplete ? 'Thought process' : 'Thinking...')} + + {!showThinking && parsedContent.thinking && ( + + {parsedContent.thinking.slice(0, 80)} + {parsedContent.thinking.length > 80 ? '...' : ''} + + )} + + + {showThinking ? '▼' : '▶'} + + + {showThinking && parsedContent.thinking != null && ( + + {parsedContent.thinking} + + )} + + ); +} diff --git a/src/components/ChatMessage/index.tsx b/src/components/ChatMessage/index.tsx new file mode 100644 index 00000000..678b969b --- /dev/null +++ b/src/components/ChatMessage/index.tsx @@ -0,0 +1,238 @@ +import React, { useState } from 'react'; +import { + View, + Text, + TouchableOpacity, + Clipboard, +} from 'react-native'; +import { useTheme, useThemedStyles } from '../../theme'; +import { stripControlTokens } from '../../utils/messageContent'; +import { CustomAlert, showAlert, hideAlert, AlertState, initialAlertState } from '../CustomAlert'; +import { AnimatedEntry } from '../AnimatedEntry'; +import { triggerHaptic } from '../../utils/haptics'; +import { createStyles } from './styles'; +import { MessageAttachments } from './components/MessageAttachments'; +import { MessageContent } from './components/MessageContent'; +import { GenerationMeta } from './components/GenerationMeta'; +import { ActionMenuSheet, EditSheet } from './components/ActionMenuSheet'; +import { parseThinkingContent, formatTime, formatDuration } from './utils'; +import type { ChatMessageProps } from './types'; +import type { Message } from '../../types'; + +function buildMessageData(message: Message) { + const displayContent = message.role === 'assistant' + ? stripControlTokens(message.content) + : message.content; + const parsedContent = message.role === 'assistant' + ? parseThinkingContent(displayContent) + : { thinking: null, response: message.content, isThinkingComplete: true }; + return { displayContent, parsedContent }; +} + +type MetaRowProps = { + message: Message; + styles: ReturnType; + isStreaming?: boolean; + showActions: boolean; + onMenuOpen: () => void; +}; + +const MessageMetaRow: React.FC = ({ message, styles, isStreaming, showActions, onMenuOpen }) => ( + + {formatTime(message.timestamp)} + {message.generationTimeMs != null && message.role === 'assistant' && ( + {formatDuration(message.generationTimeMs)} + )} + {showActions && !isStreaming && ( + + ••• + + )} + +); + +export const ChatMessage: React.FC = ({ + message, + isStreaming, + onImagePress, + onCopy, + onRetry, + onEdit, + onGenerateImage, + showActions = true, + canGenerateImage = false, + showGenerationDetails = false, + animateEntry = false, +}) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const [showActionMenu, setShowActionMenu] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [editedContent, setEditedContent] = useState(message.content); + const [showThinking, setShowThinking] = useState(false); + const [alertState, setAlertState] = useState(initialAlertState); + + const { displayContent, parsedContent } = buildMessageData(message); + + const isUser = message.role === 'user'; + const hasAttachments = message.attachments && message.attachments.length > 0; + + const handleCopy = () => { + Clipboard.setString(displayContent); + triggerHaptic('notificationSuccess'); + if (onCopy) { onCopy(displayContent); } + setShowActionMenu(false); + setAlertState(showAlert('Copied', 'Message copied to clipboard')); + }; + + const handleRetry = () => { + if (onRetry) { onRetry(message); } + setShowActionMenu(false); + }; + + const handleEdit = () => { + setEditedContent(message.content); + setShowActionMenu(false); + setTimeout(() => setIsEditing(true), 350); + }; + + const handleSaveEdit = () => { + if (onEdit && editedContent.trim() !== message.content) { + onEdit(message, editedContent.trim()); + } + setIsEditing(false); + }; + + const handleCancelEdit = () => { + setEditedContent(message.content); + setIsEditing(false); + }; + + const handleLongPress = () => { + if (showActions && !isStreaming) { + triggerHaptic('impactMedium'); + setShowActionMenu(true); + } + }; + + const handleGenerateImage = () => { + if (onGenerateImage) { + const prompt = message.role === 'assistant' + ? parsedContent.response.trim() + : message.content.trim(); + const truncatedPrompt = prompt.slice(0, 500); + onGenerateImage(truncatedPrompt); + } + setShowActionMenu(false); + }; + + if (message.isSystemInfo) { + return ( + <> + + {displayContent} + + setAlertState(hideAlert())} + /> + + ); + } + + const messageBody = ( + + + {hasAttachments && ( + + )} + + setShowThinking(!showThinking)} + styles={styles} + /> + + + setShowActionMenu(true)} + /> + + {showGenerationDetails && message.generationMeta && message.role === 'assistant' && ( + + )} + + ); + + return ( + <> + {animateEntry ? {messageBody} : messageBody} + + setShowActionMenu(false)} + isUser={isUser} + canEdit={!!onEdit} + canRetry={!!onRetry} + canGenerateImage={canGenerateImage && !!onGenerateImage} + styles={styles} + onCopy={handleCopy} + onEdit={handleEdit} + onRetry={handleRetry} + onGenerateImage={handleGenerateImage} + /> + + + + setAlertState(hideAlert())} + /> + + ); +}; diff --git a/src/components/ChatMessage/styles.ts b/src/components/ChatMessage/styles.ts new file mode 100644 index 00000000..3a8a6cf4 --- /dev/null +++ b/src/components/ChatMessage/styles.ts @@ -0,0 +1,313 @@ +import type { ThemeColors, ThemeShadows } from '../../theme'; +import { TYPOGRAPHY, SPACING, FONTS } from '../../constants'; + +const createBubbleStyles = (colors: ThemeColors) => ({ + container: { + marginVertical: 8, + paddingHorizontal: 16, + }, + userContainer: { + alignItems: 'flex-end' as const, + }, + assistantContainer: { + alignItems: 'flex-start' as const, + }, + systemInfoContainer: { + paddingVertical: 8, + paddingHorizontal: 16, + alignItems: 'center' as const, + }, + systemInfoText: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + textAlign: 'center' as const, + }, + bubble: { + maxWidth: '85%' as const, + borderRadius: 8, + paddingHorizontal: SPACING.lg, + paddingVertical: SPACING.md, + }, + bubbleWithAttachments: { + paddingHorizontal: 8, + paddingTop: 8, + paddingBottom: 12, + }, + userBubble: { + backgroundColor: colors.primary, + borderBottomRightRadius: 4, + }, + assistantBubble: { + backgroundColor: colors.surface, + borderBottomLeftRadius: 4, + minWidth: '85%' as const, + }, + attachmentsContainer: { + flexDirection: 'row' as const, + flexWrap: 'wrap' as const, + gap: 4, + marginBottom: 8, + }, + attachmentWrapper: { + borderRadius: 12, + overflow: 'hidden' as const, + }, + documentBadge: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: 6, + paddingHorizontal: 10, + paddingVertical: 6, + borderRadius: 8, + }, + documentBadgeUser: { + backgroundColor: 'rgba(0, 0, 0, 0.15)', + }, + documentBadgeAssistant: { + backgroundColor: colors.surfaceLight, + }, + documentBadgeText: { + fontSize: 12, + fontFamily: FONTS.mono, + fontWeight: '500' as const, + maxWidth: 140, + }, + documentBadgeTextUser: { + color: colors.background, + }, + documentBadgeTextAssistant: { + color: colors.text, + }, + documentBadgeSize: { + fontSize: 10, + fontFamily: FONTS.mono, + }, + documentBadgeSizeUser: { + color: 'rgba(0, 0, 0, 0.4)', + }, + documentBadgeSizeAssistant: { + color: colors.textMuted, + }, + attachmentImage: { + width: 140, + height: 140, + borderRadius: 12, + }, +}); + +const createThinkingStyles = (colors: ThemeColors) => ({ + text: { + ...TYPOGRAPHY.body, + lineHeight: 20, + paddingHorizontal: 0, + }, + userText: { + color: colors.background, + fontWeight: '400' as const, + }, + assistantText: { + color: colors.text, + fontWeight: '400' as const, + }, + cursor: { + color: colors.primary, + fontWeight: '300' as const, + }, + thinkingContainer: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + paddingVertical: 4, + }, + thinkingDots: { + flexDirection: 'row' as const, + marginRight: 8, + }, + thinkingDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: colors.primary, + marginHorizontal: 2, + }, + thinkingText: { + ...TYPOGRAPHY.body, + color: colors.textSecondary, + fontStyle: 'italic' as const, + }, + thinkingBlock: { + backgroundColor: colors.surfaceLight, + borderRadius: 8, + marginBottom: 8, + overflow: 'hidden' as const, + width: '100%' as const, + }, + thinkingHeader: { + flexDirection: 'row' as const, + alignItems: 'flex-start' as const, + padding: 8, + gap: 6, + }, + thinkingHeaderIconBox: { + width: 20, + height: 20, + borderRadius: 4, + backgroundColor: `${colors.primary}30`, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, + thinkingHeaderIconText: { + ...TYPOGRAPHY.label, + fontWeight: '600' as const, + color: colors.primary, + }, + thinkingHeaderTextContainer: { + flex: 1, + marginRight: SPACING.xs, + }, + thinkingHeaderText: { + ...TYPOGRAPHY.bodySmall, + color: colors.textMuted, + fontWeight: '500' as const, + }, + thinkingPreview: { + ...TYPOGRAPHY.bodySmall, + color: colors.text, + marginTop: 6, + lineHeight: 18, + opacity: 0.8, + }, + thinkingToggle: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + }, + thinkingBlockText: { + ...TYPOGRAPHY.h3, + color: colors.textSecondary, + lineHeight: 18, + padding: SPACING.sm, + paddingTop: 0, + fontStyle: 'italic' as const, + }, + thinkingBlockContent: { + padding: SPACING.sm, + paddingTop: 0, + }, + streamingThinkingHint: { + marginTop: 8, + }, + metaRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + marginTop: 4, + marginHorizontal: 8, + gap: 8, + }, + timestamp: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + }, + generationTime: { + ...TYPOGRAPHY.meta, + fontWeight: '400' as const, + color: colors.primary, + }, + actionHint: { + padding: 4, + }, + actionHintText: { + ...TYPOGRAPHY.bodySmall, + color: colors.textMuted, + letterSpacing: 1, + }, + generationMetaRow: { + flexDirection: 'row' as const, + flexWrap: 'wrap' as const, + alignItems: 'center' as const, + marginTop: 2, + marginHorizontal: 8, + gap: 3, + }, + generationMetaText: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + flexShrink: 1, + }, + generationMetaSep: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + opacity: 0.5, + }, +}); + +const createActionStyles = (colors: ThemeColors) => ({ + actionSheetContent: { + paddingHorizontal: SPACING.lg, + paddingBottom: SPACING.xl, + }, + actionSheetItem: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + paddingVertical: SPACING.md, + paddingHorizontal: SPACING.sm, + gap: SPACING.md, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + actionSheetText: { + ...TYPOGRAPHY.body, + color: colors.text, + }, + editSheetContent: { + paddingHorizontal: SPACING.lg, + paddingBottom: SPACING.xl, + }, + editInput: { + ...TYPOGRAPHY.body, + fontFamily: FONTS.mono, + backgroundColor: colors.surface, + borderRadius: 4, + borderWidth: 1, + borderColor: colors.border, + padding: SPACING.md, + color: colors.text, + minHeight: 100, + maxHeight: 300, + textAlignVertical: 'top' as const, + }, + editActions: { + flexDirection: 'row' as const, + gap: SPACING.sm, + marginTop: SPACING.lg, + }, + editButton: { + flex: 1, + paddingVertical: SPACING.md, + borderRadius: 4, + alignItems: 'center' as const, + borderWidth: 1, + }, + editButtonCancel: { + backgroundColor: colors.surface, + borderColor: colors.border, + }, + editButtonSave: { + backgroundColor: 'transparent' as const, + borderColor: colors.primary, + }, + editButtonText: { + ...TYPOGRAPHY.label, + fontFamily: FONTS.mono, + color: colors.textSecondary, + letterSpacing: 1, + }, + editButtonTextSave: { + color: colors.primary, + fontWeight: '600' as const, + }, +}); + +export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ + ...createBubbleStyles(colors), + ...createThinkingStyles(colors), + ...createActionStyles(colors), +}); diff --git a/src/components/ChatMessage/types.ts b/src/components/ChatMessage/types.ts new file mode 100644 index 00000000..f93ef8ec --- /dev/null +++ b/src/components/ChatMessage/types.ts @@ -0,0 +1,22 @@ +import { Message } from '../../types'; + +export interface ChatMessageProps { + message: Message; + isStreaming?: boolean; + onImagePress?: (uri: string) => void; + onCopy?: (content: string) => void; + onRetry?: (message: Message) => void; + onEdit?: (message: Message, newContent: string) => void; + onGenerateImage?: (prompt: string) => void; + showActions?: boolean; + canGenerateImage?: boolean; + showGenerationDetails?: boolean; + animateEntry?: boolean; +} + +export interface ParsedContent { + thinking: string | null; + response: string; + isThinkingComplete: boolean; + thinkingLabel?: string; +} diff --git a/src/components/ChatMessage/utils.ts b/src/components/ChatMessage/utils.ts new file mode 100644 index 00000000..d32571e7 --- /dev/null +++ b/src/components/ChatMessage/utils.ts @@ -0,0 +1,57 @@ +import type { ParsedContent } from './types'; + +export function parseThinkingContent(content: string): ParsedContent { + const thinkStartMatch = content.match(//i); + const thinkEndMatch = content.match(/<\/think>/i); + + if (!thinkStartMatch) { + return { thinking: null, response: content, isThinkingComplete: true }; + } + + const thinkStart = thinkStartMatch.index! + thinkStartMatch[0].length; + + if (!thinkEndMatch) { + const thinkingContent = content.slice(thinkStart); + return { + thinking: thinkingContent, + response: '', + isThinkingComplete: false, + }; + } + + const thinkEnd = thinkEndMatch.index!; + let thinkingContent = content.slice(thinkStart, thinkEnd).trim(); + const responseContent = content.slice(thinkEnd + thinkEndMatch[0].length).trim(); + + let thinkingLabel: string | undefined; + const labelMatch = thinkingContent.match(/^__LABEL:(.+?)__\n*/); + if (labelMatch) { + thinkingLabel = labelMatch[1]; + thinkingContent = thinkingContent.slice(labelMatch[0].length).trim(); + } + + return { + thinking: thinkingContent, + response: responseContent, + isThinkingComplete: true, + thinkingLabel, + }; +} + +export function formatTime(timestamp: number): string { + const date = new Date(timestamp); + return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +export function formatDuration(ms: number): string { + if (ms < 1000) { + return `${ms}ms`; + } + const seconds = ms / 1000; + if (seconds < 60) { + return `${seconds.toFixed(1)}s`; + } + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.floor(seconds % 60); + return `${minutes}m ${remainingSeconds}s`; +} diff --git a/src/components/CustomAlert.tsx b/src/components/CustomAlert.tsx index 272a2040..6147f104 100644 --- a/src/components/CustomAlert.tsx +++ b/src/components/CustomAlert.tsx @@ -103,13 +103,25 @@ export const showAlert = ( title: string, message?: string, buttons?: AlertButton[], - loading?: boolean ): AlertState => ({ visible: true, title, message, buttons, - loading, + loading: false, +}); + +// Helper function to show loading alert (returns state to set) +export const showLoadingAlert = ( + title: string, + message?: string, + buttons?: AlertButton[], +): AlertState => ({ + visible: true, + title, + message, + buttons, + loading: true, }); // Helper function to hide alert (returns state to set) diff --git a/src/components/DebugSheet.tsx b/src/components/DebugSheet.tsx index 4ef7c9ae..73f1c03b 100644 --- a/src/components/DebugSheet.tsx +++ b/src/components/DebugSheet.tsx @@ -250,11 +250,11 @@ const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ borderRadius: 4, }, debugRoleUser: { - backgroundColor: colors.primary + '30', + backgroundColor: `${colors.primary }30`, color: colors.primary, }, debugRoleAssistant: { - backgroundColor: colors.info + '30', + backgroundColor: `${colors.info }30`, color: colors.info, }, debugMessageIndex: { diff --git a/src/components/GenerationSettingsModal.tsx b/src/components/GenerationSettingsModal.tsx deleted file mode 100644 index 35237b56..00000000 --- a/src/components/GenerationSettingsModal.tsx +++ /dev/null @@ -1,1175 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { - View, - Text, - ScrollView, - TouchableOpacity, - Platform, -} from 'react-native'; -import Slider from '@react-native-community/slider'; -import Icon from 'react-native-vector-icons/Feather'; -import { AppSheet } from './AppSheet'; -import { useTheme, useThemedStyles } from '../theme'; -import type { ThemeColors, ThemeShadows } from '../theme'; -import { TYPOGRAPHY, SPACING } from '../constants'; -import { useAppStore } from '../stores'; -import { llmService, hardwareService } from '../services'; - -interface SettingConfig { - key: keyof typeof DEFAULT_SETTINGS; - label: string; - min: number; - max: number; - step: number; - format: (value: number) => string; - description?: string; -} - -const DEFAULT_SETTINGS = { - temperature: 0.7, - maxTokens: 1024, - topP: 0.9, - repeatPenalty: 1.1, - contextLength: 2048, - nThreads: 6, - nBatch: 256, -}; - -const SETTINGS_CONFIG: SettingConfig[] = [ - { - key: 'temperature', - label: 'Temperature', - min: 0, - max: 2, - step: 0.05, - format: (v) => v.toFixed(2), - description: 'Higher = more creative, Lower = more focused', - }, - { - key: 'maxTokens', - label: 'Max Tokens', - min: 64, - max: 8192, - step: 64, - format: (v) => v >= 1024 ? `${(v / 1024).toFixed(1)}K` : v.toString(), - description: 'Maximum length of generated response', - }, - { - key: 'topP', - label: 'Top P', - min: 0.1, - max: 1.0, - step: 0.05, - format: (v) => v.toFixed(2), - description: 'Nucleus sampling threshold', - }, - { - key: 'repeatPenalty', - label: 'Repeat Penalty', - min: 1.0, - max: 2.0, - step: 0.05, - format: (v) => v.toFixed(2), - description: 'Penalize repeated tokens', - }, - { - key: 'contextLength', - label: 'Context Length', - min: 512, - max: 32768, - step: 512, - format: (v) => v >= 1024 ? `${(v / 1024).toFixed(1)}K` : v.toString(), - description: 'Max conversation memory (requires model reload)', - }, - { - key: 'nThreads', - label: 'CPU Threads', - min: 1, - max: 12, - step: 1, - format: (v) => v.toString(), - description: 'Parallel threads for inference', - }, - { - key: 'nBatch', - label: 'Batch Size', - min: 32, - max: 512, - step: 32, - format: (v) => v.toString(), - description: 'Tokens processed per batch', - }, -]; - -interface GenerationSettingsModalProps { - visible: boolean; - onClose: () => void; - onOpenProject?: () => void; - onOpenGallery?: () => void; - onDeleteConversation?: () => void; - conversationImageCount?: number; - activeProjectName?: string | null; -} - -export const GenerationSettingsModal: React.FC = ({ - visible, - onClose, - onOpenProject, - onOpenGallery, - onDeleteConversation, - conversationImageCount = 0, - activeProjectName, -}) => { - const { colors } = useTheme(); - const styles = useThemedStyles(createStyles); - - const { - settings, - updateSettings, - downloadedModels, - downloadedImageModels, - activeImageModelId, - setActiveImageModelId, - } = useAppStore(); - const [performanceStats, setPerformanceStats] = useState(llmService.getPerformanceStats()); - const [showImageModelPicker, setShowImageModelPicker] = useState(false); - const [showClassifierModelPicker, setShowClassifierModelPicker] = useState(false); - const [imageSettingsOpen, setImageSettingsOpen] = useState(false); - const [textSettingsOpen, setTextSettingsOpen] = useState(false); - const [performanceSettingsOpen, setPerformanceSettingsOpen] = useState(false); - - const activeImageModel = downloadedImageModels.find(m => m.id === activeImageModelId); - const classifierModel = downloadedModels.find(m => m.id === settings.classifierModelId); - - useEffect(() => { - if (visible) { - setPerformanceStats(llmService.getPerformanceStats()); - } - }, [visible]); - - const handleSliderChange = (key: keyof typeof DEFAULT_SETTINGS, value: number) => { - // Update store immediately for real-time sync - updateSettings({ [key]: value }); - }; - - const handleSliderComplete = (_key: keyof typeof DEFAULT_SETTINGS, _value: number) => { - // Already updated in handleSliderChange, this is now a no-op - // but kept for compatibility with existing code - }; - - const handleResetDefaults = () => { - updateSettings(DEFAULT_SETTINGS); - }; - - // Flash attention derived values — computed once to avoid repetition in JSX - const isFlashAttnOn = settings.flashAttn ?? (Platform.OS !== 'android'); - const gpuLayersMax = (Platform.OS === 'android' && isFlashAttnOn) ? 1 : 99; - const gpuLayersEffective = Math.min(settings.gpuLayers ?? 6, gpuLayersMax); - - return ( - - {/* Performance Stats */} - {performanceStats.lastTokensPerSecond > 0 && ( - - Last Generation: - - {performanceStats.lastTokensPerSecond.toFixed(1)} tok/s - - - - {performanceStats.lastTokenCount} tokens - - - - {performanceStats.lastGenerationTime.toFixed(1)}s - - - )} - - - {/* Conversation Actions - shown first for quick access */} - {(onOpenProject || onOpenGallery || onDeleteConversation) && ( - - {onOpenProject && ( - { onClose(); setTimeout(onOpenProject, 200); }} - > - - - Project: {activeProjectName || 'Default'} - - - - )} - {onOpenGallery && conversationImageCount > 0 && ( - { onClose(); setTimeout(onOpenGallery, 200); }} - > - - - Gallery ({conversationImageCount}) - - - - )} - {onDeleteConversation && ( - { onClose(); setTimeout(onDeleteConversation, 200); }} - > - - - Delete Conversation - - - )} - - )} - - {/* IMAGE GENERATION SETTINGS */} - setImageSettingsOpen(!imageSettingsOpen)} - activeOpacity={0.7} - > - IMAGE GENERATION - - - {imageSettingsOpen && - setShowImageModelPicker(!showImageModelPicker)} - > - - Image Model - - {activeImageModel?.name || 'None selected'} - - - - - - {showImageModelPicker && ( - - {downloadedImageModels.length === 0 ? ( - - No image models downloaded. Go to Models tab to download one. - - ) : ( - <> - { - setActiveImageModelId(null); - setShowImageModelPicker(false); - }} - > - None (disable image gen) - {!activeImageModelId && ( - - )} - - {downloadedImageModels.map((model) => ( - { - setActiveImageModelId(model.id); - setShowImageModelPicker(false); - }} - > - - {model.name} - {model.style} - - {activeImageModelId === model.id && ( - - )} - - ))} - - )} - - )} - - {/* Image Generation Mode Toggle */} - - - Auto-detect image requests - - {settings.imageGenerationMode === 'auto' - ? 'Detects when you want to generate an image' - : 'Use image button to manually trigger image generation'} - - - - updateSettings({ imageGenerationMode: 'auto' })} - testID="image-gen-mode-auto" - > - - Auto - - - updateSettings({ imageGenerationMode: 'manual' })} - testID="image-gen-mode-manual" - > - - Manual - - - - - - {/* Auto-detection method (only show when auto mode is enabled) */} - {settings.imageGenerationMode === 'auto' && ( - - - Detection Method - - {settings.autoDetectMethod === 'pattern' - ? 'Fast keyword matching ("draw", "create image", etc.)' - : 'Uses current text model for uncertain cases (slower)'} - - - - updateSettings({ autoDetectMethod: 'pattern' })} - testID="auto-detect-method-pattern" - > - - Pattern - - - updateSettings({ autoDetectMethod: 'llm' })} - testID="auto-detect-method-llm" - > - - LLM - - - - - )} - - {/* Classifier Model Selector - only show when LLM mode is selected */} - {settings.imageGenerationMode === 'auto' && settings.autoDetectMethod === 'llm' && ( - <> - setShowClassifierModelPicker(!showClassifierModelPicker)} - > - - Classifier Model - - {classifierModel?.name || 'Use current model'} - - - - - - {showClassifierModelPicker && ( - - { - updateSettings({ classifierModelId: null }); - setShowClassifierModelPicker(false); - }} - > - - Use current model - No model switching needed - - {!settings.classifierModelId && ( - - )} - - {downloadedModels.map((model) => ( - { - updateSettings({ classifierModelId: model.id }); - setShowClassifierModelPicker(false); - }} - > - - {model.name} - - {hardwareService.formatModelSize(model)} - {model.id.toLowerCase().includes('smol') && ' • Fast'} - - - {settings.classifierModelId === model.id && ( - - )} - - ))} - - )} - - Tip: Use a small model (SmolLM) for fast classification - - - )} - - {/* Image Quality Settings */} - - - Image Steps - {settings.imageSteps || 20} - - - LCM models: 4-8 steps, Standard SD: 20-50 steps - - updateSettings({ imageSteps: value })} - minimumTrackTintColor={colors.primary} - maximumTrackTintColor={colors.surfaceLight} - thumbTintColor={colors.primary} - /> - - 4 - 50 - - - - - - Guidance Scale - {(settings.imageGuidanceScale || 7.5).toFixed(1)} - - - Higher = follows prompt more strictly (5-15 range) - - updateSettings({ imageGuidanceScale: value })} - minimumTrackTintColor={colors.primary} - maximumTrackTintColor={colors.surfaceLight} - thumbTintColor={colors.primary} - /> - - 1 - 20 - - - - - - Image Threads - {settings.imageThreads ?? 4} - - - CPU threads used for image generation. Takes effect next time the image model loads. - - updateSettings({ imageThreads: value })} - minimumTrackTintColor={colors.primary} - maximumTrackTintColor={colors.surfaceLight} - thumbTintColor={colors.primary} - /> - - 1 - 8 - - - - - - Image Size - {settings.imageWidth ?? 256}x{settings.imageHeight ?? 256} - - - Output resolution (smaller = faster, larger = more detail) - - updateSettings({ imageWidth: value, imageHeight: value })} - minimumTrackTintColor={colors.primary} - maximumTrackTintColor={colors.surfaceLight} - thumbTintColor={colors.primary} - /> - - 128 - 512 - - - - {/* Enhance Image Prompts Toggle */} - - - Enhance Image Prompts - - {settings.enhanceImagePrompts - ? 'Text model refines your prompt before image generation (slower but better results)' - : 'Use your prompt directly for image generation (faster)'} - - - - updateSettings({ enhanceImagePrompts: false })} - > - - Off - - - updateSettings({ enhanceImagePrompts: true })} - > - - On - - - - - } - - {/* TEXT GENERATION SETTINGS */} - setTextSettingsOpen(!textSettingsOpen)} - activeOpacity={0.7} - > - TEXT GENERATION - - - {textSettingsOpen && - - {SETTINGS_CONFIG.map((config) => ( - - - {config.label} - - {config.format((settings[config.key] ?? DEFAULT_SETTINGS[config.key]) as number)} - - - {config.description && ( - {config.description} - )} - handleSliderChange(config.key, value)} - onSlidingComplete={(value) => handleSliderComplete(config.key, value)} - minimumTrackTintColor={colors.primary} - maximumTrackTintColor={colors.surfaceLight} - thumbTintColor={colors.primary} - /> - - {config.format(config.min)} - {config.format(config.max)} - - - ))} - } - - {/* PERFORMANCE SETTINGS */} - setPerformanceSettingsOpen(!performanceSettingsOpen)} - activeOpacity={0.7} - > - PERFORMANCE - - - {performanceSettingsOpen && - - {/* GPU Acceleration Toggle - hidden on iOS (Core ML auto-dispatches) */} - {Platform.OS !== 'ios' && ( - - - GPU Acceleration - - Offload inference to GPU when available. Faster for large models, may add overhead for small ones. Requires model reload. - - - - updateSettings({ enableGpu: false })} - > - - Off - - - updateSettings({ enableGpu: true })} - > - - On - - - - - {/* GPU Layers Slider - inline when GPU is enabled */} - {settings.enableGpu && ( - - - GPU Layers - {gpuLayersEffective} - - - Layers offloaded to GPU. Higher = faster but may crash on low-VRAM devices. Requires model reload. - - updateSettings({ gpuLayers: value })} - minimumTrackTintColor={colors.primary} - maximumTrackTintColor={colors.surfaceLight} - thumbTintColor={colors.primary} - /> - {Platform.OS === 'android' && isFlashAttnOn && ( - - Flash Attention limits GPU layers to 1 on Android - - )} - - )} - - )} - - {/* Flash Attention Toggle */} - - - Flash Attention - - Faster inference and lower memory. On Android, enabling this limits GPU layers to 1. Requires model reload. - - - - updateSettings({ flashAttn: false })} - > - - Off - - - { - const updates: Parameters[0] = { flashAttn: true }; - if (Platform.OS === 'android' && (settings.gpuLayers ?? 6) > 1) { - updates.gpuLayers = 1; - } - updateSettings(updates); - }} - > - - On - - - - - - - - Model Loading Strategy - - {settings.modelLoadingStrategy === 'performance' - ? 'Keep models loaded for faster responses (uses more memory)' - : 'Load models on demand to save memory (slower switching)'} - - - - updateSettings({ modelLoadingStrategy: 'memory' })} - > - - Save Memory - - - updateSettings({ modelLoadingStrategy: 'performance' })} - > - - Fast - - - - - - {/* Show Generation Details Toggle */} - - - Show Generation Details - - Display GPU, model, tok/s, and image settings below each message - - - - updateSettings({ showGenerationDetails: false })} - > - - Off - - - updateSettings({ showGenerationDetails: true })} - > - - On - - - - - - } - - {/* Reset Button */} - - Reset to Defaults - - - - - - ); -}; - -const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ - statsBar: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - justifyContent: 'center' as const, - backgroundColor: colors.surface, - paddingVertical: 10, - paddingHorizontal: 20, - gap: 6, - flexWrap: 'wrap' as const, - }, - statsLabel: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - }, - statsValue: { - ...TYPOGRAPHY.meta, - color: colors.primary, - fontWeight: '600' as const, - }, - statsSeparator: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - }, - content: { - flex: 1, - }, - contentContainer: { - paddingHorizontal: SPACING.lg, - paddingTop: SPACING.lg, - }, - sectionLabel: { - ...TYPOGRAPHY.label, - color: colors.textMuted, - textTransform: 'uppercase' as const, - letterSpacing: 1, - marginTop: SPACING.xl, - marginBottom: SPACING.md, - }, - accordionHeader: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - justifyContent: 'space-between' as const, - marginTop: SPACING.xl, - marginBottom: SPACING.md, - paddingVertical: SPACING.sm, - }, - accordionTitle: { - ...TYPOGRAPHY.label, - color: colors.textMuted, - textTransform: 'uppercase' as const, - letterSpacing: 1, - }, - sectionCard: { - backgroundColor: colors.surface, - borderRadius: 8, - padding: SPACING.lg, - borderWidth: 1, - borderColor: colors.border, - marginBottom: SPACING.lg, - }, - settingGroup: { - marginBottom: SPACING.lg, - }, - settingHeader: { - flexDirection: 'row' as const, - justifyContent: 'space-between' as const, - alignItems: 'center' as const, - marginBottom: SPACING.sm, - }, - settingLabel: { - ...TYPOGRAPHY.body, - color: colors.text, - }, - settingValue: { - ...TYPOGRAPHY.body, - color: colors.primary, - fontWeight: '400' as const, - }, - settingDescription: { - ...TYPOGRAPHY.bodySmall, - color: colors.textSecondary, - marginBottom: SPACING.md, - lineHeight: 18, - }, - settingWarning: { - ...TYPOGRAPHY.bodySmall, - color: colors.warning, - marginTop: SPACING.xs, - lineHeight: 18, - }, - slider: { - width: '100%' as const, - height: 40, - }, - sliderLabels: { - flexDirection: 'row' as const, - justifyContent: 'space-between' as const, - marginTop: -4, - }, - sliderMinMax: { - ...TYPOGRAPHY.label, - color: colors.textMuted, - }, - actionRow: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - backgroundColor: colors.background, - padding: SPACING.md, - borderRadius: 8, - marginBottom: SPACING.sm, - gap: SPACING.md, - }, - actionText: { - ...TYPOGRAPHY.body, - color: colors.text, - flex: 1, - }, - resetButton: { - backgroundColor: colors.surface, - padding: SPACING.md, - borderRadius: 8, - alignItems: 'center' as const, - borderWidth: 1, - borderColor: colors.border, - }, - resetButtonText: { - ...TYPOGRAPHY.body, - color: colors.textSecondary, - }, - bottomPadding: { - height: 40, - }, - modelPickerButton: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - justifyContent: 'space-between' as const, - backgroundColor: colors.background, - padding: SPACING.md, - borderRadius: 8, - borderWidth: 1, - borderColor: colors.border, - marginBottom: SPACING.sm, - }, - modelPickerContent: { - flex: 1, - }, - modelPickerLabel: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - marginBottom: 2, - }, - modelPickerValue: { - ...TYPOGRAPHY.bodySmall, - fontWeight: '600' as const, - color: colors.text, - }, - modelPickerList: { - backgroundColor: colors.background, - borderRadius: 8, - borderWidth: 1, - borderColor: colors.border, - marginBottom: SPACING.md, - overflow: 'hidden' as const, - }, - modelPickerItem: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - justifyContent: 'space-between' as const, - padding: SPACING.md, - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - modelPickerItemActive: { - backgroundColor: colors.primary + '25', - }, - modelPickerItemText: { - ...TYPOGRAPHY.bodySmall, - color: colors.text, - }, - modelPickerItemDesc: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - marginTop: 2, - }, - noModelsText: { - padding: 14, - ...TYPOGRAPHY.h3, - color: colors.textMuted, - textAlign: 'center' as const, - }, - classifierNote: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - fontStyle: 'italic' as const, - marginTop: SPACING.sm, - }, - gpuLayersInline: { - marginTop: SPACING.md, - paddingTop: SPACING.md, - borderTopWidth: 1, - borderTopColor: colors.border, - }, - modeToggleContainer: { - marginBottom: SPACING.lg, - }, - modeToggleInfo: { - marginBottom: SPACING.md, - }, - modeToggleLabel: { - ...TYPOGRAPHY.body, - color: colors.text, - marginBottom: SPACING.sm, - }, - modeToggleDesc: { - ...TYPOGRAPHY.bodySmall, - color: colors.textSecondary, - lineHeight: 18, - }, - modeToggleButtons: { - flexDirection: 'row' as const, - gap: SPACING.sm, - }, - modeButton: { - flex: 1, - paddingVertical: SPACING.sm, - paddingHorizontal: SPACING.md, - borderRadius: 8, - backgroundColor: 'transparent', - alignItems: 'center' as const, - borderWidth: 1, - borderColor: colors.border, - }, - modeButtonActive: { - backgroundColor: 'transparent', - borderColor: colors.primary, - }, - modeButtonText: { - ...TYPOGRAPHY.body, - color: colors.textSecondary, - }, - modeButtonTextActive: { - color: colors.primary, - }, -}); diff --git a/src/components/GenerationSettingsModal/ConversationActionsSection.tsx b/src/components/GenerationSettingsModal/ConversationActionsSection.tsx new file mode 100644 index 00000000..2d268619 --- /dev/null +++ b/src/components/GenerationSettingsModal/ConversationActionsSection.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { View, Text, TouchableOpacity } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { useTheme, useThemedStyles } from '../../theme'; +import { createStyles } from './styles'; + +interface ConversationActionsSectionProps { + onClose: () => void; + onOpenProject?: () => void; + onOpenGallery?: () => void; + onDeleteConversation?: () => void; + conversationImageCount: number; + activeProjectName?: string | null; +} + +export const ConversationActionsSection: React.FC = ({ + onClose, + onOpenProject, + onOpenGallery, + onDeleteConversation, + conversationImageCount, + activeProjectName, +}) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + + const hasActions = onOpenProject || onOpenGallery || onDeleteConversation; + if (!hasActions) { + return null; + } + + const handleOpenProject = () => { + onClose(); + setTimeout(onOpenProject!, 200); + }; + + const handleOpenGallery = () => { + onClose(); + setTimeout(onOpenGallery!, 200); + }; + + const handleDeleteConversation = () => { + onClose(); + setTimeout(onDeleteConversation!, 200); + }; + + return ( + + {onOpenProject && ( + + + + Project: {activeProjectName || 'Default'} + + + + )} + {onOpenGallery && conversationImageCount > 0 && ( + + + + Gallery ({conversationImageCount}) + + + + )} + {onDeleteConversation && ( + + + Delete Conversation + + )} + + ); +}; diff --git a/src/components/GenerationSettingsModal/ImageGenerationSection.tsx b/src/components/GenerationSettingsModal/ImageGenerationSection.tsx new file mode 100644 index 00000000..e24d7aad --- /dev/null +++ b/src/components/GenerationSettingsModal/ImageGenerationSection.tsx @@ -0,0 +1,320 @@ +import React, { useState } from 'react'; +import { View, Text, TouchableOpacity } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { useTheme, useThemedStyles } from '../../theme'; +import { useAppStore } from '../../stores'; +import { hardwareService } from '../../services'; +import { createStyles } from './styles'; +import { ImageQualitySliders } from './ImageQualitySliders'; + +// ─── Image Model Picker ─────────────────────────────────────────────────────── + +const ImageModelPicker: React.FC = () => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const { downloadedImageModels, activeImageModelId, setActiveImageModelId } = useAppStore(); + const [showPicker, setShowPicker] = useState(false); + const activeImageModel = downloadedImageModels.find(m => m.id === activeImageModelId); + + const handleSelectNone = () => { + setActiveImageModelId(null); + setShowPicker(false); + }; + + return ( + <> + setShowPicker(!showPicker)} + > + + Image Model + + {activeImageModel?.name || 'None selected'} + + + + + + {showPicker && ( + + {downloadedImageModels.length === 0 ? ( + + No image models downloaded. Go to Models tab to download one. + + ) : ( + <> + + None (disable image gen) + {!activeImageModelId && ( + + )} + + {downloadedImageModels.map((model) => { + const isActive = activeImageModelId === model.id; + const handleSelect = () => { + setActiveImageModelId(model.id); + setShowPicker(false); + }; + return ( + + + {model.name} + {model.style} + + {isActive && } + + ); + })} + + )} + + )} + + ); +}; + +// ─── Auto-Detect Method Toggle ──────────────────────────────────────────────── + +const AutoDetectMethodToggle: React.FC = () => { + const styles = useThemedStyles(createStyles); + const { settings, updateSettings } = useAppStore(); + + return ( + + + Detection Method + + {settings.autoDetectMethod === 'pattern' + ? 'Fast keyword matching ("draw", "create image", etc.)' + : 'Uses current text model for uncertain cases (slower)'} + + + + updateSettings({ autoDetectMethod: 'pattern' })} + testID="auto-detect-method-pattern" + > + + Pattern + + + updateSettings({ autoDetectMethod: 'llm' })} + testID="auto-detect-method-llm" + > + + LLM + + + + + ); +}; + +// ─── Classifier Model Picker ────────────────────────────────────────────────── + +const ClassifierModelPicker: React.FC = () => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const { downloadedModels, settings, updateSettings } = useAppStore(); + const [showPicker, setShowPicker] = useState(false); + const classifierModel = downloadedModels.find(m => m.id === settings.classifierModelId); + + const handleSelectNone = () => { + updateSettings({ classifierModelId: null }); + setShowPicker(false); + }; + + return ( + <> + setShowPicker(!showPicker)} + > + + Classifier Model + + {classifierModel?.name || 'Use current model'} + + + + + + {showPicker && ( + + + + Use current model + No model switching needed + + {!settings.classifierModelId && ( + + )} + + {downloadedModels.map((model) => { + const isActive = settings.classifierModelId === model.id; + const handleSelect = () => { + updateSettings({ classifierModelId: model.id }); + setShowPicker(false); + }; + const isFast = model.id.toLowerCase().includes('smol'); + return ( + + + {model.name} + + {hardwareService.formatModelSize(model)} + {isFast && ' • Fast'} + + + {isActive && } + + ); + })} + + )} + + Tip: Use a small model (SmolLM) for fast classification + + + ); +}; + +// ─── Main Section ───────────────────────────────────────────────────────────── + +export const ImageGenerationSection: React.FC = () => { + const styles = useThemedStyles(createStyles); + const { settings, updateSettings } = useAppStore(); + const isAutoMode = settings.imageGenerationMode === 'auto'; + const isLlmDetect = settings.autoDetectMethod === 'llm'; + + return ( + + + + {/* Image Generation Mode Toggle */} + + + Auto-detect image requests + + {isAutoMode + ? 'Detects when you want to generate an image' + : 'Use image button to manually trigger image generation'} + + + + updateSettings({ imageGenerationMode: 'auto' })} + testID="image-gen-mode-auto" + > + + Auto + + + updateSettings({ imageGenerationMode: 'manual' })} + testID="image-gen-mode-manual" + > + + Manual + + + + + + {isAutoMode && } + {isAutoMode && isLlmDetect && } + + + + {/* Enhance Image Prompts Toggle */} + + + Enhance Image Prompts + + {settings.enhanceImagePrompts + ? 'Text model refines your prompt before image generation (slower but better results)' + : 'Use your prompt directly for image generation (faster)'} + + + + updateSettings({ enhanceImagePrompts: false })} + > + + Off + + + updateSettings({ enhanceImagePrompts: true })} + > + + On + + + + + + ); +}; diff --git a/src/components/GenerationSettingsModal/ImageQualitySliders.tsx b/src/components/GenerationSettingsModal/ImageQualitySliders.tsx new file mode 100644 index 00000000..b5efe6b9 --- /dev/null +++ b/src/components/GenerationSettingsModal/ImageQualitySliders.tsx @@ -0,0 +1,118 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import Slider from '@react-native-community/slider'; +import { useTheme, useThemedStyles } from '../../theme'; +import { useAppStore } from '../../stores'; +import { createStyles } from './styles'; + +export const ImageQualitySliders: React.FC = () => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const { settings, updateSettings } = useAppStore(); + + return ( + <> + + + Image Steps + {settings.imageSteps || 20} + + + LCM models: 4-8 steps, Standard SD: 20-50 steps + + updateSettings({ imageSteps: value })} + minimumTrackTintColor={colors.primary} + maximumTrackTintColor={colors.surfaceLight} + thumbTintColor={colors.primary} + /> + + 4 + 50 + + + + + + Guidance Scale + {(settings.imageGuidanceScale || 7.5).toFixed(1)} + + + Higher = follows prompt more strictly (5-15 range) + + updateSettings({ imageGuidanceScale: value })} + minimumTrackTintColor={colors.primary} + maximumTrackTintColor={colors.surfaceLight} + thumbTintColor={colors.primary} + /> + + 1 + 20 + + + + + + Image Threads + {settings.imageThreads ?? 4} + + + CPU threads used for image generation. Takes effect next time the image model loads. + + updateSettings({ imageThreads: value })} + minimumTrackTintColor={colors.primary} + maximumTrackTintColor={colors.surfaceLight} + thumbTintColor={colors.primary} + /> + + 1 + 8 + + + + + + Image Size + + {settings.imageWidth ?? 256}x{settings.imageHeight ?? 256} + + + + Output resolution (smaller = faster, larger = more detail) + + updateSettings({ imageWidth: value, imageHeight: value })} + minimumTrackTintColor={colors.primary} + maximumTrackTintColor={colors.surfaceLight} + thumbTintColor={colors.primary} + /> + + 128 + 512 + + + + ); +}; diff --git a/src/components/GenerationSettingsModal/PerformanceSection.tsx b/src/components/GenerationSettingsModal/PerformanceSection.tsx new file mode 100644 index 00000000..0612ef94 --- /dev/null +++ b/src/components/GenerationSettingsModal/PerformanceSection.tsx @@ -0,0 +1,212 @@ +import React from 'react'; +import { View, Text, TouchableOpacity, Platform } from 'react-native'; +import Slider from '@react-native-community/slider'; +import { useTheme, useThemedStyles } from '../../theme'; +import { useAppStore } from '../../stores'; +import { createStyles } from './styles'; + +// ─── GPU Acceleration ───────────────────────────────────────────────────────── + +const GpuAccelerationToggle: React.FC = () => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const { settings, updateSettings } = useAppStore(); + const isFlashAttnOn = settings.flashAttn ?? (Platform.OS !== 'android'); + const gpuLayersMax = (Platform.OS === 'android' && isFlashAttnOn) ? 1 : 99; + const gpuLayersEffective = Math.min(settings.gpuLayers ?? 6, gpuLayersMax); + + return ( + + + GPU Acceleration + + Offload inference to GPU when available. Faster for large models, may add overhead for small ones. Requires model reload. + + + + updateSettings({ enableGpu: false })} + > + + Off + + + updateSettings({ enableGpu: true })} + > + + On + + + + + {settings.enableGpu && ( + + + GPU Layers + {gpuLayersEffective} + + + Layers offloaded to GPU. Higher = faster but may crash on low-VRAM devices. Requires model reload. + + updateSettings({ gpuLayers: value })} + minimumTrackTintColor={colors.primary} + maximumTrackTintColor={colors.surfaceLight} + thumbTintColor={colors.primary} + /> + {Platform.OS === 'android' && isFlashAttnOn && ( + + Flash Attention limits GPU layers to 1 on Android + + )} + + )} + + ); +}; + +// ─── Flash Attention ────────────────────────────────────────────────────────── + +const FlashAttentionToggle: React.FC = () => { + const styles = useThemedStyles(createStyles); + const { settings, updateSettings } = useAppStore(); + const isFlashAttnOn = settings.flashAttn ?? (Platform.OS !== 'android'); + + const handleFlashAttnOn = () => { + const updates: Parameters[0] = { flashAttn: true }; + if (Platform.OS === 'android' && (settings.gpuLayers ?? 6) > 1) { + updates.gpuLayers = 1; + } + updateSettings(updates); + }; + + return ( + + + Flash Attention + + Faster inference and lower memory. On Android, enabling this limits GPU layers to 1. Requires model reload. + + + + updateSettings({ flashAttn: false })} + > + + Off + + + + + On + + + + + ); +}; + +// ─── Model Loading Strategy ─────────────────────────────────────────────────── + +const ModelLoadingStrategyToggle: React.FC = () => { + const styles = useThemedStyles(createStyles); + const { settings, updateSettings } = useAppStore(); + const isPerformance = settings.modelLoadingStrategy === 'performance'; + const isMemory = settings.modelLoadingStrategy === 'memory'; + + return ( + + + Model Loading Strategy + + {isPerformance + ? 'Keep models loaded for faster responses (uses more memory)' + : 'Load models on demand to save memory (slower switching)'} + + + + updateSettings({ modelLoadingStrategy: 'memory' })} + > + + Save Memory + + + updateSettings({ modelLoadingStrategy: 'performance' })} + > + + Fast + + + + + ); +}; + +// ─── Show Generation Details ────────────────────────────────────────────────── + +const ShowGenerationDetailsToggle: React.FC = () => { + const styles = useThemedStyles(createStyles); + const { settings, updateSettings } = useAppStore(); + const isOn = settings.showGenerationDetails; + + return ( + + + Show Generation Details + + Display GPU, model, tok/s, and image settings below each message + + + + updateSettings({ showGenerationDetails: false })} + > + Off + + updateSettings({ showGenerationDetails: true })} + > + On + + + + ); +}; + +// ─── Main Section ───────────────────────────────────────────────────────────── + +export const PerformanceSection: React.FC = () => { + const styles = useThemedStyles(createStyles); + + return ( + + {Platform.OS !== 'ios' && } + + + + + ); +}; diff --git a/src/components/GenerationSettingsModal/TextGenerationSection.tsx b/src/components/GenerationSettingsModal/TextGenerationSection.tsx new file mode 100644 index 00000000..856c61b0 --- /dev/null +++ b/src/components/GenerationSettingsModal/TextGenerationSection.tsx @@ -0,0 +1,144 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import Slider from '@react-native-community/slider'; +import { useTheme, useThemedStyles } from '../../theme'; +import { useAppStore } from '../../stores'; +import { createStyles } from './styles'; + +interface SettingConfig { + key: string; + label: string; + min: number; + max: number; + step: number; + format: (value: number) => string; + description?: string; +} + +const DEFAULT_SETTINGS: Record = { + temperature: 0.7, + maxTokens: 1024, + topP: 0.9, + repeatPenalty: 1.1, + contextLength: 2048, + nThreads: 6, + nBatch: 256, +}; + +const SETTINGS_CONFIG: SettingConfig[] = [ + { + key: 'temperature', + label: 'Temperature', + min: 0, + max: 2, + step: 0.05, + format: (v) => v.toFixed(2), + description: 'Higher = more creative, Lower = more focused', + }, + { + key: 'maxTokens', + label: 'Max Tokens', + min: 64, + max: 8192, + step: 64, + format: (v) => v >= 1024 ? `${(v / 1024).toFixed(1)}K` : v.toString(), + description: 'Maximum length of generated response', + }, + { + key: 'topP', + label: 'Top P', + min: 0.1, + max: 1.0, + step: 0.05, + format: (v) => v.toFixed(2), + description: 'Nucleus sampling threshold', + }, + { + key: 'repeatPenalty', + label: 'Repeat Penalty', + min: 1.0, + max: 2.0, + step: 0.05, + format: (v) => v.toFixed(2), + description: 'Penalize repeated tokens', + }, + { + key: 'contextLength', + label: 'Context Length', + min: 512, + max: 32768, + step: 512, + format: (v) => v >= 1024 ? `${(v / 1024).toFixed(1)}K` : v.toString(), + description: 'Max conversation memory (requires model reload)', + }, + { + key: 'nThreads', + label: 'CPU Threads', + min: 1, + max: 12, + step: 1, + format: (v) => v.toString(), + description: 'Parallel threads for inference', + }, + { + key: 'nBatch', + label: 'Batch Size', + min: 32, + max: 512, + step: 32, + format: (v) => v.toString(), + description: 'Tokens processed per batch', + }, +]; + +interface SettingSliderProps { + config: SettingConfig; +} + +const SettingSlider: React.FC = ({ config }) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const { settings, updateSettings } = useAppStore(); + const rawValue = (settings as Record)[config.key]; + const value = (rawValue ?? DEFAULT_SETTINGS[config.key]) as number; + + return ( + + + {config.label} + {config.format(value)} + + {config.description && ( + {config.description} + )} + updateSettings({ [config.key]: v })} + onSlidingComplete={() => {}} + minimumTrackTintColor={colors.primary} + maximumTrackTintColor={colors.surfaceLight} + thumbTintColor={colors.primary} + /> + + {config.format(config.min)} + {config.format(config.max)} + + + ); +}; + +export const TextGenerationSection: React.FC = () => { + const styles = useThemedStyles(createStyles); + + return ( + + {SETTINGS_CONFIG.map((config) => ( + + ))} + + ); +}; diff --git a/src/components/GenerationSettingsModal/index.tsx b/src/components/GenerationSettingsModal/index.tsx new file mode 100644 index 00000000..074da5b1 --- /dev/null +++ b/src/components/GenerationSettingsModal/index.tsx @@ -0,0 +1,158 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, ScrollView, TouchableOpacity } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { AppSheet } from '../AppSheet'; +import { useTheme, useThemedStyles } from '../../theme'; +import { useAppStore } from '../../stores'; +import { llmService } from '../../services'; +import { createStyles } from './styles'; +import { ConversationActionsSection } from './ConversationActionsSection'; +import { ImageGenerationSection } from './ImageGenerationSection'; +import { TextGenerationSection } from './TextGenerationSection'; +import { PerformanceSection } from './PerformanceSection'; + +const DEFAULT_SETTINGS = { + temperature: 0.7, + maxTokens: 1024, + topP: 0.9, + repeatPenalty: 1.1, + contextLength: 2048, + nThreads: 6, + nBatch: 256, +}; + +interface GenerationSettingsModalProps { + visible: boolean; + onClose: () => void; + onOpenProject?: () => void; + onOpenGallery?: () => void; + onDeleteConversation?: () => void; + conversationImageCount?: number; + activeProjectName?: string | null; +} + +export const GenerationSettingsModal: React.FC = ({ + visible, + onClose, + onOpenProject, + onOpenGallery, + onDeleteConversation, + conversationImageCount = 0, + activeProjectName, +}) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const { updateSettings } = useAppStore(); + + const [performanceStats, setPerformanceStats] = useState(llmService.getPerformanceStats()); + const [imageSettingsOpen, setImageSettingsOpen] = useState(false); + const [textSettingsOpen, setTextSettingsOpen] = useState(false); + const [performanceSettingsOpen, setPerformanceSettingsOpen] = useState(false); + + useEffect(() => { + if (visible) { + setPerformanceStats(llmService.getPerformanceStats()); + } + }, [visible]); + + const handleResetDefaults = () => { + updateSettings(DEFAULT_SETTINGS); + }; + + const hasConversationActions = !!(onOpenProject || onOpenGallery || onDeleteConversation); + + return ( + + {performanceStats.lastTokensPerSecond > 0 && ( + + Last Generation: + + {performanceStats.lastTokensPerSecond.toFixed(1)} tok/s + + + + {performanceStats.lastTokenCount} tokens + + + + {performanceStats.lastGenerationTime.toFixed(1)}s + + + )} + + + + + {/* IMAGE GENERATION SETTINGS */} + setImageSettingsOpen(!imageSettingsOpen)} + activeOpacity={0.7} + > + IMAGE GENERATION + + + {imageSettingsOpen && } + + {/* TEXT GENERATION SETTINGS */} + setTextSettingsOpen(!textSettingsOpen)} + activeOpacity={0.7} + > + TEXT GENERATION + + + {textSettingsOpen && } + + {/* PERFORMANCE SETTINGS */} + setPerformanceSettingsOpen(!performanceSettingsOpen)} + activeOpacity={0.7} + > + PERFORMANCE + + + {performanceSettingsOpen && } + + + Reset to Defaults + + + + + + ); +}; diff --git a/src/components/GenerationSettingsModal/styles.ts b/src/components/GenerationSettingsModal/styles.ts new file mode 100644 index 00000000..c765d479 --- /dev/null +++ b/src/components/GenerationSettingsModal/styles.ts @@ -0,0 +1,287 @@ +import type { ThemeColors, ThemeShadows } from '../../theme'; +import { TYPOGRAPHY, SPACING } from '../../constants'; + +const createLayoutStyles = (_colors: ThemeColors) => ({ + flex1: { + flex: 1, + }, + content: { + flex: 1, + }, + contentContainer: { + paddingHorizontal: SPACING.lg, + paddingTop: SPACING.lg, + }, + bottomPadding: { + height: 40, + }, +}); + +const createStatsStyles = (colors: ThemeColors) => ({ + statsBar: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'center' as const, + backgroundColor: colors.surface, + paddingVertical: 10, + paddingHorizontal: 20, + gap: 6, + flexWrap: 'wrap' as const, + }, + statsLabel: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + }, + statsValue: { + ...TYPOGRAPHY.meta, + color: colors.primary, + fontWeight: '600' as const, + }, + statsSeparator: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + }, +}); + +const createAccordionStyles = (colors: ThemeColors) => ({ + accordionHeaderNoMargin: { + marginTop: 0, + }, + accordionHeader: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'space-between' as const, + marginTop: SPACING.xl, + marginBottom: SPACING.md, + paddingVertical: SPACING.sm, + }, + accordionTitle: { + ...TYPOGRAPHY.label, + color: colors.textMuted, + textTransform: 'uppercase' as const, + letterSpacing: 1, + }, + sectionCard: { + backgroundColor: colors.surface, + borderRadius: 8, + padding: SPACING.lg, + borderWidth: 1, + borderColor: colors.border, + marginBottom: SPACING.lg, + }, + sectionLabel: { + ...TYPOGRAPHY.label, + color: colors.textMuted, + textTransform: 'uppercase' as const, + letterSpacing: 1, + marginTop: SPACING.xl, + marginBottom: SPACING.md, + }, +}); + +const createSliderStyles = (colors: ThemeColors) => ({ + settingGroup: { + marginBottom: SPACING.lg, + }, + settingHeader: { + flexDirection: 'row' as const, + justifyContent: 'space-between' as const, + alignItems: 'center' as const, + marginBottom: SPACING.sm, + }, + settingLabel: { + ...TYPOGRAPHY.body, + color: colors.text, + }, + settingValue: { + ...TYPOGRAPHY.body, + color: colors.primary, + fontWeight: '400' as const, + }, + settingDescription: { + ...TYPOGRAPHY.bodySmall, + color: colors.textSecondary, + marginBottom: SPACING.md, + lineHeight: 18, + }, + settingWarning: { + ...TYPOGRAPHY.bodySmall, + color: colors.warning, + marginTop: SPACING.xs, + lineHeight: 18, + }, + slider: { + width: '100%' as const, + height: 40, + }, + sliderLabels: { + flexDirection: 'row' as const, + justifyContent: 'space-between' as const, + marginTop: -4, + }, + sliderMinMax: { + ...TYPOGRAPHY.label, + color: colors.textMuted, + }, +}); + +const createActionStyles = (colors: ThemeColors) => ({ + actionRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + backgroundColor: colors.background, + padding: SPACING.md, + borderRadius: 8, + marginBottom: SPACING.sm, + gap: SPACING.md, + }, + actionText: { + ...TYPOGRAPHY.body, + color: colors.text, + flex: 1, + }, + actionTextError: { + ...TYPOGRAPHY.body, + color: colors.error, + flex: 1, + }, + resetButton: { + backgroundColor: colors.surface, + padding: SPACING.md, + borderRadius: 8, + alignItems: 'center' as const, + borderWidth: 1, + borderColor: colors.border, + }, + resetButtonText: { + ...TYPOGRAPHY.body, + color: colors.textSecondary, + }, +}); + +const createModelPickerStyles = (colors: ThemeColors) => ({ + modelPickerButton: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'space-between' as const, + backgroundColor: colors.background, + padding: SPACING.md, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + marginBottom: SPACING.sm, + }, + modelPickerContent: { + flex: 1, + }, + modelPickerLabel: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + marginBottom: 2, + }, + modelPickerValue: { + ...TYPOGRAPHY.bodySmall, + fontWeight: '600' as const, + color: colors.text, + }, + modelPickerList: { + backgroundColor: colors.background, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + marginBottom: SPACING.md, + overflow: 'hidden' as const, + }, + modelPickerItem: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'space-between' as const, + padding: SPACING.md, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + modelPickerItemActive: { + backgroundColor: `${colors.primary}25`, + }, + modelPickerItemText: { + ...TYPOGRAPHY.bodySmall, + color: colors.text, + }, + modelPickerItemDesc: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + marginTop: 2, + }, + noModelsText: { + padding: 14, + ...TYPOGRAPHY.h3, + color: colors.textMuted, + textAlign: 'center' as const, + }, + classifierNote: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + fontStyle: 'italic' as const, + marginTop: SPACING.sm, + }, +}); + +const createToggleStyles = (colors: ThemeColors) => ({ + modeToggleContainer: { + marginBottom: SPACING.lg, + }, + modeToggleInfo: { + marginBottom: SPACING.md, + }, + modeToggleLabel: { + ...TYPOGRAPHY.body, + color: colors.text, + marginBottom: SPACING.sm, + }, + modeToggleDesc: { + ...TYPOGRAPHY.bodySmall, + color: colors.textSecondary, + lineHeight: 18, + }, + modeToggleButtons: { + flexDirection: 'row' as const, + gap: SPACING.sm, + }, + modeButton: { + flex: 1, + paddingVertical: SPACING.sm, + paddingHorizontal: SPACING.md, + borderRadius: 8, + backgroundColor: 'transparent', + alignItems: 'center' as const, + borderWidth: 1, + borderColor: colors.border, + }, + modeButtonActive: { + backgroundColor: 'transparent', + borderColor: colors.primary, + }, + modeButtonText: { + ...TYPOGRAPHY.body, + color: colors.textSecondary, + }, + modeButtonTextActive: { + color: colors.primary, + }, + gpuLayersInline: { + marginTop: SPACING.md, + paddingTop: SPACING.md, + borderTopWidth: 1, + borderTopColor: colors.border, + }, +}); + +export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ + ...createLayoutStyles(colors), + ...createStatsStyles(colors), + ...createAccordionStyles(colors), + ...createSliderStyles(colors), + ...createActionStyles(colors), + ...createModelPickerStyles(colors), + ...createToggleStyles(colors), +}); diff --git a/src/components/ModelCard.styles.ts b/src/components/ModelCard.styles.ts new file mode 100644 index 00000000..ca760514 --- /dev/null +++ b/src/components/ModelCard.styles.ts @@ -0,0 +1,212 @@ +import type { ThemeColors, ThemeShadows } from '../theme'; +import { TYPOGRAPHY } from '../constants'; + +export const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ + card: { + backgroundColor: colors.surface, + borderRadius: 16, + padding: 16, + marginBottom: 16, + ...shadows.small, + }, + cardCompact: { + padding: 12, + marginBottom: 12, + borderRadius: 12, + }, + compactTopRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + marginBottom: 4, + gap: 6, + }, + compactNameGroup: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + flex: 1, + gap: 6, + minWidth: 0, + }, + compactName: { + flexShrink: 1, + }, + authorTag: { + backgroundColor: colors.surfaceLight, + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 6, + flexShrink: 0, + }, + authorTagText: { + ...TYPOGRAPHY.metaSmall, + color: colors.textSecondary, + }, + cardActive: { + borderWidth: 2, + borderColor: colors.primary, + }, + cardIncompatible: { + opacity: 0.6, + }, + header: { + flexDirection: 'row' as const, + justifyContent: 'space-between' as const, + alignItems: 'flex-start' as const, + marginBottom: 8, + }, + headerCompact: { + marginBottom: 4, + }, + titleContainer: { + flex: 1, + }, + name: { + ...TYPOGRAPHY.h3, + color: colors.text, + }, + author: { + ...TYPOGRAPHY.bodySmall, + color: colors.textSecondary, + }, + authorRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + marginTop: 4, + marginBottom: 6, + gap: 8, + }, + credibilityBadge: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 6, + gap: 3, + }, + credibilityIcon: { + ...TYPOGRAPHY.meta, + fontSize: 10, + }, + credibilityText: { + ...TYPOGRAPHY.meta, + }, + activeBadge: { + backgroundColor: colors.primary, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 8, + }, + activeBadgeText: { + ...TYPOGRAPHY.meta, + color: colors.text, + }, + description: { + ...TYPOGRAPHY.bodySmall, + color: colors.textSecondary, + marginBottom: 12, + }, + descriptionCompact: { + marginBottom: 4, + ...TYPOGRAPHY.meta, + color: colors.textSecondary, + }, + cardRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + }, + cardContent: { + flex: 1, + }, + infoRow: { + flexDirection: 'row' as const, + flexWrap: 'wrap' as const, + gap: 6, + }, + infoRowCompact: { + marginTop: 4, + marginBottom: 6, + }, + infoBadge: { + backgroundColor: colors.surfaceLight, + paddingHorizontal: 10, + paddingVertical: 4, + borderRadius: 8, + }, + sizeBadge: { + backgroundColor: `${colors.primary}20`, + }, + infoText: { + ...TYPOGRAPHY.meta, + color: colors.textSecondary, + }, + recommendedBadge: { + backgroundColor: `${colors.info}30`, + }, + recommendedText: { + color: colors.info, + }, + warningBadge: { + backgroundColor: `${colors.warning}30`, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 8, + }, + warningText: { + ...TYPOGRAPHY.meta, + color: colors.warning, + }, + visionBadge: { + backgroundColor: `${colors.info}30`, + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 8, + }, + visionText: { + ...TYPOGRAPHY.meta, + color: colors.info, + }, + codeBadge: { + backgroundColor: `${colors.warning}30`, + }, + codeText: { + ...TYPOGRAPHY.meta, + color: colors.warning, + }, + statsRow: { + flexDirection: 'row' as const, + gap: 16, + marginBottom: 12, + }, + statsText: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + }, + progressContainer: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: 12, + marginBottom: 12, + }, + progressBar: { + flex: 1, + height: 8, + backgroundColor: colors.surfaceLight, + borderRadius: 4, + overflow: 'hidden' as const, + }, + progressFill: { + height: '100%' as const, + backgroundColor: colors.primary, + borderRadius: 4, + }, + progressText: { + ...TYPOGRAPHY.meta, + color: colors.textSecondary, + width: 40, + textAlign: 'right' as const, + }, + iconButton: { + padding: 4, + flexShrink: 0, + }, +}); diff --git a/src/components/ModelCard.tsx b/src/components/ModelCard.tsx index 9c7c38a9..f4df44c9 100644 --- a/src/components/ModelCard.tsx +++ b/src/components/ModelCard.tsx @@ -1,11 +1,15 @@ import React from 'react'; import { View, Text, TouchableOpacity } from 'react-native'; -import Icon from 'react-native-vector-icons/Feather'; -import { useTheme, useThemedStyles } from '../theme'; -import type { ThemeColors, ThemeShadows } from '../theme'; -import { QUANTIZATION_INFO, CREDIBILITY_LABELS, TYPOGRAPHY } from '../constants'; +import { useThemedStyles } from '../theme'; +import { QUANTIZATION_INFO, CREDIBILITY_LABELS } from '../constants'; import { ModelFile, DownloadedModel, ModelCredibility } from '../types'; -import { huggingFaceService } from '../services/huggingface'; +import { createStyles } from './ModelCard.styles'; +import { + CompactModelCardContent, + StandardModelCardContent, + ModelInfoBadges, + ModelCardActions, +} from './ModelCardContent'; interface ModelCardProps { model: { @@ -37,6 +41,24 @@ interface ModelCardProps { compact?: boolean; } +function resolveQuantInfo(file?: ModelFile, downloadedModel?: DownloadedModel) { + const quant = file?.quantization ?? downloadedModel?.quantization; + return quant ? (QUANTIZATION_INFO[quant] ?? null) : null; +} + +function resolveFileSize(file?: ModelFile, downloadedModel?: DownloadedModel) { + const main = file?.size ?? downloadedModel?.fileSize ?? 0; + const mmProj = file?.mmProjFile?.size ?? downloadedModel?.mmProjFileSize ?? 0; + return main + mmProj; +} + +function resolveCredibility( + model: { credibility?: ModelCredibility }, + downloadedModel?: DownloadedModel, +) { + return model.credibility ?? downloadedModel?.credibility; +} + export const ModelCard: React.FC = ({ model, file, @@ -54,38 +76,26 @@ export const ModelCard: React.FC = ({ onSelect, compact, }) => { - const { colors } = useTheme(); const styles = useThemedStyles(createStyles); - const quantInfo = file - ? QUANTIZATION_INFO[file.quantization] || null - : downloadedModel - ? QUANTIZATION_INFO[downloadedModel.quantization] || null - : null; - - // Calculate total size including mmproj if present - const mainFileSize = file?.size || downloadedModel?.fileSize || 0; - const mmProjSize = file?.mmProjFile?.size || downloadedModel?.mmProjFileSize || 0; - const fileSize = mainFileSize + mmProjSize; - - // Check if this is a vision model + const quantInfo = resolveQuantInfo(file, downloadedModel); + const fileSize = resolveFileSize(file, downloadedModel); const isVisionModel = !!(file?.mmProjFile || downloadedModel?.isVisionModel); - // Calculate size range from model files (for browsing view) const sizeRange = React.useMemo(() => { - if (fileSize > 0 || !model.files || model.files.length === 0) { - return null; - } + if (fileSize > 0 || !model.files || model.files.length === 0) return null; const sizes = model.files.map(f => f.size).filter(s => s > 0); if (sizes.length === 0) return null; - const minSize = Math.min(...sizes); - const maxSize = Math.max(...sizes); - return { min: minSize, max: maxSize, count: model.files.length }; + return { + min: Math.min(...sizes), + max: Math.max(...sizes), + count: model.files.length, + }; }, [model.files, fileSize]); - // Get credibility info from model or downloaded model - const credibility = model.credibility || downloadedModel?.credibility; + const credibility = resolveCredibility(model, downloadedModel); const credibilityInfo = credibility ? CREDIBILITY_LABELS[credibility.source] : null; + const quantization = file?.quantization ?? downloadedModel?.quantization; return ( = ({ testID={testID} > - {/* Left: all card content */} {compact ? ( - <> - - - - {model.name} - - - {model.author} - - {credibilityInfo && ( - - {credibility?.source === 'lmstudio' && ( - - )} - - {credibilityInfo.label} - - - )} - - {model.downloads !== undefined && model.downloads > 0 && ( - - {formatNumber(model.downloads)} dl - - )} - - {model.description && ( - - {model.description} - - )} - {compact && (model.modelType || model.paramCount) && ( - - {model.modelType && ( - - - {model.modelType === 'text' ? 'Text' : model.modelType === 'vision' ? 'Vision' : 'Code'} - - - )} - {model.paramCount && ( - - {model.paramCount}B params - - )} - {model.minRamGB && ( - - {model.minRamGB}GB+ RAM - - )} - - )} - + ) : ( - <> - {model.name} - - - {model.author} - - {credibilityInfo && ( - - {credibility?.source === 'lmstudio' && ( - - )} - {credibility?.source === 'official' && ( - - )} - {credibility?.source === 'verified-quantizer' && ( - - )} - - {credibilityInfo.label} - - - )} - {isActive && ( - - Active - - )} - - {model.description && ( - - {model.description} - - )} - + )} - {/* Info badges */} - - {fileSize > 0 && ( - - - {huggingFaceService.formatFileSize(fileSize)} - - - )} - {sizeRange && ( - - - {sizeRange.min === sizeRange.max - ? huggingFaceService.formatFileSize(sizeRange.min) - : `${huggingFaceService.formatFileSize(sizeRange.min)} - ${huggingFaceService.formatFileSize(sizeRange.max)}`} - - - )} - {sizeRange && ( - - - {sizeRange.count} {sizeRange.count === 1 ? 'file' : 'files'} - - - )} - {quantInfo && ( - - - {file?.quantization || downloadedModel?.quantization} - - - )} - {quantInfo && ( - - {quantInfo.quality} - - )} - {isVisionModel && ( - - Vision - - )} - {!isCompatible && ( - - {incompatibleReason || 'Too large'} - - )} - + {!compact && model.downloads !== undefined && model.downloads > 0 && ( @@ -267,264 +151,31 @@ export const ModelCard: React.FC = ({ {isDownloading && ( - + - - {Math.round(downloadProgress * 100)}% - + {Math.round(downloadProgress * 100)}% )} - {/* Right: vertically centered action icon */} - {!isDownloaded && !isDownloading && onDownload && ( - - - - )} - {isDownloaded && !isActive && onSelect && ( - - - - )} - {isDownloaded && onDelete && ( - - - - )} + ); }; function formatNumber(num: number): string { - if (num >= 1000000) { - return (num / 1000000).toFixed(1) + 'M'; - } - if (num >= 1000) { - return (num / 1000).toFixed(1) + 'K'; - } + if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; + if (num >= 1000) return `${(num / 1000).toFixed(1)}K`; return num.toString(); } - -const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ - card: { - backgroundColor: colors.surface, - borderRadius: 16, - padding: 16, - marginBottom: 16, - ...shadows.small, - }, - cardCompact: { - padding: 12, - marginBottom: 12, - borderRadius: 12, - }, - compactTopRow: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - marginBottom: 4, - gap: 6, - }, - compactNameGroup: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - flex: 1, - gap: 6, - minWidth: 0, - }, - compactName: { - flexShrink: 1, - }, - authorTag: { - backgroundColor: colors.surfaceLight, - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 6, - flexShrink: 0, - }, - authorTagText: { - ...TYPOGRAPHY.metaSmall, - color: colors.textSecondary, - }, - cardActive: { - borderWidth: 2, - borderColor: colors.primary, - }, - cardIncompatible: { - opacity: 0.6, - }, - header: { - flexDirection: 'row' as const, - justifyContent: 'space-between' as const, - alignItems: 'flex-start' as const, - marginBottom: 8, - }, - headerCompact: { - marginBottom: 4, - }, - titleContainer: { - flex: 1, - }, - name: { - ...TYPOGRAPHY.h3, - color: colors.text, - }, - author: { - ...TYPOGRAPHY.bodySmall, - color: colors.textSecondary, - }, - authorRow: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - marginTop: 4, - marginBottom: 6, - gap: 8, - }, - credibilityBadge: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 6, - gap: 3, - }, - credibilityIcon: { - ...TYPOGRAPHY.meta, - fontSize: 10, - }, - credibilityText: { - ...TYPOGRAPHY.meta, - }, - activeBadge: { - backgroundColor: colors.primary, - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 8, - }, - activeBadgeText: { - ...TYPOGRAPHY.meta, - color: colors.text, - }, - description: { - ...TYPOGRAPHY.bodySmall, - color: colors.textSecondary, - marginBottom: 12, - }, - descriptionCompact: { - marginBottom: 4, - ...TYPOGRAPHY.meta, - color: colors.textSecondary, - }, - cardRow: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - }, - cardContent: { - flex: 1, - }, - infoRow: { - flexDirection: 'row' as const, - flexWrap: 'wrap' as const, - gap: 6, - }, - infoBadge: { - backgroundColor: colors.surfaceLight, - paddingHorizontal: 10, - paddingVertical: 4, - borderRadius: 8, - }, - sizeBadge: { - backgroundColor: colors.primary + '20', - }, - infoText: { - ...TYPOGRAPHY.meta, - color: colors.textSecondary, - }, - recommendedBadge: { - backgroundColor: colors.info + '30', - }, - recommendedText: { - color: colors.info, - }, - warningBadge: { - backgroundColor: colors.warning + '30', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 8, - }, - warningText: { - ...TYPOGRAPHY.meta, - color: colors.warning, - }, - visionBadge: { - backgroundColor: colors.info + '30', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 8, - }, - visionText: { - ...TYPOGRAPHY.meta, - color: colors.info, - }, - codeBadge: { - backgroundColor: colors.warning + '30', - }, - codeText: { - ...TYPOGRAPHY.meta, - color: colors.warning, - }, - statsRow: { - flexDirection: 'row' as const, - gap: 16, - marginBottom: 12, - }, - statsText: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - }, - progressContainer: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - gap: 12, - marginBottom: 12, - }, - progressBar: { - flex: 1, - height: 8, - backgroundColor: colors.surfaceLight, - borderRadius: 4, - overflow: 'hidden' as const, - }, - progressFill: { - height: '100%' as const, - backgroundColor: colors.primary, - borderRadius: 4, - }, - progressText: { - ...TYPOGRAPHY.meta, - color: colors.textSecondary, - width: 40, - textAlign: 'right' as const, - }, - iconButton: { - padding: 4, - flexShrink: 0, - }, -}); diff --git a/src/components/ModelCardContent.tsx b/src/components/ModelCardContent.tsx new file mode 100644 index 00000000..d0281adb --- /dev/null +++ b/src/components/ModelCardContent.tsx @@ -0,0 +1,318 @@ +import React from 'react'; +import { View, Text, TouchableOpacity } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { useThemedStyles, useTheme } from '../theme'; +import { createStyles } from './ModelCard.styles'; +import { huggingFaceService } from '../services/huggingface'; +import { ModelCredibility } from '../types'; + +interface CredibilityInfo { + color: string; + label: string; +} + +// ── Compact header (name + author tag + optional downloads + description + type badges) ── + +interface CompactModelCardContentProps { + model: { + name: string; + author: string; + description?: string; + downloads?: number; + modelType?: 'text' | 'vision' | 'code'; + paramCount?: number; + minRamGB?: number; + }; + credibility?: ModelCredibility; + credibilityInfo: CredibilityInfo | null; +} + +function formatNumber(num: number): string { + if (num >= 1000000) return `${(num / 1000000).toFixed(1)}M`; + if (num >= 1000) return `${(num / 1000).toFixed(1)}K`; + return num.toString(); +} + +type ModelType = 'text' | 'vision' | 'code'; + +function modelTypeLabel(modelType: ModelType): string { + if (modelType === 'vision') return 'Vision'; + if (modelType === 'code') return 'Code'; + return 'Text'; +} + +function modelTypeBadgeStyle( + styles: ReturnType, + modelType: ModelType, +) { + if (modelType === 'vision') return styles.visionBadge; + if (modelType === 'code') return styles.codeBadge; + return null; +} + +function modelTypeTextStyle( + styles: ReturnType, + modelType: ModelType, +) { + if (modelType === 'vision') return styles.visionText; + if (modelType === 'code') return styles.codeText; + return null; +} + +export const CompactModelCardContent: React.FC = ({ + model, + credibility, + credibilityInfo, +}) => { + const styles = useThemedStyles(createStyles); + + return ( + <> + + + + {model.name} + + + {model.author} + + {credibilityInfo && ( + + {credibility?.source === 'lmstudio' && ( + + )} + + {credibilityInfo.label} + + + )} + + {model.downloads !== undefined && model.downloads > 0 && ( + + {formatNumber(model.downloads)} dl + + )} + + {model.description && ( + + {model.description} + + )} + {(model.modelType || model.paramCount) && ( + + {model.modelType && ( + + + {modelTypeLabel(model.modelType)} + + + )} + {model.paramCount && ( + + {model.paramCount}B params + + )} + {model.minRamGB && ( + + {model.minRamGB}GB+ RAM + + )} + + )} + + ); +}; + +// ── Standard (non-compact) header ── + +interface StandardModelCardContentProps { + model: { + name: string; + author: string; + description?: string; + }; + credibility?: ModelCredibility; + credibilityInfo: CredibilityInfo | null; + isActive?: boolean; +} + +export const StandardModelCardContent: React.FC = ({ + model, + credibility, + credibilityInfo, + isActive, +}) => { + const styles = useThemedStyles(createStyles); + + return ( + <> + {model.name} + + + {model.author} + + {credibilityInfo && ( + + {credibility?.source === 'lmstudio' && ( + + )} + {credibility?.source === 'official' && ( + + )} + {credibility?.source === 'verified-quantizer' && ( + + )} + + {credibilityInfo.label} + + + )} + {isActive && ( + + Active + + )} + + {model.description && ( + + {model.description} + + )} + + ); +}; + +// ── Info badges row (size, quant, vision, compatibility) ── + +interface ModelInfoBadgesProps { + fileSize: number; + sizeRange: { min: number; max: number; count: number } | null; + quantInfo: { quality: string; recommended: boolean } | null; + quantization: string | undefined; + isVisionModel: boolean; + isCompatible: boolean; + incompatibleReason: string | undefined; +} + +export const ModelInfoBadges: React.FC = ({ + fileSize, + sizeRange, + quantInfo, + quantization, + isVisionModel, + isCompatible, + incompatibleReason, +}) => { + const styles = useThemedStyles(createStyles); + + return ( + + {fileSize > 0 && ( + + {huggingFaceService.formatFileSize(fileSize)} + + )} + {sizeRange && ( + + + {sizeRange.min === sizeRange.max + ? huggingFaceService.formatFileSize(sizeRange.min) + : `${huggingFaceService.formatFileSize(sizeRange.min)} - ${huggingFaceService.formatFileSize(sizeRange.max)}`} + + + )} + {sizeRange && ( + + + {sizeRange.count} {sizeRange.count === 1 ? 'file' : 'files'} + + + )} + {quantInfo && ( + + + {quantization} + + + )} + {quantInfo && ( + + {quantInfo.quality} + + )} + {isVisionModel && ( + + Vision + + )} + {!isCompatible && ( + + {incompatibleReason ?? 'Too large'} + + )} + + ); +}; + +// ── Action icon buttons (download / select / delete) ── + +interface ModelCardActionsProps { + isDownloaded: boolean | undefined; + isDownloading: boolean | undefined; + isActive: boolean | undefined; + isCompatible: boolean; + incompatibleReason: string | undefined; + testID: string | undefined; + onDownload: (() => void) | undefined; + onSelect: (() => void) | undefined; + onDelete: (() => void) | undefined; +} + +export const ModelCardActions: React.FC = ({ + isDownloaded, + isDownloading, + isActive, + isCompatible, + incompatibleReason, + testID, + onDownload, + onSelect, + onDelete, +}) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + + return ( + <> + {!isDownloaded && !isDownloading && onDownload && ( + + + + )} + {isDownloaded && !isActive && onSelect && ( + + + + )} + {isDownloaded && onDelete && ( + + + + )} + + ); +}; diff --git a/src/components/ModelSelectorModal.tsx b/src/components/ModelSelectorModal.tsx deleted file mode 100644 index c2726606..00000000 --- a/src/components/ModelSelectorModal.tsx +++ /dev/null @@ -1,564 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import { - View, - Text, - ScrollView, - TouchableOpacity, - ActivityIndicator, -} from 'react-native'; -import Icon from 'react-native-vector-icons/Feather'; -import { AppSheet } from './AppSheet'; -import { useTheme, useThemedStyles } from '../theme'; -import type { ThemeColors, ThemeShadows } from '../theme'; -import { TYPOGRAPHY } from '../constants'; -import { useAppStore } from '../stores'; -import { DownloadedModel, ONNXImageModel } from '../types'; -import { activeModelService, hardwareService } from '../services'; - -type TabType = 'text' | 'image'; - -interface ModelSelectorModalProps { - visible: boolean; - onClose: () => void; - onSelectModel: (model: DownloadedModel) => void; - onSelectImageModel?: (model: ONNXImageModel) => void; - onUnloadModel: () => void; - onUnloadImageModel?: () => void; - isLoading: boolean; - currentModelPath: string | null; - initialTab?: TabType; -} - -export const ModelSelectorModal: React.FC = ({ - visible, - onClose, - onSelectModel, - onSelectImageModel, - onUnloadModel, - onUnloadImageModel, - isLoading, - currentModelPath, - initialTab = 'text', -}) => { - const { colors } = useTheme(); - const styles = useThemedStyles(createStyles); - - const { - downloadedModels, - downloadedImageModels, - activeImageModelId, - } = useAppStore(); - - const [activeTab, setActiveTab] = useState(initialTab); - const [isLoadingImage, setIsLoadingImage] = useState(false); - - // Reset tab when modal opens - useEffect(() => { - if (visible) { - setActiveTab(initialTab); - } - }, [visible, initialTab]); - - const hasLoadedTextModel = currentModelPath !== null; - const hasLoadedImageModel = !!activeImageModelId; - - const activeImageModel = downloadedImageModels.find(m => m.id === activeImageModelId); - const activeTextModel = downloadedModels.find(m => m.filePath === currentModelPath); - - const handleSelectImageModel = async (model: ONNXImageModel) => { - if (activeImageModelId === model.id) return; - - setIsLoadingImage(true); - try { - await activeModelService.loadImageModel(model.id); - onSelectImageModel?.(model); - } catch (error) { - console.error('Failed to load image model:', error); - } finally { - setIsLoadingImage(false); - } - }; - - const handleUnloadImageModel = async () => { - setIsLoadingImage(true); - try { - await activeModelService.unloadImageModel(); - onUnloadImageModel?.(); - } catch (error) { - console.error('Failed to unload image model:', error); - } finally { - setIsLoadingImage(false); - } - }; - - const formatSize = (bytes: number): string => { - return hardwareService.formatBytes(bytes); - }; - - const isCurrentTextModel = (model: DownloadedModel): boolean => { - return currentModelPath === model.filePath; - }; - - const isCurrentImageModel = (model: ONNXImageModel): boolean => { - return activeImageModelId === model.id; - }; - - const isAnyLoading = isLoading || isLoadingImage; - - return ( - - {/* Tab Bar */} - - setActiveTab('text')} - disabled={isAnyLoading} - > - - - Text - - {hasLoadedTextModel && ( - - - - )} - - - setActiveTab('image')} - disabled={isAnyLoading} - > - - - Image - - {hasLoadedImageModel && ( - - - - )} - - - - {/* Loading Banner */} - {isAnyLoading && ( - - - Loading model... - - )} - - {/* Content */} - - {activeTab === 'text' ? ( - // Text Models Tab - <> - {/* Currently Loaded Text Model */} - {hasLoadedTextModel && ( - - - - Currently Loaded - - - - - {activeTextModel?.name || 'Unknown'} - - - {activeTextModel?.quantization} • {activeTextModel ? hardwareService.formatModelSize(activeTextModel) : '0 B'} - - - - - Unload - - - - )} - - {/* Available Text Models */} - - {hasLoadedTextModel ? 'Switch Model' : 'Available Models'} - - - {downloadedModels.length === 0 ? ( - - - No Text Models - - Download models from the Models tab - - - ) : ( - downloadedModels.map((model) => { - const isCurrent = isCurrentTextModel(model); - return ( - onSelectModel(model)} - disabled={isAnyLoading || isCurrent} - > - - - {model.name} - - - {hardwareService.formatModelSize(model)} - {model.quantization && ( - <> - - {model.quantization} - - )} - {model.isVisionModel && ( - <> - - - - Vision - - - )} - - - {isCurrent && ( - - - - )} - - ); - }) - )} - - ) : ( - // Image Models Tab - <> - {/* Currently Loaded Image Model */} - {hasLoadedImageModel && ( - - - - Currently Loaded - - - - - {activeImageModel?.name || 'Unknown'} - - - {activeImageModel?.style || 'Image'} • {formatSize(activeImageModel?.size || 0)} - - - - {isLoadingImage ? ( - - ) : ( - <> - - Unload - - )} - - - - )} - - {/* Available Image Models */} - - {hasLoadedImageModel ? 'Switch Model' : 'Available Models'} - - - {downloadedImageModels.length === 0 ? ( - - - No Image Models - - Download image models from the Models tab - - - ) : ( - downloadedImageModels.map((model) => { - const isCurrent = isCurrentImageModel(model); - return ( - handleSelectImageModel(model)} - disabled={isAnyLoading || isCurrent} - > - - - {model.name} - - - {formatSize(model.size)} - {model.style && ( - <> - - {model.style} - - )} - - - {isCurrent && ( - - - - )} - - ); - }) - )} - - )} - - - ); -}; - -const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ - tabBar: { - flexDirection: 'row' as const, - paddingHorizontal: 16, - paddingTop: 12, - paddingBottom: 8, - gap: 8, - }, - tab: { - flex: 1, - flexDirection: 'row' as const, - alignItems: 'center' as const, - justifyContent: 'center' as const, - paddingVertical: 10, - paddingHorizontal: 16, - borderRadius: 10, - backgroundColor: colors.surface, - gap: 8, - }, - tabActive: { - backgroundColor: colors.primary + '20', - }, - tabText: { - ...TYPOGRAPHY.body, - color: colors.textMuted, - }, - tabTextActive: { - color: colors.primary, - }, - tabBadge: { - width: 18, - height: 18, - borderRadius: 9, - backgroundColor: colors.primary + '30', - alignItems: 'center' as const, - justifyContent: 'center' as const, - }, - tabBadgeDot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: colors.primary, - }, - loadingBanner: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - justifyContent: 'center' as const, - backgroundColor: colors.primary + '20', - paddingVertical: 10, - gap: 10, - }, - loadingText: { - ...TYPOGRAPHY.body, - color: colors.primary, - }, - content: { - padding: 16, - }, - contentContainer: { - paddingBottom: 24, - }, - loadedSection: { - marginBottom: 20, - backgroundColor: colors.surface, - borderRadius: 12, - padding: 14, - borderWidth: 1, - borderColor: colors.primary + '40', - }, - loadedHeader: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - gap: 6, - marginBottom: 10, - }, - loadedLabel: { - ...TYPOGRAPHY.label, - color: colors.success, - textTransform: 'uppercase' as const, - }, - loadedModelItem: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - }, - loadedModelInfo: { - flex: 1, - }, - loadedModelName: { - ...TYPOGRAPHY.body, - color: colors.text, - marginBottom: 2, - }, - loadedModelMeta: { - ...TYPOGRAPHY.bodySmall, - color: colors.textSecondary, - }, - unloadButton: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - paddingVertical: 8, - paddingHorizontal: 12, - borderRadius: 8, - backgroundColor: colors.error + '15', - gap: 6, - }, - unloadButtonText: { - ...TYPOGRAPHY.bodySmall, - color: colors.error, - }, - sectionTitle: { - ...TYPOGRAPHY.label, - color: colors.textMuted, - marginBottom: 12, - textTransform: 'uppercase' as const, - }, - emptyState: { - alignItems: 'center' as const, - paddingVertical: 40, - gap: 12, - }, - emptyTitle: { - ...TYPOGRAPHY.h2, - color: colors.text, - }, - emptyText: { - ...TYPOGRAPHY.body, - color: colors.textSecondary, - textAlign: 'center' as const, - }, - modelItem: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - padding: 14, - borderRadius: 12, - marginBottom: 8, - backgroundColor: colors.surface, - }, - modelItemSelected: { - backgroundColor: colors.primary + '15', - borderWidth: 1, - borderColor: colors.primary, - }, - modelItemSelectedImage: { - backgroundColor: colors.info + '15', - borderWidth: 1, - borderColor: colors.info, - }, - modelInfo: { - flex: 1, - }, - modelName: { - ...TYPOGRAPHY.body, - color: colors.text, - marginBottom: 4, - }, - modelNameSelected: { - color: colors.primary, - }, - modelMeta: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - }, - modelSize: { - ...TYPOGRAPHY.bodySmall, - color: colors.textSecondary, - }, - metaSeparator: { - ...TYPOGRAPHY.bodySmall, - color: colors.textMuted, - marginHorizontal: 6, - }, - modelQuant: { - ...TYPOGRAPHY.bodySmall, - color: colors.textMuted, - }, - modelStyle: { - ...TYPOGRAPHY.bodySmall, - color: colors.textMuted, - }, - visionBadge: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - backgroundColor: colors.info + '20', - paddingHorizontal: 6, - paddingVertical: 2, - borderRadius: 4, - gap: 4, - }, - visionBadgeText: { - ...TYPOGRAPHY.label, - color: colors.info, - }, - checkmark: { - width: 28, - height: 28, - borderRadius: 14, - backgroundColor: colors.primary, - alignItems: 'center' as const, - justifyContent: 'center' as const, - }, -}); diff --git a/src/components/ModelSelectorModal/index.tsx b/src/components/ModelSelectorModal/index.tsx new file mode 100644 index 00000000..a7725d88 --- /dev/null +++ b/src/components/ModelSelectorModal/index.tsx @@ -0,0 +1,340 @@ +import React, { useEffect, useState } from 'react'; +import { + View, + Text, + ScrollView, + TouchableOpacity, + ActivityIndicator, +} from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { AppSheet } from '../AppSheet'; +import { useTheme, useThemedStyles } from '../../theme'; +import { useAppStore } from '../../stores'; +import { DownloadedModel, ONNXImageModel } from '../../types'; +import { activeModelService, hardwareService } from '../../services'; +import { createStyles } from './styles'; + +type TabType = 'text' | 'image'; + +interface ModelSelectorModalProps { + visible: boolean; + onClose: () => void; + onSelectModel: (model: DownloadedModel) => void; + onSelectImageModel?: (model: ONNXImageModel) => void; + onUnloadModel: () => void; + onUnloadImageModel?: () => void; + isLoading: boolean; + currentModelPath: string | null; + initialTab?: TabType; +} + +// ─── Text tab ──────────────────────────────────────────────────────────────── + +interface TextTabProps { + downloadedModels: DownloadedModel[]; + currentModelPath: string | null; + isAnyLoading: boolean; + onSelectModel: (model: DownloadedModel) => void; + onUnloadModel: () => void; +} + +const TextTab: React.FC = ({ + downloadedModels, currentModelPath, isAnyLoading, onSelectModel, onUnloadModel, +}) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const hasLoaded = currentModelPath !== null; + const activeModel = downloadedModels.find(m => m.filePath === currentModelPath); + + return ( + <> + {hasLoaded && ( + + + + Currently Loaded + + + + + {activeModel?.name || 'Unknown'} + + + {activeModel?.quantization} • {activeModel ? hardwareService.formatModelSize(activeModel) : '0 B'} + + + + + Unload + + + + )} + + {hasLoaded ? 'Switch Model' : 'Available Models'} + + {downloadedModels.length === 0 ? ( + + + No Text Models + Download models from the Models tab + + ) : ( + downloadedModels.map((model) => { + const isCurrent = currentModelPath === model.filePath; + return ( + onSelectModel(model)} + disabled={isAnyLoading || isCurrent} + > + + + {model.name} + + + {hardwareService.formatModelSize(model)} + {!!model.quantization && ( + <> + + {model.quantization} + + )} + {model.isVisionModel && ( + <> + + + + Vision + + + )} + + + {isCurrent && ( + + + + )} + + ); + }) + )} + + ); +}; + +// ─── Image tab ─────────────────────────────────────────────────────────────── + +interface ImageTabProps { + downloadedImageModels: ONNXImageModel[]; + activeImageModelId: string | null; + isAnyLoading: boolean; + isLoadingImage: boolean; + onSelectImageModel: (model: ONNXImageModel) => void; + onUnloadImageModel: () => void; +} + +const ImageTab: React.FC = ({ + downloadedImageModels, activeImageModelId, isAnyLoading, isLoadingImage, + onSelectImageModel, onUnloadImageModel, +}) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const hasLoaded = !!activeImageModelId; + const activeModel = downloadedImageModels.find(m => m.id === activeImageModelId); + + return ( + <> + {hasLoaded && ( + + + + Currently Loaded + + + + + {activeModel?.name || 'Unknown'} + + + {activeModel?.style || 'Image'} • {hardwareService.formatBytes(activeModel?.size ?? 0)} + + + + {isLoadingImage ? ( + + ) : ( + <> + + Unload + + )} + + + + )} + + {hasLoaded ? 'Switch Model' : 'Available Models'} + + {downloadedImageModels.length === 0 ? ( + + + No Image Models + Download image models from the Models tab + + ) : ( + downloadedImageModels.map((model) => { + const isCurrent = activeImageModelId === model.id; + return ( + onSelectImageModel(model)} + disabled={isAnyLoading || isCurrent} + > + + + {model.name} + + + {hardwareService.formatBytes(model.size)} + {!!model.style && ( + <> + + {model.style} + + )} + + + {isCurrent && ( + + + + )} + + ); + }) + )} + + ); +}; + +// ─── Main modal ────────────────────────────────────────────────────────────── + +export const ModelSelectorModal: React.FC = ({ + visible, + onClose, + onSelectModel, + onSelectImageModel, + onUnloadModel, + onUnloadImageModel, + isLoading, + currentModelPath, + initialTab = 'text', +}) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const { downloadedModels, downloadedImageModels, activeImageModelId } = useAppStore(); + + const [activeTab, setActiveTab] = useState(initialTab); + const [isLoadingImage, setIsLoadingImage] = useState(false); + + useEffect(() => { + if (visible) setActiveTab(initialTab); + }, [visible, initialTab]); + + const handleSelectImageModel = async (model: ONNXImageModel) => { + if (activeImageModelId === model.id) return; + setIsLoadingImage(true); + try { + await activeModelService.loadImageModel(model.id); + onSelectImageModel?.(model); + } catch (error) { + console.error('Failed to load image model:', error); + } finally { + setIsLoadingImage(false); + } + }; + + const handleUnloadImageModel = async () => { + setIsLoadingImage(true); + try { + await activeModelService.unloadImageModel(); + onUnloadImageModel?.(); + } catch (error) { + console.error('Failed to unload image model:', error); + } finally { + setIsLoadingImage(false); + } + }; + + const isAnyLoading = isLoading || isLoadingImage; + const hasLoadedTextModel = currentModelPath !== null; + const hasLoadedImageModel = !!activeImageModelId; + + return ( + + + setActiveTab('text')} + disabled={isAnyLoading} + > + + Text + {hasLoadedTextModel && ( + + + + )} + + + setActiveTab('image')} + disabled={isAnyLoading} + > + + + Image + + {hasLoadedImageModel && ( + + + + )} + + + + {isAnyLoading && ( + + + Loading model... + + )} + + + {activeTab === 'text' ? ( + + ) : ( + + )} + + + ); +}; diff --git a/src/components/ModelSelectorModal/styles.ts b/src/components/ModelSelectorModal/styles.ts new file mode 100644 index 00000000..a68c4d91 --- /dev/null +++ b/src/components/ModelSelectorModal/styles.ts @@ -0,0 +1,213 @@ +import type { ThemeColors, ThemeShadows } from '../../theme'; +import { TYPOGRAPHY } from '../../constants'; + +export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ + tabBar: { + flexDirection: 'row' as const, + paddingHorizontal: 16, + paddingTop: 12, + paddingBottom: 8, + gap: 8, + }, + tab: { + flex: 1, + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'center' as const, + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 10, + backgroundColor: colors.surface, + gap: 8, + }, + tabActive: { + backgroundColor: `${colors.primary}20`, + }, + tabText: { + ...TYPOGRAPHY.body, + color: colors.textMuted, + }, + tabTextActive: { + color: colors.primary, + }, + tabBadge: { + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: `${colors.primary}30`, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, + tabBadgeDot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: colors.primary, + }, + loadingBanner: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'center' as const, + backgroundColor: `${colors.primary}20`, + paddingVertical: 10, + gap: 10, + }, + loadingText: { + ...TYPOGRAPHY.body, + color: colors.primary, + }, + content: { + padding: 16, + }, + contentContainer: { + paddingBottom: 24, + }, + loadedSection: { + marginBottom: 20, + backgroundColor: colors.surface, + borderRadius: 12, + padding: 14, + borderWidth: 1, + borderColor: `${colors.primary}40`, + }, + loadedSectionImage: { + borderColor: `${colors.info}40`, + }, + loadedHeader: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: 6, + marginBottom: 10, + }, + loadedLabel: { + ...TYPOGRAPHY.label, + color: colors.success, + textTransform: 'uppercase' as const, + }, + loadedModelItem: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + }, + loadedModelInfo: { + flex: 1, + }, + loadedModelName: { + ...TYPOGRAPHY.body, + color: colors.text, + marginBottom: 2, + }, + loadedModelMeta: { + ...TYPOGRAPHY.bodySmall, + color: colors.textSecondary, + }, + unloadButton: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + paddingVertical: 8, + paddingHorizontal: 12, + borderRadius: 8, + backgroundColor: `${colors.error}15`, + gap: 6, + }, + unloadButtonText: { + ...TYPOGRAPHY.bodySmall, + color: colors.error, + }, + sectionTitle: { + ...TYPOGRAPHY.label, + color: colors.textMuted, + marginBottom: 12, + textTransform: 'uppercase' as const, + }, + emptyState: { + alignItems: 'center' as const, + paddingVertical: 40, + gap: 12, + }, + emptyTitle: { + ...TYPOGRAPHY.h2, + color: colors.text, + }, + emptyText: { + ...TYPOGRAPHY.body, + color: colors.textSecondary, + textAlign: 'center' as const, + }, + modelItem: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + padding: 14, + borderRadius: 12, + marginBottom: 8, + backgroundColor: colors.surface, + }, + modelItemSelected: { + backgroundColor: `${colors.primary}15`, + borderWidth: 1, + borderColor: colors.primary, + }, + modelItemSelectedImage: { + backgroundColor: `${colors.info}15`, + borderWidth: 1, + borderColor: colors.info, + }, + modelInfo: { + flex: 1, + }, + modelName: { + ...TYPOGRAPHY.body, + color: colors.text, + marginBottom: 4, + }, + modelNameSelected: { + color: colors.primary, + }, + modelNameSelectedImage: { + color: colors.info, + }, + modelMeta: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + }, + modelSize: { + ...TYPOGRAPHY.bodySmall, + color: colors.textSecondary, + }, + metaSeparator: { + ...TYPOGRAPHY.bodySmall, + color: colors.textMuted, + marginHorizontal: 6, + }, + modelQuant: { + ...TYPOGRAPHY.bodySmall, + color: colors.textMuted, + }, + modelStyle: { + ...TYPOGRAPHY.bodySmall, + color: colors.textMuted, + }, + visionBadge: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + backgroundColor: `${colors.info}20`, + paddingHorizontal: 6, + paddingVertical: 2, + borderRadius: 4, + gap: 4, + }, + visionBadgeText: { + ...TYPOGRAPHY.label, + color: colors.info, + }, + checkmark: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: colors.primary, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, + checkmarkImage: { + backgroundColor: colors.info, + }, +}); diff --git a/src/components/ProjectSelectorSheet.tsx b/src/components/ProjectSelectorSheet.tsx index aeecb134..adc005aa 100644 --- a/src/components/ProjectSelectorSheet.tsx +++ b/src/components/ProjectSelectorSheet.tsx @@ -106,7 +106,7 @@ const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ backgroundColor: colors.surface, }, projectOptionSelected: { - backgroundColor: colors.primary + '20', + backgroundColor: `${colors.primary }20`, borderWidth: 1, borderColor: colors.primary, }, @@ -114,7 +114,7 @@ const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ width: 36, height: 36, borderRadius: 8, - backgroundColor: colors.primary + '30', + backgroundColor: `${colors.primary }30`, alignItems: 'center' as const, justifyContent: 'center' as const, marginRight: 12, diff --git a/src/components/VoiceRecordButton.tsx b/src/components/VoiceRecordButton.tsx deleted file mode 100644 index 5a1ece64..00000000 --- a/src/components/VoiceRecordButton.tsx +++ /dev/null @@ -1,544 +0,0 @@ -import React, { useRef, useEffect, useState } from 'react'; -import { - View, - Text, - TouchableOpacity, - Animated, - PanResponder, - GestureResponderEvent, - PanResponderGestureState, - Vibration, -} from 'react-native'; -import ReanimatedAnimated, { - useSharedValue, - useAnimatedStyle, - withRepeat, - withTiming, - Easing, -} from 'react-native-reanimated'; -import Icon from 'react-native-vector-icons/Feather'; -import { useTheme, useThemedStyles } from '../theme'; -import type { ThemeColors, ThemeShadows } from '../theme'; -import { CustomAlert, showAlert, hideAlert, AlertState, initialAlertState } from './CustomAlert'; - -interface VoiceRecordButtonProps { - isRecording: boolean; - isAvailable: boolean; - isModelLoading?: boolean; - isTranscribing?: boolean; - partialResult: string; - error?: string | null; - disabled?: boolean; - onStartRecording: () => void; - onStopRecording: () => void; - onCancelRecording: () => void; - /** Style as send button (WhatsApp-style, replaces send when input empty) */ - asSendButton?: boolean; -} - -const CANCEL_DISTANCE = 80; - -export const VoiceRecordButton: React.FC = ({ - isRecording, - isAvailable, - isModelLoading, - isTranscribing, - partialResult, - error, - disabled, - onStartRecording, - onStopRecording, - onCancelRecording, - asSendButton = false, -}) => { - const { colors } = useTheme(); - const styles = useThemedStyles(createStyles); - - const pulseAnim = useRef(new Animated.Value(1)).current; - const loadingAnim = useRef(new Animated.Value(0)).current; - const cancelOffsetX = useRef(new Animated.Value(0)).current; - const isDraggingToCancel = useRef(false); - const [alertState, setAlertState] = useState(initialAlertState); - - // Reanimated ripple ring - const rippleScale = useSharedValue(1); - const rippleOpacity = useSharedValue(0); - - useEffect(() => { - if (isRecording) { - rippleScale.value = 1; - rippleOpacity.value = 0.4; - rippleScale.value = withRepeat( - withTiming(2.2, { duration: 1200, easing: Easing.out(Easing.ease) }), - -1, - false - ); - rippleOpacity.value = withRepeat( - withTiming(0, { duration: 1200, easing: Easing.out(Easing.ease) }), - -1, - false - ); - } else { - rippleScale.value = 1; - rippleOpacity.value = 0; - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isRecording]); - - const rippleStyle = useAnimatedStyle(() => ({ - transform: [{ scale: rippleScale.value }], - opacity: rippleOpacity.value, - })); - - // Loading animation for model loading or transcribing - useEffect(() => { - if (isModelLoading || (isTranscribing && !isRecording)) { - const spin = Animated.loop( - Animated.timing(loadingAnim, { - toValue: 1, - duration: 1000, - useNativeDriver: true, - }) - ); - spin.start(); - return () => spin.stop(); - } else { - loadingAnim.setValue(0); - } - }, [isModelLoading, isTranscribing, isRecording, loadingAnim]); - - // Use refs to avoid stale closures in PanResponder - const callbacksRef = useRef({ onStartRecording, onStopRecording, onCancelRecording }); - callbacksRef.current = { onStartRecording, onStopRecording, onCancelRecording }; - - // Pulse animation when recording - useEffect(() => { - if (isRecording) { - const pulse = Animated.loop( - Animated.sequence([ - Animated.timing(pulseAnim, { - toValue: 1.2, - duration: 500, - useNativeDriver: true, - }), - Animated.timing(pulseAnim, { - toValue: 1, - duration: 500, - useNativeDriver: true, - }), - ]) - ); - pulse.start(); - return () => pulse.stop(); - } else { - pulseAnim.setValue(1); - } - }, [isRecording, pulseAnim]); - - const panResponder = useRef( - PanResponder.create({ - onStartShouldSetPanResponder: () => true, - onMoveShouldSetPanResponder: () => true, - onPanResponderGrant: () => { - console.log('[VoiceButton] Press started'); - // Haptic feedback on press - Vibration.vibrate(50); - isDraggingToCancel.current = false; - callbacksRef.current.onStartRecording(); - }, - onPanResponderMove: ( - _: GestureResponderEvent, - gestureState: PanResponderGestureState - ) => { - const offsetX = Math.min(0, gestureState.dx); - cancelOffsetX.setValue(offsetX); - - const wasInCancelZone = isDraggingToCancel.current; - const isInCancelZone = Math.abs(offsetX) > CANCEL_DISTANCE; - - // Haptic when entering cancel zone - if (isInCancelZone && !wasInCancelZone) { - Vibration.vibrate(30); - } - - isDraggingToCancel.current = isInCancelZone; - }, - onPanResponderRelease: () => { - console.log('[VoiceButton] Press released, cancel:', isDraggingToCancel.current); - // Haptic on release - Vibration.vibrate(30); - - if (isDraggingToCancel.current) { - callbacksRef.current.onCancelRecording(); - } else { - callbacksRef.current.onStopRecording(); - } - Animated.spring(cancelOffsetX, { - toValue: 0, - useNativeDriver: true, - }).start(); - isDraggingToCancel.current = false; - }, - onPanResponderTerminate: () => { - console.log('[VoiceButton] Press terminated'); - callbacksRef.current.onCancelRecording(); - Animated.spring(cancelOffsetX, { - toValue: 0, - useNativeDriver: true, - }).start(); - isDraggingToCancel.current = false; - }, - }) - ).current; - - const handleUnavailableTap = () => { - const errorDetail = error || 'No transcription model downloaded'; - setAlertState(showAlert( - 'Voice Input Unavailable', - `${errorDetail}\n\nTo enable voice input:\n1. Go to Settings tab\n2. Scroll to "Voice Transcription"\n3. Download a Whisper model\n\nVoice transcription runs completely on-device for privacy.`, - [{ text: 'OK' }] - )); - }; - - // Show loading state when model is loading - if (isModelLoading) { - const spin = loadingAnim.interpolate({ - inputRange: [0, 1], - outputRange: ['0deg', '360deg'], - }); - - return ( - - - - {asSendButton ? ( - - ) : ( - - )} - - {!asSendButton && Loading...} - - setAlertState(hideAlert())} - /> - - ); - } - - // Show transcribing state (after recording stopped, processing audio) - if (isTranscribing && !isRecording) { - const spin = loadingAnim.interpolate({ - inputRange: [0, 1], - outputRange: ['0deg', '360deg'], - }); - - return ( - - - - {asSendButton ? ( - - ) : ( - - )} - - {!asSendButton && Transcribing...} - - setAlertState(hideAlert())} - /> - - ); - } - - // Show unavailable state instead of hiding - if (!isAvailable) { - return ( - - - - {asSendButton ? ( - - ) : ( - <> - - - - - - - )} - - - setAlertState(hideAlert())} - /> - - ); - } - - return ( - - {/* Cancel hint */} - {isRecording && ( - - Slide to cancel - - )} - - {/* Partial result display */} - {isRecording && partialResult && ( - - - {partialResult} - - - )} - - {/* Ripple ring behind button when recording */} - {isRecording && ( - - )} - - {/* Main button with pan responder for hold-to-record */} - - - {/* Show send icon by default when asSendButton, mic when recording */} - {asSendButton && !isRecording ? ( - - ) : asSendButton && isRecording ? ( - - ) : ( - - - - - )} - - - - {/* Release to end — no cancel button needed */} - setAlertState(hideAlert())} - /> - - ); -}; - -const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ - container: { - alignItems: 'center' as const, - justifyContent: 'center' as const, - }, - rippleRing: { - position: 'absolute' as const, - width: 36, - height: 36, - borderRadius: 18, - borderWidth: 2, - borderColor: colors.primary, - backgroundColor: 'transparent', - }, - buttonWrapper: { - }, - button: { - width: 36, - height: 36, - borderRadius: 18, - backgroundColor: colors.surfaceLight, - alignItems: 'center' as const, - justifyContent: 'center' as const, - }, - buttonAsSend: { - width: 44, - height: 38, - borderRadius: 19, - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: colors.primary, - }, - buttonAsSendUnavailable: { - width: 44, - height: 38, - borderRadius: 19, - backgroundColor: colors.surfaceLight, - }, - buttonAsSendLoading: { - width: 44, - height: 38, - borderRadius: 19, - backgroundColor: colors.surface, - borderWidth: 2, - borderColor: colors.primary, - borderTopColor: 'transparent', - }, - buttonRecording: { - backgroundColor: colors.primary, - }, - buttonDisabled: { - opacity: 0.5, - }, - buttonLoading: { - backgroundColor: colors.surface, - borderWidth: 2, - borderColor: colors.primary, - borderTopColor: 'transparent', - }, - buttonTranscribing: { - backgroundColor: colors.surface, - borderWidth: 2, - borderColor: colors.info, - borderTopColor: 'transparent', - }, - buttonUnavailable: { - backgroundColor: colors.surface, - borderWidth: 1, - borderColor: colors.border, - borderStyle: 'dashed' as const, - }, - loadingContainer: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - }, - loadingIndicator: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: colors.primary, - }, - loadingText: { - fontSize: 11, - color: colors.primary, - marginLeft: 6, - }, - transcribingText: { - fontSize: 11, - color: colors.info, - marginLeft: 6, - }, - micIcon: { - alignItems: 'center' as const, - }, - micBody: { - width: 8, - height: 12, - backgroundColor: colors.primary, - borderRadius: 4, - }, - micBodyRecording: { - backgroundColor: colors.surface, - }, - micBodyAsSend: { - backgroundColor: colors.text, - }, - micBodyUnavailable: { - backgroundColor: colors.textMuted, - }, - micBase: { - width: 12, - height: 3, - backgroundColor: colors.primary, - borderRadius: 1.5, - marginTop: 2, - }, - unavailableSlash: { - position: 'absolute' as const, - width: 24, - height: 2, - backgroundColor: colors.textMuted, - transform: [{ rotate: '-45deg' }], - }, - cancelHint: { - position: 'absolute' as const, - left: -100, - paddingHorizontal: 12, - paddingVertical: 6, - backgroundColor: colors.primary + '40', - borderRadius: 12, - }, - cancelHintText: { - color: colors.primary, - fontSize: 12, - fontWeight: '500' as const, - }, - partialResultContainer: { - position: 'absolute' as const, - right: 50, - maxWidth: 200, - paddingHorizontal: 10, - paddingVertical: 6, - backgroundColor: colors.surface, - borderRadius: 12, - }, - partialResultText: { - color: colors.text, - fontSize: 12, - }, -}); diff --git a/src/components/VoiceRecordButton/index.tsx b/src/components/VoiceRecordButton/index.tsx new file mode 100644 index 00000000..1d685257 --- /dev/null +++ b/src/components/VoiceRecordButton/index.tsx @@ -0,0 +1,239 @@ +import React, { useRef, useEffect, useState } from 'react'; +import { + View, + Text, + TouchableOpacity, + Animated, + PanResponder, + GestureResponderEvent, + PanResponderGestureState, + Vibration, +} from 'react-native'; +import ReanimatedAnimated, { + useSharedValue, + useAnimatedStyle, + withRepeat, + withTiming, + Easing, +} from 'react-native-reanimated'; +import { useThemedStyles } from '../../theme'; +import { CustomAlert, showAlert, hideAlert, AlertState, initialAlertState } from '../CustomAlert'; +import { createStyles } from './styles'; +import { LoadingState, TranscribingState, UnavailableButton, ButtonIcon } from './states'; + +interface VoiceRecordButtonProps { + isRecording: boolean; + isAvailable: boolean; + isModelLoading?: boolean; + isTranscribing?: boolean; + partialResult: string; + error?: string | null; + disabled?: boolean; + onStartRecording: () => void; + onStopRecording: () => void; + onCancelRecording: () => void; + asSendButton?: boolean; +} + +const CANCEL_DISTANCE = 80; + +type CallbacksRef = { onStartRecording: () => void; onStopRecording: () => void; onCancelRecording: () => void }; + +function buildPanResponder({ + isDraggingToCancel, + cancelOffsetX, + callbacksRef, +}: { + isDraggingToCancel: React.MutableRefObject; + cancelOffsetX: Animated.Value; + callbacksRef: React.MutableRefObject; +}) { + return PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: () => true, + onPanResponderGrant: () => { + console.log('[VoiceButton] Press started'); + Vibration.vibrate(50); + isDraggingToCancel.current = false; + callbacksRef.current.onStartRecording(); + }, + onPanResponderMove: (_: GestureResponderEvent, gestureState: PanResponderGestureState) => { + const offsetX = Math.min(0, gestureState.dx); + cancelOffsetX.setValue(offsetX); + const wasInCancelZone = isDraggingToCancel.current; + const isInCancelZone = Math.abs(offsetX) > CANCEL_DISTANCE; + if (isInCancelZone && !wasInCancelZone) Vibration.vibrate(30); + isDraggingToCancel.current = isInCancelZone; + }, + onPanResponderRelease: () => { + console.log('[VoiceButton] Press released, cancel:', isDraggingToCancel.current); + Vibration.vibrate(30); + if (isDraggingToCancel.current) { + callbacksRef.current.onCancelRecording(); + } else { + callbacksRef.current.onStopRecording(); + } + Animated.spring(cancelOffsetX, { toValue: 0, useNativeDriver: true }).start(); + isDraggingToCancel.current = false; + }, + onPanResponderTerminate: () => { + console.log('[VoiceButton] Press terminated'); + callbacksRef.current.onCancelRecording(); + Animated.spring(cancelOffsetX, { toValue: 0, useNativeDriver: true }).start(); + isDraggingToCancel.current = false; + }, + }); +} + +export const VoiceRecordButton: React.FC = ({ + isRecording, + isAvailable, + isModelLoading, + isTranscribing, + partialResult, + error, + disabled, + onStartRecording, + onStopRecording, + onCancelRecording, + asSendButton = false, +}) => { + const styles = useThemedStyles(createStyles); + + const pulseAnim = useRef(new Animated.Value(1)).current; + const loadingAnim = useRef(new Animated.Value(0)).current; + const cancelOffsetX = useRef(new Animated.Value(0)).current; + const isDraggingToCancel = useRef(false); + const [alertState, setAlertState] = useState(initialAlertState); + + const rippleScale = useSharedValue(1); + const rippleOpacity = useSharedValue(0); + + useEffect(() => { + if (isRecording) { + rippleScale.value = 1; + rippleOpacity.value = 0.4; + rippleScale.value = withRepeat(withTiming(2.2, { duration: 1200, easing: Easing.out(Easing.ease) }), -1, false); + rippleOpacity.value = withRepeat(withTiming(0, { duration: 1200, easing: Easing.out(Easing.ease) }), -1, false); + } else { + rippleScale.value = 1; + rippleOpacity.value = 0; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isRecording]); + + const rippleStyle = useAnimatedStyle(() => ({ + transform: [{ scale: rippleScale.value }], + opacity: rippleOpacity.value, + })); + + useEffect(() => { + if (isModelLoading || (isTranscribing && !isRecording)) { + const spin = Animated.loop(Animated.timing(loadingAnim, { toValue: 1, duration: 1000, useNativeDriver: true })); + spin.start(); + return () => spin.stop(); + } + loadingAnim.setValue(0); + }, [isModelLoading, isTranscribing, isRecording, loadingAnim]); + + const callbacksRef = useRef({ onStartRecording, onStopRecording, onCancelRecording }); + callbacksRef.current = { onStartRecording, onStopRecording, onCancelRecording }; + + useEffect(() => { + if (isRecording) { + const pulse = Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { toValue: 1.2, duration: 500, useNativeDriver: true }), + Animated.timing(pulseAnim, { toValue: 1, duration: 500, useNativeDriver: true }), + ]), + ); + pulse.start(); + return () => pulse.stop(); + } + pulseAnim.setValue(1); + }, [isRecording, pulseAnim]); + + const panResponder = useRef(buildPanResponder({ isDraggingToCancel, cancelOffsetX, callbacksRef })).current; + + const handleUnavailableTap = () => { + const errorDetail = error || 'No transcription model downloaded'; + setAlertState(showAlert( + 'Voice Input Unavailable', + `${errorDetail}\n\nTo enable voice input:\n1. Go to Settings tab\n2. Scroll to "Voice Transcription"\n3. Download a Whisper model\n\nVoice transcription runs completely on-device for privacy.`, + [{ text: 'OK' }], + )); + }; + + const alert = ( + setAlertState(hideAlert())} + /> + ); + + if (isModelLoading) { + return ( + + + {alert} + + ); + } + + if (isTranscribing && !isRecording) { + return ( + + + {alert} + + ); + } + + if (!isAvailable) { + return ( + + + + + {alert} + + ); + } + + const buttonStyle = [ + styles.button, + asSendButton && styles.buttonAsSend, + isRecording && styles.buttonRecording, + disabled && styles.buttonDisabled, + ]; + + return ( + + {isRecording && ( + + Slide to cancel + + )} + {isRecording && partialResult && ( + + {partialResult} + + )} + {isRecording && } + + + + + + {alert} + + ); +}; diff --git a/src/components/VoiceRecordButton/states.tsx b/src/components/VoiceRecordButton/states.tsx new file mode 100644 index 00000000..d0ba1ab2 --- /dev/null +++ b/src/components/VoiceRecordButton/states.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { View, Text, Animated } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { useTheme, useThemedStyles } from '../../theme'; +import { createStyles } from './styles'; + +// ─── Loading state ──────────────────────────────────────────────────────────── + +interface LoadingStateProps { + asSendButton: boolean; + loadingAnim: Animated.Value; +} + +export const LoadingState: React.FC = ({ asSendButton, loadingAnim }) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const spin = loadingAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }); + + return ( + + + {asSendButton ? : } + + {!asSendButton && Loading...} + + ); +}; + +// ─── Transcribing state ─────────────────────────────────────────────────────── + +interface TranscribingStateProps { + asSendButton: boolean; + loadingAnim: Animated.Value; +} + +export const TranscribingState: React.FC = ({ asSendButton, loadingAnim }) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const spin = loadingAnim.interpolate({ inputRange: [0, 1], outputRange: ['0deg', '360deg'] }); + + return ( + + + {asSendButton ? : } + + {!asSendButton && Transcribing...} + + ); +}; + +// ─── Unavailable state ──────────────────────────────────────────────────────── + +interface UnavailableButtonProps { + asSendButton: boolean; +} + +export const UnavailableButton: React.FC = ({ asSendButton }) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + + return ( + + {asSendButton ? ( + + ) : ( + <> + + + + + + + )} + + ); +}; + +// ─── Button icon ────────────────────────────────────────────────────────────── + +interface ButtonIconProps { + asSendButton: boolean; + isRecording: boolean; +} + +export const ButtonIcon: React.FC = ({ asSendButton, isRecording }) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + + if (asSendButton) { + return ; + } + + return ( + + + + + ); +}; diff --git a/src/components/VoiceRecordButton/styles.ts b/src/components/VoiceRecordButton/styles.ts new file mode 100644 index 00000000..ef89816e --- /dev/null +++ b/src/components/VoiceRecordButton/styles.ts @@ -0,0 +1,152 @@ +import type { ThemeColors, ThemeShadows } from '../../theme'; + +export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ + container: { + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, + rippleRing: { + position: 'absolute' as const, + width: 36, + height: 36, + borderRadius: 18, + borderWidth: 2, + borderColor: colors.primary, + backgroundColor: 'transparent', + }, + buttonWrapper: { + }, + button: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: colors.surfaceLight, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, + buttonAsSend: { + width: 44, + height: 38, + borderRadius: 19, + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: colors.primary, + }, + buttonAsSendUnavailable: { + width: 44, + height: 38, + borderRadius: 19, + backgroundColor: colors.surfaceLight, + }, + buttonAsSendLoading: { + width: 44, + height: 38, + borderRadius: 19, + backgroundColor: colors.surface, + borderWidth: 2, + borderColor: colors.primary, + borderTopColor: 'transparent', + }, + buttonRecording: { + backgroundColor: colors.primary, + }, + buttonDisabled: { + opacity: 0.5, + }, + buttonLoading: { + backgroundColor: colors.surface, + borderWidth: 2, + borderColor: colors.primary, + borderTopColor: 'transparent', + }, + buttonTranscribing: { + backgroundColor: colors.surface, + borderWidth: 2, + borderColor: colors.info, + borderTopColor: 'transparent', + }, + buttonUnavailable: { + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + borderStyle: 'dashed' as const, + }, + loadingContainer: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + }, + loadingIndicator: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: colors.primary, + }, + loadingText: { + fontSize: 11, + color: colors.primary, + marginLeft: 6, + }, + transcribingText: { + fontSize: 11, + color: colors.info, + marginLeft: 6, + }, + micIcon: { + alignItems: 'center' as const, + }, + micBody: { + width: 8, + height: 12, + backgroundColor: colors.primary, + borderRadius: 4, + }, + micBodyRecording: { + backgroundColor: colors.surface, + }, + micBodyAsSend: { + backgroundColor: colors.text, + }, + micBodyUnavailable: { + backgroundColor: colors.textMuted, + }, + micBase: { + width: 12, + height: 3, + backgroundColor: colors.primary, + borderRadius: 1.5, + marginTop: 2, + }, + unavailableSlash: { + position: 'absolute' as const, + width: 24, + height: 2, + backgroundColor: colors.textMuted, + transform: [{ rotate: '-45deg' }], + }, + cancelHint: { + position: 'absolute' as const, + left: -100, + paddingHorizontal: 12, + paddingVertical: 6, + backgroundColor: `${colors.primary}40`, + borderRadius: 12, + }, + cancelHintText: { + color: colors.primary, + fontSize: 12, + fontWeight: '500' as const, + }, + partialResultContainer: { + position: 'absolute' as const, + right: 50, + maxWidth: 200, + paddingHorizontal: 10, + paddingVertical: 6, + backgroundColor: colors.surface, + borderRadius: 12, + }, + partialResultText: { + color: colors.text, + fontSize: 12, + }, +}); diff --git a/src/constants/index.ts b/src/constants/index.ts index eac443bb..607ee353 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,215 +1,4 @@ -// Model size recommendations based on device RAM -export const MODEL_RECOMMENDATIONS = { - // RAM in GB -> max model parameters in billions - memoryToParams: [ - { minRam: 3, maxRam: 4, maxParams: 1.5, quantization: 'Q4_K_M' }, - { minRam: 4, maxRam: 6, maxParams: 3, quantization: 'Q4_K_M' }, - { minRam: 6, maxRam: 8, maxParams: 4, quantization: 'Q4_K_M' }, - { minRam: 8, maxRam: 12, maxParams: 8, quantization: 'Q4_K_M' }, - { minRam: 12, maxRam: 16, maxParams: 13, quantization: 'Q4_K_M' }, - { minRam: 16, maxRam: Infinity, maxParams: 30, quantization: 'Q4_K_M' }, - ], -}; - -// Curated list of recommended models for mobile (updated Feb 2026) -// All IDs use official org repos where available, ggml-org (HuggingFace official) as fallback -export const RECOMMENDED_MODELS = [ - // --- Text: Ultra-light (3 GB+) --- - { - id: 'Qwen/Qwen3-0.6B-GGUF', - name: 'Qwen 3 0.6B', - params: 0.6, - description: 'Latest Qwen with thinking mode, ultra-light', - minRam: 3, - type: 'text' as const, - org: 'Qwen', - }, - { - id: 'ggml-org/gemma-3-1b-it-GGUF', - name: 'Gemma 3 1B', - params: 1, - description: 'Google\'s tiny model, 128K context', - minRam: 3, - type: 'text' as const, - org: 'google', - }, - // --- Text: Small (4 GB+) --- - { - id: 'bartowski/Llama-3.2-1B-Instruct-GGUF', - name: 'Llama 3.2 1B', - params: 1, - description: 'Meta\'s fastest mobile model, 128K context', - minRam: 4, - type: 'text' as const, - org: 'meta-llama', - }, - { - id: 'ggml-org/gemma-3n-E2B-it-GGUF', - name: 'Gemma 3n E2B', - params: 2, - description: 'Google\'s mobile-first with selective activation', - minRam: 4, - type: 'text' as const, - org: 'google', - }, - // --- Text: Medium (6 GB+) --- - { - id: 'bartowski/Llama-3.2-3B-Instruct-GGUF', - name: 'Llama 3.2 3B', - params: 3, - description: 'Best quality-to-size ratio for mobile', - minRam: 6, - type: 'text' as const, - org: 'meta-llama', - }, - { - id: 'ggml-org/SmolLM3-3B-GGUF', - name: 'SmolLM3 3B', - params: 3, - description: 'Strong reasoning & 128K context', - minRam: 6, - type: 'text' as const, - org: 'HuggingFaceTB', - }, - { - id: 'bartowski/microsoft_Phi-4-mini-instruct-GGUF', - name: 'Phi-4 Mini', - params: 3.8, - description: 'Math & reasoning specialist', - minRam: 6, - type: 'text' as const, - org: 'microsoft', - }, - // --- Text: Large (8 GB+) --- - { - id: 'Qwen/Qwen3-8B-GGUF', - name: 'Qwen 3 8B', - params: 8, - description: 'Thinking + non-thinking modes, 100+ languages', - minRam: 8, - type: 'text' as const, - org: 'Qwen', - }, - // --- Vision --- - { - id: 'Qwen/Qwen3-VL-2B-Instruct-GGUF', - name: 'Qwen 3 VL 2B', - params: 2, - description: 'Compact vision-language model with thinking mode', - minRam: 4, - type: 'vision' as const, - org: 'Qwen', - }, - { - id: 'ggml-org/gemma-3n-E4B-it-GGUF', - name: 'Gemma 3n E4B', - params: 4, - description: 'Vision + audio, built for mobile', - minRam: 6, - type: 'vision' as const, - org: 'google', - }, - { - id: 'Qwen/Qwen3-VL-8B-Instruct-GGUF', - name: 'Qwen 3 VL 8B', - params: 8, - description: 'Vision-language model with thinking mode', - minRam: 8, - type: 'vision' as const, - org: 'Qwen', - }, - // --- Code --- - { - id: 'Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF', - name: 'Qwen 3 Coder A3B', - params: 3, - description: 'MoE coding model, only 3B active params', - minRam: 6, - type: 'code' as const, - org: 'Qwen', - }, -]; - -// Model organization filter options -export const MODEL_ORGS = [ - { key: 'Qwen', label: 'Qwen' }, - { key: 'meta-llama', label: 'Llama' }, - { key: 'google', label: 'Google' }, - { key: 'microsoft', label: 'Microsoft' }, - { key: 'mistralai', label: 'Mistral' }, - { key: 'deepseek-ai', label: 'DeepSeek' }, - { key: 'HuggingFaceTB', label: 'HuggingFace' }, - { key: 'nvidia', label: 'NVIDIA' }, -]; - -// Quantization levels and their properties -export const QUANTIZATION_INFO: Record = { - 'Q2_K': { - bitsPerWeight: 2.625, - quality: 'Low', - description: 'Extreme compression, noticeable quality loss', - recommended: false, - }, - 'Q3_K_S': { - bitsPerWeight: 3.4375, - quality: 'Low-Medium', - description: 'High compression, some quality loss', - recommended: false, - }, - 'Q3_K_M': { - bitsPerWeight: 3.4375, - quality: 'Medium', - description: 'Good compression with acceptable quality', - recommended: false, - }, - 'Q4_0': { - bitsPerWeight: 4, - quality: 'Medium', - description: 'Basic 4-bit quantization', - recommended: false, - }, - 'Q4_K_S': { - bitsPerWeight: 4.5, - quality: 'Medium-Good', - description: 'Good balance of size and quality', - recommended: true, - }, - 'Q4_K_M': { - bitsPerWeight: 4.5, - quality: 'Good', - description: 'Optimal for mobile - best balance', - recommended: true, - }, - 'Q5_K_S': { - bitsPerWeight: 5.5, - quality: 'Good-High', - description: 'Higher quality, larger size', - recommended: false, - }, - 'Q5_K_M': { - bitsPerWeight: 5.5, - quality: 'High', - description: 'Near original quality', - recommended: false, - }, - 'Q6_K': { - bitsPerWeight: 6.5, - quality: 'Very High', - description: 'Minimal quality loss', - recommended: false, - }, - 'Q8_0': { - bitsPerWeight: 8, - quality: 'Excellent', - description: 'Best quality, largest size', - recommended: false, - }, -}; +export { MODEL_RECOMMENDATIONS, RECOMMENDED_MODELS, MODEL_ORGS, QUANTIZATION_INFO } from './models'; // Hugging Face API configuration export const HF_API = { diff --git a/src/constants/models.ts b/src/constants/models.ts new file mode 100644 index 00000000..42202194 --- /dev/null +++ b/src/constants/models.ts @@ -0,0 +1,162 @@ +// Model size recommendations based on device RAM +export const MODEL_RECOMMENDATIONS = { + // RAM in GB -> max model parameters in billions + memoryToParams: [ + { minRam: 3, maxRam: 4, maxParams: 1.5, quantization: 'Q4_K_M' }, + { minRam: 4, maxRam: 6, maxParams: 3, quantization: 'Q4_K_M' }, + { minRam: 6, maxRam: 8, maxParams: 4, quantization: 'Q4_K_M' }, + { minRam: 8, maxRam: 12, maxParams: 8, quantization: 'Q4_K_M' }, + { minRam: 12, maxRam: 16, maxParams: 13, quantization: 'Q4_K_M' }, + { minRam: 16, maxRam: Infinity, maxParams: 30, quantization: 'Q4_K_M' }, + ], +}; + +// Curated list of recommended models for mobile (updated Feb 2026) +// All IDs use official org repos where available, ggml-org (HuggingFace official) as fallback +export const RECOMMENDED_MODELS = [ + // --- Text: Ultra-light (3 GB+) --- + { + id: 'Qwen/Qwen3-0.6B-GGUF', + name: 'Qwen 3 0.6B', + params: 0.6, + description: 'Latest Qwen with thinking mode, ultra-light', + minRam: 3, + type: 'text' as const, + org: 'Qwen', + }, + { + id: 'ggml-org/gemma-3-1b-it-GGUF', + name: 'Gemma 3 1B', + params: 1, + description: 'Google\'s tiny model, 128K context', + minRam: 3, + type: 'text' as const, + org: 'google', + }, + // --- Text: Small (4 GB+) --- + { + id: 'bartowski/Llama-3.2-1B-Instruct-GGUF', + name: 'Llama 3.2 1B', + params: 1, + description: 'Meta\'s fastest mobile model, 128K context', + minRam: 4, + type: 'text' as const, + org: 'meta-llama', + }, + { + id: 'ggml-org/gemma-3n-E2B-it-GGUF', + name: 'Gemma 3n E2B', + params: 2, + description: 'Google\'s mobile-first with selective activation', + minRam: 4, + type: 'text' as const, + org: 'google', + }, + // --- Text: Medium (6 GB+) --- + { + id: 'bartowski/Llama-3.2-3B-Instruct-GGUF', + name: 'Llama 3.2 3B', + params: 3, + description: 'Best quality-to-size ratio for mobile', + minRam: 6, + type: 'text' as const, + org: 'meta-llama', + }, + { + id: 'ggml-org/SmolLM3-3B-GGUF', + name: 'SmolLM3 3B', + params: 3, + description: 'Strong reasoning & 128K context', + minRam: 6, + type: 'text' as const, + org: 'HuggingFaceTB', + }, + { + id: 'bartowski/microsoft_Phi-4-mini-instruct-GGUF', + name: 'Phi-4 Mini', + params: 3.8, + description: 'Math & reasoning specialist', + minRam: 6, + type: 'text' as const, + org: 'microsoft', + }, + // --- Text: Large (8 GB+) --- + { + id: 'Qwen/Qwen3-8B-GGUF', + name: 'Qwen 3 8B', + params: 8, + description: 'Thinking + non-thinking modes, 100+ languages', + minRam: 8, + type: 'text' as const, + org: 'Qwen', + }, + // --- Vision --- + { + id: 'Qwen/Qwen3-VL-2B-Instruct-GGUF', + name: 'Qwen 3 VL 2B', + params: 2, + description: 'Compact vision-language model with thinking mode', + minRam: 4, + type: 'vision' as const, + org: 'Qwen', + }, + { + id: 'ggml-org/gemma-3n-E4B-it-GGUF', + name: 'Gemma 3n E4B', + params: 4, + description: 'Vision + audio, built for mobile', + minRam: 6, + type: 'vision' as const, + org: 'google', + }, + { + id: 'Qwen/Qwen3-VL-8B-Instruct-GGUF', + name: 'Qwen 3 VL 8B', + params: 8, + description: 'Vision-language model with thinking mode', + minRam: 8, + type: 'vision' as const, + org: 'Qwen', + }, + // --- Code --- + { + id: 'Qwen/Qwen3-Coder-30B-A3B-Instruct-GGUF', + name: 'Qwen 3 Coder A3B', + params: 3, + description: 'MoE coding model, only 3B active params', + minRam: 6, + type: 'code' as const, + org: 'Qwen', + }, +]; + +// Model organization filter options +export const MODEL_ORGS = [ + { key: 'Qwen', label: 'Qwen' }, + { key: 'meta-llama', label: 'Llama' }, + { key: 'google', label: 'Google' }, + { key: 'microsoft', label: 'Microsoft' }, + { key: 'mistralai', label: 'Mistral' }, + { key: 'deepseek-ai', label: 'DeepSeek' }, + { key: 'HuggingFaceTB', label: 'HuggingFace' }, + { key: 'nvidia', label: 'NVIDIA' }, +]; + +// Quantization levels and their properties +export const QUANTIZATION_INFO: Record = { + 'Q2_K': { bitsPerWeight: 2.625, quality: 'Low', description: 'Extreme compression, noticeable quality loss', recommended: false }, + 'Q3_K_S': { bitsPerWeight: 3.4375, quality: 'Low-Medium', description: 'High compression, some quality loss', recommended: false }, + 'Q3_K_M': { bitsPerWeight: 3.4375, quality: 'Medium', description: 'Good compression with acceptable quality', recommended: false }, + 'Q4_0': { bitsPerWeight: 4, quality: 'Medium', description: 'Basic 4-bit quantization', recommended: false }, + 'Q4_K_S': { bitsPerWeight: 4.5, quality: 'Medium-Good', description: 'Good balance of size and quality', recommended: true }, + 'Q4_K_M': { bitsPerWeight: 4.5, quality: 'Good', description: 'Optimal for mobile - best balance', recommended: true }, + 'Q5_K_S': { bitsPerWeight: 5.5, quality: 'Good-High', description: 'Higher quality, larger size', recommended: false }, + 'Q5_K_M': { bitsPerWeight: 5.5, quality: 'High', description: 'Near original quality', recommended: false }, + 'Q6_K': { bitsPerWeight: 6.5, quality: 'Very High', description: 'Minimal quality loss', recommended: false }, + 'Q8_0': { bitsPerWeight: 8, quality: 'Excellent', description: 'Best quality, largest size', recommended: false }, +}; diff --git a/src/navigation/AppNavigator.tsx b/src/navigation/AppNavigator.tsx index 9a7d59a1..42315d5e 100644 --- a/src/navigation/AppNavigator.tsx +++ b/src/navigation/AppNavigator.tsx @@ -9,7 +9,8 @@ import Animated, { withSpring, } from 'react-native-reanimated'; import Icon from 'react-native-vector-icons/Feather'; -import { useTheme } from '../theme'; +import { useTheme, useThemedStyles } from '../theme'; +import type { ThemeColors, ThemeShadows } from '../theme'; import { triggerHaptic } from '../utils/haptics'; import { useAppStore } from '../stores'; import { @@ -134,6 +135,7 @@ const TAB_ICON_MAP: Record = { const TabBarIcon: React.FC<{ name: string; focused: boolean }> = ({ name, focused }) => { const { colors } = useTheme(); + const tabStyles = useThemedStyles(createTabBarStyles); const scale = useSharedValue(focused ? 1.1 : 1); useEffect(() => { @@ -146,7 +148,7 @@ const TabBarIcon: React.FC<{ name: string; focused: boolean }> = ({ name, focuse })); return ( - + = ({ name, focuse color={focused ? colors.primary : colors.textMuted} /> - {focused && ( - - )} + {focused && } ); }; +const createTabBarStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ + iconContainer: { + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, + focusDot: { + position: 'absolute' as const, + top: -6, + width: 4, + height: 4, + borderRadius: 2, + backgroundColor: colors.primary, + }, +}); + // Main Tab Navigator const MainTabs: React.FC = () => { const { colors, shadows } = useTheme(); diff --git a/src/screens/ChatScreen.tsx b/src/screens/ChatScreen.tsx deleted file mode 100644 index 8fcd1c57..00000000 --- a/src/screens/ChatScreen.tsx +++ /dev/null @@ -1,1794 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { - View, - Text, - StyleSheet, - FlatList, - Keyboard, - KeyboardAvoidingView, - Platform, - ActivityIndicator, - TouchableOpacity, - Modal, - Image, - Dimensions, - PermissionsAndroid, - InteractionManager, -} from 'react-native'; -import Icon from 'react-native-vector-icons/Feather'; -import RNFS from 'react-native-fs'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import Animated, { FadeIn } from 'react-native-reanimated'; -import { - ChatMessage, - ChatInput, - ModelSelectorModal, - GenerationSettingsModal, - CustomAlert, - AlertState, - initialAlertState, - showAlert, - hideAlert, - ProjectSelectorSheet, - DebugSheet, -} from '../components'; -import { AnimatedEntry } from '../components/AnimatedEntry'; -import { AnimatedPressable } from '../components/AnimatedPressable'; -import { useTheme, useThemedStyles } from '../theme'; -import type { ThemeColors, ThemeShadows } from '../theme'; -import { APP_CONFIG, SPACING, TYPOGRAPHY } from '../constants'; -import { useAppStore, useChatStore, useProjectStore } from '../stores'; -import { llmService, modelManager, intentClassifier, activeModelService, generationService, imageGenerationService, ImageGenerationState, onnxImageGeneratorService, hardwareService, QueuedMessage } from '../services'; -import { Message, MediaAttachment, Project, DownloadedModel, ImageModeState, DebugInfo } from '../types'; -import { ChatsStackParamList } from '../navigation/types'; - -type ChatScreenRouteProp = RouteProp; - -export const ChatScreen: React.FC = () => { - const flatListRef = useRef(null); - const isNearBottomRef = useRef(true); - const contentHeightRef = useRef(0); - const scrollViewHeightRef = useRef(0); - const [isModelLoading, setIsModelLoading] = useState(false); - const [loadingModel, setLoadingModel] = useState(null); - const [supportsVision, setSupportsVision] = useState(false); - const [showProjectSelector, setShowProjectSelector] = useState(false); - const [showDebugPanel, setShowDebugPanel] = useState(false); - const [showModelSelector, setShowModelSelector] = useState(false); - const [showSettingsPanel, setShowSettingsPanel] = useState(false); - const [debugInfo, setDebugInfo] = useState(null); - const [alertState, setAlertState] = useState(initialAlertState); - const [showScrollToBottom, setShowScrollToBottom] = useState(false); - const [isClassifying, setIsClassifying] = useState(false); - // Message entry animation gating — only animate newly arriving messages - const lastMessageCountRef = useRef(0); - const [animateLastN, setAnimateLastN] = useState(0); - // Track which conversation a generation was started for - const generatingForConversationRef = useRef(null); - // Track model load start time for system messages - const modelLoadStartTimeRef = useRef(null); - const navigation = useNavigation(); - const route = useRoute(); - - const { colors } = useTheme(); - const styles = useThemedStyles(createStyles); - - const { - activeModelId, - downloadedModels, - settings, - setActiveModelId: _setActiveModelId, - activeImageModelId, - downloadedImageModels, - setDownloadedImageModels, - setIsGeneratingImage: setAppIsGeneratingImage, - setImageGenerationStatus: setAppImageGenerationStatus, - removeImagesByConversationId, - } = useAppStore(); - - // Subscribe to image generation service (lifecycle-independent) - const [imageGenState, setImageGenState] = useState( - imageGenerationService.getState() - ); - - useEffect(() => { - const unsubscribe = imageGenerationService.subscribe((state) => { - setImageGenState(state); - }); - return unsubscribe; - }, []); - - // Subscribe to generation service for queue state - useEffect(() => { - const unsubscribe = generationService.subscribe((state) => { - setQueueCount(state.queuedMessages.length); - setQueuedTexts(state.queuedMessages.map(m => m.text)); - }); - return unsubscribe; - }, []); - - // Derived state from service for convenience - const isGeneratingImage = imageGenState.isGenerating; - const imageGenerationProgress = imageGenState.progress; - const imageGenerationStatus = imageGenState.status; - const imagePreviewPath = imageGenState.previewPath; - const { - activeConversationId, - conversations, - createConversation, - addMessage, - updateMessage, - deleteMessagesAfter, - streamingMessage, - streamingForConversationId, - isStreaming, - isThinking, - setIsStreaming: _setIsStreaming, - setIsThinking: _setIsThinking, - appendToStreamingMessage: _appendToStreamingMessage, - finalizeStreamingMessage: _finalizeStreamingMessage, - clearStreamingMessage, - deleteConversation, - setActiveConversation, - setConversationProject, - } = useChatStore(); - const { projects, getProject } = useProjectStore(); - - // Refs to always hold the latest versions (avoids dependency churn in useCallback) - const startGenerationRef = useRef<(id: string, text: string) => Promise>(null as any); - const addMessageRef = useRef(addMessage); - addMessageRef.current = addMessage; - - // Queue processor — called by generationService when a queued message should be processed - const handleQueuedSend = useCallback(async (item: QueuedMessage) => { - addMessageRef.current( - item.conversationId, - { - role: 'user', - content: item.text, - }, - item.attachments - ); - await startGenerationRef.current(item.conversationId, item.messageText); - }, []); - - // Register queue processor on mount, clean up on unmount - useEffect(() => { - generationService.setQueueProcessor(handleQueuedSend); - return () => generationService.setQueueProcessor(null); - }, [handleQueuedSend]); - - const activeConversation = conversations.find( - (c) => c.id === activeConversationId - ); - const activeModel = downloadedModels.find((m) => m.id === activeModelId); - const activeProject = activeConversation?.projectId - ? getProject(activeConversation.projectId) - : null; - const activeImageModel = downloadedImageModels.find((m) => m.id === activeImageModelId); - const imageModelLoaded = !!activeImageModel; - - // Queue state from generation service - const [queueCount, setQueueCount] = useState(0); - const [queuedTexts, setQueuedTexts] = useState([]); - - // Track image mode state - const [_currentImageMode, setCurrentImageMode] = useState('auto'); - - // Fullscreen image viewer state - const [viewerImageUri, setViewerImageUri] = useState(null); - - // Count images in this conversation for the gallery button - const conversationImageCount = React.useMemo(() => { - const messages = activeConversation?.messages || []; - let count = 0; - for (const msg of messages) { - if (msg.attachments) { - for (const att of msg.attachments) { - if (att.type === 'image') count++; - } - } - } - return count; - }, [activeConversation?.messages]); - - // Handle route params - set active conversation or create new one - useEffect(() => { - const { conversationId, projectId } = route.params || {}; - - if (conversationId) { - // Navigate to existing conversation - setActiveConversation(conversationId); - } else if (activeModelId) { - // No conversation specified - create a new one - // This handles the "New Chat" button from ChatsListScreen - createConversation(activeModelId, undefined, projectId); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [route.params?.conversationId, route.params?.projectId]); - - // Clear generation ref and KV cache when conversation changes (user switched chats) - useEffect(() => { - // If we switched to a different conversation than what's generating, - // invalidate the generation so tokens don't leak - if (generatingForConversationRef.current && - generatingForConversationRef.current !== activeConversationId) { - generatingForConversationRef.current = null; - } - - // Defer KV cache clear until after animations complete to prevent UI lag - // This helps prevent the slowdown after many messages issue - const task = InteractionManager.runAfterInteractions(() => { - if (llmService.isModelLoaded()) { - llmService.clearKVCache(false).catch(() => { - // Ignore errors - cache clear is best effort - }); - } - }); - - return () => task.cancel(); - }, [activeConversationId]); - - useEffect(() => { - // Ensure model is loaded when entering chat - if (activeModelId && activeModel) { - ensureModelLoaded(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeModelId]); - - // Check vision support when activeModel changes (based on mmProjPath metadata) - // Models with mmProjPath are vision models - verify runtime support after load - useEffect(() => { - if (activeModel?.mmProjPath && llmService.isModelLoaded()) { - const multimodalSupport = llmService.getMultimodalSupport(); - if (multimodalSupport?.vision) { - setSupportsVision(true); - } - } else if (!activeModel?.mmProjPath) { - // Model doesn't have vision projector - no vision support - setSupportsVision(false); - } - }, [activeModel?.mmProjPath]); - - // Load image models on mount - defer to avoid blocking navigation - useEffect(() => { - const task = InteractionManager.runAfterInteractions(async () => { - const models = await modelManager.getDownloadedImageModels(); - setDownloadedImageModels(models); - }); - return () => task.cancel(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Preload classifier model when LLM classification is enabled with a specific model - useEffect(() => { - const preloadClassifierModel = async () => { - // Only preload if: - // 1. Auto mode with LLM detection - // 2. A specific classifier model is selected - // 3. An image model is available (so classification will be used) - // 4. Performance mode is enabled (keep models loaded) - if ( - settings.imageGenerationMode === 'auto' && - settings.autoDetectMethod === 'llm' && - settings.classifierModelId && - activeImageModelId && - settings.modelLoadingStrategy === 'performance' - ) { - const classifierModel = downloadedModels.find(m => m.id === settings.classifierModelId); - if (classifierModel && classifierModel.filePath) { - const currentPath = llmService.getLoadedModelPath(); - // Don't preload if the main model is different and already loaded - // (we don't want to replace the user's selected model) - // Only preload if no model is loaded yet - if (!currentPath) { - console.log('[ChatScreen] Preloading classifier model:', classifierModel.name); - try { - // Use activeModelService singleton - await activeModelService.loadTextModel(settings.classifierModelId); - } catch (error) { - console.warn('[ChatScreen] Failed to preload classifier model:', error); - } - } - } - } - }; - preloadClassifierModel(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [settings.imageGenerationMode, settings.autoDetectMethod, settings.classifierModelId, activeImageModelId, settings.modelLoadingStrategy]); - - useEffect(() => { - // Scroll to bottom when new messages arrive, but only if user is already near bottom - if (activeConversation?.messages.length && isNearBottomRef.current) { - setTimeout(() => { - flatListRef.current?.scrollToEnd({ animated: true }); - }, 100); - } - }, [activeConversation?.messages.length]); - - // Handle scroll position tracking - const handleScroll = (event: any) => { - const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; - const distanceFromBottom = contentSize.height - layoutMeasurement.height - contentOffset.y; - const nearBottom = distanceFromBottom < 100; - isNearBottomRef.current = nearBottom; - setShowScrollToBottom(!nearBottom); - }; - - const handleContentSizeChange = (width: number, height: number) => { - contentHeightRef.current = height; - // Only auto-scroll if user is near bottom - if (isNearBottomRef.current) { - flatListRef.current?.scrollToEnd({ animated: false }); - } - }; - - const handleLayout = (event: any) => { - scrollViewHeightRef.current = event.nativeEvent.layout.height; - }; - - // Helper to add system message to current conversation - const addSystemMessage = (content: string) => { - if (!activeConversationId || !settings.showGenerationDetails) return; - addMessage(activeConversationId, { - role: 'assistant', - content: `_${content}_`, - isSystemInfo: true, - }); - }; - - const ensureModelLoaded = async () => { - if (!activeModel || !activeModelId) return; - - const loadedPath = llmService.getLoadedModelPath(); - const currentVisionSupport = llmService.getMultimodalSupport()?.vision || false; - - // Check if we need to reload: different model OR vision model loaded without mmproj - const needsReload = loadedPath !== activeModel.filePath || - (activeModel.mmProjPath && !currentVisionSupport); - - if (!needsReload && loadedPath === activeModel.filePath) { - // Already loaded correctly - setSupportsVision(currentVisionSupport); - return; - } - - // Check if model is already being loaded by activeModelService (e.g., from HomeScreen) - const modelInfo = activeModelService.getActiveModels(); - const alreadyLoading = modelInfo.text.isLoading; - - // Check memory before loading (only if we're initiating the load) - if (!alreadyLoading) { - const memoryCheck = await activeModelService.checkMemoryForModel(activeModelId, 'text'); - - if (!memoryCheck.canLoad) { - // Critical: Not enough memory - setAlertState(showAlert( - 'Insufficient Memory', - `Cannot load ${activeModel.name}. ${memoryCheck.message}\n\nTry unloading other models from the Home screen.` - )); - return; - } - - // For warnings, add a system message but proceed with loading - if (memoryCheck.severity === 'warning' && settings.showGenerationDetails) { - // Will add warning message after load attempt - } - } - - // Only show our own loading indicator if we're the one starting the load - if (!alreadyLoading) { - setIsModelLoading(true); - setLoadingModel(activeModel); - modelLoadStartTimeRef.current = Date.now(); - - // Give UI time to render the full-screen loading state before heavy native operation - // Use a longer delay to ensure React has time to complete the re-render - await new Promise(resolve => requestAnimationFrame(() => { - requestAnimationFrame(() => { - setTimeout(() => resolve(), 200); // Increased from 50ms to allow full render - }); - })); - } - - try { - // Use activeModelService singleton - prevents duplicate loads - // If already loading, this will wait for the existing load to complete - await activeModelService.loadTextModel(activeModelId); - const multimodalSupport = llmService.getMultimodalSupport(); - setSupportsVision(multimodalSupport?.vision || false); - - // Add system message about model loading (if we did the load and details are enabled) - if (!alreadyLoading && modelLoadStartTimeRef.current && settings.showGenerationDetails) { - const loadTime = ((Date.now() - modelLoadStartTimeRef.current) / 1000).toFixed(1); - addSystemMessage(`Model loaded: ${activeModel.name} (${loadTime}s)`); - } - } catch (error: any) { - // Only show error if we were the one doing the load - if (!alreadyLoading) { - setAlertState(showAlert('Error', `Failed to load model: ${error?.message || 'Unknown error'}`)); - } - } finally { - if (!alreadyLoading) { - setIsModelLoading(false); - setLoadingModel(null); - modelLoadStartTimeRef.current = null; - } - } - }; - - const handleModelSelect = async (model: DownloadedModel) => { - // If already loaded, just close - if (llmService.getLoadedModelPath() === model.filePath) { - setShowModelSelector(false); - return; - } - - // Check memory before loading - const memoryCheck = await activeModelService.checkMemoryForModel(model.id, 'text'); - - if (!memoryCheck.canLoad) { - // Critical: Not enough memory, don't allow loading - setAlertState(showAlert('Insufficient Memory', memoryCheck.message)); - return; - } - - if (memoryCheck.severity === 'warning') { - // Warning: Ask user to confirm - setAlertState(showAlert( - 'Low Memory Warning', - memoryCheck.message, - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Load Anyway', - style: 'default', - onPress: () => { - setAlertState(hideAlert()); - proceedWithModelLoad(model); - }, - }, - ] - )); - return; - } - - // Safe to load - proceedWithModelLoad(model); - }; - - const proceedWithModelLoad = async (model: DownloadedModel) => { - setIsModelLoading(true); - setLoadingModel(model); - modelLoadStartTimeRef.current = Date.now(); - - // Give UI time to render the full-screen loading state before heavy native operation - await new Promise(resolve => requestAnimationFrame(() => { - requestAnimationFrame(() => { - setTimeout(() => resolve(), 200); - }); - })); - - try { - // Use activeModelService singleton - prevents duplicate loads - await activeModelService.loadTextModel(model.id); - // Check vision support after loading - const multimodalSupport = llmService.getMultimodalSupport(); - setSupportsVision(multimodalSupport?.vision || false); - - // Add system message about model loading - if (modelLoadStartTimeRef.current && settings.showGenerationDetails) { - const loadTime = ((Date.now() - modelLoadStartTimeRef.current) / 1000).toFixed(1); - // We need to add to the conversation after it might be created - const convId = activeConversationId || createConversation(model.id); - if (convId) { - addMessage(convId, { - role: 'assistant', - content: `_Model loaded: ${model.name} (${loadTime}s)_`, - isSystemInfo: true, - }); - } - } else if (!activeConversationId) { - // Create a new conversation if none exists - createConversation(model.id); - } - } catch (error) { - setAlertState(showAlert('Error', `Failed to load model: ${(error as Error).message}`)); - } finally { - setIsModelLoading(false); - setLoadingModel(null); - setShowModelSelector(false); - modelLoadStartTimeRef.current = null; - } - }; - - const handleUnloadModel = async () => { - // Stop any ongoing generation first - if (isStreaming) { - await llmService.stopGeneration(); - clearStreamingMessage(); - } - - const modelName = activeModel?.name; - setIsModelLoading(true); - setLoadingModel(activeModel ?? null); - try { - await activeModelService.unloadTextModel(); - setSupportsVision(false); - - // Add system message about model unloading - if (settings.showGenerationDetails && modelName) { - addSystemMessage(`Model unloaded: ${modelName}`); - } - } catch (error) { - setAlertState(showAlert('Error', `Failed to unload model: ${(error as Error).message}`)); - } finally { - setIsModelLoading(false); - setLoadingModel(null); - setShowModelSelector(false); - } - }; - - // Determine if message should be routed to image generation - const shouldRouteToImageGeneration = async (text: string, forceImageMode?: boolean): Promise => { - // If already generating image, don't start another one - route to text - if (isGeneratingImage) { - return false; - } - - // Manual mode: only generate image when explicitly forced - if (settings.imageGenerationMode === 'manual') { - return forceImageMode === true; - } - - // Force mode: always generate image - if (forceImageMode) { - return true; - } - - // Auto mode: use intent classifier - if (!imageModelLoaded) { - return false; - } - - try { - // Use LLM for classification only if autoDetectMethod is 'llm' - const useLLM = settings.autoDetectMethod === 'llm'; - const classifierModel = settings.classifierModelId - ? downloadedModels.find(m => m.id === settings.classifierModelId) - : null; - - // Show classifying indicator for LLM-based classification - if (useLLM) { - setIsClassifying(true); - } - - const intent = await intentClassifier.classifyIntent(text, { - useLLM, - classifierModel, - currentModelPath: llmService.getLoadedModelPath(), - onStatusChange: useLLM ? setAppImageGenerationStatus : undefined, - modelLoadingStrategy: settings.modelLoadingStrategy, - }); - - setIsClassifying(false); - - // Clear status if not generating image - if (intent !== 'image' && useLLM) { - setAppImageGenerationStatus(null); - setAppIsGeneratingImage(false); - } - - return intent === 'image'; - } catch (error) { - console.warn('[ChatScreen] Intent classification failed:', error); - setIsClassifying(false); - setAppImageGenerationStatus(null); - setAppIsGeneratingImage(false); - return false; - } - }; - - // Handle image generation - delegates to lifecycle-independent service - const handleImageGeneration = async (prompt: string, conversationId: string, skipUserMessage = false) => { - if (!activeImageModel) { - setAlertState(showAlert('Error', 'No image model loaded.')); - return; - } - - // Add user message (skip when generating from existing message via long-press) - if (!skipUserMessage) { - addMessage( - conversationId, - { - role: 'user', - content: prompt, - } - ); - } - - // Delegate to service - this survives navigation - const result = await imageGenerationService.generateImage({ - prompt, - conversationId, - steps: settings.imageSteps || 8, - guidanceScale: settings.imageGuidanceScale || 2.0, - previewInterval: 2, - }); - - // Reset image mode after completion - setCurrentImageMode('auto'); - - // Show error if generation failed (and wasn't cancelled) - if (!result && imageGenState.error && !imageGenState.error.includes('cancelled')) { - setAlertState(showAlert('Error', `Image generation failed: ${imageGenState.error}`)); - } - }; - - const handleSend = async (text: string, attachments?: MediaAttachment[], forceImageMode?: boolean) => { - if (!activeConversationId || !activeModel) { - setAlertState(showAlert('No Model Selected', 'Please select a model first.')); - return; - } - - // Capture the conversation ID at the start - this won't change even if user switches chats - const targetConversationId = activeConversationId; - - // Append document content to the message text - let messageText = text; - if (attachments) { - const documentAttachments = attachments.filter(a => a.type === 'document' && a.textContent); - for (const doc of documentAttachments) { - const fileName = doc.fileName || 'document'; - messageText += `\n\n---\n📄 **Attached Document: ${fileName}**\n\`\`\`\n${doc.textContent}\n\`\`\`\n---`; - } - } - - // Check if this should be routed to image generation - const shouldGenerateImage = await shouldRouteToImageGeneration(messageText, forceImageMode); - - if (shouldGenerateImage && activeImageModel) { - // Image generation bypasses the queue — goes immediately - await handleImageGeneration(text, targetConversationId); - return; - } - - // If image was requested but no model loaded, add a note - if (shouldGenerateImage && !activeImageModel) { - // Continue with text response but mention image capability - messageText = `[User wanted an image but no image model is loaded] ${messageText}`; - } - - // If currently generating, enqueue (message added to chat later when processed) - if (generationService.getState().isGenerating) { - generationService.enqueueMessage({ - id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, - conversationId: targetConversationId, - text, - attachments, - messageText, - }); - return; - } - - // Add user message to chat - addMessage( - targetConversationId, - { - role: 'user', - content: text, - }, - attachments - ); - - // Proceed with generation - await startGeneration(targetConversationId, messageText); - }; - - /** - * Start generation for a conversation. Builds context from current conversation - * state and calls generationService.generateResponse. - */ - const startGeneration = async (targetConversationId: string, messageText: string) => { - if (!activeModel) return; - - generatingForConversationRef.current = targetConversationId; - - // Ensure the correct model is loaded (not just any model) - const currentLoadedPath = llmService.getLoadedModelPath(); - const needsModelLoad = !currentLoadedPath || currentLoadedPath !== activeModel.filePath; - - if (needsModelLoad) { - await ensureModelLoaded(); - if (!llmService.isModelLoaded() || llmService.getLoadedModelPath() !== activeModel.filePath) { - setAlertState(showAlert('Error', 'Failed to load model. Please try again.')); - generatingForConversationRef.current = null; - return; - } - } - - // Rebuild context from current conversation state (messages may have changed since enqueue) - const conversation = useChatStore.getState().conversations.find(c => c.id === targetConversationId); - const conversationMessages = conversation?.messages || []; - - // Use project system prompt if available, otherwise use default - const project = conversation?.projectId - ? useProjectStore.getState().getProject(conversation.projectId) - : null; - const systemPrompt = project?.systemPrompt - || settings.systemPrompt - || APP_CONFIG.defaultSystemPrompt; - - // Find the last user message to create context version with document content - const lastUserMsg = conversationMessages[conversationMessages.length - 1]; - const userMessageForContext: Message = lastUserMsg?.role === 'user' - ? { ...lastUserMsg, content: messageText } - : lastUserMsg; - - const messagesForContext: Message[] = [ - { - id: 'system', - role: 'system', - content: systemPrompt, - timestamp: 0, - }, - // All messages except the last (which we replace with the context version) - ...conversationMessages.slice(0, -1), - userMessageForContext, - ]; - - // Update debug info and check if truncation occurred - let shouldClearCache = false; - try { - const contextDebug = await llmService.getContextDebugInfo(messagesForContext); - setDebugInfo({ - systemPrompt, - ...contextDebug, - }); - - if (contextDebug.truncatedCount > 0 || contextDebug.contextUsagePercent > 70) { - shouldClearCache = true; - } - } catch (e) { - console.log('Debug info error:', e); - } - - if (shouldClearCache) { - await llmService.clearKVCache(false).catch(() => { }); - } - - // Use generationService for background-safe generation - try { - await generationService.generateResponse( - targetConversationId, - messagesForContext, - () => { - console.log('[ChatScreen] First token received for conversation:', targetConversationId); - } - ); - } catch (error: any) { - setAlertState(showAlert('Generation Error', error.message || 'Failed to generate response')); - } - generatingForConversationRef.current = null; - }; - startGenerationRef.current = startGeneration; - - const handleStop = async () => { - console.log('[ChatScreen] handleStop called'); - generatingForConversationRef.current = null; - - // Stop text generation - call both services to ensure it stops - // generationService.stopGeneration() calls llmService.stopGeneration() internally, - // but we also call it directly as a fallback - try { - await Promise.all([ - generationService.stopGeneration().catch(() => { }), - llmService.stopGeneration().catch(() => { }), - ]); - } catch (_e) { - // Ignore errors - generation may have already finished - } - - // Stop image generation if in progress - if (isGeneratingImage) { - imageGenerationService.cancelGeneration().catch(() => { }); - } - }; - - const handleDeleteConversation = () => { - if (!activeConversationId || !activeConversation) return; - - setAlertState(showAlert( - 'Delete Conversation', - 'Are you sure you want to delete this conversation? This will also delete all images generated in this chat.', - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', - style: 'destructive', - onPress: async () => { - setAlertState(hideAlert()); - // Stop any ongoing generation first - if (isStreaming) { - await llmService.stopGeneration(); - clearStreamingMessage(); - } - // Delete associated images from disk and store - const imageIds = removeImagesByConversationId(activeConversationId); - for (const imageId of imageIds) { - await onnxImageGeneratorService.deleteGeneratedImage(imageId); - } - deleteConversation(activeConversationId); - setActiveConversation(null); - navigation.goBack(); - }, - }, - ] - )); - }; - - const handleCopyMessage = (_content: string) => { - // Copy is handled in ChatMessage component with Alert - }; - - const handleRetryMessage = async (message: Message) => { - if (!activeConversationId || !activeModel) return; - - if (message.role === 'user') { - // Delete all messages after this one and resend - deleteMessagesAfter(activeConversationId, message.id); - // Remove the user message too, then resend - const _content = message.content; - const _attachments = message.attachments; - // Actually we want to keep the message and regenerate the response - // So just delete the assistant responses after - - // Find the next message (should be assistant response) - const messages = activeConversation?.messages || []; - const messageIndex = messages.findIndex((m) => m.id === message.id); - if (messageIndex !== -1 && messageIndex < messages.length - 1) { - // Delete messages after this one - deleteMessagesAfter(activeConversationId, message.id); - } - - // Regenerate response - await regenerateResponse(message); - } else { - // For assistant messages, find the previous user message and regenerate - const messages = activeConversation?.messages || []; - const messageIndex = messages.findIndex((m) => m.id === message.id); - if (messageIndex > 0) { - const previousUserMessage = messages.slice(0, messageIndex).reverse() - .find((m) => m.role === 'user'); - if (previousUserMessage) { - // Delete this assistant message and any after it - const _prevIndex = messages.findIndex((m) => m.id === previousUserMessage.id); - deleteMessagesAfter(activeConversationId, previousUserMessage.id); - await regenerateResponse(previousUserMessage); - } - } - } - }; - - const regenerateResponse = async (userMessage: Message) => { - if (!activeConversationId || !activeModel) return; - - // Capture the conversation ID at the start - const targetConversationId = activeConversationId; - - // Check if this should be routed to image generation - const shouldGenerateImage = await shouldRouteToImageGeneration(userMessage.content); - - if (shouldGenerateImage && activeImageModel) { - await handleImageGeneration(userMessage.content, targetConversationId, true); - return; - } - - // Continue with text generation - if (!llmService.isModelLoaded()) return; - - generatingForConversationRef.current = targetConversationId; - - const messages = activeConversation?.messages || []; - const messageIndex = messages.findIndex((m) => m.id === userMessage.id); - const messagesUpToUser = messages.slice(0, messageIndex + 1); - - // Use project system prompt if available, otherwise use default - const systemPrompt = activeProject?.systemPrompt - || settings.systemPrompt - || APP_CONFIG.defaultSystemPrompt; - - const messagesForContext: Message[] = [ - { - id: 'system', - role: 'system', - content: systemPrompt, - timestamp: 0, - }, - ...messagesUpToUser, - ]; - - // Use generationService for background-safe generation - try { - await generationService.generateResponse( - targetConversationId, - messagesForContext - ); - } catch (error: any) { - setAlertState(showAlert('Generation Error', error.message || 'Failed to generate response')); - } - generatingForConversationRef.current = null; - }; - - const handleEditMessage = async (message: Message, newContent: string) => { - if (!activeConversationId || !activeModel) return; - - // Update the message content - updateMessage(activeConversationId, message.id, newContent); - - // Delete all messages after this one - deleteMessagesAfter(activeConversationId, message.id); - - // Create updated message object for regeneration - const updatedMessage: Message = { ...message, content: newContent }; - - // Regenerate response with new content - await regenerateResponse(updatedMessage); - }; - - const handleSelectProject = (project: Project | null) => { - if (activeConversationId) { - setConversationProject(activeConversationId, project?.id || null); - } - setShowProjectSelector(false); - }; - - const handleGenerateImageFromMessage = async (prompt: string) => { - if (!activeConversationId || !activeImageModel) { - setAlertState(showAlert('No Image Model', 'Please load an image model first from the Models screen.')); - return; - } - - // Skip adding user message since we're generating from an existing message - await handleImageGeneration(prompt, activeConversationId, true); - }; - - // Handle image tap to show fullscreen viewer - const handleImagePress = (uri: string) => { - setViewerImageUri(uri); - }; - - // Save image to device gallery/downloads - const handleSaveImage = async () => { - if (!viewerImageUri) return; - - try { - // Request permission on Android - if (Platform.OS === 'android') { - const _granted = await PermissionsAndroid.request( - PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, - { - title: 'Storage Permission', - message: 'App needs access to save images', - buttonNeutral: 'Ask Later', - buttonNegative: 'Cancel', - buttonPositive: 'OK', - } - ); - // Continue anyway on Android 10+ (scoped storage) - } - - // Get the source path (remove file:// prefix if present) - const sourcePath = viewerImageUri.replace('file://', ''); - - // Create destination path in Pictures/OffgridMobile folder - const picturesDir = Platform.OS === 'android' - ? `${RNFS.ExternalStorageDirectoryPath}/Pictures/OffgridMobile` - : `${RNFS.DocumentDirectoryPath}/OffgridMobile_Images`; - - // Create directory if it doesn't exist - if (!(await RNFS.exists(picturesDir))) { - await RNFS.mkdir(picturesDir); - } - - // Generate filename with timestamp - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const fileName = `generated_${timestamp}.png`; - const destPath = `${picturesDir}/${fileName}`; - - // Copy the file - await RNFS.copyFile(sourcePath, destPath); - - setAlertState(showAlert( - 'Image Saved', - Platform.OS === 'android' - ? `Saved to Pictures/OffgridMobile/${fileName}` - : `Saved to ${fileName}` - )); - } catch (error: any) { - console.error('[ChatScreen] Failed to save image:', error); - setAlertState(showAlert('Error', `Failed to save image: ${error?.message || 'Unknown error'}`)); - } - }; - - const renderMessage = ({ item, index }: { item: Message; index: number }) => ( - 0 && index >= displayMessages.length - animateLastN} - /> - ); - - // Create streaming/thinking message object for display - // Only show if the streaming is for the current conversation - const allMessages = activeConversation?.messages || []; - const isStreamingForThisConversation = streamingForConversationId === activeConversationId; - const displayMessages = isThinking && isStreamingForThisConversation - ? [ - ...allMessages, - { - id: 'thinking', - role: 'assistant' as const, - content: '', - timestamp: Date.now(), - isThinking: true, - }, - ] - : streamingMessage && isStreamingForThisConversation - ? [ - ...allMessages, - { - id: 'streaming', - role: 'assistant' as const, - content: streamingMessage, - timestamp: Date.now(), - isStreaming: true, - }, - ] - : allMessages; - - // Track new messages for entry animation - useEffect(() => { - const prev = lastMessageCountRef.current; - const curr = displayMessages.length; - if (curr > prev && prev > 0) { - setAnimateLastN(curr - prev); - } - lastMessageCountRef.current = curr; - }, [displayMessages.length]); - - // Reset animation count on conversation switch - useEffect(() => { - lastMessageCountRef.current = 0; - setAnimateLastN(0); - }, [activeConversationId]); - - if (!activeModelId || !activeModel) { - return ( - - - - - - No Model Selected - - {downloadedModels.length > 0 - ? 'Select a model to start chatting.' - : 'Download a model from the Models tab to start chatting.'} - - {downloadedModels.length > 0 && ( - setShowModelSelector(true)} - > - Select Model - - )} - - - {/* Model Selector Modal - available even when no model selected */} - setShowModelSelector(false)} - onSelectModel={handleModelSelect} - onUnloadModel={handleUnloadModel} - isLoading={isModelLoading} - currentModelPath={llmService.getLoadedModelPath()} - /> - - ); - } - - if (isModelLoading) { - const loadingModelName = loadingModel?.name || activeModel?.name || 'model'; - const modelSize = loadingModel ? hardwareService.formatModelSize(loadingModel) : activeModel ? hardwareService.formatModelSize(activeModel) : ''; - return ( - - - - Loading {loadingModelName} - {modelSize ? ( - {modelSize} - ) : null} - - Preparing model for inference. This may take a moment for larger models. - - {(loadingModel?.mmProjPath || activeModel?.mmProjPath) && ( - - Vision capabilities will be enabled. - - )} - - - ); - } - - return ( - - - {/* Header */} - - - navigation.goBack()} - > - - - - - {activeConversation?.title || 'New Chat'} - - setShowModelSelector(true)} - testID="model-selector" - > - - {activeModel.name} - - {activeImageModel && ( - - - - )} - - - - - setShowSettingsPanel(true)} - testID="chat-settings-icon" - > - - - - - - - {/* Messages */} - {displayMessages.length === 0 ? ( - - - - - - - - Start a Conversation - - - - Type a message below to begin chatting with {activeModel.name}. - - - - setShowProjectSelector(true)} - > - - - {activeProject?.name?.charAt(0).toUpperCase() || 'D'} - - - - Project: {activeProject?.name || 'Default'} — tap to change - - - - - - This conversation is completely private. All processing - happens on your device. - - - - ) : ( - item.id} - contentContainerStyle={styles.messageList} - onScroll={handleScroll} - onContentSizeChange={handleContentSizeChange} - onLayout={handleLayout} - scrollEventThrottle={16} - keyboardDismissMode="on-drag" - keyboardShouldPersistTaps="handled" - onTouchStart={() => Keyboard.dismiss()} - maintainVisibleContentPosition={{ - minIndexForVisible: 0, - autoscrollToTopThreshold: 100, - }} - /> - )} - - {/* Scroll-to-bottom button */} - {showScrollToBottom && displayMessages.length > 0 && ( - - flatListRef.current?.scrollToEnd({ animated: true })} - > - - - - )} - - {/* Image generation progress indicator with preview */} - {isGeneratingImage && ( - - - - {/* Preview image - small thumbnail */} - {imagePreviewPath && ( - - )} - - - - - - - - {imagePreviewPath ? 'Refining Image' : 'Generating Image'} - - {imageGenerationStatus && ( - - {imageGenerationStatus} - - )} - - {imageGenerationProgress && ( - - {imageGenerationProgress.step}/{imageGenerationProgress.totalSteps} - - )} - - - - - {imageGenerationProgress && ( - - - - - - )} - - - - - )} - - {/* Intent classification indicator */} - {isClassifying && ( - - - Understanding your request... - - )} - - {/* Input */} - setShowSettingsPanel(true)} - queueCount={queueCount} - queuedTexts={queuedTexts} - onClearQueue={() => generationService.clearQueue()} - placeholder={ - llmService.isModelLoaded() - ? supportsVision - ? 'Type a message or add an image...' - : 'Type a message...' - : 'Loading model...' - } - /> - - {/* Project Selector Sheet */} - setShowProjectSelector(false)} - projects={projects} - activeProject={activeProject || null} - onSelectProject={handleSelectProject} - /> - - {/* Debug Sheet */} - setShowDebugPanel(false)} - debugInfo={debugInfo} - activeProject={activeProject || null} - settings={settings} - activeConversation={activeConversation || null} - /> - - {/* Model Selector Modal */} - setShowModelSelector(false)} - onSelectModel={handleModelSelect} - onUnloadModel={handleUnloadModel} - isLoading={isModelLoading} - currentModelPath={llmService.getLoadedModelPath()} - /> - - {/* Generation Settings Modal */} - setShowSettingsPanel(false)} - onOpenProject={() => setShowProjectSelector(true)} - onOpenGallery={conversationImageCount > 0 ? () => (navigation as any).navigate('Gallery', { conversationId: activeConversationId }) : undefined} - onDeleteConversation={activeConversation ? handleDeleteConversation : undefined} - conversationImageCount={conversationImageCount} - activeProjectName={activeProject?.name || null} - /> - - {/* Fullscreen Image Viewer Modal */} - setViewerImageUri(null)} - > - - setViewerImageUri(null)} - /> - {viewerImageUri && ( - - - - - - Save - - setViewerImageUri(null)} - > - - Close - - - - )} - - - - {/* Custom Alert Modal */} - setAlertState(hideAlert())} - /> - - - ); -}; - -const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ - container: { - flex: 1, - backgroundColor: colors.background, - }, - keyboardView: { - flex: 1, - }, - header: { - paddingHorizontal: 16, - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: colors.border, - backgroundColor: colors.background, - zIndex: 10, - }, - headerRow: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - justifyContent: 'space-between' as const, - gap: SPACING.md, - }, - backButton: { - padding: SPACING.xs, - }, - headerLeft: { - flex: 1, - marginRight: 12, - }, - headerTitle: { - ...TYPOGRAPHY.h2, - color: colors.text, - marginBottom: 2, - }, - headerSubtitle: { - ...TYPOGRAPHY.h3, - color: colors.textMuted, - }, - modelSelector: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - }, - modelSelectorArrow: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - marginLeft: SPACING.xs, - }, - headerImageBadge: { - width: 18, - height: 18, - borderRadius: 9, - backgroundColor: colors.primary + '20', - alignItems: 'center' as const, - justifyContent: 'center' as const, - marginLeft: 6, - }, - headerActions: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - justifyContent: 'center' as const, - gap: 4, - }, - iconButton: { - width: 30, - height: 30, - borderRadius: 8, - backgroundColor: colors.surface, - alignItems: 'center' as const, - justifyContent: 'center' as const, - }, - messageList: { - paddingVertical: 16, - }, - scrollToBottomContainer: { - position: 'absolute' as const, - bottom: 130, - right: 16, - zIndex: 10, - }, - scrollToBottomButton: { - width: 36, - height: 36, - borderRadius: 18, - backgroundColor: colors.surface, - borderWidth: 1, - borderColor: colors.border, - alignItems: 'center' as const, - justifyContent: 'center' as const, - }, - emptyChat: { - flex: 1, - justifyContent: 'center' as const, - alignItems: 'center' as const, - paddingHorizontal: 32, - }, - emptyChatIconContainer: { - width: 80, - height: 80, - borderRadius: 8, - borderWidth: 1, - borderColor: colors.border, - backgroundColor: colors.surface, - alignItems: 'center' as const, - justifyContent: 'center' as const, - marginBottom: SPACING.lg, - }, - emptyChatTitle: { - ...TYPOGRAPHY.h2, - color: colors.text, - marginBottom: SPACING.sm, - }, - emptyChatText: { - ...TYPOGRAPHY.body, - color: colors.textSecondary, - textAlign: 'center' as const, - marginBottom: SPACING.xl, - }, - projectHint: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - backgroundColor: colors.surface, - paddingHorizontal: SPACING.lg, - paddingVertical: SPACING.sm, - borderRadius: 8, - marginBottom: SPACING.lg, - gap: SPACING.sm, - }, - projectHintIcon: { - width: 24, - height: 24, - borderRadius: 6, - backgroundColor: colors.primary + '30', - alignItems: 'center' as const, - justifyContent: 'center' as const, - }, - projectHintIconText: { - ...TYPOGRAPHY.bodySmall, - fontWeight: '600' as const, - color: colors.primary, - }, - projectHintText: { - ...TYPOGRAPHY.h3, - color: colors.primary, - fontWeight: '500' as const, - }, - privacyText: { - ...TYPOGRAPHY.h3, - color: colors.textMuted, - textAlign: 'center' as const, - maxWidth: 300, - }, - loadingContainer: { - flex: 1, - justifyContent: 'center' as const, - alignItems: 'center' as const, - gap: 16, - paddingHorizontal: 24, - }, - loadingText: { - ...TYPOGRAPHY.h1, - fontSize: 18, - fontWeight: '600' as const, - textAlign: 'center' as const, - color: colors.text, - }, - loadingSubtext: { - ...TYPOGRAPHY.body, - color: colors.textSecondary, - }, - loadingHint: { - ...TYPOGRAPHY.bodySmall, - color: colors.textMuted, - marginTop: SPACING.lg, - textAlign: 'center' as const, - paddingHorizontal: 32, - }, - noModelContainer: { - flex: 1, - justifyContent: 'center' as const, - alignItems: 'center' as const, - paddingHorizontal: SPACING.xxl, - }, - noModelIconContainer: { - width: 80, - height: 80, - borderRadius: 8, - borderWidth: 1, - borderColor: colors.border, - backgroundColor: colors.surface, - alignItems: 'center' as const, - justifyContent: 'center' as const, - marginBottom: SPACING.lg, - }, - noModelTitle: { - ...TYPOGRAPHY.h2, - color: colors.text, - marginBottom: SPACING.sm, - }, - noModelText: { - ...TYPOGRAPHY.body, - color: colors.textSecondary, - textAlign: 'center' as const, - }, - selectModelButton: { - marginTop: SPACING.xl, - backgroundColor: 'transparent', - borderWidth: 1, - borderColor: colors.primary, - paddingHorizontal: SPACING.xl, - paddingVertical: SPACING.md, - borderRadius: 8, - }, - selectModelButtonText: { - ...TYPOGRAPHY.body, - color: colors.primary, - }, - classifyingBar: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - gap: 8, - paddingHorizontal: 16, - paddingVertical: 8, - borderTopWidth: 1, - borderTopColor: colors.border, - backgroundColor: colors.background, - }, - classifyingText: { - ...TYPOGRAPHY.meta, - color: colors.textSecondary, - }, - imageProgressContainer: { - paddingHorizontal: 12, - paddingTop: 8, - paddingBottom: 4, - borderTopWidth: 1, - borderTopColor: colors.border, - backgroundColor: colors.background, - }, - imageProgressCard: { - backgroundColor: colors.surface, - borderRadius: 12, - padding: 12, - borderWidth: 1, - borderColor: colors.primary + '30', - }, - imageProgressRow: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - }, - imageProgressContent: { - flex: 1, - }, - imageProgressHeader: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - }, - imageProgressIconContainer: { - width: 32, - height: 32, - borderRadius: 8, - backgroundColor: colors.primary + '20', - alignItems: 'center' as const, - justifyContent: 'center' as const, - marginRight: 10, - }, - imageProgressInfo: { - flex: 1, - }, - imageProgressTitle: { - ...TYPOGRAPHY.body, - fontWeight: '600' as const, - color: colors.text, - }, - imageProgressStatus: { - ...TYPOGRAPHY.bodySmall, - color: colors.textSecondary, - fontStyle: 'normal' as const, - }, - imageProgressBarContainer: { - marginTop: 10, - }, - imageProgressBar: { - height: 4, - backgroundColor: colors.surfaceLight, - borderRadius: 2, - overflow: 'hidden' as const, - }, - imageProgressFill: { - height: '100%' as const, - backgroundColor: colors.primary, - borderRadius: 2, - }, - imageProgressSteps: { - ...TYPOGRAPHY.bodySmall, - fontWeight: '600' as const, - color: colors.primary, - marginRight: SPACING.sm, - }, - imagePreview: { - width: 100, - height: 100, - borderRadius: 8, - marginRight: 12, - backgroundColor: colors.surfaceLight, - }, - imageStopButton: { - width: 28, - height: 28, - borderRadius: 14, - backgroundColor: colors.error + '20', - alignItems: 'center' as const, - justifyContent: 'center' as const, - }, - // Fullscreen image viewer styles - imageViewerContainer: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.95)', - justifyContent: 'center' as const, - alignItems: 'center' as const, - }, - imageViewerBackdrop: { - ...StyleSheet.absoluteFillObject, - }, - imageViewerContent: { - width: '100%' as const, - height: '100%' as const, - justifyContent: 'center' as const, - alignItems: 'center' as const, - }, - fullscreenImage: { - width: Dimensions.get('window').width, - height: Dimensions.get('window').height * 0.7, - }, - imageViewerActions: { - flexDirection: 'row' as const, - position: 'absolute' as const, - bottom: 60, - gap: 40, - }, - imageViewerButton: { - alignItems: 'center' as const, - padding: 16, - backgroundColor: colors.surface, - borderRadius: 16, - minWidth: 80, - }, - imageViewerButtonText: { - ...TYPOGRAPHY.bodySmall, - color: colors.text, - marginTop: SPACING.xs, - fontWeight: '500' as const, - }, -}); diff --git a/src/screens/ChatScreen/ChatModalSection.tsx b/src/screens/ChatScreen/ChatModalSection.tsx new file mode 100644 index 00000000..f1fc762d --- /dev/null +++ b/src/screens/ChatScreen/ChatModalSection.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { + ModelSelectorModal, GenerationSettingsModal, + CustomAlert, hideAlert, ProjectSelectorSheet, DebugSheet, +} from '../../components'; +import { llmService } from '../../services'; +import { createStyles } from './styles'; +import { useTheme } from '../../theme'; +import { ImageViewerModal } from './ChatScreenComponents'; + +type StylesType = ReturnType; +type ColorsType = ReturnType['colors']; + +type ChatModalSectionProps = { + styles: StylesType; + colors: ColorsType; + showProjectSelector: boolean; + setShowProjectSelector: (v: boolean) => void; + showDebugPanel: boolean; + setShowDebugPanel: (v: boolean) => void; + showModelSelector: boolean; + setShowModelSelector: (v: boolean) => void; + showSettingsPanel: boolean; + setShowSettingsPanel: (v: boolean) => void; + alertState: any; + setAlertState: (v: any) => void; + debugInfo: any; + activeProject: any; + activeConversation: any; + settings: any; + projects: any[]; + handleSelectProject: (p: any) => void; + handleModelSelect: (m: any) => void; + handleUnloadModel: () => void; + handleDeleteConversation: () => void; + isModelLoading: boolean; + imageCount: number; + activeConversationId: string | null | undefined; + navigation: any; + viewerImageUri: string | null; + setViewerImageUri: (v: string | null) => void; + handleSaveImage: () => void; +}; + +export const ChatModalSection: React.FC = ({ + styles, colors, + showProjectSelector, setShowProjectSelector, + showDebugPanel, setShowDebugPanel, + showModelSelector, setShowModelSelector, + showSettingsPanel, setShowSettingsPanel, + alertState, setAlertState, + debugInfo, activeProject, activeConversation, settings, projects, + handleSelectProject, handleModelSelect, handleUnloadModel, handleDeleteConversation, + isModelLoading, imageCount, activeConversationId, navigation, + viewerImageUri, setViewerImageUri, handleSaveImage, +}) => ( + <> + setShowProjectSelector(false)} + projects={projects} + activeProject={activeProject || null} + onSelectProject={handleSelectProject} + /> + setShowDebugPanel(false)} + debugInfo={debugInfo} + activeProject={activeProject || null} + settings={settings} + activeConversation={activeConversation || null} + /> + setShowModelSelector(false)} + onSelectModel={handleModelSelect} + onUnloadModel={handleUnloadModel} + isLoading={isModelLoading} + currentModelPath={llmService.getLoadedModelPath()} + /> + setShowSettingsPanel(false)} + onOpenProject={() => setShowProjectSelector(true)} + onOpenGallery={imageCount > 0 ? () => (navigation as any).navigate('Gallery', { conversationId: activeConversationId }) : undefined} + onDeleteConversation={activeConversation ? handleDeleteConversation : undefined} + conversationImageCount={imageCount} + activeProjectName={activeProject?.name || null} + /> + setViewerImageUri(null)} + onSave={handleSaveImage} + /> + setAlertState(hideAlert())} + /> + +); diff --git a/src/screens/ChatScreen/ChatScreenComponents.tsx b/src/screens/ChatScreen/ChatScreenComponents.tsx new file mode 100644 index 00000000..bb496fc9 --- /dev/null +++ b/src/screens/ChatScreen/ChatScreenComponents.tsx @@ -0,0 +1,241 @@ +import React from 'react'; +import { + View, + Text, + ActivityIndicator, + TouchableOpacity, + Modal, + Image, +} from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { ModelSelectorModal } from '../../components'; +import { AnimatedEntry } from '../../components/AnimatedEntry'; +import { llmService } from '../../services'; +import { createStyles } from './styles'; +import { useTheme } from '../../theme'; + +type StylesType = ReturnType; +type ColorsType = ReturnType['colors']; + +export const NoModelScreen: React.FC<{ + styles: StylesType; + colors: ColorsType; + downloadedModelsCount: number; + showModelSelector: boolean; + setShowModelSelector: (v: boolean) => void; + onSelectModel: (model: any) => void; + onUnloadModel: () => void; + isModelLoading: boolean; +}> = ({ styles, colors, downloadedModelsCount, showModelSelector, setShowModelSelector, onSelectModel, onUnloadModel, isModelLoading }) => ( + + + + + + No Model Selected + + {downloadedModelsCount > 0 + ? 'Select a model to start chatting.' + : 'Download a model from the Models tab to start chatting.'} + + {downloadedModelsCount > 0 && ( + setShowModelSelector(true)}> + Select Model + + )} + + setShowModelSelector(false)} + onSelectModel={onSelectModel} + onUnloadModel={onUnloadModel} + isLoading={isModelLoading} + currentModelPath={llmService.getLoadedModelPath()} + /> + +); + +export const LoadingScreen: React.FC<{ + styles: StylesType; + colors: ColorsType; + loadingModelName: string; + modelSize: string; + hasVision: boolean; +}> = ({ styles, colors, loadingModelName, modelSize, hasVision }) => ( + + + + Loading {loadingModelName} + {modelSize ? {modelSize} : null} + + Preparing model for inference. This may take a moment for larger models. + + {hasVision && Vision capabilities will be enabled.} + + +); + +export const ChatHeader: React.FC<{ + styles: StylesType; + colors: ColorsType; + activeConversation: any; + activeModel: any; + activeImageModel: any; + navigation: any; + setShowModelSelector: (v: boolean) => void; + setShowSettingsPanel: (v: boolean) => void; +}> = ({ styles, colors, activeConversation, activeModel, activeImageModel, navigation, setShowModelSelector, setShowSettingsPanel }) => ( + + + navigation.goBack()}> + + + + + {activeConversation?.title || 'New Chat'} + + setShowModelSelector(true)} testID="model-selector"> + + {activeModel.name} + + {activeImageModel && ( + + + + )} + + + + + setShowSettingsPanel(true)} testID="chat-settings-icon"> + + + + + +); + +export const EmptyChat: React.FC<{ + styles: StylesType; + colors: ColorsType; + activeModel: any; + activeProject: any; + setShowProjectSelector: (v: boolean) => void; +}> = ({ styles, colors, activeModel, activeProject, setShowProjectSelector }) => ( + + + + + + + + Start a Conversation + + + + Type a message below to begin chatting with {activeModel.name}. + + + + setShowProjectSelector(true)}> + + + {activeProject?.name?.charAt(0).toUpperCase() || 'D'} + + + + Project: {activeProject?.name || 'Default'} — tap to change + + + + + + This conversation is completely private. All processing happens on your device. + + + +); + +export const ImageProgressIndicator: React.FC<{ + styles: StylesType; + colors: ColorsType; + imagePreviewPath: string | null | undefined; + imageGenerationStatus: string | null | undefined; + imageGenerationProgress: { step: number; totalSteps: number } | null | undefined; + onStop: () => void; +}> = ({ styles, colors, imagePreviewPath, imageGenerationStatus, imageGenerationProgress, onStop }) => ( + + + + {imagePreviewPath && ( + + )} + + + + + + + + {imagePreviewPath ? 'Refining Image' : 'Generating Image'} + + {imageGenerationStatus && ( + {imageGenerationStatus} + )} + + {imageGenerationProgress && ( + + {imageGenerationProgress.step}/{imageGenerationProgress.totalSteps} + + )} + + + + + {imageGenerationProgress && ( + + + + + + )} + + + + +); + +export const ImageViewerModal: React.FC<{ + styles: StylesType; + colors: ColorsType; + viewerImageUri: string | null; + onClose: () => void; + onSave: () => void; +}> = ({ styles, colors, viewerImageUri, onClose, onSave }) => ( + + + + {viewerImageUri && ( + + + + + + Save + + + + Close + + + + )} + + +); diff --git a/src/screens/ChatScreen/MessageRenderer.tsx b/src/screens/ChatScreen/MessageRenderer.tsx new file mode 100644 index 00000000..5cf4a0cc --- /dev/null +++ b/src/screens/ChatScreen/MessageRenderer.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { ChatMessage } from '../../components'; +import { Message } from '../../types'; +import { ChatMessageItem } from './useChatScreen'; + +type MessageRendererProps = { + item: Message | ChatMessageItem; + index: number; + displayMessagesLength: number; + animateLastN: number; + imageModelLoaded: boolean; + isStreaming: boolean; + isGeneratingImage: boolean; + showGenerationDetails: boolean; + onCopy: (content: string) => void; + onRetry: (message: Message) => void; + onEdit: (message: Message, newContent: string) => void; + onGenerateImage: (prompt: string) => void; + onImagePress: (uri: string) => void; +}; + +export const MessageRenderer: React.FC = ({ + item, + index, + displayMessagesLength, + animateLastN, + imageModelLoaded, + isStreaming, + isGeneratingImage, + showGenerationDetails, + onCopy, + onRetry, + onEdit, + onGenerateImage, + onImagePress, +}) => ( + 0 && index >= displayMessagesLength - animateLastN} + /> +); diff --git a/src/screens/ChatScreen/index.tsx b/src/screens/ChatScreen/index.tsx new file mode 100644 index 00000000..948855f7 --- /dev/null +++ b/src/screens/ChatScreen/index.tsx @@ -0,0 +1,225 @@ +import React from 'react'; +import { View, Text, FlatList, Keyboard, KeyboardAvoidingView, ActivityIndicator } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import Animated, { FadeIn } from 'react-native-reanimated'; +import { ChatInput } from '../../components'; +import { AnimatedPressable } from '../../components/AnimatedPressable'; +import { useTheme, useThemedStyles } from '../../theme'; +import { llmService, generationService } from '../../services'; +import { createStyles } from './styles'; +import { useChatScreen, getPlaceholderText } from './useChatScreen'; +import { MessageRenderer } from './MessageRenderer'; +import { + NoModelScreen, LoadingScreen, ChatHeader, EmptyChat, ImageProgressIndicator, +} from './ChatScreenComponents'; +import { ChatModalSection } from './ChatModalSection'; + +function countConversationImages(activeConversation: any): number { + const messages = activeConversation?.messages || []; + let count = 0; + for (const msg of messages) { + if (msg.attachments) { + for (const att of msg.attachments) { + if (att.type === 'image') count++; + } + } + } + return count; +} + +export const ChatScreen: React.FC = () => { + const flatListRef = React.useRef(null); + const isNearBottomRef = React.useRef(true); + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const chat = useChatScreen(); + + React.useEffect(() => { + if (chat.activeConversation?.messages.length && isNearBottomRef.current) { + setTimeout(() => { flatListRef.current?.scrollToEnd({ animated: true }); }, 100); + } + }, [chat.activeConversation?.messages.length]); + + if (!chat.activeModelId || !chat.activeModel) { + return ( + + ); + } + + if (chat.isModelLoading) { + const sizeSource = chat.loadingModel ?? chat.activeModel; + return ( + + ); + } + + const handleScroll = (event: any) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + isNearBottomRef.current = contentSize.height - layoutMeasurement.height - contentOffset.y < 100; + chat.setShowScrollToBottom(!isNearBottomRef.current); + }; + + const renderItem = ({ item, index }: { item: any; index: number }) => ( + + ); + + const imageCount = countConversationImages(chat.activeConversation); + + return ( + + + + + + + + ); +}; + +type ChatMessageAreaProps = { + flatListRef: React.RefObject; + isNearBottomRef: React.MutableRefObject; + chat: ReturnType; + styles: ReturnType; + colors: ReturnType['colors']; + handleScroll: (event: any) => void; + renderItem: (info: { item: any; index: number }) => React.JSX.Element; +}; + +const ChatMessageArea: React.FC = ({ + flatListRef, isNearBottomRef, chat, styles, colors, handleScroll, renderItem, +}) => ( + <> + {chat.displayMessages.length === 0 ? ( + + ) : ( + item.id} + contentContainerStyle={styles.messageList} + onScroll={handleScroll} + onContentSizeChange={(_w, _h) => { if (isNearBottomRef.current) flatListRef.current?.scrollToEnd({ animated: false }); }} + onLayout={() => {}} + scrollEventThrottle={16} + keyboardDismissMode="on-drag" + keyboardShouldPersistTaps="handled" + onTouchStart={() => Keyboard.dismiss()} + maintainVisibleContentPosition={{ minIndexForVisible: 0, autoscrollToTopThreshold: 100 }} + /> + )} + {chat.showScrollToBottom && chat.displayMessages.length > 0 && ( + + flatListRef.current?.scrollToEnd({ animated: true })}> + + + + )} + {chat.isGeneratingImage && ( + + )} + {chat.isClassifying && ( + + + Understanding your request... + + )} + chat.setShowSettingsPanel(true)} + queueCount={chat.queueCount} + queuedTexts={chat.queuedTexts} + onClearQueue={() => generationService.clearQueue()} + placeholder={getPlaceholderText(llmService.isModelLoaded(), chat.supportsVision)} + /> + +); diff --git a/src/screens/ChatScreen/styles.ts b/src/screens/ChatScreen/styles.ts new file mode 100644 index 00000000..ce6e7b9e --- /dev/null +++ b/src/screens/ChatScreen/styles.ts @@ -0,0 +1,210 @@ +import type { ThemeColors, ThemeShadows } from '../../theme'; +import { TYPOGRAPHY, SPACING } from '../../constants'; +import { createImageStyles } from './stylesImage'; + +const createLayoutStyles = (colors: ThemeColors) => ({ + container: { flex: 1, backgroundColor: colors.background }, + keyboardView: { flex: 1 }, + messageList: { paddingVertical: 16 }, +}); + +const createHeaderStyles = (colors: ThemeColors) => ({ + header: { + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: colors.border, + backgroundColor: colors.background, + zIndex: 10, + }, + headerRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'space-between' as const, + gap: SPACING.md, + }, + backButton: { padding: SPACING.xs }, + headerLeft: { flex: 1, marginRight: 12 }, + headerTitle: { ...TYPOGRAPHY.h2, color: colors.text, marginBottom: 2 }, + headerSubtitle: { ...TYPOGRAPHY.h3, color: colors.textMuted }, + modelSelector: { flexDirection: 'row' as const, alignItems: 'center' as const }, + modelSelectorArrow: { ...TYPOGRAPHY.meta, color: colors.textMuted, marginLeft: SPACING.xs }, + headerImageBadge: { + width: 18, + height: 18, + borderRadius: 9, + backgroundColor: `${colors.primary}20`, + alignItems: 'center' as const, + justifyContent: 'center' as const, + marginLeft: 6, + }, + headerActions: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + justifyContent: 'center' as const, + gap: 4, + }, + iconButton: { + width: 30, + height: 30, + borderRadius: 8, + backgroundColor: colors.surface, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, +}); + +const createScrollStyles = (colors: ThemeColors) => ({ + scrollToBottomContainer: { + position: 'absolute' as const, + bottom: 130, + right: 16, + zIndex: 10, + }, + scrollToBottomButton: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: colors.surface, + borderWidth: 1, + borderColor: colors.border, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, +}); + +const createEmptyChatStyles = (colors: ThemeColors) => ({ + emptyChat: { flex: 1, justifyContent: 'center' as const, alignItems: 'center' as const, paddingHorizontal: 32 }, + emptyChatIconContainer: { + width: 80, + height: 80, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + alignItems: 'center' as const, + justifyContent: 'center' as const, + marginBottom: SPACING.lg, + }, + emptyChatTitle: { ...TYPOGRAPHY.h2, color: colors.text, marginBottom: SPACING.sm }, + emptyChatText: { ...TYPOGRAPHY.body, color: colors.textSecondary, textAlign: 'center' as const, marginBottom: SPACING.xl }, + projectHint: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + backgroundColor: colors.surface, + paddingHorizontal: SPACING.lg, + paddingVertical: SPACING.sm, + borderRadius: 8, + marginBottom: SPACING.lg, + gap: SPACING.sm, + }, + projectHintIcon: { + width: 24, + height: 24, + borderRadius: 6, + backgroundColor: `${colors.primary}30`, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, + projectHintIconText: { ...TYPOGRAPHY.bodySmall, fontWeight: '600' as const, color: colors.primary }, + projectHintText: { ...TYPOGRAPHY.h3, color: colors.primary, fontWeight: '500' as const }, + privacyText: { ...TYPOGRAPHY.h3, color: colors.textMuted, textAlign: 'center' as const, maxWidth: 300 }, +}); + +const createStateScreenStyles = (colors: ThemeColors) => ({ + loadingContainer: { flex: 1, justifyContent: 'center' as const, alignItems: 'center' as const, gap: 16, paddingHorizontal: 24 }, + loadingText: { ...TYPOGRAPHY.h1, fontSize: 18, fontWeight: '600' as const, textAlign: 'center' as const, color: colors.text }, + loadingSubtext: { ...TYPOGRAPHY.body, color: colors.textSecondary }, + loadingHint: { ...TYPOGRAPHY.bodySmall, color: colors.textMuted, marginTop: SPACING.lg, textAlign: 'center' as const, paddingHorizontal: 32 }, + noModelContainer: { flex: 1, justifyContent: 'center' as const, alignItems: 'center' as const, paddingHorizontal: SPACING.xxl }, + noModelIconContainer: { + width: 80, + height: 80, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + alignItems: 'center' as const, + justifyContent: 'center' as const, + marginBottom: SPACING.lg, + }, + noModelTitle: { ...TYPOGRAPHY.h2, color: colors.text, marginBottom: SPACING.sm }, + noModelText: { ...TYPOGRAPHY.body, color: colors.textSecondary, textAlign: 'center' as const }, + selectModelButton: { + marginTop: SPACING.xl, + backgroundColor: 'transparent', + borderWidth: 1, + borderColor: colors.primary, + paddingHorizontal: SPACING.xl, + paddingVertical: SPACING.md, + borderRadius: 8, + }, + selectModelButtonText: { ...TYPOGRAPHY.body, color: colors.primary }, +}); + +const createIndicatorStyles = (colors: ThemeColors) => ({ + classifyingBar: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: 8, + paddingHorizontal: 16, + paddingVertical: 8, + borderTopWidth: 1, + borderTopColor: colors.border, + backgroundColor: colors.background, + }, + classifyingText: { ...TYPOGRAPHY.meta, color: colors.textSecondary }, + imageProgressContainer: { + paddingHorizontal: 12, + paddingTop: 8, + paddingBottom: 4, + borderTopWidth: 1, + borderTopColor: colors.border, + backgroundColor: colors.background, + }, + imageProgressCard: { + backgroundColor: colors.surface, + borderRadius: 12, + padding: 12, + borderWidth: 1, + borderColor: `${colors.primary}30`, + }, + imageProgressRow: { flexDirection: 'row' as const, alignItems: 'center' as const }, + imageProgressContent: { flex: 1 }, + imageProgressHeader: { flexDirection: 'row' as const, alignItems: 'center' as const }, + imageProgressIconContainer: { + width: 32, + height: 32, + borderRadius: 8, + backgroundColor: `${colors.primary}20`, + alignItems: 'center' as const, + justifyContent: 'center' as const, + marginRight: 10, + }, + imageProgressInfo: { flex: 1 }, + imageProgressTitle: { ...TYPOGRAPHY.body, fontWeight: '600' as const, color: colors.text }, + imageProgressStatus: { ...TYPOGRAPHY.bodySmall, color: colors.textSecondary, fontStyle: 'normal' as const }, + imageProgressBarContainer: { marginTop: 10 }, + imageProgressBar: { height: 4, backgroundColor: colors.surfaceLight, borderRadius: 2, overflow: 'hidden' as const }, + imageProgressFill: { height: '100%' as const, backgroundColor: colors.primary, borderRadius: 2 }, + imageProgressSteps: { ...TYPOGRAPHY.bodySmall, fontWeight: '600' as const, color: colors.primary, marginRight: SPACING.sm }, + imagePreview: { width: 100, height: 100, borderRadius: 8, marginRight: 12, backgroundColor: colors.surfaceLight }, + imageStopButton: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: `${colors.error}20`, + alignItems: 'center' as const, + justifyContent: 'center' as const, + }, +}); + +export const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ + ...createLayoutStyles(colors), + ...createHeaderStyles(colors), + ...createScrollStyles(colors), + ...createEmptyChatStyles(colors), + ...createStateScreenStyles(colors), + ...createIndicatorStyles(colors), + ...createImageStyles(colors, shadows), +}); diff --git a/src/screens/ChatScreen/stylesImage.ts b/src/screens/ChatScreen/stylesImage.ts new file mode 100644 index 00000000..a8c4a14b --- /dev/null +++ b/src/screens/ChatScreen/stylesImage.ts @@ -0,0 +1,48 @@ +import { Dimensions } from 'react-native'; +import type { ThemeColors, ThemeShadows } from '../../theme'; +import { TYPOGRAPHY, SPACING } from '../../constants'; + +export const createImageStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ + imageViewerContainer: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.95)', + justifyContent: 'center' as const, + alignItems: 'center' as const, + }, + imageViewerBackdrop: { + position: 'absolute' as const, + top: 0, + right: 0, + bottom: 0, + left: 0, + }, + imageViewerContent: { + width: '100%' as const, + height: '100%' as const, + justifyContent: 'center' as const, + alignItems: 'center' as const, + }, + fullscreenImage: { + width: Dimensions.get('window').width, + height: Dimensions.get('window').height * 0.7, + }, + imageViewerActions: { + flexDirection: 'row' as const, + position: 'absolute' as const, + bottom: 60, + gap: 40, + }, + imageViewerButton: { + alignItems: 'center' as const, + padding: 16, + backgroundColor: colors.surface, + borderRadius: 16, + minWidth: 80, + }, + imageViewerButtonText: { + ...TYPOGRAPHY.bodySmall, + color: colors.text, + marginTop: SPACING.xs, + fontWeight: '500' as const, + }, +}); diff --git a/src/screens/ChatScreen/types.ts b/src/screens/ChatScreen/types.ts new file mode 100644 index 00000000..7bc82e03 --- /dev/null +++ b/src/screens/ChatScreen/types.ts @@ -0,0 +1,41 @@ +import { Message } from '../../types'; + +export type ChatMessageItem = { + id: string; + role: 'assistant'; + content: string; + timestamp: number; + isThinking?: boolean; + isStreaming?: boolean; +}; + +export type StreamingState = { + isThinking: boolean; + streamingMessage: string; + isStreamingForThisConversation: boolean; +}; + +export function getDisplayMessages( + allMessages: Message[], + streaming: StreamingState, +): (Message | ChatMessageItem)[] { + const { isThinking, streamingMessage, isStreamingForThisConversation } = streaming; + if (isThinking && isStreamingForThisConversation) { + return [ + ...allMessages, + { id: 'thinking', role: 'assistant' as const, content: '', timestamp: Date.now(), isThinking: true }, + ]; + } + if (streamingMessage && isStreamingForThisConversation) { + return [ + ...allMessages, + { id: 'streaming', role: 'assistant' as const, content: streamingMessage, timestamp: Date.now(), isStreaming: true }, + ]; + } + return allMessages; +} + +export function getPlaceholderText(isModelLoaded: boolean, supportsVision: boolean): string { + if (!isModelLoaded) return 'Loading model...'; + return supportsVision ? 'Type a message or add an image...' : 'Type a message...'; +} diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts new file mode 100644 index 00000000..5a65afe6 --- /dev/null +++ b/src/screens/ChatScreen/useChatGenerationActions.ts @@ -0,0 +1,310 @@ +import { Dispatch, MutableRefObject, SetStateAction } from 'react'; +import { + AlertState, + showAlert, + hideAlert, +} from '../../components'; +import { APP_CONFIG } from '../../constants'; +import { + llmService, + intentClassifier, + generationService, + imageGenerationService, + onnxImageGeneratorService, + ImageGenerationState, +} from '../../services'; +import { useChatStore, useProjectStore } from '../../stores'; +import { Message, MediaAttachment, Project, DownloadedModel, ModelLoadingStrategy } from '../../types'; + +type SetState = Dispatch>; + +type GenerationDeps = { + activeModelId: string | null; + activeModel: DownloadedModel | undefined; + activeConversationId: string | null | undefined; + activeConversation: any; + activeProject: any; + activeImageModel: any; + imageModelLoaded: boolean; + isStreaming: boolean; + isGeneratingImage: boolean; + imageGenState: ImageGenerationState; + settings: { + showGenerationDetails: boolean; + imageGenerationMode: string; + autoDetectMethod: string; + classifierModelId?: string | null; + modelLoadingStrategy: ModelLoadingStrategy; + systemPrompt?: string; + imageSteps?: number; + imageGuidanceScale?: number; + }; + downloadedModels: DownloadedModel[]; + setAlertState: SetState; + setIsClassifying: SetState; + setAppImageGenerationStatus: (v: string | null) => void; + setAppIsGeneratingImage: (v: boolean) => void; + addMessage: (convId: string, msg: any) => void; + clearStreamingMessage: () => void; + deleteConversation: (convId: string) => void; + setActiveConversation: (convId: string | null) => void; + removeImagesByConversationId: (convId: string) => string[]; + generatingForConversationRef: MutableRefObject; + navigation: any; + ensureModelLoaded: () => Promise; +}; + +function buildMessagesForContext( + conversationId: string, + messageText: string, + systemPrompt: string, +): Message[] { + const conversation = useChatStore.getState().conversations.find(c => c.id === conversationId); + const conversationMessages = conversation?.messages || []; + const lastUserMsg = conversationMessages.at(-1); + const userMessageForContext = (lastUserMsg?.role === 'user' + ? { ...lastUserMsg, content: messageText } + : lastUserMsg) as Message; + return [ + { id: 'system', role: 'system', content: systemPrompt, timestamp: 0 }, + ...conversationMessages.slice(0, -1), + userMessageForContext, + ]; +} + +export async function shouldRouteToImageGenerationFn( + deps: Pick, + text: string, + forceImageMode?: boolean, +): Promise { + if (deps.isGeneratingImage) return false; + if (deps.settings.imageGenerationMode === 'manual') return forceImageMode === true; + if (forceImageMode) return true; + if (!deps.imageModelLoaded) return false; + try { + const useLLM = deps.settings.autoDetectMethod === 'llm'; + const classifierModel = deps.settings.classifierModelId + ? deps.downloadedModels.find(m => m.id === deps.settings.classifierModelId) + : null; + if (useLLM) deps.setIsClassifying(true); + const intent = await intentClassifier.classifyIntent(text, { + useLLM, + classifierModel, + currentModelPath: llmService.getLoadedModelPath(), + onStatusChange: useLLM ? deps.setAppImageGenerationStatus : undefined, + modelLoadingStrategy: deps.settings.modelLoadingStrategy, + }); + deps.setIsClassifying(false); + if (intent !== 'image' && useLLM) { + deps.setAppImageGenerationStatus(null); + deps.setAppIsGeneratingImage(false); + } + return intent === 'image'; + } catch (error) { + console.warn('[ChatScreen] Intent classification failed:', error); + deps.setIsClassifying(false); + deps.setAppImageGenerationStatus(null); + deps.setAppIsGeneratingImage(false); + return false; + } +} + +export type ImageGenCall = { + prompt: string; + conversationId: string; + skipUserMessage?: boolean; +}; + +export async function handleImageGenerationFn( + deps: Pick, + call: ImageGenCall, +): Promise { + const { prompt, conversationId, skipUserMessage = false } = call; + if (!deps.activeImageModel) { + deps.setAlertState(showAlert('Error', 'No image model loaded.')); + return; + } + if (!skipUserMessage) { + deps.addMessage(conversationId, { role: 'user', content: prompt }); + } + const result = await imageGenerationService.generateImage({ + prompt, + conversationId, + steps: deps.settings.imageSteps || 8, + guidanceScale: deps.settings.imageGuidanceScale || 2, + previewInterval: 2, + }); + if (!result && deps.imageGenState.error && !deps.imageGenState.error.includes('cancelled')) { + deps.setAlertState(showAlert('Error', `Image generation failed: ${deps.imageGenState.error}`)); + } +} + +export type StartGenerationCall = { setDebugInfo: SetState; targetConversationId: string; messageText: string }; + +export async function startGenerationFn(deps: GenerationDeps, call: StartGenerationCall): Promise { + const { setDebugInfo, targetConversationId, messageText } = call; + if (!deps.activeModel) return; + deps.generatingForConversationRef.current = targetConversationId; + const currentLoadedPath = llmService.getLoadedModelPath(); + const needsModelLoad = !currentLoadedPath || currentLoadedPath !== deps.activeModel.filePath; + if (needsModelLoad) { + await deps.ensureModelLoaded(); + if (!llmService.isModelLoaded() || llmService.getLoadedModelPath() !== deps.activeModel.filePath) { + deps.setAlertState(showAlert('Error', 'Failed to load model. Please try again.')); + deps.generatingForConversationRef.current = null; + return; + } + } + const conversation = useChatStore.getState().conversations.find(c => c.id === targetConversationId); + const project = conversation?.projectId + ? useProjectStore.getState().getProject(conversation.projectId) + : null; + const systemPrompt = project?.systemPrompt || deps.settings.systemPrompt || APP_CONFIG.defaultSystemPrompt; + const messagesForContext = buildMessagesForContext(targetConversationId, messageText, systemPrompt); + let shouldClearCache = false; + try { + const contextDebug = await llmService.getContextDebugInfo(messagesForContext); + setDebugInfo({ systemPrompt, ...contextDebug }); + if (contextDebug.truncatedCount > 0 || contextDebug.contextUsagePercent > 70) { + shouldClearCache = true; + } + } catch (e) { + console.log('Debug info error:', e); + } + if (shouldClearCache) { + await llmService.clearKVCache(false).catch(() => {}); + } + try { + await generationService.generateResponse( + targetConversationId, + messagesForContext, + () => { console.log('[ChatScreen] First token received for conversation:', targetConversationId); }, + ); + } catch (error: any) { + deps.setAlertState(showAlert('Generation Error', error.message || 'Failed to generate response')); + } + deps.generatingForConversationRef.current = null; +} + +export type SendCall = { + text: string; + attachments?: MediaAttachment[]; + forceImageMode?: boolean; + startGeneration: (convId: string, text: string) => Promise; + setDebugInfo: SetState; +}; + +export async function handleSendFn(deps: GenerationDeps, call: SendCall): Promise { + const { text, attachments, forceImageMode, startGeneration } = call; + if (!deps.activeConversationId || !deps.activeModel) { + deps.setAlertState(showAlert('No Model Selected', 'Please select a model first.')); + return; + } + const targetConversationId = deps.activeConversationId; + let messageText = text; + if (attachments) { + const documentAttachments = attachments.filter(a => a.type === 'document' && a.textContent); + for (const doc of documentAttachments) { + const fileName = doc.fileName || 'document'; + messageText += `\n\n---\n📄 **Attached Document: ${fileName}**\n\`\`\`\n${doc.textContent}\n\`\`\`\n---`; + } + } + const shouldGenerateImage = await shouldRouteToImageGenerationFn(deps, messageText, forceImageMode); + if (shouldGenerateImage && deps.activeImageModel) { + await handleImageGenerationFn(deps, { prompt: text, conversationId: targetConversationId }); + return; + } + if (shouldGenerateImage && !deps.activeImageModel) { + messageText = `[User wanted an image but no image model is loaded] ${messageText}`; + } + if (generationService.getState().isGenerating) { + generationService.enqueueMessage({ + id: `${Date.now()}-${Math.random().toString(36).slice(2, 11)}`, + conversationId: targetConversationId, + text, + attachments, + messageText, + }); + return; + } + deps.addMessage(targetConversationId, { role: 'user', content: text, attachments }); + await startGeneration(targetConversationId, messageText); +} + +export async function handleStopFn(deps: Pick): Promise { + console.log('[ChatScreen] handleStop called'); + deps.generatingForConversationRef.current = null; + try { + await Promise.all([ + generationService.stopGeneration().catch(() => {}), + llmService.stopGeneration().catch(() => {}), + ]); + } catch (error_) { + console.error('Error stopping generation:', error_); + } + if (deps.isGeneratingImage) { + imageGenerationService.cancelGeneration().catch(() => {}); + } +} + +export async function executeDeleteConversationFn( + deps: Pick, +): Promise { + if (!deps.activeConversationId) return; + deps.setAlertState(hideAlert()); + if (deps.isStreaming) { + await llmService.stopGeneration(); + deps.clearStreamingMessage(); + } + const imageIds = deps.removeImagesByConversationId(deps.activeConversationId); + for (const imageId of imageIds) { + await onnxImageGeneratorService.deleteGeneratedImage(imageId); + } + deps.deleteConversation(deps.activeConversationId); + deps.setActiveConversation(null); + deps.navigation.goBack(); +} + +export type RegenerateCall = { setDebugInfo: SetState; userMessage: Message }; + +export async function regenerateResponseFn(deps: GenerationDeps, call: RegenerateCall): Promise { + const { userMessage } = call; + if (!deps.activeConversationId || !deps.activeModel) return; + const targetConversationId = deps.activeConversationId; + const shouldGenerateImage = await shouldRouteToImageGenerationFn(deps, userMessage.content); + if (shouldGenerateImage && deps.activeImageModel) { + await handleImageGenerationFn(deps, { prompt: userMessage.content, conversationId: targetConversationId, skipUserMessage: true }); + return; + } + if (!llmService.isModelLoaded()) return; + deps.generatingForConversationRef.current = targetConversationId; + const messages = deps.activeConversation?.messages || []; + const messageIndex = messages.findIndex((m: Message) => m.id === userMessage.id); + const messagesUpToUser = messages.slice(0, messageIndex + 1); + const systemPrompt = deps.activeProject?.systemPrompt + || deps.settings.systemPrompt + || APP_CONFIG.defaultSystemPrompt; + const messagesForContext: Message[] = [ + { id: 'system', role: 'system', content: systemPrompt, timestamp: 0 }, + ...messagesUpToUser, + ]; + try { + await generationService.generateResponse(targetConversationId, messagesForContext); + } catch (error: any) { + deps.setAlertState(showAlert('Generation Error', error.message || 'Failed to generate response')); + } + deps.generatingForConversationRef.current = null; +} + +export type SelectProjectDeps = { + activeConversationId: string | null | undefined; + setConversationProject: (convId: string, projectId: string | null) => void; + setShowProjectSelector: SetState; +}; + +export function handleSelectProjectFn(deps: SelectProjectDeps, project: Project | null): void { + if (deps.activeConversationId) { + deps.setConversationProject(deps.activeConversationId, project?.id || null); + } + deps.setShowProjectSelector(false); +} diff --git a/src/screens/ChatScreen/useChatModelActions.ts b/src/screens/ChatScreen/useChatModelActions.ts new file mode 100644 index 00000000..2698efb9 --- /dev/null +++ b/src/screens/ChatScreen/useChatModelActions.ts @@ -0,0 +1,200 @@ +import { Dispatch, SetStateAction } from 'react'; +import { + AlertState, + showAlert, + hideAlert, +} from '../../components'; +import { llmService, activeModelService } from '../../services'; +import { DownloadedModel } from '../../types'; + +type SetState = Dispatch>; + +type ModelActionDeps = { + activeModel: DownloadedModel | undefined; + activeModelId: string | null; + activeConversationId: string | null | undefined; + isStreaming: boolean; + settings: { showGenerationDetails: boolean }; + clearStreamingMessage: () => void; + createConversation: (modelId: string, title?: string, projectId?: string) => string; + addMessage: (convId: string, msg: any) => void; + setIsModelLoading: SetState; + setLoadingModel: SetState; + setSupportsVision: SetState; + setShowModelSelector: SetState; + setAlertState: SetState; + modelLoadStartTimeRef: React.MutableRefObject; +}; + +function waitForRenderFrame(): Promise { + return new Promise(resolve => { + requestAnimationFrame(() => { + requestAnimationFrame(() => { setTimeout(resolve, 200); }); + }); + }); +} + +function addSystemMsg( + deps: Pick, + content: string, +) { + if (!deps.activeConversationId || !deps.settings.showGenerationDetails) return; + deps.addMessage(deps.activeConversationId, { + role: 'assistant', + content: `_${content}_`, + isSystemInfo: true, + }); +} + +export async function initiateModelLoad( + deps: ModelActionDeps, + alreadyLoading: boolean, +): Promise { + const { activeModel, activeModelId } = deps; + if (!activeModel || !activeModelId) return; + + if (!alreadyLoading) { + const memoryCheck = await activeModelService.checkMemoryForModel(activeModelId, 'text'); + if (!memoryCheck.canLoad) { + deps.setAlertState(showAlert( + 'Insufficient Memory', + `Cannot load ${activeModel.name}. ${memoryCheck.message}\n\nTry unloading other models from the Home screen.`, + )); + return; + } + deps.setIsModelLoading(true); + deps.setLoadingModel(activeModel); + deps.modelLoadStartTimeRef.current = Date.now(); + await waitForRenderFrame(); + } + + try { + await activeModelService.loadTextModel(activeModelId); + const multimodalSupport = llmService.getMultimodalSupport(); + deps.setSupportsVision(multimodalSupport?.vision || false); + if (!alreadyLoading && deps.modelLoadStartTimeRef.current && deps.settings.showGenerationDetails) { + const loadTime = ((Date.now() - deps.modelLoadStartTimeRef.current) / 1000).toFixed(1); + addSystemMsg(deps, `Model loaded: ${activeModel.name} (${loadTime}s)`); + } + } catch (error: any) { + if (!alreadyLoading) { + deps.setAlertState(showAlert('Error', `Failed to load model: ${error?.message || 'Unknown error'}`)); + } + } finally { + if (!alreadyLoading) { + deps.setIsModelLoading(false); + deps.setLoadingModel(null); + deps.modelLoadStartTimeRef.current = null; + } + } +} + +export async function ensureModelLoadedFn( + deps: ModelActionDeps, +): Promise { + const { activeModel, activeModelId } = deps; + if (!activeModel || !activeModelId) return; + const loadedPath = llmService.getLoadedModelPath(); + const currentVisionSupport = llmService.getMultimodalSupport()?.vision || false; + const needsReload = loadedPath !== activeModel.filePath || + (activeModel.mmProjPath && !currentVisionSupport); + if (!needsReload && loadedPath === activeModel.filePath) { + deps.setSupportsVision(currentVisionSupport); + return; + } + const alreadyLoading = activeModelService.getActiveModels().text.isLoading; + await initiateModelLoad(deps, alreadyLoading); +} + +export async function proceedWithModelLoadFn( + deps: ModelActionDeps, + model: DownloadedModel, +): Promise { + deps.setIsModelLoading(true); + deps.setLoadingModel(model); + deps.modelLoadStartTimeRef.current = Date.now(); + await waitForRenderFrame(); + try { + await activeModelService.loadTextModel(model.id); + const multimodalSupport = llmService.getMultimodalSupport(); + deps.setSupportsVision(multimodalSupport?.vision || false); + if (deps.modelLoadStartTimeRef.current && deps.settings.showGenerationDetails) { + const loadTime = ((Date.now() - deps.modelLoadStartTimeRef.current) / 1000).toFixed(1); + const convId = deps.activeConversationId || deps.createConversation(model.id); + if (convId) { + deps.addMessage(convId, { + role: 'assistant', + content: `_Model loaded: ${model.name} (${loadTime}s)_`, + isSystemInfo: true, + }); + } + } else if (!deps.activeConversationId) { + deps.createConversation(model.id); + } + } catch (error) { + deps.setAlertState(showAlert('Error', `Failed to load model: ${(error as Error).message}`)); + } finally { + deps.setIsModelLoading(false); + deps.setLoadingModel(null); + deps.setShowModelSelector(false); + deps.modelLoadStartTimeRef.current = null; + } +} + +export async function handleModelSelectFn( + deps: ModelActionDeps, + model: DownloadedModel, +): Promise { + if (llmService.getLoadedModelPath() === model.filePath) { + deps.setShowModelSelector(false); + return; + } + const memoryCheck = await activeModelService.checkMemoryForModel(model.id, 'text'); + if (!memoryCheck.canLoad) { + deps.setAlertState(showAlert('Insufficient Memory', memoryCheck.message)); + return; + } + if (memoryCheck.severity === 'warning') { + deps.setAlertState(showAlert( + 'Low Memory Warning', + memoryCheck.message, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Load Anyway', + style: 'default', + onPress: () => { + deps.setAlertState(hideAlert()); + proceedWithModelLoadFn(deps, model); + }, + }, + ], + )); + return; + } + proceedWithModelLoadFn(deps, model); +} + +export async function handleUnloadModelFn(deps: ModelActionDeps): Promise { + const { activeModel, isStreaming, clearStreamingMessage } = deps; + if (isStreaming) { + await llmService.stopGeneration(); + clearStreamingMessage(); + } + const modelName = activeModel?.name; + deps.setIsModelLoading(true); + deps.setLoadingModel(activeModel ?? null); + try { + await activeModelService.unloadTextModel(); + deps.setSupportsVision(false); + if (deps.settings.showGenerationDetails && modelName) { + addSystemMsg(deps, `Model unloaded: ${modelName}`); + } + } catch (error) { + deps.setAlertState(showAlert('Error', `Failed to unload model: ${(error as Error).message}`)); + } finally { + deps.setIsModelLoading(false); + deps.setLoadingModel(null); + deps.setShowModelSelector(false); + } +} diff --git a/src/screens/ChatScreen/useChatScreen.ts b/src/screens/ChatScreen/useChatScreen.ts new file mode 100644 index 00000000..e68ed929 --- /dev/null +++ b/src/screens/ChatScreen/useChatScreen.ts @@ -0,0 +1,261 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { AlertState, showAlert, initialAlertState } from '../../components'; +import { useAppStore, useChatStore, useProjectStore } from '../../stores'; +import { + llmService, modelManager, activeModelService, + generationService, imageGenerationService, + ImageGenerationState, hardwareService, QueuedMessage, +} from '../../services'; +import { Message, MediaAttachment, Project, DownloadedModel, DebugInfo } from '../../types'; +import { ChatsStackParamList } from '../../navigation/types'; +import { ensureModelLoadedFn, handleModelSelectFn, handleUnloadModelFn } from './useChatModelActions'; +import { + startGenerationFn, handleSendFn, handleStopFn, executeDeleteConversationFn, + regenerateResponseFn, handleImageGenerationFn, handleSelectProjectFn, +} from './useChatGenerationActions'; +import { getDisplayMessages, getPlaceholderText, ChatMessageItem, StreamingState } from './types'; +import { saveImageToGallery } from './useSaveImage'; + +export type { AlertState, ChatMessageItem, StreamingState }; +export { getDisplayMessages, getPlaceholderText }; + +type ChatScreenRouteProp = RouteProp; + +export const useChatScreen = () => { + const navigation = useNavigation(); + const route = useRoute(); + + const [isModelLoading, setIsModelLoading] = useState(false); + const [loadingModel, setLoadingModel] = useState(null); + const [supportsVision, setSupportsVision] = useState(false); + const [showProjectSelector, setShowProjectSelector] = useState(false); + const [showDebugPanel, setShowDebugPanel] = useState(false); + const [showModelSelector, setShowModelSelector] = useState(false); + const [showSettingsPanel, setShowSettingsPanel] = useState(false); + const [debugInfo, setDebugInfo] = useState(null); + const [alertState, setAlertState] = useState(initialAlertState); + const [showScrollToBottom, setShowScrollToBottom] = useState(false); + const [isClassifying, setIsClassifying] = useState(false); + const [animateLastN, setAnimateLastN] = useState(0); + const [queueCount, setQueueCount] = useState(0); + const [queuedTexts, setQueuedTexts] = useState([]); + const [viewerImageUri, setViewerImageUri] = useState(null); + const [imageGenState, setImageGenState] = useState(imageGenerationService.getState()); + + const lastMessageCountRef = useRef(0); + const generatingForConversationRef = useRef(null); + const modelLoadStartTimeRef = useRef(null); + const startGenerationRef = useRef<(id: string, text: string) => Promise>(null as any); + const addMessageRef = useRef(null as any); + + const { + activeModelId, downloadedModels, settings, activeImageModelId, + downloadedImageModels, setDownloadedImageModels, + setIsGeneratingImage: setAppIsGeneratingImage, + setImageGenerationStatus: setAppImageGenerationStatus, + removeImagesByConversationId, + } = useAppStore(); + + const { + activeConversationId, conversations, createConversation, addMessage, + updateMessageContent, deleteMessagesAfter, streamingMessage, + streamingForConversationId, isStreaming, isThinking, clearStreamingMessage, + deleteConversation, setActiveConversation, setConversationProject, + } = useChatStore(); + + const { projects, getProject } = useProjectStore(); + addMessageRef.current = addMessage; + + const activeConversation = conversations.find(c => c.id === activeConversationId); + const activeModel = downloadedModels.find(m => m.id === activeModelId); + const activeProject = activeConversation?.projectId ? getProject(activeConversation.projectId) : null; + const activeImageModel = downloadedImageModels.find(m => m.id === activeImageModelId); + const imageModelLoaded = !!activeImageModel; + const isGeneratingImage = imageGenState.isGenerating; + const isStreamingForThisConversation = streamingForConversationId === activeConversationId; + + const genDeps = { + activeModelId, activeModel, activeConversationId, activeConversation, activeProject, + activeImageModel, imageModelLoaded, isStreaming, isGeneratingImage, imageGenState, settings, + downloadedModels, setAlertState, setIsClassifying, setAppImageGenerationStatus, + setAppIsGeneratingImage, addMessage, clearStreamingMessage, deleteConversation, + setActiveConversation, removeImagesByConversationId, generatingForConversationRef, navigation, + ensureModelLoaded: async () => ensureModelLoadedFn(modelDeps), + }; + + const modelDeps = { + activeModel, activeModelId, activeConversationId, isStreaming, settings, + clearStreamingMessage, createConversation, addMessage, + setIsModelLoading, setLoadingModel, setSupportsVision, setShowModelSelector, + setAlertState, modelLoadStartTimeRef, + }; + + useEffect(() => { return imageGenerationService.subscribe(state => setImageGenState(state)); }, []); + useEffect(() => { + return generationService.subscribe(state => { + setQueueCount(state.queuedMessages.length); + setQueuedTexts(state.queuedMessages.map((m: QueuedMessage) => m.text)); + }); + }, []); + + const handleQueuedSend = useCallback(async (item: QueuedMessage) => { + addMessageRef.current(item.conversationId, { role: 'user', content: item.text, attachments: item.attachments }); + await startGenerationRef.current(item.conversationId, item.messageText); + }, []); + + useEffect(() => { + generationService.setQueueProcessor(handleQueuedSend); + return () => generationService.setQueueProcessor(null); + }, [handleQueuedSend]); + + useEffect(() => { + const { conversationId, projectId } = route.params || {}; + if (conversationId) { setActiveConversation(conversationId); } + else if (activeModelId) { createConversation(activeModelId, undefined, projectId); } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [route.params?.conversationId, route.params?.projectId]); + + useEffect(() => { + if (generatingForConversationRef.current && generatingForConversationRef.current !== activeConversationId) { + generatingForConversationRef.current = null; + } + let cancelled = false; + const timer = setTimeout(() => { + if (!cancelled && llmService.isModelLoaded()) { llmService.clearKVCache(false).catch(() => {}); } + }, 0); + return () => { cancelled = true; clearTimeout(timer); }; + }, [activeConversationId]); + + useEffect(() => { + let cancelled = false; + const timer = setTimeout(async () => { + if (!cancelled) { + const models = await modelManager.getDownloadedImageModels(); + if (!cancelled) setDownloadedImageModels(models); + } + }, 0); + return () => { cancelled = true; clearTimeout(timer); }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + const preload = async () => { + if ( + settings.imageGenerationMode === 'auto' && settings.autoDetectMethod === 'llm' && + settings.classifierModelId && activeImageModelId && settings.modelLoadingStrategy === 'performance' + ) { + const classifierModel = downloadedModels.find(m => m.id === settings.classifierModelId); + if (classifierModel?.filePath && !llmService.getLoadedModelPath()) { + try { await activeModelService.loadTextModel(settings.classifierModelId!); } + catch (error) { console.warn('[ChatScreen] Failed to preload classifier model:', error); } + } + } + }; + preload(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [settings.imageGenerationMode, settings.autoDetectMethod, settings.classifierModelId, activeImageModelId, settings.modelLoadingStrategy]); + + useEffect(() => { + if (activeModelId && activeModel) { ensureModelLoadedFn(modelDeps); } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeModelId]); + + useEffect(() => { + if (activeModel?.mmProjPath && llmService.isModelLoaded()) { + const support = llmService.getMultimodalSupport(); + if (support?.vision) setSupportsVision(true); + } else if (!activeModel?.mmProjPath) { setSupportsVision(false); } + }, [activeModel?.mmProjPath]); + + const displayMessages = getDisplayMessages( + activeConversation?.messages || [], + { isThinking, streamingMessage, isStreamingForThisConversation }, + ); + + useEffect(() => { + const prev = lastMessageCountRef.current; + const curr = displayMessages.length; + if (curr > prev && prev > 0) setAnimateLastN(curr - prev); + lastMessageCountRef.current = curr; + }, [displayMessages.length]); + + useEffect(() => { lastMessageCountRef.current = 0; setAnimateLastN(0); }, [activeConversationId]); + + const startGeneration = async (targetConversationId: string, messageText: string) => { + await startGenerationFn(genDeps, { setDebugInfo, targetConversationId, messageText }); + }; + startGenerationRef.current = startGeneration; + + return { + isModelLoading, loadingModel, supportsVision, + showProjectSelector, setShowProjectSelector, + showDebugPanel, setShowDebugPanel, + showModelSelector, setShowModelSelector, + showSettingsPanel, setShowSettingsPanel, + debugInfo, alertState, setAlertState, + showScrollToBottom, setShowScrollToBottom, + isClassifying, animateLastN, queueCount, queuedTexts, + viewerImageUri, setViewerImageUri, imageGenState, + activeModelId, activeConversationId, activeConversation, activeModel, + activeProject, activeImageModel, imageModelLoaded, isGeneratingImage, + imageGenerationProgress: imageGenState.progress, + imageGenerationStatus: imageGenState.status, + imagePreviewPath: imageGenState.previewPath, + isStreaming, isThinking, displayMessages, downloadedModels, projects, settings, + navigation, hardwareService, + handleSend: (text: string, attachments?: MediaAttachment[], forceImageMode?: boolean) => + handleSendFn(genDeps, { text, attachments, forceImageMode, startGeneration, setDebugInfo }), + handleStop: () => handleStopFn(genDeps), + handleModelSelect: (model: DownloadedModel) => handleModelSelectFn(modelDeps, model), + handleUnloadModel: () => handleUnloadModelFn(modelDeps), + handleDeleteConversation: () => { + if (!activeConversationId || !activeConversation) return; + setAlertState(showAlert( + 'Delete Conversation', + 'Are you sure you want to delete this conversation? This will also delete all images generated in this chat.', + [ + { text: 'Cancel', style: 'cancel' }, + { text: 'Delete', style: 'destructive', onPress: () => { executeDeleteConversationFn(genDeps).catch(() => {}); } }, + ], + )); + }, + handleCopyMessage: (_content: string) => {}, + handleRetryMessage: async (message: Message) => { + if (!activeConversationId || !activeModel) return; + if (message.role === 'user') { + const msgs = activeConversation?.messages || []; + const idx = msgs.findIndex((m: Message) => m.id === message.id); + if (idx !== -1 && idx < msgs.length - 1) deleteMessagesAfter(activeConversationId, message.id); + await regenerateResponseFn(genDeps, { setDebugInfo, userMessage: message }); + } else { + const msgs = activeConversation?.messages || []; + const idx = msgs.findIndex((m: Message) => m.id === message.id); + if (idx > 0) { + const prevUserMsg = msgs.slice(0, idx).reverse().find((m: Message) => m.role === 'user'); + if (prevUserMsg) { + deleteMessagesAfter(activeConversationId, prevUserMsg.id); + await regenerateResponseFn(genDeps, { setDebugInfo, userMessage: prevUserMsg }); + } + } + } + }, + handleEditMessage: async (message: Message, newContent: string) => { + if (!activeConversationId || !activeModel) return; + updateMessageContent(activeConversationId, message.id, newContent); + deleteMessagesAfter(activeConversationId, message.id); + await regenerateResponseFn(genDeps, { setDebugInfo, userMessage: { ...message, content: newContent } }); + }, + handleSelectProject: (project: Project | null) => + handleSelectProjectFn({ activeConversationId, setConversationProject, setShowProjectSelector }, project), + handleGenerateImageFromMessage: async (prompt: string) => { + if (!activeConversationId || !activeImageModel) { + setAlertState(showAlert('No Image Model', 'Please load an image model first from the Models screen.')); + return; + } + await handleImageGenerationFn(genDeps, { prompt, conversationId: activeConversationId, skipUserMessage: true }); + }, + handleImagePress: (uri: string) => setViewerImageUri(uri), + handleSaveImage: () => saveImageToGallery(viewerImageUri, setAlertState), + }; +}; diff --git a/src/screens/ChatScreen/useSaveImage.ts b/src/screens/ChatScreen/useSaveImage.ts new file mode 100644 index 00000000..c0999f50 --- /dev/null +++ b/src/screens/ChatScreen/useSaveImage.ts @@ -0,0 +1,44 @@ +import { Dispatch, SetStateAction } from 'react'; +import { Platform, PermissionsAndroid } from 'react-native'; +import RNFS from 'react-native-fs'; +import { AlertState, showAlert } from '../../components'; + +export async function saveImageToGallery( + viewerImageUri: string | null, + setAlertState: Dispatch>, +): Promise { + if (!viewerImageUri) return; + try { + if (Platform.OS === 'android') { + await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, + { + title: 'Storage Permission', + message: 'App needs access to save images', + buttonNeutral: 'Ask Later', + buttonNegative: 'Cancel', + buttonPositive: 'OK', + }, + ); + } + const sourcePath = viewerImageUri.replace('file://', ''); + const picturesDir = Platform.OS === 'android' + ? `${RNFS.ExternalStorageDirectoryPath}/Pictures/OffgridMobile` + : `${RNFS.DocumentDirectoryPath}/OffgridMobile_Images`; + if (!(await RNFS.exists(picturesDir))) { + await RNFS.mkdir(picturesDir); + } + const timestamp = new Date().toISOString().replaceAll(':', '-').replaceAll('.', '-'); + const fileName = `generated_${timestamp}.png`; + await RNFS.copyFile(sourcePath, `${picturesDir}/${fileName}`); + setAlertState(showAlert( + 'Image Saved', + Platform.OS === 'android' + ? `Saved to Pictures/OffgridMobile/${fileName}` + : `Saved to ${fileName}`, + )); + } catch (error: any) { + console.error('[ChatScreen] Failed to save image:', error); + setAlertState(showAlert('Error', `Failed to save image: ${error?.message || 'Unknown error'}`)); + } +} diff --git a/src/screens/ChatsListScreen.tsx b/src/screens/ChatsListScreen.tsx index 5c5d252b..69714290 100644 --- a/src/screens/ChatsListScreen.tsx +++ b/src/screens/ChatsListScreen.tsx @@ -84,9 +84,9 @@ export const ChatsListScreen: React.FC = () => { return 'Yesterday'; } else if (diffDays < 7) { return date.toLocaleDateString([], { weekday: 'short' }); - } else { + } return date.toLocaleDateString([], { month: 'short', day: 'numeric' }); - } + }; const renderRightActions = (conversation: Conversation) => ( @@ -106,7 +106,7 @@ export const ChatsListScreen: React.FC = () => { renderRightActions(item)} overshootRight={false} - containerStyle={{ overflow: 'visible' }} + containerStyle={styles.swipeableContainer} > ({ flex: 1, backgroundColor: colors.background, }, + swipeableContainer: { + overflow: 'visible' as const, + }, header: { flexDirection: 'row' as const, justifyContent: 'space-between' as const, diff --git a/src/screens/DeviceInfoScreen.tsx b/src/screens/DeviceInfoScreen.tsx index 1023c29e..7bffe773 100644 --- a/src/screens/DeviceInfoScreen.tsx +++ b/src/screens/DeviceInfoScreen.tsx @@ -159,7 +159,7 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ tierBadge: { ...TYPOGRAPHY.label, textTransform: 'uppercase' as const, - backgroundColor: colors.primary + '20', + backgroundColor: `${colors.primary }20`, color: colors.primary, paddingHorizontal: SPACING.sm, paddingVertical: SPACING.xs, diff --git a/src/screens/DownloadManagerScreen.tsx b/src/screens/DownloadManagerScreen.tsx deleted file mode 100644 index 62f60b96..00000000 --- a/src/screens/DownloadManagerScreen.tsx +++ /dev/null @@ -1,748 +0,0 @@ -import React, { useEffect, useState, useCallback, useRef } from 'react'; -import { - View, - Text, - FlatList, - TouchableOpacity, - RefreshControl, -} from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import Icon from 'react-native-vector-icons/Feather'; -import { Card } from '../components'; -import { CustomAlert, showAlert, hideAlert, AlertState, initialAlertState } from '../components/CustomAlert'; -import { useTheme, useThemedStyles } from '../theme'; -import type { ThemeColors, ThemeShadows } from '../theme'; -import { TYPOGRAPHY, SPACING } from '../constants'; -import { useAppStore } from '../stores'; -import { modelManager, backgroundDownloadService, activeModelService, hardwareService } from '../services'; -import { DownloadedModel, BackgroundDownloadInfo, ONNXImageModel } from '../types'; -import { useNavigation } from '@react-navigation/native'; - -type DownloadItem = { - type: 'active' | 'completed'; - modelType: 'text' | 'image'; - downloadId?: number; - modelId: string; - fileName: string; - author: string; - quantization: string; - fileSize: number; - bytesDownloaded: number; - progress: number; - status: string; - downloadedAt?: string; - filePath?: string; -}; - -export const DownloadManagerScreen: React.FC = () => { - const navigation = useNavigation(); - const { colors } = useTheme(); - const styles = useThemedStyles(createStyles); - const [isRefreshing, setIsRefreshing] = useState(false); - const [activeDownloads, setActiveDownloads] = useState([]); - const [alertState, setAlertState] = useState(initialAlertState); - const cancelledKeysRef = useRef>(new Set()); - - const { - downloadedModels, - setDownloadedModels, - downloadProgress, - setDownloadProgress, - removeDownloadedModel, - activeBackgroundDownloads, - setBackgroundDownload, - downloadedImageModels, - setDownloadedImageModels, - removeDownloadedImageModel, - removeImageModelDownloading, - } = useAppStore(); - - // Load active background downloads on mount - useEffect(() => { - loadActiveDownloads(); - - // Start polling to catch completed/stuck downloads - if (backgroundDownloadService.isAvailable()) { - modelManager.startBackgroundDownloadPolling(); - } - - return () => { - modelManager.stopBackgroundDownloadPolling(); - }; - }, []); - - // Subscribe to background download events - useEffect(() => { - if (!backgroundDownloadService.isAvailable()) return; - - const unsubProgress = backgroundDownloadService.onAnyProgress((event) => { - const key = `${event.modelId}/${event.fileName}`; - if (cancelledKeysRef.current.has(key)) return; - setDownloadProgress(key, { - progress: event.totalBytes > 0 ? event.bytesDownloaded / event.totalBytes : 0, - bytesDownloaded: event.bytesDownloaded, - totalBytes: event.totalBytes, - }); - }); - - const unsubComplete = backgroundDownloadService.onAnyComplete(async (event) => { - // Clear progress - setDownloadProgress(`${event.modelId}/${event.fileName}`, null); - - // Reload downloads - await loadActiveDownloads(); - const models = await modelManager.getDownloadedModels(); - setDownloadedModels(models); - }); - - const unsubError = backgroundDownloadService.onAnyError((event) => { - setDownloadProgress(`${event.modelId}/${event.fileName}`, null); - setBackgroundDownload(event.downloadId, null); - setAlertState(showAlert('Download Failed', event.reason || 'Unknown error')); - }); - - return () => { - unsubProgress(); - unsubComplete(); - unsubError(); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const loadActiveDownloads = async () => { - if (backgroundDownloadService.isAvailable()) { - const downloads = await modelManager.getActiveBackgroundDownloads(); - setActiveDownloads(downloads.filter(d => d.status === 'running' || d.status === 'pending' || d.status === 'paused')); - } - }; - - const handleRefresh = useCallback(async () => { - setIsRefreshing(true); - await loadActiveDownloads(); - const models = await modelManager.getDownloadedModels(); - setDownloadedModels(models); - const imageModels = await modelManager.getDownloadedImageModels(); - setDownloadedImageModels(imageModels); - setIsRefreshing(false); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - const handleRemoveDownload = async (item: DownloadItem) => { - setAlertState(showAlert( - 'Remove Download', - 'Are you sure you want to remove this download?', - [ - { text: 'No', style: 'cancel' }, - { - text: 'Yes', - style: 'destructive', - onPress: async () => { - setAlertState(hideAlert()); - try { - // Mark as cancelled so polling events don't re-add it - const key = `${item.modelId}/${item.fileName}`; - cancelledKeysRef.current.add(key); - - // Clear from progress tracking immediately (optimistic update) - setDownloadProgress(key, null); - - // Find downloadId - either from the item or by cross-referencing active downloads - let downloadId = item.downloadId; - if (!downloadId) { - const match = activeDownloads.find(d => { - const meta = activeBackgroundDownloads[d.downloadId]; - return meta && meta.fileName === item.fileName; - }); - if (match) downloadId = match.downloadId; - } - - // Remove from local activeDownloads state immediately - if (downloadId) { - setActiveDownloads(prev => prev.filter(d => d.downloadId !== downloadId)); - setBackgroundDownload(downloadId, null); - await modelManager.cancelBackgroundDownload(downloadId); - } - - // Clear image model download state so ModelsScreen unblocks - if (item.modelId.startsWith('image:')) { - const actualModelId = item.modelId.replace('image:', ''); - removeImageModelDownloading(actualModelId); - } - - // Wait a bit for native cancellation to complete, then reload - setTimeout(async () => { - await loadActiveDownloads(); - // Only clear cancelled key if native cancel succeeded - if (downloadId) { - cancelledKeysRef.current.delete(key); - } - }, 1000); - } catch (_error) { - setAlertState(showAlert('Error', 'Failed to remove download')); - } - }, - }, - ] - )); - }; - - const handleDeleteModel = async (model: DownloadedModel) => { - const totalSize = hardwareService.getModelTotalSize(model); - setAlertState(showAlert( - 'Delete Model', - `Are you sure you want to delete "${model.fileName}"? This will free up ${formatBytes(totalSize)}.`, - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', - style: 'destructive', - onPress: async () => { - setAlertState(hideAlert()); - try { - await modelManager.deleteModel(model.id); - removeDownloadedModel(model.id); - } catch (_error) { - setAlertState(showAlert('Error', 'Failed to delete model')); - } - }, - }, - ] - )); - }; - - const handleDeleteImageModel = async (model: ONNXImageModel) => { - setAlertState(showAlert( - 'Delete Image Model', - `Are you sure you want to delete "${model.name}"? This will free up ${formatBytes(model.size)}.`, - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', - style: 'destructive', - onPress: async () => { - setAlertState(hideAlert()); - try { - // Unload if this is the active model - await activeModelService.unloadImageModel(); - await modelManager.deleteImageModel(model.id); - removeDownloadedImageModel(model.id); - } catch (_error) { - setAlertState(showAlert('Error', 'Failed to delete image model')); - } - }, - }, - ] - )); - }; - - // Combine RNFS downloads and background downloads - const getDownloadItems = (): DownloadItem[] => { - const items: DownloadItem[] = []; - - // Add active RNFS downloads (iOS and foreground Android) - Object.entries(downloadProgress).forEach(([key, progress]) => { - const [_modelId, fileName] = key.split('/').slice(-2); - const fullModelId = key.substring(0, key.lastIndexOf('/')); - - // Skip invalid entries (undefined, null, or malformed keys) - if (!fileName || !fullModelId || fileName === 'undefined' || fullModelId === 'undefined' || - isNaN(progress.totalBytes) || isNaN(progress.bytesDownloaded)) { - console.warn('[DownloadManager] Skipping invalid download entry:', key, progress); - return; - } - - items.push({ - type: 'active', - modelType: 'text', - modelId: fullModelId, - fileName, - author: fullModelId.split('/')[0] || 'Unknown', - quantization: extractQuantization(fileName), - fileSize: progress.totalBytes, - bytesDownloaded: progress.bytesDownloaded, - progress: progress.progress, - status: 'downloading', - }); - }); - - // Add active background downloads (Android) - activeDownloads.forEach((download) => { - const metadata = activeBackgroundDownloads[download.downloadId]; - if (!metadata) return; - - // Skip if already tracked via RNFS progress - const key = `${metadata.modelId}/${metadata.fileName}`; - if (downloadProgress[key]) return; - - // Skip invalid entries - if (!metadata.fileName || !metadata.modelId || - metadata.fileName === 'undefined' || metadata.modelId === 'undefined' || - isNaN(metadata.totalBytes) || isNaN(download.bytesDownloaded)) { - console.warn('[DownloadManager] Skipping invalid background download:', metadata); - return; - } - - items.push({ - type: 'active', - modelType: 'text', - downloadId: download.downloadId, - modelId: metadata.modelId, - fileName: download.title || metadata.fileName, - author: metadata.author, - quantization: metadata.quantization, - fileSize: metadata.totalBytes, - bytesDownloaded: download.bytesDownloaded, - progress: metadata.totalBytes > 0 ? download.bytesDownloaded / metadata.totalBytes : 0, - status: download.status, - }); - }); - - // Add completed text model downloads - downloadedModels.forEach((model) => { - const totalSize = hardwareService.getModelTotalSize(model); - items.push({ - type: 'completed', - modelType: 'text', - modelId: model.id, - fileName: model.fileName, - author: model.author, - quantization: model.quantization, - fileSize: totalSize, - bytesDownloaded: totalSize, - progress: 1, - status: 'completed', - downloadedAt: model.downloadedAt, - filePath: model.filePath, - }); - }); - - // Add completed image model downloads - downloadedImageModels.forEach((model) => { - items.push({ - type: 'completed', - modelType: 'image', - modelId: model.id, - fileName: model.name, - author: 'Image Generation', - quantization: '', // No quantization badge for image models - fileSize: model.size, - bytesDownloaded: model.size, - progress: 1, - status: 'completed', - filePath: model.modelPath, - }); - }); - - return items; - }; - - const items = getDownloadItems(); - const activeItems = items.filter(i => i.type === 'active'); - const completedItems = items.filter(i => i.type === 'completed'); - - const renderActiveItem = ({ item }: { item: DownloadItem }) => ( - - - - - {item.fileName} - - - {item.author} - - - handleRemoveDownload(item)} - > - - - - - - - - - - {formatBytes(item.bytesDownloaded)} / {formatBytes(item.fileSize)} - - - - - - {item.quantization} - - - {item.status === 'running' ? 'Downloading...' : - item.status === 'pending' ? 'Starting...' : - item.status === 'paused' ? 'Paused' : - item.status === 'unknown' ? 'Stuck - Remove & retry' : item.status} - - - - ); - - const renderCompletedItem = ({ item }: { item: DownloadItem }) => ( - - - - - - - - {item.fileName} - - - {item.author} - - - { - if (item.modelType === 'image') { - const model = downloadedImageModels.find(m => m.id === item.modelId); - if (model) handleDeleteImageModel(model); - } else { - const model = downloadedModels.find(m => m.id === item.modelId); - if (model) handleDeleteModel(model); - } - }} - > - - - - - - {item.quantization && ( - - - {item.quantization} - - - )} - {formatBytes(item.fileSize)} - {item.downloadedAt && ( - - {new Date(item.downloadedAt).toLocaleDateString()} - - )} - - - ); - - return ( - - - navigation.goBack()} - > - - - Download Manager - - - - ( - - {/* Active Downloads Section */} - - - - Active Downloads - - {activeItems.length} - - - - {activeItems.length > 0 ? ( - activeItems.map((item, index) => ( - - {renderActiveItem({ item })} - - )) - ) : ( - - - No active downloads - - )} - - - {/* Completed Downloads Section */} - - - - Downloaded Models - - {completedItems.length} - - - - {completedItems.length > 0 ? ( - completedItems.map((item, index) => ( - - {renderCompletedItem({ item })} - - )) - ) : ( - - - No models downloaded yet - - Go to the Models tab to browse and download models - - - )} - - - {/* Storage Info */} - {completedItems.length > 0 && ( - - - - - Total storage used: {formatBytes( - completedItems.reduce((sum, item) => sum + item.fileSize, 0) - )} - - - - )} - - )} - keyExtractor={(item) => item.key} - refreshControl={ - - } - contentContainerStyle={styles.listContent} - /> - setAlertState(hideAlert())} - /> - - ); -}; - -function formatBytes(bytes: number): string { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${(bytes / Math.pow(k, i)).toFixed(i > 1 ? 2 : 0)} ${sizes[i]}`; -} - -function extractQuantization(fileName: string): string { - // Core ML models - if (fileName.toLowerCase().includes('coreml')) return 'Core ML'; - const upperName = fileName.toUpperCase(); - const patterns = ['Q2_K', 'Q3_K_S', 'Q3_K_M', 'Q4_0', 'Q4_K_S', 'Q4_K_M', 'Q5_K_S', 'Q5_K_M', 'Q6_K', 'Q8_0']; - for (const pattern of patterns) { - if (upperName.includes(pattern.replace('_', ''))) return pattern; - if (upperName.includes(pattern)) return pattern; - } - const match = fileName.match(/[QqFf]\d+[_]?[KkMmSs]*/); - return match ? match[0].toUpperCase() : 'Unknown'; -} - -const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ - container: { - flex: 1, - backgroundColor: colors.background, - }, - header: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - paddingHorizontal: SPACING.lg, - paddingVertical: SPACING.md, - borderBottomWidth: 1, - borderBottomColor: colors.border, - backgroundColor: colors.surface, - ...shadows.small, - zIndex: 1, - }, - backButton: { - padding: SPACING.sm, - marginRight: SPACING.sm, - }, - title: { - ...TYPOGRAPHY.h2, - flex: 1, - color: colors.text, - }, - headerSpacer: { - width: 40, - }, - content: { - flex: 1, - }, - listContent: { - paddingTop: SPACING.lg, - paddingBottom: SPACING.xxl, - }, - section: { - marginBottom: SPACING.xl, - }, - sectionHeader: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - paddingHorizontal: SPACING.lg, - marginBottom: SPACING.md, - gap: SPACING.sm, - }, - sectionTitle: { - ...TYPOGRAPHY.h3, - color: colors.text, - flex: 1, - }, - countBadge: { - backgroundColor: colors.surfaceLight, - paddingHorizontal: SPACING.sm + 2, - paddingVertical: SPACING.xs, - borderRadius: 12, - }, - countText: { - ...TYPOGRAPHY.meta, - color: colors.textSecondary, - }, - downloadCard: { - marginHorizontal: SPACING.lg, - marginBottom: SPACING.md, - }, - downloadHeader: { - flexDirection: 'row' as const, - alignItems: 'flex-start' as const, - marginBottom: SPACING.md, - }, - modelTypeIcon: { - width: 28, - height: 28, - borderRadius: 6, - backgroundColor: colors.surfaceLight, - alignItems: 'center' as const, - justifyContent: 'center' as const, - marginRight: SPACING.sm + 2, - }, - downloadInfo: { - flex: 1, - }, - fileName: { - ...TYPOGRAPHY.body, - color: colors.text, - marginBottom: SPACING.xs / 2, // 2px - minimal spacing between filename and author - }, - modelId: { - ...TYPOGRAPHY.meta, - color: colors.textSecondary, - }, - cancelButton: { - padding: SPACING.sm, - marginRight: -SPACING.sm, - marginTop: -SPACING.xs, - }, - deleteButton: { - padding: SPACING.sm, - marginRight: -SPACING.sm, - marginTop: -SPACING.xs, - }, - progressContainer: { - marginBottom: SPACING.md, - }, - progressBarBackground: { - height: 6, - backgroundColor: colors.surfaceLight, - borderRadius: 3, - marginBottom: SPACING.xs + 2, - overflow: 'hidden' as const, - }, - progressBarFill: { - height: '100%' as const, - backgroundColor: colors.primary, - borderRadius: 3, - }, - progressText: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - }, - downloadMeta: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - gap: SPACING.md, - }, - quantBadge: { - backgroundColor: colors.primary + '25', - paddingHorizontal: SPACING.sm, - paddingVertical: SPACING.xs, - borderRadius: 6, - }, - quantText: { - ...TYPOGRAPHY.meta, - color: colors.primary, - }, - imageBadge: { - backgroundColor: colors.info + '25', - }, - imageQuantText: { - color: colors.info, - }, - statusText: { - ...TYPOGRAPHY.meta, - color: colors.textSecondary, - }, - sizeText: { - ...TYPOGRAPHY.meta, - color: colors.textSecondary, - }, - dateText: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - }, - emptyCard: { - marginHorizontal: SPACING.lg, - alignItems: 'center' as const, - paddingVertical: SPACING.xxl, - gap: SPACING.sm, - }, - emptyText: { - ...TYPOGRAPHY.body, - color: colors.textSecondary, - marginTop: SPACING.sm, - }, - emptySubtext: { - ...TYPOGRAPHY.bodySmall, - color: colors.textMuted, - textAlign: 'center' as const, - }, - storageSection: { - paddingHorizontal: SPACING.lg, - }, - storageRow: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - gap: SPACING.sm, - backgroundColor: colors.surface, - padding: SPACING.lg, - borderRadius: 12, - }, - storageText: { - ...TYPOGRAPHY.bodySmall, - color: colors.textSecondary, - }, -}); diff --git a/src/screens/DownloadManagerScreen/index.tsx b/src/screens/DownloadManagerScreen/index.tsx new file mode 100644 index 00000000..d2857beb --- /dev/null +++ b/src/screens/DownloadManagerScreen/index.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { View, Text, FlatList, TouchableOpacity, RefreshControl } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import Icon from 'react-native-vector-icons/Feather'; +import { Card } from '../../components'; +import { CustomAlert, hideAlert } from '../../components/CustomAlert'; +import { useTheme, useThemedStyles } from '../../theme'; +import { useNavigation } from '@react-navigation/native'; +import { createStyles } from './styles'; +import { ActiveDownloadCard, CompletedDownloadCard, formatBytes } from './items'; +import { useDownloadManager } from './useDownloadManager'; + +export const DownloadManagerScreen: React.FC = () => { + const navigation = useNavigation(); + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const { + isRefreshing, + activeItems, + completedItems, + alertState, + setAlertState, + handleRefresh, + handleRemoveDownload, + handleDeleteItem, + totalStorageUsed, + } = useDownloadManager(); + + return ( + + + navigation.goBack()}> + + + Download Manager + + + + ( + + {/* Active Downloads */} + + + + Active Downloads + + {activeItems.length} + + + {activeItems.length > 0 ? ( + activeItems.map(item => ( + + + + )) + ) : ( + + + No active downloads + + )} + + + {/* Completed Downloads */} + + + + Downloaded Models + + {completedItems.length} + + + {completedItems.length > 0 ? ( + completedItems.map(item => ( + + + + )) + ) : ( + + + No models downloaded yet + + Go to the Models tab to browse and download models + + + )} + + + {/* Storage Info */} + {completedItems.length > 0 && ( + + + + + Total storage used: {formatBytes(totalStorageUsed)} + + + + )} + + )} + keyExtractor={item => item.key} + refreshControl={ + + } + contentContainerStyle={styles.listContent} + /> + + setAlertState(hideAlert())} + /> + + ); +}; diff --git a/src/screens/DownloadManagerScreen/items.tsx b/src/screens/DownloadManagerScreen/items.tsx new file mode 100644 index 00000000..d41ce3be --- /dev/null +++ b/src/screens/DownloadManagerScreen/items.tsx @@ -0,0 +1,242 @@ +import React from 'react'; +import { View, Text, TouchableOpacity } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { Card } from '../../components'; +import { useTheme, useThemedStyles } from '../../theme'; +import { hardwareService } from '../../services'; +import { DownloadedModel, BackgroundDownloadInfo, ONNXImageModel } from '../../types'; +import { createStyles } from './styles'; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export type DownloadItem = { + type: 'active' | 'completed'; + modelType: 'text' | 'image'; + downloadId?: number; + modelId: string; + fileName: string; + author: string; + quantization: string; + fileSize: number; + bytesDownloaded: number; + progress: number; + status: string; + downloadedAt?: string; + filePath?: string; +}; + +export interface DownloadItemsData { + downloadProgress: Record; + activeDownloads: BackgroundDownloadInfo[]; + activeBackgroundDownloads: Record; + downloadedModels: DownloadedModel[]; + downloadedImageModels: ONNXImageModel[]; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +export function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${(bytes / Math.pow(k, i)).toFixed(i > 1 ? 2 : 0)} ${sizes[i]}`; +} + +export function extractQuantization(fileName: string): string { + if (fileName.toLowerCase().includes('coreml')) return 'Core ML'; + const upperName = fileName.toUpperCase(); + const patterns = ['Q2_K', 'Q3_K_S', 'Q3_K_M', 'Q4_0', 'Q4_K_S', 'Q4_K_M', 'Q5_K_S', 'Q5_K_M', 'Q6_K', 'Q8_0']; + for (const pattern of patterns) { + if (upperName.includes(pattern.replace('_', ''))) return pattern; + if (upperName.includes(pattern)) return pattern; + } + const match = /[QqFf]\d+_?[KkMmSs]*/.exec(fileName); + return match ? match[0].toUpperCase() : 'Unknown'; +} + +export function getStatusText(status: string): string { + if (status === 'running') return 'Downloading...'; + if (status === 'pending') return 'Starting...'; + if (status === 'paused') return 'Paused'; + if (status === 'unknown') return 'Stuck - Remove & retry'; + return status; +} + +export function buildDownloadItems(data: DownloadItemsData): DownloadItem[] { + const items: DownloadItem[] = []; + + // Active RNFS downloads (iOS and foreground Android) + Object.entries(data.downloadProgress).forEach(([key, progress]) => { + const [_modelId, fileName] = key.split('/').slice(-2); + const fullModelId = key.substring(0, key.lastIndexOf('/')); + if (!fileName || !fullModelId || fileName === 'undefined' || fullModelId === 'undefined' || + Number.isNaN(progress.totalBytes) || Number.isNaN(progress.bytesDownloaded)) { + return; + } + items.push({ + type: 'active', + modelType: 'text', + modelId: fullModelId, + fileName, + author: fullModelId.split('/')[0] ?? 'Unknown', + quantization: extractQuantization(fileName), + fileSize: progress.totalBytes, + bytesDownloaded: progress.bytesDownloaded, + progress: progress.progress, + status: 'downloading', + }); + }); + + // Active background downloads (Android) + data.activeDownloads.forEach(download => { + const metadata = data.activeBackgroundDownloads[download.downloadId]; + if (!metadata) return; + const key = `${metadata.modelId}/${metadata.fileName}`; + if (data.downloadProgress[key]) return; + if (!metadata.fileName || !metadata.modelId || + metadata.fileName === 'undefined' || metadata.modelId === 'undefined' || + Number.isNaN(metadata.totalBytes) || Number.isNaN(download.bytesDownloaded)) { + return; + } + items.push({ + type: 'active', + modelType: 'text', + downloadId: download.downloadId, + modelId: metadata.modelId, + fileName: download.title ?? metadata.fileName, + author: metadata.author, + quantization: metadata.quantization, + fileSize: metadata.totalBytes, + bytesDownloaded: download.bytesDownloaded, + progress: metadata.totalBytes > 0 ? download.bytesDownloaded / metadata.totalBytes : 0, + status: download.status, + }); + }); + + // Completed text models + data.downloadedModels.forEach(model => { + const totalSize = hardwareService.getModelTotalSize(model); + items.push({ + type: 'completed', + modelType: 'text', + modelId: model.id, + fileName: model.fileName, + author: model.author, + quantization: model.quantization, + fileSize: totalSize, + bytesDownloaded: totalSize, + progress: 1, + status: 'completed', + downloadedAt: model.downloadedAt, + filePath: model.filePath, + }); + }); + + // Completed image models + data.downloadedImageModels.forEach(model => { + items.push({ + type: 'completed', + modelType: 'image', + modelId: model.id, + fileName: model.name, + author: 'Image Generation', + quantization: '', + fileSize: model.size, + bytesDownloaded: model.size, + progress: 1, + status: 'completed', + filePath: model.modelPath, + }); + }); + + return items; +} + +// ─── Item components ────────────────────────────────────────────────────────── + +interface ActiveDownloadCardProps { + item: DownloadItem; + onRemove: (item: DownloadItem) => void; +} + +export const ActiveDownloadCard: React.FC = ({ item, onRemove }) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + + return ( + + + + {item.fileName} + {item.author} + + onRemove(item)}> + + + + + + + + + {formatBytes(item.bytesDownloaded)} / {formatBytes(item.fileSize)} + + + + + {item.quantization} + + {getStatusText(item.status)} + + + ); +}; + +interface CompletedDownloadCardProps { + item: DownloadItem; + onDelete: (item: DownloadItem) => void; +} + +export const CompletedDownloadCard: React.FC = ({ item, onDelete }) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + + return ( + + + + + + + {item.fileName} + {item.author} + + onDelete(item)} + > + + + + + {!!item.quantization && ( + + + {item.quantization} + + + )} + {formatBytes(item.fileSize)} + {item.downloadedAt && ( + {new Date(item.downloadedAt).toLocaleDateString()} + )} + + + ); +}; diff --git a/src/screens/DownloadManagerScreen/styles.ts b/src/screens/DownloadManagerScreen/styles.ts new file mode 100644 index 00000000..465a67f9 --- /dev/null +++ b/src/screens/DownloadManagerScreen/styles.ts @@ -0,0 +1,187 @@ +import type { ThemeColors, ThemeShadows } from '../../theme'; +import { TYPOGRAPHY, SPACING } from '../../constants'; + +export const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + header: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + paddingHorizontal: SPACING.lg, + paddingVertical: SPACING.md, + borderBottomWidth: 1, + borderBottomColor: colors.border, + backgroundColor: colors.surface, + ...shadows.small, + zIndex: 1, + }, + backButton: { + padding: SPACING.sm, + marginRight: SPACING.sm, + }, + title: { + ...TYPOGRAPHY.h2, + flex: 1, + color: colors.text, + }, + headerSpacer: { + width: 40, + }, + content: { + flex: 1, + }, + listContent: { + paddingTop: SPACING.lg, + paddingBottom: SPACING.xxl, + }, + section: { + marginBottom: SPACING.xl, + }, + sectionHeader: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + paddingHorizontal: SPACING.lg, + marginBottom: SPACING.md, + gap: SPACING.sm, + }, + sectionTitle: { + ...TYPOGRAPHY.h3, + color: colors.text, + flex: 1, + }, + countBadge: { + backgroundColor: colors.surfaceLight, + paddingHorizontal: SPACING.sm + 2, + paddingVertical: SPACING.xs, + borderRadius: 12, + }, + countText: { + ...TYPOGRAPHY.meta, + color: colors.textSecondary, + }, + downloadCard: { + marginHorizontal: SPACING.lg, + marginBottom: SPACING.md, + }, + downloadHeader: { + flexDirection: 'row' as const, + alignItems: 'flex-start' as const, + marginBottom: SPACING.md, + }, + modelTypeIcon: { + width: 28, + height: 28, + borderRadius: 6, + backgroundColor: colors.surfaceLight, + alignItems: 'center' as const, + justifyContent: 'center' as const, + marginRight: SPACING.sm + 2, + }, + downloadInfo: { + flex: 1, + }, + fileName: { + ...TYPOGRAPHY.body, + color: colors.text, + marginBottom: SPACING.xs / 2, + }, + modelId: { + ...TYPOGRAPHY.meta, + color: colors.textSecondary, + }, + cancelButton: { + padding: SPACING.sm, + marginRight: -SPACING.sm, + marginTop: -SPACING.xs, + }, + deleteButton: { + padding: SPACING.sm, + marginRight: -SPACING.sm, + marginTop: -SPACING.xs, + }, + progressContainer: { + marginBottom: SPACING.md, + }, + progressBarBackground: { + height: 6, + backgroundColor: colors.surfaceLight, + borderRadius: 3, + marginBottom: SPACING.xs + 2, + overflow: 'hidden' as const, + }, + progressBarFill: { + height: '100%' as const, + backgroundColor: colors.primary, + borderRadius: 3, + }, + progressText: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + }, + downloadMeta: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: SPACING.md, + }, + quantBadge: { + backgroundColor: `${colors.primary}25`, + paddingHorizontal: SPACING.sm, + paddingVertical: SPACING.xs, + borderRadius: 6, + }, + quantText: { + ...TYPOGRAPHY.meta, + color: colors.primary, + }, + imageBadge: { + backgroundColor: `${colors.info}25`, + }, + imageQuantText: { + color: colors.info, + }, + statusText: { + ...TYPOGRAPHY.meta, + color: colors.textSecondary, + }, + sizeText: { + ...TYPOGRAPHY.meta, + color: colors.textSecondary, + }, + dateText: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + }, + emptyCard: { + marginHorizontal: SPACING.lg, + alignItems: 'center' as const, + paddingVertical: SPACING.xxl, + gap: SPACING.sm, + }, + emptyText: { + ...TYPOGRAPHY.body, + color: colors.textSecondary, + marginTop: SPACING.sm, + }, + emptySubtext: { + ...TYPOGRAPHY.bodySmall, + color: colors.textMuted, + textAlign: 'center' as const, + }, + storageSection: { + paddingHorizontal: SPACING.lg, + }, + storageRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: SPACING.sm, + backgroundColor: colors.surface, + padding: SPACING.lg, + borderRadius: 12, + }, + storageText: { + ...TYPOGRAPHY.bodySmall, + color: colors.textSecondary, + }, +}); diff --git a/src/screens/DownloadManagerScreen/useDownloadManager.ts b/src/screens/DownloadManagerScreen/useDownloadManager.ts new file mode 100644 index 00000000..46043b82 --- /dev/null +++ b/src/screens/DownloadManagerScreen/useDownloadManager.ts @@ -0,0 +1,274 @@ +import { useState, useRef, useEffect, useCallback } from 'react'; +import { AlertState, showAlert, hideAlert, initialAlertState } from '../../components/CustomAlert'; +import { useAppStore } from '../../stores'; +import { + modelManager, + backgroundDownloadService, + activeModelService, + hardwareService, +} from '../../services'; +import { DownloadedModel, BackgroundDownloadInfo, ONNXImageModel } from '../../types'; +import { DownloadItem, DownloadItemsData, buildDownloadItems, formatBytes } from './items'; + +export interface UseDownloadManagerResult { + isRefreshing: boolean; + activeItems: DownloadItem[]; + completedItems: DownloadItem[]; + alertState: AlertState; + setAlertState: (state: AlertState) => void; + handleRefresh: () => Promise; + handleRemoveDownload: (item: DownloadItem) => void; + handleDeleteItem: (item: DownloadItem) => void; + totalStorageUsed: number; +} + +export function useDownloadManager(): UseDownloadManagerResult { + const [isRefreshing, setIsRefreshing] = useState(false); + const [activeDownloads, setActiveDownloads] = useState([]); + const [alertState, setAlertState] = useState(initialAlertState); + const cancelledKeysRef = useRef>(new Set()); + + const { + downloadedModels, + setDownloadedModels, + downloadProgress, + setDownloadProgress, + removeDownloadedModel, + activeBackgroundDownloads, + setBackgroundDownload, + downloadedImageModels, + setDownloadedImageModels, + removeDownloadedImageModel, + removeImageModelDownloading, + } = useAppStore(); + + // Load active background downloads on mount + start/stop polling + useEffect(() => { + loadActiveDownloads(); + + if (backgroundDownloadService.isAvailable()) { + modelManager.startBackgroundDownloadPolling(); + } + + return () => { + modelManager.stopBackgroundDownloadPolling(); + }; + }, []); + + // Subscribe to background download service events + useEffect(() => { + if (!backgroundDownloadService.isAvailable()) return; + + const unsubProgress = backgroundDownloadService.onAnyProgress((event) => { + const key = `${event.modelId}/${event.fileName}`; + if (cancelledKeysRef.current.has(key)) return; + setDownloadProgress(key, { + progress: event.totalBytes > 0 ? event.bytesDownloaded / event.totalBytes : 0, + bytesDownloaded: event.bytesDownloaded, + totalBytes: event.totalBytes, + }); + }); + + const unsubComplete = backgroundDownloadService.onAnyComplete(async (event) => { + setDownloadProgress(`${event.modelId}/${event.fileName}`, null); + await loadActiveDownloads(); + const models = await modelManager.getDownloadedModels(); + setDownloadedModels(models); + }); + + const unsubError = backgroundDownloadService.onAnyError((event) => { + setDownloadProgress(`${event.modelId}/${event.fileName}`, null); + setBackgroundDownload(event.downloadId, null); + setAlertState(showAlert('Download Failed', event.reason || 'Unknown error')); + }); + + return () => { + unsubProgress(); + unsubComplete(); + unsubError(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const loadActiveDownloads = async () => { + if (backgroundDownloadService.isAvailable()) { + const downloads = await modelManager.getActiveBackgroundDownloads(); + setActiveDownloads( + downloads.filter( + d => d.status === 'running' || d.status === 'pending' || d.status === 'paused', + ), + ); + } + }; + + const handleRefresh = useCallback(async () => { + setIsRefreshing(true); + await loadActiveDownloads(); + const models = await modelManager.getDownloadedModels(); + setDownloadedModels(models); + const imageModels = await modelManager.getDownloadedImageModels(); + setDownloadedImageModels(imageModels); + setIsRefreshing(false); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const executeRemoveDownload = async (item: DownloadItem) => { + setAlertState(hideAlert()); + try { + const key = `${item.modelId}/${item.fileName}`; + cancelledKeysRef.current.add(key); + + // Clear from progress tracking immediately (optimistic update) + setDownloadProgress(key, null); + + // Find downloadId — either from the item or by cross-referencing active downloads + let downloadId = item.downloadId; + if (!downloadId) { + const match = activeDownloads.find(d => { + const meta = activeBackgroundDownloads[d.downloadId]; + return meta?.fileName === item.fileName; + }); + if (match) downloadId = match.downloadId; + } + + // Remove from local activeDownloads state immediately + if (downloadId) { + setActiveDownloads(prev => prev.filter(d => d.downloadId !== downloadId)); + setBackgroundDownload(downloadId, null); + await modelManager.cancelBackgroundDownload(downloadId); + } + + // Clear image model download state so ModelsScreen unblocks + if (item.modelId.startsWith('image:')) { + const actualModelId = item.modelId.replace('image:', ''); + removeImageModelDownloading(actualModelId); + } + + // Wait for native cancellation to complete, then reload + const dlId = downloadId; + const capturedKey = key; + setTimeout(() => { + loadActiveDownloads() + .then(() => { + if (dlId) cancelledKeysRef.current.delete(capturedKey); + }) + .catch(err => { + console.error('[DownloadManager] Failed to reload active downloads:', err); + }); + }, 1000); + } catch (error) { + console.error('[DownloadManager] Failed to remove download:', error); + setAlertState(showAlert('Error', 'Failed to remove download')); + } + }; + + const handleRemoveDownload = (item: DownloadItem) => { + setAlertState( + showAlert( + 'Remove Download', + 'Are you sure you want to remove this download?', + [ + { text: 'No', style: 'cancel' }, + { + text: 'Yes', + style: 'destructive', + onPress: async () => { await executeRemoveDownload(item); }, + }, + ], + ), + ); + }; + + const executeDeleteModel = async (model: DownloadedModel) => { + setAlertState(hideAlert()); + try { + await modelManager.deleteModel(model.id); + removeDownloadedModel(model.id); + } catch (error) { + console.error('[DownloadManager] Failed to delete model:', error); + setAlertState(showAlert('Error', 'Failed to delete model')); + } + }; + + const handleDeleteModel = (model: DownloadedModel) => { + const totalSize = hardwareService.getModelTotalSize(model); + setAlertState( + showAlert( + 'Delete Model', + `Are you sure you want to delete "${model.fileName}"? This will free up ${formatBytes(totalSize)}.`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { await executeDeleteModel(model); }, + }, + ], + ), + ); + }; + + const executeDeleteImageModel = async (model: ONNXImageModel) => { + setAlertState(hideAlert()); + try { + await activeModelService.unloadImageModel(); + await modelManager.deleteImageModel(model.id); + removeDownloadedImageModel(model.id); + } catch (error) { + console.error('[DownloadManager] Failed to delete image model:', error); + setAlertState(showAlert('Error', 'Failed to delete image model')); + } + }; + + const handleDeleteImageModel = (model: ONNXImageModel) => { + setAlertState( + showAlert( + 'Delete Image Model', + `Are you sure you want to delete "${model.name}"? This will free up ${formatBytes(model.size)}.`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { await executeDeleteImageModel(model); }, + }, + ], + ), + ); + }; + + const handleDeleteItem = (item: DownloadItem) => { + if (item.modelType === 'image') { + const model = downloadedImageModels.find(m => m.id === item.modelId); + if (model) handleDeleteImageModel(model); + } else { + const model = downloadedModels.find(m => m.id === item.modelId); + if (model) handleDeleteModel(model); + } + }; + + // Build items from store state + const data: DownloadItemsData = { + downloadProgress, + activeDownloads, + activeBackgroundDownloads, + downloadedModels, + downloadedImageModels, + }; + const items = buildDownloadItems(data); + const activeItems = items.filter(i => i.type === 'active'); + const completedItems = items.filter(i => i.type === 'completed'); + const totalStorageUsed = completedItems.reduce((sum, item) => sum + item.fileSize, 0); + + return { + isRefreshing, + activeItems, + completedItems, + alertState, + setAlertState, + handleRefresh, + handleRemoveDownload, + handleDeleteItem, + totalStorageUsed, + }; +} diff --git a/src/screens/GalleryScreen.tsx b/src/screens/GalleryScreen.tsx deleted file mode 100644 index 03c490e9..00000000 --- a/src/screens/GalleryScreen.tsx +++ /dev/null @@ -1,794 +0,0 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { - View, - Text, - StyleSheet, - FlatList, - Image, - TouchableOpacity, - Modal, - ScrollView, - Dimensions, - Platform, - PermissionsAndroid, - Share, -} from 'react-native'; -import Icon from 'react-native-vector-icons/Feather'; -import RNFS from 'react-native-fs'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; -import { AnimatedEntry } from '../components/AnimatedEntry'; -import { CustomAlert, showAlert, hideAlert, AlertState, initialAlertState } from '../components/CustomAlert'; -import { useTheme, useThemedStyles } from '../theme'; -import type { ThemeColors, ThemeShadows } from '../theme'; -import { TYPOGRAPHY, SPACING } from '../constants'; -import { useAppStore, useChatStore } from '../stores'; -import { imageGenerationService, onnxImageGeneratorService } from '../services'; -import type { ImageGenerationState } from '../services'; -import { GeneratedImage } from '../types'; -import { RootStackParamList } from '../navigation/types'; - -type GalleryScreenRouteProp = RouteProp; - -const { width: screenWidth } = Dimensions.get('window'); -const COLUMN_COUNT = 3; -const GRID_SPACING = 4; -const CELL_SIZE = (screenWidth - GRID_SPACING * (COLUMN_COUNT + 1)) / COLUMN_COUNT; - -export const GalleryScreen: React.FC = () => { - const navigation = useNavigation(); - const route = useRoute(); - const conversationId = route.params?.conversationId; - - const { generatedImages, removeGeneratedImage } = useAppStore(); - - const { colors } = useTheme(); - const styles = useThemedStyles(createStyles); - - // Multi-select mode state - const [isSelectMode, setIsSelectMode] = useState(false); - const [selectedIds, setSelectedIds] = useState>(new Set()); - - // Collect image attachment IDs from conversation messages for matching - const conversations = useChatStore(s => s.conversations); - const chatImageIds = useMemo(() => { - if (!conversationId) return null; - const convo = conversations.find(c => c.id === conversationId); - if (!convo) return new Set(); - const ids = new Set(); - for (const msg of convo.messages) { - if (msg.attachments) { - for (const att of msg.attachments) { - if (att.type === 'image') ids.add(att.id); - } - } - } - return ids; - }, [conversationId, conversations]); - - // Filter images when viewing from a specific conversation - // Match by conversationId field OR by image ID found in chat message attachments - const displayImages = useMemo(() => { - if (!conversationId) return generatedImages; - return generatedImages.filter( - img => img.conversationId === conversationId || (chatImageIds && chatImageIds.has(img.id)) - ); - }, [generatedImages, conversationId, chatImageIds]); - - const screenTitle = conversationId ? 'Chat Images' : 'Gallery'; - - const [selectedImage, setSelectedImage] = useState(null); - const [showDetails, setShowDetails] = useState(false); - const [alertState, setAlertState] = useState(initialAlertState); - - // Subscribe to image generation service for active generation banner - const [imageGenState, setImageGenState] = useState( - imageGenerationService.getState() - ); - - useEffect(() => { - const unsubscribe = imageGenerationService.subscribe((state) => { - setImageGenState(state); - }); - return unsubscribe; - }, []); - - // Sync images from disk into store (adds any missing ones) - useEffect(() => { - const syncFromDisk = async () => { - try { - const diskImages = await onnxImageGeneratorService.getGeneratedImages(); - if (diskImages.length > 0) { - const { generatedImages: storeImages, addGeneratedImage } = useAppStore.getState(); - const existingIds = new Set(storeImages.map(img => img.id)); - for (const img of diskImages) { - if (!existingIds.has(img.id)) { - addGeneratedImage(img); - } - } - } - } catch { - // Silently fail - images will be added as they're generated - } - }; - syncFromDisk(); - }, []); - - const handleDelete = useCallback((image: GeneratedImage) => { - setAlertState(showAlert( - 'Delete Image', - 'Are you sure you want to delete this image?', - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', - style: 'destructive', - onPress: async () => { - setAlertState(hideAlert()); - await onnxImageGeneratorService.deleteGeneratedImage(image.id); - removeGeneratedImage(image.id); - if (selectedImage?.id === image.id) { - setSelectedImage(null); - } - }, - }, - ] - )); - }, [selectedImage, removeGeneratedImage]); - - const toggleSelectMode = useCallback(() => { - setIsSelectMode(prev => { - if (prev) { - setSelectedIds(new Set()); - } - return !prev; - }); - }, []); - - const toggleImageSelection = useCallback((imageId: string) => { - setSelectedIds(prev => { - const newSet = new Set(prev); - if (newSet.has(imageId)) { - newSet.delete(imageId); - } else { - newSet.add(imageId); - } - return newSet; - }); - }, []); - - const handleDeleteSelected = useCallback(() => { - if (selectedIds.size === 0) return; - - setAlertState(showAlert( - 'Delete Images', - `Are you sure you want to delete ${selectedIds.size} image${selectedIds.size > 1 ? 's' : ''}?`, - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', - style: 'destructive', - onPress: async () => { - setAlertState(hideAlert()); - for (const imageId of selectedIds) { - await onnxImageGeneratorService.deleteGeneratedImage(imageId); - removeGeneratedImage(imageId); - } - setSelectedIds(new Set()); - setIsSelectMode(false); - }, - }, - ] - )); - }, [selectedIds, removeGeneratedImage]); - - const selectAll = useCallback(() => { - setSelectedIds(new Set(displayImages.map(img => img.id))); - }, [displayImages]); - - const handleSaveImage = useCallback(async (image: GeneratedImage) => { - try { - if (Platform.OS === 'ios') { - // On iOS, open the native share sheet so the user can save to Photos - await Share.share({ - url: `file://${image.imagePath}`, - }); - return; - } - - // Android: save to Pictures directory - await PermissionsAndroid.request( - PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, - { - title: 'Storage Permission', - message: 'App needs access to save images', - buttonNeutral: 'Ask Later', - buttonNegative: 'Cancel', - buttonPositive: 'OK', - } - ); - - const sourcePath = image.imagePath; - const picturesDir = `${RNFS.ExternalStorageDirectoryPath}/Pictures/OffgridMobile`; - - if (!(await RNFS.exists(picturesDir))) { - await RNFS.mkdir(picturesDir); - } - - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const fileName = `generated_${timestamp}.png`; - const destPath = `${picturesDir}/${fileName}`; - - await RNFS.copyFile(sourcePath, destPath); - - setAlertState(showAlert('Image Saved', `Saved to Pictures/OffgridMobile/${fileName}`)); - } catch (error: any) { - setAlertState(showAlert('Error', `Failed to save image: ${error?.message || 'Unknown error'}`)); - } - }, []); - - const handleCancelGeneration = useCallback(() => { - imageGenerationService.cancelGeneration().catch(() => {}); - }, []); - - const formatDate = (dateStr: string) => { - const ts = Number(dateStr); - const date = isNaN(ts) ? new Date(dateStr) : new Date(ts); - return date.toLocaleDateString(undefined, { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - }; - - const renderGridItem = ({ item, index }: { item: GeneratedImage; index: number }) => { - const isSelected = selectedIds.has(item.id); - - return ( - - { - if (isSelectMode) { - toggleImageSelection(item.id); - } else { - setSelectedImage(item); - } - }} - onLongPress={() => { - if (!isSelectMode) { - setIsSelectMode(true); - setSelectedIds(new Set([item.id])); - } - }} - activeOpacity={0.8} - > - - {isSelectMode && ( - - - {isSelected && } - - - )} - - - ); - }; - - return ( - - {/* Header */} - - {isSelectMode ? ( - <> - - - - - {selectedIds.size} selected - - - All - - - - - - ) : ( - <> - navigation.goBack()} - > - - - {screenTitle} - - {displayImages.length} - - {displayImages.length > 0 && ( - - - - )} - - )} - - - {/* Active generation banner */} - {imageGenState.isGenerating && ( - - - {imageGenState.previewPath && ( - - )} - - - {imageGenState.previewPath ? 'Refining...' : 'Generating...'} - - - {imageGenState.prompt} - - {imageGenState.progress && ( - - - - )} - - {imageGenState.progress && ( - - {imageGenState.progress.step}/{imageGenState.progress.totalSteps} - - )} - - - - - - )} - - {/* Grid */} - {displayImages.length === 0 ? ( - - - - {conversationId ? 'No images in this chat' : 'No generated images yet'} - - - Generate images from any chat conversation. - - - ) : ( - item.id} - numColumns={COLUMN_COUNT} - contentContainerStyle={styles.gridContainer} - columnWrapperStyle={styles.gridRow} - showsVerticalScrollIndicator={false} - /> - )} - - {/* Fullscreen Image Viewer Modal */} - { - setSelectedImage(null); - setShowDetails(false); - }} - > - - { - setSelectedImage(null); - setShowDetails(false); - }} - /> - {selectedImage && ( - - {!showDetails && ( - - )} - - {/* Details bottom sheet (replaces image view) */} - {showDetails && ( - - - Image Details - setShowDetails(false)}> - Done - - - - - - PROMPT - - {selectedImage.prompt} - - - {selectedImage.negativePrompt ? ( - - NEGATIVE - - {selectedImage.negativePrompt} - - - ) : null} - - - {selectedImage.steps} steps - - - - {selectedImage.width}x{selectedImage.height} - - - - Seed: {selectedImage.seed} - - - - {formatDate(selectedImage.createdAt)} - - - - )} - - {/* Action buttons */} - - setShowDetails(!showDetails)} - > - - Info - - handleSaveImage(selectedImage)} - > - - Save - - handleDelete(selectedImage)} - > - - Delete - - { - setSelectedImage(null); - setShowDetails(false); - }} - > - - Close - - - - )} - - - setAlertState(hideAlert())} - /> - - ); -}; - -const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ - container: { - flex: 1, - backgroundColor: colors.background, - }, - header: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - paddingHorizontal: SPACING.lg, - paddingVertical: SPACING.md, - borderBottomWidth: 1, - borderBottomColor: colors.border, - backgroundColor: colors.surface, - ...shadows.small, - zIndex: 1, - }, - closeButton: { - padding: SPACING.xs, - marginRight: SPACING.md, - }, - title: { - ...TYPOGRAPHY.h2, - color: colors.text, - flex: 1, - }, - countBadge: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - marginRight: SPACING.sm, - }, - headerButton: { - padding: SPACING.sm, - marginLeft: SPACING.xs, - }, - headerButtonDisabled: { - opacity: 0.5, - }, - headerButtonText: { - ...TYPOGRAPHY.body, - color: colors.primary, - }, - // Active generation banner - genBanner: { - backgroundColor: colors.surface, - marginHorizontal: SPACING.md, - marginTop: SPACING.md, - borderRadius: SPACING.md, - padding: SPACING.md, - }, - genBannerRow: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - gap: SPACING.sm + 2, // 10 - }, - genPreview: { - width: 40, - height: 40, - borderRadius: SPACING.sm, - backgroundColor: colors.surfaceLight, - }, - genBannerInfo: { - flex: 1, - }, - genBannerTitle: { - ...TYPOGRAPHY.body, - color: colors.text, - marginTop: 0, - }, - genBannerPrompt: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - marginTop: 2, - }, - genProgressBar: { - height: 4, - backgroundColor: colors.surfaceLight, - borderRadius: 2, - marginTop: 6, - overflow: 'hidden' as const, - }, - genProgressFill: { - height: '100%' as const, - backgroundColor: colors.primary, - borderRadius: 2, - }, - genSteps: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - }, - genCancelButton: { - padding: SPACING.sm - 2, // 6 - }, - // Grid - gridContainer: { - padding: GRID_SPACING, - }, - gridRow: { - gap: GRID_SPACING, - marginBottom: GRID_SPACING, - }, - gridItem: { - width: CELL_SIZE, - height: CELL_SIZE, - borderRadius: SPACING.sm, - overflow: 'hidden' as const, - backgroundColor: colors.surfaceLight, - }, - gridImage: { - width: '100%' as const, - height: '100%' as const, - }, - selectionOverlay: { - ...StyleSheet.absoluteFillObject, - justifyContent: 'flex-start' as const, - alignItems: 'flex-end' as const, - padding: SPACING.sm - 2, // 6 - }, - selectionOverlaySelected: { - backgroundColor: 'rgba(99, 102, 241, 0.25)', - }, - checkbox: { - width: 22, - height: 22, - borderRadius: 11, - borderWidth: 2, - borderColor: '#fff', - backgroundColor: 'rgba(0, 0, 0, 0.3)', - justifyContent: 'center' as const, - alignItems: 'center' as const, - }, - checkboxSelected: { - backgroundColor: colors.primary, - borderColor: colors.primary, - }, - // Empty state - emptyContainer: { - flex: 1, - justifyContent: 'center' as const, - alignItems: 'center' as const, - padding: SPACING.xxl, - }, - emptyTitle: { - ...TYPOGRAPHY.body, - color: colors.text, - marginTop: SPACING.lg, - }, - emptyText: { - ...TYPOGRAPHY.bodySmall, - color: colors.textMuted, - textAlign: 'center' as const, - marginTop: SPACING.sm, - }, - // Fullscreen viewer - viewerContainer: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.95)', - justifyContent: 'center' as const, - alignItems: 'center' as const, - }, - viewerBackdrop: { - ...StyleSheet.absoluteFillObject, - }, - viewerContent: { - width: '100%' as const, - height: '100%' as const, - justifyContent: 'center' as const, - alignItems: 'center' as const, - }, - fullscreenImage: { - width: Dimensions.get('window').width, - height: Dimensions.get('window').height * 0.65, - }, - viewerActions: { - flexDirection: 'row' as const, - position: 'absolute' as const, - bottom: 60, - gap: SPACING.lg + 4, // 20 - }, - viewerButton: { - alignItems: 'center' as const, - padding: SPACING.md + 2, // 14 - backgroundColor: colors.surface, - borderRadius: SPACING.md + 2, // 14 - minWidth: 70, - }, - viewerButtonActive: { - borderWidth: 1, - borderColor: colors.primary, - }, - viewerButtonText: { - ...TYPOGRAPHY.meta, - color: colors.text, - marginTop: SPACING.xs, - }, - // Details sheet (inside fullscreen viewer) - detailsSheet: { - flex: 1, - width: '100%' as const, - backgroundColor: colors.surface, - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - marginTop: 60, - overflow: 'hidden' as const, - }, - detailsSheetHeader: { - flexDirection: 'row' as const, - justifyContent: 'space-between' as const, - alignItems: 'center' as const, - paddingHorizontal: SPACING.lg, - paddingVertical: SPACING.md, - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - detailsSheetTitle: { - ...TYPOGRAPHY.h3, - color: colors.text, - }, - detailsSheetClose: { - ...TYPOGRAPHY.body, - color: colors.primary, - }, - detailsPreview: { - width: '100%' as const, - height: 200, - backgroundColor: colors.background, - }, - detailsContent: { - padding: SPACING.lg, - }, - detailRow: { - marginBottom: SPACING.sm + 2, // 10 - }, - detailLabel: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - marginBottom: 2, - }, - detailValue: { - ...TYPOGRAPHY.body, - color: colors.text, - lineHeight: 20, - }, - detailsMetaRow: { - flexDirection: 'row' as const, - flexWrap: 'wrap' as const, - gap: SPACING.sm, - marginTop: SPACING.xs, - }, - detailChip: { - backgroundColor: colors.surfaceLight, - paddingHorizontal: SPACING.sm + 2, // 10 - paddingVertical: SPACING.xs, - borderRadius: SPACING.sm, - }, - detailChipText: { - ...TYPOGRAPHY.meta, - color: colors.textSecondary, - }, - detailDate: { - ...TYPOGRAPHY.meta, - color: colors.textMuted, - marginTop: SPACING.sm + 2, // 10 - }, -}); diff --git a/src/screens/GalleryScreen/FullscreenViewer.tsx b/src/screens/GalleryScreen/FullscreenViewer.tsx new file mode 100644 index 00000000..37ad04cc --- /dev/null +++ b/src/screens/GalleryScreen/FullscreenViewer.tsx @@ -0,0 +1,125 @@ +import React from 'react'; +import { + View, + Text, + Image, + TouchableOpacity, + Modal, + ScrollView, +} from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { useTheme, useThemedStyles } from '../../theme'; +import { GeneratedImage } from '../../types'; +import { createStyles } from './styles'; +import { formatDate } from './useGalleryActions'; + +interface FullscreenViewerProps { + image: GeneratedImage | null; + showDetails: boolean; + onClose: () => void; + onToggleDetails: () => void; + onSave: (image: GeneratedImage) => void; + onDelete: (image: GeneratedImage) => void; +} + +export const FullscreenViewer: React.FC = ({ + image, + showDetails, + onClose, + onToggleDetails, + onSave, + onDelete, +}) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + + return ( + + + + {image && ( + + {!showDetails && ( + + )} + {showDetails && ( + + + Image Details + + Done + + + + + + PROMPT + {image.prompt} + + {image.negativePrompt ? ( + + NEGATIVE + {image.negativePrompt} + + ) : null} + + + {image.steps} steps + + + {image.width}x{image.height} + + + Seed: {image.seed} + + + {formatDate(image.createdAt)} + + + )} + + + + + Info + + + onSave(image)}> + + Save + + onDelete(image)}> + + Delete + + + + Close + + + + )} + + + ); +}; diff --git a/src/screens/GalleryScreen/GridItem.tsx b/src/screens/GalleryScreen/GridItem.tsx new file mode 100644 index 00000000..e1b07407 --- /dev/null +++ b/src/screens/GalleryScreen/GridItem.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { View, Image, TouchableOpacity } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { AnimatedEntry } from '../../components/AnimatedEntry'; +import { useThemedStyles } from '../../theme'; +import { GeneratedImage } from '../../types'; +import { createStyles } from './styles'; + +interface GalleryGridItemProps { + item: GeneratedImage; + index: number; + isSelectMode: boolean; + isSelected: boolean; + onPress: () => void; + onLongPress: () => void; +} + +export const GalleryGridItem: React.FC = ({ + item, + index, + isSelectMode, + isSelected, + onPress, + onLongPress, +}) => { + const styles = useThemedStyles(createStyles); + + return ( + + + + {isSelectMode && ( + + + {isSelected && } + + + )} + + + ); +}; diff --git a/src/screens/GalleryScreen/index.tsx b/src/screens/GalleryScreen/index.tsx new file mode 100644 index 00000000..08242f49 --- /dev/null +++ b/src/screens/GalleryScreen/index.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import { View, Text, Image, TouchableOpacity, FlatList } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; +import { CustomAlert, hideAlert } from '../../components/CustomAlert'; +import { useTheme, useThemedStyles } from '../../theme'; +import { GeneratedImage } from '../../types'; +import { RootStackParamList } from '../../navigation/types'; +import { createStyles, COLUMN_COUNT } from './styles'; +import { useGalleryActions } from './useGalleryActions'; +import { GalleryGridItem } from './GridItem'; +import { FullscreenViewer } from './FullscreenViewer'; + +type GalleryScreenRouteProp = RouteProp; + +export const GalleryScreen: React.FC = () => { + const navigation = useNavigation(); + const route = useRoute(); + const conversationId = route.params?.conversationId; + + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + + const { + isSelectMode, + selectedIds, + selectedImage, + setSelectedImage, + showDetails, + setShowDetails, + alertState, + setAlertState, + imageGenState, + displayImages, + handleDelete, + toggleSelectMode, + toggleImageSelection, + handleDeleteSelected, + selectAll, + handleSaveImage, + handleCancelGeneration, + closeViewer, + } = useGalleryActions(conversationId); + + const screenTitle = conversationId ? 'Chat Images' : 'Gallery'; + + const renderGridItem = ({ item, index }: { item: GeneratedImage; index: number }) => ( + { + if (isSelectMode) { + toggleImageSelection(item.id); + } else { + setSelectedImage(item); + } + }} + onLongPress={() => { + if (!isSelectMode) { + toggleSelectMode(); + toggleImageSelection(item.id); + } + }} + /> + ); + + return ( + + + {isSelectMode ? ( + <> + + + + {selectedIds.size} selected + + All + + + + + + ) : ( + <> + navigation.goBack()}> + + + {screenTitle} + {displayImages.length} + {displayImages.length > 0 && ( + + + + )} + + )} + + + {imageGenState.isGenerating && ( + + + {imageGenState.previewPath && ( + + )} + + + {imageGenState.previewPath ? 'Refining...' : 'Generating...'} + + + {imageGenState.prompt} + + {imageGenState.progress && ( + + + + )} + + {imageGenState.progress && ( + + {imageGenState.progress.step}/{imageGenState.progress.totalSteps} + + )} + + + + + + )} + + {displayImages.length === 0 ? ( + + + + {conversationId ? 'No images in this chat' : 'No generated images yet'} + + Generate images from any chat conversation. + + ) : ( + item.id} + numColumns={COLUMN_COUNT} + contentContainerStyle={styles.gridContainer} + columnWrapperStyle={styles.gridRow} + showsVerticalScrollIndicator={false} + /> + )} + + setShowDetails(prev => !prev)} + onSave={handleSaveImage} + onDelete={handleDelete} + /> + setAlertState(hideAlert())} + /> + + ); +}; diff --git a/src/screens/GalleryScreen/styles.ts b/src/screens/GalleryScreen/styles.ts new file mode 100644 index 00000000..207497b9 --- /dev/null +++ b/src/screens/GalleryScreen/styles.ts @@ -0,0 +1,208 @@ +import { StyleSheet, Dimensions } from 'react-native'; +import type { ThemeColors, ThemeShadows } from '../../theme'; +import { TYPOGRAPHY, SPACING } from '../../constants'; + +const { width: screenWidth } = Dimensions.get('window'); +export const COLUMN_COUNT = 3; +export const GRID_SPACING = 4; +export const CELL_SIZE = (screenWidth - GRID_SPACING * (COLUMN_COUNT + 1)) / COLUMN_COUNT; + +const createHeaderStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + header: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + paddingHorizontal: SPACING.lg, + paddingVertical: SPACING.md, + borderBottomWidth: 1, + borderBottomColor: colors.border, + backgroundColor: colors.surface, + ...shadows.small, + zIndex: 1, + }, + closeButton: { + padding: SPACING.xs, + marginRight: SPACING.md, + }, + title: { + ...TYPOGRAPHY.h2, + color: colors.text, + flex: 1, + }, + countBadge: { + ...TYPOGRAPHY.meta, + color: colors.textMuted, + marginRight: SPACING.sm, + }, + headerButton: { + padding: SPACING.sm, + marginLeft: SPACING.xs, + }, + headerButtonDisabled: { + opacity: 0.5, + }, + headerButtonText: { + ...TYPOGRAPHY.body, + color: colors.primary, + }, +}); + +const createGridStyles = (colors: ThemeColors) => ({ + genBanner: { + backgroundColor: colors.surface, + marginHorizontal: SPACING.md, + marginTop: SPACING.md, + borderRadius: SPACING.md, + padding: SPACING.md, + }, + genBannerRow: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: SPACING.sm + 2, + }, + genPreview: { + width: 40, + height: 40, + borderRadius: SPACING.sm, + backgroundColor: colors.surfaceLight, + }, + genBannerInfo: { flex: 1 }, + genBannerTitle: { ...TYPOGRAPHY.body, color: colors.text, marginTop: 0 }, + genBannerPrompt: { ...TYPOGRAPHY.meta, color: colors.textMuted, marginTop: 2 }, + genProgressBar: { + height: 4, + backgroundColor: colors.surfaceLight, + borderRadius: 2, + marginTop: 6, + overflow: 'hidden' as const, + }, + genProgressFill: { height: '100%' as const, backgroundColor: colors.primary, borderRadius: 2 }, + genSteps: { ...TYPOGRAPHY.meta, color: colors.textMuted }, + genCancelButton: { padding: SPACING.sm - 2 }, + gridContainer: { padding: GRID_SPACING }, + gridRow: { gap: GRID_SPACING, marginBottom: GRID_SPACING }, + gridItem: { + width: CELL_SIZE, + height: CELL_SIZE, + borderRadius: SPACING.sm, + overflow: 'hidden' as const, + backgroundColor: colors.surfaceLight, + }, + gridImage: { width: '100%' as const, height: '100%' as const }, + selectionOverlay: { + ...StyleSheet.absoluteFillObject, + justifyContent: 'flex-start' as const, + alignItems: 'flex-end' as const, + padding: SPACING.sm - 2, + }, + selectionOverlaySelected: { backgroundColor: 'rgba(99, 102, 241, 0.25)' }, + checkbox: { + width: 22, + height: 22, + borderRadius: 11, + borderWidth: 2, + borderColor: '#fff', + backgroundColor: 'rgba(0, 0, 0, 0.3)', + justifyContent: 'center' as const, + alignItems: 'center' as const, + }, + checkboxSelected: { backgroundColor: colors.primary, borderColor: colors.primary }, + emptyContainer: { + flex: 1, + justifyContent: 'center' as const, + alignItems: 'center' as const, + padding: SPACING.xxl, + }, + emptyTitle: { ...TYPOGRAPHY.body, color: colors.text, marginTop: SPACING.lg }, + emptyText: { + ...TYPOGRAPHY.bodySmall, + color: colors.textMuted, + textAlign: 'center' as const, + marginTop: SPACING.sm, + }, +}); + +const createViewerStyles = (colors: ThemeColors) => ({ + viewerContainer: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.95)', + justifyContent: 'center' as const, + alignItems: 'center' as const, + }, + viewerBackdrop: { ...StyleSheet.absoluteFillObject }, + viewerContent: { + width: '100%' as const, + height: '100%' as const, + justifyContent: 'center' as const, + alignItems: 'center' as const, + }, + fullscreenImage: { + width: Dimensions.get('window').width, + height: Dimensions.get('window').height * 0.65, + }, + viewerActions: { + flexDirection: 'row' as const, + position: 'absolute' as const, + bottom: 60, + gap: SPACING.lg + 4, + }, + viewerButton: { + alignItems: 'center' as const, + padding: SPACING.md + 2, + backgroundColor: colors.surface, + borderRadius: SPACING.md + 2, + minWidth: 70, + }, + viewerButtonActive: { borderWidth: 1, borderColor: colors.primary }, + viewerButtonText: { ...TYPOGRAPHY.meta, color: colors.text, marginTop: SPACING.xs }, + viewerButtonTextPrimary: { ...TYPOGRAPHY.meta, color: colors.primary, marginTop: SPACING.xs }, + viewerButtonTextError: { ...TYPOGRAPHY.meta, color: colors.error, marginTop: SPACING.xs }, + detailsSheet: { + flex: 1, + width: '100%' as const, + backgroundColor: colors.surface, + borderTopLeftRadius: 16, + borderTopRightRadius: 16, + marginTop: 60, + overflow: 'hidden' as const, + }, + detailsSheetHeader: { + flexDirection: 'row' as const, + justifyContent: 'space-between' as const, + alignItems: 'center' as const, + paddingHorizontal: SPACING.lg, + paddingVertical: SPACING.md, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + detailsSheetTitle: { ...TYPOGRAPHY.h3, color: colors.text }, + detailsSheetClose: { ...TYPOGRAPHY.body, color: colors.primary }, + detailsPreview: { width: '100%' as const, height: 200, backgroundColor: colors.background }, + detailsContent: { padding: SPACING.lg }, + detailRow: { marginBottom: SPACING.sm + 2 }, + detailLabel: { ...TYPOGRAPHY.meta, color: colors.textMuted, marginBottom: 2 }, + detailValue: { ...TYPOGRAPHY.body, color: colors.text, lineHeight: 20 }, + detailsMetaRow: { + flexDirection: 'row' as const, + flexWrap: 'wrap' as const, + gap: SPACING.sm, + marginTop: SPACING.xs, + }, + detailChip: { + backgroundColor: colors.surfaceLight, + paddingHorizontal: SPACING.sm + 2, + paddingVertical: SPACING.xs, + borderRadius: SPACING.sm, + }, + detailChipText: { ...TYPOGRAPHY.meta, color: colors.textSecondary }, + detailDate: { ...TYPOGRAPHY.meta, color: colors.textMuted, marginTop: SPACING.sm + 2 }, +}); + +export const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ + ...createHeaderStyles(colors, shadows), + ...createGridStyles(colors), + ...createViewerStyles(colors), +}); diff --git a/src/screens/GalleryScreen/useGalleryActions.ts b/src/screens/GalleryScreen/useGalleryActions.ts new file mode 100644 index 00000000..17ba43af --- /dev/null +++ b/src/screens/GalleryScreen/useGalleryActions.ts @@ -0,0 +1,212 @@ +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { Platform, PermissionsAndroid, Share } from 'react-native'; +import RNFS from 'react-native-fs'; +import { showAlert, hideAlert, AlertState, initialAlertState } from '../../components/CustomAlert'; +import { useAppStore, useChatStore } from '../../stores'; +import { imageGenerationService, onnxImageGeneratorService } from '../../services'; +import type { ImageGenerationState } from '../../services'; +import { GeneratedImage } from '../../types'; + +export const formatDate = (dateStr: string): string => { + const ts = Number(dateStr); + const date = isNaN(ts) ? new Date(dateStr) : new Date(ts); + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +}; + +export const useGalleryActions = (conversationId: string | undefined) => { + const { generatedImages, removeGeneratedImage } = useAppStore(); + const conversations = useChatStore(s => s.conversations); + + const [isSelectMode, setIsSelectMode] = useState(false); + const [selectedIds, setSelectedIds] = useState>(new Set()); + const [selectedImage, setSelectedImage] = useState(null); + const [showDetails, setShowDetails] = useState(false); + const [alertState, setAlertState] = useState(initialAlertState); + const [imageGenState, setImageGenState] = useState( + imageGenerationService.getState() + ); + + useEffect(() => { + const unsubscribe = imageGenerationService.subscribe((state) => { + setImageGenState(state); + }); + return unsubscribe; + }, []); + + useEffect(() => { + const syncFromDisk = async () => { + try { + const diskImages = await onnxImageGeneratorService.getGeneratedImages(); + if (diskImages.length > 0) { + const { generatedImages: storeImages, addGeneratedImage } = useAppStore.getState(); + const existingIds = new Set(storeImages.map(img => img.id)); + for (const img of diskImages) { + if (!existingIds.has(img.id)) { + addGeneratedImage(img); + } + } + } + } catch { + // Silently fail + } + }; + syncFromDisk(); + }, []); + + const chatImageIds = useMemo(() => { + if (!conversationId) return null; + const convo = conversations.find(c => c.id === conversationId); + if (!convo) return new Set(); + const ids = new Set(); + for (const msg of convo.messages) { + if (msg.attachments) { + for (const att of msg.attachments) { + if (att.type === 'image') ids.add(att.id); + } + } + } + return ids; + }, [conversationId, conversations]); + + const displayImages = useMemo(() => { + if (!conversationId) return generatedImages; + return generatedImages.filter( + img => img.conversationId === conversationId || (chatImageIds && chatImageIds.has(img.id)) + ); + }, [generatedImages, conversationId, chatImageIds]); + + const handleDelete = useCallback((image: GeneratedImage) => { + setAlertState(showAlert( + 'Delete Image', + 'Are you sure you want to delete this image?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + setAlertState(hideAlert()); + await onnxImageGeneratorService.deleteGeneratedImage(image.id); + removeGeneratedImage(image.id); + if (selectedImage?.id === image.id) { + setSelectedImage(null); + } + }, + }, + ] + )); + }, [selectedImage, removeGeneratedImage]); + + const toggleSelectMode = useCallback(() => { + setIsSelectMode(prev => { + if (prev) setSelectedIds(new Set()); + return !prev; + }); + }, []); + + const toggleImageSelection = useCallback((imageId: string) => { + setSelectedIds(prev => { + const newSet = new Set(prev); + if (newSet.has(imageId)) { + newSet.delete(imageId); + } else { + newSet.add(imageId); + } + return newSet; + }); + }, []); + + const handleDeleteSelected = useCallback(() => { + if (selectedIds.size === 0) return; + const count = selectedIds.size; + setAlertState(showAlert( + 'Delete Images', + `Are you sure you want to delete ${count} image${count > 1 ? 's' : ''}?`, + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Delete', + style: 'destructive', + onPress: async () => { + setAlertState(hideAlert()); + for (const imageId of selectedIds) { + await onnxImageGeneratorService.deleteGeneratedImage(imageId); + removeGeneratedImage(imageId); + } + setSelectedIds(new Set()); + setIsSelectMode(false); + }, + }, + ] + )); + }, [selectedIds, removeGeneratedImage]); + + const selectAll = useCallback(() => { + setSelectedIds(new Set(displayImages.map(img => img.id))); + }, [displayImages]); + + const handleSaveImage = useCallback(async (image: GeneratedImage) => { + try { + if (Platform.OS === 'ios') { + await Share.share({ url: `file://${image.imagePath}` }); + return; + } + await PermissionsAndroid.request( + PermissionsAndroid.PERMISSIONS.WRITE_EXTERNAL_STORAGE, + { + title: 'Storage Permission', + message: 'App needs access to save images', + buttonNeutral: 'Ask Later', + buttonNegative: 'Cancel', + buttonPositive: 'OK', + } + ); + const picturesDir = `${RNFS.ExternalStorageDirectoryPath}/Pictures/OffgridMobile`; + if (!(await RNFS.exists(picturesDir))) { + await RNFS.mkdir(picturesDir); + } + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const fileName = `generated_${timestamp}.png`; + await RNFS.copyFile(image.imagePath, `${picturesDir}/${fileName}`); + setAlertState(showAlert('Image Saved', `Saved to Pictures/OffgridMobile/${fileName}`)); + } catch (error: any) { + setAlertState(showAlert('Error', `Failed to save image: ${error?.message || 'Unknown error'}`)); + } + }, []); + + const handleCancelGeneration = useCallback(() => { + imageGenerationService.cancelGeneration().catch(() => {}); + }, []); + + const closeViewer = useCallback(() => { + setSelectedImage(null); + setShowDetails(false); + }, []); + + return { + isSelectMode, + selectedIds, + selectedImage, + setSelectedImage, + showDetails, + setShowDetails, + alertState, + setAlertState, + imageGenState, + displayImages, + handleDelete, + toggleSelectMode, + toggleImageSelection, + handleDeleteSelected, + selectAll, + handleSaveImage, + handleCancelGeneration, + closeViewer, + }; +}; diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx deleted file mode 100644 index 842c088f..00000000 --- a/src/screens/HomeScreen.tsx +++ /dev/null @@ -1,1123 +0,0 @@ -import React, { useEffect, useState, useRef, useCallback } from 'react'; -import { - View, - Text, - ScrollView, - TouchableOpacity, - Modal, - ActivityIndicator, - InteractionManager, -} from 'react-native'; -import { AppSheet } from '../components/AppSheet'; -import Swipeable from 'react-native-gesture-handler/Swipeable'; -import { SafeAreaView } from 'react-native-safe-area-context'; -import Icon from 'react-native-vector-icons/Feather'; -import { Button, Card, CustomAlert, initialAlertState, showAlert, hideAlert } from '../components'; -import type { AlertState } from '../components'; -import { AnimatedEntry } from '../components/AnimatedEntry'; -import { AnimatedListItem } from '../components/AnimatedListItem'; -import { AnimatedPressable } from '../components/AnimatedPressable'; -import { useFocusTrigger } from '../hooks/useFocusTrigger'; -import { useTheme, useThemedStyles } from '../theme'; -import type { ThemeColors, ThemeShadows } from '../theme'; -import { TYPOGRAPHY, SPACING } from '../constants'; -import { useAppStore, useChatStore } from '../stores'; -import { modelManager, hardwareService, activeModelService, ResourceUsage } from '../services'; -import { Conversation, DownloadedModel, ONNXImageModel } from '../types'; -import { ChatsStackParamList } from '../navigation/types'; -import { NavigatorScreenParams } from '@react-navigation/native'; -import { BottomTabNavigationProp } from '@react-navigation/bottom-tabs'; - -type MainTabParamListWithNested = { - HomeTab: undefined; - ChatsTab: NavigatorScreenParams | undefined; - ProjectsTab: undefined; - ModelsTab: undefined; - SettingsTab: undefined; -}; - -type HomeScreenNavigationProp = BottomTabNavigationProp; - -type HomeScreenProps = { - navigation: HomeScreenNavigationProp; -}; - -type ModelPickerType = 'text' | 'image' | null; - -type LoadingState = { - isLoading: boolean; - type: 'text' | 'image' | null; - modelName: string | null; -}; - -// Track if we've synced native state to avoid repeated calls -let hasInitializedNativeSync = false; - -export const HomeScreen: React.FC = ({ navigation }) => { - const focusTrigger = useFocusTrigger(); - const { colors } = useTheme(); - const styles = useThemedStyles(createStyles); - const [pickerType, setPickerType] = useState(null); - const [loadingState, setLoadingState] = useState({ - isLoading: false, - type: null, - modelName: null, - }); - const [isEjecting, setIsEjecting] = useState(false); - const [alertState, setAlertState] = useState(initialAlertState); - const [memoryInfo, setMemoryInfo] = useState(null); - const isFirstMount = useRef(true); - - const { - downloadedModels, - setDownloadedModels, - activeModelId, - setActiveModelId: _setActiveModelId, - downloadedImageModels, - setDownloadedImageModels, - activeImageModelId, - setActiveImageModelId: _setActiveImageModelId, - deviceInfo, - setDeviceInfo, - generatedImages, - } = useAppStore(); - - const { conversations, createConversation, setActiveConversation, deleteConversation } = useChatStore(); - - useEffect(() => { - // Defer heavy operations until after navigation animations complete - const task = InteractionManager.runAfterInteractions(() => { - loadData(); - // Only sync native state once per app session to avoid lag on screen transitions - if (!hasInitializedNativeSync) { - hasInitializedNativeSync = true; - activeModelService.syncWithNativeState(); - } - }); - - isFirstMount.current = false; - - return () => task.cancel(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // Refresh memory info periodically and when models change - const refreshMemoryInfo = useCallback(async () => { - try { - const info = await activeModelService.getResourceUsage(); - setMemoryInfo(info); - } catch (error) { - console.warn('[HomeScreen] Failed to get memory info:', error); - } - }, []); - - // Refresh memory when models are loaded/unloaded (subscribe to changes) - useEffect(() => { - // Initial fetch - refreshMemoryInfo(); - - // Subscribe to model changes to refresh when models load/unload - const unsubscribe = activeModelService.subscribe(() => { - refreshMemoryInfo(); - }); - - return () => unsubscribe(); - }, [refreshMemoryInfo]); - - const loadData = async () => { - if (!deviceInfo) { - const info = await hardwareService.getDeviceInfo(); - setDeviceInfo(info); - } - const models = await modelManager.getDownloadedModels(); - setDownloadedModels(models); - const imageModels = await modelManager.getDownloadedImageModels(); - setDownloadedImageModels(imageModels); - }; - - const handleSelectTextModel = async (model: DownloadedModel) => { - if (activeModelId === model.id) return; - - // Check memory before loading - const memoryCheck = await activeModelService.checkMemoryForModel(model.id, 'text'); - - if (!memoryCheck.canLoad) { - // Critical: Not enough memory, don't allow loading - setAlertState(showAlert('Insufficient Memory', memoryCheck.message)); - return; - } - - if (memoryCheck.severity === 'warning') { - // Warning: Ask user to confirm - setAlertState(showAlert( - 'Low Memory Warning', - memoryCheck.message, - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Load Anyway', - style: 'default', - onPress: () => { - setAlertState(hideAlert()); - proceedWithTextModelLoad(model); - }, - }, - ] - )); - return; - } - - // Safe to load - proceedWithTextModelLoad(model); - }; - - const proceedWithTextModelLoad = async (model: DownloadedModel) => { - setLoadingState({ isLoading: true, type: 'text', modelName: model.name }); - setPickerType(null); // Close modal when loading starts - - // Give UI time to update before starting heavy native operation - // This prevents the app from appearing frozen - await new Promise(resolve => requestAnimationFrame(() => { - requestAnimationFrame(() => { - setTimeout(() => resolve(), 100); - }); - })); - - try { - await activeModelService.loadTextModel(model.id); - } catch (error) { - setAlertState(showAlert('Error', `Failed to load model: ${(error as Error).message}`)); - } finally { - setLoadingState({ isLoading: false, type: null, modelName: null }); - } - }; - - const handleUnloadTextModel = async () => { - console.log('[HomeScreen] handleUnloadTextModel called, activeModelId:', activeModelId); - setLoadingState({ isLoading: true, type: 'text', modelName: null }); - setPickerType(null); // Close modal - try { - await activeModelService.unloadTextModel(); - console.log('[HomeScreen] unloadTextModel completed'); - } catch (error) { - console.log('[HomeScreen] unloadTextModel error:', error); - setAlertState(showAlert('Error', 'Failed to unload model')); - } finally { - setLoadingState({ isLoading: false, type: null, modelName: null }); - } - }; - - const handleSelectImageModel = async (model: ONNXImageModel) => { - if (activeImageModelId === model.id) return; - - // Check memory before loading - const memoryCheck = await activeModelService.checkMemoryForModel(model.id, 'image'); - - if (!memoryCheck.canLoad) { - // Critical: Not enough memory, don't allow loading - setAlertState(showAlert('Insufficient Memory', memoryCheck.message)); - return; - } - - if (memoryCheck.severity === 'warning') { - // Warning: Ask user to confirm - setAlertState(showAlert( - 'Low Memory Warning', - memoryCheck.message, - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Load Anyway', - style: 'default', - onPress: () => { - setAlertState(hideAlert()); - proceedWithImageModelLoad(model); - }, - }, - ] - )); - return; - } - - // Safe to load - proceedWithImageModelLoad(model); - }; - - const proceedWithImageModelLoad = async (model: ONNXImageModel) => { - setLoadingState({ isLoading: true, type: 'image', modelName: model.name }); - setPickerType(null); // Close modal when loading starts - - // Give UI time to update before starting heavy native operation - await new Promise(resolve => requestAnimationFrame(() => { - requestAnimationFrame(() => { - setTimeout(() => resolve(), 100); - }); - })); - - try { - await activeModelService.loadImageModel(model.id); - } catch (error) { - setAlertState(showAlert('Error', `Failed to load model: ${(error as Error).message}`)); - } finally { - setLoadingState({ isLoading: false, type: null, modelName: null }); - } - }; - - const handleUnloadImageModel = async () => { - setLoadingState({ isLoading: true, type: 'image', modelName: null }); - setPickerType(null); // Close modal - try { - await activeModelService.unloadImageModel(); - } catch (_error) { - setAlertState(showAlert('Error', 'Failed to unload model')); - } finally { - setLoadingState({ isLoading: false, type: null, modelName: null }); - } - }; - - const handleEjectAll = () => { - const hasModels = activeModelId || activeImageModelId; - if (!hasModels) return; - - setAlertState(showAlert( - 'Eject All Models', - 'Unload all active models to free up memory?', - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Eject All', - style: 'destructive', - onPress: async () => { - setAlertState(hideAlert()); - setIsEjecting(true); - try { - const results = await activeModelService.unloadAllModels(); - const count = (results.textUnloaded ? 1 : 0) + (results.imageUnloaded ? 1 : 0); - if (count > 0) { - setAlertState(showAlert('Done', `Unloaded ${count} model${count > 1 ? 's' : ''}`)); - } - } catch (_error) { - setAlertState(showAlert('Error', 'Failed to unload models')); - } finally { - setIsEjecting(false); - } - }, - }, - ] - )); - }; - - const startNewChat = () => { - if (!activeModelId) return; - const conversationId = createConversation(activeModelId); - setActiveConversation(conversationId); - navigation.navigate('ChatsTab', { screen: 'Chat', params: { conversationId } }); - }; - - const continueChat = (conversationId: string) => { - setActiveConversation(conversationId); - navigation.navigate('ChatsTab', { screen: 'Chat', params: { conversationId } }); - }; - - const handleDeleteConversation = (conversation: Conversation) => { - setAlertState(showAlert( - 'Delete Conversation', - `Delete "${conversation.title}"?`, - [ - { text: 'Cancel', style: 'cancel' }, - { - text: 'Delete', - style: 'destructive', - onPress: () => { - setAlertState(hideAlert()); - deleteConversation(conversation.id); - }, - }, - ] - )); - }; - - const renderRightActions = (conversation: Conversation) => ( - handleDeleteConversation(conversation)} - testID="delete-conversation-button" - > - - - ); - - const activeTextModel = downloadedModels.find((m) => m.id === activeModelId); - const activeImageModel = downloadedImageModels.find((m) => m.id === activeImageModelId); - const recentConversations = conversations.slice(0, 4); - - return ( - - - - - Off Grid - - - {/* Active Models Section */} - - - {/* Text Model */} - setPickerType('text')} - hapticType="selection" - > - - - Text - {loadingState.isLoading && loadingState.type === 'text' ? ( - - ) : ( - - )} - - {loadingState.isLoading && loadingState.type === 'text' ? ( - <> - - {loadingState.modelName || 'Unloading...'} - - Loading... - - ) : activeTextModel ? ( - <> - - {activeTextModel.name} - - - {activeTextModel.quantization} · ~{(((activeTextModel.fileSize + (activeTextModel.mmProjFileSize || 0)) * 1.5) / (1024 * 1024 * 1024)).toFixed(1)} GB - - - ) : ( - - {downloadedModels.length > 0 ? 'Tap to select' : 'No models'} - - )} - - - {/* Image Model */} - setPickerType('image')} - testID="image-model-card" - hapticType="selection" - > - - - Image - {loadingState.isLoading && loadingState.type === 'image' ? ( - - ) : ( - - )} - - {loadingState.isLoading && loadingState.type === 'image' ? ( - <> - - {loadingState.modelName || 'Unloading...'} - - Loading... - - ) : activeImageModel ? ( - <> - - {activeImageModel.name} - - - {activeImageModel.style || 'Ready'} · ~{((activeImageModel.size * 1.8) / (1024 * 1024 * 1024)).toFixed(1)} GB - - - ) : ( - - {downloadedImageModels.length > 0 ? 'Tap to select' : 'No models'} - - )} - - - - - {/* Memory info is now shown inline in the model cards above */} - - {/* Eject All - Show when models are loaded OR loading (so user can cancel) */} - {(activeModelId || activeImageModelId || loadingState.isLoading) && ( - - {isEjecting ? ( - - ) : ( - <> - - - {loadingState.isLoading ? 'Cancel Loading' : 'Eject All Models'} - - - )} - - )} - - {/* New Chat Button */} - {activeTextModel ? ( -