Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 70 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ OffgridMobile is a React Native application that brings large language models, v
| Vision AI | llama.rn multimodal | llama.rn multimodal |
| Image Generation | local-dream (MNN/QNN) | Core ML (ANE + CPU) |
| Voice Transcription | whisper.cpp | whisper.cpp |
| PDF Extraction | PdfRenderer (Kotlin) | PDFKit (Swift) |
| Document Viewer | Intent.ACTION_VIEW | QuickLook |
| Background Downloads | Native DownloadManager | RNFS / URLSession |

---
Expand All @@ -35,6 +37,9 @@ OffgridMobile is a React Native application that brings large language models, v
- **Image Generation** - On-device Stable Diffusion (CPU/NPU), real-time preview, background generation
- **AI Prompt Enhancement** - Use text LLM to expand simple prompts into detailed descriptions for better image quality
- **Voice Transcription** - On-device Whisper for speech-to-text, multiple model sizes
- **Document Attachments** - Attach text files, code, CSV, JSON, and PDFs to chat messages
- **PDF Extraction** - Native on-device PDF text extraction (Android + iOS)
- **Message Queue** - Send multiple messages during active generation, auto-processed sequentially
- **GPU Acceleration** - Optional OpenCL GPU offloading for text models
- **Auto/Manual Image Generation** - Automatic intent detection or manual toggle for image generation
- **Dark Mode** - Full light/dark theme with dynamic color palettes and elevation system
Expand Down Expand Up @@ -180,6 +185,53 @@ On-device speech recognition using whisper.cpp via whisper.rn native bindings:
- Audio temporarily buffered in native code, cleared after transcription
- Model selection in settings (Tiny: fastest, Base: balanced, Small: most accurate)

### Document Attachments

Attach documents to chat messages for context-aware conversations. Documents are parsed and included in the LLM context window alongside the user's message.

**Supported Formats:**
- **Text files** - `.txt`, `.md`, `.log`
- **Code files** - `.py`, `.js`, `.ts`, `.jsx`, `.tsx`, `.java`, `.c`, `.cpp`, `.h`, `.swift`, `.kt`, `.go`, `.rs`, `.rb`, `.php`, `.sql`, `.sh`
- **Data files** - `.csv`, `.json`, `.xml`, `.yaml`, `.yml`, `.toml`, `.ini`, `.cfg`, `.conf`, `.html`
- **PDF documents** - Native text extraction via platform-specific modules

**Features:**
- **File picker integration** - Uses `@react-native-documents/picker` for native file selection
- **PDF text extraction** - Native modules extract text from PDFs on both platforms
- **Persistent storage** - Attached files are copied to persistent app storage so they survive temp file cleanup
- **Tappable document badges** - Tap any attached document in chat to open it with the system viewer (QuickLook on iOS, Intent viewer on Android)
- **Paste-as-attachment** - Large pasted text can be attached as a document
- **File size limit** - 5MB maximum, with text content truncated to 50K characters for context window management

**PDF Extraction Implementation:**
- Android: `PdfExtractorModule` (Kotlin) uses Android's native `PdfRenderer` API
- iOS: `PDFExtractorModule` (Swift) uses Apple's `PDFKit` framework
- Both extract page-by-page text content with page separators
- Graceful fallback when PDF extraction is unavailable

**Document Viewer:**
- Uses `@react-native-documents/viewer` for cross-platform document opening
- iOS: Opens in QuickLook preview
- Android: Opens with `Intent.ACTION_VIEW` using the appropriate system app

### Message Queue

Send messages while the LLM is still generating a response. Messages are queued and processed automatically after the current generation completes.

**Features:**
- **Non-blocking input** - Send button stays active during generation, alongside a visible stop button
- **Queue indicator** - Shows count and preview of queued messages in the toolbar
- **Clear queue** - Tap the "x" on the queue indicator to discard all queued messages
- **Aggregated processing** - When generation completes, all queued messages are combined into a single prompt and processed together
- **Image bypass** - Image generation requests skip the queue and process immediately via the separate image generation service
- **Stop + Send side-by-side** - Both stop and send buttons visible in the input area during active generation

**Implementation:**
- Queue lives in `generationService.ts` as transient state (not persisted across app restarts)
- User messages are added to chat only when the queue processor picks them up, preserving correct chronology
- ChatScreen registers a `queueProcessor` callback; when generation resets and the queue has items, the service calls this callback
- Multiple queued messages are aggregated: texts joined with `\n\n`, attachments combined

### Image Generation Modes

**Auto Detection:**
Expand Down Expand Up @@ -837,6 +889,8 @@ Prevents OOM crashes by blocking loads that would exceed safe RAM limits.
- **React Navigation 7.x** - Native navigation
- **React Native Reanimated 4.x** - Performant native-thread animations
- **React Native Haptic Feedback** - Haptic responses on interactions
- **@react-native-documents/picker** - Native document picker for file attachments
- **@react-native-documents/viewer** - Native document viewer (QuickLook / Intent.ACTION_VIEW)

### Native Modules

Expand Down Expand Up @@ -864,6 +918,12 @@ Prevents OOM crashes by blocking loads that would exceed safe RAM limits.
- Safety checker disabled for reduced latency
- Supports palettized (6-bit) and full-precision (fp16) Core ML models

**PdfExtractorModule (Android + iOS):**
- Android: Kotlin module using `android.graphics.pdf.PdfRenderer` for page-by-page text extraction
- iOS: Swift module using Apple's `PDFKit` (`PDFDocument`, `PDFPage`) for text extraction
- Both expose `extractText(filePath)` returning concatenated page text with separators
- Graceful error handling when PDF is corrupted or password-protected

**DownloadManager (Android only):**
- Native Android DownloadManager wrapper
- Background download support
Expand Down Expand Up @@ -1045,7 +1105,8 @@ OffgridMobile/
│ │ ├── localDreamGenerator.ts # local-dream bridge
│ │ ├── imageGenerator.ts # Image generation utilities
│ │ ├── hardware.ts # Device info and memory
│ │ ├── documentService.ts # Document text extraction
│ │ ├── documentService.ts # Document attachment processing
│ │ ├── pdfExtractor.ts # Native PDF text extraction bridge
│ │ ├── authService.ts # Passphrase authentication
│ │ ├── voiceService.ts # Voice recording
│ │ ├── whisperService.ts # Whisper transcription
Expand Down Expand Up @@ -1079,15 +1140,20 @@ OffgridMobile/
│ │ ├── DownloadManagerModule.kt
│ │ ├── DownloadManagerPackage.kt
│ │ └── DownloadCompleteBroadcastReceiver.kt
│ └── localdream/ # local-dream native module
│ ├── LocalDreamModule.kt
│ └── LocalDreamPackage.kt
│ ├── localdream/ # local-dream native module
│ │ ├── LocalDreamModule.kt
│ │ └── LocalDreamPackage.kt
│ └── pdf/ # PDF text extraction
│ ├── PdfExtractorModule.kt
│ └── PdfExtractorPackage.kt
├── ios/ # iOS native code
│ └── OffgridMobile/
│ ├── AppDelegate.swift # Application delegate
│ ├── CoreMLDiffusion/ # Core ML image generation
│ │ ├── CoreMLDiffusionModule.swift
│ │ └── CoreMLDiffusionModule.m
│ ├── PDFExtractor/ # PDF text extraction
│ │ └── PDFExtractorModule.swift
│ └── Download/ # iOS download manager
│ ├── DownloadManagerModule.swift
│ └── DownloadManagerModule.m
Expand Down
210 changes: 209 additions & 1 deletion __tests__/integration/generation/imageGenerationFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,25 @@ import { useAppStore } from '../../../src/stores/appStore';
import { imageGenerationService } from '../../../src/services/imageGenerationService';
import { localDreamGeneratorService } from '../../../src/services/localDreamGenerator';
import { activeModelService } from '../../../src/services/activeModelService';
import { llmService } from '../../../src/services/llm';
import {
resetStores,
flushPromises,
getAppState,
getChatState,
setupWithConversation,
} from '../../utils/testHelpers';
import { createONNXImageModel, createGeneratedImage } from '../../utils/factories';
import { createONNXImageModel, createGeneratedImage, createMessage } from '../../utils/factories';
import { Message } from '../../../src/types';

// Mock the services
jest.mock('../../../src/services/localDreamGenerator');
jest.mock('../../../src/services/activeModelService');
jest.mock('../../../src/services/llm');

const mockLocalDreamService = localDreamGeneratorService as jest.Mocked<typeof localDreamGeneratorService>;
const mockActiveModelService = activeModelService as jest.Mocked<typeof activeModelService>;
const mockLlmService = llmService as jest.Mocked<typeof llmService>;

describe('Image Generation Flow Integration', () => {
beforeEach(async () => {
Expand Down Expand Up @@ -55,6 +59,11 @@ describe('Image Generation Flow Integration', () => {
});
mockActiveModelService.loadImageModel.mockResolvedValue();

// Default LLM service mocks (for prompt enhancement)
mockLlmService.isModelLoaded.mockReturnValue(false);
mockLlmService.isCurrentlyGenerating.mockReturnValue(false);
mockLlmService.stopGeneration.mockResolvedValue();

// Reset imageGenerationService state by canceling any in-progress generation
await imageGenerationService.cancelGeneration().catch(() => {});
});
Expand Down Expand Up @@ -512,4 +521,203 @@ describe('Image Generation Flow Integration', () => {
expect(message?.generationMeta?.resolution).toBe('512x512');
});
});

describe('Prompt Enhancement with Conversation Context', () => {
const setupEnhancement = () => {
const imageModel = setupImageModelState();
mockActiveModelService.getActiveModels.mockReturnValue({
text: { model: null, isLoaded: false, isLoading: false },
image: { model: imageModel, isLoaded: true, isLoading: false },
});

// Enable enhancement and set up LLM as available
useAppStore.setState({
settings: {
...useAppStore.getState().settings,
enhanceImagePrompts: true,
},
});
mockLlmService.isModelLoaded.mockReturnValue(true);
mockLlmService.isCurrentlyGenerating.mockReturnValue(false);
mockLlmService.generateResponse.mockResolvedValue('A beautifully enhanced prompt');

return imageModel;
};

it('should pass conversation history to enhancement when conversationId provided', async () => {
setupEnhancement();

// Set up a conversation with prior messages
const messages: Message[] = [
createMessage({ role: 'user', content: 'Draw me a cat' }),
createMessage({ role: 'assistant', content: 'Here is a cat image' }),
createMessage({ role: 'user', content: 'Make it darker' }),
];
const conversationId = setupWithConversation({ messages });

await imageGenerationService.generateImage({
prompt: 'Make it darker',
conversationId,
});

// Verify generateResponse was called with conversation context
expect(mockLlmService.generateResponse).toHaveBeenCalled();
const callArgs = mockLlmService.generateResponse.mock.calls[0];
const enhancementMessages = callArgs[0] as Message[];

// Should have: system + context messages + user enhance prompt
// system (1) + conversation messages (3) + user enhance (1) = 5
expect(enhancementMessages.length).toBe(5);
expect(enhancementMessages[0].role).toBe('system');
expect(enhancementMessages[0].content).toContain('conversation history');
expect(enhancementMessages[1].content).toBe('Draw me a cat');
expect(enhancementMessages[2].content).toBe('Here is a cat image');
expect(enhancementMessages[3].content).toBe('Make it darker');
expect(enhancementMessages[4].role).toBe('user');
expect(enhancementMessages[4].content).toBe('Make it darker');
});

it('should not include conversation context when no conversationId', async () => {
setupEnhancement();

await imageGenerationService.generateImage({
prompt: 'A sunset',
});

expect(mockLlmService.generateResponse).toHaveBeenCalled();
const callArgs = mockLlmService.generateResponse.mock.calls[0];
const enhancementMessages = callArgs[0] as Message[];

// Should have: system + user enhance prompt only (no context)
expect(enhancementMessages.length).toBe(2);
expect(enhancementMessages[0].role).toBe('system');
expect(enhancementMessages[0].content).not.toContain('conversation history');
expect(enhancementMessages[1].role).toBe('user');
expect(enhancementMessages[1].content).toBe('A sunset');
});

it('should truncate long messages in conversation context', async () => {
setupEnhancement();

const longContent = 'x'.repeat(1000);
const messages: Message[] = [
createMessage({ role: 'user', content: longContent }),
];
const conversationId = setupWithConversation({ messages });

await imageGenerationService.generateImage({
prompt: 'Enhance this',
conversationId,
});

const callArgs = mockLlmService.generateResponse.mock.calls[0];
const enhancementMessages = callArgs[0] as Message[];

// The context message should be truncated to 500 chars
const contextMsg = enhancementMessages.find(m => m.id.startsWith('ctx-'));
expect(contextMsg).toBeDefined();
expect(contextMsg!.content.length).toBe(500);
});

it('should limit conversation context to last 10 messages', async () => {
setupEnhancement();

// Create 15 messages
const messages: Message[] = [];
for (let i = 0; i < 15; i++) {
messages.push(createMessage({
role: i % 2 === 0 ? 'user' : 'assistant',
content: `Message ${i + 1}`,
}));
}
const conversationId = setupWithConversation({ messages });

await imageGenerationService.generateImage({
prompt: 'Generate image',
conversationId,
});

const callArgs = mockLlmService.generateResponse.mock.calls[0];
const enhancementMessages = callArgs[0] as Message[];

// system (1) + last 10 context messages + user enhance (1) = 12
expect(enhancementMessages.length).toBe(12);
// First context message should be message 6 (index 5), not message 1
const firstContextMsg = enhancementMessages[1];
expect(firstContextMsg.content).toBe('Message 6');
});

it('should skip system messages from conversation context', async () => {
setupEnhancement();

const messages: Message[] = [
createMessage({ role: 'user', content: 'Hello' }),
createMessage({ role: 'system', content: 'Model loaded successfully' }),
createMessage({ role: 'assistant', content: 'Hi there' }),
];
const conversationId = setupWithConversation({ messages });

await imageGenerationService.generateImage({
prompt: 'Draw something',
conversationId,
});

const callArgs = mockLlmService.generateResponse.mock.calls[0];
const enhancementMessages = callArgs[0] as Message[];

// system (1) + 2 context (user + assistant, system skipped) + user enhance (1) = 4
expect(enhancementMessages.length).toBe(4);
const contextMessages = enhancementMessages.filter(m => m.id.startsWith('ctx-'));
expect(contextMessages).toHaveLength(2);
expect(contextMessages.every(m => m.role !== 'system')).toBe(true);
});

it('should use original prompt when enhancement is disabled', async () => {
setupImageModelState();
mockActiveModelService.getActiveModels.mockReturnValue({
text: { model: null, isLoaded: false, isLoading: false },
image: { model: setupImageModelState(), isLoaded: true, isLoading: false },
});

// Enhancement disabled (default)
useAppStore.setState({
settings: {
...useAppStore.getState().settings,
enhanceImagePrompts: false,
},
});

const messages: Message[] = [
createMessage({ role: 'user', content: 'Draw a cat' }),
];
const conversationId = setupWithConversation({ messages });

await imageGenerationService.generateImage({
prompt: 'Make it blue',
conversationId,
});

// LLM should not be called for enhancement
expect(mockLlmService.generateResponse).not.toHaveBeenCalled();
});

it('should handle empty conversation gracefully', async () => {
setupEnhancement();

const conversationId = setupWithConversation({ messages: [] });

await imageGenerationService.generateImage({
prompt: 'A landscape',
conversationId,
});

const callArgs = mockLlmService.generateResponse.mock.calls[0];
const enhancementMessages = callArgs[0] as Message[];

// system + user enhance only (no context from empty conversation)
expect(enhancementMessages.length).toBe(2);
expect(enhancementMessages[0].role).toBe('system');
expect(enhancementMessages[0].content).not.toContain('conversation history');
});
});
});
9 changes: 5 additions & 4 deletions __tests__/integration/stores/chatStoreIntegration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -345,21 +345,22 @@ describe('ChatStore Streaming Integration', () => {
expect(conversation?.messages).toHaveLength(0);
});

it('should create conversation and clear streaming state together', () => {
it('should create conversation and preserve streaming state', () => {
const conversationId = setupWithConversation();
const chatStore = useChatStore.getState();

// Start streaming
chatStore.startStreaming(conversationId);
chatStore.appendToStreamingMessage('Content');

// Create new conversation (should clear streaming)
// Create new conversation (streaming state preserved — scoped by streamingForConversationId)
const newConvId = chatStore.createConversation('model-id', 'New Conv');

const state = getChatState();
expect(state.activeConversationId).toBe(newConvId);
expect(state.streamingMessage).toBe('');
expect(state.isStreaming).toBe(false);
// Streaming state is preserved — UI uses streamingForConversationId to scope display
expect(state.streamingMessage).toBe('Content');
expect(state.isStreaming).toBe(true);
});
});
});
Loading
Loading