@chrysb/alphaclaw 0.7.1 → 0.7.2-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/public/css/explorer.css +12 -0
- package/lib/public/js/components/cron-tab/cron-overview.js +2 -1
- package/lib/public/js/components/cron-tab/cron-run-history-panel.js +96 -61
- package/lib/public/js/components/sidebar.js +14 -2
- package/lib/public/js/components/update-modal.js +173 -0
- package/lib/public/js/components/usage-tab/constants.js +8 -0
- package/lib/public/js/components/usage-tab/formatters.js +13 -0
- package/lib/public/js/components/usage-tab/index.js +2 -0
- package/lib/public/js/components/usage-tab/overview-section.js +22 -4
- package/lib/public/js/components/usage-tab/use-usage-tab.js +51 -14
- package/lib/public/js/lib/api.js +36 -0
- package/lib/server/constants.js +3 -0
- package/lib/server/db/usage/summary.js +101 -1
- package/lib/server/init/register-server-routes.js +1 -0
- package/lib/server/routes/system.js +98 -0
- package/package.json +1 -1
|
@@ -1158,6 +1158,18 @@
|
|
|
1158
1158
|
background: rgba(255, 255, 255, 0.04);
|
|
1159
1159
|
}
|
|
1160
1160
|
|
|
1161
|
+
.release-notes-preview {
|
|
1162
|
+
padding: 8px 12px 24px 14px;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
.release-notes-preview > :first-child {
|
|
1166
|
+
margin-top: 0;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
.release-notes-preview > :last-child {
|
|
1170
|
+
margin-bottom: 0;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1161
1173
|
.file-viewer-editor-highlight-line-content .hl-comment {
|
|
1162
1174
|
color: var(--comment);
|
|
1163
1175
|
font-style: italic;
|
|
@@ -16,7 +16,7 @@ import { ErrorWarningLineIcon } from "../icons.js";
|
|
|
16
16
|
const html = htm.bind(h);
|
|
17
17
|
const kRecentRunFetchLimit = 100;
|
|
18
18
|
const kRecentRunRowsLimit = 20;
|
|
19
|
-
const kRecentRunCollapseThreshold =
|
|
19
|
+
const kRecentRunCollapseThreshold = 2;
|
|
20
20
|
const kTrendRange24h = "24h";
|
|
21
21
|
const kTrendRange7d = "7d";
|
|
22
22
|
const kTrendRange30d = "30d";
|
|
@@ -107,6 +107,7 @@ const buildCollapsedRunRows = (recentRuns = []) => {
|
|
|
107
107
|
newestTs: Number(streak[0]?.ts || 0),
|
|
108
108
|
oldestTs: Number(streak[streak.length - 1]?.ts || 0),
|
|
109
109
|
statusCounts,
|
|
110
|
+
entries: streak,
|
|
110
111
|
});
|
|
111
112
|
index = streakEnd;
|
|
112
113
|
continue;
|
|
@@ -41,11 +41,69 @@ const formatRowTimestamp = (timestampMs, variant = "overview") =>
|
|
|
41
41
|
variant === "detail"
|
|
42
42
|
? formatDetailTimestamp(timestampMs)
|
|
43
43
|
: formatOverviewTimestamp(timestampMs);
|
|
44
|
+
const renderEntrySummaryRow = ({ runEntry = {}, variant = "overview" }) => {
|
|
45
|
+
const runStatus = String(runEntry?.status || "unknown");
|
|
46
|
+
const runTokens = getCronRunTotalTokens(runEntry);
|
|
47
|
+
const runEstimatedCost = getCronRunEstimatedCost(runEntry);
|
|
48
|
+
const runTitle = String(runEntry?.jobName || "").trim();
|
|
49
|
+
const hasRunTitle = runTitle.length > 0;
|
|
50
|
+
const isDetail = variant === "detail";
|
|
51
|
+
return html`
|
|
52
|
+
<div class="ac-history-summary-row">
|
|
53
|
+
<span class="inline-flex items-center gap-2 min-w-0">
|
|
54
|
+
${isDetail
|
|
55
|
+
? html`
|
|
56
|
+
<span class="truncate text-xs text-gray-300">
|
|
57
|
+
${formatRowTimestamp(runEntry.ts, variant)}
|
|
58
|
+
</span>
|
|
59
|
+
`
|
|
60
|
+
: hasRunTitle
|
|
61
|
+
? html`
|
|
62
|
+
<span class="inline-flex items-center gap-2 min-w-0">
|
|
63
|
+
<span class="truncate text-xs text-gray-300">${runTitle}</span>
|
|
64
|
+
<span class="text-xs text-gray-500 shrink-0">
|
|
65
|
+
${formatRowTimestamp(runEntry.ts, variant)}
|
|
66
|
+
</span>
|
|
67
|
+
</span>
|
|
68
|
+
`
|
|
69
|
+
: html`
|
|
70
|
+
<span class="truncate text-xs text-gray-300">
|
|
71
|
+
${runEntry.jobId} - ${formatRowTimestamp(runEntry.ts, variant)}
|
|
72
|
+
</span>
|
|
73
|
+
`}
|
|
74
|
+
</span>
|
|
75
|
+
<span class="inline-flex items-center gap-3 shrink-0 text-xs">
|
|
76
|
+
<span class=${runStatusClassName(runStatus)}>${runStatus}</span>
|
|
77
|
+
<span class="text-gray-400">${formatDurationCompactMs(runEntry.durationMs)}</span>
|
|
78
|
+
<span class="text-gray-400">${formatTokenCount(runTokens)} tk</span>
|
|
79
|
+
${isDetail
|
|
80
|
+
? html`<span class="text-gray-500">${runDeliveryLabel(runEntry)}</span>`
|
|
81
|
+
: html`
|
|
82
|
+
<span class="text-gray-500">
|
|
83
|
+
${runEstimatedCost == null ? "—" : `~${formatCost(runEstimatedCost)}`}
|
|
84
|
+
</span>
|
|
85
|
+
`}
|
|
86
|
+
</span>
|
|
87
|
+
</div>
|
|
88
|
+
`;
|
|
89
|
+
};
|
|
90
|
+
const getCollapsedGroupAggregates = (entries = []) =>
|
|
91
|
+
entries.reduce(
|
|
92
|
+
(accumulator, runEntry) => {
|
|
93
|
+
accumulator.totalTokens += getCronRunTotalTokens(runEntry);
|
|
94
|
+
const estimatedCost = getCronRunEstimatedCost(runEntry);
|
|
95
|
+
if (estimatedCost != null) {
|
|
96
|
+
accumulator.totalCost += estimatedCost;
|
|
97
|
+
accumulator.hasAnyCost = true;
|
|
98
|
+
}
|
|
99
|
+
return accumulator;
|
|
100
|
+
},
|
|
101
|
+
{ totalTokens: 0, totalCost: 0, hasAnyCost: false },
|
|
102
|
+
);
|
|
44
103
|
const renderCollapsedGroupRow = ({ row, rowIndex, onSelectJob = () => {} }) => {
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const timeRangeLabel = `[${formatOverviewTimestamp(row.oldestTs)} - ${formatOverviewTimestamp(row.newestTs)}]`;
|
|
104
|
+
const entries = Array.isArray(row?.entries) ? row.entries : [];
|
|
105
|
+
const { totalTokens, totalCost, hasAnyCost } = getCollapsedGroupAggregates(entries);
|
|
106
|
+
const timeRangeLabel = `${formatOverviewTimestamp(row.oldestTs)} - ${formatOverviewTimestamp(row.newestTs)}`;
|
|
49
107
|
return html`
|
|
50
108
|
<details
|
|
51
109
|
key=${`collapsed:${rowIndex}:${row.jobId}`}
|
|
@@ -55,19 +113,40 @@ const renderCollapsedGroupRow = ({ row, rowIndex, onSelectJob = () => {} }) => {
|
|
|
55
113
|
<div class="ac-history-summary-row">
|
|
56
114
|
<span class="inline-flex items-center gap-2 min-w-0">
|
|
57
115
|
<span class="ac-history-toggle shrink-0" aria-hidden="true">▸</span>
|
|
58
|
-
<span class="
|
|
59
|
-
|
|
60
|
-
|
|
116
|
+
<span class="inline-flex items-center gap-2 min-w-0">
|
|
117
|
+
<span class="truncate text-xs text-gray-300">
|
|
118
|
+
${row.jobName} - ${formatTokenCount(row.count)} runs
|
|
119
|
+
</span>
|
|
120
|
+
<span class="text-xs text-gray-500 shrink-0">${timeRangeLabel}</span>
|
|
121
|
+
</span>
|
|
122
|
+
</span>
|
|
123
|
+
<span class="inline-flex items-center gap-3 shrink-0 text-xs">
|
|
124
|
+
<span class="text-gray-400">${formatTokenCount(totalTokens)} tk</span>
|
|
125
|
+
<span class="text-gray-500">
|
|
126
|
+
${hasAnyCost ? `~${formatCost(totalCost)}` : "—"}
|
|
61
127
|
</span>
|
|
62
128
|
</span>
|
|
63
129
|
</div>
|
|
64
130
|
</summary>
|
|
65
131
|
<div class="ac-history-body space-y-2 text-xs">
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
132
|
+
${entries.length > 0
|
|
133
|
+
? html`
|
|
134
|
+
<div class="ac-history-list">
|
|
135
|
+
${entries.map((runEntry, entryIndex) =>
|
|
136
|
+
renderEntryRow({
|
|
137
|
+
row: {
|
|
138
|
+
type: "entry",
|
|
139
|
+
entry: runEntry,
|
|
140
|
+
},
|
|
141
|
+
rowIndex: `${rowIndex}:${entryIndex}`,
|
|
142
|
+
variant: "overview",
|
|
143
|
+
onSelectJob,
|
|
144
|
+
showOpenJobButton: false,
|
|
145
|
+
}),
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
`
|
|
149
|
+
: null}
|
|
71
150
|
${row?.jobId
|
|
72
151
|
? html`
|
|
73
152
|
<div>
|
|
@@ -94,7 +173,6 @@ const renderEntryRow = ({
|
|
|
94
173
|
}) => {
|
|
95
174
|
const runEntry = row?.entry || row || {};
|
|
96
175
|
const runUsage = runEntry?.usage || {};
|
|
97
|
-
const runStatus = String(runEntry?.status || "unknown");
|
|
98
176
|
const runInputTokens = Number(
|
|
99
177
|
runUsage?.input_tokens ?? runUsage?.inputTokens ?? 0,
|
|
100
178
|
);
|
|
@@ -103,60 +181,17 @@ const renderEntryRow = ({
|
|
|
103
181
|
);
|
|
104
182
|
const runTokens = getCronRunTotalTokens(runEntry);
|
|
105
183
|
const runEstimatedCost = getCronRunEstimatedCost(runEntry);
|
|
106
|
-
const runTitle = String(runEntry?.jobName || "").trim();
|
|
107
|
-
const hasRunTitle = runTitle.length > 0;
|
|
108
|
-
const isDetail = variant === "detail";
|
|
109
184
|
return html`
|
|
110
185
|
<details
|
|
111
186
|
key=${`entry:${rowIndex}:${runEntry.ts}:${runEntry.jobId || ""}`}
|
|
112
187
|
class="ac-history-item"
|
|
113
188
|
>
|
|
114
189
|
<summary class="ac-history-summary">
|
|
115
|
-
<div class="
|
|
116
|
-
<span class="
|
|
117
|
-
|
|
118
|
-
${
|
|
119
|
-
|
|
120
|
-
<span class="truncate text-xs text-gray-300">
|
|
121
|
-
${formatRowTimestamp(runEntry.ts, variant)}
|
|
122
|
-
</span>
|
|
123
|
-
`
|
|
124
|
-
: hasRunTitle
|
|
125
|
-
? html`
|
|
126
|
-
<span class="inline-flex items-center gap-2 min-w-0">
|
|
127
|
-
<span class="truncate text-xs text-gray-300"
|
|
128
|
-
>${runTitle}</span
|
|
129
|
-
>
|
|
130
|
-
<span class="text-xs text-gray-500 shrink-0">
|
|
131
|
-
${formatRowTimestamp(runEntry.ts, variant)}
|
|
132
|
-
</span>
|
|
133
|
-
</span>
|
|
134
|
-
`
|
|
135
|
-
: html`
|
|
136
|
-
<span class="truncate text-xs text-gray-300">
|
|
137
|
-
${runEntry.jobId} -
|
|
138
|
-
${formatRowTimestamp(runEntry.ts, variant)}
|
|
139
|
-
</span>
|
|
140
|
-
`}
|
|
141
|
-
</span>
|
|
142
|
-
<span class="inline-flex items-center gap-3 shrink-0 text-xs">
|
|
143
|
-
<span class=${runStatusClassName(runStatus)}>${runStatus}</span>
|
|
144
|
-
<span class="text-gray-400"
|
|
145
|
-
>${formatDurationCompactMs(runEntry.durationMs)}</span
|
|
146
|
-
>
|
|
147
|
-
<span class="text-gray-400">${formatTokenCount(runTokens)} tk</span>
|
|
148
|
-
${isDetail
|
|
149
|
-
? html`<span class="text-gray-500"
|
|
150
|
-
>${runDeliveryLabel(runEntry)}</span
|
|
151
|
-
>`
|
|
152
|
-
: html`
|
|
153
|
-
<span class="text-gray-500"
|
|
154
|
-
>${runEstimatedCost == null
|
|
155
|
-
? "—"
|
|
156
|
-
: `~${formatCost(runEstimatedCost)}`}</span
|
|
157
|
-
>
|
|
158
|
-
`}
|
|
159
|
-
</span>
|
|
190
|
+
<div class="inline-flex items-center gap-2 min-w-0 w-full">
|
|
191
|
+
<span class="ac-history-toggle shrink-0" aria-hidden="true">▸</span>
|
|
192
|
+
<div class="min-w-0 flex-1">
|
|
193
|
+
${renderEntrySummaryRow({ runEntry, variant })}
|
|
194
|
+
</div>
|
|
160
195
|
</div>
|
|
161
196
|
</summary>
|
|
162
197
|
<div class="ac-history-body space-y-2 text-xs">
|
|
@@ -6,6 +6,7 @@ import { FileTree } from "./file-tree.js";
|
|
|
6
6
|
import { OverflowMenu, OverflowMenuItem } from "./overflow-menu.js";
|
|
7
7
|
import { UpdateActionButton } from "./update-action-button.js";
|
|
8
8
|
import { SidebarGitPanel } from "./sidebar-git-panel.js";
|
|
9
|
+
import { UpdateModal } from "./update-modal.js";
|
|
9
10
|
import { readUiSettings, writeUiSettings } from "../lib/ui-settings.js";
|
|
10
11
|
|
|
11
12
|
const html = htm.bind(h);
|
|
@@ -62,6 +63,7 @@ export const AppSidebar = ({
|
|
|
62
63
|
readStoredBrowseBottomPanelHeight,
|
|
63
64
|
);
|
|
64
65
|
const [isResizingBrowsePanels, setIsResizingBrowsePanels] = useState(false);
|
|
66
|
+
const [updateModalOpen, setUpdateModalOpen] = useState(false);
|
|
65
67
|
|
|
66
68
|
useEffect(() => {
|
|
67
69
|
const settings = readUiSettings();
|
|
@@ -237,10 +239,10 @@ export const AppSidebar = ({
|
|
|
237
239
|
`,
|
|
238
240
|
)}
|
|
239
241
|
<div class="sidebar-footer">
|
|
240
|
-
${acHasUpdate && acLatest
|
|
242
|
+
${acHasUpdate && acLatest
|
|
241
243
|
? html`
|
|
242
244
|
<${UpdateActionButton}
|
|
243
|
-
onClick=${
|
|
245
|
+
onClick=${() => setUpdateModalOpen(true)}
|
|
244
246
|
loading=${acUpdating}
|
|
245
247
|
warning=${true}
|
|
246
248
|
idleLabel=${`Update to v${acLatest}`}
|
|
@@ -292,6 +294,16 @@ export const AppSidebar = ({
|
|
|
292
294
|
</div>
|
|
293
295
|
</div>
|
|
294
296
|
</div>
|
|
297
|
+
<${UpdateModal}
|
|
298
|
+
visible=${updateModalOpen}
|
|
299
|
+
onClose=${() => {
|
|
300
|
+
if (acUpdating) return;
|
|
301
|
+
setUpdateModalOpen(false);
|
|
302
|
+
}}
|
|
303
|
+
version=${acLatest}
|
|
304
|
+
onUpdate=${onAcUpdate}
|
|
305
|
+
updating=${acUpdating}
|
|
306
|
+
/>
|
|
295
307
|
</div>
|
|
296
308
|
`;
|
|
297
309
|
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { h } from "https://esm.sh/preact";
|
|
2
|
+
import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
|
|
3
|
+
import htm from "https://esm.sh/htm";
|
|
4
|
+
import { marked } from "https://esm.sh/marked";
|
|
5
|
+
import { fetchAlphaclawReleaseNotes } from "../lib/api.js";
|
|
6
|
+
import { ModalShell } from "./modal-shell.js";
|
|
7
|
+
import { ActionButton } from "./action-button.js";
|
|
8
|
+
import { LoadingSpinner } from "./loading-spinner.js";
|
|
9
|
+
import { CloseIcon } from "./icons.js";
|
|
10
|
+
|
|
11
|
+
const html = htm.bind(h);
|
|
12
|
+
|
|
13
|
+
const getReleaseTagFromVersion = (version) => {
|
|
14
|
+
const rawVersion = String(version || "").trim();
|
|
15
|
+
if (!rawVersion) return "";
|
|
16
|
+
return rawVersion.startsWith("v") ? rawVersion : `v${rawVersion}`;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const formatPublishedAt = (value) => {
|
|
20
|
+
const dateMs = Date.parse(String(value || ""));
|
|
21
|
+
if (!Number.isFinite(dateMs)) return "";
|
|
22
|
+
try {
|
|
23
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
24
|
+
dateStyle: "medium",
|
|
25
|
+
timeStyle: "short",
|
|
26
|
+
}).format(new Date(dateMs));
|
|
27
|
+
} catch {
|
|
28
|
+
return "";
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const getReleaseUrl = (tag) =>
|
|
33
|
+
tag
|
|
34
|
+
? `https://github.com/chrysb/alphaclaw/releases/tag/${encodeURIComponent(tag)}`
|
|
35
|
+
: "https://github.com/chrysb/alphaclaw/releases";
|
|
36
|
+
|
|
37
|
+
export const UpdateModal = ({
|
|
38
|
+
visible = false,
|
|
39
|
+
onClose = () => {},
|
|
40
|
+
version = "",
|
|
41
|
+
onUpdate = () => {},
|
|
42
|
+
updating = false,
|
|
43
|
+
}) => {
|
|
44
|
+
const requestedTag = useMemo(() => getReleaseTagFromVersion(version), [version]);
|
|
45
|
+
const [loadingNotes, setLoadingNotes] = useState(false);
|
|
46
|
+
const [notesError, setNotesError] = useState("");
|
|
47
|
+
const [notesData, setNotesData] = useState(null);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
if (!visible) return;
|
|
51
|
+
let isActive = true;
|
|
52
|
+
const loadNotes = async () => {
|
|
53
|
+
setLoadingNotes(true);
|
|
54
|
+
setNotesError("");
|
|
55
|
+
try {
|
|
56
|
+
const data = await fetchAlphaclawReleaseNotes(requestedTag);
|
|
57
|
+
if (!isActive) return;
|
|
58
|
+
if (!data?.ok) {
|
|
59
|
+
setNotesError(data?.error || "Could not load release notes");
|
|
60
|
+
setNotesData(null);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
setNotesData(data);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (!isActive) return;
|
|
66
|
+
setNotesError(err?.message || "Could not load release notes");
|
|
67
|
+
setNotesData(null);
|
|
68
|
+
} finally {
|
|
69
|
+
if (!isActive) return;
|
|
70
|
+
setLoadingNotes(false);
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
loadNotes();
|
|
74
|
+
return () => {
|
|
75
|
+
isActive = false;
|
|
76
|
+
};
|
|
77
|
+
}, [visible, requestedTag]);
|
|
78
|
+
|
|
79
|
+
const effectiveTag = String(notesData?.tag || requestedTag || "").trim();
|
|
80
|
+
const effectiveReleaseUrl =
|
|
81
|
+
String(notesData?.htmlUrl || "").trim() || getReleaseUrl(effectiveTag);
|
|
82
|
+
const updateLabel = effectiveTag ? `Update to ${effectiveTag}` : "Update now";
|
|
83
|
+
const publishedAtLabel = formatPublishedAt(notesData?.publishedAt);
|
|
84
|
+
const releaseBody = String(notesData?.body || "").trim();
|
|
85
|
+
const releasePreviewHtml = useMemo(
|
|
86
|
+
() =>
|
|
87
|
+
marked.parse(releaseBody, {
|
|
88
|
+
gfm: true,
|
|
89
|
+
breaks: true,
|
|
90
|
+
}),
|
|
91
|
+
[releaseBody],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return html`
|
|
95
|
+
<${ModalShell}
|
|
96
|
+
visible=${visible}
|
|
97
|
+
onClose=${onClose}
|
|
98
|
+
panelClassName="relative bg-modal border border-border rounded-xl p-5 w-full max-w-3xl max-h-[92vh] overflow-hidden flex flex-col gap-4"
|
|
99
|
+
>
|
|
100
|
+
<button
|
|
101
|
+
type="button"
|
|
102
|
+
onclick=${onClose}
|
|
103
|
+
class="absolute top-5 right-5 h-8 w-8 inline-flex items-center justify-center rounded-lg ac-btn-secondary"
|
|
104
|
+
aria-label="Close modal"
|
|
105
|
+
>
|
|
106
|
+
<${CloseIcon} className="w-3.5 h-3.5 text-gray-300" />
|
|
107
|
+
</button>
|
|
108
|
+
<div class="space-y-1 pr-10">
|
|
109
|
+
<h3 class="text-sm font-semibold">AlphaClaw release notes</h3>
|
|
110
|
+
${publishedAtLabel
|
|
111
|
+
? html`<p class="text-xs text-gray-500">Published ${publishedAtLabel}</p>`
|
|
112
|
+
: null}
|
|
113
|
+
</div>
|
|
114
|
+
<div class="ac-surface-inset border border-border rounded-lg p-2 overflow-auto min-h-[220px] max-h-[66vh]">
|
|
115
|
+
${loadingNotes
|
|
116
|
+
? html`
|
|
117
|
+
<div class="min-h-[200px] flex items-center justify-center text-gray-400">
|
|
118
|
+
<span class="inline-flex items-center gap-2 text-sm">
|
|
119
|
+
<${LoadingSpinner} className="h-4 w-4" />
|
|
120
|
+
Loading release notes...
|
|
121
|
+
</span>
|
|
122
|
+
</div>
|
|
123
|
+
`
|
|
124
|
+
: notesError
|
|
125
|
+
? html`
|
|
126
|
+
<div class="space-y-2">
|
|
127
|
+
<p class="text-sm text-red-300">${notesError}</p>
|
|
128
|
+
<a
|
|
129
|
+
class="ac-tip-link text-xs"
|
|
130
|
+
href=${effectiveReleaseUrl}
|
|
131
|
+
target="_blank"
|
|
132
|
+
rel="noreferrer"
|
|
133
|
+
>View release on GitHub</a
|
|
134
|
+
>
|
|
135
|
+
</div>
|
|
136
|
+
`
|
|
137
|
+
: releaseBody
|
|
138
|
+
? html`<div
|
|
139
|
+
class="file-viewer-preview release-notes-preview"
|
|
140
|
+
dangerouslySetInnerHTML=${{ __html: releasePreviewHtml }}
|
|
141
|
+
></div>`
|
|
142
|
+
: html`
|
|
143
|
+
<div class="space-y-2">
|
|
144
|
+
<p class="text-sm text-gray-300">No release notes were published for this tag.</p>
|
|
145
|
+
<a
|
|
146
|
+
class="ac-tip-link text-xs"
|
|
147
|
+
href=${effectiveReleaseUrl}
|
|
148
|
+
target="_blank"
|
|
149
|
+
rel="noreferrer"
|
|
150
|
+
>Open release on GitHub</a
|
|
151
|
+
>
|
|
152
|
+
</div>
|
|
153
|
+
`}
|
|
154
|
+
</div>
|
|
155
|
+
<div class="flex items-center justify-end gap-2 pt-1">
|
|
156
|
+
<${ActionButton}
|
|
157
|
+
onClick=${onClose}
|
|
158
|
+
tone="ghost"
|
|
159
|
+
idleLabel="Later"
|
|
160
|
+
disabled=${updating}
|
|
161
|
+
/>
|
|
162
|
+
<${ActionButton}
|
|
163
|
+
onClick=${onUpdate}
|
|
164
|
+
tone="warning"
|
|
165
|
+
idleLabel=${updateLabel}
|
|
166
|
+
loadingLabel="Updating..."
|
|
167
|
+
loading=${updating}
|
|
168
|
+
disabled=${loadingNotes}
|
|
169
|
+
/>
|
|
170
|
+
</div>
|
|
171
|
+
</${ModalShell}>
|
|
172
|
+
`;
|
|
173
|
+
};
|
|
@@ -26,6 +26,14 @@ export const kRangeOptions = [
|
|
|
26
26
|
|
|
27
27
|
export const kDefaultUsageDays = 30;
|
|
28
28
|
export const kDefaultUsageMetric = "tokens";
|
|
29
|
+
export const kDefaultUsageBreakdown = "model";
|
|
29
30
|
export const kUsageDaysUiSettingKey = "usageDays";
|
|
30
31
|
export const kUsageMetricUiSettingKey = "usageMetric";
|
|
32
|
+
export const kUsageBreakdownUiSettingKey = "usageBreakdown";
|
|
31
33
|
export const kUsageSourceOrder = ["chat", "hooks", "cron"];
|
|
34
|
+
|
|
35
|
+
export const kUsageBreakdownOptions = [
|
|
36
|
+
{ label: "Model breakdown", value: "model" },
|
|
37
|
+
{ label: "Type breakdown", value: "source" },
|
|
38
|
+
{ label: "Agent breakdown", value: "agent" },
|
|
39
|
+
];
|
|
@@ -22,3 +22,16 @@ export const renderSourceLabel = (source) => {
|
|
|
22
22
|
if (source === "cron") return "Cron";
|
|
23
23
|
return "Chat";
|
|
24
24
|
};
|
|
25
|
+
|
|
26
|
+
export const renderBreakdownLabel = (value, breakdown) => {
|
|
27
|
+
const normalizedBreakdown = String(breakdown || "model");
|
|
28
|
+
const raw = String(value || "").trim();
|
|
29
|
+
if (!raw) return "Unknown";
|
|
30
|
+
if (normalizedBreakdown === "source") {
|
|
31
|
+
return renderSourceLabel(raw);
|
|
32
|
+
}
|
|
33
|
+
if (normalizedBreakdown === "agent") {
|
|
34
|
+
return raw === "unknown" ? "Unknown agent" : raw;
|
|
35
|
+
}
|
|
36
|
+
return raw;
|
|
37
|
+
};
|
|
@@ -53,10 +53,12 @@ export const UsageTab = ({ sessionId = "" }) => {
|
|
|
53
53
|
summary=${state.summary}
|
|
54
54
|
periodSummary=${state.periodSummary}
|
|
55
55
|
metric=${state.metric}
|
|
56
|
+
breakdown=${state.breakdown}
|
|
56
57
|
days=${state.days}
|
|
57
58
|
overviewCanvasRef=${state.overviewCanvasRef}
|
|
58
59
|
onDaysChange=${actions.setDays}
|
|
59
60
|
onMetricChange=${actions.setMetric}
|
|
61
|
+
onBreakdownChange=${actions.setBreakdown}
|
|
60
62
|
/>
|
|
61
63
|
`}
|
|
62
64
|
<${SessionsSection}
|
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
formatUsd,
|
|
8
8
|
} from "../../lib/format.js";
|
|
9
9
|
import { SegmentedControl } from "../segmented-control.js";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
kRangeOptions,
|
|
12
|
+
kUsageBreakdownOptions,
|
|
13
|
+
kUsageSourceOrder,
|
|
14
|
+
} from "./constants.js";
|
|
11
15
|
import { renderSourceLabel } from "./formatters.js";
|
|
12
16
|
|
|
13
17
|
const html = htm.bind(h);
|
|
@@ -215,10 +219,12 @@ export const OverviewSection = ({
|
|
|
215
219
|
summary = null,
|
|
216
220
|
periodSummary,
|
|
217
221
|
metric = "tokens",
|
|
222
|
+
breakdown = "model",
|
|
218
223
|
days = 30,
|
|
219
224
|
overviewCanvasRef,
|
|
220
225
|
onDaysChange = () => {},
|
|
221
226
|
onMetricChange = () => {},
|
|
227
|
+
onBreakdownChange = () => {},
|
|
222
228
|
}) => {
|
|
223
229
|
const overviewMetrics = getOverviewMetrics(summary);
|
|
224
230
|
|
|
@@ -264,9 +270,21 @@ export const OverviewSection = ({
|
|
|
264
270
|
<div
|
|
265
271
|
class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3"
|
|
266
272
|
>
|
|
267
|
-
<
|
|
268
|
-
|
|
269
|
-
|
|
273
|
+
<label class="inline-flex items-center gap-2">
|
|
274
|
+
<select
|
|
275
|
+
class="bg-black/30 border border-border rounded-lg text-xs px-2.5 py-1.5 text-gray-200 focus:border-gray-500"
|
|
276
|
+
value=${breakdown}
|
|
277
|
+
onChange=${(event) =>
|
|
278
|
+
onBreakdownChange(String(event.currentTarget?.value || "model"))}
|
|
279
|
+
aria-label="Usage chart breakdown"
|
|
280
|
+
>
|
|
281
|
+
${kUsageBreakdownOptions.map(
|
|
282
|
+
(option) => html`
|
|
283
|
+
<option value=${option.value}>${option.label}</option>
|
|
284
|
+
`,
|
|
285
|
+
)}
|
|
286
|
+
</select>
|
|
287
|
+
</label>
|
|
270
288
|
<div class="flex items-center gap-2">
|
|
271
289
|
<${SegmentedControl}
|
|
272
290
|
options=${kRangeOptions.map((option) => ({
|
|
@@ -13,12 +13,14 @@ import {
|
|
|
13
13
|
import { formatInteger, formatUsd } from "../../lib/format.js";
|
|
14
14
|
import { readUiSettings, writeUiSettings } from "../../lib/ui-settings.js";
|
|
15
15
|
import {
|
|
16
|
+
kDefaultUsageBreakdown,
|
|
16
17
|
kDefaultUsageDays,
|
|
17
18
|
kDefaultUsageMetric,
|
|
19
|
+
kUsageBreakdownUiSettingKey,
|
|
18
20
|
kUsageDaysUiSettingKey,
|
|
19
21
|
kUsageMetricUiSettingKey,
|
|
20
22
|
} from "./constants.js";
|
|
21
|
-
import { toChartColor, toLocalDayKey } from "./formatters.js";
|
|
23
|
+
import { renderBreakdownLabel, toChartColor, toLocalDayKey } from "./formatters.js";
|
|
22
24
|
|
|
23
25
|
export const useUsageTab = ({ sessionId = "" }) => {
|
|
24
26
|
const [days, setDays] = useState(() => {
|
|
@@ -35,6 +37,13 @@ export const useUsageTab = ({ sessionId = "" }) => {
|
|
|
35
37
|
? "cost"
|
|
36
38
|
: kDefaultUsageMetric;
|
|
37
39
|
});
|
|
40
|
+
const [breakdown, setBreakdown] = useState(() => {
|
|
41
|
+
const settings = readUiSettings();
|
|
42
|
+
const configured = String(settings[kUsageBreakdownUiSettingKey] || "").trim();
|
|
43
|
+
return configured === "source" || configured === "agent"
|
|
44
|
+
? configured
|
|
45
|
+
: kDefaultUsageBreakdown;
|
|
46
|
+
});
|
|
38
47
|
const [summary, setSummary] = useState(null);
|
|
39
48
|
const [sessions, setSessions] = useState([]);
|
|
40
49
|
const [sessionDetailById, setSessionDetailById] = useState({});
|
|
@@ -104,8 +113,9 @@ export const useUsageTab = ({ sessionId = "" }) => {
|
|
|
104
113
|
const settings = readUiSettings();
|
|
105
114
|
settings[kUsageDaysUiSettingKey] = days;
|
|
106
115
|
settings[kUsageMetricUiSettingKey] = metric;
|
|
116
|
+
settings[kUsageBreakdownUiSettingKey] = breakdown;
|
|
107
117
|
writeUiSettings(settings);
|
|
108
|
-
}, [days, metric]);
|
|
118
|
+
}, [days, metric, breakdown]);
|
|
109
119
|
|
|
110
120
|
useEffect(() => {
|
|
111
121
|
loadSessions();
|
|
@@ -166,28 +176,52 @@ export const useUsageTab = ({ sessionId = "" }) => {
|
|
|
166
176
|
|
|
167
177
|
const overviewDatasets = useMemo(() => {
|
|
168
178
|
const rows = Array.isArray(summary?.daily) ? summary.daily : [];
|
|
169
|
-
const
|
|
179
|
+
const allBreakdownKeys = new Set();
|
|
180
|
+
const totalsByBreakdownKey = new Map();
|
|
181
|
+
const breakdownRowKey =
|
|
182
|
+
breakdown === "source" ? "sources" : breakdown === "agent" ? "agents" : "models";
|
|
183
|
+
const breakdownValueKey =
|
|
184
|
+
breakdown === "source" ? "source" : breakdown === "agent" ? "agent" : "model";
|
|
170
185
|
for (const dayRow of rows) {
|
|
171
|
-
for (const
|
|
172
|
-
|
|
186
|
+
for (const breakdownRow of dayRow[breakdownRowKey] || []) {
|
|
187
|
+
const bucketKey = String(breakdownRow[breakdownValueKey] || "unknown");
|
|
188
|
+
allBreakdownKeys.add(bucketKey);
|
|
189
|
+
totalsByBreakdownKey.set(
|
|
190
|
+
bucketKey,
|
|
191
|
+
Number(totalsByBreakdownKey.get(bucketKey) || 0) +
|
|
192
|
+
Number(
|
|
193
|
+
metric === "cost"
|
|
194
|
+
? breakdownRow.totalCost || 0
|
|
195
|
+
: breakdownRow.totalTokens || 0,
|
|
196
|
+
),
|
|
197
|
+
);
|
|
173
198
|
}
|
|
174
199
|
}
|
|
175
200
|
const labels = rows.map((row) => String(row.date || ""));
|
|
176
|
-
const
|
|
177
|
-
|
|
201
|
+
const orderedBreakdownKeys = Array.from(allBreakdownKeys).sort(
|
|
202
|
+
(leftValue, rightValue) => {
|
|
203
|
+
const leftTotal = Number(totalsByBreakdownKey.get(leftValue) || 0);
|
|
204
|
+
const rightTotal = Number(totalsByBreakdownKey.get(rightValue) || 0);
|
|
205
|
+
if (rightTotal !== leftTotal) return rightTotal - leftTotal;
|
|
206
|
+
return leftValue.localeCompare(rightValue);
|
|
207
|
+
},
|
|
208
|
+
);
|
|
209
|
+
const datasets = orderedBreakdownKeys.map((bucketKey) => ({
|
|
210
|
+
label: bucketKey,
|
|
178
211
|
data: rows.map((row) => {
|
|
179
|
-
const found = (row
|
|
180
|
-
(
|
|
212
|
+
const found = (row[breakdownRowKey] || []).find(
|
|
213
|
+
(breakdownRow) =>
|
|
214
|
+
String(breakdownRow[breakdownValueKey] || "") === bucketKey,
|
|
181
215
|
);
|
|
182
216
|
if (!found) return 0;
|
|
183
217
|
return metric === "cost"
|
|
184
218
|
? Number(found.totalCost || 0)
|
|
185
219
|
: Number(found.totalTokens || 0);
|
|
186
220
|
}),
|
|
187
|
-
backgroundColor: toChartColor(
|
|
221
|
+
backgroundColor: toChartColor(`${breakdown}:${bucketKey}`),
|
|
188
222
|
}));
|
|
189
223
|
return { labels, datasets };
|
|
190
|
-
}, [summary, metric]);
|
|
224
|
+
}, [summary, metric, breakdown]);
|
|
191
225
|
|
|
192
226
|
useEffect(() => {
|
|
193
227
|
const canvas = overviewCanvasRef.current;
|
|
@@ -229,9 +263,10 @@ export const useUsageTab = ({ sessionId = "" }) => {
|
|
|
229
263
|
callbacks: {
|
|
230
264
|
label: (context) => {
|
|
231
265
|
const value = Number(context.parsed.y || 0);
|
|
266
|
+
const label = renderBreakdownLabel(context.dataset.label, breakdown);
|
|
232
267
|
return metric === "cost"
|
|
233
|
-
? `${
|
|
234
|
-
: `${
|
|
268
|
+
? `${label}: ${formatUsd(value)}`
|
|
269
|
+
: `${label}: ${formatInteger(value)} tokens`;
|
|
235
270
|
},
|
|
236
271
|
},
|
|
237
272
|
},
|
|
@@ -244,12 +279,13 @@ export const useUsageTab = ({ sessionId = "" }) => {
|
|
|
244
279
|
overviewChartRef.current = null;
|
|
245
280
|
}
|
|
246
281
|
};
|
|
247
|
-
}, [overviewDatasets, metric]);
|
|
282
|
+
}, [overviewDatasets, metric, breakdown]);
|
|
248
283
|
|
|
249
284
|
return {
|
|
250
285
|
state: {
|
|
251
286
|
days,
|
|
252
287
|
metric,
|
|
288
|
+
breakdown,
|
|
253
289
|
summary,
|
|
254
290
|
sessions,
|
|
255
291
|
sessionDetailById,
|
|
@@ -264,6 +300,7 @@ export const useUsageTab = ({ sessionId = "" }) => {
|
|
|
264
300
|
actions: {
|
|
265
301
|
setDays,
|
|
266
302
|
setMetric,
|
|
303
|
+
setBreakdown,
|
|
267
304
|
loadSummary,
|
|
268
305
|
loadSessionDetail,
|
|
269
306
|
setExpandedSessionIds,
|
package/lib/public/js/lib/api.js
CHANGED
|
@@ -450,6 +450,42 @@ export async function fetchAlphaclawVersion(refresh = false) {
|
|
|
450
450
|
return res.json();
|
|
451
451
|
}
|
|
452
452
|
|
|
453
|
+
export async function fetchAlphaclawReleaseNotes(tag = "") {
|
|
454
|
+
const normalizedTag = String(tag || "").trim();
|
|
455
|
+
const query = normalizedTag
|
|
456
|
+
? `?${new URLSearchParams({ tag: normalizedTag }).toString()}`
|
|
457
|
+
: "";
|
|
458
|
+
try {
|
|
459
|
+
const res = await authFetch(`/api/alphaclaw/release-notes${query}`);
|
|
460
|
+
return await parseJsonOrThrow(res, "Could not load release notes");
|
|
461
|
+
} catch {
|
|
462
|
+
const endpoint = normalizedTag
|
|
463
|
+
? `https://api.github.com/repos/chrysb/alphaclaw/releases/tags/${encodeURIComponent(normalizedTag)}`
|
|
464
|
+
: "https://api.github.com/repos/chrysb/alphaclaw/releases/latest";
|
|
465
|
+
const res = await fetch(endpoint, {
|
|
466
|
+
headers: { Accept: "application/vnd.github+json" },
|
|
467
|
+
});
|
|
468
|
+
const text = await res.text();
|
|
469
|
+
let data = null;
|
|
470
|
+
try {
|
|
471
|
+
data = text ? JSON.parse(text) : null;
|
|
472
|
+
} catch {
|
|
473
|
+
throw new Error(text || "Could not load release notes");
|
|
474
|
+
}
|
|
475
|
+
if (!res.ok) {
|
|
476
|
+
throw new Error(data?.message || text || "Could not load release notes");
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
ok: true,
|
|
480
|
+
tag: String(data?.tag_name || normalizedTag || ""),
|
|
481
|
+
name: String(data?.name || ""),
|
|
482
|
+
body: String(data?.body || ""),
|
|
483
|
+
htmlUrl: String(data?.html_url || ""),
|
|
484
|
+
publishedAt: String(data?.published_at || ""),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
453
489
|
export async function updateAlphaclaw() {
|
|
454
490
|
const res = await authFetch("/api/alphaclaw/update", { method: "POST" });
|
|
455
491
|
return res.json();
|
package/lib/server/constants.js
CHANGED
|
@@ -137,6 +137,8 @@ const kVersionCacheTtlMs = 60 * 1000;
|
|
|
137
137
|
const kLatestVersionCacheTtlMs = 10 * 60 * 1000;
|
|
138
138
|
const kOpenclawRegistryUrl = "https://registry.npmjs.org/openclaw";
|
|
139
139
|
const kAlphaclawRegistryUrl = "https://registry.npmjs.org/@chrysb%2falphaclaw";
|
|
140
|
+
const kAlphaclawGithubReleasesBaseUrl =
|
|
141
|
+
"https://api.github.com/repos/chrysb/alphaclaw/releases";
|
|
140
142
|
const kAppDir = kNpmPackageRoot;
|
|
141
143
|
const kMaxPayloadBytes = parsePositiveInt(process.env.WEBHOOK_LOG_MAX_BYTES, 50 * 1024);
|
|
142
144
|
const kWebhookPruneDays = parsePositiveInt(process.env.WEBHOOK_LOG_RETENTION_DAYS, 30);
|
|
@@ -424,6 +426,7 @@ module.exports = {
|
|
|
424
426
|
kLatestVersionCacheTtlMs,
|
|
425
427
|
kOpenclawRegistryUrl,
|
|
426
428
|
kAlphaclawRegistryUrl,
|
|
429
|
+
kAlphaclawGithubReleasesBaseUrl,
|
|
427
430
|
kAppDir,
|
|
428
431
|
kMaxPayloadBytes,
|
|
429
432
|
kWebhookPruneDays,
|
|
@@ -153,12 +153,16 @@ const getDailySummary = ({
|
|
|
153
153
|
`)
|
|
154
154
|
.all({ $lookbackMs: lookbackMs });
|
|
155
155
|
const byDateModel = new Map();
|
|
156
|
+
const byDateSource = new Map();
|
|
157
|
+
const byDateAgent = new Map();
|
|
156
158
|
for (const eventRow of eventsRows) {
|
|
157
159
|
const timestamp = coerceInt(eventRow.timestamp);
|
|
158
160
|
const dayKey = normalizedTimeZone === kUtcTimeZone
|
|
159
161
|
? toDayKey(timestamp)
|
|
160
162
|
: toTimeZoneDayKey(timestamp, normalizedTimeZone);
|
|
161
163
|
if (dayKey < startDay) continue;
|
|
164
|
+
const sessionRef = String(eventRow.session_key || eventRow.session_id || "");
|
|
165
|
+
const { agent, source } = parseAgentAndSourceFromSessionRef(sessionRef);
|
|
162
166
|
const model = String(eventRow.model || "unknown");
|
|
163
167
|
const mapKey = `${dayKey}\u0000${model}`;
|
|
164
168
|
if (!byDateModel.has(mapKey)) {
|
|
@@ -197,6 +201,52 @@ const getDailySummary = ({
|
|
|
197
201
|
if (!aggregate.provider && eventRow.provider) {
|
|
198
202
|
aggregate.provider = String(eventRow.provider || "unknown");
|
|
199
203
|
}
|
|
204
|
+
|
|
205
|
+
const sourceMapKey = `${dayKey}\u0000${source}`;
|
|
206
|
+
if (!byDateSource.has(sourceMapKey)) {
|
|
207
|
+
byDateSource.set(sourceMapKey, {
|
|
208
|
+
source,
|
|
209
|
+
date: dayKey,
|
|
210
|
+
inputTokens: 0,
|
|
211
|
+
outputTokens: 0,
|
|
212
|
+
cacheReadTokens: 0,
|
|
213
|
+
cacheWriteTokens: 0,
|
|
214
|
+
totalTokens: 0,
|
|
215
|
+
turnCount: 0,
|
|
216
|
+
totalCost: 0,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
const sourceAggregate = byDateSource.get(sourceMapKey);
|
|
220
|
+
sourceAggregate.inputTokens += metrics.inputTokens;
|
|
221
|
+
sourceAggregate.outputTokens += metrics.outputTokens;
|
|
222
|
+
sourceAggregate.cacheReadTokens += metrics.cacheReadTokens;
|
|
223
|
+
sourceAggregate.cacheWriteTokens += metrics.cacheWriteTokens;
|
|
224
|
+
sourceAggregate.totalTokens += metrics.totalTokens;
|
|
225
|
+
sourceAggregate.turnCount += 1;
|
|
226
|
+
sourceAggregate.totalCost += metrics.totalCost;
|
|
227
|
+
|
|
228
|
+
const agentMapKey = `${dayKey}\u0000${agent}`;
|
|
229
|
+
if (!byDateAgent.has(agentMapKey)) {
|
|
230
|
+
byDateAgent.set(agentMapKey, {
|
|
231
|
+
agent,
|
|
232
|
+
date: dayKey,
|
|
233
|
+
inputTokens: 0,
|
|
234
|
+
outputTokens: 0,
|
|
235
|
+
cacheReadTokens: 0,
|
|
236
|
+
cacheWriteTokens: 0,
|
|
237
|
+
totalTokens: 0,
|
|
238
|
+
turnCount: 0,
|
|
239
|
+
totalCost: 0,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
const agentAggregate = byDateAgent.get(agentMapKey);
|
|
243
|
+
agentAggregate.inputTokens += metrics.inputTokens;
|
|
244
|
+
agentAggregate.outputTokens += metrics.outputTokens;
|
|
245
|
+
agentAggregate.cacheReadTokens += metrics.cacheReadTokens;
|
|
246
|
+
agentAggregate.cacheWriteTokens += metrics.cacheWriteTokens;
|
|
247
|
+
agentAggregate.totalTokens += metrics.totalTokens;
|
|
248
|
+
agentAggregate.turnCount += 1;
|
|
249
|
+
agentAggregate.totalCost += metrics.totalCost;
|
|
200
250
|
}
|
|
201
251
|
const enriched = Array.from(byDateModel.values()).sort((a, b) => {
|
|
202
252
|
if (a.date === b.date) return b.totalTokens - a.totalTokens;
|
|
@@ -227,6 +277,50 @@ const getDailySummary = ({
|
|
|
227
277
|
pricingFound: row.pricingFound,
|
|
228
278
|
});
|
|
229
279
|
}
|
|
280
|
+
const byDateSourceRows = new Map();
|
|
281
|
+
for (const row of byDateSource.values()) {
|
|
282
|
+
if (!byDateSourceRows.has(row.date)) byDateSourceRows.set(row.date, []);
|
|
283
|
+
byDateSourceRows.get(row.date).push({
|
|
284
|
+
source: row.source,
|
|
285
|
+
inputTokens: row.inputTokens,
|
|
286
|
+
outputTokens: row.outputTokens,
|
|
287
|
+
cacheReadTokens: row.cacheReadTokens,
|
|
288
|
+
cacheWriteTokens: row.cacheWriteTokens,
|
|
289
|
+
totalTokens: row.totalTokens,
|
|
290
|
+
turnCount: row.turnCount,
|
|
291
|
+
totalCost: row.totalCost,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
for (const rows of byDateSourceRows.values()) {
|
|
295
|
+
rows.sort((left, right) => {
|
|
296
|
+
if (right.totalTokens !== left.totalTokens) {
|
|
297
|
+
return right.totalTokens - left.totalTokens;
|
|
298
|
+
}
|
|
299
|
+
return String(left.source || "").localeCompare(String(right.source || ""));
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
const byDateAgentRows = new Map();
|
|
303
|
+
for (const row of byDateAgent.values()) {
|
|
304
|
+
if (!byDateAgentRows.has(row.date)) byDateAgentRows.set(row.date, []);
|
|
305
|
+
byDateAgentRows.get(row.date).push({
|
|
306
|
+
agent: row.agent,
|
|
307
|
+
inputTokens: row.inputTokens,
|
|
308
|
+
outputTokens: row.outputTokens,
|
|
309
|
+
cacheReadTokens: row.cacheReadTokens,
|
|
310
|
+
cacheWriteTokens: row.cacheWriteTokens,
|
|
311
|
+
totalTokens: row.totalTokens,
|
|
312
|
+
turnCount: row.turnCount,
|
|
313
|
+
totalCost: row.totalCost,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
for (const rows of byDateAgentRows.values()) {
|
|
317
|
+
rows.sort((left, right) => {
|
|
318
|
+
if (right.totalTokens !== left.totalTokens) {
|
|
319
|
+
return right.totalTokens - left.totalTokens;
|
|
320
|
+
}
|
|
321
|
+
return String(left.agent || "").localeCompare(String(right.agent || ""));
|
|
322
|
+
});
|
|
323
|
+
}
|
|
230
324
|
const daily = [];
|
|
231
325
|
const totals = {
|
|
232
326
|
inputTokens: 0,
|
|
@@ -259,7 +353,13 @@ const getDailySummary = ({
|
|
|
259
353
|
turnCount: 0,
|
|
260
354
|
},
|
|
261
355
|
);
|
|
262
|
-
daily.push({
|
|
356
|
+
daily.push({
|
|
357
|
+
date,
|
|
358
|
+
...aggregate,
|
|
359
|
+
models: modelRows,
|
|
360
|
+
sources: byDateSourceRows.get(date) || [],
|
|
361
|
+
agents: byDateAgentRows.get(date) || [],
|
|
362
|
+
});
|
|
263
363
|
totals.inputTokens += aggregate.inputTokens;
|
|
264
364
|
totals.outputTokens += aggregate.outputTokens;
|
|
265
365
|
totals.cacheReadTokens += aggregate.cacheReadTokens;
|
|
@@ -124,6 +124,7 @@ const registerServerRoutes = ({
|
|
|
124
124
|
getChannelStatus,
|
|
125
125
|
openclawVersionService,
|
|
126
126
|
alphaclawVersionService,
|
|
127
|
+
kAlphaclawGithubReleasesBaseUrl: constants.kAlphaclawGithubReleasesBaseUrl,
|
|
127
128
|
clawCmd,
|
|
128
129
|
restartGateway,
|
|
129
130
|
OPENCLAW_DIR: constants.OPENCLAW_DIR,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { buildManagedPaths } = require("../internal-files-migration");
|
|
2
2
|
const { readOpenclawConfig } = require("../openclaw-config");
|
|
3
3
|
const { hasScopedBindingFields } = require("../utils/channels");
|
|
4
|
+
const https = require("https");
|
|
4
5
|
|
|
5
6
|
const registerSystemRoutes = ({
|
|
6
7
|
app,
|
|
@@ -17,6 +18,7 @@ const registerSystemRoutes = ({
|
|
|
17
18
|
getChannelStatus,
|
|
18
19
|
openclawVersionService,
|
|
19
20
|
alphaclawVersionService,
|
|
21
|
+
kAlphaclawGithubReleasesBaseUrl,
|
|
20
22
|
clawCmd,
|
|
21
23
|
restartGateway,
|
|
22
24
|
OPENCLAW_DIR,
|
|
@@ -289,6 +291,71 @@ const registerSystemRoutes = ({
|
|
|
289
291
|
return getSystemCronStatus();
|
|
290
292
|
};
|
|
291
293
|
const isVisibleInEnvars = (def) => def?.visibleInEnvars !== false;
|
|
294
|
+
const kReleaseNotesCacheTtlMs = 5 * 60 * 1000;
|
|
295
|
+
let kReleaseNotesCache = {
|
|
296
|
+
key: "",
|
|
297
|
+
fetchedAt: 0,
|
|
298
|
+
payload: null,
|
|
299
|
+
};
|
|
300
|
+
const isValidReleaseTag = (value) =>
|
|
301
|
+
/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(String(value || ""));
|
|
302
|
+
const fetchGitHubRelease = (tag = "") =>
|
|
303
|
+
new Promise((resolve, reject) => {
|
|
304
|
+
const normalizedTag = String(tag || "").trim();
|
|
305
|
+
const endpointPath = normalizedTag
|
|
306
|
+
? `/tags/${encodeURIComponent(normalizedTag)}`
|
|
307
|
+
: "/latest";
|
|
308
|
+
const requestUrl = `${kAlphaclawGithubReleasesBaseUrl}${endpointPath}`;
|
|
309
|
+
const token = String(process.env.GITHUB_TOKEN || "").trim();
|
|
310
|
+
const headers = {
|
|
311
|
+
Accept: "application/vnd.github+json",
|
|
312
|
+
"User-Agent": "alphaclaw-release-notes",
|
|
313
|
+
};
|
|
314
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
315
|
+
const request = https.get(
|
|
316
|
+
requestUrl,
|
|
317
|
+
{ headers, timeout: 7000 },
|
|
318
|
+
(response) => {
|
|
319
|
+
let raw = "";
|
|
320
|
+
response.setEncoding("utf8");
|
|
321
|
+
response.on("data", (chunk) => {
|
|
322
|
+
raw += chunk;
|
|
323
|
+
});
|
|
324
|
+
response.on("end", () => {
|
|
325
|
+
let parsed = null;
|
|
326
|
+
try {
|
|
327
|
+
parsed = raw ? JSON.parse(raw) : null;
|
|
328
|
+
} catch {
|
|
329
|
+
parsed = null;
|
|
330
|
+
}
|
|
331
|
+
const statusCode = Number(response.statusCode) || 500;
|
|
332
|
+
if (statusCode >= 400) {
|
|
333
|
+
const message =
|
|
334
|
+
parsed?.message ||
|
|
335
|
+
`GitHub release lookup failed with status ${statusCode}`;
|
|
336
|
+
return reject(
|
|
337
|
+
Object.assign(new Error(message), {
|
|
338
|
+
statusCode,
|
|
339
|
+
}),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
resolve({
|
|
343
|
+
tag: String(parsed?.tag_name || normalizedTag || ""),
|
|
344
|
+
name: String(parsed?.name || "").trim(),
|
|
345
|
+
body: String(parsed?.body || ""),
|
|
346
|
+
htmlUrl: String(parsed?.html_url || "").trim(),
|
|
347
|
+
publishedAt: String(parsed?.published_at || "").trim(),
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
},
|
|
351
|
+
);
|
|
352
|
+
request.on("timeout", () => {
|
|
353
|
+
request.destroy(new Error("GitHub release request timed out"));
|
|
354
|
+
});
|
|
355
|
+
request.on("error", (error) => {
|
|
356
|
+
reject(error);
|
|
357
|
+
});
|
|
358
|
+
});
|
|
292
359
|
|
|
293
360
|
app.get("/api/env", (req, res) => {
|
|
294
361
|
const fileVars = readEnvFile();
|
|
@@ -466,6 +533,37 @@ const registerSystemRoutes = ({
|
|
|
466
533
|
res.json(status);
|
|
467
534
|
});
|
|
468
535
|
|
|
536
|
+
app.get("/api/alphaclaw/release-notes", async (req, res) => {
|
|
537
|
+
const requestedTag = String(req.query.tag || "").trim();
|
|
538
|
+
if (requestedTag && !isValidReleaseTag(requestedTag)) {
|
|
539
|
+
return res.status(400).json({ ok: false, error: "Invalid release tag" });
|
|
540
|
+
}
|
|
541
|
+
const cacheKey = requestedTag || "latest";
|
|
542
|
+
const now = Date.now();
|
|
543
|
+
if (
|
|
544
|
+
kReleaseNotesCache.payload &&
|
|
545
|
+
kReleaseNotesCache.key === cacheKey &&
|
|
546
|
+
now - kReleaseNotesCache.fetchedAt < kReleaseNotesCacheTtlMs
|
|
547
|
+
) {
|
|
548
|
+
return res.json({ ok: true, ...kReleaseNotesCache.payload });
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
const payload = await fetchGitHubRelease(requestedTag);
|
|
552
|
+
kReleaseNotesCache = {
|
|
553
|
+
key: cacheKey,
|
|
554
|
+
fetchedAt: Date.now(),
|
|
555
|
+
payload,
|
|
556
|
+
};
|
|
557
|
+
return res.json({ ok: true, ...payload });
|
|
558
|
+
} catch (err) {
|
|
559
|
+
const statusCode = Number(err?.statusCode) || 502;
|
|
560
|
+
return res.status(statusCode).json({
|
|
561
|
+
ok: false,
|
|
562
|
+
error: err?.message || "Could not fetch release notes",
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
});
|
|
566
|
+
|
|
469
567
|
app.post("/api/alphaclaw/update", async (req, res) => {
|
|
470
568
|
console.log("[alphaclaw] /api/alphaclaw/update requested");
|
|
471
569
|
const result = await alphaclawVersionService.updateAlphaclaw();
|