Addition of an AI chat assistant and a 100MB file upload limit.

This commit is contained in:
2026-09-30 16:30:41 +03:30
parent e94600a02e
commit ae21d9bbc7
8 changed files with 139 additions and 13 deletions
+11 -2
View File
@@ -134,8 +134,12 @@ export class AiAskService {
actor: Actor,
question: string,
personId?: string,
chatContext?: string,
): Promise<AskMentoResponse> {
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]));
+1 -1
View File
@@ -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')
+5
View File
@@ -16,4 +16,9 @@ export class AskMentoDto {
@IsOptional()
@IsString()
personId?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
chatContext?: string;
}
+29
View File
@@ -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<AskMentoResponse>('/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);
}
</script>
<template>
@@ -73,6 +88,14 @@ async function submit() {
{{ source.label }}
</RouterLink>
</div>
<button
v-if="allowInsert && result.answer.trim()"
type="button"
class="insert-btn"
@click="insertAnswer"
>
درج در پیام
</button>
</div>
</section>
</template>
@@ -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;
+4 -3
View File
@@ -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;
}
+2 -2
View File
@@ -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();
+85 -5
View File
@@ -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<Array<{ id: string; title: string }>>([]);
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(() => {
<button type="button" class="fmt-btn" title="لینک CRM" @click="openCrmLinkPicker">
<UiIcon name="briefcase" :size="14" />
</button>
<button
type="button"
class="fmt-btn fmt-ai"
:title="brand.askLabelFa"
@click="openAiAssist"
>
<UiIcon name="sparkle" :size="14" />
</button>
</div>
<div class="composer-row">
<input
@@ -2704,6 +2765,22 @@ onUnmounted(() => {
</div>
</dialog>
<dialog v-if="showAiAssist" class="modal" open @click.self="showAiAssist = false">
<div class="modal-card wide ai-assist-sheet">
<h3>{{ brand.askLabelFa }}</h3>
<p class="hint">با توجه به CRM و چند پیام اخیر این گفتگو پاسخ می‌دهد؛ می‌توانید پاسخ را در پیش‌نویس درج کنید.</p>
<AskMentoPanel
:chat-context="aiChatContext"
allow-insert
:placeholder="`${brand.askLabelFa} درباره این گفتگو…`"
@insert-answer="insertAiAnswer"
/>
<div class="modal-actions">
<button type="button" @click="showAiAssist = false">بستن</button>
</div>
</div>
</dialog>
<dialog v-if="showMembers" class="modal" open @click.self="showMembers = false">
<div class="modal-card wide members-sheet">
<h3>اعضای {{ activeRoom ? roomTitle(activeRoom) : 'گفتگو' }}</h3>
@@ -3136,6 +3213,9 @@ onUnmounted(() => {
font-weight: 600;
font-size: 0.95rem;
}
.fmt-btn.fmt-ai {
color: #0f766e;
}
.msg-text em {
font-style: italic;
}