Creating a project workflow using a voice assistant and converting it into an approval card.

This commit is contained in:
2026-09-23 15:49:44 +03:30
parent 99df327ded
commit 7dc4344e0e
5 changed files with 424 additions and 7 deletions
@@ -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 {