From 29dc95e920fd6e2dabbf998696cc2648baaead81 Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 26 Feb 2026 17:29:53 -0500 Subject: [PATCH] =?UTF-8?q?feat:=20add=20AI=20auto-fix=20UI=20=E2=80=94=20?= =?UTF-8?q?types,=20API=20client,=20ValidationSummary=20button,=20review?= =?UTF-8?q?=20modal,=20and=20TreeEditorPage=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New ai-fix.ts types for request/response - fixTree() method on treesApi - "Fix with AI" button in ValidationSummary (shows for fixable errors) - AIFixReviewModal with per-fix apply/skip and apply-all - TreeEditorPage orchestrates the fix flow Co-Authored-By: Claude Opus 4.6 --- frontend/src/api/trees.ts | 8 +- .../tree-editor/AIFixReviewModal.tsx | 170 ++++++++++++++++++ .../tree-editor/ValidationSummary.tsx | 50 +++++- frontend/src/pages/TreeEditorPage.tsx | 65 ++++++- frontend/src/types/ai-fix.ts | 24 +++ frontend/src/types/index.ts | 7 + 6 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 frontend/src/components/tree-editor/AIFixReviewModal.tsx create mode 100644 frontend/src/types/ai-fix.ts diff --git a/frontend/src/api/trees.ts b/frontend/src/api/trees.ts index 8b21840c..5f12603d 100644 --- a/frontend/src/api/trees.ts +++ b/frontend/src/api/trees.ts @@ -1,5 +1,5 @@ import apiClient from './client' -import type { Tree, TreeListItem, TreeCreate, TreeUpdate, TreeFilters, TreeShareCreate, TreeShare, TreeVisibilityUpdate, TreeValidationResponse } from '@/types' +import type { Tree, TreeListItem, TreeCreate, TreeUpdate, TreeFilters, TreeShareCreate, TreeShare, TreeVisibilityUpdate, TreeValidationResponse, AIFixTreeRequest, AIFixTreeResponse } from '@/types' export const treesApi = { async list(params?: TreeFilters): Promise { @@ -65,6 +65,12 @@ export const treesApi = { const response = await apiClient.post(`/trees/${id}/can-publish`) return response.data }, + + // AI auto-fix + async fixTree(request: AIFixTreeRequest): Promise { + const response = await apiClient.post('/ai/fix-tree', request) + return response.data + }, } export default treesApi diff --git a/frontend/src/components/tree-editor/AIFixReviewModal.tsx b/frontend/src/components/tree-editor/AIFixReviewModal.tsx new file mode 100644 index 00000000..acda5769 --- /dev/null +++ b/frontend/src/components/tree-editor/AIFixReviewModal.tsx @@ -0,0 +1,170 @@ +import { useState } from 'react' +import { X, Check, SkipForward, Sparkles, ChevronDown, ChevronUp } from 'lucide-react' +import { cn } from '@/lib/utils' +import type { AIFixProposal } from '@/types' + +interface AIFixReviewModalProps { + fixes: AIFixProposal[] + onApply: (fix: AIFixProposal) => void + onApplyAll: () => void + onClose: () => void +} + +export function AIFixReviewModal({ fixes, onApply, onApplyAll, onClose }: AIFixReviewModalProps) { + const [appliedIds, setAppliedIds] = useState>(new Set()) + const [skippedIds, setSkippedIds] = useState>(new Set()) + const [expandedIds, setExpandedIds] = useState>(new Set(fixes.map(f => f.target_node_id))) + + const handleApply = (fix: AIFixProposal) => { + onApply(fix) + setAppliedIds(prev => new Set(prev).add(fix.target_node_id)) + } + + const handleSkip = (fix: AIFixProposal) => { + setSkippedIds(prev => new Set(prev).add(fix.target_node_id)) + } + + const toggleExpanded = (id: string) => { + setExpandedIds(prev => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const pendingFixes = fixes.filter( + f => !appliedIds.has(f.target_node_id) && !skippedIds.has(f.target_node_id) + ) + const allHandled = pendingFixes.length === 0 + + return ( +
+
+ {/* Header */} +
+
+ +

+ AI Fix Proposals ({fixes.length}) +

+
+ +
+ + {/* Body */} +
+ {fixes.map((fix) => { + const isApplied = appliedIds.has(fix.target_node_id) + const isSkipped = skippedIds.has(fix.target_node_id) + const isExpanded = expandedIds.has(fix.target_node_id) + + return ( +
+ {/* Fix header */} +
+
+

{fix.error_message}

+

{fix.description}

+

+ Node: {fix.target_node_id} +

+
+ {isApplied && ( + + Applied + + )} + {isSkipped && ( + Skipped + )} +
+ + {/* Expand/collapse detail */} + {!isApplied && !isSkipped && ( + <> + + + {isExpanded && ( +
+
+

Before

+
+                            {JSON.stringify(fix.original_node, null, 2)}
+                          
+
+
+

After

+
+                            {JSON.stringify(fix.fixed_node, null, 2)}
+                          
+
+
+ )} + + {/* Action buttons */} +
+ + +
+ + )} +
+ ) + })} +
+ + {/* Footer */} +
+ + {!allHandled && ( + + )} +
+
+
+ ) +} diff --git a/frontend/src/components/tree-editor/ValidationSummary.tsx b/frontend/src/components/tree-editor/ValidationSummary.tsx index fcf87bad..987be180 100644 --- a/frontend/src/components/tree-editor/ValidationSummary.tsx +++ b/frontend/src/components/tree-editor/ValidationSummary.tsx @@ -1,14 +1,16 @@ import { useState } from 'react' -import { AlertCircle, AlertTriangle, ChevronDown, ChevronUp } from 'lucide-react' +import { AlertCircle, AlertTriangle, ChevronDown, ChevronUp, Sparkles, Loader2 } from 'lucide-react' import { cn } from '@/lib/utils' import type { ValidationError } from '@/store/treeEditorStore' interface ValidationSummaryProps { errors: ValidationError[] onSelectNode: (nodeId: string) => void + onFixWithAI?: () => void + isFixing?: boolean } -export function ValidationSummary({ errors, onSelectNode }: ValidationSummaryProps) { +export function ValidationSummary({ errors, onSelectNode, onFixWithAI, isFixing }: ValidationSummaryProps) { const [isExpanded, setIsExpanded] = useState(true) const errorItems = errors.filter(e => e.severity === 'error') @@ -22,6 +24,8 @@ export function ValidationSummary({ errors, onSelectNode }: ValidationSummaryPro } } + const hasFixableErrors = errorItems.some(e => e.nodeId) + return (
{/* Header */} -
- {isExpanded ? : } - + {isExpanded ? : } + + + {/* Fix with AI button */} + {onFixWithAI && hasFixableErrors && ( + + )} + {/* Error/Warning List */} {isExpanded && ( diff --git a/frontend/src/pages/TreeEditorPage.tsx b/frontend/src/pages/TreeEditorPage.tsx index a21097b4..fa3ba215 100644 --- a/frontend/src/pages/TreeEditorPage.tsx +++ b/frontend/src/pages/TreeEditorPage.tsx @@ -5,10 +5,11 @@ import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, import { getMonacoEditor } from '@/components/tree-editor/code-mode' import { treesApi } from '@/api/trees' import { treeMarkdownApi } from '@/api/treeMarkdown' -import type { TreeCreate, TreeUpdate, TreeStatus, TreeStructure } from '@/types' +import type { TreeCreate, TreeUpdate, TreeStatus, TreeStructure, AIFixProposal } from '@/types' import { useTreeEditorStore, useTreeEditorTemporal } from '@/store/treeEditorStore' import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout' import { ValidationSummary } from '@/components/tree-editor/ValidationSummary' +import { AIFixReviewModal } from '@/components/tree-editor/AIFixReviewModal' import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts' import { usePermissions } from '@/hooks/usePermissions' import { Spinner } from '@/components/common/Spinner' @@ -58,6 +59,8 @@ export function TreeEditorPage() { const [showAnalytics, setShowAnalytics] = useState(false) const [isMetadataOpen, setIsMetadataOpen] = useState(false) const [editingNodeId, setEditingNodeId] = useState(null) + const [isFixing, setIsFixing] = useState(false) + const [fixProposals, setFixProposals] = useState(null) // Mobile detection const [isMobile, setIsMobile] = useState(false) @@ -217,6 +220,54 @@ export function TreeEditorPage() { selectNode(nodeId) } + const handleFixWithAI = async () => { + const store = useTreeEditorStore.getState() + if (!store.treeStructure) return + + const fixableErrors = store.validationErrors + .filter(e => e.severity === 'error' && e.nodeId) + .map(e => ({ node_id: e.nodeId!, message: e.message })) + + if (fixableErrors.length === 0) return + + setIsFixing(true) + try { + const result = await treesApi.fixTree({ + tree_structure: store.treeStructure as unknown as Record, + tree_name: store.name, + tree_type: 'troubleshooting', + validation_errors: fixableErrors, + }) + if (result.fixes.length > 0) { + setFixProposals(result.fixes) + } else { + toast.info('AI could not generate fixes for these errors') + } + } catch { + toast.error('Failed to generate AI fixes. Please try again.') + } finally { + setIsFixing(false) + } + } + + const handleApplyFix = (fix: AIFixProposal) => { + updateNode(fix.target_node_id, fix.fixed_node as Partial) + } + + const handleApplyAllFixes = () => { + if (!fixProposals) return + for (const fix of fixProposals) { + handleApplyFix(fix) + } + setFixProposals(null) + setTimeout(() => { validate() }, 100) + } + + const handleCloseFixModal = () => { + setFixProposals(null) + validate() + } + const handleNodeSelect = useCallback((nodeId: string | null) => { if (nodeId) { setIsMetadataOpen(false) // close metadata when opening node editor @@ -685,6 +736,8 @@ export function TreeEditorPage() { )} @@ -705,6 +758,16 @@ export function TreeEditorPage() { )} + + {/* AI Fix Review Modal */} + {fixProposals && ( + + )} ) } diff --git a/frontend/src/types/ai-fix.ts b/frontend/src/types/ai-fix.ts new file mode 100644 index 00000000..a12a6fdf --- /dev/null +++ b/frontend/src/types/ai-fix.ts @@ -0,0 +1,24 @@ +export interface AIFixValidationError { + node_id: string + message: string +} + +export interface AIFixProposal { + target_node_id: string + error_message: string + description: string + original_node: Record + fixed_node: Record +} + +export interface AIFixTreeRequest { + tree_structure: Record + tree_name: string + tree_type: 'troubleshooting' | 'procedural' | 'maintenance' + validation_errors: AIFixValidationError[] +} + +export interface AIFixTreeResponse { + fixes: AIFixProposal[] + tokens_used: { input: number; output: number } +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 1bc91dc2..2c388169 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -45,3 +45,10 @@ export type { AIAssembleResponse, AIWizardPhase, } from './ai' + +export type { + AIFixTreeRequest, + AIFixTreeResponse, + AIFixProposal, + AIFixValidationError, +} from './ai-fix'