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