termsearch 0.3.1 → 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.1-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.1`
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)
@@ -17,6 +17,14 @@ const state = {
17
17
  providers: [],
18
18
  config: null,
19
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
+ })(),
20
28
  searchHistory: (() => {
21
29
  try {
22
30
  const raw = JSON.parse(localStorage.getItem('ts-history') || '[]');
@@ -95,6 +103,43 @@ function addSearchToHistory(query) {
95
103
  persistHistory();
96
104
  }
97
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
+
98
143
  // ─── SVG Icons ────────────────────────────────────────────────────────────
99
144
  function svg(paths, size = 16, extra = '') {
100
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>`;
@@ -164,6 +209,21 @@ const AI_PRESETS = [
164
209
  { id: 'llamacpp', label: 'llama.cpp server', api_base: 'http://127.0.0.1:8080/v1', keyRequired: false, defaultModel: '' },
165
210
  ];
166
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
+
167
227
  function detectPresetFromBase(base) {
168
228
  const raw = String(base || '').toLowerCase();
169
229
  if (!raw) return 'custom';
@@ -179,12 +239,88 @@ function LangPicker() {
179
239
  if (l.code === getLang()) opt.selected = true;
180
240
  sel.append(opt);
181
241
  }
182
- 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
+ });
183
250
  const arrow = el('span', { className: 'lang-arrow', html: svg('<polyline points="6 9 12 15 18 9"/>', 12) });
184
251
  wrap.append(sel, arrow);
185
252
  return wrap;
186
253
  }
187
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
+
188
324
  // ─── Search form ──────────────────────────────────────────────────────────
189
325
  function SearchForm(value, onSearch) {
190
326
  const form = el('form', { className: 'search-form' });
@@ -526,8 +662,11 @@ function flattenSocialResults(payload) {
526
662
  ].filter((item) => item.url);
527
663
  }
528
664
 
529
- async function runSearchProgressive(q, lang, category) {
665
+ async function runSearchProgressive(q, lang, category, engines = []) {
530
666
  const params = new URLSearchParams({ q, lang, cat: category });
667
+ if (Array.isArray(engines) && engines.length > 0) {
668
+ params.set('engines', engines.join(','));
669
+ }
531
670
  const response = await fetch(`/api/search-stream?${params.toString()}`);
532
671
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
533
672
 
@@ -599,11 +738,14 @@ async function doSearch(q, category = state.category) {
599
738
  state.socialData = [];
600
739
  renderApp();
601
740
 
602
- const lang = getLang();
741
+ const lang = getResolvedLang();
742
+ const engines = state.selectedEngines.slice();
603
743
 
604
744
  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)}`);
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()}`);
607
749
  });
608
750
  const promises = [
609
751
  searchPromise,
@@ -732,6 +874,7 @@ function renderApp() {
732
874
  type: 'button',
733
875
  }, cat.label));
734
876
  });
877
+ categoryBar.append(EnginePicker());
735
878
 
736
879
  const main = el('div', { className: 'main' });
737
880
 
@@ -751,6 +894,7 @@ function renderApp() {
751
894
  const meta = el('div', { className: 'results-meta' });
752
895
  meta.append(document.createTextNode(`${state.results.length} results`));
753
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(', ')));
754
898
  main.append(meta);
755
899
  }
756
900
 
@@ -785,7 +929,7 @@ function renderHome(app) {
785
929
  el('div', { className: 'home-logo' }, 'Term', el('strong', {}, 'Search')),
786
930
  el('div', { className: 'home-tagline' },
787
931
  el('span', { className: 'tagline-desktop' }, 'Personal search engine · privacy-first · local-first'),
788
- el('span', { className: 'tagline-mobile' }, 'Private search · local-first'),
932
+ el('span', { className: 'tagline-mobile' }, 'Private local search'),
789
933
  ),
790
934
  el('div', { className: 'home-search' }, SearchForm('', (q) => { state.query = q; state.category = 'web'; doSearch(q, 'web'); })),
791
935
  el('div', { className: 'home-actions' },
@@ -857,8 +1001,13 @@ async function renderSettings() {
857
1001
  presetSelect.append(opt);
858
1002
  });
859
1003
  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' });
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 = [];
862
1011
 
863
1012
  function setModelStatus(message, type = 'info') {
864
1013
  aiModelStatus.style.display = 'block';
@@ -879,9 +1028,31 @@ async function renderSettings() {
879
1028
  }
880
1029
 
881
1030
  function populateModelList(models) {
882
- modelDataList.innerHTML = '';
1031
+ loadedModels = models.slice();
1032
+ modelSelect.innerHTML = '';
883
1033
  for (const model of models) {
884
- modelDataList.append(el('option', { value: model }));
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');
885
1056
  }
886
1057
  }
887
1058
 
@@ -911,6 +1082,11 @@ async function renderSettings() {
911
1082
  const models = Array.isArray(res.models) ? res.models : [];
912
1083
  if (!models.length) {
913
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 = [];
914
1090
  return;
915
1091
  }
916
1092
  populateModelList(models);
@@ -918,6 +1094,7 @@ async function renderSettings() {
918
1094
  if (!current || !models.includes(current)) {
919
1095
  const modelField = document.getElementById('ai-model');
920
1096
  if (modelField) modelField.value = models[0];
1097
+ modelSelect.value = models[0];
921
1098
  }
922
1099
  setModelStatus(`Loaded ${models.length} model(s)${res.provider ? ` from ${res.provider}` : ''}.`, 'ok');
923
1100
  } catch (e) {
@@ -1114,7 +1291,9 @@ async function renderSettings() {
1114
1291
  el('div', { className: 'form-group' },
1115
1292
  el('label', { className: 'form-label', for: 'ai-model' }, 'Model'),
1116
1293
  modelInput,
1117
- modelDataList,
1294
+ el('div', { className: 'form-hint', style: 'margin-top:4px' }, 'Model list (tap to open):'),
1295
+ modelSelect,
1296
+ modelQuickList,
1118
1297
  el('div', { className: 'form-row', style: 'margin-top:6px' },
1119
1298
  el('button', { id: 'ai-load-models-btn', className: 'btn', onClick: () => loadModels('manual'), type: 'button' }, 'Load models'),
1120
1299
  el('span', { className: 'form-hint', style: 'margin-top:0' }, 'Auto-loads from endpoint with current key.'),
@@ -1216,7 +1395,7 @@ async function renderSettings() {
1216
1395
  // Server info
1217
1396
  el('div', { className: 'settings-section' },
1218
1397
  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')),
1398
+ el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Version'), el('span', { className: 'info-val' }, health?.version || '0.3.2')),
1220
1399
  el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Active providers'), el('span', { className: 'info-val' }, (health?.providers || []).join(', ') || 'none')),
1221
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')),
1222
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')),
@@ -1248,6 +1427,27 @@ async function renderSettings() {
1248
1427
  setHistoryEnabled(Boolean(e.target?.checked));
1249
1428
  renderHistoryPreview();
1250
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
+ });
1251
1451
  document.getElementById('ai-key')?.addEventListener('change', () => loadModels('auto'));
1252
1452
  document.getElementById('ai-base')?.addEventListener('change', () => loadModels('auto'));
1253
1453
  presetSelect.addEventListener('change', applyPreset);
@@ -159,6 +159,97 @@ a:hover { color: var(--link-h); }
159
159
  background: rgba(109,40,217,0.15);
160
160
  }
161
161
 
162
+ .engine-picker {
163
+ margin-left: auto;
164
+ position: relative;
165
+ }
166
+ .engine-picker summary {
167
+ list-style: none;
168
+ }
169
+ .engine-picker summary::-webkit-details-marker {
170
+ display: none;
171
+ }
172
+ .engine-picker-summary {
173
+ display: inline-flex;
174
+ align-items: center;
175
+ gap: 6px;
176
+ padding: 5px 10px;
177
+ border-radius: 999px;
178
+ border: 1px solid var(--border);
179
+ background: var(--bg2);
180
+ color: var(--text2);
181
+ font-size: 11px;
182
+ cursor: pointer;
183
+ }
184
+ .engine-picker-title {
185
+ white-space: nowrap;
186
+ }
187
+ .engine-chevron {
188
+ opacity: 0.8;
189
+ }
190
+ .engine-picker[open] .engine-chevron {
191
+ transform: rotate(180deg);
192
+ }
193
+ .engine-picker-body {
194
+ position: absolute;
195
+ right: 0;
196
+ top: calc(100% + 8px);
197
+ width: min(92vw, 560px);
198
+ max-height: min(65vh, 560px);
199
+ overflow: auto;
200
+ background: var(--bg2);
201
+ border: 1px solid var(--border);
202
+ border-radius: var(--radius);
203
+ padding: 10px;
204
+ z-index: 120;
205
+ box-shadow: 0 16px 32px rgba(0,0,0,0.4);
206
+ }
207
+ .engine-preset-row {
208
+ display: flex;
209
+ gap: 6px;
210
+ margin-bottom: 8px;
211
+ flex-wrap: wrap;
212
+ }
213
+ .engine-group {
214
+ padding: 8px;
215
+ border: 1px solid var(--border2);
216
+ border-radius: var(--radius-sm);
217
+ margin-bottom: 8px;
218
+ }
219
+ .engine-group-title {
220
+ font-size: 10px;
221
+ color: var(--text3);
222
+ margin-bottom: 7px;
223
+ letter-spacing: 0.06em;
224
+ text-transform: uppercase;
225
+ }
226
+ .engine-chip-wrap {
227
+ display: flex;
228
+ flex-wrap: wrap;
229
+ gap: 6px;
230
+ }
231
+ .engine-chip {
232
+ display: inline-flex;
233
+ align-items: center;
234
+ gap: 5px;
235
+ border: 1px solid var(--border);
236
+ border-radius: 999px;
237
+ padding: 4px 8px;
238
+ cursor: pointer;
239
+ font-size: 10px;
240
+ color: var(--text2);
241
+ background: #0d0d0d;
242
+ }
243
+ .engine-chip input {
244
+ margin: 0;
245
+ }
246
+ .engine-actions {
247
+ display: flex;
248
+ gap: 8px;
249
+ justify-content: flex-end;
250
+ margin-top: 8px;
251
+ }
252
+
162
253
  /* ─── Homepage ────────────────────────────────────────────────────────────── */
163
254
  .home {
164
255
  flex: 1;
@@ -196,7 +287,6 @@ a:hover { color: var(--link-h); }
196
287
  }
197
288
  .tagline-mobile { display: none; }
198
289
  .tagline-desktop { display: inline; }
199
- }
200
290
 
201
291
  .home-search { width: 100%; max-width: 560px; margin-bottom: 14px; }
202
292
 
@@ -462,8 +552,9 @@ a:hover { color: var(--link-h); }
462
552
 
463
553
  /* AI panel */
464
554
  .panel-ai {
465
- background: var(--ai-bg);
555
+ background: linear-gradient(180deg, rgba(79,70,229,0.12) 0%, rgba(15,15,26,0.95) 42%);
466
556
  border: 1px solid var(--ai-border);
557
+ box-shadow: 0 12px 28px rgba(30,27,75,0.25);
467
558
  }
468
559
  .panel-ai .panel-header-label { color: var(--ai-text2); }
469
560
  .panel-ai .panel-label { color: var(--ai-text); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
@@ -480,6 +571,12 @@ a:hover { color: var(--link-h); }
480
571
  line-height: 1.7;
481
572
  white-space: pre-wrap;
482
573
  margin-top: 10px;
574
+ background: rgba(10,10,16,0.6);
575
+ border: 1px solid rgba(129,140,248,0.22);
576
+ border-radius: var(--radius-sm);
577
+ padding: 10px;
578
+ max-height: 280px;
579
+ overflow: auto;
483
580
  }
484
581
  .ai-meta { font-size: 11px; color: var(--text3); margin-top: 8px; }
485
582
 
@@ -659,6 +756,29 @@ a:hover { color: var(--link-h); }
659
756
  .form-input::placeholder { color: var(--text3); }
660
757
  .form-input[type="password"] { font-family: var(--font-mono); }
661
758
  .form-hint { font-size: 11px; color: var(--text3); margin-top: 3px; }
759
+ .model-quick-list {
760
+ display: flex;
761
+ flex-wrap: wrap;
762
+ gap: 6px;
763
+ margin-top: 8px;
764
+ max-height: 160px;
765
+ overflow: auto;
766
+ padding: 2px 1px 2px 0;
767
+ }
768
+ .model-chip-btn {
769
+ border: 1px solid var(--border);
770
+ border-radius: 999px;
771
+ padding: 4px 8px;
772
+ background: #0b0b0b;
773
+ color: var(--text2);
774
+ font-size: 11px;
775
+ cursor: pointer;
776
+ }
777
+ .model-chip-btn.active {
778
+ color: var(--ai-text2);
779
+ border-color: rgba(109,40,217,0.5);
780
+ background: rgba(109,40,217,0.18);
781
+ }
662
782
 
663
783
  .form-row {
664
784
  display: flex; gap: 8px; align-items: flex-end;
@@ -748,8 +868,11 @@ a:hover { color: var(--link-h); }
748
868
  /* ─── Responsive ──────────────────────────────────────────────────────────── */
749
869
  @media (max-width: 640px) {
750
870
  .header { padding: 8px 12px; gap: 8px; }
751
- .category-tabs { top: 48px; padding: 7px 12px 9px; gap: 5px; }
871
+ .category-tabs { top: 48px; padding: 7px 12px 9px; gap: 5px; flex-wrap: wrap; }
752
872
  .cat-tab { font-size: 10px; padding: 4px 8px; }
873
+ .engine-picker { width: 100%; margin-left: 0; }
874
+ .engine-picker-summary { width: 100%; justify-content: space-between; }
875
+ .engine-picker-body { width: calc(100vw - 24px); right: -6px; }
753
876
  .logo-text { font-size: 15px; }
754
877
  .home-logo { font-size: 40px; }
755
878
  .home-tagline { letter-spacing: 0.08em; margin-bottom: 20px; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termsearch",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "Personal search engine for Termux/Linux/macOS — zero-config, privacy-first, AI-optional",
5
5
  "type": "module",
6
6
  "bin": {