Addition of an AI chat assistant and a 100MB file upload limit.
This commit is contained in:
@@ -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]));
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -16,4 +16,9 @@ export class AskMentoDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
personId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(4000)
|
||||
chatContext?: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user