From 7757c0837d8c74eed55230dd82d200addf488ce0 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 3 Sep 2026 14:20:55 +0330 Subject: [PATCH] Add Ask Mento, customer mood, and Pipecat speech-to-text. Ship assistant answers, contact moods, voice dictation, and the related CRM UI so these can be deployed to production. Co-authored-by: Cursor --- .gitignore | 4 + .../20260903080000_person_mood/migration.sql | 5 + apps/api/prisma/schema.prisma | 8 + apps/api/src/ai/ai-ask.service.ts | 236 ++ apps/api/src/ai/ai-stt.service.ts | 80 + apps/api/src/ai/ai.controller.ts | 58 + apps/api/src/ai/ai.module.ts | 8 +- apps/api/src/ai/ai.service.ts | 39 +- apps/api/src/ai/dto.ts | 12 + .../src/contacts/contacts-excel.service.ts | 17 +- apps/api/src/contacts/dto.ts | 18 + apps/api/src/contacts/people.controller.ts | 1 + apps/api/src/contacts/people.service.ts | 12 +- apps/stt/.env.example | 9 + apps/stt/README.md | 33 + apps/stt/main.py | 145 ++ apps/stt/requirements.txt | 5 + apps/web/src/components/AskMentoPanel.vue | 161 ++ apps/web/src/components/CustomerMoodFace.vue | 221 ++ apps/web/src/components/EntityTimeline.vue | 19 +- .../src/components/NoteAiSuggestionPanel.vue | 9 + apps/web/src/components/QuickCreate.vue | 437 ++++ apps/web/src/components/UiIcon.vue | 25 +- apps/web/src/components/VoiceToTextButton.vue | 334 +++ .../activities/ActivityCommentSheet.vue | 343 +-- .../components/dashboard/HomeAiAssistant.vue | 106 +- apps/web/src/layouts/AppLayout.vue | 2 + apps/web/src/lib/person-mood.ts | 10 + apps/web/src/lib/spoken-text.ts | 8 + apps/web/src/views/ActivityFormView.vue | 16 +- apps/web/src/views/ContactsView.vue | 959 +++++--- apps/web/src/views/DealFormView.vue | 1302 +++++++++-- apps/web/src/views/HomeView.vue | 44 +- apps/web/src/views/NotesView.vue | 91 +- apps/web/src/views/PersonFormView.vue | 58 +- apps/web/src/views/PersonProfileView.vue | 1933 +++++++++-------- apps/web/src/views/ProfileView.vue | 19 + apps/web/src/views/ProjectCardFormView.vue | 21 + deploy/.env.production.example | 6 + deploy/DEPLOY.md | 35 +- deploy/ecosystem.config.cjs | 11 + package.json | 1 + packages/shared/src/index.ts | 36 + 43 files changed, 5156 insertions(+), 1741 deletions(-) create mode 100644 apps/api/prisma/migrations/20260903080000_person_mood/migration.sql create mode 100644 apps/api/src/ai/ai-ask.service.ts create mode 100644 apps/api/src/ai/ai-stt.service.ts create mode 100644 apps/api/src/ai/ai.controller.ts create mode 100644 apps/api/src/ai/dto.ts create mode 100644 apps/stt/.env.example create mode 100644 apps/stt/README.md create mode 100644 apps/stt/main.py create mode 100644 apps/stt/requirements.txt create mode 100644 apps/web/src/components/AskMentoPanel.vue create mode 100644 apps/web/src/components/CustomerMoodFace.vue create mode 100644 apps/web/src/components/QuickCreate.vue create mode 100644 apps/web/src/components/VoiceToTextButton.vue create mode 100644 apps/web/src/lib/person-mood.ts create mode 100644 apps/web/src/lib/spoken-text.ts diff --git a/.gitignore b/.gitignore index 90ab536..d46d336 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,7 @@ docs/~$* .idea/ .vscode/ *.tsbuildinfo +**/__pycache__/ +*.py[cod] +apps/stt/.venv/ +crm-node_modules.tgz diff --git a/apps/api/prisma/migrations/20260903080000_person_mood/migration.sql b/apps/api/prisma/migrations/20260903080000_person_mood/migration.sql new file mode 100644 index 0000000..ca6d40d --- /dev/null +++ b/apps/api/prisma/migrations/20260903080000_person_mood/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "PersonMood" AS ENUM ('newcomer', 'happy', 'unhappy', 'special'); + +-- AlterTable +ALTER TABLE "Person" ADD COLUMN "mood" "PersonMood" NOT NULL DEFAULT 'newcomer'; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index 3d973d4..6560262 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -41,6 +41,13 @@ enum ActivityPriority { very_high } +enum PersonMood { + newcomer + happy + unhappy + special +} + enum CustomFieldEntity { person company @@ -234,6 +241,7 @@ model Person { mobile String? position String? notes String? + mood PersonMood @default(newcomer) customFields Json @default("{}") loyaltyPoints Int @default(0) loyaltyTierId String? diff --git a/apps/api/src/ai/ai-ask.service.ts b/apps/api/src/ai/ai-ask.service.ts new file mode 100644 index 0000000..752350f --- /dev/null +++ b/apps/api/src/ai/ai-ask.service.ts @@ -0,0 +1,236 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import type { + AskMentoResponse, + AskMentoSourceDto, + PersonAiSummaryDto, +} from '@mento/shared'; +import { PrismaService } from '../prisma/prisma.service'; +import { + type Actor, + assigneeWhere, + noteAccessWhere, + ownerWhere, +} from '../common/record-scope'; +import { AiService } from './ai.service'; + +type CatalogItem = AskMentoSourceDto; + +function clip(value: string | null | undefined, max = 280) { + const text = (value ?? '').replace(/\s+/g, ' ').trim(); + if (!text) return ''; + return text.length > max ? `${text.slice(0, max)}…` : text; +} + +@Injectable() +export class AiAskService { + constructor( + private readonly prisma: PrismaService, + private readonly ai: AiService, + ) {} + + async ask( + actor: Actor, + question: string, + personId?: string, + ): Promise { + const { context, catalog } = await this.buildContext(actor, personId); + const raw = await this.ai.completeJson<{ + answer?: string; + sources?: Array<{ type?: string; id?: string; label?: string }>; + }>( + `You are Mento, a Persian CRM sales assistant. +Answer ONLY from the CRM context. If the context is insufficient, say so in Persian. +Do not invent customers, amounts, dates, or promises. +Return JSON: { "answer": string, "sources": [{ "type": "person"|"note"|"activity"|"deal"|"memory", "id": string, "label": string }] } +Use at most 4 sources, and only ids that appear in the context. +Write the answer in Persian, concise, with dates when present.`, + `Question:\n${question.trim()}\n\nCRM context:\n${context}`, + ); + + const allowed = new Map(catalog.map((item) => [item.id, item])); + const sources = (Array.isArray(raw.sources) ? raw.sources : []) + .map((item) => allowed.get(String(item?.id ?? ''))) + .filter((item): item is CatalogItem => Boolean(item)) + .slice(0, 4); + + const answer = String(raw.answer ?? '').trim().slice(0, 2000); + return { + answer: answer || 'با اطلاعات فعلی پرونده چیزی برای پاسخ پیدا نشد.', + sources, + }; + } + + async summarizePerson(actor: Actor, personId: string): Promise { + const { context } = await this.buildContext(actor, personId); + const raw = await this.ai.completeJson<{ + text?: string; + cta?: string; + ctaKind?: string; + }>( + `You write a short live briefing for a CRM customer card in Persian. +Return JSON: { "text": string, "cta": string, "ctaKind": "call"|"activity" } +Rules: +- 2 to 4 short sentences from the context only. +- Mention durable memory, last contact, open deals, or next follow-up when present. +- cta is one concrete next step, starting with «پیشنهاد منتو:». +- ctaKind is "call" if a phone follow-up is best, otherwise "activity". +- Do not invent facts.`, + `Customer briefing context:\n${context}`, + ); + + const text = String(raw.text ?? '').trim().slice(0, 700); + const cta = String(raw.cta ?? '').trim().slice(0, 120); + const ctaKind = raw.ctaKind === 'activity' ? 'activity' : 'call'; + return { + text: text || 'هنوز دادهٔ کافی برای خلاصه هوشمند این مشتری نیست.', + cta: cta || 'پیشنهاد منتو: امروز با مشتری تماس بگیر', + ctaKind, + }; + } + + private async buildContext(actor: Actor, personId?: string) { + if (personId) { + const person = await this.prisma.person.findFirst({ + where: { + id: personId, + organizationId: actor.organizationId, + ...ownerWhere(actor), + }, + include: { company: { select: { name: true } } }, + }); + if (!person) throw new NotFoundException('شخص پیدا نشد'); + } + + const [notes, activities, deals, memories] = await Promise.all([ + this.prisma.note.findMany({ + where: { + organizationId: actor.organizationId, + ...noteAccessWhere(actor), + ...(personId ? { personId } : {}), + }, + select: { + id: true, + title: true, + body: true, + createdAt: true, + person: { select: { firstName: true, lastName: true } }, + }, + orderBy: { createdAt: 'desc' }, + take: personId ? 15 : 8, + }), + this.prisma.activity.findMany({ + where: { + organizationId: actor.organizationId, + ...assigneeWhere(actor), + ...(personId ? { personId } : {}), + }, + select: { + id: true, + type: true, + status: true, + title: true, + description: true, + dueAt: true, + person: { select: { firstName: true, lastName: true } }, + }, + orderBy: { updatedAt: 'desc' }, + take: personId ? 15 : 10, + }), + this.prisma.deal.findMany({ + where: { + organizationId: actor.organizationId, + ...ownerWhere(actor), + ...(personId ? { personId } : { status: 'open' }), + }, + select: { + id: true, + title: true, + amount: true, + currency: true, + status: true, + isStale: true, + isRotten: true, + person: { select: { firstName: true, lastName: true } }, + stage: { select: { name: true } }, + }, + orderBy: { updatedAt: 'desc' }, + take: 10, + }), + personId + ? this.prisma.memoryItem.findMany({ + where: { organizationId: actor.organizationId, personId }, + select: { id: true, content: true, updatedAt: true }, + orderBy: { updatedAt: 'desc' }, + take: 8, + }) + : Promise.resolve([]), + ]); + + const catalog: CatalogItem[] = []; + const lines: string[] = [ + `Today: ${new Date().toISOString()}`, + personId ? `Focus personId: ${personId}` : 'Scope: current user records', + ]; + + if (personId) { + const person = await this.prisma.person.findFirst({ + where: { id: personId, organizationId: actor.organizationId }, + include: { company: { select: { name: true } } }, + }); + if (person) { + catalog.push({ + type: 'person', + id: person.id, + label: `${person.firstName} ${person.lastName}`.trim(), + }); + lines.push( + `Person: ${person.firstName} ${person.lastName} | position: ${person.position ?? '—'} | company: ${person.company?.name ?? '—'} | mood: ${person.mood} | phone: ${person.mobile || person.phone || '—'} | email: ${person.email ?? '—'}`, + ); + } + } + + for (const item of memories) { + catalog.push({ type: 'memory', id: item.id, label: clip(item.content, 60) }); + lines.push(`Memory ${item.id}: ${clip(item.content, 220)}`); + } + for (const note of notes) { + const who = note.person + ? `${note.person.firstName} ${note.person.lastName}` + : ''; + catalog.push({ + type: 'note', + id: note.id, + label: clip(note.title || note.body, 60), + }); + lines.push( + `Note ${note.id} (${note.createdAt.toISOString()})${who ? ` [${who}]` : ''}: ${clip(note.title ? `${note.title} — ${note.body}` : note.body)}`, + ); + } + for (const activity of activities) { + const who = activity.person + ? `${activity.person.firstName} ${activity.person.lastName}` + : ''; + catalog.push({ type: 'activity', id: activity.id, label: activity.title }); + lines.push( + `Activity ${activity.id} type=${activity.type} status=${activity.status} due=${activity.dueAt?.toISOString() ?? '—'} title=${activity.title}${who ? ` person=${who}` : ''}${activity.description ? ` | ${clip(activity.description, 160)}` : ''}`, + ); + } + for (const deal of deals) { + const who = deal.person + ? `${deal.person.firstName} ${deal.person.lastName}` + : ''; + catalog.push({ type: 'deal', id: deal.id, label: deal.title }); + const flags = [ + deal.isStale ? 'stale' : null, + deal.isRotten ? 'rotten' : null, + ] + .filter(Boolean) + .join(','); + lines.push( + `Deal ${deal.id} ${deal.title} status=${deal.status} stage=${deal.stage.name} amount=${deal.amount != null ? String(deal.amount) : '—'} ${deal.currency}${flags ? ` flags=${flags}` : ''}${who ? ` person=${who}` : ''}`, + ); + } + + return { context: lines.join('\n').slice(0, 12000), catalog }; + } +} diff --git a/apps/api/src/ai/ai-stt.service.ts b/apps/api/src/ai/ai-stt.service.ts new file mode 100644 index 0000000..8b80785 --- /dev/null +++ b/apps/api/src/ai/ai-stt.service.ts @@ -0,0 +1,80 @@ +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') || 60000); + 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 { + throw new ServiceUnavailableException( + 'سرویس تبدیل صوت در دسترس نیست. Pipecat 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; + throw new ServiceUnavailableException( + detail || 'تبدیل صوت به متن ناموفق بود', + ); + } + + 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 new file mode 100644 index 0000000..cb912a4 --- /dev/null +++ b/apps/api/src/ai/ai.controller.ts @@ -0,0 +1,58 @@ +import { + Body, + Controller, + Get, + Param, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { memoryStorage } from 'multer'; +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) +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); + } + + @Post('ask') + askMento(@CurrentUser() user: Actor, @Body() dto: AskMentoDto) { + return this.ask.ask(user, dto.question, dto.personId); + } + + @Get('people/:personId/summary') + personSummary( + @CurrentUser() user: Actor, + @Param('personId') personId: string, + ) { + return this.ask.summarizePerson(user, personId); + } +} diff --git a/apps/api/src/ai/ai.module.ts b/apps/api/src/ai/ai.module.ts index 3c2adc3..c05d6af 100644 --- a/apps/api/src/ai/ai.module.ts +++ b/apps/api/src/ai/ai.module.ts @@ -1,9 +1,13 @@ 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({ - providers: [AiService], - exports: [AiService], + controllers: [AiController], + providers: [AiService, AiAskService, AiSttService], + exports: [AiService, AiSttService], }) export class AiModule {} diff --git a/apps/api/src/ai/ai.service.ts b/apps/api/src/ai/ai.service.ts index 632a5ae..a340427 100644 --- a/apps/api/src/ai/ai.service.ts +++ b/apps/api/src/ai/ai.service.ts @@ -32,17 +32,6 @@ export class AiService { noteType?: string; personName?: string | null; }): Promise { - if (!this.isEnabled()) { - throw new ServiceUnavailableException('هوش مصنوعی فعال نیست'); - } - - const model = this.config.get('AI_MODEL')?.trim() || 'gpt-4o-mini'; - const baseUrl = - this.config.get('AI_BASE_URL')?.trim() || - 'https://api.openai.com/v1'; - const apiKey = this.config.get('AI_API_KEY')!.trim(); - const timeoutMs = Number(this.config.get('AI_TIMEOUT_MS') || 45000); - const system = `You analyze CRM notes in Persian/Farsi and extract structured data. Return ONLY valid JSON with this exact shape: { @@ -65,6 +54,25 @@ Rules: `Note:\n${input.body.trim()}`, ].filter(Boolean); + const raw = await this.completeJson>( + system, + userParts.join('\n'), + ); + return this.normalizeExtract(raw); + } + + async completeJson(system: string, user: string): Promise { + if (!this.isEnabled()) { + throw new ServiceUnavailableException('هوش مصنوعی فعال نیست'); + } + + const model = this.config.get('AI_MODEL')?.trim() || 'gpt-4o-mini'; + const baseUrl = + this.config.get('AI_BASE_URL')?.trim() || + 'https://api.openai.com/v1'; + const apiKey = this.config.get('AI_API_KEY')!.trim(); + const timeoutMs = Number(this.config.get('AI_TIMEOUT_MS') || 45000); + const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); @@ -81,7 +89,7 @@ Rules: response_format: { type: 'json_object' }, messages: [ { role: 'system', content: system }, - { role: 'user', content: userParts.join('\n') }, + { role: 'user', content: user }, ], }), signal: controller.signal, @@ -102,15 +110,18 @@ Rules: throw new BadRequestException('پاسخ مدل خالی بود'); } - return this.normalizeExtract(JSON.parse(raw) as Partial); + return JSON.parse(raw) as T; } catch (err) { if (err instanceof BadRequestException || err instanceof ServiceUnavailableException) { throw err; } + if (err instanceof SyntaxError) { + throw new BadRequestException('پاسخ مدل قابل خواندن نبود'); + } if (err instanceof Error && err.name === 'AbortError') { throw new ServiceUnavailableException('زمان پاسخ مدل هوش مصنوعی تمام شد'); } - throw new ServiceUnavailableException('تحلیل یادداشت با خطا مواجه شد'); + throw new ServiceUnavailableException('ارتباط با مدل هوش مصنوعی ناموفق بود'); } finally { clearTimeout(timer); } diff --git a/apps/api/src/ai/dto.ts b/apps/api/src/ai/dto.ts new file mode 100644 index 0000000..1d1bd53 --- /dev/null +++ b/apps/api/src/ai/dto.ts @@ -0,0 +1,12 @@ +import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class AskMentoDto { + @IsString() + @MinLength(2) + @MaxLength(500) + question!: string; + + @IsOptional() + @IsString() + personId?: string; +} diff --git a/apps/api/src/contacts/contacts-excel.service.ts b/apps/api/src/contacts/contacts-excel.service.ts index d76a924..ced8026 100644 --- a/apps/api/src/contacts/contacts-excel.service.ts +++ b/apps/api/src/contacts/contacts-excel.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, ConflictException, Injectable } from '@nestjs/common'; import ExcelJS from 'exceljs'; -import type { ImportContactsResult } from '@mento/shared'; +import type { ImportContactsResult, PersonMood } from '@mento/shared'; +import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@mento/shared'; import { PrismaService } from '../prisma/prisma.service'; import type { Actor } from '../common/record-scope'; import { ownerWhere } from '../common/record-scope'; @@ -21,6 +22,13 @@ function cellStr(value: ExcelJS.CellValue | undefined): string { return String(value).trim(); } +function parseMood(raw?: string): PersonMood | undefined { + const value = raw?.trim(); + if (!value) return undefined; + if (PERSON_MOODS.includes(value as PersonMood)) return value as PersonMood; + return PERSON_MOODS.find((mood) => PERSON_MOOD_LABELS[mood] === value); +} + function normalizeHeader(raw: string) { return raw.trim().toLowerCase().replace(/\s+/g, ''); } @@ -44,6 +52,10 @@ const PERSON_ALIASES: Record = { شرکت: 'companyName', notes: 'notes', یادداشت: 'notes', + mood: 'mood', + وضعیت: 'mood', + وضعیتمشتری: 'mood', + 'وضعیت مشتری': 'mood', }; const COMPANY_ALIASES: Record = { @@ -86,6 +98,7 @@ export class ContactsExcelService { { header: 'ایمیل', key: 'email', width: 28 }, { header: 'سمت', key: 'position', width: 18 }, { header: 'شرکت', key: 'companyName', width: 24 }, + { header: 'وضعیت مشتری', key: 'mood', width: 18 }, { header: 'یادداشت', key: 'notes', width: 32 }, ]; for (const p of items) { @@ -97,6 +110,7 @@ export class ContactsExcelService { email: p.email ?? '', position: p.position ?? '', companyName: p.company?.name ?? '', + mood: PERSON_MOOD_LABELS[p.mood || 'newcomer'], notes: p.notes ?? '', }); } @@ -174,6 +188,7 @@ export class ContactsExcelService { mobile: row.mobile || undefined, position: row.position || undefined, notes: row.notes || undefined, + mood: parseMood(row.mood), companyId, }); result.created += 1; diff --git a/apps/api/src/contacts/dto.ts b/apps/api/src/contacts/dto.ts index 7e865b5..7725719 100644 --- a/apps/api/src/contacts/dto.ts +++ b/apps/api/src/contacts/dto.ts @@ -1,6 +1,7 @@ import { Type } from 'class-transformer'; import { IsEmail, + IsIn, IsInt, IsObject, IsOptional, @@ -11,6 +12,7 @@ import { MinLength, ValidateIf, } from 'class-validator'; +import { PERSON_MOODS } from '@mento/shared'; export class ListQueryDto { @IsOptional() @@ -29,6 +31,14 @@ export class ListQueryDto { @Min(1) @Max(100) limit?: number = 20; + + @IsOptional() + @IsIn([...PERSON_MOODS]) + mood?: (typeof PERSON_MOODS)[number]; + + @IsOptional() + @IsIn(['name', 'newest']) + sort?: 'name' | 'newest'; } export class CreateCompanyDto { @@ -160,6 +170,10 @@ export class CreatePersonDto { @MaxLength(2000) notes?: string; + @IsOptional() + @IsIn([...PERSON_MOODS]) + mood?: (typeof PERSON_MOODS)[number]; + @IsOptional() @ValidateIf((_, v) => v !== '' && v != null) @IsString() @@ -213,6 +227,10 @@ export class UpdatePersonDto { @MaxLength(2000) notes?: string; + @IsOptional() + @IsIn([...PERSON_MOODS]) + mood?: (typeof PERSON_MOODS)[number]; + @IsOptional() @ValidateIf((_, v) => v !== '' && v != null) @IsString() diff --git a/apps/api/src/contacts/people.controller.ts b/apps/api/src/contacts/people.controller.ts index 84fc909..814764d 100644 --- a/apps/api/src/contacts/people.controller.ts +++ b/apps/api/src/contacts/people.controller.ts @@ -37,6 +37,7 @@ export class PeopleController { query.q, query.page ?? 1, query.limit ?? 20, + { mood: query.mood, sort: query.sort }, ); } diff --git a/apps/api/src/contacts/people.service.ts b/apps/api/src/contacts/people.service.ts index dc76bad..c5c8a53 100644 --- a/apps/api/src/contacts/people.service.ts +++ b/apps/api/src/contacts/people.service.ts @@ -7,6 +7,7 @@ import type { CreatePersonRequest, PaginatedResponse, PersonDto, + PersonMood, UpdatePersonRequest, } from '@mento/shared'; import { Prisma } from '@prisma/client'; @@ -41,6 +42,7 @@ type PersonRow = { mobile: string | null; position: string | null; notes: string | null; + mood: PersonMood; customFields: unknown; loyaltyPoints: number; createdAt: Date; @@ -67,10 +69,12 @@ export class PeopleService { q?: string, page = 1, limit = 20, + opts?: { mood?: PersonMood; sort?: 'name' | 'newest' }, ): Promise> { const where: Prisma.PersonWhereInput = { organizationId: actor.organizationId, ...ownerWhere(actor), + ...(opts?.mood ? { mood: opts.mood } : {}), ...(q?.trim() ? { OR: [ @@ -89,7 +93,10 @@ export class PeopleService { this.prisma.person.findMany({ where, include: personInclude, - orderBy: [{ lastName: 'asc' }, { firstName: 'asc' }], + orderBy: + opts?.sort === 'newest' + ? [{ createdAt: 'desc' }] + : [{ lastName: 'asc' }, { firstName: 'asc' }], skip: (page - 1) * limit, take: limit, }), @@ -128,6 +135,7 @@ export class PeopleService { mobile: normalizePhone(dto.mobile), position: emptyToNull(dto.position), notes: emptyToNull(dto.notes), + mood: dto.mood ?? 'newcomer', companyId, ownerId: resolveOwnerId(actor, dto.ownerId), customFields: toJsonCustomFields(dto.customFields), @@ -156,6 +164,7 @@ export class PeopleService { if ('mobile' in dto) data.mobile = normalizePhone(dto.mobile); if ('position' in dto) data.position = emptyToNull(dto.position); if ('notes' in dto) data.notes = emptyToNull(dto.notes); + if (dto.mood !== undefined) data.mood = dto.mood; if ('customFields' in dto) { data.customFields = toJsonCustomFields(dto.customFields); } @@ -238,6 +247,7 @@ export class PeopleService { mobile: row.mobile, position: row.position, notes: row.notes, + mood: row.mood, customFields: asCustomFields(row.customFields), loyaltyPoints: row.loyaltyPoints ?? 0, loyaltyTier: row.loyaltyTier, diff --git a/apps/stt/.env.example b/apps/stt/.env.example new file mode 100644 index 0000000..d252860 --- /dev/null +++ b/apps/stt/.env.example @@ -0,0 +1,9 @@ +# Pipecat STT only — same OpenAI-compatible key as the Nest AI chat (GapGPT). +OPENAI_API_KEY= +OPENAI_BASE_URL=https://api.gapgpt.app/v1 +STT_MODEL=whisper-1 +STT_PROMPT=یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسب‌وکار را دقیق بنویس. + +STT_HOST=127.0.0.1 +STT_PORT=3040 +STT_INTERNAL_TOKEN= diff --git a/apps/stt/README.md b/apps/stt/README.md new file mode 100644 index 0000000..4ac3585 --- /dev/null +++ b/apps/stt/README.md @@ -0,0 +1,33 @@ +# Mento STT (Pipecat) + +تبدیل صوت به متن — فقط STT. بدون TTS، بدون مکالمهٔ صوتی. + +Pipecat اینجا فقط `OpenAISTTService` را اجرا می‌کند (HTTP Whisper). مدل از API سازگار با OpenAI می‌آید (مثلاً GapGPT). + +## لوکال (ویندوز) + +Python 3.11+ لازم است. + +```powershell +cd apps/stt +py -3.11 -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +copy .env.example .env +``` + +در `.env` همان `AI_API_KEY` / `AI_BASE_URL` پنل را بگذارید (`OPENAI_API_KEY` و `OPENAI_BASE_URL`). + +```powershell +uvicorn main:app --host 127.0.0.1 --port 3040 +``` + +در `apps/api/.env`: + +``` +STT_ENABLED=true +STT_URL=http://127.0.0.1:3040 +STT_INTERNAL_TOKEN= +``` + +اگر `STT_INTERNAL_TOKEN` را پر کردید، همان مقدار را در هر دو `.env` بگذارید. diff --git a/apps/stt/main.py b/apps/stt/main.py new file mode 100644 index 0000000..3ff0a76 --- /dev/null +++ b/apps/stt/main.py @@ -0,0 +1,145 @@ +"""Speech-to-text sidecar. Uses Pipecat OpenAISTTService only — no TTS, no voice agent.""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +from pathlib import Path + +from dotenv import load_dotenv +from fastapi import FastAPI, File, Header, HTTPException, UploadFile +from fastapi.responses import JSONResponse +from pipecat.services.openai.stt import OpenAISTTService +from pipecat.transcriptions.language import Language + +load_dotenv(Path(__file__).resolve().parent / ".env") + +MAX_BYTES = 8 * 1024 * 1024 +WAV_MAGIC = (b"RIFF", b"WAVE") + +app = FastAPI(title="Mento STT", docs_url=None, redoc_url=None) +_stt: OpenAISTTService | None = None + + +def _env(name: str, default: str = "") -> str: + return (os.getenv(name) or default).strip() + + +def _require_token(header_token: str | None) -> None: + expected = _env("STT_INTERNAL_TOKEN") + if not expected: + return + if (header_token or "").strip() != expected: + raise HTTPException(status_code=401, detail="توکن سرویس تبدیل صوت نامعتبر است") + + +def _stt_service() -> OpenAISTTService: + global _stt + if _stt is not None: + return _stt + + api_key = _env("OPENAI_API_KEY") + if not api_key: + raise HTTPException(status_code=503, detail="کلید تبدیل صوت تنظیم نشده است") + + model = _env("STT_MODEL", "whisper-1") + prompt = _env( + "STT_PROMPT", + "یادداشت و مکالمهٔ فروش به زبان فارسی. اسامی مشتریان و اصطلاحات کسب‌وکار را دقیق بنویس.", + ) + base_url = _env("OPENAI_BASE_URL") or None + + _stt = OpenAISTTService( + api_key=api_key, + base_url=base_url, + settings=OpenAISTTService.Settings( + model=model, + language=Language.FA, + prompt=prompt, + ), + ) + return _stt + + +def _is_wav(data: bytes) -> bool: + return len(data) >= 12 and data[:4] == WAV_MAGIC[0] and data[8:12] == WAV_MAGIC[1] + + +def _to_wav(data: bytes, filename: str) -> bytes: + if _is_wav(data): + return data + + ffmpeg = shutil.which("ffmpeg") + if not ffmpeg: + raise HTTPException( + status_code=400, + detail="فرمت صوت پشتیبانی نشد. فایل WAV ارسال کنید یا ffmpeg را نصب کنید.", + ) + + suffix = Path(filename or "audio.webm").suffix or ".webm" + with tempfile.TemporaryDirectory() as tmp: + src = Path(tmp) / f"in{suffix}" + dst = Path(tmp) / "out.wav" + src.write_bytes(data) + result = subprocess.run( + [ + ffmpeg, + "-y", + "-i", + str(src), + "-ac", + "1", + "-ar", + "16000", + "-f", + "wav", + str(dst), + ], + capture_output=True, + check=False, + ) + if result.returncode != 0 or not dst.exists(): + raise HTTPException(status_code=400, detail="تبدیل فایل صوتی ناموفق بود") + return dst.read_bytes() + + +@app.get("/health") +async def health() -> dict[str, object]: + return { + "ok": True, + "engine": "pipecat", + "service": "OpenAISTTService", + "configured": bool(_env("OPENAI_API_KEY")), + } + + +@app.post("/transcribe") +async def transcribe( + audio: UploadFile = File(...), + x_stt_token: str | None = Header(default=None, alias="X-STT-Token"), +) -> JSONResponse: + _require_token(x_stt_token) + + data = await audio.read() + if not data: + raise HTTPException(status_code=400, detail="فایل صوتی خالی است") + if len(data) > MAX_BYTES: + raise HTTPException(status_code=400, detail="حجم فایل صوتی بیش از حد مجاز است") + + wav = _to_wav(data, audio.filename or "audio.wav") + service = _stt_service() + + try: + result = await service._transcribe(wav) + except HTTPException: + raise + except Exception as exc: + raise HTTPException( + status_code=502, + detail=f"تبدیل صوت به متن ناموفق بود: {exc}", + ) from exc + + text = (getattr(result, "text", None) or "").strip() + return JSONResponse({"text": text}) diff --git a/apps/stt/requirements.txt b/apps/stt/requirements.txt new file mode 100644 index 0000000..88a0410 --- /dev/null +++ b/apps/stt/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.116.1 +uvicorn[standard]==0.35.0 +python-multipart==0.0.20 +python-dotenv==1.1.1 +pipecat-ai[openai]==1.8.1 diff --git a/apps/web/src/components/AskMentoPanel.vue b/apps/web/src/components/AskMentoPanel.vue new file mode 100644 index 0000000..b7ff494 --- /dev/null +++ b/apps/web/src/components/AskMentoPanel.vue @@ -0,0 +1,161 @@ + + + + + diff --git a/apps/web/src/components/CustomerMoodFace.vue b/apps/web/src/components/CustomerMoodFace.vue new file mode 100644 index 0000000..fe66be8 --- /dev/null +++ b/apps/web/src/components/CustomerMoodFace.vue @@ -0,0 +1,221 @@ + + + + + diff --git a/apps/web/src/components/EntityTimeline.vue b/apps/web/src/components/EntityTimeline.vue index 4574b23..6fad0f3 100644 --- a/apps/web/src/components/EntityTimeline.vue +++ b/apps/web/src/components/EntityTimeline.vue @@ -2,6 +2,8 @@ import { computed, onMounted, ref, watch } from 'vue'; import { RouterLink } from 'vue-router'; import { api } from '@/lib/api'; +import VoiceToTextButton from '@/components/VoiceToTextButton.vue'; +import { appendSpokenText } from '@/lib/spoken-text'; import type { ActivityDto, NoteDto, @@ -203,9 +205,12 @@ onMounted(load); placeholder="یادداشت سریع…" @keydown.ctrl.enter.prevent="addNote" /> - +
+ + +
+import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'; +import { useRoute, useRouter } from 'vue-router'; +import type { RouteLocationRaw } from 'vue-router'; +import UiIcon from '@/components/UiIcon.vue'; + +const FAB_SIZE = 56; +const FAB_MARGIN = 20; +const POS_KEY = 'mento.quickCreateFab'; + +type FabPos = { left: number; top: number }; + +type ItemIcon = 'user' | 'building' | 'dollar' | 'calendar-plus' | 'document'; + +const items: Array<{ + key: string; + title: string; + desc: string; + icon: ItemIcon; + tone: string; + to: RouteLocationRaw; +}> = [ + { + key: 'person', + title: 'افزودن شخص', + desc: 'مخاطب جدید را ثبت کنید', + icon: 'user', + tone: 'person', + to: { name: 'person-new' }, + }, + { + key: 'company', + title: 'افزودن شرکت', + desc: 'شرکت یا سازمان جدید اضافه کنید', + icon: 'building', + tone: 'company', + to: { name: 'company-new' }, + }, + { + key: 'deal', + title: 'افزودن معامله', + desc: 'فرصت یا معامله جدید ایجاد کنید', + icon: 'dollar', + tone: 'deal', + to: { name: 'deal-new' }, + }, + { + key: 'activity', + title: 'افزودن فعالیت', + desc: 'تماس، جلسه، ایمیل و ... ثبت کنید', + icon: 'calendar-plus', + tone: 'activity', + to: { name: 'activity-new' }, + }, + { + key: 'note', + title: 'افزودن کارت (یادداشت)', + desc: 'یادداشت یا کارت جدید ایجاد کنید', + icon: 'document', + tone: 'note', + to: { name: 'notes' }, + }, +]; + +const route = useRoute(); +const router = useRouter(); +const hidden = computed(() => route.name === 'person-detail'); +const isQuickCreateOpen = ref(false); +const ready = ref(false); +const fabPos = ref({ left: FAB_MARGIN, top: FAB_MARGIN }); +const viewport = ref({ w: 1280, h: 800 }); +const dragging = ref(false); +let dragMoved = false; +let dragOffsetX = 0; +let dragOffsetY = 0; + +function defaultPos(): FabPos { + return { + left: FAB_MARGIN, + top: Math.max(FAB_MARGIN, viewport.value.h - FAB_MARGIN - FAB_SIZE), + }; +} + +function clampPos(pos: FabPos): FabPos { + const margin = 8; + return { + left: Math.min(Math.max(margin, pos.left), viewport.value.w - FAB_SIZE - margin), + top: Math.min(Math.max(margin, pos.top), viewport.value.h - FAB_SIZE - margin), + }; +} + +function loadPos() { + try { + const raw = localStorage.getItem(POS_KEY); + if (!raw) { + fabPos.value = defaultPos(); + return; + } + const parsed = JSON.parse(raw) as FabPos; + if (typeof parsed.left !== 'number' || typeof parsed.top !== 'number') { + fabPos.value = defaultPos(); + return; + } + fabPos.value = clampPos(parsed); + } catch { + fabPos.value = defaultPos(); + } +} + +function savePos() { + localStorage.setItem(POS_KEY, JSON.stringify(fabPos.value)); +} + +function onFabPointerDown(ev: PointerEvent) { + if (ev.button !== 0) return; + dragMoved = false; + dragOffsetX = ev.clientX - fabPos.value.left; + dragOffsetY = ev.clientY - fabPos.value.top; + (ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId); +} + +function onFabPointerMove(ev: PointerEvent) { + if (!((ev.currentTarget as HTMLElement).hasPointerCapture?.(ev.pointerId))) return; + const next = clampPos({ + left: ev.clientX - dragOffsetX, + top: ev.clientY - dragOffsetY, + }); + if ( + Math.abs(next.left - fabPos.value.left) > 4 || + Math.abs(next.top - fabPos.value.top) > 4 + ) { + dragMoved = true; + dragging.value = true; + } + if (dragMoved) fabPos.value = next; +} + +function onFabPointerUp(ev: PointerEvent) { + (ev.currentTarget as HTMLElement).releasePointerCapture?.(ev.pointerId); + if (dragMoved) { + fabPos.value = clampPos(fabPos.value); + savePos(); + dragging.value = false; + return; + } + dragging.value = false; + toggle(); +} + +function syncViewport() { + viewport.value = { w: window.innerWidth, h: window.innerHeight }; +} + +function onResize() { + syncViewport(); + fabPos.value = clampPos(fabPos.value); +} + +const fabStyle = computed(() => ({ + left: `${fabPos.value.left}px`, + top: `${fabPos.value.top}px`, + right: 'auto', + bottom: 'auto', +})); + +const panelStyle = computed(() => { + const gap = 18; + const width = Math.min(390, viewport.value.w - 32); + let left = fabPos.value.left; + left = Math.min(left, viewport.value.w - width - 16); + left = Math.max(16, left); + const spaceAbove = fabPos.value.top; + const placeAbove = spaceAbove > 360; + if (placeAbove) { + return { + left: `${left}px`, + right: 'auto', + bottom: `${viewport.value.h - fabPos.value.top + gap}px`, + top: 'auto', + width: `${width}px`, + }; + } + return { + left: `${left}px`, + right: 'auto', + top: `${fabPos.value.top + FAB_SIZE + gap}px`, + bottom: 'auto', + width: `${width}px`, + }; +}); + +function close() { + isQuickCreateOpen.value = false; +} + +function toggle() { + isQuickCreateOpen.value = !isQuickCreateOpen.value; +} + +function go(to: RouteLocationRaw) { + close(); + void router.push(to); +} + +function onKeydown(ev: KeyboardEvent) { + if (ev.key === 'Escape' && isQuickCreateOpen.value) { + ev.preventDefault(); + close(); + } +} + +watch(isQuickCreateOpen, (value) => { + document.body.style.overflow = value ? 'hidden' : ''; +}); + +watch(hidden, (value) => { + if (value) close(); +}); + +watch( + () => route.fullPath, + () => { + close(); + }, +); + +onMounted(() => { + syncViewport(); + loadPos(); + ready.value = true; + window.addEventListener('resize', onResize); + window.addEventListener('keydown', onKeydown); +}); + +onBeforeUnmount(() => { + window.removeEventListener('resize', onResize); + window.removeEventListener('keydown', onKeydown); + document.body.style.overflow = ''; +}); + + + + + diff --git a/apps/web/src/components/UiIcon.vue b/apps/web/src/components/UiIcon.vue index df069a5..07ff14e 100644 --- a/apps/web/src/components/UiIcon.vue +++ b/apps/web/src/components/UiIcon.vue @@ -44,7 +44,11 @@ defineProps<{ | 'check-plain' | 'calendar-sun' | 'calendar-edit' - | 'minus'; + | 'minus' + | 'dollar' + | 'layers' + | 'target' + | 'pencil'; size?: number; }>(); @@ -249,9 +253,28 @@ defineProps<{ + + + + diff --git a/apps/web/src/components/VoiceToTextButton.vue b/apps/web/src/components/VoiceToTextButton.vue new file mode 100644 index 0000000..7cff061 --- /dev/null +++ b/apps/web/src/components/VoiceToTextButton.vue @@ -0,0 +1,334 @@ + + + + + diff --git a/apps/web/src/components/activities/ActivityCommentSheet.vue b/apps/web/src/components/activities/ActivityCommentSheet.vue index df65849..60cf9ed 100644 --- a/apps/web/src/components/activities/ActivityCommentSheet.vue +++ b/apps/web/src/components/activities/ActivityCommentSheet.vue @@ -5,9 +5,10 @@ import { calendarDaysBetween, formatClockFa, formatJalaliDay, - toFaDigits, } from '@/lib/jalali'; +import { appendSpokenText } from '@/lib/spoken-text'; import UiIcon from '@/components/UiIcon.vue'; +import VoiceToTextButton from '@/components/VoiceToTextButton.vue'; import type { ActivityDto, ActivityType, OrganizationDto, OrgUserDto } from '@mento/shared'; const props = defineProps<{ @@ -35,34 +36,7 @@ const mentionOpen = ref(false); const voiceTip = ref(localStorage.getItem('mento.voiceTipSeen') !== '1'); const voiceError = ref(''); const recording = ref(false); -const seconds = ref(0); -const pullY = ref(0); - -let recognition: SpeechRecognitionLike | null = null; -let timer: number | null = null; -let snapshot = ''; -let finals = ''; -let keepOnEnd = true; -let pendingSubmit = false; -let pulling = false; -let pullStart = 0; - -type SpeechRecognitionLike = { - lang: string; - continuous: boolean; - interimResults: boolean; - start: () => void; - stop: () => void; - abort: () => void; - onresult: ((ev: SpeechRecognitionEventLike) => void) | null; - onerror: ((ev: { error?: string }) => void) | null; - onend: (() => void) | null; -}; - -type SpeechRecognitionEventLike = { - resultIndex: number; - results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }>; -}; +const voiceBusy = ref(false); const subtitle = computed(() => { const a = props.activity; @@ -81,152 +55,29 @@ const dueLabel = computed(() => { return `${formatJalaliDay(d)} ${clock}`; }); -const timerLabel = computed(() => { - const m = String(Math.floor(seconds.value / 60)).padStart(2, '0'); - const s = String(seconds.value % 60).padStart(2, '0'); - return `${toFaDigits(m)}:${toFaDigits(s)}`; -}); +const canSubmit = computed( + () => comment.value.trim().length > 0 && !props.saving && !voiceBusy.value, +); -const canSubmit = computed(() => comment.value.trim().length > 0 && !props.saving); - -function speechCtor(): (new () => SpeechRecognitionLike) | null { - const w = window as Window & { - SpeechRecognition?: new () => SpeechRecognitionLike; - webkitSpeechRecognition?: new () => SpeechRecognitionLike; - }; - return w.SpeechRecognition || w.webkitSpeechRecognition || null; +function onVoiceTranscript(text: string) { + comment.value = appendSpokenText(comment.value, text); } -function applyTranscript(interim = '') { - const extra = `${finals}${interim}`.trim(); - comment.value = extra ? `${snapshot}${snapshot && !snapshot.endsWith(' ') ? ' ' : ''}${extra}` : snapshot; -} - -function startTimer() { - stopTimer(); - seconds.value = 0; - timer = window.setInterval(() => { - seconds.value += 1; - }, 1000); -} - -function stopTimer() { - if (timer != null) { - window.clearInterval(timer); - timer = null; - } -} - -function stopRecognition(keep: boolean) { - keepOnEnd = keep; - if (!recognition) { - finishRecording(keep); - return; - } - try { - if (keep) recognition.stop(); - else recognition.abort(); - } catch { - finishRecording(keep); - return; - } - window.setTimeout(() => { - if (recording.value) finishRecording(keepOnEnd); - }, 400); -} - -function finishRecording(keep: boolean) { - if (!recording.value && !recognition) return; - stopTimer(); - recording.value = false; - pullY.value = 0; - recognition = null; - if (!keep) { - comment.value = snapshot; - pendingSubmit = false; - } else { - applyTranscript(''); - } - finals = ''; - if (pendingSubmit) { - pendingSubmit = false; - submit(); - } -} - -function startRecording() { - voiceError.value = ''; - mentionOpen.value = false; - if (voiceTip.value) { +function onVoiceBusy(active: boolean) { + voiceBusy.value = active; + if (active && voiceTip.value) { voiceTip.value = false; localStorage.setItem('mento.voiceTipSeen', '1'); } - const Ctor = speechCtor(); - if (!Ctor) { - voiceError.value = 'تایپ صوتی در این مرورگر پشتیبانی نمی‌شود.'; - return; - } - snapshot = comment.value; - finals = ''; - keepOnEnd = true; - const rec = new Ctor(); - rec.lang = 'fa-IR'; - rec.continuous = true; - rec.interimResults = true; - rec.onresult = (ev) => { - let interim = ''; - for (let i = ev.resultIndex; i < ev.results.length; i += 1) { - const piece = ev.results[i]; - if (!piece) continue; - const text = piece[0]?.transcript ?? ''; - if (piece.isFinal) finals += `${text} `; - else interim += text; - } - applyTranscript(interim); - }; - rec.onerror = (ev) => { - if (ev.error === 'aborted' || ev.error === 'no-speech') return; - voiceError.value = 'ضبط صدا ناموفق بود. دوباره تلاش کنید.'; - finishRecording(true); - }; - rec.onend = () => { - finishRecording(keepOnEnd); - }; - recognition = rec; - recording.value = true; - startTimer(); - try { - rec.start(); - } catch { - voiceError.value = 'شروع ضبط صدا ممکن نشد.'; - finishRecording(false); - } -} - -function discardRecording() { - pendingSubmit = false; - stopRecognition(false); -} - -function keepRecording() { - pendingSubmit = false; - stopRecognition(true); -} - -function sendRecording() { - pendingSubmit = true; - stopRecognition(true); } function submit() { - if (recording.value) keepRecording(); const text = comment.value.trim(); - if (!text || props.saving) return; + if (!text || props.saving || voiceBusy.value) return; emit('submit', text); } function close() { - if (recording.value) discardRecording(); emit('close'); } @@ -256,37 +107,11 @@ function onKeydown(ev: KeyboardEvent) { mentionOpen.value = false; return; } - if (recording.value) { - discardRecording(); - return; - } + if (recording.value) return; close(); } } -function onPullStart(ev: PointerEvent) { - if (!recording.value) return; - const target = ev.target as HTMLElement | null; - if (target?.closest('button')) return; - pulling = true; - pullStart = ev.clientY; - pullY.value = 0; - (ev.currentTarget as HTMLElement | null)?.setPointerCapture?.(ev.pointerId); -} - -function onPullMove(ev: PointerEvent) { - if (!pulling) return; - pullY.value = Math.max(0, ev.clientY - pullStart); -} - -function onPullEnd() { - if (!pulling) return; - const shouldCancel = pullY.value > 72; - pulling = false; - pullY.value = 0; - if (shouldCancel) discardRecording(); -} - onMounted(async () => { document.body.style.overflow = 'hidden'; window.addEventListener('keydown', onKeydown); @@ -318,17 +143,6 @@ onMounted(async () => { onBeforeUnmount(() => { document.body.style.overflow = ''; window.removeEventListener('keydown', onKeydown); - pendingSubmit = false; - stopTimer(); - if (recognition) { - try { - recognition.abort(); - } catch { - /* ignore */ - } - recognition = null; - } - recording.value = false; }); @@ -409,15 +223,15 @@ onBeforeUnmount(() => {

تایپ صوتی را امتحان کنید! 🎙️✨

- +
@@ -429,41 +243,11 @@ onBeforeUnmount(() => { یادداشت شما فقط برای اعضای تیم و مدیران {{ orgName }} قابل مشاهده خواهد بود.

-
+
- -
- -
-
- در حال ضبط صدا... - {{ timerLabel }} -
-
- - - -
-

- برای لغو، بیرون بکشید - -

+
@@ -674,15 +458,10 @@ onBeforeUnmount(() => { cursor: pointer; } -.tool.mic { +.tool-wrap :deep(.vtt) { color: #0d9488; } -.tool.on, -.tool.mic:hover { - background: #ecfdf5; -} - .tip { position: absolute; inset-inline-end: 0; @@ -800,76 +579,6 @@ onBeforeUnmount(() => { cursor: not-allowed; } -.recorder { - border: 1.5px dashed #cbd5e1; - border-radius: 16px; - padding: 0.95rem 0.85rem 0.7rem; - text-align: center; - touch-action: none; -} - -.rec-top { - display: flex; - justify-content: space-between; - align-items: center; - color: #334155; - font-size: 0.92rem; -} - -.rec-top strong { - font-weight: 800; -} - -.rec-top span { - font-variant-numeric: tabular-nums; - color: #64748b; -} - -.rec-controls { - display: flex; - align-items: center; - justify-content: space-between; - margin: 1rem 1.4rem 0.55rem; -} - -.rec-side { - width: 42px; - height: 42px; - border: 1px solid #e2e8f0; - border-radius: 12px; - background: #fff; - color: #0f766e; - display: grid; - place-items: center; - cursor: pointer; -} - -.rec-stop { - width: 68px; - height: 68px; - border: 0; - border-radius: 999px; - background: #14b8a6; - color: #fff; - display: grid; - place-items: center; - box-shadow: 0 10px 24px rgba(20, 184, 166, 0.35); - cursor: pointer; -} - -.pull { - margin: 0.35rem 0 0; - display: inline-flex; - align-items: center; - gap: 0.2rem; - color: #94a3b8; - font-size: 0.78rem; -} - -.pull :deep(.ico) { - transform: rotate(90deg); -} - @media (min-width: 900px) { .layer { background: rgba(15, 23, 42, 0.28); diff --git a/apps/web/src/components/dashboard/HomeAiAssistant.vue b/apps/web/src/components/dashboard/HomeAiAssistant.vue index 037eed8..bdaf7d2 100644 --- a/apps/web/src/components/dashboard/HomeAiAssistant.vue +++ b/apps/web/src/components/dashboard/HomeAiAssistant.vue @@ -1,22 +1,39 @@ diff --git a/apps/web/src/lib/person-mood.ts b/apps/web/src/lib/person-mood.ts new file mode 100644 index 0000000..bad0b74 --- /dev/null +++ b/apps/web/src/lib/person-mood.ts @@ -0,0 +1,10 @@ +import type { PersonMood } from '@mento/shared'; + +export const PERSON_MOODS: PersonMood[] = ['newcomer', 'happy', 'unhappy', 'special']; + +export const PERSON_MOOD_LABELS: Record = { + newcomer: 'مشتری تازه وارد', + happy: 'مشتری خوشحال', + unhappy: 'مشتری ناراضی', + special: 'مشتری خاص', +}; diff --git a/apps/web/src/lib/spoken-text.ts b/apps/web/src/lib/spoken-text.ts new file mode 100644 index 0000000..d68c761 --- /dev/null +++ b/apps/web/src/lib/spoken-text.ts @@ -0,0 +1,8 @@ +export function appendSpokenText(current: string, spoken: string): string { + const next = spoken.replace(/\s+/g, ' ').trim(); + if (!next) return current; + const prev = current.replace(/\s+$/g, ''); + if (!prev) return next; + const glue = /[\s\n]$/.test(current) ? '' : ' '; + return `${prev}${glue}${next}`; +} diff --git a/apps/web/src/views/ActivityFormView.vue b/apps/web/src/views/ActivityFormView.vue index 501ca50..ffa1e5d 100644 --- a/apps/web/src/views/ActivityFormView.vue +++ b/apps/web/src/views/ActivityFormView.vue @@ -12,6 +12,8 @@ import { toFaDigits, } from '@/lib/jalali'; import JalaliDateInput from '@/components/JalaliDateInput.vue'; +import VoiceToTextButton from '@/components/VoiceToTextButton.vue'; +import { appendSpokenText } from '@/lib/spoken-text'; import UiIcon from '@/components/UiIcon.vue'; import UserAvatar from '@/components/UserAvatar.vue'; import type { @@ -483,7 +485,12 @@ watch([id, isNew], () => void load(), { immediate: true });
- +
+ + +