Edit the artificial intelligence of the Mentact assistant

This commit is contained in:
2026-09-21 16:58:49 +03:30
parent f5a4eb577b
commit d7d82102af
4 changed files with 248 additions and 23 deletions
+149 -9
View File
@@ -11,7 +11,16 @@ import {
noteAccessWhere, noteAccessWhere,
ownerWhere, ownerWhere,
} from '../common/record-scope'; } from '../common/record-scope';
import { BUSINESS_FIELD_LABELS, brand, brandSuggestionFa, isBusinessField } from '@mento/shared'; import {
BILLING_PLAN_LABELS,
BUSINESS_FIELD_LABELS,
USER_ROLE_LABELS,
brand,
brandSuggestionFa,
isBusinessField,
type BillingPlanCode,
type UserRole,
} from '@mento/shared';
import { AiService } from './ai.service'; import { AiService } from './ai.service';
type CatalogItem = AskMentoSourceDto; type CatalogItem = AskMentoSourceDto;
@@ -22,6 +31,14 @@ function clip(value: string | null | undefined, max = 280) {
return text.length > max ? `${text.slice(0, max)}…` : text; return text.length > max ? `${text.slice(0, max)}…` : text;
} }
function roleLabel(role: string) {
return USER_ROLE_LABELS[role as UserRole] ?? role;
}
function planLabel(code: string) {
return BILLING_PLAN_LABELS[code as BillingPlanCode] ?? code;
}
@Injectable() @Injectable()
export class AiAskService { export class AiAskService {
constructor( constructor(
@@ -39,12 +56,14 @@ export class AiAskService {
answer?: string; answer?: string;
sources?: Array<{ type?: string; id?: string; label?: string }>; sources?: Array<{ type?: string; id?: string; label?: string }>;
}>( }>(
`You are ${brand.name}, a Persian CRM sales assistant. `You are ${brand.name}, a Persian CRM assistant for this user's account.
Answer ONLY from the CRM context. If the context is insufficient, say so in Persian. "محیط" means a workspace/organization the user belongs to (listed under Workspace facts).
Workspace facts are complete and authoritative. Use them for questions about how many environments the user has, the current environment, role, subscription, team size, and record counts.
Answer ONLY from the CRM context. Never say a workspace fact is unavailable if it appears in the context.
Do not invent customers, amounts, dates, or promises. Do not invent customers, amounts, dates, or promises.
Return JSON: { "answer": string, "sources": [{ "type": "person"|"note"|"activity"|"deal"|"memory", "id": string, "label": string }] } Return JSON: { "answer": string, "sources": [{ "type": "person"|"note"|"activity"|"deal"|"memory", "id": string, "label": string }] }
Use at most 4 sources, and only ids that appear in the context. Use at most 4 sources, and only ids that appear in the context. Workspace facts do not need sources.
Write the answer in Persian, concise, with dates when present.`, Write the answer in Persian, concise, with exact numbers and names when present.`,
`Question:\n${question.trim()}\n\nCRM context:\n${context}`, `Question:\n${question.trim()}\n\nCRM context:\n${context}`,
); );
@@ -102,11 +121,108 @@ Rules:
if (!person) throw new NotFoundException('شخص پیدا نشد'); if (!person) throw new NotFoundException('شخص پیدا نشد');
} }
const [org, notes, activities, deals, memories] = await Promise.all([ const now = new Date();
const todayStart = new Date(now);
todayStart.setHours(0, 0, 0, 0);
const todayEnd = new Date(now);
todayEnd.setHours(23, 59, 59, 999);
const dealScope = ownerWhere(actor);
const activityScope = assigneeWhere(actor);
const personScope = ownerWhere(actor);
const [
org,
user,
memberships,
ownerBilling,
peopleCount,
openDealCount,
staleDealCount,
overdueActivityCount,
todayActivityCount,
teamCount,
notes,
activities,
deals,
memories,
] = await Promise.all([
this.prisma.organization.findUnique({ this.prisma.organization.findUnique({
where: { id: actor.organizationId }, where: { id: actor.organizationId },
select: { name: true, businessField: true }, select: { name: true, businessField: true },
}), }),
this.prisma.user.findUnique({
where: { id: actor.userId },
select: { fullName: true, email: true, mobile: true },
}),
this.prisma.membership.findMany({
where: {
userId: actor.userId,
isActive: true,
organization: { isActive: true },
},
select: {
role: true,
organizationId: true,
organization: { select: { name: true } },
},
orderBy: { createdAt: 'asc' },
}),
this.prisma.billingAccount.findFirst({
where: {
owner: {
memberships: {
some: {
organizationId: actor.organizationId,
role: 'owner',
isActive: true,
},
},
},
},
select: {
planCode: true,
status: true,
expiresAt: true,
maxOrganizations: true,
},
}),
this.prisma.person.count({
where: { organizationId: actor.organizationId, ...personScope },
}),
this.prisma.deal.count({
where: {
organizationId: actor.organizationId,
status: 'open',
...dealScope,
},
}),
this.prisma.deal.count({
where: {
organizationId: actor.organizationId,
status: 'open',
...dealScope,
OR: [{ isStale: true }, { isRotten: true }],
},
}),
this.prisma.activity.count({
where: {
organizationId: actor.organizationId,
...activityScope,
status: 'open',
dueAt: { lt: now },
},
}),
this.prisma.activity.count({
where: {
organizationId: actor.organizationId,
...activityScope,
status: 'open',
dueAt: { gte: todayStart, lte: todayEnd },
},
}),
this.prisma.membership.count({
where: { organizationId: actor.organizationId, isActive: true },
}),
this.prisma.note.findMany({ this.prisma.note.findMany({
where: { where: {
organizationId: actor.organizationId, organizationId: actor.organizationId,
@@ -176,11 +292,35 @@ Rules:
org?.businessField && isBusinessField(org.businessField) org?.businessField && isBusinessField(org.businessField)
? BUSINESS_FIELD_LABELS[org.businessField] ? BUSINESS_FIELD_LABELS[org.businessField]
: 'نامشخص'; : 'نامشخص';
const envLines = memberships.map((row) => {
const current = row.organizationId === actor.organizationId ? ' [current]' : '';
return `${row.organization.name}${current} (role=${roleLabel(row.role)})`;
});
const billingDays = ownerBilling
? Math.max(
0,
Math.ceil(
(ownerBilling.expiresAt.getTime() - now.getTime()) /
(24 * 60 * 60 * 1000),
),
)
: null;
const lines: string[] = [ const lines: string[] = [
`Today: ${new Date().toISOString()}`, `Today: ${now.toISOString()}`,
`Organization: ${org?.name ?? '—'} | business field: ${field}`, 'Workspace facts (authoritative):',
personId ? `Focus personId: ${personId}` : 'Scope: current user records', `User: ${user?.fullName ?? '—'} | email: ${user?.email ?? '—'} | mobile: ${user?.mobile ?? '—'} | role in current environment: ${roleLabel(actor.role)}`,
`Environments (محیط) count: ${memberships.length}`,
`Environments: ${envLines.join(' | ') || 'none'}`,
`Current environment: ${org?.name ?? '—'} | business field: ${field} | team members: ${teamCount} | people: ${peopleCount} | open deals: ${openDealCount} | at-risk deals: ${staleDealCount} | overdue activities: ${overdueActivityCount} | activities due today: ${todayActivityCount}`,
]; ];
if (ownerBilling && billingDays != null) {
lines.push(
`Subscription of current environment owner: ${planLabel(ownerBilling.planCode)} | status=${ownerBilling.status} | days remaining=${billingDays} | max environments=${ownerBilling.maxOrganizations}`,
);
}
lines.push(
personId ? `Focus personId: ${personId}` : 'Scope: current user records in the current environment',
);
if (personId) { if (personId) {
const person = await this.prisma.person.findFirst({ const person = await this.prisma.person.findFirst({
@@ -37,7 +37,7 @@ function pick(label: string) {
</script> </script>
<template> <template>
<article class="panel"> <article id="home-ai-assistant" class="panel">
<header class="panel-head"> <header class="panel-head">
<h2> <h2>
<span class="spark"><UiIcon name="sparkle" :size="15" /></span> <span class="spark"><UiIcon name="sparkle" :size="15" /></span>
@@ -62,6 +62,9 @@ function pick(label: string) {
</div> </div>
<p v-if="error" class="ai-error">{{ error }}</p> <p v-if="error" class="ai-error">{{ error }}</p>
<div v-else-if="asking" class="ai-answer">
<p>در حال پاسخ…</p>
</div>
<div v-else-if="answer" class="ai-answer"> <div v-else-if="answer" class="ai-answer">
<p>{{ answer }}</p> <p>{{ answer }}</p>
<div v-if="sources?.length" class="sources"> <div v-if="sources?.length" class="sources">
+71 -12
View File
@@ -12,6 +12,9 @@ const props = defineProps<{
userInitial: string; userInitial: string;
alerts: HomeDashboardAlertDto[]; alerts: HomeDashboardAlertDto[];
askQuery: string; askQuery: string;
asking?: boolean;
askAnswer?: string;
askError?: string;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -77,6 +80,12 @@ function alertIcon(tone: string) {
if (tone === 'success') return 'trend' as const; if (tone === 'success') return 'trend' as const;
return 'sparkle' as const; return 'sparkle' as const;
} }
const shortcutLabel = computed(() =>
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform)
? '⌘K'
: 'Ctrl+K',
);
</script> </script>
<template> <template>
@@ -114,16 +123,26 @@ function alertIcon(tone: string) {
</RouterLink> </RouterLink>
</div> </div>
<form class="hero-ask" @submit.prevent="emit('ask')"> <div class="hero-ask-wrap">
<UiIcon name="sparkle" :size="18" class="ask-ico" /> <form class="hero-ask" @submit.prevent="emit('ask')">
<input <UiIcon name="sparkle" :size="18" class="ask-ico" />
:value="askQuery" <input
type="search" id="home-hero-ask"
:placeholder="brand.askPlaceholderFa" :value="askQuery"
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)" type="search"
/> :placeholder="brand.askPlaceholderFa"
<kbd>⌘K</kbd> :disabled="asking"
</form> @input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
/>
<kbd>{{ shortcutLabel }}</kbd>
<button type="submit" class="ask-send" aria-label="ارسال" :disabled="asking">
<UiIcon name="send" :size="15" />
</button>
</form>
<p v-if="asking" class="hero-ask-status">در حال پاسخ…</p>
<p v-else-if="askError" class="hero-ask-status err">{{ askError }}</p>
<p v-else-if="askAnswer" class="hero-ask-status">{{ askAnswer }}</p>
</div>
</div> </div>
<MentoAiAvatar class="hero-bot" :size="156" /> <MentoAiAvatar class="hero-bot" :size="156" />
@@ -333,16 +352,22 @@ function alertIcon(tone: string) {
color: #16a34a; color: #16a34a;
} }
.hero-ask-wrap {
display: grid;
gap: 0.45rem;
max-width: 520px;
width: 100%;
}
.hero-ask { .hero-ask {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.55rem; gap: 0.55rem;
max-width: 520px;
width: 100%; width: 100%;
background: #fff; background: #fff;
color: #0f172a; color: #0f172a;
border-radius: 999px; border-radius: 999px;
padding: 0.55rem 0.9rem; padding: 0.45rem 0.45rem 0.45rem 0.9rem;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1); box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1);
} }
@@ -374,6 +399,40 @@ function alertIcon(tone: string) {
flex-shrink: 0; flex-shrink: 0;
} }
.ask-send {
width: 34px;
height: 34px;
border: 0;
border-radius: 999px;
background: #14b8a6;
color: #fff;
cursor: pointer;
display: grid;
place-items: center;
flex-shrink: 0;
}
.ask-send:disabled {
opacity: 0.55;
cursor: wait;
}
.hero-ask-status {
margin: 0;
padding: 0.55rem 0.85rem;
border-radius: 14px;
background: rgba(255, 255, 255, 0.16);
color: #fff;
font-size: 0.82rem;
line-height: 1.7;
max-height: 7.5rem;
overflow: auto;
}
.hero-ask-status.err {
background: rgba(185, 28, 28, 0.28);
}
.hero-bot { .hero-bot {
position: relative; position: relative;
z-index: 1; z-index: 1;
+24 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money'; import { formatMoney } from '@/lib/money';
@@ -89,6 +89,10 @@ async function submitAsk(preset?: string) {
const { data } = await api.post<AskMentoResponse>('/ai/ask', { question: q }); const { data } = await api.post<AskMentoResponse>('/ai/ask', { question: q });
askAnswer.value = data.answer; askAnswer.value = data.answer;
askSources.value = data.sources ?? []; askSources.value = data.sources ?? [];
await nextTick();
document
.getElementById('home-ai-assistant')
?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (e) { } catch (e) {
if (axios.isAxiosError(e)) { if (axios.isAxiosError(e)) {
const msg = (e.response?.data as { message?: string | string[] })?.message; const msg = (e.response?.data as { message?: string | string[] })?.message;
@@ -101,7 +105,18 @@ async function submitAsk(preset?: string) {
} }
} }
function onAskHotkey(e: KeyboardEvent) {
if (!(e.metaKey || e.ctrlKey) || e.key.toLowerCase() !== 'k') return;
const tag = (e.target as HTMLElement | null)?.tagName;
if (tag === 'TEXTAREA' || (tag === 'INPUT' && (e.target as HTMLInputElement).id !== 'home-hero-ask')) {
return;
}
e.preventDefault();
document.getElementById('home-hero-ask')?.focus();
}
onMounted(async () => { onMounted(async () => {
window.addEventListener('keydown', onAskHotkey);
loading.value = true; loading.value = true;
try { try {
const [homeRes, actRes, boardRes] = await Promise.all([ const [homeRes, actRes, boardRes] = await Promise.all([
@@ -120,6 +135,10 @@ onMounted(async () => {
loading.value = false; loading.value = false;
} }
}); });
onUnmounted(() => {
window.removeEventListener('keydown', onAskHotkey);
});
</script> </script>
<template> <template>
@@ -129,6 +148,9 @@ onMounted(async () => {
:first-name="firstName" :first-name="firstName"
:user-initial="userInitial" :user-initial="userInitial"
:alerts="home?.alerts ?? []" :alerts="home?.alerts ?? []"
:asking="asking"
:ask-answer="askAnswer"
:ask-error="askError"
v-model:ask-query="askQuery" v-model:ask-query="askQuery"
@ask="submitAsk" @ask="submitAsk"
/> />
@@ -152,6 +174,7 @@ onMounted(async () => {
/> />
<HomeSourcesChart :sources="home?.sources ?? []" /> <HomeSourcesChart :sources="home?.sources ?? []" />
<HomeAiAssistant <HomeAiAssistant
id="home-ai-assistant"
v-model:ask-query="askQuery" v-model:ask-query="askQuery"
:asking="asking" :asking="asking"
:answer="askAnswer" :answer="askAnswer"