openclacky 1.2.13 → 1.2.15

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.
@@ -44,6 +44,7 @@
44
44
  </div>
45
45
  </div>
46
46
  <div id="header-right">
47
+ <button id="notify-toggle-header" class="theme-toggle-btn" data-i18n-title="notify.tooltip.off" title="Sound on task complete"></button>
47
48
  <button id="theme-toggle-header" class="theme-toggle-btn" title="Toggle theme">
48
49
  <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">
49
50
  <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>
@@ -869,6 +870,25 @@
869
870
  </div>
870
871
  </section>
871
872
 
873
+ <!-- Network / Proxy section -->
874
+ <section class="settings-section" id="network-section">
875
+ <div class="settings-section-title">
876
+ <span data-i18n="settings.network.title">Network</span>
877
+ </div>
878
+ <div class="settings-network">
879
+ <p class="settings-network-desc" data-i18n="settings.network.desc">Clacky always ignores HTTP_PROXY / HTTPS_PROXY from your shell. To route Clacky's outbound traffic through a proxy, set an explicit URL below.</p>
880
+ <div class="settings-network-url">
881
+ <label class="settings-network-url-label" for="settings-proxy-url" data-i18n="settings.network.proxyUrl">Proxy URL</label>
882
+ <div class="settings-network-url-row">
883
+ <input type="text" id="settings-proxy-url" class="field-input" placeholder="http://user:pass@host:port" autocomplete="off" spellcheck="false">
884
+ <button type="button" id="btn-save-proxy-url" class="btn-settings-action" data-i18n="settings.network.save">Save</button>
885
+ <button type="button" id="btn-clear-proxy-url" class="btn-settings-action" data-i18n="settings.network.clear">Clear</button>
886
+ </div>
887
+ <div id="settings-proxy-url-status" class="model-test-result"></div>
888
+ </div>
889
+ </div>
890
+ </section>
891
+
872
892
  <!-- Brand & License section -->
873
893
  <section class="settings-section" id="brand-license-section">
874
894
  <div class="settings-section-title">
@@ -1300,6 +1320,7 @@
1300
1320
  <script src="/i18n.js"></script>
1301
1321
  <script src="/auth.js"></script>
1302
1322
  <script src="/theme.js"></script>
1323
+ <script src="/notify.js"></script>
1303
1324
  <script src="/ws.js"></script>
1304
1325
  <script src="/ws-dispatcher.js"></script>
1305
1326
  <script src="/sessions.js"></script>
@@ -0,0 +1,154 @@
1
+ // notify.js — Task-complete sound notification module
2
+ //
3
+ // Plays a short sound when an agent task finishes, driven by the global
4
+ // `task_finished` event the server broadcasts (broadcast_all) the moment a
5
+ // task completes. We listen to this dedicated signal — rather than `complete`
6
+ // (only delivered to subscribers of that session) — so a background session
7
+ // finishing still reaches every browser. Whether a task *finished* is decided
8
+ // on the backend; this module only decides whether the user is looking.
9
+ //
10
+ // The chime only fires when the user is NOT actively looking at the
11
+ // finished session. "Not looking" means ANY of:
12
+ // 1. The finished session is not the currently open one
13
+ // (sid !== Sessions.activeId)
14
+ // 2. The browser window has lost focus (!document.hasFocus())
15
+ // 3. The tab is hidden / minimised / behind another tab (document.hidden)
16
+ //
17
+ // If the user is focused on the very session that just finished, we stay
18
+ // silent — they can already see the result.
19
+ //
20
+ // The feature is gated behind a header toggle (🔔/🔕) next to the theme
21
+ // switcher. Default OFF; the choice is persisted to localStorage.
22
+ //
23
+ // No history replay: a chime is a live cue, never re-fired on page refresh.
24
+ // The audio file is served as a static asset (/notify.mp3) by WEBrick.
25
+ //
26
+ // Depends on: Sessions (sessions.js) for activeId, I18n (i18n.js) for the
27
+ // tooltip text. Both are optional — guarded with typeof checks.
28
+ // ─────────────────────────────────────────────────────────────────────────
29
+ const Notify = (() => {
30
+ const STORAGE_KEY = "clacky-notify-sound";
31
+ const AUDIO_SRC = "/notify.mp3";
32
+
33
+ let _audio = null;
34
+
35
+ // ── State ────────────────────────────────────────────────────────────
36
+ // Default OFF: only enabled when localStorage explicitly says "on".
37
+ function enabled() {
38
+ return localStorage.getItem(STORAGE_KEY) === "on";
39
+ }
40
+
41
+ function setEnabled(on) {
42
+ localStorage.setItem(STORAGE_KEY, on ? "on" : "off");
43
+ _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
+ }
48
+
49
+ function toggle() {
50
+ setEnabled(!enabled());
51
+ }
52
+
53
+ // ── Audio ────────────────────────────────────────────────────────────
54
+ function _ensureAudio() {
55
+ if (!_audio) {
56
+ _audio = new Audio(AUDIO_SRC);
57
+ _audio.preload = "auto";
58
+ }
59
+ return _audio;
60
+ }
61
+
62
+ // Play+pause+reset muted once, triggered by the toggle click (a user
63
+ // gesture), to satisfy autoplay policies for subsequent unmuted plays.
64
+ function _prime() {
65
+ const a = _ensureAudio();
66
+ const prevMuted = a.muted;
67
+ a.muted = true;
68
+ const p = a.play();
69
+ if (p && typeof p.then === "function") {
70
+ p.then(() => {
71
+ a.pause();
72
+ a.currentTime = 0;
73
+ a.muted = prevMuted;
74
+ }).catch(() => { a.muted = prevMuted; });
75
+ } else {
76
+ a.muted = prevMuted;
77
+ }
78
+ }
79
+
80
+ function _play() {
81
+ const a = _ensureAudio();
82
+ try {
83
+ a.currentTime = 0;
84
+ const p = a.play();
85
+ // Swallow autoplay-policy rejections silently — better to miss a
86
+ // chime than to throw an unhandled promise rejection.
87
+ if (p && typeof p.catch === "function") p.catch(() => {});
88
+ } catch (_e) { /* ignore */ }
89
+ }
90
+
91
+ // ── Trigger decision ───────────────────────────────────────────────────
92
+ // Returns true when the user is NOT actively viewing the given session.
93
+ function _userIsAway(sid) {
94
+ // 1. Finished session is not the one currently open.
95
+ const activeId = (typeof Sessions !== "undefined") ? Sessions.activeId : null;
96
+ if (sid && sid !== activeId) return true;
97
+ // 2. Browser window is not focused (e.g. another app / window on top).
98
+ if (typeof document.hasFocus === "function" && !document.hasFocus()) return true;
99
+ // 3. Tab is hidden (switched to another tab, or window minimised).
100
+ if (document.hidden) return true;
101
+ return false;
102
+ }
103
+
104
+ // Called from ws-dispatcher on the `task_finished` event — a transient global
105
+ // signal the server broadcasts to every client the moment an agent task
106
+ // completes. We only decide whether the user is looking at that session;
107
+ // the "did a task just finish" judgement lives on the backend.
108
+ function onTaskFinished(sid) {
109
+ if (!enabled()) return;
110
+ if (!_userIsAway(sid)) return;
111
+ _play();
112
+ }
113
+
114
+ // ── Toggle button UI ───────────────────────────────────────────────────
115
+ function _updateToggleIcon() {
116
+ const btn = document.getElementById("notify-toggle-header");
117
+ if (!btn) return;
118
+ const on = enabled();
119
+ // Bell when ON, bell-off (muted) when OFF.
120
+ btn.innerHTML = on
121
+ ? `<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">
122
+ <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/>
123
+ <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>
124
+ </svg>`
125
+ : `<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">
126
+ <path d="M8.7 3A6 6 0 0 1 18 8c0 1.5.2 2.8.5 3.9"/>
127
+ <path d="M17 17H3s3-2 3-9a4.67 4.67 0 0 1 .3-1.7"/>
128
+ <path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/>
129
+ <line x1="2" y1="2" x2="22" y2="22"/>
130
+ </svg>`;
131
+ btn.classList.toggle("notify-on", on);
132
+ if (typeof I18n !== "undefined") {
133
+ const tip = I18n.t(on ? "notify.tooltip.on" : "notify.tooltip.off");
134
+ btn.title = tip;
135
+ btn.setAttribute("aria-label", tip);
136
+ }
137
+ }
138
+
139
+ // ── Init ─────────────────────────────────────────────────────────────
140
+ function init() {
141
+ _updateToggleIcon();
142
+ const btn = document.getElementById("notify-toggle-header");
143
+ if (btn) btn.addEventListener("click", toggle);
144
+ }
145
+
146
+ return { init, toggle, enabled, setEnabled, onTaskFinished };
147
+ })();
148
+
149
+ // Initialize on load (button binding + initial icon state).
150
+ if (document.readyState === "loading") {
151
+ document.addEventListener("DOMContentLoaded", () => Notify.init());
152
+ } else {
153
+ Notify.init();
154
+ }
Binary file
@@ -19,6 +19,7 @@ const Settings = (() => {
19
19
  _loadMedia();
20
20
  _loadBrand();
21
21
  _loadBrowserStatus();
22
+ _initNetworkSettings();
22
23
  _applyAboutTabVisibility();
23
24
  }
24
25
 
@@ -1200,6 +1201,62 @@ const Settings = (() => {
1200
1201
  }
1201
1202
  }
1202
1203
 
1204
+ // ── Network / Proxy ───────────────────────────────────────────────────────────
1205
+
1206
+ async function _initNetworkSettings() {
1207
+ const urlInput = document.getElementById("settings-proxy-url");
1208
+ const saveBtn = document.getElementById("btn-save-proxy-url");
1209
+ const clearBtn = document.getElementById("btn-clear-proxy-url");
1210
+ const status = document.getElementById("settings-proxy-url-status");
1211
+ if (!urlInput || !saveBtn) return;
1212
+
1213
+ try {
1214
+ const res = await fetch("/api/config/settings");
1215
+ const data = await res.json();
1216
+ if (data.ok) {
1217
+ urlInput.value = data.proxy_url || "";
1218
+ }
1219
+ } catch (_) { /* non-critical */ }
1220
+
1221
+ async function _patchProxyUrl(value, successKey) {
1222
+ status.textContent = "";
1223
+ status.className = "model-test-result";
1224
+ try {
1225
+ const res = await fetch("/api/config/settings", {
1226
+ method: "PATCH",
1227
+ headers: { "Content-Type": "application/json" },
1228
+ body: JSON.stringify({ proxy_url: value })
1229
+ });
1230
+ const data = await res.json();
1231
+ if (data.ok) {
1232
+ status.textContent = I18n.t(successKey);
1233
+ status.className = "model-test-result success";
1234
+ } else {
1235
+ status.textContent = data.error || I18n.t("settings.network.invalidUrl");
1236
+ status.className = "model-test-result error";
1237
+ }
1238
+ } catch (e) {
1239
+ status.textContent = e.message || I18n.t("settings.network.invalidUrl");
1240
+ status.className = "model-test-result error";
1241
+ }
1242
+ }
1243
+
1244
+ if (!saveBtn.dataset.bound) {
1245
+ saveBtn.dataset.bound = "1";
1246
+ saveBtn.addEventListener("click", () => {
1247
+ _patchProxyUrl(urlInput.value.trim(), "settings.network.saved");
1248
+ });
1249
+ }
1250
+
1251
+ if (clearBtn && !clearBtn.dataset.bound) {
1252
+ clearBtn.dataset.bound = "1";
1253
+ clearBtn.addEventListener("click", () => {
1254
+ urlInput.value = "";
1255
+ _patchProxyUrl("", "settings.network.cleared");
1256
+ });
1257
+ }
1258
+ }
1259
+
1203
1260
  // ── Brand & License ───────────────────────────────────────────────────────────
1204
1261
 
1205
1262
  // Whether the server was started with --brand-test (relaxed key validation).
@@ -1528,7 +1585,7 @@ const Settings = (() => {
1528
1585
  // The state object per kind:
1529
1586
  // { source, configured, model, base_url, api_key_masked, provider, available }
1530
1587
 
1531
- const MEDIA_KINDS = ["image", "video", "audio"];
1588
+ const MEDIA_KINDS = ["image", "video", "audio", "ocr"];
1532
1589
  let _mediaState = null;
1533
1590
  let _mediaDefaults = null;
1534
1591
  const _mediaCustomDraft = {};
@@ -1538,10 +1595,16 @@ const Settings = (() => {
1538
1595
  if (!container) return;
1539
1596
  container.innerHTML = `<div class="settings-loading">${I18n.t("settings.media.loading")}</div>`;
1540
1597
  try {
1541
- const res = await fetch("/api/config/media");
1542
- const data = await res.json();
1543
- _mediaState = data.media || {};
1544
- _mediaDefaults = data.default_provider || {};
1598
+ const [mediaRes, ocrRes] = await Promise.all([
1599
+ fetch("/api/config/media"),
1600
+ fetch("/api/config/ocr")
1601
+ ]);
1602
+ const mediaData = await mediaRes.json();
1603
+ const ocrData = await ocrRes.json();
1604
+ _mediaState = mediaData.media || {};
1605
+ _mediaDefaults = mediaData.default_provider || {};
1606
+ _mediaState["ocr"] = ocrData.ocr || { source: "off", available: [] };
1607
+ _mediaDefaults["ocr"] = ocrData.default_provider || { available: [] };
1545
1608
  _renderMediaRows();
1546
1609
  } catch (e) {
1547
1610
  container.innerHTML = `<div class="settings-error">${I18n.t("settings.media.error", { msg: e.message })}</div>`;
@@ -1557,6 +1620,14 @@ const Settings = (() => {
1557
1620
  });
1558
1621
  }
1559
1622
 
1623
+ function _refreshKindRows(_kind) {
1624
+ _renderMediaRows();
1625
+ }
1626
+
1627
+ async function _reloadKind(_kind) {
1628
+ await _loadMedia();
1629
+ }
1630
+
1560
1631
  function _renderMediaRow(kind) {
1561
1632
  const state = (_mediaState && _mediaState[kind]) || { source: "off", available: [] };
1562
1633
  const def = (_mediaDefaults && _mediaDefaults[kind]) || { available: [] };
@@ -1662,7 +1733,7 @@ const Settings = (() => {
1662
1733
  _setMediaResult(kind, "testing", I18n.t("settings.media.action.saving"));
1663
1734
  try {
1664
1735
  await _saveMediaConfig(kind, payload);
1665
- await _loadMedia();
1736
+ await _reloadKind(kind);
1666
1737
  } catch (e) {
1667
1738
  sel.disabled = false;
1668
1739
  _setMediaResult(kind, "fail", e.message);
@@ -1742,7 +1813,7 @@ const Settings = (() => {
1742
1813
  base_url: state.base_url || "",
1743
1814
  api_key: ""
1744
1815
  };
1745
- _renderMediaRows();
1816
+ _refreshKindRows(kind);
1746
1817
  });
1747
1818
 
1748
1819
  const testBtn = document.createElement("button");
@@ -1818,7 +1889,7 @@ const Settings = (() => {
1818
1889
  const fallback = (_mediaDefaults && _mediaDefaults[kind] && _mediaDefaults[kind].model) ? "auto" : "off";
1819
1890
  _mediaState[kind] = { ..._mediaState[kind], source: fallback };
1820
1891
  }
1821
- _renderMediaRows();
1892
+ _refreshKindRows(kind);
1822
1893
  });
1823
1894
 
1824
1895
  const saveBtn = document.createElement("button");
@@ -1838,7 +1909,7 @@ const Settings = (() => {
1838
1909
  api_key: d.api_key || ""
1839
1910
  });
1840
1911
  delete _mediaCustomDraft[kind];
1841
- await _loadMedia();
1912
+ await _reloadKind(kind);
1842
1913
  } catch (e) {
1843
1914
  saveBtn.disabled = false;
1844
1915
  cancelBtn.disabled = false;
@@ -1906,7 +1977,8 @@ const Settings = (() => {
1906
1977
  }
1907
1978
 
1908
1979
  async function _saveMediaConfig(kind, body) {
1909
- const res = await fetch(`/api/config/media/${kind}`, {
1980
+ const url = kind === "ocr" ? `/api/config/ocr` : `/api/config/media/${kind}`;
1981
+ const res = await fetch(url, {
1910
1982
  method: "PATCH",
1911
1983
  headers: { "Content-Type": "application/json" },
1912
1984
  body: JSON.stringify(body)
@@ -1920,10 +1992,14 @@ const Settings = (() => {
1920
1992
 
1921
1993
  async function _testMediaConfig(kind, { model, base_url, api_key }) {
1922
1994
  try {
1923
- const res = await fetch(`/api/config/media/test`, {
1995
+ const url = kind === "ocr" ? `/api/config/ocr/test` : `/api/config/media/test`;
1996
+ const payload = kind === "ocr"
1997
+ ? { model, base_url, api_key }
1998
+ : { kind, model, base_url, api_key };
1999
+ const res = await fetch(url, {
1924
2000
  method: "POST",
1925
2001
  headers: { "Content-Type": "application/json" },
1926
- body: JSON.stringify({ kind, model, base_url, api_key })
2002
+ body: JSON.stringify(payload)
1927
2003
  });
1928
2004
  const data = await res.json().catch(() => ({}));
1929
2005
  if (!res.ok) return { ok: false, message: data.error || `HTTP ${res.status}` };
@@ -265,6 +265,14 @@ WS.onEvent(ev => {
265
265
  break;
266
266
  }
267
267
 
268
+ // Transient global signal emitted the moment any agent task finishes
269
+ // (broadcast to every client, not just session subscribers). Used only
270
+ // to play the optional completion chime; the toggle gates it and the
271
+ // module decides whether the user is looking at that session.
272
+ case "task_finished":
273
+ if (typeof Notify !== "undefined") Notify.onTaskFinished(ev.session_id);
274
+ break;
275
+
268
276
  case "session_renamed": {
269
277
  Sessions.patch(ev.session_id, { name: ev.name });
270
278
  Sessions.renderList();
data/lib/clacky.rb CHANGED
@@ -92,6 +92,7 @@ require_relative "clacky/ui2/progress_indicator"
92
92
 
93
93
  # Utils
94
94
  require_relative "clacky/utils/logger"
95
+ require_relative "clacky/proxy_config"
95
96
  require_relative "clacky/platform_http_client"
96
97
  require_relative "clacky/utils/encoding"
97
98
  require_relative "clacky/utils/environment_detector"
@@ -128,6 +129,7 @@ require_relative "clacky/mcp/skill_provider"
128
129
  require_relative "clacky/media/base"
129
130
  require_relative "clacky/media/openai_compat"
130
131
  require_relative "clacky/media/generator"
132
+ require_relative "clacky/vision/resolver"
131
133
  require_relative "clacky/telemetry"
132
134
  require_relative "clacky/agent"
133
135
 
@@ -164,3 +166,5 @@ module Clacky
164
166
  class BrowserNotReachableError < AgentError; end # Chrome/Edge not running or remote debugging disabled
165
167
  # BrowserManager singleton: Clacky::BrowserManager.instance
166
168
  end
169
+
170
+ Clacky::ProxyConfig.install!
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.13
4
+ version: 1.2.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-06-08 00:00:00.000000000 Z
11
+ date: 2026-06-09 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -359,6 +359,7 @@ files:
359
359
  - lib/clacky/default_parsers/pdf_parser.rb
360
360
  - lib/clacky/default_parsers/pdf_parser_ocr.py
361
361
  - lib/clacky/default_parsers/pdf_parser_plumber.py
362
+ - lib/clacky/default_parsers/pdf_parser_vlm.py
362
363
  - lib/clacky/default_parsers/pptx_parser.rb
363
364
  - lib/clacky/default_parsers/wps_parser.rb
364
365
  - lib/clacky/default_parsers/xlsx_parser.rb
@@ -428,6 +429,7 @@ files:
428
429
  - lib/clacky/plain_ui_controller.rb
429
430
  - lib/clacky/platform_http_client.rb
430
431
  - lib/clacky/providers.rb
432
+ - lib/clacky/proxy_config.rb
431
433
  - lib/clacky/rich_ui_controller.rb
432
434
  - lib/clacky/server/browser_manager.rb
433
435
  - lib/clacky/server/channel.rb
@@ -532,6 +534,7 @@ files:
532
534
  - lib/clacky/utils/trash_directory.rb
533
535
  - lib/clacky/utils/workspace_rules.rb
534
536
  - lib/clacky/version.rb
537
+ - lib/clacky/vision/resolver.rb
535
538
  - lib/clacky/web/app.css
536
539
  - lib/clacky/web/app.js
537
540
  - lib/clacky/web/apple-touch-icon-180.png
@@ -550,6 +553,8 @@ files:
550
553
  - lib/clacky/web/marked.min.js
551
554
  - lib/clacky/web/mcp.js
552
555
  - lib/clacky/web/model-tester.js
556
+ - lib/clacky/web/notify.js
557
+ - lib/clacky/web/notify.mp3
553
558
  - lib/clacky/web/onboard.js
554
559
  - lib/clacky/web/profile.js
555
560
  - lib/clacky/web/sessions.js