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 <cursoragent@cursor.com>
This commit is contained in:
2026-09-03 14:20:55 +03:30
co-authored by Cursor
parent 0dd2966f3c
commit 7757c0837d
43 changed files with 5156 additions and 1741 deletions
+4
View File
@@ -16,3 +16,7 @@ docs/~$*
.idea/
.vscode/
*.tsbuildinfo
**/__pycache__/
*.py[cod]
apps/stt/.venv/
crm-node_modules.tgz
@@ -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';
+8
View File
@@ -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?
+236
View File
@@ -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<AskMentoResponse> {
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<PersonAiSummaryDto> {
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 };
}
}
+80
View File
@@ -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<string>('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<string>('STT_TIMEOUT_MS') || 60000);
const headers: Record<string, string> = {};
const token = this.config.get<string>('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<string>('STT_URL') ?? '').trim().replace(/\/$/, '');
}
}
+58
View File
@@ -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);
}
}
+6 -2
View File
@@ -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 {}
+25 -14
View File
@@ -32,17 +32,6 @@ export class AiService {
noteType?: string;
personName?: string | null;
}): Promise<NoteAiExtract> {
if (!this.isEnabled()) {
throw new ServiceUnavailableException('هوش مصنوعی فعال نیست');
}
const model = this.config.get<string>('AI_MODEL')?.trim() || 'gpt-4o-mini';
const baseUrl =
this.config.get<string>('AI_BASE_URL')?.trim() ||
'https://api.openai.com/v1';
const apiKey = this.config.get<string>('AI_API_KEY')!.trim();
const timeoutMs = Number(this.config.get<string>('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<Partial<NoteAiExtract>>(
system,
userParts.join('\n'),
);
return this.normalizeExtract(raw);
}
async completeJson<T>(system: string, user: string): Promise<T> {
if (!this.isEnabled()) {
throw new ServiceUnavailableException('هوش مصنوعی فعال نیست');
}
const model = this.config.get<string>('AI_MODEL')?.trim() || 'gpt-4o-mini';
const baseUrl =
this.config.get<string>('AI_BASE_URL')?.trim() ||
'https://api.openai.com/v1';
const apiKey = this.config.get<string>('AI_API_KEY')!.trim();
const timeoutMs = Number(this.config.get<string>('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<NoteAiExtract>);
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);
}
+12
View File
@@ -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;
}
@@ -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<string, string> = {
شرکت: 'companyName',
notes: 'notes',
یادداشت: 'notes',
mood: 'mood',
وضعیت: 'mood',
وضعیتمشتری: 'mood',
'وضعیت مشتری': 'mood',
};
const COMPANY_ALIASES: Record<string, string> = {
@@ -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;
+18
View File
@@ -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()
@@ -37,6 +37,7 @@ export class PeopleController {
query.q,
query.page ?? 1,
query.limit ?? 20,
{ mood: query.mood, sort: query.sort },
);
}
+11 -1
View File
@@ -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<PaginatedResponse<PersonDto>> {
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,
+9
View File
@@ -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=
+33
View File
@@ -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` بگذارید.
+145
View File
@@ -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})
+5
View File
@@ -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
+161
View File
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
import type { AskMentoResponse, AskMentoSourceDto } from '@mento/shared';
const props = defineProps<{
personId?: string;
placeholder?: string;
}>();
const question = ref('');
const asking = ref(false);
const error = ref('');
const result = ref<AskMentoResponse | null>(null);
function sourceTo(source: AskMentoSourceDto) {
if (source.type === 'person') return { name: 'person-detail', params: { id: source.id } };
if (source.type === 'activity') return { name: 'activity-detail', params: { id: source.id } };
if (source.type === 'deal') return { name: 'deal-detail', params: { id: source.id } };
return { name: 'notes' };
}
async function submit() {
const q = question.value.trim();
if (!q || asking.value) return;
asking.value = true;
error.value = '';
result.value = null;
try {
const { data } = await api.post<AskMentoResponse>('/ai/ask', {
question: q,
personId: props.personId || undefined,
});
result.value = data;
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'پرسش ناموفق بود');
} else {
error.value = 'پرسش ناموفق بود';
}
} finally {
asking.value = false;
}
}
</script>
<template>
<section class="ask">
<form @submit.prevent="submit">
<UiIcon name="sparkle" :size="16" class="ico" />
<input
v-model="question"
type="search"
:placeholder="placeholder || 'از منتو بپرس...'"
/>
<button type="submit" :disabled="asking || question.trim().length < 2">
{{ asking ? '…' : 'بپرس' }}
</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<div v-else-if="result" class="answer">
<p>{{ result.answer }}</p>
<div v-if="result.sources.length" class="sources">
<RouterLink
v-for="source in result.sources"
:key="`${source.type}-${source.id}`"
:to="sourceTo(source)"
>
{{ source.label }}
</RouterLink>
</div>
</div>
</section>
</template>
<style scoped>
.ask {
display: grid;
gap: 0.55rem;
min-width: 0;
}
form {
display: flex;
align-items: center;
gap: 0.45rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 0.35rem 0.4rem 0.35rem 0.7rem;
}
.ico {
color: #0f766e;
flex-shrink: 0;
}
input {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
font: inherit;
padding: 0.45rem 0;
}
button {
border: 0;
border-radius: 10px;
background: #14b8a6;
color: #fff;
font: inherit;
font-weight: 700;
padding: 0.45rem 0.8rem;
cursor: pointer;
}
button:disabled {
opacity: 0.55;
cursor: wait;
}
.answer {
background: #f0fdfa;
border-radius: 12px;
padding: 0.7rem 0.85rem;
}
.answer p {
margin: 0;
color: #134e4a;
font-size: 0.88rem;
line-height: 1.7;
}
.sources {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.55rem;
}
.sources a {
font-size: 0.74rem;
font-weight: 700;
color: #0f766e;
background: #fff;
border-radius: 999px;
padding: 0.18rem 0.55rem;
}
.error {
margin: 0;
color: #b91c1c;
font-size: 0.84rem;
}
</style>
@@ -0,0 +1,221 @@
<script setup lang="ts">
import type { PersonMood } from '@mento/shared';
withDefaults(
defineProps<{
mood?: PersonMood | null;
size?: number;
}>(),
{ mood: 'newcomer', size: 48 },
);
</script>
<template>
<span
class="face"
:class="mood || 'newcomer'"
:style="{ width: `${size}px`, height: `${size}px` }"
aria-hidden="true"
>
<svg viewBox="0 0 64 64" fill="none">
<circle class="skin" cx="32" cy="34" r="22" />
<g class="shine">
<ellipse cx="24" cy="26" rx="6" ry="3.2" />
</g>
<g class="eyes">
<g class="eye left">
<ellipse class="open" cx="24" cy="33" rx="3.1" ry="3.6" />
<path class="happy" d="M21 33c1.4-2.2 4.6-2.2 6 0" />
<path class="sad" d="M21.4 34.6c1.2-2 4-2 5.2 0" />
</g>
<g class="eye right">
<ellipse class="open" cx="40" cy="33" rx="3.1" ry="3.6" />
<path class="happy" d="M37 33c1.4-2.2 4.6-2.2 6 0" />
<path class="sad" d="M37.4 34.6c1.2-2 4-2 5.2 0" />
</g>
</g>
<path class="mouth smile" d="M24 42c2.6 4.4 13.4 4.4 16 0" />
<path class="mouth grin" d="M22 40.5c3.4 7 16.6 7 20 0" />
<path class="mouth frown" d="M25 46c2.8-3.6 11.2-3.6 14 0" />
<g class="tears">
<ellipse class="tear t1" cx="18" cy="40" rx="2.1" ry="3.2" />
<ellipse class="tear t2" cx="46" cy="42" rx="2.1" ry="3.2" />
</g>
<g class="crown">
<path d="M16 18.5 22 26l10-9 10 9 6-7.5-2 12H18l-2-12Z" />
<rect x="18" y="26" width="28" height="3.4" rx="1" />
<circle class="gem" cx="32" cy="20" r="2.1" />
<circle class="gem side" cx="22.5" cy="23" r="1.5" />
<circle class="gem side" cx="41.5" cy="23" r="1.5" />
</g>
</svg>
</span>
</template>
<style scoped>
.face {
display: inline-grid;
place-items: center;
flex-shrink: 0;
border-radius: 50%;
background: #fff7d6;
box-shadow: inset 0 -3px 0 rgba(180, 120, 20, 0.08);
overflow: visible;
}
svg {
width: 100%;
height: 100%;
overflow: visible;
}
.skin {
fill: #f5c542;
}
.shine ellipse {
fill: rgba(255, 255, 255, 0.35);
}
.eye .open {
fill: #3b2415;
}
.eye .happy,
.eye .sad,
.mouth,
.tears,
.crown {
display: none;
}
.eye .happy,
.eye .sad,
.mouth {
stroke: #3b2415;
stroke-width: 2.4;
stroke-linecap: round;
fill: none;
}
.face.newcomer .mouth.smile {
display: block;
}
.face.happy {
animation: bounce 1.6s ease-in-out infinite;
}
.face.happy .eye .open {
display: none;
}
.face.happy .eye .happy,
.face.happy .mouth.grin {
display: block;
}
.face.unhappy .eye .open {
display: none;
}
.face.unhappy .eye .sad,
.face.unhappy .mouth.frown,
.face.unhappy .tears {
display: block;
}
.tear {
fill: #5bb7ff;
}
.t1 {
animation: drop 1.4s ease-in infinite;
}
.t2 {
animation: drop 1.4s ease-in 0.35s infinite;
}
.face.special .mouth.smile,
.face.special .crown {
display: block;
}
.face.special .crown {
transform-origin: 32px 22px;
animation: bob 1.8s ease-in-out infinite;
}
.crown path,
.crown rect {
fill: #f0b429;
stroke: #c48414;
stroke-width: 1;
}
.gem {
fill: #ef4444;
}
.gem.side {
fill: #22c55e;
}
.face.newcomer .eye .open,
.face.special .eye .open {
transform-box: fill-box;
transform-origin: center;
animation: blink 3.4s ease-in-out infinite;
}
@keyframes blink {
0%,
42%,
48%,
100% {
transform: scaleY(1);
}
45% {
transform: scaleY(0.12);
}
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-2px);
}
}
@keyframes drop {
0% {
opacity: 0;
transform: translateY(-4px);
}
25% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(10px);
}
}
@keyframes bob {
0%,
100% {
transform: translateY(0) rotate(-4deg);
}
50% {
transform: translateY(-2px) rotate(4deg);
}
}
</style>
+16 -3
View File
@@ -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"
/>
<button class="btn" type="button" :disabled="saving || !noteDraft.trim()" @click="addNote">
ثبت یادداشت
</button>
<div class="compose-actions">
<VoiceToTextButton @transcript="(text) => (noteDraft = appendSpokenText(noteDraft, text))" />
<button class="btn" type="button" :disabled="saving || !noteDraft.trim()" @click="addNote">
ثبت یادداشت
</button>
</div>
</div>
<div class="composer">
<input
@@ -289,6 +294,14 @@ onMounted(load);
background: #fff;
}
.compose-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
}
.list {
list-style: none;
margin: 0;
@@ -235,12 +235,15 @@ function formatPayloadSummary(value: NoteAiPayloadDto | null) {
background: rgba(255, 255, 255, 0.65);
display: grid;
gap: 0.6rem;
min-width: 0;
overflow-wrap: anywhere;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
@@ -282,6 +285,8 @@ textarea {
padding: 0.55rem 0.7rem;
font: inherit;
width: 100%;
min-width: 0;
max-width: 100%;
}
.actions {
@@ -290,6 +295,10 @@ textarea {
flex-wrap: wrap;
}
.actions .btn {
min-width: 0;
}
.summary {
margin: 0;
color: var(--muted);
+437
View File
@@ -0,0 +1,437 @@
<script setup lang="ts">
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<FabPos>({ 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 = '';
});
</script>
<template>
<Teleport to="body">
<div v-if="!hidden" class="qc" dir="rtl">
<button
v-if="isQuickCreateOpen"
class="overlay"
type="button"
aria-label="بستن ایجاد سریع"
@click="close"
/>
<section
v-if="isQuickCreateOpen"
class="panel"
aria-labelledby="qc-title"
:style="panelStyle"
>
<h2 id="qc-title">ایجاد سریع</h2>
<button
v-for="item in items"
:key="item.key"
class="item"
type="button"
@click="go(item.to)"
>
<span class="ico" :class="item.tone">
<UiIcon :name="item.icon" :size="26" />
</span>
<span class="copy">
<strong>{{ item.title }}</strong>
<small>{{ item.desc }}</small>
</span>
</button>
</section>
<button
v-if="ready"
class="fab"
type="button"
:class="{ dragging }"
:style="fabStyle"
:aria-expanded="isQuickCreateOpen"
:aria-label="isQuickCreateOpen ? 'بستن ایجاد سریع' : 'ایجاد سریع'"
@pointerdown="onFabPointerDown"
@pointermove="onFabPointerMove"
@pointerup="onFabPointerUp"
@pointercancel="onFabPointerUp"
>
<UiIcon :name="isQuickCreateOpen ? 'x' : 'plus'" :size="22" />
</button>
</div>
</Teleport>
</template>
<style scoped>
.qc {
direction: rtl;
}
.overlay {
position: fixed;
inset: 0;
z-index: 90;
border: 0;
padding: 0;
background: rgba(0, 0, 0, 0.68);
cursor: pointer;
}
.panel {
position: fixed;
z-index: 92;
width: 390px;
box-sizing: border-box;
background: #fff;
border-radius: 28px;
padding: 20px;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
}
.panel h2 {
margin: 0 0 16px;
text-align: right;
font-size: 22px;
font-weight: 800;
color: #111827;
}
.item {
width: 100%;
height: 82px;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 14px;
padding: 0 14px;
border: 1px solid #eef1f2;
border-radius: 16px;
background: #fff;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04);
font: inherit;
text-align: right;
cursor: pointer;
}
.item:last-child {
margin-bottom: 0;
}
.ico {
width: 52px;
height: 52px;
border-radius: 14px;
display: grid;
place-items: center;
color: #fff;
flex-shrink: 0;
}
.ico.person {
background: #0d9488;
}
.ico.company {
background: #22d3ee;
}
.ico.deal {
background: #7c3aed;
}
.ico.activity {
background: #f97316;
}
.ico.note {
background: #2563eb;
}
.copy {
display: grid;
gap: 4px;
min-width: 0;
text-align: right;
}
.copy strong {
font-size: 16px;
font-weight: 600;
color: #111827;
}
.copy small {
font-size: 13px;
color: #94a3b8;
line-height: 1.5;
}
.fab {
position: fixed;
z-index: 93;
width: 56px;
height: 56px;
padding: 0;
margin: 0;
border: 0;
border-radius: 50%;
background: #14b8a6;
color: #fff;
display: grid;
place-items: center;
line-height: 0;
direction: ltr;
box-shadow: 0 6px 16px rgba(13, 148, 136, 0.28);
cursor: grab;
touch-action: none;
user-select: none;
}
.fab :deep(.ico) {
display: block;
position: absolute;
top: 50%;
left: 50%;
width: 36px;
height: 36px;
margin: 0;
transform: translate(-50%, -50%);
stroke-width: 1.75;
}
.fab.dragging {
cursor: grabbing;
box-shadow: 0 8px 18px rgba(13, 148, 136, 0.34);
}
</style>
+24 -1
View File
@@ -44,7 +44,11 @@ defineProps<{
| 'check-plain'
| 'calendar-sun'
| 'calendar-edit'
| 'minus';
| 'minus'
| 'dollar'
| 'layers'
| 'target'
| 'pencil';
size?: number;
}>();
</script>
@@ -249,9 +253,28 @@ defineProps<{
<template v-else-if="name === 'minus'">
<path d="M6 12h12" />
</template>
<template v-else-if="name === 'dollar'">
<circle cx="12" cy="12" r="8.4" />
<path d="M12 6.8v10.4" />
<path d="M9.4 9.1c.7-1.1 1.8-1.6 2.6-1.6 1.3 0 2.3.7 2.3 1.9 0 2.6-5 1.5-5 4.1 0 1.3 1.1 2.2 2.7 2.2 1 0 2-.5 2.6-1.4" />
</template>
<template v-else-if="name === 'check-plain'">
<path d="m5.5 12.2 4.2 4.3 8.8-9" />
</template>
<template v-else-if="name === 'layers'">
<path d="M12 4 4 8.2 12 12.4 20 8.2 12 4Z" />
<path d="M4 12.2 12 16.4 20 12.2" />
<path d="M4 16.2 12 20.4 20 16.2" />
</template>
<template v-else-if="name === 'target'">
<circle cx="12" cy="12" r="8" />
<circle cx="12" cy="12" r="4.2" />
<circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'pencil'">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</template>
</svg>
</template>
@@ -0,0 +1,334 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
const props = withDefaults(
defineProps<{
disabled?: boolean;
variant?: 'default' | 'toolbar';
hideError?: boolean;
}>(),
{ variant: 'default', hideError: false },
);
const emit = defineEmits<{
transcript: [text: string];
recording: [active: boolean];
busy: [active: boolean];
error: [message: string];
}>();
const recording = ref(false);
const transcribing = ref(false);
const error = ref('');
const seconds = ref(0);
let stream: MediaStream | null = null;
let ctx: AudioContext | null = null;
let source: MediaStreamAudioSourceNode | null = null;
let processor: ScriptProcessorNode | null = null;
let mute: GainNode | null = null;
let chunks: Float32Array[] = [];
let sampleRate = 16000;
let timer: number | null = null;
let limitTimer: number | null = null;
const MAX_SECONDS = 90;
const busy = computed(() => recording.value || transcribing.value);
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}:${s}`);
});
const ariaLabel = computed(() => {
if (transcribing.value) return 'در حال تبدیل صوت به متن';
if (recording.value) return 'توقف ضبط و تبدیل به متن';
return 'تبدیل صوت به متن';
});
function setError(message: string) {
error.value = message;
emit('error', message);
}
function setRecording(active: boolean) {
recording.value = active;
emit('recording', active);
emit('busy', busy.value);
}
function setTranscribing(active: boolean) {
transcribing.value = active;
emit('busy', recording.value || active);
}
function stopTimer() {
if (timer != null) {
window.clearInterval(timer);
timer = null;
}
if (limitTimer != null) {
window.clearTimeout(limitTimer);
limitTimer = null;
}
}
function teardownMic() {
stopTimer();
try {
processor?.disconnect();
source?.disconnect();
mute?.disconnect();
} catch {
/* ignore */
}
processor = null;
source = null;
mute = null;
stream?.getTracks().forEach((track) => track.stop());
stream = null;
if (ctx) {
void ctx.close().catch(() => undefined);
ctx = null;
}
}
function mergeChunks(parts: Float32Array[]) {
const length = parts.reduce((sum, part) => sum + part.length, 0);
const out = new Float32Array(length);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
function encodeWav(samples: Float32Array, rate: number) {
const bytes = samples.length * 2;
const buffer = new ArrayBuffer(44 + bytes);
const view = new DataView(buffer);
const write = (offset: number, text: string) => {
for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
};
write(0, 'RIFF');
view.setUint32(4, 36 + bytes, true);
write(8, 'WAVE');
write(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, rate, true);
view.setUint32(28, rate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
write(36, 'data');
view.setUint32(40, bytes, true);
let offset = 44;
for (let i = 0; i < samples.length; i += 1) {
const sample = Math.max(-1, Math.min(1, samples[i] ?? 0));
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
offset += 2;
}
return new Blob([buffer], { type: 'audio/wav' });
}
async function upload(blob: Blob) {
const form = new FormData();
form.append('audio', blob, 'dictation.wav');
const { data } = await api.post<{ text: string }>('/ai/transcribe', form);
return (data.text ?? '').trim();
}
async function finish(keep: boolean) {
const parts = chunks;
chunks = [];
const rate = sampleRate;
teardownMic();
setRecording(false);
if (!keep) return;
const samples = mergeChunks(parts);
if (samples.length < rate * 0.35) {
setError('صدای کافی ضبط نشد. کمی بیشتر صحبت کنید.');
return;
}
setTranscribing(true);
try {
const text = await upload(encodeWav(samples, rate));
if (!text) {
setError('متنی از صدا تشخیص داده نشد.');
return;
}
emit('transcript', text);
} catch (err) {
if (axios.isAxiosError(err)) {
const msg = (err.response?.data as { message?: string | string[] })?.message;
setError(Array.isArray(msg) ? msg.join('، ') : (msg ?? 'تبدیل صوت به متن ناموفق بود'));
} else {
setError('تبدیل صوت به متن ناموفق بود');
}
} finally {
setTranscribing(false);
}
}
async function start() {
setError('');
if (!navigator.mediaDevices?.getUserMedia) {
setError('این مرورگر به میکروفون دسترسی نمی‌دهد.');
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 },
});
const Ctor = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) {
setError('ضبط صدا در این مرورگر پشتیبانی نمی‌شود.');
stream.getTracks().forEach((track) => track.stop());
stream = null;
return;
}
ctx = new Ctor();
await ctx.resume();
sampleRate = ctx.sampleRate || 16000;
source = ctx.createMediaStreamSource(stream);
processor = ctx.createScriptProcessor(4096, 1, 1);
mute = ctx.createGain();
mute.gain.value = 0;
chunks = [];
processor.onaudioprocess = (event) => {
if (!recording.value) return;
chunks.push(new Float32Array(event.inputBuffer.getChannelData(0)));
};
source.connect(processor);
processor.connect(mute);
mute.connect(ctx.destination);
seconds.value = 0;
timer = window.setInterval(() => {
seconds.value += 1;
}, 1000);
limitTimer = window.setTimeout(() => {
void finish(true);
}, MAX_SECONDS * 1000);
setRecording(true);
} catch {
teardownMic();
setError('دسترسی به میکروفون داده نشد.');
}
}
function toggle() {
if (props.disabled || transcribing.value) return;
if (recording.value) {
void finish(true);
return;
}
void start();
}
onBeforeUnmount(() => {
void finish(false);
});
</script>
<template>
<div class="vtt-wrap" :class="variant">
<button
class="vtt"
type="button"
:class="{ on: recording, busy: transcribing }"
:disabled="disabled || transcribing"
:aria-label="ariaLabel"
:title="ariaLabel"
@click="toggle"
>
<UiIcon :name="recording ? 'stop' : 'mic'" :size="variant === 'toolbar' ? 18 : 16" />
<span v-if="variant === 'default' && recording" class="timer">{{ timerLabel }}</span>
<span v-else-if="variant === 'default' && transcribing" class="timer">تبدیل…</span>
</button>
<p v-if="error && !hideError" class="err">{{ error }}</p>
</div>
</template>
<style scoped>
.vtt-wrap {
display: grid;
gap: 0.3rem;
justify-items: start;
}
.vtt {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid #d7e3dc;
background: #f4faf7;
color: #0f766e;
border-radius: 999px;
min-height: 36px;
padding: 0 0.75rem;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
}
.vtt:hover:not(:disabled) {
background: #ecfdf5;
}
.vtt.on {
border-color: #f87171;
background: #fef2f2;
color: #b91c1c;
animation: pulse 1.2s ease-in-out infinite;
}
.vtt.busy:not(.on) {
opacity: 0.72;
cursor: wait;
}
.vtt:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.toolbar .vtt {
width: 38px;
height: 38px;
min-height: 38px;
padding: 0;
border-radius: 12px;
border-color: transparent;
background: #f8fafc;
justify-content: center;
}
.toolbar .vtt.on {
background: #fef2f2;
}
.timer {
font-variant-numeric: tabular-nums;
}
.err {
margin: 0;
color: #b91c1c;
font-size: 0.78rem;
}
@keyframes pulse {
50% {
box-shadow: 0 0 0 5px rgba(248, 113, 113, 0.16);
}
}
</style>
@@ -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;
});
</script>
@@ -409,15 +223,15 @@ onBeforeUnmount(() => {
</div>
<div class="tool-wrap">
<p v-if="voiceTip && !recording" class="tip">تایپ صوتی را امتحان کنید! 🎙️✨</p>
<button
class="tool mic"
type="button"
aria-label="تایپ صوتی"
:class="{ on: recording }"
@click="recording ? keepRecording() : startRecording()"
>
<UiIcon name="mic" :size="18" />
</button>
<VoiceToTextButton
variant="toolbar"
hide-error
:disabled="saving"
@transcript="onVoiceTranscript"
@recording="recording = $event"
@busy="onVoiceBusy"
@error="voiceError = $event"
/>
</div>
</div>
</div>
@@ -429,41 +243,11 @@ onBeforeUnmount(() => {
یادداشت شما فقط برای اعضای تیم و مدیران {{ orgName }} قابل مشاهده خواهد بود.
</p>
<div v-if="!recording" class="actions">
<div class="actions">
<button class="act primary" type="button" :disabled="!canSubmit" @click="submit">
ثبت نظر
{{ recording ? 'در حال ضبط…' : 'ثبت نظر' }}
</button>
<button class="act ghost" type="button" :disabled="saving" @click="close">انصراف</button>
</div>
<div
v-else
class="recorder"
:style="{ transform: pullY ? `translateY(${pullY}px)` : undefined }"
@pointerdown="onPullStart"
@pointermove="onPullMove"
@pointerup="onPullEnd"
@pointercancel="onPullEnd"
>
<div class="rec-top">
<strong>در حال ضبط صدا...</strong>
<span>{{ timerLabel }}</span>
</div>
<div class="rec-controls">
<button class="rec-side" type="button" aria-label="ارسال نظر" @click="sendRecording">
<UiIcon name="send" :size="18" />
</button>
<button class="rec-stop" type="button" aria-label="توقف ضبط" @click="keepRecording">
<UiIcon name="stop" :size="22" />
</button>
<button class="rec-side" type="button" aria-label="حذف ضبط" @click="discardRecording">
<UiIcon name="trash" :size="18" />
</button>
</div>
<p class="pull">
برای لغو، بیرون بکشید
<UiIcon name="chevron" :size="14" />
</p>
<button class="act ghost" type="button" :disabled="saving || voiceBusy" @click="close">انصراف</button>
</div>
</div>
</section>
@@ -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);
@@ -1,22 +1,39 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
import type { AskMentoSourceDto } from '@mento/shared';
defineProps<{
askQuery: string;
asking?: boolean;
answer?: string;
error?: string;
sources?: AskMentoSourceDto[];
}>();
const emit = defineEmits<{
'update:askQuery': [value: string];
ask: [];
ask: [preset?: string];
}>();
const shortcuts = [
{ label: 'خلاصه مشتری شرکت آریا را بده', to: '/contacts', icon: 'users' as const },
{ label: 'متن پیگیری برای مشتری بفرست', to: '/notes', icon: 'send' as const },
{ label: 'گزارش فروش این هفته را نشان بده', to: '/reports', icon: 'chart' as const },
{ label: 'معاملات در خطر را لیست کن', to: '/pipeline', icon: 'warning' as const },
{ label: 'معاملات باز و در خطر را بگو', icon: 'warning' as const },
{ label: 'فعالیت‌های عقب‌افتاده چیست؟', icon: 'calendar' as const },
{ label: 'مهم‌ترین پیگیری امروز چیست؟', icon: 'send' as const },
{ label: 'خلاصه وضعیت فروش را بده', icon: 'chart' as const },
];
function sourceTo(source: AskMentoSourceDto) {
if (source.type === 'person') return { name: 'person-detail', params: { id: source.id } };
if (source.type === 'activity') return { name: 'activity-detail', params: { id: source.id } };
if (source.type === 'deal') return { name: 'deal-detail', params: { id: source.id } };
return { name: 'notes' };
}
function pick(label: string) {
emit('update:askQuery', label);
emit('ask', label);
}
</script>
<template>
@@ -31,10 +48,31 @@ const shortcuts = [
<p class="ai-lead">چه کمکی می‌تونم بکنم؟</p>
<div class="ai-shortcuts">
<RouterLink v-for="item in shortcuts" :key="item.label" class="ai-chip" :to="item.to">
<button
v-for="item in shortcuts"
:key="item.label"
class="ai-chip"
type="button"
:disabled="asking"
@click="pick(item.label)"
>
<span class="chip-ico"><UiIcon :name="item.icon" :size="14" /></span>
<span>{{ item.label }}</span>
</RouterLink>
</button>
</div>
<p v-if="error" class="ai-error">{{ error }}</p>
<div v-else-if="answer" class="ai-answer">
<p>{{ answer }}</p>
<div v-if="sources?.length" class="sources">
<RouterLink
v-for="source in sources"
:key="`${source.type}-${source.id}`"
:to="sourceTo(source)"
>
{{ source.label }}
</RouterLink>
</div>
</div>
<form class="ai-input" @submit.prevent="emit('ask')">
@@ -42,9 +80,10 @@ const shortcuts = [
:value="askQuery"
type="search"
placeholder="از منتو بپرس..."
:disabled="asking"
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
/>
<button type="submit" aria-label="ارسال">
<button type="submit" aria-label="ارسال" :disabled="asking">
<UiIcon name="send" :size="15" />
</button>
</form>
@@ -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;
}
</style>
+2
View File
@@ -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(() => {
<RouterView />
</main>
</div>
<QuickCreate />
</div>
</template>
+10
View File
@@ -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<PersonMood, string> = {
newcomer: 'مشتری تازه وارد',
happy: 'مشتری خوشحال',
unhappy: 'مشتری ناراضی',
special: 'مشتری خاص',
};
+8
View File
@@ -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}`;
}
+15 -1
View File
@@ -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 });
</div>
<div v-show="tab === 'notes'" class="card note-card">
<label for="description">توضیحات و یادداشت</label>
<div class="note-head">
<label for="description">توضیحات و یادداشت</label>
<VoiceToTextButton
@transcript="(text) => (description = appendSpokenText(description, text))"
/>
</div>
<textarea
id="description"
v-model="description"
@@ -912,6 +919,13 @@ watch([id, isNew], () => void load(), { immediate: true });
gap: 0.45rem;
}
.note-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.note-card textarea {
min-height: 12rem;
resize: vertical;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+37 -7
View File
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
@@ -13,21 +12,27 @@ import HomeAiAssistant from '@/components/dashboard/HomeAiAssistant.vue';
import HomeRecentActivities from '@/components/dashboard/HomeRecentActivities.vue';
import HomeMiniPipeline from '@/components/dashboard/HomeMiniPipeline.vue';
import HomeTopCustomers from '@/components/dashboard/HomeTopCustomers.vue';
import axios from 'axios';
import type {
ActivityDto,
AskMentoResponse,
AskMentoSourceDto,
HomeDashboardDto,
PaginatedResponse,
PipelineBoardDto,
} from '@mento/shared';
const auth = useAuthStore();
const router = useRouter();
const loading = ref(true);
const home = ref<HomeDashboardDto | null>(null);
const activities = ref<ActivityDto[]>([]);
const board = ref<PipelineBoardDto | null>(null);
const askQuery = ref('');
const asking = ref(false);
const askAnswer = ref('');
const askError = ref('');
const askSources = ref<AskMentoSourceDto[]>([]);
const firstName = computed(() => {
const parts = auth.user?.fullName?.trim().split(/\s+/) ?? [];
@@ -72,10 +77,28 @@ function formatTrendAmount(value: number) {
return formatMoney(value, home.value?.currency ?? 'IRR');
}
function submitAsk() {
const q = askQuery.value.trim();
if (!q) return;
void router.push({ name: 'notes', query: { q } });
async function submitAsk(preset?: string) {
const q = (preset || askQuery.value).trim();
if (!q || asking.value) return;
if (preset) askQuery.value = preset;
asking.value = true;
askError.value = '';
askAnswer.value = '';
askSources.value = [];
try {
const { data } = await api.post<AskMentoResponse>('/ai/ask', { question: q });
askAnswer.value = data.answer;
askSources.value = data.sources ?? [];
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
askError.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'پرسش ناموفق بود');
} else {
askError.value = 'پرسش ناموفق بود';
}
} finally {
asking.value = false;
}
}
onMounted(async () => {
@@ -128,7 +151,14 @@ onMounted(async () => {
:format-amount="formatTrendAmount"
/>
<HomeSourcesChart :sources="home?.sources ?? []" />
<HomeAiAssistant v-model:ask-query="askQuery" @ask="submitAsk" />
<HomeAiAssistant
v-model:ask-query="askQuery"
:asking="asking"
:answer="askAnswer"
:error="askError"
:sources="askSources"
@ask="submitAsk"
/>
</div>
<div class="bottom-grid">
+88 -3
View File
@@ -4,7 +4,10 @@ import { useRoute, useRouter } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import NoteAiSuggestionPanel from '@/components/NoteAiSuggestionPanel.vue';
import AskMentoPanel from '@/components/AskMentoPanel.vue';
import JalaliDateInput from '@/components/JalaliDateInput.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import { appendSpokenText } from '@/lib/spoken-text';
import type {
DealDto,
NoteAttachmentDto,
@@ -290,6 +293,11 @@ onMounted(load);
</form>
</header>
<AskMentoPanel
:person-id="draftPersonId || undefined"
placeholder="از منتو بپرس؛ مثلاً آخرین بار درباره قیمت چی گفت؟"
/>
<div class="type-tabs">
<button
type="button"
@@ -332,8 +340,10 @@ onMounted(load);
/>
<div class="composer-actions">
<div class="left-actions">
<VoiceToTextButton @transcript="(text) => (draftBody = appendSpokenText(draftBody, text))" />
<button class="linkish" type="button" @click="showExtras = !showExtras">
{{ showExtras ? 'بستن جزئیات' : 'شخص / معامله / یادآوری / برچسب' }}
<span class="extras-full">{{ showExtras ? 'بستن جزئیات' : 'شخص / معامله / یادآوری / برچسب' }}</span>
<span class="extras-short">{{ showExtras ? 'بستن جزئیات' : 'جزئیات بیشتر' }}</span>
</button>
<button class="linkish" type="button" @click="fileInput?.click()">
پیوست فایل
@@ -537,6 +547,13 @@ onMounted(load);
.page {
display: grid;
gap: 1.2rem;
min-width: 0;
max-width: 100%;
padding-bottom: 6rem;
}
.page > * {
min-width: 0;
}
.page-head {
@@ -563,6 +580,7 @@ h1 {
.meta,
.empty {
color: var(--muted);
overflow-wrap: anywhere;
}
.search {
@@ -570,6 +588,7 @@ h1 {
gap: 0.5rem;
flex: 1 1 220px;
min-width: 0;
max-width: 100%;
}
.search input {
@@ -581,6 +600,7 @@ h1 {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
min-width: 0;
max-width: 100%;
}
@@ -650,6 +670,7 @@ h1 {
.search input,
.composer input,
.composer textarea,
.composer select,
.extras input,
.extras select {
border: 1px solid var(--line);
@@ -658,6 +679,8 @@ h1 {
background: #fff;
font: inherit;
width: 100%;
min-width: 0;
max-width: 100%;
}
.composer {
@@ -668,6 +691,7 @@ h1 {
padding: 1rem;
display: grid;
gap: 0.7rem;
min-width: 0;
}
.title-input {
@@ -739,6 +763,9 @@ h1 {
text-align: start;
text-decoration: underline;
text-underline-offset: 2px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.att-size {
@@ -758,6 +785,11 @@ h1 {
color: var(--accent);
cursor: pointer;
font: inherit;
text-align: start;
}
.extras-short {
display: none;
}
.extras {
@@ -766,6 +798,17 @@ h1 {
gap: 0.75rem;
}
.field {
display: grid;
gap: 0.35rem;
min-width: 0;
}
.field label {
color: var(--muted);
font-size: 0.85rem;
}
.block h2 {
margin: 0 0 0.75rem;
font-size: 1rem;
@@ -774,7 +817,7 @@ h1 {
.cards {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(min(100%, 240px), 1fr));
gap: 0.85rem;
}
@@ -785,6 +828,8 @@ h1 {
display: grid;
gap: 0.55rem;
background: #fff;
min-width: 0;
overflow-wrap: anywhere;
}
.card header {
@@ -816,6 +861,7 @@ h1 {
.body {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
line-height: 1.6;
}
@@ -872,7 +918,7 @@ h1 {
background: var(--surface);
}
@media (max-width: 700px) {
@media (max-width: 900px) {
h1 {
font-size: 1.45rem;
}
@@ -882,6 +928,10 @@ h1 {
align-items: stretch;
}
.lead {
max-width: none;
}
.search {
width: 100%;
flex: 0 0 auto;
@@ -896,20 +946,55 @@ h1 {
.type-tabs {
flex-wrap: nowrap;
overflow-x: auto;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
padding-bottom: 0.15rem;
scrollbar-width: none;
}
.type-tabs::-webkit-scrollbar {
display: none;
}
.extras {
grid-template-columns: 1fr;
}
.extras-full {
display: none;
}
.extras-short {
display: inline;
}
.composer {
padding: 0.9rem;
}
.composer-actions,
.left-actions,
.right {
flex-direction: column;
align-items: stretch;
}
.right .btn,
.right .btn-ghost {
width: 100%;
}
.cards {
grid-template-columns: 1fr;
}
.card footer {
gap: 0.5rem 0.7rem;
}
.swatches {
margin-inline-start: 0;
width: 100%;
}
}
</style>
+56 -2
View File
@@ -8,8 +8,10 @@ import axios from 'axios';
import { api } from '@/lib/api';
import type { CompanyDto, CreatePersonRequest, PersonDto } from '@mento/shared';
import type { CompanyDto, CreatePersonRequest, PersonDto, PersonMood } from '@mento/shared';
import { PERSON_MOOD_LABELS, PERSON_MOODS } from '@/lib/person-mood';
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
import CustomerMoodFace from '@/components/CustomerMoodFace.vue';
import EntityTimeline from '@/components/EntityTimeline.vue';
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
@@ -50,7 +52,7 @@ const position = ref('');
const notes = ref('');
const companyId = ref('');
const mood = ref<PersonMood>('newcomer');
const customFields = ref<Record<string, unknown>>({});
const loyaltyPoints = ref(0);
const loyaltyTierName = ref('');
@@ -74,6 +76,7 @@ function fill(p: PersonDto) {
notes.value = p.notes ?? '';
companyId.value = p.company?.id ?? '';
mood.value = p.mood || 'newcomer';
customFields.value = { ...(p.customFields ?? {}) };
loyaltyPoints.value = p.loyaltyPoints ?? 0;
loyaltyTierName.value = p.loyaltyTier?.name ?? '';
@@ -139,6 +142,7 @@ function payload(): CreatePersonRequest {
notes: notes.value || undefined,
companyId: companyId.value || null,
mood: mood.value,
customFields: customFields.value,
};
@@ -304,6 +308,26 @@ onMounted(load);
</div>
<div class="field wide">
<label>وضعیت مشتری</label>
<div class="moods">
<button
v-for="item in PERSON_MOODS"
:key="item"
class="mood"
type="button"
:class="{ on: mood === item }"
@click="mood = item"
>
<CustomerMoodFace :mood="item" :size="44" />
<span>{{ PERSON_MOOD_LABELS[item] }}</span>
</button>
</div>
</div>
<div class="field wide">
<label for="companyId">شرکت</label>
@@ -517,7 +541,37 @@ textarea {
}
.moods {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.55rem;
}
.mood {
display: flex;
align-items: center;
gap: 0.55rem;
min-height: 56px;
padding: 0.4rem 0.65rem;
border: 1px solid #e2e8f0;
border-radius: 14px;
background: #fff;
font: inherit;
text-align: start;
cursor: pointer;
}
.mood.on {
border-color: #14b8a6;
background: #ecfdf5;
box-shadow: 0 0 0 3px #ccfbf1;
}
@media (min-width: 700px) {
.moods {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (max-width: 700px) {
File diff suppressed because it is too large Load Diff
+19
View File
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue';
import { RouterLink, useRoute, useRouter } from 'vue-router';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import type { AskMentoResponse } from '@mento/shared';
import { formatCompactMoney } from '@/lib/money';
import {
calendarDaysBetween,
@@ -15,6 +16,7 @@ import { purchaseProbability } from '@/lib/person-insights';
import { USER_ROLE_LABELS } from '@/lib/roles';
import UiIcon from '@/components/UiIcon.vue';
import MentoAiAvatar from '@/components/brand/MentoAiAvatar.vue';
import AskMentoPanel from '@/components/AskMentoPanel.vue';
import ProfileEmptyState from '@/components/person-profile/ProfileEmptyState.vue';
import ProfileMoreMenu from '@/components/person-profile/ProfileMoreMenu.vue';
import type { ProfileMoreItem } from '@/components/person-profile/ProfileMoreMenu.vue';
@@ -62,6 +64,7 @@ const cards = ref<ProjectCardDto[]>([]);
const moreOpen = ref(false);
const fabOpen = ref(false);
const tab = ref<TabKey>('summary');
const aiBrief = ref('');
const moreItems: ProfileMoreItem[] = [
{ key: 'info', label: 'ویرایش اطلاعات', to: { name: 'profile', query: { tab: 'info' } } },
@@ -126,6 +129,7 @@ const probability = computed(() =>
);
const smartText = computed(() => {
if (aiBrief.value) return aiBrief.value;
const overdue = activities.value.filter(
(a) => a.status === 'open' && a.dueAt && new Date(a.dueAt).getTime() < Date.now(),
).length;
@@ -281,6 +285,7 @@ function onSmartCta() {
async function load() {
loading.value = true;
error.value = '';
aiBrief.value = '';
try {
const [actRes, boardRes] = await Promise.all([
api.get<PaginatedResponse<ActivityDto>>('/activities', {
@@ -296,6 +301,7 @@ async function load() {
} catch {
cards.value = [];
}
void loadAiBrief();
} catch {
error.value = 'بارگذاری پروفایل ناموفق بود.';
} finally {
@@ -303,6 +309,18 @@ async function load() {
}
}
async function loadAiBrief() {
try {
const { data } = await api.post<AskMentoResponse>('/ai/ask', {
question:
'وضعیت کاری امروز من را خلاصه کن: فعالیت‌های عقب‌افتاده، معاملات باز و مهم‌ترین پیگیری بعدی.',
});
aiBrief.value = data.answer;
} catch {
aiBrief.value = '';
}
}
watch(() => route.query.tab, readTab, { immediate: true });
watch(() => auth.user?.id, () => void load(), { immediate: true });
</script>
@@ -364,6 +382,7 @@ watch(() => auth.user?.id, () => void load(), { immediate: true });
<p>{{ smartText }}</p>
</div>
</header>
<AskMentoPanel placeholder="از منتو درباره کار امروز بپرس..." />
<button class="smart-cta" type="button" @click="onSmartCta">
<span>{{ smartCta }}</span>
<UiIcon name="chevron" :size="16" />
@@ -124,6 +124,8 @@ onMounted(load);
display: grid;
gap: 1rem;
max-width: 640px;
min-width: 0;
width: 100%;
}
.back {
color: var(--muted);
@@ -145,6 +147,9 @@ label {
}
input,
textarea {
width: 100%;
min-width: 0;
max-width: 100%;
border: 1px solid var(--line);
border-radius: 12px;
padding: 0.7rem 0.85rem;
@@ -153,8 +158,24 @@ textarea {
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
@media (max-width: 900px) {
h1 {
font-size: 1.35rem;
}
.actions {
flex-direction: column;
align-items: stretch;
}
.actions .btn {
width: 100%;
}
}
.error {
color: #b42318;
}
+6
View File
@@ -33,3 +33,9 @@ AI_BASE_URL=https://api.gapgpt.app/v1
AI_API_KEY=change-me
AI_MODEL=gpt-4o-mini
AI_TIMEOUT_MS=45000
# Speech-to-text — Pipecat sidecar on 127.0.0.1:3040 (not public)
STT_ENABLED=true
STT_URL=http://127.0.0.1:3040
STT_INTERNAL_TOKEN=change-me-stt-internal-token
STT_TIMEOUT_MS=60000
+28 -7
View File
@@ -8,7 +8,9 @@ PM2: `mento-crm-api` (cwd: `apps/api`، اسکریپت: `dist/src/main.js`)
کد از Gitea: `http://get.feep.ir/Mahdi/CRM_Montzar.git`
پورت‌های اشغال‌شده روی همین سرور (دست نزنید):
۳۰۰۰ مونتایم · ۳۰۰۱ فوری‌سند · ۳۰۱۰ سوپرادمین · ۳۰۲۰ کاتالوگ
۳۰۰۰ مونتایم · ۳۰۰۱ فوری‌سند · ۳۰۱۰ سوپرادمین · ۳۰۲۰ کاتالوگ
پورت داخلی STT (فقط localhost، عمومی نکنید): **۳۰۴۰** — PM2 `mento-stt`
---
@@ -116,7 +118,22 @@ mkdir -p uploads
# اختیاری یک‌بار: npm run db:seed
```
### ۶) PM2 — فقط پروسهٔ جدید
### ۶) تبدیل صوت به متن (Pipecat)
فقط STT. روی سرور Python 3.11+ لازم است.
```bash
cd /var/www/crm/apps/stt
python3.11 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env
nano .env
```
`OPENAI_API_KEY` و `OPENAI_BASE_URL` را مثل `AI_*` در `apps/api/.env` بگذارید.
`STT_INTERNAL_TOKEN` باید با مقدار همین کلید در `apps/api/.env` یکی باشد.
### ۷) PM2 — فقط پروسه‌های جدید
پروسه‌های موجود را stop نکنید.
@@ -125,11 +142,12 @@ pm2 start /var/www/crm/deploy/ecosystem.config.cjs
pm2 save
pm2 list
curl -sI http://127.0.0.1:3030/api/health
curl -s http://127.0.0.1:3040/health
```
باید `200` بیاید. خطا: `pm2 logs mento-crm-api --lines 80`
باید `200` بیاید. خطا: `pm2 logs mento-crm-api --lines 80` یا `pm2 logs mento-stt --lines 80`
### ۷) Nginx
### ۸) Nginx
```bash
sudo cp /var/www/crm/deploy/nginx-crm.montzar.ir.conf /etc/nginx/sites-available/crm.montzar.ir
@@ -140,7 +158,7 @@ sudo nginx -t && sudo systemctl reload nginx
**HTTPS روی همین باکس:** مثل `ktg.montzar.ir`، فرانت‌پروکسی (`91.247.174.1`) SSL را تمام می‌کند و به این سرور **HTTP :80** می‌زند.
اگر Certbot روی این vhost `return 404` یا فقط HTTPS بگذارد، سایت از بیرون قطع می‌شود — vhost را روی الگوی HTTP نگه دارید (فایل `deploy/nginx-crm.montzar.ir.conf`).
### ۸) تست
### ۹) تست
1. `https://crm.montzar.ir` → ورود پنل
2. دمو: `owner@demo.local` / `demo1234` (اگر seed زده باشید)
@@ -165,6 +183,9 @@ cd apps/api && npx prisma migrate deploy && npx prisma generate && cd ../..
npm run build -w @mento/api
npm run build -w @mento/web
pm2 restart mento-crm-api
# اگر requirements.txt عوض شد:
# cd apps/stt && .venv/bin/pip install -r requirements.txt && cd ../..
pm2 restart mento-stt
```
پاک نکنید: `uploads/` و `apps/api/.env`
@@ -212,7 +233,7 @@ grep -c AutomationAction /var/www/crm/node_modules/@mento/shared/dist/index.d.ts
| این پروژه | بقیه — دست نزنید |
|---|---|
| دامنه `crm.montzar.ir` | `ktg` / `adm` / مونتایم |
| پورت ۳۰۳۰ | ۳۰۰۰ / ۳۰۰۱ / ۳۰۱۰ / ۳۰۲۰ |
| PM2 نام `mento-crm-api` | پروسه‌های موجود |
| پورت ۳۰۳۰ و ۳۰۴۰ (STT فقط localhost) | ۳۰۰۰ / ۳۰۰۱ / ۳۰۱۰ / ۳۰۲۰ |
| PM2 نام `mento-crm-api` و `mento-stt` | پروسه‌های موجود |
| `/var/www/crm` | `/var/www/ktg` و … |
| دیتابیس `mento_crm` روی `:15432` | Postgres مونتایم |
+11
View File
@@ -21,5 +21,16 @@ module.exports = {
max_memory_restart: "600M",
time: true,
},
{
name: "mento-stt",
cwd: "/var/www/crm/apps/stt",
script: ".venv/bin/python",
args: "-m uvicorn main:app --host 127.0.0.1 --port 3040",
interpreter: "none",
instances: 1,
exec_mode: "fork",
max_memory_restart: "500M",
time: true,
},
],
};
+1
View File
@@ -11,6 +11,7 @@
"dev": "npm run dev --workspaces --if-present",
"dev:web": "npm run dev -w @mento/web",
"dev:api": "npm run start:dev -w @mento/api",
"dev:stt": "apps/stt/.venv/Scripts/python.exe -m uvicorn main:app --host 127.0.0.1 --port 3040 --app-dir apps/stt",
"build": "npm run build --workspaces --if-present",
"build:shared": "npm run build -w @mento/shared",
"lint": "npm run lint --workspaces --if-present",
+36
View File
@@ -169,6 +169,16 @@ export interface LoyaltyTierSummaryDto {
color: string | null;
}
export const PERSON_MOODS = ["newcomer", "happy", "unhappy", "special"] as const;
export type PersonMood = (typeof PERSON_MOODS)[number];
export const PERSON_MOOD_LABELS: Record<PersonMood, string> = {
newcomer: "مشتری تازه وارد",
happy: "مشتری خوشحال",
unhappy: "مشتری ناراضی",
special: "مشتری خاص",
};
export interface PersonDto {
id: string;
firstName: string;
@@ -179,6 +189,7 @@ export interface PersonDto {
mobile: string | null;
position: string | null;
notes: string | null;
mood: PersonMood;
customFields: Record<string, unknown>;
loyaltyPoints: number;
loyaltyTier: LoyaltyTierSummaryDto | null;
@@ -196,6 +207,7 @@ export interface CreatePersonRequest {
mobile?: string;
position?: string;
notes?: string;
mood?: PersonMood;
companyId?: string | null;
customFields?: Record<string, unknown>;
ownerId?: string | null;
@@ -523,6 +535,30 @@ export interface ApplyNoteAiSuggestionRequest {
memoryItems?: Array<{ index: number; content: string }>;
}
export type AskMentoSourceType = 'person' | 'note' | 'activity' | 'deal' | 'memory';
export interface AskMentoSourceDto {
type: AskMentoSourceType;
id: string;
label: string;
}
export interface AskMentoRequest {
question: string;
personId?: string;
}
export interface AskMentoResponse {
answer: string;
sources: AskMentoSourceDto[];
}
export interface PersonAiSummaryDto {
text: string;
cta: string;
ctaKind: 'call' | 'activity';
}
export interface MemoryItemDto {
id: string;
personId: string;