termsearch 0.3.1 → 0.3.3
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 +10 -3
- package/config.example.json +11 -0
- package/frontend/dist/app.js +341 -44
- package/frontend/dist/style.css +165 -3
- package/package.json +1 -1
- package/src/ai/orchestrator.js +19 -0
- package/src/api/routes.js +116 -22
- package/src/config/defaults.js +14 -0
- package/src/config/manager.js +11 -1
- package/src/search/engine.js +436 -73
- package/src/search/providers/ahmia.js +61 -0
- package/src/search/providers/github.js +91 -0
- package/src/search/providers/marginalia.js +49 -0
- package/src/search/providers/searxng.js +15 -5
- 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)
|
|
@@ -12,7 +12,9 @@ Zero external dependencies, no Docker, no Python. AI is optional and configured
|
|
|
12
12
|
Core capabilities:
|
|
13
13
|
|
|
14
14
|
- Zero-config search via DuckDuckGo and Wikipedia — works immediately after install
|
|
15
|
+
- Built-in GitHub Search API fallback (`github-api`) and selectable engine mix from UI
|
|
15
16
|
- Progressive enhancement: add Brave/Mojeek API keys, AI endpoints, or SearXNG when needed
|
|
17
|
+
- Search history persistence (opt-in toggle in Settings)
|
|
16
18
|
- Social profile scanner: GitHub, Bluesky, Reddit, Twitter/X, Instagram, YouTube, LinkedIn, TikTok, Telegram, Facebook
|
|
17
19
|
- Torrent search: The Pirate Bay + 1337x with direct magnet extraction
|
|
18
20
|
- Social search: Bluesky posts/actors + GDELT news
|
|
@@ -22,7 +24,7 @@ Core capabilities:
|
|
|
22
24
|
|
|
23
25
|
## Project Status
|
|
24
26
|
|
|
25
|
-
- Current line: `0.3.
|
|
27
|
+
- Current line: `0.3.3`
|
|
26
28
|
- Core is MIT — zero required API keys
|
|
27
29
|
- AI features are optional, configured via Settings page in browser
|
|
28
30
|
- Tested on: Ubuntu 24.04, Termux (Android 15/16)
|
|
@@ -89,8 +91,11 @@ Configure at **Settings → AI** in the browser. Supported endpoints:
|
|
|
89
91
|
|----------|----------|-------|-----|
|
|
90
92
|
| **Localhost** (Ollama) | `http://localhost:11434/v1` | `qwen3.5:4b` or any | not required |
|
|
91
93
|
| **Localhost** (LM Studio) | `http://localhost:1234/v1` | your loaded model | not required |
|
|
94
|
+
| **Localhost** (llama.cpp) | `http://localhost:8080/v1` | your loaded model | not required |
|
|
92
95
|
| **Chutes.ai TEE** | `https://llm.chutes.ai/v1` | `deepseek-ai/DeepSeek-V3.2-TEE` | required |
|
|
96
|
+
| **Anthropic** | `https://api.anthropic.com/v1` | `claude-3-5-haiku-latest` | required |
|
|
93
97
|
| **OpenAI** | `https://api.openai.com/v1` | `gpt-4o-mini` | required |
|
|
98
|
+
| **OpenRouter** | `https://openrouter.ai/api/v1` | any listed model | required |
|
|
94
99
|
| **API custom** | any OpenAI-compatible URL | your model | optional |
|
|
95
100
|
|
|
96
101
|
All providers use the OpenAI-compatible `/chat/completions` format. Leave API key empty for local models.
|
|
@@ -107,7 +112,7 @@ All providers use the OpenAI-compatible `/chat/completions` format. Leave API ke
|
|
|
107
112
|
src/
|
|
108
113
|
config/ config manager — load/save/defaults/env overrides
|
|
109
114
|
search/
|
|
110
|
-
providers/ DuckDuckGo, Wikipedia, Brave, Mojeek, SearXNG
|
|
115
|
+
providers/ DuckDuckGo, Wikipedia, Brave, Mojeek, SearXNG, GitHub API, Yandex, Ahmia, Marginalia
|
|
111
116
|
engine.js fan-out, merge, rank, cache
|
|
112
117
|
ranking.js source diversity ranking
|
|
113
118
|
cache.js tiered cache (L1 Map + L2 disk JSON)
|
|
@@ -172,6 +177,8 @@ TERMSEARCH_AI_API_KEY=
|
|
|
172
177
|
TERMSEARCH_AI_MODEL=glm-4.7
|
|
173
178
|
TERMSEARCH_BRAVE_API_KEY=
|
|
174
179
|
TERMSEARCH_MOJEEK_API_KEY=
|
|
180
|
+
TERMSEARCH_MARGINALIA_API_KEY=public
|
|
181
|
+
TERMSEARCH_MARGINALIA_API_BASE=https://api2.marginalia-search.com
|
|
175
182
|
TERMSEARCH_SEARXNG_URL=
|
|
176
183
|
TERMSEARCH_GITHUB_TOKEN=
|
|
177
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,15 @@ 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
|
+
aiLastQuery: null,
|
|
19
|
+
aiLastResults: null,
|
|
20
|
+
aiLastLang: null,
|
|
12
21
|
profilerData: null,
|
|
13
22
|
profilerLoading: false,
|
|
14
23
|
torrentData: [],
|
|
@@ -17,6 +26,14 @@ const state = {
|
|
|
17
26
|
providers: [],
|
|
18
27
|
config: null,
|
|
19
28
|
historyEnabled: localStorage.getItem('ts-save-history') !== '0',
|
|
29
|
+
selectedEngines: (() => {
|
|
30
|
+
try {
|
|
31
|
+
const raw = JSON.parse(localStorage.getItem('ts-engines') || '[]');
|
|
32
|
+
return Array.isArray(raw) ? raw.slice(0, 20).map((v) => String(v || '').trim().toLowerCase()).filter(Boolean) : [];
|
|
33
|
+
} catch {
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
})(),
|
|
20
37
|
searchHistory: (() => {
|
|
21
38
|
try {
|
|
22
39
|
const raw = JSON.parse(localStorage.getItem('ts-history') || '[]');
|
|
@@ -95,6 +112,53 @@ function addSearchToHistory(query) {
|
|
|
95
112
|
persistHistory();
|
|
96
113
|
}
|
|
97
114
|
|
|
115
|
+
const LANG_CANONICAL = new Map([
|
|
116
|
+
['it', 'it-IT'], ['it-it', 'it-IT'],
|
|
117
|
+
['en', 'en-US'], ['en-us', 'en-US'],
|
|
118
|
+
['es', 'es-ES'], ['es-es', 'es-ES'],
|
|
119
|
+
['fr', 'fr-FR'], ['fr-fr', 'fr-FR'],
|
|
120
|
+
['de', 'de-DE'], ['de-de', 'de-DE'],
|
|
121
|
+
['pt', 'pt-PT'], ['pt-pt', 'pt-PT'],
|
|
122
|
+
['ru', 'ru-RU'], ['ru-ru', 'ru-RU'],
|
|
123
|
+
['zh', 'zh-CN'], ['zh-cn', 'zh-CN'],
|
|
124
|
+
['ja', 'ja-JP'], ['ja-jp', 'ja-JP'],
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
function normalizeLangCode(raw) {
|
|
128
|
+
const key = String(raw || '').trim().toLowerCase();
|
|
129
|
+
return LANG_CANONICAL.get(key) || null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function getResolvedLang() {
|
|
133
|
+
const selected = getLang();
|
|
134
|
+
if (selected && selected !== 'auto') return selected;
|
|
135
|
+
const browser = normalizeLangCode(navigator.language || navigator.languages?.[0] || '');
|
|
136
|
+
return browser || 'en-US';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function persistSelectedEngines() {
|
|
140
|
+
localStorage.setItem('ts-engines', JSON.stringify(state.selectedEngines.slice(0, 20)));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function setSelectedEngines(engines) {
|
|
144
|
+
state.selectedEngines = [...new Set(
|
|
145
|
+
(Array.isArray(engines) ? engines : [])
|
|
146
|
+
.map((engine) => String(engine || '').trim().toLowerCase())
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
)].slice(0, 20);
|
|
149
|
+
persistSelectedEngines();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sanitizeHttpUrl(raw) {
|
|
153
|
+
try {
|
|
154
|
+
const url = new URL(String(raw || '').trim());
|
|
155
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
|
|
156
|
+
return url.toString();
|
|
157
|
+
} catch {
|
|
158
|
+
return '';
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
98
162
|
// ─── SVG Icons ────────────────────────────────────────────────────────────
|
|
99
163
|
function svg(paths, size = 16, extra = '') {
|
|
100
164
|
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>`;
|
|
@@ -155,13 +219,29 @@ const LANGS = [
|
|
|
155
219
|
];
|
|
156
220
|
|
|
157
221
|
const AI_PRESETS = [
|
|
158
|
-
{ id: '
|
|
159
|
-
{ id: '
|
|
160
|
-
{ id: '
|
|
161
|
-
{ id: '
|
|
162
|
-
{ id: '
|
|
163
|
-
{ id: '
|
|
164
|
-
{ id: '
|
|
222
|
+
{ id: 'ollama', label: 'LocalHost — Ollama', api_base: 'http://127.0.0.1:11434/v1', keyRequired: false, defaultModel: 'qwen3.5:4b' },
|
|
223
|
+
{ id: 'lmstudio', label: 'LocalHost — LM Studio', api_base: 'http://127.0.0.1:1234/v1', keyRequired: false, defaultModel: '' },
|
|
224
|
+
{ id: 'llamacpp', label: 'LocalHost — llama.cpp', api_base: 'http://127.0.0.1:8080/v1', keyRequired: false, defaultModel: '' },
|
|
225
|
+
{ id: 'chutes', label: 'Chutes.ai TEE', api_base: 'https://llm.chutes.ai/v1', keyRequired: true, defaultModel: 'deepseek-ai/DeepSeek-V3.2-TEE' },
|
|
226
|
+
{ id: 'anthropic',label: 'Anthropic', api_base: 'https://api.anthropic.com/v1', keyRequired: true, defaultModel: 'claude-3-5-haiku-latest' },
|
|
227
|
+
{ id: 'openai', label: 'OpenAI', api_base: 'https://api.openai.com/v1', keyRequired: true, defaultModel: 'gpt-4o-mini' },
|
|
228
|
+
{ id: 'openrouter', label: 'OpenRoute/OpenRouter', api_base: 'https://openrouter.ai/api/v1', keyRequired: true, defaultModel: 'openai/gpt-4o-mini' },
|
|
229
|
+
];
|
|
230
|
+
|
|
231
|
+
const ENGINE_GROUPS = [
|
|
232
|
+
{ label: 'Web Core', items: ['duckduckgo', 'wikipedia', 'brave', 'startpage', 'qwant', 'mojeek', 'bing', 'google', 'yahoo'] },
|
|
233
|
+
{ label: 'Uncensored', items: ['yandex', 'marginalia', 'ahmia'] },
|
|
234
|
+
{ label: 'Code & Dev', items: ['github', 'github-api', 'hackernews', 'reddit'] },
|
|
235
|
+
{ label: 'Media', items: ['youtube', 'sepiasearch'] },
|
|
236
|
+
{ label: 'Research', items: ['wikidata', 'crossref', 'openalex', 'openlibrary'] },
|
|
237
|
+
{ label: 'Federated', items: ['mastodon users', 'mastodon hashtags', 'tootfinder', 'lemmy communities', 'lemmy posts'] },
|
|
238
|
+
{ label: 'Torrent', items: ['piratebay', '1337x', 'nyaa'] },
|
|
239
|
+
];
|
|
240
|
+
|
|
241
|
+
const ENGINE_PRESETS = [
|
|
242
|
+
{ id: 'all', label: 'All', engines: [] },
|
|
243
|
+
{ id: 'balanced', label: 'Balanced', engines: ['duckduckgo', 'wikipedia', 'bing', 'startpage', 'github', 'reddit', 'youtube'] },
|
|
244
|
+
{ id: 'github', label: 'GitHub Focus', engines: ['github-api', 'github', 'duckduckgo', 'wikipedia'] },
|
|
165
245
|
];
|
|
166
246
|
|
|
167
247
|
function detectPresetFromBase(base) {
|
|
@@ -179,12 +259,88 @@ function LangPicker() {
|
|
|
179
259
|
if (l.code === getLang()) opt.selected = true;
|
|
180
260
|
sel.append(opt);
|
|
181
261
|
}
|
|
182
|
-
sel.addEventListener('change', () => {
|
|
262
|
+
sel.addEventListener('change', () => {
|
|
263
|
+
setLang(sel.value);
|
|
264
|
+
if (state.query) {
|
|
265
|
+
doSearch(state.query, state.category);
|
|
266
|
+
} else {
|
|
267
|
+
renderApp();
|
|
268
|
+
}
|
|
269
|
+
});
|
|
183
270
|
const arrow = el('span', { className: 'lang-arrow', html: svg('<polyline points="6 9 12 15 18 9"/>', 12) });
|
|
184
271
|
wrap.append(sel, arrow);
|
|
185
272
|
return wrap;
|
|
186
273
|
}
|
|
187
274
|
|
|
275
|
+
function EnginePicker() {
|
|
276
|
+
const details = el('details', { className: 'engine-picker' });
|
|
277
|
+
const selectedCount = state.selectedEngines.length;
|
|
278
|
+
const summary = el('summary', { className: 'engine-picker-summary' },
|
|
279
|
+
el('span', { className: 'engine-picker-title' }, selectedCount ? `Engines (${selectedCount})` : 'Engines (all)'),
|
|
280
|
+
iconEl('chevron', 'engine-chevron'),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
const body = el('div', { className: 'engine-picker-body' });
|
|
284
|
+
const presetRow = el('div', { className: 'engine-preset-row' });
|
|
285
|
+
ENGINE_PRESETS.forEach((preset) => {
|
|
286
|
+
presetRow.append(el('button', {
|
|
287
|
+
className: `btn ${preset.id === 'balanced' ? 'btn-primary' : ''}`,
|
|
288
|
+
type: 'button',
|
|
289
|
+
onClick: () => {
|
|
290
|
+
setSelectedEngines(preset.engines);
|
|
291
|
+
details.open = false;
|
|
292
|
+
if (state.query) doSearch(state.query, state.category);
|
|
293
|
+
else renderApp();
|
|
294
|
+
},
|
|
295
|
+
}, preset.label));
|
|
296
|
+
});
|
|
297
|
+
body.append(presetRow);
|
|
298
|
+
|
|
299
|
+
ENGINE_GROUPS.forEach((group) => {
|
|
300
|
+
const card = el('div', { className: 'engine-group' });
|
|
301
|
+
card.append(el('div', { className: 'engine-group-title' }, group.label));
|
|
302
|
+
const list = el('div', { className: 'engine-chip-wrap' });
|
|
303
|
+
group.items.forEach((engine) => {
|
|
304
|
+
const checked = state.selectedEngines.includes(engine);
|
|
305
|
+
const id = `engine-${engine.replace(/[^a-z0-9]+/g, '-')}-${Math.random().toString(36).slice(2, 6)}`;
|
|
306
|
+
const input = el('input', { id, type: 'checkbox', ...(checked ? { checked: '' } : {}) });
|
|
307
|
+
const label = el('label', { className: 'engine-chip', for: id }, input, el('span', {}, engine));
|
|
308
|
+
list.append(label);
|
|
309
|
+
});
|
|
310
|
+
card.append(list);
|
|
311
|
+
body.append(card);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
body.append(el('div', { className: 'engine-actions' },
|
|
315
|
+
el('button', {
|
|
316
|
+
className: 'btn btn-primary',
|
|
317
|
+
type: 'button',
|
|
318
|
+
onClick: () => {
|
|
319
|
+
const selected = [...details.querySelectorAll('.engine-chip input:checked')]
|
|
320
|
+
.map((node) => node.parentElement?.textContent?.trim().toLowerCase())
|
|
321
|
+
.filter(Boolean);
|
|
322
|
+
setSelectedEngines(selected);
|
|
323
|
+
details.open = false;
|
|
324
|
+
if (state.query) doSearch(state.query, state.category);
|
|
325
|
+
else renderApp();
|
|
326
|
+
},
|
|
327
|
+
}, 'Apply'),
|
|
328
|
+
el('button', {
|
|
329
|
+
className: 'btn',
|
|
330
|
+
type: 'button',
|
|
331
|
+
onClick: () => {
|
|
332
|
+
setSelectedEngines([]);
|
|
333
|
+
details.open = false;
|
|
334
|
+
if (state.query) doSearch(state.query, state.category);
|
|
335
|
+
else renderApp();
|
|
336
|
+
},
|
|
337
|
+
}, 'Reset'),
|
|
338
|
+
));
|
|
339
|
+
|
|
340
|
+
details.append(summary, body);
|
|
341
|
+
return details;
|
|
342
|
+
}
|
|
343
|
+
|
|
188
344
|
// ─── Search form ──────────────────────────────────────────────────────────
|
|
189
345
|
function SearchForm(value, onSearch) {
|
|
190
346
|
const form = el('form', { className: 'search-form' });
|
|
@@ -283,37 +439,96 @@ function renderAiPanel() {
|
|
|
283
439
|
if (!isActive) { panel.style.display = 'none'; return; }
|
|
284
440
|
panel.style.display = 'block';
|
|
285
441
|
|
|
286
|
-
const
|
|
287
|
-
const
|
|
442
|
+
const isLoading = state.aiStatus === 'loading' || state.aiStatus === 'streaming';
|
|
443
|
+
const isDone = state.aiStatus === 'done';
|
|
444
|
+
const isError = state.aiStatus === 'error';
|
|
445
|
+
const dotsClass = isDone ? 'done' : isError ? 'error' : '';
|
|
446
|
+
const statusText = state.aiStatus === 'loading' ? 'Thinking…'
|
|
447
|
+
: state.aiStatus === 'streaming' ? 'Generating…'
|
|
448
|
+
: isDone ? 'AI Summary' : 'Error';
|
|
288
449
|
|
|
450
|
+
// Dots
|
|
289
451
|
const dotsEl = el('div', { className: 'ai-dots' });
|
|
290
452
|
['violet', 'indigo', 'dim'].forEach(c => {
|
|
291
453
|
dotsEl.append(el('div', { className: `ai-dot ${dotsClass || c}` }));
|
|
292
454
|
});
|
|
293
455
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
el('span', { className: 'panel-label' }, statusText),
|
|
298
|
-
state.aiMeta?.model ? el('span', { style: 'font-size:10px;color:var(--text3);margin-left:6px' }, state.aiMeta.model) : null,
|
|
299
|
-
),
|
|
300
|
-
);
|
|
456
|
+
// Latency
|
|
457
|
+
const latMs = state.aiLatencyMs;
|
|
458
|
+
const latLabel = latMs != null ? (latMs < 1000 ? `${latMs}ms` : `${(latMs / 1000).toFixed(1)}s`) : null;
|
|
301
459
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
460
|
+
// Header
|
|
461
|
+
const headerLeft = el('div', { className: 'panel-header-left' },
|
|
462
|
+
dotsEl,
|
|
463
|
+
el('span', { className: 'panel-label' }, statusText),
|
|
464
|
+
state.aiMeta?.model ? el('span', { className: 'ai-model-label' }, state.aiMeta.model) : null,
|
|
465
|
+
latLabel ? el('span', { className: 'ai-latency-label' }, `· ${latLabel}`) : null,
|
|
466
|
+
);
|
|
467
|
+
const chevronPath = state.aiExpanded ? '<polyline points="18 15 12 9 6 15"/>' : '<polyline points="6 9 12 15 18 9"/>';
|
|
468
|
+
const expandBtn = el('button', { className: 'ai-expand-btn', type: 'button', title: state.aiExpanded ? 'Collapse' : 'Expand' });
|
|
469
|
+
expandBtn.innerHTML = svg(chevronPath, 14);
|
|
470
|
+
expandBtn.onclick = () => { state.aiExpanded = !state.aiExpanded; renderAiPanel(); };
|
|
471
|
+
const header = el('div', { className: 'panel-header' }, headerLeft, expandBtn);
|
|
472
|
+
|
|
473
|
+
// Progress bar
|
|
474
|
+
const showProgress = isLoading && state.aiProgress > 0;
|
|
475
|
+
const progressEl = showProgress ? el('div', { className: 'ai-progress-wrap' },
|
|
476
|
+
el('div', { className: 'ai-progress-bar', style: `width:${state.aiProgress}%` }),
|
|
477
|
+
) : null;
|
|
478
|
+
|
|
479
|
+
// Steps
|
|
480
|
+
const showSteps = isLoading && state.aiSteps.length > 0;
|
|
481
|
+
const stepsEl = showSteps ? el('div', { className: 'ai-steps' },
|
|
482
|
+
...state.aiSteps.slice(-4).map(s => el('div', { className: 'ai-step' }, s)),
|
|
483
|
+
) : null;
|
|
484
|
+
|
|
485
|
+
// Content
|
|
486
|
+
const contentEl = el('div', { className: `ai-content${!state.aiExpanded && !isLoading ? ' ai-content-collapsed' : ''}` });
|
|
487
|
+
if (isError) {
|
|
488
|
+
contentEl.style.color = '#f87171';
|
|
489
|
+
contentEl.textContent = state.aiError;
|
|
306
490
|
} else {
|
|
307
|
-
|
|
491
|
+
contentEl.textContent = state.aiSummary;
|
|
308
492
|
}
|
|
309
493
|
|
|
310
|
-
|
|
311
|
-
|
|
494
|
+
// Sources (shown when expanded + done)
|
|
495
|
+
const showSources = isDone && state.aiExpanded && state.aiSources.length > 0;
|
|
496
|
+
const sourcesEl = showSources ? el('div', { className: 'ai-sources' },
|
|
497
|
+
...state.aiSources.slice(0, 8).map((src, i) => {
|
|
498
|
+
const safeSrc = sanitizeHttpUrl(src);
|
|
499
|
+
if (!safeSrc) return null;
|
|
500
|
+
let label = src;
|
|
501
|
+
try {
|
|
502
|
+
const { hostname, pathname } = new URL(safeSrc);
|
|
503
|
+
const host = hostname.replace(/^www\./, '');
|
|
504
|
+
const segs = pathname.replace(/\/$/, '').split('/').filter(Boolean).slice(0, 2);
|
|
505
|
+
label = segs.length ? `${host} › ${segs.join('/')}` : host;
|
|
506
|
+
} catch {}
|
|
507
|
+
const a = el('a', { className: 'ai-source-pill', href: safeSrc, target: '_blank', rel: 'noopener noreferrer' }, `[${i + 1}] ${label}`);
|
|
508
|
+
return a;
|
|
509
|
+
}),
|
|
510
|
+
) : null;
|
|
511
|
+
|
|
512
|
+
// Footer: retry + expand/collapse
|
|
513
|
+
const retryBtn = el('button', { className: 'ai-retry-btn', type: 'button' }, 'Retry');
|
|
514
|
+
retryBtn.onclick = () => {
|
|
515
|
+
if (state.aiLastQuery) startAiSummary(state.aiLastQuery, state.aiLastResults || [], state.aiLastLang || 'en-US');
|
|
516
|
+
};
|
|
517
|
+
const toggleBtn = el('button', { className: 'ai-toggle-btn', type: 'button' },
|
|
518
|
+
state.aiExpanded ? 'Show less' : 'Show more',
|
|
519
|
+
);
|
|
520
|
+
toggleBtn.onclick = () => { state.aiExpanded = !state.aiExpanded; renderAiPanel(); };
|
|
521
|
+
const footer = el('div', { className: 'ai-footer' }, retryBtn, toggleBtn);
|
|
312
522
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
if (
|
|
523
|
+
panel.innerHTML = '';
|
|
524
|
+
panel.append(header);
|
|
525
|
+
if (progressEl) panel.append(progressEl);
|
|
526
|
+
if (stepsEl) panel.append(stepsEl);
|
|
527
|
+
panel.append(contentEl);
|
|
528
|
+
if (sourcesEl) panel.append(sourcesEl);
|
|
529
|
+
panel.append(footer);
|
|
530
|
+
|
|
531
|
+
if (state.aiStatus === 'streaming' && state.aiSummary.length < 60) {
|
|
317
532
|
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
|
318
533
|
}
|
|
319
534
|
}
|
|
@@ -526,8 +741,11 @@ function flattenSocialResults(payload) {
|
|
|
526
741
|
].filter((item) => item.url);
|
|
527
742
|
}
|
|
528
743
|
|
|
529
|
-
async function runSearchProgressive(q, lang, category) {
|
|
744
|
+
async function runSearchProgressive(q, lang, category, engines = []) {
|
|
530
745
|
const params = new URLSearchParams({ q, lang, cat: category });
|
|
746
|
+
if (Array.isArray(engines) && engines.length > 0) {
|
|
747
|
+
params.set('engines', engines.join(','));
|
|
748
|
+
}
|
|
531
749
|
const response = await fetch(`/api/search-stream?${params.toString()}`);
|
|
532
750
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
533
751
|
|
|
@@ -599,11 +817,14 @@ async function doSearch(q, category = state.category) {
|
|
|
599
817
|
state.socialData = [];
|
|
600
818
|
renderApp();
|
|
601
819
|
|
|
602
|
-
const lang =
|
|
820
|
+
const lang = getResolvedLang();
|
|
821
|
+
const engines = state.selectedEngines.slice();
|
|
603
822
|
|
|
604
823
|
try {
|
|
605
|
-
const searchPromise = runSearchProgressive(q, lang, state.category).catch(async () => {
|
|
606
|
-
|
|
824
|
+
const searchPromise = runSearchProgressive(q, lang, state.category, engines).catch(async () => {
|
|
825
|
+
const p = new URLSearchParams({ q, lang, cat: state.category });
|
|
826
|
+
if (engines.length > 0) p.set('engines', engines.join(','));
|
|
827
|
+
return api(`/api/search?${p.toString()}`);
|
|
607
828
|
});
|
|
608
829
|
const promises = [
|
|
609
830
|
searchPromise,
|
|
@@ -651,6 +872,15 @@ async function doSearch(q, category = state.category) {
|
|
|
651
872
|
async function startAiSummary(query, results, lang) {
|
|
652
873
|
state.aiStatus = 'loading';
|
|
653
874
|
state.aiSummary = '';
|
|
875
|
+
state.aiError = null;
|
|
876
|
+
state.aiProgress = 0;
|
|
877
|
+
state.aiSteps = [];
|
|
878
|
+
state.aiSources = [];
|
|
879
|
+
state.aiStartTime = Date.now();
|
|
880
|
+
state.aiLatencyMs = null;
|
|
881
|
+
state.aiLastQuery = query;
|
|
882
|
+
state.aiLastResults = results;
|
|
883
|
+
state.aiLastLang = lang;
|
|
654
884
|
renderAiPanel();
|
|
655
885
|
|
|
656
886
|
try {
|
|
@@ -677,13 +907,22 @@ async function startAiSummary(query, results, lang) {
|
|
|
677
907
|
if (!line.startsWith('data: ')) continue;
|
|
678
908
|
try {
|
|
679
909
|
const d = JSON.parse(line.slice(6));
|
|
680
|
-
if (d.chunk)
|
|
681
|
-
else if (d.
|
|
682
|
-
else if (d.
|
|
910
|
+
if (d.chunk !== undefined) { state.aiSummary += d.chunk; renderAiPanel(); }
|
|
911
|
+
else if (d.progress !== undefined) { state.aiProgress = d.progress; renderAiPanel(); }
|
|
912
|
+
else if (d.step) { state.aiSteps = [...state.aiSteps.slice(-3), d.step]; renderAiPanel(); }
|
|
913
|
+
else if (d.error) { state.aiStatus = 'error'; state.aiError = d.message || d.error; renderAiPanel(); }
|
|
914
|
+
else if (d.model != null || d.sites != null) {
|
|
915
|
+
state.aiStatus = 'done';
|
|
916
|
+
state.aiProgress = 100;
|
|
917
|
+
state.aiSources = Array.isArray(d.sites) ? d.sites.map(sanitizeHttpUrl).filter(Boolean) : [];
|
|
918
|
+
state.aiMeta = { fetchedCount: d.fetchedCount, model: d.model };
|
|
919
|
+
state.aiLatencyMs = Date.now() - state.aiStartTime;
|
|
920
|
+
renderAiPanel();
|
|
921
|
+
}
|
|
683
922
|
} catch { /* ignore */ }
|
|
684
923
|
}
|
|
685
924
|
}
|
|
686
|
-
if (state.aiStatus === 'streaming') { state.aiStatus = 'done'; renderAiPanel(); }
|
|
925
|
+
if (state.aiStatus === 'streaming') { state.aiStatus = 'done'; state.aiLatencyMs = Date.now() - state.aiStartTime; renderAiPanel(); }
|
|
687
926
|
} catch (e) {
|
|
688
927
|
state.aiStatus = 'error';
|
|
689
928
|
state.aiError = e.message;
|
|
@@ -732,6 +971,7 @@ function renderApp() {
|
|
|
732
971
|
type: 'button',
|
|
733
972
|
}, cat.label));
|
|
734
973
|
});
|
|
974
|
+
categoryBar.append(EnginePicker());
|
|
735
975
|
|
|
736
976
|
const main = el('div', { className: 'main' });
|
|
737
977
|
|
|
@@ -751,6 +991,7 @@ function renderApp() {
|
|
|
751
991
|
const meta = el('div', { className: 'results-meta' });
|
|
752
992
|
meta.append(document.createTextNode(`${state.results.length} results`));
|
|
753
993
|
if (state.providers.length) meta.append(document.createTextNode(' · ' + state.providers.join(', ')));
|
|
994
|
+
if (state.selectedEngines.length) meta.append(document.createTextNode(' · engines: ' + state.selectedEngines.join(', ')));
|
|
754
995
|
main.append(meta);
|
|
755
996
|
}
|
|
756
997
|
|
|
@@ -785,7 +1026,7 @@ function renderHome(app) {
|
|
|
785
1026
|
el('div', { className: 'home-logo' }, 'Term', el('strong', {}, 'Search')),
|
|
786
1027
|
el('div', { className: 'home-tagline' },
|
|
787
1028
|
el('span', { className: 'tagline-desktop' }, 'Personal search engine · privacy-first · local-first'),
|
|
788
|
-
el('span', { className: 'tagline-mobile' }, 'Private search
|
|
1029
|
+
el('span', { className: 'tagline-mobile' }, 'Private local search'),
|
|
789
1030
|
),
|
|
790
1031
|
el('div', { className: 'home-search' }, SearchForm('', (q) => { state.query = q; state.category = 'web'; doSearch(q, 'web'); })),
|
|
791
1032
|
el('div', { className: 'home-actions' },
|
|
@@ -857,8 +1098,13 @@ async function renderSettings() {
|
|
|
857
1098
|
presetSelect.append(opt);
|
|
858
1099
|
});
|
|
859
1100
|
const modelInput = makeInput('ai-model', ai.model, 'qwen3.5:4b');
|
|
860
|
-
|
|
861
|
-
|
|
1101
|
+
const modelSelect = el('select', { className: 'form-input', id: 'ai-model-select' },
|
|
1102
|
+
el('option', { value: '' }, 'Load models first…')
|
|
1103
|
+
);
|
|
1104
|
+
const modelQuickList = el('div', { id: 'ai-model-quick-list', className: 'model-quick-list' },
|
|
1105
|
+
el('div', { className: 'form-hint' }, 'No models loaded.')
|
|
1106
|
+
);
|
|
1107
|
+
let loadedModels = [];
|
|
862
1108
|
|
|
863
1109
|
function setModelStatus(message, type = 'info') {
|
|
864
1110
|
aiModelStatus.style.display = 'block';
|
|
@@ -879,9 +1125,31 @@ async function renderSettings() {
|
|
|
879
1125
|
}
|
|
880
1126
|
|
|
881
1127
|
function populateModelList(models) {
|
|
882
|
-
|
|
1128
|
+
loadedModels = models.slice();
|
|
1129
|
+
modelSelect.innerHTML = '';
|
|
883
1130
|
for (const model of models) {
|
|
884
|
-
|
|
1131
|
+
modelSelect.append(el('option', { value: model }, model));
|
|
1132
|
+
}
|
|
1133
|
+
modelQuickList.innerHTML = '';
|
|
1134
|
+
models.forEach((model) => {
|
|
1135
|
+
modelQuickList.append(el('button', {
|
|
1136
|
+
className: 'model-chip-btn',
|
|
1137
|
+
type: 'button',
|
|
1138
|
+
onClick: () => {
|
|
1139
|
+
const modelField = document.getElementById('ai-model');
|
|
1140
|
+
if (modelField) modelField.value = model;
|
|
1141
|
+
modelSelect.value = model;
|
|
1142
|
+
[...modelQuickList.querySelectorAll('.model-chip-btn')].forEach((n) => n.classList.remove('active'));
|
|
1143
|
+
const active = [...modelQuickList.querySelectorAll('.model-chip-btn')].find((n) => n.textContent === model);
|
|
1144
|
+
if (active) active.classList.add('active');
|
|
1145
|
+
},
|
|
1146
|
+
}, model));
|
|
1147
|
+
});
|
|
1148
|
+
const current = val('ai-model');
|
|
1149
|
+
if (current && models.includes(current)) {
|
|
1150
|
+
modelSelect.value = current;
|
|
1151
|
+
const active = [...modelQuickList.querySelectorAll('.model-chip-btn')].find((n) => n.textContent === current);
|
|
1152
|
+
if (active) active.classList.add('active');
|
|
885
1153
|
}
|
|
886
1154
|
}
|
|
887
1155
|
|
|
@@ -911,6 +1179,11 @@ async function renderSettings() {
|
|
|
911
1179
|
const models = Array.isArray(res.models) ? res.models : [];
|
|
912
1180
|
if (!models.length) {
|
|
913
1181
|
setModelStatus(`No models found${res.provider ? ` for ${res.provider}` : ''}.`, 'err');
|
|
1182
|
+
modelSelect.innerHTML = '';
|
|
1183
|
+
modelSelect.append(el('option', { value: '' }, 'No models found'));
|
|
1184
|
+
modelQuickList.innerHTML = '';
|
|
1185
|
+
modelQuickList.append(el('div', { className: 'form-hint' }, 'No models loaded.'));
|
|
1186
|
+
loadedModels = [];
|
|
914
1187
|
return;
|
|
915
1188
|
}
|
|
916
1189
|
populateModelList(models);
|
|
@@ -918,6 +1191,7 @@ async function renderSettings() {
|
|
|
918
1191
|
if (!current || !models.includes(current)) {
|
|
919
1192
|
const modelField = document.getElementById('ai-model');
|
|
920
1193
|
if (modelField) modelField.value = models[0];
|
|
1194
|
+
modelSelect.value = models[0];
|
|
921
1195
|
}
|
|
922
1196
|
setModelStatus(`Loaded ${models.length} model(s)${res.provider ? ` from ${res.provider}` : ''}.`, 'ok');
|
|
923
1197
|
} catch (e) {
|
|
@@ -1101,7 +1375,7 @@ async function renderSettings() {
|
|
|
1101
1375
|
el('label', { className: 'form-label', for: 'ai-base' }, 'API Endpoint'),
|
|
1102
1376
|
makeInput('ai-base', ai.api_base, 'http://localhost:11434/v1'),
|
|
1103
1377
|
el('div', { className: 'form-hint' },
|
|
1104
|
-
'Included presets:
|
|
1378
|
+
'Included presets: LocalHost (Ollama · LM Studio · llama.cpp) · Chutes.ai TEE · Anthropic · OpenAI · OpenRoute/OpenRouter',
|
|
1105
1379
|
el('br', {}),
|
|
1106
1380
|
'You can also keep custom OpenAI-compatible endpoints.',
|
|
1107
1381
|
),
|
|
@@ -1114,7 +1388,9 @@ async function renderSettings() {
|
|
|
1114
1388
|
el('div', { className: 'form-group' },
|
|
1115
1389
|
el('label', { className: 'form-label', for: 'ai-model' }, 'Model'),
|
|
1116
1390
|
modelInput,
|
|
1117
|
-
|
|
1391
|
+
el('div', { className: 'form-hint', style: 'margin-top:4px' }, 'Model list (tap to open):'),
|
|
1392
|
+
modelSelect,
|
|
1393
|
+
modelQuickList,
|
|
1118
1394
|
el('div', { className: 'form-row', style: 'margin-top:6px' },
|
|
1119
1395
|
el('button', { id: 'ai-load-models-btn', className: 'btn', onClick: () => loadModels('manual'), type: 'button' }, 'Load models'),
|
|
1120
1396
|
el('span', { className: 'form-hint', style: 'margin-top:0' }, 'Auto-loads from endpoint with current key.'),
|
|
@@ -1216,7 +1492,7 @@ async function renderSettings() {
|
|
|
1216
1492
|
// Server info
|
|
1217
1493
|
el('div', { className: 'settings-section' },
|
|
1218
1494
|
el('h2', {}, 'Server Info'),
|
|
1219
|
-
el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Version'), el('span', { className: 'info-val' }, health?.version || '0.3.
|
|
1495
|
+
el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Version'), el('span', { className: 'info-val' }, health?.version || '0.3.3')),
|
|
1220
1496
|
el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Active providers'), el('span', { className: 'info-val' }, (health?.providers || []).join(', ') || 'none')),
|
|
1221
1497
|
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')),
|
|
1222
1498
|
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')),
|
|
@@ -1248,6 +1524,27 @@ async function renderSettings() {
|
|
|
1248
1524
|
setHistoryEnabled(Boolean(e.target?.checked));
|
|
1249
1525
|
renderHistoryPreview();
|
|
1250
1526
|
});
|
|
1527
|
+
modelSelect.addEventListener('change', () => {
|
|
1528
|
+
if (!modelSelect.value) return;
|
|
1529
|
+
const modelField = document.getElementById('ai-model');
|
|
1530
|
+
if (modelField) modelField.value = modelSelect.value;
|
|
1531
|
+
[...modelQuickList.querySelectorAll('.model-chip-btn')].forEach((n) => {
|
|
1532
|
+
n.classList.toggle('active', n.textContent === modelSelect.value);
|
|
1533
|
+
});
|
|
1534
|
+
});
|
|
1535
|
+
modelSelect.addEventListener('focus', () => {
|
|
1536
|
+
if (!loadedModels.length) loadModels('auto');
|
|
1537
|
+
});
|
|
1538
|
+
modelInput.addEventListener('input', () => {
|
|
1539
|
+
const current = modelInput.value || '';
|
|
1540
|
+
if ([...modelSelect.options].some((opt) => opt.value === current)) {
|
|
1541
|
+
modelSelect.value = modelInput.value;
|
|
1542
|
+
}
|
|
1543
|
+
[...modelQuickList.querySelectorAll('.model-chip-btn')].forEach((n) => {
|
|
1544
|
+
n.classList.toggle('active', n.textContent === current);
|
|
1545
|
+
n.style.display = !current || n.textContent.toLowerCase().includes(current.toLowerCase()) ? 'inline-flex' : 'none';
|
|
1546
|
+
});
|
|
1547
|
+
});
|
|
1251
1548
|
document.getElementById('ai-key')?.addEventListener('change', () => loadModels('auto'));
|
|
1252
1549
|
document.getElementById('ai-base')?.addEventListener('change', () => loadModels('auto'));
|
|
1253
1550
|
presetSelect.addEventListener('change', applyPreset);
|