openclacky 1.5.2 → 1.5.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/.dockerignore +13 -0
- data/CHANGELOG.md +50 -0
- data/Dockerfile +21 -1
- data/README.md +26 -7
- data/README_CN.md +26 -7
- data/README_JA.md +26 -7
- data/lib/clacky/agent/message_compressor.rb +4 -29
- data/lib/clacky/agent/message_compressor_helper.rb +13 -15
- data/lib/clacky/agent/session_serializer.rb +6 -0
- data/lib/clacky/agent.rb +13 -1
- data/lib/clacky/agent_config.rb +42 -11
- data/lib/clacky/brand_config.rb +32 -12
- data/lib/clacky/client.rb +7 -0
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +15 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +2 -6
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +11 -4
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +5 -1
- data/lib/clacky/default_parsers/xlsx_parser.py +66 -0
- data/lib/clacky/extension/api_extension.rb +3 -2
- data/lib/clacky/message_format/anthropic.rb +27 -1
- data/lib/clacky/message_format/open_ai.rb +92 -4
- data/lib/clacky/providers.rb +67 -14
- data/lib/clacky/server/http_server.rb +361 -36
- data/lib/clacky/server/project_manager.rb +150 -0
- data/lib/clacky/server/server_master.rb +7 -0
- data/lib/clacky/server/session_registry.rb +14 -1
- data/lib/clacky/session_manager.rb +5 -4
- data/lib/clacky/tools/file_reader.rb +2 -1
- data/lib/clacky/utils/model_pricing.rb +77 -18
- data/lib/clacky/utils/parser_manager.rb +115 -7
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +1053 -143
- data/lib/clacky/web/app.js +19 -5
- data/lib/clacky/web/components/code-editor.js +50 -12
- data/lib/clacky/web/components/notify.js +139 -22
- data/lib/clacky/web/core/aside.js +21 -0
- data/lib/clacky/web/features/billing/view.js +2 -1
- data/lib/clacky/web/features/brand/store.js +3 -1
- data/lib/clacky/web/features/brand/view.js +29 -5
- data/lib/clacky/web/features/extensions/store.js +15 -6
- data/lib/clacky/web/features/extensions/view.js +8 -4
- data/lib/clacky/web/features/new-session/view.js +2 -2
- data/lib/clacky/web/features/trash/store.js +23 -9
- data/lib/clacky/web/features/trash/view.js +51 -0
- data/lib/clacky/web/features/workspace/store.js +9 -0
- data/lib/clacky/web/features/workspace/view.js +8 -1
- data/lib/clacky/web/i18n.js +99 -4
- data/lib/clacky/web/index.html +70 -17
- data/lib/clacky/web/projects.js +1143 -0
- data/lib/clacky/web/sessions.js +318 -537
- data/lib/clacky/web/settings.js +146 -7
- data/lib/clacky/web/ws-dispatcher.js +59 -1
- data/scripts/build/src/install_system_deps.sh.cc +11 -3
- data/scripts/install_system_deps.sh +11 -3
- metadata +5 -2
- data/lib/clacky/default_parsers/xlsx_parser.rb +0 -121
data/lib/clacky/web/app.js
CHANGED
|
@@ -97,7 +97,11 @@ const Router = (() => {
|
|
|
97
97
|
if (h === "mcp") return { view: "mcp", params: {} };
|
|
98
98
|
if (h === "extensions") return { view: "extensions", params: {} };
|
|
99
99
|
const mExtDetail = h.match(/^extensions\/(.+)$/);
|
|
100
|
-
if (mExtDetail)
|
|
100
|
+
if (mExtDetail) {
|
|
101
|
+
const [detailId, qs] = mExtDetail[1].split("?");
|
|
102
|
+
const source = new URLSearchParams(qs || "").get("source") || null;
|
|
103
|
+
return { view: "extensions", params: { detailId, source } };
|
|
104
|
+
}
|
|
101
105
|
if (h === "trash") return { view: "trash", params: {} };
|
|
102
106
|
if (h === "profile") return { view: "profile", params: {} };
|
|
103
107
|
// Legacy: #memories redirects to #profile (memories are now merged into
|
|
@@ -256,8 +260,9 @@ const Router = (() => {
|
|
|
256
260
|
case "extensions":
|
|
257
261
|
$("extensions-panel").style.display = "flex";
|
|
258
262
|
if (params.detailId) {
|
|
259
|
-
|
|
260
|
-
|
|
263
|
+
const sourceQs = params.source ? `?source=${encodeURIComponent(params.source)}` : "";
|
|
264
|
+
_setHash(`extensions/${params.detailId}${sourceQs}`);
|
|
265
|
+
Extensions.onPanelShow({ detailId: params.detailId, source: params.source });
|
|
261
266
|
} else {
|
|
262
267
|
_setHash("extensions");
|
|
263
268
|
Extensions.onPanelShow();
|
|
@@ -501,11 +506,20 @@ const Modal = (() => {
|
|
|
501
506
|
});
|
|
502
507
|
}
|
|
503
508
|
|
|
504
|
-
/** Show a rename dialog. Returns a Promise<string|null>.
|
|
505
|
-
|
|
509
|
+
/** Show a rename dialog. Returns a Promise<string|null>.
|
|
510
|
+
* @param {string} currentName - pre-filled value
|
|
511
|
+
* @param {string} [labelI18nKey] - i18n key for the field label (default: sessions.modal.name)
|
|
512
|
+
*/
|
|
513
|
+
function rename(currentName = "", labelI18nKey = "sessions.modal.name") {
|
|
506
514
|
return new Promise(resolve => {
|
|
507
515
|
const overlay = $("rename-modal-overlay");
|
|
508
516
|
const input = $("rename-modal-input");
|
|
517
|
+
// Update label text dynamically
|
|
518
|
+
const labelSpan = overlay.querySelector("label[for='rename-modal-input'] span[data-i18n]");
|
|
519
|
+
if (labelSpan) {
|
|
520
|
+
labelSpan.dataset.i18n = labelI18nKey;
|
|
521
|
+
labelSpan.textContent = I18n.t(labelI18nKey);
|
|
522
|
+
}
|
|
509
523
|
input.value = currentName;
|
|
510
524
|
input.classList.remove("input-error");
|
|
511
525
|
// Re-attach to <body> end so it stacks above dynamically-appended overlays.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/* global CM */
|
|
1
|
+
/* global CM, marked */
|
|
2
2
|
/**
|
|
3
3
|
* CodeEditor — a reusable wrapper around CodeMirror 6.
|
|
4
4
|
*
|
|
@@ -31,16 +31,20 @@
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
function _detectLanguage(filename) {
|
|
34
|
-
if (!filename) return
|
|
34
|
+
if (!filename) return null;
|
|
35
35
|
const ext = filename.split(".").pop().toLowerCase();
|
|
36
36
|
const map = { md: "markdown", markdown: "markdown" };
|
|
37
|
-
return map[ext] ||
|
|
37
|
+
return map[ext] || null;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
function _isDark() {
|
|
41
41
|
return document.documentElement.getAttribute("data-theme") === "dark";
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
function _isMarkdown(language) {
|
|
45
|
+
return language === "markdown" || language === "md";
|
|
46
|
+
}
|
|
47
|
+
|
|
44
48
|
function _buildExtensions(opts) {
|
|
45
49
|
const extensions = [
|
|
46
50
|
CM.lineNumbers(),
|
|
@@ -86,9 +90,24 @@
|
|
|
86
90
|
}]));
|
|
87
91
|
}
|
|
88
92
|
|
|
93
|
+
if (opts.onChange) {
|
|
94
|
+
extensions.push(CM.EditorView.updateListener.of((update) => {
|
|
95
|
+
if (update.docChanged) opts.onChange(update.state.doc.toString());
|
|
96
|
+
}));
|
|
97
|
+
}
|
|
98
|
+
|
|
89
99
|
return extensions;
|
|
90
100
|
}
|
|
91
101
|
|
|
102
|
+
function _renderMarkdownHtml(text) {
|
|
103
|
+
if (typeof marked !== "undefined") {
|
|
104
|
+
try {
|
|
105
|
+
return marked.parse(text, { breaks: true, gfm: true });
|
|
106
|
+
} catch (_) { /* fall through */ }
|
|
107
|
+
}
|
|
108
|
+
return `<pre>${text.replace(/</g, "<")}</pre>`;
|
|
109
|
+
}
|
|
110
|
+
|
|
92
111
|
function open(opts) {
|
|
93
112
|
const {
|
|
94
113
|
content = "",
|
|
@@ -101,6 +120,7 @@
|
|
|
101
120
|
|
|
102
121
|
const kind = opts.kind || (opts.filename ? _fileKind(opts.filename) : "text");
|
|
103
122
|
const language = opts.language || _detectLanguage(opts.filename);
|
|
123
|
+
const isMd = _isMarkdown(language) && kind !== "image";
|
|
104
124
|
|
|
105
125
|
let overlay = document.getElementById("code-editor-overlay");
|
|
106
126
|
if (overlay) overlay.remove();
|
|
@@ -110,8 +130,8 @@
|
|
|
110
130
|
overlay.className = "modal-overlay";
|
|
111
131
|
|
|
112
132
|
const cancelLabel = I18n.t("modal.cancel");
|
|
113
|
-
const closeLabel
|
|
114
|
-
const saveLabel
|
|
133
|
+
const closeLabel = I18n.t("modal.close");
|
|
134
|
+
const saveLabel = I18n.t("modal.save");
|
|
115
135
|
|
|
116
136
|
const isReadOnlyOrImage = readOnly || kind === "image";
|
|
117
137
|
const footerActions = isReadOnlyOrImage
|
|
@@ -119,12 +139,14 @@
|
|
|
119
139
|
: `<button class="btn btn-secondary code-editor-cancel">${cancelLabel}</button><button class="btn btn-primary code-editor-save">${saveLabel}</button>`;
|
|
120
140
|
|
|
121
141
|
overlay.innerHTML = `
|
|
122
|
-
<div class="code-editor-modal${kind === "image" ? " code-editor-modal--image" : ""}">
|
|
142
|
+
<div class="code-editor-modal${kind === "image" ? " code-editor-modal--image" : ""}${isMd ? " code-editor-modal--md" : ""}">
|
|
123
143
|
<div class="code-editor-header">
|
|
124
144
|
<h3 class="code-editor-title"></h3>
|
|
125
145
|
<button class="code-editor-close" title="${closeLabel}">×</button>
|
|
126
146
|
</div>
|
|
127
|
-
<div class="code-editor-body"
|
|
147
|
+
<div class="code-editor-body${isMd ? " code-editor-body--split" : ""}">
|
|
148
|
+
${isMd ? `<div class="code-editor-split-left"></div><div class="code-editor-split-divider"></div><div class="code-editor-split-right"><div class="code-editor-markdown-preview"></div></div>` : ""}
|
|
149
|
+
</div>
|
|
128
150
|
<div class="code-editor-footer">
|
|
129
151
|
<span class="code-editor-status"></span>
|
|
130
152
|
<div class="code-editor-actions">${footerActions}</div>
|
|
@@ -134,8 +156,8 @@
|
|
|
134
156
|
document.body.appendChild(overlay);
|
|
135
157
|
overlay.querySelector(".code-editor-title").textContent = title;
|
|
136
158
|
|
|
137
|
-
const body
|
|
138
|
-
const status
|
|
159
|
+
const body = overlay.querySelector(".code-editor-body");
|
|
160
|
+
const status = overlay.querySelector(".code-editor-status");
|
|
139
161
|
const closeBtn = overlay.querySelector(".code-editor-close");
|
|
140
162
|
const cancelBtn = overlay.querySelector(".code-editor-cancel");
|
|
141
163
|
const saveBtn = overlay.querySelector(".code-editor-save");
|
|
@@ -147,7 +169,6 @@
|
|
|
147
169
|
|
|
148
170
|
closeBtn.addEventListener("click", close);
|
|
149
171
|
if (cancelBtn) cancelBtn.addEventListener("click", close);
|
|
150
|
-
overlay.addEventListener("click", (e) => { if (e.target === overlay) close(); });
|
|
151
172
|
|
|
152
173
|
if (kind === "image") {
|
|
153
174
|
body.classList.add("code-editor-body--image");
|
|
@@ -159,7 +180,21 @@
|
|
|
159
180
|
return { close };
|
|
160
181
|
}
|
|
161
182
|
|
|
162
|
-
|
|
183
|
+
// Editor mount target: left pane for markdown split, body itself otherwise
|
|
184
|
+
const editorParent = isMd ? overlay.querySelector(".code-editor-split-left") : body;
|
|
185
|
+
const previewPane = isMd ? overlay.querySelector(".code-editor-markdown-preview") : null;
|
|
186
|
+
|
|
187
|
+
function updatePreview(text) {
|
|
188
|
+
if (previewPane) previewPane.innerHTML = _renderMarkdownHtml(text);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const editorOpts = {
|
|
192
|
+
language: language || "markdown",
|
|
193
|
+
readOnly,
|
|
194
|
+
onSave: null,
|
|
195
|
+
_getContent: null,
|
|
196
|
+
onChange: isMd ? updatePreview : null,
|
|
197
|
+
};
|
|
163
198
|
const getContent = () => view.state.doc.toString();
|
|
164
199
|
editorOpts._getContent = getContent;
|
|
165
200
|
editorOpts.onSave = onSave ? () => doSave() : null;
|
|
@@ -169,9 +204,12 @@
|
|
|
169
204
|
doc: content,
|
|
170
205
|
extensions: _buildExtensions(editorOpts),
|
|
171
206
|
}),
|
|
172
|
-
parent:
|
|
207
|
+
parent: editorParent,
|
|
173
208
|
});
|
|
174
209
|
|
|
210
|
+
// Render initial preview
|
|
211
|
+
if (isMd) updatePreview(content);
|
|
212
|
+
|
|
175
213
|
async function doSave() {
|
|
176
214
|
if (!onSave) return;
|
|
177
215
|
if (saveBtn) saveBtn.disabled = true;
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
// notify.js — Task-complete sound notification module
|
|
1
|
+
// notify.js — Task-complete sound + desktop notification module
|
|
2
2
|
//
|
|
3
|
-
// Plays a short sound when
|
|
4
|
-
//
|
|
5
|
-
// task completes. We listen to
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
3
|
+
// Plays a short sound and (when permitted) shows a browser Notification when
|
|
4
|
+
// an agent task finishes, driven by the global `task_finished` event the
|
|
5
|
+
// server broadcasts (broadcast_all) the moment a task completes. We listen to
|
|
6
|
+
// this dedicated signal — rather than `complete` (only delivered to subscribers
|
|
7
|
+
// of that session) — so a background session finishing still reaches every
|
|
8
|
+
// browser. Whether a task *finished* is decided on the backend; this module
|
|
9
|
+
// only decides whether the user is looking.
|
|
9
10
|
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
11
|
+
// Alerts only fire when the user is NOT actively looking at the finished
|
|
12
|
+
// session. "Not looking" means ANY of:
|
|
12
13
|
// 1. The finished session is not the currently open one
|
|
13
14
|
// (sid !== Sessions.activeId)
|
|
14
15
|
// 2. The browser window has lost focus (!document.hasFocus())
|
|
@@ -18,15 +19,20 @@
|
|
|
18
19
|
// silent — they can already see the result.
|
|
19
20
|
//
|
|
20
21
|
// The feature is gated behind a header toggle (🔔/🔕) next to the theme
|
|
21
|
-
// switcher. Default OFF; the choice is persisted to localStorage.
|
|
22
|
+
// switcher. Default OFF; the choice is persisted to localStorage. Enabling
|
|
23
|
+
// the toggle also requests browser Notification permission (must happen in
|
|
24
|
+
// the click gesture) so desktop toasts work when the tab is in the background.
|
|
22
25
|
//
|
|
23
|
-
// No history replay: a chime is a live cue, never re-fired on page
|
|
24
|
-
// The audio file is served as a static asset (/notify.mp3)
|
|
26
|
+
// No history replay: a chime/toast is a live cue, never re-fired on page
|
|
27
|
+
// refresh. The audio file is served as a static asset (/assets/notify.mp3).
|
|
25
28
|
//
|
|
26
|
-
// Depends on: Sessions (sessions.js) for activeId,
|
|
27
|
-
//
|
|
29
|
+
// Depends on: Sessions (sessions.js) for activeId/find, Router (app.js) for
|
|
30
|
+
// focus-on-click navigation, I18n (i18n.js) for tooltip text.
|
|
31
|
+
// All are optional — guarded with typeof checks.
|
|
28
32
|
// ─────────────────────────────────────────────────────────────────────────
|
|
29
33
|
const Notify = (() => {
|
|
34
|
+
// Historical key name kept for existing installs; value now means
|
|
35
|
+
// "task alerts" (sound + desktop when permitted), not sound-only.
|
|
30
36
|
const STORAGE_KEY = "clacky-notify-sound";
|
|
31
37
|
const AUDIO_SRC = "/assets/notify.mp3";
|
|
32
38
|
|
|
@@ -41,13 +47,54 @@ const Notify = (() => {
|
|
|
41
47
|
function setEnabled(on) {
|
|
42
48
|
localStorage.setItem(STORAGE_KEY, on ? "on" : "off");
|
|
43
49
|
_updateToggleIcon();
|
|
44
|
-
// On enabling, "prime" the audio element within this user gesture so the
|
|
45
|
-
// browser's autoplay policy lets later programmatic play() calls through.
|
|
46
|
-
if (on) _prime();
|
|
47
50
|
}
|
|
48
51
|
|
|
49
|
-
function toggle() {
|
|
50
|
-
|
|
52
|
+
async function toggle() {
|
|
53
|
+
const next = !enabled();
|
|
54
|
+
if (next) {
|
|
55
|
+
// Prime audio inside the click gesture first, then request permission.
|
|
56
|
+
// Awaiting permission before priming can drop the user-gesture token.
|
|
57
|
+
_prime();
|
|
58
|
+
const perm = await _ensurePermission();
|
|
59
|
+
setEnabled(true);
|
|
60
|
+
if (perm === "denied" && typeof Modal !== "undefined" && Modal.toast) {
|
|
61
|
+
Modal.toast(
|
|
62
|
+
(typeof I18n !== "undefined" && I18n.t("notify.toast.denied")) ||
|
|
63
|
+
"Sound alerts enabled. Desktop notifications are blocked in browser settings.",
|
|
64
|
+
"warning"
|
|
65
|
+
);
|
|
66
|
+
} else if (perm === "unsupported" && typeof Modal !== "undefined" && Modal.toast) {
|
|
67
|
+
Modal.toast(
|
|
68
|
+
(typeof I18n !== "undefined" && I18n.t("notify.toast.noDesktop")) ||
|
|
69
|
+
"Sound alerts enabled. This browser has no desktop notifications.",
|
|
70
|
+
"info"
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
setEnabled(false);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Browser Notification permission ──────────────────────────────────
|
|
79
|
+
function _notificationsSupported() {
|
|
80
|
+
return typeof window !== "undefined" && "Notification" in window;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function permission() {
|
|
84
|
+
if (!_notificationsSupported()) return "unsupported";
|
|
85
|
+
return Notification.permission; // "granted" | "denied" | "default"
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function _ensurePermission() {
|
|
89
|
+
if (!_notificationsSupported()) return "unsupported";
|
|
90
|
+
if (Notification.permission === "granted") return "granted";
|
|
91
|
+
if (Notification.permission === "denied") return "denied";
|
|
92
|
+
try {
|
|
93
|
+
const result = await Notification.requestPermission();
|
|
94
|
+
return result;
|
|
95
|
+
} catch (_e) {
|
|
96
|
+
return Notification.permission;
|
|
97
|
+
}
|
|
51
98
|
}
|
|
52
99
|
|
|
53
100
|
// ── Audio ────────────────────────────────────────────────────────────
|
|
@@ -88,6 +135,46 @@ const Notify = (() => {
|
|
|
88
135
|
} catch (_e) { /* ignore */ }
|
|
89
136
|
}
|
|
90
137
|
|
|
138
|
+
// ── Desktop notification ─────────────────────────────────────────────
|
|
139
|
+
function _notifyDesktop(sid) {
|
|
140
|
+
if (!_notificationsSupported()) return;
|
|
141
|
+
if (Notification.permission !== "granted") return;
|
|
142
|
+
|
|
143
|
+
// Keep body generic on purpose — session names can be sensitive on shared
|
|
144
|
+
// screens. Click focuses the window and navigates to the session.
|
|
145
|
+
const title = typeof I18n !== "undefined"
|
|
146
|
+
? I18n.t("notify.desktop.title")
|
|
147
|
+
: "Task complete";
|
|
148
|
+
const body = typeof I18n !== "undefined"
|
|
149
|
+
? I18n.t("notify.desktop.body")
|
|
150
|
+
: "A background task finished. Click to open it.";
|
|
151
|
+
|
|
152
|
+
let n;
|
|
153
|
+
try {
|
|
154
|
+
n = new Notification(title, {
|
|
155
|
+
body,
|
|
156
|
+
tag: sid ? `clacky-task-${sid}` : "clacky-task",
|
|
157
|
+
renotify: true,
|
|
158
|
+
silent: true, // sound is handled by our own chime
|
|
159
|
+
});
|
|
160
|
+
} catch (_e) {
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
n.onclick = () => {
|
|
165
|
+
try { window.focus(); } catch (_e) { /* ignore */ }
|
|
166
|
+
if (sid && typeof Router !== "undefined" && typeof Router.navigate === "function") {
|
|
167
|
+
Router.navigate("session", { id: sid });
|
|
168
|
+
}
|
|
169
|
+
try { n.close(); } catch (_e) { /* ignore */ }
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
// Auto-dismiss so toasts don't pile up forever.
|
|
173
|
+
setTimeout(() => {
|
|
174
|
+
try { n.close(); } catch (_e) { /* ignore */ }
|
|
175
|
+
}, 8000);
|
|
176
|
+
}
|
|
177
|
+
|
|
91
178
|
// ── Trigger decision ───────────────────────────────────────────────────
|
|
92
179
|
// Returns true when the user is NOT actively viewing the given session.
|
|
93
180
|
function _userIsAway(sid) {
|
|
@@ -109,9 +196,19 @@ const Notify = (() => {
|
|
|
109
196
|
if (!enabled()) return;
|
|
110
197
|
if (!_userIsAway(sid)) return;
|
|
111
198
|
_play();
|
|
199
|
+
_notifyDesktop(sid);
|
|
112
200
|
}
|
|
113
201
|
|
|
114
202
|
// ── Toggle button UI ───────────────────────────────────────────────────
|
|
203
|
+
function _tooltipKey(on) {
|
|
204
|
+
if (!on) return "notify.tooltip.off";
|
|
205
|
+
const perm = permission();
|
|
206
|
+
if (perm === "denied") return "notify.tooltip.onDenied";
|
|
207
|
+
if (perm === "unsupported") return "notify.tooltip.onNoDesktop";
|
|
208
|
+
if (perm === "default") return "notify.tooltip.onPending";
|
|
209
|
+
return "notify.tooltip.on";
|
|
210
|
+
}
|
|
211
|
+
|
|
115
212
|
function _updateToggleIcon() {
|
|
116
213
|
const btn = document.getElementById("notify-toggle-header");
|
|
117
214
|
if (!btn) return;
|
|
@@ -130,9 +227,10 @@ const Notify = (() => {
|
|
|
130
227
|
</svg>`;
|
|
131
228
|
btn.classList.toggle("notify-on", on);
|
|
132
229
|
if (typeof I18n !== "undefined") {
|
|
133
|
-
const tip = I18n.t(on
|
|
230
|
+
const tip = I18n.t(_tooltipKey(on));
|
|
134
231
|
btn.title = tip;
|
|
135
232
|
btn.setAttribute("aria-label", tip);
|
|
233
|
+
btn.setAttribute("data-i18n-title", _tooltipKey(on));
|
|
136
234
|
}
|
|
137
235
|
}
|
|
138
236
|
|
|
@@ -140,10 +238,29 @@ const Notify = (() => {
|
|
|
140
238
|
function init() {
|
|
141
239
|
_updateToggleIcon();
|
|
142
240
|
const btn = document.getElementById("notify-toggle-header");
|
|
143
|
-
if (btn)
|
|
241
|
+
if (btn) {
|
|
242
|
+
btn.addEventListener("click", () => {
|
|
243
|
+
// Fire-and-forget; permission prompt is awaited inside toggle.
|
|
244
|
+
toggle().then(() => _updateToggleIcon()).catch(() => _updateToggleIcon());
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
// Permission may change outside the page (browser settings); refresh tip.
|
|
248
|
+
document.addEventListener("visibilitychange", () => {
|
|
249
|
+
if (!document.hidden) _updateToggleIcon();
|
|
250
|
+
});
|
|
251
|
+
if (typeof I18n !== "undefined") {
|
|
252
|
+
document.addEventListener("langchange", () => _updateToggleIcon());
|
|
253
|
+
}
|
|
144
254
|
}
|
|
145
255
|
|
|
146
|
-
return {
|
|
256
|
+
return {
|
|
257
|
+
init,
|
|
258
|
+
toggle,
|
|
259
|
+
enabled,
|
|
260
|
+
setEnabled,
|
|
261
|
+
permission,
|
|
262
|
+
onTaskFinished,
|
|
263
|
+
};
|
|
147
264
|
})();
|
|
148
265
|
|
|
149
266
|
Clacky.Notify = Notify;
|
|
@@ -42,6 +42,13 @@
|
|
|
42
42
|
if (overlay) overlay.classList.toggle("active", open && !empty);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
function isOpen() {
|
|
46
|
+
try {
|
|
47
|
+
const stored = localStorage.getItem(OPEN_KEY);
|
|
48
|
+
return stored !== null && stored !== "0";
|
|
49
|
+
} catch (_e) { return false; }
|
|
50
|
+
}
|
|
51
|
+
|
|
45
52
|
function setOpen(open) {
|
|
46
53
|
try { localStorage.setItem(OPEN_KEY, open ? "1" : "0"); } catch (_e) { /* ignore */ }
|
|
47
54
|
applyOpenState();
|
|
@@ -91,7 +98,21 @@
|
|
|
91
98
|
});
|
|
92
99
|
}
|
|
93
100
|
|
|
101
|
+
// ── Public API ────────────────────────────────────────────────────────
|
|
102
|
+
// Expose setOpen so external code (e.g. WS dispatcher) can open/close the
|
|
103
|
+
// aside panel programmatically without duplicating localStorage logic here.
|
|
104
|
+
// open() is a no-op when the panel is already open — safe to call unconditionally.
|
|
105
|
+
if (window.Clacky) Clacky.Aside = {
|
|
106
|
+
open: () => { if (!isOpen()) setOpen(true); },
|
|
107
|
+
close: () => setOpen(false),
|
|
108
|
+
};
|
|
109
|
+
|
|
94
110
|
function init() {
|
|
111
|
+
// Re-assign in case Clacky was not yet defined when the IIFE ran.
|
|
112
|
+
if (window.Clacky) Clacky.Aside = {
|
|
113
|
+
open: () => { if (!isOpen()) setOpen(true); },
|
|
114
|
+
close: () => setOpen(false),
|
|
115
|
+
};
|
|
95
116
|
const collapse = $("btn-aside-collapse");
|
|
96
117
|
const opener = $("btn-aside-open");
|
|
97
118
|
const overlay = $("workspace-overlay");
|
|
@@ -138,7 +138,8 @@ const BillingView = (() => {
|
|
|
138
138
|
<div class="billing-period-group">${periodBtns}</div>
|
|
139
139
|
<select id="billing-model-filter" class="billing-model-filter">${modelOptions}</select>
|
|
140
140
|
<button id="billing-share-btn" class="billing-share-btn" title="${I18n.t('billing.share.tooltip') || 'Share scorecard'}">
|
|
141
|
-
|
|
141
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><polyline points="16 6 12 2 8 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><line x1="12" y1="2" x2="12" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
|
142
|
+
${I18n.t('billing.share.btn') || 'Share scorecard'}
|
|
142
143
|
</button>
|
|
143
144
|
<div class="billing-clear-container">
|
|
144
145
|
<button id="billing-clear-btn" class="billing-clear-btn" title="${I18n.t('billing.clearData') || 'Clear Data'}">
|
|
@@ -76,7 +76,7 @@ const BrandStore = (() => {
|
|
|
76
76
|
return false;
|
|
77
77
|
},
|
|
78
78
|
|
|
79
|
-
// Re-fetch status
|
|
79
|
+
// Re-fetch status and notify views without driving the main boot sequence.
|
|
80
80
|
async refresh() {
|
|
81
81
|
try {
|
|
82
82
|
const res = await fetch("/api/brand/status");
|
|
@@ -84,8 +84,10 @@ const BrandStore = (() => {
|
|
|
84
84
|
_testMode = !!data.test_mode;
|
|
85
85
|
_userLicensed = !!data.user_licensed;
|
|
86
86
|
_branded = !!data.branded;
|
|
87
|
+
_emit("brand:status", data);
|
|
87
88
|
return data;
|
|
88
89
|
} catch (_) {
|
|
90
|
+
_emit("brand:status", null);
|
|
89
91
|
return null;
|
|
90
92
|
}
|
|
91
93
|
},
|
|
@@ -15,7 +15,22 @@
|
|
|
15
15
|
const BrandView = (() => {
|
|
16
16
|
|
|
17
17
|
function _onStatus(data) {
|
|
18
|
-
if (!data
|
|
18
|
+
if (!data) return;
|
|
19
|
+
if (!data.branded) {
|
|
20
|
+
if (data.distribution_refresh_pending) {
|
|
21
|
+
_scheduleDistributionRefreshPoll();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
_applyBrandName("OpenClacky");
|
|
25
|
+
Brand.clearBrandCache();
|
|
26
|
+
_applyHeaderLogo();
|
|
27
|
+
const banner = document.getElementById("brand-activation-banner");
|
|
28
|
+
if (banner) banner.remove();
|
|
29
|
+
if (typeof Settings !== "undefined" && Settings.loadBrand) Settings.loadBrand();
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (data.product_name) _applyBrandName(data.product_name);
|
|
19
34
|
|
|
20
35
|
if (data.needs_activation) {
|
|
21
36
|
if (data.theme_color) Settings.applyAccentColor(data.theme_color, { persist: true });
|
|
@@ -268,6 +283,7 @@ const BrandView = (() => {
|
|
|
268
283
|
logoImg.alt = "OpenClacky";
|
|
269
284
|
logoImg.style.display = "";
|
|
270
285
|
if (brandWrap) brandWrap.classList.add("has-logo");
|
|
286
|
+
_applyFavicon("/favicon.svg");
|
|
271
287
|
}
|
|
272
288
|
|
|
273
289
|
function _applyFavicon(url) {
|
|
@@ -333,11 +349,19 @@ const BrandView = (() => {
|
|
|
333
349
|
let attempt = 0;
|
|
334
350
|
|
|
335
351
|
const poll = () => {
|
|
336
|
-
Brand.
|
|
337
|
-
|
|
338
|
-
|
|
352
|
+
Brand.refresh().then(status => {
|
|
353
|
+
Brand.clearBrandCache();
|
|
354
|
+
return Brand.fetchInfo().then(info => ({ status, info }));
|
|
355
|
+
}).then(({ status, info }) => {
|
|
356
|
+
if (info && info.product_name) _applyBrandName(info.product_name);
|
|
339
357
|
_applyHeaderLogo();
|
|
340
|
-
|
|
358
|
+
const refreshSettled = !!(
|
|
359
|
+
status && status.branded && !status.distribution_refresh_pending
|
|
360
|
+
);
|
|
361
|
+
if (refreshSettled && typeof Settings !== "undefined" && Settings.loadBrand) {
|
|
362
|
+
Settings.loadBrand();
|
|
363
|
+
}
|
|
364
|
+
if (refreshSettled || attempt >= delays.length) {
|
|
341
365
|
_distRefreshPolling = false;
|
|
342
366
|
return;
|
|
343
367
|
}
|
|
@@ -178,14 +178,15 @@ const ExtensionsStore = (() => {
|
|
|
178
178
|
},
|
|
179
179
|
|
|
180
180
|
/** Open the detail view for one extension (fetches contributes + versions). */
|
|
181
|
-
async loadDetail(id) {
|
|
181
|
+
async loadDetail(id, source) {
|
|
182
182
|
if (!id) return;
|
|
183
183
|
_detail = null;
|
|
184
184
|
_detailLoading = true;
|
|
185
185
|
_detailError = null;
|
|
186
186
|
_emit("extensions:detail");
|
|
187
187
|
try {
|
|
188
|
-
const
|
|
188
|
+
const isBrand = source === "brand" || _filterBrand;
|
|
189
|
+
const url = isBrand
|
|
189
190
|
? "/api/store/extension?id=" + encodeURIComponent(id) + "&source=brand"
|
|
190
191
|
: "/api/store/extension?id=" + encodeURIComponent(id);
|
|
191
192
|
const res = await fetch(url);
|
|
@@ -250,10 +251,18 @@ const ExtensionsStore = (() => {
|
|
|
250
251
|
async install(id) {
|
|
251
252
|
if (!id) return;
|
|
252
253
|
try {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
254
|
+
// Prefer the already-loaded detail (avoids a second round-trip and correctly
|
|
255
|
+
// handles brand-private extensions whose detail was fetched with &source=brand).
|
|
256
|
+
let ext;
|
|
257
|
+
if (_detail && String(_detail.id) === String(id)) {
|
|
258
|
+
ext = _detail;
|
|
259
|
+
} else {
|
|
260
|
+
const source = _filterBrand ? "&source=brand" : "";
|
|
261
|
+
const detailRes = await fetch("/api/store/extension?id=" + encodeURIComponent(id) + source);
|
|
262
|
+
const detailData = await detailRes.json();
|
|
263
|
+
if (!detailRes.ok || !detailData.ok) throw new Error(detailData.error || "fetch detail failed");
|
|
264
|
+
ext = detailData.extension;
|
|
265
|
+
}
|
|
257
266
|
const download_url = ext.download_url;
|
|
258
267
|
if (!download_url) throw new Error("No download URL available");
|
|
259
268
|
const res = await fetch("/api/store/extension/install", {
|
|
@@ -121,7 +121,8 @@ const ExtensionsView = (() => {
|
|
|
121
121
|
|
|
122
122
|
const card = document.createElement("div");
|
|
123
123
|
card.className = "extension-card extension-card-clickable";
|
|
124
|
-
card.dataset.extId
|
|
124
|
+
card.dataset.extId = ext.id != null ? String(ext.id) : (ext.name || "");
|
|
125
|
+
card.dataset.extOrigin = ext.origin || "";
|
|
125
126
|
card.innerHTML = `
|
|
126
127
|
<div class="extension-card-main">
|
|
127
128
|
${emojiHtml}
|
|
@@ -474,8 +475,11 @@ const ExtensionsView = (() => {
|
|
|
474
475
|
const card = e.target.closest(".extension-card-clickable");
|
|
475
476
|
if (card && card.dataset.extId) {
|
|
476
477
|
const router = window.Clacky && window.Clacky.Router;
|
|
477
|
-
|
|
478
|
-
|
|
478
|
+
// Use origin stored on the card; origin="self" means brand-private extension.
|
|
479
|
+
const isBrand = card.dataset.extOrigin === "self" || Extensions.state.filterBrand;
|
|
480
|
+
const source = isBrand ? "brand" : null;
|
|
481
|
+
if (router) router.navigate("extensions", { detailId: card.dataset.extId, source });
|
|
482
|
+
else Extensions.loadDetail(card.dataset.extId, source);
|
|
479
483
|
}
|
|
480
484
|
});
|
|
481
485
|
}
|
|
@@ -506,7 +510,7 @@ const ExtensionsView = (() => {
|
|
|
506
510
|
const detailId = opts && opts.detailId;
|
|
507
511
|
if (detailId) {
|
|
508
512
|
Extensions.load();
|
|
509
|
-
Extensions.loadDetail(detailId);
|
|
513
|
+
Extensions.loadDetail(detailId, opts && opts.source);
|
|
510
514
|
} else {
|
|
511
515
|
Extensions.closeDetail();
|
|
512
516
|
Extensions.load();
|
|
@@ -306,7 +306,7 @@ const NewSessionView = (() => {
|
|
|
306
306
|
const MAX_IMAGE_SIZE = 5 * 1024 * 1024;
|
|
307
307
|
const MAX_IMAGE_BYTES_SEND = 512 * 1024;
|
|
308
308
|
const MAX_IMAGE_LONG_EDGE = 1920;
|
|
309
|
-
const MAX_FILE_BYTES =
|
|
309
|
+
const MAX_FILE_BYTES = 300 * 1024 * 1024;
|
|
310
310
|
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"];
|
|
311
311
|
|
|
312
312
|
function _docTypeIcon(mimeType, filename) {
|
|
@@ -390,7 +390,7 @@ const NewSessionView = (() => {
|
|
|
390
390
|
|
|
391
391
|
function _addGenericFile(file) {
|
|
392
392
|
if (file.size > MAX_FILE_BYTES) {
|
|
393
|
-
alert(I18n.t("chat.file.tooLarge", { name: file.name, max: "
|
|
393
|
+
alert(I18n.t("chat.file.tooLarge", { name: file.name, max: "300 MB" }));
|
|
394
394
|
return;
|
|
395
395
|
}
|
|
396
396
|
const formData = new FormData();
|