Spaces:
Running
Running
| import React, { useEffect, useRef, useState } from 'react'; | |
| import { useNavigate, useLocation } from 'react-router-dom'; | |
| import { Bot } from 'lucide-react'; | |
| import useToastStore from '../../store/toastStore'; | |
| import { Card } from "../../components/ui/card"; | |
| import AIProcessingSteps from "../components/AIProcessingSteps"; | |
| import useTicketStore from "../../store/ticketStore"; | |
| import useAdminStore from '../../admin/store/adminStore'; | |
| import useAuthStore from '../../store/authStore'; | |
| import { supabase } from '../../lib/supabaseClient'; | |
| import { API_CONFIG } from '../../config'; | |
| import { analyzeTicketWithAI } from '../../services/aiAssistant'; | |
| const steps = [ | |
| "Reading your message", | |
| "Extracting technical entities", | |
| "Detecting category and priority", | |
| "Checking duplicate issues", | |
| "Finding possible solutions" | |
| ]; | |
| const AIProcessing = () => { | |
| const navigate = useNavigate(); | |
| const location = useLocation(); | |
| const { text, image_text, image_base64, template_id, template_used, user_modified, ticket_title, original_text, original_language } = location.state || {}; | |
| const setAITicket = useTicketStore((state) => state.setAITicket); | |
| const { settings } = useAdminStore(); | |
| const { user, profile } = useAuthStore(); | |
| const { showToast } = useToastStore(); | |
| const hasCalledAPI = useRef(false); | |
| const [activeStep, setActiveStep] = useState(0); | |
| useEffect(() => { | |
| if (!text) { | |
| console.warn("[AIProcessing] No ticket text found. Redirecting to /create-ticket"); | |
| navigate('/create-ticket'); | |
| return; | |
| } | |
| if (hasCalledAPI.current) return; | |
| hasCalledAPI.current = true; | |
| const analyzeTicket = async () => { | |
| console.log("[AIProcessing] Starting analysis for:", text); | |
| try { | |
| // === Single call to backend — handles ML classification + Gemini summary === | |
| // Classification, NER, priority, team assignment, duplicate detection → local ML model | |
| // ── Upload Image if present ── | |
| let uploadedImageUrl = null; | |
| if (image_base64) { | |
| try { | |
| const base64Data = image_base64.split(',')[1] || image_base64; | |
| const contentType = | |
| image_base64.match(/data:(.*?);/)?.[1] || 'image/jpeg'; | |
| const fileExt = contentType.split('/')[1] || 'jpeg'; | |
| const byteCharacters = atob(base64Data); | |
| const byteNumbers = new Array(byteCharacters.length); | |
| for (let i = 0; i < byteCharacters.length; i++) { | |
| byteNumbers[i] = byteCharacters.charCodeAt(i); | |
| } | |
| const byteArray = new Uint8Array(byteNumbers); | |
| const blob = new Blob([byteArray], { | |
| type: contentType | |
| }); | |
| const fileName = | |
| `${user?.id || 'anon'}/${Date.now()}-${Math.random() | |
| .toString(36) | |
| .substring(7)}.${fileExt}`; | |
| const { error: uploadError } = await supabase.storage | |
| .from('ticket-attachments') | |
| .upload(fileName, blob, { | |
| contentType, | |
| upsert: true | |
| }); | |
| if (!uploadError) { | |
| const { data: publicUrlData } = supabase.storage | |
| .from('ticket-attachments') | |
| .getPublicUrl(fileName); | |
| uploadedImageUrl = publicUrlData?.publicUrl; | |
| } | |
| } catch (err) { | |
| console.error("[AIProcessing] Image upload failed:", err); | |
| } | |
| } | |
| const payload = { | |
| text: text, | |
| image_text: image_text || "", | |
| image_base64: image_base64 || "", | |
| user_id: user?.id, | |
| company: | |
| profile?.company || | |
| user?.user_metadata?.company || | |
| "System", | |
| company_id: profile?.company_id || null, | |
| image_url: uploadedImageUrl, | |
| confidence_threshold: settings.aiConfidenceThreshold, | |
| duplicate_sensitivity: settings.duplicateSensitivity, | |
| // Smart Template metadata (backend can use for improved routing) | |
| template_id: template_id || null, | |
| template_used: template_used || false, | |
| user_modified: user_modified || false, | |
| ticket_title: ticket_title || null, | |
| }; | |
| const controller = new AbortController(); | |
| const timeoutId = setTimeout(() => controller.abort(), 6000); | |
| const response = await fetch( | |
| `${API_CONFIG.BACKEND_URL}/ai/analyze_stream`, | |
| { | |
| method: 'POST', | |
| headers: { | |
| 'Content-Type': 'application/json' | |
| }, | |
| body: JSON.stringify(payload), | |
| signal: controller.signal | |
| } | |
| ); | |
| clearTimeout(timeoutId); | |
| if (!response.ok) { | |
| throw new Error("Backend streaming failed"); | |
| } | |
| // ============================== | |
| // FIXED SSE BUFFERED PARSING | |
| // ============================== | |
| const reader = response.body.getReader(); | |
| const decoder = new TextDecoder("utf-8"); | |
| let done = false; | |
| let finalTicket = null; | |
| // Buffer stores incomplete SSE chunks | |
| let buffer = ""; | |
| while (!done) { | |
| const { value, done: readerDone } = await reader.read(); | |
| done = readerDone; | |
| if (value) { | |
| // Append chunk to buffer | |
| buffer += decoder.decode(value, { stream: true }); | |
| // SSE events separated by blank line | |
| const events = buffer.split('\n\n'); | |
| // Keep incomplete trailing event | |
| buffer = events.pop() || ""; | |
| for (const event of events) { | |
| const lines = event.split('\n'); | |
| for (const line of lines) { | |
| if (!line.startsWith('data: ')) continue; | |
| try { | |
| const data = JSON.parse( | |
| line.substring(6) | |
| ); | |
| if (data.step === 'done') { | |
| setActiveStep(steps.length); | |
| finalTicket = data.result; | |
| } else { | |
| const stepIndex = | |
| steps.indexOf(data.step); | |
| if (stepIndex !== -1) { | |
| setActiveStep(stepIndex); | |
| } | |
| } | |
| } catch (e) { | |
| console.error( | |
| "Error parsing stream data", | |
| e, | |
| line | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // Optional leftover buffer parsing | |
| if (buffer.trim()) { | |
| const lines = buffer.split('\n'); | |
| for (const line of lines) { | |
| if (!line.startsWith('data: ')) continue; | |
| try { | |
| const data = JSON.parse( | |
| line.substring(6) | |
| ); | |
| if (data.step === 'done') { | |
| finalTicket = data.result; | |
| } | |
| } catch (e) { | |
| console.error( | |
| "Final buffer parse error", | |
| e, | |
| line | |
| ); | |
| } | |
| } | |
| } | |
| if (!finalTicket) { | |
| throw new Error("BACKEND_STARTUP"); | |
| } | |
| // Override the backend summary using the robust frontend multi-provider failover | |
| try { | |
| const aiResult = await analyzeTicketWithAI(text, image_text, image_base64); | |
| finalTicket.summary = aiResult.summary || finalTicket.summary; | |
| if (aiResult.image_description) { | |
| finalTicket.image_description = aiResult.image_description; | |
| } | |
| // The local ML model is weak with regional languages (e.g., Telugu). | |
| // If the LLM returned classification fields, we trust it more than a low-confidence ML prediction. | |
| if (aiResult.category && (finalTicket.confidence < 0.6 || finalTicket.category === 'Unknown' || finalTicket.category === 'Access')) { | |
| finalTicket.category = aiResult.category; | |
| finalTicket.subcategory = aiResult.subcategory || finalTicket.subcategory; | |
| finalTicket.priority = aiResult.priority || finalTicket.priority; | |
| finalTicket.assigned_team = aiResult.assigned_team || finalTicket.assigned_team; | |
| finalTicket.confidence = aiResult.confidence || 0.95; | |
| } | |
| } catch (aiErr) { | |
| console.warn("[AIProcessing] Frontend summary generation failed:", aiErr); | |
| } | |
| const aiTicketObject = { | |
| ...finalTicket, | |
| status: 'analyzing', | |
| originalIssue: original_text || text, | |
| originalLanguage: original_language || 'en', | |
| capturedFileBase64: image_base64, | |
| ocrText: image_text, | |
| image_url: uploadedImageUrl || finalTicket?.image_url || null | |
| }; | |
| setAITicket(aiTicketObject); | |
| setTimeout(() => navigate('/ai-understanding'), 1000); | |
| } catch (error) { | |
| console.error("[AIProcessing] Analysis Failed:", error); | |
| // Graceful fallback for any error (e.g. backend 503 offline, streaming failed, or network protocol errors) | |
| if ( | |
| true // Always fallback gracefully to keep the ticket creation flow 100% operational! | |
| ) { | |
| console.warn( | |
| "[AIProcessing] Backend unreachable or preparing. Using local fallback." | |
| ); | |
| let summary = | |
| (text.charAt(0).toUpperCase() + text.slice(1)) | |
| .substring(0, 100) | |
| + (text.length > 100 ? '…' : ''); | |
| let image_description = ""; | |
| let fallbackCategory = "General"; | |
| let fallbackSub = "General Support"; | |
| let fallbackPriority = "Medium"; | |
| let fallbackTeam = "General Support"; | |
| try { | |
| const aiResult = await analyzeTicketWithAI(text, image_text, image_base64); | |
| summary = aiResult.summary || summary; | |
| image_description = aiResult.image_description || ""; | |
| if (aiResult.category) { | |
| fallbackCategory = aiResult.category; | |
| fallbackSub = aiResult.subcategory || fallbackSub; | |
| fallbackPriority = aiResult.priority || fallbackPriority; | |
| fallbackTeam = aiResult.assigned_team || fallbackTeam; | |
| } | |
| } catch (aiErr) { | |
| console.warn("[AIProcessing] Fallback AI summary failed:", aiErr); | |
| } | |
| const fallbackTicket = { | |
| summary, | |
| status: 'analyzing', | |
| category: fallbackCategory, | |
| subcategory: fallbackSub, | |
| priority: fallbackPriority, | |
| auto_resolve: false, | |
| assigned_team: fallbackTeam, | |
| entities: [], | |
| duplicate_ticket: { | |
| is_duplicate: false, | |
| similarity: 0 | |
| }, | |
| confidence: 0.9, | |
| needs_review: true, | |
| reasoning: | |
| "Analyzed via AI Fallback — backend ML model was unreachable.", | |
| image_description, | |
| ocr_text: image_text || "", | |
| highlights: [], | |
| originalIssue: original_text || text, | |
| originalLanguage: original_language || 'en', | |
| capturedFileBase64: image_base64, | |
| ocrText: image_text, | |
| image_url: uploadedImageUrl || null | |
| }; | |
| setAITicket(fallbackTicket); | |
| setTimeout( | |
| () => navigate('/ai-understanding'), | |
| 500 | |
| ); | |
| } else { | |
| showToast( | |
| "AI Analysis sequence failed. Check network protocols.", | |
| "error" | |
| ); | |
| navigate('/create-ticket'); | |
| } | |
| } | |
| }; | |
| analyzeTicket(); | |
| }, [text, image_text, image_base64, navigate, setAITicket, settings, user, profile]); | |
| return ( | |
| <div className="flex-1 flex items-center justify-center p-6 bg-[#f6f8f7] min-h-screen relative overflow-hidden"> | |
| <div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] bg-emerald-500/5 rounded-full blur-[100px] pointer-events-none"></div> | |
| <Card className="w-full max-w-md bg-white border border-gray-100 shadow-xl shadow-gray-200/40 rounded-3xl overflow-hidden relative z-10"> | |
| <div className="p-10 flex flex-col items-center"> | |
| <div className="w-16 h-16 bg-emerald-50 rounded-2xl flex items-center justify-center mb-6 border border-emerald-100 shadow-sm relative"> | |
| <Bot className="w-8 h-8 text-emerald-600 relative z-10" /> | |
| <div | |
| className="absolute inset-0 border-2 border-emerald-500/20 rounded-2xl animate-ping" | |
| style={{ animationDuration: '2s' }} | |
| ></div> | |
| </div> | |
| <h1 className="text-2xl font-black text-gray-900 tracking-tight text-center mb-2"> | |
| Analyzing your issue | |
| </h1> | |
| <p className="text-sm font-medium text-gray-500 text-center px-4 mb-10"> | |
| Our AI is understanding your request and checking for solutions. | |
| </p> | |
| <AIProcessingSteps | |
| steps={steps} | |
| activeStep={activeStep} | |
| /> | |
| </div> | |
| </Card> | |
| </div> | |
| ); | |
| }; | |
| export default AIProcessing; |