mirror of
https://github.com/eggent-ai/eggent.git
synced 2026-05-13 15:46:00 +00:00
release: v0.1.4 web search autostart
This commit is contained in:
15
CHANGELOG.md
15
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
|
||||
|
||||
@@ -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
|
||||
|
||||
45
docs/releases/0.1.4-web-search-autostart.md
Normal file
45
docs/releases/0.1.4-web-search-autostart.md
Normal 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.
|
||||
@@ -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) |
|
||||
|
||||
24
docs/releases/github-v0.1.4.md
Normal file
24
docs/releases/github-v0.1.4.md
Normal 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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "design-vibe",
|
||||
"version": "0.1.3",
|
||||
"version": "0.1.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -45,8 +45,8 @@ export const DEFAULT_SETTINGS: AppSettings = {
|
||||
chunkSize: 400,
|
||||
},
|
||||
search: {
|
||||
enabled: false,
|
||||
provider: "none",
|
||||
enabled: true,
|
||||
provider: "auto",
|
||||
},
|
||||
general: {
|
||||
darkMode: false,
|
||||
|
||||
@@ -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(/&/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 =
|
||||
/<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}`;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface AppSettings {
|
||||
};
|
||||
search: {
|
||||
enabled: boolean;
|
||||
provider: "searxng" | "tavily" | "none";
|
||||
provider: "auto" | "duckduckgo" | "searxng" | "tavily" | "none";
|
||||
apiKey?: string;
|
||||
baseUrl?: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user