diff --git a/skyvern-frontend/package-lock.json b/skyvern-frontend/package-lock.json index 3a5dc9df..c688a0cd 100644 --- a/skyvern-frontend/package-lock.json +++ b/skyvern-frontend/package-lock.json @@ -42,6 +42,7 @@ "clsx": "^2.1.0", "cmdk": "^1.0.0", "cors": "^2.8.5", + "country-state-city": "^3.2.1", "cross-spawn": "^7.0.6", "embla-carousel-react": "^8.0.0", "express": "^4.21.2", @@ -5015,6 +5016,11 @@ "node": ">= 0.10" } }, + "node_modules/country-state-city": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/country-state-city/-/country-state-city-3.2.1.tgz", + "integrity": "sha512-kxbanqMc6izjhc/EHkGPCTabSPZ2G6eG4/97akAYHJUN4stzzFEvQPZoF8oXDQ+10gM/O/yUmISCR1ZVxyb6EA==" + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", diff --git a/skyvern-frontend/package.json b/skyvern-frontend/package.json index a9e0d04f..0d82153e 100644 --- a/skyvern-frontend/package.json +++ b/skyvern-frontend/package.json @@ -51,6 +51,7 @@ "clsx": "^2.1.0", "cmdk": "^1.0.0", "cors": "^2.8.5", + "country-state-city": "^3.2.1", "cross-spawn": "^7.0.6", "embla-carousel-react": "^8.0.0", "express": "^4.21.2", diff --git a/skyvern-frontend/src/api/types.ts b/skyvern-frontend/src/api/types.ts index 9c00de3c..30cfc6fd 100644 --- a/skyvern-frontend/src/api/types.ts +++ b/skyvern-frontend/src/api/types.ts @@ -56,7 +56,17 @@ export const ProxyLocation = { None: "NONE", } as const; -export type ProxyLocation = (typeof ProxyLocation)[keyof typeof ProxyLocation]; +export type LegacyProxyLocation = + (typeof ProxyLocation)[keyof typeof ProxyLocation]; + +export type GeoTarget = { + country: string; + subdivision?: string; + city?: string; + isISP?: boolean; +}; + +export type ProxyLocation = LegacyProxyLocation | GeoTarget | null; export type ArtifactApiResponse = { created_at: string; diff --git a/skyvern-frontend/src/components/GeoTargetSelector.tsx b/skyvern-frontend/src/components/GeoTargetSelector.tsx new file mode 100644 index 00000000..594a1263 --- /dev/null +++ b/skyvern-frontend/src/components/GeoTargetSelector.tsx @@ -0,0 +1,207 @@ +import * as React from "react"; +import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; +import { cn } from "@/util/utils"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { GeoTarget } from "@/api/types"; +import { formatGeoTargetCompact } from "@/util/geoData"; +import { + GroupedSearchResults, + searchGeoData, + SearchResultItem, +} from "@/util/geoSearch"; +import { useDebouncedCallback } from "use-debounce"; + +interface GeoTargetSelectorProps { + value: GeoTarget | null; + onChange: (value: GeoTarget) => void; + className?: string; +} + +export function GeoTargetSelector({ + value, + onChange, + className, +}: GeoTargetSelectorProps) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const [results, setResults] = React.useState({ + countries: [], + subdivisions: [], + cities: [], + }); + const [loading, setLoading] = React.useState(false); + + const handleSearch = useDebouncedCallback(async (searchQuery: string) => { + setLoading(true); + try { + const data = await searchGeoData(searchQuery); + setResults(data); + } catch (error) { + console.error("Failed to search geo data", error); + } finally { + setLoading(false); + } + }, 300); + + // Initial load of countries + React.useEffect(() => { + if (open) { + handleSearch(""); + } + }, [open, handleSearch]); + + const onInput = (val: string) => { + setQuery(val); + handleSearch(val); + }; + + const handleSelect = (item: SearchResultItem) => { + onChange(item.value); + setOpen(false); + }; + + const isSelected = (itemValue: GeoTarget) => { + if (!value) return false; + return ( + value.country === itemValue.country && + value.subdivision === itemValue.subdivision && + value.city === itemValue.city + ); + }; + + return ( + + + + + + + + + {loading && ( +
+ Loading... +
+ )} + {!loading && + results.countries.length === 0 && + results.subdivisions.length === 0 && + results.cities.length === 0 && ( + No location found. + )} + + {!loading && ( + <> + {results.countries.length > 0 && ( + + {results.countries.map((item) => ( + handleSelect(item)} + > + {item.icon} + {item.label} + + + ))} + + )} + + {results.subdivisions.length > 0 && ( + + {results.subdivisions.map((item) => ( + handleSelect(item)} + > + {item.icon} +
+ {item.label} + + {item.description} + +
+ +
+ ))} +
+ )} + + {results.cities.length > 0 && ( + + {results.cities.map((item) => ( + handleSelect(item)} + > + {item.icon} +
+ {item.label} + + {item.description} + +
+ +
+ ))} +
+ )} + + )} +
+
+
+
+ ); +} diff --git a/skyvern-frontend/src/components/ProxySelector.tsx b/skyvern-frontend/src/components/ProxySelector.tsx index 37c40372..38b762d0 100644 --- a/skyvern-frontend/src/components/ProxySelector.tsx +++ b/skyvern-frontend/src/components/ProxySelector.tsx @@ -1,82 +1,31 @@ import { ProxyLocation } from "@/api/types"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "./ui/select"; + geoTargetToProxyLocationInput, + proxyLocationToGeoTarget, +} from "@/util/geoData"; +import { GeoTargetSelector } from "./GeoTargetSelector"; type Props = { - value: ProxyLocation | null; + value: ProxyLocation; onChange: (value: ProxyLocation) => void; className?: string; }; function ProxySelector({ value, onChange, className }: Props) { + // Convert input (string enum or object) to GeoTarget for the selector + const geoTargetValue = proxyLocationToGeoTarget(value); + return ( - + { + // Convert back to ProxyLocation enum if possible (for simple countries) + // or keep as GeoTarget object + const newValue = geoTargetToProxyLocationInput(newTarget); + onChange(newValue); + }} + /> ); } diff --git a/skyvern-frontend/src/routes/browserSessions/BrowserSessions.tsx b/skyvern-frontend/src/routes/browserSessions/BrowserSessions.tsx index 395d07d9..c323694d 100644 --- a/skyvern-frontend/src/routes/browserSessions/BrowserSessions.tsx +++ b/skyvern-frontend/src/routes/browserSessions/BrowserSessions.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { ProxyLocation } from "@/api/types"; + import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { diff --git a/skyvern-frontend/src/routes/browserSessions/hooks/useCreateBrowserSessionMutation.ts b/skyvern-frontend/src/routes/browserSessions/hooks/useCreateBrowserSessionMutation.ts index 610d6bb1..9d26d42b 100644 --- a/skyvern-frontend/src/routes/browserSessions/hooks/useCreateBrowserSessionMutation.ts +++ b/skyvern-frontend/src/routes/browserSessions/hooks/useCreateBrowserSessionMutation.ts @@ -5,6 +5,7 @@ import { useMutation } from "@tanstack/react-query"; import { getClient } from "@/api/AxiosClient"; import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import { BrowserSession } from "@/routes/workflows/types/browserSessionTypes"; +import { ProxyLocation } from "@/api/types"; function useCreateBrowserSessionMutation() { const queryClient = useQueryClient(); @@ -16,7 +17,7 @@ function useCreateBrowserSessionMutation() { proxyLocation = null, timeout = null, }: { - proxyLocation: string | null; + proxyLocation: ProxyLocation | null; timeout: number | null; }) => { const client = await getClient(credentialGetter, "sans-api-v1"); diff --git a/skyvern-frontend/src/routes/tasks/create/taskFormTypes.ts b/skyvern-frontend/src/routes/tasks/create/taskFormTypes.ts index 2c6e45ca..3c434a4e 100644 --- a/skyvern-frontend/src/routes/tasks/create/taskFormTypes.ts +++ b/skyvern-frontend/src/routes/tasks/create/taskFormTypes.ts @@ -15,7 +15,16 @@ const createNewTaskFormSchemaBase = z.object({ totpIdentifier: z.string().or(z.null()), cdpAddress: z.string().or(z.null()), errorCodeMapping: z.string().or(z.null()), - proxyLocation: z.nativeEnum(ProxyLocation).or(z.null()), + proxyLocation: z + .union([ + z.nativeEnum(ProxyLocation), + z.object({ + country: z.string(), + subdivision: z.string().optional(), + city: z.string().optional(), + }), + ]) + .nullable(), includeActionHistoryInVerification: z.boolean().or(z.null()).default(false), maxScreenshotScrolls: z.number().or(z.null()).default(null), }); diff --git a/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx b/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx index 84112162..273fe3f1 100644 --- a/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx +++ b/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx @@ -244,7 +244,7 @@ function RunWorkflowForm({ defaultValues: { ...initialValues, webhookCallbackUrl: initialSettings.webhookCallbackUrl, - proxyLocation: initialSettings.proxyLocation, + proxyLocation: initialSettings.proxyLocation ?? ProxyLocation.Residential, browserSessionId: null, cdpAddress: initialSettings.cdpAddress, maxScreenshotScrolls: initialSettings.maxScreenshotScrolls, @@ -342,7 +342,7 @@ function RunWorkflowForm({ form.reset({ ...initialValues, webhookCallbackUrl: initialSettings.webhookCallbackUrl, - proxyLocation: initialSettings.proxyLocation, + proxyLocation: initialSettings.proxyLocation ?? ProxyLocation.Residential, browserSessionId: null, cdpAddress: initialSettings.cdpAddress, maxScreenshotScrolls: initialSettings.maxScreenshotScrolls, diff --git a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts index 457f630a..2e6ab06a 100644 --- a/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/workflowYamlTypes.ts @@ -1,11 +1,11 @@ -import { RunEngine } from "@/api/types"; +import { ProxyLocation, RunEngine } from "@/api/types"; import { WorkflowBlockType } from "./workflowTypes"; import { WorkflowModel } from "./workflowTypes"; export type WorkflowCreateYAMLRequest = { title: string; description?: string | null; - proxy_location?: string | null; + proxy_location?: ProxyLocation | null; webhook_callback_url?: string | null; persist_browser_session?: boolean; model?: WorkflowModel | null; diff --git a/skyvern-frontend/src/util/geoData.ts b/skyvern-frontend/src/util/geoData.ts new file mode 100644 index 00000000..27f2b875 --- /dev/null +++ b/skyvern-frontend/src/util/geoData.ts @@ -0,0 +1,202 @@ +import { GeoTarget, ProxyLocation } from "@/api/types"; + +export const SUPPORTED_COUNTRY_CODES = [ + "US", + "AR", + "AU", + "BR", + "CA", + "DE", + "ES", + "FR", + "GB", + "IE", + "IN", + "IT", + "JP", + "MX", + "NL", + "NZ", + "TR", + "ZA", +] as const; + +export type SupportedCountryCode = (typeof SUPPORTED_COUNTRY_CODES)[number]; + +export const COUNTRY_NAMES: Record = { + US: "United States", + AR: "Argentina", + AU: "Australia", + BR: "Brazil", + CA: "Canada", + DE: "Germany", + ES: "Spain", + FR: "France", + GB: "United Kingdom", + IE: "Ireland", + IN: "India", + IT: "Italy", + JP: "Japan", + MX: "Mexico", + NL: "Netherlands", + NZ: "New Zealand", + TR: "Turkey", + ZA: "South Africa", +}; + +export const COUNTRY_FLAGS: Record = { + US: "🇺🇸", + AR: "🇦🇷", + AU: "🇦🇺", + BR: "🇧🇷", + CA: "🇨🇦", + DE: "🇩🇪", + ES: "🇪🇸", + FR: "🇫🇷", + GB: "🇬🇧", + IE: "🇮🇪", + IN: "🇮🇳", + IT: "🇮🇹", + JP: "🇯🇵", + MX: "🇲🇽", + NL: "🇳🇱", + NZ: "🇳🇿", + TR: "🇹🇷", + ZA: "🇿🇦", +}; + +// Map legacy ProxyLocation to Country Code +const PROXY_LOCATION_TO_COUNTRY: Record = { + [ProxyLocation.Residential]: "US", + [ProxyLocation.ResidentialISP]: "US", + [ProxyLocation.ResidentialAR]: "AR", + [ProxyLocation.ResidentialAU]: "AU", + [ProxyLocation.ResidentialBR]: "BR", + [ProxyLocation.ResidentialCA]: "CA", + [ProxyLocation.ResidentialDE]: "DE", + [ProxyLocation.ResidentialES]: "ES", + [ProxyLocation.ResidentialFR]: "FR", + [ProxyLocation.ResidentialGB]: "GB", + [ProxyLocation.ResidentialIE]: "IE", + [ProxyLocation.ResidentialIN]: "IN", + [ProxyLocation.ResidentialIT]: "IT", + [ProxyLocation.ResidentialJP]: "JP", + [ProxyLocation.ResidentialMX]: "MX", + [ProxyLocation.ResidentialNL]: "NL", + [ProxyLocation.ResidentialNZ]: "NZ", + [ProxyLocation.ResidentialTR]: "TR", + [ProxyLocation.ResidentialZA]: "ZA", +}; + +// Reverse map for round-tripping simple country selections +const COUNTRY_TO_PROXY_LOCATION: Record = { + US: ProxyLocation.Residential, + AR: ProxyLocation.ResidentialAR, + AU: ProxyLocation.ResidentialAU, + BR: ProxyLocation.ResidentialBR, + CA: ProxyLocation.ResidentialCA, + DE: ProxyLocation.ResidentialDE, + ES: ProxyLocation.ResidentialES, + FR: ProxyLocation.ResidentialFR, + GB: ProxyLocation.ResidentialGB, + IE: ProxyLocation.ResidentialIE, + IN: ProxyLocation.ResidentialIN, + IT: ProxyLocation.ResidentialIT, + JP: ProxyLocation.ResidentialJP, + MX: ProxyLocation.ResidentialMX, + NL: ProxyLocation.ResidentialNL, + NZ: ProxyLocation.ResidentialNZ, + TR: ProxyLocation.ResidentialTR, + ZA: ProxyLocation.ResidentialZA, +}; + +export function proxyLocationToGeoTarget( + input: ProxyLocation, +): GeoTarget | null { + if (!input) return null; + + // If it's already a GeoTarget object + if (typeof input === "object" && "country" in input) { + return input; + } + + // If it's a legacy string + if (typeof input === "string") { + if (input === ProxyLocation.None) return null; + if (input === ProxyLocation.ResidentialISP) { + return { country: "US", isISP: true }; + } + const country = PROXY_LOCATION_TO_COUNTRY[input]; + if (country) { + return { country }; + } + } + + return null; +} + +export function geoTargetToProxyLocationInput( + target: GeoTarget | null, +): ProxyLocation { + if (!target) return ProxyLocation.None; + + if (target.isISP) { + return ProxyLocation.ResidentialISP; + } + + // Try to map back to legacy enum if it's just a country + if (target.country && !target.subdivision && !target.city) { + const legacyLocation = COUNTRY_TO_PROXY_LOCATION[target.country]; + if (legacyLocation) { + return legacyLocation; + } + } + + // Otherwise return the object + return target; +} + +export function formatGeoTarget(target: GeoTarget | null): string { + if (!target || !target.country) return "No Proxy"; + + const parts = []; + + // Add Flag + if (target.country in COUNTRY_FLAGS) { + parts.push(COUNTRY_FLAGS[target.country as SupportedCountryCode]); + } + + if (target.city) parts.push(target.city); + if (target.subdivision) parts.push(target.subdivision); + + // Country Name + const countryName = + COUNTRY_NAMES[target.country as SupportedCountryCode] || target.country; + if (target.isISP) { + parts.push(`${countryName} (ISP)`); + } else { + parts.push(countryName); + } + + return parts.join(" "); +} + +export function formatGeoTargetCompact(target: GeoTarget | null): string { + if (!target || !target.country) return "No Proxy"; + + const parts = []; + if (target.city) parts.push(target.city); + if (target.subdivision) parts.push(target.subdivision); + const countryName = + COUNTRY_NAMES[target.country as SupportedCountryCode] || target.country; + if (target.isISP) { + parts.push(`${countryName} (ISP)`); + } else { + parts.push(countryName); + } + + const text = parts.join(", "); + const flag = COUNTRY_FLAGS[target.country as SupportedCountryCode] || ""; + + return `${flag} ${text}`.trim(); +} diff --git a/skyvern-frontend/src/util/geoSearch.ts b/skyvern-frontend/src/util/geoSearch.ts new file mode 100644 index 00000000..5c0e3f1f --- /dev/null +++ b/skyvern-frontend/src/util/geoSearch.ts @@ -0,0 +1,174 @@ +import { GeoTarget } from "@/api/types"; +import { + COUNTRY_FLAGS, + COUNTRY_NAMES, + SUPPORTED_COUNTRY_CODES, + SupportedCountryCode, +} from "./geoData"; + +export type SearchResultItem = { + type: "country" | "subdivision" | "city"; + label: string; + value: GeoTarget; + description?: string; + icon?: string; +}; + +export type GroupedSearchResults = { + countries: SearchResultItem[]; + subdivisions: SearchResultItem[]; + cities: SearchResultItem[]; +}; + +let cscModule: typeof import("country-state-city") | null = null; + +async function loadCsc() { + if (!cscModule) { + cscModule = await import("country-state-city"); + } + return cscModule; +} + +export async function searchGeoData( + query: string, +): Promise { + const normalizedQuery = query.trim().toLowerCase(); + const queryMatchesISP = normalizedQuery.includes("isp"); + const results: GroupedSearchResults = { + countries: [], + subdivisions: [], + cities: [], + }; + + // 1. Countries (Always search supported countries) + // We can do this without loading the heavy lib if we use our hardcoded lists + SUPPORTED_COUNTRY_CODES.forEach((code) => { + const name = COUNTRY_NAMES[code]; + const matchesCountry = + name.toLowerCase().includes(normalizedQuery) || + code.toLowerCase().includes(normalizedQuery); + const shouldIncludeUSForISP = code === "US" && queryMatchesISP; + if (matchesCountry || shouldIncludeUSForISP) { + results.countries.push({ + type: "country", + label: name, + value: { country: code }, + description: code, + icon: COUNTRY_FLAGS[code], + }); + } + }); + + if (results.countries.length > 0) { + const usIndex = results.countries.findIndex( + (item) => item.value.country === "US" && !item.value.isISP, + ); + if (usIndex !== -1 || queryMatchesISP) { + const ispItem: SearchResultItem = { + type: "country", + label: "United States (ISP)", + value: { country: "US", isISP: true }, + description: "US", + icon: COUNTRY_FLAGS.US, + }; + const insertIndex = usIndex !== -1 ? usIndex + 1 : 0; + results.countries.splice(insertIndex, 0, ispItem); + } + } + + // If query is very short, just return countries to save perf + if (normalizedQuery.length < 2) { + return results; + } + + // 2. Subdivisions & Cities (Load heavy lib) + const csc = await loadCsc(); + + // Search Subdivisions + // We only search subdivisions of SUPPORTED countries + for (const countryCode of SUPPORTED_COUNTRY_CODES) { + const states = csc.State.getStatesOfCountry(countryCode); + for (const state of states) { + if ( + state.name.toLowerCase().includes(normalizedQuery) || + state.isoCode.toLowerCase() === normalizedQuery + ) { + results.subdivisions.push({ + type: "subdivision", + label: state.name, + value: { country: countryCode, subdivision: state.isoCode }, + description: `${state.isoCode}, ${COUNTRY_NAMES[countryCode]}`, + icon: COUNTRY_FLAGS[countryCode], + }); + } + + // Limit subdivisions per country to avoid overwhelming + if (results.subdivisions.length >= 10) break; + } + } + + // Search Cities + // Searching ALL cities of ALL supported countries is heavy. + // We optimize by breaking early once we have enough results. + const prefixMatches: SearchResultItem[] = []; + const partialMatches: SearchResultItem[] = []; + + for (const countryCode of SUPPORTED_COUNTRY_CODES) { + const cities = csc.City.getCitiesOfCountry(countryCode) || []; + + for (const city of cities) { + const nameLower = city.name.toLowerCase(); + const item: SearchResultItem = { + type: "city", + label: city.name, + value: { + country: countryCode, + subdivision: city.stateCode, + city: city.name, + }, + description: `${city.stateCode}, ${COUNTRY_NAMES[countryCode]}`, + icon: COUNTRY_FLAGS[countryCode], + }; + + if (nameLower === normalizedQuery) { + // Exact match goes to the front + prefixMatches.unshift(item); + } else if (nameLower.startsWith(normalizedQuery)) { + prefixMatches.push(item); + } else if (nameLower.includes(normalizedQuery)) { + partialMatches.push(item); + } + + // Break if we have enough total cities + if (prefixMatches.length + partialMatches.length > 100) break; + } + if (prefixMatches.length + partialMatches.length > 100) break; + } + + results.cities = [...prefixMatches, ...partialMatches]; + + // Slice to final limits + results.subdivisions = results.subdivisions.slice(0, 5); + results.cities = results.cities.slice(0, 20); + + return results; +} + +export async function getCountryName(code: string): Promise { + if (code in COUNTRY_NAMES) { + return COUNTRY_NAMES[code as SupportedCountryCode]; + } + const csc = await loadCsc(); + return csc.Country.getCountryByCode(code)?.name || code; +} + +export async function getSubdivisionName( + countryCode: string, + subdivisionCode: string, +): Promise { + const csc = await loadCsc(); + return ( + csc.State.getStateByCodeAndCountry(subdivisionCode, countryCode)?.name || + subdivisionCode + ); +}