Creating a project workflow using a voice assistant and converting it into an approval card.
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import type {
|
||||
ActivityType,
|
||||
AskMentoResponse,
|
||||
AskMentoSourceDto,
|
||||
PersonAiSummaryDto,
|
||||
VoiceActivityDraft,
|
||||
VoiceCommandDraft,
|
||||
VoicePersonDraft,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
@@ -674,4 +678,155 @@ Rules:
|
||||
|
||||
return { context: lines.join('\n').slice(0, 14000), catalog };
|
||||
}
|
||||
|
||||
async parseVoiceCommand(actor: Actor, text: string): Promise<VoiceCommandDraft> {
|
||||
const now = new Date();
|
||||
const today = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Tehran',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(now);
|
||||
|
||||
const raw = await this.ai.completeJson<{
|
||||
intent?: string;
|
||||
summary?: string;
|
||||
person?: Partial<VoicePersonDraft> | null;
|
||||
activity?: Partial<VoiceActivityDraft> | null;
|
||||
}>(
|
||||
`You convert one Persian voice command into a CRM action draft.
|
||||
Return ONLY JSON:
|
||||
{
|
||||
"intent": "create_person" | "create_activity" | "none",
|
||||
"summary": string,
|
||||
"person": { "firstName": string, "lastName": string, "mobile": string, "phone": string, "email": string, "website": string, "referrer": string, "position": string, "notes": string } | null,
|
||||
"activity": { "title": string, "type": "call"|"meeting"|"task"|"note", "description": string, "dueAt": string|null, "personName": string } | null
|
||||
}
|
||||
Rules:
|
||||
- create_person only when the user asks to add/create a customer or contact.
|
||||
- create_activity only when the user asks to add a follow-up, call, meeting, or task.
|
||||
- Otherwise intent is none. Do not invent a record.
|
||||
- Do not invent phone numbers, emails, or names that were not spoken.
|
||||
- Mobile/phone: digits only, Iranian mobile as 09xxxxxxxxx.
|
||||
- dueAt: ISO 8601 in Asia/Tehran if a time is mentioned, else null. Today is ${today}.
|
||||
- summary: one short Persian sentence describing what will be saved.
|
||||
- Unused string fields are empty strings.`,
|
||||
text.trim(),
|
||||
);
|
||||
|
||||
const intent =
|
||||
raw.intent === 'create_person' || raw.intent === 'create_activity'
|
||||
? raw.intent
|
||||
: 'none';
|
||||
|
||||
if (intent === 'create_person') {
|
||||
const person = cleanPerson(raw.person);
|
||||
const missing: string[] = [];
|
||||
if (!person.firstName) missing.push('نام');
|
||||
return {
|
||||
intent,
|
||||
summary: (raw.summary ?? '').trim() || `ساخت مشتری ${person.firstName} ${person.lastName}`.trim(),
|
||||
missing,
|
||||
canConfirm: missing.length === 0,
|
||||
person,
|
||||
activity: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (intent === 'create_activity') {
|
||||
const activity = cleanActivity(raw.activity);
|
||||
if (activity.personName) {
|
||||
activity.personId = await this.matchPersonId(actor, activity.personName);
|
||||
}
|
||||
const missing: string[] = [];
|
||||
if (!activity.title) missing.push('عنوان فعالیت');
|
||||
return {
|
||||
intent,
|
||||
summary: (raw.summary ?? '').trim() || `ثبت فعالیت «${activity.title}»`,
|
||||
missing,
|
||||
canConfirm: missing.length === 0,
|
||||
person: null,
|
||||
activity,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
intent: 'none',
|
||||
summary:
|
||||
(raw.summary ?? '').trim() ||
|
||||
'در این مرحله فقط ساخت مشتری یا ثبت فعالیت از روی صوت انجام میشود.',
|
||||
missing: [],
|
||||
canConfirm: false,
|
||||
person: null,
|
||||
activity: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async matchPersonId(actor: Actor, name: string): Promise<string | null> {
|
||||
const q = name.trim();
|
||||
if (q.length < 2) return null;
|
||||
const rows = await this.prisma.person.findMany({
|
||||
where: {
|
||||
organizationId: actor.organizationId,
|
||||
...ownerWhere(actor),
|
||||
OR: [
|
||||
{ firstName: { contains: q, mode: 'insensitive' } },
|
||||
{ lastName: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
take: 5,
|
||||
});
|
||||
const exact = rows.filter((row) => `${row.firstName} ${row.lastName}`.trim() === q);
|
||||
if (exact.length === 1) return exact[0]!.id;
|
||||
if (rows.length === 1) return rows[0]!.id;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanText(value: unknown, max = 200) {
|
||||
return String(value ?? '').replace(/\s+/g, ' ').trim().slice(0, max);
|
||||
}
|
||||
|
||||
function digitsOnly(value: unknown) {
|
||||
const fa = '۰۱۲۳۴۵۶۷۸۹';
|
||||
const ar = '٠١٢٣٤٥٦٧٨٩';
|
||||
const raw = String(value ?? '')
|
||||
.replace(/[۰-۹]/g, (ch) => String(fa.indexOf(ch)))
|
||||
.replace(/[٠-٩]/g, (ch) => String(ar.indexOf(ch)));
|
||||
let digits = raw.replace(/[^\d+]/g, '');
|
||||
if (digits.startsWith('+98')) digits = `0${digits.slice(3)}`;
|
||||
else if (digits.startsWith('98') && digits.length === 12) digits = `0${digits.slice(2)}`;
|
||||
return digits.slice(0, 20);
|
||||
}
|
||||
|
||||
function cleanPerson(input: Partial<VoicePersonDraft> | null | undefined): VoicePersonDraft {
|
||||
return {
|
||||
firstName: cleanText(input?.firstName, 80),
|
||||
lastName: cleanText(input?.lastName, 80),
|
||||
mobile: digitsOnly(input?.mobile),
|
||||
phone: digitsOnly(input?.phone),
|
||||
email: cleanText(input?.email, 120),
|
||||
website: cleanText(input?.website, 300),
|
||||
referrer: cleanText(input?.referrer, 120),
|
||||
position: cleanText(input?.position, 120),
|
||||
notes: cleanText(input?.notes, 500),
|
||||
};
|
||||
}
|
||||
|
||||
const ACTIVITY_TYPES = new Set<ActivityType>(['call', 'meeting', 'task', 'note']);
|
||||
|
||||
function cleanActivity(input: Partial<VoiceActivityDraft> | null | undefined): VoiceActivityDraft {
|
||||
const type = ACTIVITY_TYPES.has(input?.type as ActivityType)
|
||||
? (input?.type as ActivityType)
|
||||
: 'task';
|
||||
const due = cleanText(input?.dueAt, 40);
|
||||
return {
|
||||
title: cleanText(input?.title, 160),
|
||||
type,
|
||||
description: cleanText(input?.description, 500),
|
||||
dueAt: due && !Number.isNaN(Date.parse(due)) ? new Date(due).toISOString() : null,
|
||||
personName: cleanText(input?.personName, 120),
|
||||
personId: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { AskMentoDto, VoiceCommandDto } from './dto';
|
||||
import { AiService } from './ai.service';
|
||||
|
||||
@Controller('ai')
|
||||
@@ -19,6 +19,11 @@ export class AiController {
|
||||
return { enabled: this.ai.isEnabled() };
|
||||
}
|
||||
|
||||
@Post('voice-command')
|
||||
voiceCommand(@CurrentUser() user: Actor, @Body() dto: VoiceCommandDto) {
|
||||
return this.ask.parseVoiceCommand(user, dto.text);
|
||||
}
|
||||
|
||||
@Post('ask')
|
||||
askMento(@CurrentUser() user: Actor, @Body() dto: AskMentoDto) {
|
||||
return this.ask.ask(user, dto.question, dto.personId);
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class VoiceCommandDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(800)
|
||||
text!: string;
|
||||
}
|
||||
|
||||
export class AskMentoDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import UiIcon from '@/components/UiIcon.vue';
|
||||
import { brand, type AskMentoSourceDto } from '@mento/shared';
|
||||
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
|
||||
import { api } from '@/lib/api';
|
||||
import { brand, type ActivityType, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared';
|
||||
|
||||
defineProps<{
|
||||
const props = defineProps<{
|
||||
askQuery: string;
|
||||
asking?: boolean;
|
||||
answer?: string;
|
||||
@@ -34,6 +38,126 @@ function pick(label: string) {
|
||||
emit('update:askQuery', label);
|
||||
emit('ask', label);
|
||||
}
|
||||
|
||||
const ACTIVITY_LABEL: Record<ActivityType, string> = {
|
||||
call: 'تماس',
|
||||
meeting: 'جلسه',
|
||||
task: 'کار',
|
||||
note: 'یادداشت',
|
||||
};
|
||||
|
||||
const voiceBuf = ref('');
|
||||
const voiceBusy = ref(false);
|
||||
const voiceError = ref('');
|
||||
const voiceDone = ref('');
|
||||
const draft = ref<VoiceCommandDraft | null>(null);
|
||||
const confirming = ref(false);
|
||||
|
||||
function apiMessage(e: unknown, fallback: string) {
|
||||
if (!axios.isAxiosError(e)) return fallback;
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
return Array.isArray(msg) ? msg.join('، ') : (msg ?? fallback);
|
||||
}
|
||||
|
||||
function onVoiceTranscript(text: string) {
|
||||
voiceBuf.value = `${voiceBuf.value} ${text}`.trim();
|
||||
}
|
||||
|
||||
function looksLikeAction(text: string) {
|
||||
return /بساز|بسازید|ثبت کن|ثبت کنید|اضافه کن|ایجاد کن|یک مشتری|مشتری جدید|یک فعالیت|فعالیت جدید/.test(text);
|
||||
}
|
||||
|
||||
async function parseCommand(text: string, fallbackToAsk: boolean) {
|
||||
voiceBusy.value = true;
|
||||
voiceError.value = '';
|
||||
voiceDone.value = '';
|
||||
draft.value = null;
|
||||
try {
|
||||
const { data } = await api.post<VoiceCommandDraft>('/ai/voice-command', { text });
|
||||
if (data.intent === 'none') {
|
||||
if (fallbackToAsk) {
|
||||
emit('ask', text);
|
||||
return;
|
||||
}
|
||||
voiceError.value = data.summary;
|
||||
return;
|
||||
}
|
||||
draft.value = data;
|
||||
} catch (e) {
|
||||
voiceError.value = apiMessage(e, 'فهمیدن دستور ناموفق بود.');
|
||||
} finally {
|
||||
voiceBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onVoiceRecording(active: boolean) {
|
||||
if (active) {
|
||||
voiceBuf.value = '';
|
||||
voiceError.value = '';
|
||||
voiceDone.value = '';
|
||||
draft.value = null;
|
||||
return;
|
||||
}
|
||||
const text = voiceBuf.value.trim();
|
||||
voiceBuf.value = '';
|
||||
if (!text) return;
|
||||
emit('update:askQuery', text);
|
||||
await parseCommand(text, false);
|
||||
}
|
||||
|
||||
async function onTypedSubmit() {
|
||||
const text = props.askQuery.trim();
|
||||
if (!text || props.asking || voiceBusy.value || confirming.value) return;
|
||||
if (looksLikeAction(text)) {
|
||||
await parseCommand(text, true);
|
||||
return;
|
||||
}
|
||||
emit('ask');
|
||||
}
|
||||
|
||||
async function confirmDraft() {
|
||||
const current = draft.value;
|
||||
if (!current?.canConfirm || confirming.value) return;
|
||||
confirming.value = true;
|
||||
voiceError.value = '';
|
||||
voiceDone.value = '';
|
||||
try {
|
||||
if (current.intent === 'create_person' && current.person) {
|
||||
const p = current.person;
|
||||
const { data } = await api.post<PersonDto>('/people', {
|
||||
firstName: p.firstName,
|
||||
lastName: p.lastName || undefined,
|
||||
mobile: p.mobile || undefined,
|
||||
phone: p.phone || undefined,
|
||||
email: p.email || undefined,
|
||||
website: p.website || undefined,
|
||||
referrer: p.referrer || undefined,
|
||||
position: p.position || undefined,
|
||||
notes: p.notes || undefined,
|
||||
});
|
||||
voiceDone.value = `مشتری «${data.fullName}» ساخته شد.`;
|
||||
} else if (current.intent === 'create_activity' && current.activity) {
|
||||
const a = current.activity;
|
||||
await api.post('/activities', {
|
||||
title: a.title,
|
||||
type: a.type,
|
||||
description: a.description || undefined,
|
||||
dueAt: a.dueAt,
|
||||
personId: a.personId,
|
||||
});
|
||||
voiceDone.value = `فعالیت «${a.title}» ثبت شد.`;
|
||||
}
|
||||
draft.value = null;
|
||||
} catch (e) {
|
||||
voiceError.value = apiMessage(e, 'انجام دستور ناموفق بود.');
|
||||
} finally {
|
||||
confirming.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelDraft() {
|
||||
draft.value = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -43,6 +167,14 @@ function pick(label: string) {
|
||||
<span class="spark"><UiIcon name="sparkle" :size="15" /></span>
|
||||
{{ brand.aiNameFa }}
|
||||
</h2>
|
||||
<VoiceToTextButton
|
||||
variant="toolbar"
|
||||
hide-error
|
||||
:disabled="asking || voiceBusy || confirming"
|
||||
@transcript="onVoiceTranscript"
|
||||
@recording="onVoiceRecording"
|
||||
@error="voiceError = $event"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<p class="ai-lead">چه کمکی میتونم بکنم؟</p>
|
||||
@@ -61,8 +193,37 @@ function pick(label: string) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="voiceBusy" class="ai-answer"><p>در حال فهمیدن دستور…</p></div>
|
||||
<div v-else-if="draft && draft.intent !== 'none'" class="confirm">
|
||||
<p>{{ draft.summary }}</p>
|
||||
<ul v-if="draft.person">
|
||||
<li>نام: {{ draft.person.firstName }} {{ draft.person.lastName }}</li>
|
||||
<li v-if="draft.person.mobile">موبایل: {{ draft.person.mobile }}</li>
|
||||
<li v-if="draft.person.phone">تلفن: {{ draft.person.phone }}</li>
|
||||
<li v-if="draft.person.email">ایمیل: {{ draft.person.email }}</li>
|
||||
<li v-if="draft.person.website">وبسایت: {{ draft.person.website }}</li>
|
||||
<li v-if="draft.person.referrer">معرف: {{ draft.person.referrer }}</li>
|
||||
</ul>
|
||||
<ul v-else-if="draft.activity">
|
||||
<li>عنوان: {{ draft.activity.title }}</li>
|
||||
<li>نوع: {{ ACTIVITY_LABEL[draft.activity.type] }}</li>
|
||||
<li v-if="draft.activity.personName">
|
||||
مشتری: {{ draft.activity.personName }}
|
||||
<span v-if="!draft.activity.personId"> (لینک نشد)</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-if="draft.missing.length" class="ai-error">ناقص است: {{ draft.missing.join('، ') }}</p>
|
||||
<div class="confirm-actions">
|
||||
<button type="button" class="ok" :disabled="!draft.canConfirm || confirming" @click="confirmDraft">
|
||||
{{ confirming ? 'در حال ثبت…' : 'تأیید و انجام' }}
|
||||
</button>
|
||||
<button type="button" class="no" :disabled="confirming" @click="cancelDraft">انصراف</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="voiceDone" class="ai-answer"><p>{{ voiceDone }}</p></div>
|
||||
<p v-if="voiceError" class="ai-error">{{ voiceError }}</p>
|
||||
<p v-if="error" class="ai-error">{{ error }}</p>
|
||||
<div v-else-if="asking" class="ai-answer">
|
||||
<div v-if="asking" class="ai-answer">
|
||||
<p>در حال پاسخ…</p>
|
||||
</div>
|
||||
<div v-else-if="answer" class="ai-answer">
|
||||
@@ -78,15 +239,15 @@ function pick(label: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="ai-input" @submit.prevent="emit('ask')">
|
||||
<form class="ai-input" @submit.prevent="onTypedSubmit">
|
||||
<input
|
||||
:value="askQuery"
|
||||
type="search"
|
||||
:placeholder="brand.askPlaceholderFa"
|
||||
:disabled="asking"
|
||||
:disabled="asking || voiceBusy || confirming"
|
||||
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
<button type="submit" aria-label="ارسال" :disabled="asking">
|
||||
<button type="submit" aria-label="ارسال" :disabled="asking || voiceBusy || confirming">
|
||||
<UiIcon name="send" :size="15" />
|
||||
</button>
|
||||
</form>
|
||||
@@ -107,6 +268,63 @@ function pick(label: string) {
|
||||
|
||||
.panel-head {
|
||||
margin-bottom: 0.55rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.confirm {
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.7rem 0.8rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.confirm p,
|
||||
.confirm li {
|
||||
margin: 0;
|
||||
color: #78350f;
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.confirm ul {
|
||||
margin: 0.35rem 0 0;
|
||||
padding: 0 1rem 0 0;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: 0.4rem;
|
||||
margin-top: 0.55rem;
|
||||
}
|
||||
|
||||
.confirm-actions button {
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.4rem 0.7rem;
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.confirm-actions .ok {
|
||||
background: #0f766e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.confirm-actions .ok:disabled,
|
||||
.confirm-actions .no:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.confirm-actions .no {
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
h2 {
|
||||
|
||||
@@ -1038,6 +1038,38 @@ export interface AskMentoResponse {
|
||||
sources: AskMentoSourceDto[];
|
||||
}
|
||||
|
||||
export type VoiceCommandIntent = 'create_person' | 'create_activity' | 'none';
|
||||
|
||||
export interface VoicePersonDraft {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
mobile: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
website: string;
|
||||
referrer: string;
|
||||
position: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface VoiceActivityDraft {
|
||||
title: string;
|
||||
type: ActivityType;
|
||||
description: string;
|
||||
dueAt: string | null;
|
||||
personName: string;
|
||||
personId: string | null;
|
||||
}
|
||||
|
||||
export interface VoiceCommandDraft {
|
||||
intent: VoiceCommandIntent;
|
||||
summary: string;
|
||||
missing: string[];
|
||||
canConfirm: boolean;
|
||||
person: VoicePersonDraft | null;
|
||||
activity: VoiceActivityDraft | null;
|
||||
}
|
||||
|
||||
export interface PersonAiSummaryDto {
|
||||
text: string;
|
||||
cta: string;
|
||||
|
||||
Reference in New Issue
Block a user