feat: add web search and location tools
- Add web_search built-in tool that searches via DuckDuckGo - Add get_location tool to get user's geographic location - Create backend search proxy endpoint (/api/v1/proxy/search) - DuckDuckGo HTML scraping with title, URL, and snippet extraction - Geolocation with OpenStreetMap reverse geocoding for city/country - Fix StreamingIndicator visibility in dark mode - Improve tool descriptions to encourage proper tool usage - Better error messages with suggestions when location fails 🤖 Generated with [Claude Code](https://claude.ai/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,9 @@ func SetupRoutes(r *gin.Engine, db *sql.DB, ollamaURL string) {
|
||||
// URL fetch proxy (for tools that need to fetch external URLs)
|
||||
v1.POST("/proxy/fetch", URLFetchProxyHandler())
|
||||
|
||||
// Web search proxy (for web_search tool)
|
||||
v1.POST("/proxy/search", WebSearchProxyHandler())
|
||||
|
||||
// Ollama proxy (optional)
|
||||
v1.Any("/ollama/*path", OllamaProxyHandler(ollamaURL))
|
||||
}
|
||||
|
||||
234
backend/internal/api/search.go
Normal file
234
backend/internal/api/search.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// SearchRequest represents a web search request
|
||||
type SearchRequest struct {
|
||||
Query string `json:"query" binding:"required"`
|
||||
MaxResults int `json:"maxResults"`
|
||||
}
|
||||
|
||||
// SearchResult represents a single search result
|
||||
type SearchResult struct {
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Snippet string `json:"snippet"`
|
||||
}
|
||||
|
||||
// WebSearchProxyHandler returns a handler that performs web searches via DuckDuckGo
|
||||
func WebSearchProxyHandler() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var req SearchRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set default and max results
|
||||
maxResults := req.MaxResults
|
||||
if maxResults <= 0 {
|
||||
maxResults = 5
|
||||
}
|
||||
if maxResults > 10 {
|
||||
maxResults = 10
|
||||
}
|
||||
|
||||
// Build DuckDuckGo HTML search URL
|
||||
searchURL := fmt.Sprintf("https://html.duckduckgo.com/html/?q=%s", url.QueryEscape(req.Query))
|
||||
|
||||
// Create HTTP client with timeout
|
||||
client := &http.Client{
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
|
||||
// Create request
|
||||
httpReq, err := http.NewRequestWithContext(c.Request.Context(), "GET", searchURL, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create request: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Set headers to mimic a browser
|
||||
httpReq.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
httpReq.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
|
||||
httpReq.Header.Set("Accept-Language", "en-US,en;q=0.5")
|
||||
|
||||
// Execute request
|
||||
resp, err := client.Do(httpReq)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to perform search: " + err.Error()})
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check status
|
||||
if resp.StatusCode >= 400 {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": "search failed: HTTP " + resp.Status})
|
||||
return
|
||||
}
|
||||
|
||||
// Read response body
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 500000)) // 500KB limit
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read response: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse results from HTML
|
||||
results := parseDuckDuckGoResults(string(body), maxResults)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"query": req.Query,
|
||||
"results": results,
|
||||
"count": len(results),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// parseDuckDuckGoResults extracts search results from DuckDuckGo HTML
|
||||
func parseDuckDuckGoResults(html string, maxResults int) []SearchResult {
|
||||
var results []SearchResult
|
||||
|
||||
// DuckDuckGo HTML result structure:
|
||||
// <div class="result results_links results_links_deep web-result">
|
||||
// <a class="result__a" href="...">Title</a>
|
||||
// <a class="result__snippet">Snippet text...</a>
|
||||
// </div>
|
||||
|
||||
// Match each result block (more permissive pattern)
|
||||
resultPattern := regexp.MustCompile(`(?s)<div[^>]*class="[^"]*results_links[^"]*"[^>]*>(.*?)</div>\s*</div>`)
|
||||
|
||||
// Patterns for extracting components
|
||||
titleURLPattern := regexp.MustCompile(`(?s)<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([^<]+)</a>`)
|
||||
snippetPattern := regexp.MustCompile(`(?s)<a[^>]*class="result__snippet"[^>]*>(.*?)</a>`)
|
||||
|
||||
resultBlocks := resultPattern.FindAllStringSubmatch(html, maxResults*3)
|
||||
|
||||
for _, match := range resultBlocks {
|
||||
if len(results) >= maxResults {
|
||||
break
|
||||
}
|
||||
if len(match) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
block := match[1]
|
||||
var result SearchResult
|
||||
|
||||
// Extract title and URL
|
||||
titleMatch := titleURLPattern.FindStringSubmatch(block)
|
||||
if len(titleMatch) >= 3 {
|
||||
result.URL = decodeURL(titleMatch[1])
|
||||
result.Title = cleanHTML(titleMatch[2])
|
||||
}
|
||||
|
||||
// Extract snippet (can contain HTML like <b> tags)
|
||||
snippetMatch := snippetPattern.FindStringSubmatch(block)
|
||||
if len(snippetMatch) >= 2 {
|
||||
result.Snippet = cleanHTML(snippetMatch[1])
|
||||
}
|
||||
|
||||
// Only add if we have a title and URL
|
||||
if result.Title != "" && result.URL != "" {
|
||||
// Skip DuckDuckGo internal links
|
||||
if strings.Contains(result.URL, "duckduckgo.com") {
|
||||
continue
|
||||
}
|
||||
results = append(results, result)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try a simpler pattern if no results found
|
||||
if len(results) == 0 {
|
||||
results = parseSimpleDuckDuckGo(html, maxResults)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// parseSimpleDuckDuckGo is a fallback parser using simpler patterns
|
||||
func parseSimpleDuckDuckGo(html string, maxResults int) []SearchResult {
|
||||
var results []SearchResult
|
||||
|
||||
// Look for result__a links (main result titles)
|
||||
pattern := regexp.MustCompile(`(?s)<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>([^<]*)</a>`)
|
||||
matches := pattern.FindAllStringSubmatch(html, maxResults*2)
|
||||
|
||||
for _, match := range matches {
|
||||
if len(results) >= maxResults {
|
||||
break
|
||||
}
|
||||
|
||||
if len(match) >= 3 {
|
||||
url := decodeURL(match[1])
|
||||
title := cleanHTML(match[2])
|
||||
|
||||
// Skip empty or DuckDuckGo internal
|
||||
if url == "" || title == "" || strings.Contains(url, "duckduckgo.com") {
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, SearchResult{
|
||||
Title: title,
|
||||
URL: url,
|
||||
Snippet: "", // Snippet extraction is more complex
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// decodeURL extracts the actual URL from DuckDuckGo's redirect URL
|
||||
func decodeURL(ddgURL string) string {
|
||||
// DuckDuckGo wraps URLs in redirect links like:
|
||||
// //duckduckgo.com/l/?uddg=https%3A%2F%2Fexample.com&...
|
||||
if strings.Contains(ddgURL, "uddg=") {
|
||||
parsed, err := url.Parse(ddgURL)
|
||||
if err == nil {
|
||||
uddg := parsed.Query().Get("uddg")
|
||||
if uddg != "" {
|
||||
return uddg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sometimes URLs start with // (protocol-relative)
|
||||
if strings.HasPrefix(ddgURL, "//") {
|
||||
return "https:" + ddgURL
|
||||
}
|
||||
|
||||
return ddgURL
|
||||
}
|
||||
|
||||
// cleanHTML removes HTML tags and decodes entities
|
||||
func cleanHTML(s string) string {
|
||||
// Remove HTML tags
|
||||
tagPattern := regexp.MustCompile(`<[^>]*>`)
|
||||
s = tagPattern.ReplaceAllString(s, "")
|
||||
|
||||
// Decode common HTML entities
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, """, "\"")
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
s = strings.ReplaceAll(s, " ", " ")
|
||||
|
||||
// Clean up whitespace
|
||||
s = strings.TrimSpace(s)
|
||||
spacePattern := regexp.MustCompile(`\s+`)
|
||||
s = spacePattern.ReplaceAllString(s, " ")
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -312,9 +312,9 @@
|
||||
// Debug logging
|
||||
console.log('[Chat] Tools enabled:', toolsState.toolsEnabled);
|
||||
console.log('[Chat] Tools count:', tools?.length ?? 0);
|
||||
console.log('[Chat] Tool names:', tools?.map(t => t.function.name) ?? []);
|
||||
console.log('[Chat] USE_FUNCTION_MODEL:', USE_FUNCTION_MODEL);
|
||||
console.log('[Chat] Using model:', chatModel, '(original:', model, ')');
|
||||
if (tools?.length) console.log('[Chat] Tool definitions:', tools);
|
||||
|
||||
await ollamaClient.streamChatWithCallbacks(
|
||||
{
|
||||
|
||||
@@ -29,15 +29,15 @@
|
||||
aria-label="Generating response"
|
||||
>
|
||||
<span
|
||||
class="animate-bounce rounded-full bg-current opacity-75 {sizeClasses}"
|
||||
class="animate-bounce rounded-full bg-slate-400 dark:bg-slate-500 {sizeClasses}"
|
||||
style="animation-delay: 0ms; animation-duration: 1s;"
|
||||
></span>
|
||||
<span
|
||||
class="animate-bounce rounded-full bg-current opacity-75 {sizeClasses}"
|
||||
class="animate-bounce rounded-full bg-slate-400 dark:bg-slate-500 {sizeClasses}"
|
||||
style="animation-delay: 150ms; animation-duration: 1s;"
|
||||
></span>
|
||||
<span
|
||||
class="animate-bounce rounded-full bg-current opacity-75 {sizeClasses}"
|
||||
class="animate-bounce rounded-full bg-slate-400 dark:bg-slate-500 {sizeClasses}"
|
||||
style="animation-delay: 300ms; animation-duration: 1s;"
|
||||
></span>
|
||||
<span class="sr-only">Generating response...</span>
|
||||
|
||||
@@ -439,6 +439,205 @@ const fetchUrlHandler: BuiltinToolHandler<FetchUrlArgs> = async (args) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Get Location Tool
|
||||
// ============================================================================
|
||||
|
||||
interface GetLocationArgs {
|
||||
highAccuracy?: boolean;
|
||||
}
|
||||
|
||||
interface LocationResult {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy: number;
|
||||
city?: string;
|
||||
country?: string;
|
||||
}
|
||||
|
||||
const getLocationDefinition: ToolDefinition = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'get_location',
|
||||
description: 'Get the user\'s current location (city, country, coordinates). Call this IMMEDIATELY when you need location for weather, local info, or nearby places. Do NOT ask the user where they are - use this tool instead.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
highAccuracy: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to request high accuracy GPS location (may take longer and use more battery). Default is false.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Reverse geocoding failed, but we still have coordinates
|
||||
}
|
||||
|
||||
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.'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Web Search Tool
|
||||
// ============================================================================
|
||||
|
||||
interface WebSearchArgs {
|
||||
query: string;
|
||||
maxResults?: number;
|
||||
}
|
||||
|
||||
interface WebSearchResult {
|
||||
title: string;
|
||||
url: string;
|
||||
snippet: string;
|
||||
}
|
||||
|
||||
const webSearchDefinition: ToolDefinition = {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'web_search',
|
||||
description: 'Search the web for current information. You MUST call this tool immediately when the user asks about weather, news, current events, sports, stocks, prices, or any real-time information. Do NOT ask the user for clarification - just search. If no location is specified for weather, call get_location first.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'The search query (e.g., "weather Berlin tomorrow", "latest news", "Bitcoin price")'
|
||||
},
|
||||
maxResults: {
|
||||
type: 'number',
|
||||
description: 'Maximum number of results to return (1-10, default 5)'
|
||||
}
|
||||
},
|
||||
required: ['query']
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const webSearchHandler: BuiltinToolHandler<WebSearchArgs> = async (args) => {
|
||||
const { query, maxResults = 5 } = args;
|
||||
|
||||
if (!query || query.trim() === '') {
|
||||
return { error: 'Search query is required' };
|
||||
}
|
||||
|
||||
// Try backend proxy first
|
||||
try {
|
||||
const proxyResponse = await fetch('/api/v1/proxy/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query, maxResults: Math.min(Math.max(1, maxResults), 10) })
|
||||
});
|
||||
|
||||
if (proxyResponse.ok) {
|
||||
const data = await proxyResponse.json();
|
||||
const results = data.results as WebSearchResult[];
|
||||
|
||||
if (results.length === 0) {
|
||||
return { message: 'No search results found for the query.', query };
|
||||
}
|
||||
|
||||
// Format results for the AI
|
||||
return {
|
||||
query,
|
||||
resultCount: results.length,
|
||||
results: results.map((r, i) => ({
|
||||
rank: i + 1,
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
snippet: r.snippet || '(no snippet available)'
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
// If proxy returns an error, extract it
|
||||
const errorData = await proxyResponse.json().catch(() => null);
|
||||
if (errorData?.error) {
|
||||
return { error: errorData.error };
|
||||
}
|
||||
} catch {
|
||||
// Proxy not available
|
||||
}
|
||||
|
||||
return {
|
||||
error: 'Web search is not available. Please start the backend server to enable web search functionality.',
|
||||
hint: 'Run the backend server with: cd backend && go run cmd/server/main.go'
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Registry of Built-in Tools
|
||||
// ============================================================================
|
||||
@@ -458,6 +657,16 @@ export const builtinTools: Map<string, ToolRegistryEntry> = new Map([
|
||||
definition: fetchUrlDefinition,
|
||||
handler: fetchUrlHandler as unknown as BuiltinToolHandler,
|
||||
isBuiltin: true
|
||||
}],
|
||||
['get_location', {
|
||||
definition: getLocationDefinition,
|
||||
handler: getLocationHandler as unknown as BuiltinToolHandler,
|
||||
isBuiltin: true
|
||||
}],
|
||||
['web_search', {
|
||||
definition: webSearchDefinition,
|
||||
handler: webSearchHandler as unknown as BuiltinToolHandler,
|
||||
isBuiltin: true
|
||||
}]
|
||||
]);
|
||||
|
||||
@@ -465,3 +674,6 @@ export const builtinTools: Map<string, ToolRegistryEntry> = new Map([
|
||||
export function getBuiltinToolDefinitions(): ToolDefinition[] {
|
||||
return Array.from(builtinTools.values()).map(entry => entry.definition);
|
||||
}
|
||||
|
||||
// Log available builtin tools at startup
|
||||
console.log('[Builtin Tools] Available:', Array.from(builtinTools.keys()));
|
||||
|
||||
@@ -156,6 +156,7 @@
|
||||
|
||||
console.log('[NewChat] Tools enabled:', toolsState.toolsEnabled);
|
||||
console.log('[NewChat] Tools count:', tools?.length ?? 0);
|
||||
console.log('[NewChat] Tool names:', tools?.map(t => t.function.name) ?? []);
|
||||
console.log('[NewChat] Using model:', chatModel, '(original:', model, ')');
|
||||
|
||||
await ollamaClient.streamChatWithCallbacks(
|
||||
|
||||
Reference in New Issue
Block a user