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:
2025-12-31 21:04:31 +01:00
parent 940abb18e8
commit b99fc3d0c1
3 changed files with 257 additions and 64 deletions

View 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",
})
}
}

View File

@@ -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))
}

View File

@@ -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.'
};
};
// ============================================================================