feat: flow export/import + procedural Flow Assist (#96)
* feat: add flow export/import backend (migration, endpoints, schemas)
Add .rfflow file export/import support:
- Migration 050: import_metadata JSONB column on trees
- GET /trees/{id}/export?format=json|xml endpoint
- POST /trees/import endpoint (creates draft, resolves categories/tags)
- FlowExportEnvelope, FlowImportRequest/Response schemas
- import_metadata field on TreeResponse
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add flow export/import frontend + backend tests
Frontend:
- ExportFlowModal with JSON/XML format selection + download
- ImportFlowModal with drag-drop file picker + preview step
- rfflowParser for client-side JSON/XML .rfflow parsing
- Export buttons on editor toolbar and library action menus
- Import button on library page next to Create New
- Provenance display for imported flows in editor
- flowTransfer API client + types
Backend:
- Fix regex->pattern deprecation in export endpoint
- 12 integration tests covering export, import, round-trip,
access control, tag/category creation, version validation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: remove XML export, JSON-only for .rfflow files
- Remove XML builder, format query param, and XML tests
- Simplify ExportFlowModal (no format picker)
- Simplify rfflowParser (JSON-only)
- Remove format field from schemas and types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: match Flow Assist chat input to AI Assistant styling + strengthen one-question prompt
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add procedural flow support to AI chat builder (Flow Assist)
- Add procedural-specific system prompts (schema, interview protocol, response format)
- Dispatch prompts by flow_type: procedural/maintenance use flat steps schema, troubleshooting uses decision tree schema
- Parse [STEPS_UPDATE] and [INTAKE_FORM] markers in AI responses
- Add validate_generated_procedural_steps() validator
- Handle intake form extraction in AI chat import endpoint
- Add StaticStepsPreview component for procedural flow preview
- Update store and page to render correct preview by flow type
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add flow type selection to Flow Assist entry points
- CreateFlowDropdown now shows "Build with Flow Assist" under each flow type
- Library page "Flow Assist" button respects current type filter
- Clean up unused AIFlowBuilderModal references
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update CLAUDE.md with AI chat builder and intake form learnings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: refine assistant chat prompt for concise answers and focused questions
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: switch AI provider to Claude Sonnet 4.6 + add shift+enter hint to chat inputs
- Default AI_PROVIDER changed from gemini to anthropic
- AI_MODEL and AI_MODEL_ANTHROPIC updated to claude-sonnet-4-6
- Added "Shift + Enter for a new line" hint below all chat textareas
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update CLAUDE.md with AI provider and chat input learnings
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add editor-embedded Flow Assist design document
Design for replacing the standalone /ai/chat page with context-aware
AI side panels embedded in each editor (Troubleshooting + Procedural).
Covers ghost node suggestion system, output-based thresholds,
config-driven model routing, knowledge integration, and per-flow
chat persistence.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add editor-embedded Flow Assist implementation plan
25-task plan across 9 phases covering backend foundation, frontend
infrastructure, tree/procedural editor integration, AI-assisted create,
old code removal, action-type dispatch, suggestion audit trail, and
build verification.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use actual root node ID in orphan validation check
AI-generated trees use descriptive IDs (e.g., "verify-account-exists")
instead of "root", causing the root node to be falsely flagged as orphaned.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add config-driven AI model tier routing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: extend AI chat session with tree_id and archived_at
Add tree_id FK (CASCADE) for editor-embedded sessions and archived_at
timestamp column to ai_chat_sessions table.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add AI suggestion audit trail table
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add action_type and focal_node_id to AI chat message API
- Add VALID_ACTION_TYPES literal and action_type/focal_node_id fields to
AIChatMessageRequest schema
- Add tree_id field to AIChatStartRequest schema for editor-embedded sessions
- Update send_message() signature with action_type and focal_node_id params
- Update start_chat_session() signature with tree_id param
- Pass new params through endpoints to service functions
- All new params have defaults so existing behavior is unchanged
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: route AI model selection through action-type config
Update get_ai_provider() to accept an optional model override parameter
(applied only to AnthropicProvider; Gemini always uses its own model).
Thread action_type-based model resolution through send_message() and
generate_final_tree() in the AI chat service.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add TypeScript types for editor-embedded AI
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add shared ContextMenu component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add useEditorAI hook and editorAI API client
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add EditorAIPanel component with Chat and Suggestions tabs
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: integrate AI panel, context menu, and ghost nodes in tree editor
- Add AI Assist panel toggle button to tree editor toolbar
- Wire EditorAIPanel alongside TreeEditorLayout with single-panel rule
- Thread onNodeContextMenu through TreeEditorLayout → FlowCanvas → FlowCanvasNode
- Add right-click context menu with Generate Branch, Explain Node, Delete actions
- Add ghost node detection (_suggestion flag) with dashed border + opacity styling
- Add Accept/Dismiss overlay buttons on ghost nodes for future suggestion handling
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: integrate AI panel, context menu, and ghost steps in procedural editor
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add AI prompt dialog and wire into CreateFlowDropdown
Replace navigation to /ai/chat with an inline AIPromptDialog modal
that collects a single prompt, generates a flow via the editor AI API,
imports it, and navigates to the editor with the AI panel open.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add glassmorphism to AI prompt dialog + maintenance Flow Assist button
- Use .glass-card-static on AIPromptDialog card for consistent design system
- Add "Build with Flow Assist" button to maintenance section in CreateFlowDropdown
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: remove standalone Flow Assist page and old AI chat components
Remove the old /ai/chat page, AI wizard modal, and all associated
components/stores/types now replaced by the editor-embedded AI panel.
Deleted:
- AIChatBuilderPage, ai-chat/ components, aiChatStore, aiChat API, ai-chat types
- AIFlowBuilderModal, ai-builder/ components, aiFlowBuilderStore
Cleaned up:
- Router (removed /ai/chat route)
- Sidebar (removed Flow Assist nav item)
- MyTreesPage (removed AI builder modal and button)
- TreeLibraryPage (removed Flow Assist button)
- API and type barrel exports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add delta response parsing and action-type prompt dispatch
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add AI suggestion audit trail endpoints
Create/list/resolve endpoints for tracking AI-applied changes to flows.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add APScheduler task to auto-archive stale AI chat sessions
Archives AI chat sessions with no activity for 30 days, runs daily at 3 AM.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: update project status for editor-embedded Flow Assist
- Add Editor-Embedded Flow Assist to CURRENT-STATE.md in-progress items
- Update CLAUDE.md: fix stale lessons (#41, #46), add new patterns (#47 editor AI architecture, #48 orphan validation)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: use correct model alias in AI_MODEL_TIERS standard tier
The dated model ID `claude-sonnet-4-6-20250514` was causing 502 errors.
Use the alias `claude-sonnet-4-6` which matches AI_MODEL_ANTHROPIC.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: send live flow context to AI Assist for full editor awareness
The AI panel now sends the current tree structure (troubleshooting) or
steps + intake form (procedural/maintenance) with each message. This
gives the AI full visibility into node details, questions, descriptions,
options, and intake form fields — not just the node ID.
- Backend: add flow_context param to schema, endpoint, and service
- Frontend: add getFlowContext callback to useEditorAI hook
- TreeEditorPage: passes treeStructure as flow context
- ProceduralEditorPage: passes steps + intakeForm as flow context
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: include flow name and description in AI Assist context
Both editors now send name and description alongside the flow structure,
so the AI can reference what the flow is about when responding.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: increase AI timeout to 120s and limit retries to 1
The 45s timeout was too short for generation tasks with full flow
context in the system prompt. The Anthropic SDK's default 2 retries
caused requests to hang for ~136s before failing. Now: 120s timeout
with max 1 retry = faster failure if it does timeout.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: wire AI-generated flow structures into editor stores
The useEditorAI hook was ignoring result.working_tree from AI responses,
so generated steps/trees never appeared in the editor. Now:
- useEditorAI calls onFlowUpdate when working_tree is present in response
- ProceduralEditorPage handles steps + intake form updates via replaceSteps
- TreeEditorPage handles tree structure updates via replaceTreeStructure
- Both stores have new bulk-replace methods for AI integration
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: add lessons learned for full-stack integration, Anthropic retries, model tiers
#49 Always verify frontend consumes backend response fields
#50 Anthropic SDK max_retries=1 to avoid 3× timeout
#51 AI model tier routing via settings.get_model_for_action()
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: move AI Assist panel to full-height side layout in both editors
The AI panel was nested inside the content area, only spanning the
step list / canvas section. Now it sits at the outermost flex level,
spanning the full page height alongside all content (toolbar,
collapsible sections, steps/canvas). This prevents the panel from
overlapping content and lets the editor area properly shrink.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: AI Assist panel as fixed right drawer (matching Copilot/Scratchpad)
Convert EditorAIPanel from in-flow flex child to fixed right-side drawer
overlay, same pattern as CopilotPanel and ScratchpadSidebar. The panel
is fixed at right:0 spanning full viewport height, and editor pages add
pr-[380px] padding when open so content shifts left without overlap.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: AI Assist panel sits below topbar with slide-in animation
- Panel now uses top:56px to sit below the app shell topbar instead of
covering it (matches the main-content grid cell area)
- Added slideInRight CSS animation for smooth drawer entrance
- Editor pages use dynamic paddingRight via PANEL_WIDTH constant
- ChatTab upgraded: markdown rendering, CopilotPanel-style message
bubbles, auto-focus input, Shift+Enter hint
- All borders use --glass-border for consistent glassmorphism
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: AI Assist panel as in-flow flex sibling (not fixed/overlay)
Replace fixed positioning with in-flow flex layout. The outermost div
is now a horizontal flex row: content column (flex-1 min-w-0) + panel
(w-[380px] shrink-0). When the panel opens, the content column
automatically shrinks — no padding hacks or z-index stacking needed.
This guarantees the content shifts left and stays fully visible.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: AI Copilot panel as in-flow flex sibling in session navigation pages
Changed CopilotPanel from fixed overlay to flex layout sibling so it
pushes main content instead of covering it during active sessions.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* docs: remove duplicate CLAUDE.md lessons #47-48
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #96.
This commit is contained in:
@@ -1,44 +0,0 @@
|
||||
import { apiClient } from './client'
|
||||
import type {
|
||||
AIChatStartResponse,
|
||||
AIChatMessageResponse,
|
||||
AIChatSessionResponse,
|
||||
AIChatGenerateResponse,
|
||||
AIChatImportResponse,
|
||||
} from '@/types'
|
||||
|
||||
export const aiChatApi = {
|
||||
startSession: async (flowType: 'troubleshooting' | 'procedural'): Promise<AIChatStartResponse> => {
|
||||
const { data } = await apiClient.post('/ai/chat/sessions', { flow_type: flowType })
|
||||
return data
|
||||
},
|
||||
|
||||
sendMessage: async (sessionId: string, content: string): Promise<AIChatMessageResponse> => {
|
||||
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/messages`, { content })
|
||||
return data
|
||||
},
|
||||
|
||||
getSession: async (sessionId: string): Promise<AIChatSessionResponse> => {
|
||||
const { data } = await apiClient.get(`/ai/chat/sessions/${sessionId}`)
|
||||
return data
|
||||
},
|
||||
|
||||
generateTree: async (sessionId: string): Promise<AIChatGenerateResponse> => {
|
||||
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/generate`)
|
||||
return data
|
||||
},
|
||||
|
||||
importTree: async (
|
||||
sessionId: string,
|
||||
params?: { name?: string; description?: string; category_id?: string; tags?: string[] }
|
||||
): Promise<AIChatImportResponse> => {
|
||||
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/import`, params || {})
|
||||
return data
|
||||
},
|
||||
|
||||
abandonSession: async (sessionId: string): Promise<void> => {
|
||||
await apiClient.delete(`/ai/chat/sessions/${sessionId}`)
|
||||
},
|
||||
}
|
||||
|
||||
export default aiChatApi
|
||||
44
frontend/src/api/editorAI.ts
Normal file
44
frontend/src/api/editorAI.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { apiClient } from './client'
|
||||
import type { AIActionType } from '@/types'
|
||||
|
||||
export interface SendMessageParams {
|
||||
sessionId: string
|
||||
content: string
|
||||
actionType?: AIActionType
|
||||
focalNodeId?: string | null
|
||||
flowContext?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export const editorAIApi = {
|
||||
startSession: async (flowType: 'troubleshooting' | 'procedural', treeId?: string) => {
|
||||
const { data } = await apiClient.post('/ai/chat/sessions', {
|
||||
flow_type: flowType,
|
||||
tree_id: treeId,
|
||||
})
|
||||
return data
|
||||
},
|
||||
|
||||
sendMessage: async ({ sessionId, content, actionType, focalNodeId, flowContext }: SendMessageParams) => {
|
||||
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/messages`, {
|
||||
content,
|
||||
action_type: actionType || 'open_chat',
|
||||
focal_node_id: focalNodeId,
|
||||
flow_context: flowContext || undefined,
|
||||
})
|
||||
return data
|
||||
},
|
||||
|
||||
getSession: async (sessionId: string) => {
|
||||
const { data } = await apiClient.get(`/ai/chat/sessions/${sessionId}`)
|
||||
return data
|
||||
},
|
||||
|
||||
generateFull: async (sessionId: string) => {
|
||||
const { data } = await apiClient.post(`/ai/chat/sessions/${sessionId}/generate`)
|
||||
return data
|
||||
},
|
||||
|
||||
abandonSession: async (sessionId: string) => {
|
||||
await apiClient.delete(`/ai/chat/sessions/${sessionId}`)
|
||||
},
|
||||
}
|
||||
17
frontend/src/api/flowTransfer.ts
Normal file
17
frontend/src/api/flowTransfer.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import apiClient from './client'
|
||||
import type { RFFlowFile, FlowImportResponse } from '@/types'
|
||||
|
||||
export const flowTransferApi = {
|
||||
async exportFlow(treeId: string): Promise<Blob> {
|
||||
const response = await apiClient.get(`/trees/${treeId}/export`, {
|
||||
responseType: 'blob',
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
|
||||
async importFlow(data: RFFlowFile, nameOverride?: string): Promise<FlowImportResponse> {
|
||||
const params = nameOverride ? { name_override: nameOverride } : undefined
|
||||
const response = await apiClient.post('/trees/import', data, { params })
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@@ -17,6 +17,6 @@ export { targetListsApi } from './targetLists'
|
||||
export { maintenanceSchedulesApi, batchLaunchApi } from './maintenanceSchedules'
|
||||
export { default as feedbackApi } from './feedback'
|
||||
export { default as aiBuilderApi } from './aiBuilder'
|
||||
export { default as aiChatApi } from './aiChat'
|
||||
export { copilotApi } from './copilot'
|
||||
export { assistantChatApi } from './assistantChat'
|
||||
export { flowTransferApi } from './flowTransfer'
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { useEffect, useRef } 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 (ref prevents double-fire)
|
||||
const hasTriggeredScaffold = useRef(false)
|
||||
useEffect(() => {
|
||||
// Reset guard when wizard resets to foundation (Start Over or close)
|
||||
if (phase === 'foundation') {
|
||||
hasTriggeredScaffold.current = false
|
||||
return
|
||||
}
|
||||
if (phase === 'scaffolding' && !hasTriggeredScaffold.current && !useAIFlowBuilderStore.getState().suggestedBranches.length) {
|
||||
hasTriggeredScaffold.current = true
|
||||
scaffold()
|
||||
}
|
||||
}, [phase, scaffold])
|
||||
|
||||
const handleClose = () => {
|
||||
reset()
|
||||
onClose()
|
||||
}
|
||||
|
||||
const createTree = async () => {
|
||||
if (!assembledTree) return null
|
||||
try {
|
||||
return await treesApi.create({
|
||||
name: assembledTree.suggested_name,
|
||||
description: assembledTree.suggested_description,
|
||||
tree_structure: assembledTree.tree_structure,
|
||||
tree_type: metadata.flow_type,
|
||||
status: 'draft',
|
||||
})
|
||||
} catch {
|
||||
toast.error('Failed to create flow. Please try again.')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenInEditor = async () => {
|
||||
const tree = await createTree()
|
||||
if (!tree) return
|
||||
handleClose()
|
||||
const editorPath =
|
||||
metadata.flow_type === 'procedural'
|
||||
? `/flows/${tree.id}/edit`
|
||||
: `/trees/${tree.id}/edit`
|
||||
navigate(editorPath)
|
||||
}
|
||||
|
||||
const handleStartFlow = async () => {
|
||||
const tree = await createTree()
|
||||
if (!tree) return
|
||||
handleClose()
|
||||
const navigatePath =
|
||||
metadata.flow_type === 'procedural'
|
||||
? `/flows/${tree.id}/navigate`
|
||||
: `/trees/${tree.id}/navigate`
|
||||
navigate(navigatePath)
|
||||
}
|
||||
|
||||
const handleBuildAnother = () => {
|
||||
reset()
|
||||
}
|
||||
|
||||
const getTitle = () => {
|
||||
switch (phase) {
|
||||
case 'foundation':
|
||||
return 'Flow Assist'
|
||||
case 'scaffolding':
|
||||
case 'generating':
|
||||
return 'AI Scaffold'
|
||||
case 'detailing':
|
||||
return 'Branch Detail'
|
||||
case 'reviewing':
|
||||
return 'Review & Assemble'
|
||||
case 'error':
|
||||
return 'Flow Assist'
|
||||
default:
|
||||
return 'Flow Assist'
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
onStartFlow={handleStartFlow}
|
||||
onBuildAnother={handleBuildAnother}
|
||||
/>
|
||||
)}
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import { Check, RefreshCw, SkipForward, ChevronRight, ChevronLeft, Zap, Square } from 'lucide-react'
|
||||
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
|
||||
import { GeneratingAnimation } from './GeneratingAnimation'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function BranchDetailView() {
|
||||
const {
|
||||
selectedBranches,
|
||||
currentBranchIndex,
|
||||
generateBranchDetail,
|
||||
assemble,
|
||||
isLoading,
|
||||
error,
|
||||
phase,
|
||||
setError,
|
||||
isGeneratingAll,
|
||||
generateAllBranchDetails,
|
||||
cancelGenerateAll,
|
||||
} = useAIFlowBuilderStore()
|
||||
|
||||
const viewingIndex = currentBranchIndex
|
||||
const setViewingIndex = (i: number) => useAIFlowBuilderStore.setState({ currentBranchIndex: i })
|
||||
const currentBranch = selectedBranches[viewingIndex]
|
||||
|
||||
const allBranchesHaveDetail = selectedBranches.every((b) => b.steps)
|
||||
const branchesWithDetail = selectedBranches.filter((b) => b.steps).length
|
||||
|
||||
const handleGenerate = async (branchName: string) => {
|
||||
setError(null)
|
||||
await generateBranchDetail(branchName)
|
||||
}
|
||||
|
||||
const handleAssemble = async () => {
|
||||
await assemble()
|
||||
}
|
||||
|
||||
if (phase === 'generating' && isLoading) {
|
||||
return (
|
||||
<GeneratingAnimation
|
||||
branchContext={
|
||||
isGeneratingAll
|
||||
? {
|
||||
current: selectedBranches.filter((b) => b.steps).length + 1,
|
||||
total: selectedBranches.length,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
{/* Content area */}
|
||||
<div className="space-y-4">
|
||||
{/* Branch tabs */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-1">
|
||||
{selectedBranches.map((branch, i) => (
|
||||
<button
|
||||
key={branch.name}
|
||||
type="button"
|
||||
onClick={() => setViewingIndex(i)}
|
||||
className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 rounded-full border px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
viewingIndex === i
|
||||
? 'border-primary/30 bg-primary/10 text-foreground'
|
||||
: 'border-border text-muted-foreground hover:bg-accent',
|
||||
branch.steps && 'pr-2'
|
||||
)}
|
||||
>
|
||||
{branch.name}
|
||||
{branch.steps && (
|
||||
<Check className="h-3 w-3 text-green-400" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Current branch detail */}
|
||||
{currentBranch && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground">{currentBranch.name}</h3>
|
||||
<p className="text-xs text-muted-foreground">{currentBranch.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentBranch.steps ? (
|
||||
<div className="space-y-3">
|
||||
{/* Mini tree preview */}
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg border border-border bg-accent/30 p-3">
|
||||
<NodePreview node={currentBranch.steps} depth={0} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleGenerate(currentBranch.name)}
|
||||
disabled={isLoading || isGeneratingAll}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
Regenerate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4 rounded-lg border border-dashed border-border bg-accent/20 py-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Generate AI detail for this branch
|
||||
</p>
|
||||
|
||||
{/* Generate All — primary action, shown when multiple branches remain */}
|
||||
{selectedBranches.filter((b) => !b.steps).length > 1 && (
|
||||
isGeneratingAll ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={cancelGenerateAll}
|
||||
className="flex items-center gap-2 rounded-lg border border-red-400/30 bg-red-400/10 px-5 py-2.5 text-sm font-medium text-red-400 hover:bg-red-400/20"
|
||||
>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop Generating
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateAllBranchDetails}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
Generate All {selectedBranches.filter((b) => !b.steps).length} Branches
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Divider + secondary actions */}
|
||||
{selectedBranches.filter((b) => !b.steps).length > 1 && (
|
||||
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<div className="h-px w-8 bg-border" />
|
||||
or
|
||||
<div className="h-px w-8 bg-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleGenerate(currentBranch.name)}
|
||||
disabled={isLoading || isGeneratingAll}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
Generate this branch
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (viewingIndex < selectedBranches.length - 1) {
|
||||
setViewingIndex(viewingIndex + 1)
|
||||
}
|
||||
}}
|
||||
disabled={isGeneratingAll}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent disabled:opacity-50"
|
||||
>
|
||||
<SkipForward className="h-3 w-3" />
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-3 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation — sticky so it's always visible */}
|
||||
<div className="sticky bottom-0 flex items-center justify-between border-t border-border bg-card pt-3 pb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewingIndex(Math.max(0, viewingIndex - 1))}
|
||||
disabled={viewingIndex === 0}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="h-3.5 w-3.5" />
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setViewingIndex(Math.min(selectedBranches.length - 1, viewingIndex + 1))
|
||||
}
|
||||
disabled={viewingIndex === selectedBranches.length - 1}
|
||||
className="flex items-center gap-1 rounded-lg border border-border px-3 py-1.5 text-xs text-muted-foreground hover:bg-accent disabled:opacity-30"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{branchesWithDetail}/{selectedBranches.length} detailed
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAssemble}
|
||||
disabled={!allBranchesHaveDetail || isLoading}
|
||||
className={cn(
|
||||
'rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
allBranchesHaveDetail && !isLoading
|
||||
? 'hover:opacity-90'
|
||||
: 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
Assemble Tree
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Recursive mini-preview of a node tree */
|
||||
function NodePreview({ node, depth }: { node: Record<string, unknown>; depth: number }) {
|
||||
const type = node.type as string
|
||||
const label =
|
||||
type === 'decision'
|
||||
? (node.question as string)
|
||||
: (node.title as string) || 'Untitled'
|
||||
const children = (node.children as Record<string, unknown>[]) || []
|
||||
|
||||
const typeColors: Record<string, string> = {
|
||||
decision: 'bg-blue-400',
|
||||
action: 'bg-amber-400',
|
||||
solution: 'bg-green-400',
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginLeft: depth * 16 }}>
|
||||
<div className="flex items-center gap-2 py-0.5">
|
||||
<div className={cn('h-2 w-2 rounded-full', typeColors[type] || 'bg-muted-foreground')} />
|
||||
<span className="text-xs text-foreground truncate">{label}</span>
|
||||
<span className="text-[10px] font-label text-muted-foreground">{type}</span>
|
||||
</div>
|
||||
{children.map((child) => (
|
||||
<NodePreview key={child.id as string ?? crypto.randomUUID()} node={child} depth={depth + 1} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { GripVertical, Plus, X, Pencil, Check, RefreshCw } from 'lucide-react'
|
||||
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AIBranch } from '@/types'
|
||||
|
||||
export function BranchSelector() {
|
||||
const {
|
||||
suggestedBranches,
|
||||
selectedBranches,
|
||||
selectBranches,
|
||||
setPhase,
|
||||
scaffold,
|
||||
isLoading,
|
||||
error,
|
||||
} = useAIFlowBuilderStore()
|
||||
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editDesc, setEditDesc] = useState('')
|
||||
const [showAddForm, setShowAddForm] = useState(false)
|
||||
const [newName, setNewName] = useState('')
|
||||
const [newDesc, setNewDesc] = useState('')
|
||||
|
||||
const toggleBranch = (branch: AIBranch) => {
|
||||
const isSelected = selectedBranches.some((b) => b.name === branch.name)
|
||||
if (isSelected) {
|
||||
selectBranches(selectedBranches.filter((b) => b.name !== branch.name))
|
||||
} else {
|
||||
selectBranches([...selectedBranches, branch])
|
||||
}
|
||||
}
|
||||
|
||||
const startEditing = (index: number) => {
|
||||
const branch = selectedBranches[index]
|
||||
setEditingIndex(index)
|
||||
setEditName(branch.name)
|
||||
setEditDesc(branch.description)
|
||||
}
|
||||
|
||||
const saveEdit = () => {
|
||||
if (editingIndex === null || !editName.trim()) return
|
||||
const updated = [...selectedBranches]
|
||||
updated[editingIndex] = {
|
||||
...updated[editingIndex],
|
||||
name: editName.trim(),
|
||||
description: editDesc.trim(),
|
||||
}
|
||||
selectBranches(updated)
|
||||
setEditingIndex(null)
|
||||
}
|
||||
|
||||
const addCustomBranch = () => {
|
||||
if (!newName.trim()) return
|
||||
const branch: AIBranch = {
|
||||
name: newName.trim(),
|
||||
description: newDesc.trim(),
|
||||
isCustom: true,
|
||||
}
|
||||
selectBranches([...selectedBranches, branch])
|
||||
setNewName('')
|
||||
setNewDesc('')
|
||||
setShowAddForm(false)
|
||||
}
|
||||
|
||||
const moveBranch = (fromIndex: number, direction: 'up' | 'down') => {
|
||||
const toIndex = direction === 'up' ? fromIndex - 1 : fromIndex + 1
|
||||
if (toIndex < 0 || toIndex >= selectedBranches.length) return
|
||||
const updated = [...selectedBranches]
|
||||
;[updated[fromIndex], updated[toIndex]] = [updated[toIndex], updated[fromIndex]]
|
||||
selectBranches(updated)
|
||||
}
|
||||
|
||||
const canProceed = selectedBranches.length >= 2
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
AI suggested {suggestedBranches.length} branches. Select, reorder, rename, or add your own.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scaffold()}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
title="Generate new suggestions"
|
||||
>
|
||||
<RefreshCw className={cn('h-3 w-3', isLoading && 'animate-spin')} />
|
||||
Regenerate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Branch list */}
|
||||
<div className="space-y-2">
|
||||
{suggestedBranches.map((branch) => {
|
||||
const isSelected = selectedBranches.some((b) => b.name === branch.name)
|
||||
const selectedIndex = selectedBranches.findIndex((b) => b.name === branch.name)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={branch.name}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-lg border p-3 transition-colors cursor-pointer',
|
||||
isSelected
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border bg-card hover:bg-accent/50'
|
||||
)}
|
||||
onClick={() => toggleBranch(branch)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border',
|
||||
isSelected
|
||||
? 'border-primary bg-primary text-white'
|
||||
: 'border-border'
|
||||
)}
|
||||
>
|
||||
{isSelected && <Check className="h-3 w-3" />}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
{editingIndex !== null && selectedIndex === editingIndex ? (
|
||||
<div className="space-y-2" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
type="text"
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
className="w-full rounded border border-border bg-card px-2 py-1 text-sm text-foreground focus:border-primary focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={editDesc}
|
||||
onChange={(e) => setEditDesc(e.target.value)}
|
||||
className="w-full rounded border border-border bg-card px-2 py-1 text-xs text-muted-foreground focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveEdit}
|
||||
className="rounded bg-primary px-2 py-0.5 text-xs text-white"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditingIndex(null)}
|
||||
className="rounded border border-border px-2 py-0.5 text-xs text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-sm font-medium text-foreground">{branch.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{branch.description}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && editingIndex !== selectedIndex && (
|
||||
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => moveBranch(selectedIndex, 'up')}
|
||||
disabled={selectedIndex === 0}
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground disabled:opacity-30"
|
||||
title="Move up"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => startEditing(selectedIndex)}
|
||||
className="rounded p-1 text-muted-foreground hover:text-foreground"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Custom branches (not in suggested) */}
|
||||
{selectedBranches
|
||||
.filter((b) => b.isCustom)
|
||||
.map((branch, i) => {
|
||||
return (
|
||||
<div
|
||||
key={`custom-${i}`}
|
||||
className="flex items-start gap-3 rounded-lg border border-primary/30 bg-primary/5 p-3"
|
||||
>
|
||||
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded border border-primary bg-primary text-white">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">{branch.name}</div>
|
||||
<div className="text-xs text-muted-foreground">{branch.description}</div>
|
||||
<span className="mt-1 inline-block rounded bg-primary/10 px-1.5 py-0.5 text-[10px] font-label text-primary">
|
||||
Custom
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
selectBranches(selectedBranches.filter((b) => b.name !== branch.name))
|
||||
}
|
||||
className="rounded p-1 text-muted-foreground hover:text-red-400"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Add custom branch */}
|
||||
{showAddForm ? (
|
||||
<div className="space-y-2 rounded-lg border border-dashed border-border p-3">
|
||||
<input
|
||||
type="text"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder="Branch name"
|
||||
className="w-full rounded border border-border bg-card px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newDesc}
|
||||
onChange={(e) => setNewDesc(e.target.value)}
|
||||
placeholder="Brief description"
|
||||
className="w-full rounded border border-border bg-card px-2 py-1.5 text-xs text-muted-foreground placeholder:text-muted-foreground/60 focus:border-primary focus:outline-none"
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addCustomBranch}
|
||||
disabled={!newName.trim()}
|
||||
className="rounded bg-primary px-2.5 py-1 text-xs text-white disabled:opacity-50"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddForm(false)}
|
||||
className="rounded border border-border px-2.5 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
className="flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add custom branch
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-3 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{selectedBranches.length} branch{selectedBranches.length !== 1 ? 'es' : ''} selected (min 2)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPhase('detailing')}
|
||||
disabled={!canProceed}
|
||||
className={cn(
|
||||
'rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
canProceed ? 'hover:opacity-90' : 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
Continue to Detail
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
|
||||
import { QuotaDisplay } from './QuotaDisplay'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function FoundationForm() {
|
||||
const { metadata, setMetadata, quota, start, isLoading, error } = useAIFlowBuilderStore()
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
|
||||
const canSubmit =
|
||||
metadata.name.trim().length > 0 &&
|
||||
metadata.description.trim().length > 0 &&
|
||||
!isLoading &&
|
||||
(quota?.allowed !== false)
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
await start()
|
||||
}
|
||||
|
||||
const addTag = () => {
|
||||
const tag = tagInput.trim()
|
||||
if (tag && !metadata.environment_tags.includes(tag)) {
|
||||
setMetadata({ environment_tags: [...metadata.environment_tags, tag] })
|
||||
}
|
||||
setTagInput('')
|
||||
}
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
setMetadata({ environment_tags: metadata.environment_tags.filter((t) => t !== tag) })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{quota && <QuotaDisplay quota={quota} />}
|
||||
|
||||
{/* Flow Type */}
|
||||
<div>
|
||||
<label className="mb-2 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
|
||||
Flow Type
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(['troubleshooting', 'procedural'] as const).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => setMetadata({ flow_type: type })}
|
||||
className={cn(
|
||||
'flex-1 rounded-lg border px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
metadata.flow_type === type
|
||||
? 'border-primary/30 bg-primary/10 text-foreground'
|
||||
: 'border-border bg-card text-muted-foreground hover:bg-accent'
|
||||
)}
|
||||
>
|
||||
{type === 'troubleshooting' ? 'Troubleshooting' : 'Procedural'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="mb-2 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
|
||||
Flow Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={metadata.name}
|
||||
onChange={(e) => setMetadata({ name: e.target.value })}
|
||||
placeholder="e.g. DNS Resolution Failures"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
maxLength={255}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="mb-2 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
|
||||
Description
|
||||
</label>
|
||||
<textarea
|
||||
value={metadata.description}
|
||||
onChange={(e) => setMetadata({ description: e.target.value })}
|
||||
placeholder="Describe what this flow covers. The more detail you provide, the better the AI suggestions will be."
|
||||
rows={4}
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20 resize-none"
|
||||
maxLength={2000}
|
||||
/>
|
||||
<p className="mt-1 text-right text-[10px] text-muted-foreground">
|
||||
{metadata.description.length}/2000
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Environment Tags */}
|
||||
<div>
|
||||
<label className="mb-2 block font-label text-[0.6875rem] uppercase tracking-wide text-muted-foreground">
|
||||
Environment Tags <span className="normal-case tracking-normal text-muted-foreground/60">(optional)</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
addTag()
|
||||
}
|
||||
}}
|
||||
placeholder="e.g. Windows Server, Active Directory"
|
||||
className="flex-1 rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addTag}
|
||||
className="rounded-lg border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
{metadata.environment_tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||
{metadata.environment_tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-card border border-border px-2.5 py-0.5 font-label text-xs text-muted-foreground"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="ml-0.5 text-muted-foreground/60 hover:text-foreground"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-400/20 bg-red-400/5 px-3 py-2 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit}
|
||||
className={cn(
|
||||
'w-full rounded-lg bg-gradient-brand py-2.5 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
canSubmit ? 'hover:opacity-90' : 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
{isLoading ? 'Creating...' : 'Continue to AI Scaffold'}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
interface GeneratingAnimationProps {
|
||||
branchContext?: { current: number; total: number }
|
||||
}
|
||||
|
||||
export function GeneratingAnimation({ branchContext }: GeneratingAnimationProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-10">
|
||||
{/* Spinner */}
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-border border-t-primary" />
|
||||
|
||||
{/* Branch context (Generate All mode) */}
|
||||
{branchContext ? (
|
||||
<>
|
||||
<p className="text-xs font-label uppercase tracking-wide text-muted-foreground">
|
||||
Branch {branchContext.current} of {branchContext.total}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">Generating branch detail...</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Generating...</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AIQuotaStatus } from '@/types'
|
||||
|
||||
interface QuotaDisplayProps {
|
||||
quota: AIQuotaStatus
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export function QuotaDisplay({ quota, compact = false }: QuotaDisplayProps) {
|
||||
if (!quota.ai_enabled) return null
|
||||
|
||||
const monthlyRemaining =
|
||||
quota.monthly_limit !== null
|
||||
? Math.max(0, quota.monthly_limit - quota.monthly_used)
|
||||
: null
|
||||
|
||||
const getColor = () => {
|
||||
if (!quota.allowed) return 'text-red-400'
|
||||
if (monthlyRemaining !== null && monthlyRemaining <= 1) return 'text-amber-400'
|
||||
return 'text-green-400'
|
||||
}
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<span className={cn('text-xs font-label', getColor())}>
|
||||
{monthlyRemaining !== null
|
||||
? `${monthlyRemaining}/${quota.monthly_limit} builds`
|
||||
: 'Unlimited'}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-border bg-accent/50 px-3 py-1.5">
|
||||
<div className={cn('h-2 w-2 rounded-full', getColor().replace('text-', 'bg-'))} />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{monthlyRemaining !== null ? (
|
||||
<>
|
||||
<span className={cn('font-medium', getColor())}>{monthlyRemaining}</span>
|
||||
{' '}of {quota.monthly_limit} AI builds remaining
|
||||
</>
|
||||
) : (
|
||||
'Unlimited AI builds'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { GitBranch, Layers, CheckCircle, ArrowRight, RotateCcw, Play } from 'lucide-react'
|
||||
import { useAIFlowBuilderStore } from '@/store/aiFlowBuilderStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TreePreviewCardProps {
|
||||
onOpenInEditor: () => void
|
||||
onStartFlow: () => void
|
||||
onBuildAnother: () => void
|
||||
}
|
||||
|
||||
export function TreePreviewCard({ onOpenInEditor, onStartFlow, onBuildAnother }: TreePreviewCardProps) {
|
||||
const { assembledTree, isLoading } = useAIFlowBuilderStore()
|
||||
|
||||
if (!assembledTree) return null
|
||||
|
||||
const { summary } = assembledTree
|
||||
|
||||
const stats = [
|
||||
{ label: 'Nodes', value: summary.node_count, icon: Layers },
|
||||
{ label: 'Decisions', value: summary.decision_count, icon: GitBranch },
|
||||
{ label: 'Solutions', value: summary.solution_count, icon: CheckCircle },
|
||||
{ label: 'Depth', value: summary.depth, icon: Layers },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-green-400/10">
|
||||
<CheckCircle className="h-6 w-6 text-green-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Tree Assembled
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
"{assembledTree.suggested_name}" is ready to review in the editor.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats grid */}
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{stats.map(({ label, value, icon: Icon }) => (
|
||||
<div
|
||||
key={label}
|
||||
className="flex flex-col items-center rounded-lg border border-border bg-accent/30 p-2.5"
|
||||
>
|
||||
<Icon className="mb-1 h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-lg font-semibold text-gradient-brand">{value}</span>
|
||||
<span className="text-[10px] font-label uppercase tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{assembledTree.suggested_description && (
|
||||
<div className="rounded-lg border border-border bg-accent/20 p-3">
|
||||
<p className="text-xs text-muted-foreground">{assembledTree.suggested_description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartFlow}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-center gap-2 rounded-lg bg-gradient-brand py-2.5 text-sm font-medium text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
Start Flow
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenInEditor}
|
||||
disabled={isLoading}
|
||||
className="flex flex-1 items-center justify-center gap-2 rounded-lg border border-border py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
Open in Editor
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBuildAnother}
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
Build Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AIWizardPhase } from '@/types'
|
||||
|
||||
const STEPS = [
|
||||
{ key: 'foundation', label: 'Foundation' },
|
||||
{ key: 'scaffolding', label: 'Scaffold' },
|
||||
{ key: 'detailing', label: 'Detail' },
|
||||
{ key: 'reviewing', label: 'Review' },
|
||||
] as const
|
||||
|
||||
const PHASE_ORDER: Record<string, number> = {
|
||||
foundation: 0,
|
||||
scaffolding: 1,
|
||||
generating: 1,
|
||||
detailing: 2,
|
||||
reviewing: 3,
|
||||
completed: 4,
|
||||
error: -1,
|
||||
}
|
||||
|
||||
interface WizardStepIndicatorProps {
|
||||
phase: AIWizardPhase
|
||||
}
|
||||
|
||||
export function WizardStepIndicator({ phase }: WizardStepIndicatorProps) {
|
||||
const currentIndex = PHASE_ORDER[phase] ?? 0
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2">
|
||||
{STEPS.map((step, i) => {
|
||||
const isCompleted = currentIndex > i
|
||||
const isCurrent = currentIndex === i
|
||||
|
||||
return (
|
||||
<div key={step.key} className="flex items-center gap-1">
|
||||
{i > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'h-px w-4 sm:w-6',
|
||||
isCompleted ? 'bg-primary' : 'bg-border'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-medium',
|
||||
isCompleted && 'bg-primary text-white',
|
||||
isCurrent && 'bg-primary/20 text-primary ring-1 ring-primary/40',
|
||||
!isCompleted && !isCurrent && 'bg-accent text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{isCompleted ? <Check className="h-3 w-3" /> : i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'hidden text-xs sm:inline',
|
||||
isCurrent ? 'font-medium text-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { Send } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (content: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function ChatInput({ onSend, disabled, placeholder = 'Type a message...' }: ChatInputProps) {
|
||||
const [value, setValue] = useState('')
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
const handleSend = useCallback(() => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed || disabled) return
|
||||
onSend(trimmed)
|
||||
setValue('')
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
}
|
||||
}, [value, disabled, onSend])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
|
||||
const handleInput = () => {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto'
|
||||
textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 160) + 'px'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-end gap-2 border-t border-border bg-card px-4 py-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onInput={handleInput}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={1}
|
||||
className={cn(
|
||||
'flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2',
|
||||
'text-sm text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:ring-1 focus:ring-primary/20 focus:outline-none',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'max-h-40'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={disabled || !value.trim()}
|
||||
className={cn(
|
||||
'flex h-10 w-10 shrink-0 items-center justify-center rounded-lg',
|
||||
'bg-gradient-brand text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 transition-opacity',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Bot, User } from 'lucide-react'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ChatMessage as ChatMessageType } from '@/types'
|
||||
|
||||
interface ChatMessageProps {
|
||||
message: ChatMessageType
|
||||
}
|
||||
|
||||
export function ChatMessage({ message }: ChatMessageProps) {
|
||||
const isAI = message.role === 'assistant'
|
||||
|
||||
return (
|
||||
<div className={cn('flex gap-3', isAI ? 'items-start' : 'items-start justify-end')}>
|
||||
{isAI && (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Bot className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'max-w-[85%] rounded-xl px-4 py-3',
|
||||
isAI
|
||||
? 'bg-card border border-border'
|
||||
: 'bg-primary/10 border border-primary/20'
|
||||
)}
|
||||
>
|
||||
{isAI ? (
|
||||
<MarkdownContent content={message.content} className="text-sm" />
|
||||
) : (
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap">{message.content}</p>
|
||||
)}
|
||||
</div>
|
||||
{!isAI && (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-accent">
|
||||
<User className="h-4 w-4 text-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { ChatMessage } from './ChatMessage'
|
||||
import { ChatInput } from './ChatInput'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import type { ChatMessage as ChatMessageType } from '@/types'
|
||||
|
||||
interface ChatPanelProps {
|
||||
messages: ChatMessageType[]
|
||||
isResponding: boolean
|
||||
onSendMessage: (content: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function ChatPanel({ messages, isResponding, onSendMessage, disabled }: ChatPanelProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [messages, isResponding])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Messages */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
|
||||
{messages.map((msg, i) => (
|
||||
<ChatMessage key={i} message={msg} />
|
||||
))}
|
||||
{isResponding && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner size="sm" />
|
||||
<span>Thinking...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<ChatInput
|
||||
onSend={onSendMessage}
|
||||
disabled={disabled || isResponding}
|
||||
placeholder={isResponding ? 'Waiting for response...' : 'Type a message...'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Sparkles, Save, RotateCcw, Loader2 } from 'lucide-react'
|
||||
import { PhaseIndicator } from './PhaseIndicator'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { InterviewPhase } from '@/types'
|
||||
|
||||
interface ChatToolbarProps {
|
||||
currentPhase: InterviewPhase
|
||||
status: 'idle' | 'active' | 'completed' | 'abandoned'
|
||||
isGenerating: boolean
|
||||
hasGeneratedTree: boolean
|
||||
isSaving: boolean
|
||||
onGenerate: () => void
|
||||
onSave: () => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
export function ChatToolbar({
|
||||
currentPhase,
|
||||
status,
|
||||
isGenerating,
|
||||
hasGeneratedTree,
|
||||
isSaving,
|
||||
onGenerate,
|
||||
onSave,
|
||||
onReset,
|
||||
}: ChatToolbarProps) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-border bg-card px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
Flow Assist
|
||||
</div>
|
||||
<PhaseIndicator currentPhase={currentPhase} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{status === 'active' && !hasGeneratedTree && (
|
||||
<button
|
||||
onClick={onGenerate}
|
||||
disabled={isGenerating}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium',
|
||||
'bg-gradient-brand text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 transition-opacity',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
Generate Flow
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasGeneratedTree && (
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium',
|
||||
'bg-gradient-brand text-white shadow-lg shadow-primary/20',
|
||||
'hover:opacity-90 transition-opacity',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
Save to Flow Library
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<RotateCcw className="h-3.5 w-3.5" />
|
||||
Start Over
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { TreeDeciduous } from 'lucide-react'
|
||||
|
||||
export function EmptyPreview() {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center p-8 text-center">
|
||||
<TreeDeciduous className="h-12 w-12 text-muted-foreground/30 mb-4" />
|
||||
<h3 className="text-sm font-medium text-muted-foreground mb-1">Flow Preview</h3>
|
||||
<p className="text-xs text-muted-foreground/70 max-w-48">
|
||||
Your flow will appear here as you describe it to the AI
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { InterviewPhase } from '@/types'
|
||||
|
||||
const PHASES: { key: InterviewPhase; label: string }[] = [
|
||||
{ key: 'scoping', label: 'Scoping' },
|
||||
{ key: 'discovery', label: 'Discovery' },
|
||||
{ key: 'enrichment', label: 'Enrichment' },
|
||||
{ key: 'review', label: 'Review' },
|
||||
{ key: 'generation', label: 'Generate' },
|
||||
]
|
||||
|
||||
interface PhaseIndicatorProps {
|
||||
currentPhase: InterviewPhase
|
||||
}
|
||||
|
||||
export function PhaseIndicator({ currentPhase }: PhaseIndicatorProps) {
|
||||
const currentIndex = PHASES.findIndex((p) => p.key === currentPhase)
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{PHASES.map((phase, i) => {
|
||||
const isActive = phase.key === currentPhase
|
||||
const isCompleted = i < currentIndex
|
||||
|
||||
return (
|
||||
<div key={phase.key} className="flex items-center">
|
||||
{i > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
'mx-1 h-px w-4',
|
||||
isCompleted ? 'bg-primary' : 'bg-border'
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
'font-label text-[0.6875rem] uppercase tracking-wide px-2 py-0.5 rounded',
|
||||
isActive && 'text-primary bg-primary/10 font-medium',
|
||||
isCompleted && 'text-primary',
|
||||
!isActive && !isCompleted && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{phase.label}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { useState, useMemo, useCallback } from 'react'
|
||||
import { TreePreviewNode } from '@/components/tree-preview/TreePreviewNode'
|
||||
import type { SharedLinksMap } from '@/components/tree-preview/TreePreviewPanel'
|
||||
import type { TreeStructure } from '@/types'
|
||||
|
||||
interface StaticTreePreviewProps {
|
||||
tree: TreeStructure
|
||||
name?: string
|
||||
}
|
||||
|
||||
function findNodeInTree(nodeId: string, tree: TreeStructure): TreeStructure | null {
|
||||
if (tree.id === nodeId) return tree
|
||||
if (tree.children) {
|
||||
for (const child of tree.children) {
|
||||
const found = findNodeInTree(nodeId, child)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function buildSharedLinksMap(node: TreeStructure, map: SharedLinksMap = new Map()): SharedLinksMap {
|
||||
const nodeLabel = node.type === 'decision' ? node.question : node.title
|
||||
if (node.type === 'decision' && node.options) {
|
||||
for (const opt of node.options) {
|
||||
if (opt.next_node_id) {
|
||||
const existing = map.get(opt.next_node_id) || []
|
||||
existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
|
||||
map.set(opt.next_node_id, existing)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (node.type === 'action' && node.next_node_id) {
|
||||
const existing = map.get(node.next_node_id) || []
|
||||
existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
|
||||
map.set(node.next_node_id, existing)
|
||||
}
|
||||
if (node.children) {
|
||||
for (const child of node.children) {
|
||||
buildSharedLinksMap(child, map)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export function StaticTreePreview({ tree, name }: StaticTreePreviewProps) {
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
|
||||
const findNode = useCallback(
|
||||
(nodeId: string) => findNodeInTree(nodeId, tree),
|
||||
[tree]
|
||||
)
|
||||
|
||||
const sharedLinksMap = useMemo(() => buildSharedLinksMap(tree), [tree])
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b border-border px-4 py-2">
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Preview: {name || 'Untitled Flow'}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Click a node to select
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<div className="inline-block min-w-full">
|
||||
<TreePreviewNode
|
||||
node={tree}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onSelect={setSelectedNodeId}
|
||||
depth={0}
|
||||
findNode={findNode}
|
||||
sharedLinksMap={sharedLinksMap}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
104
frontend/src/components/common/ContextMenu.tsx
Normal file
104
frontend/src/components/common/ContextMenu.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface ContextMenuPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface ContextMenuItem {
|
||||
id: string
|
||||
label: string
|
||||
icon?: React.ReactNode
|
||||
onClick: () => void
|
||||
variant?: 'default' | 'danger'
|
||||
separator?: boolean
|
||||
}
|
||||
|
||||
interface ContextMenuProps {
|
||||
position: ContextMenuPosition
|
||||
items: ContextMenuItem[]
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ContextMenu({ position, items, onClose }: ContextMenuProps) {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleClickOutside = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose()
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
},
|
||||
[onClose]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [handleClickOutside, handleKeyDown])
|
||||
|
||||
// Adjust position to stay within viewport
|
||||
useEffect(() => {
|
||||
if (!menuRef.current) return
|
||||
const rect = menuRef.current.getBoundingClientRect()
|
||||
const el = menuRef.current
|
||||
if (position.x + rect.width > window.innerWidth) {
|
||||
el.style.left = `${position.x - rect.width}px`
|
||||
}
|
||||
if (position.y + rect.height > window.innerHeight) {
|
||||
el.style.top = `${position.y - rect.height}px`
|
||||
}
|
||||
}, [position])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
zIndex: 100,
|
||||
left: position.x,
|
||||
top: position.y,
|
||||
}}
|
||||
className="min-w-[200px] rounded-xl border border-border bg-card p-1 shadow-lg backdrop-blur-md"
|
||||
>
|
||||
{items.map((item) => (
|
||||
<div key={item.id}>
|
||||
{item.separator && (
|
||||
<div className="my-1 border-t border-border" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
item.onClick()
|
||||
onClose()
|
||||
}}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||
item.variant === 'danger'
|
||||
? 'text-rose-400 hover:bg-rose-500/10'
|
||||
: 'text-foreground hover:bg-[rgba(255,255,255,0.06)]'
|
||||
)}
|
||||
>
|
||||
{item.icon && (
|
||||
<span className="flex h-4 w-4 items-center justify-center text-muted-foreground">
|
||||
{item.icon}
|
||||
</span>
|
||||
)}
|
||||
{item.label}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Plus, ChevronDown, Sparkles, FolderTree, ListOrdered, Wrench } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { editorAIApi } from '@/api/editorAI'
|
||||
import { apiClient } from '@/api/client'
|
||||
import { AIPromptDialog } from '@/components/editor-ai/AIPromptDialog'
|
||||
|
||||
type AIFlowType = 'troubleshooting' | 'procedural' | 'maintenance'
|
||||
|
||||
interface CreateFlowDropdownProps {
|
||||
aiEnabled: boolean
|
||||
onOpenAIBuilder: () => void
|
||||
className?: string
|
||||
/** Button label — defaults to "Create Flow" */
|
||||
label?: string
|
||||
@@ -13,11 +17,49 @@ interface CreateFlowDropdownProps {
|
||||
|
||||
export function CreateFlowDropdown({
|
||||
aiEnabled,
|
||||
onOpenAIBuilder,
|
||||
className,
|
||||
label = 'Create Flow',
|
||||
}: CreateFlowDropdownProps) {
|
||||
const [showMenu, setShowMenu] = useState(false)
|
||||
const [aiPromptOpen, setAiPromptOpen] = useState(false)
|
||||
const [aiPromptFlowType, setAiPromptFlowType] = useState<AIFlowType>('troubleshooting')
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleAIGenerate = async (prompt: string) => {
|
||||
// Start an AI session
|
||||
const session = await editorAIApi.startSession(
|
||||
aiPromptFlowType === 'maintenance' ? 'procedural' : aiPromptFlowType
|
||||
)
|
||||
const sessionId = session.id
|
||||
|
||||
// Send the user's prompt
|
||||
await editorAIApi.sendMessage({
|
||||
sessionId,
|
||||
content: prompt,
|
||||
actionType: 'generate_full',
|
||||
})
|
||||
|
||||
// Generate the full flow
|
||||
await editorAIApi.generateFull(sessionId)
|
||||
|
||||
// Import to create the tree
|
||||
const { data: importResult } = await apiClient.post(
|
||||
`/ai/chat/sessions/${sessionId}/import`,
|
||||
{}
|
||||
)
|
||||
const treeId = importResult.tree_id
|
||||
|
||||
// Navigate to the editor
|
||||
if (aiPromptFlowType === 'troubleshooting') {
|
||||
navigate(`/trees/${treeId}/edit`, {
|
||||
state: { aiPanelOpen: true, sessionId },
|
||||
})
|
||||
} else {
|
||||
navigate(`/flows/${treeId}/edit`, {
|
||||
state: { aiPanelOpen: true, sessionId },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
@@ -32,62 +74,107 @@ export function CreateFlowDropdown({
|
||||
{showMenu && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-10" onClick={() => setShowMenu(false)} />
|
||||
<div className="absolute right-0 z-20 mt-1 w-56 rounded-lg border border-border bg-card p-1 shadow-xl backdrop-blur-sm">
|
||||
<div className="absolute right-0 z-20 mt-1 w-64 rounded-lg border border-border bg-card p-1 shadow-xl backdrop-blur-sm">
|
||||
{/* Troubleshooting */}
|
||||
<Link
|
||||
to="/trees/new"
|
||||
onClick={() => setShowMenu(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<FolderTree className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Troubleshooting Tree</div>
|
||||
<div className="text-xs text-muted-foreground">Branching decision flow</div>
|
||||
</div>
|
||||
</Link>
|
||||
{aiEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowMenu(false)
|
||||
setAiPromptFlowType('troubleshooting')
|
||||
setAiPromptOpen(true)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5 text-primary ml-0.5" />
|
||||
<div className="text-left">
|
||||
<div className="text-xs text-primary font-medium">Build with Flow Assist</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="my-1 border-t border-border" />
|
||||
|
||||
{/* Procedural */}
|
||||
<Link
|
||||
to="/flows/new"
|
||||
onClick={() => setShowMenu(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4 text-muted-foreground" />
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Procedural Flow</div>
|
||||
<div className="text-xs text-muted-foreground">Step-by-step procedure</div>
|
||||
</div>
|
||||
</Link>
|
||||
{aiEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowMenu(false)
|
||||
setAiPromptFlowType('procedural')
|
||||
setAiPromptOpen(true)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5 text-primary ml-0.5" />
|
||||
<div className="text-left">
|
||||
<div className="text-xs text-primary font-medium">Build with Flow Assist</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="my-1 border-t border-border" />
|
||||
|
||||
{/* Maintenance */}
|
||||
<Link
|
||||
to="/flows/new?type=maintenance"
|
||||
onClick={() => setShowMenu(false)}
|
||||
className="flex items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<Wrench className="h-4 w-4 text-amber-400" />
|
||||
<div>
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">Maintenance Flow</div>
|
||||
<div className="text-xs text-muted-foreground">Scheduled multi-target tasks</div>
|
||||
</div>
|
||||
</Link>
|
||||
{aiEnabled && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowMenu(false)
|
||||
onOpenAIBuilder()
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Flow Assist</div>
|
||||
<div className="text-xs text-muted-foreground">AI-powered flow builder</div>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowMenu(false)
|
||||
setAiPromptFlowType('maintenance')
|
||||
setAiPromptOpen(true)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5 text-primary ml-0.5" />
|
||||
<div className="text-left">
|
||||
<div className="text-xs text-primary font-medium">Build with Flow Assist</div>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<AIPromptDialog
|
||||
isOpen={aiPromptOpen}
|
||||
onClose={() => setAiPromptOpen(false)}
|
||||
onGenerate={handleAIGenerate}
|
||||
flowType={aiPromptFlowType}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -88,9 +88,9 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed right-0 top-0 bottom-0 z-50 flex flex-col border-l"
|
||||
className="flex flex-col border-l shrink-0"
|
||||
style={{
|
||||
width: '400px',
|
||||
width: '380px',
|
||||
background: 'rgba(16, 17, 20, 0.95)',
|
||||
backdropFilter: 'var(--glass-blur)',
|
||||
WebkitBackdropFilter: 'var(--glass-blur)',
|
||||
@@ -174,6 +174,7 @@ export function CopilotPanel({ isOpen, onClose, treeId, sessionId, currentNodeId
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[0.625rem] text-muted-foreground mt-1.5 px-1">Shift + Enter for a new line</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
111
frontend/src/components/editor-ai/AIPromptDialog.tsx
Normal file
111
frontend/src/components/editor-ai/AIPromptDialog.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react'
|
||||
import { Sparkles, Loader2, AlertCircle } from 'lucide-react'
|
||||
|
||||
interface AIPromptDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onGenerate: (prompt: string) => Promise<void>
|
||||
flowType: 'troubleshooting' | 'procedural' | 'maintenance'
|
||||
}
|
||||
|
||||
const FLOW_TYPE_LABELS = {
|
||||
troubleshooting: 'Troubleshooting Flow',
|
||||
procedural: 'Project Flow',
|
||||
maintenance: 'Maintenance Flow',
|
||||
}
|
||||
|
||||
export function AIPromptDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onGenerate,
|
||||
flowType,
|
||||
}: AIPromptDialogProps) {
|
||||
const [prompt, setPrompt] = useState('')
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!prompt.trim()) return
|
||||
setIsGenerating(true)
|
||||
setError(null)
|
||||
try {
|
||||
await onGenerate(prompt)
|
||||
setPrompt('')
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Generation failed. Please try again.')
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
|
||||
onClick={() => !isGenerating && onClose()}
|
||||
/>
|
||||
|
||||
{/* Dialog */}
|
||||
<div className="glass-card-static relative w-full max-w-lg p-6 shadow-xl">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Sparkles className="h-5 w-5 text-primary" />
|
||||
<h2 className="text-lg font-heading font-semibold text-foreground">
|
||||
AI-Assisted {FLOW_TYPE_LABELS[flowType]}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
Describe what you want to build and AI will generate a starting structure for you.
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder={`Example: "A flow for troubleshooting VPN connectivity issues when users can't connect to the corporate network"`}
|
||||
rows={4}
|
||||
disabled={isGenerating}
|
||||
className="w-full rounded-xl border border-border bg-background px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none disabled:opacity-50"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mt-3 flex items-start gap-2 rounded-lg bg-rose-500/10 border border-rose-500/20 px-3 py-2">
|
||||
<AlertCircle className="h-4 w-4 text-rose-400 mt-0.5 shrink-0" />
|
||||
<p className="text-sm text-rose-400">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex justify-end gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isGenerating}
|
||||
className="rounded-[10px] bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] px-4 py-2 text-sm text-foreground hover:border-[rgba(255,255,255,0.12)] transition-colors disabled:opacity-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGenerate}
|
||||
disabled={!prompt.trim() || isGenerating}
|
||||
className="flex items-center gap-2 rounded-[10px] bg-gradient-brand px-4 py-2 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-50"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Generate
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
frontend/src/components/editor-ai/ChatTab.tsx
Normal file
96
frontend/src/components/editor-ai/ChatTab.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { Send, Sparkles, Loader2 } from 'lucide-react'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import type { EditorAIChatMessage } from '@/types'
|
||||
|
||||
interface ChatTabProps {
|
||||
messages: EditorAIChatMessage[]
|
||||
input: string
|
||||
onInputChange: (value: string) => void
|
||||
onSend: () => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function ChatTab({ messages, input, onInputChange, onSend, isLoading }: ChatTabProps) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages])
|
||||
|
||||
// Focus input when panel mounts
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => inputRef.current?.focus())
|
||||
}, [])
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
if (input.trim() && !isLoading) onSend()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
{/* Messages */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground text-sm">
|
||||
<Sparkles className="h-8 w-8 mb-2 opacity-40" />
|
||||
<p>Ask me to help build your flow</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-xl px-3.5 py-2.5 text-[0.8125rem] leading-relaxed ${
|
||||
msg.role === 'user'
|
||||
? 'bg-primary/15 text-foreground'
|
||||
: 'bg-[rgba(255,255,255,0.04)] text-foreground border border-[rgba(255,255,255,0.06)]'
|
||||
}`}
|
||||
>
|
||||
<MarkdownContent content={msg.content} className="text-[0.8125rem] leading-relaxed" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="flex justify-start">
|
||||
<div className="bg-[rgba(255,255,255,0.04)] border border-[rgba(255,255,255,0.06)] rounded-xl px-3.5 py-2.5">
|
||||
<Loader2 size={16} className="animate-spin text-primary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="px-4 py-3 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={(e) => onInputChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask AI to help..."
|
||||
rows={1}
|
||||
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-[0.8125rem] placeholder:text-muted-foreground px-3.5 py-2.5 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
onClick={onSend}
|
||||
disabled={!input.trim() || isLoading}
|
||||
className="bg-gradient-brand text-[#101114] p-2.5 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
|
||||
>
|
||||
<Send size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[0.625rem] text-muted-foreground mt-1.5 px-1">Shift + Enter for a new line</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
114
frontend/src/components/editor-ai/EditorAIPanel.tsx
Normal file
114
frontend/src/components/editor-ai/EditorAIPanel.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useState } from 'react'
|
||||
import { X, Sparkles } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { NodeSummary } from './NodeSummary'
|
||||
import { ChatTab } from './ChatTab'
|
||||
import { SuggestionsTab } from './SuggestionsTab'
|
||||
import type { EditorAIChatMessage, AISuggestion } from '@/types'
|
||||
|
||||
interface EditorAIPanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
focalNode?: { id: string; type: string; question?: string; title?: string; description?: string } | null
|
||||
flowName?: string
|
||||
flowType?: string
|
||||
nodeCount?: number
|
||||
messages: EditorAIChatMessage[]
|
||||
input: string
|
||||
onInputChange: (value: string) => void
|
||||
onSend: () => void
|
||||
isLoading: boolean
|
||||
suggestions: AISuggestion[]
|
||||
}
|
||||
|
||||
type Tab = 'chat' | 'suggestions'
|
||||
|
||||
export function EditorAIPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
focalNode,
|
||||
flowName,
|
||||
flowType,
|
||||
nodeCount,
|
||||
messages,
|
||||
input,
|
||||
onInputChange,
|
||||
onSend,
|
||||
isLoading,
|
||||
suggestions,
|
||||
}: EditorAIPanelProps) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('chat')
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const pendingCount = suggestions.filter((s) => s.status === 'pending').length
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-full w-[380px] shrink-0 flex-col border-l"
|
||||
style={{
|
||||
background: 'rgba(16, 17, 20, 0.95)',
|
||||
backdropFilter: 'var(--glass-blur)',
|
||||
WebkitBackdropFilter: 'var(--glass-blur)',
|
||||
borderColor: 'var(--glass-border)',
|
||||
animation: 'slideInRight 200ms ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 border-b shrink-0"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles size={16} className="text-primary" />
|
||||
<span className="text-sm font-semibold text-foreground">AI Assist</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-[rgba(255,255,255,0.06)] text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<NodeSummary node={focalNode} flowName={flowName} flowType={flowType} nodeCount={nodeCount} />
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 text-xs font-medium transition-colors',
|
||||
activeTab === 'chat'
|
||||
? 'border-b-2 border-primary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Chat
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('suggestions')}
|
||||
className={cn(
|
||||
'flex-1 px-3 py-2 text-xs font-medium transition-colors relative',
|
||||
activeTab === 'suggestions'
|
||||
? 'border-b-2 border-primary text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Suggestions
|
||||
{pendingCount > 0 && (
|
||||
<span className="ml-1.5 inline-flex h-4 min-w-4 items-center justify-center rounded-full bg-primary/20 px-1 text-[0.5625rem] text-primary">
|
||||
{pendingCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'chat' ? (
|
||||
<ChatTab messages={messages} input={input} onInputChange={onInputChange} onSend={onSend} isLoading={isLoading} />
|
||||
) : (
|
||||
<SuggestionsTab suggestions={suggestions} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
59
frontend/src/components/editor-ai/NodeSummary.tsx
Normal file
59
frontend/src/components/editor-ai/NodeSummary.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { HelpCircle, Zap, CheckCircle, FileText, Layout } from 'lucide-react'
|
||||
|
||||
interface NodeSummaryProps {
|
||||
node?: { id: string; type: string; question?: string; title?: string; description?: string } | null
|
||||
flowName?: string
|
||||
flowType?: string
|
||||
nodeCount?: number
|
||||
}
|
||||
|
||||
const NODE_ICONS: Record<string, typeof HelpCircle> = {
|
||||
decision: HelpCircle,
|
||||
action: Zap,
|
||||
solution: CheckCircle,
|
||||
}
|
||||
|
||||
const NODE_COLORS: Record<string, string> = {
|
||||
decision: 'text-blue-400',
|
||||
action: 'text-yellow-400',
|
||||
solution: 'text-green-400',
|
||||
}
|
||||
|
||||
export function NodeSummary({ node, flowName, flowType, nodeCount }: NodeSummaryProps) {
|
||||
if (!node) {
|
||||
return (
|
||||
<div className="border-b px-3 py-2.5" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Layout className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium text-foreground truncate">
|
||||
{flowName || 'Untitled Flow'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
<span>{flowType || 'flow'}</span>
|
||||
{nodeCount !== undefined && <span>{nodeCount} nodes</span>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Icon = NODE_ICONS[node.type] || FileText
|
||||
const colorClass = NODE_COLORS[node.type] || 'text-muted-foreground'
|
||||
|
||||
return (
|
||||
<div className="border-b px-3 py-2.5" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className={`h-3.5 w-3.5 ${colorClass}`} />
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{node.type}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm font-medium text-foreground truncate">
|
||||
{node.question || node.title || node.id}
|
||||
</p>
|
||||
{node.description && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground truncate">{node.description}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
50
frontend/src/components/editor-ai/SuggestionsTab.tsx
Normal file
50
frontend/src/components/editor-ai/SuggestionsTab.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Check, X, Clock } from 'lucide-react'
|
||||
import type { AISuggestion } from '@/types'
|
||||
|
||||
interface SuggestionsTabProps {
|
||||
suggestions: AISuggestion[]
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
accepted: { icon: Check, color: 'text-emerald-400', label: 'Accepted' },
|
||||
dismissed: { icon: X, color: 'text-rose-400', label: 'Dismissed' },
|
||||
pending: { icon: Clock, color: 'text-amber-400', label: 'Pending' },
|
||||
} as const
|
||||
|
||||
export function SuggestionsTab({ suggestions }: SuggestionsTabProps) {
|
||||
if (suggestions.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground p-6">
|
||||
No AI suggestions yet
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-3 py-3 space-y-2">
|
||||
{suggestions.map((s) => {
|
||||
const config = STATUS_CONFIG[s.status]
|
||||
const StatusIcon = config.icon
|
||||
return (
|
||||
<div key={s.id} className="rounded-lg border border-border bg-[rgba(255,255,255,0.02)] px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-[0.1em] text-muted-foreground">
|
||||
{s.action_type.replace(/_/g, ' ')}
|
||||
</span>
|
||||
<span className={`flex items-center gap-1 text-xs ${config.color}`}>
|
||||
<StatusIcon className="h-3 w-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
</div>
|
||||
{s.target_node_id && (
|
||||
<p className="mt-1 text-xs text-muted-foreground truncate">Node: {s.target_node_id}</p>
|
||||
)}
|
||||
<p className="mt-0.5 font-label text-[0.625rem] text-[#5a6170]">
|
||||
{new Date(s.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, Sparkles, BotMessageSquare, BookOpen } from 'lucide-react'
|
||||
import { LayoutGrid, Box, PenLine, Clock, FileText, Bookmark, BarChart3, Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText, BotMessageSquare, BookOpen } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
||||
@@ -81,7 +81,6 @@ export function Sidebar() {
|
||||
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" collapsed />
|
||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} collapsed />
|
||||
<NavItem href="/shares" icon={FileText} label="Exports" collapsed />
|
||||
<NavItem href="/ai/chat" icon={Sparkles} label="Flow Assist" collapsed />
|
||||
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" collapsed />
|
||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" collapsed />
|
||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" collapsed />
|
||||
@@ -114,7 +113,6 @@ export function Sidebar() {
|
||||
<NavItem href="/my-trees" icon={PenLine} label="Flow Editor" />
|
||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={activeSessionCount || undefined} />
|
||||
<NavItem href="/shares" icon={FileText} label="Exports" />
|
||||
<NavItem href="/ai/chat" icon={Sparkles} label="Flow Assist" />
|
||||
<NavItem href="/assistant" icon={BotMessageSquare} label="AI Assistant" />
|
||||
<NavItem href="/step-library" icon={Bookmark} label="Step Library" />
|
||||
<NavItem href="/analytics" icon={BarChart3} label="Analytics" />
|
||||
|
||||
100
frontend/src/components/library/ExportFlowModal.tsx
Normal file
100
frontend/src/components/library/ExportFlowModal.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Download, X } from 'lucide-react'
|
||||
import { flowTransferApi } from '@/api/flowTransfer'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
interface ExportFlowModalProps {
|
||||
treeId: string
|
||||
treeName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function slugify(name: string): string {
|
||||
return name.toLowerCase().trim().replace(/[^\w\s-]/g, '').replace(/[-\s]+/g, '-')
|
||||
}
|
||||
|
||||
export function ExportFlowModal({ treeId, treeName, onClose }: ExportFlowModalProps) {
|
||||
const [isExporting, setIsExporting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
const handleExport = async () => {
|
||||
setIsExporting(true)
|
||||
try {
|
||||
const blob = await flowTransferApi.exportFlow(treeId)
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${slugify(treeName)}.rfflow`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('Flow exported successfully')
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Export failed:', err)
|
||||
toast.error('Failed to export flow')
|
||||
} finally {
|
||||
setIsExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-sm rounded-xl border border-border bg-card shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold text-foreground">Export Flow</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Export <span className="font-medium text-foreground">{treeName}</span> as a <code className="text-xs font-label">.rfflow</code> file (JSON format).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={isExporting}
|
||||
className="bg-gradient-brand flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{isExporting ? 'Exporting…' : 'Download .rfflow'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
257
frontend/src/components/library/ImportFlowModal.tsx
Normal file
257
frontend/src/components/library/ImportFlowModal.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { FileUp, X, AlertTriangle } from 'lucide-react'
|
||||
import { flowTransferApi } from '@/api/flowTransfer'
|
||||
import { parseRFFlowFile, RFFlowParseError } from '@/lib/rfflowParser'
|
||||
import type { RFFlowFile } from '@/types'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { getTreeEditorPath } from '@/lib/routing'
|
||||
|
||||
interface ImportFlowModalProps {
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
troubleshooting: 'Troubleshooting',
|
||||
procedural: 'Project',
|
||||
maintenance: 'Maintenance',
|
||||
}
|
||||
|
||||
export function ImportFlowModal({ onClose }: ImportFlowModalProps) {
|
||||
const navigate = useNavigate()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [step, setStep] = useState<'pick' | 'preview'>('pick')
|
||||
const [parsed, setParsed] = useState<RFFlowFile | null>(null)
|
||||
const [nameOverride, setNameOverride] = useState('')
|
||||
const [parseError, setParseError] = useState<string | null>(null)
|
||||
const [isImporting, setIsImporting] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
const handleFile = async (file: File) => {
|
||||
setParseError(null)
|
||||
|
||||
if (!file.name.endsWith('.rfflow')) {
|
||||
setParseError('File must have .rfflow extension')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const text = await file.text()
|
||||
const data = parseRFFlowFile(text)
|
||||
setParsed(data)
|
||||
setNameOverride(data.flow.name)
|
||||
setStep('preview')
|
||||
} catch (err) {
|
||||
if (err instanceof RFFlowParseError) {
|
||||
setParseError(err.message)
|
||||
} else {
|
||||
setParseError('Failed to read file')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) handleFile(file)
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
const file = e.dataTransfer.files[0]
|
||||
if (file) handleFile(file)
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (!parsed) return
|
||||
setIsImporting(true)
|
||||
try {
|
||||
const overrideName = nameOverride.trim() !== parsed.flow.name ? nameOverride.trim() : undefined
|
||||
const result = await flowTransferApi.importFlow(parsed, overrideName)
|
||||
toast.success(`Imported "${result.name}" as draft`)
|
||||
onClose()
|
||||
navigate(getTreeEditorPath(result.tree_id, result.tree_type))
|
||||
} catch (err) {
|
||||
console.error('Import failed:', err)
|
||||
toast.error('Failed to import flow')
|
||||
} finally {
|
||||
setIsImporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-md rounded-xl border border-border bg-card shadow-xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileUp className="h-4 w-4 text-muted-foreground" />
|
||||
<h2 className="text-sm font-semibold text-foreground">Import Flow</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-5 py-4">
|
||||
{step === 'pick' && (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center justify-center rounded-lg border-2 border-dashed px-4 py-8 text-center transition-colors cursor-pointer',
|
||||
isDragging
|
||||
? 'border-primary/50 bg-primary/5'
|
||||
: 'border-border hover:border-[rgba(255,255,255,0.12)]'
|
||||
)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true) }}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<FileUp className="mb-2 h-8 w-8 text-muted-foreground" />
|
||||
<p className="text-sm text-foreground">
|
||||
Drop .rfflow file here or <span className="text-primary cursor-pointer">browse</span>
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">JSON format</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".rfflow"
|
||||
onChange={handleFileChange}
|
||||
className="hidden"
|
||||
/>
|
||||
{parseError && (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-rose-500/20 bg-rose-500/5 px-3 py-2">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0 text-rose-400" />
|
||||
<p className="text-xs text-rose-400">{parseError}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 'preview' && parsed && (
|
||||
<div className="space-y-4">
|
||||
{/* Editable name */}
|
||||
<div>
|
||||
<label htmlFor="import-name" className="mb-1.5 block text-xs font-medium text-muted-foreground">
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
id="import-name"
|
||||
type="text"
|
||||
value={nameOverride}
|
||||
onChange={(e) => setNameOverride(e.target.value)}
|
||||
maxLength={255}
|
||||
className={cn(
|
||||
'w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground',
|
||||
'placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Flow info */}
|
||||
<div className="space-y-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Type:</span>
|
||||
<span className="rounded bg-primary/10 px-2 py-0.5 font-label text-primary">
|
||||
{TYPE_LABELS[parsed.flow.tree_type] || parsed.flow.tree_type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{parsed.flow.description && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Description:</span>
|
||||
<p className="mt-0.5 text-foreground line-clamp-2">{parsed.flow.description}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsed.flow.category && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Category:</span>
|
||||
<span className="text-foreground">{parsed.flow.category.name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsed.flow.tags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="text-muted-foreground">Tags:</span>
|
||||
{parsed.flow.tags.map((tag) => (
|
||||
<span key={tag} className="rounded bg-card border border-border px-2 py-0.5 font-label text-foreground">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parsed.flow.author_name && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Original author:</span>
|
||||
<span className="text-foreground">{parsed.flow.author_name}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground">Version:</span>
|
||||
<span className="text-foreground">v{parsed.flow.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Flow will be imported as a <span className="font-medium text-foreground">draft</span>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 border-t border-border px-5 py-3">
|
||||
{step === 'preview' && (
|
||||
<button
|
||||
onClick={() => { setStep('pick'); setParsed(null); setParseError(null) }}
|
||||
className="mr-auto rounded-lg border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{step === 'preview' && (
|
||||
<button
|
||||
onClick={handleImport}
|
||||
disabled={isImporting || !nameOverride.trim()}
|
||||
className="bg-gradient-brand flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
<FileUp className="h-4 w-4" />
|
||||
{isImporting ? 'Importing…' : 'Import as Draft'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star } from 'lucide-react'
|
||||
import { Pencil, Globe, Lock, Trash2, GitBranch, FileText, Wrench, Star, Download } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -13,6 +13,7 @@ interface TreeGridViewProps {
|
||||
onFolderCreated: (parentId?: string | null) => void
|
||||
onDeleteTree: (tree: TreeListItem) => void
|
||||
onForkTree?: (treeId: string) => void
|
||||
onExportTree?: (treeId: string) => void
|
||||
pinnedTreeIds?: Set<string>
|
||||
onTogglePin?: (treeId: string) => void
|
||||
pinLoadingTreeIds?: Set<string>
|
||||
@@ -24,6 +25,7 @@ export function TreeGridView({
|
||||
onTagClick,
|
||||
onDeleteTree,
|
||||
onForkTree,
|
||||
onExportTree,
|
||||
pinnedTreeIds,
|
||||
onTogglePin,
|
||||
pinLoadingTreeIds,
|
||||
@@ -111,6 +113,20 @@ export function TreeGridView({
|
||||
v{tree.version} · {tree.usage_count} uses
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{onExportTree && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExportTree(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md border border-border p-2 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="Export flow"
|
||||
aria-label="Export flow"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{onForkTree && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench, Star } from 'lucide-react'
|
||||
import { Pencil, Globe, Lock, GitBranch, FileText, Trash2, Wrench, Star, Download } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -13,6 +13,7 @@ interface TreeListViewProps {
|
||||
onFolderCreated: (parentId?: string | null) => void
|
||||
onDeleteTree: (tree: TreeListItem) => void
|
||||
onForkTree?: (treeId: string) => void
|
||||
onExportTree?: (treeId: string) => void
|
||||
pinnedTreeIds?: Set<string>
|
||||
onTogglePin?: (treeId: string) => void
|
||||
pinLoadingTreeIds?: Set<string>
|
||||
@@ -24,6 +25,7 @@ export function TreeListView({
|
||||
onTagClick,
|
||||
onDeleteTree,
|
||||
onForkTree,
|
||||
onExportTree,
|
||||
pinnedTreeIds,
|
||||
onTogglePin,
|
||||
pinLoadingTreeIds,
|
||||
@@ -115,6 +117,20 @@ export function TreeListView({
|
||||
<Star size={16} fill={pinnedTreeIds?.has(tree.id) ? 'currentColor' : 'none'} />
|
||||
</button>
|
||||
)}
|
||||
{onExportTree && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExportTree(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md border border-border p-1.5 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="Export flow"
|
||||
aria-label="Export flow"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
{onForkTree && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench, Star } from 'lucide-react'
|
||||
import { Pencil, Globe, Lock, ChevronUp, ChevronDown, GitBranch, FileText, Trash2, Wrench, Star, Download } from 'lucide-react'
|
||||
import type { TreeListItem } from '@/types'
|
||||
import { TagBadges } from '@/components/common/TagBadges'
|
||||
import { cn } from '@/lib/utils'
|
||||
@@ -15,6 +15,7 @@ interface TreeTableViewProps {
|
||||
onDeleteTree: (tree: TreeListItem) => void
|
||||
onSortChange?: (sortBy: string) => void
|
||||
onForkTree?: (treeId: string) => void
|
||||
onExportTree?: (treeId: string) => void
|
||||
pinnedTreeIds?: Set<string>
|
||||
onTogglePin?: (treeId: string) => void
|
||||
pinLoadingTreeIds?: Set<string>
|
||||
@@ -29,6 +30,7 @@ export function TreeTableView({
|
||||
onDeleteTree,
|
||||
onSortChange,
|
||||
onForkTree,
|
||||
onExportTree,
|
||||
pinnedTreeIds,
|
||||
onTogglePin,
|
||||
pinLoadingTreeIds,
|
||||
@@ -226,6 +228,20 @@ export function TreeTableView({
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{onExportTree && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onExportTree(tree.id)}
|
||||
className={cn(
|
||||
'rounded-md border border-border p-1.5 text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
title="Export flow"
|
||||
aria-label="Export flow"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{onForkTree && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -39,7 +39,11 @@ function SortableStepWrapper({
|
||||
)
|
||||
}
|
||||
|
||||
export function StepList() {
|
||||
interface StepListProps {
|
||||
onStepContextMenu?: (e: React.MouseEvent, stepId: string) => void
|
||||
}
|
||||
|
||||
export function StepList({ onStepContextMenu }: StepListProps) {
|
||||
const {
|
||||
steps,
|
||||
intakeForm,
|
||||
@@ -208,6 +212,7 @@ export function StepList() {
|
||||
const contentType = step.content_type || 'action'
|
||||
const config = contentTypeConfig[contentType]
|
||||
const Icon = config.icon
|
||||
const isGhost = !!(step as unknown as Record<string, unknown>)._suggestion
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
@@ -232,53 +237,80 @@ export function StepList() {
|
||||
{({ dragHandleProps }) => (
|
||||
<div
|
||||
className={cn(
|
||||
'group flex items-center gap-2 rounded-xl border border-border px-3 py-2.5 transition-colors',
|
||||
'hover:border-primary/30 hover:bg-accent/50'
|
||||
'group flex flex-col rounded-xl border border-border px-3 py-2.5 transition-colors',
|
||||
'hover:border-primary/30 hover:bg-accent/50',
|
||||
isGhost && 'border-l-2 border-dashed !border-l-primary/40 opacity-60'
|
||||
)}
|
||||
onContextMenu={(e) => onStepContextMenu?.(e, step.id)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
|
||||
aria-label={`Drag to reorder step ${stepNumber}: ${step.title || 'Untitled step'}`}
|
||||
{...dragHandleProps}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
|
||||
aria-label={`Drag to reorder step ${stepNumber}: ${step.title || 'Untitled step'}`}
|
||||
{...dragHandleProps}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-muted-foreground">
|
||||
{stepNumber}
|
||||
</span>
|
||||
|
||||
<span className={cn('shrink-0', config.color)}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="min-w-0 flex-1 cursor-pointer truncate text-sm text-foreground"
|
||||
onClick={() => setExpandedStepId(step.id)}
|
||||
>
|
||||
{step.title || 'Untitled step'}
|
||||
</span>
|
||||
|
||||
{step.estimated_minutes && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
~{step.estimated_minutes}m
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-muted-foreground">
|
||||
{stepNumber}
|
||||
</span>
|
||||
|
||||
<span className={cn('shrink-0', config.color)}>
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
|
||||
<span
|
||||
className="min-w-0 flex-1 cursor-pointer truncate text-sm text-foreground"
|
||||
onClick={() => setExpandedStepId(step.id)}
|
||||
>
|
||||
{step.title || 'Untitled step'}
|
||||
</span>
|
||||
|
||||
{step.estimated_minutes && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
~{step.estimated_minutes}m
|
||||
</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setExpandedStepId(step.id)}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => removeStep(step.id)}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isGhost && (
|
||||
<div className="mt-2 flex gap-2 border-t border-border/50 pt-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// TODO: accept suggestion
|
||||
}}
|
||||
className="flex-1 rounded-md bg-emerald-500/20 px-2 py-1 text-xs text-emerald-400 hover:bg-emerald-500/30 transition-colors"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// TODO: dismiss suggestion
|
||||
}}
|
||||
className="flex-1 rounded-md bg-rose-500/20 px-2 py-1 text-xs text-rose-400 hover:bg-rose-500/30 transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => setExpandedStepId(step.id)}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => removeStep(step.id)}
|
||||
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SortableStepWrapper>
|
||||
|
||||
@@ -31,9 +31,10 @@ interface FlowCanvasProps {
|
||||
selectedNodeId: string | null
|
||||
onNodeSelect: (nodeId: string | null) => void
|
||||
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||
onNodeContextMenu?: (e: React.MouseEvent, nodeId: string) => void
|
||||
}
|
||||
|
||||
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: FlowCanvasProps) {
|
||||
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType, onNodeContextMenu }: FlowCanvasProps) {
|
||||
const { fitView, setCenter } = useReactFlow()
|
||||
const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout()
|
||||
const [minimapVisible, setMinimapVisible] = useState(true)
|
||||
@@ -46,7 +47,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: F
|
||||
return {
|
||||
...n,
|
||||
selected: n.id === selectedNodeId,
|
||||
data: { ...data, onToggleCollapse: toggleCollapse },
|
||||
data: { ...data, onToggleCollapse: toggleCollapse, onContextMenu: onNodeContextMenu },
|
||||
}
|
||||
}
|
||||
if (n.type === 'answerStub') {
|
||||
@@ -59,7 +60,7 @@ function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: F
|
||||
}
|
||||
return n
|
||||
})
|
||||
}, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType])
|
||||
}, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType, onNodeContextMenu])
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks)
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges)
|
||||
|
||||
@@ -41,10 +41,14 @@ export interface FlowCanvasNodeData {
|
||||
hasValidationErrors: boolean
|
||||
isNew: boolean
|
||||
onToggleCollapse: (nodeId: string) => void
|
||||
onContextMenu?: (e: React.MouseEvent, nodeId: string) => void
|
||||
onAcceptSuggestion?: (nodeId: string) => void
|
||||
onDismissSuggestion?: (nodeId: string) => void
|
||||
}
|
||||
|
||||
function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
|
||||
const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, onToggleCollapse } = data as unknown as FlowCanvasNodeData
|
||||
const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, onToggleCollapse, onContextMenu, onAcceptSuggestion, onDismissSuggestion } = data as unknown as FlowCanvasNodeData
|
||||
const isGhost = !!(node as unknown as Record<string, unknown>)._suggestion
|
||||
const nodeType = node.type as Exclude<NodeType, 'answer'>
|
||||
const config = NODE_TYPE_CONFIG[nodeType] ?? NODE_TYPE_CONFIG.decision
|
||||
const Icon = config.icon
|
||||
@@ -61,10 +65,12 @@ function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
|
||||
<Handle type="target" position={Position.Top} className="!bg-border !w-2 !h-2 !border-0" />
|
||||
|
||||
<div
|
||||
onContextMenu={(e) => onContextMenu?.(e, node.id)}
|
||||
className={cn(
|
||||
'w-[280px] rounded-xl border border-border bg-card shadow-sm cursor-pointer transition-all',
|
||||
config.borderClass,
|
||||
selected && 'ring-1 ring-primary shadow-md'
|
||||
selected && 'ring-1 ring-primary shadow-md',
|
||||
isGhost && 'border-dashed !border-primary/40 opacity-60'
|
||||
)}
|
||||
>
|
||||
{/* Header */}
|
||||
@@ -142,6 +148,30 @@ function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ghost node accept/dismiss overlay */}
|
||||
{isGhost && (
|
||||
<div className="mt-2 flex gap-2 border-t border-border/50 pt-2 px-3 pb-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onAcceptSuggestion?.(node.id)
|
||||
}}
|
||||
className="flex-1 rounded-md bg-emerald-500/20 px-2 py-1 text-xs text-emerald-400 hover:bg-emerald-500/30 transition-colors"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDismissSuggestion?.(node.id)
|
||||
}}
|
||||
className="flex-1 rounded-md bg-rose-500/20 px-2 py-1 text-xs text-rose-400 hover:bg-rose-500/30 transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Source handle at bottom */}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface TreeEditorLayoutProps {
|
||||
editingNodeId: string | null
|
||||
onNodeSelect: (nodeId: string | null) => void
|
||||
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||
onNodeContextMenu?: (e: React.MouseEvent, nodeId: string) => void
|
||||
}
|
||||
|
||||
export function TreeEditorLayout({
|
||||
@@ -28,6 +29,7 @@ export function TreeEditorLayout({
|
||||
editingNodeId,
|
||||
onNodeSelect,
|
||||
onSelectAnswerType,
|
||||
onNodeContextMenu,
|
||||
}: TreeEditorLayoutProps) {
|
||||
const editorMode = useTreeEditorStore(s => s.editorMode)
|
||||
|
||||
@@ -70,6 +72,7 @@ export function TreeEditorLayout({
|
||||
selectedNodeId={editingNodeId}
|
||||
onNodeSelect={onNodeSelect}
|
||||
onSelectAnswerType={onSelectAnswerType}
|
||||
onNodeContextMenu={onNodeContextMenu}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
161
frontend/src/hooks/useEditorAI.ts
Normal file
161
frontend/src/hooks/useEditorAI.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import { editorAIApi } from '@/api/editorAI'
|
||||
import type {
|
||||
AIActionType,
|
||||
EditorAIChatMessage,
|
||||
AISuggestion,
|
||||
ContextMenuPosition,
|
||||
} from '@/types'
|
||||
|
||||
interface UseEditorAIOptions {
|
||||
flowType: 'troubleshooting' | 'procedural'
|
||||
treeId?: string | null
|
||||
/** Returns the live flow structure from the editor for AI context */
|
||||
getFlowContext?: () => Record<string, unknown> | null
|
||||
/** Called when the AI response contains a working_tree update */
|
||||
onFlowUpdate?: (workingTree: Record<string, unknown>, metadata?: Record<string, unknown> | null) => void
|
||||
}
|
||||
|
||||
export function useEditorAI({ flowType, treeId, getFlowContext, onFlowUpdate }: UseEditorAIOptions) {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [focalNodeId, setFocalNodeId] = useState<string | null>(null)
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
position: ContextMenuPosition
|
||||
nodeId: string
|
||||
} | null>(null)
|
||||
const [sessionId, setSessionId] = useState<string | null>(null)
|
||||
const [messages, setMessages] = useState<EditorAIChatMessage[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [suggestions, setSuggestions] = useState<AISuggestion[]>([])
|
||||
|
||||
const pendingActionRef = useRef<AIActionType>('open_chat')
|
||||
|
||||
const ensureSession = useCallback(async () => {
|
||||
if (sessionId) return sessionId
|
||||
try {
|
||||
const result = await editorAIApi.startSession(flowType, treeId || undefined)
|
||||
setSessionId(result.session_id)
|
||||
if (result.greeting) {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'assistant' as const,
|
||||
content: result.greeting,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
}
|
||||
return result.session_id
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}, [sessionId, flowType, treeId])
|
||||
|
||||
const openPanel = useCallback((nodeId?: string, actionType?: AIActionType) => {
|
||||
setIsOpen(true)
|
||||
if (nodeId) setFocalNodeId(nodeId)
|
||||
if (actionType) pendingActionRef.current = actionType
|
||||
}, [])
|
||||
|
||||
const closePanel = useCallback(() => {
|
||||
setIsOpen(false)
|
||||
setContextMenu(null)
|
||||
}, [])
|
||||
|
||||
const openContextMenu = useCallback((e: React.MouseEvent, nodeId: string) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setContextMenu({ position: { x: e.clientX, y: e.clientY }, nodeId })
|
||||
}, [])
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setContextMenu(null)
|
||||
}, [])
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
if (!input.trim() || isLoading) return
|
||||
|
||||
const currentInput = input
|
||||
const currentAction = pendingActionRef.current
|
||||
const currentFocalNodeId = focalNodeId
|
||||
|
||||
const userMessage: EditorAIChatMessage = {
|
||||
role: 'user',
|
||||
content: currentInput,
|
||||
timestamp: new Date().toISOString(),
|
||||
action_type: currentAction,
|
||||
}
|
||||
setMessages((prev) => [...prev, userMessage])
|
||||
setInput('')
|
||||
setIsLoading(true)
|
||||
|
||||
try {
|
||||
const sid = await ensureSession()
|
||||
if (!sid) return
|
||||
|
||||
const result = await editorAIApi.sendMessage({
|
||||
sessionId: sid,
|
||||
content: currentInput,
|
||||
actionType: currentAction,
|
||||
focalNodeId: currentFocalNodeId,
|
||||
flowContext: getFlowContext?.() || null,
|
||||
})
|
||||
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: result.content,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
|
||||
// Apply AI-generated flow structure to the editor
|
||||
if (result.working_tree && onFlowUpdate) {
|
||||
onFlowUpdate(result.working_tree, result.tree_metadata || null)
|
||||
}
|
||||
} catch {
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Sorry, something went wrong. Please try again.',
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
])
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
pendingActionRef.current = 'open_chat'
|
||||
}
|
||||
}, [input, isLoading, ensureSession, focalNodeId, getFlowContext, onFlowUpdate])
|
||||
|
||||
const triggerAction = useCallback(
|
||||
(nodeId: string, actionType: AIActionType, prompt: string) => {
|
||||
setFocalNodeId(nodeId)
|
||||
pendingActionRef.current = actionType
|
||||
setInput(prompt)
|
||||
setIsOpen(true)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
openPanel,
|
||||
closePanel,
|
||||
focalNodeId,
|
||||
setFocalNodeId,
|
||||
contextMenu,
|
||||
openContextMenu,
|
||||
closeContextMenu,
|
||||
messages,
|
||||
input,
|
||||
setInput,
|
||||
sendMessage,
|
||||
isLoading,
|
||||
suggestions,
|
||||
setSuggestions,
|
||||
triggerAction,
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,11 @@
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeInRight {
|
||||
from { transform: translateX(30px); opacity: 0; }
|
||||
to { transform: translateX(0); opacity: 1; }
|
||||
|
||||
52
frontend/src/lib/rfflowParser.ts
Normal file
52
frontend/src/lib/rfflowParser.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import type { RFFlowFile, FlowExportData } from '@/types'
|
||||
|
||||
export class RFFlowParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = 'RFFlowParseError'
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRFFlowFile(content: string): RFFlowFile {
|
||||
const trimmed = content.trim()
|
||||
|
||||
if (!trimmed.startsWith('{')) {
|
||||
throw new RFFlowParseError('Invalid file format. Expected JSON.')
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(trimmed)
|
||||
validateEnvelope(data)
|
||||
return data as RFFlowFile
|
||||
} catch (err) {
|
||||
if (err instanceof RFFlowParseError) throw err
|
||||
throw new RFFlowParseError(`Invalid JSON: ${(err as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
function validateEnvelope(data: unknown): asserts data is RFFlowFile {
|
||||
const obj = data as Record<string, unknown>
|
||||
|
||||
if (!obj.rfflow_version) {
|
||||
throw new RFFlowParseError('Missing rfflow_version')
|
||||
}
|
||||
if (obj.rfflow_version !== '1.0') {
|
||||
throw new RFFlowParseError(`Unsupported version: ${obj.rfflow_version}. Only 1.0 is supported.`)
|
||||
}
|
||||
|
||||
const flow = obj.flow as Record<string, unknown> | undefined
|
||||
if (!flow) {
|
||||
throw new RFFlowParseError('Missing flow data')
|
||||
}
|
||||
if (!flow.name) {
|
||||
throw new RFFlowParseError('Flow must have a name')
|
||||
}
|
||||
if (!flow.tree_structure) {
|
||||
throw new RFFlowParseError('Flow must have a tree_structure')
|
||||
}
|
||||
|
||||
const validTypes: FlowExportData['tree_type'][] = ['troubleshooting', 'procedural', 'maintenance']
|
||||
if (!validTypes.includes(flow.tree_type as FlowExportData['tree_type'])) {
|
||||
throw new RFFlowParseError(`Invalid tree_type: ${flow.tree_type}`)
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
import { useCallback, useEffect, useState } 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)
|
||||
}
|
||||
}, []) // 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 [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const handleGenerate = useCallback(() => {
|
||||
generateTree()
|
||||
}, [generateTree])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (isSaving) return
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const treeId = await importToEditor({
|
||||
name: treeMetadata?.name,
|
||||
description: treeMetadata?.description,
|
||||
tags: treeMetadata?.tags,
|
||||
})
|
||||
const path = getTreeEditorPath(treeId, flowType)
|
||||
navigate(path)
|
||||
toast.success('Flow saved to library')
|
||||
} catch {
|
||||
toast.error('Failed to save flow')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
}
|
||||
}, [isSaving, importToEditor, treeMetadata, flowType, navigate])
|
||||
|
||||
const handleReset = useCallback(async () => {
|
||||
await abandonSession()
|
||||
// Clear session from URL
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
next.delete('session')
|
||||
return next
|
||||
}, { replace: true })
|
||||
startSession(flowType)
|
||||
}, [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}
|
||||
isSaving={isSaving}
|
||||
onGenerate={handleGenerate}
|
||||
onSave={handleSave}
|
||||
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
|
||||
@@ -233,39 +233,42 @@ export default function AssistantChatPage() {
|
||||
|
||||
{/* Input */}
|
||||
<div className="px-6 py-4 border-t shrink-0" style={{ borderColor: 'var(--glass-border)' }}>
|
||||
<div className="flex items-end gap-3 max-w-3xl mx-auto">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask about IT, networking, troubleshooting..."
|
||||
rows={3}
|
||||
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || loading}
|
||||
className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
|
||||
title="Send message"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
{messages.length >= 2 && (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="flex items-end gap-3">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask about IT, networking, troubleshooting..."
|
||||
rows={3}
|
||||
className="flex-1 resize-none rounded-xl border bg-card text-foreground text-sm placeholder:text-muted-foreground px-4 py-3 focus:outline-none focus:border-[rgba(6,182,212,0.3)]"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
disabled={loading}
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={() => setShowConclude(true)}
|
||||
disabled={loading}
|
||||
className="p-3 rounded-xl border text-muted-foreground hover:text-amber-400 hover:border-amber-400/30 hover:bg-amber-400/10 transition-all disabled:opacity-40"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
title="Conclude session"
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || loading}
|
||||
className="bg-gradient-brand text-[#101114] p-3 rounded-xl hover:opacity-90 active:scale-[0.97] transition-all disabled:opacity-40"
|
||||
title="Send message"
|
||||
>
|
||||
<Flag size={18} />
|
||||
<Send size={18} />
|
||||
</button>
|
||||
)}
|
||||
{messages.length >= 2 && (
|
||||
<button
|
||||
onClick={() => setShowConclude(true)}
|
||||
disabled={loading}
|
||||
className="p-3 rounded-xl border text-muted-foreground hover:text-amber-400 hover:border-amber-400/30 hover:bg-amber-400/10 transition-all disabled:opacity-40"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
title="Conclude session"
|
||||
>
|
||||
<Flag size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[0.625rem] text-muted-foreground mt-1.5 px-1">Shift + Enter for a new line</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown, Wrench, Sparkles } from 'lucide-react'
|
||||
import { Play, Pencil, Share2, Trash2, GitBranch, Clock, TrendingUp, FolderTree, Plus, ListOrdered, ChevronDown, Wrench } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import type { TreeListItem } from '@/types'
|
||||
@@ -12,8 +12,6 @@ import { cn } from '@/lib/utils'
|
||||
import { useAuthStore } from '@/store/authStore'
|
||||
import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
|
||||
import { aiBuilderApi } from '@/api/aiBuilder'
|
||||
import { ForkModal } from '@/components/library/ForkModal'
|
||||
|
||||
interface TreeWithStats extends TreeListItem {
|
||||
@@ -35,18 +33,12 @@ export function MyTreesPage() {
|
||||
const [treeToShare, setTreeToShare] = useState<TreeWithStats | null>(null)
|
||||
const [showShareModal, setShowShareModal] = useState(false)
|
||||
const [showCreateMenu, setShowCreateMenu] = useState(false)
|
||||
const [showAIBuilder, setShowAIBuilder] = useState(false)
|
||||
const [aiEnabled, setAiEnabled] = useState(false)
|
||||
const [forkTarget, setForkTarget] = useState<TreeWithStats | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadMyTrees()
|
||||
}, [user?.id])
|
||||
|
||||
useEffect(() => {
|
||||
aiBuilderApi.getQuota().then((q) => setAiEnabled(q.ai_enabled)).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadMyTrees = async () => {
|
||||
if (!user?.id) return
|
||||
setIsLoading(true)
|
||||
@@ -178,25 +170,6 @@ export function MyTreesPage() {
|
||||
<div className="text-xs text-muted-foreground">Scheduled multi-target tasks</div>
|
||||
</div>
|
||||
</Link>
|
||||
{aiEnabled && (
|
||||
<>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowCreateMenu(false)
|
||||
setShowAIBuilder(true)
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-md px-3 py-2.5 text-sm text-foreground hover:bg-accent"
|
||||
>
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Flow Assist</div>
|
||||
<div className="text-xs text-muted-foreground">AI-powered flow builder</div>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -425,11 +398,6 @@ export function MyTreesPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Flow Builder Modal */}
|
||||
<AIFlowBuilderModal
|
||||
isOpen={showAIBuilder}
|
||||
onClose={() => setShowAIBuilder(false)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Save, ArrowLeft, ListOrdered, Wrench, Settings, FileText, Calendar } from 'lucide-react'
|
||||
import { Save, ArrowLeft, ListOrdered, Wrench, Settings, FileText, Calendar, Sparkles, Layers } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
|
||||
import { CollapsibleEditorSection } from '@/components/procedural-editor/CollapsibleEditorSection'
|
||||
@@ -10,8 +10,12 @@ import { getScheduleSummary } from '@/components/procedural-editor/scheduleUtils
|
||||
import { StepList } from '@/components/procedural-editor/StepList'
|
||||
import { TagInput } from '@/components/common/TagInput'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { EditorAIPanel } from '@/components/editor-ai/EditorAIPanel'
|
||||
import { ContextMenu } from '@/components/common/ContextMenu'
|
||||
import { useEditorAI } from '@/hooks/useEditorAI'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { TreeType, MaintenanceSchedule, TargetList } from '@/types'
|
||||
import type { TreeType, MaintenanceSchedule, TargetList, ProceduralStep, IntakeFormField } from '@/types'
|
||||
|
||||
type SectionKey = 'details' | 'intake' | 'schedule'
|
||||
|
||||
@@ -42,8 +46,34 @@ export function ProceduralEditorPage() {
|
||||
setIsSaving,
|
||||
markSaved,
|
||||
getTreeForSave,
|
||||
replaceSteps,
|
||||
} = useProceduralEditorStore()
|
||||
|
||||
const steps = useProceduralEditorStore(s => s.steps)
|
||||
|
||||
const handleFlowUpdate = useCallback((workingTree: Record<string, unknown>, metadata?: Record<string, unknown> | null) => {
|
||||
const stepsData = workingTree.steps as ProceduralStep[] | undefined
|
||||
if (stepsData && Array.isArray(stepsData)) {
|
||||
// Intake form may be in working_tree or in metadata
|
||||
const intakeData = (workingTree.intake_form || metadata?.intake_form) as IntakeFormField[] | undefined
|
||||
replaceSteps(stepsData, intakeData)
|
||||
}
|
||||
}, [replaceSteps])
|
||||
|
||||
const editorAI = useEditorAI({
|
||||
flowType: 'procedural',
|
||||
treeId: id,
|
||||
getFlowContext: useCallback(() => {
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
steps: steps as unknown as Record<string, unknown>[],
|
||||
intake_form: intakeForm,
|
||||
}
|
||||
}, [steps, intakeForm, name, description]),
|
||||
onFlowUpdate: handleFlowUpdate,
|
||||
})
|
||||
|
||||
const isMaintenance = treeType === 'maintenance'
|
||||
const flowLabel = isMaintenance ? 'Maintenance Flow' : 'Procedure'
|
||||
|
||||
@@ -150,7 +180,9 @@ export function ProceduralEditorPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Main content column */}
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
{/* Toolbar — sticky */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -175,6 +207,19 @@ export function ProceduralEditorPage() {
|
||||
{isDirty && (
|
||||
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => editorAI.isOpen ? editorAI.closePanel() : editorAI.openPanel()}
|
||||
title="Toggle AI Assist panel"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors',
|
||||
editorAI.isOpen
|
||||
? 'bg-primary/10 text-primary border-primary/30'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
AI Assist
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSave('draft')}
|
||||
disabled={isSaving}
|
||||
@@ -272,10 +317,55 @@ export function ProceduralEditorPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step List — flex-1, scrolls independently */}
|
||||
{/* Step List */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
<StepList />
|
||||
<StepList onStepContextMenu={editorAI.openContextMenu} />
|
||||
</div>
|
||||
|
||||
{editorAI.contextMenu && (
|
||||
<ContextMenu
|
||||
position={editorAI.contextMenu.position}
|
||||
items={[
|
||||
{
|
||||
id: 'generate-steps',
|
||||
label: 'Generate Steps After',
|
||||
icon: <Sparkles className="h-4 w-4" />,
|
||||
onClick: () => editorAI.triggerAction(
|
||||
editorAI.contextMenu!.nodeId,
|
||||
'add_steps',
|
||||
`Generate steps after this step`
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'expand-step',
|
||||
label: 'Expand Step',
|
||||
icon: <Layers className="h-4 w-4" />,
|
||||
onClick: () => editorAI.triggerAction(
|
||||
editorAI.contextMenu!.nodeId,
|
||||
'quick_action',
|
||||
`Expand this step into detailed substeps`
|
||||
),
|
||||
},
|
||||
]}
|
||||
onClose={editorAI.closeContextMenu}
|
||||
/>
|
||||
)}
|
||||
</div>{/* end main content column */}
|
||||
|
||||
<EditorAIPanel
|
||||
isOpen={editorAI.isOpen}
|
||||
onClose={editorAI.closePanel}
|
||||
focalNode={null}
|
||||
flowName={name}
|
||||
flowType={isMaintenance ? 'maintenance' : 'procedural'}
|
||||
nodeCount={steps.length}
|
||||
messages={editorAI.messages}
|
||||
input={editorAI.input}
|
||||
onInputChange={editorAI.setInput}
|
||||
onSend={editorAI.sendMessage}
|
||||
isLoading={editorAI.isLoading}
|
||||
suggestions={editorAI.suggestions}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -599,7 +599,7 @@ export function ProceduralNavigationPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right panel - step detail */}
|
||||
{/* Right panel - step detail + copilot */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-y-contain p-4 sm:p-6">
|
||||
{currentStep && (
|
||||
<StepDetail
|
||||
@@ -636,6 +636,17 @@ export function ProceduralNavigationPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AI Copilot - in-flow panel */}
|
||||
{treeId && copilotOpen && (
|
||||
<CopilotPanel
|
||||
isOpen={copilotOpen}
|
||||
onClose={() => setCopilotOpen(false)}
|
||||
treeId={treeId}
|
||||
sessionId={session?.id}
|
||||
currentNodeId={runtimeSteps[currentStepIndex]?.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* CSAT Modal */}
|
||||
@@ -708,18 +719,9 @@ export function ProceduralNavigationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Copilot */}
|
||||
{/* AI Copilot toggle button */}
|
||||
{treeId && (
|
||||
<>
|
||||
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
|
||||
<CopilotPanel
|
||||
isOpen={copilotOpen}
|
||||
onClose={() => setCopilotOpen(false)}
|
||||
treeId={treeId}
|
||||
sessionId={session?.id}
|
||||
currentNodeId={runtimeSteps[currentStepIndex]?.id}
|
||||
/>
|
||||
</>
|
||||
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -17,7 +17,7 @@ import { TreeGridView } from '@/components/library/TreeGridView'
|
||||
import { TreeListView } from '@/components/library/TreeListView'
|
||||
import { TreeTableView } from '@/components/library/TreeTableView'
|
||||
import { ViewToggle } from '@/components/library/ViewToggle'
|
||||
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
|
||||
|
||||
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
@@ -67,7 +67,6 @@ export function QuickStartPage() {
|
||||
const [showAllFavorites, setShowAllFavorites] = useState(false)
|
||||
|
||||
// AI Builder
|
||||
const [showAIBuilder, setShowAIBuilder] = useState(false)
|
||||
const { aiEnabled } = useCachedQuota()
|
||||
|
||||
// Tab state
|
||||
@@ -454,7 +453,7 @@ export function QuickStartPage() {
|
||||
{activeTab === 'mine' && canCreateTrees && (
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
|
||||
/>
|
||||
)}
|
||||
<ViewToggle view={dashboardMyFlowsView} onChange={setDashboardMyFlowsView} />
|
||||
@@ -481,7 +480,7 @@ export function QuickStartPage() {
|
||||
{activeTab === 'mine' && canCreateTrees && (
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
|
||||
label="Create your first flow"
|
||||
/>
|
||||
)}
|
||||
@@ -646,13 +645,6 @@ export function QuickStartPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Builder Modal */}
|
||||
{showAIBuilder && (
|
||||
<AIFlowBuilderModal
|
||||
isOpen={showAIBuilder}
|
||||
onClose={() => setShowAIBuilder(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
|
||||
import { useStore } from 'zustand'
|
||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3, Settings } from 'lucide-react'
|
||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3, Settings, Download, Sparkles } from 'lucide-react'
|
||||
import { getMonacoEditor } from '@/components/tree-editor/code-mode'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { treeMarkdownApi } from '@/api/treeMarkdown'
|
||||
@@ -16,6 +16,11 @@ import { Spinner } from '@/components/common/Spinner'
|
||||
import { cn, safeGetItem } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel'
|
||||
import { ExportFlowModal } from '@/components/library/ExportFlowModal'
|
||||
import { EditorAIPanel } from '@/components/editor-ai/EditorAIPanel'
|
||||
import { ContextMenu } from '@/components/common/ContextMenu'
|
||||
import { useEditorAI } from '@/hooks/useEditorAI'
|
||||
import { findNodeInTree } from '@/store/treeEditorStore'
|
||||
|
||||
/** Recursively check if any node in the tree has type 'answer' */
|
||||
function hasAnswerNodes(node: TreeStructure): boolean {
|
||||
@@ -31,11 +36,13 @@ export function TreeEditorPage() {
|
||||
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
isDirty,
|
||||
isLoading,
|
||||
isSaving,
|
||||
validationErrors,
|
||||
editorMode,
|
||||
treeStructure,
|
||||
initNewTree,
|
||||
loadTree,
|
||||
loadDraft,
|
||||
@@ -48,7 +55,10 @@ export function TreeEditorPage() {
|
||||
setSaving,
|
||||
selectNode,
|
||||
updateNode,
|
||||
deleteNode,
|
||||
setEditorMode,
|
||||
getAllNodeIds,
|
||||
replaceTreeStructure,
|
||||
} = useTreeEditorStore()
|
||||
|
||||
// Access undo/redo from temporal store
|
||||
@@ -61,6 +71,8 @@ export function TreeEditorPage() {
|
||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
||||
const [isFixing, setIsFixing] = useState(false)
|
||||
const [fixProposals, setFixProposals] = useState<AIFixProposal[] | null>(null)
|
||||
const [showExportModal, setShowExportModal] = useState(false)
|
||||
const [importMetadata, setImportMetadata] = useState<Record<string, string | null> | null>(null)
|
||||
|
||||
// Mobile detection
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
@@ -71,6 +83,38 @@ export function TreeEditorPage() {
|
||||
return () => window.removeEventListener('resize', check)
|
||||
}, [])
|
||||
|
||||
// AI Assist panel
|
||||
const handleFlowUpdate = useCallback((workingTree: Record<string, unknown>) => {
|
||||
// For troubleshooting flows, working_tree is the tree structure directly
|
||||
if (workingTree.type && workingTree.id) {
|
||||
replaceTreeStructure(workingTree as unknown as TreeStructure)
|
||||
}
|
||||
}, [replaceTreeStructure])
|
||||
|
||||
const editorAI = useEditorAI({
|
||||
flowType: 'troubleshooting',
|
||||
treeId: id,
|
||||
getFlowContext: useCallback(() => {
|
||||
if (!treeStructure) return null
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
tree_structure: treeStructure as unknown as Record<string, unknown>,
|
||||
}
|
||||
}, [treeStructure, name, description]),
|
||||
onFlowUpdate: handleFlowUpdate,
|
||||
})
|
||||
|
||||
const previousEditingNodeRef = useRef<string | null>(null)
|
||||
|
||||
const handleAIPanelClose = useCallback(() => {
|
||||
editorAI.closePanel()
|
||||
if (previousEditingNodeRef.current) {
|
||||
setEditingNodeId(previousEditingNodeRef.current)
|
||||
previousEditingNodeRef.current = null
|
||||
}
|
||||
}, [editorAI])
|
||||
|
||||
// Calculate if there are blocking errors
|
||||
const hasBlockingErrors = validationErrors.some(e => e.severity === 'error')
|
||||
|
||||
@@ -166,6 +210,7 @@ export function TreeEditorPage() {
|
||||
}
|
||||
loadTree(tree)
|
||||
setTreeStatus(tree.status) // Load status from existing tree
|
||||
if (tree.import_metadata) setImportMetadata(tree.import_metadata)
|
||||
} catch (err) {
|
||||
console.error('Failed to load tree:', err)
|
||||
toast.error('Failed to load flow')
|
||||
@@ -477,7 +522,9 @@ export function TreeEditorPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Main content column */}
|
||||
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
||||
|
||||
{/* Draft Restore Prompt */}
|
||||
{showDraftPrompt && (
|
||||
@@ -687,6 +734,45 @@ export function TreeEditorPage() {
|
||||
)}
|
||||
|
||||
{/* Validate */}
|
||||
{isEditMode && (
|
||||
<button
|
||||
onClick={() => setShowExportModal(true)}
|
||||
title="Export flow as .rfflow file"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm font-medium text-muted-foreground',
|
||||
'hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Export
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* AI Assist toggle */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (editorAI.isOpen) {
|
||||
handleAIPanelClose()
|
||||
} else {
|
||||
if (editingNodeId) {
|
||||
previousEditingNodeRef.current = editingNodeId
|
||||
setEditingNodeId(null)
|
||||
}
|
||||
editorAI.openPanel()
|
||||
}
|
||||
}}
|
||||
title="Toggle AI Assist panel"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors',
|
||||
editorAI.isOpen
|
||||
? 'bg-primary/10 text-primary border-primary/30'
|
||||
: 'bg-card text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
AI Assist
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleManualValidate}
|
||||
disabled={isSaving}
|
||||
@@ -742,15 +828,29 @@ export function TreeEditorPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import provenance */}
|
||||
{importMetadata && (
|
||||
<div className="mx-4 mb-2 flex items-center gap-2 text-xs font-label text-muted-foreground">
|
||||
<FileText className="h-3 w-3" />
|
||||
<span>
|
||||
Imported{importMetadata.original_author_name ? ` from ${importMetadata.original_author_name}` : ''}
|
||||
{importMetadata.imported_at ? ` on ${new Date(importMetadata.imported_at).toLocaleDateString()}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Editor */}
|
||||
<TreeEditorLayout
|
||||
isMobile={isMobile}
|
||||
isMetadataOpen={isMetadataOpen}
|
||||
onCloseMetadata={() => setIsMetadataOpen(false)}
|
||||
editingNodeId={editingNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onSelectAnswerType={handleSelectAnswerType}
|
||||
/>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<TreeEditorLayout
|
||||
isMobile={isMobile}
|
||||
isMetadataOpen={isMetadataOpen}
|
||||
onCloseMetadata={() => setIsMetadataOpen(false)}
|
||||
editingNodeId={editorAI.isOpen ? null : editingNodeId}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onSelectAnswerType={handleSelectAnswerType}
|
||||
onNodeContextMenu={editorAI.openContextMenu}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Flow Analytics Panel (collapsible) */}
|
||||
{showAnalytics && id && (
|
||||
@@ -768,6 +868,87 @@ export function TreeEditorPage() {
|
||||
onClose={handleCloseFixModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Export Modal */}
|
||||
{showExportModal && id && (
|
||||
<ExportFlowModal
|
||||
treeId={id}
|
||||
treeName={name || 'flow'}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Context Menu */}
|
||||
{editorAI.contextMenu && (
|
||||
<ContextMenu
|
||||
position={editorAI.contextMenu.position}
|
||||
items={[
|
||||
{
|
||||
id: 'generate-branch',
|
||||
label: 'Generate Branch',
|
||||
icon: <Sparkles className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
if (editingNodeId) {
|
||||
previousEditingNodeRef.current = editingNodeId
|
||||
setEditingNodeId(null)
|
||||
}
|
||||
editorAI.triggerAction(
|
||||
editorAI.contextMenu!.nodeId,
|
||||
'generate_branch',
|
||||
`Generate a branch from this node`
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'explain',
|
||||
label: 'Explain Node',
|
||||
icon: <Sparkles className="h-4 w-4" />,
|
||||
onClick: () => {
|
||||
if (editingNodeId) {
|
||||
previousEditingNodeRef.current = editingNodeId
|
||||
setEditingNodeId(null)
|
||||
}
|
||||
editorAI.triggerAction(
|
||||
editorAI.contextMenu!.nodeId,
|
||||
'quick_action',
|
||||
`Explain what this node does`
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'sep1',
|
||||
label: '',
|
||||
onClick: () => {},
|
||||
separator: true,
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
label: 'Delete Node',
|
||||
variant: 'danger' as const,
|
||||
onClick: () => deleteNode(editorAI.contextMenu!.nodeId),
|
||||
},
|
||||
]}
|
||||
onClose={editorAI.closeContextMenu}
|
||||
/>
|
||||
)}
|
||||
</div>{/* end main content column */}
|
||||
|
||||
<EditorAIPanel
|
||||
isOpen={editorAI.isOpen}
|
||||
onClose={handleAIPanelClose}
|
||||
focalNode={editorAI.focalNodeId && treeStructure
|
||||
? findNodeInTree(editorAI.focalNodeId, treeStructure)
|
||||
: null}
|
||||
flowName={name}
|
||||
flowType="troubleshooting"
|
||||
nodeCount={treeStructure ? getAllNodeIds().length : 0}
|
||||
messages={editorAI.messages}
|
||||
input={editorAI.input}
|
||||
onInputChange={editorAI.setInput}
|
||||
onSend={editorAI.sendMessage}
|
||||
isLoading={editorAI.isLoading}
|
||||
suggestions={editorAI.suggestions}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { X, RotateCcw, Play, Sparkles } from 'lucide-react'
|
||||
import { X, RotateCcw, Play, FileUp } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { categoriesApi } from '@/api/categories'
|
||||
import { foldersApi } from '@/api/folders'
|
||||
@@ -8,6 +8,8 @@ import { sessionsApi } from '@/api/sessions'
|
||||
import type { TreeListItem, CategoryListItem, FolderListItem, Session } from '@/types'
|
||||
import { FolderEditModal } from '@/components/library/FolderEditModal'
|
||||
import { ForkModal } from '@/components/library/ForkModal'
|
||||
import { ExportFlowModal } from '@/components/library/ExportFlowModal'
|
||||
import { ImportFlowModal } from '@/components/library/ImportFlowModal'
|
||||
import { ConfirmDialog } from '@/components/common/ConfirmDialog'
|
||||
import { TreeGridView } from '@/components/library/TreeGridView'
|
||||
import { TreeListView } from '@/components/library/TreeListView'
|
||||
@@ -20,7 +22,6 @@ import { usePermissions } from '@/hooks/usePermissions'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
import { usePinnedFlowsStore } from '@/store/pinnedFlowsStore'
|
||||
import { useCachedQuota } from '@/hooks/useCachedQuota'
|
||||
import { AIFlowBuilderModal } from '@/components/ai-builder/AIFlowBuilderModal'
|
||||
import { CreateFlowDropdown } from '@/components/common/CreateFlowDropdown'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
@@ -76,8 +77,12 @@ export function TreeLibraryPage() {
|
||||
// Fork modal state
|
||||
const [forkTarget, setForkTarget] = useState<TreeListItem | null>(null)
|
||||
|
||||
// Import/Export modal state
|
||||
const [showImportModal, setShowImportModal] = useState(false)
|
||||
const [exportTarget, setExportTarget] = useState<TreeListItem | null>(null)
|
||||
|
||||
// AI builder state
|
||||
const [showAIBuilder, setShowAIBuilder] = useState(false)
|
||||
|
||||
const { aiEnabled } = useCachedQuota()
|
||||
|
||||
// Pin store
|
||||
@@ -250,6 +255,11 @@ export function TreeLibraryPage() {
|
||||
if (tree) setForkTarget(tree)
|
||||
}
|
||||
|
||||
const handleExportTree = (treeId: string) => {
|
||||
const tree = trees.find((t) => t.id === treeId)
|
||||
if (tree) setExportTarget(tree)
|
||||
}
|
||||
|
||||
const hasActiveFilters =
|
||||
selectedCategoryId || selectedTags.length > 0 || searchQuery || selectedFolderId
|
||||
|
||||
@@ -273,18 +283,16 @@ export function TreeLibraryPage() {
|
||||
</div>
|
||||
{canCreateTrees && (
|
||||
<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" />
|
||||
Flow Assist
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setShowImportModal(true)}
|
||||
className="flex items-center gap-2 rounded-lg border border-border bg-[rgba(255,255,255,0.04)] px-4 py-2 text-sm font-medium text-foreground hover:border-[rgba(255,255,255,0.12)] transition-colors"
|
||||
>
|
||||
<FileUp className="h-4 w-4" />
|
||||
Import
|
||||
</button>
|
||||
<CreateFlowDropdown
|
||||
aiEnabled={aiEnabled}
|
||||
onOpenAIBuilder={() => setShowAIBuilder(true)}
|
||||
|
||||
label="Create New"
|
||||
/>
|
||||
</div>
|
||||
@@ -493,6 +501,7 @@ export function TreeLibraryPage() {
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
onForkTree={handleForkTree}
|
||||
onExportTree={handleExportTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
@@ -509,6 +518,7 @@ export function TreeLibraryPage() {
|
||||
setShowDeleteConfirm(true)
|
||||
}}
|
||||
onForkTree={handleForkTree}
|
||||
onExportTree={handleExportTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
@@ -530,6 +540,7 @@ export function TreeLibraryPage() {
|
||||
)
|
||||
}}
|
||||
onForkTree={handleForkTree}
|
||||
onExportTree={handleExportTree}
|
||||
pinnedTreeIds={pinnedTreeIds}
|
||||
onTogglePin={togglePin}
|
||||
pinLoadingTreeIds={pinLoadingTreeIds}
|
||||
@@ -567,13 +578,6 @@ export function TreeLibraryPage() {
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
|
||||
{/* AI Builder Modal */}
|
||||
{showAIBuilder && (
|
||||
<AIFlowBuilderModal
|
||||
isOpen={showAIBuilder}
|
||||
onClose={() => setShowAIBuilder(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{forkTarget && (
|
||||
<ForkModal
|
||||
@@ -582,6 +586,20 @@ export function TreeLibraryPage() {
|
||||
onClose={() => setForkTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{exportTarget && (
|
||||
<ExportFlowModal
|
||||
treeId={exportTarget.id}
|
||||
treeName={exportTarget.name}
|
||||
onClose={() => setExportTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showImportModal && (
|
||||
<ImportFlowModal
|
||||
onClose={() => { setShowImportModal(false); loadTrees() }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -626,9 +626,9 @@ export function TreeNavigationPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
<div className="h-full flex overflow-hidden">
|
||||
{/* Main Content */}
|
||||
<div className={cn('h-full overflow-y-auto px-4 py-8 transition-[padding] duration-200', scratchpadOpen && 'sm:pr-[440px]')}>
|
||||
<div className={cn('h-full flex-1 min-w-0 overflow-y-auto px-4 py-8 transition-[padding] duration-200', scratchpadOpen && 'sm:pr-[440px]')}>
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
@@ -1264,6 +1264,17 @@ export function TreeNavigationPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Copilot - in-flow panel */}
|
||||
{treeId && copilotOpen && (
|
||||
<CopilotPanel
|
||||
isOpen={copilotOpen}
|
||||
onClose={() => setCopilotOpen(false)}
|
||||
treeId={treeId}
|
||||
sessionId={session?.id}
|
||||
currentNodeId={currentNodeId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scratchpad Sidebar */}
|
||||
{session && (
|
||||
<ScratchpadSidebar
|
||||
@@ -1274,18 +1285,9 @@ export function TreeNavigationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AI Copilot */}
|
||||
{/* AI Copilot toggle button */}
|
||||
{treeId && (
|
||||
<>
|
||||
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
|
||||
<CopilotPanel
|
||||
isOpen={copilotOpen}
|
||||
onClose={() => setCopilotOpen(false)}
|
||||
treeId={treeId}
|
||||
sessionId={session?.id}
|
||||
currentNodeId={currentNodeId}
|
||||
/>
|
||||
</>
|
||||
<CopilotToggle isOpen={copilotOpen} onToggle={() => setCopilotOpen(true)} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -36,7 +36,6 @@ const TeamAnalyticsPage = lazy(() => import('@/pages/TeamAnalyticsPage'))
|
||||
const MyAnalyticsPage = lazy(() => import('@/pages/MyAnalyticsPage'))
|
||||
const FeedbackPage = lazy(() => import('@/pages/FeedbackPage'))
|
||||
const StepLibraryPage = lazy(() => import('@/pages/StepLibraryPage'))
|
||||
const AIChatBuilderPage = lazy(() => import('@/pages/AIChatBuilderPage'))
|
||||
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
|
||||
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
|
||||
const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage'))
|
||||
@@ -291,14 +290,6 @@ export const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'ai/chat',
|
||||
element: (
|
||||
<Suspense fallback={<PageLoader />}>
|
||||
<AIChatBuilderPage />
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'assistant',
|
||||
element: (
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import { AxiosError } from 'axios'
|
||||
import { aiChatApi } from '@/api/aiChat'
|
||||
import type {
|
||||
ChatMessage,
|
||||
InterviewPhase,
|
||||
TreeStructure,
|
||||
} from '@/types'
|
||||
|
||||
interface TreeMetadata {
|
||||
name?: string
|
||||
description?: string
|
||||
tags?: string[]
|
||||
category_id?: string
|
||||
}
|
||||
|
||||
interface AIChatState {
|
||||
// Session
|
||||
sessionId: string | null
|
||||
status: 'idle' | 'active' | 'completed' | 'abandoned'
|
||||
currentPhase: InterviewPhase
|
||||
flowType: 'troubleshooting' | 'procedural' | null
|
||||
|
||||
// Conversation
|
||||
messages: ChatMessage[]
|
||||
isResponding: boolean
|
||||
|
||||
// Progressive tree
|
||||
workingTree: TreeStructure | null
|
||||
treeMetadata: TreeMetadata | null
|
||||
|
||||
// Final generation
|
||||
generatedTree: TreeStructure | null
|
||||
isGenerating: boolean
|
||||
importedTreeId: string | null
|
||||
|
||||
// Error
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
startSession: (flowType: 'troubleshooting' | 'procedural') => Promise<void>
|
||||
sendMessage: (content: string) => Promise<void>
|
||||
generateTree: () => Promise<void>
|
||||
importToEditor: (params?: { name?: string; description?: string; category_id?: string; tags?: string[] }) => Promise<string>
|
||||
abandonSession: () => Promise<void>
|
||||
resumeSession: (sessionId: string) => Promise<void>
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
const initialState = {
|
||||
sessionId: null,
|
||||
status: 'idle' as const,
|
||||
currentPhase: 'scoping' as InterviewPhase,
|
||||
flowType: null,
|
||||
messages: [],
|
||||
isResponding: false,
|
||||
workingTree: null,
|
||||
treeMetadata: null,
|
||||
generatedTree: null,
|
||||
isGenerating: false,
|
||||
importedTreeId: null,
|
||||
error: null,
|
||||
}
|
||||
|
||||
function extractErrorMessage(e: unknown, fallback: string): string {
|
||||
if (e instanceof AxiosError && e.response?.data?.detail) {
|
||||
const detail = e.response.data.detail
|
||||
return typeof detail === 'string' ? detail : detail.message || fallback
|
||||
}
|
||||
if (e instanceof Error) return e.message
|
||||
return fallback
|
||||
}
|
||||
|
||||
export const useAIChatStore = create<AIChatState>((set, get) => ({
|
||||
...initialState,
|
||||
|
||||
startSession: async (flowType) => {
|
||||
set({ ...initialState, status: 'active', flowType, isResponding: true, error: null })
|
||||
|
||||
try {
|
||||
const response = await aiChatApi.startSession(flowType)
|
||||
set({
|
||||
sessionId: response.session_id,
|
||||
currentPhase: response.current_phase,
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: response.greeting,
|
||||
timestamp: new Date().toISOString(),
|
||||
}],
|
||||
isResponding: false,
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
set({ error: extractErrorMessage(e, 'Failed to start session'), isResponding: false, status: 'idle' })
|
||||
}
|
||||
},
|
||||
|
||||
sendMessage: async (content) => {
|
||||
const { sessionId, messages, isResponding } = get()
|
||||
if (!sessionId || isResponding) return
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
set({
|
||||
messages: [...messages, userMessage],
|
||||
isResponding: true,
|
||||
error: null,
|
||||
})
|
||||
|
||||
try {
|
||||
const response = await aiChatApi.sendMessage(sessionId, content)
|
||||
const aiMessage: ChatMessage = {
|
||||
role: 'assistant',
|
||||
content: response.content,
|
||||
timestamp: new Date().toISOString(),
|
||||
}
|
||||
|
||||
set((state) => ({
|
||||
messages: [...state.messages, aiMessage],
|
||||
currentPhase: response.current_phase,
|
||||
workingTree: (response.working_tree as TreeStructure | null) ?? state.workingTree,
|
||||
treeMetadata: (response.tree_metadata as TreeMetadata | null) ?? state.treeMetadata,
|
||||
isResponding: false,
|
||||
}))
|
||||
} catch (e: unknown) {
|
||||
set({ error: extractErrorMessage(e, 'Failed to send message'), isResponding: false })
|
||||
}
|
||||
},
|
||||
|
||||
generateTree: async () => {
|
||||
const { sessionId, isGenerating } = get()
|
||||
if (!sessionId || isGenerating) return
|
||||
|
||||
set({ isGenerating: true, error: null })
|
||||
|
||||
try {
|
||||
const response = await aiChatApi.generateTree(sessionId)
|
||||
set({
|
||||
generatedTree: response.tree_structure as unknown as TreeStructure,
|
||||
workingTree: response.tree_structure as unknown as TreeStructure,
|
||||
treeMetadata: response.tree_metadata as TreeMetadata,
|
||||
status: 'completed',
|
||||
isGenerating: false,
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
set({ error: extractErrorMessage(e, 'Failed to generate tree'), isGenerating: false })
|
||||
}
|
||||
},
|
||||
|
||||
importToEditor: async (params) => {
|
||||
const { sessionId } = get()
|
||||
if (!sessionId) throw new Error('No active session')
|
||||
|
||||
const response = await aiChatApi.importTree(sessionId, params)
|
||||
set({ importedTreeId: response.tree_id })
|
||||
return response.tree_id
|
||||
},
|
||||
|
||||
abandonSession: async () => {
|
||||
const { sessionId } = get()
|
||||
if (!sessionId) return
|
||||
|
||||
try {
|
||||
await aiChatApi.abandonSession(sessionId)
|
||||
} catch {
|
||||
// Best effort — session may have already expired
|
||||
}
|
||||
set({ ...initialState })
|
||||
},
|
||||
|
||||
resumeSession: async (sessionId) => {
|
||||
set({ isResponding: true, error: null })
|
||||
|
||||
try {
|
||||
const session = await aiChatApi.getSession(sessionId)
|
||||
set({
|
||||
sessionId: session.session_id,
|
||||
status: session.status === 'active' ? 'active' : (session.status as 'completed' | 'abandoned'),
|
||||
currentPhase: session.current_phase,
|
||||
flowType: session.flow_type,
|
||||
messages: session.conversation_history as ChatMessage[],
|
||||
workingTree: session.working_tree as TreeStructure | null,
|
||||
treeMetadata: session.tree_metadata as TreeMetadata | null,
|
||||
generatedTree: session.generated_tree as TreeStructure | null,
|
||||
isResponding: false,
|
||||
})
|
||||
} catch (e: unknown) {
|
||||
set({ error: extractErrorMessage(e, 'Failed to resume session'), isResponding: false })
|
||||
}
|
||||
},
|
||||
|
||||
reset: () => set({ ...initialState }),
|
||||
}))
|
||||
@@ -1,237 +0,0 @@
|
||||
import { create } from 'zustand'
|
||||
import { aiBuilderApi } from '@/api/aiBuilder'
|
||||
import type { AIQuotaStatus, AIBranch, AIAssembleResponse, AIWizardPhase } from '@/types'
|
||||
|
||||
interface AIFlowBuilderState {
|
||||
// Wizard state
|
||||
phase: AIWizardPhase
|
||||
conversationId: string | null
|
||||
metadata: {
|
||||
flow_type: 'troubleshooting' | 'procedural'
|
||||
name: string
|
||||
description: string
|
||||
environment_tags: string[]
|
||||
category_id: string | null
|
||||
}
|
||||
|
||||
// Stage 2
|
||||
suggestedBranches: AIBranch[]
|
||||
selectedBranches: AIBranch[]
|
||||
|
||||
// Stage 3
|
||||
currentBranchIndex: number
|
||||
|
||||
// Stage 4
|
||||
assembledTree: AIAssembleResponse | null
|
||||
|
||||
// Quota
|
||||
quota: AIQuotaStatus | null
|
||||
|
||||
// UI state
|
||||
isLoading: boolean
|
||||
isGeneratingAll: boolean
|
||||
stopGeneratingAll: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
loadQuota: () => Promise<void>
|
||||
setMetadata: (metadata: Partial<AIFlowBuilderState['metadata']>) => void
|
||||
start: () => Promise<void>
|
||||
scaffold: () => Promise<void>
|
||||
selectBranches: (branches: AIBranch[]) => void
|
||||
generateBranchDetail: (branchName: string) => Promise<void>
|
||||
assemble: () => Promise<void>
|
||||
generateAllBranchDetails: () => Promise<void>
|
||||
cancelGenerateAll: () => void
|
||||
reset: () => void
|
||||
setPhase: (phase: AIWizardPhase) => void
|
||||
setError: (error: string | null) => void
|
||||
}
|
||||
|
||||
const initialMetadata = {
|
||||
flow_type: 'troubleshooting' as const,
|
||||
name: '',
|
||||
description: '',
|
||||
environment_tags: [] as string[],
|
||||
category_id: null as string | null,
|
||||
}
|
||||
|
||||
export const useAIFlowBuilderStore = create<AIFlowBuilderState>()((set, get) => ({
|
||||
phase: 'foundation',
|
||||
conversationId: null,
|
||||
metadata: { ...initialMetadata },
|
||||
suggestedBranches: [],
|
||||
selectedBranches: [],
|
||||
currentBranchIndex: 0,
|
||||
assembledTree: null,
|
||||
quota: null,
|
||||
isLoading: false,
|
||||
isGeneratingAll: false,
|
||||
stopGeneratingAll: false,
|
||||
error: null,
|
||||
|
||||
loadQuota: async () => {
|
||||
try {
|
||||
const quota = await aiBuilderApi.getQuota()
|
||||
set({ quota })
|
||||
} catch {
|
||||
// Silently fail — quota display is optional
|
||||
}
|
||||
},
|
||||
|
||||
setMetadata: (metadata) => {
|
||||
set((state) => ({
|
||||
metadata: { ...state.metadata, ...metadata },
|
||||
}))
|
||||
},
|
||||
|
||||
start: async () => {
|
||||
const { metadata } = get()
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const response = await aiBuilderApi.start({
|
||||
flow_type: metadata.flow_type,
|
||||
name: metadata.name,
|
||||
description: metadata.description,
|
||||
environment_tags: metadata.environment_tags,
|
||||
category_id: metadata.category_id ?? undefined,
|
||||
})
|
||||
set({
|
||||
conversationId: response.conversation_id,
|
||||
phase: 'scaffolding',
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = _extractError(err)
|
||||
set({ error: message, isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
scaffold: async () => {
|
||||
const { conversationId } = get()
|
||||
if (!conversationId) return
|
||||
set({ isLoading: true, error: null, phase: 'generating' })
|
||||
try {
|
||||
const response = await aiBuilderApi.scaffold(conversationId)
|
||||
const branches: AIBranch[] = response.branches.map((b) => ({
|
||||
name: b.name,
|
||||
description: b.description,
|
||||
}))
|
||||
set({
|
||||
suggestedBranches: branches,
|
||||
selectedBranches: branches,
|
||||
phase: 'scaffolding',
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = _extractError(err)
|
||||
set({ error: message, phase: 'error', isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
selectBranches: (branches) => {
|
||||
set({ selectedBranches: branches })
|
||||
},
|
||||
|
||||
generateBranchDetail: async (branchName) => {
|
||||
const { conversationId, selectedBranches } = get()
|
||||
if (!conversationId) return
|
||||
set({ isLoading: true, error: null, phase: 'generating' })
|
||||
try {
|
||||
const response = await aiBuilderApi.branchDetail(conversationId, branchName)
|
||||
const updatedBranches = selectedBranches.map((b) =>
|
||||
b.name === branchName ? { ...b, steps: response.steps } : b
|
||||
)
|
||||
// Advance to the next branch that still needs detail
|
||||
const nextIndex = updatedBranches.findIndex((b) => !b.steps)
|
||||
const currentBranchIndex = nextIndex !== -1 ? nextIndex : updatedBranches.findIndex((b) => b.name === branchName)
|
||||
set({
|
||||
selectedBranches: updatedBranches,
|
||||
currentBranchIndex,
|
||||
phase: 'detailing',
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = _extractError(err)
|
||||
set({ error: message, phase: 'error', isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
assemble: async () => {
|
||||
const { conversationId, selectedBranches } = get()
|
||||
if (!conversationId) return
|
||||
set({ isLoading: true, error: null })
|
||||
try {
|
||||
const response = await aiBuilderApi.assemble(
|
||||
conversationId,
|
||||
selectedBranches.map((b) => ({
|
||||
name: b.name,
|
||||
description: b.description,
|
||||
steps: b.steps,
|
||||
}))
|
||||
)
|
||||
set({
|
||||
assembledTree: response,
|
||||
phase: 'reviewing',
|
||||
isLoading: false,
|
||||
})
|
||||
} catch (err) {
|
||||
const message = _extractError(err)
|
||||
set({ error: message, phase: 'error', isLoading: false })
|
||||
}
|
||||
},
|
||||
|
||||
generateAllBranchDetails: async () => {
|
||||
const { selectedBranches, generateBranchDetail } = get()
|
||||
const undetailed = selectedBranches.filter((b) => !b.steps)
|
||||
if (undetailed.length === 0) return
|
||||
|
||||
set({ isGeneratingAll: true, stopGeneratingAll: false, error: null })
|
||||
|
||||
for (const branch of undetailed) {
|
||||
if (get().stopGeneratingAll) break
|
||||
// Set currentBranchIndex so tabs show the active branch
|
||||
const idx = get().selectedBranches.findIndex((b) => b.name === branch.name)
|
||||
if (idx !== -1) set({ currentBranchIndex: idx })
|
||||
await generateBranchDetail(branch.name)
|
||||
// If generateBranchDetail set phase to 'error', stop
|
||||
if (get().phase === 'error') break
|
||||
}
|
||||
|
||||
set({ isGeneratingAll: false })
|
||||
},
|
||||
|
||||
cancelGenerateAll: () => {
|
||||
set({ stopGeneratingAll: true })
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
set({
|
||||
phase: 'foundation',
|
||||
conversationId: null,
|
||||
metadata: { ...initialMetadata },
|
||||
suggestedBranches: [],
|
||||
selectedBranches: [],
|
||||
currentBranchIndex: 0,
|
||||
assembledTree: null,
|
||||
isLoading: false,
|
||||
isGeneratingAll: false,
|
||||
stopGeneratingAll: false,
|
||||
error: null,
|
||||
})
|
||||
},
|
||||
|
||||
setPhase: (phase) => set({ phase }),
|
||||
setError: (error) => set({ error }),
|
||||
}))
|
||||
|
||||
function _extractError(err: unknown): string {
|
||||
if (err && typeof err === 'object' && 'response' in err) {
|
||||
const axiosErr = err as { response?: { data?: { detail?: string | { message?: string } } } }
|
||||
const detail = axiosErr.response?.data?.detail
|
||||
if (typeof detail === 'string') return detail
|
||||
if (detail && typeof detail === 'object' && 'message' in detail) return detail.message ?? 'Unknown error'
|
||||
}
|
||||
if (err instanceof Error) return err.message
|
||||
return 'An unexpected error occurred'
|
||||
}
|
||||
@@ -112,6 +112,9 @@ interface ProceduralEditorState {
|
||||
updateField: (index: number, updates: Partial<IntakeFormField>) => void
|
||||
moveField: (fromIndex: number, toIndex: number) => void
|
||||
|
||||
// Actions - AI Integration
|
||||
replaceSteps: (steps: ProceduralStep[], intakeForm?: IntakeFormField[]) => void
|
||||
|
||||
// Actions - Save
|
||||
setIsSaving: (saving: boolean) => void
|
||||
markSaved: () => void
|
||||
@@ -341,6 +344,15 @@ export const useProceduralEditorStore = create<ProceduralEditorState>()(
|
||||
})
|
||||
},
|
||||
|
||||
// AI Integration
|
||||
replaceSteps: (steps, intakeForm) => {
|
||||
set((state) => {
|
||||
state.steps = steps
|
||||
if (intakeForm) state.intakeForm = intakeForm
|
||||
state.isDirty = true
|
||||
})
|
||||
},
|
||||
|
||||
// Save
|
||||
setIsSaving: (saving) => set((state) => { state.isSaving = saving }),
|
||||
markSaved: () => set((state) => { state.isDirty = false }),
|
||||
|
||||
@@ -283,6 +283,9 @@ interface TreeEditorState {
|
||||
syncMarkdownToTree: () => void
|
||||
syncTreeToMarkdown: () => void
|
||||
|
||||
// Actions - AI Integration
|
||||
replaceTreeStructure: (structure: TreeStructure) => void
|
||||
|
||||
// Actions - State
|
||||
setLoading: (loading: boolean) => void
|
||||
setSaving: (saving: boolean) => void
|
||||
@@ -855,7 +858,7 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
|
||||
// Check for orphaned nodes (not root and not referenced)
|
||||
allNodeIds.forEach(id => {
|
||||
if (id !== 'root' && !referencedIds.has(id)) {
|
||||
if (id !== state.treeStructure?.id && !referencedIds.has(id)) {
|
||||
// Check if it's a direct child of another node (via children array)
|
||||
let isChildOfAny = false
|
||||
const checkIfChild = (node: TreeStructure) => {
|
||||
@@ -1015,6 +1018,15 @@ export const useTreeEditorStore = create<TreeEditorState>()(
|
||||
}
|
||||
},
|
||||
|
||||
// AI Integration
|
||||
replaceTreeStructure: (structure: TreeStructure) => {
|
||||
set((state) => {
|
||||
state.treeStructure = structure
|
||||
state.selectedNodeId = structure.id
|
||||
state.isDirty = true
|
||||
})
|
||||
},
|
||||
|
||||
setLoading: (loading: boolean) => {
|
||||
set((state) => { state.isLoading = loading })
|
||||
},
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
export type InterviewPhase = 'scoping' | 'discovery' | 'enrichment' | 'review' | 'generation'
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
export interface AIChatStartResponse {
|
||||
session_id: string
|
||||
greeting: string
|
||||
current_phase: InterviewPhase
|
||||
}
|
||||
|
||||
export interface AIChatMessageResponse {
|
||||
content: string
|
||||
current_phase: InterviewPhase
|
||||
working_tree: Record<string, unknown> | null
|
||||
tree_metadata: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface AIChatSessionResponse {
|
||||
session_id: string
|
||||
status: 'active' | 'completed' | 'abandoned'
|
||||
current_phase: InterviewPhase
|
||||
flow_type: 'troubleshooting' | 'procedural'
|
||||
conversation_history: ChatMessage[]
|
||||
working_tree: Record<string, unknown> | null
|
||||
tree_metadata: Record<string, unknown> | null
|
||||
message_count: number
|
||||
generated_tree: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface AIChatGenerateResponse {
|
||||
tree_structure: Record<string, unknown>
|
||||
tree_metadata: Record<string, unknown>
|
||||
status: string
|
||||
}
|
||||
|
||||
export interface AIChatImportResponse {
|
||||
tree_id: string
|
||||
tree_type: string
|
||||
}
|
||||
62
frontend/src/types/editor-ai.ts
Normal file
62
frontend/src/types/editor-ai.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export type AIActionType =
|
||||
| 'generate_full'
|
||||
| 'generate_branch'
|
||||
| 'modify_node'
|
||||
| 'add_steps'
|
||||
| 'quick_action'
|
||||
| 'open_chat'
|
||||
| 'variable_inference'
|
||||
|
||||
export interface AIDelta {
|
||||
action: 'add' | 'modify' | 'delete'
|
||||
target_node_id: string
|
||||
nodes: Record<string, unknown>[]
|
||||
explanation: string
|
||||
}
|
||||
|
||||
export interface AISuggestion {
|
||||
id: string
|
||||
action_type: AIActionType
|
||||
target_node_id: string | null
|
||||
changes_json: {
|
||||
before?: Record<string, unknown>
|
||||
after?: Record<string, unknown>
|
||||
delta?: AIDelta
|
||||
}
|
||||
status: 'pending' | 'accepted' | 'dismissed'
|
||||
created_at: string
|
||||
resolved_at: string | null
|
||||
}
|
||||
|
||||
export interface EditorAIChatMessage {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
timestamp: string
|
||||
action_type?: AIActionType
|
||||
delta?: AIDelta
|
||||
knowledge?: KnowledgeCitation[]
|
||||
}
|
||||
|
||||
export interface KnowledgeCitation {
|
||||
title: string
|
||||
url: string
|
||||
excerpt: string
|
||||
}
|
||||
|
||||
export interface ContextMenuAction {
|
||||
id: string
|
||||
label: string
|
||||
icon: string
|
||||
action_type: AIActionType
|
||||
description?: string
|
||||
}
|
||||
|
||||
export interface ContextMenuPosition {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface SuggestionMarker {
|
||||
_suggestion?: true
|
||||
_suggestion_id?: string
|
||||
}
|
||||
33
frontend/src/types/flowTransfer.ts
Normal file
33
frontend/src/types/flowTransfer.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export interface FlowExportCategory {
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
export interface FlowExportData {
|
||||
name: string
|
||||
description: string | null
|
||||
tree_type: 'troubleshooting' | 'procedural' | 'maintenance'
|
||||
version: number
|
||||
author_name: string | null
|
||||
category: FlowExportCategory | null
|
||||
tags: string[]
|
||||
tree_structure: Record<string, unknown>
|
||||
intake_form: Record<string, unknown>[] | null
|
||||
}
|
||||
|
||||
export interface RFFlowFile {
|
||||
rfflow_version: string
|
||||
exported_at: string
|
||||
source_app: string
|
||||
flow: FlowExportData
|
||||
}
|
||||
|
||||
export interface FlowImportResponse {
|
||||
tree_id: string
|
||||
name: string
|
||||
tree_type: string
|
||||
status: string
|
||||
category_created: boolean
|
||||
tags_created: string[]
|
||||
validation_warnings: string[]
|
||||
}
|
||||
@@ -55,12 +55,21 @@ export type {
|
||||
AIFixValidationError,
|
||||
} from './ai-fix'
|
||||
|
||||
|
||||
export type {
|
||||
InterviewPhase,
|
||||
ChatMessage,
|
||||
AIChatStartResponse,
|
||||
AIChatMessageResponse,
|
||||
AIChatSessionResponse,
|
||||
AIChatGenerateResponse,
|
||||
AIChatImportResponse,
|
||||
} from './ai-chat'
|
||||
RFFlowFile,
|
||||
FlowExportData,
|
||||
FlowExportCategory,
|
||||
FlowImportResponse,
|
||||
} from './flowTransfer'
|
||||
|
||||
export type {
|
||||
AIActionType,
|
||||
AIDelta,
|
||||
AISuggestion,
|
||||
EditorAIChatMessage,
|
||||
KnowledgeCitation,
|
||||
ContextMenuAction,
|
||||
ContextMenuPosition,
|
||||
SuggestionMarker,
|
||||
} from './editor-ai'
|
||||
|
||||
@@ -176,6 +176,12 @@ export interface Tree {
|
||||
updated_at: string
|
||||
usage_count: number
|
||||
fork_info: ForkInfo | null
|
||||
import_metadata: {
|
||||
original_author_name?: string | null
|
||||
exported_at?: string
|
||||
imported_at?: string
|
||||
source_app?: string
|
||||
} | null
|
||||
}
|
||||
|
||||
export interface TreeListItem {
|
||||
|
||||
Reference in New Issue
Block a user