@vierratale/ai 0.1.0-beta.1 → 0.1.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -0
- package/package.json +1 -1
- package/src/cli.js +79 -4
- package/src/providers/base.js +4 -0
- package/src/providers/ollama.js +4 -0
- package/src/providers/openai.js +4 -0
- package/src/session.js +41 -0
- package/src/ui/banner.js +18 -12
- package/src/ui/branding.js +1 -1
- package/src/ui/terminal.js +50 -7
- package/src/utils/intents.js +30 -0
- package/src/utils/websearch.js +93 -0
package/README.md
CHANGED
|
@@ -40,9 +40,28 @@ vierrataleai --model vierratale-pro # Use specific model
|
|
|
40
40
|
- `/model [name]` - Switch model
|
|
41
41
|
- `/provider [name]` - Switch provider
|
|
42
42
|
- `/models` - List available models
|
|
43
|
+
- `/search <query>` - Search the web and summarize with AI
|
|
43
44
|
- `/clear` - Clear screen
|
|
45
|
+
- `/new` - Start a new conversation (clear memory)
|
|
44
46
|
- `/quit` - Exit
|
|
45
47
|
|
|
48
|
+
## Smart Search
|
|
49
|
+
|
|
50
|
+
Asking a question (Who/What/When/Where/Why/How...?) automatically searches the
|
|
51
|
+
web and summarizes the results with AI. For example:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
> Who is Cristiano Ronaldo?
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
triggers a live web search and answers from the results. Use `/search <query>`
|
|
58
|
+
to force a search explicitly.
|
|
59
|
+
|
|
60
|
+
## Conversation Memory
|
|
61
|
+
|
|
62
|
+
Your conversation is saved automatically and restored on the next launch, so
|
|
63
|
+
you can continue where you left off. Use `/new` to start fresh.
|
|
64
|
+
|
|
46
65
|
## Configuration
|
|
47
66
|
|
|
48
67
|
Config file: `~/.config/vierrataleai/config.json`
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -6,6 +6,9 @@ import { ProviderFactory } from './providers/index.js';
|
|
|
6
6
|
import { Branding } from './ui/branding.js';
|
|
7
7
|
import { Terminal } from './ui/terminal.js';
|
|
8
8
|
import { loadSystemPrompt, showBanner } from './ui/banner.js';
|
|
9
|
+
import { WebSearch } from './utils/websearch.js';
|
|
10
|
+
import { detectIntent } from './utils/intents.js';
|
|
11
|
+
import { Session } from './session.js';
|
|
9
12
|
|
|
10
13
|
function parseArgs(args) {
|
|
11
14
|
const parsed = { provider: null, model: null, clear: false, version: false, help: false };
|
|
@@ -37,15 +40,61 @@ Commands (in chat):
|
|
|
37
40
|
/model [name] Switch model
|
|
38
41
|
/provider [name] Switch provider
|
|
39
42
|
/models List available models
|
|
43
|
+
/search <query> Search the web and summarize with AI
|
|
40
44
|
/clear Clear screen
|
|
45
|
+
/new Start a new conversation (clear memory)
|
|
41
46
|
/quit Exit
|
|
42
47
|
`);
|
|
43
48
|
}
|
|
44
49
|
|
|
50
|
+
async function answerWithSearch(messages, provider, query) {
|
|
51
|
+
Terminal.printInfo('Searching the web...');
|
|
52
|
+
const results = await WebSearch.search(query);
|
|
53
|
+
|
|
54
|
+
if (results.length === 0) {
|
|
55
|
+
Terminal.printError('No results found for the query.');
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
Terminal.printSearchResults(results);
|
|
60
|
+
|
|
61
|
+
const searchContext = results.map((r, i) =>
|
|
62
|
+
`${i + 1}. ${r.title}\n URL: ${r.url}\n ${r.snippet}`
|
|
63
|
+
).join('\n\n');
|
|
64
|
+
|
|
65
|
+
messages.push({
|
|
66
|
+
role: 'user',
|
|
67
|
+
content: `Web search results for "${query}":\n\n${searchContext}\n\nPlease summarize these results and answer based on them. Be concise.`,
|
|
68
|
+
});
|
|
69
|
+
Session.save(messages);
|
|
70
|
+
|
|
71
|
+
Terminal.printAIStart();
|
|
72
|
+
let response = '';
|
|
73
|
+
try {
|
|
74
|
+
for await (const chunk of provider.stream(messages, {
|
|
75
|
+
model: Config.get('model'),
|
|
76
|
+
systemPrompt,
|
|
77
|
+
})) {
|
|
78
|
+
Terminal.printAIChunk(chunk);
|
|
79
|
+
response += chunk;
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
Terminal.printError(err.message || 'Stream error');
|
|
83
|
+
}
|
|
84
|
+
Terminal.printAIEnd();
|
|
85
|
+
if (response) {
|
|
86
|
+
messages.push({ role: 'assistant', content: response });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
45
90
|
async function chat(provider, systemPrompt) {
|
|
46
|
-
const messages =
|
|
91
|
+
const messages = Session.load();
|
|
47
92
|
let exited = false;
|
|
48
93
|
|
|
94
|
+
if (messages.length > 0) {
|
|
95
|
+
Terminal.printInfo(`Resumed ${messages.filter((m) => m.role === 'user').length} previous message(s).`);
|
|
96
|
+
}
|
|
97
|
+
|
|
49
98
|
const rl = createInterface({
|
|
50
99
|
input: process.stdin,
|
|
51
100
|
output: process.stdout,
|
|
@@ -61,7 +110,7 @@ async function chat(provider, systemPrompt) {
|
|
|
61
110
|
|
|
62
111
|
const prompt = () => new Promise((resolve) => {
|
|
63
112
|
rl.question(
|
|
64
|
-
|
|
113
|
+
`\n${Branding.colors.bold}${Branding.colors.primary}┌ ${Branding.USER_PROMPT}${Branding.colors.reset} `,
|
|
65
114
|
resolve
|
|
66
115
|
);
|
|
67
116
|
});
|
|
@@ -90,7 +139,7 @@ async function chat(provider, systemPrompt) {
|
|
|
90
139
|
|
|
91
140
|
if (cmd === '/clear') {
|
|
92
141
|
Terminal.clear();
|
|
93
|
-
showBanner(provider.name, Config.get('model'));
|
|
142
|
+
showBanner(provider.name, Config.get('model'), provider.displayName);
|
|
94
143
|
continue;
|
|
95
144
|
}
|
|
96
145
|
|
|
@@ -99,6 +148,13 @@ async function chat(provider, systemPrompt) {
|
|
|
99
148
|
continue;
|
|
100
149
|
}
|
|
101
150
|
|
|
151
|
+
if (cmd === '/new') {
|
|
152
|
+
Session.clear();
|
|
153
|
+
messages.length = 0;
|
|
154
|
+
Terminal.printSuccess('Started a new conversation.');
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
102
158
|
if (cmd === '/models') {
|
|
103
159
|
const models = provider.name === 'ollama'
|
|
104
160
|
? Catalog.getLocalModels()
|
|
@@ -146,7 +202,25 @@ async function chat(provider, systemPrompt) {
|
|
|
146
202
|
continue;
|
|
147
203
|
}
|
|
148
204
|
|
|
205
|
+
if (cmd.startsWith('/search ')) {
|
|
206
|
+
const query = trimmed.slice(8).trim();
|
|
207
|
+
if (!query) {
|
|
208
|
+
Terminal.printWarning('Usage: /search <query>');
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
await answerWithSearch(messages, provider, query);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const intent = detectIntent(trimmed);
|
|
216
|
+
if (intent.type === 'knowledge') {
|
|
217
|
+
Terminal.printSuccess(`Auto-search: "${trimmed}"`);
|
|
218
|
+
await answerWithSearch(messages, provider, trimmed);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
|
|
149
222
|
messages.push({ role: 'user', content: trimmed });
|
|
223
|
+
Session.save(messages);
|
|
150
224
|
Terminal.printAIStart();
|
|
151
225
|
|
|
152
226
|
let response = '';
|
|
@@ -166,6 +240,7 @@ async function chat(provider, systemPrompt) {
|
|
|
166
240
|
if (response) {
|
|
167
241
|
messages.push({ role: 'assistant', content: response });
|
|
168
242
|
}
|
|
243
|
+
Session.save(messages);
|
|
169
244
|
}
|
|
170
245
|
}
|
|
171
246
|
|
|
@@ -198,7 +273,7 @@ export async function run() {
|
|
|
198
273
|
Config.save({ provider: provider.name });
|
|
199
274
|
|
|
200
275
|
if (args.clear) Terminal.clear();
|
|
201
|
-
showBanner(provider.name, Config.get('model'));
|
|
276
|
+
showBanner(provider.name, Config.get('model'), provider.displayName);
|
|
202
277
|
|
|
203
278
|
const systemPrompt = loadSystemPrompt();
|
|
204
279
|
await chat(provider, systemPrompt);
|
package/src/providers/base.js
CHANGED
package/src/providers/ollama.js
CHANGED
|
@@ -8,6 +8,10 @@ export class OllamaProvider extends BaseProvider {
|
|
|
8
8
|
this.host = Config.get('ollamaHost');
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
get displayName() {
|
|
12
|
+
return 'Cortex';
|
|
13
|
+
}
|
|
14
|
+
|
|
11
15
|
async isAvailable() {
|
|
12
16
|
try {
|
|
13
17
|
const resp = await fetch(`${this.host}/api/tags`, { signal: AbortSignal.timeout(3000) });
|
package/src/providers/openai.js
CHANGED
package/src/session.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { Config } from './config.js';
|
|
4
|
+
|
|
5
|
+
const SESSION_FILE = join(Config.getConfigDir(), 'history.json');
|
|
6
|
+
const MAX_MESSAGES = 20;
|
|
7
|
+
|
|
8
|
+
export const Session = {
|
|
9
|
+
load() {
|
|
10
|
+
try {
|
|
11
|
+
if (existsSync(SESSION_FILE)) {
|
|
12
|
+
const raw = readFileSync(SESSION_FILE, 'utf-8');
|
|
13
|
+
const data = JSON.parse(raw || '[]');
|
|
14
|
+
if (Array.isArray(data)) return data.slice(-MAX_MESSAGES);
|
|
15
|
+
}
|
|
16
|
+
} catch {}
|
|
17
|
+
return [];
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
save(messages) {
|
|
21
|
+
try {
|
|
22
|
+
const dir = Config.getConfigDir();
|
|
23
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
24
|
+
writeFileSync(SESSION_FILE, JSON.stringify(messages.slice(-MAX_MESSAGES), null, 2));
|
|
25
|
+
} catch {}
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
clear() {
|
|
29
|
+
try {
|
|
30
|
+
if (existsSync(SESSION_FILE)) writeFileSync(SESSION_FILE, '[]');
|
|
31
|
+
} catch {}
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
hasHistory() {
|
|
35
|
+
try {
|
|
36
|
+
return existsSync(SESSION_FILE) && (JSON.parse(readFileSync(SESSION_FILE, 'utf-8') || '[]').length > 0);
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
package/src/ui/banner.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFileSync } from 'fs';
|
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import { Branding } from './branding.js';
|
|
5
|
+
import { Terminal } from './terminal.js';
|
|
5
6
|
|
|
6
7
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
8
|
|
|
@@ -15,17 +16,22 @@ export function loadSystemPrompt() {
|
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
|
|
18
|
-
export function showBanner(
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
console.log(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
);
|
|
27
|
-
console.log(
|
|
28
|
-
|
|
29
|
-
);
|
|
19
|
+
export function showBanner(providerName, model, providerDisplayName) {
|
|
20
|
+
const C = Branding.colors;
|
|
21
|
+
const width = 44;
|
|
22
|
+
const border = ` ╔${'═'.repeat(width)}╗`;
|
|
23
|
+
|
|
24
|
+
console.log();
|
|
25
|
+
console.log(`${C.primary}${Branding.BANNER}${C.reset}`);
|
|
26
|
+
console.log(` ${C.bold}${C.accent}▸ Vierratale AI ▸ Cortex Engine${C.reset}`);
|
|
27
|
+
console.log();
|
|
28
|
+
console.log(border);
|
|
29
|
+
console.log(` ║ ${C.bold}${C.primary}Model${C.reset}${' '.repeat(width - 12)}║`);
|
|
30
|
+
console.log(` ║ ${C.accent}${model}${C.reset}${' '.repeat(Math.max(1, width - 5 - model.length))}║`);
|
|
31
|
+
console.log(` ║ ${C.bold}${C.primary}Engine${C.reset}${' '.repeat(width - 13)}║`);
|
|
32
|
+
console.log(` ║ ${C.accent}${providerDisplayName || 'Cortex'}${C.reset}${' '.repeat(Math.max(1, width - 7 - (providerDisplayName || 'Cortex').length))}║`);
|
|
33
|
+
console.log(` ╚${'═'.repeat(width)}╝`);
|
|
34
|
+
console.log();
|
|
35
|
+
console.log(` ${C.dim}Type /help for commands · /quit to exit${C.reset}`);
|
|
30
36
|
console.log();
|
|
31
37
|
}
|
package/src/ui/branding.js
CHANGED
package/src/ui/terminal.js
CHANGED
|
@@ -3,25 +3,56 @@ import { Branding } from './branding.js';
|
|
|
3
3
|
const C = Branding.colors;
|
|
4
4
|
|
|
5
5
|
export const Terminal = {
|
|
6
|
+
divider(char = '─', length = 50) {
|
|
7
|
+
return `${C.dim}${char.repeat(length)}${C.reset}`;
|
|
8
|
+
},
|
|
9
|
+
|
|
6
10
|
printUser(text) {
|
|
7
|
-
|
|
11
|
+
const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
12
|
+
console.log();
|
|
13
|
+
console.log(`${this.divider('─', 4)} ${C.bold}${C.primary}YOU${C.reset} ${C.dim}${time}${C.reset} ${this.divider('─', 34)}`);
|
|
14
|
+
console.log(`${C.primary}┃${C.reset} ${text}`);
|
|
8
15
|
},
|
|
9
16
|
|
|
10
17
|
printAIStart() {
|
|
11
|
-
|
|
18
|
+
const time = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
19
|
+
this.spinner = { frame: 0, timer: null };
|
|
20
|
+
console.log();
|
|
21
|
+
console.log(`${this.divider('─', 4)} ${C.bold}${C.accent}${Branding.AI_PROMPT}${C.reset} ${C.dim}${time}${C.reset} ${this.divider('─', 34)}`);
|
|
22
|
+
this.curLine = '';
|
|
23
|
+
this._printPrefix();
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
_printPrefix() {
|
|
27
|
+
process.stdout.write(`${C.accent}┃ ${C.reset}`);
|
|
12
28
|
},
|
|
13
29
|
|
|
14
30
|
printAIChunk(text) {
|
|
31
|
+
this.curLine = (this.curLine || '') + text;
|
|
15
32
|
process.stdout.write(text);
|
|
33
|
+
this._maybeWrap();
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
_maybeWrap() {
|
|
37
|
+
if (this.curLine && this.curLine.length > 100) {
|
|
38
|
+
const words = this.curLine.split(' ');
|
|
39
|
+
if (words.length > 1) {
|
|
40
|
+
const lastWord = words[words.length - 1];
|
|
41
|
+
const rest = this.curLine.slice(0, this.curLine.length - lastWord.length).trimEnd();
|
|
42
|
+
process.stdout.write(`\n${C.accent}┃ ${C.reset}`);
|
|
43
|
+
this.curLine = lastWord;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
16
46
|
},
|
|
17
47
|
|
|
18
48
|
printAIEnd() {
|
|
19
|
-
|
|
49
|
+
process.stdout.write('\n');
|
|
50
|
+
console.log(`${C.accent}┗${C.reset}${this.divider('─', 44)}`);
|
|
20
51
|
console.log();
|
|
21
52
|
},
|
|
22
53
|
|
|
23
54
|
printError(msg) {
|
|
24
|
-
console.
|
|
55
|
+
console.log(`${C.error}✖ ${msg}${C.reset}`);
|
|
25
56
|
},
|
|
26
57
|
|
|
27
58
|
printInfo(msg) {
|
|
@@ -29,11 +60,23 @@ export const Terminal = {
|
|
|
29
60
|
},
|
|
30
61
|
|
|
31
62
|
printSuccess(msg) {
|
|
32
|
-
console.log(`${C.success}${msg}${C.reset}`);
|
|
63
|
+
console.log(`${C.success}✔ ${msg}${C.reset}`);
|
|
33
64
|
},
|
|
34
65
|
|
|
35
66
|
printWarning(msg) {
|
|
36
|
-
console.log(`${C.warning}${msg}${C.reset}`);
|
|
67
|
+
console.log(`${C.warning}⚠ ${msg}${C.reset}`);
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
printSearchResults(results) {
|
|
71
|
+
console.log(`${C.warning}┌── Web Search Results ──${C.reset}`);
|
|
72
|
+
results.forEach((r, i) => {
|
|
73
|
+
console.log(
|
|
74
|
+
` ${C.warning}${i + 1}.${C.reset} ${C.bold}${r.title}${C.reset}`
|
|
75
|
+
);
|
|
76
|
+
console.log(` ${C.dim}${r.url}${C.reset}`);
|
|
77
|
+
if (r.snippet) console.log(` ${r.snippet.slice(0, 120)}`);
|
|
78
|
+
});
|
|
79
|
+
console.log(`${C.warning}└──${C.reset}`);
|
|
37
80
|
},
|
|
38
81
|
|
|
39
82
|
clear() {
|
|
@@ -44,6 +87,6 @@ export const Terminal = {
|
|
|
44
87
|
return text
|
|
45
88
|
.replace(/\*\*(.*?)\*\*/g, `${C.bold}$1${C.reset}`)
|
|
46
89
|
.replace(/`([^`]+)`/g, `${C.accent}\`$1\`${C.reset}`)
|
|
47
|
-
.replace(/```(\w*)\n([\s\S]*?)```/g, `${C.dim}┌─$
|
|
90
|
+
.replace(/```(\w*)\n([\s\S]*?)```/g, `${C.dim}┌─${C.reset}\n$2${C.dim}└─────${C.reset}`);
|
|
48
91
|
},
|
|
49
92
|
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
const QUESTION_PREFIXES = [
|
|
2
|
+
'who', 'what', 'when', 'where', 'why', 'how',
|
|
3
|
+
'which', 'whose', 'whom', 'is', 'are', 'was', 'were',
|
|
4
|
+
'can', 'could', 'does', 'do', 'did', 'would', 'should', 'will',
|
|
5
|
+
];
|
|
6
|
+
|
|
7
|
+
const KNOWLEDGE_PREFIXES = [
|
|
8
|
+
'tell me about', "what is", "what's", "who is", "who's",
|
|
9
|
+
'what are', 'who was', 'when did', 'when was', 'where is',
|
|
10
|
+
'where was', 'how did', 'how does', 'how to', 'why did', 'why is',
|
|
11
|
+
'latest', 'news', 'current',
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function detectIntent(text) {
|
|
15
|
+
const lower = text.toLowerCase().trim();
|
|
16
|
+
if (lower.startsWith('/')) return { type: 'command' };
|
|
17
|
+
|
|
18
|
+
const isQuestion = /\?$/.test(lower);
|
|
19
|
+
const firstWord = lower.split(/\s+/)[0] || '';
|
|
20
|
+
|
|
21
|
+
let needsWeb = false;
|
|
22
|
+
if (isQuestion && QUESTION_PREFIXES.includes(firstWord)) {
|
|
23
|
+
needsWeb = true;
|
|
24
|
+
}
|
|
25
|
+
if (KNOWLEDGE_PREFIXES.some((p) => lower.startsWith(p + ' ') || lower === p)) {
|
|
26
|
+
needsWeb = true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return { type: needsWeb ? 'knowledge' : 'chat' };
|
|
30
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const DDG_SEARCH = 'https://html.duckduckgo.com/html/';
|
|
2
|
+
const WIKI_SEARCH = 'https://en.wikipedia.org/w/api.php';
|
|
3
|
+
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36';
|
|
4
|
+
|
|
5
|
+
export class WebSearch {
|
|
6
|
+
static async search(query, maxResults = 5) {
|
|
7
|
+
let results = await this._searchDuckDuckGo(query, maxResults);
|
|
8
|
+
if (results.length === 0) {
|
|
9
|
+
results = await this._searchWikipedia(query, maxResults);
|
|
10
|
+
}
|
|
11
|
+
return results;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
static async _searchDuckDuckGo(query, maxResults) {
|
|
15
|
+
const url = `${DDG_SEARCH}?q=${encodeURIComponent(query)}`;
|
|
16
|
+
try {
|
|
17
|
+
const resp = await fetch(url, {
|
|
18
|
+
headers: { 'User-Agent': UA },
|
|
19
|
+
signal: AbortSignal.timeout(10000),
|
|
20
|
+
});
|
|
21
|
+
if (!resp.ok) return [];
|
|
22
|
+
const html = await resp.text();
|
|
23
|
+
return this._parseDuckDuckGo(html, maxResults);
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static async _searchWikipedia(query, maxResults) {
|
|
30
|
+
const params = new URLSearchParams({
|
|
31
|
+
action: 'query',
|
|
32
|
+
list: 'search',
|
|
33
|
+
srsearch: query,
|
|
34
|
+
srlimit: String(maxResults),
|
|
35
|
+
format: 'json',
|
|
36
|
+
utf8: '1',
|
|
37
|
+
});
|
|
38
|
+
const url = `${WIKI_SEARCH}?${params.toString()}`;
|
|
39
|
+
try {
|
|
40
|
+
const resp = await fetch(url, {
|
|
41
|
+
headers: { 'User-Agent': UA },
|
|
42
|
+
signal: AbortSignal.timeout(10000),
|
|
43
|
+
});
|
|
44
|
+
if (!resp.ok) return [];
|
|
45
|
+
const data = await resp.json();
|
|
46
|
+
const search = data?.query?.search || [];
|
|
47
|
+
return search.map((r) => ({
|
|
48
|
+
title: r.title,
|
|
49
|
+
url: `https://en.wikipedia.org/wiki/${encodeURIComponent(r.title.replace(/ /g, '_'))}`,
|
|
50
|
+
snippet: this._stripTags(r.snippet || ''),
|
|
51
|
+
source: 'wikipedia',
|
|
52
|
+
}));
|
|
53
|
+
} catch {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
static _parseDuckDuckGo(html, maxResults) {
|
|
59
|
+
const results = [];
|
|
60
|
+
const resultRegex = /<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>(.*?)<\/a>[\s\S]*?<a[^>]*class="result__snippet"[^>]*>(.*?)<\/a>/gi;
|
|
61
|
+
let match;
|
|
62
|
+
let count = 0;
|
|
63
|
+
while ((match = resultRegex.exec(html)) !== null && count < maxResults) {
|
|
64
|
+
let url = match[1];
|
|
65
|
+
const snippet = this._stripTags(match[3] || '');
|
|
66
|
+
const title = this._stripTags(match[2] || '');
|
|
67
|
+
url = this._decodeDdgUrl(url);
|
|
68
|
+
if (url && !url.includes('duckduckgo.com/y.js')) {
|
|
69
|
+
results.push({ title, url, snippet, source: 'duckduckgo' });
|
|
70
|
+
count++;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return results;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
static _decodeDdgUrl(url) {
|
|
77
|
+
const match = url.match(/uddg=([^&]+)/);
|
|
78
|
+
return match ? decodeURIComponent(match[1]) : url;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
static _stripTags(text) {
|
|
82
|
+
const map = {
|
|
83
|
+
'&': '&', '<': '<', '>': '>', '"': '"',
|
|
84
|
+
''': "'", ''': "'", ''': "'", ' ': ' ',
|
|
85
|
+
''': "'", '…': '...',
|
|
86
|
+
};
|
|
87
|
+
return String(text)
|
|
88
|
+
.replace(/<[^>]*>/g, '')
|
|
89
|
+
.replace(/&[a-zA-Z0-9#]+;/g, (m) => map[m] ?? '')
|
|
90
|
+
.replace(/\s{2,}/g, ' ')
|
|
91
|
+
.trim();
|
|
92
|
+
}
|
|
93
|
+
}
|