From 230d7b3bf6d51bf56903891b79e468c9dd6d2e3a Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 23 Sep 2026 13:58:43 +0330 Subject: [PATCH] AI-powered speech-to-text transformation --- apps/api/src/ai/ai-stt.service.ts | 89 ---- apps/api/src/ai/ai.controller.ts | 28 +- apps/api/src/ai/ai.module.ts | 5 +- apps/web/src/components/VoiceToTextButton.vue | 418 +++++------------- 4 files changed, 120 insertions(+), 420 deletions(-) delete mode 100644 apps/api/src/ai/ai-stt.service.ts diff --git a/apps/api/src/ai/ai-stt.service.ts b/apps/api/src/ai/ai-stt.service.ts deleted file mode 100644 index 03c3792..0000000 --- a/apps/api/src/ai/ai-stt.service.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { - BadRequestException, - Injectable, - ServiceUnavailableException, -} from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; - -const MAX_AUDIO_BYTES = 8 * 1024 * 1024; - -@Injectable() -export class AiSttService { - constructor(private readonly config: ConfigService) {} - - isEnabled(): boolean { - return ( - this.config.get('STT_ENABLED') === 'true' && - Boolean(this.sttUrl()) - ); - } - - async transcribe(file: Express.Multer.File | undefined) { - if (!this.isEnabled()) { - throw new ServiceUnavailableException('تبدیل صوت به متن فعال نیست'); - } - if (!file?.buffer?.length) { - throw new BadRequestException('فایل صوتی ارسال نشده است'); - } - if (file.size > MAX_AUDIO_BYTES) { - throw new BadRequestException('حجم فایل صوتی بیش از حد مجاز است'); - } - - const form = new FormData(); - form.append( - 'audio', - new Blob([new Uint8Array(file.buffer)], { - type: file.mimetype || 'audio/wav', - }), - file.originalname || 'audio.wav', - ); - - const timeoutMs = Number(this.config.get('STT_TIMEOUT_MS') || 180000); - const headers: Record = {}; - const token = this.config.get('STT_INTERNAL_TOKEN')?.trim(); - if (token) headers['X-STT-Token'] = token; - - let res: Response; - try { - res = await fetch(`${this.sttUrl()}/transcribe`, { - method: 'POST', - headers, - body: form, - signal: AbortSignal.timeout(timeoutMs), - }); - } catch (e) { - const name = e instanceof Error ? e.name : ''; - if (name === 'TimeoutError' || name === 'AbortError') { - throw new ServiceUnavailableException( - 'زمان تبدیل صوت تمام شد. اگر تازه مدل عوض شده، چند دقیقه صبر کنید تا دانلود شود و دوباره امتحان کنید.', - ); - } - throw new ServiceUnavailableException( - 'سرویس تبدیل صوت خاموش است. مدل medium معمولاً حافظهٔ سرور را پر می‌کند و پروسه را می‌کشد. در apps/stt/.env مقدار STT_MODEL=small بگذارید و pm2 restart mento-stt بزنید.', - ); - } - - const payload = (await res.json().catch(() => ({}))) as { - text?: string; - detail?: string | Array<{ msg?: string }>; - }; - - if (!res.ok) { - const detail = Array.isArray(payload.detail) - ? payload.detail.map((item) => item.msg).filter(Boolean).join('، ') - : payload.detail; - const raw = String(detail || '').trim(); - let message = raw || 'تبدیل صوت به متن ناموفق بود'; - if (raw.length > 220) { - message = 'تبدیل صوت به متن ناموفق بود. سرویس STT محلی را بررسی کنید.'; - } - throw new ServiceUnavailableException(message); - } - - return { text: (payload.text ?? '').trim() }; - } - - private sttUrl(): string { - return (this.config.get('STT_URL') ?? '').trim().replace(/\/$/, ''); - } -} diff --git a/apps/api/src/ai/ai.controller.ts b/apps/api/src/ai/ai.controller.ts index cb912a4..5f2b81b 100644 --- a/apps/api/src/ai/ai.controller.ts +++ b/apps/api/src/ai/ai.controller.ts @@ -1,22 +1,10 @@ -import { - Body, - Controller, - Get, - Param, - Post, - UploadedFile, - UseGuards, - UseInterceptors, -} from '@nestjs/common'; -import { FileInterceptor } from '@nestjs/platform-express'; -import { memoryStorage } from 'multer'; +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; import type { Actor } from '../common/record-scope'; import { AiAskService } from './ai-ask.service'; import { AskMentoDto } from './dto'; import { AiService } from './ai.service'; -import { AiSttService } from './ai-stt.service'; @Controller('ai') @UseGuards(JwtAuthGuard) @@ -24,23 +12,11 @@ export class AiController { constructor( private readonly ai: AiService, private readonly ask: AiAskService, - private readonly stt: AiSttService, ) {} @Get('status') status() { - return { enabled: this.ai.isEnabled(), stt: this.stt.isEnabled() }; - } - - @Post('transcribe') - @UseInterceptors( - FileInterceptor('audio', { - storage: memoryStorage(), - limits: { fileSize: 8 * 1024 * 1024 }, - }), - ) - transcribe(@UploadedFile() file: Express.Multer.File) { - return this.stt.transcribe(file); + return { enabled: this.ai.isEnabled() }; } @Post('ask') diff --git a/apps/api/src/ai/ai.module.ts b/apps/api/src/ai/ai.module.ts index c05d6af..e4980b4 100644 --- a/apps/api/src/ai/ai.module.ts +++ b/apps/api/src/ai/ai.module.ts @@ -2,12 +2,11 @@ import { Global, Module } from '@nestjs/common'; import { AiAskService } from './ai-ask.service'; import { AiController } from './ai.controller'; import { AiService } from './ai.service'; -import { AiSttService } from './ai-stt.service'; @Global() @Module({ controllers: [AiController], - providers: [AiService, AiAskService, AiSttService], - exports: [AiService, AiSttService], + providers: [AiService, AiAskService], + exports: [AiService], }) export class AiModule {} diff --git a/apps/web/src/components/VoiceToTextButton.vue b/apps/web/src/components/VoiceToTextButton.vue index 72f9611..54b9f7e 100644 --- a/apps/web/src/components/VoiceToTextButton.vue +++ b/apps/web/src/components/VoiceToTextButton.vue @@ -1,10 +1,30 @@