Implements the complete AI flow builder feature using a guided 4-stage wizard (Foundation → Scaffold → Branch Detail → Review & Assemble). AI assists at bounded points using Claude Haiku for cost-efficient structured JSON generation (~$0.01-0.03/flow). Backend: new models (ai_conversations, ai_usage), Alembic migration, quota enforcement with billing anchor, Anthropic API integration with prompt caching, tree validation, conversation CRUD with 24h TTL, APScheduler cleanup job, 5 API endpoints, Pydantic schemas. Frontend: TypeScript types, API client, Zustand store for wizard state, 7 components (modal, step indicator, foundation form, branch selector, branch detail view, tree preview, quota display), MyTreesPage integration with "Build with AI" button (hidden when AI not configured). Tests: 14 validator unit tests + 11 endpoint integration tests with mocked Anthropic (zero real API spend). All 25 tests passing. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
136 lines
3.7 KiB
TypeScript
136 lines
3.7 KiB
TypeScript
import { useEffect } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { Modal } from '@/components/common/Modal'
|
|
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
|
|
import { treesApi } from '@/api/trees'
|
|
import { toast } from '@/lib/toast'
|
|
import { WizardStepIndicator } from './WizardStepIndicator'
|
|
import { FoundationForm } from './FoundationForm'
|
|
import { BranchSelector } from './BranchSelector'
|
|
import { BranchDetailView } from './BranchDetailView'
|
|
import { TreePreviewCard } from './TreePreviewCard'
|
|
import { GeneratingAnimation } from './GeneratingAnimation'
|
|
|
|
interface AIFlowBuilderModalProps {
|
|
isOpen: boolean
|
|
onClose: () => void
|
|
}
|
|
|
|
export function AIFlowBuilderModal({ isOpen, onClose }: AIFlowBuilderModalProps) {
|
|
const navigate = useNavigate()
|
|
const {
|
|
phase,
|
|
metadata,
|
|
assembledTree,
|
|
loadQuota,
|
|
scaffold,
|
|
reset,
|
|
} = useAIFlowBuilderStore()
|
|
|
|
// Load quota when modal opens
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
loadQuota()
|
|
}
|
|
}, [isOpen, loadQuota])
|
|
|
|
// Auto-trigger scaffold after conversation starts
|
|
useEffect(() => {
|
|
if (phase === 'scaffolding' && !useAIFlowBuilderStore.getState().suggestedBranches.length) {
|
|
scaffold()
|
|
}
|
|
}, [phase, scaffold])
|
|
|
|
const handleClose = () => {
|
|
reset()
|
|
onClose()
|
|
}
|
|
|
|
const handleOpenInEditor = async () => {
|
|
if (!assembledTree) return
|
|
try {
|
|
const tree = await treesApi.create({
|
|
name: assembledTree.suggested_name,
|
|
description: assembledTree.suggested_description,
|
|
tree_structure: assembledTree.tree_structure,
|
|
tree_type: metadata.flow_type,
|
|
})
|
|
handleClose()
|
|
const editorPath =
|
|
metadata.flow_type === 'procedural'
|
|
? `/flows/${tree.id}/edit`
|
|
: `/trees/${tree.id}/edit`
|
|
navigate(editorPath)
|
|
} catch {
|
|
toast.error('Failed to create flow. Please try again.')
|
|
}
|
|
}
|
|
|
|
const getTitle = () => {
|
|
switch (phase) {
|
|
case 'foundation':
|
|
return 'Build with AI'
|
|
case 'scaffolding':
|
|
case 'generating':
|
|
return 'AI Scaffold'
|
|
case 'detailing':
|
|
return 'Branch Detail'
|
|
case 'reviewing':
|
|
return 'Review & Assemble'
|
|
case 'error':
|
|
return 'AI Flow Builder'
|
|
default:
|
|
return 'Build with AI'
|
|
}
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
isOpen={isOpen}
|
|
onClose={handleClose}
|
|
title={getTitle()}
|
|
size="lg"
|
|
footer={
|
|
<WizardStepIndicator phase={phase} />
|
|
}
|
|
>
|
|
{phase === 'foundation' && <FoundationForm />}
|
|
{phase === 'scaffolding' && <BranchSelector />}
|
|
{phase === 'generating' && <GeneratingAnimation />}
|
|
{phase === 'detailing' && <BranchDetailView />}
|
|
{phase === 'reviewing' && (
|
|
<TreePreviewCard onOpenInEditor={handleOpenInEditor} />
|
|
)}
|
|
{phase === 'error' && <ErrorView />}
|
|
</Modal>
|
|
)
|
|
}
|
|
|
|
function ErrorView() {
|
|
const { error, reset, setPhase } = useAIFlowBuilderStore()
|
|
|
|
return (
|
|
<div className="flex flex-col items-center gap-4 py-8">
|
|
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-4 py-3 text-sm text-red-400">
|
|
{error || 'An unexpected error occurred.'}
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPhase('foundation')}
|
|
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
Go Back
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={reset}
|
|
className="rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
|
>
|
|
Start Over
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|