feat: image AI titles (3 suggestions), describe-images action, pin/list fixes, i18n
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 44s

- Add image description service + API route for AI-powered image analysis
- Image title generation returns 3 selectable suggestions via TitleSuggestions component
- Add "Describe images" action in AI assistant (individual + collective)
- Fix pin refresh propagation in card and tabs view
- Fix note creation refresh in tabs mode, pass all notes to tabs view
- Add RTL support (dir="auto") on note content elements
- Pass UI language dynamically to AI endpoints instead of hardcoded 'fr'
- Add 18 missing i18n keys in both en.json and fr.json
- Sparkles button on images for AI title generation (bottom-right, pulse animation)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-29 22:34:13 +02:00
parent fc06519f56
commit d91072ed6b
11 changed files with 453 additions and 59 deletions

View File

@@ -11,7 +11,7 @@ import {
Briefcase, Palette, GraduationCap, Coffee,
Lightbulb, Minimize2, AlignLeft, Wand2,
Globe, BookOpen, FileText, RotateCcw, Check,
Maximize2,
Maximize2, ImageIcon,
} from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { MarkdownContent } from '@/components/markdown-content'
@@ -52,9 +52,10 @@ interface ActionDef {
id: string
icon: any
apiPath: string
body: (content: string) => object
body: (content: string, images?: string[], lang?: string) => object
resultKey: string
i18nKey: string
isImageAction?: boolean
}
const ACTION_IDS = [
@@ -62,6 +63,7 @@ const ACTION_IDS = [
{ id: 'shorten', icon: Minimize2, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'shorten' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.shorten' },
{ id: 'improve', icon: AlignLeft, apiPath: '/api/ai/reformulate', body: (content: string) => ({ text: content, option: 'improve' }), resultKey: 'reformulatedText', i18nKey: 'ai.action.improve' },
{ id: 'markdown', icon: Wand2, apiPath: '/api/ai/transform-markdown', body: (content: string) => ({ text: content }), resultKey: 'transformedText', i18nKey: 'ai.action.toMarkdown' },
{ id: 'describe-images', icon: ImageIcon, apiPath: '/api/ai/describe-image', body: (_content: string, images?: string[], lang?: string) => ({ imageUrls: images || [], mode: 'description', language: lang || 'fr' }), resultKey: 'descriptions', i18nKey: 'ai.action.describeImages', isImageAction: true },
]
// ── Types ─────────────────────────────────────────────────────────────────────
@@ -155,6 +157,40 @@ export function ContextualAIChat({
// ── Action execution ────────────────────────────────────────────────────────
const handleAction = async (action: ActionDef) => {
// Image-specific action
if (action.isImageAction) {
if (!noteImages || noteImages.length === 0) {
toast.error(t('ai.noImagesError') || 'Aucune image dans cette note')
return
}
setActionLoading(action.id)
setActionPreview(null)
try {
const res = await fetch(action.apiPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.body('', noteImages, language)),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
// Format image descriptions for preview
const descs = data.descriptions || []
let resultText = descs.map((d: any) =>
noteImages.length > 1 ? `**Image ${d.index + 1}:** ${d.description}` : d.description
).join('\n\n')
if (data.combinedSummary) {
resultText += `\n\n---\n**${t('ai.overview') || 'Résumé'}:** ${data.combinedSummary}`
}
setActionPreview({ label: t(action.i18nKey), text: resultText })
} catch (e: any) {
toast.error(e.message || t('ai.actionError'))
} finally {
setActionLoading(null)
}
return
}
// Text-based actions
const wc = (noteContent || '').split(/\s+/).filter(Boolean).length
if (!noteContent || wc < 5) {
toast.error(t('ai.minWordsError'))
@@ -166,7 +202,7 @@ export function ContextualAIChat({
const res = await fetch(action.apiPath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(action.body(noteContent)),
body: JSON.stringify(action.body(noteContent, undefined, language)),
})
const data = await res.json()
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
@@ -491,6 +527,33 @@ export function ContextualAIChat({
{t('ai.transformationsDesc')}
</p>
{/* Image actions — shown when note has images */}
{noteImages && noteImages.length > 0 && ACTION_IDS.filter(a => a.isImageAction).map(action => {
const Icon = action.icon
const loading = actionLoading === action.id
return (
<button
key={action.id}
onClick={() => handleAction(action)}
disabled={!!actionLoading}
className="w-full flex items-center gap-3 rounded-xl border border-border/60 bg-card px-4 py-3 text-sm font-medium text-foreground hover:bg-muted hover:border-primary/40 transition-all text-left disabled:opacity-60"
>
{loading
? <Loader2 className="h-4 w-4 text-primary animate-spin shrink-0" />
: <Icon className="h-4 w-4 text-primary shrink-0" />
}
<div className="flex flex-col">
<span>{t(action.i18nKey)}</span>
{noteImages.length > 1 && (
<span className="text-[10px] text-muted-foreground">{noteImages.length} images</span>
)}
</div>
{loading && <span className="ml-auto text-[10px] text-muted-foreground">{t('ai.processingAction')}</span>}
</button>
)
})}
{/* Text actions — shown when note has sufficient text */}
{!noteContent || noteContent.trim().split(/\s+/).filter(Boolean).length < 5 ? (
<div className="flex items-start gap-2 p-3 rounded-xl border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/30">
<Lightbulb className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
@@ -499,7 +562,7 @@ export function ContextualAIChat({
</p>
</div>
) : (
ACTION_IDS.map(action => {
ACTION_IDS.filter(a => !a.isImageAction).map(action => {
const Icon = action.icon
const loading = actionLoading === action.id
return (