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:
2025-12-31 20:04:09 +01:00
parent 6013191376
commit 26b4f342fc
6 changed files with 454 additions and 4 deletions

View File

@@ -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(
{

View File

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

View File

@@ -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()));

View File

@@ -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(