| 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 |
|