krasavacode 0.3.5 → 0.4.0
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/bin/krasavacode.js +4 -4
- package/package.json +1 -1
- package/src/cooldowns.js +54 -0
- package/src/doctor.js +19 -0
- package/src/hub.js +7 -5
- package/src/launch.js +19 -20
- package/src/metrics-proxy.js +162 -75
- package/src/preset.js +35 -58
- package/src/providers.js +234 -0
- package/src/setup.js +409 -0
- package/src/setup-gemini.js +0 -427
package/src/providers.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider registry: единый источник правды для всех бесплатных провайдеров.
|
|
3
|
+
*
|
|
4
|
+
* Приоритет в chain (по убыванию щедрости free tier):
|
|
5
|
+
* 1. cerebras — 1M токенов/день, Llama 3.3 70B / Qwen3 235B
|
|
6
|
+
* 2. groq — 1000 RPD, Kimi K2 / DeepSeek-R1
|
|
7
|
+
* 3. gemini — 250 RPD, Gemini 2.5 Flash (фолбэк)
|
|
8
|
+
* 4. pollinations — без квоты, gpt-oss-20b (последний резерв)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { readFile, access } from 'node:fs/promises';
|
|
14
|
+
|
|
15
|
+
const ROOT = join(homedir(), '.krasavacode');
|
|
16
|
+
export const KEYS_DIR = join(ROOT, 'keys');
|
|
17
|
+
|
|
18
|
+
// Keys live in ~/.krasavacode/keys/<provider>.env as: PROVIDER_API_KEY=...
|
|
19
|
+
const ENV_VAR_NAMES = {
|
|
20
|
+
cerebras: 'CEREBRAS_API_KEY',
|
|
21
|
+
groq: 'GROQ_API_KEY',
|
|
22
|
+
gemini: 'GEMINI_API_KEY',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export const PROVIDERS = {
|
|
26
|
+
cerebras: {
|
|
27
|
+
id: 'cerebras',
|
|
28
|
+
name: 'Cerebras',
|
|
29
|
+
tagline: '1M токенов/день, скорость 2600 ток/сек',
|
|
30
|
+
consoleUrl: 'https://cloud.cerebras.ai/?utm_source=krasavacode',
|
|
31
|
+
keyPattern: /^csk-[A-Za-z0-9]{20,}$/,
|
|
32
|
+
keyExample: 'csk-…',
|
|
33
|
+
keyHowto: [
|
|
34
|
+
'Зарегистрируйся (Sign up) — бесплатно, без карты',
|
|
35
|
+
'В дашборде нажми «API Keys» в левом меню',
|
|
36
|
+
'Нажми «Create API Key» → введи любое название',
|
|
37
|
+
'Скопируй ключ (начинается с csk-)',
|
|
38
|
+
],
|
|
39
|
+
quota: '~1 000 000 токенов в день, 30 запросов/мин',
|
|
40
|
+
bestModel: 'Qwen 3 235B (Cerebras)',
|
|
41
|
+
rpd: null, // unlimited by RPD, only TPD-bound
|
|
42
|
+
tpd: 1_000_000,
|
|
43
|
+
rpm: 30,
|
|
44
|
+
contextLimit: 8_000,
|
|
45
|
+
// OpenAI-compatible API
|
|
46
|
+
verify: async (key) => {
|
|
47
|
+
const res = await fetch('https://api.cerebras.ai/v1/chat/completions', {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: {
|
|
50
|
+
'content-type': 'application/json',
|
|
51
|
+
'authorization': `Bearer ${key}`,
|
|
52
|
+
},
|
|
53
|
+
body: JSON.stringify({
|
|
54
|
+
model: 'llama-3.3-70b',
|
|
55
|
+
messages: [{ role: 'user', content: 'Reply: ok' }],
|
|
56
|
+
max_tokens: 5,
|
|
57
|
+
}),
|
|
58
|
+
signal: AbortSignal.timeout(15000),
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
let msg = `HTTP ${res.status}`;
|
|
62
|
+
try { msg = (await res.json())?.error?.message || msg; } catch {}
|
|
63
|
+
return { ok: false, error: msg };
|
|
64
|
+
}
|
|
65
|
+
const data = await res.json();
|
|
66
|
+
return { ok: true, text: data.choices?.[0]?.message?.content?.trim() || 'ok' };
|
|
67
|
+
},
|
|
68
|
+
ccrProvider: () => ({
|
|
69
|
+
name: 'cerebras',
|
|
70
|
+
api_base_url: 'https://api.cerebras.ai/v1/chat/completions',
|
|
71
|
+
api_key: '$CEREBRAS_API_KEY',
|
|
72
|
+
models: ['qwen-3-235b-a22b-instruct-2507', 'llama-3.3-70b', 'gpt-oss-120b'],
|
|
73
|
+
}),
|
|
74
|
+
defaultModel: 'qwen-3-235b-a22b-instruct-2507',
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
groq: {
|
|
78
|
+
id: 'groq',
|
|
79
|
+
name: 'Groq',
|
|
80
|
+
tagline: '1000 запросов/день, Kimi K2 + DeepSeek-R1',
|
|
81
|
+
consoleUrl: 'https://console.groq.com/keys',
|
|
82
|
+
keyPattern: /^gsk_[A-Za-z0-9]{40,}$/,
|
|
83
|
+
keyExample: 'gsk_…',
|
|
84
|
+
keyHowto: [
|
|
85
|
+
'Войди через Google или GitHub — без карты',
|
|
86
|
+
'Перейди в раздел «API Keys» (страница уже открыта)',
|
|
87
|
+
'Нажми «Create API Key» → введи название',
|
|
88
|
+
'Скопируй ключ (начинается с gsk_)',
|
|
89
|
+
],
|
|
90
|
+
quota: '~1 000 запросов в день, 30 запросов/мин',
|
|
91
|
+
bestModel: 'Kimi K2 (через Groq)',
|
|
92
|
+
rpd: 1000,
|
|
93
|
+
tpd: null,
|
|
94
|
+
rpm: 30,
|
|
95
|
+
contextLimit: 128_000,
|
|
96
|
+
verify: async (key) => {
|
|
97
|
+
const res = await fetch('https://api.groq.com/openai/v1/chat/completions', {
|
|
98
|
+
method: 'POST',
|
|
99
|
+
headers: {
|
|
100
|
+
'content-type': 'application/json',
|
|
101
|
+
'authorization': `Bearer ${key}`,
|
|
102
|
+
},
|
|
103
|
+
body: JSON.stringify({
|
|
104
|
+
model: 'llama-3.3-70b-versatile',
|
|
105
|
+
messages: [{ role: 'user', content: 'Reply: ok' }],
|
|
106
|
+
max_tokens: 5,
|
|
107
|
+
}),
|
|
108
|
+
signal: AbortSignal.timeout(15000),
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
let msg = `HTTP ${res.status}`;
|
|
112
|
+
try { msg = (await res.json())?.error?.message || msg; } catch {}
|
|
113
|
+
return { ok: false, error: msg };
|
|
114
|
+
}
|
|
115
|
+
const data = await res.json();
|
|
116
|
+
return { ok: true, text: data.choices?.[0]?.message?.content?.trim() || 'ok' };
|
|
117
|
+
},
|
|
118
|
+
ccrProvider: () => ({
|
|
119
|
+
name: 'groq',
|
|
120
|
+
api_base_url: 'https://api.groq.com/openai/v1/chat/completions',
|
|
121
|
+
api_key: '$GROQ_API_KEY',
|
|
122
|
+
models: [
|
|
123
|
+
'moonshotai/kimi-k2-instruct',
|
|
124
|
+
'deepseek-r1-distill-llama-70b',
|
|
125
|
+
'llama-3.3-70b-versatile',
|
|
126
|
+
'qwen/qwen3-32b',
|
|
127
|
+
],
|
|
128
|
+
}),
|
|
129
|
+
defaultModel: 'moonshotai/kimi-k2-instruct',
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
gemini: {
|
|
133
|
+
id: 'gemini',
|
|
134
|
+
name: 'Google Gemini',
|
|
135
|
+
tagline: '250 запросов/день, Gemini 2.5 Flash',
|
|
136
|
+
consoleUrl: 'https://aistudio.google.com/apikey',
|
|
137
|
+
keyPattern: /^AIza[A-Za-z0-9_-]{35}$/,
|
|
138
|
+
keyExample: 'AIzaSy…',
|
|
139
|
+
keyHowto: [
|
|
140
|
+
'Войди через свой Google-аккаунт (Gmail/YouTube подойдут)',
|
|
141
|
+
'Нажми «Create API key» наверху страницы',
|
|
142
|
+
'Если попросит выбрать проект — оставь предложенный',
|
|
143
|
+
'Скопируй ключ (начинается с AIza)',
|
|
144
|
+
],
|
|
145
|
+
quota: '~250 запросов в день, 10 запросов/мин',
|
|
146
|
+
bestModel: 'Gemini 2.5 Flash',
|
|
147
|
+
rpd: 250,
|
|
148
|
+
tpd: null,
|
|
149
|
+
rpm: 10,
|
|
150
|
+
contextLimit: 1_000_000,
|
|
151
|
+
verify: async (key) => {
|
|
152
|
+
const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent?key=${encodeURIComponent(key)}`;
|
|
153
|
+
const res = await fetch(url, {
|
|
154
|
+
method: 'POST',
|
|
155
|
+
headers: { 'content-type': 'application/json' },
|
|
156
|
+
body: JSON.stringify({
|
|
157
|
+
contents: [{ parts: [{ text: 'Reply: ok' }] }],
|
|
158
|
+
generationConfig: { maxOutputTokens: 20 },
|
|
159
|
+
}),
|
|
160
|
+
signal: AbortSignal.timeout(15000),
|
|
161
|
+
});
|
|
162
|
+
if (!res.ok) {
|
|
163
|
+
let msg = `HTTP ${res.status}`;
|
|
164
|
+
try { msg = (await res.json())?.error?.message || msg; } catch {}
|
|
165
|
+
return { ok: false, error: msg };
|
|
166
|
+
}
|
|
167
|
+
const data = await res.json();
|
|
168
|
+
return { ok: true, text: (data.candidates?.[0]?.content?.parts?.[0]?.text || 'ok').trim() };
|
|
169
|
+
},
|
|
170
|
+
ccrProvider: () => ({
|
|
171
|
+
name: 'gemini',
|
|
172
|
+
api_base_url: 'https://generativelanguage.googleapis.com/v1beta/models/',
|
|
173
|
+
api_key: '$GEMINI_API_KEY',
|
|
174
|
+
models: ['gemini-2.5-flash', 'gemini-flash-latest'],
|
|
175
|
+
transformer: { use: ['gemini'] },
|
|
176
|
+
}),
|
|
177
|
+
defaultModel: 'gemini-2.5-flash',
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
export const PROVIDER_PRIORITY = ['cerebras', 'groq', 'gemini'];
|
|
182
|
+
|
|
183
|
+
export function pollinationsProvider() {
|
|
184
|
+
return {
|
|
185
|
+
name: 'pollinations',
|
|
186
|
+
api_base_url: 'https://text.pollinations.ai/openai/chat/completions',
|
|
187
|
+
api_key: 'public',
|
|
188
|
+
models: ['openai', 'openai-fast'],
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function envFile(providerId) {
|
|
193
|
+
return join(KEYS_DIR, `${providerId}.env`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function exists(p) { return access(p).then(() => true).catch(() => false); }
|
|
197
|
+
|
|
198
|
+
export async function loadProviderKey(providerId) {
|
|
199
|
+
// New layout: ~/.krasavacode/keys/<id>.env
|
|
200
|
+
const newPath = envFile(providerId);
|
|
201
|
+
// Legacy layout (gemini only): ~/.krasavacode/gemini.env
|
|
202
|
+
const legacyPath = join(ROOT, `${providerId}.env`);
|
|
203
|
+
|
|
204
|
+
for (const p of [newPath, legacyPath]) {
|
|
205
|
+
try {
|
|
206
|
+
const content = await readFile(p, 'utf8');
|
|
207
|
+
const varName = ENV_VAR_NAMES[providerId];
|
|
208
|
+
const m = content.match(new RegExp(`^${varName}=(.+)$`, 'm'));
|
|
209
|
+
if (m) return m[1].trim();
|
|
210
|
+
} catch {}
|
|
211
|
+
}
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function isProviderConfigured(providerId) {
|
|
216
|
+
return (await loadProviderKey(providerId)) != null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Returns ids of all configured providers, in priority order. */
|
|
220
|
+
export async function configuredProviders() {
|
|
221
|
+
const result = [];
|
|
222
|
+
for (const id of PROVIDER_PRIORITY) {
|
|
223
|
+
if (await isProviderConfigured(id)) result.push(id);
|
|
224
|
+
}
|
|
225
|
+
return result;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function getProviderEnvVarName(providerId) {
|
|
229
|
+
return ENV_VAR_NAMES[providerId];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function providerEnvFile(providerId) {
|
|
233
|
+
return envFile(providerId);
|
|
234
|
+
}
|
package/src/setup.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { mkdir, writeFile, chmod, readFile, access } from 'node:fs/promises';
|
|
3
|
+
import { homedir, platform } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import net from 'node:net';
|
|
8
|
+
import {
|
|
9
|
+
PROVIDERS,
|
|
10
|
+
PROVIDER_PRIORITY,
|
|
11
|
+
KEYS_DIR,
|
|
12
|
+
loadProviderKey,
|
|
13
|
+
isProviderConfigured,
|
|
14
|
+
configuredProviders,
|
|
15
|
+
getProviderEnvVarName,
|
|
16
|
+
providerEnvFile,
|
|
17
|
+
} from './providers.js';
|
|
18
|
+
|
|
19
|
+
const ROOT = join(homedir(), '.krasavacode');
|
|
20
|
+
const STATE_FILE = join(ROOT, 'state.json');
|
|
21
|
+
|
|
22
|
+
function openBrowser(url) {
|
|
23
|
+
const cmd = platform() === 'darwin' ? 'open'
|
|
24
|
+
: platform() === 'win32' ? 'start'
|
|
25
|
+
: 'xdg-open';
|
|
26
|
+
const args = platform() === 'win32' ? ['', url] : [url];
|
|
27
|
+
try {
|
|
28
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore', shell: platform() === 'win32' }).unref();
|
|
29
|
+
return true;
|
|
30
|
+
} catch { return false; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function readState() { try { return JSON.parse(await readFile(STATE_FILE, 'utf8')); } catch { return {}; } }
|
|
34
|
+
async function writeState(s) { await writeFile(STATE_FILE, JSON.stringify(s, null, 2)); }
|
|
35
|
+
|
|
36
|
+
function getFreePort() {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
const srv = net.createServer();
|
|
39
|
+
srv.unref();
|
|
40
|
+
srv.on('error', reject);
|
|
41
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
42
|
+
const { port } = srv.address();
|
|
43
|
+
srv.close(() => resolve(port));
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function persistKey(providerId, key) {
|
|
49
|
+
await mkdir(KEYS_DIR, { recursive: true });
|
|
50
|
+
const file = providerEnvFile(providerId);
|
|
51
|
+
const varName = getProviderEnvVarName(providerId);
|
|
52
|
+
await writeFile(file, `${varName}=${key}\n`);
|
|
53
|
+
try { await chmod(file, 0o600); } catch {}
|
|
54
|
+
const state = await readState();
|
|
55
|
+
state.lastSetupAt = new Date().toISOString();
|
|
56
|
+
state.lastConfiguredProvider = providerId;
|
|
57
|
+
await writeState(state);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readJsonBody(req, max = 8 * 1024) {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const chunks = [];
|
|
63
|
+
let total = 0;
|
|
64
|
+
req.on('data', d => {
|
|
65
|
+
total += d.length;
|
|
66
|
+
if (total > max) { req.destroy(); reject(new Error('body too large')); }
|
|
67
|
+
chunks.push(d);
|
|
68
|
+
});
|
|
69
|
+
req.on('end', () => {
|
|
70
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); }
|
|
71
|
+
catch { reject(new Error('bad json')); }
|
|
72
|
+
});
|
|
73
|
+
req.on('error', reject);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function html() {
|
|
78
|
+
// ── HTML страницы ───────────────────────────────────────────────
|
|
79
|
+
// Три таба: Cerebras / Groq / Gemini. Inline CSS, dark/light theme.
|
|
80
|
+
const cards = PROVIDER_PRIORITY.map(id => {
|
|
81
|
+
const p = PROVIDERS[id];
|
|
82
|
+
const steps = p.keyHowto.map((s, i) => `<li>${s}</li>`).join('');
|
|
83
|
+
return `
|
|
84
|
+
<section class="tab-content" data-tab="${id}" hidden>
|
|
85
|
+
<div class="hero">
|
|
86
|
+
<h2>${p.name}</h2>
|
|
87
|
+
<p class="tagline">${p.tagline}</p>
|
|
88
|
+
<ul class="quota">
|
|
89
|
+
<li><b>Квота:</b> ${p.quota}</li>
|
|
90
|
+
<li><b>Лучшая модель:</b> ${p.bestModel}</li>
|
|
91
|
+
<li><b>Контекст:</b> ${p.contextLimit.toLocaleString('ru')} токенов</li>
|
|
92
|
+
</ul>
|
|
93
|
+
</div>
|
|
94
|
+
<ol class="steps">${steps}</ol>
|
|
95
|
+
<div class="open-row">
|
|
96
|
+
<a class="open-btn" href="${p.consoleUrl}" target="_blank" rel="noopener">Открыть страницу регистрации →</a>
|
|
97
|
+
</div>
|
|
98
|
+
<div class="field">
|
|
99
|
+
<label>Вставь ключ ${p.name}:</label>
|
|
100
|
+
<input data-provider="${id}" type="text" autocomplete="off" spellcheck="false" placeholder="${p.keyExample}">
|
|
101
|
+
<button data-action="verify" data-provider="${id}" class="submit">Подключить и проверить</button>
|
|
102
|
+
<div class="msg" data-provider-msg="${id}"></div>
|
|
103
|
+
</div>
|
|
104
|
+
</section>`;
|
|
105
|
+
}).join('\n');
|
|
106
|
+
|
|
107
|
+
const tabs = PROVIDER_PRIORITY.map((id, i) => {
|
|
108
|
+
const p = PROVIDERS[id];
|
|
109
|
+
return `<button class="tab" data-tab-button="${id}"${i === 0 ? ' aria-current="true"' : ''}>${p.name} <span class="tab-status" data-provider-status="${id}"></span></button>`;
|
|
110
|
+
}).join('');
|
|
111
|
+
|
|
112
|
+
return `<!doctype html>
|
|
113
|
+
<html lang="ru">
|
|
114
|
+
<head>
|
|
115
|
+
<meta charset="utf-8">
|
|
116
|
+
<title>KRASAVACODE — подключение AI-провайдеров</title>
|
|
117
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
118
|
+
<style>
|
|
119
|
+
* { box-sizing: border-box; }
|
|
120
|
+
body { margin: 0; padding: 32px 16px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
121
|
+
background: linear-gradient(180deg,#f7f7fb 0%,#ecedf3 100%); color:#1d1d1f; min-height:100vh;
|
|
122
|
+
display:flex; align-items:flex-start; justify-content:center; }
|
|
123
|
+
.card { width:100%; max-width:680px; background:#fff; border-radius:20px;
|
|
124
|
+
box-shadow:0 20px 60px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.04); padding:36px; }
|
|
125
|
+
h1 { font-size:28px; margin:0 0 6px; letter-spacing:-.5px; }
|
|
126
|
+
.sub { color:#65656d; margin:0 0 24px; }
|
|
127
|
+
.tabs { display:flex; gap:6px; border-bottom:2px solid #e3e3eb; margin-bottom:24px; flex-wrap:wrap; }
|
|
128
|
+
.tab { background:none; border:none; padding:12px 16px; cursor:pointer; font-size:15px; font-weight:500;
|
|
129
|
+
color:#65656d; border-bottom:2px solid transparent; margin-bottom:-2px; transition:all .15s; }
|
|
130
|
+
.tab[aria-current="true"] { color:#1d1d1f; border-bottom-color:#1d1d1f; }
|
|
131
|
+
.tab-status { font-size:13px; }
|
|
132
|
+
.tab-status.ok::before { content:" ✅"; }
|
|
133
|
+
.tab-status.fail::before { content:" ⚠️"; }
|
|
134
|
+
.hero h2 { font-size:22px; margin:0 0 4px; }
|
|
135
|
+
.tagline { color:#65656d; margin:0 0 16px; font-size:15px; }
|
|
136
|
+
.quota { list-style:none; padding:0; margin:0 0 20px; background:#f5f5f9; border-radius:12px; padding:14px 18px; }
|
|
137
|
+
.quota li { padding:3px 0; font-size:14px; color:#3a3a44; }
|
|
138
|
+
.quota li b { color:#1d1d1f; }
|
|
139
|
+
.steps { padding-left:20px; margin:0 0 16px; color:#3a3a44; font-size:14.5px; line-height:1.7; }
|
|
140
|
+
.open-row { margin-bottom:24px; }
|
|
141
|
+
.open-btn { display:inline-block; padding:10px 18px; background:#1a73e8; color:#fff; text-decoration:none;
|
|
142
|
+
border-radius:10px; font-weight:500; font-size:14px; }
|
|
143
|
+
.open-btn:hover { background:#1666d3; }
|
|
144
|
+
.field label { display:block; font-weight:600; margin-bottom:8px; font-size:14px; color:#3a3a44; }
|
|
145
|
+
input[type="text"] { width:100%; padding:14px 16px; border:2px solid #e3e3eb; border-radius:12px;
|
|
146
|
+
font-size:15px; font-family:'SF Mono',Menlo,Consolas,monospace; outline:none; }
|
|
147
|
+
input[type="text"]:focus { border-color:#1a73e8; }
|
|
148
|
+
.submit { margin-top:12px; width:100%; padding:14px; background:#1d1d1f; color:#fff; border:none;
|
|
149
|
+
border-radius:12px; font-size:16px; font-weight:600; cursor:pointer; }
|
|
150
|
+
.submit:hover:not(:disabled) { background:#000; }
|
|
151
|
+
.submit:disabled { background:#aaa; cursor:not-allowed; }
|
|
152
|
+
.msg { margin-top:14px; padding:14px 16px; border-radius:10px; font-size:14px; }
|
|
153
|
+
.msg.error { background:#fff1f0; color:#b00020; border:1px solid #ffd1cc; }
|
|
154
|
+
.msg.ok { background:#effaf3; color:#1a7f4d; border:1px solid #b8e6c9; }
|
|
155
|
+
.msg.ok strong { display:block; font-size:16px; margin-bottom:4px; }
|
|
156
|
+
.footer { margin-top:24px; font-size:13px; color:#8b8b94; text-align:center; }
|
|
157
|
+
.footer .done-btn { display:inline-block; margin-top:8px; padding:10px 22px; background:#1a7f4d; color:#fff;
|
|
158
|
+
text-decoration:none; border-radius:10px; font-weight:600; }
|
|
159
|
+
@media (prefers-color-scheme:dark) {
|
|
160
|
+
body { background:linear-gradient(180deg,#1a1a1f 0%,#0f0f12 100%); color:#f0f0f5; }
|
|
161
|
+
.card { background:#232328; box-shadow:0 20px 60px rgba(0,0,0,.4); }
|
|
162
|
+
.tabs { border-bottom-color:#3a3a42; }
|
|
163
|
+
.tab { color:#8b8b94; }
|
|
164
|
+
.tab[aria-current="true"] { color:#f0f0f5; border-bottom-color:#f0f0f5; }
|
|
165
|
+
.tagline, .footer { color:#8b8b94; }
|
|
166
|
+
.quota { background:#2c2c33; }
|
|
167
|
+
.quota li { color:#b8b8c0; }
|
|
168
|
+
.quota li b { color:#f0f0f5; }
|
|
169
|
+
.steps { color:#b8b8c0; }
|
|
170
|
+
input[type="text"] { background:#2c2c33; border-color:#3a3a42; color:#f0f0f5; }
|
|
171
|
+
.submit { background:#f0f0f5; color:#1d1d1f; }
|
|
172
|
+
.submit:hover:not(:disabled) { background:#fff; }
|
|
173
|
+
.msg.error { background:#3a1f1f; color:#ff8a80; border-color:#5a2929; }
|
|
174
|
+
.msg.ok { background:#1f3a2a; color:#8eddb0; border-color:#295a3c; }
|
|
175
|
+
}
|
|
176
|
+
</style>
|
|
177
|
+
</head>
|
|
178
|
+
<body>
|
|
179
|
+
<div class="card">
|
|
180
|
+
<h1>Подключаем AI-провайдеры</h1>
|
|
181
|
+
<p class="sub">Любые из этих трёх дают бесплатный вайбкодинг. Чем больше подключишь — тем устойчивее (если один упрётся в лимит, переключится на следующий).</p>
|
|
182
|
+
|
|
183
|
+
<div class="tabs" role="tablist">${tabs}</div>
|
|
184
|
+
${cards}
|
|
185
|
+
|
|
186
|
+
<div class="footer">
|
|
187
|
+
<p>Ключи хранятся только у тебя в <code>~/.krasavacode/keys/</code> с chmod 600. Ничего не отправляется кроме одного тестового запроса в каждый сервис.</p>
|
|
188
|
+
<p style="margin-top:18px;"><a href="#" class="done-btn" data-action="done">Готово, запустить вайбкодинг</a></p>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<script>
|
|
193
|
+
const tabs = document.querySelectorAll('[data-tab-button]');
|
|
194
|
+
const contents = document.querySelectorAll('[data-tab]');
|
|
195
|
+
|
|
196
|
+
function showTab(id) {
|
|
197
|
+
tabs.forEach(t => {
|
|
198
|
+
if (t.dataset.tabButton === id) t.setAttribute('aria-current', 'true');
|
|
199
|
+
else t.removeAttribute('aria-current');
|
|
200
|
+
});
|
|
201
|
+
contents.forEach(c => c.hidden = c.dataset.tab !== id);
|
|
202
|
+
}
|
|
203
|
+
tabs.forEach(t => t.addEventListener('click', () => showTab(t.dataset.tabButton)));
|
|
204
|
+
showTab('${PROVIDER_PRIORITY[0]}');
|
|
205
|
+
|
|
206
|
+
async function refreshStatus() {
|
|
207
|
+
try {
|
|
208
|
+
const r = await fetch('/api/status');
|
|
209
|
+
const data = await r.json();
|
|
210
|
+
document.querySelectorAll('[data-provider-status]').forEach(s => {
|
|
211
|
+
const id = s.dataset.providerStatus;
|
|
212
|
+
s.classList.remove('ok','fail');
|
|
213
|
+
if (data.configured.includes(id)) s.classList.add('ok');
|
|
214
|
+
});
|
|
215
|
+
} catch {}
|
|
216
|
+
}
|
|
217
|
+
refreshStatus();
|
|
218
|
+
|
|
219
|
+
document.querySelectorAll('[data-action="verify"]').forEach(btn => {
|
|
220
|
+
btn.addEventListener('click', async () => {
|
|
221
|
+
const id = btn.dataset.provider;
|
|
222
|
+
const input = document.querySelector('input[data-provider="' + id + '"]');
|
|
223
|
+
const msg = document.querySelector('[data-provider-msg="' + id + '"]');
|
|
224
|
+
const key = (input.value || '').trim();
|
|
225
|
+
msg.className = 'msg';
|
|
226
|
+
msg.textContent = '';
|
|
227
|
+
if (!key) { msg.className = 'msg error'; msg.textContent = 'Поле пустое — вставь ключ.'; return; }
|
|
228
|
+
btn.disabled = true; btn.textContent = 'Проверяю…';
|
|
229
|
+
try {
|
|
230
|
+
const r = await fetch('/api/verify', {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
headers: {'content-type': 'application/json'},
|
|
233
|
+
body: JSON.stringify({ provider: id, key }),
|
|
234
|
+
});
|
|
235
|
+
const data = await r.json();
|
|
236
|
+
if (!r.ok || !data.ok) {
|
|
237
|
+
msg.className = 'msg error';
|
|
238
|
+
msg.textContent = data.error || 'Не получилось.';
|
|
239
|
+
btn.disabled = false; btn.textContent = 'Подключить и проверить';
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
msg.className = 'msg ok';
|
|
243
|
+
msg.innerHTML = '<strong>✅ Подключено!</strong>Ответил «' + escapeHtml(data.text || 'ok') + '». Можно подключить ещё провайдер во вкладках выше или нажать «Готово».';
|
|
244
|
+
btn.textContent = 'Подключено ✓';
|
|
245
|
+
input.disabled = true;
|
|
246
|
+
refreshStatus();
|
|
247
|
+
} catch (e) {
|
|
248
|
+
msg.className = 'msg error'; msg.textContent = 'Сеть не отвечает: ' + e.message;
|
|
249
|
+
btn.disabled = false; btn.textContent = 'Подключить и проверить';
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
document.querySelector('[data-action="done"]').addEventListener('click', async (e) => {
|
|
255
|
+
e.preventDefault();
|
|
256
|
+
await fetch('/api/done', { method: 'POST' });
|
|
257
|
+
window.close();
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
function escapeHtml(s) {
|
|
261
|
+
return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
window.addEventListener('beforeunload', () => {
|
|
265
|
+
navigator.sendBeacon && navigator.sendBeacon('/api/cancel');
|
|
266
|
+
});
|
|
267
|
+
</script>
|
|
268
|
+
</body>
|
|
269
|
+
</html>`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function browserOnboarding() {
|
|
273
|
+
const port = await getFreePort();
|
|
274
|
+
const url = `http://127.0.0.1:${port}`;
|
|
275
|
+
|
|
276
|
+
let resolveResult;
|
|
277
|
+
const done = new Promise(r => { resolveResult = r; });
|
|
278
|
+
|
|
279
|
+
const server = http.createServer(async (req, res) => {
|
|
280
|
+
if (req.method === 'GET' && (req.url === '/' || req.url.startsWith('/?'))) {
|
|
281
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
282
|
+
return res.end(html());
|
|
283
|
+
}
|
|
284
|
+
if (req.method === 'GET' && req.url === '/api/status') {
|
|
285
|
+
const cfg = await configuredProviders();
|
|
286
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
287
|
+
return res.end(JSON.stringify({ configured: cfg }));
|
|
288
|
+
}
|
|
289
|
+
if (req.method === 'POST' && req.url === '/api/verify') {
|
|
290
|
+
try {
|
|
291
|
+
const body = await readJsonBody(req);
|
|
292
|
+
const provider = String(body.provider || '');
|
|
293
|
+
const key = String(body.key || '').trim();
|
|
294
|
+
const def = PROVIDERS[provider];
|
|
295
|
+
if (!def) throw new Error('Unknown provider: ' + provider);
|
|
296
|
+
if (!def.keyPattern.test(key)) {
|
|
297
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
298
|
+
return res.end(JSON.stringify({ error: `Не похоже на ключ ${def.name}. Ожидается: ${def.keyExample}` }));
|
|
299
|
+
}
|
|
300
|
+
const r = await def.verify(key);
|
|
301
|
+
if (!r.ok) {
|
|
302
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
303
|
+
return res.end(JSON.stringify({ error: r.error }));
|
|
304
|
+
}
|
|
305
|
+
await persistKey(provider, key);
|
|
306
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
307
|
+
return res.end(JSON.stringify({ ok: true, text: r.text }));
|
|
308
|
+
} catch (e) {
|
|
309
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
310
|
+
return res.end(JSON.stringify({ error: e.message }));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
if (req.method === 'POST' && (req.url === '/api/done' || req.url === '/api/cancel')) {
|
|
314
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
315
|
+
res.end('{}');
|
|
316
|
+
const ok = req.url === '/api/done';
|
|
317
|
+
setTimeout(async () => {
|
|
318
|
+
const cfg = await configuredProviders();
|
|
319
|
+
resolveResult({ launchAfter: ok, configured: cfg });
|
|
320
|
+
}, 100);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
res.writeHead(404);
|
|
324
|
+
res.end();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
server.listen(port, '127.0.0.1');
|
|
328
|
+
|
|
329
|
+
const opened = openBrowser(url);
|
|
330
|
+
console.log('');
|
|
331
|
+
console.log(' ╔═══════════════════════════════════════════════════╗');
|
|
332
|
+
console.log(' ║ KRASAVACODE — подключаем AI-провайдеров ║');
|
|
333
|
+
console.log(' ╚═══════════════════════════════════════════════════╝');
|
|
334
|
+
console.log('');
|
|
335
|
+
if (opened) console.log(` 🌐 Открыл настройку в браузере: ${url}`);
|
|
336
|
+
else console.log(` Скопируй и открой: ${url}`);
|
|
337
|
+
console.log(' Можно подключить один или несколько (для надёжности).');
|
|
338
|
+
console.log('');
|
|
339
|
+
console.log(' Ctrl+C — отменить.');
|
|
340
|
+
console.log('');
|
|
341
|
+
|
|
342
|
+
let result;
|
|
343
|
+
try {
|
|
344
|
+
result = await Promise.race([
|
|
345
|
+
done,
|
|
346
|
+
new Promise((_, rej) => process.once('SIGINT', () => rej(new Error('cancelled'))) ),
|
|
347
|
+
]);
|
|
348
|
+
} catch {
|
|
349
|
+
result = { launchAfter: false, configured: await configuredProviders() };
|
|
350
|
+
}
|
|
351
|
+
server.close();
|
|
352
|
+
return result;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* ─── CLI fallback (non-GUI envs) ─── */
|
|
356
|
+
|
|
357
|
+
function prompt(q) {
|
|
358
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
359
|
+
return new Promise(resolve => rl.question(q, ans => { rl.close(); resolve(ans); }));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function cliOnboarding() {
|
|
363
|
+
console.log('\n Браузерный режим недоступен — запускаю текстовый.\n');
|
|
364
|
+
console.log(' Доступные провайдеры:');
|
|
365
|
+
for (const id of PROVIDER_PRIORITY) {
|
|
366
|
+
const p = PROVIDERS[id];
|
|
367
|
+
const have = await isProviderConfigured(id) ? '✓' : ' ';
|
|
368
|
+
console.log(` [${have}] ${p.name} — ${p.tagline}`);
|
|
369
|
+
}
|
|
370
|
+
console.log('');
|
|
371
|
+
const which = (await prompt(' Какой настроить? (cerebras / groq / gemini): ')).trim().toLowerCase();
|
|
372
|
+
const def = PROVIDERS[which];
|
|
373
|
+
if (!def) { console.log(' Неизвестный провайдер.'); return { launchAfter: false }; }
|
|
374
|
+
|
|
375
|
+
console.log(`\n Открой: ${def.consoleUrl}`);
|
|
376
|
+
for (const s of def.keyHowto) console.log(` • ${s}`);
|
|
377
|
+
|
|
378
|
+
let key;
|
|
379
|
+
for (let i = 0; i < 3; i++) {
|
|
380
|
+
key = (await prompt(`\n Вставь ключ ${def.name}: `)).trim();
|
|
381
|
+
if (def.keyPattern.test(key)) break;
|
|
382
|
+
console.log(` ⚠️ Не похоже на ключ. Ожидается: ${def.keyExample}\n`);
|
|
383
|
+
}
|
|
384
|
+
if (!def.keyPattern.test(key)) return { launchAfter: false };
|
|
385
|
+
|
|
386
|
+
console.log(' ⏳ Проверяю…');
|
|
387
|
+
const r = await def.verify(key);
|
|
388
|
+
if (!r.ok) { console.log(` ❌ ${r.error}`); return { launchAfter: false }; }
|
|
389
|
+
await persistKey(which, key);
|
|
390
|
+
console.log(' ✅ Подключено.\n');
|
|
391
|
+
|
|
392
|
+
const launch = (await prompt(' Запустить вайбкодинг сейчас? [Enter — да, n — позже]: ')).trim().toLowerCase();
|
|
393
|
+
return { launchAfter: ['', 'y', 'yes', 'д', 'да'].includes(launch) };
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
export async function runSetup() {
|
|
397
|
+
const useBrowser = process.env.KRASAVACODE_NO_BROWSER !== '1' && process.stdout.isTTY !== false;
|
|
398
|
+
if (useBrowser) {
|
|
399
|
+
try { return await browserOnboarding(); }
|
|
400
|
+
catch (e) {
|
|
401
|
+
console.error(' Браузерный мастер упал:', e.message);
|
|
402
|
+
return cliOnboarding();
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return cliOnboarding();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Backward-compat alias for old subcommand
|
|
409
|
+
export { runSetup as runSetupGemini };
|