openclacky 1.3.0 → 1.3.2

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.
@@ -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>`;
@@ -752,7 +878,7 @@ const Billing = (() => {
752
878
  function _renderSessionList() {
753
879
  if (!_sessions || _sessions.length === 0) {
754
880
  return `
755
- <div class="billing-section billing-sessions-section">
881
+ <div class="billing-sessions-section">
756
882
  <h3>${I18n.t("billing.sessions") || "Sessions"}</h3>
757
883
  <div class="billing-sessions-empty">${I18n.t("billing.noSessions") || "No session data"}</div>
758
884
  </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>
@@ -800,7 +926,7 @@ const Billing = (() => {
800
926
  }).join("");
801
927
 
802
928
  return `
803
- <div class="billing-section billing-sessions-section">
929
+ <div class="billing-sessions-section">
804
930
  <h3>${I18n.t("billing.sessions") || "Sessions"}</h3>
805
931
  <div class="billing-sessions-header">
806
932
  <span class="billing-cell billing-cell-index">#</span>
@@ -114,6 +114,8 @@ const I18n = (() => {
114
114
  "workspace.loading": "Loading…",
115
115
  "workspace.error": "Failed to load files",
116
116
  "workspace.downloadFailed": "Download failed",
117
+ "workspace.revealInFinder": "Reveal in Finder",
118
+ "workspace.revealFailed": "Failed to reveal file",
117
119
  "sib.model.tooltip": "Click to switch model",
118
120
  "sib.model.tooltip.busy": "Model switching is disabled while the agent is responding",
119
121
  "sib.variant.header": "Quick switch",
@@ -434,6 +436,10 @@ const I18n = (() => {
434
436
  "skills.toggle.disableDesc": "AI can auto-invoke. Disable to prevent auto-triggering (manual use still available)",
435
437
  "skills.toggle.enableDesc": "AI cannot auto-invoke. Enable to allow auto-triggering",
436
438
  "skills.toggleError": "Error: ",
439
+ "skills.deleteConfirm": "Are you sure you want to delete \"{{name}}\"? This cannot be undone.",
440
+ "skills.deleteError": "Delete failed.",
441
+ "skills.deleted": "Skill \"{{name}}\" deleted.",
442
+ "skills.btn.delete": "Delete skill",
437
443
  "skills.ac.empty": "No skills available",
438
444
  "skills.upload.uploading": "Uploading…",
439
445
  "skills.upload.uploaded": "Uploaded",
@@ -995,6 +1001,8 @@ const I18n = (() => {
995
1001
  "workspace.loading": "加载中…",
996
1002
  "workspace.error": "加载文件失败",
997
1003
  "workspace.downloadFailed": "下载失败",
1004
+ "workspace.revealInFinder": "打开所在文件夹",
1005
+ "workspace.revealFailed": "无法打开文件位置",
998
1006
  "sib.model.tooltip": "点击切换模型",
999
1007
  "sib.model.tooltip.busy": "Agent 回复中,暂时无法切换模型",
1000
1008
  "sib.variant.header": "快速切换",
@@ -1314,6 +1322,10 @@ const I18n = (() => {
1314
1322
  "skills.toggle.disableDesc": "AI 可自动调用。关闭后 AI 不会主动触发(手动使用仍可用)",
1315
1323
  "skills.toggle.enableDesc": "AI 不会自动调用。开启后允许 AI 主动触发",
1316
1324
  "skills.toggleError": "错误:",
1325
+ "skills.deleteConfirm": "确定要删除 \"{{name}}\" 吗?此操作不可撤销。",
1326
+ "skills.deleteError": "删除失败。",
1327
+ "skills.deleted": "技能 \"{{name}}\" 已删除。",
1328
+ "skills.btn.delete": "删除技能",
1317
1329
  "skills.ac.empty": "暂无可用技能",
1318
1330
  "skills.upload.uploading": "上传中…",
1319
1331
  "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 ─────────────────────────────────────────────────────────── -->
@@ -442,6 +443,7 @@
442
443
 
443
444
  <!-- ── WORKSPACE PANEL (right) ──────────────────────────────────── -->
444
445
  <aside id="workspace-panel" class="collapsed">
446
+ <div id="workspace-resize-handle"></div>
445
447
  <div id="workspace-header">
446
448
  <span id="workspace-title" data-i18n="workspace.title">Workspace</span>
447
449
  <div class="workspace-header-actions">
@@ -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
+ })();
@@ -104,10 +104,6 @@ const Settings = (() => {
104
104
  </div>
105
105
  </div>
106
106
  <div class="model-card-grid-actions">
107
- ${!isDefault ? `<button class="btn-card-grid-action btn-card-grid-action-primary" data-index="${index}" data-action="default">
108
- <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
109
- <span>${I18n.t("settings.models.btn.setDefault")}</span>
110
- </button>` : ""}
111
107
  <div class="model-card-grid-toolbar">
112
108
  <button class="btn-card-grid-action" data-index="${index}" data-action="test">
113
109
  <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
@@ -123,12 +119,16 @@ const Settings = (() => {
123
119
  </button>` : ""}
124
120
  </div>
125
121
  </div>
126
- ${websiteUrl ? `<div class="model-card-grid-footer">
127
- <a class="model-card-grid-link" href="${_esc(websiteUrl)}" target="_blank" rel="noopener noreferrer">
122
+ <div class="model-card-grid-footer">
123
+ ${!isDefault ? `<button class="btn-card-grid-action btn-card-grid-action-primary" data-index="${index}" data-action="default">
124
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
125
+ <span>${I18n.t("settings.models.btn.setDefault")}</span>
126
+ </button>` : `<span></span>`}
127
+ ${websiteUrl ? `<a class="model-card-grid-link" href="${_esc(websiteUrl)}" target="_blank" rel="noopener noreferrer">
128
128
  ${I18n.t("settings.models.link.topUp")}
129
129
  <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7"/><path d="M8 7h9v9"/></svg>
130
- </a>
131
- </div>` : ""}
130
+ </a>` : ""}
131
+ </div>
132
132
  `;
133
133
 
134
134
  container.appendChild(card);
@@ -51,7 +51,21 @@ const Skills = (() => {
51
51
  async function _loadBrandSkills() {
52
52
  const container = $("brand-skills-list");
53
53
  if (!container) return;
54
- container.innerHTML = `<div class="brand-skills-loading">${I18n.t("skills.loading")}</div>`;
54
+ container.innerHTML = Array.from({ length: 4 }).map(() => `
55
+ <div class="brand-skill-card">
56
+ <div class="brand-skill-card-main">
57
+ <div class="brand-skill-info">
58
+ <div class="brand-skill-title">
59
+ <span class="skel skel-title"></span>
60
+ <span class="skel" style="height:1rem;width:3.5rem;border-radius:4px;"></span>
61
+ </div>
62
+ <span class="skel skel-subtitle"></span>
63
+ </div>
64
+ <div class="brand-skill-actions">
65
+ <span class="skel" style="height:1.75rem;width:4.5rem;border-radius:6px;"></span>
66
+ </div>
67
+ </div>
68
+ </div>`).join("");
55
69
 
56
70
  try {
57
71
  const res = await fetch("/api/brand/skills");
@@ -163,11 +177,16 @@ const Skills = (() => {
163
177
  <span class="brand-skill-version latest">v${escapeHtml(latestVersion)}</span>
164
178
  <button class="btn-brand-update" data-name="${escapeHtml(name)}">${I18n.t("skills.brand.btn.update")}</button>`;
165
179
  } else {
166
- // Installed and up-to-date — show version badge + "Use" button
180
+ // Installed and up-to-date — show version badge + "Use" + delete buttons
167
181
  const displayVersion = installedVersion || latestVersion;
168
182
  statusHtml = `
169
183
  <span class="brand-skill-version installed">v${escapeHtml(displayVersion)} ✓</span>
170
- <button class="btn-brand-use" data-name="${escapeHtml(name)}">${I18n.t("skills.brand.btn.use")}</button>`;
184
+ <button class="btn-brand-use" data-name="${escapeHtml(name)}">${I18n.t("skills.brand.btn.use")}</button>
185
+ <button class="btn-skill-delete btn-brand-delete" data-name="${escapeHtml(name)}" title="${I18n.t("skills.btn.delete")}">
186
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
187
+ <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"/>
188
+ </svg>
189
+ </button>`;
171
190
  }
172
191
 
173
192
  // Free skills show a "Free" badge; paid (encrypted) brand skills show "Private".
@@ -196,12 +215,17 @@ const Skills = (() => {
196
215
  </div>`;
197
216
 
198
217
  // Bind install/update/use buttons
199
- const installBtn = card.querySelector(".btn-brand-install");
200
- const updateBtn = card.querySelector(".btn-brand-update");
201
- const useBtn = card.querySelector(".btn-brand-use");
218
+ const installBtn = card.querySelector(".btn-brand-install");
219
+ const updateBtn = card.querySelector(".btn-brand-update");
220
+ const useBtn = card.querySelector(".btn-brand-use");
221
+ const deleteBtn = card.querySelector(".btn-brand-delete");
202
222
  if (installBtn) installBtn.addEventListener("click", () => _installBrandSkill(name, installBtn));
203
223
  if (updateBtn) updateBtn.addEventListener("click", () => _installBrandSkill(name, updateBtn));
204
224
  if (useBtn) useBtn.addEventListener("click", () => _useInstalledSkill(name));
225
+ if (deleteBtn) deleteBtn.addEventListener("click", (e) => {
226
+ e.stopPropagation();
227
+ _deleteBrandSkill(name);
228
+ });
205
229
 
206
230
  return card;
207
231
  }
@@ -266,6 +290,24 @@ const Skills = (() => {
266
290
  }
267
291
  }
268
292
 
293
+ async function _deleteBrandSkill(name) {
294
+ if (!confirm(I18n.t("skills.deleteConfirm", { name }))) return;
295
+ try {
296
+ const res = await fetch(`/api/brand/skills/${encodeURIComponent(name)}`, { method: "DELETE" });
297
+ const data = await res.json();
298
+ if (!res.ok || !data.ok) { alert(data.error || I18n.t("skills.deleteError")); return; }
299
+
300
+ const skill = _brandSkills.find(s => s.name === name);
301
+ if (skill) skill.installed_version = null;
302
+
303
+ _renderBrandSkills();
304
+ await Skills.load();
305
+ Modal.toast(I18n.t("skills.deleted", { name }), "success");
306
+ } catch (e) {
307
+ console.error("[Skills] brand skill delete failed", e);
308
+ }
309
+ }
310
+
269
311
  /** Open a new session and trigger a brand skill by sending "/{name}" as the first message. */
270
312
  async function _useInstalledSkill(name) {
271
313
  const maxN = Sessions.all.reduce((max, s) => {
@@ -336,6 +378,15 @@ const Skills = (() => {
336
378
  ? ""
337
379
  : `<button class="btn-skill-use" data-name="${escapeHtml(skill.name)}">${I18n.t("skills.btn.use")}</button>`;
338
380
 
381
+ // Delete button only for custom (non-system, non-brand) skills
382
+ const deleteButtonHtml = isSystem
383
+ ? ""
384
+ : `<button class="btn-skill-delete" data-name="${escapeHtml(skill.name)}" title="${I18n.t("skills.btn.delete")}">
385
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
386
+ <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"/>
387
+ </svg>
388
+ </button>`;
389
+
339
390
  card.innerHTML = `
340
391
  <div class="skill-card-main">
341
392
  <div class="skill-card-info">
@@ -353,6 +404,7 @@ const Skills = (() => {
353
404
  <span class="skill-toggle-track"></span>
354
405
  </label>
355
406
  ${useButtonHtml}
407
+ ${deleteButtonHtml}
356
408
  </div>
357
409
  </div>
358
410
  ${errorNoticeHtml}`;
@@ -385,6 +437,15 @@ const Skills = (() => {
385
437
  useBtn.addEventListener("click", () => _useInstalledSkill(skill.name));
386
438
  }
387
439
 
440
+ // Bind delete button event
441
+ const deleteBtn = card.querySelector(".btn-skill-delete");
442
+ if (deleteBtn) {
443
+ deleteBtn.addEventListener("click", (e) => {
444
+ e.stopPropagation();
445
+ Skills.delete(skill.name);
446
+ });
447
+ }
448
+
388
449
  return card;
389
450
  }
390
451
 
@@ -579,6 +640,20 @@ const Skills = (() => {
579
640
  }
580
641
  },
581
642
 
643
+ /** Delete a skill by name. Prompts confirmation, then DELETE to server. */
644
+ async delete(name) {
645
+ if (!confirm(I18n.t("skills.deleteConfirm", { name }))) return;
646
+ try {
647
+ const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, { method: "DELETE" });
648
+ const data = await res.json();
649
+ if (!res.ok) { alert(data.error || I18n.t("skills.deleteError")); return; }
650
+ Modal.toast(I18n.t("skills.deleted", { name }), "success");
651
+ await Skills.load();
652
+ } catch (e) {
653
+ console.error("[Skills] delete failed", e);
654
+ }
655
+ },
656
+
582
657
  /** Switch the Skills panel to the brand-skills tab.
583
658
  * Called externally (e.g. from settings.js after license activation) to
584
659
  * guide the user directly to the Brand Skills download page.
@@ -49,7 +49,7 @@ const Tasks = (() => {
49
49
  return isZh ? `每 ${n} 分钟` : `Every ${n} min`;
50
50
  }
51
51
  // Every N hours
52
- if (isAny(min) && hour.startsWith("*/") && isAny(dom) && isAny(month) && isAny(dow)) {
52
+ if ((isAny(min) || isNum(min)) && hour.startsWith("*/") && isAny(dom) && isAny(month) && isAny(dow)) {
53
53
  const n = hour.slice(2);
54
54
  return isZh ? `每 ${n} 小时` : `Every ${n} hr`;
55
55
  }
@@ -108,13 +108,9 @@ const Tasks = (() => {
108
108
  ? `<span class="task-card-badge task-card-badge-paused">${I18n.t("tasks.paused")}</span>`
109
109
  : "";
110
110
 
111
- const preview = (t.content || "")
112
- .split("\n")
113
- .map(l => l.trim())
114
- .find(l => l.length > 0) || I18n.t("tasks.empty");
115
- const previewText = preview.length > 120
116
- ? escapeHtml(preview.slice(0, 120)) + "…"
117
- : escapeHtml(preview);
111
+ const content = t.content || "";
112
+ const isTruncated = content.trim().length > 0;
113
+ const previewText = escapeHtml(content.replace(/\s+/g, " ").trim()) || escapeHtml(I18n.t("tasks.empty"));
118
114
 
119
115
  const toggleBtnHtml = t.scheduled ? (isPaused
120
116
  ? `<button class="task-action-btn task-btn-toggle task-btn-resume">
@@ -146,7 +142,7 @@ const Tasks = (() => {
146
142
  ${pausedBadge}
147
143
  ${schedLabel}
148
144
  </div>
149
- <div class="task-card-preview">${previewText}</div>
145
+ <div class="task-card-preview${isTruncated ? " task-card-preview-expandable" : ""}">${previewText}</div>
150
146
  </div>
151
147
  <div class="task-card-actions">
152
148
  <button class="task-run-btn task-btn-run" title="${I18n.t("tasks.btn.run")}">
@@ -170,12 +166,24 @@ const Tasks = (() => {
170
166
  <span>${I18n.t("tasks.btn.delete")}</span>
171
167
  </button>
172
168
  </div>
173
- </div>`;
169
+ </div>
170
+ ${isTruncated ? `<div class="task-card-detail" hidden><pre class="task-card-detail-content">${escapeHtml(content)}</pre></div>` : ""}`;
174
171
 
175
172
  row.querySelector(".task-btn-run").addEventListener("click", e => {
176
173
  e.stopPropagation();
177
174
  Tasks.run(t.name);
178
175
  });
176
+
177
+ if (isTruncated) {
178
+ const previewEl = row.querySelector(".task-card-preview");
179
+ const detailEl = row.querySelector(".task-card-detail");
180
+ previewEl.addEventListener("click", e => {
181
+ e.stopPropagation();
182
+ const expanded = !detailEl.hidden;
183
+ detailEl.hidden = expanded;
184
+ row.classList.toggle("task-card-expanded", !expanded);
185
+ });
186
+ }
179
187
  const toggleBtn = row.querySelector(".task-btn-toggle");
180
188
  if (toggleBtn) {
181
189
  toggleBtn.addEventListener("click", e => {