From 82f87b65cb7901de6630aa64d9108680a188dbd3 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Sun, 14 Jun 2026 20:30:10 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20AI=20Writer=20inline=20=E2=80=94=20g?= =?UTF-8?q?=C3=A9n=C3=A8re=20du=20contenu=20au=20curseur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mode 'write' ajouté à paragraph-refactor.service.ts - Endpoint /api/ai/reformulate étendu (option 'write' + writePrompt) - UI : champ de prompt inline apparaît au curseur après slash menu - Tape / → 'Écrire avec l'IA' → décrit ce que tu veux → Entrée - L'IA génère et insère le contenu à la position du curseur - Pas de migration DB, réutilise l'infra existante - i18n FR/EN --- memento-note/app/api/ai/reformulate/route.ts | 34 +++---- memento-note/components/rich-text-editor.tsx | 99 ++++++++++++++++++- .../ai/services/paragraph-refactor.service.ts | 56 ++++++++--- memento-note/locales/en.json | 3 + memento-note/locales/fr.json | 3 + 5 files changed, 159 insertions(+), 36 deletions(-) diff --git a/memento-note/app/api/ai/reformulate/route.ts b/memento-note/app/api/ai/reformulate/route.ts index 02599e4..1039429 100644 --- a/memento-note/app/api/ai/reformulate/route.ts +++ b/memento-note/app/api/ai/reformulate/route.ts @@ -23,39 +23,37 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: 'Feature disabled' }, { status: 403 }) } - const { text, option, format, language } = await request.json() - - // Validation - if (!text || typeof text !== 'string') { - return NextResponse.json({ error: 'Text is required' }, { status: 400 }) - } + const { text, option, format, language, writePrompt } = await request.json() // Map option to refactor mode - const modeMap: Record = { + const modeMap: Record = { 'clarify': 'clarify', 'shorten': 'shorten', 'improve': 'improveStyle', 'fix_grammar': 'fix_grammar', 'translate': 'translate', - 'explain': 'explain' + 'explain': 'explain', + 'write': 'write' } const mode = modeMap[option] if (!mode) { return NextResponse.json( - { error: 'Invalid option. Use: clarify, shorten, improve, fix_grammar, translate, or explain' }, + { error: 'Invalid option. Use: clarify, shorten, improve, fix_grammar, translate, explain, or write' }, { status: 400 } ) } - // Validate word count - const validation = paragraphRefactorService.validateWordCount(text) - if (!validation.valid) { - return NextResponse.json({ - error: validation.error, - errorKey: validation.errorKey, - params: validation.params, - }, { status: 400 }) + // Validate word count (skip for 'write' mode — no selection needed) + if (mode !== 'write') { + const validation = paragraphRefactorService.validateWordCount(text) + if (!validation.valid) { + return NextResponse.json({ + error: validation.error, + errorKey: validation.errorKey, + params: validation.params, + }, { status: 400 }) + } } // Check quota @@ -75,7 +73,7 @@ export async function POST(request: NextRequest) { } // Use the ParagraphRefactorService - const result = await paragraphRefactorService.refactor(text, mode, format === 'html' ? 'html' : 'markdown', language) + const result = await paragraphRefactorService.refactor(text, mode, format === 'html' ? 'html' : 'markdown', language, writePrompt) incrementUsageAsync(session.user.id, 'reformulate') diff --git a/memento-note/components/rich-text-editor.tsx b/memento-note/components/rich-text-editor.tsx index fab2e27..4a71420 100644 --- a/memento-note/components/rich-text-editor.tsx +++ b/memento-note/components/rich-text-editor.tsx @@ -109,7 +109,7 @@ type SlashItem = { shortcut?: string isImage?: boolean isAi?: boolean - aiOption?: 'clarify' | 'shorten' | 'improve' + aiOption?: 'clarify' | 'shorten' | 'improve' | 'write' command: (editor: Editor, range?: any) => void } @@ -173,7 +173,12 @@ const slashCommands: SlashItem[] = [ { title: 'Clarifier', description: 'Rendre le texte plus clair', icon: Lightbulb, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => { } }, { title: 'Raccourcir', description: 'Condenser le texte', icon: Scissors, category: 'IA Note', isAi: true, aiOption: 'shorten', command: () => { } }, { title: 'Améliorer', description: 'Améliorer le style', icon: Wand2, category: 'IA Note', isAi: true, aiOption: 'improve', command: () => { } }, - { title: 'Développer', description: 'Élaborer et enrichir le texte', icon: Expand, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => { } }, + { + title: 'Développer', description: 'Élaborer et enrichir le texte', icon: Expand, category: 'IA Note', isAi: true, aiOption: 'clarify', command: () => { } + }, + { + title: 'Écrire avec l\'IA', description: 'Générer du contenu au curseur', icon: Sparkles, category: 'IA Note', isAi: true, aiOption: 'write', command: () => { } + }, // Formatting extensions { title: 'Bold', description: 'Make text bold', icon: Bold, category: 'Formatting', command: (e) => e.chain().focus().toggleBold().run() }, { title: 'Italic', description: 'Make text italic', icon: Italic, category: 'Formatting', command: (e) => e.chain().focus().toggleItalic().run() }, @@ -1672,6 +1677,9 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: const [coords, setCoords] = useState({ top: 0, left: 0 }) const [previewCoords, setPreviewCoords] = useState({ top: 0, left: 0, side: 'right' as 'right' | 'left' }) const [aiLoading, setAiLoading] = useState(false) + const [aiWriterOpen, setAiWriterOpen] = useState(false) + const [aiWriterPrompt, setAiWriterPrompt] = useState('') + const [aiWriterLoading, setAiWriterLoading] = useState(false) const menuRef = useRef(null) const selectedItemRef = useRef(null) const menuInteracting = useRef(false) @@ -1715,6 +1723,7 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: { ...slashCommands[34], title: t('richTextEditor.slashLinkPreview'), description: t('richTextEditor.slashLinkPreviewDesc'), categoryId: 'embed', slashKeywords: ['link', 'lien', 'url', 'preview', 'apercu', 'aperçu', 'embed', 'card', 'carte'] }, { ...slashCommands[35], title: t('richTextEditor.slashMath'), description: t('richTextEditor.slashMathDesc'), categoryId: 'text', slashKeywords: ['math', 'maths', 'equation', 'équation', 'formula', 'formule', 'latex', 'katex'] }, { ...slashCommands[36], title: t('richTextEditor.slashColumns'), description: t('richTextEditor.slashColumnsDesc'), categoryId: 'text', slashKeywords: ['columns', 'colonnes', 'cols', 'layout', 'mise', 'page', 'cote', 'côte'] }, + { ...slashCommands[37], title: t('richTextEditor.slashAiWriter') || 'Écrire avec l\'IA', description: t('richTextEditor.slashAiWriterDesc') || 'Générer du contenu au curseur', categoryId: 'ai', slashKeywords: ['ecrire', 'écrire', 'write', 'ia', 'ai', 'generer', 'générer', 'rediger', 'rédiger'] }, { title: t('richTextEditor.slashNoteLink'), description: t('richTextEditor.slashNoteLinkDesc'), @@ -1757,6 +1766,10 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: } if (item.isImage) { deleteSlashText(); closeMenu(); onInsertImage(editor) + } else if (item.isAi && item.aiOption === 'write') { + deleteSlashText(); closeMenu() + setAiWriterOpen(true) + setAiWriterPrompt('') } else if (item.isAi && item.aiOption) { deleteSlashText(); closeMenu(); setAiLoading(true) try { @@ -1784,6 +1797,43 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: } }, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t, requestAiConsent]) + const handleAiWriterSubmit = useCallback(async () => { + if (!aiWriterPrompt.trim() || !editor) return + setAiWriterLoading(true) + try { + const consented = await requestAiConsent() + if (!consented) return + + const noteTitle = (editor.storage as any).structuredViewBlock?.noteTitle || '' + const res = await fetch('/api/ai/reformulate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: noteTitle, + option: 'write', + format: 'html', + language: 'Francais', + writePrompt: aiWriterPrompt.trim(), + }), + }) + const data = await res.json() + if (!res.ok) { + toast.error(data.error || 'Erreur') + return + } + const html = data.reformulatedText || data.text || '' + if (html) { + editor.chain().focus().insertContent(html).run() + } + setAiWriterOpen(false) + setAiWriterPrompt('') + } catch (err: any) { + toast.error(err.message || 'Erreur') + } finally { + setAiWriterLoading(false) + } + }, [aiWriterPrompt, editor, requestAiConsent]) + // Charger les favoris fréquents lors de l'ouverture useEffect(() => { if (!isOpen) return @@ -2071,6 +2121,51 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor: )} + + {aiWriterOpen && createPortal( +
{ + const { from } = editor.state.selection + const c = editor.view.coordsAtPos(from) + return c.bottom + 4 + })(), + left: (() => { + const { from } = editor.state.selection + const c = editor.view.coordsAtPos(from) + return Math.min(c.left, window.innerWidth - 420) + })(), + }} + dir="auto" + > + + setAiWriterPrompt(e.target.value)} + onKeyDown={(e) => { + e.stopPropagation() + if (e.key === 'Enter') { e.preventDefault(); handleAiWriterSubmit() } + if (e.key === 'Escape') { setAiWriterOpen(false); setAiWriterPrompt('') } + }} + autoFocus + disabled={aiWriterLoading} + placeholder={t('richTextEditor.aiWriterPlaceholder') || 'Décris ce que tu veux écrire...'} + className="flex-1 min-w-[300px] bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> + {aiWriterLoading && } + {!aiWriterLoading && aiWriterPrompt.trim() && ( + + )} +
, + document.body + )} , document.body ) diff --git a/memento-note/lib/ai/services/paragraph-refactor.service.ts b/memento-note/lib/ai/services/paragraph-refactor.service.ts index dd6976e..b23949d 100644 --- a/memento-note/lib/ai/services/paragraph-refactor.service.ts +++ b/memento-note/lib/ai/services/paragraph-refactor.service.ts @@ -10,7 +10,7 @@ import { LanguageDetectionService } from './language-detection.service' import { getTagsProvider } from '../factory' import { getSystemConfig } from '@/lib/config' -export type RefactorMode = 'clarify' | 'shorten' | 'improveStyle' | 'fix_grammar' | 'translate' | 'explain' +export type RefactorMode = 'clarify' | 'shorten' | 'improveStyle' | 'fix_grammar' | 'translate' | 'explain' | 'write' export interface RefactorOption { mode: RefactorMode @@ -69,24 +69,29 @@ export class ParagraphRefactorService { content: string, mode: RefactorMode, format: 'html' | 'markdown' = 'markdown', - targetLanguage?: string + targetLanguage?: string, + writePrompt?: string ): Promise { - // Validate word count - const wordCount = content.split(/\s+/).length - if (wordCount < this.MIN_WORDS || wordCount > this.MAX_WORDS) { - throw new Error( - `Please select ${this.MIN_WORDS}-${this.MAX_WORDS} words to reformulate` - ) + // 'write' mode skips word count validation (generates from prompt, no selection needed) + if (mode !== 'write') { + const wordCount = content.split(/\s+/).length + if (wordCount < this.MIN_WORDS || wordCount > this.MAX_WORDS) { + throw new Error( + `Please select ${this.MIN_WORDS}-${this.MAX_WORDS} words to reformulate` + ) + } } // Detect language or use provided target language - const { language: detectedLanguage } = await this.languageDetection.detectLanguage(content) + const { language: detectedLanguage } = mode === 'write' + ? { language: targetLanguage || 'fr' } + : await this.languageDetection.detectLanguage(content) const language = targetLanguage || detectedLanguage try { // Build prompts const systemPrompt = this.getSystemPrompt(mode, format) - const userPrompt = this.getUserPrompt(mode, content, language, format) + const userPrompt = this.getUserPrompt(mode, content, language, format, writePrompt) // Get AI provider from factory const config = await getSystemConfig() @@ -99,10 +104,10 @@ export class ParagraphRefactorService { // Calculate word count change const refactoredWordCount = refactored.split(/\s+/).length const wordCountChange = { - original: wordCount, + original: mode === 'write' ? 0 : content.split(/\s+/).length, refactored: refactoredWordCount, - difference: refactoredWordCount - wordCount, - percentage: ((refactoredWordCount - wordCount) / wordCount) * 100 + difference: refactoredWordCount - (mode === 'write' ? 0 : content.split(/\s+/).length), + percentage: 0 } return { @@ -252,7 +257,13 @@ Your goal: Explain the selected text, concept, or word clearly and concisely. Provide context, definitions, or relevant information to help the user understand it. CRITICAL LANGUAGE RULE: You MUST explain it in the requested language (which is the user's interface language). -Keep it concise but informative.${formatInstruction}` +Keep it concise but informative.${formatInstruction}`, + + write: `You are an expert writer and content creator. +Your goal: Write new content based on the user's instruction. Be clear, well-structured, and informative. +Write naturally in the requested language. + +If context is provided (note title or surrounding text), use it to make the content relevant.${formatInstruction}` } return prompts[mode] @@ -261,7 +272,7 @@ Keep it concise but informative.${formatInstruction}` /** * Get mode-specific user prompt */ - private getUserPrompt(mode: RefactorMode, content: string, language: string, format: 'html' | 'markdown' = 'markdown'): string { + private getUserPrompt(mode: RefactorMode, content: string, language: string, format: 'html' | 'markdown' = 'markdown', writePrompt?: string): string { const instructions = { clarify: `IMPORTANT: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English. @@ -283,7 +294,20 @@ Please fix any spelling, grammar, or punctuation errors in this ${language} text Only return the translated text, nothing else.`, explain: `Please explain the following text/concept in ${language}. -Keep the explanation clear, educational, and concise.` +Keep the explanation clear, educational, and concise.`, + + write: `Write the following in ${language}. Only return the content, no meta-commentary.` + } + + if (mode === 'write') { + const suffix = `CRITICAL: Respond ONLY with the generated content in ${language}. No explanations, no meta-commentary. Format strictly as ${format === 'html' ? 'HTML tags' : 'Markdown'}.` + const contextPart = content.trim() ? `\n\nContext (note: "${content.trim().slice(0, 100)}"):\n` : '' + return `${instructions.write} + +${writePrompt || 'Write a paragraph about the current topic.'} + +${contextPart} +${suffix}` } const systemSuffix = mode === 'explain' diff --git a/memento-note/locales/en.json b/memento-note/locales/en.json index 5b32cac..207eecf 100644 --- a/memento-note/locales/en.json +++ b/memento-note/locales/en.json @@ -2560,6 +2560,9 @@ "pdfExportSuccess": "PDF ready!", "generateExercises": "Generate exercises", "generateExercisesHint": "5 exercises + answers", + "slashAiWriter": "Write with AI", + "slashAiWriterDesc": "Generate content at cursor", + "aiWriterPlaceholder": "Describe what you want to write...", "exercisesLoading": "Generating exercises...", "exercisesGenerated": "exercises created!", "aiGenerateExercises": "Generate exercises", diff --git a/memento-note/locales/fr.json b/memento-note/locales/fr.json index 43ce987..007fb87 100644 --- a/memento-note/locales/fr.json +++ b/memento-note/locales/fr.json @@ -2564,6 +2564,9 @@ "pdfExportSuccess": "PDF prêt !", "generateExercises": "Générer des exercices", "generateExercisesHint": "5 exercices + corrigés", + "slashAiWriter": "Écrire avec l'IA", + "slashAiWriterDesc": "Générer du contenu au curseur", + "aiWriterPlaceholder": "Décris ce que tu veux écrire...", "exercisesLoading": "Génération des exercices...", "exercisesGenerated": "exercices créés !", "aiGenerateExercises": "Générer des exercices",