{{t:settings-page.appearance.heading}}
@@ -232,23 +238,23 @@
{{t:settings-page.server.cache-heading}}
diff --git a/src/styles/components/_settings.scss b/src/styles/components/_settings.scss
index ff524adf..b7c08a08 100644
--- a/src/styles/components/_settings.scss
+++ b/src/styles/components/_settings.scss
@@ -54,8 +54,50 @@
}
}
-.settings-nav-mobile {
+.settings-sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: $space-1;
margin-bottom: $space-6;
+
+ @media (min-width: 768px) {
+ width: 11.25rem;
+ flex-shrink: 0;
+ align-self: flex-start;
+ position: sticky;
+ top: $space-8;
+ margin-bottom: 0;
+ }
+}
+
+.settings-nav-search {
+ display: flex;
+ align-items: center;
+ gap: $space-2;
+ background: var(--bg-hover);
+ box-shadow: $box-shadow;
+ border: 1px solid transparent;
+ border-radius: $radius-lg;
+ padding: $space-3;
+ color: var(--text-secondary);
+
+ input {
+ background: none;
+ border: none;
+ outline: none;
+ flex: 1;
+ font-size: $font-size-base;
+ color: var(--text-primary);
+ min-width: 0;
+
+ &::placeholder {
+ color: var(--text-secondary);
+ }
+ }
+}
+
+
+.settings-nav-mobile {
position: relative;
&:after {
@@ -103,12 +145,7 @@
@media (min-width: 768px) {
display: flex;
flex-direction: column;
- flex-shrink: 0;
- width: 11.25rem;
gap: 2px;
- position: sticky;
- top: $space-8;
- align-self: flex-start;
}
}
@@ -149,6 +186,18 @@
}
}
+.settings-search-active .settings-tab-panel {
+ display: block;
+}
+
+.settings-search-active .settings-page-actions {
+ display: none;
+}
+
+.settings-search-active.settings-search-empty .settings-tab-panel {
+ display: none;
+}
+
.settings-page-main {
display: flex;
flex-direction: column;
diff --git a/src/styles/components/_store.scss b/src/styles/components/_store.scss
index bf78166e..c77c8127 100644
--- a/src/styles/components/_store.scss
+++ b/src/styles/components/_store.scss
@@ -272,8 +272,8 @@
-webkit-appearance: none;
flex: 1;
min-width: 0;
- background: var(--search-bar-bg);
- border-radius: $search-bar-radius;
+ background: var(--bg-hover);
+ border-radius: $radius-lg;
color: var(--text-primary);
font-size: $font-size-base;
padding: $space-3;
From 5a27f08c38088d52856a4dd5fb6fa13e49d0f7f9 Mon Sep 17 00:00:00 2001
From: fccview
Date: Mon, 4 May 2026 16:07:35 +0100
Subject: [PATCH 24/28] update at a glance to be much smarter
---
.../commands/builtins/ai-summary/index.ts | 29 +-
.../commands/builtins/at-a-glance/index.ts | 312 +++++++++++++++++-
.../commands/builtins/wikipedia/index.ts | 35 +-
src/server/routes/slots.ts | 2 +
src/server/search.ts | 16 +-
src/server/types/extension.ts | 3 +
src/server/utils/cache.ts | 60 ++--
src/server/utils/plugin-assets.ts | 2 +
src/server/utils/search.ts | 2 +
src/server/utils/text.ts | 20 ++
src/styles/components/_settings.scss | 4 +-
11 files changed, 428 insertions(+), 57 deletions(-)
create mode 100644 src/server/utils/text.ts
diff --git a/src/server/extensions/commands/builtins/ai-summary/index.ts b/src/server/extensions/commands/builtins/ai-summary/index.ts
index 509805b9..3fefd608 100644
--- a/src/server/extensions/commands/builtins/ai-summary/index.ts
+++ b/src/server/extensions/commands/builtins/ai-summary/index.ts
@@ -1,9 +1,13 @@
+import { createHash } from "node:crypto";
import {
SlotPanelPosition,
TranslateFunction,
+ type PluginContext,
+ type ScoredResult,
type SettingField,
type SlotPlugin,
} from "../../../../types";
+import { SHORT_TTL_MS, type TtlCache } from "../../../../utils/cache";
import { logger } from "../../../../utils/logger";
import { asString, getSettings } from "../../../../utils/plugin-settings";
@@ -111,6 +115,17 @@ function escapeHtml(s: string): string {
const DEFAULT_SYSTEM_PROMPT =
"You are a helpful assistant that summarises web search results. Write a concise 2–3 sentence summary answering the query based on the provided snippets. Do not invent facts. Do not include citations.";
+let _summaryCache!: TtlCache;
+
+function _summaryCacheKey(query: string, results: ScoredResult[]): string {
+ const fp = results
+ .slice(0, 6)
+ .map((r) => `${r.url}\n${r.snippet}`)
+ .join("\n\n");
+ const hash = createHash("sha256").update(fp).digest("hex").slice(0, 24);
+ return `${query.trim().toLowerCase()}|${hash}`;
+}
+
async function chatComplete(
settings: AISummarySettings,
messages: OpenAIMessage[],
@@ -201,6 +216,10 @@ const aiSummarySlot: SlotPlugin = {
t: TranslateFunction,
+ init(ctx: PluginContext): void {
+ _summaryCache = ctx.createCache(SHORT_TTL_MS);
+ },
+
async trigger(): Promise {
const settings = await getAISummarySettings();
return !!settings.baseUrl && !!settings.model;
@@ -208,8 +227,14 @@ const aiSummarySlot: SlotPlugin = {
async execute(query, context): Promise<{ title?: string; html: string }> {
const results = context?.results ?? [];
if (results.length === 0) return { html: "" };
- const summary = await generateAISummary(query, results);
- if (!summary) return { html: "" };
+ const key = _summaryCacheKey(query, results);
+ let summary = _summaryCache.get(key);
+ if (summary === null) {
+ const generated = await generateAISummary(query, results);
+ if (!generated) return { html: "" };
+ _summaryCache.set(key, generated);
+ summary = generated;
+ }
return {
html:
'' +
diff --git a/src/server/extensions/commands/builtins/at-a-glance/index.ts b/src/server/extensions/commands/builtins/at-a-glance/index.ts
index c21a7c16..a44abebb 100644
--- a/src/server/extensions/commands/builtins/at-a-glance/index.ts
+++ b/src/server/extensions/commands/builtins/at-a-glance/index.ts
@@ -1,19 +1,226 @@
+import * as cheerio from "cheerio";
import {
SlotPanelPosition,
TranslateFunction,
+ type PluginContext,
+ type SettingField,
+ type ScoredResult,
type SlotPlugin,
} from "../../../../types";
+import {
+ asString,
+ getSettings,
+ isDisabled,
+} from "../../../../utils/plugin-settings";
+import type { TtlCache } from "../../../../utils/cache";
+import { looksLikeProse, stripSnippetPrefix } from "../../../../utils/text";
+import { getRandomUserAgent } from "../../../../utils/user-agents";
+
+const SETTINGS_ID = "slot-at-a-glance";
+const WIKIPEDIA_SETTINGS_ID = "slot-wikipedia";
+const WIKIPEDIA_HOSTNAME = "wikipedia.org";
+
+let _extractCache!: TtlCache;
-function escapeHtml(s: string): string {
- return s
+const _escapeHtml = (s: string): string =>
+ s
.replace(/&/g, "&")
.replace(//g, ">")
- .replace(/"/g, """);
-}
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+
+const _isWikipediaUrl = (url: string): boolean => {
+ try {
+ const host = new URL(url).hostname.toLowerCase();
+ return host === WIKIPEDIA_HOSTNAME || host.endsWith(`.${WIKIPEDIA_HOSTNAME}`);
+ } catch {
+ return false;
+ }
+};
+
+const _scoreSnippet = (snippet: string, queryTerms: string[]): number => {
+ if (!snippet || snippet.length < 20) return 0;
+ if (!looksLikeProse(snippet)) return 0;
+ const lower = snippet.toLowerCase();
+ const termHits = queryTerms.filter((t) => lower.includes(t)).length;
+ const densityBonus = termHits / Math.max(queryTerms.length, 1);
+ const lengthScore = Math.min(snippet.length / 200, 1);
+ return lengthScore * (1 + densityBonus);
+};
+
+const _pickBestResult = (
+ results: ScoredResult[],
+ excludeWikipedia: boolean,
+ queryTerms: string[],
+): ScoredResult | null => {
+ const candidates = excludeWikipedia
+ ? results.filter((r) => !_isWikipediaUrl(r.url))
+ : results;
+ if (candidates.length === 0) return null;
+ return candidates.reduce((best, r) =>
+ _scoreSnippet(r.snippet, queryTerms) > _scoreSnippet(best.snippet, queryTerms)
+ ? r
+ : best,
+ );
+};
+
+type ExcerptMode = "strict" | "full";
+
+const _pushExtractParagraph = (
+ found: string[],
+ text: string,
+ perParaBudget: number,
+): void => {
+ found.push(
+ text.length > perParaBudget ? `${text.slice(0, perParaBudget)}…` : text,
+ );
+};
+
+const _finalizeExtractJoin = (found: string[], maxLength: number): string => {
+ let joined = found.join("\n\n");
+ if (joined.length > maxLength) {
+ joined = `${joined.slice(0, maxLength)}…`;
+ }
+ return joined;
+};
+
+const _extractFromHtml = (
+ html: string,
+ queryTerms: string[],
+ maxLength: number,
+ maxParagraphs: number,
+ excerptMode: ExcerptMode,
+): string | null => {
+ const sepCost = Math.max(0, maxParagraphs - 1) * 2;
+ const perParaBudget = Math.max(
+ 1,
+ Math.floor((maxLength - sepCost) / maxParagraphs),
+ );
+
+ const $ = cheerio.load(html);
+ $("script, style, nav, header, footer, aside").remove();
+ const root = $("article, main, [role='main']").first();
+ const scope = root.length ? root : $("body");
+ const found: string[] = [];
+
+ if (excerptMode === "strict") {
+ scope.find("p").each((_i, el) => {
+ if (found.length >= maxParagraphs) return false;
+ const text = $(el).text().replace(/\s+/g, " ").trim();
+ if (text.length < 60) return;
+ if (!looksLikeProse(text)) return;
+ const lower = text.toLowerCase();
+ if (queryTerms.some((t) => lower.includes(t))) {
+ _pushExtractParagraph(found, text, perParaBudget);
+ }
+ });
+ return found.length > 0 ? _finalizeExtractJoin(found, maxLength) : null;
+ }
+
+ let anchored = false;
+ scope.find("p").each((_i, el) => {
+ if (found.length >= maxParagraphs) return false;
+ const text = $(el).text().replace(/\s+/g, " ").trim();
+ if (text.length < 60) return;
+ if (!looksLikeProse(text)) return;
+ const lower = text.toLowerCase();
+ if (!anchored) {
+ if (queryTerms.some((t) => lower.includes(t))) {
+ _pushExtractParagraph(found, text, perParaBudget);
+ anchored = true;
+ }
+ return;
+ }
+ _pushExtractParagraph(found, text, perParaBudget);
+ });
+ return found.length > 0 ? _finalizeExtractJoin(found, maxLength) : null;
+};
+
+const _extractCacheKey = (
+ url: string,
+ excerptMode: ExcerptMode,
+ maxLength: number,
+ maxParagraphs: number,
+ queryTerms: string[],
+): string => {
+ const termsKey = [...queryTerms].sort().join("\x1f");
+ return `${url}\x1e${excerptMode}\x1e${maxLength}\x1e${maxParagraphs}\x1e${termsKey}`;
+};
+
+const _fetchExtract = async (
+ url: string,
+ queryTerms: string[],
+ maxLength: number,
+ maxParagraphs: number,
+ excerptMode: ExcerptMode,
+ timeoutMs: number,
+ fetchFn: (url: string, init?: RequestInit) => Promise,
+): Promise => {
+ const cacheKey = _extractCacheKey(
+ url,
+ excerptMode,
+ maxLength,
+ maxParagraphs,
+ queryTerms,
+ );
+ const cached = _extractCache.get(cacheKey);
+ if (cached !== null) return cached;
+
+ const controller = new AbortController();
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
+ try {
+ const res = await fetchFn(url, {
+ signal: controller.signal,
+ headers: { "User-Agent": getRandomUserAgent(), Accept: "text/html" },
+ });
+ clearTimeout(timer);
+ if (!res.ok) return null;
+ const ct = res.headers.get("content-type") ?? "";
+ if (!ct.includes("text/html")) return null;
+ const html = await res.text();
+ const extracted = _extractFromHtml(
+ html,
+ queryTerms,
+ maxLength,
+ maxParagraphs,
+ excerptMode,
+ );
+ if (extracted) _extractCache.set(cacheKey, extracted);
+ return extracted;
+ } catch {
+ clearTimeout(timer);
+ return null;
+ }
+};
+
+const _loadSettings = async () => {
+ const stored = await getSettings(SETTINGS_ID);
+ const rawLength = parseInt(asString(stored["snippetLength"]), 10);
+ const rawTimeout = parseFloat(asString(stored["fetchTimeoutSeconds"]) || "3");
+ const rawParagraphs = parseInt(asString(stored["paragraphs"]) || "1", 10);
+ const rawMode = asString(stored["excerptMode"]).toLowerCase();
+ const excerptMode: ExcerptMode = rawMode === "strict" ? "strict" : "full";
+
+ return {
+ maxLength:
+ Number.isFinite(rawLength) && rawLength > 0
+ ? Math.max(100, rawLength)
+ : 400,
+ fetchContent: asString(stored["fetchContent"]) !== "false",
+ fetchTimeoutMs: Number.isFinite(rawTimeout)
+ ? Math.max(1, Math.min(rawTimeout, 30)) * 1000
+ : 3_000,
+ maxParagraphs: Number.isFinite(rawParagraphs)
+ ? Math.max(1, Math.min(rawParagraphs, 5))
+ : 1,
+ excerptMode,
+ };
+};
const atAGlanceSlot: SlotPlugin = {
id: "at-a-glance",
+ settingsId: SETTINGS_ID,
name: "At a Glance",
get description(): string {
return this.t!("at-a-glance.description");
@@ -23,25 +230,108 @@ const atAGlanceSlot: SlotPlugin = {
t: TranslateFunction,
+ init(ctx: PluginContext): void {
+ _extractCache = ctx.createCache(60 * 60 * 1000);
+ },
+
trigger(): boolean {
return true;
},
- async execute(_query: string, context): Promise<{ html: string }> {
+ settingsSchema: [
+ {
+ key: "snippetLength",
+ label: "Snippet length",
+ type: "number",
+ default: "400",
+ placeholder: "400",
+ description:
+ "Maximum characters for the combined snippet (paragraphs share this budget). At least 100; otherwise uses your number, or 400 if unset or invalid.",
+ },
+ {
+ key: "paragraphs",
+ label: "Paragraphs",
+ type: "select",
+ options: ["1", "2", "3", "4", "5"],
+ default: "1",
+ description:
+ "Number of paragraphs to extract from the page. More gives more context but takes more space.",
+ },
+ {
+ key: "excerptMode",
+ label: "Excerpt mode",
+ type: "select",
+ options: ["full", "strict"],
+ default: "full",
+ description:
+ "Full: first paragraph must match the query, following won't need to. Strict: every paragraph must match the query.",
+ },
+ {
+ key: "fetchContent",
+ label: "Fetch page content",
+ type: "toggle",
+ default: "true",
+ description:
+ "Fetch the actual page for richer content. Disable to use search snippets only (faster).",
+ },
+ {
+ key: "fetchTimeoutSeconds",
+ label: "Fetch timeout (seconds)",
+ type: "number",
+ default: "3",
+ placeholder: "3",
+ description:
+ "How long to wait for the page fetch before falling back to the search snippet.",
+ advanced: true,
+ },
+ ] as SettingField[],
+
+ async execute(query: string, context): Promise<{ html: string }> {
const results = context?.results ?? [];
- const top = results.length > 0 && results[0].snippet ? results[0] : null;
- if (!top) return { html: "" };
+ if (results.length === 0) return { html: "" };
+
+ const [settings, wikipediaDisabled] = await Promise.all([
+ _loadSettings(),
+ isDisabled(WIKIPEDIA_SETTINGS_ID),
+ ]);
+
+ const queryTerms = query.toLowerCase().split(/\s+/).filter(Boolean);
+ const best = _pickBestResult(results, !wikipediaDisabled, queryTerms);
+ if (!best) return { html: "" };
+
+ let snippet = looksLikeProse(best.snippet)
+ ? stripSnippetPrefix(best.snippet)
+ : "";
+
+ if (settings.fetchContent && context?.fetch) {
+ const extracted = await _fetchExtract(
+ best.url,
+ queryTerms,
+ settings.maxLength,
+ settings.maxParagraphs,
+ settings.excerptMode,
+ settings.fetchTimeoutMs,
+ context.fetch,
+ );
+ if (extracted) snippet = extracted;
+ }
+
+ if (!snippet) return { html: "" };
+
+ if (snippet.length > settings.maxLength) {
+ snippet = `${snippet.slice(0, settings.maxLength)}…`;
+ }
const foundOn = this.t!("at-a-glance.found-on", {
- sources_text: top.sources.join(", "),
+ sources_text: best.sources.join(", "),
});
return {
html:
'' +
- `${escapeHtml(top.snippet)}` +
- `${escapeHtml(top.title)}` +
- `${escapeHtml(foundOn)}` +
+ `${_escapeHtml(snippet)}` +
+ `${_escapeHtml(best.title)}` +
+ `${_escapeHtml(foundOn)}` +
"",
};
},
diff --git a/src/server/extensions/commands/builtins/wikipedia/index.ts b/src/server/extensions/commands/builtins/wikipedia/index.ts
index d18d8c7f..6fa8e5e2 100644
--- a/src/server/extensions/commands/builtins/wikipedia/index.ts
+++ b/src/server/extensions/commands/builtins/wikipedia/index.ts
@@ -4,6 +4,7 @@ import {
type PluginContext,
type SlotPlugin,
} from "../../../../types";
+import type { TtlCache } from "../../../../utils/cache";
const TIMEOUT_MS = 5_000;
const USER_AGENT = "degoog/1.0 (+https://github.com/degoog-org/degoog)";
@@ -27,10 +28,7 @@ interface WikiPage {
let _template = "";
-let _cache: { query: string | null; page: WikiPage | null } = {
- query: null,
- page: null,
-};
+let _wikiCache!: TtlCache;
async function _fetchWikipedia(query: string): Promise {
const controller = new AbortController();
@@ -92,22 +90,35 @@ const wikipediaSlot: SlotPlugin = {
init(ctx: PluginContext): void {
_template = ctx.template;
+ _wikiCache = ctx.createCache(60 * 60 * 1000);
},
async trigger(query: string): Promise {
const q = query.trim();
if (q.length < 2 || q.length > 100) return false;
- const page = await _fetchWikipedia(q);
- _cache = { query: q, page };
- return page !== null;
+ const key = q.toLowerCase();
+ const page = _wikiCache.get(key);
+ if (page === null) {
+ const fetched = await _fetchWikipedia(q);
+ if (fetched) {
+ _wikiCache.set(key, fetched);
+ return true;
+ }
+ return false;
+ }
+ return true;
},
async execute(query: string): Promise<{ title?: string; html: string }> {
const q = query.trim();
- let page = _cache.query === q ? _cache.page : null;
- if (!page) {
- page = await _fetchWikipedia(q);
- _cache = { query: q, page };
+ const key = q.toLowerCase();
+ let page = _wikiCache.get(key);
+ if (page === null) {
+ const fetched = await _fetchWikipedia(q);
+ if (fetched) {
+ _wikiCache.set(key, fetched);
+ page = fetched;
+ }
}
if (!page) return { html: "" };
@@ -123,7 +134,7 @@ const wikipediaSlot: SlotPlugin = {
const html = _template.replace(
/\{\{(\w+)\}\}/g,
- (_, key: string) => sanitizePage[key] ?? "",
+ (_, k: string) => sanitizePage[k] ?? "",
);
return { title: page.title, html };
diff --git a/src/server/routes/slots.ts b/src/server/routes/slots.ts
index 29affd8f..3aa9898f 100644
--- a/src/server/routes/slots.ts
+++ b/src/server/routes/slots.ts
@@ -6,6 +6,7 @@ import {
SlotPanelResult,
SlotPluginContext,
} from "../types";
+import { createCache } from "../utils/cache";
import { getLocale } from "../utils/hono";
import { logger } from "../utils/logger";
import { outgoingFetch } from "../utils/outgoing";
@@ -70,6 +71,7 @@ router.post("/api/slots/glance", async (c) => {
results: body.results,
fetch: outgoingFetch as SlotPluginContext["fetch"],
signProxyUrl: buildSignedProxyUrl,
+ createCache,
};
const t0 = performance.now();
const out = await plugin.execute(body.query!.trim(), context);
diff --git a/src/server/search.ts b/src/server/search.ts
index 7520eb69..fd6060ed 100644
--- a/src/server/search.ts
+++ b/src/server/search.ts
@@ -19,6 +19,7 @@ import type {
} from "./types";
import { extractImageUrl } from "./utils/extract-image";
import { outgoingFetch, parseOutgoingTransport } from "./utils/outgoing";
+import { stripHtml } from "./utils/text";
import { asString, getSettings } from "./utils/plugin-settings";
import { buildSignedProxyUrl } from "./utils/proxy-sign";
@@ -100,8 +101,9 @@ const _mergeIntoMap = (
if (!existing.sources.includes(r.source)) {
existing.sources.push(r.source);
}
- if (r.snippet.length > existing.snippet.length) {
- existing.snippet = r.snippet;
+ const cleanSnippet = stripHtml(r.snippet);
+ if (cleanSnippet.length > existing.snippet.length) {
+ existing.snippet = cleanSnippet;
}
if (r.thumbnail && !existing.thumbnail) {
existing.thumbnail = r.thumbnail;
@@ -110,6 +112,8 @@ const _mergeIntoMap = (
} else {
urlMap.set(normalized, {
...r,
+ title: stripHtml(r.title),
+ snippet: stripHtml(r.snippet),
url: normalized,
score: positionScore,
sources: [r.source],
@@ -304,10 +308,10 @@ export const search = async (
type === "web"
? await getActiveWebEngines(config)
: getEnginesForSearchType(type, config).map((e) => ({
- id: e.id,
- instance: e.instance,
- score: 1,
- }));
+ id: e.id,
+ instance: e.instance,
+ score: 1,
+ }));
if (rawActiveEngines.length === 0) {
return {
diff --git a/src/server/types/extension.ts b/src/server/types/extension.ts
index 4e6dad46..deb672ba 100644
--- a/src/server/types/extension.ts
+++ b/src/server/types/extension.ts
@@ -1,3 +1,4 @@
+import type { CreateCache } from "../utils/cache";
import type {
SearchResult,
ScoredResult,
@@ -80,6 +81,7 @@ export interface PluginContext {
readFile: (filename: string) => Promise;
signProxyUrl: (url: string) => string;
fetch?: (url: string, init?: RequestInit) => Promise;
+ createCache: CreateCache;
}
export interface SearchEngine {
@@ -120,6 +122,7 @@ export interface SlotPluginContext {
results?: ScoredResult[];
fetch?: (url: string, init?: RequestInit) => Promise;
signProxyUrl?: (url: string) => string;
+ createCache: CreateCache;
}
export interface SlotPlugin {
diff --git a/src/server/utils/cache.ts b/src/server/utils/cache.ts
index 27939ef1..66d73d4a 100644
--- a/src/server/utils/cache.ts
+++ b/src/server/utils/cache.ts
@@ -1,33 +1,45 @@
import type { SearchResponse } from "../types";
-const TTL_MS = 12 * 60 * 60 * 1000;
-const SHORT_TTL_MS = 2 * 60 * 1000;
-const NEWS_TTL_MS = 30 * 60 * 1000;
-const store = new Map();
+export const TTL_MS = 12 * 60 * 60 * 1000;
+export const SHORT_TTL_MS = 2 * 60 * 1000;
+export const NEWS_TTL_MS = 30 * 60 * 1000;
-export function get(key: string): SearchResponse | null {
- const entry = store.get(key);
- if (!entry || Date.now() > entry.expiresAt) {
- if (entry) store.delete(key);
- return null;
- }
- return entry.value;
-}
+export type TtlCache = {
+ get(key: string): T | null;
+ set(key: string, value: T, ttlMs?: number): void;
+ clear(): void;
+};
-export function set(
- key: string,
- value: SearchResponse,
- ttlMs: number = TTL_MS,
-): void {
- store.set(key, { value, expiresAt: Date.now() + ttlMs });
+export function createCache(defaultTtlMs: number): TtlCache {
+ const store = new Map();
+ return {
+ get(key: string): T | null {
+ const entry = store.get(key);
+ if (!entry) return null;
+ if (Date.now() > entry.expiresAt) {
+ store.delete(key);
+ return null;
+ }
+ return entry.value;
+ },
+ set(key: string, value: T, ttlMs: number = defaultTtlMs): void {
+ store.set(key, { value, expiresAt: Date.now() + ttlMs });
+ },
+ clear(): void {
+ store.clear();
+ },
+ };
}
-export function hasFailedEngines(response: SearchResponse): boolean {
- return response.engineTimings.some((et) => et.resultCount === 0);
-}
+export type CreateCache = typeof createCache;
-export { TTL_MS, SHORT_TTL_MS, NEWS_TTL_MS };
+const _searchCache = createCache(TTL_MS);
-export function clear(): void {
- store.clear();
+export const get = (key: string): SearchResponse | null => _searchCache.get(key);
+export const set = (key: string, value: SearchResponse, ttlMs: number = TTL_MS): void =>
+ _searchCache.set(key, value, ttlMs);
+export const clear = (): void => _searchCache.clear();
+
+export function hasFailedEngines(response: SearchResponse): boolean {
+ return response.engineTimings.some((et) => et.resultCount === 0);
}
diff --git a/src/server/utils/plugin-assets.ts b/src/server/utils/plugin-assets.ts
index c6e90822..fdc61029 100644
--- a/src/server/utils/plugin-assets.ts
+++ b/src/server/utils/plugin-assets.ts
@@ -68,6 +68,7 @@ export function getScriptFolderSource(
import { join } from "path";
import type { PluginContext, SettingField } from "../types";
+import { createCache } from "./cache";
import { outgoingFetch } from "./outgoing";
import { buildSignedProxyUrl } from "./proxy-sign";
import {
@@ -117,6 +118,7 @@ export async function initPlugin(
readFile(join(entryPath, filename), "utf-8"),
signProxyUrl: buildSignedProxyUrl,
fetch: outgoingFetch as PluginContext["fetch"],
+ createCache,
};
await Promise.resolve(plugin.init(ctx));
}
diff --git a/src/server/utils/search.ts b/src/server/utils/search.ts
index 55e1025a..8b4604a7 100644
--- a/src/server/utils/search.ts
+++ b/src/server/utils/search.ts
@@ -11,6 +11,7 @@ import {
SlotPluginContext,
TimeFilter,
} from "../types";
+import { createCache } from "./cache";
import { logger } from "./logger";
import { outgoingFetch } from "./outgoing";
import { asString, getSettings, isDisabled } from "./plugin-settings";
@@ -184,6 +185,7 @@ export async function runSlotPlugins(
results: plugin.waitForResults ? results : undefined,
fetch: outgoingFetch as SlotPluginContext["fetch"],
signProxyUrl: buildSignedProxyUrl,
+ createCache,
};
const t0 = performance.now();
const out = await plugin.execute(query, context);
diff --git a/src/server/utils/text.ts b/src/server/utils/text.ts
new file mode 100644
index 00000000..e0e77949
--- /dev/null
+++ b/src/server/utils/text.ts
@@ -0,0 +1,20 @@
+export const stripHtml = (text: string): string =>
+ text.replace(/<[^>]+>/g, "").replace(/\s+/g, " ").trim();
+
+const _DATE_PREFIX =
+ /^(?:\d{1,2}\s+)?(?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)?|May|Jun(?:e)?|Jul(?:y)?|Aug(?:ust)?|Sep(?:tember)?|Oct(?:ober)?|Nov(?:ember)?|Dec(?:ember)?)\s+\d{1,2},?\s+\d{4}|\d{4}-\d{2}-\d{2}|\d{1,2}\/\d{1,2}\/\d{4}|\d+\s+(?:second|minute|hour|day|week|month|year)s?\s+ago/i;
+
+export const stripSnippetPrefix = (text: string): string => {
+ const stripped = text.replace(
+ new RegExp(`^(?:${_DATE_PREFIX.source})\\s*[-–—·]\\s*`, "i"),
+ "",
+ );
+ return stripped || text;
+};
+
+export const looksLikeProse = (text: string): boolean => {
+ if (/\{[^}]{0,500}\}/.test(text)) return false;
+ const specialChars = (text.match(/[^a-zA-Z0-9\s.,!?'"()\-–—]/g) ?? []).length;
+ const words = text.split(/\s+/).filter(Boolean);
+ return specialChars / text.length < 0.10 && words.length >= 8;
+};
diff --git a/src/styles/components/_settings.scss b/src/styles/components/_settings.scss
index b7c08a08..887c0167 100644
--- a/src/styles/components/_settings.scss
+++ b/src/styles/components/_settings.scss
@@ -10,7 +10,7 @@
min-height: 100vh;
@media (min-width: 768px) {
- max-width: 60rem;
+ max-width: 70rem;
}
}
@@ -61,7 +61,7 @@
margin-bottom: $space-6;
@media (min-width: 768px) {
- width: 11.25rem;
+ width: 14rem;
flex-shrink: 0;
align-self: flex-start;
position: sticky;
From 84a85355c68f4b3b6e2f786f39399963d47cb837 Mon Sep 17 00:00:00 2001
From: fccview
Date: Mon, 4 May 2026 18:30:18 +0100
Subject: [PATCH 25/28] update docs and fix minor issues
---
docs/plugins.html | 35 ++++++++++++++++++++++++++++++-----
src/server/routes/slots.ts | 2 +-
src/server/utils/search.ts | 2 +-
3 files changed, 32 insertions(+), 7 deletions(-)
diff --git a/docs/plugins.html b/docs/plugins.html
index d50eca71..09aa46f7 100644
--- a/docs/plugins.html
+++ b/docs/plugins.html
@@ -201,7 +201,29 @@ Plugin context and init()
// ctx.readFile is an async function to read any file from your plugin folder
// ctx.signProxyUrl(url) returns a signed /api/proxy/image URL for any external image
// ctx.fetch(url, init?) is a proxy-aware fetch, respects your outgoing proxy settings
+ // ctx.createCache(defaultTtlMs) returns an in-memory TTL cache { get, set, clear }
}
+ TTL cache (createCache)
+
+ Both init(ctx) and slot execute(query, context)
+ receive createCache: a factory compatible with
+ the built-in server helper. Call
+ ctx.createCache<T>(defaultTtlMs) (or
+ context.createCache<T>(defaultTtlMs) in slots) to get an
+ object with get(key), set(key, value, ttlMs?), and
+ clear(). Keys are strings; values are typed by your generic
+ T. Entries expire automatically after the TTL (milliseconds).
+
+
+ Built-in and third-party code paths share this API: typical usage is to
+ call createCache once in init, keep the returned
+ cache on a module-level variable, and reuse get /
+ set from execute (for example for fetched page
+ excerpts). Prefer always using
+ ctx.createCache / context.createCache rather than
+ importing cache helpers from server internals so plugins behave the same in
+ every environment.
+
Store ctx.fetch during init if your plugin
makes outgoing HTTP requests outside of execute(), for
@@ -400,11 +422,14 @@
Slot plugins
execute(query, context) (async): Return an object
with an optional title string and an
html string. If you return an empty string for the
- html, nothing will show up. The context contains the
- clientIp, an array of results, and
- signProxyUrl(url) for proxying external images. Note
- that the results array is only populated if you set
- waitForResults: true on your plugin.
+ html, nothing will show up. The context includes
+ clientIp; results (only when
+ waitForResults: true); proxy-aware
+ fetch(url, init?); signProxyUrl(url) for
+ external images; and createCache(defaultTtlMs) for the same
+ TTL cache factory as in init(ctx). Note that the results
+ array is only populated if you set waitForResults: true on
+ your plugin.
diff --git a/src/server/routes/slots.ts b/src/server/routes/slots.ts
index 3aa9898f..f7a5bc8c 100644
--- a/src/server/routes/slots.ts
+++ b/src/server/routes/slots.ts
@@ -90,7 +90,7 @@ router.post("/api/slots/glance", async (c) => {
position: plugin.position,
gridSize: plugin.gridSize,
});
- } catch {}
+ } catch { }
}
return c.json({ panels });
});
diff --git a/src/server/utils/search.ts b/src/server/utils/search.ts
index 8b4604a7..41a07474 100644
--- a/src/server/utils/search.ts
+++ b/src/server/utils/search.ts
@@ -204,7 +204,7 @@ export async function runSlotPlugins(
position: effectivePosition,
gridSize: plugin.gridSize,
});
- } catch {}
+ } catch { }
}
return panels;
}
From 64565b1b03064de49c7082c9d9e66f9633fb1c9f Mon Sep 17 00:00:00 2001
From: fccview
Date: Tue, 5 May 2026 10:34:38 +0100
Subject: [PATCH 26/28] allow adding base url
---
CONTRIBUTING.md | 10 +-
docs/aliases.html | 2 +-
docs/api.html | 2 +-
docs/engines.html | 2 +-
docs/env.html | 23 +++-
docs/index.html | 2 +-
docs/plugins.html | 2 +-
docs/store.html | 2 +-
docs/styling.html | 2 +-
docs/themes.html | 2 +-
docs/translations.html | 2 +-
docs/transports.html | 2 +-
src/client/modules/init.ts | 5 +-
src/client/modules/media/media.ts | 5 +-
.../modules/modals/settings-modal/modal.ts | 3 +-
src/client/modules/result-actions.ts | 5 +-
src/client/modules/settings/settings.ts | 19 +--
src/client/modules/tabs/tab-search.ts | 11 +-
src/client/modules/tabs/tabs.ts | 3 +-
src/client/settings/engines-tab.ts | 3 +-
src/client/settings/general-tab.ts | 9 +-
src/client/settings/proxy-test.ts | 3 +-
src/client/settings/server-tab.ts | 108 +++++++++++-------
src/client/settings/store-tab.ts | 9 +-
.../settings/store/store-tab-handlers.ts | 17 +--
src/client/settings/store/store-tab-render.ts | 14 +--
src/client/settings/themes-tab.ts | 3 +-
src/client/utils/autocomplete.ts | 5 +-
src/client/utils/base-url.ts | 7 ++
src/client/utils/engines.ts | 3 +-
src/client/utils/favicon.ts | 4 +-
src/client/utils/install-prompt.ts | 5 +-
src/client/utils/navigation.ts | 10 +-
src/client/utils/search-bar-actions.ts | 3 +-
src/client/utils/search-navigation.ts | 5 +-
src/client/utils/search-utils.ts | 5 +-
.../utils/search/search-actions-lucky.ts | 3 +-
.../utils/search/search-actions-page.ts | 14 ++-
.../utils/search/search-actions-perform.ts | 21 ++--
.../utils/search/search-actions-retry.ts | 5 +-
src/client/utils/theme.ts | 3 +-
src/client/utils/time-filter.ts | 3 +-
src/client/utils/url.ts | 7 +-
src/locales/en-US.json | 2 +-
src/locales/fr-FR.json | 2 +-
src/locales/it.json | 2 +-
src/public/icons/fontawesome/LICENSE.txt | 2 +-
.../commands/builtins/speedtest/script.html | 6 +-
src/server/index.ts | 31 +++--
src/server/routes/pages.ts | 39 +++++--
src/server/utils/base-url.ts | 16 +++
51 files changed, 302 insertions(+), 171 deletions(-)
create mode 100644 src/client/utils/base-url.ts
create mode 100644 src/server/utils/base-url.ts
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index e79ce796..c42d3f48 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -6,8 +6,8 @@
# Coding standards
-- **Functions that return a value** — use arrow functions (e.g. `const getX = () => value`).
-- **Functions that do not return** (side-effect only) — use regular `function` declarations.
-- **Internal / private helpers** — prepend with `_` (e.g. `_parseQuery`, `_formatDate`).
-- **CSS** — Reuse existing app classes where possible (see [Styling](docs/styling.html) for a list). Must use SCSS/CSS variables (e.g. `$primary or var(--text-primary)`) so themes and light/dark mode keep working wel.
-- **Structure** — Keep things modular; follow the existing folder structure (e.g. one folder per plugin, one file or folder per engine).
+- **Functions that return a value** - use arrow functions (e.g. `const getX = () => value`).
+- **Functions that do not return** (side-effect only) - use regular `function` declarations.
+- **Internal / private helpers** - prepend with `_` (e.g. `_parseQuery`, `_formatDate`).
+- **CSS** - Reuse existing app classes where possible (see [Styling](docs/styling.html) for a list). Must use SCSS/CSS variables (e.g. `$primary or var(--text-primary)`) so themes and light/dark mode keep working wel.
+- **Structure** - Keep things modular; follow the existing folder structure (e.g. one folder per plugin, one file or folder per engine).
diff --git a/docs/aliases.html b/docs/aliases.html
index c7864ab7..4f60490c 100644
--- a/docs/aliases.html
+++ b/docs/aliases.html
@@ -3,7 +3,7 @@
- Aliases — Degoog docs
+ Aliases - Degoog docs
`;
+ result = result.replace("", `${baseScript}\n `);
+ result = result.replace(
+ /(<(?:link|script|a|form)[^>]*(?:href|src|action)=")\/(?!\/)/g,
+ `$1${BASE_URL}/`,
+ );
+ }
+
+ return result;
}
function isFullDocument(html: string): boolean {
@@ -277,7 +291,9 @@ const _apiKeySectionLocked = `{{t:settings-page.server.
async function buildPage(filename: string, locale?: string): Promise {
let html = await Bun.file(`src/public/${filename}`).text();
if (html.includes("__API_KEY_SECTION__")) {
- const content = isPasswordRequired() ? _apiKeySection : _apiKeySectionLocked;
+ const content = isPasswordRequired()
+ ? _apiKeySection
+ : _apiKeySectionLocked;
html = html.replace("__API_KEY_SECTION__", content);
}
const t = await getTranslator(locale);
@@ -288,7 +304,7 @@ router.get("/", async (c) => {
const q = c.req.query("q");
if (q?.trim()) {
const params = new URLSearchParams(c.req.url.split("?")[1] || "");
- return c.redirect(`/search?${params.toString()}`, 302);
+ return c.redirect(`${BASE_URL || BASE_PATH}/search?${params.toString()}`, 302);
}
const locale = getLocale(c);
const override = await getThemeHtml("index");
@@ -348,7 +364,7 @@ router.get("/search", async (c) => {
return c.html(_injectIntoHead(html, actionsScript));
});
-router.get("/settings/", (c) => c.redirect("/settings", 301));
+router.get("/settings/", (c) => c.redirect(`${BASE_URL || BASE_PATH}/settings`, 301));
router.get("/settings", async (c) => {
const locale = getLocale(c);
if (isPublicInstance())
@@ -360,10 +376,10 @@ router.get("/settings", async (c) => {
});
router.get("/settings/:tab", async (c) => {
- if (isPublicInstance()) return c.redirect("/settings", 302);
+ if (isPublicInstance()) return c.redirect(`${BASE_URL || BASE_PATH}/settings`, 302);
const tab = c.req.param("tab");
if (!(SETTINGS_TABS as readonly string[]).includes(tab)) {
- return c.redirect("/settings", 302);
+ return c.redirect(`${BASE_URL || BASE_PATH}/settings`, 302);
}
const locale = getLocale(c);
if (await shouldServeSettingsGate(c)) {
@@ -387,7 +403,16 @@ router.get("/opensearch.xml", (c) => {
c.req.header("x-forwarded-host") ||
c.req.header("host") ||
new URL(c.req.url).host;
- return c.body(buildOpenSearchXml(`${proto}://${host}`), 200, {
+ const basePath = BASE_URL
+ ? (() => {
+ try {
+ return new URL(BASE_URL).pathname.replace(/\/+$/, "");
+ } catch {
+ return BASE_URL;
+ }
+ })()
+ : "";
+ return c.body(buildOpenSearchXml(`${proto}://${host}${basePath}`), 200, {
"Content-Type": "application/opensearchdescription+xml; charset=utf-8",
});
});
diff --git a/src/server/utils/base-url.ts b/src/server/utils/base-url.ts
new file mode 100644
index 00000000..bb3c2d72
--- /dev/null
+++ b/src/server/utils/base-url.ts
@@ -0,0 +1,16 @@
+const _baseUrl = (process.env.DEGOOG_BASE_URL ?? "").trim().replace(/\/+$/, "");
+
+const _basePath = (() => {
+ if (!_baseUrl) return "";
+ if (!/^https?:\/\//i.test(_baseUrl)) return _baseUrl;
+ try {
+ const u = new URL(_baseUrl);
+ const p = u.pathname.replace(/\/+$/, "");
+ return p === "/" ? "" : p;
+ } catch {
+ return _baseUrl;
+ }
+})();
+
+export const getBaseUrl = (): string => _baseUrl;
+export const getBasePath = (): string => _basePath;
From 5e19d8b24933033f050bdd34cb91dc9b3db14dd9 Mon Sep 17 00:00:00 2001
From: fccview
Date: Tue, 5 May 2026 10:35:26 +0100
Subject: [PATCH 27/28] bump that fucker, too much stuff for a minor release
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index da24c3b7..16296d44 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "degoog",
- "version": "0.15.1-develop",
+ "version": "0.16.0",
"type": "module",
"scripts": {
"dev": "bun run --watch src/server/index.ts",
From c929f3cbfae673c291255417bdd485ce9e32f29c Mon Sep 17 00:00:00 2001
From: fccview
Date: Tue, 5 May 2026 10:40:26 +0100
Subject: [PATCH 28/28] fix tests
---
tests/public/url.test.ts | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/tests/public/url.test.ts b/tests/public/url.test.ts
index 9a4ee9fc..bb47dc74 100644
--- a/tests/public/url.test.ts
+++ b/tests/public/url.test.ts
@@ -1,8 +1,14 @@
-import { describe, test, expect } from "bun:test";
+import { describe, test, expect, beforeAll } from "bun:test";
import { buildSearchUrl, proxyImageUrl, faviconUrl } from "../../src/client/utils/url";
import { state } from "../../src/client/state";
describe("public/url", () => {
+ beforeAll(() => {
+ const g = globalThis as unknown as { window?: { __DEGOOG_BASE_URL__?: string } };
+ if (!g.window) g.window = {};
+ g.window.__DEGOOG_BASE_URL__ = "";
+ });
+
test("proxyImageUrl returns empty for empty url", () => {
expect(proxyImageUrl("")).toBe("");
});
Plugin context and init()
// ctx.readFile is an async function to read any file from your plugin folder // ctx.signProxyUrl(url) returns a signed /api/proxy/image URL for any external image // ctx.fetch(url, init?) is a proxy-aware fetch, respects your outgoing proxy settings + // ctx.createCache(defaultTtlMs) returns an in-memory TTL cache { get, set, clear } } +TTL cache (createCache)
+
+ Both init(ctx) and slot execute(query, context)
+ receive createCache: a factory compatible with
+ the built-in server helper. Call
+ ctx.createCache<T>(defaultTtlMs) (or
+ context.createCache<T>(defaultTtlMs) in slots) to get an
+ object with get(key), set(key, value, ttlMs?), and
+ clear(). Keys are strings; values are typed by your generic
+ T. Entries expire automatically after the TTL (milliseconds).
+
+ Built-in and third-party code paths share this API: typical usage is to
+ call createCache once in init, keep the returned
+ cache on a module-level variable, and reuse get /
+ set from execute (for example for fetched page
+ excerpts). Prefer always using
+ ctx.createCache / context.createCache rather than
+ importing cache helpers from server internals so plugins behave the same in
+ every environment.
+
Store ctx.fetch during init if your plugin
makes outgoing HTTP requests outside of execute(), for
@@ -400,11 +422,14 @@
Slot plugins
execute(query, context) (async): Return an object with an optionaltitle string and an
html string. If you return an empty string for the
- html, nothing will show up. The context contains the
- clientIp, an array of results, and
- signProxyUrl(url) for proxying external images. Note
- that the results array is only populated if you set
- waitForResults: true on your plugin.
+ html, nothing will show up. The context includes
+ clientIp; results (only when
+ waitForResults: true); proxy-aware
+ fetch(url, init?); signProxyUrl(url) for
+ external images; and createCache(defaultTtlMs) for the same
+ TTL cache factory as in init(ctx). Note that the results
+ array is only populated if you set waitForResults: true on
+ your plugin.
diff --git a/src/server/routes/slots.ts b/src/server/routes/slots.ts
index 3aa9898f..f7a5bc8c 100644
--- a/src/server/routes/slots.ts
+++ b/src/server/routes/slots.ts
@@ -90,7 +90,7 @@ router.post("/api/slots/glance", async (c) => {
position: plugin.position,
gridSize: plugin.gridSize,
});
- } catch {}
+ } catch { }
}
return c.json({ panels });
});
diff --git a/src/server/utils/search.ts b/src/server/utils/search.ts
index 8b4604a7..41a07474 100644
--- a/src/server/utils/search.ts
+++ b/src/server/utils/search.ts
@@ -204,7 +204,7 @@ export async function runSlotPlugins(
position: effectivePosition,
gridSize: plugin.gridSize,
});
- } catch {}
+ } catch { }
}
return panels;
}
From 64565b1b03064de49c7082c9d9e66f9633fb1c9f Mon Sep 17 00:00:00 2001
From: fccview {{t:settings-page.server.
async function buildPage(filename: string, locale?: string): Promise