feat(ai-session): add FlowPilot AI-powered troubleshooting sessions
Implements Phase 1 of the FlowPilot-First pivot — the core AI session experience where engineers describe a problem and FlowPilot guides them through structured diagnosis with selectable options, free-text escape hatches, and auto-generated documentation on resolution. Backend: AISession + AISessionStep models, FlowPilot Engine (LLM orchestration with structured JSON output), Flow Matching Engine v1 (semantic + keyword + recency scoring), 8 API endpoints with auth, rate limiting, and AI quota enforcement. Frontend: Intake screen, conversational session view with sidebar, step cards with options/actions/resolution suggestions, resolve/escalate modals, documentation view with rating, session history integration, and /pilot route with sidebar navigation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
67
frontend/src/api/aiSessions.ts
Normal file
67
frontend/src/api/aiSessions.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import apiClient from './client'
|
||||
import type {
|
||||
AISessionCreateRequest,
|
||||
AISessionCreateResponse,
|
||||
StepResponseRequest,
|
||||
StepResponseResponse,
|
||||
ResolveSessionRequest,
|
||||
EscalateSessionRequest,
|
||||
SessionCloseResponse,
|
||||
SessionDocumentation,
|
||||
AISessionSummary,
|
||||
AISessionDetail,
|
||||
} from '@/types/ai-session'
|
||||
|
||||
export const aiSessionsApi = {
|
||||
async createSession(data: AISessionCreateRequest): Promise<AISessionCreateResponse> {
|
||||
const response = await apiClient.post<AISessionCreateResponse>('/ai-sessions', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async respondToStep(sessionId: string, data: StepResponseRequest): Promise<StepResponseResponse> {
|
||||
const response = await apiClient.post<StepResponseResponse>(
|
||||
`/ai-sessions/${sessionId}/respond`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async resolveSession(sessionId: string, data: ResolveSessionRequest): Promise<SessionCloseResponse> {
|
||||
const response = await apiClient.post<SessionCloseResponse>(
|
||||
`/ai-sessions/${sessionId}/resolve`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async escalateSession(sessionId: string, data: EscalateSessionRequest): Promise<SessionCloseResponse> {
|
||||
const response = await apiClient.post<SessionCloseResponse>(
|
||||
`/ai-sessions/${sessionId}/escalate`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async listSessions(params?: { status?: string; skip?: number; limit?: number }): Promise<AISessionSummary[]> {
|
||||
const response = await apiClient.get<AISessionSummary[]>('/ai-sessions', { params })
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getSession(sessionId: string): Promise<AISessionDetail> {
|
||||
const response = await apiClient.get<AISessionDetail>(`/ai-sessions/${sessionId}`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getDocumentation(sessionId: string): Promise<SessionDocumentation> {
|
||||
const response = await apiClient.get<SessionDocumentation>(
|
||||
`/ai-sessions/${sessionId}/documentation`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async rateSession(sessionId: string, data: { rating: number; feedback?: string }): Promise<void> {
|
||||
await apiClient.post(`/ai-sessions/${sessionId}/rate`, data)
|
||||
},
|
||||
}
|
||||
|
||||
export default aiSessionsApi
|
||||
@@ -24,3 +24,4 @@ export { scriptsApi } from './scripts'
|
||||
export { integrationsApi, sessionPsaApi } from './integrations'
|
||||
export { sidebarApi } from './sidebar'
|
||||
export { sessionToFlowApi } from './sessionToFlow'
|
||||
export { aiSessionsApi } from './aiSessions'
|
||||
|
||||
60
frontend/src/components/flowpilot/AISessionListItem.tsx
Normal file
60
frontend/src/components/flowpilot/AISessionListItem.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Clock, CheckCircle2, ArrowUpRight, AlertCircle, Pause } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AISessionSummary } from '@/types/ai-session'
|
||||
|
||||
interface AISessionListItemProps {
|
||||
session: AISessionSummary
|
||||
}
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
active: { icon: Clock, color: 'text-primary', label: 'Active' },
|
||||
paused: { icon: Pause, color: 'text-amber-400', label: 'Paused' },
|
||||
resolved: { icon: CheckCircle2, color: 'text-emerald-400', label: 'Resolved' },
|
||||
escalated: { icon: ArrowUpRight, color: 'text-amber-400', label: 'Escalated' },
|
||||
abandoned: { icon: AlertCircle, color: 'text-[#5a6170]', label: 'Abandoned' },
|
||||
} as const
|
||||
|
||||
export function AISessionListItem({ session }: AISessionListItemProps) {
|
||||
const config = STATUS_CONFIG[session.status as keyof typeof STATUS_CONFIG] ?? STATUS_CONFIG.active
|
||||
const StatusIcon = config.icon
|
||||
|
||||
return (
|
||||
<Link
|
||||
to={`/pilot/${session.id}`}
|
||||
className="glass-card block p-4 transition-all"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-foreground truncate">
|
||||
{session.problem_summary || 'Untitled session'}
|
||||
</p>
|
||||
<div className="mt-1.5 flex items-center gap-3 flex-wrap">
|
||||
{session.problem_domain && (
|
||||
<span className="font-label rounded-md bg-primary/10 px-2 py-0.5 text-[0.625rem] uppercase tracking-wider text-primary">
|
||||
{session.problem_domain}
|
||||
</span>
|
||||
)}
|
||||
<span className={cn('flex items-center gap-1 text-xs', config.color)}>
|
||||
<StatusIcon size={12} />
|
||||
{config.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{session.step_count} steps
|
||||
</span>
|
||||
<span className="text-xs text-[#5a6170]">
|
||||
{new Date(session.created_at).toLocaleDateString(undefined, {
|
||||
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{session.session_rating && (
|
||||
<span className="font-label text-xs text-amber-400">
|
||||
{'★'.repeat(session.session_rating)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
44
frontend/src/components/flowpilot/ConfidenceIndicator.tsx
Normal file
44
frontend/src/components/flowpilot/ConfidenceIndicator.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ConfidenceIndicatorProps {
|
||||
tier: string
|
||||
score: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
const TIER_CONFIG = {
|
||||
guided: {
|
||||
color: 'bg-emerald-400',
|
||||
label: 'Proven path',
|
||||
description: 'FlowPilot is following a known resolution path with high confidence.',
|
||||
},
|
||||
exploring: {
|
||||
color: 'bg-amber-400',
|
||||
label: 'Investigating',
|
||||
description: 'FlowPilot is narrowing down the issue based on your responses.',
|
||||
},
|
||||
discovery: {
|
||||
color: 'bg-violet-400',
|
||||
label: 'New territory',
|
||||
description: 'FlowPilot is exploring this issue — your responses help build the knowledge base.',
|
||||
},
|
||||
} as const
|
||||
|
||||
export function ConfidenceIndicator({ tier, score, className }: ConfidenceIndicatorProps) {
|
||||
const config = TIER_CONFIG[tier as keyof typeof TIER_CONFIG] ?? TIER_CONFIG.discovery
|
||||
|
||||
return (
|
||||
<div className={cn('group relative inline-flex items-center gap-2', className)}>
|
||||
<span className={cn('h-2 w-2 rounded-full', config.color)} />
|
||||
<span className="font-label text-xs text-muted-foreground">{config.label}</span>
|
||||
|
||||
{/* Tooltip */}
|
||||
<div className="pointer-events-none absolute left-0 top-full z-50 mt-2 w-56 rounded-lg border border-border bg-card p-3 opacity-0 shadow-lg transition-opacity group-hover:pointer-events-auto group-hover:opacity-100">
|
||||
<p className="text-xs text-muted-foreground">{config.description}</p>
|
||||
<p className="mt-1 font-label text-[0.625rem] text-[#5a6170]">
|
||||
Confidence: {Math.round(score * 100)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
140
frontend/src/components/flowpilot/FlowPilotActionBar.tsx
Normal file
140
frontend/src/components/flowpilot/FlowPilotActionBar.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { useState } from 'react'
|
||||
import { CheckCircle2, ArrowUpRight } from 'lucide-react'
|
||||
import type { ResolveSessionRequest, EscalateSessionRequest, SessionDocumentation } from '@/types/ai-session'
|
||||
|
||||
interface FlowPilotActionBarProps {
|
||||
canResolve: boolean
|
||||
canEscalate: boolean
|
||||
isProcessing: boolean
|
||||
onResolve: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
|
||||
onEscalate: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
|
||||
}
|
||||
|
||||
export function FlowPilotActionBar({
|
||||
canResolve,
|
||||
canEscalate,
|
||||
isProcessing,
|
||||
onResolve,
|
||||
onEscalate,
|
||||
}: FlowPilotActionBarProps) {
|
||||
const [showResolve, setShowResolve] = useState(false)
|
||||
const [showEscalate, setShowEscalate] = useState(false)
|
||||
const [resolutionSummary, setResolutionSummary] = useState('')
|
||||
const [escalationReason, setEscalationReason] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const handleResolve = async () => {
|
||||
if (!resolutionSummary.trim() || resolutionSummary.length < 5) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onResolve({ resolution_summary: resolutionSummary })
|
||||
setShowResolve(false)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEscalate = async () => {
|
||||
if (!escalationReason.trim() || escalationReason.length < 5) return
|
||||
setSubmitting(true)
|
||||
try {
|
||||
await onEscalate({ escalation_reason: escalationReason })
|
||||
setShowEscalate(false)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Bottom bar */}
|
||||
<div
|
||||
className="flex items-center gap-3 border-t px-5 py-3"
|
||||
style={{ borderColor: 'var(--glass-border)', background: 'rgba(16, 17, 20, 0.8)', backdropFilter: 'blur(12px)' }}
|
||||
>
|
||||
<button
|
||||
onClick={() => { setShowResolve(true); setShowEscalate(false) }}
|
||||
disabled={!canResolve || isProcessing}
|
||||
className="flex items-center gap-2 rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-4 py-2 text-sm font-medium text-emerald-400 hover:bg-emerald-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<CheckCircle2 size={16} />
|
||||
Resolve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowEscalate(true); setShowResolve(false) }}
|
||||
disabled={!canEscalate || isProcessing}
|
||||
className="flex items-center gap-2 rounded-lg bg-amber-500/10 border border-amber-500/20 px-4 py-2 text-sm font-medium text-amber-400 hover:bg-amber-500/20 disabled:opacity-40 disabled:pointer-events-none transition-colors"
|
||||
>
|
||||
<ArrowUpRight size={16} />
|
||||
Escalate
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Resolve modal */}
|
||||
{showResolve && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="glass-card-static w-full max-w-lg p-6">
|
||||
<h3 className="font-heading text-lg font-semibold text-foreground mb-1">Resolve Session</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">Summarize what fixed the issue. This will be included in the auto-generated documentation.</p>
|
||||
<textarea
|
||||
value={resolutionSummary}
|
||||
onChange={(e) => setResolutionSummary(e.target.value)}
|
||||
placeholder="What resolved the issue?"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
|
||||
rows={4}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowResolve(false)}
|
||||
className="rounded-lg px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleResolve}
|
||||
disabled={resolutionSummary.length < 5 || submitting}
|
||||
className="rounded-lg bg-emerald-500/20 border border-emerald-500/30 px-4 py-2 text-sm font-medium text-emerald-400 hover:bg-emerald-500/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? 'Resolving...' : 'Resolve Session'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Escalate modal */}
|
||||
{showEscalate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div className="glass-card-static w-full max-w-lg p-6">
|
||||
<h3 className="font-heading text-lg font-semibold text-foreground mb-1">Escalate Session</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">Explain why this needs escalation. FlowPilot will package the context for the next engineer.</p>
|
||||
<textarea
|
||||
value={escalationReason}
|
||||
onChange={(e) => setEscalationReason(e.target.value)}
|
||||
placeholder="Why does this need to be escalated?"
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
|
||||
rows={4}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowEscalate(false)}
|
||||
className="rounded-lg px-4 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleEscalate}
|
||||
disabled={escalationReason.length < 5 || submitting}
|
||||
className="rounded-lg bg-amber-500/20 border border-amber-500/30 px-4 py-2 text-sm font-medium text-amber-400 hover:bg-amber-500/30 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{submitting ? 'Escalating...' : 'Escalate Session'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
119
frontend/src/components/flowpilot/FlowPilotIntake.tsx
Normal file
119
frontend/src/components/flowpilot/FlowPilotIntake.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useState } from 'react'
|
||||
import { Sparkles, FileText, Terminal } from 'lucide-react'
|
||||
import type { AISessionCreateRequest } from '@/types/ai-session'
|
||||
|
||||
interface FlowPilotIntakeProps {
|
||||
onSubmit: (request: AISessionCreateRequest) => void
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
export function FlowPilotIntake({ onSubmit, isLoading }: FlowPilotIntakeProps) {
|
||||
const [text, setText] = useState('')
|
||||
const [showLogs, setShowLogs] = useState(false)
|
||||
const [logContent, setLogContent] = useState('')
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!text.trim() && !logContent.trim()) return
|
||||
|
||||
const intake_content: Record<string, unknown> = {}
|
||||
if (text.trim()) intake_content.text = text.trim()
|
||||
if (logContent.trim()) intake_content.log_content = logContent.trim()
|
||||
|
||||
const intake_type = logContent.trim()
|
||||
? text.trim() ? 'combined' : 'log_paste'
|
||||
: 'free_text'
|
||||
|
||||
onSubmit({ intake_type, intake_content })
|
||||
}
|
||||
|
||||
const hasContent = text.trim() || logContent.trim()
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10">
|
||||
<Sparkles size={24} className="text-primary animate-pulse" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-foreground">Analyzing your issue...</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">FlowPilot is classifying the problem and searching for relevant flows</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-center pt-[10vh]">
|
||||
<div className="w-full max-w-2xl">
|
||||
<div className="text-center mb-6">
|
||||
<h1 className="font-heading text-2xl font-bold tracking-tight text-foreground">
|
||||
What are you troubleshooting?
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Describe the issue, paste an error message, or paste log output
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="glass-card-static p-5 space-y-4">
|
||||
{/* Main text area */}
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
placeholder="e.g. User can't access shared drive after password reset, getting 'Access Denied' in Event Viewer..."
|
||||
className="w-full rounded-lg border border-border bg-card 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"
|
||||
rows={5}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Input type toggles */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
className={`flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
showLogs
|
||||
? 'bg-primary/10 text-primary border border-primary/20'
|
||||
: 'bg-card/50 text-muted-foreground border border-border hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<Terminal size={12} />
|
||||
Paste Logs
|
||||
</button>
|
||||
<button
|
||||
disabled
|
||||
className="flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium bg-card/50 text-[#5a6170] border border-border opacity-50 cursor-not-allowed"
|
||||
title="Coming in Phase 2"
|
||||
>
|
||||
<FileText size={12} />
|
||||
Pull from Ticket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Log paste area */}
|
||||
{showLogs && (
|
||||
<textarea
|
||||
value={logContent}
|
||||
onChange={(e) => setLogContent(e.target.value)}
|
||||
placeholder="Paste log output, error messages, or Event Viewer entries here..."
|
||||
className="w-full rounded-lg border border-border bg-card px-4 py-3 font-mono text-xs text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
|
||||
rows={6}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[0.6875rem] text-[#5a6170]">
|
||||
FlowPilot will analyze your input and guide you through diagnosis
|
||||
</p>
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!hasContent}
|
||||
className="rounded-lg bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] disabled:opacity-40 disabled:shadow-none transition-all"
|
||||
>
|
||||
Start Session
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
frontend/src/components/flowpilot/FlowPilotOptions.tsx
Normal file
58
frontend/src/components/flowpilot/FlowPilotOptions.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { StepOptionSchema } from '@/types/ai-session'
|
||||
|
||||
interface FlowPilotOptionsProps {
|
||||
options: StepOptionSchema[]
|
||||
onSelect: (value: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function FlowPilotOptions({ options, onSelect, disabled }: FlowPilotOptionsProps) {
|
||||
const [selected, setSelected] = useState<string | null>(null)
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (disabled) return
|
||||
setSelected(value)
|
||||
onSelect(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{options.map((option) => {
|
||||
const isSelected = selected === option.value
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
onClick={() => handleSelect(option.value)}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'group relative rounded-xl border p-4 text-left transition-all',
|
||||
'hover:border-[rgba(6,182,212,0.3)] hover:shadow-[0_0_20px_rgba(6,182,212,0.08)]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40',
|
||||
isSelected
|
||||
? 'border-primary/40 bg-primary/10'
|
||||
: 'border-border bg-card/50',
|
||||
disabled && 'pointer-events-none opacity-60'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-foreground">{option.label}</p>
|
||||
{option.followup_hint && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{option.followup_hint}</p>
|
||||
)}
|
||||
</div>
|
||||
{isSelected && (
|
||||
<span className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/20 text-primary">
|
||||
<Check size={12} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
171
frontend/src/components/flowpilot/FlowPilotSession.tsx
Normal file
171
frontend/src/components/flowpilot/FlowPilotSession.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { Network, Clock, Hash } from 'lucide-react'
|
||||
import type {
|
||||
AISessionDetail,
|
||||
AISessionStepResponse,
|
||||
StepResponseRequest,
|
||||
ResolveSessionRequest,
|
||||
EscalateSessionRequest,
|
||||
SessionDocumentation,
|
||||
} from '@/types/ai-session'
|
||||
import { ConfidenceIndicator } from './ConfidenceIndicator'
|
||||
import { FlowPilotStepCard } from './FlowPilotStepCard'
|
||||
import { FlowPilotActionBar } from './FlowPilotActionBar'
|
||||
import { SessionDocView } from './SessionDocView'
|
||||
|
||||
interface FlowPilotSessionProps {
|
||||
session: AISessionDetail
|
||||
allSteps: AISessionStepResponse[]
|
||||
currentStep: AISessionStepResponse | null
|
||||
isProcessing: boolean
|
||||
canResolve: boolean
|
||||
canEscalate: boolean
|
||||
documentation: SessionDocumentation | null
|
||||
onRespond: (response: StepResponseRequest) => void
|
||||
onResolve: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
|
||||
onEscalate: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
|
||||
onRate: (rating: number) => void
|
||||
}
|
||||
|
||||
export function FlowPilotSession({
|
||||
session,
|
||||
allSteps,
|
||||
currentStep,
|
||||
isProcessing,
|
||||
canResolve,
|
||||
canEscalate,
|
||||
documentation,
|
||||
onRespond,
|
||||
onResolve,
|
||||
onEscalate,
|
||||
onRate,
|
||||
}: FlowPilotSessionProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Auto-scroll to latest step
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [allSteps.length])
|
||||
|
||||
const isCompleted = session.status === 'resolved' || session.status === 'escalated'
|
||||
|
||||
// Show documentation view for completed sessions
|
||||
if (isCompleted && documentation) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<SessionDocView
|
||||
documentation={documentation}
|
||||
onRate={onRate}
|
||||
currentRating={session.session_rating}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Main content area: conversation + sidebar */}
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Conversation column */}
|
||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-6">
|
||||
<div className="mx-auto max-w-2xl space-y-3">
|
||||
{allSteps.map((step) => (
|
||||
<FlowPilotStepCard
|
||||
key={step.step_id}
|
||||
step={step}
|
||||
isCurrentStep={currentStep?.step_id === step.step_id}
|
||||
isProcessing={isProcessing && currentStep?.step_id === step.step_id}
|
||||
onRespond={onRespond}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<div
|
||||
className="hidden w-72 shrink-0 overflow-y-auto border-l p-4 lg:block"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Problem summary */}
|
||||
{session.problem_summary && (
|
||||
<div>
|
||||
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] mb-1">
|
||||
Problem
|
||||
</h4>
|
||||
<p className="text-sm text-foreground">{session.problem_summary}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Domain */}
|
||||
{session.problem_domain && (
|
||||
<div>
|
||||
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] mb-1">
|
||||
Domain
|
||||
</h4>
|
||||
<span className="font-label rounded-md bg-primary/10 px-2 py-0.5 text-[0.625rem] uppercase tracking-wider text-primary">
|
||||
{session.problem_domain}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confidence */}
|
||||
<div>
|
||||
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] mb-1">
|
||||
Confidence
|
||||
</h4>
|
||||
<ConfidenceIndicator
|
||||
tier={session.confidence_tier}
|
||||
score={currentStep?.confidence_score ?? 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Matched flow */}
|
||||
{session.matched_flow_id && (
|
||||
<div>
|
||||
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-[#5a6170] mb-1">
|
||||
Matched flow
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<Network size={14} className="text-muted-foreground" />
|
||||
<span className="text-xs text-foreground">
|
||||
{session.match_score ? `${Math.round(session.match_score * 100)}% match` : 'Match found'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steps */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Hash size={12} className="text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">{session.step_count} steps</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock size={12} className="text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(session.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action bar */}
|
||||
{session.status === 'active' && (
|
||||
<FlowPilotActionBar
|
||||
canResolve={canResolve}
|
||||
canEscalate={canEscalate}
|
||||
isProcessing={isProcessing}
|
||||
onResolve={onResolve}
|
||||
onEscalate={onEscalate}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
245
frontend/src/components/flowpilot/FlowPilotStepCard.tsx
Normal file
245
frontend/src/components/flowpilot/FlowPilotStepCard.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useState } from 'react'
|
||||
import { MessageSquare, Zap, CheckCircle2, SkipForward, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { AISessionStepResponse, StepResponseRequest } from '@/types/ai-session'
|
||||
import { FlowPilotOptions } from './FlowPilotOptions'
|
||||
|
||||
interface FlowPilotStepCardProps {
|
||||
step: AISessionStepResponse
|
||||
isCurrentStep: boolean
|
||||
isProcessing: boolean
|
||||
onRespond: (response: StepResponseRequest) => void
|
||||
}
|
||||
|
||||
const STEP_TYPE_ICONS = {
|
||||
question: MessageSquare,
|
||||
action: Zap,
|
||||
intake_analysis: MessageSquare,
|
||||
verification: CheckCircle2,
|
||||
info_request: MessageSquare,
|
||||
script_generation: Zap,
|
||||
note: MessageSquare,
|
||||
} as const
|
||||
|
||||
export function FlowPilotStepCard({ step, isCurrentStep, isProcessing, onRespond }: FlowPilotStepCardProps) {
|
||||
const [freeText, setFreeText] = useState('')
|
||||
const [showFreeText, setShowFreeText] = useState(false)
|
||||
const [isCollapsed, setIsCollapsed] = useState(!isCurrentStep)
|
||||
|
||||
const content = step.content as Record<string, unknown>
|
||||
const stepText = (content.text as string) || ''
|
||||
const contentType = (content.type as string) || step.step_type
|
||||
const isResolutionSuggestion = contentType === 'resolution_suggestion'
|
||||
const Icon = STEP_TYPE_ICONS[step.step_type as keyof typeof STEP_TYPE_ICONS] ?? MessageSquare
|
||||
|
||||
const handleOptionSelect = (value: string) => {
|
||||
onRespond({ selected_option: value })
|
||||
}
|
||||
|
||||
const handleFreeTextSubmit = () => {
|
||||
if (!freeText.trim()) return
|
||||
onRespond({ free_text_input: freeText.trim() })
|
||||
setFreeText('')
|
||||
setShowFreeText(false)
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
onRespond({ was_skipped: true })
|
||||
}
|
||||
|
||||
const handleActionComplete = (success: boolean) => {
|
||||
onRespond({
|
||||
action_result: { success, details: '' },
|
||||
})
|
||||
}
|
||||
|
||||
const handleResolutionResponse = (accepted: boolean) => {
|
||||
if (accepted) {
|
||||
// Parent will handle opening the resolve modal
|
||||
onRespond({ selected_option: 'resolution_accepted' })
|
||||
} else {
|
||||
onRespond({ free_text_input: 'No, keep investigating. The issue is not resolved.' })
|
||||
}
|
||||
}
|
||||
|
||||
// Completed step (collapsed view)
|
||||
if (!isCurrentStep && isCollapsed) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => setIsCollapsed(false)}
|
||||
className="w-full text-left glass-card-static p-4 opacity-70 hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Icon size={16} className="shrink-0 text-muted-foreground" />
|
||||
<p className="text-sm text-foreground truncate flex-1">{stepText}</p>
|
||||
<ChevronDown size={14} className="shrink-0 text-muted-foreground" />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// Expanded completed step
|
||||
if (!isCurrentStep && !isCollapsed) {
|
||||
return (
|
||||
<div className="glass-card-static p-4 opacity-80">
|
||||
<button
|
||||
onClick={() => setIsCollapsed(true)}
|
||||
className="mb-2 flex w-full items-center justify-between text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={16} className="text-muted-foreground" />
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
Step {step.step_order + 1}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronUp size={14} className="text-muted-foreground" />
|
||||
</button>
|
||||
<p className="text-sm text-foreground">{stepText}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Current active step
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'glass-card-static p-5',
|
||||
isResolutionSuggestion && 'border-emerald-500/30'
|
||||
)}
|
||||
>
|
||||
{/* Context message */}
|
||||
{step.context_message && (
|
||||
<div className="mb-3 rounded-lg bg-primary/5 px-3 py-2 border border-primary/10">
|
||||
<p className="text-xs text-muted-foreground">{step.context_message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step content */}
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
<span className={cn(
|
||||
'mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg',
|
||||
isResolutionSuggestion ? 'bg-emerald-500/10 text-emerald-400' : 'bg-primary/10 text-primary'
|
||||
)}>
|
||||
<Icon size={14} />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground">{stepText}</p>
|
||||
{isResolutionSuggestion && typeof content.resolution_summary === 'string' && (
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
{content.resolution_summary}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interactive area */}
|
||||
{isCurrentStep && !isProcessing && (
|
||||
<div className="space-y-3">
|
||||
{/* Resolution suggestion buttons */}
|
||||
{isResolutionSuggestion && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleResolutionResponse(true)}
|
||||
className="flex-1 rounded-lg bg-emerald-500/10 border border-emerald-500/20 px-4 py-2.5 text-sm font-medium text-emerald-400 hover:bg-emerald-500/20 transition-colors"
|
||||
>
|
||||
Yes, this is resolved
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleResolutionResponse(false)}
|
||||
className="flex-1 rounded-lg bg-card/50 border border-border px-4 py-2.5 text-sm font-medium text-foreground hover:bg-card transition-colors"
|
||||
>
|
||||
No, keep investigating
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options for question steps */}
|
||||
{!isResolutionSuggestion && step.options.length > 0 && (
|
||||
<FlowPilotOptions
|
||||
options={step.options}
|
||||
onSelect={handleOptionSelect}
|
||||
disabled={isProcessing}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Action step buttons */}
|
||||
{!isResolutionSuggestion && step.step_type === 'action' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleActionComplete(true)}
|
||||
className="flex-1 rounded-lg bg-primary/10 border border-primary/20 px-4 py-2.5 text-sm font-medium text-primary hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
I've completed this action
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleActionComplete(false)}
|
||||
className="flex-1 rounded-lg bg-card/50 border border-border px-4 py-2.5 text-sm font-medium text-foreground hover:bg-card transition-colors"
|
||||
>
|
||||
This didn't work
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Free text escape hatch */}
|
||||
{!isResolutionSuggestion && step.allow_free_text && (
|
||||
<>
|
||||
{!showFreeText ? (
|
||||
<button
|
||||
onClick={() => setShowFreeText(true)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
None of these — let me describe
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<textarea
|
||||
value={freeText}
|
||||
onChange={(e) => setFreeText(e.target.value)}
|
||||
placeholder="Describe what you're seeing..."
|
||||
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none resize-none"
|
||||
rows={3}
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleFreeTextSubmit}
|
||||
disabled={!freeText.trim()}
|
||||
className="rounded-lg bg-gradient-brand px-4 py-1.5 text-sm font-semibold text-[#101114] hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowFreeText(false); setFreeText('') }}
|
||||
className="rounded-lg px-4 py-1.5 text-sm text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Skip option */}
|
||||
{!isResolutionSuggestion && step.allow_skip && (
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="flex items-center gap-1.5 text-xs text-[#5a6170] hover:text-muted-foreground transition-colors"
|
||||
>
|
||||
<SkipForward size={12} />
|
||||
I can't check this right now
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Processing indicator */}
|
||||
{isProcessing && (
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
|
||||
<span className="text-xs text-muted-foreground">FlowPilot is thinking...</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
frontend/src/components/flowpilot/SessionDocView.tsx
Normal file
120
frontend/src/components/flowpilot/SessionDocView.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
import { FileText, Clock, CheckCircle2, ArrowUpRight, Star } from 'lucide-react'
|
||||
import type { SessionDocumentation } from '@/types/ai-session'
|
||||
|
||||
interface SessionDocViewProps {
|
||||
documentation: SessionDocumentation
|
||||
onRate?: (rating: number) => void
|
||||
currentRating?: number | null
|
||||
}
|
||||
|
||||
export function SessionDocView({ documentation, onRate, currentRating }: SessionDocViewProps) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Header */}
|
||||
<div className="glass-card-static p-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<FileText size={16} />
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-heading text-lg font-semibold text-foreground">Session Documentation</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">{documentation.problem_summary}</p>
|
||||
<div className="mt-2 flex items-center gap-3 flex-wrap">
|
||||
{documentation.problem_domain && (
|
||||
<span className="font-label rounded-md bg-primary/10 px-2 py-0.5 text-[0.625rem] uppercase tracking-wider text-primary">
|
||||
{documentation.problem_domain}
|
||||
</span>
|
||||
)}
|
||||
{documentation.duration_display && (
|
||||
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock size={12} />
|
||||
{documentation.duration_display}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{documentation.total_steps} steps
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Outcome */}
|
||||
{(documentation.resolution_summary || documentation.escalation_reason) && (
|
||||
<div className={`glass-card-static p-4 border-l-2 ${documentation.resolution_summary ? 'border-l-emerald-500' : 'border-l-amber-500'}`}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
{documentation.resolution_summary ? (
|
||||
<CheckCircle2 size={14} className="text-emerald-400" />
|
||||
) : (
|
||||
<ArrowUpRight size={14} className="text-amber-400" />
|
||||
)}
|
||||
<span className="font-label text-[0.625rem] uppercase tracking-wider text-muted-foreground">
|
||||
{documentation.resolution_summary ? 'Resolved' : 'Escalated'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-foreground">
|
||||
{documentation.resolution_summary || documentation.escalation_reason}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Intake summary */}
|
||||
<div className="glass-card-static p-4">
|
||||
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-muted-foreground mb-2">
|
||||
Original intake
|
||||
</h4>
|
||||
<p className="text-sm text-foreground whitespace-pre-wrap">{documentation.intake_summary}</p>
|
||||
</div>
|
||||
|
||||
{/* Diagnostic steps */}
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-label text-[0.625rem] uppercase tracking-wider text-muted-foreground px-1">
|
||||
Diagnostic trail
|
||||
</h4>
|
||||
{documentation.diagnostic_steps.map((step) => (
|
||||
<div key={step.step_number} className="glass-card-static p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="font-label flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-card text-[0.625rem] text-muted-foreground border border-border">
|
||||
{step.step_number}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-foreground">{step.description}</p>
|
||||
{step.engineer_response && (
|
||||
<p className="mt-1 text-xs text-primary">→ {step.engineer_response}</p>
|
||||
)}
|
||||
{step.outcome && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">Outcome: {step.outcome}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Rating */}
|
||||
{onRate && (
|
||||
<div className="glass-card-static p-4 text-center">
|
||||
<p className="text-sm text-muted-foreground mb-2">How helpful was this session?</p>
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
{[1, 2, 3, 4, 5].map((star) => (
|
||||
<button
|
||||
key={star}
|
||||
onClick={() => onRate(star)}
|
||||
className="p-1 transition-colors"
|
||||
>
|
||||
<Star
|
||||
size={20}
|
||||
className={
|
||||
(currentRating ?? 0) >= star
|
||||
? 'fill-amber-400 text-amber-400'
|
||||
: 'text-muted-foreground hover:text-amber-400'
|
||||
}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
8
frontend/src/components/flowpilot/index.ts
Normal file
8
frontend/src/components/flowpilot/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { FlowPilotIntake } from './FlowPilotIntake'
|
||||
export { FlowPilotSession } from './FlowPilotSession'
|
||||
export { FlowPilotStepCard } from './FlowPilotStepCard'
|
||||
export { FlowPilotOptions } from './FlowPilotOptions'
|
||||
export { FlowPilotActionBar } from './FlowPilotActionBar'
|
||||
export { ConfidenceIndicator } from './ConfidenceIndicator'
|
||||
export { SessionDocView } from './SessionDocView'
|
||||
export { AISessionListItem } from './AISessionListItem'
|
||||
@@ -3,7 +3,7 @@ import { useLocation } from 'react-router-dom'
|
||||
import {
|
||||
LayoutGrid, Network, Wrench, Clock, FileOutput, BarChart3,
|
||||
Settings, PanelLeftClose, PanelLeftOpen, MessageSquareText,
|
||||
BookOpen, Lightbulb, Code2, Library, Brain, WandSparkles,
|
||||
BookOpen, Lightbulb, Code2, Library, Brain, WandSparkles, Sparkles,
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||
@@ -83,6 +83,7 @@ export function Sidebar() {
|
||||
<>
|
||||
{/* Collapsed: icon-only nav */}
|
||||
<div className="flex flex-col items-center px-1.5 py-3 space-y-1">
|
||||
<NavItem href="/pilot" icon={Sparkles} label="New Session" iconColor={NAV_COLORS.dashboard} collapsed />
|
||||
<NavItem href="/" icon={LayoutGrid} label="Dashboard" iconColor={NAV_COLORS.dashboard} collapsed />
|
||||
<NavItem href="/sessions" icon={Clock} label="Sessions" badge={stats?.active_count || undefined} iconColor={NAV_COLORS.sessions} collapsed />
|
||||
<NavItem href="/trees" icon={Network} label="All Flows" matchPaths={['/trees', '/flows']} iconColor={NAV_COLORS.flows} collapsed />
|
||||
@@ -117,6 +118,13 @@ export function Sidebar() {
|
||||
|
||||
<div style={{ borderBottom: '1px solid var(--glass-border)' }} />
|
||||
|
||||
{/* New Session CTA */}
|
||||
<div className="px-3 pt-2 pb-1">
|
||||
<NavItem href="/pilot" icon={Sparkles} label="New Session" iconColor={NAV_COLORS.dashboard} />
|
||||
</div>
|
||||
|
||||
<div style={{ borderBottom: '1px solid var(--glass-border)' }} />
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="px-3 py-2 space-y-0.5">
|
||||
{/* Dashboard (standalone) */}
|
||||
|
||||
209
frontend/src/hooks/useFlowPilotSession.ts
Normal file
209
frontend/src/hooks/useFlowPilotSession.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { aiSessionsApi } from '@/api'
|
||||
import type {
|
||||
AISessionCreateRequest,
|
||||
AISessionCreateResponse,
|
||||
AISessionDetail,
|
||||
AISessionStepResponse,
|
||||
StepResponseRequest,
|
||||
StepResponseResponse,
|
||||
ResolveSessionRequest,
|
||||
EscalateSessionRequest,
|
||||
SessionDocumentation,
|
||||
} from '@/types/ai-session'
|
||||
import { toast } from '@/lib/toast'
|
||||
|
||||
export interface UseFlowPilotSession {
|
||||
// State
|
||||
session: AISessionDetail | null
|
||||
currentStep: AISessionStepResponse | null
|
||||
allSteps: AISessionStepResponse[]
|
||||
isLoading: boolean
|
||||
isProcessing: boolean
|
||||
error: string | null
|
||||
|
||||
// Actions
|
||||
startSession: (intake: AISessionCreateRequest) => Promise<void>
|
||||
respondToStep: (response: StepResponseRequest) => Promise<void>
|
||||
resolveSession: (data: ResolveSessionRequest) => Promise<SessionDocumentation>
|
||||
escalateSession: (data: EscalateSessionRequest) => Promise<SessionDocumentation>
|
||||
rateSession: (rating: number, feedback?: string) => Promise<void>
|
||||
loadSession: (sessionId: string) => Promise<void>
|
||||
|
||||
// Derived
|
||||
isActive: boolean
|
||||
canResolve: boolean
|
||||
canEscalate: boolean
|
||||
|
||||
// Post-close
|
||||
documentation: SessionDocumentation | null
|
||||
}
|
||||
|
||||
export function useFlowPilotSession(): UseFlowPilotSession {
|
||||
const [session, setSession] = useState<AISessionDetail | null>(null)
|
||||
const [currentStep, setCurrentStep] = useState<AISessionStepResponse | null>(null)
|
||||
const [allSteps, setAllSteps] = useState<AISessionStepResponse[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [documentation, setDocumentation] = useState<SessionDocumentation | null>(null)
|
||||
|
||||
const startSession = useCallback(async (intake: AISessionCreateRequest) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result: AISessionCreateResponse = await aiSessionsApi.createSession(intake)
|
||||
const firstStep = result.first_step
|
||||
|
||||
setSession({
|
||||
id: result.session_id,
|
||||
status: result.status,
|
||||
intake_type: intake.intake_type,
|
||||
intake_content: intake.intake_content,
|
||||
problem_summary: result.problem_summary,
|
||||
problem_domain: result.problem_domain,
|
||||
confidence_tier: result.confidence_tier,
|
||||
step_count: 1,
|
||||
session_rating: null,
|
||||
created_at: new Date().toISOString(),
|
||||
resolved_at: null,
|
||||
matched_flow_id: result.matched_flow_id,
|
||||
match_score: result.match_score,
|
||||
resolution_summary: null,
|
||||
resolution_action: null,
|
||||
escalation_reason: null,
|
||||
session_feedback: null,
|
||||
steps: [firstStep],
|
||||
})
|
||||
setAllSteps([firstStep])
|
||||
setCurrentStep(firstStep)
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : 'Failed to start session'
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const respondToStep = useCallback(async (response: StepResponseRequest) => {
|
||||
if (!session) return
|
||||
setIsProcessing(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result: StepResponseResponse = await aiSessionsApi.respondToStep(session.id, response)
|
||||
|
||||
setSession(prev => prev ? {
|
||||
...prev,
|
||||
status: result.status,
|
||||
confidence_tier: result.confidence_tier,
|
||||
step_count: prev.step_count + 1,
|
||||
} : null)
|
||||
|
||||
if (result.next_step) {
|
||||
setAllSteps(prev => [...prev, result.next_step!])
|
||||
setCurrentStep(result.next_step)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : 'Failed to process response'
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const resolveSession = useCallback(async (data: ResolveSessionRequest): Promise<SessionDocumentation> => {
|
||||
if (!session) throw new Error('No active session')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
const result = await aiSessionsApi.resolveSession(session.id, data)
|
||||
setSession(prev => prev ? { ...prev, status: 'resolved' } : null)
|
||||
setDocumentation(result.documentation)
|
||||
setCurrentStep(null)
|
||||
toast.success('Session resolved')
|
||||
return result.documentation
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : 'Failed to resolve session'
|
||||
toast.error(message)
|
||||
throw e
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const escalateSession = useCallback(async (data: EscalateSessionRequest): Promise<SessionDocumentation> => {
|
||||
if (!session) throw new Error('No active session')
|
||||
setIsProcessing(true)
|
||||
try {
|
||||
const result = await aiSessionsApi.escalateSession(session.id, data)
|
||||
setSession(prev => prev ? { ...prev, status: 'escalated' } : null)
|
||||
setDocumentation(result.documentation)
|
||||
setCurrentStep(null)
|
||||
toast.success('Session escalated')
|
||||
return result.documentation
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : 'Failed to escalate session'
|
||||
toast.error(message)
|
||||
throw e
|
||||
} finally {
|
||||
setIsProcessing(false)
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const rateSession = useCallback(async (rating: number, feedback?: string) => {
|
||||
if (!session) return
|
||||
try {
|
||||
await aiSessionsApi.rateSession(session.id, { rating, feedback })
|
||||
setSession(prev => prev ? { ...prev, session_rating: rating } : null)
|
||||
toast.success('Thanks for your feedback!')
|
||||
} catch {
|
||||
toast.error('Failed to submit rating')
|
||||
}
|
||||
}, [session])
|
||||
|
||||
const loadSession = useCallback(async (sessionId: string) => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const detail = await aiSessionsApi.getSession(sessionId)
|
||||
setSession(detail)
|
||||
setAllSteps(detail.steps)
|
||||
setCurrentStep(detail.status === 'active' ? detail.steps[detail.steps.length - 1] ?? null : null)
|
||||
|
||||
if (detail.status === 'resolved' || detail.status === 'escalated') {
|
||||
const doc = await aiSessionsApi.getDocumentation(sessionId)
|
||||
setDocumentation(doc)
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : 'Failed to load session'
|
||||
setError(message)
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isActive = session?.status === 'active'
|
||||
const canResolve = isActive && allSteps.length >= 1
|
||||
const canEscalate = isActive && allSteps.length >= 1
|
||||
|
||||
return {
|
||||
session,
|
||||
currentStep,
|
||||
allSteps,
|
||||
isLoading,
|
||||
isProcessing,
|
||||
error,
|
||||
startSession,
|
||||
respondToStep,
|
||||
resolveSession,
|
||||
escalateSession,
|
||||
rateSession,
|
||||
loadSession,
|
||||
isActive,
|
||||
canResolve,
|
||||
canEscalate,
|
||||
documentation,
|
||||
}
|
||||
}
|
||||
83
frontend/src/pages/FlowPilotSessionPage.tsx
Normal file
83
frontend/src/pages/FlowPilotSessionPage.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
import { useFlowPilotSession } from '@/hooks/useFlowPilotSession'
|
||||
import { FlowPilotIntake, FlowPilotSession } from '@/components/flowpilot'
|
||||
|
||||
export default function FlowPilotSessionPage() {
|
||||
const { sessionId } = useParams<{ sessionId?: string }>()
|
||||
const fp = useFlowPilotSession()
|
||||
|
||||
// Load existing session if ID in URL
|
||||
useEffect(() => {
|
||||
if (sessionId && !fp.session) {
|
||||
fp.loadSession(sessionId)
|
||||
}
|
||||
}, [sessionId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Error state
|
||||
if (fp.error && !fp.session) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[50vh]">
|
||||
<div className="glass-card-static p-6 text-center max-w-md">
|
||||
<p className="text-sm text-rose-400">{fp.error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-3 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Intake screen (no session yet)
|
||||
if (!fp.session) {
|
||||
return (
|
||||
<div className="h-full p-6">
|
||||
<FlowPilotIntake onSubmit={fp.startSession} isLoading={fp.isLoading} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Active/completed session
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center gap-3 border-b px-5 py-3 shrink-0"
|
||||
style={{ borderColor: 'var(--glass-border)' }}
|
||||
>
|
||||
<span className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Sparkles size={14} className="text-primary" />
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="font-heading text-sm font-semibold text-foreground truncate">
|
||||
{fp.session.problem_summary || 'FlowPilot Session'}
|
||||
</h1>
|
||||
</div>
|
||||
<span className="font-label rounded-md bg-card px-2 py-0.5 text-[0.625rem] uppercase tracking-wider text-muted-foreground border border-border">
|
||||
{fp.session.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Session content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<FlowPilotSession
|
||||
session={fp.session}
|
||||
allSteps={fp.allSteps}
|
||||
currentStep={fp.currentStep}
|
||||
isProcessing={fp.isProcessing}
|
||||
canResolve={fp.canResolve}
|
||||
canEscalate={fp.canEscalate}
|
||||
documentation={fp.documentation}
|
||||
onRespond={fp.respondToStep}
|
||||
onResolve={fp.resolveSession}
|
||||
onEscalate={fp.escalateSession}
|
||||
onRate={fp.rateSession}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,14 +2,16 @@ import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { PageMeta } from '@/components/common/PageMeta'
|
||||
import { sessionsApi } from '@/api/sessions'
|
||||
import { aiSessionsApi } from '@/api/aiSessions'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import type { Session, TreeListItem, SessionOutcome } from '@/types'
|
||||
import type { Session, TreeListItem, SessionOutcome, AISessionSummary } from '@/types'
|
||||
import type { DateRange } from 'react-day-picker'
|
||||
import { SessionFilters } from '@/components/session/SessionFilters'
|
||||
import type { SessionFilterState } from '@/components/session/SessionFilters'
|
||||
import { Spinner } from '@/components/common/Spinner'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { SessionIllustration } from '@/components/common/EmptyStateIllustrations'
|
||||
import { AISessionListItem } from '@/components/flowpilot/AISessionListItem'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { getSessionResumePath } from '@/lib/routing'
|
||||
@@ -18,6 +20,11 @@ export function SessionHistoryPage() {
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
// Top-level tab: flow sessions vs AI sessions
|
||||
const [sessionType, setSessionType] = useState<'flow' | 'ai'>('flow')
|
||||
const [aiSessions, setAiSessions] = useState<AISessionSummary[]>([])
|
||||
const [aiLoading, setAiLoading] = useState(false)
|
||||
|
||||
const [sessions, setSessions] = useState<Session[]>([])
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [trees, setTrees] = useState<TreeListItem[]>([])
|
||||
@@ -140,6 +147,25 @@ export function SessionHistoryPage() {
|
||||
setSearchParams(params, { replace: true })
|
||||
}, [filters, setSearchParams])
|
||||
|
||||
// Load AI sessions when tab is active
|
||||
useEffect(() => {
|
||||
if (sessionType !== 'ai') return
|
||||
let cancelled = false
|
||||
const loadAiSessions = async () => {
|
||||
setAiLoading(true)
|
||||
try {
|
||||
const data = await aiSessionsApi.listSessions({ limit: 50 })
|
||||
if (!cancelled) setAiSessions(data)
|
||||
} catch {
|
||||
if (!cancelled) toast.error('Failed to load AI sessions')
|
||||
} finally {
|
||||
if (!cancelled) setAiLoading(false)
|
||||
}
|
||||
}
|
||||
loadAiSessions()
|
||||
return () => { cancelled = true }
|
||||
}, [sessionType])
|
||||
|
||||
const handleFilterChange = (newFilters: SessionFilterState) => {
|
||||
setFilters(newFilters)
|
||||
}
|
||||
@@ -226,7 +252,34 @@ export function SessionHistoryPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
{/* Session type toggle */}
|
||||
<div className="mb-4 flex gap-1 rounded-lg bg-card/50 p-1 w-fit border border-border">
|
||||
<button
|
||||
onClick={() => setSessionType('flow')}
|
||||
className={cn(
|
||||
'rounded-md px-4 py-1.5 text-sm font-medium transition-colors',
|
||||
sessionType === 'flow'
|
||||
? 'bg-primary/10 text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
Flow Sessions
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSessionType('ai')}
|
||||
className={cn(
|
||||
'rounded-md px-4 py-1.5 text-sm font-medium transition-colors',
|
||||
sessionType === 'ai'
|
||||
? 'bg-primary/10 text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
AI Sessions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs (flow sessions only) */}
|
||||
{sessionType === 'flow' && (
|
||||
<div className="mb-6 flex gap-2 border-b border-border">
|
||||
{(['active', 'prepared', 'completed', 'all'] as const).map((tab) => (
|
||||
<button
|
||||
@@ -243,8 +296,39 @@ export function SessionHistoryPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search and Filter Controls */}
|
||||
{/* AI Sessions view */}
|
||||
{sessionType === 'ai' && (
|
||||
aiLoading ? (
|
||||
<div className="flex justify-center py-12">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : aiSessions.length === 0 ? (
|
||||
<EmptyState
|
||||
title="No AI sessions yet"
|
||||
description="Start a FlowPilot session to get AI-guided troubleshooting. Sessions will appear here."
|
||||
action={
|
||||
<Link
|
||||
to="/pilot"
|
||||
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||
>
|
||||
Start AI Session
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{aiSessions.map((s) => (
|
||||
<AISessionListItem key={s.id} session={s} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Flow Sessions Content */}
|
||||
{sessionType === 'flow' && (
|
||||
<>
|
||||
<div className="mb-6">
|
||||
<SessionFilters
|
||||
filters={filters}
|
||||
@@ -474,6 +558,8 @@ export function SessionHistoryPage() {
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -45,6 +45,7 @@ const ScriptLibraryPage = lazy(() => import('@/pages/ScriptLibraryPage'))
|
||||
const ScriptManagePage = lazy(() => import('@/pages/ScriptManagePage'))
|
||||
const AssistantChatPage = lazy(() => import('@/pages/AssistantChatPage'))
|
||||
const FlowAssistPage = lazy(() => import('@/pages/FlowAssistPage'))
|
||||
const FlowPilotSessionPage = lazy(() => import('@/pages/FlowPilotSessionPage'))
|
||||
const KBAcceleratorPage = lazy(() => import('@/pages/KBAcceleratorPage'))
|
||||
const GuidesHubPage = lazy(() => import('@/pages/GuidesHubPage'))
|
||||
const GuideDetailPage = lazy(() => import('@/pages/GuideDetailPage'))
|
||||
@@ -169,6 +170,8 @@ export const router = sentryCreateBrowserRouter([
|
||||
{ path: 'kb-accelerator', element: page(KBAcceleratorPage) },
|
||||
{ path: 'assistant', element: page(AssistantChatPage) },
|
||||
{ path: 'flow-assist', element: page(FlowAssistPage) },
|
||||
{ path: 'pilot', element: page(FlowPilotSessionPage) },
|
||||
{ path: 'pilot/:sessionId', element: page(FlowPilotSessionPage) },
|
||||
{ path: 'guides', element: page(GuidesHubPage) },
|
||||
{ path: 'guides/:slug', element: page(GuideDetailPage) },
|
||||
// Admin routes
|
||||
|
||||
129
frontend/src/types/ai-session.ts
Normal file
129
frontend/src/types/ai-session.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
// ── Intake ──
|
||||
|
||||
export interface AISessionCreateRequest {
|
||||
intake_type: 'free_text' | 'psa_ticket' | 'screenshot' | 'log_paste' | 'combined'
|
||||
intake_content: Record<string, unknown>
|
||||
psa_ticket_id?: string
|
||||
psa_connection_id?: string
|
||||
}
|
||||
|
||||
export interface AISessionCreateResponse {
|
||||
session_id: string
|
||||
status: string
|
||||
confidence_tier: string
|
||||
problem_summary: string | null
|
||||
problem_domain: string | null
|
||||
matched_flow_id: string | null
|
||||
matched_flow_name: string | null
|
||||
match_score: number | null
|
||||
first_step: AISessionStepResponse
|
||||
}
|
||||
|
||||
// ── Step interaction ──
|
||||
|
||||
export interface StepOptionSchema {
|
||||
label: string
|
||||
value: string
|
||||
followup_hint: string | null
|
||||
}
|
||||
|
||||
export interface AISessionStepResponse {
|
||||
step_id: string
|
||||
step_order: number
|
||||
step_type: string
|
||||
content: Record<string, unknown>
|
||||
context_message: string | null
|
||||
options: StepOptionSchema[]
|
||||
allow_free_text: boolean
|
||||
allow_skip: boolean
|
||||
confidence_tier: string
|
||||
confidence_score: number
|
||||
}
|
||||
|
||||
export interface StepResponseRequest {
|
||||
selected_option?: string | null
|
||||
free_text_input?: string | null
|
||||
was_skipped?: boolean
|
||||
action_result?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export interface StepResponseResponse {
|
||||
session_id: string
|
||||
status: string
|
||||
confidence_tier: string
|
||||
confidence_score: number
|
||||
next_step: AISessionStepResponse | null
|
||||
resolution_suggested: boolean
|
||||
resolution_summary: string | null
|
||||
}
|
||||
|
||||
// ── Resolution / Escalation ──
|
||||
|
||||
export interface ResolveSessionRequest {
|
||||
resolution_summary: string
|
||||
resolution_action?: string | null
|
||||
session_rating?: number | null
|
||||
session_feedback?: string | null
|
||||
}
|
||||
|
||||
export interface EscalateSessionRequest {
|
||||
escalation_reason: string
|
||||
escalated_to_id?: string | null
|
||||
}
|
||||
|
||||
export interface DocumentationStep {
|
||||
step_number: number
|
||||
step_type: string
|
||||
description: string
|
||||
engineer_response: string | null
|
||||
outcome: string | null
|
||||
}
|
||||
|
||||
export interface SessionDocumentation {
|
||||
problem_summary: string
|
||||
problem_domain: string | null
|
||||
intake_summary: string
|
||||
diagnostic_steps: DocumentationStep[]
|
||||
resolution_summary: string | null
|
||||
escalation_reason: string | null
|
||||
total_steps: number
|
||||
duration_display: string | null
|
||||
generated_at: string
|
||||
}
|
||||
|
||||
export interface SessionCloseResponse {
|
||||
session_id: string
|
||||
status: string
|
||||
documentation: SessionDocumentation
|
||||
}
|
||||
|
||||
export interface RateSessionRequest {
|
||||
rating: number
|
||||
feedback?: string | null
|
||||
}
|
||||
|
||||
// ── List / Detail ──
|
||||
|
||||
export interface AISessionSummary {
|
||||
id: string
|
||||
status: string
|
||||
intake_type: string
|
||||
problem_summary: string | null
|
||||
problem_domain: string | null
|
||||
confidence_tier: string
|
||||
step_count: number
|
||||
session_rating: number | null
|
||||
created_at: string
|
||||
resolved_at: string | null
|
||||
}
|
||||
|
||||
export interface AISessionDetail extends AISessionSummary {
|
||||
intake_content: Record<string, unknown>
|
||||
matched_flow_id: string | null
|
||||
match_score: number | null
|
||||
resolution_summary: string | null
|
||||
resolution_action: string | null
|
||||
escalation_reason: string | null
|
||||
session_feedback: string | null
|
||||
steps: AISessionStepResponse[]
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export * from './admin'
|
||||
export * from './analytics'
|
||||
export * from './copilot'
|
||||
export type { AssistantChat, AssistantChatMessage, ChatListItem, ChatMessageResponse, RetentionSettings } from './assistant-chat'
|
||||
export * from './ai-session'
|
||||
|
||||
// API response wrapper types
|
||||
export interface PaginatedResponse<T> {
|
||||
|
||||
Reference in New Issue
Block a user