@swapai/core 0.2.2 → 0.3.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 +43 -0
- package/dist/chunk-AZVNSHXP.js +253 -0
- package/dist/chunk-AZVNSHXP.js.map +1 -0
- package/dist/classifiers-ui.d.ts +39 -0
- package/dist/classifiers-ui.js +9 -0
- package/dist/classifiers-ui.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +56 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.js +118 -1
- package/dist/index.js.map +1 -1
- package/package.json +8 -1
package/README.md
CHANGED
|
@@ -113,6 +113,49 @@ import { classifyWithReference } from "@swapai/core/effect";
|
|
|
113
113
|
const program = classifyWithReference(relevance, input, callReferenceEffect);
|
|
114
114
|
```
|
|
115
115
|
|
|
116
|
+
## Classifier monitor
|
|
117
|
+
|
|
118
|
+
Start the built-in local monitor against the same data directory as your
|
|
119
|
+
classifiers:
|
|
120
|
+
|
|
121
|
+
```sh
|
|
122
|
+
npx @swapai/core classifiers-ui --data-directory .swapai
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Then open `http://127.0.0.1:4789`. The page shows every stored classifier, its
|
|
126
|
+
retained and lifetime example counts, last measured held-out error, target
|
|
127
|
+
error, and whether it is loaded, training, or trained. The page refreshes every
|
|
128
|
+
three seconds. SwapAI never returns classification inputs or saved model paths
|
|
129
|
+
from the monitor API.
|
|
130
|
+
|
|
131
|
+
If you want an npm script in your application:
|
|
132
|
+
|
|
133
|
+
```json
|
|
134
|
+
{
|
|
135
|
+
"scripts": {
|
|
136
|
+
"swapai:classifiers": "swapai classifiers-ui"
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
npm run swapai:classifiers
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
The server binds to `127.0.0.1` by default. Use `--host`, `--port`, or the
|
|
146
|
+
`SWAPAI_DATA_DIRECTORY` environment variable when needed. Applications can
|
|
147
|
+
also start it programmatically:
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import { startClassifiersUi } from "@swapai/core/classifiers-ui";
|
|
151
|
+
|
|
152
|
+
const monitor = await startClassifiersUi({
|
|
153
|
+
dataDirectory: ".swapai",
|
|
154
|
+
host: "127.0.0.1",
|
|
155
|
+
port: 4789,
|
|
156
|
+
});
|
|
157
|
+
```
|
|
158
|
+
|
|
116
159
|
## Runtime
|
|
117
160
|
|
|
118
161
|
Needle runs locally. On first use SwapAI creates its private runtime under
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// src/classifiers-ui.ts
|
|
2
|
+
import { createServer } from "http";
|
|
3
|
+
import { resolve } from "path";
|
|
4
|
+
|
|
5
|
+
// src/status.ts
|
|
6
|
+
import { existsSync } from "fs";
|
|
7
|
+
import { createRequire } from "module";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
var { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
|
|
10
|
+
var DEFAULT_LIVE_WITHIN_MS = 1e4;
|
|
11
|
+
function readClassifierStatuses(options) {
|
|
12
|
+
const liveWithinMs = options.liveWithinMs ?? DEFAULT_LIVE_WITHIN_MS;
|
|
13
|
+
if (!Number.isSafeInteger(liveWithinMs) || liveWithinMs <= 0) {
|
|
14
|
+
throw new TypeError("liveWithinMs must be a positive integer");
|
|
15
|
+
}
|
|
16
|
+
const databasePath = join(options.dataDirectory, "swapai.sqlite");
|
|
17
|
+
if (!existsSync(databasePath)) return [];
|
|
18
|
+
const database = new DatabaseSync(databasePath, { readOnly: true });
|
|
19
|
+
try {
|
|
20
|
+
database.exec("PRAGMA busy_timeout = 5000");
|
|
21
|
+
if (!hasTable(database, "classifiers") || !hasTable(database, "generations")) {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const columns = tableColumns(database, "classifiers");
|
|
25
|
+
const errorColumn = columns.has("last_evaluated_error") ? "c.last_evaluated_error" : "NULL";
|
|
26
|
+
const evaluatedAtColumn = columns.has("last_evaluated_at") ? "c.last_evaluated_at" : "NULL";
|
|
27
|
+
const rows = database.prepare(`
|
|
28
|
+
SELECT
|
|
29
|
+
c.name,
|
|
30
|
+
c.config_json,
|
|
31
|
+
c.total_examples_logged,
|
|
32
|
+
c.new_examples_since_training,
|
|
33
|
+
c.training_attempts,
|
|
34
|
+
c.examples_used_for_training,
|
|
35
|
+
${errorColumn} AS last_evaluated_error,
|
|
36
|
+
${evaluatedAtColumn} AS last_evaluated_at,
|
|
37
|
+
c.total_local_classifications,
|
|
38
|
+
c.total_retests,
|
|
39
|
+
c.consecutive_retest_failures,
|
|
40
|
+
c.training_lease_owner,
|
|
41
|
+
c.training_lease_until,
|
|
42
|
+
c.updated_at,
|
|
43
|
+
g.trained,
|
|
44
|
+
g.needle_version,
|
|
45
|
+
(
|
|
46
|
+
SELECT COUNT(*)
|
|
47
|
+
FROM examples e
|
|
48
|
+
WHERE e.classifier_name = c.name
|
|
49
|
+
AND e.generation = c.active_generation
|
|
50
|
+
) AS retained_examples
|
|
51
|
+
FROM classifiers c
|
|
52
|
+
JOIN generations g
|
|
53
|
+
ON g.classifier_name = c.name
|
|
54
|
+
AND g.generation = c.active_generation
|
|
55
|
+
ORDER BY c.name COLLATE NOCASE, c.name
|
|
56
|
+
`).all().map((value) => value);
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
const liveAfter = now - liveWithinMs;
|
|
59
|
+
const runtimes = hasTable(database, "classifier_runtimes") ? database.prepare(`
|
|
60
|
+
SELECT classifier_name, runtime_id
|
|
61
|
+
FROM classifier_runtimes
|
|
62
|
+
WHERE heartbeat_at >= ?
|
|
63
|
+
`).all(liveAfter).map((value) => value) : [];
|
|
64
|
+
const liveByClassifier = /* @__PURE__ */ new Map();
|
|
65
|
+
for (const runtime of runtimes) {
|
|
66
|
+
const existing = liveByClassifier.get(runtime.classifier_name) ?? /* @__PURE__ */ new Set();
|
|
67
|
+
existing.add(runtime.runtime_id);
|
|
68
|
+
liveByClassifier.set(runtime.classifier_name, existing);
|
|
69
|
+
}
|
|
70
|
+
return rows.map((stored) => {
|
|
71
|
+
const config = parseConfig(stored.config_json);
|
|
72
|
+
const liveRuntimes = liveByClassifier.get(stored.name) ?? /* @__PURE__ */ new Set();
|
|
73
|
+
return {
|
|
74
|
+
name: stored.name,
|
|
75
|
+
resultType: config.resultType,
|
|
76
|
+
retainedExamples: stored.retained_examples,
|
|
77
|
+
totalExamplesLogged: stored.total_examples_logged,
|
|
78
|
+
newExamplesSinceTraining: stored.new_examples_since_training,
|
|
79
|
+
examplesUsedForLastTraining: stored.examples_used_for_training,
|
|
80
|
+
lastEvaluatedError: stored.last_evaluated_error,
|
|
81
|
+
lastEvaluatedAt: stored.last_evaluated_at,
|
|
82
|
+
acceptableError: config.acceptableError,
|
|
83
|
+
loaded: liveRuntimes.size > 0,
|
|
84
|
+
loadedProcessCount: liveRuntimes.size,
|
|
85
|
+
training: stored.training_lease_owner !== null && stored.training_lease_until !== null && stored.training_lease_until > now && liveRuntimes.has(stored.training_lease_owner),
|
|
86
|
+
trained: stored.trained === 1,
|
|
87
|
+
trainingAttempts: stored.training_attempts,
|
|
88
|
+
totalLocalClassifications: stored.total_local_classifications,
|
|
89
|
+
totalRetests: stored.total_retests,
|
|
90
|
+
consecutiveRetestFailures: stored.consecutive_retest_failures,
|
|
91
|
+
needleVersion: stored.needle_version,
|
|
92
|
+
updatedAt: stored.updated_at
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
} finally {
|
|
96
|
+
database.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function parseConfig(configJson) {
|
|
100
|
+
const config = JSON.parse(configJson);
|
|
101
|
+
const resultType = config.result?.type;
|
|
102
|
+
if (resultType !== "number" && resultType !== "boolean" && resultType !== "string") {
|
|
103
|
+
throw new Error("Stored classifier has an invalid result type");
|
|
104
|
+
}
|
|
105
|
+
const acceptableError = typeof config.acceptableError === "string" ? parsePercentage(config.acceptableError) : config.acceptableError;
|
|
106
|
+
if (typeof acceptableError !== "number" || !Number.isFinite(acceptableError) || acceptableError < 0 || acceptableError > 1) {
|
|
107
|
+
throw new Error("Stored classifier has an invalid acceptable error");
|
|
108
|
+
}
|
|
109
|
+
return { resultType, acceptableError };
|
|
110
|
+
}
|
|
111
|
+
function parsePercentage(value) {
|
|
112
|
+
const match = /^(\d+(?:\.\d+)?)%$/.exec(value);
|
|
113
|
+
return match === null ? Number.NaN : Number(match[1]) / 100;
|
|
114
|
+
}
|
|
115
|
+
function hasTable(database, name) {
|
|
116
|
+
return database.prepare(`
|
|
117
|
+
SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?
|
|
118
|
+
`).get(name) !== void 0;
|
|
119
|
+
}
|
|
120
|
+
function tableColumns(database, table) {
|
|
121
|
+
return new Set(
|
|
122
|
+
database.prepare(`PRAGMA table_info(${table})`).all().map(
|
|
123
|
+
(value) => value.name
|
|
124
|
+
)
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// src/classifiers-ui.ts
|
|
129
|
+
async function startClassifiersUi(options = {}) {
|
|
130
|
+
const dataDirectory = resolve(
|
|
131
|
+
options.dataDirectory ?? process.env.SWAPAI_DATA_DIRECTORY ?? ".swapai"
|
|
132
|
+
);
|
|
133
|
+
const host = options.host ?? "127.0.0.1";
|
|
134
|
+
const port = options.port ?? 4789;
|
|
135
|
+
if (!Number.isSafeInteger(port) || port < 0 || port > 65535) {
|
|
136
|
+
throw new TypeError("port must be a whole number between 0 and 65535");
|
|
137
|
+
}
|
|
138
|
+
const server = createServer((request, response) => {
|
|
139
|
+
setHeaders(response);
|
|
140
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
141
|
+
response.writeHead(405, { Allow: "GET, HEAD" });
|
|
142
|
+
response.end();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
|
|
146
|
+
if (pathname === "/health") {
|
|
147
|
+
sendJson(response, 200, { service: "swapai-classifiers-ui", status: "ok" }, request.method);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
if (pathname === "/api/classifiers") {
|
|
151
|
+
try {
|
|
152
|
+
sendJson(response, 200, {
|
|
153
|
+
generatedAt: Date.now(),
|
|
154
|
+
classifiers: readClassifierStatuses({ dataDirectory })
|
|
155
|
+
}, request.method);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
sendJson(response, 500, {
|
|
158
|
+
error: error instanceof Error ? error.message : "Could not read classifier status"
|
|
159
|
+
}, request.method);
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (pathname === "/") {
|
|
164
|
+
const body = request.method === "HEAD" ? void 0 : PAGE;
|
|
165
|
+
response.writeHead(200, {
|
|
166
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
167
|
+
"Content-Length": Buffer.byteLength(PAGE)
|
|
168
|
+
});
|
|
169
|
+
response.end(body);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
response.writeHead(404);
|
|
173
|
+
response.end();
|
|
174
|
+
});
|
|
175
|
+
await new Promise((resolveListen, reject) => {
|
|
176
|
+
server.once("error", reject);
|
|
177
|
+
server.listen(port, host, () => {
|
|
178
|
+
server.off("error", reject);
|
|
179
|
+
resolveListen();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
const address = server.address();
|
|
183
|
+
if (address === null || typeof address === "string") {
|
|
184
|
+
server.close();
|
|
185
|
+
throw new Error("Classifier monitor did not start on a TCP port");
|
|
186
|
+
}
|
|
187
|
+
const displayHost = host.includes(":") ? `[${host}]` : host;
|
|
188
|
+
return {
|
|
189
|
+
url: `http://${displayHost}:${address.port}/`,
|
|
190
|
+
close: () => new Promise((resolveClose, reject) => {
|
|
191
|
+
server.close((error) => error === void 0 ? resolveClose() : reject(error));
|
|
192
|
+
})
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
function setHeaders(response) {
|
|
196
|
+
response.setHeader("Cache-Control", "no-store");
|
|
197
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
198
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
199
|
+
response.setHeader(
|
|
200
|
+
"Content-Security-Policy",
|
|
201
|
+
"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'"
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
function sendJson(response, status, value, method) {
|
|
205
|
+
const serialized = JSON.stringify(value);
|
|
206
|
+
response.writeHead(status, {
|
|
207
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
208
|
+
"Content-Length": Buffer.byteLength(serialized)
|
|
209
|
+
});
|
|
210
|
+
response.end(method === "HEAD" ? void 0 : serialized);
|
|
211
|
+
}
|
|
212
|
+
var PAGE = String.raw`<!doctype html>
|
|
213
|
+
<html lang="en">
|
|
214
|
+
<head>
|
|
215
|
+
<meta charset="utf-8">
|
|
216
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
217
|
+
<title>SwapAI classifiers</title>
|
|
218
|
+
<style>
|
|
219
|
+
:root { color-scheme: dark; --bg:#0b0b0c; --panel:#121214; --line:#26262a; --muted:#929299; --text:#f5f5f4; --yellow:#f7e45c; --green:#62d692; --blue:#77b7ff; --red:#ff7c7c; }
|
|
220
|
+
* { box-sizing:border-box } body { margin:0; background:var(--bg); color:var(--text); font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
|
|
221
|
+
header { position:sticky; top:0; z-index:2; display:flex; align-items:center; justify-content:space-between; padding:18px 28px; background:rgba(11,11,12,.9); border-bottom:1px solid var(--line); backdrop-filter:blur(14px); }
|
|
222
|
+
.brand { display:flex; gap:12px; align-items:center; font-weight:700; font-size:16px } .mark { width:22px; height:22px; border-radius:50%; background:linear-gradient(135deg,#171719 22%,var(--yellow) 72%); box-shadow:0 0 20px #f7e45c33; }
|
|
223
|
+
.updated { color:var(--muted); font-size:12px } main { width:min(1180px,calc(100% - 40px)); margin:0 auto; padding:54px 0 80px; }
|
|
224
|
+
h1 { margin:0; font-size:clamp(30px,5vw,48px); letter-spacing:-.045em } .intro { color:var(--muted); font-size:17px; margin:8px 0 32px }
|
|
225
|
+
.summary { display:flex; gap:10px; margin-bottom:18px; flex-wrap:wrap } .summary span,.pill { border:1px solid var(--line); background:#171719; border-radius:999px; padding:5px 10px; }
|
|
226
|
+
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(330px,1fr)); gap:14px } .card { background:var(--panel); border:1px solid var(--line); border-radius:15px; padding:20px; box-shadow:0 12px 45px #0003; }
|
|
227
|
+
.card-head { display:flex; justify-content:space-between; gap:15px; align-items:flex-start; margin-bottom:20px } h2 { margin:0; font-size:18px; letter-spacing:-.02em; overflow-wrap:anywhere } .type { color:var(--muted); font-size:12px; margin-top:3px }
|
|
228
|
+
.states { display:flex; flex-wrap:wrap; gap:6px; justify-content:flex-end } .pill { padding:3px 8px; font-size:11px; color:var(--muted) } .pill.on { color:var(--green); border-color:#62d69255; background:#62d69210 } .pill.training { color:var(--blue); border-color:#77b7ff55; background:#77b7ff10 }
|
|
229
|
+
dl { display:grid; grid-template-columns:1fr auto; margin:0; gap:0 } dt,dd { margin:0; padding:11px 0; border-top:1px solid var(--line) } dt { color:var(--muted) } dd { font-variant-numeric:tabular-nums; font-weight:600; text-align:right } .good { color:var(--green) } .bad { color:var(--red) }
|
|
230
|
+
.bar { grid-column:1/-1; height:5px; padding:0; margin:-3px 0 10px; border:0; border-radius:5px; background:#252529; overflow:hidden } .bar i { display:block; height:100%; background:var(--yellow); border-radius:inherit }
|
|
231
|
+
.empty,.error { border:1px dashed var(--line); border-radius:15px; padding:42px; color:var(--muted); text-align:center } .error { color:var(--red) }
|
|
232
|
+
@media (max-width:600px) { header { padding:15px 18px } main { width:min(100% - 28px,1180px); padding-top:36px } .grid { grid-template-columns:1fr } }
|
|
233
|
+
</style>
|
|
234
|
+
</head>
|
|
235
|
+
<body>
|
|
236
|
+
<header><div class="brand"><span class="mark"></span>SwapAI</div><div class="updated" id="updated">Connecting…</div></header>
|
|
237
|
+
<main><h1>Classifiers</h1><p class="intro">Live training progress and measured accuracy from this SwapAI data directory.</p><div id="summary" class="summary"></div><div id="content"></div></main>
|
|
238
|
+
<script>
|
|
239
|
+
const content=document.querySelector('#content'),summary=document.querySelector('#summary'),updated=document.querySelector('#updated');
|
|
240
|
+
const esc=value=>String(value).replace(/[&<>"']/g,char=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[char]));
|
|
241
|
+
const pct=value=>value===null?'Not measured':new Intl.NumberFormat(undefined,{style:'percent',maximumFractionDigits:2}).format(value);
|
|
242
|
+
const count=value=>new Intl.NumberFormat().format(value);
|
|
243
|
+
async function refresh(){try{const response=await fetch('/api/classifiers',{cache:'no-store'});if(!response.ok)throw new Error('Status API returned '+response.status);const data=await response.json(),items=data.classifiers;updated.textContent='Updated '+new Date(data.generatedAt).toLocaleTimeString();summary.innerHTML='<span>'+count(items.length)+' classifiers</span><span>'+count(items.filter(x=>x.loaded).length)+' loaded</span><span>'+count(items.filter(x=>x.trained).length)+' trained</span><span>'+count(items.filter(x=>x.training).length)+' training</span>';if(items.length===0){content.innerHTML='<div class="empty">No classifiers have used this data directory yet.</div>';return}content.className='grid';content.innerHTML=items.map(item=>{const measured=item.lastEvaluatedError,within=measured!==null&&measured<=item.acceptableError;const progress=measured===null?0:Math.min(100,item.acceptableError===0?(measured===0?100:0):item.acceptableError/measured*100);return '<article class="card"><div class="card-head"><div><h2>'+esc(item.name)+'</h2><div class="type">'+esc(item.resultType)+' result · '+esc(item.needleVersion||'No model yet')+'</div></div><div class="states"><span class="pill '+(item.loaded?'on':'')+'">'+(item.loaded?'Loaded':'Not loaded')+(item.loadedProcessCount>1?' ×'+item.loadedProcessCount:'')+'</span><span class="pill '+(item.training?'training':'')+'">'+(item.training?'Training':'Idle')+'</span><span class="pill '+(item.trained?'on':'')+'">'+(item.trained?'Trained':'Learning')+'</span></div></div><dl><dt>Collected examples</dt><dd>'+count(item.retainedExamples)+'</dd><dt>Lifetime examples</dt><dd>'+count(item.totalExamplesLogged)+'</dd><dt>Last measured error</dt><dd class="'+(within?'good':measured===null?'':'bad')+'">'+pct(measured)+'</dd><dt>Target error</dt><dd>'+pct(item.acceptableError)+' or less</dd><div class="bar"><i style="width:'+progress+'%"></i></div><dt>Training attempts</dt><dd>'+count(item.trainingAttempts)+'</dd><dt>Local classifications</dt><dd>'+count(item.totalLocalClassifications)+'</dd></dl></article>'}).join('')}catch(error){updated.textContent='Disconnected';content.className='';content.innerHTML='<div class="error">'+esc(error.message||error)+'</div>'}}
|
|
244
|
+
refresh();setInterval(refresh,3000);
|
|
245
|
+
</script>
|
|
246
|
+
</body>
|
|
247
|
+
</html>`;
|
|
248
|
+
|
|
249
|
+
export {
|
|
250
|
+
readClassifierStatuses,
|
|
251
|
+
startClassifiersUi
|
|
252
|
+
};
|
|
253
|
+
//# sourceMappingURL=chunk-AZVNSHXP.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/classifiers-ui.ts","../src/status.ts"],"sourcesContent":["import { createServer, type ServerResponse } from \"node:http\";\nimport { resolve } from \"node:path\";\n\nimport { readClassifierStatuses } from \"./status.js\";\n\nexport { readClassifierStatuses } from \"./status.js\";\nexport type {\n ClassifierStatus,\n ReadClassifierStatusesOptions,\n} from \"./status.js\";\n\nexport interface StartClassifiersUiOptions {\n readonly dataDirectory?: string;\n readonly host?: string;\n readonly port?: number;\n}\n\nexport interface ClassifiersUiServer {\n readonly url: string;\n close(): Promise<void>;\n}\n\nexport async function startClassifiersUi(\n options: StartClassifiersUiOptions = {},\n): Promise<ClassifiersUiServer> {\n const dataDirectory = resolve(\n options.dataDirectory ?? process.env.SWAPAI_DATA_DIRECTORY ?? \".swapai\",\n );\n const host = options.host ?? \"127.0.0.1\";\n const port = options.port ?? 4789;\n if (!Number.isSafeInteger(port) || port < 0 || port > 65_535) {\n throw new TypeError(\"port must be a whole number between 0 and 65535\");\n }\n\n const server = createServer((request, response) => {\n setHeaders(response);\n if (request.method !== \"GET\" && request.method !== \"HEAD\") {\n response.writeHead(405, { Allow: \"GET, HEAD\" });\n response.end();\n return;\n }\n const pathname = new URL(request.url ?? \"/\", \"http://localhost\").pathname;\n if (pathname === \"/health\") {\n sendJson(response, 200, { service: \"swapai-classifiers-ui\", status: \"ok\" }, request.method);\n return;\n }\n if (pathname === \"/api/classifiers\") {\n try {\n sendJson(response, 200, {\n generatedAt: Date.now(),\n classifiers: readClassifierStatuses({ dataDirectory }),\n }, request.method);\n } catch (error) {\n sendJson(response, 500, {\n error: error instanceof Error ? error.message : \"Could not read classifier status\",\n }, request.method);\n }\n return;\n }\n if (pathname === \"/\") {\n const body = request.method === \"HEAD\" ? undefined : PAGE;\n response.writeHead(200, {\n \"Content-Type\": \"text/html; charset=utf-8\",\n \"Content-Length\": Buffer.byteLength(PAGE),\n });\n response.end(body);\n return;\n }\n response.writeHead(404);\n response.end();\n });\n\n await new Promise<void>((resolveListen, reject) => {\n server.once(\"error\", reject);\n server.listen(port, host, () => {\n server.off(\"error\", reject);\n resolveListen();\n });\n });\n const address = server.address();\n if (address === null || typeof address === \"string\") {\n server.close();\n throw new Error(\"Classifier monitor did not start on a TCP port\");\n }\n const displayHost = host.includes(\":\") ? `[${host}]` : host;\n return {\n url: `http://${displayHost}:${address.port}/`,\n close: () => new Promise<void>((resolveClose, reject) => {\n server.close((error) => error === undefined ? resolveClose() : reject(error));\n }),\n };\n}\n\nfunction setHeaders(response: ServerResponse): void {\n response.setHeader(\"Cache-Control\", \"no-store\");\n response.setHeader(\"X-Content-Type-Options\", \"nosniff\");\n response.setHeader(\"Referrer-Policy\", \"no-referrer\");\n response.setHeader(\n \"Content-Security-Policy\",\n \"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'\",\n );\n}\n\nfunction sendJson(\n response: ServerResponse,\n status: number,\n value: unknown,\n method: string | undefined,\n): void {\n const serialized = JSON.stringify(value);\n response.writeHead(status, {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Content-Length\": Buffer.byteLength(serialized),\n });\n response.end(method === \"HEAD\" ? undefined : serialized);\n}\n\nconst PAGE = String.raw`<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>SwapAI classifiers</title>\n <style>\n :root { color-scheme: dark; --bg:#0b0b0c; --panel:#121214; --line:#26262a; --muted:#929299; --text:#f5f5f4; --yellow:#f7e45c; --green:#62d692; --blue:#77b7ff; --red:#ff7c7c; }\n * { box-sizing:border-box } body { margin:0; background:var(--bg); color:var(--text); font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif; }\n header { position:sticky; top:0; z-index:2; display:flex; align-items:center; justify-content:space-between; padding:18px 28px; background:rgba(11,11,12,.9); border-bottom:1px solid var(--line); backdrop-filter:blur(14px); }\n .brand { display:flex; gap:12px; align-items:center; font-weight:700; font-size:16px } .mark { width:22px; height:22px; border-radius:50%; background:linear-gradient(135deg,#171719 22%,var(--yellow) 72%); box-shadow:0 0 20px #f7e45c33; }\n .updated { color:var(--muted); font-size:12px } main { width:min(1180px,calc(100% - 40px)); margin:0 auto; padding:54px 0 80px; }\n h1 { margin:0; font-size:clamp(30px,5vw,48px); letter-spacing:-.045em } .intro { color:var(--muted); font-size:17px; margin:8px 0 32px }\n .summary { display:flex; gap:10px; margin-bottom:18px; flex-wrap:wrap } .summary span,.pill { border:1px solid var(--line); background:#171719; border-radius:999px; padding:5px 10px; }\n .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(330px,1fr)); gap:14px } .card { background:var(--panel); border:1px solid var(--line); border-radius:15px; padding:20px; box-shadow:0 12px 45px #0003; }\n .card-head { display:flex; justify-content:space-between; gap:15px; align-items:flex-start; margin-bottom:20px } h2 { margin:0; font-size:18px; letter-spacing:-.02em; overflow-wrap:anywhere } .type { color:var(--muted); font-size:12px; margin-top:3px }\n .states { display:flex; flex-wrap:wrap; gap:6px; justify-content:flex-end } .pill { padding:3px 8px; font-size:11px; color:var(--muted) } .pill.on { color:var(--green); border-color:#62d69255; background:#62d69210 } .pill.training { color:var(--blue); border-color:#77b7ff55; background:#77b7ff10 }\n dl { display:grid; grid-template-columns:1fr auto; margin:0; gap:0 } dt,dd { margin:0; padding:11px 0; border-top:1px solid var(--line) } dt { color:var(--muted) } dd { font-variant-numeric:tabular-nums; font-weight:600; text-align:right } .good { color:var(--green) } .bad { color:var(--red) }\n .bar { grid-column:1/-1; height:5px; padding:0; margin:-3px 0 10px; border:0; border-radius:5px; background:#252529; overflow:hidden } .bar i { display:block; height:100%; background:var(--yellow); border-radius:inherit }\n .empty,.error { border:1px dashed var(--line); border-radius:15px; padding:42px; color:var(--muted); text-align:center } .error { color:var(--red) }\n @media (max-width:600px) { header { padding:15px 18px } main { width:min(100% - 28px,1180px); padding-top:36px } .grid { grid-template-columns:1fr } }\n </style>\n</head>\n<body>\n <header><div class=\"brand\"><span class=\"mark\"></span>SwapAI</div><div class=\"updated\" id=\"updated\">Connecting…</div></header>\n <main><h1>Classifiers</h1><p class=\"intro\">Live training progress and measured accuracy from this SwapAI data directory.</p><div id=\"summary\" class=\"summary\"></div><div id=\"content\"></div></main>\n <script>\n const content=document.querySelector('#content'),summary=document.querySelector('#summary'),updated=document.querySelector('#updated');\n const esc=value=>String(value).replace(/[&<>\"']/g,char=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[char]));\n const pct=value=>value===null?'Not measured':new Intl.NumberFormat(undefined,{style:'percent',maximumFractionDigits:2}).format(value);\n const count=value=>new Intl.NumberFormat().format(value);\n async function refresh(){try{const response=await fetch('/api/classifiers',{cache:'no-store'});if(!response.ok)throw new Error('Status API returned '+response.status);const data=await response.json(),items=data.classifiers;updated.textContent='Updated '+new Date(data.generatedAt).toLocaleTimeString();summary.innerHTML='<span>'+count(items.length)+' classifiers</span><span>'+count(items.filter(x=>x.loaded).length)+' loaded</span><span>'+count(items.filter(x=>x.trained).length)+' trained</span><span>'+count(items.filter(x=>x.training).length)+' training</span>';if(items.length===0){content.innerHTML='<div class=\"empty\">No classifiers have used this data directory yet.</div>';return}content.className='grid';content.innerHTML=items.map(item=>{const measured=item.lastEvaluatedError,within=measured!==null&&measured<=item.acceptableError;const progress=measured===null?0:Math.min(100,item.acceptableError===0?(measured===0?100:0):item.acceptableError/measured*100);return '<article class=\"card\"><div class=\"card-head\"><div><h2>'+esc(item.name)+'</h2><div class=\"type\">'+esc(item.resultType)+' result · '+esc(item.needleVersion||'No model yet')+'</div></div><div class=\"states\"><span class=\"pill '+(item.loaded?'on':'')+'\">'+(item.loaded?'Loaded':'Not loaded')+(item.loadedProcessCount>1?' ×'+item.loadedProcessCount:'')+'</span><span class=\"pill '+(item.training?'training':'')+'\">'+(item.training?'Training':'Idle')+'</span><span class=\"pill '+(item.trained?'on':'')+'\">'+(item.trained?'Trained':'Learning')+'</span></div></div><dl><dt>Collected examples</dt><dd>'+count(item.retainedExamples)+'</dd><dt>Lifetime examples</dt><dd>'+count(item.totalExamplesLogged)+'</dd><dt>Last measured error</dt><dd class=\"'+(within?'good':measured===null?'':'bad')+'\">'+pct(measured)+'</dd><dt>Target error</dt><dd>'+pct(item.acceptableError)+' or less</dd><div class=\"bar\"><i style=\"width:'+progress+'%\"></i></div><dt>Training attempts</dt><dd>'+count(item.trainingAttempts)+'</dd><dt>Local classifications</dt><dd>'+count(item.totalLocalClassifications)+'</dd></dl></article>'}).join('')}catch(error){updated.textContent='Disconnected';content.className='';content.innerHTML='<div class=\"error\">'+esc(error.message||error)+'</div>'}}\n refresh();setInterval(refresh,3000);\n </script>\n</body>\n</html>`;\n","import { existsSync } from \"node:fs\";\nimport { createRequire } from \"node:module\";\nimport { join } from \"node:path\";\nimport type { DatabaseSync as DatabaseSyncType } from \"node:sqlite\";\n\nconst { DatabaseSync } = createRequire(import.meta.url)(\"node:sqlite\") as typeof import(\"node:sqlite\");\n\nconst DEFAULT_LIVE_WITHIN_MS = 10_000;\n\nexport interface ReadClassifierStatusesOptions {\n readonly dataDirectory: string;\n readonly liveWithinMs?: number;\n}\n\nexport interface ClassifierStatus {\n readonly name: string;\n readonly resultType: \"number\" | \"boolean\" | \"string\";\n readonly retainedExamples: number;\n readonly totalExamplesLogged: number;\n readonly newExamplesSinceTraining: number;\n readonly examplesUsedForLastTraining: number;\n readonly lastEvaluatedError: number | null;\n readonly lastEvaluatedAt: number | null;\n readonly acceptableError: number;\n readonly loaded: boolean;\n readonly loadedProcessCount: number;\n readonly training: boolean;\n readonly trained: boolean;\n readonly trainingAttempts: number;\n readonly totalLocalClassifications: number;\n readonly totalRetests: number;\n readonly consecutiveRetestFailures: number;\n readonly needleVersion: string | null;\n readonly updatedAt: number;\n}\n\ninterface StatusRow {\n name: string;\n config_json: string;\n total_examples_logged: number;\n new_examples_since_training: number;\n training_attempts: number;\n examples_used_for_training: number;\n last_evaluated_error: number | null;\n last_evaluated_at: number | null;\n total_local_classifications: number;\n total_retests: number;\n consecutive_retest_failures: number;\n training_lease_owner: string | null;\n training_lease_until: number | null;\n updated_at: number;\n trained: number;\n needle_version: string | null;\n retained_examples: number;\n}\n\ninterface RuntimeRow {\n classifier_name: string;\n runtime_id: string;\n}\n\nexport function readClassifierStatuses(\n options: ReadClassifierStatusesOptions,\n): ClassifierStatus[] {\n const liveWithinMs = options.liveWithinMs ?? DEFAULT_LIVE_WITHIN_MS;\n if (!Number.isSafeInteger(liveWithinMs) || liveWithinMs <= 0) {\n throw new TypeError(\"liveWithinMs must be a positive integer\");\n }\n const databasePath = join(options.dataDirectory, \"swapai.sqlite\");\n if (!existsSync(databasePath)) return [];\n\n const database = new DatabaseSync(databasePath, { readOnly: true });\n try {\n database.exec(\"PRAGMA busy_timeout = 5000\");\n if (!hasTable(database, \"classifiers\") || !hasTable(database, \"generations\")) {\n return [];\n }\n const columns = tableColumns(database, \"classifiers\");\n const errorColumn = columns.has(\"last_evaluated_error\")\n ? \"c.last_evaluated_error\"\n : \"NULL\";\n const evaluatedAtColumn = columns.has(\"last_evaluated_at\")\n ? \"c.last_evaluated_at\"\n : \"NULL\";\n const rows = database.prepare(`\n SELECT\n c.name,\n c.config_json,\n c.total_examples_logged,\n c.new_examples_since_training,\n c.training_attempts,\n c.examples_used_for_training,\n ${errorColumn} AS last_evaluated_error,\n ${evaluatedAtColumn} AS last_evaluated_at,\n c.total_local_classifications,\n c.total_retests,\n c.consecutive_retest_failures,\n c.training_lease_owner,\n c.training_lease_until,\n c.updated_at,\n g.trained,\n g.needle_version,\n (\n SELECT COUNT(*)\n FROM examples e\n WHERE e.classifier_name = c.name\n AND e.generation = c.active_generation\n ) AS retained_examples\n FROM classifiers c\n JOIN generations g\n ON g.classifier_name = c.name\n AND g.generation = c.active_generation\n ORDER BY c.name COLLATE NOCASE, c.name\n `).all().map((value) => value as unknown as StatusRow);\n\n const now = Date.now();\n const liveAfter = now - liveWithinMs;\n const runtimes = hasTable(database, \"classifier_runtimes\")\n ? database.prepare(`\n SELECT classifier_name, runtime_id\n FROM classifier_runtimes\n WHERE heartbeat_at >= ?\n `).all(liveAfter).map((value) => value as unknown as RuntimeRow)\n : [];\n const liveByClassifier = new Map<string, Set<string>>();\n for (const runtime of runtimes) {\n const existing = liveByClassifier.get(runtime.classifier_name) ?? new Set<string>();\n existing.add(runtime.runtime_id);\n liveByClassifier.set(runtime.classifier_name, existing);\n }\n\n return rows.map((stored) => {\n const config = parseConfig(stored.config_json);\n const liveRuntimes = liveByClassifier.get(stored.name) ?? new Set<string>();\n return {\n name: stored.name,\n resultType: config.resultType,\n retainedExamples: stored.retained_examples,\n totalExamplesLogged: stored.total_examples_logged,\n newExamplesSinceTraining: stored.new_examples_since_training,\n examplesUsedForLastTraining: stored.examples_used_for_training,\n lastEvaluatedError: stored.last_evaluated_error,\n lastEvaluatedAt: stored.last_evaluated_at,\n acceptableError: config.acceptableError,\n loaded: liveRuntimes.size > 0,\n loadedProcessCount: liveRuntimes.size,\n training:\n stored.training_lease_owner !== null &&\n stored.training_lease_until !== null &&\n stored.training_lease_until > now &&\n liveRuntimes.has(stored.training_lease_owner),\n trained: stored.trained === 1,\n trainingAttempts: stored.training_attempts,\n totalLocalClassifications: stored.total_local_classifications,\n totalRetests: stored.total_retests,\n consecutiveRetestFailures: stored.consecutive_retest_failures,\n needleVersion: stored.needle_version,\n updatedAt: stored.updated_at,\n };\n });\n } finally {\n database.close();\n }\n}\n\nfunction parseConfig(configJson: string): {\n resultType: \"number\" | \"boolean\" | \"string\";\n acceptableError: number;\n} {\n const config = JSON.parse(configJson) as {\n result?: { type?: unknown };\n acceptableError?: unknown;\n };\n const resultType = config.result?.type;\n if (resultType !== \"number\" && resultType !== \"boolean\" && resultType !== \"string\") {\n throw new Error(\"Stored classifier has an invalid result type\");\n }\n const acceptableError = typeof config.acceptableError === \"string\"\n ? parsePercentage(config.acceptableError)\n : config.acceptableError;\n if (\n typeof acceptableError !== \"number\" ||\n !Number.isFinite(acceptableError) ||\n acceptableError < 0 ||\n acceptableError > 1\n ) {\n throw new Error(\"Stored classifier has an invalid acceptable error\");\n }\n return { resultType, acceptableError };\n}\n\nfunction parsePercentage(value: string): number {\n const match = /^(\\d+(?:\\.\\d+)?)%$/.exec(value);\n return match === null ? Number.NaN : Number(match[1]) / 100;\n}\n\nfunction hasTable(database: DatabaseSyncType, name: string): boolean {\n return database.prepare(`\n SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?\n `).get(name) !== undefined;\n}\n\nfunction tableColumns(database: DatabaseSyncType, table: string): Set<string> {\n return new Set(\n database.prepare(`PRAGMA table_info(${table})`).all().map((value) =>\n (value as { name: string }).name,\n ),\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
interface ReadClassifierStatusesOptions {
|
|
2
|
+
readonly dataDirectory: string;
|
|
3
|
+
readonly liveWithinMs?: number;
|
|
4
|
+
}
|
|
5
|
+
interface ClassifierStatus {
|
|
6
|
+
readonly name: string;
|
|
7
|
+
readonly resultType: "number" | "boolean" | "string";
|
|
8
|
+
readonly retainedExamples: number;
|
|
9
|
+
readonly totalExamplesLogged: number;
|
|
10
|
+
readonly newExamplesSinceTraining: number;
|
|
11
|
+
readonly examplesUsedForLastTraining: number;
|
|
12
|
+
readonly lastEvaluatedError: number | null;
|
|
13
|
+
readonly lastEvaluatedAt: number | null;
|
|
14
|
+
readonly acceptableError: number;
|
|
15
|
+
readonly loaded: boolean;
|
|
16
|
+
readonly loadedProcessCount: number;
|
|
17
|
+
readonly training: boolean;
|
|
18
|
+
readonly trained: boolean;
|
|
19
|
+
readonly trainingAttempts: number;
|
|
20
|
+
readonly totalLocalClassifications: number;
|
|
21
|
+
readonly totalRetests: number;
|
|
22
|
+
readonly consecutiveRetestFailures: number;
|
|
23
|
+
readonly needleVersion: string | null;
|
|
24
|
+
readonly updatedAt: number;
|
|
25
|
+
}
|
|
26
|
+
declare function readClassifierStatuses(options: ReadClassifierStatusesOptions): ClassifierStatus[];
|
|
27
|
+
|
|
28
|
+
interface StartClassifiersUiOptions {
|
|
29
|
+
readonly dataDirectory?: string;
|
|
30
|
+
readonly host?: string;
|
|
31
|
+
readonly port?: number;
|
|
32
|
+
}
|
|
33
|
+
interface ClassifiersUiServer {
|
|
34
|
+
readonly url: string;
|
|
35
|
+
close(): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
declare function startClassifiersUi(options?: StartClassifiersUiOptions): Promise<ClassifiersUiServer>;
|
|
38
|
+
|
|
39
|
+
export { type ClassifierStatus, type ClassifiersUiServer, type ReadClassifierStatusesOptions, type StartClassifiersUiOptions, readClassifierStatuses, startClassifiersUi };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
startClassifiersUi
|
|
4
|
+
} from "./chunk-AZVNSHXP.js";
|
|
5
|
+
|
|
6
|
+
// src/cli.ts
|
|
7
|
+
var args = process.argv.slice(2);
|
|
8
|
+
var command = args.shift();
|
|
9
|
+
if (command === "--help" || command === "-h" || command === void 0) {
|
|
10
|
+
printHelp();
|
|
11
|
+
process.exit(command === void 0 ? 1 : 0);
|
|
12
|
+
}
|
|
13
|
+
if (command !== "classifiers-ui") {
|
|
14
|
+
console.error(`Unknown command: ${command}`);
|
|
15
|
+
printHelp();
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
var options = parseOptions(args);
|
|
19
|
+
var server = await startClassifiersUi(options);
|
|
20
|
+
console.log(`SwapAI classifier monitor: ${server.url}`);
|
|
21
|
+
console.log(`Data directory: ${options.dataDirectory ?? process.env.SWAPAI_DATA_DIRECTORY ?? ".swapai"}`);
|
|
22
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
23
|
+
process.once(signal, () => {
|
|
24
|
+
void server.close().finally(() => process.exit(0));
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function parseOptions(values) {
|
|
28
|
+
const options2 = {};
|
|
29
|
+
for (let index = 0; index < values.length; index += 1) {
|
|
30
|
+
const flag = values[index];
|
|
31
|
+
if (flag === "--help" || flag === "-h") {
|
|
32
|
+
printHelp();
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
const value = values[index + 1];
|
|
36
|
+
if (value === void 0) throw new TypeError(`${flag} needs a value`);
|
|
37
|
+
if (flag === "--data-directory") options2.dataDirectory = value;
|
|
38
|
+
else if (flag === "--host") options2.host = value;
|
|
39
|
+
else if (flag === "--port") options2.port = Number(value);
|
|
40
|
+
else throw new TypeError(`Unknown option: ${flag}`);
|
|
41
|
+
index += 1;
|
|
42
|
+
}
|
|
43
|
+
return options2;
|
|
44
|
+
}
|
|
45
|
+
function printHelp() {
|
|
46
|
+
console.log(`Usage: swapai classifiers-ui [options]
|
|
47
|
+
|
|
48
|
+
Starts the local SwapAI classifier monitor.
|
|
49
|
+
|
|
50
|
+
Options:
|
|
51
|
+
--data-directory <path> SwapAI data directory (default: .swapai)
|
|
52
|
+
--host <host> Listen host (default: 127.0.0.1)
|
|
53
|
+
--port <port> Listen port (default: 4789)
|
|
54
|
+
-h, --help Show this help`);
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { startClassifiersUi } from \"./classifiers-ui.js\";\n\nconst args = process.argv.slice(2);\nconst command = args.shift();\n\nif (command === \"--help\" || command === \"-h\" || command === undefined) {\n printHelp();\n process.exit(command === undefined ? 1 : 0);\n}\nif (command !== \"classifiers-ui\") {\n console.error(`Unknown command: ${command}`);\n printHelp();\n process.exit(1);\n}\n\nconst options = parseOptions(args);\nconst server = await startClassifiersUi(options);\nconsole.log(`SwapAI classifier monitor: ${server.url}`);\nconsole.log(`Data directory: ${options.dataDirectory ?? process.env.SWAPAI_DATA_DIRECTORY ?? \".swapai\"}`);\n\nfor (const signal of [\"SIGINT\", \"SIGTERM\"] as const) {\n process.once(signal, () => {\n void server.close().finally(() => process.exit(0));\n });\n}\n\nfunction parseOptions(values: string[]): {\n dataDirectory?: string;\n host?: string;\n port?: number;\n} {\n const options: { dataDirectory?: string; host?: string; port?: number } = {};\n for (let index = 0; index < values.length; index += 1) {\n const flag = values[index];\n if (flag === \"--help\" || flag === \"-h\") {\n printHelp();\n process.exit(0);\n }\n const value = values[index + 1];\n if (value === undefined) throw new TypeError(`${flag} needs a value`);\n if (flag === \"--data-directory\") options.dataDirectory = value;\n else if (flag === \"--host\") options.host = value;\n else if (flag === \"--port\") options.port = Number(value);\n else throw new TypeError(`Unknown option: ${flag}`);\n index += 1;\n }\n return options;\n}\n\nfunction printHelp(): void {\n console.log(`Usage: swapai classifiers-ui [options]\n\nStarts the local SwapAI classifier monitor.\n\nOptions:\n --data-directory <path> SwapAI data directory (default: .swapai)\n --host <host> Listen host (default: 127.0.0.1)\n --port <port> Listen port (default: 4789)\n -h, --help Show this help`);\n}\n"],"mappings":";;;;;;AAGA,IAAM,OAAO,QAAQ,KAAK,MAAM,CAAC;AACjC,IAAM,UAAU,KAAK,MAAM;AAE3B,IAAI,YAAY,YAAY,YAAY,QAAQ,YAAY,QAAW;AACrE,YAAU;AACV,UAAQ,KAAK,YAAY,SAAY,IAAI,CAAC;AAC5C;AACA,IAAI,YAAY,kBAAkB;AAChC,UAAQ,MAAM,oBAAoB,OAAO,EAAE;AAC3C,YAAU;AACV,UAAQ,KAAK,CAAC;AAChB;AAEA,IAAM,UAAU,aAAa,IAAI;AACjC,IAAM,SAAS,MAAM,mBAAmB,OAAO;AAC/C,QAAQ,IAAI,8BAA8B,OAAO,GAAG,EAAE;AACtD,QAAQ,IAAI,mBAAmB,QAAQ,iBAAiB,QAAQ,IAAI,yBAAyB,SAAS,EAAE;AAExG,WAAW,UAAU,CAAC,UAAU,SAAS,GAAY;AACnD,UAAQ,KAAK,QAAQ,MAAM;AACzB,SAAK,OAAO,MAAM,EAAE,QAAQ,MAAM,QAAQ,KAAK,CAAC,CAAC;AAAA,EACnD,CAAC;AACH;AAEA,SAAS,aAAa,QAIpB;AACA,QAAMA,WAAoE,CAAC;AAC3E,WAAS,QAAQ,GAAG,QAAQ,OAAO,QAAQ,SAAS,GAAG;AACrD,UAAM,OAAO,OAAO,KAAK;AACzB,QAAI,SAAS,YAAY,SAAS,MAAM;AACtC,gBAAU;AACV,cAAQ,KAAK,CAAC;AAAA,IAChB;AACA,UAAM,QAAQ,OAAO,QAAQ,CAAC;AAC9B,QAAI,UAAU,OAAW,OAAM,IAAI,UAAU,GAAG,IAAI,gBAAgB;AACpE,QAAI,SAAS,mBAAoB,CAAAA,SAAQ,gBAAgB;AAAA,aAChD,SAAS,SAAU,CAAAA,SAAQ,OAAO;AAAA,aAClC,SAAS,SAAU,CAAAA,SAAQ,OAAO,OAAO,KAAK;AAAA,QAClD,OAAM,IAAI,UAAU,mBAAmB,IAAI,EAAE;AAClD,aAAS;AAAA,EACX;AACA,SAAOA;AACT;AAEA,SAAS,YAAkB;AACzB,UAAQ,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,0CAQ4B;AAC1C;","names":["options"]}
|