feat: architectural grid editor fullPage + slash commands + doc info panel + AI title

This commit is contained in:
Antigravity
2026-05-07 22:29:02 +00:00
parent 0d8252aec0
commit e458b63115
126 changed files with 7652 additions and 1110 deletions

View File

@@ -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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
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![${safeMdAlt}](${imageUrl})\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>
)}