Skip to content

Commit 6932db7

Browse files
authored
Merge pull request #8 from openai/jayw/dev
Implement local file lazy loading with web workers
2 parents 07af2a7 + bb976f9 commit 6932db7

3 files changed

Lines changed: 441 additions & 113 deletions

File tree

src/components/app/app.ts

Lines changed: 161 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import type {
3232
import { EuphonySearchWindow } from '../search-window/search-window';
3333
import { NightjarToast } from '../toast/toast';
3434
import { EuphonyTokenWindow } from '../token-window/token-window';
35+
import type { LocalDataWorkerMessage } from './local-data-worker';
36+
import LocalDataWorkerInline from './local-data-worker?worker';
3537
import { RequestWorker } from './request-worker';
3638
import { URLManager } from './url-manager';
3739

@@ -276,6 +278,19 @@ export class EuphonyApp extends LitElement {
276278

277279
// URL manager
278280
urlManager: URLManager;
281+
localDataWorker: Worker;
282+
localDataWorkerRequestCount = 0;
283+
get localDataWorkerRequestID() {
284+
return this.localDataWorkerRequestCount++;
285+
}
286+
activeLocalDataWorkerRequestID: number | null = null;
287+
localDataWorkerPendingRequests = new Map<
288+
number,
289+
{
290+
resolve: () => void;
291+
reject: (reason?: unknown) => void;
292+
}
293+
>();
279294

280295
// Debouncers
281296
cacheInfoTooltipDebouncer: number | null = null;
@@ -287,6 +302,13 @@ export class EuphonyApp extends LitElement {
287302
super();
288303

289304
this.urlManager = new URLManager(this);
305+
this.localDataWorker = new LocalDataWorkerInline();
306+
this.localDataWorker.addEventListener(
307+
'message',
308+
(e: MessageEvent<LocalDataWorkerMessage>) => {
309+
this.localDataWorkerMessageHandler(e);
310+
}
311+
);
290312

291313
// Update the configs based on the current URL
292314
this.urlManager.updateConfigsFromURL();
@@ -380,6 +402,11 @@ export class EuphonyApp extends LitElement {
380402
});
381403
}
382404

405+
disconnectedCallback(): void {
406+
this.localDataWorker.terminate();
407+
super.disconnectedCallback();
408+
}
409+
383410
/**
384411
* This method is called when the DOM is added for the first time
385412
*/
@@ -786,12 +813,14 @@ export class EuphonyApp extends LitElement {
786813
break;
787814
}
788815
case 'Load from clipboard': {
816+
this.isLoadingData = true;
789817
navigator.clipboard.readText().then(
790-
clipText => {
791-
this.loadDataFromText(clipText, 'clipboard');
818+
async clipText => {
819+
await this.loadDataFromText(clipText, 'clipboard');
792820
},
793821
(err: unknown) => {
794822
console.error('Failed to read clipboard contents: ', err);
823+
this.isLoadingData = false;
795824
}
796825
);
797826
break;
@@ -1300,125 +1329,147 @@ export class EuphonyApp extends LitElement {
13001329
}
13011330
};
13021331

1303-
loadDataFromText = (sourceText: string, sourceName: 'clipboard' | 'file') => {
1304-
let allData: (Record<string, unknown> | string | Conversation)[] = [];
1305-
// Try to convert the source text to a JSON or JSONL file.
1306-
try {
1307-
const jsonData = JSON.parse(sourceText) as Record<string, unknown>;
1308-
allData = [jsonData];
1309-
} catch (_error) {
1310-
// Try to read each line as a JSON object
1311-
for (const line of sourceText.split('\n')) {
1312-
try {
1313-
allData.push(JSON.parse(line) as Record<string, unknown> | string);
1314-
} catch (_error) {
1315-
// pass
1332+
loadDataFromText = (
1333+
sourceText: string,
1334+
sourceName: 'clipboard' | 'file'
1335+
) => {
1336+
this.curPage = 1;
1337+
this.resetHash();
1338+
const requestID = this.localDataWorkerRequestID;
1339+
this.activeLocalDataWorkerRequestID = requestID;
1340+
1341+
return new Promise<void>((resolve, reject) => {
1342+
this.localDataWorkerPendingRequests.set(requestID, { resolve, reject });
1343+
const message: LocalDataWorkerMessage = {
1344+
command: 'startParseData',
1345+
payload: {
1346+
requestID,
1347+
sourceName,
1348+
sourceText
13161349
}
1317-
}
1318-
}
1350+
};
1351+
this.localDataWorker.postMessage(message);
1352+
});
1353+
};
13191354

1320-
// Return if there is no data read
1321-
if (allData.length === 0) {
1322-
this.toastMessage = `Failed to read any JSON or JSONL data from your ${sourceName}. Please double check and try again.`;
1323-
this.toastType = 'error';
1324-
if (this.toastComponent) {
1325-
this.toastComponent.show();
1326-
}
1327-
return;
1328-
}
1355+
loadDataFromFile = (sourceFile: File) => {
1356+
this.curPage = 1;
1357+
this.resetHash();
1358+
const requestID = this.localDataWorkerRequestID;
1359+
this.activeLocalDataWorkerRequestID = requestID;
1360+
1361+
return new Promise<void>((resolve, reject) => {
1362+
this.localDataWorkerPendingRequests.set(requestID, { resolve, reject });
1363+
const message: LocalDataWorkerMessage = {
1364+
command: 'startParseData',
1365+
payload: {
1366+
requestID,
1367+
sourceName: 'file',
1368+
sourceFile
1369+
}
1370+
};
1371+
this.localDataWorker.postMessage(message);
1372+
});
1373+
};
13291374

1330-
this.codexSessionData = [];
1375+
localDataWorkerMessageHandler(e: MessageEvent<LocalDataWorkerMessage>) {
1376+
switch (e.data.command) {
1377+
case 'finishParseData': {
1378+
const { requestID, sourceName, dataType } = e.data.payload;
1379+
const pendingRequest =
1380+
this.localDataWorkerPendingRequests.get(requestID);
1381+
this.localDataWorkerPendingRequests.delete(requestID);
1382+
if (requestID !== this.activeLocalDataWorkerRequestID) {
1383+
pendingRequest?.resolve();
1384+
break;
1385+
}
1386+
blobPath = null;
1387+
this.isLoadingData = false;
13311388

1332-
// Codex session JSONL is a stream of event objects, not Harmony
1333-
// conversations. Detect it early and render with the Codex component.
1334-
if (isCodexSessionJSONL(allData as unknown[])) {
1335-
this.codexSessionData = [allData as unknown[]];
1336-
this.allConversationData = [];
1337-
this.conversationData = [];
1338-
this.JSONData = [];
1339-
this.selectedConversationIDs = new Set();
1340-
this.dataType = DataType.CODEX;
1341-
this._totalConversationSize = 1;
1342-
this._totalConversationSizeIncludingUnfiltered = 1;
1343-
this.isLoadingFromCache = false;
1344-
this.isLoadingFromClipboard = true;
1345-
1346-
this.toastMessage = `Codex session loaded successfully from ${sourceName}`;
1347-
this.toastType = 'success';
1348-
if (this.toastComponent) {
1349-
this.toastComponent.show();
1350-
}
1351-
return;
1352-
}
1389+
this.codexSessionData = [];
1390+
this.allConversationData = [];
1391+
this.conversationData = [];
1392+
this.JSONData = [];
13531393

1354-
// Validate the data
1355-
// If the data is not a conversation, we render it as JSON
1356-
if (!this.validateAndTransformConversations(allData)) {
1357-
this.toastMessage =
1358-
'Failed to find harmony-formatted data. Render JSON instead.';
1359-
this.toastType = 'warning';
1360-
if (this.toastComponent) {
1361-
this.toastComponent.show();
1362-
}
1394+
if (dataType === 'codex') {
1395+
this.codexSessionData = [e.data.payload.codexSessionData];
1396+
this.selectedConversationIDs = new Set();
1397+
this.dataType = DataType.CODEX;
1398+
this._totalConversationSize = 1;
1399+
this._totalConversationSizeIncludingUnfiltered = 1;
1400+
this.isLoadingFromCache = false;
1401+
this.isLoadingFromClipboard = true;
13631402

1364-
this.JSONData = allData as Record<string, unknown>[];
1365-
this.dataType = DataType.JSON;
1366-
this._totalConversationSize = allData.length;
1367-
this._totalConversationSizeIncludingUnfiltered = allData.length;
1368-
return;
1369-
}
1403+
this.toastMessage = `Codex session loaded successfully from ${sourceName}`;
1404+
this.toastType = 'success';
1405+
} else if (dataType === 'json') {
1406+
this.JSONData = e.data.payload.jsonData;
1407+
this.dataType = DataType.JSON;
1408+
this._totalConversationSize = this.JSONData.length;
1409+
this._totalConversationSizeIncludingUnfiltered = this.JSONData.length;
1410+
this.isLoadingFromCache = false;
1411+
this.isLoadingFromClipboard = true;
1412+
1413+
this.toastMessage =
1414+
'Failed to find harmony-formatted data. Render JSON instead.';
1415+
this.toastType = 'warning';
1416+
} else {
1417+
const conversationData = e.data.payload.conversationData;
1418+
this._totalConversationSize = conversationData.length;
1419+
this._totalConversationSizeIncludingUnfiltered =
1420+
conversationData.length;
1421+
1422+
if (this.isEditorMode) {
1423+
this.selectedConversationIDs = new Set();
1424+
for (let i = 0; i < conversationData.length; i++) {
1425+
this.selectedConversationIDs.add(i);
1426+
}
1427+
}
1428+
1429+
this.allConversationData = conversationData;
1430+
this.conversationData = this.isEditorMode
1431+
? conversationData
1432+
: conversationData.slice(
1433+
(this.curPage - 1) * this.itemsPerPage,
1434+
this.curPage * this.itemsPerPage
1435+
);
1436+
this.dataType = DataType.CONVERSATION;
1437+
this.isLoadingFromCache = false;
1438+
this.isLoadingFromClipboard = true;
13701439

1371-
// The data is valid conversation, so we render it as conversations
1372-
this._totalConversationSize = allData.length;
1373-
this._totalConversationSizeIncludingUnfiltered = allData.length;
1440+
this.toastMessage = `Data loaded successfully from ${sourceName}`;
1441+
this.toastType = 'success';
1442+
}
13741443

1375-
// Set all the conversations as selected in editor mode
1376-
if (this.isEditorMode) {
1377-
this.selectedConversationIDs = new Set();
1378-
for (let i = 0; i < allData.length; i++) {
1379-
this.selectedConversationIDs.add(i);
1444+
this.toastComponent?.show();
1445+
pendingRequest?.resolve();
1446+
break;
13801447
}
1381-
}
13821448

1383-
// People might encode the JSON differently, so we need to load them based
1384-
// on the type
1385-
if (typeof allData[0] === 'string') {
1386-
const newData: Conversation[] = allData.map(item => {
1387-
if (typeof item === 'string') {
1388-
const parsed = parseConversationJSONString(item);
1389-
if (parsed === null) {
1390-
this.toastMessage = `Failed to format JSONL data from your ${sourceName}. Please double check and try again.`;
1391-
this.toastType = 'error';
1392-
if (this.toastComponent) {
1393-
this.toastComponent.show();
1394-
}
1395-
throw new Error('Failed to parse conversation JSON string');
1396-
}
1397-
return parsed;
1449+
case 'error': {
1450+
const { requestID, sourceName, message } = e.data.payload;
1451+
const pendingRequest =
1452+
this.localDataWorkerPendingRequests.get(requestID);
1453+
this.localDataWorkerPendingRequests.delete(requestID);
1454+
if (requestID !== this.activeLocalDataWorkerRequestID) {
1455+
pendingRequest?.reject(new Error(message));
1456+
break;
13981457
}
1399-
return item as Conversation;
1400-
});
1401-
this.allConversationData = newData;
1402-
this.conversationData = newData;
1403-
this.dataType = DataType.CONVERSATION;
1404-
} else {
1405-
const typedData = allData as Conversation[];
1406-
this.allConversationData = typedData;
1407-
this.conversationData = typedData;
1408-
this.dataType = DataType.CONVERSATION;
1409-
}
1458+
this.isLoadingData = false;
14101459

1411-
// Update the cache info
1412-
this.isLoadingFromCache = false;
1413-
this.isLoadingFromClipboard = true;
1460+
this.toastMessage = `Failed to read any JSON or JSONL data from your ${sourceName}. Please double check and try again.\n\n${message}`;
1461+
this.toastType = 'error';
1462+
this.toastComponent?.show();
1463+
pendingRequest?.reject(new Error(message));
1464+
break;
1465+
}
14141466

1415-
// Show a successful toast
1416-
this.toastMessage = `Data loaded successfully from ${sourceName}`;
1417-
this.toastType = 'success';
1418-
if (this.toastComponent) {
1419-
this.toastComponent.show();
1467+
default: {
1468+
console.error('Unknown local data worker message', e.data.command);
1469+
break;
1470+
}
14201471
}
1421-
};
1472+
}
14221473

14231474
localFileInputChanged(e: Event) {
14241475
const inputElement = e.target as HTMLInputElement;
@@ -1427,15 +1478,13 @@ export class EuphonyApp extends LitElement {
14271478
return;
14281479
}
14291480

1430-
file
1431-
.text()
1432-
.then(text => {
1433-
this.loadDataFromText(text, 'file');
1434-
})
1481+
this.isLoadingData = true;
1482+
this.loadDataFromFile(file)
14351483
.catch((error: unknown) => {
14361484
this.toastMessage = `Failed to read local file.\n\n${error}`;
14371485
this.toastType = 'error';
14381486
this.toastComponent?.show();
1487+
this.isLoadingData = false;
14391488
})
14401489
.finally(() => {
14411490
inputElement.value = '';

0 commit comments

Comments
 (0)