feat: architectural grid editor fullPage + slash commands + doc info panel + AI title
This commit is contained in:
@@ -13,8 +13,9 @@ import {
|
||||
Globe, BookOpen, FileText, RotateCcw, Check,
|
||||
Maximize2, ImageIcon, Link2, Download, ArrowDownToLine,
|
||||
GitMerge, PlusCircle, Eye, Code, Languages,
|
||||
Presentation, PenTool, ExternalLink,
|
||||
Presentation, PenTool, ExternalLink, ImagePlus,
|
||||
} from 'lucide-react'
|
||||
import { exportExcalidrawSceneToPngBlob } from '@/lib/client/excalidraw-export-image'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
import { MarkdownContent } from '@/components/markdown-content'
|
||||
import { toast } from 'sonner'
|
||||
@@ -94,6 +95,8 @@ interface ContextualAIChatProps {
|
||||
notebooks?: Array<{ id: string; name: string }>
|
||||
/** Extra classes forwarded to the aside root element */
|
||||
className?: string
|
||||
/** How to embed generated diagram images (markdown vs rich text HTML) */
|
||||
diagramInsertFormat?: 'markdown' | 'html'
|
||||
}
|
||||
|
||||
// ── Component ─────────────────────────────────────────────────────────────────
|
||||
@@ -109,6 +112,7 @@ export function ContextualAIChat({
|
||||
lastActionApplied = false,
|
||||
notebooks = [],
|
||||
className,
|
||||
diagramInsertFormat = 'markdown',
|
||||
}: ContextualAIChatProps) {
|
||||
const { t, language } = useLanguage()
|
||||
const webSearchAvailable = useWebSearchAvailable()
|
||||
@@ -135,6 +139,7 @@ export function ContextualAIChat({
|
||||
const [slideStyle, setSlideStyle] = useState('soft')
|
||||
const [diagramType, setDiagramType] = useState('auto')
|
||||
const [diagramStyle, setDiagramStyle] = useState('default')
|
||||
const [diagramEmbedLoading, setDiagramEmbedLoading] = useState(false)
|
||||
|
||||
// Resource tab state
|
||||
const [resourceUrl, setResourceUrl] = useState('')
|
||||
@@ -329,9 +334,8 @@ export function ContextualAIChat({
|
||||
})
|
||||
} else if (type === 'diagram' && poll.canvasId) {
|
||||
toast.success(t('ai.generate.diagramReady') || 'Diagramme généré !', {
|
||||
id: toastId, duration: 10000,
|
||||
description: t('ai.generate.toastSuccessDiagram') || 'Votre diagramme est disponible dans le Lab.',
|
||||
action: { label: t('ai.generate.openDiagram') || 'Ouvrir', onClick: () => { window.location.href = `/lab?id=${poll.canvasId}` } },
|
||||
id: toastId, duration: 8000,
|
||||
description: t('ai.generate.diagramReadyHint') || '',
|
||||
})
|
||||
} else {
|
||||
toast.success(type === 'slides'
|
||||
@@ -365,6 +369,53 @@ export function ContextualAIChat({
|
||||
}
|
||||
}, [])
|
||||
|
||||
const buildDiagramImageSnippet = (imageUrl: string, alt: string) => {
|
||||
if (diagramInsertFormat === 'html') {
|
||||
const safeAlt = alt.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<')
|
||||
return `\n<p><img src="${imageUrl}" alt="${safeAlt}" loading="lazy" style="max-width:100%;height:auto;border-radius:8px;" /></p>\n`
|
||||
}
|
||||
const safeMdAlt = alt.replace(/[\[\]]/g, '')
|
||||
return `\n\n\n\n`
|
||||
}
|
||||
|
||||
const handleEmbedDiagramInNote = async (canvasId: string) => {
|
||||
if (!onApplyToNote) {
|
||||
toast.error(t('ai.generate.insertNeedEditor'))
|
||||
return
|
||||
}
|
||||
setDiagramEmbedLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/canvas?id=${encodeURIComponent(canvasId)}`)
|
||||
const data = await res.json()
|
||||
if (!res.ok || !data.canvas?.data) {
|
||||
toast.error(data.error || t('ai.generate.insertFetchError'))
|
||||
return
|
||||
}
|
||||
const blob = await exportExcalidrawSceneToPngBlob(data.canvas.data)
|
||||
if (!blob) {
|
||||
toast.error(t('ai.generate.insertExportError'))
|
||||
return
|
||||
}
|
||||
const fd = new FormData()
|
||||
fd.append('file', blob, `diagram-${canvasId.slice(-8)}.png`)
|
||||
const up = await fetch('/api/upload', { method: 'POST', body: fd })
|
||||
const upJson = await up.json()
|
||||
if (!up.ok || !upJson.url) {
|
||||
toast.error(upJson.error || t('ai.generate.insertUploadError'))
|
||||
return
|
||||
}
|
||||
const alt = t('ai.generate.diagramImageAlt')
|
||||
const snippet = buildDiagramImageSnippet(upJson.url as string, alt)
|
||||
onApplyToNote(`${noteContent ?? ''}${snippet}`)
|
||||
toast.success(t('ai.generate.insertedInNote'))
|
||||
} catch (e) {
|
||||
console.error('[embed diagram]', e)
|
||||
toast.error(t('ai.generate.insertExportError'))
|
||||
} finally {
|
||||
setDiagramEmbedLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Resource tab handlers ────────────────────────────────────────────────────
|
||||
|
||||
const handleScrapeUrl = async () => {
|
||||
@@ -467,46 +518,47 @@ export function ContextualAIChat({
|
||||
className,
|
||||
)}>
|
||||
|
||||
{/* ── Header ───────────────────────────────────────────────── */}
|
||||
<div className="px-4 py-3 border-b border-border/40 flex items-center justify-between shrink-0">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-base font-semibold text-foreground flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary shrink-0" />
|
||||
{t('ai.aiNoteTitle')}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{noteTitle ? `"${noteTitle}"` : t('ai.currentNote')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{lastActionApplied && onUndoLastAction && (
|
||||
{/* ── Header ─────────────────────────────────────────── */}
|
||||
<div className="px-5 pt-5 pb-4 border-b border-border/40 shrink-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-memento-serif text-xl font-medium text-foreground flex items-center gap-2 leading-tight">
|
||||
<Sparkles className="h-4 w-4 text-amber-500 shrink-0" />
|
||||
{t('ai.aiNoteTitle')}
|
||||
</h2>
|
||||
<p className="text-[11px] text-muted-foreground uppercase tracking-[0.15em] mt-1 truncate font-medium">
|
||||
{noteTitle ? noteTitle : t('ai.currentNote')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0 -mt-1">
|
||||
{lastActionApplied && onUndoLastAction && (
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-950/30"
|
||||
onClick={onUndoLastAction}
|
||||
title={t('ai.undoLastAction')}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-7 w-7 text-amber-500 hover:text-amber-600 hover:bg-amber-50 dark:hover:bg-amber-950/30"
|
||||
onClick={onUndoLastAction}
|
||||
title={t('ai.undoLastAction')}
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
title={expanded ? t('ai.shrinkPanel') : t('ai.expandPanel')}
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
{expanded
|
||||
? <Minimize2 className="h-3.5 w-3.5" />
|
||||
: <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
)}
|
||||
{/* Expand / Shrink */}
|
||||
<Button
|
||||
variant="ghost" size="icon"
|
||||
className="h-7 w-7 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
title={expanded ? t('ai.shrinkPanel') : t('ai.expandPanel')}
|
||||
>
|
||||
{expanded
|
||||
? <Minimize2 className="h-3.5 w-3.5" />
|
||||
: <Maximize2 className="h-3.5 w-3.5" />}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-7 w-7 text-muted-foreground hover:text-foreground">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={onClose} className="h-7 w-7 text-muted-foreground hover:text-foreground">
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Tabs ─────────────────────────────────────────────────── */}
|
||||
{/* ── Tabs ────────────────────────────────────────────── */}
|
||||
<div className="flex border-b border-border/40 shrink-0">
|
||||
{([
|
||||
{ id: 'chat', icon: Bot, label: t('ai.chatTab') },
|
||||
@@ -517,14 +569,17 @@ export function ContextualAIChat({
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id as any)}
|
||||
className={cn(
|
||||
'flex-1 py-2.5 border-b-2 text-xs font-semibold flex items-center justify-center gap-1.5 transition-all',
|
||||
'relative flex-1 py-3 text-[10px] font-bold uppercase tracking-[0.15em] flex items-center justify-center gap-1.5 transition-all',
|
||||
activeTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground',
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<tab.icon className="h-3 w-3" />
|
||||
{tab.label}
|
||||
{activeTab === tab.id && (
|
||||
<span className="absolute bottom-0 left-0 right-0 h-[2px] bg-foreground" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -1089,11 +1144,24 @@ export function ContextualAIChat({
|
||||
|
||||
{/* Diagram result */}
|
||||
{generateResult?.type === 'diagram' && generateResult.canvasId && (
|
||||
<a href={`/lab?id=${generateResult.canvasId}`}
|
||||
className="mt-2 w-full flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40 transition-all">
|
||||
<ExternalLink className="h-4 w-4 shrink-0" />
|
||||
{t('ai.generate.openDiagram') || 'Ouvrir dans le Lab'}
|
||||
</a>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<a href={`/lab?id=${generateResult.canvasId}`}
|
||||
className="w-full flex items-center gap-2 rounded-xl border border-green-200 bg-green-50 dark:border-green-800 dark:bg-green-950/30 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-300 hover:bg-green-100 dark:hover:bg-green-900/40 transition-all">
|
||||
<PenTool className="h-4 w-4 shrink-0" />
|
||||
{t('ai.generate.openInExcalidraw')}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
disabled={diagramEmbedLoading}
|
||||
onClick={() => void handleEmbedDiagramInNote(generateResult.canvasId!)}
|
||||
className="w-full flex items-center gap-2 rounded-xl border border-cyan-200 bg-cyan-50 dark:border-cyan-800 dark:bg-cyan-950/30 px-4 py-2.5 text-sm font-medium text-cyan-700 dark:text-cyan-300 hover:bg-cyan-100 dark:hover:bg-cyan-900/40 transition-all disabled:opacity-60 text-left"
|
||||
>
|
||||
{diagramEmbedLoading
|
||||
? <Loader2 className="h-4 w-4 shrink-0 animate-spin" />
|
||||
: <ImagePlus className="h-4 w-4 shrink-0" />}
|
||||
{t('ai.generate.insertDiagramInNote')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user