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,550 @@
1
+ // ── Skills · view — rendering, slots, DOM event wiring ─────────────────────
2
+ //
3
+ // The view owns everything that touches the DOM: rendering skill cards,
4
+ // switching tabs, wiring panel listeners, the import bar. It reads data only
5
+ // through SkillsStore.state and reacts to store events via SkillsStore.on(...).
6
+ // It never fetches or mutates core data directly — it calls store actions.
7
+ //
8
+ // Several entry points (onPanelShow / renderSection / toggleImportBar /
9
+ // openBrandSkillsTab) are still invoked on the `Skills` global by other modules
10
+ // (app.js, settings.js, SkillAC). The view augments the same `Skills` facade
11
+ // with these UI methods so existing callers keep working unchanged.
12
+ //
13
+ // Depends on: SkillsStore (store.js), I18n/Modal/Router/Brand, Sessions,
14
+ // global $ / escapeHtml helpers.
15
+ // ───────────────────────────────────────────────────────────────────────────
16
+
17
+ const SkillsView = (() => {
18
+ let _domWired = false;
19
+
20
+ // ── My Skills rendering ──────────────────────────────────────────────────
21
+
22
+ function _renderMySkills() {
23
+ const container = $("skills-list");
24
+ if (!container) { console.error("[Skills] skills-list not found!"); return; }
25
+ container.innerHTML = "";
26
+
27
+ const skills = SkillsStore.state.skills;
28
+ const visible = SkillsStore.state.showSystemSkills
29
+ ? skills
30
+ : skills.filter(s => s.always_show || s.source !== "default");
31
+
32
+ if (visible.length === 0) {
33
+ container.appendChild(_renderEmptyState());
34
+ return;
35
+ }
36
+
37
+ const sorted = [
38
+ ...visible.filter(s => s.source === "default"),
39
+ ...visible.filter(s => s.source !== "default")
40
+ ];
41
+ sorted.forEach((skill, i) => {
42
+ try {
43
+ container.appendChild(_renderSkillCard(skill));
44
+ } catch (e) {
45
+ console.error("[Skills] _renderSkillCard failed for skill", i, skill.name, e);
46
+ }
47
+ });
48
+ }
49
+
50
+ function _renderEmptyState() {
51
+ const emptyWrapper = document.createElement("div");
52
+ emptyWrapper.className = "skills-empty";
53
+
54
+ const emptyTextEl = document.createElement("div");
55
+ emptyTextEl.className = "skills-empty-text";
56
+ emptyTextEl.textContent = I18n.t("skills.empty");
57
+
58
+ const createBtn = document.createElement("div");
59
+ createBtn.className = "skills-empty-create-btn";
60
+ createBtn.innerHTML = `
61
+ <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">
62
+ <path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z"/><path d="M12 8v8"/><path d="M8 12h8"/>
63
+ </svg>
64
+ <span>${escapeHtml(I18n.t("skills.empty.createBtn"))}</span>
65
+ <svg class="skills-empty-create-arrow" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
66
+ <path d="M5 12h14"/><path d="M12 5l7 7-7 7"/>
67
+ </svg>`;
68
+ createBtn.addEventListener("click", () => Skills.createInSession("/skill-creator"));
69
+
70
+ emptyWrapper.appendChild(emptyTextEl);
71
+ emptyWrapper.appendChild(createBtn);
72
+ return emptyWrapper;
73
+ }
74
+
75
+ function _renderSkillCard(skill) {
76
+ const card = document.createElement("div");
77
+ card.className = "skill-card" + (skill.invalid ? " skill-card-invalid" : "");
78
+
79
+ const isSystem = skill.source === "default" || skill.source === "brand";
80
+ const badgeClass = isSystem ? "skill-badge skill-badge-system" : "skill-badge skill-badge-custom";
81
+ const badgeLabel = isSystem ? I18n.t("skills.badge.system") : I18n.t("skills.badge.custom");
82
+
83
+ let warnIconHtml = "";
84
+ let errorNoticeHtml = "";
85
+ if (skill.invalid) {
86
+ const reason = skill.invalid_reason || I18n.t("skills.invalid.reason");
87
+ errorNoticeHtml = `<div class="skill-notice skill-notice-error">⚠ ${escapeHtml(reason)}</div>`;
88
+ } else if (skill.warnings && skill.warnings.length > 0) {
89
+ const reason = skill.warnings.join("\n");
90
+ const tooltip = I18n.t("skills.warning.tooltip", { reason });
91
+ warnIconHtml = `<span class="skill-warn-icon" data-tooltip="${escapeHtml(tooltip)}">⚠</span>`;
92
+ }
93
+
94
+ const toggleDisabled = isSystem || skill.invalid;
95
+ const toggleTitle = isSystem ? I18n.t("skills.systemDisabledTip")
96
+ : skill.invalid ? I18n.t("skills.invalid.toggleTip")
97
+ : skill.enabled ? I18n.t("skills.toggle.disableDesc")
98
+ : I18n.t("skills.toggle.enableDesc");
99
+
100
+ const currentLang = I18n.lang();
101
+ const description = (currentLang === "zh" && skill.description_zh)
102
+ ? skill.description_zh
103
+ : skill.description || "";
104
+
105
+ const useButtonHtml = skill.invalid
106
+ ? ""
107
+ : `<button class="btn-skill-use" data-name="${escapeHtml(skill.name)}">${I18n.t("skills.btn.use")}</button>`;
108
+
109
+ const deleteButtonHtml = isSystem
110
+ ? ""
111
+ : `<button class="btn-skill-delete" data-name="${escapeHtml(skill.name)}" title="${I18n.t("skills.btn.delete")}">
112
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
113
+ <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"/>
114
+ </svg>
115
+ </button>`;
116
+
117
+ card.innerHTML = `
118
+ <div class="skill-card-main">
119
+ <div class="skill-card-info">
120
+ <div class="skill-card-title">
121
+ ${warnIconHtml}
122
+ <span class="skill-name">${escapeHtml((currentLang === "zh" && skill.name_zh) ? skill.name_zh : skill.name)}</span>
123
+ <span class="${badgeClass}">${badgeLabel}</span>
124
+ ${skill.invalid ? `<span class="skill-badge skill-badge-invalid">${I18n.t("skills.badge.invalid")}</span>` : ""}
125
+ </div>
126
+ <div class="skill-card-desc">${escapeHtml(description)}</div>
127
+ </div>
128
+ <div class="skill-card-actions">
129
+ <label class="skill-toggle ${toggleDisabled ? "skill-toggle-disabled" : ""}" data-tooltip="${escapeHtml(toggleTitle)}">
130
+ <input type="checkbox" class="skill-toggle-input" ${skill.enabled ? "checked" : ""} ${toggleDisabled ? "disabled" : ""}>
131
+ <span class="skill-toggle-track"></span>
132
+ </label>
133
+ ${useButtonHtml}
134
+ ${deleteButtonHtml}
135
+ </div>
136
+ </div>
137
+ ${errorNoticeHtml}`;
138
+
139
+ if (!isSystem) {
140
+ const checkbox = card.querySelector(".skill-toggle-input");
141
+ checkbox.addEventListener("change", () => Skills.toggle(skill.name, checkbox.checked));
142
+ }
143
+
144
+ const toggleLabel = card.querySelector(".skill-toggle");
145
+ if (toggleLabel) {
146
+ toggleLabel.addEventListener("mouseenter", () => {
147
+ const scroller = toggleLabel.closest(".skills-tab-content");
148
+ if (!scroller) return;
149
+ const toggleTop = toggleLabel.getBoundingClientRect().top;
150
+ const scrollerTop = scroller.getBoundingClientRect().top;
151
+ if (toggleTop - scrollerTop < 80) {
152
+ toggleLabel.setAttribute("data-tooltip-pos", "bottom");
153
+ }
154
+ });
155
+ }
156
+
157
+ const useBtn = card.querySelector(".btn-skill-use");
158
+ if (useBtn) useBtn.addEventListener("click", () => Skills.useInstalledSkill(skill.name));
159
+
160
+ const deleteBtn = card.querySelector(".btn-skill-delete");
161
+ if (deleteBtn) deleteBtn.addEventListener("click", (e) => {
162
+ e.stopPropagation();
163
+ Skills.delete(skill.name);
164
+ });
165
+
166
+ return card;
167
+ }
168
+
169
+ // ── Brand Skills rendering ───────────────────────────────────────────────
170
+
171
+ function _renderBrandLoading() {
172
+ const container = $("brand-skills-list");
173
+ if (!container) return;
174
+ container.innerHTML = Array.from({ length: 4 }).map(() => `
175
+ <div class="brand-skill-card">
176
+ <div class="brand-skill-card-main">
177
+ <div class="brand-skill-info">
178
+ <div class="brand-skill-title">
179
+ <span class="skel skel-title"></span>
180
+ <span class="skel" style="height:1rem;width:3.5rem;border-radius:4px;"></span>
181
+ </div>
182
+ <span class="skel skel-subtitle"></span>
183
+ </div>
184
+ <div class="brand-skill-actions">
185
+ <span class="skel" style="height:1.75rem;width:4.5rem;border-radius:6px;"></span>
186
+ </div>
187
+ </div>
188
+ </div>`).join("");
189
+ }
190
+
191
+ function _renderBrandError(payload) {
192
+ const container = $("brand-skills-list");
193
+ if (!container) return;
194
+ const msg = payload.network
195
+ ? "Network error \u2014 please try again."
196
+ : escapeHtml(payload.error || I18n.t("skills.brand.loadFailed"));
197
+ container.innerHTML = `<div class="brand-skills-error">${msg}</div>`;
198
+ }
199
+
200
+ function _applyBrandWarning(warning, warningCode) {
201
+ const warningBanner = $("brand-skills-warning");
202
+ if (!warningBanner) return;
203
+ const warningText = warningCode ? I18n.t("skills.brand.warning." + warningCode) : warning;
204
+ if (warningText) {
205
+ warningBanner.textContent = warningText;
206
+ if (warningCode) warningBanner.setAttribute("data-i18n", "skills.brand.warning." + warningCode);
207
+ else warningBanner.removeAttribute("data-i18n");
208
+ warningBanner.style.display = "";
209
+ } else {
210
+ warningBanner.style.display = "none";
211
+ warningBanner.removeAttribute("data-i18n");
212
+ }
213
+ }
214
+
215
+ function _renderBrandSkills() {
216
+ const container = $("brand-skills-list");
217
+ if (!container) return;
218
+ container.innerHTML = "";
219
+
220
+ const brandSkills = SkillsStore.state.brandSkills;
221
+ const freeMode = SkillsStore.state.freeMode;
222
+ const paidSkillsCount = SkillsStore.state.paidSkillsCount;
223
+
224
+ if (brandSkills.length === 0 && !(freeMode && paidSkillsCount > 0)) {
225
+ container.innerHTML = `<div class="brand-skills-empty">${I18n.t("skills.brand.empty")}</div>`;
226
+ return;
227
+ }
228
+
229
+ brandSkills.forEach(skill => container.appendChild(_renderBrandSkillCard(skill)));
230
+
231
+ if (freeMode && paidSkillsCount > 0) {
232
+ container.appendChild(_renderPaidHint(paidSkillsCount));
233
+ }
234
+ }
235
+
236
+ function _renderPaidHint(paidSkillsCount) {
237
+ const hint = document.createElement("div");
238
+ hint.className = "brand-skills-paid-hint";
239
+
240
+ const msgEl = document.createElement("div");
241
+ msgEl.className = "brand-skills-paid-hint-msg";
242
+ msgEl.textContent = I18n.t("skills.brand.paidHint", { n: paidSkillsCount });
243
+ msgEl.setAttribute("data-i18n", "skills.brand.paidHint");
244
+ msgEl.setAttribute("data-i18n-vars", `n=${paidSkillsCount}`);
245
+
246
+ const btn = document.createElement("button");
247
+ btn.className = "brand-skills-activate-btn";
248
+ btn.textContent = I18n.t("skills.brand.activateBtn");
249
+ btn.setAttribute("data-i18n", "skills.brand.activateBtn");
250
+ btn.addEventListener("click", () => {
251
+ if (typeof Brand !== "undefined" && Brand.goToLicenseInput) Brand.goToLicenseInput();
252
+ else Router.navigate("settings");
253
+ });
254
+
255
+ hint.appendChild(msgEl);
256
+ hint.appendChild(btn);
257
+ return hint;
258
+ }
259
+
260
+ function _renderBrandSkillCard(skill) {
261
+ const name = skill.name;
262
+ const installedVersion = skill.installed_version;
263
+ const latestVersion = (skill.latest_version || {}).version || skill.version;
264
+ const needsUpdate = skill.needs_update;
265
+
266
+ let statusHtml = "";
267
+ if (!installedVersion) {
268
+ const versionBadge = latestVersion
269
+ ? `<span class="brand-skill-version latest">v${escapeHtml(latestVersion)}</span>` : "";
270
+ statusHtml = `${versionBadge}<button class="btn-brand-install" data-name="${escapeHtml(name)}">${I18n.t("skills.brand.btn.install")}</button>`;
271
+ } else if (needsUpdate) {
272
+ statusHtml = `
273
+ <span class="brand-skill-version installed">v${escapeHtml(installedVersion)}</span>
274
+ <span class="brand-skill-update-arrow">→</span>
275
+ <span class="brand-skill-version latest">v${escapeHtml(latestVersion)}</span>
276
+ <button class="btn-brand-update" data-name="${escapeHtml(name)}">${I18n.t("skills.brand.btn.update")}</button>`;
277
+ } else {
278
+ const displayVersion = installedVersion || latestVersion;
279
+ statusHtml = `
280
+ <span class="brand-skill-version installed">v${escapeHtml(displayVersion)} ✓</span>
281
+ <button class="btn-brand-use" data-name="${escapeHtml(name)}">${I18n.t("skills.brand.btn.use")}</button>
282
+ <button class="btn-skill-delete btn-brand-delete" data-name="${escapeHtml(name)}" title="${I18n.t("skills.btn.delete")}">
283
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
284
+ <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"/>
285
+ </svg>
286
+ </button>`;
287
+ }
288
+
289
+ const badge = skill.is_free
290
+ ? `<span class="brand-skill-badge-free" title="${I18n.t("skills.brand.freeTip")}">✨ ${I18n.t("skills.brand.free")}</span>`
291
+ : `<span class="brand-skill-badge-private" title="${I18n.t("skills.brand.privateTip")}">🔒 ${I18n.t("skills.brand.private")}</span>`;
292
+
293
+ const currentLang = I18n.lang();
294
+ const description = (currentLang === "zh" && skill.description_zh)
295
+ ? skill.description_zh
296
+ : skill.description || "";
297
+
298
+ const card = document.createElement("div");
299
+ card.className = "brand-skill-card";
300
+ card.innerHTML = `
301
+ <div class="brand-skill-card-main">
302
+ <div class="brand-skill-info">
303
+ <div class="brand-skill-title">
304
+ <span class="brand-skill-name">${escapeHtml((currentLang === "zh" && skill.name_zh) ? skill.name_zh : name)}</span>
305
+ ${badge}
306
+ </div>
307
+ <div class="brand-skill-desc">${escapeHtml(description)}</div>
308
+ </div>
309
+ <div class="brand-skill-actions">${statusHtml}</div>
310
+ </div>`;
311
+
312
+ const installBtn = card.querySelector(".btn-brand-install");
313
+ const updateBtn = card.querySelector(".btn-brand-update");
314
+ const useBtn = card.querySelector(".btn-brand-use");
315
+ const deleteBtn = card.querySelector(".btn-brand-delete");
316
+ if (installBtn) installBtn.addEventListener("click", () => _runBrandInstall(name, installBtn));
317
+ if (updateBtn) updateBtn.addEventListener("click", () => _runBrandInstall(name, updateBtn));
318
+ if (useBtn) useBtn.addEventListener("click", () => Skills.useInstalledSkill(name));
319
+ if (deleteBtn) deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); Skills.deleteBrandSkill(name); });
320
+
321
+ return card;
322
+ }
323
+
324
+ async function _runBrandInstall(name, btn) {
325
+ const originalText = btn.textContent;
326
+ btn.disabled = true;
327
+ btn.textContent = I18n.t("skills.brand.btn.installing");
328
+ const result = await Skills.installBrandSkill(name);
329
+ if (!result || !result.ok) {
330
+ _showBrandInstallError(btn, (result && result.message) || I18n.t("skills.brand.unknownError"));
331
+ btn.disabled = false;
332
+ btn.textContent = originalText;
333
+ }
334
+ // On success the store emits brandSkills:changed → the tab re-renders,
335
+ // replacing this button.
336
+ }
337
+
338
+ function _showBrandInstallError(btn, message) {
339
+ const existing = btn.parentElement.querySelector(".brand-install-error");
340
+ if (existing) existing.remove();
341
+ const tip = document.createElement("div");
342
+ tip.className = "brand-install-error";
343
+ tip.textContent = message;
344
+ btn.parentElement.appendChild(tip);
345
+ setTimeout(() => tip.remove(), 5000);
346
+ }
347
+
348
+ // ── Tab switching (pure DOM) ─────────────────────────────────────────────
349
+
350
+ function _applyTab(tab) {
351
+ document.querySelectorAll(".skills-tab").forEach(btn => {
352
+ btn.classList.toggle("active", btn.dataset.tab === tab);
353
+ });
354
+ const tabMy = $("skills-tab-my");
355
+ const tabBrand = $("skills-tab-brand");
356
+ if (tabMy) tabMy.style.display = tab === "my-skills" ? "" : "none";
357
+ if (tabBrand) tabBrand.style.display = tab === "brand-skills" ? "" : "none";
358
+
359
+ const showSystemLabel = $("label-show-system");
360
+ const refreshBtn = $("btn-refresh-brand-skills");
361
+ if (showSystemLabel) showSystemLabel.style.display = tab === "my-skills" ? "" : "none";
362
+ if (refreshBtn) refreshBtn.style.display = tab === "brand-skills" ? "" : "none";
363
+ }
364
+
365
+ // ── One-time DOM wiring ──────────────────────────────────────────────────
366
+
367
+ function _wireDom() {
368
+ if (_domWired) return;
369
+
370
+ document.querySelectorAll(".skills-tab").forEach(btn => {
371
+ btn.addEventListener("click", () => Skills.setActiveTab(btn.dataset.tab));
372
+ });
373
+
374
+ const refreshBtn = $("btn-refresh-brand-skills");
375
+ if (refreshBtn) {
376
+ refreshBtn.addEventListener("click", async () => {
377
+ const icon = refreshBtn.querySelector("svg");
378
+ if (icon) icon.classList.add("spinning");
379
+ refreshBtn.disabled = true;
380
+ await Skills.loadBrandSkills();
381
+ if (icon) icon.classList.remove("spinning");
382
+ refreshBtn.disabled = false;
383
+ });
384
+ }
385
+
386
+ const chkSystem = $("chk-show-system-skills");
387
+ if (chkSystem) {
388
+ chkSystem.checked = SkillsStore.state.showSystemSkills;
389
+ chkSystem.addEventListener("change", () => Skills.setShowSystemSkills(chkSystem.checked));
390
+ }
391
+
392
+ document.addEventListener("langchange", () => {
393
+ _renderMySkills();
394
+ _renderBrandSkills();
395
+ });
396
+
397
+ _domWired = true;
398
+ }
399
+
400
+ // ── Store subscriptions ──────────────────────────────────────────────────
401
+
402
+ function _subscribe() {
403
+ Skills.on("skills:changed", () => {
404
+ Skills.renderSection();
405
+ if (Router.current === "skills") {
406
+ try { _renderMySkills(); } catch (e) { console.error("[Skills] _renderMySkills failed", e); }
407
+ }
408
+ });
409
+
410
+ Skills.on("brandSkills:loading", _renderBrandLoading);
411
+ Skills.on("brandSkills:error", _renderBrandError);
412
+ Skills.on("brandSkills:changed", (p) => {
413
+ if (p) _applyBrandWarning(p.warning, p.warningCode);
414
+ _renderBrandSkills();
415
+ });
416
+
417
+ Skills.on("tab:changed", (p) => _applyTab(p.tab));
418
+
419
+ Skills.on("brandStatus:changed", (p) => {
420
+ const brandTab = $("tab-brand-skills");
421
+ if (brandTab) brandTab.style.display = p.branded ? "" : "none";
422
+ if (p.activatedChanged && Router.current === "skills") _renderMySkills();
423
+ });
424
+ }
425
+
426
+ // ── UI facade methods (called externally on the Skills global) ───────────
427
+
428
+ const viewApi = {
429
+ renderSection() {
430
+ const labelEl = $("skills-sidebar-label");
431
+ if (!labelEl) return;
432
+ labelEl.textContent = I18n.t("sidebar.skills");
433
+ },
434
+
435
+ onPanelShow() {
436
+ _wireDom();
437
+ _renderMySkills();
438
+ Skills.renderSection();
439
+ _applyTab(SkillsStore.state.activeTab);
440
+ if (SkillsStore.state.activeTab === "brand-skills") Skills.loadBrandSkills();
441
+ Skills.refreshBrandStatus();
442
+ },
443
+
444
+ openBrandSkillsTab() {
445
+ Skills.onPanelShow();
446
+ Skills.setActiveTab("brand-skills");
447
+ },
448
+
449
+ toggleImportBar() {
450
+ Skills.setActiveTab("my-skills");
451
+
452
+ const bar = $("skill-import-bar");
453
+ const input = $("skill-import-input");
454
+ const confirmBtn = $("btn-skill-import-confirm");
455
+ const cancelBtn = $("btn-skill-import-cancel");
456
+ if (!bar) return;
457
+
458
+ const isOpen = bar.style.display !== "none";
459
+ if (isOpen) {
460
+ bar.style.display = "none";
461
+ if (input) input.value = "";
462
+ return;
463
+ }
464
+
465
+ bar.style.display = "";
466
+ if (input) {
467
+ input.focus();
468
+ input.placeholder = I18n.t("skills.import.placeholder");
469
+ }
470
+
471
+ if (!bar.dataset.wired) {
472
+ bar.dataset.wired = "1";
473
+ confirmBtn.addEventListener("click", () => _doImportFromBar());
474
+ input.addEventListener("keydown", (e) => {
475
+ if (e.key === "Enter") { e.preventDefault(); _doImportFromBar(); }
476
+ });
477
+ cancelBtn.addEventListener("click", () => {
478
+ bar.style.display = "none";
479
+ input.value = "";
480
+ });
481
+
482
+ const browseBtn = $("btn-skill-import-browse");
483
+ const fileInput = $("skill-import-file");
484
+ if (browseBtn && fileInput) {
485
+ browseBtn.addEventListener("click", () => fileInput.click());
486
+ fileInput.addEventListener("change", async () => {
487
+ const file = fileInput.files[0];
488
+ if (!file) return;
489
+ input.value = file.name;
490
+ input.placeholder = "";
491
+ browseBtn.disabled = true;
492
+ browseBtn.style.opacity = "0.5";
493
+ try {
494
+ const form = new FormData();
495
+ form.append("file", file);
496
+ const res = await fetch("/api/upload", { method: "POST", body: form });
497
+ const data = await res.json();
498
+ if (res.ok && data.path) input.value = data.path;
499
+ else { input.value = ""; alert(data.error || "Upload failed"); }
500
+ } catch (e) {
501
+ input.value = "";
502
+ console.error("[Skills] upload error", e);
503
+ } finally {
504
+ browseBtn.disabled = false;
505
+ browseBtn.style.opacity = "";
506
+ fileInput.value = "";
507
+ }
508
+ });
509
+ }
510
+ }
511
+ },
512
+
513
+ resetAfterUnbind() {
514
+ SkillsStore.resetAfterUnbind();
515
+ const panel = $("skills-panel");
516
+ if (panel && panel.style.display !== "none") _applyTab("my-skills");
517
+ },
518
+ };
519
+
520
+ async function _doImportFromBar() {
521
+ const input = $("skill-import-input");
522
+ const bar = $("skill-import-bar");
523
+ const url = (input ? input.value : "").trim();
524
+
525
+ const result = await Skills.importSkill(url);
526
+ if (result.ok) {
527
+ if (bar) bar.style.display = "none";
528
+ if (input) input.value = "";
529
+ return;
530
+ }
531
+ if (result.reason === "empty") {
532
+ input && input.focus();
533
+ return;
534
+ }
535
+ if (result.reason === "invalid") {
536
+ input.classList.add("skill-import-input-error");
537
+ setTimeout(() => input.classList.remove("skill-import-input-error"), 1200);
538
+ input.focus();
539
+ return;
540
+ }
541
+ alert(I18n.lang() === "zh" ? "导入技能时网络错误。" : "Network error while importing skill.");
542
+ }
543
+
544
+ return { init: _subscribe, api: viewApi, _doImportFromBar };
545
+ })();
546
+
547
+ // Augment the Skills facade with view-owned UI methods, then wire subscriptions.
548
+ Object.assign(Skills, SkillsView.api);
549
+ Skills._doImportFromBar = SkillsView._doImportFromBar;
550
+ SkillsView.init();
@@ -0,0 +1,135 @@
1
+ // ── Tasks · store — schedule data, CRUD, business actions ──────────────────
2
+ //
3
+ // Single source of truth for cron tasks. Owns state, talks to the server, runs
4
+ // CRUD + "open a session and run a command" actions. Never touches render DOM —
5
+ // it emits events and lets the view react.
6
+ //
7
+ // Internal bus (Store.on / _emit) is always live; Clacky.ext.emit mirrors to
8
+ // the extension bus (silenced under ?pure=true).
9
+ //
10
+ // `Tasks` stays the single public facade so existing callers keep working.
11
+ //
12
+ // Depends on: WS, Sessions, Skills, Router, I18n, Clacky.ext.
13
+ // ───────────────────────────────────────────────────────────────────────────
14
+
15
+ const TasksStore = (() => {
16
+ let _tasks = []; // [{ name, content, cron, enabled, scheduled }]
17
+
18
+ const _listeners = {};
19
+
20
+ function _on(event, handler) {
21
+ (_listeners[event] ||= []).push(handler);
22
+ return () => {
23
+ const list = _listeners[event];
24
+ const i = list ? list.indexOf(handler) : -1;
25
+ if (i >= 0) list.splice(i, 1);
26
+ };
27
+ }
28
+
29
+ function _emit(event, payload) {
30
+ (_listeners[event] || []).forEach((h) => h(payload));
31
+ if (window.Clacky && Clacky.ext) Clacky.ext.emit(event, payload);
32
+ }
33
+
34
+ const state = {
35
+ get tasks() { return _tasks; },
36
+ };
37
+
38
+ // Create a session and queue a command. Shared by run/create/edit actions.
39
+ async function _openSessionWith(message, onSession) {
40
+ const maxN = Sessions.all.reduce((max, s) => {
41
+ const m = s.name.match(/^Session (\d+)$/);
42
+ return m ? Math.max(max, parseInt(m[1], 10)) : max;
43
+ }, 0);
44
+ const res = await fetch("/api/sessions", {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/json" },
47
+ body: JSON.stringify({ name: "Session " + (maxN + 1), source: "manual" })
48
+ });
49
+ const data = await res.json();
50
+ if (!res.ok) { alert(I18n.t("tasks.sessionError") + (data.error || "unknown")); return null; }
51
+
52
+ const session = data.session;
53
+ if (!session) return null;
54
+
55
+ if (!WS.ready) { WS.connect(); Skills.load(); }
56
+
57
+ Sessions.add(session);
58
+ Sessions.renderList();
59
+ if (onSession) onSession(session);
60
+ else Sessions.setPendingMessage(session.id, message);
61
+ Sessions.select(session.id);
62
+ return session;
63
+ }
64
+
65
+ const Tasks = {
66
+ on: _on,
67
+ state,
68
+
69
+ /** Fetch cron tasks; emit so the view re-renders. */
70
+ async load() {
71
+ try {
72
+ const res = await fetch("/api/cron-tasks");
73
+ const data = await res.json();
74
+ _tasks = data.cron_tasks || [];
75
+ _emit("tasks:changed", { tasks: _tasks });
76
+ } catch (e) {
77
+ console.error("[Tasks] load failed", e);
78
+ }
79
+ },
80
+
81
+ /** Run a task now; on success reload and hand the session to Sessions. */
82
+ async run(name) {
83
+ const res = await fetch(`/api/cron-tasks/${encodeURIComponent(name)}/run`, { method: "POST" });
84
+ const data = await res.json();
85
+ if (!res.ok) { alert(I18n.t("tasks.runError") + (data.error || "unknown")); return; }
86
+
87
+ if (data.session) {
88
+ await Tasks.load();
89
+ Sessions.add(data.session);
90
+ Sessions.renderList();
91
+ Sessions.setPendingRunTask(data.session.id);
92
+ Sessions.select(data.session.id);
93
+ }
94
+ },
95
+
96
+ /** Toggle a scheduled task's enabled flag. `wasPaused` is the pre-click
97
+ * paused state; true means we resume (enabled: true). */
98
+ async toggleEnabled(name, wasPaused) {
99
+ const nextEnabled = wasPaused;
100
+ const res = await fetch(`/api/cron-tasks/${encodeURIComponent(name)}`, {
101
+ method: "PATCH",
102
+ headers: { "Content-Type": "application/json" },
103
+ body: JSON.stringify({ enabled: nextEnabled })
104
+ });
105
+ if (!res.ok) {
106
+ let msg = "";
107
+ try { msg = (await res.json()).error || ""; } catch (_) {}
108
+ alert(I18n.t("tasks.toggleError") + (msg ? " " + msg : ""));
109
+ return;
110
+ }
111
+ await Tasks.load();
112
+ },
113
+
114
+ /** Create a new task via a session running /cron-task-creator. */
115
+ createInSession() {
116
+ return _openSessionWith("/cron-task-creator");
117
+ },
118
+
119
+ /** Edit a task via a session that auto-sends the edit command. */
120
+ editInSession(name) {
121
+ return _openSessionWith(`/cron-task-creator I'm editing ${name} task`);
122
+ },
123
+
124
+ async delete(name) {
125
+ if (!confirm(I18n.t("tasks.confirmDelete", { name }))) return;
126
+ const res = await fetch(`/api/cron-tasks/${encodeURIComponent(name)}`, { method: "DELETE" });
127
+ if (!res.ok) { alert(I18n.t("tasks.deleteError")); return; }
128
+ await Tasks.load();
129
+ },
130
+ };
131
+
132
+ return Tasks;
133
+ })();
134
+
135
+ const Tasks = TasksStore;