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:
@@ -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';
|
||||
@@ -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?
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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(/\/$/, '');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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=
|
||||
@@ -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` بگذارید.
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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: 'مشتری خاص',
|
||||
};
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+657
-302
File diff suppressed because it is too large
Load Diff
+1089
-213
File diff suppressed because it is too large
Load Diff
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 مونتایم |
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user