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 +2 -2
- package/bin/termsearch.js +1 -1
- package/frontend/dist/app.js +231 -9
- package/frontend/dist/style.css +7 -0
- package/package.json +1 -1
- package/src/ai/providers/openai-compat.js +151 -0
- package/src/api/routes.js +122 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# TermSearch - Personal Search Engine
|
|
2
2
|
|
|
3
|
-
[](#project-status)
|
|
4
4
|
[](LICENSE)
|
|
5
5
|
[](https://nodejs.org)
|
|
6
6
|
[](https://termux.dev)
|
|
@@ -22,7 +22,7 @@ Core capabilities:
|
|
|
22
22
|
|
|
23
23
|
## Project Status
|
|
24
24
|
|
|
25
|
-
- Current line: `0.3.
|
|
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.
|
|
35
|
+
let VERSION = '0.3.1';
|
|
36
36
|
try { VERSION = JSON.parse(readFileSync(PKG_PATH, 'utf8')).version || VERSION; } catch { /* ignore */ }
|
|
37
37
|
|
|
38
38
|
// ─── Data dir + paths ─────────────────────────────────────────────────────
|
package/frontend/dist/app.js
CHANGED
|
@@ -16,6 +16,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' },
|
|
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
|
-
'
|
|
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
|
-
'
|
|
1104
|
+
'Included presets: Chutes.ai · Anthropic · OpenAI · OpenRoute/OpenRouter · llama.cpp · Ollama · LM Studio',
|
|
930
1105
|
el('br', {}),
|
|
931
|
-
'
|
|
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
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
el('
|
|
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.
|
|
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 ────────────────────────────────────────────────────────────
|
package/frontend/dist/style.css
CHANGED
|
@@ -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
|
@@ -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.
|
|
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();
|