feat: add AI chat builder frontend — types, API client, store, components, page, routing

- TypeScript types for chat session, messages, and responses
- API client module with all 6 endpoints
- Zustand store with session management, message sending, tree generation, import, resume
- 7 chat components: ChatMessage, ChatInput, ChatPanel, PhaseIndicator, ChatToolbar, EmptyPreview, StaticTreePreview
- AIChatBuilderPage with split-panel layout (60% chat / 40% preview)
- Route at /ai/chat with lazy loading
- "Build with AI" button on TreeLibraryPage
- Session resume via URL search params

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-27 07:20:04 -05:00
parent 0da67586da
commit 596153085a
15 changed files with 844 additions and 6 deletions

View File

@@ -0,0 +1,44 @@
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

View File

@@ -17,3 +17,4 @@ 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'

View File

@@ -0,0 +1,72 @@
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>
)
}

View File

@@ -0,0 +1,41 @@
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>
)
}

View File

@@ -0,0 +1,47 @@
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>
)
}

View File

@@ -0,0 +1,76 @@
import { Sparkles, Download, RotateCcw, ArrowRight } 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
onGenerate: () => void
onImport: () => void
onReset: () => void
}
export function ChatToolbar({
currentPhase,
status,
isGenerating,
hasGeneratedTree,
onGenerate,
onImport,
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" />
Build with AI
</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'
)}
>
<Download className="h-3.5 w-3.5" />
{isGenerating ? 'Generating...' : 'Generate Flow'}
</button>
)}
{hasGeneratedTree && (
<button
onClick={onImport}
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'
)}
>
<ArrowRight className="h-3.5 w-3.5" />
Import to Editor
</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>
)
}

View File

@@ -0,0 +1,13 @@
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>
)
}

View File

@@ -0,0 +1,50 @@
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>
)
}

View File

@@ -0,0 +1,80 @@
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>
)
}

View File

@@ -0,0 +1,151 @@
import { useCallback, useEffect } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { useAIChatStore } from '@/store/aiChatStore'
import { ChatPanel } from '@/components/ai-chat/ChatPanel'
import { ChatToolbar } from '@/components/ai-chat/ChatToolbar'
import { EmptyPreview } from '@/components/ai-chat/EmptyPreview'
import { StaticTreePreview } from '@/components/ai-chat/StaticTreePreview'
import { Spinner } from '@/components/common/Spinner'
import { getTreeEditorPath } from '@/lib/routing'
import { toast } from '@/lib/toast'
import type { TreeStructure } from '@/types'
export function AIChatBuilderPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const flowType = searchParams.get('type') === 'procedural' ? 'procedural' : 'troubleshooting'
const {
sessionId,
status,
currentPhase,
messages,
isResponding,
workingTree,
treeMetadata,
generatedTree,
isGenerating,
error,
startSession,
sendMessage,
generateTree,
importToEditor,
abandonSession,
resumeSession,
} = useAIChatStore()
// Start or resume session on mount
useEffect(() => {
const resumeId = searchParams.get('session')
if (resumeId && !sessionId) {
resumeSession(resumeId)
} else if (!sessionId && status === 'idle') {
startSession(flowType as 'troubleshooting' | 'procedural')
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
// Store sessionId in URL for resume support
useEffect(() => {
if (sessionId && !searchParams.get('session')) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev)
next.set('session', sessionId)
return next
}, { replace: true })
}
}, [sessionId, searchParams, setSearchParams])
const handleSendMessage = useCallback(
(content: string) => {
sendMessage(content)
},
[sendMessage]
)
const handleGenerate = useCallback(() => {
generateTree()
}, [generateTree])
const handleImport = useCallback(async () => {
try {
const treeId = await importToEditor({
name: treeMetadata?.name,
description: treeMetadata?.description,
tags: treeMetadata?.tags,
})
const path = getTreeEditorPath(treeId, flowType)
navigate(path)
toast.success('Flow imported to editor')
} catch {
toast.error('Failed to import flow')
}
}, [importToEditor, treeMetadata, flowType, navigate])
const handleReset = useCallback(() => {
abandonSession()
// Clear session from URL
setSearchParams((prev) => {
const next = new URLSearchParams(prev)
next.delete('session')
return next
}, { replace: true })
startSession(flowType as 'troubleshooting' | 'procedural')
}, [abandonSession, startSession, flowType, setSearchParams])
// Show error toast
useEffect(() => {
if (error) {
toast.error(error)
}
}, [error])
if (status === 'idle' && !sessionId) {
return (
<div className="flex h-full items-center justify-center">
<Spinner />
</div>
)
}
const previewTree = (generatedTree || workingTree) as TreeStructure | null
return (
<div className="flex h-full flex-col">
<ChatToolbar
currentPhase={currentPhase}
status={status}
isGenerating={isGenerating}
hasGeneratedTree={!!generatedTree}
onGenerate={handleGenerate}
onImport={handleImport}
onReset={handleReset}
/>
<div className="flex flex-1 overflow-hidden">
{/* Left panel: Chat (60%) */}
<div className="flex w-3/5 flex-col border-r border-border max-lg:w-full">
<ChatPanel
messages={messages}
isResponding={isResponding}
onSendMessage={handleSendMessage}
disabled={status !== 'active'}
/>
</div>
{/* Right panel: Tree preview (40%) — hidden below 1024px */}
<div className="w-2/5 overflow-hidden bg-background max-lg:hidden">
{previewTree ? (
<StaticTreePreview
tree={previewTree}
name={treeMetadata?.name}
/>
) : (
<EmptyPreview />
)}
</div>
</div>
</div>
)
}
export default AIChatBuilderPage

View File

@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback, useMemo } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { X, RotateCcw, Play } from 'lucide-react'
import { X, RotateCcw, Play, Sparkles } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { categoriesApi } from '@/api/categories'
import { foldersApi } from '@/api/folders'
@@ -272,11 +272,22 @@ export function TreeLibraryPage() {
</p>
</div>
{canCreateTrees && (
<CreateFlowDropdown
aiEnabled={aiEnabled}
onOpenAIBuilder={() => setShowAIBuilder(true)}
label="Create New"
/>
<div className="flex items-center gap-2">
{aiEnabled && (
<button
onClick={() => navigate('/ai/chat')}
className="flex items-center gap-2 rounded-lg bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 transition-opacity"
>
<Sparkles className="h-4 w-4" />
Build with AI
</button>
)}
<CreateFlowDropdown
aiEnabled={aiEnabled}
onOpenAIBuilder={() => setShowAIBuilder(true)}
label="Create New"
/>
</div>
)}
</div>

View File

@@ -33,6 +33,7 @@ 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 AccountSettingsPage = lazy(() => import('@/pages/AccountSettingsPage'))
// Admin pages
const AdminLayout = lazy(() => import('@/components/admin/AdminLayout'))
@@ -253,6 +254,14 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: 'ai/chat',
element: (
<Suspense fallback={<PageLoader />}>
<AIChatBuilderPage />
</Suspense>
),
},
// Admin routes
{
path: 'admin',

View File

@@ -0,0 +1,190 @@
import { create } from 'zustand'
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,
}
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) {
const message = e instanceof Error ? e.message : 'Failed to start session'
set({ error: message, isResponding: false, status: 'idle' })
}
},
sendMessage: async (content) => {
const { sessionId, messages } = get()
if (!sessionId) 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) {
const message = e instanceof Error ? e.message : 'Failed to send message'
set({ error: message, isResponding: false })
}
},
generateTree: async () => {
const { sessionId } = get()
if (!sessionId) 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) {
const message = e instanceof Error ? e.message : 'Failed to generate tree'
set({ error: message, 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) {
const message = e instanceof Error ? e.message : 'Failed to resume session'
set({ error: message, isResponding: false })
}
},
reset: () => set({ ...initialState }),
}))

View File

@@ -0,0 +1,43 @@
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
}

View File

@@ -52,3 +52,13 @@ export type {
AIFixProposal,
AIFixValidationError,
} from './ai-fix'
export type {
InterviewPhase,
ChatMessage,
AIChatStartResponse,
AIChatMessageResponse,
AIChatSessionResponse,
AIChatGenerateResponse,
AIChatImportResponse,
} from './ai-chat'