openclacky 1.3.1 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +44 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/lib/clacky/agent/session_serializer.rb +65 -11
  7. data/lib/clacky/agent/time_machine.rb +247 -26
  8. data/lib/clacky/agent.rb +12 -1
  9. data/lib/clacky/agent_config.rb +14 -2
  10. data/lib/clacky/brand_config.rb +1 -1
  11. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  12. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  13. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  14. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  15. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  16. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  17. data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
  18. data/lib/clacky/media/openai_compat.rb +64 -1
  19. data/lib/clacky/media/output_dir.rb +43 -0
  20. data/lib/clacky/message_history.rb +9 -0
  21. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  22. data/lib/clacky/server/git_panel.rb +115 -0
  23. data/lib/clacky/server/http_server.rb +521 -13
  24. data/lib/clacky/server/server_master.rb +6 -4
  25. data/lib/clacky/utils/environment_detector.rb +16 -0
  26. data/lib/clacky/version.rb +1 -1
  27. data/lib/clacky/web/app.css +512 -60
  28. data/lib/clacky/web/app.js +30 -7
  29. data/lib/clacky/web/components/code-editor.js +197 -0
  30. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  31. data/lib/clacky/web/core/aside.js +112 -0
  32. data/lib/clacky/web/core/ext.js +387 -0
  33. data/lib/clacky/web/features/backup/store.js +92 -0
  34. data/lib/clacky/web/features/backup/view.js +94 -0
  35. data/lib/clacky/web/features/billing/store.js +163 -0
  36. data/lib/clacky/web/{billing.js → features/billing/view.js} +134 -242
  37. data/lib/clacky/web/features/brand/store.js +110 -0
  38. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  39. data/lib/clacky/web/features/channels/store.js +103 -0
  40. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  41. data/lib/clacky/web/features/creator/store.js +81 -0
  42. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  43. data/lib/clacky/web/features/mcp/store.js +158 -0
  44. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  45. data/lib/clacky/web/features/model-tester/store.js +77 -0
  46. data/lib/clacky/web/features/model-tester/view.js +7 -0
  47. data/lib/clacky/web/features/profile/store.js +170 -0
  48. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  49. data/lib/clacky/web/features/share/store.js +145 -0
  50. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  51. data/lib/clacky/web/features/skills/store.js +303 -0
  52. data/lib/clacky/web/features/skills/view.js +550 -0
  53. data/lib/clacky/web/features/tasks/store.js +135 -0
  54. data/lib/clacky/web/features/tasks/view.js +241 -0
  55. data/lib/clacky/web/features/trash/store.js +242 -0
  56. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  57. data/lib/clacky/web/features/version/store.js +165 -0
  58. data/lib/clacky/web/features/version/view.js +323 -0
  59. data/lib/clacky/web/features/workspace/store.js +99 -0
  60. data/lib/clacky/web/features/workspace/view.js +305 -0
  61. data/lib/clacky/web/i18n.js +60 -6
  62. data/lib/clacky/web/index.html +117 -57
  63. data/lib/clacky/web/sessions.js +221 -25
  64. data/lib/clacky/web/settings.js +121 -25
  65. data/lib/clacky/web/skills.js +3 -821
  66. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  67. data/lib/clacky.rb +1 -0
  68. metadata +45 -20
  69. data/lib/clacky/web/backup.js +0 -119
  70. data/lib/clacky/web/model-tester.js +0 -66
  71. data/lib/clacky/web/tasks.js +0 -365
  72. data/lib/clacky/web/version.js +0 -449
  73. data/lib/clacky/web/workspace.js +0 -212
  74. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  75. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  76. /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
  77. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  78. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -0,0 +1,110 @@
1
+ // ── Brand · store — white-label status flags + brand info cache + network ──
2
+ //
3
+ // Owns the brand status flags (test mode / user-licensed / branded), the cached
4
+ // /api/brand response, and the brand network calls (status, info, activate).
5
+ // It never renders.
6
+ //
7
+ // check() / refresh() fetch status and emit events; the view reacts to drive
8
+ // banners, logo, badges. Emits mirror to the extension bus via Clacky.ext.emit.
9
+ //
10
+ // `Brand` stays the single public facade.
11
+ //
12
+ // Depends on: Clacky.ext.
13
+ // ───────────────────────────────────────────────────────────────────────────
14
+
15
+ const BrandStore = (() => {
16
+ let _testMode = false;
17
+ let _userLicensed = false;
18
+ let _branded = false;
19
+
20
+ let _brandInfoCache = null;
21
+ let _brandInfoFetching = null;
22
+
23
+ const _listeners = {};
24
+
25
+ function _on(event, handler) {
26
+ (_listeners[event] ||= []).push(handler);
27
+ return () => {
28
+ const list = _listeners[event];
29
+ const i = list ? list.indexOf(handler) : -1;
30
+ if (i >= 0) list.splice(i, 1);
31
+ };
32
+ }
33
+
34
+ function _emit(event, payload) {
35
+ (_listeners[event] || []).forEach((h) => h(payload));
36
+ if (window.Clacky && Clacky.ext) Clacky.ext.emit(event, payload);
37
+ }
38
+
39
+ // Fetch /api/brand once and cache. Returns a Promise<info>.
40
+ function _fetchBrandInfo() {
41
+ if (_brandInfoCache) return Promise.resolve(_brandInfoCache);
42
+ if (_brandInfoFetching) return _brandInfoFetching;
43
+ _brandInfoFetching = fetch("/api/brand")
44
+ .then(r => r.json())
45
+ .then(info => { _brandInfoCache = info; _brandInfoFetching = null; return info; })
46
+ .catch(err => { _brandInfoFetching = null; throw err; });
47
+ return _brandInfoFetching;
48
+ }
49
+
50
+ const Brand = {
51
+ on: _on,
52
+ get testMode() { return _testMode; },
53
+ get userLicensed() { return _userLicensed; },
54
+ get branded() { return _branded; },
55
+
56
+ fetchInfo: _fetchBrandInfo,
57
+
58
+ clearBrandCache() {
59
+ _brandInfoCache = null;
60
+ _brandInfoFetching = null;
61
+ },
62
+
63
+ // Check brand status and emit an event for the view to act on.
64
+ // Always resolves false (boot is no longer deferred on activation).
65
+ async check() {
66
+ try {
67
+ const res = await fetch("/api/brand/status");
68
+ const data = await res.json();
69
+ _testMode = !!data.test_mode;
70
+ _userLicensed = !!data.user_licensed;
71
+ _branded = !!data.branded;
72
+ _emit("brand:status", data);
73
+ } catch (_) {
74
+ _emit("brand:status", null);
75
+ }
76
+ return false;
77
+ },
78
+
79
+ // Re-fetch status to refresh flags only (no UI boot driving).
80
+ async refresh() {
81
+ try {
82
+ const res = await fetch("/api/brand/status");
83
+ const data = await res.json();
84
+ _testMode = !!data.test_mode;
85
+ _userLicensed = !!data.user_licensed;
86
+ _branded = !!data.branded;
87
+ return data;
88
+ } catch (_) {
89
+ return null;
90
+ }
91
+ },
92
+
93
+ fetchSkillsBanner() {
94
+ return fetch("/api/brand/skills").then(r => r.json());
95
+ },
96
+
97
+ async activate(key) {
98
+ const res = await fetch("/api/brand/activate", {
99
+ method: "POST",
100
+ headers: { "Content-Type": "application/json" },
101
+ body: JSON.stringify({ license_key: key })
102
+ });
103
+ return res.json();
104
+ },
105
+ };
106
+
107
+ return Brand;
108
+ })();
109
+
110
+ const Brand = BrandStore;
@@ -1,76 +1,34 @@
1
- // brand.jsWhite-label branding support
1
+ // ── Brand · view banners, logo/favicon, owner badge, activation panel ───
2
2
  //
3
- // Responsibilities:
4
- // 1. On boot, fetch GET /api/brand/status
5
- // - If needs_activation show brand activation panel (like onboard)
6
- // - If branded + warning → show a dismissible warning bar
7
- // - If not branded → no-op (standard OpenClacky experience)
8
- // 2. Fetch GET /api/brand and apply product_name to all branded DOM elements
3
+ // Owns all white-label DOM: activation banner, warning bar, header logo +
4
+ // favicon + theme color, OWNER badge, the "get serial" link, and the activation
5
+ // panel flow. Reads brand info through BrandStore; status/activation network
6
+ // calls go through store actions.
9
7
  //
10
- // Load order: must be loaded after onboard.js and before app.js
11
-
12
- const Brand = (() => {
13
-
14
- // ── Public API ─────────────────────────────────────────────────────────────
15
-
16
- // Whether the server was started with --brand-test (set during check()).
17
- let _testMode = false;
18
- // Whether the current license is bound to a user (creator mode).
19
- let _userLicensed = false;
20
- // Whether this installation has an activated brand license (any kind).
21
- let _branded = false;
22
-
23
- // Check brand status. Returns true if activation is needed
24
- // (caller should defer normal UI boot until activation is done or skipped).
25
- async function check() {
26
- try {
27
- const res = await fetch("/api/brand/status");
28
- const data = await res.json();
29
-
30
- _testMode = !!data.test_mode;
31
- _userLicensed = !!data.user_licensed;
32
- _branded = !!data.branded;
33
-
34
- if (!data.branded) return false;
35
-
36
- // Brand name is already baked into the HTML by the server at request time,
37
- // so no DOM update is needed here on boot.
38
-
39
- if (data.needs_activation) {
40
- _showActivationBanner(data.product_name);
41
- _applyHeaderLogo();
42
-
43
- // Backend just kicked off an async refresh of the public distribution
44
- // (logo, theme_color, homepage_url, support_*). brand.yml will be
45
- // written once the network round-trip completes — re-poll a few
46
- // seconds later so the user sees the full brand visuals in *this*
47
- // session instead of having to activate or reload the page first.
48
- if (data.distribution_refresh_pending) {
49
- _scheduleDistributionRefreshPoll();
50
- }
8
+ // Augments the `Brand` facade with the apply* / goToLicenseInput methods that
9
+ // other modules call.
10
+ //
11
+ // Depends on: BrandStore, I18n, Router, Settings (optional), WS/Tasks/Skills
12
+ // (boot fallback), global $ helper.
13
+ // ───────────────────────────────────────────────────────────────────────────
51
14
 
52
- return false;
53
- }
15
+ const BrandView = (() => {
54
16
 
55
- if (data.warning) _showWarning(data.warning);
17
+ function _onStatus(data) {
18
+ if (!data || !data.branded) return;
56
19
 
57
- // Load full brand info to apply logo in header
20
+ if (data.needs_activation) {
21
+ _showActivationBanner(data.product_name);
58
22
  _applyHeaderLogo();
59
- // Show OWNER badge for creator-tier licenses (user_licensed=true).
60
- _applyOwnerBadge();
61
-
62
- return false;
63
- } catch (_) {
64
- return false;
23
+ if (data.distribution_refresh_pending) _scheduleDistributionRefreshPoll();
24
+ return;
65
25
  }
66
- }
67
26
 
68
- // ── Internal ───────────────────────────────────────────────────────────────
27
+ if (data.warning) _showWarning(data.warning);
28
+ _applyHeaderLogo();
29
+ _applyOwnerBadge();
30
+ }
69
31
 
70
- // Show a dismissible activation banner at the top of the page.
71
- // Defers rendering until /api/brand/skills resolves so the banner shows its
72
- // final copy in one shot. Falls back to a generic prompt if the API fails
73
- // or stays silent for 5s.
74
32
  function _showActivationBanner(brandName) {
75
33
  if (document.getElementById("brand-activation-banner")) return;
76
34
 
@@ -84,11 +42,7 @@ const Brand = (() => {
84
42
  _renderActivationBanner(name, data);
85
43
  };
86
44
 
87
- fetch("/api/brand/skills")
88
- .then(r => r.json())
89
- .then(settle)
90
- .catch(() => settle(null));
91
-
45
+ Brand.fetchSkillsBanner().then(settle).catch(() => settle(null));
92
46
  setTimeout(() => settle(null), 5000);
93
47
  }
94
48
 
@@ -138,14 +92,11 @@ const Brand = (() => {
138
92
  document.getElementById("main").prepend(bar);
139
93
  }
140
94
 
141
- // Navigate to Settings, scroll to Brand & License section, flash it, then focus the input.
142
95
  function _goToLicenseInput() {
143
96
  Router.navigate("settings");
144
- // Settings.open() loads brand status; wait a tick for the panel to render.
145
97
  if (typeof Settings !== "undefined") Settings.open();
146
- // Settings.open() triggers an async fetch; wait for layout to stabilise before scrolling.
147
98
  setTimeout(() => {
148
- const generalTabBtn = document.querySelector('#settings-tabs .settings-tab[data-tab="general"]');
99
+ const generalTabBtn = document.querySelector('#settings-tabs .settings-tab[data-tab="general"]');
149
100
  if (generalTabBtn && !generalTabBtn.classList.contains("active")) generalTabBtn.click();
150
101
 
151
102
  const section = document.getElementById("brand-license-section");
@@ -160,7 +111,6 @@ const Brand = (() => {
160
111
  }
161
112
 
162
113
  if (section) {
163
- // Flash the section to draw the user's eye (re-trigger if clicked again).
164
114
  section.classList.remove("section-highlight");
165
115
  void section.offsetWidth; // force reflow to restart animation
166
116
  section.classList.add("section-highlight");
@@ -171,17 +121,6 @@ const Brand = (() => {
171
121
  }, 300);
172
122
  }
173
123
 
174
- function _showActivationPanel(brandName) {
175
- if (brandName) {
176
- const title = $("brand-title");
177
- const sub = $("brand-subtitle");
178
- if (title) title.textContent = I18n.t("brand.activate.title", { name: brandName });
179
- if (sub) sub.textContent = I18n.t("brand.activate.subtitle");
180
- }
181
- Router.navigate("brand");
182
- _bindActivationPanel();
183
- }
184
-
185
124
  function _bindActivationPanel() {
186
125
  $("brand-btn-activate").addEventListener("click", _doActivate);
187
126
  $("brand-license-key").addEventListener("keydown", e => {
@@ -199,8 +138,7 @@ const Brand = (() => {
199
138
  return;
200
139
  }
201
140
 
202
- // In brand-test mode accept any non-empty key so developers can test without a real license.
203
- if (!_testMode && !/^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{8}){4}$/.test(key)) {
141
+ if (!Brand.testMode && !/^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{8}){4}$/.test(key)) {
204
142
  _setResult(false, I18n.t("settings.brand.invalidFormat"));
205
143
  return;
206
144
  }
@@ -210,17 +148,12 @@ const Brand = (() => {
210
148
  _setResult(null, "");
211
149
 
212
150
  try {
213
- const res = await fetch("/api/brand/activate", {
214
- method: "POST",
215
- headers: { "Content-Type": "application/json" },
216
- body: JSON.stringify({ license_key: key })
217
- });
218
- const data = await res.json();
151
+ const data = await Brand.activate(key);
219
152
 
220
153
  if (data.ok) {
221
154
  _setResult(true, I18n.t("brand.activate.success"));
222
155
  if (data.product_name) _applyBrandName(data.product_name);
223
- _clearBrandCache();
156
+ Brand.clearBrandCache();
224
157
  _applyHeaderLogo();
225
158
  setTimeout(_bootUI, 800);
226
159
  } else {
@@ -236,8 +169,6 @@ const Brand = (() => {
236
169
  }
237
170
 
238
171
  function _skipActivation() {
239
- // Show a dismissible warning so the user knows brand features are unavailable.
240
- // Pass the i18n key so the bar text updates when the user switches language.
241
172
  _showWarning(I18n.t("brand.skip.warning"), "brand.skip.warning");
242
173
  _bootUI();
243
174
  }
@@ -250,7 +181,6 @@ const Brand = (() => {
250
181
  el.className = "onboard-test-result " + (ok ? "result-ok" : "result-fail");
251
182
  }
252
183
 
253
- // Replace all branded text nodes in the DOM.
254
184
  function _applyBrandName(name) {
255
185
  const nodes = {
256
186
  "page-title": name,
@@ -264,40 +194,21 @@ const Brand = (() => {
264
194
  });
265
195
  }
266
196
 
267
- // Cache for brand info to avoid redundant fetches and duplicate image loads.
268
- let _brandInfoCache = null;
269
- let _brandInfoFetching = null;
270
-
271
- // Fetch /api/brand once and cache the result. Returns a Promise<info>.
272
- function _fetchBrandInfo() {
273
- if (_brandInfoCache) return Promise.resolve(_brandInfoCache);
274
- if (_brandInfoFetching) return _brandInfoFetching;
275
- _brandInfoFetching = fetch("/api/brand")
276
- .then(r => r.json())
277
- .then(info => { _brandInfoCache = info; _brandInfoFetching = null; return info; })
278
- .catch(err => { _brandInfoFetching = null; throw err; });
279
- return _brandInfoFetching;
280
- }
281
-
282
- // Fetch /api/brand and apply logo_url + product_name to the header if available.
283
197
  function _applyHeaderLogo() {
284
- _fetchBrandInfo().then(info => {
198
+ Brand.fetchInfo().then(info => {
285
199
  const logoImg = document.getElementById("header-logo-img");
286
200
  const logoText = document.getElementById("header-logo");
287
201
  const brandWrap = document.getElementById("header-brand");
288
202
 
289
- // Apply theme color — overrides --color-accent-primary and --color-button-primary
290
203
  if (info.theme_color) {
291
204
  const root = document.documentElement;
292
205
  root.style.setProperty("--color-accent-primary", info.theme_color);
293
206
  root.style.setProperty("--color-accent-hover", info.theme_color);
294
207
  root.style.setProperty("--color-button-primary", info.theme_color);
295
208
  root.style.setProperty("--color-button-primary-hover", info.theme_color);
296
- // Also update browser tab color on mobile
297
209
  const metaTheme = document.querySelector("meta[name='theme-color']");
298
210
  if (metaTheme) metaTheme.setAttribute("content", info.theme_color);
299
211
  } else {
300
- // No brand theme — remove any previously applied overrides to restore defaults
301
212
  const root = document.documentElement;
302
213
  root.style.removeProperty("--color-accent-primary");
303
214
  root.style.removeProperty("--color-accent-hover");
@@ -305,60 +216,46 @@ const Brand = (() => {
305
216
  root.style.removeProperty("--color-button-primary-hover");
306
217
  }
307
218
 
308
- // header-brand already has onclick="Router.navigate('chat')" in HTML, no extra link needed
309
-
310
219
  const hasLogo = !!(info.logo_url && logoImg);
311
220
 
312
221
  if (hasLogo) {
313
222
  if (logoImg.src && logoImg.src === info.logo_url) {
314
- // Already showing the correct logo — only ensure favicon is in sync
315
223
  _applyFavicon(info.logo_url);
316
224
  } else {
317
- // Pre-load the image; only show it once loaded to avoid layout flicker
318
225
  const img = new Image();
319
226
  img.onload = () => {
320
227
  logoImg.src = info.logo_url;
321
228
  logoImg.alt = info.product_name || "";
322
229
  logoImg.style.display = "";
323
230
  if (brandWrap) brandWrap.classList.add("has-logo");
324
- // Update browser tab favicon to match the brand logo
325
231
  _applyFavicon(info.logo_url);
326
232
  };
327
- img.onerror = () => {
328
- // Logo failed to load — keep text-only mode
329
- };
233
+ img.onerror = () => {};
330
234
  img.src = info.logo_url;
331
235
  }
332
236
  } else if (info.product_name) {
333
- // Brand configured but no logo — hide the image, brand name text is enough
334
237
  if (logoImg) {
335
238
  logoImg.style.display = "none";
336
239
  logoImg.src = "";
337
240
  }
338
241
  if (brandWrap) brandWrap.classList.remove("has-logo");
339
242
  } else {
340
- // No brand at all — show default OpenClacky logo
341
243
  _applyDefaultLogo();
342
244
  }
343
245
 
344
- // Always show brand name text; hide it only when no brand name is set
345
246
  if (logoText) {
346
247
  const name = info.product_name || "";
347
248
  if (name) {
348
249
  logoText.textContent = name;
349
250
  logoText.style.display = "";
350
251
  } else {
351
- // No brand configured — show the default "OpenClacky" name
352
252
  logoText.textContent = "OpenClacky";
353
253
  logoText.style.display = "";
354
254
  }
355
255
  }
356
- }).catch(() => {
357
- // Silently ignore — logo is non-critical
358
- });
256
+ }).catch(() => {});
359
257
  }
360
258
 
361
- // Apply the default OpenClacky logo based on current theme.
362
259
  function _applyDefaultLogo() {
363
260
  const logoImg = document.getElementById("header-logo-img");
364
261
  const brandWrap = document.getElementById("header-brand");
@@ -370,8 +267,6 @@ const Brand = (() => {
370
267
  if (brandWrap) brandWrap.classList.add("has-logo");
371
268
  }
372
269
 
373
- // Replace the browser tab favicon with the given URL.
374
- // Works for both image URLs and SVG data URIs.
375
270
  function _applyFavicon(url) {
376
271
  let link = document.querySelector("link[rel='icon']");
377
272
  if (!link) {
@@ -387,9 +282,6 @@ const Brand = (() => {
387
282
  link.href = url;
388
283
  }
389
284
 
390
- // Show a dismissible warning bar above the main content.
391
- // The i18n key is stored on the span so I18n.applyAll() can re-translate
392
- // it when the user switches language without dismissing the bar.
393
285
  function _showWarning(message, i18nKey) {
394
286
  const existing = document.getElementById("brand-warning-bar");
395
287
  if (existing) return;
@@ -411,51 +303,29 @@ const Brand = (() => {
411
303
  document.getElementById("main").prepend(bar);
412
304
  }
413
305
 
414
- // Continue the boot sequence after brand check is resolved (activated or skipped).
415
- // Delegates to window.bootAfterBrand() defined in app.js so the onboard check
416
- // runs before WS.connect() — ensures key_setup is shown when no API key exists.
417
306
  function _bootUI() {
418
307
  if (typeof window.bootAfterBrand === "function") {
419
308
  window.bootAfterBrand();
420
309
  } else {
421
- // Fallback: app.js not yet loaded, boot directly
422
310
  WS.connect();
423
311
  Tasks.load();
424
312
  Skills.load();
425
313
  }
426
314
  }
427
315
 
428
- // Bust the cached /api/brand response so the next applyHeaderLogo() call
429
- // fetches fresh data from the server (needed after license key switch).
430
- function _clearBrandCache() {
431
- _brandInfoCache = null;
432
- _brandInfoFetching = null;
433
- }
434
-
435
- // Poll /api/brand a few times to pick up freshly-refreshed distribution
436
- // assets (logo_url / theme_color / homepage_url). Used on first boot of a
437
- // branded-but-unactivated install, where /api/brand/status just kicked off
438
- // an async distribution fetch — brand.yml will be written once the round-
439
- // trip completes, and we want to apply the result in the current session.
440
- //
441
- // Delay schedule: 3s, 8s, 15s from now. Stops early once logo_url and
442
- // theme_color are both present (assumed "fully refreshed").
443
- // Safe to call multiple times: a second call is ignored while a poll chain
444
- // is already in flight.
445
316
  let _distRefreshPolling = false;
446
317
  function _scheduleDistributionRefreshPoll() {
447
318
  if (_distRefreshPolling) return;
448
319
  _distRefreshPolling = true;
449
320
 
450
- const delays = [3000, 5000, 7000]; // cumulative: 3s, 8s, 15s
321
+ const delays = [3000, 5000, 7000];
451
322
  let attempt = 0;
452
323
 
453
324
  const poll = () => {
454
- _clearBrandCache();
455
- _fetchBrandInfo().then(info => {
325
+ Brand.clearBrandCache();
326
+ Brand.fetchInfo().then(info => {
456
327
  const hasFullBrand = !!(info && info.logo_url && info.theme_color);
457
328
  _applyHeaderLogo();
458
- // Stop when we've got the full brand visuals, or we've exhausted retries.
459
329
  if (hasFullBrand || attempt >= delays.length) {
460
330
  _distRefreshPolling = false;
461
331
  return;
@@ -470,30 +340,17 @@ const Brand = (() => {
470
340
  setTimeout(poll, delays[attempt++]);
471
341
  }
472
342
 
473
- // Show or hide the OWNER badge next to the header logo, based on whether
474
- // the current license is bound to a user (creator tier). Idempotent — safe
475
- // to call multiple times. Should be invoked after any state change that
476
- // affects userLicensed: initial check(), post-activation refresh(),
477
- // post-unbind, etc.
478
343
  function _applyOwnerBadge() {
479
344
  const badge = document.getElementById("header-owner-badge");
480
345
  if (!badge) return;
481
- badge.style.display = _userLicensed ? "" : "none";
346
+ badge.style.display = Brand.userLicensed ? "" : "none";
482
347
  }
483
348
 
484
- // Show or hide the "Get a Serial Number" helper row in the activation form,
485
- // driven by whether the brand vendor has published a homepage_url.
486
- // URL source: /api/brand → homepage_url field (set by the brand partner
487
- // via BrandConfig/distribution, never hardcoded in the client).
488
- // Rules:
489
- // - homepage_url present → row visible, button opens the URL
490
- // - homepage_url missing/empty → row hidden (no dead link for
491
- // unbranded or brand-without-homepage setups)
492
349
  function _applyGetSerialLink() {
493
350
  const row = document.getElementById("brand-get-serial");
494
351
  const btn = document.getElementById("btn-get-serial");
495
352
  if (!row || !btn) return;
496
- _fetchBrandInfo().then(info => {
353
+ Brand.fetchInfo().then(info => {
497
354
  const url = info && typeof info.homepage_url === "string" ? info.homepage_url.trim() : "";
498
355
  if (url) {
499
356
  row.style.display = "";
@@ -507,28 +364,21 @@ const Brand = (() => {
507
364
  });
508
365
  }
509
366
 
510
- // Refresh internal brand state by re-fetching /api/brand/status.
511
- // Unlike check() — which also drives the boot UI — refresh() only updates
512
- // the cached flags (_branded / _userLicensed / _testMode) so code that
513
- // reads them (e.g. Creator.updateSidebarVisibility) sees fresh values
514
- // after a license activation without a full page reload.
515
- async function refresh() {
516
- try {
517
- const res = await fetch("/api/brand/status");
518
- const data = await res.json();
519
- _testMode = !!data.test_mode;
520
- _userLicensed = !!data.user_licensed;
521
- _branded = !!data.branded;
522
- return data;
523
- } catch (_) {
524
- return null;
525
- }
367
+ function _subscribe() {
368
+ Brand.on("brand:status", _onStatus);
369
+ window.addEventListener("clacky-theme-change", () => {});
526
370
  }
527
371
 
528
- // Both themes use the same transparent A版 logo via CSS, no swap needed.
529
- window.addEventListener("clacky-theme-change", e => {
530
- // No-op
531
- });
372
+ const viewApi = {
373
+ applyBrandName: _applyBrandName,
374
+ applyHeaderLogo: _applyHeaderLogo,
375
+ applyOwnerBadge: _applyOwnerBadge,
376
+ applyGetSerialLink: _applyGetSerialLink,
377
+ goToLicenseInput: _goToLicenseInput,
378
+ };
532
379
 
533
- return { check, refresh, applyBrandName: _applyBrandName, applyHeaderLogo: _applyHeaderLogo, applyOwnerBadge: _applyOwnerBadge, applyGetSerialLink: _applyGetSerialLink, clearBrandCache: _clearBrandCache, goToLicenseInput: _goToLicenseInput, get userLicensed() { return _userLicensed; }, get branded() { return _branded; } };
380
+ return { init: _subscribe, api: viewApi };
534
381
  })();
382
+
383
+ Object.assign(Brand, BrandView.api);
384
+ BrandView.init();
@@ -0,0 +1,103 @@
1
+ // ── Channels · store — channel status data + Agent-driven actions ──────────
2
+ //
3
+ // Channels is an Agent-First panel: no config forms. The store fetches platform
4
+ // status and runs "open a session and send a /channel-manager command" actions.
5
+ // It never renders — it emits events the view reacts to.
6
+ //
7
+ // Internal bus always live; Clacky.ext.emit mirrors to the extension bus.
8
+ //
9
+ // `Channels` stays the single public facade.
10
+ //
11
+ // Depends on: Sessions, I18n, Clacky.ext.
12
+ // ───────────────────────────────────────────────────────────────────────────
13
+
14
+ const ChannelsStore = (() => {
15
+ let _channels = [];
16
+
17
+ const _listeners = {};
18
+
19
+ function _on(event, handler) {
20
+ (_listeners[event] ||= []).push(handler);
21
+ return () => {
22
+ const list = _listeners[event];
23
+ const i = list ? list.indexOf(handler) : -1;
24
+ if (i >= 0) list.splice(i, 1);
25
+ };
26
+ }
27
+
28
+ function _emit(event, payload) {
29
+ (_listeners[event] || []).forEach((h) => h(payload));
30
+ if (window.Clacky && Clacky.ext) Clacky.ext.emit(event, payload);
31
+ }
32
+
33
+ const state = {
34
+ get channels() { return _channels; },
35
+ };
36
+
37
+ // Create a session, register it, queue a command, and navigate to it.
38
+ async function _sendToAgent(command, sessionName) {
39
+ try {
40
+ const maxN = Sessions.all.reduce((max, s) => {
41
+ const m = s.name.match(/^Session (\d+)$/);
42
+ return m ? Math.max(max, parseInt(m[1], 10)) : max;
43
+ }, 0);
44
+ const name = sessionName || ("Session " + (maxN + 1));
45
+
46
+ const res = await fetch("/api/sessions", {
47
+ method: "POST",
48
+ headers: { "Content-Type": "application/json" },
49
+ body: JSON.stringify({ name, source: "setup" }),
50
+ });
51
+ const data = await res.json();
52
+ if (!res.ok) throw new Error(data.error || I18n.t("channels.sessionError"));
53
+ const session = data.session;
54
+ if (!session) throw new Error(I18n.t("channels.noSession"));
55
+
56
+ Sessions.add(session);
57
+ Sessions.renderList();
58
+ Sessions.setPendingMessage(session.id, command);
59
+ Sessions.select(session.id);
60
+ } catch (e) {
61
+ alert("Error: " + e.message);
62
+ }
63
+ }
64
+
65
+ const Channels = {
66
+ on: _on,
67
+ state,
68
+
69
+ /** Fetch channel status; emit so the view re-renders. */
70
+ async load({ silent = false } = {}) {
71
+ if (!silent) _emit("channels:loading");
72
+ try {
73
+ const res = await fetch("/api/channels");
74
+ const data = await res.json();
75
+ _channels = data.channels || [];
76
+ _emit("channels:changed", { channels: _channels });
77
+ } catch (e) {
78
+ _emit("channels:error", { message: e.message });
79
+ }
80
+ },
81
+
82
+ /** Toggle a channel's enabled flag; reload silently on success. */
83
+ async toggle(platform, desired) {
84
+ const res = await fetch(`/api/channels/${encodeURIComponent(platform)}/enabled`, {
85
+ method: "PATCH",
86
+ headers: { "Content-Type": "application/json" },
87
+ body: JSON.stringify({ enabled: desired }),
88
+ });
89
+ const data = await res.json();
90
+ if (!res.ok || !data.ok) throw new Error(data.error || "toggle failed");
91
+ await Channels.load({ silent: true });
92
+ },
93
+
94
+ /** Open a session and run the channel doctor / setup commands. */
95
+ runTest(command, name) { return _sendToAgent(command, name); },
96
+ openSetup(command, name) { return _sendToAgent(command, name); },
97
+ sendToAgent: _sendToAgent,
98
+ };
99
+
100
+ return Channels;
101
+ })();
102
+
103
+ const Channels = ChannelsStore;