feat: add AIAssistPanel with replace and merge modes
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
131
frontend/src/components/network/panels/AIAssistPanel.tsx
Normal file
131
frontend/src/components/network/panels/AIAssistPanel.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import { Sparkles, ChevronUp, ChevronDown, AlertTriangle } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { networkDiagramsApi } from '@/api'
|
||||||
|
import type { AIGenerateResponse } from '@/types'
|
||||||
|
|
||||||
|
interface AIAssistPanelProps {
|
||||||
|
onGenerate: (result: AIGenerateResponse, mode: 'replace' | 'merge') => void
|
||||||
|
getExistingBounds: () => { minX: number; maxX: number; minY: number; maxY: number } | null
|
||||||
|
hasNodes: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AIAssistPanel({ onGenerate, getExistingBounds, hasNodes }: AIAssistPanelProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
const [description, setDescription] = useState('')
|
||||||
|
const [mode, setMode] = useState<'replace' | 'merge'>('replace')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleGenerate = useCallback(async () => {
|
||||||
|
if (!description.trim()) return
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const result = await networkDiagramsApi.aiGenerate({
|
||||||
|
description: description.trim(),
|
||||||
|
mode,
|
||||||
|
existingBounds: mode === 'merge' ? getExistingBounds() : null,
|
||||||
|
})
|
||||||
|
onGenerate(result, mode)
|
||||||
|
setDescription('')
|
||||||
|
setExpanded(false)
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = err instanceof Error ? err.message : 'Generation failed. Please try again.'
|
||||||
|
setError(msg)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [description, mode, onGenerate, getExistingBounds])
|
||||||
|
|
||||||
|
if (!expanded) {
|
||||||
|
return (
|
||||||
|
<div className="border-t border-default bg-card">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(true)}
|
||||||
|
className="flex w-full items-center justify-center gap-2 px-4 py-2 text-xs text-muted-foreground hover:text-primary"
|
||||||
|
>
|
||||||
|
<Sparkles size={14} />
|
||||||
|
AI Generate
|
||||||
|
<ChevronUp size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-default bg-card">
|
||||||
|
<div className="flex items-center justify-between border-b border-default px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-heading">
|
||||||
|
<Sparkles size={14} />
|
||||||
|
AI Generate
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setExpanded(false)} className="text-muted-foreground hover:text-primary">
|
||||||
|
<ChevronDown size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('replace')}
|
||||||
|
className={cn(
|
||||||
|
'rounded px-3 py-1 text-xs font-medium transition-colors',
|
||||||
|
mode === 'replace'
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: 'border border-default text-muted-foreground hover:text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Generate New
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setMode('merge')}
|
||||||
|
className={cn(
|
||||||
|
'rounded px-3 py-1 text-xs font-medium transition-colors',
|
||||||
|
mode === 'merge'
|
||||||
|
? 'bg-accent text-white'
|
||||||
|
: 'border border-default text-muted-foreground hover:text-primary',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Add to Diagram
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'replace' && hasNodes && (
|
||||||
|
<div className="flex items-start gap-2 rounded border border-yellow-500/30 bg-yellow-500/5 px-3 py-2">
|
||||||
|
<AlertTriangle size={14} className="mt-0.5 shrink-0 text-yellow-400" />
|
||||||
|
<p className="text-[11px] text-yellow-400">
|
||||||
|
This will replace your current diagram. Save first if needed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
placeholder="Describe the network you want to create... e.g. 'Small office with a firewall, core switch, 3 access points, and a file server'"
|
||||||
|
rows={3}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full resize-none rounded border border-default bg-input px-3 py-2 text-xs text-primary placeholder:text-muted-foreground focus:border-accent focus:outline-none disabled:opacity-50"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error && <p className="text-[11px] text-red-400">{error}</p>}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center gap-2 py-2">
|
||||||
|
<div className="h-2 w-2 animate-pulse rounded-full bg-accent" />
|
||||||
|
<span className="text-xs text-muted-foreground">Generating your network diagram...</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={!description.trim()}
|
||||||
|
className="rounded bg-accent px-4 py-2 text-xs font-medium text-white hover:bg-accent/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Generate
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user