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:
43
memento-note/app/api/ai/describe-image/route.ts
Normal file
43
memento-note/app/api/ai/describe-image/route.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { auth } from '@/auth'
|
||||||
|
import { getAISettings } from '@/app/actions/ai-settings'
|
||||||
|
import { describeImages } from '@/lib/ai/services/image-description.service'
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const session = await auth()
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const userSettings = await getAISettings(session.user.id)
|
||||||
|
if (userSettings.paragraphRefactor === false) {
|
||||||
|
return NextResponse.json({ error: 'Feature disabled' }, { status: 403 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { imageUrls, mode, language } = await req.json()
|
||||||
|
|
||||||
|
if (!Array.isArray(imageUrls) || imageUrls.length === 0) {
|
||||||
|
return NextResponse.json({ error: 'imageUrls must be a non-empty array' }, { status: 400 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await describeImages(
|
||||||
|
imageUrls,
|
||||||
|
mode === 'title' ? 'title' : 'description',
|
||||||
|
language || 'fr'
|
||||||
|
)
|
||||||
|
|
||||||
|
// For title mode, return suggestions in same format as /api/ai/title-suggestions
|
||||||
|
if (mode === 'title' && result.suggestions) {
|
||||||
|
return NextResponse.json({ suggestions: result.suggestions })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(result)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[describe-image] Error:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: error.message || 'Failed to describe image' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Briefcase, Palette, GraduationCap, Coffee,
|
Briefcase, Palette, GraduationCap, Coffee,
|
||||||
Lightbulb, Minimize2, AlignLeft, Wand2,
|
Lightbulb, Minimize2, AlignLeft, Wand2,
|
||||||
Globe, BookOpen, FileText, RotateCcw, Check,
|
Globe, BookOpen, FileText, RotateCcw, Check,
|
||||||
Maximize2,
|
Maximize2, ImageIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useLanguage } from '@/lib/i18n'
|
import { useLanguage } from '@/lib/i18n'
|
||||||
import { MarkdownContent } from '@/components/markdown-content'
|
import { MarkdownContent } from '@/components/markdown-content'
|
||||||
@@ -52,9 +52,10 @@ interface ActionDef {
|
|||||||
id: string
|
id: string
|
||||||
icon: any
|
icon: any
|
||||||
apiPath: string
|
apiPath: string
|
||||||
body: (content: string) => object
|
body: (content: string, images?: string[], lang?: string) => object
|
||||||
resultKey: string
|
resultKey: string
|
||||||
i18nKey: string
|
i18nKey: string
|
||||||
|
isImageAction?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTION_IDS = [
|
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: '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: '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: '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 ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
@@ -155,6 +157,40 @@ export function ContextualAIChat({
|
|||||||
|
|
||||||
// ── Action execution ────────────────────────────────────────────────────────
|
// ── Action execution ────────────────────────────────────────────────────────
|
||||||
const handleAction = async (action: ActionDef) => {
|
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
|
const wc = (noteContent || '').split(/\s+/).filter(Boolean).length
|
||||||
if (!noteContent || wc < 5) {
|
if (!noteContent || wc < 5) {
|
||||||
toast.error(t('ai.minWordsError'))
|
toast.error(t('ai.minWordsError'))
|
||||||
@@ -166,7 +202,7 @@ export function ContextualAIChat({
|
|||||||
const res = await fetch(action.apiPath, {
|
const res = await fetch(action.apiPath, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(action.body(noteContent)),
|
body: JSON.stringify(action.body(noteContent, undefined, language)),
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
if (!res.ok) throw new Error(data.error || t('ai.genericError'))
|
||||||
@@ -491,6 +527,33 @@ export function ContextualAIChat({
|
|||||||
{t('ai.transformationsDesc')}
|
{t('ai.transformationsDesc')}
|
||||||
</p>
|
</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 ? (
|
{!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">
|
<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" />
|
<Lightbulb className="h-4 w-4 text-amber-500 shrink-0 mt-0.5" />
|
||||||
@@ -499,7 +562,7 @@ export function ContextualAIChat({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
ACTION_IDS.map(action => {
|
ACTION_IDS.filter(a => !a.isImageAction).map(action => {
|
||||||
const Icon = action.icon
|
const Icon = action.icon
|
||||||
const loading = actionLoading === action.id
|
const loading = actionLoading === action.id
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -399,23 +399,26 @@ export function HomeClient({ initialNotes, initialSettings }: HomeClientProps) {
|
|||||||
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
|
<div className="text-center py-8 text-gray-500">{t('general.loading')}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<FavoritesSection
|
{!isTabs && (
|
||||||
pinnedNotes={pinnedNotes}
|
<FavoritesSection
|
||||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
pinnedNotes={pinnedNotes}
|
||||||
onSizeChange={handleSizeChange}
|
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||||
/>
|
onSizeChange={handleSizeChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{(notes.filter((note) => !note.isPinned).length > 0 || isTabs) && (
|
{(notes.filter((note) => !note.isPinned).length > 0 || isTabs) && (
|
||||||
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
|
<div className={cn(isTabs && 'flex min-h-0 flex-1 flex-col')}>
|
||||||
<NotesMainSection
|
<NotesMainSection
|
||||||
viewMode={notesViewMode}
|
viewMode={notesViewMode}
|
||||||
notes={notes.filter((note) => !note.isPinned)}
|
notes={isTabs ? notes : notes.filter((note) => !note.isPinned)}
|
||||||
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
onEdit={(note, readOnly) => setEditingNote({ note, readOnly })}
|
||||||
onSizeChange={handleSizeChange}
|
onSizeChange={handleSizeChange}
|
||||||
currentNotebookId={searchParams.get('notebook')}
|
currentNotebookId={searchParams.get('notebook')}
|
||||||
noteHistoryMode={noteHistoryMode}
|
noteHistoryMode={noteHistoryMode}
|
||||||
onOpenHistory={handleOpenHistory}
|
onOpenHistory={handleOpenHistory}
|
||||||
onEnableHistory={handleEnableHistory}
|
onEnableHistory={handleEnableHistory}
|
||||||
|
onNoteCreated={handleNoteCreated}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -299,6 +299,7 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
addOptimisticNote({ isPinned: !note.isPinned })
|
addOptimisticNote({ isPinned: !note.isPinned })
|
||||||
await togglePin(note.id, !note.isPinned)
|
await togglePin(note.id, !note.isPinned)
|
||||||
|
triggerRefresh()
|
||||||
|
|
||||||
if (!note.isPinned) {
|
if (!note.isPinned) {
|
||||||
toast.success(t('notes.pinned') || 'Note pinned')
|
toast.success(t('notes.pinned') || 'Note pinned')
|
||||||
@@ -312,6 +313,7 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
addOptimisticNote({ isArchived: !note.isArchived })
|
addOptimisticNote({ isArchived: !note.isArchived })
|
||||||
await toggleArchive(note.id, !note.isArchived)
|
await toggleArchive(note.id, !note.isArchived)
|
||||||
|
triggerRefresh()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -516,7 +518,7 @@ export const NoteCard = memo(function NoteCard({
|
|||||||
|
|
||||||
{/* Title */}
|
{/* Title */}
|
||||||
{optimisticNote.title && (
|
{optimisticNote.title && (
|
||||||
<h3 className="text-lg font-heading font-semibold mb-2 pr-20 text-foreground leading-tight tracking-tight">
|
<h3 dir="auto" className="text-lg font-heading font-semibold mb-2 pr-20 text-foreground leading-tight tracking-tight">
|
||||||
{optimisticNote.title}
|
{optimisticNote.title}
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -632,6 +632,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
{/* Title */}
|
{/* Title */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Input
|
<Input
|
||||||
|
dir="auto"
|
||||||
placeholder={t('notes.titlePlaceholder')}
|
placeholder={t('notes.titlePlaceholder')}
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
@@ -705,6 +706,7 @@ export function NoteEditor({ note, readOnly = false, onClose }: NoteEditorProps)
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Textarea
|
<Textarea
|
||||||
|
dir="auto"
|
||||||
placeholder={isMarkdown ? t('notes.takeNoteMarkdown') : t('notes.takeNote')}
|
placeholder={isMarkdown ? t('notes.takeNoteMarkdown') : t('notes.takeNote')}
|
||||||
value={content}
|
value={content}
|
||||||
onChange={(e) => setContent(e.target.value)}
|
onChange={(e) => setContent(e.target.value)}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { createNote } from '@/app/actions/notes'
|
|||||||
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
import { fetchLinkMetadata } from '@/app/actions/scrape'
|
||||||
import { CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, Note } from '@/lib/types'
|
import { CheckItem, NOTE_COLORS, NoteColor, LinkMetadata, Note } from '@/lib/types'
|
||||||
import { ContextualAIChat } from './contextual-ai-chat'
|
import { ContextualAIChat } from './contextual-ai-chat'
|
||||||
import { Maximize2, Minimize2, Sparkles } from 'lucide-react'
|
import { Maximize2, Minimize2, Sparkles, Loader2 } from 'lucide-react'
|
||||||
import { useNotebooks } from '@/context/notebooks-context'
|
import { useNotebooks } from '@/context/notebooks-context'
|
||||||
import { Checkbox } from '@/components/ui/checkbox'
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
import {
|
import {
|
||||||
@@ -98,7 +98,7 @@ export function NoteInput({
|
|||||||
}
|
}
|
||||||
}, [session?.user?.id])
|
}, [session?.user?.id])
|
||||||
|
|
||||||
const { t } = useLanguage()
|
const { t, language: uiLanguage } = useLanguage()
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams()
|
||||||
const currentNotebookId = searchParams.get('notebook') || undefined // Get current notebook from URL
|
const currentNotebookId = searchParams.get('notebook') || undefined // Get current notebook from URL
|
||||||
const [isExpanded, setIsExpanded] = useState(defaultExpanded || forceExpanded)
|
const [isExpanded, setIsExpanded] = useState(defaultExpanded || forceExpanded)
|
||||||
@@ -466,6 +466,33 @@ export function NoteInput({
|
|||||||
return () => document.removeEventListener('paste', handlePaste)
|
return () => document.removeEventListener('paste', handlePaste)
|
||||||
}, [t])
|
}, [t])
|
||||||
|
|
||||||
|
// AI title from images
|
||||||
|
const [isGeneratingImageTitle, setIsGeneratingImageTitle] = useState(false)
|
||||||
|
const [imageTitleSuggestions, setImageTitleSuggestions] = useState<any[]>([])
|
||||||
|
|
||||||
|
const handleImageTitleSuggestion = async (imageUrls?: string[]) => {
|
||||||
|
const urls = imageUrls || images
|
||||||
|
if (urls.length === 0) return
|
||||||
|
setIsGeneratingImageTitle(true)
|
||||||
|
setImageTitleSuggestions([])
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/describe-image', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ imageUrls: urls, mode: 'title', language: uiLanguage }),
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (!res.ok) throw new Error(data.error || 'AI error')
|
||||||
|
if (data.suggestions?.length > 0) {
|
||||||
|
setImageTitleSuggestions(data.suggestions)
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
toast.error(e.message || t('ai.genericError'))
|
||||||
|
} finally {
|
||||||
|
setIsGeneratingImageTitle(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleAddLink = async () => {
|
const handleAddLink = async () => {
|
||||||
if (!linkUrl) return
|
if (!linkUrl) return
|
||||||
|
|
||||||
@@ -582,6 +609,7 @@ export function NoteInput({
|
|||||||
setSelectedLabels([])
|
setSelectedLabels([])
|
||||||
setCollaborators([])
|
setCollaborators([])
|
||||||
setDismissedTitleSuggestions(false)
|
setDismissedTitleSuggestions(false)
|
||||||
|
setImageTitleSuggestions([])
|
||||||
|
|
||||||
toast.success(t('notes.noteCreated'))
|
toast.success(t('notes.noteCreated'))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -626,6 +654,7 @@ export function NoteInput({
|
|||||||
setSelectedLabels([])
|
setSelectedLabels([])
|
||||||
setCollaborators([])
|
setCollaborators([])
|
||||||
setDismissedTitleSuggestions(false)
|
setDismissedTitleSuggestions(false)
|
||||||
|
setImageTitleSuggestions([])
|
||||||
}
|
}
|
||||||
|
|
||||||
const collapsedWidthClass = fullWidth ? 'w-full max-w-none mx-0' : 'max-w-2xl mx-auto'
|
const collapsedWidthClass = fullWidth ? 'w-full max-w-none mx-0' : 'max-w-2xl mx-auto'
|
||||||
@@ -708,7 +737,7 @@ export function NoteInput({
|
|||||||
className="w-full bg-transparent text-lg font-semibold text-foreground outline-none placeholder:text-muted-foreground/40"
|
className="w-full bg-transparent text-lg font-semibold text-foreground outline-none placeholder:text-muted-foreground/40"
|
||||||
placeholder={t('notes.titlePlaceholder')}
|
placeholder={t('notes.titlePlaceholder')}
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => { setTitle(e.target.value); setImageTitleSuggestions([]) }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -723,6 +752,17 @@ export function NoteInput({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Image title suggestions */}
|
||||||
|
{!title && imageTitleSuggestions.length > 0 && (
|
||||||
|
<div className="px-5">
|
||||||
|
<TitleSuggestions
|
||||||
|
suggestions={imageTitleSuggestions}
|
||||||
|
onSelect={(s) => { setTitle(s); setImageTitleSuggestions([]) }}
|
||||||
|
onDismiss={() => setImageTitleSuggestions([])}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Content area — scrolls internally when constrained by max-h */}
|
{/* Content area — scrolls internally when constrained by max-h */}
|
||||||
<div className="px-5 pb-3 flex-1 min-h-0 overflow-y-auto">
|
<div className="px-5 pb-3 flex-1 min-h-0 overflow-y-auto">
|
||||||
{type === 'text' ? (
|
{type === 'text' ? (
|
||||||
@@ -746,6 +786,34 @@ export function NoteInput({
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{/* Images — rendered between content and tag suggestions */}
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2 mt-2">
|
||||||
|
{images.map((img, idx) => (
|
||||||
|
<div key={idx} className="relative group inline-block w-fit">
|
||||||
|
<img src={img} alt={`Upload ${idx + 1}`} className="max-h-64 rounded-lg object-contain block" />
|
||||||
|
{!title && !content.trim() && aiAssistantEnabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute bottom-2 right-2 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-primary/60 backdrop-blur-sm text-primary-foreground animate-pulse hover:animate-none hover:bg-primary hover:w-auto hover:gap-1.5 hover:px-2.5 transition-all overflow-hidden group/ai"
|
||||||
|
onClick={() => handleImageTitleSuggestion()}
|
||||||
|
disabled={isGeneratingImageTitle}
|
||||||
|
title={t('notes.generateTitleFromImage') || 'Générer un titre'}>
|
||||||
|
{isGeneratingImageTitle
|
||||||
|
? <Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
|
||||||
|
: <Sparkles className="h-3.5 w-3.5 shrink-0" />}
|
||||||
|
<span className="text-[10px] font-medium whitespace-nowrap opacity-0 group-hover/ai:opacity-100 transition-opacity">{t('notes.suggestTitle') || 'Titre'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm"
|
||||||
|
className="absolute top-2 right-2 h-7 w-7 p-0 bg-background/80 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() => setImages(images.filter((_, i) => i !== idx))}>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<GhostTags
|
<GhostTags
|
||||||
suggestions={filteredSuggestions}
|
suggestions={filteredSuggestions}
|
||||||
addedTags={selectedLabels}
|
addedTags={selectedLabels}
|
||||||
@@ -756,6 +824,34 @@ export function NoteInput({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5 py-2">
|
<div className="space-y-1.5 py-2">
|
||||||
|
{/* Images — rendered before checklist items */}
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-2 mb-2">
|
||||||
|
{images.map((img, idx) => (
|
||||||
|
<div key={idx} className="relative group inline-block w-fit">
|
||||||
|
<img src={img} alt={`Upload ${idx + 1}`} className="max-h-64 rounded-lg object-contain block" />
|
||||||
|
{!title && !content.trim() && aiAssistantEnabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="absolute bottom-2 right-2 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-primary/60 backdrop-blur-sm text-primary-foreground animate-pulse hover:animate-none hover:bg-primary hover:w-auto hover:gap-1.5 hover:px-2.5 transition-all overflow-hidden group/ai"
|
||||||
|
onClick={() => handleImageTitleSuggestion()}
|
||||||
|
disabled={isGeneratingImageTitle}
|
||||||
|
title={t('notes.generateTitleFromImage') || 'Générer un titre'}>
|
||||||
|
{isGeneratingImageTitle
|
||||||
|
? <Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
|
||||||
|
: <Sparkles className="h-3.5 w-3.5 shrink-0" />}
|
||||||
|
<span className="text-[10px] font-medium whitespace-nowrap opacity-0 group-hover/ai:opacity-100 transition-opacity">{t('notes.suggestTitle') || 'Titre'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm"
|
||||||
|
className="absolute top-2 right-2 h-7 w-7 p-0 bg-background/80 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
|
onClick={() => setImages(images.filter((_, i) => i !== idx))}>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{checkItems.map((item) => (
|
{checkItems.map((item) => (
|
||||||
<div key={item.id} className="flex items-center gap-2 group">
|
<div key={item.id} className="flex items-center gap-2 group">
|
||||||
<Checkbox className="shrink-0" />
|
<Checkbox className="shrink-0" />
|
||||||
@@ -788,22 +884,6 @@ export function NoteInput({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Images */}
|
|
||||||
{images.length > 0 && (
|
|
||||||
<div className="flex flex-col gap-2 px-5 pb-3">
|
|
||||||
{images.map((img, idx) => (
|
|
||||||
<div key={idx} className="relative group">
|
|
||||||
<img src={img} alt={`Upload ${idx + 1}`} className="max-h-64 rounded-lg object-contain" />
|
|
||||||
<Button variant="ghost" size="sm"
|
|
||||||
className="absolute top-2 right-2 h-7 w-7 p-0 bg-background/80 opacity-0 group-hover:opacity-100 transition-opacity"
|
|
||||||
onClick={() => setImages(images.filter((_, i) => i !== idx))}>
|
|
||||||
<X className="h-3.5 w-3.5" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Link previews */}
|
{/* Link previews */}
|
||||||
{links.length > 0 && (
|
{links.length > 0 && (
|
||||||
<div className="flex flex-col gap-2 px-5 pb-3">
|
<div className="flex flex-col gap-2 px-5 pb-3">
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ interface NotesMainSectionProps {
|
|||||||
noteHistoryMode?: 'manual' | 'auto'
|
noteHistoryMode?: 'manual' | 'auto'
|
||||||
onOpenHistory?: (note: Note) => void
|
onOpenHistory?: (note: Note) => void
|
||||||
onEnableHistory?: (noteId: string) => Promise<void>
|
onEnableHistory?: (noteId: string) => Promise<void>
|
||||||
|
onNoteCreated?: (note: Note) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NotesMainSection({
|
export function NotesMainSection({
|
||||||
@@ -39,6 +40,7 @@ export function NotesMainSection({
|
|||||||
noteHistoryMode = 'manual',
|
noteHistoryMode = 'manual',
|
||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
onEnableHistory,
|
onEnableHistory,
|
||||||
|
onNoteCreated,
|
||||||
}: NotesMainSectionProps) {
|
}: NotesMainSectionProps) {
|
||||||
if (viewMode === 'tabs') {
|
if (viewMode === 'tabs') {
|
||||||
return (
|
return (
|
||||||
@@ -50,6 +52,7 @@ export function NotesMainSection({
|
|||||||
noteHistoryMode={noteHistoryMode}
|
noteHistoryMode={noteHistoryMode}
|
||||||
onOpenHistory={onOpenHistory}
|
onOpenHistory={onOpenHistory}
|
||||||
onEnableHistory={onEnableHistory}
|
onEnableHistory={onEnableHistory}
|
||||||
|
onNoteCreated={onNoteCreated}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ interface NotesTabsViewProps {
|
|||||||
noteHistoryMode?: 'manual' | 'auto'
|
noteHistoryMode?: 'manual' | 'auto'
|
||||||
onOpenHistory?: (note: Note) => void
|
onOpenHistory?: (note: Note) => void
|
||||||
onEnableHistory?: (noteId: string) => Promise<void>
|
onEnableHistory?: (noteId: string) => Promise<void>
|
||||||
|
onNoteCreated?: (note: Note) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type SortOrder = 'date-desc' | 'date-asc' | 'title-asc' | 'title-desc'
|
type SortOrder = 'date-desc' | 'date-asc' | 'title-asc' | 'title-desc'
|
||||||
@@ -256,6 +257,7 @@ function SortableNoteListItem({
|
|||||||
|
|
||||||
{/* Row 2: title */}
|
{/* Row 2: title */}
|
||||||
<p
|
<p
|
||||||
|
dir="auto"
|
||||||
className={cn(
|
className={cn(
|
||||||
'mb-1.5 text-[13.5px] leading-snug transition-colors',
|
'mb-1.5 text-[13.5px] leading-snug transition-colors',
|
||||||
selected
|
selected
|
||||||
@@ -268,7 +270,7 @@ function SortableNoteListItem({
|
|||||||
|
|
||||||
{/* Row 3: snippet */}
|
{/* Row 3: snippet */}
|
||||||
{snippet && (
|
{snippet && (
|
||||||
<p className="line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60">
|
<p dir="auto" className="line-clamp-2 text-[12px] leading-relaxed text-muted-foreground/60">
|
||||||
{snippet}
|
{snippet}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -560,7 +562,6 @@ function NoteMetaSidebar({
|
|||||||
icon={note.isPinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
|
icon={note.isPinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
|
||||||
label={note.isPinned ? t('notes.unpin') : t('notes.pin')}
|
label={note.isPinned ? t('notes.unpin') : t('notes.pin')}
|
||||||
onClick={() => onPinToggle(note)}
|
onClick={() => onPinToggle(note)}
|
||||||
disabled
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Archive */}
|
{/* Archive */}
|
||||||
@@ -593,10 +594,11 @@ export function NotesTabsView({
|
|||||||
notes,
|
notes,
|
||||||
onEdit,
|
onEdit,
|
||||||
currentNotebookId,
|
currentNotebookId,
|
||||||
|
|
||||||
noteHistoryMode = 'manual',
|
noteHistoryMode = 'manual',
|
||||||
onOpenHistory,
|
onOpenHistory,
|
||||||
onEnableHistory,
|
onEnableHistory,
|
||||||
|
onNoteCreated,
|
||||||
}: NotesTabsViewProps) {
|
}: NotesTabsViewProps) {
|
||||||
const { t, language } = useLanguage()
|
const { t, language } = useLanguage()
|
||||||
const { triggerRefresh } = useNoteRefreshOptional()
|
const { triggerRefresh } = useNoteRefreshOptional()
|
||||||
@@ -667,21 +669,18 @@ export function NotesTabsView({
|
|||||||
return () => window.removeEventListener('label-deleted', handler)
|
return () => window.removeEventListener('label-deleted', handler)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Sorted display items (does NOT affect persisted order)
|
// Sorted display items — pinned notes always float to the top
|
||||||
const sortedItems = useMemo(() => {
|
const sortedItems = useMemo(() => {
|
||||||
if (sortOrder === 'date-desc') return [...items]
|
const pinned = items.filter(n => n.isPinned)
|
||||||
return [...items].sort((a, b) => {
|
const unpinned = items.filter(n => !n.isPinned)
|
||||||
if (sortOrder === 'date-asc') {
|
const sortFn = (a: Note, b: Note) => {
|
||||||
return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
if (sortOrder === 'date-desc') return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||||
}
|
if (sortOrder === 'date-asc') return new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
||||||
if (sortOrder === 'title-asc') {
|
if (sortOrder === 'title-asc') return (a.title || '').localeCompare(b.title || '')
|
||||||
return (a.title || '').localeCompare(b.title || '')
|
if (sortOrder === 'title-desc') return (b.title || '').localeCompare(a.title || '')
|
||||||
}
|
|
||||||
if (sortOrder === 'title-desc') {
|
|
||||||
return (b.title || '').localeCompare(a.title || '')
|
|
||||||
}
|
|
||||||
return 0
|
return 0
|
||||||
})
|
}
|
||||||
|
return [...pinned.sort(sortFn), ...unpinned.sort(sortFn)]
|
||||||
}, [items, sortOrder])
|
}, [items, sortOrder])
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -727,6 +726,7 @@ export function NotesTabsView({
|
|||||||
return [...pinned, newNote, ...unpinned]
|
return [...pinned, newNote, ...unpinned]
|
||||||
})
|
})
|
||||||
setSelectedId(newNote.id)
|
setSelectedId(newNote.id)
|
||||||
|
onNoteCreated?.(newNote)
|
||||||
triggerRefresh()
|
triggerRefresh()
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t('notes.createFailed') || 'Impossible de créer la note')
|
toast.error(t('notes.createFailed') || 'Impossible de créer la note')
|
||||||
@@ -739,6 +739,7 @@ export function NotesTabsView({
|
|||||||
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: next } : n))
|
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: next } : n))
|
||||||
try {
|
try {
|
||||||
await updateNote(note.id, { isPinned: next }, { skipRevalidation: true })
|
await updateNote(note.id, { isPinned: next }, { skipRevalidation: true })
|
||||||
|
triggerRefresh()
|
||||||
toast.success(next ? (t('notes.pinned') || 'Épinglée') : (t('notes.unpinned') || 'Désépinglée'))
|
toast.success(next ? (t('notes.pinned') || 'Épinglée') : (t('notes.unpinned') || 'Désépinglée'))
|
||||||
} catch {
|
} catch {
|
||||||
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: note.isPinned } : n))
|
setItems((prev) => prev.map((n) => n.id === note.id ? { ...n, isPinned: note.isPinned } : n))
|
||||||
|
|||||||
151
memento-note/lib/ai/services/image-description.service.ts
Normal file
151
memento-note/lib/ai/services/image-description.service.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { generateText } from 'ai'
|
||||||
|
import { readFile } from 'fs/promises'
|
||||||
|
import path from 'path'
|
||||||
|
import { getChatProvider } from '../factory'
|
||||||
|
import { getSystemConfig } from '@/lib/config'
|
||||||
|
|
||||||
|
export interface ImageDescriptionResult {
|
||||||
|
descriptions: Array<{
|
||||||
|
index: number
|
||||||
|
description: string
|
||||||
|
}>
|
||||||
|
suggestions?: Array<{
|
||||||
|
title: string
|
||||||
|
confidence: number
|
||||||
|
reasoning?: string
|
||||||
|
}>
|
||||||
|
combinedSummary?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const UPLOAD_DIR = path.join(process.cwd(), 'data', 'uploads')
|
||||||
|
|
||||||
|
async function resolveImageAsBase64(imageUrl: string): Promise<string> {
|
||||||
|
const localMatch = imageUrl.match(/\/uploads\/(.+)/)
|
||||||
|
if (localMatch) {
|
||||||
|
const filePath = path.join(UPLOAD_DIR, localMatch[1])
|
||||||
|
const buffer = await readFile(filePath)
|
||||||
|
const ext = path.extname(imageUrl).toLowerCase()
|
||||||
|
const mime = ext === '.png' ? 'image/png' : ext === '.gif' ? 'image/gif' : ext === '.webp' ? 'image/webp' : 'image/jpeg'
|
||||||
|
return `data:${mime};base64,${buffer.toString('base64')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote URL — fetch and convert
|
||||||
|
const res = await fetch(imageUrl)
|
||||||
|
if (!res.ok) throw new Error(`Failed to fetch image: ${imageUrl}`)
|
||||||
|
const contentType = res.headers.get('content-type') || 'image/jpeg'
|
||||||
|
const arrayBuffer = await res.arrayBuffer()
|
||||||
|
const base64 = Buffer.from(arrayBuffer).toString('base64')
|
||||||
|
return `data:${contentType};base64,${base64}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function describeImages(
|
||||||
|
imageUrls: string[],
|
||||||
|
mode: 'description' | 'title',
|
||||||
|
language: string = 'fr'
|
||||||
|
): Promise<ImageDescriptionResult> {
|
||||||
|
const config = await getSystemConfig()
|
||||||
|
const model = getChatProvider(config).getModel()
|
||||||
|
|
||||||
|
const isTitleMode = mode === 'title'
|
||||||
|
const langMap: Record<string, string> = {
|
||||||
|
fr: 'French', en: 'English', fa: 'Persian', ar: 'Arabic',
|
||||||
|
es: 'Spanish', de: 'German', it: 'Italian', pt: 'Portuguese',
|
||||||
|
ru: 'Russian', zh: 'Chinese', ja: 'Japanese', ko: 'Korean',
|
||||||
|
hi: 'Hindi', nl: 'Dutch', pl: 'Polish',
|
||||||
|
}
|
||||||
|
const langName = langMap[language] || 'English'
|
||||||
|
|
||||||
|
// Resolve all images as base64 data URLs (same approach as the chat route)
|
||||||
|
const imageDataUrls = await Promise.all(imageUrls.map(url => resolveImageAsBase64(url)))
|
||||||
|
|
||||||
|
if (isTitleMode) {
|
||||||
|
const prompt = imageUrls.length === 1
|
||||||
|
? `Look carefully at this image and identify every concrete detail you can see: objects, people, animals, text, logos, colors, location/setting, actions, weather, time of day, style (photo/illustration/diagram), and any notable elements.
|
||||||
|
|
||||||
|
Then generate 3 specific, descriptive titles (3-7 words each) in ${langName}. Each title must mention concrete elements actually visible in the image — do NOT use generic or abstract words like "beautiful scene", "interesting image", "visual content". Be precise and factual.
|
||||||
|
|
||||||
|
Good example: "Red bicycle parked near a brick café wall"
|
||||||
|
Bad example: "Beautiful urban scenery"
|
||||||
|
|
||||||
|
Respond ONLY with a JSON array: [{"title": "title1", "confidence": 0.95}, {"title": "title2", "confidence": 0.85}, {"title": "title3", "confidence": 0.75}]`
|
||||||
|
: `Look carefully at these images and identify every concrete detail visible: objects, people, animals, text, logos, colors, locations, actions, weather, styles, and any notable elements across all images.
|
||||||
|
|
||||||
|
Then generate 3 specific, descriptive titles (3-7 words each) in ${langName} that capture what these images collectively show. Each title must mention concrete elements actually visible — do NOT use generic or abstract words like "beautiful scenes", "collection of images". Be precise and factual.
|
||||||
|
|
||||||
|
Good example: "Red bicycle and brick café on a sunny street"
|
||||||
|
Bad example: "Beautiful urban scenery collection"
|
||||||
|
|
||||||
|
Respond ONLY with a JSON array: [{"title": "title1", "confidence": 0.95}, {"title": "title2", "confidence": 0.85}, {"title": "title3", "confidence": 0.75}]`
|
||||||
|
|
||||||
|
const content: any[] = [{ type: 'text', text: prompt }]
|
||||||
|
for (const dataUrl of imageDataUrls) {
|
||||||
|
content.push({ type: 'image', image: dataUrl })
|
||||||
|
}
|
||||||
|
|
||||||
|
const { text } = await generateText({
|
||||||
|
model,
|
||||||
|
messages: [{ role: 'user', content }],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Parse JSON response
|
||||||
|
const jsonMatch = text.match(/\[[\s\S]*\]/)
|
||||||
|
const parsed = jsonMatch ? JSON.parse(jsonMatch[0]) : []
|
||||||
|
|
||||||
|
const suggestions = parsed.map((t: any) => ({
|
||||||
|
title: t.title?.trim().replace(/^["']|["']$/g, '') || '',
|
||||||
|
confidence: Math.round((t.confidence || 0.5) * 100),
|
||||||
|
reasoning: undefined,
|
||||||
|
})).filter((s: any) => s.title)
|
||||||
|
|
||||||
|
return {
|
||||||
|
descriptions: [],
|
||||||
|
suggestions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single image description
|
||||||
|
if (imageUrls.length === 1) {
|
||||||
|
const content: any[] = [
|
||||||
|
{ type: 'text', text: `Describe this image in detail in ${langName}. Be specific about what you see: objects, people, colors, setting, mood, text visible. Keep it under 100 words.` },
|
||||||
|
{ type: 'image', image: imageDataUrls[0] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { text } = await generateText({
|
||||||
|
model,
|
||||||
|
messages: [{ role: 'user', content }],
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
descriptions: [{ index: 0, description: text.trim() }],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple images: describe each individually
|
||||||
|
const descriptions: Array<{ index: number; description: string }> = []
|
||||||
|
|
||||||
|
for (let i = 0; i < imageDataUrls.length; i++) {
|
||||||
|
const content: any[] = [
|
||||||
|
{ type: 'text', text: `Describe this image (image ${i + 1} of ${imageDataUrls.length}) in ${langName}. Be specific: objects, people, colors, setting, text visible. Under 80 words.` },
|
||||||
|
{ type: 'image', image: imageDataUrls[i] },
|
||||||
|
]
|
||||||
|
|
||||||
|
const { text } = await generateText({
|
||||||
|
model,
|
||||||
|
messages: [{ role: 'user', content }],
|
||||||
|
})
|
||||||
|
|
||||||
|
descriptions.push({ index: i, description: text.trim() })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combined summary
|
||||||
|
const allDescriptions = descriptions.map(d => d.description).join('\n')
|
||||||
|
const { text: summary } = await generateText({
|
||||||
|
model,
|
||||||
|
prompt: `Based on these individual image descriptions, write a brief (1-2 sentence) overall summary in ${langName} of what these images collectively show:\n\n${allDescriptions}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
descriptions,
|
||||||
|
combinedSummary: summary.trim(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,7 +39,8 @@
|
|||||||
"newNoteTabsHint": "Create note in this notebook",
|
"newNoteTabsHint": "Create note in this notebook",
|
||||||
"noLabelsInNotebook": "No labels in this notebook yet",
|
"noLabelsInNotebook": "No labels in this notebook yet",
|
||||||
"archive": "Archive",
|
"archive": "Archive",
|
||||||
"trash": "Trash"
|
"trash": "Trash",
|
||||||
|
"clearFilter": "Remove filter"
|
||||||
},
|
},
|
||||||
"notes": {
|
"notes": {
|
||||||
"title": "Notes",
|
"title": "Notes",
|
||||||
@@ -186,7 +187,21 @@
|
|||||||
"sortDateDesc": "Date (newest)",
|
"sortDateDesc": "Date (newest)",
|
||||||
"sortDateAsc": "Date (oldest)",
|
"sortDateAsc": "Date (oldest)",
|
||||||
"sortTitleAsc": "Title A → Z",
|
"sortTitleAsc": "Title A → Z",
|
||||||
"sortTitleDesc": "Title Z → A"
|
"sortTitleDesc": "Title Z → A",
|
||||||
|
"suggestTitle": "AI title",
|
||||||
|
"generateTitleFromImage": "Generate title from image",
|
||||||
|
"titleGenerated": "Title generated",
|
||||||
|
"content": "Content",
|
||||||
|
"restore": "Restore",
|
||||||
|
"createFailed": "Failed to create note",
|
||||||
|
"updateFailed": "Failed to update note",
|
||||||
|
"archived": "Note archived",
|
||||||
|
"archiveFailed": "Failed to archive",
|
||||||
|
"sort": "Sort",
|
||||||
|
"confirmDeleteTitle": "Delete note",
|
||||||
|
"leftShare": "Share removed",
|
||||||
|
"dismissed": "Note dismissed from recent",
|
||||||
|
"generalNotes": "General Notes"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"previous": "←",
|
"previous": "←",
|
||||||
@@ -390,11 +405,14 @@
|
|||||||
"transformationsDesc": "Transformations — applied directly to the note",
|
"transformationsDesc": "Transformations — applied directly to the note",
|
||||||
"writeMinWordsAction": "Write at least 5 words to activate AI actions.",
|
"writeMinWordsAction": "Write at least 5 words to activate AI actions.",
|
||||||
"processingAction": "Processing...",
|
"processingAction": "Processing...",
|
||||||
|
"noImagesError": "No images in this note",
|
||||||
|
"overview": "Overview",
|
||||||
"action": {
|
"action": {
|
||||||
"clarify": "Clarify",
|
"clarify": "Clarify",
|
||||||
"shorten": "Shorten",
|
"shorten": "Shorten",
|
||||||
"improve": "Improve",
|
"improve": "Improve",
|
||||||
"toMarkdown": "To Markdown"
|
"toMarkdown": "To Markdown",
|
||||||
|
"describeImages": "Describe images"
|
||||||
},
|
},
|
||||||
"openAssistant": "Open AI Assistant",
|
"openAssistant": "Open AI Assistant",
|
||||||
"poweredByMomento": "Powered by Momento AI",
|
"poweredByMomento": "Powered by Momento AI",
|
||||||
@@ -409,7 +427,9 @@
|
|||||||
"historyTab": "History",
|
"historyTab": "History",
|
||||||
"insightsTab": "Insights",
|
"insightsTab": "Insights",
|
||||||
"aiCopilot": "AI Copilot",
|
"aiCopilot": "AI Copilot",
|
||||||
"suggestTitle": "AI title suggestion"
|
"suggestTitle": "AI title suggestion",
|
||||||
|
"generateTitleFromImage": "Generate title from image",
|
||||||
|
"titleGenerated": "Title generated from image"
|
||||||
},
|
},
|
||||||
"titleSuggestions": {
|
"titleSuggestions": {
|
||||||
"available": "Title suggestions",
|
"available": "Title suggestions",
|
||||||
@@ -750,7 +770,8 @@
|
|||||||
"pdfGeneratedOn": "Generated on:",
|
"pdfGeneratedOn": "Generated on:",
|
||||||
"confidence": "confidence",
|
"confidence": "confidence",
|
||||||
"savingReminder": "Failed to save reminder",
|
"savingReminder": "Failed to save reminder",
|
||||||
"removingReminder": "Failed to remove reminder"
|
"removingReminder": "Failed to remove reminder",
|
||||||
|
"generatingDescription": "Please wait..."
|
||||||
},
|
},
|
||||||
"notebookSuggestion": {
|
"notebookSuggestion": {
|
||||||
"title": "Move to {name}?",
|
"title": "Move to {name}?",
|
||||||
@@ -1143,7 +1164,8 @@
|
|||||||
"notesViewDescription": "Choose how notes are shown on home and in notebooks.",
|
"notesViewDescription": "Choose how notes are shown on home and in notebooks.",
|
||||||
"notesViewLabel": "Notes layout",
|
"notesViewLabel": "Notes layout",
|
||||||
"notesViewTabs": "Tabs (OneNote-style)",
|
"notesViewTabs": "Tabs (OneNote-style)",
|
||||||
"notesViewMasonry": "Cards (grid)"
|
"notesViewMasonry": "Cards (grid)",
|
||||||
|
"selectTheme": "Select theme"
|
||||||
},
|
},
|
||||||
"generalSettings": {
|
"generalSettings": {
|
||||||
"title": "General Settings",
|
"title": "General Settings",
|
||||||
@@ -1567,7 +1589,8 @@
|
|||||||
"createFailed": "Creation failed",
|
"createFailed": "Creation failed",
|
||||||
"deleteSpace": "Delete space",
|
"deleteSpace": "Delete space",
|
||||||
"deleted": "Space deleted",
|
"deleted": "Space deleted",
|
||||||
"deleteError": "Error deleting"
|
"deleteError": "Error deleting",
|
||||||
|
"rename": "Rename"
|
||||||
},
|
},
|
||||||
"lab": {
|
"lab": {
|
||||||
"initializing": "Initializing workspace",
|
"initializing": "Initializing workspace",
|
||||||
|
|||||||
@@ -396,11 +396,14 @@
|
|||||||
"transformationsDesc": "Transformations — appliquées directement à la note",
|
"transformationsDesc": "Transformations — appliquées directement à la note",
|
||||||
"writeMinWordsAction": "Écrivez au moins 5 mots pour activer les actions IA.",
|
"writeMinWordsAction": "Écrivez au moins 5 mots pour activer les actions IA.",
|
||||||
"processingAction": "Traitement en cours...",
|
"processingAction": "Traitement en cours...",
|
||||||
|
"noImagesError": "Aucune image dans cette note",
|
||||||
|
"overview": "Résumé",
|
||||||
"action": {
|
"action": {
|
||||||
"clarify": "Clarifier",
|
"clarify": "Clarifier",
|
||||||
"shorten": "Raccourcir",
|
"shorten": "Raccourcir",
|
||||||
"improve": "Améliorer",
|
"improve": "Améliorer",
|
||||||
"toMarkdown": "Convertir en Markdown"
|
"toMarkdown": "Convertir en Markdown",
|
||||||
|
"describeImages": "Décrire les images"
|
||||||
},
|
},
|
||||||
"openAssistant": "Ouvrir l'Assistant IA",
|
"openAssistant": "Ouvrir l'Assistant IA",
|
||||||
"poweredByMomento": "Propulsé par Momento AI",
|
"poweredByMomento": "Propulsé par Momento AI",
|
||||||
@@ -415,7 +418,9 @@
|
|||||||
"historyTab": "Historique",
|
"historyTab": "Historique",
|
||||||
"insightsTab": "Insights",
|
"insightsTab": "Insights",
|
||||||
"aiCopilot": "Copilote IA",
|
"aiCopilot": "Copilote IA",
|
||||||
"suggestTitle": "Suggestion de titre IA"
|
"suggestTitle": "Suggestion de titre IA",
|
||||||
|
"generateTitleFromImage": "Générer un titre à partir de l'image",
|
||||||
|
"titleGenerated": "Titre généré à partir de l'image"
|
||||||
},
|
},
|
||||||
"aiSettings": {
|
"aiSettings": {
|
||||||
"description": "Configurez vos fonctionnalités IA et préférences",
|
"description": "Configurez vos fonctionnalités IA et préférences",
|
||||||
@@ -445,6 +450,7 @@
|
|||||||
"notesViewLabel": "Affichage des notes",
|
"notesViewLabel": "Affichage des notes",
|
||||||
"notesViewTabs": "Onglets (type OneNote)",
|
"notesViewTabs": "Onglets (type OneNote)",
|
||||||
"notesViewMasonry": "Cartes (grille)",
|
"notesViewMasonry": "Cartes (grille)",
|
||||||
|
"selectTheme": "Sélectionner le thème",
|
||||||
"title": "Apparence"
|
"title": "Apparence"
|
||||||
},
|
},
|
||||||
"auth": {
|
"auth": {
|
||||||
@@ -852,7 +858,8 @@
|
|||||||
"pdfGeneratedOn": "Généré le :",
|
"pdfGeneratedOn": "Généré le :",
|
||||||
"confidence": "confiance",
|
"confidence": "confiance",
|
||||||
"savingReminder": "Erreur lors de la sauvegarde du rappel",
|
"savingReminder": "Erreur lors de la sauvegarde du rappel",
|
||||||
"removingReminder": "Erreur lors de la suppression du rappel"
|
"removingReminder": "Erreur lors de la suppression du rappel",
|
||||||
|
"generatingDescription": "Veuillez patienter..."
|
||||||
},
|
},
|
||||||
"notebookSuggestion": {
|
"notebookSuggestion": {
|
||||||
"description": "Cette note semble appartenir à ce carnet",
|
"description": "Cette note semble appartenir à ce carnet",
|
||||||
@@ -1014,7 +1021,21 @@
|
|||||||
"sortDateDesc": "Date (récent)",
|
"sortDateDesc": "Date (récent)",
|
||||||
"sortDateAsc": "Date (ancien)",
|
"sortDateAsc": "Date (ancien)",
|
||||||
"sortTitleAsc": "Titre A → Z",
|
"sortTitleAsc": "Titre A → Z",
|
||||||
"sortTitleDesc": "Titre Z → A"
|
"sortTitleDesc": "Titre Z → A",
|
||||||
|
"suggestTitle": "Titre IA",
|
||||||
|
"generateTitleFromImage": "Générer un titre à partir de l'image",
|
||||||
|
"titleGenerated": "Titre généré",
|
||||||
|
"content": "Contenu",
|
||||||
|
"restore": "Restaurer",
|
||||||
|
"createFailed": "Impossible de créer la note",
|
||||||
|
"updateFailed": "Mise à jour échouée",
|
||||||
|
"archived": "Note archivée",
|
||||||
|
"archiveFailed": "Échec de l'archivage",
|
||||||
|
"sort": "Trier",
|
||||||
|
"confirmDeleteTitle": "Supprimer la note",
|
||||||
|
"leftShare": "Partage retiré",
|
||||||
|
"dismissed": "Note retirée des récentes",
|
||||||
|
"generalNotes": "Notes générales"
|
||||||
},
|
},
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"next": "→",
|
"next": "→",
|
||||||
@@ -1178,7 +1199,8 @@
|
|||||||
"noLabelsInNotebook": "Aucune étiquette dans ce carnet",
|
"noLabelsInNotebook": "Aucune étiquette dans ce carnet",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"reminders": "Rappels",
|
"reminders": "Rappels",
|
||||||
"trash": "Corbeille"
|
"trash": "Corbeille",
|
||||||
|
"clearFilter": "Retirer le filtre"
|
||||||
},
|
},
|
||||||
"support": {
|
"support": {
|
||||||
"aiApiCosts": "Coûts API IA :",
|
"aiApiCosts": "Coûts API IA :",
|
||||||
@@ -1567,7 +1589,8 @@
|
|||||||
"createFailed": "Échec de la création",
|
"createFailed": "Échec de la création",
|
||||||
"deleteSpace": "Supprimer l'espace",
|
"deleteSpace": "Supprimer l'espace",
|
||||||
"deleted": "Espace supprimé",
|
"deleted": "Espace supprimé",
|
||||||
"deleteError": "Erreur lors de la suppression"
|
"deleteError": "Erreur lors de la suppression",
|
||||||
|
"rename": "Renommer"
|
||||||
},
|
},
|
||||||
"lab": {
|
"lab": {
|
||||||
"initializing": "Initialisation de l'espace de travail",
|
"initializing": "Initialisation de l'espace de travail",
|
||||||
|
|||||||
Reference in New Issue
Block a user