PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.13
Yatra – Travel Booking & Tour Operator Software v3.0.13
3.0.17 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 All 85 releases
yatra / resources / js / components / ai / AiFieldAffordance.tsx

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

337 lines 11.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useCallback, useEffect, useRef, useState } from "react";
2 import {
3 Sparkles,
4 Wand2,
5 Scissors,
6 Maximize2,
7 Heart,
8 Pencil,
9 Check,
10 X,
11 Loader2,
12 AlertTriangle,
13 } from "lucide-react";
14 import { __ } from "../../lib/i18n";
15 import { aiApi, type AiGenerateResponse } from "../../api/ai-api";
16 import {
17 isAiReady,
18 isAiEligible,
19 isAiModuleEnabled,
20 } from "../../lib/ai-availability";
21
22 interface AiFieldAffordanceProps {
23 /**
24 * Generation task name from PromptBuilder::ALLOWED_TASKS. Drives both the
25 * server-side prompt template AND the labels users see in the menu.
26 * Improve* tasks are derived per-action, not passed in.
27 */
28 task: string;
29 /** Current field value — used for `Improve` operations + as a "filled" cue. */
30 value: string;
31 /** Called with the generated text once the operator accepts the preview. */
32 onAccept: (next: string) => void;
33 /**
34 * Returns the per-field context object that grounds generation. Called
35 * lazily on click so the latest form values are captured every time.
36 */
37 buildContext: () => Record<string, unknown>;
38 /** Field label shown inside the menu / preview ("Description", "FAQ"...). */
39 label: string;
40 /** When false, "Improve" submenu is hidden. Default true. */
41 allowImprove?: boolean;
42 /** Hides the affordance entirely (e.g. when read-only). */
43 disabled?: boolean;
44 /** Optional className for the trigger button. */
45 className?: string;
46 }
47
48 interface PreviewState {
49 text: string;
50 source: "generate" | "improve";
51 task: string;
52 }
53
54 const IMPROVE_OPTIONS: Array<{
55 task: string;
56 label: string;
57 icon: React.ComponentType<{ className?: string }>;
58 }> = [
59 { task: "improve-tighten", label: "Tighten", icon: Scissors },
60 { task: "improve-lengthen", label: "Lengthen", icon: Maximize2 },
61 { task: "improve-tone-warmer", label: "Warmer tone", icon: Heart },
62 { task: "improve-tone-formal", label: "More formal", icon: Pencil },
63 { task: "improve-tone-shorter", label: "Make shorter", icon: Scissors },
64 ];
65
66 export const AiFieldAffordance: React.FC<AiFieldAffordanceProps> = ({
67 task,
68 value,
69 onAccept,
70 buildContext,
71 label,
72 allowImprove = true,
73 disabled = false,
74 className = "",
75 }) => {
76 const [open, setOpen] = useState(false);
77 const [showImprove, setShowImprove] = useState(false);
78 const [isRunning, setIsRunning] = useState(false);
79 const [error, setError] = useState<string | null>(null);
80 const [preview, setPreview] = useState<PreviewState | null>(null);
81 const wrapperRef = useRef<HTMLDivElement | null>(null);
82
83 // Hard gate — completely skip rendering when the operator can't use AI.
84 // Cheap to evaluate; keeps editors clean for free / Personal tier users.
85 // Computed early so hooks below can reference it via dep arrays, but
86 // the actual early-return runs only AFTER every hook below — see
87 // rules-of-hooks. The trade-off is the useEffect listener registers
88 // even on disabled instances; the listener body itself is a no-op
89 // until `open` is true, so the cost is just one addEventListener +
90 // removeEventListener per mount.
91 const gateBlocks = disabled || !isAiEligible() || !isAiModuleEnabled();
92 const ready = isAiReady();
93
94 // Close menu on outside click.
95 useEffect(() => {
96 if (!open) return;
97 const onDoc = (e: MouseEvent) => {
98 if (
99 wrapperRef.current &&
100 !wrapperRef.current.contains(e.target as Node)
101 ) {
102 setOpen(false);
103 setShowImprove(false);
104 }
105 };
106 document.addEventListener("mousedown", onDoc);
107 return () => document.removeEventListener("mousedown", onDoc);
108 }, [open]);
109
110 const runGenerate = useCallback(async () => {
111 setOpen(false);
112 setShowImprove(false);
113 setError(null);
114 setIsRunning(true);
115 try {
116 const ctx = buildContext();
117 const res: AiGenerateResponse = await aiApi.generate(task, ctx);
118 setPreview({ text: res.text, source: "generate", task });
119 } catch (e: any) {
120 setError(extractError(e));
121 } finally {
122 setIsRunning(false);
123 }
124 }, [task, buildContext]);
125
126 const runImprove = useCallback(
127 async (improveTask: string) => {
128 setOpen(false);
129 setShowImprove(false);
130 setError(null);
131 setIsRunning(true);
132 try {
133 const res: AiGenerateResponse = await aiApi.improve(improveTask, value);
134 setPreview({ text: res.text, source: "improve", task: improveTask });
135 } catch (e: any) {
136 setError(extractError(e));
137 } finally {
138 setIsRunning(false);
139 }
140 },
141 [value],
142 );
143
144 const accept = () => {
145 if (!preview) return;
146 onAccept(preview.text);
147 setPreview(null);
148 };
149
150 const reject = () => setPreview(null);
151
152 const tryAgain = () => {
153 if (!preview) return;
154 if (preview.source === "improve") {
155 runImprove(preview.task);
156 } else {
157 runGenerate();
158 }
159 };
160
161 const hasValue = value.trim() !== "";
162
163 // Gate render after every hook has been called. See top-of-file note.
164 if (gateBlocks) {
165 return null;
166 }
167
168 return (
169 <div ref={wrapperRef} className={`relative inline-block ${className}`}>
170 <button
171 type="button"
172 onClick={() => {
173 if (!ready) {
174 setError(
175 __(
176 "Add an API key in Yatra → AI Assistant before using this.",
177 "yatra",
178 ),
179 );
180 return;
181 }
182 setOpen((v) => !v);
183 setShowImprove(false);
184 setError(null);
185 }}
186 disabled={isRunning}
187 title={__("AI Assistant", "yatra")}
188 className={`inline-flex h-7 w-7 items-center justify-center rounded-md border border-transparent text-gray-400 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 disabled:opacity-50 dark:hover:bg-blue-900/20 dark:hover:text-blue-300 ${
189 isRunning ? "animate-pulse" : ""
190 }`}
191 aria-label={__("AI Assistant", "yatra")}
192 >
193 {isRunning ? (
194 <Loader2 className="h-4 w-4 animate-spin" />
195 ) : (
196 <Sparkles className="h-4 w-4" />
197 )}
198 </button>
199
200 {/* Action menu */}
201 {open && !preview && (
202 <div
203 className="absolute right-0 z-30 mt-1 w-56 rounded-md border border-gray-200 bg-white p-1 text-sm shadow-lg dark:border-gray-700 dark:bg-gray-800"
204 role="menu"
205 >
206 <button
207 type="button"
208 onClick={runGenerate}
209 className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-gray-700 hover:bg-blue-50 dark:text-gray-200 dark:hover:bg-blue-900/30"
210 >
211 <Wand2 className="h-4 w-4 text-blue-500" />
212 <span className="flex-1">
213 {hasValue ? __("Regenerate", "yatra") : __("Generate", "yatra")}{" "}
214 <span className="text-gray-400">— {label}</span>
215 </span>
216 </button>
217
218 {allowImprove && hasValue && (
219 <>
220 <button
221 type="button"
222 onClick={() => setShowImprove((v) => !v)}
223 className="mt-0.5 flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-gray-700 hover:bg-blue-50 dark:text-gray-200 dark:hover:bg-blue-900/30"
224 >
225 <Pencil className="h-4 w-4 text-blue-500" />
226 <span className="flex-1">{__("Improve this", "yatra")}</span>
227 <span className="text-gray-400">{showImprove ? "▴" : "▾"}</span>
228 </button>
229 {showImprove && (
230 <div className="ml-2 mt-0.5 border-l border-gray-100 pl-2 dark:border-gray-700">
231 {IMPROVE_OPTIONS.map((opt) => {
232 const Icon = opt.icon;
233 return (
234 <button
235 key={opt.task}
236 type="button"
237 onClick={() => runImprove(opt.task)}
238 className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-gray-700 hover:bg-blue-50 dark:text-gray-200 dark:hover:bg-blue-900/30"
239 >
240 <Icon className="h-3.5 w-3.5 text-blue-400" />
241 {__(opt.label, "yatra")}
242 </button>
243 );
244 })}
245 </div>
246 )}
247 </>
248 )}
249 </div>
250 )}
251
252 {/* Preview popover — Accept / Try again / Cancel */}
253 {preview && (
254 <div className="absolute right-0 z-30 mt-1 w-[420px] max-w-[90vw] rounded-md border border-blue-200 bg-white p-3 text-sm shadow-xl dark:border-blue-500/40 dark:bg-gray-800">
255 <div className="mb-2 flex items-center gap-2 text-xs font-medium text-blue-600 dark:text-blue-300">
256 <Sparkles className="h-3.5 w-3.5" />
257 {preview.source === "improve"
258 ? __("AI improved your text", "yatra")
259 : __("AI generated this draft", "yatra")}
260 </div>
261 <div className="max-h-72 overflow-y-auto whitespace-pre-wrap rounded border border-gray-100 bg-gray-50 p-2 text-xs text-gray-800 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200">
262 {preview.text || (
263 <span className="text-gray-400">
264 {__("(empty response)", "yatra")}
265 </span>
266 )}
267 </div>
268 <div className="mt-2 flex items-center justify-end gap-2">
269 <button
270 type="button"
271 onClick={tryAgain}
272 className="rounded border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
273 >
274 {__("Try again", "yatra")}
275 </button>
276 <button
277 type="button"
278 onClick={reject}
279 className="inline-flex items-center gap-1 rounded border border-gray-200 px-2 py-1 text-xs text-gray-600 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
280 >
281 <X className="h-3 w-3" />
282 {__("Discard", "yatra")}
283 </button>
284 <button
285 type="button"
286 onClick={accept}
287 disabled={!preview.text}
288 className="inline-flex items-center gap-1 rounded bg-blue-600 px-3 py-1 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
289 >
290 <Check className="h-3 w-3" />
291 {__("Accept", "yatra")}
292 </button>
293 </div>
294 </div>
295 )}
296
297 {/* Inline error toast */}
298 {error && !preview && (
299 <div
300 className="absolute right-0 z-30 mt-1 w-72 rounded-md border border-red-200 bg-white p-2 text-xs text-red-700 shadow-lg dark:border-red-500/40 dark:bg-gray-800 dark:text-red-300"
301 role="alert"
302 >
303 <div className="flex items-start gap-2">
304 <AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
305 <div className="flex-1">{error}</div>
306 <button
307 type="button"
308 onClick={() => setError(null)}
309 className="text-red-400 hover:text-red-600"
310 aria-label={__("Dismiss", "yatra")}
311 >
312 <X className="h-3.5 w-3.5" />
313 </button>
314 </div>
315 </div>
316 )}
317 </div>
318 );
319 };
320
321 function extractError(e: any): string {
322 if (!e) return "AI request failed.";
323 const data = e?.response?.data ?? e?.data ?? null;
324 if (data && typeof data === "object") {
325 if (typeof (data as any).message === "string") return (data as any).message;
326 if (Array.isArray((data as any).errors)) {
327 return (data as any).errors
328 .map((x: any) => x?.message ?? String(x))
329 .join("; ");
330 }
331 }
332 if (typeof e?.message === "string") return e.message;
333 return String(e);
334 }
335
336 export default AiFieldAffordance;
337