openclacky 1.5.3 → 1.5.5
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.
- checksums.yaml +4 -4
- data/.dockerignore +13 -0
- data/CHANGELOG.md +57 -0
- data/Dockerfile +21 -1
- data/README.md +26 -7
- data/README_CN.md +26 -7
- data/README_JA.md +26 -7
- data/lib/clacky/agent/message_compressor.rb +4 -29
- data/lib/clacky/agent/message_compressor_helper.rb +17 -16
- data/lib/clacky/agent/session_serializer.rb +7 -0
- data/lib/clacky/agent.rb +14 -2
- data/lib/clacky/agent_config.rb +10 -5
- data/lib/clacky/agent_profile.rb +25 -3
- data/lib/clacky/brand_config.rb +32 -12
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +15 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +1 -5
- data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -2
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +43 -34
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +9 -3
- data/lib/clacky/default_extensions/git/ext.yml +3 -0
- data/lib/clacky/default_extensions/meeting/ext.yml +3 -0
- data/lib/clacky/default_extensions/time_machine/ext.yml +4 -0
- data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
- data/lib/clacky/default_skills/new/SKILL.md +17 -12
- data/lib/clacky/extension/api_extension.rb +5 -2
- data/lib/clacky/extension/loader.rb +1 -0
- data/lib/clacky/extension/packager.rb +60 -15
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/message_format/anthropic.rb +1 -1
- data/lib/clacky/message_format/bedrock.rb +1 -1
- data/lib/clacky/message_format/open_ai.rb +34 -3
- data/lib/clacky/providers.rb +77 -8
- data/lib/clacky/server/http_server.rb +495 -39
- data/lib/clacky/server/project_manager.rb +150 -0
- data/lib/clacky/server/server_master.rb +7 -0
- data/lib/clacky/server/session_registry.rb +14 -1
- data/lib/clacky/server/web_ui_controller.rb +12 -2
- data/lib/clacky/session_manager.rb +6 -4
- data/lib/clacky/tools/file_reader.rb +2 -1
- data/lib/clacky/tools/terminal.rb +30 -3
- data/lib/clacky/utils/model_pricing.rb +69 -18
- data/lib/clacky/utils/parser_manager.rb +115 -7
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1216 -318
- data/lib/clacky/web/app.js +27 -4
- data/lib/clacky/web/components/notify.js +139 -22
- data/lib/clacky/web/core/aside.js +21 -0
- data/lib/clacky/web/features/brand/store.js +3 -1
- data/lib/clacky/web/features/brand/view.js +29 -5
- data/lib/clacky/web/features/extensions/store.js +214 -9
- data/lib/clacky/web/features/extensions/view.js +156 -11
- data/lib/clacky/web/features/new-session/store.js +25 -3
- data/lib/clacky/web/features/new-session/view.js +339 -142
- data/lib/clacky/web/features/skills/view.js +1 -1
- data/lib/clacky/web/features/trash/store.js +23 -9
- data/lib/clacky/web/features/trash/view.js +51 -0
- data/lib/clacky/web/i18n.js +124 -15
- data/lib/clacky/web/index.html +105 -79
- data/lib/clacky/web/projects.js +1143 -0
- data/lib/clacky/web/sessions.js +320 -538
- data/lib/clacky/web/settings.js +138 -5
- data/lib/clacky/web/utils.js +1 -0
- data/lib/clacky/web/ws-dispatcher.js +98 -4
- data/scripts/build/src/install_system_deps.sh.cc +11 -3
- data/scripts/install_system_deps.sh +11 -3
- metadata +5 -2
- data/lib/clacky/default_parsers/xlsx_parser.rb +0 -121
|
@@ -17,19 +17,29 @@
|
|
|
17
17
|
// Depends on: I18n, global $ / escapeHtml helpers, Clacky.ext (core/ext.js)
|
|
18
18
|
// ───────────────────────────────────────────────────────────────────────────
|
|
19
19
|
|
|
20
|
+
const EXT_TAB_KEY = "clacky-ext-tab";
|
|
21
|
+
const EXT_SORT_KEY = "clacky-ext-sort";
|
|
22
|
+
const EXT_INSTALLED_KEY = "clacky-ext-installed-filter";
|
|
23
|
+
|
|
20
24
|
const ExtensionsStore = (() => {
|
|
21
25
|
// ── State (single source of truth) ─────────────────────────────────────
|
|
22
26
|
let _extensions = []; // [{ id, name, name_zh, description, ..., units }]
|
|
23
27
|
let _allExtensions = []; // unfiltered result from server
|
|
24
28
|
let _query = ""; // current search text
|
|
25
|
-
let _sort = "downloads";
|
|
29
|
+
let _sort = (function() { try { return localStorage.getItem(EXT_SORT_KEY) || "downloads"; } catch (_) { return "downloads"; } })();
|
|
26
30
|
let _filterInstalled = false; // when true, show only installed extensions
|
|
27
31
|
let _filterBrand = false; // when true, show only brand-private extensions
|
|
32
|
+
let _filterSystem = false; // when true, show system (builtin) extensions
|
|
33
|
+
let _filterMarket = true; // when true, show marketplace extensions
|
|
34
|
+
let _onlyInstalled = (function() { try { return localStorage.getItem(EXT_INSTALLED_KEY) === "installed"; } catch (_) { return false; } })();
|
|
28
35
|
let _loading = false;
|
|
29
36
|
let _error = null; // soft warning when the store is unreachable
|
|
30
37
|
let _detail = null; // currently opened extension detail, or null
|
|
31
38
|
let _detailLoading = false;
|
|
32
39
|
let _detailError = null;
|
|
40
|
+
let _installJob = null; // { stage, progress } while install is running, null otherwise
|
|
41
|
+
let _installError = null; // error message from a failed install attempt
|
|
42
|
+
let _pollToken = 0; // bumped by closeDetail() to abandon an in-flight poll loop
|
|
33
43
|
|
|
34
44
|
// ── Internal event bus ──────────────────────────────────────────────────
|
|
35
45
|
const _listeners = {}; // event => [handler]
|
|
@@ -55,11 +65,16 @@ const ExtensionsStore = (() => {
|
|
|
55
65
|
get sort() { return _sort; },
|
|
56
66
|
get filterInstalled() { return _filterInstalled; },
|
|
57
67
|
get filterBrand() { return _filterBrand; },
|
|
68
|
+
get filterSystem() { return _filterSystem; },
|
|
69
|
+
get filterMarket() { return _filterMarket; },
|
|
70
|
+
get onlyInstalled() { return _onlyInstalled; },
|
|
58
71
|
get loading() { return _loading; },
|
|
59
72
|
get error() { return _error; },
|
|
60
73
|
get detail() { return _detail; },
|
|
61
74
|
get detailLoading() { return _detailLoading; },
|
|
62
75
|
get detailError() { return _detailError; },
|
|
76
|
+
get installJob() { return _installJob; },
|
|
77
|
+
get installError() { return _installError; },
|
|
63
78
|
};
|
|
64
79
|
|
|
65
80
|
const Extensions = {
|
|
@@ -68,8 +83,10 @@ const ExtensionsStore = (() => {
|
|
|
68
83
|
|
|
69
84
|
/** Fetch the catalog from the server for the current query + sort. */
|
|
70
85
|
async load() {
|
|
86
|
+
if (_filterSystem) return Extensions.loadSystemExtensions();
|
|
71
87
|
if (_filterInstalled) return;
|
|
72
88
|
if (_filterBrand) return Extensions.loadBrandExtensions();
|
|
89
|
+
if (_filterMarket) return Extensions.loadMarketExtensions();
|
|
73
90
|
_loading = true;
|
|
74
91
|
_error = null;
|
|
75
92
|
_emit("extensions:loading");
|
|
@@ -96,26 +113,111 @@ const ExtensionsStore = (() => {
|
|
|
96
113
|
}
|
|
97
114
|
},
|
|
98
115
|
|
|
116
|
+
/** Load builtin system extensions from /api/store/extensions/system. */
|
|
117
|
+
async loadSystemExtensions() {
|
|
118
|
+
_loading = true;
|
|
119
|
+
_error = null;
|
|
120
|
+
_emit("extensions:loading");
|
|
121
|
+
try {
|
|
122
|
+
const res = await fetch("/api/store/extensions/system");
|
|
123
|
+
const data = await res.json();
|
|
124
|
+
const all = data.extensions || [];
|
|
125
|
+
_extensions = _query
|
|
126
|
+
? all.filter(e => [e.name, e.display_name, e.display_name_zh, e.description].some(
|
|
127
|
+
f => f && f.toLowerCase().includes(_query.toLowerCase())))
|
|
128
|
+
: all;
|
|
129
|
+
_error = null;
|
|
130
|
+
_loading = false;
|
|
131
|
+
_emit("extensions:changed", { extensions: _extensions, warning: _error });
|
|
132
|
+
} catch (e) {
|
|
133
|
+
console.error("[Extensions] loadSystemExtensions failed", e);
|
|
134
|
+
_extensions = [];
|
|
135
|
+
_error = I18n.t("extensions.loadFailed");
|
|
136
|
+
_loading = false;
|
|
137
|
+
_emit("extensions:error", { network: true });
|
|
138
|
+
} finally {
|
|
139
|
+
_loading = false;
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
/** Load marketplace extensions (all, not filtered by installed). */
|
|
144
|
+
async loadMarketExtensions() {
|
|
145
|
+
_loading = true;
|
|
146
|
+
_error = null;
|
|
147
|
+
_emit("extensions:loading");
|
|
148
|
+
try {
|
|
149
|
+
const params = new URLSearchParams();
|
|
150
|
+
if (_query) params.set("q", _query);
|
|
151
|
+
if (_sort) params.set("sort", _sort);
|
|
152
|
+
const qs = params.toString();
|
|
153
|
+
const res = await fetch("/api/store/extensions" + (qs ? "?" + qs : ""));
|
|
154
|
+
const data = await res.json();
|
|
155
|
+
_allExtensions = data.extensions || [];
|
|
156
|
+
_error = data.warning || null;
|
|
157
|
+
_loading = false;
|
|
158
|
+
_extensions = _onlyInstalled ? _allExtensions.filter(e => e.installed) : _allExtensions;
|
|
159
|
+
_emit("extensions:changed", { extensions: _extensions, warning: _error });
|
|
160
|
+
} catch (e) {
|
|
161
|
+
console.error("[Extensions] loadMarketExtensions failed", e);
|
|
162
|
+
_extensions = [];
|
|
163
|
+
_error = I18n.t("extensions.loadFailed");
|
|
164
|
+
_loading = false;
|
|
165
|
+
_emit("extensions:error", { network: true });
|
|
166
|
+
} finally {
|
|
167
|
+
_loading = false;
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
|
|
99
171
|
/** Set the search text and reload. */
|
|
100
172
|
setQuery(query) {
|
|
101
173
|
_query = (query || "").trim();
|
|
102
|
-
if (
|
|
174
|
+
if (_filterSystem) return Extensions.loadSystemExtensions();
|
|
103
175
|
if (_filterBrand) return Extensions.loadBrandExtensions();
|
|
104
|
-
return Extensions.
|
|
176
|
+
return Extensions.loadMarketExtensions();
|
|
105
177
|
},
|
|
106
178
|
|
|
107
179
|
/** Set the sort order and reload. */
|
|
108
180
|
setSort(sort) {
|
|
109
181
|
_sort = sort || "downloads";
|
|
182
|
+
try { localStorage.setItem(EXT_SORT_KEY, _sort); } catch (_) {}
|
|
183
|
+
if (_filterSystem) return Extensions.loadSystemExtensions();
|
|
184
|
+
if (_filterBrand) return Extensions.loadBrandExtensions();
|
|
185
|
+
return Extensions.loadMarketExtensions();
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
/** Toggle the global "only installed" overlay and reload the current tab. */
|
|
189
|
+
async setOnlyInstalled(only) {
|
|
190
|
+
_onlyInstalled = !!only;
|
|
191
|
+
try { localStorage.setItem(EXT_INSTALLED_KEY, _onlyInstalled ? "installed" : "all"); } catch (_) {}
|
|
192
|
+
if (_filterSystem) return Extensions.loadSystemExtensions();
|
|
110
193
|
if (_filterBrand) return Extensions.loadBrandExtensions();
|
|
111
|
-
|
|
112
|
-
|
|
194
|
+
return Extensions.loadMarketExtensions();
|
|
195
|
+
},
|
|
196
|
+
|
|
197
|
+
/** Switch to the system (builtin) extensions tab. */
|
|
198
|
+
async setFilterSystem() {
|
|
199
|
+
_filterSystem = true;
|
|
200
|
+
_filterMarket = false;
|
|
201
|
+
_filterInstalled = false;
|
|
202
|
+
_filterBrand = false;
|
|
203
|
+
return Extensions.loadSystemExtensions();
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
/** Switch to the marketplace extensions tab. */
|
|
207
|
+
async setFilterMarket() {
|
|
208
|
+
_filterMarket = true;
|
|
209
|
+
_filterSystem = false;
|
|
210
|
+
_filterInstalled = false;
|
|
211
|
+
_filterBrand = false;
|
|
212
|
+
return Extensions.loadMarketExtensions();
|
|
113
213
|
},
|
|
114
214
|
|
|
115
215
|
/** Toggle the "installed only" filter — fetches from local store when enabled. */
|
|
116
216
|
async setFilterInstalled(onlyInstalled) {
|
|
117
217
|
_filterInstalled = !!onlyInstalled;
|
|
118
218
|
_filterBrand = false;
|
|
219
|
+
_filterSystem = false;
|
|
220
|
+
_filterMarket = !onlyInstalled;
|
|
119
221
|
if (_filterInstalled) {
|
|
120
222
|
_loading = true;
|
|
121
223
|
_emit("extensions:loading");
|
|
@@ -143,10 +245,12 @@ const ExtensionsStore = (() => {
|
|
|
143
245
|
async setFilterBrand(onlyBrand) {
|
|
144
246
|
_filterBrand = !!onlyBrand;
|
|
145
247
|
_filterInstalled = false;
|
|
248
|
+
_filterSystem = false;
|
|
249
|
+
_filterMarket = !onlyBrand;
|
|
146
250
|
if (_filterBrand) {
|
|
147
251
|
return Extensions.loadBrandExtensions();
|
|
148
252
|
} else {
|
|
149
|
-
return Extensions.
|
|
253
|
+
return Extensions.loadMarketExtensions();
|
|
150
254
|
}
|
|
151
255
|
},
|
|
152
256
|
|
|
@@ -159,10 +263,11 @@ const ExtensionsStore = (() => {
|
|
|
159
263
|
const res = await fetch("/api/store/extensions/brand");
|
|
160
264
|
const data = await res.json();
|
|
161
265
|
const all = data.extensions || [];
|
|
162
|
-
|
|
266
|
+
const matched = _query
|
|
163
267
|
? all.filter(e => [e.name, e.name_zh, e.description].some(
|
|
164
268
|
f => f && f.toLowerCase().includes(_query.toLowerCase())))
|
|
165
269
|
: all;
|
|
270
|
+
_extensions = _onlyInstalled ? matched.filter(e => e.installed) : matched;
|
|
166
271
|
_error = data.warning || null;
|
|
167
272
|
_loading = false;
|
|
168
273
|
_emit("extensions:changed", { extensions: _extensions, warning: _error });
|
|
@@ -183,6 +288,14 @@ const ExtensionsStore = (() => {
|
|
|
183
288
|
_detail = null;
|
|
184
289
|
_detailLoading = true;
|
|
185
290
|
_detailError = null;
|
|
291
|
+
// Reopening/refreshing this detail (back button, switching panels and
|
|
292
|
+
// returning, etc.) must not resurrect a stale install/update job
|
|
293
|
+
// snapshot from a previous visit — bump the token so any poll loop
|
|
294
|
+
// still running for that old visit becomes a no-op, and clear the
|
|
295
|
+
// snapshot itself so the button starts from a clean slate.
|
|
296
|
+
_pollToken += 1;
|
|
297
|
+
_installJob = null;
|
|
298
|
+
_installError = null;
|
|
186
299
|
_emit("extensions:detail");
|
|
187
300
|
try {
|
|
188
301
|
const isBrand = source === "brand" || _filterBrand;
|
|
@@ -224,6 +337,16 @@ const ExtensionsStore = (() => {
|
|
|
224
337
|
_detail = null;
|
|
225
338
|
_detailLoading = false;
|
|
226
339
|
_detailError = null;
|
|
340
|
+
// Abandon any install/update poll still running for this detail — bumping
|
|
341
|
+
// the token makes the next loop iteration in _pollInstallStatus a no-op,
|
|
342
|
+
// so we stop hammering the status endpoint once nobody is watching.
|
|
343
|
+
// Also clear the job/error snapshot itself: since nothing will update it
|
|
344
|
+
// anymore, leaving a stale value around would make the button look frozen
|
|
345
|
+
// (e.g. stuck on "downloading 22%") the next time this extension's detail
|
|
346
|
+
// is reopened.
|
|
347
|
+
_pollToken += 1;
|
|
348
|
+
_installJob = null;
|
|
349
|
+
_installError = null;
|
|
227
350
|
_emit("extensions:detail");
|
|
228
351
|
},
|
|
229
352
|
|
|
@@ -239,6 +362,8 @@ const ExtensionsStore = (() => {
|
|
|
239
362
|
});
|
|
240
363
|
const data = await res.json();
|
|
241
364
|
if (!res.ok || !data.ok) throw new Error(data.error || "toggle failed");
|
|
365
|
+
const activeTab = _filterSystem ? "system" : _filterBrand ? "brand" : "market";
|
|
366
|
+
try { sessionStorage.setItem(EXT_TAB_KEY, activeTab); } catch (_) {}
|
|
242
367
|
location.reload();
|
|
243
368
|
} catch (e) {
|
|
244
369
|
console.error("[Extensions] setEnabled failed", e);
|
|
@@ -250,6 +375,11 @@ const ExtensionsStore = (() => {
|
|
|
250
375
|
/** Install a marketplace extension by fetching its download_url then posting to the local server. */
|
|
251
376
|
async install(id) {
|
|
252
377
|
if (!id) return;
|
|
378
|
+
_installError = null;
|
|
379
|
+
// Capture the current token so that if the user closes the detail panel
|
|
380
|
+
// mid-install, both the poll loop and this catch block notice and stop
|
|
381
|
+
// touching state for a view nobody is looking at anymore.
|
|
382
|
+
const token = _pollToken;
|
|
253
383
|
try {
|
|
254
384
|
// Prefer the already-loaded detail (avoids a second round-trip and correctly
|
|
255
385
|
// handles brand-private extensions whose detail was fetched with &source=brand).
|
|
@@ -265,6 +395,8 @@ const ExtensionsStore = (() => {
|
|
|
265
395
|
}
|
|
266
396
|
const download_url = ext.download_url;
|
|
267
397
|
if (!download_url) throw new Error("No download URL available");
|
|
398
|
+
|
|
399
|
+
// Start async install — server returns job_id immediately
|
|
268
400
|
const res = await fetch("/api/store/extension/install", {
|
|
269
401
|
method: "POST",
|
|
270
402
|
headers: { "Content-Type": "application/json" },
|
|
@@ -272,10 +404,15 @@ const ExtensionsStore = (() => {
|
|
|
272
404
|
});
|
|
273
405
|
const data = await res.json();
|
|
274
406
|
if (!res.ok || !data.ok) throw new Error(data.error || "install failed");
|
|
275
|
-
|
|
407
|
+
|
|
408
|
+
// Poll for progress until done.
|
|
409
|
+
const jobId = data.job_id;
|
|
410
|
+
await _pollInstallStatus(jobId, token);
|
|
276
411
|
} catch (e) {
|
|
277
412
|
console.error("[Extensions] install failed", e);
|
|
278
|
-
|
|
413
|
+
if (token !== _pollToken) return; // panel was closed — nothing to update
|
|
414
|
+
_installJob = null;
|
|
415
|
+
_installError = e.message;
|
|
279
416
|
_emit("extensions:detail");
|
|
280
417
|
}
|
|
281
418
|
},
|
|
@@ -303,8 +440,76 @@ const ExtensionsStore = (() => {
|
|
|
303
440
|
_emit("extensions:detail");
|
|
304
441
|
}
|
|
305
442
|
},
|
|
443
|
+
|
|
444
|
+
/** Create a new extension via ext-studio and return the session_id. */
|
|
445
|
+
async createNew(idea) {
|
|
446
|
+
const res = await fetch("/api/ext/ext-studio/develop", {
|
|
447
|
+
method: "POST",
|
|
448
|
+
headers: { "Content-Type": "application/json" },
|
|
449
|
+
body: JSON.stringify({ idea: idea || null }),
|
|
450
|
+
});
|
|
451
|
+
return res.json();
|
|
452
|
+
},
|
|
453
|
+
|
|
306
454
|
};
|
|
307
455
|
|
|
456
|
+
// Poll /api/store/extension/install/status until done, updating _installJob each tick.
|
|
457
|
+
// `token` is the _pollToken snapshot taken when the poll started; if the
|
|
458
|
+
// user closes the detail panel, closeDetail() bumps _pollToken and this
|
|
459
|
+
// loop notices on its next tick and quietly stops (no more state writes,
|
|
460
|
+
// no more network requests).
|
|
461
|
+
const POLL_MAX_DURATION_MS = 30 * 60 * 1000; // give up after 30 minutes total
|
|
462
|
+
|
|
463
|
+
async function _pollInstallStatus(jobId, token) {
|
|
464
|
+
const INTERVAL = 1000;
|
|
465
|
+
const startedAt = Date.now();
|
|
466
|
+
let first = true;
|
|
467
|
+
while (true) {
|
|
468
|
+
// Check immediately on the first iteration — the job already exists
|
|
469
|
+
// server-side by the time we get here, so there's no reason to sit on
|
|
470
|
+
// the initial "downloading" placeholder for a full INTERVAL before
|
|
471
|
+
// fetching real progress. Subsequent iterations still wait normally.
|
|
472
|
+
if (first) {
|
|
473
|
+
first = false;
|
|
474
|
+
} else {
|
|
475
|
+
await new Promise(r => setTimeout(r, INTERVAL));
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Panel closed mid-poll — abandon silently, nothing left to update.
|
|
479
|
+
if (token !== _pollToken) return;
|
|
480
|
+
|
|
481
|
+
if (Date.now() - startedAt > POLL_MAX_DURATION_MS) {
|
|
482
|
+
throw new Error(I18n.t("extensions.action.installTimeout"));
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
let data;
|
|
486
|
+
try {
|
|
487
|
+
const r = await fetch("/api/store/extension/install/status?job_id=" + encodeURIComponent(jobId));
|
|
488
|
+
data = await r.json();
|
|
489
|
+
} catch (e) {
|
|
490
|
+
// network hiccup — keep polling
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
if (!data.ok) {
|
|
494
|
+
throw new Error(data.error || "install failed");
|
|
495
|
+
}
|
|
496
|
+
if (token !== _pollToken) return; // closed while the fetch was in flight
|
|
497
|
+
|
|
498
|
+
_installJob = { stage: data.stage, progress: data.progress };
|
|
499
|
+
_emit("extensions:detail");
|
|
500
|
+
|
|
501
|
+
if (data.stage === "done") {
|
|
502
|
+
_installJob = null;
|
|
503
|
+
location.reload();
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (data.stage === "error") {
|
|
507
|
+
_installJob = null;
|
|
508
|
+
throw new Error(data.error || "install failed");
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
308
513
|
return Extensions;
|
|
309
514
|
})();
|
|
310
515
|
|
|
@@ -102,7 +102,7 @@ const ExtensionsView = (() => {
|
|
|
102
102
|
const versionHtml = ext.version
|
|
103
103
|
? `<span class="extension-version">v${escapeHtml(String(ext.version))}</span>` : "";
|
|
104
104
|
const canUpdate = ext.installed_version && ext.version && ext.installed_version !== ext.version;
|
|
105
|
-
const installedHtml = ext.installed
|
|
105
|
+
const installedHtml = (ext.installed && ext.layer !== "builtin")
|
|
106
106
|
? `<span class="extension-installed">${escapeHtml(I18n.t("extensions.installed"))}</span>` : "";
|
|
107
107
|
const updatableHtml = canUpdate
|
|
108
108
|
? `<span class="extension-updatable">${escapeHtml(I18n.t("extensions.updatable"))}</span>` : "";
|
|
@@ -119,8 +119,12 @@ const ExtensionsView = (() => {
|
|
|
119
119
|
const installsHtml = ext.download_count > 0
|
|
120
120
|
? `<span class="extension-installs">${escapeHtml(String(ext.download_count))} ${escapeHtml(I18n.t("extensions.installs"))}</span>` : "";
|
|
121
121
|
|
|
122
|
+
const isSystem = ext.layer === "builtin";
|
|
123
|
+
const slug = ext.slug || (ext.id != null ? String(ext.id) : (ext.name || ""));
|
|
124
|
+
const toggleHtml = isSystem ? _renderCardToggle(slug, ext.disabled) : "";
|
|
125
|
+
|
|
122
126
|
const card = document.createElement("div");
|
|
123
|
-
card.className = "extension-card extension-card-clickable";
|
|
127
|
+
card.className = isSystem ? "extension-card extension-card-system" : "extension-card extension-card-clickable";
|
|
124
128
|
card.dataset.extId = ext.id != null ? String(ext.id) : (ext.name || "");
|
|
125
129
|
card.dataset.extOrigin = ext.origin || "";
|
|
126
130
|
card.innerHTML = `
|
|
@@ -140,10 +144,59 @@ const ExtensionsView = (() => {
|
|
|
140
144
|
${description ? `<div class="extension-desc">${escapeHtml(description)}</div>` : ""}
|
|
141
145
|
${homepageHtml ? `<div class="extension-meta">${homepageHtml}</div>` : ""}
|
|
142
146
|
</div>
|
|
147
|
+
${toggleHtml}
|
|
143
148
|
</div>`;
|
|
149
|
+
|
|
150
|
+
if (isSystem) {
|
|
151
|
+
const toggle = card.querySelector(".ext-card-toggle");
|
|
152
|
+
if (toggle) {
|
|
153
|
+
toggle.addEventListener("click", (e) => {
|
|
154
|
+
e.stopPropagation();
|
|
155
|
+
const id = toggle.dataset.extToggle;
|
|
156
|
+
const enabled = toggle.dataset.extEnabled === "1";
|
|
157
|
+
Extensions.setEnabled(id, !enabled);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
card.addEventListener("click", (e) => {
|
|
162
|
+
if (e.target.closest("a")) return;
|
|
163
|
+
const router = window.Clacky && window.Clacky.Router;
|
|
164
|
+
const isBrand = card.dataset.extOrigin === "self" || Extensions.state.filterBrand;
|
|
165
|
+
const source = isBrand ? "brand" : null;
|
|
166
|
+
if (router) router.navigate("extensions", { detailId: card.dataset.extId, source });
|
|
167
|
+
else Extensions.loadDetail(card.dataset.extId, source);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
144
171
|
return card;
|
|
145
172
|
}
|
|
146
173
|
|
|
174
|
+
function _renderCardToggle(id, disabled) {
|
|
175
|
+
const checked = !disabled;
|
|
176
|
+
return `
|
|
177
|
+
<button type="button" class="ext-card-toggle${checked ? " ext-card-toggle-on" : ""}"
|
|
178
|
+
data-ext-toggle="${escapeHtml(id)}" data-ext-enabled="${checked ? "1" : "0"}"
|
|
179
|
+
title="${escapeHtml(I18n.t(checked ? "extensions.action.disable" : "extensions.action.enable"))}">
|
|
180
|
+
<span class="ext-card-toggle-thumb"></span>
|
|
181
|
+
</button>`;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Returns a human-readable button label for a given install job state.
|
|
185
|
+
function _installJobLabel(job) {
|
|
186
|
+
if (!job) return I18n.t("extensions.action.install");
|
|
187
|
+
switch (job.stage) {
|
|
188
|
+
case "downloading": {
|
|
189
|
+
// Suppress "0%" — it's shown for a very brief instant right after the
|
|
190
|
+
// download starts (or never, for fast installs) and just looks like a stall.
|
|
191
|
+
const pct = (job.progress != null && job.progress > 0) ? ` ${job.progress}%` : "...";
|
|
192
|
+
return I18n.t("extensions.action.downloading") + pct;
|
|
193
|
+
}
|
|
194
|
+
case "extracting": return I18n.t("extensions.action.extracting") + "...";
|
|
195
|
+
case "verifying": return I18n.t("extensions.action.verifying") + "...";
|
|
196
|
+
default: return I18n.t("extensions.action.installing");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
147
200
|
function _renderDetail() {
|
|
148
201
|
const panel = $("extensions-detail");
|
|
149
202
|
const body = $("extensions-body");
|
|
@@ -180,6 +233,33 @@ const ExtensionsView = (() => {
|
|
|
180
233
|
|
|
181
234
|
panel.innerHTML = _detailShell(_detailContent(st.detail));
|
|
182
235
|
_wireDetail();
|
|
236
|
+
|
|
237
|
+
// If an install/update job is in-progress, update the button text to reflect current stage.
|
|
238
|
+
// Note: both the "install" and "update" buttons drive the same job/poll flow, so we must
|
|
239
|
+
// look for whichever one is actually present in the freshly-rendered DOM.
|
|
240
|
+
const job = st.installJob;
|
|
241
|
+
const actionBtn = document.querySelector("[data-ext-install], [data-ext-update]");
|
|
242
|
+
if (job) {
|
|
243
|
+
if (actionBtn) {
|
|
244
|
+
actionBtn.disabled = true;
|
|
245
|
+
actionBtn.textContent = _installJobLabel(job);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// If the last install/update attempt failed, show an inline error banner
|
|
250
|
+
if (st.installError) {
|
|
251
|
+
if (actionBtn) {
|
|
252
|
+
actionBtn.disabled = false;
|
|
253
|
+
// Insert error message right after the button if not already there
|
|
254
|
+
let errEl = panel.querySelector(".extension-install-error");
|
|
255
|
+
if (!errEl) {
|
|
256
|
+
errEl = document.createElement("p");
|
|
257
|
+
errEl.className = "extension-install-error";
|
|
258
|
+
actionBtn.insertAdjacentElement("afterend", errEl);
|
|
259
|
+
}
|
|
260
|
+
errEl.textContent = st.installError;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
183
263
|
}
|
|
184
264
|
|
|
185
265
|
function _detailShell(inner) {
|
|
@@ -223,7 +303,11 @@ const ExtensionsView = (() => {
|
|
|
223
303
|
installBtn.addEventListener("click", () => {
|
|
224
304
|
const id = installBtn.getAttribute("data-ext-install");
|
|
225
305
|
installBtn.disabled = true;
|
|
226
|
-
|
|
306
|
+
// Show a "downloading" placeholder rather than a generic "installing" —
|
|
307
|
+
// the next real state is always the download starting, so this avoids
|
|
308
|
+
// a jarring "installing → downloading → installing" flash before any
|
|
309
|
+
// real progress data has arrived.
|
|
310
|
+
installBtn.textContent = _installJobLabel({ stage: "downloading", progress: null });
|
|
227
311
|
Extensions.install(id);
|
|
228
312
|
});
|
|
229
313
|
}
|
|
@@ -233,7 +317,7 @@ const ExtensionsView = (() => {
|
|
|
233
317
|
updateBtn.addEventListener("click", () => {
|
|
234
318
|
const id = updateBtn.getAttribute("data-ext-update");
|
|
235
319
|
updateBtn.disabled = true;
|
|
236
|
-
updateBtn.textContent =
|
|
320
|
+
updateBtn.textContent = _installJobLabel({ stage: "downloading", progress: null });
|
|
237
321
|
Extensions.update(id);
|
|
238
322
|
});
|
|
239
323
|
}
|
|
@@ -327,7 +411,7 @@ const ExtensionsView = (() => {
|
|
|
327
411
|
}
|
|
328
412
|
return "";
|
|
329
413
|
}
|
|
330
|
-
const slug = ext.
|
|
414
|
+
const slug = ext.slug || id;
|
|
331
415
|
const toggleKey = ext.disabled ? "extensions.action.enable" : "extensions.action.disable";
|
|
332
416
|
const toggleCls = ext.disabled ? "extension-action-enable" : "extension-action-disable";
|
|
333
417
|
const disabledBadge = ext.disabled
|
|
@@ -445,16 +529,36 @@ const ExtensionsView = (() => {
|
|
|
445
529
|
sortSelect.addEventListener("change", () => Extensions.setSort(sortSelect.value));
|
|
446
530
|
}
|
|
447
531
|
|
|
532
|
+
const installedFilter = $("extensions-installed-filter");
|
|
533
|
+
if (installedFilter) {
|
|
534
|
+
installedFilter.value = ExtensionsStore.state.onlyInstalled ? "installed" : "all";
|
|
535
|
+
installedFilter.addEventListener("change", () => {
|
|
536
|
+
Extensions.setOnlyInstalled(installedFilter.value === "installed");
|
|
537
|
+
});
|
|
538
|
+
}
|
|
448
539
|
document.querySelectorAll(".extensions-filter-tab").forEach(btn => {
|
|
449
540
|
btn.addEventListener("click", () => {
|
|
450
541
|
document.querySelectorAll(".extensions-filter-tab").forEach(b => b.classList.remove("extensions-filter-tab-active"));
|
|
451
542
|
btn.classList.add("extensions-filter-tab-active");
|
|
452
543
|
const filter = btn.dataset.filter;
|
|
453
|
-
|
|
454
|
-
|
|
544
|
+
const sortEl = $("extensions-sort");
|
|
545
|
+
const installedFilterEl = $("extensions-installed-filter");
|
|
546
|
+
if (filter === "system") {
|
|
547
|
+
if (sortEl) sortEl.style.display = "none";
|
|
548
|
+
if (installedFilterEl) installedFilterEl.style.display = "none";
|
|
549
|
+
Extensions.setFilterSystem();
|
|
455
550
|
} else if (filter === "brand") {
|
|
551
|
+
if (sortEl) sortEl.style.display = "";
|
|
552
|
+
if (installedFilterEl) installedFilterEl.style.display = "";
|
|
456
553
|
Extensions.setFilterBrand(true);
|
|
554
|
+
} else if (filter === "market") {
|
|
555
|
+
if (sortEl) sortEl.style.display = "";
|
|
556
|
+
if (installedFilterEl) installedFilterEl.style.display = "";
|
|
557
|
+
Extensions.setFilterMarket();
|
|
558
|
+
} else if (filter === "installed") {
|
|
559
|
+
Extensions.setFilterInstalled(true);
|
|
457
560
|
} else {
|
|
561
|
+
if (sortEl) sortEl.style.display = "";
|
|
458
562
|
Extensions.setFilterInstalled(false);
|
|
459
563
|
}
|
|
460
564
|
});
|
|
@@ -484,8 +588,24 @@ const ExtensionsView = (() => {
|
|
|
484
588
|
});
|
|
485
589
|
}
|
|
486
590
|
|
|
591
|
+
const newBtn = $("extensions-new-btn");
|
|
592
|
+
if (newBtn) {
|
|
593
|
+
newBtn.addEventListener("click", () => {
|
|
594
|
+
const seed = I18n.t("extensions.newExt.seed");
|
|
595
|
+
ExtensionsStore.createNew(seed || null)
|
|
596
|
+
.then((data) => {
|
|
597
|
+
if (data && data.session_id) {
|
|
598
|
+
const router = window.Clacky && window.Clacky.Router;
|
|
599
|
+
if (router) router.navigate("session", { id: data.session_id });
|
|
600
|
+
}
|
|
601
|
+
})
|
|
602
|
+
.catch(() => {});
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
487
606
|
document.addEventListener("langchange", () => {
|
|
488
607
|
if (input) input.placeholder = I18n.t("extensions.searchPlaceholder");
|
|
608
|
+
if (newBtn) newBtn.textContent = I18n.t("extensions.newExt.btn");
|
|
489
609
|
_renderList();
|
|
490
610
|
_renderDetail();
|
|
491
611
|
});
|
|
@@ -503,17 +623,42 @@ const ExtensionsView = (() => {
|
|
|
503
623
|
const viewApi = {
|
|
504
624
|
async onPanelShow(opts) {
|
|
505
625
|
_wireDom();
|
|
506
|
-
// Apply brand tab visibility based on current status (fast path),
|
|
507
|
-
// then also refresh in background so it stays up-to-date.
|
|
508
626
|
_applyBrandTab();
|
|
509
627
|
if (window.Skills && Skills.refreshBrandStatus) Skills.refreshBrandStatus();
|
|
628
|
+
|
|
629
|
+
const sortEl = $("extensions-sort");
|
|
510
630
|
const detailId = opts && opts.detailId;
|
|
511
631
|
if (detailId) {
|
|
512
|
-
|
|
632
|
+
if (sortEl) sortEl.style.display = "";
|
|
633
|
+
Extensions.loadMarketExtensions();
|
|
513
634
|
Extensions.loadDetail(detailId, opts && opts.source);
|
|
514
635
|
} else {
|
|
515
636
|
Extensions.closeDetail();
|
|
516
|
-
|
|
637
|
+
|
|
638
|
+
let restoredTab = null;
|
|
639
|
+
try { restoredTab = sessionStorage.getItem(EXT_TAB_KEY); } catch (_) {}
|
|
640
|
+
if (restoredTab) {
|
|
641
|
+
try { sessionStorage.removeItem(EXT_TAB_KEY); } catch (_) {}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const tab = restoredTab || "market";
|
|
645
|
+
document.querySelectorAll(".extensions-filter-tab").forEach(b => {
|
|
646
|
+
b.classList.toggle("extensions-filter-tab-active", b.dataset.filter === tab);
|
|
647
|
+
});
|
|
648
|
+
const installedFilterEl = $("extensions-installed-filter");
|
|
649
|
+
if (tab === "system") {
|
|
650
|
+
if (sortEl) sortEl.style.display = "none";
|
|
651
|
+
if (installedFilterEl) installedFilterEl.style.display = "none";
|
|
652
|
+
Extensions.setFilterSystem();
|
|
653
|
+
} else if (tab === "brand") {
|
|
654
|
+
if (sortEl) sortEl.style.display = "";
|
|
655
|
+
if (installedFilterEl) installedFilterEl.style.display = "";
|
|
656
|
+
Extensions.setFilterBrand(true);
|
|
657
|
+
} else {
|
|
658
|
+
if (sortEl) sortEl.style.display = "";
|
|
659
|
+
if (installedFilterEl) installedFilterEl.style.display = "";
|
|
660
|
+
Extensions.setFilterMarket();
|
|
661
|
+
}
|
|
517
662
|
}
|
|
518
663
|
},
|
|
519
664
|
};
|