Skip to content

Commit a0d3cc2

Browse files
committed
fix(chat): don't flash the legacy notice for in-flight messages
1 parent 8796548 commit a0d3cc2

9 files changed

Lines changed: 195 additions & 23 deletions

File tree

__tests__/ChatBar.test.tsx

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import type { PermissionStatus } from 'react-native-audio-api';
66

77
// ── mocks ─────────────────────────────────────────────────────────────────────
88

9+
const mockRunWithModelOffloaded = jest.fn(
10+
async (operation: () => Promise<unknown>) => operation()
11+
);
12+
913
jest.mock('../context/ThemeContext', () => ({
1014
useTheme: () => ({
1115
theme: {
@@ -23,6 +27,8 @@ jest.mock('../store/llmStore', () => ({
2327
interrupt: jest.fn(),
2428
loadModel: jest.fn(),
2529
model: null,
30+
runWithModelOffloaded:
31+
mockRunWithModelOffloaded as unknown as LLMStore['runWithModelOffloaded'],
2632
};
2733
return selector ? selector(state) : state;
2834
}),
@@ -229,6 +235,8 @@ beforeEach(() => {
229235
interrupt: jest.fn(),
230236
loadModel: jest.fn(),
231237
model: null,
238+
runWithModelOffloaded:
239+
mockRunWithModelOffloaded as unknown as LLMStore['runWithModelOffloaded'],
232240
};
233241
return selector ? selector(state) : state;
234242
}
@@ -237,6 +245,7 @@ beforeEach(() => {
237245
mockUseAttachment.openSheet.mockClear();
238246
mockUseAttachment.clearAll.mockClear();
239247
mockUseAttachment.removeAttachment.mockClear();
248+
mockRunWithModelOffloaded.mockClear();
240249
jest.clearAllMocks();
241250
jest.spyOn(console, 'error').mockImplementation(() => {});
242251
jest.spyOn(console, 'warn').mockImplementation(() => {});
@@ -483,9 +492,15 @@ describe('attachment', () => {
483492
expect(screen.getByTestId('attach-btn')).toBeTruthy();
484493
});
485494

486-
it('opens attachment sheet when + button is pressed', () => {
495+
it('offloads the LLM before opening the attachment sheet', async () => {
487496
renderBar();
488-
fireEvent.press(screen.getByTestId('attach-btn'));
497+
await act(async () => {
498+
fireEvent.press(screen.getByTestId('attach-btn'));
499+
});
500+
expect(mockRunWithModelOffloaded).toHaveBeenCalledWith(
501+
expect.any(Function),
502+
{ restore: false }
503+
);
489504
expect(mockUseAttachment.openSheet).toHaveBeenCalled();
490505
});
491506

__tests__/VectorStoreContext.test.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ const mockStoreInstances: Array<{
77
load: jest.Mock;
88
unload: jest.Mock;
99
}> = [];
10+
const mockEmbeddingInstances: Array<{
11+
unload: jest.Mock;
12+
}> = [];
1013

1114
jest.mock('@react-native-rag/op-sqlite', () => ({
1215
OPSQLiteVectorStore: jest
@@ -41,7 +44,13 @@ jest.mock('../utils/embeddingModel', () => ({
4144
}));
4245

4346
jest.mock('../utils/lfmEmbeddings', () => ({
44-
LFMEmbeddings: jest.fn().mockImplementation(() => ({})),
47+
LFMEmbeddings: jest.fn().mockImplementation(() => {
48+
const instance = {
49+
unload: jest.fn().mockResolvedValue(undefined),
50+
};
51+
mockEmbeddingInstances.push(instance);
52+
return instance;
53+
}),
4554
}));
4655

4756
jest.mock('../store/embeddingModelStore', () => ({
@@ -80,6 +89,7 @@ const renderProvider = () =>
8089
beforeEach(() => {
8190
jest.clearAllMocks();
8291
mockStoreInstances.length = 0;
92+
mockEmbeddingInstances.length = 0;
8393
mockMigrate.mockResolvedValue(undefined);
8494
});
8595

@@ -105,6 +115,14 @@ describe('VectorStoreProvider init/teardown chain', () => {
105115
expect(mockStoreInstances[1].unload).not.toHaveBeenCalled();
106116
});
107117

118+
it('releases the downloaded embedding model after initialization', async () => {
119+
renderProvider();
120+
await flush();
121+
122+
expect(mockEmbeddingInstances).toHaveLength(1);
123+
expect(mockEmbeddingInstances[0].unload).toHaveBeenCalledTimes(1);
124+
});
125+
108126
it('unmount during init unloads the store exactly once', async () => {
109127
const deferred = createDeferred<void>();
110128
mockMigrate.mockReturnValueOnce(deferred.promise);

__tests__/lfmEmbeddings.test.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,76 @@ const makeEmbeddings = () =>
88
tokenizerSource: 'file://tokenizer.json',
99
});
1010

11+
describe('LFMEmbeddings.runWithLoadedModel', () => {
12+
it('loads for an operation and always unloads afterwards', async () => {
13+
const embeddings = makeEmbeddings();
14+
const load = jest.spyOn(embeddings, 'load').mockResolvedValue(embeddings);
15+
const unload = jest.spyOn(embeddings, 'unload').mockResolvedValue();
16+
const operation = jest.fn().mockResolvedValue('result');
17+
18+
await expect(embeddings.runWithLoadedModel(operation)).resolves.toBe(
19+
'result'
20+
);
21+
expect(load).toHaveBeenCalledTimes(1);
22+
expect(operation).toHaveBeenCalledTimes(1);
23+
expect(unload).toHaveBeenCalledTimes(1);
24+
expect(load.mock.invocationCallOrder[0]).toBeLessThan(
25+
operation.mock.invocationCallOrder[0]
26+
);
27+
expect(operation.mock.invocationCallOrder[0]).toBeLessThan(
28+
unload.mock.invocationCallOrder[0]
29+
);
30+
});
31+
32+
it('unloads when the operation fails', async () => {
33+
const embeddings = makeEmbeddings();
34+
jest.spyOn(embeddings, 'load').mockResolvedValue(embeddings);
35+
const unload = jest.spyOn(embeddings, 'unload').mockResolvedValue();
36+
37+
await expect(
38+
embeddings.runWithLoadedModel(async () => {
39+
throw new Error('embedding failed');
40+
})
41+
).rejects.toThrow('embedding failed');
42+
expect(unload).toHaveBeenCalledTimes(1);
43+
});
44+
45+
it('serializes operations so one cannot unload another model session', async () => {
46+
const embeddings = makeEmbeddings();
47+
const load = jest.spyOn(embeddings, 'load').mockResolvedValue(embeddings);
48+
const unload = jest.spyOn(embeddings, 'unload').mockResolvedValue();
49+
let finishFirst!: () => void;
50+
let markFirstStarted!: () => void;
51+
const firstGate = new Promise<void>((resolve) => {
52+
finishFirst = resolve;
53+
});
54+
const firstStarted = new Promise<void>((resolve) => {
55+
markFirstStarted = resolve;
56+
});
57+
const order: string[] = [];
58+
59+
const first = embeddings.runWithLoadedModel(async () => {
60+
order.push('first-start');
61+
markFirstStarted();
62+
await firstGate;
63+
order.push('first-end');
64+
});
65+
const second = embeddings.runWithLoadedModel(async () => {
66+
order.push('second');
67+
});
68+
69+
await firstStarted;
70+
expect(order).toEqual(['first-start']);
71+
72+
finishFirst();
73+
await Promise.all([first, second]);
74+
75+
expect(order).toEqual(['first-start', 'first-end', 'second']);
76+
expect(load).toHaveBeenCalledTimes(2);
77+
expect(unload).toHaveBeenCalledTimes(2);
78+
});
79+
});
80+
1181
describe('embedding input limits', () => {
1282
it('sends a short query through with its prefix intact', async () => {
1383
const embeddings = makeEmbeddings();

__tests__/useAttachment.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,13 +30,16 @@ import { launchImageLibrary, launchCamera } from 'react-native-image-picker';
3030
import * as DocumentPicker from 'expo-document-picker';
3131
import { useAttachment } from '../hooks/useAttachment';
3232
import { useEmbeddingModelStore } from '../store/embeddingModelStore';
33+
import { useVectorStore } from '../context/VectorStoreContext';
3334

3435
const mockLaunchImageLibrary = launchImageLibrary as jest.Mock;
3536
const mockLaunchCamera = launchCamera as jest.Mock;
3637
const mockGetDocumentAsync = DocumentPicker.getDocumentAsync as jest.Mock;
38+
const mockUseVectorStore = useVectorStore as jest.Mock;
3739

3840
beforeEach(() => {
3941
jest.clearAllMocks();
42+
mockUseVectorStore.mockReturnValue({ vectorStore: {}, embeddings: null });
4043
useEmbeddingModelStore.setState({ status: 'ready', progress: 1 });
4144
});
4245

@@ -99,6 +102,13 @@ describe('useAttachment', () => {
99102
const mockAddSource = jest
100103
.fn()
101104
.mockResolvedValue({ success: true, sourceId: 42 });
105+
const runWithLoadedModel = jest.fn(
106+
async (operation: () => Promise<unknown>) => operation()
107+
);
108+
mockUseVectorStore.mockReturnValue({
109+
vectorStore: {},
110+
embeddings: { runWithLoadedModel },
111+
});
102112
const { useSourceStore } = require('../store/sourceStore');
103113
useSourceStore.getState.mockReturnValue({
104114
addSource: mockAddSource,
@@ -115,6 +125,7 @@ describe('useAttachment', () => {
115125
expect(att.type).toBe('document');
116126
expect(att.sourceId).toBe(42);
117127
expect(att.status).toBe('ready');
128+
expect(runWithLoadedModel).toHaveBeenCalledTimes(1);
118129
});
119130

120131
describe('abandoned source cleanup', () => {

components/chat-screen/ChatBar.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,18 +183,24 @@ const ChatBar = ({
183183
interrupt,
184184
loadModel,
185185
model: loadedModel,
186+
runWithModelOffloaded,
186187
} = useLLMStore();
187188
const loadSelectedModel = useCallback(async () => {
188189
if (model?.isDownloaded && loadedModel?.id !== model.id) {
189190
return loadModel(model);
190191
}
191192
}, [model, loadedModel, loadModel]);
192193

193-
const handleAttach = useCallback(() => {
194+
const handleAttach = useCallback(async () => {
194195
Keyboard.dismiss();
195-
loadSelectedModel();
196-
openSheet();
197-
}, [loadSelectedModel, openSheet]);
196+
try {
197+
await runWithModelOffloaded(async () => {}, { restore: false });
198+
} catch (error) {
199+
console.error('Failed to offload model before attachment picker:', error);
200+
} finally {
201+
openSheet();
202+
}
203+
}, [openSheet, runWithModelOffloaded]);
198204

199205
const imageAttachment = attachments.find((a) => a.type === 'image');
200206
const hasLoadingAttachment = attachments.some((a) => a.status === 'loading');

components/chat-screen/ChatScreen.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export default function ChatScreen({
8989
sendChatMessage,
9090
loadModel,
9191
model: loadedModel,
92+
runWithModelOffloaded,
9293
generationError,
9394
retryLastGeneration,
9495
} = useLLMStore();
@@ -252,15 +253,21 @@ export default function ChatScreen({
252253
let sourceDocuments: SourceDocument[] = [];
253254
let preferredSourceDocuments: SourceDocument[] = [];
254255
if (vectorStore) {
255-
({ context, sourceDocuments, preferredSourceDocuments } =
256-
await buildMessageSources({
256+
const prepareSources = () =>
257+
buildMessageSources({
257258
userInput,
258259
attachmentSourceIds,
259260
enabledSources,
260261
sources: allSources,
261262
vectorStore,
262263
embeddings,
263-
}));
264+
});
265+
({ context, sourceDocuments, preferredSourceDocuments } = embeddings
266+
? await runWithModelOffloaded(
267+
() => embeddings.runWithLoadedModel(prepareSources),
268+
{ restore: false }
269+
)
270+
: await prepareSources());
264271
}
265272

266273
// Enable new sources for this chat (persists for future messages)

context/VectorStoreContext.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { LFMEmbeddings } from '../utils/lfmEmbeddings';
1111
import { ensureKeywordIndex } from '../database/keywordIndex';
1212
import { isEmbeddingModelDownloaded } from '../utils/embeddingModel';
1313
import { useEmbeddingModelStore } from '../store/embeddingModelStore';
14+
import { useLLMStore } from '../store/llmStore';
1415

1516
const VectorStoreContext = createContext<{
1617
vectorStore: OPSQLiteVectorStore | null;
@@ -80,7 +81,13 @@ export const VectorStoreProvider = ({
8081

8182
if (cancelled) return;
8283
if (downloaded) {
83-
await store.load();
84+
await useLLMStore.getState().runWithModelOffloaded(
85+
async () => {
86+
await store.load();
87+
await lfmEmbeddings.unload();
88+
},
89+
{ restore: false }
90+
);
8491
}
8592

8693
if (cancelled) return;

hooks/useAttachment.ts

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import Toast from 'react-native-toast-message';
77
import { useSourceStore } from '../store/sourceStore';
88
import { useVectorStore } from '../context/VectorStoreContext';
99
import { useEmbeddingModelStore } from '../store/embeddingModelStore';
10+
import { useLLMStore } from '../store/llmStore';
1011
import { documentErrorMessage } from '../utils/documentErrorMessage';
1112

1213
export interface Attachment {
@@ -194,14 +195,25 @@ export const useAttachment = () => {
194195
prev.map((a) => (a.id === attachmentId ? { ...a, progress } : a))
195196
);
196197
};
197-
const result = await addSource(
198-
newSource,
199-
asset.uri,
200-
vectorStore!,
201-
embeddings,
202-
handleProgress,
203-
abortController.signal
204-
);
198+
const addDocumentSource = () =>
199+
addSource(
200+
newSource,
201+
asset.uri,
202+
vectorStore!,
203+
embeddings,
204+
handleProgress,
205+
abortController.signal
206+
);
207+
const indexDocumentSource = () =>
208+
embeddings
209+
? useLLMStore
210+
.getState()
211+
.runWithModelOffloaded(
212+
() => embeddings.runWithLoadedModel(addDocumentSource),
213+
{ restore: false }
214+
)
215+
: addDocumentSource();
216+
const result = await indexDocumentSource();
205217
if (result.cancelled) return;
206218
const isCurrentDocumentRequest =
207219
attachmentRequestRef.current === requestId &&
@@ -271,9 +283,16 @@ export const useAttachment = () => {
271283

272284
const downloadModelAndContinue = useCallback(async () => {
273285
if (!vectorStore) return;
274-
const ready = await useEmbeddingModelStore
275-
.getState()
276-
.ensureReady(vectorStore);
286+
const ready = await useLLMStore.getState().runWithModelOffloaded(
287+
async () => {
288+
const loaded = await useEmbeddingModelStore
289+
.getState()
290+
.ensureReady(vectorStore);
291+
await embeddings?.unload();
292+
return loaded;
293+
},
294+
{ restore: false }
295+
);
277296
if (!ready) {
278297
Toast.show({
279298
type: 'defaultToast',
@@ -285,7 +304,7 @@ export const useAttachment = () => {
285304
embeddingDownloadSheetRef.current?.dismiss();
286305
await runDocumentPicker();
287306
}
288-
}, [vectorStore, runDocumentPicker]);
307+
}, [vectorStore, embeddings, runDocumentPicker]);
289308

290309
const removeAttachment = useCallback(
291310
(id: string) => {

0 commit comments

Comments
 (0)