openclacky 0.9.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -0
- data/docs/security-design.md +109 -0
- data/lib/clacky/agent/message_compressor_helper.rb +82 -69
- data/lib/clacky/agent/session_serializer.rb +9 -1
- data/lib/clacky/agent/skill_manager.rb +7 -0
- data/lib/clacky/agent.rb +11 -3
- data/lib/clacky/banner.rb +65 -0
- data/lib/clacky/block_font.rb +331 -0
- data/lib/clacky/brand_config.rb +73 -5
- data/lib/clacky/client.rb +129 -633
- data/lib/clacky/default_skills/activate-license/SKILL.md +118 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +10 -20
- data/lib/clacky/message_format/anthropic.rb +241 -0
- data/lib/clacky/message_format/open_ai.rb +135 -0
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +2 -0
- data/lib/clacky/server/channel/adapters/wecom/ws_client.rb +13 -0
- data/lib/clacky/server/http_server.rb +12 -2
- data/lib/clacky/session_manager.rb +7 -2
- data/lib/clacky/tools/browser.rb +109 -280
- data/lib/clacky/ui2/block_font.rb +10 -0
- data/lib/clacky/ui2/components/welcome_banner.rb +23 -22
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +588 -6
- data/lib/clacky/web/app.js +30 -15
- data/lib/clacky/web/brand.js +141 -9
- data/lib/clacky/web/i18n.js +28 -2
- data/lib/clacky/web/index.html +142 -127
- data/lib/clacky/web/onboard.js +192 -225
- data/lib/clacky/web/sessions.js +12 -8
- data/lib/clacky/web/settings.js +57 -4
- data/lib/clacky.rb +2 -0
- data/scripts/install.sh +60 -15
- metadata +8 -1
data/lib/clacky/web/app.js
CHANGED
|
@@ -38,8 +38,8 @@ function escapeHtml(str) {
|
|
|
38
38
|
// one is shown. Modules must NOT touch panel display styles directly.
|
|
39
39
|
// ─────────────────────────────────────────────────────────────────────────
|
|
40
40
|
const PANELS = [
|
|
41
|
+
"setup-panel",
|
|
41
42
|
"onboard-panel",
|
|
42
|
-
"brand-panel",
|
|
43
43
|
"welcome",
|
|
44
44
|
"chat-panel",
|
|
45
45
|
"task-detail-panel",
|
|
@@ -188,8 +188,13 @@ const Router = (() => {
|
|
|
188
188
|
Sessions.renderList();
|
|
189
189
|
break;
|
|
190
190
|
|
|
191
|
+
case "setup":
|
|
192
|
+
// Full-screen mandatory setup (language + API key). No hash — keep URL clean.
|
|
193
|
+
$("setup-panel").style.display = "flex";
|
|
194
|
+
break;
|
|
195
|
+
|
|
191
196
|
case "onboard":
|
|
192
|
-
//
|
|
197
|
+
// Kept for compatibility; setup-panel is now used for first-run setup.
|
|
193
198
|
$("onboard-panel").style.display = "flex";
|
|
194
199
|
break;
|
|
195
200
|
|
|
@@ -911,23 +916,33 @@ Settings.init();
|
|
|
911
916
|
Channels.init();
|
|
912
917
|
|
|
913
918
|
// Boot sequence:
|
|
914
|
-
// 1.
|
|
915
|
-
//
|
|
919
|
+
// 1. Brand check — shows a dismissible top banner if license activation is needed.
|
|
920
|
+
// Never blocks boot; user can activate at any time via the banner.
|
|
921
|
+
// 2. Onboard check — first-run setup (key_setup / soul_setup)
|
|
916
922
|
// 3. Normal UI boot — WS + sessions + tasks + skills
|
|
917
923
|
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
//
|
|
921
|
-
//
|
|
922
|
-
(async () => {
|
|
923
|
-
const { needsOnboard, phase } = await Onboard.check();
|
|
924
|
+
// key_setup → hard block: shows full-screen setup-panel (language + API key).
|
|
925
|
+
// On success, setup-panel auto-launches /onboard session then boots UI.
|
|
926
|
+
// soul_setup → soft: auto-launches /onboard session and boots UI immediately.
|
|
927
|
+
// No blocking panel shown.
|
|
924
928
|
|
|
929
|
+
window.bootAfterBrand = async function() {
|
|
930
|
+
const { needsOnboard, phase } = await Onboard.check();
|
|
931
|
+
// key_setup blocks boot entirely; onboard.js calls _bootUI() when done.
|
|
925
932
|
if (needsOnboard && phase === "key_setup") return;
|
|
926
933
|
|
|
927
|
-
|
|
928
|
-
|
|
934
|
+
// soul_setup: Onboard.check() already launched the session and called _bootUI().
|
|
935
|
+
// For any other state, boot normally here.
|
|
936
|
+
if (!needsOnboard) {
|
|
937
|
+
WS.connect(); // triggers session_list → Router.restoreFromHash()
|
|
938
|
+
Tasks.load();
|
|
939
|
+
Skills.load();
|
|
940
|
+
}
|
|
941
|
+
};
|
|
929
942
|
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
943
|
+
(async () => {
|
|
944
|
+
// Brand.check() now only shows a top banner when activation is needed;
|
|
945
|
+
// it never returns true to block boot, so we always continue to bootAfterBrand().
|
|
946
|
+
await Brand.check();
|
|
947
|
+
await window.bootAfterBrand();
|
|
933
948
|
})();
|
data/lib/clacky/web/brand.js
CHANGED
|
@@ -31,12 +31,17 @@ const Brand = (() => {
|
|
|
31
31
|
// so no DOM update is needed here on boot.
|
|
32
32
|
|
|
33
33
|
if (data.needs_activation) {
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// Show a top banner instead of a blocking full-screen panel.
|
|
35
|
+
// Boot continues normally; user can activate at any time via the banner.
|
|
36
|
+
_showActivationBanner(data.brand_name);
|
|
37
|
+
return false;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
if (data.warning) _showWarning(data.warning);
|
|
39
41
|
|
|
42
|
+
// Load full brand info to apply logo in header
|
|
43
|
+
_applyHeaderLogo();
|
|
44
|
+
|
|
40
45
|
return false;
|
|
41
46
|
} catch (_) {
|
|
42
47
|
return false;
|
|
@@ -45,6 +50,71 @@ const Brand = (() => {
|
|
|
45
50
|
|
|
46
51
|
// ── Internal ───────────────────────────────────────────────────────────────
|
|
47
52
|
|
|
53
|
+
// Show a dismissible activation banner at the top of the page.
|
|
54
|
+
// Clicking the banner creates a dedicated session and invokes the
|
|
55
|
+
// /activate-license skill to guide the user through activation interactively.
|
|
56
|
+
function _showActivationBanner(brandName) {
|
|
57
|
+
const existing = document.getElementById("brand-activation-banner");
|
|
58
|
+
if (existing) return;
|
|
59
|
+
|
|
60
|
+
const bar = document.createElement("div");
|
|
61
|
+
bar.id = "brand-activation-banner";
|
|
62
|
+
bar.className = "brand-activation-banner";
|
|
63
|
+
|
|
64
|
+
const span = document.createElement("span");
|
|
65
|
+
const name = brandName || I18n.t("brand.banner.defaultName");
|
|
66
|
+
span.textContent = I18n.t("brand.banner.prompt", { name });
|
|
67
|
+
span.setAttribute("data-i18n", "brand.banner.prompt");
|
|
68
|
+
if (brandName) span.setAttribute("data-i18n-vars", `name=${brandName}`);
|
|
69
|
+
|
|
70
|
+
const link = document.createElement("button");
|
|
71
|
+
link.className = "brand-activation-banner-link";
|
|
72
|
+
link.textContent = I18n.t("brand.banner.action");
|
|
73
|
+
link.setAttribute("data-i18n", "brand.banner.action");
|
|
74
|
+
link.addEventListener("click", () => _startActivationSession(brandName));
|
|
75
|
+
|
|
76
|
+
const closeBtn = document.createElement("button");
|
|
77
|
+
closeBtn.className = "brand-activation-banner-close";
|
|
78
|
+
closeBtn.innerHTML = "✕";
|
|
79
|
+
closeBtn.onclick = () => bar.remove();
|
|
80
|
+
|
|
81
|
+
bar.appendChild(span);
|
|
82
|
+
bar.appendChild(link);
|
|
83
|
+
bar.appendChild(closeBtn);
|
|
84
|
+
document.getElementById("main").prepend(bar);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Create a dedicated session and send the /activate-license slash command
|
|
88
|
+
// to guide the user through license activation interactively.
|
|
89
|
+
async function _startActivationSession(brandName) {
|
|
90
|
+
try {
|
|
91
|
+
const lang = (typeof I18n.lang === "function" ? I18n.lang() : null) || "zh";
|
|
92
|
+
const res = await fetch("/api/sessions", {
|
|
93
|
+
method: "POST",
|
|
94
|
+
headers: { "Content-Type": "application/json" },
|
|
95
|
+
body: JSON.stringify({ name: "🔑 " + I18n.t("brand.banner.sessionName") })
|
|
96
|
+
});
|
|
97
|
+
const data = await res.json();
|
|
98
|
+
const session = data.session;
|
|
99
|
+
if (!session) throw new Error("No session returned");
|
|
100
|
+
|
|
101
|
+
const nameArg = brandName ? ` name:${brandName}` : "";
|
|
102
|
+
Sessions.add(session);
|
|
103
|
+
Sessions.renderList();
|
|
104
|
+
Sessions.setPendingMessage(session.id, `/activate-license lang:${lang}${nameArg}`);
|
|
105
|
+
Sessions.select(session.id);
|
|
106
|
+
} catch (e) {
|
|
107
|
+
// Fallback: show inline error in banner
|
|
108
|
+
const banner = document.getElementById("brand-activation-banner");
|
|
109
|
+
if (banner) {
|
|
110
|
+
const err = document.createElement("span");
|
|
111
|
+
err.style.color = "var(--color-danger)";
|
|
112
|
+
err.textContent = " " + e.message;
|
|
113
|
+
banner.appendChild(err);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
48
118
|
function _showActivationPanel(brandName) {
|
|
49
119
|
if (brandName) {
|
|
50
120
|
const title = $("brand-title");
|
|
@@ -94,6 +164,7 @@ const Brand = (() => {
|
|
|
94
164
|
if (data.ok) {
|
|
95
165
|
_setResult(true, I18n.t("brand.activate.success"));
|
|
96
166
|
if (data.brand_name) _applyBrandName(data.brand_name);
|
|
167
|
+
_applyHeaderLogo();
|
|
97
168
|
setTimeout(_bootUI, 800);
|
|
98
169
|
} else {
|
|
99
170
|
_setResult(false, data.error || I18n.t("settings.brand.activationFailed"));
|
|
@@ -108,6 +179,9 @@ const Brand = (() => {
|
|
|
108
179
|
}
|
|
109
180
|
|
|
110
181
|
function _skipActivation() {
|
|
182
|
+
// Show a dismissible warning so the user knows brand features are unavailable.
|
|
183
|
+
// Pass the i18n key so the bar text updates when the user switches language.
|
|
184
|
+
_showWarning(I18n.t("brand.skip.warning"), "brand.skip.warning");
|
|
111
185
|
_bootUI();
|
|
112
186
|
}
|
|
113
187
|
|
|
@@ -133,24 +207,82 @@ const Brand = (() => {
|
|
|
133
207
|
});
|
|
134
208
|
}
|
|
135
209
|
|
|
210
|
+
// Fetch /api/brand and apply logo_url + brand_name to the header if available.
|
|
211
|
+
function _applyHeaderLogo() {
|
|
212
|
+
fetch("/api/brand").then(r => r.json()).then(info => {
|
|
213
|
+
const logoImg = document.getElementById("header-logo-img");
|
|
214
|
+
const logoText = document.getElementById("header-logo");
|
|
215
|
+
const brandWrap = document.getElementById("header-brand");
|
|
216
|
+
|
|
217
|
+
const hasLogo = !!(info.logo_url && logoImg);
|
|
218
|
+
|
|
219
|
+
if (hasLogo) {
|
|
220
|
+
// Pre-load the image; only show it once loaded to avoid layout flicker
|
|
221
|
+
const img = new Image();
|
|
222
|
+
img.onload = () => {
|
|
223
|
+
logoImg.src = info.logo_url;
|
|
224
|
+
logoImg.alt = info.brand_name || "";
|
|
225
|
+
logoImg.style.display = "";
|
|
226
|
+
if (brandWrap) brandWrap.classList.add("has-logo");
|
|
227
|
+
};
|
|
228
|
+
img.onerror = () => {
|
|
229
|
+
// Logo failed to load — keep text-only mode
|
|
230
|
+
};
|
|
231
|
+
img.src = info.logo_url;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Always show brand name text; hide it only when no brand name is set
|
|
235
|
+
if (logoText) {
|
|
236
|
+
const name = info.brand_name || "";
|
|
237
|
+
if (name) {
|
|
238
|
+
logoText.textContent = name;
|
|
239
|
+
logoText.style.display = "";
|
|
240
|
+
} else {
|
|
241
|
+
// No brand name at all — hide the text span
|
|
242
|
+
logoText.style.display = "none";
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}).catch(() => {
|
|
246
|
+
// Silently ignore — logo is non-critical
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
136
250
|
// Show a dismissible warning bar above the main content.
|
|
137
|
-
|
|
251
|
+
// The i18n key is stored on the span so I18n.applyAll() can re-translate
|
|
252
|
+
// it when the user switches language without dismissing the bar.
|
|
253
|
+
function _showWarning(message, i18nKey) {
|
|
138
254
|
const existing = document.getElementById("brand-warning-bar");
|
|
139
255
|
if (existing) return;
|
|
140
256
|
|
|
141
257
|
const bar = document.createElement("div");
|
|
142
258
|
bar.id = "brand-warning-bar";
|
|
143
259
|
bar.className = "brand-warning-bar";
|
|
144
|
-
|
|
145
|
-
|
|
260
|
+
|
|
261
|
+
const span = document.createElement("span");
|
|
262
|
+
span.textContent = message;
|
|
263
|
+
if (i18nKey) span.setAttribute("data-i18n", i18nKey);
|
|
264
|
+
|
|
265
|
+
const btn = document.createElement("button");
|
|
266
|
+
btn.innerHTML = "✕";
|
|
267
|
+
btn.onclick = () => bar.remove();
|
|
268
|
+
|
|
269
|
+
bar.appendChild(span);
|
|
270
|
+
bar.appendChild(btn);
|
|
146
271
|
document.getElementById("main").prepend(bar);
|
|
147
272
|
}
|
|
148
273
|
|
|
149
|
-
//
|
|
274
|
+
// Continue the boot sequence after brand check is resolved (activated or skipped).
|
|
275
|
+
// Delegates to window.bootAfterBrand() defined in app.js so the onboard check
|
|
276
|
+
// runs before WS.connect() — ensures key_setup is shown when no API key exists.
|
|
150
277
|
function _bootUI() {
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
278
|
+
if (typeof window.bootAfterBrand === "function") {
|
|
279
|
+
window.bootAfterBrand();
|
|
280
|
+
} else {
|
|
281
|
+
// Fallback: app.js not yet loaded, boot directly
|
|
282
|
+
WS.connect();
|
|
283
|
+
Tasks.load();
|
|
284
|
+
Skills.load();
|
|
285
|
+
}
|
|
154
286
|
}
|
|
155
287
|
|
|
156
288
|
return { check, applyBrandName: _applyBrandName };
|
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -192,9 +192,12 @@ const I18n = (() => {
|
|
|
192
192
|
"settings.brand.label.brand": "Brand",
|
|
193
193
|
"settings.brand.label.status": "Status",
|
|
194
194
|
"settings.brand.label.expires": "Expires",
|
|
195
|
+
"settings.brand.label.supportQr": "Tech Support",
|
|
196
|
+
"settings.brand.label.qrHint": "Scan with your phone camera",
|
|
195
197
|
"settings.brand.btn.change": "Change License Key",
|
|
196
198
|
"settings.brand.badge.active": "Active",
|
|
197
|
-
"settings.brand.badge.warning": "
|
|
199
|
+
"settings.brand.badge.warning": "Expiring Soon",
|
|
200
|
+
"settings.brand.badge.expired": "Expired",
|
|
198
201
|
"settings.brand.desc": "Have a license key from a brand partner? Enter it below to activate branded mode.",
|
|
199
202
|
"settings.brand.btn.activate": "Activate",
|
|
200
203
|
"settings.brand.btn.activating": "Activating…",
|
|
@@ -219,6 +222,8 @@ const I18n = (() => {
|
|
|
219
222
|
"onboard.key.baseurl": "Base URL",
|
|
220
223
|
"onboard.key.apikey": "API Key",
|
|
221
224
|
"onboard.key.btn.test": "Test & Continue →",
|
|
225
|
+
"onboard.key.btn.back": "← Back",
|
|
226
|
+
"onboard.provider.custom": "Custom",
|
|
222
227
|
"onboard.key.testing": "Testing…",
|
|
223
228
|
"onboard.key.saving": "Saving…",
|
|
224
229
|
"onboard.soul.title": "Personalize your assistant",
|
|
@@ -236,6 +241,14 @@ const I18n = (() => {
|
|
|
236
241
|
"brand.activate.title": "Activate {{name}}",
|
|
237
242
|
"brand.activate.subtitle": "Enter your license key to get started.",
|
|
238
243
|
"brand.activate.success": "License activated successfully!",
|
|
244
|
+
"brand.skip.warning": "Brand license not activated — brand-exclusive skills are unavailable. You can activate your license anytime in Settings → Brand & License.",
|
|
245
|
+
|
|
246
|
+
// ── Brand activation banner ──
|
|
247
|
+
"brand.banner.prompt": "{{name}} is not activated yet — some features are unavailable.",
|
|
248
|
+
"brand.banner.defaultName": "Your license",
|
|
249
|
+
"brand.banner.action": "Activate Now",
|
|
250
|
+
"brand.banner.sessionName": "License Activation",
|
|
251
|
+
|
|
239
252
|
"onboard.welcome": "Welcome to {{name}}",
|
|
240
253
|
},
|
|
241
254
|
|
|
@@ -419,9 +432,12 @@ const I18n = (() => {
|
|
|
419
432
|
"settings.brand.label.brand": "品牌",
|
|
420
433
|
"settings.brand.label.status": "状态",
|
|
421
434
|
"settings.brand.label.expires": "到期时间",
|
|
435
|
+
"settings.brand.label.supportQr": "技术支持",
|
|
436
|
+
"settings.brand.label.qrHint": "使用手机扫描二维码",
|
|
422
437
|
"settings.brand.btn.change": "更换授权码",
|
|
423
438
|
"settings.brand.badge.active": "已激活",
|
|
424
|
-
"settings.brand.badge.warning": "
|
|
439
|
+
"settings.brand.badge.warning": "即将过期",
|
|
440
|
+
"settings.brand.badge.expired": "已过期",
|
|
425
441
|
"settings.brand.desc": "有品牌合作伙伴的授权码?在下方输入以激活品牌模式。",
|
|
426
442
|
"settings.brand.btn.activate": "激活",
|
|
427
443
|
"settings.brand.btn.activating": "激活中…",
|
|
@@ -446,6 +462,8 @@ const I18n = (() => {
|
|
|
446
462
|
"onboard.key.baseurl": "Base URL",
|
|
447
463
|
"onboard.key.apikey": "API Key",
|
|
448
464
|
"onboard.key.btn.test": "测试并继续 →",
|
|
465
|
+
"onboard.key.btn.back": "← 返回",
|
|
466
|
+
"onboard.provider.custom": "自定义",
|
|
449
467
|
"onboard.key.testing": "测试中…",
|
|
450
468
|
"onboard.key.saving": "保存中…",
|
|
451
469
|
"onboard.soul.title": "个性化助手",
|
|
@@ -463,6 +481,14 @@ const I18n = (() => {
|
|
|
463
481
|
"brand.activate.title": "激活 {{name}}",
|
|
464
482
|
"brand.activate.subtitle": "输入授权码以开始使用。",
|
|
465
483
|
"brand.activate.success": "授权激活成功!",
|
|
484
|
+
"brand.skip.warning": "品牌授权未激活 — 品牌专属技能暂不可用。可随时在「设置 → 品牌 & 授权」中激活。",
|
|
485
|
+
|
|
486
|
+
// ── Brand activation banner ──
|
|
487
|
+
"brand.banner.prompt": "{{name}} 尚未激活授权 — 部分功能暂不可用。",
|
|
488
|
+
"brand.banner.defaultName": "您的授权",
|
|
489
|
+
"brand.banner.action": "立即激活",
|
|
490
|
+
"brand.banner.sessionName": "激活授权",
|
|
491
|
+
|
|
466
492
|
"onboard.welcome": "欢迎使用 {{name}}",
|
|
467
493
|
}
|
|
468
494
|
};
|