Creating a project workflow using a voice assistant and converting it into an approval card.
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user