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:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user