@hed-hog/core 0.0.222 → 0.0.232

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/ai/ai.controller.d.ts +90 -0
  2. package/dist/ai/ai.controller.d.ts.map +1 -0
  3. package/dist/ai/ai.controller.js +119 -0
  4. package/dist/ai/ai.controller.js.map +1 -0
  5. package/dist/ai/ai.module.d.ts +3 -0
  6. package/dist/ai/ai.module.d.ts.map +1 -0
  7. package/dist/ai/ai.module.js +26 -0
  8. package/dist/ai/ai.module.js.map +1 -0
  9. package/dist/ai/ai.service.d.ts +69 -0
  10. package/dist/ai/ai.service.d.ts.map +1 -0
  11. package/dist/ai/ai.service.js +394 -0
  12. package/dist/ai/ai.service.js.map +1 -0
  13. package/dist/ai/dto/chat-agent.dto.d.ts +4 -0
  14. package/dist/ai/dto/chat-agent.dto.d.ts.map +1 -0
  15. package/dist/ai/dto/chat-agent.dto.js +22 -0
  16. package/dist/ai/dto/chat-agent.dto.js.map +1 -0
  17. package/dist/ai/dto/chat.dto.d.ts +7 -0
  18. package/dist/ai/dto/chat.dto.d.ts.map +1 -0
  19. package/dist/ai/dto/chat.dto.js +38 -0
  20. package/dist/ai/dto/chat.dto.js.map +1 -0
  21. package/dist/ai/dto/create-agent.dto.d.ts +7 -0
  22. package/dist/ai/dto/create-agent.dto.d.ts.map +1 -0
  23. package/dist/ai/dto/create-agent.dto.js +38 -0
  24. package/dist/ai/dto/create-agent.dto.js.map +1 -0
  25. package/dist/ai/dto/update-agent.dto.d.ts +7 -0
  26. package/dist/ai/dto/update-agent.dto.d.ts.map +1 -0
  27. package/dist/ai/dto/update-agent.dto.js +38 -0
  28. package/dist/ai/dto/update-agent.dto.js.map +1 -0
  29. package/dist/auth/auth.controller.d.ts +2 -2
  30. package/dist/auth/auth.service.d.ts +7 -7
  31. package/dist/core.module.d.ts.map +1 -1
  32. package/dist/core.module.js +3 -0
  33. package/dist/core.module.js.map +1 -1
  34. package/dist/file/file.service.d.ts +2 -0
  35. package/dist/file/file.service.d.ts.map +1 -1
  36. package/dist/file/file.service.js +17 -2
  37. package/dist/file/file.service.js.map +1 -1
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/session/session.controller.d.ts +1 -1
  43. package/dist/session/session.service.d.ts +3 -3
  44. package/dist/user/user.controller.d.ts +1 -1
  45. package/dist/user/user.service.d.ts +3 -3
  46. package/hedhog/data/menu.yaml +17 -3
  47. package/hedhog/data/route.yaml +64 -0
  48. package/hedhog/data/setting_group.yaml +33 -1
  49. package/hedhog/frontend/app/ai_agent/page.tsx.ejs +468 -0
  50. package/hedhog/table/ai_agent.yaml +24 -0
  51. package/package.json +5 -5
  52. package/src/ai/ai.controller.ts +61 -0
  53. package/src/ai/ai.module.ts +13 -0
  54. package/src/ai/ai.service.ts +546 -0
  55. package/src/ai/dto/chat-agent.dto.ts +7 -0
  56. package/src/ai/dto/chat.dto.ts +20 -0
  57. package/src/ai/dto/create-agent.dto.ts +20 -0
  58. package/src/ai/dto/update-agent.dto.ts +20 -0
  59. package/src/core.module.ts +3 -0
  60. package/src/file/file.service.ts +34 -8
  61. package/src/index.ts +1 -0
@@ -0,0 +1,546 @@
1
+ import { PaginationDTO } from '@hed-hog/api-pagination';
2
+ import { PrismaService } from '@hed-hog/api-prisma';
3
+ import {
4
+ BadRequestException,
5
+ forwardRef,
6
+ Inject,
7
+ Injectable,
8
+ NotFoundException,
9
+ } from '@nestjs/common';
10
+ import { Prisma } from '@prisma/client';
11
+ import axios from 'axios';
12
+ import { DeleteDTO } from '../dto/delete.dto';
13
+ import { SettingService } from '../setting/setting.service';
14
+ import { ChatAgentDTO } from './dto/chat-agent.dto';
15
+ import { ChatDTO } from './dto/chat.dto';
16
+ import { CreateAgentDTO } from './dto/create-agent.dto';
17
+ import { UpdateAgentDTO } from './dto/update-agent.dto';
18
+
19
+ type AiProvider = 'openai' | 'gemini';
20
+
21
+ type AiAgentRecord = {
22
+ id: number;
23
+ slug: string;
24
+ provider: AiProvider;
25
+ model: string | null;
26
+ instructions: string | null;
27
+ external_agent_id: string | null;
28
+ created_at: Date;
29
+ updated_at: Date;
30
+ };
31
+
32
+ @Injectable()
33
+ export class AiService {
34
+ constructor(
35
+ @Inject(forwardRef(() => PrismaService))
36
+ private readonly prismaService: PrismaService,
37
+ @Inject(forwardRef(() => SettingService))
38
+ private readonly settingService: SettingService,
39
+ ) {}
40
+
41
+ async chat(data: ChatDTO) {
42
+ const provider = data.provider || 'openai';
43
+
44
+ if (provider === 'openai') {
45
+ const model = data.model || 'gpt-4o-mini';
46
+ const content = await this.chatWithOpenAi({
47
+ message: data.message,
48
+ model,
49
+ systemPrompt: data.systemPrompt,
50
+ });
51
+
52
+ return {
53
+ provider,
54
+ model,
55
+ content,
56
+ };
57
+ }
58
+
59
+ const model = data.model || 'gemini-1.5-flash';
60
+ const content = await this.chatWithGemini({
61
+ message: data.message,
62
+ model,
63
+ systemPrompt: data.systemPrompt,
64
+ });
65
+
66
+ return {
67
+ provider,
68
+ model,
69
+ content,
70
+ };
71
+ }
72
+
73
+ async createAgent(data: CreateAgentDTO) {
74
+ const slug = data.slug.trim();
75
+ const provider = data.provider || 'openai';
76
+ const model = data.model || this.getDefaultModel(provider);
77
+ const instructions = data.instructions?.trim() || null;
78
+
79
+ const existing = await this.findAgentBySlug(slug);
80
+ if (existing) {
81
+ return existing;
82
+ }
83
+
84
+ let externalAgentId: string | null = null;
85
+
86
+ if (provider === 'openai') {
87
+ externalAgentId = await this.createOpenAiAssistant({
88
+ slug,
89
+ model,
90
+ instructions,
91
+ });
92
+ }
93
+
94
+ await this.prismaService.$executeRaw`
95
+ INSERT INTO ai_agent (slug, provider, model, instructions, external_agent_id)
96
+ VALUES (${slug}, ${provider}, ${model}, ${instructions}, ${externalAgentId})
97
+ `;
98
+
99
+ return this.findAgentBySlug(slug);
100
+ }
101
+
102
+ async listAgents(paginationParams: PaginationDTO) {
103
+ const page = Math.max(Number(paginationParams?.page || 1), 1);
104
+ const pageSize = Math.min(
105
+ Math.max(Number(paginationParams?.pageSize || 10), 1),
106
+ 100,
107
+ );
108
+ const offset = (page - 1) * pageSize;
109
+ const search = String(paginationParams?.search || '').trim();
110
+
111
+ const whereClause = search
112
+ ? Prisma.sql`WHERE LOWER(slug) LIKE LOWER(${`%${search}%`})`
113
+ : Prisma.empty;
114
+
115
+ const data = await this.prismaService.$queryRaw<AiAgentRecord[]>(Prisma.sql`
116
+ SELECT id, slug, provider, model, instructions, external_agent_id, created_at, updated_at
117
+ FROM ai_agent
118
+ ${whereClause}
119
+ ORDER BY id DESC
120
+ LIMIT ${pageSize}
121
+ OFFSET ${offset}
122
+ `);
123
+
124
+ const totalResult = await this.prismaService.$queryRaw<Array<{ total: bigint | number | string }>>(Prisma.sql`
125
+ SELECT COUNT(*) AS total
126
+ FROM ai_agent
127
+ ${whereClause}
128
+ `);
129
+
130
+ const total = Number(totalResult?.[0]?.total || 0);
131
+
132
+ return {
133
+ data,
134
+ total,
135
+ page,
136
+ pageSize,
137
+ totalPages: Math.ceil(total / pageSize),
138
+ };
139
+ }
140
+
141
+ async getAgentById(agentId: number) {
142
+ const agent = await this.findAgentById(agentId);
143
+
144
+ if (!agent) {
145
+ throw new NotFoundException(`AI agent with id "${agentId}" was not found.`);
146
+ }
147
+
148
+ return agent;
149
+ }
150
+
151
+ async updateAgent(agentId: number, data: UpdateAgentDTO) {
152
+ const currentAgent = await this.findAgentById(agentId);
153
+
154
+ if (!currentAgent) {
155
+ throw new NotFoundException(`AI agent with id "${agentId}" was not found.`);
156
+ }
157
+
158
+ const slug = data.slug?.trim() ?? currentAgent.slug;
159
+ const provider = data.provider ?? currentAgent.provider;
160
+ const model = data.model ?? currentAgent.model ?? this.getDefaultModel(provider);
161
+ const instructions = data.instructions ?? currentAgent.instructions;
162
+
163
+ if (slug !== currentAgent.slug) {
164
+ const existingSlug = await this.findAgentBySlug(slug);
165
+ if (existingSlug && existingSlug.id !== agentId) {
166
+ throw new BadRequestException(`AI agent with slug "${slug}" already exists.`);
167
+ }
168
+ }
169
+
170
+ let externalAgentId = currentAgent.external_agent_id;
171
+
172
+ if (provider !== 'openai') {
173
+ externalAgentId = null;
174
+ } else if (!externalAgentId) {
175
+ externalAgentId = await this.createOpenAiAssistant({
176
+ slug,
177
+ model,
178
+ instructions,
179
+ });
180
+ }
181
+
182
+ await this.prismaService.$executeRaw`
183
+ UPDATE ai_agent
184
+ SET slug = ${slug},
185
+ provider = ${provider},
186
+ model = ${model},
187
+ instructions = ${instructions},
188
+ external_agent_id = ${externalAgentId},
189
+ updated_at = NOW()
190
+ WHERE id = ${agentId}
191
+ `;
192
+
193
+ return this.getAgentById(agentId);
194
+ }
195
+
196
+ async deleteAgents({ ids }: DeleteDTO) {
197
+ const existing = await this.prismaService.$queryRaw<Array<{ id: number }>>(Prisma.sql`
198
+ SELECT id
199
+ FROM ai_agent
200
+ WHERE id IN (${Prisma.join(ids)})
201
+ `);
202
+
203
+ if (existing.length !== ids.length) {
204
+ const existingIds = existing.map((item) => item.id);
205
+ const missingIds = ids.filter((id) => !existingIds.includes(id));
206
+ throw new NotFoundException(
207
+ `AI agents with ids "${missingIds.join(', ')}" were not found.`,
208
+ );
209
+ }
210
+
211
+ const deletedCount = await this.prismaService.$executeRaw`
212
+ DELETE FROM ai_agent
213
+ WHERE id IN (${Prisma.join(ids)})
214
+ `;
215
+
216
+ return {
217
+ count: Number(deletedCount || 0),
218
+ };
219
+ }
220
+
221
+ async chatWithAgent(slug: string, data: ChatAgentDTO) {
222
+ const agent = await this.findAgentBySlug(slug);
223
+
224
+ if (!agent) {
225
+ throw new NotFoundException(`AI agent with slug "${slug}" was not found.`);
226
+ }
227
+
228
+ if (agent.provider === 'openai') {
229
+ if (agent.external_agent_id) {
230
+ const content = await this.chatWithOpenAiAssistant(
231
+ agent.external_agent_id,
232
+ data.message,
233
+ );
234
+
235
+ return {
236
+ slug: agent.slug,
237
+ provider: agent.provider,
238
+ model: agent.model,
239
+ content,
240
+ };
241
+ }
242
+
243
+ const content = await this.chatWithOpenAi({
244
+ message: data.message,
245
+ model: agent.model || this.getDefaultModel('openai'),
246
+ systemPrompt: agent.instructions || undefined,
247
+ });
248
+
249
+ return {
250
+ slug: agent.slug,
251
+ provider: agent.provider,
252
+ model: agent.model,
253
+ content,
254
+ };
255
+ }
256
+
257
+ const content = await this.chatWithGemini({
258
+ message: data.message,
259
+ model: agent.model || this.getDefaultModel('gemini'),
260
+ systemPrompt: agent.instructions || undefined,
261
+ });
262
+
263
+ return {
264
+ slug: agent.slug,
265
+ provider: agent.provider,
266
+ model: agent.model,
267
+ content,
268
+ };
269
+ }
270
+
271
+ async getAgentBySlug(slug: string) {
272
+ const agent = await this.findAgentBySlug(slug);
273
+
274
+ if (!agent) {
275
+ throw new NotFoundException(`AI agent with slug "${slug}" was not found.`);
276
+ }
277
+
278
+ return agent;
279
+ }
280
+
281
+ private async findAgentBySlug(slug: string): Promise<AiAgentRecord | null> {
282
+ const result = await this.prismaService.$queryRaw<AiAgentRecord[]>`
283
+ SELECT id, slug, provider, model, instructions, external_agent_id, created_at, updated_at
284
+ FROM ai_agent
285
+ WHERE slug = ${slug}
286
+ LIMIT 1
287
+ `;
288
+
289
+ return result?.[0] || null;
290
+ }
291
+
292
+ private async findAgentById(id: number): Promise<AiAgentRecord | null> {
293
+ const result = await this.prismaService.$queryRaw<AiAgentRecord[]>`
294
+ SELECT id, slug, provider, model, instructions, external_agent_id, created_at, updated_at
295
+ FROM ai_agent
296
+ WHERE id = ${id}
297
+ LIMIT 1
298
+ `;
299
+
300
+ return result?.[0] || null;
301
+ }
302
+
303
+ private getDefaultModel(provider: AiProvider): string {
304
+ if (provider === 'openai') {
305
+ return 'gpt-4o-mini';
306
+ }
307
+
308
+ return 'gemini-1.5-flash';
309
+ }
310
+
311
+ private async getApiKeys() {
312
+ const settings = await this.settingService.getSettingValues([
313
+ 'ai-openai-api-key',
314
+ 'ai-gemini-api-key',
315
+ ]);
316
+
317
+ return {
318
+ openai: settings['ai-openai-api-key'],
319
+ gemini: settings['ai-gemini-api-key'],
320
+ };
321
+ }
322
+
323
+ private async chatWithOpenAi({
324
+ message,
325
+ model,
326
+ systemPrompt,
327
+ }: {
328
+ message: string;
329
+ model: string;
330
+ systemPrompt?: string;
331
+ }) {
332
+ const { openai } = await this.getApiKeys();
333
+
334
+ if (!openai) {
335
+ throw new BadRequestException(
336
+ 'OpenAI API key is not configured (setting "ai-openai-api-key").',
337
+ );
338
+ }
339
+
340
+ const messages: Array<{ role: 'system' | 'user'; content: string }> = [];
341
+
342
+ if (systemPrompt) {
343
+ messages.push({ role: 'system', content: systemPrompt });
344
+ }
345
+
346
+ messages.push({ role: 'user', content: message });
347
+
348
+ const response = await axios.post(
349
+ 'https://api.openai.com/v1/chat/completions',
350
+ {
351
+ model,
352
+ messages,
353
+ },
354
+ {
355
+ headers: {
356
+ Authorization: `Bearer ${openai}`,
357
+ 'Content-Type': 'application/json',
358
+ },
359
+ },
360
+ );
361
+
362
+ return response.data?.choices?.[0]?.message?.content || '';
363
+ }
364
+
365
+ private async createOpenAiAssistant({
366
+ slug,
367
+ model,
368
+ instructions,
369
+ }: {
370
+ slug: string;
371
+ model: string;
372
+ instructions: string | null;
373
+ }): Promise<string> {
374
+ const { openai } = await this.getApiKeys();
375
+
376
+ if (!openai) {
377
+ throw new BadRequestException(
378
+ 'OpenAI API key is not configured (setting "ai-openai-api-key").',
379
+ );
380
+ }
381
+
382
+ const response = await axios.post(
383
+ 'https://api.openai.com/v1/assistants',
384
+ {
385
+ name: slug,
386
+ model,
387
+ instructions: instructions || `You are the assistant identified by slug ${slug}.`,
388
+ },
389
+ {
390
+ headers: {
391
+ Authorization: `Bearer ${openai}`,
392
+ 'Content-Type': 'application/json',
393
+ 'OpenAI-Beta': 'assistants=v2',
394
+ },
395
+ },
396
+ );
397
+
398
+ const assistantId = response.data?.id;
399
+
400
+ if (!assistantId) {
401
+ throw new BadRequestException('OpenAI assistant creation failed.');
402
+ }
403
+
404
+ return assistantId;
405
+ }
406
+
407
+ private async chatWithOpenAiAssistant(
408
+ assistantId: string,
409
+ message: string,
410
+ ): Promise<string> {
411
+ const { openai } = await this.getApiKeys();
412
+
413
+ if (!openai) {
414
+ throw new BadRequestException(
415
+ 'OpenAI API key is not configured (setting "ai-openai-api-key").',
416
+ );
417
+ }
418
+
419
+ const threadResponse = await axios.post(
420
+ 'https://api.openai.com/v1/threads',
421
+ {
422
+ messages: [{ role: 'user', content: message }],
423
+ },
424
+ {
425
+ headers: {
426
+ Authorization: `Bearer ${openai}`,
427
+ 'Content-Type': 'application/json',
428
+ 'OpenAI-Beta': 'assistants=v2',
429
+ },
430
+ },
431
+ );
432
+
433
+ const threadId = threadResponse.data?.id;
434
+
435
+ if (!threadId) {
436
+ throw new BadRequestException('OpenAI thread creation failed.');
437
+ }
438
+
439
+ const runResponse = await axios.post(
440
+ `https://api.openai.com/v1/threads/${threadId}/runs`,
441
+ {
442
+ assistant_id: assistantId,
443
+ },
444
+ {
445
+ headers: {
446
+ Authorization: `Bearer ${openai}`,
447
+ 'Content-Type': 'application/json',
448
+ 'OpenAI-Beta': 'assistants=v2',
449
+ },
450
+ },
451
+ );
452
+
453
+ const runId = runResponse.data?.id;
454
+
455
+ if (!runId) {
456
+ throw new BadRequestException('OpenAI assistant run failed to start.');
457
+ }
458
+
459
+ for (let i = 0; i < 30; i++) {
460
+ const runStatusResponse = await axios.get(
461
+ `https://api.openai.com/v1/threads/${threadId}/runs/${runId}`,
462
+ {
463
+ headers: {
464
+ Authorization: `Bearer ${openai}`,
465
+ 'OpenAI-Beta': 'assistants=v2',
466
+ },
467
+ },
468
+ );
469
+
470
+ const status = runStatusResponse.data?.status;
471
+
472
+ if (status === 'completed') {
473
+ break;
474
+ }
475
+
476
+ if (['cancelled', 'failed', 'expired'].includes(status)) {
477
+ throw new BadRequestException(
478
+ `OpenAI assistant run did not complete successfully (status: ${status}).`,
479
+ );
480
+ }
481
+
482
+ await this.sleep(1000);
483
+ }
484
+
485
+ const messagesResponse = await axios.get(
486
+ `https://api.openai.com/v1/threads/${threadId}/messages`,
487
+ {
488
+ headers: {
489
+ Authorization: `Bearer ${openai}`,
490
+ 'OpenAI-Beta': 'assistants=v2',
491
+ },
492
+ },
493
+ );
494
+
495
+ const assistantMessage = messagesResponse.data?.data?.find(
496
+ (item: any) => item.role === 'assistant',
497
+ );
498
+
499
+ const output = assistantMessage?.content?.[0]?.text?.value;
500
+
501
+ return output || '';
502
+ }
503
+
504
+ private async chatWithGemini({
505
+ message,
506
+ model,
507
+ systemPrompt,
508
+ }: {
509
+ message: string;
510
+ model: string;
511
+ systemPrompt?: string;
512
+ }) {
513
+ const { gemini } = await this.getApiKeys();
514
+
515
+ if (!gemini) {
516
+ throw new BadRequestException(
517
+ 'Gemini API key is not configured (setting "ai-gemini-api-key").',
518
+ );
519
+ }
520
+
521
+ const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${gemini}`;
522
+
523
+ const payload: any = {
524
+ contents: [{ parts: [{ text: message }] }],
525
+ };
526
+
527
+ if (systemPrompt) {
528
+ payload.systemInstruction = {
529
+ parts: [{ text: systemPrompt }],
530
+ };
531
+ }
532
+
533
+ const response = await axios.post(url, payload, {
534
+ headers: {
535
+ 'Content-Type': 'application/json',
536
+ },
537
+ });
538
+
539
+ const parts = response.data?.candidates?.[0]?.content?.parts || [];
540
+ return parts.map((part: any) => part.text || '').join('');
541
+ }
542
+
543
+ private async sleep(ms: number) {
544
+ await new Promise((resolve) => setTimeout(resolve, ms));
545
+ }
546
+ }
@@ -0,0 +1,7 @@
1
+ import { IsNotEmpty, IsString } from 'class-validator';
2
+
3
+ export class ChatAgentDTO {
4
+ @IsString()
5
+ @IsNotEmpty()
6
+ message: string;
7
+ }
@@ -0,0 +1,20 @@
1
+ import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
2
+
3
+ export class ChatDTO {
4
+ @IsString()
5
+ @IsNotEmpty()
6
+ message: string;
7
+
8
+ @IsOptional()
9
+ @IsString()
10
+ @IsIn(['openai', 'gemini'])
11
+ provider?: 'openai' | 'gemini';
12
+
13
+ @IsOptional()
14
+ @IsString()
15
+ model?: string;
16
+
17
+ @IsOptional()
18
+ @IsString()
19
+ systemPrompt?: string;
20
+ }
@@ -0,0 +1,20 @@
1
+ import { IsIn, IsNotEmpty, IsOptional, IsString } from 'class-validator';
2
+
3
+ export class CreateAgentDTO {
4
+ @IsString()
5
+ @IsNotEmpty()
6
+ slug: string;
7
+
8
+ @IsOptional()
9
+ @IsString()
10
+ @IsIn(['openai', 'gemini'])
11
+ provider?: 'openai' | 'gemini';
12
+
13
+ @IsOptional()
14
+ @IsString()
15
+ model?: string;
16
+
17
+ @IsOptional()
18
+ @IsString()
19
+ instructions?: string;
20
+ }
@@ -0,0 +1,20 @@
1
+ import { IsIn, IsOptional, IsString } from 'class-validator';
2
+
3
+ export class UpdateAgentDTO {
4
+ @IsOptional()
5
+ @IsString()
6
+ slug?: string;
7
+
8
+ @IsOptional()
9
+ @IsString()
10
+ @IsIn(['openai', 'gemini'])
11
+ provider?: 'openai' | 'gemini';
12
+
13
+ @IsOptional()
14
+ @IsString()
15
+ model?: string;
16
+
17
+ @IsOptional()
18
+ @IsString()
19
+ instructions?: string;
20
+ }
@@ -7,6 +7,7 @@ import { HttpModule } from '@nestjs/axios';
7
7
  import { forwardRef, Global, Inject, MiddlewareConsumer, Module, NestModule, OnModuleInit } from '@nestjs/common';
8
8
  import { ConfigModule } from '@nestjs/config';
9
9
  import { APP_INTERCEPTOR, Reflector } from '@nestjs/core';
10
+ import { AiModule } from './ai/ai.module';
10
11
  import { AuthModule } from './auth/auth.module';
11
12
  import { SystemModule } from './core/system.module';
12
13
  import { DashboardModule } from './dashboard/dashboard.module';
@@ -37,6 +38,7 @@ import { ValidatorServiceLocator } from './validators/service-locator';
37
38
  imports: [
38
39
  HttpModule,
39
40
  ConfigModule.forRoot(),
41
+ forwardRef(() => AiModule),
40
42
  forwardRef(() => InstallModule),
41
43
  forwardRef(() => AuthModule),
42
44
  forwardRef(() => DashboardModule),
@@ -72,6 +74,7 @@ import { ValidatorServiceLocator } from './validators/service-locator';
72
74
  SettingModule,
73
75
  FileModule,
74
76
  SessionModule,
77
+ AiModule,
75
78
  IsStrongPasswordWithSettingsConstraint,
76
79
  IsEmailWithSettingsConstraint,
77
80
  IsPinCodeWithSettingConstraint,
@@ -2,12 +2,12 @@ import { getLocaleText } from '@hed-hog/api-locale';
2
2
  import { PaginationDTO, PaginationService } from '@hed-hog/api-pagination';
3
3
  import { PrismaService } from '@hed-hog/api-prisma';
4
4
  import {
5
- BadRequestException,
6
- forwardRef,
7
- Inject,
8
- Injectable,
9
- NotFoundException,
10
- OnModuleInit
5
+ BadRequestException,
6
+ forwardRef,
7
+ Inject,
8
+ Injectable,
9
+ NotFoundException,
10
+ OnModuleInit
11
11
  } from '@nestjs/common';
12
12
  import { JwtService } from '@nestjs/jwt';
13
13
  import { SettingService } from '../setting/setting.service';
@@ -21,6 +21,23 @@ export class FileService implements OnModuleInit {
21
21
  private providerId: number;
22
22
  private mimetypes: Record<string, number> = {};
23
23
 
24
+ private hasMojibake(value: string): boolean {
25
+ return /Ã.|Â.|â[\u0080-\u00BF]/.test(value);
26
+ }
27
+
28
+ private normalizeUploadedFilename(filename: string): string {
29
+ if (!filename) {
30
+ return filename;
31
+ }
32
+
33
+ if (!this.hasMojibake(filename)) {
34
+ return filename;
35
+ }
36
+
37
+ const decoded = Buffer.from(filename, 'latin1').toString('utf8');
38
+ return this.hasMojibake(decoded) ? filename : decoded;
39
+ }
40
+
24
41
  constructor(
25
42
  @Inject(forwardRef(() => PrismaService))
26
43
  private readonly prismaService: PrismaService,
@@ -284,11 +301,20 @@ export class FileService implements OnModuleInit {
284
301
  throw new BadRequestException(`File too large: ${fileBuffer.size} bytes`);
285
302
  }
286
303
 
287
- const url = await provider.upload(destination, fileBuffer);
304
+ const normalizedFilename = this.normalizeUploadedFilename(
305
+ fileBuffer.originalname,
306
+ );
307
+
308
+ const normalizedFile = {
309
+ ...fileBuffer,
310
+ originalname: normalizedFilename,
311
+ };
312
+
313
+ const url = await provider.upload(destination, normalizedFile);
288
314
 
289
315
  const file = await this.prismaService.file.create({
290
316
  data: {
291
- filename: fileBuffer.originalname,
317
+ filename: normalizedFilename,
292
318
  path: url,
293
319
  provider_id: this.providerId,
294
320
  location: destination,
package/src/index.ts CHANGED
@@ -23,6 +23,7 @@ export * from './mail/mail.module';
23
23
  export * from './mail/mail.service';
24
24
 
25
25
  export * from './setting/setting.service';
26
+ export * from './ai/ai.service';
26
27
 
27
28
  // Validators
28
29
  export * from './validators/is-email-with-settings.validator';