Edit the artificial intelligence of the Mentact assistant
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user