openclacky 1.1.2 → 1.1.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/.clacky/skills/gem-release/SKILL.md +27 -31
- data/CHANGELOG.md +30 -0
- data/Dockerfile +28 -0
- data/README.md +4 -0
- data/README_CN.md +198 -0
- data/docs/engineering-article.md +343 -0
- data/lib/clacky/agent/llm_caller.rb +2 -5
- data/lib/clacky/agent/session_serializer.rb +4 -0
- data/lib/clacky/agent.rb +22 -1
- data/lib/clacky/brand_config.rb +87 -5
- data/lib/clacky/cli.rb +1 -1
- data/lib/clacky/client.rb +15 -11
- data/lib/clacky/message_format/anthropic.rb +30 -2
- data/lib/clacky/message_format/bedrock.rb +13 -1
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/providers.rb +34 -0
- data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +142 -5
- data/lib/clacky/server/channel/adapters/dingtalk/api_client.rb +309 -0
- data/lib/clacky/server/http_server.rb +130 -15
- data/lib/clacky/server/session_registry.rb +9 -6
- data/lib/clacky/ui2/ui_controller.rb +14 -0
- data/lib/clacky/ui_interface.rb +14 -0
- data/lib/clacky/utils/model_pricing.rb +96 -25
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1286 -1116
- data/lib/clacky/web/brand.js +20 -5
- data/lib/clacky/web/i18n.js +42 -0
- data/lib/clacky/web/index.html +26 -7
- data/lib/clacky/web/onboard.js +6 -0
- data/lib/clacky/web/sessions.js +194 -11
- data/lib/clacky/web/settings.js +51 -10
- data/lib/clacky/web/skills.js +53 -31
- data/lib/clacky/web/vendor/hljs/highlight.min.js +1244 -0
- data/lib/clacky/web/vendor/hljs/hljs-theme.css +95 -0
- data/scripts/build/lib/apt.sh +30 -10
- data/scripts/build/lib/network.sh +3 -2
- data/scripts/install.sh +30 -9
- data/scripts/install_browser.sh +2 -1
- data/scripts/install_full.sh +2 -1
- data/scripts/install_rails_deps.sh +30 -9
- data/scripts/install_system_deps.sh +30 -9
- metadata +7 -17
- data/docs/HOW-TO-USE-CN.md +0 -96
- data/docs/HOW-TO-USE.md +0 -94
- data/docs/browser-cdp-native-design.md +0 -195
- data/docs/c-end-user-positioning.md +0 -64
- data/docs/config.example.yml +0 -27
- data/docs/deploy-architecture.md +0 -619
- data/docs/deploy_subagent_design.md +0 -540
- data/docs/install-script-simplification.md +0 -89
- data/docs/memory-architecture.md +0 -343
- data/docs/openclacky_cloud_api_reference.md +0 -584
- data/docs/security-design.md +0 -109
- data/docs/session-management-redesign.md +0 -202
- data/docs/system-skill-authoring-guide.md +0 -47
- data/docs/why-developer.md +0 -371
- data/docs/why-openclacky.md +0 -266
data/lib/clacky/web/brand.js
CHANGED
|
@@ -39,7 +39,7 @@ const Brand = (() => {
|
|
|
39
39
|
if (data.needs_activation) {
|
|
40
40
|
// Show a top banner instead of a blocking full-screen panel.
|
|
41
41
|
// Boot continues normally; user can activate at any time via the banner.
|
|
42
|
-
_showActivationBanner(data.product_name);
|
|
42
|
+
_showActivationBanner(data.product_name, data.free_skills_count, data.paid_skills_count);
|
|
43
43
|
|
|
44
44
|
// Apply logo/theme from whatever is already cached in brand.yml —
|
|
45
45
|
// install.sh only writes product_name + package_name, but if a
|
|
@@ -77,7 +77,7 @@ const Brand = (() => {
|
|
|
77
77
|
// Show a dismissible activation banner at the top of the page.
|
|
78
78
|
// Clicking the banner creates a dedicated session and invokes the
|
|
79
79
|
// Clicking the banner opens Settings and focuses the license key input directly.
|
|
80
|
-
function _showActivationBanner(brandName) {
|
|
80
|
+
function _showActivationBanner(brandName, freeCount, paidCount) {
|
|
81
81
|
const existing = document.getElementById("brand-activation-banner");
|
|
82
82
|
if (existing) return;
|
|
83
83
|
|
|
@@ -87,9 +87,19 @@ const Brand = (() => {
|
|
|
87
87
|
|
|
88
88
|
const span = document.createElement("span");
|
|
89
89
|
const name = brandName || I18n.t("brand.banner.defaultName");
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
90
|
+
const free = Number(freeCount) || 0;
|
|
91
|
+
const paid = Number(paidCount) || 0;
|
|
92
|
+
|
|
93
|
+
let i18nKey;
|
|
94
|
+
if (free > 0 && paid > 0) i18nKey = "brand.banner.freePromptBoth";
|
|
95
|
+
else if (free > 0 && paid === 0) i18nKey = "brand.banner.freePromptOnlyFree";
|
|
96
|
+
else if (free === 0 && paid > 0) i18nKey = "brand.banner.freePromptOnlyPaid";
|
|
97
|
+
else i18nKey = "brand.banner.prompt";
|
|
98
|
+
|
|
99
|
+
const vars = { name, free, paid, freePlural: free === 1 ? "" : "s", paidPlural: paid === 1 ? "" : "s" };
|
|
100
|
+
span.textContent = I18n.t(i18nKey, vars);
|
|
101
|
+
span.setAttribute("data-i18n", i18nKey);
|
|
102
|
+
span.setAttribute("data-i18n-vars", `name=${name};free=${free};paid=${paid};freePlural=${vars.freePlural};paidPlural=${vars.paidPlural}`);
|
|
93
103
|
|
|
94
104
|
const link = document.createElement("button");
|
|
95
105
|
link.className = "brand-activation-banner-link";
|
|
@@ -97,6 +107,11 @@ const Brand = (() => {
|
|
|
97
107
|
link.setAttribute("data-i18n", "brand.banner.action");
|
|
98
108
|
link.addEventListener("click", () => _goToLicenseInput());
|
|
99
109
|
|
|
110
|
+
// Hide the "Activate Now" button when there is nothing premium to unlock.
|
|
111
|
+
if (paid === 0 && free > 0) {
|
|
112
|
+
link.style.display = "none";
|
|
113
|
+
}
|
|
114
|
+
|
|
100
115
|
const closeBtn = document.createElement("button");
|
|
101
116
|
closeBtn.className = "brand-activation-banner-close";
|
|
102
117
|
closeBtn.innerHTML = "✕";
|
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -78,6 +78,14 @@ const I18n = (() => {
|
|
|
78
78
|
"sib.dir.changePrompt": "Change working directory:",
|
|
79
79
|
"sib.model.tooltip": "Click to switch model",
|
|
80
80
|
"sib.signal.tooltip": "Recent LLM latency",
|
|
81
|
+
"sib.reasoning.tooltip": "Click to change reasoning effort",
|
|
82
|
+
"sib.reasoning.label": "Reasoning",
|
|
83
|
+
"sib.reasoning.heading": "Reasoning effort",
|
|
84
|
+
"sib.reasoning.hint": "Higher effort = deeper thinking, slower response.",
|
|
85
|
+
"sib.reasoning.off": "Default",
|
|
86
|
+
"sib.reasoning.low": "Low",
|
|
87
|
+
"sib.reasoning.medium": "Medium",
|
|
88
|
+
"sib.reasoning.high": "High",
|
|
81
89
|
"sessions.thinking": "Thinking…",
|
|
82
90
|
"sessions.default_name": "Session {{n}}",
|
|
83
91
|
"sessions.badge.cron": "Auto",
|
|
@@ -330,6 +338,10 @@ const I18n = (() => {
|
|
|
330
338
|
"skills.brand.btn.use": "Use",
|
|
331
339
|
"skills.brand.private": "Private",
|
|
332
340
|
"skills.brand.privateTip": "Private — licensed to your organization",
|
|
341
|
+
"skills.brand.free": "Free",
|
|
342
|
+
"skills.brand.freeTip": "Free — no serial number required",
|
|
343
|
+
"skills.brand.lockedTip": "Activate your license to unlock this premium skill.",
|
|
344
|
+
"skills.brand.paidHint": "{{n}} more premium skill(s) available — activate your license to unlock.",
|
|
333
345
|
"skills.brand.installFailed": "Install failed: ",
|
|
334
346
|
"skills.brand.unknownError": "unknown error",
|
|
335
347
|
"skills.brand.networkError": "Network error during install.",
|
|
@@ -481,6 +493,11 @@ const I18n = (() => {
|
|
|
481
493
|
"settings.lang.en": "English",
|
|
482
494
|
"settings.lang.zh": "中文",
|
|
483
495
|
|
|
496
|
+
"settings.fontSize.title": "Font Size",
|
|
497
|
+
"settings.fontSize.small": "Small",
|
|
498
|
+
"settings.fontSize.medium": "Medium",
|
|
499
|
+
"settings.fontSize.large": "Large",
|
|
500
|
+
|
|
484
501
|
// ── Onboard ──
|
|
485
502
|
"onboard.title": "Welcome to {{brand}}",
|
|
486
503
|
"onboard.subtitle": "Let's get you set up in a minute.",
|
|
@@ -526,6 +543,10 @@ const I18n = (() => {
|
|
|
526
543
|
"brand.banner.defaultName": "Your license",
|
|
527
544
|
"brand.banner.action": "Activate Now",
|
|
528
545
|
"brand.banner.sessionName": "License Activation",
|
|
546
|
+
"brand.banner.freePromptZero": "Welcome to {{name}} — {{paid}} premium skill{{paidPlural}} can be unlocked with a serial number.",
|
|
547
|
+
"brand.banner.freePromptOnlyFree": "Welcome to {{name}} — {{free}} free skill{{freePlural}} ready to use.",
|
|
548
|
+
"brand.banner.freePromptOnlyPaid": "Welcome to {{name}} — {{paid}} premium skill{{paidPlural}} can be unlocked with a serial number.",
|
|
549
|
+
"brand.banner.freePromptBoth": "Welcome to {{name}} — {{free}} free skill{{freePlural}} ready to use, plus {{paid}} premium skill{{paidPlural}} unlockable with a serial number.",
|
|
529
550
|
|
|
530
551
|
"header.owner.tooltip": "Creator — click to open Creator Hub",
|
|
531
552
|
|
|
@@ -604,6 +625,14 @@ const I18n = (() => {
|
|
|
604
625
|
"sib.dir.changePrompt": "切换工作目录:",
|
|
605
626
|
"sib.model.tooltip": "点击切换模型",
|
|
606
627
|
"sib.signal.tooltip": "最近一次 LLM 响应延迟",
|
|
628
|
+
"sib.reasoning.tooltip": "点击调整思考等级",
|
|
629
|
+
"sib.reasoning.label": "思考",
|
|
630
|
+
"sib.reasoning.heading": "思考等级",
|
|
631
|
+
"sib.reasoning.hint": "等级越高,思考越深,响应越慢。",
|
|
632
|
+
"sib.reasoning.off": "默认",
|
|
633
|
+
"sib.reasoning.low": "低",
|
|
634
|
+
"sib.reasoning.medium": "中",
|
|
635
|
+
"sib.reasoning.high": "高",
|
|
607
636
|
"sessions.thinking": "思考中…",
|
|
608
637
|
"sessions.default_name": "对话 {{n}}",
|
|
609
638
|
"sessions.badge.cron": "定时",
|
|
@@ -853,6 +882,10 @@ const I18n = (() => {
|
|
|
853
882
|
"skills.brand.btn.use": "使用",
|
|
854
883
|
"skills.brand.private": "私有",
|
|
855
884
|
"skills.brand.privateTip": "私有 — 仅授权给您的组织",
|
|
885
|
+
"skills.brand.free": "免费",
|
|
886
|
+
"skills.brand.freeTip": "免费 — 无需序列号即可使用",
|
|
887
|
+
"skills.brand.lockedTip": "激活授权后可解锁该增值技能。",
|
|
888
|
+
"skills.brand.paidHint": "还有 {{n}} 个增值技能 — 激活序列号后即可解锁。",
|
|
856
889
|
"skills.brand.installFailed": "安装失败:",
|
|
857
890
|
"skills.brand.unknownError": "未知错误",
|
|
858
891
|
"skills.brand.networkError": "安装时网络错误。",
|
|
@@ -1004,6 +1037,11 @@ const I18n = (() => {
|
|
|
1004
1037
|
"settings.lang.en": "English",
|
|
1005
1038
|
"settings.lang.zh": "中文",
|
|
1006
1039
|
|
|
1040
|
+
"settings.fontSize.title": "字体大小",
|
|
1041
|
+
"settings.fontSize.small": "小",
|
|
1042
|
+
"settings.fontSize.medium": "中",
|
|
1043
|
+
"settings.fontSize.large": "大",
|
|
1044
|
+
|
|
1007
1045
|
// ── Onboard ──
|
|
1008
1046
|
"onboard.title": "欢迎使用 {{brand}}",
|
|
1009
1047
|
"onboard.subtitle": "一分钟完成配置,马上开始。",
|
|
@@ -1049,6 +1087,10 @@ const I18n = (() => {
|
|
|
1049
1087
|
"brand.banner.defaultName": "您的授权",
|
|
1050
1088
|
"brand.banner.action": "立即激活",
|
|
1051
1089
|
"brand.banner.sessionName": "激活授权",
|
|
1090
|
+
"brand.banner.freePromptZero": "欢迎使用 {{name}} — 还有 {{paid}} 个增值技能,输入序列号后即可解锁。",
|
|
1091
|
+
"brand.banner.freePromptOnlyFree": "欢迎使用 {{name}} — 已自动安装 {{free}} 个免费技能,现可直接使用。",
|
|
1092
|
+
"brand.banner.freePromptOnlyPaid": "欢迎使用 {{name}} — 还有 {{paid}} 个增值技能,输入序列号后即可解锁。",
|
|
1093
|
+
"brand.banner.freePromptBoth": "欢迎使用 {{name}} — 已自动安装 {{free}} 个免费技能,还有 {{paid}} 个增值技能可输入序列号解锁。",
|
|
1052
1094
|
|
|
1053
1095
|
"header.owner.tooltip": "创作者 — 点击进入创作者中心",
|
|
1054
1096
|
|
data/lib/clacky/web/index.html
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
|
-
<html lang="en" id="html-root">
|
|
2
|
+
<html lang="en" id="html-root" data-font-size="medium">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8">
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
6
|
<title id="page-title">{{BRAND_NAME}}</title>
|
|
7
7
|
<link rel="icon" type="image/svg+xml" href="/icon.svg">
|
|
8
8
|
<link rel="stylesheet" href="/vendor/katex/katex.min.css">
|
|
9
|
+
<link rel="stylesheet" href="/vendor/hljs/hljs-theme.css">
|
|
9
10
|
<link rel="stylesheet" href="/app.css">
|
|
10
11
|
<script>
|
|
11
12
|
// Inline theme init — must run before CSS renders to prevent flash of wrong theme.
|
|
@@ -308,6 +309,11 @@
|
|
|
308
309
|
<div id="sib-model-dropdown" class="sib-model-dropdown" style="display:none"></div>
|
|
309
310
|
</span>
|
|
310
311
|
<span class="sib-sep sib-sep-after-model">│</span>
|
|
312
|
+
<span id="sib-reasoning-wrap">
|
|
313
|
+
<span id="sib-reasoning" class="sib-reasoning-clickable" data-i18n-title="sib.reasoning.tooltip" title="Click to change reasoning effort"></span>
|
|
314
|
+
<div id="sib-reasoning-dropdown" class="sib-reasoning-dropdown" style="display:none" role="menu"></div>
|
|
315
|
+
</span>
|
|
316
|
+
<span class="sib-sep sib-sep-after-reasoning">│</span>
|
|
311
317
|
<!-- Latency signal: 4-bar signal + TTFT number. Hidden until the first LLM
|
|
312
318
|
call completes (see updateInfoBar / Sessions.renderSignalBars). Click
|
|
313
319
|
opens a mini benchmark panel (see Step 3/4 — not yet implemented). -->
|
|
@@ -380,7 +386,7 @@
|
|
|
380
386
|
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
|
|
381
387
|
</svg>
|
|
382
388
|
<span data-i18n="creator.promo.text">Publish your skills & build your own brand on OpenClacky.</span>
|
|
383
|
-
<a href="https://www.openclacky.com" target="_blank" data-i18n="creator.promo.link">Learn more →</a>
|
|
389
|
+
<a href="https://www.openclacky.com/creators" target="_blank" data-i18n="creator.promo.link">Learn more →</a>
|
|
384
390
|
</div>
|
|
385
391
|
|
|
386
392
|
<!-- Section 1: Cloud skills (published) -->
|
|
@@ -700,6 +706,18 @@
|
|
|
700
706
|
</div>
|
|
701
707
|
</section>
|
|
702
708
|
|
|
709
|
+
<!-- Font Size section -->
|
|
710
|
+
<section class="settings-section" id="font-size-section">
|
|
711
|
+
<div class="settings-section-title">
|
|
712
|
+
<span data-i18n="settings.fontSize.title">Font Size</span>
|
|
713
|
+
</div>
|
|
714
|
+
<div class="settings-lang-btns">
|
|
715
|
+
<button id="settings-btn-font-small" class="settings-lang-btn" data-font="small" data-i18n="settings.fontSize.small">小</button>
|
|
716
|
+
<button id="settings-btn-font-medium" class="settings-lang-btn" data-font="medium" data-i18n="settings.fontSize.medium">中</button>
|
|
717
|
+
<button id="settings-btn-font-large" class="settings-lang-btn" data-font="large" data-i18n="settings.fontSize.large">大</button>
|
|
718
|
+
</div>
|
|
719
|
+
</section>
|
|
720
|
+
|
|
703
721
|
<!-- Brand & License section -->
|
|
704
722
|
<section class="settings-section" id="brand-license-section">
|
|
705
723
|
<div class="settings-section-title">
|
|
@@ -834,7 +852,7 @@
|
|
|
834
852
|
<div class="setup-field">
|
|
835
853
|
<label class="setup-label" data-i18n="onboard.key.provider">Provider</label>
|
|
836
854
|
<div class="custom-select-wrapper" id="setup-provider-wrapper">
|
|
837
|
-
<div class="custom-select-trigger">
|
|
855
|
+
<div class="custom-select-trigger" tabindex="0">
|
|
838
856
|
<span class="custom-select-value placeholder" data-i18n="onboard.key.provider.placeholder">— Choose provider —</span>
|
|
839
857
|
<svg class="custom-select-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
840
858
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
@@ -872,7 +890,7 @@
|
|
|
872
890
|
<div class="setup-field">
|
|
873
891
|
<label class="setup-label">
|
|
874
892
|
<span data-i18n="onboard.key.apikey">API Key</span>
|
|
875
|
-
<a id="setup-get-apikey-link" href="#" target="_blank" rel="noopener" style="display:none;margin-left:
|
|
893
|
+
<a id="setup-get-apikey-link" href="#" target="_blank" rel="noopener" style="display:none;margin-left:0.5rem;font-size:0.75rem;color:var(--accent,#6366f1);text-decoration:none;opacity:0.85;" data-i18n="onboard.key.getApiKey">How to get →</a>
|
|
876
894
|
</label>
|
|
877
895
|
<div class="setup-input-row">
|
|
878
896
|
<input id="setup-api-key" type="password" class="setup-input" data-i18n-placeholder="settings.models.placeholder.apikey" placeholder="sk-…">
|
|
@@ -885,9 +903,9 @@
|
|
|
885
903
|
</div>
|
|
886
904
|
</div>
|
|
887
905
|
|
|
888
|
-
<div class="setup-docs-hint" style="font-size:
|
|
906
|
+
<div class="setup-docs-hint" style="font-size:0.75rem;margin-top:2px;text-align:left;">
|
|
889
907
|
<span style="color:var(--muted,#6b7280);" data-i18n="onboard.key.docsGuide.question">New to AI keys?</span>
|
|
890
|
-
<a id="setup-docs-link" href="https://www.openclacky.com/docs/ai-key-guide" target="_blank" rel="noopener" style="margin-left:
|
|
908
|
+
<a id="setup-docs-link" href="https://www.openclacky.com/docs/ai-key-guide" target="_blank" rel="noopener" style="margin-left:0.25rem;color:var(--accent,#6366f1);text-decoration:none;" data-i18n="onboard.key.docsGuide.cta">See the guide →</a>
|
|
891
909
|
</div>
|
|
892
910
|
|
|
893
911
|
<div id="setup-test-result" class="setup-test-result" style="min-height:0;"></div>
|
|
@@ -965,7 +983,7 @@
|
|
|
965
983
|
<!-- Prompt modal for text input -->
|
|
966
984
|
<div id="prompt-modal-overlay" class="modal-overlay" style="display:none">
|
|
967
985
|
<div class="modal-box sm">
|
|
968
|
-
<div id="prompt-modal-message" style="font-size:
|
|
986
|
+
<div id="prompt-modal-message" style="font-size:0.875rem;line-height:1.6;margin-bottom:0.75rem"></div>
|
|
969
987
|
<input type="text" id="prompt-modal-input" class="prompt-modal-input" autocomplete="off" spellcheck="false">
|
|
970
988
|
<div class="modal-actions">
|
|
971
989
|
<button id="prompt-modal-cancel" class="btn-secondary" data-i18n="modal.cancel">Cancel</button>
|
|
@@ -997,6 +1015,7 @@
|
|
|
997
1015
|
|
|
998
1016
|
|
|
999
1017
|
<script src="/marked.min.js"></script>
|
|
1018
|
+
<script src="/vendor/hljs/highlight.min.js"></script>
|
|
1000
1019
|
<script src="/vendor/katex/katex.min.js"></script>
|
|
1001
1020
|
<script src="/vendor/katex/auto-render.min.js"></script>
|
|
1002
1021
|
<script src="/i18n.js"></script>
|
data/lib/clacky/web/onboard.js
CHANGED
|
@@ -86,6 +86,12 @@ const Onboard = (() => {
|
|
|
86
86
|
_showSetupStep("key");
|
|
87
87
|
await _loadProviders();
|
|
88
88
|
_bindKeyStep();
|
|
89
|
+
// Nudge: focus the provider trigger so the user knows step 1 is "pick
|
|
90
|
+
// a provider". `tabindex="0"` on the trigger makes it focusable; the
|
|
91
|
+
// .open class is NOT toggled, so the dropdown stays closed — we just
|
|
92
|
+
// get the accent-color border via `.custom-select-trigger:focus`.
|
|
93
|
+
const trigger = $("setup-provider-wrapper")?.querySelector(".custom-select-trigger");
|
|
94
|
+
if (trigger) trigger.focus({ preventScroll: true });
|
|
89
95
|
});
|
|
90
96
|
}
|
|
91
97
|
|
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -103,6 +103,29 @@ const Sessions = (() => {
|
|
|
103
103
|
const titleAttr = title ? ` title="${title}"` : "";
|
|
104
104
|
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
|
|
105
105
|
};
|
|
106
|
+
// Override code block rendering: apply syntax highlighting + header with
|
|
107
|
+
// language label and copy button.
|
|
108
|
+
renderer.code = function({ text: code, lang }) {
|
|
109
|
+
const language = (lang || "").split(/\s+/)[0]; // strip extra info after lang
|
|
110
|
+
const highlighted = _highlightCode(code, language);
|
|
111
|
+
const displayLang = language || "text";
|
|
112
|
+
return (
|
|
113
|
+
`<div class="code-block">` +
|
|
114
|
+
`<div class="code-block-header">` +
|
|
115
|
+
`<span class="code-block-lang">${escapeHtml(displayLang)}</span>` +
|
|
116
|
+
`<button type="button" class="code-block-copy" aria-label="${I18n.t("chat.copy")}" title="${I18n.t("chat.copy")}">` +
|
|
117
|
+
`<svg class="code-copy-icon" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
|
|
118
|
+
`<path fill="currentColor" d="M10 1H4a2 2 0 0 0-2 2v8h1.5V3a.5.5 0 0 1 .5-.5h6V1zm3 3H6a2 2 0 0 0-2 2v8a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2zm.5 10a.5.5 0 0 1-.5.5H6a.5.5 0 0 1-.5-.5V6a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 .5.5v8z"/>` +
|
|
119
|
+
`</svg>` +
|
|
120
|
+
`<svg class="code-copy-icon-check" viewBox="0 0 16 16" width="14" height="14" aria-hidden="true">` +
|
|
121
|
+
`<path fill="currentColor" d="M13.5 3.5 6 11 2.5 7.5 1 9l5 5 9-9z"/>` +
|
|
122
|
+
`</svg>` +
|
|
123
|
+
`</button>` +
|
|
124
|
+
`</div>` +
|
|
125
|
+
`<pre><code class="hljs${language ? ` language-${escapeHtml(language)}` : ""}">${highlighted}</code></pre>` +
|
|
126
|
+
`</div>`
|
|
127
|
+
);
|
|
128
|
+
};
|
|
106
129
|
html = marked.parse(prepared, { breaks: true, gfm: true, renderer });
|
|
107
130
|
} else {
|
|
108
131
|
html = escapeHtml(prepared).replace(/\n/g, "<br>");
|
|
@@ -114,6 +137,23 @@ const Sessions = (() => {
|
|
|
114
137
|
return html;
|
|
115
138
|
}
|
|
116
139
|
|
|
140
|
+
// Apply highlight.js to a code string. Returns highlighted HTML (already escaped
|
|
141
|
+
// by hljs). Falls back to plain escaped text if hljs is unavailable.
|
|
142
|
+
function _highlightCode(code, language) {
|
|
143
|
+
if (typeof hljs === "undefined") return escapeHtml(code);
|
|
144
|
+
if (language && hljs.getLanguage(language)) {
|
|
145
|
+
try {
|
|
146
|
+
return hljs.highlight(code, { language, ignoreIllegals: true }).value;
|
|
147
|
+
} catch (_) { /* fall through */ }
|
|
148
|
+
}
|
|
149
|
+
// Auto-detect when no language specified or language not recognized
|
|
150
|
+
try {
|
|
151
|
+
return hljs.highlightAuto(code).value;
|
|
152
|
+
} catch (_) {
|
|
153
|
+
return escapeHtml(code);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
117
157
|
// Pull $$...$$, \[...\], $...$, \(...\) out of `text` and replace each with a
|
|
118
158
|
// sentinel placeholder so marked won't mangle the LaTeX source. The matched
|
|
119
159
|
// segments are pushed (with display flag) onto `out` for later KaTeX rendering.
|
|
@@ -1414,13 +1454,27 @@ const Sessions = (() => {
|
|
|
1414
1454
|
}
|
|
1415
1455
|
|
|
1416
1456
|
// Install the click-delegation listener on #messages exactly once.
|
|
1417
|
-
// Handles copy clicks for all current AND future assistant bubbles
|
|
1457
|
+
// Handles copy clicks for all current AND future assistant bubbles
|
|
1458
|
+
// AND code block copy buttons.
|
|
1418
1459
|
let _copyDelegationInstalled = false;
|
|
1419
1460
|
function _ensureCopyDelegation() {
|
|
1420
1461
|
if (_copyDelegationInstalled) return;
|
|
1421
1462
|
const messages = $("messages");
|
|
1422
1463
|
if (!messages) return;
|
|
1423
1464
|
messages.addEventListener("click", (e) => {
|
|
1465
|
+
// ── Code block copy button ──
|
|
1466
|
+
const codeBtn = e.target.closest(".code-block-copy");
|
|
1467
|
+
if (codeBtn) {
|
|
1468
|
+
e.preventDefault();
|
|
1469
|
+
e.stopPropagation();
|
|
1470
|
+
const block = codeBtn.closest(".code-block");
|
|
1471
|
+
if (!block) return;
|
|
1472
|
+
const codeEl = block.querySelector("pre code");
|
|
1473
|
+
if (!codeEl) return;
|
|
1474
|
+
_copyTextAndFlash(codeBtn, codeEl.textContent || "");
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
// ── Message-level copy button ──
|
|
1424
1478
|
const btn = e.target.closest(".msg-copy-btn");
|
|
1425
1479
|
if (!btn) return;
|
|
1426
1480
|
e.preventDefault();
|
|
@@ -1605,8 +1659,10 @@ const Sessions = (() => {
|
|
|
1605
1659
|
// Re-render session list (badges/labels) when the user switches language
|
|
1606
1660
|
document.addEventListener("langchange", () => Sessions.renderList());
|
|
1607
1661
|
// Browsers block file:// navigation from http:// pages. Intercept clicks on
|
|
1608
|
-
// file:// links and delegate to the backend API
|
|
1609
|
-
|
|
1662
|
+
// file:// links and delegate to the backend API.
|
|
1663
|
+
// Local deployments (localhost / 127.0.0.1 / ::1): open the file with the
|
|
1664
|
+
// OS default handler. Remote deployments: download the file.
|
|
1665
|
+
document.addEventListener("click", async (e) => {
|
|
1610
1666
|
const link = e.target.closest("a[href^='file://']");
|
|
1611
1667
|
if (!link) return;
|
|
1612
1668
|
e.preventDefault();
|
|
@@ -1614,11 +1670,32 @@ const Sessions = (() => {
|
|
|
1614
1670
|
// file:///C:/foo → /C:/foo after replace; strip the leading slash for Windows drive letters
|
|
1615
1671
|
if (/^\/[A-Za-z]:/.test(filePath)) filePath = filePath.substring(1);
|
|
1616
1672
|
if (!filePath) return;
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1673
|
+
|
|
1674
|
+
const hostname = window.location.hostname;
|
|
1675
|
+
const isLocal = ["localhost", "127.0.0.1", "::1"].includes(hostname);
|
|
1676
|
+
const action = isLocal ? "open" : "download";
|
|
1677
|
+
|
|
1678
|
+
try {
|
|
1679
|
+
const resp = await fetch("/api/file-action", {
|
|
1680
|
+
method: "POST",
|
|
1681
|
+
headers: { "Content-Type": "application/json" },
|
|
1682
|
+
body: JSON.stringify({ path: filePath, action })
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1685
|
+
if (action === "download" && resp.ok) {
|
|
1686
|
+
const blob = await resp.blob();
|
|
1687
|
+
const url = URL.createObjectURL(blob);
|
|
1688
|
+
const a = document.createElement("a");
|
|
1689
|
+
a.href = url;
|
|
1690
|
+
a.download = filePath.split("/").pop() || "download";
|
|
1691
|
+
document.body.appendChild(a);
|
|
1692
|
+
a.click();
|
|
1693
|
+
a.remove();
|
|
1694
|
+
URL.revokeObjectURL(url);
|
|
1695
|
+
}
|
|
1696
|
+
} catch (err) {
|
|
1697
|
+
console.error("file-action failed:", err);
|
|
1698
|
+
}
|
|
1622
1699
|
});
|
|
1623
1700
|
},
|
|
1624
1701
|
|
|
@@ -2122,7 +2199,7 @@ const Sessions = (() => {
|
|
|
2122
2199
|
this._lastSession = s;
|
|
2123
2200
|
if (!s) {
|
|
2124
2201
|
// Hide all spans when no session
|
|
2125
|
-
["sib-id", "sib-status", "sib-dir", "sib-mode", "sib-model", "sib-tasks", "sib-cost"].forEach(id => {
|
|
2202
|
+
["sib-id", "sib-status", "sib-dir", "sib-mode", "sib-model", "sib-reasoning", "sib-tasks", "sib-cost"].forEach(id => {
|
|
2126
2203
|
const el = $(id); if (el) el.textContent = "";
|
|
2127
2204
|
});
|
|
2128
2205
|
const sibIdEl = $("sib-id");
|
|
@@ -2184,6 +2261,18 @@ const Sessions = (() => {
|
|
|
2184
2261
|
}
|
|
2185
2262
|
if (sibModelWrap) sibModelWrap.style.display = s.model ? "" : "none";
|
|
2186
2263
|
|
|
2264
|
+
const sibReasoning = $("sib-reasoning");
|
|
2265
|
+
const sibReasoningWrap = $("sib-reasoning-wrap");
|
|
2266
|
+
const sibSepAfterReasoning = document.querySelector(".sib-sep-after-reasoning");
|
|
2267
|
+
if (sibReasoning) {
|
|
2268
|
+
const eff = (s.reasoning_effort || "off").toLowerCase();
|
|
2269
|
+
sibReasoning.textContent = I18n.t(`sib.reasoning.${eff}`);
|
|
2270
|
+
sibReasoning.dataset.sessionId = s.id;
|
|
2271
|
+
sibReasoning.dataset.reasoningEffort = eff;
|
|
2272
|
+
}
|
|
2273
|
+
if (sibReasoningWrap) sibReasoningWrap.style.display = "";
|
|
2274
|
+
if (sibSepAfterReasoning) sibSepAfterReasoning.style.display = "";
|
|
2275
|
+
|
|
2187
2276
|
// Latency signal — read from s.latest_latency (populated by:
|
|
2188
2277
|
// - HTTP /api/sessions → session_registry#list (from agent.latest_latency)
|
|
2189
2278
|
// - WS session_update events patched by app.js
|
|
@@ -3097,7 +3186,7 @@ const Sessions = (() => {
|
|
|
3097
3186
|
console.log("[Model Switcher] Models count:", models.length);
|
|
3098
3187
|
|
|
3099
3188
|
if (models.length === 0) {
|
|
3100
|
-
dropdown.innerHTML = '<div style="padding:
|
|
3189
|
+
dropdown.innerHTML = '<div style="padding:0.75rem;text-align:center;color:var(--color-text-secondary);font-size:0.6875rem;">No models configured</div>';
|
|
3101
3190
|
return;
|
|
3102
3191
|
}
|
|
3103
3192
|
|
|
@@ -3167,7 +3256,7 @@ const Sessions = (() => {
|
|
|
3167
3256
|
console.log("[Model Switcher] Dropdown populated, children count:", dropdown.children.length);
|
|
3168
3257
|
} catch (e) {
|
|
3169
3258
|
console.error("Failed to load models:", e);
|
|
3170
|
-
dropdown.innerHTML = '<div style="padding:
|
|
3259
|
+
dropdown.innerHTML = '<div style="padding:0.75rem;text-align:center;color:var(--color-error);font-size:0.6875rem;">Error loading models</div>';
|
|
3171
3260
|
}
|
|
3172
3261
|
}
|
|
3173
3262
|
|
|
@@ -3491,6 +3580,100 @@ const Sessions = (() => {
|
|
|
3491
3580
|
|
|
3492
3581
|
})();
|
|
3493
3582
|
|
|
3583
|
+
// ── Session Info Bar Reasoning Effort Switcher ────────────────────────────
|
|
3584
|
+
(function() {
|
|
3585
|
+
let _isOpen = false;
|
|
3586
|
+
const LEVELS = ["off", "low", "medium", "high"];
|
|
3587
|
+
|
|
3588
|
+
document.addEventListener("click", async (e) => {
|
|
3589
|
+
const el = e.target.closest("#sib-reasoning");
|
|
3590
|
+
if (el) {
|
|
3591
|
+
e.stopPropagation();
|
|
3592
|
+
const dropdown = $("sib-reasoning-dropdown");
|
|
3593
|
+
if (!dropdown) return;
|
|
3594
|
+
|
|
3595
|
+
if (_isOpen) {
|
|
3596
|
+
dropdown.style.display = "none";
|
|
3597
|
+
_isOpen = false;
|
|
3598
|
+
return;
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
_populate(dropdown, el.dataset.sessionId, el.dataset.reasoningEffort || "off");
|
|
3602
|
+
|
|
3603
|
+
const rect = el.getBoundingClientRect();
|
|
3604
|
+
dropdown.style.left = `${rect.left + rect.width / 2}px`;
|
|
3605
|
+
dropdown.style.top = `${rect.top - 6}px`;
|
|
3606
|
+
dropdown.style.transform = "translate(-50%, -100%)";
|
|
3607
|
+
dropdown.style.display = "block";
|
|
3608
|
+
_isOpen = true;
|
|
3609
|
+
return;
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3612
|
+
if (_isOpen && !e.target.closest("#sib-reasoning-dropdown")) {
|
|
3613
|
+
const dropdown = $("sib-reasoning-dropdown");
|
|
3614
|
+
if (dropdown) dropdown.style.display = "none";
|
|
3615
|
+
_isOpen = false;
|
|
3616
|
+
}
|
|
3617
|
+
});
|
|
3618
|
+
|
|
3619
|
+
function _populate(dropdown, sessionId, current) {
|
|
3620
|
+
dropdown.innerHTML = "";
|
|
3621
|
+
|
|
3622
|
+
const header = document.createElement("div");
|
|
3623
|
+
header.className = "sib-reasoning-header";
|
|
3624
|
+
const heading = document.createElement("div");
|
|
3625
|
+
heading.className = "sib-reasoning-heading";
|
|
3626
|
+
heading.textContent = I18n.t("sib.reasoning.heading");
|
|
3627
|
+
const hint = document.createElement("div");
|
|
3628
|
+
hint.className = "sib-reasoning-hint";
|
|
3629
|
+
hint.textContent = I18n.t("sib.reasoning.hint");
|
|
3630
|
+
header.appendChild(heading);
|
|
3631
|
+
header.appendChild(hint);
|
|
3632
|
+
dropdown.appendChild(header);
|
|
3633
|
+
|
|
3634
|
+
LEVELS.forEach(level => {
|
|
3635
|
+
const opt = document.createElement("div");
|
|
3636
|
+
opt.className = "sib-reasoning-option";
|
|
3637
|
+
if (level === current) opt.classList.add("current");
|
|
3638
|
+
|
|
3639
|
+
const label = document.createElement("span");
|
|
3640
|
+
label.className = "sib-reasoning-name";
|
|
3641
|
+
label.textContent = I18n.t(`sib.reasoning.${level}`);
|
|
3642
|
+
opt.appendChild(label);
|
|
3643
|
+
|
|
3644
|
+
opt.addEventListener("click", () => _switch(sessionId, level));
|
|
3645
|
+
dropdown.appendChild(opt);
|
|
3646
|
+
});
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
async function _switch(sessionId, level) {
|
|
3650
|
+
const dropdown = $("sib-reasoning-dropdown");
|
|
3651
|
+
if (dropdown) {
|
|
3652
|
+
dropdown.style.display = "none";
|
|
3653
|
+
_isOpen = false;
|
|
3654
|
+
}
|
|
3655
|
+
|
|
3656
|
+
try {
|
|
3657
|
+
const res = await fetch(`/api/sessions/${sessionId}/reasoning_effort`, {
|
|
3658
|
+
method: "PATCH",
|
|
3659
|
+
headers: { "Content-Type": "application/json" },
|
|
3660
|
+
body: JSON.stringify({ reasoning_effort: level })
|
|
3661
|
+
});
|
|
3662
|
+
const data = await res.json();
|
|
3663
|
+
if (!res.ok) throw new Error(data.error || "Unknown error");
|
|
3664
|
+
|
|
3665
|
+
const el = $("sib-reasoning");
|
|
3666
|
+
if (el) {
|
|
3667
|
+
el.textContent = I18n.t(`sib.reasoning.${level}`);
|
|
3668
|
+
el.dataset.reasoningEffort = level;
|
|
3669
|
+
}
|
|
3670
|
+
} catch (e) {
|
|
3671
|
+
console.error("Failed to switch reasoning effort:", e);
|
|
3672
|
+
alert("Failed to switch reasoning effort: " + e.message);
|
|
3673
|
+
}
|
|
3674
|
+
}
|
|
3675
|
+
})();
|
|
3676
|
+
|
|
3494
3677
|
document.addEventListener("langchange", () => {
|
|
3495
3678
|
if (Sessions._lastSession) Sessions.updateInfoBar(Sessions._lastSession);
|
|
3496
3679
|
});
|
data/lib/clacky/web/settings.js
CHANGED
|
@@ -81,7 +81,7 @@ const Settings = (() => {
|
|
|
81
81
|
<label class="model-field quick-setup-field" ${(model.model || model.base_url) ? 'style="display:none"' : ''}>
|
|
82
82
|
<span class="field-label">${I18n.t("settings.models.field.quicksetup")}</span>
|
|
83
83
|
<div class="custom-select-wrapper" data-index="${index}">
|
|
84
|
-
<div class="custom-select-trigger">
|
|
84
|
+
<div class="custom-select-trigger" tabindex="0">
|
|
85
85
|
<span class="custom-select-value placeholder">${I18n.t("settings.models.placeholder.provider")}</span>
|
|
86
86
|
<svg class="custom-select-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
87
87
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
@@ -126,7 +126,7 @@ const Settings = (() => {
|
|
|
126
126
|
<label class="model-field">
|
|
127
127
|
<span class="field-label">
|
|
128
128
|
${I18n.t("settings.models.field.apikey")}
|
|
129
|
-
<a class="get-apikey-link" data-index="${index}" href="#" target="_blank" rel="noopener" style="display:none;margin-left:
|
|
129
|
+
<a class="get-apikey-link" data-index="${index}" href="#" target="_blank" rel="noopener" style="display:none;margin-left:0.5rem;font-size:0.75rem;color:var(--accent,#6366f1);text-decoration:none;opacity:0.85;">${I18n.t("settings.models.field.getApiKey")}</a>
|
|
130
130
|
</span>
|
|
131
131
|
<div class="field-input-row">
|
|
132
132
|
<input type="password" class="field-input api-key-input" data-key="api_key" data-index="${index}"
|
|
@@ -142,9 +142,9 @@ const Settings = (() => {
|
|
|
142
142
|
</div>
|
|
143
143
|
|
|
144
144
|
<div class="model-card-footer">
|
|
145
|
-
${!model.api_key_masked ? `<span class="model-card-docs-link" style="font-size:
|
|
145
|
+
${!model.api_key_masked ? `<span class="model-card-docs-link" style="font-size:0.75rem;">
|
|
146
146
|
<span style="color:var(--muted,#6b7280);">${I18n.t("settings.models.field.docsGuide.question")}</span>
|
|
147
|
-
<a href="https://www.openclacky.com/docs/ai-key-guide" target="_blank" rel="noopener" style="margin-left:
|
|
147
|
+
<a href="https://www.openclacky.com/docs/ai-key-guide" target="_blank" rel="noopener" style="margin-left:0.25rem;color:var(--accent,#6366f1);text-decoration:none;">${I18n.t("settings.models.field.docsGuide.cta")}</a>
|
|
148
148
|
</span>` : ""}
|
|
149
149
|
<span class="model-test-result" data-index="${index}"></span>
|
|
150
150
|
<div class="model-card-actions-row">
|
|
@@ -709,10 +709,22 @@ const Settings = (() => {
|
|
|
709
709
|
behavior: "smooth"
|
|
710
710
|
});
|
|
711
711
|
|
|
712
|
-
// Put focus on the new card
|
|
713
|
-
//
|
|
714
|
-
|
|
715
|
-
|
|
712
|
+
// Put focus on the new card so the user can start configuring.
|
|
713
|
+
// Priority order:
|
|
714
|
+
// 1. The provider `.custom-select-trigger` — nudges the user to
|
|
715
|
+
// pick a provider first (step 1 of the 3-field form). It's a
|
|
716
|
+
// div with tabindex="0", so it gets the accent border via
|
|
717
|
+
// `:focus` without expanding the dropdown.
|
|
718
|
+
// 2. Fall back to the first form input (used when the quick-setup
|
|
719
|
+
// field is hidden, e.g. on a model card that already has values).
|
|
720
|
+
const providerTrigger = last.querySelector(".custom-select-wrapper .custom-select-trigger");
|
|
721
|
+
const isVisible = el => el && el.offsetParent !== null;
|
|
722
|
+
if (isVisible(providerTrigger)) {
|
|
723
|
+
providerTrigger.focus({ preventScroll: true });
|
|
724
|
+
} else {
|
|
725
|
+
const firstInput = last.querySelector("input, select, textarea");
|
|
726
|
+
if (firstInput) firstInput.focus({ preventScroll: true });
|
|
727
|
+
}
|
|
716
728
|
});
|
|
717
729
|
});
|
|
718
730
|
}
|
|
@@ -1048,11 +1060,11 @@ const Settings = (() => {
|
|
|
1048
1060
|
|
|
1049
1061
|
function _initLangBtns() {
|
|
1050
1062
|
// Highlight the active language button on open
|
|
1051
|
-
document.querySelectorAll(".settings-lang-btn").forEach(btn => {
|
|
1063
|
+
document.querySelectorAll("#language-section .settings-lang-btn").forEach(btn => {
|
|
1052
1064
|
btn.classList.toggle("active", btn.dataset.lang === I18n.lang());
|
|
1053
1065
|
btn.addEventListener("click", () => {
|
|
1054
1066
|
I18n.setLang(btn.dataset.lang);
|
|
1055
|
-
document.querySelectorAll(".settings-lang-btn").forEach(b =>
|
|
1067
|
+
document.querySelectorAll("#language-section .settings-lang-btn").forEach(b =>
|
|
1056
1068
|
b.classList.toggle("active", b.dataset.lang === I18n.lang())
|
|
1057
1069
|
);
|
|
1058
1070
|
});
|
|
@@ -1129,11 +1141,40 @@ const Settings = (() => {
|
|
|
1129
1141
|
});
|
|
1130
1142
|
|
|
1131
1143
|
_initLangBtns();
|
|
1144
|
+
_initFontBtns();
|
|
1132
1145
|
|
|
1133
1146
|
// Re-render model cards when language changes (dynamic HTML, not data-i18n)
|
|
1134
1147
|
document.addEventListener("langchange", () => _renderCards());
|
|
1135
1148
|
}
|
|
1136
1149
|
|
|
1150
|
+
// ── Font Size ──────────────────────────────────────────────────────────
|
|
1151
|
+
const FONT_STORAGE_KEY = "clacky-font-size";
|
|
1152
|
+
const FONT_DEFAULT = "medium";
|
|
1153
|
+
|
|
1154
|
+
function _applyFontSize(size) {
|
|
1155
|
+
document.documentElement.setAttribute("data-font-size", size);
|
|
1156
|
+
try { localStorage.setItem(FONT_STORAGE_KEY, size); } catch (_) {}
|
|
1157
|
+
// Update active state on all font-size buttons (if settings panel is open)
|
|
1158
|
+
document.querySelectorAll("#font-size-section .settings-lang-btn").forEach(btn => {
|
|
1159
|
+
btn.classList.toggle("active", btn.dataset.font === size);
|
|
1160
|
+
});
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
function _initFontBtns() {
|
|
1164
|
+
// Apply saved preference (or default) on page load
|
|
1165
|
+
let saved = null;
|
|
1166
|
+
try { saved = localStorage.getItem(FONT_STORAGE_KEY); } catch (_) {}
|
|
1167
|
+
_applyFontSize(saved || FONT_DEFAULT);
|
|
1168
|
+
|
|
1169
|
+
// Wire up button clicks
|
|
1170
|
+
document.querySelectorAll("#font-size-section .settings-lang-btn").forEach(btn => {
|
|
1171
|
+
btn.classList.toggle("active", btn.dataset.font === (saved || FONT_DEFAULT));
|
|
1172
|
+
btn.addEventListener("click", () => {
|
|
1173
|
+
_applyFontSize(btn.dataset.font);
|
|
1174
|
+
});
|
|
1175
|
+
});
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1137
1178
|
// ── QR Code Lightbox ───────────────────────────────────────────────────
|
|
1138
1179
|
// Sets up click-to-enlarge behaviour for the support QR code.
|
|
1139
1180
|
// Safe to call multiple times — idempotent via a data attribute guard.
|