openclacky 1.3.0 → 1.3.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8423d4e64f056251f1763e5cc34502e394f08e2b26de993da5da6a85a88111ef
4
- data.tar.gz: 62e60aee3f8654e881870117b4716e053ab0f9328bc3898392ff1ed27517474c
3
+ metadata.gz: a39f825dd6192443ca9f0d30139e20a4b7fe366f9415d721c9a2401bfed1dc4a
4
+ data.tar.gz: f825a682fad6a33983e6a582a52d5a9378b7bd29d118b5a2fe1d5c26dc7b3195
5
5
  SHA512:
6
- metadata.gz: d675c6b981a1fc5f24bbee79dd19dc273817a7f4e233c2ed86a42def4f2fd2876cbc845a9f693383d7f4b67d39f6a62df1eafa65def2a8ace7bb876559c6aba5
7
- data.tar.gz: 48adb156c4c8ab26d2908537ac409c161a56b1f6fa5aa2146bdaa5df6b11c2b333e5a04a6aafc4404f1c94ce210fe0c23854d0f86f9cc509f1a27a781f9aa007
6
+ metadata.gz: 62cf37fe9eb083438853faeaf8f28a4579300d51607d2fbd8eef9caba69dc82d4b2c8dc98ae70f666500c7913b47325b0a6b58a5f3043b7139e8265b12035525
7
+ data.tar.gz: 706a5237c4aae5092068c91dfbc6c3cb7b1a93cc27b6878cbf32f043de5ec0c038321228d626efb3b2db744e8b81b4d6889fc518ee8c2495496714e628bf7a29
data/CHANGELOG.md CHANGED
@@ -5,6 +5,20 @@ 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.3.1] - 2026-06-17
9
+
10
+ ### Added
11
+ - Delete skills directly from the Web UI
12
+
13
+ ### Improved
14
+ - Sidebar is now draggable to resize, with width persisted across sessions
15
+ - Sidebar scrollbar only appears while scrolling for a cleaner look
16
+ - Billing page UI polish and mobile adaptation
17
+ - Default openclacky image model switched to Nano Banana 2
18
+
19
+ ### Fixed
20
+ - Onboarding device-login window no longer gets blocked as a popup
21
+
8
22
  ## [1.3.0] - 2026-06-17
9
23
 
10
24
  ### Added
@@ -59,7 +59,7 @@ module Clacky
59
59
  "or-gemini-3-1-flash-image" => "Nano Banana 2",
60
60
  "or-gpt-image-2" => "GPT Image 2"
61
61
  },
62
- "default_image_model" => "or-gpt-image-2",
62
+ "default_image_model" => "or-gemini-3-1-flash-image",
63
63
  # Video generation models served by the openclacky gateway, which
64
64
  # routes them to Vertex AI Veo (async predictLongRunning under the
65
65
  # hood; the gateway hides the polling and returns the MP4 inline).
@@ -627,6 +627,9 @@ module Clacky
627
627
  elsif method == "PATCH" && path.match?(%r{^/api/skills/[^/]+/toggle$})
628
628
  name = URI.decode_www_form_component(path.sub("/api/skills/", "").sub("/toggle", ""))
629
629
  api_toggle_skill(name, req, res)
630
+ elsif method == "DELETE" && path.match?(%r{^/api/skills/[^/]+$})
631
+ name = URI.decode_www_form_component(path.sub("/api/skills/", ""))
632
+ api_delete_skill(name, res)
630
633
  elsif method == "POST" && path.match?(%r{^/api/brand/skills/[^/]+/install$})
631
634
  slug = URI.decode_www_form_component(path.sub("/api/brand/skills/", "").sub("/install", ""))
632
635
  api_brand_skill_install(slug, req, res)
@@ -3463,6 +3466,14 @@ module Clacky
3463
3466
  json_response(res, 422, { error: e.message })
3464
3467
  end
3465
3468
 
3469
+ private def api_delete_skill(name, res)
3470
+ skill = @skill_loader[name]
3471
+ return json_response(res, 404, { error: "Skill not found: #{name}" }) unless skill
3472
+
3473
+ FileUtils.rm_rf(skill.directory)
3474
+ json_response(res, 200, { ok: true })
3475
+ end
3476
+
3466
3477
  # POST /api/my-skills/:name/publish
3467
3478
  # GET /api/creator/skills
3468
3479
  # Returns two separate groups:
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.3.0"
4
+ VERSION = "1.3.1"
5
5
  end
@@ -250,7 +250,7 @@ html { font-size: 16px; }
250
250
  .skel-label { height: 0.75rem; width: 4rem; margin-top: 0.375rem; }
251
251
  .skel-block { height: 9rem; width: 100%; }
252
252
  .skel-block-sm { height: 5rem; width: 100%; }
253
- .skel-heatmap { height: 7rem; width: 100%; }
253
+ .skel-heatmap { height: 5rem; width: 178px; }
254
254
 
255
255
 
256
256
 
@@ -375,7 +375,7 @@ body {
375
375
  }
376
376
 
377
377
  .header-logo-img {
378
- height: 2.3rem;
378
+ height: 2rem;
379
379
  object-fit: contain;
380
380
  display: block;
381
381
  flex-shrink: 0;
@@ -498,10 +498,6 @@ body {
498
498
  .theme-toggle-btn:active {
499
499
  background: var(--color-bg-hover);
500
500
  }
501
- /* Sound-notification toggle shares .theme-toggle-btn; highlight when ON. */
502
- #notify-toggle-header.notify-on:hover {
503
- color: var(--color-accent-primary, var(--color-text-primary));
504
- }
505
501
 
506
502
  /* ── Content Row (Sidebar + Main) ───────────────────────────────────────── */
507
503
  #app > aside,
@@ -530,8 +526,9 @@ body {
530
526
  /* The sidebar sits on the outer frame (bg-primary) so it's visibly a layer
531
527
  BEHIND the chat surface (bg-secondary). Subtle right border separates them. */
532
528
  #sidebar {
533
- width: 15rem;
534
- min-width: 15rem;
529
+ --sidebar-width: 15rem;
530
+ width: var(--sidebar-width);
531
+ min-width: var(--sidebar-width);
535
532
  background: var(--color-bg-primary);
536
533
  border-right: 1px solid var(--color-border-primary);
537
534
  display: flex;
@@ -540,9 +537,22 @@ body {
540
537
  height: 100%;
541
538
  flex-shrink: 0;
542
539
  margin-left: 0;
540
+ position: relative;
543
541
  }
544
542
  #sidebar.hidden {
545
- margin-left: -15rem;
543
+ margin-left: calc(-1 * var(--sidebar-width));
544
+ }
545
+ #sidebar-resize-handle {
546
+ position: absolute;
547
+ top: 0;
548
+ right: -3px;
549
+ bottom: 0;
550
+ width: 6px;
551
+ cursor: col-resize;
552
+ z-index: 10;
553
+ }
554
+ #sidebar:has(#sidebar-resize-handle.active) {
555
+ transition: background-color var(--transition-base);
546
556
  }
547
557
  #sidebar-header {
548
558
  display: flex;
@@ -578,6 +588,22 @@ body {
578
588
  flex-direction: column;
579
589
  min-height: 0;
580
590
  padding: 0 6px 8px;
591
+ margin-right: -1px;
592
+ scrollbar-width: thin;
593
+ scrollbar-color: transparent transparent;
594
+ }
595
+ #sidebar-list.is-scrolling {
596
+ scrollbar-color: var(--color-border-secondary) transparent;
597
+ }
598
+ #sidebar-list::-webkit-scrollbar { width: 4px; }
599
+ #sidebar-list::-webkit-scrollbar-track { background: transparent; }
600
+ #sidebar-list::-webkit-scrollbar-thumb {
601
+ background: transparent;
602
+ border-radius: 4px;
603
+ transition: background 0.4s;
604
+ }
605
+ #sidebar-list.is-scrolling::-webkit-scrollbar-thumb {
606
+ background: var(--color-border-secondary);
581
607
  }
582
608
  #session-list { padding: 4px 0 0; min-height: 6.75rem; }
583
609
 
@@ -598,10 +624,6 @@ body {
598
624
  background: var(--color-bg-primary);
599
625
  z-index: 10;
600
626
  }
601
- .sidebar-divider:first-child {
602
- margin-top: 0;
603
- padding-top: 18px;
604
- }
605
627
  .sidebar-divider span {
606
628
  white-space: nowrap;
607
629
  flex: 0 0 auto;
@@ -2128,7 +2150,7 @@ body {
2128
2150
  .msg-user .msg-time { color: var(--color-text-secondary); right: 0; left: auto; padding-right: 0.25rem; }
2129
2151
  .msg-assistant .msg-time { color: var(--color-text-secondary); left: 0; right: auto; padding-left: 0.25rem; }
2130
2152
 
2131
- .msg-user { background: var(--color-accent-soft); color: var(--color-text-primary); align-self: flex-end; white-space: pre-wrap; border: 1px solid var(--color-border-secondary); }
2153
+ .msg-user { background: var(--color-accent-soft); color: var(--color-text-primary); align-self: flex-end; white-space: pre-wrap; }
2132
2154
  [data-theme="dark"] .msg-user { background: var(--color-accent-soft); }
2133
2155
  .msg-assistant { background: var(--color-bg-tertiary); border: 1px solid var(--color-border-primary); align-self: flex-start; }
2134
2156
 
@@ -2777,11 +2799,11 @@ body {
2777
2799
  #session-info-bar {
2778
2800
  display: flex;
2779
2801
  align-items: center;
2780
- gap: 6px;
2802
+ gap: 0.375rem;
2781
2803
  flex-wrap: wrap;
2782
- padding: 6px 0 6px 0;
2783
- margin-left: 24px;
2784
- margin-right: 24px;
2804
+ padding: 0.375rem 0.75rem;
2805
+ margin-left: 1.5rem;
2806
+ margin-right: 1.5rem;
2785
2807
  background: transparent;
2786
2808
  border-top: none;
2787
2809
  font-size: 0.6875rem;
@@ -6541,6 +6563,24 @@ body {
6541
6563
  background: var(--color-success-bg);
6542
6564
  }
6543
6565
 
6566
+ /* ── Delete button ────────────────────────────────────────────────────────── */
6567
+ .btn-skill-delete {
6568
+ background: transparent;
6569
+ border: 1px solid transparent;
6570
+ border-radius: 6px;
6571
+ color: var(--color-text-tertiary);
6572
+ cursor: pointer;
6573
+ padding: 0.25rem;
6574
+ display: inline-flex;
6575
+ align-items: center;
6576
+ transition: color .15s, border-color .15s, background .15s;
6577
+ }
6578
+ .btn-skill-delete:hover {
6579
+ color: var(--color-error, #c0392b);
6580
+ border-color: var(--color-error-border, #f5c6c6);
6581
+ background: var(--color-error-bg, #fff0f0);
6582
+ }
6583
+
6544
6584
  /* ── Skills sidebar section ──────────────────────────────────────────────── */
6545
6585
  #skill-list-items { padding: 0 0.5rem 0.5rem; display: flex; flex-direction: column; gap: 2px; }
6546
6586
 
@@ -9712,9 +9752,6 @@ body.setup-mode[data-theme="dark"] {
9712
9752
  border-radius: 12px;
9713
9753
  transition: border-color 0.15s ease;
9714
9754
  }
9715
- .billing-stat-card:hover {
9716
- border-color: color-mix(in srgb, var(--color-accent-primary) 40%, transparent);
9717
- }
9718
9755
  .billing-stat-primary {
9719
9756
  border-color: color-mix(in srgb, var(--color-accent-primary) 35%, transparent);
9720
9757
  }
@@ -9762,7 +9799,12 @@ body.setup-mode[data-theme="dark"] {
9762
9799
  }
9763
9800
 
9764
9801
  /* ── Heatmap ─────────────────────────────────────────────────────────── */
9765
- .billing-heatmap-row { width: 100%; }
9802
+ .billing-heatmap-row {
9803
+ display: grid;
9804
+ grid-template-columns: auto 1fr;
9805
+ gap: 1rem;
9806
+ width: 100%;
9807
+ }
9766
9808
  .billing-heat-dow-row {
9767
9809
  display: grid;
9768
9810
  grid-template-columns: repeat(7, 28px);
@@ -9790,11 +9832,11 @@ body.setup-mode[data-theme="dark"] {
9790
9832
  }
9791
9833
  .billing-heat-cell.is-empty { background: transparent; }
9792
9834
  .billing-heat-cell[data-level="0"] { background: var(--color-border-primary); }
9793
- .billing-heat-cell[data-level="1"] { background: #9be9a8; }
9794
- .billing-heat-cell[data-level="2"] { background: #40c463; }
9795
- .billing-heat-cell[data-level="3"] { background: #30a14e; }
9796
- .billing-heat-cell[data-level="4"] { background: #216e39; }
9797
- .billing-heat-cell[data-level="5"] { background: #0a4020; }
9835
+ .billing-heat-cell[data-level="1"] { background: color-mix(in srgb, var(--color-accent-primary) 20%, var(--color-bg-secondary)); }
9836
+ .billing-heat-cell[data-level="2"] { background: color-mix(in srgb, var(--color-accent-primary) 40%, var(--color-bg-secondary)); }
9837
+ .billing-heat-cell[data-level="3"] { background: color-mix(in srgb, var(--color-accent-primary) 60%, var(--color-bg-secondary)); }
9838
+ .billing-heat-cell[data-level="4"] { background: color-mix(in srgb, var(--color-accent-primary) 80%, var(--color-bg-secondary)); }
9839
+ .billing-heat-cell[data-level="5"] { background: var(--color-accent-primary); }
9798
9840
  .billing-heat-legend {
9799
9841
  display: flex;
9800
9842
  align-items: center;
@@ -9808,6 +9850,63 @@ body.setup-mode[data-theme="dark"] {
9808
9850
  aspect-ratio: auto;
9809
9851
  }
9810
9852
 
9853
+ /* ── Heatmap + Trend two-column row ──────────────────────────────────── */
9854
+ .billing-heatmap-card,
9855
+ .billing-trend-card {
9856
+ min-width: 0;
9857
+ min-height: 140px;
9858
+ }
9859
+
9860
+ /* ── Cost Trend Line Chart ───────────────────────────────────────────── */
9861
+ .billing-trend-total {
9862
+ font-size: 0.75rem;
9863
+ font-weight: 600;
9864
+ color: var(--color-accent-primary);
9865
+ }
9866
+ .billing-trend-chart {
9867
+ flex: 1;
9868
+ min-height: 0;
9869
+ }
9870
+ .billing-trend-svg {
9871
+ width: 100%;
9872
+ height: 100%;
9873
+ overflow: visible;
9874
+ }
9875
+ .billing-trend-grid-line {
9876
+ stroke: var(--color-border-primary);
9877
+ stroke-width: 1;
9878
+ }
9879
+ .billing-trend-y-label {
9880
+ font-size: 9px;
9881
+ fill: var(--color-text-secondary);
9882
+ text-anchor: end;
9883
+ }
9884
+ .billing-trend-x-label {
9885
+ font-size: 9px;
9886
+ fill: var(--color-text-secondary);
9887
+ text-anchor: middle;
9888
+ }
9889
+ .billing-trend-line {
9890
+ stroke: var(--color-accent-primary);
9891
+ stroke-width: 1.5;
9892
+ stroke-linecap: round;
9893
+ stroke-linejoin: round;
9894
+ }
9895
+ .billing-trend-area {
9896
+ fill: url(#billing-trend-grad);
9897
+ }
9898
+ .billing-trend-dot {
9899
+ fill: var(--color-accent-primary);
9900
+ stroke: var(--color-bg-secondary);
9901
+ stroke-width: 2;
9902
+ cursor: pointer;
9903
+ opacity: 0;
9904
+ transition: opacity 0.15s;
9905
+ }
9906
+ .billing-trend-svg:hover .billing-trend-dot {
9907
+ opacity: 1;
9908
+ }
9909
+
9811
9910
  /* ── Chart Card base ─────────────────────────────────────────────────── */
9812
9911
  .billing-chart-row { width: 100%; }
9813
9912
  .billing-chart-card {
@@ -10002,6 +10101,8 @@ body.setup-mode[data-theme="dark"] {
10002
10101
  border: 1px solid var(--color-border-primary);
10003
10102
  border-radius: 12px;
10004
10103
  padding: 1.25rem;
10104
+ height: 16rem;
10105
+ overflow-y: auto;
10005
10106
  }
10006
10107
  .billing-section h3 {
10007
10108
  font-size: 0.9375rem;
@@ -10103,6 +10204,7 @@ body.setup-mode[data-theme="dark"] {
10103
10204
  height: 100%;
10104
10205
  background: var(--color-accent-primary);
10105
10206
  border-radius: 3px;
10207
+ min-width: 8px;
10106
10208
  }
10107
10209
  .billing-model-cost {
10108
10210
  font-size: 0.8125rem;
@@ -10603,6 +10705,9 @@ body.setup-mode[data-theme="dark"] {
10603
10705
  text-align: right;
10604
10706
  color: var(--color-text-primary);
10605
10707
  }
10708
+ .billing-cell-total {
10709
+ color: var(--color-accent-primary);
10710
+ }
10606
10711
  .billing-cell-hit {
10607
10712
  color: #3b82f6;
10608
10713
  }
@@ -11180,6 +11285,7 @@ body.setup-mode[data-theme="dark"] {
11180
11285
  transform: translateX(-100%);
11181
11286
  margin-left: 0 !important;
11182
11287
  }
11288
+ #sidebar-resize-handle { display: none; }
11183
11289
 
11184
11290
  /* Overlay backdrop */
11185
11291
  #sidebar-overlay {
@@ -11219,7 +11325,9 @@ body.setup-mode[data-theme="dark"] {
11219
11325
 
11220
11326
  /* Session info bar: single-line, no hover-expand, font smaller */
11221
11327
  #session-info-bar {
11222
- padding: 0.1875rem 0.75rem;
11328
+ padding: 0.1875rem 0.5rem;
11329
+ margin-left: 0.75rem;
11330
+ margin-right: 0.75rem;
11223
11331
  font-size: 0.625rem;
11224
11332
  overflow: hidden;
11225
11333
  white-space: nowrap;
@@ -11246,9 +11354,14 @@ body.setup-mode[data-theme="dark"] {
11246
11354
  margin: 0 0.25rem;
11247
11355
  }
11248
11356
 
11357
+ /* Tighten input area outer padding on mobile */
11358
+ #input-area {
11359
+ padding: 0px 12px 12px;
11360
+ }
11361
+
11249
11362
  /* Input bar: proportional spacing, touch-friendly */
11250
11363
  #input-bar {
11251
- padding: 0.5rem 0.625rem;
11364
+ padding: 0.375rem 0.5rem;
11252
11365
  gap: 0.25rem;
11253
11366
  }
11254
11367
 
@@ -11264,12 +11377,12 @@ body.setup-mode[data-theme="dark"] {
11264
11377
  /* Textarea: prevent iOS auto-zoom (must be ≥1rem) */
11265
11378
  #user-input {
11266
11379
  font-size: 1rem;
11267
- padding: 0.4375rem 0.625rem;
11380
+ padding: 0.1875rem 0.375rem;
11268
11381
  }
11269
11382
 
11270
11383
  /* Send button: bigger tap target */
11271
11384
  #btn-send, #btn-interrupt {
11272
- padding: 0.4375rem 0.875rem;
11385
+ padding: 0.3125rem 0.75rem;
11273
11386
  font-size: 0.875rem;
11274
11387
  }
11275
11388
 
@@ -11434,6 +11547,8 @@ body.setup-mode[data-theme="dark"] {
11434
11547
  #trash-body { padding: 1rem 1rem 5rem; }
11435
11548
  #creator-body { padding: 1rem 1rem 5rem; }
11436
11549
 
11550
+ .billing-heatmap-row { grid-template-columns: 1fr; }
11551
+
11437
11552
  /* ── MCP page ── */
11438
11553
  #mcp-panel {
11439
11554
  overflow: visible;
@@ -513,6 +513,57 @@ if ($("btn-toggle-sidebar")) {
513
513
  // Tap overlay to close sidebar on mobile
514
514
  $("sidebar-overlay").addEventListener("click", _closeSidebar);
515
515
 
516
+ // ── Sidebar resize ──────────────────────────────────────────────────────
517
+ (function _initSidebarResize() {
518
+ const sidebar = $("sidebar");
519
+ const handle = $("sidebar-resize-handle");
520
+ if (!sidebar || !handle) return;
521
+
522
+ const MIN_W = 12; // rem
523
+ const MAX_W = 32; // rem
524
+ const baseFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
525
+
526
+ let startX = 0;
527
+ let startW = 0;
528
+
529
+ // Restore saved width
530
+ const saved = localStorage.getItem("sidebar-width");
531
+ if (saved) {
532
+ const w = parseFloat(saved);
533
+ if (w >= MIN_W && w <= MAX_W) {
534
+ sidebar.style.setProperty("--sidebar-width", w + "rem");
535
+ }
536
+ }
537
+
538
+ function _getWidth() {
539
+ return parseFloat(getComputedStyle(sidebar).getPropertyValue("--sidebar-width"));
540
+ }
541
+
542
+ handle.addEventListener("mousedown", (e) => {
543
+ e.preventDefault();
544
+ startX = e.clientX;
545
+ startW = _getWidth();
546
+ handle.classList.add("active");
547
+ document.body.style.cursor = "col-resize";
548
+ document.body.style.userSelect = "none";
549
+ });
550
+
551
+ document.addEventListener("mousemove", (e) => {
552
+ if (!handle.classList.contains("active")) return;
553
+ const dx = (e.clientX - startX) / baseFontSize;
554
+ const newW = Math.min(MAX_W, Math.max(MIN_W, startW + dx));
555
+ sidebar.style.setProperty("--sidebar-width", newW + "rem");
556
+ });
557
+
558
+ document.addEventListener("mouseup", () => {
559
+ if (!handle.classList.contains("active")) return;
560
+ handle.classList.remove("active");
561
+ document.body.style.cursor = "";
562
+ document.body.style.userSelect = "";
563
+ localStorage.setItem("sidebar-width", _getWidth());
564
+ });
565
+ })();
566
+
516
567
  // On mobile: start with sidebar hidden
517
568
  if (_isMobile()) _closeSidebar();
518
569
 
@@ -124,9 +124,12 @@ const Billing = (() => {
124
124
  `).join("")}
125
125
  </div>
126
126
  <div class="billing-heatmap-row">
127
- <div class="billing-chart-card billing-chart-wide billing-heatmap-card">
127
+ <div class="billing-chart-card billing-heatmap-card">
128
128
  <div class="skel skel-heatmap"></div>
129
129
  </div>
130
+ <div class="billing-chart-card billing-trend-card">
131
+ <div class="skel skel-block-sm"></div>
132
+ </div>
130
133
  </div>
131
134
  <div class="billing-bottom-grid">
132
135
  <div class="billing-section"><div class="skel skel-block"></div></div>
@@ -160,9 +163,12 @@ const Billing = (() => {
160
163
  `).join("")}
161
164
  </div>
162
165
  <div class="billing-heatmap-row">
163
- <div class="billing-chart-card billing-chart-wide billing-heatmap-card">
166
+ <div class="billing-chart-card billing-heatmap-card">
164
167
  <div class="skel skel-heatmap"></div>
165
168
  </div>
169
+ <div class="billing-chart-card billing-trend-card">
170
+ <div class="skel skel-block-sm"></div>
171
+ </div>
166
172
  </div>
167
173
  <div class="billing-bottom-grid">
168
174
  <div class="billing-section"><div class="skel skel-block"></div></div>
@@ -185,7 +191,7 @@ const Billing = (() => {
185
191
  // Model filter options (使用完整模型列表)
186
192
  const models = _allModels.length > 0 ? _allModels : (_summary.by_model ? Object.keys(_summary.by_model) : []);
187
193
  const modelOptions = [`<option value="all">${I18n.t("billing.allModels") || "All Models"}</option>`]
188
- .concat(models.map(m => `<option value="${_esc(m)}" ${m === _currentModel ? "selected" : ""}>${_esc(m)}</option>`))
194
+ .concat(models.filter(m => m).map(m => `<option value="${_esc(m)}" ${m === _currentModel ? "selected" : ""}>${_esc(m)}</option>`))
189
195
  .join("");
190
196
 
191
197
  container.innerHTML = `
@@ -214,7 +220,7 @@ const Billing = (() => {
214
220
  </div>
215
221
 
216
222
  <div class="billing-stats-row">
217
- <div class="billing-stat-card billing-stat-primary">
223
+ <div class="billing-stat-card">
218
224
  <div class="billing-stat-icon billing-stat-icon-cost">
219
225
  <svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"><circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="1.5"/><path d="M10 6v1m0 6v1M7.5 10a2.5 2.5 0 0 0 2.5 2.5c1.38 0 2.5-.56 2.5-1.25S11.38 10 10 10c-1.38 0-2.5-.56-2.5-1.25S8.62 7.5 10 7.5A2.5 2.5 0 0 1 12.5 10" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>
220
226
  </div>
@@ -254,6 +260,7 @@ const Billing = (() => {
254
260
 
255
261
  <div class="billing-heatmap-row">
256
262
  ${_renderHeatmap()}
263
+ ${_renderCostTrend()}
257
264
  </div>
258
265
 
259
266
  <div class="billing-bottom-grid">
@@ -294,6 +301,7 @@ const Billing = (() => {
294
301
  // Bind chart tooltip handlers
295
302
  _bindChartTooltip();
296
303
  _bindHeatmapTooltip();
304
+ _bindTrendTooltip();
297
305
  }
298
306
 
299
307
  // Builds the per-period scorecard numbers from a raw summary object, using
@@ -445,6 +453,41 @@ const Billing = (() => {
445
453
  });
446
454
  }
447
455
 
456
+ function _bindTrendTooltip() {
457
+ const svg = document.querySelector(".billing-trend-svg");
458
+ const tooltip = document.getElementById("billing-tooltip");
459
+ if (!svg || !tooltip) return;
460
+
461
+ svg.addEventListener("mousemove", (e) => {
462
+ const dot = e.target.closest(".billing-trend-dot");
463
+ if (!dot) {
464
+ tooltip.style.display = "none";
465
+ return;
466
+ }
467
+ tooltip.innerHTML = `
468
+ <div class="tooltip-header">
469
+ <span class="tooltip-date">${dot.dataset.date}</span>
470
+ </div>
471
+ <div class="tooltip-row">
472
+ <span class="tooltip-label">${I18n.t("billing.cost") || "Cost"}</span>
473
+ <span class="tooltip-value">${dot.dataset.cost}</span>
474
+ </div>
475
+ `;
476
+ tooltip.style.display = "block";
477
+ tooltip.style.visibility = "hidden";
478
+ const rect = tooltip.getBoundingClientRect();
479
+ const ovf = e.clientX + 15 + rect.width - window.innerWidth;
480
+ tooltip.style.left = ovf > 0 ? `${e.clientX - 15 - rect.width}px` : `${e.clientX + 15}px`;
481
+ tooltip.style.top = `${e.clientY - 10}px`;
482
+ tooltip.style.visibility = "visible";
483
+ });
484
+
485
+ svg.addEventListener("mouseleave", () => {
486
+ tooltip.style.display = "none";
487
+ tooltip.style.visibility = "";
488
+ });
489
+ }
490
+
448
491
  function _bindClearHandlers() {
449
492
  const clearBtn = document.getElementById("billing-clear-btn");
450
493
  const clearPopup = document.getElementById("billing-clear-popup");
@@ -600,6 +643,7 @@ const Billing = (() => {
600
643
  }
601
644
 
602
645
  const entries = Object.entries(_summary.by_model)
646
+ .filter(([_, data]) => (typeof data === "object" ? data.cost : data) > 0)
603
647
  .sort((a, b) => (b[1].cost || b[1]) - (a[1].cost || a[1]));
604
648
 
605
649
  const totalCost = entries.reduce((sum, [, data]) => sum + (typeof data === "object" ? data.cost : data), 0) || 1;
@@ -653,7 +697,7 @@ const Billing = (() => {
653
697
  const dowHeader = dowLabels.map(l => `<span class="billing-heat-dow">${_esc(l)}</span>`).join("");
654
698
 
655
699
  return `
656
- <div class="billing-chart-card billing-chart-wide billing-heatmap-card">
700
+ <div class="billing-chart-card billing-heatmap-card">
657
701
  <div class="billing-chart-header">
658
702
  <h4>${I18n.t("billing.heatmap.title") || "Activity"}</h4>
659
703
  <div class="billing-heat-legend">
@@ -672,6 +716,88 @@ const Billing = (() => {
672
716
  `;
673
717
  }
674
718
 
719
+ function _renderCostTrend() {
720
+ if (!_daily || _daily.length < 2) {
721
+ return `<div class="billing-chart-card billing-trend-card"><div class="billing-chart-empty">${I18n.t("billing.noData") || "No data available"}</div></div>`;
722
+ }
723
+
724
+ const days = _daily.slice(-30);
725
+ const costs = days.map(d => _convertCost(d.cost || 0));
726
+ const maxCost = Math.max(...costs, 0.0001);
727
+ const minCost = Math.min(...costs);
728
+
729
+ const pad = { top: 20, right: 16, bottom: 22, left: 48 };
730
+ const w = 400;
731
+ const h = 140;
732
+ const plotW = w - pad.left - pad.right;
733
+ const plotH = h - pad.top - pad.bottom;
734
+
735
+ const range = maxCost - minCost || 1;
736
+ const xStep = days.length > 1 ? plotW / (days.length - 1) : plotW;
737
+ const points = costs.map((c, i) => {
738
+ const x = pad.left + i * xStep;
739
+ const y = pad.top + plotH - ((c - minCost) / range) * plotH;
740
+ return `${x},${y}`;
741
+ }).join(" ");
742
+
743
+ const areaPoints = costs.length > 0
744
+ ? `${pad.left},${pad.top + plotH} ${points} ${pad.left + (costs.length - 1) * xStep},${pad.top + plotH}`
745
+ : "";
746
+
747
+ const yTicks = 4;
748
+ const yLabels = Array.from({ length: yTicks + 1 }, (_, i) => {
749
+ const val = minCost + (range / yTicks) * i;
750
+ const y = pad.top + plotH - ((val - minCost) / range) * plotH;
751
+ return { val, y };
752
+ });
753
+
754
+ const showEvery = days.length > 20 ? 10 : days.length > 10 ? 5 : days.length > 5 ? 3 : 1;
755
+ const xLabels = [];
756
+ let lastX = -50;
757
+ days.forEach((d, i) => {
758
+ if (i % showEvery !== 0 && i !== days.length - 1) return;
759
+ const x = pad.left + i * xStep;
760
+ if (x - lastX < 40) return;
761
+ lastX = x;
762
+ xLabels.push({ date: d.date.slice(5), x });
763
+ });
764
+
765
+ const currencySymbol = _getCurrencySymbol();
766
+
767
+ return `
768
+ <div class="billing-chart-card billing-trend-card">
769
+ <div class="billing-chart-header">
770
+ <h4>${I18n.t("billing.costTrend") || "Cost Trend"}</h4>
771
+ <span class="billing-trend-total">${currencySymbol}${_formatCost(costs.reduce((a, b) => a + b, 0))}</span>
772
+ </div>
773
+ <div class="billing-trend-chart">
774
+ <svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" class="billing-trend-svg">
775
+ ${yLabels.map(l => `
776
+ <line x1="${pad.left}" y1="${l.y}" x2="${w - pad.right}" y2="${l.y}" class="billing-trend-grid-line" />
777
+ <text x="${pad.left - 6}" y="${l.y + 4}" class="billing-trend-y-label">${currencySymbol}${_formatCost(l.val)}</text>
778
+ `).join("")}
779
+ ${xLabels.map(l => `
780
+ <text x="${l.x}" y="${h - 4}" class="billing-trend-x-label">${l.date}</text>
781
+ `).join("")}
782
+ <defs>
783
+ <linearGradient id="billing-trend-grad" x1="0" y1="0" x2="0" y2="1">
784
+ <stop offset="0%" stop-color="#4f46e5" stop-opacity="0.15" />
785
+ <stop offset="100%" stop-color="#4f46e5" stop-opacity="0.02" />
786
+ </linearGradient>
787
+ </defs>
788
+ <polygon points="${areaPoints}" fill="url(#billing-trend-grad)" class="billing-trend-area" />
789
+ <polyline points="${points}" fill="none" class="billing-trend-line" />
790
+ ${costs.map((c, i) => {
791
+ const cx = pad.left + i * xStep;
792
+ const cy = pad.top + plotH - ((c - minCost) / range) * plotH;
793
+ return `<circle cx="${cx}" cy="${cy}" r="3" class="billing-trend-dot" data-date="${days[i].date}" data-cost="${currencySymbol}${_formatCost(c)}" />`;
794
+ }).join("")}
795
+ </svg>
796
+ </div>
797
+ </div>
798
+ `;
799
+ }
800
+
675
801
  function _renderCombinedChart() {
676
802
  if (!_daily || _daily.length === 0) {
677
803
  return `<div class="billing-chart-card billing-chart-wide"><div class="billing-chart-empty">${I18n.t("billing.noData") || "No data available"}</div></div>`;
@@ -782,7 +908,7 @@ const Billing = (() => {
782
908
  <span class="billing-cell-main">${_esc(displayName)}</span>
783
909
  <span class="billing-cell-sub">${requests} ${I18n.t("billing.requests") || "req"} · ${_esc(models)}</span>
784
910
  </div>
785
- <div class="billing-cell billing-cell-number">${_formatCompact(totalTokens)}</div>
911
+ <div class="billing-cell billing-cell-number billing-cell-total">${_formatCompact(totalTokens)}</div>
786
912
  <div class="billing-cell billing-cell-number billing-cell-hit">${_formatCompact(cacheHit)}</div>
787
913
  <div class="billing-cell billing-cell-number billing-cell-miss">${_formatCompact(cacheMiss)}</div>
788
914
  <div class="billing-cell billing-cell-number">${_formatCompact(completionTokens)}</div>
@@ -434,6 +434,10 @@ const I18n = (() => {
434
434
  "skills.toggle.disableDesc": "AI can auto-invoke. Disable to prevent auto-triggering (manual use still available)",
435
435
  "skills.toggle.enableDesc": "AI cannot auto-invoke. Enable to allow auto-triggering",
436
436
  "skills.toggleError": "Error: ",
437
+ "skills.deleteConfirm": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.",
438
+ "skills.deleteError": "Delete failed.",
439
+ "skills.deleted": "Skill \"{{name}}\" deleted.",
440
+ "skills.btn.delete": "Delete skill",
437
441
  "skills.ac.empty": "No skills available",
438
442
  "skills.upload.uploading": "Uploading…",
439
443
  "skills.upload.uploaded": "Uploaded",
@@ -1314,6 +1318,10 @@ const I18n = (() => {
1314
1318
  "skills.toggle.disableDesc": "AI 可自动调用。关闭后 AI 不会主动触发(手动使用仍可用)",
1315
1319
  "skills.toggle.enableDesc": "AI 不会自动调用。开启后允许 AI 主动触发",
1316
1320
  "skills.toggleError": "错误:",
1321
+ "skills.deleteConfirm": "确定要删除 \"{{name}}\" 吗?此操作不可撤销。",
1322
+ "skills.deleteError": "删除失败。",
1323
+ "skills.deleted": "技能 \"{{name}}\" 已删除。",
1324
+ "skills.btn.delete": "删除技能",
1317
1325
  "skills.ac.empty": "暂无可用技能",
1318
1326
  "skills.upload.uploading": "上传中…",
1319
1327
  "skills.upload.uploaded": "已上传",
@@ -291,6 +291,7 @@
291
291
  </span>
292
292
  </div>
293
293
  </div>
294
+ <div id="sidebar-resize-handle"></div>
294
295
  </aside>
295
296
 
296
297
  <!-- ── MAIN ─────────────────────────────────────────────────────────── -->
@@ -423,6 +423,8 @@ const Onboard = (() => {
423
423
  const zh = _selectedLang === "zh";
424
424
  _setDeviceError("");
425
425
 
426
+ const w = window.open("about:blank", "_blank");
427
+
426
428
  let data;
427
429
  try {
428
430
  const res = await fetch("/api/onboard/device/start", { method: "POST" });
@@ -432,6 +434,7 @@ const Onboard = (() => {
432
434
  }
433
435
 
434
436
  if (!data || !data.ok) {
437
+ if (w && !w.closed) w.close();
435
438
  _setDeviceError((data && data.error) || (zh ? "无法发起登录,请稍后重试。" : "Could not start login. Please try again."));
436
439
  return;
437
440
  }
@@ -443,7 +446,11 @@ const Onboard = (() => {
443
446
  if (link && url) link.href = url;
444
447
 
445
448
  _showDevicePending(true);
446
- if (url) window.open(url, "_blank", "noopener");
449
+ if (w && !w.closed) {
450
+ w.location.href = url;
451
+ } else {
452
+ window.open(url, "_blank");
453
+ }
447
454
 
448
455
  _devicePolling = true;
449
456
  _pollDevice(data.device_code, (data.interval || 5) * 1000);
@@ -5235,3 +5235,14 @@ document.addEventListener("langchange", () => {
5235
5235
  document.addEventListener("currencychange", () => {
5236
5236
  if (Sessions._lastSession) Sessions.updateInfoBar(Sessions._lastSession);
5237
5237
  });
5238
+
5239
+ (function () {
5240
+ const sidebarList = document.getElementById("sidebar-list");
5241
+ if (!sidebarList) return;
5242
+ let scrollTimer = null;
5243
+ sidebarList.addEventListener("scroll", () => {
5244
+ sidebarList.classList.add("is-scrolling");
5245
+ clearTimeout(scrollTimer);
5246
+ scrollTimer = setTimeout(() => sidebarList.classList.remove("is-scrolling"), 1000);
5247
+ }, { passive: true });
5248
+ })();
@@ -336,6 +336,15 @@ const Skills = (() => {
336
336
  ? ""
337
337
  : `<button class="btn-skill-use" data-name="${escapeHtml(skill.name)}">${I18n.t("skills.btn.use")}</button>`;
338
338
 
339
+ // Delete button only for custom (non-system, non-brand) skills
340
+ const deleteButtonHtml = isSystem
341
+ ? ""
342
+ : `<button class="btn-skill-delete" data-name="${escapeHtml(skill.name)}" title="${I18n.t("skills.btn.delete")}">
343
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
344
+ <polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
345
+ </svg>
346
+ </button>`;
347
+
339
348
  card.innerHTML = `
340
349
  <div class="skill-card-main">
341
350
  <div class="skill-card-info">
@@ -353,6 +362,7 @@ const Skills = (() => {
353
362
  <span class="skill-toggle-track"></span>
354
363
  </label>
355
364
  ${useButtonHtml}
365
+ ${deleteButtonHtml}
356
366
  </div>
357
367
  </div>
358
368
  ${errorNoticeHtml}`;
@@ -385,6 +395,15 @@ const Skills = (() => {
385
395
  useBtn.addEventListener("click", () => _useInstalledSkill(skill.name));
386
396
  }
387
397
 
398
+ // Bind delete button event
399
+ const deleteBtn = card.querySelector(".btn-skill-delete");
400
+ if (deleteBtn) {
401
+ deleteBtn.addEventListener("click", (e) => {
402
+ e.stopPropagation();
403
+ Skills.delete(skill.name);
404
+ });
405
+ }
406
+
388
407
  return card;
389
408
  }
390
409
 
@@ -579,6 +598,20 @@ const Skills = (() => {
579
598
  }
580
599
  },
581
600
 
601
+ /** Delete a skill by name. Prompts confirmation, then DELETE to server. */
602
+ async delete(name) {
603
+ if (!confirm(I18n.t("skills.deleteConfirm", { name }))) return;
604
+ try {
605
+ const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, { method: "DELETE" });
606
+ const data = await res.json();
607
+ if (!res.ok) { alert(data.error || I18n.t("skills.deleteError")); return; }
608
+ Modal.toast(I18n.t("skills.deleted", { name }), "success");
609
+ await Skills.load();
610
+ } catch (e) {
611
+ console.error("[Skills] delete failed", e);
612
+ }
613
+ },
614
+
582
615
  /** Switch the Skills panel to the brand-skills tab.
583
616
  * Called externally (e.g. from settings.js after license activation) to
584
617
  * guide the user directly to the Brand Skills download page.
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 1.3.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
8
- autorequire:
9
8
  bindir: bin
10
9
  cert_chain: []
11
- date: 2026-06-16 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: faraday
@@ -270,8 +269,8 @@ email:
270
269
  - yafei@dao42.com
271
270
  executables:
272
271
  - clacky
273
- - openclacky
274
272
  - clarky
273
+ - openclacky
275
274
  extensions: []
276
275
  extra_rdoc_files: []
277
276
  files:
@@ -632,7 +631,6 @@ metadata:
632
631
  homepage_uri: https://github.com/clacky-ai/openclacky
633
632
  source_code_uri: https://github.com/clacky-ai/openclacky
634
633
  changelog_uri: https://github.com/clacky-ai/openclacky/blob/main/CHANGELOG.md
635
- post_install_message:
636
634
  rdoc_options: []
637
635
  require_paths:
638
636
  - lib
@@ -650,8 +648,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
650
648
  - !ruby/object:Gem::Version
651
649
  version: '0'
652
650
  requirements: []
653
- rubygems_version: 3.5.22
654
- signing_key:
651
+ rubygems_version: 3.6.9
655
652
  specification_version: 4
656
653
  summary: The most Token-efficient open-source AI Agent — BYOK, Skill-driven, IM-integrated.
657
654
  test_files: []