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,
ownerWhere,
} 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';
type CatalogItem = AskMentoSourceDto;
@@ -22,6 +31,14 @@ function clip(value: string | null | undefined, max = 280) {
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()
export class AiAskService {
constructor(
@@ -39,12 +56,14 @@ export class AiAskService {
answer?: string;
sources?: Array<{ type?: string; id?: string; label?: string }>;
}>(
`You are ${brand.name}, a Persian CRM sales assistant.
Answer ONLY from the CRM context. If the context is insufficient, say so in Persian.
`You are ${brand.name}, a Persian CRM assistant for this user's account.
"محیط" 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.
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.
Write the answer in Persian, concise, with dates when present.`,
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 exact numbers and names when present.`,
`Question:\n${question.trim()}\n\nCRM context:\n${context}`,
);
@@ -102,11 +121,108 @@ Rules:
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({
where: { id: actor.organizationId },
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({
where: {
organizationId: actor.organizationId,
@@ -176,11 +292,35 @@ Rules:
org?.businessField && isBusinessField(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[] = [
`Today: ${new Date().toISOString()}`,
`Organization: ${org?.name ?? '—'} | business field: ${field}`,
personId ? `Focus personId: ${personId}` : 'Scope: current user records',
`Today: ${now.toISOString()}`,
'Workspace facts (authoritative):',
`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) {
const person = await this.prisma.person.findFirst({
@@ -37,7 +37,7 @@ function pick(label: string) {
</script>
<template>
<article class="panel">
<article id="home-ai-assistant" class="panel">
<header class="panel-head">
<h2>
<span class="spark"><UiIcon name="sparkle" :size="15" /></span>
@@ -62,6 +62,9 @@ function pick(label: string) {
</div>
<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">
<p>{{ answer }}</p>
<div v-if="sources?.length" class="sources">
+71 -12
View File
@@ -12,6 +12,9 @@ const props = defineProps<{
userInitial: string;
alerts: HomeDashboardAlertDto[];
askQuery: string;
asking?: boolean;
askAnswer?: string;
askError?: string;
}>();
const emit = defineEmits<{
@@ -77,6 +80,12 @@ function alertIcon(tone: string) {
if (tone === 'success') return 'trend' as const;
return 'sparkle' as const;
}
const shortcutLabel = computed(() =>
typeof navigator !== 'undefined' && /Mac|iPhone|iPad/i.test(navigator.platform)
? '⌘K'
: 'Ctrl+K',
);
</script>
<template>
@@ -114,16 +123,26 @@ function alertIcon(tone: string) {
</RouterLink>
</div>
<form class="hero-ask" @submit.prevent="emit('ask')">
<UiIcon name="sparkle" :size="18" class="ask-ico" />
<input
:value="askQuery"
type="search"
:placeholder="brand.askPlaceholderFa"
@input="emit('update:askQuery', ($event.target as HTMLInputElement).value)"
/>
<kbd>⌘K</kbd>
</form>
<div class="hero-ask-wrap">
<form class="hero-ask" @submit.prevent="emit('ask')">
<UiIcon name="sparkle" :size="18" class="ask-ico" />
<input
id="home-hero-ask"
:value="askQuery"
type="search"
:placeholder="brand.askPlaceholderFa"
:disabled="asking"
@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>
<MentoAiAvatar class="hero-bot" :size="156" />
@@ -333,16 +352,22 @@ function alertIcon(tone: string) {
color: #16a34a;
}
.hero-ask-wrap {
display: grid;
gap: 0.45rem;
max-width: 520px;
width: 100%;
}
.hero-ask {
display: flex;
align-items: center;
gap: 0.55rem;
max-width: 520px;
width: 100%;
background: #fff;
color: #0f172a;
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);
}
@@ -374,6 +399,40 @@ function alertIcon(tone: string) {
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 {
position: relative;
z-index: 1;
+24 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { api } from '@/lib/api';
import { formatMoney } from '@/lib/money';
@@ -89,6 +89,10 @@ async function submitAsk(preset?: string) {
const { data } = await api.post<AskMentoResponse>('/ai/ask', { question: q });
askAnswer.value = data.answer;
askSources.value = data.sources ?? [];
await nextTick();
document
.getElementById('home-ai-assistant')
?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
} catch (e) {
if (axios.isAxiosError(e)) {
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 () => {
window.addEventListener('keydown', onAskHotkey);
loading.value = true;
try {
const [homeRes, actRes, boardRes] = await Promise.all([
@@ -120,6 +135,10 @@ onMounted(async () => {
loading.value = false;
}
});
onUnmounted(() => {
window.removeEventListener('keydown', onAskHotkey);
});
</script>
<template>
@@ -129,6 +148,9 @@ onMounted(async () => {
:first-name="firstName"
:user-initial="userInitial"
:alerts="home?.alerts ?? []"
:asking="asking"
:ask-answer="askAnswer"
:ask-error="askError"
v-model:ask-query="askQuery"
@ask="submitAsk"
/>
@@ -152,6 +174,7 @@ onMounted(async () => {
/>
<HomeSourcesChart :sources="home?.sources ?? []" />
<HomeAiAssistant
id="home-ai-assistant"
v-model:ask-query="askQuery"
:asking="asking"
:answer="askAnswer"