PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.10
Yatra – Travel Booking & Tour Operator Software v3.0.10
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 2.0.10 All 83 releases
yatra / resources / js / pages / AiAssistant.tsx

AiAssistant.tsx in Yatra – Travel Booking & Tour Operator Software 3.0.10, at resources/js/pages/AiAssistant.tsx

1,657 lines 66.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React, { useCallback, useEffect, useState } from "react";
2 import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3 import {
4 Sparkles,
5 KeyRound,
6 ExternalLink,
7 Loader2,
8 Lock,
9 CheckCircle2,
10 XCircle,
11 BarChart3,
12 Plug,
13 Eye,
14 EyeOff,
15 Trash2,
16 Save,
17 Pencil,
18 RotateCcw,
19 ChevronDown,
20 ChevronUp,
21 AlertTriangle,
22 MessageCircle,
23 Crown,
24 Info,
25 } from "lucide-react";
26 import { __ } from "../lib/i18n";
27 import { useToast } from "../components/ui/toast";
28 import { Button } from "../components/ui/button";
29 import { Input } from "../components/ui/input";
30 import { Label } from "../components/ui/label";
31 import { Select } from "../components/ui/select";
32 import {
33 ModulePageSkeleton,
34 ModuleStatGridSkeleton,
35 ModuleSectionSkeleton,
36 } from "../components/ui/module-skeleton";
37 import { PageHeader } from "../components/common/PageHeader";
38 import {
39 Card,
40 CardContent,
41 CardDescription,
42 CardHeader,
43 CardTitle,
44 } from "../components/ui/card";
45 import {
46 aiApi,
47 type AiMeta,
48 type AiBrandVoice,
49 type AiChatLimits,
50 type AiPromptRow,
51 type AiUsageSummary,
52 } from "../api/ai-api";
53
54 type SectionId = "keys" | "voice" | "prompts" | "usage";
55
56 interface SectionDef {
57 id: SectionId;
58 label: string;
59 icon: React.ComponentType<{ className?: string }>;
60 description: string;
61 }
62
63 const SECTIONS: SectionDef[] = [
64 {
65 id: "keys",
66 label: "API Keys",
67 icon: KeyRound,
68 description:
69 "Bring your own OpenAI or Anthropic key. Keys are stored encrypted and never leave your server.",
70 },
71 {
72 id: "voice",
73 label: "Brand Voice",
74 icon: Sparkles,
75 description:
76 "Tone, examples, vocabulary preferences — applied to every generation so AI output sounds like your business.",
77 },
78 {
79 id: "prompts",
80 label: "Prompts",
81 icon: Pencil,
82 description:
83 "Inspect or edit every system + user prompt the assistant uses. Overrides fall back to defaults per-field.",
84 },
85 {
86 id: "usage",
87 label: "Usage",
88 icon: BarChart3,
89 description: "Token usage by month, per provider, per feature.",
90 },
91 ];
92
93 const DEFAULT_BRAND_VOICE: AiBrandVoice = {
94 tone: "",
95 examples: [],
96 forbidden: [],
97 required: [],
98 language: "en-US",
99 default_provider: "openai",
100 default_model: "",
101 };
102
103 /* -------------------------------------------------------------------------- */
104 /* Plan tier badge */
105 /* -------------------------------------------------------------------------- */
106
107 /**
108 * Pill rendered in the AI Assistant page header so operators see at a
109 * glance which license tier unlocks this page. Mirrors the badge style
110 * used on the Modules page card so the language is consistent across
111 * surfaces. Keep colors aligned with the Modules.tsx badges:
112 * growth → emerald/teal gradient
113 * agency → purple/indigo gradient
114 */
115 const PlanBadge: React.FC<{ tier: "growth" | "agency" }> = ({ tier }) => {
116 if (tier === "agency") {
117 return (
118 <span className="inline-flex items-center gap-1 rounded-full bg-gradient-to-r from-purple-600 to-indigo-500 px-2.5 py-1 text-xs font-medium text-white shadow-sm">
119 <Crown className="h-3 w-3" />
120 {__("Scale plan", "yatra")}
121 </span>
122 );
123 }
124 return (
125 <span className="inline-flex items-center gap-1 rounded-full bg-gradient-to-r from-emerald-500 to-teal-500 px-2.5 py-1 text-xs font-medium text-white shadow-sm">
126 <Sparkles className="h-3 w-3" />
127 {__("Growth plan", "yatra")}
128 </span>
129 );
130 };
131
132 /* -------------------------------------------------------------------------- */
133 /* Upgrade card */
134 /* -------------------------------------------------------------------------- */
135
136 const UpgradeCard: React.FC<{ meta?: AiMeta }> = ({ meta }) => {
137 const upgradeUrl =
138 meta?.upgrade_url || "https://wpyatra.com/pricing?module=ai-assistant";
139 const licensePageUrl =
140 meta?.license_page_url || "admin.php?page=yatra&subpage=license";
141
142 return (
143 <Card className="max-w-3xl">
144 <CardHeader>
145 <div className="flex items-start gap-3">
146 <div className="rounded-md bg-blue-100 p-2 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400">
147 <Sparkles className="h-5 w-5" />
148 </div>
149 <div className="flex-1">
150 <div className="flex flex-wrap items-center gap-2">
151 <CardTitle>{__("AI Assistant", "yatra")}</CardTitle>
152 <PlanBadge tier="growth" />
153 </div>
154 <CardDescription className="mt-1">
155 {__(
156 "AI Assistant unlocks on the Growth plan (or Scale). Bring your own OpenAI or Anthropic key — costs are paid directly to your provider, no per-call markup from the plugin.",
157 "yatra",
158 )}
159 </CardDescription>
160 </div>
161 </div>
162 </CardHeader>
163 <CardContent className="space-y-4">
164 <p className="text-sm text-gray-600 dark:text-gray-300">
165 {__(
166 "Inline AI affordances on trip descriptions, SEO meta, included/excluded items, FAQs, and more — write less, finish more.",
167 "yatra",
168 )}
169 </p>
170 <div className="flex flex-wrap items-center gap-3 pt-2">
171 <Button asChild>
172 <a href={upgradeUrl} target="_blank" rel="noopener noreferrer">
173 {meta?.is_pro_active
174 ? __("Upgrade plan", "yatra")
175 : __("Get Yatra Pro Growth", "yatra")}
176 <ExternalLink className="ml-2 h-4 w-4" />
177 </a>
178 </Button>
179 {meta?.is_pro_active && (
180 <Button variant="outline" asChild>
181 <a href={licensePageUrl}>{__("Manage License", "yatra")}</a>
182 </Button>
183 )}
184 </div>
185 </CardContent>
186 </Card>
187 );
188 };
189
190 /* -------------------------------------------------------------------------- */
191 /* Settings page */
192 /* -------------------------------------------------------------------------- */
193
194 const AiAssistant: React.FC = () => {
195 const { showToast } = useToast();
196 const queryClient = useQueryClient();
197 const [activeSection, setActiveSection] = useState<SectionId>("keys");
198
199 // ---------- meta ----------
200 const { data: meta, isLoading: metaLoading } = useQuery<AiMeta>({
201 queryKey: ["ai-meta"],
202 queryFn: () => aiApi.meta(),
203 });
204
205 // ---------- brand voice ----------
206 const { data: bvResp } = useQuery<{ data: AiBrandVoice }>({
207 queryKey: ["ai-brand-voice"],
208 queryFn: () => aiApi.getBrandVoice(),
209 enabled: Boolean(meta?.is_ai_eligible),
210 });
211
212 const [bv, setBv] = useState<AiBrandVoice>(DEFAULT_BRAND_VOICE);
213 useEffect(() => {
214 if (bvResp?.data) {
215 setBv({ ...DEFAULT_BRAND_VOICE, ...bvResp.data });
216 }
217 }, [bvResp]);
218
219 const saveBvMutation = useMutation({
220 mutationFn: (data: AiBrandVoice) => aiApi.saveBrandVoice(data),
221 onSuccess: () => {
222 showToast(__("Brand voice saved.", "yatra"), "success");
223 queryClient.invalidateQueries({ queryKey: ["ai-brand-voice"] });
224 },
225 onError: (e: any) => showToast(extractError(e), "error"),
226 });
227
228 // ---------- API keys ----------
229 const [keyDrafts, setKeyDrafts] = useState<Record<string, string>>({});
230 const [showKey, setShowKey] = useState<Record<string, boolean>>({});
231
232 const setKeyMutation = useMutation({
233 mutationFn: ({ provider, key }: { provider: string; key: string }) =>
234 aiApi.setKey(provider, key),
235 onSuccess: (_resp, vars) => {
236 showToast(__("API key saved.", "yatra"), "success");
237 setKeyDrafts((d) => ({ ...d, [vars.provider]: "" }));
238 queryClient.invalidateQueries({ queryKey: ["ai-meta"] });
239 },
240 onError: (e: any) => showToast(extractError(e), "error"),
241 });
242
243 const deleteKeyMutation = useMutation({
244 mutationFn: (provider: string) => aiApi.deleteKey(provider),
245 onSuccess: () => {
246 showToast(__("API key cleared.", "yatra"), "success");
247 queryClient.invalidateQueries({ queryKey: ["ai-meta"] });
248 },
249 onError: (e: any) => showToast(extractError(e), "error"),
250 });
251
252 const testKeyMutation = useMutation({
253 mutationFn: (provider: string) => aiApi.testKey(provider),
254 onSuccess: (resp) => {
255 showToast(
256 resp.message || __("Connection successful.", "yatra"),
257 "success",
258 );
259 },
260 onError: (e: any) => showToast(extractError(e), "error"),
261 });
262
263 // ---------- public chat toggle ----------
264 const tripChatMutation = useMutation({
265 mutationFn: (enabled: boolean) => aiApi.setTripChatEnabled(enabled),
266 onSuccess: (resp) => {
267 showToast(resp.message, "success");
268 queryClient.invalidateQueries({ queryKey: ["ai-meta"] });
269 },
270 onError: (e: any) => showToast(extractError(e), "error"),
271 });
272
273 // ---------- public chat rate-limits (operator-configurable) ----------
274 const [chatLimitsDraft, setChatLimitsDraft] = useState<
275 Record<keyof AiChatLimits, string>
276 >({
277 per_trip_day: "",
278 per_ip_hour: "",
279 per_session: "",
280 per_session_booking: "",
281 max_message_chars: "",
282 history_turns: "",
283 });
284 // Hydrate the form whenever the server values arrive / change.
285 useEffect(() => {
286 if (meta?.trip_chat_limits) {
287 const l = meta.trip_chat_limits;
288 setChatLimitsDraft({
289 per_trip_day: String(l.per_trip_day),
290 per_ip_hour: String(l.per_ip_hour),
291 per_session: String(l.per_session),
292 per_session_booking: String(l.per_session_booking),
293 max_message_chars: String(l.max_message_chars),
294 history_turns: String(l.history_turns),
295 });
296 }
297 }, [meta?.trip_chat_limits]);
298
299 const tripChatLimitsMutation = useMutation({
300 mutationFn: (patch: Partial<AiChatLimits>) =>
301 aiApi.setTripChatLimits(patch),
302 onSuccess: (resp) => {
303 showToast(resp.message, "success");
304 queryClient.invalidateQueries({ queryKey: ["ai-meta"] });
305 },
306 onError: (e: any) => showToast(extractError(e), "error"),
307 });
308
309 // ---------- usage ----------
310 const { data: usageResp } = useQuery<{ data: AiUsageSummary }>({
311 queryKey: ["ai-usage"],
312 queryFn: () => aiApi.getUsage(),
313 enabled: Boolean(meta?.is_ai_eligible) && activeSection === "usage",
314 });
315
316 // ---------- prompts ----------
317 const { data: promptsResp, isLoading: promptsLoading } = useQuery<{
318 data: AiPromptRow[];
319 }>({
320 queryKey: ["ai-prompts"],
321 queryFn: () => aiApi.listPrompts(),
322 enabled: Boolean(meta?.is_ai_eligible) && activeSection === "prompts",
323 });
324
325 const [expandedPrompt, setExpandedPrompt] = useState<string | null>(null);
326 const [promptDrafts, setPromptDrafts] = useState<
327 Record<
328 string,
329 {
330 system: string;
331 user: string;
332 max_tokens: string;
333 temperature: string;
334 }
335 >
336 >({});
337
338 // Hydrate drafts whenever the prompt list changes (e.g. after save/reset).
339 useEffect(() => {
340 if (!promptsResp?.data) return;
341 const next: typeof promptDrafts = {};
342 for (const row of promptsResp.data) {
343 const effSystem =
344 row.override.system && row.override.system !== ""
345 ? row.override.system
346 : row.default.system;
347 const effUser =
348 row.override.user && row.override.user !== ""
349 ? row.override.user
350 : row.default.user;
351 const effMaxTokens =
352 row.override.max_tokens != null
353 ? String(row.override.max_tokens)
354 : String(row.default.max_tokens);
355 const effTemperature =
356 row.override.temperature != null
357 ? String(row.override.temperature)
358 : String(row.default.temperature);
359 next[row.task] = {
360 system: effSystem,
361 user: effUser,
362 max_tokens: effMaxTokens,
363 temperature: effTemperature,
364 };
365 }
366 setPromptDrafts(next);
367 }, [promptsResp]);
368
369 const savePromptMutation = useMutation({
370 mutationFn: ({
371 task,
372 override,
373 }: {
374 task: string;
375 override: Parameters<typeof aiApi.savePromptOverride>[1];
376 }) => aiApi.savePromptOverride(task, override),
377 onSuccess: () => {
378 showToast(__("Prompt saved.", "yatra"), "success");
379 queryClient.invalidateQueries({ queryKey: ["ai-prompts"] });
380 },
381 onError: (e: any) => showToast(extractError(e), "error"),
382 });
383
384 const resetPromptMutation = useMutation({
385 mutationFn: (task: string) => aiApi.resetPrompt(task),
386 onSuccess: () => {
387 showToast(__("Prompt reset to default.", "yatra"), "success");
388 queryClient.invalidateQueries({ queryKey: ["ai-prompts"] });
389 },
390 onError: (e: any) => showToast(extractError(e), "error"),
391 });
392
393 const resetAllPromptsMutation = useMutation({
394 mutationFn: () => aiApi.resetAllPrompts(),
395 onSuccess: () => {
396 showToast(__("All prompts reset to defaults.", "yatra"), "success");
397 queryClient.invalidateQueries({ queryKey: ["ai-prompts"] });
398 },
399 onError: (e: any) => showToast(extractError(e), "error"),
400 });
401
402 /* --------------------------- early-out states --------------------------- */
403
404 if (metaLoading) {
405 return <ModulePageSkeleton variant="tabs" />;
406 }
407
408 if (!meta || !meta.is_ai_eligible) {
409 return (
410 <div className="space-y-6">
411 <PageHeader
412 description={__(
413 "Inline AI generation for trip content, SEO, and more.",
414 "yatra",
415 )}
416 />
417 <UpgradeCard meta={meta} />
418 </div>
419 );
420 }
421
422 const moduleEnabled = meta.is_module_enabled;
423
424 /* ------------------------------- helpers ------------------------------- */
425
426 const updateBv = <K extends keyof AiBrandVoice>(
427 key: K,
428 value: AiBrandVoice[K],
429 ) => setBv((prev) => ({ ...prev, [key]: value }));
430
431 const updateBvList = (
432 key: "examples" | "forbidden" | "required",
433 raw: string,
434 ) => {
435 // Textarea → array of trimmed lines. Empty lines drop.
436 const list = raw
437 .split(/\r?\n/)
438 .map((s) => s.trim())
439 .filter((s) => s !== "");
440 setBv((prev) => ({ ...prev, [key]: list }));
441 };
442
443 const submitBv = (e: React.FormEvent) => {
444 e.preventDefault();
445 saveBvMutation.mutate(bv);
446 };
447
448 /* ------------------------------- renderers ------------------------------- */
449
450 // Per-provider docs metadata — explains why an operator needs the
451 // key + where to find it + the canonical pricing page. Keyed by
452 // provider id so adding a future provider is one entry. Source of
453 // truth: each provider's published docs.
454 const providerDocs: Record<
455 string,
456 {
457 whyDescription: string;
458 whereToFind: string;
459 docsUrl: string;
460 pricingUrl: string;
461 }
462 > = {
463 openai: {
464 whyDescription: __(
465 "Authenticates every request the plugin sends to OpenAI on your behalf. OpenAI bills your account directly per request — the plugin adds no markup. Without a key, the AI sparkle affordances on Trip / SEO / Email editors stay disabled.",
466 "yatra",
467 ),
468 whereToFind: __(
469 "Sign in at platform.openai.com → API keys → Create new secret key. Copy the value (starts with sk-…) immediately — OpenAI shows it only once.",
470 "yatra",
471 ),
472 docsUrl: "https://platform.openai.com/api-keys",
473 pricingUrl: "https://openai.com/api/pricing/",
474 },
475 anthropic: {
476 whyDescription: __(
477 "Authenticates requests to Anthropic's Claude API. Anthropic bills your account directly per request — no plugin markup. Used for the same in-editor sparkle features OpenAI handles; pick whichever provider your team prefers.",
478 "yatra",
479 ),
480 whereToFind: __(
481 "Sign in at console.anthropic.com → Settings → API Keys → Create Key. Copy the value (starts with sk-ant-…) immediately — Anthropic shows it only once.",
482 "yatra",
483 ),
484 docsUrl: "https://console.anthropic.com/settings/keys",
485 pricingUrl: "https://www.anthropic.com/pricing#anthropic-api",
486 },
487 };
488
489 const renderKeys = () => (
490 <Card>
491 <CardHeader>
492 <CardTitle>{__("API Keys", "yatra")}</CardTitle>
493 <CardDescription>
494 {__(
495 "Keys are encrypted at rest with libsodium and never sent to the browser. Generation requests are made from your server directly to your chosen provider — no third-party proxy is involved.",
496 "yatra",
497 )}
498 </CardDescription>
499 </CardHeader>
500 <CardContent className="space-y-4">
501 {/* Setup walkthrough — same pattern WhatsApp uses, so an
502 operator who configured one module knows what to expect
503 on the other. Explains the model: bring your own key,
504 usage is billed by the provider, the plugin adds no
505 markup. */}
506 <div className="rounded-md border border-blue-200 bg-blue-50/40 p-3 text-sm dark:border-blue-700 dark:bg-blue-900/20">
507 <div className="flex items-start gap-2">
508 <Info className="mt-0.5 h-4 w-4 shrink-0 text-blue-600 dark:text-blue-400" />
509 <div className="space-y-2 text-blue-900 dark:text-blue-200">
510 <div className="font-medium">
511 {__("How AI Assistant billing works", "yatra")}
512 </div>
513 <p className="text-xs">
514 {__(
515 "You bring your own OpenAI or Anthropic key. Each AI generation (trip description, FAQ, email body, etc.) is a single API call your provider charges you for — usually fractions of a cent. The plugin never proxies traffic and adds no markup. You only need one key to start; you can save both and pick a default per task under the Defaults tab.",
516 "yatra",
517 )}
518 </p>
519 <p className="text-[11px] opacity-80">
520 {__(
521 'Click "Where to find this" next to a provider for the exact steps in their dashboard.',
522 "yatra",
523 )}
524 </p>
525 </div>
526 </div>
527 </div>
528 {meta.providers.map((p) => {
529 const status = meta.keys[p.id] ?? { configured: false, hint: "" };
530 const draft = keyDrafts[p.id] ?? "";
531 const visible = Boolean(showKey[p.id]);
532 const docs = providerDocs[p.id];
533 return (
534 <div
535 key={p.id}
536 className="rounded-md border border-gray-200 p-3 dark:border-gray-700"
537 >
538 <div className="mb-2 flex items-center justify-between gap-2">
539 <div className="flex items-center gap-2">
540 <Plug className="h-4 w-4 text-gray-500" />
541 <div className="font-medium text-gray-900 dark:text-white">
542 {p.label}
543 </div>
544 {status.configured ? (
545 <span className="inline-flex items-center gap-1 rounded-full bg-green-50 px-2 py-0.5 text-[11px] font-medium text-green-700 dark:bg-green-900/30 dark:text-green-300">
546 <CheckCircle2 className="h-3 w-3" />
547 {__("Configured", "yatra")}
548 </span>
549 ) : (
550 <span className="inline-flex items-center gap-1 rounded-full bg-gray-100 px-2 py-0.5 text-[11px] font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-300">
551 <XCircle className="h-3 w-3" />
552 {__("Not configured", "yatra")}
553 </span>
554 )}
555 </div>
556 {status.configured && (
557 <div className="text-xs text-gray-500">{status.hint}</div>
558 )}
559 </div>
560
561 {/* Per-provider "why + where" callout. Two columns: a
562 short why-needed paragraph + a where-to-find recipe
563 with a deep link to the provider's keys page. Only
564 renders when we have docs metadata for the provider
565 — the fallback is silent so a future custom provider
566 doesn't show a broken docs block. */}
567 {docs && (
568 <div className="mb-3 space-y-2 rounded-md border border-gray-100 bg-gray-50/60 p-3 text-xs text-gray-700 dark:border-gray-700 dark:bg-gray-800/40 dark:text-gray-300">
569 <div className="flex items-start gap-1.5">
570 <Info className="mt-0.5 h-3.5 w-3.5 shrink-0 text-gray-500" />
571 <div>
572 <span className="font-medium text-gray-900 dark:text-white">
573 {__("Why it's needed:", "yatra")}
574 </span>{" "}
575 {docs.whyDescription}
576 </div>
577 </div>
578 <div className="flex items-start gap-1.5">
579 <KeyRound className="mt-0.5 h-3.5 w-3.5 shrink-0 text-gray-500" />
580 <div>
581 <span className="font-medium text-gray-900 dark:text-white">
582 {__("Where to find it:", "yatra")}
583 </span>{" "}
584 {docs.whereToFind}
585 </div>
586 </div>
587 <div className="flex flex-wrap gap-2 pt-1">
588 <a
589 href={docs.docsUrl}
590 target="_blank"
591 rel="noopener noreferrer"
592 className="inline-flex items-center gap-1 rounded border border-gray-300 px-2 py-0.5 text-[11px] text-gray-700 hover:bg-white dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
593 >
594 {__("Open API keys page", "yatra")}
595 <ExternalLink className="h-3 w-3" />
596 </a>
597 <a
598 href={docs.pricingUrl}
599 target="_blank"
600 rel="noopener noreferrer"
601 className="inline-flex items-center gap-1 rounded border border-gray-300 px-2 py-0.5 text-[11px] text-gray-700 hover:bg-white dark:border-gray-600 dark:text-gray-200 dark:hover:bg-gray-800"
602 >
603 {__("Pricing", "yatra")}
604 <ExternalLink className="h-3 w-3" />
605 </a>
606 </div>
607 </div>
608 )}
609
610 <div className="flex flex-wrap items-end gap-2">
611 <div className="flex-1 min-w-[240px]">
612 <Label htmlFor={`ai-key-${p.id}`}>
613 {__("API key", "yatra")}
614 </Label>
615 <div className="relative mt-1">
616 <Input
617 id={`ai-key-${p.id}`}
618 type={visible ? "text" : "password"}
619 value={draft}
620 placeholder={
621 status.configured
622 ? __("Enter a new key to replace…", "yatra")
623 : __("sk-… or sk-ant-…", "yatra")
624 }
625 autoComplete="off"
626 onChange={(e) =>
627 setKeyDrafts((d) => ({
628 ...d,
629 [p.id]: e.target.value,
630 }))
631 }
632 />
633 <button
634 type="button"
635 onClick={() =>
636 setShowKey((s) => ({ ...s, [p.id]: !s[p.id] }))
637 }
638 className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
639 aria-label={
640 visible
641 ? __("Hide key", "yatra")
642 : __("Show key", "yatra")
643 }
644 >
645 {visible ? (
646 <EyeOff className="h-4 w-4" />
647 ) : (
648 <Eye className="h-4 w-4" />
649 )}
650 </button>
651 </div>
652 </div>
653 <Button
654 type="button"
655 onClick={() =>
656 setKeyMutation.mutate({ provider: p.id, key: draft })
657 }
658 disabled={draft.trim() === "" || setKeyMutation.isPending}
659 >
660 <Save className="mr-2 h-4 w-4" />
661 {__("Save", "yatra")}
662 </Button>
663 {status.configured && (
664 <>
665 <Button
666 type="button"
667 variant="outline"
668 onClick={() => testKeyMutation.mutate(p.id)}
669 disabled={testKeyMutation.isPending}
670 >
671 {testKeyMutation.isPending &&
672 testKeyMutation.variables === p.id ? (
673 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
674 ) : null}
675 {__("Test connection", "yatra")}
676 </Button>
677 <Button
678 type="button"
679 variant="ghost"
680 onClick={() => deleteKeyMutation.mutate(p.id)}
681 disabled={deleteKeyMutation.isPending}
682 >
683 <Trash2 className="mr-2 h-4 w-4" />
684 {__("Clear", "yatra")}
685 </Button>
686 </>
687 )}
688 </div>
689 </div>
690 );
691 })}
692
693 {!moduleEnabled && (
694 <div className="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50/40 p-3 text-xs text-amber-800 dark:border-amber-500/40 dark:bg-amber-900/20 dark:text-amber-200">
695 <Lock className="mt-0.5 h-4 w-4 shrink-0" />
696 <div>
697 {__(
698 "AI Assistant module is not enabled yet. Once a key is saved, enable the module from the Modules page so sparkle affordances appear in the editors.",
699 "yatra",
700 )}{" "}
701 <a
702 className="font-medium underline"
703 href="admin.php?page=yatra&subpage=modules"
704 >
705 {__("Go to Modules →", "yatra")}
706 </a>
707 </div>
708 </div>
709 )}
710
711 {/* Public chat-widget toggle — separate from the module switch
712 so operators can opt-in to the front-of-house AI without
713 touching their back-office editor affordances. */}
714 <div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
715 <div className="flex items-start justify-between gap-3">
716 <div className="flex items-start gap-2">
717 <MessageCircle className="mt-0.5 h-4 w-4 text-blue-600 dark:text-blue-300" />
718 <div>
719 <div className="text-sm font-medium text-gray-900 dark:text-white">
720 {__("Public Chat Widget", "yatra")}
721 </div>
722 <div className="mt-0.5 text-xs text-gray-500 dark:text-gray-400">
723 {__(
724 'Adds a "Chat with AI about this trip" button below the Send Enquiry button on every single-trip page. Visitors can ask trip-specific questions. Rate-limited per IP/session/trip/day so a hostile visitor can\'t burn your provider budget.',
725 "yatra",
726 )}
727 </div>
728 </div>
729 </div>
730 <button
731 type="button"
732 role="switch"
733 aria-checked={Boolean(meta.trip_chat_enabled)}
734 onClick={() => tripChatMutation.mutate(!meta.trip_chat_enabled)}
735 disabled={tripChatMutation.isPending || !moduleEnabled}
736 className={`relative inline-flex h-6 w-11 shrink-0 cursor-pointer rounded-full transition-colors ${
737 meta.trip_chat_enabled
738 ? "bg-blue-600"
739 : "bg-gray-300 dark:bg-gray-600"
740 } ${
741 tripChatMutation.isPending || !moduleEnabled
742 ? "opacity-50 cursor-not-allowed"
743 : ""
744 }`}
745 >
746 <span
747 className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
748 meta.trip_chat_enabled ? "translate-x-5" : "translate-x-0.5"
749 } translate-y-0.5`}
750 />
751 </button>
752 </div>
753 {!moduleEnabled && (
754 <div className="mt-2 text-[11px] text-amber-600 dark:text-amber-300">
755 {__(
756 "Enable the AI Assistant module first, then turn this on.",
757 "yatra",
758 )}
759 </div>
760 )}
761
762 {/* Rate-limit configuration. Only meaningful when the chat
763 widget itself is enabled — but we render the form anyway
764 so operators can pre-tune limits before flipping the
765 toggle. Each field is clamped to its server-side schema
766 range on save so a runaway number can't accidentally
767 uncap the AI bill. */}
768 {meta.trip_chat_enabled && meta.trip_chat_limits_schema && (
769 <div className="mt-3 space-y-3 border-t border-gray-200 pt-3 dark:border-gray-700">
770 <div>
771 <div className="text-xs font-medium text-gray-700 dark:text-gray-200">
772 {__("Chat rate limits", "yatra")}
773 </div>
774 <div className="mt-0.5 text-[11px] text-gray-500 dark:text-gray-400">
775 {__(
776 "Caps on the public chat widget to protect your AI provider budget. Higher numbers = more freedom for visitors and more potential cost. Each field is clamped to a safe range on save.",
777 "yatra",
778 )}
779 </div>
780 </div>
781
782 <div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
783 <LimitField
784 label={__("Per trip · per day", "yatra")}
785 hint={__("Max chat messages per trip per day.", "yatra")}
786 field="per_trip_day"
787 draft={chatLimitsDraft}
788 setDraft={setChatLimitsDraft}
789 schema={meta.trip_chat_limits_schema}
790 />
791 <LimitField
792 label={__("Per IP · per hour", "yatra")}
793 hint={__(
794 "Max messages from one visitor IP per hour.",
795 "yatra",
796 )}
797 field="per_ip_hour"
798 draft={chatLimitsDraft}
799 setDraft={setChatLimitsDraft}
800 schema={meta.trip_chat_limits_schema}
801 />
802 <LimitField
803 label={__("Per session (info)", "yatra")}
804 hint={__(
805 "Cap when visitor is just asking questions.",
806 "yatra",
807 )}
808 field="per_session"
809 draft={chatLimitsDraft}
810 setDraft={setChatLimitsDraft}
811 schema={meta.trip_chat_limits_schema}
812 />
813 <LimitField
814 label={__("Per session (booking)", "yatra")}
815 hint={__(
816 "Higher cap once visitor signals booking intent.",
817 "yatra",
818 )}
819 field="per_session_booking"
820 draft={chatLimitsDraft}
821 setDraft={setChatLimitsDraft}
822 schema={meta.trip_chat_limits_schema}
823 />
824 <LimitField
825 label={__("Max message length", "yatra")}
826 hint={__(
827 "Max characters in a single visitor message.",
828 "yatra",
829 )}
830 field="max_message_chars"
831 draft={chatLimitsDraft}
832 setDraft={setChatLimitsDraft}
833 schema={meta.trip_chat_limits_schema}
834 />
835 <LimitField
836 label={__("History turns sent", "yatra")}
837 hint={__(
838 "How many recent turns the AI sees for context.",
839 "yatra",
840 )}
841 field="history_turns"
842 draft={chatLimitsDraft}
843 setDraft={setChatLimitsDraft}
844 schema={meta.trip_chat_limits_schema}
845 />
846 </div>
847
848 <div className="flex justify-end gap-2">
849 <Button
850 type="button"
851 variant="outline"
852 onClick={() => {
853 // Revert the form to whatever the server currently
854 // has stored, discarding unsaved edits.
855 if (meta.trip_chat_limits) {
856 const l = meta.trip_chat_limits;
857 setChatLimitsDraft({
858 per_trip_day: String(l.per_trip_day),
859 per_ip_hour: String(l.per_ip_hour),
860 per_session: String(l.per_session),
861 per_session_booking: String(l.per_session_booking),
862 max_message_chars: String(l.max_message_chars),
863 history_turns: String(l.history_turns),
864 });
865 }
866 }}
867 disabled={tripChatLimitsMutation.isPending}
868 >
869 {__("Revert", "yatra")}
870 </Button>
871 <Button
872 type="button"
873 onClick={() => {
874 // Convert all fields to numbers — empty / NaN
875 // skipped so the server falls back to whatever
876 // it currently has stored for that key.
877 const patch: Partial<AiChatLimits> = {};
878 (
879 Object.keys(chatLimitsDraft) as Array<keyof AiChatLimits>
880 ).forEach((k) => {
881 const v = parseInt(chatLimitsDraft[k], 10);
882 if (Number.isFinite(v)) patch[k] = v;
883 });
884 tripChatLimitsMutation.mutate(patch);
885 }}
886 disabled={tripChatLimitsMutation.isPending}
887 >
888 {tripChatLimitsMutation.isPending ? (
889 <>
890 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
891 {__("Saving…", "yatra")}
892 </>
893 ) : (
894 <>
895 <Save className="mr-2 h-4 w-4" />
896 {__("Save limits", "yatra")}
897 </>
898 )}
899 </Button>
900 </div>
901 </div>
902 )}
903 </div>
904 </CardContent>
905 </Card>
906 );
907
908 const renderVoice = () => (
909 <Card>
910 <CardHeader>
911 <CardTitle>{__("Brand voice", "yatra")}</CardTitle>
912 <CardDescription>
913 {__(
914 "Define your voice once — it flows into every generation across trips, SEO, and emails. The more specific, the more your AI output sounds like you.",
915 "yatra",
916 )}
917 </CardDescription>
918 </CardHeader>
919 <CardContent>
920 {/* Intro callout — explains the WHY of brand voice. Every
921 AI prompt the plugin sends to OpenAI / Anthropic gets the
922 brand-voice block auto-prepended as the first lines of
923 the system prompt, so trip descriptions, enquiry replies,
924 email subjects, and chat responses all share a consistent
925 tone. */}
926 <div className="mb-4 rounded-md border border-blue-200 bg-blue-50/60 p-3 text-xs text-blue-900 dark:border-blue-500/40 dark:bg-blue-900/20 dark:text-blue-200">
927 <div className="mb-1 font-semibold">
928 {__("How brand voice is used", "yatra")}
929 </div>
930 <p className="leading-relaxed">
931 {__(
932 "Everything you fill in here is automatically prepended to every prompt the plugin sends to your AI provider. Trip descriptions, enquiry replies, email templates, the public chat widget, and the back-office wizards all start with these instructions — so your output sounds like one consistent brand, not a different voice per feature.",
933 "yatra",
934 )}
935 </p>
936 </div>
937
938 <form onSubmit={submitBv} className="space-y-5">
939 <div>
940 <Label htmlFor="bv-tone">{__("Tone description", "yatra")}</Label>
941 <p className="mb-1.5 text-xs text-gray-500 dark:text-gray-400">
942 {__(
943 "The single most important field. 2-3 sentences describing how you'd describe your brand to a new copywriter. Plain English — not marketing jargon. AI reads this every generation and aims for this tone.",
944 "yatra",
945 )}
946 </p>
947 <textarea
948 id="bv-tone"
949 rows={3}
950 className="mt-1 w-full rounded-md border border-gray-300 bg-white p-2 text-sm dark:border-gray-600 dark:bg-gray-800"
951 value={bv.tone}
952 placeholder={__(
953 "e.g. Warm, adventurous, never corporate. Speak directly to the traveler. We're a small team, not a corporation.",
954 "yatra",
955 )}
956 onChange={(e) => updateBv("tone", e.target.value)}
957 />
958 </div>
959
960 <div>
961 <Label htmlFor="bv-examples">
962 {__("Style examples (one paragraph per line)", "yatra")}
963 </Label>
964 <p className="mb-1.5 text-xs text-gray-500 dark:text-gray-400">
965 {__(
966 "Paste 3-5 paragraphs from your existing trip pages, emails, or marketing copy that sound right. AI uses these as few-shot examples — concrete writing samples beat abstract tone descriptions for getting the voice exactly right. One paragraph per line.",
967 "yatra",
968 )}
969 </p>
970 <textarea
971 id="bv-examples"
972 rows={6}
973 className="mt-1 w-full rounded-md border border-gray-300 bg-white p-2 font-mono text-xs dark:border-gray-600 dark:bg-gray-800"
974 value={bv.examples.join("\n")}
975 placeholder={__(
976 "Paste 3-5 paragraphs from your existing trips/emails that sound like you. AI uses these as few-shot examples.",
977 "yatra",
978 )}
979 onChange={(e) => updateBvList("examples", e.target.value)}
980 />
981 </div>
982
983 <div className="grid gap-4 md:grid-cols-2">
984 <div>
985 <Label htmlFor="bv-forbidden">
986 {__("Forbidden words / phrases", "yatra")}
987 </Label>
988 <p className="mb-1.5 text-xs text-gray-500 dark:text-gray-400">
989 {__(
990 'Words AI must never use. Common entries: "unforgettable", "once in a lifetime", "breathtaking", "world-class", or any competitor name. One per line — AI treats these as a hard blocklist.',
991 "yatra",
992 )}
993 </p>
994 <textarea
995 id="bv-forbidden"
996 rows={4}
997 className="mt-1 w-full rounded-md border border-gray-300 bg-white p-2 text-xs dark:border-gray-600 dark:bg-gray-800"
998 value={bv.forbidden.join("\n")}
999 placeholder={__(
1000 "One per line — words / phrases to avoid.",
1001 "yatra",
1002 )}
1003 onChange={(e) => updateBvList("forbidden", e.target.value)}
1004 />
1005 </div>
1006 <div>
1007 <Label htmlFor="bv-required">
1008 {__("Required mentions", "yatra")}
1009 </Label>
1010 <p className="mb-1.5 text-xs text-gray-500 dark:text-gray-400">
1011 {__(
1012 'Things AI should weave in WHEN RELEVANT — never forced. Examples: "local guides", "small group sizes", "sustainable practices", "our 24/7 support". One per line. AI mentions these only when they fit the context naturally.',
1013 "yatra",
1014 )}
1015 </p>
1016 <textarea
1017 id="bv-required"
1018 rows={4}
1019 className="mt-1 w-full rounded-md border border-gray-300 bg-white p-2 text-xs dark:border-gray-600 dark:bg-gray-800"
1020 value={bv.required.join("\n")}
1021 placeholder={__(
1022 "One per line — things to weave in when relevant.",
1023 "yatra",
1024 )}
1025 onChange={(e) => updateBvList("required", e.target.value)}
1026 />
1027 </div>
1028 </div>
1029
1030 <div className="grid gap-4 md:grid-cols-3">
1031 <div>
1032 <Label htmlFor="bv-lang">{__("Default language", "yatra")}</Label>
1033 <p className="mb-1.5 text-xs text-gray-500 dark:text-gray-400">
1034 {__(
1035 "BCP-47 code. AI writes in this language unless overridden by per-prompt context (e.g. enquiry replies match the customer's preferred language when known).",
1036 "yatra",
1037 )}
1038 </p>
1039 <Input
1040 id="bv-lang"
1041 value={bv.language}
1042 placeholder="en-US"
1043 onChange={(e) => updateBv("language", e.target.value)}
1044 />
1045 </div>
1046 <div>
1047 <Label htmlFor="bv-provider">
1048 {__("Default provider", "yatra")}
1049 </Label>
1050 <p className="mb-1.5 text-xs text-gray-500 dark:text-gray-400">
1051 {__(
1052 "Which AI service handles each generation by default. Per-feature usage runs through the provider whose API key is configured under API Keys above.",
1053 "yatra",
1054 )}
1055 </p>
1056 <Select
1057 id="bv-provider"
1058 value={bv.default_provider}
1059 onChange={(e) => updateBv("default_provider", e.target.value)}
1060 >
1061 {meta.providers.map((p) => (
1062 <option key={p.id} value={p.id}>
1063 {p.label}
1064 </option>
1065 ))}
1066 </Select>
1067 </div>
1068 <div>
1069 <Label htmlFor="bv-model">{__("Default model", "yatra")}</Label>
1070 <p className="mb-1.5 text-xs text-gray-500 dark:text-gray-400">
1071 {__(
1072 "Specific model within the provider. Smaller / faster models (gpt-4o-mini, Claude Haiku) are cheaper and ~good enough for most generations. Larger models are slower + costlier but produce richer output for trip descriptions.",
1073 "yatra",
1074 )}
1075 </p>
1076 <Select
1077 id="bv-model"
1078 value={bv.default_model}
1079 onChange={(e) => updateBv("default_model", e.target.value)}
1080 >
1081 <option value="">{__("(Provider default)", "yatra")}</option>
1082 {(
1083 meta.providers.find((p) => p.id === bv.default_provider)
1084 ?.models ?? []
1085 ).map((m) => (
1086 <option key={m.id} value={m.id}>
1087 {m.label}
1088 </option>
1089 ))}
1090 </Select>
1091 </div>
1092 </div>
1093
1094 <div className="flex items-center gap-3 pt-2">
1095 <Button type="submit" disabled={saveBvMutation.isPending}>
1096 {saveBvMutation.isPending ? (
1097 <>
1098 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1099 {__("Saving…", "yatra")}
1100 </>
1101 ) : (
1102 <>
1103 <Save className="mr-2 h-4 w-4" />
1104 {__("Save brand voice", "yatra")}
1105 </>
1106 )}
1107 </Button>
1108 </div>
1109 </form>
1110 </CardContent>
1111 </Card>
1112 );
1113
1114 const renderUsage = () => {
1115 const usage = usageResp?.data;
1116 return (
1117 <Card>
1118 <CardHeader>
1119 <CardTitle>{__("Usage", "yatra")}</CardTitle>
1120 <CardDescription>
1121 {__(
1122 "Rolling 13-month token totals, per provider, per task. Costs are paid directly to your AI provider — no markup is added.",
1123 "yatra",
1124 )}
1125 </CardDescription>
1126 </CardHeader>
1127 <CardContent>
1128 {!usage ? (
1129 <ModuleStatGridSkeleton tiles={3} />
1130 ) : (
1131 <>
1132 <div className="mb-4 grid grid-cols-3 gap-3">
1133 <Stat
1134 label={__("Total calls", "yatra")}
1135 value={usage.totals.calls.toLocaleString()}
1136 />
1137 <Stat
1138 label={__("Prompt tokens", "yatra")}
1139 value={usage.totals.prompt.toLocaleString()}
1140 />
1141 <Stat
1142 label={__("Completion tokens", "yatra")}
1143 value={usage.totals.completion.toLocaleString()}
1144 />
1145 </div>
1146
1147 {Object.keys(usage.months).length === 0 && (
1148 <p className="text-sm text-gray-500">
1149 {__("No AI usage yet.", "yatra")}
1150 </p>
1151 )}
1152
1153 <div className="space-y-3">
1154 {Object.entries(usage.months).map(([month, providers]) => (
1155 <div
1156 key={month}
1157 className="rounded-md border border-gray-200 p-3 dark:border-gray-700"
1158 >
1159 <div className="mb-2 text-sm font-medium text-gray-900 dark:text-white">
1160 {month}
1161 {month === usage.current_month && (
1162 <span className="ml-2 rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-semibold uppercase text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
1163 {__("Current", "yatra")}
1164 </span>
1165 )}
1166 </div>
1167 {Object.entries(providers).map(([prov, bucket]) => (
1168 <div
1169 key={prov}
1170 className="mb-1 grid grid-cols-4 gap-2 text-xs"
1171 >
1172 <div className="font-medium text-gray-700 dark:text-gray-200">
1173 {prov}
1174 </div>
1175 <div>
1176 {bucket.calls} {__("calls", "yatra")}
1177 </div>
1178 <div>
1179 {bucket.prompt.toLocaleString()} {__("in", "yatra")}
1180 </div>
1181 <div>
1182 {bucket.completion.toLocaleString()}{" "}
1183 {__("out", "yatra")}
1184 </div>
1185 </div>
1186 ))}
1187 </div>
1188 ))}
1189 </div>
1190 </>
1191 )}
1192 </CardContent>
1193 </Card>
1194 );
1195 };
1196
1197 const renderPrompts = () => {
1198 const rows = promptsResp?.data ?? [];
1199
1200 // Group by category for an easier-to-scan list.
1201 const grouped: Record<string, AiPromptRow[]> = {};
1202 for (const r of rows) {
1203 (grouped[r.category] ||= []).push(r);
1204 }
1205 const groupOrder = Object.keys(grouped);
1206
1207 const submitPrompt = (row: AiPromptRow) => {
1208 const draft = promptDrafts[row.task];
1209 if (!draft) return;
1210 // Only persist fields that differ from default. Empty string = revert.
1211 const override: {
1212 system?: string;
1213 user?: string;
1214 max_tokens?: number | null;
1215 temperature?: number | null;
1216 } = {};
1217
1218 const sysTrim = draft.system.trim();
1219 override.system =
1220 sysTrim === "" || sysTrim === row.default.system.trim()
1221 ? ""
1222 : draft.system;
1223
1224 const usrTrim = draft.user.trim();
1225 override.user =
1226 usrTrim === "" || usrTrim === row.default.user.trim() ? "" : draft.user;
1227
1228 const mt = parseInt(draft.max_tokens, 10);
1229 override.max_tokens =
1230 Number.isFinite(mt) && mt !== row.default.max_tokens ? mt : null;
1231
1232 const tp = parseFloat(draft.temperature);
1233 override.temperature =
1234 Number.isFinite(tp) && tp !== row.default.temperature ? tp : null;
1235
1236 savePromptMutation.mutate({ task: row.task, override });
1237 };
1238
1239 return (
1240 <Card>
1241 <CardHeader>
1242 <div className="flex flex-wrap items-start justify-between gap-3">
1243 <div>
1244 <CardTitle>{__("Prompts", "yatra")}</CardTitle>
1245 <CardDescription>
1246 {__(
1247 "Every system + user prompt the assistant uses. Edit any field — blank fields fall back to the shipped default per-field. Brand voice is auto-prepended to system prompts, so don't repeat it here.",
1248 "yatra",
1249 )}
1250 </CardDescription>
1251 </div>
1252 <Button
1253 type="button"
1254 variant="outline"
1255 onClick={() => {
1256 if (
1257 window.confirm(
1258 __(
1259 "Reset every prompt to its shipped default? This wipes all your overrides.",
1260 "yatra",
1261 ),
1262 )
1263 ) {
1264 resetAllPromptsMutation.mutate();
1265 }
1266 }}
1267 disabled={resetAllPromptsMutation.isPending}
1268 >
1269 <RotateCcw className="mr-2 h-4 w-4" />
1270 {__("Reset all", "yatra")}
1271 </Button>
1272 </div>
1273 </CardHeader>
1274 <CardContent>
1275 {promptsLoading ? (
1276 <ModuleSectionSkeleton lines={5} />
1277 ) : rows.length === 0 ? (
1278 <p className="text-sm text-gray-500">
1279 {__("No prompts available.", "yatra")}
1280 </p>
1281 ) : (
1282 <div className="space-y-6">
1283 <div className="flex items-start gap-2 rounded-md border border-blue-200 bg-blue-50/40 p-3 text-xs text-blue-800 dark:border-blue-500/40 dark:bg-blue-900/20 dark:text-blue-200">
1284 <AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
1285 <div>
1286 {__(
1287 "Use {{placeholders}} to inject runtime context (e.g. {{name}}, {{extra_context}}). Removing a placeholder means the assistant won't see that field — edit with care.",
1288 "yatra",
1289 )}
1290 </div>
1291 </div>
1292
1293 {groupOrder.map((cat) => (
1294 <div key={cat}>
1295 <div className="mb-2 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
1296 {cat}
1297 </div>
1298 <div className="space-y-2">
1299 {grouped[cat].map((row) => {
1300 const isOpen = expandedPrompt === row.task;
1301 const draft = promptDrafts[row.task] ?? {
1302 system: row.default.system,
1303 user: row.default.user,
1304 max_tokens: String(row.default.max_tokens),
1305 temperature: String(row.default.temperature),
1306 };
1307 const saving =
1308 savePromptMutation.isPending &&
1309 (savePromptMutation.variables as any)?.task ===
1310 row.task;
1311 const resetting =
1312 resetPromptMutation.isPending &&
1313 resetPromptMutation.variables === row.task;
1314
1315 return (
1316 <div
1317 key={row.task}
1318 className="rounded-md border border-gray-200 dark:border-gray-700"
1319 >
1320 <button
1321 type="button"
1322 onClick={() =>
1323 setExpandedPrompt(isOpen ? null : row.task)
1324 }
1325 className="flex w-full items-start justify-between gap-2 px-3 py-2 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-800/60"
1326 >
1327 <div className="min-w-0 flex-1">
1328 <div className="flex flex-wrap items-center gap-2">
1329 <span className="font-medium text-gray-900 dark:text-white">
1330 {row.label}
1331 </span>
1332 <code className="rounded bg-gray-100 px-1.5 py-0.5 text-[10px] text-gray-600 dark:bg-gray-800 dark:text-gray-300">
1333 {row.task}
1334 </code>
1335 {row.has_override && (
1336 <span className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
1337 {__("Customized", "yatra")}
1338 </span>
1339 )}
1340 </div>
1341 {/* Operator-facing one-liner explaining
1342 WHERE this prompt is used in the
1343 product. Renders only when registered
1344 (no description = nothing shown). */}
1345 {row.description && (
1346 <div className="mt-0.5 text-[11px] leading-snug text-gray-500 dark:text-gray-400">
1347 {row.description}
1348 </div>
1349 )}
1350 </div>
1351 {isOpen ? (
1352 <ChevronUp className="mt-1 h-4 w-4 shrink-0 text-gray-400" />
1353 ) : (
1354 <ChevronDown className="mt-1 h-4 w-4 shrink-0 text-gray-400" />
1355 )}
1356 </button>
1357
1358 {isOpen && (
1359 <div className="space-y-3 border-t border-gray-200 p-3 dark:border-gray-700">
1360 <div>
1361 <Label htmlFor={`pr-sys-${row.task}`}>
1362 {__("System prompt", "yatra")}
1363 </Label>
1364 <textarea
1365 id={`pr-sys-${row.task}`}
1366 rows={5}
1367 className="mt-1 w-full rounded-md border border-gray-300 bg-white p-2 font-mono text-xs dark:border-gray-600 dark:bg-gray-800"
1368 value={draft.system}
1369 onChange={(e) =>
1370 setPromptDrafts((d) => ({
1371 ...d,
1372 [row.task]: {
1373 ...draft,
1374 system: e.target.value,
1375 },
1376 }))
1377 }
1378 />
1379 </div>
1380
1381 <div>
1382 <Label htmlFor={`pr-usr-${row.task}`}>
1383 {__("User prompt", "yatra")}
1384 </Label>
1385 <textarea
1386 id={`pr-usr-${row.task}`}
1387 rows={10}
1388 className="mt-1 w-full rounded-md border border-gray-300 bg-white p-2 font-mono text-xs dark:border-gray-600 dark:bg-gray-800"
1389 value={draft.user}
1390 onChange={(e) =>
1391 setPromptDrafts((d) => ({
1392 ...d,
1393 [row.task]: {
1394 ...draft,
1395 user: e.target.value,
1396 },
1397 }))
1398 }
1399 />
1400 </div>
1401
1402 <div className="grid gap-3 md:grid-cols-2">
1403 <div>
1404 <Label htmlFor={`pr-mt-${row.task}`}>
1405 {__("Max tokens", "yatra")}{" "}
1406 <span className="text-[10px] text-gray-400">
1407 ({__("default", "yatra")}:{" "}
1408 {row.default.max_tokens})
1409 </span>
1410 </Label>
1411 <Input
1412 id={`pr-mt-${row.task}`}
1413 type="number"
1414 min={16}
1415 max={8000}
1416 value={draft.max_tokens}
1417 onChange={(e) =>
1418 setPromptDrafts((d) => ({
1419 ...d,
1420 [row.task]: {
1421 ...draft,
1422 max_tokens: e.target.value,
1423 },
1424 }))
1425 }
1426 />
1427 </div>
1428 <div>
1429 <Label htmlFor={`pr-tp-${row.task}`}>
1430 {__("Temperature", "yatra")}{" "}
1431 <span className="text-[10px] text-gray-400">
1432 ({__("default", "yatra")}:{" "}
1433 {row.default.temperature})
1434 </span>
1435 </Label>
1436 <Input
1437 id={`pr-tp-${row.task}`}
1438 type="number"
1439 min={0}
1440 max={2}
1441 step={0.1}
1442 value={draft.temperature}
1443 onChange={(e) =>
1444 setPromptDrafts((d) => ({
1445 ...d,
1446 [row.task]: {
1447 ...draft,
1448 temperature: e.target.value,
1449 },
1450 }))
1451 }
1452 />
1453 </div>
1454 </div>
1455
1456 <details className="rounded-md border border-dashed border-gray-200 px-3 py-2 text-xs dark:border-gray-700">
1457 <summary className="cursor-pointer text-gray-500 hover:text-gray-700 dark:text-gray-400">
1458 {__("Show shipped defaults", "yatra")}
1459 </summary>
1460 <div className="mt-2 space-y-2">
1461 <div>
1462 <div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400">
1463 {__("Default system", "yatra")}
1464 </div>
1465 <pre className="max-h-40 overflow-auto whitespace-pre-wrap rounded bg-gray-50 p-2 text-[11px] text-gray-700 dark:bg-gray-900 dark:text-gray-200">
1466 {row.default.system}
1467 </pre>
1468 </div>
1469 <div>
1470 <div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400">
1471 {__("Default user", "yatra")}
1472 </div>
1473 <pre className="max-h-60 overflow-auto whitespace-pre-wrap rounded bg-gray-50 p-2 text-[11px] text-gray-700 dark:bg-gray-900 dark:text-gray-200">
1474 {row.default.user}
1475 </pre>
1476 </div>
1477 </div>
1478 </details>
1479
1480 <div className="flex flex-wrap items-center gap-2 pt-1">
1481 <Button
1482 type="button"
1483 onClick={() => submitPrompt(row)}
1484 disabled={saving}
1485 >
1486 {saving ? (
1487 <>
1488 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1489 {__("Saving…", "yatra")}
1490 </>
1491 ) : (
1492 <>
1493 <Save className="mr-2 h-4 w-4" />
1494 {__("Save", "yatra")}
1495 </>
1496 )}
1497 </Button>
1498 {row.has_override && (
1499 <Button
1500 type="button"
1501 variant="ghost"
1502 onClick={() =>
1503 resetPromptMutation.mutate(row.task)
1504 }
1505 disabled={resetting}
1506 >
1507 {resetting ? (
1508 <Loader2 className="mr-2 h-4 w-4 animate-spin" />
1509 ) : (
1510 <RotateCcw className="mr-2 h-4 w-4" />
1511 )}
1512 {__("Reset to default", "yatra")}
1513 </Button>
1514 )}
1515 </div>
1516 </div>
1517 )}
1518 </div>
1519 );
1520 })}
1521 </div>
1522 </div>
1523 ))}
1524 </div>
1525 )}
1526 </CardContent>
1527 </Card>
1528 );
1529 };
1530
1531 const currentSection =
1532 SECTIONS.find((s) => s.id === activeSection) ?? SECTIONS[0];
1533
1534 return (
1535 <div className="space-y-6">
1536 <PageHeader
1537 description={__(
1538 "Inline AI generation grounded in your trip data. Bring your own key.",
1539 "yatra",
1540 )}
1541 />
1542
1543 <div className="grid gap-6 lg:grid-cols-[240px_1fr]">
1544 <aside className="lg:sticky lg:top-4 lg:self-start">
1545 <Card>
1546 <CardContent className="p-2">
1547 <nav className="flex flex-col gap-1">
1548 {SECTIONS.map((s) => {
1549 const Icon = s.icon;
1550 const active = s.id === activeSection;
1551 return (
1552 <button
1553 key={s.id}
1554 type="button"
1555 onClick={() => setActiveSection(s.id)}
1556 className={`flex items-center gap-2 rounded-md px-3 py-2 text-left text-sm transition-colors ${
1557 active
1558 ? "bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"
1559 : "text-gray-700 hover:bg-gray-50 dark:text-gray-200 dark:hover:bg-gray-800"
1560 }`}
1561 >
1562 <Icon className="h-4 w-4 shrink-0" />
1563 <span className="flex-1 truncate">{s.label}</span>
1564 </button>
1565 );
1566 })}
1567 </nav>
1568 </CardContent>
1569 </Card>
1570 <div className="mt-3 px-2 text-xs text-gray-500 dark:text-gray-400">
1571 {currentSection.description}
1572 </div>
1573 </aside>
1574
1575 <div className="space-y-4">
1576 {activeSection === "keys" && renderKeys()}
1577 {activeSection === "voice" && renderVoice()}
1578 {activeSection === "prompts" && renderPrompts()}
1579 {activeSection === "usage" && renderUsage()}
1580 </div>
1581 </div>
1582 </div>
1583 );
1584 };
1585
1586 /**
1587 * Numeric input + label for one chat rate-limit field. Shows the
1588 * schema-declared min/max as a hint so the operator knows the
1589 * accepted range before they save.
1590 */
1591 const LimitField: React.FC<{
1592 label: string;
1593 hint: string;
1594 field: keyof AiChatLimits;
1595 draft: Record<keyof AiChatLimits, string>;
1596 setDraft: React.Dispatch<
1597 React.SetStateAction<Record<keyof AiChatLimits, string>>
1598 >;
1599 schema: Record<
1600 keyof AiChatLimits,
1601 { default: number; min: number; max: number }
1602 >;
1603 }> = ({ label, hint, field, draft, setDraft, schema }) => {
1604 const range = schema[field];
1605 return (
1606 <div>
1607 <Label htmlFor={`chat-limit-${field}`}>{label}</Label>
1608 <Input
1609 id={`chat-limit-${field}`}
1610 type="number"
1611 min={range?.min}
1612 max={range?.max}
1613 value={draft[field]}
1614 onChange={(e) =>
1615 setDraft((prev) => ({ ...prev, [field]: e.target.value }))
1616 }
1617 />
1618 <div className="mt-0.5 text-[10px] text-gray-500 dark:text-gray-400">
1619 {hint}
1620 {range && (
1621 <>
1622 {" "}
1623 <span className="text-gray-400">
1624 ({range.min}–{range.max})
1625 </span>
1626 </>
1627 )}
1628 </div>
1629 </div>
1630 );
1631 };
1632
1633 const Stat: React.FC<{ label: string; value: string }> = ({ label, value }) => (
1634 <div className="rounded-md border border-gray-200 p-3 dark:border-gray-700">
1635 <div className="text-[11px] uppercase tracking-wide text-gray-500 dark:text-gray-400">
1636 {label}
1637 </div>
1638 <div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
1639 {value}
1640 </div>
1641 </div>
1642 );
1643
1644 function extractError(e: any): string {
1645 const data = e?.response?.data ?? e?.data ?? null;
1646 if (
1647 data &&
1648 typeof data === "object" &&
1649 typeof (data as any).message === "string"
1650 ) {
1651 return (data as any).message;
1652 }
1653 return e?.message || "AI request failed.";
1654 }
1655
1656 export default AiAssistant;
1657