diff --git a/backend/internal/api/geolocation.go b/backend/internal/api/geolocation.go new file mode 100644 index 0000000..b325b9c --- /dev/null +++ b/backend/internal/api/geolocation.go @@ -0,0 +1,171 @@ +package api + +import ( + "encoding/json" + "fmt" + "net" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +// IPGeoResponse represents the response from ip-api.com +type IPGeoResponse struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` + Country string `json:"country"` + CountryCode string `json:"countryCode"` + Region string `json:"region"` + RegionName string `json:"regionName"` + City string `json:"city"` + Zip string `json:"zip"` + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + Timezone string `json:"timezone"` + ISP string `json:"isp"` + Query string `json:"query"` // The IP that was looked up +} + +// LocationResponse is what we return to the frontend +type LocationResponse struct { + Success bool `json:"success"` + City string `json:"city,omitempty"` + Region string `json:"region,omitempty"` + Country string `json:"country,omitempty"` + CountryCode string `json:"countryCode,omitempty"` + Latitude float64 `json:"latitude,omitempty"` + Longitude float64 `json:"longitude,omitempty"` + Timezone string `json:"timezone,omitempty"` + IP string `json:"ip,omitempty"` + Error string `json:"error,omitempty"` + Source string `json:"source"` // "ip" to indicate this is IP-based +} + +// getClientIP extracts the real client IP, handling proxies +func getClientIP(c *gin.Context) string { + // Check X-Forwarded-For first (for reverse proxies) + xff := c.GetHeader("X-Forwarded-For") + if xff != "" { + // First IP is the original client + if i := strings.IndexByte(xff, ','); i >= 0 { + return strings.TrimSpace(xff[:i]) + } + return strings.TrimSpace(xff) + } + + // Check X-Real-IP (nginx style) + xri := c.GetHeader("X-Real-IP") + if xri != "" { + return strings.TrimSpace(xri) + } + + // Fallback to RemoteAddr + host, _, err := net.SplitHostPort(c.Request.RemoteAddr) + if err != nil { + return c.Request.RemoteAddr + } + return host +} + +// isPrivateIP checks if an IP is private/localhost +func isPrivateIP(ip string) bool { + parsed := net.ParseIP(ip) + if parsed == nil { + return false + } + + // Check for localhost + if parsed.IsLoopback() { + return true + } + + // Check for private ranges + privateRanges := []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "fc00::/7", // IPv6 private + } + + for _, cidr := range privateRanges { + _, network, err := net.ParseCIDR(cidr) + if err != nil { + continue + } + if network.Contains(parsed) { + return true + } + } + + return false +} + +// IPGeolocationHandler returns location based on client IP +func IPGeolocationHandler() gin.HandlerFunc { + return func(c *gin.Context) { + clientIP := getClientIP(c) + + // If running locally, we can't geolocate private IPs + // ip-api.com will use the server's public IP in this case + ipToLookup := clientIP + if isPrivateIP(clientIP) { + // Let ip-api.com auto-detect from the request + ipToLookup = "" + } + + // Build the URL - ip-api.com is free for non-commercial use (45 req/min) + // Using HTTP because HTTPS requires paid plan + url := "http://ip-api.com/json/" + if ipToLookup != "" { + url += ipToLookup + } + + // Make the request + httpClient := &http.Client{Timeout: 10 * time.Second} + resp, err := httpClient.Get(url) + if err != nil { + c.JSON(http.StatusServiceUnavailable, LocationResponse{ + Success: false, + Error: "Failed to reach geolocation service", + Source: "ip", + }) + return + } + defer resp.Body.Close() + + var geoResp IPGeoResponse + if err := json.NewDecoder(resp.Body).Decode(&geoResp); err != nil { + c.JSON(http.StatusInternalServerError, LocationResponse{ + Success: false, + Error: "Failed to parse geolocation response", + Source: "ip", + }) + return + } + + // Check if ip-api returned an error + if geoResp.Status != "success" { + c.JSON(http.StatusOK, LocationResponse{ + Success: false, + Error: fmt.Sprintf("Geolocation failed: %s", geoResp.Message), + Source: "ip", + }) + return + } + + c.JSON(http.StatusOK, LocationResponse{ + Success: true, + City: geoResp.City, + Region: geoResp.RegionName, + Country: geoResp.Country, + CountryCode: geoResp.CountryCode, + Latitude: geoResp.Lat, + Longitude: geoResp.Lon, + Timezone: geoResp.Timezone, + IP: geoResp.Query, + Source: "ip", + }) + } +} diff --git a/backend/internal/api/routes.go b/backend/internal/api/routes.go index 0ba5e71..0d7900e 100644 --- a/backend/internal/api/routes.go +++ b/backend/internal/api/routes.go @@ -42,6 +42,9 @@ func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string) { // Web search proxy (for web_search tool) v1.POST("/proxy/search", WebSearchProxyHandler()) + // IP-based geolocation (fallback when browser geolocation fails) + v1.GET("/location", IPGeolocationHandler()) + // Ollama proxy (optional) v1.Any("/ollama/*path", OllamaProxyHandler(ollamaURL)) } diff --git a/frontend/src/lib/tools/builtin.ts b/frontend/src/lib/tools/builtin.ts index 8817e6d..ede77e7 100644 --- a/frontend/src/lib/tools/builtin.ts +++ b/frontend/src/lib/tools/builtin.ts @@ -459,7 +459,7 @@ const getLocationDefinition: ToolDefinition = { type: 'function', function: { name: 'get_location', - description: 'Gets the user\'s current city and country from their device GPS. Returns location that can be used with web_search. If this fails, ask user for their location.', + description: 'ALWAYS call this first when user asks about weather, local news, nearby places, or anything location-dependent WITHOUT specifying a city. Automatically detects user\'s city via GPS or IP. Only ask user for location if this tool returns an error.', parameters: { type: 'object', properties: { @@ -472,82 +472,101 @@ const getLocationDefinition: ToolDefinition = { } }; +/** + * Try IP-based geolocation via backend as fallback + */ +async function tryIPGeolocation(): Promise { + try { + const response = await fetch('/api/v1/location'); + if (!response.ok) return null; + + const data = await response.json(); + if (!data.success) return null; + + return { + latitude: data.latitude, + longitude: data.longitude, + accuracy: -1, // IP geolocation has no accuracy metric + city: data.city, + country: data.country + }; + } catch { + return null; + } +} + const getLocationHandler: BuiltinToolHandler = async (args) => { const { highAccuracy = false } = args; - // Check if geolocation is available - if (!navigator.geolocation) { - return { error: 'Geolocation is not supported by this browser' }; - } - - try { - const position = await new Promise((resolve, reject) => { - navigator.geolocation.getCurrentPosition(resolve, reject, { - enableHighAccuracy: highAccuracy, - timeout: 30000, // 30 seconds - user needs time to accept permission prompt - maximumAge: 300000 // Cache for 5 minutes - }); - }); - - const result: LocationResult = { - latitude: position.coords.latitude, - longitude: position.coords.longitude, - accuracy: Math.round(position.coords.accuracy) - }; - - // Try to get city/country via reverse geocoding (using a free service) + // Try browser geolocation first (most accurate) + if (navigator.geolocation) { try { - const geoResponse = await fetch( - `https://nominatim.openstreetmap.org/reverse?lat=${result.latitude}&lon=${result.longitude}&format=json`, - { - headers: { - 'User-Agent': 'OllamaWebUI/1.0' + const position = await new Promise((resolve, reject) => { + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: highAccuracy, + timeout: 15000, // 15 seconds for browser geolocation + maximumAge: 300000 // Cache for 5 minutes + }); + }); + + const result: LocationResult = { + latitude: position.coords.latitude, + longitude: position.coords.longitude, + accuracy: Math.round(position.coords.accuracy) + }; + + // Try to get city/country via reverse geocoding (using a free service) + try { + const geoResponse = await fetch( + `https://nominatim.openstreetmap.org/reverse?lat=${result.latitude}&lon=${result.longitude}&format=json`, + { + headers: { + 'User-Agent': 'OllamaWebUI/1.0' + } + } + ); + + if (geoResponse.ok) { + const geoData = await geoResponse.json(); + if (geoData.address) { + result.city = geoData.address.city || geoData.address.town || geoData.address.village || geoData.address.municipality; + result.country = geoData.address.country; } } - ); - - if (geoResponse.ok) { - const geoData = await geoResponse.json(); - if (geoData.address) { - result.city = geoData.address.city || geoData.address.town || geoData.address.village || geoData.address.municipality; - result.country = geoData.address.country; - } + } catch { + // Reverse geocoding failed, but we still have coordinates } + + return { + location: result, + source: 'gps', + message: result.city + ? `User is located in ${result.city}${result.country ? ', ' + result.country : ''}` + : `User is at coordinates ${result.latitude.toFixed(4)}, ${result.longitude.toFixed(4)}` + }; } catch { - // Reverse geocoding failed, but we still have coordinates + // Browser geolocation failed, try IP fallback + console.log('[get_location] Browser geolocation failed, trying IP fallback...'); } + } + // Fallback: IP-based geolocation via backend + const ipResult = await tryIPGeolocation(); + if (ipResult) { return { - location: result, - message: result.city - ? `User is located in ${result.city}${result.country ? ', ' + result.country : ''}` - : `User is at coordinates ${result.latitude.toFixed(4)}, ${result.longitude.toFixed(4)}` - }; - } catch (error) { - if (error instanceof GeolocationPositionError) { - switch (error.code) { - case error.PERMISSION_DENIED: - return { - error: 'Location permission denied', - suggestion: 'Ask the user for their city/location directly, then use web_search with that location.' - }; - case error.POSITION_UNAVAILABLE: - return { - error: 'Location services unavailable on this device', - suggestion: 'Ask the user for their city/location directly, then use web_search with that location.' - }; - case error.TIMEOUT: - return { - error: 'Location request timed out', - suggestion: 'Ask the user for their city/location directly, then use web_search with that location.' - }; - } - } - return { - error: `Failed to get location: ${error instanceof Error ? error.message : 'Unknown error'}`, - suggestion: 'Ask the user for their city/location directly, then use web_search with that location.' + location: ipResult, + source: 'ip', + message: ipResult.city + ? `User is approximately located in ${ipResult.city}${ipResult.country ? ', ' + ipResult.country : ''} (based on IP address)` + : `Could not determine city from IP address` }; } + + // Both methods failed + return { + error: 'Could not determine location (browser geolocation unavailable and backend not reachable)', + suggestion: 'Ask the user for their city/location directly, then use web_search with that location.' + }; }; // ============================================================================