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:
2026-09-03 14:20:55 +03:30
co-authored by Cursor
parent 0dd2966f3c
commit 7757c0837d
43 changed files with 5156 additions and 1741 deletions
+15 -1
View File
@@ -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;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+37 -7
View File
@@ -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">
+88 -3
View File
@@ -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>
+56 -2
View File
@@ -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
+19
View File
@@ -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;
}