openclacky 1.3.9 → 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 +22 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +27 -1
- data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -2
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +132 -74
- 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 +95 -7
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +20 -6
- data/lib/clacky/web/features/extensions/store.js +28 -10
- data/lib/clacky/web/features/extensions/view.js +16 -9
- data/lib/clacky/web/features/new-session/view.js +1 -1
- data/lib/clacky/web/i18n.js +6 -2
- data/lib/clacky/web/sessions.js +32 -2
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: de09b43a446d08c592e1cd49deb914a3ff8c70375cf653f130d2aef9a1cd460e
|
|
4
|
+
data.tar.gz: 8030ce355abbe973c9b8294995744b5d4a96e3e38bfc494cb081621172619085
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: bd57f31e2d97f1a98b10a775017e2c42ce6354e3fab4a87f9392e884ecca3a2ca3c3ec19d30e8adcb7d8498330446b3d80be787c3594e26b63d07a3e2349f9a7
|
|
7
|
+
data.tar.gz: 4b18ae8839c6bd9ab319c7077dca1bc61ea836d38152438215c61a524099c7e17213b0b76f37f8157621a7b168325042fe3137bf7d0b5e257a3609c58ca7c8fc
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
|
|
7
|
+
## [1.3.10] - 2026-07-09
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Extension author shown inline in title row on extension cards
|
|
11
|
+
- Version input in publish modal — writes back to `ext.yml`, shows friendly Chinese error on version conflict
|
|
12
|
+
- Local extensions now included in the installed extensions list
|
|
13
|
+
|
|
14
|
+
### Improved
|
|
15
|
+
- Published extensions list in publish panel — better style and UX
|
|
16
|
+
- Publishing state, success label, and done button added to publish modal flow
|
|
17
|
+
- OSS CDN prioritized for latest version check (faster upgrade detection)
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- Agent card description clamped to 2 lines; author hidden for built-in agents
|
|
21
|
+
- New-session scrollbar centering — use padding calc instead of max-width
|
|
22
|
+
- `file://` links with spaces and non-ASCII paths now render correctly (#350)
|
|
23
|
+
- Invalid API key error message improved to cover expired key case
|
|
24
|
+
- Local extension card in ext-studio now correctly appends action buttons
|
|
25
|
+
- Navigate back to list when extension detail fails to load
|
|
26
|
+
- Page reloads correctly after install/enable/disable/uninstall extension
|
|
27
|
+
- Enriched installed extensions with market data; shows unlisted badge
|
|
28
|
+
|
|
7
29
|
## [1.3.9] - 2026-07-08
|
|
8
30
|
|
|
9
31
|
### Added
|
|
@@ -129,7 +129,33 @@ class ExtStudioExt < Clacky::ApiExtension
|
|
|
129
129
|
json(ok: true, ext_id: ext_id)
|
|
130
130
|
end
|
|
131
131
|
|
|
132
|
-
# POST /api/ext/ext-studio/
|
|
132
|
+
# POST /api/ext/ext-studio/set_version
|
|
133
|
+
# body: { ext_id, version }
|
|
134
|
+
# Writes the new version string back to the local ext.yml.
|
|
135
|
+
post "/set_version" do
|
|
136
|
+
ext_id = require_ext_id!
|
|
137
|
+
version = presence(json_body["version"])
|
|
138
|
+
error!("version required", status: 422) unless version
|
|
139
|
+
|
|
140
|
+
result = Clacky::ExtensionLoader.load_all(force: false)
|
|
141
|
+
container = Array(result.containers).find { |id, _| id == ext_id }&.last
|
|
142
|
+
error!("extension not found: #{ext_id}", status: 404) unless container
|
|
143
|
+
|
|
144
|
+
yml_path = File.join(container[:dir], "ext.yml")
|
|
145
|
+
error!("ext.yml not found", status: 404) unless File.exist?(yml_path)
|
|
146
|
+
|
|
147
|
+
content = File.read(yml_path)
|
|
148
|
+
if content =~ /^version:/
|
|
149
|
+
content = content.sub(/^version:.*$/, "version: #{version}")
|
|
150
|
+
else
|
|
151
|
+
content = content.rstrip + "\nversion: #{version}\n"
|
|
152
|
+
end
|
|
153
|
+
File.write(yml_path, content)
|
|
154
|
+
|
|
155
|
+
json(ok: true, ext_id: ext_id, version: version)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
|
|
133
159
|
# body: { idea? }
|
|
134
160
|
# Spawns a session bound to the ext-developer agent, optionally seeded with
|
|
135
161
|
# the user's idea as the first task — the "let AI build it for me" entry.
|
|
@@ -19,8 +19,8 @@ contributes:
|
|
|
19
19
|
- id: ext-developer
|
|
20
20
|
title: Extension Developer
|
|
21
21
|
title_zh: 扩展开发
|
|
22
|
-
description: AI expert that helps you build, debug, and publish
|
|
23
|
-
description_zh:
|
|
22
|
+
description: AI expert that helps you build, debug, and publish extensions
|
|
23
|
+
description_zh: 帮你开发、调试、发布扩展的 AI 专家
|
|
24
24
|
order: 5
|
|
25
25
|
prompt: agents/ext-developer/system_prompt.md
|
|
26
26
|
avatar: agents/ext-developer/avatar.png
|
|
@@ -48,9 +48,11 @@
|
|
|
48
48
|
"btn.unpublish": "Unpublish",
|
|
49
49
|
"published.confirm": "Unpublish {{id}} from the marketplace?",
|
|
50
50
|
"err.generic": "Something went wrong: {{msg}}",
|
|
51
|
+
"err.version_conflict": "Version {{ver}} has already been published. Please enter a higher version number above and try again.",
|
|
52
|
+
"pub.version.label": "Version",
|
|
51
53
|
|
|
52
54
|
"publish.confirm.title": "Publish to the marketplace?",
|
|
53
|
-
"publish.confirm.body": "\"{{id}}\" will be packed and uploaded to the
|
|
55
|
+
"publish.confirm.body": "\"{{id}}\" will be packed and uploaded to the extension marketplace, where anyone can discover and install it. Make sure it's ready to share publicly.",
|
|
54
56
|
"publish.confirm.ok": "Publish",
|
|
55
57
|
"publish.confirm.cancel": "Cancel",
|
|
56
58
|
"bind.title": "Authorize this device",
|
|
@@ -67,7 +69,7 @@
|
|
|
67
69
|
|
|
68
70
|
"pub.title.new": "Publish to the marketplace",
|
|
69
71
|
"pub.title.update": "Publish a new version",
|
|
70
|
-
"pub.intro": "\"{{name}}\" will be packed and uploaded to the
|
|
72
|
+
"pub.intro": "\"{{name}}\" will be packed and uploaded to the extension marketplace, where anyone can discover and install it.",
|
|
71
73
|
"pub.version.new": "First release · v{{ver}}",
|
|
72
74
|
"pub.version.update": "v{{prev}} → v{{ver}}",
|
|
73
75
|
"pub.version.missing": "No version in ext.yml — add a \"version\" field before publishing.",
|
|
@@ -77,10 +79,13 @@
|
|
|
77
79
|
"pub.readme.ok": "README.md detected",
|
|
78
80
|
"pub.readme.missing": "No README.md — consider adding one so users understand your extension.",
|
|
79
81
|
"pub.btn.publish": "Publish v{{ver}}",
|
|
82
|
+
"pub.btn.publishing": "Publishing…",
|
|
83
|
+
"pub.btn.done": "Done",
|
|
80
84
|
"pub.btn.cancel": "Cancel",
|
|
81
85
|
"pub.progress.packing": "Packing…",
|
|
82
86
|
"pub.progress.uploading": "Uploading to marketplace…",
|
|
83
87
|
"pub.done": "Published {{id}} v{{ver}} — {{status}}",
|
|
88
|
+
"pub.success": "Published successfully",
|
|
84
89
|
"pub.done.close": "Done",
|
|
85
90
|
"pub.retry": "Try again",
|
|
86
91
|
"extlist.section.cloud": "Published Extensions",
|
|
@@ -132,7 +137,7 @@
|
|
|
132
137
|
"skills.shadow.tooltip": "Local copy shadows a same-named brand skill",
|
|
133
138
|
"skills.newSkill.label": "Create a new skill with /skill-creator",
|
|
134
139
|
"skills.newSkill.btn": "Create New Skill",
|
|
135
|
-
"skills.promo.text": "Publish your skills & build your own brand
|
|
140
|
+
"skills.promo.text": "Publish your skills & build your own brand.",
|
|
136
141
|
"skills.promo.link": "Learn more →",
|
|
137
142
|
"skills.locked": "Creator license required to publish cloud skills.",
|
|
138
143
|
"skills.publishing": "Publishing…",
|
|
@@ -178,9 +183,11 @@
|
|
|
178
183
|
"btn.unpublish": "下架",
|
|
179
184
|
"published.confirm": "确定要从市场下架 {{id}} 吗?",
|
|
180
185
|
"err.generic": "出错了:{{msg}}",
|
|
186
|
+
"err.version_conflict": "版本 {{ver}} 已发布过,请在上方输入更高的版本号后重试。",
|
|
187
|
+
"pub.version.label": "版本号",
|
|
181
188
|
|
|
182
189
|
"publish.confirm.title": "确定发布到市场?",
|
|
183
|
-
"publish.confirm.body": "「{{id}}
|
|
190
|
+
"publish.confirm.body": "「{{id}}」将被打包并上传到扩展市场,任何人都能发现并安装它。请确认它已准备好公开分享。",
|
|
184
191
|
"publish.confirm.ok": "发布",
|
|
185
192
|
"publish.confirm.cancel": "取消",
|
|
186
193
|
"bind.title": "授权此设备",
|
|
@@ -197,7 +204,7 @@
|
|
|
197
204
|
|
|
198
205
|
"pub.title.new": "发布到市场",
|
|
199
206
|
"pub.title.update": "发布新版本",
|
|
200
|
-
"pub.intro": "「{{name}}
|
|
207
|
+
"pub.intro": "「{{name}}」将被打包并上传到扩展市场,任何人都能发现并安装它。",
|
|
201
208
|
"pub.version.new": "首次发布 · v{{ver}}",
|
|
202
209
|
"pub.version.update": "v{{prev}} → v{{ver}}",
|
|
203
210
|
"pub.version.missing": "ext.yml 里没有 version — 请先补上 version 字段再发布。",
|
|
@@ -207,10 +214,13 @@
|
|
|
207
214
|
"pub.readme.ok": "已检测到 README.md",
|
|
208
215
|
"pub.readme.missing": "未找到 README.md — 建议补一份,方便用户了解你的扩展。",
|
|
209
216
|
"pub.btn.publish": "发布 v{{ver}}",
|
|
217
|
+
"pub.btn.publishing": "发布中…",
|
|
218
|
+
"pub.btn.done": "完成",
|
|
210
219
|
"pub.btn.cancel": "取消",
|
|
211
220
|
"pub.progress.packing": "打包中…",
|
|
212
221
|
"pub.progress.uploading": "上传到市场…",
|
|
213
222
|
"pub.done": "已发布 {{id}} v{{ver}} — {{status}}",
|
|
223
|
+
"pub.success": "已发布成功",
|
|
214
224
|
"pub.done.close": "完成",
|
|
215
225
|
"pub.retry": "重试",
|
|
216
226
|
"extlist.section.cloud": "已发布扩展",
|
|
@@ -262,7 +272,7 @@
|
|
|
262
272
|
"skills.shadow.tooltip": "本地副本覆盖了同名品牌 skill",
|
|
263
273
|
"skills.newSkill.label": "用 /skill-creator 创建新 skill",
|
|
264
274
|
"skills.newSkill.btn": "创建新 Skill",
|
|
265
|
-
"skills.promo.text": "发布你的 skill
|
|
275
|
+
"skills.promo.text": "发布你的 skill,打造自己的品牌。",
|
|
266
276
|
"skills.promo.link": "了解更多 →",
|
|
267
277
|
"skills.locked": "发布云端 skill 需要创作者授权。",
|
|
268
278
|
"skills.publishing": "发布中…",
|
|
@@ -465,44 +475,48 @@
|
|
|
465
475
|
|
|
466
476
|
// Unified publish flow: one modal that walks the creator from a release form
|
|
467
477
|
// (version + notes) through progress → device binding (if needed) → done.
|
|
468
|
-
// The creator owns the version
|
|
469
|
-
//
|
|
470
|
-
//
|
|
478
|
+
// The creator owns the version; if it conflicts with the latest published
|
|
479
|
+
// version they can bump it inline — the modal writes it back to ext.yml
|
|
480
|
+
// before uploading. `prevVersionOrPromise` may be a value or a Promise.
|
|
471
481
|
// Resolves true when a publish succeeded, false otherwise.
|
|
472
|
-
function runPublishFlow(ext,
|
|
482
|
+
function runPublishFlow(ext, prevVersionOrPromise) {
|
|
473
483
|
return new Promise((resolve) => {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
|
|
479
|
-
const
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
metaBlock.appendChild(el("span", { class: "studio-pub-version", text: verText }));
|
|
484
|
+
let currentVersion = ext.version || "";
|
|
485
|
+
let isUpdate = false;
|
|
486
|
+
|
|
487
|
+
const verField = el("div", { class: "studio-field" });
|
|
488
|
+
verField.appendChild(el("label", { class: "studio-label", text: t("pub.version.label") }));
|
|
489
|
+
const verInput = el("input", { class: "studio-input", type: "text", value: currentVersion, placeholder: "1.0.0" });
|
|
490
|
+
verInput.addEventListener("input", () => {
|
|
491
|
+
currentVersion = verInput.value.trim();
|
|
492
|
+
publishBtn.disabled = !currentVersion;
|
|
493
|
+
publishBtn.textContent = currentVersion ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: "?" });
|
|
494
|
+
});
|
|
495
|
+
verField.appendChild(verInput);
|
|
487
496
|
|
|
488
497
|
const notesField = el("div", { class: "studio-field" });
|
|
489
498
|
notesField.appendChild(el("label", { class: "studio-label", text: t("pub.notes.label") }));
|
|
490
|
-
const notes = el("textarea", { class: "studio-textarea", rows: "
|
|
499
|
+
const notes = el("textarea", { class: "studio-textarea", rows: "4", placeholder: t("pub.notes.placeholder") });
|
|
491
500
|
notesField.appendChild(notes);
|
|
492
501
|
|
|
493
502
|
const status = el("p", { class: "studio-modal-status" });
|
|
494
503
|
status.style.display = "none";
|
|
495
504
|
|
|
496
|
-
const bodyChildren = [
|
|
505
|
+
const bodyChildren = [
|
|
506
|
+
el("p", { class: "studio-modal-intro", text: t("pub.intro", { name: ext.name }) }),
|
|
507
|
+
verField,
|
|
508
|
+
notesField,
|
|
509
|
+
status,
|
|
510
|
+
];
|
|
497
511
|
|
|
498
512
|
let done = false;
|
|
499
513
|
const modal = openModal({
|
|
500
|
-
title:
|
|
514
|
+
title: t("pub.title.new"),
|
|
501
515
|
body: el("div", null, bodyChildren),
|
|
502
516
|
buttons: [
|
|
503
517
|
{ label: t("pub.btn.cancel"), keepOpen: false, onClick: () => { if (!done) resolve(false); } },
|
|
504
518
|
{
|
|
505
|
-
label:
|
|
519
|
+
label: currentVersion ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: "?" }),
|
|
506
520
|
primary: true,
|
|
507
521
|
keepOpen: true,
|
|
508
522
|
onClick: () => submit(),
|
|
@@ -512,50 +526,82 @@
|
|
|
512
526
|
|
|
513
527
|
const publishBtn = modal.footer.querySelector(".studio-btn-primary");
|
|
514
528
|
const cancelBtn = modal.footer.querySelector(".studio-btn:not(.studio-btn-primary)");
|
|
515
|
-
if (!
|
|
529
|
+
if (!currentVersion) publishBtn.disabled = true;
|
|
530
|
+
|
|
531
|
+
Promise.resolve(prevVersionOrPromise).then((prevVersion) => {
|
|
532
|
+
if (done || !prevVersion) return;
|
|
533
|
+
isUpdate = true;
|
|
534
|
+
const titleEl = modal.overlay.querySelector(".studio-modal-title");
|
|
535
|
+
if (titleEl) titleEl.textContent = t("pub.title.update");
|
|
536
|
+
if (prevVersion !== currentVersion) {
|
|
537
|
+
currentVersion = prevVersion;
|
|
538
|
+
verInput.value = currentVersion;
|
|
539
|
+
publishBtn.disabled = false;
|
|
540
|
+
publishBtn.textContent = t("pub.btn.publish", { ver: currentVersion });
|
|
541
|
+
}
|
|
542
|
+
});
|
|
516
543
|
|
|
517
|
-
function setProgress(msg) {
|
|
544
|
+
function setProgress(msg, isError) {
|
|
518
545
|
status.style.display = "";
|
|
519
546
|
status.textContent = msg;
|
|
547
|
+
status.className = "studio-modal-status" + (isError ? " studio-modal-status-error" : "");
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
function resetButtons() {
|
|
551
|
+
publishBtn.disabled = !currentVersion;
|
|
552
|
+
publishBtn.textContent = currentVersion ? t("pub.btn.publish", { ver: currentVersion }) : t("pub.btn.publish", { ver: "?" });
|
|
553
|
+
cancelBtn.disabled = false;
|
|
554
|
+
notes.disabled = false;
|
|
555
|
+
verInput.disabled = false;
|
|
520
556
|
}
|
|
521
557
|
|
|
522
558
|
async function submit() {
|
|
523
559
|
if (done || publishBtn.disabled) return;
|
|
560
|
+
const ver = currentVersion;
|
|
561
|
+
if (!ver) return;
|
|
562
|
+
|
|
524
563
|
publishBtn.disabled = true;
|
|
564
|
+
publishBtn.textContent = t("pub.btn.publishing");
|
|
525
565
|
cancelBtn.disabled = true;
|
|
526
566
|
notes.disabled = true;
|
|
527
|
-
|
|
567
|
+
verInput.disabled = true;
|
|
568
|
+
status.style.display = "none";
|
|
569
|
+
|
|
528
570
|
try {
|
|
529
|
-
|
|
571
|
+
if (ver !== ext.version) {
|
|
572
|
+
await postJson("/set_version", { ext_id: ext.id, version: ver });
|
|
573
|
+
}
|
|
574
|
+
|
|
530
575
|
const data = await publishWithBinding({
|
|
531
576
|
ext_id: ext.id,
|
|
532
577
|
force: isUpdate,
|
|
533
578
|
changelog: notes.value.trim(),
|
|
534
579
|
});
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
cancelBtn.disabled = false;
|
|
538
|
-
notes.disabled = false;
|
|
539
|
-
status.style.display = "none";
|
|
540
|
-
return;
|
|
541
|
-
}
|
|
580
|
+
|
|
581
|
+
if (data === null) { resetButtons(); return; }
|
|
542
582
|
if (!data.ok) throw new Error(data.error || "Publish failed");
|
|
583
|
+
|
|
543
584
|
done = true;
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
status: data.status || "",
|
|
548
|
-
}));
|
|
549
|
-
publishBtn.style.display = "none";
|
|
550
|
-
cancelBtn.textContent = t("pub.done.close");
|
|
551
|
-
cancelBtn.disabled = false;
|
|
552
|
-
cancelBtn.onclick = () => { modal.close(); resolve(true); };
|
|
553
|
-
} catch (e) {
|
|
554
|
-
setProgress(t("err.generic", { msg: e.message }));
|
|
585
|
+
const successLabel = el("span", { class: "studio-pub-success", text: t("pub.success") });
|
|
586
|
+
modal.footer.insertBefore(successLabel, modal.footer.firstChild);
|
|
587
|
+
cancelBtn.style.display = "none";
|
|
555
588
|
publishBtn.disabled = false;
|
|
556
|
-
publishBtn.textContent = t("pub.
|
|
557
|
-
|
|
558
|
-
|
|
589
|
+
publishBtn.textContent = t("pub.btn.done");
|
|
590
|
+
publishBtn.onclick = () => { modal.close(); resolve(true); };
|
|
591
|
+
} catch (e) {
|
|
592
|
+
const msg = e.message || "";
|
|
593
|
+
const isConflict = /must be greater than/i.test(msg);
|
|
594
|
+
if (isConflict) {
|
|
595
|
+
setProgress(t("err.version_conflict", { ver }), true);
|
|
596
|
+
verInput.classList.add("studio-input-error");
|
|
597
|
+
verInput.disabled = false;
|
|
598
|
+
verInput.focus();
|
|
599
|
+
verInput.select();
|
|
600
|
+
verInput.addEventListener("input", () => verInput.classList.remove("studio-input-error"), { once: true });
|
|
601
|
+
} else {
|
|
602
|
+
setProgress(t("err.generic", { msg }), true);
|
|
603
|
+
}
|
|
604
|
+
resetButtons();
|
|
559
605
|
}
|
|
560
606
|
}
|
|
561
607
|
});
|
|
@@ -739,13 +785,13 @@
|
|
|
739
785
|
async function doPublish() {
|
|
740
786
|
const ext = store.selected();
|
|
741
787
|
if (!ext) return;
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
const ok = await runPublishFlow(ext,
|
|
788
|
+
const prevVersionPromise = getJson("/published")
|
|
789
|
+
.then((data) => {
|
|
790
|
+
const match = (data.extensions || []).find((e) => e.id === ext.id);
|
|
791
|
+
return match ? match.version : null;
|
|
792
|
+
})
|
|
793
|
+
.catch(() => null);
|
|
794
|
+
const ok = await runPublishFlow(ext, prevVersionPromise);
|
|
749
795
|
if (ok) loadPublished();
|
|
750
796
|
}
|
|
751
797
|
|
|
@@ -774,17 +820,23 @@
|
|
|
774
820
|
const exts = data.extensions || [];
|
|
775
821
|
if (!exts.length) { box.appendChild(el("p", { class: "studio-empty", text: t("published.empty") })); return; }
|
|
776
822
|
exts.forEach((e) => {
|
|
777
|
-
const
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
823
|
+
const card = el("div", { class: "studio-skill-card" });
|
|
824
|
+
const head = el("div", { class: "studio-skill-head" });
|
|
825
|
+
head.appendChild(el("span", { class: "studio-skill-name", text: e.name || e.id }));
|
|
826
|
+
const statusKind = e.status === "draft" ? "local" : "published";
|
|
827
|
+
head.appendChild(el("span", { class: `studio-skill-badge studio-skill-badge-${statusKind}`, text: e.status === "draft" ? t("extlist.badge.draft") : t("extlist.badge.published") }));
|
|
828
|
+
if (e.version) head.appendChild(el("span", { style: "font-size:11px;color:var(--color-text-muted);", text: "v" + e.version }));
|
|
829
|
+
const unpubBtn = el("button", { class: "studio-btn studio-btn-danger", text: t("btn.unpublish") });
|
|
830
|
+
unpubBtn.style.marginLeft = "auto";
|
|
831
|
+
unpubBtn.addEventListener("click", async () => {
|
|
832
|
+
if (!window.confirm(t("published.confirm", { id: e.id }))) return;
|
|
833
|
+
unpubBtn.disabled = true;
|
|
834
|
+
try { await postJson("/unpublish", { ext_id: e.id }); loadPublished(); }
|
|
835
|
+
catch (err) { unpubBtn.disabled = false; feedback(t("err.generic", { msg: err.message }), "error"); }
|
|
836
|
+
});
|
|
837
|
+
head.appendChild(unpubBtn);
|
|
838
|
+
card.appendChild(head);
|
|
839
|
+
box.appendChild(card);
|
|
788
840
|
});
|
|
789
841
|
} catch (e) {
|
|
790
842
|
box.appendChild(el("p", { class: "studio-empty", text: t("err.generic", { msg: e.message }) }));
|
|
@@ -927,6 +979,7 @@
|
|
|
927
979
|
const packBtn = el("button", { class: "studio-btn studio-btn-ghost", text: t("extlist.btn.pack") });
|
|
928
980
|
packBtn.addEventListener("click", () => doPack(ext, packBtn));
|
|
929
981
|
actions.appendChild(packBtn);
|
|
982
|
+
card.appendChild(actions);
|
|
930
983
|
return card;
|
|
931
984
|
}
|
|
932
985
|
|
|
@@ -1291,8 +1344,8 @@
|
|
|
1291
1344
|
.studio-feedback-error { color: var(--color-error); }
|
|
1292
1345
|
.studio-feedback-warn { color: var(--color-warning, var(--color-text-secondary)); }
|
|
1293
1346
|
.studio-published { margin-top: 16px; border-top: 1px solid var(--color-border-primary); padding-top: 12px; }
|
|
1294
|
-
.studio-published-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding:
|
|
1295
|
-
.studio-published-name { color: var(--color-text-primary); }
|
|
1347
|
+
.studio-published-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 0; font-size: 12px; border-bottom: 1px solid var(--color-border-primary); }
|
|
1348
|
+
.studio-published-name { display: flex; align-items: center; gap: 6px; color: var(--color-text-primary); }
|
|
1296
1349
|
.studio-page { width: 100%; }
|
|
1297
1350
|
.studio-page-head { margin-bottom: 20px; }
|
|
1298
1351
|
.studio-page-title { margin: 0; font-size: 22px; font-weight: 600; color: var(--color-text-primary); }
|
|
@@ -1305,7 +1358,7 @@
|
|
|
1305
1358
|
.studio-skill-section { margin-bottom: 28px; }
|
|
1306
1359
|
.studio-skill-section-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 12px; }
|
|
1307
1360
|
.studio-skill-hint { font-size: 11px; color: var(--color-text-muted); }
|
|
1308
|
-
.studio-skill-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md, 8px); padding:
|
|
1361
|
+
.studio-skill-card { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md, 8px); padding: 12px; margin: 0 0 10px; background: var(--color-bg-secondary); }
|
|
1309
1362
|
.studio-skill-head { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
|
1310
1363
|
.studio-skill-name { font-size: 14px; font-weight: 600; color: var(--color-text-primary); }
|
|
1311
1364
|
.studio-skill-badge { font-size: 11px; padding: 1px 8px; border-radius: 10px; }
|
|
@@ -1314,7 +1367,7 @@
|
|
|
1314
1367
|
.studio-skill-badge-changed { background: var(--color-warning-bg, var(--color-bg-hover)); color: var(--color-warning, var(--color-text-secondary)); }
|
|
1315
1368
|
.studio-skill-badge-shadow { background: var(--color-bg-hover); color: var(--color-text-secondary); }
|
|
1316
1369
|
.studio-skill-desc { margin: 6px 0 8px; font-size: 12px; color: var(--color-text-secondary); line-height: 1.5; }
|
|
1317
|
-
.studio-skill-meta { display: flex; gap: 12px; font-size: 11px; color: var(--color-text-muted);
|
|
1370
|
+
.studio-skill-meta { display: flex; gap: 12px; font-size: 11px; color: var(--color-text-muted); }
|
|
1318
1371
|
.studio-skill-promo { border: 1px solid var(--color-border-primary); border-radius: var(--radius-md, 8px); padding: 16px 18px; margin: 0 0 24px; background: var(--color-bg-secondary); }
|
|
1319
1372
|
.studio-skill-promo-text { margin: 0 0 4px; font-size: 14px; font-weight: 600; color: var(--color-text-primary); }
|
|
1320
1373
|
.studio-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 9999; animation: studio-overlay-in 0.15s ease; }
|
|
@@ -1329,9 +1382,14 @@
|
|
|
1329
1382
|
.studio-pub-units::before { content: "·"; margin-right: 10px; }
|
|
1330
1383
|
.studio-modal-body { font-size: 13px; color: var(--color-text-secondary); line-height: 1.6; }
|
|
1331
1384
|
.studio-modal-status { margin: 0 0 8px; font-size: 13px; color: var(--color-text-secondary); line-height: 1.6; }
|
|
1385
|
+
.studio-modal-status-error { color: var(--color-error); }
|
|
1386
|
+
.studio-input { width: 100%; box-sizing: border-box; background: var(--color-bg-input); border: 1px solid var(--color-border-primary); border-radius: var(--radius-sm); padding: 7px 10px; color: var(--color-text-primary); font-size: 13px; font-family: monospace; }
|
|
1387
|
+
.studio-input:focus { border-color: var(--color-accent-primary); outline: none; }
|
|
1388
|
+
.studio-input-error { border-color: var(--color-error) !important; }
|
|
1332
1389
|
.studio-modal-code { margin: 0 0 8px; font-size: 13px; font-family: monospace; color: var(--color-text-primary); }
|
|
1333
1390
|
.studio-modal-link { display: inline-block; font-size: 13px; color: var(--color-accent-primary); }
|
|
1334
1391
|
.studio-modal-footer { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; padding-top: 16px; border-top: 1px solid var(--color-border-primary); }
|
|
1392
|
+
.studio-pub-success { margin-right: auto; font-size: 13px; font-weight: 500; color: var(--color-success); }
|
|
1335
1393
|
`;
|
|
1336
1394
|
document.head.appendChild(style);
|
|
1337
1395
|
})();
|
data/lib/clacky/locales/en.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Clacky
|
|
|
5
5
|
EN = {
|
|
6
6
|
"llm.error.insufficient_credit" => "Insufficient credit, please top up your account to continue",
|
|
7
7
|
"llm.error.rate_limit_400" => "Rate limit or service issue, retrying...",
|
|
8
|
-
"llm.error.invalid_api_key" => "
|
|
8
|
+
"llm.error.invalid_api_key" => "API key is invalid or expired, please update it in Settings",
|
|
9
9
|
"llm.error.403.model_not_allowed" => "This model is not available on your current plan",
|
|
10
10
|
"llm.error.403.api_key_revoked" => "API key has been revoked, please generate a new one",
|
|
11
11
|
"llm.error.403.api_key_expired" => "API key has expired, please generate a new one",
|
data/lib/clacky/locales/zh.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Clacky
|
|
|
5
5
|
ZH = {
|
|
6
6
|
"llm.error.insufficient_credit" => "账户余额不足,请前往控制台充值后继续使用",
|
|
7
7
|
"llm.error.rate_limit_400" => "请求频率过高或服务暂时不可用,正在重试...",
|
|
8
|
-
"llm.error.invalid_api_key" => "API
|
|
8
|
+
"llm.error.invalid_api_key" => "API Key 无效或已过期,请到设置中重新配置",
|
|
9
9
|
"llm.error.403.model_not_allowed" => "当前模型不支持免费试用,请升级套餐或切换其他模型",
|
|
10
10
|
"llm.error.403.api_key_revoked" => "API 密钥已被撤销,请前往控制台重新生成",
|
|
11
11
|
"llm.error.403.api_key_expired" => "API 密钥已过期,请前往控制台重新生成",
|
data/lib/clacky/prompts/base.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
- Ask clarifying questions if requirements are unclear.
|
|
4
4
|
- Break down complex tasks into manageable steps.
|
|
5
5
|
- **USE TOOLS to create/modify files** — don't just return content.
|
|
6
|
-
- When the user asks to send/download a file or you generate one for them, append `[filename](file://~/path/to/file)` at the end of your reply.
|
|
6
|
+
- When the user asks to send/download a file or you generate one for them, append `[filename](file://~/path/to/file)` at the end of your reply. Use the raw file path as-is (keep non-ASCII characters and spaces literal); never URL-encode it.
|
|
7
7
|
|
|
8
8
|
## Tool Usage Rules
|
|
9
9
|
|
|
@@ -117,6 +117,7 @@ module Clacky
|
|
|
117
117
|
WEB_ROOT = File.expand_path("../web", __dir__)
|
|
118
118
|
EXCHANGE_RATE_PRIMARY_BASE_URL = "https://open.er-api.com/v6/latest"
|
|
119
119
|
EXCHANGE_RATE_FALLBACK_URL = "https://api.frankfurter.app/latest"
|
|
120
|
+
OSS_CDN_BASE = "https://oss.1024code.com/openclacky"
|
|
120
121
|
|
|
121
122
|
# Default SOUL.md written when the user skips the onboard conversation.
|
|
122
123
|
# A richer version is created by the Agent during the soul_setup phase.
|
|
@@ -538,7 +539,8 @@ module Clacky
|
|
|
538
539
|
when ["POST", "/api/onboard/complete"] then api_onboard_complete(req, res)
|
|
539
540
|
when ["POST", "/api/onboard/skip-soul"] then api_onboard_skip_soul(req, res)
|
|
540
541
|
when ["GET", "/api/store/skills"] then api_store_skills(res)
|
|
541
|
-
when ["GET", "/api/store/extensions"]
|
|
542
|
+
when ["GET", "/api/store/extensions"] then api_store_extensions(req, res)
|
|
543
|
+
when ["GET", "/api/store/extensions/installed"] then api_store_extensions_installed(res)
|
|
542
544
|
when ["GET", "/api/store/extension"] then api_store_extension_detail(req, res)
|
|
543
545
|
when ["POST", "/api/store/extension/install"] then api_store_extension_install(req, res)
|
|
544
546
|
when ["POST", "/api/store/extension/disable"] then api_store_extension_disable(req, res)
|
|
@@ -2347,6 +2349,66 @@ module Clacky
|
|
|
2347
2349
|
end
|
|
2348
2350
|
end
|
|
2349
2351
|
|
|
2352
|
+
# GET /api/store/extensions/installed
|
|
2353
|
+
#
|
|
2354
|
+
# Returns all locally installed extensions (all layers: builtin, installed,
|
|
2355
|
+
# local) regardless of whether they are still listed on the marketplace.
|
|
2356
|
+
def api_store_extensions_installed(res)
|
|
2357
|
+
result = Clacky::ExtensionLoader.load_all
|
|
2358
|
+
disabled = Clacky::ExtensionLoader.disabled_ids
|
|
2359
|
+
|
|
2360
|
+
local_entries = Array(result&.containers).filter_map do |ext_id, container|
|
|
2361
|
+
next unless container[:layer] == :installed
|
|
2362
|
+
|
|
2363
|
+
[ext_id, container]
|
|
2364
|
+
end.to_h
|
|
2365
|
+
|
|
2366
|
+
market_by_slug = fetch_batch_market_data(local_entries.keys)
|
|
2367
|
+
|
|
2368
|
+
extensions = local_entries.map do |ext_id, container|
|
|
2369
|
+
market = market_by_slug[ext_id]
|
|
2370
|
+
{
|
|
2371
|
+
"id" => ext_id,
|
|
2372
|
+
"name" => market ? (market["name"] || ext_id) : ext_id,
|
|
2373
|
+
"name_zh" => market&.dig("name_zh"),
|
|
2374
|
+
"name_en" => market&.dig("name_en"),
|
|
2375
|
+
"slug" => ext_id,
|
|
2376
|
+
"version" => market ? (market["version"] || container[:version]) : container[:version],
|
|
2377
|
+
"description" => market&.dig("description"),
|
|
2378
|
+
"author" => market&.dig("author"),
|
|
2379
|
+
"icon_url" => market&.dig("icon_url"),
|
|
2380
|
+
"units" => market&.dig("units"),
|
|
2381
|
+
"homepage" => market ? (market["homepage"] || "") : "",
|
|
2382
|
+
"origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
|
|
2383
|
+
"hub_active" => market&.dig("hub_active"),
|
|
2384
|
+
"unlisted" => market.nil?,
|
|
2385
|
+
"layer" => container[:layer].to_s,
|
|
2386
|
+
"installed" => true,
|
|
2387
|
+
"removable" => true,
|
|
2388
|
+
"disabled" => disabled.include?(ext_id),
|
|
2389
|
+
}
|
|
2390
|
+
end
|
|
2391
|
+
|
|
2392
|
+
json_response(res, 200, { ok: true, extensions: extensions })
|
|
2393
|
+
rescue StandardError => e
|
|
2394
|
+
json_response(res, 500, { ok: false, error: e.message })
|
|
2395
|
+
end
|
|
2396
|
+
|
|
2397
|
+
private def fetch_batch_market_data(slugs)
|
|
2398
|
+
return {} if slugs.empty?
|
|
2399
|
+
|
|
2400
|
+
client = Clacky::PlatformHttpClient.new
|
|
2401
|
+
slugs.each_slice(50).each_with_object({}) do |batch, result|
|
|
2402
|
+
ids_param = batch.join(",")
|
|
2403
|
+
response = client.get("/api/v1/extensions/batch?ids=#{ids_param}")
|
|
2404
|
+
next unless response[:success]
|
|
2405
|
+
|
|
2406
|
+
Array(response.dig(:data, "extensions")).each { |ext| result[ext["name"]] = ext }
|
|
2407
|
+
end
|
|
2408
|
+
rescue StandardError
|
|
2409
|
+
{}
|
|
2410
|
+
end
|
|
2411
|
+
|
|
2350
2412
|
# Slugs of every extension container currently loaded (any layer), used to
|
|
2351
2413
|
# flag "installed" on the public marketplace catalog.
|
|
2352
2414
|
def installed_extension_slugs
|
|
@@ -2381,7 +2443,32 @@ module Clacky
|
|
|
2381
2443
|
)
|
|
2382
2444
|
json_response(res, 200, { ok: true, extension: ext })
|
|
2383
2445
|
else
|
|
2384
|
-
|
|
2446
|
+
container = extension_container(id)
|
|
2447
|
+
if container && container[:layer] == :installed
|
|
2448
|
+
market = fetch_batch_market_data([id])[id]
|
|
2449
|
+
ext = {
|
|
2450
|
+
"id" => id,
|
|
2451
|
+
"name" => market ? (market["name"] || id) : id,
|
|
2452
|
+
"name_zh" => market&.dig("name_zh"),
|
|
2453
|
+
"name_en" => market&.dig("name_en"),
|
|
2454
|
+
"slug" => id,
|
|
2455
|
+
"version" => market ? (market["version"] || container[:version]) : container[:version],
|
|
2456
|
+
"description" => market&.dig("description"),
|
|
2457
|
+
"author" => market&.dig("author"),
|
|
2458
|
+
"icon_url" => market&.dig("icon_url"),
|
|
2459
|
+
"units" => market&.dig("units"),
|
|
2460
|
+
"homepage" => market ? (market["homepage"] || "") : "",
|
|
2461
|
+
"origin" => market ? (market["origin"] || container[:origin]) : container[:origin],
|
|
2462
|
+
"hub_active" => market&.dig("hub_active"),
|
|
2463
|
+
"unlisted" => market.nil?,
|
|
2464
|
+
"installed" => true,
|
|
2465
|
+
"removable" => true,
|
|
2466
|
+
"disabled" => container[:disabled] == true,
|
|
2467
|
+
}
|
|
2468
|
+
json_response(res, 200, { ok: true, extension: ext })
|
|
2469
|
+
else
|
|
2470
|
+
json_response(res, 404, { ok: false, error: result[:error] || "Not found" })
|
|
2471
|
+
end
|
|
2385
2472
|
end
|
|
2386
2473
|
end
|
|
2387
2474
|
|
|
@@ -2899,7 +2986,7 @@ module Clacky
|
|
|
2899
2986
|
require "net/http"
|
|
2900
2987
|
require "uri"
|
|
2901
2988
|
|
|
2902
|
-
oss_base =
|
|
2989
|
+
oss_base = OSS_CDN_BASE
|
|
2903
2990
|
latest_url = "#{oss_base}/latest.txt"
|
|
2904
2991
|
|
|
2905
2992
|
Clacky::Logger.info("[Upgrade] Non-official source — fetching latest version from OSS CDN")
|
|
@@ -3071,11 +3158,12 @@ module Clacky
|
|
|
3071
3158
|
end
|
|
3072
3159
|
|
|
3073
3160
|
# Query the latest openclacky version.
|
|
3074
|
-
# Strategy:
|
|
3075
|
-
# then fall back to `gem list -r`
|
|
3076
|
-
# Uses Terminal (PTY + login shell) so rbenv/mise shims and gem mirrors work correctly.
|
|
3161
|
+
# Strategy: OSS CDN latest.txt first (fast, CDN-accelerated), then RubyGems API,
|
|
3162
|
+
# then fall back to `gem list -r` as a last resort.
|
|
3077
3163
|
private def fetch_latest_version_from_gem
|
|
3078
|
-
|
|
3164
|
+
fetch_oss_latest_version("#{OSS_CDN_BASE}/latest.txt") ||
|
|
3165
|
+
fetch_latest_version_from_rubygems_api ||
|
|
3166
|
+
fetch_latest_version_from_gem_command
|
|
3079
3167
|
end
|
|
3080
3168
|
|
|
3081
3169
|
# Try RubyGems official REST API — fast and always up-to-date.
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/app.css
CHANGED
|
@@ -1874,9 +1874,7 @@ body {
|
|
|
1874
1874
|
flex-direction: column;
|
|
1875
1875
|
gap: 1.25rem;
|
|
1876
1876
|
width: 100%;
|
|
1877
|
-
max-
|
|
1878
|
-
margin: 0 auto;
|
|
1879
|
-
padding: 5.5rem 1.5rem 1rem;
|
|
1877
|
+
padding: 6rem max(1.5rem, calc(50% - 30rem)) 1rem;
|
|
1880
1878
|
}
|
|
1881
1879
|
.new-session-composer {
|
|
1882
1880
|
position: relative;
|
|
@@ -2007,6 +2005,11 @@ body {
|
|
|
2007
2005
|
font-size: 0.78125rem;
|
|
2008
2006
|
color: var(--color-text-tertiary);
|
|
2009
2007
|
line-height: 1.4;
|
|
2008
|
+
flex: 1;
|
|
2009
|
+
overflow: hidden;
|
|
2010
|
+
display: -webkit-box;
|
|
2011
|
+
-webkit-line-clamp: 2;
|
|
2012
|
+
-webkit-box-orient: vertical;
|
|
2010
2013
|
}
|
|
2011
2014
|
.agent-card-badge {
|
|
2012
2015
|
position: absolute;
|
|
@@ -2026,7 +2029,6 @@ body {
|
|
|
2026
2029
|
color: var(--color-accent-primary);
|
|
2027
2030
|
}
|
|
2028
2031
|
.agent-card-author {
|
|
2029
|
-
margin-top: 0.25rem;
|
|
2030
2032
|
font-size: 0.72rem;
|
|
2031
2033
|
color: var(--color-text-tertiary);
|
|
2032
2034
|
opacity: 0.8;
|
|
@@ -7169,7 +7171,7 @@ body {
|
|
|
7169
7171
|
.extension-card:hover { border-color: var(--color-text-muted); }
|
|
7170
7172
|
.extension-card-main {
|
|
7171
7173
|
display: flex;
|
|
7172
|
-
align-items:
|
|
7174
|
+
align-items: center;
|
|
7173
7175
|
gap: 0.75rem;
|
|
7174
7176
|
}
|
|
7175
7177
|
.extension-emoji {
|
|
@@ -7195,7 +7197,7 @@ body {
|
|
|
7195
7197
|
color: var(--color-text-secondary);
|
|
7196
7198
|
background: var(--color-bg-tertiary, rgba(255,255,255,0.05));
|
|
7197
7199
|
border-radius: 4px;
|
|
7198
|
-
padding: 0.0625rem
|
|
7200
|
+
padding: 0.0625rem 0.375rem;
|
|
7199
7201
|
}
|
|
7200
7202
|
.extension-units {
|
|
7201
7203
|
font-size: 0.6875rem;
|
|
@@ -7208,6 +7210,13 @@ body {
|
|
|
7208
7210
|
border-radius: 4px;
|
|
7209
7211
|
padding: 0.0625rem 0.375rem;
|
|
7210
7212
|
}
|
|
7213
|
+
.extension-unlisted {
|
|
7214
|
+
font-size: 0.6875rem;
|
|
7215
|
+
color: var(--color-text-secondary);
|
|
7216
|
+
background: var(--color-surface-2, rgba(0,0,0,0.06));
|
|
7217
|
+
border-radius: 4px;
|
|
7218
|
+
padding: 0.0625rem 0.375rem;
|
|
7219
|
+
}
|
|
7211
7220
|
.extension-desc {
|
|
7212
7221
|
font-size: 0.75rem;
|
|
7213
7222
|
color: var(--color-text-secondary);
|
|
@@ -7222,6 +7231,11 @@ body {
|
|
|
7222
7231
|
font-size: 0.6875rem;
|
|
7223
7232
|
color: var(--color-text-muted);
|
|
7224
7233
|
}
|
|
7234
|
+
.extension-author {
|
|
7235
|
+
font-size: 0.75rem;
|
|
7236
|
+
color: var(--color-text-muted);
|
|
7237
|
+
font-weight: 400;
|
|
7238
|
+
}
|
|
7225
7239
|
.extension-meta {
|
|
7226
7240
|
display: flex;
|
|
7227
7241
|
align-items: center;
|
|
@@ -77,10 +77,14 @@ const ExtensionsStore = (() => {
|
|
|
77
77
|
const res = await fetch("/api/store/extensions" + (qs ? "?" + qs : ""));
|
|
78
78
|
const data = await res.json();
|
|
79
79
|
_allExtensions = data.extensions || [];
|
|
80
|
-
_extensions = _filterInstalled ? _allExtensions.filter(e => e.installed) : _allExtensions;
|
|
81
80
|
_error = data.warning || null;
|
|
82
81
|
_loading = false;
|
|
83
|
-
|
|
82
|
+
if (_filterInstalled) {
|
|
83
|
+
await Extensions.setFilterInstalled(true);
|
|
84
|
+
} else {
|
|
85
|
+
_extensions = _allExtensions;
|
|
86
|
+
_emit("extensions:changed", { extensions: _extensions, warning: _error });
|
|
87
|
+
}
|
|
84
88
|
} catch (e) {
|
|
85
89
|
console.error("[Extensions] load failed", e);
|
|
86
90
|
_extensions = [];
|
|
@@ -104,11 +108,26 @@ const ExtensionsStore = (() => {
|
|
|
104
108
|
return Extensions.load();
|
|
105
109
|
},
|
|
106
110
|
|
|
107
|
-
/** Toggle the "installed only" filter
|
|
108
|
-
setFilterInstalled(onlyInstalled) {
|
|
111
|
+
/** Toggle the "installed only" filter — fetches from local store when enabled. */
|
|
112
|
+
async setFilterInstalled(onlyInstalled) {
|
|
109
113
|
_filterInstalled = !!onlyInstalled;
|
|
110
|
-
|
|
111
|
-
|
|
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
|
+
}
|
|
112
131
|
},
|
|
113
132
|
|
|
114
133
|
/** Open the detail view for one extension (fetches contributes + versions). */
|
|
@@ -158,8 +177,7 @@ const ExtensionsStore = (() => {
|
|
|
158
177
|
});
|
|
159
178
|
const data = await res.json();
|
|
160
179
|
if (!res.ok || !data.ok) throw new Error(data.error || "toggle failed");
|
|
161
|
-
|
|
162
|
-
Extensions.load();
|
|
180
|
+
location.reload();
|
|
163
181
|
} catch (e) {
|
|
164
182
|
console.error("[Extensions] setEnabled failed", e);
|
|
165
183
|
_detailError = e.message;
|
|
@@ -184,7 +202,7 @@ const ExtensionsStore = (() => {
|
|
|
184
202
|
});
|
|
185
203
|
const data = await res.json();
|
|
186
204
|
if (!res.ok || !data.ok) throw new Error(data.error || "install failed");
|
|
187
|
-
|
|
205
|
+
location.reload();
|
|
188
206
|
} catch (e) {
|
|
189
207
|
console.error("[Extensions] install failed", e);
|
|
190
208
|
_detailError = e.message;
|
|
@@ -203,7 +221,7 @@ const ExtensionsStore = (() => {
|
|
|
203
221
|
});
|
|
204
222
|
const data = await res.json();
|
|
205
223
|
if (!res.ok || !data.ok) throw new Error(data.error || "uninstall failed");
|
|
206
|
-
|
|
224
|
+
location.reload();
|
|
207
225
|
} catch (e) {
|
|
208
226
|
console.error("[Extensions] uninstall failed", e);
|
|
209
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
|
|
|
@@ -219,6 +222,8 @@ const ExtensionsView = (() => {
|
|
|
219
222
|
? `<span class="extension-version">v${escapeHtml(String(ext.version))}</span>` : "";
|
|
220
223
|
const installedHtml = ext.installed
|
|
221
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>` : "";
|
|
222
227
|
const unitsText = _formatUnits(ext.units);
|
|
223
228
|
const unitsHtml = unitsText
|
|
224
229
|
? `<span class="extension-units">${escapeHtml(unitsText)}</span>` : "";
|
|
@@ -226,7 +231,7 @@ const ExtensionsView = (() => {
|
|
|
226
231
|
? `<a class="extension-homepage" href="${escapeHtml(ext.homepage)}" target="_blank" rel="noopener noreferrer">${I18n.t("extensions.homepage")}</a>`
|
|
227
232
|
: "";
|
|
228
233
|
const authorHtml = ext.author
|
|
229
|
-
? `<span class="extension-
|
|
234
|
+
? `<span class="extension-author">· ${escapeHtml(ext.author)}</span>` : "";
|
|
230
235
|
|
|
231
236
|
return `
|
|
232
237
|
<div class="extension-detail-hero">
|
|
@@ -234,12 +239,14 @@ const ExtensionsView = (() => {
|
|
|
234
239
|
<div class="extension-detail-heading">
|
|
235
240
|
<div class="extension-title">
|
|
236
241
|
<span class="extension-name extension-name-lg">${escapeHtml(name)}</span>
|
|
242
|
+
${authorHtml}
|
|
237
243
|
${versionHtml}
|
|
238
244
|
${installedHtml}
|
|
245
|
+
${unlistedHtml}
|
|
239
246
|
${unitsHtml}
|
|
240
247
|
</div>
|
|
241
248
|
${description ? `<div class="extension-desc extension-desc-detail">${escapeHtml(description)}</div>` : ""}
|
|
242
|
-
${
|
|
249
|
+
${homepageHtml ? `<div class="extension-meta">${homepageHtml}</div>` : ""}
|
|
243
250
|
${_renderActions(ext)}
|
|
244
251
|
</div>
|
|
245
252
|
</div>
|
|
@@ -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,7 +508,7 @@ 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",
|
|
@@ -519,7 +519,9 @@ const I18n = (() => {
|
|
|
519
519
|
"extensions.noResults": "No extensions match your search.",
|
|
520
520
|
"extensions.loadFailed": "Could not reach the extension store.",
|
|
521
521
|
"extensions.homepage": "Homepage",
|
|
522
|
+
"extensions.by": "by",
|
|
522
523
|
"extensions.installed": "Installed",
|
|
524
|
+
"extensions.unlisted": "Unlisted",
|
|
523
525
|
"extensions.unit.skill": "skill",
|
|
524
526
|
"extensions.unit.skills": "skills",
|
|
525
527
|
"extensions.unit.agent": "agent",
|
|
@@ -1469,7 +1471,7 @@ const I18n = (() => {
|
|
|
1469
1471
|
"mcp.title": "MCP 工具",
|
|
1470
1472
|
"mcp.subtitle": "标准 Model Context Protocol 服务(与 Claude Desktop / Cursor 兼容)。编辑 ~/.clacky/mcp.json 添加或移除服务。",
|
|
1471
1473
|
"extensions.title": "扩展市场",
|
|
1472
|
-
"extensions.subtitle": "
|
|
1474
|
+
"extensions.subtitle": "浏览并管理扩展,用 Agent、技能与面板增强你的工作流。",
|
|
1473
1475
|
"extensions.searchPlaceholder": "搜索扩展…",
|
|
1474
1476
|
"extensions.sort.newest": "最新",
|
|
1475
1477
|
"extensions.sort.updated": "最近更新",
|
|
@@ -1480,7 +1482,9 @@ const I18n = (() => {
|
|
|
1480
1482
|
"extensions.noResults": "没有匹配的扩展。",
|
|
1481
1483
|
"extensions.loadFailed": "无法连接扩展市场。",
|
|
1482
1484
|
"extensions.homepage": "主页",
|
|
1485
|
+
"extensions.by": "作者",
|
|
1483
1486
|
"extensions.installed": "已安装",
|
|
1487
|
+
"extensions.unlisted": "已下架",
|
|
1484
1488
|
"extensions.unit.skill": "个技能",
|
|
1485
1489
|
"extensions.unit.skills": "个技能",
|
|
1486
1490
|
"extensions.unit.agent": "个 Agent",
|
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;
|