src / toolsProvider.ts
import { tool, Tool, ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import { configSchematics } from "./config";
interface SearXNGResult {
title: string;
url: string;
content: string;
engine: string;
score?: number;
}
interface SearXNGResponse {
query: string;
number_of_results: number;
results: SearXNGResult[];
}
export async function toolsProvider(ctl: ToolsProviderController): Promise<Tool[]> {
const tools: Tool[] = [];
const config = ctl.getPluginConfig(configSchematics);
const searxngUrl = config.get("searxngUrl") as string;
const defaultPageSize = config.get("defaultPageSize") as number;
const timeout = config.get("timeout") as number;
const searchTool = tool({
name: "search_web",
description: `Search the web using a local SearXNG instance.
Returns search results with titles, URLs, and snippets.
Use this when you need up-to-date information or specific facts not in your training data.`,
parameters: {
query: z.string().describe("The search query string"),
num_results: z.number().min(1).max(20).optional()
.describe(`Number of results to return (1-20). Default: ${defaultPageSize}`),
time_range: z.string().optional()
.describe("Optional time filter: 'day', 'week', 'month', or 'year'")
},
implementation: async (params: { query: string; num_results?: number; time_range?: string }) => {
try {
const { query, num_results, time_range } = params;
const pageSize = num_results ?? defaultPageSize;
// Build SearXNG API URL with JSON format
const searchParams = new URLSearchParams({
q: query,
format: "json",
pageno: "1",
safesearch: "0"
});
// Validate and add time range if specified
if (time_range) {
const validRanges = ["day", "week", "month", "year"];
if (validRanges.includes(time_range)) {
searchParams.append("time_range", time_range);
} else {
console.log(`Warning: Invalid time_range "${time_range}" provided. Ignoring.`);
}
}
const searchUrl = `${searxngUrl}/search?${searchParams.toString()}`;
console.log(`Querying SearXNG: ${searchUrl.replace(/format=json/, "format=...")}`);
// Execute search with timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeout);
const response = await fetch(searchUrl, {
method: "GET",
headers: {
"Accept": "application/json",
"User-Agent": "LM-Studio-Plugin/1.0"
},
signal: controller.signal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`SearXNG returned status ${response.status}: ${response.statusText}`);
}
const data: SearXNGResponse = await response.json();
if (!data.results || data.results.length === 0) {
return `No results found for query: "${query}"`;
}
// Format results for the LLM
const formattedResults = data.results
.slice(0, pageSize)
.map((result, index) => {
return `[${index + 1}] ${result.title}
URL: ${result.url}
Snippet: ${result.content.substring(0, 300)}${result.content.length > 300 ? '...' : ''}
Source: ${result.engine}`;
})
.join("\n\n");
return `Search results for "${query}" (${Math.min(data.results.length, pageSize)} of ${data.number_of_results} total):
${formattedResults}
Note: These results are from SearXNG metasearch engine aggregating multiple sources.`;
} catch (error) {
if (error instanceof Error) {
if (error.name === "AbortError") {
return `Error: SearXNG request timed out after ${timeout}ms. Please check if your SearXNG instance is running at ${searxngUrl}`;
}
return `Error searching SearXNG: ${error.message}. Please ensure your SearXNG instance is accessible at ${searxngUrl} and has JSON API enabled in settings.yml (search: formats: - json).`;
}
return `Unknown error occurred while searching.`;
}
}
});
const fetchPageTool = tool({
name: "fetch_page_content",
description: "Fetch and extract text content from a specific URL found in search results.",
parameters: {
url: z.string().url().describe("The URL to fetch content from"),
max_length: z.number().min(100).max(10000).optional()
.describe("Maximum characters to return (default: 2000)")
},
implementation: async (params: { url: string; max_length?: number }) => {
try {
const { url, max_length } = params;
const maxLength = max_length ?? 2000;
const response = await fetch(url, {
headers: {
"User-Agent": "Mozilla/5.0 (compatible; LM-Studio-Bot/1.0)"
}
});
if (!response.ok) {
return `Failed to fetch ${url}: ${response.statusText}`;
}
const html = await response.text();
// Simple HTML to text extraction
let text = html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
// Limit length
if (text.length > maxLength) {
text = text.substring(0, maxLength) + "... [truncated]";
}
return `Content from ${url}:\n\n${text}`;
} catch (error) {
return `Error fetching page: ${error instanceof Error ? error.message : String(error)}`;
}
}
});
tools.push(searchTool);
tools.push(fetchPageTool);
return tools;
}