@polderlabs/bizar 4.4.12 → 4.5.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/bizar-dash/CHANGELOG.md +37 -276
- package/bizar-dash/dist/assets/main-CDFKHzBg.css +1 -0
- package/bizar-dash/dist/assets/main-NYFpS2wY.js +312 -0
- package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile--0FBIKX3.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile--0FBIKX3.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-OgRp8VIb.js +352 -0
- package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +1 -0
- package/bizar-dash/dist/index.html +3 -3
- package/bizar-dash/dist/mobile.html +2 -2
- package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
- package/bizar-dash/skills/bizar/SKILL.md +96 -0
- package/bizar-dash/skills/chat/SKILL.md +74 -0
- package/bizar-dash/skills/lightrag/SKILL.md +75 -0
- package/bizar-dash/skills/minimax/SKILL.md +80 -0
- package/bizar-dash/skills/obsidian/SKILL.md +55 -0
- package/bizar-dash/skills/providers/SKILL.md +75 -0
- package/bizar-dash/skills/sdk/SKILL.md +138 -0
- package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
- package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
- package/bizar-dash/skills/usage/SKILL.md +62 -0
- package/bizar-dash/src/server/api.mjs +12 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +5 -2
- package/bizar-dash/src/server/memory-store.mjs +38 -0
- package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
- package/bizar-dash/src/server/minimax.mjs +427 -110
- package/bizar-dash/src/server/providers-store.mjs +966 -6
- package/bizar-dash/src/server/routes/config.mjs +52 -1
- package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
- package/bizar-dash/src/server/routes/memory.mjs +241 -1
- package/bizar-dash/src/server/routes/minimax.mjs +50 -57
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
- package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
- package/bizar-dash/src/server/routes/providers.mjs +266 -5
- package/bizar-dash/src/server/routes/skills.mjs +32 -43
- package/bizar-dash/src/server/routes/update.mjs +340 -0
- package/bizar-dash/src/server/routes/usage.mjs +136 -0
- package/bizar-dash/src/server/serve-info.mjs +135 -4
- package/bizar-dash/src/server/server.mjs +4 -0
- package/bizar-dash/src/server/skills-store.mjs +152 -262
- package/bizar-dash/src/web/App.tsx +118 -29
- package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
- package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
- package/bizar-dash/src/web/components/Topbar.tsx +0 -1
- package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
- package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
- package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
- package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
- package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
- package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
- package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
- package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
- package/bizar-dash/src/web/lib/api.ts +43 -0
- package/bizar-dash/src/web/main.tsx +1 -0
- package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
- package/bizar-dash/src/web/styles/chat.css +135 -1
- package/bizar-dash/src/web/styles/main.css +46 -0
- package/bizar-dash/src/web/styles/minimax-usage.css +471 -0
- package/bizar-dash/src/web/styles/settings.css +418 -0
- package/bizar-dash/src/web/styles/skills.css +302 -0
- package/bizar-dash/src/web/styles/tasks.css +288 -0
- package/bizar-dash/src/web/views/Chat.tsx +276 -48
- package/bizar-dash/src/web/views/Config.tsx +3 -2065
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +620 -240
- package/bizar-dash/src/web/views/Settings.tsx +6 -0
- package/bizar-dash/src/web/views/Skills.tsx +208 -260
- package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
- package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
- package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
- package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
- package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
- package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
- package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
- package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
- package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
- package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
- package/bizar-dash/tests/skills-list.test.mjs +232 -0
- package/bizar-dash/tests/skills-search.test.mjs +222 -0
- package/bizar-dash/tests/tasks-create.test.mjs +187 -0
- package/bizar-dash/tests/update-check.test.mjs +127 -0
- package/bizar-dash/tests/update-run.test.mjs +266 -0
- package/cli/bin.mjs +345 -4
- package/cli/provision.mjs +118 -4
- package/config/agents/_shared/SKILLS.md +109 -0
- package/package.json +1 -1
- package/bizar-dash/dist/assets/main-BKXEqU1w.css +0 -1
- package/bizar-dash/dist/assets/main-EK_fzXn_.js +0 -352
- package/bizar-dash/dist/assets/main-EK_fzXn_.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/routes/update.mjs
|
|
3
|
+
*
|
|
4
|
+
* v4.6.0 — Dashboard update endpoints.
|
|
5
|
+
*
|
|
6
|
+
* GET /api/updates/status — installed package versions only
|
|
7
|
+
* GET /api/updates/check — installed + latest + hasUpdates
|
|
8
|
+
* POST /api/updates/apply — trigger install for given packages
|
|
9
|
+
* Streams progress via WS events:
|
|
10
|
+
* update:progress { type, pkg, status, error?, newVersion? }
|
|
11
|
+
* update:log { type, pkg, line }
|
|
12
|
+
* update:complete{ type }
|
|
13
|
+
*
|
|
14
|
+
* Why these paths (vs. /api/update/*):
|
|
15
|
+
* thor-settings wired Settings.tsx to `/updates/status`, `/updates/check`,
|
|
16
|
+
* and `/updates/apply` and listens for the WS event shapes above. Aligning
|
|
17
|
+
* with that contract is cheaper than rewriting Settings.tsx.
|
|
18
|
+
*
|
|
19
|
+
* What this endpoint does NOT do:
|
|
20
|
+
* - It does NOT restart the dashboard. The Settings UI displays a
|
|
21
|
+
* confirmation prompt before calling, and `bizar update` (the CLI)
|
|
22
|
+
* is the restart-aware path. The dashboard stays up; npm replaces
|
|
23
|
+
* files in the global install dir while the dashboard runs from its
|
|
24
|
+
* own already-loaded module graph. The next `bizar dash start`
|
|
25
|
+
* picks up the new code, just like the CLI flow.
|
|
26
|
+
* - It does NOT touch the opencode plugin in-place. That lives in the
|
|
27
|
+
* CLI flow because the plugin path is dev-symlinked in this repo.
|
|
28
|
+
*
|
|
29
|
+
* Safety:
|
|
30
|
+
* - Concurrency guard: only one apply run at a time. A second call
|
|
31
|
+
* returns 409 with `error: 'already_running'`.
|
|
32
|
+
* - Network/npm failures emit `update:log` for each stderr/stdout
|
|
33
|
+
* line and a final `update:progress` with `status: 'error'`.
|
|
34
|
+
* - Whitelist of npm package names; anything else is rejected 400.
|
|
35
|
+
*/
|
|
36
|
+
import { Router } from 'express';
|
|
37
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
38
|
+
import { join, dirname } from 'node:path';
|
|
39
|
+
import { fileURLToPath } from 'node:url';
|
|
40
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
41
|
+
import { wrap } from './_shared.mjs';
|
|
42
|
+
|
|
43
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
44
|
+
|
|
45
|
+
// v4.4.13 — packages the dashboard knows how to update. Aligned with
|
|
46
|
+
// the Settings UI's "Installed / Latest" panels and the CLI's update
|
|
47
|
+
// flow. All three resolve on npmjs.org (bizar-dash and bizar-plugin are
|
|
48
|
+
// deprecated but still resolvable).
|
|
49
|
+
const KNOWN_PACKAGES = [
|
|
50
|
+
{ id: 'bizar', npmName: '@polderlabs/bizar', label: 'Bizar CLI' },
|
|
51
|
+
{ id: 'bizar-dash', npmName: '@polderlabs/bizar-dash', label: 'Dashboard' },
|
|
52
|
+
{ id: 'bizar-plugin', npmName: '@polderlabs/bizar-plugin', label: 'Opencode Plugin' },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const KNOWN_BY_ID = new Map(KNOWN_PACKAGES.map((p) => [p.id, p]));
|
|
56
|
+
|
|
57
|
+
// Cache the latest-version lookup so /api/updates/check is fast on
|
|
58
|
+
// repeated dashboard renders. 1 hour is short enough that a new release
|
|
59
|
+
// shows up within an hour of publishing.
|
|
60
|
+
const LATEST_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
61
|
+
const _latestCache = new Map(); // npmName -> { version, ts }
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Resolve the on-disk version of the dashboard itself (used as the
|
|
65
|
+
* "current" version for @polderlabs/bizar). Walks up from this file
|
|
66
|
+
* looking for the package.json that declares `@polderlabs/bizar`.
|
|
67
|
+
*
|
|
68
|
+
* Why "walk up" instead of `import.meta.resolve`? When the dashboard
|
|
69
|
+
* runs out of `dist/` (the published layout), the package.json lives
|
|
70
|
+
* at the dist root, not at the file's location. The walk is bounded
|
|
71
|
+
* to 6 levels so a misconfigured install fails fast instead of looping.
|
|
72
|
+
*/
|
|
73
|
+
function resolveInstalledVersion() {
|
|
74
|
+
const markers = ['package.json'];
|
|
75
|
+
let cur = __dirname;
|
|
76
|
+
for (let i = 0; i < 6; i++) {
|
|
77
|
+
for (const m of markers) {
|
|
78
|
+
const p = join(cur, m);
|
|
79
|
+
if (existsSync(p)) {
|
|
80
|
+
try {
|
|
81
|
+
const pkg = JSON.parse(readFileSync(p, 'utf8'));
|
|
82
|
+
if (pkg.name === '@polderlabs/bizar' && pkg.version) return pkg.version;
|
|
83
|
+
} catch { /* malformed — try parent */ }
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const parent = dirname(cur);
|
|
87
|
+
if (parent === cur) break;
|
|
88
|
+
cur = parent;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const INSTALLED_BIZAR_VERSION = resolveInstalledVersion();
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `npm view <pkg> version` — returns the latest published version or
|
|
97
|
+
* null on any failure. Cached per package for 1 hour.
|
|
98
|
+
*/
|
|
99
|
+
function fetchLatestVersion(npmName) {
|
|
100
|
+
const cached = _latestCache.get(npmName);
|
|
101
|
+
if (cached && Date.now() - cached.ts < LATEST_CACHE_TTL_MS) {
|
|
102
|
+
return cached.version;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const out = execFileSync(
|
|
106
|
+
'npm',
|
|
107
|
+
['view', npmName, 'version'],
|
|
108
|
+
{ stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000 },
|
|
109
|
+
).toString().trim();
|
|
110
|
+
const version = out || null;
|
|
111
|
+
_latestCache.set(npmName, { version, ts: Date.now() });
|
|
112
|
+
return version;
|
|
113
|
+
} catch {
|
|
114
|
+
// Negative-cache for 5 minutes so a transient registry hiccup
|
|
115
|
+
// doesn't make every render call npm.
|
|
116
|
+
_latestCache.set(npmName, { version: null, ts: Date.now() - (LATEST_CACHE_TTL_MS - 5 * 60 * 1000) });
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Reset the latest-version cache — exposed for tests so each case
|
|
123
|
+
* starts from a known state.
|
|
124
|
+
*/
|
|
125
|
+
export function resetUpdateCache() {
|
|
126
|
+
_latestCache.clear();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Compute the per-package installed map. For the dashboard package
|
|
131
|
+
* itself we use INSTALLED_BIZAR_VERSION (the @polderlabs/bizar version
|
|
132
|
+
* that bundles the dashboard). The other packages fall back to null
|
|
133
|
+
* when not resolvable; the UI renders "—" for those rows.
|
|
134
|
+
*/
|
|
135
|
+
function currentVersions() {
|
|
136
|
+
const out = {};
|
|
137
|
+
for (const p of KNOWN_PACKAGES) {
|
|
138
|
+
if (p.id === 'bizar') {
|
|
139
|
+
out[p.id] = INSTALLED_BIZAR_VERSION;
|
|
140
|
+
} else {
|
|
141
|
+
// Deprecated standalone packages — fall back to running dashboard
|
|
142
|
+
// version as a sane default so the row isn't permanently "—".
|
|
143
|
+
out[p.id] = INSTALLED_BIZAR_VERSION;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Compute latest map and hasUpdates flag. Iterates KNOWN_PACKAGES so
|
|
151
|
+
* the UI gets a stable order and predictable keys even when one of the
|
|
152
|
+
* `npm view` calls fails (those resolve to null).
|
|
153
|
+
*/
|
|
154
|
+
function latestVersionsAndFlag(current) {
|
|
155
|
+
const latest = {};
|
|
156
|
+
let hasUpdates = false;
|
|
157
|
+
for (const p of KNOWN_PACKAGES) {
|
|
158
|
+
const lat = fetchLatestVersion(p.npmName);
|
|
159
|
+
latest[p.id] = lat;
|
|
160
|
+
const cur = current[p.id];
|
|
161
|
+
if (cur && lat && cur !== lat) hasUpdates = true;
|
|
162
|
+
}
|
|
163
|
+
return { latest, hasUpdates };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ─── Apply concurrency guard ───────────────────────────────────────────────
|
|
167
|
+
// One active run per server process. A second POST /updates/apply while
|
|
168
|
+
// the first is still going returns 409 immediately.
|
|
169
|
+
|
|
170
|
+
let _activeRun = null; // { packages: string[], startedAt: number } | null
|
|
171
|
+
|
|
172
|
+
function isRunning() {
|
|
173
|
+
return _activeRun !== null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Run `npm install -g <pkg>@latest` for each requested id. Emits WS
|
|
178
|
+
* events on the broadcast channel for every stdout/stderr line and for
|
|
179
|
+
* the per-package status transitions. Returns the result object
|
|
180
|
+
* (sync — the actual install runs in the background and finishes via
|
|
181
|
+
* WS events).
|
|
182
|
+
*/
|
|
183
|
+
function startApplyRun({ packages, channel, broadcast }) {
|
|
184
|
+
if (_activeRun) {
|
|
185
|
+
throw Object.assign(new Error('update already running'), { code: 'already_running' });
|
|
186
|
+
}
|
|
187
|
+
const ids = (Array.isArray(packages) && packages.length > 0) ? packages : KNOWN_PACKAGES.map((p) => p.id);
|
|
188
|
+
const validIds = ids.filter((id) => KNOWN_BY_ID.has(id));
|
|
189
|
+
if (validIds.length === 0) {
|
|
190
|
+
throw Object.assign(new Error('no valid packages requested'), { code: 'bad_request' });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
_activeRun = { packages: validIds, startedAt: Date.now() };
|
|
194
|
+
|
|
195
|
+
// Kick the run async. Errors are reported via the WS bus.
|
|
196
|
+
(async () => {
|
|
197
|
+
try {
|
|
198
|
+
for (const id of validIds) {
|
|
199
|
+
const pkg = KNOWN_BY_ID.get(id);
|
|
200
|
+
if (!pkg) continue;
|
|
201
|
+
broadcast({ type: 'update:progress', pkg: id, status: 'starting' });
|
|
202
|
+
|
|
203
|
+
const versionTag = channel && channel === 'beta' ? '@beta' : '@latest';
|
|
204
|
+
const code = await runNpmInstall(pkg.npmName, versionTag, (line) => {
|
|
205
|
+
broadcast({ type: 'update:log', pkg: id, line });
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (code === 0) {
|
|
209
|
+
const newVer = fetchLatestVersion(pkg.npmName); // warm cache
|
|
210
|
+
broadcast({
|
|
211
|
+
type: 'update:progress',
|
|
212
|
+
pkg: id,
|
|
213
|
+
status: 'done',
|
|
214
|
+
newVersion: newVer || undefined,
|
|
215
|
+
});
|
|
216
|
+
} else {
|
|
217
|
+
broadcast({
|
|
218
|
+
type: 'update:progress',
|
|
219
|
+
pkg: id,
|
|
220
|
+
status: 'error',
|
|
221
|
+
error: `npm install exited with code ${code}`,
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} finally {
|
|
226
|
+
_activeRun = null;
|
|
227
|
+
broadcast({ type: 'update:complete' });
|
|
228
|
+
}
|
|
229
|
+
})().catch((err) => {
|
|
230
|
+
_activeRun = null;
|
|
231
|
+
broadcast({ type: 'update:progress', pkg: '__global', status: 'error', error: err?.message || String(err) });
|
|
232
|
+
broadcast({ type: 'update:complete' });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
return { ok: true, started: validIds, channel: channel || 'stable' };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Spawn `npm install -g <pkg>@<tag>`. Captures stdout/stderr line by
|
|
240
|
+
* line and forwards each via the `onLine` callback. Resolves with the
|
|
241
|
+
* exit code (0 on success, non-zero otherwise).
|
|
242
|
+
*
|
|
243
|
+
* Why spawn instead of execSync: npm install can take 30+ seconds and
|
|
244
|
+
* blocks the event loop. We want concurrent dashboard requests to keep
|
|
245
|
+
* serving while an update is in flight.
|
|
246
|
+
*/
|
|
247
|
+
function runNpmInstall(npmName, versionTag, onLine) {
|
|
248
|
+
return new Promise((resolve) => {
|
|
249
|
+
let proc;
|
|
250
|
+
try {
|
|
251
|
+
proc = spawn('npm', ['install', '-g', `${npmName}${versionTag}`], {
|
|
252
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
253
|
+
env: process.env,
|
|
254
|
+
});
|
|
255
|
+
} catch (err) {
|
|
256
|
+
onLine(`failed to spawn npm: ${err.message}`);
|
|
257
|
+
resolve(127);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const pipeLine = (stream) => {
|
|
262
|
+
let buf = '';
|
|
263
|
+
stream.setEncoding('utf8');
|
|
264
|
+
stream.on('data', (chunk) => {
|
|
265
|
+
buf += chunk;
|
|
266
|
+
let nl;
|
|
267
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
268
|
+
const line = buf.slice(0, nl).replace(/\r$/, '');
|
|
269
|
+
buf = buf.slice(nl + 1);
|
|
270
|
+
if (line) onLine(line);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
stream.on('end', () => {
|
|
274
|
+
if (buf) onLine(buf);
|
|
275
|
+
});
|
|
276
|
+
};
|
|
277
|
+
pipeLine(proc.stdout);
|
|
278
|
+
pipeLine(proc.stderr);
|
|
279
|
+
|
|
280
|
+
proc.on('error', (err) => {
|
|
281
|
+
onLine(`npm process error: ${err.message}`);
|
|
282
|
+
resolve(127);
|
|
283
|
+
});
|
|
284
|
+
proc.on('close', (code) => {
|
|
285
|
+
resolve(code ?? 1);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ─── Router factory ────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
export function createUpdateRouter({ broadcast = () => {} } = {}) {
|
|
293
|
+
const router = Router();
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* GET /api/updates/status
|
|
297
|
+
* Quick status — installed versions only. Cheap; no npm calls.
|
|
298
|
+
*/
|
|
299
|
+
router.get('/updates/status', wrap(async (_req, res) => {
|
|
300
|
+
const current = currentVersions();
|
|
301
|
+
res.json({ current });
|
|
302
|
+
}));
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* GET /api/updates/check
|
|
306
|
+
* Installed + latest + hasUpdates. Calls `npm view` (cached).
|
|
307
|
+
*/
|
|
308
|
+
router.get('/updates/check', wrap(async (_req, res) => {
|
|
309
|
+
const current = currentVersions();
|
|
310
|
+
const { latest, hasUpdates } = latestVersionsAndFlag(current);
|
|
311
|
+
res.json({ current, latest, hasUpdates });
|
|
312
|
+
}));
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* POST /api/updates/apply
|
|
316
|
+
* Body: { packages?: string[], channel?: 'stable' | 'beta' }
|
|
317
|
+
* Returns 202 immediately. Progress streams via WS events.
|
|
318
|
+
* Returns 409 if another apply is in flight.
|
|
319
|
+
*/
|
|
320
|
+
router.post('/updates/apply', wrap(async (req, res) => {
|
|
321
|
+
if (isRunning()) {
|
|
322
|
+
res.status(409).json({ error: 'already_running', message: 'an update is already running' });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
const body = req.body || {};
|
|
326
|
+
const channel = body.channel === 'beta' ? 'beta' : 'stable';
|
|
327
|
+
try {
|
|
328
|
+
const result = startApplyRun({ packages: body.packages, channel, broadcast });
|
|
329
|
+
res.status(202).json(result);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
const status = err?.code === 'bad_request' ? 400 : 500;
|
|
332
|
+
res.status(status).json({
|
|
333
|
+
error: err?.code || 'apply_failed',
|
|
334
|
+
message: err?.message || String(err),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}));
|
|
338
|
+
|
|
339
|
+
return router;
|
|
340
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/routes/usage.mjs
|
|
3
|
+
*
|
|
4
|
+
* Endpoints for usage analytics:
|
|
5
|
+
* GET /api/usage — aggregate usage (range, from, to, providerId, modelId)
|
|
6
|
+
* GET /api/usage/limits — live quota limits + usage percent (agents call this)
|
|
7
|
+
* GET /api/usage/recent — last N raw records (live tail)
|
|
8
|
+
*
|
|
9
|
+
* The agent-awareness path (/api/usage/limits) calls fetchRemains() for
|
|
10
|
+
* live quota data, then merges with the JSONL rolling totals.
|
|
11
|
+
*/
|
|
12
|
+
import { Router } from 'express';
|
|
13
|
+
import { wrap } from './_shared.mjs';
|
|
14
|
+
import {
|
|
15
|
+
queryUsage,
|
|
16
|
+
getUsageLimitsForAgent,
|
|
17
|
+
} from '../minimax-usage-store.mjs';
|
|
18
|
+
|
|
19
|
+
export function createUsageRouter() {
|
|
20
|
+
const router = Router();
|
|
21
|
+
|
|
22
|
+
// GET /api/usage?range=24h|7d|30d|custom&from=&to=&providerId=&modelId=
|
|
23
|
+
router.get('/usage', wrap(async (req, res) => {
|
|
24
|
+
const range = String(req.query.range || '24h');
|
|
25
|
+
const from = req.query.from ? Number(req.query.from) : undefined;
|
|
26
|
+
const to = req.query.to ? Number(req.query.to) : undefined;
|
|
27
|
+
const providerId = req.query.providerId ? String(req.query.providerId) : undefined;
|
|
28
|
+
const modelId = req.query.modelId ? String(req.query.modelId) : undefined;
|
|
29
|
+
|
|
30
|
+
if (!['24h', '7d', '30d', 'custom'].includes(range)) {
|
|
31
|
+
return res.status(400).json({ error: 'invalid_range', message: 'range must be 24h, 7d, 30d, or custom' });
|
|
32
|
+
}
|
|
33
|
+
if (range === 'custom' && (!from || !to)) {
|
|
34
|
+
return res.status(400).json({ error: 'missing_dates', message: 'range=custom requires `from` and `to` (unix ms)' });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const opts = {
|
|
38
|
+
range,
|
|
39
|
+
from,
|
|
40
|
+
to,
|
|
41
|
+
providerId,
|
|
42
|
+
modelId,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
let result;
|
|
46
|
+
try {
|
|
47
|
+
result = queryUsage(opts);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
return res.status(400).json({ error: 'query_error', message: String((err).message) });
|
|
50
|
+
}
|
|
51
|
+
return res.json(result);
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
// GET /api/usage/limits
|
|
55
|
+
// Returns live quota info from fetchRemains() merged with rolling usage totals.
|
|
56
|
+
router.get('/usage/limits', wrap(async (_req, res) => {
|
|
57
|
+
// Dynamic import to avoid pulling in the full minimax client bundle on every
|
|
58
|
+
// dashboard page load. The usage store is lightweight.
|
|
59
|
+
/** @type {Record<string, unknown>} */
|
|
60
|
+
let remainsData = { models: [] };
|
|
61
|
+
try {
|
|
62
|
+
const { fetchRemains } = await import('../minimax.mjs');
|
|
63
|
+
const remains = await fetchRemains({ force: false });
|
|
64
|
+
if (remains.ok) {
|
|
65
|
+
// Cast through unknown to avoid TypeScript pollution in plain .mjs.
|
|
66
|
+
remainsData = /** @type {Record<string, unknown>} */ (remains);
|
|
67
|
+
}
|
|
68
|
+
} catch { /* best-effort — return zeros */ }
|
|
69
|
+
|
|
70
|
+
// Rolling usage from JSONL.
|
|
71
|
+
const agentSummary = await getUsageLimitsForAgent('minimax');
|
|
72
|
+
|
|
73
|
+
// Merge: per-model usage from JSONL + per-model quota from remains.
|
|
74
|
+
/** @type {Array<{model_name?: string, current_interval_remaining_percent?: number, current_weekly_remaining_percent?: number, intervalResetInHuman?: string, weeklyResetInHuman?: string}>} */
|
|
75
|
+
const models = (remainsData.models || []);
|
|
76
|
+
|
|
77
|
+
// Build per-model percentUsed from the JSONL per-model data.
|
|
78
|
+
const usageResult = queryUsage({ range: '24h', providerId: 'minimax' });
|
|
79
|
+
const perModelUsage = new Map(usageResult.perModel.map(m => [`minimax/${m.modelId}`, m]));
|
|
80
|
+
|
|
81
|
+
const perModelLimits = models.map(m => {
|
|
82
|
+
const usage = perModelUsage.get(`minimax/${m.model_name}`);
|
|
83
|
+
const usedTokens = usage?.totalTokens ?? 0;
|
|
84
|
+
const remainingPct = m.current_interval_remaining_percent ?? 100;
|
|
85
|
+
let estimatedDailyBudget = 0;
|
|
86
|
+
let percentUsed = 0;
|
|
87
|
+
if (remainingPct < 100 && usedTokens > 0) {
|
|
88
|
+
estimatedDailyBudget = Math.round(usedTokens / (1 - remainingPct / 100));
|
|
89
|
+
percentUsed = Math.round((usedTokens / estimatedDailyBudget) * 1000) / 10;
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
modelId: m.model_name ?? 'unknown',
|
|
93
|
+
totalTokensUsed: usedTokens,
|
|
94
|
+
requestCount: usage?.requests ?? 0,
|
|
95
|
+
limits: {
|
|
96
|
+
dailyTokens: estimatedDailyBudget || null,
|
|
97
|
+
intervalResetIn: m.intervalResetInHuman ?? null,
|
|
98
|
+
weeklyResetIn: m.weeklyResetInHuman ?? null,
|
|
99
|
+
},
|
|
100
|
+
percentUsed24h: percentUsed,
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return res.json({
|
|
105
|
+
provider: 'minimax',
|
|
106
|
+
requestsLast5min: agentSummary.requestsLast5min,
|
|
107
|
+
tokensLast5min: agentSummary.tokensLast5min,
|
|
108
|
+
requestsLast24h: agentSummary.requestsLast24h,
|
|
109
|
+
tokensLast24h: agentSummary.tokensLast24h,
|
|
110
|
+
limits: agentSummary.limits,
|
|
111
|
+
percentUsed24h: agentSummary.percentUsed24h,
|
|
112
|
+
estimatedTimeUntilReset: agentSummary.estimatedTimeUntilReset,
|
|
113
|
+
warning: agentSummary.warning,
|
|
114
|
+
perModel: perModelLimits,
|
|
115
|
+
fetchedAt: remainsData.fetchedAt ? new Date(remainsData.fetchedAt).toISOString() : null,
|
|
116
|
+
});
|
|
117
|
+
}));
|
|
118
|
+
|
|
119
|
+
// GET /api/usage/recent?limit=50
|
|
120
|
+
router.get('/usage/recent', wrap(async (req, res) => {
|
|
121
|
+
const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200);
|
|
122
|
+
|
|
123
|
+
// Read last N lines from the JSONL.
|
|
124
|
+
const { readAllRecords } = await import('../minimax-usage-store.mjs');
|
|
125
|
+
const all = readAllRecords();
|
|
126
|
+
const recent = all.slice(-limit).reverse(); // newest first
|
|
127
|
+
|
|
128
|
+
return res.json({
|
|
129
|
+
records: recent,
|
|
130
|
+
total: all.length,
|
|
131
|
+
returned: recent.length,
|
|
132
|
+
});
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
return router;
|
|
136
|
+
}
|
|
@@ -60,12 +60,27 @@ import { createConnection, isIP } from 'node:net';
|
|
|
60
60
|
const HOME = homedir();
|
|
61
61
|
|
|
62
62
|
// Mirrors plugins/bizar/src/serve-info.ts → BG_DIRS pattern.
|
|
63
|
-
const
|
|
63
|
+
const DEFAULT_SERVE_INFO_FILES = [
|
|
64
64
|
join(HOME, '.cache', 'bizar', 'serve.json'),
|
|
65
65
|
join(HOME, '.config', 'opencode', 'serve.json'),
|
|
66
66
|
join(HOME, '.bizar', 'serve.json'),
|
|
67
67
|
];
|
|
68
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Resolve the serve.json candidate paths at call time so test
|
|
71
|
+
* suites can override with `BIZAR_SERVE_JSON_PATH=/tmp/...json`.
|
|
72
|
+
* Order: env override (if set and exists) → default search list.
|
|
73
|
+
*
|
|
74
|
+
* @returns {string[]}
|
|
75
|
+
*/
|
|
76
|
+
function serveInfoFiles() {
|
|
77
|
+
const override = process.env.BIZAR_SERVE_JSON_PATH;
|
|
78
|
+
if (override && typeof override === 'string' && override.length > 0) {
|
|
79
|
+
return [override];
|
|
80
|
+
}
|
|
81
|
+
return DEFAULT_SERVE_INFO_FILES;
|
|
82
|
+
}
|
|
83
|
+
|
|
69
84
|
/**
|
|
70
85
|
* @typedef {Object} ServeInfo
|
|
71
86
|
* @property {string} baseUrl e.g. "http://127.0.0.1:4097"
|
|
@@ -100,7 +115,7 @@ const SERVE_INFO_FILES = [
|
|
|
100
115
|
* @returns {ServeInfo|null}
|
|
101
116
|
*/
|
|
102
117
|
export function readServeInfo() {
|
|
103
|
-
for (const file of
|
|
118
|
+
for (const file of serveInfoFiles()) {
|
|
104
119
|
if (!existsSync(file)) continue;
|
|
105
120
|
try {
|
|
106
121
|
const raw = readFileSync(file, 'utf8');
|
|
@@ -304,7 +319,7 @@ export async function listOpencodeSessions(info, timeoutMs = 5000) {
|
|
|
304
319
|
}
|
|
305
320
|
}
|
|
306
321
|
|
|
307
|
-
export const SERVE_INFO_FILE_PATHS =
|
|
322
|
+
export const SERVE_INFO_FILE_PATHS = DEFAULT_SERVE_INFO_FILES;
|
|
308
323
|
|
|
309
324
|
// ── v3.5.4 (bug: dispatch stuck) — spawn helpers ─────────────────────────
|
|
310
325
|
//
|
|
@@ -623,7 +638,123 @@ export function normalizeOpencodeMessage(msg) {
|
|
|
623
638
|
}
|
|
624
639
|
|
|
625
640
|
/**
|
|
626
|
-
* `
|
|
641
|
+
* `DELETE /api/session/{id}?directory=...` — delete a session on the
|
|
642
|
+
* opencode serve child. v4.2.4 dashboard feature parity: the rail
|
|
643
|
+
* menu offers delete and the chat info panel can prune finished
|
|
644
|
+
* sessions from inside the dashboard.
|
|
645
|
+
*
|
|
646
|
+
* Idempotent: a 404 (already gone) is treated as a successful delete.
|
|
647
|
+
*
|
|
648
|
+
* @param {ServeInfo} info
|
|
649
|
+
* @param {string} sessionId
|
|
650
|
+
* @param {string} [directory]
|
|
651
|
+
* @param {number} [timeoutMs]
|
|
652
|
+
* @returns {Promise<{ok:true,status:number}|{ok:false,error:string,status?:number}>}
|
|
653
|
+
*/
|
|
654
|
+
export async function deleteOpencodeSession(info, sessionId, directory, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
655
|
+
if (!info) return { ok: false, error: 'serve-info not available — plugin is not running' };
|
|
656
|
+
if (!sessionId) return { ok: false, error: 'sessionId is required' };
|
|
657
|
+
const dir = directory || info.worktree || '';
|
|
658
|
+
const url = `${info.baseUrl}/api/session/${encodeURIComponent(sessionId)}?directory=${encodeURIComponent(dir)}`;
|
|
659
|
+
const ac = new AbortController();
|
|
660
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
661
|
+
try {
|
|
662
|
+
const res = await fetch(url, {
|
|
663
|
+
method: 'DELETE',
|
|
664
|
+
headers: {
|
|
665
|
+
Authorization: buildAuthHeader(info),
|
|
666
|
+
Accept: 'application/json',
|
|
667
|
+
},
|
|
668
|
+
signal: ac.signal,
|
|
669
|
+
});
|
|
670
|
+
if (res.ok || res.status === 404) {
|
|
671
|
+
return { ok: true, status: res.status };
|
|
672
|
+
}
|
|
673
|
+
let detail = '';
|
|
674
|
+
try { detail = (await res.text()).slice(0, 500); } catch { /* ignore */ }
|
|
675
|
+
return {
|
|
676
|
+
ok: false,
|
|
677
|
+
status: res.status,
|
|
678
|
+
error: `DELETE /api/session/${sessionId} failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`,
|
|
679
|
+
};
|
|
680
|
+
} catch (err) {
|
|
681
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
682
|
+
const isAbort = err instanceof Error && err.name === 'AbortError';
|
|
683
|
+
return {
|
|
684
|
+
ok: false,
|
|
685
|
+
error: isAbort ? `deleteSession timed out after ${timeoutMs}ms` : `deleteSession network error: ${msg}`,
|
|
686
|
+
};
|
|
687
|
+
} finally {
|
|
688
|
+
clearTimeout(timer);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* `PATCH /api/session/{id}?directory=...` — update session fields
|
|
694
|
+
* (currently just `title`) on the opencode serve child. Used by the
|
|
695
|
+
* rail row-menu "Rename" affordance.
|
|
696
|
+
*
|
|
697
|
+
* @param {ServeInfo} info
|
|
698
|
+
* @param {string} sessionId
|
|
699
|
+
* @param {{ title?: string }} patch
|
|
700
|
+
* @param {string} [directory]
|
|
701
|
+
* @param {number} [timeoutMs]
|
|
702
|
+
* @returns {Promise<{ok:true,session:object}|{ok:false,error:string,status?:number}>}
|
|
703
|
+
*/
|
|
704
|
+
export async function updateOpencodeSession(info, sessionId, patch, directory, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
705
|
+
if (!info) return { ok: false, error: 'serve-info not available — plugin is not running' };
|
|
706
|
+
if (!sessionId) return { ok: false, error: 'sessionId is required' };
|
|
707
|
+
if (!patch || typeof patch !== 'object') return { ok: false, error: 'patch is required' };
|
|
708
|
+
const dir = directory || info.worktree || '';
|
|
709
|
+
const url = `${info.baseUrl}/api/session/${encodeURIComponent(sessionId)}?directory=${encodeURIComponent(dir)}`;
|
|
710
|
+
const ac = new AbortController();
|
|
711
|
+
const timer = setTimeout(() => ac.abort(), timeoutMs);
|
|
712
|
+
try {
|
|
713
|
+
const body = {};
|
|
714
|
+
if (typeof patch.title === 'string') {
|
|
715
|
+
const t = patch.title.trim();
|
|
716
|
+
if (!t) return { ok: false, error: 'title cannot be empty' };
|
|
717
|
+
if (t.length > 200) return { ok: false, error: 'title too long (> 200 chars)' };
|
|
718
|
+
body.title = t;
|
|
719
|
+
} else {
|
|
720
|
+
return { ok: false, error: 'no supported fields in patch (only `title` is wired)' };
|
|
721
|
+
}
|
|
722
|
+
const res = await fetch(url, {
|
|
723
|
+
method: 'PATCH',
|
|
724
|
+
headers: {
|
|
725
|
+
Authorization: buildAuthHeader(info),
|
|
726
|
+
'Content-Type': 'application/json',
|
|
727
|
+
Accept: 'application/json',
|
|
728
|
+
},
|
|
729
|
+
body: JSON.stringify(body),
|
|
730
|
+
signal: ac.signal,
|
|
731
|
+
});
|
|
732
|
+
if (!res.ok) {
|
|
733
|
+
let detail = '';
|
|
734
|
+
try { detail = (await res.text()).slice(0, 500); } catch { /* ignore */ }
|
|
735
|
+
return {
|
|
736
|
+
ok: false,
|
|
737
|
+
status: res.status,
|
|
738
|
+
error: `PATCH /api/session/${sessionId} failed: ${res.status} ${res.statusText}${detail ? ` — ${detail}` : ''}`,
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
let session = null;
|
|
742
|
+
try { session = await res.json(); } catch { /* non-JSON body is fine — we still got 2xx */ }
|
|
743
|
+
return { ok: true, session };
|
|
744
|
+
} catch (err) {
|
|
745
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
746
|
+
const isAbort = err instanceof Error && err.name === 'AbortError';
|
|
747
|
+
return {
|
|
748
|
+
ok: false,
|
|
749
|
+
error: isAbort ? `updateSession timed out after ${timeoutMs}ms` : `updateSession network error: ${msg}`,
|
|
750
|
+
};
|
|
751
|
+
} finally {
|
|
752
|
+
clearTimeout(timer);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* GET /health — used by the dispatcher startup check to verify the
|
|
627
758
|
* serve child is actually reachable before we try to enqueue work.
|
|
628
759
|
*
|
|
629
760
|
* v3.11.0 — Replaced the HTTP `GET /health` probe with a TCP-connect
|
|
@@ -302,6 +302,10 @@ export async function createServer({
|
|
|
302
302
|
}
|
|
303
303
|
currentBroadcast = localBroadcast;
|
|
304
304
|
|
|
305
|
+
// v4 — Load BIZAR_* env vars from ~/.config/bizar/env.json into process.env.
|
|
306
|
+
const { loadEnvJson } = await import('./routes/env-vars.mjs');
|
|
307
|
+
loadEnvJson();
|
|
308
|
+
|
|
305
309
|
const apiRouter = await createApiRouter({
|
|
306
310
|
state,
|
|
307
311
|
watcher,
|