Four items from the design-plan audit, all flagged as locked-design or
Codex corrections, shipped together so the GTM demo path covers them
end-to-end before bug bash.
1. Live AI assessment refresh on the magic-moment screen. Backend already
publishes handoff_assessment_ready when enrich_escalation_async commits;
wire the frontend listener so the senior sees the assessment populate
without a manual reopen. New event type + onAssessmentReady handler on
streamEscalations; AssistantChatPage opens a scoped SSE subscription
whenever it tracks a handoff missing its assessment, refetches on match,
and replaces magicHandoff / overlayHandoff in place. Closes the loop on
the async-assessment commit e8ba74e.
2. Suggested-step chips below the chat input. Locked design from the plan
(Codex correction). Chip strip renders above the composer post-claim
when ai_assessment_data.suggested_steps[] is non-empty. Click prefills
the input and focuses; first send or explicit X hides for the session.
3. Unread 6px dot on EscalationQueue cards. localStorage-persisted seen
set (rf-escalation-seen, capped 200). Dot top-right when not seen.
Cleared on open (card click) or claim (Pick Up) — NOT on hover, per
Codex correction. Pick Up stops propagation so it doesn't double-fire.
4. Race-condition toast on claim conflict. The /claim endpoint previously
silently overwrote claimed_by — both seniors thought they owned the
session. New HandoffAlreadyClaimedError carries the winner's id/name/
timestamp; claim_session rejects different-user re-claims (same-user is
idempotent for double-click safety); endpoint returns 409 with
structured detail. AssistantChatPage.handleStartHere extracts and
surfaces "Already claimed by {name} {time_ago}." via toast, drops
?pickup=true, dismisses magic-moment so the loser flows back to queue.
Tests: 2 new unit tests in test_handoff_manager.py (conflict raises,
same-user idempotent). Full handoff + escalation suite (34 tests) green.
Frontend tsc -b clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
324 lines
10 KiB
TypeScript
324 lines
10 KiB
TypeScript
import apiClient from './client'
|
|
import type {
|
|
AISessionCreateRequest,
|
|
AISessionCreateResponse,
|
|
StepResponseRequest,
|
|
StepResponseResponse,
|
|
ResolveSessionRequest,
|
|
EscalateSessionRequest,
|
|
SessionCloseResponse,
|
|
SessionDocumentation,
|
|
AISessionSummary,
|
|
AISessionDetail,
|
|
AISessionSearchResult,
|
|
SimilarSession,
|
|
PickupSessionRequest,
|
|
StatusUpdateRequest,
|
|
StatusUpdateResponse,
|
|
ChatSessionCreateResponse,
|
|
ChatMessageRequest,
|
|
ChatMessageResponse,
|
|
HandoffCreatedEvent,
|
|
HandoffAssessmentReadyEvent,
|
|
EscalationStreamHandlers,
|
|
} 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 createChatSession(data: AISessionCreateRequest): Promise<ChatSessionCreateResponse> {
|
|
const response = await apiClient.post<ChatSessionCreateResponse>('/ai-sessions', {
|
|
...data,
|
|
session_type: 'chat',
|
|
})
|
|
return response.data
|
|
},
|
|
|
|
async sendChatMessage(sessionId: string, data: ChatMessageRequest): Promise<ChatMessageResponse> {
|
|
const response = await apiClient.post<ChatMessageResponse>(
|
|
`/ai-sessions/${sessionId}/chat`,
|
|
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
|
|
session_type?: string
|
|
skip?: number
|
|
limit?: number
|
|
problem_domain?: string
|
|
matched_flow_id?: string
|
|
confidence_tier?: string
|
|
ticket_id?: string
|
|
date_from?: string
|
|
date_to?: string
|
|
q?: string
|
|
}): Promise<AISessionSummary[]> {
|
|
// Strip empty string values so they aren't sent as empty query params
|
|
const cleanParams = params
|
|
? Object.fromEntries(Object.entries(params).filter(([, v]) => v !== '' && v !== undefined))
|
|
: undefined
|
|
const response = await apiClient.get<AISessionSummary[]>('/ai-sessions', { params: cleanParams })
|
|
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 streamDocumentation(
|
|
sessionId: string,
|
|
onChunk: (text: string) => void,
|
|
onDone: () => void,
|
|
onError: (error: string) => void,
|
|
): Promise<void> {
|
|
const token = localStorage.getItem('access_token')
|
|
const baseUrl = import.meta.env.VITE_API_URL || ''
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`${baseUrl}/api/v1/ai-sessions/${sessionId}/documentation/stream`,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
},
|
|
)
|
|
|
|
if (!response.ok) {
|
|
onError(`HTTP ${response.status}`)
|
|
return
|
|
}
|
|
|
|
const reader = response.body?.getReader()
|
|
if (!reader) {
|
|
onError('No response body')
|
|
return
|
|
}
|
|
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) break
|
|
|
|
buffer += decoder.decode(value, { stream: true })
|
|
const lines = buffer.split('\n')
|
|
buffer = lines.pop() || ''
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith('data: ')) {
|
|
const data = line.slice(6)
|
|
if (data === '[DONE]') {
|
|
onDone()
|
|
return
|
|
}
|
|
if (data.startsWith('[ERROR]')) {
|
|
onError(data.slice(8))
|
|
return
|
|
}
|
|
onChunk(data)
|
|
}
|
|
}
|
|
}
|
|
// Stream ended without [DONE]
|
|
onDone()
|
|
} catch (err) {
|
|
onError(err instanceof Error ? err.message : 'Stream failed')
|
|
}
|
|
},
|
|
|
|
async rateSession(sessionId: string, data: { rating: number; feedback?: string }): Promise<void> {
|
|
await apiClient.post(`/ai-sessions/${sessionId}/rate`, data)
|
|
},
|
|
|
|
async retryPsaPush(sessionId: string): Promise<{ psa_push_status: string; psa_push_error: string | null }> {
|
|
const response = await apiClient.post<{ psa_push_status: string; psa_push_error: string | null }>(
|
|
`/ai-sessions/${sessionId}/retry-psa-push`
|
|
)
|
|
return response.data
|
|
},
|
|
|
|
async saveTaskLane(sessionId: string, data: {
|
|
questions: Array<{ text: string; context?: string }>;
|
|
actions: Array<{ label: string; command?: string | null; description?: string }>;
|
|
responses: Array<Record<string, unknown>>;
|
|
}): Promise<void> {
|
|
await apiClient.put(`/ai-sessions/${sessionId}/task-lane`, data)
|
|
},
|
|
|
|
async pauseSession(sessionId: string): Promise<void> {
|
|
await apiClient.post(`/ai-sessions/${sessionId}/pause`)
|
|
},
|
|
|
|
async resumeSession(sessionId: string): Promise<void> {
|
|
await apiClient.post(`/ai-sessions/${sessionId}/resume`)
|
|
},
|
|
|
|
async abandonSession(sessionId: string, reason?: string): Promise<void> {
|
|
await apiClient.post(`/ai-sessions/${sessionId}/abandon`, null, {
|
|
params: reason ? { reason } : undefined,
|
|
})
|
|
},
|
|
|
|
async deleteSession(sessionId: string): Promise<void> {
|
|
await apiClient.delete(`/ai-sessions/${sessionId}`)
|
|
},
|
|
|
|
async pickupSession(sessionId: string, data: PickupSessionRequest): Promise<StepResponseResponse> {
|
|
const response = await apiClient.post<StepResponseResponse>(
|
|
`/ai-sessions/${sessionId}/pickup`,
|
|
data
|
|
)
|
|
return response.data
|
|
},
|
|
|
|
async linkTicket(sessionId: string, data: { psa_ticket_id: string; psa_connection_id: string }): Promise<AISessionDetail> {
|
|
const response = await apiClient.post<AISessionDetail>(
|
|
`/ai-sessions/${sessionId}/link-ticket`,
|
|
data
|
|
)
|
|
return response.data
|
|
},
|
|
|
|
async getEscalationQueue(): Promise<AISessionSummary[]> {
|
|
const response = await apiClient.get<AISessionSummary[]>('/ai-sessions/escalation-queue')
|
|
return response.data
|
|
},
|
|
|
|
// Native EventSource cannot send Authorization headers, so we use fetch +
|
|
// ReadableStream and parse SSE frames manually (same pattern as
|
|
// `streamDocumentation`). The returned promise resolves on clean stream
|
|
// close (server hangs up) and rejects on network/HTTP error so the caller
|
|
// can decide whether to reconnect with backoff.
|
|
async streamEscalations(
|
|
handlers: EscalationStreamHandlers,
|
|
signal: AbortSignal,
|
|
): Promise<void> {
|
|
const token = localStorage.getItem('access_token')
|
|
const baseUrl = import.meta.env.VITE_API_URL || ''
|
|
|
|
const response = await fetch(
|
|
`${baseUrl}/api/v1/ai-sessions/escalations/stream`,
|
|
{
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
signal,
|
|
},
|
|
)
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Escalation stream failed: HTTP ${response.status}`)
|
|
}
|
|
|
|
const reader = response.body?.getReader()
|
|
if (!reader) {
|
|
throw new Error('Escalation stream: no response body')
|
|
}
|
|
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read()
|
|
if (done) return
|
|
|
|
buffer += decoder.decode(value, { stream: true })
|
|
|
|
// SSE frames are separated by blank lines. Hold the trailing partial
|
|
// frame in the buffer until the next chunk completes it.
|
|
const frames = buffer.split('\n\n')
|
|
buffer = frames.pop() ?? ''
|
|
|
|
for (const frame of frames) {
|
|
if (!frame) continue
|
|
let eventType = 'message'
|
|
let data = ''
|
|
for (const line of frame.split('\n')) {
|
|
if (line.startsWith(':')) continue // comment / keepalive
|
|
if (line.startsWith('event: ')) eventType = line.slice(7).trim()
|
|
else if (line.startsWith('data: ')) data += line.slice(6)
|
|
}
|
|
if (!data) continue
|
|
try {
|
|
const parsed = JSON.parse(data) as Record<string, unknown>
|
|
if (eventType === 'handoff_created' && parsed.type === 'handoff_created') {
|
|
handlers.onHandoffCreated?.(parsed as unknown as HandoffCreatedEvent)
|
|
} else if (
|
|
eventType === 'handoff_assessment_ready' &&
|
|
parsed.type === 'handoff_assessment_ready'
|
|
) {
|
|
handlers.onAssessmentReady?.(
|
|
parsed as unknown as HandoffAssessmentReadyEvent,
|
|
)
|
|
} else if (eventType === 'ready') {
|
|
handlers.onReady?.()
|
|
}
|
|
} catch {
|
|
// skip malformed frame
|
|
}
|
|
}
|
|
}
|
|
},
|
|
|
|
async search(q: string, limit: number = 5): Promise<AISessionSearchResult[]> {
|
|
const response = await apiClient.get<AISessionSearchResult[]>('/ai-sessions/search', {
|
|
params: { q, limit },
|
|
})
|
|
return response.data
|
|
},
|
|
|
|
async getSimilar(sessionId: string, limit: number = 5): Promise<SimilarSession[]> {
|
|
const response = await apiClient.get<SimilarSession[]>(`/ai-sessions/${sessionId}/similar`, {
|
|
params: { limit },
|
|
})
|
|
return response.data
|
|
},
|
|
|
|
async generateStatusUpdate(sessionId: string, data: StatusUpdateRequest): Promise<StatusUpdateResponse> {
|
|
const response = await apiClient.post<StatusUpdateResponse>(
|
|
`/ai-sessions/${sessionId}/status-update`,
|
|
data
|
|
)
|
|
return response.data
|
|
},
|
|
}
|
|
|
|
export default aiSessionsApi
|