termsearch 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/config.example.json +11 -0
- package/frontend/dist/app.js +240 -48
- package/frontend/dist/style.css +115 -7
- package/package.json +1 -1
- package/src/ai/orchestrator.js +19 -0
- package/src/api/routes.js +5 -3
- package/src/config/defaults.js +14 -0
- package/src/config/manager.js +11 -1
- package/src/search/engine.js +37 -5
- package/src/search/providers/ahmia.js +61 -0
- package/src/search/providers/marginalia.js +49 -0
- package/src/search/providers/yandex.js +68 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# TermSearch - Personal Search Engine
|
|
2
2
|
|
|
3
|
-
[](#project-status)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](https://nodejs.org)
|
|
6
6
|
[](https://termux.dev)
|
|
@@ -24,7 +24,7 @@ Core capabilities:
|
|
|
24
24
|
|
|
25
25
|
## Project Status
|
|
26
26
|
|
|
27
|
-
- Current line: `0.3.
|
|
27
|
+
- Current line: `0.3.3`
|
|
28
28
|
- Core is MIT — zero required API keys
|
|
29
29
|
- AI features are optional, configured via Settings page in browser
|
|
30
30
|
- Tested on: Ubuntu 24.04, Termux (Android 15/16)
|
|
@@ -112,7 +112,7 @@ All providers use the OpenAI-compatible `/chat/completions` format. Leave API ke
|
|
|
112
112
|
src/
|
|
113
113
|
config/ config manager — load/save/defaults/env overrides
|
|
114
114
|
search/
|
|
115
|
-
providers/ DuckDuckGo, Wikipedia, Brave, Mojeek, SearXNG, GitHub API
|
|
115
|
+
providers/ DuckDuckGo, Wikipedia, Brave, Mojeek, SearXNG, GitHub API, Yandex, Ahmia, Marginalia
|
|
116
116
|
engine.js fan-out, merge, rank, cache
|
|
117
117
|
ranking.js source diversity ranking
|
|
118
118
|
cache.js tiered cache (L1 Map + L2 disk JSON)
|
|
@@ -177,6 +177,8 @@ TERMSEARCH_AI_API_KEY=
|
|
|
177
177
|
TERMSEARCH_AI_MODEL=glm-4.7
|
|
178
178
|
TERMSEARCH_BRAVE_API_KEY=
|
|
179
179
|
TERMSEARCH_MOJEEK_API_KEY=
|
|
180
|
+
TERMSEARCH_MARGINALIA_API_KEY=public
|
|
181
|
+
TERMSEARCH_MARGINALIA_API_BASE=https://api2.marginalia-search.com
|
|
180
182
|
TERMSEARCH_SEARXNG_URL=
|
|
181
183
|
TERMSEARCH_GITHUB_TOKEN=
|
|
182
184
|
TERMSEARCH_INSTAGRAM_SESSION=
|
package/config.example.json
CHANGED
|
@@ -24,6 +24,17 @@
|
|
|
24
24
|
"enabled": false,
|
|
25
25
|
"api_key": ""
|
|
26
26
|
},
|
|
27
|
+
"yandex": {
|
|
28
|
+
"enabled": true
|
|
29
|
+
},
|
|
30
|
+
"ahmia": {
|
|
31
|
+
"enabled": true
|
|
32
|
+
},
|
|
33
|
+
"marginalia": {
|
|
34
|
+
"enabled": true,
|
|
35
|
+
"api_key": "public",
|
|
36
|
+
"api_base": "https://api2.marginalia-search.com"
|
|
37
|
+
},
|
|
27
38
|
"searxng": {
|
|
28
39
|
"enabled": false,
|
|
29
40
|
"url": ""
|
package/frontend/dist/app.js
CHANGED
|
@@ -9,6 +9,16 @@ const state = {
|
|
|
9
9
|
aiStatus: 'idle',
|
|
10
10
|
aiError: null,
|
|
11
11
|
aiMeta: null,
|
|
12
|
+
aiProgress: 0,
|
|
13
|
+
aiSteps: [],
|
|
14
|
+
aiSources: [],
|
|
15
|
+
aiExpanded: false,
|
|
16
|
+
aiStartTime: null,
|
|
17
|
+
aiLatencyMs: null,
|
|
18
|
+
aiSession: [], // { q, r }[] — ultimi 4 summary (passati all'AI come contesto)
|
|
19
|
+
aiLastQuery: null,
|
|
20
|
+
aiLastResults: null,
|
|
21
|
+
aiLastLang: null,
|
|
12
22
|
profilerData: null,
|
|
13
23
|
profilerLoading: false,
|
|
14
24
|
torrentData: [],
|
|
@@ -35,6 +45,47 @@ const state = {
|
|
|
35
45
|
})(),
|
|
36
46
|
};
|
|
37
47
|
|
|
48
|
+
let mobileBarCleanup = null;
|
|
49
|
+
|
|
50
|
+
function clearMobileBarLayout() {
|
|
51
|
+
if (typeof mobileBarCleanup === 'function') {
|
|
52
|
+
mobileBarCleanup();
|
|
53
|
+
}
|
|
54
|
+
mobileBarCleanup = null;
|
|
55
|
+
document.documentElement.style.setProperty('--mobile-bar-height', '0px');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function bindMobileBarLayout(mobileBar) {
|
|
59
|
+
clearMobileBarLayout();
|
|
60
|
+
if (!mobileBar) return;
|
|
61
|
+
|
|
62
|
+
const media = window.matchMedia('(max-width: 640px)');
|
|
63
|
+
const root = document.documentElement;
|
|
64
|
+
const update = () => {
|
|
65
|
+
if (!media.matches || !mobileBar.isConnected) {
|
|
66
|
+
root.style.setProperty('--mobile-bar-height', '0px');
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const h = Math.ceil(mobileBar.getBoundingClientRect().height);
|
|
70
|
+
root.style.setProperty('--mobile-bar-height', `${h}px`);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const onResize = () => update();
|
|
74
|
+
window.addEventListener('resize', onResize);
|
|
75
|
+
|
|
76
|
+
let observer = null;
|
|
77
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
78
|
+
observer = new ResizeObserver(update);
|
|
79
|
+
observer.observe(mobileBar);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
requestAnimationFrame(update);
|
|
83
|
+
mobileBarCleanup = () => {
|
|
84
|
+
window.removeEventListener('resize', onResize);
|
|
85
|
+
if (observer) observer.disconnect();
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
38
89
|
function buildSearchHash(query, category = 'web') {
|
|
39
90
|
const params = new URLSearchParams();
|
|
40
91
|
if (query) params.set('q', query);
|
|
@@ -140,6 +191,16 @@ function setSelectedEngines(engines) {
|
|
|
140
191
|
persistSelectedEngines();
|
|
141
192
|
}
|
|
142
193
|
|
|
194
|
+
function sanitizeHttpUrl(raw) {
|
|
195
|
+
try {
|
|
196
|
+
const url = new URL(String(raw || '').trim());
|
|
197
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
|
|
198
|
+
return url.toString();
|
|
199
|
+
} catch {
|
|
200
|
+
return '';
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
143
204
|
// ─── SVG Icons ────────────────────────────────────────────────────────────
|
|
144
205
|
function svg(paths, size = 16, extra = '') {
|
|
145
206
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" ${extra}>${paths}</svg>`;
|
|
@@ -200,17 +261,18 @@ const LANGS = [
|
|
|
200
261
|
];
|
|
201
262
|
|
|
202
263
|
const AI_PRESETS = [
|
|
203
|
-
{ id: '
|
|
204
|
-
{ id: '
|
|
205
|
-
{ id: '
|
|
206
|
-
{ id: '
|
|
207
|
-
{ id: '
|
|
208
|
-
{ id: '
|
|
209
|
-
{ id: '
|
|
264
|
+
{ id: 'ollama', label: 'LocalHost — Ollama', api_base: 'http://127.0.0.1:11434/v1', keyRequired: false, defaultModel: 'qwen3.5:4b' },
|
|
265
|
+
{ id: 'lmstudio', label: 'LocalHost — LM Studio', api_base: 'http://127.0.0.1:1234/v1', keyRequired: false, defaultModel: '' },
|
|
266
|
+
{ id: 'llamacpp', label: 'LocalHost — llama.cpp', api_base: 'http://127.0.0.1:8080/v1', keyRequired: false, defaultModel: '' },
|
|
267
|
+
{ id: 'chutes', label: 'Chutes.ai TEE', api_base: 'https://llm.chutes.ai/v1', keyRequired: true, defaultModel: 'deepseek-ai/DeepSeek-V3.2-TEE' },
|
|
268
|
+
{ id: 'anthropic',label: 'Anthropic', api_base: 'https://api.anthropic.com/v1', keyRequired: true, defaultModel: 'claude-3-5-haiku-latest' },
|
|
269
|
+
{ id: 'openai', label: 'OpenAI', api_base: 'https://api.openai.com/v1', keyRequired: true, defaultModel: 'gpt-4o-mini' },
|
|
270
|
+
{ id: 'openrouter', label: 'OpenRoute/OpenRouter', api_base: 'https://openrouter.ai/api/v1', keyRequired: true, defaultModel: 'openai/gpt-4o-mini' },
|
|
210
271
|
];
|
|
211
272
|
|
|
212
273
|
const ENGINE_GROUPS = [
|
|
213
274
|
{ label: 'Web Core', items: ['duckduckgo', 'wikipedia', 'brave', 'startpage', 'qwant', 'mojeek', 'bing', 'google', 'yahoo'] },
|
|
275
|
+
{ label: 'Uncensored', items: ['yandex', 'marginalia', 'ahmia'] },
|
|
214
276
|
{ label: 'Code & Dev', items: ['github', 'github-api', 'hackernews', 'reddit'] },
|
|
215
277
|
{ label: 'Media', items: ['youtube', 'sepiasearch'] },
|
|
216
278
|
{ label: 'Research', items: ['wikidata', 'crossref', 'openalex', 'openlibrary'] },
|
|
@@ -419,37 +481,119 @@ function renderAiPanel() {
|
|
|
419
481
|
if (!isActive) { panel.style.display = 'none'; return; }
|
|
420
482
|
panel.style.display = 'block';
|
|
421
483
|
|
|
422
|
-
const
|
|
423
|
-
const
|
|
484
|
+
const isLoading = state.aiStatus === 'loading' || state.aiStatus === 'streaming';
|
|
485
|
+
const isDone = state.aiStatus === 'done';
|
|
486
|
+
const isError = state.aiStatus === 'error';
|
|
487
|
+
const dotsClass = isDone ? 'done' : isError ? 'error' : '';
|
|
424
488
|
|
|
489
|
+
// Row 1: dots + "AI" (always) + model + latency
|
|
425
490
|
const dotsEl = el('div', { className: 'ai-dots' });
|
|
426
491
|
['violet', 'indigo', 'dim'].forEach(c => {
|
|
427
492
|
dotsEl.append(el('div', { className: `ai-dot ${dotsClass || c}` }));
|
|
428
493
|
});
|
|
494
|
+
const latMs = state.aiLatencyMs;
|
|
495
|
+
const latLabel = latMs != null ? (latMs < 1000 ? `${latMs}ms` : `${(latMs / 1000).toFixed(1)}s`) : null;
|
|
496
|
+
const row1 = el('div', { className: 'panel-header-left' },
|
|
497
|
+
dotsEl,
|
|
498
|
+
el('span', { className: 'panel-label' }, 'AI'),
|
|
499
|
+
state.aiMeta?.model ? el('span', { className: 'ai-model-label' }, state.aiMeta.model) : null,
|
|
500
|
+
latLabel ? el('span', { className: 'ai-latency-label' }, `· ${latLabel}`) : null,
|
|
501
|
+
);
|
|
429
502
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
503
|
+
// Row 2: status text (violet) + step/fetch meta
|
|
504
|
+
const statusText = isError ? 'Error'
|
|
505
|
+
: isDone ? 'Done'
|
|
506
|
+
: state.aiStatus === 'loading' ? 'Thinking…' : 'Generating…';
|
|
507
|
+
const statusColor = isError ? '#f87171' : isDone ? '#a78bfa' : '#a78bfa';
|
|
508
|
+
const lastStep = state.aiSteps.length > 0 ? state.aiSteps[state.aiSteps.length - 1] : null;
|
|
509
|
+
const metaText = isDone && state.aiMeta?.fetchedCount ? `· ${state.aiMeta.fetchedCount} pages read`
|
|
510
|
+
: isLoading && lastStep ? `· ${lastStep}` : '';
|
|
511
|
+
const row2 = el('div', { className: 'ai-status-row' },
|
|
512
|
+
el('span', { className: 'ai-status-text', style: `color:${statusColor}` }, statusText),
|
|
513
|
+
metaText ? el('span', { className: 'ai-status-meta' }, metaText) : null,
|
|
436
514
|
);
|
|
437
515
|
|
|
438
|
-
const
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
516
|
+
const chevronPath = state.aiExpanded ? '<polyline points="18 15 12 9 6 15"/>' : '<polyline points="6 9 12 15 18 9"/>';
|
|
517
|
+
const expandBtn = el('button', { className: 'ai-expand-btn', type: 'button', title: state.aiExpanded ? 'Collapse' : 'Expand' });
|
|
518
|
+
expandBtn.innerHTML = svg(chevronPath, 14);
|
|
519
|
+
expandBtn.onclick = () => { state.aiExpanded = !state.aiExpanded; renderAiPanel(); };
|
|
520
|
+
|
|
521
|
+
const headerInner = el('div', { className: 'ai-header-inner' }, row1, row2);
|
|
522
|
+
const header = el('div', { className: 'panel-header' }, headerInner, expandBtn);
|
|
523
|
+
|
|
524
|
+
// Progress bar
|
|
525
|
+
const showProgress = isLoading && state.aiProgress > 0;
|
|
526
|
+
const progressEl = showProgress ? el('div', { className: 'ai-progress-wrap' },
|
|
527
|
+
el('div', { className: 'ai-progress-bar', style: `width:${state.aiProgress}%` }),
|
|
528
|
+
) : null;
|
|
529
|
+
|
|
530
|
+
// Steps
|
|
531
|
+
const showSteps = isLoading && state.aiSteps.length > 0;
|
|
532
|
+
const stepsEl = showSteps ? el('div', { className: 'ai-steps' },
|
|
533
|
+
...state.aiSteps.slice(-4).map(s => el('div', { className: 'ai-step' }, s)),
|
|
534
|
+
) : null;
|
|
535
|
+
|
|
536
|
+
// Content
|
|
537
|
+
const contentEl = el('div', { className: `ai-content${!state.aiExpanded && !isLoading ? ' ai-content-collapsed' : ''}` });
|
|
538
|
+
if (isError) {
|
|
539
|
+
contentEl.style.color = '#f87171';
|
|
540
|
+
contentEl.textContent = state.aiError;
|
|
442
541
|
} else {
|
|
443
|
-
|
|
542
|
+
contentEl.textContent = state.aiSummary;
|
|
444
543
|
}
|
|
445
544
|
|
|
446
|
-
|
|
447
|
-
|
|
545
|
+
// Sources (shown when expanded + done)
|
|
546
|
+
const showSources = isDone && state.aiExpanded && state.aiSources.length > 0;
|
|
547
|
+
const sourcesEl = showSources ? el('div', { className: 'ai-sources' },
|
|
548
|
+
...state.aiSources.slice(0, 8).map((src, i) => {
|
|
549
|
+
const safeSrc = sanitizeHttpUrl(src);
|
|
550
|
+
if (!safeSrc) return null;
|
|
551
|
+
let label = src;
|
|
552
|
+
try {
|
|
553
|
+
const { hostname, pathname } = new URL(safeSrc);
|
|
554
|
+
const host = hostname.replace(/^www\./, '');
|
|
555
|
+
const segs = pathname.replace(/\/$/, '').split('/').filter(Boolean).slice(0, 2);
|
|
556
|
+
label = segs.length ? `${host} › ${segs.join('/')}` : host;
|
|
557
|
+
} catch {}
|
|
558
|
+
const a = el('a', { className: 'ai-source-pill', href: safeSrc, target: '_blank', rel: 'noopener noreferrer' }, `[${i + 1}] ${label}`);
|
|
559
|
+
return a;
|
|
560
|
+
}),
|
|
561
|
+
) : null;
|
|
562
|
+
|
|
563
|
+
// Session memory (shown when expanded + more than 1 entry, all except current)
|
|
564
|
+
const prevSession = state.aiSession.slice(0, -1);
|
|
565
|
+
const sessionEl = (state.aiExpanded && prevSession.length > 0) ? el('div', { className: 'ai-session' },
|
|
566
|
+
el('p', { className: 'ai-session-label' }, 'Session'),
|
|
567
|
+
...prevSession.map((item, i) =>
|
|
568
|
+
el('div', { className: 'ai-session-item' },
|
|
569
|
+
el('span', { className: 'ai-session-num' }, `${i + 1}.`),
|
|
570
|
+
el('span', { className: 'ai-session-q' }, item.q),
|
|
571
|
+
el('span', { className: 'ai-session-r' }, `→ ${item.r}`),
|
|
572
|
+
)
|
|
573
|
+
),
|
|
574
|
+
) : null;
|
|
448
575
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
576
|
+
// Footer: retry + expand/collapse
|
|
577
|
+
const retryBtn = el('button', { className: 'ai-retry-btn', type: 'button' }, 'Retry');
|
|
578
|
+
retryBtn.onclick = () => {
|
|
579
|
+
if (state.aiLastQuery) startAiSummary(state.aiLastQuery, state.aiLastResults || [], state.aiLastLang || 'en-US');
|
|
580
|
+
};
|
|
581
|
+
const toggleBtn = el('button', { className: 'ai-toggle-btn', type: 'button' },
|
|
582
|
+
state.aiExpanded ? 'Show less' : 'Show more',
|
|
583
|
+
);
|
|
584
|
+
toggleBtn.onclick = () => { state.aiExpanded = !state.aiExpanded; renderAiPanel(); };
|
|
585
|
+
const footer = el('div', { className: 'ai-footer' }, retryBtn, toggleBtn);
|
|
586
|
+
|
|
587
|
+
panel.innerHTML = '';
|
|
588
|
+
panel.append(header);
|
|
589
|
+
if (progressEl) panel.append(progressEl);
|
|
590
|
+
if (stepsEl) panel.append(stepsEl);
|
|
591
|
+
panel.append(contentEl);
|
|
592
|
+
if (sourcesEl) panel.append(sourcesEl);
|
|
593
|
+
if (sessionEl) panel.append(sessionEl);
|
|
594
|
+
panel.append(footer);
|
|
595
|
+
|
|
596
|
+
if (state.aiStatus === 'streaming' && state.aiSummary.length < 60) {
|
|
453
597
|
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
454
598
|
}
|
|
455
599
|
}
|
|
@@ -793,13 +937,22 @@ async function doSearch(q, category = state.category) {
|
|
|
793
937
|
async function startAiSummary(query, results, lang) {
|
|
794
938
|
state.aiStatus = 'loading';
|
|
795
939
|
state.aiSummary = '';
|
|
940
|
+
state.aiError = null;
|
|
941
|
+
state.aiProgress = 0;
|
|
942
|
+
state.aiSteps = [];
|
|
943
|
+
state.aiSources = [];
|
|
944
|
+
state.aiStartTime = Date.now();
|
|
945
|
+
state.aiLatencyMs = null;
|
|
946
|
+
state.aiLastQuery = query;
|
|
947
|
+
state.aiLastResults = results;
|
|
948
|
+
state.aiLastLang = lang;
|
|
796
949
|
renderAiPanel();
|
|
797
950
|
|
|
798
951
|
try {
|
|
799
952
|
const r = await fetch('/api/ai-summary', {
|
|
800
953
|
method: 'POST',
|
|
801
954
|
headers: { 'Content-Type': 'application/json' },
|
|
802
|
-
body: JSON.stringify({ query, lang, results: results.slice(0, 10), stream: true }),
|
|
955
|
+
body: JSON.stringify({ query, lang, results: results.slice(0, 10), stream: true, session: state.aiSession }),
|
|
803
956
|
});
|
|
804
957
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
805
958
|
|
|
@@ -819,13 +972,28 @@ async function startAiSummary(query, results, lang) {
|
|
|
819
972
|
if (!line.startsWith('data: ')) continue;
|
|
820
973
|
try {
|
|
821
974
|
const d = JSON.parse(line.slice(6));
|
|
822
|
-
if (d.chunk)
|
|
823
|
-
else if (d.
|
|
824
|
-
else if (d.
|
|
975
|
+
if (d.chunk !== undefined) { state.aiSummary += d.chunk; renderAiPanel(); }
|
|
976
|
+
else if (d.progress !== undefined) { state.aiProgress = d.progress; renderAiPanel(); }
|
|
977
|
+
else if (d.step) { state.aiSteps = [...state.aiSteps.slice(-3), d.step]; renderAiPanel(); }
|
|
978
|
+
else if (d.error) { state.aiStatus = 'error'; state.aiError = d.message || d.error; renderAiPanel(); }
|
|
979
|
+
else if (d.model != null || d.sites != null) {
|
|
980
|
+
state.aiStatus = 'done';
|
|
981
|
+
state.aiProgress = 100;
|
|
982
|
+
state.aiSources = Array.isArray(d.sites) ? d.sites.map(sanitizeHttpUrl).filter(Boolean) : [];
|
|
983
|
+
state.aiMeta = { fetchedCount: d.fetchedCount, model: d.model };
|
|
984
|
+
state.aiLatencyMs = Date.now() - state.aiStartTime;
|
|
985
|
+
// Save to session memory (max 4 entries, skip if already saved for this query)
|
|
986
|
+
const lastEntry = state.aiSession[state.aiSession.length - 1];
|
|
987
|
+
if (state.aiSummary && (!lastEntry || lastEntry.q !== query)) {
|
|
988
|
+
const r = state.aiSummary.split(/[.!?\n]/)[0].slice(0, 100);
|
|
989
|
+
state.aiSession = [...state.aiSession.slice(-3), { q: query, r }];
|
|
990
|
+
}
|
|
991
|
+
renderAiPanel();
|
|
992
|
+
}
|
|
825
993
|
} catch { /* ignore */ }
|
|
826
994
|
}
|
|
827
995
|
}
|
|
828
|
-
if (state.aiStatus === 'streaming') { state.aiStatus = 'done'; renderAiPanel(); }
|
|
996
|
+
if (state.aiStatus === 'streaming') { state.aiStatus = 'done'; state.aiLatencyMs = Date.now() - state.aiStartTime; renderAiPanel(); }
|
|
829
997
|
} catch (e) {
|
|
830
998
|
state.aiStatus = 'error';
|
|
831
999
|
state.aiError = e.message;
|
|
@@ -840,12 +1008,13 @@ function renderApp() {
|
|
|
840
1008
|
app.innerHTML = '';
|
|
841
1009
|
|
|
842
1010
|
if (!state.query) {
|
|
1011
|
+
clearMobileBarLayout();
|
|
843
1012
|
renderHome(app);
|
|
844
1013
|
return;
|
|
845
1014
|
}
|
|
846
1015
|
|
|
847
1016
|
// Results page
|
|
848
|
-
const header = el('div', { className: 'header' },
|
|
1017
|
+
const header = el('div', { className: 'header hide-mobile' },
|
|
849
1018
|
el('div', { className: 'logo-text', onClick: () => { state.query = ''; state.category = 'web'; navigate('#/'); renderApp(); } },
|
|
850
1019
|
'Term', el('strong', {}, 'Search'),
|
|
851
1020
|
),
|
|
@@ -856,26 +1025,47 @@ function renderApp() {
|
|
|
856
1025
|
el('button', { className: 'btn-icon', title: 'Toggle theme', onClick: toggleTheme }, iconEl('theme')),
|
|
857
1026
|
),
|
|
858
1027
|
);
|
|
859
|
-
|
|
1028
|
+
|
|
1029
|
+
const categoryBar = el('div', { className: 'category-tabs hide-mobile' });
|
|
860
1030
|
const categories = [
|
|
861
1031
|
{ id: 'web', label: 'Web' },
|
|
862
1032
|
{ id: 'images', label: 'Images' },
|
|
863
1033
|
{ id: 'news', label: 'News' },
|
|
864
1034
|
];
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
1035
|
+
const buildCatTabs = (container) => {
|
|
1036
|
+
categories.forEach((cat) => {
|
|
1037
|
+
container.append(el('button', {
|
|
1038
|
+
className: `cat-tab ${state.category === cat.id ? 'active' : ''}`,
|
|
1039
|
+
onClick: () => {
|
|
1040
|
+
if (state.category === cat.id) return;
|
|
1041
|
+
state.category = cat.id;
|
|
1042
|
+
navigate(buildSearchHash(state.query, state.category));
|
|
1043
|
+
if (state.query) doSearch(state.query, state.category);
|
|
1044
|
+
},
|
|
1045
|
+
type: 'button',
|
|
1046
|
+
}, cat.label));
|
|
1047
|
+
});
|
|
1048
|
+
};
|
|
1049
|
+
buildCatTabs(categoryBar);
|
|
877
1050
|
categoryBar.append(EnginePicker());
|
|
878
1051
|
|
|
1052
|
+
const mobileTabs = el('div', { className: 'mobile-bar-tabs' });
|
|
1053
|
+
buildCatTabs(mobileTabs);
|
|
1054
|
+
const mobileBar = el('div', { className: 'mobile-bar' },
|
|
1055
|
+
el('div', { className: 'mobile-bar-search' }, SearchForm(state.query, (q, cat) => { state.query = q; doSearch(q, cat); })),
|
|
1056
|
+
mobileTabs,
|
|
1057
|
+
el('div', { className: 'mobile-bar-engine' }, EnginePicker()),
|
|
1058
|
+
el('div', { className: 'mobile-bar-row' },
|
|
1059
|
+
el('div', {
|
|
1060
|
+
className: 'mobile-logo',
|
|
1061
|
+
onClick: () => { state.query = ''; state.category = 'web'; navigate('#/'); renderApp(); },
|
|
1062
|
+
}, 'Term', el('strong', {}, 'Search')),
|
|
1063
|
+
LangPicker(),
|
|
1064
|
+
el('button', { className: 'btn-icon', title: 'Settings', onClick: () => navigate('#/settings') }, iconEl('settings')),
|
|
1065
|
+
el('button', { className: 'btn-icon', title: 'Toggle theme', onClick: toggleTheme }, iconEl('theme')),
|
|
1066
|
+
),
|
|
1067
|
+
);
|
|
1068
|
+
|
|
879
1069
|
const main = el('div', { className: 'main' });
|
|
880
1070
|
|
|
881
1071
|
// AI panel placeholder
|
|
@@ -919,7 +1109,8 @@ function renderApp() {
|
|
|
919
1109
|
if (socPanel) main.append(socPanel);
|
|
920
1110
|
}
|
|
921
1111
|
|
|
922
|
-
app.append(header, categoryBar, main);
|
|
1112
|
+
app.append(header, categoryBar, main, mobileBar);
|
|
1113
|
+
bindMobileBarLayout(mobileBar);
|
|
923
1114
|
renderAiPanel();
|
|
924
1115
|
}
|
|
925
1116
|
|
|
@@ -954,6 +1145,7 @@ async function renderSettings() {
|
|
|
954
1145
|
const app = document.getElementById('app');
|
|
955
1146
|
if (!app) return;
|
|
956
1147
|
app.innerHTML = '';
|
|
1148
|
+
clearMobileBarLayout();
|
|
957
1149
|
|
|
958
1150
|
let cfg = state.config;
|
|
959
1151
|
if (!cfg) {
|
|
@@ -1278,7 +1470,7 @@ async function renderSettings() {
|
|
|
1278
1470
|
el('label', { className: 'form-label', for: 'ai-base' }, 'API Endpoint'),
|
|
1279
1471
|
makeInput('ai-base', ai.api_base, 'http://localhost:11434/v1'),
|
|
1280
1472
|
el('div', { className: 'form-hint' },
|
|
1281
|
-
'Included presets:
|
|
1473
|
+
'Included presets: LocalHost (Ollama · LM Studio · llama.cpp) · Chutes.ai TEE · Anthropic · OpenAI · OpenRoute/OpenRouter',
|
|
1282
1474
|
el('br', {}),
|
|
1283
1475
|
'You can also keep custom OpenAI-compatible endpoints.',
|
|
1284
1476
|
),
|
|
@@ -1395,7 +1587,7 @@ async function renderSettings() {
|
|
|
1395
1587
|
// Server info
|
|
1396
1588
|
el('div', { className: 'settings-section' },
|
|
1397
1589
|
el('h2', {}, 'Server Info'),
|
|
1398
|
-
el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Version'), el('span', { className: 'info-val' }, health?.version || '0.3.
|
|
1590
|
+
el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Version'), el('span', { className: 'info-val' }, health?.version || '0.3.3')),
|
|
1399
1591
|
el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Active providers'), el('span', { className: 'info-val' }, (health?.providers || []).join(', ') || 'none')),
|
|
1400
1592
|
el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'AI'), el('span', { className: 'info-val' }, health?.ai_enabled ? `enabled (${health.ai_model})` : 'not configured')),
|
|
1401
1593
|
el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'GitHub'), el('a', { href: 'https://github.com/DioNanos/termsearch', target: '_blank', className: 'info-val', style: 'color:var(--link)' }, 'DioNanos/termsearch')),
|
package/frontend/dist/style.css
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
--link: #8ab4f8;
|
|
14
14
|
--link-h: #aecbfa;
|
|
15
15
|
--url: #34a853;
|
|
16
|
+
--mobile-bar-height: 0px;
|
|
16
17
|
--radius: 12px;
|
|
17
18
|
--radius-sm: 8px;
|
|
18
19
|
--radius-xs: 6px;
|
|
@@ -565,6 +566,26 @@ a:hover { color: var(--link-h); }
|
|
|
565
566
|
.ai-dot.dim { background: #5b21b6; animation: pulse 1.4s ease-in-out 300ms infinite; }
|
|
566
567
|
.ai-dot.done { background: #34d399; animation: none; }
|
|
567
568
|
.ai-dot.error { background: #f87171; animation: none; }
|
|
569
|
+
.panel-ai .panel-header { cursor: default; align-items: flex-start; }
|
|
570
|
+
.ai-header-inner { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
|
571
|
+
.ai-status-row { display: flex; align-items: center; gap: 6px; }
|
|
572
|
+
.ai-status-text { font-size: 14px; font-weight: 500; }
|
|
573
|
+
.ai-status-meta { font-size: 10px; color: #4b5563; }
|
|
574
|
+
.ai-model-label { font-size: 10px; color: var(--text3); margin-left: 6px; }
|
|
575
|
+
.ai-latency-label{ font-size: 10px; color: #4b5563; margin-left: 4px; }
|
|
576
|
+
.ai-expand-btn { background: none; border: none; color: var(--text3); cursor: pointer; padding: 0; margin-left: 8px; margin-top: 2px; display: flex; align-items: center; flex-shrink: 0; }
|
|
577
|
+
.ai-expand-btn:hover { color: var(--text2); }
|
|
578
|
+
|
|
579
|
+
/* Progress bar */
|
|
580
|
+
.ai-progress-wrap { height: 3px; background: rgba(255,255,255,0.06); border-radius: 99px; margin: 8px 0; overflow: hidden; }
|
|
581
|
+
.ai-progress-bar { height: 100%; background: linear-gradient(90deg, #7c3aed, #6366f1); border-radius: 99px; transition: width 0.4s ease; }
|
|
582
|
+
|
|
583
|
+
/* Steps */
|
|
584
|
+
.ai-steps { margin: 6px 0 4px; display: flex; flex-direction: column; gap: 3px; }
|
|
585
|
+
.ai-step { font-size: 10px; color: #4b5563; display: flex; align-items: center; gap: 6px; }
|
|
586
|
+
.ai-step::before { content: ''; display: inline-block; width: 4px; height: 4px; border-radius: 50%; background: #6366f1; flex-shrink: 0; }
|
|
587
|
+
|
|
588
|
+
/* Content */
|
|
568
589
|
.ai-content {
|
|
569
590
|
font-size: 14px;
|
|
570
591
|
color: #d1d5db;
|
|
@@ -575,9 +596,40 @@ a:hover { color: var(--link-h); }
|
|
|
575
596
|
border: 1px solid rgba(129,140,248,0.22);
|
|
576
597
|
border-radius: var(--radius-sm);
|
|
577
598
|
padding: 10px;
|
|
578
|
-
|
|
579
|
-
overflow: auto;
|
|
599
|
+
overflow: hidden;
|
|
580
600
|
}
|
|
601
|
+
.ai-content.ai-content-collapsed {
|
|
602
|
+
display: -webkit-box;
|
|
603
|
+
-webkit-line-clamp: 4;
|
|
604
|
+
-webkit-box-orient: vertical;
|
|
605
|
+
overflow: hidden;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/* Sources */
|
|
609
|
+
.ai-sources { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
|
610
|
+
.ai-source-pill {
|
|
611
|
+
font-size: 10px; color: #6b7280;
|
|
612
|
+
border: 1px solid #1f2937; border-radius: 99px;
|
|
613
|
+
padding: 2px 8px; text-decoration: none;
|
|
614
|
+
white-space: nowrap; overflow: hidden; max-width: 200px; text-overflow: ellipsis;
|
|
615
|
+
transition: color 0.15s, border-color 0.15s;
|
|
616
|
+
}
|
|
617
|
+
.ai-source-pill:hover { color: #a78bfa; border-color: #4c1d95; }
|
|
618
|
+
|
|
619
|
+
/* Session memory */
|
|
620
|
+
.ai-session { margin-top: 10px; padding-top: 8px; border-top: 1px solid #1b1b2d; }
|
|
621
|
+
.ai-session-label { font-size: 10px; color: #374151; margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.05em; }
|
|
622
|
+
.ai-session-item { display: flex; align-items: baseline; gap: 5px; margin-bottom: 3px; flex-wrap: wrap; }
|
|
623
|
+
.ai-session-num { font-size: 9px; color: #374151; flex-shrink: 0; }
|
|
624
|
+
.ai-session-q { font-size: 10px; color: #4b5563; }
|
|
625
|
+
.ai-session-r { font-size: 10px; color: #374151; }
|
|
626
|
+
|
|
627
|
+
/* Footer */
|
|
628
|
+
.ai-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 10px; }
|
|
629
|
+
.ai-retry-btn { font-size: 12px; color: #a78bfa; background: none; border: none; cursor: pointer; padding: 0; }
|
|
630
|
+
.ai-retry-btn:hover { color: #c4b5fd; }
|
|
631
|
+
.ai-toggle-btn { font-size: 11px; color: #4b5563; background: none; border: none; cursor: pointer; padding: 0; }
|
|
632
|
+
.ai-toggle-btn:hover { color: #9ca3af; }
|
|
581
633
|
.ai-meta { font-size: 11px; color: var(--text3); margin-top: 8px; }
|
|
582
634
|
|
|
583
635
|
/* Profiler panel */
|
|
@@ -865,14 +917,70 @@ a:hover { color: var(--link-h); }
|
|
|
865
917
|
}
|
|
866
918
|
.footer-link:hover { color: var(--text2); }
|
|
867
919
|
|
|
920
|
+
/* ─── Mobile bottom bar ───────────────────────────────────────────────────── */
|
|
921
|
+
.mobile-bar {
|
|
922
|
+
display: none;
|
|
923
|
+
}
|
|
924
|
+
.mobile-logo {
|
|
925
|
+
font-size: 15px;
|
|
926
|
+
font-weight: 300;
|
|
927
|
+
letter-spacing: -0.2px;
|
|
928
|
+
color: var(--text);
|
|
929
|
+
white-space: nowrap;
|
|
930
|
+
user-select: none;
|
|
931
|
+
cursor: pointer;
|
|
932
|
+
}
|
|
933
|
+
.mobile-logo strong {
|
|
934
|
+
font-weight: 700;
|
|
935
|
+
background: linear-gradient(90deg, #a78bfa, #818cf8);
|
|
936
|
+
-webkit-background-clip: text;
|
|
937
|
+
-webkit-text-fill-color: transparent;
|
|
938
|
+
background-clip: text;
|
|
939
|
+
}
|
|
940
|
+
@media (max-width: 640px) {
|
|
941
|
+
.mobile-bar {
|
|
942
|
+
display: flex;
|
|
943
|
+
flex-direction: column;
|
|
944
|
+
position: fixed;
|
|
945
|
+
bottom: 0; left: 0; right: 0;
|
|
946
|
+
background: var(--bg);
|
|
947
|
+
border-top: 1px solid var(--border2);
|
|
948
|
+
box-shadow: 0 -12px 28px rgba(0,0,0,0.45);
|
|
949
|
+
z-index: 220;
|
|
950
|
+
padding-bottom: max(6px, env(safe-area-inset-bottom, 0px));
|
|
951
|
+
transform: translateZ(0);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
.mobile-bar-search { padding: 8px 12px 4px; }
|
|
955
|
+
.mobile-bar-search .search-form { width: 100%; }
|
|
956
|
+
.mobile-bar-tabs {
|
|
957
|
+
display: flex;
|
|
958
|
+
align-items: center;
|
|
959
|
+
gap: 4px;
|
|
960
|
+
padding: 2px 12px 4px;
|
|
961
|
+
overflow-x: auto;
|
|
962
|
+
scrollbar-width: none;
|
|
963
|
+
}
|
|
964
|
+
.mobile-bar-tabs::-webkit-scrollbar { display: none; }
|
|
965
|
+
.mobile-bar-engine { padding: 0 12px 4px; }
|
|
966
|
+
.mobile-bar-row {
|
|
967
|
+
display: flex;
|
|
968
|
+
align-items: center;
|
|
969
|
+
gap: 6px;
|
|
970
|
+
padding: 4px 12px 2px;
|
|
971
|
+
}
|
|
972
|
+
.mobile-bar-row .lang-wrap { margin-left: auto; }
|
|
973
|
+
|
|
868
974
|
/* ─── Responsive ──────────────────────────────────────────────────────────── */
|
|
869
975
|
@media (max-width: 640px) {
|
|
870
|
-
|
|
871
|
-
.category-tabs {
|
|
976
|
+
/* Hide desktop header + category bar — mobile uses bottom bar */
|
|
977
|
+
.header.hide-mobile, .category-tabs.hide-mobile { display: none !important; }
|
|
978
|
+
.main { padding-bottom: calc(20px + var(--mobile-bar-height, 0px) + env(safe-area-inset-bottom, 0px)); }
|
|
979
|
+
|
|
872
980
|
.cat-tab { font-size: 10px; padding: 4px 8px; }
|
|
873
|
-
.engine-picker { width: 100%; margin-left: 0; }
|
|
874
|
-
.engine-picker-summary { width: 100%; justify-content: space-between; }
|
|
875
|
-
.engine-picker-body { width: calc(100vw - 24px); right: -6px; }
|
|
981
|
+
.mobile-bar-engine .engine-picker { width: 100%; margin-left: 0; }
|
|
982
|
+
.mobile-bar-engine .engine-picker-summary { width: 100%; justify-content: space-between; }
|
|
983
|
+
.engine-picker-body { width: calc(100vw - 24px); right: -6px; bottom: calc(100% + 8px); top: auto; }
|
|
876
984
|
.logo-text { font-size: 15px; }
|
|
877
985
|
.home-logo { font-size: 40px; }
|
|
878
986
|
.home-tagline { letter-spacing: 0.08em; margin-bottom: 20px; }
|
package/package.json
CHANGED
package/src/ai/orchestrator.js
CHANGED
|
@@ -45,8 +45,14 @@ export async function generateSummary({
|
|
|
45
45
|
results = [],
|
|
46
46
|
session = [],
|
|
47
47
|
onToken = null,
|
|
48
|
+
onProgress = null,
|
|
49
|
+
onStep = null,
|
|
48
50
|
docCache = null,
|
|
49
51
|
}, aiConfig) {
|
|
52
|
+
const emit = (progress, step) => {
|
|
53
|
+
if (onProgress) onProgress(progress);
|
|
54
|
+
if (step && onStep) onStep(step);
|
|
55
|
+
};
|
|
50
56
|
if (!aiConfig?.enabled || !aiConfig?.api_base || !aiConfig?.model) {
|
|
51
57
|
return { error: 'ai_not_configured', message: 'AI not configured. Add endpoint in Settings.' };
|
|
52
58
|
}
|
|
@@ -61,6 +67,7 @@ export async function generateSummary({
|
|
|
61
67
|
|
|
62
68
|
try {
|
|
63
69
|
// Phase 1: AI decides which URLs to fetch
|
|
70
|
+
emit(5, 'Analyzing query…');
|
|
64
71
|
const phase1Prompt = buildFetchDecisionPrompt({
|
|
65
72
|
query,
|
|
66
73
|
results,
|
|
@@ -83,6 +90,15 @@ export async function generateSummary({
|
|
|
83
90
|
const allResultUrls = results.slice(0, 10).map((r) => r.url).filter(Boolean);
|
|
84
91
|
const { urls: urlsToFetch } = parseFetchDecision(phase1Result?.content, allResultUrls);
|
|
85
92
|
|
|
93
|
+
// Emit step per URL before batch fetch
|
|
94
|
+
emit(15, `Fetching ${urlsToFetch.length || allResultUrls.slice(0, 2).length} source(s)…`);
|
|
95
|
+
urlsToFetch.slice(0, 6).forEach((url) => {
|
|
96
|
+
try {
|
|
97
|
+
const host = new URL(url).hostname.replace(/^www\./, '');
|
|
98
|
+
if (onStep) onStep(`Reading: ${host}`);
|
|
99
|
+
} catch { if (onStep) onStep('Reading source…'); }
|
|
100
|
+
});
|
|
101
|
+
|
|
86
102
|
// Fetch the selected URLs
|
|
87
103
|
let documents = [];
|
|
88
104
|
if (urlsToFetch.length > 0) {
|
|
@@ -99,6 +115,8 @@ export async function generateSummary({
|
|
|
99
115
|
documents = fallback.filter((d) => d.status === 'ok' && d.content);
|
|
100
116
|
}
|
|
101
117
|
|
|
118
|
+
emit(60, `Synthesizing from ${documents.length} page(s)…`);
|
|
119
|
+
|
|
102
120
|
// Phase 2: synthesize summary
|
|
103
121
|
const phase2Prompt = buildAgenticSummaryPrompt({ query, lang, results, documents, session });
|
|
104
122
|
|
|
@@ -107,6 +125,7 @@ export async function generateSummary({
|
|
|
107
125
|
|
|
108
126
|
if (typeof onToken === 'function') {
|
|
109
127
|
// Streaming mode
|
|
128
|
+
emit(65, 'Generating summary…');
|
|
110
129
|
const streamResult = await stream(phase2Prompt, onToken, {
|
|
111
130
|
...ai,
|
|
112
131
|
systemPrompt: 'You are a search assistant. Write your answer directly. Do not include reasoning or thinking.',
|
package/src/api/routes.js
CHANGED
|
@@ -11,7 +11,7 @@ import { detectProfileTarget, scanProfile, PROFILER_PLATFORMS } from '../profile
|
|
|
11
11
|
import { fetchBlueskyPosts, fetchBlueskyActors, fetchGdeltArticles } from '../social/search.js';
|
|
12
12
|
import { scrapeTPB, scrape1337x, extractMagnetFromUrl } from '../torrent/scrapers.js';
|
|
13
13
|
|
|
14
|
-
const APP_VERSION = '0.3.
|
|
14
|
+
const APP_VERSION = '0.3.3';
|
|
15
15
|
const ALLOWED_CATEGORIES = new Set(['web', 'images', 'news']);
|
|
16
16
|
const ALLOWED_LANGS = new Set(['auto', 'it-IT', 'en-US', 'es-ES', 'fr-FR', 'de-DE', 'pt-PT', 'ru-RU', 'zh-CN', 'ja-JP']);
|
|
17
17
|
|
|
@@ -409,7 +409,9 @@ export function createRouter(config, rateLimiters) {
|
|
|
409
409
|
const result = await generateSummary(
|
|
410
410
|
{
|
|
411
411
|
query, lang, results, session,
|
|
412
|
-
onToken:
|
|
412
|
+
onToken: (chunk) => sendEvent('token', { chunk }),
|
|
413
|
+
onProgress: (p) => sendEvent('progress', { progress: p }),
|
|
414
|
+
onStep: (text) => sendEvent('step', { step: text }),
|
|
413
415
|
docCache: getDocCache(),
|
|
414
416
|
},
|
|
415
417
|
cfg.ai
|
|
@@ -450,7 +452,7 @@ export function createRouter(config, rateLimiters) {
|
|
|
450
452
|
return sendJson(res, 400, { error: 'invalid_body' });
|
|
451
453
|
}
|
|
452
454
|
// Whitelist accepted config keys to prevent unexpected writes
|
|
453
|
-
const allowed = ['port', 'host', 'ai', 'brave', 'mojeek', 'searxng', 'search', 'rate_limit'];
|
|
455
|
+
const allowed = ['port', 'host', 'ai', 'brave', 'mojeek', 'yandex', 'ahmia', 'marginalia', 'searxng', 'search', 'rate_limit'];
|
|
454
456
|
const filtered = {};
|
|
455
457
|
for (const key of allowed) {
|
|
456
458
|
if (key in body) filtered[key] = body[key];
|
package/src/config/defaults.js
CHANGED
|
@@ -49,6 +49,20 @@ export const DEFAULTS = {
|
|
|
49
49
|
api_base: 'https://api.mojeek.com',
|
|
50
50
|
},
|
|
51
51
|
|
|
52
|
+
yandex: {
|
|
53
|
+
enabled: true,
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
ahmia: {
|
|
57
|
+
enabled: true,
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
marginalia: {
|
|
61
|
+
enabled: true,
|
|
62
|
+
api_key: 'public',
|
|
63
|
+
api_base: 'https://api2.marginalia-search.com',
|
|
64
|
+
},
|
|
65
|
+
|
|
52
66
|
searxng: {
|
|
53
67
|
enabled: false,
|
|
54
68
|
url: '', // e.g. http://localhost:9090
|
package/src/config/manager.js
CHANGED
|
@@ -91,6 +91,12 @@ class ConfigManager {
|
|
|
91
91
|
if (process.env.TERMSEARCH_MOJEEK_API_KEY) {
|
|
92
92
|
overrides.mojeek = { api_key: process.env.TERMSEARCH_MOJEEK_API_KEY, enabled: true };
|
|
93
93
|
}
|
|
94
|
+
if (process.env.TERMSEARCH_MARGINALIA_API_KEY) {
|
|
95
|
+
overrides.marginalia = { api_key: process.env.TERMSEARCH_MARGINALIA_API_KEY, enabled: true };
|
|
96
|
+
}
|
|
97
|
+
if (process.env.TERMSEARCH_MARGINALIA_API_BASE) {
|
|
98
|
+
overrides.marginalia = { ...(overrides.marginalia || {}), api_base: process.env.TERMSEARCH_MARGINALIA_API_BASE, enabled: true };
|
|
99
|
+
}
|
|
94
100
|
if (process.env.TERMSEARCH_SEARXNG_URL) {
|
|
95
101
|
overrides.searxng = { url: process.env.TERMSEARCH_SEARXNG_URL, enabled: true };
|
|
96
102
|
}
|
|
@@ -113,6 +119,9 @@ class ConfigManager {
|
|
|
113
119
|
if (safePartial?.mojeek?.api_key && !safePartial?.mojeek?.hasOwnProperty('enabled')) {
|
|
114
120
|
this._config.mojeek.enabled = Boolean(this._config.mojeek.api_key);
|
|
115
121
|
}
|
|
122
|
+
if (safePartial?.marginalia?.api_key && !safePartial?.marginalia?.hasOwnProperty('enabled')) {
|
|
123
|
+
this._config.marginalia.enabled = Boolean(this._config.marginalia.api_key);
|
|
124
|
+
}
|
|
116
125
|
if (safePartial?.searxng?.url && !safePartial?.searxng?.hasOwnProperty('enabled')) {
|
|
117
126
|
this._config.searxng.enabled = Boolean(this._config.searxng.url);
|
|
118
127
|
}
|
|
@@ -120,7 +129,7 @@ class ConfigManager {
|
|
|
120
129
|
}
|
|
121
130
|
|
|
122
131
|
_sanitizeSensitiveKeys(partial) {
|
|
123
|
-
const sections = ['ai', 'brave', 'mojeek'];
|
|
132
|
+
const sections = ['ai', 'brave', 'mojeek', 'marginalia'];
|
|
124
133
|
for (const section of sections) {
|
|
125
134
|
const block = partial?.[section];
|
|
126
135
|
if (!block || typeof block !== 'object' || !Object.prototype.hasOwnProperty.call(block, 'api_key')) continue;
|
|
@@ -173,6 +182,7 @@ class ConfigManager {
|
|
|
173
182
|
ai: { ...c.ai, api_key: maskKey(c.ai.api_key) },
|
|
174
183
|
brave: { ...c.brave, api_key: maskKey(c.brave.api_key) },
|
|
175
184
|
mojeek: { ...c.mojeek, api_key: maskKey(c.mojeek.api_key) },
|
|
185
|
+
marginalia: { ...c.marginalia, api_key: maskKey(c.marginalia.api_key) },
|
|
176
186
|
};
|
|
177
187
|
}
|
|
178
188
|
}
|
package/src/search/engine.js
CHANGED
|
@@ -9,6 +9,9 @@ import * as brave from './providers/brave.js';
|
|
|
9
9
|
import * as mojeek from './providers/mojeek.js';
|
|
10
10
|
import * as searxng from './providers/searxng.js';
|
|
11
11
|
import * as github from './providers/github.js';
|
|
12
|
+
import * as yandex from './providers/yandex.js';
|
|
13
|
+
import * as ahmia from './providers/ahmia.js';
|
|
14
|
+
import * as marginalia from './providers/marginalia.js';
|
|
12
15
|
|
|
13
16
|
let _searchCache = null;
|
|
14
17
|
let _docCache = null;
|
|
@@ -49,6 +52,10 @@ export const ALLOWED_ENGINES = new Set([
|
|
|
49
52
|
'1337x',
|
|
50
53
|
'piratebay',
|
|
51
54
|
'nyaa',
|
|
55
|
+
// uncensored / alternative index engines
|
|
56
|
+
'yandex',
|
|
57
|
+
'ahmia',
|
|
58
|
+
'marginalia',
|
|
52
59
|
// local aliases for direct providers
|
|
53
60
|
'ddg',
|
|
54
61
|
'wiki',
|
|
@@ -96,6 +103,24 @@ const PROVIDER_REGISTRY = {
|
|
|
96
103
|
run: github.search,
|
|
97
104
|
defaultProvider: false,
|
|
98
105
|
},
|
|
106
|
+
yandex: {
|
|
107
|
+
aliases: new Set(['yandex']),
|
|
108
|
+
enabled: (cfg) => cfg?.yandex?.enabled !== false,
|
|
109
|
+
run: yandex.search,
|
|
110
|
+
defaultProvider: false,
|
|
111
|
+
},
|
|
112
|
+
ahmia: {
|
|
113
|
+
aliases: new Set(['ahmia']),
|
|
114
|
+
enabled: (cfg) => cfg?.ahmia?.enabled !== false,
|
|
115
|
+
run: ahmia.search,
|
|
116
|
+
defaultProvider: false,
|
|
117
|
+
},
|
|
118
|
+
marginalia: {
|
|
119
|
+
aliases: new Set(['marginalia']),
|
|
120
|
+
enabled: (cfg) => cfg?.marginalia?.enabled !== false,
|
|
121
|
+
run: marginalia.search,
|
|
122
|
+
defaultProvider: false,
|
|
123
|
+
},
|
|
99
124
|
};
|
|
100
125
|
|
|
101
126
|
export function initCaches(dataDir, cfg) {
|
|
@@ -162,10 +187,7 @@ function resolveProviderPlan(cfg, requestedEngines = [], category = 'web') {
|
|
|
162
187
|
|
|
163
188
|
const providers = [...explicitProviders].filter((name) => enabledProviders.includes(name));
|
|
164
189
|
if (providers.length === 0) {
|
|
165
|
-
return {
|
|
166
|
-
providers: defaultProviders,
|
|
167
|
-
searxEngines: category === 'web' && defaultProviders.includes('searxng') ? CURATED_WEB_ENGINES.slice() : [],
|
|
168
|
-
};
|
|
190
|
+
return { providers: [], searxEngines: [] };
|
|
169
191
|
}
|
|
170
192
|
|
|
171
193
|
return { providers, searxEngines };
|
|
@@ -301,6 +323,7 @@ async function runProviderDetailed(name, args) {
|
|
|
301
323
|
const responded = new Set();
|
|
302
324
|
const failed = new Set();
|
|
303
325
|
const failedDetails = [];
|
|
326
|
+
const skipHealth = new Set();
|
|
304
327
|
|
|
305
328
|
if (name === 'searxng') {
|
|
306
329
|
const unresponsive = Array.isArray(meta.unresponsive) ? meta.unresponsive.map((engine) => normalizeEngineName(engine)).filter(Boolean) : [];
|
|
@@ -327,9 +350,15 @@ async function runProviderDetailed(name, args) {
|
|
|
327
350
|
failedDetails.push({ engine: name, reason: String(meta.error) });
|
|
328
351
|
} else {
|
|
329
352
|
responded.add(name);
|
|
353
|
+
if (results.length === 0 || meta.skipHealth === true || meta.empty === true) {
|
|
354
|
+
skipHealth.add(name);
|
|
355
|
+
}
|
|
330
356
|
}
|
|
331
357
|
|
|
332
|
-
for (const engine of responded)
|
|
358
|
+
for (const engine of responded) {
|
|
359
|
+
if (skipHealth.has(engine)) continue;
|
|
360
|
+
recordEngineOutcome(engine, true);
|
|
361
|
+
}
|
|
333
362
|
for (const detail of failedDetails) recordEngineOutcome(detail.engine, false, detail.reason);
|
|
334
363
|
|
|
335
364
|
return {
|
|
@@ -558,5 +587,8 @@ export function getEnabledProviders(cfg) {
|
|
|
558
587
|
if (cfg.mojeek?.enabled && cfg.mojeek?.api_key) providers.push('mojeek');
|
|
559
588
|
if (cfg.searxng?.enabled && cfg.searxng?.url) providers.push('searxng');
|
|
560
589
|
providers.push('github-api');
|
|
590
|
+
if (cfg?.yandex?.enabled !== false) providers.push('yandex');
|
|
591
|
+
if (cfg?.ahmia?.enabled !== false) providers.push('ahmia');
|
|
592
|
+
if (cfg?.marginalia?.enabled !== false) providers.push('marginalia');
|
|
561
593
|
return providers;
|
|
562
594
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Ahmia.fi — clearnet index of Tor hidden services (.onion)
|
|
2
|
+
// No API key required — results include .onion URLs (accessible via Tor Browser)
|
|
3
|
+
|
|
4
|
+
const AHMIA_ENDPOINT = 'https://ahmia.fi/search/';
|
|
5
|
+
const UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0';
|
|
6
|
+
|
|
7
|
+
function parseAhmia(html) {
|
|
8
|
+
const results = [];
|
|
9
|
+
// Each result: <h4><a href="...">Title</a></h4> followed by <p>snippet</p>
|
|
10
|
+
const blockRe = /<h4[^>]*>([\s\S]*?)<\/h4>([\s\S]*?)(?=<h4|<\/ol|$)/gi;
|
|
11
|
+
let m;
|
|
12
|
+
while ((m = blockRe.exec(html)) !== null && results.length < 15) {
|
|
13
|
+
const titleBlock = m[1];
|
|
14
|
+
const afterBlock = m[2];
|
|
15
|
+
|
|
16
|
+
const aMatch = titleBlock.match(/<a[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
|
|
17
|
+
if (!aMatch) continue;
|
|
18
|
+
|
|
19
|
+
const url = aMatch[1];
|
|
20
|
+
if (!url.startsWith('http') || url.includes('ahmia.fi')) continue;
|
|
21
|
+
|
|
22
|
+
const title = aMatch[2].replace(/<[^>]+>/g, '').trim();
|
|
23
|
+
if (!title) continue;
|
|
24
|
+
|
|
25
|
+
const pMatch = afterBlock.match(/<p[^>]*>([\s\S]*?)<\/p>/i);
|
|
26
|
+
const snippet = pMatch
|
|
27
|
+
? pMatch[1].replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/ /g, ' ').trim().slice(0, 300)
|
|
28
|
+
: '';
|
|
29
|
+
|
|
30
|
+
results.push({ title, url, snippet, engine: 'ahmia', score: 0 });
|
|
31
|
+
}
|
|
32
|
+
return results;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function search({ query, page = 1, timeoutMs = 12000 }) {
|
|
36
|
+
const params = new URLSearchParams({ q: query });
|
|
37
|
+
if (page > 1) params.set('page', String(page - 1));
|
|
38
|
+
|
|
39
|
+
const ac = new AbortController();
|
|
40
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
41
|
+
try {
|
|
42
|
+
const r = await fetch(`${AHMIA_ENDPOINT}?${params}`, {
|
|
43
|
+
headers: {
|
|
44
|
+
'User-Agent': UA,
|
|
45
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
46
|
+
'Accept-Language': 'en-US,en;q=0.5',
|
|
47
|
+
},
|
|
48
|
+
signal: ac.signal,
|
|
49
|
+
});
|
|
50
|
+
clearTimeout(timer);
|
|
51
|
+
if (!r.ok) return { results: [], _meta: { error: `ahmia_http_${r.status}` } };
|
|
52
|
+
const html = await r.text();
|
|
53
|
+
if (html.length < 500) return { results: [], _meta: { error: 'ahmia_unexpected_html' } };
|
|
54
|
+
const results = parseAhmia(html);
|
|
55
|
+
if (results.length === 0) return { results: [], _meta: { empty: true, skipHealth: true } };
|
|
56
|
+
return { results, _meta: {} };
|
|
57
|
+
} catch {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
return { results: [], _meta: { error: 'ahmia_unreachable' } };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// Marginalia Search provider (api2, key-based).
|
|
2
|
+
// Public key works with shared limits; user key can be configured.
|
|
3
|
+
|
|
4
|
+
const DEFAULT_API = 'https://api2.marginalia-search.com';
|
|
5
|
+
|
|
6
|
+
export async function search({ query, page = 1, timeoutMs = 10000, config }) {
|
|
7
|
+
const cfg = config || {};
|
|
8
|
+
const apiBase = String(cfg?.marginalia?.api_base || DEFAULT_API).replace(/\/$/, '');
|
|
9
|
+
const apiKey = String(cfg?.marginalia?.api_key || process.env.TERMSEARCH_MARGINALIA_API_KEY || 'public').trim() || 'public';
|
|
10
|
+
const enabled = cfg?.marginalia?.enabled !== false;
|
|
11
|
+
if (!enabled) return { results: [], _meta: { error: 'marginalia_disabled' } };
|
|
12
|
+
|
|
13
|
+
const params = new URLSearchParams({
|
|
14
|
+
query,
|
|
15
|
+
count: '10',
|
|
16
|
+
page: String(Math.max(1, Number(page) || 1)),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const ac = new AbortController();
|
|
20
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
21
|
+
try {
|
|
22
|
+
const r = await fetch(`${apiBase}/search?${params}`, {
|
|
23
|
+
headers: {
|
|
24
|
+
Accept: 'application/json',
|
|
25
|
+
'User-Agent': 'TermSearch/1.0 (personal search)',
|
|
26
|
+
'API-Key': apiKey,
|
|
27
|
+
},
|
|
28
|
+
signal: ac.signal,
|
|
29
|
+
});
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
if (!r.ok) return { results: [], _meta: { error: `marginalia_http_${r.status}` } };
|
|
32
|
+
const data = await r.json();
|
|
33
|
+
const list = Array.isArray(data.results)
|
|
34
|
+
? data.results
|
|
35
|
+
: (Array.isArray(data.result) ? data.result : []);
|
|
36
|
+
const results = list.slice(0, 15).map((item) => ({
|
|
37
|
+
title: String(item.title || item.url || '').trim(),
|
|
38
|
+
url: String(item.url || '').trim(),
|
|
39
|
+
snippet: String(item.description || item.snippet || '').trim(),
|
|
40
|
+
engine: 'marginalia',
|
|
41
|
+
score: 0,
|
|
42
|
+
})).filter((r) => r.url.startsWith('http'));
|
|
43
|
+
if (results.length === 0) return { results: [], _meta: { empty: true, skipHealth: true } };
|
|
44
|
+
return { results, _meta: {} };
|
|
45
|
+
} catch {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
return { results: [], _meta: { error: 'marginalia_unreachable' } };
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Yandex HTML scraper — no API key required
|
|
2
|
+
// Different political/content filtering than US engines; Russian/global index
|
|
3
|
+
|
|
4
|
+
const YANDEX_ENDPOINT = 'https://yandex.com/search/';
|
|
5
|
+
const UA = 'Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0';
|
|
6
|
+
|
|
7
|
+
function parseYandex(html) {
|
|
8
|
+
const results = [];
|
|
9
|
+
|
|
10
|
+
// Primary: OrganicTitle-Link class (standard desktop layout)
|
|
11
|
+
const titleRe = /<a[^>]+class="[^"]*OrganicTitle-Link[^"]*"[^>]+href="([^"#]+)"[^>]*>([\s\S]*?)<\/a>/gi;
|
|
12
|
+
let m;
|
|
13
|
+
while ((m = titleRe.exec(html)) !== null && results.length < 15) {
|
|
14
|
+
const url = m[1];
|
|
15
|
+
if (!url.startsWith('http') || url.includes('yandex.') || url.includes('ya.ru')) continue;
|
|
16
|
+
const title = m[2].replace(/<[^>]+>/g, '').replace(/&/g, '&').trim();
|
|
17
|
+
if (!title) continue;
|
|
18
|
+
|
|
19
|
+
// Look for snippet in the 3KB after the title match
|
|
20
|
+
const chunk = html.slice(m.index, m.index + 3000);
|
|
21
|
+
const snipM = chunk.match(/class="[^"]*(?:OrganicText|TextContainer|Organic-Text|organic__text)[^"]*"[^>]*>([\s\S]*?)<\/(?:div|span|p)>/i);
|
|
22
|
+
const snippet = snipM
|
|
23
|
+
? snipM[1].replace(/<[^>]+>/g, '').replace(/&/g, '&').replace(/ /g, ' ').trim().slice(0, 300)
|
|
24
|
+
: '';
|
|
25
|
+
|
|
26
|
+
results.push({ title, url, snippet, engine: 'yandex', score: 0 });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return results;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function search({ query, lang = 'en-US', page = 1, timeoutMs = 12000 }) {
|
|
33
|
+
const params = new URLSearchParams({
|
|
34
|
+
text: query,
|
|
35
|
+
p: String(Math.max(0, Number(page) - 1)),
|
|
36
|
+
numdoc: '10',
|
|
37
|
+
lr: '10417', // world region
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const ac = new AbortController();
|
|
41
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
42
|
+
try {
|
|
43
|
+
const r = await fetch(`${YANDEX_ENDPOINT}?${params}`, {
|
|
44
|
+
headers: {
|
|
45
|
+
'User-Agent': UA,
|
|
46
|
+
Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
|
|
47
|
+
'Accept-Language': 'en-US,en;q=0.5',
|
|
48
|
+
},
|
|
49
|
+
signal: ac.signal,
|
|
50
|
+
});
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
if (!r.ok) return { results: [], _meta: { error: `yandex_http_${r.status}` } };
|
|
53
|
+
const html = await r.text();
|
|
54
|
+
// Explicit detection: Yandex can serve anti-bot pages.
|
|
55
|
+
if (html.includes('showcaptcha') || html.includes('robot-captcha')) {
|
|
56
|
+
return { results: [], _meta: { error: 'yandex_captcha' } };
|
|
57
|
+
}
|
|
58
|
+
if (html.length < 2000) {
|
|
59
|
+
return { results: [], _meta: { error: 'yandex_unexpected_html' } };
|
|
60
|
+
}
|
|
61
|
+
const results = parseYandex(html);
|
|
62
|
+
if (results.length === 0) return { results: [], _meta: { empty: true, skipHealth: true } };
|
|
63
|
+
return { results, _meta: {} };
|
|
64
|
+
} catch {
|
|
65
|
+
clearTimeout(timer);
|
|
66
|
+
return { results: [], _meta: { error: 'yandex_unreachable' } };
|
|
67
|
+
}
|
|
68
|
+
}
|