Skip to content
This repository was archived by the owner on Jul 4, 2025. It is now read-only.

Commit 65da850

Browse files
authored
Merge pull request #686 from janhq/feat/add-download-state
feat: add download state
2 parents 2b47c8e + 5b44c0a commit 65da850

File tree

13 files changed

+426
-11080
lines changed

13 files changed

+426
-11080
lines changed

cortex-js/package-lock.json

Lines changed: 0 additions & 11047 deletions
This file was deleted.

cortex-js/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"@nestjs/config": "^3.2.2",
3838
"@nestjs/core": "^10.0.0",
3939
"@nestjs/devtools-integration": "^0.1.6",
40+
"@nestjs/event-emitter": "^2.0.4",
4041
"@nestjs/mapped-types": "*",
4142
"@nestjs/platform-express": "^10.0.0",
4243
"@nestjs/swagger": "^7.3.1",

cortex-js/src/app.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ import { env } from 'node:process';
1414
import { SeedService } from './usecases/seed/seed.service';
1515
import { FileManagerModule } from './infrastructure/services/file-manager/file-manager.module';
1616
import { AppLoggerMiddleware } from './infrastructure/middlewares/app.logger.middleware';
17+
import { EventEmitterModule } from '@nestjs/event-emitter';
18+
import { AppController } from './infrastructure/controllers/app.controller';
19+
import { DownloadManagerModule } from './download-manager/download-manager.module';
1720

1821
@Module({
1922
imports: [
@@ -24,6 +27,7 @@ import { AppLoggerMiddleware } from './infrastructure/middlewares/app.logger.mid
2427
isGlobal: true,
2528
envFilePath: env.NODE_ENV !== 'production' ? '.env.development' : '.env',
2629
}),
30+
EventEmitterModule.forRoot(),
2731
DatabaseModule,
2832
MessagesModule,
2933
ThreadsModule,
@@ -34,7 +38,9 @@ import { AppLoggerMiddleware } from './infrastructure/middlewares/app.logger.mid
3438
ExtensionModule,
3539
FileManagerModule,
3640
ModelRepositoryModule,
41+
DownloadManagerModule,
3742
],
43+
controllers: [AppController],
3844
providers: [SeedService],
3945
})
4046
export class AppModule implements NestModule {

cortex-js/src/command.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import { KillCommand } from './infrastructure/commanders/kill.command';
2929
import { PresetCommand } from './infrastructure/commanders/presets.command';
3030
import { EmbeddingCommand } from './infrastructure/commanders/embeddings.command';
3131
import { BenchmarkCommand } from './infrastructure/commanders/benchmark.command';
32+
import { EventEmitterModule } from '@nestjs/event-emitter';
33+
import { DownloadManagerModule } from './download-manager/download-manager.module';
3234

3335
@Module({
3436
imports: [
@@ -37,6 +39,7 @@ import { BenchmarkCommand } from './infrastructure/commanders/benchmark.command'
3739
envFilePath:
3840
process.env.NODE_ENV !== 'production' ? '.env.development' : '.env',
3941
}),
42+
EventEmitterModule.forRoot(),
4043
DatabaseModule,
4144
ModelsModule,
4245
CortexModule,
@@ -46,6 +49,7 @@ import { BenchmarkCommand } from './infrastructure/commanders/benchmark.command'
4649
AssistantsModule,
4750
MessagesModule,
4851
FileManagerModule,
52+
DownloadManagerModule,
4953
],
5054
providers: [
5155
CortexCommand,
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
export class DownloadState {
2+
/**
3+
* The id of a particular download. Being used to prevent duplication of downloads.
4+
*/
5+
id: string;
6+
7+
/**
8+
* For displaying purposes.
9+
*/
10+
title: string;
11+
12+
/**
13+
* The type of download.
14+
*/
15+
type: DownloadType;
16+
17+
/**
18+
* The status of the download.
19+
*/
20+
status: DownloadStatus;
21+
22+
/**
23+
* Explanation of the error if the download failed.
24+
*/
25+
error?: string;
26+
27+
/**
28+
* The actual downloads. [DownloadState] is just a group to supporting for download multiple files.
29+
*/
30+
children: DownloadItem[];
31+
}
32+
33+
export enum DownloadStatus {
34+
Pending = 'pending',
35+
Downloading = 'downloading',
36+
Error = 'error',
37+
Downloaded = 'downloaded',
38+
}
39+
40+
export class DownloadItem {
41+
/**
42+
* Filename of the download.
43+
*/
44+
id: string;
45+
46+
time: {
47+
elapsed: number;
48+
remaining: number;
49+
};
50+
51+
size: {
52+
total: number;
53+
transferred: number;
54+
};
55+
56+
checksum?: string;
57+
58+
status: DownloadStatus;
59+
60+
error?: string;
61+
62+
metadata?: Record<string, unknown>;
63+
}
64+
65+
export interface DownloadStateEvent {
66+
data: DownloadState[];
67+
}
68+
69+
export enum DownloadType {
70+
Model = 'model',
71+
Miscelanous = 'miscelanous',
72+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { Module } from '@nestjs/common';
2+
import { DownloadManagerService } from './download-manager.service';
3+
import { HttpModule } from '@nestjs/axios';
4+
5+
@Module({
6+
imports: [HttpModule],
7+
providers: [DownloadManagerService],
8+
exports: [DownloadManagerService],
9+
})
10+
export class DownloadManagerModule {}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { DownloadManagerService } from './download-manager.service';
3+
4+
describe('DownloadManagerService', () => {
5+
let service: DownloadManagerService;
6+
7+
beforeEach(async () => {
8+
const module: TestingModule = await Test.createTestingModule({
9+
providers: [DownloadManagerService],
10+
}).compile();
11+
12+
service = module.get<DownloadManagerService>(DownloadManagerService);
13+
});
14+
15+
it('should be defined', () => {
16+
expect(service).toBeDefined();
17+
});
18+
});
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import {
2+
DownloadItem,
3+
DownloadState,
4+
DownloadStatus,
5+
DownloadType,
6+
} from '@/domain/models/download.interface';
7+
import { HttpService } from '@nestjs/axios';
8+
import { Injectable } from '@nestjs/common';
9+
import { EventEmitter2 } from '@nestjs/event-emitter';
10+
import { createWriteStream } from 'node:fs';
11+
import { firstValueFrom } from 'rxjs';
12+
13+
@Injectable()
14+
export class DownloadManagerService {
15+
private allDownloadStates: DownloadState[] = [];
16+
private abortControllers: Record<string, Record<string, AbortController>> =
17+
{};
18+
19+
constructor(
20+
private readonly httpService: HttpService,
21+
private readonly eventEmitter: EventEmitter2,
22+
) {
23+
// start emitting download state each 500ms
24+
setInterval(() => {
25+
this.eventEmitter.emit('download.event', this.allDownloadStates);
26+
}, 500);
27+
}
28+
29+
async abortDownload(downloadId: string) {
30+
if (!this.abortControllers[downloadId]) {
31+
return;
32+
}
33+
Object.keys(this.abortControllers[downloadId]).forEach((destination) => {
34+
this.abortControllers[downloadId][destination].abort();
35+
});
36+
delete this.abortControllers[downloadId];
37+
this.allDownloadStates = this.allDownloadStates.filter(
38+
(downloadState) => downloadState.id !== downloadId,
39+
);
40+
}
41+
42+
async submitDownloadRequest(
43+
downloadId: string,
44+
title: string,
45+
downloadType: DownloadType,
46+
urlToDestination: Record<string, string>,
47+
) {
48+
if (
49+
this.allDownloadStates.find(
50+
(downloadState) => downloadState.id === downloadId,
51+
)
52+
) {
53+
return;
54+
}
55+
56+
const downloadItems: DownloadItem[] = Object.keys(urlToDestination).map(
57+
(url) => {
58+
const destination = urlToDestination[url];
59+
const downloadItem: DownloadItem = {
60+
id: destination,
61+
time: {
62+
elapsed: 0,
63+
remaining: 0,
64+
},
65+
size: {
66+
total: 0,
67+
transferred: 0,
68+
},
69+
status: DownloadStatus.Downloading,
70+
};
71+
72+
return downloadItem;
73+
},
74+
);
75+
76+
const downloadState: DownloadState = {
77+
id: downloadId,
78+
title: title,
79+
type: downloadType,
80+
status: DownloadStatus.Downloading,
81+
children: downloadItems,
82+
};
83+
84+
this.allDownloadStates.push(downloadState);
85+
this.abortControllers[downloadId] = {};
86+
87+
Object.keys(urlToDestination).forEach((url) => {
88+
const destination = urlToDestination[url];
89+
this.downloadFile(downloadId, url, destination);
90+
});
91+
}
92+
93+
private async downloadFile(
94+
downloadId: string,
95+
url: string,
96+
destination: string,
97+
) {
98+
const controller = new AbortController();
99+
// adding to abort controllers
100+
this.abortControllers[downloadId][destination] = controller;
101+
102+
const response = await firstValueFrom(
103+
this.httpService.get(url, {
104+
responseType: 'stream',
105+
signal: controller.signal,
106+
}),
107+
);
108+
109+
// check if response is success
110+
if (!response) {
111+
throw new Error('Failed to download model');
112+
}
113+
114+
const writer = createWriteStream(destination);
115+
const totalBytes = response.headers['content-length'];
116+
117+
// update download state
118+
const currentDownloadState = this.allDownloadStates.find(
119+
(downloadState) => downloadState.id === downloadId,
120+
);
121+
if (!currentDownloadState) {
122+
return;
123+
}
124+
const downloadItem = currentDownloadState?.children.find(
125+
(downloadItem) => downloadItem.id === destination,
126+
);
127+
if (downloadItem) {
128+
downloadItem.size.total = totalBytes;
129+
}
130+
131+
let transferredBytes = 0;
132+
133+
writer.on('finish', () => {
134+
// delete the abort controller
135+
delete this.abortControllers[downloadId][destination];
136+
const currentDownloadState = this.allDownloadStates.find(
137+
(downloadState) => downloadState.id === downloadId,
138+
);
139+
if (!currentDownloadState) {
140+
return;
141+
}
142+
143+
// update current child status to downloaded, find by destination as id
144+
const downloadItem = currentDownloadState?.children.find(
145+
(downloadItem) => downloadItem.id === destination,
146+
);
147+
if (downloadItem) {
148+
downloadItem.status = DownloadStatus.Downloaded;
149+
}
150+
151+
const allChildrenDownloaded = currentDownloadState?.children.every(
152+
(downloadItem) => downloadItem.status === DownloadStatus.Downloaded,
153+
);
154+
155+
if (allChildrenDownloaded) {
156+
delete this.abortControllers[downloadId];
157+
currentDownloadState.status = DownloadStatus.Downloaded;
158+
// remove download state if all children is downloaded
159+
this.allDownloadStates = this.allDownloadStates.filter(
160+
(downloadState) => downloadState.id !== downloadId,
161+
);
162+
}
163+
});
164+
165+
writer.on('error', (error) => {
166+
delete this.abortControllers[downloadId][destination];
167+
const currentDownloadState = this.allDownloadStates.find(
168+
(downloadState) => downloadState.id === downloadId,
169+
);
170+
if (!currentDownloadState) {
171+
return;
172+
}
173+
174+
const downloadItem = currentDownloadState?.children.find(
175+
(downloadItem) => downloadItem.id === destination,
176+
);
177+
if (downloadItem) {
178+
downloadItem.status = DownloadStatus.Error;
179+
downloadItem.error = error.message;
180+
}
181+
182+
currentDownloadState.status = DownloadStatus.Error;
183+
currentDownloadState.error = error.message;
184+
185+
// remove download state if all children is downloaded
186+
this.allDownloadStates = this.allDownloadStates.filter(
187+
(downloadState) => downloadState.id !== downloadId,
188+
);
189+
});
190+
191+
response.data.on('data', (chunk: any) => {
192+
transferredBytes += chunk.length;
193+
194+
const currentDownloadState = this.allDownloadStates.find(
195+
(downloadState) => downloadState.id === downloadId,
196+
);
197+
if (!currentDownloadState) return;
198+
199+
const downloadItem = currentDownloadState?.children.find(
200+
(downloadItem) => downloadItem.id === destination,
201+
);
202+
if (downloadItem) {
203+
downloadItem.size.transferred = transferredBytes;
204+
}
205+
});
206+
207+
response.data.pipe(writer);
208+
}
209+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import {
2+
DownloadState,
3+
DownloadStateEvent,
4+
} from '@/domain/models/download.interface';
5+
import { Controller, Sse } from '@nestjs/common';
6+
import { EventEmitter2 } from '@nestjs/event-emitter';
7+
import { Observable, fromEvent, map } from 'rxjs';
8+
9+
@Controller('app')
10+
export class AppController {
11+
constructor(private readonly eventEmitter: EventEmitter2) {}
12+
13+
@Sse('download')
14+
downloadEvent(): Observable<DownloadStateEvent> {
15+
return fromEvent(this.eventEmitter, 'download.event').pipe(
16+
map((downloadState: DownloadState[]) => ({ data: downloadState })),
17+
);
18+
}
19+
}

0 commit comments

Comments
 (0)