From ae21d9bbc772b5ebca4b741e8c2b87775e5c97d0 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 30 Sep 2026 16:30:41 +0330 Subject: [PATCH] Addition of an AI chat assistant and a 100MB file upload limit. --- apps/api/src/ai/ai-ask.service.ts | 13 +++- apps/api/src/ai/ai.controller.ts | 2 +- apps/api/src/ai/dto.ts | 5 ++ apps/web/src/components/AskMentoPanel.vue | 29 ++++++++ apps/web/src/lib/chatMessageFormat.ts | 7 +- apps/web/src/lib/useOrgChat.ts | 4 +- apps/web/src/views/ConversationsView.vue | 90 +++++++++++++++++++++-- packages/shared/src/index.ts | 2 + 8 files changed, 139 insertions(+), 13 deletions(-) diff --git a/apps/api/src/ai/ai-ask.service.ts b/apps/api/src/ai/ai-ask.service.ts index d6511d1..7c071bd 100644 --- a/apps/api/src/ai/ai-ask.service.ts +++ b/apps/api/src/ai/ai-ask.service.ts @@ -134,8 +134,12 @@ export class AiAskService { actor: Actor, question: string, personId?: string, + chatContext?: string, ): Promise { const { context, catalog } = await this.buildContext(actor, personId); + const chatBlock = String(chatContext || '') + .trim() + .slice(0, 3500); const raw = await this.ai.completeJson<{ answer?: string; sources?: Array<{ type?: string; id?: string; label?: string }>; @@ -145,12 +149,17 @@ export class AiAskService { "مشتری" / "مشتری جدید" means a Person (contact) record. Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday–Friday), this_month (current Jalali month). Workspace facts and Period stats are complete and authoritative for counts of customers, companies, activities, deals, won deals, invoices, quotes, products, notes, and tickets. -Answer ONLY from the CRM context. Never say a listed count is unavailable. +Answer ONLY from the CRM context for CRM facts. Never say a listed count is unavailable. +If a "Recent conversation" block is provided, use it only to understand the user's question and tone; do not invent CRM facts from chat alone. 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. Workspace/period facts do not need sources; when naming customers, prefer person sources. 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}${ + chatBlock + ? `\n\nRecent conversation (for context only, not CRM facts):\n${chatBlock}` + : '' + }`, ); const allowed = new Map(catalog.map((item) => [item.id, item])); diff --git a/apps/api/src/ai/ai.controller.ts b/apps/api/src/ai/ai.controller.ts index 1234719..ed88c34 100644 --- a/apps/api/src/ai/ai.controller.ts +++ b/apps/api/src/ai/ai.controller.ts @@ -26,7 +26,7 @@ export class AiController { @Post('ask') askMento(@CurrentUser() user: Actor, @Body() dto: AskMentoDto) { - return this.ask.ask(user, dto.question, dto.personId); + return this.ask.ask(user, dto.question, dto.personId, dto.chatContext); } @Get('people/:personId/summary') diff --git a/apps/api/src/ai/dto.ts b/apps/api/src/ai/dto.ts index 765dd2d..7f3a8eb 100644 --- a/apps/api/src/ai/dto.ts +++ b/apps/api/src/ai/dto.ts @@ -16,4 +16,9 @@ export class AskMentoDto { @IsOptional() @IsString() personId?: string; + + @IsOptional() + @IsString() + @MaxLength(4000) + chatContext?: string; } diff --git a/apps/web/src/components/AskMentoPanel.vue b/apps/web/src/components/AskMentoPanel.vue index 7d9392e..ad08566 100644 --- a/apps/web/src/components/AskMentoPanel.vue +++ b/apps/web/src/components/AskMentoPanel.vue @@ -9,6 +9,14 @@ import { brand, type AskMentoResponse, type AskMentoSourceDto } from '@mento/sha const props = defineProps<{ personId?: string; placeholder?: string; + /** زمینهٔ گفتگو برای پاسخ مرتبط‌تر داخل چت */ + chatContext?: string; + /** دکمهٔ درج پاسخ در پیش‌نویس پیام */ + allowInsert?: boolean; +}>(); + +const emit = defineEmits<{ + insertAnswer: [text: string]; }>(); const question = ref(''); @@ -33,6 +41,7 @@ async function submit() { const { data } = await api.post('/ai/ask', { question: q, personId: props.personId || undefined, + chatContext: props.chatContext?.trim() || undefined, }); result.value = data; } catch (e) { @@ -46,6 +55,12 @@ async function submit() { asking.value = false; } } + +function insertAnswer() { + const text = result.value?.answer?.trim(); + if (!text) return; + emit('insertAnswer', text); +} @@ -153,6 +176,12 @@ button:disabled { padding: 0.18rem 0.55rem; } +.insert-btn { + margin-top: 0.65rem; + width: 100%; + background: #0f766e; +} + .error { margin: 0; color: #b91c1c; diff --git a/apps/web/src/lib/chatMessageFormat.ts b/apps/web/src/lib/chatMessageFormat.ts index 1e03306..55c343f 100644 --- a/apps/web/src/lib/chatMessageFormat.ts +++ b/apps/web/src/lib/chatMessageFormat.ts @@ -12,7 +12,8 @@ export type ChatTextSeg = { const URL_RE = /https?:\/\/[^\s<>"'`]+|(?:www\.)[^\s<>"'`]+/gi; -const CRM_RE = /mento:\/\/(person|deal|ticket)\/([A-Za-z0-9_-]+)/gi; +/** لینک عمیق CRM — طرح فعلی montact؛ mento برای پیام‌های قدیمی پشتیبانی می‌شود */ +const CRM_RE = /(?:montact|mento):\/\/(person|deal|ticket)\/([A-Za-z0-9_-]+)/gi; const BOLD_RE = /\*([^*\n]+)\*/g; const ITALIC_RE = /_([^_\n]+)_/g; const LIST_LINE_RE = /^\s*[-•]\s+/; @@ -145,7 +146,7 @@ function applyHighlight(parts: ChatTextSeg[], q: string): ChatTextSeg[] { } /** - * فرمت سبک شبیه تلگرام: *پررنگ*، _ایتالیک_، لیست با - ، لینک http، mento://… + * فرمت سبک شبیه تلگرام: *پررنگ*، _ایتالیک_، لیست با - ، لینک http، montact://… */ export function parseChatMessageText( content: string, @@ -191,6 +192,6 @@ export function insertCrmLink( id: string, title?: string, ): string { - const link = `mento://${kind}/${id}`; + const link = `montact://${kind}/${id}`; return title ? `${title} ${link}` : link; } diff --git a/apps/web/src/lib/useOrgChat.ts b/apps/web/src/lib/useOrgChat.ts index d8cf352..04ffb58 100644 --- a/apps/web/src/lib/useOrgChat.ts +++ b/apps/web/src/lib/useOrgChat.ts @@ -136,7 +136,7 @@ function shouldCountUnread(roomId: string, content: string, fromSelf: boolean): } const IMAGE_MAX_EDGE = 1600; const IMAGE_WEBP_QUALITY = 0.82; -const MAX_UPLOAD_BYTES = 40 * 1024 * 1024; +const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; /** Synced hard-delete marker (legacy edit attempts). */ const HARD_DELETE_MARKER = '__MENTO_DEL__'; /** Realtime hide command delivered as a normal text message (external WS delivers text reliably). */ @@ -1497,7 +1497,7 @@ export function useOrgChat() { ) { if (!file) return; if (file.size > MAX_UPLOAD_BYTES) { - throw new Error('حجم فایل بیش از حد مجاز است (۴۰ مگابایت)'); + throw new Error('حجم فایل بیش از حد مجاز است (۱۰۰ مگابایت)'); } const session = await ensureSession(); diff --git a/apps/web/src/views/ConversationsView.vue b/apps/web/src/views/ConversationsView.vue index a14f338..b371436 100644 --- a/apps/web/src/views/ConversationsView.vue +++ b/apps/web/src/views/ConversationsView.vue @@ -35,13 +35,15 @@ import { parseChatMessageText, } from '@/lib/chatMessageFormat'; import { useAuthStore } from '@/stores/auth'; +import AskMentoPanel from '@/components/AskMentoPanel.vue'; import UiIcon from '@/components/UiIcon.vue'; import UserAvatar from '@/components/UserAvatar.vue'; -import type { - PaginatedResponse, - PersonDto, - PipelineBoardDto, - TicketDto, +import { + brand, + type PaginatedResponse, + type PersonDto, + type PipelineBoardDto, + type TicketDto, } from '@mento/shared'; type PendingAttach = { @@ -139,6 +141,8 @@ const crmLinkKind = ref<'person' | 'deal' | 'ticket'>('person'); const crmLinkQuery = ref(''); const crmLinkResults = ref>([]); const crmLinkBusy = ref(false); +const showAiAssist = ref(false); +const aiChatContext = ref(''); const chatSearchOpen = ref(false); const chatSearchQuery = ref(''); const chatSearchIndex = ref(0); @@ -774,6 +778,55 @@ function openCrmLinkPicker() { void searchCrmLinks(); } +function messageSenderLabel(msg: ChatMessage) { + if (isMineMessage(msg)) return 'شما'; + return ( + peers.value.find((p) => p.chatUserId === msg.senderId)?.fullName || + peers.value.find((p) => p.id === msg.senderId)?.fullName || + 'همکار' + ); +} + +function buildChatContext(): string { + const room = activeRoom.value; + const title = room ? roomTitle(room) : 'گفتگو'; + const recent = messages.value.slice(-20); + const lines = recent.map((m) => { + const who = messageSenderLabel(m); + if (m.deleted) return `${who}: [حذف‌شده]`; + if (m.type === 'text') { + return `${who}: ${(m.content || '').replace(/\s+/g, ' ').trim().slice(0, 280)}`; + } + return `${who}: ${previewLabelForMessage(m)}`; + }); + return `گفتگو: ${title}\n${lines.join('\n')}`.slice(0, 3500); +} + +function openAiAssist() { + aiChatContext.value = buildChatContext(); + showAiAssist.value = true; + emojiOpen.value = false; +} + +function insertAiAnswer(text: string) { + const chunk = text.trim(); + if (!chunk) return; + const el = draftInputEl.value; + const value = draft.value; + const start = el?.selectionStart ?? value.length; + const end = el?.selectionEnd ?? value.length; + const pad = + start > 0 && value.slice(0, start).length && !/\s$/.test(value.slice(0, start)) + ? '\n' + : ''; + draft.value = value.slice(0, start) + pad + chunk + value.slice(end); + showAiAssist.value = false; + nextTick(() => { + draftInputEl.value?.focus(); + onDraftInput(); + }); +} + function insertCrmEntity(item: { id: string; title: string }) { const chunk = insertCrmLink(crmLinkKind.value, item.id, item.title); const el = draftInputEl.value; @@ -2264,6 +2317,14 @@ onUnmounted(() => { +
{
+ + + +