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
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:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user