@songsid/agend 2.3.0-beta.6 → 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/agent-cli-instructions.md +1 -1
- package/dist/agent-cli.js +21 -2
- package/dist/agent-cli.js.map +1 -1
- package/dist/agent-endpoint.js +4 -0
- package/dist/agent-endpoint.js.map +1 -1
- package/dist/agent-file-read.js +68 -8
- package/dist/agent-file-read.js.map +1 -1
- package/dist/agent-whats-new.js +4 -0
- package/dist/agent-whats-new.js.map +1 -1
- package/dist/auth-api.js +3 -1
- package/dist/auth-api.js.map +1 -1
- package/dist/authority/authority-state.d.ts +38 -0
- package/dist/authority/authority-state.js +114 -0
- package/dist/authority/authority-state.js.map +1 -0
- package/dist/authority/session-authorizations.d.ts +43 -0
- package/dist/authority/session-authorizations.js +102 -0
- package/dist/authority/session-authorizations.js.map +1 -0
- package/dist/authority/web-authority.d.ts +31 -11
- package/dist/authority/web-authority.js +39 -12
- package/dist/authority/web-authority.js.map +1 -1
- package/dist/cache-ledger.d.ts +16 -3
- package/dist/cache-ledger.js +33 -13
- package/dist/cache-ledger.js.map +1 -1
- package/dist/cache-service.d.ts +55 -3
- package/dist/cache-service.js +152 -16
- package/dist/cache-service.js.map +1 -1
- package/dist/channel/mcp-tools.js +13 -7
- package/dist/channel/mcp-tools.js.map +1 -1
- package/dist/classic-channel-manager.d.ts +2 -2
- package/dist/classic-channel-manager.js +2 -6
- package/dist/classic-channel-manager.js.map +1 -1
- package/dist/config-validator.js +10 -4
- package/dist/config-validator.js.map +1 -1
- package/dist/daemon.d.ts +2 -0
- package/dist/daemon.js +31 -9
- package/dist/daemon.js.map +1 -1
- package/dist/delivery-outbox.d.ts +8 -0
- package/dist/delivery-outbox.js +7 -1
- package/dist/delivery-outbox.js.map +1 -1
- package/dist/fleet-manager.d.ts +109 -2
- package/dist/fleet-manager.js +280 -12
- package/dist/fleet-manager.js.map +1 -1
- package/dist/general-knowledge/skills/cross-instance-messaging/SKILL.md +2 -1
- package/dist/general-knowledge/skills/multi-channel/SKILL.md +2 -1
- package/dist/general-knowledge/skills/web-appearance/SKILL.md +89 -30
- package/dist/general-knowledge/skills/web-dashboard/SKILL.md +62 -8
- package/dist/general-knowledge/skills/worker-collaboration/SKILL.md +1 -1
- package/dist/human-delivery-receipts.d.ts +14 -0
- package/dist/human-delivery-receipts.js +42 -6
- package/dist/human-delivery-receipts.js.map +1 -1
- package/dist/instance-config-impact.js +1 -0
- package/dist/instance-config-impact.js.map +1 -1
- package/dist/instance-names.d.ts +4 -0
- package/dist/instance-names.js +12 -0
- package/dist/instance-names.js.map +1 -0
- package/dist/instructions.d.ts +8 -1
- package/dist/instructions.js +9 -2
- package/dist/instructions.js.map +1 -1
- package/dist/locale.js +52 -0
- package/dist/locale.js.map +1 -1
- package/dist/outbound-handlers.d.ts +6 -0
- package/dist/outbound-handlers.js +49 -2
- package/dist/outbound-handlers.js.map +1 -1
- package/dist/outbound-schemas.d.ts +57 -1
- package/dist/outbound-schemas.js +34 -2
- package/dist/outbound-schemas.js.map +1 -1
- package/dist/public-web-gateway.js +8 -2
- package/dist/public-web-gateway.js.map +1 -1
- package/dist/rollout-index.d.ts +5 -0
- package/dist/rollout-index.js +26 -5
- package/dist/rollout-index.js.map +1 -1
- package/dist/settings-api.d.ts +6 -0
- package/dist/settings-api.js +54 -1
- package/dist/settings-api.js.map +1 -1
- package/dist/settings-change.js +31 -0
- package/dist/settings-change.js.map +1 -1
- package/dist/settings-effect.js +10 -1
- package/dist/settings-effect.js.map +1 -1
- package/dist/tool-permissions.d.ts +0 -7
- package/dist/tool-permissions.js +73 -5
- package/dist/tool-permissions.js.map +1 -1
- package/dist/topic-commands.d.ts +2 -1
- package/dist/topic-commands.js +2 -4
- package/dist/topic-commands.js.map +1 -1
- package/dist/types.d.ts +13 -2
- package/dist/ui/app-effects.js +46 -28
- package/dist/ui/chat-ambient.js +307 -0
- package/dist/ui/chat-effect-posters.js +8 -8
- package/dist/ui/chat-effects.js +51 -22
- package/dist/ui/chat-strings.js +21 -6
- package/dist/ui/chat-thread.js +1 -1
- package/dist/ui/fleet-usage.js +401 -0
- package/dist/ui/panel-chat.js +22 -9
- package/dist/ui/panel-fleet.js +6 -5
- package/dist/ui/panel-settings.js +10 -2
- package/dist/ui/settings-appearance.js +8 -0
- package/dist/ui/settings-mods.js +19 -6
- package/dist/ui/settings-owner-effects.js +3 -3
- package/dist/ui/settings-strings.js +30 -0
- package/dist/ui/settings-web-authority.js +91 -0
- package/dist/ui/shared/app-route.js +1 -1
- package/dist/ui/shared/app-session.js +2 -0
- package/dist/ui/shared/app-shell.js +2 -1
- package/dist/ui/shared/app-themes.js +24 -6
- package/dist/ui/shared/app.css +95 -0
- package/dist/ui/shared/effect-app-runtime.js +68 -19
- package/dist/ui/shared/effect-chat-runtime.js +16 -3
- package/dist/ui/shared/effect-frame.js +17 -3
- package/dist/ui/shared/effect-policy.js +18 -5
- package/dist/ui/shared/effect-profiler.js +31 -9
- package/dist/ui/shared/effect-registry.js +17 -41
- package/dist/ui/shared/effect-stock-trust.js +4 -4
- package/dist/ui/shared/effect-trial-stage.js +18 -7
- package/dist/ui/shared/fx-motion.js +67 -0
- package/dist/ui/shared/usage-dialog.js +8 -0
- package/dist/ui/shared/view-strings.js +4 -0
- package/dist/ui/shared/web-authorize.js +151 -0
- package/dist/usage-report.d.ts +158 -0
- package/dist/usage-report.js +226 -0
- package/dist/usage-report.js.map +1 -0
- package/dist/usage-rollup.d.ts +51 -0
- package/dist/usage-rollup.js +168 -0
- package/dist/usage-rollup.js.map +1 -0
- package/dist/web-ambient.d.ts +72 -0
- package/dist/web-ambient.js +154 -0
- package/dist/web-ambient.js.map +1 -0
- package/dist/web-api.d.ts +33 -0
- package/dist/web-api.js +148 -18
- package/dist/web-api.js.map +1 -1
- package/dist/web-appearance-tools.d.ts +3 -0
- package/dist/web-appearance-tools.js +18 -3
- package/dist/web-appearance-tools.js.map +1 -1
- package/dist/web-appearance-view.d.ts +14 -2
- package/dist/web-appearance-view.js +32 -5
- package/dist/web-appearance-view.js.map +1 -1
- package/dist/web-appearance.d.ts +15 -1
- package/dist/web-appearance.js +77 -1
- package/dist/web-appearance.js.map +1 -1
- package/dist/web-asset-parts.d.ts +2 -2
- package/dist/web-asset-parts.js +3 -3
- package/dist/web-asset-parts.js.map +1 -1
- package/dist/web-auth.js +3 -1
- package/dist/web-auth.js.map +1 -1
- package/dist/web-effect-bootstrap.d.ts +12 -0
- package/dist/web-effect-bootstrap.js +49 -0
- package/dist/web-effect-bootstrap.js.map +1 -0
- package/dist/web-effect-bundle.js +46 -1
- package/dist/web-effect-bundle.js.map +1 -1
- package/dist/web-effect-playback.d.ts +30 -0
- package/dist/web-effect-playback.js +71 -0
- package/dist/web-effect-playback.js.map +1 -0
- package/dist/web-effect-profiles.d.ts +4 -4
- package/dist/web-effect-service.d.ts +11 -4
- package/dist/web-effect-service.js +25 -4
- package/dist/web-effect-service.js.map +1 -1
- package/dist/web-effect-tools.d.ts +1 -0
- package/dist/web-effect-tools.js +2 -1
- package/dist/web-effect-tools.js.map +1 -1
- package/dist/web-effect-worker.d.ts +1 -1
- package/dist/web-effect-worker.js +2 -2
- package/dist/web-effect-worker.js.map +1 -1
- package/dist/web-guest-access.js +3 -1
- package/dist/web-guest-access.js.map +1 -1
- package/dist/web-mod-agent.js +5 -1
- package/dist/web-mod-agent.js.map +1 -1
- package/dist/web-mod-bundle.d.ts +9 -1
- package/dist/web-mod-bundle.js +62 -3
- package/dist/web-mod-bundle.js.map +1 -1
- package/dist/web-mod-export.d.ts +9 -0
- package/dist/web-mod-export.js +21 -0
- package/dist/web-mod-export.js.map +1 -1
- package/dist/web-mod-store.d.ts +2 -1
- package/dist/web-mod-store.js +13 -6
- package/dist/web-mod-store.js.map +1 -1
- package/dist/web-session-authorization.d.ts +132 -0
- package/dist/web-session-authorization.js +193 -0
- package/dist/web-session-authorization.js.map +1 -0
- package/dist/web-session.d.ts +5 -0
- package/dist/web-session.js +16 -0
- package/dist/web-session.js.map +1 -1
- package/dist/web-settings-patch.d.ts +26 -2
- package/dist/web-settings-patch.js +50 -1
- package/dist/web-settings-patch.js.map +1 -1
- package/dist/web-settings-tools.d.ts +8 -0
- package/dist/web-settings-tools.js +14 -1
- package/dist/web-settings-tools.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/dist/web-theme-store.d.ts +39 -0
- package/dist/web-theme-store.js +122 -11
- package/dist/web-theme-store.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,9 +6,10 @@
|
|
|
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
|
+
import { AuthorizeChip } from "/assets/web-authorize.js";
|
|
12
13
|
import { useLease } from "/assets/app-ctx.js";
|
|
13
14
|
import { modelText, namesOf, namesStore } from "/assets/instance-nav.js";
|
|
14
15
|
import { PanelHeader, onPanelKey, setTitle, statusClass, statusLabel, openDrawer, requestNewInstance } from "/assets/app-shell.js";
|
|
@@ -23,7 +24,7 @@ import { Icon } from "/assets/ui-icons.js";
|
|
|
23
24
|
import { toast } from "/assets/ui-toast.js";
|
|
24
25
|
import { confirmDialog } from "/assets/ui-confirm.js";
|
|
25
26
|
import { savePref } from "/assets/app-prefs.js";
|
|
26
|
-
import { applyTheme, resolvedMode, themeFor, themeImageUrl, themeMenuItems, themesStore } from "/assets/app-themes.js";
|
|
27
|
+
import { applyTheme, clearAmbient, resolvedMode, themeFor, themeImageUrl, themeMenuItems, themesStore } from "/assets/app-themes.js";
|
|
27
28
|
import { colorMenuItem } from "/assets/identity-color.js";
|
|
28
29
|
import { IdentityCard, useCard } from "/assets/identity-card.js";
|
|
29
30
|
import { AgentWidget, instanceBinds, widgetFor } from "/assets/agent-widget.js";
|
|
@@ -56,6 +57,8 @@ import { installTour, refreshTourSpot } from "./chat-tour.js";
|
|
|
56
57
|
import { LightboxHost, openLightbox } from "./image-lightbox.js";
|
|
57
58
|
import { ChatEffectPosters } from "/ui/js/chat-effect-posters.js";
|
|
58
59
|
import { ChatEffects, chatEffectsMenuAvailable } from "./chat-effects.js";
|
|
60
|
+
import { setFollowsReducedMotion } from "/assets/fx-motion.js";
|
|
61
|
+
import { AmbientMotionNote, ChatAmbient, ambientMenuItems } from "./chat-ambient.js";
|
|
59
62
|
import { startAppEffects, getAppEffects } from "./app-effects.js";
|
|
60
63
|
|
|
61
64
|
const R = () => globalThis.AgendChatRender;
|
|
@@ -116,8 +119,8 @@ export function boot({ stream, boot: bootData, deps = {} }) {
|
|
|
116
119
|
}
|
|
117
120
|
// #1604: the preview opt-in and Tour live in Settings → This device now (and on each preview card's menu).
|
|
118
121
|
installTour();
|
|
119
|
-
// #
|
|
120
|
-
|
|
122
|
+
// #2118: viewing current in-scope Chat effects needs no management or personal-slot authority.
|
|
123
|
+
startAppEffects({ store });
|
|
121
124
|
return store;
|
|
122
125
|
}
|
|
123
126
|
|
|
@@ -223,6 +226,9 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
|
|
|
223
226
|
const themeKey = JSON.stringify(theme);
|
|
224
227
|
const card = useCard(name, inst.card_v);
|
|
225
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), []);
|
|
226
232
|
const [wrap, setWrap] = useState(codeWrap);
|
|
227
233
|
const view = useRef(null), split = useRef(null), thread = useRef(null), bg = useRef(null), effectSurface = useRef(null);
|
|
228
234
|
useLayoutEffect(() => { applyTheme(view.current, theme, mode); }, [themeKey, mode]);
|
|
@@ -275,12 +281,14 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
|
|
|
275
281
|
colorMenuItem(name, inst),
|
|
276
282
|
// #1629: a person's control over this agent's theme — not over the public link (it only views themes).
|
|
277
283
|
...themeMenuItems(name),
|
|
284
|
+
// #2117: its ambient — why it is not showing here (with the way to change that), and a person's Clear.
|
|
285
|
+
...ambientMenuItems(name, { publicLink: document.body.dataset.publicLink === "1", clear: clearAmbient }),
|
|
278
286
|
chatEffectsMenuAvailable() ? { key: "effects", label: t("chatFx.title"), onSelect: () => setDialog("effects") } : null,
|
|
279
287
|
{ key: "delete", label: t("chat.delete"), icon: "trash", danger: true, onSelect: () => setDialog("delete") },
|
|
280
288
|
];
|
|
281
289
|
const cls = statusClass(inst, exec, awaiting);
|
|
282
290
|
// #1920 phase 3: a guest has this chat only — no Details, View or Log to switch to, no lifecycle, colour, theme,
|
|
283
|
-
//
|
|
291
|
+
// effect management or delete (a guest's status frame carries no model or effort either, so those chips do not show).
|
|
284
292
|
const guest = guestPage();
|
|
285
293
|
// #1269 quick actions: the model and effort it runs, each a way to /model or /effort (the same pick list).
|
|
286
294
|
const pick = (command) => store.runCommand(name, command, "", { alive: () => lease.current() });
|
|
@@ -288,14 +296,17 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
|
|
|
288
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}
|
|
289
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}`;
|
|
290
298
|
return html`<div class=${`panel p-chat${wrap ? " wrap-code" : ""}`} ref=${view}>
|
|
291
|
-
<${PanelHeader} title=${headerName(namesOf(inst || { name }, names.primary))} sub=${sub} nav=${guest ? null : html`<${InstanceSwitch} name=${name} current="chat" />`}
|
|
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}>
|
|
292
300
|
<${ChatCard} key=${name} name=${name} card=${card} />
|
|
293
301
|
<${AgentWidget} slot="chat_header" widget=${widgetFor(name, "chat_header")} binds=${instanceBinds(inst, exec, awaiting)} />
|
|
294
302
|
<div class="chat-split" ref=${split}>
|
|
295
303
|
<div ref=${effectSurface} class=${`chat-main${theme && theme.background ? " has-th-bg" : ""}`}>
|
|
296
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}
|
|
305
|
+
<${ChatAmbient} name=${name} surface=${effectSurface} part="layer" />
|
|
306
|
+
<${AmbientMotionNote} name=${name} canvasPlaying=${canvasPlayback?.name === name && canvasPlayback.playing} />
|
|
297
307
|
<${Thread} name=${name} th=${thread} />
|
|
298
308
|
<${Dock} name=${name} inst=${inst} lease=${lease} exec=${exec} awaiting=${awaiting} workingLabel=${theme ? theme.workingLabel : undefined} />
|
|
309
|
+
<${ChatAmbient} name=${name} surface=${effectSurface} part="front" />
|
|
299
310
|
</div>
|
|
300
311
|
<${PreviewPanel} name=${name} split=${split} msgs=${() => store.state.msgs[name] || []} subscribe=${(fn) => store.subscribe(fn)}
|
|
301
312
|
keyOf=${msgKey} reveal=${(k) => thread.current && thread.current.reveal(k)} download=${downloadHtml} allow=${(commit) => setPreviewOptIn(true, commit)} />
|
|
@@ -318,9 +329,11 @@ function Thread({ name, th }) {
|
|
|
318
329
|
setPreviewOptIn, copyText, download: downloadHtml, toggleWrap,
|
|
319
330
|
effectTrialStatus: x => getAppEffects()?.trialStatus(x) ?? "localAdmin",
|
|
320
331
|
fixEffectTrial: (x, reason) => {
|
|
321
|
-
if (reason === "
|
|
332
|
+
if (reason === "reducedMotion") setFollowsReducedMotion(false);
|
|
333
|
+
else if (reason === "busy") getAppEffects()?.stopTrial();
|
|
334
|
+
else if (reason === "transport") location.reload();
|
|
322
335
|
else if (reason === "localAdmin") location.href = "/signin?next=" + encodeURIComponent(location.pathname + location.search);
|
|
323
|
-
else navigate("/
|
|
336
|
+
else navigate("/settings/appearance");
|
|
324
337
|
},
|
|
325
338
|
canTryEffect: x => getAppEffects()?.canTry(x) === true,
|
|
326
339
|
startEffectTrial: x => { const seq = navStore.get().seq; void getAppEffects()?.startTrial(x).then(ok => { if (!ok && live && navStore.get().seq === seq && document.hasFocus?.() === true && document.visibilityState === "visible") toast(t("chat.effectTrialUnavailable")); }); },
|
|
@@ -384,7 +397,7 @@ function Dock({ name, inst, lease, exec, awaiting, workingLabel }) {
|
|
|
384
397
|
<button type="button" class="btn btn-sm" onClick=${() => store.discardFailed(name)}>${t("chat.discard")}</button></div>` : null}
|
|
385
398
|
<${Composer} name=${name} lease=${lease} busy=${R().isBusy(exec)} />
|
|
386
399
|
<p class="hint" title=${t(guestPage() ? "chat.guestMore" : "chat.composerMore")}>${t("chat.composerHint")}</p>
|
|
387
|
-
${guestPage() ? html`<p class="guest-note">${t("chat.guestNote")}</p>` :
|
|
400
|
+
${guestPage() ? html`<p class="guest-note">${t("chat.guestNote")}</p>` : html`<${AuthorizeChip} />`}
|
|
388
401
|
</div></div>`;
|
|
389
402
|
}
|
|
390
403
|
|
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
|
|