Add Smart Notes AI analysis and Mento Memory with user approval.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "NoteAiSuggestionStatus" AS ENUM ('pending', 'applied', 'dismissed', 'failed');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "MemorySourceType" AS ENUM ('note', 'manual');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "NoteAiSuggestion" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"noteId" TEXT NOT NULL,
|
||||
"status" "NoteAiSuggestionStatus" NOT NULL DEFAULT 'pending',
|
||||
"payload" JSONB,
|
||||
"errorMessage" TEXT,
|
||||
"analyzedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "NoteAiSuggestion_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MemoryItem" (
|
||||
"id" TEXT NOT NULL,
|
||||
"organizationId" TEXT NOT NULL,
|
||||
"personId" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"sourceType" "MemorySourceType" NOT NULL DEFAULT 'manual',
|
||||
"sourceId" TEXT,
|
||||
"confirmedById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "MemoryItem_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "NoteAiSuggestion_noteId_key" ON "NoteAiSuggestion"("noteId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "NoteAiSuggestion_organizationId_status_idx" ON "NoteAiSuggestion"("organizationId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MemoryItem_organizationId_personId_updatedAt_idx" ON "MemoryItem"("organizationId", "personId", "updatedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MemoryItem_personId_idx" ON "MemoryItem"("personId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "MemoryItem_confirmedById_idx" ON "MemoryItem"("confirmedById");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NoteAiSuggestion" ADD CONSTRAINT "NoteAiSuggestion_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "NoteAiSuggestion" ADD CONSTRAINT "NoteAiSuggestion_noteId_fkey" FOREIGN KEY ("noteId") REFERENCES "Note"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MemoryItem" ADD CONSTRAINT "MemoryItem_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MemoryItem" ADD CONSTRAINT "MemoryItem_personId_fkey" FOREIGN KEY ("personId") REFERENCES "Person"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "MemoryItem" ADD CONSTRAINT "MemoryItem_confirmedById_fkey" FOREIGN KEY ("confirmedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -103,6 +103,18 @@ enum AutomationAction {
|
||||
add_loyalty_points
|
||||
}
|
||||
|
||||
enum NoteAiSuggestionStatus {
|
||||
pending
|
||||
applied
|
||||
dismissed
|
||||
failed
|
||||
}
|
||||
|
||||
enum MemorySourceType {
|
||||
note
|
||||
manual
|
||||
}
|
||||
|
||||
model Organization {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
@@ -134,6 +146,8 @@ model Organization {
|
||||
projectCards ProjectCard[]
|
||||
loyaltyTiers LoyaltyTier[]
|
||||
automationRules AutomationRule[]
|
||||
memoryItems MemoryItem[]
|
||||
noteAiSuggestions NoteAiSuggestion[]
|
||||
}
|
||||
|
||||
model User {
|
||||
@@ -158,6 +172,7 @@ model User {
|
||||
assignedTickets Ticket[] @relation("TicketAssignee")
|
||||
reportedTickets Ticket[] @relation("TicketReporter")
|
||||
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
|
||||
confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -223,6 +238,7 @@ model Person {
|
||||
owner User? @relation("PersonOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||
tickets Ticket[]
|
||||
projectCards ProjectCard[]
|
||||
memoryItems MemoryItem[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -359,6 +375,7 @@ model Note {
|
||||
authorId String?
|
||||
author User? @relation("NoteAuthor", fields: [authorId], references: [id], onDelete: SetNull)
|
||||
attachments NoteAttachment[]
|
||||
aiSuggestion NoteAiSuggestion?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -390,6 +407,41 @@ model NoteAttachment {
|
||||
@@index([uploadedById])
|
||||
}
|
||||
|
||||
model NoteAiSuggestion {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
noteId String @unique
|
||||
note Note @relation(fields: [noteId], references: [id], onDelete: Cascade)
|
||||
status NoteAiSuggestionStatus @default(pending)
|
||||
payload Json?
|
||||
errorMessage String?
|
||||
analyzedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, status])
|
||||
}
|
||||
|
||||
model MemoryItem {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
|
||||
personId String
|
||||
person Person @relation(fields: [personId], references: [id], onDelete: Cascade)
|
||||
content String
|
||||
sourceType MemorySourceType @default(manual)
|
||||
sourceId String?
|
||||
confirmedById String?
|
||||
confirmedBy User? @relation("MemoryConfirmedBy", fields: [confirmedById], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([organizationId, personId, updatedAt])
|
||||
@@index([personId])
|
||||
@@index([confirmedById])
|
||||
}
|
||||
|
||||
model CustomFieldDefinition {
|
||||
id String @id @default(cuid())
|
||||
organizationId String
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AiService } from './ai.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [AiService],
|
||||
exports: [AiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
@@ -0,0 +1,154 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export type NoteAiExtract = {
|
||||
nextAction: {
|
||||
title: string;
|
||||
description?: string;
|
||||
dueAt?: string | null;
|
||||
} | null;
|
||||
memoryItems: Array<{ content: string; category?: string }>;
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AiService {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return (
|
||||
this.config.get<string>('AI_ENABLED') === 'true' &&
|
||||
Boolean(this.config.get<string>('AI_API_KEY')?.trim())
|
||||
);
|
||||
}
|
||||
|
||||
async extractFromNote(input: {
|
||||
body: string;
|
||||
title?: string | null;
|
||||
noteType?: string;
|
||||
personName?: string | null;
|
||||
}): Promise<NoteAiExtract> {
|
||||
if (!this.isEnabled()) {
|
||||
throw new ServiceUnavailableException('هوش مصنوعی فعال نیست');
|
||||
}
|
||||
|
||||
const model = this.config.get<string>('AI_MODEL')?.trim() || 'gpt-4o-mini';
|
||||
const baseUrl =
|
||||
this.config.get<string>('AI_BASE_URL')?.trim() ||
|
||||
'https://api.openai.com/v1';
|
||||
const apiKey = this.config.get<string>('AI_API_KEY')!.trim();
|
||||
const timeoutMs = Number(this.config.get<string>('AI_TIMEOUT_MS') || 45000);
|
||||
|
||||
const system = `You analyze CRM notes in Persian/Farsi and extract structured data.
|
||||
Return ONLY valid JSON with this exact shape:
|
||||
{
|
||||
"nextAction": { "title": string, "description"?: string, "dueAt"?: string|null } | null,
|
||||
"memoryItems": [{ "content": string, "category"?: string }],
|
||||
"summary"?: string
|
||||
}
|
||||
Rules:
|
||||
- nextAction: one concrete follow-up task if the note implies action; otherwise null.
|
||||
- dueAt: ISO 8601 date-time if a deadline is mentioned; otherwise null.
|
||||
- memoryItems: durable facts about the customer (preferences, budget, decisions, family, objections) — NOT tasks.
|
||||
- Keep text in Persian when the note is Persian.
|
||||
- Do not invent facts not supported by the note.
|
||||
- Maximum 5 memory items.`;
|
||||
|
||||
const userParts = [
|
||||
input.title ? `Title: ${input.title}` : null,
|
||||
input.noteType ? `Type: ${input.noteType}` : null,
|
||||
input.personName ? `Customer: ${input.personName}` : null,
|
||||
`Note:\n${input.body.trim()}`,
|
||||
].filter(Boolean);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${baseUrl.replace(/\/$/, '')}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
temperature: 0.2,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: system },
|
||||
{ role: 'user', content: userParts.join('\n') },
|
||||
],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new ServiceUnavailableException(
|
||||
`خطا در ارتباط با مدل هوش مصنوعی (${res.status})${text ? `: ${text.slice(0, 200)}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
choices?: Array<{ message?: { content?: string } }>;
|
||||
};
|
||||
const raw = data.choices?.[0]?.message?.content?.trim();
|
||||
if (!raw) {
|
||||
throw new BadRequestException('پاسخ مدل خالی بود');
|
||||
}
|
||||
|
||||
return this.normalizeExtract(JSON.parse(raw) as Partial<NoteAiExtract>);
|
||||
} catch (err) {
|
||||
if (err instanceof BadRequestException || err instanceof ServiceUnavailableException) {
|
||||
throw err;
|
||||
}
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
throw new ServiceUnavailableException('زمان پاسخ مدل هوش مصنوعی تمام شد');
|
||||
}
|
||||
throw new ServiceUnavailableException('تحلیل یادداشت با خطا مواجه شد');
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeExtract(raw: Partial<NoteAiExtract>): NoteAiExtract {
|
||||
const nextAction =
|
||||
raw.nextAction && typeof raw.nextAction === 'object'
|
||||
? {
|
||||
title: String(raw.nextAction.title ?? '').trim().slice(0, 200),
|
||||
description: raw.nextAction.description
|
||||
? String(raw.nextAction.description).trim().slice(0, 2000)
|
||||
: undefined,
|
||||
dueAt: raw.nextAction.dueAt
|
||||
? String(raw.nextAction.dueAt).trim()
|
||||
: null,
|
||||
}
|
||||
: null;
|
||||
|
||||
const memoryItems = Array.isArray(raw.memoryItems)
|
||||
? raw.memoryItems
|
||||
.map((item) => ({
|
||||
content: String(item?.content ?? '').trim().slice(0, 500),
|
||||
category: item?.category
|
||||
? String(item.category).trim().slice(0, 40)
|
||||
: undefined,
|
||||
}))
|
||||
.filter((item) => item.content.length > 0)
|
||||
.slice(0, 5)
|
||||
: [];
|
||||
|
||||
const cleanedNext =
|
||||
nextAction && nextAction.title.length > 0 ? nextAction : null;
|
||||
|
||||
return {
|
||||
nextAction: cleanedNext,
|
||||
memoryItems,
|
||||
summary: raw.summary ? String(raw.summary).trim().slice(0, 500) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import { ReportsModule } from './reports/reports.module';
|
||||
import { ProjectBoardsModule } from './project-boards/project-boards.module';
|
||||
import { LoyaltyModule } from './loyalty/loyalty.module';
|
||||
import { AutomationsModule } from './automations/automations.module';
|
||||
import { AiModule } from './ai/ai.module';
|
||||
import { MemoryModule } from './memory/memory.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -31,6 +33,7 @@ import { AutomationsModule } from './automations/automations.module';
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env', '../../.env'],
|
||||
}),
|
||||
AiModule,
|
||||
PrismaModule,
|
||||
StorageModule,
|
||||
HealthModule,
|
||||
@@ -55,6 +58,7 @@ import { AutomationsModule } from './automations/automations.module';
|
||||
ProjectBoardsModule,
|
||||
LoyaltyModule,
|
||||
AutomationsModule,
|
||||
MemoryModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class ListMemoryQueryDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
personId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class CreateMemoryItemDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
personId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(500)
|
||||
content!: string;
|
||||
}
|
||||
|
||||
export class UpdateMemoryItemDto {
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(500)
|
||||
content!: string;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import type { Actor } from '../common/record-scope';
|
||||
import {
|
||||
CreateMemoryItemDto,
|
||||
ListMemoryQueryDto,
|
||||
UpdateMemoryItemDto,
|
||||
} from './dto';
|
||||
import { MemoryService } from './memory.service';
|
||||
|
||||
@Controller('memory')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class MemoryController {
|
||||
constructor(private readonly memory: MemoryService) {}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: Actor, @Query() query: ListMemoryQueryDto) {
|
||||
return this.memory.list(
|
||||
user.organizationId,
|
||||
user,
|
||||
query.personId,
|
||||
query.page ?? 1,
|
||||
query.limit ?? 50,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: Actor, @Body() dto: CreateMemoryItemDto) {
|
||||
return this.memory.create(user.organizationId, user.userId, user, dto);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@CurrentUser() user: Actor,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateMemoryItemDto,
|
||||
) {
|
||||
return this.memory.update(user.organizationId, id, user, dto);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async remove(@CurrentUser() user: Actor, @Param('id') id: string) {
|
||||
await this.memory.remove(user.organizationId, id, user);
|
||||
return { ok: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MemoryController } from './memory.controller';
|
||||
import { MemoryService } from './memory.service';
|
||||
|
||||
@Module({
|
||||
controllers: [MemoryController],
|
||||
providers: [MemoryService],
|
||||
exports: [MemoryService],
|
||||
})
|
||||
export class MemoryModule {}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
CreateMemoryItemRequest,
|
||||
MemoryItemDto,
|
||||
PaginatedResponse,
|
||||
UpdateMemoryItemRequest,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { type Actor, ownerWhere } from '../common/record-scope';
|
||||
|
||||
@Injectable()
|
||||
export class MemoryService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list(
|
||||
organizationId: string,
|
||||
actor: Actor,
|
||||
personId: string,
|
||||
page = 1,
|
||||
limit = 50,
|
||||
): Promise<PaginatedResponse<MemoryItemDto>> {
|
||||
await this.assertPersonAccess(organizationId, actor, personId);
|
||||
|
||||
const where = { organizationId, personId };
|
||||
const [rows, total] = await this.prisma.$transaction([
|
||||
this.prisma.memoryItem.findMany({
|
||||
where,
|
||||
include: {
|
||||
confirmedBy: { select: { id: true, fullName: true } },
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.memoryItem.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: rows.map((row) => this.toDto(row)),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
};
|
||||
}
|
||||
|
||||
async create(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
actor: Actor,
|
||||
dto: CreateMemoryItemRequest,
|
||||
): Promise<MemoryItemDto> {
|
||||
await this.assertPersonAccess(organizationId, actor, dto.personId);
|
||||
|
||||
const row = await this.prisma.memoryItem.create({
|
||||
data: {
|
||||
organizationId,
|
||||
personId: dto.personId,
|
||||
content: dto.content.trim(),
|
||||
sourceType: 'manual',
|
||||
confirmedById: userId,
|
||||
},
|
||||
include: {
|
||||
confirmedBy: { select: { id: true, fullName: true } },
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async update(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: Actor,
|
||||
dto: UpdateMemoryItemRequest,
|
||||
): Promise<MemoryItemDto> {
|
||||
const existing = await this.findAccessible(organizationId, id, actor);
|
||||
const row = await this.prisma.memoryItem.update({
|
||||
where: { id: existing.id },
|
||||
data: { content: dto.content.trim() },
|
||||
include: {
|
||||
confirmedBy: { select: { id: true, fullName: true } },
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
async remove(organizationId: string, id: string, actor: Actor): Promise<void> {
|
||||
const existing = await this.findAccessible(organizationId, id, actor);
|
||||
await this.prisma.memoryItem.delete({ where: { id: existing.id } });
|
||||
}
|
||||
|
||||
async createFromNote(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
personId: string,
|
||||
noteId: string,
|
||||
content: string,
|
||||
): Promise<MemoryItemDto> {
|
||||
const row = await this.prisma.memoryItem.create({
|
||||
data: {
|
||||
organizationId,
|
||||
personId,
|
||||
content: content.trim(),
|
||||
sourceType: 'note',
|
||||
sourceId: noteId,
|
||||
confirmedById: userId,
|
||||
},
|
||||
include: {
|
||||
confirmedBy: { select: { id: true, fullName: true } },
|
||||
},
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
private async findAccessible(
|
||||
organizationId: string,
|
||||
id: string,
|
||||
actor: Actor,
|
||||
) {
|
||||
const row = await this.prisma.memoryItem.findFirst({
|
||||
where: { id, organizationId },
|
||||
include: { person: { select: { id: true, ownerId: true } } },
|
||||
});
|
||||
if (!row) throw new NotFoundException('مورد حافظه پیدا نشد');
|
||||
await this.assertPersonAccess(organizationId, actor, row.personId);
|
||||
return row;
|
||||
}
|
||||
|
||||
private async assertPersonAccess(
|
||||
organizationId: string,
|
||||
actor: Actor,
|
||||
personId: string,
|
||||
) {
|
||||
const person = await this.prisma.person.findFirst({
|
||||
where: { id: personId, organizationId, ...ownerWhere(actor) },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!person) {
|
||||
throw new BadRequestException('شخص انتخابشده معتبر نیست');
|
||||
}
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
personId: string;
|
||||
content: string;
|
||||
sourceType: string;
|
||||
sourceId: string | null;
|
||||
confirmedBy: { id: string; fullName: string } | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): MemoryItemDto {
|
||||
return {
|
||||
id: row.id,
|
||||
personId: row.personId,
|
||||
content: row.content,
|
||||
sourceType: row.sourceType as MemoryItemDto['sourceType'],
|
||||
sourceId: row.sourceId,
|
||||
confirmedBy: row.confirmedBy,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -168,3 +168,54 @@ export class UpdateNoteDto {
|
||||
@IsString()
|
||||
dealId?: string | null;
|
||||
}
|
||||
|
||||
class NoteAiNextActionDtoClass {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_, v) => v !== '' && v != null)
|
||||
@IsDateString()
|
||||
dueAt?: string | null;
|
||||
}
|
||||
|
||||
class ApplyMemoryEditDto {
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
index!: number;
|
||||
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(500)
|
||||
content!: string;
|
||||
}
|
||||
|
||||
export class ApplyNoteAiSuggestionDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
applyNextAction?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Type(() => Number)
|
||||
@IsInt({ each: true })
|
||||
@Min(0, { each: true })
|
||||
memoryIndices?: number[];
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => NoteAiNextActionDtoClass)
|
||||
nextAction?: NoteAiNextActionDtoClass;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Type(() => ApplyMemoryEditDto)
|
||||
memoryItems?: ApplyMemoryEditDto[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import type {
|
||||
ApplyNoteAiSuggestionRequest,
|
||||
NoteAiPayloadDto,
|
||||
NoteAiSuggestionDto,
|
||||
} from '@mento/shared';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AiService } from '../ai/ai.service';
|
||||
import { MemoryService } from '../memory/memory.service';
|
||||
import { type Actor, noteAccessWhere } from '../common/record-scope';
|
||||
|
||||
@Injectable()
|
||||
export class NoteAiService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly ai: AiService,
|
||||
private readonly memory: MemoryService,
|
||||
) {}
|
||||
|
||||
async analyze(
|
||||
organizationId: string,
|
||||
noteId: string,
|
||||
actor: Actor,
|
||||
): Promise<NoteAiSuggestionDto> {
|
||||
const note = await this.loadNote(organizationId, noteId, actor);
|
||||
if (!this.ai.isEnabled()) {
|
||||
throw new ServiceUnavailableException('هوش مصنوعی فعال نیست');
|
||||
}
|
||||
|
||||
try {
|
||||
const extract = await this.ai.extractFromNote({
|
||||
body: note.body,
|
||||
title: note.title,
|
||||
noteType: note.noteType,
|
||||
personName: note.person
|
||||
? `${note.person.firstName} ${note.person.lastName}`.trim()
|
||||
: null,
|
||||
});
|
||||
|
||||
const row = await this.prisma.noteAiSuggestion.upsert({
|
||||
where: { noteId },
|
||||
create: {
|
||||
organizationId,
|
||||
noteId,
|
||||
status: 'pending',
|
||||
payload: extract,
|
||||
analyzedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
status: 'pending',
|
||||
payload: extract,
|
||||
errorMessage: null,
|
||||
analyzedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return this.toDto(row);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error ? err.message : 'تحلیل یادداشت ناموفق بود';
|
||||
await this.prisma.noteAiSuggestion.upsert({
|
||||
where: { noteId },
|
||||
create: {
|
||||
organizationId,
|
||||
noteId,
|
||||
status: 'failed',
|
||||
errorMessage: message,
|
||||
analyzedAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
status: 'failed',
|
||||
errorMessage: message,
|
||||
analyzedAt: new Date(),
|
||||
},
|
||||
});
|
||||
throw err instanceof ServiceUnavailableException ||
|
||||
err instanceof BadRequestException
|
||||
? err
|
||||
: new ServiceUnavailableException(message);
|
||||
}
|
||||
}
|
||||
|
||||
async getSuggestion(
|
||||
organizationId: string,
|
||||
noteId: string,
|
||||
actor: Actor,
|
||||
): Promise<NoteAiSuggestionDto | null> {
|
||||
await this.ensureNoteAccess(organizationId, noteId, actor);
|
||||
const row = await this.prisma.noteAiSuggestion.findUnique({
|
||||
where: { noteId },
|
||||
});
|
||||
return row ? this.toDto(row) : null;
|
||||
}
|
||||
|
||||
async apply(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
noteId: string,
|
||||
actor: Actor,
|
||||
dto: ApplyNoteAiSuggestionRequest,
|
||||
): Promise<{
|
||||
suggestion: NoteAiSuggestionDto;
|
||||
activityId?: string;
|
||||
memoryItemIds: string[];
|
||||
}> {
|
||||
const note = await this.loadNote(organizationId, noteId, actor);
|
||||
const suggestion = await this.prisma.noteAiSuggestion.findUnique({
|
||||
where: { noteId },
|
||||
});
|
||||
if (!suggestion || suggestion.status !== 'pending') {
|
||||
throw new BadRequestException('پیشنهاد فعالی برای این یادداشت وجود ندارد');
|
||||
}
|
||||
|
||||
const payload = suggestion.payload as NoteAiPayloadDto | null;
|
||||
if (!payload) {
|
||||
throw new BadRequestException('داده پیشنهاد نامعتبر است');
|
||||
}
|
||||
|
||||
let activityId: string | undefined;
|
||||
const memoryItemIds: string[] = [];
|
||||
|
||||
if (dto.applyNextAction) {
|
||||
const next = dto.nextAction ?? payload.nextAction;
|
||||
if (!next?.title?.trim()) {
|
||||
throw new BadRequestException('عنوان کار بعدی الزامی است');
|
||||
}
|
||||
const activity = await this.prisma.activity.create({
|
||||
data: {
|
||||
organizationId,
|
||||
type: 'task',
|
||||
title: next.title.trim().slice(0, 200),
|
||||
description: next.description?.trim() || note.body,
|
||||
dueAt: next.dueAt ? new Date(next.dueAt) : note.reminderAt,
|
||||
personId: note.personId,
|
||||
companyId: note.companyId,
|
||||
dealId: note.dealId,
|
||||
assigneeId: userId,
|
||||
},
|
||||
});
|
||||
activityId = activity.id;
|
||||
}
|
||||
|
||||
if (dto.memoryIndices?.length) {
|
||||
if (!note.personId) {
|
||||
throw new BadRequestException(
|
||||
'برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد',
|
||||
);
|
||||
}
|
||||
const indices = [...new Set(dto.memoryIndices)].filter(
|
||||
(i) => i >= 0 && i < payload.memoryItems.length,
|
||||
);
|
||||
for (const index of indices) {
|
||||
const edited = dto.memoryItems?.find((m) => m.index === index);
|
||||
const content =
|
||||
edited?.content?.trim() ||
|
||||
payload.memoryItems[index]?.content?.trim();
|
||||
if (!content) continue;
|
||||
const item = await this.memory.createFromNote(
|
||||
organizationId,
|
||||
userId,
|
||||
note.personId,
|
||||
noteId,
|
||||
content,
|
||||
);
|
||||
memoryItemIds.push(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await this.prisma.noteAiSuggestion.update({
|
||||
where: { noteId },
|
||||
data: { status: 'applied' },
|
||||
});
|
||||
|
||||
return {
|
||||
suggestion: this.toDto(updated),
|
||||
activityId,
|
||||
memoryItemIds,
|
||||
};
|
||||
}
|
||||
|
||||
async dismiss(
|
||||
organizationId: string,
|
||||
noteId: string,
|
||||
actor: Actor,
|
||||
): Promise<NoteAiSuggestionDto> {
|
||||
await this.ensureNoteAccess(organizationId, noteId, actor);
|
||||
const existing = await this.prisma.noteAiSuggestion.findUnique({
|
||||
where: { noteId },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new NotFoundException('پیشنهادی برای این یادداشت وجود ندارد');
|
||||
}
|
||||
const row = await this.prisma.noteAiSuggestion.update({
|
||||
where: { noteId },
|
||||
data: { status: 'dismissed' },
|
||||
});
|
||||
return this.toDto(row);
|
||||
}
|
||||
|
||||
private async loadNote(
|
||||
organizationId: string,
|
||||
noteId: string,
|
||||
actor: Actor,
|
||||
) {
|
||||
const note = await this.prisma.note.findFirst({
|
||||
where: { id: noteId, organizationId, ...noteAccessWhere(actor) },
|
||||
include: {
|
||||
person: { select: { id: true, firstName: true, lastName: true } },
|
||||
},
|
||||
});
|
||||
if (!note) throw new NotFoundException('یادداشت پیدا نشد');
|
||||
return note;
|
||||
}
|
||||
|
||||
private async ensureNoteAccess(
|
||||
organizationId: string,
|
||||
noteId: string,
|
||||
actor: Actor,
|
||||
) {
|
||||
await this.loadNote(organizationId, noteId, actor);
|
||||
}
|
||||
|
||||
private toDto(row: {
|
||||
id: string;
|
||||
noteId: string;
|
||||
status: string;
|
||||
payload: unknown;
|
||||
errorMessage: string | null;
|
||||
analyzedAt: Date | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): NoteAiSuggestionDto {
|
||||
return {
|
||||
id: row.id,
|
||||
noteId: row.noteId,
|
||||
status: row.status as NoteAiSuggestionDto['status'],
|
||||
payload: (row.payload as NoteAiPayloadDto | null) ?? null,
|
||||
errorMessage: row.errorMessage,
|
||||
analyzedAt: row.analyzedAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@ import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { CurrentUser } from '../auth/current-user.decorator';
|
||||
import { CreateNoteDto, ListNotesQueryDto, UpdateNoteDto } from './dto';
|
||||
import { CreateNoteDto, ListNotesQueryDto, UpdateNoteDto, ApplyNoteAiSuggestionDto } from './dto';
|
||||
import { NoteAiService } from './note-ai.service';
|
||||
import { NotesService } from './notes.service';
|
||||
|
||||
type AuthUser = { organizationId: string; userId: string; role: string };
|
||||
@@ -24,7 +25,10 @@ type AuthUser = { organizationId: string; userId: string; role: string };
|
||||
@Controller('notes')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class NotesController {
|
||||
constructor(private readonly notesService: NotesService) {}
|
||||
constructor(
|
||||
private readonly notesService: NotesService,
|
||||
private readonly noteAi: NoteAiService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@@ -83,6 +87,30 @@ export class NotesController {
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':id/analyze')
|
||||
analyze(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.noteAi.analyze(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Get(':id/ai-suggestion')
|
||||
getAiSuggestion(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.noteAi.getSuggestion(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Post(':id/ai-suggestion/apply')
|
||||
applyAiSuggestion(
|
||||
@CurrentUser() user: AuthUser,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: ApplyNoteAiSuggestionDto,
|
||||
) {
|
||||
return this.noteAi.apply(user.organizationId, user.userId, id, user, dto);
|
||||
}
|
||||
|
||||
@Post(':id/ai-suggestion/dismiss')
|
||||
dismissAiSuggestion(@CurrentUser() user: AuthUser, @Param('id') id: string) {
|
||||
return this.noteAi.dismiss(user.organizationId, id, user);
|
||||
}
|
||||
|
||||
@Post(':id/attachments')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MemoryModule } from '../memory/memory.module';
|
||||
import { NotesController } from './notes.controller';
|
||||
import { NoteAiService } from './note-ai.service';
|
||||
import { NotesService } from './notes.service';
|
||||
|
||||
@Module({
|
||||
imports: [MemoryModule],
|
||||
controllers: [NotesController],
|
||||
providers: [NotesService],
|
||||
providers: [NotesService, NoteAiService],
|
||||
exports: [NotesService],
|
||||
})
|
||||
export class NotesModule {}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { MemoryItemDto, PaginatedResponse } from '@mento/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
personId: string;
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref('');
|
||||
const items = ref<MemoryItemDto[]>([]);
|
||||
const draft = ref('');
|
||||
const editingId = ref<string | null>(null);
|
||||
const editContent = ref('');
|
||||
|
||||
const sourceLabel: Record<string, string> = {
|
||||
note: 'از یادداشت',
|
||||
manual: 'دستی',
|
||||
};
|
||||
|
||||
async function load() {
|
||||
if (!props.personId) return;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<PaginatedResponse<MemoryItemDto>>('/memory', {
|
||||
params: { personId: props.personId, limit: 100 },
|
||||
});
|
||||
items.value = data.items;
|
||||
} catch {
|
||||
error.value = 'بارگذاری حافظه مشتری ناموفق بود';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function addItem() {
|
||||
if (!draft.value.trim()) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post('/memory', {
|
||||
personId: props.personId,
|
||||
content: draft.value.trim(),
|
||||
});
|
||||
draft.value = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'ذخیره ناموفق بود');
|
||||
} else {
|
||||
error.value = 'ذخیره ناموفق بود';
|
||||
}
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(item: MemoryItemDto) {
|
||||
editingId.value = item.id;
|
||||
editContent.value = item.content;
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingId.value = null;
|
||||
editContent.value = '';
|
||||
}
|
||||
|
||||
async function saveEdit(id: string) {
|
||||
if (!editContent.value.trim()) return;
|
||||
saving.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.patch(`/memory/${id}`, { content: editContent.value.trim() });
|
||||
cancelEdit();
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'ویرایش ناموفق بود';
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(id: string) {
|
||||
if (!confirm('این مورد از حافظه حذف شود؟')) return;
|
||||
try {
|
||||
await api.delete(`/memory/${id}`);
|
||||
if (editingId.value === id) cancelEdit();
|
||||
await load();
|
||||
} catch {
|
||||
error.value = 'حذف ناموفق بود';
|
||||
}
|
||||
}
|
||||
|
||||
function formatWhen(value: string) {
|
||||
try {
|
||||
return new Intl.DateTimeFormat('fa-IR', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
}).format(new Date(value));
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
const hasItems = computed(() => items.value.length > 0);
|
||||
|
||||
watch(() => props.personId, load, { immediate: true });
|
||||
onMounted(load);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="memory-panel">
|
||||
<header>
|
||||
<div>
|
||||
<p class="eyebrow">Mento Memory</p>
|
||||
<h2>حافظه مشتری</h2>
|
||||
<p class="lead">نکات پایدار درباره این شخص — فقط با تأیید شما ذخیره میشود.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="add-form" @submit.prevent="addItem">
|
||||
<textarea
|
||||
v-model="draft"
|
||||
rows="2"
|
||||
placeholder="مثلاً: بودجه تا ۵۰۰ میلیون · ترجیح تماس صبحها"
|
||||
/>
|
||||
<button class="btn btn-ghost" type="submit" :disabled="saving || !draft.trim()">
|
||||
افزودن
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading" class="muted">در حال بارگذاری…</p>
|
||||
<p v-else-if="!hasItems" class="muted">هنوز موردی در حافظه ثبت نشده.</p>
|
||||
|
||||
<ul v-else class="items">
|
||||
<li v-for="item in items" :key="item.id">
|
||||
<template v-if="editingId === item.id">
|
||||
<textarea v-model="editContent" rows="2" />
|
||||
<div class="row-actions">
|
||||
<button type="button" class="btn" :disabled="saving" @click="saveEdit(item.id)">
|
||||
ذخیره
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" @click="cancelEdit">انصراف</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="content">{{ item.content }}</p>
|
||||
<p class="meta">
|
||||
<span>{{ sourceLabel[item.sourceType] ?? item.sourceType }}</span>
|
||||
<span v-if="item.confirmedBy"> · {{ item.confirmedBy.fullName }}</span>
|
||||
<span> · {{ formatWhen(item.updatedAt) }}</span>
|
||||
</p>
|
||||
<div class="row-actions">
|
||||
<button type="button" @click="startEdit(item)">ویرایش</button>
|
||||
<button type="button" class="danger" @click="removeItem(item.id)">حذف</button>
|
||||
</div>
|
||||
</template>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.memory-panel {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0.15rem 0 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.lead {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.add-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 0.6rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
textarea {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.9rem;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.items {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.items li {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
padding: 0.75rem 0.85rem;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.content {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 0.35rem 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.row-actions button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.row-actions button.danger {
|
||||
color: #b42318;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #b42318;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,318 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import type { NoteAiPayloadDto, NoteAiSuggestionDto } from '@mento/shared';
|
||||
|
||||
const props = defineProps<{
|
||||
noteId: string;
|
||||
hasPerson: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
applied: [];
|
||||
dismissed: [];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const applying = ref(false);
|
||||
const error = ref('');
|
||||
const suggestion = ref<NoteAiSuggestionDto | null>(null);
|
||||
const applyNextAction = ref(true);
|
||||
const selectedMemory = reactive<Record<number, boolean>>({});
|
||||
const nextTitle = ref('');
|
||||
const nextDueAt = ref('');
|
||||
const memoryEdits = reactive<Record<number, string>>({});
|
||||
|
||||
const payload = computed(() => suggestion.value?.payload ?? null);
|
||||
const isPending = computed(() => suggestion.value?.status === 'pending');
|
||||
const memoryItems = computed(() => payload.value?.memoryItems ?? []);
|
||||
|
||||
watch(
|
||||
() => props.noteId,
|
||||
() => {
|
||||
void load();
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(payload, (value) => {
|
||||
if (!value) return;
|
||||
applyNextAction.value = Boolean(value.nextAction);
|
||||
nextTitle.value = value.nextAction?.title ?? '';
|
||||
nextDueAt.value = value.nextAction?.dueAt
|
||||
? value.nextAction.dueAt.slice(0, 16)
|
||||
: '';
|
||||
value.memoryItems.forEach((item, index) => {
|
||||
selectedMemory[index] = true;
|
||||
memoryEdits[index] = item.content;
|
||||
});
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.get<NoteAiSuggestionDto | null>(
|
||||
`/notes/${props.noteId}/ai-suggestion`,
|
||||
);
|
||||
suggestion.value = data;
|
||||
} catch {
|
||||
suggestion.value = null;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function analyze() {
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const { data } = await api.post<NoteAiSuggestionDto>(
|
||||
`/notes/${props.noteId}/analyze`,
|
||||
);
|
||||
suggestion.value = data;
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'تحلیل ناموفق بود');
|
||||
} else {
|
||||
error.value = 'تحلیل ناموفق بود';
|
||||
}
|
||||
await load();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function apply() {
|
||||
if (!payload.value || !isPending.value) return;
|
||||
applying.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
const memoryIndices = memoryItems.value
|
||||
.map((_, index) => index)
|
||||
.filter((index) => selectedMemory[index]);
|
||||
|
||||
await api.post(`/notes/${props.noteId}/ai-suggestion/apply`, {
|
||||
applyNextAction: applyNextAction.value && Boolean(nextTitle.value.trim()),
|
||||
memoryIndices: props.hasPerson ? memoryIndices : [],
|
||||
nextAction: applyNextAction.value
|
||||
? {
|
||||
title: nextTitle.value.trim(),
|
||||
description: payload.value.nextAction?.description,
|
||||
dueAt: nextDueAt.value
|
||||
? new Date(nextDueAt.value).toISOString()
|
||||
: null,
|
||||
}
|
||||
: undefined,
|
||||
memoryItems: memoryIndices.map((index) => ({
|
||||
index,
|
||||
content: memoryEdits[index]?.trim() || memoryItems.value[index]?.content,
|
||||
})),
|
||||
});
|
||||
await load();
|
||||
emit('applied');
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e)) {
|
||||
const msg = (e.response?.data as { message?: string | string[] })?.message;
|
||||
error.value = Array.isArray(msg) ? msg.join('، ') : (msg ?? 'اعمال پیشنهاد ناموفق بود');
|
||||
} else {
|
||||
error.value = 'اعمال پیشنهاد ناموفق بود';
|
||||
}
|
||||
} finally {
|
||||
applying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function dismiss() {
|
||||
applying.value = true;
|
||||
error.value = '';
|
||||
try {
|
||||
await api.post(`/notes/${props.noteId}/ai-suggestion/dismiss`);
|
||||
await load();
|
||||
emit('dismissed');
|
||||
} catch {
|
||||
error.value = 'رد پیشنهاد ناموفق بود';
|
||||
} finally {
|
||||
applying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPayloadSummary(value: NoteAiPayloadDto | null) {
|
||||
if (!value?.summary) return '';
|
||||
return value.summary;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="ai-panel">
|
||||
<header>
|
||||
<strong>پیشنهاد Mento AI</strong>
|
||||
<button
|
||||
v-if="!isPending"
|
||||
type="button"
|
||||
class="link"
|
||||
:disabled="loading"
|
||||
@click="analyze"
|
||||
>
|
||||
{{ loading ? '…' : 'تحلیل مجدد' }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="!suggestion"
|
||||
type="button"
|
||||
class="link"
|
||||
:disabled="loading"
|
||||
@click="analyze"
|
||||
>
|
||||
{{ loading ? 'در حال تحلیل…' : 'تحلیل با AI' }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<p v-if="error" class="error">{{ error }}</p>
|
||||
<p v-else-if="loading && !suggestion" class="muted">در حال تحلیل یادداشت…</p>
|
||||
<p v-else-if="suggestion?.status === 'failed'" class="error">
|
||||
{{ suggestion.errorMessage || 'تحلیل ناموفق بود' }}
|
||||
<button type="button" class="link" @click="analyze">تلاش دوباره</button>
|
||||
</p>
|
||||
<p v-else-if="!suggestion" class="muted">
|
||||
<button type="button" class="link" :disabled="loading" @click="analyze">
|
||||
تحلیل این یادداشت با AI
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<template v-else-if="isPending && payload">
|
||||
<p v-if="formatPayloadSummary(payload)" class="summary">{{ formatPayloadSummary(payload) }}</p>
|
||||
|
||||
<div v-if="payload.nextAction" class="block">
|
||||
<label class="check">
|
||||
<input v-model="applyNextAction" type="checkbox" />
|
||||
کار بعدی
|
||||
</label>
|
||||
<input v-model="nextTitle" type="text" placeholder="عنوان کار بعدی" />
|
||||
<input v-model="nextDueAt" type="datetime-local" />
|
||||
</div>
|
||||
|
||||
<div v-if="memoryItems.length" class="block">
|
||||
<p class="block-title">حافظه مشتری (با تأیید شما ذخیره میشود)</p>
|
||||
<p v-if="!hasPerson" class="warn">برای ذخیره حافظه، یادداشت باید به یک شخص متصل باشد.</p>
|
||||
<label
|
||||
v-for="(_, index) in memoryItems"
|
||||
:key="index"
|
||||
class="memory-row"
|
||||
>
|
||||
<input
|
||||
v-model="selectedMemory[index]"
|
||||
type="checkbox"
|
||||
:disabled="!hasPerson"
|
||||
/>
|
||||
<textarea v-model="memoryEdits[index]" rows="2" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button type="button" class="btn" :disabled="applying" @click="apply">
|
||||
{{ applying ? '…' : 'اعمال انتخابشده' }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" :disabled="applying" @click="dismiss">
|
||||
رد کردن
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<p v-else-if="suggestion?.status === 'applied'" class="muted ok">پیشنهاد اعمال شد.</p>
|
||||
<p v-else-if="suggestion?.status === 'dismissed'" class="muted">پیشنهاد رد شد.</p>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-panel {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border: 1px dashed var(--accent);
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.65);
|
||||
display: grid;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.link {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--accent);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.block {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.memory-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.45rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
input[type='text'],
|
||||
input[type='datetime-local'],
|
||||
textarea {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summary {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #067647;
|
||||
}
|
||||
|
||||
.warn {
|
||||
margin: 0;
|
||||
color: #b54708;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin: 0;
|
||||
color: #b42318;
|
||||
}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import axios from 'axios';
|
||||
import { api } from '@/lib/api';
|
||||
import NoteAiSuggestionPanel from '@/components/NoteAiSuggestionPanel.vue';
|
||||
import type {
|
||||
DealDto,
|
||||
NoteAttachmentDto,
|
||||
@@ -198,6 +199,7 @@ async function saveNote() {
|
||||
: null,
|
||||
};
|
||||
const pending = [...draftFiles.value];
|
||||
const wasNew = !editingId.value;
|
||||
let noteId = editingId.value;
|
||||
if (editingId.value) {
|
||||
await api.patch(`/notes/${editingId.value}`, payload);
|
||||
@@ -208,6 +210,13 @@ async function saveNote() {
|
||||
if (noteId && pending.length) {
|
||||
await uploadFiles(noteId, pending);
|
||||
}
|
||||
if (noteId && wasNew && draftBody.value.trim().length >= 8) {
|
||||
try {
|
||||
await api.post(`/notes/${noteId}/analyze`);
|
||||
} catch {
|
||||
/* panel shows retry */
|
||||
}
|
||||
}
|
||||
resetDraft();
|
||||
await load();
|
||||
} catch (e) {
|
||||
@@ -264,7 +273,7 @@ onMounted(load);
|
||||
<div>
|
||||
<p class="eyebrow">Smart Notes</p>
|
||||
<h1>یادداشتها</h1>
|
||||
<p class="lead">مثل Keep بنویسید؛ به شخص یا معامله وصل کنید. AI و حافظه مشتری در گامهای بعد میآید.</p>
|
||||
<p class="lead">مثل Keep بنویسید؛ AI کار بعدی و حافظه مشتری را پیشنهاد میدهد — فقط با تأیید شما اعمال میشود.</p>
|
||||
</div>
|
||||
<form class="search" @submit.prevent="load">
|
||||
<input v-model="q" type="search" placeholder="جستجو در متن و برچسب…" />
|
||||
@@ -408,6 +417,10 @@ onMounted(load);
|
||||
<span v-if="note.deal"> · {{ note.deal.title }}</span>
|
||||
<span v-if="note.reminderAt"> · یادآوری {{ formatWhen(note.reminderAt) }}</span>
|
||||
</p>
|
||||
<NoteAiSuggestionPanel
|
||||
:note-id="note.id"
|
||||
:has-person="Boolean(note.person?.id)"
|
||||
/>
|
||||
<footer>
|
||||
<button type="button" @click="startEdit(note)">ویرایش</button>
|
||||
<label class="attach-label">
|
||||
@@ -474,6 +487,10 @@ onMounted(load);
|
||||
<span v-if="note.deal"> · {{ note.deal.title }}</span>
|
||||
<span v-if="note.reminderAt"> · یادآوری {{ formatWhen(note.reminderAt) }}</span>
|
||||
</p>
|
||||
<NoteAiSuggestionPanel
|
||||
:note-id="note.id"
|
||||
:has-person="Boolean(note.person?.id)"
|
||||
/>
|
||||
<footer>
|
||||
<button type="button" @click="startEdit(note)">ویرایش</button>
|
||||
<label class="attach-label">
|
||||
|
||||
@@ -11,6 +11,7 @@ import { api } from '@/lib/api';
|
||||
import type { CompanyDto, CreatePersonRequest, PersonDto } from '@mento/shared';
|
||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
|
||||
import EntityTimeline from '@/components/EntityTimeline.vue';
|
||||
import MentoMemoryPanel from '@/components/MentoMemoryPanel.vue';
|
||||
|
||||
|
||||
|
||||
@@ -337,6 +338,8 @@ onMounted(load);
|
||||
|
||||
<CustomFieldsSection entity-type="person" v-model="customFields" />
|
||||
|
||||
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
|
||||
|
||||
<EntityTimeline
|
||||
v-if="!isNew && id"
|
||||
entity-type="person"
|
||||
|
||||
@@ -25,3 +25,11 @@ SMS_BASE_URL=http://smspanel.Trez.ir
|
||||
SMS_SEND_PATH=/SendMessageWithCode.ashx
|
||||
SMS_OTP_TEMPLATE=کد ورود پرتال منتو: {code}
|
||||
# SMS_TIMEOUT_MS=20000
|
||||
|
||||
# AI — GapGPT (OpenAI-compatible)
|
||||
AI_ENABLED=true
|
||||
AI_PROVIDER=openai
|
||||
AI_BASE_URL=https://api.gapgpt.app/v1
|
||||
AI_API_KEY=change-me
|
||||
AI_MODEL=gpt-4o-mini
|
||||
AI_TIMEOUT_MS=45000
|
||||
|
||||
@@ -75,10 +75,10 @@
|
||||
| [x] | تایملاین یکپارچه (پایه) | روی پروندهها |
|
||||
| [x] | انواع Note | معمولی، نتیجه تماس، جلسه، نکته مهم، هشدار، کار بعدی |
|
||||
| [x] | پیوست فایل روی Note | ذخیره محلی (`uploads/`)؛ آماده ارتقا به MinIO/S3 |
|
||||
| [ ] | AI: استخراج Next Action | با تأیید کاربر |
|
||||
| [ ] | پیشنهاد اقدام هوشمند | یادآور/فعالیت با تأیید |
|
||||
| [ ] | Mento Memory با منبع | خلاصه پایدار مشتری |
|
||||
| [ ] | ویرایش/حذف Memory Item | |
|
||||
| [x] | AI: استخراج Next Action | با تأیید کاربر |
|
||||
| [x] | پیشنهاد اقدام هوشمند | یادآور/فعالیت با تأیید |
|
||||
| [x] | Mento Memory با منبع | خلاصه پایدار مشتری |
|
||||
| [x] | ویرایش/حذف Memory Item | |
|
||||
| [ ] | Voice-to-Note / خلاصه تماس | فاز بعدتر |
|
||||
| [ ] | Ask Mento | پرسش زبان طبیعی |
|
||||
| [ ] | Note شخصی در برابر تیمی | |
|
||||
@@ -140,7 +140,7 @@
|
||||
|
||||
1. API عمومی و وبهوک
|
||||
2. پنل پیامکی / درگاه پرداخت
|
||||
3. Smart Notes AI / Memory وقتی کلید مدل مشخص شد
|
||||
3. Voice-to-Note / Ask Mento (فاز بعدتر Smart Notes)
|
||||
4. ارتقای ذخیره فایل به MinIO در صورت نیاز استقرار
|
||||
---
|
||||
|
||||
|
||||
@@ -469,6 +469,69 @@ export interface UpdateNoteRequest {
|
||||
dealId?: string | null;
|
||||
}
|
||||
|
||||
export type NoteAiSuggestionStatus =
|
||||
| 'pending'
|
||||
| 'applied'
|
||||
| 'dismissed'
|
||||
| 'failed';
|
||||
|
||||
export type MemorySourceType = 'note' | 'manual';
|
||||
|
||||
export interface NoteAiNextActionDto {
|
||||
title: string;
|
||||
description?: string;
|
||||
dueAt?: string | null;
|
||||
}
|
||||
|
||||
export interface NoteAiMemoryExtractDto {
|
||||
content: string;
|
||||
category?: string;
|
||||
}
|
||||
|
||||
export interface NoteAiPayloadDto {
|
||||
nextAction: NoteAiNextActionDto | null;
|
||||
memoryItems: NoteAiMemoryExtractDto[];
|
||||
summary?: string;
|
||||
}
|
||||
|
||||
export interface NoteAiSuggestionDto {
|
||||
id: string;
|
||||
noteId: string;
|
||||
status: NoteAiSuggestionStatus;
|
||||
payload: NoteAiPayloadDto | null;
|
||||
errorMessage: string | null;
|
||||
analyzedAt: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ApplyNoteAiSuggestionRequest {
|
||||
applyNextAction?: boolean;
|
||||
memoryIndices?: number[];
|
||||
nextAction?: NoteAiNextActionDto;
|
||||
memoryItems?: Array<{ index: number; content: string }>;
|
||||
}
|
||||
|
||||
export interface MemoryItemDto {
|
||||
id: string;
|
||||
personId: string;
|
||||
content: string;
|
||||
sourceType: MemorySourceType;
|
||||
sourceId: string | null;
|
||||
confirmedBy: UserSummaryDto | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CreateMemoryItemRequest {
|
||||
personId: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface UpdateMemoryItemRequest {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ImportContactsResult {
|
||||
created: number;
|
||||
skipped: number;
|
||||
|
||||
Reference in New Issue
Block a user