termsearch 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/bin/termsearch.js +1 -1
- package/frontend/dist/app.js +436 -14
- package/frontend/dist/style.css +132 -2
- package/package.json +1 -1
- package/src/ai/providers/openai-compat.js +151 -0
- package/src/api/routes.js +223 -10
- package/src/search/engine.js +404 -73
- package/src/search/providers/github.js +91 -0
- package/src/search/providers/searxng.js +15 -5
package/frontend/dist/style.css
CHANGED
|
@@ -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;
|
|
@@ -192,7 +283,10 @@ a:hover { color: var(--link-h); }
|
|
|
192
283
|
letter-spacing: 0.15em;
|
|
193
284
|
text-transform: uppercase;
|
|
194
285
|
margin-bottom: 32px;
|
|
286
|
+
text-align: center;
|
|
195
287
|
}
|
|
288
|
+
.tagline-mobile { display: none; }
|
|
289
|
+
.tagline-desktop { display: inline; }
|
|
196
290
|
|
|
197
291
|
.home-search { width: 100%; max-width: 560px; margin-bottom: 14px; }
|
|
198
292
|
|
|
@@ -458,8 +552,9 @@ a:hover { color: var(--link-h); }
|
|
|
458
552
|
|
|
459
553
|
/* AI panel */
|
|
460
554
|
.panel-ai {
|
|
461
|
-
background:
|
|
555
|
+
background: linear-gradient(180deg, rgba(79,70,229,0.12) 0%, rgba(15,15,26,0.95) 42%);
|
|
462
556
|
border: 1px solid var(--ai-border);
|
|
557
|
+
box-shadow: 0 12px 28px rgba(30,27,75,0.25);
|
|
463
558
|
}
|
|
464
559
|
.panel-ai .panel-header-label { color: var(--ai-text2); }
|
|
465
560
|
.panel-ai .panel-label { color: var(--ai-text); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
|
|
@@ -476,6 +571,12 @@ a:hover { color: var(--link-h); }
|
|
|
476
571
|
line-height: 1.7;
|
|
477
572
|
white-space: pre-wrap;
|
|
478
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;
|
|
479
580
|
}
|
|
480
581
|
.ai-meta { font-size: 11px; color: var(--text3); margin-top: 8px; }
|
|
481
582
|
|
|
@@ -655,6 +756,29 @@ a:hover { color: var(--link-h); }
|
|
|
655
756
|
.form-input::placeholder { color: var(--text3); }
|
|
656
757
|
.form-input[type="password"] { font-family: var(--font-mono); }
|
|
657
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
|
+
}
|
|
658
782
|
|
|
659
783
|
.form-row {
|
|
660
784
|
display: flex; gap: 8px; align-items: flex-end;
|
|
@@ -744,10 +868,16 @@ a:hover { color: var(--link-h); }
|
|
|
744
868
|
/* ─── Responsive ──────────────────────────────────────────────────────────── */
|
|
745
869
|
@media (max-width: 640px) {
|
|
746
870
|
.header { padding: 8px 12px; gap: 8px; }
|
|
747
|
-
.category-tabs { top: 48px; padding: 7px 12px 9px; gap: 5px; }
|
|
871
|
+
.category-tabs { top: 48px; padding: 7px 12px 9px; gap: 5px; flex-wrap: wrap; }
|
|
748
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; }
|
|
749
876
|
.logo-text { font-size: 15px; }
|
|
750
877
|
.home-logo { font-size: 40px; }
|
|
878
|
+
.home-tagline { letter-spacing: 0.08em; margin-bottom: 20px; }
|
|
879
|
+
.tagline-desktop { display: none; }
|
|
880
|
+
.tagline-mobile { display: inline; }
|
|
751
881
|
.result-title { font-size: 15px; }
|
|
752
882
|
.settings-section { padding: 14px; }
|
|
753
883
|
.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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// All API route handlers
|
|
2
2
|
|
|
3
3
|
import express from 'express';
|
|
4
|
-
import { search, searchStream, getEnabledProviders, getDocCache } from '../search/engine.js';
|
|
4
|
+
import { search, searchStream, getEnabledProviders, getDocCache, ALLOWED_ENGINES } from '../search/engine.js';
|
|
5
5
|
import { batchFetch, fetchReadableDocument } from '../fetch/document.js';
|
|
6
6
|
import { generateSummary, testConnection } from '../ai/orchestrator.js';
|
|
7
7
|
import { refineQuery } from '../ai/query.js';
|
|
@@ -11,8 +11,9 @@ 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.2';
|
|
15
15
|
const ALLOWED_CATEGORIES = new Set(['web', 'images', 'news']);
|
|
16
|
+
const ALLOWED_LANGS = new Set(['auto', 'it-IT', 'en-US', 'es-ES', 'fr-FR', 'de-DE', 'pt-PT', 'ru-RU', 'zh-CN', 'ja-JP']);
|
|
16
17
|
|
|
17
18
|
function parseCategory(raw) {
|
|
18
19
|
const category = String(raw || 'web').trim().toLowerCase();
|
|
@@ -22,13 +23,184 @@ function parseCategory(raw) {
|
|
|
22
23
|
function parseEngines(raw) {
|
|
23
24
|
if (!raw) return [];
|
|
24
25
|
const source = Array.isArray(raw) ? raw.join(',') : String(raw);
|
|
25
|
-
|
|
26
|
+
const parsed = [...new Set(
|
|
26
27
|
source
|
|
27
28
|
.split(',')
|
|
28
29
|
.map((entry) => entry.trim().toLowerCase())
|
|
29
30
|
.filter(Boolean)
|
|
30
31
|
.slice(0, 12)
|
|
31
32
|
)];
|
|
33
|
+
if (!parsed.every((engine) => ALLOWED_ENGINES.has(engine))) return null;
|
|
34
|
+
return parsed;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeLang(raw) {
|
|
38
|
+
const input = String(raw || '').trim();
|
|
39
|
+
if (!input) return null;
|
|
40
|
+
const lower = input.toLowerCase();
|
|
41
|
+
const exact = [...ALLOWED_LANGS].find((lang) => lang.toLowerCase() === lower);
|
|
42
|
+
if (exact) return exact;
|
|
43
|
+
const shortMap = {
|
|
44
|
+
it: 'it-IT',
|
|
45
|
+
en: 'en-US',
|
|
46
|
+
es: 'es-ES',
|
|
47
|
+
fr: 'fr-FR',
|
|
48
|
+
de: 'de-DE',
|
|
49
|
+
pt: 'pt-PT',
|
|
50
|
+
ru: 'ru-RU',
|
|
51
|
+
zh: 'zh-CN',
|
|
52
|
+
ja: 'ja-JP',
|
|
53
|
+
};
|
|
54
|
+
return shortMap[lower] || null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function resolveLang(rawLang, acceptLanguageHeader = '') {
|
|
58
|
+
const normalized = normalizeLang(rawLang || 'auto');
|
|
59
|
+
if (normalized && normalized !== 'auto') return normalized;
|
|
60
|
+
const first = String(acceptLanguageHeader || '')
|
|
61
|
+
.split(',')
|
|
62
|
+
.map((part) => part.trim().split(';')[0])
|
|
63
|
+
.find(Boolean);
|
|
64
|
+
const fromHeader = normalizeLang(first || '');
|
|
65
|
+
return fromHeader || 'en-US';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function normalizeBase(rawBase) {
|
|
69
|
+
const raw = String(rawBase || '').trim();
|
|
70
|
+
if (!raw) return '';
|
|
71
|
+
return raw.replace(/\/$/, '');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function detectModelProvider(base, preset = '') {
|
|
75
|
+
const p = String(preset || '').trim().toLowerCase();
|
|
76
|
+
if (p === 'openroute') return 'openrouter';
|
|
77
|
+
if (p && p !== 'custom') return p;
|
|
78
|
+
const b = String(base || '').toLowerCase();
|
|
79
|
+
if (b.includes('anthropic.com')) return 'anthropic';
|
|
80
|
+
if (b.includes('openrouter.ai')) return 'openrouter';
|
|
81
|
+
if (b.includes('openai.com')) return 'openai';
|
|
82
|
+
if (b.includes('chutes.ai')) return 'chutes';
|
|
83
|
+
if (b.includes(':11434')) return 'ollama';
|
|
84
|
+
if (b.includes(':1234')) return 'lmstudio';
|
|
85
|
+
if (b.includes(':8080')) return 'llamacpp';
|
|
86
|
+
return 'openai_compat';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function parseModelPayload(payload) {
|
|
90
|
+
if (Array.isArray(payload)) {
|
|
91
|
+
return payload.map((item) => typeof item === 'string' ? item.trim() : String(item?.id || item?.name || '').trim()).filter(Boolean);
|
|
92
|
+
}
|
|
93
|
+
if (!payload || typeof payload !== 'object') return [];
|
|
94
|
+
if (Array.isArray(payload.data)) {
|
|
95
|
+
return payload.data.map((item) => String(item?.id || '').trim()).filter(Boolean);
|
|
96
|
+
}
|
|
97
|
+
if (Array.isArray(payload.models)) {
|
|
98
|
+
return payload.models.map((item) => typeof item === 'string' ? item.trim() : String(item?.id || item?.name || '').trim()).filter(Boolean);
|
|
99
|
+
}
|
|
100
|
+
if (Array.isArray(payload?.result?.models)) {
|
|
101
|
+
return payload.result.models.map((item) => typeof item === 'string' ? item.trim() : String(item?.id || item?.name || '').trim()).filter(Boolean);
|
|
102
|
+
}
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildModelAuthVariants(provider, apiKey) {
|
|
107
|
+
const key = String(apiKey || '').trim();
|
|
108
|
+
if (!key) return [{ Accept: 'application/json' }];
|
|
109
|
+
if (provider === 'chutes') {
|
|
110
|
+
return [
|
|
111
|
+
{ Accept: 'application/json', Authorization: `Bearer ${key}` },
|
|
112
|
+
{ Accept: 'application/json', 'x-api-key': key },
|
|
113
|
+
{ Accept: 'application/json', Authorization: `Bearer ${key}`, 'x-api-key': key },
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
return [{ Accept: 'application/json', Authorization: `Bearer ${key}` }];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function fetchOpenAiCompatibleModels(base, apiKey, provider = 'openai_compat', timeoutMs = 10000) {
|
|
120
|
+
const variants = buildModelAuthVariants(provider, apiKey);
|
|
121
|
+
for (const headers of variants) {
|
|
122
|
+
const ac = new AbortController();
|
|
123
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch(`${base}/models`, { headers, signal: ac.signal });
|
|
126
|
+
if (!response.ok) continue;
|
|
127
|
+
const payload = await response.json();
|
|
128
|
+
const parsed = parseModelPayload(payload);
|
|
129
|
+
if (parsed.length > 0) return parsed;
|
|
130
|
+
} catch {
|
|
131
|
+
// try next variant
|
|
132
|
+
} finally {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return [];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function fetchOpenRouterModels(base, apiKey, timeoutMs = 10000) {
|
|
140
|
+
const endpoint = base.includes('/api/v1') ? `${base}/models` : `${base}/api/v1/models`;
|
|
141
|
+
const ac = new AbortController();
|
|
142
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
143
|
+
try {
|
|
144
|
+
const headers = { Accept: 'application/json' };
|
|
145
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
146
|
+
const response = await fetch(endpoint, { headers, signal: ac.signal });
|
|
147
|
+
if (!response.ok) return [];
|
|
148
|
+
const payload = await response.json();
|
|
149
|
+
return parseModelPayload(payload);
|
|
150
|
+
} catch {
|
|
151
|
+
return [];
|
|
152
|
+
} finally {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function fetchAnthropicModels(base, apiKey, timeoutMs = 10000) {
|
|
158
|
+
if (!apiKey) return [];
|
|
159
|
+
const endpoint = base.endsWith('/v1') ? `${base}/models` : `${base}/v1/models`;
|
|
160
|
+
const ac = new AbortController();
|
|
161
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
162
|
+
try {
|
|
163
|
+
const response = await fetch(endpoint, {
|
|
164
|
+
headers: {
|
|
165
|
+
Accept: 'application/json',
|
|
166
|
+
'x-api-key': apiKey,
|
|
167
|
+
'anthropic-version': '2023-06-01',
|
|
168
|
+
},
|
|
169
|
+
signal: ac.signal,
|
|
170
|
+
});
|
|
171
|
+
if (!response.ok) return [];
|
|
172
|
+
const payload = await response.json();
|
|
173
|
+
return parseModelPayload(payload);
|
|
174
|
+
} catch {
|
|
175
|
+
return [];
|
|
176
|
+
} finally {
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function fetchOllamaModels(base, timeoutMs = 10000) {
|
|
182
|
+
const origin = (() => {
|
|
183
|
+
try {
|
|
184
|
+
const u = new URL(base);
|
|
185
|
+
return `${u.protocol}//${u.host}`;
|
|
186
|
+
} catch {
|
|
187
|
+
return base;
|
|
188
|
+
}
|
|
189
|
+
})();
|
|
190
|
+
const ac = new AbortController();
|
|
191
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
192
|
+
try {
|
|
193
|
+
const response = await fetch(`${origin}/api/tags`, { headers: { Accept: 'application/json' }, signal: ac.signal });
|
|
194
|
+
if (!response.ok) return [];
|
|
195
|
+
const payload = await response.json();
|
|
196
|
+
return (payload?.models || [])
|
|
197
|
+
.map((item) => String(item?.name || '').trim())
|
|
198
|
+
.filter(Boolean);
|
|
199
|
+
} catch {
|
|
200
|
+
return [];
|
|
201
|
+
} finally {
|
|
202
|
+
clearTimeout(timer);
|
|
203
|
+
}
|
|
32
204
|
}
|
|
33
205
|
|
|
34
206
|
export function createRouter(config, rateLimiters) {
|
|
@@ -68,6 +240,7 @@ export function createRouter(config, rateLimiters) {
|
|
|
68
240
|
'/api/magnet': { post: { summary: 'Extract magnet from page URL' } },
|
|
69
241
|
'/api/scan': { post: { summary: 'Scan site pages by query' } },
|
|
70
242
|
'/api/config': { get: { summary: 'Read config (masked)' }, post: { summary: 'Update config' } },
|
|
243
|
+
'/api/config/models': { post: { summary: 'List AI models from selected provider endpoint' } },
|
|
71
244
|
},
|
|
72
245
|
});
|
|
73
246
|
});
|
|
@@ -84,11 +257,12 @@ export function createRouter(config, rateLimiters) {
|
|
|
84
257
|
if (!q) return sendJson(res, 400, { error: 'missing_query', message: 'q parameter required' });
|
|
85
258
|
if (q.length > cfg.search.max_query_length) return sendJson(res, 400, { error: 'query_too_long' });
|
|
86
259
|
|
|
87
|
-
const lang =
|
|
260
|
+
const lang = resolveLang(req.query.lang, req.headers['accept-language']);
|
|
88
261
|
const safe = String(req.query.safe || '1');
|
|
89
262
|
const page = Number(req.query.page || '1');
|
|
90
263
|
const category = parseCategory(req.query.cat);
|
|
91
264
|
const engines = parseEngines(req.query.engines);
|
|
265
|
+
if (engines === null) return sendJson(res, 400, { error: 'invalid_engines', message: 'engines must be a comma-separated allowlisted set.' });
|
|
92
266
|
|
|
93
267
|
try {
|
|
94
268
|
const result = await search({ query: q, lang, safe, page, category, engines }, cfg);
|
|
@@ -111,11 +285,12 @@ export function createRouter(config, rateLimiters) {
|
|
|
111
285
|
if (!q) return sendJson(res, 400, { error: 'missing_query' });
|
|
112
286
|
if (q.length > cfg.search.max_query_length) return sendJson(res, 400, { error: 'query_too_long' });
|
|
113
287
|
|
|
114
|
-
const lang =
|
|
288
|
+
const lang = resolveLang(req.query.lang, req.headers['accept-language']);
|
|
115
289
|
const safe = String(req.query.safe || '1');
|
|
116
290
|
const page = Number(req.query.page || '1');
|
|
117
291
|
const category = parseCategory(req.query.cat);
|
|
118
292
|
const engines = parseEngines(req.query.engines);
|
|
293
|
+
if (engines === null) return sendJson(res, 400, { error: 'invalid_engines', message: 'engines must be a comma-separated allowlisted set.' });
|
|
119
294
|
|
|
120
295
|
applySecurityHeaders(res);
|
|
121
296
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
@@ -134,6 +309,8 @@ export function createRouter(config, rateLimiters) {
|
|
|
134
309
|
lang,
|
|
135
310
|
results: chunk.results || [],
|
|
136
311
|
providers: chunk.providers || [],
|
|
312
|
+
degraded: chunk.degraded === true,
|
|
313
|
+
engineStats: chunk.engineStats || { responded: chunk.providers || [], failed: [], unstable: [], health: {} },
|
|
137
314
|
});
|
|
138
315
|
} else {
|
|
139
316
|
send({
|
|
@@ -143,8 +320,8 @@ export function createRouter(config, rateLimiters) {
|
|
|
143
320
|
results: chunk.results || [],
|
|
144
321
|
allResults: chunk.results || [],
|
|
145
322
|
providers: chunk.providers || [],
|
|
146
|
-
degraded:
|
|
147
|
-
engineStats: { responded: chunk.providers || [], failed: [], unstable: [], health: {} },
|
|
323
|
+
degraded: chunk.degraded === true,
|
|
324
|
+
engineStats: chunk.engineStats || { responded: chunk.providers || [], failed: [], unstable: [], health: {} },
|
|
148
325
|
});
|
|
149
326
|
}
|
|
150
327
|
}
|
|
@@ -189,7 +366,7 @@ export function createRouter(config, rateLimiters) {
|
|
|
189
366
|
if (!cfg.ai?.enabled) return sendJson(res, 200, { refined_query: req.body?.query, intent: 'other', also_search: [] });
|
|
190
367
|
|
|
191
368
|
const query = String(req.body?.query || '').trim();
|
|
192
|
-
const lang =
|
|
369
|
+
const lang = resolveLang(req.body?.lang, req.headers['accept-language']);
|
|
193
370
|
if (!query) return sendJson(res, 400, { error: 'missing_query' });
|
|
194
371
|
|
|
195
372
|
const result = await refineQuery({ query, lang }, cfg.ai);
|
|
@@ -213,7 +390,7 @@ export function createRouter(config, rateLimiters) {
|
|
|
213
390
|
}
|
|
214
391
|
|
|
215
392
|
const query = String(req.body?.query || '').trim();
|
|
216
|
-
const lang =
|
|
393
|
+
const lang = resolveLang(req.body?.lang, req.headers['accept-language']);
|
|
217
394
|
const results = Array.isArray(req.body?.results) ? req.body.results : [];
|
|
218
395
|
const session = Array.isArray(req.body?.session) ? req.body.session.slice(-4) : [];
|
|
219
396
|
const streamMode = req.body?.stream !== false;
|
|
@@ -305,6 +482,38 @@ export function createRouter(config, rateLimiters) {
|
|
|
305
482
|
sendJson(res, 405, { error: 'method_not_allowed', message: 'Use POST /api/config/test-ai' });
|
|
306
483
|
});
|
|
307
484
|
|
|
485
|
+
// ─── Fetch provider model list ───────────────────────────────────────────
|
|
486
|
+
router.post('/api/config/models', express.json({ limit: '8kb' }), async (req, res) => {
|
|
487
|
+
const ip = req.clientIp;
|
|
488
|
+
if (!rateLimiters.checkGeneral(ip)) {
|
|
489
|
+
return sendRateLimited(res, { windowMs: rateLimiters.windowMs });
|
|
490
|
+
}
|
|
491
|
+
const cfg = config.getConfig();
|
|
492
|
+
const base = normalizeBase(req.body?.api_base || cfg.ai?.api_base || '');
|
|
493
|
+
const apiKey = String(req.body?.api_key || cfg.ai?.api_key || '');
|
|
494
|
+
const preset = String(req.body?.preset || '').trim().toLowerCase();
|
|
495
|
+
if (!base) return sendJson(res, 400, { ok: false, error: 'missing_api_base' });
|
|
496
|
+
|
|
497
|
+
const provider = detectModelProvider(base, preset);
|
|
498
|
+
let models = [];
|
|
499
|
+
if (provider === 'anthropic') {
|
|
500
|
+
models = await fetchAnthropicModels(base, apiKey);
|
|
501
|
+
} else if (provider === 'openrouter') {
|
|
502
|
+
models = await fetchOpenRouterModels(base, apiKey);
|
|
503
|
+
} else if (provider === 'ollama') {
|
|
504
|
+
models = await fetchOllamaModels(base);
|
|
505
|
+
if (models.length === 0) {
|
|
506
|
+
models = await fetchOpenAiCompatibleModels(base, apiKey, provider);
|
|
507
|
+
}
|
|
508
|
+
} else {
|
|
509
|
+
models = await fetchOpenAiCompatibleModels(base, apiKey, provider);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
models = [...new Set(models)].slice(0, 80);
|
|
513
|
+
applySecurityHeaders(res);
|
|
514
|
+
res.json({ ok: true, provider, models });
|
|
515
|
+
});
|
|
516
|
+
|
|
308
517
|
// ─── Test search provider ─────────────────────────────────────────────────
|
|
309
518
|
router.get('/api/config/test-provider/:name', async (req, res) => {
|
|
310
519
|
const cfg = config.getConfig();
|
|
@@ -327,7 +536,11 @@ export function createRouter(config, rateLimiters) {
|
|
|
327
536
|
results = await mojeekSearch({ query: testQuery, config: cfg, timeoutMs: 8000 });
|
|
328
537
|
} else if (name === 'searxng') {
|
|
329
538
|
const { search: searxSearch } = await import('../search/providers/searxng.js');
|
|
330
|
-
|
|
539
|
+
const response = await searxSearch({ query: testQuery, config: cfg, timeoutMs: 8000 });
|
|
540
|
+
results = Array.isArray(response) ? response : (response?.results || []);
|
|
541
|
+
} else if (name === 'github') {
|
|
542
|
+
const { search: githubSearch } = await import('../search/providers/github.js');
|
|
543
|
+
results = await githubSearch({ query: testQuery, config: cfg, timeoutMs: 8000 });
|
|
331
544
|
} else {
|
|
332
545
|
return sendJson(res, 400, { error: 'unknown_provider' });
|
|
333
546
|
}
|