feat: AI Writer inline — génère du contenu au curseur
Some checks failed
CI / Lint, Unit Tests & Build (push) Failing after 1m19s
CI / Deploy production (on server) (push) Has been skipped

- 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
This commit is contained in:
Antigravity
2026-06-14 20:30:10 +00:00
parent b9a80f9e64
commit 82f87b65cb
5 changed files with 159 additions and 36 deletions

View File

@@ -23,39 +23,37 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 }) return NextResponse.json({ error: 'Feature disabled' }, { status: 403 })
} }
const { text, option, format, language } = await request.json() const { text, option, format, language, writePrompt } = await request.json()
// Validation
if (!text || typeof text !== 'string') {
return NextResponse.json({ error: 'Text is required' }, { status: 400 })
}
// Map option to refactor mode // Map option to refactor mode
const modeMap: Record<string, 'clarify' | 'shorten' | 'improveStyle' | 'fix_grammar' | 'translate' | 'explain'> = { const modeMap: Record<string, 'clarify' | 'shorten' | 'improveStyle' | 'fix_grammar' | 'translate' | 'explain' | 'write'> = {
'clarify': 'clarify', 'clarify': 'clarify',
'shorten': 'shorten', 'shorten': 'shorten',
'improve': 'improveStyle', 'improve': 'improveStyle',
'fix_grammar': 'fix_grammar', 'fix_grammar': 'fix_grammar',
'translate': 'translate', 'translate': 'translate',
'explain': 'explain' 'explain': 'explain',
'write': 'write'
} }
const mode = modeMap[option] const mode = modeMap[option]
if (!mode) { if (!mode) {
return NextResponse.json( 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 } { status: 400 }
) )
} }
// Validate word count // Validate word count (skip for 'write' mode — no selection needed)
const validation = paragraphRefactorService.validateWordCount(text) if (mode !== 'write') {
if (!validation.valid) { const validation = paragraphRefactorService.validateWordCount(text)
return NextResponse.json({ if (!validation.valid) {
error: validation.error, return NextResponse.json({
errorKey: validation.errorKey, error: validation.error,
params: validation.params, errorKey: validation.errorKey,
}, { status: 400 }) params: validation.params,
}, { status: 400 })
}
} }
// Check quota // Check quota
@@ -75,7 +73,7 @@ export async function POST(request: NextRequest) {
} }
// Use the ParagraphRefactorService // 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') incrementUsageAsync(session.user.id, 'reformulate')

View File

@@ -109,7 +109,7 @@ type SlashItem = {
shortcut?: string shortcut?: string
isImage?: boolean isImage?: boolean
isAi?: boolean isAi?: boolean
aiOption?: 'clarify' | 'shorten' | 'improve' aiOption?: 'clarify' | 'shorten' | 'improve' | 'write'
command: (editor: Editor, range?: any) => void 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: '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: '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: '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 // Formatting extensions
{ title: 'Bold', description: 'Make text bold', icon: Bold, category: 'Formatting', command: (e) => e.chain().focus().toggleBold().run() }, { 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() }, { 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 [coords, setCoords] = useState({ top: 0, left: 0 })
const [previewCoords, setPreviewCoords] = useState({ top: 0, left: 0, side: 'right' as 'right' | 'left' }) const [previewCoords, setPreviewCoords] = useState({ top: 0, left: 0, side: 'right' as 'right' | 'left' })
const [aiLoading, setAiLoading] = useState(false) const [aiLoading, setAiLoading] = useState(false)
const [aiWriterOpen, setAiWriterOpen] = useState(false)
const [aiWriterPrompt, setAiWriterPrompt] = useState('')
const [aiWriterLoading, setAiWriterLoading] = useState(false)
const menuRef = useRef<HTMLDivElement>(null) const menuRef = useRef<HTMLDivElement>(null)
const selectedItemRef = useRef<HTMLButtonElement>(null) const selectedItemRef = useRef<HTMLButtonElement>(null)
const menuInteracting = useRef(false) 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[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[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[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'), title: t('richTextEditor.slashNoteLink'),
description: t('richTextEditor.slashNoteLinkDesc'), description: t('richTextEditor.slashNoteLinkDesc'),
@@ -1757,6 +1766,10 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
} }
if (item.isImage) { if (item.isImage) {
deleteSlashText(); closeMenu(); onInsertImage(editor) deleteSlashText(); closeMenu(); onInsertImage(editor)
} else if (item.isAi && item.aiOption === 'write') {
deleteSlashText(); closeMenu()
setAiWriterOpen(true)
setAiWriterPrompt('')
} else if (item.isAi && item.aiOption) { } else if (item.isAi && item.aiOption) {
deleteSlashText(); closeMenu(); setAiLoading(true) deleteSlashText(); closeMenu(); setAiLoading(true)
try { try {
@@ -1784,6 +1797,43 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
} }
}, [editor, closeMenu, deleteSlashText, onInsertImage, onSuggestCharts, t, requestAiConsent]) }, [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 // Charger les favoris fréquents lors de l'ouverture
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
@@ -2071,6 +2121,51 @@ function SlashCommandMenu({ editor, onInsertImage, onSuggestCharts }: { editor:
<SlashPreview itemTitle={selectedItem.title} /> <SlashPreview itemTitle={selectedItem.title} />
</div> </div>
)} )}
{aiWriterOpen && createPortal(
<div
className="fixed z-[9999] flex items-center gap-2 rounded-xl border border-brand-accent/30 bg-card shadow-xl px-3 py-2"
style={{
top: (() => {
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"
>
<Sparkles size={16} className="text-brand-accent flex-shrink-0" />
<input
type="text"
value={aiWriterPrompt}
onChange={(e) => 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 && <Loader2 size={14} className="animate-spin text-brand-accent flex-shrink-0" />}
{!aiWriterLoading && aiWriterPrompt.trim() && (
<button
onClick={handleAiWriterSubmit}
className="text-xs font-medium text-brand-accent hover:underline flex-shrink-0"
>
{t('general.send') || '→'}
</button>
)}
</div>,
document.body
)}
</>, </>,
document.body document.body
) )

View File

@@ -10,7 +10,7 @@ import { LanguageDetectionService } from './language-detection.service'
import { getTagsProvider } from '../factory' import { getTagsProvider } from '../factory'
import { getSystemConfig } from '@/lib/config' 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 { export interface RefactorOption {
mode: RefactorMode mode: RefactorMode
@@ -69,24 +69,29 @@ export class ParagraphRefactorService {
content: string, content: string,
mode: RefactorMode, mode: RefactorMode,
format: 'html' | 'markdown' = 'markdown', format: 'html' | 'markdown' = 'markdown',
targetLanguage?: string targetLanguage?: string,
writePrompt?: string
): Promise<RefactorResult> { ): Promise<RefactorResult> {
// Validate word count // 'write' mode skips word count validation (generates from prompt, no selection needed)
const wordCount = content.split(/\s+/).length if (mode !== 'write') {
if (wordCount < this.MIN_WORDS || wordCount > this.MAX_WORDS) { const wordCount = content.split(/\s+/).length
throw new Error( if (wordCount < this.MIN_WORDS || wordCount > this.MAX_WORDS) {
`Please select ${this.MIN_WORDS}-${this.MAX_WORDS} words to reformulate` throw new Error(
) `Please select ${this.MIN_WORDS}-${this.MAX_WORDS} words to reformulate`
)
}
} }
// Detect language or use provided target language // 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 const language = targetLanguage || detectedLanguage
try { try {
// Build prompts // Build prompts
const systemPrompt = this.getSystemPrompt(mode, format) 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 // Get AI provider from factory
const config = await getSystemConfig() const config = await getSystemConfig()
@@ -99,10 +104,10 @@ export class ParagraphRefactorService {
// Calculate word count change // Calculate word count change
const refactoredWordCount = refactored.split(/\s+/).length const refactoredWordCount = refactored.split(/\s+/).length
const wordCountChange = { const wordCountChange = {
original: wordCount, original: mode === 'write' ? 0 : content.split(/\s+/).length,
refactored: refactoredWordCount, refactored: refactoredWordCount,
difference: refactoredWordCount - wordCount, difference: refactoredWordCount - (mode === 'write' ? 0 : content.split(/\s+/).length),
percentage: ((refactoredWordCount - wordCount) / wordCount) * 100 percentage: 0
} }
return { 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. 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). 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] return prompts[mode]
@@ -261,7 +272,7 @@ Keep it concise but informative.${formatInstruction}`
/** /**
* Get mode-specific user prompt * 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 = { const instructions = {
clarify: `IMPORTANT: The text below is in ${language}. Your response MUST be in ${language}. Do NOT translate to English. 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.`, Only return the translated text, nothing else.`,
explain: `Please explain the following text/concept in ${language}. 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' const systemSuffix = mode === 'explain'

View File

@@ -2560,6 +2560,9 @@
"pdfExportSuccess": "PDF ready!", "pdfExportSuccess": "PDF ready!",
"generateExercises": "Generate exercises", "generateExercises": "Generate exercises",
"generateExercisesHint": "5 exercises + answers", "generateExercisesHint": "5 exercises + answers",
"slashAiWriter": "Write with AI",
"slashAiWriterDesc": "Generate content at cursor",
"aiWriterPlaceholder": "Describe what you want to write...",
"exercisesLoading": "Generating exercises...", "exercisesLoading": "Generating exercises...",
"exercisesGenerated": "exercises created!", "exercisesGenerated": "exercises created!",
"aiGenerateExercises": "Generate exercises", "aiGenerateExercises": "Generate exercises",

View File

@@ -2564,6 +2564,9 @@
"pdfExportSuccess": "PDF prêt !", "pdfExportSuccess": "PDF prêt !",
"generateExercises": "Générer des exercices", "generateExercises": "Générer des exercices",
"generateExercisesHint": "5 exercices + corrigés", "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...", "exercisesLoading": "Génération des exercices...",
"exercisesGenerated": "exercices créés !", "exercisesGenerated": "exercices créés !",
"aiGenerateExercises": "Générer des exercices", "aiGenerateExercises": "Générer des exercices",