feat: add IP-based geolocation fallback for location tool
- Add /api/v1/location endpoint using ip-api.com for IP geolocation - Update get_location tool to try browser GPS first, then IP fallback - Make tool description directive to trigger automatic usage - Handle private IPs by letting ip-api.com auto-detect The tool chain now works: get_location → web_search → fetch_url Browser geolocation often fails on desktop Linux; IP fallback provides city-level accuracy which is sufficient for weather queries. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
171
backend/internal/api/geolocation.go
Normal file
171
backend/internal/api/geolocation.go
Normal file
@@ -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",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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<LocationResult | null> {
|
||||
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<GetLocationArgs> = 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<GeolocationPosition>((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<GeolocationPosition>((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.'
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user