openclacky 1.3.1 → 1.3.3

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 (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/lib/clacky/agent/session_serializer.rb +65 -11
  7. data/lib/clacky/agent/time_machine.rb +247 -26
  8. data/lib/clacky/agent.rb +12 -1
  9. data/lib/clacky/agent_config.rb +14 -2
  10. data/lib/clacky/brand_config.rb +1 -1
  11. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  12. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  13. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  14. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  15. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  16. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  17. data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
  18. data/lib/clacky/media/openai_compat.rb +64 -1
  19. data/lib/clacky/media/output_dir.rb +43 -0
  20. data/lib/clacky/message_history.rb +9 -0
  21. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  22. data/lib/clacky/server/git_panel.rb +115 -0
  23. data/lib/clacky/server/http_server.rb +521 -13
  24. data/lib/clacky/server/server_master.rb +6 -4
  25. data/lib/clacky/utils/environment_detector.rb +16 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +512 -60
  28. data/lib/clacky/web/app.js +30 -7
  29. data/lib/clacky/web/components/code-editor.js +197 -0
  30. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  31. data/lib/clacky/web/core/aside.js +112 -0
  32. data/lib/clacky/web/core/ext.js +387 -0
  33. data/lib/clacky/web/features/backup/store.js +92 -0
  34. data/lib/clacky/web/features/backup/view.js +94 -0
  35. data/lib/clacky/web/features/billing/store.js +163 -0
  36. data/lib/clacky/web/{billing.js → features/billing/view.js} +134 -242
  37. data/lib/clacky/web/features/brand/store.js +110 -0
  38. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  39. data/lib/clacky/web/features/channels/store.js +103 -0
  40. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  41. data/lib/clacky/web/features/creator/store.js +81 -0
  42. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  43. data/lib/clacky/web/features/mcp/store.js +158 -0
  44. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  45. data/lib/clacky/web/features/model-tester/store.js +77 -0
  46. data/lib/clacky/web/features/model-tester/view.js +7 -0
  47. data/lib/clacky/web/features/profile/store.js +170 -0
  48. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  49. data/lib/clacky/web/features/share/store.js +145 -0
  50. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  51. data/lib/clacky/web/features/skills/store.js +303 -0
  52. data/lib/clacky/web/features/skills/view.js +550 -0
  53. data/lib/clacky/web/features/tasks/store.js +135 -0
  54. data/lib/clacky/web/features/tasks/view.js +241 -0
  55. data/lib/clacky/web/features/trash/store.js +242 -0
  56. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  57. data/lib/clacky/web/features/version/store.js +165 -0
  58. data/lib/clacky/web/features/version/view.js +323 -0
  59. data/lib/clacky/web/features/workspace/store.js +99 -0
  60. data/lib/clacky/web/features/workspace/view.js +305 -0
  61. data/lib/clacky/web/i18n.js +60 -6
  62. data/lib/clacky/web/index.html +117 -57
  63. data/lib/clacky/web/sessions.js +221 -25
  64. data/lib/clacky/web/settings.js +121 -25
  65. data/lib/clacky/web/skills.js +3 -821
  66. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  67. data/lib/clacky.rb +1 -0
  68. metadata +45 -20
  69. data/lib/clacky/web/backup.js +0 -119
  70. data/lib/clacky/web/model-tester.js +0 -66
  71. data/lib/clacky/web/tasks.js +0 -365
  72. data/lib/clacky/web/version.js +0 -449
  73. data/lib/clacky/web/workspace.js +0 -212
  74. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  75. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  76. /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
  77. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  78. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -0,0 +1,640 @@
1
+ // ── Official panel: time_machine ──────────────────────────────────────────
2
+ //
3
+ // Vertical timeline of the session's tasks (mounted in session.aside tab slot).
4
+ // Clicking a past row opens a right-side drawer with the diff details — the
5
+ // 288px aside is too narrow to host both a file list and a unified diff, so
6
+ // the drawer escapes to ~720px / 90vw and overlays everything.
7
+ //
8
+ // Backed by:
9
+ // GET /api/sessions/:id/time_machine — task list
10
+ // GET /api/sessions/:id/time_machine/:tid/diff — files this task touched
11
+ // GET /api/sessions/:id/time_machine/:tid/diff?path=… — unified diff for one file
12
+ // POST /api/sessions/:id/time_machine/switch — restore working tree
13
+ //
14
+ // Switching rewrites files on disk, so it always goes through an inline confirm.
15
+ // All user-supplied / backend-supplied text is rendered with textContent — no
16
+ // innerHTML on dynamic content.
17
+ // ───────────────────────────────────────────────────────────────────────────
18
+
19
+ (() => {
20
+ if (!window.Clacky || !Clacky.ext) return;
21
+
22
+ const t = (k, fallback) => {
23
+ const v = (typeof I18n !== "undefined") ? I18n.t(k) : null;
24
+ return (v && v !== k) ? v : fallback;
25
+ };
26
+
27
+ if (!document.getElementById("tm-panel-style")) {
28
+ const style = document.createElement("style");
29
+ style.id = "tm-panel-style";
30
+ style.textContent = `
31
+ .tm-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; }
32
+ .tm-list { flex: 1; min-height: 0; overflow: auto; padding: 12px 14px; }
33
+ .tm-rail { position: relative; }
34
+ .tm-rail::before {
35
+ content: ""; position: absolute;
36
+ left: 14.5px; top: 6px; bottom: 6px;
37
+ width: 1px; background: var(--color-border-primary);
38
+ z-index: 0;
39
+ }
40
+ .tm-loading, .tm-empty, .tm-error { color: var(--color-text-tertiary); padding: 16px; font-size: 12px; text-align: center; }
41
+ .tm-error { color: var(--color-error); }
42
+
43
+ .tm-item { position: relative; padding: 9px 12px 9px 28px; border-radius: var(--radius-md); cursor: pointer; margin-bottom: 8px; z-index: 1; }
44
+ .tm-item:hover { background: var(--color-bg-hover); }
45
+ .tm-item.current { background: var(--color-accent-soft); cursor: default; }
46
+ .tm-item.active { background: var(--color-bg-hover); outline: 1px solid var(--color-accent-primary); }
47
+ .tm-item.undone { cursor: pointer; }
48
+
49
+ .tm-item::before {
50
+ content: ""; position: absolute; left: 11px; top: 14px;
51
+ width: 8px; height: 8px; border-radius: 50%;
52
+ background: var(--color-bg-primary);
53
+ border: 1px solid var(--color-border-strong);
54
+ box-sizing: border-box;
55
+ z-index: 2;
56
+ }
57
+ .tm-item:hover::before { background: var(--color-bg-hover); }
58
+ .tm-item.current::before {
59
+ background: var(--color-accent-primary);
60
+ border-color: var(--color-accent-primary);
61
+ box-shadow: 0 0 0 3px var(--color-accent-soft);
62
+ }
63
+ .tm-item.undone::before { border-color: var(--color-text-muted); opacity: 0.6; }
64
+
65
+ .tm-item.empty .tm-title { color: var(--color-text-muted); }
66
+ .tm-item.empty .tm-time { color: var(--color-text-muted); opacity: 0.7; }
67
+
68
+ .tm-head { display: flex; align-items: center; gap: 6px; }
69
+ .tm-badge { flex: none; font-size: 10px; padding: 0 6px; border-radius: var(--radius-pill); }
70
+ .tm-badge.now { background: var(--color-accent-primary); color: var(--color-text-inverse); }
71
+ .tm-badge.branch { background: var(--color-bg-hover); color: var(--color-text-tertiary); }
72
+ .tm-title { font-size: 13px; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
73
+ .tm-item.undone .tm-title { color: var(--color-text-muted); text-decoration: line-through; }
74
+ .tm-time { font-size: 11px; color: var(--color-text-tertiary); margin-top: 2px; }
75
+ .tm-change-count { font-size: 11px; color: var(--color-text-tertiary); margin-left: 4px; }
76
+
77
+ .tm-mini { margin: 0 0 8px 28px; padding: 8px 10px; border: 1px solid var(--color-border-secondary); border-radius: var(--radius-md); background: var(--color-bg-secondary); display: flex; flex-direction: column; gap: 6px; }
78
+ .tm-mini-files { display: flex; flex-direction: column; gap: 2px; }
79
+ .tm-mini-file { font-size: 11px; color: var(--color-text-secondary); display: flex; gap: 6px; align-items: center; overflow: hidden; }
80
+ .tm-mini-file-tag { flex: none; font-size: 9px; padding: 0 5px; border-radius: var(--radius-sm); }
81
+ .tm-mini-file-tag.added { background: var(--color-success-soft, #1f6e2c33); color: var(--color-success, #4eb965); }
82
+ .tm-mini-file-tag.modified { background: var(--color-accent-soft); color: var(--color-accent-primary); }
83
+ .tm-mini-file-tag.deleted { background: var(--color-error-soft, #b03a3a33); color: var(--color-error); }
84
+ .tm-mini-file-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
85
+ .tm-mini.undone .tm-mini-files .tm-mini-file-name { text-decoration: line-through; color: var(--color-text-tertiary); }
86
+ .tm-mini.undone .tm-mini-files .tm-mini-file-tag { opacity: 0.6; }
87
+ .tm-mini-more { font-size: 11px; color: var(--color-text-tertiary); padding-left: 4px; }
88
+ .tm-mini-empty { font-size: 11px; color: var(--color-text-tertiary); padding: 4px 0; }
89
+ .tm-mini-actions { display: flex; gap: 6px; margin-top: 2px; justify-content: flex-end; align-items: center; }
90
+ .tm-mini-btn { padding: 4px 10px; font-size: 11px; line-height: 16px; cursor: pointer; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-bg-primary); color: var(--color-text-secondary); }
91
+ .tm-mini-btn:hover { background: var(--color-bg-hover); }
92
+ .tm-mini-btn.primary { background: var(--color-accent-primary); color: var(--color-text-inverse); border-color: var(--color-accent-primary); }
93
+ .tm-mini-btn.primary:hover { background: var(--color-accent-hover); border-color: var(--color-accent-hover); }
94
+ .tm-mini-btn:disabled { opacity: 0.5; cursor: default; }
95
+
96
+ .tm-mini-confirm {
97
+ display: flex; flex-direction: column; gap: 6px;
98
+ padding: 8px; margin-top: 2px;
99
+ border: 1px solid var(--color-border-secondary);
100
+ border-radius: var(--radius-sm);
101
+ background: var(--color-bg-primary);
102
+ }
103
+ .tm-mini-confirm-msg { font-size: 11px; color: var(--color-text-secondary); line-height: 16px; }
104
+ .tm-mini-confirm-msg strong { color: var(--color-text-primary); font-weight: 500; }
105
+ .tm-mini-confirm-files { display: flex; flex-direction: column; gap: 2px; max-height: 140px; overflow: auto; }
106
+ .tm-mini-confirm-loading { font-size: 11px; color: var(--color-text-tertiary); padding: 4px 0; }
107
+
108
+ .tm-foot { flex: none; padding: 8px 14px; font-size: 11px; color: var(--color-text-tertiary); border-top: 1px solid var(--color-border-secondary); }
109
+
110
+ /* ── Drawer ───────────────────────────────────────────────────────── */
111
+ .tm-drawer-mask {
112
+ position: fixed; inset: 0; background: rgba(0, 0, 0, 0.4);
113
+ z-index: 1000; opacity: 0; transition: opacity 0.2s;
114
+ }
115
+ .tm-drawer-mask.open { opacity: 1; }
116
+ .tm-drawer {
117
+ position: fixed; top: 0; right: 0; bottom: 0;
118
+ width: min(720px, 90vw);
119
+ background: var(--color-bg-primary);
120
+ border-left: 1px solid var(--color-border-primary);
121
+ box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
122
+ z-index: 1001;
123
+ display: flex; flex-direction: column;
124
+ transform: translateX(100%);
125
+ transition: transform 0.25s cubic-bezier(0.2, 0.8, 0.2, 1);
126
+ }
127
+ .tm-drawer.open { transform: translateX(0); }
128
+
129
+ .tm-drawer-head { flex: none; padding: 14px 18px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--color-border-secondary); }
130
+ .tm-drawer-title-wrap { flex: 1; min-width: 0; }
131
+ .tm-drawer-title { font-size: 14px; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 500; }
132
+ .tm-drawer-time { font-size: 11px; color: var(--color-text-tertiary); margin-top: 2px; }
133
+ .tm-drawer-close { flex: none; padding: 4px 10px; font-size: 12px; cursor: pointer; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-bg-secondary); color: var(--color-text-secondary); }
134
+ .tm-drawer-close:hover { background: var(--color-bg-hover); }
135
+ .tm-restore-btn { flex: none; padding: 5px 14px; font-size: 12px; cursor: pointer; border: 1px solid transparent; border-radius: var(--radius-sm); background: var(--color-accent-primary); color: var(--color-text-inverse); }
136
+ .tm-restore-btn:hover { background: var(--color-accent-hover); }
137
+ .tm-restore-btn:disabled { opacity: 0.5; cursor: default; }
138
+
139
+ .tm-confirm-row { flex: none; display: flex; gap: 8px; padding: 10px 18px; align-items: center; background: var(--color-bg-secondary); border-bottom: 1px solid var(--color-border-secondary); }
140
+ .tm-confirm-msg { flex: 1; font-size: 12px; color: var(--color-text-secondary); }
141
+ .tm-confirm-btn { padding: 4px 12px; cursor: pointer; border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); background: var(--color-bg-secondary); font-size: 12px; }
142
+ .tm-confirm-btn.go { background: var(--color-accent-primary); color: var(--color-text-inverse); border-color: transparent; }
143
+ .tm-confirm-btn:disabled { opacity: 0.5; cursor: default; }
144
+
145
+ .tm-drawer-body { flex: 1; min-height: 0; display: flex; }
146
+ .tm-files { flex: 0 0 220px; overflow: auto; border-right: 1px solid var(--color-border-secondary); padding: 6px 0; }
147
+ .tm-file { padding: 7px 14px; font-size: 12px; cursor: pointer; display: flex; align-items: center; gap: 8px; }
148
+ .tm-file:hover { background: var(--color-bg-hover); }
149
+ .tm-file.active { background: var(--color-accent-soft); }
150
+ .tm-file-tag { flex: none; font-size: 9px; padding: 1px 6px; border-radius: var(--radius-sm); }
151
+ .tm-file-tag.added { background: var(--color-success-soft, #1f6e2c33); color: var(--color-success, #4eb965); }
152
+ .tm-file-tag.modified { background: var(--color-accent-soft); color: var(--color-accent-primary); }
153
+ .tm-file-tag.deleted { background: var(--color-error-soft, #b03a3a33); color: var(--color-error); }
154
+ .tm-file-path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text-secondary); }
155
+
156
+ .tm-diff-wrap { flex: 1; min-width: 0; display: flex; flex-direction: column; }
157
+ .tm-diff-path { flex: none; padding: 8px 16px; font-size: 11px; color: var(--color-text-tertiary); border-bottom: 1px solid var(--color-border-secondary); font-family: ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; }
158
+ .tm-diff-path:empty { display: none; }
159
+ .tm-diff { flex: 1; min-width: 0; overflow: auto; padding: 10px 0; font-family: ui-monospace, monospace; font-size: 12px; line-height: 1.55; }
160
+ .tm-diff-stub, .tm-diff-loading { color: var(--color-text-tertiary); padding: 20px; text-align: center; }
161
+ .tm-diff-line { white-space: pre; padding: 0 16px; min-width: max-content; }
162
+ .tm-diff-line.add { background: var(--color-success-soft, #1f6e2c33); color: var(--color-success, #4eb965); }
163
+ .tm-diff-line.del { background: var(--color-error-soft, #b03a3a33); color: var(--color-error); }
164
+ .tm-diff-line.hunk { color: var(--color-text-tertiary); margin-top: 6px; }
165
+ .tm-diff-line.meta { color: var(--color-text-tertiary); }
166
+ `;
167
+ document.head.appendChild(style);
168
+ }
169
+
170
+ function el(tag, attrs, ...kids) {
171
+ const node = document.createElement(tag);
172
+ if (attrs) {
173
+ for (const [k, v] of Object.entries(attrs)) {
174
+ if (k === "class") node.className = v;
175
+ else if (k === "text") node.textContent = v;
176
+ else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2), v);
177
+ else node.setAttribute(k, v);
178
+ }
179
+ }
180
+ kids.forEach((c) => { if (c == null) return; node.appendChild(typeof c === "string" ? document.createTextNode(c) : c); });
181
+ return node;
182
+ }
183
+
184
+ async function api(sessionId, suffix, opts) {
185
+ const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/time_machine${suffix}`, opts);
186
+ return res.json();
187
+ }
188
+
189
+ function relTime(ts) {
190
+ if (!ts) return "";
191
+ const now = Date.now() / 1000;
192
+ const d = Math.max(0, now - ts);
193
+ if (d < 60) return t("tm.justNow", "刚刚");
194
+ if (d < 3600) return `${Math.floor(d / 60)} ${t("tm.minAgo", "分钟前")}`;
195
+ if (d < 86400) return `${Math.floor(d / 3600)} ${t("tm.hourAgo", "小时前")}`;
196
+ if (d < 86400 * 7) return `${Math.floor(d / 86400)} ${t("tm.dayAgo", "天前")}`;
197
+ const dt = new Date(ts * 1000);
198
+ return dt.toLocaleString();
199
+ }
200
+
201
+ function renderTimeline(state) {
202
+ const { listEl, tasks } = state;
203
+ listEl.replaceChildren();
204
+
205
+ const rail = el("div", { class: "tm-rail" });
206
+ listEl.appendChild(rail);
207
+
208
+ const ordered = tasks.slice().reverse();
209
+ ordered.forEach((task) => {
210
+ const isCurrent = task.status === "current";
211
+ const isEmpty = !isCurrent && (task.change_count || 0) === 0;
212
+
213
+ const row = el("div", { class: `tm-item ${task.status}`, "data-task": String(task.task_id) });
214
+ if (isEmpty) row.classList.add("empty");
215
+ if (state.expanded === task.task_id) row.classList.add("active");
216
+
217
+ const head = el("div", { class: "tm-head" });
218
+ head.appendChild(el("div", { class: "tm-title", text: task.summary }));
219
+ if (isCurrent) {
220
+ head.appendChild(el("span", { class: "tm-badge now", text: t("tm.badge.current", "当前") }));
221
+ }
222
+ if (task.has_branches) {
223
+ head.appendChild(el("span", { class: "tm-badge branch", text: t("tm.badge.branch", "分支") }));
224
+ }
225
+ row.appendChild(head);
226
+
227
+ const meta = el("div", { class: "tm-time" });
228
+ if (task.started_at) meta.appendChild(document.createTextNode(relTime(task.started_at)));
229
+ if (!isCurrent) {
230
+ const cc = task.change_count || 0;
231
+ meta.appendChild(el("span", { class: "tm-change-count",
232
+ text: cc === 0 ? ` · ${t("tm.noChanges", "无改动")}` : ` · ${cc} ${t("tm.changedFiles", "个文件")}`
233
+ }));
234
+ }
235
+ row.appendChild(meta);
236
+
237
+ if (!isCurrent) {
238
+ row.addEventListener("click", () => toggleInline(state, task));
239
+ }
240
+ rail.appendChild(row);
241
+
242
+ if (state.expanded === task.task_id) {
243
+ rail.appendChild(buildInline(state, task));
244
+ }
245
+ });
246
+ }
247
+
248
+ function buildInline(state, task) {
249
+ const isEmpty = (task.change_count || 0) === 0;
250
+ const isUndone = task.status === "undone";
251
+ const filesWrap = el("div", { class: "tm-mini-files" },
252
+ isEmpty
253
+ ? el("div", { class: "tm-mini-empty", text: t("tm.diff.noChangesInTask", "本步无文件改动。") })
254
+ : el("div", { class: "tm-mini-empty", text: t("tm.diff.loading", "正在读取改动…") }));
255
+ const detailsBtn = el("button", { class: "tm-mini-btn", type: "button", text: t("tm.viewDetails", "查看详情") });
256
+ if (isEmpty) detailsBtn.disabled = true;
257
+ const restoreBtn = el("button", { class: "tm-mini-btn primary", type: "button", text: t("tm.restore.go", "回到这里") });
258
+ const actions = el("div", { class: "tm-mini-actions" }, detailsBtn, restoreBtn);
259
+ const card = el("div", { class: `tm-mini ${isUndone ? "undone" : ""}` }, filesWrap, actions);
260
+
261
+ detailsBtn.addEventListener("click", (e) => { e.stopPropagation(); openDrawer(state, task); });
262
+ restoreBtn.addEventListener("click", (e) => {
263
+ e.stopPropagation();
264
+ openConfirm(state, task, actions);
265
+ });
266
+
267
+ if (!isEmpty) loadInlineFiles(state, task, filesWrap);
268
+ return card;
269
+ }
270
+
271
+ function openConfirm(state, task, actions) {
272
+ const msg = el("div", { class: "tm-mini-confirm-msg" });
273
+ msg.appendChild(document.createTextNode(t("tm.restore.previewLoading", "正在分析将受影响的文件…")));
274
+ const filesBox = el("div", { class: "tm-mini-confirm-files" });
275
+ const confirmYes = el("button", { class: "tm-mini-btn primary", type: "button", text: t("tm.restore.confirm", "确认回到这里") });
276
+ confirmYes.disabled = true;
277
+ const confirmNo = el("button", { class: "tm-mini-btn", type: "button", text: t("tm.restore.cancel", "取消") });
278
+ const confirmActions = el("div", { class: "tm-mini-actions" }, confirmNo, confirmYes);
279
+ const box = el("div", { class: "tm-mini-confirm" }, msg, filesBox, confirmActions);
280
+ actions.replaceWith(box);
281
+
282
+ confirmNo.addEventListener("click", (ev) => { ev.stopPropagation(); box.replaceWith(actions); });
283
+ confirmYes.addEventListener("click", async (ev) => {
284
+ ev.stopPropagation();
285
+ confirmYes.disabled = true; confirmNo.disabled = true;
286
+ await performRestoreInline(state, task.task_id);
287
+ });
288
+
289
+ loadRestorePreview(state, task.task_id, msg, filesBox, confirmYes);
290
+ }
291
+
292
+ async function loadRestorePreview(state, taskId, msg, filesBox, confirmBtn) {
293
+ let res;
294
+ try { res = await api(state.sessionId, `/${taskId}/restore_preview`); }
295
+ catch (_e) {
296
+ msg.replaceChildren(document.createTextNode(t("tm.restore.previewFail", "无法预览受影响文件。仍将继续操作。")));
297
+ confirmBtn.disabled = false;
298
+ return;
299
+ }
300
+ if (state.expanded !== taskId) return;
301
+ const changes = (res && res.ok && Array.isArray(res.changes)) ? res.changes : [];
302
+ confirmBtn.disabled = false;
303
+
304
+ if (changes.length === 0) {
305
+ msg.replaceChildren(document.createTextNode(t("tm.restore.previewEmpty", "当前工作区与目标状态一致,回到这里不会修改任何文件。")));
306
+ return;
307
+ }
308
+
309
+ msg.replaceChildren();
310
+ const tpl = t("tm.restore.previewMsg", "以下 %d 个文件会被恢复,当前的修改将被覆盖:");
311
+ msg.appendChild(document.createTextNode(tpl.replace("%d", String(changes.length))));
312
+
313
+ const tagText = {
314
+ create: t("tm.tag.created", "新建"),
315
+ modify: t("tm.tag.modified", "修改"),
316
+ delete: t("tm.tag.deleted", "删除"),
317
+ };
318
+ const statusClass = { create: "added", modify: "modified", delete: "deleted" };
319
+ const shown = changes.slice(0, 5);
320
+ const nodes = shown.map((f) => el("div", { class: "tm-mini-file", title: f.path },
321
+ el("span", { class: `tm-mini-file-tag ${statusClass[f.action] || ""}`, text: tagText[f.action] || f.action }),
322
+ el("span", { class: "tm-mini-file-name", text: f.path }),
323
+ ));
324
+ if (changes.length > shown.length) {
325
+ nodes.push(el("div", { class: "tm-mini-more",
326
+ text: t("tm.moreFiles", "还有 %d 个").replace("%d", changes.length - shown.length) }));
327
+ }
328
+ filesBox.replaceChildren(...nodes);
329
+ }
330
+
331
+ async function loadInlineFiles(state, task, filesWrap) {
332
+ const taskId = task.task_id;
333
+ const isUndone = task.status === "undone";
334
+ let res;
335
+ try { res = await api(state.sessionId, `/${taskId}/diff`); }
336
+ catch (_e) {
337
+ filesWrap.replaceChildren(el("div", { class: "tm-mini-empty", text: t("tm.diff.fail", "读取改动失败") }));
338
+ return;
339
+ }
340
+ if (state.expanded !== taskId) return;
341
+ if (!res.ok) {
342
+ filesWrap.replaceChildren(el("div", { class: "tm-mini-empty", text: res.error || t("tm.diff.fail", "读取改动失败") }));
343
+ return;
344
+ }
345
+ const files = res.files || [];
346
+ if (files.length === 0) {
347
+ filesWrap.replaceChildren(el("div", { class: "tm-mini-empty", text: t("tm.diff.noFiles", "没有文件改动。") }));
348
+ return;
349
+ }
350
+ const tagText = { added: t("tm.tag.added", "新增"), modified: t("tm.tag.modified", "修改"), deleted: t("tm.tag.deleted", "删除") };
351
+ const undoneHint = t("tm.undone.fileHint", "该步骤已被撤销,此改动已不在工作区");
352
+ const shown = files.slice(0, 3);
353
+ const nodes = shown.map((f) => el("div",
354
+ { class: "tm-mini-file", title: isUndone ? `${f.path} — ${undoneHint}` : f.path },
355
+ el("span", { class: `tm-mini-file-tag ${f.status}`, text: tagText[f.status] || f.status }),
356
+ el("span", { class: "tm-mini-file-name", text: f.path.split("/").pop() }),
357
+ ));
358
+ if (files.length > shown.length) {
359
+ nodes.push(el("div", { class: "tm-mini-more", text: `… ${t("tm.moreFiles", "还有 %d 个").replace("%d", files.length - shown.length)}` }));
360
+ }
361
+ filesWrap.replaceChildren(...nodes);
362
+ }
363
+
364
+ function toggleInline(state, task) {
365
+ state.expanded = (state.expanded === task.task_id) ? null : task.task_id;
366
+ renderTimeline(state);
367
+ }
368
+
369
+ async function performRestoreInline(state, taskId) {
370
+ state.footEl.textContent = t("tm.restoring", "正在恢复…");
371
+ try {
372
+ const res = await api(state.sessionId, "/switch", {
373
+ method: "POST",
374
+ headers: { "Content-Type": "application/json" },
375
+ body: JSON.stringify({ task_id: taskId }),
376
+ });
377
+ if (res.ok) {
378
+ state.footEl.textContent = res.message || t("tm.restored", "已恢复");
379
+ state.expanded = null;
380
+ await loadHistory(state);
381
+ } else {
382
+ state.footEl.textContent = res.error || t("tm.restoreFailed", "恢复失败");
383
+ }
384
+ } catch (_e) {
385
+ state.footEl.textContent = t("tm.restoreFailed", "恢复失败");
386
+ }
387
+ }
388
+
389
+ function renderDiffText(patch) {
390
+ const wrap = el("div");
391
+ if (!patch || patch.trim() === "") {
392
+ wrap.appendChild(el("div", { class: "tm-diff-stub", text: t("tm.diff.same", "这一步没有改动这个文件的内容。") }));
393
+ return wrap;
394
+ }
395
+ patch.split("\n").forEach((line) => {
396
+ let cls = "tm-diff-line";
397
+ if (line.startsWith("+++") || line.startsWith("---")) cls += " meta";
398
+ else if (line.startsWith("@@")) cls += " hunk";
399
+ else if (line.startsWith("+")) cls += " add";
400
+ else if (line.startsWith("-")) cls += " del";
401
+ wrap.appendChild(el("div", { class: cls, text: line || " " }));
402
+ });
403
+ return wrap;
404
+ }
405
+
406
+ async function loadFileDiff(state, rel) {
407
+ state.diffEl.replaceChildren(el("div", { class: "tm-diff-loading", text: t("tm.diff.loading", "正在读取差异…") }));
408
+ try {
409
+ const res = await api(state.sessionId, `/${state.selected}/diff?path=${encodeURIComponent(rel)}`);
410
+ if (!res.ok) {
411
+ state.diffEl.replaceChildren(el("div", { class: "tm-diff-stub", text: res.error || t("tm.diff.fail", "读取差异失败") }));
412
+ return;
413
+ }
414
+ if (res.binary) {
415
+ state.diffEl.replaceChildren(el("div", { class: "tm-diff-stub", text: t("tm.diff.binary", "二进制文件,跳过逐行对比。") }));
416
+ return;
417
+ }
418
+ state.diffEl.replaceChildren(renderDiffText(res.patch));
419
+ } catch (_e) {
420
+ state.diffEl.replaceChildren(el("div", { class: "tm-diff-stub", text: t("tm.diff.fail", "读取差异失败") }));
421
+ }
422
+ }
423
+
424
+ function openDrawer(state, task) {
425
+ state.selected = task.task_id;
426
+
427
+ state.drawerTitleEl.textContent = task.summary;
428
+ state.drawerTimeEl.textContent = task.started_at ? relTime(task.started_at) : "";
429
+ state.confirmRow.style.display = "none";
430
+ state.restoreBtn.disabled = false;
431
+ state.filesEl.replaceChildren(el("div", { class: "tm-diff-loading", text: t("tm.diff.loading", "正在读取改动…") }));
432
+ state.diffEl.replaceChildren();
433
+ state.diffPathEl.textContent = "";
434
+
435
+ state.maskEl.style.display = "block";
436
+ state.drawerEl.style.display = "flex";
437
+ requestAnimationFrame(() => {
438
+ state.maskEl.classList.add("open");
439
+ state.drawerEl.classList.add("open");
440
+ });
441
+
442
+ loadDrawerFiles(state, task.task_id);
443
+ }
444
+
445
+ async function loadDrawerFiles(state, taskId) {
446
+ let res;
447
+ try {
448
+ res = await api(state.sessionId, `/${taskId}/diff`);
449
+ } catch (_e) {
450
+ state.filesEl.replaceChildren(el("div", { class: "tm-diff-stub", text: t("tm.diff.fail", "读取改动失败") }));
451
+ return;
452
+ }
453
+ if (state.selected !== taskId) return;
454
+ if (!res.ok) {
455
+ state.filesEl.replaceChildren(el("div", { class: "tm-diff-stub", text: res.error || t("tm.diff.fail", "读取改动失败") }));
456
+ return;
457
+ }
458
+ const files = res.files || [];
459
+ if (files.length === 0) {
460
+ state.filesEl.replaceChildren(el("div", { class: "tm-diff-stub", text: t("tm.diff.noFiles", "没有文件改动。") }));
461
+ return;
462
+ }
463
+ const tagText = { added: t("tm.tag.added", "新增"), modified: t("tm.tag.modified", "修改"), deleted: t("tm.tag.deleted", "删除") };
464
+ const fileNodes = files.map((f) => {
465
+ const basename = f.path.split("/").pop();
466
+ const node = el("div", { class: "tm-file", title: f.path },
467
+ el("span", { class: `tm-file-tag ${f.status}`, text: tagText[f.status] || f.status }),
468
+ el("span", { class: "tm-file-path", text: basename }),
469
+ );
470
+ node.addEventListener("click", () => {
471
+ state.filesEl.querySelectorAll(".tm-file.active").forEach((n) => n.classList.remove("active"));
472
+ node.classList.add("active");
473
+ state.diffPathEl.textContent = f.path;
474
+ if (f.binary) {
475
+ state.diffEl.replaceChildren(el("div", { class: "tm-diff-stub", text: t("tm.diff.binary", "二进制文件,跳过逐行对比。") }));
476
+ } else {
477
+ loadFileDiff(state, f.path);
478
+ }
479
+ });
480
+ return node;
481
+ });
482
+ state.filesEl.replaceChildren(...fileNodes);
483
+ fileNodes[0].click();
484
+ }
485
+
486
+ function closeDrawer(state) {
487
+ state.maskEl.classList.remove("open");
488
+ state.drawerEl.classList.remove("open");
489
+ setTimeout(() => {
490
+ state.maskEl.style.display = "none";
491
+ state.drawerEl.style.display = "none";
492
+ }, 250);
493
+ state.selected = null;
494
+ }
495
+
496
+ async function performRestore(state) {
497
+ state.restoreBtn.disabled = true;
498
+ state.footEl.textContent = t("tm.restoring", "正在恢复…");
499
+ try {
500
+ const res = await api(state.sessionId, "/switch", {
501
+ method: "POST",
502
+ headers: { "Content-Type": "application/json" },
503
+ body: JSON.stringify({ task_id: state.selected }),
504
+ });
505
+ if (res.ok) {
506
+ state.footEl.textContent = res.message || t("tm.restored", "已恢复");
507
+ closeDrawer(state);
508
+ await loadHistory(state);
509
+ } else {
510
+ state.footEl.textContent = res.error || t("tm.restoreFailed", "恢复失败");
511
+ state.restoreBtn.disabled = false;
512
+ }
513
+ } catch (_e) {
514
+ state.footEl.textContent = t("tm.restoreFailed", "恢复失败");
515
+ state.restoreBtn.disabled = false;
516
+ }
517
+ }
518
+
519
+ async function loadHistory(state) {
520
+ state.listEl.replaceChildren(el("div", { class: "tm-loading", text: t("tm.loading", "正在读取历史…") }));
521
+ let data;
522
+ try {
523
+ data = await api(state.sessionId, "");
524
+ } catch (_e) {
525
+ state.listEl.replaceChildren(el("div", { class: "tm-error", text: t("tm.error", "读取历史失败") }));
526
+ return;
527
+ }
528
+ state.tasks = (data && data.tasks) || [];
529
+ if (state.tasks.length === 0) {
530
+ state.listEl.replaceChildren(el("div", { class: "tm-empty", text: t("tm.empty", "还没有可回到的版本。") }));
531
+ return;
532
+ }
533
+ renderTimeline(state);
534
+ }
535
+
536
+ // The drawer is global — only one can be open at a time across mounts, and
537
+ // it lives on document.body so it can escape the narrow aside column.
538
+ function buildDrawer() {
539
+ if (document.getElementById("tm-drawer-root")) {
540
+ return {
541
+ mask: document.querySelector(".tm-drawer-mask"),
542
+ drawer: document.getElementById("tm-drawer-root"),
543
+ title: document.querySelector(".tm-drawer-title"),
544
+ time: document.querySelector(".tm-drawer-time"),
545
+ restore: document.querySelector(".tm-restore-btn"),
546
+ close: document.querySelector(".tm-drawer-close"),
547
+ confirmRow: document.querySelector(".tm-confirm-row"),
548
+ confirmYes: document.querySelector(".tm-confirm-btn.go"),
549
+ confirmNo: document.querySelector(".tm-confirm-btn:not(.go)"),
550
+ files: document.querySelector(".tm-files"),
551
+ diff: document.querySelector(".tm-diff"),
552
+ diffPath: document.querySelector(".tm-diff-path"),
553
+ };
554
+ }
555
+
556
+ const mask = el("div", { class: "tm-drawer-mask" });
557
+ const titleEl = el("div", { class: "tm-drawer-title" });
558
+ const timeEl = el("div", { class: "tm-drawer-time" });
559
+ const restoreBtn = el("button", { class: "tm-restore-btn", type: "button", text: t("tm.restore.go", "回到这里") });
560
+ const closeBtn = el("button", { class: "tm-drawer-close", type: "button", text: t("tm.detail.close", "关闭") });
561
+ const head = el("div", { class: "tm-drawer-head" },
562
+ el("div", { class: "tm-drawer-title-wrap" }, titleEl, timeEl),
563
+ restoreBtn, closeBtn,
564
+ );
565
+
566
+ const confirmYes = el("button", { class: "tm-confirm-btn go", type: "button", text: t("tm.restore.confirm", "确认恢复") });
567
+ const confirmNo = el("button", { class: "tm-confirm-btn", type: "button", text: t("tm.restore.cancel", "取消") });
568
+ const confirmRow = el("div", { class: "tm-confirm-row" },
569
+ el("span", { class: "tm-confirm-msg", text: t("tm.restore.msg", "回到这一步会把文件恢复到当时的状态。") }),
570
+ confirmYes, confirmNo,
571
+ );
572
+ confirmRow.style.display = "none";
573
+
574
+ const filesEl = el("div", { class: "tm-files" });
575
+ const diffPathEl = el("div", { class: "tm-diff-path" });
576
+ const diffEl = el("div", { class: "tm-diff" });
577
+ const diffWrap = el("div", { class: "tm-diff-wrap" }, diffPathEl, diffEl);
578
+ const body = el("div", { class: "tm-drawer-body" }, filesEl, diffWrap);
579
+
580
+ const drawer = el("div", { id: "tm-drawer-root", class: "tm-drawer" }, head, confirmRow, body);
581
+ drawer.style.display = "none";
582
+ mask.style.display = "none";
583
+
584
+ document.body.appendChild(mask);
585
+ document.body.appendChild(drawer);
586
+
587
+ return { mask, drawer, title: titleEl, time: timeEl, restore: restoreBtn, close: closeBtn,
588
+ confirmRow, confirmYes, confirmNo, files: filesEl, diff: diffEl, diffPath: diffPathEl };
589
+ }
590
+
591
+ Clacky.ext.ui.mount("session.aside", (ctx) => {
592
+ if (!ctx || !ctx.sessionId) return null;
593
+
594
+ const list = el("div", { class: "tm-list" });
595
+ const foot = el("div", { class: "tm-foot", text: t("tm.foot", "每完成一步会自动存档。点击想回到的版本即可恢复。") });
596
+ const root = el("div", { class: "tm-panel", "data-panel": "tm" }, list, foot);
597
+
598
+ const d = buildDrawer();
599
+
600
+ const state = {
601
+ sessionId: ctx.sessionId,
602
+ tasks: [],
603
+ selected: null,
604
+ expanded: null,
605
+ panelEl: root, listEl: list, footEl: foot,
606
+ maskEl: d.mask, drawerEl: d.drawer,
607
+ drawerTitleEl: d.title, drawerTimeEl: d.time,
608
+ restoreBtn: d.restore, filesEl: d.files, diffEl: d.diff, diffPathEl: d.diffPath, confirmRow: d.confirmRow,
609
+ };
610
+
611
+ // Re-bind the global drawer's controls to this mount's state. (The drawer
612
+ // is shared across mounts but only one is interactive at a time.)
613
+ const onClose = () => closeDrawer(state);
614
+ const onRestoreClick = () => {
615
+ d.confirmRow.style.display = "flex";
616
+ d.restore.disabled = true;
617
+ };
618
+ const onCancelClick = () => {
619
+ d.confirmRow.style.display = "none";
620
+ d.restore.disabled = false;
621
+ };
622
+ const onGoClick = () => performRestore(state);
623
+ const onMaskClick = () => closeDrawer(state);
624
+ const onKey = (e) => { if (e.key === "Escape" && d.drawer.classList.contains("open")) closeDrawer(state); };
625
+
626
+ d.close.onclick = onClose;
627
+ d.restore.onclick = onRestoreClick;
628
+ d.confirmNo.onclick = onCancelClick;
629
+ d.confirmYes.onclick = onGoClick;
630
+ d.mask.onclick = onMaskClick;
631
+ document.addEventListener("keydown", onKey);
632
+
633
+ loadHistory(state);
634
+ return root;
635
+ }, {
636
+ panel: "time_machine",
637
+ order: 20,
638
+ tab: { id: "tm", label: t("tm.tab", "时光机") },
639
+ });
640
+ })();
@@ -1,2 +1,5 @@
1
1
  name: coding
2
2
  description: AI coding assistant and technical co-founder
3
+ panels:
4
+ - git
5
+ - time_machine
File without changes
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: cron-task-creator
3
- description: 'Create, manage, and run scheduled automated tasks (cron jobs) in Clacky. Use this skill whenever the user wants to create a new automated task or cron job, set up recurring automation, schedule something to run daily/weekly/hourly, view all scheduled tasks, edit an existing task prompt or cron schedule, enable or disable a task, delete a task, check task run history or logs, or run a task immediately via the WebUI. Trigger on phrases like 定时任务, 自动化任务, 每天自动, 创建任务, cron, 定时执行, scheduled task, automate this, run every day, set up automation, edit my task, list my tasks, what tasks do I have, disable task, run task now, task history, etc.'
3
+ description: 'Create, manage, and run scheduled automated tasks (cron jobs) in Clacky. Use this skill whenever the user wants to create a new automated task or cron job, set up recurring automation, schedule something to run daily/weekly/hourly, view all scheduled tasks, edit an existing task prompt or cron schedule, enable or disable a task, delete a task, check task run history or logs, or run a task immediately via the WebUI. Trigger on phrases like cron, scheduled task, run every day, automate this; 定时任务, 每天自动, 定时执行.'
4
4
  disable-model-invocation: false
5
5
  user-invocable: true
6
6
  ---