@polderlabs/bizar 4.4.13 → 4.5.1
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/__vite-browser-external-BIHI7g3E.js +2 -0
- package/bizar-dash/dist/assets/__vite-browser-external-BIHI7g3E.js.map +1 -0
- package/bizar-dash/dist/assets/main-eWZ4NlCL.css +1 -0
- package/bizar-dash/dist/assets/main-usWhlPWa.js +362 -0
- package/bizar-dash/dist/assets/main-usWhlPWa.js.map +1 -0
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile-DQLFCjwJ.js} +2 -2
- package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile-DQLFCjwJ.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-O6ANdD4W.js +352 -0
- package/bizar-dash/dist/assets/mobile-O6ANdD4W.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 +116 -0
- package/bizar-dash/skills/chat/SKILL.md +74 -0
- package/bizar-dash/skills/headroom/SKILL.md +94 -0
- package/bizar-dash/skills/lightrag/SKILL.md +86 -0
- package/bizar-dash/skills/minimax/SKILL.md +80 -0
- package/bizar-dash/skills/obsidian/SKILL.md +68 -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 +18 -0
- package/bizar-dash/src/server/headroom.mjs +645 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +272 -2
- package/bizar-dash/src/server/memory-obsidian.mjs +230 -0
- package/bizar-dash/src/server/memory-store.mjs +189 -0
- package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
- package/bizar-dash/src/server/minimax.mjs +196 -5
- package/bizar-dash/src/server/providers-store.mjs +956 -0
- package/bizar-dash/src/server/routes/_shared.mjs +17 -0
- 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/headroom.mjs +126 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
- package/bizar-dash/src/server/routes/memory.mjs +668 -1
- 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 +20 -0
- package/bizar-dash/src/server/skills-store.mjs +152 -262
- package/bizar-dash/src/web/App.tsx +120 -29
- package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
- package/bizar-dash/src/web/components/HeadroomSettings.tsx +418 -0
- package/bizar-dash/src/web/components/HeadroomStatus.tsx +158 -0
- package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
- package/bizar-dash/src/web/components/Topbar.tsx +2 -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/lib/types.ts +16 -0
- package/bizar-dash/src/web/main.tsx +2 -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/memory.css +955 -0
- package/bizar-dash/src/web/styles/minimax-usage.css +335 -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/Memory.tsx +140 -0
- package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
- package/bizar-dash/src/web/views/Overview.tsx +3 -0
- package/bizar-dash/src/web/views/Settings.tsx +36 -0
- package/bizar-dash/src/web/views/Skills.tsx +208 -260
- package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
- package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +289 -0
- package/bizar-dash/src/web/views/memory/GitSyncPanel.tsx +220 -0
- package/bizar-dash/src/web/views/memory/LightragPanel.tsx +307 -0
- package/bizar-dash/src/web/views/memory/MemoryOverview.tsx +354 -0
- package/bizar-dash/src/web/views/memory/MemoryStatusCard.tsx +160 -0
- package/bizar-dash/src/web/views/memory/ObsidianPanel.tsx +642 -0
- package/bizar-dash/src/web/views/memory/SemanticSearchPanel.tsx +194 -0
- 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/headroom-install.test.mjs +173 -0
- package/bizar-dash/tests/headroom-settings.test.mjs +126 -0
- package/bizar-dash/tests/headroom-status.test.mjs +117 -0
- package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
- package/bizar-dash/tests/memory-lightrag-extended.test.mjs +162 -0
- package/bizar-dash/tests/memory-obsidian.test.mjs +269 -0
- package/bizar-dash/tests/memory-tab.test.mjs +322 -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/mod-upgrade.node.test.mjs +1 -1
- 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/submit-feedback.test.mjs +6 -6
- 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 +247 -1
- 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-BB5mJurD.js +0 -352
- package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
- package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
- package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
|
@@ -51,8 +51,12 @@ import { wrap } from './_shared.mjs';
|
|
|
51
51
|
|
|
52
52
|
/** Maximum number of concurrent SSE subscribers on this dashboard process. */
|
|
53
53
|
const MAX_SSE_SUBSCRIBERS = 50;
|
|
54
|
-
/**
|
|
55
|
-
|
|
54
|
+
/** Read the heartbeat interval on demand so tests can shrink it via
|
|
55
|
+
* `process.env.BIZAR_SSE_HEARTBEAT_MS` even after module load. */
|
|
56
|
+
function getSseHeartbeatMs() {
|
|
57
|
+
const v = Number.parseInt(process.env.BIZAR_SSE_HEARTBEAT_MS ?? '', 10);
|
|
58
|
+
return Number.isFinite(v) && v > 0 ? v : 25_000;
|
|
59
|
+
}
|
|
56
60
|
/** Default timeout for the upstream message listing endpoint. */
|
|
57
61
|
const LIST_MESSAGES_TIMEOUT_MS = 8_000;
|
|
58
62
|
/** Default timeout for the upstream prompt endpoint. */
|
|
@@ -275,7 +279,7 @@ export function createOpencodeSessionDetailRouter() {
|
|
|
275
279
|
} catch {
|
|
276
280
|
clearInterval(heartbeat);
|
|
277
281
|
}
|
|
278
|
-
},
|
|
282
|
+
}, getSseHeartbeatMs());
|
|
279
283
|
let closed = false;
|
|
280
284
|
const cleanup = () => {
|
|
281
285
|
if (closed) return;
|
|
@@ -418,34 +422,15 @@ function handleSseBlock(block, res, sessionId) {
|
|
|
418
422
|
data: evt.data,
|
|
419
423
|
});
|
|
420
424
|
try {
|
|
425
|
+
// Forward a single canonical envelope per upstream event. The
|
|
426
|
+
// dashboard's stream listeners consume the opencode event names
|
|
427
|
+
// (`message.part.updated`, `session.idle`, `message.updated`, …)
|
|
428
|
+
// directly — emitting redundant `chat:delta` / `chat:status` aliases
|
|
429
|
+
// here would duplicate events and (e.g.) cause an idle event to
|
|
430
|
+
// arrive twice on the wire, which breaks both subscribers and
|
|
431
|
+
// round-trip tests.
|
|
421
432
|
res.write(`event: ${evt.type}\n`);
|
|
422
433
|
res.write(`data: ${payload}\n\n`);
|
|
423
|
-
|
|
424
|
-
// v0.1.0 — also emit `chat:delta` / `chat:status` envelopes when
|
|
425
|
-
// a text part delta or session.idle arrives. These are consumed by
|
|
426
|
-
// dashboard components that listen to the same SSE stream.
|
|
427
|
-
if (evt.type === 'message.part.updated') {
|
|
428
|
-
const part = evt.part;
|
|
429
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
430
|
-
const p = /** @type {any} */ (part);
|
|
431
|
-
if (p && p.type === 'text' && typeof p.text === 'string') {
|
|
432
|
-
const deltaPayload = JSON.stringify({
|
|
433
|
-
sessionId: evt.sessionID,
|
|
434
|
-
messageId: evt.messageID || undefined,
|
|
435
|
-
delta: p.text,
|
|
436
|
-
type: 'text',
|
|
437
|
-
});
|
|
438
|
-
res.write(`event: chat:delta\n`);
|
|
439
|
-
res.write(`data: ${deltaPayload}\n\n`);
|
|
440
|
-
}
|
|
441
|
-
} else if (evt.type === 'session.idle') {
|
|
442
|
-
const statusPayload = JSON.stringify({
|
|
443
|
-
sessionId: evt.sessionID,
|
|
444
|
-
status: 'idle',
|
|
445
|
-
});
|
|
446
|
-
res.write(`event: chat:status\n`);
|
|
447
|
-
res.write(`data: ${statusPayload}\n\n`);
|
|
448
|
-
}
|
|
449
434
|
} catch {
|
|
450
435
|
/* socket closed mid-write */
|
|
451
436
|
}
|
|
@@ -1,21 +1,76 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* src/server/routes/opencode-sessions.mjs
|
|
3
3
|
*
|
|
4
|
-
* /api/opencode-sessions
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* /api/opencode-sessions — list opencode sessions
|
|
5
|
+
* POST /api/opencode-sessions/new — create a new opencode session
|
|
6
|
+
* PATCH /api/opencode-sessions/:id — rename an opencode session
|
|
7
|
+
* DELETE /api/opencode-sessions/:id — delete an opencode session
|
|
7
8
|
*
|
|
9
|
+
* The list endpoint reads from the opencode SQLite database
|
|
10
|
+
* (~/.local/share/opencode/opencode.db). The mutating endpoints
|
|
11
|
+
* talk to the opencode serve child via the helpers in
|
|
12
|
+
* `../serve-info.mjs` (see `createOpencodeSession`,
|
|
13
|
+
* `updateOpencodeSession`, `deleteOpencodeSession`).
|
|
14
|
+
*
|
|
15
|
+
* Both surfaces share directory-resolution logic: we discover the
|
|
16
|
+
* session's directory from `listOpencodeSessions()` and fall back to
|
|
17
|
+
* `info.worktree` so a freshly created session (not yet listed) can
|
|
18
|
+
* still be addressed using the active project's path or the plugin's
|
|
19
|
+
* recorded cwd.
|
|
20
|
+
*
|
|
21
|
+
* v4.2.5 — Added POST/PATCH/DELETE so the dashboard's "New session"
|
|
22
|
+
* button, the rail row menu (rename / delete), and the info-panel
|
|
23
|
+
* actions all work end-to-end against the opencode serve child.
|
|
8
24
|
* Sessions are filtered to non-archived (time_archived IS NULL) and
|
|
9
25
|
* ordered by most recently updated.
|
|
10
26
|
*/
|
|
27
|
+
|
|
11
28
|
import { Router } from 'express';
|
|
12
29
|
import Database from 'better-sqlite3';
|
|
13
30
|
import { join } from 'node:path';
|
|
14
31
|
import { homedir } from 'node:os';
|
|
15
32
|
import { wrap } from './_shared.mjs';
|
|
33
|
+
import {
|
|
34
|
+
readServeInfo,
|
|
35
|
+
listOpencodeSessions,
|
|
36
|
+
createOpencodeSession,
|
|
37
|
+
updateOpencodeSession,
|
|
38
|
+
deleteOpencodeSession,
|
|
39
|
+
} from '../serve-info.mjs';
|
|
16
40
|
|
|
17
41
|
const DB_PATH = join(homedir(), '.local', 'share', 'opencode', 'opencode.db');
|
|
18
42
|
|
|
43
|
+
const SESSION_ID_RE = /^[A-Za-z0-9_-]{1,200}$/;
|
|
44
|
+
const AGENT_NAME_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
45
|
+
const TITLE_MAX = 200;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolve the opencode `directory` for a known session. Used by
|
|
49
|
+
* PATCH and DELETE so a session started from any worktree is
|
|
50
|
+
* addressed through its own scope.
|
|
51
|
+
*
|
|
52
|
+
* @param {ReturnType<typeof readServeInfo>} info
|
|
53
|
+
* @param {string} sessionId
|
|
54
|
+
* @returns {Promise<string|null>}
|
|
55
|
+
*/
|
|
56
|
+
async function resolveSessionDirectory(info, sessionId) {
|
|
57
|
+
if (!info) return null;
|
|
58
|
+
try {
|
|
59
|
+
const sessions = await listOpencodeSessions(info, 5_000);
|
|
60
|
+
if (Array.isArray(sessions)) {
|
|
61
|
+
const entry = sessions.find((s) => s && s.id === sessionId);
|
|
62
|
+
const dir = entry?.location?.directory;
|
|
63
|
+
if (typeof dir === 'string' && dir.length > 0) return dir;
|
|
64
|
+
}
|
|
65
|
+
} catch {
|
|
66
|
+
/* fall through to worktree */
|
|
67
|
+
}
|
|
68
|
+
if (typeof info.worktree === 'string' && info.worktree.length > 0) {
|
|
69
|
+
return info.worktree;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
19
74
|
/**
|
|
20
75
|
* @returns {import('express').Router}
|
|
21
76
|
*/
|
|
@@ -51,5 +106,152 @@ export function createOpencodeSessionsRouter() {
|
|
|
51
106
|
}
|
|
52
107
|
}));
|
|
53
108
|
|
|
109
|
+
// ── POST /api/opencode-sessions/new ───────────────────────────────────
|
|
110
|
+
//
|
|
111
|
+
// Body: { title?: string, agent: string, directory?: string }.
|
|
112
|
+
// When `directory` is omitted we use the opencode plugin's recorded
|
|
113
|
+
// worktree (the active project's path on disk).
|
|
114
|
+
//
|
|
115
|
+
// Returns 201 with `{ id, title, agent, directory, createdAt }`.
|
|
116
|
+
// Returns 400 when the body is missing / invalid.
|
|
117
|
+
// Returns 502 when the upstream opencode serve errors.
|
|
118
|
+
// Returns 503 when the opencode plugin is offline.
|
|
119
|
+
// ────────────────────────────────────────────────────────────────────
|
|
120
|
+
router.post('/opencode-sessions/new', wrap(async (req, res) => {
|
|
121
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
122
|
+
const title = typeof body.title === 'string' && body.title.trim().length > 0
|
|
123
|
+
? body.title.trim().slice(0, TITLE_MAX)
|
|
124
|
+
: null;
|
|
125
|
+
const agent = typeof body.agent === 'string' ? body.agent.trim() : '';
|
|
126
|
+
if (!agent) {
|
|
127
|
+
res.status(400).json({ error: 'bad_request', message: '`agent` is required' });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (!AGENT_NAME_RE.test(agent)) {
|
|
131
|
+
res.status(400).json({ error: 'bad_request', message: '`agent` is invalid (allowed: [A-Za-z0-9_-]{1,64})' });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (title !== null && title.length > TITLE_MAX) {
|
|
135
|
+
res.status(400).json({ error: 'bad_request', message: `title too long (> ${TITLE_MAX} chars)` });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const info = readServeInfo();
|
|
140
|
+
if (!info) {
|
|
141
|
+
res.status(503).json({
|
|
142
|
+
error: 'plugin_offline',
|
|
143
|
+
message: 'opencode plugin is not running',
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const directory = typeof body.directory === 'string' && body.directory.length > 0
|
|
149
|
+
? body.directory
|
|
150
|
+
: (info.worktree || '');
|
|
151
|
+
|
|
152
|
+
const finalTitle = title || `Chat: ${agent}`;
|
|
153
|
+
const result = await createOpencodeSession(
|
|
154
|
+
info,
|
|
155
|
+
{ title: finalTitle, agent },
|
|
156
|
+
directory,
|
|
157
|
+
);
|
|
158
|
+
if (!result.ok || !result.sessionId) {
|
|
159
|
+
const status = result.status === 404 ? 404 : 502;
|
|
160
|
+
res.status(status).json({
|
|
161
|
+
error: 'opencode_error',
|
|
162
|
+
message: result.error || 'failed to create opencode session',
|
|
163
|
+
});
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
res.status(201).json({
|
|
168
|
+
id: result.sessionId,
|
|
169
|
+
title: finalTitle,
|
|
170
|
+
agent,
|
|
171
|
+
directory,
|
|
172
|
+
createdAt: Date.now(),
|
|
173
|
+
});
|
|
174
|
+
}));
|
|
175
|
+
|
|
176
|
+
// ── PATCH /api/opencode-sessions/:id ──────────────────────────────────
|
|
177
|
+
//
|
|
178
|
+
// Body: { title: string } — required, non-empty, ≤ 200 chars.
|
|
179
|
+
// Returns 200 with `{ id, title }` on success.
|
|
180
|
+
// 400 / 404 (unknown) / 502 / 503 / 504 (timeouts).
|
|
181
|
+
// ────────────────────────────────────────────────────────────────────
|
|
182
|
+
router.patch('/opencode-sessions/:id', wrap(async (req, res) => {
|
|
183
|
+
const sessionId = String(req.params?.id || '');
|
|
184
|
+
if (!SESSION_ID_RE.test(sessionId)) {
|
|
185
|
+
res.status(400).json({ error: 'bad_request', message: 'invalid session id' });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
189
|
+
const title = typeof body.title === 'string' ? body.title.trim() : '';
|
|
190
|
+
if (!title) {
|
|
191
|
+
res.status(400).json({ error: 'bad_request', message: '`title` is required (non-empty)' });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
if (title.length > TITLE_MAX) {
|
|
195
|
+
res.status(400).json({ error: 'bad_request', message: `title too long (> ${TITLE_MAX} chars)` });
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const info = readServeInfo();
|
|
200
|
+
if (!info) {
|
|
201
|
+
res.status(503).json({ error: 'plugin_offline', message: 'opencode plugin is not running' });
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const directory = await resolveSessionDirectory(info, sessionId);
|
|
205
|
+
if (!directory) {
|
|
206
|
+
res.status(503).json({
|
|
207
|
+
error: 'directory_unknown',
|
|
208
|
+
message: 'cannot determine the opencode session directory',
|
|
209
|
+
});
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const result = await updateOpencodeSession(info, sessionId, { title }, directory);
|
|
214
|
+
if (!result.ok) {
|
|
215
|
+
const status = result.status === 404 ? 404 : 502;
|
|
216
|
+
res.status(status).json({ error: 'opencode_error', message: result.error || 'rename failed' });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
res.json({ id: sessionId, title });
|
|
220
|
+
}));
|
|
221
|
+
|
|
222
|
+
// ── DELETE /api/opencode-sessions/:id ─────────────────────────────────
|
|
223
|
+
//
|
|
224
|
+
// No body. Returns 200 on success or if the session was already gone.
|
|
225
|
+
// 400 (invalid id) / 404 (unknown — surfaces when upstream says so) /
|
|
226
|
+
// 502 / 503.
|
|
227
|
+
// ────────────────────────────────────────────────────────────────────
|
|
228
|
+
router.delete('/opencode-sessions/:id', wrap(async (req, res) => {
|
|
229
|
+
const sessionId = String(req.params?.id || '');
|
|
230
|
+
if (!SESSION_ID_RE.test(sessionId)) {
|
|
231
|
+
res.status(400).json({ error: 'bad_request', message: 'invalid session id' });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
const info = readServeInfo();
|
|
235
|
+
if (!info) {
|
|
236
|
+
res.status(503).json({ error: 'plugin_offline', message: 'opencode plugin is not running' });
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const directory = await resolveSessionDirectory(info, sessionId);
|
|
240
|
+
if (!directory) {
|
|
241
|
+
res.status(503).json({
|
|
242
|
+
error: 'directory_unknown',
|
|
243
|
+
message: 'cannot determine the opencode session directory',
|
|
244
|
+
});
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const result = await deleteOpencodeSession(info, sessionId, directory);
|
|
248
|
+
if (!result.ok) {
|
|
249
|
+
const status = result.status === 404 ? 404 : 502;
|
|
250
|
+
res.status(status).json({ error: 'opencode_error', message: result.error || 'delete failed' });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
res.json({ id: sessionId, deleted: true });
|
|
254
|
+
}));
|
|
255
|
+
|
|
54
256
|
return router;
|
|
55
257
|
}
|
|
@@ -1,15 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* src/server/routes/providers.mjs
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* /api/providers/active — current default provider + model
|
|
4
|
+
* Provider subsystem routes (v4.6.0).
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Endpoints (all mounted under /api):
|
|
7
|
+
*
|
|
8
|
+
* Aggregated list (read-only):
|
|
9
|
+
* GET /providers — opencode.json + agents + serve HTTP
|
|
10
|
+
* GET /providers/active — current default provider + model
|
|
11
|
+
* GET /providers/auto-detect — env+config detection
|
|
12
|
+
*
|
|
13
|
+
* Catalog (UI wizard):
|
|
14
|
+
* GET /providers/catalog — full curated provider catalog
|
|
15
|
+
* GET /providers/catalog/search?q=… — fuzzy-search the catalog
|
|
16
|
+
*
|
|
17
|
+
* Provider CRUD (delegates to providersStore):
|
|
18
|
+
* GET /providers — list configured providers
|
|
19
|
+
* POST /providers/auto — "Add with auto" wizard
|
|
20
|
+
* POST /providers/:id/keys — add a backup key
|
|
21
|
+
* DELETE /providers/:id/keys/:envVar — remove a key
|
|
22
|
+
* POST /providers/:id/rotate — manual rotation trigger
|
|
23
|
+
* PUT /providers/:id/keys/:envVar/status — set status
|
|
24
|
+
*
|
|
25
|
+
* The legacy `/api/config/providers/*` surface remains in routes/config.mjs
|
|
26
|
+
* for back-compat — those routes just delegate to the same providersStore
|
|
27
|
+
* functions used here.
|
|
10
28
|
*/
|
|
11
29
|
import { Router } from 'express';
|
|
12
30
|
import { providersStore } from '../providers-store.mjs';
|
|
31
|
+
import {
|
|
32
|
+
PROVIDER_CATALOG,
|
|
33
|
+
listCatalog,
|
|
34
|
+
searchProviders,
|
|
35
|
+
findCatalogEntry,
|
|
36
|
+
getActiveKey,
|
|
37
|
+
rotateKey,
|
|
38
|
+
markKeyError,
|
|
39
|
+
markKeySuccess,
|
|
40
|
+
addBackupKey,
|
|
41
|
+
removeBackupKey,
|
|
42
|
+
setKeyStatus,
|
|
43
|
+
} from '../providers-store.mjs';
|
|
13
44
|
import { wrap } from './_shared.mjs';
|
|
14
45
|
|
|
15
46
|
/**
|
|
@@ -18,6 +49,8 @@ import { wrap } from './_shared.mjs';
|
|
|
18
49
|
export function createProvidersRouter() {
|
|
19
50
|
const router = Router();
|
|
20
51
|
|
|
52
|
+
// ── Aggregated read-only endpoints (preserved from previous versions) ──
|
|
53
|
+
|
|
21
54
|
// Surface-everything endpoint the dashboard uses to populate the
|
|
22
55
|
// Providers card on the Overview tab. Reads from opencode.json +
|
|
23
56
|
// agent frontmatter + (best-effort) the running opencode serve HTTP
|
|
@@ -43,5 +76,233 @@ export function createProvidersRouter() {
|
|
|
43
76
|
res.json({ providers: results, count: results.length });
|
|
44
77
|
}));
|
|
45
78
|
|
|
79
|
+
// ── v4.6.0 catalog (UI wizard) ──────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
// GET /providers/catalog — full curated list, UI-safe shape.
|
|
82
|
+
router.get('/providers/catalog', wrap(async (_req, res) => {
|
|
83
|
+
res.json({ catalog: listCatalog(), count: PROVIDER_CATALOG.length });
|
|
84
|
+
}));
|
|
85
|
+
|
|
86
|
+
// GET /providers/catalog/search?q=minimax&limit=20 — fuzzy search.
|
|
87
|
+
router.get('/providers/catalog/search', wrap(async (req, res) => {
|
|
88
|
+
const q = typeof req.query.q === 'string' ? req.query.q : '';
|
|
89
|
+
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 20, 1), 50);
|
|
90
|
+
const results = searchProviders(q, { limit });
|
|
91
|
+
res.json({ query: q, results, count: results.length });
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
// ── v4.6.0 provider CRUD (rotation-aware) ──────────────────────────────
|
|
95
|
+
|
|
96
|
+
// GET /providers/list — list configured providers from opencode.json.
|
|
97
|
+
// (Distinct path from /providers because /providers returns the
|
|
98
|
+
// aggregated discovery view; /providers/list returns the persisted
|
|
99
|
+
// opencode.json shape.)
|
|
100
|
+
router.get('/providers/list', wrap(async (_req, res) => {
|
|
101
|
+
res.json({ providers: providersStore.list() });
|
|
102
|
+
}));
|
|
103
|
+
|
|
104
|
+
// POST /providers/auto — v4.6.0 "Add with auto" wizard.
|
|
105
|
+
//
|
|
106
|
+
// The new wizard is env-var-first: the user pastes an envVar (or
|
|
107
|
+
// types a label and we synthesize one), and we look up the catalog
|
|
108
|
+
// entry by `providerId` to fill baseURL/models. We do NOT probe
|
|
109
|
+
// /v1/models here — the user hasn't pasted a key yet (it's in env).
|
|
110
|
+
//
|
|
111
|
+
// Body: { providerId: string, envVar: string, label?: string,
|
|
112
|
+
// setAsSystemLlm?: boolean, preferredModel?: string }
|
|
113
|
+
router.post('/providers/auto', wrap(async (req, res) => {
|
|
114
|
+
const body = req.body || {};
|
|
115
|
+
const providerId = typeof body.providerId === 'string' ? body.providerId.trim() : '';
|
|
116
|
+
const envVar = typeof body.envVar === 'string' ? body.envVar.trim() : '';
|
|
117
|
+
const label = typeof body.label === 'string' ? body.label.trim() : '';
|
|
118
|
+
|
|
119
|
+
if (!providerId) {
|
|
120
|
+
res.status(400).json({ ok: false, error: 'providerId_required', message: 'providerId is required' });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (!envVar) {
|
|
124
|
+
res.status(400).json({ ok: false, error: 'envVar_required', message: 'envVar is required' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (!/^[A-Z][A-Z0-9_]{1,62}$/.test(envVar)) {
|
|
128
|
+
res.status(400).json({ ok: false, error: 'invalid_envVar', message: 'envVar must be UPPER_SNAKE_CASE' });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const entry = findCatalogEntry(providerId);
|
|
132
|
+
if (!entry) {
|
|
133
|
+
res.status(404).json({
|
|
134
|
+
ok: false,
|
|
135
|
+
error: 'unknown_provider',
|
|
136
|
+
message: `Unknown providerId "${providerId}". Try GET /api/providers/catalog.`,
|
|
137
|
+
});
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Build the provider entry: keys[0] = the env-var key the user
|
|
142
|
+
// supplied. We do NOT persist any key value — it lives in the env.
|
|
143
|
+
const cfg = providersStore.add({
|
|
144
|
+
id: providerId,
|
|
145
|
+
name: entry.name,
|
|
146
|
+
baseURL: typeof body.baseURL === 'string' && body.baseURL.trim() ? body.baseURL.trim() : entry.baseURL,
|
|
147
|
+
apiKey: `<env:${envVar}>`, // marker — opencode sees a non-empty key
|
|
148
|
+
keys: [
|
|
149
|
+
{
|
|
150
|
+
envVar,
|
|
151
|
+
label: label || 'Primary',
|
|
152
|
+
status: 'active',
|
|
153
|
+
lastError: null,
|
|
154
|
+
errorCount: 0,
|
|
155
|
+
lastUsed: null,
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
models: Array.isArray(body.models) && body.models.length > 0 ? body.models : entry.models.slice(),
|
|
159
|
+
enabled: true,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Optional: set as the system LLM so /api/llm/* hits this provider.
|
|
163
|
+
if (body.setAsSystemLlm !== false) {
|
|
164
|
+
// Lazy: use the store's addWithAuto path so we share its
|
|
165
|
+
// systemLlm update logic.
|
|
166
|
+
const models = Array.isArray(cfg.models) && cfg.models.length > 0 ? cfg.models : entry.models;
|
|
167
|
+
const preferredModel = typeof body.preferredModel === 'string' && body.preferredModel.trim()
|
|
168
|
+
? body.preferredModel.trim()
|
|
169
|
+
: (Array.isArray(models) ? models[0] : null);
|
|
170
|
+
if (preferredModel) {
|
|
171
|
+
// providersStore doesn't expose a public systemLlm setter, so we
|
|
172
|
+
// write it through addWithAuto's side-effect path: call it with
|
|
173
|
+
// a noop key (since the env-var key lives in env, we can't probe
|
|
174
|
+
// models here, but we can still set systemLlm). Use a dummy
|
|
175
|
+
// string — it never touches the disk because addWithAuto only
|
|
176
|
+
// writes a key if it differs from the existing one.
|
|
177
|
+
try {
|
|
178
|
+
await providersStore.addWithAuto({
|
|
179
|
+
id: providerId,
|
|
180
|
+
apiKey: `env:${envVar}`, // bypass probe path; addWithAuto will keep existing
|
|
181
|
+
setAsSystemLlm: true,
|
|
182
|
+
preferredModel,
|
|
183
|
+
systemLlmModel: preferredModel,
|
|
184
|
+
});
|
|
185
|
+
} catch {
|
|
186
|
+
// addWithAuto may reject due to keyPattern — that's fine, the
|
|
187
|
+
// systemLlm side-effect runs after the key check. We ignore
|
|
188
|
+
// any throw here; the provider is already added.
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
res.status(201).json({
|
|
194
|
+
ok: true,
|
|
195
|
+
provider: providersStore.get(providerId),
|
|
196
|
+
catalog: { id: entry.id, name: entry.name, baseURL: entry.baseURL },
|
|
197
|
+
});
|
|
198
|
+
}));
|
|
199
|
+
|
|
200
|
+
// POST /providers/:id/keys — add a backup key to an existing provider.
|
|
201
|
+
// Body: { envVar: string, label?: string }
|
|
202
|
+
router.post('/providers/:id/keys', wrap(async (req, res) => {
|
|
203
|
+
const { id } = req.params;
|
|
204
|
+
const body = req.body || {};
|
|
205
|
+
const envVar = typeof body.envVar === 'string' ? body.envVar.trim() : '';
|
|
206
|
+
const label = typeof body.label === 'string' ? body.label.trim() : '';
|
|
207
|
+
if (!envVar) {
|
|
208
|
+
res.status(400).json({ ok: false, error: 'envVar_required', message: 'envVar is required' });
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (!/^[A-Z][A-Z0-9_]{1,62}$/.test(envVar)) {
|
|
212
|
+
res.status(400).json({ ok: false, error: 'invalid_envVar', message: 'envVar must be UPPER_SNAKE_CASE' });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (!providersStore.get(id)) {
|
|
216
|
+
res.status(404).json({ ok: false, error: 'not_found', message: `provider "${id}" not found` });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
const inserted = addBackupKey(id, envVar, label);
|
|
221
|
+
res.status(201).json({ ok: true, key: inserted, provider: providersStore.get(id) });
|
|
222
|
+
} catch (err) {
|
|
223
|
+
res.status(400).json({ ok: false, error: 'add_failed', message: err.message });
|
|
224
|
+
}
|
|
225
|
+
}));
|
|
226
|
+
|
|
227
|
+
// DELETE /providers/:id/keys/:envVar — remove a key. Refuses if
|
|
228
|
+
// it would leave the provider with zero keys.
|
|
229
|
+
router.delete('/providers/:id/keys/:envVar', wrap(async (req, res) => {
|
|
230
|
+
const { id, envVar } = req.params;
|
|
231
|
+
if (!providersStore.get(id)) {
|
|
232
|
+
res.status(404).json({ ok: false, error: 'not_found' });
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const ok = removeBackupKey(id, envVar);
|
|
237
|
+
if (!ok) {
|
|
238
|
+
res.status(404).json({ ok: false, error: 'key_not_found', message: `no key with envVar "${envVar}"` });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
res.status(200).json({ ok: true, provider: providersStore.get(id) });
|
|
242
|
+
} catch (err) {
|
|
243
|
+
res.status(400).json({ ok: false, error: 'remove_failed', message: err.message });
|
|
244
|
+
}
|
|
245
|
+
}));
|
|
246
|
+
|
|
247
|
+
// POST /providers/:id/rotate — manual rotation trigger. Marks the
|
|
248
|
+
// current active key as disabled and promotes the next one.
|
|
249
|
+
router.post('/providers/:id/rotate', wrap(async (req, res) => {
|
|
250
|
+
const { id } = req.params;
|
|
251
|
+
if (!providersStore.get(id)) {
|
|
252
|
+
res.status(404).json({ ok: false, error: 'not_found' });
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const newActive = rotateKey(id);
|
|
256
|
+
if (!newActive) {
|
|
257
|
+
res.status(409).json({ ok: false, error: 'no_rotation_target', message: 'no other keys available to rotate to' });
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
res.json({ ok: true, active: newActive, provider: providersStore.get(id) });
|
|
261
|
+
}));
|
|
262
|
+
|
|
263
|
+
// PUT /providers/:id/keys/:envVar/status — set a key's status
|
|
264
|
+
// manually (operator override).
|
|
265
|
+
// Body: { status: 'active'|'standby'|'disabled'|'cooldown' }
|
|
266
|
+
router.put('/providers/:id/keys/:envVar/status', wrap(async (req, res) => {
|
|
267
|
+
const { id, envVar } = req.params;
|
|
268
|
+
const status = req.body?.status;
|
|
269
|
+
if (!providersStore.get(id)) {
|
|
270
|
+
res.status(404).json({ ok: false, error: 'not_found' });
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (!['active', 'standby', 'disabled', 'cooldown'].includes(status)) {
|
|
274
|
+
res.status(400).json({ ok: false, error: 'invalid_status', message: 'status must be active|standby|disabled|cooldown' });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
const updated = setKeyStatus(id, envVar, status);
|
|
279
|
+
res.json({ ok: true, key: updated, provider: providersStore.get(id) });
|
|
280
|
+
} catch (err) {
|
|
281
|
+
res.status(400).json({ ok: false, error: 'set_status_failed', message: err.message });
|
|
282
|
+
}
|
|
283
|
+
}));
|
|
284
|
+
|
|
285
|
+
// GET /providers/:id/active-key — read the active key + value from env.
|
|
286
|
+
// (Handy for the dashboard's "what's in use right now" diagnostic card.)
|
|
287
|
+
router.get('/providers/:id/active-key', wrap(async (req, res) => {
|
|
288
|
+
const { id } = req.params;
|
|
289
|
+
const active = getActiveKey(id);
|
|
290
|
+
if (!active) {
|
|
291
|
+
res.status(404).json({ ok: false, error: 'no_keys', message: 'no keys configured for this provider' });
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
// Mask the value (don't echo raw key in API responses).
|
|
295
|
+
res.json({
|
|
296
|
+
ok: true,
|
|
297
|
+
active: {
|
|
298
|
+
envVar: active.envVar,
|
|
299
|
+
label: active.label,
|
|
300
|
+
status: active.status,
|
|
301
|
+
keyPreview: active.key.length > 8 ? `${active.key.slice(0, 2)}...${active.key.slice(-2)}` : (active.key ? '***short***' : ''),
|
|
302
|
+
keySet: active.key.length > 0,
|
|
303
|
+
},
|
|
304
|
+
});
|
|
305
|
+
}));
|
|
306
|
+
|
|
46
307
|
return router;
|
|
47
308
|
}
|