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
+161
View File
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { ref } from 'vue';
import { RouterLink } from 'vue-router';
import axios from 'axios';
import { api } from '@/lib/api';
import UiIcon from '@/components/UiIcon.vue';
import type { AskMentoResponse, AskMentoSourceDto } from '@mento/shared';
const props = defineProps<{
personId?: string;
placeholder?: string;
}>();
const question = ref('');
const asking = ref(false);
const error = ref('');
const result = ref<AskMentoResponse | null>(null);
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' };
}
async function submit() {
const q = question.value.trim();
if (!q || asking.value) return;
asking.value = true;
error.value = '';
result.value = null;
try {
const { data } = await api.post<AskMentoResponse>('/ai/ask', {
question: q,
personId: props.personId || undefined,
});
result.value = data;
} catch (e) {
if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message;
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'پرسش ناموفق بود');
} else {
error.value = 'پرسش ناموفق بود';
}
} finally {
asking.value = false;
}
}
</script>
<template>
<section class="ask">
<form @submit.prevent="submit">
<UiIcon name="sparkle" :size="16" class="ico" />
<input
v-model="question"
type="search"
:placeholder="placeholder || 'از منتو بپرس...'"
/>
<button type="submit" :disabled="asking || question.trim().length < 2">
{{ asking ? '…' : 'بپرس' }}
</button>
</form>
<p v-if="error" class="error">{{ error }}</p>
<div v-else-if="result" class="answer">
<p>{{ result.answer }}</p>
<div v-if="result.sources.length" class="sources">
<RouterLink
v-for="source in result.sources"
:key="`${source.type}-${source.id}`"
:to="sourceTo(source)"
>
{{ source.label }}
</RouterLink>
</div>
</div>
</section>
</template>
<style scoped>
.ask {
display: grid;
gap: 0.55rem;
min-width: 0;
}
form {
display: flex;
align-items: center;
gap: 0.45rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 14px;
padding: 0.35rem 0.4rem 0.35rem 0.7rem;
}
.ico {
color: #0f766e;
flex-shrink: 0;
}
input {
flex: 1;
min-width: 0;
border: 0;
background: transparent;
font: inherit;
padding: 0.45rem 0;
}
button {
border: 0;
border-radius: 10px;
background: #14b8a6;
color: #fff;
font: inherit;
font-weight: 700;
padding: 0.45rem 0.8rem;
cursor: pointer;
}
button:disabled {
opacity: 0.55;
cursor: wait;
}
.answer {
background: #f0fdfa;
border-radius: 12px;
padding: 0.7rem 0.85rem;
}
.answer p {
margin: 0;
color: #134e4a;
font-size: 0.88rem;
line-height: 1.7;
}
.sources {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.55rem;
}
.sources a {
font-size: 0.74rem;
font-weight: 700;
color: #0f766e;
background: #fff;
border-radius: 999px;
padding: 0.18rem 0.55rem;
}
.error {
margin: 0;
color: #b91c1c;
font-size: 0.84rem;
}
</style>
@@ -0,0 +1,221 @@
<script setup lang="ts">
import type { PersonMood } from '@mento/shared';
withDefaults(
defineProps<{
mood?: PersonMood | null;
size?: number;
}>(),
{ mood: 'newcomer', size: 48 },
);
</script>
<template>
<span
class="face"
:class="mood || 'newcomer'"
:style="{ width: `${size}px`, height: `${size}px` }"
aria-hidden="true"
>
<svg viewBox="0 0 64 64" fill="none">
<circle class="skin" cx="32" cy="34" r="22" />
<g class="shine">
<ellipse cx="24" cy="26" rx="6" ry="3.2" />
</g>
<g class="eyes">
<g class="eye left">
<ellipse class="open" cx="24" cy="33" rx="3.1" ry="3.6" />
<path class="happy" d="M21 33c1.4-2.2 4.6-2.2 6 0" />
<path class="sad" d="M21.4 34.6c1.2-2 4-2 5.2 0" />
</g>
<g class="eye right">
<ellipse class="open" cx="40" cy="33" rx="3.1" ry="3.6" />
<path class="happy" d="M37 33c1.4-2.2 4.6-2.2 6 0" />
<path class="sad" d="M37.4 34.6c1.2-2 4-2 5.2 0" />
</g>
</g>
<path class="mouth smile" d="M24 42c2.6 4.4 13.4 4.4 16 0" />
<path class="mouth grin" d="M22 40.5c3.4 7 16.6 7 20 0" />
<path class="mouth frown" d="M25 46c2.8-3.6 11.2-3.6 14 0" />
<g class="tears">
<ellipse class="tear t1" cx="18" cy="40" rx="2.1" ry="3.2" />
<ellipse class="tear t2" cx="46" cy="42" rx="2.1" ry="3.2" />
</g>
<g class="crown">
<path d="M16 18.5 22 26l10-9 10 9 6-7.5-2 12H18l-2-12Z" />
<rect x="18" y="26" width="28" height="3.4" rx="1" />
<circle class="gem" cx="32" cy="20" r="2.1" />
<circle class="gem side" cx="22.5" cy="23" r="1.5" />
<circle class="gem side" cx="41.5" cy="23" r="1.5" />
</g>
</svg>
</span>
</template>
<style scoped>
.face {
display: inline-grid;
place-items: center;
flex-shrink: 0;
border-radius: 50%;
background: #fff7d6;
box-shadow: inset 0 -3px 0 rgba(180, 120, 20, 0.08);
overflow: visible;
}
svg {
width: 100%;
height: 100%;
overflow: visible;
}
.skin {
fill: #f5c542;
}
.shine ellipse {
fill: rgba(255, 255, 255, 0.35);
}
.eye .open {
fill: #3b2415;
}
.eye .happy,
.eye .sad,
.mouth,
.tears,
.crown {
display: none;
}
.eye .happy,
.eye .sad,
.mouth {
stroke: #3b2415;
stroke-width: 2.4;
stroke-linecap: round;
fill: none;
}
.face.newcomer .mouth.smile {
display: block;
}
.face.happy {
animation: bounce 1.6s ease-in-out infinite;
}
.face.happy .eye .open {
display: none;
}
.face.happy .eye .happy,
.face.happy .mouth.grin {
display: block;
}
.face.unhappy .eye .open {
display: none;
}
.face.unhappy .eye .sad,
.face.unhappy .mouth.frown,
.face.unhappy .tears {
display: block;
}
.tear {
fill: #5bb7ff;
}
.t1 {
animation: drop 1.4s ease-in infinite;
}
.t2 {
animation: drop 1.4s ease-in 0.35s infinite;
}
.face.special .mouth.smile,
.face.special .crown {
display: block;
}
.face.special .crown {
transform-origin: 32px 22px;
animation: bob 1.8s ease-in-out infinite;
}
.crown path,
.crown rect {
fill: #f0b429;
stroke: #c48414;
stroke-width: 1;
}
.gem {
fill: #ef4444;
}
.gem.side {
fill: #22c55e;
}
.face.newcomer .eye .open,
.face.special .eye .open {
transform-box: fill-box;
transform-origin: center;
animation: blink 3.4s ease-in-out infinite;
}
@keyframes blink {
0%,
42%,
48%,
100% {
transform: scaleY(1);
}
45% {
transform: scaleY(0.12);
}
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-2px);
}
}
@keyframes drop {
0% {
opacity: 0;
transform: translateY(-4px);
}
25% {
opacity: 1;
}
100% {
opacity: 0;
transform: translateY(10px);
}
}
@keyframes bob {
0%,
100% {
transform: translateY(0) rotate(-4deg);
}
50% {
transform: translateY(-2px) rotate(4deg);
}
}
</style>
+16 -3
View File
@@ -2,6 +2,8 @@
import { computed, onMounted, ref, watch } from 'vue';
import { RouterLink } from 'vue-router';
import { api } from '@/lib/api';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import { appendSpokenText } from '@/lib/spoken-text';
import type {
ActivityDto,
NoteDto,
@@ -203,9 +205,12 @@ onMounted(load);
placeholder="یادداشت سریع…"
@keydown.ctrl.enter.prevent="addNote"
/>
<button class="btn" type="button" :disabled="saving || !noteDraft.trim()" @click="addNote">
ثبت یادداشت
</button>
<div class="compose-actions">
<VoiceToTextButton @transcript="(text) => (noteDraft = appendSpokenText(noteDraft, text))" />
<button class="btn" type="button" :disabled="saving || !noteDraft.trim()" @click="addNote">
ثبت یادداشت
</button>
</div>
</div>
<div class="composer">
<input
@@ -289,6 +294,14 @@ onMounted(load);
background: #fff;
}
.compose-actions {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: space-between;
}
.list {
list-style: none;
margin: 0;
@@ -235,12 +235,15 @@ function formatPayloadSummary(value: NoteAiPayloadDto | null) {
background: rgba(255, 255, 255, 0.65);
display: grid;
gap: 0.6rem;
min-width: 0;
overflow-wrap: anywhere;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
@@ -282,6 +285,8 @@ textarea {
padding: 0.55rem 0.7rem;
font: inherit;
width: 100%;
min-width: 0;
max-width: 100%;
}
.actions {
@@ -290,6 +295,10 @@ textarea {
flex-wrap: wrap;
}
.actions .btn {
min-width: 0;
}
.summary {
margin: 0;
color: var(--muted);
+437
View File
@@ -0,0 +1,437 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { RouteLocationRaw } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
const FAB_SIZE = 56;
const FAB_MARGIN = 20;
const POS_KEY = 'mento.quickCreateFab';
type FabPos = { left: number; top: number };
type ItemIcon = 'user' | 'building' | 'dollar' | 'calendar-plus' | 'document';
const items: Array<{
key: string;
title: string;
desc: string;
icon: ItemIcon;
tone: string;
to: RouteLocationRaw;
}> = [
{
key: 'person',
title: 'افزودن شخص',
desc: 'مخاطب جدید را ثبت کنید',
icon: 'user',
tone: 'person',
to: { name: 'person-new' },
},
{
key: 'company',
title: 'افزودن شرکت',
desc: 'شرکت یا سازمان جدید اضافه کنید',
icon: 'building',
tone: 'company',
to: { name: 'company-new' },
},
{
key: 'deal',
title: 'افزودن معامله',
desc: 'فرصت یا معامله جدید ایجاد کنید',
icon: 'dollar',
tone: 'deal',
to: { name: 'deal-new' },
},
{
key: 'activity',
title: 'افزودن فعالیت',
desc: 'تماس، جلسه، ایمیل و ... ثبت کنید',
icon: 'calendar-plus',
tone: 'activity',
to: { name: 'activity-new' },
},
{
key: 'note',
title: 'افزودن کارت (یادداشت)',
desc: 'یادداشت یا کارت جدید ایجاد کنید',
icon: 'document',
tone: 'note',
to: { name: 'notes' },
},
];
const route = useRoute();
const router = useRouter();
const hidden = computed(() => route.name === 'person-detail');
const isQuickCreateOpen = ref(false);
const ready = ref(false);
const fabPos = ref<FabPos>({ left: FAB_MARGIN, top: FAB_MARGIN });
const viewport = ref({ w: 1280, h: 800 });
const dragging = ref(false);
let dragMoved = false;
let dragOffsetX = 0;
let dragOffsetY = 0;
function defaultPos(): FabPos {
return {
left: FAB_MARGIN,
top: Math.max(FAB_MARGIN, viewport.value.h - FAB_MARGIN - FAB_SIZE),
};
}
function clampPos(pos: FabPos): FabPos {
const margin = 8;
return {
left: Math.min(Math.max(margin, pos.left), viewport.value.w - FAB_SIZE - margin),
top: Math.min(Math.max(margin, pos.top), viewport.value.h - FAB_SIZE - margin),
};
}
function loadPos() {
try {
const raw = localStorage.getItem(POS_KEY);
if (!raw) {
fabPos.value = defaultPos();
return;
}
const parsed = JSON.parse(raw) as FabPos;
if (typeof parsed.left !== 'number' || typeof parsed.top !== 'number') {
fabPos.value = defaultPos();
return;
}
fabPos.value = clampPos(parsed);
} catch {
fabPos.value = defaultPos();
}
}
function savePos() {
localStorage.setItem(POS_KEY, JSON.stringify(fabPos.value));
}
function onFabPointerDown(ev: PointerEvent) {
if (ev.button !== 0) return;
dragMoved = false;
dragOffsetX = ev.clientX - fabPos.value.left;
dragOffsetY = ev.clientY - fabPos.value.top;
(ev.currentTarget as HTMLElement).setPointerCapture(ev.pointerId);
}
function onFabPointerMove(ev: PointerEvent) {
if (!((ev.currentTarget as HTMLElement).hasPointerCapture?.(ev.pointerId))) return;
const next = clampPos({
left: ev.clientX - dragOffsetX,
top: ev.clientY - dragOffsetY,
});
if (
Math.abs(next.left - fabPos.value.left) > 4 ||
Math.abs(next.top - fabPos.value.top) > 4
) {
dragMoved = true;
dragging.value = true;
}
if (dragMoved) fabPos.value = next;
}
function onFabPointerUp(ev: PointerEvent) {
(ev.currentTarget as HTMLElement).releasePointerCapture?.(ev.pointerId);
if (dragMoved) {
fabPos.value = clampPos(fabPos.value);
savePos();
dragging.value = false;
return;
}
dragging.value = false;
toggle();
}
function syncViewport() {
viewport.value = { w: window.innerWidth, h: window.innerHeight };
}
function onResize() {
syncViewport();
fabPos.value = clampPos(fabPos.value);
}
const fabStyle = computed(() => ({
left: `${fabPos.value.left}px`,
top: `${fabPos.value.top}px`,
right: 'auto',
bottom: 'auto',
}));
const panelStyle = computed(() => {
const gap = 18;
const width = Math.min(390, viewport.value.w - 32);
let left = fabPos.value.left;
left = Math.min(left, viewport.value.w - width - 16);
left = Math.max(16, left);
const spaceAbove = fabPos.value.top;
const placeAbove = spaceAbove > 360;
if (placeAbove) {
return {
left: `${left}px`,
right: 'auto',
bottom: `${viewport.value.h - fabPos.value.top + gap}px`,
top: 'auto',
width: `${width}px`,
};
}
return {
left: `${left}px`,
right: 'auto',
top: `${fabPos.value.top + FAB_SIZE + gap}px`,
bottom: 'auto',
width: `${width}px`,
};
});
function close() {
isQuickCreateOpen.value = false;
}
function toggle() {
isQuickCreateOpen.value = !isQuickCreateOpen.value;
}
function go(to: RouteLocationRaw) {
close();
void router.push(to);
}
function onKeydown(ev: KeyboardEvent) {
if (ev.key === 'Escape' && isQuickCreateOpen.value) {
ev.preventDefault();
close();
}
}
watch(isQuickCreateOpen, (value) => {
document.body.style.overflow = value ? 'hidden' : '';
});
watch(hidden, (value) => {
if (value) close();
});
watch(
() => route.fullPath,
() => {
close();
},
);
onMounted(() => {
syncViewport();
loadPos();
ready.value = true;
window.addEventListener('resize', onResize);
window.addEventListener('keydown', onKeydown);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', onResize);
window.removeEventListener('keydown', onKeydown);
document.body.style.overflow = '';
});
</script>
<template>
<Teleport to="body">
<div v-if="!hidden" class="qc" dir="rtl">
<button
v-if="isQuickCreateOpen"
class="overlay"
type="button"
aria-label="بستن ایجاد سریع"
@click="close"
/>
<section
v-if="isQuickCreateOpen"
class="panel"
aria-labelledby="qc-title"
:style="panelStyle"
>
<h2 id="qc-title">ایجاد سریع</h2>
<button
v-for="item in items"
:key="item.key"
class="item"
type="button"
@click="go(item.to)"
>
<span class="ico" :class="item.tone">
<UiIcon :name="item.icon" :size="26" />
</span>
<span class="copy">
<strong>{{ item.title }}</strong>
<small>{{ item.desc }}</small>
</span>
</button>
</section>
<button
v-if="ready"
class="fab"
type="button"
:class="{ dragging }"
:style="fabStyle"
:aria-expanded="isQuickCreateOpen"
:aria-label="isQuickCreateOpen ? 'بستن ایجاد سریع' : 'ایجاد سریع'"
@pointerdown="onFabPointerDown"
@pointermove="onFabPointerMove"
@pointerup="onFabPointerUp"
@pointercancel="onFabPointerUp"
>
<UiIcon :name="isQuickCreateOpen ? 'x' : 'plus'" :size="22" />
</button>
</div>
</Teleport>
</template>
<style scoped>
.qc {
direction: rtl;
}
.overlay {
position: fixed;
inset: 0;
z-index: 90;
border: 0;
padding: 0;
background: rgba(0, 0, 0, 0.68);
cursor: pointer;
}
.panel {
position: fixed;
z-index: 92;
width: 390px;
box-sizing: border-box;
background: #fff;
border-radius: 28px;
padding: 20px;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
}
.panel h2 {
margin: 0 0 16px;
text-align: right;
font-size: 22px;
font-weight: 800;
color: #111827;
}
.item {
width: 100%;
height: 82px;
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 14px;
padding: 0 14px;
border: 1px solid #eef1f2;
border-radius: 16px;
background: #fff;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04);
font: inherit;
text-align: right;
cursor: pointer;
}
.item:last-child {
margin-bottom: 0;
}
.ico {
width: 52px;
height: 52px;
border-radius: 14px;
display: grid;
place-items: center;
color: #fff;
flex-shrink: 0;
}
.ico.person {
background: #0d9488;
}
.ico.company {
background: #22d3ee;
}
.ico.deal {
background: #7c3aed;
}
.ico.activity {
background: #f97316;
}
.ico.note {
background: #2563eb;
}
.copy {
display: grid;
gap: 4px;
min-width: 0;
text-align: right;
}
.copy strong {
font-size: 16px;
font-weight: 600;
color: #111827;
}
.copy small {
font-size: 13px;
color: #94a3b8;
line-height: 1.5;
}
.fab {
position: fixed;
z-index: 93;
width: 56px;
height: 56px;
padding: 0;
margin: 0;
border: 0;
border-radius: 50%;
background: #14b8a6;
color: #fff;
display: grid;
place-items: center;
line-height: 0;
direction: ltr;
box-shadow: 0 6px 16px rgba(13, 148, 136, 0.28);
cursor: grab;
touch-action: none;
user-select: none;
}
.fab :deep(.ico) {
display: block;
position: absolute;
top: 50%;
left: 50%;
width: 36px;
height: 36px;
margin: 0;
transform: translate(-50%, -50%);
stroke-width: 1.75;
}
.fab.dragging {
cursor: grabbing;
box-shadow: 0 8px 18px rgba(13, 148, 136, 0.34);
}
</style>
+24 -1
View File
@@ -44,7 +44,11 @@ defineProps<{
| 'check-plain'
| 'calendar-sun'
| 'calendar-edit'
| 'minus';
| 'minus'
| 'dollar'
| 'layers'
| 'target'
| 'pencil';
size?: number;
}>();
</script>
@@ -249,9 +253,28 @@ defineProps<{
<template v-else-if="name === 'minus'">
<path d="M6 12h12" />
</template>
<template v-else-if="name === 'dollar'">
<circle cx="12" cy="12" r="8.4" />
<path d="M12 6.8v10.4" />
<path d="M9.4 9.1c.7-1.1 1.8-1.6 2.6-1.6 1.3 0 2.3.7 2.3 1.9 0 2.6-5 1.5-5 4.1 0 1.3 1.1 2.2 2.7 2.2 1 0 2-.5 2.6-1.4" />
</template>
<template v-else-if="name === 'check-plain'">
<path d="m5.5 12.2 4.2 4.3 8.8-9" />
</template>
<template v-else-if="name === 'layers'">
<path d="M12 4 4 8.2 12 12.4 20 8.2 12 4Z" />
<path d="M4 12.2 12 16.4 20 12.2" />
<path d="M4 16.2 12 20.4 20 16.2" />
</template>
<template v-else-if="name === 'target'">
<circle cx="12" cy="12" r="8" />
<circle cx="12" cy="12" r="4.2" />
<circle cx="12" cy="12" r="1.2" fill="currentColor" stroke="none" />
</template>
<template v-else-if="name === 'pencil'">
<path d="M12 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
</template>
</svg>
</template>
@@ -0,0 +1,334 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from 'vue';
import axios from 'axios';
import { api } from '@/lib/api';
import { toFaDigits } from '@/lib/jalali';
import UiIcon from '@/components/UiIcon.vue';
const props = withDefaults(
defineProps<{
disabled?: boolean;
variant?: 'default' | 'toolbar';
hideError?: boolean;
}>(),
{ variant: 'default', hideError: false },
);
const emit = defineEmits<{
transcript: [text: string];
recording: [active: boolean];
busy: [active: boolean];
error: [message: string];
}>();
const recording = ref(false);
const transcribing = ref(false);
const error = ref('');
const seconds = ref(0);
let stream: MediaStream | null = null;
let ctx: AudioContext | null = null;
let source: MediaStreamAudioSourceNode | null = null;
let processor: ScriptProcessorNode | null = null;
let mute: GainNode | null = null;
let chunks: Float32Array[] = [];
let sampleRate = 16000;
let timer: number | null = null;
let limitTimer: number | null = null;
const MAX_SECONDS = 90;
const busy = computed(() => recording.value || transcribing.value);
const timerLabel = computed(() => {
const m = String(Math.floor(seconds.value / 60)).padStart(2, '0');
const s = String(seconds.value % 60).padStart(2, '0');
return toFaDigits(`${m}:${s}`);
});
const ariaLabel = computed(() => {
if (transcribing.value) return 'در حال تبدیل صوت به متن';
if (recording.value) return 'توقف ضبط و تبدیل به متن';
return 'تبدیل صوت به متن';
});
function setError(message: string) {
error.value = message;
emit('error', message);
}
function setRecording(active: boolean) {
recording.value = active;
emit('recording', active);
emit('busy', busy.value);
}
function setTranscribing(active: boolean) {
transcribing.value = active;
emit('busy', recording.value || active);
}
function stopTimer() {
if (timer != null) {
window.clearInterval(timer);
timer = null;
}
if (limitTimer != null) {
window.clearTimeout(limitTimer);
limitTimer = null;
}
}
function teardownMic() {
stopTimer();
try {
processor?.disconnect();
source?.disconnect();
mute?.disconnect();
} catch {
/* ignore */
}
processor = null;
source = null;
mute = null;
stream?.getTracks().forEach((track) => track.stop());
stream = null;
if (ctx) {
void ctx.close().catch(() => undefined);
ctx = null;
}
}
function mergeChunks(parts: Float32Array[]) {
const length = parts.reduce((sum, part) => sum + part.length, 0);
const out = new Float32Array(length);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
function encodeWav(samples: Float32Array, rate: number) {
const bytes = samples.length * 2;
const buffer = new ArrayBuffer(44 + bytes);
const view = new DataView(buffer);
const write = (offset: number, text: string) => {
for (let i = 0; i < text.length; i += 1) view.setUint8(offset + i, text.charCodeAt(i));
};
write(0, 'RIFF');
view.setUint32(4, 36 + bytes, true);
write(8, 'WAVE');
write(12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, 1, true);
view.setUint32(24, rate, true);
view.setUint32(28, rate * 2, true);
view.setUint16(32, 2, true);
view.setUint16(34, 16, true);
write(36, 'data');
view.setUint32(40, bytes, true);
let offset = 44;
for (let i = 0; i < samples.length; i += 1) {
const sample = Math.max(-1, Math.min(1, samples[i] ?? 0));
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7fff, true);
offset += 2;
}
return new Blob([buffer], { type: 'audio/wav' });
}
async function upload(blob: Blob) {
const form = new FormData();
form.append('audio', blob, 'dictation.wav');
const { data } = await api.post<{ text: string }>('/ai/transcribe', form);
return (data.text ?? '').trim();
}
async function finish(keep: boolean) {
const parts = chunks;
chunks = [];
const rate = sampleRate;
teardownMic();
setRecording(false);
if (!keep) return;
const samples = mergeChunks(parts);
if (samples.length < rate * 0.35) {
setError('صدای کافی ضبط نشد. کمی بیشتر صحبت کنید.');
return;
}
setTranscribing(true);
try {
const text = await upload(encodeWav(samples, rate));
if (!text) {
setError('متنی از صدا تشخیص داده نشد.');
return;
}
emit('transcript', text);
} catch (err) {
if (axios.isAxiosError(err)) {
const msg = (err.response?.data as { message?: string | string[] })?.message;
setError(Array.isArray(msg) ? msg.join('، ') : (msg ?? 'تبدیل صوت به متن ناموفق بود'));
} else {
setError('تبدیل صوت به متن ناموفق بود');
}
} finally {
setTranscribing(false);
}
}
async function start() {
setError('');
if (!navigator.mediaDevices?.getUserMedia) {
setError('این مرورگر به میکروفون دسترسی نمی‌دهد.');
return;
}
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 },
});
const Ctor = window.AudioContext || (window as Window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) {
setError('ضبط صدا در این مرورگر پشتیبانی نمی‌شود.');
stream.getTracks().forEach((track) => track.stop());
stream = null;
return;
}
ctx = new Ctor();
await ctx.resume();
sampleRate = ctx.sampleRate || 16000;
source = ctx.createMediaStreamSource(stream);
processor = ctx.createScriptProcessor(4096, 1, 1);
mute = ctx.createGain();
mute.gain.value = 0;
chunks = [];
processor.onaudioprocess = (event) => {
if (!recording.value) return;
chunks.push(new Float32Array(event.inputBuffer.getChannelData(0)));
};
source.connect(processor);
processor.connect(mute);
mute.connect(ctx.destination);
seconds.value = 0;
timer = window.setInterval(() => {
seconds.value += 1;
}, 1000);
limitTimer = window.setTimeout(() => {
void finish(true);
}, MAX_SECONDS * 1000);
setRecording(true);
} catch {
teardownMic();
setError('دسترسی به میکروفون داده نشد.');
}
}
function toggle() {
if (props.disabled || transcribing.value) return;
if (recording.value) {
void finish(true);
return;
}
void start();
}
onBeforeUnmount(() => {
void finish(false);
});
</script>
<template>
<div class="vtt-wrap" :class="variant">
<button
class="vtt"
type="button"
:class="{ on: recording, busy: transcribing }"
:disabled="disabled || transcribing"
:aria-label="ariaLabel"
:title="ariaLabel"
@click="toggle"
>
<UiIcon :name="recording ? 'stop' : 'mic'" :size="variant === 'toolbar' ? 18 : 16" />
<span v-if="variant === 'default' && recording" class="timer">{{ timerLabel }}</span>
<span v-else-if="variant === 'default' && transcribing" class="timer">تبدیل…</span>
</button>
<p v-if="error && !hideError" class="err">{{ error }}</p>
</div>
</template>
<style scoped>
.vtt-wrap {
display: grid;
gap: 0.3rem;
justify-items: start;
}
.vtt {
display: inline-flex;
align-items: center;
gap: 0.35rem;
border: 1px solid #d7e3dc;
background: #f4faf7;
color: #0f766e;
border-radius: 999px;
min-height: 36px;
padding: 0 0.75rem;
font: inherit;
font-size: 0.8rem;
font-weight: 700;
cursor: pointer;
}
.vtt:hover:not(:disabled) {
background: #ecfdf5;
}
.vtt.on {
border-color: #f87171;
background: #fef2f2;
color: #b91c1c;
animation: pulse 1.2s ease-in-out infinite;
}
.vtt.busy:not(.on) {
opacity: 0.72;
cursor: wait;
}
.vtt:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.toolbar .vtt {
width: 38px;
height: 38px;
min-height: 38px;
padding: 0;
border-radius: 12px;
border-color: transparent;
background: #f8fafc;
justify-content: center;
}
.toolbar .vtt.on {
background: #fef2f2;
}
.timer {
font-variant-numeric: tabular-nums;
}
.err {
margin: 0;
color: #b91c1c;
font-size: 0.78rem;
}
@keyframes pulse {
50% {
box-shadow: 0 0 0 5px rgba(248, 113, 113, 0.16);
}
}
</style>
@@ -5,9 +5,10 @@ import {
calendarDaysBetween,
formatClockFa,
formatJalaliDay,
toFaDigits,
} from '@/lib/jalali';
import { appendSpokenText } from '@/lib/spoken-text';
import UiIcon from '@/components/UiIcon.vue';
import VoiceToTextButton from '@/components/VoiceToTextButton.vue';
import type { ActivityDto, ActivityType, OrganizationDto, OrgUserDto } from '@mento/shared';
const props = defineProps<{
@@ -35,34 +36,7 @@ const mentionOpen = ref(false);
const voiceTip = ref(localStorage.getItem('mento.voiceTipSeen') !== '1');
const voiceError = ref('');
const recording = ref(false);
const seconds = ref(0);
const pullY = ref(0);
let recognition: SpeechRecognitionLike | null = null;
let timer: number | null = null;
let snapshot = '';
let finals = '';
let keepOnEnd = true;
let pendingSubmit = false;
let pulling = false;
let pullStart = 0;
type SpeechRecognitionLike = {
lang: string;
continuous: boolean;
interimResults: boolean;
start: () => void;
stop: () => void;
abort: () => void;
onresult: ((ev: SpeechRecognitionEventLike) => void) | null;
onerror: ((ev: { error?: string }) => void) | null;
onend: (() => void) | null;
};
type SpeechRecognitionEventLike = {
resultIndex: number;
results: ArrayLike<{ isFinal: boolean; 0: { transcript: string } }>;
};
const voiceBusy = ref(false);
const subtitle = computed(() => {
const a = props.activity;
@@ -81,152 +55,29 @@ const dueLabel = computed(() => {
return `${formatJalaliDay(d)} ${clock}`;
});
const timerLabel = computed(() => {
const m = String(Math.floor(seconds.value / 60)).padStart(2, '0');
const s = String(seconds.value % 60).padStart(2, '0');
return `${toFaDigits(m)}:${toFaDigits(s)}`;
});
const canSubmit = computed(
() => comment.value.trim().length > 0 && !props.saving && !voiceBusy.value,
);
const canSubmit = computed(() => comment.value.trim().length > 0 && !props.saving);
function speechCtor(): (new () => SpeechRecognitionLike) | null {
const w = window as Window & {
SpeechRecognition?: new () => SpeechRecognitionLike;
webkitSpeechRecognition?: new () => SpeechRecognitionLike;
};
return w.SpeechRecognition || w.webkitSpeechRecognition || null;
function onVoiceTranscript(text: string) {
comment.value = appendSpokenText(comment.value, text);
}
function applyTranscript(interim = '') {
const extra = `${finals}${interim}`.trim();
comment.value = extra ? `${snapshot}${snapshot && !snapshot.endsWith(' ') ? ' ' : ''}${extra}` : snapshot;
}
function startTimer() {
stopTimer();
seconds.value = 0;
timer = window.setInterval(() => {
seconds.value += 1;
}, 1000);
}
function stopTimer() {
if (timer != null) {
window.clearInterval(timer);
timer = null;
}
}
function stopRecognition(keep: boolean) {
keepOnEnd = keep;
if (!recognition) {
finishRecording(keep);
return;
}
try {
if (keep) recognition.stop();
else recognition.abort();
} catch {
finishRecording(keep);
return;
}
window.setTimeout(() => {
if (recording.value) finishRecording(keepOnEnd);
}, 400);
}
function finishRecording(keep: boolean) {
if (!recording.value && !recognition) return;
stopTimer();
recording.value = false;
pullY.value = 0;
recognition = null;
if (!keep) {
comment.value = snapshot;
pendingSubmit = false;
} else {
applyTranscript('');
}
finals = '';
if (pendingSubmit) {
pendingSubmit = false;
submit();
}
}
function startRecording() {
voiceError.value = '';
mentionOpen.value = false;
if (voiceTip.value) {
function onVoiceBusy(active: boolean) {
voiceBusy.value = active;
if (active && voiceTip.value) {
voiceTip.value = false;
localStorage.setItem('mento.voiceTipSeen', '1');
}
const Ctor = speechCtor();
if (!Ctor) {
voiceError.value = 'تایپ صوتی در این مرورگر پشتیبانی نمی‌شود.';
return;
}
snapshot = comment.value;
finals = '';
keepOnEnd = true;
const rec = new Ctor();
rec.lang = 'fa-IR';
rec.continuous = true;
rec.interimResults = true;
rec.onresult = (ev) => {
let interim = '';
for (let i = ev.resultIndex; i < ev.results.length; i += 1) {
const piece = ev.results[i];
if (!piece) continue;
const text = piece[0]?.transcript ?? '';
if (piece.isFinal) finals += `${text} `;
else interim += text;
}
applyTranscript(interim);
};
rec.onerror = (ev) => {
if (ev.error === 'aborted' || ev.error === 'no-speech') return;
voiceError.value = 'ضبط صدا ناموفق بود. دوباره تلاش کنید.';
finishRecording(true);
};
rec.onend = () => {
finishRecording(keepOnEnd);
};
recognition = rec;
recording.value = true;
startTimer();
try {
rec.start();
} catch {
voiceError.value = 'شروع ضبط صدا ممکن نشد.';
finishRecording(false);
}
}
function discardRecording() {
pendingSubmit = false;
stopRecognition(false);
}
function keepRecording() {
pendingSubmit = false;
stopRecognition(true);
}
function sendRecording() {
pendingSubmit = true;
stopRecognition(true);
}
function submit() {
if (recording.value) keepRecording();
const text = comment.value.trim();
if (!text || props.saving) return;
if (!text || props.saving || voiceBusy.value) return;
emit('submit', text);
}
function close() {
if (recording.value) discardRecording();
emit('close');
}
@@ -256,37 +107,11 @@ function onKeydown(ev: KeyboardEvent) {
mentionOpen.value = false;
return;
}
if (recording.value) {
discardRecording();
return;
}
if (recording.value) return;
close();
}
}
function onPullStart(ev: PointerEvent) {
if (!recording.value) return;
const target = ev.target as HTMLElement | null;
if (target?.closest('button')) return;
pulling = true;
pullStart = ev.clientY;
pullY.value = 0;
(ev.currentTarget as HTMLElement | null)?.setPointerCapture?.(ev.pointerId);
}
function onPullMove(ev: PointerEvent) {
if (!pulling) return;
pullY.value = Math.max(0, ev.clientY - pullStart);
}
function onPullEnd() {
if (!pulling) return;
const shouldCancel = pullY.value > 72;
pulling = false;
pullY.value = 0;
if (shouldCancel) discardRecording();
}
onMounted(async () => {
document.body.style.overflow = 'hidden';
window.addEventListener('keydown', onKeydown);
@@ -318,17 +143,6 @@ onMounted(async () => {
onBeforeUnmount(() => {
document.body.style.overflow = '';
window.removeEventListener('keydown', onKeydown);
pendingSubmit = false;
stopTimer();
if (recognition) {
try {
recognition.abort();
} catch {
/* ignore */
}
recognition = null;
}
recording.value = false;
});
</script>
@@ -409,15 +223,15 @@ onBeforeUnmount(() => {
</div>
<div class="tool-wrap">
<p v-if="voiceTip && !recording" class="tip">تایپ صوتی را امتحان کنید! 🎙️✨</p>
<button
class="tool mic"
type="button"
aria-label="تایپ صوتی"
:class="{ on: recording }"
@click="recording ? keepRecording() : startRecording()"
>
<UiIcon name="mic" :size="18" />
</button>
<VoiceToTextButton
variant="toolbar"
hide-error
:disabled="saving"
@transcript="onVoiceTranscript"
@recording="recording = $event"
@busy="onVoiceBusy"
@error="voiceError = $event"
/>
</div>
</div>
</div>
@@ -429,41 +243,11 @@ onBeforeUnmount(() => {
یادداشت شما فقط برای اعضای تیم و مدیران {{ orgName }} قابل مشاهده خواهد بود.
</p>
<div v-if="!recording" class="actions">
<div class="actions">
<button class="act primary" type="button" :disabled="!canSubmit" @click="submit">
ثبت نظر
{{ recording ? 'در حال ضبط…' : 'ثبت نظر' }}
</button>
<button class="act ghost" type="button" :disabled="saving" @click="close">انصراف</button>
</div>
<div
v-else
class="recorder"
:style="{ transform: pullY ? `translateY(${pullY}px)` : undefined }"
@pointerdown="onPullStart"
@pointermove="onPullMove"
@pointerup="onPullEnd"
@pointercancel="onPullEnd"
>
<div class="rec-top">
<strong>در حال ضبط صدا...</strong>
<span>{{ timerLabel }}</span>
</div>
<div class="rec-controls">
<button class="rec-side" type="button" aria-label="ارسال نظر" @click="sendRecording">
<UiIcon name="send" :size="18" />
</button>
<button class="rec-stop" type="button" aria-label="توقف ضبط" @click="keepRecording">
<UiIcon name="stop" :size="22" />
</button>
<button class="rec-side" type="button" aria-label="حذف ضبط" @click="discardRecording">
<UiIcon name="trash" :size="18" />
</button>
</div>
<p class="pull">
برای لغو، بیرون بکشید
<UiIcon name="chevron" :size="14" />
</p>
<button class="act ghost" type="button" :disabled="saving || voiceBusy" @click="close">انصراف</button>
</div>
</div>
</section>
@@ -674,15 +458,10 @@ onBeforeUnmount(() => {
cursor: pointer;
}
.tool.mic {
.tool-wrap :deep(.vtt) {
color: #0d9488;
}
.tool.on,
.tool.mic:hover {
background: #ecfdf5;
}
.tip {
position: absolute;
inset-inline-end: 0;
@@ -800,76 +579,6 @@ onBeforeUnmount(() => {
cursor: not-allowed;
}
.recorder {
border: 1.5px dashed #cbd5e1;
border-radius: 16px;
padding: 0.95rem 0.85rem 0.7rem;
text-align: center;
touch-action: none;
}
.rec-top {
display: flex;
justify-content: space-between;
align-items: center;
color: #334155;
font-size: 0.92rem;
}
.rec-top strong {
font-weight: 800;
}
.rec-top span {
font-variant-numeric: tabular-nums;
color: #64748b;
}
.rec-controls {
display: flex;
align-items: center;
justify-content: space-between;
margin: 1rem 1.4rem 0.55rem;
}
.rec-side {
width: 42px;
height: 42px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
color: #0f766e;
display: grid;
place-items: center;
cursor: pointer;
}
.rec-stop {
width: 68px;
height: 68px;
border: 0;
border-radius: 999px;
background: #14b8a6;
color: #fff;
display: grid;
place-items: center;
box-shadow: 0 10px 24px rgba(20, 184, 166, 0.35);
cursor: pointer;
}
.pull {
margin: 0.35rem 0 0;
display: inline-flex;
align-items: center;
gap: 0.2rem;
color: #94a3b8;
font-size: 0.78rem;
}
.pull :deep(.ico) {
transform: rotate(90deg);
}
@media (min-width: 900px) {
.layer {
background: rgba(15, 23, 42, 0.28);
@@ -1,22 +1,39 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router';
import UiIcon from '@/components/UiIcon.vue';
import type { AskMentoSourceDto } from '@mento/shared';
defineProps<{
askQuery: string;
asking?: boolean;
answer?: string;
error?: string;
sources?: AskMentoSourceDto[];
}>();
const emit = defineEmits<{
'update:askQuery': [value: string];
ask: [];
ask: [preset?: string];
}>();
const shortcuts = [
{ label: 'خلاصه مشتری شرکت آریا را بده', to: '/contacts', icon: 'users' as const },
{ label: 'متن پیگیری برای مشتری بفرست', to: '/notes', icon: 'send' as const },
{ label: 'گزارش فروش این هفته را نشان بده', to: '/reports', icon: 'chart' as const },
{ label: 'معاملات در خطر را لیست کن', to: '/pipeline', icon: 'warning' as const },
{ 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);
}
</script>
<template>
@@ -31,10 +48,31 @@ const shortcuts = [
<p class="ai-lead">چه کمکی می‌تونم بکنم؟</p>
<div class="ai-shortcuts">
<RouterLink v-for="item in shortcuts" :key="item.label" class="ai-chip" :to="item.to">
<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>
</RouterLink>
</button>
</div>
<p v-if="error" class="ai-error">{{ error }}</p>
<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="emit('ask')">
@@ -42,9 +80,10 @@ const shortcuts = [
:value="askQuery"
type="search"
placeholder="از منتو بپرس..."
:disabled="asking"
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
/>
<button type="submit" aria-label="ارسال">
<button type="submit" aria-label="ارسال" :disabled="asking">
<UiIcon name="send" :size="15" />
</button>
</form>
@@ -110,15 +149,22 @@ h2 {
border: 1px solid #eef2f7;
color: #334155;
font-size: 0.76rem;
text-decoration: none;
text-align: start;
font: inherit;
cursor: pointer;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.03);
}
.ai-chip:hover {
.ai-chip:hover:not(:disabled) {
background: #f0fdfa;
border-color: #99f6e4;
}
.ai-chip:disabled {
opacity: 0.6;
cursor: wait;
}
.chip-ico {
width: 26px;
height: 26px;
@@ -155,4 +201,44 @@ h2 {
display: grid;
place-items: center;
}
.ai-input button:disabled {
opacity: 0.55;
}
.ai-answer {
background: #f0fdfa;
border-radius: 12px;
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>