termsearch 0.3.0 → 0.3.2
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 +8 -3
- package/bin/termsearch.js +1 -1
- package/frontend/dist/app.js +436 -14
- package/frontend/dist/style.css +132 -2
- package/package.json +1 -1
- package/src/ai/providers/openai-compat.js +151 -0
- package/src/api/routes.js +223 -10
- package/src/search/engine.js +404 -73
- package/src/search/providers/github.js +91 -0
- package/src/search/providers/searxng.js +15 -5
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.2`
|
|
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
|
|
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)
|
package/bin/termsearch.js
CHANGED
|
@@ -32,7 +32,7 @@ function info(msg) { console.log(` ${CYAN}→${RESET} ${msg}`); }
|
|
|
32
32
|
|
|
33
33
|
// ─── Package version ──────────────────────────────────────────────────────
|
|
34
34
|
|
|
35
|
-
let VERSION = '0.3.
|
|
35
|
+
let VERSION = '0.3.1';
|
|
36
36
|
try { VERSION = JSON.parse(readFileSync(PKG_PATH, 'utf8')).version || VERSION; } catch { /* ignore */ }
|
|
37
37
|
|
|
38
38
|
// ─── Data dir + paths ─────────────────────────────────────────────────────
|
package/frontend/dist/app.js
CHANGED
|
@@ -16,6 +16,23 @@ const state = {
|
|
|
16
16
|
loading: false,
|
|
17
17
|
providers: [],
|
|
18
18
|
config: null,
|
|
19
|
+
historyEnabled: localStorage.getItem('ts-save-history') !== '0',
|
|
20
|
+
selectedEngines: (() => {
|
|
21
|
+
try {
|
|
22
|
+
const raw = JSON.parse(localStorage.getItem('ts-engines') || '[]');
|
|
23
|
+
return Array.isArray(raw) ? raw.slice(0, 20).map((v) => String(v || '').trim().toLowerCase()).filter(Boolean) : [];
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
})(),
|
|
28
|
+
searchHistory: (() => {
|
|
29
|
+
try {
|
|
30
|
+
const raw = JSON.parse(localStorage.getItem('ts-history') || '[]');
|
|
31
|
+
return Array.isArray(raw) ? raw.slice(0, 50).filter((q) => typeof q === 'string' && q.trim()) : [];
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
})(),
|
|
19
36
|
};
|
|
20
37
|
|
|
21
38
|
function buildSearchHash(query, category = 'web') {
|
|
@@ -65,6 +82,64 @@ function toggleTheme() {
|
|
|
65
82
|
localStorage.setItem('ts-theme', isLight ? 'light' : 'dark');
|
|
66
83
|
}
|
|
67
84
|
|
|
85
|
+
function setHistoryEnabled(enabled) {
|
|
86
|
+
state.historyEnabled = Boolean(enabled);
|
|
87
|
+
localStorage.setItem('ts-save-history', state.historyEnabled ? '1' : '0');
|
|
88
|
+
if (!state.historyEnabled) {
|
|
89
|
+
state.searchHistory = [];
|
|
90
|
+
localStorage.removeItem('ts-history');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function persistHistory() {
|
|
95
|
+
if (!state.historyEnabled) return;
|
|
96
|
+
localStorage.setItem('ts-history', JSON.stringify(state.searchHistory.slice(0, 50)));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function addSearchToHistory(query) {
|
|
100
|
+
const q = String(query || '').trim();
|
|
101
|
+
if (!q || !state.historyEnabled) return;
|
|
102
|
+
state.searchHistory = [q, ...state.searchHistory.filter((item) => item !== q)].slice(0, 50);
|
|
103
|
+
persistHistory();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const LANG_CANONICAL = new Map([
|
|
107
|
+
['it', 'it-IT'], ['it-it', 'it-IT'],
|
|
108
|
+
['en', 'en-US'], ['en-us', 'en-US'],
|
|
109
|
+
['es', 'es-ES'], ['es-es', 'es-ES'],
|
|
110
|
+
['fr', 'fr-FR'], ['fr-fr', 'fr-FR'],
|
|
111
|
+
['de', 'de-DE'], ['de-de', 'de-DE'],
|
|
112
|
+
['pt', 'pt-PT'], ['pt-pt', 'pt-PT'],
|
|
113
|
+
['ru', 'ru-RU'], ['ru-ru', 'ru-RU'],
|
|
114
|
+
['zh', 'zh-CN'], ['zh-cn', 'zh-CN'],
|
|
115
|
+
['ja', 'ja-JP'], ['ja-jp', 'ja-JP'],
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
function normalizeLangCode(raw) {
|
|
119
|
+
const key = String(raw || '').trim().toLowerCase();
|
|
120
|
+
return LANG_CANONICAL.get(key) || null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function getResolvedLang() {
|
|
124
|
+
const selected = getLang();
|
|
125
|
+
if (selected && selected !== 'auto') return selected;
|
|
126
|
+
const browser = normalizeLangCode(navigator.language || navigator.languages?.[0] || '');
|
|
127
|
+
return browser || 'en-US';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function persistSelectedEngines() {
|
|
131
|
+
localStorage.setItem('ts-engines', JSON.stringify(state.selectedEngines.slice(0, 20)));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function setSelectedEngines(engines) {
|
|
135
|
+
state.selectedEngines = [...new Set(
|
|
136
|
+
(Array.isArray(engines) ? engines : [])
|
|
137
|
+
.map((engine) => String(engine || '').trim().toLowerCase())
|
|
138
|
+
.filter(Boolean)
|
|
139
|
+
)].slice(0, 20);
|
|
140
|
+
persistSelectedEngines();
|
|
141
|
+
}
|
|
142
|
+
|
|
68
143
|
// ─── SVG Icons ────────────────────────────────────────────────────────────
|
|
69
144
|
function svg(paths, size = 16, extra = '') {
|
|
70
145
|
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>`;
|
|
@@ -124,6 +199,38 @@ const LANGS = [
|
|
|
124
199
|
{ code: 'ja-JP', label: '🇯🇵 JA' },
|
|
125
200
|
];
|
|
126
201
|
|
|
202
|
+
const AI_PRESETS = [
|
|
203
|
+
{ id: 'openai', label: 'OpenAI', api_base: 'https://api.openai.com/v1', keyRequired: true, defaultModel: 'gpt-4o-mini' },
|
|
204
|
+
{ id: 'chutes', label: 'Chutes.ai', api_base: 'https://llm.chutes.ai/v1', keyRequired: true, defaultModel: 'deepseek-ai/DeepSeek-V3.2-TEE' },
|
|
205
|
+
{ id: 'openrouter', label: 'OpenRoute/OpenRouter', api_base: 'https://openrouter.ai/api/v1', keyRequired: true, defaultModel: 'openai/gpt-4o-mini' },
|
|
206
|
+
{ id: 'anthropic', label: 'Anthropic', api_base: 'https://api.anthropic.com/v1', keyRequired: true, defaultModel: 'claude-3-5-haiku-latest' },
|
|
207
|
+
{ id: 'ollama', label: 'Ollama', api_base: 'http://127.0.0.1:11434/v1', keyRequired: false, defaultModel: 'qwen3.5:4b' },
|
|
208
|
+
{ id: 'lmstudio', label: 'LM Studio', api_base: 'http://127.0.0.1:1234/v1', keyRequired: false, defaultModel: '' },
|
|
209
|
+
{ id: 'llamacpp', label: 'llama.cpp server', api_base: 'http://127.0.0.1:8080/v1', keyRequired: false, defaultModel: '' },
|
|
210
|
+
];
|
|
211
|
+
|
|
212
|
+
const ENGINE_GROUPS = [
|
|
213
|
+
{ label: 'Web Core', items: ['duckduckgo', 'wikipedia', 'brave', 'startpage', 'qwant', 'mojeek', 'bing', 'google', 'yahoo'] },
|
|
214
|
+
{ label: 'Code & Dev', items: ['github', 'github-api', 'hackernews', 'reddit'] },
|
|
215
|
+
{ label: 'Media', items: ['youtube', 'sepiasearch'] },
|
|
216
|
+
{ label: 'Research', items: ['wikidata', 'crossref', 'openalex', 'openlibrary'] },
|
|
217
|
+
{ label: 'Federated', items: ['mastodon users', 'mastodon hashtags', 'tootfinder', 'lemmy communities', 'lemmy posts'] },
|
|
218
|
+
{ label: 'Torrent', items: ['piratebay', '1337x', 'nyaa'] },
|
|
219
|
+
];
|
|
220
|
+
|
|
221
|
+
const ENGINE_PRESETS = [
|
|
222
|
+
{ id: 'all', label: 'All', engines: [] },
|
|
223
|
+
{ id: 'balanced', label: 'Balanced', engines: ['duckduckgo', 'wikipedia', 'bing', 'startpage', 'github', 'reddit', 'youtube'] },
|
|
224
|
+
{ id: 'github', label: 'GitHub Focus', engines: ['github-api', 'github', 'duckduckgo', 'wikipedia'] },
|
|
225
|
+
];
|
|
226
|
+
|
|
227
|
+
function detectPresetFromBase(base) {
|
|
228
|
+
const raw = String(base || '').toLowerCase();
|
|
229
|
+
if (!raw) return 'custom';
|
|
230
|
+
const preset = AI_PRESETS.find((p) => raw.startsWith(String(p.api_base).toLowerCase()));
|
|
231
|
+
return preset ? preset.id : 'custom';
|
|
232
|
+
}
|
|
233
|
+
|
|
127
234
|
function LangPicker() {
|
|
128
235
|
const wrap = el('div', { className: 'lang-wrap' });
|
|
129
236
|
const sel = el('select', { className: 'lang-select' });
|
|
@@ -132,23 +239,106 @@ function LangPicker() {
|
|
|
132
239
|
if (l.code === getLang()) opt.selected = true;
|
|
133
240
|
sel.append(opt);
|
|
134
241
|
}
|
|
135
|
-
sel.addEventListener('change', () => {
|
|
242
|
+
sel.addEventListener('change', () => {
|
|
243
|
+
setLang(sel.value);
|
|
244
|
+
if (state.query) {
|
|
245
|
+
doSearch(state.query, state.category);
|
|
246
|
+
} else {
|
|
247
|
+
renderApp();
|
|
248
|
+
}
|
|
249
|
+
});
|
|
136
250
|
const arrow = el('span', { className: 'lang-arrow', html: svg('<polyline points="6 9 12 15 18 9"/>', 12) });
|
|
137
251
|
wrap.append(sel, arrow);
|
|
138
252
|
return wrap;
|
|
139
253
|
}
|
|
140
254
|
|
|
255
|
+
function EnginePicker() {
|
|
256
|
+
const details = el('details', { className: 'engine-picker' });
|
|
257
|
+
const selectedCount = state.selectedEngines.length;
|
|
258
|
+
const summary = el('summary', { className: 'engine-picker-summary' },
|
|
259
|
+
el('span', { className: 'engine-picker-title' }, selectedCount ? `Engines (${selectedCount})` : 'Engines (all)'),
|
|
260
|
+
iconEl('chevron', 'engine-chevron'),
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
const body = el('div', { className: 'engine-picker-body' });
|
|
264
|
+
const presetRow = el('div', { className: 'engine-preset-row' });
|
|
265
|
+
ENGINE_PRESETS.forEach((preset) => {
|
|
266
|
+
presetRow.append(el('button', {
|
|
267
|
+
className: `btn ${preset.id === 'balanced' ? 'btn-primary' : ''}`,
|
|
268
|
+
type: 'button',
|
|
269
|
+
onClick: () => {
|
|
270
|
+
setSelectedEngines(preset.engines);
|
|
271
|
+
details.open = false;
|
|
272
|
+
if (state.query) doSearch(state.query, state.category);
|
|
273
|
+
else renderApp();
|
|
274
|
+
},
|
|
275
|
+
}, preset.label));
|
|
276
|
+
});
|
|
277
|
+
body.append(presetRow);
|
|
278
|
+
|
|
279
|
+
ENGINE_GROUPS.forEach((group) => {
|
|
280
|
+
const card = el('div', { className: 'engine-group' });
|
|
281
|
+
card.append(el('div', { className: 'engine-group-title' }, group.label));
|
|
282
|
+
const list = el('div', { className: 'engine-chip-wrap' });
|
|
283
|
+
group.items.forEach((engine) => {
|
|
284
|
+
const checked = state.selectedEngines.includes(engine);
|
|
285
|
+
const id = `engine-${engine.replace(/[^a-z0-9]+/g, '-')}-${Math.random().toString(36).slice(2, 6)}`;
|
|
286
|
+
const input = el('input', { id, type: 'checkbox', ...(checked ? { checked: '' } : {}) });
|
|
287
|
+
const label = el('label', { className: 'engine-chip', for: id }, input, el('span', {}, engine));
|
|
288
|
+
list.append(label);
|
|
289
|
+
});
|
|
290
|
+
card.append(list);
|
|
291
|
+
body.append(card);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
body.append(el('div', { className: 'engine-actions' },
|
|
295
|
+
el('button', {
|
|
296
|
+
className: 'btn btn-primary',
|
|
297
|
+
type: 'button',
|
|
298
|
+
onClick: () => {
|
|
299
|
+
const selected = [...details.querySelectorAll('.engine-chip input:checked')]
|
|
300
|
+
.map((node) => node.parentElement?.textContent?.trim().toLowerCase())
|
|
301
|
+
.filter(Boolean);
|
|
302
|
+
setSelectedEngines(selected);
|
|
303
|
+
details.open = false;
|
|
304
|
+
if (state.query) doSearch(state.query, state.category);
|
|
305
|
+
else renderApp();
|
|
306
|
+
},
|
|
307
|
+
}, 'Apply'),
|
|
308
|
+
el('button', {
|
|
309
|
+
className: 'btn',
|
|
310
|
+
type: 'button',
|
|
311
|
+
onClick: () => {
|
|
312
|
+
setSelectedEngines([]);
|
|
313
|
+
details.open = false;
|
|
314
|
+
if (state.query) doSearch(state.query, state.category);
|
|
315
|
+
else renderApp();
|
|
316
|
+
},
|
|
317
|
+
}, 'Reset'),
|
|
318
|
+
));
|
|
319
|
+
|
|
320
|
+
details.append(summary, body);
|
|
321
|
+
return details;
|
|
322
|
+
}
|
|
323
|
+
|
|
141
324
|
// ─── Search form ──────────────────────────────────────────────────────────
|
|
142
325
|
function SearchForm(value, onSearch) {
|
|
143
326
|
const form = el('form', { className: 'search-form' });
|
|
144
327
|
const sicon = el('span', { className: 'search-icon', html: ICONS.search });
|
|
328
|
+
const listId = `search-history-list-${Math.random().toString(36).slice(2, 8)}`;
|
|
145
329
|
const input = el('input', {
|
|
146
330
|
className: 'search-input', type: 'search',
|
|
147
331
|
placeholder: 'Search...', value: value || '',
|
|
148
332
|
autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: 'false',
|
|
333
|
+
...(state.historyEnabled ? { list: listId } : {}),
|
|
149
334
|
});
|
|
150
335
|
const btn = el('button', { className: 'search-btn', type: 'submit', html: ICONS.search });
|
|
151
336
|
form.append(sicon, input, btn);
|
|
337
|
+
if (state.historyEnabled && state.searchHistory.length) {
|
|
338
|
+
const dl = el('datalist', { id: listId });
|
|
339
|
+
state.searchHistory.slice(0, 12).forEach((q) => dl.append(el('option', { value: q })));
|
|
340
|
+
form.append(dl);
|
|
341
|
+
}
|
|
152
342
|
form.addEventListener('submit', (e) => {
|
|
153
343
|
e.preventDefault();
|
|
154
344
|
const q = input.value.trim();
|
|
@@ -472,8 +662,11 @@ function flattenSocialResults(payload) {
|
|
|
472
662
|
].filter((item) => item.url);
|
|
473
663
|
}
|
|
474
664
|
|
|
475
|
-
async function runSearchProgressive(q, lang, category) {
|
|
665
|
+
async function runSearchProgressive(q, lang, category, engines = []) {
|
|
476
666
|
const params = new URLSearchParams({ q, lang, cat: category });
|
|
667
|
+
if (Array.isArray(engines) && engines.length > 0) {
|
|
668
|
+
params.set('engines', engines.join(','));
|
|
669
|
+
}
|
|
477
670
|
const response = await fetch(`/api/search-stream?${params.toString()}`);
|
|
478
671
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
479
672
|
|
|
@@ -531,6 +724,7 @@ async function runSearchProgressive(q, lang, category) {
|
|
|
531
724
|
|
|
532
725
|
async function doSearch(q, category = state.category) {
|
|
533
726
|
if (!q.trim()) return;
|
|
727
|
+
addSearchToHistory(q);
|
|
534
728
|
state.category = ['web', 'images', 'news'].includes(category) ? category : 'web';
|
|
535
729
|
state.loading = true;
|
|
536
730
|
state.results = [];
|
|
@@ -544,11 +738,14 @@ async function doSearch(q, category = state.category) {
|
|
|
544
738
|
state.socialData = [];
|
|
545
739
|
renderApp();
|
|
546
740
|
|
|
547
|
-
const lang =
|
|
741
|
+
const lang = getResolvedLang();
|
|
742
|
+
const engines = state.selectedEngines.slice();
|
|
548
743
|
|
|
549
744
|
try {
|
|
550
|
-
const searchPromise = runSearchProgressive(q, lang, state.category).catch(async () => {
|
|
551
|
-
|
|
745
|
+
const searchPromise = runSearchProgressive(q, lang, state.category, engines).catch(async () => {
|
|
746
|
+
const p = new URLSearchParams({ q, lang, cat: state.category });
|
|
747
|
+
if (engines.length > 0) p.set('engines', engines.join(','));
|
|
748
|
+
return api(`/api/search?${p.toString()}`);
|
|
552
749
|
});
|
|
553
750
|
const promises = [
|
|
554
751
|
searchPromise,
|
|
@@ -677,6 +874,7 @@ function renderApp() {
|
|
|
677
874
|
type: 'button',
|
|
678
875
|
}, cat.label));
|
|
679
876
|
});
|
|
877
|
+
categoryBar.append(EnginePicker());
|
|
680
878
|
|
|
681
879
|
const main = el('div', { className: 'main' });
|
|
682
880
|
|
|
@@ -696,6 +894,7 @@ function renderApp() {
|
|
|
696
894
|
const meta = el('div', { className: 'results-meta' });
|
|
697
895
|
meta.append(document.createTextNode(`${state.results.length} results`));
|
|
698
896
|
if (state.providers.length) meta.append(document.createTextNode(' · ' + state.providers.join(', ')));
|
|
897
|
+
if (state.selectedEngines.length) meta.append(document.createTextNode(' · engines: ' + state.selectedEngines.join(', ')));
|
|
699
898
|
main.append(meta);
|
|
700
899
|
}
|
|
701
900
|
|
|
@@ -728,7 +927,10 @@ function renderApp() {
|
|
|
728
927
|
function renderHome(app) {
|
|
729
928
|
const home = el('div', { className: 'home' },
|
|
730
929
|
el('div', { className: 'home-logo' }, 'Term', el('strong', {}, 'Search')),
|
|
731
|
-
el('div', { className: 'home-tagline' },
|
|
930
|
+
el('div', { className: 'home-tagline' },
|
|
931
|
+
el('span', { className: 'tagline-desktop' }, 'Personal search engine · privacy-first · local-first'),
|
|
932
|
+
el('span', { className: 'tagline-mobile' }, 'Private local search'),
|
|
933
|
+
),
|
|
732
934
|
el('div', { className: 'home-search' }, SearchForm('', (q) => { state.query = q; state.category = 'web'; doSearch(q, 'web'); })),
|
|
733
935
|
el('div', { className: 'home-actions' },
|
|
734
936
|
LangPicker(),
|
|
@@ -767,6 +969,7 @@ async function renderSettings() {
|
|
|
767
969
|
const brave = cfg.brave || {};
|
|
768
970
|
const mojeek = cfg.mojeek || {};
|
|
769
971
|
const searxng = cfg.searxng || {};
|
|
972
|
+
const detectedPreset = detectPresetFromBase(ai.api_base);
|
|
770
973
|
|
|
771
974
|
const header = el('div', { className: 'header' },
|
|
772
975
|
el('button', { className: 'btn', onClick: () => history.back() }, iconEl('back'), ' Back'),
|
|
@@ -788,11 +991,152 @@ async function renderSettings() {
|
|
|
788
991
|
}
|
|
789
992
|
|
|
790
993
|
const saveAlertEl = el('div', { style: 'display:none' });
|
|
994
|
+
const aiModelStatus = el('div', { id: 'ai-model-status', style: 'display:none' });
|
|
995
|
+
const historyInfoEl = el('div', { id: 'history-preview', className: 'form-hint', style: 'margin-top:8px' });
|
|
996
|
+
const presetSelect = el('select', { className: 'form-input', id: 'ai-preset' });
|
|
997
|
+
presetSelect.append(el('option', { value: 'custom' }, 'Custom'));
|
|
998
|
+
AI_PRESETS.forEach((preset) => {
|
|
999
|
+
const opt = el('option', { value: preset.id }, preset.label);
|
|
1000
|
+
if (preset.id === detectedPreset) opt.selected = true;
|
|
1001
|
+
presetSelect.append(opt);
|
|
1002
|
+
});
|
|
1003
|
+
const modelInput = makeInput('ai-model', ai.model, 'qwen3.5:4b');
|
|
1004
|
+
const modelSelect = el('select', { className: 'form-input', id: 'ai-model-select' },
|
|
1005
|
+
el('option', { value: '' }, 'Load models first…')
|
|
1006
|
+
);
|
|
1007
|
+
const modelQuickList = el('div', { id: 'ai-model-quick-list', className: 'model-quick-list' },
|
|
1008
|
+
el('div', { className: 'form-hint' }, 'No models loaded.')
|
|
1009
|
+
);
|
|
1010
|
+
let loadedModels = [];
|
|
1011
|
+
|
|
1012
|
+
function setModelStatus(message, type = 'info') {
|
|
1013
|
+
aiModelStatus.style.display = 'block';
|
|
1014
|
+
aiModelStatus.className = `alert alert-${type}`;
|
|
1015
|
+
aiModelStatus.textContent = message;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function renderHistoryPreview() {
|
|
1019
|
+
if (!state.historyEnabled) {
|
|
1020
|
+
historyInfoEl.textContent = 'Search history disabled.';
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
if (!state.searchHistory.length) {
|
|
1024
|
+
historyInfoEl.textContent = 'No searches saved yet.';
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
historyInfoEl.textContent = `Recent: ${state.searchHistory.slice(0, 8).join(' · ')}`;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function populateModelList(models) {
|
|
1031
|
+
loadedModels = models.slice();
|
|
1032
|
+
modelSelect.innerHTML = '';
|
|
1033
|
+
for (const model of models) {
|
|
1034
|
+
modelSelect.append(el('option', { value: model }, model));
|
|
1035
|
+
}
|
|
1036
|
+
modelQuickList.innerHTML = '';
|
|
1037
|
+
models.forEach((model) => {
|
|
1038
|
+
modelQuickList.append(el('button', {
|
|
1039
|
+
className: 'model-chip-btn',
|
|
1040
|
+
type: 'button',
|
|
1041
|
+
onClick: () => {
|
|
1042
|
+
const modelField = document.getElementById('ai-model');
|
|
1043
|
+
if (modelField) modelField.value = model;
|
|
1044
|
+
modelSelect.value = model;
|
|
1045
|
+
[...modelQuickList.querySelectorAll('.model-chip-btn')].forEach((n) => n.classList.remove('active'));
|
|
1046
|
+
const active = [...modelQuickList.querySelectorAll('.model-chip-btn')].find((n) => n.textContent === model);
|
|
1047
|
+
if (active) active.classList.add('active');
|
|
1048
|
+
},
|
|
1049
|
+
}, model));
|
|
1050
|
+
});
|
|
1051
|
+
const current = val('ai-model');
|
|
1052
|
+
if (current && models.includes(current)) {
|
|
1053
|
+
modelSelect.value = current;
|
|
1054
|
+
const active = [...modelQuickList.querySelectorAll('.model-chip-btn')].find((n) => n.textContent === current);
|
|
1055
|
+
if (active) active.classList.add('active');
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
async function loadModels(trigger = 'manual') {
|
|
1060
|
+
const base = val('ai-base');
|
|
1061
|
+
const key = val('ai-key');
|
|
1062
|
+
const presetId = val('ai-preset');
|
|
1063
|
+
if (!base) {
|
|
1064
|
+
setModelStatus('Set API endpoint first.', 'info');
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
const preset = AI_PRESETS.find((p) => p.id === presetId);
|
|
1068
|
+
if (preset?.keyRequired && !key) {
|
|
1069
|
+
setModelStatus(`Insert API key for ${preset.label} to load models.`, 'info');
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
const btn = document.getElementById('ai-load-models-btn');
|
|
1074
|
+
if (btn) btn.disabled = true;
|
|
1075
|
+
setModelStatus('Loading models…', 'info');
|
|
1076
|
+
try {
|
|
1077
|
+
const res = await api('/api/config/models', {
|
|
1078
|
+
method: 'POST',
|
|
1079
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1080
|
+
body: JSON.stringify({ api_base: base, api_key: key, preset: presetId }),
|
|
1081
|
+
});
|
|
1082
|
+
const models = Array.isArray(res.models) ? res.models : [];
|
|
1083
|
+
if (!models.length) {
|
|
1084
|
+
setModelStatus(`No models found${res.provider ? ` for ${res.provider}` : ''}.`, 'err');
|
|
1085
|
+
modelSelect.innerHTML = '';
|
|
1086
|
+
modelSelect.append(el('option', { value: '' }, 'No models found'));
|
|
1087
|
+
modelQuickList.innerHTML = '';
|
|
1088
|
+
modelQuickList.append(el('div', { className: 'form-hint' }, 'No models loaded.'));
|
|
1089
|
+
loadedModels = [];
|
|
1090
|
+
return;
|
|
1091
|
+
}
|
|
1092
|
+
populateModelList(models);
|
|
1093
|
+
const current = val('ai-model');
|
|
1094
|
+
if (!current || !models.includes(current)) {
|
|
1095
|
+
const modelField = document.getElementById('ai-model');
|
|
1096
|
+
if (modelField) modelField.value = models[0];
|
|
1097
|
+
modelSelect.value = models[0];
|
|
1098
|
+
}
|
|
1099
|
+
setModelStatus(`Loaded ${models.length} model(s)${res.provider ? ` from ${res.provider}` : ''}.`, 'ok');
|
|
1100
|
+
} catch (e) {
|
|
1101
|
+
setModelStatus(`Model load failed: ${e.message}`, 'err');
|
|
1102
|
+
} finally {
|
|
1103
|
+
if (btn) btn.disabled = false;
|
|
1104
|
+
if (trigger === 'manual') {
|
|
1105
|
+
setTimeout(() => { aiModelStatus.style.display = 'none'; }, 4500);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
function applyPreset() {
|
|
1111
|
+
const presetId = val('ai-preset');
|
|
1112
|
+
const preset = AI_PRESETS.find((p) => p.id === presetId);
|
|
1113
|
+
const hintEl = document.getElementById('ai-preset-hint');
|
|
1114
|
+
if (!preset) {
|
|
1115
|
+
if (hintEl) hintEl.textContent = 'Custom endpoint mode.';
|
|
1116
|
+
return;
|
|
1117
|
+
}
|
|
1118
|
+
const baseField = document.getElementById('ai-base');
|
|
1119
|
+
const modelField = document.getElementById('ai-model');
|
|
1120
|
+
if (baseField) baseField.value = preset.api_base;
|
|
1121
|
+
if (modelField && (!modelField.value || modelField.value === ai.model) && preset.defaultModel) {
|
|
1122
|
+
modelField.value = preset.defaultModel;
|
|
1123
|
+
}
|
|
1124
|
+
if (hintEl) {
|
|
1125
|
+
hintEl.textContent = preset.keyRequired
|
|
1126
|
+
? `Preset ready: insert API key for ${preset.label}, then load models.`
|
|
1127
|
+
: `Preset ready: local endpoint (${preset.label}).`;
|
|
1128
|
+
}
|
|
1129
|
+
if (!preset.keyRequired || val('ai-key')) {
|
|
1130
|
+
loadModels('preset');
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
791
1133
|
|
|
792
1134
|
async function saveSettings() {
|
|
793
1135
|
const aiKey = val('ai-key');
|
|
794
1136
|
const braveKey = val('brave-key');
|
|
795
1137
|
const mojeekKey = val('mojeek-key');
|
|
1138
|
+
setHistoryEnabled(isChecked('history-enabled'));
|
|
1139
|
+
renderHistoryPreview();
|
|
796
1140
|
const update = {
|
|
797
1141
|
ai: {
|
|
798
1142
|
api_base: val('ai-base'),
|
|
@@ -920,15 +1264,23 @@ async function renderSettings() {
|
|
|
920
1264
|
el('div', { className: 'settings-section' },
|
|
921
1265
|
el('h2', {}, 'AI Configuration (optional)'),
|
|
922
1266
|
el('div', { className: 'alert alert-info', style: 'margin-bottom:12px;font-size:11px' },
|
|
923
|
-
'
|
|
1267
|
+
'Preset ready: just set key (if required), load models, save.'
|
|
1268
|
+
),
|
|
1269
|
+
el('div', { className: 'form-group' },
|
|
1270
|
+
el('label', { className: 'form-label', for: 'ai-preset' }, 'Preset'),
|
|
1271
|
+
presetSelect,
|
|
1272
|
+
el('div', { className: 'form-row', style: 'margin-top:6px' },
|
|
1273
|
+
el('button', { id: 'ai-preset-apply-btn', className: 'btn', onClick: applyPreset, type: 'button' }, 'Apply preset'),
|
|
1274
|
+
el('span', { id: 'ai-preset-hint', className: 'form-hint', style: 'margin-top:0' }, 'Select a preset to prefill endpoint/model.'),
|
|
1275
|
+
),
|
|
924
1276
|
),
|
|
925
1277
|
el('div', { className: 'form-group' },
|
|
926
1278
|
el('label', { className: 'form-label', for: 'ai-base' }, 'API Endpoint'),
|
|
927
1279
|
makeInput('ai-base', ai.api_base, 'http://localhost:11434/v1'),
|
|
928
1280
|
el('div', { className: 'form-hint' },
|
|
929
|
-
'
|
|
1281
|
+
'Included presets: Chutes.ai · Anthropic · OpenAI · OpenRoute/OpenRouter · llama.cpp · Ollama · LM Studio',
|
|
930
1282
|
el('br', {}),
|
|
931
|
-
'
|
|
1283
|
+
'You can also keep custom OpenAI-compatible endpoints.',
|
|
932
1284
|
),
|
|
933
1285
|
),
|
|
934
1286
|
el('div', { className: 'form-group' },
|
|
@@ -938,11 +1290,15 @@ async function renderSettings() {
|
|
|
938
1290
|
),
|
|
939
1291
|
el('div', { className: 'form-group' },
|
|
940
1292
|
el('label', { className: 'form-label', for: 'ai-model' }, 'Model'),
|
|
941
|
-
|
|
942
|
-
el('div', { className: 'form-hint' },
|
|
943
|
-
|
|
944
|
-
|
|
1293
|
+
modelInput,
|
|
1294
|
+
el('div', { className: 'form-hint', style: 'margin-top:4px' }, 'Model list (tap to open):'),
|
|
1295
|
+
modelSelect,
|
|
1296
|
+
modelQuickList,
|
|
1297
|
+
el('div', { className: 'form-row', style: 'margin-top:6px' },
|
|
1298
|
+
el('button', { id: 'ai-load-models-btn', className: 'btn', onClick: () => loadModels('manual'), type: 'button' }, 'Load models'),
|
|
1299
|
+
el('span', { className: 'form-hint', style: 'margin-top:0' }, 'Auto-loads from endpoint with current key.'),
|
|
945
1300
|
),
|
|
1301
|
+
aiModelStatus,
|
|
946
1302
|
),
|
|
947
1303
|
el('div', { className: 'form-row', style: 'margin-top:4px' },
|
|
948
1304
|
el('button', { id: 'ai-test-btn', className: 'btn btn-primary', onClick: testAi }, 'Test Connection'),
|
|
@@ -951,6 +1307,31 @@ async function renderSettings() {
|
|
|
951
1307
|
el('div', { id: 'ai-test-result', style: 'display:none' }),
|
|
952
1308
|
),
|
|
953
1309
|
|
|
1310
|
+
// Search history
|
|
1311
|
+
el('div', { className: 'settings-section' },
|
|
1312
|
+
el('h2', {}, 'Search History'),
|
|
1313
|
+
el('div', { className: 'toggle-row' },
|
|
1314
|
+
el('span', { className: 'toggle-label' }, 'Save search history'),
|
|
1315
|
+
el('label', { className: 'toggle' },
|
|
1316
|
+
el('input', { type: 'checkbox', id: 'history-enabled', ...(state.historyEnabled ? { checked: '' } : {}) }),
|
|
1317
|
+
el('span', { className: 'toggle-slider' }),
|
|
1318
|
+
),
|
|
1319
|
+
),
|
|
1320
|
+
historyInfoEl,
|
|
1321
|
+
el('div', { style: 'margin-top:10px;display:flex;gap:8px;align-items:center' },
|
|
1322
|
+
el('button', {
|
|
1323
|
+
className: 'btn',
|
|
1324
|
+
type: 'button',
|
|
1325
|
+
onClick: () => {
|
|
1326
|
+
state.searchHistory = [];
|
|
1327
|
+
localStorage.removeItem('ts-history');
|
|
1328
|
+
renderHistoryPreview();
|
|
1329
|
+
},
|
|
1330
|
+
}, 'Clear history'),
|
|
1331
|
+
el('button', { className: 'btn btn-primary', onClick: saveSettings, type: 'button' }, 'Save preference'),
|
|
1332
|
+
),
|
|
1333
|
+
),
|
|
1334
|
+
|
|
954
1335
|
// Providers
|
|
955
1336
|
el('div', { className: 'settings-section' },
|
|
956
1337
|
el('h2', {}, 'Search Providers'),
|
|
@@ -1014,7 +1395,7 @@ async function renderSettings() {
|
|
|
1014
1395
|
// Server info
|
|
1015
1396
|
el('div', { className: 'settings-section' },
|
|
1016
1397
|
el('h2', {}, 'Server Info'),
|
|
1017
|
-
el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Version'), el('span', { className: 'info-val' }, health?.version || '0.3.
|
|
1398
|
+
el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Version'), el('span', { className: 'info-val' }, health?.version || '0.3.2')),
|
|
1018
1399
|
el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Active providers'), el('span', { className: 'info-val' }, (health?.providers || []).join(', ') || 'none')),
|
|
1019
1400
|
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')),
|
|
1020
1401
|
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')),
|
|
@@ -1041,6 +1422,47 @@ async function renderSettings() {
|
|
|
1041
1422
|
);
|
|
1042
1423
|
|
|
1043
1424
|
app.append(header, main);
|
|
1425
|
+
renderHistoryPreview();
|
|
1426
|
+
document.getElementById('history-enabled')?.addEventListener('change', (e) => {
|
|
1427
|
+
setHistoryEnabled(Boolean(e.target?.checked));
|
|
1428
|
+
renderHistoryPreview();
|
|
1429
|
+
});
|
|
1430
|
+
modelSelect.addEventListener('change', () => {
|
|
1431
|
+
if (!modelSelect.value) return;
|
|
1432
|
+
const modelField = document.getElementById('ai-model');
|
|
1433
|
+
if (modelField) modelField.value = modelSelect.value;
|
|
1434
|
+
[...modelQuickList.querySelectorAll('.model-chip-btn')].forEach((n) => {
|
|
1435
|
+
n.classList.toggle('active', n.textContent === modelSelect.value);
|
|
1436
|
+
});
|
|
1437
|
+
});
|
|
1438
|
+
modelSelect.addEventListener('focus', () => {
|
|
1439
|
+
if (!loadedModels.length) loadModels('auto');
|
|
1440
|
+
});
|
|
1441
|
+
modelInput.addEventListener('input', () => {
|
|
1442
|
+
const current = modelInput.value || '';
|
|
1443
|
+
if ([...modelSelect.options].some((opt) => opt.value === current)) {
|
|
1444
|
+
modelSelect.value = modelInput.value;
|
|
1445
|
+
}
|
|
1446
|
+
[...modelQuickList.querySelectorAll('.model-chip-btn')].forEach((n) => {
|
|
1447
|
+
n.classList.toggle('active', n.textContent === current);
|
|
1448
|
+
n.style.display = !current || n.textContent.toLowerCase().includes(current.toLowerCase()) ? 'inline-flex' : 'none';
|
|
1449
|
+
});
|
|
1450
|
+
});
|
|
1451
|
+
document.getElementById('ai-key')?.addEventListener('change', () => loadModels('auto'));
|
|
1452
|
+
document.getElementById('ai-base')?.addEventListener('change', () => loadModels('auto'));
|
|
1453
|
+
presetSelect.addEventListener('change', applyPreset);
|
|
1454
|
+
if (detectedPreset && detectedPreset !== 'custom') {
|
|
1455
|
+
const hintEl = document.getElementById('ai-preset-hint');
|
|
1456
|
+
const preset = AI_PRESETS.find((p) => p.id === detectedPreset);
|
|
1457
|
+
if (hintEl && preset) {
|
|
1458
|
+
hintEl.textContent = preset.keyRequired
|
|
1459
|
+
? `Preset detected: ${preset.label}. Insert key and load models.`
|
|
1460
|
+
: `Preset detected: ${preset.label}.`;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
if (ai.api_base && (!AI_PRESETS.find((p) => p.id === detectedPreset)?.keyRequired)) {
|
|
1464
|
+
loadModels('auto');
|
|
1465
|
+
}
|
|
1044
1466
|
}
|
|
1045
1467
|
|
|
1046
1468
|
// ─── Bootstrap ────────────────────────────────────────────────────────────
|