@hed-hog/core 0.0.232 → 0.0.234

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.
@@ -3,5 +3,6 @@ export declare class ChatDTO {
3
3
  provider?: 'openai' | 'gemini';
4
4
  model?: string;
5
5
  systemPrompt?: string;
6
+ file_id?: number;
6
7
  }
7
8
  //# sourceMappingURL=chat.dto.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"chat.dto.d.ts","sourceRoot":"","sources":["../../../src/ai/dto/chat.dto.ts"],"names":[],"mappings":"AAEA,qBAAa,OAAO;IAGlB,OAAO,EAAE,MAAM,CAAC;IAKhB,QAAQ,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAI/B,KAAK,CAAC,EAAE,MAAM,CAAC;IAIf,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB"}
1
+ {"version":3,"file":"chat.dto.d.ts","sourceRoot":"","sources":["../../../src/ai/dto/chat.dto.ts"],"names":[],"mappings":"AAGA,qBAAa,OAAO;IAGlB,OAAO,EAAE,MAAM,CAAC;IAKhB,QAAQ,CAAC,EAAE,QAAQ,GAAG,QAAQ,CAAC;IAI/B,KAAK,CAAC,EAAE,MAAM,CAAC;IAIf,YAAY,CAAC,EAAE,MAAM,CAAC;IAKtB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB"}
@@ -10,6 +10,7 @@ var __metadata = (this && this.__metadata) || function (k, v) {
10
10
  };
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
12
  exports.ChatDTO = void 0;
13
+ const class_transformer_1 = require("class-transformer");
13
14
  const class_validator_1 = require("class-validator");
14
15
  class ChatDTO {
15
16
  }
@@ -35,4 +36,10 @@ __decorate([
35
36
  (0, class_validator_1.IsString)(),
36
37
  __metadata("design:type", String)
37
38
  ], ChatDTO.prototype, "systemPrompt", void 0);
39
+ __decorate([
40
+ (0, class_validator_1.IsOptional)(),
41
+ (0, class_transformer_1.Type)(() => Number),
42
+ (0, class_validator_1.IsInt)(),
43
+ __metadata("design:type", Number)
44
+ ], ChatDTO.prototype, "file_id", void 0);
38
45
  //# sourceMappingURL=chat.dto.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"chat.dto.js","sourceRoot":"","sources":["../../../src/ai/dto/chat.dto.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,qDAAyE;AAEzE,MAAa,OAAO;CAiBnB;AAjBD,0BAiBC;AAdC;IAFC,IAAA,0BAAQ,GAAE;IACV,IAAA,4BAAU,GAAE;;wCACG;AAKhB;IAHC,IAAA,4BAAU,GAAE;IACZ,IAAA,0BAAQ,GAAE;IACV,IAAA,sBAAI,EAAC,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;;yCACI;AAI/B;IAFC,IAAA,4BAAU,GAAE;IACZ,IAAA,0BAAQ,GAAE;;sCACI;AAIf;IAFC,IAAA,4BAAU,GAAE;IACZ,IAAA,0BAAQ,GAAE;;6CACW"}
1
+ {"version":3,"file":"chat.dto.js","sourceRoot":"","sources":["../../../src/ai/dto/chat.dto.ts"],"names":[],"mappings":";;;;;;;;;;;;AAAA,yDAAyC;AACzC,qDAAgF;AAEhF,MAAa,OAAO;CAsBnB;AAtBD,0BAsBC;AAnBC;IAFC,IAAA,0BAAQ,GAAE;IACV,IAAA,4BAAU,GAAE;;wCACG;AAKhB;IAHC,IAAA,4BAAU,GAAE;IACZ,IAAA,0BAAQ,GAAE;IACV,IAAA,sBAAI,EAAC,CAAC,QAAQ,EAAE,QAAQ,CAAC,CAAC;;yCACI;AAI/B;IAFC,IAAA,4BAAU,GAAE;IACZ,IAAA,0BAAQ,GAAE;;sCACI;AAIf;IAFC,IAAA,4BAAU,GAAE;IACZ,IAAA,0BAAQ,GAAE;;6CACW;AAKtB;IAHC,IAAA,4BAAU,GAAE;IACZ,IAAA,wBAAI,EAAC,GAAG,EAAE,CAAC,MAAM,CAAC;IAClB,IAAA,uBAAK,GAAE;;wCACS"}
package/dist/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export * from './user/user.service';
10
10
  export * from './mail/mail.module';
11
11
  export * from './mail/mail.service';
12
12
  export * from './setting/setting.service';
13
+ export * from './ai/ai.module';
13
14
  export * from './ai/ai.service';
14
15
  export * from './validators/is-email-with-settings.validator';
15
16
  export * from './validators/is-pin-code-with-setting.validator';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,eAAe,CAAC;AAG9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AAGzC,cAAc,qBAAqB,CAAC;AAGpC,cAAc,0BAA0B,CAAC;AACzC,cAAc,qBAAqB,CAAC;AAGpC,cAAc,yBAAyB,CAAC;AAGxC,cAAc,iCAAiC,CAAC;AAChD,cAAc,qBAAqB,CAAC;AAEpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AAEpC,cAAc,2BAA2B,CAAC;AAC1C,cAAc,iBAAiB,CAAC;AAGhC,cAAc,+CAA+C,CAAC;AAC9D,cAAc,iDAAiD,CAAC;AAChE,cAAc,yDAAyD,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,cAAc,eAAe,CAAC;AAG9B,cAAc,qBAAqB,CAAC;AACpC,cAAc,0BAA0B,CAAC;AAGzC,cAAc,qBAAqB,CAAC;AAGpC,cAAc,0BAA0B,CAAC;AACzC,cAAc,qBAAqB,CAAC;AAGpC,cAAc,yBAAyB,CAAC;AAGxC,cAAc,iCAAiC,CAAC;AAChD,cAAc,qBAAqB,CAAC;AAEpC,cAAc,oBAAoB,CAAC;AACnC,cAAc,qBAAqB,CAAC;AAEpC,cAAc,2BAA2B,CAAC;AAE1C,cAAc,gBAAgB,CAAC;AAC/B,cAAc,iBAAiB,CAAC;AAGhC,cAAc,+CAA+C,CAAC;AAC9D,cAAc,iDAAiD,CAAC;AAChE,cAAc,yDAAyD,CAAC"}
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ __exportStar(require("./user/user.service"), exports);
32
32
  __exportStar(require("./mail/mail.module"), exports);
33
33
  __exportStar(require("./mail/mail.service"), exports);
34
34
  __exportStar(require("./setting/setting.service"), exports);
35
+ __exportStar(require("./ai/ai.module"), exports);
35
36
  __exportStar(require("./ai/ai.service"), exports);
36
37
  // Validators
37
38
  __exportStar(require("./validators/is-email-with-settings.validator"), exports);
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,cAAc;AACd,gDAA8B;AAE9B,cAAc;AACd,sDAAoC;AACpC,2DAAyC;AAEzC,cAAc;AACd,sDAAoC;AAEpC,oBAAoB;AACpB,2DAAyC;AACzC,sDAAoC;AAEpC,gBAAgB;AAChB,0DAAwC;AAExC,cAAc;AACd,kEAAgD;AAChD,sDAAoC;AAEpC,qDAAmC;AACnC,sDAAoC;AAEpC,4DAA0C;AAC1C,kDAAgC;AAEhC,aAAa;AACb,gFAA8D;AAC9D,kFAAgE;AAChE,0FAAwE"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AAAA,cAAc;AACd,gDAA8B;AAE9B,cAAc;AACd,sDAAoC;AACpC,2DAAyC;AAEzC,cAAc;AACd,sDAAoC;AAEpC,oBAAoB;AACpB,2DAAyC;AACzC,sDAAoC;AAEpC,gBAAgB;AAChB,0DAAwC;AAExC,cAAc;AACd,kEAAgD;AAChD,sDAAoC;AAEpC,qDAAmC;AACnC,sDAAoC;AAEpC,4DAA0C;AAE1C,iDAA+B;AAC/B,kDAAgC;AAEhC,aAAa;AACb,gFAA8D;AAC9D,kFAAgE;AAChE,0FAAwE"}
@@ -18,14 +18,6 @@ import {
18
18
  } from '@/components/ui/alert-dialog';
19
19
  import { Button } from '@/components/ui/button';
20
20
  import { Card, CardContent } from '@/components/ui/card';
21
- import {
22
- Dialog,
23
- DialogContent,
24
- DialogDescription,
25
- DialogFooter,
26
- DialogHeader,
27
- DialogTitle,
28
- } from '@/components/ui/dialog';
29
21
  import {
30
22
  Form,
31
23
  FormControl,
@@ -42,6 +34,14 @@ import {
42
34
  SelectTrigger,
43
35
  SelectValue,
44
36
  } from '@/components/ui/select';
37
+ import {
38
+ Sheet,
39
+ SheetContent,
40
+ SheetDescription,
41
+ SheetFooter,
42
+ SheetHeader,
43
+ SheetTitle,
44
+ } from '@/components/ui/sheet';
45
45
  import {
46
46
  Table,
47
47
  TableBody,
@@ -330,17 +330,20 @@ export default function AiAgentPage() {
330
330
  }}
331
331
  />
332
332
 
333
- <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
334
- <DialogContent>
335
- <DialogHeader>
336
- <DialogTitle>
333
+ <Sheet open={isDialogOpen} onOpenChange={setIsDialogOpen}>
334
+ <SheetContent side="right" className="sm:max-w-xl overflow-y-auto">
335
+ <SheetHeader>
336
+ <SheetTitle>
337
337
  {editingAgent ? t('editTitle') : t('createTitle')}
338
- </DialogTitle>
339
- <DialogDescription>{t('dialogDescription')}</DialogDescription>
340
- </DialogHeader>
338
+ </SheetTitle>
339
+ <SheetDescription>{t('dialogDescription')}</SheetDescription>
340
+ </SheetHeader>
341
341
 
342
342
  <Form {...form}>
343
- <form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
343
+ <form
344
+ className="flex flex-col px-4 gap-4"
345
+ onSubmit={form.handleSubmit(onSubmit)}
346
+ >
344
347
  <FormField
345
348
  control={form.control}
346
349
  name="slug"
@@ -369,7 +372,7 @@ export default function AiAgentPage() {
369
372
  value={field.value}
370
373
  onValueChange={field.onChange}
371
374
  >
372
- <SelectTrigger>
375
+ <SelectTrigger className="w-full">
373
376
  <SelectValue
374
377
  placeholder={t('fieldProviderPlaceholder')}
375
378
  />
@@ -415,7 +418,8 @@ export default function AiAgentPage() {
415
418
  <FormControl>
416
419
  <Textarea
417
420
  placeholder={t('fieldInstructionsPlaceholder')}
418
- rows={4}
421
+ rows={10}
422
+ className="min-h-24"
419
423
  {...field}
420
424
  />
421
425
  </FormControl>
@@ -424,23 +428,16 @@ export default function AiAgentPage() {
424
428
  )}
425
429
  />
426
430
 
427
- <DialogFooter>
428
- <Button
429
- type="button"
430
- variant="outline"
431
- onClick={() => setIsDialogOpen(false)}
432
- >
433
- {t('cancel')}
434
- </Button>
431
+ <SheetFooter className="p-0">
435
432
  <Button type="submit">
436
- <Bot className="h-4 w-4 mr-2" />
433
+ <Bot className="h-4 w-4" />
437
434
  {editingAgent ? t('saveChanges') : t('createAgent')}
438
435
  </Button>
439
- </DialogFooter>
436
+ </SheetFooter>
440
437
  </form>
441
438
  </Form>
442
- </DialogContent>
443
- </Dialog>
439
+ </SheetContent>
440
+ </Sheet>
444
441
 
445
442
  <AlertDialog
446
443
  open={Boolean(agentToDelete)}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hed-hog/core",
3
- "version": "0.0.232",
3
+ "version": "0.0.234",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "dependencies": {
@@ -23,15 +23,16 @@
23
23
  "jsonwebtoken": "^9.0.2",
24
24
  "multer": "^2.0.1",
25
25
  "pako": "^2.1.0",
26
+ "pdf-parse": "^1.1.1",
26
27
  "png-to-ico": "^2.1.8",
27
28
  "qrcode": "^1.5.4",
28
29
  "rxjs": "^7.8.2",
29
30
  "sharp": "^0.34.2",
30
31
  "speakeasy": "^2.0.0",
31
32
  "uuid": "^11.1.0",
32
- "@hed-hog/api": "0.0.3",
33
33
  "@hed-hog/api-prisma": "0.0.4",
34
34
  "@hed-hog/api-types": "0.0.1",
35
+ "@hed-hog/api": "0.0.3",
35
36
  "@hed-hog/api-locale": "0.0.11",
36
37
  "@hed-hog/api-pagination": "0.0.5",
37
38
  "@hed-hog/api-mail": "0.0.7"
@@ -1,6 +1,7 @@
1
1
  import { Role } from '@hed-hog/api';
2
2
  import { Pagination } from '@hed-hog/api-pagination';
3
- import { Body, Controller, Delete, Get, Inject, Param, ParseIntPipe, Patch, Post, forwardRef } from '@nestjs/common';
3
+ import { Body, Controller, Delete, Get, Inject, Param, ParseIntPipe, Patch, Post, UploadedFile, UseInterceptors, forwardRef } from '@nestjs/common';
4
+ import { FileInterceptor } from '@nestjs/platform-express';
4
5
  import { DeleteDTO } from '../dto/delete.dto';
5
6
  import { AiService } from './ai.service';
6
7
  import { ChatAgentDTO } from './dto/chat-agent.dto';
@@ -17,8 +18,9 @@ export class AiController {
17
18
  ) {}
18
19
 
19
20
  @Post('chat')
20
- async chat(@Body() data: ChatDTO) {
21
- return this.aiService.chat(data);
21
+ @UseInterceptors(FileInterceptor('file'))
22
+ async chat(@Body() data: ChatDTO, @UploadedFile() file?: MulterFile) {
23
+ return this.aiService.chat(data, file);
22
24
  }
23
25
 
24
26
  @Post('agent')
@@ -55,7 +57,12 @@ export class AiController {
55
57
  }
56
58
 
57
59
  @Post('agent/:slug/chat')
58
- async chatWithAgent(@Param('slug') slug: string, @Body() data: ChatAgentDTO) {
59
- return this.aiService.chatWithAgent(slug, data);
60
+ @UseInterceptors(FileInterceptor('file'))
61
+ async chatWithAgent(
62
+ @Param('slug') slug: string,
63
+ @Body() data: ChatAgentDTO,
64
+ @UploadedFile() file?: MulterFile,
65
+ ) {
66
+ return this.aiService.chatWithAgent(slug, data, file);
60
67
  }
61
68
  }
@@ -1,11 +1,16 @@
1
1
  import { PrismaModule } from '@hed-hog/api-prisma';
2
2
  import { Module, forwardRef } from '@nestjs/common';
3
+ import { FileModule } from '../file/file.module';
3
4
  import { SettingModule } from '../setting/setting.module';
4
5
  import { AiController } from './ai.controller';
5
6
  import { AiService } from './ai.service';
6
7
 
7
8
  @Module({
8
- imports: [forwardRef(() => PrismaModule), forwardRef(() => SettingModule)],
9
+ imports: [
10
+ forwardRef(() => PrismaModule),
11
+ forwardRef(() => SettingModule),
12
+ forwardRef(() => FileModule),
13
+ ],
9
14
  controllers: [AiController],
10
15
  providers: [AiService],
11
16
  exports: [AiService],
@@ -1,15 +1,19 @@
1
1
  import { PaginationDTO } from '@hed-hog/api-pagination';
2
2
  import { PrismaService } from '@hed-hog/api-prisma';
3
3
  import {
4
- BadRequestException,
5
- forwardRef,
6
- Inject,
7
- Injectable,
8
- NotFoundException,
4
+ BadRequestException,
5
+ forwardRef,
6
+ Inject,
7
+ Injectable,
8
+ Logger,
9
+ NotFoundException,
9
10
  } from '@nestjs/common';
10
11
  import { Prisma } from '@prisma/client';
11
12
  import axios from 'axios';
13
+ import { createHash } from 'crypto';
14
+ import pdfParse from 'pdf-parse';
12
15
  import { DeleteDTO } from '../dto/delete.dto';
16
+ import { FileService } from '../file/file.service';
13
17
  import { SettingService } from '../setting/setting.service';
14
18
  import { ChatAgentDTO } from './dto/chat-agent.dto';
15
19
  import { ChatDTO } from './dto/chat.dto';
@@ -29,17 +33,28 @@ type AiAgentRecord = {
29
33
  updated_at: Date;
30
34
  };
31
35
 
36
+ type AiAttachment = {
37
+ filename: string;
38
+ mimeType: string;
39
+ buffer: Buffer;
40
+ };
41
+
32
42
  @Injectable()
33
43
  export class AiService {
44
+ private readonly logger = new Logger(AiService.name);
45
+
34
46
  constructor(
35
47
  @Inject(forwardRef(() => PrismaService))
36
48
  private readonly prismaService: PrismaService,
37
49
  @Inject(forwardRef(() => SettingService))
38
50
  private readonly settingService: SettingService,
51
+ @Inject(forwardRef(() => FileService))
52
+ private readonly fileService: FileService,
39
53
  ) {}
40
54
 
41
- async chat(data: ChatDTO) {
55
+ async chat(data: ChatDTO, file?: MulterFile) {
42
56
  const provider = data.provider || 'openai';
57
+ const attachment = await this.resolveAttachment(file, data.file_id);
43
58
 
44
59
  if (provider === 'openai') {
45
60
  const model = data.model || 'gpt-4o-mini';
@@ -47,6 +62,7 @@ export class AiService {
47
62
  message: data.message,
48
63
  model,
49
64
  systemPrompt: data.systemPrompt,
65
+ attachment,
50
66
  });
51
67
 
52
68
  return {
@@ -61,6 +77,7 @@ export class AiService {
61
77
  message: data.message,
62
78
  model,
63
79
  systemPrompt: data.systemPrompt,
80
+ attachment,
64
81
  });
65
82
 
66
83
  return {
@@ -93,7 +110,13 @@ export class AiService {
93
110
 
94
111
  await this.prismaService.$executeRaw`
95
112
  INSERT INTO ai_agent (slug, provider, model, instructions, external_agent_id)
96
- VALUES (${slug}, ${provider}, ${model}, ${instructions}, ${externalAgentId})
113
+ VALUES (
114
+ ${slug},
115
+ CAST(${provider} AS ai_agent_provider_enum),
116
+ ${model},
117
+ ${instructions},
118
+ ${externalAgentId}
119
+ )
97
120
  `;
98
121
 
99
122
  return this.findAgentBySlug(slug);
@@ -182,7 +205,7 @@ export class AiService {
182
205
  await this.prismaService.$executeRaw`
183
206
  UPDATE ai_agent
184
207
  SET slug = ${slug},
185
- provider = ${provider},
208
+ provider = CAST(${provider} AS ai_agent_provider_enum),
186
209
  model = ${model},
187
210
  instructions = ${instructions},
188
211
  external_agent_id = ${externalAgentId},
@@ -218,15 +241,16 @@ export class AiService {
218
241
  };
219
242
  }
220
243
 
221
- async chatWithAgent(slug: string, data: ChatAgentDTO) {
244
+ async chatWithAgent(slug: string, data: ChatAgentDTO, file?: MulterFile) {
222
245
  const agent = await this.findAgentBySlug(slug);
246
+ const attachment = await this.resolveAttachment(file, data.file_id);
223
247
 
224
248
  if (!agent) {
225
249
  throw new NotFoundException(`AI agent with slug "${slug}" was not found.`);
226
250
  }
227
251
 
228
252
  if (agent.provider === 'openai') {
229
- if (agent.external_agent_id) {
253
+ if (agent.external_agent_id && !attachment) {
230
254
  const content = await this.chatWithOpenAiAssistant(
231
255
  agent.external_agent_id,
232
256
  data.message,
@@ -244,6 +268,7 @@ export class AiService {
244
268
  message: data.message,
245
269
  model: agent.model || this.getDefaultModel('openai'),
246
270
  systemPrompt: agent.instructions || undefined,
271
+ attachment,
247
272
  });
248
273
 
249
274
  return {
@@ -258,6 +283,7 @@ export class AiService {
258
283
  message: data.message,
259
284
  model: agent.model || this.getDefaultModel('gemini'),
260
285
  systemPrompt: agent.instructions || undefined,
286
+ attachment,
261
287
  });
262
288
 
263
289
  return {
@@ -278,6 +304,64 @@ export class AiService {
278
304
  return agent;
279
305
  }
280
306
 
307
+ async extractAttachmentText(file?: MulterFile, fileId?: number): Promise<string> {
308
+ const attachment = await this.resolveAttachment(file, fileId);
309
+
310
+ if (!attachment) {
311
+ return '';
312
+ }
313
+
314
+ if (this.isPdfMime(attachment.mimeType)) {
315
+ return this.extractPdfText(attachment.buffer);
316
+ }
317
+
318
+ if (this.isTextMime(attachment.mimeType)) {
319
+ return this.toUtf8Text(attachment.buffer);
320
+ }
321
+
322
+ return '';
323
+ }
324
+
325
+ async debugAttachmentForLlm(file?: MulterFile, fileId?: number) {
326
+ const attachment = await this.resolveAttachment(file, fileId);
327
+
328
+ if (!attachment) {
329
+ return {
330
+ hasAttachment: false,
331
+ };
332
+ }
333
+
334
+ let mode: 'image' | 'pdf-text' | 'text' | 'binary' = 'binary';
335
+ let extractedText = '';
336
+
337
+ if (this.isImageMime(attachment.mimeType)) {
338
+ mode = 'image';
339
+ } else if (this.isPdfMime(attachment.mimeType)) {
340
+ mode = 'pdf-text';
341
+ extractedText = await this.extractPdfText(attachment.buffer);
342
+ } else if (this.isTextMime(attachment.mimeType)) {
343
+ mode = 'text';
344
+ extractedText = this.toUtf8Text(attachment.buffer);
345
+ }
346
+
347
+ const result = {
348
+ hasAttachment: true,
349
+ filename: attachment.filename,
350
+ mimeType: attachment.mimeType,
351
+ bytes: attachment.buffer.length,
352
+ mode,
353
+ textLength: extractedText.length,
354
+ textSample: extractedText.slice(0, 600),
355
+ sha256: createHash('sha256').update(attachment.buffer).digest('hex'),
356
+ };
357
+
358
+ this.logger.warn(
359
+ `[AI-ATTACHMENT-DEBUG] fileId=${fileId || 'upload'} filename="${result.filename}" mime=${result.mimeType} mode=${result.mode} bytes=${result.bytes} textLength=${result.textLength}`,
360
+ );
361
+
362
+ return result;
363
+ }
364
+
281
365
  private async findAgentBySlug(slug: string): Promise<AiAgentRecord | null> {
282
366
  const result = await this.prismaService.$queryRaw<AiAgentRecord[]>`
283
367
  SELECT id, slug, provider, model, instructions, external_agent_id, created_at, updated_at
@@ -324,10 +408,12 @@ export class AiService {
324
408
  message,
325
409
  model,
326
410
  systemPrompt,
411
+ attachment,
327
412
  }: {
328
413
  message: string;
329
414
  model: string;
330
415
  systemPrompt?: string;
416
+ attachment?: AiAttachment | null;
331
417
  }) {
332
418
  const { openai } = await this.getApiKeys();
333
419
 
@@ -337,13 +423,14 @@ export class AiService {
337
423
  );
338
424
  }
339
425
 
340
- const messages: Array<{ role: 'system' | 'user'; content: string }> = [];
426
+ const messages: Array<any> = [];
341
427
 
342
428
  if (systemPrompt) {
343
429
  messages.push({ role: 'system', content: systemPrompt });
344
430
  }
345
431
 
346
- messages.push({ role: 'user', content: message });
432
+ const userContent = await this.buildOpenAiUserContent(message, attachment);
433
+ messages.push({ role: 'user', content: userContent });
347
434
 
348
435
  const response = await axios.post(
349
436
  'https://api.openai.com/v1/chat/completions',
@@ -505,10 +592,12 @@ export class AiService {
505
592
  message,
506
593
  model,
507
594
  systemPrompt,
595
+ attachment,
508
596
  }: {
509
597
  message: string;
510
598
  model: string;
511
599
  systemPrompt?: string;
600
+ attachment?: AiAttachment | null;
512
601
  }) {
513
602
  const { gemini } = await this.getApiKeys();
514
603
 
@@ -520,8 +609,39 @@ export class AiService {
520
609
 
521
610
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${gemini}`;
522
611
 
612
+ const userParts: any[] = [{ text: message }];
613
+
614
+ if (attachment) {
615
+ if (this.isTextMime(attachment.mimeType)) {
616
+ userParts.push({
617
+ text: `\n\n[Attached file: ${attachment.filename}]\n${this.toUtf8Text(attachment.buffer)}`,
618
+ });
619
+ } else if (this.isPdfMime(attachment.mimeType)) {
620
+ const pdfText = await this.extractPdfText(attachment.buffer);
621
+ if (pdfText) {
622
+ userParts.push({
623
+ text: `\n\n[Attached file: ${attachment.filename}]\n${pdfText}`,
624
+ });
625
+ } else {
626
+ userParts.push({
627
+ inline_data: {
628
+ mime_type: attachment.mimeType,
629
+ data: attachment.buffer.toString('base64'),
630
+ },
631
+ });
632
+ }
633
+ } else {
634
+ userParts.push({
635
+ inline_data: {
636
+ mime_type: attachment.mimeType,
637
+ data: attachment.buffer.toString('base64'),
638
+ },
639
+ });
640
+ }
641
+ }
642
+
523
643
  const payload: any = {
524
- contents: [{ parts: [{ text: message }] }],
644
+ contents: [{ parts: userParts }],
525
645
  };
526
646
 
527
647
  if (systemPrompt) {
@@ -543,4 +663,128 @@ export class AiService {
543
663
  private async sleep(ms: number) {
544
664
  await new Promise((resolve) => setTimeout(resolve, ms));
545
665
  }
666
+
667
+ private async resolveAttachment(
668
+ uploadFile?: MulterFile,
669
+ fileId?: number,
670
+ ): Promise<AiAttachment | null> {
671
+ if (uploadFile) {
672
+ const mimeType = this.normalizeAttachmentMimeType(
673
+ uploadFile.mimetype,
674
+ uploadFile.originalname,
675
+ );
676
+ return {
677
+ filename: uploadFile.originalname,
678
+ mimeType,
679
+ buffer: uploadFile.buffer,
680
+ };
681
+ }
682
+
683
+ if (fileId) {
684
+ const { file, buffer } = await this.fileService.getBuffer(fileId);
685
+ const mimeType = this.normalizeAttachmentMimeType(
686
+ file.file_mimetype?.name || 'application/octet-stream',
687
+ file.filename,
688
+ );
689
+ return {
690
+ filename: file.filename,
691
+ mimeType,
692
+ buffer,
693
+ };
694
+ }
695
+
696
+ return null;
697
+ }
698
+
699
+ private async buildOpenAiUserContent(
700
+ message: string,
701
+ attachment?: AiAttachment | null,
702
+ ) {
703
+ if (!attachment) {
704
+ return message;
705
+ }
706
+
707
+ if (this.isImageMime(attachment.mimeType)) {
708
+ const dataUri = `data:${attachment.mimeType};base64,${attachment.buffer.toString('base64')}`;
709
+ return [
710
+ { type: 'text', text: message },
711
+ { type: 'image_url', image_url: { url: dataUri } },
712
+ ];
713
+ }
714
+
715
+ let text: string;
716
+
717
+ if (this.isTextMime(attachment.mimeType)) {
718
+ text = this.toUtf8Text(attachment.buffer);
719
+ } else if (this.isPdfMime(attachment.mimeType)) {
720
+ text =
721
+ (await this.extractPdfText(attachment.buffer)) ||
722
+ `[PDF attachment with unreadable text: ${attachment.filename}]`;
723
+ } else {
724
+ text = `[Binary attachment: ${attachment.filename} | ${attachment.mimeType} | ${attachment.buffer.length} bytes]`;
725
+ }
726
+
727
+ return `${message}\n\n[Attached file: ${attachment.filename}]\n${text}`;
728
+ }
729
+
730
+ private isImageMime(mimeType: string) {
731
+ return mimeType.startsWith('image/');
732
+ }
733
+
734
+ private isTextMime(mimeType: string) {
735
+ return (
736
+ mimeType.startsWith('text/') ||
737
+ mimeType === 'application/json' ||
738
+ mimeType === 'application/xml'
739
+ );
740
+ }
741
+
742
+ private isPdfMime(mimeType: string) {
743
+ return (
744
+ mimeType === 'application/pdf' ||
745
+ mimeType === 'application/x-pdf' ||
746
+ mimeType.endsWith('/pdf')
747
+ );
748
+ }
749
+
750
+ private normalizeAttachmentMimeType(mimeType: string, filename?: string) {
751
+ const current = (mimeType || '').toLowerCase();
752
+ if (current && current !== 'application/octet-stream') {
753
+ return current;
754
+ }
755
+
756
+ const name = (filename || '').toLowerCase();
757
+
758
+ if (name.endsWith('.pdf')) return 'application/pdf';
759
+ if (name.endsWith('.json')) return 'application/json';
760
+ if (name.endsWith('.xml')) return 'application/xml';
761
+ if (name.endsWith('.txt') || name.endsWith('.csv')) return 'text/plain';
762
+ if (name.endsWith('.png')) return 'image/png';
763
+ if (name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'image/jpeg';
764
+ if (name.endsWith('.webp')) return 'image/webp';
765
+
766
+ return current || 'application/octet-stream';
767
+ }
768
+
769
+ private async extractPdfText(buffer: Buffer) {
770
+ try {
771
+ const result = await pdfParse(buffer);
772
+ const text = (result?.text || '')
773
+ .replace(/\r/g, '')
774
+ .replace(/[ \t]+\n/g, '\n')
775
+ .replace(/\n{3,}/g, '\n\n')
776
+ .trim();
777
+ if (!text) {
778
+ return '';
779
+ }
780
+
781
+ return text.slice(0, 60000);
782
+ } catch {
783
+ return '';
784
+ }
785
+ }
786
+
787
+ private toUtf8Text(buffer: Buffer) {
788
+ return buffer.toString('utf8');
789
+ }
546
790
  }
@@ -1,7 +1,13 @@
1
- import { IsNotEmpty, IsString } from 'class-validator';
1
+ import { Type } from 'class-transformer';
2
+ import { IsInt, IsNotEmpty, IsOptional, IsString } from 'class-validator';
2
3
 
3
4
  export class ChatAgentDTO {
4
5
  @IsString()
5
6
  @IsNotEmpty()
6
7
  message: string;
8
+
9
+ @IsOptional()
10
+ @Type(() => Number)
11
+ @IsInt()
12
+ file_id?: number;
7
13
  }