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,
|
actor: Actor,
|
||||||
question: string,
|
question: string,
|
||||||
personId?: string,
|
personId?: string,
|
||||||
|
chatContext?: string,
|
||||||
): Promise<AskMentoResponse> {
|
): Promise<AskMentoResponse> {
|
||||||
const { context, catalog } = await this.buildContext(actor, personId);
|
const { context, catalog } = await this.buildContext(actor, personId);
|
||||||
|
const chatBlock = String(chatContext || '')
|
||||||
|
.trim()
|
||||||
|
.slice(0, 3500);
|
||||||
const raw = await this.ai.completeJson<{
|
const raw = await this.ai.completeJson<{
|
||||||
answer?: string;
|
answer?: string;
|
||||||
sources?: Array<{ type?: string; id?: string; label?: string }>;
|
sources?: Array<{ type?: string; id?: string; label?: string }>;
|
||||||
@@ -145,12 +149,17 @@ export class AiAskService {
|
|||||||
"مشتری" / "مشتری جدید" means a Person (contact) record.
|
"مشتری" / "مشتری جدید" means a Person (contact) record.
|
||||||
Period stats use Asia/Tehran calendar: today, yesterday, this_week (Saturday–Friday), this_month (current Jalali month).
|
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.
|
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.
|
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. Workspace/period facts do not need sources; when naming customers, prefer person sources.
|
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.`,
|
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]));
|
const allowed = new Map(catalog.map((item) => [item.id, item]));
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export class AiController {
|
|||||||
|
|
||||||
@Post('ask')
|
@Post('ask')
|
||||||
askMento(@CurrentUser() user: Actor, @Body() dto: AskMentoDto) {
|
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')
|
@Get('people/:personId/summary')
|
||||||
|
|||||||
@@ -16,4 +16,9 @@ export class AskMentoDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
personId?: string;
|
personId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(4000)
|
||||||
|
chatContext?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import { brand, type AskMentoResponse, type AskMentoSourceDto } from '@mento/sha
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
personId?: string;
|
personId?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
/** زمینهٔ گفتگو برای پاسخ مرتبطتر داخل چت */
|
||||||
|
chatContext?: string;
|
||||||
|
/** دکمهٔ درج پاسخ در پیشنویس پیام */
|
||||||
|
allowInsert?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
insertAnswer: [text: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const question = ref('');
|
const question = ref('');
|
||||||
@@ -33,6 +41,7 @@ async function submit() {
|
|||||||
const { data } = await api.post<AskMentoResponse>('/ai/ask', {
|
const { data } = await api.post<AskMentoResponse>('/ai/ask', {
|
||||||
question: q,
|
question: q,
|
||||||
personId: props.personId || undefined,
|
personId: props.personId || undefined,
|
||||||
|
chatContext: props.chatContext?.trim() || undefined,
|
||||||
});
|
});
|
||||||
result.value = data;
|
result.value = data;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -46,6 +55,12 @@ async function submit() {
|
|||||||
asking.value = false;
|
asking.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function insertAnswer() {
|
||||||
|
const text = result.value?.answer?.trim();
|
||||||
|
if (!text) return;
|
||||||
|
emit('insertAnswer', text);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -73,6 +88,14 @@ async function submit() {
|
|||||||
{{ source.label }}
|
{{ source.label }}
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="allowInsert && result.answer.trim()"
|
||||||
|
type="button"
|
||||||
|
class="insert-btn"
|
||||||
|
@click="insertAnswer"
|
||||||
|
>
|
||||||
|
درج در پیام
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -153,6 +176,12 @@ button:disabled {
|
|||||||
padding: 0.18rem 0.55rem;
|
padding: 0.18rem 0.55rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.insert-btn {
|
||||||
|
margin-top: 0.65rem;
|
||||||
|
width: 100%;
|
||||||
|
background: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #b91c1c;
|
color: #b91c1c;
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ export type ChatTextSeg = {
|
|||||||
|
|
||||||
const URL_RE =
|
const URL_RE =
|
||||||
/https?:\/\/[^\s<>"'`]+|(?:www\.)[^\s<>"'`]+/gi;
|
/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 BOLD_RE = /\*([^*\n]+)\*/g;
|
||||||
const ITALIC_RE = /_([^_\n]+)_/g;
|
const ITALIC_RE = /_([^_\n]+)_/g;
|
||||||
const LIST_LINE_RE = /^\s*[-•]\s+/;
|
const LIST_LINE_RE = /^\s*[-•]\s+/;
|
||||||
@@ -145,7 +146,7 @@ function applyHighlight(parts: ChatTextSeg[], q: string): ChatTextSeg[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* فرمت سبک شبیه تلگرام: *پررنگ*، _ایتالیک_، لیست با - ، لینک http، mento://…
|
* فرمت سبک شبیه تلگرام: *پررنگ*، _ایتالیک_، لیست با - ، لینک http، montact://…
|
||||||
*/
|
*/
|
||||||
export function parseChatMessageText(
|
export function parseChatMessageText(
|
||||||
content: string,
|
content: string,
|
||||||
@@ -191,6 +192,6 @@ export function insertCrmLink(
|
|||||||
id: string,
|
id: string,
|
||||||
title?: string,
|
title?: string,
|
||||||
): string {
|
): string {
|
||||||
const link = `mento://${kind}/${id}`;
|
const link = `montact://${kind}/${id}`;
|
||||||
return title ? `${title} ${link}` : link;
|
return title ? `${title} ${link}` : link;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ function shouldCountUnread(roomId: string, content: string, fromSelf: boolean):
|
|||||||
}
|
}
|
||||||
const IMAGE_MAX_EDGE = 1600;
|
const IMAGE_MAX_EDGE = 1600;
|
||||||
const IMAGE_WEBP_QUALITY = 0.82;
|
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). */
|
/** Synced hard-delete marker (legacy edit attempts). */
|
||||||
const HARD_DELETE_MARKER = '__MENTO_DEL__';
|
const HARD_DELETE_MARKER = '__MENTO_DEL__';
|
||||||
/** Realtime hide command delivered as a normal text message (external WS delivers text reliably). */
|
/** 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) return;
|
||||||
if (file.size > MAX_UPLOAD_BYTES) {
|
if (file.size > MAX_UPLOAD_BYTES) {
|
||||||
throw new Error('حجم فایل بیش از حد مجاز است (۴۰ مگابایت)');
|
throw new Error('حجم فایل بیش از حد مجاز است (۱۰۰ مگابایت)');
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await ensureSession();
|
const session = await ensureSession();
|
||||||
|
|||||||
@@ -35,13 +35,15 @@ import {
|
|||||||
parseChatMessageText,
|
parseChatMessageText,
|
||||||
} from '@/lib/chatMessageFormat';
|
} from '@/lib/chatMessageFormat';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import AskMentoPanel from '@/components/AskMentoPanel.vue';
|
||||||
import UiIcon from '@/components/UiIcon.vue';
|
import UiIcon from '@/components/UiIcon.vue';
|
||||||
import UserAvatar from '@/components/UserAvatar.vue';
|
import UserAvatar from '@/components/UserAvatar.vue';
|
||||||
import type {
|
import {
|
||||||
PaginatedResponse,
|
brand,
|
||||||
PersonDto,
|
type PaginatedResponse,
|
||||||
PipelineBoardDto,
|
type PersonDto,
|
||||||
TicketDto,
|
type PipelineBoardDto,
|
||||||
|
type TicketDto,
|
||||||
} from '@mento/shared';
|
} from '@mento/shared';
|
||||||
|
|
||||||
type PendingAttach = {
|
type PendingAttach = {
|
||||||
@@ -139,6 +141,8 @@ const crmLinkKind = ref<'person' | 'deal' | 'ticket'>('person');
|
|||||||
const crmLinkQuery = ref('');
|
const crmLinkQuery = ref('');
|
||||||
const crmLinkResults = ref<Array<{ id: string; title: string }>>([]);
|
const crmLinkResults = ref<Array<{ id: string; title: string }>>([]);
|
||||||
const crmLinkBusy = ref(false);
|
const crmLinkBusy = ref(false);
|
||||||
|
const showAiAssist = ref(false);
|
||||||
|
const aiChatContext = ref('');
|
||||||
const chatSearchOpen = ref(false);
|
const chatSearchOpen = ref(false);
|
||||||
const chatSearchQuery = ref('');
|
const chatSearchQuery = ref('');
|
||||||
const chatSearchIndex = ref(0);
|
const chatSearchIndex = ref(0);
|
||||||
@@ -774,6 +778,55 @@ function openCrmLinkPicker() {
|
|||||||
void searchCrmLinks();
|
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 }) {
|
function insertCrmEntity(item: { id: string; title: string }) {
|
||||||
const chunk = insertCrmLink(crmLinkKind.value, item.id, item.title);
|
const chunk = insertCrmLink(crmLinkKind.value, item.id, item.title);
|
||||||
const el = draftInputEl.value;
|
const el = draftInputEl.value;
|
||||||
@@ -2264,6 +2317,14 @@ onUnmounted(() => {
|
|||||||
<button type="button" class="fmt-btn" title="لینک CRM" @click="openCrmLinkPicker">
|
<button type="button" class="fmt-btn" title="لینک CRM" @click="openCrmLinkPicker">
|
||||||
<UiIcon name="briefcase" :size="14" />
|
<UiIcon name="briefcase" :size="14" />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fmt-btn fmt-ai"
|
||||||
|
:title="brand.askLabelFa"
|
||||||
|
@click="openAiAssist"
|
||||||
|
>
|
||||||
|
<UiIcon name="sparkle" :size="14" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="composer-row">
|
<div class="composer-row">
|
||||||
<input
|
<input
|
||||||
@@ -2704,6 +2765,22 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</dialog>
|
</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">
|
<dialog v-if="showMembers" class="modal" open @click.self="showMembers = false">
|
||||||
<div class="modal-card wide members-sheet">
|
<div class="modal-card wide members-sheet">
|
||||||
<h3>اعضای {{ activeRoom ? roomTitle(activeRoom) : 'گفتگو' }}</h3>
|
<h3>اعضای {{ activeRoom ? roomTitle(activeRoom) : 'گفتگو' }}</h3>
|
||||||
@@ -3136,6 +3213,9 @@ onUnmounted(() => {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
|
.fmt-btn.fmt-ai {
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
.msg-text em {
|
.msg-text em {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1153,6 +1153,8 @@ export interface AskMentoSourceDto {
|
|||||||
export interface AskMentoRequest {
|
export interface AskMentoRequest {
|
||||||
question: string;
|
question: string;
|
||||||
personId?: string;
|
personId?: string;
|
||||||
|
/** زمینهٔ اختیاری گفتگو برای پاسخ مرتبطتر داخل چت */
|
||||||
|
chatContext?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AskMentoResponse {
|
export interface AskMentoResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user