@vierratale/ai 0.1.0-beta.5 → 0.1.0-beta.7
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 +32 -13
- package/package.json +4 -2
- package/scripts/postinstall.js +35 -0
- package/src/catalog.js +20 -0
- package/src/cli.js +111 -13
- package/src/config.js +25 -4
- package/src/providers/anthropic.js +99 -0
- package/src/providers/base.js +4 -0
- package/src/providers/cortex.js +5 -0
- package/src/providers/gemini.js +97 -0
- package/src/providers/index.js +5 -1
- package/src/providers/openai.js +11 -1
- package/src/ui/banner.js +10 -5
- package/src/ui/branding.js +1 -1
- package/src/utils/webfetch.js +115 -0
package/README.md
CHANGED
|
@@ -12,27 +12,43 @@ npm install -g @vierratale/ai
|
|
|
12
12
|
npx @vierratale/ai
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
+
The first launch detects and, if needed, installs and starts the local engine
|
|
16
|
+
(**Cortex** - "secretly" Ollama under the hood), then pulls the model you use.
|
|
17
|
+
|
|
15
18
|
## Usage
|
|
16
19
|
|
|
17
20
|
```bash
|
|
18
21
|
vierrataleai # Start chat (auto-detect provider)
|
|
19
22
|
vierrataleai --provider cortex # Use local models
|
|
20
|
-
vierrataleai --provider openai # Use cloud models
|
|
23
|
+
vierrataleai --provider openai # Use cloud models (OpenAI)
|
|
24
|
+
vierrataleai --provider anthropic # Use cloud models (Claude)
|
|
25
|
+
vierrataleai --provider gemini # Use cloud models (Gemini)
|
|
21
26
|
vierrataleai --model vierratale-pro # Use specific model
|
|
22
27
|
```
|
|
23
28
|
|
|
24
29
|
## Models
|
|
25
30
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
|
29
|
-
|
|
30
|
-
| vierratale-
|
|
31
|
-
| vierratale-
|
|
32
|
-
| vierratale-
|
|
33
|
-
| vierratale-
|
|
34
|
-
| vierratale-
|
|
35
|
-
| vierratale-
|
|
31
|
+
Local models run through the **Cortex** engine:
|
|
32
|
+
|
|
33
|
+
| Model | Backend |
|
|
34
|
+
|-------|---------|
|
|
35
|
+
| vierratale-lite | qwen2.5-coder:0.5b |
|
|
36
|
+
| vierratale-fast | qwen2.5-coder:1.5b |
|
|
37
|
+
| vierratale-small | qwen2.5-coder:3b |
|
|
38
|
+
| vierratale-balanced | qwen2.5-coder:7b |
|
|
39
|
+
| vierratale-plus | qwen2.5-coder:14b |
|
|
40
|
+
| vierratale-pro | qwen2.5-coder:32b |
|
|
41
|
+
|
|
42
|
+
Cloud models are available when the matching provider + API key is configured:
|
|
43
|
+
|
|
44
|
+
| Model | Backend |
|
|
45
|
+
|-------|---------|
|
|
46
|
+
| vierratale-cloud-mini | gpt-4o-mini (OpenAI) |
|
|
47
|
+
| vierratale-cloud | gpt-4o (OpenAI) |
|
|
48
|
+
| vierratale-cloud-fast | claude-3-5-haiku-20241022 (Anthropic) |
|
|
49
|
+
| vierratale-cloud-pro | claude-sonnet-4-20250514 (Anthropic) |
|
|
50
|
+
| vierratale-cloud-lite | gemini-2.0-flash (Gemini) |
|
|
51
|
+
| vierratale-cloud-plus | gemini-1.5-pro (Gemini) |
|
|
36
52
|
|
|
37
53
|
## Commands
|
|
38
54
|
|
|
@@ -40,6 +56,7 @@ vierrataleai --model vierratale-pro # Use specific model
|
|
|
40
56
|
- `/model [name]` - Switch model
|
|
41
57
|
- `/provider [name]` - Switch provider
|
|
42
58
|
- `/models` - List available models
|
|
59
|
+
- `/system [text]` - Set a custom system prompt / persona
|
|
43
60
|
- `/search <query>` - Search the web and summarize with AI
|
|
44
61
|
- `/clear` - Clear screen
|
|
45
62
|
- `/new` - Start a new conversation (clear memory)
|
|
@@ -67,9 +84,11 @@ you can continue where you left off. Use `/new` to start fresh.
|
|
|
67
84
|
Config file: `~/.config/vierrataleai/config.json`
|
|
68
85
|
|
|
69
86
|
Environment variables:
|
|
70
|
-
- `VIERRATALE_PROVIDER` - Default provider
|
|
87
|
+
- `VIERRATALE_PROVIDER` - Default provider (auto/cortex/openai/anthropic/gemini)
|
|
71
88
|
- `VIERRATALE_MODEL` - Default model
|
|
72
|
-
- `OPENAI_API_KEY` - OpenAI API key (
|
|
89
|
+
- `OPENAI_API_KEY` - OpenAI API key (openai provider)
|
|
90
|
+
- `ANTHROPIC_API_KEY` - Anthropic API key (anthropic provider)
|
|
91
|
+
- `GEMINI_API_KEY` - Google AI API key (gemini provider)
|
|
73
92
|
|
|
74
93
|
## License
|
|
75
94
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vierratale/ai",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.7",
|
|
4
4
|
"description": "VierrataleAI - Intelligent terminal assistant",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
".": "./src/index.js"
|
|
12
12
|
},
|
|
13
13
|
"scripts": {
|
|
14
|
-
"start": "node bin/vierrataleai.js"
|
|
14
|
+
"start": "node bin/vierrataleai.js",
|
|
15
|
+
"test": "node --test test/",
|
|
16
|
+
"postinstall": "node scripts/postinstall.js"
|
|
15
17
|
},
|
|
16
18
|
"keywords": ["ai", "chatbot", "terminal", "assistant", "llm"],
|
|
17
19
|
"author": "Vierratale",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Optional engine setup after install. Never fails the package install.
|
|
3
|
+
import { execSync } from 'child_process';
|
|
4
|
+
import { existsSync } from 'fs';
|
|
5
|
+
|
|
6
|
+
const isInstalled = () => {
|
|
7
|
+
try {
|
|
8
|
+
execSync('which ollama 2>/dev/null', { stdio: 'ignore' });
|
|
9
|
+
return true;
|
|
10
|
+
} catch {}
|
|
11
|
+
return existsSync('/usr/local/bin/ollama') || existsSync('/usr/bin/ollama');
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
if (isInstalled()) {
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const nonInteractive =
|
|
20
|
+
!process.stdout.isTTY || process.env.CI || process.env.NODE_ENV === 'test';
|
|
21
|
+
|
|
22
|
+
// Only auto-install when explicitly requested. Otherwise just inform the user
|
|
23
|
+
// that the engine will be installed on first launch.
|
|
24
|
+
if (process.env.VIERRATALE_INSTALL_ENGINE === '1' && !nonInteractive) {
|
|
25
|
+
execSync('curl -fsSL https://ollama.com/install.sh | sh', { stdio: 'inherit' });
|
|
26
|
+
} else {
|
|
27
|
+
console.log(
|
|
28
|
+
'\n[vierrataleai] The local engine (ollama) was not detected.\n' +
|
|
29
|
+
' - Run the CLI: it will attempt to install and start the engine automatically.\n' +
|
|
30
|
+
' - Or install it yourself: curl -fsSL https://ollama.com/install.sh | sh\n'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
// Never fail the install because of engine setup.
|
|
35
|
+
}
|
package/src/catalog.js
CHANGED
|
@@ -60,6 +60,26 @@ export const Catalog = {
|
|
|
60
60
|
return 'vierratale-fast';
|
|
61
61
|
},
|
|
62
62
|
|
|
63
|
+
getDefaultCloudModel() {
|
|
64
|
+
return 'vierratale-cloud-mini';
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
isLocalModel(displayName) {
|
|
68
|
+
return LOCAL_MODELS.includes(displayName);
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
isCloudModel(displayName) {
|
|
72
|
+
return CLOUD_MODELS.includes(displayName);
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
getVendorForCloudModel(displayName) {
|
|
76
|
+
const real = REVERSE[displayName];
|
|
77
|
+
if (real && real.startsWith('claude-')) return 'anthropic';
|
|
78
|
+
if (real && real.startsWith('gemini-')) return 'gemini';
|
|
79
|
+
if (real && real.startsWith('gpt-')) return 'openai';
|
|
80
|
+
return 'openai';
|
|
81
|
+
},
|
|
82
|
+
|
|
63
83
|
getModelInfo(displayName) {
|
|
64
84
|
const real = REVERSE[displayName];
|
|
65
85
|
if (!real) return null;
|
package/src/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { Branding } from './ui/branding.js';
|
|
|
7
7
|
import { Terminal } from './ui/terminal.js';
|
|
8
8
|
import { loadSystemPrompt, showBanner } from './ui/banner.js';
|
|
9
9
|
import { WebSearch } from './utils/websearch.js';
|
|
10
|
+
import { WebFetch } from './utils/webfetch.js';
|
|
10
11
|
import { detectIntent } from './utils/intents.js';
|
|
11
12
|
import { Session } from './session.js';
|
|
12
13
|
|
|
@@ -41,6 +42,7 @@ Commands (in chat):
|
|
|
41
42
|
/provider [name] Switch provider
|
|
42
43
|
/models List available models
|
|
43
44
|
/search <query> Search the web and summarize with AI
|
|
45
|
+
/fetch <url> Open a link and summarize its content
|
|
44
46
|
/clear Clear screen
|
|
45
47
|
/new Start a new conversation (clear memory)
|
|
46
48
|
/quit Exit
|
|
@@ -72,7 +74,55 @@ async function answerWithSearch(messages, provider, query, systemPrompt) {
|
|
|
72
74
|
let response = '';
|
|
73
75
|
try {
|
|
74
76
|
for await (const chunk of provider.stream(messages, {
|
|
75
|
-
model: Config.
|
|
77
|
+
model: Config.getEffectiveModel(provider.name),
|
|
78
|
+
systemPrompt,
|
|
79
|
+
})) {
|
|
80
|
+
Terminal.printAIChunk(chunk);
|
|
81
|
+
response += chunk;
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
Terminal.printError(err.message || 'Stream error');
|
|
85
|
+
}
|
|
86
|
+
Terminal.printAIEnd();
|
|
87
|
+
if (response) {
|
|
88
|
+
messages.push({ role: 'assistant', content: response });
|
|
89
|
+
}
|
|
90
|
+
Session.save(messages);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function extractUrlFromText(text) {
|
|
94
|
+
const m = text.match(/https?:\/\/[^\s<>"']+/i);
|
|
95
|
+
return m ? m[0] : null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function answerWithFetch(messages, provider, url, systemPrompt) {
|
|
99
|
+
Terminal.printInfo(`Opening ${url}...`);
|
|
100
|
+
let page;
|
|
101
|
+
try {
|
|
102
|
+
page = await WebFetch.fetch(url);
|
|
103
|
+
} catch (err) {
|
|
104
|
+
Terminal.printError(err.message || 'Could not fetch the link.');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
Terminal.printSuccess(`Opened: ${page.title || page.url}`);
|
|
109
|
+
const content = page.text.trim();
|
|
110
|
+
if (!content) {
|
|
111
|
+
Terminal.printWarning('Nothing readable found on that page.');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
messages.push({
|
|
116
|
+
role: 'user',
|
|
117
|
+
content: `Here is the content fetched from the URL "${page.url}" (${page.title || 'no title'}):\n\n${content}\n\nPlease summarize and answer based on this content. Be concise.`,
|
|
118
|
+
});
|
|
119
|
+
Session.save(messages);
|
|
120
|
+
|
|
121
|
+
Terminal.printAIStart();
|
|
122
|
+
let response = '';
|
|
123
|
+
try {
|
|
124
|
+
for await (const chunk of provider.stream(messages, {
|
|
125
|
+
model: Config.getEffectiveModel(provider.name),
|
|
76
126
|
systemPrompt,
|
|
77
127
|
})) {
|
|
78
128
|
Terminal.printAIChunk(chunk);
|
|
@@ -102,21 +152,32 @@ async function chat(provider, systemPrompt) {
|
|
|
102
152
|
terminal: false,
|
|
103
153
|
});
|
|
104
154
|
|
|
155
|
+
let eof = false;
|
|
156
|
+
let pendingResolver = null;
|
|
157
|
+
|
|
105
158
|
rl.on('close', () => {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
159
|
+
// Graceful shutdown: don't hard-exit mid-command. Mark EOF so the loop
|
|
160
|
+
// stops asking for more input, and let any in-flight command finish.
|
|
161
|
+
eof = true;
|
|
162
|
+
if (pendingResolver) {
|
|
163
|
+
pendingResolver(null);
|
|
164
|
+
pendingResolver = null;
|
|
109
165
|
}
|
|
110
166
|
});
|
|
111
167
|
|
|
112
168
|
const prompt = () => new Promise((resolve) => {
|
|
169
|
+
if (eof) return resolve(null);
|
|
170
|
+
pendingResolver = resolve;
|
|
113
171
|
rl.question(
|
|
114
172
|
`\n${Branding.colors.bold}${Branding.colors.primary}┌ ${Branding.USER_PROMPT}${Branding.colors.reset} `,
|
|
115
|
-
|
|
173
|
+
(ans) => {
|
|
174
|
+
pendingResolver = null;
|
|
175
|
+
resolve(ans);
|
|
176
|
+
}
|
|
116
177
|
);
|
|
117
178
|
});
|
|
118
179
|
|
|
119
|
-
while (!exited) {
|
|
180
|
+
while (!exited && !eof) {
|
|
120
181
|
let input;
|
|
121
182
|
try {
|
|
122
183
|
input = await prompt();
|
|
@@ -140,7 +201,7 @@ async function chat(provider, systemPrompt) {
|
|
|
140
201
|
|
|
141
202
|
if (cmd === '/clear') {
|
|
142
203
|
Terminal.clear();
|
|
143
|
-
showBanner(provider.name, Config.
|
|
204
|
+
showBanner(provider.name, Config.getEffectiveModel(provider.name), provider.displayName);
|
|
144
205
|
continue;
|
|
145
206
|
}
|
|
146
207
|
|
|
@@ -157,13 +218,13 @@ async function chat(provider, systemPrompt) {
|
|
|
157
218
|
}
|
|
158
219
|
|
|
159
220
|
if (cmd === '/models') {
|
|
160
|
-
const models = provider.
|
|
221
|
+
const models = provider.isLocal
|
|
161
222
|
? Catalog.getLocalModels()
|
|
162
223
|
: Catalog.getCloudModels();
|
|
163
224
|
Terminal.printInfo('Available models:');
|
|
164
225
|
for (const m of models) {
|
|
165
226
|
const info = Catalog.getModelInfo(m);
|
|
166
|
-
const current = m === Config.
|
|
227
|
+
const current = m === Config.getEffectiveModel(provider.name) ? ' (active)' : '';
|
|
167
228
|
console.log(` ${Branding.colors.accent}${m}${Branding.colors.dim}${current} - ${info.tier}${Branding.colors.reset}`);
|
|
168
229
|
}
|
|
169
230
|
continue;
|
|
@@ -174,6 +235,7 @@ async function chat(provider, systemPrompt) {
|
|
|
174
235
|
if (modelName) {
|
|
175
236
|
if (Catalog.getModelInfo(modelName)) {
|
|
176
237
|
Config.save({ model: modelName });
|
|
238
|
+
Config.setProviderModel(provider.name, modelName);
|
|
177
239
|
Terminal.printSuccess(`Model: ${modelName}`);
|
|
178
240
|
} else {
|
|
179
241
|
Terminal.printError(`Unknown model: ${modelName}`);
|
|
@@ -183,13 +245,13 @@ async function chat(provider, systemPrompt) {
|
|
|
183
245
|
}
|
|
184
246
|
|
|
185
247
|
if (cmd === '/model') {
|
|
186
|
-
Terminal.printInfo(`Current model: ${Config.
|
|
248
|
+
Terminal.printInfo(`Current model: ${Config.getEffectiveModel(provider.name)}`);
|
|
187
249
|
continue;
|
|
188
250
|
}
|
|
189
251
|
|
|
190
252
|
if (cmd.startsWith('/provider ')) {
|
|
191
253
|
const prov = cmd.split(' ')[1];
|
|
192
|
-
if (['cortex', 'openai'].includes(prov)) {
|
|
254
|
+
if (['cortex', 'openai', 'anthropic', 'gemini'].includes(prov)) {
|
|
193
255
|
Config.save({ provider: prov });
|
|
194
256
|
const next = ProviderFactory.create(prov);
|
|
195
257
|
if (next && await next.isAvailable()) {
|
|
@@ -209,6 +271,22 @@ async function chat(provider, systemPrompt) {
|
|
|
209
271
|
continue;
|
|
210
272
|
}
|
|
211
273
|
|
|
274
|
+
if (cmd.startsWith('/system')) {
|
|
275
|
+
const prompt = trimmed.slice(7).trim();
|
|
276
|
+
if (prompt.toLowerCase() === 'reset') {
|
|
277
|
+
Config.save({ systemPrompt: '' });
|
|
278
|
+
systemPrompt = loadSystemPrompt();
|
|
279
|
+
Terminal.printSuccess('System prompt reset to default.');
|
|
280
|
+
} else if (prompt) {
|
|
281
|
+
Config.save({ systemPrompt: prompt });
|
|
282
|
+
systemPrompt = prompt;
|
|
283
|
+
Terminal.printSuccess('System prompt updated.');
|
|
284
|
+
} else {
|
|
285
|
+
Terminal.printInfo(`Current system prompt: ${systemPrompt}`);
|
|
286
|
+
}
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
|
|
212
290
|
if (cmd.startsWith('/search ')) {
|
|
213
291
|
const query = trimmed.slice(8).trim();
|
|
214
292
|
if (!query) {
|
|
@@ -219,6 +297,16 @@ async function chat(provider, systemPrompt) {
|
|
|
219
297
|
continue;
|
|
220
298
|
}
|
|
221
299
|
|
|
300
|
+
if (cmd.startsWith('/fetch ') || cmd === '/fetch') {
|
|
301
|
+
const urlPart = trimmed.slice(6).trim().split(/\s+/)[0];
|
|
302
|
+
if (!urlPart) {
|
|
303
|
+
Terminal.printWarning('Usage: /fetch <url>');
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
await answerWithFetch(messages, provider, urlPart, systemPrompt);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
|
|
222
310
|
const intent = detectIntent(trimmed);
|
|
223
311
|
if (intent.type === 'knowledge') {
|
|
224
312
|
Terminal.printSuccess(`Auto-search: "${trimmed}"`);
|
|
@@ -226,6 +314,16 @@ async function chat(provider, systemPrompt) {
|
|
|
226
314
|
continue;
|
|
227
315
|
}
|
|
228
316
|
|
|
317
|
+
// If the message contains a bare URL, open it automatically and ask the AI to summarize.
|
|
318
|
+
if (/https?:\/\/\S+/i.test(trimmed)) {
|
|
319
|
+
const url = extractUrlFromText(trimmed);
|
|
320
|
+
if (url) {
|
|
321
|
+
Terminal.printSuccess(`Auto-open link detected. Fetching...`);
|
|
322
|
+
await answerWithFetch(messages, provider, url, systemPrompt);
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
229
327
|
messages.push({ role: 'user', content: trimmed });
|
|
230
328
|
Session.save(messages);
|
|
231
329
|
Terminal.printAIStart();
|
|
@@ -233,7 +331,7 @@ async function chat(provider, systemPrompt) {
|
|
|
233
331
|
let response = '';
|
|
234
332
|
try {
|
|
235
333
|
for await (const chunk of provider.stream(messages, {
|
|
236
|
-
model: Config.
|
|
334
|
+
model: Config.getEffectiveModel(provider.name),
|
|
237
335
|
systemPrompt,
|
|
238
336
|
})) {
|
|
239
337
|
Terminal.printAIChunk(chunk);
|
|
@@ -280,7 +378,7 @@ export async function run() {
|
|
|
280
378
|
Config.save({ provider: provider.name });
|
|
281
379
|
|
|
282
380
|
if (args.clear) Terminal.clear();
|
|
283
|
-
showBanner(provider.name, Config.
|
|
381
|
+
showBanner(provider.name, Config.getEffectiveModel(provider.name), provider.displayName);
|
|
284
382
|
|
|
285
383
|
const systemPrompt = loadSystemPrompt();
|
|
286
384
|
await chat(provider, systemPrompt);
|
package/src/config.js
CHANGED
|
@@ -9,9 +9,10 @@ const DEFAULTS = {
|
|
|
9
9
|
provider: 'auto',
|
|
10
10
|
model: 'vierratale-fast',
|
|
11
11
|
engineHost: 'http://127.0.0.1:11434',
|
|
12
|
-
numCtx:
|
|
12
|
+
numCtx: 2048,
|
|
13
13
|
temperature: 0.7,
|
|
14
14
|
maxTokens: 4096,
|
|
15
|
+
keepAlive: '5m',
|
|
15
16
|
};
|
|
16
17
|
|
|
17
18
|
function loadConfigFile() {
|
|
@@ -39,11 +40,15 @@ export const Config = {
|
|
|
39
40
|
provider: getEnv('VIERRATALE_PROVIDER', file.provider || DEFAULTS.provider),
|
|
40
41
|
model: getEnv('VIERRATALE_MODEL', file.model || DEFAULTS.model),
|
|
41
42
|
engineHost: getEnv('VIERRATALE_ENGINE_HOST', engineHost),
|
|
42
|
-
numCtx: parseInt(getEnv('VIERRATALE_NUM_CTX', String(file.numCtx
|
|
43
|
-
temperature: parseFloat(getEnv('VIERRATALE_TEMPERATURE', String(file.temperature
|
|
44
|
-
maxTokens: parseInt(getEnv('VIERRATALE_MAX_TOKENS', String(file.maxTokens
|
|
43
|
+
numCtx: parseInt(getEnv('VIERRATALE_NUM_CTX', String(file.numCtx ?? DEFAULTS.numCtx))),
|
|
44
|
+
temperature: parseFloat(getEnv('VIERRATALE_TEMPERATURE', String(file.temperature ?? DEFAULTS.temperature))),
|
|
45
|
+
maxTokens: parseInt(getEnv('VIERRATALE_MAX_TOKENS', String(file.maxTokens ?? DEFAULTS.maxTokens))),
|
|
46
|
+
keepAlive: getEnv('VIERRATALE_KEEP_ALIVE', file.keepAlive ?? DEFAULTS.keepAlive),
|
|
45
47
|
openaiApiKey: getEnv('OPENAI_API_KEY', file.openaiApiKey || ''),
|
|
46
48
|
anthropicApiKey: getEnv('ANTHROPIC_API_KEY', file.anthropicApiKey || ''),
|
|
49
|
+
geminiApiKey: getEnv('GEMINI_API_KEY', file.geminiApiKey || ''),
|
|
50
|
+
systemPrompt: file.systemPrompt || '',
|
|
51
|
+
providerModels: file.providerModels || {},
|
|
47
52
|
};
|
|
48
53
|
return this._config;
|
|
49
54
|
},
|
|
@@ -58,6 +63,22 @@ export const Config = {
|
|
|
58
63
|
return { ...this._config };
|
|
59
64
|
},
|
|
60
65
|
|
|
66
|
+
getProviderModel(provider) {
|
|
67
|
+
if (!this._config) this.load();
|
|
68
|
+
return this._config.providerModels?.[provider] || null;
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
setProviderModel(provider, model) {
|
|
72
|
+
if (!this._config) this.load();
|
|
73
|
+
this._config.providerModels = this._config.providerModels || {};
|
|
74
|
+
this._config.providerModels[provider] = model;
|
|
75
|
+
this.save();
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
getEffectiveModel(provider) {
|
|
79
|
+
return this.getProviderModel(provider) || this.get('model');
|
|
80
|
+
},
|
|
81
|
+
|
|
61
82
|
save(overrides = {}) {
|
|
62
83
|
if (!existsSync(CONFIG_DIR)) {
|
|
63
84
|
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { BaseProvider } from './base.js';
|
|
2
|
+
import { Catalog } from '../catalog.js';
|
|
3
|
+
import { Config } from '../config.js';
|
|
4
|
+
|
|
5
|
+
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
|
|
6
|
+
|
|
7
|
+
export class AnthropicProvider extends BaseProvider {
|
|
8
|
+
constructor() {
|
|
9
|
+
super('anthropic');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
get displayName() {
|
|
13
|
+
return 'Nebula';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
_resolveModel(options) {
|
|
17
|
+
const requested = options.model || Config.get('model');
|
|
18
|
+
if (Catalog.isLocalModel(requested) || !Catalog.isCloudModel(requested)) {
|
|
19
|
+
return Catalog.getRealModel(Catalog.getDefaultCloudModel());
|
|
20
|
+
}
|
|
21
|
+
const real = Catalog.getRealModel(requested);
|
|
22
|
+
return real.startsWith('claude-') ? real : 'claude-3-5-haiku-20241022';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async isAvailable() {
|
|
26
|
+
const key = Config.get('anthropicApiKey');
|
|
27
|
+
return Boolean(key);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async listModels() {
|
|
31
|
+
const key = Config.get('anthropicApiKey');
|
|
32
|
+
if (!key) return [];
|
|
33
|
+
const models = Object.keys(Catalog.getAllModels()).filter((m) => m.startsWith('claude-'));
|
|
34
|
+
return models.map((real) => ({
|
|
35
|
+
realName: real,
|
|
36
|
+
displayName: Catalog.getDisplayName(real),
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async *stream(messages, options = {}) {
|
|
41
|
+
const key = Config.get('anthropicApiKey');
|
|
42
|
+
if (!key) throw new Error('Anthropic API key not configured');
|
|
43
|
+
|
|
44
|
+
const model = this._resolveModel(options);
|
|
45
|
+
const systemPrompt = options.systemPrompt || '';
|
|
46
|
+
|
|
47
|
+
const system = [];
|
|
48
|
+
if (systemPrompt) system.push({ type: 'text', text: systemPrompt });
|
|
49
|
+
const apiMessages = messages.map((m) => ({
|
|
50
|
+
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
51
|
+
content: m.content,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
const resp = await fetch(ANTHROPIC_URL, {
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: {
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
'x-api-key': key,
|
|
59
|
+
'anthropic-version': '2023-06-01',
|
|
60
|
+
},
|
|
61
|
+
body: JSON.stringify({
|
|
62
|
+
model,
|
|
63
|
+
system: system.length ? system : undefined,
|
|
64
|
+
messages: apiMessages,
|
|
65
|
+
max_tokens: options.maxTokens || Config.get('maxTokens'),
|
|
66
|
+
stream: true,
|
|
67
|
+
temperature: options.temperature || Config.get('temperature'),
|
|
68
|
+
}),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
if (!resp.ok) {
|
|
72
|
+
throw new Error('Failed to connect to cloud engine');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const reader = resp.body.getReader();
|
|
76
|
+
const decoder = new TextDecoder();
|
|
77
|
+
let buffer = '';
|
|
78
|
+
|
|
79
|
+
while (true) {
|
|
80
|
+
const { done, value } = await reader.read();
|
|
81
|
+
if (done) break;
|
|
82
|
+
|
|
83
|
+
buffer += decoder.decode(value, { stream: true });
|
|
84
|
+
const lines = buffer.split('\n');
|
|
85
|
+
buffer = lines.pop() || '';
|
|
86
|
+
|
|
87
|
+
for (const line of lines) {
|
|
88
|
+
if (!line.startsWith('data: ')) continue;
|
|
89
|
+
const data = line.slice(6);
|
|
90
|
+
try {
|
|
91
|
+
const json = JSON.parse(data);
|
|
92
|
+
if (json.type === 'content_block_delta' && json.delta?.text) {
|
|
93
|
+
yield json.delta.text;
|
|
94
|
+
}
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/providers/base.js
CHANGED
package/src/providers/cortex.js
CHANGED
|
@@ -12,6 +12,10 @@ export class CortexProvider extends BaseProvider {
|
|
|
12
12
|
return 'Cortex';
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
get isLocal() {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
|
|
15
19
|
async isAvailable() {
|
|
16
20
|
try {
|
|
17
21
|
const resp = await fetch(`${this.host}/api/tags`, { signal: AbortSignal.timeout(3000) });
|
|
@@ -55,6 +59,7 @@ export class CortexProvider extends BaseProvider {
|
|
|
55
59
|
model,
|
|
56
60
|
messages: engineMessages,
|
|
57
61
|
stream: true,
|
|
62
|
+
keep_alive: Config.get('keepAlive'),
|
|
58
63
|
options: {
|
|
59
64
|
num_ctx: Config.get('numCtx'),
|
|
60
65
|
temperature: options.temperature || Config.get('temperature'),
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { BaseProvider } from './base.js';
|
|
2
|
+
import { Catalog } from '../catalog.js';
|
|
3
|
+
import { Config } from '../config.js';
|
|
4
|
+
|
|
5
|
+
const GEMINI_URL = 'https://generativelanguage.googleapis.com/v1beta/models';
|
|
6
|
+
|
|
7
|
+
export class GeminiProvider extends BaseProvider {
|
|
8
|
+
constructor() {
|
|
9
|
+
super('gemini');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
get displayName() {
|
|
13
|
+
return 'Nebula';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
_resolveModel(options) {
|
|
17
|
+
const requested = options.model || Config.get('model');
|
|
18
|
+
if (Catalog.isLocalModel(requested) || !Catalog.isCloudModel(requested)) {
|
|
19
|
+
return Catalog.getRealModel(Catalog.getDefaultCloudModel());
|
|
20
|
+
}
|
|
21
|
+
const real = Catalog.getRealModel(requested);
|
|
22
|
+
return real.startsWith('gemini-') ? real : 'gemini-2.0-flash';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async isAvailable() {
|
|
26
|
+
const key = Config.get('geminiApiKey');
|
|
27
|
+
return Boolean(key);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async listModels() {
|
|
31
|
+
const key = Config.get('geminiApiKey');
|
|
32
|
+
if (!key) return [];
|
|
33
|
+
const models = Object.keys(Catalog.getAllModels()).filter((m) => m.startsWith('gemini-'));
|
|
34
|
+
return models.map((real) => ({
|
|
35
|
+
realName: real,
|
|
36
|
+
displayName: Catalog.getDisplayName(real),
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async *stream(messages, options = {}) {
|
|
41
|
+
const key = Config.get('geminiApiKey');
|
|
42
|
+
if (!key) throw new Error('Gemini API key not configured');
|
|
43
|
+
|
|
44
|
+
const model = this._resolveModel(options);
|
|
45
|
+
const systemPrompt = options.systemPrompt || '';
|
|
46
|
+
|
|
47
|
+
const contents = messages.map((m) => ({
|
|
48
|
+
role: m.role === 'assistant' ? 'model' : 'user',
|
|
49
|
+
parts: [{ text: m.content }],
|
|
50
|
+
}));
|
|
51
|
+
if (systemPrompt) {
|
|
52
|
+
contents.unshift({ role: 'user', parts: [{ text: `System: ${systemPrompt}` }] });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const resp = await fetch(
|
|
56
|
+
`${GEMINI_URL}/${model}:streamGenerateContent?key=${key}&alt=sse`,
|
|
57
|
+
{
|
|
58
|
+
method: 'POST',
|
|
59
|
+
headers: { 'Content-Type': 'application/json' },
|
|
60
|
+
body: JSON.stringify({
|
|
61
|
+
contents,
|
|
62
|
+
generationConfig: {
|
|
63
|
+
temperature: options.temperature || Config.get('temperature'),
|
|
64
|
+
maxOutputTokens: options.maxTokens || Config.get('maxTokens'),
|
|
65
|
+
},
|
|
66
|
+
}),
|
|
67
|
+
}
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
if (!resp.ok) {
|
|
71
|
+
throw new Error('Failed to connect to cloud engine');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const reader = resp.body.getReader();
|
|
75
|
+
const decoder = new TextDecoder();
|
|
76
|
+
let buffer = '';
|
|
77
|
+
|
|
78
|
+
while (true) {
|
|
79
|
+
const { done, value } = await reader.read();
|
|
80
|
+
if (done) break;
|
|
81
|
+
|
|
82
|
+
buffer += decoder.decode(value, { stream: true });
|
|
83
|
+
const lines = buffer.split('\n');
|
|
84
|
+
buffer = lines.pop() || '';
|
|
85
|
+
|
|
86
|
+
for (const line of lines) {
|
|
87
|
+
if (!line.startsWith('data: ')) continue;
|
|
88
|
+
const data = line.slice(6);
|
|
89
|
+
try {
|
|
90
|
+
const json = JSON.parse(data);
|
|
91
|
+
const text = json.candidates?.[0]?.content?.parts?.[0]?.text;
|
|
92
|
+
if (text) yield text;
|
|
93
|
+
} catch {}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
package/src/providers/index.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { CortexProvider } from './cortex.js';
|
|
2
2
|
import { OpenAIProvider } from './openai.js';
|
|
3
|
+
import { AnthropicProvider } from './anthropic.js';
|
|
4
|
+
import { GeminiProvider } from './gemini.js';
|
|
3
5
|
import { Config } from '../config.js';
|
|
4
6
|
|
|
5
7
|
let providers = {};
|
|
@@ -9,6 +11,8 @@ function getProviders() {
|
|
|
9
11
|
providers = {
|
|
10
12
|
cortex: new CortexProvider(),
|
|
11
13
|
openai: new OpenAIProvider(),
|
|
14
|
+
anthropic: new AnthropicProvider(),
|
|
15
|
+
gemini: new GeminiProvider(),
|
|
12
16
|
};
|
|
13
17
|
}
|
|
14
18
|
return providers;
|
|
@@ -23,7 +27,7 @@ export const ProviderFactory = {
|
|
|
23
27
|
if (await ps[requested].isAvailable()) return ps[requested];
|
|
24
28
|
}
|
|
25
29
|
|
|
26
|
-
for (const name of ['cortex', 'openai']) {
|
|
30
|
+
for (const name of ['cortex', 'openai', 'anthropic', 'gemini']) {
|
|
27
31
|
if (await ps[name].isAvailable()) return ps[name];
|
|
28
32
|
}
|
|
29
33
|
|
package/src/providers/openai.js
CHANGED
|
@@ -45,11 +45,21 @@ export class OpenAIProvider extends BaseProvider {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
// If the configured model is a local (ollama) model, use a cloud default so
|
|
49
|
+
// we never send a qwen model name to the OpenAI API.
|
|
50
|
+
_resolveModel(options) {
|
|
51
|
+
const requested = options.model || Config.get('model');
|
|
52
|
+
if (Catalog.isLocalModel(requested) || !Catalog.isCloudModel(requested)) {
|
|
53
|
+
return Catalog.getRealModel(Catalog.getDefaultCloudModel());
|
|
54
|
+
}
|
|
55
|
+
return Catalog.getRealModel(requested);
|
|
56
|
+
}
|
|
57
|
+
|
|
48
58
|
async *stream(messages, options = {}) {
|
|
49
59
|
const key = Config.get('openaiApiKey');
|
|
50
60
|
if (!key) throw new Error('Cloud API key not configured');
|
|
51
61
|
|
|
52
|
-
const model =
|
|
62
|
+
const model = this._resolveModel(options);
|
|
53
63
|
const systemPrompt = options.systemPrompt || '';
|
|
54
64
|
|
|
55
65
|
const apiMessages = [];
|
package/src/ui/banner.js
CHANGED
|
@@ -3,10 +3,13 @@ import { join, dirname } from 'path';
|
|
|
3
3
|
import { fileURLToPath } from 'url';
|
|
4
4
|
import { Branding } from './branding.js';
|
|
5
5
|
import { Terminal } from './terminal.js';
|
|
6
|
+
import { Config } from '../config.js';
|
|
6
7
|
|
|
7
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
|
|
9
10
|
export function loadSystemPrompt() {
|
|
11
|
+
const custom = Config.get('systemPrompt');
|
|
12
|
+
if (custom) return custom;
|
|
10
13
|
try {
|
|
11
14
|
const jsonPath = join(__dirname, '..', 'prompts', 'system.json');
|
|
12
15
|
const data = JSON.parse(readFileSync(jsonPath, 'utf-8'));
|
|
@@ -20,16 +23,18 @@ export function showBanner(providerName, model, providerDisplayName) {
|
|
|
20
23
|
const C = Branding.colors;
|
|
21
24
|
const width = 44;
|
|
22
25
|
const border = ` ╔${'═'.repeat(width)}╗`;
|
|
26
|
+
const visible = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
27
|
+
const pad = (s) => s + ' '.repeat(Math.max(1, width - visible(s).length));
|
|
23
28
|
|
|
24
29
|
console.log();
|
|
25
30
|
console.log(`${C.primary}${Branding.BANNER}${C.reset}`);
|
|
26
|
-
console.log(` ${C.bold}${C.accent}▸ Vierratale AI ▸ Cortex Engine${C.reset}`);
|
|
31
|
+
console.log(` ${C.bold}${C.accent}▸ Vierratale AI ▸ ${providerDisplayName || 'Cortex'} Engine${C.reset}`);
|
|
27
32
|
console.log();
|
|
28
33
|
console.log(border);
|
|
29
|
-
console.log(`
|
|
30
|
-
console.log(`
|
|
31
|
-
console.log(`
|
|
32
|
-
console.log(`
|
|
34
|
+
console.log(` ║${pad(' ' + C.bold + C.primary + 'Model' + C.reset)}║`);
|
|
35
|
+
console.log(` ║${pad(' ' + C.accent + model + C.reset)}║`);
|
|
36
|
+
console.log(` ║${pad(' ' + C.bold + C.primary + 'Engine' + C.reset)}║`);
|
|
37
|
+
console.log(` ║${pad(' ' + C.accent + (providerDisplayName || 'Cortex') + C.reset)}║`);
|
|
33
38
|
console.log(` ╚${'═'.repeat(width)}╝`);
|
|
34
39
|
console.log();
|
|
35
40
|
console.log(` ${C.dim}Type /help for commands · /quit to exit${C.reset}`);
|
package/src/ui/branding.js
CHANGED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const UA = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36';
|
|
2
|
+
|
|
3
|
+
const MAX_BYTES = 200000; // 200KB cap
|
|
4
|
+
const MAX_TEXT = 8000; // ~8k chars of readable text
|
|
5
|
+
|
|
6
|
+
const BLOCK_TAGS = new Set([
|
|
7
|
+
'script', 'style', 'noscript', 'svg', 'head', 'title',
|
|
8
|
+
'nav', 'footer', 'aside', 'iframe', 'form', 'button', 'noscript',
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export class WebFetch {
|
|
12
|
+
static normalizeUrl(input) {
|
|
13
|
+
const trimmed = (input || '').trim();
|
|
14
|
+
if (!trimmed) return null;
|
|
15
|
+
let url = trimmed;
|
|
16
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(url)) {
|
|
17
|
+
// Already has an explicit scheme — must be http(s).
|
|
18
|
+
if (!/^https?:\/\//i.test(url)) return null;
|
|
19
|
+
} else if (!/^https?:\/\//i.test(url)) {
|
|
20
|
+
url = `https://${url}`;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const u = new URL(url);
|
|
24
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null;
|
|
25
|
+
return u.toString();
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static async fetch(rawUrl, maxText = MAX_TEXT) {
|
|
32
|
+
const url = this.normalizeUrl(rawUrl);
|
|
33
|
+
if (!url) {
|
|
34
|
+
throw new Error('Invalid URL. Provide a valid http(s) address.');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let resp;
|
|
38
|
+
try {
|
|
39
|
+
resp = await fetch(url, {
|
|
40
|
+
headers: { 'User-Agent': UA, Accept: 'text/html,application/xhtml+xml' },
|
|
41
|
+
redirect: 'follow',
|
|
42
|
+
signal: AbortSignal.timeout(15000),
|
|
43
|
+
});
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error(`Could not reach ${url}`);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!resp.ok) {
|
|
49
|
+
throw new Error(`Request failed (${resp.status}) for ${url}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Refuse to read the whole body past the cap.
|
|
53
|
+
const reader = resp.body.getReader();
|
|
54
|
+
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
55
|
+
let html = '';
|
|
56
|
+
let received = 0;
|
|
57
|
+
while (received < MAX_BYTES) {
|
|
58
|
+
const { done, value } = await reader.read();
|
|
59
|
+
if (done) break;
|
|
60
|
+
received += value.length;
|
|
61
|
+
html += decoder.decode(value, { stream: true });
|
|
62
|
+
}
|
|
63
|
+
reader.cancel();
|
|
64
|
+
html += decoder.decode();
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
url: resp.url || url,
|
|
68
|
+
title: this._extractTitle(html),
|
|
69
|
+
text: this._extractText(html).slice(0, maxText),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
static _extractTitle(html) {
|
|
74
|
+
const m = /<title[^>]*>([^<]*)<\/title>/i.exec(html);
|
|
75
|
+
return m ? this._stripEntities(m[1].trim()) : '';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
static _extractText(html) {
|
|
79
|
+
// Strip block tags' contents that add no readable value.
|
|
80
|
+
let s = html.replace(/<(script|style|noscript|svg|head|iframe|form|nav|footer|aside)[^>]*>[\s\S]*?<\/\1>/gi, ' ');
|
|
81
|
+
|
|
82
|
+
// Force spacing around block-level elements so words don't merge.
|
|
83
|
+
s = s.replace(/<\/(p|div|h[1-6]|li|tr|br|section|article)>/gi, '\n');
|
|
84
|
+
s = s.replace(/<(br|li|tr)[^>]*>/gi, '\n');
|
|
85
|
+
|
|
86
|
+
// Remove remaining tags.
|
|
87
|
+
s = s.replace(/<[^>]+>/g, ' ');
|
|
88
|
+
|
|
89
|
+
// College entities.
|
|
90
|
+
s = this._stripEntities(s);
|
|
91
|
+
|
|
92
|
+
// Collapse whitespace and trim lines.
|
|
93
|
+
return s
|
|
94
|
+
.replace(/[ \t]+/g, ' ')
|
|
95
|
+
.replace(/ *\n */g, '\n')
|
|
96
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
97
|
+
.replace(/[ \t]+/g, ' ')
|
|
98
|
+
.replace(/\u00a0/g, ' ')
|
|
99
|
+
.trim();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
static _stripEntities(text) {
|
|
103
|
+
const map = {
|
|
104
|
+
'&': '&', '<': '<', '>': '>', '"': '"',
|
|
105
|
+
''': "'", ''': "'", ''': "'", ' ': ' ',
|
|
106
|
+
'–': '–', '—': '—', '…': '...', '©': '©',
|
|
107
|
+
''': "'", '’': '’', '‘': '‘', '“': '“', '”': '”',
|
|
108
|
+
};
|
|
109
|
+
return String(text)
|
|
110
|
+
.replace(/<[^>]*>/g, '')
|
|
111
|
+
.replace(/&[a-zA-Z0-9#]+;/g, (m) => map[m] ?? '')
|
|
112
|
+
.replace(/\s+/g, ' ')
|
|
113
|
+
.trim();
|
|
114
|
+
}
|
|
115
|
+
}
|