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
|
add_loyalty_points
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum NoteAiSuggestionStatus {
|
||||||
|
pending
|
||||||
|
applied
|
||||||
|
dismissed
|
||||||
|
failed
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MemorySourceType {
|
||||||
|
note
|
||||||
|
manual
|
||||||
|
}
|
||||||
|
|
||||||
model Organization {
|
model Organization {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String
|
name String
|
||||||
@@ -134,6 +146,8 @@ model Organization {
|
|||||||
projectCards ProjectCard[]
|
projectCards ProjectCard[]
|
||||||
loyaltyTiers LoyaltyTier[]
|
loyaltyTiers LoyaltyTier[]
|
||||||
automationRules AutomationRule[]
|
automationRules AutomationRule[]
|
||||||
|
memoryItems MemoryItem[]
|
||||||
|
noteAiSuggestions NoteAiSuggestion[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
@@ -158,6 +172,7 @@ model User {
|
|||||||
assignedTickets Ticket[] @relation("TicketAssignee")
|
assignedTickets Ticket[] @relation("TicketAssignee")
|
||||||
reportedTickets Ticket[] @relation("TicketReporter")
|
reportedTickets Ticket[] @relation("TicketReporter")
|
||||||
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
|
ticketMessages TicketMessage[] @relation("TicketMessageAuthor")
|
||||||
|
confirmedMemories MemoryItem[] @relation("MemoryConfirmedBy")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -223,6 +238,7 @@ model Person {
|
|||||||
owner User? @relation("PersonOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
owner User? @relation("PersonOwner", fields: [ownerId], references: [id], onDelete: SetNull)
|
||||||
tickets Ticket[]
|
tickets Ticket[]
|
||||||
projectCards ProjectCard[]
|
projectCards ProjectCard[]
|
||||||
|
memoryItems MemoryItem[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -359,6 +375,7 @@ model Note {
|
|||||||
authorId String?
|
authorId String?
|
||||||
author User? @relation("NoteAuthor", fields: [authorId], references: [id], onDelete: SetNull)
|
author User? @relation("NoteAuthor", fields: [authorId], references: [id], onDelete: SetNull)
|
||||||
attachments NoteAttachment[]
|
attachments NoteAttachment[]
|
||||||
|
aiSuggestion NoteAiSuggestion?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -390,6 +407,41 @@ model NoteAttachment {
|
|||||||
@@index([uploadedById])
|
@@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 {
|
model CustomFieldDefinition {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
organizationId String
|
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 { ProjectBoardsModule } from './project-boards/project-boards.module';
|
||||||
import { LoyaltyModule } from './loyalty/loyalty.module';
|
import { LoyaltyModule } from './loyalty/loyalty.module';
|
||||||
import { AutomationsModule } from './automations/automations.module';
|
import { AutomationsModule } from './automations/automations.module';
|
||||||
|
import { AiModule } from './ai/ai.module';
|
||||||
|
import { MemoryModule } from './memory/memory.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -31,6 +33,7 @@ import { AutomationsModule } from './automations/automations.module';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
envFilePath: ['.env', '../../.env'],
|
envFilePath: ['.env', '../../.env'],
|
||||||
}),
|
}),
|
||||||
|
AiModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
StorageModule,
|
StorageModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
@@ -55,6 +58,7 @@ import { AutomationsModule } from './automations/automations.module';
|
|||||||
ProjectBoardsModule,
|
ProjectBoardsModule,
|
||||||
LoyaltyModule,
|
LoyaltyModule,
|
||||||
AutomationsModule,
|
AutomationsModule,
|
||||||
|
MemoryModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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()
|
@IsString()
|
||||||
dealId?: string | null;
|
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 { memoryStorage } from 'multer';
|
||||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
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';
|
import { NotesService } from './notes.service';
|
||||||
|
|
||||||
type AuthUser = { organizationId: string; userId: string; role: string };
|
type AuthUser = { organizationId: string; userId: string; role: string };
|
||||||
@@ -24,7 +25,10 @@ type AuthUser = { organizationId: string; userId: string; role: string };
|
|||||||
@Controller('notes')
|
@Controller('notes')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class NotesController {
|
export class NotesController {
|
||||||
constructor(private readonly notesService: NotesService) {}
|
constructor(
|
||||||
|
private readonly notesService: NotesService,
|
||||||
|
private readonly noteAi: NoteAiService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(
|
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')
|
@Post(':id/attachments')
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FileInterceptor('file', {
|
FileInterceptor('file', {
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { MemoryModule } from '../memory/memory.module';
|
||||||
import { NotesController } from './notes.controller';
|
import { NotesController } from './notes.controller';
|
||||||
|
import { NoteAiService } from './note-ai.service';
|
||||||
import { NotesService } from './notes.service';
|
import { NotesService } from './notes.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [MemoryModule],
|
||||||
controllers: [NotesController],
|
controllers: [NotesController],
|
||||||
providers: [NotesService],
|
providers: [NotesService, NoteAiService],
|
||||||
exports: [NotesService],
|
exports: [NotesService],
|
||||||
})
|
})
|
||||||
export class NotesModule {}
|
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 { useRouter } from 'vue-router';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { api } from '@/lib/api';
|
import { api } from '@/lib/api';
|
||||||
|
import NoteAiSuggestionPanel from '@/components/NoteAiSuggestionPanel.vue';
|
||||||
import type {
|
import type {
|
||||||
DealDto,
|
DealDto,
|
||||||
NoteAttachmentDto,
|
NoteAttachmentDto,
|
||||||
@@ -198,6 +199,7 @@ async function saveNote() {
|
|||||||
: null,
|
: null,
|
||||||
};
|
};
|
||||||
const pending = [...draftFiles.value];
|
const pending = [...draftFiles.value];
|
||||||
|
const wasNew = !editingId.value;
|
||||||
let noteId = editingId.value;
|
let noteId = editingId.value;
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
await api.patch(`/notes/${editingId.value}`, payload);
|
await api.patch(`/notes/${editingId.value}`, payload);
|
||||||
@@ -208,6 +210,13 @@ async function saveNote() {
|
|||||||
if (noteId && pending.length) {
|
if (noteId && pending.length) {
|
||||||
await uploadFiles(noteId, pending);
|
await uploadFiles(noteId, pending);
|
||||||
}
|
}
|
||||||
|
if (noteId && wasNew && draftBody.value.trim().length >= 8) {
|
||||||
|
try {
|
||||||
|
await api.post(`/notes/${noteId}/analyze`);
|
||||||
|
} catch {
|
||||||
|
/* panel shows retry */
|
||||||
|
}
|
||||||
|
}
|
||||||
resetDraft();
|
resetDraft();
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -264,7 +273,7 @@ onMounted(load);
|
|||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">Smart Notes</p>
|
<p class="eyebrow">Smart Notes</p>
|
||||||
<h1>یادداشتها</h1>
|
<h1>یادداشتها</h1>
|
||||||
<p class="lead">مثل Keep بنویسید؛ به شخص یا معامله وصل کنید. AI و حافظه مشتری در گامهای بعد میآید.</p>
|
<p class="lead">مثل Keep بنویسید؛ AI کار بعدی و حافظه مشتری را پیشنهاد میدهد — فقط با تأیید شما اعمال میشود.</p>
|
||||||
</div>
|
</div>
|
||||||
<form class="search" @submit.prevent="load">
|
<form class="search" @submit.prevent="load">
|
||||||
<input v-model="q" type="search" placeholder="جستجو در متن و برچسب…" />
|
<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.deal"> · {{ note.deal.title }}</span>
|
||||||
<span v-if="note.reminderAt"> · یادآوری {{ formatWhen(note.reminderAt) }}</span>
|
<span v-if="note.reminderAt"> · یادآوری {{ formatWhen(note.reminderAt) }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
<NoteAiSuggestionPanel
|
||||||
|
:note-id="note.id"
|
||||||
|
:has-person="Boolean(note.person?.id)"
|
||||||
|
/>
|
||||||
<footer>
|
<footer>
|
||||||
<button type="button" @click="startEdit(note)">ویرایش</button>
|
<button type="button" @click="startEdit(note)">ویرایش</button>
|
||||||
<label class="attach-label">
|
<label class="attach-label">
|
||||||
@@ -474,6 +487,10 @@ onMounted(load);
|
|||||||
<span v-if="note.deal"> · {{ note.deal.title }}</span>
|
<span v-if="note.deal"> · {{ note.deal.title }}</span>
|
||||||
<span v-if="note.reminderAt"> · یادآوری {{ formatWhen(note.reminderAt) }}</span>
|
<span v-if="note.reminderAt"> · یادآوری {{ formatWhen(note.reminderAt) }}</span>
|
||||||
</p>
|
</p>
|
||||||
|
<NoteAiSuggestionPanel
|
||||||
|
:note-id="note.id"
|
||||||
|
:has-person="Boolean(note.person?.id)"
|
||||||
|
/>
|
||||||
<footer>
|
<footer>
|
||||||
<button type="button" @click="startEdit(note)">ویرایش</button>
|
<button type="button" @click="startEdit(note)">ویرایش</button>
|
||||||
<label class="attach-label">
|
<label class="attach-label">
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { api } from '@/lib/api';
|
|||||||
import type { CompanyDto, CreatePersonRequest, PersonDto } from '@mento/shared';
|
import type { CompanyDto, CreatePersonRequest, PersonDto } from '@mento/shared';
|
||||||
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
|
import CustomFieldsSection from '@/components/CustomFieldsSection.vue';
|
||||||
import EntityTimeline from '@/components/EntityTimeline.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" />
|
<CustomFieldsSection entity-type="person" v-model="customFields" />
|
||||||
|
|
||||||
|
<MentoMemoryPanel v-if="!isNew && id" :person-id="id" />
|
||||||
|
|
||||||
<EntityTimeline
|
<EntityTimeline
|
||||||
v-if="!isNew && id"
|
v-if="!isNew && id"
|
||||||
entity-type="person"
|
entity-type="person"
|
||||||
|
|||||||
@@ -25,3 +25,11 @@ SMS_BASE_URL=http://smspanel.Trez.ir
|
|||||||
SMS_SEND_PATH=/SendMessageWithCode.ashx
|
SMS_SEND_PATH=/SendMessageWithCode.ashx
|
||||||
SMS_OTP_TEMPLATE=کد ورود پرتال منتو: {code}
|
SMS_OTP_TEMPLATE=کد ورود پرتال منتو: {code}
|
||||||
# SMS_TIMEOUT_MS=20000
|
# 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] | تایملاین یکپارچه (پایه) | روی پروندهها |
|
||||||
| [x] | انواع Note | معمولی، نتیجه تماس، جلسه، نکته مهم، هشدار، کار بعدی |
|
| [x] | انواع Note | معمولی، نتیجه تماس، جلسه، نکته مهم، هشدار، کار بعدی |
|
||||||
| [x] | پیوست فایل روی Note | ذخیره محلی (`uploads/`)؛ آماده ارتقا به MinIO/S3 |
|
| [x] | پیوست فایل روی Note | ذخیره محلی (`uploads/`)؛ آماده ارتقا به MinIO/S3 |
|
||||||
| [ ] | AI: استخراج Next Action | با تأیید کاربر |
|
| [x] | AI: استخراج Next Action | با تأیید کاربر |
|
||||||
| [ ] | پیشنهاد اقدام هوشمند | یادآور/فعالیت با تأیید |
|
| [x] | پیشنهاد اقدام هوشمند | یادآور/فعالیت با تأیید |
|
||||||
| [ ] | Mento Memory با منبع | خلاصه پایدار مشتری |
|
| [x] | Mento Memory با منبع | خلاصه پایدار مشتری |
|
||||||
| [ ] | ویرایش/حذف Memory Item | |
|
| [x] | ویرایش/حذف Memory Item | |
|
||||||
| [ ] | Voice-to-Note / خلاصه تماس | فاز بعدتر |
|
| [ ] | Voice-to-Note / خلاصه تماس | فاز بعدتر |
|
||||||
| [ ] | Ask Mento | پرسش زبان طبیعی |
|
| [ ] | Ask Mento | پرسش زبان طبیعی |
|
||||||
| [ ] | Note شخصی در برابر تیمی | |
|
| [ ] | Note شخصی در برابر تیمی | |
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
|
|
||||||
1. API عمومی و وبهوک
|
1. API عمومی و وبهوک
|
||||||
2. پنل پیامکی / درگاه پرداخت
|
2. پنل پیامکی / درگاه پرداخت
|
||||||
3. Smart Notes AI / Memory وقتی کلید مدل مشخص شد
|
3. Voice-to-Note / Ask Mento (فاز بعدتر Smart Notes)
|
||||||
4. ارتقای ذخیره فایل به MinIO در صورت نیاز استقرار
|
4. ارتقای ذخیره فایل به MinIO در صورت نیاز استقرار
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -469,6 +469,69 @@ export interface UpdateNoteRequest {
|
|||||||
dealId?: string | null;
|
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 {
|
export interface ImportContactsResult {
|
||||||
created: number;
|
created: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user