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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # TermSearch - Personal Search Engine
2
2
 
3
- [![Status](https://img.shields.io/badge/Status-0.3.0-blue.svg)](#project-status)
3
+ [![Status](https://img.shields.io/badge/Status-0.3.2-blue.svg)](#project-status)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
5
  [![Node.js](https://img.shields.io/badge/Node.js-18%2B-green.svg)](https://nodejs.org)
6
6
  [![Target](https://img.shields.io/badge/Target-Termux%20%2F%20Linux%20%2F%20macOS-green.svg)](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.0`
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.0';
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 ─────────────────────────────────────────────────────
@@ -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', () => { setLang(sel.value); });
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 = getLang();
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
- return api(`/api/search?q=${encodeURIComponent(q)}&lang=${lang}&cat=${encodeURIComponent(state.category)}`);
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' }, 'Personal search engine · privacy-first · local-first'),
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
- 'Any OpenAI-compatible endpoint: Ollama, LM Studio, Groq, OpenAI, Z.AI, Chutes.ai…'
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
- 'Localhost: localhost:11434/v1 (Ollama) · localhost:1234/v1 (LM Studio)',
1281
+ 'Included presets: Chutes.ai · Anthropic · OpenAI · OpenRoute/OpenRouter · llama.cpp · Ollama · LM Studio',
930
1282
  el('br', {}),
931
- 'Chutes/OpenAI-compatible: llm.chutes.ai/v1 · OpenAI: api.openai.com/v1',
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
- makeInput('ai-model', ai.model, 'qwen3.5:4b'),
942
- el('div', { className: 'form-hint' },
943
- 'Localhost: qwen3.5:4b, llama3.2, mistral… · Chutes: deepseek-ai/DeepSeek-V3.2-TEE',
944
- el('br', {}), 'OpenAI: gpt-4o-mini',
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.0')),
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 ────────────────────────────────────────────────────────────