Files
CRM_Montzar/apps/web/src/components/dashboard/HomeAiAssistant.vue
T

466 lines
12 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import UiIcon from '@/components/UiIcon.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import { api } from '@/lib/api';
import { brand, type ActivityType, type AskMentoSourceDto, type PersonDto, type VoiceCommandDraft } from '@mento/shared';
const props = defineProps<{
askQuery: string;
asking?: boolean;
answer?: string;
error?: string;
sources?: AskMentoSourceDto[];
}>();
const emit = defineEmits<{
'update:askQuery': [value: string];
ask: [preset?: string];
}>();
const shortcuts = [
{ 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);
}
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>
<article id="home-ai-assistant" class="panel">
<header class="panel-head">
<h2>
<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>
<div class="ai-shortcuts">
<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>
</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-if="asking" class="ai-answer">
<p>در حال پاسخ…</p>
</div>
<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="onTypedSubmit">
<input
:value="askQuery"
type="search"
:placeholder="brand.askPlaceholderFa"
:disabled="asking || voiceBusy || confirming"
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
/>
<button type="submit" aria-label="ارسال" :disabled="asking || voiceBusy || confirming">
<UiIcon name="send" :size="15" />
</button>
</form>
</article>
</template>
<style scoped>
.panel {
background: #fff;
border-radius: var(--radius);
padding: 1rem 1rem 0.95rem;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04);
border: 1px solid #eef2f7;
min-width: 0;
display: flex;
flex-direction: column;
}
.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 {
margin: 0;
font-size: 0.92rem;
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: #0f172a;
}
.spark {
color: #eab308;
display: grid;
}
.ai-lead {
margin: 0 0 0.75rem;
padding: 0.55rem 0.75rem;
width: fit-content;
max-width: 100%;
background: #d1fae5;
color: #115e59;
font-size: 0.8rem;
font-weight: 600;
border-radius: 14px 14px 4px 14px;
}
.ai-shortcuts {
display: grid;
gap: 0.4rem;
margin-bottom: 0.8rem;
flex: 1;
}
.ai-chip {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.58rem 0.7rem;
border-radius: var(--radius);
background: #fff;
border: 1px solid #eef2f7;
color: #334155;
font-size: 0.76rem;
text-align: start;
font: inherit;
cursor: pointer;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
}
.ai-chip:hover:not(:disabled) {
background: #f0fdfa;
border-color: #99f6e4;
}
.ai-chip:disabled {
opacity: 0.6;
cursor: wait;
}
.chip-ico {
width: 26px;
height: 26px;
border-radius: var(--radius);
background: #ecfdf8;
color: #14b8a6;
display: grid;
place-items: center;
flex-shrink: 0;
}
.ai-input {
display: flex;
gap: 0.4rem;
margin-top: auto;
}
.ai-input input {
flex: 1;
border: 1px solid #e2e8f0;
border-radius: var(--radius);
padding: 0.58rem 0.75rem;
background: #f8fafc;
font-size: 0.8rem;
}
.ai-input button {
width: 40px;
border: 0;
border-radius: var(--radius);
background: #14b8a6;
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
}
.ai-input button:disabled {
opacity: 0.55;
}
.ai-answer {
background: #f0fdfa;
border-radius: var(--radius);
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>