pilotswarm 0.5.42 → 0.5.44
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/mcp/README.md +25 -1
- package/mcp/dist/src/auth.d.ts.map +1 -1
- package/mcp/dist/src/auth.js +5 -3
- package/mcp/dist/src/auth.js.map +1 -1
- package/mcp/dist/src/resources/models.d.ts.map +1 -1
- package/mcp/dist/src/resources/models.js +21 -14
- package/mcp/dist/src/resources/models.js.map +1 -1
- package/mcp/dist/src/server.d.ts.map +1 -1
- package/mcp/dist/src/server.js +2 -0
- package/mcp/dist/src/server.js.map +1 -1
- package/mcp/dist/src/tools/agent-packages.d.ts.map +1 -1
- package/mcp/dist/src/tools/agent-packages.js +28 -16
- package/mcp/dist/src/tools/agent-packages.js.map +1 -1
- package/mcp/dist/src/tools/models.d.ts.map +1 -1
- package/mcp/dist/src/tools/models.js +55 -47
- package/mcp/dist/src/tools/models.js.map +1 -1
- package/mcp/dist/src/tools/providers.d.ts +18 -0
- package/mcp/dist/src/tools/providers.d.ts.map +1 -0
- package/mcp/dist/src/tools/providers.js +284 -0
- package/mcp/dist/src/tools/providers.js.map +1 -0
- package/mcp/dist/src/tools/sessions.d.ts.map +1 -1
- package/mcp/dist/src/tools/sessions.js +10 -1
- package/mcp/dist/src/tools/sessions.js.map +1 -1
- package/mcp/dist/src/tools/system.js +2 -2
- package/mcp/dist/src/tools/system.js.map +1 -1
- package/mcp/dist/src/util/respond.d.ts.map +1 -1
- package/mcp/dist/src/util/respond.js +6 -0
- package/mcp/dist/src/util/respond.js.map +1 -1
- package/package.json +3 -3
- package/tui/README.md +8 -5
- package/tui/src/agents-cli.js +80 -78
- package/tui/src/app.js +119 -17
- package/tui/src/node-sdk-transport.js +150 -347
- package/ui/core/src/controller.js +1079 -23
- package/ui/core/src/reducer.js +649 -1
- package/ui/core/src/selectors.js +891 -6
- package/ui/core/src/state.js +163 -3
- package/ui/react/src/components.js +146 -4
- package/ui/react/src/web-app.js +1732 -50
- package/web/README.md +4 -4
- package/web/api/router.js +33 -0
- package/web/dist/assets/{abnfDiagram-VRR7QNED-Jh9nLlRI.js → abnfDiagram-VRR7QNED-67Q0WWge.js} +1 -1
- package/web/dist/assets/{arc-CmN4jFMW.js → arc-BvcYFngS.js} +1 -1
- package/web/dist/assets/{architectureDiagram-ZJ3FMSHR-DgUSVwl7.js → architectureDiagram-ZJ3FMSHR-CPBN0rk8.js} +1 -1
- package/web/dist/assets/{blockDiagram-677ZJIJ3-CCJEXNgp.js → blockDiagram-677ZJIJ3-D_hp1Oah.js} +1 -1
- package/web/dist/assets/{c4Diagram-LMCZKHZV-DG11zUre.js → c4Diagram-LMCZKHZV-DAOVGMIW.js} +1 -1
- package/web/dist/assets/channel-DiW7lUT8.js +1 -0
- package/web/dist/assets/{chunk-2Q5K7J3B-Cl8MUWGh.js → chunk-2Q5K7J3B-DajZuaot.js} +1 -1
- package/web/dist/assets/{chunk-32BRIVSS-BaLutEQY.js → chunk-32BRIVSS-C6pXBsgt.js} +1 -1
- package/web/dist/assets/{chunk-5VM5RSS4-BOPcnMeW.js → chunk-5VM5RSS4-780edrmi.js} +1 -1
- package/web/dist/assets/{chunk-EX3LRPZG-bY2tdfJ2.js → chunk-EX3LRPZG-C0e4NVuz.js} +1 -1
- package/web/dist/assets/{chunk-JWPE2WC7-CASBfQW6.js → chunk-JWPE2WC7-uRe83I6C.js} +1 -1
- package/web/dist/assets/{chunk-MOJQB5TN-WrEHfiAj.js → chunk-MOJQB5TN-B0WPoPjh.js} +1 -1
- package/web/dist/assets/{chunk-RYQCIY6F-XQoZii_K.js → chunk-RYQCIY6F-eRFuHHyG.js} +1 -1
- package/web/dist/assets/{chunk-V7JOEXUC-BvA5V7VF.js → chunk-V7JOEXUC-C8FDXprk.js} +1 -1
- package/web/dist/assets/{chunk-VR4S4FIN-SK_jCKYo.js → chunk-VR4S4FIN-DPFpLQdB.js} +1 -1
- package/web/dist/assets/{chunk-XXDRQBXY-CtZZImS8.js → chunk-XXDRQBXY-CoGQOPKW.js} +1 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-CpAhBd5x.js +1 -0
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-CpAhBd5x.js +1 -0
- package/web/dist/assets/{cose-bilkent-JH36ORCC-PRXhsdiX.js → cose-bilkent-JH36ORCC-BTBSfO5v.js} +1 -1
- package/web/dist/assets/{cynefin-VYW2F7L2-CzUocqvb.js → cynefin-VYW2F7L2-DgTkQEoK.js} +1 -1
- package/web/dist/assets/{cynefinDiagram-TSTJHNR4-DYGEqQ23.js → cynefinDiagram-TSTJHNR4-DiBMlKoP.js} +1 -1
- package/web/dist/assets/{dagre-VKFMJZFB-CAyb_zBo.js → dagre-VKFMJZFB-Qv-4dcbk.js} +1 -1
- package/web/dist/assets/{diagram-FQU43EPY-CvpXMmL-.js → diagram-FQU43EPY-CgyAb7yx.js} +1 -1
- package/web/dist/assets/{diagram-G47NLZAW-DBQRZJS0.js → diagram-G47NLZAW-BxN-KcGp.js} +1 -1
- package/web/dist/assets/{diagram-NH7WQ7WH-BGzD61gL.js → diagram-NH7WQ7WH-BiellpWz.js} +1 -1
- package/web/dist/assets/{diagram-OA4YK3LP-DqQf4oMW.js → diagram-OA4YK3LP-C8Ktgrwv.js} +1 -1
- package/web/dist/assets/{diagram-WEI45ONY-CYHoWtg7.js → diagram-WEI45ONY-Bj5K1X91.js} +1 -1
- package/web/dist/assets/{ebnfDiagram-CCIWWBDH-oMOMgcs8.js → ebnfDiagram-CCIWWBDH-DNQI2XT9.js} +1 -1
- package/web/dist/assets/{erDiagram-Q63AITRT-DaZaseAH.js → erDiagram-Q63AITRT-D5Xkllu6.js} +1 -1
- package/web/dist/assets/{flowDiagram-23GEKE2U-BIe9TtAL.js → flowDiagram-23GEKE2U-DJpTtMuj.js} +1 -1
- package/web/dist/assets/{ganttDiagram-NO4QXBWP-DitrY3hM.js → ganttDiagram-NO4QXBWP-C-EQlCJf.js} +1 -1
- package/web/dist/assets/{gitGraphDiagram-IHSO6WYX-DRjIH_99.js → gitGraphDiagram-IHSO6WYX-C8vpSbvW.js} +1 -1
- package/web/dist/assets/index-C2ioXNJJ.js +24 -0
- package/web/dist/assets/index-CVYm02UI.css +1 -0
- package/web/dist/assets/{infoDiagram-FWYZ7A6U-BJCx4g9_.js → infoDiagram-FWYZ7A6U-CKNzsR3r.js} +1 -1
- package/web/dist/assets/{ishikawaDiagram-FXEZZL3T-CLxmaTWp.js → ishikawaDiagram-FXEZZL3T-CjyQq3lf.js} +1 -1
- package/web/dist/assets/{journeyDiagram-5HDEW3XC-D2_VI8CC.js → journeyDiagram-5HDEW3XC-CK3EEkJt.js} +1 -1
- package/web/dist/assets/{kanban-definition-HUTT4EX6-DnUq9E7I.js → kanban-definition-HUTT4EX6-CHQ8YkPx.js} +1 -1
- package/web/dist/assets/{linear-mKMwWrAB.js → linear-CUYirs1h.js} +1 -1
- package/web/dist/assets/{mermaid.core-CApAWULn.js → mermaid.core-Di8sQC4g.js} +4 -4
- package/web/dist/assets/{mindmap-definition-LN4V7U3C-D2twxIMO.js → mindmap-definition-LN4V7U3C-D38dmHP5.js} +1 -1
- package/web/dist/assets/{papaparse.min-BhDLFNak.js → papaparse.min-DWBU_pXS.js} +1 -1
- package/web/dist/assets/{pegDiagram-2B236MQR-D2RtIeDP.js → pegDiagram-2B236MQR-BU759QCo.js} +1 -1
- package/web/dist/assets/{pieDiagram-ENE6RG2P-vonumKaj.js → pieDiagram-ENE6RG2P-BbpfHbsj.js} +1 -1
- package/web/dist/assets/pilotswarm-DrM3pXOV.js +97 -0
- package/web/dist/assets/{quadrantDiagram-ABIIQ3AL-CAZL1kJH.js → quadrantDiagram-ABIIQ3AL-BYZ3XHZn.js} +1 -1
- package/web/dist/assets/{railroadDiagram-RFXS5EU6-CgpDomFp.js → railroadDiagram-RFXS5EU6-D18gkZBJ.js} +1 -1
- package/web/dist/assets/{requirementDiagram-TGXJPOKE-9B6yfsXV.js → requirementDiagram-TGXJPOKE-D6vo26Ib.js} +1 -1
- package/web/dist/assets/{sankeyDiagram-HTMAVEWB-B_FAnwxi.js → sankeyDiagram-HTMAVEWB-CxQwXnRE.js} +1 -1
- package/web/dist/assets/{sequenceDiagram-DBY2YBRQ-BbL6OCms.js → sequenceDiagram-DBY2YBRQ-CfGisw4A.js} +1 -1
- package/web/dist/assets/{sizeCapture-X5ZJPWSS-DIRKFU8M.js → sizeCapture-X5ZJPWSS-BDK7-yA-.js} +1 -1
- package/web/dist/assets/{stateDiagram-2N3HPSRC-pUX5NF32.js → stateDiagram-2N3HPSRC-8IyidYSV.js} +1 -1
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-F7qYkvpr.js +1 -0
- package/web/dist/assets/{swimlanes-5IMT3BWC-DO7BjQ4-.js → swimlanes-5IMT3BWC-CgNqXCvM.js} +2 -2
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-CUKrHLkY.js +8 -0
- package/web/dist/assets/{timeline-definition-FHXFAJF6-BcbYRQ7s.js → timeline-definition-FHXFAJF6-DAJsGj6d.js} +1 -1
- package/web/dist/assets/{vennDiagram-L72KCM5P-CUt_orN5.js → vennDiagram-L72KCM5P-DDVdvG5k.js} +1 -1
- package/web/dist/assets/{wardleyDiagram-EHGQE667-BlUPWQdi.js → wardleyDiagram-EHGQE667-CiAs_m72.js} +1 -1
- package/web/dist/assets/{xychartDiagram-FW5EYKEG-CDW7A5_U.js → xychartDiagram-FW5EYKEG-D6OLJtsD.js} +1 -1
- package/web/dist/index.html +3 -3
- package/web/runtime.js +220 -2
- package/web/server.js +35 -7
- package/web/dist/assets/channel-BQBGhjIw.js +0 -1
- package/web/dist/assets/classDiagram-OUVF2IWQ-BIr20mVO.js +0 -1
- package/web/dist/assets/classDiagram-v2-EOCWNBFH-BIr20mVO.js +0 -1
- package/web/dist/assets/index-Cl6-pI9g.js +0 -24
- package/web/dist/assets/index-DRLA5aNR.css +0 -1
- package/web/dist/assets/pilotswarm-DETFHWYc.js +0 -97
- package/web/dist/assets/stateDiagram-v2-6OUMAXLB-J_okbpZN.js +0 -1
- package/web/dist/assets/swimlanesDiagram-G3AALYLV-gTxQyRZa.js +0 -8
package/ui/react/src/web-app.js
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
selectLogFilterModal,
|
|
47
47
|
selectModelPickerModal,
|
|
48
48
|
selectNavigationError,
|
|
49
|
+
selectProviderTable,
|
|
49
50
|
selectReasoningEffortPickerModal,
|
|
50
51
|
selectContextTierPickerModal,
|
|
51
52
|
selectRenameSessionModal,
|
|
@@ -4003,7 +4004,7 @@ function ScrollLinesPanel({ title, titleRight = null, color, focused, actions, l
|
|
|
4003
4004
|
*/
|
|
4004
4005
|
const SESSION_DETAIL_NONE = "—";
|
|
4005
4006
|
|
|
4006
|
-
function SessionDetailBox({ session, childCount = 0 }) {
|
|
4007
|
+
function SessionDetailBox({ session, childCount = 0, pause = null, controller = null }) {
|
|
4007
4008
|
// EVERY field renders on EVERY selection, empty ones as an em dash. The box
|
|
4008
4009
|
// is a fixed grid of rows, so moving through the list cannot change its
|
|
4009
4010
|
// height — a box that grew and shrank would shove the list under the
|
|
@@ -4060,6 +4061,37 @@ function SessionDetailBox({ session, childCount = 0 }) {
|
|
|
4060
4061
|
|
|
4061
4062
|
const statusSummary = selectSessionStatusSummary(session);
|
|
4062
4063
|
|
|
4064
|
+
// Why this session is stopped, where the person is standing.
|
|
4065
|
+
//
|
|
4066
|
+
// "(waiting)" is a state, not a reason, and the four reasons have four
|
|
4067
|
+
// different remedies — so the sentence is the server's own, the same one
|
|
4068
|
+
// the "Paused now" band shows, with a way through to the provider that
|
|
4069
|
+
// caused it. `waitReason` covers every OTHER kind of wait (a scheduled
|
|
4070
|
+
// one, an agent asking to sleep), which is a different fact and says so.
|
|
4071
|
+
const waitReason = typeof session?.waitReason === "string" && session.waitReason.trim()
|
|
4072
|
+
? session.waitReason.trim()
|
|
4073
|
+
: null;
|
|
4074
|
+
const pauseBlock = (!pause && !waitReason) ? null : React.createElement("div", {
|
|
4075
|
+
className: `ps-session-detail-pause${pause ? " is-paused" : ""}`,
|
|
4076
|
+
},
|
|
4077
|
+
React.createElement("span", { className: "ps-session-detail-label" }, pause ? "Paused" : "Waiting"),
|
|
4078
|
+
React.createElement("span", { className: "ps-session-detail-why" },
|
|
4079
|
+
// The sentence already says when it clears wherever it can; `clears`
|
|
4080
|
+
// only fills the gap where it cannot.
|
|
4081
|
+
pause ? pause.reason : waitReason,
|
|
4082
|
+
pause && pause.clears
|
|
4083
|
+
? React.createElement("span", { className: "ps-session-detail-clears" }, pause.clears)
|
|
4084
|
+
: null,
|
|
4085
|
+
pause && controller ? React.createElement("button", {
|
|
4086
|
+
type: "button", className: "ps-budget-link",
|
|
4087
|
+
title: pause.provider
|
|
4088
|
+
? `Open ${pause.provider} in Providers & Budgets`
|
|
4089
|
+
: "Open providers and budgets",
|
|
4090
|
+
onClick: () => controller.openBudget(
|
|
4091
|
+
pause.provider ? { provider: pause.provider } : {},
|
|
4092
|
+
).catch(() => {}),
|
|
4093
|
+
}, pause.provider ? `Open ${pause.provider}` : "Open providers and budgets") : null));
|
|
4094
|
+
|
|
4063
4095
|
return React.createElement("div", { className: "ps-session-detail-box" },
|
|
4064
4096
|
// The row above ellipsizes to one line, so this is the only place the
|
|
4065
4097
|
// whole name is legible. Two lines are RESERVED whether or not they are
|
|
@@ -4076,7 +4108,10 @@ function SessionDetailBox({ session, childCount = 0 }) {
|
|
|
4076
4108
|
// selectSessionStatusSummary for why raw made this flip idle/waiting.
|
|
4077
4109
|
field("Updated", statusSummary ? `${statusSummary.relative} (${statusSummary.status})` : null),
|
|
4078
4110
|
field("Children", children),
|
|
4079
|
-
field("Access", access)
|
|
4111
|
+
field("Access", access),
|
|
4112
|
+
// Outside the fixed grid above: this one is a sentence, and the grid's
|
|
4113
|
+
// whole point is that every row is one clamped line.
|
|
4114
|
+
pauseBlock);
|
|
4080
4115
|
}
|
|
4081
4116
|
|
|
4082
4117
|
/**
|
|
@@ -4477,6 +4512,11 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
4477
4512
|
// every row until these two lines existed.
|
|
4478
4513
|
canvasBySessionId: state.canvas?.bySessionId,
|
|
4479
4514
|
canvasPrefs: state.canvas?.prefs,
|
|
4515
|
+
// Why a waiting session is waiting. Same trap as the canvas slices
|
|
4516
|
+
// above: omit it and every row computes as if nothing were paused, and
|
|
4517
|
+
// three sessions stopped for three different reasons all read
|
|
4518
|
+
// "waiting".
|
|
4519
|
+
budgetPaused: state.budget?.paused,
|
|
4480
4520
|
}), shallowEqualObject);
|
|
4481
4521
|
const computedRows = React.useMemo(() => selectSessionRows({
|
|
4482
4522
|
sessions: {
|
|
@@ -4504,7 +4544,8 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
4504
4544
|
bySessionId: viewState.canvasBySessionId,
|
|
4505
4545
|
prefs: viewState.canvasPrefs,
|
|
4506
4546
|
},
|
|
4507
|
-
|
|
4547
|
+
budget: { paused: viewState.budgetPaused },
|
|
4548
|
+
}), [viewState.activeSessionId, viewState.auth, viewState.branding, viewState.budgetPaused, viewState.canvasBySessionId, viewState.canvasPrefs, viewState.connectionMode, viewState.filterQuery, viewState.listDeselected, viewState.ownerFilter, viewState.pinnedIds, viewState.manualOrder, viewState.selectedIds, viewState.selectMode, viewState.sessionsById, viewState.sessionsFlat]);
|
|
4508
4549
|
// Hold the previous rows when a poll produced identical output.
|
|
4509
4550
|
const rows = useStableValue(computedRows);
|
|
4510
4551
|
const activeSession = viewState.activeSessionId
|
|
@@ -5237,6 +5278,10 @@ function SessionPane({ controller, actions = null, panelClassName = "", structur
|
|
|
5237
5278
|
? React.createElement(SessionDetailBox, {
|
|
5238
5279
|
session: activeSession,
|
|
5239
5280
|
childCount: activeRow?.childCount || 0,
|
|
5281
|
+
// Why it is stopped, and a way through to the surface that can
|
|
5282
|
+
// change it. The row already carries the joined pause record.
|
|
5283
|
+
pause: activeRow?.pause || null,
|
|
5284
|
+
controller,
|
|
5240
5285
|
})
|
|
5241
5286
|
: null),
|
|
5242
5287
|
(manageOpen && activeSession && !activeSession.isGroup)
|
|
@@ -5341,6 +5386,15 @@ function PlusGlyph() {
|
|
|
5341
5386
|
React.createElement("line", { x1: "5", y1: "12", x2: "19", y2: "12" }));
|
|
5342
5387
|
}
|
|
5343
5388
|
|
|
5389
|
+
function TrashGlyph() {
|
|
5390
|
+
return React.createElement(Glyph, null,
|
|
5391
|
+
React.createElement("path", { d: "M3 6h18" }),
|
|
5392
|
+
React.createElement("path", { d: "M8 6V4h8v2" }),
|
|
5393
|
+
React.createElement("path", { d: "M19 6l-1 15H6L5 6" }),
|
|
5394
|
+
React.createElement("path", { d: "M10 11v5" }),
|
|
5395
|
+
React.createElement("path", { d: "M14 11v5" }));
|
|
5396
|
+
}
|
|
5397
|
+
|
|
5344
5398
|
// Theme picker. Deliberately a contrast disc rather than a sun/moon: the picker
|
|
5345
5399
|
// offers DOOM, WinAMP, win95 and friends, not a light/dark binary.
|
|
5346
5400
|
function ContrastGlyph() {
|
|
@@ -5371,6 +5425,17 @@ function CogGlyph() {
|
|
|
5371
5425
|
React.createElement("path", { d: "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.6a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" }));
|
|
5372
5426
|
}
|
|
5373
5427
|
|
|
5428
|
+
// Providers & Budgets. A coin, not a chart or a gauge: the surface is about
|
|
5429
|
+
// what a turn COSTS and who is paying, and a chart glyph would collide with
|
|
5430
|
+
// the inspector's stats mark.
|
|
5431
|
+
function CoinGlyph() {
|
|
5432
|
+
return React.createElement(Glyph, null,
|
|
5433
|
+
React.createElement("circle", { cx: "12", cy: "12", r: "9" }),
|
|
5434
|
+
React.createElement("path", { d: "M14.5 9.2a2.6 2.6 0 0 0-2.5-1.6c-1.5 0-2.6.8-2.6 2s1 1.7 2.6 2 2.6.8 2.6 2-1.1 2-2.6 2a2.6 2.6 0 0 1-2.5-1.6" }),
|
|
5435
|
+
React.createElement("path", { d: "M12 6v1.6" }),
|
|
5436
|
+
React.createElement("path", { d: "M12 16.4V18" }));
|
|
5437
|
+
}
|
|
5438
|
+
|
|
5374
5439
|
// Restart a system session. Was "↻" — the one text codepoint in an otherwise
|
|
5375
5440
|
// all-SVG row, so it rendered a weight lighter and sat a pixel high.
|
|
5376
5441
|
function RestartGlyph() {
|
|
@@ -8452,6 +8517,7 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
|
|
|
8452
8517
|
setHeaderSlot(!mobile ? document.getElementById("ps-header-toolbar-slot") : null);
|
|
8453
8518
|
}, [mobile]);
|
|
8454
8519
|
const adminVisible = useControllerSelector(controller, (state) => Boolean(state.admin?.visible));
|
|
8520
|
+
const budgetOpen = useControllerSelector(controller, (state) => Boolean(state.ui?.budgetOpen));
|
|
8455
8521
|
const canvasView = useControllerSelector(controller, selectCanvasView, shallowEqualObject);
|
|
8456
8522
|
const diagnosticsOpen = useControllerSelector(controller, (s) => s?.ui?.diagnosticsOpen === true);
|
|
8457
8523
|
const canvasMaximized = useControllerSelector(controller, (s) => s?.ui?.canvasMaximized === true);
|
|
@@ -8521,6 +8587,19 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
|
|
|
8521
8587
|
// (loaded), green + yellow (loaded with unseen changes).
|
|
8522
8588
|
loadedDot: canvasView.exists,
|
|
8523
8589
|
},
|
|
8590
|
+
// Providers & Budgets. Both device classes: what a turn costs, and
|
|
8591
|
+
// what stopped a session, are answers a person needs wherever they
|
|
8592
|
+
// are — and unlike the admin console the surface reflows to a phone.
|
|
8593
|
+
{
|
|
8594
|
+
key: "budget",
|
|
8595
|
+
icon: React.createElement(CoinGlyph),
|
|
8596
|
+
label: budgetOpen ? "Close budget" : "Budget — providers, limits and usage",
|
|
8597
|
+
onClick: () => {
|
|
8598
|
+
if (budgetOpen) controller.closeBudget();
|
|
8599
|
+
else controller.openBudget().catch(() => {});
|
|
8600
|
+
},
|
|
8601
|
+
active: budgetOpen,
|
|
8602
|
+
},
|
|
8524
8603
|
// Diagnostics — Inspector + Activity as one column, desktop only.
|
|
8525
8604
|
//
|
|
8526
8605
|
// They used to be tabs inside the chat pane and were always present.
|
|
@@ -8613,10 +8692,10 @@ function Toolbar({ controller, mobile, canvasPaneOpen = false, onToggleCanvasPan
|
|
|
8613
8692
|
);
|
|
8614
8693
|
}
|
|
8615
8694
|
|
|
8616
|
-
// Two groups. Diagnostics and the admin console are TOOLS — they
|
|
8617
|
-
// the app rather than on the conversation — so they sit apart from
|
|
8618
|
-
// session controls, hard right. Everything else keeps its order.
|
|
8619
|
-
const RIGHT_GROUP = new Set(["diagnostics", "admin"]);
|
|
8695
|
+
// Two groups. Budget, Diagnostics and the admin console are TOOLS — they
|
|
8696
|
+
// act on the app rather than on the conversation — so they sit apart from
|
|
8697
|
+
// the session controls, hard right. Everything else keeps its order.
|
|
8698
|
+
const RIGHT_GROUP = new Set(["budget", "diagnostics", "admin"]);
|
|
8620
8699
|
|
|
8621
8700
|
// While the canvas is full screen, ANY other button first drops full
|
|
8622
8701
|
// screen and then does its own job. Pressing Filter and watching nothing
|
|
@@ -9261,6 +9340,1364 @@ function DiagnosticsSplitResizeHandle({ controller, splitAdjust = 0 }) {
|
|
|
9261
9340
|
|
|
9262
9341
|
|
|
9263
9342
|
|
|
9343
|
+
// ── Providers & Budgets ─────────────────────────────────────────────────────
|
|
9344
|
+
//
|
|
9345
|
+
// One table. docs/proposals/providers-and-budgets-meters.md is the build spec:
|
|
9346
|
+
// every provider the viewer can use, with what has been spent against what may
|
|
9347
|
+
// be spent, for the day, the week and the month. Selecting a row expands its
|
|
9348
|
+
// per-model limits and opens its day-by-day chart underneath.
|
|
9349
|
+
//
|
|
9350
|
+
// A cell is `used / quota`. A period with no limit shows ∞, and the figure
|
|
9351
|
+
// beside it is still real usage — the meter runs whether or not anybody capped
|
|
9352
|
+
// it.
|
|
9353
|
+
//
|
|
9354
|
+
// TWO PAIRS OF NUMBERS, AND THEY ARE NEVER MIXED. Unticked, every cell is the
|
|
9355
|
+
// viewer's own spend against their share of the limit. Ticked, every cell is
|
|
9356
|
+
// everyone's spend against the limit itself. The selector decides which pair a
|
|
9357
|
+
// cell holds; the heading over the three columns says which one is on screen;
|
|
9358
|
+
// nothing in this file recomputes either.
|
|
9359
|
+
//
|
|
9360
|
+
// Nothing here decides who may do what. Every change goes through the
|
|
9361
|
+
// controller to the `cms_provider_*` procedures, the DATABASE refuses what the
|
|
9362
|
+
// viewer may not do, and its refusal — already written for a person, and
|
|
9363
|
+
// naming the remedy — is printed unchanged.
|
|
9364
|
+
//
|
|
9365
|
+
// The vocabulary is binding: provider, limit, allowance, hold, paused, period.
|
|
9366
|
+
// Never pool, payer, grant, quota rule, or bind mode.
|
|
9367
|
+
|
|
9368
|
+
const BUDGET_PERIOD_OPTIONS = [
|
|
9369
|
+
{ value: "day", label: "Daily" },
|
|
9370
|
+
{ value: "week", label: "Weekly" },
|
|
9371
|
+
{ value: "month", label: "Monthly" },
|
|
9372
|
+
];
|
|
9373
|
+
const BUDGET_PERIOD_TITLES = { day: "Daily", week: "Weekly", month: "Monthly" };
|
|
9374
|
+
|
|
9375
|
+
// Plain under 90%, amber to 100%, red over. Colour is never the only signal —
|
|
9376
|
+
// the two numbers in the cell already say it — so this only tints the used
|
|
9377
|
+
// figure and nothing depends on being able to see the tint.
|
|
9378
|
+
const BUDGET_TONE_CLASS = { plain: "", amber: " warn", red: " over", idle: "" };
|
|
9379
|
+
|
|
9380
|
+
/**
|
|
9381
|
+
* One number format for a WHOLE axis, chosen from its top value.
|
|
9382
|
+
*
|
|
9383
|
+
* A per-value formatter is right for a table and wrong for a scale: the same
|
|
9384
|
+
* y-axis printed 18.4M, 13.8M, 9.21M and 4.60M, and four precisions down one
|
|
9385
|
+
* ruler read as four different units.
|
|
9386
|
+
*/
|
|
9387
|
+
function fixedScaleFormatter(max) {
|
|
9388
|
+
const top = Math.abs(Number(max) || 0);
|
|
9389
|
+
const [unit, div] = top >= 1e9 ? ["B", 1e9]
|
|
9390
|
+
: top >= 1e6 ? ["M", 1e6]
|
|
9391
|
+
: top >= 1e3 ? ["K", 1e3]
|
|
9392
|
+
: ["", 1];
|
|
9393
|
+
const scaled = top / div;
|
|
9394
|
+
const decimals = unit === "" ? 0 : scaled >= 100 ? 0 : scaled >= 10 ? 1 : 2;
|
|
9395
|
+
return (value) => {
|
|
9396
|
+
const v = Number(value);
|
|
9397
|
+
if (!Number.isFinite(v)) return "—";
|
|
9398
|
+
// Zero is zero in every unit; "0.00M" is noise on the baseline.
|
|
9399
|
+
if (v === 0) return "0";
|
|
9400
|
+
return `${(v / div).toFixed(decimals)}${unit}`;
|
|
9401
|
+
};
|
|
9402
|
+
}
|
|
9403
|
+
|
|
9404
|
+
/** Parse "20,000,000" / "20M" / "20000000" to a whole number of tokens, or null. */
|
|
9405
|
+
function parseTokenCount(raw) {
|
|
9406
|
+
const s = String(raw ?? "").trim().replace(/[,_\s]/g, "").toUpperCase();
|
|
9407
|
+
if (!s) return null;
|
|
9408
|
+
const m = /^(\d+(?:\.\d+)?)([KMB])?$/.exec(s);
|
|
9409
|
+
if (!m) return null;
|
|
9410
|
+
const mult = m[2] === "K" ? 1e3 : m[2] === "M" ? 1e6 : m[2] === "B" ? 1e9 : 1;
|
|
9411
|
+
// A whole number of tokens, as the field says. "1.5" used to round to 2 —
|
|
9412
|
+
// one of a bare decimal and "2.5M" is a typo and the other is meant, and
|
|
9413
|
+
// guessing between them is how a limit lands ten times off.
|
|
9414
|
+
if (!m[2] && /\./.test(m[1])) return null;
|
|
9415
|
+
const n = Math.round(Number(m[1]) * mult);
|
|
9416
|
+
// Above 2^53 a JS number stops being the integer that was typed, so the
|
|
9417
|
+
// value SAVED is not the value shown — and past bigint it fails deep in
|
|
9418
|
+
// the database as a bare "Internal server error". Refused here, where the
|
|
9419
|
+
// field can say so, exactly like 0 and a negative already are.
|
|
9420
|
+
if (!Number.isSafeInteger(n)) return null;
|
|
9421
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
9422
|
+
}
|
|
9423
|
+
|
|
9424
|
+
/**
|
|
9425
|
+
* "resets in 2h 31m (00:00 UTC)".
|
|
9426
|
+
*
|
|
9427
|
+
* The clock is formatted ONCE, in the selector (formatUntilLabel), so every
|
|
9428
|
+
* reset on this surface is spelled the same way. This only puts the verb in
|
|
9429
|
+
* front of it. It used to be a second formatter here, which is how one instant
|
|
9430
|
+
* came to read three ways.
|
|
9431
|
+
*/
|
|
9432
|
+
function budgetResetPhrase(resetsLabel) {
|
|
9433
|
+
return resetsLabel ? `resets ${resetsLabel}` : "no reset recorded";
|
|
9434
|
+
}
|
|
9435
|
+
|
|
9436
|
+
/** "3 sessions" / "1 session". */
|
|
9437
|
+
function budgetPlural(n, one, many) {
|
|
9438
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
9439
|
+
}
|
|
9440
|
+
|
|
9441
|
+
/**
|
|
9442
|
+
* A model reference a session can run: `<provider>:<model>`.
|
|
9443
|
+
*
|
|
9444
|
+
* The catalog's `providerId` is the TYPE ("azure-openai"); a provider is an
|
|
9445
|
+
* instance of that type with a name of its own, and both a model-scoped limit
|
|
9446
|
+
* and a default tuple are refused by the database unless the model half names
|
|
9447
|
+
* the provider it belongs to.
|
|
9448
|
+
*/
|
|
9449
|
+
function budgetModelsForProvider(models, provider) {
|
|
9450
|
+
if (!provider?.typeId) return [];
|
|
9451
|
+
return models
|
|
9452
|
+
.filter((model) => model.providerId === provider.typeId)
|
|
9453
|
+
.map((model) => ({
|
|
9454
|
+
qualifiedName: `${provider.name}:${model.modelName}`,
|
|
9455
|
+
modelName: model.modelName,
|
|
9456
|
+
}));
|
|
9457
|
+
}
|
|
9458
|
+
|
|
9459
|
+
/** The provider TYPES this deployment can instantiate, from its model catalog. */
|
|
9460
|
+
function budgetProviderTypes(models) {
|
|
9461
|
+
const seen = new Map();
|
|
9462
|
+
for (const model of models) {
|
|
9463
|
+
if (!model.providerId || seen.has(model.providerId)) continue;
|
|
9464
|
+
seen.set(model.providerId, { id: model.providerId, type: model.providerType || model.providerId });
|
|
9465
|
+
}
|
|
9466
|
+
return [...seen.values()].sort((a, b) => a.id.localeCompare(b.id));
|
|
9467
|
+
}
|
|
9468
|
+
|
|
9469
|
+
/** listModels() is flat on the web transport and grouped on the direct one. */
|
|
9470
|
+
function budgetNormalizeModels(raw) {
|
|
9471
|
+
const top = Array.isArray(raw)
|
|
9472
|
+
? raw
|
|
9473
|
+
: (Array.isArray(raw?.providers) ? raw.providers : (Array.isArray(raw?.models) ? raw.models : []));
|
|
9474
|
+
const flat = top.flatMap((entry) => (
|
|
9475
|
+
Array.isArray(entry?.models)
|
|
9476
|
+
? entry.models.map((model) => ({ ...model, providerId: model.providerId || entry.providerId }))
|
|
9477
|
+
: [entry]
|
|
9478
|
+
));
|
|
9479
|
+
return flat
|
|
9480
|
+
.filter((model) => model && (model.modelName || model.qualifiedName))
|
|
9481
|
+
.map((model) => ({
|
|
9482
|
+
modelName: model.modelName || String(model.qualifiedName).split(":").pop(),
|
|
9483
|
+
providerId: model.providerId || "",
|
|
9484
|
+
providerType: model.providerType || model.providerId || "",
|
|
9485
|
+
}));
|
|
9486
|
+
}
|
|
9487
|
+
|
|
9488
|
+
/**
|
|
9489
|
+
* One sheet for every change on this surface. Local state, no shared slot: a
|
|
9490
|
+
* refused write is READ where the person is looking rather than dismissed, and
|
|
9491
|
+
* the sheet stays open so the value that was refused is still on screen.
|
|
9492
|
+
*/
|
|
9493
|
+
function BudgetSheet({ title, subtitle, children, footNote, confirmLabel, danger = false, busy = false,
|
|
9494
|
+
disabled = false, error = null, onCancel, onConfirm = null, cancelLabel = "Cancel" }) {
|
|
9495
|
+
// aria-modal promises focus stays inside. Keep the promise: Tab cycles
|
|
9496
|
+
// within the sheet, and a sheet with no input (Delete, Remove) takes focus
|
|
9497
|
+
// itself so the first Tab does not land behind the backdrop.
|
|
9498
|
+
const sheetRef = React.useRef(null);
|
|
9499
|
+
React.useEffect(() => {
|
|
9500
|
+
const root = sheetRef.current;
|
|
9501
|
+
if (!root) return undefined;
|
|
9502
|
+
const focusables = () => [...root.querySelectorAll('button:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])')];
|
|
9503
|
+
if (!root.contains(document.activeElement)) { const f = focusables(); (f[0] || root).focus(); }
|
|
9504
|
+
const onKey = (event) => {
|
|
9505
|
+
if (event.key !== "Tab") return;
|
|
9506
|
+
const f = focusables();
|
|
9507
|
+
if (!f.length) return;
|
|
9508
|
+
const first = f[0];
|
|
9509
|
+
const last = f[f.length - 1];
|
|
9510
|
+
if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
|
9511
|
+
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
|
9512
|
+
};
|
|
9513
|
+
root.addEventListener("keydown", onKey);
|
|
9514
|
+
return () => root.removeEventListener("keydown", onKey);
|
|
9515
|
+
}, []);
|
|
9516
|
+
// Capture phase, and stopPropagation: the surface's own Escape handler is
|
|
9517
|
+
// ALSO on capture and closes the whole workspace. It exempts INPUT/SELECT
|
|
9518
|
+
// targets, so a sheet whose focus sat on a button let Escape fall through
|
|
9519
|
+
// to it — the sheet AND the surface vanished together.
|
|
9520
|
+
React.useEffect(() => {
|
|
9521
|
+
const onKey = (event) => {
|
|
9522
|
+
if (event.key !== "Escape") return;
|
|
9523
|
+
event.preventDefault();
|
|
9524
|
+
event.stopPropagation();
|
|
9525
|
+
onCancel();
|
|
9526
|
+
};
|
|
9527
|
+
window.addEventListener("keydown", onKey, true);
|
|
9528
|
+
return () => window.removeEventListener("keydown", onKey, true);
|
|
9529
|
+
}, [onCancel]);
|
|
9530
|
+
return React.createElement("div", {
|
|
9531
|
+
className: "ps-budget-sheet-backdrop",
|
|
9532
|
+
// preventDefault, or the browser's own default action for this same
|
|
9533
|
+
// mousedown moves focus AFTER the close handler has restored it —
|
|
9534
|
+
// and it lands on <body>, because a backdrop is a div nothing can
|
|
9535
|
+
// focus. Cancel and Escape never hit this, which is why the backdrop
|
|
9536
|
+
// was the one close path that still stranded a keyboard user.
|
|
9537
|
+
onMouseDown: (event) => {
|
|
9538
|
+
if (event.target !== event.currentTarget) return;
|
|
9539
|
+
event.preventDefault();
|
|
9540
|
+
onCancel();
|
|
9541
|
+
},
|
|
9542
|
+
},
|
|
9543
|
+
React.createElement("div", {
|
|
9544
|
+
className: "ps-budget-sheet", role: "dialog", "aria-modal": "true", "aria-label": title,
|
|
9545
|
+
ref: sheetRef, tabIndex: -1,
|
|
9546
|
+
},
|
|
9547
|
+
React.createElement("div", { className: "ps-budget-sheet-top" },
|
|
9548
|
+
React.createElement("div", { className: "ps-budget-sheet-title" }, title),
|
|
9549
|
+
subtitle ? React.createElement("div", { className: "ps-budget-sheet-sub" }, subtitle) : null),
|
|
9550
|
+
React.createElement("div", { className: "ps-budget-sheet-body" }, children,
|
|
9551
|
+
error ? React.createElement("div", { className: "ps-budget-sheet-error", role: "alert" }, error) : null),
|
|
9552
|
+
React.createElement("div", { className: "ps-budget-sheet-foot" },
|
|
9553
|
+
React.createElement("span", { className: "ps-budget-sheet-note" }, footNote || ""),
|
|
9554
|
+
React.createElement("div", { className: "ps-budget-sheet-actions" },
|
|
9555
|
+
React.createElement("button", { type: "button", className: "ps-mini-button", onClick: onCancel, disabled: busy }, cancelLabel),
|
|
9556
|
+
// `busy` is a change in flight (the label becomes "…"); `disabled`
|
|
9557
|
+
// is a form that is not yet valid, where the label stays so the
|
|
9558
|
+
// button still says what it will do.
|
|
9559
|
+
onConfirm ? React.createElement("button", {
|
|
9560
|
+
type: "button",
|
|
9561
|
+
className: `ps-mini-button ${danger ? "is-danger" : "is-primary"}`,
|
|
9562
|
+
onClick: onConfirm, disabled: busy || disabled,
|
|
9563
|
+
}, busy ? "…" : confirmLabel) : null))));
|
|
9564
|
+
}
|
|
9565
|
+
|
|
9566
|
+
/**
|
|
9567
|
+
* A labelled field inside a sheet. A <div>, not a <label>: a <label> wrapping a
|
|
9568
|
+
* segmented control gives its FIRST button an accessible name that swallows
|
|
9569
|
+
* every sibling's text ("Period Daily Weekly Monthly"), so a screen reader
|
|
9570
|
+
* cannot tell the options apart.
|
|
9571
|
+
*/
|
|
9572
|
+
function SheetField({ label, hint, children }) {
|
|
9573
|
+
return React.createElement("div", { className: "ps-budget-field", role: "group", "aria-label": label },
|
|
9574
|
+
React.createElement("span", { className: "ps-budget-label" }, label),
|
|
9575
|
+
children,
|
|
9576
|
+
hint ? React.createElement("span", { className: "ps-budget-field-hint" }, hint) : null);
|
|
9577
|
+
}
|
|
9578
|
+
|
|
9579
|
+
/**
|
|
9580
|
+
* A segmented control: one of N words.
|
|
9581
|
+
*
|
|
9582
|
+
* Pressed buttons, not radios. A radiogroup promises the arrow keys move
|
|
9583
|
+
* between the options and that only one of them is a tab stop; neither is true
|
|
9584
|
+
* here, so this claims no contract it does not keep.
|
|
9585
|
+
*/
|
|
9586
|
+
function Segmented({ value, options, onChange, label = null }) {
|
|
9587
|
+
return React.createElement("div", {
|
|
9588
|
+
className: "ps-budget-seg", role: "group", "aria-label": label || undefined,
|
|
9589
|
+
},
|
|
9590
|
+
options.map((option) => React.createElement("button", {
|
|
9591
|
+
key: option.value, type: "button", "aria-pressed": value === option.value ? "true" : "false",
|
|
9592
|
+
className: `ps-budget-seg-btn${value === option.value ? " is-on" : ""}`,
|
|
9593
|
+
onClick: () => onChange(option.value),
|
|
9594
|
+
}, option.label)));
|
|
9595
|
+
}
|
|
9596
|
+
|
|
9597
|
+
/**
|
|
9598
|
+
* One cell: the used figure, then the quota beside it.
|
|
9599
|
+
*
|
|
9600
|
+
* The two halves are marked apart. Printed as one string they read as one
|
|
9601
|
+
* number, and "480.0K / 500.0K" is a fact about two.
|
|
9602
|
+
*/
|
|
9603
|
+
function budgetCellEl(cell) {
|
|
9604
|
+
// Nobody signed in: the used figure is UNKNOWN, and unknown is not zero.
|
|
9605
|
+
const tone = cell.known ? (BUDGET_TONE_CLASS[cell.tone] || "") : " idle";
|
|
9606
|
+
return React.createElement(React.Fragment, null,
|
|
9607
|
+
React.createElement("span", { className: `ps-budget-pct${tone}` }, cell.usedLabel),
|
|
9608
|
+
// The provider's limit is spent even though your share is not. Marked
|
|
9609
|
+
// in the cell, because a plain number here means "you may spend this"
|
|
9610
|
+
// and you may not.
|
|
9611
|
+
cell.providerExhausted
|
|
9612
|
+
? React.createElement("span", { className: "ps-budget-blocked", title: "The provider's own limit is spent." }, " ⏻")
|
|
9613
|
+
: null,
|
|
9614
|
+
" ",
|
|
9615
|
+
React.createElement("span", {
|
|
9616
|
+
className: `ps-budget-q ps-budget-sub${cell.uncapped ? " is-inf" : ""}`,
|
|
9617
|
+
}, `/ ${cell.quotaLabel}`));
|
|
9618
|
+
}
|
|
9619
|
+
|
|
9620
|
+
/** What a cell says on hover: the period, its reset, and whether it is capped. */
|
|
9621
|
+
function budgetCellTitle(row, column, cell) {
|
|
9622
|
+
const who = row.kind === "model" ? `${row.providerName} · ${row.label}` : row.providerName;
|
|
9623
|
+
const cap = cell.uncapped
|
|
9624
|
+
? "no limit for this period"
|
|
9625
|
+
: `${cell.usedLabel} of ${cell.quotaLabel}${cell.pct == null ? "" : ` (${cell.pct}%)`}`;
|
|
9626
|
+
// Your share can have room while the limit it is a share OF is spent, and
|
|
9627
|
+
// then nothing runs. The tint alone would not say which of the two it is.
|
|
9628
|
+
const blocked = cell.providerExhausted
|
|
9629
|
+
? " The provider's own limit for this period is spent, so nothing runs against it whatever your share says."
|
|
9630
|
+
: "";
|
|
9631
|
+
return `${who} · ${column.label} — ${cap}. It ${budgetResetPhrase(cell.resetsLabel)}.${blocked}`;
|
|
9632
|
+
}
|
|
9633
|
+
|
|
9634
|
+
/**
|
|
9635
|
+
* The table, and the tick that decides which pair of numbers is in it.
|
|
9636
|
+
*
|
|
9637
|
+
* Rows arrive in draw order — shared providers, then the viewer's own, each
|
|
9638
|
+
* followed by its model rows while it is selected. They are not sorted here: a
|
|
9639
|
+
* sort would move a model row out from under the provider it belongs to.
|
|
9640
|
+
*/
|
|
9641
|
+
function ProviderTable({ controller, view, defaults = null }) {
|
|
9642
|
+
const select = React.useCallback((name, scope = "*") => {
|
|
9643
|
+
controller.selectBudgetProvider(name, scope).catch(() => {});
|
|
9644
|
+
}, [controller]);
|
|
9645
|
+
|
|
9646
|
+
const nameCell = (row) => {
|
|
9647
|
+
if (row.kind === "model") {
|
|
9648
|
+
// Selectable in its own right: usage and any limit belong to this
|
|
9649
|
+
// model, so the chart follows it.
|
|
9650
|
+
return React.createElement("td", { className: "ps-budget-grid-name is-model" },
|
|
9651
|
+
React.createElement("button", {
|
|
9652
|
+
type: "button", className: "ps-budget-grid-pick",
|
|
9653
|
+
"aria-pressed": row.selected ? "true" : "false",
|
|
9654
|
+
onClick: () => select(row.providerName, row.scope),
|
|
9655
|
+
},
|
|
9656
|
+
React.createElement("span", { className: "ps-budget-grid-mname" }, row.label),
|
|
9657
|
+
React.createElement("span", { className: "ps-budget-sub" }, " · model")));
|
|
9658
|
+
}
|
|
9659
|
+
return React.createElement("td", { className: "ps-budget-grid-name" },
|
|
9660
|
+
// ONE control for the whole cell. Selecting a provider and
|
|
9661
|
+
// expanding its per-model limits are the same act, so they are the
|
|
9662
|
+
// same button rather than two that do the same thing.
|
|
9663
|
+
React.createElement("button", {
|
|
9664
|
+
type: "button", className: "ps-budget-grid-pick",
|
|
9665
|
+
"aria-pressed": row.selected ? "true" : "false",
|
|
9666
|
+
"aria-expanded": row.modelRowCount > 0 ? (row.expanded ? "true" : "false") : undefined,
|
|
9667
|
+
onClick: () => select(row.providerName),
|
|
9668
|
+
},
|
|
9669
|
+
React.createElement("span", { className: "ps-budget-grid-pname" }, row.label),
|
|
9670
|
+
// Whose it is, for a provider that has an owner. Beside the name
|
|
9671
|
+
// rather than in the chip: the chip says WHAT kind of provider it
|
|
9672
|
+
// is, and this says whose — two different facts.
|
|
9673
|
+
row.ownerLabel
|
|
9674
|
+
? React.createElement("span", { className: "ps-budget-grid-owner" }, `(${row.ownerLabel})`)
|
|
9675
|
+
: null,
|
|
9676
|
+
// The class, named. Shared and User look different because they
|
|
9677
|
+
// behave differently: one is everyone's and an administrator's to
|
|
9678
|
+
// change, the other is a single person's.
|
|
9679
|
+
row.classLabel
|
|
9680
|
+
? React.createElement("span", {
|
|
9681
|
+
className: `ps-budget-chip is-${row.class}`,
|
|
9682
|
+
title: row.class === "shared"
|
|
9683
|
+
? "Shared: anyone may spend from it. An administrator manages its limits."
|
|
9684
|
+
: (row.ownedByMe === false
|
|
9685
|
+
? "A user provider belonging to another user."
|
|
9686
|
+
: "Your own provider. Only you see it or spend from it."),
|
|
9687
|
+
}, row.classLabel)
|
|
9688
|
+
: null,
|
|
9689
|
+
defaults?.mine?.provider === row.providerName
|
|
9690
|
+
? React.createElement("span", { className: "ps-budget-chip" }, "my default")
|
|
9691
|
+
: null,
|
|
9692
|
+
defaults?.cluster?.provider === row.providerName
|
|
9693
|
+
? React.createElement("span", { className: "ps-budget-chip" }, "cluster default")
|
|
9694
|
+
: null,
|
|
9695
|
+
defaults?.system?.provider === row.providerName
|
|
9696
|
+
? React.createElement("span", { className: "ps-budget-chip" }, "system default")
|
|
9697
|
+
: null,
|
|
9698
|
+
// Not a class mark: a hold stops every turn against this provider
|
|
9699
|
+
// right now, and a screen that hides that is lying about the row.
|
|
9700
|
+
row.hold
|
|
9701
|
+
? React.createElement("span", {
|
|
9702
|
+
className: "ps-budget-chip is-hold", title: `It ${row.hold.label}.`,
|
|
9703
|
+
}, "on hold")
|
|
9704
|
+
: null,
|
|
9705
|
+
// A fact about how the TOTAL is divided, so it appears only where
|
|
9706
|
+
// the total is on screen.
|
|
9707
|
+
row.allowanceLabel
|
|
9708
|
+
? React.createElement("span", { className: "ps-budget-grid-allow" }, row.allowanceLabel)
|
|
9709
|
+
: null,
|
|
9710
|
+
row.expandLabel
|
|
9711
|
+
? React.createElement("span", { className: "ps-budget-grid-teaser" }, row.expandLabel)
|
|
9712
|
+
: null));
|
|
9713
|
+
};
|
|
9714
|
+
|
|
9715
|
+
const bodyRow = (row) => React.createElement("tr", {
|
|
9716
|
+
key: row.key,
|
|
9717
|
+
className: `ps-budget-grid-row${row.kind === "model" ? " is-model" : ""}${row.selected ? " is-on" : ""}`,
|
|
9718
|
+
// The numbers are part of the row too. A click that started on the name
|
|
9719
|
+
// button is already handled there, so it is let through rather than
|
|
9720
|
+
// toggling the selection twice.
|
|
9721
|
+
onClick: (event) => {
|
|
9722
|
+
if (event.target?.closest?.("button")) return;
|
|
9723
|
+
select(row.providerName, row.kind === "model" ? row.scope : "*");
|
|
9724
|
+
},
|
|
9725
|
+
},
|
|
9726
|
+
nameCell(row),
|
|
9727
|
+
view.columns.map((column) => React.createElement("td", {
|
|
9728
|
+
key: column.id, className: "ps-budget-grid-num",
|
|
9729
|
+
// On a phone the column headings are gone and each number prints its
|
|
9730
|
+
// own period from this. It comes from the column rather than from the
|
|
9731
|
+
// stylesheet counting cells, so the two cannot drift apart.
|
|
9732
|
+
"data-period": column.label,
|
|
9733
|
+
title: budgetCellTitle(row, column, row.cells[column.id]),
|
|
9734
|
+
}, budgetCellEl(row.cells[column.id]))));
|
|
9735
|
+
|
|
9736
|
+
return React.createElement(React.Fragment, null,
|
|
9737
|
+
React.createElement("div", { className: "ps-budget-table-head" },
|
|
9738
|
+
React.createElement("span", { className: "ps-budget-label" },
|
|
9739
|
+
`Providers${view.providerCount > 0 ? ` · ${view.providerCount}` : ""}`),
|
|
9740
|
+
React.createElement("label", { className: "ps-budget-toggle" },
|
|
9741
|
+
React.createElement("input", {
|
|
9742
|
+
type: "checkbox", checked: view.overall,
|
|
9743
|
+
onChange: () => controller.setBudgetOverall(),
|
|
9744
|
+
}),
|
|
9745
|
+
React.createElement("span", null, view.overallLabel))),
|
|
9746
|
+
// A namespace that really is empty, reported only after a read that
|
|
9747
|
+
// SUCCEEDED. A failed read is handled above this component.
|
|
9748
|
+
view.empty
|
|
9749
|
+
? React.createElement("div", { className: "ps-budget-empty" },
|
|
9750
|
+
"No provider exists yet. Add one in Admin Console → Model Providers to start spending.")
|
|
9751
|
+
: React.createElement("table", { className: "ps-budget-grid" },
|
|
9752
|
+
React.createElement("thead", null,
|
|
9753
|
+
React.createElement("tr", null,
|
|
9754
|
+
React.createElement("th", {
|
|
9755
|
+
className: "ps-budget-grid-h is-name", rowSpan: 2, scope: "col",
|
|
9756
|
+
}, "Provider"),
|
|
9757
|
+
// Which pair of numbers the three columns hold. It is
|
|
9758
|
+
// the heading, not a footnote, because every figure
|
|
9759
|
+
// under it means something different when the tick
|
|
9760
|
+
// changes.
|
|
9761
|
+
React.createElement("th", {
|
|
9762
|
+
className: "ps-budget-grid-h is-span", colSpan: 3, scope: "colgroup",
|
|
9763
|
+
}, view.usageHeading)),
|
|
9764
|
+
React.createElement("tr", null,
|
|
9765
|
+
view.columns.map((column) => React.createElement("th", {
|
|
9766
|
+
key: column.id, className: "ps-budget-grid-h is-num", scope: "col",
|
|
9767
|
+
}, column.label)))),
|
|
9768
|
+
React.createElement("tbody", null, view.rows.map(bodyRow))));
|
|
9769
|
+
}
|
|
9770
|
+
|
|
9771
|
+
/**
|
|
9772
|
+
* The selected provider's days, with the dashed line at whatever quota the Day
|
|
9773
|
+
* cell just showed. The chart is drawn from the same number as the row, so the
|
|
9774
|
+
* two cannot disagree.
|
|
9775
|
+
*/
|
|
9776
|
+
function ProviderDayChart({ controller, view }) {
|
|
9777
|
+
const series = view.series;
|
|
9778
|
+
const row = view.selected;
|
|
9779
|
+
const paneProps = {
|
|
9780
|
+
className: "ps-budget-chart-pane",
|
|
9781
|
+
"data-range-days": String(view.rangeDays),
|
|
9782
|
+
};
|
|
9783
|
+
|
|
9784
|
+
// How many dates the x-axis can print is MEASURED, not assumed. "About
|
|
9785
|
+
// seven" is right at 1440px and wrong at 390px, where each slot is 15px,
|
|
9786
|
+
// "08-16" needs 26px, and every date came out clipped to "08-".
|
|
9787
|
+
const [axisNode, setAxisNode] = React.useState(null);
|
|
9788
|
+
const [axisWidth, setAxisWidth] = React.useState(0);
|
|
9789
|
+
React.useEffect(() => {
|
|
9790
|
+
if (!axisNode || typeof ResizeObserver === "undefined") return undefined;
|
|
9791
|
+
const measure = () => {
|
|
9792
|
+
const next = Math.round(axisNode.getBoundingClientRect().width);
|
|
9793
|
+
setAxisWidth((current) => (Math.abs(next - current) < 1 ? current : next));
|
|
9794
|
+
};
|
|
9795
|
+
const observer = new ResizeObserver(measure);
|
|
9796
|
+
observer.observe(axisNode);
|
|
9797
|
+
measure();
|
|
9798
|
+
return () => observer.disconnect();
|
|
9799
|
+
}, [axisNode]);
|
|
9800
|
+
|
|
9801
|
+
const days = series.days;
|
|
9802
|
+
const dayCount = days.length;
|
|
9803
|
+
const xTicks = React.useMemo(() => {
|
|
9804
|
+
const GAP_PX = 5; // .ps-budget-tl-x gap
|
|
9805
|
+
const DATE_PX = 30; // "08-16" plus a little air, at either axis font size
|
|
9806
|
+
const room = axisWidth - ((dayCount - 1) * GAP_PX);
|
|
9807
|
+
const fits = axisWidth > 0 ? Math.floor(room / DATE_PX) : 7;
|
|
9808
|
+
const maxTicks = Math.max(1, Math.min(7, fits));
|
|
9809
|
+
const every = Math.max(1, Math.ceil(dayCount / maxTicks));
|
|
9810
|
+
const out = new Set();
|
|
9811
|
+
// Anchored on the LAST day, so today is always named and the spacing
|
|
9812
|
+
// runs evenly back from it.
|
|
9813
|
+
for (let i = dayCount - 1; i >= 0; i -= every) out.add(i);
|
|
9814
|
+
return out;
|
|
9815
|
+
}, [axisWidth, dayCount]);
|
|
9816
|
+
|
|
9817
|
+
const head = React.createElement("div", { className: "ps-budget-chart-head" },
|
|
9818
|
+
React.createElement("span", { className: "ps-budget-chart-title" },
|
|
9819
|
+
React.createElement("strong", null, row.subject),
|
|
9820
|
+
React.createElement("span", { className: "ps-budget-sub" },
|
|
9821
|
+
` · ${view.usageHeading.toLowerCase()}, by day`)),
|
|
9822
|
+
React.createElement("div", { className: "ps-budget-chart-range" },
|
|
9823
|
+
React.createElement(Segmented, {
|
|
9824
|
+
value: view.rangeDays,
|
|
9825
|
+
options: view.rangeOptions,
|
|
9826
|
+
label: "Chart range",
|
|
9827
|
+
onChange: (days) => controller.setBudgetRangeDays(days).catch(() => {}),
|
|
9828
|
+
}),
|
|
9829
|
+
React.createElement("span", { className: "ps-budget-sub" }, series.rangeLabel)));
|
|
9830
|
+
|
|
9831
|
+
// A failed read is a failure, never an empty chart: "no usage" and "we could
|
|
9832
|
+
// not find out" are different facts and only one is safe to act on.
|
|
9833
|
+
if (series.failed) {
|
|
9834
|
+
return React.createElement("div", paneProps, head,
|
|
9835
|
+
React.createElement("div", { className: "ps-budget-empty is-error", role: "alert" },
|
|
9836
|
+
React.createElement("div", null, `The daily usage for ${row.subject} could not be read.`),
|
|
9837
|
+
React.createElement("div", { className: "ps-budget-sub" }, series.error)));
|
|
9838
|
+
}
|
|
9839
|
+
if (series.loading && !series.loaded) {
|
|
9840
|
+
return React.createElement("div", paneProps, head,
|
|
9841
|
+
React.createElement("div", { className: "ps-budget-empty" }, "Reading the daily usage…"));
|
|
9842
|
+
}
|
|
9843
|
+
|
|
9844
|
+
const scaleMax = series.scaleMax;
|
|
9845
|
+
const ticks = [1, 0.75, 0.5, 0.25, 0];
|
|
9846
|
+
const tickText = fixedScaleFormatter(scaleMax);
|
|
9847
|
+
const activeDays = days.filter((day) => day.tokens > 0).length;
|
|
9848
|
+
const quotaTop = series.quotaPct == null ? null : Math.max(0, Math.min(96, 100 - series.quotaPct));
|
|
9849
|
+
|
|
9850
|
+
const axisEl = React.createElement("div", { className: "ps-budget-xaxis" },
|
|
9851
|
+
React.createElement("div", { className: "ps-budget-xaxis-spacer" }),
|
|
9852
|
+
// ONE span per column, blank ones included, so a label always sits on
|
|
9853
|
+
// its own bar.
|
|
9854
|
+
React.createElement("div", { className: "ps-budget-tl-x", ref: setAxisNode },
|
|
9855
|
+
days.map((day, index) => React.createElement("span", {
|
|
9856
|
+
key: day.dayUtc,
|
|
9857
|
+
className: xTicks.has(index) ? "is-tick" : undefined,
|
|
9858
|
+
}, xTicks.has(index) ? day.label : ""))));
|
|
9859
|
+
|
|
9860
|
+
return React.createElement("div", paneProps, head,
|
|
9861
|
+
// An administrator reading their own share sees a chart of two
|
|
9862
|
+
// different subjects. Say which is which, or one reads as the other.
|
|
9863
|
+
series.mixedScope ? React.createElement("div", { className: "ps-budget-note is-warn" },
|
|
9864
|
+
"The bars are what the whole fleet spent on this provider each day. "
|
|
9865
|
+
+ "The dashed line is YOUR share of the daily limit, not the limit itself. "
|
|
9866
|
+
+ "Tick “Show overall usage” to read both as the provider's.") : null,
|
|
9867
|
+
series.stale ? React.createElement("div", { className: "ps-budget-empty is-error", role: "alert" },
|
|
9868
|
+
React.createElement("div", null, "These figures are from an earlier read. The most recent read failed."),
|
|
9869
|
+
React.createElement("div", { className: "ps-budget-sub" }, series.error)) : null,
|
|
9870
|
+
React.createElement("div", { className: "ps-budget-chart" },
|
|
9871
|
+
React.createElement("div", { className: "ps-budget-yaxis-title" }, "tokens"),
|
|
9872
|
+
React.createElement("div", { className: "ps-budget-plotwrap" },
|
|
9873
|
+
React.createElement("div", { className: "ps-budget-yaxis" },
|
|
9874
|
+
// A range with nothing in it and no limit gets no ruler:
|
|
9875
|
+
// every tick on it would be a number nobody spent.
|
|
9876
|
+
scaleMax > 0
|
|
9877
|
+
? ticks.map((frac) => React.createElement("div", {
|
|
9878
|
+
key: `t${frac}`, className: "ps-budget-ytick", style: { top: `${(1 - frac) * 100}%` },
|
|
9879
|
+
}, tickText(scaleMax * frac)))
|
|
9880
|
+
: null),
|
|
9881
|
+
React.createElement("div", { className: "ps-budget-plot" },
|
|
9882
|
+
scaleMax > 0
|
|
9883
|
+
? ticks.map((frac) => React.createElement("div", {
|
|
9884
|
+
key: `g${frac}`, className: "ps-budget-gridline", style: { top: `${(1 - frac) * 100}%` },
|
|
9885
|
+
}))
|
|
9886
|
+
: null,
|
|
9887
|
+
// The dashed line IS the Day cell's quota, the same object
|
|
9888
|
+
// the row printed. There is nothing to draw when the day is
|
|
9889
|
+
// uncapped.
|
|
9890
|
+
quotaTop == null ? null : React.createElement("div", {
|
|
9891
|
+
className: "ps-budget-tl-median", style: { top: `${quotaTop}%` },
|
|
9892
|
+
}, React.createElement("span", null, `the Day limit · ${series.quotaLabel} a day`)),
|
|
9893
|
+
React.createElement("div", { className: "ps-budget-bars" },
|
|
9894
|
+
days.map((day) => React.createElement("div", {
|
|
9895
|
+
key: day.dayUtc,
|
|
9896
|
+
className: `ps-budget-tl-col${day.tokens === 0 ? " is-idle" : ""}`
|
|
9897
|
+
+ `${series.quotaTokens != null && day.tokens > series.quotaTokens ? " is-over" : ""}`,
|
|
9898
|
+
title: `${day.label} · ${day.tokens
|
|
9899
|
+
? `${day.tokens.toLocaleString("en-US")} tokens · ${budgetPlural(day.turns, "turn", "turns")}`
|
|
9900
|
+
: "no usage"}`,
|
|
9901
|
+
},
|
|
9902
|
+
React.createElement("div", {
|
|
9903
|
+
className: "ps-budget-tl-bar",
|
|
9904
|
+
// An idle day keeps a hairline so the column is
|
|
9905
|
+
// visibly a day that happened, not a gap.
|
|
9906
|
+
style: { height: day.tokens === 0 ? "2px" : `${Math.max(1, day.pct)}%` },
|
|
9907
|
+
})))),
|
|
9908
|
+
series.empty
|
|
9909
|
+
? React.createElement("div", { className: "ps-budget-noaxis" },
|
|
9910
|
+
`No token usage on ${row.subject} in this range.`)
|
|
9911
|
+
: null)),
|
|
9912
|
+
axisEl,
|
|
9913
|
+
React.createElement("div", { className: "ps-budget-xaxis-title" },
|
|
9914
|
+
`Daily totals · UTC · ${series.rangeLabel.toLowerCase()}`),
|
|
9915
|
+
React.createElement("div", { className: "ps-budget-legend" },
|
|
9916
|
+
React.createElement("span", null, `Peak ${series.peakLabel} tokens/day`),
|
|
9917
|
+
React.createElement("span", null, `${activeDays} of ${dayCount} days used`),
|
|
9918
|
+
series.quotaTokens != null
|
|
9919
|
+
? React.createElement("span", null, `Dashed line: ${series.quotaLabel} a day`)
|
|
9920
|
+
: React.createElement("span", null, "No daily limit, so no line"))));
|
|
9921
|
+
}
|
|
9922
|
+
|
|
9923
|
+
function ProviderSystemSpend({ view }) {
|
|
9924
|
+
const spend = view.systemSpend;
|
|
9925
|
+
if (!spend) return null;
|
|
9926
|
+
const modelLabel = (label) => String(label || "").startsWith(`${view.selectedProvider}:`)
|
|
9927
|
+
? String(label).slice(view.selectedProvider.length + 1)
|
|
9928
|
+
: String(label || "Unknown model");
|
|
9929
|
+
return React.createElement("section", { className: "ps-budget-system-spend", "aria-label": "System spend" },
|
|
9930
|
+
React.createElement("div", { className: "ps-budget-chart-head" },
|
|
9931
|
+
React.createElement("span", { className: "ps-budget-chart-title" },
|
|
9932
|
+
React.createElement("strong", null, "System spend")),
|
|
9933
|
+
React.createElement("span", { className: "ps-budget-sub" }, spend.rangeLabel)),
|
|
9934
|
+
spend.loading && !spend.loaded
|
|
9935
|
+
? React.createElement("div", { className: "ps-budget-empty" }, "Reading system spend…")
|
|
9936
|
+
: null,
|
|
9937
|
+
spend.error
|
|
9938
|
+
? React.createElement("div", { className: "ps-budget-empty is-error", role: "alert" }, spend.error)
|
|
9939
|
+
: null,
|
|
9940
|
+
spend.loaded ? React.createElement(React.Fragment, null,
|
|
9941
|
+
React.createElement("div", { className: "ps-budget-system-total" },
|
|
9942
|
+
React.createElement("strong", null, `${spend.tokensLabel} tokens`),
|
|
9943
|
+
React.createElement("span", { className: "ps-budget-sub" }, ` · ${budgetPlural(spend.turns, "turn", "turns")}`)),
|
|
9944
|
+
spend.models.length
|
|
9945
|
+
? React.createElement("div", { className: "ps-budget-system-models" },
|
|
9946
|
+
spend.models.map((model) => React.createElement("div", { key: model.key, className: "ps-budget-system-model" },
|
|
9947
|
+
React.createElement("span", null, modelLabel(model.label)),
|
|
9948
|
+
React.createElement("strong", null, `${model.tokensLabel} · ${budgetPlural(model.turns, "turn", "turns")}`))))
|
|
9949
|
+
: React.createElement("div", { className: "ps-budget-sub" }, "No system spend in this range.")) : null);
|
|
9950
|
+
}
|
|
9951
|
+
|
|
9952
|
+
/**
|
|
9953
|
+
* Everything one provider can be changed to: its limits, its allowance, its
|
|
9954
|
+
* hold, and whether it is a default.
|
|
9955
|
+
*
|
|
9956
|
+
* One sheet, because they are all edits to the row that is already named at the
|
|
9957
|
+
* top of it. Each section states what saving will do before the confirm is
|
|
9958
|
+
* reachable.
|
|
9959
|
+
*/
|
|
9960
|
+
function EditProviderSheet({
|
|
9961
|
+
row, rawRows, models, isAdmin, busy, error, onCancel, onRun,
|
|
9962
|
+
initialPeriod = "day", initialScope = "*",
|
|
9963
|
+
}) {
|
|
9964
|
+
const shared = row.class === "shared";
|
|
9965
|
+
// A limit is a CHANGE, and the database refuses it from anyone who is not
|
|
9966
|
+
// an admin on a shared provider or the owner of a personal one. Offering
|
|
9967
|
+
// the section anyway meant filling in a form, pressing the button and
|
|
9968
|
+
// only then being told — so it is gated the way Allowance and Hold
|
|
9969
|
+
// already were.
|
|
9970
|
+
const mayManage = row.manageable === true;
|
|
9971
|
+
const sections = [
|
|
9972
|
+
...(mayManage ? [{ value: "limit", label: "Limits" }] : []),
|
|
9973
|
+
...(mayManage && isAdmin && shared ? [{ value: "allowance", label: "Allowance" }] : []),
|
|
9974
|
+
...(mayManage && isAdmin ? [{ value: "hold", label: "Hold" }] : []),
|
|
9975
|
+
];
|
|
9976
|
+
const [section, setSection] = React.useState("limit");
|
|
9977
|
+
|
|
9978
|
+
// ── the limit being edited ─────────────────────────────────────────
|
|
9979
|
+
// Opened on the row the reader was standing on: a per-model limit row
|
|
9980
|
+
// means they were looking at THAT limit, and landing them on "Daily · all
|
|
9981
|
+
// models" makes them re-find it.
|
|
9982
|
+
const [period, setPeriod] = React.useState(initialPeriod);
|
|
9983
|
+
const [scopeKind, setScopeKind] = React.useState(initialScope === "*" ? "all" : "model");
|
|
9984
|
+
const [model, setModel] = React.useState(initialScope === "*" ? "" : initialScope);
|
|
9985
|
+
const [tokens, setTokens] = React.useState("");
|
|
9986
|
+
React.useEffect(() => {
|
|
9987
|
+
if (!model && models[0]) setModel(models[0].qualifiedName);
|
|
9988
|
+
}, [models, model]);
|
|
9989
|
+
|
|
9990
|
+
const providerRaw = rawRows.find((raw) => raw.providerName === row.providerName && raw.rowKind !== "model") || null;
|
|
9991
|
+
const chosenModel = scopeKind === "model" ? (model || models[0]?.qualifiedName || "") : null;
|
|
9992
|
+
const targetRaw = scopeKind === "model"
|
|
9993
|
+
? rawRows.find((raw) => raw.providerName === row.providerName && raw.rowKind === "model" && raw.scope === chosenModel) || null
|
|
9994
|
+
: providerRaw;
|
|
9995
|
+
// These are the PROVIDER's figures, never the viewer's share: a limit caps
|
|
9996
|
+
// everyone, so the sentence about it has to be about everyone.
|
|
9997
|
+
const targetCell = targetRaw?.periods?.[period] || null;
|
|
9998
|
+
const existingTokens = targetCell?.quotaTokens ?? null;
|
|
9999
|
+
// NULL is "not known", and it is not zero. A model row exists in the grid
|
|
10000
|
+
// only once that model HAS a limit, so the first limit on a model had no
|
|
10001
|
+
// row to read and reported 0 spent — which withheld the "this blocks now"
|
|
10002
|
+
// warning at exactly the moment it mattered and paused people who had
|
|
10003
|
+
// been told the period was empty.
|
|
10004
|
+
const spentTokens = targetRaw ? (Number(targetCell?.usedTokens) || 0) : null;
|
|
10005
|
+
const spendKnown = spentTokens != null;
|
|
10006
|
+
const resetsLabel = row.cells[period]?.resetsLabel || null;
|
|
10007
|
+
const periodLabel = BUDGET_PERIOD_TITLES[period] || period;
|
|
10008
|
+
const scopeLabel = chosenModel || "all models";
|
|
10009
|
+
|
|
10010
|
+
// The field follows the (period, scope) being pointed at, so it always
|
|
10011
|
+
// shows the limit that is actually there rather than the last one typed.
|
|
10012
|
+
React.useEffect(() => {
|
|
10013
|
+
setTokens(existingTokens == null ? "" : Number(existingTokens).toLocaleString("en-US"));
|
|
10014
|
+
}, [existingTokens, period, chosenModel]);
|
|
10015
|
+
|
|
10016
|
+
const parsed = parseTokenCount(tokens);
|
|
10017
|
+
const limitValid = Boolean(parsed) && (scopeKind !== "model" || Boolean(chosenModel));
|
|
10018
|
+
const replaces = existingTokens != null;
|
|
10019
|
+
// An allowance divides every limit, so what actually stops a person is
|
|
10020
|
+
// their SHARE of the new number, not the number. Comparing against the
|
|
10021
|
+
// raw limit said "nobody is blocked" while saving paused everyone whose
|
|
10022
|
+
// own spend was already past their slice of it.
|
|
10023
|
+
const allowancePctNow = Number.isFinite(row.allowancePct) ? row.allowancePct : 100;
|
|
10024
|
+
const newCeiling = parsed == null || allowancePctNow >= 100
|
|
10025
|
+
? parsed
|
|
10026
|
+
: Math.max(1, Math.floor((parsed * allowancePctNow) / 100));
|
|
10027
|
+
const yourSpent = Number(targetCell?.yourUsedTokens);
|
|
10028
|
+
const wouldPause = spendKnown && parsed !== null && spentTokens >= parsed;
|
|
10029
|
+
const wouldPauseSomeone = !wouldPause && parsed !== null && newCeiling !== parsed
|
|
10030
|
+
&& Number.isFinite(yourSpent) && yourSpent >= newCeiling;
|
|
10031
|
+
|
|
10032
|
+
// ── the allowance ──────────────────────────────────────────────────
|
|
10033
|
+
const currentPct = Number.isFinite(row.allowancePct) ? row.allowancePct : 100;
|
|
10034
|
+
const [pctRaw, setPctRaw] = React.useState(String(currentPct));
|
|
10035
|
+
const pct = Number(String(pctRaw).trim());
|
|
10036
|
+
const pctValid = Number.isFinite(pct) && pct >= 1 && pct <= 100 && Math.floor(pct) === pct;
|
|
10037
|
+
const providerLimits = ["day", "week", "month"]
|
|
10038
|
+
.map((id) => ({ id, label: BUDGET_PERIOD_TITLES[id], tokens: providerRaw?.periods?.[id]?.quotaTokens ?? null }))
|
|
10039
|
+
.filter((entry) => entry.tokens != null);
|
|
10040
|
+
|
|
10041
|
+
// ── the hold ───────────────────────────────────────────────────────
|
|
10042
|
+
const held = Boolean(row.hold);
|
|
10043
|
+
const [holdMode, setHoldMode] = React.useState(held ? "release" : "forever");
|
|
10044
|
+
const [until, setUntil] = React.useState("");
|
|
10045
|
+
// A datetime-local value is wall-clock with no zone. Read it as local time
|
|
10046
|
+
// and send the instant, so a hold set for 18:00 ends at the reader's 18:00.
|
|
10047
|
+
const untilIso = (() => {
|
|
10048
|
+
if (holdMode !== "until" || !until) return null;
|
|
10049
|
+
const at = new Date(until);
|
|
10050
|
+
return Number.isNaN(at.getTime()) ? null : at.toISOString();
|
|
10051
|
+
})();
|
|
10052
|
+
// An instant that has already passed stops nothing. The server accepts it
|
|
10053
|
+
// and stores it, so nothing downstream catches it — and the screen then
|
|
10054
|
+
// confirmed in words that the provider was stopped when it was not.
|
|
10055
|
+
const untilIsPast = Boolean(untilIso) && Date.parse(untilIso) <= Date.now();
|
|
10056
|
+
const holdValid = holdMode !== "until" || (Boolean(untilIso) && !untilIsPast);
|
|
10057
|
+
const holdHint = holdMode !== "until" ? null
|
|
10058
|
+
: untilIsPast ? "That time has already passed. A hold has to end in the future."
|
|
10059
|
+
: (until && !untilIso ? "That is not a time this can read." : null);
|
|
10060
|
+
|
|
10061
|
+
const titles = { limit: "Limits", allowance: "Allowance", hold: "Hold" };
|
|
10062
|
+
const confirms = {
|
|
10063
|
+
limit: replaces ? "Replace limit" : "Save limit",
|
|
10064
|
+
allowance: "Save allowance",
|
|
10065
|
+
hold: holdMode === "release" ? "Release hold" : "Place hold",
|
|
10066
|
+
};
|
|
10067
|
+
const foots = {
|
|
10068
|
+
limit: "Limits are enforced.",
|
|
10069
|
+
allowance: "Applies to everyone.",
|
|
10070
|
+
hold: "Stops new turns.",
|
|
10071
|
+
};
|
|
10072
|
+
const valid = section === "limit" ? limitValid
|
|
10073
|
+
: section === "allowance" ? pctValid
|
|
10074
|
+
: section === "hold" ? holdValid : true;
|
|
10075
|
+
|
|
10076
|
+
const submit = () => {
|
|
10077
|
+
if (section === "limit") {
|
|
10078
|
+
if (limitValid) onRun("limit", { period, model: chosenModel, tokens: parsed });
|
|
10079
|
+
} else if (section === "allowance") {
|
|
10080
|
+
if (pctValid) onRun("allowance", { pct });
|
|
10081
|
+
} else if (section === "hold") {
|
|
10082
|
+
onRun("hold", holdMode === "release"
|
|
10083
|
+
? { release: true, untilUtc: null }
|
|
10084
|
+
: { release: false, untilUtc: untilIso });
|
|
10085
|
+
}
|
|
10086
|
+
};
|
|
10087
|
+
|
|
10088
|
+
const limitBody = React.createElement(React.Fragment, null,
|
|
10089
|
+
React.createElement(SheetField, {
|
|
10090
|
+
label: "Time period",
|
|
10091
|
+
hint: "One limit per period and scope.",
|
|
10092
|
+
},
|
|
10093
|
+
React.createElement(Segmented, {
|
|
10094
|
+
value: period, onChange: setPeriod, options: BUDGET_PERIOD_OPTIONS, label: "Time period",
|
|
10095
|
+
})),
|
|
10096
|
+
React.createElement(SheetField, { label: "Applies to" },
|
|
10097
|
+
React.createElement(Segmented, {
|
|
10098
|
+
value: scopeKind, onChange: setScopeKind, label: "Applies to",
|
|
10099
|
+
options: [{ value: "all", label: "all models" }, { value: "model", label: "one model" }],
|
|
10100
|
+
}),
|
|
10101
|
+
scopeKind === "model" ? React.createElement("select", {
|
|
10102
|
+
className: "ps-budget-input", "aria-label": "Model this limit applies to",
|
|
10103
|
+
value: model, onChange: (event) => setModel(event.target.value),
|
|
10104
|
+
}, models.length === 0
|
|
10105
|
+
? React.createElement("option", { value: "" }, "No models are listed for this provider's type")
|
|
10106
|
+
: models.map((option) => React.createElement("option", {
|
|
10107
|
+
key: option.qualifiedName, value: option.qualifiedName,
|
|
10108
|
+
}, option.qualifiedName))) : null),
|
|
10109
|
+
React.createElement(SheetField, {
|
|
10110
|
+
label: "Tokens",
|
|
10111
|
+
hint: parsed
|
|
10112
|
+
? `= ${parsed.toLocaleString("en-US")} tokens.`
|
|
10113
|
+
: tokens.trim()
|
|
10114
|
+
? "A whole number of tokens: 20,000,000 or 20M."
|
|
10115
|
+
: "Remove the limit to uncap.",
|
|
10116
|
+
},
|
|
10117
|
+
React.createElement("input", {
|
|
10118
|
+
className: `ps-budget-input${tokens && !parsed ? " is-invalid" : ""}`, value: tokens,
|
|
10119
|
+
"aria-label": "Tokens",
|
|
10120
|
+
// No inputMode="numeric": the field accepts "20M", and a numeric
|
|
10121
|
+
// keypad cannot type the M.
|
|
10122
|
+
placeholder: "20,000,000 or 20M",
|
|
10123
|
+
onChange: (event) => setTokens(event.target.value),
|
|
10124
|
+
onKeyDown: (event) => { if (event.key === "Enter") submit(); },
|
|
10125
|
+
})),
|
|
10126
|
+
// What this period has already spent, said out loud rather than alluded
|
|
10127
|
+
// to. It is the provider's own figure — the one the limit measures.
|
|
10128
|
+
React.createElement("div", { className: "ps-budget-consequences" },
|
|
10129
|
+
React.createElement("div", null,
|
|
10130
|
+
`${periodLabel} · ${scopeLabel} — `,
|
|
10131
|
+
spendKnown
|
|
10132
|
+
? React.createElement(React.Fragment, null,
|
|
10133
|
+
React.createElement("strong", null, `${spentTokens.toLocaleString("en-US")} tokens`),
|
|
10134
|
+
shared ? " spent by users this period." : " spent this period.")
|
|
10135
|
+
// Said plainly rather than guessed at zero: this period may
|
|
10136
|
+
// already be spent past the number about to be saved.
|
|
10137
|
+
: React.createElement(React.Fragment, null,
|
|
10138
|
+
React.createElement("strong", null, "not measured yet"),
|
|
10139
|
+
". Current spend will count.")),
|
|
10140
|
+
resetsLabel ? React.createElement("div", { className: "ps-budget-sub" },
|
|
10141
|
+
`Resets ${resetsLabel}.`) : null),
|
|
10142
|
+
replaces ? React.createElement("div", { className: "ps-budget-note is-warn" },
|
|
10143
|
+
`Replaces the existing ${formatCompactNumber(existingTokens)} token limit.`) : null,
|
|
10144
|
+
React.createElement("div", { className: `ps-budget-note${wouldPause || wouldPauseSomeone ? " is-warn" : ""}` },
|
|
10145
|
+
"Current spend counts. Saving does not reset usage.",
|
|
10146
|
+
wouldPause
|
|
10147
|
+
? ` ${spentTokens.toLocaleString("en-US")} already meets ${parsed.toLocaleString("en-US")}, so sessions on ${row.providerName} pause on their next turn${resetsLabel ? ` — until this period ${budgetResetPhrase(resetsLabel)}` : ""}.`
|
|
10148
|
+
: wouldPauseSomeone
|
|
10149
|
+
// The provider has room and a PERSON does not. With an
|
|
10150
|
+
// allowance that is the common case, and the limit alone
|
|
10151
|
+
// never showed it.
|
|
10152
|
+
? ` At ${allowancePctNow}% each that is ${newCeiling.toLocaleString("en-US")} per person, which you have already spent — so sessions pause on their next turn${resetsLabel ? ` — until this period ${budgetResetPhrase(resetsLabel)}` : ""}.`
|
|
10153
|
+
: ""),
|
|
10154
|
+
replaces ? React.createElement("div", null,
|
|
10155
|
+
React.createElement("button", {
|
|
10156
|
+
type: "button", className: "ps-mini-button is-danger", disabled: busy,
|
|
10157
|
+
onClick: () => onRun("removeLimit", { period, model: chosenModel }),
|
|
10158
|
+
}, `Remove the ${periodLabel} · ${scopeLabel} limit`),
|
|
10159
|
+
React.createElement("div", { className: "ps-budget-note" },
|
|
10160
|
+
"Usage history is kept.")) : null);
|
|
10161
|
+
|
|
10162
|
+
const allowanceBody = React.createElement(React.Fragment, null,
|
|
10163
|
+
React.createElement(SheetField, {
|
|
10164
|
+
label: "Share per person",
|
|
10165
|
+
hint: pctValid
|
|
10166
|
+
? (pct >= 100
|
|
10167
|
+
? "100% removes the per-person ceiling."
|
|
10168
|
+
: `Each person may use up to ${pct}% of each limit, per period.`)
|
|
10169
|
+
: "A whole number from 1 to 100.",
|
|
10170
|
+
},
|
|
10171
|
+
React.createElement("input", {
|
|
10172
|
+
className: `ps-budget-input${pctRaw && !pctValid ? " is-invalid" : ""}`, value: pctRaw,
|
|
10173
|
+
// Its own name. A group label is not reliably announced when focus
|
|
10174
|
+
// lands straight on the field, and "edit text, blank" is not a field.
|
|
10175
|
+
"aria-label": "Share per person, as a percentage",
|
|
10176
|
+
inputMode: "numeric", placeholder: "20",
|
|
10177
|
+
onChange: (event) => setPctRaw(event.target.value),
|
|
10178
|
+
onKeyDown: (event) => { if (event.key === "Enter" && pctValid) submit(); },
|
|
10179
|
+
})),
|
|
10180
|
+
React.createElement("div", { className: "ps-budget-consequences" },
|
|
10181
|
+
providerLimits.length === 0
|
|
10182
|
+
? React.createElement("div", { className: "ps-budget-note is-warn" },
|
|
10183
|
+
"No limits yet. The allowance applies when one is added.")
|
|
10184
|
+
: React.createElement(React.Fragment, null,
|
|
10185
|
+
providerLimits.map((entry) => React.createElement("div", { key: entry.id },
|
|
10186
|
+
`${entry.label} · ${formatCompactNumber(entry.tokens)} → `,
|
|
10187
|
+
React.createElement("strong", null,
|
|
10188
|
+
pctValid ? `${formatCompactNumber(Math.floor(entry.tokens * pct / 100))} per person` : "—"))),
|
|
10189
|
+
React.createElement("div", { className: "ps-budget-note" },
|
|
10190
|
+
"The provider limit still caps total spend.")),
|
|
10191
|
+
pctValid && pct < currentPct ? React.createElement("div", { className: "ps-budget-note is-warn" },
|
|
10192
|
+
"Users already past the new ceiling pause on their next turn.") : null));
|
|
10193
|
+
|
|
10194
|
+
const holdBody = React.createElement(React.Fragment, null,
|
|
10195
|
+
React.createElement(SheetField, { label: "What to do" },
|
|
10196
|
+
React.createElement(Segmented, {
|
|
10197
|
+
value: holdMode, onChange: setHoldMode, label: "What to do",
|
|
10198
|
+
options: [
|
|
10199
|
+
...(held ? [{ value: "release", label: "release it" }] : []),
|
|
10200
|
+
{ value: "forever", label: "hold until released" },
|
|
10201
|
+
{ value: "until", label: "hold until a time" },
|
|
10202
|
+
],
|
|
10203
|
+
})),
|
|
10204
|
+
holdMode === "until" ? React.createElement(SheetField, {
|
|
10205
|
+
label: "Ends at",
|
|
10206
|
+
hint: holdHint || "Sessions resume at this time.",
|
|
10207
|
+
},
|
|
10208
|
+
React.createElement("input", {
|
|
10209
|
+
className: `ps-budget-input${until && !holdValid ? " is-invalid" : ""}`,
|
|
10210
|
+
"aria-label": "The hold ends at",
|
|
10211
|
+
type: "datetime-local", value: until,
|
|
10212
|
+
onChange: (event) => setUntil(event.target.value),
|
|
10213
|
+
})) : null,
|
|
10214
|
+
held ? React.createElement("div", { className: "ps-budget-consequences" },
|
|
10215
|
+
React.createElement("div", null, `This provider is on hold: it ${row.hold.label}.`)) : null,
|
|
10216
|
+
React.createElement("div", { className: `ps-budget-note${holdMode === "release" ? "" : " is-warn"}` },
|
|
10217
|
+
holdMode === "release"
|
|
10218
|
+
? `Releasing wakes every session waiting on ${row.providerName}. A session still over a limit stays paused for that reason instead.`
|
|
10219
|
+
: `Every session on ${row.providerName} pauses on its next turn. Messages sent to them are kept and answered when the hold lifts.`));
|
|
10220
|
+
|
|
10221
|
+
return React.createElement(BudgetSheet, {
|
|
10222
|
+
title: titles[section], subtitle: `${row.providerName}${shared ? " · shared" : ""}`,
|
|
10223
|
+
confirmLabel: confirms[section] || "",
|
|
10224
|
+
danger: section === "hold" && holdMode === "release",
|
|
10225
|
+
busy, disabled: !valid, error, onCancel,
|
|
10226
|
+
onConfirm: confirms[section] ? submit : null,
|
|
10227
|
+
cancelLabel: confirms[section] ? "Cancel" : "Done",
|
|
10228
|
+
footNote: foots[section],
|
|
10229
|
+
},
|
|
10230
|
+
sections.length > 1 ? React.createElement(SheetField, { label: "What to change" },
|
|
10231
|
+
React.createElement(Segmented, {
|
|
10232
|
+
value: section, onChange: setSection, options: sections, label: "What to change",
|
|
10233
|
+
})) : null,
|
|
10234
|
+
section === "limit" ? limitBody : null,
|
|
10235
|
+
section === "allowance" ? allowanceBody : null,
|
|
10236
|
+
section === "hold" ? holdBody : null);
|
|
10237
|
+
}
|
|
10238
|
+
|
|
10239
|
+
/**
|
|
10240
|
+
* Add a provider. The name is permanent — sessions refer to it — and the
|
|
10241
|
+
* credential is the key itself, never a pointer to one.
|
|
10242
|
+
*/
|
|
10243
|
+
function CreateProviderSheet({
|
|
10244
|
+
types, existingNames, isAdmin, busy, error, onCancel, onConfirm,
|
|
10245
|
+
initialName = "", initialShared = false, initialType = "",
|
|
10246
|
+
}) {
|
|
10247
|
+
const [name, setName] = React.useState(initialName);
|
|
10248
|
+
const [type, setType] = React.useState(initialType || types[0]?.id || "");
|
|
10249
|
+
const [credential, setCredential] = React.useState("");
|
|
10250
|
+
const [baseUrl, setBaseUrl] = React.useState("");
|
|
10251
|
+
const [shared, setShared] = React.useState(isAdmin && initialShared);
|
|
10252
|
+
const ref = React.useRef(null);
|
|
10253
|
+
React.useEffect(() => { ref.current?.focus(); }, []);
|
|
10254
|
+
React.useEffect(() => { if (!type && types[0]) setType(types[0].id); }, [types, type]);
|
|
10255
|
+
|
|
10256
|
+
const trimmed = name.trim();
|
|
10257
|
+
// A name is unique across the cluster and immutable, so a clash is
|
|
10258
|
+
// permanent. The database refuses it too; refusing here saves the trip.
|
|
10259
|
+
const taken = existingNames.some((existing) => existing.toLowerCase() === trimmed.toLowerCase());
|
|
10260
|
+
// MAJOR 16: the database's own check constraint allows only these, and it
|
|
10261
|
+
// reports every violation with one fixed sentence — so a name refused for
|
|
10262
|
+
// its LENGTH was told to use letters and numbers, which it already did.
|
|
10263
|
+
// Bounded and named here instead, where the rule can be said precisely.
|
|
10264
|
+
const badChars = trimmed !== "" && !/^[A-Za-z0-9._-]+$/.test(trimmed);
|
|
10265
|
+
const tooLong = trimmed.length > 64;
|
|
10266
|
+
const valid = Boolean(trimmed) && !taken && !badChars && !tooLong
|
|
10267
|
+
&& Boolean(type) && Boolean(credential.trim());
|
|
10268
|
+
const isGithub = /github/i.test(type);
|
|
10269
|
+
const submit = () => {
|
|
10270
|
+
if (!valid) return;
|
|
10271
|
+
const input = {
|
|
10272
|
+
name: trimmed, type, shared,
|
|
10273
|
+
credentials: isGithub ? { githubToken: credential.trim() } : { apiKey: credential.trim() },
|
|
10274
|
+
baseUrl: baseUrl.trim() || null,
|
|
10275
|
+
};
|
|
10276
|
+
setCredential("");
|
|
10277
|
+
onConfirm(input);
|
|
10278
|
+
};
|
|
10279
|
+
const cancel = () => {
|
|
10280
|
+
setCredential("");
|
|
10281
|
+
onCancel();
|
|
10282
|
+
};
|
|
10283
|
+
|
|
10284
|
+
return React.createElement(BudgetSheet, {
|
|
10285
|
+
title: isGithub ? "Add GitHub Copilot provider" : "Add model provider",
|
|
10286
|
+
subtitle: shared ? "Shared" : "Personal",
|
|
10287
|
+
confirmLabel: "Add provider", busy, disabled: !valid, error, onCancel: cancel, onConfirm: submit,
|
|
10288
|
+
footNote: "",
|
|
10289
|
+
},
|
|
10290
|
+
React.createElement(SheetField, {
|
|
10291
|
+
label: "Name",
|
|
10292
|
+
hint: taken ? "A provider with that name already exists, and names are unique across the cluster."
|
|
10293
|
+
: tooLong ? `Too long: ${trimmed.length} characters, and a name may be at most 64.`
|
|
10294
|
+
: badChars ? "Letters, numbers, dot, dash and underscore only — no colon, no spaces, no accents: a session refers to it as \"<provider>:<model>\"."
|
|
10295
|
+
: "Permanent. Used in provider:model.",
|
|
10296
|
+
},
|
|
10297
|
+
React.createElement("input", {
|
|
10298
|
+
ref, className: `ps-budget-input${(taken || badChars || tooLong) && trimmed ? " is-invalid" : ""}`,
|
|
10299
|
+
"aria-label": "Provider name",
|
|
10300
|
+
value: name, placeholder: "azure-prod",
|
|
10301
|
+
onChange: (event) => setName(event.target.value),
|
|
10302
|
+
onKeyDown: (event) => { if (event.key === "Enter" && valid) submit(); },
|
|
10303
|
+
})),
|
|
10304
|
+
React.createElement(SheetField, {
|
|
10305
|
+
label: "Type",
|
|
10306
|
+
hint: "Backend and model catalog.",
|
|
10307
|
+
},
|
|
10308
|
+
React.createElement("select", {
|
|
10309
|
+
className: "ps-budget-input", "aria-label": "Provider type",
|
|
10310
|
+
value: type, onChange: (event) => setType(event.target.value),
|
|
10311
|
+
}, types.length === 0
|
|
10312
|
+
? React.createElement("option", { value: "" }, "No provider types are listed on this deployment")
|
|
10313
|
+
: types.map((option) => React.createElement("option", { key: option.id, value: option.id }, option.id)))),
|
|
10314
|
+
React.createElement(SheetField, {
|
|
10315
|
+
label: isGithub ? "GitHub Copilot token" : "API key",
|
|
10316
|
+
hint: "Paste the token value.",
|
|
10317
|
+
},
|
|
10318
|
+
React.createElement("input", {
|
|
10319
|
+
className: "ps-budget-input", type: "password", autoComplete: "off",
|
|
10320
|
+
"aria-label": isGithub ? "GitHub Copilot token" : "API key",
|
|
10321
|
+
value: credential, placeholder: "Paste token",
|
|
10322
|
+
onChange: (event) => setCredential(event.target.value),
|
|
10323
|
+
})),
|
|
10324
|
+
React.createElement(SheetField, { label: "Base URL", hint: "Optional." },
|
|
10325
|
+
React.createElement("input", {
|
|
10326
|
+
className: "ps-budget-input", value: baseUrl, placeholder: "https://…",
|
|
10327
|
+
"aria-label": "Base URL",
|
|
10328
|
+
onChange: (event) => setBaseUrl(event.target.value),
|
|
10329
|
+
})),
|
|
10330
|
+
isAdmin ? React.createElement(SheetField, { label: "Access" },
|
|
10331
|
+
React.createElement(Segmented, {
|
|
10332
|
+
value: shared ? "shared" : "mine", onChange: (value) => setShared(value === "shared"),
|
|
10333
|
+
label: "Access",
|
|
10334
|
+
options: [{ value: "shared", label: "Shared" }, { value: "mine", label: "Only me" }],
|
|
10335
|
+
})) : null);
|
|
10336
|
+
}
|
|
10337
|
+
|
|
10338
|
+
/** Delete a provider. Its sessions are not moved anywhere — they wait. */
|
|
10339
|
+
function DeleteProviderSheet({ row, limitCount, defaults, busy, error, onCancel, onConfirm }) {
|
|
10340
|
+
const isClusterDefault = defaults?.cluster?.provider === row.providerName;
|
|
10341
|
+
const isMyDefault = defaults?.mine?.provider === row.providerName;
|
|
10342
|
+
return React.createElement(BudgetSheet, {
|
|
10343
|
+
title: `Delete ${row.providerName}?`,
|
|
10344
|
+
subtitle: row.class === "shared" ? "Shared provider"
|
|
10345
|
+
: (row.ownedByMe === false ? "User provider · owned by another user" : "User provider"),
|
|
10346
|
+
confirmLabel: "Delete provider", danger: true, busy, error, onCancel, onConfirm,
|
|
10347
|
+
// Not "undoes this": re-creating the name only lets the waiting
|
|
10348
|
+
// sessions resolve again, against a fresh budget. The limits do not
|
|
10349
|
+
// come back.
|
|
10350
|
+
footNote: "Creating the name again lets waiting sessions resume, on a fresh budget.",
|
|
10351
|
+
},
|
|
10352
|
+
// One line per consequence. Each is a fact somebody could be surprised by;
|
|
10353
|
+
// none of them needs a paragraph.
|
|
10354
|
+
React.createElement("div", { className: "ps-budget-consequences" },
|
|
10355
|
+
React.createElement("div", null,
|
|
10356
|
+
"Sessions on it ", React.createElement("strong", null, "wait indefinitely"), "."),
|
|
10357
|
+
limitCount > 0 ? React.createElement("div", null,
|
|
10358
|
+
// budgetPlural already prints the count. Prefixing it printed
|
|
10359
|
+
// "2 2 limits".
|
|
10360
|
+
React.createElement("strong", null, budgetPlural(limitCount, "limit", "limits")),
|
|
10361
|
+
" deleted with it, and they do not come back.") : null,
|
|
10362
|
+
isClusterDefault ? React.createElement("div", null,
|
|
10363
|
+
React.createElement("strong", null, "It is the cluster default."),
|
|
10364
|
+
" The cluster is left with none.") : null,
|
|
10365
|
+
isMyDefault ? React.createElement("div", null,
|
|
10366
|
+
React.createElement("strong", null, "It is your default."),
|
|
10367
|
+
" Yours is cleared.") : null,
|
|
10368
|
+
React.createElement("div", { className: "ps-budget-sub" }, "Usage history is retained under this name.")));
|
|
10369
|
+
}
|
|
10370
|
+
|
|
10371
|
+
/**
|
|
10372
|
+
* What the surface needs out of the store. selectProviderTable builds fresh
|
|
10373
|
+
* arrays on every call, so subscribing to it directly would re-render on every
|
|
10374
|
+
* unrelated dispatch — and a streaming session dispatches per token. These are
|
|
10375
|
+
* the raw references it actually reads, compared by identity.
|
|
10376
|
+
*/
|
|
10377
|
+
function budgetViewDeps(state) {
|
|
10378
|
+
const budget = state.budget || {};
|
|
10379
|
+
return [
|
|
10380
|
+
Boolean(state.ui?.budgetOpen), budget.loading, budget.refreshing, budget.loaded,
|
|
10381
|
+
budget.error, budget.grid, budget.paused, budget.overall, budget.selectedProvider,
|
|
10382
|
+
budget.selectedScope, budget.missingProvider, budget.series,
|
|
10383
|
+
state.auth?.authorization?.role, state.admin?.profile,
|
|
10384
|
+
];
|
|
10385
|
+
}
|
|
10386
|
+
|
|
10387
|
+
/**
|
|
10388
|
+
* The whole surface: a head, one table, and the selected provider's days.
|
|
10389
|
+
*
|
|
10390
|
+
* No device prop: the stylesheet reflows the table into cards on its own, so
|
|
10391
|
+
* there is nothing here for a phone to decide.
|
|
10392
|
+
*/
|
|
10393
|
+
function ProviderBudgetView({ controller }) {
|
|
10394
|
+
const deps = useControllerSelector(controller, budgetViewDeps, shallowEqualObject);
|
|
10395
|
+
const view = React.useMemo(() => selectProviderTable(controller.getState()), [controller, deps]);
|
|
10396
|
+
// The grid as the server sent it. The sheets need the PROVIDER's own
|
|
10397
|
+
// figures — a limit caps everyone — and the table's cells hold whichever
|
|
10398
|
+
// pair the tick is showing, which is the viewer's own half the time.
|
|
10399
|
+
const rawRows = React.useMemo(() => controller.getState().budget?.grid || [], [controller, deps]);
|
|
10400
|
+
|
|
10401
|
+
// What the sheets need and the table does not: which type each provider is
|
|
10402
|
+
// an instance of, and what the two defaults currently name. Read once, and
|
|
10403
|
+
// a failure is not fatal — the controls that need it say they have nothing
|
|
10404
|
+
// to offer.
|
|
10405
|
+
const [models, setModels] = React.useState([]);
|
|
10406
|
+
const [providerTypes, setProviderTypes] = React.useState(new Map());
|
|
10407
|
+
const [defaults, setDefaults] = React.useState(null);
|
|
10408
|
+
const readDefaults = React.useCallback(() => {
|
|
10409
|
+
if (typeof controller.transport.getDefaults !== "function") return;
|
|
10410
|
+
Promise.resolve(controller.transport.getDefaults())
|
|
10411
|
+
.then((rows) => setDefaults(rows || null))
|
|
10412
|
+
.catch(() => {});
|
|
10413
|
+
}, [controller]);
|
|
10414
|
+
React.useEffect(() => {
|
|
10415
|
+
let live = true;
|
|
10416
|
+
if (typeof controller.transport.listModels === "function") {
|
|
10417
|
+
Promise.resolve(controller.transport.listModels())
|
|
10418
|
+
.then((rows) => { if (live) setModels(budgetNormalizeModels(rows)); })
|
|
10419
|
+
.catch(() => {});
|
|
10420
|
+
}
|
|
10421
|
+
if (typeof controller.transport.listProviders === "function") {
|
|
10422
|
+
Promise.resolve(controller.transport.listProviders())
|
|
10423
|
+
.then((rows) => {
|
|
10424
|
+
if (!live) return;
|
|
10425
|
+
const map = new Map();
|
|
10426
|
+
for (const provider of (rows?.providers || [])) map.set(provider.name, provider.typeId || "");
|
|
10427
|
+
setProviderTypes(map);
|
|
10428
|
+
})
|
|
10429
|
+
.catch(() => {});
|
|
10430
|
+
}
|
|
10431
|
+
readDefaults();
|
|
10432
|
+
return () => { live = false; };
|
|
10433
|
+
}, [controller, readDefaults]);
|
|
10434
|
+
|
|
10435
|
+
// Opened by the toolbar, which loads it. Mounting with nothing behind it —
|
|
10436
|
+
// a restored state, a second mount — reads once rather than showing zeros.
|
|
10437
|
+
React.useEffect(() => {
|
|
10438
|
+
const budget = controller.getState().budget || {};
|
|
10439
|
+
if (!budget.loaded && !budget.loading && !budget.error) {
|
|
10440
|
+
controller.loadProviderTable().catch(() => {});
|
|
10441
|
+
}
|
|
10442
|
+
}, [controller]);
|
|
10443
|
+
|
|
10444
|
+
// Limits reset, administrators raise them, sessions resume — and nothing
|
|
10445
|
+
// here would notice.
|
|
10446
|
+
React.useEffect(() => {
|
|
10447
|
+
const id = setInterval(() => { controller.refreshProviderTable().catch(() => {}); }, 60_000);
|
|
10448
|
+
return () => clearInterval(id);
|
|
10449
|
+
}, [controller]);
|
|
10450
|
+
|
|
10451
|
+
// Escape closes the surface — the key every full-screen thing in this app
|
|
10452
|
+
// answers to. Not while a <select> or an input has focus, not under a
|
|
10453
|
+
// modal, and not while a sheet is open: Escape belongs to whatever is
|
|
10454
|
+
// nearest the person.
|
|
10455
|
+
React.useEffect(() => {
|
|
10456
|
+
const onKey = (event) => {
|
|
10457
|
+
if (event.key !== "Escape") return;
|
|
10458
|
+
const target = event.target;
|
|
10459
|
+
const tag = target && target.tagName;
|
|
10460
|
+
if (tag === "SELECT" || tag === "INPUT" || tag === "TEXTAREA") return;
|
|
10461
|
+
if (controller.getState().ui.modal) return;
|
|
10462
|
+
if (document.querySelector(".ps-budget-sheet")) return;
|
|
10463
|
+
event.preventDefault();
|
|
10464
|
+
event.stopPropagation();
|
|
10465
|
+
controller.closeBudget();
|
|
10466
|
+
};
|
|
10467
|
+
window.addEventListener("keydown", onKey, true);
|
|
10468
|
+
return () => window.removeEventListener("keydown", onKey, true);
|
|
10469
|
+
}, [controller]);
|
|
10470
|
+
|
|
10471
|
+
// ── the sheets ─────────────────────────────────────────────────────
|
|
10472
|
+
const [sheet, setSheet] = React.useState(null);
|
|
10473
|
+
const [sheetBusy, setSheetBusy] = React.useState(false);
|
|
10474
|
+
const [sheetError, setSheetError] = React.useState(null);
|
|
10475
|
+
// The control the sheet was opened FROM. A sheet that closes without
|
|
10476
|
+
// handing focus back drops it on <body>, and the next Tab restarts at the
|
|
10477
|
+
// top of the document — nineteen presses from where the person was.
|
|
10478
|
+
const openerRef = React.useRef(null);
|
|
10479
|
+
const openSheet = (spec) => {
|
|
10480
|
+
openerRef.current = typeof document !== "undefined" ? document.activeElement : null;
|
|
10481
|
+
setSheetError(null); setSheetBusy(false); setSheet(spec);
|
|
10482
|
+
};
|
|
10483
|
+
const closeSheet = React.useCallback(() => {
|
|
10484
|
+
setSheet(null); setSheetError(null); setSheetBusy(false);
|
|
10485
|
+
const opener = openerRef.current;
|
|
10486
|
+
openerRef.current = null;
|
|
10487
|
+
// A control the change itself removed (Delete) is skipped rather than
|
|
10488
|
+
// focused into a detached node.
|
|
10489
|
+
if (opener && typeof opener.focus === "function" && document.contains(opener)) {
|
|
10490
|
+
opener.focus();
|
|
10491
|
+
}
|
|
10492
|
+
}, []);
|
|
10493
|
+
// Every change comes back as {ok, error, code, result} — nothing throws. A
|
|
10494
|
+
// refusal stays in the sheet, in the server's own words, because they name
|
|
10495
|
+
// the remedy and nothing this file could invent would.
|
|
10496
|
+
const runChange = async (run, { keepOpen = false, onDone = null } = {}) => {
|
|
10497
|
+
setSheetBusy(true);
|
|
10498
|
+
setSheetError(null);
|
|
10499
|
+
const outcome = await run();
|
|
10500
|
+
setSheetBusy(false);
|
|
10501
|
+
if (!outcome || outcome.ok !== true) {
|
|
10502
|
+
setSheetError(outcome?.error || "The change was refused.");
|
|
10503
|
+
return;
|
|
10504
|
+
}
|
|
10505
|
+
if (!keepOpen) closeSheet();
|
|
10506
|
+
if (onDone) onDone(outcome.result || null);
|
|
10507
|
+
};
|
|
10508
|
+
const say = (text) => controller.dispatch({ type: "ui/status", text });
|
|
10509
|
+
|
|
10510
|
+
const selected = view.selected;
|
|
10511
|
+
// Edit and Remove are about the PROVIDER even while one of its model rows
|
|
10512
|
+
// is the row being read: a limit is edited on the provider, and there is
|
|
10513
|
+
// no such thing as deleting a model.
|
|
10514
|
+
const selectedProvider = view.selectedProviderRow;
|
|
10515
|
+
const modelsForSelected = selected
|
|
10516
|
+
? budgetModelsForProvider(models, {
|
|
10517
|
+
name: selected.providerName, typeId: providerTypes.get(selected.providerName) || "",
|
|
10518
|
+
})
|
|
10519
|
+
: [];
|
|
10520
|
+
|
|
10521
|
+
let sheetEl = null;
|
|
10522
|
+
if (sheet?.kind === "edit" && selectedProvider) {
|
|
10523
|
+
sheetEl = React.createElement(EditProviderSheet, {
|
|
10524
|
+
row: selectedProvider, rawRows, models: modelsForSelected, isAdmin: view.isAdmin,
|
|
10525
|
+
// MAJOR 17: the sheet opens on the limit the reader was standing
|
|
10526
|
+
// on, rather than always on Daily / all models.
|
|
10527
|
+
initialPeriod: sheet.period || "day",
|
|
10528
|
+
initialScope: sheet.scope || "*",
|
|
10529
|
+
busy: sheetBusy, error: sheetError, onCancel: closeSheet,
|
|
10530
|
+
onRun: (kind, payload) => {
|
|
10531
|
+
const name = selected.providerName;
|
|
10532
|
+
if (kind === "limit") {
|
|
10533
|
+
return runChange(() => controller.setProviderLimit({ provider: name, ...payload }), {
|
|
10534
|
+
onDone: (result) => {
|
|
10535
|
+
const seeded = Number(result?.seededTokens) || 0;
|
|
10536
|
+
say(seeded > 0
|
|
10537
|
+
? `Limit saved. This period had already spent ${formatCompactNumber(seeded)} tokens, and they count against it.`
|
|
10538
|
+
: "Limit saved. This period has spent nothing against it yet.");
|
|
10539
|
+
},
|
|
10540
|
+
});
|
|
10541
|
+
}
|
|
10542
|
+
if (kind === "removeLimit") {
|
|
10543
|
+
return runChange(() => controller.removeProviderLimit({ provider: name, ...payload }), {
|
|
10544
|
+
onDone: () => say(`That period no longer caps ${name}.`),
|
|
10545
|
+
});
|
|
10546
|
+
}
|
|
10547
|
+
if (kind === "allowance") {
|
|
10548
|
+
return runChange(() => controller.setProviderAllowance({ provider: name, pct: payload.pct }), {
|
|
10549
|
+
onDone: () => say(payload.pct >= 100
|
|
10550
|
+
? `Everyone shares the whole of ${name} again.`
|
|
10551
|
+
: `Each person may now use up to ${payload.pct}% of each of ${name}'s limits.`),
|
|
10552
|
+
});
|
|
10553
|
+
}
|
|
10554
|
+
if (kind === "hold") {
|
|
10555
|
+
return runChange(() => controller.setProviderHold({ provider: name, ...payload }), {
|
|
10556
|
+
// Reported from what the row says AFTER the read, not
|
|
10557
|
+
// from what was sent: a hold that is not live must
|
|
10558
|
+
// never be confirmed as one.
|
|
10559
|
+
onDone: () => {
|
|
10560
|
+
if (payload.release) {
|
|
10561
|
+
say(`The hold on ${name} is released. Sessions waiting on it wake up.`);
|
|
10562
|
+
return;
|
|
10563
|
+
}
|
|
10564
|
+
// From the STATE, which the change already
|
|
10565
|
+
// re-read — `view` here is the render that opened
|
|
10566
|
+
// the sheet and predates the write.
|
|
10567
|
+
const raw = (controller.getState().budget?.grid || []).find(
|
|
10568
|
+
(r) => r.providerName === name && r.rowKind !== "model");
|
|
10569
|
+
const live = raw?.holdIndefinite === true
|
|
10570
|
+
|| (raw?.holdUntilUtc ? Date.parse(raw.holdUntilUtc) > Date.now() : false);
|
|
10571
|
+
say(live
|
|
10572
|
+
? `${name} is on hold. No new turn runs against it until it is released.`
|
|
10573
|
+
: `${name} is NOT on hold: the time given has already passed.`);
|
|
10574
|
+
},
|
|
10575
|
+
});
|
|
10576
|
+
}
|
|
10577
|
+
return undefined;
|
|
10578
|
+
},
|
|
10579
|
+
});
|
|
10580
|
+
}
|
|
10581
|
+
|
|
10582
|
+
// ── the head ───────────────────────────────────────────────────────
|
|
10583
|
+
const head = React.createElement("div", { className: "ps-budget-head" },
|
|
10584
|
+
React.createElement("div", { className: "ps-budget-headline" },
|
|
10585
|
+
React.createElement("span", { className: "ps-budget-coin", "aria-hidden": "true" }, "◎"),
|
|
10586
|
+
React.createElement("h2", { className: "ps-budget-h" }, "Providers & Budgets"),
|
|
10587
|
+
React.createElement("span", { className: "ps-budget-sub" }, "Usage and limits")),
|
|
10588
|
+
React.createElement("div", { className: "ps-budget-head-actions" },
|
|
10589
|
+
React.createElement(IconButton, {
|
|
10590
|
+
icon: React.createElement(CogGlyph),
|
|
10591
|
+
label: "Model providers",
|
|
10592
|
+
className: "ps-mini-button",
|
|
10593
|
+
onClick: () => { controller.closeBudget(); void controller.openAdminConsole(); },
|
|
10594
|
+
}),
|
|
10595
|
+
React.createElement(IconButton, {
|
|
10596
|
+
icon: "↻",
|
|
10597
|
+
label: "Refresh providers and budgets",
|
|
10598
|
+
className: "ps-mini-button",
|
|
10599
|
+
disabled: view.loading || view.refreshing,
|
|
10600
|
+
onClick: () => controller.refreshProviderTable().catch(() => {}),
|
|
10601
|
+
}),
|
|
10602
|
+
React.createElement("button", {
|
|
10603
|
+
type: "button", className: "ps-budget-close", onClick: () => controller.closeBudget(),
|
|
10604
|
+
"aria-label": "Close providers and budgets", title: "Close (Esc)",
|
|
10605
|
+
}, "✕")));
|
|
10606
|
+
|
|
10607
|
+
// A read that FAILED is never dressed up as an empty one: "no providers"
|
|
10608
|
+
// and "we could not find out" are different facts, and only one of them is
|
|
10609
|
+
// safe to act on.
|
|
10610
|
+
let body;
|
|
10611
|
+
if (view.failed) {
|
|
10612
|
+
body = React.createElement("div", { className: "ps-budget-empty is-error", role: "alert" },
|
|
10613
|
+
React.createElement("div", null, "Providers and budgets could not be read. ",
|
|
10614
|
+
React.createElement("button", {
|
|
10615
|
+
type: "button", className: "ps-budget-link",
|
|
10616
|
+
onClick: () => controller.loadProviderTable().catch(() => {}),
|
|
10617
|
+
}, "Try again")),
|
|
10618
|
+
React.createElement("div", { className: "ps-budget-sub" }, view.error));
|
|
10619
|
+
} else if (view.loading && !view.loaded) {
|
|
10620
|
+
body = React.createElement("div", { className: "ps-budget-empty" }, "Reading providers and budgets…");
|
|
10621
|
+
} else {
|
|
10622
|
+
body = React.createElement(React.Fragment, null,
|
|
10623
|
+
// Numbers are still on screen but the last read failed. Say they
|
|
10624
|
+
// are old rather than passing them off as current.
|
|
10625
|
+
view.stale ? React.createElement("div", { className: "ps-budget-empty is-error", role: "alert" },
|
|
10626
|
+
React.createElement("div", null, "These figures are from an earlier read. The most recent read failed."),
|
|
10627
|
+
React.createElement("div", { className: "ps-budget-sub" }, view.error)) : null,
|
|
10628
|
+
// One line, not a band. WHY a particular session is stopped is said
|
|
10629
|
+
// on the session itself, which is where the remedy is; this only
|
|
10630
|
+
// answers "is anything waiting right now".
|
|
10631
|
+
view.paused.count > 0 ? React.createElement("div", {
|
|
10632
|
+
className: `ps-budget-waiting${view.paused.stale ? " is-stale" : ""}`, role: "status",
|
|
10633
|
+
},
|
|
10634
|
+
view.paused.sentence,
|
|
10635
|
+
" ",
|
|
10636
|
+
view.paused.provider ? React.createElement("button", {
|
|
10637
|
+
type: "button", className: "ps-budget-link",
|
|
10638
|
+
onClick: () => {
|
|
10639
|
+
// The link OPENS the row; it does not toggle it shut when
|
|
10640
|
+
// the reader is already standing on it.
|
|
10641
|
+
if (view.selectedProvider === view.paused.provider) return;
|
|
10642
|
+
controller.selectBudgetProvider(view.paused.provider).catch(() => {});
|
|
10643
|
+
},
|
|
10644
|
+
}, `Open ${view.paused.provider}`) : null,
|
|
10645
|
+
// The numbers above were read fine; THIS line is the old one.
|
|
10646
|
+
view.paused.stale ? React.createElement("span", { className: "ps-budget-sub" },
|
|
10647
|
+
" This line is from an earlier read — the last one failed.") : null) : null,
|
|
10648
|
+
// The waiting list failed and there is nothing behind it, so there
|
|
10649
|
+
// is no line to mark. Say the read failed rather than nothing.
|
|
10650
|
+
view.paused.count === 0 && view.paused.error ? React.createElement("div", {
|
|
10651
|
+
className: "ps-budget-waiting is-stale", role: "status",
|
|
10652
|
+
}, "The list of waiting sessions could not be read. The figures below were read successfully.") : null,
|
|
10653
|
+
// Somebody arrived here by clicking a provider name on a stopped
|
|
10654
|
+
// session, and no such provider exists. Without this the selection
|
|
10655
|
+
// is cleared and they are left on a table with nothing selected
|
|
10656
|
+
// and no sign that the name they clicked is the missing thing.
|
|
10657
|
+
// Creating it again is the whole remedy, but provider lifecycle
|
|
10658
|
+
// belongs to Admin Console rather than this usage-policy surface.
|
|
10659
|
+
view.missing ? React.createElement("div", {
|
|
10660
|
+
className: "ps-budget-waiting is-missing", role: "status",
|
|
10661
|
+
},
|
|
10662
|
+
view.missing.sentence,
|
|
10663
|
+
" ",
|
|
10664
|
+
React.createElement("button", {
|
|
10665
|
+
type: "button", className: "ps-budget-link",
|
|
10666
|
+
onClick: () => { controller.closeBudget(); void controller.openAdminConsole(); },
|
|
10667
|
+
}, "Open Model Providers")) : null,
|
|
10668
|
+
React.createElement("div", {
|
|
10669
|
+
className: `ps-budget-body${view.refreshing ? " is-refreshing" : ""}`,
|
|
10670
|
+
},
|
|
10671
|
+
React.createElement(ProviderTable, { controller, view, defaults }),
|
|
10672
|
+
React.createElement("div", { className: "ps-budget-actions" },
|
|
10673
|
+
React.createElement(IconButton, {
|
|
10674
|
+
icon: React.createElement(ManageGlyph),
|
|
10675
|
+
label: "Edit usage policy",
|
|
10676
|
+
className: "ps-mini-button",
|
|
10677
|
+
disabled: !selectedProvider || !selectedProvider.manageable,
|
|
10678
|
+
onClick: () => openSheet({
|
|
10679
|
+
kind: "edit",
|
|
10680
|
+
// Open on the row the reader is standing on: a model
|
|
10681
|
+
// row means they were looking at THAT limit.
|
|
10682
|
+
scope: selected?.kind === "model" ? selected.scope : "*",
|
|
10683
|
+
}),
|
|
10684
|
+
}),
|
|
10685
|
+
React.createElement("span", { className: "ps-budget-sub" },
|
|
10686
|
+
selected ? selected.providerName : "No provider selected")),
|
|
10687
|
+
React.createElement("div", { className: "ps-budget-foot ps-budget-sub" },
|
|
10688
|
+
"Manage providers and defaults in Admin Console."),
|
|
10689
|
+
// Selecting a row opens its days, with the dashed line at whatever
|
|
10690
|
+
// quota the Day cell just showed.
|
|
10691
|
+
selected ? React.createElement(ProviderDayChart, { controller, view }) : null,
|
|
10692
|
+
selected ? React.createElement(ProviderSystemSpend, { view }) : null));
|
|
10693
|
+
}
|
|
10694
|
+
|
|
10695
|
+
return React.createElement("div", { className: "ps-budget-surface" },
|
|
10696
|
+
head,
|
|
10697
|
+
React.createElement("div", { className: "ps-budget" }, body),
|
|
10698
|
+
sheetEl);
|
|
10699
|
+
}
|
|
10700
|
+
|
|
9264
10701
|
function ModalLayer({ controller }) {
|
|
9265
10702
|
const themeId = useControllerSelector(controller, (state) => state.ui.themeId);
|
|
9266
10703
|
const touchScale = useControllerSelector(controller, (state) => Boolean(state.ui.touchScale));
|
|
@@ -10214,10 +11651,12 @@ function formatAdminPrincipalLabel(principal) {
|
|
|
10214
11651
|
|
|
10215
11652
|
function AdminConsolePanel({ controller, mobile = false }) {
|
|
10216
11653
|
const view = useControllerSelector(controller, selectAdminConsole, shallowEqualObject);
|
|
10217
|
-
const draftRef = React.useRef(null);
|
|
10218
11654
|
const packages = view.packages || {};
|
|
10219
11655
|
const showPackages = view.section === "packages";
|
|
10220
11656
|
const showWorkers = view.section === "workers";
|
|
11657
|
+
const [providerSheet, setProviderSheet] = React.useState(null);
|
|
11658
|
+
const [providerSheetBusy, setProviderSheetBusy] = React.useState(false);
|
|
11659
|
+
const [providerSheetError, setProviderSheetError] = React.useState(null);
|
|
10221
11660
|
// Workspace pane geometry: user-resizable (drag the pane's left edge for
|
|
10222
11661
|
// width, the Preview header for the tree/preview split), persisted.
|
|
10223
11662
|
const [wsLayout, setWsLayout] = React.useState(() => {
|
|
@@ -10257,39 +11696,25 @@ function AdminConsolePanel({ controller, mobile = false }) {
|
|
|
10257
11696
|
window.addEventListener("pointercancel", onUp);
|
|
10258
11697
|
}, [wsLayout.navWidth, updateWsLayout]);
|
|
10259
11698
|
|
|
10260
|
-
React.useEffect(() => {
|
|
10261
|
-
if (view.ghcpKey.editing) {
|
|
10262
|
-
// Defer focus to next tick so the input has mounted.
|
|
10263
|
-
const handle = window.requestAnimationFrame(() => {
|
|
10264
|
-
if (draftRef.current) draftRef.current.focus();
|
|
10265
|
-
});
|
|
10266
|
-
return () => window.cancelAnimationFrame(handle);
|
|
10267
|
-
}
|
|
10268
|
-
return undefined;
|
|
10269
|
-
}, [view.ghcpKey.editing]);
|
|
10270
|
-
|
|
10271
11699
|
const onClose = React.useCallback(() => {
|
|
10272
11700
|
controller.closeAdminConsole();
|
|
10273
11701
|
}, [controller]);
|
|
10274
|
-
const
|
|
10275
|
-
|
|
10276
|
-
|
|
10277
|
-
|
|
10278
|
-
|
|
10279
|
-
|
|
10280
|
-
|
|
10281
|
-
|
|
10282
|
-
|
|
10283
|
-
|
|
10284
|
-
|
|
10285
|
-
|
|
10286
|
-
|
|
10287
|
-
|
|
10288
|
-
|
|
10289
|
-
}, [controller]);
|
|
10290
|
-
const onDraftChange = React.useCallback((event) => {
|
|
10291
|
-
controller.setAdminGhcpKeyDraft(event.target.value);
|
|
10292
|
-
}, [controller]);
|
|
11702
|
+
const closeProviderSheet = React.useCallback(() => {
|
|
11703
|
+
setProviderSheet(null);
|
|
11704
|
+
setProviderSheetBusy(false);
|
|
11705
|
+
setProviderSheetError(null);
|
|
11706
|
+
}, []);
|
|
11707
|
+
const submitProvider = React.useCallback(async (input) => {
|
|
11708
|
+
setProviderSheetBusy(true);
|
|
11709
|
+
setProviderSheetError(null);
|
|
11710
|
+
const outcome = await controller.createAdminProvider(input);
|
|
11711
|
+
setProviderSheetBusy(false);
|
|
11712
|
+
if (!outcome?.ok) {
|
|
11713
|
+
setProviderSheetError(outcome?.error || "The provider could not be created.");
|
|
11714
|
+
return;
|
|
11715
|
+
}
|
|
11716
|
+
closeProviderSheet();
|
|
11717
|
+
}, [closeProviderSheet, controller]);
|
|
10293
11718
|
|
|
10294
11719
|
const principalLabel = formatAdminPrincipalLabel(view.principal);
|
|
10295
11720
|
|
|
@@ -10304,8 +11729,11 @@ function AdminConsolePanel({ controller, mobile = false }) {
|
|
|
10304
11729
|
"aria-label": "Close the admin console",
|
|
10305
11730
|
}, "\u2715"));
|
|
10306
11731
|
|
|
10307
|
-
const
|
|
10308
|
-
|
|
11732
|
+
const providerSection = React.createElement(AdminModelProvidersSection, {
|
|
11733
|
+
controller,
|
|
11734
|
+
view,
|
|
11735
|
+
onAddPersonal: () => setProviderSheet({ shared: false, github: true }),
|
|
11736
|
+
onAddShared: () => setProviderSheet({ shared: true, github: false }),
|
|
10309
11737
|
});
|
|
10310
11738
|
|
|
10311
11739
|
const tree = React.createElement(AdminSettingsTree, { controller, view });
|
|
@@ -10316,6 +11744,24 @@ function AdminConsolePanel({ controller, mobile = false }) {
|
|
|
10316
11744
|
const dialog = packages.addDialog?.open
|
|
10317
11745
|
? React.createElement(AdminAddPackageDialog, { controller, dialog: packages.addDialog })
|
|
10318
11746
|
: null;
|
|
11747
|
+
const createProviderDialog = providerSheet
|
|
11748
|
+
? React.createElement(CreateProviderSheet, {
|
|
11749
|
+
types: view.modelProviders?.providerTypes || [],
|
|
11750
|
+
existingNames: [
|
|
11751
|
+
...(view.modelProviders?.sharedProviders || []),
|
|
11752
|
+
...(view.modelProviders?.myProviders || []),
|
|
11753
|
+
].map((provider) => provider.name),
|
|
11754
|
+
isAdmin: view.isAdmin,
|
|
11755
|
+
initialShared: providerSheet.shared,
|
|
11756
|
+
initialType: providerSheet.github
|
|
11757
|
+
? (view.modelProviders?.providerTypes || []).find((type) => /github/i.test(type.id))?.id || ""
|
|
11758
|
+
: "",
|
|
11759
|
+
busy: providerSheetBusy,
|
|
11760
|
+
error: providerSheetError,
|
|
11761
|
+
onCancel: closeProviderSheet,
|
|
11762
|
+
onConfirm: submitProvider,
|
|
11763
|
+
})
|
|
11764
|
+
: null;
|
|
10319
11765
|
|
|
10320
11766
|
if (mobile) {
|
|
10321
11767
|
// Drill-in stack: settings list → section content; a selected package
|
|
@@ -10335,13 +11781,14 @@ function AdminConsolePanel({ controller, mobile = false }) {
|
|
|
10335
11781
|
body = React.createElement("div", { className: "ps-admin-mobile-stack" }, tree,
|
|
10336
11782
|
React.createElement(AdminWorkersPane, { controller, view }));
|
|
10337
11783
|
} else {
|
|
10338
|
-
body = React.createElement("div", { className: "ps-admin-mobile-stack" }, tree,
|
|
11784
|
+
body = React.createElement("div", { className: "ps-admin-mobile-stack" }, tree, providerSection);
|
|
10339
11785
|
}
|
|
10340
11786
|
return React.createElement("div", { className: "ps-admin-console is-mobile" },
|
|
10341
11787
|
header,
|
|
10342
11788
|
view.loadError ? React.createElement("div", { className: "ps-admin-console__error", role: "alert" }, view.loadError) : null,
|
|
10343
11789
|
body,
|
|
10344
|
-
dialog
|
|
11790
|
+
dialog,
|
|
11791
|
+
createProviderDialog);
|
|
10345
11792
|
}
|
|
10346
11793
|
|
|
10347
11794
|
return React.createElement("div", { className: "ps-admin-console is-workspace" },
|
|
@@ -10363,9 +11810,10 @@ function AdminConsolePanel({ controller, mobile = false }) {
|
|
|
10363
11810
|
"aria-label": "Resize settings column",
|
|
10364
11811
|
})),
|
|
10365
11812
|
React.createElement("div", { className: "ps-admin-main" },
|
|
10366
|
-
showPackages ? detail : showWorkers ? React.createElement(AdminWorkersPane, { controller, view }) :
|
|
11813
|
+
showPackages ? detail : showWorkers ? React.createElement(AdminWorkersPane, { controller, view }) : providerSection),
|
|
10367
11814
|
workspacePane),
|
|
10368
|
-
dialog
|
|
11815
|
+
dialog,
|
|
11816
|
+
createProviderDialog);
|
|
10369
11817
|
}
|
|
10370
11818
|
|
|
10371
11819
|
function AdminWorkersPane({ controller, view }) {
|
|
@@ -10449,7 +11897,17 @@ function AdminSettingsTree({ controller, view }) {
|
|
|
10449
11897
|
? { owner: { provider: row.owner.provider, subject: row.owner.subject } }
|
|
10450
11898
|
: {}),
|
|
10451
11899
|
})
|
|
10452
|
-
: () =>
|
|
11900
|
+
: () => {
|
|
11901
|
+
if (row.id === "providers" || row.id === "myProviders") {
|
|
11902
|
+
controller.setAdminSection("providers");
|
|
11903
|
+
controller.setAdminModelProviderPage("mine");
|
|
11904
|
+
} else if (row.id === "sharedProviders") {
|
|
11905
|
+
controller.setAdminSection("providers");
|
|
11906
|
+
controller.setAdminModelProviderPage("shared");
|
|
11907
|
+
} else {
|
|
11908
|
+
controller.setAdminSection(row.id === "agents" ? "packages" : "workers");
|
|
11909
|
+
}
|
|
11910
|
+
};
|
|
10453
11911
|
return React.createElement("button", {
|
|
10454
11912
|
key: row.id,
|
|
10455
11913
|
type: "button",
|
|
@@ -10475,11 +11933,11 @@ function AdminSettingsTree({ controller, view }) {
|
|
|
10475
11933
|
}),
|
|
10476
11934
|
packages.loading ? React.createElement("div", { className: "ps-admin-tree__hint" }, "Loading packages…") : null,
|
|
10477
11935
|
packages.error ? React.createElement("div", { className: "ps-admin-tree__hint is-error" }, packages.error) : null,
|
|
10478
|
-
React.createElement("button", {
|
|
11936
|
+
view.section === "packages" ? React.createElement("button", {
|
|
10479
11937
|
type: "button",
|
|
10480
11938
|
className: "ps-admin-tree__add",
|
|
10481
11939
|
onClick: () => controller.openAdminAddPackage(),
|
|
10482
|
-
}, "+ Add package"));
|
|
11940
|
+
}, "+ Add package") : null);
|
|
10483
11941
|
}
|
|
10484
11942
|
|
|
10485
11943
|
function AdminPackageDetailPane({ controller, view }) {
|
|
@@ -10891,6 +12349,219 @@ function AdminAddPackageDialog({ controller, dialog }) {
|
|
|
10891
12349
|
: dialog.updateName ? "Import & update" : "Import & publish"))));
|
|
10892
12350
|
}
|
|
10893
12351
|
|
|
12352
|
+
function adminDefaultModel(defaultView) {
|
|
12353
|
+
return defaultView?.configured?.model || "";
|
|
12354
|
+
}
|
|
12355
|
+
|
|
12356
|
+
function AdminDefaultSelect({ label, hint, value, choices, disabled, emptyLabel, onChange }) {
|
|
12357
|
+
return React.createElement("label", { className: "ps-admin-provider-default" },
|
|
12358
|
+
React.createElement("span", { className: "ps-admin-detail__label" }, label),
|
|
12359
|
+
React.createElement("select", {
|
|
12360
|
+
className: "ps-budget-input",
|
|
12361
|
+
value,
|
|
12362
|
+
disabled,
|
|
12363
|
+
"aria-label": label,
|
|
12364
|
+
onChange: (event) => onChange(event.target.value),
|
|
12365
|
+
},
|
|
12366
|
+
React.createElement("option", { value: "" }, emptyLabel),
|
|
12367
|
+
value && !choices.some((choice) => choice.qualifiedName === value)
|
|
12368
|
+
? React.createElement("option", { value }, `${value} (currently configured)`)
|
|
12369
|
+
: null,
|
|
12370
|
+
choices.map((choice) => React.createElement("option", {
|
|
12371
|
+
key: choice.qualifiedName,
|
|
12372
|
+
value: choice.qualifiedName,
|
|
12373
|
+
}, `${choice.provider} · ${choice.model}`))),
|
|
12374
|
+
hint ? React.createElement("span", { className: "ps-admin-console__hint" }, hint) : null);
|
|
12375
|
+
}
|
|
12376
|
+
|
|
12377
|
+
function AdminProviderRows({ providers, isAdmin, personal, busy, onSystemUse, onDelete }) {
|
|
12378
|
+
if (!providers.length) {
|
|
12379
|
+
return React.createElement("p", { className: "ps-admin-console__hint" },
|
|
12380
|
+
personal ? "No personal providers yet." : "No shared providers are configured.");
|
|
12381
|
+
}
|
|
12382
|
+
return React.createElement("div", { className: "ps-admin-provider-list" },
|
|
12383
|
+
providers.map((provider) => React.createElement("div", {
|
|
12384
|
+
key: provider.name,
|
|
12385
|
+
className: "ps-admin-provider-row",
|
|
12386
|
+
},
|
|
12387
|
+
React.createElement("div", { className: "ps-admin-provider-row__identity" },
|
|
12388
|
+
React.createElement("strong", null, provider.name),
|
|
12389
|
+
React.createElement("span", { className: `ps-scope-badge is-${provider.class === "shared" ? "shared" : "user"}` }, provider.class),
|
|
12390
|
+
provider.isMyDefault ? React.createElement("span", { className: "ps-scope-badge is-off" }, "my default") : null,
|
|
12391
|
+
provider.isClusterDefault ? React.createElement("span", { className: "ps-scope-badge is-off" }, "cluster default") : null,
|
|
12392
|
+
provider.isSystemDefault ? React.createElement("span", { className: "ps-scope-badge is-off" }, "system default") : null),
|
|
12393
|
+
React.createElement("div", { className: "ps-admin-provider-row__actions" },
|
|
12394
|
+
personal && isAdmin ? React.createElement("label", { className: "ps-admin-console__system-toggle" },
|
|
12395
|
+
React.createElement("input", {
|
|
12396
|
+
type: "checkbox",
|
|
12397
|
+
checked: provider.systemUseEnabled,
|
|
12398
|
+
disabled: busy,
|
|
12399
|
+
onChange: (event) => onSystemUse(provider.name, event.target.checked),
|
|
12400
|
+
}),
|
|
12401
|
+
"Allow system sessions") : null,
|
|
12402
|
+
React.createElement(IconButton, {
|
|
12403
|
+
icon: React.createElement(TrashGlyph),
|
|
12404
|
+
label: `Delete ${provider.name}`,
|
|
12405
|
+
className: "ps-mini-button is-danger",
|
|
12406
|
+
disabled: busy,
|
|
12407
|
+
onClick: () => onDelete(provider),
|
|
12408
|
+
})))));
|
|
12409
|
+
}
|
|
12410
|
+
|
|
12411
|
+
function AdminModelProvidersSection({ controller, view, onAddPersonal, onAddShared }) {
|
|
12412
|
+
const providers = view.modelProviders || {};
|
|
12413
|
+
const pending = Boolean(providers.mutation?.pending);
|
|
12414
|
+
const systemConfigured = adminDefaultModel(providers.systemSessionDefault);
|
|
12415
|
+
const [systemDraft, setSystemDraft] = React.useState(systemConfigured);
|
|
12416
|
+
const [restartDisposition, setRestartDisposition] = React.useState("");
|
|
12417
|
+
const [restartResult, setRestartResult] = React.useState(null);
|
|
12418
|
+
|
|
12419
|
+
React.useEffect(() => {
|
|
12420
|
+
setSystemDraft(systemConfigured);
|
|
12421
|
+
}, [systemConfigured]);
|
|
12422
|
+
|
|
12423
|
+
const choiceFor = (choices, qualifiedName) => choices.find((choice) => choice.qualifiedName === qualifiedName) || null;
|
|
12424
|
+
const setDefault = (scope, choices, qualifiedName) => {
|
|
12425
|
+
void controller.setAdminModelDefault(scope, choiceFor(choices, qualifiedName));
|
|
12426
|
+
};
|
|
12427
|
+
const deleteProvider = (provider) => {
|
|
12428
|
+
if (!window.confirm(`Delete ${provider.name}? Sessions using it will wait until that provider name exists again.`)) return;
|
|
12429
|
+
void controller.deleteAdminProvider(provider.name, { shared: provider.class === "shared" });
|
|
12430
|
+
};
|
|
12431
|
+
const applySystemDefault = async () => {
|
|
12432
|
+
setRestartResult(null);
|
|
12433
|
+
const outcome = await controller.setAdminSystemModelDefault(
|
|
12434
|
+
choiceFor(providers.systemChoices || [], systemDraft),
|
|
12435
|
+
{ restartDisposition: restartDisposition || null },
|
|
12436
|
+
);
|
|
12437
|
+
if (outcome?.ok) setRestartResult(outcome.result?.restart || { requested: false });
|
|
12438
|
+
};
|
|
12439
|
+
const inheritingAgents = (providers.systemAgentRoutes || []).filter((route) => !route.override);
|
|
12440
|
+
const sharedPage = providers.page === "shared" && view.isAdmin;
|
|
12441
|
+
|
|
12442
|
+
const pageHeader = React.createElement("div", { className: "ps-admin-provider-heading" },
|
|
12443
|
+
React.createElement("h3", null, "Model Providers"),
|
|
12444
|
+
React.createElement(IconButton, {
|
|
12445
|
+
icon: "↻",
|
|
12446
|
+
label: "Refresh model providers",
|
|
12447
|
+
className: "ps-mini-button",
|
|
12448
|
+
disabled: providers.loading,
|
|
12449
|
+
onClick: () => controller.refreshAdminModelProviders(),
|
|
12450
|
+
}));
|
|
12451
|
+
|
|
12452
|
+
const myPage = React.createElement(React.Fragment, null,
|
|
12453
|
+
React.createElement("div", { className: "ps-admin-provider-block" },
|
|
12454
|
+
React.createElement("div", { className: "ps-admin-provider-block__head" },
|
|
12455
|
+
React.createElement("h4", null, "My Providers"),
|
|
12456
|
+
React.createElement("button", {
|
|
12457
|
+
type: "button", className: "ps-primary-button",
|
|
12458
|
+
onClick: onAddPersonal, disabled: pending,
|
|
12459
|
+
}, "Add provider")),
|
|
12460
|
+
React.createElement(AdminProviderRows, {
|
|
12461
|
+
providers: providers.myProviders || [], isAdmin: view.isAdmin, personal: true, busy: pending,
|
|
12462
|
+
onSystemUse: (name, enabled) => controller.setAdminProviderSystemUse(name, enabled),
|
|
12463
|
+
onDelete: deleteProvider,
|
|
12464
|
+
})),
|
|
12465
|
+
React.createElement("div", { className: "ps-admin-provider-block" },
|
|
12466
|
+
React.createElement(AdminDefaultSelect, {
|
|
12467
|
+
label: "My Session Default",
|
|
12468
|
+
hint: `Effective: ${providers.mySessionDefault?.effective?.model || "none"}.`,
|
|
12469
|
+
value: adminDefaultModel(providers.mySessionDefault),
|
|
12470
|
+
choices: providers.userChoices || [],
|
|
12471
|
+
disabled: pending,
|
|
12472
|
+
emptyLabel: "Use cluster default",
|
|
12473
|
+
onChange: (value) => setDefault("user", providers.userChoices || [], value),
|
|
12474
|
+
})));
|
|
12475
|
+
|
|
12476
|
+
const sharedProvidersPage = React.createElement(React.Fragment, null,
|
|
12477
|
+
React.createElement("div", { className: "ps-admin-provider-block" },
|
|
12478
|
+
React.createElement("div", { className: "ps-admin-provider-block__head" },
|
|
12479
|
+
React.createElement("h4", null, "Shared Providers"),
|
|
12480
|
+
React.createElement("button", {
|
|
12481
|
+
type: "button", className: "ps-primary-button",
|
|
12482
|
+
onClick: onAddShared, disabled: pending,
|
|
12483
|
+
}, "Add provider")),
|
|
12484
|
+
React.createElement(AdminProviderRows, {
|
|
12485
|
+
providers: providers.sharedProviders || [], isAdmin: true, personal: false, busy: pending,
|
|
12486
|
+
onSystemUse: () => {}, onDelete: deleteProvider,
|
|
12487
|
+
})),
|
|
12488
|
+
React.createElement("div", { className: "ps-admin-provider-block" },
|
|
12489
|
+
React.createElement(AdminDefaultSelect, {
|
|
12490
|
+
label: "Cluster Session Default",
|
|
12491
|
+
hint: `Effective: ${providers.clusterSessionDefault?.effective?.model || "none"}.`,
|
|
12492
|
+
value: adminDefaultModel(providers.clusterSessionDefault),
|
|
12493
|
+
choices: providers.clusterChoices || [],
|
|
12494
|
+
disabled: pending,
|
|
12495
|
+
emptyLabel: "Use first shared provider",
|
|
12496
|
+
onChange: (value) => setDefault("cluster", providers.clusterChoices || [], value),
|
|
12497
|
+
})),
|
|
12498
|
+
React.createElement("div", { className: "ps-admin-provider-block" },
|
|
12499
|
+
React.createElement("h4", null, "System Session Default"),
|
|
12500
|
+
React.createElement(AdminDefaultSelect, {
|
|
12501
|
+
label: "Model",
|
|
12502
|
+
hint: "Shared and system-enabled personal providers.",
|
|
12503
|
+
value: systemDraft,
|
|
12504
|
+
choices: providers.systemChoices || [],
|
|
12505
|
+
disabled: pending,
|
|
12506
|
+
emptyLabel: "Use first eligible provider",
|
|
12507
|
+
onChange: setSystemDraft,
|
|
12508
|
+
}),
|
|
12509
|
+
React.createElement("div", { className: "ps-admin-detail__label" }, "Existing system sessions"),
|
|
12510
|
+
React.createElement(Segmented, {
|
|
12511
|
+
value: restartDisposition,
|
|
12512
|
+
onChange: setRestartDisposition,
|
|
12513
|
+
label: "Existing system sessions",
|
|
12514
|
+
options: [
|
|
12515
|
+
{ value: "", label: "Future only" },
|
|
12516
|
+
{ value: "complete", label: "Complete & restart" },
|
|
12517
|
+
{ value: "terminate", label: "Terminate & restart" },
|
|
12518
|
+
{ value: "hard_delete", label: "Hard delete & restart" },
|
|
12519
|
+
],
|
|
12520
|
+
}),
|
|
12521
|
+
React.createElement("div", { className: "ps-admin-provider-apply" },
|
|
12522
|
+
React.createElement("p", { className: "ps-admin-console__hint" },
|
|
12523
|
+
`${inheritingAgents.length} agent${inheritingAgents.length === 1 ? "" : "s"} inherit this default.`),
|
|
12524
|
+
React.createElement("button", {
|
|
12525
|
+
type: "button", className: "ps-primary-button", disabled: pending, onClick: applySystemDefault,
|
|
12526
|
+
}, pending && providers.mutation?.pending === "systemDefault" ? "Applying…" : "Apply")),
|
|
12527
|
+
restartResult?.requested ? React.createElement("p", { className: "ps-admin-console__status" },
|
|
12528
|
+
`${restartResult.restarted}/${restartResult.affected} restarted${restartResult.failures?.length ? ` · ${restartResult.failures.length} failed` : ""}.`) : null),
|
|
12529
|
+
React.createElement("div", { className: "ps-admin-provider-block" },
|
|
12530
|
+
React.createElement("h4", null, "System Agent Overrides"),
|
|
12531
|
+
(providers.systemAgentRoutes || []).length === 0
|
|
12532
|
+
? React.createElement("p", { className: "ps-admin-console__hint" }, "No system agents.")
|
|
12533
|
+
: React.createElement("div", { className: "ps-admin-override-table" },
|
|
12534
|
+
(providers.systemAgentRoutes || []).map((route) => React.createElement("div", {
|
|
12535
|
+
key: route.agentId, className: "ps-admin-override-row",
|
|
12536
|
+
},
|
|
12537
|
+
React.createElement("div", { className: "ps-admin-override-row__agent" },
|
|
12538
|
+
React.createElement("strong", null, route.title),
|
|
12539
|
+
React.createElement("span", null, route.effectiveModel || "blocked"),
|
|
12540
|
+
React.createElement("small", null, route.override ? "override" : "system default")),
|
|
12541
|
+
React.createElement("select", {
|
|
12542
|
+
className: "ps-budget-input",
|
|
12543
|
+
value: route.override?.model || "",
|
|
12544
|
+
disabled: pending,
|
|
12545
|
+
"aria-label": `Model override for ${route.title}`,
|
|
12546
|
+
onChange: (event) => {
|
|
12547
|
+
const choice = choiceFor(providers.systemChoices || [], event.target.value);
|
|
12548
|
+
if (choice) void controller.setAdminSystemAgentModel(route.agentId, choice);
|
|
12549
|
+
else void controller.clearAdminSystemAgentModel(route.agentId);
|
|
12550
|
+
},
|
|
12551
|
+
},
|
|
12552
|
+
React.createElement("option", { value: "" }, "Inherit system default"),
|
|
12553
|
+
(providers.systemChoices || []).map((choice) => React.createElement("option", {
|
|
12554
|
+
key: choice.qualifiedName, value: choice.qualifiedName,
|
|
12555
|
+
}, `${choice.provider} · ${choice.model}`))))))));
|
|
12556
|
+
|
|
12557
|
+
return React.createElement("section", { className: "ps-admin-detail ps-admin-model-providers" },
|
|
12558
|
+
pageHeader,
|
|
12559
|
+
providers.error || providers.mutation?.error
|
|
12560
|
+
? React.createElement("div", { className: "ps-admin-console__error", role: "alert" }, providers.mutation?.error || providers.error)
|
|
12561
|
+
: null,
|
|
12562
|
+
sharedPage ? sharedProvidersPage : myPage);
|
|
12563
|
+
}
|
|
12564
|
+
|
|
10894
12565
|
function AdminGhcpSection({ view, draftRef, onBeginEdit, onCancelEdit, onClear, onSubmit, onDraftChange, onRefresh, controller }) {
|
|
10895
12566
|
return React.createElement("section", { className: "ps-admin-console__section" },
|
|
10896
12567
|
React.createElement("h3", null, "GitHub Copilot key"),
|
|
@@ -11084,6 +12755,10 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
11084
12755
|
selectedArtifactId: rootState.files.selectedArtifactId || null,
|
|
11085
12756
|
artifactPaneOpen: Boolean(rootState.files.paneOpen),
|
|
11086
12757
|
adminVisible: Boolean(rootState.admin?.visible),
|
|
12758
|
+
// Providers & Budgets takes the workspace, like the admin console. The
|
|
12759
|
+
// reducer keeps the two apart — opening either closes the other — so
|
|
12760
|
+
// this only decides which one is drawn.
|
|
12761
|
+
budgetOpen: Boolean(rootState.ui?.budgetOpen),
|
|
11087
12762
|
}), shallowEqualObject);
|
|
11088
12763
|
const profileSettingsHydratedRef = React.useRef(false);
|
|
11089
12764
|
const lastProfileSettingsJsonRef = React.useRef(null);
|
|
@@ -11716,6 +13391,11 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
11716
13391
|
current === "split" ? "chat" : current === "chat" ? "sessions" : "split"
|
|
11717
13392
|
));
|
|
11718
13393
|
}
|
|
13394
|
+
// The budget surface REPLACES the workspace, so a pane the
|
|
13395
|
+
// phone switches to would render behind it and the tap would
|
|
13396
|
+
// look dead. Picking a pane puts it away. Already closed, the
|
|
13397
|
+
// reducer returns the same state and nothing re-renders.
|
|
13398
|
+
controller.closeBudget();
|
|
11719
13399
|
setMobilePane(paneId);
|
|
11720
13400
|
if (focus) controller.setFocus(focus);
|
|
11721
13401
|
},
|
|
@@ -11723,9 +13403,11 @@ export function PilotSwarmWebApp({ controller }) {
|
|
|
11723
13403
|
React.createElement("div", { className: "ps-workspace" },
|
|
11724
13404
|
state.adminVisible
|
|
11725
13405
|
? React.createElement(AdminConsolePanel, { controller, mobile })
|
|
11726
|
-
:
|
|
11727
|
-
?
|
|
11728
|
-
: (
|
|
13406
|
+
: state.budgetOpen
|
|
13407
|
+
? React.createElement(ProviderBudgetView, { controller })
|
|
13408
|
+
: (filesFullscreenActive
|
|
13409
|
+
? fullscreenWorkspace
|
|
13410
|
+
: (mobile ? mobileContent : desktopWorkspace)),
|
|
11729
13411
|
mobileCanvasLayer),
|
|
11730
13412
|
mobile && filesFullscreenActive
|
|
11731
13413
|
? React.createElement(MobileArtifactOverlay, { controller })
|