openclacky 1.3.2 → 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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +28 -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 +49 -5
  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/default_agents/_panels/git/panel.js +201 -0
  11. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  12. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  13. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  14. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  15. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  16. data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
  17. data/lib/clacky/media/openai_compat.rb +64 -1
  18. data/lib/clacky/media/output_dir.rb +43 -0
  19. data/lib/clacky/message_history.rb +9 -0
  20. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  21. data/lib/clacky/server/git_panel.rb +115 -0
  22. data/lib/clacky/server/http_server.rb +497 -12
  23. data/lib/clacky/server/server_master.rb +6 -4
  24. data/lib/clacky/version.rb +1 -1
  25. data/lib/clacky/web/app.css +473 -60
  26. data/lib/clacky/web/app.js +30 -7
  27. data/lib/clacky/web/components/code-editor.js +197 -0
  28. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  29. data/lib/clacky/web/core/aside.js +112 -0
  30. data/lib/clacky/web/core/ext.js +387 -0
  31. data/lib/clacky/web/features/backup/store.js +92 -0
  32. data/lib/clacky/web/features/backup/view.js +94 -0
  33. data/lib/clacky/web/features/billing/store.js +163 -0
  34. data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
  35. data/lib/clacky/web/features/brand/store.js +110 -0
  36. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  37. data/lib/clacky/web/features/channels/store.js +103 -0
  38. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  39. data/lib/clacky/web/features/creator/store.js +81 -0
  40. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  41. data/lib/clacky/web/features/mcp/store.js +158 -0
  42. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  43. data/lib/clacky/web/features/model-tester/store.js +77 -0
  44. data/lib/clacky/web/features/model-tester/view.js +7 -0
  45. data/lib/clacky/web/features/profile/store.js +170 -0
  46. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  47. data/lib/clacky/web/features/share/store.js +145 -0
  48. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  49. data/lib/clacky/web/features/skills/store.js +303 -0
  50. data/lib/clacky/web/features/skills/view.js +550 -0
  51. data/lib/clacky/web/features/tasks/store.js +135 -0
  52. data/lib/clacky/web/features/tasks/view.js +241 -0
  53. data/lib/clacky/web/features/trash/store.js +242 -0
  54. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  55. data/lib/clacky/web/features/version/store.js +165 -0
  56. data/lib/clacky/web/features/version/view.js +323 -0
  57. data/lib/clacky/web/features/workspace/store.js +99 -0
  58. data/lib/clacky/web/features/workspace/view.js +305 -0
  59. data/lib/clacky/web/i18n.js +56 -6
  60. data/lib/clacky/web/index.html +117 -58
  61. data/lib/clacky/web/sessions.js +221 -25
  62. data/lib/clacky/web/settings.js +118 -22
  63. data/lib/clacky/web/skills.js +3 -863
  64. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  65. data/lib/clacky.rb +1 -0
  66. metadata +45 -20
  67. data/lib/clacky/web/backup.js +0 -119
  68. data/lib/clacky/web/model-tester.js +0 -66
  69. data/lib/clacky/web/tasks.js +0 -373
  70. data/lib/clacky/web/version.js +0 -449
  71. data/lib/clacky/web/workspace.js +0 -316
  72. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  73. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  74. /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
  75. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  76. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -1,866 +1,6 @@
1
- // ── Skillsskills state, rendering, enable/disable ──────────────────────
2
- //
3
- // Responsibilities:
4
- // - Single source of truth for skills data
5
- // - Render the "Skills" entry in the sidebar
6
- // - Show/render the skills panel with My Skills / Brand Skills tabs
7
- // - Toggle enable/disable via PATCH /api/skills/:name/toggle
8
- // - Create new skill by opening a session with /skill-creator
9
- //
10
- // Panel switching is delegated to Router — Skills only manages data + rendering.
11
- //
12
- // Depends on: WS (ws.js), Sessions (sessions.js), Router (app.js),
13
- // global $ / escapeHtml helpers
14
- // ─────────────────────────────────────────────────────────────────────────
15
-
16
- const Skills = (() => {
17
- // ── Private state ──────────────────────────────────────────────────────
18
- let _skills = []; // [{ name, description, source, enabled }]
19
- let _brandSkills = []; // skills from cloud license API
20
- let _activeTab = "my-skills"; // "my-skills" | "brand-skills"
21
- let _brandActivated = false; // whether a license is currently active
22
- let _freeMode = false; // brand-skills tab is showing free-mode skills
23
- let _paidSkillsCount = 0; // count of premium (encrypted) skills locked behind activation
24
- let _domWired = false; // whether one-time DOM listeners have been bound
25
- let _showSystemSkills = false; // whether system (source=default) skills are shown
26
-
27
- // ── Private helpers ────────────────────────────────────────────────────
28
-
29
- /** Switch tabs inside the skills panel. */
30
- function _switchTab(tab) {
31
- _activeTab = tab;
32
- document.querySelectorAll(".skills-tab").forEach(btn => {
33
- btn.classList.toggle("active", btn.dataset.tab === tab);
34
- });
35
- $("skills-tab-my").style.display = tab === "my-skills" ? "" : "none";
36
- $("skills-tab-brand").style.display = tab === "brand-skills" ? "" : "none";
37
-
38
- // Toggle visibility of tab-specific controls in the tab bar
39
- const showSystemLabel = $("label-show-system");
40
- const refreshBtn = $("btn-refresh-brand-skills");
41
- if (showSystemLabel) showSystemLabel.style.display = tab === "my-skills" ? "" : "none";
42
- if (refreshBtn) refreshBtn.style.display = tab === "brand-skills" ? "" : "none";
43
-
44
- // Refresh brand skills every time the tab is opened
45
- if (tab === "brand-skills") {
46
- _loadBrandSkills();
47
- }
48
- }
49
-
50
- /** Fetch brand skills from the server and re-render the tab. */
51
- async function _loadBrandSkills() {
52
- const container = $("brand-skills-list");
53
- if (!container) return;
54
- container.innerHTML = Array.from({ length: 4 }).map(() => `
55
- <div class="brand-skill-card">
56
- <div class="brand-skill-card-main">
57
- <div class="brand-skill-info">
58
- <div class="brand-skill-title">
59
- <span class="skel skel-title"></span>
60
- <span class="skel" style="height:1rem;width:3.5rem;border-radius:4px;"></span>
61
- </div>
62
- <span class="skel skel-subtitle"></span>
63
- </div>
64
- <div class="brand-skill-actions">
65
- <span class="skel" style="height:1.75rem;width:4.5rem;border-radius:6px;"></span>
66
- </div>
67
- </div>
68
- </div>`).join("");
69
-
70
- try {
71
- const res = await fetch("/api/brand/skills");
72
- const data = await res.json();
73
-
74
- if (!res.ok || !data.ok) {
75
- container.innerHTML = '<div class="brand-skills-error">' + escapeHtml(data.error || I18n.t("skills.brand.loadFailed")) + "</div>";
76
- return;
77
- }
78
-
79
- _brandSkills = data.skills || [];
80
- _freeMode = !!data.free_mode;
81
- _paidSkillsCount = Number(data.paid_skills_count) || 0;
82
-
83
- // Soft warning: remote API unavailable but local skills returned.
84
- // Prefer the server-provided warning_code for proper i18n; fall back to
85
- // the raw `warning` string (legacy / non-codified messages).
86
- const warningBanner = $("brand-skills-warning");
87
- const warningText = data.warning_code
88
- ? I18n.t("skills.brand.warning." + data.warning_code)
89
- : data.warning;
90
- if (warningText) {
91
- if (warningBanner) {
92
- warningBanner.textContent = warningText;
93
- // Let i18n re-render pick this up on language switch.
94
- if (data.warning_code) {
95
- warningBanner.setAttribute("data-i18n", "skills.brand.warning." + data.warning_code);
96
- } else {
97
- warningBanner.removeAttribute("data-i18n");
98
- }
99
- warningBanner.style.display = "";
100
- }
101
- } else {
102
- if (warningBanner) {
103
- warningBanner.style.display = "none";
104
- warningBanner.removeAttribute("data-i18n");
105
- }
106
- }
107
-
108
- _renderBrandSkills();
109
- } catch (e) {
110
- container.innerHTML = '<div class="brand-skills-error">Network error \u2014 please try again.</div>';
111
- console.error("[Skills] brand skills load failed", e);
112
- }
113
- }
114
-
115
- /** Render all brand skills into the brand-skills tab. */
116
- function _renderBrandSkills() {
117
- const container = $("brand-skills-list");
118
- if (!container) return;
119
- container.innerHTML = "";
120
-
121
- if (_brandSkills.length === 0 && !(_freeMode && _paidSkillsCount > 0)) {
122
- container.innerHTML = `<div class="brand-skills-empty">${I18n.t("skills.brand.empty")}</div>`;
123
- return;
124
- }
125
-
126
- _brandSkills.forEach(skill => {
127
- const card = _renderBrandSkillCard(skill);
128
- container.appendChild(card);
129
- });
130
-
131
- // Free mode + premium skills exist → show a footer hint inviting the user to activate.
132
- if (_freeMode && _paidSkillsCount > 0) {
133
- const hint = document.createElement("div");
134
- hint.className = "brand-skills-paid-hint";
135
-
136
- const msgEl = document.createElement("div");
137
- msgEl.className = "brand-skills-paid-hint-msg";
138
- msgEl.textContent = I18n.t("skills.brand.paidHint", { n: _paidSkillsCount });
139
- msgEl.setAttribute("data-i18n", "skills.brand.paidHint");
140
- msgEl.setAttribute("data-i18n-vars", `n=${_paidSkillsCount}`);
141
-
142
- const btn = document.createElement("button");
143
- btn.className = "brand-skills-activate-btn";
144
- btn.textContent = I18n.t("skills.brand.activateBtn");
145
- btn.setAttribute("data-i18n", "skills.brand.activateBtn");
146
- btn.addEventListener("click", () => {
147
- if (typeof Brand !== "undefined" && Brand.goToLicenseInput) {
148
- Brand.goToLicenseInput();
149
- } else {
150
- Router.navigate("settings");
151
- }
152
- });
153
-
154
- hint.appendChild(msgEl);
155
- hint.appendChild(btn);
156
- container.appendChild(hint);
157
- }
158
- }
159
-
160
- /** Render a single brand skill card. */
161
- function _renderBrandSkillCard(skill) {
162
- const name = skill.name;
163
- const installedVersion = skill.installed_version;
164
- const latestVersion = (skill.latest_version || {}).version || skill.version;
165
- const needsUpdate = skill.needs_update;
166
-
167
- // Determine action badge
168
- let statusHtml = "";
169
- if (!installedVersion) {
170
- const versionBadge = latestVersion
171
- ? `<span class="brand-skill-version latest">v${escapeHtml(latestVersion)}</span>` : "";
172
- statusHtml = `${versionBadge}<button class="btn-brand-install" data-name="${escapeHtml(name)}">${I18n.t("skills.brand.btn.install")}</button>`;
173
- } else if (needsUpdate) {
174
- statusHtml = `
175
- <span class="brand-skill-version installed">v${escapeHtml(installedVersion)}</span>
176
- <span class="brand-skill-update-arrow">→</span>
177
- <span class="brand-skill-version latest">v${escapeHtml(latestVersion)}</span>
178
- <button class="btn-brand-update" data-name="${escapeHtml(name)}">${I18n.t("skills.brand.btn.update")}</button>`;
179
- } else {
180
- // Installed and up-to-date — show version badge + "Use" + delete buttons
181
- const displayVersion = installedVersion || latestVersion;
182
- statusHtml = `
183
- <span class="brand-skill-version installed">v${escapeHtml(displayVersion)} ✓</span>
184
- <button class="btn-brand-use" data-name="${escapeHtml(name)}">${I18n.t("skills.brand.btn.use")}</button>
185
- <button class="btn-skill-delete btn-brand-delete" data-name="${escapeHtml(name)}" title="${I18n.t("skills.btn.delete")}">
186
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
187
- <polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
188
- </svg>
189
- </button>`;
190
- }
191
-
192
- // Free skills show a "Free" badge; paid (encrypted) brand skills show "Private".
193
- const badge = skill.is_free
194
- ? `<span class="brand-skill-badge-free" title="${I18n.t("skills.brand.freeTip")}">✨ ${I18n.t("skills.brand.free")}</span>`
195
- : `<span class="brand-skill-badge-private" title="${I18n.t("skills.brand.privateTip")}">🔒 ${I18n.t("skills.brand.private")}</span>`;
196
-
197
- // Choose description based on current language
198
- const currentLang = I18n.lang();
199
- const description = (currentLang === "zh" && skill.description_zh)
200
- ? skill.description_zh
201
- : skill.description || "";
202
-
203
- const card = document.createElement("div");
204
- card.className = "brand-skill-card";
205
- card.innerHTML = `
206
- <div class="brand-skill-card-main">
207
- <div class="brand-skill-info">
208
- <div class="brand-skill-title">
209
- <span class="brand-skill-name">${escapeHtml((currentLang === "zh" && skill.name_zh) ? skill.name_zh : name)}</span>
210
- ${badge}
211
- </div>
212
- <div class="brand-skill-desc">${escapeHtml(description)}</div>
213
- </div>
214
- <div class="brand-skill-actions">${statusHtml}</div>
215
- </div>`;
216
-
217
- // Bind install/update/use buttons
218
- const installBtn = card.querySelector(".btn-brand-install");
219
- const updateBtn = card.querySelector(".btn-brand-update");
220
- const useBtn = card.querySelector(".btn-brand-use");
221
- const deleteBtn = card.querySelector(".btn-brand-delete");
222
- if (installBtn) installBtn.addEventListener("click", () => _installBrandSkill(name, installBtn));
223
- if (updateBtn) updateBtn.addEventListener("click", () => _installBrandSkill(name, updateBtn));
224
- if (useBtn) useBtn.addEventListener("click", () => _useInstalledSkill(name));
225
- if (deleteBtn) deleteBtn.addEventListener("click", (e) => {
226
- e.stopPropagation();
227
- _deleteBrandSkill(name);
228
- });
229
-
230
- return card;
231
- }
232
-
233
- /** Show a temporary inline error message below `btn`, auto-dismiss after 5 s. */
234
- function _showBrandInstallError(btn, message) {
235
- // Remove any existing error tip on this button's parent
236
- const existing = btn.parentElement.querySelector(".brand-install-error");
237
- if (existing) existing.remove();
238
-
239
- const tip = document.createElement("div");
240
- tip.className = "brand-install-error";
241
- tip.textContent = message;
242
- btn.parentElement.appendChild(tip);
243
- setTimeout(() => tip.remove(), 5000);
244
- }
245
-
246
- /** Return a user-friendly message for install/update errors. */
247
- function _friendlyInstallError(rawError) {
248
- if (!rawError) return I18n.t("skills.brand.unknownError");
249
- const lower = rawError.toLowerCase();
250
- if (lower.includes("timeout") || lower.includes("network error") ||
251
- lower.includes("execution expired") || lower.includes("failed to open")) {
252
- return I18n.t("skills.brand.networkRetry");
253
- }
254
- return I18n.t("skills.brand.installFailed") + rawError;
255
- }
256
-
257
- /** Install or update a brand skill. */
258
- async function _installBrandSkill(name, btn) {
259
- const originalText = btn.textContent;
260
- btn.disabled = true;
261
- btn.textContent = I18n.t("skills.brand.btn.installing");
262
-
263
- try {
264
- const res = await fetch(`/api/brand/skills/${encodeURIComponent(name)}/install`, { method: "POST" });
265
- const data = await res.json();
266
-
267
- if (!res.ok || !data.ok) {
268
- _showBrandInstallError(btn, _friendlyInstallError(data.error));
269
- btn.disabled = false;
270
- btn.textContent = originalText;
271
- return;
272
- }
273
-
274
- // Update local state to reflect installed version
275
- const skill = _brandSkills.find(s => s.name === name);
276
- if (skill) {
277
- skill.installed_version = data.version;
278
- skill.needs_update = false;
279
- }
280
-
281
- // Re-render brand skills tab
282
- _renderBrandSkills();
283
-
284
- // Also reload My Skills — the new skill may appear there now
285
- await Skills.load();
286
- } catch (e) {
287
- _showBrandInstallError(btn, I18n.t("skills.brand.networkRetry"));
288
- btn.disabled = false;
289
- btn.textContent = originalText;
290
- }
291
- }
292
-
293
- async function _deleteBrandSkill(name) {
294
- if (!confirm(I18n.t("skills.deleteConfirm", { name }))) return;
295
- try {
296
- const res = await fetch(`/api/brand/skills/${encodeURIComponent(name)}`, { method: "DELETE" });
297
- const data = await res.json();
298
- if (!res.ok || !data.ok) { alert(data.error || I18n.t("skills.deleteError")); return; }
299
-
300
- const skill = _brandSkills.find(s => s.name === name);
301
- if (skill) skill.installed_version = null;
302
-
303
- _renderBrandSkills();
304
- await Skills.load();
305
- Modal.toast(I18n.t("skills.deleted", { name }), "success");
306
- } catch (e) {
307
- console.error("[Skills] brand skill delete failed", e);
308
- }
309
- }
310
-
311
- /** Open a new session and trigger a brand skill by sending "/{name}" as the first message. */
312
- async function _useInstalledSkill(name) {
313
- const maxN = Sessions.all.reduce((max, s) => {
314
- const m = s.name.match(/^Session (\d+)$/);
315
- return m ? Math.max(max, parseInt(m[1], 10)) : max;
316
- }, 0);
317
- const res = await fetch("/api/sessions", {
318
- method: "POST",
319
- headers: { "Content-Type": "application/json" },
320
- body: JSON.stringify({ name: "Session " + (maxN + 1), source: "manual" })
321
- });
322
- const data = await res.json();
323
- if (!res.ok) { alert(I18n.t("tasks.sessionError") + (data.error || "unknown")); return; }
324
-
325
- const session = data.session;
326
- if (!session) return;
327
-
328
- if (!WS.ready) {
329
- WS.connect();
330
- Skills.load();
331
- }
332
-
333
- Sessions.add(session);
334
- Sessions.renderList();
335
- Sessions.setPendingMessage(session.id, "/" + name);
336
- Sessions.select(session.id);
337
- }
338
-
339
- /** Render a single skill card in My Skills tab. */
340
- function _renderSkillCard(skill) {
341
- const card = document.createElement("div");
342
- // invalid = unrecoverable (can't be used at all); warning = auto-corrected but fully usable
343
- card.className = "skill-card" + (skill.invalid ? " skill-card-invalid" : "");
344
-
345
- // "default" = built-in gem skills; "brand" = encrypted brand/system skills
346
- const isSystem = skill.source === "default" || skill.source === "brand";
347
- const badgeClass = isSystem ? "skill-badge skill-badge-system" : "skill-badge skill-badge-custom";
348
- const badgeLabel = isSystem ? I18n.t("skills.badge.system") : I18n.t("skills.badge.custom");
349
-
350
- // Build warning icon for skills with auto-corrected issues (still fully usable)
351
- // Build error notice for truly invalid skills (can't be used)
352
- let warnIconHtml = "";
353
- let errorNoticeHtml = "";
354
- if (skill.invalid) {
355
- const reason = skill.invalid_reason || I18n.t("skills.invalid.reason");
356
- errorNoticeHtml = `<div class="skill-notice skill-notice-error">⚠ ${escapeHtml(reason)}</div>`;
357
- } else if (skill.warnings && skill.warnings.length > 0) {
358
- const reason = skill.warnings.join("\n");
359
- const tooltip = I18n.t("skills.warning.tooltip", { reason });
360
- warnIconHtml = `<span class="skill-warn-icon" data-tooltip="${escapeHtml(tooltip)}">⚠</span>`;
361
- }
362
-
363
- // toggle is only disabled for system skills or truly invalid ones; warning skills are fine
364
- const toggleDisabled = isSystem || skill.invalid;
365
- const toggleTitle = isSystem ? I18n.t("skills.systemDisabledTip")
366
- : skill.invalid ? I18n.t("skills.invalid.toggleTip")
367
- : skill.enabled ? I18n.t("skills.toggle.disableDesc")
368
- : I18n.t("skills.toggle.enableDesc");
369
-
370
- // Choose description based on current language
371
- const currentLang = I18n.lang();
372
- const description = (currentLang === "zh" && skill.description_zh)
373
- ? skill.description_zh
374
- : skill.description || "";
375
-
376
- // Show "Use" button for all skills except invalid ones
377
- const useButtonHtml = skill.invalid
378
- ? ""
379
- : `<button class="btn-skill-use" data-name="${escapeHtml(skill.name)}">${I18n.t("skills.btn.use")}</button>`;
380
-
381
- // Delete button only for custom (non-system, non-brand) skills
382
- const deleteButtonHtml = isSystem
383
- ? ""
384
- : `<button class="btn-skill-delete" data-name="${escapeHtml(skill.name)}" title="${I18n.t("skills.btn.delete")}">
385
- <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
386
- <polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
387
- </svg>
388
- </button>`;
389
-
390
- card.innerHTML = `
391
- <div class="skill-card-main">
392
- <div class="skill-card-info">
393
- <div class="skill-card-title">
394
- ${warnIconHtml}
395
- <span class="skill-name">${escapeHtml((currentLang === "zh" && skill.name_zh) ? skill.name_zh : skill.name)}</span>
396
- <span class="${badgeClass}">${badgeLabel}</span>
397
- ${skill.invalid ? `<span class="skill-badge skill-badge-invalid">${I18n.t("skills.badge.invalid")}</span>` : ""}
398
- </div>
399
- <div class="skill-card-desc">${escapeHtml(description)}</div>
400
- </div>
401
- <div class="skill-card-actions">
402
- <label class="skill-toggle ${toggleDisabled ? "skill-toggle-disabled" : ""}" data-tooltip="${escapeHtml(toggleTitle)}">
403
- <input type="checkbox" class="skill-toggle-input" ${skill.enabled ? "checked" : ""} ${toggleDisabled ? "disabled" : ""}>
404
- <span class="skill-toggle-track"></span>
405
- </label>
406
- ${useButtonHtml}
407
- ${deleteButtonHtml}
408
- </div>
409
- </div>
410
- ${errorNoticeHtml}`;
411
-
412
- // Bind toggle event
413
- if (!isSystem) {
414
- const checkbox = card.querySelector(".skill-toggle-input");
415
- checkbox.addEventListener("change", async () => {
416
- await Skills.toggle(skill.name, checkbox.checked);
417
- });
418
- }
419
-
420
- // Flip tooltip below when toggle is near top of scroll container
421
- const toggleLabel = card.querySelector(".skill-toggle");
422
- if (toggleLabel) {
423
- toggleLabel.addEventListener("mouseenter", () => {
424
- const scroller = toggleLabel.closest(".skills-tab-content");
425
- if (!scroller) return;
426
- const toggleTop = toggleLabel.getBoundingClientRect().top;
427
- const scrollerTop = scroller.getBoundingClientRect().top;
428
- if (toggleTop - scrollerTop < 80) {
429
- toggleLabel.setAttribute("data-tooltip-pos", "bottom");
430
- }
431
- });
432
- }
433
-
434
- // Bind "Use" button event
435
- const useBtn = card.querySelector(".btn-skill-use");
436
- if (useBtn) {
437
- useBtn.addEventListener("click", () => _useInstalledSkill(skill.name));
438
- }
439
-
440
- // Bind delete button event
441
- const deleteBtn = card.querySelector(".btn-skill-delete");
442
- if (deleteBtn) {
443
- deleteBtn.addEventListener("click", (e) => {
444
- e.stopPropagation();
445
- Skills.delete(skill.name);
446
- });
447
- }
448
-
449
- return card;
450
- }
451
-
452
- /** Render My Skills tab content. */
453
- function _renderMySkills() {
454
- const container = $("skills-list");
455
- console.log("[Skills] _renderMySkills, container=", container, "_skills.length=", _skills.length);
456
- if (!container) { console.error("[Skills] skills-list not found!"); return; }
457
- container.innerHTML = "";
458
-
459
- // Optionally hide system (source=default) skills that aren't marked always_show
460
- const visible = _showSystemSkills
461
- ? _skills
462
- : _skills.filter(s => s.always_show || s.source !== "default");
463
-
464
- if (visible.length === 0) {
465
- const emptyText = I18n.t("skills.empty");
466
- const createBtnText = I18n.t("skills.empty.createBtn");
467
-
468
- const emptyWrapper = document.createElement("div");
469
- emptyWrapper.className = "skills-empty";
470
-
471
- const emptyTextEl = document.createElement("div");
472
- emptyTextEl.className = "skills-empty-text";
473
- emptyTextEl.textContent = emptyText;
474
-
475
- const createBtn = document.createElement("div");
476
- createBtn.className = "skills-empty-create-btn";
477
- createBtn.innerHTML = `
478
- <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">
479
- <path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2z"/><path d="M12 8v8"/><path d="M8 12h8"/>
480
- </svg>
481
- <span>${escapeHtml(createBtnText)}</span>
482
- <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">
483
- <path d="M5 12h14"/><path d="M12 5l7 7-7 7"/>
484
- </svg>`;
485
- createBtn.addEventListener("click", () => Skills.createInSession("/skill-creator"));
486
-
487
- emptyWrapper.appendChild(emptyTextEl);
488
- emptyWrapper.appendChild(createBtn);
489
- container.appendChild(emptyWrapper);
490
- } else {
491
- // System skills first, then custom
492
- const sorted = [
493
- ...visible.filter(s => s.source === "default"),
494
- ...visible.filter(s => s.source !== "default")
495
- ];
496
- sorted.forEach((skill, i) => {
497
- try {
498
- container.appendChild(_renderSkillCard(skill));
499
- } catch (e) {
500
- console.error("[Skills] _renderSkillCard failed for skill", i, skill.name, e);
501
- }
502
- });
503
- }
504
- }
505
-
506
- // ── Public API ─────────────────────────────────────────────────────────
507
- return {
508
-
509
- // ── Data ─────────────────────────────────────────────────────────────
510
-
511
- /** Return current skills list (read-only snapshot). */
512
- get all() { return _skills.slice(); },
513
-
514
- /** Fetch skills from server; re-render sidebar + panel if open. */
515
- async load() {
516
- try {
517
- const res = await fetch("/api/skills");
518
- const data = await res.json();
519
- _skills = data.skills || [];
520
- Skills.renderSection();
521
- if (Router.current === "skills") {
522
- try {
523
- _renderMySkills();
524
- } catch (renderErr) {
525
- console.error("[Skills] _renderMySkills failed", renderErr);
526
- }
527
- }
528
- } catch (e) {
529
- console.error("[Skills] load failed", e);
530
- }
531
- },
532
-
533
- // ── Router interface ──────────────────────────────────────────────────
534
-
535
- /** Called by Router when the skills panel becomes active. */
536
- onPanelShow() {
537
- // ── One-time DOM wiring ──────────────────────────────────────────────
538
- // Bind tab clicks here (not in the IIFE) because $ and the DOM elements
539
- // are only guaranteed to exist after app.js has loaded and the panel
540
- // has been shown at least once. Guard with _domWired so we only do this
541
- // once no matter how many times the user navigates to the Skills panel.
542
- if (!_domWired) {
543
- document.querySelectorAll(".skills-tab").forEach(btn => {
544
- btn.addEventListener("click", () => _switchTab(btn.dataset.tab));
545
- });
546
-
547
- const refreshBtn = $("btn-refresh-brand-skills");
548
- if (refreshBtn) {
549
- refreshBtn.addEventListener("click", async () => {
550
- // Add spinning animation
551
- const icon = refreshBtn.querySelector("svg");
552
- if (icon) {
553
- icon.classList.add("spinning");
554
- }
555
- refreshBtn.disabled = true;
556
-
557
- _brandSkills = [];
558
- await _loadBrandSkills();
559
-
560
- // Remove spinning animation
561
- if (icon) {
562
- icon.classList.remove("spinning");
563
- }
564
- refreshBtn.disabled = false;
565
- });
566
- }
567
-
568
- // Wire the "show system skills" checkbox
569
- const chkSystem = $("chk-show-system-skills");
570
- if (chkSystem) {
571
- chkSystem.checked = _showSystemSkills;
572
- chkSystem.addEventListener("change", () => {
573
- _showSystemSkills = chkSystem.checked;
574
- _renderMySkills();
575
- });
576
- }
577
-
578
- // Re-render skill cards when the user switches language
579
- document.addEventListener("langchange", () => {
580
- _renderMySkills();
581
- _renderBrandSkills();
582
- });
583
-
584
- _domWired = true;
585
- }
586
-
587
- _renderMySkills();
588
- Skills.renderSection();
589
-
590
- // Restore active tab state immediately
591
- _switchTab(_activeTab);
592
-
593
- // Async: check brand license status and update Brand Skills tab visibility.
594
- fetch("/api/brand/status")
595
- .then(res => res.json())
596
- .then(data => {
597
- const prevActivated = _brandActivated;
598
-
599
- _brandActivated = data.branded && !data.needs_activation;
600
-
601
- // Show the Brand Skills tab for any branded project, even without an active
602
- // license — the tab itself will show an activation prompt in that case.
603
- const brandTab = $("tab-brand-skills");
604
- if (brandTab) brandTab.style.display = data.branded ? "" : "none";
605
-
606
- // Re-render my-skills tab if brand activated status changed.
607
- if (prevActivated !== _brandActivated) {
608
- _renderMySkills();
609
- }
610
- })
611
- .catch(() => {
612
- // On network error, keep whatever is currently shown
613
- });
614
- },
615
-
616
- // ── Sidebar rendering ─────────────────────────────────────────────────
617
-
618
- renderSection() {
619
- // Sidebar item is static in HTML — just update the label text.
620
- const labelEl = $("skills-sidebar-label");
621
- if (!labelEl) return;
622
- labelEl.textContent = I18n.t("sidebar.skills");
623
- },
624
-
625
- // ── Actions ───────────────────────────────────────────────────────────
626
-
627
- /** Toggle enable/disable for a skill. */
628
- async toggle(name, enabled) {
629
- try {
630
- const res = await fetch(`/api/skills/${encodeURIComponent(name)}/toggle`, {
631
- method: "PATCH",
632
- headers: { "Content-Type": "application/json" },
633
- body: JSON.stringify({ enabled })
634
- });
635
- const data = await res.json();
636
- if (!res.ok) { alert(I18n.t("skills.toggleError") + (data.error || "unknown")); return; }
637
- await Skills.load();
638
- } catch (e) {
639
- console.error("[Skills] toggle failed", e);
640
- }
641
- },
642
-
643
- /** Delete a skill by name. Prompts confirmation, then DELETE to server. */
644
- async delete(name) {
645
- if (!confirm(I18n.t("skills.deleteConfirm", { name }))) return;
646
- try {
647
- const res = await fetch(`/api/skills/${encodeURIComponent(name)}`, { method: "DELETE" });
648
- const data = await res.json();
649
- if (!res.ok) { alert(data.error || I18n.t("skills.deleteError")); return; }
650
- Modal.toast(I18n.t("skills.deleted", { name }), "success");
651
- await Skills.load();
652
- } catch (e) {
653
- console.error("[Skills] delete failed", e);
654
- }
655
- },
656
-
657
- /** Switch the Skills panel to the brand-skills tab.
658
- * Called externally (e.g. from settings.js after license activation) to
659
- * guide the user directly to the Brand Skills download page.
660
- * Ensures DOM is wired and forces a fresh load of brand skills.
661
- */
662
- openBrandSkillsTab() {
663
- // Make sure the panel DOM listeners are wired before switching tabs
664
- Skills.onPanelShow();
665
- // Force reload brand skills (activation may have just happened)
666
- _brandSkills = [];
667
- _switchTab("brand-skills");
668
- },
669
-
670
- /** Reset the skills panel back to My Skills tab and clear brand data.
671
- * Called after license unbind so the user is not left on Brand Skills tab.
672
- */
673
- resetAfterUnbind() {
674
- _brandSkills = [];
675
- _brandActivated = false;
676
- _activeTab = "my-skills";
677
- // Hide the Brand Skills tab since there is no active license
678
- const brandTab = $("tab-brand-skills");
679
- if (brandTab) brandTab.style.display = "none";
680
- // If the panel is currently visible, switch to My Skills immediately
681
- const panel = $("skills-panel");
682
- if (panel && panel.style.display !== "none") {
683
- _switchTab("my-skills");
684
- }
685
- },
686
-
687
- // ── Import bar ────────────────────────────────────────────────────────
688
-
689
- /** Toggle the inline import bar below the My Skills header.
690
- * Switches to "my-skills" tab first so the bar is visible.
691
- * Wires confirm / cancel / Enter key handlers on first call.
692
- */
693
- toggleImportBar() {
694
- // Always switch to My Skills tab so the import bar appears in context
695
- _switchTab("my-skills");
696
-
697
- const bar = $("skill-import-bar");
698
- const input = $("skill-import-input");
699
- const confirmBtn = $("btn-skill-import-confirm");
700
- const cancelBtn = $("btn-skill-import-cancel");
701
- if (!bar) return;
702
-
703
- const isOpen = bar.style.display !== "none";
704
-
705
- if (isOpen) {
706
- // Close the bar
707
- bar.style.display = "none";
708
- if (input) input.value = "";
709
- return;
710
- }
711
-
712
- // Open the bar
713
- bar.style.display = "";
714
- if (input) {
715
- input.focus();
716
- input.placeholder = I18n.t("skills.import.placeholder");
717
- }
718
-
719
- // Wire one-time listeners (guard with dataset flag)
720
- if (!bar.dataset.wired) {
721
- bar.dataset.wired = "1";
722
-
723
- // Confirm button
724
- confirmBtn.addEventListener("click", () => Skills._doImportFromBar());
725
-
726
- // Enter key in input
727
- input.addEventListener("keydown", (e) => {
728
- if (e.key === "Enter") { e.preventDefault(); Skills._doImportFromBar(); }
729
- });
730
-
731
- // Cancel button
732
- cancelBtn.addEventListener("click", () => {
733
- bar.style.display = "none";
734
- input.value = "";
735
- });
736
-
737
- // Browse button — open system file picker, upload zip, fill path into input
738
- const browseBtn = $("btn-skill-import-browse");
739
- const fileInput = $("skill-import-file");
740
- if (browseBtn && fileInput) {
741
- browseBtn.addEventListener("click", () => fileInput.click());
742
- fileInput.addEventListener("change", async () => {
743
- const file = fileInput.files[0];
744
- if (!file) return;
745
-
746
- // Show filename immediately so the user sees feedback
747
- input.value = file.name;
748
- input.placeholder = "";
749
- browseBtn.disabled = true;
750
- browseBtn.style.opacity = "0.5";
751
-
752
- try {
753
- const form = new FormData();
754
- form.append("file", file);
755
- const res = await fetch("/api/upload", { method: "POST", body: form });
756
- const data = await res.json();
757
- if (res.ok && data.path) {
758
- // Fill the server-side temp path — /skill-add will read it directly
759
- input.value = data.path;
760
- } else {
761
- input.value = "";
762
- alert(data.error || "Upload failed");
763
- }
764
- } catch (e) {
765
- input.value = "";
766
- console.error("[Skills] upload error", e);
767
- } finally {
768
- browseBtn.disabled = false;
769
- browseBtn.style.opacity = "";
770
- // Reset file input so the same file can be picked again if needed
771
- fileInput.value = "";
772
- }
773
- });
774
- }
775
- }
776
- },
777
-
778
- /** Execute import: validate URL, open a session and send /skill-add <url>. */
779
- async _doImportFromBar() {
780
- const input = $("skill-import-input");
781
- const bar = $("skill-import-bar");
782
- const url = (input ? input.value : "").trim();
783
-
784
- if (!url) {
785
- input && input.focus();
786
- return;
787
- }
788
-
789
- // Validate: accept http(s) URLs or absolute local paths (from upload)
790
- const isUrl = /^https?:\/\//i.test(url);
791
- const isLocalPath = url.startsWith("/") || url.startsWith("~");
792
- if (!isUrl && !isLocalPath) {
793
- input.classList.add("skill-import-input-error");
794
- setTimeout(() => input.classList.remove("skill-import-input-error"), 1200);
795
- input.focus();
796
- return;
797
- }
798
-
799
- // Close the bar immediately — the session takes over from here
800
- if (bar) bar.style.display = "none";
801
- if (input) input.value = "";
802
-
803
- // Create a new session and queue the /skill-add command
804
- try {
805
- const maxN = Sessions.all.reduce((max, s) => {
806
- const m = s.name.match(/^Session (\d+)$/);
807
- return m ? Math.max(max, parseInt(m[1], 10)) : max;
808
- }, 0);
809
- const res = await fetch("/api/sessions", {
810
- method: "POST",
811
- headers: { "Content-Type": "application/json" },
812
- body: JSON.stringify({ name: "Session " + (maxN + 1), source: "manual" })
813
- });
814
- const data = await res.json();
815
- if (!res.ok) { alert(I18n.t("tasks.sessionError") + (data.error || "unknown")); return; }
816
-
817
- const session = data.session;
818
- if (!session) return;
819
-
820
- if (!WS.ready) { WS.connect(); Tasks.load(); }
821
-
822
- Sessions.add(session);
823
- Sessions.renderList();
824
- Sessions.setPendingMessage(session.id, `/skill-add ${url}`);
825
- Sessions.select(session.id);
826
- } catch (e) {
827
- console.error("[Skills] import failed", e);
828
- alert(I18n.lang() === "zh" ? "导入技能时网络错误。" : "Network error while importing skill.");
829
- }
830
- },
831
-
832
- /** Create a new custom skill by opening a session and sending /skill-creator. */
833
- async createInSession(message) {
834
- const maxN = Sessions.all.reduce((max, s) => {
835
- const m = s.name.match(/^Session (\d+)$/);
836
- return m ? Math.max(max, parseInt(m[1], 10)) : max;
837
- }, 0);
838
- const res = await fetch("/api/sessions", {
839
- method: "POST",
840
- headers: { "Content-Type": "application/json" },
841
- body: JSON.stringify({ name: "Session " + (maxN + 1), source: "manual" })
842
- });
843
- const data = await res.json();
844
- if (!res.ok) { alert(I18n.t("tasks.sessionError") + (data.error || "unknown")); return; }
845
-
846
- const session = data.session;
847
- if (!session) return;
848
-
849
- // If WS is not yet connected (e.g. called during onboarding), boot the UI
850
- // first so WS connects, then use setPendingMessage so the command is sent
851
- // once the socket is ready.
852
- if (!WS.ready) {
853
- WS.connect();
854
- Tasks.load();
855
- }
856
-
857
- Sessions.add(session);
858
- Sessions.renderList();
859
- Sessions.setPendingMessage(session.id, message || "/skill-creator");
860
- Sessions.select(session.id);
861
- },
862
- };
863
- })();
1
+ // ── SkillACslash-command autocomplete + composer bindings ──────────────
2
+ // NOTE: The Skills data/render module moved to features/skills/{store,view}.js.
3
+ // Only the composer-side autocomplete lives here.
864
4
 
865
5
  // ─────────────────────────────────────────────────────────────────────────
866
6
  // SkillAC — slash-command skill autocomplete dropdown + composer bindings