@songsid/agend 2.3.0-beta.7 → 2.3.0-beta.8
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/dist/channel/mcp-tools.js +1 -1
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +29 -9
- package/dist/daemon.js.map +1 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +13 -10
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +15 -8
- package/dist/instance-config-impact.js +1 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/outbound-handlers.js +4 -1
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.js +1 -1
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/settings-api.js +4 -0
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +4 -0
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +2 -0
- package/dist/settings-effect.js.map +1 -1
- package/dist/ui/app-effects.js +5 -5
- package/dist/ui/chat-ambient.js +8 -7
- package/dist/ui/chat-effect-posters.js +3 -2
- package/dist/ui/chat-effects.js +19 -13
- package/dist/ui/chat-strings.js +4 -2
- package/dist/ui/chat-thread.js +1 -1
- package/dist/ui/fleet-usage.js +401 -0
- package/dist/ui/panel-chat.js +9 -4
- package/dist/ui/panel-fleet.js +6 -5
- package/dist/ui/panel-settings.js +6 -1
- package/dist/ui/settings-owner-effects.js +1 -1
- package/dist/ui/settings-strings.js +2 -0
- package/dist/ui/shared/app-route.js +1 -1
- package/dist/ui/shared/app.css +67 -0
- package/dist/ui/shared/effect-app-runtime.js +3 -3
- package/dist/ui/shared/effect-chat-runtime.js +1 -1
- package/dist/ui/shared/effect-policy.js +3 -4
- package/dist/ui/shared/effect-profiler.js +31 -9
- package/dist/ui/shared/effect-registry.js +9 -26
- package/dist/ui/shared/effect-stock-trust.js +2 -2
- package/dist/ui/shared/fx-motion.js +12 -3
- package/dist/ui/shared/usage-dialog.js +8 -0
- package/dist/ui/shared/view-strings.js +4 -0
- package/dist/usage-report.d.ts +12 -1
- package/dist/usage-report.js +39 -8
- package/dist/usage-report.js.map +1 -1
- package/dist/web-api.d.ts +1 -0
- package/dist/web-api.js +7 -2
- package/dist/web-api.js.map +1 -1
- package/dist/web-settings-patch.d.ts +3 -1
- package/dist/web-settings-patch.js +4 -1
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-shell-routes.d.ts +1 -1
- package/dist/web-shell-routes.js +1 -1
- package/dist/web-shell-routes.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
// #2122: Fleet → Usage (/ui/fleet/usage) — each agent's tokens, requests, delivered messages and their API-equivalent
|
|
2
|
+
// at list price over a range of days, ranked; a daily trend, a calendar heatmap and a CSV of the same answer. Behind the
|
|
3
|
+
// session gate (/ui/js/), imported by the Fleet panel. Read-only: it changes nothing.
|
|
4
|
+
//
|
|
5
|
+
// One GET /ui/usage-report?… per opening and per choice of range or grouping (a person's navigation); no timer re-reads
|
|
6
|
+
// it (#1374). Every amount says what it is: "API-equivalent at list price — not money spent" (design §3).
|
|
7
|
+
import { html, useEffect, useLayoutEffect, useMemo, useRef, useState } from "/assets/app-html.js";
|
|
8
|
+
import { t, register } from "/assets/app-i18n.js";
|
|
9
|
+
import { Empty, ErrorState, Skeleton } from "/assets/ui-states.js";
|
|
10
|
+
import { Icon } from "/assets/ui-icons.js";
|
|
11
|
+
import { money, tokens } from "./fleet-cache.js";
|
|
12
|
+
|
|
13
|
+
register("fusage", {
|
|
14
|
+
en: {
|
|
15
|
+
range: "Range", today: "Today", r7d: "7 days", month: "This month", all: "All", custom: "Custom", from: "From", to: "To", apply: "Apply",
|
|
16
|
+
group: "Group by", g_instance: "Agent", g_backend: "Backend", g_model: "Model", g_profile: "Credential profile", g_team: "Team (as configured now)",
|
|
17
|
+
csv: "Download CSV", basis: "API-equivalent at list price (checked {0}) — not money spent. Subscriptions are not billed per token.",
|
|
18
|
+
amount: "API-equivalent", input: "Input", output: "Output", read: "Cache read", write: "Cache write", total: "Total tokens", requests: "Requests", messages: "Messages",
|
|
19
|
+
notPriced: "not priced", partial: "partial", noTeam: "(no team)", unknown: "unknown",
|
|
20
|
+
f_cache_write_not_reported: "cache writes not reported (Codex) — an estimate", f_not_priced: "not priced: {0}", f_source_unknown: "read before backend and profile were recorded",
|
|
21
|
+
scanning: "Scanning — totals may still grow.", updated: "Updated {0} · read every 10 min", since: "Collected since {0}", notYet: "Not collected yet",
|
|
22
|
+
refresh: "Refresh",
|
|
23
|
+
notMeasured: "Not measured", nm_credit_billed: "Kiro is credit-billed and records no tokens", nm_unsupported: "AgEnD can't read this CLI's usage yet", nm_no_data: "nothing read yet",
|
|
24
|
+
shared: "shares its working directory with {0}; their transcripts can't be told apart",
|
|
25
|
+
empty: "Nothing recorded yet", emptyHint: "Usage is read from Claude Code and Codex transcripts, from when the fleet started.",
|
|
26
|
+
blocked: "Not offered over the public link", blockedHint: "Usage is offered over the public link only when \"Usage report over the public link\" is on in Settings → Web — on the dashboard reached directly, on the computer running AgEnD.",
|
|
27
|
+
openSettings: "Open Settings → Web on that computer",
|
|
28
|
+
partialDays: "Daily detail from {0}: the earlier days in this range are too many rows to send. The rows and totals above cover the whole range; the trend, the heatmap and the CSV start at {0}.",
|
|
29
|
+
csvFrom: "Download CSV (from {0})",
|
|
30
|
+
noDaily: "No daily detail for this range: even its latest day has more rows than one answer carries, and a part of a day is never shown as the day. The rows and totals above are complete; the trend, the heatmap and the CSV need the daily detail and are not shown.",
|
|
31
|
+
zoneChanged: "The fleet's timezone changed: these days were made in {0}. A day keeps the zone it was made in, so a day made in two shows each part (see the heatmap). Days from now on are in {1}.",
|
|
32
|
+
dayTokens: "{0} tokens", partNotPriced: "part not priced",
|
|
33
|
+
trendUnpriced: "Not priced, so not in these bars: {0}. Their tokens are in the rows and the heatmap.",
|
|
34
|
+
off: "Usage is turned off", offHint: "web.usage_panel: false hides it.",
|
|
35
|
+
trend: "Daily trend", trendTokens: "tokens (nothing here is priced)", others: "Others", heatmap: "Daily totals",
|
|
36
|
+
h_idle: "{0}: read, nothing used", h_gap: "{0}: not collected", h_day: "{0}: {1}", keyGap: "not collected", keyIdle: "read, nothing used", keyUsed: "used (stronger: more tokens)", scale: "Tallest day: {0}", earlier: "← Scroll for earlier days",
|
|
37
|
+
trendLabel: "Daily {0} for the top {1} and others, {2} to {3}.", trendLabelAll: "Daily {0} for {1} agents, {2} to {3}.", heatLabel: "{0} days; {1} with usage, {2} not collected.",
|
|
38
|
+
zoneNote: "Days in {0}, whole hours.", teamsNote: "Teams as configured now.",
|
|
39
|
+
noRows: "Nothing in this range.",
|
|
40
|
+
},
|
|
41
|
+
"zh-TW": {
|
|
42
|
+
range: "期間", today: "今天", r7d: "7 天", month: "本月", all: "全部", custom: "自訂", from: "從", to: "到", apply: "套用",
|
|
43
|
+
group: "分組", g_instance: "Agent", g_backend: "Backend", g_model: "模型", g_profile: "憑證 profile", g_team: "Team(依目前設定)",
|
|
44
|
+
csv: "下載 CSV", basis: "依牌價換算的 API 等值(查證日期 {0})——不是實際花費。訂閱方案不按 token 計費。",
|
|
45
|
+
amount: "API 等值", input: "輸入", output: "輸出", read: "快取讀取", write: "快取寫入", total: "token 總計", requests: "請求數", messages: "訊息數",
|
|
46
|
+
notPriced: "未定價", partial: "部分", noTeam: "(不屬於任何 team)", unknown: "未知",
|
|
47
|
+
f_cache_write_not_reported: "未回報快取寫入(Codex)——是估算", f_not_priced: "未定價:{0}", f_source_unknown: "讀取時尚未記錄 backend 與 profile",
|
|
48
|
+
scanning: "掃描中——總數可能還會增加。", updated: "更新於 {0} · 每 10 分鐘讀取一次", since: "自 {0} 起收集", notYet: "尚未收集",
|
|
49
|
+
refresh: "重新整理",
|
|
50
|
+
notMeasured: "未量測", nm_credit_billed: "Kiro 以 credit 計費,不記錄 token", nm_unsupported: "AgEnD 目前還讀不到這個 CLI 的用量", nm_no_data: "尚未讀到任何資料",
|
|
51
|
+
shared: "與 {0} 共用工作目錄,無法分辨各自的 transcript",
|
|
52
|
+
empty: "還沒有任何紀錄", emptyHint: "用量從 Claude Code 與 Codex 的 transcript 讀取,自 fleet 啟動起計。",
|
|
53
|
+
blocked: "公開連結上不提供", blockedHint: "只有在設定 → 網頁裡開啟「透過公開連結提供用量報告」時,公開連結才會提供用量;這個設定要在執行 AgEnD 的那台電腦上、直接開啟的儀表板裡變更。",
|
|
54
|
+
openSettings: "在那台電腦上開啟設定 → 網頁",
|
|
55
|
+
partialDays: "每日明細從 {0} 起:這段期間更早的日子資料列太多,無法一次送出。上方的各列與總計涵蓋整段期間;趨勢、熱度圖與 CSV 則從 {0} 開始。",
|
|
56
|
+
csvFrom: "下載 CSV(從 {0} 起)",
|
|
57
|
+
noDaily: "這段期間沒有每日明細:連最近的一天,資料列都超過一次回答能帶的數量,而不完整的一天絕不會被當成那一天顯示。上方的各列與總計是完整的;趨勢、熱度圖與 CSV 需要每日明細,所以不顯示。",
|
|
58
|
+
zoneChanged: "fleet 的時區曾經變更:這些日子分別依 {0} 計算。每一天保留它當初計算時的時區,所以跨兩個時區的日子會分別列出各部分(見熱度圖)。之後的日子依 {1} 計算。",
|
|
59
|
+
dayTokens: "{0} 個 token", partNotPriced: "部分未定價",
|
|
60
|
+
trendUnpriced: "未定價,所以不在這些長條裡:{0}。它們的 token 列在各列與熱度圖中。",
|
|
61
|
+
off: "用量已關閉", offHint: "已設定 web.usage_panel: false。",
|
|
62
|
+
trend: "每日趨勢", trendTokens: "token(這裡沒有任何已定價的用量)", others: "其他", heatmap: "每日總計",
|
|
63
|
+
h_idle: "{0}:已讀取,沒有用量", h_gap: "{0}:未收集", h_day: "{0}:{1}", keyGap: "未收集", keyIdle: "已讀取,沒有用量", keyUsed: "有用量(顏色越濃,token 越多)", scale: "最高的一天:{0}", earlier: "← 往左捲動可看較早的日子",
|
|
64
|
+
trendLabel: "前 {1} 名與其他的每日{0},{2} 至 {3}。", trendLabelAll: "{1} 個 agent 的每日{0},{2} 至 {3}。", heatLabel: "{0} 天;{1} 天有用量,{2} 天未收集。",
|
|
65
|
+
zoneNote: "日期依 {0},以整點小時計。", teamsNote: "Team 依目前設定。",
|
|
66
|
+
noRows: "這段期間沒有任何紀錄。",
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
const tu = (k, ...v) => t(`fusage.${k}`, ...v);
|
|
70
|
+
|
|
71
|
+
export const USAGE_RANGES = ["today", "7d", "month", "all", "custom"];
|
|
72
|
+
export const USAGE_GROUPS = ["instance", "backend", "model", "profile", "team"];
|
|
73
|
+
const TOP = 5;
|
|
74
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
75
|
+
const n = (v) => Number(v || 0).toLocaleString("en-US");
|
|
76
|
+
|
|
77
|
+
/** The query for a choice of range and grouping. */
|
|
78
|
+
export function usageQuery({ range, group, from, to }) {
|
|
79
|
+
const q = new URLSearchParams({ range, group });
|
|
80
|
+
if (range === "custom") { q.set("from", from); q.set("to", to); }
|
|
81
|
+
return `/ui/usage-report?${q}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** A row's or a day's amount as shown: the API-equivalent, or why there is none. */
|
|
85
|
+
export function amountText(r) {
|
|
86
|
+
if (r.amount === null || r.amount === undefined) return tu("notPriced");
|
|
87
|
+
return `${money(r.amount)}${r.priced === "partial" ? ` (${tu("partial")})` : ""}`;
|
|
88
|
+
}
|
|
89
|
+
/** A group key as shown: "" is no team, "unknown" says what it is. */
|
|
90
|
+
export function keyText(group, key) {
|
|
91
|
+
if (group === "team" && key === "") return tu("noTeam");
|
|
92
|
+
if (key === "unknown") return tu("unknown");
|
|
93
|
+
return key;
|
|
94
|
+
}
|
|
95
|
+
const writes = (r) => (r.cacheWrite5m || 0) + (r.cacheWrite1h || 0) + (r.cacheWrite || 0);
|
|
96
|
+
function flagText(f, r) {
|
|
97
|
+
if (f === "not_priced") return tu("f_not_priced", (r.unpriced || []).join(", "));
|
|
98
|
+
return ["cache_write_not_reported", "source_unknown"].includes(f) ? tu(`f_${f}`) : f;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── CSV (design §4): the daily rows of this same answer; messages on their own rows (they are per agent, not per model).
|
|
102
|
+
const CSV_COLUMNS = ["date", "zone", "instance", "backend", "model", "tier", "profile", "input", "output", "cache_read", "cache_write_5m", "cache_write_1h", "cache_write", "requests", "messages", "amount", "basis", "flags"];
|
|
103
|
+
/** One CSV field: quoted when it must be, and never read as a formula by a spreadsheet (=, +, -, @, tab, CR first). */
|
|
104
|
+
export function csvField(v) {
|
|
105
|
+
let s = v === null || v === undefined ? "" : String(v);
|
|
106
|
+
if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`;
|
|
107
|
+
return /[",\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
108
|
+
}
|
|
109
|
+
export function usageCsv(report) {
|
|
110
|
+
const lines = [CSV_COLUMNS.join(",")];
|
|
111
|
+
for (const d of report.daily || []) {
|
|
112
|
+
lines.push([d.date, d.zone, d.instance, d.backend, d.model, d.tier, d.profile, d.input, d.output, d.cacheRead, d.cacheWrite5m, d.cacheWrite1h, d.cacheWrite,
|
|
113
|
+
d.requests, "", d.amount === null ? "" : d.amount.toFixed(6), report.basis, (d.flags || []).join(" ")].map(csvField).join(","));
|
|
114
|
+
}
|
|
115
|
+
for (const m of report.messagesDaily || []) {
|
|
116
|
+
lines.push([m.date, report.zone, m.instance, "", "", "", "", "", "", "", "", "", "", "", m.count, "", report.basis, ""].map(csvField).join(","));
|
|
117
|
+
}
|
|
118
|
+
return `${lines.join("\r\n")}\r\n`;
|
|
119
|
+
}
|
|
120
|
+
function download(text, name) {
|
|
121
|
+
const url = URL.createObjectURL(new Blob([text], { type: "text/csv;charset=utf-8" }));
|
|
122
|
+
const a = document.createElement("a"); a.href = url; a.download = name; a.className = "offscreen";
|
|
123
|
+
document.body.appendChild(a); a.click(); a.remove();
|
|
124
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Days: the range's local dates, which of them were read (coverage, §5.5), and what each one holds.
|
|
128
|
+
const shift = (date, k) => new Date(Date.UTC(+date.slice(0, 4), +date.slice(5, 7) - 1, +date.slice(8, 10) + k)).toISOString().slice(0, 10);
|
|
129
|
+
/** The local dates from `from` to `to` (inclusive), at most `max`, the latest kept. */
|
|
130
|
+
export function datesBetween(from, to, max = 400) {
|
|
131
|
+
if (!DATE.test(from) || !DATE.test(to) || from > to) return [];
|
|
132
|
+
const out = [];
|
|
133
|
+
for (let d = to; d >= from && out.length < max; d = shift(d, -1)) out.unshift(d);
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
/** The local dates in `zone` that some coverage interval reaches (hour by hour, as days are made, §5.3). */
|
|
137
|
+
export function coveredDates(coverage, zone) {
|
|
138
|
+
let fmt;
|
|
139
|
+
try { fmt = new Intl.DateTimeFormat("en-CA", { timeZone: zone, year: "numeric", month: "2-digit", day: "2-digit" }); } catch { fmt = new Intl.DateTimeFormat("en-CA", { timeZone: "UTC", year: "numeric", month: "2-digit", day: "2-digit" }); }
|
|
140
|
+
const out = new Set();
|
|
141
|
+
for (const c of coverage || []) for (const [a, b] of c.intervals || []) {
|
|
142
|
+
for (let h = Math.floor(a / 3_600_000) * 3_600_000; h <= b; h += 3_600_000) out.add(fmt.format(new Date(h)));
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
/** Intensity 1–4 for a day's value against the range's non-zero days (quartiles, as tokscale's heatmap); 0 for none. */
|
|
147
|
+
export function levels(values) {
|
|
148
|
+
const nz = values.filter((v) => v > 0).sort((a, b) => a - b);
|
|
149
|
+
const q = (p) => nz[Math.max(0, Math.ceil(p * nz.length) - 1)];
|
|
150
|
+
const cut = nz.length ? [q(0.25), q(0.5), q(0.75)] : [];
|
|
151
|
+
return (v) => (!(v > 0) ? 0 : v <= cut[0] ? 1 : v <= cut[1] ? 2 : v <= cut[2] ? 3 : 4);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const tokensOf = (d) => d.input + d.output + d.cacheRead + d.cacheWrite5m + d.cacheWrite1h + d.cacheWrite;
|
|
155
|
+
/**
|
|
156
|
+
* Each day of the answer: its tokens (whether it was used at all), the priced part of its amount, whether part of it is
|
|
157
|
+
* not priced, and its parts by zone (a day folded before the fleet's timezone changed keeps its own zone, §5.3).
|
|
158
|
+
*/
|
|
159
|
+
export function dayTotals(report) {
|
|
160
|
+
const m = new Map();
|
|
161
|
+
for (const d of report.daily || []) {
|
|
162
|
+
let e = m.get(d.date);
|
|
163
|
+
if (!e) { e = { tokens: 0, amount: 0, unpriced: false, zones: new Map() }; m.set(d.date, e); }
|
|
164
|
+
const t = tokensOf(d);
|
|
165
|
+
e.tokens += t;
|
|
166
|
+
if (d.amount === null) e.unpriced = true; else e.amount += d.amount;
|
|
167
|
+
const z = e.zones.get(d.zone) || { tokens: 0, amount: 0 };
|
|
168
|
+
z.tokens += t; z.amount += d.amount || 0; e.zones.set(d.zone, z);
|
|
169
|
+
}
|
|
170
|
+
return m;
|
|
171
|
+
}
|
|
172
|
+
/** A day in words: its tokens, its API-equivalent where priced, what is not priced, and each zone's part. */
|
|
173
|
+
export function dayText(date, e) {
|
|
174
|
+
const parts = [tu("dayTokens", tokens(e.tokens))];
|
|
175
|
+
if (e.amount > 0) parts.push(money(e.amount));
|
|
176
|
+
if (e.unpriced) parts.push(tu(e.amount > 0 ? "partNotPriced" : "notPriced"));
|
|
177
|
+
const zones = e.zones.size > 1 ? ` (${[...e.zones].map(([z, p]) => `${z}: ${tokens(p.tokens)}`).join(", ")})` : "";
|
|
178
|
+
return tu("h_day", date, parts.join(" · ") + zones);
|
|
179
|
+
}
|
|
180
|
+
/** The zones the answer's days were made in, when they are not all the fleet's current one (§5.3). */
|
|
181
|
+
export function zonesChanged(report) {
|
|
182
|
+
const zones = [...new Set((report.daily || []).map((d) => d.zone))];
|
|
183
|
+
return zones.length > 1 || (zones.length === 1 && zones[0] !== report.zone) ? zones : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** The trend's series: the top `TOP` agents by the measure over the range, then "others"; one value per date. */
|
|
187
|
+
export function trendSeries(report, dates) {
|
|
188
|
+
const priced = report.totals && report.totals.amount !== null && report.totals.amount > 0;
|
|
189
|
+
const measure = (d) => (priced ? d.amount || 0 : d.input + d.output + d.cacheRead + d.cacheWrite5m + d.cacheWrite1h + d.cacheWrite);
|
|
190
|
+
const byAgent = new Map();
|
|
191
|
+
for (const d of report.daily || []) byAgent.set(d.instance, (byAgent.get(d.instance) || 0) + measure(d));
|
|
192
|
+
const ranked = [...byAgent.entries()].filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
|
193
|
+
const top = ranked.slice(0, TOP).map(([k]) => k);
|
|
194
|
+
const names = ranked.length > TOP ? [...top, tu("others")] : top;
|
|
195
|
+
const at = new Map(dates.map((d, i) => [d, i]));
|
|
196
|
+
const values = names.map(() => dates.map(() => 0));
|
|
197
|
+
for (const d of report.daily || []) {
|
|
198
|
+
const i = at.get(d.date);
|
|
199
|
+
if (i === undefined) continue;
|
|
200
|
+
const s = top.indexOf(d.instance);
|
|
201
|
+
values[s >= 0 ? s : names.length - 1][i] += measure(d);
|
|
202
|
+
}
|
|
203
|
+
return { priced, names, values, totals: names.map((_, s) => values[s].reduce((a, b) => a + b, 0)) };
|
|
204
|
+
}
|
|
205
|
+
const fmtMeasure = (priced, v) => (priced ? money(v) : tokens(v));
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A chart wider than its box opens at its latest days (the right end), as a person reads a trend, and says that the
|
|
209
|
+
* earlier days are a scroll away.
|
|
210
|
+
*/
|
|
211
|
+
function useLatestFirst(deps) {
|
|
212
|
+
const ref = useRef(null);
|
|
213
|
+
const [wide, setWide] = useState(false);
|
|
214
|
+
useLayoutEffect(() => {
|
|
215
|
+
const el = ref.current, over = !!el && el.scrollWidth > el.clientWidth;
|
|
216
|
+
if (over) el.scrollLeft = el.scrollWidth;
|
|
217
|
+
setWide(over);
|
|
218
|
+
}, deps);
|
|
219
|
+
return [ref, wide];
|
|
220
|
+
}
|
|
221
|
+
const EarlierHint = ({ wide }) => (wide ? html`<p class="note us-earlier">${tu("earlier")}</p>` : null);
|
|
222
|
+
|
|
223
|
+
function Trend({ report, dates }) {
|
|
224
|
+
const s = useMemo(() => trendSeries(report, dates), [report, dates]);
|
|
225
|
+
const [scroller, wide] = useLatestFirst([report, dates]);
|
|
226
|
+
if (!s.names.length || !dates.length) return null;
|
|
227
|
+
// A value scale on the left (the day with the most, and zero), the bars after it.
|
|
228
|
+
const slot = 14, left = 52, right = 24, W = Math.max(320, left + dates.length * slot + right), H = 170, top = 16, base = 146;
|
|
229
|
+
const sums = dates.map((_, i) => s.values.reduce((a, v) => a + v[i], 0));
|
|
230
|
+
const max = Math.max(...sums, 0) || 1;
|
|
231
|
+
const what = s.priced ? tu("amount") : tu("trendTokens"), others = s.names.length > TOP;
|
|
232
|
+
const label = others ? tu("trendLabel", what, TOP, dates[0], dates[dates.length - 1]) : tu("trendLabelAll", what, s.names.length, dates[0], dates[dates.length - 1]);
|
|
233
|
+
return html`<section class="us-trend card" aria-labelledby="us-trend-h">
|
|
234
|
+
<h3 id="us-trend-h">${tu("trend")} <span class="note">· ${s.priced ? tu("amount") : tu("trendTokens")}</span></h3>
|
|
235
|
+
<p class="note us-scale">${tu("scale", fmtMeasure(s.priced, max))}</p>
|
|
236
|
+
<div class="us-scroll" ref=${scroller}><svg class="us-chart" viewBox=${`0 0 ${W} ${H}`} width=${W} height=${H} role="img" aria-label=${label}>
|
|
237
|
+
<line class="us-grid" x1=${left - 4} x2=${W} y1=${top} y2=${top} /><line class="us-base" x1=${left - 4} x2=${W} y1=${base} y2=${base} />
|
|
238
|
+
<text class="us-axis" x=${left - 8} y=${top + 3} text-anchor="end">${fmtMeasure(s.priced, max)}</text>
|
|
239
|
+
<text class="us-axis" x=${left - 8} y=${base + 3} text-anchor="end">0</text>
|
|
240
|
+
${dates.map((date, i) => {
|
|
241
|
+
let y = base;
|
|
242
|
+
return html`<g key=${date}>${s.values.map((v, k) => {
|
|
243
|
+
const h = v[i] > 0 ? Math.max(1, ((base - top) * v[i]) / max) : 0;
|
|
244
|
+
y -= h;
|
|
245
|
+
return h ? html`<rect key=${k} class=${`us-s${k}`} x=${left + i * slot + 2} y=${y} width=${slot - 4} height=${h}><title>${`${date} · ${s.names[k]}: ${fmtMeasure(s.priced, v[i])}`}</title></rect>` : null;
|
|
246
|
+
})}${(dates.length - 1 - i) % 7 === 0 || dates.length <= 8 ? html`<text class="us-axis" x=${left + i * slot + slot / 2} y=${H - 6} text-anchor="middle">${date.slice(5)}</text>` : null}</g>`;
|
|
247
|
+
})}
|
|
248
|
+
</svg></div>
|
|
249
|
+
<${EarlierHint} wide=${wide} />
|
|
250
|
+
${s.priced && (report.totals.unpriced || []).length ? html`<p class="note us-unpriced">${tu("trendUnpriced", report.totals.unpriced.join(", "))}</p>` : null}
|
|
251
|
+
<ul class="us-legend">${s.names.map((name, k) => html`<li key=${name}><span class=${`us-sw us-s${k}`} aria-hidden="true"></span><span class="mono">${name}</span> <span class="note">${fmtMeasure(s.priced, s.totals[k])}</span></li>`)}</ul>
|
|
252
|
+
</section>`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function Heatmap({ report, dates }) {
|
|
256
|
+
const covered = useMemo(() => coveredDates(report.coverage, report.zone), [report]);
|
|
257
|
+
// Whether a day was used, and how much, is its tokens: a day used only by a model not in the price table is used,
|
|
258
|
+
// never "nothing used" (its title says it is not priced).
|
|
259
|
+
const byDate = useMemo(() => dayTotals(report), [report]);
|
|
260
|
+
const [scroller, wide] = useLatestFirst([report, dates]);
|
|
261
|
+
if (!dates.length) return null;
|
|
262
|
+
const level = levels(dates.map((d) => byDate.get(d)?.tokens || 0));
|
|
263
|
+
// Weeks as columns, Monday first (a date's weekday from its own Y-M-D: no zone involved).
|
|
264
|
+
const dow = (d) => (new Date(`${d}T00:00:00Z`).getUTCDay() + 6) % 7;
|
|
265
|
+
// Weeks as columns under a month row (its short name where a month begins).
|
|
266
|
+
const lead = dow(dates[0]), cell = 16, head = 14, W = (Math.ceil((lead + dates.length) / 7)) * cell + 2, H = head + 7 * cell + 2;
|
|
267
|
+
let used = 0, gaps = 0;
|
|
268
|
+
const months = [];
|
|
269
|
+
const cells = dates.map((d, i) => {
|
|
270
|
+
const e = byDate.get(d), v = e ? e.tokens : 0, k = lead + i, x = Math.floor(k / 7) * cell + 1, y = head + (k % 7) * cell + 1;
|
|
271
|
+
if (i === 0 || d.endsWith("-01")) months.push(html`<text key=${`m${d}`} class="us-axis" x=${x} y=${10}>${new Date(`${d}T12:00:00Z`).toLocaleDateString([], { month: "short", timeZone: "UTC" })}</text>`);
|
|
272
|
+
const state = v > 0 ? `l${level(v)}` : covered.has(d) ? "idle" : "gap";
|
|
273
|
+
if (v > 0) used++; else if (state === "gap") gaps++;
|
|
274
|
+
const title = v > 0 ? dayText(d, e) : state === "idle" ? tu("h_idle", d) : tu("h_gap", d);
|
|
275
|
+
return html`<rect key=${d} class=${`us-h ${state}`} x=${x} y=${y} width=${cell - 3} height=${cell - 3} rx="2"><title>${title}</title></rect>`;
|
|
276
|
+
});
|
|
277
|
+
return html`<section class="us-heat card" aria-labelledby="us-heat-h">
|
|
278
|
+
<h3 id="us-heat-h">${tu("heatmap")}</h3>
|
|
279
|
+
<div class="us-scroll" ref=${scroller}><svg class="us-heatmap" viewBox=${`0 0 ${W} ${H}`} width=${W} height=${H} role="img" aria-label=${tu("heatLabel", dates.length, used, gaps)}>${months}${cells}</svg></div>
|
|
280
|
+
<${EarlierHint} wide=${wide} />
|
|
281
|
+
<p class="note us-heat-key"><span class="us-h-sw gap" aria-hidden="true"></span>${tu("keyGap")} · <span class="us-h-sw idle" aria-hidden="true"></span>${tu("keyIdle")} · <span class="us-h-sw l4" aria-hidden="true"></span>${tu("keyUsed")}</p>
|
|
282
|
+
</section>`;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function Row({ r, group, max, measure }) {
|
|
286
|
+
const share = max > 0 ? Math.max(0.01, measure(r) / max) : 0;
|
|
287
|
+
const meta = [r.backends.join(", "), r.models.join(", "), r.profiles.join(", ")].filter(Boolean).join(" · ");
|
|
288
|
+
return html`<tr class="us-row">
|
|
289
|
+
<th scope="row" class="us-name"><span class="mono">${keyText(group, r.key)}</span>
|
|
290
|
+
<svg class="us-bar" viewBox="0 0 100 6" preserveAspectRatio="none" aria-hidden="true"><rect class="us-bar-bg" width="100" height="6" rx="3" /><rect class="us-bar-fg" width=${Math.round(share * 100)} height="6" rx="3" /></svg>
|
|
291
|
+
${group !== "instance" || meta ? html`<span class="us-meta">${meta}</span>` : null}
|
|
292
|
+
${r.flags.length ? html`<span class="us-flags">${r.flags.map((f) => html`<span key=${f} class="ch-tag">${flagText(f, r)}</span>`)}</span>` : null}</th>
|
|
293
|
+
<td data-label=${tu("amount")} class=${`num${r.amount === null ? " muted" : ""}`}>${amountText(r)}</td>
|
|
294
|
+
<td data-label=${tu("input")} class="num">${tokens(r.input)}</td>
|
|
295
|
+
<td data-label=${tu("output")} class="num">${tokens(r.output)}</td>
|
|
296
|
+
<td data-label=${tu("read")} class="num">${tokens(r.cacheRead)}</td>
|
|
297
|
+
<td data-label=${tu("write")} class="num">${r.flags.includes("cache_write_not_reported") && writes(r) === 0 ? "—" : tokens(writes(r))}</td>
|
|
298
|
+
<td data-label=${tu("total")} class="num">${tokens(r.total)}</td>
|
|
299
|
+
<td data-label=${tu("requests")} class="num">${n(r.requests)}</td>
|
|
300
|
+
<td data-label=${tu("messages")} class="num">${r.messages === null ? "—" : n(r.messages)}</td>
|
|
301
|
+
</tr>`;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The tab's body for one answer of GET /ui/usage-report. */
|
|
305
|
+
export function UsageReport({ report, choice, setChoice, refresh, fetchedAt }) {
|
|
306
|
+
const group = report.group; // what this answer is grouped by (the choice may be on its way)
|
|
307
|
+
const [draft, setDraft] = useState({ from: choice.from || report.range.from, to: choice.to || report.range.to });
|
|
308
|
+
const dates = useMemo(() => {
|
|
309
|
+
const first = report.range.from === "0000-01-01"
|
|
310
|
+
? [...(report.daily || []).map((d) => d.date), ...(report.messagesDaily || []).map((m) => m.date)].sort()[0] || report.range.to
|
|
311
|
+
: report.range.from;
|
|
312
|
+
// Daily detail cut to the latest whole days (too many rows): the charts start where it starts.
|
|
313
|
+
// No daily detail at all (not even the latest day fits): no dates — no chart drawn from a part of a day.
|
|
314
|
+
if (report.dailyTruncated && !report.dailyFrom) return [];
|
|
315
|
+
return datesBetween(report.dailyTruncated && report.dailyFrom > first ? report.dailyFrom : first, report.range.to);
|
|
316
|
+
}, [report]);
|
|
317
|
+
const noDaily = report.dailyTruncated && !report.dailyFrom;
|
|
318
|
+
const zones = zonesChanged(report);
|
|
319
|
+
const csvFrom = report.dailyTruncated ? report.dailyFrom : report.range.kind === "all" ? "all" : report.range.from;
|
|
320
|
+
const rows = report.rows.filter((r) => r.total > 0 || (r.messages || 0) > 0);
|
|
321
|
+
const anyAmount = rows.some((r) => r.amount !== null && r.amount > 0);
|
|
322
|
+
const measure = (r) => (anyAmount ? r.amount || 0 : r.total);
|
|
323
|
+
const max = Math.max(0, ...rows.map(measure));
|
|
324
|
+
const since = (report.coverage || []).flatMap((c) => c.intervals || []).reduce((m, [a]) => Math.min(m, a), Infinity);
|
|
325
|
+
// Nothing at all to show yet: never collected, or nothing in everything collected.
|
|
326
|
+
const empty = !rows.length && (report.range.kind === "all" || !Number.isFinite(since));
|
|
327
|
+
return html`<div class="us">
|
|
328
|
+
<div class="us-bar-top">
|
|
329
|
+
<div class="seg-inline" role="group" aria-label=${tu("range")}>${USAGE_RANGES.map((k) => html`<button key=${k} type="button"
|
|
330
|
+
class=${`btn btn-sm${k === choice.range ? " btn-primary" : ""}`} aria-pressed=${k === choice.range ? "true" : "false"}
|
|
331
|
+
onClick=${() => (k === "custom" ? setChoice({ ...choice, range: "custom", from: draft.from, to: draft.to }) : setChoice({ ...choice, range: k }))}>${tu(k === "7d" ? "r7d" : k)}</button>`)}</div>
|
|
332
|
+
<label class="us-group">${tu("group")} <select value=${choice.group} onChange=${(e) => setChoice({ ...choice, group: e.target.value })}>
|
|
333
|
+
${USAGE_GROUPS.map((g) => html`<option key=${g} value=${g}>${tu(`g_${g}`)}</option>`)}</select></label>
|
|
334
|
+
<button type="button" class="btn btn-sm us-csv" disabled=${noDaily} title=${noDaily ? tu("noDaily") : undefined} onClick=${() => download(usageCsv(report), `agend-usage-${csvFrom}-${report.range.to}.csv`)}>
|
|
335
|
+
<${Icon} name="download" size=${14} />${report.dailyTruncated && !noDaily ? tu("csvFrom", report.dailyFrom) : tu("csv")}</button>
|
|
336
|
+
</div>
|
|
337
|
+
${choice.range === "custom" ? html`<form class="us-custom" onSubmit=${(e) => { e.preventDefault(); if (DATE.test(draft.from) && DATE.test(draft.to) && draft.from <= draft.to) setChoice({ ...choice, ...draft }); }}>
|
|
338
|
+
<label>${tu("from")} <input type="date" value=${draft.from} max=${draft.to} onInput=${(e) => setDraft({ ...draft, from: e.target.value })} /></label>
|
|
339
|
+
<label>${tu("to")} <input type="date" value=${draft.to} min=${draft.from} onInput=${(e) => setDraft({ ...draft, to: e.target.value })} /></label>
|
|
340
|
+
<button type="submit" class="btn btn-sm">${tu("apply")}</button></form>` : null}
|
|
341
|
+
<p class="us-basis" role="note">${tu("basis", report.pricesChecked)}</p>
|
|
342
|
+
${noDaily ? html`<p class="us-partial" role="status">${tu("noDaily")}</p>` : report.dailyTruncated ? html`<p class="us-partial" role="status">${tu("partialDays", report.dailyFrom)}</p>` : null}
|
|
343
|
+
${zones ? html`<p class="us-partial" role="status">${tu("zoneChanged", zones.join(", "), report.zone)}</p>` : null}
|
|
344
|
+
<p class="note us-fresh">${tu("updated", new Date(fetchedAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }))} · ${Number.isFinite(since) ? tu("since", new Date(since).toLocaleDateString()) : tu("notYet")} · ${tu("zoneNote", report.zone)}${group === "team" ? ` ${tu("teamsNote")}` : ""}
|
|
345
|
+
${report.scanning.active && (!report.scanning.caughtUp || report.scanning.pendingBytes > 0) ? html` <span class="us-scan" role="status">${tu("scanning")} <button type="button" class="btn btn-sm" onClick=${refresh}>${tu("refresh")}</button></span>` : null}</p>
|
|
346
|
+
${empty ? html`<${Empty} icon="chart" title=${tu("empty")} hint=${tu("emptyHint")} />` : html`
|
|
347
|
+
<dl class="us-totals card">
|
|
348
|
+
<div class="ch-metric"><dt>${tu("amount")}</dt><dd>${amountText(report.totals)}</dd></div>
|
|
349
|
+
<div class="ch-metric"><dt>${tu("total")}</dt><dd>${tokens(report.totals.total)}</dd></div>
|
|
350
|
+
<div class="ch-metric"><dt>${tu("requests")}</dt><dd>${n(report.totals.requests)}</dd></div>
|
|
351
|
+
<div class="ch-metric"><dt>${tu("messages")}</dt><dd>${report.totals.messages === null ? "—" : n(report.totals.messages)}</dd></div>
|
|
352
|
+
</dl>
|
|
353
|
+
${rows.length ? html`<table class="us-table"><thead><tr><th scope="col">${tu(`g_${group}`)}</th><th scope="col" class="num">${tu("amount")}</th>
|
|
354
|
+
<th scope="col" class="num">${tu("input")}</th><th scope="col" class="num">${tu("output")}</th><th scope="col" class="num">${tu("read")}</th><th scope="col" class="num">${tu("write")}</th>
|
|
355
|
+
<th scope="col" class="num">${tu("total")}</th><th scope="col" class="num">${tu("requests")}</th><th scope="col" class="num">${tu("messages")}</th></tr></thead>
|
|
356
|
+
<tbody>${rows.map((r) => html`<${Row} key=${r.key} r=${r} group=${group} max=${max} measure=${measure} />`)}</tbody></table>`
|
|
357
|
+
: html`<p class="note">${tu("noRows")}</p>`}
|
|
358
|
+
<${Trend} report=${report} dates=${dates} />
|
|
359
|
+
<${Heatmap} report=${report} dates=${dates} />`}
|
|
360
|
+
${report.notMeasured.length || report.shared.length ? html`<section class="us-rest" aria-labelledby="us-rest-h"><h3 id="us-rest-h">${tu("notMeasured")}</h3>
|
|
361
|
+
<ul class="rows">${report.notMeasured.map((m) => html`<li key=${m.name} class="row-item"><span class="grow strong mono">${m.name}</span><span class="muted">${m.backend} · ${tu(`nm_${m.reason}`)}</span></li>`)}
|
|
362
|
+
${report.shared.map((m) => html`<li key=${m.name} class="row-item"><span class="grow strong mono">${m.name}</span><span class="muted">${m.backend} · ${tu("shared", m.with.join(", "))}</span></li>`)}</ul></section>` : null}
|
|
363
|
+
</div>`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* The tab: its own read (a refusal is a state the page explains, not an error): 403 over the public link without the
|
|
368
|
+
* key, 404 with web.usage_panel: false.
|
|
369
|
+
*/
|
|
370
|
+
export function UsageTab({ lease }) {
|
|
371
|
+
const [choice, setChoice] = useState({ range: "7d", group: "instance", from: "", to: "" });
|
|
372
|
+
const [version, setVersion] = useState(0);
|
|
373
|
+
const [state, setState] = useState(null);
|
|
374
|
+
const seq = useRef(0);
|
|
375
|
+
const path = choice.range === "custom" && !(DATE.test(choice.from) && DATE.test(choice.to)) ? null : usageQuery(choice);
|
|
376
|
+
useEffect(() => {
|
|
377
|
+
if (!path) return;
|
|
378
|
+
const mine = ++seq.current;
|
|
379
|
+
(async () => {
|
|
380
|
+
try {
|
|
381
|
+
const r = await lease.fetch(path, { method: "GET", headers: { "Content-Type": "application/json" } });
|
|
382
|
+
if (!lease.current() || mine !== seq.current) return;
|
|
383
|
+
if (r.status === 403 || r.status === 404) {
|
|
384
|
+
const body = await r.json().catch(() => null);
|
|
385
|
+
if (!lease.current() || mine !== seq.current) return; // a newer read (or another page) owns the view
|
|
386
|
+
// Over the public link: where it is turned on — Settings → Web on the dashboard reached directly.
|
|
387
|
+
setState({ blocked: r.status === 403 ? "blocked" : "off", settings: body && typeof body.settings === "string" && /^http:\/\/localhost:\d+\/settings\/web$/.test(body.settings) ? body.settings : null });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
391
|
+
const d = await r.json();
|
|
392
|
+
if (lease.current() && mine === seq.current) setState(d && Array.isArray(d.rows) ? { data: d, at: Date.now() } : { error: true });
|
|
393
|
+
} catch { if (lease.current() && mine === seq.current) setState({ error: true }); }
|
|
394
|
+
})();
|
|
395
|
+
}, [lease, path, version]);
|
|
396
|
+
if (!state) return html`<${Skeleton} lines=${5} />`;
|
|
397
|
+
if (state.blocked) return html`<${Empty} icon=${state.blocked === "blocked" ? "globe" : "chart"} title=${tu(state.blocked)} hint=${tu(`${state.blocked}Hint`)}
|
|
398
|
+
action=${state.settings ? html`<a class="btn btn-primary us-fix" href=${state.settings}>${tu("openSettings")}</a>` : null} />`;
|
|
399
|
+
if (state.error) return html`<${ErrorState} onRetry=${() => setVersion((v) => v + 1)} />`;
|
|
400
|
+
return html`<${UsageReport} report=${state.data} fetchedAt=${state.at} choice=${choice} setChoice=${setChoice} refresh=${() => setVersion((v) => v + 1)} />`;
|
|
401
|
+
}
|
package/dist/ui/panel-chat.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// right, the agent's at full width, and one rounded composer pinned below with attach, Stop reply and Send inside it.
|
|
7
7
|
// The instance's own actions (details, start, stop, restart, delete) are in the header's ⋯ menu, and only the ones
|
|
8
8
|
// that apply to its state are offered (#1408 rough edges 4 and 7).
|
|
9
|
-
import { html, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-html.js";
|
|
9
|
+
import { html, useCallback, useEffect, useLayoutEffect, useRef, useState } from "/assets/app-html.js";
|
|
10
10
|
import { t } from "/assets/app-i18n.js";
|
|
11
11
|
import { appStore, guestPage, useStore } from "/assets/app-store.js";
|
|
12
12
|
import { AuthorizeChip } from "/assets/web-authorize.js";
|
|
@@ -57,6 +57,7 @@ import { installTour, refreshTourSpot } from "./chat-tour.js";
|
|
|
57
57
|
import { LightboxHost, openLightbox } from "./image-lightbox.js";
|
|
58
58
|
import { ChatEffectPosters } from "/ui/js/chat-effect-posters.js";
|
|
59
59
|
import { ChatEffects, chatEffectsMenuAvailable } from "./chat-effects.js";
|
|
60
|
+
import { setFollowsReducedMotion } from "/assets/fx-motion.js";
|
|
60
61
|
import { AmbientMotionNote, ChatAmbient, ambientMenuItems } from "./chat-ambient.js";
|
|
61
62
|
import { startAppEffects, getAppEffects } from "./app-effects.js";
|
|
62
63
|
|
|
@@ -225,6 +226,9 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
|
|
|
225
226
|
const themeKey = JSON.stringify(theme);
|
|
226
227
|
const card = useCard(name, inst.card_v);
|
|
227
228
|
const [dialog, setDialog] = useState(null); // "delete" | null
|
|
229
|
+
const [canvasPlayback, setCanvasPlayback] = useState(null);
|
|
230
|
+
const publishCanvasPlayback = useCallback(value => setCanvasPlayback(previous =>
|
|
231
|
+
!previous && !value.playing || previous?.name === value.name && previous.playing === value.playing ? previous : value), []);
|
|
228
232
|
const [wrap, setWrap] = useState(codeWrap);
|
|
229
233
|
const view = useRef(null), split = useRef(null), thread = useRef(null), bg = useRef(null), effectSurface = useRef(null);
|
|
230
234
|
useLayoutEffect(() => { applyTheme(view.current, theme, mode); }, [themeKey, mode]);
|
|
@@ -292,14 +296,14 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
|
|
|
292
296
|
${inst.model ? html`<button type="button" class="hd-chip" title=${t("chat.chipModel", modelText(inst))} aria-label=${t("chat.chipModel", modelText(inst))} onClick=${() => pick("model")}>${modelText(inst)}</button>` : null}
|
|
293
297
|
${inst.effort ? html`<button type="button" class="hd-chip" title=${t("chat.chipEffort", inst.effort)} aria-label=${t("chat.chipEffort", inst.effort)} onClick=${() => pick("effort")}>${inst.effort}</button>` : null}`;
|
|
294
298
|
return html`<div class=${`panel p-chat${wrap ? " wrap-code" : ""}`} ref=${view}>
|
|
295
|
-
<${PanelHeader} title=${headerName(namesOf(inst || { name }, names.primary))} sub=${sub} nav=${guest ? null : html`<${InstanceSwitch} name=${name} current="chat" />`} notice=${html`<${ChatEffects} name=${name} lease=${lease} element=${effectSurface} store=${store} open=${dialog === "effects"} onClose=${() => setDialog(null)} />`}><${ChatEffectPosters} name=${name} lease=${lease} element=${effectSurface} /><${HeaderTools} navKey=${navKey} />${guest ? null : html`<${Menu} items=${items} label=${t("app.more")} />`}</${PanelHeader}>
|
|
299
|
+
<${PanelHeader} title=${headerName(namesOf(inst || { name }, names.primary))} sub=${sub} nav=${guest ? null : html`<${InstanceSwitch} name=${name} current="chat" />`} notice=${html`<${ChatEffects} name=${name} lease=${lease} element=${effectSurface} store=${store} open=${dialog === "effects"} onPlayback=${publishCanvasPlayback} onClose=${() => setDialog(null)} />`}><${ChatEffectPosters} name=${name} lease=${lease} element=${effectSurface} /><${HeaderTools} navKey=${navKey} />${guest ? null : html`<${Menu} items=${items} label=${t("app.more")} />`}</${PanelHeader}>
|
|
296
300
|
<${ChatCard} key=${name} name=${name} card=${card} />
|
|
297
301
|
<${AgentWidget} slot="chat_header" widget=${widgetFor(name, "chat_header")} binds=${instanceBinds(inst, exec, awaiting)} />
|
|
298
302
|
<div class="chat-split" ref=${split}>
|
|
299
303
|
<div ref=${effectSurface} class=${`chat-main${theme && theme.background ? " has-th-bg" : ""}`}>
|
|
300
304
|
${theme && theme.background ? html`<img ref=${bg} class=${`th-bg th-bg-${theme.background.fit}`} src=${themeImageUrl(name, theme.background.image)} alt="" aria-hidden="true" decoding="async" />` : null}
|
|
301
305
|
<${ChatAmbient} name=${name} surface=${effectSurface} part="layer" />
|
|
302
|
-
<${AmbientMotionNote} name=${name} />
|
|
306
|
+
<${AmbientMotionNote} name=${name} canvasPlaying=${canvasPlayback?.name === name && canvasPlayback.playing} />
|
|
303
307
|
<${Thread} name=${name} th=${thread} />
|
|
304
308
|
<${Dock} name=${name} inst=${inst} lease=${lease} exec=${exec} awaiting=${awaiting} workingLabel=${theme ? theme.workingLabel : undefined} />
|
|
305
309
|
<${ChatAmbient} name=${name} surface=${effectSurface} part="front" />
|
|
@@ -325,7 +329,8 @@ function Thread({ name, th }) {
|
|
|
325
329
|
setPreviewOptIn, copyText, download: downloadHtml, toggleWrap,
|
|
326
330
|
effectTrialStatus: x => getAppEffects()?.trialStatus(x) ?? "localAdmin",
|
|
327
331
|
fixEffectTrial: (x, reason) => {
|
|
328
|
-
if (reason === "
|
|
332
|
+
if (reason === "reducedMotion") setFollowsReducedMotion(false);
|
|
333
|
+
else if (reason === "busy") getAppEffects()?.stopTrial();
|
|
329
334
|
else if (reason === "transport") location.reload();
|
|
330
335
|
else if (reason === "localAdmin") location.href = "/signin?next=" + encodeURIComponent(location.pathname + location.search);
|
|
331
336
|
else navigate("/settings/appearance");
|
package/dist/ui/panel-fleet.js
CHANGED
|
@@ -21,6 +21,7 @@ import { confirmedWrite } from "./settings-confirm.js";
|
|
|
21
21
|
import { OrgCanvas } from "./fleet-canvas.js";
|
|
22
22
|
import { labelOf } from "/assets/instance-nav.js";
|
|
23
23
|
import { CacheReport } from "./fleet-cache.js";
|
|
24
|
+
import { UsageTab } from "./fleet-usage.js";
|
|
24
25
|
import { BackendSelect, ModelSelect } from "./settings-dialogs.js";
|
|
25
26
|
|
|
26
27
|
/** #2015: one of the largest processes as text: "python3 18.1 GB (dev-a)". */
|
|
@@ -36,7 +37,7 @@ register("fleet", {
|
|
|
36
37
|
hostMemory_elevated: "Host memory pressure: elevated — new agents start one at a time, at least 5 s apart",
|
|
37
38
|
hostMemory_critical: "Host memory pressure: critical — new agents wait for memory to recover",
|
|
38
39
|
hostMemory_top: "Largest: {0}", hostMemory_ownerFleet: "the fleet", hostMemory_ownerTmux: "tmux", hostMemory_ownerOther: "not an instance",
|
|
39
|
-
title: "Fleet", tasks: "Tasks", schedules: "Schedules", teams: "Teams", org: "Org chart", cache: "Cache", config: "Config",
|
|
40
|
+
title: "Fleet", tasks: "Tasks", schedules: "Schedules", teams: "Teams", org: "Org chart", cache: "Cache", usage: "Usage", config: "Config",
|
|
40
41
|
docs: "Docs", docsNewTab: "Docs (opens in a new tab)",
|
|
41
42
|
newTask: "New task", newSchedule: "New schedule", newTeam: "New team",
|
|
42
43
|
noTasks: "No tasks yet", noTasksHint: "Create one to coordinate fleet work.", noSchedules: "No schedules yet", noSchedulesHint: "Automate recurring work with cron.",
|
|
@@ -68,7 +69,7 @@ register("fleet", {
|
|
|
68
69
|
hostMemory_elevated: "主機記憶體壓力偏高:新的 agent 一次啟動一個,至少間隔 5 秒",
|
|
69
70
|
hostMemory_critical: "主機記憶體壓力嚴重:新的 agent 會等記憶體恢復再啟動",
|
|
70
71
|
hostMemory_top: "最大:{0}", hostMemory_ownerFleet: "fleet 本身", hostMemory_ownerTmux: "tmux", hostMemory_ownerOther: "不屬於任何 instance",
|
|
71
|
-
title: "Fleet", tasks: "Tasks", schedules: "排程", teams: "Teams", org: "組織圖", cache: "快取", config: "設定",
|
|
72
|
+
title: "Fleet", tasks: "Tasks", schedules: "排程", teams: "Teams", org: "組織圖", cache: "快取", usage: "用量", config: "設定",
|
|
72
73
|
docs: "文件", docsNewTab: "文件(在新分頁開啟)",
|
|
73
74
|
newTask: "新增 task", newSchedule: "新增排程", newTeam: "新增 team",
|
|
74
75
|
noTasks: "還沒有 task", noTasksHint: "建立一個來協調 fleet 的工作。", noSchedules: "還沒有排程", noSchedulesHint: "用 cron 自動執行例行工作。",
|
|
@@ -102,7 +103,7 @@ const DOCS_SITE = "https://songsid.github.io/AgEnD";
|
|
|
102
103
|
export function docsUrl(language = lang()) {
|
|
103
104
|
return `${DOCS_SITE}${language === "zh-TW" ? "/zh-tw" : ""}/docs/web-guide/`;
|
|
104
105
|
}
|
|
105
|
-
const ICONS = { tasks: "tasks", schedules: "clock", teams: "team", org: "org", cache: "chart", config: "sliders" };
|
|
106
|
+
const ICONS = { tasks: "tasks", schedules: "clock", teams: "team", org: "org", cache: "chart", usage: "chart", config: "sliders" };
|
|
106
107
|
const BACKENDS = ["claude-code", "codex", "opencode", "kiro-cli", "antigravity", "grok", "muse"];
|
|
107
108
|
|
|
108
109
|
async function api(method, path, body, lease) {
|
|
@@ -141,7 +142,7 @@ export function FleetPanel({ route, navKey }) {
|
|
|
141
142
|
const lease = useLease(navKey);
|
|
142
143
|
const tab = route.tab;
|
|
143
144
|
useEffect(() => { setTitle(`${t("fleet.title")} · ${t(`fleet.${tab}`)}`); }, [tab, navKey]);
|
|
144
|
-
const Body = { tasks: Tasks, schedules: Schedules, teams: Teams, org: Org, cache: Cache, config: Config }[tab] || Tasks;
|
|
145
|
+
const Body = { tasks: Tasks, schedules: Schedules, teams: Teams, org: Org, cache: Cache, usage: UsageTab, config: Config }[tab] || Tasks;
|
|
145
146
|
// #1685: host memory pressure slowing or holding new agents — shown here, where macOS's routine "elevated" is not a chat notice.
|
|
146
147
|
const { hostMemory, hostMemoryTop } = useStore(appStore);
|
|
147
148
|
// #2015: and which processes hold the most, and whose — text only (never chat, #1712).
|
|
@@ -151,7 +152,7 @@ export function FleetPanel({ route, navKey }) {
|
|
|
151
152
|
<${PanelHeader} title=${t("fleet.title")} sub=${sub}><${DocsLink} /><${HeaderTools} navKey=${navKey} /></${PanelHeader}>
|
|
152
153
|
<nav data-trust="panel-navigation" class="seg" aria-label=${t("fleet.title")}>${FLEET_TABS.map(k => html`<a key=${k} href=${fleetPath(k)} class=${`seg-item${k === tab ? " active" : ""}`}
|
|
153
154
|
aria-current=${k === tab ? "page" : undefined}><${Icon} name=${ICONS[k]} size=${16} /><span>${t(`fleet.${k}`)}</span></a>`)}</nav>
|
|
154
|
-
<div data-trust="panel-content" class="panel-body"><div class=${`col${tab === "org" || tab === "cache" ? " col-wide" : ""}`}><${Body} lease=${lease} /></div></div>
|
|
155
|
+
<div data-trust="panel-content" class="panel-body"><div class=${`col${tab === "org" || tab === "cache" || tab === "usage" ? " col-wide" : ""}`}><${Body} lease=${lease} /></div></div>
|
|
155
156
|
</div>`;
|
|
156
157
|
}
|
|
157
158
|
|
|
@@ -779,13 +779,14 @@ function WebSection({ ctx, staged }) {
|
|
|
779
779
|
const original = useMemo(() => ({
|
|
780
780
|
echo: web.echo_to_channel ?? true,
|
|
781
781
|
log: web.log_tab === true, // #1218: off by default
|
|
782
|
+
usagePub: web.usage_report_on_public_link === true, // #2122: off by default
|
|
782
783
|
pub: { allow_public: (web.public_link && web.public_link.allow_public) ?? true, ttl_minutes: (web.public_link && web.public_link.ttl_minutes) ?? 120, protocol: (web.public_link && web.public_link.protocol) ?? "http2" },
|
|
783
784
|
session: { absolute_hours: local.absolute_hours ?? null, idle_hours: local.idle_hours ?? null },
|
|
784
785
|
terminal: { enabled: term.enabled ?? null, ttl_minutes: term.ttl_minutes ?? null },
|
|
785
786
|
}), []);
|
|
786
787
|
const text = (v) => (v === null || v === undefined ? "" : String(v));
|
|
787
788
|
const [f, setF] = useState(() => ({
|
|
788
|
-
echo: original.echo, log: original.log, pub: original.pub.allow_public, ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol,
|
|
789
|
+
echo: original.echo, log: original.log, usagePub: original.usagePub, pub: original.pub.allow_public, ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol,
|
|
789
790
|
abs: text(original.session.absolute_hours), idle: text(original.session.idle_hours),
|
|
790
791
|
termOn: original.terminal.enabled !== false, termTtl: text(original.terminal.ttl_minutes),
|
|
791
792
|
}));
|
|
@@ -814,6 +815,7 @@ function WebSection({ ctx, staged }) {
|
|
|
814
815
|
const body = {
|
|
815
816
|
...(f.echo !== original.echo ? { echo_to_channel: f.echo } : {}),
|
|
816
817
|
...(f.log !== original.log ? { log_tab: f.log } : {}),
|
|
818
|
+
...(f.usagePub !== original.usagePub ? { usage_report_on_public_link: f.usagePub } : {}),
|
|
817
819
|
...(Object.keys(publicPatch).length ? { public_link: publicPatch } : {}),
|
|
818
820
|
...(Object.keys(sessionPatch).length ? { session: { local: sessionPatch } } : {}),
|
|
819
821
|
...(Object.keys(terminalPatch).length ? { web_terminal: terminalPatch } : {}),
|
|
@@ -821,6 +823,7 @@ function WebSection({ ctx, staged }) {
|
|
|
821
823
|
const fields = [
|
|
822
824
|
...(body.echo_to_channel !== undefined ? ["web.echo_to_channel"] : []),
|
|
823
825
|
...(body.log_tab !== undefined ? ["web.log_tab"] : []),
|
|
826
|
+
...(body.usage_report_on_public_link !== undefined ? ["web.usage_report_on_public_link"] : []),
|
|
824
827
|
...Object.keys(publicPatch).map((k) => `web.public_link.${k}`),
|
|
825
828
|
...Object.keys(sessionPatch).map((k) => `web.session.local.${k}`),
|
|
826
829
|
...Object.keys(terminalPatch).map((k) => `web_terminal.${k}`),
|
|
@@ -862,6 +865,8 @@ function WebSection({ ctx, staged }) {
|
|
|
862
865
|
<p class="note">${tn("webEchoHint")}</p>
|
|
863
866
|
<label class="check"><input id="webLogTab" type="checkbox" checked=${f.log} onChange=${(e) => set("log")(e.target.checked)} /><span>${tn("webLogTab")}</span><${Impact} schema=${schema} field="web.log_tab" /></label>
|
|
864
867
|
<p class="note">${tn("webLogTabHint")}</p>
|
|
868
|
+
<label class="check"><input id="webUsagePublic" type="checkbox" checked=${f.usagePub} onChange=${(e) => set("usagePub")(e.target.checked)} /><span>${tn("webUsagePublic")}</span><${Impact} schema=${schema} field="web.usage_report_on_public_link" /></label>
|
|
869
|
+
<p class="note">${tn("webUsagePublicHint")}</p>
|
|
865
870
|
<label class="check"><input id="publicWebLink" type="checkbox" checked=${f.pub} onChange=${(e) => set("pub")(e.target.checked)} /><span>${tn("publicLink")}</span><${Impact} schema=${schema} field="web.public_link.allow_public" /></label>
|
|
866
871
|
<p class="note">${tn("publicLinkHint")}</p>
|
|
867
872
|
<div class="grid2">
|
|
@@ -39,7 +39,7 @@ export function OwnerEffects({ catalog, busy = false }) {
|
|
|
39
39
|
useEffect(() => { life.current.alive = true; const stop = watchOwnerEffects(); return () => { life.current.alive = false; life.current.seq++; stop(); }; }, []);
|
|
40
40
|
useEffect(() => {
|
|
41
41
|
let live = true; const entry = getAppEffects();
|
|
42
|
-
const pull = () => { if (!live) return; const gates = entry?.gates(); setRuntime({ entry, snapshot: entry?.api?.ownerSnapshot?.(), cursor: entry?.cursorSnapshot?.(), available: !!entry && !entry.dead && gates.enabled && gates.show && gates.transportReady && !gates.safe && !gates.publicLink && !gates.reducedMotion
|
|
42
|
+
const pull = () => { if (!live) return; const gates = entry?.gates(); setRuntime({ entry, snapshot: entry?.api?.ownerSnapshot?.(), cursor: entry?.cursorSnapshot?.(), available: !!entry && !entry.dead && gates.enabled && gates.show && gates.transportReady && !gates.safe && !gates.publicLink && !gates.reducedMotion }); };
|
|
43
43
|
const off = entry?.subscribe(pull), end = entry?.onEnd(pull); pull();
|
|
44
44
|
return () => { live = false; off?.(); end?.(); };
|
|
45
45
|
}, []);
|
|
@@ -15,6 +15,7 @@ register("settings", {
|
|
|
15
15
|
publicProtocol: "Tunnel protocol",
|
|
16
16
|
webEcho: "Web echo",
|
|
17
17
|
webEchoHint: "Copies web messages to the bound Telegram or Discord topic. ClassicBot rooms follow it unless a room sets its own.",
|
|
18
|
+
webUsagePublic: "Usage report over the public link", webUsagePublicHint: "Fleet → Usage (up to 400 days of each agent's tokens, requests, messages and API-equivalent amounts) is offered over the temporary public link too. Off by default; guests never see it. Applies at once.",
|
|
18
19
|
webLogTab: "Log tab: each agent's steps", webLogTabHint: "Shows each instance's prompts, replies, tool calls and results, and fleet events in its Log tab, read from its own transcript only while the tab is open. Secret values are masked. Also over the public link. Turning it on waits for a fleet admin's confirmation.",
|
|
19
20
|
general: "General Settings",
|
|
20
21
|
advanced: "Advanced",
|
|
@@ -562,6 +563,7 @@ register("settings", {
|
|
|
562
563
|
publicProtocol: "Tunnel 協定",
|
|
563
564
|
webEcho: "Web 同步",
|
|
564
565
|
webEchoHint: "將網頁訊息複製到綁定的 Telegram 或 Discord 主題。ClassicBot 聊天室也照這個設定,除非該聊天室另外設定。",
|
|
566
|
+
webUsagePublic: "透過公開連結提供用量報告", webUsagePublicHint: "Fleet → 用量(最多 400 天、每個 agent 的 token、請求、訊息與依牌價換算的 API 等值)也會透過臨時公開連結提供。預設關閉;訪客一律看不到。變更立即生效。",
|
|
565
567
|
webLogTab: "紀錄分頁:每個 agent 的步驟", webLogTabHint: "在每個 instance 的「紀錄」分頁顯示提示、回覆、工具呼叫與結果,以及 fleet 事件;只在分頁開著時從它自己的對話紀錄讀取。機密值會遮蔽。公開連結也看得到。開啟需 fleet admin 確認。",
|
|
566
568
|
general: "一般設定",
|
|
567
569
|
advanced: "進階",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// #1408 §3: the app's routes on the client. The same table as the server's classifier (src/web-shell-routes.ts;
|
|
2
2
|
// a test runs both over one list of cases). Pure: no DOM, no history.
|
|
3
3
|
|
|
4
|
-
export const FLEET_TABS = ["tasks", "schedules", "teams", "org", "cache", "config"];
|
|
4
|
+
export const FLEET_TABS = ["tasks", "schedules", "teams", "org", "cache", "usage", "config"];
|
|
5
5
|
export const SETTINGS_SECTIONS = ["device", "appearance", "agents", "bots", "classic", "general", "web", "advanced"]; // #1604: device first
|
|
6
6
|
const MAX_INSTANCE_NAME = 128;
|
|
7
7
|
const PATH_SEPARATOR_OR_CONTROL = /[/\\\u0000-\u001f\u007f]/;
|