import React, { useEffect, useMemo, useState } from "react"; import { Sparkles, X, Loader2, Check, CheckCircle2, AlertCircle, Clock, Eye, HelpCircle, RefreshCw, } from "lucide-react"; import { __ } from "../../lib/i18n"; import { aiApi, type AiGenerateResponse } from "../../api/ai-api"; import { isAiReady } from "../../lib/ai-availability"; import { Button } from "../ui/button"; import { Modal } from "../ui/modal"; import { parseItineraryText, type ParsedDay } from "./itineraryParser"; import { detectClarification } from "./clarificationDetector"; /** * Each selectable field on the AutoFillTrip modal — maps a TripForm * field name to the prompt task that fills it and to a "decoder" * that turns the LLM's text into the shape TripForm expects. */ interface TaskSpec { /** Unique key matching a TripForm field name. */ key: string; /** Human-readable label shown in the modal. */ label: string; /** Server-side prompt task identifier. */ task: string; /** Decode the raw LLM output into the form-state value. */ decode: (text: string, formData: any) => unknown; /** Build a preview string from the decoded value for the modal. */ preview: (value: unknown) => string; } /** Item shape TripForm uses for included_items / excluded_items. */ interface AmenityItem { title: string; description: string; } /** Itinerary entry mirroring TripForm's `ItineraryEntry` minimally. */ interface ItineraryEntry { id: string; day: number; day_title?: string; item_type_id: string; item_id: string; item_type: "Activity"; title: string; description: string; start_time: string; end_time: string; time_type: "flexible"; cost_per_person: boolean; included_items: string[]; excluded_items: string[]; images: string[]; status: "active"; } /** Itinerary day shape — must match TripForm.ItineraryDay. */ interface ItineraryDay { day: number; day_title?: string; entries: ItineraryEntry[]; } /** * The LLM emits plain text with blank lines between paragraphs. The trip * description field uses a RichTextEditor that expects HTML — without * conversion the result renders as one collapsed blob. We wrap each * non-empty paragraph block in `

` and preserve single-line breaks * as `
` so list-like content (rare for descriptions) still survives. */ function plainTextToHtml(text: string): string { if (!text) return ""; const escape = (s: string) => s.replace(/&/g, "&").replace(//g, ">"); const paragraphs = text .replace(/\r\n?/g, "\n") .split(/\n{2,}/) .map((p) => p.trim()) .filter((p) => p !== ""); return paragraphs .map((p) => `

${escape(p).replace(/\n/g, "
")}

`) .join(""); } function decodeAmenityList(text: string): AmenityItem[] { return text .split(/\r?\n/) .map((line) => line .replace(/^[\s\-\*••]+/, "") // strip leading bullets, even though prompt says no .trim(), ) .filter((line) => line !== "" && !/^Day\s+\d+/i.test(line)) .map((title) => ({ title, description: "" })); } function decodeItinerary(text: string): ItineraryDay[] { const parsed: ParsedDay[] = parseItineraryText(text); return parsed.map((p) => ({ day: p.day, day_title: p.day_title, entries: [ { id: `ai-day-${p.day}-${Date.now()}`, day: p.day, day_title: p.day_title, item_type_id: "", item_id: "", item_type: "Activity", title: p.day_title || `Day ${p.day}`, description: p.description, start_time: "", end_time: "", time_type: "flexible", cost_per_person: false, included_items: [], excluded_items: [], images: [], status: "active", }, ], })); } const ALL_TASKS: TaskSpec[] = [ { key: "short_description", label: __("Short description", "yatra"), task: "trip-short-description", decode: (t) => t.trim(), preview: (v) => String(v ?? ""), }, { key: "description", label: __("Trip description", "yatra"), task: "trip-description", // RichTextEditor expects HTML — wrap LLM's plain-text paragraphs in

. decode: (t) => plainTextToHtml(t), preview: (v) => typeof v === "string" ? v .replace(/<\/p>\s*

/gi, "\n\n") .replace(/<[^>]+>/g, "") .trim() : "", }, { key: "highlights", label: __("Highlights", "yatra"), task: "trip-highlights", // TripForm's `highlights` field is a string[] — each line of the // LLM output becomes one bullet, stripping any accidental leading // bullets / dashes the model may have added despite the prompt. decode: (t) => t .split(/\r?\n/) .map((l) => l.replace(/^[\s\-\*•·●]+/, "").trim()) .filter((l) => l !== ""), preview: (v) => Array.isArray(v) ? (v as string[]).map((s) => `• ${s}`).join("\n") : "", }, { key: "included_items", label: __("What's included", "yatra"), task: "trip-included-items", decode: (t) => decodeAmenityList(t), preview: (v) => Array.isArray(v) ? (v as AmenityItem[]).map((i) => `• ${i.title}`).join("\n") : "", }, { key: "excluded_items", label: __("What's excluded", "yatra"), task: "trip-excluded-items", decode: (t) => decodeAmenityList(t), preview: (v) => Array.isArray(v) ? (v as AmenityItem[]).map((i) => `• ${i.title}`).join("\n") : "", }, { key: "cancellation_policy", label: __("Cancellation policy", "yatra"), task: "trip-cancellation-policy", decode: (t) => t.trim(), preview: (v) => String(v ?? ""), }, { key: "itinerary_days", label: __("Day-by-day itinerary", "yatra"), task: "trip-itinerary", decode: (t) => decodeItinerary(t), preview: (v) => Array.isArray(v) ? (v as ItineraryDay[]) .map( (d) => `Day ${d.day}: ${d.day_title || ""}\n${ d.entries[0]?.description ?? "" }`, ) .join("\n\n") : "", }, { key: "meta_title", label: __("SEO meta title", "yatra"), task: "seo-meta-title", decode: (t) => t.trim().slice(0, 60), preview: (v) => String(v ?? ""), }, { key: "meta_description", label: __("SEO meta description", "yatra"), task: "seo-meta-description", decode: (t) => t.trim().slice(0, 160), preview: (v) => String(v ?? ""), }, ]; type TaskStatus = "idle" | "running" | "done" | "failed" | "needs_context"; interface TaskRow { spec: TaskSpec; selected: boolean; status: TaskStatus; decoded?: unknown; preview?: string; error?: string; accepted: boolean; /** AI's clarification prose, when status === "needs_context". */ clarificationMessage?: string; /** AI's bulleted questions, when status === "needs_context". */ clarificationQuestions?: string[]; /** Operator's free-text reply to the clarification, used on retry. */ retryContext?: string; } interface AutoFillTripModalProps { open: boolean; onClose: () => void; buildContext: () => Record; /** Called once with `{ fieldName: decodedValue }` for each accepted task. */ onFieldsAccepted: (updates: Record) => void; /** When true, only the itinerary task is preselected — used by the * dedicated "Generate Itinerary" button so we don't trigger 9 calls. */ itineraryOnly?: boolean; } export const AutoFillTripModal: React.FC = ({ open, onClose, buildContext, onFieldsAccepted, itineraryOnly = false, }) => { const initialRows = useMemo( () => ALL_TASKS.map((spec) => ({ spec, selected: itineraryOnly ? spec.key === "itinerary_days" : true, status: "idle", accepted: false, })), [itineraryOnly], ); const [rows, setRows] = useState(initialRows); const [phase, setPhase] = useState<"setup" | "running" | "preview">("setup"); // Free-text context the operator types into the modal. Gets passed to // every selected task as `{{extra_context}}`, so a brand-new trip // with only a title still produces useful output instead of the LLM // politely asking for clarification. const [extraContext, setExtraContext] = useState(""); // Reset state whenever the modal re-opens — prevents stale results // from a previous run from leaking into a fresh one. useEffect(() => { if (open) { setRows(initialRows); setPhase("setup"); setExtraContext(""); } }, [open, initialRows]); if (!open) return null; if (!isAiReady()) { return (

{__("AI Assistant not configured", "yatra")}

{__( "Add an OpenAI or Anthropic key under Yatra → AI Assistant first.", "yatra", )}

); } const selectedCount = rows.filter((r) => r.selected).length; const generatedCount = rows.filter((r) => r.status === "done").length; const failedCount = rows.filter((r) => r.status === "failed").length; const needsContextCount = rows.filter( (r) => r.status === "needs_context", ).length; const allDone = rows .filter((r) => r.selected) .every( (r) => r.status === "done" || r.status === "failed" || r.status === "needs_context", ); const toggle = (key: string) => setRows((prev) => prev.map((r) => r.spec.key === key ? { ...r, selected: !r.selected } : r, ), ); const toggleAccept = (key: string) => setRows((prev) => prev.map((r) => r.spec.key === key && r.status === "done" ? { ...r, accepted: !r.accepted } : r, ), ); /** * Generate a single task. Used both for the initial batch (called via * Promise.all from runGenerations) and for inline retries when the * operator answers a clarification question. * * The extra-context argument is appended to whatever was typed in the * setup textarea — that way the operator's original notes aren't lost * when they reply to a clarification. */ const runOneTask = async (taskKey: string, extraSuffix = "") => { setRows((prev) => prev.map((row) => row.spec.key === taskKey ? { ...row, status: "running" as TaskStatus, error: undefined, clarificationMessage: undefined, clarificationQuestions: undefined, } : row, ), ); const spec = ALL_TASKS.find((t) => t.key === taskKey); if (!spec) return; const combinedContext = [extraContext.trim(), extraSuffix.trim()] .filter((s) => s !== "") .join("\n\n"); const ctx = { ...buildContext(), extra_context: combinedContext, }; try { const res: AiGenerateResponse = await aiApi.generate(spec.task, ctx); // Detect "I need more info" responses BEFORE decoding — otherwise // the question text gets pushed into form fields as if it were // content (e.g. lines become highlight bullets, zero days parse). const clarification = detectClarification(res.text, { expectDayHeads: spec.task === "trip-itinerary", }); if (clarification.isClarification) { setRows((prev) => prev.map((row) => row.spec.key === taskKey ? { ...row, status: "needs_context" as TaskStatus, clarificationMessage: clarification.message, clarificationQuestions: clarification.questions, retryContext: "", } : row, ), ); return; } const decoded = spec.decode(res.text, ctx); const preview = spec.preview(decoded); setRows((prev) => prev.map((row) => row.spec.key === taskKey ? { ...row, status: "done" as TaskStatus, decoded, preview, accepted: true, } : row, ), ); } catch (e: any) { const msg = extractError(e); setRows((prev) => prev.map((row) => row.spec.key === taskKey ? { ...row, status: "failed" as TaskStatus, error: msg, } : row, ), ); } }; const runGenerations = async () => { setPhase("running"); const selected = rows.filter((r) => r.selected).map((r) => r.spec.key); // Fire all selected tasks in parallel. The provider rate-limits us if // we go too wide; in practice 9 simultaneous chat completions is fine // for OpenAI/Anthropic on a paid tier. await Promise.all(selected.map((k) => runOneTask(k))); setPhase("preview"); }; const retryWithContext = (taskKey: string) => { const row = rows.find((r) => r.spec.key === taskKey); const suffix = row?.retryContext?.trim() ?? ""; if (suffix === "") return; void runOneTask(taskKey, suffix); }; const updateRetryContext = (taskKey: string, value: string) => setRows((prev) => prev.map((row) => row.spec.key === taskKey ? { ...row, retryContext: value } : row, ), ); const applyAccepted = () => { const updates: Record = {}; for (const r of rows) { if (r.status === "done" && r.accepted && r.decoded !== undefined) { updates[r.spec.key] = r.decoded; } } onFieldsAccepted(updates); onClose(); }; return (
{/* Header */}

{itineraryOnly ? __("Generate itinerary", "yatra") : __("Auto-fill trip with AI", "yatra")}

{/* Body */}
{phase === "setup" && (

{itineraryOnly ? __( "AI will generate a day-by-day itinerary grounded in this trip's facts. Existing itinerary will be replaced when you accept the result.", "yatra", ) : __( "Pick the sections to generate. AI runs them in parallel, then you review each result before anything touches the form.", "yatra", )}

{/* Freeform context — the single most valuable input on a fresh trip. Without it the LLM has only a title and refuses to invent facts. */}