feat: brainstorm sessions, PDF document Q&A, embedding fixes, and UI improvements
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
- Add brainstorm feature with collaborative canvas, AI idea generation, live cursors, playback, and export - Add PDF upload/extraction/ingestion pipeline with pgvector document search (RAG) - Add document Q&A overlay with streaming chat and PDF preview - Add note attachments UI with status polling, grid layout, and auto-scroll - Add task extraction AI tool and agent executor improvements - Fix NoteEmbedding missing updatedAt column, re-index 66 notes with 1536-dim embeddings - Fix brainstorm 'Create Note' button: add success toast and redirect to created note - Fix memory echo notification infinite polling - Fix chat route to always include document_search tool - Add brainstorm i18n keys across all 14 locales - Add socket server for real-time brainstorm collaboration - Add hierarchical notebook selector and organize notebook dialog improvements - Add sidebar brainstorm section with session management - Update prisma schema with brainstorm tables, attachments, and document chunks
This commit is contained in:
@@ -23,7 +23,7 @@ import '../tools'
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator'
|
||||
export type AgentType = 'scraper' | 'researcher' | 'monitor' | 'custom' | 'slide-generator' | 'excalidraw-generator' | 'task-extractor'
|
||||
|
||||
export interface AgentExecutionResult {
|
||||
success: boolean
|
||||
@@ -941,6 +941,28 @@ IMPERATIVE DESIGN RULES:
|
||||
- Concise points (max 100 chars), punchy and short titles
|
||||
- Strict JSON for generate_pptx, no text outside JSON.`,
|
||||
},
|
||||
'task-extractor': {
|
||||
fr: `Tu es un expert en gestion de tâches et extraction d'action items. Tu analyses des notes et documents pour identifier toutes les tâches, TODOs, et actions à accomplir.
|
||||
|
||||
Utilise OBLIGATOIREMENT l'outil task_extract. Ne réponds PAS avec du texte, appelle directement l'outil.
|
||||
|
||||
## RÈGLES
|
||||
- Identifie TOUTES les tâches explicites et implicites
|
||||
- Pour chaque tâche, détermine: priorité (High/Medium/Low), assigné, deadline, statut
|
||||
- Les priorités High = urgent/dates proches, Medium = important, Low = Nice to have
|
||||
- Regroupe par priorité dans la note de synthèse
|
||||
- Utilise le format Markdown avec une table récapitulative`,
|
||||
en: `You are a task extraction specialist. You analyze notes and documents to identify ALL action items, TODOs, and tasks to accomplish.
|
||||
|
||||
You MUST use the task_extract tool. Do NOT respond with text, call the tool directly.
|
||||
|
||||
## RULES
|
||||
- Identify ALL explicit and implicit tasks
|
||||
- For each task, determine: priority (High/Medium/Low), assignee, deadline, status
|
||||
- High priority = urgent/close deadlines, Medium = important, Low = Nice to have
|
||||
- Group by priority in the synthesis note
|
||||
- Use Markdown format with a summary table`,
|
||||
},
|
||||
}
|
||||
|
||||
// --- Tool-Use Agent ---
|
||||
@@ -1172,6 +1194,38 @@ async function executeToolUseAgent(
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'task-extractor': {
|
||||
const untitled = lang === 'fr' ? 'Sans titre' : 'Untitled'
|
||||
const dateLocale = lang === 'fr' ? 'fr-FR' : 'en-US'
|
||||
let notes: any[] = []
|
||||
if (agent.sourceNotebookId) {
|
||||
notes = await prisma.note.findMany({
|
||||
where: { notebookId: agent.sourceNotebookId, userId: agent.userId, isArchived: false, trashedAt: null },
|
||||
orderBy: { createdAt: 'desc' }, take: 20,
|
||||
select: { id: true, title: true, content: true, createdAt: true }
|
||||
})
|
||||
} else {
|
||||
notes = await prisma.note.findMany({
|
||||
where: { userId: agent.userId, isArchived: false, trashedAt: null },
|
||||
orderBy: { updatedAt: 'desc' }, take: 20,
|
||||
select: { id: true, title: true, content: true, createdAt: true }
|
||||
})
|
||||
}
|
||||
const notebookId = agent.sourceNotebookId || agent.targetNotebookId || null
|
||||
prompt = lang === 'fr'
|
||||
? `Analyse les notes suivantes et extrais TOUS les action items, tâches et TODOs. Utilise l'outil task_extract pour créer une note de synthèse.${notebookId ? ` Passe notebookId="${notebookId}" à task_extract.` : ''}`
|
||||
: `Analyze the following notes and extract ALL action items, tasks and TODOs. Use the task_extract tool to create a synthesis note.${notebookId ? ` Pass notebookId="${notebookId}" to task_extract.` : ''}`
|
||||
if (notes.length > 0) {
|
||||
const notesContext = notes.map(n =>
|
||||
`### ${n.title || untitled} (${n.createdAt.toLocaleDateString(dateLocale)})\n${n.content.substring(0, 500)}`
|
||||
).join('\n\n')
|
||||
prompt += `\n\n${lang === 'fr' ? 'Notes à analyser' : 'Notes to analyze'}:\n\n${notesContext}`
|
||||
}
|
||||
prompt += `\n\n${lang === 'fr'
|
||||
? 'IMPORTANT : Utilise OBLIGATOIREMENT l\'outil task_extract. Ne réponds pas avec du texte, appelle directement l\'outil.'
|
||||
: 'IMPORTANT: You MUST use the task_extract tool. Do NOT respond with text, call the tool directly.'}`
|
||||
break
|
||||
}
|
||||
default: {
|
||||
const urls: string[] = agent.sourceUrls ? JSON.parse(agent.sourceUrls) : []
|
||||
prompt = agent.role || (lang === 'fr' ? 'Accomplis la tâche demandée en utilisant les outils disponibles.' : 'Accomplish the requested task using available tools.')
|
||||
|
||||
83
memento-note/lib/ai/services/document-chunking.service.ts
Normal file
83
memento-note/lib/ai/services/document-chunking.service.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
interface ChunkInput {
|
||||
text: string
|
||||
pageNumber: number
|
||||
}
|
||||
|
||||
export interface DocumentChunkData {
|
||||
content: string
|
||||
chunkIndex: number
|
||||
pageNumber: number
|
||||
startChar: number
|
||||
endChar: number
|
||||
metadata?: string
|
||||
}
|
||||
|
||||
export class DocumentChunkingService {
|
||||
private readonly CHUNK_SIZE = 800
|
||||
private readonly OVERLAP = 200
|
||||
|
||||
chunk(pages: ChunkInput[]): DocumentChunkData[] {
|
||||
const chunks: DocumentChunkData[] = []
|
||||
let globalIndex = 0
|
||||
let previousTail = ''
|
||||
|
||||
for (const page of pages) {
|
||||
const text = page.text.trim()
|
||||
if (!text) continue
|
||||
|
||||
const sections = this.splitSections(text)
|
||||
let buffer = previousTail
|
||||
let bufferStart = 0
|
||||
|
||||
for (const section of sections) {
|
||||
if (buffer.length + section.length > this.CHUNK_SIZE && buffer.length > 0) {
|
||||
chunks.push({
|
||||
content: buffer.trim(),
|
||||
chunkIndex: globalIndex++,
|
||||
pageNumber: page.pageNumber,
|
||||
startChar: bufferStart,
|
||||
endChar: bufferStart + buffer.length,
|
||||
})
|
||||
previousTail = buffer.slice(-this.OVERLAP)
|
||||
buffer = previousTail + '\n' + section
|
||||
bufferStart += buffer.length - section.length - previousTail.length
|
||||
} else {
|
||||
buffer += (buffer ? '\n\n' : '') + section
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
chunks.push({
|
||||
content: buffer.trim(),
|
||||
chunkIndex: globalIndex++,
|
||||
pageNumber: page.pageNumber,
|
||||
startChar: bufferStart,
|
||||
endChar: bufferStart + buffer.length,
|
||||
})
|
||||
previousTail = buffer.slice(-this.OVERLAP)
|
||||
}
|
||||
}
|
||||
|
||||
return chunks
|
||||
}
|
||||
|
||||
private splitSections(text: string): string[] {
|
||||
const lines = text.split('\n')
|
||||
const sections: string[] = []
|
||||
let current = ''
|
||||
|
||||
for (const line of lines) {
|
||||
const isHeading = /^(#{1,6}\s|[A-Z][A-Z\s]{5,}$)/.test(line.trim())
|
||||
if (isHeading && current.trim()) {
|
||||
sections.push(current.trim())
|
||||
current = line
|
||||
} else {
|
||||
current += (current ? '\n' : '') + line
|
||||
}
|
||||
}
|
||||
if (current.trim()) sections.push(current.trim())
|
||||
return sections
|
||||
}
|
||||
}
|
||||
|
||||
export const documentChunkingService = new DocumentChunkingService()
|
||||
56
memento-note/lib/ai/services/document-extraction.service.ts
Normal file
56
memento-note/lib/ai/services/document-extraction.service.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import fs from 'fs'
|
||||
import path from 'path'
|
||||
import * as pdfjsLib from 'pdfjs-dist/legacy/build/pdf.mjs'
|
||||
|
||||
if (typeof pdfjsLib.GlobalWorkerOptions !== 'undefined') {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = path.join(
|
||||
process.cwd(),
|
||||
'node_modules/pdfjs-dist/legacy/build/pdf.worker.mjs'
|
||||
)
|
||||
}
|
||||
|
||||
interface ExtractedPage {
|
||||
pageNumber: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ExtractedDocument {
|
||||
pages: ExtractedPage[]
|
||||
totalPages: number
|
||||
metadata: { title?: string; author?: string }
|
||||
}
|
||||
|
||||
export class DocumentExtractionService {
|
||||
async extractPdf(filePath: string): Promise<ExtractedDocument> {
|
||||
const dataBuffer = fs.readFileSync(filePath)
|
||||
const doc = await pdfjsLib.getDocument({
|
||||
data: new Uint8Array(dataBuffer),
|
||||
useSystemFonts: true,
|
||||
useWorkerFetch: false,
|
||||
isEvalSupported: false,
|
||||
}).promise
|
||||
|
||||
const pages: ExtractedPage[] = []
|
||||
for (let i = 1; i <= doc.numPages; i++) {
|
||||
const page = await doc.getPage(i)
|
||||
const content = await page.getTextContent()
|
||||
const text = content.items
|
||||
.map((item: any) => item.str)
|
||||
.join(' ')
|
||||
pages.push({ pageNumber: i, text })
|
||||
}
|
||||
|
||||
const metadata = await doc.getMetadata().catch(() => null) as any
|
||||
|
||||
return {
|
||||
pages,
|
||||
totalPages: doc.numPages,
|
||||
metadata: {
|
||||
title: metadata?.info?.Title,
|
||||
author: metadata?.info?.Author,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const documentExtractionService = new DocumentExtractionService()
|
||||
79
memento-note/lib/ai/services/document-ingestion.service.ts
Normal file
79
memento-note/lib/ai/services/document-ingestion.service.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
import { documentExtractionService } from './document-extraction.service'
|
||||
import { documentChunkingService } from './document-chunking.service'
|
||||
import { embeddingService } from './embedding.service'
|
||||
|
||||
export class DocumentIngestionService {
|
||||
async ingest(attachmentId: string): Promise<void> {
|
||||
const attachment = await prisma.noteAttachment.findUnique({
|
||||
where: { id: attachmentId },
|
||||
})
|
||||
if (!attachment) throw new Error('Attachment not found')
|
||||
|
||||
await prisma.noteAttachment.update({
|
||||
where: { id: attachmentId },
|
||||
data: { status: 'processing' },
|
||||
})
|
||||
|
||||
try {
|
||||
const extracted = await documentExtractionService.extractPdf(attachment.filePath)
|
||||
|
||||
await prisma.noteAttachment.update({
|
||||
where: { id: attachmentId },
|
||||
data: { pageCount: extracted.totalPages },
|
||||
})
|
||||
|
||||
const chunkInputs = extracted.pages.map(p => ({
|
||||
text: p.text,
|
||||
pageNumber: p.pageNumber,
|
||||
}))
|
||||
const chunks = documentChunkingService.chunk(chunkInputs)
|
||||
|
||||
const created = await Promise.all(
|
||||
chunks.map(c =>
|
||||
prisma.documentChunk.create({
|
||||
data: {
|
||||
attachmentId,
|
||||
content: c.content,
|
||||
chunkIndex: c.chunkIndex,
|
||||
pageNumber: c.pageNumber,
|
||||
startChar: c.startChar,
|
||||
endChar: c.endChar,
|
||||
metadata: c.metadata,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const BATCH_SIZE = 20
|
||||
for (let i = 0; i < created.length; i += BATCH_SIZE) {
|
||||
const batch = created.slice(i, i + BATCH_SIZE)
|
||||
const texts = batch.map(c => c.content)
|
||||
const embeddings = await embeddingService.generateBatchEmbeddings(texts)
|
||||
|
||||
await Promise.all(
|
||||
batch.map((chunk, idx) =>
|
||||
prisma.$executeRawUnsafe(
|
||||
`UPDATE "DocumentChunk" SET embedding = $1::vector WHERE id = $2`,
|
||||
embeddingService.toVectorString(embeddings[idx].embedding),
|
||||
chunk.id
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
await prisma.noteAttachment.update({
|
||||
where: { id: attachmentId },
|
||||
data: { status: 'ready' },
|
||||
})
|
||||
} catch (error: any) {
|
||||
await prisma.noteAttachment.update({
|
||||
where: { id: attachmentId },
|
||||
data: { status: 'failed', error: error.message?.substring(0, 500) },
|
||||
})
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const documentIngestionService = new DocumentIngestionService()
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Embedding Service
|
||||
* Generates vector embeddings for semantic search and similarity analysis.
|
||||
* Stores embeddings as native pgvector(1536) in PostgreSQL.
|
||||
* Stores embeddings as native pgvector in PostgreSQL.
|
||||
*/
|
||||
|
||||
import { getAIProvider } from '../factory'
|
||||
|
||||
@@ -385,6 +385,85 @@ export class SemanticSearchService {
|
||||
await Promise.allSettled(batch.map(noteId => this.indexNote(noteId)))
|
||||
}
|
||||
}
|
||||
|
||||
async searchWithDocuments(
|
||||
userId: string,
|
||||
query: string,
|
||||
options?: SearchOptions & { noteId?: string; includeDocuments?: boolean }
|
||||
): Promise<(SearchResult & { source?: 'note' | 'document'; pageNumber?: number; fileName?: string })[]> {
|
||||
const includeDocuments = options?.includeDocuments !== false
|
||||
const noteResults = await this.searchAsUser(userId, query, options)
|
||||
|
||||
if (!includeDocuments) return noteResults
|
||||
|
||||
const queryEmbedding = await embeddingService.generateEmbedding(query)
|
||||
const vectorStr = embeddingService.toVectorString(queryEmbedding.embedding)
|
||||
|
||||
let noteFilter = ''
|
||||
const params: any[] = [vectorStr, 50, userId]
|
||||
|
||||
if (options?.noteId) {
|
||||
assertSafeId(options.noteId, 'noteId')
|
||||
params.push(options.noteId)
|
||||
noteFilter = `AND na."noteId" = $${params.length}`
|
||||
} else if (options?.notebookId) {
|
||||
assertSafeId(options.notebookId, 'notebookId')
|
||||
params.push(options.notebookId)
|
||||
noteFilter = `AND n."notebookId" = $${params.length}`
|
||||
}
|
||||
|
||||
const documentResults = await prisma.$queryRawUnsafe(
|
||||
`SELECT
|
||||
dc.content,
|
||||
dc."pageNumber",
|
||||
na."fileName",
|
||||
na."noteId",
|
||||
n.title as "noteTitle"
|
||||
FROM "DocumentChunk" dc
|
||||
JOIN "NoteAttachment" na ON na.id = dc."attachmentId"
|
||||
JOIN "Note" n ON n.id = na."noteId"
|
||||
WHERE dc."embedding" IS NOT NULL
|
||||
AND na.status = 'ready'
|
||||
AND n."trashedAt" IS NULL
|
||||
AND n."userId" = $3
|
||||
${noteFilter}
|
||||
ORDER BY dc."embedding" <=> $1::vector
|
||||
LIMIT $2`,
|
||||
...params
|
||||
) as any[]
|
||||
|
||||
const K = 60
|
||||
const fused = new Map<string, any>()
|
||||
|
||||
for (let i = 0; i < noteResults.length; i++) {
|
||||
const r = noteResults[i]
|
||||
fused.set(r.noteId, {
|
||||
...r,
|
||||
source: 'note',
|
||||
rrfScore: 1 / (K + i + 1),
|
||||
})
|
||||
}
|
||||
|
||||
for (let i = 0; i < documentResults.length; i++) {
|
||||
const r = documentResults[i]
|
||||
const key = `doc_${r.noteId}_${r.pageNumber}_${i}`
|
||||
fused.set(key, {
|
||||
noteId: r.noteId,
|
||||
title: `${r.noteTitle || 'Untitled'} → ${r.fileName} (p.${r.pageNumber})`,
|
||||
content: r.content.substring(0, 500),
|
||||
score: 0.5,
|
||||
matchType: 'related' as const,
|
||||
source: 'document',
|
||||
pageNumber: r.pageNumber,
|
||||
fileName: r.fileName,
|
||||
rrfScore: 1 / (K + i + 1),
|
||||
})
|
||||
}
|
||||
|
||||
return Array.from(fused.values())
|
||||
.sort((a, b) => b.rrfScore - a.rrfScore)
|
||||
.slice(0, options?.limit || 20)
|
||||
}
|
||||
}
|
||||
|
||||
export const semanticSearchService = new SemanticSearchService()
|
||||
|
||||
73
memento-note/lib/ai/tools/document-search.tool.ts
Normal file
73
memento-note/lib/ai/tools/document-search.tool.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { tool } from 'ai'
|
||||
import { z } from 'zod'
|
||||
import { toolRegistry } from './registry'
|
||||
import { embeddingService } from '@/lib/ai/services/embedding.service'
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
toolRegistry.register({
|
||||
name: 'document_search',
|
||||
description: 'Search within PDF documents attached to notes. Returns relevant passages with page numbers and source document info.',
|
||||
isInternal: true,
|
||||
buildTool: (ctx) =>
|
||||
tool({
|
||||
description: `Search within PDF documents attached to the user's notes.
|
||||
Returns matching passages with page numbers, chunk content, and the source note/document info.
|
||||
Use this when the user asks about specific documents, PDFs, or attached files.`,
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe('The search query to find relevant passages in documents'),
|
||||
noteId: z.string().optional().describe('Optional: restrict search to attachments of a specific note'),
|
||||
limit: z.number().optional().describe('Max results to return (default 5)').default(5),
|
||||
}),
|
||||
execute: async ({ query, noteId, limit = 5 }) => {
|
||||
try {
|
||||
const queryEmbedding = await embeddingService.generateEmbedding(query)
|
||||
const vectorStr = embeddingService.toVectorString(queryEmbedding.embedding)
|
||||
|
||||
let noteFilter = ''
|
||||
const params: any[] = [vectorStr, limit, ctx.userId]
|
||||
|
||||
if (noteId) {
|
||||
noteFilter = `AND na."noteId" = $4`
|
||||
params.push(noteId)
|
||||
}
|
||||
|
||||
const results = await prisma.$queryRawUnsafe(
|
||||
`SELECT
|
||||
dc.id as "chunkId",
|
||||
dc.content,
|
||||
dc."pageNumber",
|
||||
dc."chunkIndex",
|
||||
na.id as "attachmentId",
|
||||
na."fileName",
|
||||
na."pageCount",
|
||||
na."noteId",
|
||||
n.title as "noteTitle"
|
||||
FROM "DocumentChunk" dc
|
||||
JOIN "NoteAttachment" na ON na.id = dc."attachmentId"
|
||||
JOIN "Note" n ON n.id = na."noteId"
|
||||
WHERE dc."embedding" IS NOT NULL
|
||||
AND na.status = 'ready'
|
||||
AND n."trashedAt" IS NULL
|
||||
AND n."userId" = $3
|
||||
${noteFilter}
|
||||
ORDER BY dc."embedding" <=> $1::vector
|
||||
LIMIT $2`,
|
||||
...params
|
||||
) as any[]
|
||||
|
||||
if (!results.length) return { results: [], message: 'No matching documents found' }
|
||||
|
||||
return results.map(r => ({
|
||||
content: r.content.substring(0, 600),
|
||||
pageNumber: r.pageNumber,
|
||||
chunkIndex: r.chunkIndex,
|
||||
fileName: r.fileName,
|
||||
noteId: r.noteId,
|
||||
noteTitle: r.noteTitle || 'Untitled',
|
||||
}))
|
||||
} catch (e: any) {
|
||||
return { error: `Document search failed: ${e.message}` }
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
@@ -13,6 +13,8 @@ import './memory.tool'
|
||||
import './excalidraw.tool'
|
||||
import './pptx.tool'
|
||||
import './slides.tool'
|
||||
import './document-search.tool'
|
||||
import './task-extract.tool'
|
||||
|
||||
// Re-export registry
|
||||
export { toolRegistry, type ToolContext, type RegisteredTool } from './registry'
|
||||
|
||||
@@ -60,7 +60,7 @@ const PALETTES: Record<string, Theme> = {
|
||||
coastal_coral: { primary: '005f73', secondary: '0a9396', accent: 'ee9b00', light: 'e9f5f5', bg: 'ffffff' },
|
||||
vibrant_orange_mint: { primary: 'e05c00', secondary: '2ec4b6', accent: 'ff9f1c', light: 'edfaf9', bg: 'ffffff' },
|
||||
platinum_white_gold: { primary: '0a0a0a', secondary: '404040', accent: 'c9a84c', light: 'f5f5f0', bg: 'ffffff' },
|
||||
architectural_mono: { primary: '1C1C1C', secondary: '75B2D6', accent: 'D4A373', light: 'EDE9DF', bg: 'F2F0E9' },
|
||||
architectural_mono: { primary: '1C1C1C', secondary: 'A47148', accent: 'D4A373', light: 'EDE9DF', bg: 'F2F0E9' },
|
||||
minimal_silk: { primary: '212529', secondary: '6c757d', accent: 'dee2e6', light: 'f8f9fa', bg: 'ffffff' },
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ class ToolRegistry {
|
||||
* When webOnly is true, only web tools are included (no note access).
|
||||
*/
|
||||
buildToolsForChat(ctx: ToolContext & { webOnly?: boolean }): Record<string, any> {
|
||||
const toolNames: string[] = ctx.webOnly ? [] : ['note_search', 'note_read']
|
||||
const toolNames: string[] = ctx.webOnly ? [] : ['note_search', 'note_read', 'document_search', 'task_extract']
|
||||
|
||||
// Add web tools only when user toggled web search AND config is present
|
||||
if (ctx.webSearch) {
|
||||
|
||||
106
memento-note/lib/ai/tools/task-extract.tool.ts
Normal file
106
memento-note/lib/ai/tools/task-extract.tool.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { tool } from 'ai'
|
||||
import { z } from 'zod'
|
||||
import { toolRegistry } from './registry'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getTagsProvider } from '@/lib/ai/factory'
|
||||
import { getSystemConfig } from '@/lib/config'
|
||||
|
||||
toolRegistry.register({
|
||||
name: 'task_extract',
|
||||
description: 'Extract action items (TODOs) from notes in a notebook. Reads all notes, identifies tasks with assignees and deadlines, and creates a synthesis note.',
|
||||
isInternal: true,
|
||||
buildTool: (ctx) =>
|
||||
tool({
|
||||
description: 'Extract action items from notes in a notebook. Creates a new note with all identified tasks.',
|
||||
inputSchema: z.object({
|
||||
notebookId: z.string().optional().describe('Notebook ID to scan. If omitted, scans all user notes.'),
|
||||
noteIds: z.array(z.string()).optional().describe('Specific note IDs to scan instead of a whole notebook.'),
|
||||
locale: z.string().optional().describe('Language for the output (fr, en, es, de, etc.)'),
|
||||
}),
|
||||
execute: async ({ notebookId, noteIds, locale }) => {
|
||||
try {
|
||||
let where: any = { userId: ctx.userId, trashedAt: null }
|
||||
if (noteIds && noteIds.length > 0) {
|
||||
where.id = { in: noteIds }
|
||||
} else if (notebookId) {
|
||||
where.notebookId = notebookId
|
||||
}
|
||||
|
||||
const notes = await prisma.note.findMany({
|
||||
where,
|
||||
select: { id: true, title: true, content: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 50,
|
||||
})
|
||||
|
||||
if (notes.length === 0) {
|
||||
return { error: 'No notes found to analyze' }
|
||||
}
|
||||
|
||||
const notesContext = notes.map(n =>
|
||||
`[ID: ${n.id}] "${n.title}":\n${(n.content || '').slice(0, 800)}`
|
||||
).join('\n\n---\n\n')
|
||||
|
||||
const lang = locale === 'fr' ? 'français' : locale === 'es' ? 'espagnol' : locale === 'de' ? 'allemand' : locale === 'it' ? 'italien' : locale === 'pt' ? 'portugais' : locale === 'nl' ? 'néerlandais' : locale === 'ru' ? 'russe' : locale === 'zh' ? 'chinois' : locale === 'ja' ? 'japonais' : locale === 'ar' ? 'arabe' : locale === 'fa' ? 'persan' : locale === 'hi' ? 'hindi' : 'English'
|
||||
|
||||
const config = await getSystemConfig()
|
||||
const provider = getTagsProvider(config)
|
||||
|
||||
const prompt = `You are a task extraction specialist. Analyze the following notes and extract ALL action items, tasks, and TODOs.
|
||||
|
||||
For each task identified, provide:
|
||||
- **Task**: Clear, actionable description
|
||||
- **Source**: The note title where it was found
|
||||
- **Assignee**: If mentioned (otherwise "Unassigned")
|
||||
- **Deadline**: If mentioned (otherwise "No deadline")
|
||||
- **Priority**: High/Medium/Low based on urgency signals in the text
|
||||
- **Status**: If already completed or in-progress based on context
|
||||
|
||||
NOTES TO ANALYZE:
|
||||
${notesContext}
|
||||
|
||||
Respond in ${lang}. Structure the output as a clean Markdown document with:
|
||||
1. A summary paragraph
|
||||
2. Tasks grouped by priority (High → Medium → Low)
|
||||
3. A summary table at the end
|
||||
|
||||
Format each task as:
|
||||
### [Priority] Task Title
|
||||
- **Description**: ...
|
||||
- **Source note**: ...
|
||||
- **Assignee**: ...
|
||||
- **Deadline**: ...
|
||||
- **Status**: ...`
|
||||
|
||||
const result = await provider.generateText(prompt)
|
||||
|
||||
const summaryTitle = locale === 'fr'
|
||||
? `Action Items — ${new Date().toLocaleDateString('fr-FR')}`
|
||||
: `Action Items — ${new Date().toLocaleDateString('en-US')}`
|
||||
|
||||
const createdNote = await prisma.note.create({
|
||||
data: {
|
||||
title: summaryTitle,
|
||||
content: result,
|
||||
type: 'markdown',
|
||||
isMarkdown: true,
|
||||
autoGenerated: true,
|
||||
userId: ctx.userId,
|
||||
notebookId: notebookId || null,
|
||||
},
|
||||
select: { id: true, title: true },
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
noteId: createdNote.id,
|
||||
title: createdNote.title,
|
||||
notesAnalyzed: notes.length,
|
||||
tasksNoteUrl: `/notes/${createdNote.id}`,
|
||||
}
|
||||
} catch (e: any) {
|
||||
return { error: `Task extraction failed: ${e.message}` }
|
||||
}
|
||||
},
|
||||
}),
|
||||
})
|
||||
130
memento-note/lib/brainstorm-collab.ts
Normal file
130
memento-note/lib/brainstorm-collab.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import prisma from '@/lib/prisma'
|
||||
|
||||
export async function verifyParticipant(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
requiredRole?: 'host' | 'editor' | 'viewer'
|
||||
): Promise<{ isParticipant: boolean; role: string }> {
|
||||
const participant = await prisma.brainstormParticipant.findFirst({
|
||||
where: { sessionId, userId },
|
||||
})
|
||||
|
||||
if (!participant) {
|
||||
return { isParticipant: false, role: 'none' }
|
||||
}
|
||||
|
||||
await prisma.brainstormParticipant.update({
|
||||
where: { id: participant.id },
|
||||
data: { lastSeenAt: new Date() },
|
||||
})
|
||||
|
||||
if (requiredRole === 'host' && participant.role !== 'host') {
|
||||
return { isParticipant: false, role: participant.role }
|
||||
}
|
||||
if (requiredRole === 'editor' && participant.role === 'viewer') {
|
||||
return { isParticipant: false, role: participant.role }
|
||||
}
|
||||
|
||||
return { isParticipant: true, role: participant.role }
|
||||
}
|
||||
|
||||
export async function logActivity(
|
||||
sessionId: string,
|
||||
action: string,
|
||||
userId?: string | null,
|
||||
details?: Record<string, any>
|
||||
) {
|
||||
await prisma.brainstormActivity.create({
|
||||
data: {
|
||||
sessionId,
|
||||
userId: userId || null,
|
||||
action,
|
||||
details: details ? JSON.stringify(details) : null,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// [UPDATE - SÉCURITÉ] Résoudre l'userId et le périmètre de notes autorisé pour les appels IA.
|
||||
// - Hôte : accès complet à ses notes (publicNoteIds = null)
|
||||
// - Invité : restreint aux contextNoteIds publics de la session (publicNoteIds = string[] | [])
|
||||
export async function resolveAiContextUserId(
|
||||
sessionId: string,
|
||||
requestingUserId: string
|
||||
): Promise<{ aiUserId: string; isGuest: boolean; publicNoteIds: string[] | null }> {
|
||||
const session = await prisma.brainstormSession.findUnique({
|
||||
where: { id: sessionId },
|
||||
select: {
|
||||
userId: true,
|
||||
contextNoteIds: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!session) throw new Error('Session not found')
|
||||
|
||||
const isHost = session.userId === requestingUserId
|
||||
if (isHost) {
|
||||
return { aiUserId: requestingUserId, isGuest: false, publicNoteIds: null }
|
||||
}
|
||||
|
||||
// Invité : on restreint aux contextNoteIds déclarés publics par l'hôte
|
||||
const publicNoteIds: string[] = session.contextNoteIds
|
||||
? (JSON.parse(session.contextNoteIds) as string[])
|
||||
: []
|
||||
|
||||
return {
|
||||
aiUserId: session.userId,
|
||||
isGuest: true,
|
||||
publicNoteIds: publicNoteIds.length > 0 ? publicNoteIds : [],
|
||||
}
|
||||
}
|
||||
|
||||
// [UPDATE - SÉCURITÉ] Sanitize les notes injectées dans un prompt IA pour un invité.
|
||||
// Tronque le contenu et masque les entités nommées (Prénom Nom) avec [Person].
|
||||
export function sanitizeNotesForGuest(
|
||||
notes: { id: string; title: string | null; summary: string }[]
|
||||
): { id: string; title: string; summary: string }[] {
|
||||
const namedEntityRe = /\b[A-ZÀ-Ü][a-zà-ü]+ [A-ZÀ-Ü][a-zà-ü]+\b/g
|
||||
return notes.map(n => ({
|
||||
id: n.id,
|
||||
title: (n.title || 'Note').replace(namedEntityRe, '[Person]'),
|
||||
summary: n.summary.slice(0, 80).replace(namedEntityRe, '[Person]') + '…',
|
||||
}))
|
||||
}
|
||||
|
||||
export async function captureSnapshot(
|
||||
sessionId: string,
|
||||
label: string,
|
||||
activityId?: string
|
||||
): Promise<void> {
|
||||
const ideas = await prisma.brainstormIdea.findMany({
|
||||
where: { sessionId, status: 'active' },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
waveNumber: true,
|
||||
positionX: true,
|
||||
positionY: true,
|
||||
parentIdeaId: true,
|
||||
noveltyScore: true,
|
||||
createdByType: true,
|
||||
status: true,
|
||||
},
|
||||
orderBy: [{ waveNumber: 'asc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
|
||||
const maxStep = await prisma.brainstormSnapshot.findFirst({
|
||||
where: { sessionId },
|
||||
orderBy: { step: 'desc' },
|
||||
select: { step: true },
|
||||
})
|
||||
|
||||
await prisma.brainstormSnapshot.create({
|
||||
data: {
|
||||
sessionId,
|
||||
activityId: activityId || null,
|
||||
step: (maxStep?.step || 0) + 1,
|
||||
label,
|
||||
ideaGraph: JSON.stringify(ideas),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -18,6 +18,11 @@ export const queryKeys = {
|
||||
aiSettings: (userId: string) => ['ai', 'settings', userId] as const,
|
||||
titleSuggestions: (content: string) => ['ai', 'title-suggestions', content] as const,
|
||||
autoTags: (content: string, notebookId?: string | null) => ['ai', 'auto-tags', content, notebookId] as const,
|
||||
|
||||
// Brainstorm
|
||||
brainstormSessions: () => ['brainstorm', 'sessions'] as const,
|
||||
brainstormSharedSessions: () => ['brainstorm', 'shared-sessions'] as const,
|
||||
brainstormSession: (sessionId: string) => ['brainstorm', 'session', sessionId] as const,
|
||||
} as const
|
||||
|
||||
export type QueryKeys = typeof queryKeys
|
||||
|
||||
27
memento-note/lib/socket-emit.ts
Normal file
27
memento-note/lib/socket-emit.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// [UPDATE - TEMPS RÉEL] Helper pour émettre des événements Socket.io depuis les API routes Next.js.
|
||||
// Utilise un canal HTTP interne vers le process socket-server.ts séparé.
|
||||
// Non-fatal : en cas d'échec, le client récupérera l'état via React Query polling.
|
||||
|
||||
export async function emitToSession(
|
||||
sessionId: string,
|
||||
event: string,
|
||||
data: unknown
|
||||
): Promise<void> {
|
||||
const socketUrl = process.env.SOCKET_INTERNAL_URL || 'http://localhost:3003'
|
||||
const internalKey = process.env.SOCKET_INTERNAL_KEY || ''
|
||||
|
||||
try {
|
||||
await fetch(`${socketUrl}/emit`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-internal-key': internalKey,
|
||||
},
|
||||
body: JSON.stringify({ sessionId, event, data }),
|
||||
signal: AbortSignal.timeout(2000), // 2s max — ne pas bloquer l'API
|
||||
})
|
||||
} catch {
|
||||
// Non-fatal — le canal Socket est best-effort
|
||||
// Le client se resynchronise via invalidation React Query
|
||||
}
|
||||
}
|
||||
74
memento-note/lib/utils/format-localized-date.ts
Normal file
74
memento-note/lib/utils/format-localized-date.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { format, type Locale } from 'date-fns'
|
||||
import { toJalaali } from 'jalaali-js'
|
||||
|
||||
const JALALI_MONTHS_FA = [
|
||||
'فروردین',
|
||||
'اردیبهشت',
|
||||
'خرداد',
|
||||
'تیر',
|
||||
'مرداد',
|
||||
'شهریور',
|
||||
'مهر',
|
||||
'آبان',
|
||||
'آذر',
|
||||
'دی',
|
||||
'بهمن',
|
||||
'اسفند',
|
||||
] as const
|
||||
|
||||
const PERSIAN_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹'] as const
|
||||
|
||||
/** Western digits → Persian (Extended Arabic-Indic) numerals, e.g. 1405 → ۱۴۰۵ */
|
||||
export function toPersianDigits(input: string): string {
|
||||
return input.replace(/\d/g, (ch) => PERSIAN_DIGITS[Number(ch)] ?? ch)
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n < 10 ? `0${n}` : `${n}`
|
||||
}
|
||||
|
||||
function formatJalaliAbsolute(date: Date, pattern: string): string {
|
||||
const { jy, jm, jd } = toJalaali(date)
|
||||
const monthName = JALALI_MONTHS_FA[jm - 1]
|
||||
const h = pad2(date.getHours())
|
||||
const min = pad2(date.getMinutes())
|
||||
|
||||
let s: string
|
||||
switch (pattern) {
|
||||
case 'd MMM yyyy':
|
||||
s = `${jd} ${monthName} ${jy}`
|
||||
break
|
||||
case 'd MMM yyyy · HH:mm':
|
||||
s = `${jd} ${monthName} ${jy} · ${h}:${min}`
|
||||
break
|
||||
case 'd MMM yyyy HH:mm':
|
||||
s = `${jd} ${monthName} ${jy} ${h}:${min}`
|
||||
break
|
||||
case 'd MMM · HH:mm':
|
||||
s = `${jd} ${monthName} · ${h}:${min}`
|
||||
break
|
||||
case 'MMM d, yyyy':
|
||||
s = `${monthName} ${jd}، ${jy}`
|
||||
break
|
||||
default:
|
||||
s = `${jd} ${monthName} ${jy}`
|
||||
}
|
||||
return toPersianDigits(s)
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute calendar dates for Persian (`fa`) use the Solar Hijri (Jalali / هجری شمسی) calendar.
|
||||
* Times remain in the user's local timezone. For relative phrases, keep using `formatDistanceToNow` with `faIR`.
|
||||
*/
|
||||
export function formatAbsoluteDateLocalized(
|
||||
date: Date | string,
|
||||
language: string,
|
||||
pattern: string,
|
||||
locale: Locale
|
||||
): string {
|
||||
const d = typeof date === 'string' ? new Date(date) : date
|
||||
if (language === 'fa') {
|
||||
return formatJalaliAbsolute(d, pattern)
|
||||
}
|
||||
return format(d, pattern, { locale })
|
||||
}
|
||||
Reference in New Issue
Block a user