From dd664a5452aec0184afcf45aea6890813c120900 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 16 Jan 2026 01:34:43 +0100 Subject: [PATCH 1/2] feat: Implemented database table for ai suggestions, and retrieved them in web app. Solver can now ask follow up and it triggers again a new AI suggestions. Solver can navigate though suggestions --- app/Http/Controllers/Tickets/Crud.php | 68 ++++- app/Models/AiFeedback.php | 33 +++ app/Models/AiSuggestion.php | 39 +++ app/Models/Ticket.php | 10 +- backend-ai/requirements.txt | 1 + backend-ai/src/main.py | 96 +++++- backend-ai/src/templates/ticket_analysis.j2 | 52 +++- ...15_224918_create_ai_suggestions_tables.php | 45 +++ docker-compose.yml | 7 + lang/en/tickets.php | 31 +- lang/fr/tickets.php | 13 + .../tickets/partials/AiAssistantPanel.tsx | 273 ++++++++++++++++++ .../pages/tickets/tabs/informations-tab.tsx | 14 + resources/js/types/index.d.ts | 12 + routes/tickets.php | 1 + 15 files changed, 650 insertions(+), 45 deletions(-) create mode 100644 app/Models/AiFeedback.php create mode 100644 app/Models/AiSuggestion.php create mode 100644 database/migrations/2026_01_15_224918_create_ai_suggestions_tables.php create mode 100644 resources/js/pages/tickets/partials/AiAssistantPanel.tsx diff --git a/app/Http/Controllers/Tickets/Crud.php b/app/Http/Controllers/Tickets/Crud.php index ea453bf..0f04482 100644 --- a/app/Http/Controllers/Tickets/Crud.php +++ b/app/Http/Controllers/Tickets/Crud.php @@ -57,6 +57,38 @@ public function manage(Request $request): Response return $this->renderTicketList($request, 'tickets/manage'); } + public function aiFollowUp(Request $request, Ticket $ticket): RedirectResponse + { + $validated = $request->validate([ + 'feedback' => 'required|string|max:1000', + ]); + + $latestSuggestion = $ticket->aiSuggestions()->latest()->first(); + + try { + $payload = [ + 'ticket_id' => $ticket->id, + 'title' => $ticket->title, + 'description' => $ticket->description, + 'status' => 'pending_analysis', + 'previous_suggestion' => $latestSuggestion ? json_encode($latestSuggestion->generated_content) : null, + 'user_feedback' => $validated['feedback'] + ]; + + // Direct Redis push (matching Listener logic) + // Using 'ai_worker' connection as per DispatchTicketToAiQueue listener + \Illuminate\Support\Facades\Redis::connection('ai_worker')->rpush('ticket_processing_queue', json_encode($payload)); + + // Optional: You might want to create a placeholder AiSuggestion with status 'generating' if you were tracking status in DB, + // but for now we just push to queue. + + return back()->with('flash.created', 'AI refinement requested.'); + + } catch (\Exception $e) { + return back()->with('flash.error', 'Failed to request AI refinement: ' . $e->getMessage()); + } + } + private function applyUserVisibilityFilter(Builder $query, User $user, bool $onlyMyTickets = false): Builder { if ($user->hasRole('admin')) { @@ -110,7 +142,7 @@ private function renderTicketList(Request $request, string $view): Response $open = (clone $statsQuery) ->where(function (Builder $q) { - $q->whereHas('status', fn (Builder $subQ) => $subQ->where('is_closed', false)) + $q->whereHas('status', fn(Builder $subQ) => $subQ->where('is_closed', false)) ->orWhereNull('status_id'); }) ->count(); @@ -120,7 +152,7 @@ private function renderTicketList(Request $request, string $view): Response ->count(); $resolved = (clone $statsQuery) - ->whereHas('status', fn (Builder $q) => $q->where('is_closed', true)) + ->whereHas('status', fn(Builder $q) => $q->where('is_closed', true)) ->count(); $driver = config('database.default'); @@ -128,13 +160,13 @@ private function renderTicketList(Request $request, string $view): Response if ($connection === 'sqlite') { $avgResolutionDays = (clone $statsQuery) - ->whereHas('status', fn (Builder $q) => $q->where('is_closed', true)) + ->whereHas('status', fn(Builder $q) => $q->where('is_closed', true)) ->whereNotNull('updated_at') ->selectRaw('AVG(JULIANDAY(updated_at) - JULIANDAY(created_at)) as avg_days') ->value('avg_days') ?? 0; } else { $avgResolutionDays = (clone $statsQuery) - ->whereHas('status', fn (Builder $q) => $q->where('is_closed', true)) + ->whereHas('status', fn(Builder $q) => $q->where('is_closed', true)) ->whereNotNull('updated_at') ->selectRaw('AVG(TIMESTAMPDIFF(DAY, created_at, updated_at)) as avg_days') ->value('avg_days') ?? 0; @@ -224,9 +256,18 @@ public function create(): Response public function show(Ticket $ticket): Response { $ticket->load([ - 'user.avatar', 'priority', 'status', 'category', 'asset', - 'assignees.user.avatar', 'comments.user.avatar', 'comments.attachments', - 'logs.user.avatar', 'schedules.user.avatar', 'attachments', + 'user.avatar', + 'priority', + 'status', + 'category', + 'asset', + 'assignees.user.avatar', + 'comments.user.avatar', + 'comments.attachments', + 'logs.user.avatar', + 'schedules.user.avatar', + 'attachments', + 'aiSuggestions' => fn($q) => $q->latest(), ]); $searchContext = implode(' ', array_filter([ @@ -256,7 +297,8 @@ public function show(Ticket $ticket): Response $similarTickets = $filteredResults->map(function ($result) use ($ticketsInfo) { $info = $ticketsInfo->get($result['ticket_id']); - if (!$info) return null; + if (!$info) + return null; return [ 'id' => $info->id, @@ -303,7 +345,7 @@ public function show(Ticket $ticket): Response return Inertia::render('tickets/show', [ 'ticket' => $ticket, 'events' => $events, - 'solvers' => User::permission('be assigned tickets')->with('avatar')->get()->map(fn ($user) => [ + 'solvers' => User::permission('be assigned tickets')->with('avatar')->get()->map(fn($user) => [ 'id' => $user->id, 'name' => $user->name, 'email' => $user->email, @@ -540,7 +582,7 @@ public function archived(Request $request): Response 'assignees.user.avatar' ])->whereNotNull('archived_at'); - if (! $user->can('view all archived tickets')) { + if (!$user->can('view all archived tickets')) { $query = $this->applyUserVisibilityFilter($query, $user, true); } @@ -550,7 +592,7 @@ public function archived(Request $request): Response $statsQuery = Ticket::whereNotNull('archived_at'); - if (! $user->can('view all archived tickets')) { + if (!$user->can('view all archived tickets')) { $statsQuery = $this->applyUserVisibilityFilter($statsQuery, $user, true); } @@ -561,10 +603,10 @@ public function archived(Request $request): Response ->whereNotNull('status_id') ->groupBy('status_id') ->get() - ->mapWithKeys(fn ($item) => [$item->status_id => (int) $item->count]); + ->mapWithKeys(fn($item) => [$item->status_id => (int) $item->count]); $resolved = (clone $statsQuery) - ->whereHas('status', fn (Builder $q) => $q->where('is_closed', true)) + ->whereHas('status', fn(Builder $q) => $q->where('is_closed', true)) ->count(); $driver = config('database.default'); diff --git a/app/Models/AiFeedback.php b/app/Models/AiFeedback.php new file mode 100644 index 0000000..a947de2 --- /dev/null +++ b/app/Models/AiFeedback.php @@ -0,0 +1,33 @@ +belongsTo(AiSuggestion::class, 'suggestion_id'); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/AiSuggestion.php b/app/Models/AiSuggestion.php new file mode 100644 index 0000000..2914998 --- /dev/null +++ b/app/Models/AiSuggestion.php @@ -0,0 +1,39 @@ + 'array', + 'generated_content' => 'array', + 'retrieved_chunks' => 'array', + ]; + + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class); + } + + public function feedback(): HasOne + { + return $this->hasOne(AiFeedback::class, 'suggestion_id'); + } +} diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php index c098d32..4ec94d4 100644 --- a/app/Models/Ticket.php +++ b/app/Models/Ticket.php @@ -9,7 +9,7 @@ use Illuminate\Database\Eloquent\Relations\HasManyThrough; use Illuminate\Database\Eloquent\SoftDeletes; -class Ticket extends Model +class Ticket extends Model { use HasFactory, SoftDeletes; @@ -143,6 +143,14 @@ public function author(): BelongsTo return $this->belongsTo(User::class, 'author_id'); } + /** + * @return HasMany + */ + public function aiSuggestions(): HasMany + { + return $this->hasMany(AiSuggestion::class); + } + /** * Scope to filter archived tickets */ diff --git a/backend-ai/requirements.txt b/backend-ai/requirements.txt index 321265d..6a3f577 100644 --- a/backend-ai/requirements.txt +++ b/backend-ai/requirements.txt @@ -2,3 +2,4 @@ redis==5.0.1 requests jinja2 openai +pymysql diff --git a/backend-ai/src/main.py b/backend-ai/src/main.py index 16b833b..a20b960 100644 --- a/backend-ai/src/main.py +++ b/backend-ai/src/main.py @@ -8,11 +8,21 @@ from pydantic import BaseModel, Field, ValidationError from typing import List, Optional +import pymysql +import hashlib + # Configuration via environment variables (Best Practice: 12-factor app) REDIS_HOST = os.getenv('REDIS_HOST', 'redis') REDIS_PORT = int(os.getenv('REDIS_PORT', 6379)) QUEUE_NAME = os.getenv('REDIS_QUEUE_NAME', 'ticket_processing_queue') +# DB Configuration +DB_HOST = os.getenv('DB_HOST', 'db') +DB_PORT = int(os.getenv('DB_PORT', 3306)) +DB_USER = os.getenv('DB_USER', 'ticketack') +DB_PASSWORD = os.getenv('DB_PASSWORD', 'secret') +DB_DATABASE = os.getenv('DB_DATABASE', 'ticketack') + # LiteLLM Configuration LITELLM_HOST = os.getenv('LITELLM_HOST', 'http://litellm:4000') MODEL_NAME = "gemma:2b" @@ -32,15 +42,74 @@ ETL_API_URL = os.getenv('ETL_API_URL', 'http://etl-api:8000') # Pydantic model to validate the AI output + +class AnalysisStep(BaseModel): + description: str = Field(description="Titre ou action principale de l'étape") + details: Optional[str] = Field(description="Détails techniques ou commande à exécuter", default="") + confidence_score: Optional[float] = Field(description="Confiance spécifique pour cette étape", default=None) + class TicketAnalysis(BaseModel): summary: str = Field(description="Résumé du problème identifié en 1 phrase") analysis: str = Field(description="Analyse technique de la cause probable") - steps: List[str] = Field(description="Liste des étapes de résolution") + steps: List[AnalysisStep] = Field(description="Liste structurée des étapes de résolution") missing_info: Optional[str] = Field(description="Questions à poser au client si incomplet") - confidence_score: float = Field(description="Score de confiance entre 0.0 et 1.0") + confidence_score: float = Field(description="Score de confiance global entre 0.0 et 1.0") citations: List[str] = Field(description="IDs des documents utilisés", default_factory=list) +def get_db_connection(): + return pymysql.connect( + host=DB_HOST, + user=DB_USER, + password=DB_PASSWORD, + database=DB_DATABASE, + port=DB_PORT, + cursorclass=pymysql.cursors.DictCursor, + autocommit=True + ) + +def save_suggestion_to_db(ticket_id, analysis_json, prompt_text, documents, model_name, temp=0.2): + try: + # Calculate prompt hash + prompt_hash = hashlib.sha256(prompt_text.encode('utf-8')).hexdigest() + + # Prepare snapshot + model_snapshot = json.dumps({ + "model": model_name, + "temperature": temp + }) + + # Prepare retrieved chunks + chunks_snapshot = json.dumps(documents) + + # Parse analysis to get confidence and time (simulation for time) + # Note: analysis_json is already a JSON string from model_dump_json + analysis_data = json.loads(analysis_json) + confidence = analysis_data.get('confidence_score', 0.5) + + connection = get_db_connection() + with connection.cursor() as cursor: + sql = """ + INSERT INTO ai_suggestions + (ticket_id, model_config_snapshot, prompt_hash, generated_content, retrieved_chunks, confidence_score, processing_time_ms, created_at, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) + """ + cursor.execute(sql, ( + ticket_id, + model_snapshot, + prompt_hash, + analysis_json, + chunks_snapshot, + confidence, + 0 # Processing time not tracked strictly yet + )) + connection.close() + print(f"💾 [IA] Suggestion sauvegardée en BDD pour le ticket #{ticket_id}.") + return True + except Exception as e: + print(f"❌ [IA] Erreur sauvegarde BDD : {str(e)}") + return False + def retrieve_context(query, limit=3): """ Queries the ETL API (endpoint /retrieve_context) to retrieve content. @@ -67,7 +136,7 @@ def retrieve_context(query, limit=3): print(f"⚠️ [RAG] Erreur lors de la récupération du contexte : {str(e)}") return [] -def generate_ai_opinion(ticket_id, title, context_text): +def generate_ai_opinion(ticket_id, title, context_text, user_feedback=None, previous_suggestion=None): """ Generates an AI opinion via LiteLLM and validates with Pydantic. """ @@ -80,11 +149,15 @@ def generate_ai_opinion(ticket_id, title, context_text): prompt = template.render( title=title, description=context_text, - documents=documents + documents=documents, + user_feedback=user_feedback, + previous_suggestion=previous_suggestion ) print(f"🧠 [IA] Envoi du prompt à LiteLLM ({MODEL_NAME})...") + temperature = 0.2 + # 3. LLM call response = client.chat.completions.create( model=MODEL_NAME, @@ -92,7 +165,7 @@ def generate_ai_opinion(ticket_id, title, context_text): {"role": "user", "content": prompt} ], stream=False, - temperature=0.2 # More deterministic for JSON + temperature=temperature ) raw_content = response.choices[0].message.content @@ -109,7 +182,13 @@ def generate_ai_opinion(ticket_id, title, context_text): analysis = TicketAnalysis(**data) print("✅ [IA] JSON validé par Pydantic.") - return analysis.model_dump_json(indent=2) + + final_json = analysis.model_dump_json(indent=2) + + # 5. Save to DB + save_suggestion_to_db(ticket_id, final_json, prompt, documents, MODEL_NAME, temperature) + + return final_json except json.JSONDecodeError: print(f"⚠️ [IA] Echec du parsing JSON. Raw: {raw_content[:200]}...") @@ -131,9 +210,12 @@ def process_ticket(payload): title = payload.get('title') description = payload.get('description', 'Pas de description fournie.') + user_feedback = payload.get('user_feedback') + previous_suggestion = payload.get('previous_suggestion') + print(f"🤖 [IA] Analyse du ticket #{ticket_id} : {title}") - opinion = generate_ai_opinion(ticket_id, title, description) + opinion = generate_ai_opinion(ticket_id, title, description, user_feedback, previous_suggestion) print("="*60) print(f"📝 OPINION IA pour le ticket #{ticket_id}") diff --git a/backend-ai/src/templates/ticket_analysis.j2 b/backend-ai/src/templates/ticket_analysis.j2 index 4594add..631ef33 100644 --- a/backend-ai/src/templates/ticket_analysis.j2 +++ b/backend-ai/src/templates/ticket_analysis.j2 @@ -11,22 +11,44 @@ Contexte (Documents récupérés de la base de connaissance): Aucun document pertinent trouvé dans la base de connaissance. {% endif %} -Demande du Client: -Titre: {{ title }} -Description: {{ description }} +Vous êtes un expert technique de support informatique niveau 3. +Analysez le ticket suivant pour proposer une solution structurée. -Instructions de Sécurité et Qualité: -1. Base-toi EXCLUSIVEMENT sur les documents fournis dans le contexte. Si les documents ne contiennent pas la réponse, indique-le clairement et propose des pistes de diagnostic générales. -2. Ne jamais inventer de commandes, de chemins de fichiers ou de procédures qui ne sont pas dans le contexte. -3. Adopte un ton professionnel, technique mais pédagogique. +Titre du ticket : {{ title }} +Description : +{{ description }} -Format de Sortie Attendu (JSON Strict): -Réponds UNIQUEMENT avec un objet JSON respectant ce schéma : +{% if previous_suggestion %} +--- +CONTEXTE CONVERSATIONNEL : +Le système a déjà proposé une solution, mais l'utilisateur souhaite une précision ou une correction. + +Dernière suggestion faite par l'IA : +{{ previous_suggestion }} + +Retour de l'utilisateur (Feedback) : +"{{ user_feedback }}" + +INSTRUCTION SUPPLELMENTAIRE : +Veuillez réviser votre analyse en tenant compte spécifiquement du retour de l'utilisateur ci-dessus. Si l'utilisateur demande une clarification, détaillez ce point. Si l'utilisateur signale une erreur, corrigez-la. +--- +{% endif %} + +Documents de contexte (RAG) : +{% for doc in documents %} +- [{{ doc.source }}] : {{ doc.content }} +{% endfor %} + +Répondez UNIQUEMENT au format JSON strict suivant : { -"summary": "Résumé du problème identifié en 1 phrase", -"analysis": "Analyse technique de la cause probable", -"steps": ["Étape 1", "Étape 2",...], -"missing_info": "Questions à poser au client si le ticket est incomplet", -"confidence_score": 0.0 à 1.0, -"citations": ["id du document utilisé"] + "summary": "Résumé du problème identifié en 1 phrase", + "analysis": "Analyse technique de la cause probable (prenant en compte le feedback si présent)", + "steps": [ + { + "description": "Titre de l'action", + "details": "Détails techniques, commandes ou explication approfondie" + } + ], + "missing_info": "Questions à poser au client si incomplet (facultatif)", + "confidence_score": 0.9 } diff --git a/database/migrations/2026_01_15_224918_create_ai_suggestions_tables.php b/database/migrations/2026_01_15_224918_create_ai_suggestions_tables.php new file mode 100644 index 0000000..2150c19 --- /dev/null +++ b/database/migrations/2026_01_15_224918_create_ai_suggestions_tables.php @@ -0,0 +1,45 @@ +id(); + $table->foreignId('ticket_id')->constrained('tickets')->onDelete('cascade'); + $table->json('model_config_snapshot')->comment('Version du modèle et paramètres (temp, top_p) au moment du tirage'); + $table->char('prompt_hash', 64)->comment('Empreinte du template de prompt utilisé'); + $table->json('generated_content')->comment('La réponse structurée brute de l IA'); + $table->json('retrieved_chunks')->nullable()->comment('IDs et scores des documents RAG utilisés'); + $table->float('confidence_score')->nullable(); + $table->integer('processing_time_ms')->nullable(); + $table->timestamps(); + }); + + Schema::create('ai_feedbacks', function (Blueprint $table) { + $table->id(); + $table->foreignId('suggestion_id')->constrained('ai_suggestions')->onDelete('cascade'); + $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); + $table->enum('action_type', ['accepted', 'edited', 'rejected']); + $table->text('final_content')->nullable()->comment('Contenu final réellement envoyé au client'); + $table->enum('rejection_reason', ['hallucination', 'irrelevant', 'outdated', 'unsafe', 'other'])->nullable(); + $table->text('rejection_comment')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ai_feedbacks'); + Schema::dropIfExists('ai_suggestions'); + } +}; diff --git a/docker-compose.yml b/docker-compose.yml index 8b0f256..53af96d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -193,8 +193,15 @@ services: REDIS_HOST: redis REDIS_PORT: 6379 REDIS_QUEUE_NAME: ticket_processing_queue + DB_CONNECTION: mysql + DB_HOST: db + DB_PORT: 3306 + DB_DATABASE: ticketack + DB_USERNAME: ticketack + DB_PASSWORD: secret networks: - sail + - ticketack-network depends_on: - redis - litellm diff --git a/lang/en/tickets.php b/lang/en/tickets.php index f604292..26d593e 100644 --- a/lang/en/tickets.php +++ b/lang/en/tickets.php @@ -146,22 +146,22 @@ 'category' => 'Category', 'clear' => 'Clear filters', ], - 'fields'=> [ - 'id' => 'ID', - 'title' => 'Title', - 'status' => 'Status', - 'priority' => 'Priority', - 'author' => 'Author', + 'fields' => [ + 'id' => 'ID', + 'title' => 'Title', + 'status' => 'Status', + 'priority' => 'Priority', + 'author' => 'Author', 'updated_at' => 'Updated At', ], 'status' => [ - 'open' => 'Open', + 'open' => 'Open', 'closed' => 'Closed', ], 'priority' => [ - 'low' => 'Low', + 'low' => 'Low', 'medium' => 'Medium', - 'high' => 'High', + 'high' => 'High', ], 'archive' => [ @@ -674,4 +674,17 @@ 'updated_at' => 'Last updated', 'footer_text' => 'Document generated by', ], + 'ai_assistant_title' => 'AI Assistant', + 'ai_summary' => 'Summary', + 'ai_steps' => 'Suggested Steps', + 'btn_accept' => 'Accept Solution', + 'btn_reject' => 'Reject', + 'confirm_modal_title' => 'Validate Solution', + 'confirm_modal_body' => 'Are you sure this solution is valid and safe to send?', + 'ai_solution_copied' => 'Solution copied to clipboard', + 'ai_suggestion_accepted_header' => 'AI Solution:', + 'btn_refine' => 'Refine', + 'ai_refine_label' => 'Refine your request or correct the AI', + 'ai_refine_placeholder' => 'Ex: Can you detail step 2? / The error is simpler...', + 'ai_refine_submit' => 'Generate new solution', ]; diff --git a/lang/fr/tickets.php b/lang/fr/tickets.php index 12c9ca1..aafed41 100644 --- a/lang/fr/tickets.php +++ b/lang/fr/tickets.php @@ -674,4 +674,17 @@ 'updated_at' => 'Dernière mise à jour', 'footer_text' => 'Document généré par', ], + 'ai_assistant_title' => 'Assistant IA', + 'ai_summary' => 'Résumé', + 'ai_steps' => 'Étapes Suggérées', + 'btn_accept' => 'Accepter la Solution', + 'btn_reject' => 'Refuser', + 'confirm_modal_title' => 'Valider la Solution', + 'confirm_modal_body' => 'Êtes-vous sûr que cette solution est valide ?', + 'ai_solution_copied' => 'Solution copiée dans le presse-papier', + 'ai_suggestion_accepted_header' => 'Solution IA :', + 'btn_refine' => 'Affiner', + 'ai_refine_label' => 'Précisez votre demande ou corrigez l\'IA', + 'ai_refine_placeholder' => 'Ex: Peux-tu détailler l\'étape 2 ? / L\'erreur est plus simple...', + 'ai_refine_submit' => 'Générer nouvelle solution', ]; \ No newline at end of file diff --git a/resources/js/pages/tickets/partials/AiAssistantPanel.tsx b/resources/js/pages/tickets/partials/AiAssistantPanel.tsx new file mode 100644 index 0000000..c2c70b3 --- /dev/null +++ b/resources/js/pages/tickets/partials/AiAssistantPanel.tsx @@ -0,0 +1,273 @@ +import React, { useState } from 'react'; +import { useTrans } from '@/lib/translation'; +import { Description, Dialog, DialogPanel, DialogTitle } from '@headlessui/react'; +import { router } from '@inertiajs/react'; +import { CheckCircle, ChevronLeft, ChevronRight, MessageSquarePlus, Sparkles, XCircle } from 'lucide-react'; + +interface AiSuggestion { + id: number; + generated_content: { + summary: string; + steps: Array<{ + description: string; + details?: string; + }>; + analysis: string; + missing_info?: string; + }; + confidence_score?: number; + created_at?: string; +} + +interface Props { + ticketId: number; + suggestions: AiSuggestion[]; + onAccept: (content: string) => void; + onReject?: () => void; +} + +export default function AiAssistantPanel({ ticketId, suggestions, onAccept, onReject }: Props) { + const trans = useTrans(); + const t = (key: string, defaultVal?: string): string => trans(key) || defaultVal || key; + + const [activeIndex, setActiveIndex] = useState(0); + const [isOpen, setIsOpen] = useState(false); + const [isRejected, setIsRejected] = useState(false); + const [isAccepted, setIsAccepted] = useState(false); + + // Follow-up state + const [isRefining, setIsRefining] = useState(false); + const [feedback, setFeedback] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + // If no suggestions, or rejected explicitly (visual hide), return null + if (!suggestions || suggestions.length === 0 || isRejected) return null; + + const currentSuggestion = suggestions[activeIndex]; + + const handleAcceptClick = () => { + setIsOpen(true); + }; + + const confirmAccept = () => { + const stepsFormatted = currentSuggestion.generated_content.steps + .map((step, i) => `${i + 1}. **${step.description}**\n ${step.details || ''}`) + .join('\n\n'); + + const contentToCopy = `**${t('tickets.ai_suggestion_accepted_header', 'AI Solution:')}**\n\n${stepsFormatted}`; + + onAccept(contentToCopy); + setIsAccepted(true); + setIsOpen(false); + }; + + const handleReject = () => { + setIsRejected(true); + if (onReject) onReject(); + }; + + const handlePrevious = () => { + if (activeIndex < suggestions.length - 1) { + setActiveIndex(activeIndex + 1); + } + }; + + const handleNext = () => { + if (activeIndex > 0) { + setActiveIndex(activeIndex - 1); + } + }; + + const submitRefinement = (e: React.FormEvent) => { + e.preventDefault(); + if (!feedback.trim()) return; + + setIsSubmitting(true); + router.post(route('tickets.ai_followup', ticketId), { + feedback: feedback + }, { + preserveScroll: true, + onSuccess: () => { + setIsRefining(false); + setFeedback(''); + // Usually Inertia reload will update props, reset index to 0 (latest) + setActiveIndex(0); + setIsSubmitting(false); + }, + onError: () => { + setIsSubmitting(false); + } + }); + }; + + return ( + <> +
+
+
+ +

+ {t('tickets.ai_assistant_title', 'Assistant IA')} +

+ {suggestions.length > 1 && ( + + ({suggestions.length - activeIndex}/{suggestions.length}) + + )} +
+ +
+ {/* Navigation Buttons */} + {suggestions.length > 1 && ( +
+ +
+ +
+ )} + + {currentSuggestion.confidence_score && ( + + {(currentSuggestion.confidence_score * 100).toFixed(0)}% + + )} +
+
+ +
+
+

+ {t('tickets.ai_summary', 'Résumé')} +

+

+ {currentSuggestion.generated_content.summary} +

+
+ +
+

+ {t('tickets.ai_steps', 'Étapes suggérées')} +

+
    + {currentSuggestion.generated_content.steps.map((step, idx) => ( +
  • + + {idx + 1} + +
    +

    + {typeof step === 'string' ? step : step.description} +

    + {typeof step !== 'string' && step.details && ( +

    + {step.details} +

    + )} +
    +
  • + ))} +
+
+ + {isRefining ? ( +
+ +