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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # TermSearch - Personal Search Engine
2
2
 
3
- [![Status](https://img.shields.io/badge/Status-0.3.1-blue.svg)](#project-status)
3
+ [![Status](https://img.shields.io/badge/Status-0.3.3-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.1`
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=
@@ -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": ""
@@ -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: 'openai', label: 'OpenAI', api_base: 'https://api.openai.com/v1', keyRequired: true, defaultModel: 'gpt-4o-mini' },
159
- { id: 'chutes', label: 'Chutes.ai', api_base: 'https://llm.chutes.ai/v1', keyRequired: true, defaultModel: 'deepseek-ai/DeepSeek-V3.2-TEE' },
160
- { id: 'openrouter', label: 'OpenRoute/OpenRouter', api_base: 'https://openrouter.ai/api/v1', keyRequired: true, defaultModel: 'openai/gpt-4o-mini' },
161
- { id: 'anthropic', label: 'Anthropic', api_base: 'https://api.anthropic.com/v1', keyRequired: true, defaultModel: 'claude-3-5-haiku-latest' },
162
- { id: 'ollama', label: 'Ollama', api_base: 'http://127.0.0.1:11434/v1', keyRequired: false, defaultModel: 'qwen3.5:4b' },
163
- { id: 'lmstudio', label: 'LM Studio', api_base: 'http://127.0.0.1:1234/v1', keyRequired: false, defaultModel: '' },
164
- { id: 'llamacpp', label: 'llama.cpp server', api_base: 'http://127.0.0.1:8080/v1', keyRequired: false, defaultModel: '' },
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', () => { setLang(sel.value); });
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 dotsClass = state.aiStatus === 'done' ? 'done' : state.aiStatus === 'error' ? 'error' : '';
287
- const statusText = state.aiStatus === 'loading' ? 'Thinking…' : state.aiStatus === 'streaming' ? 'Generating…' : state.aiStatus === 'done' ? 'AI Summary' : 'Error';
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
- const header = el('div', { className: 'panel-header' },
295
- el('div', { className: 'panel-header-left' },
296
- dotsEl,
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
- const content = el('div', { className: 'ai-content' });
303
- if (state.aiError) {
304
- content.style.color = '#f87171';
305
- content.textContent = state.aiError;
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
- content.textContent = state.aiSummary;
491
+ contentEl.textContent = state.aiSummary;
308
492
  }
309
493
 
310
- panel.innerHTML = '';
311
- panel.append(header, content);
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
- if (state.aiMeta?.fetchedCount) {
314
- panel.append(el('div', { className: 'ai-meta' }, `Read ${state.aiMeta.fetchedCount} pages`));
315
- }
316
- if (state.aiStatus === 'streaming') {
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 = getLang();
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
- return api(`/api/search?q=${encodeURIComponent(q)}&lang=${lang}&cat=${encodeURIComponent(state.category)}`);
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) { state.aiSummary += d.chunk; renderAiPanel(); }
681
- else if (d.error) { state.aiStatus = 'error'; state.aiError = d.message || d.error; renderAiPanel(); }
682
- else if (d.model || d.sites) { state.aiStatus = 'done'; state.aiMeta = { fetchedCount: d.fetchedCount, model: d.model }; renderAiPanel(); }
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 · local-first'),
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
- modelInput.setAttribute('list', 'ai-model-list');
861
- const modelDataList = el('datalist', { id: 'ai-model-list' });
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
- modelDataList.innerHTML = '';
1128
+ loadedModels = models.slice();
1129
+ modelSelect.innerHTML = '';
883
1130
  for (const model of models) {
884
- modelDataList.append(el('option', { value: model }));
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: Chutes.ai · Anthropic · OpenAI · OpenRoute/OpenRouter · llama.cpp · Ollama · LM Studio',
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
- modelDataList,
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.1')),
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);