PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
yatra / resources / js / components / ai / AutoFillTripModal.tsx

AutoFillTripModal.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.13, at resources/js/components/ai/AutoFillTripModal.tsx

810 lines 27.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useEffect, useMemo, useState } from "react";
2 import {
3 Sparkles,
4 X,
5 Loader2,
6 Check,
7 CheckCircle2,
8 AlertCircle,
9 Clock,
10 Eye,
11 HelpCircle,
12 RefreshCw,
13 } from "lucide-react";
14 import { __ } from "../../lib/i18n";
15 import { aiApi, type AiGenerateResponse } from "../../api/ai-api";
16 import { isAiReady } from "../../lib/ai-availability";
17 import { Button } from "../ui/button";
18 import { Modal } from "../ui/modal";
19 import { parseItineraryText, type ParsedDay } from "./itineraryParser";
20 import { detectClarification } from "./clarificationDetector";
21
22 /**
23 * Each selectable field on the AutoFillTrip modal — maps a TripForm
24 * field name to the prompt task that fills it and to a "decoder"
25 * that turns the LLM's text into the shape TripForm expects.
26 */
27 interface TaskSpec {
28 /** Unique key matching a TripForm field name. */
29 key: string;
30 /** Human-readable label shown in the modal. */
31 label: string;
32 /** Server-side prompt task identifier. */
33 task: string;
34 /** Decode the raw LLM output into the form-state value. */
35 decode: (text: string, formData: any) => unknown;
36 /** Build a preview string from the decoded value for the modal. */
37 preview: (value: unknown) => string;
38 }
39
40 /** Item shape TripForm uses for included_items / excluded_items. */
41 interface AmenityItem {
42 title: string;
43 description: string;
44 }
45
46 /** Itinerary entry mirroring TripForm's `ItineraryEntry` minimally. */
47 interface ItineraryEntry {
48 id: string;
49 day: number;
50 day_title?: string;
51 item_type_id: string;
52 item_id: string;
53 item_type: "Activity";
54 title: string;
55 description: string;
56 start_time: string;
57 end_time: string;
58 time_type: "flexible";
59 cost_per_person: boolean;
60 included_items: string[];
61 excluded_items: string[];
62 images: string[];
63 status: "active";
64 }
65
66 /** Itinerary day shape — must match TripForm.ItineraryDay. */
67 interface ItineraryDay {
68 day: number;
69 day_title?: string;
70 entries: ItineraryEntry[];
71 }
72
73 /**
74 * The LLM emits plain text with blank lines between paragraphs. The trip
75 * description field uses a RichTextEditor that expects HTML — without
76 * conversion the result renders as one collapsed blob. We wrap each
77 * non-empty paragraph block in `<p>` and preserve single-line breaks
78 * as `<br>` so list-like content (rare for descriptions) still survives.
79 */
80 function plainTextToHtml(text: string): string {
81 if (!text) return "";
82 const escape = (s: string) =>
83 s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
84 const paragraphs = text
85 .replace(/\r\n?/g, "\n")
86 .split(/\n{2,}/)
87 .map((p) => p.trim())
88 .filter((p) => p !== "");
89 return paragraphs
90 .map((p) => `<p>${escape(p).replace(/\n/g, "<br>")}</p>`)
91 .join("");
92 }
93
94 function decodeAmenityList(text: string): AmenityItem[] {
95 return text
96 .split(/\r?\n/)
97 .map((line) =>
98 line
99 .replace(/^[\s\-\*••]+/, "") // strip leading bullets, even though prompt says no
100 .trim(),
101 )
102 .filter((line) => line !== "" && !/^Day\s+\d+/i.test(line))
103 .map((title) => ({ title, description: "" }));
104 }
105
106 function decodeItinerary(text: string): ItineraryDay[] {
107 const parsed: ParsedDay[] = parseItineraryText(text);
108 return parsed.map<ItineraryDay>((p) => ({
109 day: p.day,
110 day_title: p.day_title,
111 entries: [
112 {
113 id: `ai-day-${p.day}-${Date.now()}`,
114 day: p.day,
115 day_title: p.day_title,
116 item_type_id: "",
117 item_id: "",
118 item_type: "Activity",
119 title: p.day_title || `Day ${p.day}`,
120 description: p.description,
121 start_time: "",
122 end_time: "",
123 time_type: "flexible",
124 cost_per_person: false,
125 included_items: [],
126 excluded_items: [],
127 images: [],
128 status: "active",
129 },
130 ],
131 }));
132 }
133
134 const ALL_TASKS: TaskSpec[] = [
135 {
136 key: "short_description",
137 label: __("Short description", "yatra"),
138 task: "trip-short-description",
139 decode: (t) => t.trim(),
140 preview: (v) => String(v ?? ""),
141 },
142 {
143 key: "description",
144 label: __("Trip description", "yatra"),
145 task: "trip-description",
146 // RichTextEditor expects HTML — wrap LLM's plain-text paragraphs in <p>.
147 decode: (t) => plainTextToHtml(t),
148 preview: (v) =>
149 typeof v === "string"
150 ? v
151 .replace(/<\/p>\s*<p>/gi, "\n\n")
152 .replace(/<[^>]+>/g, "")
153 .trim()
154 : "",
155 },
156 {
157 key: "highlights",
158 label: __("Highlights", "yatra"),
159 task: "trip-highlights",
160 // TripForm's `highlights` field is a string[] — each line of the
161 // LLM output becomes one bullet, stripping any accidental leading
162 // bullets / dashes the model may have added despite the prompt.
163 decode: (t) =>
164 t
165 .split(/\r?\n/)
166 .map((l) => l.replace(/^[\s\-\*•·●]+/, "").trim())
167 .filter((l) => l !== ""),
168 preview: (v) =>
169 Array.isArray(v) ? (v as string[]).map((s) => `• ${s}`).join("\n") : "",
170 },
171 {
172 key: "included_items",
173 label: __("What's included", "yatra"),
174 task: "trip-included-items",
175 decode: (t) => decodeAmenityList(t),
176 preview: (v) =>
177 Array.isArray(v)
178 ? (v as AmenityItem[]).map((i) => `• ${i.title}`).join("\n")
179 : "",
180 },
181 {
182 key: "excluded_items",
183 label: __("What's excluded", "yatra"),
184 task: "trip-excluded-items",
185 decode: (t) => decodeAmenityList(t),
186 preview: (v) =>
187 Array.isArray(v)
188 ? (v as AmenityItem[]).map((i) => `• ${i.title}`).join("\n")
189 : "",
190 },
191 {
192 key: "cancellation_policy",
193 label: __("Cancellation policy", "yatra"),
194 task: "trip-cancellation-policy",
195 decode: (t) => t.trim(),
196 preview: (v) => String(v ?? ""),
197 },
198 {
199 key: "itinerary_days",
200 label: __("Day-by-day itinerary", "yatra"),
201 task: "trip-itinerary",
202 decode: (t) => decodeItinerary(t),
203 preview: (v) =>
204 Array.isArray(v)
205 ? (v as ItineraryDay[])
206 .map(
207 (d) =>
208 `Day ${d.day}: ${d.day_title || ""}\n${
209 d.entries[0]?.description ?? ""
210 }`,
211 )
212 .join("\n\n")
213 : "",
214 },
215 {
216 key: "meta_title",
217 label: __("SEO meta title", "yatra"),
218 task: "seo-meta-title",
219 decode: (t) => t.trim().slice(0, 60),
220 preview: (v) => String(v ?? ""),
221 },
222 {
223 key: "meta_description",
224 label: __("SEO meta description", "yatra"),
225 task: "seo-meta-description",
226 decode: (t) => t.trim().slice(0, 160),
227 preview: (v) => String(v ?? ""),
228 },
229 ];
230
231 type TaskStatus = "idle" | "running" | "done" | "failed" | "needs_context";
232
233 interface TaskRow {
234 spec: TaskSpec;
235 selected: boolean;
236 status: TaskStatus;
237 decoded?: unknown;
238 preview?: string;
239 error?: string;
240 accepted: boolean;
241 /** AI's clarification prose, when status === "needs_context". */
242 clarificationMessage?: string;
243 /** AI's bulleted questions, when status === "needs_context". */
244 clarificationQuestions?: string[];
245 /** Operator's free-text reply to the clarification, used on retry. */
246 retryContext?: string;
247 }
248
249 interface AutoFillTripModalProps {
250 open: boolean;
251 onClose: () => void;
252 buildContext: () => Record<string, unknown>;
253 /** Called once with `{ fieldName: decodedValue }` for each accepted task. */
254 onFieldsAccepted: (updates: Record<string, unknown>) => void;
255 /** When true, only the itinerary task is preselected — used by the
256 * dedicated "Generate Itinerary" button so we don't trigger 9 calls. */
257 itineraryOnly?: boolean;
258 }
259
260 export const AutoFillTripModal: React.FC<AutoFillTripModalProps> = ({
261 open,
262 onClose,
263 buildContext,
264 onFieldsAccepted,
265 itineraryOnly = false,
266 }) => {
267 const initialRows = useMemo<TaskRow[]>(
268 () =>
269 ALL_TASKS.map((spec) => ({
270 spec,
271 selected: itineraryOnly ? spec.key === "itinerary_days" : true,
272 status: "idle",
273 accepted: false,
274 })),
275 [itineraryOnly],
276 );
277
278 const [rows, setRows] = useState<TaskRow[]>(initialRows);
279 const [phase, setPhase] = useState<"setup" | "running" | "preview">("setup");
280 // Free-text context the operator types into the modal. Gets passed to
281 // every selected task as `{{extra_context}}`, so a brand-new trip
282 // with only a title still produces useful output instead of the LLM
283 // politely asking for clarification.
284 const [extraContext, setExtraContext] = useState("");
285
286 // Reset state whenever the modal re-opens — prevents stale results
287 // from a previous run from leaking into a fresh one.
288 useEffect(() => {
289 if (open) {
290 setRows(initialRows);
291 setPhase("setup");
292 setExtraContext("");
293 }
294 }, [open, initialRows]);
295
296 if (!open) return null;
297
298 if (!isAiReady()) {
299 return (
300 <Modal isOpen={open} onClose={onClose} size="md" hideHeader hideFooter>
301 <div className="p-6 max-w-md">
302 <div className="flex items-start gap-3">
303 <AlertCircle className="h-5 w-5 text-amber-500 mt-0.5" />
304 <div>
305 <h3 className="font-semibold text-gray-900 dark:text-white">
306 {__("AI Assistant not configured", "yatra")}
307 </h3>
308 <p className="mt-1 text-sm text-gray-600 dark:text-gray-300">
309 {__(
310 "Add an OpenAI or Anthropic key under Yatra → AI Assistant first.",
311 "yatra",
312 )}
313 </p>
314 <div className="mt-3 flex gap-2">
315 <Button asChild>
316 <a href="admin.php?page=yatra&subpage=ai-assistant">
317 {__("Open AI settings", "yatra")}
318 </a>
319 </Button>
320 <Button variant="outline" onClick={onClose}>
321 {__("Cancel", "yatra")}
322 </Button>
323 </div>
324 </div>
325 </div>
326 </div>
327 </Modal>
328 );
329 }
330
331 const selectedCount = rows.filter((r) => r.selected).length;
332 const generatedCount = rows.filter((r) => r.status === "done").length;
333 const failedCount = rows.filter((r) => r.status === "failed").length;
334 const needsContextCount = rows.filter(
335 (r) => r.status === "needs_context",
336 ).length;
337 const allDone = rows
338 .filter((r) => r.selected)
339 .every(
340 (r) =>
341 r.status === "done" ||
342 r.status === "failed" ||
343 r.status === "needs_context",
344 );
345
346 const toggle = (key: string) =>
347 setRows((prev) =>
348 prev.map((r) =>
349 r.spec.key === key ? { ...r, selected: !r.selected } : r,
350 ),
351 );
352
353 const toggleAccept = (key: string) =>
354 setRows((prev) =>
355 prev.map((r) =>
356 r.spec.key === key && r.status === "done"
357 ? { ...r, accepted: !r.accepted }
358 : r,
359 ),
360 );
361
362 /**
363 * Generate a single task. Used both for the initial batch (called via
364 * Promise.all from runGenerations) and for inline retries when the
365 * operator answers a clarification question.
366 *
367 * The extra-context argument is appended to whatever was typed in the
368 * setup textarea — that way the operator's original notes aren't lost
369 * when they reply to a clarification.
370 */
371 const runOneTask = async (taskKey: string, extraSuffix = "") => {
372 setRows((prev) =>
373 prev.map((row) =>
374 row.spec.key === taskKey
375 ? {
376 ...row,
377 status: "running" as TaskStatus,
378 error: undefined,
379 clarificationMessage: undefined,
380 clarificationQuestions: undefined,
381 }
382 : row,
383 ),
384 );
385
386 const spec = ALL_TASKS.find((t) => t.key === taskKey);
387 if (!spec) return;
388
389 const combinedContext = [extraContext.trim(), extraSuffix.trim()]
390 .filter((s) => s !== "")
391 .join("\n\n");
392
393 const ctx = {
394 ...buildContext(),
395 extra_context: combinedContext,
396 };
397
398 try {
399 const res: AiGenerateResponse = await aiApi.generate(spec.task, ctx);
400
401 // Detect "I need more info" responses BEFORE decoding — otherwise
402 // the question text gets pushed into form fields as if it were
403 // content (e.g. lines become highlight bullets, zero days parse).
404 const clarification = detectClarification(res.text, {
405 expectDayHeads: spec.task === "trip-itinerary",
406 });
407 if (clarification.isClarification) {
408 setRows((prev) =>
409 prev.map((row) =>
410 row.spec.key === taskKey
411 ? {
412 ...row,
413 status: "needs_context" as TaskStatus,
414 clarificationMessage: clarification.message,
415 clarificationQuestions: clarification.questions,
416 retryContext: "",
417 }
418 : row,
419 ),
420 );
421 return;
422 }
423
424 const decoded = spec.decode(res.text, ctx);
425 const preview = spec.preview(decoded);
426 setRows((prev) =>
427 prev.map((row) =>
428 row.spec.key === taskKey
429 ? {
430 ...row,
431 status: "done" as TaskStatus,
432 decoded,
433 preview,
434 accepted: true,
435 }
436 : row,
437 ),
438 );
439 } catch (e: any) {
440 const msg = extractError(e);
441 setRows((prev) =>
442 prev.map((row) =>
443 row.spec.key === taskKey
444 ? {
445 ...row,
446 status: "failed" as TaskStatus,
447 error: msg,
448 }
449 : row,
450 ),
451 );
452 }
453 };
454
455 const runGenerations = async () => {
456 setPhase("running");
457 const selected = rows.filter((r) => r.selected).map((r) => r.spec.key);
458 // Fire all selected tasks in parallel. The provider rate-limits us if
459 // we go too wide; in practice 9 simultaneous chat completions is fine
460 // for OpenAI/Anthropic on a paid tier.
461 await Promise.all(selected.map((k) => runOneTask(k)));
462 setPhase("preview");
463 };
464
465 const retryWithContext = (taskKey: string) => {
466 const row = rows.find((r) => r.spec.key === taskKey);
467 const suffix = row?.retryContext?.trim() ?? "";
468 if (suffix === "") return;
469 void runOneTask(taskKey, suffix);
470 };
471
472 const updateRetryContext = (taskKey: string, value: string) =>
473 setRows((prev) =>
474 prev.map((row) =>
475 row.spec.key === taskKey ? { ...row, retryContext: value } : row,
476 ),
477 );
478
479 const applyAccepted = () => {
480 const updates: Record<string, unknown> = {};
481 for (const r of rows) {
482 if (r.status === "done" && r.accepted && r.decoded !== undefined) {
483 updates[r.spec.key] = r.decoded;
484 }
485 }
486 onFieldsAccepted(updates);
487 onClose();
488 };
489
490 return (
491 <Modal
492 isOpen={open}
493 onClose={onClose}
494 size="xl"
495 hideHeader
496 hideFooter
497 bodyScrollClassName="overflow-visible"
498 >
499 <div className="flex h-[80vh] max-h-[800px] min-w-[640px] max-w-[920px] flex-col">
500 {/* Header */}
501 <div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
502 <div className="flex items-center gap-2">
503 <Sparkles className="h-5 w-5 text-blue-600" />
504 <h2 className="text-lg font-semibold text-gray-900 dark:text-white">
505 {itineraryOnly
506 ? __("Generate itinerary", "yatra")
507 : __("Auto-fill trip with AI", "yatra")}
508 </h2>
509 </div>
510 <button
511 type="button"
512 onClick={onClose}
513 className="rounded text-gray-400 hover:text-gray-600"
514 aria-label={__("Close", "yatra")}
515 >
516 <X className="h-5 w-5" />
517 </button>
518 </div>
519
520 {/* Body */}
521 <div className="flex-1 overflow-y-auto px-6 py-4">
522 {phase === "setup" && (
523 <div className="space-y-3">
524 <p className="text-sm text-gray-600 dark:text-gray-300">
525 {itineraryOnly
526 ? __(
527 "AI will generate a day-by-day itinerary grounded in this trip's facts. Existing itinerary will be replaced when you accept the result.",
528 "yatra",
529 )
530 : __(
531 "Pick the sections to generate. AI runs them in parallel, then you review each result before anything touches the form.",
532 "yatra",
533 )}
534 </p>
535
536 {/* Freeform context — the single most valuable input on
537 a fresh trip. Without it the LLM has only a title and
538 refuses to invent facts. */}
539 <div className="rounded-md border border-blue-200 bg-blue-50/40 p-3 dark:border-blue-500/40 dark:bg-blue-900/20">
540 <label
541 htmlFor="autofill-context"
542 className="mb-1 flex items-center gap-1.5 text-xs font-medium text-blue-900 dark:text-blue-200"
543 >
544 <Sparkles className="h-3.5 w-3.5" />
545 {__("Tell AI about this trip", "yatra")}
546 <span className="ml-1 rounded bg-blue-200 px-1.5 py-0.5 text-[10px] font-semibold uppercase text-blue-800 dark:bg-blue-900/60 dark:text-blue-200">
547 {__("Recommended", "yatra")}
548 </span>
549 </label>
550 <textarea
551 id="autofill-context"
552 rows={4}
553 className="w-full rounded-md border border-blue-200 bg-white p-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-400 dark:border-blue-500/40 dark:bg-gray-800"
554 placeholder={__(
555 "e.g. 7-day cultural trek to Dhaka, moderate difficulty. Includes food tours, rickshaw rides, old town walking tour. Best for ages 18-65. Mid-range hotels.",
556 "yatra",
557 )}
558 value={extraContext}
559 onChange={(e) => setExtraContext(e.target.value)}
560 />
561 <p className="mt-1 text-[11px] text-blue-700 dark:text-blue-300">
562 {__(
563 "The more facts you share, the better the output. Existing fields on the form (title, destinations, duration) are also passed automatically.",
564 "yatra",
565 )}
566 </p>
567 </div>
568
569 <div className="rounded-md border border-gray-200 dark:border-gray-700">
570 {rows.map((r) => (
571 <label
572 key={r.spec.key}
573 className="flex items-center gap-3 border-b border-gray-100 px-3 py-2 last:border-b-0 dark:border-gray-700"
574 >
575 <input
576 type="checkbox"
577 checked={r.selected}
578 onChange={() => toggle(r.spec.key)}
579 />
580 <span className="text-sm text-gray-900 dark:text-white">
581 {r.spec.label}
582 </span>
583 <span className="ml-auto text-xs text-gray-400">
584 {r.spec.task}
585 </span>
586 </label>
587 ))}
588 </div>
589 <p className="text-xs text-gray-500 dark:text-gray-400">
590 {__("Tokens count against your provider plan.", "yatra")}{" "}
591 <a
592 className="text-blue-600 underline dark:text-blue-300"
593 href="admin.php?page=yatra&subpage=ai-assistant"
594 target="_blank"
595 rel="noopener noreferrer"
596 >
597 {__("See usage", "yatra")}
598 </a>
599 </p>
600 </div>
601 )}
602
603 {phase !== "setup" && (
604 <div className="space-y-3">
605 {rows
606 .filter((r) => r.selected)
607 .map((r) => (
608 <TaskRowView
609 key={r.spec.key}
610 row={r}
611 onToggleAccept={() => toggleAccept(r.spec.key)}
612 onRetryContextChange={(v) =>
613 updateRetryContext(r.spec.key, v)
614 }
615 onRetry={() => retryWithContext(r.spec.key)}
616 />
617 ))}
618 </div>
619 )}
620 </div>
621
622 {/* Footer */}
623 <div className="flex items-center justify-between border-t border-gray-200 px-6 py-3 dark:border-gray-700">
624 <div className="text-xs text-gray-500 dark:text-gray-400">
625 {phase === "setup" &&
626 (selectedCount === 0
627 ? __("Pick at least one section.", "yatra")
628 : `${selectedCount} ${__("sections selected.", "yatra")}`)}
629 {phase === "running" &&
630 `${__("Generating…", "yatra")} ${generatedCount}/${selectedCount}`}
631 {phase === "preview" && (
632 <>
633 {`${generatedCount} ${__("ready", "yatra")}`}
634 {needsContextCount > 0 && (
635 <span className="ml-2 text-amber-600 dark:text-amber-300">
636 {`${needsContextCount} ${__("need more info", "yatra")}`}
637 </span>
638 )}
639 {failedCount > 0 && (
640 <span className="ml-2 text-red-500">
641 {`${failedCount} ${__("failed", "yatra")}`}
642 </span>
643 )}
644 </>
645 )}
646 </div>
647 <div className="flex gap-2">
648 <Button variant="outline" onClick={onClose}>
649 {__("Cancel", "yatra")}
650 </Button>
651 {phase === "setup" && (
652 <Button onClick={runGenerations} disabled={selectedCount === 0}>
653 <Sparkles className="mr-2 h-4 w-4" />
654 {__("Generate", "yatra")}
655 </Button>
656 )}
657 {phase === "preview" && (
658 <Button
659 onClick={applyAccepted}
660 disabled={!rows.some((r) => r.accepted && r.status === "done")}
661 >
662 <Check className="mr-2 h-4 w-4" />
663 {__("Apply accepted", "yatra")}
664 </Button>
665 )}
666 {phase === "running" && !allDone && (
667 <Button disabled>
668 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
669 {__("Working…", "yatra")}
670 </Button>
671 )}
672 </div>
673 </div>
674 </div>
675 </Modal>
676 );
677 };
678
679 const TaskRowView: React.FC<{
680 row: TaskRow;
681 onToggleAccept: () => void;
682 onRetryContextChange: (value: string) => void;
683 onRetry: () => void;
684 }> = ({ row, onToggleAccept, onRetryContextChange, onRetry }) => {
685 const [expanded, setExpanded] = useState(false);
686 const retryDisabled = (row.retryContext ?? "").trim() === "";
687 return (
688 <div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
689 <div className="flex items-center gap-2">
690 <StatusBadge status={row.status} />
691 <div className="font-medium text-sm text-gray-900 dark:text-white">
692 {row.spec.label}
693 </div>
694 <div className="ml-auto flex items-center gap-2">
695 {row.status === "done" && (
696 <>
697 <button
698 type="button"
699 onClick={() => setExpanded((v) => !v)}
700 className="inline-flex items-center gap-1 rounded border border-gray-200 px-2 py-0.5 text-xs text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
701 >
702 <Eye className="h-3 w-3" />
703 {expanded ? __("Hide", "yatra") : __("Preview", "yatra")}
704 </button>
705 <label className="flex items-center gap-1 text-xs">
706 <input
707 type="checkbox"
708 checked={row.accepted}
709 onChange={onToggleAccept}
710 />
711 {__("Apply", "yatra")}
712 </label>
713 </>
714 )}
715 </div>
716 </div>
717 {expanded && row.status === "done" && (
718 <pre className="mt-2 max-h-64 overflow-y-auto whitespace-pre-wrap rounded bg-gray-50 p-2 text-xs text-gray-800 dark:bg-gray-900 dark:text-gray-200">
719 {row.preview || (
720 <span className="text-gray-400">{__("(empty)", "yatra")}</span>
721 )}
722 </pre>
723 )}
724 {row.status === "needs_context" && (
725 <div className="mt-2 space-y-2 rounded-md border border-amber-200 bg-amber-50/60 p-3 text-xs dark:border-amber-500/40 dark:bg-amber-900/20">
726 <div className="flex items-start gap-2 text-amber-900 dark:text-amber-200">
727 <HelpCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
728 <div className="flex-1">
729 <div className="font-medium">
730 {__(
731 "AI needs a few more details to write something useful.",
732 "yatra",
733 )}
734 </div>
735 {row.clarificationMessage && (
736 <p className="mt-1 text-amber-800/90 dark:text-amber-200/90">
737 {row.clarificationMessage}
738 </p>
739 )}
740 {row.clarificationQuestions &&
741 row.clarificationQuestions.length > 0 && (
742 <ul className="mt-1.5 list-disc space-y-0.5 pl-4 text-amber-900 dark:text-amber-200">
743 {row.clarificationQuestions.map((q, i) => (
744 <li key={i}>{q}</li>
745 ))}
746 </ul>
747 )}
748 </div>
749 </div>
750 <textarea
751 rows={3}
752 placeholder={__(
753 "Type the answers here — e.g. duration, activities, difficulty, best season…",
754 "yatra",
755 )}
756 value={row.retryContext ?? ""}
757 onChange={(e) => onRetryContextChange(e.target.value)}
758 className="w-full rounded-md border border-amber-200 bg-white p-2 text-sm focus:outline-none focus:ring-2 focus:ring-amber-400 dark:border-amber-500/40 dark:bg-gray-800"
759 />
760 <div className="flex justify-end">
761 <Button
762 type="button"
763 size="sm"
764 onClick={onRetry}
765 disabled={retryDisabled}
766 >
767 <RefreshCw className="mr-2 h-3.5 w-3.5" />
768 {__("Try again with this", "yatra")}
769 </Button>
770 </div>
771 </div>
772 )}
773 {row.status === "failed" && (
774 <div className="mt-2 flex items-start gap-2 rounded bg-red-50 p-2 text-xs text-red-700 dark:bg-red-900/30 dark:text-red-300">
775 <AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
776 <div className="flex-1">{row.error}</div>
777 </div>
778 )}
779 </div>
780 );
781 };
782
783 const StatusBadge: React.FC<{ status: TaskStatus }> = ({ status }) => {
784 if (status === "idle") {
785 return <Clock className="h-4 w-4 text-gray-400" />;
786 }
787 if (status === "running") {
788 return <Loader2 className="h-4 w-4 animate-spin text-blue-500" />;
789 }
790 if (status === "done") {
791 return <CheckCircle2 className="h-4 w-4 text-green-500" />;
792 }
793 return <AlertCircle className="h-4 w-4 text-red-500" />;
794 };
795
796 function extractError(e: any): string {
797 if (!e) return "Generation failed.";
798 const data = e?.response?.data ?? e?.data ?? null;
799 if (
800 data &&
801 typeof data === "object" &&
802 typeof (data as any).message === "string"
803 ) {
804 return (data as any).message;
805 }
806 return e?.message || "Generation failed.";
807 }
808
809 export default AutoFillTripModal;
810