@chrysb/alphaclaw 0.6.0 → 0.6.2-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 +1 -1
- package/lib/public/css/cron.css +535 -0
- package/lib/public/css/theme.css +72 -0
- package/lib/public/js/app.js +45 -10
- package/lib/public/js/components/action-button.js +26 -20
- package/lib/public/js/components/agents-tab/agent-detail-panel.js +98 -17
- package/lib/public/js/components/agents-tab/agent-tools/index.js +105 -0
- package/lib/public/js/components/agents-tab/agent-tools/tool-catalog.js +289 -0
- package/lib/public/js/components/agents-tab/agent-tools/use-agent-tools.js +128 -0
- package/lib/public/js/components/agents-tab/index.js +4 -0
- package/lib/public/js/components/cron-tab/cron-calendar-helpers.js +385 -0
- package/lib/public/js/components/cron-tab/cron-calendar.js +441 -0
- package/lib/public/js/components/cron-tab/cron-helpers.js +326 -0
- package/lib/public/js/components/cron-tab/cron-job-detail.js +425 -0
- package/lib/public/js/components/cron-tab/cron-job-list.js +305 -0
- package/lib/public/js/components/cron-tab/cron-job-usage.js +70 -0
- package/lib/public/js/components/cron-tab/cron-overview.js +599 -0
- package/lib/public/js/components/cron-tab/cron-runs-trend-card.js +277 -0
- package/lib/public/js/components/cron-tab/index.js +100 -0
- package/lib/public/js/components/cron-tab/use-cron-tab.js +366 -0
- package/lib/public/js/components/doctor/summary-cards.js +5 -11
- package/lib/public/js/components/google/gmail-setup-wizard.js +30 -30
- package/lib/public/js/components/google/index.js +1 -1
- package/lib/public/js/components/icons.js +13 -0
- package/lib/public/js/components/pill-tabs.js +33 -0
- package/lib/public/js/components/pop-actions.js +58 -0
- package/lib/public/js/components/routes/agents-route.js +4 -0
- package/lib/public/js/components/routes/cron-route.js +9 -0
- package/lib/public/js/components/routes/index.js +1 -0
- package/lib/public/js/components/segmented-control.js +15 -9
- package/lib/public/js/components/summary-stat-card.js +17 -0
- package/lib/public/js/components/tooltip.js +50 -4
- package/lib/public/js/components/watchdog-tab.js +46 -1
- package/lib/public/js/lib/api.js +94 -0
- package/lib/public/js/lib/app-navigation.js +2 -0
- package/lib/public/js/lib/storage-keys.js +1 -0
- package/lib/public/setup.html +1 -0
- package/lib/server/agents/agents.js +15 -0
- package/lib/server/constants.js +1 -0
- package/lib/server/cost-utils.js +312 -0
- package/lib/server/cron-service.js +461 -0
- package/lib/server/db/usage/index.js +100 -1
- package/lib/server/db/usage/pricing.js +1 -83
- package/lib/server/db/usage/sessions.js +4 -1
- package/lib/server/db/usage/shared.js +2 -1
- package/lib/server/db/usage/summary.js +5 -1
- package/lib/server/gmail-watch.js +0 -1
- package/lib/server/onboarding/index.js +39 -5
- package/lib/server/onboarding/openclaw.js +25 -19
- package/lib/server/onboarding/validation.js +28 -0
- package/lib/server/routes/cron.js +148 -0
- package/lib/server.js +13 -0
- package/package.json +1 -1
|
@@ -0,0 +1,305 @@
|
|
|
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 {
|
|
5
|
+
formatCronScheduleLabel,
|
|
6
|
+
formatRelativeCompact,
|
|
7
|
+
getCronJobHealth,
|
|
8
|
+
getCronJobHealthClassName,
|
|
9
|
+
kAllCronJobsRouteKey,
|
|
10
|
+
} from "./cron-helpers.js";
|
|
11
|
+
|
|
12
|
+
const html = htm.bind(h);
|
|
13
|
+
const kGroupOrder = ["daily", "weekly", "monthly", "other"];
|
|
14
|
+
const kGroupLabelByKey = {
|
|
15
|
+
daily: "Daily",
|
|
16
|
+
weekly: "Weekly",
|
|
17
|
+
monthly: "Monthly",
|
|
18
|
+
other: "Other",
|
|
19
|
+
};
|
|
20
|
+
const kMinutesPerHour = 60;
|
|
21
|
+
|
|
22
|
+
const parseCronNumeric = (value = "") => {
|
|
23
|
+
const parsed = Number.parseInt(String(value || "").trim(), 10);
|
|
24
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const normalizeCronWeekday = (value) => {
|
|
28
|
+
if (!Number.isFinite(value)) return null;
|
|
29
|
+
const normalized = value === 7 ? 0 : value;
|
|
30
|
+
return normalized >= 0 && normalized <= 6 ? normalized : null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const parseCronWeekdayField = (field = "") => {
|
|
34
|
+
const raw = String(field || "").trim().toLowerCase();
|
|
35
|
+
if (!raw || raw === "*") return null;
|
|
36
|
+
const segments = raw.split(",").map((segment) => segment.trim()).filter(Boolean);
|
|
37
|
+
const weekdays = [];
|
|
38
|
+
segments.forEach((segment) => {
|
|
39
|
+
const rangeMatch = segment.match(/^(\d{1,2})-(\d{1,2})$/);
|
|
40
|
+
if (rangeMatch) {
|
|
41
|
+
const start = normalizeCronWeekday(parseCronNumeric(rangeMatch[1]));
|
|
42
|
+
const end = normalizeCronWeekday(parseCronNumeric(rangeMatch[2]));
|
|
43
|
+
if (start == null || end == null) return;
|
|
44
|
+
if (start <= end) {
|
|
45
|
+
for (let value = start; value <= end; value += 1) weekdays.push(value);
|
|
46
|
+
} else {
|
|
47
|
+
for (let value = start; value <= 6; value += 1) weekdays.push(value);
|
|
48
|
+
for (let value = 0; value <= end; value += 1) weekdays.push(value);
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const single = normalizeCronWeekday(parseCronNumeric(segment));
|
|
53
|
+
if (single != null) weekdays.push(single);
|
|
54
|
+
});
|
|
55
|
+
if (weekdays.length === 0) return null;
|
|
56
|
+
return Math.min(...weekdays);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const parseCronMinuteOfDay = ({ minuteField = "", hourField = "" }) => {
|
|
60
|
+
const minute = parseCronNumeric(minuteField);
|
|
61
|
+
const hour = parseCronNumeric(hourField);
|
|
62
|
+
if (minute == null || hour == null) return null;
|
|
63
|
+
if (minute < 0 || minute > 59 || hour < 0 || hour > 23) return null;
|
|
64
|
+
return hour * kMinutesPerHour + minute;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const parseCronFields = (schedule = {}) => {
|
|
68
|
+
const cronExpr = String(
|
|
69
|
+
schedule?.expr || schedule?.cron || schedule?.cronExpr || "",
|
|
70
|
+
).trim();
|
|
71
|
+
const cronFields = cronExpr.split(/\s+/);
|
|
72
|
+
if (cronFields.length < 5) return null;
|
|
73
|
+
const [minuteField, hourField, dayOfMonthField, monthField, dayOfWeekField] = cronFields;
|
|
74
|
+
return {
|
|
75
|
+
minuteField,
|
|
76
|
+
hourField,
|
|
77
|
+
dayOfMonthField,
|
|
78
|
+
monthField,
|
|
79
|
+
dayOfWeekField,
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const getInternalSortMeta = (job = {}, groupKey = "other") => {
|
|
84
|
+
const schedule = job?.schedule || {};
|
|
85
|
+
const scheduleKind = String(schedule?.kind || "").trim().toLowerCase();
|
|
86
|
+
const cronFields = parseCronFields(schedule);
|
|
87
|
+
const minuteOfDay = cronFields
|
|
88
|
+
? parseCronMinuteOfDay({
|
|
89
|
+
minuteField: cronFields.minuteField,
|
|
90
|
+
hourField: cronFields.hourField,
|
|
91
|
+
})
|
|
92
|
+
: null;
|
|
93
|
+
const nameKey = String(job?.name || job?.id || "").toLowerCase();
|
|
94
|
+
if (groupKey === "daily") {
|
|
95
|
+
if (scheduleKind === "every") {
|
|
96
|
+
const everyMs = Number(schedule?.everyMs || Number.MAX_SAFE_INTEGER);
|
|
97
|
+
return {
|
|
98
|
+
groupRank: 0,
|
|
99
|
+
primary: Number.isFinite(everyMs) ? everyMs : Number.MAX_SAFE_INTEGER,
|
|
100
|
+
secondary: nameKey,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (
|
|
104
|
+
cronFields &&
|
|
105
|
+
cronFields.dayOfMonthField === "*" &&
|
|
106
|
+
cronFields.monthField === "*" &&
|
|
107
|
+
cronFields.dayOfWeekField === "*" &&
|
|
108
|
+
minuteOfDay != null
|
|
109
|
+
) {
|
|
110
|
+
return {
|
|
111
|
+
groupRank: 1,
|
|
112
|
+
primary: minuteOfDay,
|
|
113
|
+
secondary: nameKey,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (groupKey === "weekly" && cronFields) {
|
|
118
|
+
const weekday = parseCronWeekdayField(cronFields.dayOfWeekField);
|
|
119
|
+
return {
|
|
120
|
+
groupRank: 0,
|
|
121
|
+
primary: weekday == null ? Number.MAX_SAFE_INTEGER : weekday,
|
|
122
|
+
secondary: minuteOfDay == null ? Number.MAX_SAFE_INTEGER : minuteOfDay,
|
|
123
|
+
tertiary: nameKey,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (groupKey === "monthly" && cronFields) {
|
|
127
|
+
const dayOfMonth = parseCronNumeric(cronFields.dayOfMonthField);
|
|
128
|
+
return {
|
|
129
|
+
groupRank: 0,
|
|
130
|
+
primary: dayOfMonth == null ? Number.MAX_SAFE_INTEGER : dayOfMonth,
|
|
131
|
+
secondary: minuteOfDay == null ? Number.MAX_SAFE_INTEGER : minuteOfDay,
|
|
132
|
+
tertiary: nameKey,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
groupRank: 99,
|
|
137
|
+
primary: Number.MAX_SAFE_INTEGER,
|
|
138
|
+
secondary: Number.MAX_SAFE_INTEGER,
|
|
139
|
+
tertiary: nameKey,
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const compareSortable = (left, right) => {
|
|
144
|
+
if (left === right) return 0;
|
|
145
|
+
return left > right ? 1 : -1;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const sortGroupItems = (items = [], groupKey = "other") =>
|
|
149
|
+
[...items].sort((leftJob, rightJob) => {
|
|
150
|
+
const leftMeta = getInternalSortMeta(leftJob, groupKey);
|
|
151
|
+
const rightMeta = getInternalSortMeta(rightJob, groupKey);
|
|
152
|
+
const rankResult = compareSortable(leftMeta.groupRank, rightMeta.groupRank);
|
|
153
|
+
if (rankResult !== 0) return rankResult;
|
|
154
|
+
const primaryResult = compareSortable(leftMeta.primary, rightMeta.primary);
|
|
155
|
+
if (primaryResult !== 0) return primaryResult;
|
|
156
|
+
const secondaryResult = compareSortable(leftMeta.secondary, rightMeta.secondary);
|
|
157
|
+
if (secondaryResult !== 0) return secondaryResult;
|
|
158
|
+
return compareSortable(leftMeta.tertiary, rightMeta.tertiary);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
const getScheduleGroupKey = (schedule = {}) => {
|
|
162
|
+
const kind = String(schedule?.kind || "").trim().toLowerCase();
|
|
163
|
+
if (kind === "every") {
|
|
164
|
+
const everyMs = Number(schedule?.everyMs || 0);
|
|
165
|
+
if (Number.isFinite(everyMs) && everyMs > 0) {
|
|
166
|
+
if (everyMs <= 24 * 60 * 60 * 1000) return "daily";
|
|
167
|
+
if (everyMs <= 7 * 24 * 60 * 60 * 1000) return "weekly";
|
|
168
|
+
if (everyMs <= 31 * 24 * 60 * 60 * 1000) return "monthly";
|
|
169
|
+
}
|
|
170
|
+
return "other";
|
|
171
|
+
}
|
|
172
|
+
const cronExpr = String(
|
|
173
|
+
schedule?.expr || schedule?.cron || schedule?.cronExpr || "",
|
|
174
|
+
).trim();
|
|
175
|
+
const cronFields = cronExpr.split(/\s+/);
|
|
176
|
+
if (cronFields.length >= 5) {
|
|
177
|
+
const [, , dayOfMonthField, monthField, dayOfWeekField] = cronFields;
|
|
178
|
+
if (
|
|
179
|
+
dayOfMonthField === "*" &&
|
|
180
|
+
monthField === "*" &&
|
|
181
|
+
dayOfWeekField === "*"
|
|
182
|
+
) {
|
|
183
|
+
return "daily";
|
|
184
|
+
}
|
|
185
|
+
if (
|
|
186
|
+
dayOfMonthField === "*" &&
|
|
187
|
+
monthField === "*" &&
|
|
188
|
+
dayOfWeekField !== "*"
|
|
189
|
+
) {
|
|
190
|
+
return "weekly";
|
|
191
|
+
}
|
|
192
|
+
if (dayOfMonthField !== "*" && monthField === "*") {
|
|
193
|
+
return "monthly";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return "other";
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const CronJobList = ({
|
|
200
|
+
jobs = [],
|
|
201
|
+
selectedRouteKey = kAllCronJobsRouteKey,
|
|
202
|
+
onSelectAllJobs = () => {},
|
|
203
|
+
onSelectJob = () => {},
|
|
204
|
+
}) => {
|
|
205
|
+
const [searchQuery, setSearchQuery] = useState("");
|
|
206
|
+
const normalizedQuery = String(searchQuery || "").trim().toLowerCase();
|
|
207
|
+
const filteredJobs = useMemo(() => {
|
|
208
|
+
if (!normalizedQuery) return jobs;
|
|
209
|
+
return jobs.filter((job) => {
|
|
210
|
+
const name = String(job?.name || "").toLowerCase();
|
|
211
|
+
const id = String(job?.id || "").toLowerCase();
|
|
212
|
+
return name.includes(normalizedQuery) || id.includes(normalizedQuery);
|
|
213
|
+
});
|
|
214
|
+
}, [jobs, normalizedQuery]);
|
|
215
|
+
const groupedJobs = useMemo(() => {
|
|
216
|
+
const groups = {
|
|
217
|
+
daily: [],
|
|
218
|
+
weekly: [],
|
|
219
|
+
monthly: [],
|
|
220
|
+
other: [],
|
|
221
|
+
};
|
|
222
|
+
filteredJobs.forEach((job) => {
|
|
223
|
+
const groupKey = getScheduleGroupKey(job?.schedule);
|
|
224
|
+
if (!groups[groupKey]) groups.other.push(job);
|
|
225
|
+
else groups[groupKey].push(job);
|
|
226
|
+
});
|
|
227
|
+
return {
|
|
228
|
+
daily: sortGroupItems(groups.daily, "daily"),
|
|
229
|
+
weekly: sortGroupItems(groups.weekly, "weekly"),
|
|
230
|
+
monthly: sortGroupItems(groups.monthly, "monthly"),
|
|
231
|
+
other: sortGroupItems(groups.other, "other"),
|
|
232
|
+
};
|
|
233
|
+
}, [filteredJobs]);
|
|
234
|
+
|
|
235
|
+
return html`
|
|
236
|
+
<div class="cron-list-panel-inner">
|
|
237
|
+
<div class="cron-list-sticky-search">
|
|
238
|
+
<input
|
|
239
|
+
type="text"
|
|
240
|
+
value=${searchQuery}
|
|
241
|
+
placeholder="Search cron jobs..."
|
|
242
|
+
class="cron-list-search-input"
|
|
243
|
+
onInput=${(event) => setSearchQuery(event.target.value)}
|
|
244
|
+
/>
|
|
245
|
+
</div>
|
|
246
|
+
<button
|
|
247
|
+
type="button"
|
|
248
|
+
class=${`cron-list-item cron-list-all ${selectedRouteKey === kAllCronJobsRouteKey ? "is-selected" : ""}`}
|
|
249
|
+
onclick=${onSelectAllJobs}
|
|
250
|
+
>
|
|
251
|
+
<span class="cron-list-item-title">All Jobs</span>
|
|
252
|
+
<span class="cron-list-item-subtitle">${jobs.length} total</span>
|
|
253
|
+
</button>
|
|
254
|
+
|
|
255
|
+
<div class="cron-list-items">
|
|
256
|
+
${kGroupOrder.map((groupKey) => {
|
|
257
|
+
const groupItems = groupedJobs[groupKey] || [];
|
|
258
|
+
if (groupItems.length === 0) return null;
|
|
259
|
+
return html`
|
|
260
|
+
<div key=${groupKey} class="cron-list-group">
|
|
261
|
+
<div class="cron-list-group-header">${kGroupLabelByKey[groupKey] || "Other"}</div>
|
|
262
|
+
<div class="cron-list-group-items">
|
|
263
|
+
${groupItems.map((job) => {
|
|
264
|
+
const health = getCronJobHealth(job);
|
|
265
|
+
const selected = selectedRouteKey === String(job.id || "");
|
|
266
|
+
return html`
|
|
267
|
+
<button
|
|
268
|
+
key=${job.id}
|
|
269
|
+
type="button"
|
|
270
|
+
class=${`cron-list-item ${selected ? "is-selected" : ""}`}
|
|
271
|
+
onclick=${() => onSelectJob(job.id)}
|
|
272
|
+
>
|
|
273
|
+
<span class="cron-list-item-row">
|
|
274
|
+
<span class="cron-list-item-title truncate">${job.name || job.id}</span>
|
|
275
|
+
<span class="cron-list-status-inline">
|
|
276
|
+
<span class="cron-list-last-run">
|
|
277
|
+
${formatRelativeCompact(job?.state?.lastRunAtMs)}
|
|
278
|
+
</span>
|
|
279
|
+
<span
|
|
280
|
+
class=${`cron-list-health-dot ${getCronJobHealthClassName(health)}`}
|
|
281
|
+
title=${health}
|
|
282
|
+
></span>
|
|
283
|
+
</span>
|
|
284
|
+
</span>
|
|
285
|
+
<span class="cron-list-item-subtitle">
|
|
286
|
+
${formatCronScheduleLabel(job.schedule, {
|
|
287
|
+
includeTimeZoneWhenDifferent: true,
|
|
288
|
+
})}
|
|
289
|
+
</span>
|
|
290
|
+
</button>
|
|
291
|
+
`;
|
|
292
|
+
})}
|
|
293
|
+
</div>
|
|
294
|
+
</div>
|
|
295
|
+
`;
|
|
296
|
+
})}
|
|
297
|
+
</div>
|
|
298
|
+
${filteredJobs.length === 0
|
|
299
|
+
? html`
|
|
300
|
+
<div class="text-xs text-gray-500 px-1 py-2">No cron jobs match your search.</div>
|
|
301
|
+
`
|
|
302
|
+
: null}
|
|
303
|
+
</div>
|
|
304
|
+
`;
|
|
305
|
+
};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { h } from "https://esm.sh/preact";
|
|
2
|
+
import htm from "https://esm.sh/htm";
|
|
3
|
+
import { formatCost, formatTokenCount } from "./cron-helpers.js";
|
|
4
|
+
|
|
5
|
+
const html = htm.bind(h);
|
|
6
|
+
|
|
7
|
+
const resolveDominantModel = (usage = null) => {
|
|
8
|
+
const list = Array.isArray(usage?.modelBreakdown) ? usage.modelBreakdown : [];
|
|
9
|
+
if (list.length === 0) return "—";
|
|
10
|
+
const first = list[0];
|
|
11
|
+
const model = String(first?.model || "").trim();
|
|
12
|
+
const provider = String(first?.provider || "").trim();
|
|
13
|
+
if (!model && !provider) return "—";
|
|
14
|
+
if (!provider) return model;
|
|
15
|
+
if (!model) return provider;
|
|
16
|
+
return `${provider} / ${model}`;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const CronJobUsage = ({ usage = null, usageDays = 30, onSetUsageDays = () => {} }) => {
|
|
20
|
+
const totals = usage?.totals || {};
|
|
21
|
+
const totalRuns = Number(totals?.runCount || 0);
|
|
22
|
+
const totalTokens = Number(totals?.totalTokens || 0);
|
|
23
|
+
const averageTokensPerRun = totalRuns > 0 ? Math.round(totalTokens / totalRuns) : 0;
|
|
24
|
+
return html`
|
|
25
|
+
<section class="bg-surface border border-border rounded-xl p-4 space-y-3">
|
|
26
|
+
<div class="flex items-center justify-between gap-2">
|
|
27
|
+
<h3 class="card-label">Usage</h3>
|
|
28
|
+
<div class="flex items-center gap-1">
|
|
29
|
+
${[7, 30].map(
|
|
30
|
+
(days) => html`
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
class=${`text-xs px-2 py-1 rounded border ${
|
|
34
|
+
usageDays === days
|
|
35
|
+
? "border-cyan-400 text-cyan-200 bg-cyan-400/10"
|
|
36
|
+
: "border-border text-gray-400 hover:text-gray-200"
|
|
37
|
+
}`}
|
|
38
|
+
onclick=${() => onSetUsageDays(days)}
|
|
39
|
+
>
|
|
40
|
+
${days}d
|
|
41
|
+
</button>
|
|
42
|
+
`,
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
<div class="grid grid-cols-2 gap-2 text-xs">
|
|
47
|
+
<div class="ac-surface-inset rounded-lg p-2">
|
|
48
|
+
<div class="text-gray-500">Total tokens</div>
|
|
49
|
+
<div class="text-gray-200 font-mono">${formatTokenCount(totals.totalTokens)}</div>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="ac-surface-inset rounded-lg p-2">
|
|
52
|
+
<div class="text-gray-500">Estimated cost</div>
|
|
53
|
+
<div class="text-gray-200 font-mono">${formatCost(totals.totalCost)}</div>
|
|
54
|
+
</div>
|
|
55
|
+
<div class="ac-surface-inset rounded-lg p-2">
|
|
56
|
+
<div class="text-gray-500">Runs</div>
|
|
57
|
+
<div class="text-gray-200 font-mono">${formatTokenCount(totals.runCount)}</div>
|
|
58
|
+
</div>
|
|
59
|
+
<div class="ac-surface-inset rounded-lg p-2">
|
|
60
|
+
<div class="text-gray-500">Avg tokens/run</div>
|
|
61
|
+
<div class="text-gray-200 font-mono">${formatTokenCount(averageTokensPerRun)}</div>
|
|
62
|
+
</div>
|
|
63
|
+
</div>
|
|
64
|
+
<div class="text-xs text-gray-500">
|
|
65
|
+
Dominant model:
|
|
66
|
+
<span class="text-gray-300 font-mono">${resolveDominantModel(usage)}</span>
|
|
67
|
+
</div>
|
|
68
|
+
</section>
|
|
69
|
+
`;
|
|
70
|
+
};
|