openclacky 1.2.7 → 1.2.9

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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/lib/clacky/agent.rb +3 -0
  4. data/lib/clacky/agent_config.rb +91 -7
  5. data/lib/clacky/billing/billing_store.rb +107 -3
  6. data/lib/clacky/cli.rb +105 -0
  7. data/lib/clacky/client.rb +38 -5
  8. data/lib/clacky/default_skills/channel-manager/SKILL.md +33 -110
  9. data/lib/clacky/default_skills/deploy/SKILL.md +2 -1
  10. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +39 -0
  11. data/lib/clacky/default_skills/mcp-manager/SKILL.md +0 -7
  12. data/lib/clacky/default_skills/media-gen/SKILL.md +128 -0
  13. data/lib/clacky/media/base.rb +68 -0
  14. data/lib/clacky/media/gemini.rb +36 -0
  15. data/lib/clacky/media/generator.rb +78 -0
  16. data/lib/clacky/media/openai_compat.rb +168 -0
  17. data/lib/clacky/patch_loader.rb +282 -0
  18. data/lib/clacky/providers.rb +82 -0
  19. data/lib/clacky/server/channel/adapters/base.rb +4 -0
  20. data/lib/clacky/server/channel/channel_manager.rb +1 -1
  21. data/lib/clacky/server/channel/user_adapter_loader.rb +177 -0
  22. data/lib/clacky/server/channel.rb +5 -0
  23. data/lib/clacky/server/http_server.rb +236 -25
  24. data/lib/clacky/server/scheduler.rb +1 -4
  25. data/lib/clacky/shell_hook_loader.rb +181 -0
  26. data/lib/clacky/telemetry.rb +11 -5
  27. data/lib/clacky/version.rb +1 -1
  28. data/lib/clacky/web/app.css +326 -24
  29. data/lib/clacky/web/billing.js +117 -22
  30. data/lib/clacky/web/i18n.js +84 -6
  31. data/lib/clacky/web/index.html +14 -2
  32. data/lib/clacky/web/model-tester.js +58 -0
  33. data/lib/clacky/web/onboard.js +17 -30
  34. data/lib/clacky/web/settings.js +322 -97
  35. data/lib/clacky.rb +9 -0
  36. data/scripts/build/lib/network.sh +61 -30
  37. data/scripts/install.sh +61 -30
  38. data/scripts/install_browser.sh +61 -30
  39. data/scripts/install_full.sh +61 -30
  40. data/scripts/install_rails_deps.sh +61 -30
  41. data/scripts/install_system_deps.sh +61 -30
  42. metadata +12 -3
  43. data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +0 -574
@@ -3858,6 +3858,7 @@ body {
3858
3858
  .model-card-grid-actions {
3859
3859
  display: flex;
3860
3860
  flex-direction: row;
3861
+ align-items: center;
3861
3862
  flex-wrap: wrap;
3862
3863
  gap: 0.5rem;
3863
3864
  padding-top: 0.625rem;
@@ -3867,7 +3868,7 @@ body {
3867
3868
  display: inline-flex;
3868
3869
  align-items: center;
3869
3870
  justify-content: center;
3870
- gap: 0.25rem;
3871
+ gap: 0.3125rem;
3871
3872
  padding: 0.375rem 0.5rem;
3872
3873
  border: 1px solid var(--color-border-primary);
3873
3874
  border-radius: 6px;
@@ -3877,15 +3878,9 @@ body {
3877
3878
  cursor: pointer;
3878
3879
  transition: all 0.15s;
3879
3880
  white-space: nowrap;
3881
+ min-width: 0;
3880
3882
  }
3881
- .btn-card-grid-action svg {
3882
- flex-shrink: 0;
3883
- }
3884
- .btn-card-grid-action span {
3885
- overflow: hidden;
3886
- text-overflow: ellipsis;
3887
- white-space: nowrap;
3888
- }
3883
+ .btn-card-grid-action svg { flex-shrink: 0; }
3889
3884
  .btn-card-grid-action:hover:not(:disabled) {
3890
3885
  border-color: var(--color-accent-primary);
3891
3886
  color: var(--color-accent-primary);
@@ -3895,13 +3890,22 @@ body {
3895
3890
  opacity: 0.5;
3896
3891
  cursor: not-allowed;
3897
3892
  }
3893
+ .btn-card-grid-action-primary {
3894
+ background: var(--color-bg-secondary);
3895
+ color: var(--color-text-primary);
3896
+ font-weight: 500;
3897
+ }
3898
3898
  .btn-card-grid-action-danger:hover:not(:disabled) {
3899
3899
  border-color: var(--color-error);
3900
3900
  color: var(--color-error);
3901
3901
  background: color-mix(in srgb, var(--color-error) 8%, transparent);
3902
3902
  }
3903
- a.btn-card-grid-action {
3904
- text-decoration: none;
3903
+ .model-card-grid-toolbar {
3904
+ display: flex;
3905
+ flex-wrap: wrap;
3906
+ gap: 0.375rem;
3907
+ margin-left: auto;
3908
+ justify-content: flex-end;
3905
3909
  }
3906
3910
  .model-card-grid-footer {
3907
3911
  display: flex;
@@ -9018,11 +9022,10 @@ body.setup-mode[data-theme="dark"] {
9018
9022
  transition: height 0.2s ease;
9019
9023
  }
9020
9024
  .billing-cache-miss {
9021
- background: #3b82f6;
9025
+ background: #f59e0b;
9022
9026
  width: 100%;
9023
9027
  transition: height 0.2s ease;
9024
- }
9025
- .billing-output-bar {
9028
+ }.billing-output-bar {
9026
9029
  width: 12px;
9027
9030
  background: #10b981;
9028
9031
  border-radius: 2px 2px 0 0;
@@ -9038,10 +9041,10 @@ body.setup-mode[data-theme="dark"] {
9038
9041
  }
9039
9042
 
9040
9043
  /* Legend colors */
9044
+ .billing-legend-total { background: #6366f1; }
9041
9045
  .billing-legend-cache-hit { background: #93c5fd; }
9042
- .billing-legend-cache-miss { background: #3b82f6; }
9046
+ .billing-legend-cache-miss { background: #f59e0b; }
9043
9047
  .billing-legend-output { background: #10b981; }
9044
-
9045
9048
  /* ── Chart Tooltip ───────────────────────────────────────────────────── */
9046
9049
  .billing-chart-tooltip {
9047
9050
  display: none;
@@ -9085,10 +9088,10 @@ body.setup-mode[data-theme="dark"] {
9085
9088
  border-radius: 50%;
9086
9089
  flex-shrink: 0;
9087
9090
  }
9091
+ .tooltip-total { background: #6366f1; }
9088
9092
  .tooltip-cache-hit { background: #93c5fd; }
9089
- .tooltip-cache-miss { background: #3b82f6; }
9090
- .tooltip-output { background: #10b981; }
9091
- .tooltip-label {
9093
+ .tooltip-cache-miss { background: #f59e0b; }
9094
+ .tooltip-output { background: #10b981; }.tooltip-label {
9092
9095
  flex: 1;
9093
9096
  color: var(--color-text-secondary);
9094
9097
  }
@@ -9155,8 +9158,8 @@ body.setup-mode[data-theme="dark"] {
9155
9158
  border-radius: 3px;
9156
9159
  transition: width 0.3s ease;
9157
9160
  }
9158
- .billing-bar-prompt {
9159
- background: linear-gradient(90deg, #3b82f6 0%, #60a5fa 100%);
9161
+ .billing-bar-total {
9162
+ background: linear-gradient(90deg, #6366f1 0%, #818cf8 100%);
9160
9163
  }
9161
9164
  .billing-bar-completion {
9162
9165
  background: linear-gradient(90deg, #10b981 0%, #34d399 100%);
@@ -9164,10 +9167,9 @@ body.setup-mode[data-theme="dark"] {
9164
9167
  .billing-bar-cache-read {
9165
9168
  background: linear-gradient(90deg, #93c5fd 0%, #bfdbfe 100%);
9166
9169
  }
9167
- .billing-bar-cache-write {
9168
- background: linear-gradient(90deg, #6366f1 0%, #818cf8 100%);
9170
+ .billing-bar-cache-miss {
9171
+ background: linear-gradient(90deg, #f59e0b 0%, #fbbf24 100%);
9169
9172
  }
9170
-
9171
9173
  /* ── Model List ──────────────────────────────────────────────────────── */
9172
9174
  .billing-model-list {
9173
9175
  display: flex;
@@ -9569,3 +9571,303 @@ body.setup-mode[data-theme="dark"] {
9569
9571
  color: var(--color-text-secondary);
9570
9572
  font-weight: 500;
9571
9573
  }
9574
+
9575
+ /* ── Sessions List ───────────────────────────────────────────────────── */
9576
+ .billing-sessions-row {
9577
+ margin-top: 1rem;
9578
+ }
9579
+ .billing-sessions-section {
9580
+ background: var(--color-bg-secondary);
9581
+ border: 1px solid var(--color-border-primary);
9582
+ border-radius: 12px;
9583
+ padding: 1.25rem;
9584
+ }
9585
+ .billing-sessions-section h3 {
9586
+ font-size: 0.9375rem;
9587
+ font-weight: 600;
9588
+ color: var(--color-text-primary);
9589
+ margin: 0 0 1rem 0;
9590
+ }
9591
+ .billing-sessions-empty {
9592
+ text-align: center;
9593
+ padding: 2rem;
9594
+ color: var(--color-text-secondary);
9595
+ font-size: 0.875rem;
9596
+ }
9597
+ .billing-sessions-header {
9598
+ display: grid;
9599
+ grid-template-columns: 36px 1.5fr 0.8fr 0.8fr 0.8fr 0.8fr 0.8fr 1fr;
9600
+ gap: 0.5rem;
9601
+ padding: 0.75rem 1rem;
9602
+ background: var(--color-bg-tertiary);
9603
+ border-radius: 8px;
9604
+ font-size: 0.75rem;
9605
+ font-weight: 600;
9606
+ color: var(--color-text-secondary);
9607
+ text-transform: uppercase;
9608
+ letter-spacing: 0.05em;
9609
+ margin-bottom: 0.5rem;
9610
+ }
9611
+ .billing-sessions-list {
9612
+ display: flex;
9613
+ flex-direction: column;
9614
+ gap: 0.25rem;
9615
+ }
9616
+ .billing-session-row {
9617
+ display: grid;
9618
+ grid-template-columns: 36px 1.5fr 0.8fr 0.8fr 0.8fr 0.8fr 0.8fr 1fr;
9619
+ gap: 0.5rem;
9620
+ padding: 0.75rem 1rem;
9621
+ border-radius: 8px;
9622
+ transition: background 0.15s;
9623
+ align-items: center;
9624
+ }
9625
+ .billing-session-row:hover {
9626
+ background: var(--color-bg-tertiary);
9627
+ }
9628
+ .billing-session-deleted {
9629
+ opacity: 0.7;
9630
+ border-left: 3px solid var(--color-warning);
9631
+ }
9632
+ .billing-session-deleted .billing-cell-main {
9633
+ color: var(--color-text-secondary);
9634
+ font-style: italic;
9635
+ }
9636
+ .billing-cell {
9637
+ font-size: 0.875rem;
9638
+ white-space: nowrap;
9639
+ overflow: hidden;
9640
+ text-overflow: ellipsis;
9641
+ }
9642
+ .billing-cell-index {
9643
+ font-size: 0.75rem;
9644
+ color: var(--color-text-secondary);
9645
+ text-align: center;
9646
+ }
9647
+ .billing-cell-session {
9648
+ display: flex;
9649
+ flex-direction: column;
9650
+ gap: 0.125rem;
9651
+ min-width: 0;
9652
+ }
9653
+ .billing-cell-main {
9654
+ font-weight: 500;
9655
+ color: var(--color-text-primary);
9656
+ font-family: var(--font-mono);
9657
+ white-space: nowrap;
9658
+ overflow: hidden;
9659
+ text-overflow: ellipsis;
9660
+ }
9661
+ .billing-cell-sub {
9662
+ font-size: 0.7rem;
9663
+ color: var(--color-text-secondary);
9664
+ white-space: nowrap;
9665
+ overflow: hidden;
9666
+ text-overflow: ellipsis;
9667
+ }
9668
+ .billing-cell-number {
9669
+ font-family: var(--font-mono);
9670
+ text-align: right;
9671
+ color: var(--color-text-primary);
9672
+ }
9673
+ .billing-cell-hit {
9674
+ color: #3b82f6;
9675
+ }
9676
+ .billing-cell-miss {
9677
+ color: #f59e0b;
9678
+ }
9679
+ .billing-cell-cost {
9680
+ font-family: var(--font-mono);
9681
+ font-weight: 600;
9682
+ color: var(--color-accent);
9683
+ text-align: right;
9684
+ }
9685
+ .billing-cell-time {
9686
+ font-size: 0.75rem;
9687
+ color: var(--color-text-secondary);
9688
+ }
9689
+ @media (max-width: 768px) {
9690
+ .billing-sessions-header {
9691
+ display: none;
9692
+ }
9693
+ .billing-session-row {
9694
+ grid-template-columns: 1fr 1fr;
9695
+ gap: 0.5rem;
9696
+ padding: 1rem;
9697
+ }
9698
+ .billing-cell-index {
9699
+ display: none;
9700
+ }
9701
+ .billing-cell-session {
9702
+ grid-column: 1 / -1;
9703
+ }
9704
+ .billing-cell-number {
9705
+ text-align: left;
9706
+ }
9707
+ .billing-cell-number::before {
9708
+ font-size: 0.625rem;
9709
+ color: var(--color-text-secondary);
9710
+ text-transform: uppercase;
9711
+ display: block;
9712
+ }
9713
+ .billing-cell-time {
9714
+ grid-column: 1 / -1;
9715
+ text-align: right;
9716
+ }
9717
+ }
9718
+
9719
+ /* ════ Media generation (Settings → Models tab) ════ */
9720
+ .settings-section-desc {
9721
+ font-size: 0.8125rem;
9722
+ color: var(--color-text-secondary);
9723
+ line-height: 1.5;
9724
+ margin: -0.25rem 0 0.5rem;
9725
+ }
9726
+ .media-row {
9727
+ border: 1px solid var(--color-border-primary);
9728
+ border-radius: 8px;
9729
+ background: var(--color-bg-secondary);
9730
+ margin-bottom: 0.5rem;
9731
+ overflow: hidden;
9732
+ }
9733
+ .media-row.is-expanded {
9734
+ background: var(--color-bg-secondary);
9735
+ }
9736
+ .media-row-head {
9737
+ display: flex;
9738
+ align-items: center;
9739
+ gap: 0.75rem;
9740
+ padding: 0.5rem 0.75rem;
9741
+ min-height: 2.25rem;
9742
+ }
9743
+ .media-row-title {
9744
+ font-size: 0.875rem;
9745
+ font-weight: 600;
9746
+ color: var(--color-text-primary);
9747
+ min-width: 3rem;
9748
+ }
9749
+ .media-row-segmented {
9750
+ display: inline-flex;
9751
+ background: var(--color-bg-primary);
9752
+ border: 1px solid var(--color-border-primary);
9753
+ border-radius: 6px;
9754
+ padding: 1px;
9755
+ }
9756
+ .media-row-segmented button {
9757
+ background: transparent;
9758
+ border: none;
9759
+ padding: 0.25rem 0.625rem;
9760
+ font-size: 0.75rem;
9761
+ color: var(--color-text-secondary);
9762
+ border-radius: 5px;
9763
+ cursor: pointer;
9764
+ transition: background-color 0.15s ease, color 0.15s ease;
9765
+ font-family: inherit;
9766
+ }
9767
+ .media-row-segmented button:hover:not(.is-active):not(:disabled) {
9768
+ color: var(--color-text-primary);
9769
+ }
9770
+ .media-row-segmented button.is-active {
9771
+ background: var(--color-button-primary);
9772
+ color: #fff;
9773
+ }
9774
+ .media-row-segmented button:disabled {
9775
+ opacity: 0.4;
9776
+ cursor: not-allowed;
9777
+ }
9778
+ .media-row-status {
9779
+ margin-left: auto;
9780
+ font-size: 0.75rem;
9781
+ color: var(--color-text-secondary);
9782
+ font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
9783
+ white-space: nowrap;
9784
+ overflow: hidden;
9785
+ text-overflow: ellipsis;
9786
+ max-width: 16rem;
9787
+ }
9788
+ .media-row-detail {
9789
+ border-top: 1px solid var(--color-border-primary);
9790
+ padding: 0.625rem 0.75rem;
9791
+ font-size: 0.8125rem;
9792
+ color: var(--color-text-secondary);
9793
+ line-height: 1.5;
9794
+ background: var(--color-bg-primary);
9795
+ }
9796
+ .media-row-detail.is-warning {
9797
+ color: var(--color-warning, #d97706);
9798
+ }
9799
+ .media-row-hint {
9800
+ margin-top: 0.375rem;
9801
+ font-size: 0.75rem;
9802
+ color: var(--color-text-tertiary, var(--color-text-secondary));
9803
+ }
9804
+ .media-row-detail .media-kv {
9805
+ display: grid;
9806
+ grid-template-columns: max-content 1fr;
9807
+ gap: 0.25rem 0.75rem;
9808
+ font-size: 0.8125rem;
9809
+ }
9810
+ .media-row-detail .media-kv-key {
9811
+ color: var(--color-text-secondary);
9812
+ }
9813
+ .media-row-detail .media-kv-val {
9814
+ color: var(--color-text-primary);
9815
+ font-weight: 500;
9816
+ word-break: break-all;
9817
+ }
9818
+ .media-row-actions {
9819
+ display: flex;
9820
+ gap: 0.5rem;
9821
+ margin-top: 0.5rem;
9822
+ justify-content: flex-end;
9823
+ }
9824
+ .media-row-btn {
9825
+ background: transparent;
9826
+ border: 1px solid var(--color-border-primary);
9827
+ color: var(--color-text-primary);
9828
+ border-radius: 6px;
9829
+ padding: 0.3125rem 0.75rem;
9830
+ font-size: 0.75rem;
9831
+ cursor: pointer;
9832
+ transition: background-color 0.15s ease;
9833
+ font-family: inherit;
9834
+ }
9835
+ .media-row-btn:hover {
9836
+ background: var(--color-bg-secondary);
9837
+ }
9838
+ .media-row-btn.is-primary {
9839
+ background: var(--color-button-primary);
9840
+ color: #fff;
9841
+ border-color: transparent;
9842
+ }
9843
+ .media-row-btn.is-primary:hover {
9844
+ background: var(--color-button-primary-hover);
9845
+ }
9846
+ .media-custom-form {
9847
+ display: grid;
9848
+ grid-template-columns: max-content 1fr;
9849
+ gap: 0.4375rem 0.625rem;
9850
+ align-items: center;
9851
+ }
9852
+ .media-custom-form label {
9853
+ font-size: 0.75rem;
9854
+ color: var(--color-text-secondary);
9855
+ }
9856
+ .media-custom-form input {
9857
+ width: 100%;
9858
+ background: var(--color-bg-secondary);
9859
+ border: 1px solid var(--color-border-primary);
9860
+ color: var(--color-text-primary);
9861
+ border-radius: 6px;
9862
+ padding: 0.375rem 0.5rem;
9863
+ font-size: 0.8125rem;
9864
+ font-family: inherit;
9865
+ box-sizing: border-box;
9866
+ }
9867
+ .media-custom-form .media-form-actions {
9868
+ grid-column: 1 / -1;
9869
+ display: flex;
9870
+ gap: 0.5rem;
9871
+ justify-content: flex-end;
9872
+ margin-top: 0.25rem;
9873
+ }
@@ -4,6 +4,7 @@
4
4
  const Billing = (() => {
5
5
  let _summary = null;
6
6
  let _daily = [];
7
+ let _sessions = []; // 会话列表
7
8
  let _allModels = []; // 保存完整的模型列表
8
9
  let _currentPeriod = "day";
9
10
  let _currentModel = "all";
@@ -73,15 +74,19 @@ const Billing = (() => {
73
74
 
74
75
  try {
75
76
  const modelParam = (_currentModel && _currentModel !== "all") ? `&model=${encodeURIComponent(_currentModel)}` : "";
76
- const [summaryRes, dailyRes] = await Promise.all([
77
+ const [summaryRes, dailyRes, sessionsRes] = await Promise.all([
77
78
  fetch(`/api/billing/summary?period=${_currentPeriod}${modelParam}`),
78
- fetch(`/api/billing/daily?days=30${modelParam}`)
79
+ fetch(`/api/billing/daily?days=30${modelParam}`),
80
+ fetch(`/api/billing/sessions?period=${_currentPeriod}${modelParam}&limit=100`)
79
81
  ]);
80
82
 
81
83
  _summary = await summaryRes.json();
82
84
  const dailyData = await dailyRes.json();
83
85
  _daily = dailyData.days || [];
84
86
 
87
+ const sessionsData = await sessionsRes.json();
88
+ _sessions = sessionsData.sessions || [];
89
+
85
90
  // 保存完整模型列表(仅在未筛选时更新)
86
91
  if (!_currentModel || _currentModel === "all") {
87
92
  _allModels = _summary.by_model ? Object.keys(_summary.by_model) : [];
@@ -172,6 +177,10 @@ const Billing = (() => {
172
177
  <div class="billing-chart-row">
173
178
  ${_renderCombinedChart()}
174
179
  </div>
180
+
181
+ <div class="billing-sessions-row">
182
+ ${_renderSessionList()}
183
+ </div>
175
184
  </div>
176
185
  `;
177
186
 
@@ -219,14 +228,19 @@ const Billing = (() => {
219
228
  <span class="tooltip-date">${date}</span>
220
229
  <span class="tooltip-total">${total} tokens</span>
221
230
  </div>
231
+ <div class="tooltip-row">
232
+ <span class="tooltip-dot tooltip-total"></span>
233
+ <span class="tooltip-label">${I18n.t("billing.totalTokens") || "Total Tokens"}</span>
234
+ <span class="tooltip-value">${total}</span>
235
+ </div>
222
236
  <div class="tooltip-row">
223
237
  <span class="tooltip-dot tooltip-cache-hit"></span>
224
- <span class="tooltip-label">${I18n.t("billing.inputCacheHit") || "Input (Cache Hit)"}</span>
238
+ <span class="tooltip-label">${I18n.t("billing.inputCacheHit") || "Input (Hit)"}</span>
225
239
  <span class="tooltip-value">${cacheHit}</span>
226
240
  </div>
227
241
  <div class="tooltip-row">
228
242
  <span class="tooltip-dot tooltip-cache-miss"></span>
229
- <span class="tooltip-label">${I18n.t("billing.inputCacheMiss") || "Input (Cache Miss)"}</span>
243
+ <span class="tooltip-label">${I18n.t("billing.inputCacheMiss") || "Input (Miss)"}</span>
230
244
  <span class="tooltip-value">${cacheMiss}</span>
231
245
  </div>
232
246
  <div class="tooltip-row">
@@ -333,35 +347,50 @@ const Billing = (() => {
333
347
  }
334
348
 
335
349
  function _renderTokenBreakdown() {
350
+ const totalTokens = _summary.total_tokens || 0;
351
+ const promptTokens = _summary.prompt_tokens || 0;
352
+ const completionTokens = _summary.completion_tokens || 0;
353
+ const cacheReadTokens = _summary.cache_read_tokens || 0;
354
+ const cacheMissTokens = promptTokens - cacheReadTokens;
355
+
336
356
  return `
337
357
  <div class="billing-section billing-token-section">
338
358
  <h3>${I18n.t("billing.tokenBreakdown") || "Token Breakdown"}</h3>
339
359
  <div class="billing-token-bars">
340
360
  <div class="billing-token-bar-item">
341
361
  <div class="billing-token-bar-header">
342
- <span class="billing-token-bar-label">${I18n.t("billing.inputCacheHit") || "Input (Cache Hit)"}</span>
343
- <span class="billing-token-bar-value">${_formatCompact(_summary.cache_read_tokens)}</span>
362
+ <span class="billing-token-bar-label">${I18n.t("billing.totalTokens") || "Total Tokens"}</span>
363
+ <span class="billing-token-bar-value">${_formatCompact(totalTokens)}</span>
344
364
  </div>
345
365
  <div class="billing-token-bar-track">
346
- <div class="billing-token-bar-fill billing-bar-cache-read" style="width: ${_getTokenPercent('cache_read')}%"></div>
366
+ <div class="billing-token-bar-fill billing-bar-total" style="width: 100%"></div>
347
367
  </div>
348
368
  </div>
349
369
  <div class="billing-token-bar-item">
350
370
  <div class="billing-token-bar-header">
351
- <span class="billing-token-bar-label">${I18n.t("billing.inputCacheMiss") || "Input (Cache Miss)"}</span>
352
- <span class="billing-token-bar-value">${_formatCompact(_summary.prompt_tokens)}</span>
371
+ <span class="billing-token-bar-label">${I18n.t("billing.inputCacheHit") || "Input (Hit)"}</span>
372
+ <span class="billing-token-bar-value">${_formatCompact(cacheReadTokens)}</span>
353
373
  </div>
354
374
  <div class="billing-token-bar-track">
355
- <div class="billing-token-bar-fill billing-bar-prompt" style="width: ${_getTokenPercent('prompt')}%"></div>
375
+ <div class="billing-token-bar-fill billing-bar-cache-read" style="width: ${_getTokenPercent(cacheReadTokens, totalTokens)}%"></div>
376
+ </div>
377
+ </div>
378
+ <div class="billing-token-bar-item">
379
+ <div class="billing-token-bar-header">
380
+ <span class="billing-token-bar-label">${I18n.t("billing.inputCacheMiss") || "Input (Miss)"}</span>
381
+ <span class="billing-token-bar-value">${_formatCompact(cacheMissTokens)}</span>
382
+ </div>
383
+ <div class="billing-token-bar-track">
384
+ <div class="billing-token-bar-fill billing-bar-cache-miss" style="width: ${_getTokenPercent(cacheMissTokens, totalTokens)}%"></div>
356
385
  </div>
357
386
  </div>
358
387
  <div class="billing-token-bar-item">
359
388
  <div class="billing-token-bar-header">
360
389
  <span class="billing-token-bar-label">${I18n.t("billing.output") || "Output"}</span>
361
- <span class="billing-token-bar-value">${_formatCompact(_summary.completion_tokens)}</span>
390
+ <span class="billing-token-bar-value">${_formatCompact(completionTokens)}</span>
362
391
  </div>
363
392
  <div class="billing-token-bar-track">
364
- <div class="billing-token-bar-fill billing-bar-completion" style="width: ${_getTokenPercent('completion')}%"></div>
393
+ <div class="billing-token-bar-fill billing-bar-completion" style="width: ${_getTokenPercent(completionTokens, totalTokens)}%"></div>
365
394
  </div>
366
395
  </div>
367
396
  </div>
@@ -369,9 +398,8 @@ const Billing = (() => {
369
398
  `;
370
399
  }
371
400
 
372
- function _getTokenPercent(type) {
373
- const total = _summary.total_tokens || 1;
374
- const value = _summary[type + '_tokens'] || 0;
401
+ function _getTokenPercent(value, total) {
402
+ if (!total || total === 0) return 0;
375
403
  return Math.min((value / total) * 100, 100).toFixed(1);
376
404
  }
377
405
 
@@ -427,9 +455,8 @@ const Billing = (() => {
427
455
 
428
456
  const recentDays = _daily.slice(-14);
429
457
  // Max values for scaling
430
- const maxInput = Math.max(...recentDays.map(d => (d.prompt_tokens || 0) + (d.cache_read_tokens || 0)), 1);
431
- const maxOutput = Math.max(...recentDays.map(d => d.completion_tokens || 0), 1);
432
- const maxVal = Math.max(maxInput, maxOutput);
458
+ const maxInput = Math.max(...recentDays.map(d => d.prompt_tokens || 0), 1);
459
+ const maxOutput = Math.max(...recentDays.map(d => d.completion_tokens || 0), 1); const maxVal = Math.max(maxInput, maxOutput);
433
460
 
434
461
  // Chart height in pixels
435
462
  const chartHeight = 120;
@@ -437,9 +464,10 @@ const Billing = (() => {
437
464
  // Generate bars: each date has Input (stacked: cache hit + cache miss) and Output
438
465
  const chartBars = recentDays.map((d, i) => {
439
466
  const cacheHit = d.cache_read_tokens || 0; // 命中缓存
440
- const cacheMiss = d.prompt_tokens || 0; // 未命中缓存(实际发送的prompt)
467
+ const totalPrompt = d.prompt_tokens || 0; // 全部输入token
468
+ const cacheMiss = totalPrompt - cacheHit; // 未命中缓存 = 全部输入 - 命中
441
469
  const output = d.completion_tokens || 0;
442
- const totalInput = cacheHit + cacheMiss;
470
+ const totalInput = totalPrompt;
443
471
  const totalTokens = totalInput + output;
444
472
 
445
473
  // Calculate heights in pixels
@@ -471,13 +499,17 @@ const Billing = (() => {
471
499
  <div class="billing-chart-header">
472
500
  <h4>${I18n.t("billing.dailyUsage") || "Usage Details"}</h4>
473
501
  <div class="billing-chart-legends">
502
+ <span class="billing-chart-legend">
503
+ <span class="billing-legend-dot billing-legend-total"></span>
504
+ ${I18n.t("billing.totalTokens") || "Total Tokens"}
505
+ </span>
474
506
  <span class="billing-chart-legend">
475
507
  <span class="billing-legend-dot billing-legend-cache-hit"></span>
476
- ${I18n.t("billing.inputCacheHit") || "Input (Cache Hit)"}
508
+ ${I18n.t("billing.inputCacheHit") || "Input (Hit)"}
477
509
  </span>
478
510
  <span class="billing-chart-legend">
479
511
  <span class="billing-legend-dot billing-legend-cache-miss"></span>
480
- ${I18n.t("billing.inputCacheMiss") || "Input (Cache Miss)"}
512
+ ${I18n.t("billing.inputCacheMiss") || "Input (Miss)"}
481
513
  </span>
482
514
  <span class="billing-chart-legend">
483
515
  <span class="billing-legend-dot billing-legend-output"></span>
@@ -493,6 +525,69 @@ const Billing = (() => {
493
525
  `;
494
526
  }
495
527
 
528
+ function _renderSessionList() {
529
+ if (!_sessions || _sessions.length === 0) {
530
+ return `
531
+ <div class="billing-section billing-sessions-section">
532
+ <h3>${I18n.t("billing.sessions") || "Sessions"}</h3>
533
+ <div class="billing-sessions-empty">${I18n.t("billing.noSessions") || "No session data"}</div>
534
+ </div>
535
+ `;
536
+ }
537
+
538
+ const rows = _sessions.map((s, index) => {
539
+ const sessionId = s.session_id || "unknown";
540
+ const isDeleted = s.is_deleted;
541
+ const sessionName = s.session_name || sessionId;
542
+ const displayName = isDeleted ? (I18n.t("billing.deletedSessions") || "已删除会话") : (sessionName.length > 25 ? sessionName.slice(0, 25) + "..." : sessionName);
543
+ const totalCost = _convertCost(s.total_cost || 0);
544
+ const totalTokens = s.total_tokens || 0;
545
+ const promptTokens = s.prompt_tokens || 0;
546
+ const cacheHit = s.cache_read_tokens || 0;
547
+ const cacheMiss = promptTokens - cacheHit;
548
+ const completionTokens = s.completion_tokens || 0;
549
+ const requests = s.requests || 0;
550
+ const models = (s.models || []).join(", ");
551
+ const lastRequest = s.last_request ? new Date(s.last_request).toLocaleString() : "-";
552
+ const rowClass = isDeleted ? "billing-session-row billing-session-deleted" : "billing-session-row";
553
+
554
+ return `
555
+ <div class="${rowClass}" data-session-id="${_esc(sessionId)}">
556
+ <div class="billing-cell billing-cell-index">${index + 1}</div>
557
+ <div class="billing-cell billing-cell-session" title="${_esc(sessionName)}">
558
+ <span class="billing-cell-main">${_esc(displayName)}</span>
559
+ <span class="billing-cell-sub">${requests} ${I18n.t("billing.requests") || "req"} · ${_esc(models)}</span>
560
+ </div>
561
+ <div class="billing-cell billing-cell-number">${_formatCompact(totalTokens)}</div>
562
+ <div class="billing-cell billing-cell-number billing-cell-hit">${_formatCompact(cacheHit)}</div>
563
+ <div class="billing-cell billing-cell-number billing-cell-miss">${_formatCompact(cacheMiss)}</div>
564
+ <div class="billing-cell billing-cell-number">${_formatCompact(completionTokens)}</div>
565
+ <div class="billing-cell billing-cell-cost">${_getCurrencySymbol()}${_formatCost(totalCost)}</div>
566
+ <div class="billing-cell billing-cell-time">${lastRequest}</div>
567
+ </div>
568
+ `;
569
+ }).join("");
570
+
571
+ return `
572
+ <div class="billing-section billing-sessions-section">
573
+ <h3>${I18n.t("billing.sessions") || "Sessions"}</h3>
574
+ <div class="billing-sessions-header">
575
+ <span class="billing-cell billing-cell-index">#</span>
576
+ <span class="billing-cell billing-cell-session">${I18n.t("billing.sessionId") || "Session"}</span>
577
+ <span class="billing-cell billing-cell-number">${I18n.t("billing.headerTotal") || "总消耗"}</span>
578
+ <span class="billing-cell billing-cell-number">${I18n.t("billing.headerHit") || "命中"}</span>
579
+ <span class="billing-cell billing-cell-number">${I18n.t("billing.headerMiss") || "未命中"}</span>
580
+ <span class="billing-cell billing-cell-number">${I18n.t("billing.headerOutput") || "输出"}</span>
581
+ <span class="billing-cell billing-cell-cost">${I18n.t("billing.cost") || "Cost"}</span>
582
+ <span class="billing-cell billing-cell-time">${I18n.t("billing.lastRequest") || "Time"}</span>
583
+ </div>
584
+ <div class="billing-sessions-list">
585
+ ${rows}
586
+ </div>
587
+ </div>
588
+ `;
589
+ }
590
+
496
591
  // ── Helpers ─────────────────────────────────────────────────────────────────
497
592
 
498
593
  function _formatCost(cost) {