openclacky 1.4.0 → 1.5.0
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 +55 -0
- data/lib/clacky/agent/llm_caller.rb +3 -3
- data/lib/clacky/agent/session_serializer.rb +50 -9
- data/lib/clacky/agent.rb +4 -5
- data/lib/clacky/brand_config.rb +102 -10
- data/lib/clacky/client.rb +22 -16
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +135 -84
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +144 -1
- data/lib/clacky/default_extensions/ext-studio/ext.yml +2 -4
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +412 -173
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +522 -0
- data/lib/clacky/extension/api_extension.rb +5 -6
- data/lib/clacky/extension/loader.rb +16 -2
- data/lib/clacky/extension/packager.rb +10 -1
- data/lib/clacky/extension/scaffold/templates/full/api/handler.rb.erb +16 -0
- data/lib/clacky/mcp/http_transport.rb +1 -1
- data/lib/clacky/platform_http_client.rb +9 -7
- data/lib/clacky/providers.rb +113 -5
- data/lib/clacky/server/http_server.rb +131 -38
- data/lib/clacky/server/session_registry.rb +9 -0
- data/lib/clacky/tools/grep.rb +6 -1
- data/lib/clacky/utils/environment_detector.rb +16 -9
- data/lib/clacky/utils/file_processor.rb +21 -24
- data/lib/clacky/utils/model_pricing.rb +85 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +441 -48
- data/lib/clacky/web/app.js +29 -6
- data/lib/clacky/web/components/onboard.js +21 -7
- data/lib/clacky/web/features/backup/view.js +4 -3
- data/lib/clacky/web/features/extensions/store.js +56 -4
- data/lib/clacky/web/features/extensions/view.js +54 -24
- data/lib/clacky/web/features/mcp/view.js +5 -2
- data/lib/clacky/web/features/new-session/store.js +8 -4
- data/lib/clacky/web/features/version/view.js +5 -1
- data/lib/clacky/web/features/workspace/store.js +11 -0
- data/lib/clacky/web/features/workspace/view.js +10 -4
- data/lib/clacky/web/i18n.js +58 -36
- data/lib/clacky/web/index.html +74 -7
- data/lib/clacky/web/sessions.js +72 -15
- data/lib/clacky/web/settings.js +22 -3
- data/lib/clacky/web/theme.js +27 -58
- data/scripts/uninstall.sh +1 -1
- metadata +2 -5
- data/lib/clacky/default_extensions/ext-studio/skills/ext-debug/SKILL.md +0 -71
- data/lib/clacky/default_extensions/ext-studio/skills/ext-publish/SKILL.md +0 -73
- data/lib/clacky/default_extensions/ext-studio/skills/ext-scaffold/SKILL.md +0 -65
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +0 -106
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -75,12 +75,11 @@ const I18n = (() => {
|
|
|
75
75
|
"chat.send": "Send",
|
|
76
76
|
"chat.edit.warn": "Editing this message will discard the conversation history, file snapshots, and token costs after it. Continue?",
|
|
77
77
|
"chat.edit.warnSkip": "Don't show this again",
|
|
78
|
-
"chat.empty.title": "
|
|
79
|
-
"chat.empty.subtitle": "Ask
|
|
80
|
-
"chat.empty.tip1": "
|
|
81
|
-
"chat.empty.tip2": "
|
|
82
|
-
"chat.empty.tip3": "
|
|
83
|
-
"chat.empty.tip4": "Each session is isolated, but long-term memories are shared across sessions",
|
|
78
|
+
"chat.empty.title": "How can I help you today?",
|
|
79
|
+
"chat.empty.subtitle": "Ask a question, or start with a skill.",
|
|
80
|
+
"chat.empty.tip1": "Research, write, and translate",
|
|
81
|
+
"chat.empty.tip2": "Summarize and organize information",
|
|
82
|
+
"chat.empty.tip3": "Upload an image or file for me to analyze",
|
|
84
83
|
|
|
85
84
|
// ── Session list ──
|
|
86
85
|
"sessions.empty": "No sessions yet",
|
|
@@ -162,6 +161,11 @@ const I18n = (() => {
|
|
|
162
161
|
"sib.variant.default": "default",
|
|
163
162
|
"sib.signal.tooltip": "Recent LLM latency",
|
|
164
163
|
"sib.reasoning.tooltip": "Click to change reasoning effort",
|
|
164
|
+
"sib.id.label": "Session file",
|
|
165
|
+
"sib.status.tooltip": "Current session status",
|
|
166
|
+
"sib.mode.tooltip": "Permission mode — controls whether the agent asks before making changes",
|
|
167
|
+
"sib.tasks.tooltip": "Total number of tasks completed in this session",
|
|
168
|
+
"sib.cost.tooltip": "Estimated token cost for this session",
|
|
165
169
|
"sib.reasoning.label": "Reasoning",
|
|
166
170
|
"sib.reasoning.heading": "Reasoning effort",
|
|
167
171
|
"sib.reasoning.hint": "Higher effort = deeper thinking, slower response.",
|
|
@@ -179,7 +183,8 @@ const I18n = (() => {
|
|
|
179
183
|
"sessions.badge.channel": "Channel",
|
|
180
184
|
"sessions.badge.coding": "Coding",
|
|
181
185
|
"sessions.badge.setup": "Setup",
|
|
182
|
-
"sessions.newSession": "
|
|
186
|
+
"sessions.newSession": "New Session",
|
|
187
|
+
"sessions.advancedOptions": "Advanced options",
|
|
183
188
|
"sessions.actions.pin": "Pin",
|
|
184
189
|
"sessions.actions.unpin": "Unpin",
|
|
185
190
|
"sessions.actions.fork": "Fork",
|
|
@@ -519,6 +524,7 @@ const I18n = (() => {
|
|
|
519
524
|
"extensions.sort.downloads": "Most installed",
|
|
520
525
|
"extensions.filter.all": "All",
|
|
521
526
|
"extensions.filter.installed": "Installed",
|
|
527
|
+
"extensions.filter.brand": "Brand",
|
|
522
528
|
"extensions.empty": "No extensions available yet.",
|
|
523
529
|
"extensions.noResults": "No extensions match your search.",
|
|
524
530
|
"extensions.loadFailed": "Could not reach the extension store.",
|
|
@@ -537,16 +543,9 @@ const I18n = (() => {
|
|
|
537
543
|
"extensions.unit.api": "API",
|
|
538
544
|
"extensions.unit.apis": "APIs",
|
|
539
545
|
"extensions.detail.back": "Back",
|
|
546
|
+
"extensions.detail.readme": "Description",
|
|
540
547
|
"extensions.detail.contributes": "What's inside",
|
|
541
|
-
"extensions.detail.entry_points": "Access points",
|
|
542
548
|
"extensions.detail.versions": "Version history",
|
|
543
|
-
"extensions.slot.sidebar.nav": "Sidebar nav",
|
|
544
|
-
"extensions.slot.sidebar.nav.top": "Sidebar nav (top)",
|
|
545
|
-
"extensions.slot.sidebar.nav.bottom": "Sidebar nav (bottom)",
|
|
546
|
-
"extensions.slot.sidebar.footer": "Sidebar footer",
|
|
547
|
-
"extensions.slot.main.workspace": "Main workspace",
|
|
548
|
-
"extensions.slot.session.aside": "Session aside",
|
|
549
|
-
"extensions.slot.settings.tabs": "Settings tab",
|
|
550
549
|
"extensions.section.agents": "Agents",
|
|
551
550
|
"extensions.section.skills": "Skills",
|
|
552
551
|
"extensions.section.panels": "Panels",
|
|
@@ -560,6 +559,7 @@ const I18n = (() => {
|
|
|
560
559
|
"extensions.action.enable": "Enable",
|
|
561
560
|
"extensions.action.remove": "Remove",
|
|
562
561
|
"extensions.action.removeConfirm": "Remove this extension? Its files will be deleted. You can reinstall it later.",
|
|
562
|
+
"extensions.action.removePurgeData": "Also delete this extension's data (kept by default; reinstalling restores it)",
|
|
563
563
|
"mcp.loading": "Loading…",
|
|
564
564
|
"mcp.empty.title": "No MCP servers configured",
|
|
565
565
|
"mcp.empty.body": "Create ~/.clacky/mcp.json to plug in MCP servers. The format matches Claude Desktop and Cursor, so existing configs work as-is.",
|
|
@@ -578,10 +578,10 @@ const I18n = (() => {
|
|
|
578
578
|
"mcp.btn.hide": "Hide tools",
|
|
579
579
|
"mcp.btn.refresh": "Refresh",
|
|
580
580
|
"mcp.btn.remove": "Remove",
|
|
581
|
-
"mcp.btn.askClacky": "Let
|
|
581
|
+
"mcp.btn.askClacky": "Let AI help me set this up",
|
|
582
582
|
"mcp.cta.title": "Want to add a new MCP server?",
|
|
583
|
-
"mcp.cta.body": "
|
|
584
|
-
"mcp.cta.button": "Ask
|
|
583
|
+
"mcp.cta.body": "Describe what you need in plain words — the AI will pick the right package, ask for the parameters that matter, and verify it works.",
|
|
584
|
+
"mcp.cta.button": "Ask AI to add one",
|
|
585
585
|
"mcp.remove.confirm": "Remove MCP server '{{name}}'? Its tools will no longer be available.",
|
|
586
586
|
"mcp.remove.error": "Failed to remove: {{msg}}",
|
|
587
587
|
"mcp.loadError": "Failed to load MCP servers: {{msg}}",
|
|
@@ -663,6 +663,9 @@ const I18n = (() => {
|
|
|
663
663
|
"settings.models.baseurl.variant.mainland_cn_coding": "Mainland · Coding Plan",
|
|
664
664
|
"settings.models.baseurl.variant.international_payg": "International · Pay-as-you-go",
|
|
665
665
|
"settings.models.baseurl.variant.international_coding": "International · Coding Plan",
|
|
666
|
+
"settings.models.baseurl.variant.ark_payg": "Pay-as-you-go",
|
|
667
|
+
"settings.models.baseurl.variant.ark_coding": "Coding Plan",
|
|
668
|
+
"settings.models.baseurl.variant.ark_agent": "Agent Plan",
|
|
666
669
|
"settings.models.btn.save": "Save",
|
|
667
670
|
"settings.models.btn.saving": "Saving…",
|
|
668
671
|
"settings.models.btn.saved": "Saved ✓",
|
|
@@ -784,6 +787,13 @@ const I18n = (() => {
|
|
|
784
787
|
|
|
785
788
|
"settings.accentColor.title": "Accent Color",
|
|
786
789
|
|
|
790
|
+
"settings.bgTheme.title": "Background",
|
|
791
|
+
"settings.bgTheme.light": "Light",
|
|
792
|
+
"settings.bgTheme.dark": "Dark",
|
|
793
|
+
"settings.bgTheme.dim": "Dim",
|
|
794
|
+
"settings.bgTheme.warm": "Warm",
|
|
795
|
+
"settings.bgTheme.black": "Blue Dark",
|
|
796
|
+
|
|
787
797
|
// ── Onboard ──
|
|
788
798
|
"onboard.title": "Welcome to {{brand}}",
|
|
789
799
|
"onboard.subtitle": "Let's get you set up in a minute.",
|
|
@@ -818,6 +828,7 @@ const I18n = (() => {
|
|
|
818
828
|
"onboard.manual.toggle": "Configure manually with your own API key",
|
|
819
829
|
"onboard.provider.custom": "Custom",
|
|
820
830
|
"provider.recommended": "Recommended",
|
|
831
|
+
"provider.name.volcengine_ark": "Volcengine Ark (Doubao)",
|
|
821
832
|
"provider.promo.openclacky.title": "OpenClacky AI Keys",
|
|
822
833
|
"provider.promo.openclacky.1": "All frontier models, one key — Claude, Gemini, DeepSeek & more.",
|
|
823
834
|
"provider.promo.openclacky.2": "Top reasoning, best value, best vision — unified in one API.",
|
|
@@ -1055,12 +1066,11 @@ const I18n = (() => {
|
|
|
1055
1066
|
"chat.send": "发送",
|
|
1056
1067
|
"chat.edit.warn": "编辑此消息将丢弃后续对话历史、文件快照及已产生的 token 费用,是否继续?",
|
|
1057
1068
|
"chat.edit.warnSkip": "不再提示",
|
|
1058
|
-
"chat.empty.title": "
|
|
1059
|
-
"chat.empty.subtitle": "
|
|
1060
|
-
"chat.empty.tip1": "
|
|
1061
|
-
"chat.empty.tip2": "
|
|
1062
|
-
"chat.empty.tip3": "
|
|
1063
|
-
"chat.empty.tip4": "每个会话上下文独立,但长期记忆全局共享",
|
|
1069
|
+
"chat.empty.title": "有什么可以帮你?",
|
|
1070
|
+
"chat.empty.subtitle": "直接提问,或用一个技能开始。",
|
|
1071
|
+
"chat.empty.tip1": "查资料、写文案、做翻译",
|
|
1072
|
+
"chat.empty.tip2": "归纳总结、整理信息",
|
|
1073
|
+
"chat.empty.tip3": "上传图片或文件,让我帮你分析",
|
|
1064
1074
|
|
|
1065
1075
|
// ── Session list ──
|
|
1066
1076
|
"sessions.empty": "暂无会话",
|
|
@@ -1142,6 +1152,11 @@ const I18n = (() => {
|
|
|
1142
1152
|
"sib.variant.default": "默认",
|
|
1143
1153
|
"sib.signal.tooltip": "最近一次 LLM 响应延迟",
|
|
1144
1154
|
"sib.reasoning.tooltip": "点击调整思考等级",
|
|
1155
|
+
"sib.id.label": "会话文件",
|
|
1156
|
+
"sib.status.tooltip": "当前会话状态",
|
|
1157
|
+
"sib.mode.tooltip": "权限模式 — 控制 Agent 执行操作前是否需要确认",
|
|
1158
|
+
"sib.tasks.tooltip": "本次会话累计完成的任务数",
|
|
1159
|
+
"sib.cost.tooltip": "本次会话的预估 Token 费用",
|
|
1145
1160
|
"sib.reasoning.label": "思考",
|
|
1146
1161
|
"sib.reasoning.heading": "思考等级",
|
|
1147
1162
|
"sib.reasoning.hint": "等级越高,思考越深,响应越慢。",
|
|
@@ -1159,7 +1174,8 @@ const I18n = (() => {
|
|
|
1159
1174
|
"sessions.badge.channel": "频道",
|
|
1160
1175
|
"sessions.badge.coding": "Coding",
|
|
1161
1176
|
"sessions.badge.setup": "配置",
|
|
1162
|
-
"sessions.newSession": "
|
|
1177
|
+
"sessions.newSession": "新会话",
|
|
1178
|
+
"sessions.advancedOptions": "高级选项",
|
|
1163
1179
|
"sessions.loadMore": "加载更多会话",
|
|
1164
1180
|
"sessions.actions.pin": "置顶",
|
|
1165
1181
|
"sessions.actions.unpin": "取消置顶",
|
|
@@ -1498,6 +1514,7 @@ const I18n = (() => {
|
|
|
1498
1514
|
"extensions.sort.downloads": "安装最多",
|
|
1499
1515
|
"extensions.filter.all": "全部",
|
|
1500
1516
|
"extensions.filter.installed": "已安装",
|
|
1517
|
+
"extensions.filter.brand": "品牌扩展",
|
|
1501
1518
|
"extensions.empty": "暂无可用扩展。",
|
|
1502
1519
|
"extensions.noResults": "没有匹配的扩展。",
|
|
1503
1520
|
"extensions.loadFailed": "无法连接扩展市场。",
|
|
@@ -1516,16 +1533,9 @@ const I18n = (() => {
|
|
|
1516
1533
|
"extensions.unit.api": "个 API",
|
|
1517
1534
|
"extensions.unit.apis": "个 API",
|
|
1518
1535
|
"extensions.detail.back": "返回",
|
|
1536
|
+
"extensions.detail.readme": "使用说明",
|
|
1519
1537
|
"extensions.detail.contributes": "包含内容",
|
|
1520
|
-
"extensions.detail.entry_points": "访问入口",
|
|
1521
1538
|
"extensions.detail.versions": "版本历史",
|
|
1522
|
-
"extensions.slot.sidebar.nav": "侧边栏导航",
|
|
1523
|
-
"extensions.slot.sidebar.nav.top": "侧边栏导航(顶部)",
|
|
1524
|
-
"extensions.slot.sidebar.nav.bottom": "侧边栏导航(底部)",
|
|
1525
|
-
"extensions.slot.sidebar.footer": "侧边栏底部",
|
|
1526
|
-
"extensions.slot.main.workspace": "主工作区",
|
|
1527
|
-
"extensions.slot.session.aside": "会话侧栏",
|
|
1528
|
-
"extensions.slot.settings.tabs": "设置页标签",
|
|
1529
1539
|
"extensions.section.agents": "Agent",
|
|
1530
1540
|
"extensions.section.skills": "技能",
|
|
1531
1541
|
"extensions.section.panels": "面板",
|
|
@@ -1539,6 +1549,7 @@ const I18n = (() => {
|
|
|
1539
1549
|
"extensions.action.enable": "启用",
|
|
1540
1550
|
"extensions.action.remove": "移除",
|
|
1541
1551
|
"extensions.action.removeConfirm": "确定移除此扩展?相关文件会被删除,之后可重新安装。",
|
|
1552
|
+
"extensions.action.removePurgeData": "同时删除该扩展的数据(默认保留,重新安装会自动恢复)",
|
|
1542
1553
|
"mcp.loading": "加载中…",
|
|
1543
1554
|
"mcp.empty.title": "尚未配置 MCP 服务",
|
|
1544
1555
|
"mcp.empty.body": "新建 ~/.clacky/mcp.json 即可接入 MCP 服务。格式与 Claude Desktop、Cursor 一致,现有配置可直接复用。",
|
|
@@ -1557,10 +1568,10 @@ const I18n = (() => {
|
|
|
1557
1568
|
"mcp.btn.hide": "收起",
|
|
1558
1569
|
"mcp.btn.refresh": "刷新",
|
|
1559
1570
|
"mcp.btn.remove": "移除",
|
|
1560
|
-
"mcp.btn.askClacky": "让
|
|
1571
|
+
"mcp.btn.askClacky": "让 AI 帮我配置",
|
|
1561
1572
|
"mcp.cta.title": "想接入新的 MCP 服务?",
|
|
1562
|
-
"mcp.cta.body": "
|
|
1563
|
-
"mcp.cta.button": "让
|
|
1573
|
+
"mcp.cta.body": "用一句大白话描述你想做什么——AI 会挑合适的包、问关键参数、跑通后回复你。",
|
|
1574
|
+
"mcp.cta.button": "让 AI 帮我添加",
|
|
1564
1575
|
"mcp.remove.confirm": "移除 MCP 服务 '{{name}}'?相关工具将不再可用。",
|
|
1565
1576
|
"mcp.remove.error": "移除失败:{{msg}}",
|
|
1566
1577
|
"mcp.loadError": "加载 MCP 服务失败:{{msg}}",
|
|
@@ -1649,6 +1660,9 @@ const I18n = (() => {
|
|
|
1649
1660
|
"settings.models.baseurl.variant.mainland_cn_coding": "大陆 · Coding Plan",
|
|
1650
1661
|
"settings.models.baseurl.variant.international_payg": "海外 · 按量付费",
|
|
1651
1662
|
"settings.models.baseurl.variant.international_coding": "海外 · Coding Plan",
|
|
1663
|
+
"settings.models.baseurl.variant.ark_payg": "按量付费",
|
|
1664
|
+
"settings.models.baseurl.variant.ark_coding": "Coding Plan",
|
|
1665
|
+
"settings.models.baseurl.variant.ark_agent": "Agent Plan",
|
|
1652
1666
|
"settings.models.btn.save": "保存",
|
|
1653
1667
|
"settings.models.btn.saving": "保存中…",
|
|
1654
1668
|
"settings.models.btn.saved": "已保存 ✓",
|
|
@@ -1770,6 +1784,13 @@ const I18n = (() => {
|
|
|
1770
1784
|
|
|
1771
1785
|
"settings.accentColor.title": "主色",
|
|
1772
1786
|
|
|
1787
|
+
"settings.bgTheme.title": "背景",
|
|
1788
|
+
"settings.bgTheme.light": "浅色",
|
|
1789
|
+
"settings.bgTheme.dark": "深色",
|
|
1790
|
+
"settings.bgTheme.dim": "暗灰",
|
|
1791
|
+
"settings.bgTheme.warm": "暖色",
|
|
1792
|
+
"settings.bgTheme.black": "蓝黑",
|
|
1793
|
+
|
|
1773
1794
|
// ── Onboard ──
|
|
1774
1795
|
"onboard.title": "欢迎使用 {{brand}}",
|
|
1775
1796
|
"onboard.subtitle": "一分钟完成配置,马上开始。",
|
|
@@ -1804,6 +1825,7 @@ const I18n = (() => {
|
|
|
1804
1825
|
"onboard.manual.toggle": "使用自己的 API Key 手动配置",
|
|
1805
1826
|
"onboard.provider.custom": "自定义",
|
|
1806
1827
|
"provider.recommended": "推荐",
|
|
1828
|
+
"provider.name.volcengine_ark": "火山方舟(豆包)",
|
|
1807
1829
|
"provider.promo.openclacky.title": "推荐 OpenClacky AI Keys",
|
|
1808
1830
|
"provider.promo.openclacky.1": "一个 Key 直通全球顶级模型 — Claude、Gemini、DeepSeek 等。",
|
|
1809
1831
|
"provider.promo.openclacky.2": "最强推理 × 最优性价比 × 最佳视觉 — 一站式统一接入。",
|
data/lib/clacky/web/index.html
CHANGED
|
@@ -54,6 +54,14 @@
|
|
|
54
54
|
<div id="header-right">
|
|
55
55
|
<span id="ext-slot-header-right" data-slot="header.right"></span>
|
|
56
56
|
|
|
57
|
+
<button id="reload-header" class="theme-toggle-btn" title="Reload" onclick="location.reload()">
|
|
58
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
|
|
59
|
+
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
|
|
60
|
+
<path d="M21 3v5h-5"/>
|
|
61
|
+
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
|
|
62
|
+
<path d="M8 16H3v5"/>
|
|
63
|
+
</svg>
|
|
64
|
+
</button>
|
|
57
65
|
<button id="share-toggle-header" class="theme-toggle-btn" data-i18n-title="share.tooltip" title="Share">
|
|
58
66
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
|
|
59
67
|
<circle cx="18" cy="5" r="3"/>
|
|
@@ -64,11 +72,7 @@
|
|
|
64
72
|
</svg>
|
|
65
73
|
</button>
|
|
66
74
|
<button id="notify-toggle-header" class="theme-toggle-btn" data-i18n-title="notify.tooltip.off" title="Sound on task complete"></button>
|
|
67
|
-
|
|
68
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon-sm">
|
|
69
|
-
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
|
|
70
|
-
</svg>
|
|
71
|
-
</button>
|
|
75
|
+
|
|
72
76
|
</div>
|
|
73
77
|
</header>
|
|
74
78
|
|
|
@@ -128,7 +132,28 @@
|
|
|
128
132
|
<div class="sidebar-divider">
|
|
129
133
|
<span data-i18n="sidebar.chat">Sessions</span>
|
|
130
134
|
<div class="sidebar-divider-actions">
|
|
131
|
-
<
|
|
135
|
+
<div class="btn-split-wrap">
|
|
136
|
+
<button id="btn-new-session-inline" class="btn-split-main" title="New Session">
|
|
137
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
138
|
+
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
|
|
139
|
+
<line x1="12" y1="8" x2="12" y2="14" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
|
|
140
|
+
<line x1="9" y1="11" x2="15" y2="11" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
|
|
141
|
+
</svg>
|
|
142
|
+
<span data-i18n="sessions.newSession">New Session</span>
|
|
143
|
+
</button>
|
|
144
|
+
<div class="btn-split-dropdown">
|
|
145
|
+
<button id="btn-new-session-advanced" class="btn-split-arrow" aria-label="Advanced new session options">
|
|
146
|
+
<svg width="10" height="10" viewBox="0 0 10 10" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
147
|
+
<path d="M2 3.5L5 6.5L8 3.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
|
148
|
+
</svg>
|
|
149
|
+
</button>
|
|
150
|
+
<div class="btn-split-menu">
|
|
151
|
+
<button class="btn-split-menu-item" id="btn-new-session-goto-advanced">
|
|
152
|
+
<span data-i18n="sessions.advancedOptions">Advanced options</span>
|
|
153
|
+
</button>
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
132
157
|
</div>
|
|
133
158
|
</div>
|
|
134
159
|
|
|
@@ -659,14 +684,15 @@
|
|
|
659
684
|
<div class="extensions-toolbar">
|
|
660
685
|
<div class="extensions-filter-tabs">
|
|
661
686
|
<button type="button" class="extensions-filter-tab extensions-filter-tab-active" data-filter="all" data-i18n="extensions.filter.all">All</button>
|
|
687
|
+
<button type="button" class="extensions-filter-tab" data-filter="brand" id="tab-extensions-brand" data-i18n="extensions.filter.brand" style="display:none">Brand</button>
|
|
662
688
|
<button type="button" class="extensions-filter-tab" data-filter="installed" data-i18n="extensions.filter.installed">Installed</button>
|
|
663
689
|
</div>
|
|
664
690
|
<div class="extensions-toolbar-right">
|
|
665
691
|
<input type="text" id="extensions-search-input" class="extensions-search" placeholder="Search extensions…" />
|
|
666
692
|
<select id="extensions-sort" class="extensions-sort">
|
|
693
|
+
<option value="downloads" data-i18n="extensions.sort.downloads">Most downloaded</option>
|
|
667
694
|
<option value="newest" data-i18n="extensions.sort.newest">Newest</option>
|
|
668
695
|
<option value="updated" data-i18n="extensions.sort.updated">Recently updated</option>
|
|
669
|
-
<option value="downloads" data-i18n="extensions.sort.downloads">Most downloaded</option>
|
|
670
696
|
</select>
|
|
671
697
|
</div>
|
|
672
698
|
</div>
|
|
@@ -928,6 +954,43 @@
|
|
|
928
954
|
</div>
|
|
929
955
|
</section>
|
|
930
956
|
|
|
957
|
+
<!-- Background Theme section -->
|
|
958
|
+
<section class="settings-section" id="bg-theme-section">
|
|
959
|
+
<div class="settings-section-title">
|
|
960
|
+
<span data-i18n="settings.bgTheme.title">Background</span>
|
|
961
|
+
</div>
|
|
962
|
+
<div class="settings-bg-theme-cards">
|
|
963
|
+
<button class="settings-bg-theme-card bg-theme-dark" data-bg-theme="dark">
|
|
964
|
+
<div class="bg-card-preview">
|
|
965
|
+
<div class="bg-card-sidebar"></div>
|
|
966
|
+
<div class="bg-card-main"></div>
|
|
967
|
+
</div>
|
|
968
|
+
<span class="bg-card-label" data-i18n="settings.bgTheme.dark">Dark</span>
|
|
969
|
+
</button>
|
|
970
|
+
<button class="settings-bg-theme-card bg-theme-dim" data-bg-theme="dim">
|
|
971
|
+
<div class="bg-card-preview">
|
|
972
|
+
<div class="bg-card-sidebar"></div>
|
|
973
|
+
<div class="bg-card-main"></div>
|
|
974
|
+
</div>
|
|
975
|
+
<span class="bg-card-label" data-i18n="settings.bgTheme.dim">Dim</span>
|
|
976
|
+
</button>
|
|
977
|
+
<button class="settings-bg-theme-card bg-theme-warm" data-bg-theme="warm">
|
|
978
|
+
<div class="bg-card-preview">
|
|
979
|
+
<div class="bg-card-sidebar"></div>
|
|
980
|
+
<div class="bg-card-main"></div>
|
|
981
|
+
</div>
|
|
982
|
+
<span class="bg-card-label" data-i18n="settings.bgTheme.warm">Warm</span>
|
|
983
|
+
</button>
|
|
984
|
+
<button class="settings-bg-theme-card bg-theme-light" data-bg-theme="light">
|
|
985
|
+
<div class="bg-card-preview">
|
|
986
|
+
<div class="bg-card-sidebar"></div>
|
|
987
|
+
<div class="bg-card-main"></div>
|
|
988
|
+
</div>
|
|
989
|
+
<span class="bg-card-label" data-i18n="settings.bgTheme.light">Light</span>
|
|
990
|
+
</button>
|
|
991
|
+
</div>
|
|
992
|
+
</section>
|
|
993
|
+
|
|
931
994
|
<!-- Accent Color section -->
|
|
932
995
|
<section class="settings-section" id="accent-color-section">
|
|
933
996
|
<div class="settings-section-title">
|
|
@@ -940,6 +1003,10 @@
|
|
|
940
1003
|
<button class="settings-accent-swatch swatch-sunrise-orange" data-color="#F59E0B" title="Sunrise Orange"></button>
|
|
941
1004
|
<button class="settings-accent-swatch swatch-rose-violet" data-color="#8B5CF6" title="Rose Violet"></button>
|
|
942
1005
|
<button class="settings-accent-swatch swatch-coral-red" data-color="#EF4444" title="Coral Red"></button>
|
|
1006
|
+
<button class="settings-accent-swatch swatch-sky-blue" data-color="#38BDF8" title="Sky Blue"></button>
|
|
1007
|
+
<button class="settings-accent-swatch swatch-hot-pink" data-color="#EC4899" title="Hot Pink"></button>
|
|
1008
|
+
<button class="settings-accent-swatch swatch-warm-brown" data-color="#92745A" title="Warm Brown"></button>
|
|
1009
|
+
<button class="settings-accent-swatch swatch-slate-gray" data-color="#6B7280" title="Slate Gray"></button>
|
|
943
1010
|
</div>
|
|
944
1011
|
</section>
|
|
945
1012
|
</div>
|
data/lib/clacky/web/sessions.js
CHANGED
|
@@ -390,11 +390,13 @@ const Sessions = (() => {
|
|
|
390
390
|
const tip1 = I18n.t("chat.empty.tip1");
|
|
391
391
|
const tip2 = I18n.t("chat.empty.tip2");
|
|
392
392
|
const tip3 = I18n.t("chat.empty.tip3");
|
|
393
|
-
const tip4 = I18n.t("chat.empty.tip4");
|
|
394
393
|
return `
|
|
395
394
|
<div class="chat-empty-icon" aria-hidden="true">
|
|
396
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="
|
|
397
|
-
<path d="M21
|
|
395
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
|
|
396
|
+
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z"/>
|
|
397
|
+
<circle cx="8.5" cy="12" r="0.9" fill="currentColor" stroke="none"/>
|
|
398
|
+
<circle cx="12" cy="12" r="0.9" fill="currentColor" stroke="none"/>
|
|
399
|
+
<circle cx="15.5" cy="12" r="0.9" fill="currentColor" stroke="none"/>
|
|
398
400
|
</svg>
|
|
399
401
|
</div>
|
|
400
402
|
<div class="chat-empty-title">${escapeHtml(title)}</div>
|
|
@@ -403,7 +405,6 @@ const Sessions = (() => {
|
|
|
403
405
|
<li>${escapeHtml(tip1)}</li>
|
|
404
406
|
<li>${escapeHtml(tip2)}</li>
|
|
405
407
|
<li>${escapeHtml(tip3)}</li>
|
|
406
|
-
<li>${escapeHtml(tip4)}</li>
|
|
407
408
|
</ul>
|
|
408
409
|
`;
|
|
409
410
|
}
|
|
@@ -487,8 +488,8 @@ const Sessions = (() => {
|
|
|
487
488
|
// ── New session controls (split button + welcome + modal) ──────────────
|
|
488
489
|
//
|
|
489
490
|
// Wires up every button/interaction that kicks off session creation:
|
|
490
|
-
// - "+ New Session" inline split-button (quick create)
|
|
491
|
-
// - "▾" arrow button (
|
|
491
|
+
// - "+ New Session" inline split-button (quick create — directly creates a plain session)
|
|
492
|
+
// - "▾" arrow button (navigates to /#new for advanced options)
|
|
492
493
|
// - "+ New Session" big button on the welcome screen
|
|
493
494
|
// - New Session Modal: close / cancel / create / overlay click / browse
|
|
494
495
|
// - Load-more button (rendered dynamically by renderList)
|
|
@@ -497,7 +498,28 @@ const Sessions = (() => {
|
|
|
497
498
|
// we call addEventListener directly (no ?. / no `if` guards). If any is
|
|
498
499
|
// missing, it means HTML and JS drifted and we want the loud error.
|
|
499
500
|
function _initNewSessionControls() {
|
|
500
|
-
|
|
501
|
+
// Main button: directly create a plain session using the last-used agent.
|
|
502
|
+
const _btnNewInline = document.getElementById("btn-new-session-inline");
|
|
503
|
+
_btnNewInline.addEventListener("click", async () => {
|
|
504
|
+
if (_btnNewInline.disabled) return;
|
|
505
|
+
_btnNewInline.disabled = true;
|
|
506
|
+
try {
|
|
507
|
+
const session = await NewSessionStore.createSession({ existingSessions: _sessions, useDefaults: true });
|
|
508
|
+
if (!session) return;
|
|
509
|
+
NewSessionStore.reset();
|
|
510
|
+
// Add to local list immediately so renderList shows it and findOrFetch
|
|
511
|
+
// can locate it synchronously (without a round-trip to the API).
|
|
512
|
+
if (!_sessions.find(s => s.id === session.id)) {
|
|
513
|
+
_sessions.unshift(session);
|
|
514
|
+
}
|
|
515
|
+
location.hash = `session/${session.id}`;
|
|
516
|
+
} finally {
|
|
517
|
+
_btnNewInline.disabled = false;
|
|
518
|
+
}
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// Arrow button: hover (CSS) shows menu. Menu item navigates to /#new on click.
|
|
522
|
+
document.getElementById("btn-new-session-goto-advanced")
|
|
501
523
|
.addEventListener("click", () => { location.hash = "#new"; });
|
|
502
524
|
|
|
503
525
|
document.addEventListener("click", (e) => {
|
|
@@ -1250,11 +1272,16 @@ const Sessions = (() => {
|
|
|
1250
1272
|
|
|
1251
1273
|
// Mark the last tool-item in a group as done (update status indicator).
|
|
1252
1274
|
// collapsed: true → keep stdout hidden (history mode); false → show immediately (live mode).
|
|
1253
|
-
function _completeLastToolItem(group, result,
|
|
1275
|
+
function _completeLastToolItem(group, result, opts = {}) {
|
|
1254
1276
|
const body = group.querySelector(".tool-group-body");
|
|
1255
1277
|
const items = body.querySelectorAll(".tool-item");
|
|
1256
1278
|
if (!items.length) return;
|
|
1257
|
-
|
|
1279
|
+
_completeToolItem(items[items.length - 1], result, opts);
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// Mark a specific tool-item element as done (update status + render result).
|
|
1283
|
+
function _completeToolItem(last, result, { collapsed = false } = {}) {
|
|
1284
|
+
if (!last) return;
|
|
1258
1285
|
const status = last.querySelector(".tool-item-status");
|
|
1259
1286
|
if (status) {
|
|
1260
1287
|
status.className = "tool-item-status ok";
|
|
@@ -1358,6 +1385,10 @@ const Sessions = (() => {
|
|
|
1358
1385
|
bubbleHtml += escapeHtml(ev.content || "");
|
|
1359
1386
|
el.innerHTML = bubbleHtml;
|
|
1360
1387
|
if (ev.created_at) el.dataset.createdAt = ev.created_at;
|
|
1388
|
+
// Messages archived into a compressed chunk can't be edited (the backend
|
|
1389
|
+
// truncate keys off the active in-memory history). Flag them so the edit
|
|
1390
|
+
// affordance stays hidden.
|
|
1391
|
+
if (ev.editable === false) el.dataset.editable = "false";
|
|
1361
1392
|
const wrap = document.createElement("div");
|
|
1362
1393
|
wrap.className = "msg-user-wrap";
|
|
1363
1394
|
wrap.appendChild(el);
|
|
@@ -1757,7 +1788,9 @@ const Sessions = (() => {
|
|
|
1757
1788
|
});
|
|
1758
1789
|
|
|
1759
1790
|
bar.appendChild(copyBtn);
|
|
1760
|
-
|
|
1791
|
+
// Skip the edit affordance for messages already archived into a compressed
|
|
1792
|
+
// chunk — editing them would silently no-op on the backend.
|
|
1793
|
+
if (el.dataset.editable !== "false") bar.appendChild(editBtn);
|
|
1761
1794
|
wrap.appendChild(bar);
|
|
1762
1795
|
}
|
|
1763
1796
|
|
|
@@ -1811,8 +1844,9 @@ const Sessions = (() => {
|
|
|
1811
1844
|
sendBtn.textContent = I18n.t("chat.send");
|
|
1812
1845
|
sendBtn.addEventListener("click", () => _submitEdit(el, textarea.value.trim()));
|
|
1813
1846
|
|
|
1847
|
+
const editIme = IME.track(textarea);
|
|
1814
1848
|
textarea.addEventListener("keydown", (e) => {
|
|
1815
|
-
if (e.key === "Enter" && !e.shiftKey) {
|
|
1849
|
+
if (e.key === "Enter" && !e.shiftKey && !editIme.isComposing(e)) {
|
|
1816
1850
|
e.preventDefault();
|
|
1817
1851
|
_submitEdit(el, textarea.value.trim());
|
|
1818
1852
|
}
|
|
@@ -3220,13 +3254,19 @@ const Sessions = (() => {
|
|
|
3220
3254
|
if (sibStatus) {
|
|
3221
3255
|
sibStatus.innerHTML = `<span class="sib-dot"></span>${s.status || "idle"}`;
|
|
3222
3256
|
sibStatus.className = `sib-status-${s.status || "idle"}`;
|
|
3257
|
+
sibStatus.title = I18n.t("sib.status.tooltip");
|
|
3223
3258
|
}
|
|
3224
3259
|
|
|
3225
|
-
// Session ID
|
|
3226
|
-
// trigger for the session actions dropdown (download, etc.).
|
|
3260
|
+
// Session ID label — shows localised "Session file" label with short hash suffix.
|
|
3261
|
+
// The span itself is the click trigger for the session actions dropdown (download, etc.).
|
|
3227
3262
|
const sibId = $("sib-id");
|
|
3228
3263
|
if (sibId) {
|
|
3229
|
-
|
|
3264
|
+
const shortHash = s.id ? s.id.slice(0, 8) : "";
|
|
3265
|
+
const lp = I18n.lang() === "zh" ? "(" : " (";
|
|
3266
|
+
const rp = I18n.lang() === "zh" ? ")" : ")";
|
|
3267
|
+
sibId.textContent = shortHash
|
|
3268
|
+
? `${I18n.t("sib.id.label")}${lp}${shortHash}${rp}`
|
|
3269
|
+
: I18n.t("sib.id.label");
|
|
3230
3270
|
sibId.title = s.id || "";
|
|
3231
3271
|
if (s.id) {
|
|
3232
3272
|
sibId.dataset.sessionId = s.id;
|
|
@@ -3249,6 +3289,7 @@ const Sessions = (() => {
|
|
|
3249
3289
|
const sibSepAfterMode = document.querySelector(".sib-sep-after-mode");
|
|
3250
3290
|
if (sibMode) {
|
|
3251
3291
|
sibMode.textContent = s.permission_mode || "";
|
|
3292
|
+
sibMode.title = s.permission_mode ? I18n.t("sib.mode.tooltip") : "";
|
|
3252
3293
|
sibMode.style.display = s.permission_mode ? "" : "none";
|
|
3253
3294
|
}
|
|
3254
3295
|
if (sibSepAfterMode) {
|
|
@@ -3303,7 +3344,10 @@ const Sessions = (() => {
|
|
|
3303
3344
|
|
|
3304
3345
|
// Tasks
|
|
3305
3346
|
const sibTasks = $("sib-tasks");
|
|
3306
|
-
if (sibTasks)
|
|
3347
|
+
if (sibTasks) {
|
|
3348
|
+
sibTasks.textContent = I18n.t("sessions.metaTasks", { n: s.total_tasks || 0 });
|
|
3349
|
+
sibTasks.title = I18n.t("sib.tasks.tooltip");
|
|
3350
|
+
}
|
|
3307
3351
|
|
|
3308
3352
|
// Cost — show N/A when pricing is unknown (estimated)
|
|
3309
3353
|
const sibCost = $("sib-cost");
|
|
@@ -3315,6 +3359,7 @@ const Sessions = (() => {
|
|
|
3315
3359
|
} else {
|
|
3316
3360
|
sibCost.textContent = "N/A";
|
|
3317
3361
|
}
|
|
3362
|
+
sibCost.title = I18n.t("sib.cost.tooltip");
|
|
3318
3363
|
}
|
|
3319
3364
|
|
|
3320
3365
|
const bar = $("session-info-bar");
|
|
@@ -3456,9 +3501,21 @@ const Sessions = (() => {
|
|
|
3456
3501
|
},
|
|
3457
3502
|
|
|
3458
3503
|
// Update the last tool-item with a result status tick.
|
|
3504
|
+
// If _liveToolGroup was collapsed by an intervening info message (e.g.
|
|
3505
|
+
// "Subagent start/completed" during invoke_skill), fall back to the last
|
|
3506
|
+
// still-running .tool-item in the DOM.
|
|
3459
3507
|
appendToolResult(result) {
|
|
3460
3508
|
if (Sessions._liveToolGroup && Sessions._liveLastToolItem) {
|
|
3461
3509
|
_completeLastToolItem(Sessions._liveToolGroup, result);
|
|
3510
|
+
return;
|
|
3511
|
+
}
|
|
3512
|
+
const messages = RenderTarget.current();
|
|
3513
|
+
if (messages) {
|
|
3514
|
+
const running = messages.querySelectorAll(".tool-item-status.running");
|
|
3515
|
+
if (running.length > 0) {
|
|
3516
|
+
const item = running[running.length - 1].closest(".tool-item");
|
|
3517
|
+
if (item) { _completeToolItem(item, result); return; }
|
|
3518
|
+
}
|
|
3462
3519
|
}
|
|
3463
3520
|
},
|
|
3464
3521
|
|
data/lib/clacky/web/settings.js
CHANGED
|
@@ -59,9 +59,15 @@ const Settings = (() => {
|
|
|
59
59
|
_models.forEach((m, i) => _renderCard(container, m, i));
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
function _providerDisplayName(p) {
|
|
63
|
+
if (!p) return I18n.t("settings.models.provider.custom");
|
|
64
|
+
const translated = p.name_key ? I18n.t(p.name_key) : null;
|
|
65
|
+
return (translated && translated !== p.name_key) ? translated : p.name;
|
|
66
|
+
}
|
|
67
|
+
|
|
62
68
|
function _getProviderName(model) {
|
|
63
69
|
const p = _findProviderByBaseUrl(model.base_url);
|
|
64
|
-
return p
|
|
70
|
+
return _providerDisplayName(p);
|
|
65
71
|
}
|
|
66
72
|
|
|
67
73
|
function _findProviderByBaseUrl(baseUrl) {
|
|
@@ -82,7 +88,7 @@ const Settings = (() => {
|
|
|
82
88
|
const isDefault = model.type === "default";
|
|
83
89
|
const isLite = model.type === "lite";
|
|
84
90
|
const provider = _findProviderByBaseUrl(model.base_url);
|
|
85
|
-
const providerName =
|
|
91
|
+
const providerName = _providerDisplayName(provider);
|
|
86
92
|
const websiteUrl = provider && provider.website_url;
|
|
87
93
|
const displayName = model.model || I18n.t("settings.models.unnamed");
|
|
88
94
|
|
|
@@ -266,7 +272,7 @@ const Settings = (() => {
|
|
|
266
272
|
const dropdown = document.getElementById("model-modal-provider-dropdown");
|
|
267
273
|
dropdown.innerHTML = `
|
|
268
274
|
<div class="custom-select-option" data-value="">${I18n.t("settings.models.placeholder.provider")}</div>
|
|
269
|
-
${_providers.map(p => `<div class="custom-select-option" data-value="${p.id}" data-label="${_esc(
|
|
275
|
+
${_providers.map(p => { const dn = _providerDisplayName(p); return `<div class="custom-select-option" data-value="${p.id}" data-label="${_esc(dn)}">${_esc(dn)}${p.id === "openclacky" ? ` <span class="provider-badge-recommended">${I18n.t("provider.recommended")}</span>` : ""}</div>`; }).join("")}
|
|
270
276
|
<div class="custom-select-option" data-value="custom">${I18n.t("settings.models.custom")}</div>
|
|
271
277
|
`;
|
|
272
278
|
|
|
@@ -2207,6 +2213,7 @@ const Settings = (() => {
|
|
|
2207
2213
|
_initFontBtns();
|
|
2208
2214
|
_initCurrencyBtns();
|
|
2209
2215
|
_initAccentColorBtns();
|
|
2216
|
+
_initBgThemeCards();
|
|
2210
2217
|
|
|
2211
2218
|
// Re-render model cards when language changes (dynamic HTML, not data-i18n)
|
|
2212
2219
|
document.addEventListener("langchange", () => {
|
|
@@ -2396,6 +2403,18 @@ const Settings = (() => {
|
|
|
2396
2403
|
});
|
|
2397
2404
|
}
|
|
2398
2405
|
|
|
2406
|
+
// ── Background Theme Cards ────────────────────────────────────────────
|
|
2407
|
+
function _initBgThemeCards() {
|
|
2408
|
+
// Sync initial active state from Theme module.
|
|
2409
|
+
const current = Theme.current();
|
|
2410
|
+
document.querySelectorAll("#bg-theme-section .settings-bg-theme-card").forEach(btn => {
|
|
2411
|
+
btn.classList.toggle("active", btn.dataset.bgTheme === current);
|
|
2412
|
+
btn.addEventListener("click", () => {
|
|
2413
|
+
Theme.applyBg(btn.dataset.bgTheme);
|
|
2414
|
+
});
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2399
2418
|
// ── QR Code Lightbox ───────────────────────────────────────────────────
|
|
2400
2419
|
// Sets up click-to-enlarge behaviour for the support QR code.
|
|
2401
2420
|
// Safe to call multiple times — idempotent via a data attribute guard.
|