neuroalpha 0.6.13 → 0.7.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/README.md +14 -10
- package/bin/cli.js +79 -69
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +2 -2
- package/package.json +4 -5
- package/public/activate.html +41 -19
- package/public/activate.js +81 -10
- package/public/app.js +300 -152
- package/public/index.html +4 -37
- package/server/api/routes.js +570 -906
- package/server/auth/index.js +576 -761
- package/server/brokers/index.js +1 -2
- package/server/config.js +73 -53
- package/server/index.js +186 -212
- package/server/llm/local-engine.js +843 -0
- package/server/llm/providers.js +45 -34
- package/server/notify/index.js +304 -466
- package/deploy/docs/01-central.md +0 -96
- package/deploy/docs/02-cloud-instances.md +0 -130
- package/deploy/docs/03-desktop-build.md +0 -92
- package/deploy/docs/04-client-guide.md +0 -101
- package/deploy/docs/05-operations.md +0 -109
- package/deploy/docs/06-env-reference.md +0 -91
- package/deploy/docs/07-stress-test.md +0 -253
- package/deploy/docs/pdf/01-central.pdf +0 -0
- package/deploy/docs/pdf/02-cloud-instances.pdf +0 -0
- package/deploy/docs/pdf/03-desktop-build.pdf +0 -0
- package/deploy/docs/pdf/04-client-guide.pdf +0 -0
- package/deploy/docs/pdf/05-operations.pdf +0 -0
- package/deploy/docs/pdf/06-env-reference.pdf +0 -0
- package/deploy/docs/pdf/07-stress-test.pdf +0 -0
- package/deploy/install.sh +0 -128
- package/deploy/k8s/instance.yaml +0 -147
- package/public/admin.html +0 -604
- package/public/landing.html +0 -1207
- package/public/legal.html +0 -134
- package/scripts/set-admin-password.mjs +0 -30
- package/server/billing/yookassa.js +0 -142
- package/server/instance/agent.js +0 -259
- package/server/instance/central.js +0 -514
- package/server/instance/identity.js +0 -79
- package/server/instance/provision.js +0 -166
- package/server/instance/signing.js +0 -71
- package/server/role.js +0 -44
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
// Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software.
|
|
2
|
+
// Автодиагностика железа ПК, установка и запуск локальной ИИ-модели для «Стратега» (llama.cpp llama-server + Qwen3 GGUF):
|
|
3
|
+
// • распознаёт дискретные GPU (NVIDIA CUDA, AMD/Intel Vulkan) и единую память Unified Memory на AMD Ryzen AI Max+ 395 / 495 и Apple Silicon;
|
|
4
|
+
// • хранит движок и веса строго в постоянной папке DATA_DIR/local-llm (переживает любые обновления npx neuroalpha@latest);
|
|
5
|
+
// • поддерживает докачку многогигабайтных .gguf файлов по HTTP Range, отображение прогресса через WebSocket и чистую остановку дочернего процесса.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import net from 'node:net';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { execFile, spawn } from 'node:child_process';
|
|
11
|
+
import { promisify } from 'node:util';
|
|
12
|
+
import { DATA_DIR } from '../config.js';
|
|
13
|
+
import { kvGet, kvSet } from '../db.js';
|
|
14
|
+
import { hub } from '../hub.js';
|
|
15
|
+
|
|
16
|
+
const execFileAsync = promisify(execFile);
|
|
17
|
+
const GB = 1024 ** 3;
|
|
18
|
+
const MB = 1024 ** 2;
|
|
19
|
+
|
|
20
|
+
const LOCAL_ROOT = path.join(DATA_DIR, 'local-llm');
|
|
21
|
+
const ENGINE_ROOT = path.join(LOCAL_ROOT, 'engine');
|
|
22
|
+
const MODELS_DIR = path.join(LOCAL_ROOT, 'models');
|
|
23
|
+
|
|
24
|
+
export const LOCAL_PROVIDER_ID = 'local_llama';
|
|
25
|
+
|
|
26
|
+
export const LOCAL_MODEL_CATALOG = [
|
|
27
|
+
{
|
|
28
|
+
id: 'qwen3-4b',
|
|
29
|
+
label: 'Qwen3 4B (лёгкая · Q4_K_M)',
|
|
30
|
+
tier: 'light',
|
|
31
|
+
repo: 'Qwen/Qwen3-4B-GGUF',
|
|
32
|
+
file: 'Qwen3-4B-Q4_K_M.gguf',
|
|
33
|
+
sizeBytes: 2497280256,
|
|
34
|
+
sizeGb: 2.5,
|
|
35
|
+
minMemGb: 6,
|
|
36
|
+
minCpuRamGb: 10,
|
|
37
|
+
minDiskGb: 4.0,
|
|
38
|
+
desc: 'Компактная модель для базовых ПК и ноутбуков (от 6 ГБ видеопамяти или от 10–12 ГБ ОЗУ на процессоре).',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'qwen3-8b',
|
|
42
|
+
label: 'Qwen3 8B (базовая · Q4_K_M)',
|
|
43
|
+
tier: 'basic',
|
|
44
|
+
repo: 'Qwen/Qwen3-8B-GGUF',
|
|
45
|
+
file: 'Qwen3-8B-Q4_K_M.gguf',
|
|
46
|
+
sizeBytes: 5027783488,
|
|
47
|
+
sizeGb: 5.0,
|
|
48
|
+
minMemGb: 8,
|
|
49
|
+
minCpuRamGb: 18,
|
|
50
|
+
minDiskGb: 7.0,
|
|
51
|
+
desc: 'Уверенный русский язык и вызов инструментов анализа счёта (GPU 8–11 ГБ или от 20 ГБ быстрой ОЗУ).',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
id: 'qwen3-14b',
|
|
55
|
+
label: 'Qwen3 14B (оптимальная · Q4_K_M)',
|
|
56
|
+
tier: 'mid',
|
|
57
|
+
repo: 'Qwen/Qwen3-14B-GGUF',
|
|
58
|
+
file: 'Qwen3-14B-Q4_K_M.gguf',
|
|
59
|
+
sizeBytes: 9001752960,
|
|
60
|
+
sizeGb: 9.0,
|
|
61
|
+
minMemGb: 12,
|
|
62
|
+
minCpuRamGb: 30,
|
|
63
|
+
minDiskGb: 12.0,
|
|
64
|
+
desc: 'Глубокая аналитика портфеля и сделок для видеокарт 12–20 ГБ VRAM или 24 ГБ единой памяти.',
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
id: 'qwen3-30b-a3b',
|
|
68
|
+
label: 'Qwen3 30B-A3B MoE (быстрая профи · Q4_K_M)',
|
|
69
|
+
tier: 'high',
|
|
70
|
+
repo: 'Qwen/Qwen3-30B-A3B-GGUF',
|
|
71
|
+
file: 'Qwen3-30B-A3B-Q4_K_M.gguf',
|
|
72
|
+
sizeBytes: 18556685824,
|
|
73
|
+
sizeGb: 18.6,
|
|
74
|
+
minMemGb: 22,
|
|
75
|
+
minCpuRamGb: 44,
|
|
76
|
+
minDiskGb: 22.0,
|
|
77
|
+
desc: 'Быстрая экспертная MoE-модель (30B параметров, 3B активных на токен): идеальна для 24 ГБ VRAM и 64 ГБ AMD Ryzen AI Max+ 395/495.',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
id: 'qwen3-32b',
|
|
81
|
+
label: 'Qwen3 32B Dense (плотная профи · Q4_K_M)',
|
|
82
|
+
tier: 'high_dense',
|
|
83
|
+
repo: 'Qwen/Qwen3-32B-GGUF',
|
|
84
|
+
file: 'Qwen3-32B-Q4_K_M.gguf',
|
|
85
|
+
sizeBytes: 19762149024,
|
|
86
|
+
sizeGb: 19.8,
|
|
87
|
+
minMemGb: 24,
|
|
88
|
+
minCpuRamGb: 48,
|
|
89
|
+
minDiskGb: 24.0,
|
|
90
|
+
desc: 'Полноразмерная плотная модель 32B повышенной точности рассуждений (от 24–32 ГБ VRAM / единой памяти).',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'qwen3-next-80b-a3b',
|
|
94
|
+
label: 'Qwen3-Next 80B-A3B Instruct (Ультра · Q4_K_M)',
|
|
95
|
+
tier: 'ultra',
|
|
96
|
+
repo: 'Qwen/Qwen3-Next-80B-A3B-Instruct-GGUF',
|
|
97
|
+
file: 'Qwen3-Next-80B-A3B-Instruct-Q4_K_M.gguf',
|
|
98
|
+
sizeBytes: 48410988384,
|
|
99
|
+
sizeGb: 48.4,
|
|
100
|
+
minMemGb: 56,
|
|
101
|
+
minCpuRamGb: 92,
|
|
102
|
+
minDiskGb: 54.0,
|
|
103
|
+
desc: 'Флагман 80B MoE (активно 3B): максимальный уровень Стратега для станций AMD Ryzen AI Max+ 395 / 495 (96–128 ГБ единой памяти).',
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
let hwCache = { at: 0, data: null };
|
|
108
|
+
let engineChild = null;
|
|
109
|
+
let enginePort = 0;
|
|
110
|
+
let activeLocalModelId = '';
|
|
111
|
+
let activeBackend = '';
|
|
112
|
+
let installAbort = null;
|
|
113
|
+
|
|
114
|
+
let jobState = {
|
|
115
|
+
phase: 'idle', // idle | engine | model | starting | running | error
|
|
116
|
+
modelId: '',
|
|
117
|
+
backend: '',
|
|
118
|
+
percent: 0,
|
|
119
|
+
downloadedBytes: 0,
|
|
120
|
+
totalBytes: 0,
|
|
121
|
+
speedMbSec: 0,
|
|
122
|
+
etaSec: null,
|
|
123
|
+
message: '',
|
|
124
|
+
error: '',
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
let onActivatedCallback = null;
|
|
128
|
+
export function setLocalModelReadyHook(fn) {
|
|
129
|
+
onActivatedCallback = fn;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function emitStatus() {
|
|
133
|
+
hub.emit({ type: 'localLlm:status', status: getLocalEngineQuickStatus() });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function getLocalEngineQuickStatus() {
|
|
137
|
+
return {
|
|
138
|
+
running: Boolean(engineChild && !engineChild.killed && enginePort > 0),
|
|
139
|
+
port: enginePort,
|
|
140
|
+
activeModelId: activeLocalModelId,
|
|
141
|
+
activeBackend,
|
|
142
|
+
job: { ...jobState },
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function round1(x) {
|
|
147
|
+
return Math.round((Number(x) || 0) * 10) / 10;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function probeNvidiaGpu() {
|
|
151
|
+
try {
|
|
152
|
+
const { stdout } = await execFileAsync(
|
|
153
|
+
'nvidia-smi',
|
|
154
|
+
['--query-gpu=name,memory.total', '--format=csv,noheader,nounits'],
|
|
155
|
+
{ timeout: 3500, windowsHide: true },
|
|
156
|
+
);
|
|
157
|
+
const lines = String(stdout || '').trim().split(/\r?\n/).filter(Boolean);
|
|
158
|
+
if (!lines.length) return null;
|
|
159
|
+
let bestName = '';
|
|
160
|
+
let bestMb = 0;
|
|
161
|
+
for (const line of lines) {
|
|
162
|
+
const parts = line.split(',').map((s) => s.trim());
|
|
163
|
+
const mb = Number(parts[parts.length - 1]) || 0;
|
|
164
|
+
if (mb > bestMb) {
|
|
165
|
+
bestMb = mb;
|
|
166
|
+
bestName = parts.slice(0, -1).join(', ');
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (bestMb > 0) return { vendor: 'nvidia', name: bestName, vramGb: round1(bestMb / 1024) };
|
|
170
|
+
} catch { /* no nvidia-smi */ }
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function probeWindowsDisplayGpus() {
|
|
175
|
+
if (process.platform !== 'win32') return [];
|
|
176
|
+
const psScript = `
|
|
177
|
+
$out = @()
|
|
178
|
+
Get-ItemProperty -Path 'HKLM:\\SYSTEM\\CurrentControlSet\\Control\\Class\\{4d36e968-e325-11ce-bfc1-08002be10318}\\0*' -ErrorAction SilentlyContinue | ForEach-Object {
|
|
179
|
+
$desc = $_.'DriverDesc'
|
|
180
|
+
$mem = $_.'HardwareInformation.qwMemorySize'
|
|
181
|
+
if (-not $mem) { $mem = $_.'HardwareInformation.MemorySize' }
|
|
182
|
+
if ($desc) { $out += [PSCustomObject]@{ Name = [string]$desc; Bytes = [int64]($mem -as [int64]) } }
|
|
183
|
+
}
|
|
184
|
+
if ($out.Count -eq 0) {
|
|
185
|
+
Get-CimInstance Win32_VideoController -ErrorAction SilentlyContinue | ForEach-Object {
|
|
186
|
+
$out += [PSCustomObject]@{ Name = [string]$_.Name; Bytes = [int64]$_.AdapterRAM }
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
$out | ConvertTo-Json -Compress
|
|
190
|
+
`.trim();
|
|
191
|
+
try {
|
|
192
|
+
const { stdout } = await execFileAsync(
|
|
193
|
+
'powershell',
|
|
194
|
+
['-NoProfile', '-NonInteractive', '-Command', psScript],
|
|
195
|
+
{ timeout: 4500, windowsHide: true },
|
|
196
|
+
);
|
|
197
|
+
const raw = String(stdout || '').trim();
|
|
198
|
+
if (!raw) return [];
|
|
199
|
+
const parsed = JSON.parse(raw);
|
|
200
|
+
const arr = Array.isArray(parsed) ? parsed : [parsed];
|
|
201
|
+
return arr
|
|
202
|
+
.filter((x) => x?.Name && !/microsoft basic|remote desktop|parsec|virtual/i.test(x.Name))
|
|
203
|
+
.map((x) => ({ name: String(x.Name).trim(), vramGb: round1(Math.max(0, Number(x.Bytes) || 0) / GB) }));
|
|
204
|
+
} catch {
|
|
205
|
+
return [];
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function probeLinuxGpus() {
|
|
210
|
+
if (process.platform !== 'linux') return [];
|
|
211
|
+
const gpus = [];
|
|
212
|
+
try {
|
|
213
|
+
const drmDir = '/sys/class/drm';
|
|
214
|
+
if (fs.existsSync(drmDir)) {
|
|
215
|
+
for (const entry of fs.readdirSync(drmDir)) {
|
|
216
|
+
if (!/^card\d+$/.test(entry)) continue;
|
|
217
|
+
const vramPath = path.join(drmDir, entry, 'device', 'mem_info_vram_total');
|
|
218
|
+
if (fs.existsSync(vramPath)) {
|
|
219
|
+
const bytes = Number(fs.readFileSync(vramPath, 'utf8').trim()) || 0;
|
|
220
|
+
gpus.push({ name: `AMD GPU (${entry})`, vramGb: round1(bytes / GB) });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
} catch { /* ignore */ }
|
|
225
|
+
return gpus;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async function probeOllamaLocal() {
|
|
229
|
+
try {
|
|
230
|
+
const r = await fetch('http://127.0.0.1:11434/api/tags', { signal: AbortSignal.timeout(1200) });
|
|
231
|
+
if (!r.ok) return { running: false, models: [] };
|
|
232
|
+
const j = await r.json().catch(() => ({}));
|
|
233
|
+
const models = (j.models || []).map((m) => String(m.name || '').trim()).filter(Boolean);
|
|
234
|
+
return { running: true, models };
|
|
235
|
+
} catch {
|
|
236
|
+
return { running: false, models: [] };
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export async function detectLocalHardware({ refresh = false } = {}) {
|
|
241
|
+
if (!refresh && hwCache.data && Date.now() - hwCache.at < 60000) {
|
|
242
|
+
return hwCache.data;
|
|
243
|
+
}
|
|
244
|
+
const cpus = os.cpus() || [];
|
|
245
|
+
const cpuModel = String(cpus[0]?.model || 'CPU').trim();
|
|
246
|
+
const cpuCores = cpus.length || 4;
|
|
247
|
+
const totalRamGb = round1(os.totalmem() / GB);
|
|
248
|
+
const freeRamGb = round1(os.freemem() / GB);
|
|
249
|
+
|
|
250
|
+
let freeDiskGb = 50;
|
|
251
|
+
try {
|
|
252
|
+
const st = fs.statfsSync(DATA_DIR);
|
|
253
|
+
freeDiskGb = round1((Number(st.bavail) * Number(st.bsize)) / GB);
|
|
254
|
+
} catch { /* fallback */ }
|
|
255
|
+
|
|
256
|
+
let gpuName = '';
|
|
257
|
+
let gpuVendor = 'none';
|
|
258
|
+
let dedicatedVramGb = 0;
|
|
259
|
+
let isUnifiedMemory = false;
|
|
260
|
+
let isRyzenAiMax = false;
|
|
261
|
+
let usableVramGb = 0;
|
|
262
|
+
let recommendedBackend = 'cpu';
|
|
263
|
+
let availableBackends = ['cpu'];
|
|
264
|
+
|
|
265
|
+
const ryzenAiMaxCpu = /ryzen\s+ai\s+max\+?\s*(3\d\d|4\d\d)/i.test(cpuModel);
|
|
266
|
+
|
|
267
|
+
if (process.platform === 'darwin' && process.arch === 'arm64') {
|
|
268
|
+
gpuVendor = 'apple';
|
|
269
|
+
gpuName = `${cpuModel} (Unified Memory)`;
|
|
270
|
+
isUnifiedMemory = true;
|
|
271
|
+
usableVramGb = round1(totalRamGb * 0.75);
|
|
272
|
+
recommendedBackend = 'metal';
|
|
273
|
+
availableBackends = ['metal', 'cpu'];
|
|
274
|
+
} else {
|
|
275
|
+
const nv = await probeNvidiaGpu();
|
|
276
|
+
const winGpus = nv ? [] : await probeWindowsDisplayGpus();
|
|
277
|
+
const linGpus = nv || process.platform !== 'linux' ? [] : await probeLinuxGpus();
|
|
278
|
+
const allOther = [...winGpus, ...linGpus].sort((a, b) => b.vramGb - a.vramGb);
|
|
279
|
+
const radeonStrixGpu = allOther.find((g) => /radeon\s+80[56]0s|gfx1151|strix\s*halo|ryzen\s+ai\s+max/i.test(g.name));
|
|
280
|
+
|
|
281
|
+
if (ryzenAiMaxCpu || radeonStrixGpu) {
|
|
282
|
+
gpuVendor = 'amd';
|
|
283
|
+
isUnifiedMemory = true;
|
|
284
|
+
isRyzenAiMax = true;
|
|
285
|
+
gpuName = radeonStrixGpu?.name
|
|
286
|
+
? `${radeonStrixGpu.name} · Unified Memory (${totalRamGb} ГБ)`
|
|
287
|
+
: `${cpuModel} · Radeon Unified Memory`;
|
|
288
|
+
dedicatedVramGb = radeonStrixGpu?.vramGb || 0;
|
|
289
|
+
usableVramGb = round1(Math.max(dedicatedVramGb, totalRamGb * 0.75));
|
|
290
|
+
recommendedBackend = 'vulkan';
|
|
291
|
+
availableBackends = ['vulkan', 'rocm', 'cpu'];
|
|
292
|
+
} else if (nv && nv.vramGb >= 4) {
|
|
293
|
+
gpuVendor = 'nvidia';
|
|
294
|
+
gpuName = nv.name;
|
|
295
|
+
dedicatedVramGb = nv.vramGb;
|
|
296
|
+
usableVramGb = nv.vramGb;
|
|
297
|
+
recommendedBackend = 'cuda';
|
|
298
|
+
availableBackends = ['cuda', 'vulkan', 'cpu'];
|
|
299
|
+
} else if (allOther.length > 0 && allOther[0].vramGb >= 4) {
|
|
300
|
+
const top = allOther[0];
|
|
301
|
+
gpuVendor = /amd|radeon/i.test(top.name) ? 'amd' : (/intel|arc/i.test(top.name) ? 'intel' : 'gpu');
|
|
302
|
+
gpuName = top.name;
|
|
303
|
+
dedicatedVramGb = top.vramGb;
|
|
304
|
+
usableVramGb = top.vramGb;
|
|
305
|
+
recommendedBackend = 'vulkan';
|
|
306
|
+
availableBackends = gpuVendor === 'amd' ? ['vulkan', 'rocm', 'cpu'] : ['vulkan', 'cpu'];
|
|
307
|
+
} else if (allOther.length > 0) {
|
|
308
|
+
gpuName = allOther[0].name;
|
|
309
|
+
dedicatedVramGb = allOther[0].vramGb;
|
|
310
|
+
availableBackends = ['cpu', 'vulkan'];
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const ollama = await probeOllamaLocal();
|
|
315
|
+
const canInstall = usableVramGb >= 6 || totalRamGb >= 10;
|
|
316
|
+
const reasonBlocked = !canInstall
|
|
317
|
+
? `Недостаточно ресурсов для локальной ИИ-модели (ОЗУ ${totalRamGb} ГБ, GPU ${usableVramGb} ГБ). Используйте встроенный облачный прокси НейроАльфа или внешний API.`
|
|
318
|
+
: (freeDiskGb < 4 ? `Недостаточно свободного места на диске (${freeDiskGb} ГБ свободно в ${DATA_DIR}).` : '');
|
|
319
|
+
|
|
320
|
+
const data = {
|
|
321
|
+
platform: process.platform,
|
|
322
|
+
arch: process.arch,
|
|
323
|
+
cpuModel,
|
|
324
|
+
cpuCores,
|
|
325
|
+
totalRamGb,
|
|
326
|
+
freeRamGb,
|
|
327
|
+
freeDiskGb,
|
|
328
|
+
gpuVendor,
|
|
329
|
+
gpuName,
|
|
330
|
+
dedicatedVramGb,
|
|
331
|
+
usableVramGb,
|
|
332
|
+
isUnifiedMemory,
|
|
333
|
+
isRyzenAiMax,
|
|
334
|
+
recommendedBackend,
|
|
335
|
+
availableBackends,
|
|
336
|
+
canInstall: Boolean(canInstall && freeDiskGb >= 4),
|
|
337
|
+
reasonBlocked,
|
|
338
|
+
ollama,
|
|
339
|
+
modelsDir: MODELS_DIR,
|
|
340
|
+
};
|
|
341
|
+
hwCache = { at: Date.now(), data };
|
|
342
|
+
return data;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export async function getLocalEngineFullState({ refresh = false } = {}) {
|
|
346
|
+
const hw = await detectLocalHardware({ refresh });
|
|
347
|
+
fs.mkdirSync(MODELS_DIR, { recursive: true });
|
|
348
|
+
|
|
349
|
+
const models = LOCAL_MODEL_CATALOG.map((m) => {
|
|
350
|
+
const fullPath = path.join(MODELS_DIR, m.file);
|
|
351
|
+
const installed = fs.existsSync(fullPath) && fs.statSync(fullPath).size >= m.sizeBytes * 0.95;
|
|
352
|
+
const fitsGpu = hw.usableVramGb >= m.minMemGb;
|
|
353
|
+
const fitsCpu = hw.totalRamGb >= m.minCpuRamGb;
|
|
354
|
+
const fitsDisk = installed || hw.freeDiskGb >= m.minDiskGb;
|
|
355
|
+
const compatible = (fitsGpu || fitsCpu) && fitsDisk;
|
|
356
|
+
return {
|
|
357
|
+
...m,
|
|
358
|
+
installed,
|
|
359
|
+
fitsGpu,
|
|
360
|
+
fitsCpu,
|
|
361
|
+
fitsDisk,
|
|
362
|
+
compatible,
|
|
363
|
+
accelMode: fitsGpu ? (hw.isUnifiedMemory ? 'Unified GPU (Vulkan/Metal)' : 'GPU') : (fitsCpu ? 'CPU + RAM' : 'Недостаточно памяти'),
|
|
364
|
+
};
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// Выбираем самую сильную совместимую модель (предпочитая qwen3-30b-a3b перед dense 32b на 24–48 ГБ из-за 3× более высокой скорости MoE)
|
|
368
|
+
let recommendedModelId = '';
|
|
369
|
+
const priorityOrder = ['qwen3-next-80b-a3b', 'qwen3-30b-a3b', 'qwen3-14b', 'qwen3-8b', 'qwen3-4b'];
|
|
370
|
+
for (const id of priorityOrder) {
|
|
371
|
+
const hit = models.find((m) => m.id === id);
|
|
372
|
+
if (hit && hit.compatible && (hit.fitsGpu || hw.usableVramGb < 6)) {
|
|
373
|
+
recommendedModelId = id;
|
|
374
|
+
break;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
if (!recommendedModelId) {
|
|
378
|
+
const anyCompat = [...models].reverse().find((m) => m.compatible);
|
|
379
|
+
if (anyCompat) recommendedModelId = anyCompat.id;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
return {
|
|
383
|
+
hardware: hw,
|
|
384
|
+
recommendedModelId,
|
|
385
|
+
models,
|
|
386
|
+
...getLocalEngineQuickStatus(),
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// ---------- Скачивание бинарника llama-server из официальных релизов ggml-org/llama.cpp ----------
|
|
391
|
+
function findExecutableRecursive(dir, targetName) {
|
|
392
|
+
if (!fs.existsSync(dir)) return '';
|
|
393
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
394
|
+
const full = path.join(dir, e.name);
|
|
395
|
+
if (e.isDirectory()) {
|
|
396
|
+
const sub = findExecutableRecursive(full, targetName);
|
|
397
|
+
if (sub) return sub;
|
|
398
|
+
} else if (e.name.toLowerCase() === targetName.toLowerCase()) {
|
|
399
|
+
return full;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return '';
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function getInstalledServerBinary(backend) {
|
|
406
|
+
const exeName = process.platform === 'win32' ? 'llama-server.exe' : 'llama-server';
|
|
407
|
+
return findExecutableRecursive(path.join(ENGINE_ROOT, backend), exeName);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function downloadFileWithResume(url, destPath, { signal, onProgress, label = '' } = {}) {
|
|
411
|
+
const partPath = `${destPath}.part`;
|
|
412
|
+
fs.mkdirSync(path.dirname(destPath), { recursive: true });
|
|
413
|
+
let startByte = fs.existsSync(partPath) ? fs.statSync(partPath).size : 0;
|
|
414
|
+
|
|
415
|
+
const headers = { 'User-Agent': 'NeuroAlpha-LocalLLM/0.7' };
|
|
416
|
+
if (startByte > 0) headers.Range = `bytes=${startByte}-`;
|
|
417
|
+
|
|
418
|
+
const res = await fetch(url, { headers, redirect: 'follow', signal });
|
|
419
|
+
if (res.status === 416) {
|
|
420
|
+
// Уже скачан полностью
|
|
421
|
+
fs.renameSync(partPath, destPath);
|
|
422
|
+
return destPath;
|
|
423
|
+
}
|
|
424
|
+
if (!res.ok) {
|
|
425
|
+
throw new Error(`Ошибка загрузки ${label || path.basename(destPath)}: HTTP ${res.status}`);
|
|
426
|
+
}
|
|
427
|
+
const isPartial = res.status === 206;
|
|
428
|
+
if (!isPartial) startByte = 0;
|
|
429
|
+
|
|
430
|
+
const contentLen = Number(res.headers.get('content-length')) || 0;
|
|
431
|
+
const totalBytes = contentLen > 0 ? startByte + contentLen : 0;
|
|
432
|
+
const out = fs.createWriteStream(partPath, { flags: isPartial ? 'a' : 'w' });
|
|
433
|
+
|
|
434
|
+
let downloaded = startByte;
|
|
435
|
+
let lastEmitAt = 0;
|
|
436
|
+
let speedWindowBytes = 0;
|
|
437
|
+
let speedWindowStart = Date.now();
|
|
438
|
+
let speedMbSec = 0;
|
|
439
|
+
|
|
440
|
+
try {
|
|
441
|
+
for await (const chunk of res.body) {
|
|
442
|
+
if (signal?.aborted) throw Object.assign(new Error('Загрузка отменена пользователем'), { name: 'AbortError' });
|
|
443
|
+
if (!out.write(chunk)) {
|
|
444
|
+
await new Promise((r) => out.once('drain', r));
|
|
445
|
+
}
|
|
446
|
+
downloaded += chunk.length;
|
|
447
|
+
speedWindowBytes += chunk.length;
|
|
448
|
+
const now = Date.now();
|
|
449
|
+
if (now - lastEmitAt >= 400) {
|
|
450
|
+
const dt = (now - speedWindowStart) / 1000;
|
|
451
|
+
if (dt >= 0.8) {
|
|
452
|
+
speedMbSec = round1((speedWindowBytes / MB) / dt);
|
|
453
|
+
speedWindowBytes = 0;
|
|
454
|
+
speedWindowStart = now;
|
|
455
|
+
}
|
|
456
|
+
lastEmitAt = now;
|
|
457
|
+
if (onProgress) onProgress({ downloaded, totalBytes, speedMbSec });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
await new Promise((resolve, reject) => {
|
|
461
|
+
out.end((err) => (err ? reject(err) : resolve()));
|
|
462
|
+
});
|
|
463
|
+
fs.renameSync(partPath, destPath);
|
|
464
|
+
return destPath;
|
|
465
|
+
} catch (e) {
|
|
466
|
+
try { out.destroy(); } catch { /* ignore */ }
|
|
467
|
+
throw e;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
async function ensureLlamaServerBinary(backend, { signal } = {}) {
|
|
472
|
+
const existing = getInstalledServerBinary(backend);
|
|
473
|
+
if (existing) return existing;
|
|
474
|
+
|
|
475
|
+
jobState = { ...jobState, phase: 'engine', percent: 0, message: `Поиск актуальной сборки движка llama.cpp (${backend})…` };
|
|
476
|
+
emitStatus();
|
|
477
|
+
|
|
478
|
+
const relRes = await fetch('https://api.github.com/repos/ggml-org/llama.cpp/releases?per_page=8', {
|
|
479
|
+
headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'NeuroAlpha-LocalLLM/0.7' },
|
|
480
|
+
signal,
|
|
481
|
+
});
|
|
482
|
+
if (!relRes.ok) throw new Error(`Не удалось получить список релизов llama.cpp (HTTP ${relRes.status})`);
|
|
483
|
+
const releases = await relRes.json();
|
|
484
|
+
const rel = (releases || []).find((r) => Array.isArray(r.assets) && r.assets.some((a) => /^llama-.*-bin-/.test(a.name)));
|
|
485
|
+
if (!rel) throw new Error('Не найден бинарный релиз движка llama.cpp');
|
|
486
|
+
|
|
487
|
+
const assets = rel.assets || [];
|
|
488
|
+
const plat = process.platform;
|
|
489
|
+
const arch = process.arch === 'arm64' ? 'arm64' : 'x64';
|
|
490
|
+
|
|
491
|
+
const pickAsset = (rxList) => {
|
|
492
|
+
for (const rx of rxList) {
|
|
493
|
+
const hit = assets.find((a) => rx.test(a.name));
|
|
494
|
+
if (hit) return hit;
|
|
495
|
+
}
|
|
496
|
+
return null;
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
const toDownload = [];
|
|
500
|
+
if (plat === 'win32') {
|
|
501
|
+
if (backend === 'vulkan') {
|
|
502
|
+
const a = pickAsset([new RegExp(`^llama-.*-bin-win-vulkan-${arch}\\.zip$`, 'i')]);
|
|
503
|
+
if (a) toDownload.push(a);
|
|
504
|
+
} else if (backend === 'rocm') {
|
|
505
|
+
const a = pickAsset([/^llama-.*-bin-win-rocm-.*-x64\.zip$/i, /^llama-.*-bin-win-hip-radeon-x64\.zip$/i]);
|
|
506
|
+
if (a) toDownload.push(a);
|
|
507
|
+
} else if (backend === 'cuda') {
|
|
508
|
+
const a = pickAsset([/^llama-.*-bin-win-cuda-12\..*-x64\.zip$/i, /^llama-.*-bin-win-cuda-.*-x64\.zip$/i]);
|
|
509
|
+
const rt = pickAsset([/^cudart-llama-.*win-cuda-12\..*-x64\.zip$/i, /^cudart-llama-.*win-cuda-.*-x64\.zip$/i]);
|
|
510
|
+
if (a) toDownload.push(a);
|
|
511
|
+
if (rt) toDownload.push(rt);
|
|
512
|
+
}
|
|
513
|
+
if (!toDownload.length) {
|
|
514
|
+
const cpuAsset = pickAsset([new RegExp(`^llama-.*-bin-win-cpu-${arch}\\.zip$`, 'i')]);
|
|
515
|
+
if (cpuAsset) toDownload.push(cpuAsset);
|
|
516
|
+
}
|
|
517
|
+
} else if (plat === 'darwin') {
|
|
518
|
+
const a = pickAsset([new RegExp(`^llama-.*-bin-macos-${arch}\\.tar\\.gz$`, 'i')]);
|
|
519
|
+
if (a) toDownload.push(a);
|
|
520
|
+
} else {
|
|
521
|
+
if (backend === 'vulkan') {
|
|
522
|
+
const a = pickAsset([new RegExp(`^llama-.*-bin-ubuntu-vulkan-${arch}\\.tar\\.gz$`, 'i')]);
|
|
523
|
+
if (a) toDownload.push(a);
|
|
524
|
+
} else if (backend === 'rocm') {
|
|
525
|
+
const a = pickAsset([/^llama-.*-bin-ubuntu-rocm-.*-x64\.tar\.gz$/i]);
|
|
526
|
+
if (a) toDownload.push(a);
|
|
527
|
+
} else if (backend === 'cuda') {
|
|
528
|
+
const a = pickAsset([/^llama-.*-bin-ubuntu-cuda-12\..*-x64\.tar\.gz$/i, /^llama-.*-bin-ubuntu-cuda-.*-x64\.tar\.gz$/i]);
|
|
529
|
+
if (a) toDownload.push(a);
|
|
530
|
+
}
|
|
531
|
+
if (!toDownload.length) {
|
|
532
|
+
const cpuAsset = pickAsset([new RegExp(`^llama-.*-bin-ubuntu-${arch}\\.tar\\.gz$`, 'i')]);
|
|
533
|
+
if (cpuAsset) toDownload.push(cpuAsset);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
if (!toDownload.length) {
|
|
538
|
+
throw new Error(`Нет готовой сборки llama-server под платформу ${plat}/${arch} (${backend})`);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const targetDir = path.join(ENGINE_ROOT, backend);
|
|
542
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
543
|
+
|
|
544
|
+
for (const asset of toDownload) {
|
|
545
|
+
const archivePath = path.join(targetDir, asset.name);
|
|
546
|
+
jobState = { ...jobState, phase: 'engine', message: `Загрузка движка ${asset.name}…` };
|
|
547
|
+
emitStatus();
|
|
548
|
+
await downloadFileWithResume(asset.browser_download_url, archivePath, {
|
|
549
|
+
signal,
|
|
550
|
+
label: asset.name,
|
|
551
|
+
onProgress: ({ downloaded, totalBytes, speedMbSec }) => {
|
|
552
|
+
jobState = {
|
|
553
|
+
...jobState,
|
|
554
|
+
phase: 'engine',
|
|
555
|
+
downloadedBytes: downloaded,
|
|
556
|
+
totalBytes: totalBytes || asset.size || 0,
|
|
557
|
+
speedMbSec,
|
|
558
|
+
percent: totalBytes > 0 ? Math.min(99, Math.round((downloaded / totalBytes) * 100)) : 0,
|
|
559
|
+
message: `Загрузка движка ИИ (${backend}): ${asset.name}`,
|
|
560
|
+
};
|
|
561
|
+
emitStatus();
|
|
562
|
+
},
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
jobState = { ...jobState, message: `Распаковка движка ${asset.name}…` };
|
|
566
|
+
emitStatus();
|
|
567
|
+
await execFileAsync('tar', ['-xf', archivePath, '-C', targetDir], { timeout: 90000, windowsHide: true });
|
|
568
|
+
try { fs.unlinkSync(archivePath); } catch { /* ignore */ }
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const binPath = getInstalledServerBinary(backend);
|
|
572
|
+
if (!binPath) throw new Error('После распаковки не найден исполняемый файл llama-server');
|
|
573
|
+
if (process.platform !== 'win32') {
|
|
574
|
+
try { fs.chmodSync(binPath, 0o755); } catch { /* ignore */ }
|
|
575
|
+
}
|
|
576
|
+
return binPath;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function findFreeLocalPort(preferred = 18089) {
|
|
580
|
+
return new Promise((resolve, reject) => {
|
|
581
|
+
const srv = net.createServer();
|
|
582
|
+
srv.unref();
|
|
583
|
+
srv.on('error', () => {
|
|
584
|
+
const fallback = net.createServer();
|
|
585
|
+
fallback.unref();
|
|
586
|
+
fallback.on('error', reject);
|
|
587
|
+
fallback.listen(0, '127.0.0.1', () => {
|
|
588
|
+
const p = fallback.address().port;
|
|
589
|
+
fallback.close(() => resolve(p));
|
|
590
|
+
});
|
|
591
|
+
});
|
|
592
|
+
srv.listen(preferred, '127.0.0.1', () => {
|
|
593
|
+
const p = srv.address().port;
|
|
594
|
+
srv.close(() => resolve(p));
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
export function stopLocalEngine() {
|
|
600
|
+
if (engineChild) {
|
|
601
|
+
try { engineChild.kill(); } catch { /* ignore */ }
|
|
602
|
+
engineChild = null;
|
|
603
|
+
}
|
|
604
|
+
enginePort = 0;
|
|
605
|
+
activeLocalModelId = '';
|
|
606
|
+
activeBackend = '';
|
|
607
|
+
if (jobState.phase === 'running' || jobState.phase === 'starting') {
|
|
608
|
+
jobState = { phase: 'idle', modelId: '', backend: '', percent: 0, downloadedBytes: 0, totalBytes: 0, speedMbSec: 0, etaSec: null, message: '', error: '' };
|
|
609
|
+
}
|
|
610
|
+
kvSet('localLlmAutoStart', null);
|
|
611
|
+
emitStatus();
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
process.on('exit', () => {
|
|
615
|
+
if (engineChild) {
|
|
616
|
+
try { engineChild.kill(); } catch { /* ignore */ }
|
|
617
|
+
}
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
export async function startLocalEngine({ modelId, backend = '' } = {}) {
|
|
621
|
+
const model = LOCAL_MODEL_CATALOG.find((m) => m.id === modelId);
|
|
622
|
+
if (!model) throw new Error('Неизвестная модель локального ИИ');
|
|
623
|
+
const modelPath = path.join(MODELS_DIR, model.file);
|
|
624
|
+
if (!fs.existsSync(modelPath)) {
|
|
625
|
+
throw new Error(`Файл весов ${model.file} ещё не скачан`);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const hw = await detectLocalHardware();
|
|
629
|
+
const chosenBackend = backend || kvGet('localLlmBackend') || hw.recommendedBackend || 'cpu';
|
|
630
|
+
const binPath = await ensureLlamaServerBinary(chosenBackend);
|
|
631
|
+
|
|
632
|
+
if (engineChild) {
|
|
633
|
+
try { engineChild.kill(); } catch { /* ignore */ }
|
|
634
|
+
engineChild = null;
|
|
635
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const port = await findFreeLocalPort(18089);
|
|
639
|
+
const ngl = chosenBackend === 'cpu' ? '0' : '99';
|
|
640
|
+
const ctxSize = model.tier === 'ultra' || model.tier === 'high' ? '24576' : '16384';
|
|
641
|
+
const args = [
|
|
642
|
+
'-m', modelPath,
|
|
643
|
+
'--alias', model.id,
|
|
644
|
+
'--host', '127.0.0.1',
|
|
645
|
+
'--port', String(port),
|
|
646
|
+
'--jinja',
|
|
647
|
+
'-ngl', ngl,
|
|
648
|
+
'-c', ctxSize,
|
|
649
|
+
];
|
|
650
|
+
|
|
651
|
+
jobState = {
|
|
652
|
+
phase: 'starting',
|
|
653
|
+
modelId: model.id,
|
|
654
|
+
backend: chosenBackend,
|
|
655
|
+
percent: 100,
|
|
656
|
+
downloadedBytes: model.sizeBytes,
|
|
657
|
+
totalBytes: model.sizeBytes,
|
|
658
|
+
speedMbSec: 0,
|
|
659
|
+
etaSec: 0,
|
|
660
|
+
message: `Запуск локальной модели ${model.label} (${chosenBackend})…`,
|
|
661
|
+
error: '',
|
|
662
|
+
};
|
|
663
|
+
emitStatus();
|
|
664
|
+
|
|
665
|
+
const child = spawn(binPath, args, {
|
|
666
|
+
cwd: path.dirname(binPath),
|
|
667
|
+
stdio: 'ignore',
|
|
668
|
+
windowsHide: true,
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
let exitedEarly = false;
|
|
672
|
+
child.once('exit', () => {
|
|
673
|
+
exitedEarly = true;
|
|
674
|
+
if (engineChild === child) {
|
|
675
|
+
engineChild = null;
|
|
676
|
+
enginePort = 0;
|
|
677
|
+
activeLocalModelId = '';
|
|
678
|
+
emitStatus();
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
const healthUrl = `http://127.0.0.1:${port}/health`;
|
|
683
|
+
const deadline = Date.now() + 90000;
|
|
684
|
+
let ready = false;
|
|
685
|
+
while (Date.now() < deadline && !exitedEarly) {
|
|
686
|
+
try {
|
|
687
|
+
const r = await fetch(healthUrl, { signal: AbortSignal.timeout(1500) });
|
|
688
|
+
if (r.ok) { ready = true; break; }
|
|
689
|
+
} catch { /* loading weights into VRAM/UMA */ }
|
|
690
|
+
await new Promise((r) => setTimeout(r, 700));
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
if (!ready) {
|
|
694
|
+
try { child.kill(); } catch { /* ignore */ }
|
|
695
|
+
jobState = { ...jobState, phase: 'error', error: 'Локальный сервер llama-server не вышел на готовность (проверьте свободный объём памяти или выберите более лёгкую модель)' };
|
|
696
|
+
emitStatus();
|
|
697
|
+
throw new Error(jobState.error);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
engineChild = child;
|
|
701
|
+
enginePort = port;
|
|
702
|
+
activeLocalModelId = model.id;
|
|
703
|
+
activeBackend = chosenBackend;
|
|
704
|
+
kvSet('localLlmBackend', chosenBackend);
|
|
705
|
+
kvSet('localLlmAutoStart', { modelId: model.id, backend: chosenBackend });
|
|
706
|
+
|
|
707
|
+
jobState = {
|
|
708
|
+
phase: 'running',
|
|
709
|
+
modelId: model.id,
|
|
710
|
+
backend: chosenBackend,
|
|
711
|
+
percent: 100,
|
|
712
|
+
downloadedBytes: model.sizeBytes,
|
|
713
|
+
totalBytes: model.sizeBytes,
|
|
714
|
+
speedMbSec: 0,
|
|
715
|
+
etaSec: 0,
|
|
716
|
+
message: `Локальная модель ${model.label} запущена на 127.0.0.1:${port}`,
|
|
717
|
+
error: '',
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
if (onActivatedCallback) {
|
|
721
|
+
onActivatedCallback({
|
|
722
|
+
port,
|
|
723
|
+
modelId: model.id,
|
|
724
|
+
label: model.label,
|
|
725
|
+
backend: chosenBackend,
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
emitStatus();
|
|
729
|
+
return getLocalEngineQuickStatus();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export function startLocalModelInstall({ modelId, backend = '' } = {}) {
|
|
733
|
+
if (jobState.phase === 'engine' || jobState.phase === 'model' || jobState.phase === 'starting') {
|
|
734
|
+
throw new Error('Установка локальной модели уже выполняется');
|
|
735
|
+
}
|
|
736
|
+
const model = LOCAL_MODEL_CATALOG.find((m) => m.id === modelId);
|
|
737
|
+
if (!model) throw new Error('Выберите модель из каталога');
|
|
738
|
+
|
|
739
|
+
const ctrl = new AbortController();
|
|
740
|
+
installAbort = ctrl;
|
|
741
|
+
|
|
742
|
+
jobState = {
|
|
743
|
+
phase: 'engine',
|
|
744
|
+
modelId: model.id,
|
|
745
|
+
backend: backend || '',
|
|
746
|
+
percent: 0,
|
|
747
|
+
downloadedBytes: 0,
|
|
748
|
+
totalBytes: model.sizeBytes,
|
|
749
|
+
speedMbSec: 0,
|
|
750
|
+
etaSec: null,
|
|
751
|
+
message: 'Подготовка к установке локальной модели…',
|
|
752
|
+
error: '',
|
|
753
|
+
};
|
|
754
|
+
emitStatus();
|
|
755
|
+
|
|
756
|
+
(async () => {
|
|
757
|
+
try {
|
|
758
|
+
const hw = await detectLocalHardware({ refresh: true });
|
|
759
|
+
const chosenBackend = backend || hw.recommendedBackend || 'cpu';
|
|
760
|
+
jobState.backend = chosenBackend;
|
|
761
|
+
|
|
762
|
+
await ensureLlamaServerBinary(chosenBackend, { signal: ctrl.signal });
|
|
763
|
+
|
|
764
|
+
const destModelPath = path.join(MODELS_DIR, model.file);
|
|
765
|
+
const alreadyHasModel = fs.existsSync(destModelPath) && fs.statSync(destModelPath).size >= model.sizeBytes * 0.95;
|
|
766
|
+
if (!alreadyHasModel) {
|
|
767
|
+
if (hw.freeDiskGb < model.minDiskGb) {
|
|
768
|
+
throw new Error(`Недостаточно свободного места на диске: нужно ${model.minDiskGb} ГБ, доступно ${hw.freeDiskGb} ГБ`);
|
|
769
|
+
}
|
|
770
|
+
const hfUrl = `https://huggingface.co/${model.repo}/resolve/main/${model.file}?download=true`;
|
|
771
|
+
await downloadFileWithResume(hfUrl, destModelPath, {
|
|
772
|
+
signal: ctrl.signal,
|
|
773
|
+
label: model.file,
|
|
774
|
+
onProgress: ({ downloaded, totalBytes, speedMbSec }) => {
|
|
775
|
+
const tot = totalBytes || model.sizeBytes;
|
|
776
|
+
const remBytes = Math.max(0, tot - downloaded);
|
|
777
|
+
const etaSec = speedMbSec > 0.1 ? Math.round((remBytes / MB) / speedMbSec) : null;
|
|
778
|
+
jobState = {
|
|
779
|
+
phase: 'model',
|
|
780
|
+
modelId: model.id,
|
|
781
|
+
backend: chosenBackend,
|
|
782
|
+
percent: tot > 0 ? Math.min(99, Math.round((downloaded / tot) * 100)) : 0,
|
|
783
|
+
downloadedBytes: downloaded,
|
|
784
|
+
totalBytes: tot,
|
|
785
|
+
speedMbSec,
|
|
786
|
+
etaSec,
|
|
787
|
+
message: `Скачивание весов ${model.label}`,
|
|
788
|
+
error: '',
|
|
789
|
+
};
|
|
790
|
+
emitStatus();
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
await startLocalEngine({ modelId: model.id, backend: chosenBackend });
|
|
796
|
+
} catch (e) {
|
|
797
|
+
if (e.name === 'AbortError') {
|
|
798
|
+
jobState = { phase: 'idle', modelId: '', backend: '', percent: 0, downloadedBytes: 0, totalBytes: 0, speedMbSec: 0, etaSec: null, message: 'Загрузка приостановлена (можно продолжить с того же места)', error: '' };
|
|
799
|
+
} else {
|
|
800
|
+
jobState = { ...jobState, phase: 'error', error: e.message || String(e) };
|
|
801
|
+
}
|
|
802
|
+
emitStatus();
|
|
803
|
+
} finally {
|
|
804
|
+
if (installAbort === ctrl) installAbort = null;
|
|
805
|
+
}
|
|
806
|
+
})();
|
|
807
|
+
|
|
808
|
+
return getLocalEngineQuickStatus();
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
export function cancelLocalModelInstall() {
|
|
812
|
+
if (installAbort) {
|
|
813
|
+
installAbort.abort();
|
|
814
|
+
installAbort = null;
|
|
815
|
+
}
|
|
816
|
+
return getLocalEngineQuickStatus();
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
export function deleteLocalModel(modelId) {
|
|
820
|
+
const model = LOCAL_MODEL_CATALOG.find((m) => m.id === modelId);
|
|
821
|
+
if (!model) throw new Error('Модель не найдена');
|
|
822
|
+
if (activeLocalModelId === model.id) {
|
|
823
|
+
stopLocalEngine();
|
|
824
|
+
}
|
|
825
|
+
for (const p of [path.join(MODELS_DIR, model.file), path.join(MODELS_DIR, `${model.file}.part`)]) {
|
|
826
|
+
if (fs.existsSync(p)) {
|
|
827
|
+
try { fs.unlinkSync(p); } catch { /* ignore */ }
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
return { ok: true };
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
export function resumeSavedLocalEngineOnStartup() {
|
|
834
|
+
const saved = kvGet('localLlmAutoStart');
|
|
835
|
+
if (!saved?.modelId) return;
|
|
836
|
+
const model = LOCAL_MODEL_CATALOG.find((m) => m.id === saved.modelId);
|
|
837
|
+
if (!model) return;
|
|
838
|
+
const fullPath = path.join(MODELS_DIR, model.file);
|
|
839
|
+
if (!fs.existsSync(fullPath)) return;
|
|
840
|
+
setImmediate(() => {
|
|
841
|
+
startLocalEngine({ modelId: saved.modelId, backend: saved.backend || '' }).catch(() => {});
|
|
842
|
+
});
|
|
843
|
+
}
|