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, "
")}
. 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
{__(
"Add an OpenAI or Anthropic key under Yatra → AI Assistant first.",
"yatra",
)}
{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",
)}
{__("Tokens count against your provider plan.", "yatra")}{" "}
{__("See usage", "yatra")}
{row.clarificationMessage}
{__("AI Assistant not configured", "yatra")}
{itineraryOnly
? __("Generate itinerary", "yatra")
: __("Auto-fill trip with AI", "yatra")}
{row.preview || (
{__("(empty)", "yatra")}
)}
)}
{row.status === "needs_context" && (
{row.clarificationQuestions.map((q, i) => (
)}