release: v0.1.4 web search autostart

This commit is contained in:
ilya-bov
2026-03-23 15:09:47 +03:00
parent 5db7c8e66b
commit 61a43dc45a
12 changed files with 315 additions and 20 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "design-vibe",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"scripts": {
"dev": "next dev",

View File

@@ -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",
});
}

View File

@@ -387,14 +387,20 @@ export default function SettingsPage() {
}}
className="w-full rounded-md border bg-background px-3 py-2 text-sm"
>
<option value="auto">Auto (recommended)</option>
<option value="duckduckgo">DuckDuckGo (no API key)</option>
<option value="none">Disabled</option>
<option value="searxng">SearXNG (self-hosted)</option>
<option value="tavily">Tavily API</option>
</select>
</div>
{settings.search.provider === "tavily" && (
{(settings.search.provider === "tavily" ||
settings.search.provider === "auto") && (
<div className="space-y-2">
<Label>Tavily API Key</Label>
<Label>
Tavily API Key
{settings.search.provider === "auto" ? " (optional)" : ""}
</Label>
<Input
type="password"
value={settings.search.apiKey || ""}
@@ -403,9 +409,13 @@ export default function SettingsPage() {
/>
</div>
)}
{settings.search.provider === "searxng" && (
{(settings.search.provider === "searxng" ||
settings.search.provider === "auto") && (
<div className="space-y-2">
<Label>SearXNG URL</Label>
<Label>
SearXNG URL
{settings.search.provider === "auto" ? " (optional)" : ""}
</Label>
<Input
value={settings.search.baseUrl || ""}
onChange={(e) => updateSettings("search.baseUrl", e.target.value)}

View File

@@ -45,8 +45,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
chunkSize: 400,
},
search: {
enabled: false,
provider: "none",
enabled: true,
provider: "auto",
},
general: {
darkMode: false,

View File

@@ -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<string> {
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<string> {
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<string> {
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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&#39;/g, "'")
.replace(/&#x27;/g, "'")
.replace(/&nbsp;/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 =
/<a\b(?=[^>]*\bclass="[^"]*\bresult__a\b[^"]*")([^>]*)>([\s\S]*?)<\/a>/gi;
const nextResultRegex =
/<a\b(?=[^>]*\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<string> {
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}`;
}

View File

@@ -47,7 +47,7 @@ export interface AppSettings {
};
search: {
enabled: boolean;
provider: "searxng" | "tavily" | "none";
provider: "auto" | "duckduckgo" | "searxng" | "tavily" | "none";
apiKey?: string;
baseUrl?: string;
};