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(/([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;
};