@chrysb/alphaclaw 0.6.2-beta.5 → 0.7.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/public/css/agents.css +37 -13
- package/lib/public/css/cron.css +124 -41
- package/lib/public/css/shell.css +61 -2
- package/lib/public/css/theme.css +2 -1
- package/lib/public/js/app.js +41 -33
- package/lib/public/js/components/agents-tab/agent-detail-panel.js +61 -49
- package/lib/public/js/components/agents-tab/agent-overview/index.js +9 -0
- package/lib/public/js/components/agents-tab/agent-overview/tools-card.js +54 -0
- package/lib/public/js/components/cron-tab/cron-calendar.js +297 -203
- package/lib/public/js/components/cron-tab/cron-helpers.js +48 -0
- package/lib/public/js/components/cron-tab/cron-insights-panel.js +294 -0
- package/lib/public/js/components/cron-tab/cron-job-detail.js +38 -363
- package/lib/public/js/components/cron-tab/cron-job-settings-card.js +233 -0
- package/lib/public/js/components/cron-tab/cron-overview.js +40 -19
- package/lib/public/js/components/cron-tab/cron-prompt-editor.js +173 -0
- package/lib/public/js/components/cron-tab/cron-run-history-panel.js +74 -62
- package/lib/public/js/components/cron-tab/cron-runs-trend-card.js +24 -24
- package/lib/public/js/components/cron-tab/index.js +170 -78
- package/lib/public/js/components/envars.js +187 -46
- package/lib/public/js/components/file-viewer/editor-surface.js +5 -1
- package/lib/public/js/components/file-viewer/use-editor-line-number-sync.js +36 -0
- package/lib/public/js/components/file-viewer/use-file-viewer.js +7 -23
- package/lib/public/js/components/file-viewer/utils.js +1 -5
- package/lib/public/js/components/models-tab/index.js +137 -133
- package/lib/public/js/components/models-tab/provider-auth-card.js +8 -1
- package/lib/public/js/components/models-tab/use-models.js +35 -8
- package/lib/public/js/components/onboarding/welcome-pairing-step.js +88 -59
- package/lib/public/js/components/pane-shell.js +27 -0
- package/lib/public/js/components/routes/envars-route.js +1 -3
- package/lib/public/js/components/routes/models-route.js +1 -3
- package/lib/public/js/lib/app-navigation.js +1 -1
- package/lib/server/cost-utils.js +2 -2
- package/package.json +1 -1
|
@@ -276,6 +276,54 @@ export const getCronJobHealthClassName = (health = "") => {
|
|
|
276
276
|
|
|
277
277
|
export const formatTokenCount = (value) => formatInteger(Number(value || 0));
|
|
278
278
|
export const formatCost = (value) => formatUsd(Number(value || 0));
|
|
279
|
+
export const getCronRunTotalTokens = (entry = {}) => {
|
|
280
|
+
const usage = entry?.usage || {};
|
|
281
|
+
const componentCandidates = [
|
|
282
|
+
usage?.input_tokens,
|
|
283
|
+
usage?.inputTokens,
|
|
284
|
+
usage?.output_tokens,
|
|
285
|
+
usage?.outputTokens,
|
|
286
|
+
usage?.cache_read_tokens,
|
|
287
|
+
usage?.cacheReadTokens,
|
|
288
|
+
usage?.cache_write_tokens,
|
|
289
|
+
usage?.cacheWriteTokens,
|
|
290
|
+
];
|
|
291
|
+
const componentTotal = componentCandidates.reduce((sum, candidate) => {
|
|
292
|
+
const numericValue = Number(candidate);
|
|
293
|
+
if (!Number.isFinite(numericValue) || numericValue < 0) return sum;
|
|
294
|
+
return sum + numericValue;
|
|
295
|
+
}, 0);
|
|
296
|
+
if (componentTotal > 0) return componentTotal;
|
|
297
|
+
const totalCandidates = [
|
|
298
|
+
usage?.total_tokens,
|
|
299
|
+
usage?.totalTokens,
|
|
300
|
+
entry?.total_tokens,
|
|
301
|
+
entry?.totalTokens,
|
|
302
|
+
];
|
|
303
|
+
for (const candidate of totalCandidates) {
|
|
304
|
+
const numericValue = Number(candidate);
|
|
305
|
+
if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;
|
|
306
|
+
}
|
|
307
|
+
return 0;
|
|
308
|
+
};
|
|
309
|
+
export const getCronRunEstimatedCost = (entry = {}) => {
|
|
310
|
+
const usage = entry?.usage || {};
|
|
311
|
+
const candidates = [
|
|
312
|
+
entry?.estimatedCost,
|
|
313
|
+
entry?.estimated_cost,
|
|
314
|
+
usage?.estimatedCost,
|
|
315
|
+
usage?.estimated_cost,
|
|
316
|
+
usage?.totalCost,
|
|
317
|
+
usage?.total_cost,
|
|
318
|
+
usage?.costUsd,
|
|
319
|
+
usage?.cost,
|
|
320
|
+
];
|
|
321
|
+
for (const candidate of candidates) {
|
|
322
|
+
const numericValue = Number(candidate);
|
|
323
|
+
if (Number.isFinite(numericValue) && numericValue >= 0) return numericValue;
|
|
324
|
+
}
|
|
325
|
+
return null;
|
|
326
|
+
};
|
|
279
327
|
const hasHeartbeatOnlySummary = (job = {}) => {
|
|
280
328
|
const state = job?.state || {};
|
|
281
329
|
try {
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
import { h } from "https://esm.sh/preact";
|
|
2
|
+
import { useMemo, useState } from "https://esm.sh/preact/hooks";
|
|
3
|
+
import htm from "https://esm.sh/htm";
|
|
4
|
+
import { SegmentedControl } from "../segmented-control.js";
|
|
5
|
+
import { Badge } from "../badge.js";
|
|
6
|
+
import {
|
|
7
|
+
formatCost,
|
|
8
|
+
formatTokenCount,
|
|
9
|
+
getCronRunEstimatedCost,
|
|
10
|
+
getCronRunTotalTokens,
|
|
11
|
+
} from "./cron-helpers.js";
|
|
12
|
+
|
|
13
|
+
const html = htm.bind(h);
|
|
14
|
+
|
|
15
|
+
const kRange24h = "24h";
|
|
16
|
+
const kRange7d = "7d";
|
|
17
|
+
const kRange30d = "30d";
|
|
18
|
+
const kRangeOptions = [
|
|
19
|
+
{ label: "24h", value: kRange24h },
|
|
20
|
+
{ label: "7d", value: kRange7d },
|
|
21
|
+
{ label: "30d", value: kRange30d },
|
|
22
|
+
];
|
|
23
|
+
const kRangeWindowMsByValue = {
|
|
24
|
+
[kRange24h]: 24 * 60 * 60 * 1000,
|
|
25
|
+
[kRange7d]: 7 * 24 * 60 * 60 * 1000,
|
|
26
|
+
[kRange30d]: 30 * 24 * 60 * 60 * 1000,
|
|
27
|
+
};
|
|
28
|
+
const kTopListLimit = 3;
|
|
29
|
+
const kBadgeToneByInsight = {
|
|
30
|
+
"Token hungry": "warning",
|
|
31
|
+
"Potentially wasteful": "danger",
|
|
32
|
+
"Most expensive": "accent",
|
|
33
|
+
};
|
|
34
|
+
const kWastefulMinRuns = 10;
|
|
35
|
+
const kTokenHungryMinAvgTokensPerRun = 100000;
|
|
36
|
+
const kTokenHungryMinRuns = 3;
|
|
37
|
+
const formatRunCountLabel = (count = 0) => {
|
|
38
|
+
const safeCount = Number(count || 0);
|
|
39
|
+
const countLabel = formatTokenCount(safeCount);
|
|
40
|
+
return `${countLabel} ${safeCount === 1 ? "run" : "runs"}`;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const readDeliveryMode = (job = null) =>
|
|
44
|
+
String(job?.delivery?.mode || job?.deliveryMode || "none")
|
|
45
|
+
.trim()
|
|
46
|
+
.toLowerCase();
|
|
47
|
+
|
|
48
|
+
const sortDescBy = (items = [], selectors = []) =>
|
|
49
|
+
[...items].sort((left, right) => {
|
|
50
|
+
for (const selector of selectors) {
|
|
51
|
+
const leftValue = Number(selector(left) || 0);
|
|
52
|
+
const rightValue = Number(selector(right) || 0);
|
|
53
|
+
if (leftValue === rightValue) continue;
|
|
54
|
+
return rightValue - leftValue;
|
|
55
|
+
}
|
|
56
|
+
return String(left?.jobName || "").localeCompare(
|
|
57
|
+
String(right?.jobName || ""),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const buildInsightMetrics = ({
|
|
62
|
+
jobs = [],
|
|
63
|
+
bulkRunsByJobId = {},
|
|
64
|
+
rangeValue = kRange7d,
|
|
65
|
+
}) => {
|
|
66
|
+
const nowMs = Date.now();
|
|
67
|
+
const windowMs = Number(
|
|
68
|
+
kRangeWindowMsByValue[rangeValue] || kRangeWindowMsByValue[kRange7d],
|
|
69
|
+
);
|
|
70
|
+
const cutoffMs = nowMs - windowMs;
|
|
71
|
+
const metricsByJobId = jobs.reduce((accumulator, job) => {
|
|
72
|
+
const jobId = String(job?.id || "");
|
|
73
|
+
if (!jobId) return accumulator;
|
|
74
|
+
accumulator[jobId] = {
|
|
75
|
+
jobId,
|
|
76
|
+
jobName: String(job?.name || jobId),
|
|
77
|
+
runCount: 0,
|
|
78
|
+
totalTokens: 0,
|
|
79
|
+
totalCost: 0,
|
|
80
|
+
hasCostData: false,
|
|
81
|
+
hasDelivery: readDeliveryMode(job) !== "none",
|
|
82
|
+
};
|
|
83
|
+
return accumulator;
|
|
84
|
+
}, {});
|
|
85
|
+
|
|
86
|
+
Object.entries(bulkRunsByJobId || {}).forEach(([jobIdValue, runResult]) => {
|
|
87
|
+
const jobId = String(jobIdValue || "");
|
|
88
|
+
if (!jobId) return;
|
|
89
|
+
if (!metricsByJobId[jobId]) {
|
|
90
|
+
metricsByJobId[jobId] = {
|
|
91
|
+
jobId,
|
|
92
|
+
jobName: jobId,
|
|
93
|
+
runCount: 0,
|
|
94
|
+
totalTokens: 0,
|
|
95
|
+
totalCost: 0,
|
|
96
|
+
hasCostData: false,
|
|
97
|
+
hasDelivery: false,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const entries = Array.isArray(runResult?.entries) ? runResult.entries : [];
|
|
101
|
+
entries.forEach((entry) => {
|
|
102
|
+
const timestampMs = Number(entry?.ts || 0);
|
|
103
|
+
if (
|
|
104
|
+
!Number.isFinite(timestampMs) ||
|
|
105
|
+
timestampMs < cutoffMs ||
|
|
106
|
+
timestampMs > nowMs
|
|
107
|
+
) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
metricsByJobId[jobId].runCount += 1;
|
|
111
|
+
metricsByJobId[jobId].totalTokens += Number(getCronRunTotalTokens(entry) || 0);
|
|
112
|
+
const estimatedCost = getCronRunEstimatedCost(entry);
|
|
113
|
+
if (estimatedCost != null) {
|
|
114
|
+
metricsByJobId[jobId].hasCostData = true;
|
|
115
|
+
metricsByJobId[jobId].totalCost += Number(estimatedCost || 0);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return Object.values(metricsByJobId).map((entry) => ({
|
|
121
|
+
...entry,
|
|
122
|
+
avgTokensPerRun:
|
|
123
|
+
entry.runCount > 0 ? Math.round(entry.totalTokens / entry.runCount) : 0,
|
|
124
|
+
avgCostPerRun: entry.runCount > 0 ? entry.totalCost / entry.runCount : 0,
|
|
125
|
+
}));
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
const renderInsightRow = ({
|
|
129
|
+
title = "",
|
|
130
|
+
rows = [],
|
|
131
|
+
onSelectJob = () => {},
|
|
132
|
+
}) => {
|
|
133
|
+
const badgeTone = kBadgeToneByInsight[title] || "neutral";
|
|
134
|
+
const topRow = rows[0];
|
|
135
|
+
const overflowRows = rows.slice(1);
|
|
136
|
+
return html`
|
|
137
|
+
<div class="rounded-lg border border-border bg-black/20 px-3 py-2 space-y-1.5">
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
class="w-full text-left hover:brightness-110 transition"
|
|
141
|
+
onClick=${() => onSelectJob(topRow.jobId)}
|
|
142
|
+
>
|
|
143
|
+
<div class="flex items-start justify-between gap-3">
|
|
144
|
+
<div class="min-w-0">
|
|
145
|
+
<div class="text-sm text-gray-100 truncate">${topRow.jobName}</div>
|
|
146
|
+
<div class="text-xs text-gray-400 truncate mt-1">
|
|
147
|
+
${`${topRow.primaryLabel} · ${topRow.secondaryLabel}`}
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
<div class="shrink-0 inline-flex items-center gap-1.5">
|
|
151
|
+
<${Badge} tone=${badgeTone}>${title}</${Badge}>
|
|
152
|
+
</div>
|
|
153
|
+
</div>
|
|
154
|
+
</button>
|
|
155
|
+
${
|
|
156
|
+
overflowRows.length > 0
|
|
157
|
+
? html`
|
|
158
|
+
<details class="group">
|
|
159
|
+
<summary
|
|
160
|
+
class="list-none cursor-pointer text-[11px] text-gray-500 hover:text-gray-300"
|
|
161
|
+
>
|
|
162
|
+
Show more
|
|
163
|
+
</summary>
|
|
164
|
+
<div class="mt-1.5 divide-y divide-border">
|
|
165
|
+
${overflowRows.map(
|
|
166
|
+
(row, index) => html`
|
|
167
|
+
<button
|
|
168
|
+
key=${`${title}:${row.jobId}`}
|
|
169
|
+
type="button"
|
|
170
|
+
class="w-full text-left py-2 hover:brightness-110 transition"
|
|
171
|
+
onClick=${() => onSelectJob(row.jobId)}
|
|
172
|
+
>
|
|
173
|
+
<div class="flex items-start justify-between gap-3">
|
|
174
|
+
<div class="min-w-0">
|
|
175
|
+
<div class="text-sm text-gray-200 truncate">
|
|
176
|
+
${row.jobName}
|
|
177
|
+
</div>
|
|
178
|
+
<div
|
|
179
|
+
class="text-[11px] text-gray-500 truncate mt-1"
|
|
180
|
+
>
|
|
181
|
+
${`${row.primaryLabel} · ${row.secondaryLabel}`}
|
|
182
|
+
</div>
|
|
183
|
+
</div>
|
|
184
|
+
<div
|
|
185
|
+
class="text-[11px] uppercase tracking-wide text-gray-500"
|
|
186
|
+
>
|
|
187
|
+
#${index + 2}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
</button>
|
|
191
|
+
`,
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
</details>
|
|
195
|
+
`
|
|
196
|
+
: null
|
|
197
|
+
}
|
|
198
|
+
</div>
|
|
199
|
+
`;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export const CronInsightsPanel = ({
|
|
203
|
+
jobs = [],
|
|
204
|
+
bulkRunsByJobId = {},
|
|
205
|
+
onSelectJob = () => {},
|
|
206
|
+
}) => {
|
|
207
|
+
const [rangeValue, setRangeValue] = useState(kRange7d);
|
|
208
|
+
const metrics = useMemo(
|
|
209
|
+
() => buildInsightMetrics({ jobs, bulkRunsByJobId, rangeValue }),
|
|
210
|
+
[bulkRunsByJobId, jobs, rangeValue],
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const tokenHungryRows = useMemo(
|
|
214
|
+
() =>
|
|
215
|
+
sortDescBy(
|
|
216
|
+
metrics.filter(
|
|
217
|
+
(entry) =>
|
|
218
|
+
entry.runCount >= kTokenHungryMinRuns &&
|
|
219
|
+
entry.avgTokensPerRun >= kTokenHungryMinAvgTokensPerRun,
|
|
220
|
+
),
|
|
221
|
+
[(entry) => entry.avgTokensPerRun, (entry) => entry.totalTokens],
|
|
222
|
+
)
|
|
223
|
+
.slice(0, kTopListLimit)
|
|
224
|
+
.map((entry) => ({
|
|
225
|
+
...entry,
|
|
226
|
+
primaryLabel: `${formatTokenCount(entry.avgTokensPerRun)} avg tokens/run`,
|
|
227
|
+
secondaryLabel: `${formatTokenCount(entry.totalTokens)} total tokens · ${formatRunCountLabel(entry.runCount)}`,
|
|
228
|
+
})),
|
|
229
|
+
[metrics],
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const wastefulRows = useMemo(
|
|
233
|
+
() =>
|
|
234
|
+
sortDescBy(
|
|
235
|
+
metrics.filter(
|
|
236
|
+
(entry) =>
|
|
237
|
+
entry.runCount >= kWastefulMinRuns &&
|
|
238
|
+
entry.hasDelivery === false &&
|
|
239
|
+
(entry.totalTokens > 0 || entry.totalCost > 0),
|
|
240
|
+
),
|
|
241
|
+
[(entry) => entry.totalTokens, (entry) => entry.runCount],
|
|
242
|
+
)
|
|
243
|
+
.slice(0, kTopListLimit)
|
|
244
|
+
.map((entry) => ({
|
|
245
|
+
...entry,
|
|
246
|
+
primaryLabel: `${formatRunCountLabel(entry.runCount)} with no delivery`,
|
|
247
|
+
secondaryLabel: `${formatTokenCount(entry.totalTokens)} total tokens${entry.hasCostData ? ` · ${formatCost(entry.totalCost)}` : ""}`,
|
|
248
|
+
})),
|
|
249
|
+
[metrics],
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const expensiveRows = useMemo(
|
|
253
|
+
() =>
|
|
254
|
+
sortDescBy(
|
|
255
|
+
metrics.filter((entry) => entry.runCount > 0 && entry.totalCost > 0),
|
|
256
|
+
[(entry) => entry.totalCost, (entry) => entry.avgCostPerRun],
|
|
257
|
+
)
|
|
258
|
+
.slice(0, kTopListLimit)
|
|
259
|
+
.map((entry) => ({
|
|
260
|
+
...entry,
|
|
261
|
+
primaryLabel: `${formatCost(entry.totalCost)} total estimated cost`,
|
|
262
|
+
secondaryLabel: `${formatCost(entry.avgCostPerRun)} avg/run · ${formatRunCountLabel(entry.runCount)}`,
|
|
263
|
+
})),
|
|
264
|
+
[metrics],
|
|
265
|
+
);
|
|
266
|
+
const insightRows = [
|
|
267
|
+
{ title: "Token hungry", rows: tokenHungryRows },
|
|
268
|
+
{ title: "Potentially wasteful", rows: wastefulRows },
|
|
269
|
+
{ title: "Most expensive", rows: expensiveRows },
|
|
270
|
+
].filter((group) => Array.isArray(group.rows) && group.rows.length > 0);
|
|
271
|
+
if (insightRows.length === 0) return null;
|
|
272
|
+
|
|
273
|
+
return html`
|
|
274
|
+
<section class="bg-surface border border-border rounded-xl p-4 space-y-3">
|
|
275
|
+
<div class="flex items-center justify-between gap-2">
|
|
276
|
+
<h3 class="card-label cron-calendar-title">Insights</h3>
|
|
277
|
+
<${SegmentedControl}
|
|
278
|
+
options=${kRangeOptions}
|
|
279
|
+
value=${rangeValue}
|
|
280
|
+
onChange=${setRangeValue}
|
|
281
|
+
/>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
<div class="grid grid-cols-1 gap-2">
|
|
285
|
+
${insightRows.map((group) =>
|
|
286
|
+
renderInsightRow({
|
|
287
|
+
title: group.title,
|
|
288
|
+
rows: group.rows,
|
|
289
|
+
onSelectJob,
|
|
290
|
+
}))}
|
|
291
|
+
</div>
|
|
292
|
+
</section>
|
|
293
|
+
`;
|
|
294
|
+
};
|