src / webTools.ts
/**
* Web tools: Web Search, Image Search, Visit Website, View Images.
* Ported from danielsig/duckduckgo and danielsig/visit-website (pure fetch, no extra deps).
* Images are saved to ~/.lmstudio-toolkit/images/ so they are always accessible.
*/
import { tool, Tool, ToolCallContext } from "@lmstudio/sdk";
import { mkdir, writeFile } from "fs/promises";
import { homedir } from "os";
import { join } from "path";
import { z } from "zod";
const IMAGES_DIR = join(homedir(), ".lmstudio-toolkit", "images");
const SPOOF_AGENTS = [
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3.1 Safari/605.1.15",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36 Edg/134.0.0.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:97.0) Gecko/20100101 Firefox/97.0",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/97.0.4692.71 Safari/537.36",
];
function randAgent() {
return SPOOF_AGENTS[Math.floor(Math.random() * SPOOF_AGENTS.length)];
}
function spoofHeaders(refererDomain = "duckduckgo.com"): Record<string, string> {
return {
"User-Agent": randAgent(),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Accept-Encoding": "gzip, deflate, br",
"Referer": `https://${refererDomain}/`,
"Origin": `https://${refererDomain}`,
"Connection": "keep-alive",
"Cache-Control": "max-age=0",
};
}
// ββ rate-limit helper ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
let lastRequest = 0;
const MIN_GAP_MS = 2000;
function waitIfNeeded() {
const now = Date.now();
const gap = now - lastRequest;
lastRequest = now;
if (gap < MIN_GAP_MS) return new Promise<void>((r) => setTimeout(r, MIN_GAP_MS - gap));
return Promise.resolve();
}
// ββ HTML fetch βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
async function fetchHTML(url: string, signal: AbortSignal, warn: (m: string) => void) {
const res = await fetch(url, { method: "GET", signal, headers: spoofHeaders(new URL(url).hostname) });
if (!res.ok) { warn(`HTTP ${res.status}: ${res.statusText}`); throw new Error(`HTTP ${res.status}`); }
const html = await res.text();
const bodyMatch = html.match(/<body[^>]*>/);
const body = html.slice(bodyMatch ? bodyMatch.index! : 0, html.lastIndexOf("</body>") + 7 || undefined);
const headStart = html.indexOf("<head>");
const head = headStart >= 0 ? html.slice(headStart, html.indexOf("</head>") + 7) : "";
return { html, head, body };
}
// ββ link / image extraction ββββββββββββββββββββββββββββββββββββββββββββββββββ
function extractLinks(body: string, base: string, max: number, terms?: string[]): [string, string][] {
const results: { label: string; url: string; score: number }[] = [];
const re = /<a\s+[^>]*?href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(body))) {
const raw = m[1];
const href = raw.startsWith("/") ? new URL(raw, base).href : raw;
if (!href.startsWith("http")) continue;
if (results.some((r) => r.url === href)) continue;
const label = m[2].replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
let score = label.length - href.length / 10;
if (terms) score += terms.reduce((a, t) => a + (label.toLowerCase().includes(t.toLowerCase()) ? 500 : 0), 0);
results.push({ label, url: href, score });
}
return results.sort((a, b) => b.score - a.score).slice(0, max).map((r) => [r.label, r.url]);
}
function extractImages(body: string, base: string, max: number, terms?: string[]): [string, string][] {
const results: { alt: string; src: string; score: number }[] = [];
const re = /<img(\s[^>]*?)\/?>/g;
let m: RegExpExecArray | null;
while ((m = re.exec(body))) {
const attrs = m[1];
const srcMatch = attrs.match(/\ssrc="([^"]+)"/);
if (!srcMatch) continue;
const raw = srcMatch[1];
const src = raw.startsWith("/") ? new URL(raw, base).href : raw;
if (!src.startsWith("http") || !src.match(/\.(svg|png|webp|gif|jpe?g)(\?.*)?$/i)) continue;
const alt = attrs.match(/\salt="([^"]+)"/)?.[1] ?? "";
let score = alt.length;
if (terms) score += terms.reduce((a, t) => a + (alt.toLowerCase().includes(t.toLowerCase()) ? 500 : 0), 0);
results.push({ alt, src, score });
}
return results.sort((a, b) => b.score - a.score).slice(0, max).map((r) => [r.alt, r.src]);
}
// ββ image download helper ββββββββββββββββββββββββββββββββββββββββββββββββββββ
async function downloadImages(
urls: string[],
signal: AbortSignal,
warn: (m: string) => void,
): Promise<string[]> {
await mkdir(IMAGES_DIR, { recursive: true });
const ts = Date.now();
const results = await Promise.all(
urls.map(async (url, i) => {
try {
const res = await fetch(url, { signal, headers: spoofHeaders(new URL(url).hostname) });
if (!res.ok) { warn(`Image ${i + 1}: HTTP ${res.status}`); return null; }
const bytes = new Uint8Array(await res.arrayBuffer());
if (!bytes.length) { warn(`Image ${i + 1}: empty`); return null; }
const ext = /image\/([\w]+)/.exec(res.headers.get("content-type") ?? "")?.[1]
?? /\.([\w]+)(?:\?.*)?$/.exec(url)?.[1] ?? "jpg";
const name = `${ts}-${i + 1}.${ext}`;
const path = join(IMAGES_DIR, name);
await writeFile(path, bytes);
return path;
} catch (e: any) {
if (e instanceof DOMException && e.name === "AbortError") return null;
warn(`Image ${i + 1}: ${e.message}`);
return null;
}
}),
);
return results.map((p: string | null, i: number) => p ? `` : `Error downloading image ${i + 1}: ${urls[i]}`);
}
// ββ tool definitions βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
export function buildWebTools(): Tool[] {
// ββ WEB SEARCH βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const webSearch = tool({
name: "web_search",
description:
"Search the web using DuckDuckGo. Returns a list of [title, URL] pairs. " +
"Use visit_website to read the content of any result.",
parameters: {
query: z.string().min(1),
page_size: z.number().int().min(1).max(10).optional().describe("Results per page (default 7)."),
safe_search: z.enum(["strict", "moderate", "off"]).optional(),
page: z.number().int().min(1).max(100).optional().describe("Page number (default 1)."),
},
implementation: async ({ query, page_size, safe_search, page }, { status, warn, signal }: ToolCallContext) => {
status("Searching DuckDuckGoβ¦");
await waitIfNeeded();
page_size = page_size ?? 7;
safe_search = safe_search ?? "moderate";
page = page ?? 1;
const url = new URL("https://duckduckgo.com/html/");
url.searchParams.set("q", query);
if (safe_search !== "moderate") url.searchParams.set("p", safe_search === "strict" ? "-1" : "1");
if (page > 1) url.searchParams.set("s", String(page_size * (page - 1)));
const res = await fetch(url.toString(), { method: "GET", signal, headers: spoofHeaders() });
if (!res.ok) return `Error: HTTP ${res.status} ${res.statusText}`;
const html = await res.text();
const links: [string, string][] = [];
const re = /\shref="[^"]*(https?[^?&"]+)[^>]*>([^<]*)/g;
let m: RegExpExecArray | null;
while (links.length < page_size && (m = re.exec(html))) {
const label = m[2].replace(/\s+/g, " ").trim();
const href = decodeURIComponent(m[1]);
if (!links.some(([, u]) => u === href)) links.push([label, href]);
}
if (!links.length) return "No results found.";
status(`Found ${links.length} results.`);
return { links, count: links.length };
},
});
// ββ IMAGE SEARCH ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const imageSearch = tool({
name: "image_search",
description:
"Search for images using DuckDuckGo. Downloads and returns images as markdown so they appear inline in the chat.",
parameters: {
query: z.string().min(1),
count: z.number().int().min(1).max(10).optional().describe("Number of images (default 5)."),
safe_search: z.enum(["strict", "moderate", "off"]).optional(),
},
implementation: async ({ query, count, safe_search }, { status, warn, signal }: ToolCallContext) => {
status("Fetching DuckDuckGo image tokenβ¦");
await waitIfNeeded();
count = count ?? 5;
safe_search = safe_search ?? "moderate";
// Step 1: get vqd token
const initUrl = new URL("https://duckduckgo.com/");
initUrl.searchParams.set("q", query);
initUrl.searchParams.set("iax", "images");
initUrl.searchParams.set("ia", "images");
const initRes = await fetch(initUrl.toString(), { method: "GET", signal, headers: spoofHeaders() });
if (!initRes.ok) return `Error: HTTP ${initRes.status}`;
const initHtml = await initRes.text();
const vqd = initHtml.match(/vqd="([^"]+)"/)?.[1];
if (!vqd) return "Error: could not extract vqd token from DuckDuckGo.";
await new Promise<void>((r) => setTimeout(r, 1000));
// Step 2: fetch image JSON
status("Fetching image resultsβ¦");
const imgUrl = new URL("https://duckduckgo.com/i.js");
imgUrl.searchParams.set("q", query);
imgUrl.searchParams.set("o", "json");
imgUrl.searchParams.set("l", "us-en");
imgUrl.searchParams.set("vqd", vqd);
imgUrl.searchParams.set("f", ",,,,,");
if (safe_search !== "moderate") imgUrl.searchParams.set("p", safe_search === "strict" ? "-1" : "1");
const imgRes = await fetch(imgUrl.toString(), { method: "GET", signal, headers: spoofHeaders() });
if (!imgRes.ok) return `Error: HTTP ${imgRes.status}`;
const data = await imgRes.json() as { results?: { image: string }[] };
const urls = (data.results ?? [])
.slice(0, count)
.map((r) => r.image)
.filter((u) => u && /\.(jpg|jpeg|png|gif|webp)$/i.test(u));
if (!urls.length) return "No images found.";
status(`Downloading ${urls.length} imagesβ¦`);
return await downloadImages(urls, signal, warn);
},
});
// ββ VIEW IMAGES βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const viewImages = tool({
name: "view_images",
description:
"Download and display images from a list of URLs. Returns markdown image tags so images appear inline in chat.",
parameters: {
urls: z.array(z.string().url()).min(1),
},
implementation: async ({ urls }, { status, warn, signal }: ToolCallContext) => {
status(`Downloading ${urls.length} image(s)β¦`);
return await downloadImages(urls, signal, warn);
},
});
// ββ VISIT WEBSITE βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const visitWebsite = tool({
name: "visit_website",
description:
"Fetch a URL and return its title, headings, text content, links, and images. " +
"Images are downloaded and returned as inline markdown. " +
"Use find_in_page to focus on relevant sections when the page is large.",
parameters: {
url: z.string().url(),
find_in_page: z.array(z.string()).optional().describe("Search terms to prioritise relevant content, links, and images."),
max_links: z.number().int().min(0).max(200).optional().describe("Max links to return (default 40)."),
max_images: z.number().int().min(0).max(50).optional().describe("Max images to return (default 5)."),
content_limit: z.number().int().min(0).max(10000).optional().describe("Max text chars (default 3000)."),
},
implementation: async ({ url, find_in_page, max_links, max_images, content_limit }, ctx: ToolCallContext) => {
const { status, warn, signal } = ctx;
status("Fetching pageβ¦");
max_links = max_links ?? 40;
max_images = max_images ?? 5;
content_limit = content_limit ?? 3000;
const { head, body } = await fetchHTML(url, signal, warn);
status("Extracting contentβ¦");
const title = head.match(/<title>([^<]*)<\/title>/)?.[1] ?? "";
const h1 = body.match(/<h1[^>]*>([^<]*)<\/h1>/)?.[1] ?? "";
const h2 = body.match(/<h2[^>]*>([^<]*)<\/h2>/)?.[1] ?? "";
const links = max_links ? extractLinks(body, url, max_links, find_in_page) : [];
const imgPairs = max_images ? extractImages(body, url, max_images, find_in_page) : [];
let images: string[] = [];
if (imgPairs.length) {
status(`Downloading ${imgPairs.length} image(s)β¦`);
images = await downloadImages(imgPairs.map(([, src]) => src), signal, warn);
}
const rawText = body
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, "")
.replace(/\s+/g, " ")
.trim();
let content = rawText.slice(0, content_limit);
if (find_in_page?.length && rawText.length > content_limit) {
const terms = find_in_page;
const chunkSize = Math.floor(content_limit / terms.length);
content = terms
.map((t) => {
const idx = rawText.toLowerCase().indexOf(t.toLowerCase());
if (idx < 0) return "";
const start = Math.max(0, idx - chunkSize / 4);
return rawText.slice(start, start + chunkSize);
})
.filter(Boolean)
.join(" β¦ ");
}
return { url, title, h1, h2, links, images, content };
},
});
return [webSearch, imageSearch, viewImages, visitWebsite];
}