beast-agent 2.3.3 → 2.3.4

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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "beast-agent",
3
3
  "productName": "Beast Agent",
4
- "version": "2.3.3",
4
+ "version": "2.3.4",
5
5
  "description": "Ultra-fast local agent shell for Windows.",
6
6
  "author": "algokodcom (AlgoKod)",
7
7
  "license": "MIT",
package/src/agent/mem0.js CHANGED
@@ -101,11 +101,16 @@ function loadPipeline() {
101
101
  _pipeLoading = (async () => {
102
102
  try {
103
103
  const { pipeline, env } = require('@xenova/transformers');
104
+ const bus = require('./progressbus');
104
105
  const modelsDir = process.env.BEAST_MODELS_DIR || path.join(beastRoot(), 'models');
105
106
  fs.mkdirSync(modelsDir, { recursive: true });
106
107
  env.cacheDir = modelsDir;
107
108
  env.allowLocalModels = false;
108
- const p = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', { quantized: true });
109
+ const p = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2', {
110
+ quantized: true,
111
+ progress_callback: bus.fileProgressAggregator('emb'),
112
+ });
113
+ bus.emitInstallProgress('emb', { pct: 100 });
109
114
  _pipe = async (texts) => {
110
115
  const out = [];
111
116
  for (const t of texts) {
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ /* Kurulum progress bus — agent modülleri (whisper / mem0 / tools) yüzde üretir,
4
+ main abone olup renderer'a 'install-progress' agent:event olarak aktarır.
5
+ transformers.js progress_callback'i dosya bazlı geldiği için per-dosya
6
+ loaded/total toplamından genel yüzde hesaplayan aggregator da burada. */
7
+
8
+ const listeners = new Set();
9
+
10
+ function onInstallProgress(fn) {
11
+ if (typeof fn === 'function') listeners.add(fn);
12
+ return () => listeners.delete(fn);
13
+ }
14
+
15
+ function emitInstallProgress(id, data) {
16
+ for (const fn of listeners) {
17
+ try { fn(id, data); } catch {}
18
+ }
19
+ }
20
+
21
+ /* transformers.js benzeri dosya bazlı akışlar için: per-dosya loaded/total'dan
22
+ genel yüzde üreten progress_callback üretir. */
23
+ function fileProgressAggregator(id) {
24
+ const files = new Map(); // file -> { loaded, total }
25
+ return (d) => {
26
+ try {
27
+ const key = d && (d.file || d.name);
28
+ if (!key || typeof d.loaded !== 'number' || typeof d.total !== 'number' || d.total <= 0) return;
29
+ files.set(String(key), { loaded: d.loaded, total: d.total });
30
+ let loaded = 0;
31
+ let total = 0;
32
+ for (const v of files.values()) {
33
+ loaded += v.loaded;
34
+ total += v.total;
35
+ }
36
+ if (total > 0) emitInstallProgress(id, { pct: (loaded / total) * 100, loaded, total });
37
+ } catch {}
38
+ };
39
+ }
40
+
41
+ module.exports = { onInstallProgress, emitInstallProgress, fileProgressAggregator };
@@ -695,21 +695,25 @@ async function findSystemPython() {
695
695
  return null;
696
696
  }
697
697
 
698
- function httpsGetBuffer(url, redirectsLeft = 4, signal) {
698
+ function httpsGetBuffer(url, redirectsLeft = 4, signal, onProgress) {
699
699
  return new Promise((resolve, reject) => {
700
700
  const req = https.get(url, { headers: { 'User-Agent': 'BeastAgent/1.0 (+python bootstrap)' } }, (res) => {
701
701
  if ([301, 302, 303, 307, 308].includes(res.statusCode) && res.headers.location && redirectsLeft > 0) {
702
702
  res.resume();
703
- return resolve(httpsGetBuffer(new URL(res.headers.location, url).toString(), redirectsLeft - 1, signal));
703
+ return resolve(httpsGetBuffer(new URL(res.headers.location, url).toString(), redirectsLeft - 1, signal, onProgress));
704
704
  }
705
705
  if (res.statusCode !== 200) {
706
706
  res.resume();
707
707
  return reject(new Error(`indirme başarısız: HTTP ${res.statusCode}`));
708
708
  }
709
+ const total = parseInt(res.headers['content-length'] || '0', 10) || 0;
709
710
  const chunks = [];
710
711
  let size = 0;
711
712
  res.on('data', (c) => {
712
713
  size += c.length;
714
+ if (typeof onProgress === 'function') {
715
+ try { onProgress(size, total); } catch {}
716
+ }
713
717
  if (size > 80 * 1024 * 1024) {
714
718
  req.destroy(new Error('dosya çok büyük'));
715
719
  return;
@@ -731,7 +735,10 @@ async function installEmbeddedPython(signal) {
731
735
  const dest = path.join(beastAppDir(), 'py');
732
736
  fs.mkdirSync(dest, { recursive: true });
733
737
  const zipPath = path.join(os.tmpdir(), 'beast-py-embed.zip');
734
- const buf = await httpsGetBuffer(PYTHON_EMBED_URL, 4, signal);
738
+ const bus = require('./progressbus');
739
+ const buf = await httpsGetBuffer(PYTHON_EMBED_URL, 4, signal, (size, total) => {
740
+ if (total > 0) bus.emitInstallProgress('python', { pct: (size / total) * 100, loaded: size, total });
741
+ });
735
742
  fs.writeFileSync(zipPath, buf);
736
743
  const r = await runCommand(
737
744
  `Expand-Archive -LiteralPath "${zipPath}" -DestinationPath "${dest}" -Force`,
@@ -36,6 +36,83 @@ function waLogSafe(line) {
36
36
 
37
37
  const TRACK_CAP = 500;
38
38
 
39
+ /* ---------- TTS → WhatsApp sesli not (OGG/Opus) ---------- */
40
+
41
+ let _ffmpegPath = null;
42
+ function getFfmpegPath() {
43
+ if (_ffmpegPath !== null) return _ffmpegPath;
44
+ try { _ffmpegPath = require('ffmpeg-static') || ''; } catch { _ffmpegPath = ''; }
45
+ return _ffmpegPath;
46
+ }
47
+
48
+ function runFfmpeg(args, input) {
49
+ return new Promise((resolve, reject) => {
50
+ try {
51
+ const { spawn } = require('child_process');
52
+ const p = spawn(getFfmpegPath(), args, { windowsHide: true });
53
+ const out = [];
54
+ let err = '';
55
+ p.stdout.on('data', (c) => out.push(c));
56
+ p.stderr.on('data', (c) => { if (err.length < 400) err += c.toString(); });
57
+ p.on('error', reject);
58
+ p.on('close', (code) => {
59
+ if (code !== 0) return reject(new Error(`ffmpeg ${code}: ${err.slice(0, 200)}`));
60
+ resolve(Buffer.concat(out));
61
+ });
62
+ p.stdin.on('error', () => {});
63
+ p.stdin.write(input);
64
+ p.stdin.end();
65
+ } catch (e) {
66
+ reject(e);
67
+ }
68
+ });
69
+ }
70
+
71
+ /* Herhangi bir ses buffer'ı → OGG/Opus (WhatsApp sesli not formatı). Zaten OggS ise olduğu gibi döner. */
72
+ async function toVoiceOggOpus(buf) {
73
+ if (buf.length > 4 && buf.subarray(0, 4).toString('latin1') === 'OggS') return buf;
74
+ const ff = getFfmpegPath();
75
+ if (!ff) throw new Error('ffmpeg bulunamadı');
76
+ return runFfmpeg([
77
+ '-hide_banner', '-loglevel', 'error',
78
+ '-i', 'pipe:0',
79
+ '-c:a', 'libopus', '-b:a', '48k', '-ar', '24000', '-ac', '1',
80
+ '-application', 'voip', '-vbr', 'on',
81
+ '-f', 'ogg', 'pipe:1',
82
+ ], buf);
83
+ }
84
+
85
+ /* Sesli not dalga formu: 64 segmentlik normalize genlik histogramı (Uint8Array) */
86
+ async function audioWaveform(buf) {
87
+ try {
88
+ const ff = getFfmpegPath();
89
+ if (!ff) return undefined;
90
+ const pcm = await runFfmpeg([
91
+ '-hide_banner', '-loglevel', 'error',
92
+ '-i', 'pipe:0', '-f', 's16le', '-ar', '16000', '-ac', '1', 'pipe:1',
93
+ ], buf);
94
+ const SEG = 64;
95
+ const step = Math.max(1, Math.floor(pcm.length / 2 / SEG));
96
+ const wave = new Uint8Array(SEG);
97
+ let max = 1;
98
+ for (let i = 0; i < SEG; i++) {
99
+ let sum = 0;
100
+ const start = i * step;
101
+ for (let j = 0; j < step; j++) {
102
+ const off = (start + j) * 2;
103
+ if (off + 1 < pcm.length) sum += Math.abs(pcm.readInt16LE(off));
104
+ }
105
+ const v = Math.round(sum / step);
106
+ wave[i] = v;
107
+ if (v > max) max = v;
108
+ }
109
+ for (let i = 0; i < SEG; i++) wave[i] = Math.min(255, Math.round((wave[i] / max) * 255));
110
+ return wave;
111
+ } catch {
112
+ return undefined;
113
+ }
114
+ }
115
+
39
116
  /* lastKnownPresence etiketleri */
40
117
  const PRESENCE_LABELS = {
41
118
  available: 'çevrimiçi',
@@ -470,11 +547,29 @@ class WhatsAppBridge {
470
547
  return id ? { id } : true;
471
548
  }
472
549
 
473
- /* Sesli not olarak yanıtla (TTS çıktısı mp3 buffer) */
550
+ /* Sesli not olarak yanıtla (TTS çıktısı mp3 buffer → WhatsApp sesli not formatı OGG/Opus'a çevrilir;
551
+ ptt sesli notlar mp3 ile açılmaz, mutlaka audio/ogg; codecs=opus olmalı) */
474
552
  async sendAudio(jid, audioBuf) {
475
553
  if (!this.sock || !this.connected || !audioBuf) return false;
476
554
  try {
477
- const ret = await this.sock.sendMessage(jid, { audio: audioBuf, ptt: true, mimetype: 'audio/mpeg' });
555
+ let voice = audioBuf;
556
+ let mime = 'audio/mpeg';
557
+ let ptt = false;
558
+ let waveform;
559
+ try {
560
+ voice = await toVoiceOggOpus(audioBuf);
561
+ mime = 'audio/ogg; codecs=opus';
562
+ ptt = true;
563
+ waveform = await audioWaveform(voice);
564
+ } catch {
565
+ /* ffmpeg dönüşümü başarısızsa mp3'ü ptt'siz ses mesajı olarak dene */
566
+ voice = audioBuf;
567
+ mime = 'audio/mpeg';
568
+ ptt = false;
569
+ }
570
+ const msg = { audio: voice, ptt, mimetype: mime };
571
+ if (waveform) msg.waveform = waveform;
572
+ const ret = await this.sock.sendMessage(jid, msg);
478
573
  this._trackOutgoing(jid, '[sesli yanıt]', ret);
479
574
  return true;
480
575
  } catch (e) {
package/src/main.js CHANGED
@@ -655,11 +655,16 @@ async function ensureStt() {
655
655
  hf.env.cacheDir = modelsDir;
656
656
  hf.env.allowLocalModels = false;
657
657
  let lastErr = null;
658
+ const bus = require('./agent/progressbus');
658
659
  for (const dtype of ['q4f16', 'q4', 'q8']) {
659
660
  try {
660
- const p = await hf.pipeline('automatic-speech-recognition', wanted, { dtype });
661
+ const p = await hf.pipeline('automatic-speech-recognition', wanted, {
662
+ dtype,
663
+ progress_callback: bus.fileProgressAggregator('stt'),
664
+ });
661
665
  sttPipeline = p;
662
666
  sttModel = wanted;
667
+ bus.emitInstallProgress('stt', { pct: 100 });
663
668
  waLog('STT hazır: ' + wanted + ' (dtype ' + dtype + ')');
664
669
  return sttPipeline;
665
670
  } catch (e) {
@@ -676,6 +681,37 @@ async function ensureStt() {
676
681
  return sttLoading;
677
682
  }
678
683
 
684
+ /* ---------- Kurulum yüzde göstergesi: agent modüllerinden gelen progress
685
+ renderer'a 'install-progress' event'i olarak akıtılır (throttle'lı);
686
+ installPctState'i install:status da okur (sekme sonradan açılırsa ilk
687
+ çizimde yüzde zaten dolu gelir). ---------- */
688
+ const installPctState = {}; // id -> { pct, loaded, total, ts }
689
+ {
690
+ const bus = require('./agent/progressbus');
691
+ const lastSent = new Map(); // id -> { t, pct }
692
+ bus.onInstallProgress((id, d) => {
693
+ try {
694
+ if (!id || !d || typeof d.pct !== 'number' || !isFinite(d.pct)) return;
695
+ const cur = {
696
+ pct: Math.max(0, Math.min(100, Math.round(d.pct))),
697
+ loaded: d.loaded || 0,
698
+ total: d.total || 0,
699
+ ts: Date.now(),
700
+ };
701
+ installPctState[id] = cur;
702
+ const now = Date.now();
703
+ const prev = lastSent.get(id) || { t: 0, pct: -1 };
704
+ /* aynı yüzde tekrarını ve <500ms'lik küçük sıçramaları yut (event fırtınası olmasın) */
705
+ if (cur.pct === prev.pct && now - prev.t < 3000) return;
706
+ if (now - prev.t < 500 && Math.abs(cur.pct - prev.pct) < 2) return;
707
+ lastSent.set(id, { t: now, pct: cur.pct });
708
+ if (win && !win.isDestroyed()) {
709
+ win.webContents.send('agent:event', { type: 'install-progress', id, pct: cur.pct, loaded: cur.loaded, total: cur.total });
710
+ }
711
+ } catch {}
712
+ });
713
+ }
714
+
679
715
  /* ogg/opus/mp3 → mono 16kHz Float32 PCM (ffmpeg ile) */
680
716
  function decodeAudioToPcm16k(buf) {
681
717
  return new Promise((resolve, reject) => {
@@ -3918,7 +3954,18 @@ async function ocrRead({ image, lang = 'tur+eng' } = {}) {
3918
3954
  if (!worker) {
3919
3955
  const tessDir = path.join(APP_DIR, 'tessdata');
3920
3956
  fs.mkdirSync(tessDir, { recursive: true });
3921
- worker = await t.createWorker(langKey, 1, { cachePath: tessDir, logger: () => {} });
3957
+ const bus = require('./agent/progressbus');
3958
+ worker = await t.createWorker(langKey, 1, {
3959
+ cachePath: tessDir,
3960
+ logger: (m) => {
3961
+ try {
3962
+ /* dil verisi (.traineddata) inerken yüzde üret — OCR çalışma anını kirletme */
3963
+ if (m && /traineddata/i.test(String(m.status || '')) && typeof m.progress === 'number' && isFinite(m.progress)) {
3964
+ bus.emitInstallProgress('ocr', { pct: m.progress * 100 });
3965
+ }
3966
+ } catch {}
3967
+ },
3968
+ });
3922
3969
  _ocrWorkers.set(langKey, worker);
3923
3970
  }
3924
3971
  let input = image;
@@ -5839,6 +5886,20 @@ ipcMain.handle('stt:prefetch', () => {
5839
5886
  ipcMain.handle('install:status', async () => {
5840
5887
  const rows = [];
5841
5888
  const pkgOk = (id) => { try { require.resolve(id); return true; } catch { return false; } };
5889
+ /* progress bus'tan canlı yüzde (10 dk taze ise güvenilir sayılır) */
5890
+ const pctOf = (id) => {
5891
+ const s = installPctState[id];
5892
+ return s && Date.now() - s.ts < 10 * 60 * 1000 ? s : null;
5893
+ };
5894
+ const pctFields = (id) => {
5895
+ const s = pctOf(id);
5896
+ if (!s) return {};
5897
+ return {
5898
+ pct: s.pct,
5899
+ loadedMb: s.loaded ? Math.round(s.loaded / 1048576) : undefined,
5900
+ totalMb: s.total ? Math.round(s.total / 1048576) : undefined,
5901
+ };
5902
+ };
5842
5903
  const scanModel = (rel) => {
5843
5904
  const dir = path.join(APP_DIR, 'models', ...rel.split('/'));
5844
5905
  let files = [];
@@ -5870,14 +5931,28 @@ ipcMain.handle('install:status', async () => {
5870
5931
  else if (s.hasCfg && s.onnx >= 2 && s.tmp === 0) state = 'downloaded';
5871
5932
  else if (s.files.length) state = 'partial';
5872
5933
  else state = 'missing';
5873
- rows.push({ id: 'stt', name: 'STT modeli — whisper-large-v3-turbo', state, detail: sttEngineLabel(), mb: Math.round(s.bytes / 1048576) });
5934
+ rows.push({
5935
+ id: 'stt',
5936
+ name: 'STT modeli — whisper-large-v3-turbo',
5937
+ state,
5938
+ detail: sttEngineLabel(),
5939
+ mb: Math.round(s.bytes / 1048576),
5940
+ ...(['missing', 'partial', 'loading'].includes(state) ? pctFields('stt') : {}),
5941
+ });
5874
5942
  }
5875
5943
 
5876
5944
  /* 2) Embedding modeli (hafıza semantik arama) */
5877
5945
  {
5878
5946
  const s = scanModel('Xenova/all-MiniLM-L6-v2');
5879
5947
  const state = s.hasCfg && s.onnx >= 1 && s.tmp === 0 ? 'ok' : (s.files.length ? 'partial' : 'missing');
5880
- rows.push({ id: 'emb', name: 'Embedding modeli — all-MiniLM-L6-v2', state, detail: 'hafıza semantik arama', mb: Math.round(s.bytes / 1048576) });
5948
+ rows.push({
5949
+ id: 'emb',
5950
+ name: 'Embedding modeli — all-MiniLM-L6-v2',
5951
+ state,
5952
+ detail: 'hafıza semantik arama',
5953
+ mb: Math.round(s.bytes / 1048576),
5954
+ ...(['missing', 'partial', 'loading'].includes(state) ? pctFields('emb') : {}),
5955
+ });
5881
5956
  }
5882
5957
 
5883
5958
  /* 3) ffmpeg */
@@ -5890,7 +5965,17 @@ ipcMain.handle('install:status', async () => {
5890
5965
  rows.push({ id: 'ort', name: 'ONNX Runtime — model motoru', state: pkgOk('onnxruntime-node') ? 'ok' : 'missing', detail: 'npm paketi' });
5891
5966
 
5892
5967
  /* 5) OCR */
5893
- rows.push({ id: 'ocr', name: 'OCR — Tesseract (ekran okuma)', state: pkgOk('tesseract.js') ? 'ok' : 'missing', detail: pkgOk('tesseract.js') ? 'kurulu — dil verisi ilk kullanımda iner' : 'npm paketi eksik' });
5968
+ {
5969
+ const po = pctOf('ocr');
5970
+ const dl = pkgOk('tesseract.js') && po && po.pct < 100; // dil verisi şu an iniyor
5971
+ rows.push({
5972
+ id: 'ocr',
5973
+ name: 'OCR — Tesseract (ekran okuma)',
5974
+ state: dl ? 'loading' : (pkgOk('tesseract.js') ? 'ok' : 'missing'),
5975
+ detail: dl ? 'dil verisi iniyor — ilk OCR kullanımında' : (pkgOk('tesseract.js') ? 'kurulu — dil verisi ilk kullanımda iner' : 'npm paketi eksik'),
5976
+ ...(dl ? pctFields('ocr') : {}),
5977
+ });
5978
+ }
5894
5979
 
5895
5980
  /* 6) Python (opsiyonel — betikler) */
5896
5981
  let pyVer = '';
@@ -5900,7 +5985,15 @@ ipcMain.handle('install:status', async () => {
5900
5985
  if (pyVer) break;
5901
5986
  } catch {}
5902
5987
  }
5903
- rows.push({ id: 'python', name: 'Python — betikler / web arama', state: pyVer ? 'ok' : 'optional', detail: pyVer ? 'v' + pyVer : 'sistemde bulunamadı — opsiyonel' });
5988
+ if (!pyVer) {
5989
+ const ps = pctOf('python');
5990
+ if (ps && ps.pct < 100) { // gömülü python zip'i şu an iniyor
5991
+ rows.push({ id: 'python', name: 'Python — betikler / web arama', state: 'loading', detail: 'gömülü python indiriliyor', ...pctFields('python') });
5992
+ }
5993
+ }
5994
+ if (pyVer || !rows.some((r) => r.id === 'python')) {
5995
+ rows.push({ id: 'python', name: 'Python — betikler / web arama', state: pyVer ? 'ok' : 'optional', detail: pyVer ? 'v' + pyVer : 'sistemde bulunamadı — opsiyonel' });
5996
+ }
5904
5997
 
5905
5998
  /* 7) Edge TTS (bulut) */
5906
5999
  rows.push({ id: 'edge', name: 'Edge TTS — seslendirme', state: 'cloud', detail: 'bulut — kurulum gerekmez' });
@@ -2146,13 +2146,26 @@ async function renderInstallPane() {
2146
2146
  if (!el) return;
2147
2147
  if (!rows || !rows.length) { el.innerHTML = '<div class="sub">Durum alınamadı</div>'; return; }
2148
2148
  autoStart(rows);
2149
+ const hasPct = (r) => typeof r.pct === 'number' && ['missing', 'partial', 'loading'].includes(r.state);
2150
+ const pctLabel = (r) => {
2151
+ if (!hasPct(r)) return '<span id="inst-pct-' + r.id + '" style="display:none"></span>';
2152
+ const mb = (r.loadedMb && r.totalMb) ? ' · ' + r.loadedMb + '/' + r.totalMb + ' MB' : '';
2153
+ return '<span id="inst-pct-' + r.id + '" style="color:#d9a441;font-weight:700;font-size:11px;flex:none">%' + Math.round(r.pct) + mb + '</span>';
2154
+ };
2155
+ const bar = (r) => {
2156
+ if (!hasPct(r)) return '<div id="inst-bar-' + r.id + '" style="display:none"></div>';
2157
+ return '<div id="inst-bar-' + r.id + '" style="margin-top:7px;height:6px;border-radius:3px;background:var(--border);overflow:hidden">' +
2158
+ '<div id="inst-fill-' + r.id + '" style="height:100%;width:' + Math.max(2, Math.round(r.pct)) + '%;background:#d9a441;border-radius:3px;transition:width .4s ease"></div></div>';
2159
+ };
2149
2160
  el.innerHTML = rows.map((r) => (
2150
2161
  '<div style="display:flex;align-items:center;gap:10px;padding:9px 0;border-bottom:1px solid var(--border)">' +
2151
2162
  '<div style="flex:1;min-width:0">' +
2152
2163
  '<div style="font-size:13px">' + escapeHtml(r.name) + '</div>' +
2153
2164
  '<div class="sub" style="font-size:11px">' + escapeHtml(r.detail || '') + (r.mb ? ' · ' + r.mb + ' MB' : '') + '</div>' +
2165
+ bar(r) +
2154
2166
  '</div>' +
2155
2167
  badge(r.state) +
2168
+ pctLabel(r) +
2156
2169
  '</div>'
2157
2170
  )).join('');
2158
2171
  };
@@ -2168,6 +2181,26 @@ async function renderInstallPane() {
2168
2181
  refreshTimer = setInterval(refresh, 4000);
2169
2182
  }
2170
2183
 
2184
+ /* Kurulum progress event'i: sekme açıksa bar'ı anında güncelle (4 sn polling beklemeden).
2185
+ Sekme kapalıysa yok say — açılınca install:status zaten kayıtlı yüzeyi getirir. */
2186
+ function updateInstallPct(ev) {
2187
+ try {
2188
+ if (!ev || !ev.id) return;
2189
+ const pane = document.getElementById('tab-install');
2190
+ if (!pane || pane.hidden) return;
2191
+ const bar = document.getElementById('inst-bar-' + ev.id);
2192
+ const fill = document.getElementById('inst-fill-' + ev.id);
2193
+ const label = document.getElementById('inst-pct-' + ev.id);
2194
+ if (!bar || !fill || !label) return;
2195
+ const pct = Math.max(0, Math.min(100, Math.round(ev.pct || 0)));
2196
+ const mb = (ev.loaded && ev.total) ? ' · ' + Math.round(ev.loaded / 1048576) + '/' + Math.round(ev.total / 1048576) + ' MB' : '';
2197
+ bar.style.display = '';
2198
+ fill.style.width = Math.max(2, pct) + '%';
2199
+ label.style.display = '';
2200
+ label.textContent = '%' + pct + mb;
2201
+ } catch {}
2202
+ }
2203
+
2171
2204
  async function renderTtsPane() { const pane = $('#tab-tts');
2172
2205
  if (!pane) return;
2173
2206
  /* STT DURUMU: model indirildi mi / iniyor mu / hangi motor */
@@ -4672,6 +4705,7 @@ function onEvent(ev) {
4672
4705
  showApprovalCard(ev);
4673
4706
  return;
4674
4707
  }
4708
+ if (ev.type === 'install-progress') { updateInstallPct(ev); return; }
4675
4709
  if (ev.type === 'update') {
4676
4710
  if (ev.downloaded) toast(_t('up_downloaded') + ' (v' + (ev.version || '?') + ') — /update now');
4677
4711
  else if (ev.available && ev.version && ev.version !== ev.current) toast(_t('up_available') + ' (v' + ev.version + ')');