openclacky 1.3.8 → 1.3.10
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/CHANGELOG.md +40 -0
- data/lib/clacky/brand_config.rb +2 -1
- data/lib/clacky/default_extensions/coding/ext.yml +0 -1
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +0 -1
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -4
- data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -3
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +221 -114
- data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +1 -2
- data/lib/clacky/default_extensions/general/ext.yml +0 -1
- data/lib/clacky/default_extensions/git/ext.yml +0 -1
- data/lib/clacky/default_extensions/meeting/ext.yml +0 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +0 -1
- data/lib/clacky/extension/api_extension.rb +11 -2
- data/lib/clacky/extension/dispatcher.rb +3 -2
- data/lib/clacky/extension/packager.rb +0 -6
- data/lib/clacky/locales/en.rb +1 -1
- data/lib/clacky/locales/zh.rb +1 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/server/http_server.rb +115 -7
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +123 -32
- data/lib/clacky/web/features/extensions/store.js +62 -9
- data/lib/clacky/web/features/extensions/view.js +54 -17
- data/lib/clacky/web/features/new-session/view.js +1 -1
- data/lib/clacky/web/i18n.js +16 -4
- data/lib/clacky/web/index.html +12 -6
- data/lib/clacky/web/sessions.js +32 -2
- metadata +1 -1
|
@@ -19,9 +19,11 @@
|
|
|
19
19
|
|
|
20
20
|
const ExtensionsStore = (() => {
|
|
21
21
|
// ── State (single source of truth) ─────────────────────────────────────
|
|
22
|
-
let _extensions
|
|
23
|
-
let
|
|
24
|
-
let
|
|
22
|
+
let _extensions = []; // [{ id, name, name_zh, description, ..., units }]
|
|
23
|
+
let _allExtensions = []; // unfiltered result from server
|
|
24
|
+
let _query = ""; // current search text
|
|
25
|
+
let _sort = "newest"; // "newest" | "updated" | "downloads"
|
|
26
|
+
let _filterInstalled = false; // when true, show only installed extensions
|
|
25
27
|
let _loading = false;
|
|
26
28
|
let _error = null; // soft warning when the store is unreachable
|
|
27
29
|
let _detail = null; // currently opened extension detail, or null
|
|
@@ -50,6 +52,7 @@ const ExtensionsStore = (() => {
|
|
|
50
52
|
get extensions() { return _extensions; },
|
|
51
53
|
get query() { return _query; },
|
|
52
54
|
get sort() { return _sort; },
|
|
55
|
+
get filterInstalled() { return _filterInstalled; },
|
|
53
56
|
get loading() { return _loading; },
|
|
54
57
|
get error() { return _error; },
|
|
55
58
|
get detail() { return _detail; },
|
|
@@ -73,10 +76,15 @@ const ExtensionsStore = (() => {
|
|
|
73
76
|
const qs = params.toString();
|
|
74
77
|
const res = await fetch("/api/store/extensions" + (qs ? "?" + qs : ""));
|
|
75
78
|
const data = await res.json();
|
|
76
|
-
|
|
79
|
+
_allExtensions = data.extensions || [];
|
|
77
80
|
_error = data.warning || null;
|
|
78
81
|
_loading = false;
|
|
79
|
-
|
|
82
|
+
if (_filterInstalled) {
|
|
83
|
+
await Extensions.setFilterInstalled(true);
|
|
84
|
+
} else {
|
|
85
|
+
_extensions = _allExtensions;
|
|
86
|
+
_emit("extensions:changed", { extensions: _extensions, warning: _error });
|
|
87
|
+
}
|
|
80
88
|
} catch (e) {
|
|
81
89
|
console.error("[Extensions] load failed", e);
|
|
82
90
|
_extensions = [];
|
|
@@ -100,6 +108,28 @@ const ExtensionsStore = (() => {
|
|
|
100
108
|
return Extensions.load();
|
|
101
109
|
},
|
|
102
110
|
|
|
111
|
+
/** Toggle the "installed only" filter — fetches from local store when enabled. */
|
|
112
|
+
async setFilterInstalled(onlyInstalled) {
|
|
113
|
+
_filterInstalled = !!onlyInstalled;
|
|
114
|
+
if (_filterInstalled) {
|
|
115
|
+
_loading = true;
|
|
116
|
+
_emit("extensions:loading");
|
|
117
|
+
try {
|
|
118
|
+
const res = await fetch("/api/store/extensions/installed");
|
|
119
|
+
const data = await res.json();
|
|
120
|
+
_extensions = data.extensions || [];
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.error("[Extensions] load installed failed", e);
|
|
123
|
+
_extensions = [];
|
|
124
|
+
} finally {
|
|
125
|
+
_loading = false;
|
|
126
|
+
}
|
|
127
|
+
_emit("extensions:changed", { extensions: _extensions, warning: _error });
|
|
128
|
+
} else {
|
|
129
|
+
return Extensions.load();
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
|
|
103
133
|
/** Open the detail view for one extension (fetches contributes + versions). */
|
|
104
134
|
async loadDetail(id) {
|
|
105
135
|
if (!id) return;
|
|
@@ -147,8 +177,7 @@ const ExtensionsStore = (() => {
|
|
|
147
177
|
});
|
|
148
178
|
const data = await res.json();
|
|
149
179
|
if (!res.ok || !data.ok) throw new Error(data.error || "toggle failed");
|
|
150
|
-
|
|
151
|
-
Extensions.load();
|
|
180
|
+
location.reload();
|
|
152
181
|
} catch (e) {
|
|
153
182
|
console.error("[Extensions] setEnabled failed", e);
|
|
154
183
|
_detailError = e.message;
|
|
@@ -156,6 +185,31 @@ const ExtensionsStore = (() => {
|
|
|
156
185
|
}
|
|
157
186
|
},
|
|
158
187
|
|
|
188
|
+
/** Install a marketplace extension by fetching its download_url then posting to the local server. */
|
|
189
|
+
async install(id) {
|
|
190
|
+
if (!id) return;
|
|
191
|
+
try {
|
|
192
|
+
const detailRes = await fetch("/api/store/extension?id=" + encodeURIComponent(id));
|
|
193
|
+
const detailData = await detailRes.json();
|
|
194
|
+
if (!detailRes.ok || !detailData.ok) throw new Error(detailData.error || "fetch detail failed");
|
|
195
|
+
const ext = detailData.extension;
|
|
196
|
+
const download_url = ext.download_url;
|
|
197
|
+
if (!download_url) throw new Error("No download URL available");
|
|
198
|
+
const res = await fetch("/api/store/extension/install", {
|
|
199
|
+
method: "POST",
|
|
200
|
+
headers: { "Content-Type": "application/json" },
|
|
201
|
+
body: JSON.stringify({ download_url, name: ext.name }),
|
|
202
|
+
});
|
|
203
|
+
const data = await res.json();
|
|
204
|
+
if (!res.ok || !data.ok) throw new Error(data.error || "install failed");
|
|
205
|
+
location.reload();
|
|
206
|
+
} catch (e) {
|
|
207
|
+
console.error("[Extensions] install failed", e);
|
|
208
|
+
_detailError = e.message;
|
|
209
|
+
_emit("extensions:detail");
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
|
|
159
213
|
/** Remove an installed extension, then return to the list. */
|
|
160
214
|
async uninstall(id) {
|
|
161
215
|
if (!id) return;
|
|
@@ -167,8 +221,7 @@ const ExtensionsStore = (() => {
|
|
|
167
221
|
});
|
|
168
222
|
const data = await res.json();
|
|
169
223
|
if (!res.ok || !data.ok) throw new Error(data.error || "uninstall failed");
|
|
170
|
-
|
|
171
|
-
Extensions.load();
|
|
224
|
+
location.reload();
|
|
172
225
|
} catch (e) {
|
|
173
226
|
console.error("[Extensions] uninstall failed", e);
|
|
174
227
|
_detailError = e.message;
|
|
@@ -89,12 +89,16 @@ const ExtensionsView = (() => {
|
|
|
89
89
|
? `<span class="extension-version">v${escapeHtml(String(ext.version))}</span>` : "";
|
|
90
90
|
const installedHtml = ext.installed
|
|
91
91
|
? `<span class="extension-installed">${escapeHtml(I18n.t("extensions.installed"))}</span>` : "";
|
|
92
|
+
const unlistedHtml = (ext.unlisted && ext.installed)
|
|
93
|
+
? `<span class="extension-unlisted">${escapeHtml(I18n.t("extensions.unlisted"))}</span>` : "";
|
|
92
94
|
const unitsText = _formatUnits(ext.units);
|
|
93
95
|
const unitsHtml = unitsText
|
|
94
96
|
? `<span class="extension-units">${escapeHtml(unitsText)}</span>` : "";
|
|
95
97
|
const homepageHtml = ext.homepage
|
|
96
98
|
? `<a class="extension-homepage" href="${escapeHtml(ext.homepage)}" target="_blank" rel="noopener noreferrer">${I18n.t("extensions.homepage")}</a>`
|
|
97
99
|
: "";
|
|
100
|
+
const authorHtml = ext.author
|
|
101
|
+
? `<span class="extension-author">· ${escapeHtml(ext.author)}</span>` : "";
|
|
98
102
|
|
|
99
103
|
const card = document.createElement("div");
|
|
100
104
|
card.className = "extension-card extension-card-clickable";
|
|
@@ -105,14 +109,14 @@ const ExtensionsView = (() => {
|
|
|
105
109
|
<div class="extension-info">
|
|
106
110
|
<div class="extension-title">
|
|
107
111
|
<span class="extension-name">${escapeHtml(name)}</span>
|
|
112
|
+
${authorHtml}
|
|
108
113
|
${versionHtml}
|
|
109
114
|
${installedHtml}
|
|
115
|
+
${unlistedHtml}
|
|
110
116
|
${unitsHtml}
|
|
111
117
|
</div>
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
${homepageHtml}
|
|
115
|
-
</div>
|
|
118
|
+
${description ? `<div class="extension-desc">${escapeHtml(description)}</div>` : ""}
|
|
119
|
+
${homepageHtml ? `<div class="extension-meta">${homepageHtml}</div>` : ""}
|
|
116
120
|
</div>
|
|
117
121
|
</div>`;
|
|
118
122
|
return card;
|
|
@@ -147,9 +151,8 @@ const ExtensionsView = (() => {
|
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
if (st.detailError) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
_wireDetail();
|
|
154
|
+
console.warn("[Extensions] detail error, navigating back:", st.detailError);
|
|
155
|
+
_backToList();
|
|
153
156
|
return;
|
|
154
157
|
}
|
|
155
158
|
|
|
@@ -189,6 +192,16 @@ const ExtensionsView = (() => {
|
|
|
189
192
|
if (ok) Extensions.uninstall(id);
|
|
190
193
|
});
|
|
191
194
|
}
|
|
195
|
+
|
|
196
|
+
const installBtn = document.querySelector("[data-ext-install]");
|
|
197
|
+
if (installBtn) {
|
|
198
|
+
installBtn.addEventListener("click", () => {
|
|
199
|
+
const id = installBtn.getAttribute("data-ext-install");
|
|
200
|
+
installBtn.disabled = true;
|
|
201
|
+
installBtn.textContent = I18n.t("extensions.action.installing");
|
|
202
|
+
Extensions.install(id);
|
|
203
|
+
});
|
|
204
|
+
}
|
|
192
205
|
}
|
|
193
206
|
|
|
194
207
|
function _backToList() {
|
|
@@ -209,12 +222,16 @@ const ExtensionsView = (() => {
|
|
|
209
222
|
? `<span class="extension-version">v${escapeHtml(String(ext.version))}</span>` : "";
|
|
210
223
|
const installedHtml = ext.installed
|
|
211
224
|
? `<span class="extension-installed">${escapeHtml(I18n.t("extensions.installed"))}</span>` : "";
|
|
225
|
+
const unlistedHtml = ext.unlisted
|
|
226
|
+
? `<span class="extension-unlisted">${escapeHtml(I18n.t("extensions.unlisted"))}</span>` : "";
|
|
212
227
|
const unitsText = _formatUnits(ext.units);
|
|
213
228
|
const unitsHtml = unitsText
|
|
214
229
|
? `<span class="extension-units">${escapeHtml(unitsText)}</span>` : "";
|
|
215
230
|
const homepageHtml = ext.homepage
|
|
216
231
|
? `<a class="extension-homepage" href="${escapeHtml(ext.homepage)}" target="_blank" rel="noopener noreferrer">${I18n.t("extensions.homepage")}</a>`
|
|
217
232
|
: "";
|
|
233
|
+
const authorHtml = ext.author
|
|
234
|
+
? `<span class="extension-author">· ${escapeHtml(ext.author)}</span>` : "";
|
|
218
235
|
|
|
219
236
|
return `
|
|
220
237
|
<div class="extension-detail-hero">
|
|
@@ -222,14 +239,14 @@ const ExtensionsView = (() => {
|
|
|
222
239
|
<div class="extension-detail-heading">
|
|
223
240
|
<div class="extension-title">
|
|
224
241
|
<span class="extension-name extension-name-lg">${escapeHtml(name)}</span>
|
|
242
|
+
${authorHtml}
|
|
225
243
|
${versionHtml}
|
|
226
244
|
${installedHtml}
|
|
245
|
+
${unlistedHtml}
|
|
227
246
|
${unitsHtml}
|
|
228
247
|
</div>
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
${homepageHtml}
|
|
232
|
-
</div>
|
|
248
|
+
${description ? `<div class="extension-desc extension-desc-detail">${escapeHtml(description)}</div>` : ""}
|
|
249
|
+
${homepageHtml ? `<div class="extension-meta">${homepageHtml}</div>` : ""}
|
|
233
250
|
${_renderActions(ext)}
|
|
234
251
|
</div>
|
|
235
252
|
</div>
|
|
@@ -240,8 +257,17 @@ const ExtensionsView = (() => {
|
|
|
240
257
|
// Manage buttons for a locally installed extension: enable/disable toggle
|
|
241
258
|
// (always available when installed) plus remove (installed layer only).
|
|
242
259
|
function _renderActions(ext) {
|
|
243
|
-
|
|
244
|
-
|
|
260
|
+
const id = ext.id != null ? String(ext.id) : (ext.name || ext.slug || "");
|
|
261
|
+
if (!ext.installed) {
|
|
262
|
+
if (ext.origin === "marketplace" && ext.download_url) {
|
|
263
|
+
return `
|
|
264
|
+
<div class="extension-detail-actions">
|
|
265
|
+
<button type="button" class="extension-action extension-action-install" data-ext-install="${escapeHtml(id)}">${escapeHtml(I18n.t("extensions.action.install"))}</button>
|
|
266
|
+
</div>`;
|
|
267
|
+
}
|
|
268
|
+
return "";
|
|
269
|
+
}
|
|
270
|
+
const slug = ext.name || ext.slug || id;
|
|
245
271
|
const toggleKey = ext.disabled ? "extensions.action.enable" : "extensions.action.disable";
|
|
246
272
|
const toggleCls = ext.disabled ? "extension-action-enable" : "extension-action-disable";
|
|
247
273
|
const disabledBadge = ext.disabled
|
|
@@ -274,10 +300,13 @@ const ExtensionsView = (() => {
|
|
|
274
300
|
const singular = type.replace(/s$/, "");
|
|
275
301
|
const heading = _sectionHeading(type);
|
|
276
302
|
const rows = items.map((it) => {
|
|
277
|
-
const
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
303
|
+
const isStr = typeof it === "string";
|
|
304
|
+
const title = isStr ? it
|
|
305
|
+
: ((currentLang === "zh" && it.title_zh) ? it.title_zh
|
|
306
|
+
: (it.title || it.name || it.id || singular));
|
|
307
|
+
const desc = isStr ? ""
|
|
308
|
+
: ((currentLang === "zh" && it.description_zh) ? it.description_zh
|
|
309
|
+
: (it.description || ""));
|
|
281
310
|
return `
|
|
282
311
|
<li class="extension-contrib-item">
|
|
283
312
|
<span class="extension-contrib-title">${escapeHtml(String(title))}</span>
|
|
@@ -340,6 +369,14 @@ const ExtensionsView = (() => {
|
|
|
340
369
|
sortSelect.addEventListener("change", () => Extensions.setSort(sortSelect.value));
|
|
341
370
|
}
|
|
342
371
|
|
|
372
|
+
document.querySelectorAll(".extensions-filter-tab").forEach(btn => {
|
|
373
|
+
btn.addEventListener("click", () => {
|
|
374
|
+
document.querySelectorAll(".extensions-filter-tab").forEach(b => b.classList.remove("extensions-filter-tab-active"));
|
|
375
|
+
btn.classList.add("extensions-filter-tab-active");
|
|
376
|
+
Extensions.setFilterInstalled(btn.dataset.filter === "installed");
|
|
377
|
+
});
|
|
378
|
+
});
|
|
379
|
+
|
|
343
380
|
const list = $("extensions-list");
|
|
344
381
|
if (list) {
|
|
345
382
|
list.addEventListener("click", (e) => {
|
|
@@ -110,7 +110,7 @@ const NewSessionView = (() => {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
const author = (a.author || "").trim();
|
|
113
|
-
if (author) {
|
|
113
|
+
if (author && !_isBuiltin(a)) {
|
|
114
114
|
const by = document.createElement("div");
|
|
115
115
|
by.className = "agent-card-author";
|
|
116
116
|
by.textContent = _isZh() ? `作者 ${author}` : `by ${author}`;
|
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -508,16 +508,20 @@ const I18n = (() => {
|
|
|
508
508
|
"mcp.title": "MCP Servers",
|
|
509
509
|
"mcp.subtitle": "Standard Model Context Protocol servers (Claude Desktop / Cursor compatible). Edit ~/.clacky/mcp.json to add or remove servers.",
|
|
510
510
|
"extensions.title": "Extension Market",
|
|
511
|
-
"extensions.subtitle": "Browse and manage
|
|
511
|
+
"extensions.subtitle": "Browse and manage extensions — add agents, skills, and panels to supercharge your workflow.",
|
|
512
512
|
"extensions.searchPlaceholder": "Search extensions…",
|
|
513
513
|
"extensions.sort.newest": "Newest",
|
|
514
514
|
"extensions.sort.updated": "Recently updated",
|
|
515
515
|
"extensions.sort.downloads": "Most downloaded",
|
|
516
|
+
"extensions.filter.all": "All",
|
|
517
|
+
"extensions.filter.installed": "Installed",
|
|
516
518
|
"extensions.empty": "No extensions available yet.",
|
|
517
519
|
"extensions.noResults": "No extensions match your search.",
|
|
518
520
|
"extensions.loadFailed": "Could not reach the extension store.",
|
|
519
521
|
"extensions.homepage": "Homepage",
|
|
522
|
+
"extensions.by": "by",
|
|
520
523
|
"extensions.installed": "Installed",
|
|
524
|
+
"extensions.unlisted": "Unlisted",
|
|
521
525
|
"extensions.unit.skill": "skill",
|
|
522
526
|
"extensions.unit.skills": "skills",
|
|
523
527
|
"extensions.unit.agent": "agent",
|
|
@@ -534,10 +538,12 @@ const I18n = (() => {
|
|
|
534
538
|
"extensions.section.panels": "Panels",
|
|
535
539
|
"extensions.section.api": "APIs",
|
|
536
540
|
"extensions.disabled": "Disabled",
|
|
541
|
+
"extensions.action.install": "Install",
|
|
542
|
+
"extensions.action.installing": "Installing…",
|
|
537
543
|
"extensions.action.disable": "Disable",
|
|
538
544
|
"extensions.action.enable": "Enable",
|
|
539
545
|
"extensions.action.remove": "Remove",
|
|
540
|
-
"extensions.action.removeConfirm": "Remove this extension? Its files will be deleted. You can reinstall it
|
|
546
|
+
"extensions.action.removeConfirm": "Remove this extension? Its files will be deleted. You can reinstall it later.",
|
|
541
547
|
"mcp.loading": "Loading…",
|
|
542
548
|
"mcp.empty.title": "No MCP servers configured",
|
|
543
549
|
"mcp.empty.body": "Create ~/.clacky/mcp.json to plug in MCP servers. The format matches Claude Desktop and Cursor, so existing configs work as-is.",
|
|
@@ -1465,16 +1471,20 @@ const I18n = (() => {
|
|
|
1465
1471
|
"mcp.title": "MCP 工具",
|
|
1466
1472
|
"mcp.subtitle": "标准 Model Context Protocol 服务(与 Claude Desktop / Cursor 兼容)。编辑 ~/.clacky/mcp.json 添加或移除服务。",
|
|
1467
1473
|
"extensions.title": "扩展市场",
|
|
1468
|
-
"extensions.subtitle": "
|
|
1474
|
+
"extensions.subtitle": "浏览并管理扩展,用 Agent、技能与面板增强你的工作流。",
|
|
1469
1475
|
"extensions.searchPlaceholder": "搜索扩展…",
|
|
1470
1476
|
"extensions.sort.newest": "最新",
|
|
1471
1477
|
"extensions.sort.updated": "最近更新",
|
|
1472
1478
|
"extensions.sort.downloads": "下载最多",
|
|
1479
|
+
"extensions.filter.all": "全部",
|
|
1480
|
+
"extensions.filter.installed": "已安装",
|
|
1473
1481
|
"extensions.empty": "暂无可用扩展。",
|
|
1474
1482
|
"extensions.noResults": "没有匹配的扩展。",
|
|
1475
1483
|
"extensions.loadFailed": "无法连接扩展市场。",
|
|
1476
1484
|
"extensions.homepage": "主页",
|
|
1485
|
+
"extensions.by": "作者",
|
|
1477
1486
|
"extensions.installed": "已安装",
|
|
1487
|
+
"extensions.unlisted": "已下架",
|
|
1478
1488
|
"extensions.unit.skill": "个技能",
|
|
1479
1489
|
"extensions.unit.skills": "个技能",
|
|
1480
1490
|
"extensions.unit.agent": "个 Agent",
|
|
@@ -1491,10 +1501,12 @@ const I18n = (() => {
|
|
|
1491
1501
|
"extensions.section.panels": "面板",
|
|
1492
1502
|
"extensions.section.api": "API",
|
|
1493
1503
|
"extensions.disabled": "已禁用",
|
|
1504
|
+
"extensions.action.install": "安装",
|
|
1505
|
+
"extensions.action.installing": "安装中…",
|
|
1494
1506
|
"extensions.action.disable": "禁用",
|
|
1495
1507
|
"extensions.action.enable": "启用",
|
|
1496
1508
|
"extensions.action.remove": "移除",
|
|
1497
|
-
"extensions.action.removeConfirm": "
|
|
1509
|
+
"extensions.action.removeConfirm": "确定移除此扩展?相关文件会被删除,之后可重新安装。",
|
|
1498
1510
|
"mcp.loading": "加载中…",
|
|
1499
1511
|
"mcp.empty.title": "尚未配置 MCP 服务",
|
|
1500
1512
|
"mcp.empty.body": "新建 ~/.clacky/mcp.json 即可接入 MCP 服务。格式与 Claude Desktop、Cursor 一致,现有配置可直接复用。",
|
data/lib/clacky/web/index.html
CHANGED
|
@@ -662,12 +662,18 @@
|
|
|
662
662
|
</div>
|
|
663
663
|
|
|
664
664
|
<div class="extensions-toolbar">
|
|
665
|
-
<
|
|
666
|
-
<
|
|
667
|
-
<
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
665
|
+
<div class="extensions-filter-tabs">
|
|
666
|
+
<button type="button" class="extensions-filter-tab extensions-filter-tab-active" data-filter="all" data-i18n="extensions.filter.all">All</button>
|
|
667
|
+
<button type="button" class="extensions-filter-tab" data-filter="installed" data-i18n="extensions.filter.installed">Installed</button>
|
|
668
|
+
</div>
|
|
669
|
+
<div class="extensions-toolbar-right">
|
|
670
|
+
<input type="text" id="extensions-search-input" class="extensions-search" placeholder="Search extensions…" />
|
|
671
|
+
<select id="extensions-sort" class="extensions-sort">
|
|
672
|
+
<option value="newest" data-i18n="extensions.sort.newest">Newest</option>
|
|
673
|
+
<option value="updated" data-i18n="extensions.sort.updated">Recently updated</option>
|
|
674
|
+
<option value="downloads" data-i18n="extensions.sort.downloads">Most downloaded</option>
|
|
675
|
+
</select>
|
|
676
|
+
</div>
|
|
671
677
|
</div>
|
|
672
678
|
|
|
673
679
|
<div id="extensions-warning" class="brand-skills-warning" style="display:none"></div>
|
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -113,6 +113,33 @@ const Sessions = (() => {
|
|
|
113
113
|
return html;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
// Encode file:// URLs inside raw markdown BEFORE marked parses it. The AI
|
|
117
|
+
// emits raw paths (spaces / non-ASCII / % literal); marked's link tokenizer
|
|
118
|
+
// treats a space as the end of the URL, so links with spaces are never
|
|
119
|
+
// recognized. Re-encoding the path (decode then encode, idempotent) turns
|
|
120
|
+
// spaces into %20 and a literal % into %25 so marked sees a valid URL.
|
|
121
|
+
function _encodeFileUrlsInMarkdown(text) {
|
|
122
|
+
return text.replace(
|
|
123
|
+
/(!?\[[^\]]*\]\()(file:\/{2,3})([^)]+)(\))/g,
|
|
124
|
+
(_m, open, scheme, path, close) => {
|
|
125
|
+
try { path = decodeURI(path); } catch (_) { /* keep raw if malformed */ }
|
|
126
|
+
return open + scheme + encodeURI(path) + close;
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Normalize a file:// href for use in <a href>. The AI emits raw paths
|
|
132
|
+
// (Chinese chars / spaces literal); encode them to a valid percent-encoded
|
|
133
|
+
// URL. Idempotent: an already-encoded path is decoded first so it isn't
|
|
134
|
+
// double-encoded. Non file:// links pass through untouched.
|
|
135
|
+
function _normalizeFileHref(href) {
|
|
136
|
+
if (typeof href !== "string" || !href.startsWith("file://")) return href;
|
|
137
|
+
const prefix = href.match(/^file:\/{2,3}/)[0];
|
|
138
|
+
let path = href.slice(prefix.length);
|
|
139
|
+
try { path = decodeURI(path); } catch (_) { /* keep raw if malformed */ }
|
|
140
|
+
return prefix + encodeURI(path);
|
|
141
|
+
}
|
|
142
|
+
|
|
116
143
|
// Run marked on a text string. Returns HTML. Falls back to escaped plain text
|
|
117
144
|
// if the marked library is unavailable.
|
|
118
145
|
function _markedParse(text) {
|
|
@@ -122,6 +149,7 @@ const Sessions = (() => {
|
|
|
122
149
|
const math = [];
|
|
123
150
|
const PLACEHOLDER = (i) => `\u0000KTX${i}\u0000`;
|
|
124
151
|
let prepared = _extractMath(text, math, PLACEHOLDER);
|
|
152
|
+
prepared = _encodeFileUrlsInMarkdown(prepared);
|
|
125
153
|
|
|
126
154
|
let html;
|
|
127
155
|
if (typeof marked !== "undefined") {
|
|
@@ -138,7 +166,7 @@ const Sessions = (() => {
|
|
|
138
166
|
const renderer = new marked.Renderer();
|
|
139
167
|
renderer.link = function({ href, title, text }) {
|
|
140
168
|
const titleAttr = title ? ` title="${title}"` : "";
|
|
141
|
-
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
|
|
169
|
+
return `<a href="${_normalizeFileHref(href)}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
|
|
142
170
|
};
|
|
143
171
|
// Override code block rendering: apply syntax highlighting + header with
|
|
144
172
|
// language label and copy button.
|
|
@@ -2307,7 +2335,9 @@ const Sessions = (() => {
|
|
|
2307
2335
|
const link = e.target.closest("a[href^='file://']");
|
|
2308
2336
|
if (!link) return;
|
|
2309
2337
|
e.preventDefault();
|
|
2310
|
-
|
|
2338
|
+
const rawHref = link.getAttribute("href").replace(/^file:\/\//, "");
|
|
2339
|
+
let filePath;
|
|
2340
|
+
try { filePath = decodeURIComponent(rawHref); } catch (_) { filePath = rawHref; }
|
|
2311
2341
|
// file:///C:/foo → /C:/foo after replace; strip the leading slash for Windows drive letters
|
|
2312
2342
|
if (/^\/[A-Za-z]:/.test(filePath)) filePath = filePath.substring(1);
|
|
2313
2343
|
if (!filePath) return;
|