feat: add AI chat builder frontend — types, API client, store, components, page, routing
- TypeScript types for chat session, messages, and responses - API client module with all 6 endpoints - Zustand store with session management, message sending, tree generation, import, resume - 7 chat components: ChatMessage, ChatInput, ChatPanel, PhaseIndicator, ChatToolbar, EmptyPreview, StaticTreePreview - AIChatBuilderPage with split-panel layout (60% chat / 40% preview) - Route at /ai/chat with lazy loading - "Build with AI" button on TreeLibraryPage - Session resume via URL search params Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
151
frontend/src/pages/AIChatBuilderPage.tsx
Normal file
151
frontend/src/pages/AIChatBuilderPage.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useAIChatStore } from '@/store/aiChatStore'
|
||||
import { ChatPanel } from '@/components/ai-chat/ChatPanel'
|
||||
import { ChatToolbar } from '@/components/ai-chat/ChatToolbar'
|
||||
import { EmptyPreview } from '@/components/ai-chat/EmptyPreview'
|
||||
import { StaticTreePreview } from '@/components/ai-chat/StaticTreePreview'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { getTreeEditorPath } from '@/lib/routing'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { TreeStructure } from '@/types'
|
||||
|
||||
export function AIChatBuilderPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const flowType = searchParams.get('type') === 'procedural' ? 'procedural' : 'troubleshooting'
|
||||
|
||||
const {
|
||||
sessionId,
|
||||
status,
|
||||
currentPhase,
|
||||
messages,
|
||||
isResponding,
|
||||
workingTree,
|
||||
treeMetadata,
|
||||
generatedTree,
|
||||
isGenerating,
|
||||
error,
|
||||
startSession,
|
||||
sendMessage,
|
||||
generateTree,
|
||||
importToEditor,
|
||||
abandonSession,
|
||||
resumeSession,
|
||||
} = useAIChatStore()
|
||||
|
||||
// Start or resume session on mount
|
||||
useEffect(() => {
|
||||
const resumeId = searchParams.get('session')
|
||||
if (resumeId && !sessionId) {
|
||||
resumeSession(resumeId)
|
||||
} else if (!sessionId && status === 'idle') {
|
||||
startSession(flowType as 'troubleshooting' | 'procedural')
|
||||
}
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Store sessionId in URL for resume support
|
||||
useEffect(() => {
|
||||
if (sessionId && !searchParams.get('session')) {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.set('session', sessionId)
|
||||
return next
|
||||
}, { replace: true })
|
||||
}
|
||||
}, [sessionId, searchParams, setSearchParams])
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
(content: string) => {
|
||||
sendMessage(content)
|
||||
},
|
||||
[sendMessage]
|
||||
)
|
||||
|
||||
const handleGenerate = useCallback(() => {
|
||||
generateTree()
|
||||
}, [generateTree])
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
try {
|
||||
const treeId = await importToEditor({
|
||||
name: treeMetadata?.name,
|
||||
description: treeMetadata?.description,
|
||||
tags: treeMetadata?.tags,
|
||||
})
|
||||
const path = getTreeEditorPath(treeId, flowType)
|
||||
navigate(path)
|
||||
toast.success('Flow imported to editor')
|
||||
} catch {
|
||||
toast.error('Failed to import flow')
|
||||
}
|
||||
}, [importToEditor, treeMetadata, flowType, navigate])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
abandonSession()
|
||||
// Clear session from URL
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.delete('session')
|
||||
return next
|
||||
}, { replace: true })
|
||||
startSession(flowType as 'troubleshooting' | 'procedural')
|
||||
}, [abandonSession, startSession, flowType, setSearchParams])
|
||||
|
||||
// Show error toast
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
toast.error(error)
|
||||
}
|
||||
}, [error])
|
||||
|
||||
if (status === 'idle' && !sessionId) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const previewTree = (generatedTree || workingTree) as TreeStructure | null
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<ChatToolbar
|
||||
currentPhase={currentPhase}
|
||||
status={status}
|
||||
isGenerating={isGenerating}
|
||||
hasGeneratedTree={!!generatedTree}
|
||||
onGenerate={handleGenerate}
|
||||
onImport={handleImport}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Left panel: Chat (60%) */}
|
||||
<div className="flex w-3/5 flex-col border-r border-border max-lg:w-full">
|
||||
<ChatPanel
|
||||
messages={messages}
|
||||
isResponding={isResponding}
|
||||
onSendMessage={handleSendMessage}
|
||||
disabled={status !== 'active'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right panel: Tree preview (40%) — hidden below 1024px */}
|
||||
<div className="w-2/5 overflow-hidden bg-background max-lg:hidden">
|
||||
{previewTree ? (
|
||||
<StaticTreePreview
|
||||
tree={previewTree}
|
||||
name={treeMetadata?.name}
|
||||
/>
|
||||
) : (
|
||||
<EmptyPreview />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AIChatBuilderPage
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { X, RotateCcw, Play } from 'lucide-react'
|
||||
import { X, RotateCcw, Play, Sparkles } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
import { foldersApi } from '@/api/folders'
|
||||
@@ -272,11 +272,22 @@ export function TreeLibraryPage() {
|
||||
</p>
|
||||
</div>
|
||||
{canCreateTrees && (
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
label="Create New"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
{aiEnabled && (
|
||||
<button
|
||||
onClick={() => navigate('/ai/chat')}
|
||||
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Build with AI
|
||||
</button>
|
||||
)}
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
label="Create New"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user