letfixit 0.1.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/.env.example +33 -0
- package/README.md +226 -0
- package/adapters/android/build.gradle +28 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/ChoreographerAdapter.kt +43 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/LogcatReader.kt +64 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/MemoryObserver.kt +46 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/OkHttpInterceptor.kt +53 -0
- package/adapters/android/src/main/kotlin/dev/perfanalyzer/PerfAnalyzer.kt +100 -0
- package/adapters/flutter/lib/perf_analyzer.dart +73 -0
- package/adapters/flutter/lib/src/frame_observer.dart +45 -0
- package/adapters/flutter/lib/src/network_observer.dart +72 -0
- package/adapters/flutter/lib/src/rebuild_observer.dart +57 -0
- package/adapters/flutter/lib/src/universal_event.dart +50 -0
- package/adapters/flutter/lib/src/vm_service_adapter.dart +81 -0
- package/adapters/flutter/pubspec.lock +184 -0
- package/adapters/flutter/pubspec.yaml +13 -0
- package/config/model.manifest.example.json +108 -0
- package/config/model.manifest.json +108 -0
- package/core/schema/universal_event.dart +63 -0
- package/dashboard/dist/assets/index-D5TCSsvB.js +107 -0
- package/dashboard/dist/index.html +118 -0
- package/package.json +62 -0
- package/scripts/encrypt-model.js +86 -0
- package/scripts/fill-manifest.js +67 -0
- package/scripts/load-env.js +20 -0
- package/scripts/model_crypto.js +14 -0
- package/scripts/setup-model.js +269 -0
- package/server/adb_bridge.js +201 -0
- package/server/adb_score.js +77 -0
- package/server/ai_proxy.js +373 -0
- package/server/analysis_engine.js +222 -0
- package/server/android_bridge.js +124 -0
- package/server/android_live.js +167 -0
- package/server/data/device_gpu_db.json +465 -0
- package/server/device_db.js +94 -0
- package/server/device_review.js +146 -0
- package/server/finding_generator.js +56 -0
- package/server/flutter_bridge.js +414 -0
- package/server/index.js +619 -0
- package/server/ios_bridge.js +181 -0
- package/server/model_config.js +54 -0
- package/server/report_template.js +169 -0
- package/server/session_manager.js +370 -0
- package/server/widget_gpu_classifier.js +77 -0
- package/server/widget_triage.js +78 -0
- package/setup.sh +160 -0
package/server/index.js
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
require('dotenv').config();
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const { WebSocketServer, WebSocket } = require('ws');
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { spawn } = require('child_process');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const readline = require('readline');
|
|
10
|
+
|
|
11
|
+
const flutterBridge = require('./flutter_bridge');
|
|
12
|
+
const androidBridge = require('./android_bridge');
|
|
13
|
+
const engine = require('./analysis_engine');
|
|
14
|
+
const deviceDb = require('./device_db');
|
|
15
|
+
const session = require('./session_manager');
|
|
16
|
+
const { classifyWidget } = require('./widget_gpu_classifier');
|
|
17
|
+
const { getModuleAnalysis, aiStatus } = require('./ai_proxy');
|
|
18
|
+
const deviceReview = require('./device_review');
|
|
19
|
+
const androidLive = require('./android_live');
|
|
20
|
+
const adb = require('./adb_bridge');
|
|
21
|
+
const model = require('./model_config');
|
|
22
|
+
|
|
23
|
+
const PORT = parseInt(process.env.DASHBOARD_PORT || '8081', 10);
|
|
24
|
+
const LOCAL_AI_PORT = parseInt(process.env.LOCAL_AI_PORT || '8080', 10);
|
|
25
|
+
const ENABLE_LOCAL_AI = process.env.ENABLE_LOCAL_AI !== 'false';
|
|
26
|
+
|
|
27
|
+
// llama-server is installed system-wide via: curl -LsSf https://llama.app/install.sh | sh
|
|
28
|
+
// Model weights: bin/ultron/ultron-n0-beta.gguf — installed by `npm run setup`.
|
|
29
|
+
const MODEL_PATH = model.MODEL_PATH;
|
|
30
|
+
|
|
31
|
+
// ── Terminal styling + branded startup ─────────────────────────────────────────
|
|
32
|
+
const C = { reset: '\x1b[0m', bold: '\x1b[1m', dim: '\x1b[2m', mag: '\x1b[95m', grn: '\x1b[92m', yel: '\x1b[93m', cyan: '\x1b[96m' };
|
|
33
|
+
const col = (x, s) => `${x}${s}${C.reset}`;
|
|
34
|
+
const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
|
|
35
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
36
|
+
|
|
37
|
+
function bannerBox(lines) {
|
|
38
|
+
const w = Math.max(...lines.map((l) => stripAnsi(l).length));
|
|
39
|
+
console.log(col(C.mag, ' ╔' + '═'.repeat(w + 2) + '╗'));
|
|
40
|
+
for (const l of lines) console.log(col(C.mag, ' ║ ') + l + ' '.repeat(w - stripAnsi(l).length) + col(C.mag, ' ║'));
|
|
41
|
+
console.log(col(C.mag, ' ╚' + '═'.repeat(w + 2) + '╝'));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function printWelcome() {
|
|
45
|
+
console.log('');
|
|
46
|
+
bannerBox([
|
|
47
|
+
'',
|
|
48
|
+
col(C.bold + C.mag, '✦ P A R I T Y F I X') + col(C.dim, ' v0.1.0'),
|
|
49
|
+
col(C.reset, 'AI-based realtime performance analysis & suggestions'),
|
|
50
|
+
col(C.dim, 'Flutter · Android · iOS powered by Ultron n0-beta'),
|
|
51
|
+
'',
|
|
52
|
+
]);
|
|
53
|
+
console.log('');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// "Ultron n0-beta initializing…" — starts the local model and waits briefly for
|
|
57
|
+
// it to answer, without blocking the whole boot on weight loading.
|
|
58
|
+
async function initUltron() {
|
|
59
|
+
if (!ENABLE_LOCAL_AI) {
|
|
60
|
+
console.log(' ' + col(C.dim, 'Ultron n0-beta is disabled (ENABLE_LOCAL_AI=false) — using cloud fallback if configured.\n'));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (!model.isReady()) {
|
|
64
|
+
console.log(' ' + col(C.yel, '⚠ Ultron n0-beta not installed.') + ' Run ' + col(C.cyan, 'npm run setup') + ' to download it.');
|
|
65
|
+
const s = aiStatus();
|
|
66
|
+
console.log(' ' + col(C.dim, s.anyAvailable ? 'A cloud fallback key is set — analysis will use it for now.\n' : 'No AI provider yet — findings will show without suggestions until setup.\n'));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
process.stdout.write(' ' + col(C.mag, '✦ Ultron n0-beta') + col(C.dim, ' initializing'));
|
|
71
|
+
startLlamafile();
|
|
72
|
+
// Poll health for a few seconds; don't hang the boot if weights are still loading.
|
|
73
|
+
let ok = false;
|
|
74
|
+
for (let i = 0; i < 8 && !ok; i++) { process.stdout.write(col(C.dim, '.')); ok = await isLlamaHealthy(); if (!ok) await sleep(500); }
|
|
75
|
+
process.stdout.write('\n');
|
|
76
|
+
if (ok) {
|
|
77
|
+
console.log(' ' + col(C.grn, '✓ ready') + col(C.dim, ` — ${model.MODEL_NAME} running locally on :${LOCAL_AI_PORT} (${model.sizeGB()} GB, private)`));
|
|
78
|
+
} else {
|
|
79
|
+
console.log(' ' + col(C.yel, '… still loading') + col(C.dim, ' — weights load in the background (first start can take ~20s).'));
|
|
80
|
+
}
|
|
81
|
+
console.log(' ' + col(C.dim, model.DISCLAIMER.split(' — ')[0] + ' — treat suggestions as hints.\n'));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── Interactive startup ───────────────────────────────────────────────────────
|
|
85
|
+
// On launch: choose framework → paste Flutter VM link → validate the connection
|
|
86
|
+
// before the dashboard starts. flutter_bridge.js reads process.env.FLUTTER_VM_URL
|
|
87
|
+
// lazily at connect() time, so setting it here is enough.
|
|
88
|
+
const ask = (rl, q) => new Promise((res) => rl.question(q, (a) => res(a)));
|
|
89
|
+
|
|
90
|
+
/** Accepts a bare port, an http(s):// VM URI, or a ws(s):// URL → normalized ws URL. */
|
|
91
|
+
function normalizeVMUrl(input) {
|
|
92
|
+
let s = (input || '').trim().replace(/^['"]|['"]$/g, '');
|
|
93
|
+
if (!s) return null;
|
|
94
|
+
if (/^\d+$/.test(s)) return `ws://127.0.0.1:${s}/ws`; // just a port
|
|
95
|
+
s = s.replace(/^http:/i, 'ws:').replace(/^https:/i, 'wss:'); // http → ws
|
|
96
|
+
if (!/^wss?:\/\//i.test(s)) s = 'ws://' + s; // bare host:port
|
|
97
|
+
if (!/\/ws$/.test(s)) s = s.endsWith('/') ? s + 'ws' : s + '/ws'; // ensure /ws
|
|
98
|
+
return s;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Opens a throwaway WS to the VM Service and calls getVersion to confirm it's live. */
|
|
102
|
+
function validateVM(url) {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
let ws, done = false;
|
|
105
|
+
const finish = (ok) => { if (done) return; done = true; try { ws && ws.close(); } catch (_) {} resolve(ok); };
|
|
106
|
+
try { ws = new WebSocket(url); } catch { return resolve(false); }
|
|
107
|
+
const timer = setTimeout(() => finish(false), 6000);
|
|
108
|
+
ws.on('open', () => ws.send(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'getVersion' })));
|
|
109
|
+
ws.on('message', (raw) => {
|
|
110
|
+
try { const m = JSON.parse(raw); if (m.id === 1) { clearTimeout(timer); finish(!!m.result); } } catch (_) {}
|
|
111
|
+
});
|
|
112
|
+
ws.on('error', () => { clearTimeout(timer); finish(false); });
|
|
113
|
+
ws.on('close', () => { clearTimeout(timer); finish(false); });
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function chooseFramework(rl) {
|
|
118
|
+
while (true) {
|
|
119
|
+
console.log(`
|
|
120
|
+
What do you want to analyze?
|
|
121
|
+
1) Flutter (VM Service — live dashboard)
|
|
122
|
+
2) Android native — live debug monitor (over adb, no SDK — live dashboard)
|
|
123
|
+
3) Connected Device Performance Test (Android / iOS — any app, iteration report)
|
|
124
|
+
4) Website (work in progress)`);
|
|
125
|
+
const a = (await ask(rl, ' → Select [1-4]: ')).trim().toLowerCase();
|
|
126
|
+
if (a === '1' || a === 'flutter') return 'flutter';
|
|
127
|
+
if (a === '2' || a === 'android-live' || a === 'android') return 'android-live';
|
|
128
|
+
if (a === '3' || a === 'device' || a === 'ios' || a === 'adb') return 'device';
|
|
129
|
+
if (a === '4' || a === 'website') { console.log(' ⚠️ Website support is a work in progress — please reselect.'); continue; }
|
|
130
|
+
console.log(' ⚠️ Invalid choice — enter 1, 2, 3, or 4.');
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Pick a device + package for the Android live monitor.
|
|
135
|
+
async function promptAndroidLive(rl) {
|
|
136
|
+
if (!(await adb.isAvailable())) {
|
|
137
|
+
console.log('\n ❌ `adb` not found. Install Android platform-tools (or set ADB_PATH in .env).');
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
const devices = await adb.listDevices();
|
|
141
|
+
if (devices.length === 0) {
|
|
142
|
+
console.log('\n ⚠️ No devices in `device` state. Enable USB debugging + authorize, then retry.');
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
let dev = devices[0];
|
|
146
|
+
if (devices.length > 1) {
|
|
147
|
+
console.log('\n Connected devices:');
|
|
148
|
+
devices.forEach((d, i) => console.log(` ${i + 1}) ${d.model} (${d.serial})`));
|
|
149
|
+
const pick = parseInt(await ask(rl, ` → Select device [1-${devices.length}]: `), 10);
|
|
150
|
+
dev = devices[pick - 1] || devices[0];
|
|
151
|
+
}
|
|
152
|
+
const fg = await adb.foregroundPackage(dev.serial);
|
|
153
|
+
const prompt = fg
|
|
154
|
+
? ` 📦 App package [${fg}]: `
|
|
155
|
+
: ' 📦 App package (e.g. com.example.app): ';
|
|
156
|
+
const pkg = ((await ask(rl, prompt)).trim() || fg || '').trim();
|
|
157
|
+
if (!pkg) { console.log(' ⚠️ No package — cancelled.'); return null; }
|
|
158
|
+
console.log(` ✅ Will stream ${pkg} from ${dev.model}. Open the dashboard and switch the platform toggle to Android.`);
|
|
159
|
+
return { serial: dev.serial, pkg };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function promptAndValidateVM(rl) {
|
|
163
|
+
while (true) {
|
|
164
|
+
console.log('\n Start your app with: flutter run --vm-service-port=8181 --disable-service-auth-codes');
|
|
165
|
+
const input = await ask(rl, ' 🔌 Paste the Flutter VM Service link (or just the port): ');
|
|
166
|
+
const url = normalizeVMUrl(input);
|
|
167
|
+
if (!url) { console.log(' ⚠️ Empty input — try again.'); continue; }
|
|
168
|
+
process.stdout.write(` ⏳ Checking ${url} ... `);
|
|
169
|
+
const ok = await validateVM(url);
|
|
170
|
+
if (ok) {
|
|
171
|
+
console.log('✅ connected');
|
|
172
|
+
process.env.FLUTTER_VM_URL = url;
|
|
173
|
+
return url;
|
|
174
|
+
}
|
|
175
|
+
console.log('❌ no response');
|
|
176
|
+
console.log(' Make sure the app is running and the link/port is correct, then try again.');
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function interactiveSetup() {
|
|
181
|
+
// Non-interactive (CI / background): fall back to env or default, don't block.
|
|
182
|
+
if (!process.stdin.isTTY) {
|
|
183
|
+
if (!process.env.FLUTTER_VM_URL) process.env.FLUTTER_VM_URL = 'ws://127.0.0.1:8181/ws';
|
|
184
|
+
console.log(` ↳ Non-interactive start — using ${process.env.FLUTTER_VM_URL}`);
|
|
185
|
+
return { mode: 'flutter' };
|
|
186
|
+
}
|
|
187
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
188
|
+
const framework = await chooseFramework(rl);
|
|
189
|
+
let result = { mode: framework };
|
|
190
|
+
if (framework === 'flutter') {
|
|
191
|
+
await promptAndValidateVM(rl);
|
|
192
|
+
} else if (framework === 'android-live') {
|
|
193
|
+
// Live streaming monitor — configured here, started once the server is up.
|
|
194
|
+
result.android = await promptAndroidLive(rl);
|
|
195
|
+
} else if (framework === 'device') {
|
|
196
|
+
// Self-contained review: runs iterations, writes an HTML report, opens it.
|
|
197
|
+
// Falls through afterwards so the dashboard also comes up and serves /reports.
|
|
198
|
+
await deviceReview.run((q) => ask(rl, q), { port: PORT }).catch((e) => console.error(' ❌ Device review failed:', e.message));
|
|
199
|
+
}
|
|
200
|
+
rl.close();
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── "exit" command listener ───────────────────────────────────────────────────
|
|
205
|
+
function startCommandListener() {
|
|
206
|
+
if (!process.stdin.isTTY) return;
|
|
207
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
208
|
+
console.log(' 💡 Type "exit" (or Ctrl+C) to stop the analyzer and local AI.\n');
|
|
209
|
+
rl.on('line', (line) => {
|
|
210
|
+
const cmd = line.trim().toLowerCase();
|
|
211
|
+
if (cmd === 'exit' || cmd === 'quit' || cmd === 'q') { rl.close(); shutdown(); }
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Nemotron local AI auto-start ──────────────────────────────────────────────
|
|
216
|
+
let llamaProc = null;
|
|
217
|
+
|
|
218
|
+
async function isLlamaHealthy() {
|
|
219
|
+
try {
|
|
220
|
+
const ctrl = new AbortController();
|
|
221
|
+
const t = setTimeout(() => ctrl.abort(), 1500);
|
|
222
|
+
const res = await fetch(`http://localhost:${LOCAL_AI_PORT}/health`, { signal: ctrl.signal });
|
|
223
|
+
clearTimeout(t);
|
|
224
|
+
return res.ok;
|
|
225
|
+
} catch (_) {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function startLlamafile() {
|
|
231
|
+
if (!ENABLE_LOCAL_AI) return;
|
|
232
|
+
|
|
233
|
+
if (!model.isReady()) {
|
|
234
|
+
// initUltron() already messaged the user; stay quiet here.
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Reuse an already-running model (e.g. after a server restart) instead of
|
|
239
|
+
// spawning a duplicate that would fail to bind the port.
|
|
240
|
+
if (await isLlamaHealthy()) return;
|
|
241
|
+
|
|
242
|
+
// Cap CPU usage: use at most half the cores (min 2) so realtime profiling and
|
|
243
|
+
// the dashboard stay responsive while the model runs. Overridable via LOCAL_AI_THREADS.
|
|
244
|
+
const threadCap = parseInt(process.env.LOCAL_AI_THREADS || '', 10) ||
|
|
245
|
+
Math.max(2, Math.floor(os.cpus().length / 2));
|
|
246
|
+
|
|
247
|
+
// 'llama' CLI is installed via: curl -LsSf https://llama.app/install.sh | sh
|
|
248
|
+
// shell:true resolves ~/.local/bin in PATH on all shells without hardcoding paths.
|
|
249
|
+
const cmd = `llama serve --model "${MODEL_PATH}" --port ${LOCAL_AI_PORT} --threads ${threadCap} --log-disable`;
|
|
250
|
+
try {
|
|
251
|
+
llamaProc = spawn(cmd, { stdio: 'ignore', detached: false, shell: true });
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.error(' ❌ llama serve failed to start:', err.message);
|
|
254
|
+
console.log(' ↳ Install llama.cpp: curl -LsSf https://llama.app/install.sh | sh');
|
|
255
|
+
console.log(' ↳ Continuing without local AI — cloud API fallback will be used.');
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
llamaProc.on('error', (err) => {
|
|
260
|
+
if (err.code === 'ENOENT') {
|
|
261
|
+
console.error(' ❌ llama not in PATH. Install: curl -LsSf https://llama.app/install.sh | sh');
|
|
262
|
+
} else {
|
|
263
|
+
console.error(` ❌ ${model.MODEL_NAME} error:`, err.message);
|
|
264
|
+
}
|
|
265
|
+
llamaProc = null;
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
llamaProc.on('exit', (code) => {
|
|
269
|
+
if (code !== 0 && code !== null) console.log(` ⚠️ ${model.MODEL_NAME} exited (code ${code})`);
|
|
270
|
+
llamaProc = null;
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function stopLlamafile() {
|
|
275
|
+
if (llamaProc) { llamaProc.kill(); llamaProc = null; }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ── Express + WebSocket ───────────────────────────────────────────────────────
|
|
279
|
+
const app = express();
|
|
280
|
+
app.use(express.json());
|
|
281
|
+
app.use(express.static(path.join(__dirname, '../dashboard/dist')));
|
|
282
|
+
// Generated device performance reports (self-contained HTML, opened in a browser).
|
|
283
|
+
app.use('/reports', express.static(deviceReview.REPORTS_DIR));
|
|
284
|
+
|
|
285
|
+
// Auto-detected device (OS/arch) from the connected Flutter VM. Precise
|
|
286
|
+
// GPU/RAM comes from the user picking a target device in the dashboard.
|
|
287
|
+
let detectedDevice = null;
|
|
288
|
+
|
|
289
|
+
// ── Device DB + module-test REST API ──────────────────────────────────────────
|
|
290
|
+
app.get('/api/devices', (_req, res) => {
|
|
291
|
+
res.json({
|
|
292
|
+
devices: deviceDb.listDevices(),
|
|
293
|
+
configs: deviceDb.listUniqueConfigs(),
|
|
294
|
+
detected: detectedDevice,
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
app.get('/api/session', (_req, res) => {
|
|
299
|
+
res.json({ session: session.snapshot(), detected: detectedDevice });
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// Live widget tree from the Flutter inspector, annotated with per-node GPU cost.
|
|
303
|
+
function normalizeTreeNode(node, depth = 0) {
|
|
304
|
+
if (!node || depth > 60) return null;
|
|
305
|
+
const rawName = node.widgetRuntimeType || node.description || node.type || 'Widget';
|
|
306
|
+
const name = String(rawName).split(/[\s(<[]/)[0] || 'Widget'; // strip "-[GlobalKey#..]" etc.
|
|
307
|
+
const info = classifyWidget(name);
|
|
308
|
+
const kids = Array.isArray(node.children) ? node.children : [];
|
|
309
|
+
return {
|
|
310
|
+
name,
|
|
311
|
+
gpuCost: info.cost,
|
|
312
|
+
riskyTiers: info.risky,
|
|
313
|
+
note: info.note,
|
|
314
|
+
children: kids.map((c) => normalizeTreeNode(c, depth + 1)).filter(Boolean),
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
app.get('/api/widget-tree', async (_req, res) => {
|
|
319
|
+
try {
|
|
320
|
+
const raw = await flutterBridge.getWidgetTree();
|
|
321
|
+
if (!raw) {
|
|
322
|
+
return res.json({ tree: null, hint: 'Widget inspector unavailable. Connect a Flutter app running in debug mode.' });
|
|
323
|
+
}
|
|
324
|
+
res.json({ tree: normalizeTreeNode(raw) });
|
|
325
|
+
} catch (err) {
|
|
326
|
+
res.json({ tree: null, hint: err.message });
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
app.post('/api/session/start', (req, res) => {
|
|
331
|
+
const { module, targetDeviceIds } = req.body || {};
|
|
332
|
+
session.start(module, Array.isArray(targetDeviceIds) ? targetDeviceIds : []);
|
|
333
|
+
res.json({ session: session.snapshot() });
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
app.post('/api/session/next-run', (_req, res) => {
|
|
337
|
+
session.nextRun();
|
|
338
|
+
res.json({ session: session.snapshot() });
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
app.post('/api/session/stop', (_req, res) => {
|
|
342
|
+
session.stop();
|
|
343
|
+
res.json({ session: session.snapshot() });
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
app.post('/api/session/reset', (_req, res) => {
|
|
347
|
+
session.reset();
|
|
348
|
+
res.json({ ok: true });
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// The single AI call — only fires after ≥5 completed runs.
|
|
352
|
+
app.post('/api/session/analyze', async (_req, res) => {
|
|
353
|
+
const s = session.getState();
|
|
354
|
+
if (!s || s.runs.length < s.requiredRuns) {
|
|
355
|
+
return res.status(400).json({ error: `Need at least ${s ? s.requiredRuns : 5} completed runs before analysis.` });
|
|
356
|
+
}
|
|
357
|
+
s.status = 'analyzing';
|
|
358
|
+
broadcast({ _type: 'session', session: sessionSnapshot() });
|
|
359
|
+
|
|
360
|
+
const agg = session.aggregate();
|
|
361
|
+
const ai = await getModuleAnalysis(agg);
|
|
362
|
+
s.analysis = { ...agg, ai };
|
|
363
|
+
s.status = 'done';
|
|
364
|
+
broadcast({ _type: 'session', session: sessionSnapshot() });
|
|
365
|
+
res.json({ analysis: s.analysis });
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
app.get('/api/ai-status', (_req, res) => {
|
|
369
|
+
const modelReady = model.isReady();
|
|
370
|
+
res.json({
|
|
371
|
+
localAI: {
|
|
372
|
+
enabled: ENABLE_LOCAL_AI,
|
|
373
|
+
running: llamaProc !== null,
|
|
374
|
+
modelReady,
|
|
375
|
+
modelSizeGB: model.sizeGB(),
|
|
376
|
+
port: LOCAL_AI_PORT,
|
|
377
|
+
model: model.MODEL_NAME,
|
|
378
|
+
disclaimer: model.DISCLAIMER,
|
|
379
|
+
hint: !modelReady ? 'Run `npm run setup` to download the model.' : null,
|
|
380
|
+
},
|
|
381
|
+
cloudAI: {
|
|
382
|
+
groq: !!process.env.GROQ_API_KEY,
|
|
383
|
+
gemini: !!process.env.GEMINI_API_KEY,
|
|
384
|
+
claude: !!process.env.CLAUDE_API_KEY,
|
|
385
|
+
},
|
|
386
|
+
anyAIAvailable: (ENABLE_LOCAL_AI && modelReady) ||
|
|
387
|
+
!!process.env.GROQ_API_KEY ||
|
|
388
|
+
!!process.env.GEMINI_API_KEY ||
|
|
389
|
+
!!process.env.CLAUDE_API_KEY,
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Instant, code-based advice for a single widget (used by the Widget Tree on
|
|
394
|
+
// tap). Zero CPU — pure classifier lookup. Pass ?ai=1 to also escalate to the
|
|
395
|
+
// local LLM for a deeper, device-agnostic suggestion.
|
|
396
|
+
app.get('/api/widget-advice', async (req, res) => {
|
|
397
|
+
const name = String(req.query.name || '').trim();
|
|
398
|
+
if (!name) return res.status(400).json({ error: 'name required' });
|
|
399
|
+
const info = classifyWidget(name);
|
|
400
|
+
const advice = {
|
|
401
|
+
widget: name,
|
|
402
|
+
gpuCost: info.cost,
|
|
403
|
+
riskyTiers: info.risky,
|
|
404
|
+
note: info.note,
|
|
405
|
+
source: 'code',
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
if (req.query.ai === '1' && info.cost !== 'low') {
|
|
409
|
+
try {
|
|
410
|
+
const { getAISuggestion } = require('./ai_proxy');
|
|
411
|
+
const ai = await getAISuggestion({
|
|
412
|
+
rule: 'widget_advice',
|
|
413
|
+
widget: name,
|
|
414
|
+
title: `${name}: ${info.cost} GPU cost widget`,
|
|
415
|
+
metric: { value: info.cost, unit: 'gpu-cost', budget: 'low' },
|
|
416
|
+
hypothesis: info.note,
|
|
417
|
+
});
|
|
418
|
+
if (ai) advice.ai = ai;
|
|
419
|
+
} catch (_) {}
|
|
420
|
+
}
|
|
421
|
+
res.json(advice);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// Deeper analysis of a single finding, triggered explicitly by the user
|
|
425
|
+
// ("Deep analyze" button). An LLM call here is intentional — it's user-driven,
|
|
426
|
+
// not per-finding automatic, so it doesn't hammer the CPU passively.
|
|
427
|
+
app.post('/api/deep-analyze', async (req, res) => {
|
|
428
|
+
const finding = req.body || {};
|
|
429
|
+
if (!finding.rule && !finding.title) return res.status(400).json({ error: 'finding required' });
|
|
430
|
+
try {
|
|
431
|
+
const { getAISuggestion } = require('./ai_proxy');
|
|
432
|
+
const ai = await getAISuggestion(finding);
|
|
433
|
+
if (!ai) return res.json({ ok: false, error: 'No AI provider available or all failed.' });
|
|
434
|
+
res.json({ ok: true, ...ai });
|
|
435
|
+
} catch (err) {
|
|
436
|
+
res.json({ ok: false, error: err.message });
|
|
437
|
+
}
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
app.get('*', (_req, res) => {
|
|
441
|
+
const index = path.join(__dirname, '../dashboard/dist/index.html');
|
|
442
|
+
res.sendFile(index, (err) => {
|
|
443
|
+
if (err) res.status(404).send('Dashboard not built. Run: npm run build');
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
const server = http.createServer(app);
|
|
448
|
+
const wss = new WebSocketServer({ server, path: '/ws' });
|
|
449
|
+
|
|
450
|
+
function broadcast(data) {
|
|
451
|
+
const msg = JSON.stringify(data);
|
|
452
|
+
wss.clients.forEach((c) => { if (c.readyState === 1) c.send(msg); });
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
function sessionSnapshot() {
|
|
456
|
+
return session.snapshot();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Track bridge states so new dashboard clients get current status immediately
|
|
460
|
+
const bridgeStatus = {
|
|
461
|
+
flutter: { connected: false, url: process.env.FLUTTER_VM_URL || 'ws://127.0.0.1:8181/ws' },
|
|
462
|
+
android: { connected: false },
|
|
463
|
+
};
|
|
464
|
+
|
|
465
|
+
wss.on('connection', (ws) => {
|
|
466
|
+
// Send current bridge status, detected device, and any active session to every
|
|
467
|
+
// new dashboard client right away.
|
|
468
|
+
ws.send(JSON.stringify({ _type: 'status', ...bridgeStatus, detected: detectedDevice }));
|
|
469
|
+
const snap = sessionSnapshot();
|
|
470
|
+
if (snap) ws.send(JSON.stringify({ _type: 'session', session: snap }));
|
|
471
|
+
|
|
472
|
+
ws.on('message', (raw) => {
|
|
473
|
+
try {
|
|
474
|
+
const event = JSON.parse(raw);
|
|
475
|
+
handleAppEvent(event);
|
|
476
|
+
} catch (_) {}
|
|
477
|
+
});
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// Rebuild events fire once per widget build (can be hundreds/sec) — far too
|
|
481
|
+
// noisy to stream raw. We tally them per widget and flush aggregated per-second
|
|
482
|
+
// counts to the dashboard below. Everything else streams live.
|
|
483
|
+
const _rebuildTally = new Map();
|
|
484
|
+
|
|
485
|
+
// Every incoming app event: (1) feed the active module-test session,
|
|
486
|
+
// (2) forward raw telemetry to the dashboard so the live panels update,
|
|
487
|
+
// (3) run the passive rule engine and broadcast any finding for the junk feed.
|
|
488
|
+
// (NO real-time AI — AI only runs in the module test flow.)
|
|
489
|
+
function handleAppEvent(event) {
|
|
490
|
+
session.ingest(event);
|
|
491
|
+
|
|
492
|
+
if (event.event === 'rebuild') {
|
|
493
|
+
const w = event.widget || 'Unknown';
|
|
494
|
+
_rebuildTally.set(w, (_rebuildTally.get(w) || 0) + 1);
|
|
495
|
+
} else if (event.event) {
|
|
496
|
+
// frame / memory / api / error / state → straight to the live panels
|
|
497
|
+
broadcast(event);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const finding = engine.processEvent(event);
|
|
501
|
+
if (finding) {
|
|
502
|
+
finding.aiProcessed = true; // no per-finding AI; card renders rule text only
|
|
503
|
+
broadcast(finding);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// Flush aggregated rebuild counts once per second so the Rebuild Tracker shows
|
|
508
|
+
// a true "rebuilds / second" rate instead of a flood of single events.
|
|
509
|
+
setInterval(() => {
|
|
510
|
+
if (_rebuildTally.size === 0) return;
|
|
511
|
+
for (const [widget, count] of _rebuildTally) {
|
|
512
|
+
broadcast({
|
|
513
|
+
platform: 'flutter',
|
|
514
|
+
event: 'rebuild',
|
|
515
|
+
timestamp: Date.now(),
|
|
516
|
+
widget,
|
|
517
|
+
severity: count > 30 ? 'critical' : count > 10 ? 'high' : count > 5 ? 'medium' : 'low',
|
|
518
|
+
metric: { value: count, unit: 'rebuilds/s', budget: 10 },
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
_rebuildTally.clear();
|
|
522
|
+
}, 1000).unref();
|
|
523
|
+
|
|
524
|
+
flutterBridge.on('event', (event) => handleAppEvent(event));
|
|
525
|
+
flutterBridge.on('device', (d) => {
|
|
526
|
+
detectedDevice = d;
|
|
527
|
+
broadcast({ _type: 'status', ...bridgeStatus, detected: detectedDevice });
|
|
528
|
+
console.log(` 📱 Detected: ${d.os || '?'} / ${d.arch || '?'}`);
|
|
529
|
+
});
|
|
530
|
+
flutterBridge.on('status', (s) => {
|
|
531
|
+
bridgeStatus.flutter = { connected: s.connected, url: s.url };
|
|
532
|
+
broadcast({ _type: 'status', ...bridgeStatus, detected: detectedDevice });
|
|
533
|
+
if (s.connected) console.log(' 📡 Flutter VM connected — data flowing to dashboard');
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
androidBridge.on('event', (event) => handleAppEvent(event));
|
|
537
|
+
androidBridge.on('status', (s) => {
|
|
538
|
+
bridgeStatus.android = { connected: s.connected };
|
|
539
|
+
broadcast({ _type: 'status', ...bridgeStatus, detected: detectedDevice });
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
// Android native live monitor (adb) — streams into the same pipeline.
|
|
543
|
+
androidLive.on('event', (event) => handleAppEvent(event));
|
|
544
|
+
androidLive.on('status', (s) => {
|
|
545
|
+
bridgeStatus.android = { connected: s.connected };
|
|
546
|
+
broadcast({ _type: 'status', ...bridgeStatus, detected: detectedDevice });
|
|
547
|
+
if (s.connected) console.log(' 📡 Android live monitor connected — data flowing to dashboard');
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// Broadcast module-test session state changes to all dashboard clients.
|
|
551
|
+
session.on('update', (snap) => broadcast({ _type: 'session', session: snap }));
|
|
552
|
+
|
|
553
|
+
// ── Error handler ─────────────────────────────────────────────────────────────
|
|
554
|
+
server.on('error', (err) => {
|
|
555
|
+
if (err.code === 'EADDRINUSE') {
|
|
556
|
+
console.error(`
|
|
557
|
+
❌ Port ${PORT} is already in use.
|
|
558
|
+
Kill the existing process first:
|
|
559
|
+
lsof -ti :${PORT} | xargs kill
|
|
560
|
+
Or set a different port in .env:
|
|
561
|
+
DASHBOARD_PORT=7001
|
|
562
|
+
`);
|
|
563
|
+
} else {
|
|
564
|
+
console.error(' ❌ Server error:', err.message);
|
|
565
|
+
}
|
|
566
|
+
process.exit(1);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// ── Startup ───────────────────────────────────────────────────────────────────
|
|
570
|
+
async function main() {
|
|
571
|
+
printWelcome();
|
|
572
|
+
// "Ultron n0-beta initializing…" — warms the local model up front (idempotent)
|
|
573
|
+
// so it's ready by the time analysis needs it, and owns the AI startup output.
|
|
574
|
+
await initUltron();
|
|
575
|
+
|
|
576
|
+
const setup = await interactiveSetup();
|
|
577
|
+
|
|
578
|
+
server.listen(PORT, () => {
|
|
579
|
+
console.log('');
|
|
580
|
+
console.log(` ${col(C.mag, '📊 Dashboard')} → ${col(C.cyan, `http://localhost:${PORT}`)}`);
|
|
581
|
+
if (setup && setup.mode === 'android-live') {
|
|
582
|
+
console.log(' ' + col(C.dim, '📡 Android live monitor starting — switch the dashboard platform toggle to Android.'));
|
|
583
|
+
} else if (!setup || setup.mode === 'flutter') {
|
|
584
|
+
console.log(' ' + col(C.dim, '⏳ Waiting for your app to connect…'));
|
|
585
|
+
}
|
|
586
|
+
console.log('');
|
|
587
|
+
|
|
588
|
+
flutterBridge.connect();
|
|
589
|
+
androidBridge.start();
|
|
590
|
+
|
|
591
|
+
// Start the Android native live monitor if the user chose it and picked an app.
|
|
592
|
+
if (setup && setup.mode === 'android-live' && setup.android) {
|
|
593
|
+
androidLive.start(setup.android.serial, setup.android.pkg).catch((e) =>
|
|
594
|
+
console.error(' ❌ Android live monitor failed:', e.message));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
startCommandListener();
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// ── Graceful shutdown (Ctrl+C / SIGTERM / "exit") ─────────────────────────────
|
|
602
|
+
let shuttingDown = false;
|
|
603
|
+
function shutdown() {
|
|
604
|
+
if (shuttingDown) return;
|
|
605
|
+
shuttingDown = true;
|
|
606
|
+
console.log('\n 👋 Stopping ParityFix — shutting down local AI and server...');
|
|
607
|
+
try { androidLive.stop(); } catch (_) {}
|
|
608
|
+
stopLlamafile();
|
|
609
|
+
server.close(() => {
|
|
610
|
+
console.log(' ✅ Stopped. Bye!');
|
|
611
|
+
process.exit(0);
|
|
612
|
+
});
|
|
613
|
+
// Safety net if server.close() hangs on lingering sockets.
|
|
614
|
+
setTimeout(() => process.exit(0), 3000).unref();
|
|
615
|
+
}
|
|
616
|
+
process.on('SIGINT', shutdown);
|
|
617
|
+
process.on('SIGTERM', shutdown);
|
|
618
|
+
|
|
619
|
+
main();
|