@chrysb/alphaclaw 0.4.3 → 0.4.5

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.
Files changed (34) hide show
  1. package/lib/public/js/components/file-tree.js +37 -8
  2. package/lib/public/js/components/gateway.js +74 -42
  3. package/lib/public/js/components/icons.js +13 -0
  4. package/lib/public/js/components/usage-tab/overview-section.js +100 -26
  5. package/lib/public/js/lib/api.js +31 -0
  6. package/lib/server/constants.js +22 -26
  7. package/lib/server/db/usage/index.js +35 -0
  8. package/lib/server/db/usage/pricing.js +82 -0
  9. package/lib/server/db/usage/schema.js +87 -0
  10. package/lib/server/db/usage/sessions.js +217 -0
  11. package/lib/server/db/usage/shared.js +139 -0
  12. package/lib/server/db/usage/summary.js +280 -0
  13. package/lib/server/db/usage/timeseries.js +64 -0
  14. package/lib/server/{watchdog-db.js → db/watchdog/index.js} +1 -18
  15. package/lib/server/db/watchdog/schema.js +21 -0
  16. package/lib/server/{webhooks-db.js → db/webhooks/index.js} +1 -22
  17. package/lib/server/db/webhooks/schema.js +25 -0
  18. package/lib/server/gmail-push.js +102 -6
  19. package/lib/server/gmail-watch.js +5 -20
  20. package/lib/server/helpers.js +5 -21
  21. package/lib/server/routes/browse/index.js +29 -0
  22. package/lib/server/routes/google.js +2 -10
  23. package/lib/server/routes/telegram.js +3 -14
  24. package/lib/server/routes/usage.js +1 -5
  25. package/lib/server/routes/webhooks.js +2 -6
  26. package/lib/server/utils/boolean.js +22 -0
  27. package/lib/server/utils/json.js +31 -0
  28. package/lib/server/utils/network.js +5 -0
  29. package/lib/server/utils/number.js +8 -0
  30. package/lib/server/utils/shell.js +16 -0
  31. package/lib/server/webhook-middleware.js +1 -2
  32. package/lib/server.js +3 -3
  33. package/package.json +1 -1
  34. package/lib/server/usage-db.js +0 -838
@@ -13,6 +13,7 @@ import {
13
13
  createBrowseFile,
14
14
  createBrowseFolder,
15
15
  moveBrowsePath,
16
+ downloadBrowseFile,
16
17
  } from "../lib/api.js";
17
18
  import {
18
19
  kDraftIndexChangedEventName,
@@ -40,6 +41,7 @@ import {
40
41
  FileAddLineIcon,
41
42
  FolderAddLineIcon,
42
43
  DeleteBinLineIcon,
44
+ DownloadLineIcon,
43
45
  } from "./icons.js";
44
46
  import { LoadingSpinner } from "./loading-spinner.js";
45
47
  import { ConfirmDialog } from "./confirm-dialog.js";
@@ -224,6 +226,7 @@ const TreeContextMenu = ({
224
226
  isLocked,
225
227
  onNewFile,
226
228
  onNewFolder,
229
+ onDownload,
227
230
  onDelete,
228
231
  onClose,
229
232
  }) => {
@@ -252,6 +255,7 @@ const TreeContextMenu = ({
252
255
  const isRoot = targetType === "root";
253
256
  const contextFolder = isFolder ? targetPath : "";
254
257
  const canCreate = !isLocked && (isFolder || isRoot);
258
+ const canDownload = isFile && targetPath;
255
259
  const canDelete = !isLocked && (isFolder || isFile) && targetPath;
256
260
 
257
261
  return html`
@@ -278,18 +282,33 @@ const TreeContextMenu = ({
278
282
  </button>
279
283
  `
280
284
  : null}
281
- ${canDelete
285
+ ${canDownload || canDelete
282
286
  ? html`
283
287
  ${canCreate
284
288
  ? html`<div class="tree-context-menu-sep"></div>`
285
289
  : null}
286
- <button
287
- class="tree-context-menu-item"
288
- onclick=${() => { onDelete(targetPath); onClose(); }}
289
- >
290
- <${DeleteBinLineIcon} className="tree-context-menu-icon" />
291
- <span>Delete</span>
292
- </button>
290
+ ${canDownload
291
+ ? html`
292
+ <button
293
+ class="tree-context-menu-item"
294
+ onclick=${() => { onDownload(targetPath); onClose(); }}
295
+ >
296
+ <${DownloadLineIcon} className="tree-context-menu-icon" />
297
+ <span>Download</span>
298
+ </button>
299
+ `
300
+ : null}
301
+ ${canDelete
302
+ ? html`
303
+ <button
304
+ class="tree-context-menu-item"
305
+ onclick=${() => { onDelete(targetPath); onClose(); }}
306
+ >
307
+ <${DeleteBinLineIcon} className="tree-context-menu-icon" />
308
+ <span>Delete</span>
309
+ </button>
310
+ `
311
+ : null}
293
312
  `
294
313
  : null}
295
314
  ${isLocked
@@ -942,6 +961,15 @@ export const FileTree = ({
942
961
  setContextMenu(null);
943
962
  };
944
963
 
964
+ const requestDownload = async (targetPath) => {
965
+ try {
966
+ await downloadBrowseFile(targetPath);
967
+ showToast("Download started", "success");
968
+ } catch (downloadError) {
969
+ showToast(downloadError.message || "Could not download file", "error");
970
+ }
971
+ };
972
+
945
973
  const handleDragDrop = async (action, sourcePath, targetFolder) => {
946
974
  if (action === "start") {
947
975
  setDragSourcePath(sourcePath);
@@ -1208,6 +1236,7 @@ export const FileTree = ({
1208
1236
  isLocked=${!!contextMenu.isLocked}
1209
1237
  onNewFile=${(folder) => requestCreate(folder, "file")}
1210
1238
  onNewFolder=${(folder) => requestCreate(folder, "folder")}
1239
+ onDownload=${requestDownload}
1211
1240
  onDelete=${requestDelete}
1212
1241
  onClose=${closeContextMenu}
1213
1242
  />
@@ -1,10 +1,7 @@
1
1
  import { h } from "https://esm.sh/preact";
2
2
  import { useEffect, useMemo, useState } from "https://esm.sh/preact/hooks";
3
3
  import htm from "https://esm.sh/htm";
4
- import {
5
- fetchOpenclawVersion,
6
- updateOpenclaw,
7
- } from "../lib/api.js";
4
+ import { fetchOpenclawVersion, updateOpenclaw } from "../lib/api.js";
8
5
  import { UpdateActionButton } from "./update-action-button.js";
9
6
  import { ConfirmDialog } from "./confirm-dialog.js";
10
7
  import { showToast } from "./toast.js";
@@ -38,7 +35,8 @@ const VersionRow = ({
38
35
  const [hasUpdate, setHasUpdate] = useState(false);
39
36
  const [error, setError] = useState("");
40
37
  const [hasViewedChangelog, setHasViewedChangelog] = useState(false);
41
- const [confirmWithoutChangelogOpen, setConfirmWithoutChangelogOpen] = useState(false);
38
+ const [confirmWithoutChangelogOpen, setConfirmWithoutChangelogOpen] =
39
+ useState(false);
42
40
  const simulateUpdate = (() => {
43
41
  try {
44
42
  const params = new URLSearchParams(window.location.search);
@@ -124,7 +122,9 @@ const VersionRow = ({
124
122
  setChecking(true);
125
123
  setError("");
126
124
  try {
127
- const data = isUpdateAction ? await applyUpdate() : await fetchVersion(true);
125
+ const data = isUpdateAction
126
+ ? await applyUpdate()
127
+ : await fetchVersion(true);
128
128
  setVersion(data.currentVersion || version);
129
129
  setLatestVersion(data.latestVersion || null);
130
130
  setHasUpdate(!!data.hasUpdate);
@@ -158,10 +158,14 @@ const VersionRow = ({
158
158
  } catch (err) {
159
159
  setError(
160
160
  err.message ||
161
- (isUpdateAction ? `Could not update ${label}` : "Could not check updates"),
161
+ (isUpdateAction
162
+ ? `Could not update ${label}`
163
+ : "Could not check updates"),
162
164
  );
163
165
  showToast(
164
- isUpdateAction ? `Could not update ${label}` : "Could not check updates",
166
+ isUpdateAction
167
+ ? `Could not update ${label}`
168
+ : "Could not check updates",
165
169
  "error",
166
170
  );
167
171
  await onActionComplete({
@@ -201,7 +205,8 @@ const VersionRow = ({
201
205
  ? `${version}`
202
206
  : "..."}
203
207
  </p>
204
- ${error && effectiveHasUpdate &&
208
+ ${error &&
209
+ effectiveHasUpdate &&
205
210
  html`<div
206
211
  class="mt-1 text-xs text-red-300 bg-red-900/30 border border-red-800 rounded-lg px-2 py-1"
207
212
  >
@@ -209,7 +214,9 @@ const VersionRow = ({
209
214
  </div>`}
210
215
  </div>
211
216
  <div class="flex items-center gap-3 shrink-0">
212
- ${effectiveHasUpdate && effectiveLatestVersion && html`
217
+ ${effectiveHasUpdate &&
218
+ effectiveLatestVersion &&
219
+ html`
213
220
  <a
214
221
  href=${changelogUrl}
215
222
  target="_blank"
@@ -228,7 +235,9 @@ const VersionRow = ({
228
235
  idleLabel=${isUpdateActionActive
229
236
  ? updateIdleLabel
230
237
  : "Check updates"}
231
- loadingLabel=${isUpdateActionActive ? "Updating..." : "Checking..."}
238
+ loadingLabel=${isUpdateActionActive
239
+ ? "Updating..."
240
+ : "Checking..."}
232
241
  className="hidden md:inline-flex"
233
242
  />
234
243
  `
@@ -240,12 +249,15 @@ const VersionRow = ({
240
249
  idleLabel=${isUpdateActionActive
241
250
  ? updateIdleLabel
242
251
  : "Check updates"}
243
- loadingLabel=${isUpdateActionActive ? "Updating..." : "Checking..."}
252
+ loadingLabel=${isUpdateActionActive
253
+ ? "Updating..."
254
+ : "Checking..."}
244
255
  />
245
256
  `}
246
257
  </div>
247
258
  </div>
248
- ${showMobileUpdateRow && html`
259
+ ${showMobileUpdateRow &&
260
+ html`
249
261
  <div class="mt-2 md:hidden flex items-center gap-2">
250
262
  <a
251
263
  href=${changelogUrl}
@@ -296,24 +308,25 @@ export const Gateway = ({
296
308
  const dotClass = isRunning
297
309
  ? "ac-status-dot ac-status-dot--healthy"
298
310
  : "w-2 h-2 rounded-full bg-yellow-500 animate-pulse";
299
- const watchdogHealth = watchdogStatus?.lifecycle === "crash_loop"
300
- ? "crash_loop"
301
- : watchdogStatus?.health;
302
- const watchdogDotClass = watchdogHealth === "healthy"
303
- ? "ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset"
304
- : watchdogHealth === "degraded"
305
- ? "bg-yellow-500"
306
- : watchdogHealth === "unhealthy" || watchdogHealth === "crash_loop"
307
- ? "bg-red-500"
308
- : "bg-gray-500";
309
- const watchdogLabel = watchdogHealth === "unknown"
310
- ? "initializing"
311
- : watchdogHealth || "unknown";
311
+ const watchdogHealth =
312
+ watchdogStatus?.lifecycle === "crash_loop"
313
+ ? "crash_loop"
314
+ : watchdogStatus?.health;
315
+ const watchdogDotClass =
316
+ watchdogHealth === "healthy"
317
+ ? "ac-status-dot ac-status-dot--healthy ac-status-dot--healthy-offset"
318
+ : watchdogHealth === "degraded"
319
+ ? "bg-yellow-500"
320
+ : watchdogHealth === "unhealthy" || watchdogHealth === "crash_loop"
321
+ ? "bg-red-500"
322
+ : "bg-gray-500";
323
+ const watchdogLabel =
324
+ watchdogHealth === "unknown" ? "initializing" : watchdogHealth || "unknown";
312
325
  const isRepairInProgress = repairing || !!watchdogStatus?.operationInProgress;
326
+ const showInspectButton = watchdogHealth === "degraded" && !!onOpenWatchdog;
313
327
  const showRepairButton =
314
328
  isRepairInProgress ||
315
329
  watchdogStatus?.lifecycle === "crash_loop" ||
316
- watchdogStatus?.health === "degraded" ||
317
330
  watchdogStatus?.health === "unhealthy" ||
318
331
  watchdogStatus?.health === "crashed";
319
332
  const liveUptimeMs = useMemo(() => {
@@ -339,7 +352,9 @@ export const Gateway = ({
339
352
  <div class="min-w-0 flex items-center gap-2 text-sm">
340
353
  <span class=${dotClass}></span>
341
354
  <span class="font-semibold">Gateway:</span>
342
- <span class="text-gray-400">${restarting ? "restarting..." : status || "checking..."}</span>
355
+ <span class="text-gray-400"
356
+ >${restarting ? "restarting..." : status || "checking..."}</span
357
+ >
343
358
  </div>
344
359
  <div class="flex items-center gap-3 shrink-0">
345
360
  ${!restarting && isRunning
@@ -367,18 +382,22 @@ export const Gateway = ({
367
382
  onclick=${onOpenWatchdog}
368
383
  title="Open Watchdog tab"
369
384
  >
370
- <span class=${watchdogDotClass.startsWith("ac-status-dot")
371
- ? watchdogDotClass
372
- : `w-2 h-2 rounded-full ${watchdogDotClass}`}></span>
385
+ <span
386
+ class=${watchdogDotClass.startsWith("ac-status-dot")
387
+ ? watchdogDotClass
388
+ : `w-2 h-2 rounded-full ${watchdogDotClass}`}
389
+ ></span>
373
390
  <span class="font-semibold">Watchdog:</span>
374
391
  <span class="text-gray-400">${watchdogLabel}</span>
375
392
  </button>
376
393
  `
377
394
  : html`
378
395
  <div class="inline-flex items-center gap-2 text-sm">
379
- <span class=${watchdogDotClass.startsWith("ac-status-dot")
380
- ? watchdogDotClass
381
- : `w-2 h-2 rounded-full ${watchdogDotClass}`}></span>
396
+ <span
397
+ class=${watchdogDotClass.startsWith("ac-status-dot")
398
+ ? watchdogDotClass
399
+ : `w-2 h-2 rounded-full ${watchdogDotClass}`}
400
+ ></span>
382
401
  <span class="font-semibold">Watchdog:</span>
383
402
  <span class="text-gray-400">${watchdogLabel}</span>
384
403
  </div>
@@ -386,24 +405,37 @@ export const Gateway = ({
386
405
  ${onRepair
387
406
  ? html`
388
407
  <div class="shrink-0 w-32 flex justify-end">
389
- ${showRepairButton
408
+ ${showInspectButton
390
409
  ? html`
391
410
  <${UpdateActionButton}
392
- onClick=${onRepair}
393
- loading=${isRepairInProgress}
394
- warning=${true}
395
- idleLabel="Repair"
396
- loadingLabel="Repairing..."
411
+ onClick=${onOpenWatchdog}
412
+ warning=${false}
413
+ idleLabel="Inspect"
414
+ loadingLabel="Inspect"
397
415
  className="w-full justify-center"
398
416
  />
399
417
  `
400
- : html`<span class="inline-flex h-7 w-full" aria-hidden="true"></span>`}
418
+ : showRepairButton
419
+ ? html`
420
+ <${UpdateActionButton}
421
+ onClick=${onRepair}
422
+ loading=${isRepairInProgress}
423
+ warning=${true}
424
+ idleLabel="Repair"
425
+ loadingLabel="Repairing..."
426
+ className="w-full justify-center"
427
+ />
428
+ `
429
+ : html`<span
430
+ class="inline-flex h-7 w-full"
431
+ aria-hidden="true"
432
+ ></span>`}
401
433
  </div>
402
434
  `
403
435
  : null}
404
436
  </div>
405
437
  </div>
406
- <div class="mt-3 pt-3 border-t border-border">
438
+ <div class="mt-3">
407
439
  <${VersionRow}
408
440
  label="OpenClaw"
409
441
  currentVersion=${openclawVersion}
@@ -288,6 +288,19 @@ export const FolderAddLineIcon = ({ className = "" }) => html`
288
288
  </svg>
289
289
  `;
290
290
 
291
+ export const DownloadLineIcon = ({ className = "" }) => html`
292
+ <svg
293
+ class=${className}
294
+ viewBox="0 0 24 24"
295
+ fill="currentColor"
296
+ aria-hidden="true"
297
+ >
298
+ <path
299
+ d="M3 19H21V21H3V19ZM13 13.1716L19.0711 7.1005L20.4853 8.51472L12 17L3.51472 8.51472L4.92893 7.1005L11 13.1716V2H13V13.1716Z"
300
+ />
301
+ </svg>
302
+ `;
303
+
291
304
  export const RestartLineIcon = ({ className = "" }) => html`
292
305
  <svg
293
306
  class=${className}
@@ -26,40 +26,78 @@ const SummaryCard = ({ title, tokens, cost }) => html`
26
26
  `;
27
27
 
28
28
  const AgentCostDistribution = ({ summary }) => {
29
- const agents = Array.isArray(summary?.costByAgent?.agents) ? summary.costByAgent.agents : [];
30
- const [selectedAgent, setSelectedAgent] = useState(() => String(agents[0]?.agent || ""));
29
+ const agents = Array.isArray(summary?.costByAgent?.agents)
30
+ ? summary.costByAgent.agents
31
+ : [];
32
+ const missingPricingModels = Array.from(
33
+ new Set(
34
+ (summary?.daily || [])
35
+ .flatMap((dayRow) => dayRow?.models || [])
36
+ .filter(
37
+ (modelRow) =>
38
+ !modelRow?.pricingFound && Number(modelRow?.totalTokens || 0) > 0,
39
+ )
40
+ .map(
41
+ (modelRow) =>
42
+ String(modelRow?.model || "unknown").trim() || "unknown",
43
+ ),
44
+ ),
45
+ ).sort((leftValue, rightValue) => leftValue.localeCompare(rightValue));
46
+ const missingPricingPreview = missingPricingModels.slice(0, 3).join(", ");
47
+ const hasMoreMissingPricingModels = missingPricingModels.length > 3;
48
+ const missingPricingLabel = missingPricingModels.length
49
+ ? hasMoreMissingPricingModels
50
+ ? `${missingPricingPreview}, +${missingPricingModels.length - 3} more`
51
+ : missingPricingPreview
52
+ : "";
53
+ const [selectedAgent, setSelectedAgent] = useState(() =>
54
+ String(agents[0]?.agent || ""),
55
+ );
31
56
  useEffect(() => {
32
57
  if (agents.length === 0) {
33
58
  if (selectedAgent) setSelectedAgent("");
34
59
  return;
35
60
  }
36
- const hasSelectedAgent = agents.some((row) => String(row.agent || "") === selectedAgent);
61
+ const hasSelectedAgent = agents.some(
62
+ (row) => String(row.agent || "") === selectedAgent,
63
+ );
37
64
  if (!hasSelectedAgent) setSelectedAgent(String(agents[0]?.agent || ""));
38
65
  }, [agents, selectedAgent]);
39
66
  const selectedAgentRow =
40
- agents.find((row) => String(row.agent || "") === selectedAgent) || agents[0] || null;
67
+ agents.find((row) => String(row.agent || "") === selectedAgent) ||
68
+ agents[0] ||
69
+ null;
41
70
 
42
71
  return html`
43
72
  <div class="bg-surface border border-border rounded-xl p-4">
44
73
  ${agents.length === 0
45
74
  ? html`
46
- <div class="flex flex-wrap items-start sm:items-center justify-between gap-3 mb-3">
47
- <h2 class="card-label text-xs">Cost breakdown</h2>
75
+ <div
76
+ class="flex flex-wrap items-start sm:items-center justify-between gap-3 mb-3"
77
+ >
78
+ <h2 class="card-label text-xs">Estimated cost breakdown</h2>
48
79
  </div>
49
- <p class="text-xs text-gray-500">No agent usage recorded for this range.</p>
80
+ <p class="text-xs text-gray-500">
81
+ No agent usage recorded for this range.
82
+ </p>
50
83
  `
51
84
  : html`
52
85
  <div class="space-y-3">
53
- <div class="flex flex-wrap items-start sm:items-center justify-between gap-3">
54
- <h2 class="card-label text-xs">Cost breakdown</h2>
55
- <div class="inline-flex flex-wrap items-center gap-3 text-xs text-gray-500">
56
- <span class="text-gray-300">${formatUsd(selectedAgentRow?.totalCost)}</span>
57
- <span>${formatInteger(selectedAgentRow?.totalTokens)} tok</span>
58
- <label class="inline-flex items-center gap-2 text-xs text-gray-500">
86
+ <div
87
+ class="flex flex-wrap items-start sm:items-center justify-between gap-3"
88
+ >
89
+ <h2 class="card-label text-xs">Estimated cost breakdown</h2>
90
+ <div
91
+ class="inline-flex flex-wrap items-center gap-3 text-xs text-gray-500"
92
+ >
93
+ <label
94
+ class="inline-flex items-center gap-2 text-xs text-gray-500"
95
+ >
59
96
  <select
60
97
  class="bg-black/30 border border-border rounded-lg text-xs px-2.5 py-1.5 text-gray-200 focus:border-gray-500"
61
98
  value=${String(selectedAgentRow?.agent || "")}
62
- onChange=${(e) => setSelectedAgent(String(e.currentTarget?.value || ""))}
99
+ onChange=${(e) =>
100
+ setSelectedAgent(String(e.currentTarget?.value || ""))}
63
101
  >
64
102
  ${agents.map(
65
103
  (agentRow) => html`
@@ -74,17 +112,29 @@ const AgentCostDistribution = ({ summary }) => {
74
112
  </div>
75
113
  <div class="grid grid-cols-1 sm:grid-cols-3 gap-2">
76
114
  ${kUsageSourceOrder.map((sourceName) => {
77
- const sourceRow = (selectedAgentRow?.sourceBreakdown || []).find(
78
- (row) => String(row.source || "") === sourceName,
79
- ) || { source: sourceName, totalCost: 0, totalTokens: 0, turnCount: 0 };
115
+ const sourceRow = (
116
+ selectedAgentRow?.sourceBreakdown || []
117
+ ).find((row) => String(row.source || "") === sourceName) || {
118
+ source: sourceName,
119
+ totalCost: 0,
120
+ totalTokens: 0,
121
+ turnCount: 0,
122
+ };
80
123
  return html`
81
124
  <div class="ac-surface-inset px-2.5 py-2">
82
- <p class="text-[11px] text-gray-500">${renderSourceLabel(sourceRow.source)}</p>
83
- <p class="text-xs text-gray-300 mt-0.5">${formatUsd(sourceRow.totalCost)}</p>
125
+ <p class="text-[11px] text-gray-500">
126
+ ${renderSourceLabel(sourceRow.source)}
127
+ </p>
128
+ <p class="text-xs text-gray-300 mt-0.5">
129
+ ${formatUsd(sourceRow.totalCost)}
130
+ </p>
84
131
  <p class="text-[11px] text-gray-500 mt-0.5">
85
- ${formatInteger(sourceRow.totalTokens)} tok
86
- ·
87
- ${formatCountLabel(sourceRow.turnCount, "turn", "turns")}
132
+ ${formatInteger(sourceRow.totalTokens)} tok ·
133
+ ${formatCountLabel(
134
+ sourceRow.turnCount,
135
+ "turn",
136
+ "turns",
137
+ )}
88
138
  </p>
89
139
  </div>
90
140
  `;
@@ -92,6 +142,19 @@ const AgentCostDistribution = ({ summary }) => {
92
142
  </div>
93
143
  </div>
94
144
  `}
145
+ ${missingPricingModels.length
146
+ ? html`
147
+ <div class="mt-3">
148
+ <p class="text-[11px] text-gray-500">
149
+ <span>
150
+ . Missing model pricing for ${missingPricingModels.length}
151
+ ${missingPricingModels.length === 1 ? "model" : "models"}:
152
+ ${missingPricingLabel}.
153
+ </span>
154
+ </p>
155
+ </div>
156
+ `
157
+ : null}
95
158
  </div>
96
159
  `;
97
160
  };
@@ -107,7 +170,11 @@ export const OverviewSection = ({
107
170
  }) => html`
108
171
  <div class="space-y-4">
109
172
  <div class="grid grid-cols-1 md:grid-cols-3 gap-3">
110
- <${SummaryCard} title="Today" tokens=${periodSummary.today.tokens} cost=${periodSummary.today.cost} />
173
+ <${SummaryCard}
174
+ title="Today"
175
+ tokens=${periodSummary.today.tokens}
176
+ cost=${periodSummary.today.cost}
177
+ />
111
178
  <${SummaryCard}
112
179
  title="Last 7 days"
113
180
  tokens=${periodSummary.week.tokens}
@@ -120,11 +187,18 @@ export const OverviewSection = ({
120
187
  />
121
188
  </div>
122
189
  <div class="bg-surface border border-border rounded-xl p-4">
123
- <div class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3">
124
- <h2 class="card-label text-xs">Daily ${metric === "tokens" ? "tokens" : "cost"} by model</h2>
190
+ <div
191
+ class="flex flex-col sm:flex-row sm:items-center justify-between gap-2 mb-3"
192
+ >
193
+ <h2 class="card-label text-xs">
194
+ Daily ${metric === "tokens" ? "tokens" : "cost"} by model
195
+ </h2>
125
196
  <div class="flex items-center gap-2">
126
197
  <${SegmentedControl}
127
- options=${kRangeOptions.map((option) => ({ label: option.label, value: option.value }))}
198
+ options=${kRangeOptions.map((option) => ({
199
+ label: option.label,
200
+ value: option.value,
201
+ }))}
128
202
  value=${days}
129
203
  onChange=${onDaysChange}
130
204
  />
@@ -576,6 +576,37 @@ export const deleteBrowseFile = async (filePath) => {
576
576
  return parseJsonOrThrow(res, 'Could not delete file');
577
577
  };
578
578
 
579
+ export const downloadBrowseFile = async (filePath) => {
580
+ const params = new URLSearchParams({ path: String(filePath || "") });
581
+ const res = await authFetch(`/api/browse/download?${params.toString()}`);
582
+ if (!res.ok) {
583
+ const errorText = await res.text();
584
+ throw new Error(errorText || "Could not download file");
585
+ }
586
+ const fileBlob = await res.blob();
587
+ const urlApi = window?.URL || URL;
588
+ if (!urlApi?.createObjectURL || !urlApi?.revokeObjectURL) {
589
+ throw new Error("Download is not supported in this browser");
590
+ }
591
+ const downloadUrl = urlApi.createObjectURL(fileBlob);
592
+ const fileName =
593
+ String(filePath || "")
594
+ .split("/")
595
+ .filter(Boolean)
596
+ .pop() || "download";
597
+ try {
598
+ const downloadLink = document.createElement("a");
599
+ downloadLink.href = downloadUrl;
600
+ downloadLink.download = fileName;
601
+ document.body?.appendChild(downloadLink);
602
+ downloadLink.click();
603
+ downloadLink.remove();
604
+ } finally {
605
+ urlApi.revokeObjectURL(downloadUrl);
606
+ }
607
+ return { ok: true };
608
+ };
609
+
579
610
  export const restoreBrowseFile = async (filePath) => {
580
611
  const res = await authFetch('/api/browse/restore', {
581
612
  method: 'POST',