Skip to content

Commit db3d768

Browse files
kfaracikNorbertKlockiewiczclaude
authored
feat(onboarding): implement dynamic default models based on device RAM (#228)
## PR Objective This Pull Request introduces dynamic default model suggestions on the onboarding screen based on the device's available RAM. Resolves #222. By tailoring the initial model suggestions to the hardware capabilities, we prevent out-of-memory crashes on low-end devices while ensuring that high-end device users get the most capable (and multimodal) models right out of the box. ## What changed? ### New Features & Logic * **Dynamic Thresholds:** Replaced the static `startingModels` array with a new dynamic function that evaluates the device's RAM (in GB). * **Categorized Model Tiers:** * **Low-end (< 4 GB RAM):** Suggests lightweight models (e.g., `Qwen 3 - 0.6B`, `LFM 2.5 VL - 450M`) that fit comfortably in limited memory. * **Mid-range (4 - 6 GB RAM):** Suggests balanced models (e.g., `LLaMA 3.2 - 1B - SpinQuant`, `Qwen 3 - 1.7B`). * **High-end (> 6 GB RAM):** Suggests the most powerful and multimodal models (e.g., `Gemma 4 VL - 2B`, `Qwen 2.5 - 3B`). * **Onboarding Integration:** Connected the RAM-fetching utility to the Onboarding component to seamlessly inject these dynamic recommendations during the initial setup. ### Tests * Added unit tests to verify that the correct array of models is returned for each specific RAM threshold (Low, Mid, and High-end). --------- Co-authored-by: Norbert Klockiewicz <Nklockiewicz12@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent bbb0ea5 commit db3d768

5 files changed

Lines changed: 144 additions & 24 deletions

File tree

__tests__/defaultModels.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { getStartingModels } from '../constants/default-models';
2+
3+
describe('getStartingModels', () => {
4+
it('returns low-end model suggestions below 4 GB RAM', () => {
5+
expect(getStartingModels(3.99)).toEqual([
6+
'Qwen 3 - 0.6B',
7+
'LFM 2.5 VL - 450M',
8+
'LFM 2.5 - 1.2B',
9+
]);
10+
});
11+
12+
it('returns mid-range model suggestions from 4 GB to 6 GB RAM', () => {
13+
expect(getStartingModels(4)).toEqual([
14+
'Qwen 3 - 1.7B',
15+
'LFM 2.5 - 1.2B',
16+
'LFM 2.5 VL - 1.6B',
17+
]);
18+
expect(getStartingModels(6)).toEqual([
19+
'Qwen 3 - 1.7B',
20+
'LFM 2.5 - 1.2B',
21+
'LFM 2.5 VL - 1.6B',
22+
]);
23+
});
24+
25+
it('returns high-end model suggestions above 6 GB RAM', () => {
26+
expect(getStartingModels(6.01)).toEqual([
27+
'Gemma 4 - 2B',
28+
'Gemma 4 VL - 2B',
29+
'Qwen 3 - 1.7B',
30+
]);
31+
});
32+
33+
it('falls back to low-end suggestions when RAM detection fails or is zero', () => {
34+
const lowEnd = ['Qwen 3 - 0.6B', 'LFM 2.5 VL - 450M', 'LFM 2.5 - 1.2B'];
35+
expect(getStartingModels(0)).toEqual(lowEnd);
36+
expect(getStartingModels(-1)).toEqual(lowEnd);
37+
});
38+
});

__tests__/modelRepository.test.ts

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
// __tests__/modelRepository.test.ts
2-
import { getAllModels, addModel } from '../database/modelRepository';
2+
import { type SQLiteDatabase } from 'expo-sqlite';
3+
import { getAllModels, getModelsByNames } from '../database/modelRepository';
34

45
jest.mock('expo-sqlite', () => {
56
const stableDb = {};
67
return { useSQLiteContext: jest.fn(() => stableDb) };
78
});
89

10+
type ModelReader = Pick<SQLiteDatabase, 'getAllAsync'>;
11+
912
describe('vision flag', () => {
1013
it('maps vision INTEGER 1 to boolean true from DB row', async () => {
11-
const mockDb = {
14+
const mockDb: ModelReader = {
1215
getAllAsync: jest.fn().mockResolvedValue([
1316
{
1417
id: 1,
@@ -26,14 +29,14 @@ describe('vision flag', () => {
2629
modelSize: null,
2730
},
2831
]),
29-
} as any;
32+
};
3033

3134
const models = await getAllModels(mockDb);
3235
expect(models[0].vision).toBe(true);
3336
});
3437

3538
it('maps vision INTEGER 0 to boolean false from DB row', async () => {
36-
const mockDb = {
39+
const mockDb: ModelReader = {
3740
getAllAsync: jest.fn().mockResolvedValue([
3841
{
3942
id: 1,
@@ -51,7 +54,7 @@ describe('vision flag', () => {
5154
modelSize: null,
5255
},
5356
]),
54-
} as any;
57+
};
5558

5659
const models = await getAllModels(mockDb);
5760
expect(models[0].vision).toBe(false);
@@ -60,7 +63,7 @@ describe('vision flag', () => {
6063

6164
describe('systemPrompt field', () => {
6265
it('maps systemPrompt string from DB row', async () => {
63-
const mockDb = {
66+
const mockDb: ModelReader = {
6467
getAllAsync: jest.fn().mockResolvedValue([
6568
{
6669
id: 1,
@@ -79,14 +82,14 @@ describe('systemPrompt field', () => {
7982
systemPrompt: 'Polish prompt',
8083
},
8184
]),
82-
} as any;
85+
};
8386

8487
const models = await getAllModels(mockDb);
8588
expect(models[0].systemPrompt).toBe('Polish prompt');
8689
});
8790

8891
it('maps null systemPrompt from DB row', async () => {
89-
const mockDb = {
92+
const mockDb: ModelReader = {
9093
getAllAsync: jest.fn().mockResolvedValue([
9194
{
9295
id: 1,
@@ -105,9 +108,62 @@ describe('systemPrompt field', () => {
105108
systemPrompt: null,
106109
},
107110
]),
108-
} as any;
111+
};
109112

110113
const models = await getAllModels(mockDb);
111114
expect(models[0].systemPrompt).toBeNull();
112115
});
113116
});
117+
118+
describe('getModelsByNames', () => {
119+
it('returns models in the requested order', async () => {
120+
const mockDb: ModelReader = {
121+
getAllAsync: jest.fn().mockResolvedValue([
122+
{
123+
id: 2,
124+
modelName: 'Qwen 3 - 1.7B',
125+
source: 'remote',
126+
isDownloaded: 0,
127+
modelPath: '',
128+
tokenizerPath: '',
129+
tokenizerConfigPath: '',
130+
featured: 1,
131+
experimental: 0,
132+
thinking: 1,
133+
vision: 0,
134+
labels: null,
135+
parameters: 2.03,
136+
modelSize: 2.16,
137+
systemPrompt: null,
138+
},
139+
{
140+
id: 1,
141+
modelName: 'LFM 2.5 - 1.2B',
142+
source: 'remote',
143+
isDownloaded: 0,
144+
modelPath: '',
145+
tokenizerPath: '',
146+
tokenizerConfigPath: '',
147+
featured: 1,
148+
experimental: 0,
149+
thinking: 0,
150+
vision: 0,
151+
labels: null,
152+
parameters: 1.2,
153+
modelSize: 1.14,
154+
systemPrompt: null,
155+
},
156+
]),
157+
};
158+
159+
const models = await getModelsByNames(mockDb, [
160+
'LFM 2.5 - 1.2B',
161+
'Qwen 3 - 1.7B',
162+
]);
163+
164+
expect(models.map((model) => model.modelName)).toEqual([
165+
'LFM 2.5 - 1.2B',
166+
'Qwen 3 - 1.7B',
167+
]);
168+
});
169+
});

app/(modals)/select-starting-model.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import { getNextChatId } from '../../database/chatRepository';
1111
import { useChatStore } from '../../store/chatStore';
1212
import { useLLMStore } from '../../store/llmStore';
1313
import { useSQLiteContext } from 'expo-sqlite';
14-
import { getStartingModels, Model } from '../../database/modelRepository';
14+
import { getModelsByNames, Model } from '../../database/modelRepository';
1515
import { Theme } from '../../styles/colors';
16+
import { getStartingModels } from '../../constants/default-models';
17+
import { getDeviceMemoryGB } from '../../utils/modelCompatibility';
1618

1719
function SelectStartingModelScreen() {
1820
const router = useRouter();
@@ -24,10 +26,16 @@ function SelectStartingModelScreen() {
2426
const { downloadedModels } = useModelStore();
2527
const [selectedModel, setSelectedModel] = useState<Model | null>(null);
2628
const [startingModels, setStartingModels] = useState<Model[]>([]);
29+
const suggestedStartingModelNames = useMemo(
30+
() => getStartingModels(getDeviceMemoryGB()),
31+
[]
32+
);
2733

2834
useEffect(() => {
29-
getStartingModels(db).then((models) => setStartingModels(models));
30-
}, [db]);
35+
getModelsByNames(db, suggestedStartingModelNames).then((models) =>
36+
setStartingModels(models)
37+
);
38+
}, [db, suggestedStartingModelNames]);
3139

3240
useEffect(() => {
3341
if (downloadedModels.length > 0) setSelectedModel(downloadedModels[0]);
@@ -73,8 +81,8 @@ function SelectStartingModelScreen() {
7381
model={model}
7482
onPress={
7583
downloadedModels.find((m) => m.id === model.id)
76-
? (model) => {
77-
setSelectedModel(model);
84+
? (pressedModel) => {
85+
setSelectedModel(pressedModel);
7886
}
7987
: () => {}
8088
}

constants/default-models.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,17 @@ import {
1717
GEMMA4_E2B_MM,
1818
} from 'react-native-executorch';
1919

20-
export const startingModels = [
21-
'LFM 2.5 - 1.2B',
22-
'Gemma 4 VL - 2B',
23-
'Qwen 3 - 1.7B',
24-
];
20+
export const getStartingModels = (deviceRamInGB: number): string[] => {
21+
if (deviceRamInGB < 4) {
22+
return ['Qwen 3 - 0.6B', 'LFM 2.5 VL - 450M', 'LFM 2.5 - 1.2B'];
23+
}
24+
25+
if (deviceRamInGB <= 6) {
26+
return ['Qwen 3 - 1.7B', 'LFM 2.5 - 1.2B', 'LFM 2.5 VL - 1.6B'];
27+
}
28+
29+
return ['Gemma 4 - 2B', 'Gemma 4 VL - 2B', 'Qwen 3 - 1.7B'];
30+
};
2531

2632
const RNE_MODELS = [
2733
QWEN3_0_6B_QUANTIZED,

database/modelRepository.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { type SQLiteDatabase } from 'expo-sqlite';
2-
import { DEFAULT_MODELS, startingModels } from '../constants/default-models';
2+
import { DEFAULT_MODELS } from '../constants/default-models';
33

44
export type Model = {
55
id: number;
@@ -167,11 +167,23 @@ export const updateModel = async (
167167
);
168168
};
169169

170-
export const getStartingModels = async (db: SQLiteDatabase) => {
171-
const placeholders = startingModels.map(() => '?').join(', ');
170+
export const getModelsByNames = async (
171+
db: SQLiteDatabase,
172+
modelNames: string[]
173+
) => {
174+
if (modelNames.length === 0) {
175+
return [];
176+
}
177+
178+
const placeholders = modelNames.map(() => '?').join(', ');
172179
const rawModels = await db.getAllAsync<RawModel>(
173180
`SELECT * FROM models WHERE modelName IN (${placeholders})`,
174-
startingModels
181+
modelNames
175182
);
176-
return rawModels.map(hydrateModel);
183+
return rawModels
184+
.map(hydrateModel)
185+
.sort(
186+
(a, b) =>
187+
modelNames.indexOf(a.modelName) - modelNames.indexOf(b.modelName)
188+
);
177189
};

0 commit comments

Comments
 (0)