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