From 61a43dc45a97e6e9367c01bde382121225009f29 Mon Sep 17 00:00:00 2001 From: ilya-bov <111734093+ilya-bov@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:09:47 +0300 Subject: [PATCH] release: v0.1.4 web search autostart --- CHANGELOG.md | 15 ++ README.md | 4 +- docs/releases/0.1.4-web-search-autostart.md | 45 ++++ docs/releases/README.md | 1 + docs/releases/github-v0.1.4.md | 24 +++ package-lock.json | 4 +- package.json | 2 +- src/app/api/health/route.ts | 2 +- src/app/dashboard/settings/page.tsx | 18 +- src/lib/storage/settings-store.ts | 4 +- src/lib/tools/search-engine.ts | 214 +++++++++++++++++++- src/lib/types.ts | 2 +- 12 files changed, 315 insertions(+), 20 deletions(-) create mode 100644 docs/releases/0.1.4-web-search-autostart.md create mode 100644 docs/releases/github-v0.1.4.md diff --git a/CHANGELOG.md b/CHANGELOG.md index f8b3a6d..d6ae284 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to this project will be documented in this file. +## [0.1.4] - 2026-03-23 + +### Added +- Keyless DuckDuckGo web search backend with HTML results parsing and Instant Answer fallback. +- New web search providers in settings: `Auto` and `DuckDuckGo (no API key)`. + +### Changed +- Web search now defaults to enabled with provider `auto`. +- Auto search routing now prioritizes Tavily (if key exists), then SearXNG (if URL is configured), then falls back to DuckDuckGo. +- Settings UI now supports optional Tavily key / SearXNG URL in `Auto` mode. + +### Fixed +- `search_web` tool no longer requires external provider setup to be usable on fresh installs. +- Health endpoint version updated to `0.1.4`. + ## [0.1.2] - 2026-03-06 ### Added diff --git a/README.md b/README.md index 295d708..f0de979 100644 --- a/README.md +++ b/README.md @@ -22,8 +22,8 @@ The app runs as a Next.js service and stores runtime state on disk (`./data`). ## Releases -- Latest release snapshot: [0.1.3 - OAuth Native CLI Providers](./docs/releases/0.1.3-oauth-native-cli-providers.md) -- GitHub release body : [v0.1.3](./docs/releases/github-v0.1.3.md) +- Latest release snapshot: [0.1.4 - Web Search Autostart](./docs/releases/0.1.4-web-search-autostart.md) +- GitHub release body : [v0.1.4](./docs/releases/github-v0.1.4.md) - Release archive: [docs/releases/README.md](./docs/releases/README.md) ## Contributing and Support diff --git a/docs/releases/0.1.4-web-search-autostart.md b/docs/releases/0.1.4-web-search-autostart.md new file mode 100644 index 0000000..237837a --- /dev/null +++ b/docs/releases/0.1.4-web-search-autostart.md @@ -0,0 +1,45 @@ +# Eggent 0.1.4 - Web Search Autostart + +Date: 2026-03-23 +Type: Patch release snapshot + +## Release Name +`Web Search Autostart` + +This release makes web search available from first launch without requiring Tavily or a self-hosted search backend. + +## What Is Included + +### 1) Auto Web Search Provider Routing +- Added `auto` provider mode for web search. +- Auto mode now selects providers in this order: + - Tavily (if API key is available), + - SearXNG (if base URL is configured), + - DuckDuckGo (keyless fallback). + +### 2) Keyless DuckDuckGo Search Backend +- Added DuckDuckGo support with no API key required. +- Primary path uses DuckDuckGo HTML search results. +- Fallback path uses DuckDuckGo Instant Answer API if HTML parsing does not return usable results. + +### 3) Search Enabled by Default +- Default settings now enable web search from startup with provider `auto`. +- This removes dependency on external search setup for fresh installs. + +### 4) Settings UI Updates +- Added new provider options: + - `Auto (recommended)` + - `DuckDuckGo (no API key)` +- Auto mode now supports optional Tavily API key and optional SearXNG URL fields. + +## New in 0.1.4 + +- Web search available out of the box on fresh installs. +- Keyless DuckDuckGo integration. +- Auto provider selection and fallback logic. +- Package/app health version bumped to `0.1.4`. + +## Upgrade Notes + +- No migration is required. +- Existing installations can switch to `Auto` in `Dashboard -> Settings -> Web Search` to use startup-ready web search behavior. diff --git a/docs/releases/README.md b/docs/releases/README.md index cff05d8..c721ce2 100644 --- a/docs/releases/README.md +++ b/docs/releases/README.md @@ -4,6 +4,7 @@ This directory contains release summaries and publish-ready notes. | Version | Name | Date | Notes | | --- | --- | --- | --- | +| `0.1.4` | Web Search Autostart | 2026-03-23 | [Full snapshot](./0.1.4-web-search-autostart.md), [GitHub body](./github-v0.1.4.md) | | `0.1.3` | OAuth Native CLI Providers | 2026-03-06 | [Full snapshot](./0.1.3-oauth-native-cli-providers.md), [GitHub body](./github-v0.1.3.md) | | `0.1.2` | Dark Theme and Python Recovery | 2026-03-06 | [Full snapshot](./0.1.2-dark-theme-python-recovery.md), [GitHub body](./github-v0.1.2.md) | | `0.1.1` | Unified Context | 2026-03-03 | [Full snapshot](./0.1.1-unified-context.md), [GitHub body](./github-v0.1.1.md) | diff --git a/docs/releases/github-v0.1.4.md b/docs/releases/github-v0.1.4.md new file mode 100644 index 0000000..998ed7a --- /dev/null +++ b/docs/releases/github-v0.1.4.md @@ -0,0 +1,24 @@ +## Eggent v0.1.4 - Web Search Autostart + +Patch release focused on making web search available immediately on startup, without mandatory external search tool setup. + +### Highlights + +- Added `auto` web search provider mode with priority routing: + - Tavily (when key exists), + - SearXNG (when URL is configured), + - DuckDuckGo keyless fallback. +- Added built-in DuckDuckGo web search backend (no API key required). +- Enabled web search by default for fresh installs (`search.enabled=true`, `search.provider=auto`). +- Updated Settings UI with new provider options: `Auto` and `DuckDuckGo`. +- Version bump to `0.1.4` across package metadata and `GET /api/health`. + +### Upgrade Notes + +- No data migration required. +- To opt in on existing installs, set provider to `Auto` in `Dashboard -> Settings -> Web Search`. + +### Links + +- Full release snapshot: `docs/releases/0.1.4-web-search-autostart.md` +- Installation and update guide: `README.md` diff --git a/package-lock.json b/package-lock.json index 077b67c..e86e515 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "design-vibe", - "version": "0.1.3", + "version": "0.1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "design-vibe", - "version": "0.1.3", + "version": "0.1.4", "dependencies": { "@ai-sdk/anthropic": "^3.0.37", "@ai-sdk/google": "^3.0.21", diff --git a/package.json b/package.json index 0ea365a..0a8d35f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "design-vibe", - "version": "0.1.3", + "version": "0.1.4", "private": true, "scripts": { "dev": "next dev", diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index db6a0bc..cb731c8 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -2,6 +2,6 @@ export async function GET() { return Response.json({ status: "ok", timestamp: new Date().toISOString(), - version: "0.1.3", + version: "0.1.4", }); } diff --git a/src/app/dashboard/settings/page.tsx b/src/app/dashboard/settings/page.tsx index 85c1df4..2ed37d0 100644 --- a/src/app/dashboard/settings/page.tsx +++ b/src/app/dashboard/settings/page.tsx @@ -387,14 +387,20 @@ export default function SettingsPage() { }} className="w-full rounded-md border bg-background px-3 py-2 text-sm" > + + - {settings.search.provider === "tavily" && ( + {(settings.search.provider === "tavily" || + settings.search.provider === "auto") && (
- +
)} - {settings.search.provider === "searxng" && ( + {(settings.search.provider === "searxng" || + settings.search.provider === "auto") && (
- + updateSettings("search.baseUrl", e.target.value)} diff --git a/src/lib/storage/settings-store.ts b/src/lib/storage/settings-store.ts index ce95f73..1f47b2a 100644 --- a/src/lib/storage/settings-store.ts +++ b/src/lib/storage/settings-store.ts @@ -45,8 +45,8 @@ export const DEFAULT_SETTINGS: AppSettings = { chunkSize: 400, }, search: { - enabled: false, - provider: "none", + enabled: true, + provider: "auto", }, general: { darkMode: false, diff --git a/src/lib/tools/search-engine.ts b/src/lib/tools/search-engine.ts index fd22f4d..310a5db 100644 --- a/src/lib/tools/search-engine.ts +++ b/src/lib/tools/search-engine.ts @@ -6,6 +6,10 @@ interface SearchResult { snippet: string; } +const MAX_RESULTS = 10; +const DDG_HTML_ENDPOINT = "https://html.duckduckgo.com/html"; +const DDG_INSTANT_ENDPOINT = "https://api.duckduckgo.com/"; + /** * Search the web using configured provider */ @@ -14,12 +18,17 @@ export async function searchWeb( limit: number, searchConfig: AppSettings["search"] ): Promise { + const cappedLimit = Math.max(1, Math.min(MAX_RESULTS, limit || 5)); try { switch (searchConfig.provider) { + case "auto": + return await searchAuto(query, cappedLimit, searchConfig); + case "duckduckgo": + return await searchDuckDuckGo(query, cappedLimit); case "searxng": - return await searchSearxng(query, limit, searchConfig); + return await searchSearxng(query, cappedLimit, searchConfig); case "tavily": - return await searchTavily(query, limit, searchConfig); + return await searchTavily(query, cappedLimit, searchConfig); default: return "Search is not configured. Please set up a search provider in settings."; } @@ -28,6 +37,33 @@ export async function searchWeb( } } +async function searchAuto( + query: string, + limit: number, + config: AppSettings["search"] +): Promise { + const hasTavilyKey = Boolean(config.apiKey || process.env.TAVILY_API_KEY); + const hasSearxngUrl = Boolean(config.baseUrl?.trim()); + + if (hasTavilyKey) { + try { + return await searchTavily(query, limit, config); + } catch { + // Fall through to the next provider. + } + } + + if (hasSearxngUrl) { + try { + return await searchSearxng(query, limit, config); + } catch { + // Fall through to keyless fallback. + } + } + + return await searchDuckDuckGo(query, limit); +} + /** * Search using SearXNG instance */ @@ -59,7 +95,7 @@ async function searchSearxng( snippet: r.content, })); - return formatResults(results, query); + return formatResults(results, query, "SearXNG"); } /** @@ -72,7 +108,7 @@ async function searchTavily( ): Promise { const apiKey = config.apiKey || process.env.TAVILY_API_KEY; if (!apiKey) { - return "Tavily API key not configured."; + throw new Error("Tavily API key not configured."); } const response = await fetch("https://api.tavily.com/search", { @@ -105,15 +141,179 @@ async function searchTavily( if (data.answer) { output += `**Quick Answer:** ${data.answer}\n\n`; } - output += formatResults(results, query); + output += formatResults(results, query, "Tavily"); return output; } -function formatResults(results: SearchResult[], query: string): string { +function decodeHtmlEntities(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/'/g, "'") + .replace(/ /g, " ") + .replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code))) + .replace(/&#x([0-9a-f]+);/gi, (_, code) => + String.fromCodePoint(parseInt(code, 16)) + ); +} + +function stripHtml(text: string): string { + return text + .replace(/<[^>]+>/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function decodeDuckDuckGoUrl(rawUrl: string): string { + try { + const parsed = new URL( + rawUrl.startsWith("//") ? `https:${rawUrl}` : rawUrl + ); + const redirected = parsed.searchParams.get("uddg"); + if (redirected) return redirected; + } catch { + // Keep raw URL if it's already a direct destination. + } + return rawUrl; +} + +function parseDuckDuckGoHtml(html: string, limit: number): SearchResult[] { + const results: SearchResult[] = []; + const resultRegex = + /]*\bclass="[^"]*\bresult__a\b[^"]*")([^>]*)>([\s\S]*?)<\/a>/gi; + const nextResultRegex = + /]*\bclass="[^"]*\bresult__a\b[^"]*")[^>]*>/i; + + for (const match of html.matchAll(resultRegex)) { + const attrs = match[1] ?? ""; + const title = decodeHtmlEntities(stripHtml(match[2] ?? "")); + const hrefMatch = /\bhref="([^"]*)"/i.exec(attrs); + const url = decodeDuckDuckGoUrl(decodeHtmlEntities(hrefMatch?.[1] ?? "")); + + const resultEnd = (match.index ?? 0) + match[0].length; + const tail = html.slice(resultEnd); + const nextResultIndex = tail.search(nextResultRegex); + const scopedTail = + nextResultIndex >= 0 ? tail.slice(0, nextResultIndex) : tail; + const snippetMatch = + /<(?:a|span)\b(?=[^>]*\bclass="[^"]*\bresult__snippet\b[^"]*")[^>]*>([\s\S]*?)<\/(?:a|span)>/i.exec( + scopedTail + ); + const snippet = decodeHtmlEntities(stripHtml(snippetMatch?.[1] ?? "")); + + if (title && url) { + results.push({ title, url, snippet }); + } + if (results.length >= limit) { + break; + } + } + + return results; +} + +function flattenInstantTopics(topics: unknown[], bucket: SearchResult[]) { + for (const topic of topics) { + if (!topic || typeof topic !== "object") continue; + const record = topic as { + Text?: unknown; + FirstURL?: unknown; + Topics?: unknown[]; + }; + if (Array.isArray(record.Topics)) { + flattenInstantTopics(record.Topics, bucket); + continue; + } + if (typeof record.Text === "string" && typeof record.FirstURL === "string") { + bucket.push({ + title: record.Text.split(" - ")[0] || record.Text, + url: record.FirstURL, + snippet: record.Text, + }); + } + } +} + +async function searchDuckDuckGo(query: string, limit: number): Promise { + const htmlUrl = new URL(DDG_HTML_ENDPOINT); + htmlUrl.searchParams.set("q", query); + + try { + const response = await fetch(htmlUrl.toString(), { + headers: { + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36", + }, + }); + + if (!response.ok) { + throw new Error(`DuckDuckGo error: ${response.status} ${response.statusText}`); + } + + const html = await response.text(); + const results = parseDuckDuckGoHtml(html, limit); + if (results.length > 0) { + return formatResults(results, query, "DuckDuckGo"); + } + } catch { + // Fallback to instant-answer API. + } + + const instantUrl = new URL(DDG_INSTANT_ENDPOINT); + instantUrl.searchParams.set("q", query); + instantUrl.searchParams.set("format", "json"); + instantUrl.searchParams.set("no_html", "1"); + instantUrl.searchParams.set("skip_disambig", "1"); + + const instantResponse = await fetch(instantUrl.toString(), { + headers: { Accept: "application/json" }, + }); + + if (!instantResponse.ok) { + throw new Error( + `DuckDuckGo fallback error: ${instantResponse.status} ${instantResponse.statusText}` + ); + } + + const data = (await instantResponse.json()) as { + AbstractText?: string; + AbstractURL?: string; + Heading?: string; + RelatedTopics?: unknown[]; + }; + + const results: SearchResult[] = []; + if (data.AbstractText && data.AbstractURL) { + results.push({ + title: data.Heading || query, + url: data.AbstractURL, + snippet: data.AbstractText, + }); + } + if (Array.isArray(data.RelatedTopics)) { + flattenInstantTopics(data.RelatedTopics, results); + } + + return formatResults(results.slice(0, limit), query, "DuckDuckGo"); +} + +function formatResults( + results: SearchResult[], + query: string, + providerName?: string +): string { if (results.length === 0) { return `No search results found for: "${query}"`; } + const header = providerName + ? `Search results for "${query}" (${providerName}):` + : `Search results for "${query}":`; + const formatted = results .map( (r, i) => @@ -121,5 +321,5 @@ function formatResults(results: SearchResult[], query: string): string { ) .join("\n\n"); - return `Search results for "${query}":\n\n${formatted}`; + return `${header}\n\n${formatted}`; } diff --git a/src/lib/types.ts b/src/lib/types.ts index ff5a98b..6669d5e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -47,7 +47,7 @@ export interface AppSettings { }; search: { enabled: boolean; - provider: "searxng" | "tavily" | "none"; + provider: "auto" | "duckduckgo" | "searxng" | "tavily" | "none"; apiKey?: string; baseUrl?: string; };