openclacky 1.3.2 → 1.3.4

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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/docs/rich_ui_guide.md +277 -0
  7. data/docs/rich_ui_refactor_plan.md +396 -0
  8. data/lib/clacky/agent/llm_caller.rb +10 -4
  9. data/lib/clacky/agent/session_serializer.rb +52 -7
  10. data/lib/clacky/agent/time_machine.rb +247 -26
  11. data/lib/clacky/agent.rb +15 -3
  12. data/lib/clacky/api_extension.rb +262 -0
  13. data/lib/clacky/api_extension_loader.rb +156 -0
  14. data/lib/clacky/cli.rb +93 -3
  15. data/lib/clacky/client.rb +38 -13
  16. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  17. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  18. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  19. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  20. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  21. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  22. data/lib/clacky/default_skills/media-gen/SKILL.md +37 -10
  23. data/lib/clacky/idle_compression_timer.rb +3 -1
  24. data/lib/clacky/locales/en.rb +26 -0
  25. data/lib/clacky/locales/i18n.rb +26 -0
  26. data/lib/clacky/locales/zh.rb +26 -0
  27. data/lib/clacky/media/openai_compat.rb +64 -1
  28. data/lib/clacky/message_history.rb +9 -0
  29. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  30. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  31. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  32. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  33. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  34. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  35. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  36. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  37. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  38. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  39. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  41. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  42. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  43. data/lib/clacky/rich_ui.rb +57 -0
  44. data/lib/clacky/rich_ui_controller.rb +3 -1549
  45. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  46. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  47. data/lib/clacky/server/git_panel.rb +115 -0
  48. data/lib/clacky/server/http_server.rb +547 -15
  49. data/lib/clacky/server/server_master.rb +6 -4
  50. data/lib/clacky/server/session_registry.rb +1 -1
  51. data/lib/clacky/shell_hook_loader.rb +1 -1
  52. data/lib/clacky/tools/edit.rb +14 -2
  53. data/lib/clacky/ui2/ui_controller.rb +7 -0
  54. data/lib/clacky/version.rb +1 -1
  55. data/lib/clacky/web/app.css +511 -101
  56. data/lib/clacky/web/app.js +95 -14
  57. data/lib/clacky/web/components/code-editor.js +197 -0
  58. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  59. data/lib/clacky/web/{onboard.js → components/onboard.js} +18 -2
  60. data/lib/clacky/web/core/aside.js +117 -0
  61. data/lib/clacky/web/core/ext.js +387 -0
  62. data/lib/clacky/web/features/backup/store.js +92 -0
  63. data/lib/clacky/web/features/backup/view.js +94 -0
  64. data/lib/clacky/web/features/billing/store.js +163 -0
  65. data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
  66. data/lib/clacky/web/features/brand/store.js +110 -0
  67. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  68. data/lib/clacky/web/features/channels/store.js +103 -0
  69. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  70. data/lib/clacky/web/features/creator/store.js +81 -0
  71. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  72. data/lib/clacky/web/features/mcp/store.js +158 -0
  73. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  74. data/lib/clacky/web/features/model-tester/store.js +77 -0
  75. data/lib/clacky/web/features/model-tester/view.js +7 -0
  76. data/lib/clacky/web/features/profile/store.js +170 -0
  77. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  78. data/lib/clacky/web/features/share/store.js +145 -0
  79. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  80. data/lib/clacky/web/features/skills/store.js +331 -0
  81. data/lib/clacky/web/features/skills/view.js +581 -0
  82. data/lib/clacky/web/features/tasks/store.js +135 -0
  83. data/lib/clacky/web/features/tasks/view.js +241 -0
  84. data/lib/clacky/web/features/trash/store.js +242 -0
  85. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  86. data/lib/clacky/web/features/version/store.js +165 -0
  87. data/lib/clacky/web/features/version/view.js +323 -0
  88. data/lib/clacky/web/features/workspace/store.js +99 -0
  89. data/lib/clacky/web/features/workspace/view.js +305 -0
  90. data/lib/clacky/web/i18n.js +68 -6
  91. data/lib/clacky/web/index.html +113 -62
  92. data/lib/clacky/web/sessions.js +493 -39
  93. data/lib/clacky/web/settings.js +143 -49
  94. data/lib/clacky/web/skills.js +3 -863
  95. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  96. data/lib/clacky/web/ws-dispatcher.js +7 -3
  97. data/lib/clacky.rb +17 -1
  98. metadata +81 -20
  99. data/lib/clacky/web/backup.js +0 -119
  100. data/lib/clacky/web/model-tester.js +0 -66
  101. data/lib/clacky/web/tasks.js +0 -373
  102. data/lib/clacky/web/version.js +0 -449
  103. data/lib/clacky/web/workspace.js +0 -316
  104. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  105. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  106. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  107. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -0,0 +1,581 @@
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
+ const editButtonHtml = isSystem
118
+ ? ""
119
+ : `<button class="btn-skill-edit" data-name="${escapeHtml(skill.name)}" title="${I18n.t("skills.btn.edit")}">
120
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
121
+ <path d="M12 20h9"/>
122
+ <path d="M16.5 3.5a2.121 2.121 0 1 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/>
123
+ </svg>
124
+ </button>`;
125
+
126
+ card.innerHTML = `
127
+ <div class="skill-card-main">
128
+ <div class="skill-card-info">
129
+ <div class="skill-card-title">
130
+ ${warnIconHtml}
131
+ <span class="skill-name">${escapeHtml((currentLang === "zh" && skill.name_zh) ? skill.name_zh : skill.name)}</span>
132
+ <span class="${badgeClass}">${badgeLabel}</span>
133
+ ${skill.invalid ? `<span class="skill-badge skill-badge-invalid">${I18n.t("skills.badge.invalid")}</span>` : ""}
134
+ </div>
135
+ <div class="skill-card-desc">${escapeHtml(description)}</div>
136
+ </div>
137
+ <div class="skill-card-actions">
138
+ <label class="skill-toggle ${toggleDisabled ? "skill-toggle-disabled" : ""}" data-tooltip="${escapeHtml(toggleTitle)}">
139
+ <input type="checkbox" class="skill-toggle-input" ${skill.enabled ? "checked" : ""} ${toggleDisabled ? "disabled" : ""}>
140
+ <span class="skill-toggle-track"></span>
141
+ </label>
142
+ ${useButtonHtml}
143
+ ${editButtonHtml}
144
+ ${deleteButtonHtml}
145
+ </div>
146
+ </div>
147
+ ${errorNoticeHtml}`;
148
+
149
+ if (!isSystem) {
150
+ const checkbox = card.querySelector(".skill-toggle-input");
151
+ checkbox.addEventListener("change", () => Skills.toggle(skill.name, checkbox.checked));
152
+ }
153
+
154
+ const toggleLabel = card.querySelector(".skill-toggle");
155
+ if (toggleLabel) {
156
+ toggleLabel.addEventListener("mouseenter", () => {
157
+ const scroller = toggleLabel.closest(".skills-tab-content");
158
+ if (!scroller) return;
159
+ const toggleTop = toggleLabel.getBoundingClientRect().top;
160
+ const scrollerTop = scroller.getBoundingClientRect().top;
161
+ if (toggleTop - scrollerTop < 80) {
162
+ toggleLabel.setAttribute("data-tooltip-pos", "bottom");
163
+ }
164
+ });
165
+ }
166
+
167
+ const useBtn = card.querySelector(".btn-skill-use");
168
+ if (useBtn) useBtn.addEventListener("click", () => Skills.useInstalledSkill(skill.name));
169
+
170
+ const editBtn = card.querySelector(".btn-skill-edit");
171
+ if (editBtn) editBtn.addEventListener("click", (e) => {
172
+ e.stopPropagation();
173
+ _editSkill(skill);
174
+ });
175
+
176
+ const deleteBtn = card.querySelector(".btn-skill-delete");
177
+ if (deleteBtn) deleteBtn.addEventListener("click", (e) => {
178
+ e.stopPropagation();
179
+ Skills.delete(skill.name);
180
+ });
181
+
182
+ return card;
183
+ }
184
+
185
+ // ── Brand Skills rendering ───────────────────────────────────────────────
186
+
187
+ function _renderBrandLoading() {
188
+ const container = $("brand-skills-list");
189
+ if (!container) return;
190
+ container.innerHTML = Array.from({ length: 4 }).map(() => `
191
+ <div class="brand-skill-card">
192
+ <div class="brand-skill-card-main">
193
+ <div class="brand-skill-info">
194
+ <div class="brand-skill-title">
195
+ <span class="skel skel-title"></span>
196
+ <span class="skel" style="height:1rem;width:3.5rem;border-radius:4px;"></span>
197
+ </div>
198
+ <span class="skel skel-subtitle"></span>
199
+ </div>
200
+ <div class="brand-skill-actions">
201
+ <span class="skel" style="height:1.75rem;width:4.5rem;border-radius:6px;"></span>
202
+ </div>
203
+ </div>
204
+ </div>`).join("");
205
+ }
206
+
207
+ function _renderBrandError(payload) {
208
+ const container = $("brand-skills-list");
209
+ if (!container) return;
210
+ const msg = payload.network
211
+ ? "Network error \u2014 please try again."
212
+ : escapeHtml(payload.error || I18n.t("skills.brand.loadFailed"));
213
+ container.innerHTML = `<div class="brand-skills-error">${msg}</div>`;
214
+ }
215
+
216
+ function _applyBrandWarning(warning, warningCode) {
217
+ const warningBanner = $("brand-skills-warning");
218
+ if (!warningBanner) return;
219
+ const warningText = warningCode ? I18n.t("skills.brand.warning." + warningCode) : warning;
220
+ if (warningText) {
221
+ warningBanner.textContent = warningText;
222
+ if (warningCode) warningBanner.setAttribute("data-i18n", "skills.brand.warning." + warningCode);
223
+ else warningBanner.removeAttribute("data-i18n");
224
+ warningBanner.style.display = "";
225
+ } else {
226
+ warningBanner.style.display = "none";
227
+ warningBanner.removeAttribute("data-i18n");
228
+ }
229
+ }
230
+
231
+ function _renderBrandSkills() {
232
+ const container = $("brand-skills-list");
233
+ if (!container) return;
234
+ container.innerHTML = "";
235
+
236
+ const brandSkills = SkillsStore.state.brandSkills;
237
+ const freeMode = SkillsStore.state.freeMode;
238
+ const paidSkillsCount = SkillsStore.state.paidSkillsCount;
239
+
240
+ if (brandSkills.length === 0 && !(freeMode && paidSkillsCount > 0)) {
241
+ container.innerHTML = `<div class="brand-skills-empty">${I18n.t("skills.brand.empty")}</div>`;
242
+ return;
243
+ }
244
+
245
+ brandSkills.forEach(skill => container.appendChild(_renderBrandSkillCard(skill)));
246
+
247
+ if (freeMode && paidSkillsCount > 0) {
248
+ container.appendChild(_renderPaidHint(paidSkillsCount));
249
+ }
250
+ }
251
+
252
+ function _renderPaidHint(paidSkillsCount) {
253
+ const hint = document.createElement("div");
254
+ hint.className = "brand-skills-paid-hint";
255
+
256
+ const msgEl = document.createElement("div");
257
+ msgEl.className = "brand-skills-paid-hint-msg";
258
+ msgEl.textContent = I18n.t("skills.brand.paidHint", { n: paidSkillsCount });
259
+ msgEl.setAttribute("data-i18n", "skills.brand.paidHint");
260
+ msgEl.setAttribute("data-i18n-vars", `n=${paidSkillsCount}`);
261
+
262
+ const btn = document.createElement("button");
263
+ btn.className = "brand-skills-activate-btn";
264
+ btn.textContent = I18n.t("skills.brand.activateBtn");
265
+ btn.setAttribute("data-i18n", "skills.brand.activateBtn");
266
+ btn.addEventListener("click", () => {
267
+ if (typeof Brand !== "undefined" && Brand.goToLicenseInput) Brand.goToLicenseInput();
268
+ else Router.navigate("settings");
269
+ });
270
+
271
+ hint.appendChild(msgEl);
272
+ hint.appendChild(btn);
273
+ return hint;
274
+ }
275
+
276
+ function _renderBrandSkillCard(skill) {
277
+ const name = skill.name;
278
+ const installedVersion = skill.installed_version;
279
+ const latestVersion = (skill.latest_version || {}).version || skill.version;
280
+ const needsUpdate = skill.needs_update;
281
+
282
+ let statusHtml = "";
283
+ if (!installedVersion) {
284
+ const versionBadge = latestVersion
285
+ ? `<span class="brand-skill-version latest">v${escapeHtml(latestVersion)}</span>` : "";
286
+ statusHtml = `${versionBadge}<button class="btn-brand-install" data-name="${escapeHtml(name)}">${I18n.t("skills.brand.btn.install")}</button>`;
287
+ } else if (needsUpdate) {
288
+ statusHtml = `
289
+ <span class="brand-skill-version installed">v${escapeHtml(installedVersion)}</span>
290
+ <span class="brand-skill-update-arrow">→</span>
291
+ <span class="brand-skill-version latest">v${escapeHtml(latestVersion)}</span>
292
+ <button class="btn-brand-update" data-name="${escapeHtml(name)}">${I18n.t("skills.brand.btn.update")}</button>`;
293
+ } else {
294
+ const displayVersion = installedVersion || latestVersion;
295
+ statusHtml = `
296
+ <span class="brand-skill-version installed">v${escapeHtml(displayVersion)} ✓</span>
297
+ <button class="btn-brand-use" data-name="${escapeHtml(name)}">${I18n.t("skills.brand.btn.use")}</button>
298
+ <button class="btn-skill-delete btn-brand-delete" data-name="${escapeHtml(name)}" title="${I18n.t("skills.btn.delete")}">
299
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
300
+ <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"/>
301
+ </svg>
302
+ </button>`;
303
+ }
304
+
305
+ const badge = skill.is_free
306
+ ? `<span class="brand-skill-badge-free" title="${I18n.t("skills.brand.freeTip")}">✨ ${I18n.t("skills.brand.free")}</span>`
307
+ : `<span class="brand-skill-badge-private" title="${I18n.t("skills.brand.privateTip")}">🔒 ${I18n.t("skills.brand.private")}</span>`;
308
+
309
+ const currentLang = I18n.lang();
310
+ const description = (currentLang === "zh" && skill.description_zh)
311
+ ? skill.description_zh
312
+ : skill.description || "";
313
+
314
+ const card = document.createElement("div");
315
+ card.className = "brand-skill-card";
316
+ card.innerHTML = `
317
+ <div class="brand-skill-card-main">
318
+ <div class="brand-skill-info">
319
+ <div class="brand-skill-title">
320
+ <span class="brand-skill-name">${escapeHtml((currentLang === "zh" && skill.name_zh) ? skill.name_zh : name)}</span>
321
+ ${badge}
322
+ </div>
323
+ <div class="brand-skill-desc">${escapeHtml(description)}</div>
324
+ </div>
325
+ <div class="brand-skill-actions">${statusHtml}</div>
326
+ </div>`;
327
+
328
+ const installBtn = card.querySelector(".btn-brand-install");
329
+ const updateBtn = card.querySelector(".btn-brand-update");
330
+ const useBtn = card.querySelector(".btn-brand-use");
331
+ const deleteBtn = card.querySelector(".btn-brand-delete");
332
+ if (installBtn) installBtn.addEventListener("click", () => _runBrandInstall(name, installBtn));
333
+ if (updateBtn) updateBtn.addEventListener("click", () => _runBrandInstall(name, updateBtn));
334
+ if (useBtn) useBtn.addEventListener("click", () => Skills.useInstalledSkill(name));
335
+ if (deleteBtn) deleteBtn.addEventListener("click", (e) => { e.stopPropagation(); Skills.deleteBrandSkill(name); });
336
+
337
+ return card;
338
+ }
339
+
340
+ async function _runBrandInstall(name, btn) {
341
+ const originalText = btn.textContent;
342
+ btn.disabled = true;
343
+ btn.textContent = I18n.t("skills.brand.btn.installing");
344
+ const result = await Skills.installBrandSkill(name);
345
+ if (!result || !result.ok) {
346
+ _showBrandInstallError(btn, (result && result.message) || I18n.t("skills.brand.unknownError"));
347
+ btn.disabled = false;
348
+ btn.textContent = originalText;
349
+ }
350
+ // On success the store emits brandSkills:changed → the tab re-renders,
351
+ // replacing this button.
352
+ }
353
+
354
+ function _showBrandInstallError(btn, message) {
355
+ const existing = btn.parentElement.querySelector(".brand-install-error");
356
+ if (existing) existing.remove();
357
+ const tip = document.createElement("div");
358
+ tip.className = "brand-install-error";
359
+ tip.textContent = message;
360
+ btn.parentElement.appendChild(tip);
361
+ setTimeout(() => tip.remove(), 5000);
362
+ }
363
+
364
+ // ── Tab switching (pure DOM) ─────────────────────────────────────────────
365
+
366
+ function _applyTab(tab) {
367
+ document.querySelectorAll(".skills-tab").forEach(btn => {
368
+ btn.classList.toggle("active", btn.dataset.tab === tab);
369
+ });
370
+ const tabMy = $("skills-tab-my");
371
+ const tabBrand = $("skills-tab-brand");
372
+ if (tabMy) tabMy.style.display = tab === "my-skills" ? "" : "none";
373
+ if (tabBrand) tabBrand.style.display = tab === "brand-skills" ? "" : "none";
374
+
375
+ const showSystemLabel = $("label-show-system");
376
+ const refreshBtn = $("btn-refresh-brand-skills");
377
+ if (showSystemLabel) showSystemLabel.style.display = tab === "my-skills" ? "" : "none";
378
+ if (refreshBtn) refreshBtn.style.display = tab === "brand-skills" ? "" : "none";
379
+ }
380
+
381
+ // ── One-time DOM wiring ──────────────────────────────────────────────────
382
+
383
+ function _wireDom() {
384
+ if (_domWired) return;
385
+
386
+ document.querySelectorAll(".skills-tab").forEach(btn => {
387
+ btn.addEventListener("click", () => Skills.setActiveTab(btn.dataset.tab));
388
+ });
389
+
390
+ const refreshBtn = $("btn-refresh-brand-skills");
391
+ if (refreshBtn) {
392
+ refreshBtn.addEventListener("click", async () => {
393
+ const icon = refreshBtn.querySelector("svg");
394
+ if (icon) icon.classList.add("spinning");
395
+ refreshBtn.disabled = true;
396
+ await Skills.loadBrandSkills();
397
+ if (icon) icon.classList.remove("spinning");
398
+ refreshBtn.disabled = false;
399
+ });
400
+ }
401
+
402
+ const chkSystem = $("chk-show-system-skills");
403
+ if (chkSystem) {
404
+ chkSystem.checked = SkillsStore.state.showSystemSkills;
405
+ chkSystem.addEventListener("change", () => Skills.setShowSystemSkills(chkSystem.checked));
406
+ }
407
+
408
+ document.addEventListener("langchange", () => {
409
+ _renderMySkills();
410
+ _renderBrandSkills();
411
+ });
412
+
413
+ _domWired = true;
414
+ }
415
+
416
+ // ── Store subscriptions ──────────────────────────────────────────────────
417
+
418
+ function _subscribe() {
419
+ Skills.on("skills:changed", () => {
420
+ Skills.renderSection();
421
+ if (Router.current === "skills") {
422
+ try { _renderMySkills(); } catch (e) { console.error("[Skills] _renderMySkills failed", e); }
423
+ }
424
+ });
425
+
426
+ Skills.on("brandSkills:loading", _renderBrandLoading);
427
+ Skills.on("brandSkills:error", _renderBrandError);
428
+ Skills.on("brandSkills:changed", (p) => {
429
+ if (p) _applyBrandWarning(p.warning, p.warningCode);
430
+ _renderBrandSkills();
431
+ });
432
+
433
+ Skills.on("tab:changed", (p) => _applyTab(p.tab));
434
+
435
+ Skills.on("brandStatus:changed", (p) => {
436
+ const brandTab = $("tab-brand-skills");
437
+ if (brandTab) brandTab.style.display = p.branded ? "" : "none";
438
+ if (p.activatedChanged && Router.current === "skills") _renderMySkills();
439
+ });
440
+ }
441
+
442
+ // ── UI facade methods (called externally on the Skills global) ───────────
443
+
444
+ const viewApi = {
445
+ renderSection() {
446
+ const labelEl = $("skills-sidebar-label");
447
+ if (!labelEl) return;
448
+ labelEl.textContent = I18n.t("sidebar.skills");
449
+ },
450
+
451
+ onPanelShow() {
452
+ _wireDom();
453
+ _renderMySkills();
454
+ Skills.renderSection();
455
+ _applyTab(SkillsStore.state.activeTab);
456
+ if (SkillsStore.state.activeTab === "brand-skills") Skills.loadBrandSkills();
457
+ Skills.refreshBrandStatus();
458
+ },
459
+
460
+ openBrandSkillsTab() {
461
+ Skills.onPanelShow();
462
+ Skills.setActiveTab("brand-skills");
463
+ },
464
+
465
+ toggleImportBar() {
466
+ Skills.setActiveTab("my-skills");
467
+
468
+ const bar = $("skill-import-bar");
469
+ const input = $("skill-import-input");
470
+ const confirmBtn = $("btn-skill-import-confirm");
471
+ const cancelBtn = $("btn-skill-import-cancel");
472
+ if (!bar) return;
473
+
474
+ const isOpen = bar.style.display !== "none";
475
+ if (isOpen) {
476
+ bar.style.display = "none";
477
+ if (input) input.value = "";
478
+ return;
479
+ }
480
+
481
+ bar.style.display = "";
482
+ if (input) {
483
+ input.focus();
484
+ input.placeholder = I18n.t("skills.import.placeholder");
485
+ }
486
+
487
+ if (!bar.dataset.wired) {
488
+ bar.dataset.wired = "1";
489
+ confirmBtn.addEventListener("click", () => _doImportFromBar());
490
+ input.addEventListener("keydown", (e) => {
491
+ if (e.key === "Enter") { e.preventDefault(); _doImportFromBar(); }
492
+ });
493
+ cancelBtn.addEventListener("click", () => {
494
+ bar.style.display = "none";
495
+ input.value = "";
496
+ });
497
+
498
+ const browseBtn = $("btn-skill-import-browse");
499
+ const fileInput = $("skill-import-file");
500
+ if (browseBtn && fileInput) {
501
+ browseBtn.addEventListener("click", () => fileInput.click());
502
+ fileInput.addEventListener("change", async () => {
503
+ const file = fileInput.files[0];
504
+ if (!file) return;
505
+ input.value = file.name;
506
+ input.placeholder = "";
507
+ browseBtn.disabled = true;
508
+ browseBtn.style.opacity = "0.5";
509
+ try {
510
+ const form = new FormData();
511
+ form.append("file", file);
512
+ const res = await fetch("/api/upload", { method: "POST", body: form });
513
+ const data = await res.json();
514
+ if (res.ok && data.path) input.value = data.path;
515
+ else { input.value = ""; alert(data.error || "Upload failed"); }
516
+ } catch (e) {
517
+ input.value = "";
518
+ console.error("[Skills] upload error", e);
519
+ } finally {
520
+ browseBtn.disabled = false;
521
+ browseBtn.style.opacity = "";
522
+ fileInput.value = "";
523
+ }
524
+ });
525
+ }
526
+ }
527
+ },
528
+
529
+ resetAfterUnbind() {
530
+ SkillsStore.resetBrandState();
531
+ const panel = $("skills-panel");
532
+ if (panel && panel.style.display !== "none") _applyTab("my-skills");
533
+ },
534
+ };
535
+
536
+ async function _editSkill(skill) {
537
+ const res = await Skills.fetchSkillContent(skill.name);
538
+ if (!res.ok) { alert(I18n.t("skills.editFail") + ": " + res.error); return; }
539
+
540
+ CodeEditor.open({
541
+ content: res.content,
542
+ title: skill.name,
543
+ language: "markdown",
544
+ onSave: async (newContent) => {
545
+ const r = await Skills.updateSkillContent(skill.name, newContent);
546
+ if (!r.ok) throw new Error(r.error);
547
+ }
548
+ });
549
+ }
550
+
551
+ async function _doImportFromBar() {
552
+ const input = $("skill-import-input");
553
+ const bar = $("skill-import-bar");
554
+ const url = (input ? input.value : "").trim();
555
+
556
+ const result = await Skills.importSkill(url);
557
+ if (result.ok) {
558
+ if (bar) bar.style.display = "none";
559
+ if (input) input.value = "";
560
+ return;
561
+ }
562
+ if (result.reason === "empty") {
563
+ input && input.focus();
564
+ return;
565
+ }
566
+ if (result.reason === "invalid") {
567
+ input.classList.add("skill-import-input-error");
568
+ setTimeout(() => input.classList.remove("skill-import-input-error"), 1200);
569
+ input.focus();
570
+ return;
571
+ }
572
+ alert(I18n.lang() === "zh" ? "导入技能时网络错误。" : "Network error while importing skill.");
573
+ }
574
+
575
+ return { init: _subscribe, api: viewApi, _doImportFromBar };
576
+ })();
577
+
578
+ // Augment the Skills facade with view-owned UI methods, then wire subscriptions.
579
+ Object.assign(Skills, SkillsView.api);
580
+ Skills._doImportFromBar = SkillsView._doImportFromBar;
581
+ SkillsView.init();