openclacky 1.2.18 → 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.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +35 -0
  3. data/lib/clacky/agent/time_machine.rb +256 -74
  4. data/lib/clacky/agent/tool_executor.rb +12 -0
  5. data/lib/clacky/agent.rb +15 -20
  6. data/lib/clacky/agent_config.rb +18 -0
  7. data/lib/clacky/cli.rb +55 -3
  8. data/lib/clacky/default_skills/media-gen/SKILL.md +172 -5
  9. data/lib/clacky/media/base.rb +93 -0
  10. data/lib/clacky/media/gemini.rb +10 -0
  11. data/lib/clacky/media/generator.rb +57 -0
  12. data/lib/clacky/media/openai_compat.rb +160 -0
  13. data/lib/clacky/message_history.rb +12 -7
  14. data/lib/clacky/providers.rb +29 -1
  15. data/lib/clacky/rich_ui_controller.rb +3 -1
  16. data/lib/clacky/server/backup_manager.rb +200 -0
  17. data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -2
  18. data/lib/clacky/server/channel/adapters/feishu/bot.rb +68 -15
  19. data/lib/clacky/server/channel/channel_manager.rb +65 -50
  20. data/lib/clacky/server/http_server.rb +356 -14
  21. data/lib/clacky/server/scheduler.rb +19 -0
  22. data/lib/clacky/server/session_registry.rb +8 -4
  23. data/lib/clacky/session_manager.rb +40 -2
  24. data/lib/clacky/tools/trash_manager.rb +14 -0
  25. data/lib/clacky/ui2/components/command_suggestions.rb +1 -0
  26. data/lib/clacky/ui2/components/modal_component.rb +34 -7
  27. data/lib/clacky/ui2/ui_controller.rb +150 -19
  28. data/lib/clacky/utils/file_processor.rb +75 -4
  29. data/lib/clacky/version.rb +1 -1
  30. data/lib/clacky/web/app.css +2283 -1277
  31. data/lib/clacky/web/app.js +73 -1
  32. data/lib/clacky/web/backup.js +119 -0
  33. data/lib/clacky/web/billing.js +224 -11
  34. data/lib/clacky/web/channels.js +81 -11
  35. data/lib/clacky/web/design-sample.css +247 -0
  36. data/lib/clacky/web/design-sample.html +127 -0
  37. data/lib/clacky/web/favicon.svg +16 -0
  38. data/lib/clacky/web/i18n.js +167 -31
  39. data/lib/clacky/web/index.html +176 -55
  40. data/lib/clacky/web/logo_nav_dark.png +0 -0
  41. data/lib/clacky/web/onboard.js +121 -28
  42. data/lib/clacky/web/sessions.js +447 -192
  43. data/lib/clacky/web/settings.js +21 -1
  44. data/lib/clacky/web/skills.js +34 -1
  45. data/lib/clacky/web/tasks.js +129 -61
  46. data/lib/clacky/web/utils.js +72 -0
  47. data/lib/clacky/web/ws-dispatcher.js +6 -0
  48. data/lib/clacky.rb +1 -0
  49. metadata +9 -8
  50. data/lib/clacky/server/channel/group_message_buffer.rb +0 -53
@@ -20,6 +20,7 @@ const Settings = (() => {
20
20
  _loadBrand();
21
21
  _loadBrowserStatus();
22
22
  _initNetworkSettings();
23
+ if (window.Backup) Backup.load();
23
24
  _applyAboutTabVisibility();
24
25
  }
25
26
 
@@ -1636,10 +1637,11 @@ const Settings = (() => {
1636
1637
  const def = (_mediaDefaults && _mediaDefaults[kind]) || { available: [] };
1637
1638
  const autoAvailable = !!(def && def.model);
1638
1639
  const isCustomEditing = state.source === "custom" && (!state.configured || _mediaCustomDraft[kind]);
1640
+ const isVisionPrimary = kind === "ocr" && state.source === "auto" && state.primary;
1639
1641
 
1640
1642
  const row = document.createElement("div");
1641
1643
  row.className = "media-row";
1642
- if (isCustomEditing || (state.source === "auto" && state.configured) || (state.source === "custom" && state.configured)) {
1644
+ if (!isVisionPrimary && (isCustomEditing || (state.source === "auto" && state.configured) || (state.source === "custom" && state.configured))) {
1643
1645
  row.classList.add("is-expanded");
1644
1646
  }
1645
1647
  row.dataset.kind = kind;
@@ -1653,6 +1655,24 @@ const Settings = (() => {
1653
1655
  title.textContent = I18n.t(`settings.media.kind.${kind}`);
1654
1656
  head.appendChild(title);
1655
1657
 
1658
+ // When the default chat model already supports vision, the OCR sidecar
1659
+ // reuses it automatically — there's nothing to choose, so show a single
1660
+ // read-only note instead of the off/auto/custom switcher.
1661
+ if (isVisionPrimary) {
1662
+ const note = document.createElement("span");
1663
+ note.className = "media-row-status media-vision-primary-note";
1664
+ note.textContent = I18n.t("settings.media.vision.primary");
1665
+ head.appendChild(note);
1666
+
1667
+ const model = document.createElement("span");
1668
+ model.className = "media-vision-primary-model";
1669
+ model.textContent = state.model || "";
1670
+ head.appendChild(model);
1671
+
1672
+ row.appendChild(head);
1673
+ return row;
1674
+ }
1675
+
1656
1676
  const seg = document.createElement("div");
1657
1677
  seg.className = "media-row-segmented";
1658
1678
  ["off", "auto", "custom"].forEach(src => {
@@ -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}`;
@@ -374,7 +384,7 @@ const Skills = (() => {
374
384
  const toggleTop = toggleLabel.getBoundingClientRect().top;
375
385
  const scrollerTop = scroller.getBoundingClientRect().top;
376
386
  if (toggleTop - scrollerTop < 80) {
377
- toggleLabel.classList.add("skill-toggle-flip");
387
+ toggleLabel.setAttribute("data-tooltip-pos", "bottom");
378
388
  }
379
389
  });
380
390
  }
@@ -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.
@@ -18,81 +18,158 @@ const Tasks = (() => {
18
18
 
19
19
  // ── Private helpers ────────────────────────────────────────────────────
20
20
 
21
+ function _humanCron(cron) {
22
+ if (!cron) return cron;
23
+ const parts = cron.trim().split(/\s+/);
24
+ if (parts.length !== 5) return cron;
25
+ const [min, hour, dom, month, dow] = parts;
26
+
27
+ const isAny = v => v === "*";
28
+ const isNum = v => /^\d+$/.test(v);
29
+ const pad = n => String(n).padStart(2, "0");
30
+
31
+ const lang = (typeof I18n !== "undefined" && I18n.lang()) || "zh";
32
+ const isZh = lang === "zh";
33
+
34
+ const dowNames = isZh
35
+ ? ["周日","周一","周二","周三","周四","周五","周六"]
36
+ : ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"];
37
+
38
+ const monthNames = isZh
39
+ ? ["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"]
40
+ : ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
41
+
42
+ const timeStr = (isNum(hour) && isNum(min))
43
+ ? `${pad(hour)}:${pad(min)}`
44
+ : null;
45
+
46
+ // Every N minutes
47
+ if (min.startsWith("*/") && isAny(hour) && isAny(dom) && isAny(month) && isAny(dow)) {
48
+ const n = min.slice(2);
49
+ return isZh ? `每 ${n} 分钟` : `Every ${n} min`;
50
+ }
51
+ // Every N hours
52
+ if (isAny(min) && hour.startsWith("*/") && isAny(dom) && isAny(month) && isAny(dow)) {
53
+ const n = hour.slice(2);
54
+ return isZh ? `每 ${n} 小时` : `Every ${n} hr`;
55
+ }
56
+ // Every minute
57
+ if (isAny(min) && isAny(hour) && isAny(dom) && isAny(month) && isAny(dow)) {
58
+ return isZh ? "每分钟" : "Every minute";
59
+ }
60
+ // Every hour at :MM
61
+ if (isNum(min) && isAny(hour) && isAny(dom) && isAny(month) && isAny(dow)) {
62
+ return isZh ? `每小时 :${pad(min)}` : `Hourly at :${pad(min)}`;
63
+ }
64
+ // Specific day-of-week
65
+ if (timeStr && isAny(dom) && isAny(month) && isNum(dow)) {
66
+ const d = dowNames[parseInt(dow, 10)] || dow;
67
+ return isZh ? `每${d} ${timeStr}` : `${d} ${timeStr}`;
68
+ }
69
+ // Weekdays (1-5)
70
+ if (timeStr && isAny(dom) && isAny(month) && dow === "1-5") {
71
+ return isZh ? `工作日 ${timeStr}` : `Weekdays ${timeStr}`;
72
+ }
73
+ // Weekends (0,6 or 6,0)
74
+ if (timeStr && isAny(dom) && isAny(month) && (dow === "0,6" || dow === "6,0")) {
75
+ return isZh ? `周末 ${timeStr}` : `Weekends ${timeStr}`;
76
+ }
77
+ // Every day at HH:MM
78
+ if (timeStr && isAny(dom) && isAny(month) && isAny(dow)) {
79
+ return isZh ? `每天 ${timeStr}` : `Daily ${timeStr}`;
80
+ }
81
+ // Specific day of month
82
+ if (timeStr && isNum(dom) && isAny(month) && isAny(dow)) {
83
+ return isZh ? `每月 ${dom} 日 ${timeStr}` : `Monthly day ${dom} ${timeStr}`;
84
+ }
85
+ // Specific month + day
86
+ if (timeStr && isNum(dom) && isNum(month) && isAny(dow)) {
87
+ const m = monthNames[parseInt(month, 10) - 1] || month;
88
+ return isZh ? `${m}${dom}日 ${timeStr}` : `${m} ${dom} ${timeStr}`;
89
+ }
90
+
91
+ return cron;
92
+ }
93
+
21
94
  /** Render a single task row in the main panel table. */
22
95
  function _renderTaskRow(t) {
23
96
  const row = document.createElement("div");
24
- row.className = "task-table-row";
97
+ row.className = "task-card";
25
98
  row.dataset.name = t.name;
26
99
 
100
+ const isPaused = t.scheduled && t.enabled === false;
101
+ row.classList.toggle("task-card-paused", isPaused);
102
+
27
103
  const schedLabel = t.scheduled
28
- ? escapeHtml(t.cron)
29
- : `<span class="sched-manual">${I18n.t("tasks.manual")}</span>`;
104
+ ? `<span class="task-card-cron" title="${escapeHtml(t.cron)}">${escapeHtml(_humanCron(t.cron))}</span>`
105
+ : `<span class="task-card-cron task-card-cron-manual">${I18n.t("tasks.manual")}</span>`;
30
106
 
31
- // Visual hint: dim the schedule + prepend a "Paused" badge when disabled.
32
- const isPaused = t.scheduled && t.enabled === false;
33
- const schedCell = isPaused
34
- ? `<span class="sched-paused-badge">${I18n.t("tasks.paused")}</span>
35
- <span class="sched-paused-cron">${schedLabel}</span>`
36
- : schedLabel;
107
+ const pausedBadge = isPaused
108
+ ? `<span class="task-card-badge task-card-badge-paused">${I18n.t("tasks.paused")}</span>`
109
+ : "";
37
110
 
38
- const preview = (t.content || "")
111
+ const preview = (t.content || "")
39
112
  .split("\n")
40
113
  .map(l => l.trim())
41
114
  .find(l => l.length > 0) || I18n.t("tasks.empty");
42
- const previewText = preview.length > 80
43
- ? escapeHtml(preview.slice(0, 80)) + "…"
115
+ const previewText = preview.length > 120
116
+ ? escapeHtml(preview.slice(0, 120)) + "…"
44
117
  : escapeHtml(preview);
45
118
 
46
- // Build the pause/resume button only for *scheduled* tasks. Manual tasks
47
- // have no cron to disable.
48
119
  const toggleBtnHtml = t.scheduled ? (isPaused
49
- ? `<button class="task-btn task-btn-toggle task-btn-resume" title="${I18n.t("tasks.btn.resume")}">
50
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
120
+ ? `<button class="task-action-btn task-btn-toggle task-btn-resume">
121
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
51
122
  <polygon points="6 3 20 12 6 21 6 3"/>
52
- </svg><span class="btn-label"> ${I18n.t("tasks.btn.resume")}</span>
123
+ </svg>
124
+ <span>${I18n.t("tasks.btn.resume")}</span>
53
125
  </button>`
54
- : `<button class="task-btn task-btn-toggle task-btn-pause" title="${I18n.t("tasks.btn.pause")}">
55
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
126
+ : `<button class="task-action-btn task-btn-toggle task-btn-pause">
127
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
56
128
  <rect x="6" y="4" width="4" height="16" rx="1"/>
57
129
  <rect x="14" y="4" width="4" height="16" rx="1"/>
58
- </svg><span class="btn-label"> ${I18n.t("tasks.btn.pause")}</span>
130
+ </svg>
131
+ <span>${I18n.t("tasks.btn.pause")}</span>
59
132
  </button>`
60
133
  ) : "";
61
134
 
62
- row.classList.toggle("task-row-paused", isPaused);
63
-
64
135
  row.innerHTML = `
65
- <div class="task-col task-col-name">
66
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="task-icon task-name-icon">
67
- <circle cx="12" cy="12" r="10"/>
68
- <polyline points="12 6 12 12 16 14"/>
69
- </svg>
70
- <div class="task-name-info">
71
- <span class="task-name-text">${escapeHtml(t.name)}</span>
72
- <span class="task-name-sched">${schedCell}</span>
73
- </div>
74
- </div>
75
- <div class="task-col task-col-schedule">${schedCell}</div>
76
- <div class="task-col task-col-content">${previewText}</div>
77
- <div class="task-col task-col-actions">
78
- <button class="task-btn task-btn-run" title="${I18n.t("tasks.btn.run")}">
79
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
80
- <polygon points="6 3 20 12 6 21 6 3"/>
81
- </svg><span class="btn-label"> ${I18n.t("tasks.btn.run")}</span>
82
- </button>
83
- ${toggleBtnHtml}
84
- <button class="task-btn task-btn-edit" title="${I18n.t("tasks.btn.edit")}">
85
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
86
- <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
87
- <path d="m15 5 4 4"/>
88
- </svg><span class="btn-label"> ${I18n.t("tasks.btn.edit")}</span>
89
- </button>
90
- <button class="task-btn task-btn-del" title="Delete">
91
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
92
- <path d="M18 6 6 18"/>
93
- <path d="m6 6 12 12"/>
136
+ <div class="task-card-main">
137
+ <div class="task-card-icon">
138
+ <svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
139
+ <circle cx="12" cy="12" r="10"/>
140
+ <polyline points="12 6 12 12 16 14"/>
94
141
  </svg>
95
- </button>
142
+ </div>
143
+ <div class="task-card-info">
144
+ <div class="task-card-title-row">
145
+ <span class="task-card-name">${escapeHtml(t.name)}</span>
146
+ ${pausedBadge}
147
+ ${schedLabel}
148
+ </div>
149
+ <div class="task-card-preview">${previewText}</div>
150
+ </div>
151
+ <div class="task-card-actions">
152
+ <button class="task-run-btn task-btn-run" title="${I18n.t("tasks.btn.run")}">
153
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
154
+ <polygon points="6 3 20 12 6 21 6 3"/>
155
+ </svg>
156
+ <span>${I18n.t("tasks.btn.run")}</span>
157
+ </button>
158
+ ${toggleBtnHtml}
159
+ <button class="task-action-btn task-btn-edit">
160
+ <svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
161
+ <path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z"/>
162
+ <path d="m15 5 4 4"/>
163
+ </svg>
164
+ <span>${I18n.t("tasks.btn.edit")}</span>
165
+ </button>
166
+ <button class="task-action-btn task-btn-del">
167
+ <svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
168
+ <path d="M3 6h18"/><path d="M19 6l-1 14H6L5 6"/><path d="M8 6V4h8v2"/>
169
+ </svg>
170
+ <span>${I18n.t("tasks.btn.delete")}</span>
171
+ </button>
172
+ </div>
96
173
  </div>`;
97
174
 
98
175
  row.querySelector(".task-btn-run").addEventListener("click", e => {
@@ -178,15 +255,6 @@ const Tasks = (() => {
178
255
  return;
179
256
  }
180
257
 
181
- const header = document.createElement("div");
182
- header.className = "task-table-header";
183
- header.innerHTML = `
184
- <div class="task-col task-col-name">${I18n.t("tasks.col.name")}</div>
185
- <div class="task-col task-col-schedule">${I18n.t("tasks.col.schedule")}</div>
186
- <div class="task-col task-col-content">${I18n.t("tasks.col.task")}</div>
187
- <div class="task-col task-col-actions"></div>`;
188
- table.appendChild(header);
189
-
190
258
  _tasks.forEach(t => table.appendChild(_renderTaskRow(t)));
191
259
  },
192
260
 
@@ -55,3 +55,75 @@ const IME = (() => {
55
55
 
56
56
  return { track, bindEnter };
57
57
  })();
58
+
59
+ const Tooltip = (() => {
60
+ const GAP = 8;
61
+ let el = null;
62
+ let _hideTimer = null;
63
+
64
+ function _el() {
65
+ if (!el) el = document.getElementById("tooltip");
66
+ return el;
67
+ }
68
+
69
+ function show(anchor) {
70
+ const tip = _el();
71
+ if (!tip) return;
72
+ clearTimeout(_hideTimer);
73
+
74
+ const text = anchor.getAttribute("data-tooltip");
75
+ if (!text) return;
76
+
77
+ const pos = anchor.getAttribute("data-tooltip-pos") || "top";
78
+ tip.textContent = text;
79
+ tip.setAttribute("data-pos", pos);
80
+ tip.style.display = "block";
81
+
82
+ const r = anchor.getBoundingClientRect();
83
+ const tw = tip.offsetWidth;
84
+ const th = tip.offsetHeight;
85
+
86
+ let top, left;
87
+ if (pos === "bottom") {
88
+ top = r.bottom + GAP;
89
+ left = r.left + r.width / 2 - tw / 2;
90
+ } else if (pos === "left") {
91
+ top = r.top + r.height / 2 - th / 2;
92
+ left = r.left - tw - GAP;
93
+ } else if (pos === "right") {
94
+ top = r.top + r.height / 2 - th / 2;
95
+ left = r.right + GAP;
96
+ } else {
97
+ top = r.top - th - GAP;
98
+ left = r.left + r.width / 2 - tw / 2;
99
+ }
100
+
101
+ left = Math.max(6, Math.min(left, window.innerWidth - tw - 6));
102
+ top = Math.max(6, Math.min(top, window.innerHeight - th - 6));
103
+
104
+ tip.style.left = `${left}px`;
105
+ tip.style.top = `${top}px`;
106
+ requestAnimationFrame(() => tip.classList.add("visible"));
107
+ }
108
+
109
+ function hide() {
110
+ const tip = _el();
111
+ if (!tip) return;
112
+ tip.classList.remove("visible");
113
+ _hideTimer = setTimeout(() => { tip.style.display = "none"; }, 120);
114
+ }
115
+
116
+ function init() {
117
+ document.addEventListener("mouseover", (e) => {
118
+ const anchor = e.target.closest("[data-tooltip]");
119
+ if (anchor) show(anchor);
120
+ });
121
+ document.addEventListener("mouseout", (e) => {
122
+ const anchor = e.target.closest("[data-tooltip]");
123
+ if (!anchor) return;
124
+ if (!anchor.contains(e.relatedTarget)) hide();
125
+ });
126
+ }
127
+
128
+ return { init, show, hide };
129
+ })();
@@ -424,6 +424,12 @@ WS.onEvent(ev => {
424
424
  }
425
425
  });
426
426
 
427
+ // ── Warning transformation ─────────────────────────────────────────────────
428
+
429
+ function _transformRetryWarning(message) {
430
+ return message;
431
+ }
432
+
427
433
  // ── Error rendering ────────────────────────────────────────────────────────
428
434
 
429
435
  function renderErrorEvent(ev) {
data/lib/clacky.rb CHANGED
@@ -136,6 +136,7 @@ require_relative "clacky/agent"
136
136
  require_relative "clacky/server/session_registry"
137
137
  require_relative "clacky/server/web_ui_controller"
138
138
  require_relative "clacky/server/browser_manager"
139
+ require_relative "clacky/server/backup_manager"
139
140
  require_relative "clacky/cli"
140
141
 
141
142
  # Runtime patch layer: load user/AI patches from ~/.clacky/patches/ after all
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.2.18
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-13 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:
@@ -432,6 +431,7 @@ files:
432
431
  - lib/clacky/providers.rb
433
432
  - lib/clacky/proxy_config.rb
434
433
  - lib/clacky/rich_ui_controller.rb
434
+ - lib/clacky/server/backup_manager.rb
435
435
  - lib/clacky/server/browser_manager.rb
436
436
  - lib/clacky/server/channel.rb
437
437
  - lib/clacky/server/channel/adapters/base.rb
@@ -456,7 +456,6 @@ files:
456
456
  - lib/clacky/server/channel/channel_config.rb
457
457
  - lib/clacky/server/channel/channel_manager.rb
458
458
  - lib/clacky/server/channel/channel_ui_controller.rb
459
- - lib/clacky/server/channel/group_message_buffer.rb
460
459
  - lib/clacky/server/channel/user_adapter_loader.rb
461
460
  - lib/clacky/server/discover.rb
462
461
  - lib/clacky/server/epipe_safe_io.rb
@@ -540,12 +539,16 @@ files:
540
539
  - lib/clacky/web/app.js
541
540
  - lib/clacky/web/apple-touch-icon-180.png
542
541
  - lib/clacky/web/auth.js
542
+ - lib/clacky/web/backup.js
543
543
  - lib/clacky/web/billing.js
544
544
  - lib/clacky/web/brand.js
545
545
  - lib/clacky/web/channels.js
546
546
  - lib/clacky/web/creator.js
547
547
  - lib/clacky/web/datepicker.js
548
+ - lib/clacky/web/design-sample.css
549
+ - lib/clacky/web/design-sample.html
548
550
  - lib/clacky/web/favicon.ico
551
+ - lib/clacky/web/favicon.svg
549
552
  - lib/clacky/web/i18n.js
550
553
  - lib/clacky/web/icon-dark.svg
551
554
  - lib/clacky/web/icon.svg
@@ -628,7 +631,6 @@ metadata:
628
631
  homepage_uri: https://github.com/clacky-ai/openclacky
629
632
  source_code_uri: https://github.com/clacky-ai/openclacky
630
633
  changelog_uri: https://github.com/clacky-ai/openclacky/blob/main/CHANGELOG.md
631
- post_install_message:
632
634
  rdoc_options: []
633
635
  require_paths:
634
636
  - lib
@@ -646,8 +648,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
646
648
  - !ruby/object:Gem::Version
647
649
  version: '0'
648
650
  requirements: []
649
- rubygems_version: 3.5.22
650
- signing_key:
651
+ rubygems_version: 3.6.9
651
652
  specification_version: 4
652
653
  summary: The most Token-efficient open-source AI Agent — BYOK, Skill-driven, IM-integrated.
653
654
  test_files: []
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Clacky
4
- module Channel
5
- # Stores recent group chat messages per chat_id so that when the bot is
6
- # @-mentioned it can inject prior conversation context into the agent prompt.
7
- # Thread-safe; bounded to MAX_MESSAGES per chat to limit memory growth.
8
- class GroupMessageBuffer
9
- MAX_MESSAGES = 15
10
- PROMPT_LIMIT = 5
11
-
12
- Entry = Struct.new(:user_id, :user_name, :text, keyword_init: true)
13
-
14
- def initialize
15
- @buffers = {}
16
- @mutex = Mutex.new
17
- end
18
-
19
- # @param chat_id [String]
20
- # @param user_id [String]
21
- # @param user_name [String, nil]
22
- # @param text [String]
23
- def push(chat_id, user_id:, text:, user_name: nil)
24
- return if text.nil? || text.strip.empty?
25
-
26
- @mutex.synchronize do
27
- buf = (@buffers[chat_id] ||= [])
28
- buf << Entry.new(user_id: user_id, user_name: user_name, text: text.strip)
29
- buf.shift if buf.size > MAX_MESSAGES
30
- end
31
- end
32
-
33
- # Return the most recent `limit` entries without clearing the buffer.
34
- # @param chat_id [String]
35
- # @param limit [Integer, nil] max entries to return; nil = all
36
- # @return [Array<Entry>]
37
- def peek(chat_id, limit: nil)
38
- @mutex.synchronize do
39
- buf = @buffers[chat_id] || []
40
- limit ? buf.last(limit) : buf.dup
41
- end
42
- end
43
-
44
- # Return buffered entries for a chat and clear them atomically.
45
- # Returns an empty array when there is no history.
46
- # @param chat_id [String]
47
- # @return [Array<Entry>]
48
- def take(chat_id)
49
- @mutex.synchronize { @buffers.delete(chat_id) || [] }
50
- end
51
- end
52
- end
53
- end