openclacky 1.2.16 → 1.2.17
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +16 -0
- data/lib/clacky/agent/session_serializer.rb +5 -2
- data/lib/clacky/default_skills/channel-manager/SKILL.md +4 -2
- data/lib/clacky/server/http_server.rb +22 -1
- data/lib/clacky/telemetry.rb +20 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +305 -0
- data/lib/clacky/web/billing.js +144 -0
- data/lib/clacky/web/i18n.js +144 -1
- data/lib/clacky/web/index.html +11 -0
- data/lib/clacky/web/marked.min.js +55 -45
- data/lib/clacky/web/sessions.js +6 -1
- data/lib/clacky/web/share.js +843 -0
- data/lib/clacky/web/vendor/qrcode/qrcode.min.js +8 -0
- data/lib/clacky/web/ws-dispatcher.js +1 -0
- data/scripts/install.ps1 +20 -19
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3a0f79d1b7995b24b2b1a5d31dc36d4342ad1ff925b66749b7edb6268d0bcf29
|
|
4
|
+
data.tar.gz: b1f583cde8ffb619a4cb3558f074c369251c5445acce05116d1bc415e94eb3b4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 18921df7e5d4d4a7f3cbf1e19bd1b65bbc8360fa064d81ffc129375d86ec9a07ebfc324414d67a7d6df9782dfd8f966065755d0224d8af77b92604d6320195e2
|
|
7
|
+
data.tar.gz: 013db4dddea3c901bd3e8885241676b7fdd2ca2856a9a4df4cfde4b2475b7e57dc7795386d53db934acfe5786515501fd724e3ef559e8d5e786a6b07a3312db6
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.2.17] - 2026-06-12
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Session sharing to Web UI — share any session via a shareable link with billing integration
|
|
12
|
+
- Share telemetry tracking
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Markdown rendering in certain edge cases
|
|
16
|
+
- Image blocks not detected in replay round counting, potentially causing history truncation
|
|
17
|
+
- History images served as base64 causing replay lag, now proxied through server
|
|
18
|
+
- WSL kernel repair getting stuck in infinite loop on pending state
|
|
19
|
+
- WeChat QR login fallback showing false stale-session errors
|
|
20
|
+
|
|
21
|
+
### More
|
|
22
|
+
- Background color styling update
|
|
23
|
+
|
|
8
24
|
## [1.2.16] - 2026-06-10
|
|
9
25
|
|
|
10
26
|
### Added
|
|
@@ -201,8 +201,11 @@ module Clacky
|
|
|
201
201
|
if msg[:content].is_a?(String)
|
|
202
202
|
!msg[:content].start_with?("[SYSTEM]")
|
|
203
203
|
elsif msg[:content].is_a?(Array)
|
|
204
|
-
# Must contain at least one text or image block (not a tool_result array)
|
|
205
|
-
|
|
204
|
+
# Must contain at least one text or image block (not a tool_result array).
|
|
205
|
+
# "image_url" covers image-only messages (user sent a picture with no
|
|
206
|
+
# accompanying text); without it such messages start no round and get
|
|
207
|
+
# dropped on replay, making the image vanish on session reopen.
|
|
208
|
+
msg[:content].any? { |b| b.is_a?(Hash) && %w[text image image_url].include?(b[:type].to_s) }
|
|
206
209
|
else
|
|
207
210
|
false
|
|
208
211
|
end
|
|
@@ -253,13 +253,15 @@ browser(action="navigate", url="<qr_page_url>")
|
|
|
253
253
|
>
|
|
254
254
|
> `http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/weixin-qr.html?url=<URL-encoded qrcode_url>`
|
|
255
255
|
>
|
|
256
|
-
> Scan the QR code with WeChat
|
|
256
|
+
> Scan the QR code with WeChat and confirm in the app. I'm already watching for your scan — no need to reply.
|
|
257
|
+
|
|
258
|
+
**Do NOT wait for the user to reply "done".** Immediately proceed to Step 3 and start polling — exactly as in the browser-succeeds path. The polling script must already be running while the user scans, so it can observe the `scaned → confirmed` transition; otherwise a real scan can be misread as a stale session.
|
|
257
259
|
|
|
258
260
|
The page renders a proper scannable QR code image. Do NOT open the raw `qrcode_url` directly — that page shows "请使用微信扫码打开" with no actual QR image.
|
|
259
261
|
|
|
260
262
|
#### Step 3 — Wait for scan and save credentials
|
|
261
263
|
|
|
262
|
-
|
|
264
|
+
As soon as the QR page has been presented to the user — whether you opened it via the browser tool **or** gave the user the manual link — immediately run the polling script in the background. **In both cases, do NOT wait for the user to confirm or reply "done" before starting the poll** — the script must already be running while the user scans:
|
|
263
265
|
|
|
264
266
|
```bash
|
|
265
267
|
ruby "SKILL_DIR/weixin_setup.rb" --qrcode-id "$QRCODE_ID"
|
|
@@ -45,7 +45,12 @@ module Clacky
|
|
|
45
45
|
if url
|
|
46
46
|
url
|
|
47
47
|
elsif type.to_s == "image" && path && File.exist?(path.to_s)
|
|
48
|
-
|
|
48
|
+
# Serve via the /api/local-image proxy instead of inlining a base64
|
|
49
|
+
# data URL. Inlining forced a synchronous disk-read + full base64
|
|
50
|
+
# encode + downscale on every history replay (2-3s lag for sessions
|
|
51
|
+
# with downgraded text-model images). The proxy lets the browser
|
|
52
|
+
# lazy-load + cache the image, keeping the replay response tiny.
|
|
53
|
+
"/api/local-image?path=#{CGI.escape(path.to_s)}"
|
|
49
54
|
elsif name
|
|
50
55
|
type.to_s == "image" ? "expired:#{name}" : "pdf:#{name}"
|
|
51
56
|
end
|
|
@@ -448,6 +453,7 @@ module Clacky
|
|
|
448
453
|
when ["POST", "/api/browser/configure"] then api_browser_configure(req, res)
|
|
449
454
|
when ["POST", "/api/browser/reload"] then api_browser_reload(res)
|
|
450
455
|
when ["POST", "/api/browser/toggle"] then api_browser_toggle(res)
|
|
456
|
+
when ["POST", "/api/telemetry"] then api_telemetry(req, res)
|
|
451
457
|
when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
|
|
452
458
|
when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(req, res)
|
|
453
459
|
when ["GET", "/api/store/skills"] then api_store_skills(res)
|
|
@@ -854,6 +860,17 @@ module Clacky
|
|
|
854
860
|
json_response(res, 500, { ok: false, error: e.message })
|
|
855
861
|
end
|
|
856
862
|
|
|
863
|
+
# POST /api/telemetry
|
|
864
|
+
# Body: { "event": "share_open" | "share_download", ... }
|
|
865
|
+
# Fire-and-forget telemetry from the WebUI frontend.
|
|
866
|
+
def api_telemetry(req, res)
|
|
867
|
+
body = parse_json_body(req) || {}
|
|
868
|
+
Clacky::Telemetry.share!(event: body["event"], extra: body["extra"])
|
|
869
|
+
json_response(res, 200, { ok: true })
|
|
870
|
+
rescue StandardError => e
|
|
871
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
872
|
+
end
|
|
873
|
+
|
|
857
874
|
# POST /api/media/image
|
|
858
875
|
# Body: { "prompt": "...", "aspect_ratio": "landscape|square|portrait",
|
|
859
876
|
# "output_dir": "<absolute path, optional>" }
|
|
@@ -1442,6 +1459,8 @@ module Clacky
|
|
|
1442
1459
|
branded: true,
|
|
1443
1460
|
needs_activation: true,
|
|
1444
1461
|
product_name: brand.product_name,
|
|
1462
|
+
homepage_url: brand.homepage_url,
|
|
1463
|
+
logo_url: brand.logo_url,
|
|
1445
1464
|
test_mode: @brand_test,
|
|
1446
1465
|
distribution_refresh_pending: refresh_pending
|
|
1447
1466
|
})
|
|
@@ -1481,6 +1500,8 @@ module Clacky
|
|
|
1481
1500
|
branded: true,
|
|
1482
1501
|
needs_activation: false,
|
|
1483
1502
|
product_name: brand.product_name,
|
|
1503
|
+
homepage_url: brand.homepage_url,
|
|
1504
|
+
logo_url: brand.logo_url,
|
|
1484
1505
|
warning: warning,
|
|
1485
1506
|
test_mode: @brand_test,
|
|
1486
1507
|
user_licensed: brand.user_licensed?,
|
data/lib/clacky/telemetry.rb
CHANGED
|
@@ -71,6 +71,26 @@ module Clacky
|
|
|
71
71
|
fire_and_forget("/api/v1/telemetry/task", payload.compact)
|
|
72
72
|
end
|
|
73
73
|
|
|
74
|
+
# Called from the WebUI when user opens the share modal or completes
|
|
75
|
+
# a share action (e.g. downloads poster). Tracks share engagement.
|
|
76
|
+
#
|
|
77
|
+
# @param event [String] "share_open" or "share_download"
|
|
78
|
+
# @param extra [Hash] optional context (platform, scorecard mode, etc.)
|
|
79
|
+
def share!(event:, extra: {})
|
|
80
|
+
return unless enabled?
|
|
81
|
+
|
|
82
|
+
brand = Clacky::BrandConfig.load
|
|
83
|
+
payload = {
|
|
84
|
+
device_id: resolve_device_id(brand),
|
|
85
|
+
version: Clacky::VERSION,
|
|
86
|
+
brand: brand.branded? ? brand.package_name : nil,
|
|
87
|
+
event: event
|
|
88
|
+
}
|
|
89
|
+
payload.merge!(extra) if extra.is_a?(Hash)
|
|
90
|
+
|
|
91
|
+
fire_and_forget("/api/v1/telemetry/share", payload.compact)
|
|
92
|
+
end
|
|
93
|
+
|
|
74
94
|
# ── private helpers ────────────────────────────────────────────────
|
|
75
95
|
|
|
76
96
|
private def enabled?
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/app.css
CHANGED
|
@@ -9150,6 +9150,22 @@ body.setup-mode[data-theme="dark"] {
|
|
|
9150
9150
|
color: var(--color-text-primary);
|
|
9151
9151
|
border-color: var(--color-error);
|
|
9152
9152
|
}
|
|
9153
|
+
.billing-share-btn {
|
|
9154
|
+
padding: 0.5rem 0.75rem;
|
|
9155
|
+
border: 1px solid var(--color-accent, #38bdf8);
|
|
9156
|
+
border-radius: 8px;
|
|
9157
|
+
background: var(--color-bg-secondary);
|
|
9158
|
+
color: var(--color-accent, #38bdf8);
|
|
9159
|
+
font-size: 0.8125rem;
|
|
9160
|
+
font-weight: 600;
|
|
9161
|
+
white-space: nowrap;
|
|
9162
|
+
cursor: pointer;
|
|
9163
|
+
transition: all 0.2s ease;
|
|
9164
|
+
}
|
|
9165
|
+
.billing-share-btn:hover {
|
|
9166
|
+
background: var(--color-accent, #38bdf8);
|
|
9167
|
+
color: #fff;
|
|
9168
|
+
}
|
|
9153
9169
|
.billing-clear-popup {
|
|
9154
9170
|
position: absolute;
|
|
9155
9171
|
top: 100%;
|
|
@@ -9238,6 +9254,51 @@ body.setup-mode[data-theme="dark"] {
|
|
|
9238
9254
|
}
|
|
9239
9255
|
|
|
9240
9256
|
/* ── Chart Row (Single Line) ─────────────────────────────────────────── */
|
|
9257
|
+
.billing-heatmap-row { width: 100%; }
|
|
9258
|
+
.billing-heat-dow-row {
|
|
9259
|
+
display: grid;
|
|
9260
|
+
grid-template-columns: repeat(7, 32px);
|
|
9261
|
+
gap: 6px;
|
|
9262
|
+
margin: 0.75rem 0 6px;
|
|
9263
|
+
justify-content: start;
|
|
9264
|
+
}
|
|
9265
|
+
.billing-heat-dow {
|
|
9266
|
+
text-align: center;
|
|
9267
|
+
font-size: 11px;
|
|
9268
|
+
color: var(--color-text-tertiary, var(--color-text-secondary));
|
|
9269
|
+
}
|
|
9270
|
+
.billing-heat-grid {
|
|
9271
|
+
display: grid;
|
|
9272
|
+
grid-template-columns: repeat(7, 32px);
|
|
9273
|
+
gap: 6px;
|
|
9274
|
+
justify-content: start;
|
|
9275
|
+
}
|
|
9276
|
+
.billing-heat-cell {
|
|
9277
|
+
width: 32px;
|
|
9278
|
+
height: 32px;
|
|
9279
|
+
border-radius: 6px;
|
|
9280
|
+
background: var(--color-bg-primary);
|
|
9281
|
+
}
|
|
9282
|
+
.billing-heat-cell.is-empty { background: transparent; }
|
|
9283
|
+
.billing-heat-cell[data-level="0"] { background: var(--color-bg-primary); border: 1px solid var(--color-border-primary); }
|
|
9284
|
+
.billing-heat-cell[data-level="1"] { background: #9be9a8; }
|
|
9285
|
+
.billing-heat-cell[data-level="2"] { background: #40c463; }
|
|
9286
|
+
.billing-heat-cell[data-level="3"] { background: #30a14e; }
|
|
9287
|
+
.billing-heat-cell[data-level="4"] { background: #216e39; }
|
|
9288
|
+
.billing-heat-cell[data-level="5"] { background: #0a4020; }
|
|
9289
|
+
.billing-heat-legend {
|
|
9290
|
+
display: flex;
|
|
9291
|
+
align-items: center;
|
|
9292
|
+
gap: 5px;
|
|
9293
|
+
font-size: 11px;
|
|
9294
|
+
color: var(--color-text-secondary);
|
|
9295
|
+
}
|
|
9296
|
+
.billing-heat-legend .billing-heat-cell {
|
|
9297
|
+
width: 13px;
|
|
9298
|
+
height: 13px;
|
|
9299
|
+
aspect-ratio: auto;
|
|
9300
|
+
}
|
|
9301
|
+
|
|
9241
9302
|
.billing-chart-row {
|
|
9242
9303
|
width: 100%;
|
|
9243
9304
|
}
|
|
@@ -10363,3 +10424,247 @@ body.setup-mode[data-theme="dark"] {
|
|
|
10363
10424
|
.msg-phase-empty .msg-phase-summary { cursor: default; }
|
|
10364
10425
|
.msg-phase-empty .msg-phase-summary::before { visibility: hidden; }
|
|
10365
10426
|
.msg-phase-empty .msg-phase-body { display: none; }
|
|
10427
|
+
|
|
10428
|
+
/* ── Share modal ──────────────────────────────────────────────────────── */
|
|
10429
|
+
.share-overlay {
|
|
10430
|
+
position: fixed;
|
|
10431
|
+
inset: 0;
|
|
10432
|
+
z-index: 1200;
|
|
10433
|
+
display: flex;
|
|
10434
|
+
align-items: center;
|
|
10435
|
+
justify-content: center;
|
|
10436
|
+
background: var(--color-bg-overlay);
|
|
10437
|
+
opacity: 0;
|
|
10438
|
+
transition: opacity .18s ease;
|
|
10439
|
+
padding: 20px;
|
|
10440
|
+
}
|
|
10441
|
+
.share-overlay.open { opacity: 1; }
|
|
10442
|
+
|
|
10443
|
+
.share-modal {
|
|
10444
|
+
position: relative;
|
|
10445
|
+
width: 100%;
|
|
10446
|
+
max-width: 660px;
|
|
10447
|
+
max-height: 90vh;
|
|
10448
|
+
overflow-y: auto;
|
|
10449
|
+
background: var(--color-bg-card);
|
|
10450
|
+
border: 1px solid var(--color-border-primary);
|
|
10451
|
+
border-radius: var(--radius-lg, 14px);
|
|
10452
|
+
box-shadow: 0 18px 48px rgba(15, 18, 28, 0.22);
|
|
10453
|
+
padding: 24px;
|
|
10454
|
+
transform: translateY(8px) scale(.98);
|
|
10455
|
+
transition: transform .18s ease;
|
|
10456
|
+
}
|
|
10457
|
+
.share-overlay.open .share-modal { transform: translateY(0) scale(1); }
|
|
10458
|
+
|
|
10459
|
+
.share-close {
|
|
10460
|
+
position: absolute;
|
|
10461
|
+
top: 12px;
|
|
10462
|
+
right: 14px;
|
|
10463
|
+
border: none;
|
|
10464
|
+
background: none;
|
|
10465
|
+
font-size: 22px;
|
|
10466
|
+
line-height: 1;
|
|
10467
|
+
color: var(--color-text-muted);
|
|
10468
|
+
cursor: pointer;
|
|
10469
|
+
}
|
|
10470
|
+
.share-close:hover { color: var(--color-text-primary); }
|
|
10471
|
+
|
|
10472
|
+
.share-title {
|
|
10473
|
+
margin: 0 0 4px;
|
|
10474
|
+
font-size: 17px;
|
|
10475
|
+
font-weight: 700;
|
|
10476
|
+
color: var(--color-text-primary);
|
|
10477
|
+
}
|
|
10478
|
+
.share-subtitle {
|
|
10479
|
+
margin: 0 0 16px;
|
|
10480
|
+
font-size: 13px;
|
|
10481
|
+
color: var(--color-text-tertiary);
|
|
10482
|
+
}
|
|
10483
|
+
|
|
10484
|
+
.share-body {
|
|
10485
|
+
display: flex;
|
|
10486
|
+
gap: 20px;
|
|
10487
|
+
align-items: flex-start;
|
|
10488
|
+
}
|
|
10489
|
+
.share-controls {
|
|
10490
|
+
flex: 1;
|
|
10491
|
+
min-width: 0;
|
|
10492
|
+
}
|
|
10493
|
+
|
|
10494
|
+
.share-poster-wrap {
|
|
10495
|
+
display: flex;
|
|
10496
|
+
justify-content: center;
|
|
10497
|
+
flex-shrink: 0;
|
|
10498
|
+
}
|
|
10499
|
+
.share-poster-img {
|
|
10500
|
+
width: 220px;
|
|
10501
|
+
border-radius: var(--radius-md, 10px);
|
|
10502
|
+
box-shadow: 0 6px 18px rgba(15, 18, 28, 0.16);
|
|
10503
|
+
}
|
|
10504
|
+
|
|
10505
|
+
@media (max-width: 560px) {
|
|
10506
|
+
.share-body { flex-direction: column; align-items: stretch; }
|
|
10507
|
+
.share-poster-wrap { margin-bottom: 4px; }
|
|
10508
|
+
.share-poster-img { width: 180px; }
|
|
10509
|
+
}
|
|
10510
|
+
|
|
10511
|
+
.share-row-label {
|
|
10512
|
+
font-size: 12px;
|
|
10513
|
+
font-weight: 600;
|
|
10514
|
+
color: var(--color-text-tertiary);
|
|
10515
|
+
}
|
|
10516
|
+
|
|
10517
|
+
.share-theme-row {
|
|
10518
|
+
display: flex;
|
|
10519
|
+
flex-direction: column;
|
|
10520
|
+
gap: 6px;
|
|
10521
|
+
margin-bottom: 14px;
|
|
10522
|
+
}
|
|
10523
|
+
.share-theme-chips {
|
|
10524
|
+
display: flex;
|
|
10525
|
+
gap: 8px;
|
|
10526
|
+
}
|
|
10527
|
+
.share-theme-chip {
|
|
10528
|
+
flex: 1;
|
|
10529
|
+
height: 34px;
|
|
10530
|
+
border: 2px solid transparent;
|
|
10531
|
+
border-radius: var(--radius-sm, 6px);
|
|
10532
|
+
cursor: pointer;
|
|
10533
|
+
position: relative;
|
|
10534
|
+
display: flex;
|
|
10535
|
+
align-items: center;
|
|
10536
|
+
justify-content: center;
|
|
10537
|
+
transition: border-color .12s ease, transform .12s ease;
|
|
10538
|
+
}
|
|
10539
|
+
.share-theme-chip:hover { transform: translateY(-1px); }
|
|
10540
|
+
.share-theme-chip.is-active { border-color: var(--color-button-primary); }
|
|
10541
|
+
.share-theme-name {
|
|
10542
|
+
font-size: 11px;
|
|
10543
|
+
font-weight: 700;
|
|
10544
|
+
color: rgba(0,0,0,.55);
|
|
10545
|
+
text-shadow: 0 1px 0 rgba(255,255,255,.4);
|
|
10546
|
+
pointer-events: none;
|
|
10547
|
+
}
|
|
10548
|
+
.share-theme-chip[data-theme="geek"] .share-theme-name {
|
|
10549
|
+
color: rgba(255,255,255,.92);
|
|
10550
|
+
text-shadow: 0 1px 2px rgba(0,0,0,.4);
|
|
10551
|
+
}
|
|
10552
|
+
|
|
10553
|
+
.share-periods {
|
|
10554
|
+
display: inline-flex;
|
|
10555
|
+
gap: 4px;
|
|
10556
|
+
padding: 3px;
|
|
10557
|
+
margin-bottom: 12px;
|
|
10558
|
+
border: 1px solid var(--color-border-primary);
|
|
10559
|
+
border-radius: var(--radius-sm, 6px);
|
|
10560
|
+
background: var(--color-bg-primary);
|
|
10561
|
+
}
|
|
10562
|
+
.share-period {
|
|
10563
|
+
padding: 6px 16px;
|
|
10564
|
+
border: none;
|
|
10565
|
+
border-radius: var(--radius-sm, 5px);
|
|
10566
|
+
background: transparent;
|
|
10567
|
+
color: var(--color-text-secondary);
|
|
10568
|
+
font-size: 13px;
|
|
10569
|
+
font-weight: 600;
|
|
10570
|
+
cursor: pointer;
|
|
10571
|
+
transition: background .12s ease, color .12s ease;
|
|
10572
|
+
}
|
|
10573
|
+
.share-period:hover { color: var(--color-text-primary); }
|
|
10574
|
+
.share-period.is-active {
|
|
10575
|
+
background: var(--color-button-primary);
|
|
10576
|
+
color: var(--color-button-primary-text);
|
|
10577
|
+
}
|
|
10578
|
+
|
|
10579
|
+
.share-platforms {
|
|
10580
|
+
display: grid;
|
|
10581
|
+
grid-template-columns: repeat(4, 1fr);
|
|
10582
|
+
gap: 8px;
|
|
10583
|
+
margin-bottom: 14px;
|
|
10584
|
+
}
|
|
10585
|
+
.share-platform {
|
|
10586
|
+
padding: 9px 4px;
|
|
10587
|
+
border: 1px solid var(--color-border-primary);
|
|
10588
|
+
border-radius: var(--radius-sm, 6px);
|
|
10589
|
+
background: var(--color-bg-primary);
|
|
10590
|
+
color: var(--color-text-secondary);
|
|
10591
|
+
font-size: 13px;
|
|
10592
|
+
font-weight: 600;
|
|
10593
|
+
cursor: pointer;
|
|
10594
|
+
transition: background .12s ease, border-color .12s ease, color .12s ease;
|
|
10595
|
+
}
|
|
10596
|
+
.share-platform:hover {
|
|
10597
|
+
background: var(--color-bg-hover);
|
|
10598
|
+
border-color: var(--color-border-strong);
|
|
10599
|
+
}
|
|
10600
|
+
.share-platform.is-active {
|
|
10601
|
+
background: var(--color-button-primary);
|
|
10602
|
+
color: var(--color-button-primary-text);
|
|
10603
|
+
border-color: var(--color-button-primary);
|
|
10604
|
+
}
|
|
10605
|
+
|
|
10606
|
+
.share-editor { margin-bottom: 14px; }
|
|
10607
|
+
.share-editor-head {
|
|
10608
|
+
display: flex;
|
|
10609
|
+
align-items: center;
|
|
10610
|
+
justify-content: space-between;
|
|
10611
|
+
margin-bottom: 6px;
|
|
10612
|
+
}
|
|
10613
|
+
.share-shuffle {
|
|
10614
|
+
border: none;
|
|
10615
|
+
background: none;
|
|
10616
|
+
color: var(--color-button-primary);
|
|
10617
|
+
font-size: 12px;
|
|
10618
|
+
font-weight: 600;
|
|
10619
|
+
cursor: pointer;
|
|
10620
|
+
padding: 2px 4px;
|
|
10621
|
+
}
|
|
10622
|
+
.share-shuffle:hover { text-decoration: underline; }
|
|
10623
|
+
.share-text {
|
|
10624
|
+
width: 100%;
|
|
10625
|
+
box-sizing: border-box;
|
|
10626
|
+
resize: vertical;
|
|
10627
|
+
min-height: 72px;
|
|
10628
|
+
padding: 10px 12px;
|
|
10629
|
+
border: 1px solid var(--color-border-primary);
|
|
10630
|
+
border-radius: var(--radius-sm, 6px);
|
|
10631
|
+
background: var(--color-bg-primary);
|
|
10632
|
+
color: var(--color-text-primary);
|
|
10633
|
+
font-size: 13px;
|
|
10634
|
+
line-height: 1.5;
|
|
10635
|
+
font-family: inherit;
|
|
10636
|
+
}
|
|
10637
|
+
.share-text:focus {
|
|
10638
|
+
outline: none;
|
|
10639
|
+
border-color: var(--color-button-primary);
|
|
10640
|
+
}
|
|
10641
|
+
|
|
10642
|
+
.share-actions {
|
|
10643
|
+
display: flex;
|
|
10644
|
+
flex-wrap: wrap;
|
|
10645
|
+
gap: 8px;
|
|
10646
|
+
}
|
|
10647
|
+
.share-btn-primary, .share-btn-secondary {
|
|
10648
|
+
padding: 10px;
|
|
10649
|
+
border-radius: var(--radius-sm, 6px);
|
|
10650
|
+
font-size: 13px;
|
|
10651
|
+
font-weight: 600;
|
|
10652
|
+
cursor: pointer;
|
|
10653
|
+
border: 1px solid var(--color-border-primary);
|
|
10654
|
+
}
|
|
10655
|
+
.share-btn-primary {
|
|
10656
|
+
flex: 1 1 100%;
|
|
10657
|
+
background: var(--color-button-primary);
|
|
10658
|
+
color: var(--color-button-primary-text);
|
|
10659
|
+
border-color: var(--color-button-primary);
|
|
10660
|
+
}
|
|
10661
|
+
.share-btn-primary:hover { background: var(--color-button-primary-hover); }
|
|
10662
|
+
.share-btn-secondary {
|
|
10663
|
+
flex: 1 1 calc(50% - 4px);
|
|
10664
|
+
min-width: 90px;
|
|
10665
|
+
background: var(--color-bg-primary);
|
|
10666
|
+
color: var(--color-text-secondary);
|
|
10667
|
+
}
|
|
10668
|
+
.share-btn-secondary:hover { background: var(--color-bg-hover); }
|
|
10669
|
+
|
|
10670
|
+
#share-toggle-header svg { display: block; }
|
data/lib/clacky/web/billing.js
CHANGED
|
@@ -126,6 +126,9 @@ const Billing = (() => {
|
|
|
126
126
|
<div class="billing-controls">
|
|
127
127
|
<div class="billing-period-group">${periodBtns}</div>
|
|
128
128
|
<select id="billing-model-filter" class="billing-model-filter">${modelOptions}</select>
|
|
129
|
+
<button id="billing-share-btn" class="billing-share-btn" title="${I18n.t('billing.share.tooltip') || 'Share scorecard'}">
|
|
130
|
+
📤 ${I18n.t('billing.share.btn') || 'Share scorecard'}
|
|
131
|
+
</button>
|
|
129
132
|
<div class="billing-clear-container">
|
|
130
133
|
<button id="billing-clear-btn" class="billing-clear-btn" title="${I18n.t('billing.clearData') || 'Clear Data'}">
|
|
131
134
|
🗑️
|
|
@@ -169,6 +172,10 @@ const Billing = (() => {
|
|
|
169
172
|
</div>
|
|
170
173
|
</div>
|
|
171
174
|
|
|
175
|
+
<div class="billing-heatmap-row">
|
|
176
|
+
${_renderHeatmap()}
|
|
177
|
+
</div>
|
|
178
|
+
|
|
172
179
|
<div class="billing-bottom-grid">
|
|
173
180
|
${_renderTokenBreakdown()}
|
|
174
181
|
${_renderModelBreakdown()}
|
|
@@ -201,8 +208,69 @@ const Billing = (() => {
|
|
|
201
208
|
// Bind clear button handlers
|
|
202
209
|
_bindClearHandlers();
|
|
203
210
|
|
|
211
|
+
// Bind scorecard share button
|
|
212
|
+
document.getElementById("billing-share-btn")?.addEventListener("click", _openScorecardShare);
|
|
213
|
+
|
|
204
214
|
// Bind chart tooltip handlers
|
|
205
215
|
_bindChartTooltip();
|
|
216
|
+
_bindHeatmapTooltip();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Builds the per-period scorecard numbers from a raw summary object, using
|
|
220
|
+
// the same currency / formatting conventions as the billing dashboard.
|
|
221
|
+
function _scorecardStatsFor(summary, periodKey) {
|
|
222
|
+
const prompt = summary.prompt_tokens || 0;
|
|
223
|
+
const cacheRead = summary.cache_read_tokens || 0;
|
|
224
|
+
const rate = prompt === 0 ? "0" : ((cacheRead / prompt) * 100).toFixed(1);
|
|
225
|
+
return {
|
|
226
|
+
key: periodKey,
|
|
227
|
+
period: _periodLabel(periodKey),
|
|
228
|
+
cacheHitRate: rate,
|
|
229
|
+
costStr: `${_getCurrencySymbol()}${_formatCost(_convertCost(summary.total_cost || 0))}`,
|
|
230
|
+
tokensStr: _formatCompact(summary.total_tokens || 0),
|
|
231
|
+
requests: _formatNumber(summary.record_count || 0)
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Daily token totals for the heatmap (GitHub-contribution style), oldest →
|
|
236
|
+
// newest. Each entry: { date: "YYYY-MM-DD", tokens: <total> }.
|
|
237
|
+
function _heatmapDays() {
|
|
238
|
+
return (_daily || []).map((d) => ({
|
|
239
|
+
date: d.date,
|
|
240
|
+
tokens: (d.prompt_tokens || 0) + (d.completion_tokens || 0),
|
|
241
|
+
cost: d.cost || 0
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function _openScorecardShare() {
|
|
246
|
+
if (!_summary || typeof Share === "undefined" || !Share.openScorecard) return;
|
|
247
|
+
const modelParam = (_currentModel && _currentModel !== "all") ? `&model=${encodeURIComponent(_currentModel)}` : "";
|
|
248
|
+
|
|
249
|
+
// Open instantly with the period the dashboard already has, then fetch the
|
|
250
|
+
// other periods in the background and hot-swap them in (no blocking await).
|
|
251
|
+
const periods = {};
|
|
252
|
+
periods[_currentPeriod] = _scorecardStatsFor(_summary, _currentPeriod);
|
|
253
|
+
|
|
254
|
+
Share.openScorecard({
|
|
255
|
+
periods: periods,
|
|
256
|
+
defaultPeriod: _currentPeriod,
|
|
257
|
+
heatmap: _heatmapDays(),
|
|
258
|
+
period: periods[_currentPeriod].period,
|
|
259
|
+
cacheHitRate: periods[_currentPeriod].cacheHitRate,
|
|
260
|
+
costStr: periods[_currentPeriod].costStr,
|
|
261
|
+
tokensStr: periods[_currentPeriod].tokensStr,
|
|
262
|
+
requests: periods[_currentPeriod].requests
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const others = ["day", "week", "month"].filter((p) => p !== _currentPeriod);
|
|
266
|
+
others.forEach((p) => {
|
|
267
|
+
fetch(`/api/billing/summary?period=${p}${modelParam}`)
|
|
268
|
+
.then((r) => r.json())
|
|
269
|
+
.then((summary) => {
|
|
270
|
+
if (Share.addScorecardPeriod) Share.addScorecardPeriod(p, _scorecardStatsFor(summary, p));
|
|
271
|
+
})
|
|
272
|
+
.catch(() => {});
|
|
273
|
+
});
|
|
206
274
|
}
|
|
207
275
|
|
|
208
276
|
function _bindChartTooltip() {
|
|
@@ -261,6 +329,42 @@ const Billing = (() => {
|
|
|
261
329
|
});
|
|
262
330
|
}
|
|
263
331
|
|
|
332
|
+
function _bindHeatmapTooltip() {
|
|
333
|
+
const grid = document.getElementById("billing-heat-grid");
|
|
334
|
+
const tooltip = document.getElementById("billing-tooltip");
|
|
335
|
+
if (!grid || !tooltip) return;
|
|
336
|
+
|
|
337
|
+
grid.addEventListener("mousemove", (e) => {
|
|
338
|
+
const cell = e.target.closest(".billing-heat-cell");
|
|
339
|
+
if (!cell || cell.classList.contains("is-empty") || !cell.dataset.date) {
|
|
340
|
+
tooltip.style.display = "none";
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
tooltip.innerHTML = `
|
|
345
|
+
<div class="tooltip-header">
|
|
346
|
+
<span class="tooltip-date">${cell.dataset.date}</span>
|
|
347
|
+
</div>
|
|
348
|
+
<div class="tooltip-row">
|
|
349
|
+
<span class="tooltip-dot tooltip-total"></span>
|
|
350
|
+
<span class="tooltip-label">${I18n.t("billing.totalTokens") || "Total Tokens"}</span>
|
|
351
|
+
<span class="tooltip-value">${cell.dataset.tokens}</span>
|
|
352
|
+
</div>
|
|
353
|
+
<div class="tooltip-row">
|
|
354
|
+
<span class="tooltip-label">${I18n.t("billing.cost") || "Cost"}</span>
|
|
355
|
+
<span class="tooltip-value">${cell.dataset.cost}</span>
|
|
356
|
+
</div>
|
|
357
|
+
`;
|
|
358
|
+
tooltip.style.display = "block";
|
|
359
|
+
tooltip.style.left = `${e.clientX + 15}px`;
|
|
360
|
+
tooltip.style.top = `${e.clientY - 10}px`;
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
grid.addEventListener("mouseleave", () => {
|
|
364
|
+
tooltip.style.display = "none";
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
264
368
|
function _bindClearHandlers() {
|
|
265
369
|
const clearBtn = document.getElementById("billing-clear-btn");
|
|
266
370
|
const clearPopup = document.getElementById("billing-clear-popup");
|
|
@@ -448,6 +552,46 @@ const Billing = (() => {
|
|
|
448
552
|
`;
|
|
449
553
|
}
|
|
450
554
|
|
|
555
|
+
function _renderHeatmap() {
|
|
556
|
+
const days = _heatmapDays();
|
|
557
|
+
if (!days || days.length === 0) {
|
|
558
|
+
return `<div class="billing-chart-card billing-chart-wide"><div class="billing-chart-empty">${I18n.t("billing.noData") || "No data available"}</div></div>`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const maxTok = Math.max(...days.map(d => d.tokens), 1);
|
|
562
|
+
const firstDow = new Date(days[0].date + "T00:00:00").getDay();
|
|
563
|
+
const cells = [];
|
|
564
|
+
for (let i = 0; i < firstDow; i++) cells.push('<div class="billing-heat-cell is-empty"></div>');
|
|
565
|
+
days.forEach((d) => {
|
|
566
|
+
const ratio = d.tokens / maxTok;
|
|
567
|
+
const lvl = d.tokens === 0 ? 0 : ratio >= 0.75 ? 5 : ratio >= 0.5 ? 4 : ratio >= 0.25 ? 3 : ratio >= 0.08 ? 2 : 1;
|
|
568
|
+
const costStr = `${_getCurrencySymbol()}${_formatCost(_convertCost(d.cost))}`;
|
|
569
|
+
cells.push(`<div class="billing-heat-cell" data-level="${lvl}" data-date="${d.date}" data-tokens="${_formatCompact(d.tokens)}" data-cost="${costStr}"></div>`);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
const dowLabels = (I18n.t("billing.heatmap.dow") || "S,M,T,W,T,F,S").split(",");
|
|
573
|
+
const dowHeader = dowLabels.map(l => `<span class="billing-heat-dow">${_esc(l)}</span>`).join("");
|
|
574
|
+
|
|
575
|
+
return `
|
|
576
|
+
<div class="billing-chart-card billing-chart-wide billing-heatmap-card">
|
|
577
|
+
<div class="billing-chart-header">
|
|
578
|
+
<h4>${I18n.t("billing.heatmap.title") || "Activity"}</h4>
|
|
579
|
+
<div class="billing-heat-legend">
|
|
580
|
+
<span>${I18n.t("billing.heatmap.less") || "Less"}</span>
|
|
581
|
+
<span class="billing-heat-cell" data-level="1"></span>
|
|
582
|
+
<span class="billing-heat-cell" data-level="2"></span>
|
|
583
|
+
<span class="billing-heat-cell" data-level="3"></span>
|
|
584
|
+
<span class="billing-heat-cell" data-level="4"></span>
|
|
585
|
+
<span class="billing-heat-cell" data-level="5"></span>
|
|
586
|
+
<span>${I18n.t("billing.heatmap.more") || "More"}</span>
|
|
587
|
+
</div>
|
|
588
|
+
</div>
|
|
589
|
+
<div class="billing-heat-dow-row">${dowHeader}</div>
|
|
590
|
+
<div class="billing-heat-grid" id="billing-heat-grid">${cells.join("")}</div>
|
|
591
|
+
</div>
|
|
592
|
+
`;
|
|
593
|
+
}
|
|
594
|
+
|
|
451
595
|
function _renderCombinedChart() {
|
|
452
596
|
if (!_daily || _daily.length === 0) {
|
|
453
597
|
return `<div class="billing-chart-card billing-chart-wide"><div class="billing-chart-empty">${I18n.t("billing.noData") || "No data available"}</div></div>`;
|