termsearch 0.3.0 → 0.3.1

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.1-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)
@@ -22,7 +22,7 @@ Core capabilities:
22
22
 
23
23
  ## Project Status
24
24
 
25
- - Current line: `0.3.0`
25
+ - Current line: `0.3.1`
26
26
  - Core is MIT — zero required API keys
27
27
  - AI features are optional, configured via Settings page in browser
28
28
  - Tested on: Ubuntu 24.04, Termux (Android 15/16)
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,15 @@ const state = {
16
16
  loading: false,
17
17
  providers: [],
18
18
  config: null,
19
+ historyEnabled: localStorage.getItem('ts-save-history') !== '0',
20
+ searchHistory: (() => {
21
+ try {
22
+ const raw = JSON.parse(localStorage.getItem('ts-history') || '[]');
23
+ return Array.isArray(raw) ? raw.slice(0, 50).filter((q) => typeof q === 'string' && q.trim()) : [];
24
+ } catch {
25
+ return [];
26
+ }
27
+ })(),
19
28
  };
20
29
 
21
30
  function buildSearchHash(query, category = 'web') {
@@ -65,6 +74,27 @@ function toggleTheme() {
65
74
  localStorage.setItem('ts-theme', isLight ? 'light' : 'dark');
66
75
  }
67
76
 
77
+ function setHistoryEnabled(enabled) {
78
+ state.historyEnabled = Boolean(enabled);
79
+ localStorage.setItem('ts-save-history', state.historyEnabled ? '1' : '0');
80
+ if (!state.historyEnabled) {
81
+ state.searchHistory = [];
82
+ localStorage.removeItem('ts-history');
83
+ }
84
+ }
85
+
86
+ function persistHistory() {
87
+ if (!state.historyEnabled) return;
88
+ localStorage.setItem('ts-history', JSON.stringify(state.searchHistory.slice(0, 50)));
89
+ }
90
+
91
+ function addSearchToHistory(query) {
92
+ const q = String(query || '').trim();
93
+ if (!q || !state.historyEnabled) return;
94
+ state.searchHistory = [q, ...state.searchHistory.filter((item) => item !== q)].slice(0, 50);
95
+ persistHistory();
96
+ }
97
+
68
98
  // ─── SVG Icons ────────────────────────────────────────────────────────────
69
99
  function svg(paths, size = 16, extra = '') {
70
100
  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 +154,23 @@ const LANGS = [
124
154
  { code: 'ja-JP', label: '🇯🇵 JA' },
125
155
  ];
126
156
 
157
+ 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: '' },
165
+ ];
166
+
167
+ function detectPresetFromBase(base) {
168
+ const raw = String(base || '').toLowerCase();
169
+ if (!raw) return 'custom';
170
+ const preset = AI_PRESETS.find((p) => raw.startsWith(String(p.api_base).toLowerCase()));
171
+ return preset ? preset.id : 'custom';
172
+ }
173
+
127
174
  function LangPicker() {
128
175
  const wrap = el('div', { className: 'lang-wrap' });
129
176
  const sel = el('select', { className: 'lang-select' });
@@ -142,13 +189,20 @@ function LangPicker() {
142
189
  function SearchForm(value, onSearch) {
143
190
  const form = el('form', { className: 'search-form' });
144
191
  const sicon = el('span', { className: 'search-icon', html: ICONS.search });
192
+ const listId = `search-history-list-${Math.random().toString(36).slice(2, 8)}`;
145
193
  const input = el('input', {
146
194
  className: 'search-input', type: 'search',
147
195
  placeholder: 'Search...', value: value || '',
148
196
  autocomplete: 'off', autocorrect: 'off', autocapitalize: 'off', spellcheck: 'false',
197
+ ...(state.historyEnabled ? { list: listId } : {}),
149
198
  });
150
199
  const btn = el('button', { className: 'search-btn', type: 'submit', html: ICONS.search });
151
200
  form.append(sicon, input, btn);
201
+ if (state.historyEnabled && state.searchHistory.length) {
202
+ const dl = el('datalist', { id: listId });
203
+ state.searchHistory.slice(0, 12).forEach((q) => dl.append(el('option', { value: q })));
204
+ form.append(dl);
205
+ }
152
206
  form.addEventListener('submit', (e) => {
153
207
  e.preventDefault();
154
208
  const q = input.value.trim();
@@ -531,6 +585,7 @@ async function runSearchProgressive(q, lang, category) {
531
585
 
532
586
  async function doSearch(q, category = state.category) {
533
587
  if (!q.trim()) return;
588
+ addSearchToHistory(q);
534
589
  state.category = ['web', 'images', 'news'].includes(category) ? category : 'web';
535
590
  state.loading = true;
536
591
  state.results = [];
@@ -728,7 +783,10 @@ function renderApp() {
728
783
  function renderHome(app) {
729
784
  const home = el('div', { className: 'home' },
730
785
  el('div', { className: 'home-logo' }, 'Term', el('strong', {}, 'Search')),
731
- el('div', { className: 'home-tagline' }, 'Personal search engine · privacy-first · local-first'),
786
+ el('div', { className: 'home-tagline' },
787
+ el('span', { className: 'tagline-desktop' }, 'Personal search engine · privacy-first · local-first'),
788
+ el('span', { className: 'tagline-mobile' }, 'Private search · local-first'),
789
+ ),
732
790
  el('div', { className: 'home-search' }, SearchForm('', (q) => { state.query = q; state.category = 'web'; doSearch(q, 'web'); })),
733
791
  el('div', { className: 'home-actions' },
734
792
  LangPicker(),
@@ -767,6 +825,7 @@ async function renderSettings() {
767
825
  const brave = cfg.brave || {};
768
826
  const mojeek = cfg.mojeek || {};
769
827
  const searxng = cfg.searxng || {};
828
+ const detectedPreset = detectPresetFromBase(ai.api_base);
770
829
 
771
830
  const header = el('div', { className: 'header' },
772
831
  el('button', { className: 'btn', onClick: () => history.back() }, iconEl('back'), ' Back'),
@@ -788,11 +847,119 @@ async function renderSettings() {
788
847
  }
789
848
 
790
849
  const saveAlertEl = el('div', { style: 'display:none' });
850
+ const aiModelStatus = el('div', { id: 'ai-model-status', style: 'display:none' });
851
+ const historyInfoEl = el('div', { id: 'history-preview', className: 'form-hint', style: 'margin-top:8px' });
852
+ const presetSelect = el('select', { className: 'form-input', id: 'ai-preset' });
853
+ presetSelect.append(el('option', { value: 'custom' }, 'Custom'));
854
+ AI_PRESETS.forEach((preset) => {
855
+ const opt = el('option', { value: preset.id }, preset.label);
856
+ if (preset.id === detectedPreset) opt.selected = true;
857
+ presetSelect.append(opt);
858
+ });
859
+ 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' });
862
+
863
+ function setModelStatus(message, type = 'info') {
864
+ aiModelStatus.style.display = 'block';
865
+ aiModelStatus.className = `alert alert-${type}`;
866
+ aiModelStatus.textContent = message;
867
+ }
868
+
869
+ function renderHistoryPreview() {
870
+ if (!state.historyEnabled) {
871
+ historyInfoEl.textContent = 'Search history disabled.';
872
+ return;
873
+ }
874
+ if (!state.searchHistory.length) {
875
+ historyInfoEl.textContent = 'No searches saved yet.';
876
+ return;
877
+ }
878
+ historyInfoEl.textContent = `Recent: ${state.searchHistory.slice(0, 8).join(' · ')}`;
879
+ }
880
+
881
+ function populateModelList(models) {
882
+ modelDataList.innerHTML = '';
883
+ for (const model of models) {
884
+ modelDataList.append(el('option', { value: model }));
885
+ }
886
+ }
887
+
888
+ async function loadModels(trigger = 'manual') {
889
+ const base = val('ai-base');
890
+ const key = val('ai-key');
891
+ const presetId = val('ai-preset');
892
+ if (!base) {
893
+ setModelStatus('Set API endpoint first.', 'info');
894
+ return;
895
+ }
896
+ const preset = AI_PRESETS.find((p) => p.id === presetId);
897
+ if (preset?.keyRequired && !key) {
898
+ setModelStatus(`Insert API key for ${preset.label} to load models.`, 'info');
899
+ return;
900
+ }
901
+
902
+ const btn = document.getElementById('ai-load-models-btn');
903
+ if (btn) btn.disabled = true;
904
+ setModelStatus('Loading models…', 'info');
905
+ try {
906
+ const res = await api('/api/config/models', {
907
+ method: 'POST',
908
+ headers: { 'Content-Type': 'application/json' },
909
+ body: JSON.stringify({ api_base: base, api_key: key, preset: presetId }),
910
+ });
911
+ const models = Array.isArray(res.models) ? res.models : [];
912
+ if (!models.length) {
913
+ setModelStatus(`No models found${res.provider ? ` for ${res.provider}` : ''}.`, 'err');
914
+ return;
915
+ }
916
+ populateModelList(models);
917
+ const current = val('ai-model');
918
+ if (!current || !models.includes(current)) {
919
+ const modelField = document.getElementById('ai-model');
920
+ if (modelField) modelField.value = models[0];
921
+ }
922
+ setModelStatus(`Loaded ${models.length} model(s)${res.provider ? ` from ${res.provider}` : ''}.`, 'ok');
923
+ } catch (e) {
924
+ setModelStatus(`Model load failed: ${e.message}`, 'err');
925
+ } finally {
926
+ if (btn) btn.disabled = false;
927
+ if (trigger === 'manual') {
928
+ setTimeout(() => { aiModelStatus.style.display = 'none'; }, 4500);
929
+ }
930
+ }
931
+ }
932
+
933
+ function applyPreset() {
934
+ const presetId = val('ai-preset');
935
+ const preset = AI_PRESETS.find((p) => p.id === presetId);
936
+ const hintEl = document.getElementById('ai-preset-hint');
937
+ if (!preset) {
938
+ if (hintEl) hintEl.textContent = 'Custom endpoint mode.';
939
+ return;
940
+ }
941
+ const baseField = document.getElementById('ai-base');
942
+ const modelField = document.getElementById('ai-model');
943
+ if (baseField) baseField.value = preset.api_base;
944
+ if (modelField && (!modelField.value || modelField.value === ai.model) && preset.defaultModel) {
945
+ modelField.value = preset.defaultModel;
946
+ }
947
+ if (hintEl) {
948
+ hintEl.textContent = preset.keyRequired
949
+ ? `Preset ready: insert API key for ${preset.label}, then load models.`
950
+ : `Preset ready: local endpoint (${preset.label}).`;
951
+ }
952
+ if (!preset.keyRequired || val('ai-key')) {
953
+ loadModels('preset');
954
+ }
955
+ }
791
956
 
792
957
  async function saveSettings() {
793
958
  const aiKey = val('ai-key');
794
959
  const braveKey = val('brave-key');
795
960
  const mojeekKey = val('mojeek-key');
961
+ setHistoryEnabled(isChecked('history-enabled'));
962
+ renderHistoryPreview();
796
963
  const update = {
797
964
  ai: {
798
965
  api_base: val('ai-base'),
@@ -920,15 +1087,23 @@ async function renderSettings() {
920
1087
  el('div', { className: 'settings-section' },
921
1088
  el('h2', {}, 'AI Configuration (optional)'),
922
1089
  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…'
1090
+ 'Preset ready: just set key (if required), load models, save.'
1091
+ ),
1092
+ el('div', { className: 'form-group' },
1093
+ el('label', { className: 'form-label', for: 'ai-preset' }, 'Preset'),
1094
+ presetSelect,
1095
+ el('div', { className: 'form-row', style: 'margin-top:6px' },
1096
+ el('button', { id: 'ai-preset-apply-btn', className: 'btn', onClick: applyPreset, type: 'button' }, 'Apply preset'),
1097
+ el('span', { id: 'ai-preset-hint', className: 'form-hint', style: 'margin-top:0' }, 'Select a preset to prefill endpoint/model.'),
1098
+ ),
924
1099
  ),
925
1100
  el('div', { className: 'form-group' },
926
1101
  el('label', { className: 'form-label', for: 'ai-base' }, 'API Endpoint'),
927
1102
  makeInput('ai-base', ai.api_base, 'http://localhost:11434/v1'),
928
1103
  el('div', { className: 'form-hint' },
929
- 'Localhost: localhost:11434/v1 (Ollama) · localhost:1234/v1 (LM Studio)',
1104
+ 'Included presets: Chutes.ai · Anthropic · OpenAI · OpenRoute/OpenRouter · llama.cpp · Ollama · LM Studio',
930
1105
  el('br', {}),
931
- 'Chutes/OpenAI-compatible: llm.chutes.ai/v1 · OpenAI: api.openai.com/v1',
1106
+ 'You can also keep custom OpenAI-compatible endpoints.',
932
1107
  ),
933
1108
  ),
934
1109
  el('div', { className: 'form-group' },
@@ -938,11 +1113,13 @@ async function renderSettings() {
938
1113
  ),
939
1114
  el('div', { className: 'form-group' },
940
1115
  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',
1116
+ modelInput,
1117
+ modelDataList,
1118
+ el('div', { className: 'form-row', style: 'margin-top:6px' },
1119
+ el('button', { id: 'ai-load-models-btn', className: 'btn', onClick: () => loadModels('manual'), type: 'button' }, 'Load models'),
1120
+ el('span', { className: 'form-hint', style: 'margin-top:0' }, 'Auto-loads from endpoint with current key.'),
945
1121
  ),
1122
+ aiModelStatus,
946
1123
  ),
947
1124
  el('div', { className: 'form-row', style: 'margin-top:4px' },
948
1125
  el('button', { id: 'ai-test-btn', className: 'btn btn-primary', onClick: testAi }, 'Test Connection'),
@@ -951,6 +1128,31 @@ async function renderSettings() {
951
1128
  el('div', { id: 'ai-test-result', style: 'display:none' }),
952
1129
  ),
953
1130
 
1131
+ // Search history
1132
+ el('div', { className: 'settings-section' },
1133
+ el('h2', {}, 'Search History'),
1134
+ el('div', { className: 'toggle-row' },
1135
+ el('span', { className: 'toggle-label' }, 'Save search history'),
1136
+ el('label', { className: 'toggle' },
1137
+ el('input', { type: 'checkbox', id: 'history-enabled', ...(state.historyEnabled ? { checked: '' } : {}) }),
1138
+ el('span', { className: 'toggle-slider' }),
1139
+ ),
1140
+ ),
1141
+ historyInfoEl,
1142
+ el('div', { style: 'margin-top:10px;display:flex;gap:8px;align-items:center' },
1143
+ el('button', {
1144
+ className: 'btn',
1145
+ type: 'button',
1146
+ onClick: () => {
1147
+ state.searchHistory = [];
1148
+ localStorage.removeItem('ts-history');
1149
+ renderHistoryPreview();
1150
+ },
1151
+ }, 'Clear history'),
1152
+ el('button', { className: 'btn btn-primary', onClick: saveSettings, type: 'button' }, 'Save preference'),
1153
+ ),
1154
+ ),
1155
+
954
1156
  // Providers
955
1157
  el('div', { className: 'settings-section' },
956
1158
  el('h2', {}, 'Search Providers'),
@@ -1014,7 +1216,7 @@ async function renderSettings() {
1014
1216
  // Server info
1015
1217
  el('div', { className: 'settings-section' },
1016
1218
  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')),
1219
+ el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Version'), el('span', { className: 'info-val' }, health?.version || '0.3.1')),
1018
1220
  el('div', { className: 'info-row' }, el('span', { className: 'info-key' }, 'Active providers'), el('span', { className: 'info-val' }, (health?.providers || []).join(', ') || 'none')),
1019
1221
  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
1222
  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 +1243,26 @@ async function renderSettings() {
1041
1243
  );
1042
1244
 
1043
1245
  app.append(header, main);
1246
+ renderHistoryPreview();
1247
+ document.getElementById('history-enabled')?.addEventListener('change', (e) => {
1248
+ setHistoryEnabled(Boolean(e.target?.checked));
1249
+ renderHistoryPreview();
1250
+ });
1251
+ document.getElementById('ai-key')?.addEventListener('change', () => loadModels('auto'));
1252
+ document.getElementById('ai-base')?.addEventListener('change', () => loadModels('auto'));
1253
+ presetSelect.addEventListener('change', applyPreset);
1254
+ if (detectedPreset && detectedPreset !== 'custom') {
1255
+ const hintEl = document.getElementById('ai-preset-hint');
1256
+ const preset = AI_PRESETS.find((p) => p.id === detectedPreset);
1257
+ if (hintEl && preset) {
1258
+ hintEl.textContent = preset.keyRequired
1259
+ ? `Preset detected: ${preset.label}. Insert key and load models.`
1260
+ : `Preset detected: ${preset.label}.`;
1261
+ }
1262
+ }
1263
+ if (ai.api_base && (!AI_PRESETS.find((p) => p.id === detectedPreset)?.keyRequired)) {
1264
+ loadModels('auto');
1265
+ }
1044
1266
  }
1045
1267
 
1046
1268
  // ─── Bootstrap ────────────────────────────────────────────────────────────
@@ -192,6 +192,10 @@ a:hover { color: var(--link-h); }
192
192
  letter-spacing: 0.15em;
193
193
  text-transform: uppercase;
194
194
  margin-bottom: 32px;
195
+ text-align: center;
196
+ }
197
+ .tagline-mobile { display: none; }
198
+ .tagline-desktop { display: inline; }
195
199
  }
196
200
 
197
201
  .home-search { width: 100%; max-width: 560px; margin-bottom: 14px; }
@@ -748,6 +752,9 @@ a:hover { color: var(--link-h); }
748
752
  .cat-tab { font-size: 10px; padding: 4px 8px; }
749
753
  .logo-text { font-size: 15px; }
750
754
  .home-logo { font-size: 40px; }
755
+ .home-tagline { letter-spacing: 0.08em; margin-bottom: 20px; }
756
+ .tagline-desktop { display: none; }
757
+ .tagline-mobile { display: inline; }
751
758
  .result-title { font-size: 15px; }
752
759
  .settings-section { padding: 14px; }
753
760
  .form-row { flex-direction: column; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termsearch",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Personal search engine for Termux/Linux/macOS — zero-config, privacy-first, AI-optional",
5
5
  "type": "module",
6
6
  "bin": {
@@ -56,6 +56,151 @@ function sleep(ms) {
56
56
  return new Promise((resolve) => setTimeout(resolve, ms));
57
57
  }
58
58
 
59
+ function isAnthropicBase(base) {
60
+ return /anthropic\.com/i.test(base);
61
+ }
62
+
63
+ function normalizeAnthropicBase(base) {
64
+ const clean = String(base || '').replace(/\/$/, '');
65
+ return /\/v1$/i.test(clean) ? clean : `${clean}/v1`;
66
+ }
67
+
68
+ function buildAnthropicHeaders(apiKey) {
69
+ return {
70
+ 'Content-Type': 'application/json',
71
+ 'anthropic-version': '2023-06-01',
72
+ ...(apiKey ? { 'x-api-key': apiKey } : {}),
73
+ };
74
+ }
75
+
76
+ function extractAnthropicText(payload) {
77
+ const parts = Array.isArray(payload?.content) ? payload.content : [];
78
+ return parts
79
+ .map((part) => (part?.type === 'text' ? String(part?.text || '') : ''))
80
+ .join('')
81
+ .trim();
82
+ }
83
+
84
+ async function callAnthropic(prompt, {
85
+ apiBase,
86
+ apiKey,
87
+ model,
88
+ maxTokens = 1200,
89
+ timeoutMs = 90_000,
90
+ systemPrompt = null,
91
+ temperature = 0.3,
92
+ } = {}) {
93
+ if (!apiKey) throw buildAiError('ai_provider_auth', 'ai 401: authentication failed');
94
+ const base = normalizeAnthropicBase(apiBase);
95
+ const body = {
96
+ model,
97
+ max_tokens: maxTokens,
98
+ temperature,
99
+ messages: [{ role: 'user', content: prompt }],
100
+ };
101
+ if (systemPrompt) body.system = systemPrompt;
102
+
103
+ const ac = new AbortController();
104
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
105
+ try {
106
+ const response = await fetch(`${base}/messages`, {
107
+ method: 'POST',
108
+ headers: buildAnthropicHeaders(apiKey),
109
+ body: JSON.stringify(body),
110
+ signal: ac.signal,
111
+ });
112
+ if (response.status === 401 || response.status === 403) {
113
+ throw buildAiError('ai_provider_auth', `ai ${response.status}: authentication failed`);
114
+ }
115
+ if (response.status === 429) {
116
+ throw buildAiError('ai_rate_limited_provider', 'ai 429: rate limit exceeded');
117
+ }
118
+ if (!response.ok) {
119
+ const errBody = await response.text().catch(() => '');
120
+ throw new Error(`ai ${response.status}: ${errBody.slice(0, 220)}`);
121
+ }
122
+ const data = await response.json();
123
+ return {
124
+ content: extractAnthropicText(data),
125
+ reasoning: '',
126
+ model: data?.model || model,
127
+ };
128
+ } finally {
129
+ clearTimeout(timer);
130
+ }
131
+ }
132
+
133
+ async function streamAnthropic(prompt, onToken, {
134
+ apiBase,
135
+ apiKey,
136
+ model,
137
+ maxTokens = 1200,
138
+ timeoutMs = 90_000,
139
+ systemPrompt = null,
140
+ temperature = 0.3,
141
+ } = {}) {
142
+ if (!apiKey) throw buildAiError('ai_provider_auth', 'ai 401: authentication failed');
143
+ const base = normalizeAnthropicBase(apiBase);
144
+ const body = {
145
+ model,
146
+ max_tokens: maxTokens,
147
+ temperature,
148
+ stream: true,
149
+ messages: [{ role: 'user', content: prompt }],
150
+ };
151
+ if (systemPrompt) body.system = systemPrompt;
152
+
153
+ const ac = new AbortController();
154
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
155
+ try {
156
+ const response = await fetch(`${base}/messages`, {
157
+ method: 'POST',
158
+ headers: buildAnthropicHeaders(apiKey),
159
+ body: JSON.stringify(body),
160
+ signal: ac.signal,
161
+ });
162
+ if (response.status === 401 || response.status === 403) {
163
+ throw buildAiError('ai_provider_auth', `ai ${response.status}: authentication failed`);
164
+ }
165
+ if (response.status === 429) {
166
+ throw buildAiError('ai_rate_limited_provider', 'ai 429: rate limit exceeded');
167
+ }
168
+ if (!response.ok) {
169
+ const errBody = await response.text().catch(() => '');
170
+ throw new Error(`ai ${response.status}: ${errBody.slice(0, 220)}`);
171
+ }
172
+
173
+ const reader = response.body.getReader();
174
+ const decoder = new TextDecoder();
175
+ let fullContent = '';
176
+ let buffer = '';
177
+ while (true) {
178
+ const { done, value } = await reader.read();
179
+ if (done) break;
180
+ buffer += decoder.decode(value, { stream: true });
181
+ const lines = buffer.split('\n');
182
+ buffer = lines.pop() || '';
183
+ for (const line of lines) {
184
+ if (!line.startsWith('data: ')) continue;
185
+ const raw = line.slice(6).trim();
186
+ if (!raw || raw === '[DONE]') continue;
187
+ try {
188
+ const parsed = JSON.parse(raw);
189
+ const chunk = parsed?.delta?.text || parsed?.text || '';
190
+ if (!chunk) continue;
191
+ fullContent += chunk;
192
+ onToken(chunk, fullContent);
193
+ } catch {
194
+ // ignore malformed SSE chunk
195
+ }
196
+ }
197
+ }
198
+ return { content: fullContent.trim(), reasoning: '', model };
199
+ } finally {
200
+ clearTimeout(timer);
201
+ }
202
+ }
203
+
59
204
  // Single non-streaming call with retry logic
60
205
  // Returns { content, reasoning, model } or throws
61
206
  export async function call(prompt, {
@@ -70,6 +215,9 @@ export async function call(prompt, {
70
215
  } = {}) {
71
216
  const base = String(apiBase || '').replace(/\/$/, '');
72
217
  if (!base || !model) throw buildAiError('ai_unavailable', 'AI not configured');
218
+ if (isAnthropicBase(base)) {
219
+ return callAnthropic(prompt, { apiBase: base, apiKey, model, maxTokens, timeoutMs, systemPrompt, temperature });
220
+ }
73
221
 
74
222
  const messages = systemPrompt
75
223
  ? [{ role: 'system', content: systemPrompt }, { role: 'user', content: prompt }]
@@ -155,6 +303,9 @@ export async function stream(prompt, onToken, {
155
303
  } = {}) {
156
304
  const base = String(apiBase || '').replace(/\/$/, '');
157
305
  if (!base || !model) throw buildAiError('ai_unavailable', 'AI not configured');
306
+ if (isAnthropicBase(base)) {
307
+ return streamAnthropic(prompt, onToken, { apiBase: base, apiKey, model, maxTokens, timeoutMs, systemPrompt, temperature });
308
+ }
158
309
 
159
310
  const messages = systemPrompt
160
311
  ? [{ role: 'system', content: systemPrompt }, { role: 'user', content: prompt }]
package/src/api/routes.js CHANGED
@@ -11,7 +11,7 @@ import { detectProfileTarget, scanProfile, PROFILER_PLATFORMS } from '../profile
11
11
  import { fetchBlueskyPosts, fetchBlueskyActors, fetchGdeltArticles } from '../social/search.js';
12
12
  import { scrapeTPB, scrape1337x, extractMagnetFromUrl } from '../torrent/scrapers.js';
13
13
 
14
- const APP_VERSION = '0.3.0';
14
+ const APP_VERSION = '0.3.1';
15
15
  const ALLOWED_CATEGORIES = new Set(['web', 'images', 'news']);
16
16
 
17
17
  function parseCategory(raw) {
@@ -31,6 +31,96 @@ function parseEngines(raw) {
31
31
  )];
32
32
  }
33
33
 
34
+ function normalizeBase(rawBase) {
35
+ const raw = String(rawBase || '').trim();
36
+ if (!raw) return '';
37
+ return raw.replace(/\/$/, '');
38
+ }
39
+
40
+ function detectModelProvider(base, preset = '') {
41
+ const p = String(preset || '').trim().toLowerCase();
42
+ if (p && p !== 'custom') return p;
43
+ const b = String(base || '').toLowerCase();
44
+ if (b.includes('anthropic.com')) return 'anthropic';
45
+ if (b.includes('openrouter.ai')) return 'openrouter';
46
+ if (b.includes('openai.com')) return 'openai';
47
+ if (b.includes('chutes.ai')) return 'chutes';
48
+ if (b.includes(':11434')) return 'ollama';
49
+ if (b.includes(':1234')) return 'lmstudio';
50
+ if (b.includes(':8080')) return 'llamacpp';
51
+ return 'openai_compat';
52
+ }
53
+
54
+ async function fetchOpenAiCompatibleModels(base, apiKey, timeoutMs = 10000) {
55
+ const ac = new AbortController();
56
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
57
+ try {
58
+ const headers = { Accept: 'application/json' };
59
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
60
+ const response = await fetch(`${base}/models`, { headers, signal: ac.signal });
61
+ if (!response.ok) return [];
62
+ const payload = await response.json();
63
+ return (payload?.data || [])
64
+ .map((item) => String(item?.id || '').trim())
65
+ .filter(Boolean);
66
+ } catch {
67
+ return [];
68
+ } finally {
69
+ clearTimeout(timer);
70
+ }
71
+ }
72
+
73
+ async function fetchAnthropicModels(base, apiKey, timeoutMs = 10000) {
74
+ if (!apiKey) return [];
75
+ const endpoint = base.endsWith('/v1') ? `${base}/models` : `${base}/v1/models`;
76
+ const ac = new AbortController();
77
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
78
+ try {
79
+ const response = await fetch(endpoint, {
80
+ headers: {
81
+ Accept: 'application/json',
82
+ 'x-api-key': apiKey,
83
+ 'anthropic-version': '2023-06-01',
84
+ },
85
+ signal: ac.signal,
86
+ });
87
+ if (!response.ok) return [];
88
+ const payload = await response.json();
89
+ return (payload?.data || [])
90
+ .map((item) => String(item?.id || '').trim())
91
+ .filter(Boolean);
92
+ } catch {
93
+ return [];
94
+ } finally {
95
+ clearTimeout(timer);
96
+ }
97
+ }
98
+
99
+ async function fetchOllamaModels(base, timeoutMs = 10000) {
100
+ const origin = (() => {
101
+ try {
102
+ const u = new URL(base);
103
+ return `${u.protocol}//${u.host}`;
104
+ } catch {
105
+ return base;
106
+ }
107
+ })();
108
+ const ac = new AbortController();
109
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
110
+ try {
111
+ const response = await fetch(`${origin}/api/tags`, { headers: { Accept: 'application/json' }, signal: ac.signal });
112
+ if (!response.ok) return [];
113
+ const payload = await response.json();
114
+ return (payload?.models || [])
115
+ .map((item) => String(item?.name || '').trim())
116
+ .filter(Boolean);
117
+ } catch {
118
+ return [];
119
+ } finally {
120
+ clearTimeout(timer);
121
+ }
122
+ }
123
+
34
124
  export function createRouter(config, rateLimiters) {
35
125
  const router = express.Router();
36
126
 
@@ -68,6 +158,7 @@ export function createRouter(config, rateLimiters) {
68
158
  '/api/magnet': { post: { summary: 'Extract magnet from page URL' } },
69
159
  '/api/scan': { post: { summary: 'Scan site pages by query' } },
70
160
  '/api/config': { get: { summary: 'Read config (masked)' }, post: { summary: 'Update config' } },
161
+ '/api/config/models': { post: { summary: 'List AI models from selected provider endpoint' } },
71
162
  },
72
163
  });
73
164
  });
@@ -305,6 +396,36 @@ export function createRouter(config, rateLimiters) {
305
396
  sendJson(res, 405, { error: 'method_not_allowed', message: 'Use POST /api/config/test-ai' });
306
397
  });
307
398
 
399
+ // ─── Fetch provider model list ───────────────────────────────────────────
400
+ router.post('/api/config/models', express.json({ limit: '8kb' }), async (req, res) => {
401
+ const ip = req.clientIp;
402
+ if (!rateLimiters.checkGeneral(ip)) {
403
+ return sendRateLimited(res, { windowMs: rateLimiters.windowMs });
404
+ }
405
+ const cfg = config.getConfig();
406
+ const base = normalizeBase(req.body?.api_base || cfg.ai?.api_base || '');
407
+ const apiKey = String(req.body?.api_key || cfg.ai?.api_key || '');
408
+ const preset = String(req.body?.preset || '').trim().toLowerCase();
409
+ if (!base) return sendJson(res, 400, { ok: false, error: 'missing_api_base' });
410
+
411
+ const provider = detectModelProvider(base, preset);
412
+ let models = [];
413
+ if (provider === 'anthropic') {
414
+ models = await fetchAnthropicModels(base, apiKey);
415
+ } else if (provider === 'ollama') {
416
+ models = await fetchOllamaModels(base);
417
+ if (models.length === 0) {
418
+ models = await fetchOpenAiCompatibleModels(base, apiKey);
419
+ }
420
+ } else {
421
+ models = await fetchOpenAiCompatibleModels(base, apiKey);
422
+ }
423
+
424
+ models = [...new Set(models)].slice(0, 80);
425
+ applySecurityHeaders(res);
426
+ res.json({ ok: true, provider, models });
427
+ });
428
+
308
429
  // ─── Test search provider ─────────────────────────────────────────────────
309
430
  router.get('/api/config/test-provider/:name', async (req, res) => {
310
431
  const cfg = config.getConfig();