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 @@
+
+
+
+
+
+ {{ error }}
+
+
{{ result.answer }}
+
+
+ {{ source.label }}
+
+
+
+
+
+
+
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"
/>
-
+
+ (noteDraft = appendSpokenText(noteDraft, text))" />
+
+
+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 @@
+
+
+
+
+
+
{{ error }}
+
+
+
+
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 @@
@@ -31,10 +48,31 @@ const shortcuts = [
چه کمکی میتونم بکنم؟
-
+
+
+
+
+ {{ error }}
+
+
{{ answer }}
+
+
+ {{ source.label }}
+
+
@@ -110,15 +149,22 @@ h2 {
border: 1px solid #eef2f7;
color: #334155;
font-size: 0.76rem;
- text-decoration: none;
+ text-align: start;
+ font: inherit;
+ cursor: pointer;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
}
-.ai-chip:hover {
+.ai-chip:hover:not(:disabled) {
background: #f0fdfa;
border-color: #99f6e4;
}
+.ai-chip:disabled {
+ opacity: 0.6;
+ cursor: wait;
+}
+
.chip-ico {
width: 26px;
height: 26px;
@@ -155,4 +201,44 @@ h2 {
display: grid;
place-items: center;
}
+
+.ai-input button:disabled {
+ opacity: 0.55;
+}
+
+.ai-answer {
+ background: #f0fdfa;
+ border-radius: 12px;
+ padding: 0.7rem 0.8rem;
+ margin-bottom: 0.75rem;
+}
+
+.ai-answer p {
+ margin: 0;
+ color: #134e4a;
+ font-size: 0.82rem;
+ line-height: 1.7;
+}
+
+.sources {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.3rem;
+ margin-top: 0.5rem;
+}
+
+.sources a {
+ font-size: 0.72rem;
+ font-weight: 700;
+ color: #0f766e;
+ background: #fff;
+ border-radius: 999px;
+ padding: 0.15rem 0.5rem;
+}
+
+.ai-error {
+ margin: 0 0 0.7rem;
+ color: #b91c1c;
+ font-size: 0.8rem;
+}
diff --git a/apps/web/src/layouts/AppLayout.vue b/apps/web/src/layouts/AppLayout.vue
index c9dc98c..b2874bc 100644
--- a/apps/web/src/layouts/AppLayout.vue
+++ b/apps/web/src/layouts/AppLayout.vue
@@ -6,6 +6,7 @@ import { api } from '@/lib/api';
import { formatJalaliShort, toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
import MentoLogo from '@/components/brand/MentoLogo.vue';
+import QuickCreate from '@/components/QuickCreate.vue';
import type {
ActivitySummaryDto,
NotificationDto,
@@ -455,6 +456,7 @@ onUnmounted(() => {
+
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 });
-
+
+
+ (description = appendSpokenText(description, text))"
+ />
+
+
+
+
diff --git a/apps/web/src/views/DealFormView.vue b/apps/web/src/views/DealFormView.vue
index 16dccc9..4b2b616 100644
--- a/apps/web/src/views/DealFormView.vue
+++ b/apps/web/src/views/DealFormView.vue
@@ -1,20 +1,26 @@
-
-
-
-
معامله
-
{{ isNew ? 'معامله جدید' : 'ویرایش معامله' }}
-
وضعیت: {{ status === 'won' ? 'برنده' : status === 'lost' ? 'از دسترفته' : 'باز' }}
+